Unblock OpenCode controller responses during interruption

Keep commands ordered while processing bound RPC responses independently. Fail pending controller requests on EOF before draining the queue. Verify acceptance, interruption and shutdown through the real bundled proxy and retain prior reviews and stopped campaigns.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-04 22:26:55 -05:00
1 parent c3e0cb303a
commit 267aeb3c5d
5 files changed
+116 -1

No files matched your search

@@ -21,3 +21,5 @@ OpenCode's inner driver admitted the finishing report locally without invoking t
This correction changes four additional OpenCode-only production paths. The shared instruction, server receipt/feedback and UI bytes remain identical to 3a7349d. Admit the new source and measure all scripted boundaries again. Confirm only the two affected OpenCode live cases on the new immutable source, one attempt each with the original model, deadlines and budget stops. Keep the four passing Claude/Codex observations explicitly attributed to 3a7349d, backed by the exact provider-path delta and final-head CI. Do not relabel them as live runs on the new head. No new baseline or unchanged-source paid retry is included.
The e171282 confirmation campaign 37257534042 was stopped during build after fresh review identified a controller/inner-session acceptance race. No paid step started and no provider was executed. Provider-free regression reproduces accepted feedback losing its result when idle/error arrives in flight. Completion settlement now holds terminal mapping, explicit interruption and close until the bound decision and tool result finish, and refuses a concurrent terminal submission before invoking the controller. Raw SSE frames remain retained at receipt; normalized terminal settlement follows the finishing decision. Confirm the same two OpenCode cases only on the newly admitted immutable source; preserve e171282's completed failing review and zero-provider campaign.
Fresh c3e0cb303 review identified a real serial-input dependency: an interrupt waits for completion while its controller response queues behind the interrupt. No paid campaign was admitted on this source. The proxy now handles only bound response frames outside the serial command queue; commands retain their order and bootstrap failure gates. EOF rejects existing controller waiters and prevents new unanswered requests, then drains the command queue and closes. A real production-bundled proxy, verified local native launcher and fake OpenCode MCP/SSE server reproduce the deadlock before the fix and verify accepted feedback reaches the provider, interruption completes, exactly one result is proposed and EOF exits. A second EOF-without-feedback case proposes no result. This is zero-provider proxy coverage; the real runnerd boundary remains separately covered. Fixture setup and macOS launcher failures are retained, not relabeled as behavioral failures.
@@ -42,6 +42,7 @@ const pending = new Map<
string,
{ resolve(value: unknown): void; reject(error: Error): void; retainEnvelope: boolean }
>();
let controllerInputClosed = false;
let nextServerRequestId = 1;
let driver: OpenCodeServerDriver | null = null;
let session: HarnessSession | null = null;
@@ -58,6 +59,7 @@ function send(value: unknown): void {
}
function requestController(method: string, params: unknown, retainEnvelope = false): Promise<unknown> {
if (controllerInputClosed) return Promise.reject(new Error("OpenCode controller input is closed"));
const id = `opencode-${nextServerRequestId++}`;
send({ id, method, params });
return new Promise((resolveValue, reject) =>
@@ -411,6 +413,12 @@ input.on("line", (line) => {
process.stderr.write(`Invalid JSON-RPC input: ${String(error)}\n`);
return;
}
if (message.method === undefined && message.id !== undefined) {
// A command may await this bound response. Queueing the response behind
// that command would deadlock completion, interruption and shutdown.
void handle(message).catch(failProxy);
return;
}
pendingInput = enqueueOpenCodeProxyInput(
pendingInput,
async () => {
@@ -461,6 +469,12 @@ function shutdown(exitCode = 0): Promise<void> {
}
input.on("close", () => {
controllerInputClosed = true;
for (const waiter of pending.values())
waiter.reject(new Error("OpenCode controller input closed before its response"));
pending.clear();
// Preserve command order and the bootstrap drain. A queued command cannot
// create a new unanswered controller request after EOF.
void pendingInput.then(() => shutdown());
});
process.on("SIGTERM", () => {
@@ -57,3 +57,88 @@ describe("OpenCode proxy input sequencing", () => {
expect(events).toEqual(["initialize failed"]);
});
});
it.each([true, false])("settles a real proxy interrupt with controller feedback or EOF (feedback: %s)", async respond => {
const { execFileSync, spawn } = await import("node:child_process");
const { chmod, mkdir, mkdtemp, readFile, readdir, rm, writeFile } = 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-interrupt-feedback-"));
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 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 runtime = join(root, "runtime");
const child = spawn(process.execPath, [proxy, "--paperclip-trusted-opencode-executable", fd === undefined ? executable : "/proc/self/fd/3"], {
env: { ...process.env, PAPERCLIP_OPENCODE_RUNTIME_DIR: runtime, OPENROUTER_API_KEY: "fixture-key" },
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);
send({ id: 2, method: "thread/start", params: { cwd: root, model: "openrouter/deepseek/deepseek-v4-flash-0731", completionContract: { revision: "proxy-v1", criterionIds: ["objective"] }, conversationMode: "prepared" } });
await response(2);
send({ id: 3, method: "turn/start", params: { 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." }] } }) }] } });
await response(3);
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!.tool).toBe("paperclip_finish");
const feedback = "Accepted. Include [Saved document](/PAP/issues/PAP-1#document-plan).";
// The interrupt is ahead of the response on stdin. Commands stay serial,
// but their awaited response must not queue behind the command itself.
send({ id: 4, method: "turn/interrupt", params: { turnId: call.params!.turnId } });
if (respond) {
send({ id: call.id, result: { success: true, contentItems: [{ type: "inputText", text: feedback }] } });
} else {
child.stdin!.end();
}
expect(await response(4)).toBe(true);
if (respond) {
const sessions = (await readdir(runtime, { withFileTypes: true })).filter(entry => entry.isDirectory());
await expect.poll(async () => JSON.parse(await readFile(join(runtime, sessions[0]!.name, "data/fake-completion-feedback.json"), "utf8"))).toMatchObject([
{ result: { content: [{ text: expect.stringContaining(feedback) }] } },
]);
child.stdin!.end();
}
await expect.poll(() => exited, { timeout: 5_000 }).toBe(true);
await exit;
expect(messages.filter(message => message.method === "paperclip/runResult")).toHaveLength(respond ? 1 : 0);
} 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);
@@ -37,7 +37,7 @@ describe("native instruction comparison admission", () => {
expect(nativeInstructionVariant(file => baseline.get(file)!)).toBe("baseline");
const candidate = new Map(files.map(file => [file, readFileSync(new URL(`../../${file}`, import.meta.url))]));
const currentVariant = nativeInstructionVariant(file => candidate.get(file)!);
expect(["baseline", "candidate", "corrected", "feedback", "opencodeFeedback", "opencodeFeedbackSettlement"]).toContain(currentVariant);
expect(["baseline", "candidate", "corrected", "feedback", "opencodeFeedback", "opencodeFeedbackSettlement", "opencodeFeedbackResponses"]).toContain(currentVariant);
candidate.set(files[0]!, currentVariant === "candidate" ? baseline.get(files[0]!)! : Buffer.from("unknown source"));
expect(() => nativeInstructionVariant(file => candidate.get(file)!)).toThrow("Mixed or unknown");
baseline.set(files[0]!, Buffer.from("unknown source"));
@@ -86,6 +86,19 @@ export const NATIVE_INSTRUCTION_VARIANTS = {
"packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts": "6f84af59e2a5e03b2eac48b094da3dfcbf213fe727350c79fbbef4cc554f03d2",
"packages/paperclip-runner/src/cli/opencode-proxy-input.ts": "a7bf61c79047bf1aa205b32bbecc962ade4934ebac1fbbb5bcaa3a404f868ce7",
},
opencodeFeedbackResponses: {
"packages/paperclip-runner/src/backends/runtime-context.ts": "bbdab79c5b1bd57c4ddbc44edfe745b40eb694aa28a465452964109aedd6104b",
"packages/paperclip-runner/src/backends/codex-native-backend.ts": "affecc515a623e0dfeb338f553ea53ebb7d18baa3d13e4395d17b21000dbee93",
"packages/paperclip-runner/src/backends/opencode-native-backend.ts": "d5dc4cc2c06b37d22be47c9f15f828c06b439d8241a8d34498ca4339273eed96",
"server/src/services/native-runtime/paperclip-runner-tool-authority.ts": "d2360cdaa63902cbf0c6a6109da452ba7e0b2a5aaa76ee5cab9c63b4bf12e584",
"server/src/services/native-runtime/native-completion-feedback.ts": "1f9da911ec5107c6b7543bc1b4134fc74597a29af6ed40101a895045a9e26ce0",
"ui/src/lib/issue-reference.ts": "ab578752acc7e185333cb2b701f6db71cedbb44675db042f78fa88417e7b2ddf",
"ui/src/components/MarkdownBody.tsx": "f3608614b143667f7dba087be81fd1449e4eac268a203e41256d1c99691c2541",
"packages/paperclip-runner/src/backends/native-backend-factory.ts": "63c8aee5548bc1b711a8b92bc348a00b6a7454d7815757380fb719988a90237f",
"packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts": "e1b40e56cb05f3851f42ebf3d94be6aca8f0a2025820ddfd15ca01439d9eefb6",
"packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts": "48b6a672bdd63297ef773736e9768cbc5a968a371e85c9356f7733e4d39282a0",
"packages/paperclip-runner/src/cli/opencode-proxy-input.ts": "a7bf61c79047bf1aa205b32bbecc962ade4934ebac1fbbb5bcaa3a404f868ce7",
},
} as const;
// Admission explicitly binds every changed production path as well as comparison setup.
@@ -95,6 +108,7 @@ const comparisonFiles = new Set([
"packages/paperclip-runner/src/backends/native-backend-factory.test.ts",
"packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts",
"packages/paperclip-runner/src/cli/opencode-proxy-completion.test.ts",
"packages/paperclip-runner/src/cli/opencode-proxy-input.test.ts",
"packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts",
"packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs",
"packages/paperclip-runner/src/backends/native-instruction-measurement.test.ts",