fix(native): carry each warm run's own completion contract (#19874)

## 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 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-07 12:40:51 -06:00
1 parent 5a20fca9f7
commit dcaada5f59
14 files changed
+239 -24

No files matched your search

@@ -211,6 +211,45 @@ struct ProviderCompletionContract {
criterion_ids: Vec<String>,
}
/// 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<bool, LocalRunnerError> {
if self.config.provider == "cursor_cloud" {
return Ok(false);
}
let authorized_tools = authorized_tools.into_iter().collect::<Vec<_>>();
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(&current));
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"));
@@ -483,6 +483,7 @@ class HarnessNativeSession implements NativeSession {
async attachRun(input: {
identity: OpenNativeSessionInput["identity"];
currentRunGrant?: NativeRunRuntimeGrant;
completionContract?: { revision: string; criterionIds: readonly string[] };
}): Promise<void> {
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) {
@@ -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<void> {
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;
}
}
@@ -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) {
@@ -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<string, unknown>) {
const completionContract = record(params.completionContract);
const revision = text(completionContract.revision).trim();
@@ -564,7 +564,7 @@ export interface HarnessSession {
displayId?: string | null;
};
events(): AsyncIterable<PrpEvent>;
attachRun?(input: { runId: string; currentRunGrant?: NativeRunRuntimeGrant }): Promise<void> | void;
attachRun?(input: { runId: string; currentRunGrant?: NativeRunRuntimeGrant; completionContract?: { revision: string; criterionIds: readonly string[] } }): Promise<void> | void;
startTurn(input: {
message: NativeUserMessage;
/** Set by orchestration only after successful provider-session recovery. */
@@ -201,7 +201,8 @@ export class NativeSessionCleanupQuarantinedError extends Error {
export interface NativeSession {
identity(): NativeRunIdentity;
capabilities(): Promise<NativeSessionCapabilities>;
attachRun?(input: { identity: NativeRunIdentity; currentRunGrant?: NativeRunRuntimeGrant }): Promise<void>;
/** 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<void>;
/** Return the confirmed released source, or false when active use was not released. */
release?(): Promise<DurableRecoveryIdentity | false>;
/** Relinquish controller authority without suspending provider execution. */
@@ -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<void>;
/** Records post-rehydration driver mapping without affecting run authority. */
recordTraceInterpretation?(input: CodexTraceInterpretation): void;
@@ -110,7 +110,7 @@ export class CodexHarnessSession
};
}
async attachRun(input: { runId: string; currentRunGrant?: NativeRunRuntimeGrant }): Promise<void> {
async attachRun(input: { runId: string; currentRunGrant?: NativeRunRuntimeGrant; completionContract?: { revision: string; criterionIds: readonly string[] } }): Promise<void> {
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) {
@@ -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;
@@ -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 = {
@@ -4667,6 +4667,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
turnId: string;
itemId: string;
currentRunGrant?: NativeRunRuntimeGrant;
completionContract?: { revision: string; criterionIds: readonly string[] };
}): Promise<void> {
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<void> {
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<void> {
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,
);
@@ -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);
@@ -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) } }