Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docker/agent/daemon/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@ describe("fetchRetry — per-request timeout (no infinite wedge)", () => {
});

describe("DAEMON_VERSION", () => {
it("is bumped to 0.41.0: multi-repo goals (S3b) — resolveEngineRepo picks the clone by the claim's bound connectionId + pm_spawn_tasks gains a `repo` field", () => {
expect(DAEMON_VERSION).toBe("0.41.0");
it("is bumped to 0.42.0: classify a Claude session/usage-limit exhaustion — release-not-fail via the `claude_limited` report outcome + report `claudeRateLimitedUntil` on register/heartbeat", () => {
expect(DAEMON_VERSION).toBe("0.42.0");
});
});

Expand Down
30 changes: 27 additions & 3 deletions docker/agent/daemon/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,18 @@ export function readOrCreateFingerprint(
// CLOAK_KEY, JWT keypair) fresh per run so the operator isn't asked for synthesizable values.
// The floor to require before inference starts EMITTING these recipes (else an older daemon
// throws on the unknown provider).
export const DAEMON_VERSION = "0.41.0";
// 0.42.0: classify a Claude SESSION/USAGE-limit exhaustion separately from a coding failure (the
// daemon half of G, mirror of the GitHub rate-limit pause in 0.39.0). When a run's `claude`
// child stops on a 429 + "hit your session limit · resets 3am (UTC)", the daemon (a) reports
// the NEW no-penalty `claude_limited` report outcome carrying `claudeRateLimitedUntil` (the
// reset instant) so the server requeues the task instead of pausing it as a run_failure, and
// short-circuits the in-place retry so it doesn't burn attempts against an exhausted account;
// (b) remembers the reset across ticks and PAUSES claims until it passes; and (c) reports the
// new `AuthStatus.claudeRateLimitedUntil` field on register + heartbeat so the machine card +
// fleet sidebar render the honest "usage limit, resolves automatically" reason. Additive wire
// field + new report outcome (both platform-side, from the paired server task) — bump so a
// floor can require the image that releases-not-fails on a Claude usage limit.
export const DAEMON_VERSION = "0.42.0";

/** Agent login readiness reported to the platform on register + heartbeat. */
export interface AuthStatus {
Expand All @@ -293,6 +304,10 @@ export interface AuthStatus {
/** ISO 8601 quota-reset time when the GitHub credential is valid but its API
* quota is exhausted; null/absent when not throttled. */
ghRateLimitedUntil?: string | null;
/** ISO 8601 reset time when the ACCOUNT's Claude session/usage limit is exhausted
* (0.42.0). Unlike `ghRateLimitedUntil` (probed each tick), this is REMEMBERED from
* the run that discovered the limit; null/absent when not limited. */
claudeRateLimitedUntil?: string | null;
}

/**
Expand Down Expand Up @@ -427,9 +442,13 @@ export interface EngineRunUsage {
* - `discussion_reply` (S2b): an interactive `discussion` run posted ONE assistant
* turn (via the discussion-turn endpoint) and is waiting on the human's next
* message. The server parks the task on its `discussion_reply` turn-parking pause;
* the human's reply (S2a endpoint) re-queues it for the next turn. */
* the human's reply (S2a endpoint) re-queues it for the next turn.
* - `claude_limited` (0.42.0): the run stopped because the ACCOUNT's Claude session/usage
* limit is exhausted (429 + "hit your session limit"). NOT a failure of the task's work —
* the server requeues the phase with NO penalty (no transient-retry bump, no run_failure
* pause), holding it out of the claim pool until `claudeRateLimitedUntil`. */
export interface EngineReport {
outcome: "succeeded" | "failed" | "retryable" | "clarification" | "discussion_reply";
outcome: "succeeded" | "failed" | "retryable" | "clarification" | "discussion_reply" | "claude_limited";
observations?: unknown;
reviewRejected?: boolean;
error?: string;
Expand All @@ -442,6 +461,11 @@ export interface EngineReport {
failureClass?: string;
/** Bounded transient reason code on a `retryable` outcome (see `RetryReason`). */
reason?: string;
/** 0.42.0 — on a `claude_limited` outcome: the ISO 8601 instant the account's Claude
* session/usage limit resets. The server sets the requeued phase's `retryNotBefore` from
* it (so it isn't instantly re-claimed) and stamps it on the run's Machine. Best-effort:
* the daemon always sends a value (a parsed reset, else a conservative default window). */
claudeRateLimitedUntil?: string;
/** Gap C — on a `clarification` outcome: the question the run paused to ask, and its
* 2–4 options. The server raises a clarification pause an operator answers. */
question?: string;
Expand Down
81 changes: 75 additions & 6 deletions docker/agent/daemon/container-loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ import {
resolveAndProvisionForClaim,
pruneDisabledRepos,
resetDisabledRepos,
assembleEngineReport,
claudeLimitIsoForHeartbeat,
__setClaudeRateLimitedUntil,
type TerminalRunState,
} from "./container-loop";
import { provisionRepoForClaim } from "./repos";
import { ATTACHMENT_INFRA_FAILURE_CLASS, type StageResult } from "./goal-attachments";
Expand All @@ -55,6 +59,7 @@ import type {
ConnectedRepo,
LoadedRepoReport,
EngineClaim,
EngineReport,
} from "./api";

/** The git subcommand arrays passed to execFileSync (dropping the leading `-C <dir>`). */
Expand Down Expand Up @@ -975,12 +980,76 @@ describe("attachment-creds preflight invariant (#904: skip the run, never wedge
});

it("reports the preflight failure with highest precedence in the outcome cascade", () => {
const reportDecl = src.indexOf("let report: EngineReport;");
const preflightBranch = src.indexOf("report = attachmentPreflight;");
const clarificationBranch = src.indexOf('report = { outcome: "clarification"');
expect(preflightBranch).toBeGreaterThan(reportDecl);
// The attachment-creds run_failure outranks clarification/discussion/success.
expect(preflightBranch).toBeLessThan(clarificationBranch);
// The attachment-creds run_failure outranks clarification/discussion/success — proven
// directly against the extracted pure decision (assembleEngineReport).
const preflight: EngineReport = { outcome: "failed", error: "attachment_unreadable", failureClass: "infra" };
const report = assembleEngineReport({
attachmentPreflight: preflight,
clarification: { question: "q?", options: ["a", "b"] },
discussionTurn: {},
provisioned: true,
diskLow: false,
pushFailed: false,
ok: true,
accountLimit: null,
verdict: null,
llmRetryDisabled: false,
});
expect(report).toBe(preflight);
});
});

describe("Claude usage/session-limit release (daemon half of G)", () => {
const base: TerminalRunState = {
attachmentPreflight: null,
clarification: null,
discussionTurn: null,
provisioned: true,
diskLow: false,
pushFailed: false,
ok: false,
accountLimit: null,
verdict: null,
llmRetryDisabled: false,
};

afterEach(() => __setClaudeRateLimitedUntil(null));

it("reports the no-penalty `claude_limited` release carrying the reset, NOT retryable/failed", () => {
const resetAt = "2026-07-05T03:00:00.000Z";
const report = assembleEngineReport({
...base,
accountLimit: { limited: true, resetAt },
// A transient verdict is present too — the account-limit branch must WIN over it.
verdict: { retryable: true, reason: "rate_limit", provider: "claude" },
});
expect(report).toEqual({ outcome: "claude_limited", claudeRateLimitedUntil: resetAt });
});

it("a NON-limited run still falls through to retryable, then failed", () => {
expect(
assembleEngineReport({ ...base, verdict: { retryable: true, reason: "overloaded", provider: "claude" } }),
).toEqual({ outcome: "retryable", reason: "overloaded" });
expect(assembleEngineReport({ ...base })).toEqual({ outcome: "failed" });
});

it("success / push-failure / provision-failure all outrank the limit branch", () => {
const accountLimit = { limited: true, resetAt: "2026-07-05T03:00:00.000Z" };
expect(assembleEngineReport({ ...base, ok: true, accountLimit }).outcome).toBe("succeeded");
expect(assembleEngineReport({ ...base, pushFailed: true, accountLimit }).outcome).toBe("failed");
expect(assembleEngineReport({ ...base, provisioned: false, accountLimit }).outcome).toBe("failed");
});

it("remembers a FUTURE block (pauses claims + surfaces on heartbeat) and auto-clears once past", () => {
const now = Date.UTC(2026, 6, 4, 10, 0, 0);
const future = Date.UTC(2026, 6, 4, 11, 0, 0);
__setClaudeRateLimitedUntil(future);
// Serialized onto the heartbeat / read by the claim gate while still in the future.
expect(claudeLimitIsoForHeartbeat(now)).toBe(new Date(future).toISOString());
// Past the reset → no longer limited (claims resume, heartbeat reports null) — self-healing.
expect(claudeLimitIsoForHeartbeat(future + 1)).toBeNull();
__setClaudeRateLimitedUntil(null);
expect(claudeLimitIsoForHeartbeat(now)).toBeNull();
});
});

Expand Down
Loading
Loading