diff --git a/packages/paperclip-runner/src/live/live-session.test.ts b/packages/paperclip-runner/src/live/live-session.test.ts index c70ccd0def9..426e6d8a39c 100644 --- a/packages/paperclip-runner/src/live/live-session.test.ts +++ b/packages/paperclip-runner/src/live/live-session.test.ts @@ -532,7 +532,30 @@ describe("Capability live runnerd and Codex session", () => { ); expect(resumed.mockState().comments).toHaveLength(1); expect(resumed.mockState().idempotency).toHaveLength(1); - expect(resumed.snapshot().evidence.filter((entry) => entry.kind === "tool_result")).toHaveLength(2); + const replayedSnapshot = resumed.snapshot(); + expect(replayedSnapshot.terminalTurns).toEqual([ + expect.objectContaining({ turnId: "turn-1", status: "interrupted" }), + expect.objectContaining({ + turnId: "turn-1:durable-duplicate-replay", + status: "completed", + reconciledByAttemptId: "attempt-terminal-resumed", + }), + ]); + expect(replayedSnapshot.evidence.filter((entry) => entry.kind === "tool_result")).toHaveLength(2); + const receipts = replayedSnapshot.evidence.filter((entry) => entry.kind === "tool_result"); + expect(receipts.map((entry) => (entry.data.result as Record).ok)).toEqual([true, true]); + expect(receipts.map((entry) => ( + ((entry.data.result as Record).result as Record).disposition + ))).toEqual(["applied", "duplicate"]); + expect(receipts.every((entry) => ( + Number(entry.data.beforeRevision) <= Number(entry.data.afterRevision) + ))).toBe(true); + expect(receipts.every((entry) => ( + Number((entry.data.result as Record).stateRevision) + === Number(entry.data.afterRevision) + ))).toBe(true); + expect(receipts.at(-1)?.data.afterRevision).toBe(resumed.mockState().revision); + expect(state.transports[1]!.requests.some((request) => request.method === "turn/start")).toBe(false); const rejected = await state.transports[1]!.invokeServerRequest({ id: "request-turn-1:new-key", @@ -552,6 +575,11 @@ describe("Capability live runnerd and Codex session", () => { expect(resumed.mockState().comments).toHaveLength(1); expect(resumed.mockState().idempotency).toHaveLength(1); expect(resumed.snapshot().evidence.filter((entry) => entry.kind === "tool_result")).toHaveLength(2); + await resumed.completeAttempt("succeeded"); + expect((await store.load(resumed.id))?.attempts).toMatchObject([ + { attemptId: "attempt-terminal-killed", status: "terminated" }, + { attemptId: "attempt-terminal-resumed", status: "succeeded" }, + ]); await resumedService.shutdown(resumed.id, "terminal replay test complete"); }); diff --git a/packages/paperclip-runner/src/live/live-session.ts b/packages/paperclip-runner/src/live/live-session.ts index a5d07bfb9be..6e791764f1c 100644 --- a/packages/paperclip-runner/src/live/live-session.ts +++ b/packages/paperclip-runner/src/live/live-session.ts @@ -1381,18 +1381,25 @@ export class CapabilityLiveSession { afterRevision: result.stateRevision, }); this.#emit({ turnId, kind: "activity", reason: "tool_result" }); - await this.#persist(); if (terminalReplay && replayDisposition !== "duplicate") { + await this.#persist(); return { success: false, contentItems: [{ type: "inputText", text: "Terminal-turn replay did not resolve to a durable duplicate." }], }; } + if (terminalReplay) { + this.#recordTerminalFact(this.#durableReplayTurnId(turnId), "completed"); + } + await this.#persist(); return this.#codexToolResponse(result); } #isDurableTerminalReplay(turnId: string, input: unknown): boolean { if (turnId.length === 0 || this.#activeTurnId !== null) return false; + if (this.#terminalTurns.some( + (candidate) => candidate.turnId === this.#durableReplayTurnId(turnId), + )) return false; const terminal = this.#terminalTurns.find((candidate) => candidate.turnId === turnId); if ( terminal?.status !== "interrupted" || @@ -1406,6 +1413,10 @@ export class CapabilityLiveSession { ); } + #durableReplayTurnId(interruptedTurnId: string): string { + return `${interruptedTurnId}:durable-duplicate-replay`; + } + #codexToolResponse(result: CapabilitySemanticToolResult): Record { return { success: result.ok, diff --git a/packages/paperclip-runner/src/mock-core/capability-mock-control-plane-adapter.test.ts b/packages/paperclip-runner/src/mock-core/capability-mock-control-plane-adapter.test.ts index 974ce8d003f..46d0fb0e2f4 100644 --- a/packages/paperclip-runner/src/mock-core/capability-mock-control-plane-adapter.test.ts +++ b/packages/paperclip-runner/src/mock-core/capability-mock-control-plane-adapter.test.ts @@ -214,6 +214,7 @@ describe("CapabilityMockControlPlaneAdapter", () => { }); const replayed = await adapter.applyCommand(envelope); expect(replayed.disposition).toBe("duplicate"); + expect(replayed.stateRevision).toBe(adapter.snapshot().revision); expect(adapter.snapshot().comments.filter((comment) => comment.body === "Exactly once.")).toHaveLength(1); expect(adapter.decisionRecords().map((decision) => decision.outcome)).toContain("faulted"); expect(adapter.decisionRecords().map((decision) => decision.outcome)).toContain("duplicate"); diff --git a/packages/paperclip-runner/src/mock-core/capability-mock-control-plane-adapter.ts b/packages/paperclip-runner/src/mock-core/capability-mock-control-plane-adapter.ts index d5b8e220d3e..dab978c7d24 100644 --- a/packages/paperclip-runner/src/mock-core/capability-mock-control-plane-adapter.ts +++ b/packages/paperclip-runner/src/mock-core/capability-mock-control-plane-adapter.ts @@ -413,7 +413,11 @@ export class CapabilityMockControlPlaneAdapter implements CapabilityMockControlP this.#recordDecisionOnly(run.id, envelope.command.kind, "duplicate", "command_already_applied", [ ...existing.result.entityRefs, ]); - return deepFreeze({ ...clone(existing.result), disposition: "duplicate" }); + return deepFreeze({ + ...clone(existing.result), + disposition: "duplicate", + stateRevision: this.#state.revision, + }); } if ( envelope.expectedRevision !== undefined &&