fix(native): retry ACPX session.open startup timeouts and give cold Claude a 60 s bound (#19661)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Native runs start an ACPX provider through Runner. `session.open`
opens the provider session before any prompt.
> - On a loaded host, a cold ACPX Claude `session.open` took longer than
its 30-second bound. The controller reported
`runner_local_connect_failed: PRP command session.open timed out`.
> - The server classified this as the generic
`native_session_interrupted` with `ambiguous_state`, so the task was
blocked at once. A person had to click Retry, sometimes several times.
In one restart-chaos storm, one task needed three Retry clicks, and on
another task both Retry runs timed out too.
> - The server already has a bounded same-run bootstrap retry for
failures that come before provider work (#18854). It did not recognize
this timeout. The 30-second bound also cut off normal slow startups.
> - This pull request gives cold ACPX Claude a 60-second `session.open`
bound, like Pi and OpenCode (#19487). It also retries a startup
`session.open` timeout automatically on the same run, from clean state.
> - The benefit is that a slow cold start on a busy host completes, or
retries itself, instead of waiting for a person.

## Linked Issues or Issue Description

Refs #18854 (bounded bootstrap retry for definite `session.open`
failures), #19487 (60-second cold OpenCode admission), #19665 (the
bootstrap proof that this change reuses).

**What happened?**

ACPX Claude runs failed with `runner_local_connect_failed: PRP command
session.open timed out` about 35 to 40 seconds after they started. The
coordinator recorded `native_session_interrupted` with `recoveryMode:
"ambiguous_state"` and `providerSessionEstablished: false`. The task was
blocked with a board-owned action. No provider event and no checkpoint
existed. The host 1-minute load was 17 to 44, sometimes with no restart
nearby.

**Expected behavior**

A normal slow cold start completes. A `session.open` that still times
out during runner startup, with no provider event, no checkpoint and no
turn, retries on the same run with backoff. At most two automatic
attempts follow the first. A run where provider work may have started is
never retried automatically.

**Steps to reproduce**

1. Run ACPX Claude tasks on a host with a 1-minute load of about 25 or
more.
2. Observe a run fail after about 35 to 40 seconds with `PRP command
session.open timed out`.
3. Observe the task blocked with `ambiguous_state` and a manual Retry.

**Paperclip version or commit**

`master` at `6c7cc2c5d6`.

## What Changed

The bound (evidence below):

- `coldAdmissionTimeoutMs` gives cold ACPX Claude 60 seconds (live
adoption keeps 30 seconds). The runner's Rust sidecar
`session_open_timeout` and the sidecar's provider handshake
(`acpxSessionHandshakeTimeoutMs`) give Claude and Pi 60 seconds too. All
three bounds must agree, or the shortest one fails first. Claude's warm
run attachment uses the same cold budget, like Pi.

The automatic retry:

- `nativeSessionFailureSourceCode` returns
`provider_session_open_timeout` only for exactly `<startup failure
code>: PRP command session.open timed out`. The startup failure codes
are `runner_local_connect_failed`, `runner_direct_wss_failed` and
`runner_ingress_unavailable`. The transport uses them only before
startup completes. After startup the code is
`provider_transport_failed`, which is unchanged.
- `nativeProviderRecoveryEvidence` treats the cause as pre-session.
Durable evidence still decides: a bootstrap retry needs no provider
event (`session.started`, `turn.started` and the others) and no
checkpoint. The existing budget applies: three attempts with backoff of
about 30 seconds, then about 60 seconds. When the budget is used, the
task is blocked with the existing "Provider startup failed on 3 attempts
before any provider work began" action.
- A timeout is indeterminate: the runner can open a provider session
after the controller stops waiting. Live runs showed both leftovers, so
the retry now starts from clean state:
- `prepareNativeSessionOpenTimeoutRetry` (server) runs when the failure
settles. It quarantines a local runner root only when the
restart-recovery proof (`runnerdStateProvesIncompleteBootstrap`, #19665)
shows that no turn began and every recorded process is gone. Any other
root, and any remote runner, stays `ambiguous_state` as before.
- `resetAbandonedBootstrapRecord` (runner ACPX adapter) runs on a fresh
open (no runner-recorded identity). If ACPX has a saved record for the
session key with no messages, no prompt and no events, it is marked
`reset_on_next_ensure`. Claude saves nothing before the first prompt, so
reusing such a record fails the first turn with
`session_resume_required`. A record that holds any conversation is
unchanged.
- A chat retry of an exhausted `provider_session_open_timeout` run is
accepted like the other pre-provider causes.
- `doc/execution-semantics.md` and the Runner README describe the cause
and the bound.

## Verification

**Measured startup distribution** (ACPX Claude
`runner.session.bootstrap` / `runner.session.startup` spans from
restart-chaos run events, 1-minute load 8 to 44):

| Source | Bound | Successful cold bootstraps (s) | Failures |
| --- | --- | --- | --- |
| Earlier storm evidence | 30 s | 2.9, 4.7, 7.4, 8.6, 8.7, 26.2, 27.4,
27.6, 29.2 | startups ended at 34.9 to 40.3 s (the 30 s `session.open`
bound plus spawn and `run.prepare`) |
| This branch, wave s3601 | 60 s | 6.3, 7.8, 8.1, 13.4, 14.4, 22.3,
22.8, 22.9, **37.3** | 0 `session.open` timeouts |
| This branch, wave s3602 | 60 s | 13 samples, p50 10.5, p90 17.9, max
25.7 | 0 `session.open` timeouts |
| This branch, wave s3610 (final head, load 28 to 42) | 60 s | 10.4,
11.3, 11.7, 12.0, 13.3, 14.2, 29.6, **30.4**, **30.9** | 0
`session.open` timeouts |

Under load, successes clustered just under 30 s and failures sat just
above it, so the 30 s bound was cutting the distribution off. With 60 s,
cold starts of 30.4, 30.9 and 37.3 s succeeded that the old bound would
have failed. 60 s is about 1.6 times the largest observed success, and
it matches Pi and OpenCode.

**Live restart-chaos waves** (local, ACPX Claude only, 4 in flight,
`--rc06-masked`, real provider, K1/K2 restarts):

- s3601 (8 tasks, load 19 to 32): 7/8 PASS. No `session.open` timeout.
The one failure is an R3 Retry signature after K1
(`native_restart_recovery_blocked`), not this cause.
- s3602 (8 tasks, load 17 to 39): 8/8 PASS. No `session.open` timeout.
Three Retry clicks were for `native_provider_terminal_failed`
(`provider_turn_lost_on_restore`), a different restart signature.
- Forced timeout, s3609 (4 tasks, B5, K2): a local, uncommitted fault
injection gave every other cold startup a 1.5 s `session.open` deadline.
Result 4/4 PASS. Run 4fe6537a timed out, retried itself on the same run,
and then succeeded through four K2 restarts with no person involved. Run
02799a85 hit the injection three times in a row; the budget stopped it
after attempt 3, and one Retry completed it.
- Before the two clean-state fixes, the same forced wave (s3607 and
s3608) showed the retry reusing leftover state: `turn.start` failed with
`session_resume_required`, and then "ACPX session is not open". Those
runs are the red evidence for the two clean-state fixes.
- Final head, s3610 (8 tasks, K1/K2, no injection, load 28 to 42): **8/8
PASS**, 0 `session.open` timeouts. The one Retry click (t06) was for
`provider_turn_lost_on_restore`, a different restart signature.

**Tests:**

- Red before the fix: `native-startup-retry.integration.test.ts -t
timeout` (embedded PostgreSQL, real executor, real resume claim). The 3
retry cases failed with `native_session_interrupted` /
`ambiguous_state`.
- Green: `native-startup-retry.integration.test.ts` passes 36/36. New
cases:
- for each startup failure code, a timeout retries on the same run with
backoff, keeps the task `in_progress` with an agent-owned
`resume_native_run` wake, and blocks after attempt 3 with a board-owned
action;
- no automatic retry when `session.started` or `turn.started` is
durable, when the code is `provider_transport_failed`, or when another
command timed out;
- a real runner root with an opened session and no turn is quarantined;
a root with a turn command stays `ambiguous_state` and intact; a remote
runner is never prepared.
- `codex-runtime-adapter.test.ts`: 113 pass. New cases: a 45 s delayed
handshake is admitted for Claude and rejected for Codex; a
never-prompted record is reset only on a fresh open; records with
messages, a prompt or events are kept.
- `abandoned-bootstrap-record.test.ts` (real `createAcpRuntime` with
`createRuntimeStore` and a controlled ACP peer that, like Claude, cannot
load a never-prompted session): without the reset, a fresh open reuses
the record and the first turn fails with SESSION_RESUME_REQUIRED after
`session/load`. With the reset, the provider receives `session/new` and
the turn completes.
- `vitest run src/drivers/acpx src/cli` in the runner: 107 files, 1,935
tests pass.
- `native-session-executor.test.ts -t "session.open|startup|bootstrap"`:
144 pass. `runnerd-codex-transport.test.ts -t "cold process admission"`:
4 pass. `cargo test -p paperclip-runner-core --lib admission_budget`
passes.
- `npx tsc --noEmit -p server` and the runner `tsc -p
tsconfig.test.json` pass.

## Risks

- **This changes Runner Rust code. The Daytona runner image must be
rebuilt after merge** for remote sandboxes to get the 60-second sidecar
bound. Until then, a remote Claude `session.open` still fails at 30
seconds in the sidecar (as a definite failure, which already retries). A
remote timeout stays operator-owned, because a remote root cannot be
proven clean here.
- A cold ACPX Claude start that hangs now fails after about 60 seconds
instead of 30 seconds.
- A timed-out `session.open` may have created an empty provider session.
No prompt was sent. The retry quarantines that runner root and asks ACPX
for a new session.
- Same-run retries of a fresh-attempt Retry run can still fail with
`isolated_task_directory_binding_mismatch`. This is RC-35 (workspace
re-preparation), which has its own fix in progress.
- No schema or API change. The new error code appears on runs and in run
error codes.

## Model Used

Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, through Claude Code with
tool use (shell, file edits, test execution).

## 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

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-06 19:37:11 -06:00
1 parent b94963f91e
commit 9a52a2dfeb
12 files changed
+515 -20

No files matched your search

+14 -2
View File
@@ -2305,8 +2305,20 @@ existing bounded disposition-only recovery policy.
A bootstrap retry requires durable evidence that no provider session, provider
event, or checkpoint exists. A runner report that `session.open` failed or was
rejected qualifies, because that command runs before any prompt. An
indeterminate `session.open` outcome does not qualify. Bootstrap retries back
rejected qualifies, because that command runs before any prompt. A
`session.open` that the controller stopped waiting for during runner startup
(`<startup failure code>: PRP command session.open timed out`) also qualifies
as `provider_session_open_timeout`: the controller sends a prompt only after
`session.open` completes, and closing the failed transport stops that runner
before the retry starts a new one. The runner can still open an empty provider
session after the controller stops waiting, so the retry starts from clean
state: a local runner root is quarantined only when the restart-recovery
bootstrap proof shows that no turn began and its processes are gone, and a
fresh ACPX open replaces a saved session record that holds no conversation. A
root that fails the proof, a remote runner, and a provider session or provider
event that became durable before the failure settled keep the state ambiguous.
A runner-reported indeterminate `session.open` outcome, and a timeout after
startup completed, do not qualify. Bootstrap retries back
off: about 30 seconds after the first attempt and about 60 seconds after the
second, with jitter that is persisted with the failure. Before it schedules a
bootstrap retry, the executor looks for a known provider limit for the agent.
+3 -1
View File
@@ -403,7 +403,9 @@ and both must succeed before artifact metadata and source-bound hashes are
recorded. Run Docker and native qualification on hosted CI rather than a local
developer machine.
Pi cold provider admission has an absolute 60-second budget. Warm run attachment
Pi and Claude cold provider admission have an absolute 60-second budget, and
the runner's ACPX sidecar and the sidecar's provider session handshake bound their `session.open` at 60 seconds too. A cold
Claude `session.open` timed out at the earlier 30-second bound on a loaded host. Warm run attachment
checkpoints the old ACPX sidecar and starts a new one, so it uses that same budget
while retaining the existing Runner authority. Live adoption and ordinary
commands retain their 30-second bounds. Closing the transport cancels admission;
@@ -91,8 +91,10 @@ pub struct AcpxSidecarTransport {
// A fresh Pi process verifies and copies its native closure before ACP admission.
// Reopen/recovery uses the same path. Ordinary sidecar requests retain their bound.
// A cold Claude session.open exceeded 30 s on a loaded host; it shares Pi's
// 60 s bound, matching the controller's cold admission budget.
fn session_open_timeout(agent: &str, ordinary: Duration) -> Duration {
if agent == "pi" {
if agent == "pi" || agent == "claude" {
Duration::from_secs(60)
} else {
ordinary
@@ -1202,13 +1204,15 @@ mod tests {
}
#[test]
fn only_pi_cold_open_gets_the_longer_admission_budget() {
fn only_pi_and_claude_cold_open_get_the_longer_admission_budget() {
let ordinary = Duration::from_secs(30);
assert_eq!(
session_open_timeout("pi", ordinary),
Duration::from_secs(60)
);
for agent in ["claude", "codex", "grok", "cursor", "copilot"] {
for agent in ["pi", "claude"] {
assert_eq!(
session_open_timeout(agent, ordinary),
Duration::from_secs(60)
);
}
for agent in ["codex", "grok", "cursor", "copilot"] {
assert_eq!(session_open_timeout(agent, ordinary), ordinary);
}
}
@@ -0,0 +1,107 @@
import { spawn, type ChildProcess } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createAcpRuntime, createAgentRegistry, createRuntimeStore } from "acpx/runtime";
import { describe, expect, it } from "vitest";
import { resetAbandonedBootstrapRecord } from "./codex-runtime-adapter.js";
// A controlled ACP peer that behaves like Claude: a session that never received
// a prompt was never saved, so session/load (and session/resume) of it fails.
const peer = String.raw`
const send = x => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', ...x }) + '\n');
let next = 0;
const prompted = new Set(JSON.parse(process.env.PROMPTED || '[]'));
require('node:readline').createInterface({ input: process.stdin }).on('line', line => {
const m = JSON.parse(line);
if (m.method === 'initialize') send({ id: m.id, result: { protocolVersion: 1, agentCapabilities: { loadSession: true }, authMethods: [] } });
else if (m.method === 'session/new') send({ id: m.id, result: { sessionId: process.env.SESSION_PREFIX + (next++) } });
else if (m.method === 'session/load') {
if (prompted.has(m.params.sessionId)) send({ id: m.id, result: {} });
else send({ id: m.id, error: { code: -32002, message: 'No conversation found with session ID: ' + m.params.sessionId } });
}
else if (m.method === 'session/prompt') send({ id: m.id, result: { stopReason: 'end_turn' } });
});`;
async function withRuntime<T>(dir: string, prefix: string, run: (input: {
runtime: ReturnType<typeof createAcpRuntime>; methods: string[];
}) => Promise<T>): Promise<T> {
const children: ChildProcess[] = [];
const methods: string[] = [];
const runtime = createAcpRuntime({
cwd: dir,
agentRegistry: createAgentRegistry({ overrides: { claude: "fixture" } }),
sessionStore: createRuntimeStore({ stateDir: join(dir, "state") }),
permissionMode: "deny-all",
onAcpMessage: (direction, value) => { if (direction === "outbound") methods.push((value as { method: string }).method); },
spawnAgent: () => {
const child = spawn(process.execPath, ["-e", peer], { cwd: dir, env: { ...process.env, SESSION_PREFIX: prefix }, stdio: ["pipe", "pipe", "pipe"] });
children.push(child);
return child;
},
});
try {
return await run({ runtime, methods });
} finally {
for (const child of children) if (child.exitCode === null) child.kill("SIGKILL");
}
}
async function prompt(runtime: ReturnType<typeof createAcpRuntime>, handle: Awaited<ReturnType<ReturnType<typeof createAcpRuntime>["ensureSession"]>>) {
const turn = runtime.startTurn({ handle, text: "fixture", mode: "prompt", requestId: "first-turn", timeoutMs: 5_000 });
const drain = (async () => { for await (const _event of turn.events) { /* drain */ } })();
const result = await turn.result;
await drain;
return result;
}
describe("a saved ACPX record from a bootstrap that stopped before any prompt", () => {
async function abandonBootstrap(dir: string) {
// The first bootstrap opens a session, then its runner stops before a turn.
await withRuntime(dir, "abandoned-", async ({ runtime, methods }) => {
const handle = await runtime.ensureSession({ sessionKey: "paperclip-session-key", agent: "claude", mode: "persistent", cwd: dir });
expect(handle.backendSessionId).toBe("abandoned-0");
expect(methods).toContain("session/new");
await runtime.close({ handle, reason: "runner stopped before a turn" });
});
}
it("is reused by a fresh open without a reset, so the first turn cannot load it", async () => {
const dir = await mkdtemp(join(tmpdir(), "acpx-abandoned-record-"));
try {
await abandonBootstrap(dir);
await withRuntime(dir, "retry-", async ({ runtime, methods }) => {
const handle = await runtime.ensureSession({ sessionKey: "paperclip-session-key", agent: "claude", mode: "persistent", cwd: dir });
expect(handle.backendSessionId).toBe("abandoned-0");
const result = await prompt(runtime, handle);
expect(result.status).toBe("failed");
expect(JSON.stringify(result)).toMatch(/SESSION_RESUME_REQUIRED|could not be resumed|No conversation found/);
expect(methods).toContain("session/load");
expect(methods).not.toContain("session/prompt");
});
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it("is replaced by a new provider session after the reset, and the first turn runs", async () => {
const dir = await mkdtemp(join(tmpdir(), "acpx-abandoned-record-"));
try {
await abandonBootstrap(dir);
expect(await resetAbandonedBootstrapRecord(createRuntimeStore({ stateDir: join(dir, "state") }), "paperclip-session-key")).toBe(true);
await withRuntime(dir, "retry-", async ({ runtime, methods }) => {
const handle = await runtime.ensureSession({ sessionKey: "paperclip-session-key", agent: "claude", mode: "persistent", cwd: dir });
expect(handle.backendSessionId).toBe("retry-0");
expect(await prompt(runtime, handle)).toMatchObject({ status: "completed" });
expect(methods).toContain("session/new");
expect(methods).not.toContain("session/load");
expect(methods).toContain("session/prompt");
await runtime.close({ handle, reason: "fixture cleanup" });
});
// The used session now holds a conversation, so a later reset leaves it alone.
expect(await resetAbandonedBootstrapRecord(createRuntimeStore({ stateDir: join(dir, "state") }), "paperclip-session-key")).toBe(false);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});
@@ -12,13 +12,14 @@ import type {
AcpRuntime,
AcpRuntimeHandle,
AcpRuntimeOptions,
AcpSessionRecord,
AcpSessionStore,
} from "acpx/runtime";
import { decodeAcpxRuntimeHandleState } from "acpx/runtime";
import { describe, expect, it, vi } from "vitest";
import type { VerifiedAcpxCommandLease } from "./installation-integrity.js";
import { claudeExitPlanModeProposal, openCodexAcpxRuntime } from "./codex-runtime-adapter.js";
import { acpxSessionHandshakeTimeoutMs, claudeExitPlanModeProposal, openCodexAcpxRuntime, resetAbandonedBootstrapRecord } from "./codex-runtime-adapter.js";
import { cursorInstructionBinding } from "./cursor-instructions.js";
import { persistedAcpxTurnUsage } from "./usage-accounting.js";
import { createAcpxCommandLeaseOwner } from "./command-lease-owner.js";
@@ -2510,6 +2511,101 @@ describe("Codex ACPX runtime adapter", () => {
}
});
it.each([
{ agent: "claude" as const, admitted: true },
{ agent: "codex" as const, admitted: false },
])("bounds a 45 s cold $agent session handshake by the agent's admission budget", async ({ agent, admitted }) => {
expect(acpxSessionHandshakeTimeoutMs(agent)).toBe(admitted ? 60_000 : 30_000);
// Pi shares Claude's budget; its admission needs Pi-only options, so only the bound is checked here.
expect(acpxSessionHandshakeTimeoutMs("pi")).toBe(60_000);
vi.useFakeTimers();
try {
const child = fakeChild();
const command = fakeCommand();
vi.mocked(command.spawn).mockReturnValue(child);
const runtime = fakeRuntime();
const options = openOptions(command);
const opening = openCodexAcpxRuntime({ ...options, profile: { ...options.profile, agent } }, {
createRegistry: () => registry(),
createStore: () => store(),
awaitProviderOwnership: providerOwnershipEstablished,
awaitProviderExit: providerOwnershipEstablished,
createRuntime: (runtimeOptions) => {
vi.mocked(runtime.ensureSession).mockImplementation(async () => {
runtimeOptions.spawnAgent?.({ command: "ignored", args: ["--stdio"], options: {} });
await new Promise((resolve) => setTimeout(resolve, 45_000));
return HANDLE;
});
return runtime;
},
});
const settled = opening.then(() => "admitted", (error: unknown) => error instanceof Error ? error.message : String(error));
await vi.advanceTimersByTimeAsync(46_000);
const outcome = await settled;
if (admitted) expect(outcome).toBe("admitted");
else expect(outcome).toContain("handshake exceeded its admission deadline");
} finally {
vi.useRealTimers();
}
});
describe("a record left by a bootstrap that stopped before any prompt", () => {
const savedRecord = (overrides: Partial<AcpSessionRecord> = {}): AcpSessionRecord => ({
schema: "acpx.session.v1", acpxRecordId: "provider-key", acpSessionId: "never-prompted", agentCommand: "agent",
cwd: "/workspace", name: "provider-key", createdAt: "2026-11-07T00:32:24.053Z", lastUsedAt: "2026-11-07T00:32:24.053Z",
lastSeq: 0, eventLog: {} as AcpSessionRecord["eventLog"], messages: [], updated_at: "2026-11-07T00:32:24.053Z",
cumulative_token_usage: {}, request_token_usage: {}, acpx: { current_model_id: "claude-sonnet-5" },
...overrides,
}) as AcpSessionRecord;
it("asks ACPX for a new session instead of reusing a never-prompted record", async () => {
const sessions = store();
vi.mocked(sessions.load).mockResolvedValue(savedRecord());
expect(await resetAbandonedBootstrapRecord(sessions, "provider-key")).toBe(true);
expect(sessions.save).toHaveBeenCalledWith(expect.objectContaining({
acpSessionId: "never-prompted", acpx: { current_model_id: "claude-sonnet-5", reset_on_next_ensure: true },
}));
});
it.each([
["holds a conversation", { messages: [{ User: { id: "m", content: [] } }] as unknown as AcpSessionRecord["messages"] }],
["recorded a prompt", { lastPromptAt: "2026-11-07T00:33:09.553Z" }],
["recorded provider events", { lastSeq: 3 }],
])("keeps a record that %s", async (_label, overrides) => {
const sessions = store();
vi.mocked(sessions.load).mockResolvedValue(savedRecord(overrides));
expect(await resetAbandonedBootstrapRecord(sessions, "provider-key")).toBe(false);
expect(sessions.save).not.toHaveBeenCalled();
});
it.each([false, true])("resets it before a fresh open only (restoring=%s)", async (restoring) => {
const sessions = store();
vi.mocked(sessions.load).mockResolvedValue(savedRecord());
const child = fakeChild();
const command = fakeCommand();
vi.mocked(command.spawn).mockReturnValue(child);
const runtime = fakeRuntime();
const order: string[] = [];
vi.mocked(sessions.save).mockImplementation(async () => { order.push("save"); });
const port = await openCodexAcpxRuntime({ ...openOptions(command), restoringSession: restoring }, {
createRegistry: () => registry(),
createStore: () => sessions,
awaitProviderOwnership: providerOwnershipEstablished,
awaitProviderExit: providerOwnershipEstablished,
createRuntime: (runtimeOptions) => {
vi.mocked(runtime.ensureSession).mockImplementation(async () => {
order.push("ensure");
runtimeOptions.spawnAgent?.({ command: "ignored", args: ["--stdio"], options: {} });
return HANDLE;
});
return runtime;
},
});
expect(order).toEqual(restoring ? ["ensure"] : ["save", "ensure"]);
await port.close({ reason: "test complete" });
});
});
it("terminates a provider spawned before the session handshake rejects", async () => {
const child = fakeChild();
const command = fakeCommand();
@@ -77,6 +77,15 @@ const activeCodexRuntimeCleanupOwners = new Set<Promise<unknown>>();
// qualified runtime enough time to complete that local handshake.
const SESSION_HANDSHAKE_TIMEOUT_MS = 30_000;
/**
* Pi and Claude get the same 60 s cold session.open bound as the runner's
* sidecar and the controller's cold admission budget. A cold Claude handshake
* exceeded 30 s on a loaded host; a shorter inner bound would fail first.
*/
export function acpxSessionHandshakeTimeoutMs(agent: string): number {
return agent === "pi" || agent === "claude" ? 60_000 : SESSION_HANDSHAKE_TIMEOUT_MS;
}
interface GoalAwareAcpRuntime extends AcpRuntime {
requestExtension?(input: {
handle: AcpRuntimeHandle;
@@ -543,6 +552,7 @@ export async function openQualifiedAcpxRuntime(
let handle: AcpRuntimeHandle | null = null;
const ensuredSession = Promise.resolve()
.then(() => options.restoringSession ? undefined : resetAbandonedBootstrapRecord(baseStore, options.providerSessionKey))
.then(() =>
runtime.ensureSession({
sessionKey: options.providerSessionKey,
@@ -647,7 +657,7 @@ export async function openQualifiedAcpxRuntime(
try {
const boundedHandshake = boundedSessionHandshake(
handshake,
dependencies.sessionHandshakeTimeoutMs ?? SESSION_HANDSHAKE_TIMEOUT_MS,
dependencies.sessionHandshakeTimeoutMs ?? acpxSessionHandshakeTimeoutMs(options.profile.agent),
);
handle = options.signal
? await raceRuntimeHandshakeWithAbort(boundedHandshake, options.signal)
@@ -1116,6 +1126,24 @@ function nonEmptyRuntimeIdentity(
return typeof value === "string" && value.length > 0 ? value : undefined;
}
/**
* A fresh open has no runner-recorded provider identity, so a saved record for
* its session key was left by an earlier bootstrap of the same session that
* stopped before any prompt (for example a session.open the controller gave up
* on). Its provider session was never used and may not be resumable: Claude
* saves nothing until the first prompt. Ask ACPX to start a new session instead
* of reusing it. A record that holds any conversation is left unchanged.
*/
export async function resetAbandonedBootstrapRecord(store: AcpSessionStore, sessionKey: string): Promise<boolean> {
const record = await store.load(sessionKey);
if (!record || record.acpx?.reset_on_next_ensure === true) return false;
// Only a record that provably holds no conversation qualifies.
if (!Array.isArray(record.messages) || record.messages.length > 0 || record.lastSeq !== 0 ||
record.lastPromptAt !== undefined) return false;
await store.save({ ...record, acpx: { ...record.acpx, reset_on_next_ensure: true } });
return true;
}
async function boundedSessionHandshake(
handshake: Promise<AcpRuntimeHandle>,
timeoutMs: number,
@@ -6183,13 +6183,17 @@ describe("cold process admission budget", () => {
it("bounds Pi cold startup and recovery at 60 seconds", () => {
expect(coldAdmissionTimeoutMs("acpx", "pi", true)).toBe(60_000);
});
it("bounds a cold ACPX Claude session.open at 60 seconds", () => {
expect(coldAdmissionTimeoutMs("acpx", "claude", true)).toBe(60_000);
expect(coldAdmissionTimeoutMs("acpx", "claude", false)).toBe(30_000);
});
it("bounds a cold OpenCode restore at 60 seconds", () => {
expect(coldAdmissionTimeoutMs("opencode", undefined, true)).toBe(60_000);
});
it("preserves live adoption and every other provider at 30 seconds", () => {
expect(coldAdmissionTimeoutMs("acpx", "pi", false)).toBe(30_000);
expect(coldAdmissionTimeoutMs("opencode", undefined, false)).toBe(30_000);
for (const agent of ["codex", "claude", "cursor", "copilot", "grok", undefined]) {
for (const agent of ["codex", "cursor", "copilot", "grok", undefined]) {
expect(coldAdmissionTimeoutMs("acpx", agent, true)).toBe(30_000);
}
for (const provider of ["codex", "cursor_cloud", undefined]) {
@@ -8502,10 +8502,15 @@ export function resolveRunnerdCodexSkillInputs(
});
}
/** Controller admission budget; ordinary command and turn deadlines are independent. */
/**
* Controller admission budget; ordinary command and turn deadlines are independent.
* A cold ACPX Claude session.open timed out at the old 30 s bound on a loaded
* host (1-minute load 17-43), so it shares Pi's and OpenCode's 60 s cold budget.
* The runner's sidecar session.open bound matches.
*/
export function coldAdmissionTimeoutMs(provider: string | undefined, agent: string | undefined, coldProcess: boolean): number {
if (!coldProcess) return 30_000;
return (provider === "acpx" && agent === "pi") || provider === "opencode" ? 60_000 : 30_000;
return (provider === "acpx" && (agent === "pi" || agent === "claude")) || provider === "opencode" ? 60_000 : 30_000;
}
export interface RunnerCommandFailure extends Error {
@@ -566,6 +566,7 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
"provider_transport_failed",
"native_runner_process_exited",
"provider_session_open_rejected",
"provider_session_open_timeout",
"local_runtime_api_listener_unavailable",
];
if (
@@ -19108,6 +19108,25 @@ it("classifies only a definite session.open outcome as a bounded pre-session fai
expect(nativeSessionFailureSourceCode(new Error(`PRP command session.open indeterminate: ${modelDetail}`)))
.toBe("native_session_interrupted");
});
it("classifies a startup session.open timeout as a bounded transient startup failure", () => {
for (const startupCode of ["runner_local_connect_failed", "runner_direct_wss_failed", "runner_ingress_unavailable"]) {
const code = nativeSessionFailureSourceCode(new Error(`${startupCode}: PRP command session.open timed out`));
expect(code).toBe("provider_session_open_timeout");
expect(nativeSessionFailureDisposition(1, new Date(), code)).toMatchObject({ phase: "retryable_failure" });
expect(nativeSessionFailureDisposition(3, new Date(), code)).toMatchObject({
phase: "terminal_failure", failureCode: "native_session_retry_exhausted", nextAttemptAt: null,
});
}
// After startup completed, or for any other command, the provider may have done work.
expect(nativeSessionFailureSourceCode(new Error("provider_transport_failed: PRP command session.open timed out")))
.toBe("provider_transport_failed");
for (const message of [
"runner_local_connect_failed: PRP command turn.start timed out",
"runner_local_connect_failed: PRP command session.open timed out after turn",
]) {
expect(nativeSessionFailureSourceCode(new Error(message))).toBe("native_session_interrupted");
}
});
describe("HTTP Stop during remote runner staging", () => {
it("does not dispatch after Stop and bounded bootstrap cleanup", async () => {
const { createCapabilityRunnerdCodexTransport } = await import("@paperclipai/paperclip-runner/live");
@@ -2119,7 +2119,7 @@ function runnerdStateBase(): string {
);
}
function scopedRunnerdStateRoot(execution: NativeExecutionInput): string {
export function scopedRunnerdStateRoot(execution: NativeExecutionInput): string {
// Antigravity's first release owns a fresh per-turn daemon. Its native
// conversation is stored separately and may load under each new run's grant.
const scope = execution.provider.kind === "acpx" && execution.provider.agent === "antigravity"
@@ -6075,6 +6075,25 @@ export async function restoreRemoteBootstrapCheckpointForRecovery(input: {
await restoreNativeRunnerCheckpoint(input.db, input.execution.binding, input.root, nativeSessionKey(input.execution));
}
/**
* A session.open timeout leaves an indeterminate root: the runner may have
* opened a provider session after the controller stopped waiting. A same-run
* bootstrap retry may start only from an empty root. A local root that proves
* no turn began (the restart-recovery proof) is quarantined; any other root,
* and any remote runner, keeps the run fail-closed.
*/
export function prepareNativeSessionOpenTimeoutRetry(execution: NativeExecutionInput, localRunner: boolean): boolean {
if (!localRunner) return false;
const root = scopedRunnerdStateRoot(execution);
if (!lstatSync(root, { throwIfNoEntry: false })) return true;
if (!isSafeNativeStateDirectory(root)) return false;
const identity = readRunnerdDurableIdentity(root);
if (!identity || identity.runId !== execution.binding.runId) return false;
if (!runnerdStateProvesIncompleteBootstrap(root, { localProcesses: true })) return false;
quarantineRunnerdStateRoot(root, "identity_indeterminate");
return true;
}
export function runnerdStateProvesIncompleteBootstrap(
root: string,
options: { localProcesses: boolean } = { localProcesses: false },
@@ -7455,6 +7474,7 @@ export function nativeSessionFailureSourceCode(
| typeof NATIVE_CURRENT_WAKE_COMMENTS_UNREAD
| typeof NATIVE_CURRENT_WAKE_COMMENTS_CHANGED_AFTER_READ
| "provider_session_open_rejected"
| "provider_session_open_timeout"
| "runner_ingress_access_rejected"
| "runner_ingress_acquisition_failed"
| "local_runtime_api_listener_unavailable"
@@ -7562,6 +7582,14 @@ export function nativeSessionFailureSourceCode(
}
return "provider_session_open_rejected";
}
// The controller stopped waiting for session.open while runner startup was
// still incomplete: the transport reports its startup failure code only
// before startup completes, and session.open precedes every prompt. The
// outcome is unknown, so durable evidence (no provider event, no checkpoint)
// still decides whether a same-run bootstrap retry is safe.
if (/^(?:runner_local_connect_failed|runner_direct_wss_failed|runner_ingress_unavailable): PRP command session\.open timed out$/.test(message)) {
return "provider_session_open_timeout";
}
// This process has no HTTP listener (graceful shutdown closes it first), so
// the control plane cannot hand a runner its callback origin. The lookup runs
// before any runner or provider process is created.
@@ -7684,6 +7712,7 @@ export async function nativeProviderRecoveryEvidence(input: {
"provider_request_protocol_error",
"native_runner_process_exited",
"provider_session_open_rejected",
"provider_session_open_timeout",
"local_runtime_api_listener_unavailable",
]).has(input.sourceFailureCode);
if (!checkpointExists && !providerEventsExist && (definitelyPreSession
@@ -10940,7 +10969,13 @@ async function executePaperclipNativeSessionWithinScope(
throw error;
}
const now = new Date();
const classifiedFailureCode = nativeSessionFailureSourceCode(error);
const startupClassifiedFailureCode = nativeSessionFailureSourceCode(error);
// A timed-out session.open may still have opened a provider session
// before its runner stopped. The same-run retry must not reuse it.
const classifiedFailureCode = startupClassifiedFailureCode === "provider_session_open_timeout" &&
!prepareNativeSessionOpenTimeoutRetry(input.execution, input.runnerExecutionTarget?.kind !== "remote")
? "native_session_interrupted" as const
: startupClassifiedFailureCode;
const sourceFailureCode =
classifiedFailureCode === "native_event_replay_conflict"
? classifiedFailureCode
@@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto";
import { mkdtempSync, rmSync } from "node:fs";
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { basename, join } from "node:path";
import { and, eq, sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
@@ -28,7 +29,7 @@ vi.mock("../../vendor/paperclip-runner/index.js", async (importOriginal) => ({
executeNativeSession: provider.execute,
}));
import { executePaperclipNativeSession } from "./native-session-executor.js";
import { executePaperclipNativeSession, prepareNativeSessionOpenTimeoutRetry, scopedRunnerdStateRoot } 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";
@@ -480,6 +481,187 @@ describe("native provider startup retry", () => {
expect(state.issue.status).toBe("in_progress");
});
it.each(["runner_local_connect_failed", "runner_direct_wss_failed", "runner_ingress_unavailable"])(
"retries a %s session.open startup timeout on the same run within the bounded budget",
async (startupFailureCode) => {
const seeded = await seedRun(await createAgent());
// The controller stopped waiting for session.open during startup, before
// any provider identity, event, checkpoint, or prompt existed.
const timeout = () => new Error(`${startupFailureCode}: PRP command session.open timed out`);
const firstFailedAt = await failStartup(seeded, timeout());
let state = await read(seeded);
expect(state.coordinator).toMatchObject({
phase: "retryable_failure",
recoveryState: "resuming_session",
attempt: 1,
failureDetail: expect.objectContaining({
originalFailureCode: "provider_session_open_timeout",
recoveryMode: "bootstrap_retry",
providerSessionEstablished: false,
providerEventsExist: false,
checkpointExists: false,
}),
});
const [low, high] = backoffWindow(firstFailedAt, 1);
expect(state.coordinator.nextAttemptAt!.getTime()).toBeGreaterThanOrEqual(low);
expect(state.coordinator.nextAttemptAt!.getTime()).toBeLessThanOrEqual(high + 5_000);
expect(state.issue.status).toBe("in_progress");
expect(state.issue.executionRunId).toBe(seeded.runId);
expect(state.action).toMatchObject({
ownerType: "agent",
wakePolicy: { kind: "resume_native_run", runId: seeded.runId },
});
await failStartup(seeded, timeout(), { leaseOwner: await claimRetry(seeded) });
state = await read(seeded);
expect(state.coordinator).toMatchObject({ phase: "retryable_failure", attempt: 2 });
expect(state.issue.status).toBe("in_progress");
// At most two automatic attempts follow the first; then a person decides.
await failStartup(seeded, timeout(), { leaseOwner: await claimRetry(seeded) });
state = await read(seeded);
expect(state.coordinator).toMatchObject({
phase: "terminal_failure",
failureCode: "native_session_retry_exhausted",
attempt: 3,
nextAttemptAt: null,
failureDetail: expect.objectContaining({ originalFailureCode: "provider_session_open_timeout", recoveryMode: "bootstrap_retry" }),
});
expect(state.issue.status).toBe("blocked");
expect(state.action).toMatchObject({ ownerType: "board", cause: "native_session_retry_exhausted", wakePolicy: null });
},
);
it.each([
{ name: "the provider session started before the timeout settled", eventType: "session.started", message: "runner_local_connect_failed: PRP command session.open timed out" },
{ name: "a provider turn started", eventType: "turn.started", message: "runner_local_connect_failed: PRP command session.open timed out" },
{ name: "the timeout came after startup completed", eventType: null, message: "provider_transport_failed: PRP command session.open timed out" },
{ name: "a different startup command timed out", eventType: null, message: "runner_local_connect_failed: PRP command turn.start timed out" },
])("never retries a timeout automatically when $name", async ({ eventType, message }) => {
const seeded = await seedRun(await createAgent());
provider.execute.mockReset().mockImplementation(async () => {
if (eventType) {
await db.insert(heartbeatRunEvents).values({
companyId, runId: seeded.runId, agentId: seeded.agentId, seq: 900, eventType, stream: "system",
});
}
throw new Error(message);
});
await expect(executePaperclipNativeSession({
db,
execution: seeded.execution,
runnerInstanceId: seeded.runnerInstanceId,
backend: {
descriptor: async () => { throw new Error("Unexpected provider descriptor call"); },
openSession: async () => { throw new Error("Unexpected provider session call"); },
},
})).rejects.toThrow("timed out");
const state = await read(seeded);
expect(state.coordinator).toMatchObject({
phase: "terminal_failure",
nextAttemptAt: null,
failureDetail: expect.objectContaining({ recoveryMode: "ambiguous_state" }),
});
expect(state.issue.status).toBe("blocked");
expect(await claimNativeSessionResumptions({
db,
runnerInstanceId: "startup-retry-test",
runIds: [seeded.runId],
now: new Date(Date.now() + 60 * 60_000),
deferIneligible: false,
})).toEqual([]);
});
describe("state left by a timed-out session.open", () => {
let stateBase: string;
let previousStateDir: string | undefined;
beforeAll(() => {
previousStateDir = process.env.PAPERCLIP_RUNNER_STATE_DIR;
stateBase = mkdtempSync(join(tmpdir(), "native-startup-retry-state-"));
process.env.PAPERCLIP_RUNNER_STATE_DIR = stateBase;
});
afterAll(() => {
if (previousStateDir === undefined) delete process.env.PAPERCLIP_RUNNER_STATE_DIR;
else process.env.PAPERCLIP_RUNNER_STATE_DIR = previousStateDir;
rmSync(stateBase, { recursive: true, force: true });
});
const deadPid = async () => {
const child = spawn(process.execPath, ["-e", ""], { stdio: "ignore" });
await new Promise(resolveExit => child.once("exit", resolveExit));
return child.pid!;
};
const event = (sourceSeq: number, eventType: string, payload: Record<string, unknown> = {}) => ({
sourceSeq, eventType, priority: 0, envelope: { kind: "event", payload: { eventType, payload } },
});
/** The runner opened a fresh provider session after the controller stopped waiting, then stopped. */
async function writeOpenedSessionRoot(seeded: Seeded, options: { turnIssued?: boolean } = {}) {
const pid = await deadPid();
const root = scopedRunnerdStateRoot(seeded.execution);
mkdirSync(join(root, "control-plane"), { recursive: true });
mkdirSync(join(root, "runner"), { recursive: true });
writeFileSync(join(root, "control-plane", "control-plane-state.json"), JSON.stringify({
schema: "paperclip.runner.durable.control-plane-state.v2",
identity: { runId: seeded.runId, normalizedSessionId: seeded.execution.binding.runId },
bootstrapHistory: { schema: "paperclip.runner.bootstrap-history.v1", credentialsIssued: 1, adopted: false },
commands: [
{ type: "run.prepare", status: "completed", result: { result: { status: "prepared" } } },
{ type: "session.open", status: "completed", result: { commandType: "session.open", status: "completed",
result: { driver: "acpx_runtime", processId: pid, provider: "acpx", status: "started" } } },
{ type: "runner.drain", status: "completed" },
{ type: "runner.suspend", status: "completed" },
...(options.turnIssued ? [{ type: "turn.start", status: "pending" }] : []),
],
committedEvents: [event(1, "session.started", { processId: pid }), event(2, "session.capabilities.updated")],
ackedSourceSeq: 2,
connectionCount: 1,
}));
writeFileSync(join(root, "runner", "runner-state.json"), JSON.stringify({
schema: "paperclip.runner.durable.state.v1", lifecycle: "suspended", nextSourceSeq: 3, ackedSourceSeq: 2, outbox: [],
processedCommands: {
command_prp_00000001: { commandType: "run.prepare", status: "completed" },
command_prp_00000002: { commandType: "session.open", status: "completed", result: { processId: pid, status: "started" } },
},
}));
return root;
}
const timeout = () => new Error("runner_local_connect_failed: PRP command session.open timed out");
it("quarantines an opened session that no turn used, so the same-run retry starts fresh", async () => {
const seeded = await seedRun(await createAgent());
const root = await writeOpenedSessionRoot(seeded);
await failStartup(seeded, timeout());
const state = await read(seeded);
expect(state.coordinator).toMatchObject({
phase: "retryable_failure",
failureDetail: expect.objectContaining({ originalFailureCode: "provider_session_open_timeout", recoveryMode: "bootstrap_retry" }),
});
expect(existsSync(root)).toBe(false);
expect(readdirSync(join(stateBase, "quarantine")).some(name => name.startsWith(`${basename(root)}.identity_indeterminate.`))).toBe(true);
});
it("keeps the run operator-owned and the root intact when a turn may have started", async () => {
const seeded = await seedRun(await createAgent());
const root = await writeOpenedSessionRoot(seeded, { turnIssued: true });
await failStartup(seeded, timeout());
const state = await read(seeded);
expect(state.coordinator).toMatchObject({
phase: "terminal_failure",
nextAttemptAt: null,
failureDetail: expect.objectContaining({ originalFailureCode: "native_session_interrupted", recoveryMode: "ambiguous_state" }),
});
expect(existsSync(root)).toBe(true);
});
it("never prepares a remote runner's timeout for an automatic retry", async () => {
const seeded = await seedRun(await createAgent());
expect(prepareNativeSessionOpenTimeoutRetry(seeded.execution, false)).toBe(false);
expect(prepareNativeSessionOpenTimeoutRetry(seeded.execution, true)).toBe(true);
});
});
it("retries startup that a draining server interrupted before any provider session", async () => {
const seeded = await seedRun(await createAgent());
// A graceful shutdown closes the HTTP listener first; startup that reaches