mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-06 05:56:19 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Native runs start the provider through Runner. `session.open` starts the provider session before any prompt. > - When `session.open` fails, the server records the generic `native_session_interrupted`. Recovery evidence then reports `ambiguous_state`, so the first failure blocks the task with no automatic retry. > - The failure is often temporary. On one instance an agent's provider account hit its usage limit. For about 14 hours every later run failed at `session.open`, and 47 tasks stayed blocked. Outside limit windows, the same error also occurs once and a later retry succeeds. > - The server already has a bounded same-run bootstrap retry for failures that are proven to come before provider work. It did not recognize this failure, and it did not consider a known provider limit. > - This pull request classifies a definite `session.open` failure as a pre-session failure. Bootstrap retries back off and wait out a known provider limit. The task is blocked only after the shared budget is used, with a clear reason. > - The benefit is that a temporary startup failure or a provider limit no longer strands tasks on the first failure, and every existing replay guard stays in force. ## Linked Issues or Issue Description Refs #18847. That pull request keeps the provider cause on `session.open` rejections and names typed categories (for example `classification=provider_rate_limit`). This change does not depend on it. With #18847, a typed limit at `session.open` uses the same cooldown path, because the server reads the cause with `providerFailureFromError`. Refs #18622, which added the `ProviderFailure` cause. Refs #13993, an open change about legacy adapter startup backoff; it does not touch the native recovery path. **What happened?** A native run (`paperclip_runner`) failed at `session.open` before any prompt: `ACPX sidecar command session.open was rejected (retryable=false, classification=session_ensure_failed)`. The server recorded `native_session_interrupted`. Recovery evidence had no checkpoint and no provider event, but the cause was not in the pre-session list, so the mode was `ambiguous_state`. The coordinator went to `terminal_failure`, a board-owned `active_run_watchdog` action was created, and the task was set to `blocked` on the first failure. During a provider usage limit, every later run of the agent failed the same way, so each task was blocked. **Expected behavior** A run that provably failed before provider work retries automatically, with backoff and a bounded budget. If the agent has a known provider limit, the retry waits for the cooldown. The task is blocked only when the budget is used, and the recovery action explains why. A run where provider work may have started is never replayed. **Steps to reproduce** 1. Make Runner reject `session.open` for a native run (for example a provider at its usage limit). 2. Observe the coordinator: `terminal_failure`, `recoveryMode: "ambiguous_state"`, `providerSessionEstablished: false`. The task is `blocked`. 3. The new `native-startup-retry.integration.test.ts` reproduces this with a real database and the executor. **Paperclip version or commit** `master` at `5877d751c0e`. **Deployment mode** Native Runner with an ACPX Claude provider. The fix is in the server and is not specific to one deployment mode. ## What Changed - `nativeSessionFailureSourceCode` returns a new cause, `provider_session_open_rejected`, when Runner reports that `session.open` failed or was rejected. Those outcomes are definite, and the command runs before any prompt. An `indeterminate` outcome stays `native_session_interrupted`. Earlier, more specific causes (for example a missing runtime package) keep their classification. - `nativeProviderRecoveryEvidence` treats the new cause as pre-session. The durable evidence still decides. A bootstrap retry happens only with no provider session, no provider event and no checkpoint. Otherwise the mode stays `ambiguous_state` and the task is blocked as before. - New module `native-runtime/native-startup-retry.ts` plans each bootstrap retry. It is called only for `bootstrap_retry` mode. - Backoff: the existing `infrastructureRetryDelayMs` (about 30 s, then about 60 s, plus or minus 20 %). The jitter derives from the run and attempt, and it is persisted with `nextAttemptAt`. - Limit cooldown: evidence is a `rate_limit` or `quota_exhausted` provider failure on the current failure (the #18847 shape), or on an agent run that started in the last 24 hours. A `native_provider_usage_limit` or `provider_quota` error code also counts. The cooldown is the provider's `retryAt` (or `retryNotBefore`), or one hour after the failure when no reset time is known. A later successful run of the agent clears older evidence. The cooldown is capped at 24 hours. The retry runs after the cooldown plus the backoff delay. - A failed evidence read is logged and falls back to plain backoff, so it cannot stop the failure from settling. - The budget does not change: three provider attempts, shared with checkpoint resumes and replacements. When it is used, the coordinator records `native_session_retry_exhausted`. The task is blocked, and the board-owned action says: "Provider startup failed on 3 attempts before any provider work began. Automatic retries are stopped." When a limit is known, the message includes the expected reset time. - While a retry waits, the coordinator detail, the recovery action evidence and the scheduled wake record `providerCooldownAt`. The next action names the reset time, and the task projection shows "Retry scheduled". - `PROVIDER_LIMIT_DEFAULT_COOLDOWN_MS` (one hour) moves to `system-recovery-state.ts`. The legacy provider-quota recovery default now refers to it. The value does not change. - A chat retry of an exhausted `provider_session_open_rejected` run is accepted like the other pre-provider causes. The existing proofs still apply. - `doc/execution-semantics.md` describes the backoff, the cooldown and the exhausted state. ## Verification - Red: before the fix, the first new integration case failed. The coordinator was `terminal_failure` with `recoveryMode: "ambiguous_state"`, `originalFailureCode: "native_session_interrupted"` and `providerSessionEstablished: false`, and the task was `blocked`. Four more cases failed the same way. The two never-retry cases passed before and after the fix. - Green: `npx vitest run src/services/native-runtime/native-startup-retry.integration.test.ts` in `server` (embedded PostgreSQL, the real executor, and the real resume claim). 8 cases pass: - a rejected `session.open` is retried with backoff (attempt 1 in 24 to 36 s, attempt 2 in 48 to 72 s), and the task stays `in_progress` with an agent-owned action and a `resume_native_run` wake; - after three attempts the task is blocked, the action is board-owned with the clear reason, and the resume claim refuses another attempt; - a recent limit failure holds the retry until one hour after that failure, and the claim refuses it before the cooldown; - a provider reset time from an earlier run, or from the startup failure itself, sets the cooldown; - a distant reset time holds the retry for at most one day; - a later success, another agent's limit, and an expired limit do not delay the retry; - a durable provider event, or an indeterminate `session.open` outcome, still blocks the task with `ambiguous_state` and no retry. - `native-session-executor.test.ts`: all 1,087 tests pass, including a new classification case for `failed`, `rejected`, `indeterminate` and a different command. - `chat-channels.integration.test.ts -t "requires terminal-safe native ownership for an exact chat retry"`: 23 pass, including the new `session_open` case. That case fails without the list change. - Related suites pass: native session resumption, native provider failure, capacity and overload integration tests, provider failure classification, native status arbiter corpus, native restart recovery. - `npx tsc --noEmit -p server` passes. `git diff --check` passes. - [GitHub CI](https://github.com/paperclipai/paperclip/actions/runs/41713988964) is green on head `32561aa335d`. This includes `ci / verify`, `ci / e2e`, typecheck, build, all server, chat and workspace groups, all serialized server suites, and all Runner checks, which run the native-runtime server tests. In the first attempt, eleven jobs stopped because their runners received a shutdown signal; no test reported a failure. A rerun of those jobs passed. - A local run of the whole `server/src/services/native-runtime` directory was stopped before completion because it runs serially for a long time. CI ran those files. - Runtime dependency: the new work is one indexed query (`company_id`, `agent_id`, `started_at`) with `LIMIT 20`. It runs only when a bootstrap retry is planned or exhausted, not on the normal path. No new timer or polling: the wait uses the existing persisted `nextAttemptAt` deadline and the existing resume dispatch. - Not verified: a live provider account at its usage limit, and a live remote sandbox that waits through a full cooldown. The full repository suite was not run locally; CI ran it. ## Risks - A task that waits for a cooldown stays `in_progress` with its execution lock, for up to one day. This is the existing same-run retry state. Stop, reassignment, pause and an explicit Retry keep their current behavior through the existing claim checks. - During the wait the failed run keeps its environment lease in the same way as any same-run retry. A remote lease with an unexported prepared workspace is retained by the orphaned-lease sweep until the retry runs. Other active leases of the failed run are released after the usual five minutes, and the retry acquires a new one. - A `rate_limit` failure without a reset time (for example a short HTTP 429) delays only startup-failure retries of that agent, by up to one hour. Normal runs are not delayed, and a later success clears the evidence. - A startup failure that needs user action (for example a sign-in failure) now uses two automatic retries, about 90 seconds in total, before the task is blocked. Before, it was blocked at once with an ambiguous reason. - The agent's other queued runs are not held during a cooldown. Each one fails at `session.open` without provider work and then waits for the same cooldown. An agent-wide admission hold has no clean extension point in the current queue, so it is not part of this change. - Re-arming consumed scheduled monitors is out of scope. This change makes it matter less: a run that consumed a monitor wake now retries itself on the same run. A monitor that a run consumed before its retry budget was used stays consumed. - No schema or API change. The new error code appears on runs and in run error codes. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used Anthropic Claude Opus 5.5 (`claude-opus-5-5`) through Claude Code, with extended reasoning and tool use (shell, file editing, test execution). The host does not expose the context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
555 lines
20 KiB
TypeScript
555 lines
20 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { eq, sql } from "drizzle-orm";
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
agents,
|
|
companies,
|
|
completionContracts,
|
|
createDb,
|
|
heartbeatRunEvents,
|
|
heartbeatRuns,
|
|
issueRecoveryActions,
|
|
issues,
|
|
nativeRunFinalizations,
|
|
} from "@paperclipai/db";
|
|
import type { NativeExecutionInputV1 } from "@paperclipai/paperclip-runner";
|
|
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
|
|
|
|
const provider = vi.hoisted(() => ({ execute: vi.fn() }));
|
|
vi.mock("../../vendor/paperclip-runner/index.js", async (importOriginal) => ({
|
|
...(await importOriginal<
|
|
typeof import("../../vendor/paperclip-runner/index.js")
|
|
>()),
|
|
executeNativeSession: provider.execute,
|
|
}));
|
|
|
|
import { executePaperclipNativeSession } from "./native-session-executor.js";
|
|
import { buildNativeCompletionContract } from "./completion-contracts.js";
|
|
import { prepareNativeHeartbeatRun } from "./prepare-native-run.js";
|
|
import { claimNativeSessionResumptions } from "./native-finalization-reconciler.js";
|
|
|
|
type SessionOpenStatus = "failed" | "rejected" | "indeterminate";
|
|
|
|
/** Mirrors the runner's failed-command error: provider detail stays off the message. */
|
|
function sessionOpenFailure(options: {
|
|
status?: SessionOpenStatus;
|
|
providerFailure?: Record<string, unknown>;
|
|
} = {}) {
|
|
const status = options.status ?? "failed";
|
|
const result = {
|
|
result: {
|
|
code: "command_execution_failed",
|
|
message: options.providerFailure
|
|
? "Provider request failed"
|
|
: "failed to start ACPX provider: ACPX sidecar command session.open was rejected (retryable=false, classification=session_ensure_failed)",
|
|
...(options.providerFailure ? { providerFailure: options.providerFailure } : {}),
|
|
},
|
|
status,
|
|
};
|
|
const error = new Error(
|
|
`PRP command session.open ${status}: ${JSON.stringify(result, (key, value) =>
|
|
key === "providerFailure" ? undefined : value)}`,
|
|
);
|
|
Object.defineProperty(error, "runnerCommandResult", {
|
|
value: structuredClone(result),
|
|
enumerable: false,
|
|
});
|
|
return error;
|
|
}
|
|
|
|
function limitFailure(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
schema: "paperclip.provider-failure.v1",
|
|
provider: "anthropic",
|
|
phase: "turn",
|
|
category: "rate_limit",
|
|
message: "Usage limit reached for this account.",
|
|
retryability: "unknown",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const backoffWindow = (from: Date, attempt: number) => {
|
|
const base = 30_000 * 2 ** (attempt - 1);
|
|
return [from.getTime() + base * 0.8, from.getTime() + base * 1.2] as const;
|
|
};
|
|
|
|
describe("native provider startup retry", () => {
|
|
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: ReturnType<typeof createDb>;
|
|
const companyId = randomUUID();
|
|
|
|
beforeAll(async () => {
|
|
temporary = await startEmbeddedPostgresTestDatabase("native-startup-retry-");
|
|
db = createDb(temporary.connectionString);
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Native startup retry",
|
|
issuePrefix: "NSR",
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await temporary?.cleanup();
|
|
});
|
|
|
|
async function createAgent() {
|
|
const agentId = randomUUID();
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: `Startup agent ${agentId.slice(0, 8)}`,
|
|
status: "active",
|
|
adapterType: "paperclip_runner",
|
|
adapterConfig: { provider: "acpx", agent: "claude" },
|
|
});
|
|
return agentId;
|
|
}
|
|
|
|
async function seedRun(agentId: string) {
|
|
const issueId = randomUUID();
|
|
const runId = randomUUID();
|
|
const [issue] = await db
|
|
.insert(issues)
|
|
.values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Start work after a provider startup failure",
|
|
status: "in_progress",
|
|
workMode: "standard",
|
|
assigneeAgentId: agentId,
|
|
})
|
|
.returning();
|
|
const [run] = await db
|
|
.insert(heartbeatRuns)
|
|
.values({
|
|
id: runId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
runtimeMode: "native",
|
|
nativeIssueId: issueId,
|
|
invocationSource: "assignment",
|
|
triggerDetail: "system",
|
|
startedAt: new Date(),
|
|
contextSnapshot: { issueId },
|
|
})
|
|
.returning();
|
|
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
|
|
const native = await prepareNativeHeartbeatRun({
|
|
db,
|
|
run,
|
|
issue,
|
|
environmentLeaseId: randomUUID(),
|
|
});
|
|
const [contract] = await db
|
|
.select()
|
|
.from(completionContracts)
|
|
.where(eq(completionContracts.issueId, issueId));
|
|
const execution: NativeExecutionInputV1 = {
|
|
schema: "paperclip.native-execution-input.v1",
|
|
binding: { companyId, runId, issueId, agentId, executionWorkspaceId: runId },
|
|
provider: { kind: "codex", model: "gpt-5.6-luna" },
|
|
task: {
|
|
identifier: issueId,
|
|
title: issue.title,
|
|
description: null,
|
|
prompt: "Continue the task.",
|
|
workMode: "standard",
|
|
},
|
|
workspace: { cwd: "/tmp", repoUrl: null, repoRef: null, branchName: null },
|
|
session: {
|
|
normalizedSessionId: native.normalizedSessionId,
|
|
driverKind: "codex_app_server",
|
|
protocolVersion: 1,
|
|
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
|
|
},
|
|
completionContract: {
|
|
id: contract.id,
|
|
sha256: contract.canonicalSha256,
|
|
schemaVersion: "paperclip.completion-contract.v1",
|
|
contract: buildNativeCompletionContract(issue, { revision: contract.revision }),
|
|
},
|
|
interactionResponses: [],
|
|
credentialBindings: [],
|
|
};
|
|
// Dispatch persists this envelope before execution; same-run recovery needs it.
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({
|
|
runnerProfileJson: sql`coalesce(${heartbeatRuns.runnerProfileJson}, '{}'::jsonb)
|
|
|| ${JSON.stringify({ nativeExecutionInput: execution })}::jsonb`,
|
|
})
|
|
.where(eq(heartbeatRuns.id, runId));
|
|
await db.insert(nativeRunFinalizations).values({
|
|
runId,
|
|
companyId,
|
|
issueId,
|
|
phase: "observed",
|
|
});
|
|
return { agentId, issueId, runId, execution, runnerInstanceId: native.runnerInstanceId };
|
|
}
|
|
|
|
type Seeded = Awaited<ReturnType<typeof seedRun>>;
|
|
|
|
async function failStartup(
|
|
seeded: Seeded,
|
|
failure: Error,
|
|
options: { leaseOwner?: string; beforeFailure?: () => Promise<void> } = {},
|
|
) {
|
|
provider.execute.mockReset().mockImplementation(async () => {
|
|
await options.beforeFailure?.();
|
|
throw failure;
|
|
});
|
|
const startedAt = new Date();
|
|
await expect(
|
|
executePaperclipNativeSession({
|
|
db,
|
|
execution: seeded.execution,
|
|
runnerInstanceId: seeded.runnerInstanceId,
|
|
...(options.leaseOwner ? { leaseOwner: options.leaseOwner } : {}),
|
|
backend: {
|
|
descriptor: async () => {
|
|
throw new Error("Unexpected provider descriptor call");
|
|
},
|
|
openSession: async () => {
|
|
throw new Error("Unexpected provider session call");
|
|
},
|
|
},
|
|
}),
|
|
).rejects.toThrow("PRP command session.open");
|
|
expect(provider.execute).toHaveBeenCalledTimes(1);
|
|
return startedAt;
|
|
}
|
|
|
|
async function read(seeded: Seeded) {
|
|
const [coordinator] = await db
|
|
.select()
|
|
.from(nativeRunFinalizations)
|
|
.where(eq(nativeRunFinalizations.runId, seeded.runId));
|
|
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seeded.runId));
|
|
const [issue] = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
|
|
const actions = await db
|
|
.select()
|
|
.from(issueRecoveryActions)
|
|
.where(eq(issueRecoveryActions.sourceIssueId, seeded.issueId));
|
|
const action = actions.find((entry) => ["active", "escalated"].includes(entry.status)) ?? null;
|
|
return { coordinator: coordinator!, run: run!, issue: issue!, action };
|
|
}
|
|
|
|
/** Claims the scheduled retry through the same path the recovery scheduler uses. */
|
|
async function claimRetry(seeded: Seeded) {
|
|
const { coordinator } = await read(seeded);
|
|
expect(coordinator.nextAttemptAt).toBeInstanceOf(Date);
|
|
const early = await claimNativeSessionResumptions({
|
|
db,
|
|
runnerInstanceId: "startup-retry-test",
|
|
runIds: [seeded.runId],
|
|
now: new Date(coordinator.nextAttemptAt!.getTime() - 1),
|
|
deferIneligible: false,
|
|
});
|
|
expect(early).toEqual([]);
|
|
const claims = await claimNativeSessionResumptions({
|
|
db,
|
|
runnerInstanceId: "startup-retry-test",
|
|
runIds: [seeded.runId],
|
|
now: new Date(coordinator.nextAttemptAt!.getTime() + 1),
|
|
deferIneligible: false,
|
|
});
|
|
expect(claims).toHaveLength(1);
|
|
return claims[0]!.leaseOwner;
|
|
}
|
|
|
|
async function recordPriorRun(agentId: string, values: Partial<typeof heartbeatRuns.$inferInsert>) {
|
|
await db.insert(heartbeatRuns).values({
|
|
id: randomUUID(),
|
|
companyId,
|
|
agentId,
|
|
runtimeMode: "native",
|
|
invocationSource: "assignment",
|
|
triggerDetail: "system",
|
|
...values,
|
|
});
|
|
}
|
|
|
|
it("retries a rejected session.open with growing backoff instead of blocking the task", async () => {
|
|
const seeded = await seedRun(await createAgent());
|
|
|
|
const firstFailedAt = await failStartup(seeded, sessionOpenFailure());
|
|
let state = await read(seeded);
|
|
expect(state.coordinator).toMatchObject({
|
|
phase: "retryable_failure",
|
|
recoveryState: "resuming_session",
|
|
attempt: 1,
|
|
leaseOwner: null,
|
|
failureDetail: expect.objectContaining({
|
|
originalFailureCode: "provider_session_open_rejected",
|
|
recoveryMode: "bootstrap_retry",
|
|
providerSessionEstablished: false,
|
|
providerEventsExist: false,
|
|
checkpointExists: false,
|
|
}),
|
|
});
|
|
const [firstLow, firstHigh] = backoffWindow(firstFailedAt, 1);
|
|
expect(state.coordinator.nextAttemptAt!.getTime()).toBeGreaterThanOrEqual(firstLow);
|
|
expect(state.coordinator.nextAttemptAt!.getTime()).toBeLessThanOrEqual(firstHigh + 5_000);
|
|
expect(state.coordinator.failureDetail).not.toHaveProperty("providerCooldownAt");
|
|
expect(state.issue.status).toBe("in_progress");
|
|
expect(state.run).toMatchObject({
|
|
status: "failed",
|
|
nativePhase: "retryable_failure",
|
|
errorCode: "provider_session_open_rejected",
|
|
});
|
|
expect(state.action).toMatchObject({
|
|
ownerType: "agent",
|
|
ownerAgentId: seeded.agentId,
|
|
wakePolicy: {
|
|
kind: "resume_native_run",
|
|
runId: seeded.runId,
|
|
notBefore: state.coordinator.nextAttemptAt!.toISOString(),
|
|
},
|
|
});
|
|
|
|
const secondFailedAt = await failStartup(seeded, sessionOpenFailure(), {
|
|
leaseOwner: await claimRetry(seeded),
|
|
});
|
|
state = await read(seeded);
|
|
expect(state.coordinator).toMatchObject({ phase: "retryable_failure", attempt: 2 });
|
|
const [secondLow, secondHigh] = backoffWindow(secondFailedAt, 2);
|
|
expect(state.coordinator.nextAttemptAt!.getTime()).toBeGreaterThanOrEqual(secondLow);
|
|
expect(state.coordinator.nextAttemptAt!.getTime()).toBeLessThanOrEqual(secondHigh + 5_000);
|
|
expect(state.issue.status).toBe("in_progress");
|
|
});
|
|
|
|
it("blocks with a clear reason once the bounded startup budget is exhausted", async () => {
|
|
const seeded = await seedRun(await createAgent());
|
|
await failStartup(seeded, sessionOpenFailure());
|
|
await failStartup(seeded, sessionOpenFailure(), { leaseOwner: await claimRetry(seeded) });
|
|
await failStartup(seeded, sessionOpenFailure(), { leaseOwner: await claimRetry(seeded) });
|
|
|
|
const state = await read(seeded);
|
|
expect(state.coordinator).toMatchObject({
|
|
phase: "terminal_failure",
|
|
recoveryState: "blocked",
|
|
failureCode: "native_session_retry_exhausted",
|
|
attempt: 3,
|
|
nextAttemptAt: null,
|
|
failureDetail: expect.objectContaining({
|
|
originalFailureCode: "provider_session_open_rejected",
|
|
recoveryMode: "bootstrap_retry",
|
|
providerSessionEstablished: false,
|
|
}),
|
|
});
|
|
expect(state.issue.status).toBe("blocked");
|
|
expect(state.issue.executionRunId).toBeNull();
|
|
expect(state.action).toMatchObject({
|
|
ownerType: "board",
|
|
cause: "native_session_retry_exhausted",
|
|
wakePolicy: null,
|
|
});
|
|
expect(state.action!.nextAction).toContain("3 attempts");
|
|
expect(state.action!.nextAction).toContain("before any provider work began");
|
|
expect(String(state.coordinator.failureDetail?.nextAction)).toContain("before any provider work began");
|
|
// The task cannot be claimed for another automatic attempt.
|
|
expect(await claimNativeSessionResumptions({
|
|
db,
|
|
runnerInstanceId: "startup-retry-test",
|
|
runIds: [seeded.runId],
|
|
now: new Date(Date.now() + 60 * 60_000),
|
|
deferIneligible: false,
|
|
})).toEqual([]);
|
|
});
|
|
|
|
it("waits for the cooldown of a recent provider usage limit before retrying", async () => {
|
|
const agentId = await createAgent();
|
|
const limitedAt = new Date(Date.now() - 5 * 60_000);
|
|
await recordPriorRun(agentId, {
|
|
status: "failed",
|
|
startedAt: new Date(limitedAt.getTime() - 60_000),
|
|
finishedAt: limitedAt,
|
|
errorCode: "native_provider_terminal_failed",
|
|
resultJson: { providerFailure: limitFailure() },
|
|
});
|
|
const seeded = await seedRun(agentId);
|
|
|
|
const failedAt = await failStartup(seeded, sessionOpenFailure());
|
|
const state = await read(seeded);
|
|
const cooldownAt = new Date(limitedAt.getTime() + 60 * 60_000);
|
|
expect(state.coordinator).toMatchObject({
|
|
phase: "retryable_failure",
|
|
failureDetail: expect.objectContaining({
|
|
recoveryMode: "bootstrap_retry",
|
|
providerCooldownAt: cooldownAt.toISOString(),
|
|
}),
|
|
});
|
|
const [low, high] = backoffWindow(cooldownAt, 1);
|
|
expect(state.coordinator.nextAttemptAt!.getTime()).toBeGreaterThanOrEqual(low);
|
|
expect(state.coordinator.nextAttemptAt!.getTime()).toBeLessThanOrEqual(high);
|
|
expect(state.coordinator.nextAttemptAt!.getTime()).toBeGreaterThan(failedAt.getTime() + 50 * 60_000);
|
|
expect(String(state.coordinator.failureDetail?.nextAction)).toContain(cooldownAt.toISOString());
|
|
expect(state.issue.status).toBe("in_progress");
|
|
expect(state.action).toMatchObject({
|
|
ownerType: "agent",
|
|
evidence: expect.objectContaining({ providerCooldownAt: cooldownAt.toISOString() }),
|
|
wakePolicy: expect.objectContaining({
|
|
kind: "resume_native_run",
|
|
notBefore: state.coordinator.nextAttemptAt!.toISOString(),
|
|
}),
|
|
});
|
|
expect(await claimNativeSessionResumptions({
|
|
db,
|
|
runnerInstanceId: "startup-retry-test",
|
|
runIds: [seeded.runId],
|
|
now: new Date(cooldownAt.getTime() - 1),
|
|
deferIneligible: false,
|
|
})).toEqual([]);
|
|
});
|
|
|
|
it("uses a provider-reported reset time from earlier runs or from the startup failure itself", async () => {
|
|
const recordedAgent = await createAgent();
|
|
const recordedResetAt = new Date(Date.now() + 3 * 60 * 60_000);
|
|
await recordPriorRun(recordedAgent, {
|
|
status: "failed",
|
|
startedAt: new Date(Date.now() - 20 * 60_000),
|
|
finishedAt: new Date(Date.now() - 10 * 60_000),
|
|
errorCode: "native_provider_terminal_failed",
|
|
resultJson: {
|
|
providerFailure: limitFailure({
|
|
category: "quota_exhausted",
|
|
retryAt: recordedResetAt.toISOString(),
|
|
retryability: "requires_action",
|
|
}),
|
|
},
|
|
});
|
|
const recorded = await seedRun(recordedAgent);
|
|
await failStartup(recorded, sessionOpenFailure());
|
|
let state = await read(recorded);
|
|
expect(state.coordinator.failureDetail).toMatchObject({
|
|
providerCooldownAt: recordedResetAt.toISOString(),
|
|
});
|
|
expect(state.coordinator.nextAttemptAt!.getTime()).toBeGreaterThanOrEqual(
|
|
backoffWindow(recordedResetAt, 1)[0],
|
|
);
|
|
|
|
// A runner that classifies the startup rejection itself supplies the evidence.
|
|
const startupResetAt = new Date(Date.now() + 2 * 60 * 60_000);
|
|
const classified = await seedRun(await createAgent());
|
|
await failStartup(classified, sessionOpenFailure({
|
|
providerFailure: limitFailure({
|
|
phase: "startup",
|
|
retryAt: startupResetAt.toISOString(),
|
|
retryability: "transient",
|
|
}),
|
|
}));
|
|
state = await read(classified);
|
|
expect(state.coordinator).toMatchObject({
|
|
phase: "retryable_failure",
|
|
failureDetail: expect.objectContaining({
|
|
recoveryMode: "bootstrap_retry",
|
|
providerCooldownAt: startupResetAt.toISOString(),
|
|
}),
|
|
});
|
|
expect(state.coordinator.nextAttemptAt!.getTime()).toBeGreaterThanOrEqual(
|
|
backoffWindow(startupResetAt, 1)[0],
|
|
);
|
|
expect(state.issue.status).toBe("in_progress");
|
|
});
|
|
|
|
it("holds a startup retry for at most one day before trying again", async () => {
|
|
const seeded = await seedRun(await createAgent());
|
|
const failedAt = await failStartup(seeded, sessionOpenFailure({
|
|
providerFailure: limitFailure({
|
|
phase: "startup",
|
|
category: "quota_exhausted",
|
|
retryAt: new Date(Date.now() + 3 * 24 * 60 * 60_000).toISOString(),
|
|
retryability: "requires_action",
|
|
}),
|
|
}));
|
|
const state = await read(seeded);
|
|
const cooldownAt = Date.parse(String(state.coordinator.failureDetail?.providerCooldownAt));
|
|
expect(cooldownAt).toBeGreaterThanOrEqual(failedAt.getTime() + 24 * 60 * 60_000);
|
|
expect(cooldownAt).toBeLessThanOrEqual(Date.now() + 24 * 60 * 60_000);
|
|
expect(state.coordinator.nextAttemptAt!.getTime()).toBeGreaterThanOrEqual(backoffWindow(new Date(cooldownAt), 1)[0]);
|
|
});
|
|
|
|
it("ignores limit evidence that a later success, another agent, or time has cleared", async () => {
|
|
const agentId = await createAgent();
|
|
const otherAgentId = await createAgent();
|
|
await recordPriorRun(agentId, {
|
|
status: "failed",
|
|
startedAt: new Date(Date.now() - 30 * 60_000),
|
|
finishedAt: new Date(Date.now() - 25 * 60_000),
|
|
errorCode: "native_provider_terminal_failed",
|
|
resultJson: { providerFailure: limitFailure() },
|
|
});
|
|
await recordPriorRun(agentId, {
|
|
status: "succeeded",
|
|
startedAt: new Date(Date.now() - 20 * 60_000),
|
|
finishedAt: new Date(Date.now() - 15 * 60_000),
|
|
});
|
|
await recordPriorRun(agentId, {
|
|
status: "failed",
|
|
startedAt: new Date(Date.now() - 3 * 60 * 60_000),
|
|
finishedAt: new Date(Date.now() - 2 * 60 * 60_000),
|
|
errorCode: "native_provider_usage_limit",
|
|
});
|
|
await recordPriorRun(otherAgentId, {
|
|
status: "failed",
|
|
startedAt: new Date(Date.now() - 2 * 60_000),
|
|
finishedAt: new Date(Date.now() - 60_000),
|
|
errorCode: "native_provider_terminal_failed",
|
|
resultJson: { providerFailure: limitFailure() },
|
|
});
|
|
const seeded = await seedRun(agentId);
|
|
|
|
const failedAt = await failStartup(seeded, sessionOpenFailure());
|
|
const state = await read(seeded);
|
|
expect(state.coordinator.phase).toBe("retryable_failure");
|
|
expect(state.coordinator.failureDetail).not.toHaveProperty("providerCooldownAt");
|
|
expect(state.coordinator.nextAttemptAt!.getTime()).toBeLessThanOrEqual(
|
|
backoffWindow(failedAt, 1)[1] + 5_000,
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
{ name: "a durable provider event exists", status: "failed" as const, providerEvent: true },
|
|
{ name: "the session.open outcome is indeterminate", status: "indeterminate" as const, providerEvent: false },
|
|
])("never retries automatically when $name", async ({ status, providerEvent }) => {
|
|
const seeded = await seedRun(await createAgent());
|
|
await failStartup(seeded, sessionOpenFailure({ status }), {
|
|
beforeFailure: providerEvent
|
|
? async () => {
|
|
await db.insert(heartbeatRunEvents).values({
|
|
companyId,
|
|
runId: seeded.runId,
|
|
agentId: seeded.agentId,
|
|
seq: 900,
|
|
eventType: "turn.started",
|
|
stream: "system",
|
|
});
|
|
}
|
|
: undefined,
|
|
});
|
|
|
|
const state = await read(seeded);
|
|
expect(state.coordinator).toMatchObject({
|
|
phase: "terminal_failure",
|
|
recoveryState: "blocked",
|
|
nextAttemptAt: null,
|
|
failureDetail: expect.objectContaining({
|
|
recoveryMode: "ambiguous_state",
|
|
providerSessionEstablished: providerEvent,
|
|
}),
|
|
});
|
|
expect(state.issue.status).toBe("blocked");
|
|
expect(state.action).toMatchObject({ ownerType: "board", wakePolicy: null });
|
|
expect(await claimNativeSessionResumptions({
|
|
db,
|
|
runnerInstanceId: "startup-retry-test",
|
|
runIds: [seeded.runId],
|
|
now: new Date(Date.now() + 60 * 60_000),
|
|
deferIneligible: false,
|
|
})).toEqual([]);
|
|
});
|
|
});
|