diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 12afad1ec74..ca5ae86945e 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -2279,12 +2279,21 @@ HTTP mutation or create another receipt. Missing, changed, pending or unknown outcomes remain unanswered for authoritative reconciliation; a stopped run or revoked authority cannot recover a result. -The native Claude sidecar also sets its HTTP MCP client budget to one hour, -matching the Runner connection lease budget. This prevents Claude's default +The native Claude sidecar also sets its HTTP MCP client budget to one hour. +This fixed client ceiling prevents Claude's default 60-second HTTP deadline from aborting an otherwise pending durable request. The setting applies to that native Claude session's MCP client; other provider sessions and ordinary bridge deadlines remain unchanged. Explicit cancellation and Runner shutdown still take precedence over that client budget. +The native OpenCode proxy uses the same one-hour MCP client budget and leaves +its semantic bridge request pending under Runner ownership. A controller delay +must not turn an in-flight write into a provider-visible timeout and retry. +Ordinary OpenCode driver calls keep their 30-second deadline. Stop, bridge close +and Runner lease expiry still end the native wait. Unanswered effects stay +pending and continue to block a new run; a longer wait does not certify an outcome. +The one-hour client deadline does not renew with the Runner lease. If the client +deadline expires while Runner still owns a call, its effect remains unknown +until authoritative reconciliation. Expiry permits neither replay nor attachment. An adopted transport can receive authenticated queued semantic calls before the driver publishes its recovered session and tool handler. It holds those calls until the handler is installed, then rechecks the original authority and diff --git a/maps/agent-access-map/runs-adapters.md b/maps/agent-access-map/runs-adapters.md index 74728827d57..74ba6a7c22f 100644 --- a/maps/agent-access-map/runs-adapters.md +++ b/maps/agent-access-map/runs-adapters.md @@ -320,6 +320,18 @@ session-control access. Deployed diagnostic usefulness remains unverified. ## `session` +Native OpenCode semantic tool requests wait under the existing durable Runner +owner across the ordinary 30-second MCP deadline. The native MCP client uses +a fixed one-hour ceiling; ordinary driver calls keep their deadline. +Company, tool, run and Stop authority do not change. Unanswered effects still +block run attachment, including after the client ceiling expires while the +Runner lease remains valid. The [proxy regression](../../packages/paperclip-runner/src/live/runnerd-opencode-streaming-tools.test.ts) +checks delayed delivery, one tool call, and a settled next run with a deterministic +provider. The [bridge suite](../../packages/paperclip-runner/src/drivers/opencode/mcp-bridge.test.ts) +covers result replay, cancellation and teardown. These are local protocol checks; +live provider and deployed access remain separate checks in the +[session recipe](../feature-map/runs-adapters.md#session). + Fresh remote Retry preparation retains the admitted strategy in its saved fingerprint. An unchanged follow-up must not report strategy drift; actual policy changes retain the replacement guard. This changes no retry, session-reset, diff --git a/maps/feature-map/runs-adapters.md b/maps/feature-map/runs-adapters.md index 51092b077e0..b2c786031c3 100644 --- a/maps/feature-map/runs-adapters.md +++ b/maps/feature-map/runs-adapters.md @@ -129,6 +129,14 @@ the owner's newer completed checkpoint. The cover checkpoint restoration and its guards; real-provider acceptance remains separate evidence. +For native OpenCode, hold an assigned tool result for more than 30 seconds. +Verify that the provider receives the saved result once and the next run can +attach with no pending tool call. Stop and bridge teardown must still end an +unanswered wait without claiming the action failed or repeating it. The +[native proxy regression](../../packages/paperclip-runner/src/live/runnerd-opencode-streaming-tools.test.ts) +uses the real Rust Runner and proxy with a deterministic provider; live provider +and deployed sandbox acceptance remain separate evidence. + After a completed warm turn, change an agent setting that invalidates its instruction copy, then send a follow-up. Verify the old runner suspends during preparation, saved files remain available, and the follow-up completes once. 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 3ebbcab66b7..dc21cb13fcd 100644 --- a/packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts +++ b/packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts @@ -134,6 +134,9 @@ async function open( ), runtimeContext, dynamicTools, + // Runner owns these calls until a durable result, cancellation, or lease + // expiry. A shorter MCP deadline would leave an unknown effect pending. + durableToolRequests: true, completionFeedback: openCodeProxyCompletionFeedback((method, params) => requestController(method, params, true)), dynamicToolHandler: async (call) => requestController("item/tool/call", { diff --git a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.test.ts b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.test.ts index 40c2fc68fd7..94bc3056fbd 100644 --- a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.test.ts +++ b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.test.ts @@ -31,6 +31,59 @@ async function rpc( } describe("OpenCode MCP bridge", () => { + it("keeps a durable call pending across the ordinary timeout and replays its one result", async () => { + let started!: () => void; + const begun = new Promise(resolve => { started = resolve; }); + let complete!: (value: unknown) => void; + const result = new Promise(resolve => { complete = resolve; }); + const aborted = vi.fn(); + const handler = vi.fn(({ signal }) => { + signal.addEventListener("abort", aborted, { once: true }); + started(); + return result; + }); + const bridge = await startOpenCodeMcpBridge({ + tools: [{ name: "documents.write", inputSchema: { type: "object" } }], + timeoutMs: null, + handler, + }); + bridges.push(bridge); + const request = { id: "durable-write", method: "tools/call", params: { name: "documents.write", arguments: {} } }; + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const pending = rpc(bridge, request).then(response => response.json()); + try { + await begun; + await vi.advanceTimersByTimeAsync(30_001); + expect(aborted).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + complete({ receipt: "saved-once" }); + } + const delivered = await pending; + expect(delivered).toMatchObject({ result: { content: [{ text: '{"receipt":"saved-once"}' }] } }); + expect(await (await rpc(bridge, request)).json()).toEqual(delivered); + expect(handler).toHaveBeenCalledOnce(); + }); + + it("cancels an unanswered durable call when the bridge closes without inventing a result", async () => { + let started!: () => void; + const begun = new Promise(resolve => { started = resolve; }); + const aborted = vi.fn(); + const handler = vi.fn(({ signal }) => new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => { aborted(); reject(new Error("unknown outcome")); }, { once: true }); + started(); + })); + const bridge = await startOpenCodeMcpBridge({ + tools: [{ name: "documents.write", inputSchema: { type: "object" } }], timeoutMs: null, handler, + }); + const pending = rpc(bridge, { id: "close-durable", method: "tools/call", params: { name: "documents.write", arguments: {} } }); + await begun; + await bridge.close(); + expect(await (await pending).json()).toMatchObject({ result: { isError: true, content: [{ text: "Paperclip tool call cancelled" }] } }); + expect(aborted).toHaveBeenCalledOnce(); + expect(handler).toHaveBeenCalledOnce(); + }); + it("binds to loopback, requires authentication, and exposes only its closed catalog", async () => { const bridge = await startOpenCodeMcpBridge({ secret: "session-secret", @@ -247,10 +300,11 @@ describe("OpenCode MCP bridge", () => { }); }); - it("honors MCP cancellation notifications", async () => { + it.each([undefined, null])("honors MCP cancellation notifications (timeout=%s)", async timeoutMs => { const started = vi.fn(); const bridge = await startOpenCodeMcpBridge({ tools: [{ name: "documents.read", inputSchema: { type: "object" } }], + timeoutMs, handler: ({ signal }) => new Promise((_resolve, reject) => { started(); signal.addEventListener("abort", () => reject(new Error("handler aborted")), { once: true }); diff --git a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts index af847094c40..40377fe56ad 100644 --- a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts +++ b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts @@ -29,7 +29,8 @@ export interface OpenCodeMcpBridgeOptions { /** Runner-owned operations callable only by a trusted harness extension and never advertised to the model. */ privateTools?: readonly Readonly>[]; handler: (call: OpenCodeMcpCall) => Promise; - timeoutMs?: number; + /** Null leaves durable requests with the owning Runner lease and bridge close. */ + timeoutMs?: number | null; /** Human-gated private operations may remain pending much longer than model tool calls. */ privateToolTimeoutMs?: number; maxBodyBytes?: number; @@ -76,7 +77,7 @@ export async function startOpenCodeMcpBridge( calls, controllers, handler: options.handler, - timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + timeoutMs: options.timeoutMs === null ? null : options.timeoutMs ?? DEFAULT_TIMEOUT_MS, privateToolTimeoutMs: options.privateToolTimeoutMs ?? options.timeoutMs ?? DEFAULT_TIMEOUT_MS, privateToolNames: new Set(privateTools.map((tool) => tool.name)), maxBodyBytes: options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES, @@ -146,7 +147,7 @@ async function handleRequest( calls: Map }>; controllers: Map; handler: OpenCodeMcpBridgeOptions["handler"]; - timeoutMs: number; + timeoutMs: number | null; privateToolTimeoutMs: number; privateToolNames: ReadonlySet; maxBodyBytes: number; @@ -321,23 +322,23 @@ function closeServer(server: Server): Promise { function withCancellationAndTimeout( promise: Promise, controller: AbortController, - timeoutMs: number, + timeoutMs: number | null, ): Promise { return new Promise((resolve, reject) => { let settled = false; const finish = (callback: () => void) => { if (settled) return; settled = true; - clearTimeout(timer); + if (timer !== undefined) clearTimeout(timer); controller.signal.removeEventListener("abort", onAbort); callback(); }; const onAbort = () => finish(() => reject(new Error("Paperclip tool call cancelled"))); - const timer = setTimeout(() => { + const timer = timeoutMs === null ? undefined : setTimeout(() => { finish(() => reject(new Error("Paperclip tool call timed out"))); controller.abort(); }, timeoutMs); - timer.unref?.(); + timer?.unref?.(); controller.signal.addEventListener("abort", onAbort, { once: true }); promise.then( (value) => finish(() => resolve(value)), diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts index 1824f4e4bb2..12880cf0453 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts @@ -153,6 +153,22 @@ afterAll(async () => { }); describe("OpenCodeServerDriver", () => { + it.each([false, true])("uses the native MCP client ceiling only for durable tool requests (%s)", async durableToolRequests => { + await chmod(fixture, 0o755); + const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-tool-budget-")); + roots.push(root); + const driver = new OpenCodeServerDriver({ + model: "openrouter/test-model", command: fixture, runtimeDirectory: root, durableToolRequests, + }); + const session = await driver.openSession({ runId: "budget", normalizedSessionId: "budget", workingDirectory: root }); + try { + const config = JSON.parse(await readFile(join(root, "budget", "config", "opencode", "opencode.json"), "utf8")); + expect(config.mcp.paperclip.timeout).toBe(durableToolRequests ? 60 * 60 * 1_000 : 30_000); + } finally { + await session.close({ reason: "test" }); + } + }); + it("exports company traces across warm runs without giving the ingestion key to the provider", async () => { await chmod(fixture, 0o755); const bodies: Array> = []; 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 01504933c1e..a5c701ecc79 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts @@ -198,6 +198,8 @@ export interface OpenCodeServerDriverOptions { environment?: NodeJS.ProcessEnv; dynamicTools?: readonly Readonly>[]; dynamicToolHandler?: DynamicToolHandler; + /** Internal native proxy capability: semantic calls have durable PRP ownership. */ + durableToolRequests?: boolean; completionFeedback?: OpenCodeCompletionFeedback; onSpawn?: (meta: { pid: number; @@ -2431,6 +2433,7 @@ async function startRuntime(input: { const bridge = await startOpenCodeMcpBridge({ tools: input.options.dynamicTools, handler: input.dispatch, + ...(input.options.durableToolRequests ? { timeoutMs: null } : {}), }).catch(async (error) => { await removeIsolatedHome(isolatedHome).catch(() => undefined); throw error; @@ -2558,7 +2561,10 @@ async function startRuntime(input: { enabled: true, oauth: false, headers: { Authorization: `Bearer ${bridge.secret}` }, - timeout: 30_000, + // Use the same fixed one-hour MCP client ceiling as native ACPX. + // Runner ownership can outlive this deadline; an unanswered effect + // must still remain pending and block replay after client expiry. + timeout: input.options.durableToolRequests ? 60 * 60 * 1_000 : 30_000, }, ...(assignedMcp ? { diff --git a/packages/paperclip-runner/src/live/runnerd-opencode-streaming-tools.test.ts b/packages/paperclip-runner/src/live/runnerd-opencode-streaming-tools.test.ts index 0a37918ac06..604cc561a98 100644 --- a/packages/paperclip-runner/src/live/runnerd-opencode-streaming-tools.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-opencode-streaming-tools.test.ts @@ -12,7 +12,7 @@ const runner = defaultCapabilityRunnerdBinary(); const compilerAvailable = process.platform !== "win32" && spawnSync("cc", ["--version"]).status === 0; const digest = (path: string) => `sha256:${createHash("sha256").update(readFileSync(path)).digest("hex")}`; -it.skipIf(!compilerAvailable || !existsSync(runner)).each(["discovery", "completion"])( +it.skipIf(!compilerAvailable || !existsSync(runner)).each(["discovery", "completion", "durable"])( "acknowledges %s before its MCP deadline after a burst of streamed text", async tool => { const root = await mkdtemp(join(tmpdir(), "runnerd-opencode-streaming-")); @@ -44,10 +44,14 @@ it.skipIf(!compilerAvailable || !existsSync(runner)).each(["discovery", "complet providerNodeCommand: providerNode, providerNodeCommandSha256: digest(providerNode), opencodeCommand: executable, opencodeCommandSha256: digest(executable), opencodeProxyPath: proxy, opencodeProxySha256: digest(proxy), + ...(tool === "durable" ? { lifecyclePolicy: { mode: "warm" as const, idleTimeoutMs: 60_000 } } : {}), environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "synthetic-fixture-key" }, }); transport.setServerRequestHandler(async request => { seen.push(String(request.params.tool)); + // Keep the real Rust → proxy → HTTP MCP request open beyond both old + // 30-second deadlines. The saved result must reach the provider once. + if (tool === "durable" && seen.length === 1) await new Promise(resolve => setTimeout(resolve, 31_000)); return dynamicToolResponse(request.params.tool === "paperclip_finish" ? "Accepted completion." : { tools: [] }); }); let completed: Promise | undefined; @@ -67,10 +71,23 @@ it.skipIf(!compilerAvailable || !existsSync(runner)).each(["discovery", "complet await expect.poll(async () => { try { return JSON.parse(await readFile(join(workspace, "fake-stream-backlog-receipt.json"), "utf8")); } catch { return null; } - }, { timeout: 15_000 }).toMatchObject({ acknowledged: true }); + }, { timeout: tool === "durable" ? 45_000 : 15_000 }).toMatchObject({ acknowledged: true }); await completed; - expect(seen).toEqual(tool === "discovery" ? ["paperclip_search_assigned_tools", "paperclip_finish"] : ["paperclip_finish"]); + expect(seen).toEqual(tool !== "completion" ? ["paperclip_search_assigned_tools", "paperclip_finish"] : ["paperclip_finish"]); expect(text.join("")).toBe("Streaming progress. ".repeat(800)); + if (tool === "durable") { + const runnerPid = transport.processInfo().pid; + await transport.attachRun!({ runId: "next-settled-run", turnId: "next-turn", itemId: "next-item" }); + completed = (async () => { + for await (const event of transport.notifications()) if (event.method === "turn/completed") return; + })(); + await transport.request("turn/start", { input: [{ type: "text", text: "stream-backlog-discovery" }] }); + await completed; + expect(transport.processInfo().pid).toBe(runnerPid); + expect(seen).toEqual(["paperclip_search_assigned_tools", "paperclip_finish", "paperclip_search_assigned_tools", "paperclip_finish"]); + const provider = JSON.parse(await readFile(join(root, "state", "runner", "codex-provider-state.json"), "utf8")); + expect(provider.toolBridge.pending).toEqual({}); + } } finally { await transport.close(); await completed; diff --git a/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs b/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs index 84e8552e1b2..d86ce0f955e 100644 --- a/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs +++ b/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs @@ -160,18 +160,23 @@ function emitFrame(value) { for (const response of clients) response.write(frame); } +let retainedCompletionContract; function parsedPromptText(promptBody) { const text = promptBody.parts?.[0]?.text ?? ""; try { - return JSON.parse(text); + const prompt = JSON.parse(text); + retainedCompletionContract = prompt.completionContract ?? prompt.task?.completionContract ?? retainedCompletionContract; + return prompt; } catch { - return { message: text }; + // Ordinary follow-ups contain only the new message. The real provider + // retains its task contract in conversation history across these turns. + return { message: text, completionContract: retainedCompletionContract }; } } async function mcpRequest(method, params, signal) { const response = await fetch(mcp.url, { - signal, + signal: signal ?? AbortSignal.timeout(mcp.timeout), method: "POST", headers: { ...mcp.headers, @@ -478,7 +483,7 @@ const server = createServer(async (request, response) => { } setTimeout(async () => { const parsedPrompt = parsedPromptText(promptPayload); - const backlogTool = String(parsedPrompt.message ?? "").match(/stream-backlog-(discovery|completion)/)?.[1]; + const backlogTool = String(parsedPrompt.message ?? parsedPrompt.task?.prompt ?? "").match(/stream-backlog-(discovery|completion|durable)/)?.[1]; if (backlogTool) { const messageID = "message-stream-backlog"; const partID = "part-stream-backlog"; @@ -491,13 +496,14 @@ const server = createServer(async (request, response) => { try { const result = backlogTool === "completion" ? await callTerminalTool(promptPayload, AbortSignal.timeout(5000)) - : await mcpRequest("tools/call", { name: "paperclip_search_assigned_tools", arguments: {} }, AbortSignal.timeout(5000)); + : await mcpRequest("tools/call", { name: "paperclip_search_assigned_tools", arguments: {} }, + backlogTool === "durable" ? undefined : AbortSignal.timeout(5000)); receipt = { acknowledged: result.result?.isError !== true && !!result.result, result }; } catch (error) { receipt = { acknowledged: false, timedOut: error.name === "TimeoutError" }; } await writeFile(join(process.cwd(), "fake-stream-backlog-receipt.json"), JSON.stringify(receipt)); - if (backlogTool === "discovery" && receipt.acknowledged) await callTerminalTool(promptPayload); + if (backlogTool !== "completion" && receipt.acknowledged) await callTerminalTool(promptPayload); emit({ type: "session.idle", properties: { sessionID: session.id } }); return; }