fix(opencode): bind the attached run's contract after a restored launch

Runnerd restores a saved OpenCode session before it applies run.attach.
The restored proxy launches with the saved run's completion contract, but
its environment already names the attached run. On turn/start the run id
then matched, so the proxy skipped the attachment and kept the saved
contract. Every finish claim for the current revision was refused with
"Semantic result completion contract revision does not match".

The proxy now also rebinds when the turn's contract differs from the one
its session holds. The driver's stale-revision error names the expected
contractRevision, like the criteria error does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 committed 2026-11-08 03:34:17 -06:00
1 parent d3b353c80a
commit 0b3d9c9285
6 files changed
+136 -6

No files matched your search

@@ -11,7 +11,11 @@ import type {
} from "../contracts/harness-driver.js";
import { OpenCodeServerDriver } from "../drivers/opencode/opencode-server-driver.js";
import { parseNativeRuntimeContext } from "../contracts/runtime-context.js";
import { openCodeProxyCompletionContract, openCodeProxyTaskEnvelope } from "./opencode-proxy-task-envelope.js";
import {
openCodeProxyCompletionContract,
openCodeProxyTaskEnvelope,
sameOpenCodeProxyCompletionContract,
} from "./opencode-proxy-task-envelope.js";
import {
openCodeProxyItemNotification,
openCodeProxyToolNotification,
@@ -51,6 +55,11 @@ let cwd = "";
let activeModel = "";
let activeTurnId: string | null = null;
let activeRunId = process.env.PAPERCLIP_RUN_ID ?? "runnerd-run";
// The contract the open session checks finish claims against. The launch
// environment and the launch parameters can belong to different runs: runnerd
// restores a saved session with its saved contract while its environment
// already names the run being attached.
let activeCompletionContract: ReturnType<typeof openCodeProxyCompletionContract> = undefined;
const announcedTurnIds = new Set<string>();
const launchBinding = trustedOpenCodeLaunchBinding(process.argv.slice(2));
const usageLedger = new OpenCodeProxyUsageLedger();
@@ -172,6 +181,7 @@ async function open(
});
}
finishDiagnostics.bind(session.ids().driverSessionId);
activeCompletionContract = openCodeProxyCompletionContract(params.completionContract);
eventPump = pumpEvents(session);
void eventPump.catch((error) => failProxy(error));
return threadResponse(
@@ -360,13 +370,16 @@ async function handle(message: RpcMessage): Promise<void> {
const runId = params.paperclipRunId;
if (typeof runId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(runId))
throw new Error("Invalid Paperclip run identity");
if (runId !== activeRunId) {
// Runnerd sends the attached run's contract with its run identity;
// the launch parameters may still name an earlier run's contract,
// even when the launch environment already names this run.
const contract = openCodeProxyCompletionContract(params.paperclipCompletionContract);
if (runId !== activeRunId
|| (contract !== undefined && !sameOpenCodeProxyCompletionContract(contract, activeCompletionContract))) {
if (!session.attachRun) throw new Error("OpenCode run attachment unavailable");
// 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;
if (contract !== undefined) activeCompletionContract = contract;
}
}
const inputItems = Array.isArray(params.input)
@@ -183,3 +183,82 @@ it.each([true, false])("settles a real proxy interrupt with controller feedback
await rm(root, { recursive: true, force: true });
}
}, 30_000);
// Runnerd restores a saved session before it applies `run.attach`: the proxy
// launches with the saved run's contract while its environment already names
// the attached run. The attached run's contract still arrives on turn/start.
it("binds the current contract when a restored launch already names the attached run", async () => {
const { execFileSync, spawn } = await import("node:child_process");
const { chmod, mkdir, mkdtemp, rm } = await import("node:fs/promises");
const { closeSync, openSync } = await import("node:fs");
const { randomUUID } = await import("node:crypto");
const { tmpdir } = await import("node:os");
const { join, resolve } = await import("node:path");
const { createInterface } = await import("node:readline");
const root = await mkdtemp(join(tmpdir(), "opencode-proxy-restored-contract-"));
const launchRoot = join(root, `.paperclip-verified-executable-${randomUUID().replaceAll("-", "")}`);
await mkdir(launchRoot, { mode: 0o700 });
await chmod(launchRoot, 0o700);
const executable = join(launchRoot, "launch");
const fixture = resolve("test/fixtures/fake-opencode-server.mjs");
execFileSync("cc", ["-x", "c", "-o", executable, "-"], { input: `#include <unistd.h>\n#include <stdlib.h>\nint main(int argc, char **argv) { char **args = calloc(argc + 2, sizeof(char *)); args[0] = ${JSON.stringify(process.execPath)}; args[1] = ${JSON.stringify(fixture)}; for (int i = 1; i < argc; i++) args[i + 1] = argv[i]; execv(args[0], args); return 127; }` });
if (process.platform === "darwin") {
execFileSync("codesign", ["--force", "--sign", "-", executable]);
execFileSync("codesign", ["--verify", "--strict", executable]);
}
await chmod(executable, 0o500);
const proxy = join(root, "proxy.cjs");
await (await import("node:fs/promises")).writeFile(proxy, execFileSync(process.execPath, ["--input-type=module", "-e", `import { bundleVerifiedProviderEntrypoints } from "./scripts/build-verified-provider-entrypoints.mjs"; const entries = await bundleVerifiedProviderEntrypoints({ write: false }); process.stdout.write(entries.find(({ entrypoint }) => entrypoint.name === "opencode-app-server-proxy").verifiedResult.outputFiles[0].contents);`], { maxBuffer: 16 * 1024 * 1024 }));
const fd = process.platform === "linux" ? openSync(executable, "r") : undefined;
const child = spawn(process.execPath, [proxy, "--paperclip-trusted-opencode-executable", fd === undefined ? executable : "/proc/self/fd/3"], {
env: { ...process.env, PAPERCLIP_OPENCODE_RUNTIME_DIR: join(root, "runtime"), OPENROUTER_API_KEY: "fixture-key",
PAPERCLIP_RUN_ID: "attached-run-2" },
stdio: fd === undefined ? ["pipe", "pipe", "pipe"] : ["pipe", "pipe", "pipe", fd],
detached: process.platform !== "win32",
});
if (fd !== undefined) closeSync(fd);
const messages: Array<{ id?: string | number; method?: string; params?: Record<string, unknown>; result?: unknown; error?: unknown }> = [];
const input = createInterface({ input: child.stdout! });
input.on("line", line => messages.push(JSON.parse(line)));
child.stderr!.resume();
let exited = false;
const exit = new Promise<void>(resolve => child.once("exit", () => { exited = true; resolve(); }));
const send = (message: unknown) => child.stdin!.write(`${JSON.stringify(message)}\n`);
const response = async (id: number) => {
await expect.poll(() => messages.find(message => message.id === id), { timeout: 5_000 }).toBeDefined();
const value = messages.find(message => message.id === id)!;
expect(value.error).toBeUndefined();
return value.result;
};
try {
send({ id: 1, method: "initialize", params: {} });
await response(1);
// Launch parameters carry the saved run's contract.
send({ id: 2, method: "thread/start", params: { cwd: root, model: "openrouter/deepseek/deepseek-v4-flash-0731", completionContract: { revision: "1", criterionIds: ["objective"] }, conversationMode: "prepared" } });
await response(2);
send({ id: 3, method: "turn/start", params: { paperclipRunId: "attached-run-2",
paperclipCompletionContract: { revision: "2", criterionIds: ["pending_comment_1", "pending_comment_2"] },
input: [{ type: "text", text: JSON.stringify({ schema: "paperclip.native-model-envelope.v3", task: { prompt: "completion-feedback" },
completionContract: { revision: "2", criteria: [{ id: "pending_comment_1", requirement: "Use a readable font." }, { id: "pending_comment_2", requirement: "Add a contact section." }] } }) }] } });
await response(3);
// The finish claim reaches the controller on its first try.
await expect.poll(() => messages.find(message => message.method === "item/tool/call"), { timeout: 5_000 }).toBeDefined();
const call = messages.find(message => message.method === "item/tool/call")!;
expect(call.params).toMatchObject({ tool: "paperclip_finish", arguments: { completionClaim: { contractRevision: "2" } } });
send({ id: call.id, result: { success: true, contentItems: [{ type: "inputText", text: "Accepted." }] } });
await expect.poll(() => messages.filter(message => message.method === "paperclip/runResult").length, { timeout: 5_000 }).toBe(1);
expect(JSON.stringify(messages.find(message => message.method === "paperclip/runResult"))).toContain('"contractRevision":"2"');
child.stdin!.end();
await expect.poll(() => exited, { timeout: 5_000 }).toBe(true);
await exit;
} finally {
child.kill("SIGTERM");
if (!exited) {
await Promise.race([exit, new Promise(resolve => setTimeout(resolve, 1_000))]);
if (!exited && child.pid) process.kill(process.platform === "win32" ? child.pid : -child.pid, "SIGKILL");
await exit;
}
input.close();
await rm(root, { recursive: true, force: true });
}
}, 30_000);
@@ -22,6 +22,17 @@ export function openCodeProxyCompletionContract(value: unknown): { revision: str
: undefined;
}
/** Whether two per-turn contracts name the same revision and criterion ids, in order. */
export function sameOpenCodeProxyCompletionContract(
left: { revision: string; criterionIds: readonly string[] } | undefined,
right: { revision: string; criterionIds: readonly string[] } | undefined,
): boolean {
if (left === undefined || right === undefined) return left === right;
return left.revision === right.revision
&& left.criterionIds.length === right.criterionIds.length
&& left.criterionIds.every((id, index) => id === right.criterionIds[index]);
}
export function openCodeProxyTaskEnvelope(params: Record<string, unknown>) {
const completionContract = record(params.completionContract);
const revision = text(completionContract.revision).trim();
@@ -2579,6 +2579,25 @@ describe("OpenCodeServerDriver", () => {
await session.close({ reason: "test" });
});
it("names the expected revision in a repairable stale-revision tool error", async () => {
await chmod(fixture, 0o755);
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-revision-"));
const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-workspace-"));
roots.push(root, workspace);
const driver = new OpenCodeServerDriver({ model: "openrouter/deepseek/deepseek-v4-flash-0731", runtimeDirectory: root, command: fixture, environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" }, taskEnvelope: createCodexTaskEnvelope({ objective: "Apply the accepted decision", contractRevision: "approval-v2", criteria: [{ id: "human_response", requirement: "Apply the approved response" }] }) });
const session = await driver.openSession({ runId: "revision-repair", normalizedSessionId: "revision-repair", workingDirectory: workspace });
await session.startTurn({ message: { role: "user", text: "repair-revision" } });
const events = await collectTurnEvents(session.events());
const results = events.filter((event) => event.eventType === "run.result.proposed");
expect(results).toHaveLength(1);
expect(results[0].payload).toMatchObject({ completionClaim: { contractRevision: "approval-v2" } });
const files = await readdir(root, { recursive: true });
const evidence = files.find((name) => name.endsWith("fake-revision-repair.json"));
expect(evidence).toBeDefined();
expect(JSON.parse(await readFile(join(root, evidence!), "utf8"))).toMatchObject({ result: { isError: true, content: [{ text: expect.stringContaining('Use contractRevision "approval-v2"') }] } });
await session.close({ reason: "test" });
});
it.each(["monitor", "response_wake"] as const)("resolves an omitted %s through the real OpenCode MCP boundary only from current server state", async kind => {
for (const eligible of [true, false]) {
await chmod(fixture, 0o755);
@@ -1232,8 +1232,10 @@ class OpenCodeHarnessSession implements HarnessSession {
validation.result.completionClaim.contractRevision !==
this.#taskEnvelope.completionContract.revision
) {
// Name the expected revision so the provider can repair its claim
// in this turn, as the criteria error below does.
throw new Error(
"Semantic result completion contract revision does not match",
`Semantic result completion contract revision does not match. Use contractRevision ${JSON.stringify(this.#taskEnvelope.completionContract.revision)}.`,
);
}
const expectedIds = this.#taskEnvelope.completionContract.criteria.map((criterion) => criterion.id);
@@ -275,6 +275,12 @@ async function callTerminalTool(promptBody, signal) {
const rejected = await mcpRequest("tools/call", { name: "paperclip_finish", arguments: bad });
await writeFile(join(process.env.XDG_DATA_HOME, "fake-criteria-repair.json"), JSON.stringify(rejected));
}
if (String(prompt.message ?? "").includes("repair-revision")) {
const stale = structuredClone(result);
stale.completionClaim.contractRevision = "stale-revision";
const rejected = await mcpRequest("tools/call", { name: "paperclip_finish", arguments: stale });
await writeFile(join(process.env.XDG_DATA_HOME, "fake-revision-repair.json"), JSON.stringify(rejected));
}
const call = () => mcpRequest("tools/call", {
name: blocked ? "paperclip_block" : "paperclip_finish",
arguments: result,