diff --git a/server/src/services/native-runtime/native-bootstrap-proof.ts b/server/src/services/native-runtime/native-bootstrap-proof.ts index 1e0476d4e27..bf7660c5ccb 100644 --- a/server/src/services/native-runtime/native-bootstrap-proof.ts +++ b/server/src/services/native-runtime/native-bootstrap-proof.ts @@ -97,7 +97,9 @@ function collectRecordedProcessIds(value: unknown, into: Array<{ id: unknown; gr if (Array.isArray(value)) return value.every(entry => collectRecordedProcessIds(entry, into, depth + 1)); if (!value || typeof value !== "object") return true; for (const [key, entry] of Object.entries(value)) { - if (key === "processId" || key === "pid") into.push({ id: entry, group: false }); + // A null id records a launch that had not produced a process yet, so + // there is nothing to prove gone. Any other non-number fails the proof. + if (key === "processId" || key === "pid") { if (entry !== null) into.push({ id: entry, group: false }); } else if (key === "processGroupId") { if (entry !== null) into.push({ id: entry, group: true }); } else if (!collectRecordedProcessIds(entry, into, depth + 1)) return false; } diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 0cfb4377f7d..fe16e4da45e 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -1857,6 +1857,44 @@ describe("native incomplete-bootstrap evidence", () => { it("stays fail-closed after session.open for a runner whose processes are on another host", async () => { expect(await provesLocally(drainedAfterSessionOpen(await deadPid()), false)).toBe(false); }); + + // OpenCode records provider startup ownership twice while session.open + // runs: first before launch (no process yet, ids null), then with the + // launched server's process and process group. + const openCodeStartup = (sourceSeq: number, processId: number | null) => ({ + sourceSeq, priority: 0, eventType: "harness.diagnostic", envelope: { kind: "event", payload: { + eventType: "harness.diagnostic", payload: { code: "provider_startup_ownership", startup: { + attemptedProcessGeneration: 1, authenticatedThreadId: null, directChildExitObserved: false, exitCode: null, + failedStage: null, command: { commandType: "session.open", controllerSeq: 2 }, + processId, processGroupId: processId, + } } } }, + }); + const drainedOpenCodeAfterSessionOpen = (pid: number) => { + const fixture = drainedAfterSessionOpen(pid); + fixture.runner.nextSourceSeq = 6; + fixture.runner.outbox = [openCodeStartup(4, null), openCodeStartup(5, pid)]; + return fixture; + }; + + it("allows a fresh bootstrap after a drain once OpenCode opened its session before the first turn", async () => { + expect(await provesLocally(drainedOpenCodeAfterSessionOpen(await deadPid()))).toBe(true); + }); + + it.each<[string, (fixture: ReturnType) => void]>([ + ["the OpenCode server process is alive", f => { + (f.runner.outbox as unknown[])[1] = openCodeStartup(5, process.pid); + }], + ["a startup process id is not a number", f => { + const startup = openCodeStartup(5, null); + (startup.envelope.payload.payload.startup as Record).processId = "8174"; + (f.runner.outbox as unknown[])[1] = startup; + }], + ["OpenCode started a turn", f => { (f.runner.outbox as unknown[]).push(sessionEvent(6, "turn.started")); }], + ])("stays fail-closed for OpenCode when %s", async (_label, mutate) => { + const fixture = drainedOpenCodeAfterSessionOpen(await deadPid()); + mutate(fixture); + expect(await provesLocally(fixture)).toBe(false); + }); }); describe("native provider usage normalization", () => {