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) } }