mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 06:16:06 -10:00
fix(native): ask for the result once after a restart continuation ends without one (#19771)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Native runs continue a turn that a control-plane restart lost, in the same conversation (#19501, #19699) > - In restart storms 5, 6b and 8, ACPX Claude runs crossed restarts, ran the continuation, and then failed with `native_session_retry_exhausted` > - The continuation completed, but Claude ended it with "I'll wait for the notification when it completes" for a background command, and did not call `paperclip_finish` > - The next same-run attempt then sent no turn at all and failed with "session returned no semantic result" > - This pull request makes the continuation ask for the result, and asks once more in the same attempt if the continuation still ends without one > - The benefit is that a restart-interrupted task settles without a Retry, and the restarts that the run survived cannot block the request ## Linked Issues or Issue Description Refs #19501, #19699, #19720 **What happened?** Storm 8, run 84c532f7 and its Retry 0faead66 (ACPX Claude, task CHA-5). The steps are the same in both runs: 1. `resume_dead_runner`, then `session.resumed`, then `turn.failed` with `provider_turn_lost_on_restore` (recoverable). 2. The restart continuation runs. Claude starts the bench in the background and ends the turn with "I'll wait for the notification when it completes". The turn completes with no `paperclip_finish`. 3. Lifecycle: "provider turn completed without a semantic result; same-run disposition recovery persisted" (attempt 2). 4. 30 s later, attempt 3 restores the session. It sends no turn. After about 2 s it fails with `native_finalization_missing: session returned no semantic result`, and the attempt count makes it `native_session_retry_exhausted`. **Expected behavior** The continuation tells the agent to finish and report its result. If the continuation still completes without a result, Paperclip asks for the disposition one time. Restarts that the run already survived do not block that request. An agent that never reports a result still fails closed. **Root cause** - `packages/paperclip-runner/src/drivers/codex/codex-harness-session.ts` `startTurn` (lines 224 and 248): the restart continuation uses the driver's one-shot disposition marker (`dispositionOnlyRecoveryAvailable`). #19699 uses the marker on purpose, so that a crash can adopt the accepted continuation turn. The marker then stays consumed with the continuation turn id. - `packages/paperclip-runner/src/drivers/codex/codex-session-terminal.ts` (line 226): only a *lost* continuation gives the marker back. A *completed* continuation with no result does not. - `packages/paperclip-runner/src/native-session-runtime.ts` (`dispositionRecoveryWasSubmitted`, about line 2761): on the next attempt, the consumed marker looks like a disposition request that already ran. Recovery replays the continuation's terminal and starts no turn. So the disposition request never happens. - `server/src/services/native-runtime/native-session-executor.ts` (lines 9755-9761 and `nativeSessionFailureDisposition` at 7768): the `semantic_result_missing` same-run retry charges a provider attempt on the same counter as restart resumes. In this run, attempt 3 was the last allowed attempt. - The continuation prompt (`restartContinuation`) did not tell the agent to report a result, or not to stop and wait for a background notification. ## What Changed - Codex-protocol driver (used by Codex and by ACPX through runnerd): when a restart continuation completes without a semantic result, the driver gives back the one-shot disposition marker. The disposition request is not a continuation, so it consumes the marker for good. - Native session runtime: after that, the same attempt sends one disposition request (`Paperclip semantic-result recovery after a restart continuation ... invoke exactly one paperclip_finish or paperclip_block`). It does this only when the driver shows that the continuation borrowed the marker and gave it back. Other drivers keep the current behavior. - Restart continuation prompt: it now says to finish in this turn, not to end the turn to wait for a background command or a notification, and to report one `paperclip_finish` or `paperclip_block`. - `doc/execution-semantics.md`: one additive paragraph. - No server change. The disposition request runs in the same attempt, so it uses no provider attempt. This avoids `native-restart-recovery.ts` (#19747). ## Verification Red before the fix, green after: - `codex-app-server-driver.recovery.test.ts` "keeps one disposition request after a restart continuation completes without a result". Red: marker stays consumed on `turn-2`. Green: the marker is given back, one disposition turn starts, and a resultless disposition turn does not re-arm. - `native-session-runtime.test.ts` "settles a restart continuation that ended without a result through one disposition turn (Codex driver)". Real Codex-protocol driver with a fake transport. Red: only one `turn/start`, then `native_finalization_missing`. Green: the disposition turn `turn-3` reports the result and the run completes. - `native-session-runtime.test.ts` "asks once for the disposition when a restart continuation ends without a result" and "fails closed when the driver keeps the continuation's disposition marker". Also run: - `npx vitest run src/native-session-runtime.test.ts src/drivers/codex/` in `packages/paperclip-runner`: 593 passed. - `src/live/runnerd-restart-continuation.test.ts` (real runnerd and fake Codex, killed runner): 5 passed. - `tsc --noEmit` for `packages/paperclip-runner`. - `pnpm verify` exits 2 because the doc change is a global CI input. Required CI supplies the broad coverage. Not run: a live replay of storm 8 t05 with Claude. The chaos harness needs provider keys from the operator's shell. ## Risks - Low to medium. A run whose continuation ends without a result now gets one more provider turn in the same attempt. The bound is one disposition request per continuation. Each continuation still needs an admitted restart, and the provider-attempt budget bounds those. - If the controller crashes after the continuation completes but before its checkpoint, the checkpoint keeps the stricter consumed marker. That case fails closed as before. - The prompt text changes for restart continuations. It does not change a protocol schema or a profile digest. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, through Claude Code with tool use (shell, file edits, code 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 - [ ] All Paperclip CI gates are green - [ ] 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 <[email protected]>
This commit is contained in:
1 parent
fff70fe49e
commit
ef83d566ca
10 files changed
+370
-10
No files matched your search
@@ -2641,6 +2641,16 @@ waits for that start, like a terminal, instead of failing the new turn. If this
|
||||
session's own protocol failure ends a pending turn start, the run reports
|
||||
`codex_turn_start_protocol_failure` with the cause instead of a runner exit.
|
||||
|
||||
The continuation tells the agent to finish in that turn, not to end it while
|
||||
waiting for a background command or a notification, and to report one
|
||||
`paperclip_finish` or `paperclip_block`. The continuation only borrows the
|
||||
one-shot disposition marker so a crash can adopt its accepted turn. If the
|
||||
continuation completes without a semantic result, the driver returns the marker
|
||||
and the same attempt sends one disposition request. That request does not use
|
||||
another provider attempt, so restarts the run already survived cannot block it.
|
||||
A disposition request that also ends without a result consumes the marker and
|
||||
fails closed as before.
|
||||
|
||||
Provider execution and control-plane finalization have different clocks. A healthy provider can think or execute a long tool without output. Once execution settles, recovery and finalization control steps have a 60-second deadline, checked on startup and every 15 seconds. With a healthy database and scheduler, an abandoned transition must be repaired or surfaced within 90 seconds. Terminal persistence must not wait on provider cleanup or publication; a late finalizer cannot change a reassigned or closed task or release another run's locks. In particular, a done report from a run whose task now belongs to another agent, a user or nobody is kept as an accepted result but records `completion_owner_superseded` and preserves the task status for the current owner. Historical ambiguous runs are never automatically replayed after an upgrade.
|
||||
|
||||
When that deadline fails a native run with a prepared workspace, the same
|
||||
|
||||
@@ -582,6 +582,12 @@ export interface HarnessSession {
|
||||
}): Promise<{
|
||||
turnId: string;
|
||||
effectiveCollaborationMode?: "default" | "plan";
|
||||
/**
|
||||
* This restart continuation borrowed the driver's one-shot disposition
|
||||
* marker. If it completes without a result the driver returns the marker,
|
||||
* and orchestration may send one disposition request in the same attempt.
|
||||
*/
|
||||
borrowedDispositionMarker?: true;
|
||||
}>;
|
||||
steer?(input: {
|
||||
/** Queued follow-ups remain distinct from active-turn steering. */
|
||||
|
||||
@@ -224,6 +224,12 @@ export interface NativeSession {
|
||||
}): Promise<{
|
||||
turnId: string;
|
||||
effectiveCollaborationMode?: "default" | "plan";
|
||||
/**
|
||||
* This restart continuation borrowed the driver's one-shot disposition
|
||||
* marker. If it completes without a result the driver returns the marker,
|
||||
* and orchestration may send one disposition request in the same attempt.
|
||||
*/
|
||||
borrowedDispositionMarker?: true;
|
||||
}>;
|
||||
steer?(input: {
|
||||
mode?: "steer" | "follow_up";
|
||||
|
||||
@@ -63,6 +63,40 @@ function cursorProviderIdentity(mode: unknown) {
|
||||
}
|
||||
|
||||
describe("Codex app-server Codex driver", () => {
|
||||
it("keeps one disposition request after a restart continuation completes without a result", async () => {
|
||||
const transport = new FakeCodexTransport();
|
||||
const session = await makeDriver([transport]).openSession({
|
||||
runId: "run-resultless-continuation", normalizedSessionId: "session-resultless-continuation", workingDirectory: WORKSPACE,
|
||||
});
|
||||
const lost = { code: "provider_turn_lost_on_restore", recoverable: true };
|
||||
await session.startTurn({ message: { role: "user", text: "Finish the request." } });
|
||||
transport.push("turn/completed", { threadId: "thread-1", turn: { id: "turn-1", status: "failed", error: lost, items: [] } });
|
||||
await collectUntilTerminal(session.events());
|
||||
transport.turnStartResponse = Promise.resolve({ turn: { id: "turn-2", status: "inProgress", items: [] } });
|
||||
await session.startTurn({ message: { role: "user", text: "Continue." }, continuation: true });
|
||||
// The provider ends the continuation without paperclip_finish, for
|
||||
// example while it says it will wait for a background command.
|
||||
transport.push("turn/completed", { threadId: "thread-1", turn: { id: "turn-2", status: "completed", items: [] } });
|
||||
await collectUntilTerminal(session.events());
|
||||
const afterContinuation = await session.snapshot();
|
||||
// The continuation borrowed the one-shot marker for crash adoption; it was
|
||||
// not the disposition request, so the allowance is still available.
|
||||
expect(afterContinuation).toMatchObject({ activeTurnId: null, semanticResult: null,
|
||||
dispositionOnlyRecoveryConsumed: false, dispositionOnlyRecoveryTurnId: null });
|
||||
expect(nativeRestartInterruptedTurnId({ ...afterContinuation, semanticResult: null })).toBeNull();
|
||||
transport.turnStartResponse = Promise.resolve({ turn: { id: "turn-3", status: "inProgress", items: [] } });
|
||||
await expect(session.startTurn({ message: { role: "user", text: "Report the disposition." } }))
|
||||
.resolves.toMatchObject({ turnId: "turn-3" });
|
||||
expect(await session.snapshot()).toMatchObject({ dispositionOnlyRecoveryConsumed: true, dispositionOnlyRecoveryTurnId: "turn-3" });
|
||||
// The disposition request itself is the bound: a resultless answer to it
|
||||
// does not re-arm another request.
|
||||
transport.push("turn/completed", { threadId: "thread-1", turn: { id: "turn-3", status: "completed", items: [] } });
|
||||
await collectUntilTerminal(session.events());
|
||||
expect(await session.snapshot()).toMatchObject({ dispositionOnlyRecoveryConsumed: true, dispositionOnlyRecoveryTurnId: "turn-3" });
|
||||
await expect(session.startTurn({ message: { role: "user", text: "Again." } })).rejects.toThrow();
|
||||
await session.close({ reason: "fixture complete" });
|
||||
});
|
||||
|
||||
it("offers another restart continuation when a later restart loses the continuation turn itself", async () => {
|
||||
const transport = new FakeCodexTransport();
|
||||
const session = await makeDriver([transport]).openSession({
|
||||
|
||||
@@ -152,6 +152,7 @@ export class CodexHarnessSession
|
||||
this.resultTurnId = null;
|
||||
this.dispositionOnlyRecoveryConsumed = false;
|
||||
this.dispositionOnlyRecoveryTurnId = null;
|
||||
this.restartContinuationTurnId = null;
|
||||
this.terminal = false;
|
||||
this.terminalTurns.clear();
|
||||
this.turnStarted = false;
|
||||
@@ -368,6 +369,7 @@ export class CodexHarnessSession
|
||||
if (dispositionOnlyRecovery) {
|
||||
this.dispositionOnlyRecoveryTurnId = turnId;
|
||||
}
|
||||
this.restartContinuationTurnId = dispositionOnlyRecovery && continuationTurn ? turnId : null;
|
||||
this.emit("turn.accepted", { turnId }, { turnId });
|
||||
if (this.interruptQueued) {
|
||||
this.interruptQueued = false;
|
||||
@@ -376,6 +378,7 @@ export class CodexHarnessSession
|
||||
return {
|
||||
turnId,
|
||||
effectiveCollaborationMode,
|
||||
...(this.restartContinuationTurnId === turnId ? { borrowedDispositionMarker: true as const } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -153,6 +153,14 @@ export class CodexSessionState {
|
||||
dispositionOnlyRecoveryAvailable = false;
|
||||
dispositionOnlyRecoveryConsumed = false;
|
||||
dispositionOnlyRecoveryTurnId: string | null = null;
|
||||
/**
|
||||
* A restart continuation borrows the one-shot disposition marker so a
|
||||
* crash during it can adopt the accepted turn. It is not the disposition
|
||||
* request: if it completes without a result, the allowance is returned.
|
||||
* In memory only; a checkpoint taken during the continuation keeps the
|
||||
* stricter consumed marker.
|
||||
*/
|
||||
restartContinuationTurnId: string | null = null;
|
||||
turnStarted = false;
|
||||
readonly terminalTurns = new Map<string, string>();
|
||||
/**
|
||||
|
||||
@@ -236,6 +236,18 @@ export function mapTerminalTurn(
|
||||
state.dispositionOnlyRecoveryTurnId = null;
|
||||
state.terminal = false;
|
||||
state.dispositionOnlyRecoveryAvailable = true;
|
||||
} else if (eventType === "turn.completed" && state.result === null && state.conversationMode === "task" &&
|
||||
state.currentGoal?.status !== "active" && state.restartContinuationTurnId === turnId &&
|
||||
state.dispositionOnlyRecoveryConsumed && state.dispositionOnlyRecoveryTurnId === turnId) {
|
||||
// The restart continuation only borrowed the marker. It ended without a
|
||||
// result (for example, the agent said it would wait for a background
|
||||
// command), so the one disposition request it displaced is still owed.
|
||||
// That request is not a continuation and consumes the marker for good.
|
||||
state.restartContinuationTurnId = null;
|
||||
state.dispositionOnlyRecoveryConsumed = false;
|
||||
state.dispositionOnlyRecoveryTurnId = null;
|
||||
state.terminal = false;
|
||||
state.dispositionOnlyRecoveryAvailable = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -337,6 +337,57 @@ describe("executeNativeSession recovery", () => {
|
||||
expect(executionInput.workspace).toEqual(originalWorkspace);
|
||||
});
|
||||
|
||||
it("settles a restart continuation that ended without a result through one disposition turn (Codex driver)", async () => {
|
||||
const lost = { code: "provider_turn_lost_on_restore", recoverable: true };
|
||||
const originalTransport = new FakeCodexTransport();
|
||||
const recoveredTransport = new FakeCodexTransport();
|
||||
const transports = [originalTransport, recoveredTransport];
|
||||
const executionInput = { ...input, workspace: { ...input.workspace, cwd: WORKSPACE } };
|
||||
const backend = createCodexNativeSessionBackend(executionInput, {
|
||||
runnerInstanceId: "runner-recovery",
|
||||
transportFactory: () => transports.shift()!,
|
||||
});
|
||||
const original = await backend.openSession({ identity, workingDirectory: WORKSPACE });
|
||||
await original.startTurn({ message: { role: "user", text: "Finish the assigned review." } });
|
||||
const checkpoint = await original.snapshot();
|
||||
expect(checkpoint).toMatchObject({ activeTurnId: "turn-1", semanticResult: null });
|
||||
await original.close({ reason: "controller lost during the turn" });
|
||||
recoveredTransport.readResponse = { thread: { id: "thread-1", sessionId: "provider-session-1", cwd: WORKSPACE,
|
||||
turns: [{ id: "turn-1", status: "failed", error: lost, items: [] }] } };
|
||||
const turnStarts = () => recoveredTransport.calls.filter(call => call.method === "turn/start");
|
||||
Object.defineProperty(recoveredTransport, "turnStartResponse", {
|
||||
get: () => Promise.resolve({ turn: { id: `turn-${turnStarts().length + 1}`, status: "inProgress", items: [] } }),
|
||||
});
|
||||
const checkpoints: PersistedNativeSession[] = [];
|
||||
const completeRun = vi.fn(async () => undefined);
|
||||
const port: ControlPlanePort = {
|
||||
async openRun() {},
|
||||
async checkpointSession(value) { checkpoints.push(structuredClone(value)); },
|
||||
async appendEvent(event: PrpEvent) { return { cursor: event.sourceSeq, highestContiguousSourceSeq: event.sourceSeq, disposition: "committed" }; },
|
||||
async replayEvents() { return { events: [], highestContiguousSourceSeq: 0 }; },
|
||||
completeRun,
|
||||
};
|
||||
const execution = executeNativeSession({
|
||||
input: executionInput, backend, controlPlane: port, persistedSession: checkpoint,
|
||||
runnerInstanceId: "runner-recovery", controlPlaneInstanceId: "control-recovery", resumeInterruptedTurn: true,
|
||||
});
|
||||
void execution.catch(() => undefined);
|
||||
await vi.waitFor(() => expect(turnStarts()).toHaveLength(1));
|
||||
// Like the RC-60 Claude turns: the continuation ends saying it will wait
|
||||
// for a background command, with no paperclip_finish.
|
||||
recoveredTransport.push("turn/completed", { threadId: "thread-1", turn: { id: "turn-2", status: "completed", items: [] } });
|
||||
await vi.waitFor(() => expect(turnStarts()).toHaveLength(2));
|
||||
const disposition = JSON.stringify(turnStarts()[1]!.params);
|
||||
expect(disposition).toContain("Paperclip semantic-result recovery after a restart continuation");
|
||||
recoveredTransport.push("paperclip/runResult", { threadId: "thread-1", turnId: "turn-3", result });
|
||||
recoveredTransport.push("turn/completed", { threadId: "thread-1", turn: { id: "turn-3", status: "completed", items: [] } });
|
||||
await expect(execution).resolves.toMatchObject({ result, turnId: "turn-3" });
|
||||
expect(completeRun).toHaveBeenCalledOnce();
|
||||
expect(turnStarts()).toHaveLength(2);
|
||||
expect(checkpoints.at(-1)).toMatchObject({ semanticResult: result, dispositionOnlyRecoveryConsumed: true,
|
||||
dispositionOnlyRecoveryTurnId: "turn-3" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
"completed", "pending", "duplicate", "conflicting", "other turn", "newer turn",
|
||||
"wrong session", "invalid", "restore-lost live", "restore-lost read",
|
||||
@@ -584,6 +635,160 @@ describe("executeNativeSession recovery", () => {
|
||||
expect(nativeRestartInterruptedTurnId(interrupted)).toBe("turn-recovery");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "asks once for the disposition when a restart continuation ends without a result", driverReArms: true },
|
||||
{ label: "fails closed when the driver keeps the continuation's disposition marker", driverReArms: false },
|
||||
])("$label", async ({ driverReArms }) => {
|
||||
const failure = { code: "provider_turn_lost_on_restore", recoverable: true };
|
||||
const checkpoint: PersistedNativeSession = {
|
||||
backendKind: "runner", driverKind: "acpx_runtime", sessionId: "record-recovery", identity,
|
||||
providerSessionId: "provider-recovery", cursor: "0", activeTurnId: "turn-recovery",
|
||||
terminalTurns: [], semanticResult: null, pendingRuntimeRequests: [], lineage: [],
|
||||
};
|
||||
const lostTerminal = { turnId: "turn-recovery", fingerprint: JSON.stringify({ terminalState: "failed", error: failure, result: null }) };
|
||||
const completedTerminal = (turnId: string) => ({ turnId, fingerprint: JSON.stringify({ terminalState: "completed", error: null, result: null }) });
|
||||
let starts = 0;
|
||||
const startTurn = vi.fn<NativeSession["startTurn"]>(async () => ++starts === 1
|
||||
? { turnId: "turn-continuation", borrowedDispositionMarker: true as const } : { turnId: "turn-disposition" });
|
||||
let continuationDone = false;
|
||||
const session: NativeSession = {
|
||||
identity: () => identity,
|
||||
async capabilities() { return { resume: true, typedEvents: true, steering: false, interruption: true, structuredResult: true }; },
|
||||
async *events() {
|
||||
yield runnerEvent(1, "turn.failed", { status: "failed", error: failure });
|
||||
if (!startTurn.mock.calls.length) return;
|
||||
// The agent ends the continuation saying it will wait for a
|
||||
// background command, without paperclip_finish.
|
||||
continuationDone = true;
|
||||
yield { ...runnerEvent(2, "turn.completed", { status: "completed", error: null }), turnId: "turn-continuation" };
|
||||
if (startTurn.mock.calls.length < 2) return;
|
||||
yield { ...runnerEvent(3, "turn.completed", { status: "completed", error: null }), turnId: "turn-disposition" };
|
||||
},
|
||||
startTurn,
|
||||
async result() { return startTurn.mock.calls.length >= 2 ? { result, terminal, turnId: "turn-disposition" } : null; },
|
||||
async snapshot() {
|
||||
const calls = startTurn.mock.calls.length;
|
||||
if (calls === 0) return { ...checkpoint, activeTurnId: null, terminalTurns: [lostTerminal] };
|
||||
// The continuation borrows the driver's one-shot marker while it runs.
|
||||
if (calls === 1 && !continuationDone) return { ...checkpoint, activeTurnId: "turn-continuation", terminalTurns: [lostTerminal],
|
||||
dispositionOnlyRecoveryConsumed: true, dispositionOnlyRecoveryTurnId: "turn-continuation" };
|
||||
if (calls === 1) return { ...checkpoint, activeTurnId: null, terminalTurns: [lostTerminal, completedTerminal("turn-continuation")],
|
||||
dispositionOnlyRecoveryConsumed: !driverReArms, dispositionOnlyRecoveryTurnId: driverReArms ? null : "turn-continuation" };
|
||||
return { ...checkpoint, activeTurnId: null, terminalTurns: [lostTerminal, completedTerminal("turn-continuation"), completedTerminal("turn-disposition")],
|
||||
dispositionOnlyRecoveryConsumed: true, dispositionOnlyRecoveryTurnId: "turn-disposition" };
|
||||
},
|
||||
async close() {},
|
||||
};
|
||||
const backend: NativeSessionBackend = {
|
||||
async descriptor() { return { kind: "runner", name: "restart", version: "1", capabilities: await session.capabilities() }; },
|
||||
async openSession() { throw new Error("must preserve the provider session"); },
|
||||
async recoverSession() { return { recovered: true, session }; },
|
||||
};
|
||||
const port: ControlPlanePort = {
|
||||
async openRun() {},
|
||||
async checkpointSession() {},
|
||||
async appendEvent(event: PrpEvent) { return { cursor: event.sourceSeq, highestContiguousSourceSeq: event.sourceSeq, disposition: "committed" }; },
|
||||
async replayEvents() { return { events: [], highestContiguousSourceSeq: 0 }; },
|
||||
async completeRun() {},
|
||||
};
|
||||
const execution = executeNativeSession({
|
||||
input, backend, controlPlane: port, persistedSession: checkpoint,
|
||||
runnerInstanceId: "runner-recovery", controlPlaneInstanceId: "control-recovery",
|
||||
resumeInterruptedTurn: true,
|
||||
});
|
||||
if (!driverReArms) {
|
||||
await expect(execution).rejects.toThrow("native_finalization_missing: session returned no semantic result");
|
||||
expect(startTurn).toHaveBeenCalledOnce();
|
||||
return;
|
||||
}
|
||||
await expect(execution).resolves.toMatchObject({ result, turnId: "turn-disposition" });
|
||||
expect(startTurn).toHaveBeenCalledTimes(2);
|
||||
const continuation = startTurn.mock.calls[0]![0] as Parameters<NativeSession["startTurn"]>[0];
|
||||
expect(continuation.message.text).toContain("paperclip_finish");
|
||||
expect(continuation.message.text).toContain("background");
|
||||
const disposition = startTurn.mock.calls[1]![0] as Parameters<NativeSession["startTurn"]>[0];
|
||||
expect(disposition.continuation).toBeUndefined();
|
||||
expect(disposition.message.text).toContain("Paperclip semantic-result recovery");
|
||||
expect(disposition.message.text).toContain("paperclip_finish or paperclip_block");
|
||||
expect(disposition.message.text).not.toContain(input.task.prompt);
|
||||
});
|
||||
|
||||
it("asks for the disposition when the continuation completes before its start checkpoint settles", async () => {
|
||||
// The checkpoint already records the lost turn, so the continuation starts
|
||||
// while the event consumer is running. The provider completes it at once:
|
||||
// the driver has returned the marker before any post-start snapshot, and
|
||||
// the completion reaches the consumer while the start checkpoint is held.
|
||||
const failure = { code: "provider_turn_lost_on_restore", recoverable: true };
|
||||
const lostTerminal = { turnId: "turn-recovery", fingerprint: JSON.stringify({ terminalState: "failed", error: failure, result: null }) };
|
||||
const completedTerminal = (turnId: string) => ({ turnId, fingerprint: JSON.stringify({ terminalState: "completed", error: null, result: null }) });
|
||||
const checkpoint: PersistedNativeSession = {
|
||||
backendKind: "runner", driverKind: "acpx_runtime", sessionId: "record-recovery", identity,
|
||||
providerSessionId: "provider-recovery", cursor: "0", activeTurnId: null,
|
||||
terminalTurns: [lostTerminal], semanticResult: null, pendingRuntimeRequests: [], lineage: [],
|
||||
};
|
||||
let releaseStartCheckpoint!: () => void;
|
||||
const startCheckpointHeld = new Promise<void>(resolve => { releaseStartCheckpoint = resolve; });
|
||||
let continuationCompletionSeen = false;
|
||||
let starts = 0;
|
||||
const startTurn = vi.fn<NativeSession["startTurn"]>(async () => ++starts === 1
|
||||
? { turnId: "turn-continuation", borrowedDispositionMarker: true as const } : { turnId: "turn-disposition" });
|
||||
let wakeEvents!: () => void;
|
||||
let eventsWoken = new Promise<void>(resolve => { wakeEvents = resolve; });
|
||||
const session: NativeSession = {
|
||||
identity: () => identity,
|
||||
async capabilities() { return { resume: true, typedEvents: true, steering: false, interruption: true, structuredResult: true }; },
|
||||
async *events() {
|
||||
while (startTurn.mock.calls.length < 1) { await eventsWoken; eventsWoken = new Promise(resolve => { wakeEvents = resolve; }); }
|
||||
continuationCompletionSeen = true;
|
||||
yield { ...runnerEvent(2, "turn.completed", { status: "completed", error: null }), turnId: "turn-continuation" };
|
||||
while (startTurn.mock.calls.length < 2) { await eventsWoken; eventsWoken = new Promise(resolve => { wakeEvents = resolve; }); }
|
||||
yield { ...runnerEvent(3, "turn.completed", { status: "completed", error: null }), turnId: "turn-disposition" };
|
||||
},
|
||||
startTurn: async (value) => { const started = await startTurn(value); wakeEvents(); return started; },
|
||||
async result() { return startTurn.mock.calls.length >= 2 ? { result, terminal, turnId: "turn-disposition" } : null; },
|
||||
async snapshot() {
|
||||
const calls = startTurn.mock.calls.length;
|
||||
if (calls === 0) return structuredClone(checkpoint);
|
||||
if (calls === 1) return { ...checkpoint, terminalTurns: [lostTerminal, completedTerminal("turn-continuation")],
|
||||
dispositionOnlyRecoveryConsumed: false, dispositionOnlyRecoveryTurnId: null };
|
||||
return { ...checkpoint, terminalTurns: [lostTerminal, completedTerminal("turn-continuation"), completedTerminal("turn-disposition")],
|
||||
dispositionOnlyRecoveryConsumed: true, dispositionOnlyRecoveryTurnId: "turn-disposition" };
|
||||
},
|
||||
async close() {},
|
||||
};
|
||||
const backend: NativeSessionBackend = {
|
||||
async descriptor() { return { kind: "runner", name: "restart", version: "1", capabilities: await session.capabilities() }; },
|
||||
async openSession() { throw new Error("must preserve the provider session"); },
|
||||
async recoverSession() { return { recovered: true, session }; },
|
||||
};
|
||||
const persisted: PersistedNativeSession[] = [];
|
||||
const port: ControlPlanePort = {
|
||||
async openRun() {},
|
||||
async checkpointSession(snapshot) {
|
||||
// Hold the continuation's start checkpoint until the consumer has
|
||||
// already received the continuation's completion.
|
||||
if (startTurn.mock.calls.length === 1 && persisted.every(value => value.terminalTurns?.length !== 2)) {
|
||||
await vi.waitFor(() => expect(continuationCompletionSeen).toBe(true));
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
releaseStartCheckpoint();
|
||||
}
|
||||
persisted.push(structuredClone(snapshot));
|
||||
},
|
||||
async appendEvent(event: PrpEvent) { return { cursor: event.sourceSeq, highestContiguousSourceSeq: event.sourceSeq, disposition: "committed" }; },
|
||||
async replayEvents() { return { events: [], highestContiguousSourceSeq: 0 }; },
|
||||
async completeRun() {},
|
||||
};
|
||||
await expect(executeNativeSession({
|
||||
input, backend, controlPlane: port, persistedSession: checkpoint,
|
||||
runnerInstanceId: "runner-recovery", controlPlaneInstanceId: "control-recovery",
|
||||
resumeInterruptedTurn: true,
|
||||
})).resolves.toMatchObject({ result, turnId: "turn-disposition" });
|
||||
await startCheckpointHeld;
|
||||
expect(startTurn).toHaveBeenCalledTimes(2);
|
||||
expect((startTurn.mock.calls[0]![0] as { continuation?: true }).continuation).toBe(true);
|
||||
expect(startTurn.mock.calls[1]![0].message.text).toContain("Paperclip semantic-result recovery after a restart continuation");
|
||||
});
|
||||
|
||||
it("never continues task work when a restart loses a compaction turn", async () => {
|
||||
const failure = { code: "provider_turn_lost_on_restore", recoverable: true };
|
||||
const checkpoint: PersistedNativeSession = {
|
||||
|
||||
@@ -2642,6 +2642,7 @@ export async function executeNativeSession(
|
||||
const snapshot = await session.snapshot(signal ? { signal } : undefined);
|
||||
signal?.throwIfAborted();
|
||||
await persistCheckpoint(snapshot, signal);
|
||||
return snapshot;
|
||||
};
|
||||
const recoveredSnapshot = await session.snapshot();
|
||||
const restartContinuation = () => ({
|
||||
@@ -2652,6 +2653,8 @@ export async function executeNativeSession(
|
||||
"Continue the current user request from the recorded progress and preserved workspace.",
|
||||
"Reconcile any unfinished tool or command before proceeding: inspect its current state and recorded outcomes.",
|
||||
"Do not repeat completed work or blindly rerun an action whose outcome is unknown. If an external action cannot be reconciled, report the specific blocker.",
|
||||
"Finish in this turn. Nothing resumes this conversation after the turn ends, so do not end it to wait for a background command or a notification; wait for or check that work first.",
|
||||
"Then invoke exactly one paperclip_finish or paperclip_block with the accurate disposition.",
|
||||
].join("\n"),
|
||||
completion: {
|
||||
revision: input.completionContract.contract.revision,
|
||||
@@ -2661,8 +2664,67 @@ export async function executeNativeSession(
|
||||
continuation: true as const,
|
||||
requestedCollaborationMode: "executionMode" in input ? input.executionMode : "default" as const,
|
||||
});
|
||||
// A restart continuation borrows the driver's one-shot disposition
|
||||
// marker so a crash during it can adopt the accepted turn. A driver that
|
||||
// hands the marker back when the continuation completes without a result
|
||||
// still owes that one disposition request; ask it in this same attempt so
|
||||
// a restart-recovery budget already spent cannot block it.
|
||||
const continuationDispositionTurn = () => {
|
||||
const envelope = buildNativeModelEnvelope(input);
|
||||
envelope.completionContract = structuredClone(input.completionContract.contract);
|
||||
if ("requestedSkills" in envelope) envelope.requestedSkills = [];
|
||||
envelope.task.prompt = [
|
||||
"Paperclip semantic-result recovery after a restart continuation.",
|
||||
"The previous turn ended without paperclip_finish or paperclip_block, so Paperclip cannot settle this task.",
|
||||
"Do not repeat completed work. If a command you started is still running, wait for or check it in this turn; nothing resumes this conversation after the turn ends.",
|
||||
"Then invoke exactly one paperclip_finish or paperclip_block with the accurate current disposition, and stop.",
|
||||
].join("\n");
|
||||
if ("workspace" in envelope && envelope.workspace && recoveredSnapshot.workingDirectory) {
|
||||
envelope.workspace.cwd = recoveredSnapshot.workingDirectory;
|
||||
}
|
||||
return {
|
||||
message: { role: "user" as const, text: JSON.stringify(envelope) },
|
||||
requestedCollaborationMode: "executionMode" in input ? input.executionMode : "default" as const,
|
||||
};
|
||||
};
|
||||
let restartContinuationStarted = false;
|
||||
let borrowedContinuationTurnId: string | null = null;
|
||||
let continuationDispositionRequested = false;
|
||||
// The consumer may already be running when a continuation starts, and the
|
||||
// continuation can complete before its start checkpoint settles. A
|
||||
// terminal waits for this setup so it neither misses the borrowed turn nor
|
||||
// persists its own checkpoint ahead of the continuation's.
|
||||
let continuationSetup: Promise<void> | null = null;
|
||||
const startRestartContinuation = (signal?: AbortSignal) => {
|
||||
restartContinuationStarted = true;
|
||||
continuationSetup = (async () => {
|
||||
// The driver reports the borrowed marker with the accepted turn; a
|
||||
// later snapshot may already show the marker returned.
|
||||
const started = await session.startTurn(restartContinuation());
|
||||
if (started.borrowedDispositionMarker === true) borrowedContinuationTurnId = started.turnId;
|
||||
await checkpoint(signal);
|
||||
})();
|
||||
return continuationSetup;
|
||||
};
|
||||
const requestContinuationDisposition = async (event: PrpEvent, signal: AbortSignal) => {
|
||||
if (continuationDispositionRequested || event.eventType !== "turn.completed") return false;
|
||||
await continuationSetup?.catch(() => undefined);
|
||||
signal.throwIfAborted();
|
||||
if (borrowedContinuationTurnId === null || event.turnId !== borrowedContinuationTurnId) return false;
|
||||
const snapshot = await session.snapshot();
|
||||
signal.throwIfAborted();
|
||||
if (snapshot.semanticResult || snapshot.activeTurnId || snapshot.goal || snapshot.pendingRuntimeRequests?.length ||
|
||||
snapshot.dispositionOnlyRecoveryConsumed !== false ||
|
||||
!snapshot.terminalTurns?.some(terminal => terminal.turnId === borrowedContinuationTurnId)) return false;
|
||||
continuationDispositionRequested = true;
|
||||
await persistCheckpoint(snapshot, signal);
|
||||
signal.throwIfAborted();
|
||||
await session.startTurn(continuationDispositionTurn());
|
||||
await checkpoint(signal);
|
||||
return true;
|
||||
};
|
||||
const resumeInterruptedTurn = async (event: PrpEvent, signal: AbortSignal) => {
|
||||
if (await requestContinuationDisposition(event, signal)) return true;
|
||||
if (restartContinuationStarted || !recovered || !options.resumeInterruptedTurn ||
|
||||
// A compaction-only run never continues task work after a restart.
|
||||
// Runnerd records a restart-lost turn only for these providers, after
|
||||
@@ -2679,8 +2741,7 @@ export async function executeNativeSession(
|
||||
// history to adopt an accepted continuation if its checkpoint was lost.
|
||||
await persistCheckpoint(snapshot, signal);
|
||||
signal.throwIfAborted();
|
||||
await session.startTurn(restartContinuation());
|
||||
await checkpoint(signal);
|
||||
await startRestartContinuation(signal);
|
||||
return true;
|
||||
};
|
||||
const recoveredActiveTurnId = recovered
|
||||
@@ -2911,9 +2972,7 @@ export async function executeNativeSession(
|
||||
}
|
||||
await checkpoint();
|
||||
} else if (shouldStartFreshTurn && recovered && checkpointedInterruption) {
|
||||
restartContinuationStarted = true;
|
||||
await session.startTurn(restartContinuation());
|
||||
await checkpoint();
|
||||
await startRestartContinuation();
|
||||
} else if (shouldStartFreshTurn) {
|
||||
let modelEnvelope = recovered
|
||||
? buildNativeModelEnvelope(input, { resumedSession: true })
|
||||
|
||||
+22
-5
@@ -7,9 +7,9 @@ import { createHash, randomUUID } from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { chmod, copyFile, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { createRunnerProviderLaunch, runnerLaunchRequirementsDigest, QUALIFIED_ACPX_PROFILES } from "@paperclipai/paperclip-runner";
|
||||
import { createCapabilityRunnerdCodexTransport, defaultCapabilityRunnerdBinary, readRunnerdArtifactBinding, type CapabilityRunnerdCodexTransportOptions } from "@paperclipai/paperclip-runner/live";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
@@ -152,7 +152,24 @@ child.on("exit", code => process.exit(code ?? 1));
|
||||
process.on("SIGTERM", () => child.kill("SIGTERM"));
|
||||
`;
|
||||
await writeFile(shim, script);
|
||||
const nodeDigest = digest(await readFile(process.execPath));
|
||||
// Runnerd refuses a group- or world-writable qualified ACPX launch. A
|
||||
// hosted CI image can ship such a Node, or run under umask 0002.
|
||||
await chmod(shim, 0o600);
|
||||
let sidecarNode = process.execPath;
|
||||
if ((await stat(sidecarNode)).mode & 0o022) {
|
||||
// Same approach as the runner's copyOwnedNodeRuntime test helper.
|
||||
const owned = join(root, "sidecar-node");
|
||||
await mkdir(owned);
|
||||
sidecarNode = join(owned, "runtime");
|
||||
await copyFile(process.execPath, sidecarNode);
|
||||
await chmod(sidecarNode, 0o700);
|
||||
// Homebrew Node resolves its libnode dylib next to the relocated executable.
|
||||
const libraries = join(dirname(process.execPath), "..", "lib");
|
||||
for (const name of (await readdir(libraries).catch(() => [] as string[])).filter(name => /^libnode\..*\.dylib$/.test(name))) {
|
||||
await copyFile(join(libraries, name), join(owned, name));
|
||||
}
|
||||
}
|
||||
const nodeDigest = digest(await readFile(sidecarNode));
|
||||
const identity = { runId: "fresh-resume-run", normalizedSessionId: "checkpointed-session", runnerInstanceId: "fresh-runner",
|
||||
environmentLeaseId: "same-lease", turnId: "same-turn", itemId: "same-item" };
|
||||
const stateDirectory = join(root, ".paperclip-runtime", "paperclip-runner", "sessions",
|
||||
@@ -172,11 +189,11 @@ process.on("SIGTERM", () => child.kill("SIGTERM"));
|
||||
},
|
||||
prepareLaunch: async input => ({ configurationId: "qualified-fixture", requirementsDigest: runnerLaunchRequirementsDigest(input),
|
||||
provider: createRunnerProviderLaunch(input, { options: {}, root: directory, cwd: root, codexHome: root,
|
||||
codexCommand: "", providerNodeCommand: process.execPath, opencodeProxyPath: "", acpxSidecarPath: shim }) }),
|
||||
codexCommand: "", providerNodeCommand: sidecarNode, opencodeProxyPath: "", acpxSidecarPath: shim }) }),
|
||||
runnerProcessLauncher: spec => {
|
||||
launches(adoptExistingRunner);
|
||||
const child = spawn(binary, [...spec.args, "--acpx-launch-authority-digest", digest("fixture authority"),
|
||||
"--acpx-sidecar-command", process.execPath, "--acpx-sidecar-command-sha256", nodeDigest,
|
||||
"--acpx-sidecar-command", sidecarNode, "--acpx-sidecar-command-sha256", nodeDigest,
|
||||
"--acpx-sidecar-script", shim, "--acpx-sidecar-script-sha256", digest(script)],
|
||||
{ env: { PATH: process.env.PATH, ...spec.environment }, stdio: ["ignore", "ignore", "pipe"], detached: true });
|
||||
children.push(child);
|
||||
|
||||
Reference in new issue
Block a user