Settle OpenCode completion before ending its turn

Keep controller acceptance, inner result and tool feedback consistent during idle, error, abort, close and interruption. Reject concurrent finishing calls before controller admission. Preserve the stopped zero-provider campaign and qualify only a fresh bounded source.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-04 22:12:16 -05:00
1 parent e171282078
commit c3e0cb303a
5 files changed
+124 -3

No files matched your search

@@ -19,3 +19,5 @@ The receipt-only correction at `be604fcbf81ff238ee38774ff6e644bf0d84eb2d` failed
OpenCode's inner driver admitted the finishing report locally without invoking the controller feedback handler. Its proxy also discarded the controller response envelope, including the success flag. The correction forwards the exact terminal call ID, thread, turn, tool and structured result through the existing controller request channel. It waits for the controller before committing the inner result, preserves accepted response text and concrete rejection reasons, and rejects malformed acceptance or a turn that ended during feedback. Dynamic-tool unwrapping stays unchanged. A provider-free test crosses the real runnerd, proxy and fake OpenCode boundary: the first completion is rejected, the provider corrects it in the same turn, the second receives the exact final-response feedback, and only one semantic result is proposed.
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.
@@ -2153,6 +2153,86 @@ describe("OpenCodeServerDriver", () => {
await session.close({ reason: "test" });
});
it.each(["session.idle", "session.status", "session.error", "aborted"])("settles accepted completion before a racing %s event", async terminalType => {
await chmod(fixture, 0o755);
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-completion-race-"));
const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-completion-race-workspace-"));
roots.push(root, workspace);
let stream: ReadableStreamDefaultController<Uint8Array>;
let releaseFeedback!: () => void;
let feedbackStarted!: () => void;
const started = new Promise<void>(resolve => { feedbackStarted = resolve; });
const release = new Promise<void>(resolve => { releaseFeedback = resolve; });
const feedback = "Accepted. Keep the exact saved document link in the final response.";
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" },
fetch: async (input, init) => String(input).endsWith("/event")
? new Response(new ReadableStream<Uint8Array>({ start(controller) { stream = controller; } }), { headers: { "Content-Type": "text/event-stream" } })
: fetch(input, init),
completionFeedback: async () => { feedbackStarted(); await release; return feedback; },
});
const session = await driver.openSession({ runId: "completion-race", normalizedSessionId: "completion-race", workingDirectory: workspace });
try {
const events = collectTurnEvents(session.events());
await session.startTurn({ message: { role: "user", text: "completion-feedback" } });
await started;
stream!.enqueue(new TextEncoder().encode(`data: ${JSON.stringify({ type: terminalType === "aborted" ? "session.error" : terminalType, id: "terminal-during-feedback", properties: { sessionID: session.ids().providerSessionId, status: { type: "idle" }, error: terminalType === "aborted" ? { name: "MessageAbortedError", data: { message: "Aborted" } } : { name: "FixtureError", message: "Provider ended during feedback." } } })}\n\n`));
// Drain the queued SSE frame before releasing the controller response.
await new Promise<void>(resolve => setImmediate(resolve));
releaseFeedback();
const observed = await events;
const proposed = observed.findIndex(event => event.eventType === "run.result.proposed");
const completed = observed.findIndex(event => event.eventType === "item.completed" && event.payload.kind === "dynamicToolCall" && (event.payload.item as { result?: unknown })?.result === feedback);
const terminal = observed.findIndex(event => TURN_TERMINAL_EVENT_TYPES.has(event.eventType));
expect(proposed).toBeGreaterThanOrEqual(0);
expect(completed).toBeGreaterThan(proposed);
expect(terminal).toBeGreaterThan(completed);
expect(observed.filter(event => event.eventType === "run.result.proposed")).toHaveLength(1);
expect((await session.snapshot()).semanticResult).not.toBeNull();
const sessionRoots = (await readdir(root, { withFileTypes: true })).filter(entry => entry.isDirectory());
await expect.poll(async () => JSON.parse(await readFile(join(root, sessionRoots[0]!.name, "data/fake-completion-feedback.json"), "utf8"))).toMatchObject([
{ result: { content: [{ text: expect.stringContaining(feedback) }] } },
]);
} finally {
releaseFeedback();
await session.close({ reason: "test complete" });
}
});
it.each(["close", "interrupt"] as const)("settles bound completion before explicit %s", async operation => {
await chmod(fixture, 0o755);
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-completion-stop-"));
const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-completion-stop-workspace-"));
roots.push(root, workspace);
let releaseFeedback!: () => void, feedbackStarted!: () => void;
const started = new Promise<void>(resolve => { feedbackStarted = resolve; });
const release = new Promise<void>(resolve => { releaseFeedback = resolve; });
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" },
completionFeedback: async () => { feedbackStarted(); await release; return "Accepted completion."; },
});
const session = await driver.openSession({ runId: "completion-stop", normalizedSessionId: "completion-stop", workingDirectory: workspace });
try {
await session.startTurn({ message: { role: "user", text: "finish" } });
await started;
let stopped = false;
const stop = session[operation]({ reason: "fixture stop" }).then(() => { stopped = true; });
await new Promise<void>(resolve => setImmediate(resolve));
expect(stopped).toBe(false);
releaseFeedback();
await stop;
expect((await session.snapshot()).semanticResult).not.toBeNull();
const transcript = await session.transcript();
expect(transcript.events.filter(event => event.eventType === "run.result.proposed")).toHaveLength(1);
expect(transcript.events.some(event => event.eventType === "item.completed" && (event.payload.item as { is_error?: boolean })?.is_error)).toBe(false);
} finally {
releaseFeedback();
await session.close({ reason: "test complete" });
}
});
it("rejects malformed and oversized SSE frames", async () => {
const stream = (value: string) =>
new ReadableStream<Uint8Array>({
@@ -444,6 +444,7 @@ class OpenCodeHarnessSession implements HarnessSession {
readonly #conversationMode: "task" | "prepared";
#sendFullContext: boolean;
#closed = false;
#completionSettlement: Promise<void> | null = null;
#abort = new AbortController();
constructor(input: {
@@ -625,6 +626,7 @@ class OpenCodeHarnessSession implements HarnessSession {
}
async interrupt(input: { turnId?: string; reason?: string }): Promise<void> {
await this.#completionSettlement;
if (
input.turnId &&
this.#activeTurnId &&
@@ -874,6 +876,10 @@ class OpenCodeHarnessSession implements HarnessSession {
}
async close(): Promise<void> {
if (this.#closed) return;
// A controller response is authoritative. Settle its tool result before
// closing the provider or the event stream, rather than reject after acceptance.
await this.#completionSettlement;
if (this.#closed) return;
this.#closed = true;
this.#abort.abort();
@@ -942,7 +948,10 @@ class OpenCodeHarnessSession implements HarnessSession {
{ turnId, itemId: call.callId },
);
if (tool === PRP_COMPLETION_TOOL_NAME || tool === PRP_BLOCK_TOOL_NAME) {
let settle: (() => void) | undefined;
try {
if (this.#closed || this.#completionSettlement)
throw new Error("A completion is already settling or the session is closed");
const validation = validatePrpStructuredRunResult(call.arguments);
if (!validation.ok) throw new Error("Invalid semantic result");
if (
@@ -972,6 +981,7 @@ class OpenCodeHarnessSession implements HarnessSession {
const fingerprint = canonicalJson(validation.result);
if (this.#resultFingerprint && this.#resultFingerprint !== fingerprint)
throw new Error("A different semantic result was already committed");
this.#completionSettlement = new Promise<void>(resolve => { settle = resolve; });
// Wait for the bound controller before committing or resolving the
// provider call. A rejection remains repairable in this same turn.
const feedback = this.#completionFeedback
@@ -981,8 +991,6 @@ class OpenCodeHarnessSession implements HarnessSession {
: "Semantic completion accepted.";
if (typeof feedback !== "string" || !feedback.trim())
throw new Error("Completion feedback omitted its response text");
if (this.#closed || this.#activeTurnId !== turnId || this.#terminalTurns.has(turnId))
throw new Error("The turn ended while checking completion. The result was not accepted.");
if (this.#resultFingerprint && this.#resultFingerprint !== fingerprint)
throw new Error("A different semantic result was already committed");
if (!this.#resultFingerprint) {
@@ -1019,6 +1027,11 @@ class OpenCodeHarnessSession implements HarnessSession {
is_error: true, error: error instanceof Error ? error.message : String(error) },
}, { turnId, itemId: call.callId });
throw error;
} finally {
if (settle) {
this.#completionSettlement = null;
settle();
}
}
}
if (!this.#dynamicToolHandler)
@@ -1303,6 +1316,16 @@ class OpenCodeHarnessSession implements HarnessSession {
}
throw error;
}
const type = text(record(event).type);
const properties = record(record(event).properties);
if (type === "session.idle" || type === "session.error"
|| (type === "session.status" && text(record(record(event).properties).status && record(record(record(event).properties).status).type) === "idle")) {
// Do not seal the turn while its bound controller is deciding a
// finishing call. Acceptance/rejection and the tool result must
// precede the provider's terminal event.
await this.#completionSettlement;
if (this.#closed) return;
}
this.#mapProviderEvent(event, frameId);
}
throw new Error(
@@ -1312,6 +1335,8 @@ class OpenCodeHarnessSession implements HarnessSession {
if (this.#closed || this.#abort.signal.aborted) return;
attempts += 1;
if (attempts > 3) {
await this.#completionSettlement;
if (this.#closed) return;
this.#emit("harness.diagnostic", {
code: "opencode_sse_failed",
message: redact(String(error), this.#runtime.sensitiveValues),
@@ -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"]).toContain(currentVariant);
expect(["baseline", "candidate", "corrected", "feedback", "opencodeFeedback", "opencodeFeedbackSettlement"]).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"));
@@ -73,6 +73,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",
},
opencodeFeedbackSettlement: {
"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": "6f84af59e2a5e03b2eac48b094da3dfcbf213fe727350c79fbbef4cc554f03d2",
"packages/paperclip-runner/src/cli/opencode-proxy-input.ts": "a7bf61c79047bf1aa205b32bbecc962ade4934ebac1fbbb5bcaa3a404f868ce7",
},
} as const;
// Admission explicitly binds every changed production path as well as comparison setup.
@@ -80,6 +93,7 @@ const comparisonFiles = new Set([
...Object.keys(NATIVE_INSTRUCTION_VARIANTS.baseline),
"packages/paperclip-runner/src/backends/runtime-context.test.ts",
"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/live/runnerd-codex-transport.test.ts",
"packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs",