Files
PaperClipAI/server/src/services/native-runtime/native-startup-retry.integration.test.ts
T
Devin FoleyandPaperclip 909d86b59a fix(native): retry runs that fail before provider work (#18854)
## 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>
2026-11-03 13:24:59 -08:00

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([]);
});
});