From dcaada5f59085cf27a0a3625640bb586563f249e Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:40:51 -0600 Subject: [PATCH] fix(native): carry each warm run's own completion contract (#19874) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - A warm runner (`lifecycleMode: warm`) keeps runnerd and the provider process alive between runs, so a follow-up message can start fast. > - Each run has its own completion contract. The revision changes with each follow-up, and the agent must name that revision when it finishes. > - On a warm attachment the transport rebuilt the `run.attach` payload from its constructor options. Those options belong to the first run. Every later run kept the first run's contract. > - The OpenCode proxy checks the finish claim locally against that stale contract. Every warm OpenCode follow-up was refused ("Semantic result completion contract revision does not match"), and the agent looped until the run timed out. > - This pull request carries each run's own contract into the warm attachment, and lets runnerd adopt it without a cold provider resume. > - The benefit: warm follow-ups finish correctly, and they keep the live provider process, which is the reason to be warm. ## Linked Issues or Issue Description Refs #19852 (startup sub-spans), #19846 (TTFT baseline). **What existing behavior does this improve?** Warm cross-run attachment for native Codex and OpenCode agents. **Current behavior** In a local measurement wave with `lifecycleMode: warm`, 4 of 4 OpenCode follow-ups looped for 8+ minutes. Each finish call was refused with "Semantic result completion contract revision does not match". The saved `runAttachTemplate` after the attachment still had revision `1`, while the follow-up turn named revision `2`. Codex hid the defect because its finish claim goes through the controller's tool path, which uses the current input. **Proposed behavior** The attachment carries the attaching run's contract. Runnerd updates the contract in place for Codex and OpenCode and keeps the provider process. The OpenCode proxy receives the current contract with the run identity on `turn/start`. **Reason and benefit** Warm follow-ups are the largest TTFT lever in the baseline. A correctness-only fix (cold-resume the provider when the contract changes) would make every warm follow-up relaunch Codex or OpenCode, because the revision changes on every follow-up. ## What Changed - `attachRun` carries `completionContract` through `NativeSession`, the harness backend, the Codex harness session and the runnerd transport. `executeNativeSession` passes the current input's contract. The transport uses it instead of its first-run `resumeCompletionContract`. - Runnerd (`codex_provider.rs`, `attach_run_in_place`): a changed contract no longer forces a cold resume for `codex` and `opencode`. Codex app-server never receives the contract, and the OpenCode proxy now receives it per turn. Other providers keep the cold-resume boundary. A changed tool set still forces it. - Runnerd sends `paperclipCompletionContract` with each OpenCode `turn/start`, next to the existing `paperclipRunId`. - The OpenCode proxy passes that contract to `attachRun` when the run changes. `OpenCodeHarnessSession.attachRun` replaces its envelope's contract and validates it. ## Verification - `src/live/runnerd-codex-transport-warm-attachment.test.ts` (new case): real runnerd and fake Codex. After a warm attachment with a new contract, the saved `runAttachTemplate` has the new revision and the Codex pid is unchanged. - `src/cli/opencode-proxy-input.test.ts`: the real proxy bundle serves a warm second run with revision `proxy-v2`. The second `paperclip/runResult` is produced and names `proxy-v2`. Without the fix the finish call is refused. - Each test fails without its part of the fix: the proxy test without the proxy change; the transport test without the transport change (stale saved revision); and, with `PAPERCLIP_ATTACH_TRANSITION_RUNNER` pointed at a runnerd built without the `codex_provider.rs` change, the Codex pid changes. - `cargo test -p paperclip-runner-core --lib warm` (11 tests), `native-session-runtime.test.ts` (232), the OpenCode driver, backend and warm-attachment suites, and `tsc --noEmit` for the runner package pass. ## Risks - Previous-release story (`rules/upgrade-safe-run-state.mdc`): no persisted shape changes. Saved `runAttachTemplate` values carry forward, and the next attachment writes the current contract. A new runnerd with an old OpenCode proxy behaves as today: the proxy ignores the new parameter and keeps the stale contract. An old runnerd sends no parameter, so a new proxy keeps its launch contract. - In-place adoption widens the warm-retention path for Codex and OpenCode to changed contracts. The idle, accounting and pending-work checks still apply first. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use (shell, file edit, 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 - [ ] 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 --- .../crates/runner-core/src/codex_provider.rs | 136 ++++++++++++++++-- .../src/backends/harness-driver-backend.ts | 2 + .../src/cli/opencode-app-server-proxy.ts | 7 +- .../src/cli/opencode-proxy-input.test.ts | 10 +- .../src/cli/opencode-proxy-task-envelope.ts | 12 ++ .../src/contracts/harness-driver.ts | 2 +- .../src/contracts/native-session-backend.ts | 3 +- .../src/drivers/codex/app-server-transport.ts | 2 + .../drivers/codex/codex-harness-session.ts | 3 +- .../opencode/opencode-server-driver.ts | 22 ++- ...rd-codex-transport-warm-attachment.test.ts | 43 ++++++ .../src/live/runnerd-codex-transport.ts | 9 +- .../src/native-session-runtime.test.ts | 8 +- .../src/native-session-runtime.ts | 4 + 14 files changed, 239 insertions(+), 24 deletions(-) diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs index 72997fd5ac6..033e4fb8dfe 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs @@ -211,6 +211,45 @@ struct ProviderCompletionContract { criterion_ids: Vec, } +/// Whether a warm run attachment may keep the live provider process. +/// +/// The completion contract is run-scoped and changes with each follow-up. +/// Codex app-server never receives it (runnerd validates the claim), and the +/// OpenCode proxy receives the current contract with each turn/start, so a +/// replaced contract needs no cold resume for them. Other providers, a changed +/// tool set, and adding or removing a contract keep the cold-resume boundary. +fn warm_attachment_keeps_provider( + provider: &str, + tools_unchanged: bool, + current: Option<&ProviderCompletionContract>, + next: Option<&ProviderCompletionContract>, +) -> bool { + if provider == "cursor_cloud" || !tools_unchanged { + return false; + } + current == next + || (current.is_some() && next.is_some() && matches!(provider, "codex" | "opencode")) +} + +/// Run-scoped identity a retained OpenCode proxy needs on each turn/start: its +/// launch parameters belong to the first run it served. +fn opencode_turn_identity_params( + run_id: Option<&str>, + contract: Option<&ProviderCompletionContract>, +) -> Vec<(&'static str, Value)> { + let mut params = Vec::new(); + if let Some(run_id) = run_id { + params.push(("paperclipRunId", json!(run_id))); + } + if let Some(contract) = contract { + params.push(( + "paperclipCompletionContract", + json!({"revision": contract.revision, "criterionIds": contract.criterion_ids}), + )); + } + params +} + fn base64_encode(input: &[u8]) -> String { const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let mut encoded = String::with_capacity(input.len().div_ceil(3) * 4); @@ -1469,18 +1508,18 @@ impl CodexProvider { completion_contract: Option<(&str, &[String])>, accounting: &mut Vec<(String, Value)>, ) -> Result { - if self.config.provider == "cursor_cloud" { - return Ok(false); - } let authorized_tools = authorized_tools.into_iter().collect::>(); let completion_contract = completion_contract.map(|(revision, criterion_ids)| ProviderCompletionContract { revision: revision.to_owned(), criterion_ids: criterion_ids.to_vec(), }); - if authorized_tools != self.authorized_tools - || completion_contract != self.completion_contract - { + if !warm_attachment_keeps_provider( + &self.config.provider, + authorized_tools == self.authorized_tools, + self.completion_contract.as_ref(), + completion_contract.as_ref(), + ) { return Ok(false); } let (tail, result) = self.drain_completed_turn_tail_for_warm_attachment(); @@ -1503,12 +1542,13 @@ impl CodexProvider { // The provider process and its thread remain authoritative. Exact // settled-turn identities stay in memory so delayed output from an // earlier run cannot be accepted as the next turn. A changed semantic - // tool or completion contract returns false so the caller can preserve - // the existing cold-resume behavior for that incompatible boundary. + // tool set (or a changed contract for other providers) returned false + // above so the caller preserves the cold-resume boundary. self.completed_turn_authority = None; self.active_turn_result_authoritative = false; self.completion_reconciliation_pending = false; self.expected_shutdown = false; + self.completion_contract = completion_contract; Ok(true) } @@ -2086,8 +2126,10 @@ impl CodexProvider { // The retained proxy's launch environment belongs to its first run. // Only runnerd's rotated authority supplies the current run identity. if self.config.provider == "opencode" { - if let Some(run_id) = run_id { - turn_params_object.insert("paperclipRunId".to_owned(), json!(run_id)); + for (key, value) in + opencode_turn_identity_params(run_id, self.completion_contract.as_ref()) + { + turn_params_object.insert(key.to_owned(), value); } } if let Some(effort) = effort { @@ -6019,6 +6061,80 @@ done assert!(config.validate().is_err()); } + fn contract(revision: &str) -> ProviderCompletionContract { + ProviderCompletionContract { + revision: revision.to_owned(), + criterion_ids: vec!["objective".to_owned()], + } + } + + #[test] + fn warm_attachment_adopts_a_replaced_contract_only_for_codex_and_opencode() { + let (first, second) = (contract("1"), contract("2")); + for provider in ["codex", "opencode"] { + assert!(warm_attachment_keeps_provider( + provider, + true, + Some(&first), + Some(&second) + )); + assert!(warm_attachment_keeps_provider( + provider, + true, + Some(&first), + Some(&first) + )); + // Adding or removing a contract, or changing tools, still cold-resumes. + assert!(!warm_attachment_keeps_provider( + provider, + true, + Some(&first), + None + )); + assert!(!warm_attachment_keeps_provider( + provider, + true, + None, + Some(&second) + )); + assert!(!warm_attachment_keeps_provider( + provider, + false, + Some(&first), + Some(&second) + )); + } + assert!(!warm_attachment_keeps_provider( + "aws_agentcore", + true, + Some(&first), + Some(&second) + )); + assert!(!warm_attachment_keeps_provider( + "cursor_cloud", + true, + Some(&first), + Some(&first) + )); + } + + #[test] + fn opencode_turns_carry_the_current_run_and_contract() { + let current = contract("2"); + let params = opencode_turn_identity_params(Some("run-2"), Some(¤t)); + assert_eq!( + params, + vec![ + ("paperclipRunId", json!("run-2")), + ( + "paperclipCompletionContract", + json!({"revision": "2", "criterionIds": ["objective"]}) + ), + ] + ); + assert!(opencode_turn_identity_params(None, None).is_empty()); + } + #[test] fn does_not_forward_an_ambient_opencode_command_override() { assert!(!OPENCODE_PROVIDER_ENVIRONMENT_KEYS.contains(&"PAPERCLIP_OPENCODE_COMMAND")); diff --git a/packages/paperclip-runner/src/backends/harness-driver-backend.ts b/packages/paperclip-runner/src/backends/harness-driver-backend.ts index 50e56be89cd..51f03ff31ac 100644 --- a/packages/paperclip-runner/src/backends/harness-driver-backend.ts +++ b/packages/paperclip-runner/src/backends/harness-driver-backend.ts @@ -483,6 +483,7 @@ class HarnessNativeSession implements NativeSession { async attachRun(input: { identity: OpenNativeSessionInput["identity"]; currentRunGrant?: NativeRunRuntimeGrant; + completionContract?: { revision: string; criterionIds: readonly string[] }; }): Promise { this.#assertProtocolIntegrity(); const currentIdentity = this.#input.identity; @@ -500,6 +501,7 @@ class HarnessNativeSession implements NativeSession { try { await this.#session.attachRun({ runId: input.identity.runId, ...(input.currentRunGrant ? { currentRunGrant: structuredClone(input.currentRunGrant) } : {}), + ...(input.completionContract ? { completionContract: structuredClone(input.completionContract) } : {}), }); this.#assertProtocolIntegrity(); } catch (error) { diff --git a/packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts b/packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts index 9bdab7c2477..90e98ca0c2b 100644 --- a/packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts +++ b/packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts @@ -11,7 +11,7 @@ import type { } from "../contracts/harness-driver.js"; import { OpenCodeServerDriver } from "../drivers/opencode/opencode-server-driver.js"; import { parseNativeRuntimeContext } from "../contracts/runtime-context.js"; -import { openCodeProxyTaskEnvelope } from "./opencode-proxy-task-envelope.js"; +import { openCodeProxyCompletionContract, openCodeProxyTaskEnvelope } from "./opencode-proxy-task-envelope.js"; import { openCodeProxyItemNotification, openCodeProxyToolNotification, @@ -356,7 +356,10 @@ async function handle(message: RpcMessage): Promise { throw new Error("Invalid Paperclip run identity"); if (runId !== activeRunId) { if (!session.attachRun) throw new Error("OpenCode run attachment unavailable"); - await session.attachRun({ runId }); + // Runnerd sends the attached run's contract with its run identity; + // the launch parameters still name the first run's contract. + const contract = openCodeProxyCompletionContract(params.paperclipCompletionContract); + await session.attachRun({ runId, ...(contract ? { completionContract: contract } : {}) }); activeRunId = runId; } } diff --git a/packages/paperclip-runner/src/cli/opencode-proxy-input.test.ts b/packages/paperclip-runner/src/cli/opencode-proxy-input.test.ts index 7b3a0a27a7b..e29ff22ba03 100644 --- a/packages/paperclip-runner/src/cli/opencode-proxy-input.test.ts +++ b/packages/paperclip-runner/src/cli/opencode-proxy-input.test.ts @@ -152,7 +152,11 @@ it.each([true, false])("settles a real proxy interrupt with controller feedback { result: { content: [{ text: expect.stringContaining(feedback) }] } }, ]); await expect.poll(() => bodies.length, { timeout: 5_000 }).toBe(1); - send({ id: 5, method: "turn/start", params: { paperclipRunId: "current-run-2", input: [{ type: "text", text: JSON.stringify({ schema: "paperclip.native-model-envelope.v3", task: { prompt: "completion-feedback" }, completionContract: { revision: "proxy-v1", criteria: [{ id: "objective", requirement: "Complete the fixture." }] } }) }] } }); + // The warm second run has its own contract revision. Runnerd sends it with + // the run identity; the proxy's launch parameters still name proxy-v1. + send({ id: 5, method: "turn/start", params: { paperclipRunId: "current-run-2", + paperclipCompletionContract: { revision: "proxy-v2", criterionIds: ["objective"] }, + input: [{ type: "text", text: JSON.stringify({ schema: "paperclip.native-model-envelope.v3", task: { prompt: "completion-feedback" }, completionContract: { revision: "proxy-v2", criteria: [{ id: "objective", requirement: "Complete the fixture." }] } }) }] } }); await response(5); await expect.poll(() => messages.find(message => message.method === "item/tool/call" && message.id !== call.id), { timeout: 5_000 }).toBeDefined(); const warmCall = messages.find(message => message.method === "item/tool/call" && message.id !== call.id)!; @@ -164,7 +168,9 @@ it.each([true, false])("settles a real proxy interrupt with controller feedback } await expect.poll(() => exited, { timeout: 5_000 }).toBe(true); await exit; - expect(messages.filter(message => message.method === "paperclip/runResult")).toHaveLength(respond ? 2 : 0); + const results = messages.filter(message => message.method === "paperclip/runResult"); + expect(results).toHaveLength(respond ? 2 : 0); + if (respond) expect(JSON.stringify(results[1])).toContain("proxy-v2"); } finally { child.kill("SIGTERM"); if (!exited) { diff --git a/packages/paperclip-runner/src/cli/opencode-proxy-task-envelope.ts b/packages/paperclip-runner/src/cli/opencode-proxy-task-envelope.ts index 535b64d6d30..d19cbacb52b 100644 --- a/packages/paperclip-runner/src/cli/opencode-proxy-task-envelope.ts +++ b/packages/paperclip-runner/src/cli/opencode-proxy-task-envelope.ts @@ -10,6 +10,18 @@ function text(value: unknown, fallback = ""): string { return typeof value === "string" ? value : fallback; } +/** Runnerd's per-turn `paperclipCompletionContract`, or undefined when absent or malformed. */ +export function openCodeProxyCompletionContract(value: unknown): { revision: string; criterionIds: string[] } | undefined { + const contract = record(value); + const revision = text(contract.revision).trim(); + const criterionIds = Array.isArray(contract.criterionIds) + ? contract.criterionIds.filter((id): id is string => typeof id === "string" && id.trim().length > 0).map((id) => id.trim()) + : []; + return revision && criterionIds.length > 0 && criterionIds.length === (contract.criterionIds as unknown[]).length + ? { revision, criterionIds } + : undefined; +} + export function openCodeProxyTaskEnvelope(params: Record) { const completionContract = record(params.completionContract); const revision = text(completionContract.revision).trim(); diff --git a/packages/paperclip-runner/src/contracts/harness-driver.ts b/packages/paperclip-runner/src/contracts/harness-driver.ts index 22dc49246d0..de5e9c8a29f 100644 --- a/packages/paperclip-runner/src/contracts/harness-driver.ts +++ b/packages/paperclip-runner/src/contracts/harness-driver.ts @@ -564,7 +564,7 @@ export interface HarnessSession { displayId?: string | null; }; events(): AsyncIterable; - attachRun?(input: { runId: string; currentRunGrant?: NativeRunRuntimeGrant }): Promise | void; + attachRun?(input: { runId: string; currentRunGrant?: NativeRunRuntimeGrant; completionContract?: { revision: string; criterionIds: readonly string[] } }): Promise | void; startTurn(input: { message: NativeUserMessage; /** Set by orchestration only after successful provider-session recovery. */ diff --git a/packages/paperclip-runner/src/contracts/native-session-backend.ts b/packages/paperclip-runner/src/contracts/native-session-backend.ts index f994ece9d0a..2057e8ee56b 100644 --- a/packages/paperclip-runner/src/contracts/native-session-backend.ts +++ b/packages/paperclip-runner/src/contracts/native-session-backend.ts @@ -201,7 +201,8 @@ export class NativeSessionCleanupQuarantinedError extends Error { export interface NativeSession { identity(): NativeRunIdentity; capabilities(): Promise; - attachRun?(input: { identity: NativeRunIdentity; currentRunGrant?: NativeRunRuntimeGrant }): Promise; + /** completionContract is the attaching run's own contract; a retained session must not keep the prior run's. */ + attachRun?(input: { identity: NativeRunIdentity; currentRunGrant?: NativeRunRuntimeGrant; completionContract?: { revision: string; criterionIds: readonly string[] } }): Promise; /** Return the confirmed released source, or false when active use was not released. */ release?(): Promise; /** Relinquish controller authority without suspending provider execution. */ diff --git a/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts b/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts index 2262a3494ca..bddbbe998eb 100644 --- a/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts +++ b/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts @@ -82,6 +82,8 @@ export interface CodexAppServerTransport { turnId: string; itemId: string; currentRunGrant?: NativeRunRuntimeGrant; + /** The attaching run's own completion contract. */ + completionContract?: { revision: string; criterionIds: readonly string[] }; }): Promise; /** Records post-rehydration driver mapping without affecting run authority. */ recordTraceInterpretation?(input: CodexTraceInterpretation): void; diff --git a/packages/paperclip-runner/src/drivers/codex/codex-harness-session.ts b/packages/paperclip-runner/src/drivers/codex/codex-harness-session.ts index 5a4285942d9..2c7c2ab0ad4 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-harness-session.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-harness-session.ts @@ -110,7 +110,7 @@ export class CodexHarnessSession }; } - async attachRun(input: { runId: string; currentRunGrant?: NativeRunRuntimeGrant }): Promise { + async attachRun(input: { runId: string; currentRunGrant?: NativeRunRuntimeGrant; completionContract?: { revision: string; criterionIds: readonly string[] } }): Promise { this.assertProtocolIntegrity(); const transportOwnsQuiescence = this.transport.attachRun !== undefined; if ( @@ -129,6 +129,7 @@ export class CodexHarnessSession turnId: `turn_attachment_${randomUUID().replaceAll("-", "")}`, itemId: `item_attachment_${randomUUID().replaceAll("-", "")}`, ...(input.currentRunGrant ? { currentRunGrant: structuredClone(input.currentRunGrant) } : {}), + ...(input.completionContract ? { completionContract: structuredClone(input.completionContract) } : {}), }); this.assertProtocolIntegrity(); if (transportOwnsQuiescence) { diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts index 76f9fd97e1d..72f2b5801f1 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts @@ -493,7 +493,7 @@ class OpenCodeHarnessSession implements HarnessSession { readonly #workingDirectory: string; readonly #runnerInstanceId: string; readonly #model: string; - readonly #taskEnvelope: CodexTaskEnvelope; + #taskEnvelope: CodexTaskEnvelope; readonly #systemInstructions: string; readonly #dynamicToolHandler?: DynamicToolHandler; readonly #completionFeedback?: OpenCodeCompletionFeedback; @@ -657,11 +657,29 @@ class OpenCodeHarnessSession implements HarnessSession { }; } - attachRun(input: { runId: string }): void { + attachRun(input: { runId: string; completionContract?: { revision: string; criterionIds: readonly string[] } }): void { if (this.#activeTurnId !== null) throw new Error("opencode_run_attach_busy"); if (!input.runId) throw new Error("opencode_run_attach_invalid"); + const contract = input.completionContract; + if (contract !== undefined && (!contract.revision.trim() || contract.criterionIds.length === 0 + || contract.criterionIds.some((id) => typeof id !== "string" || !id.trim()))) + throw new Error("opencode_run_attach_invalid"); this.#runId = input.runId; + // A retained process serves later runs. Each run's finish claim must name + // that run's own contract revision, not the first run's. + if (contract !== undefined) { + this.#taskEnvelope = { + ...this.#taskEnvelope, + completionContract: { + revision: contract.revision.trim(), + criteria: contract.criterionIds.map((id) => ({ + id: id.trim(), + requirement: `Satisfy the Paperclip completion criterion ${id.trim()}.`, + })), + }, + }; + } this.#result = null; this.#resultFingerprint = null; this.#resultCallId = null; diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport-warm-attachment.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport-warm-attachment.test.ts index f6861fb0cec..c1955a2fd88 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport-warm-attachment.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport-warm-attachment.test.ts @@ -709,6 +709,49 @@ it("rotates PRP authority in place for a warm cross-run attachment", async () => } }, 30_000); +it("carries each warm run's own completion contract and keeps the provider process", async () => { + const stateDirectory = await mkdtemp(join(tmpdir(), "runnerd-warm-contract-")); + const bundle = createCapabilityRunnerdCodexTransport({ + runnerBinary: defaultCapabilityRunnerdBinary(), + codexCommand: fakeCodex, + codexArgs: fakeCodexArgs(stateDirectory), + stateDirectory, + lifecyclePolicy: { mode: "warm", idleTimeoutMs: 60_000 }, + // The transport is created for the first run; its options name that run's contract. + resumeCompletionContract: { revision: "first-run-contract", criterionIds: ["objective"] }, + }); + bundle.transport.setServerRequestHandler(async () => ({ success: true, contentItems: [] })); + const savedContract = async () => JSON.parse(await readFile( + join(stateDirectory, "control-plane", "control-plane-state.json"), "utf8")).runAttachTemplate?.completionContract; + const notifications = bundle.transport.notifications()[Symbol.asyncIterator](); + const completed = async () => { + for (;;) if ((await notifications.next()).value?.method === "turn/completed") return; + }; + try { + await bundle.transport.request("initialize", {}); + await bundle.transport.request("thread/start", { + cwd: tmpdir(), dynamicTools: [], + completionContract: { revision: "first-run-contract", criterionIds: ["objective"] }, + }); + const providerPid = bundle.evidence().codexPid; + await bundle.transport.request("turn/start", { input: [{ type: "text", text: "first run" }] }); + await completed(); + + await bundle.transport.attachRun!({ + runId: "run-warm-contract-second", turnId: "turn-warm-contract-second", itemId: "item-warm-contract-second", + completionContract: { revision: "second-run-contract", criterionIds: ["objective", "follow-up"] }, + }); + expect(await savedContract()).toEqual({ revision: "second-run-contract", criterionIds: ["objective", "follow-up"] }); + await bundle.transport.request("turn/start", { input: [{ type: "text", text: "second run" }] }); + await completed(); + // A changed contract no longer forces a cold provider resume. + expect(bundle.evidence()).toMatchObject({ codexPid: providerPid, runnerExited: false }); + } finally { + await bundle.transport.close(); + await rm(stateDirectory, { recursive: true, force: true }); + } +}, 30_000); + it("keeps prior-run events on ordinary accounting when the owner moves ahead for a warm attachment", async () => { const stateDirectory = await mkdtemp(join(tmpdir(), "runnerd-warm-owner-")); const first = { diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 42bf22e7bd8..07f74b734af 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -4667,6 +4667,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { turnId: string; itemId: string; currentRunGrant?: NativeRunRuntimeGrant; + completionContract?: { revision: string; criterionIds: readonly string[] }; }): Promise { if (this.#attachmentOperation !== null) { return Promise.reject(new Error("native_runner_run_attachment_in_progress")); @@ -4684,6 +4685,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { turnId: string; itemId: string; currentRunGrant?: NativeRunRuntimeGrant; + completionContract?: { revision: string; criterionIds: readonly string[] }; }): Promise { const previous = record(this.#runAttachTemplate?.provider).runtimeContext; const current = input.currentRunGrant?.runtimeContext; @@ -4696,6 +4698,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { turnId: string; itemId: string; currentRunGrant?: NativeRunRuntimeGrant; + completionContract?: { revision: string; criterionIds: readonly string[] }; }): Promise { if (this.#pendingWarmRecoveryCompletion !== null) { throw new Error("native_runner_warm_transition_completion_pending"); @@ -4751,7 +4754,8 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { this.#runAttachTemplate, desired, this.#authorizedTools, - this.options.resumeCompletionContract, + // A retained transport's options belong to its first run. + input.completionContract ?? this.options.resumeCompletionContract, currentRunGrant?.runtimeContext, currentRunGrant ? { text: currentRunGrant.instructions, context: currentRunGrant.runtimeContext } : undefined, ) @@ -4759,7 +4763,8 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { core.store.state, desired, this.#authorizedTools, - this.options.resumeCompletionContract, + // A retained transport's options belong to its first run. + input.completionContract ?? this.options.resumeCompletionContract, currentRunGrant?.runtimeContext, currentRunGrant ? { text: currentRunGrant.instructions, context: currentRunGrant.runtimeContext } : undefined, ); diff --git a/packages/paperclip-runner/src/native-session-runtime.test.ts b/packages/paperclip-runner/src/native-session-runtime.test.ts index 3d0a7a180d3..45953672e1d 100644 --- a/packages/paperclip-runner/src/native-session-runtime.test.ts +++ b/packages/paperclip-runner/src/native-session-runtime.test.ts @@ -6249,9 +6249,11 @@ describe("executeNativeSession recovery", () => { onSession, }), ).rejects.toBe(attachmentFailure); + // The attaching run's own contract travels with the attachment. + const completionContract = { revision: "1", criterionIds: ["objective"] }; expect(attachRun).toHaveBeenCalledWith(provider === "copilot" - ? { identity, currentRunGrant: { runtimeContext: null, instructions: expect.any(String) } } - : { identity }); + ? { identity, completionContract, currentRunGrant: { runtimeContext: null, instructions: expect.any(String) } } + : { identity, completionContract }); expect(retainedIdentity).toEqual(identity); expect(onSession).toHaveBeenCalledOnce(); expect(onSession).toHaveBeenCalledWith(null); @@ -6334,7 +6336,7 @@ describe("executeNativeSession recovery", () => { onSession, }), ).rejects.toBe(admissionFailure); - expect(attachRun).toHaveBeenCalledWith({ identity }); + expect(attachRun).toHaveBeenCalledWith({ identity, completionContract: { revision: "1", criterionIds: ["objective"] } }); expect(openRun).toHaveBeenCalledOnce(); expect(onSession).toHaveBeenCalledOnce(); expect(onSession).toHaveBeenCalledWith(null); diff --git a/packages/paperclip-runner/src/native-session-runtime.ts b/packages/paperclip-runner/src/native-session-runtime.ts index 570ada37be7..c47046fd350 100644 --- a/packages/paperclip-runner/src/native-session-runtime.ts +++ b/packages/paperclip-runner/src/native-session-runtime.ts @@ -2339,6 +2339,10 @@ export async function executeNativeSession( const attach = async (signal?: AbortSignal) => { signal?.throwIfAborted(); await options.existingSession!.attachRun!({ identity, + completionContract: { + revision: input.completionContract.contract.revision, + criterionIds: input.completionContract.contract.criteria.map((criterion) => criterion.id), + }, ...(input.provider.kind === "acpx" && input.provider.agent === "copilot" ? { currentRunGrant: { runtimeContext: "runtimeContext" in input ? input.runtimeContext : null, instructions: nativeSystemInstructions(input) } }