phase D: delete the subprocess path

The serve is now the only way an opencode turn runs. runner.ts and its tests are gone, and
so is the OPENCODE_TURNS switch — there is no fallback engine any more, and the recovery for
a bad day is git rather than a config flag. Deliberate, and cheap right now precisely because
nothing depends on opencode yet.

What goes with it: mapRunLine and its NDJSON fixtures, the temp-file spill for --file image
attachments, the supersede-and-kill dance, the process watchdogs, the pidfile-adjacent child
tracking, and stopAllOpenCodeTurns. All of it existed to work around stdin being /dev/null.

Verified after deletion, with no env var set at all: tool call, tool result, 5 streaming
deltas, text and cost, through the real chat socket.

Also corrected the comments the deletion falsified rather than leaving them to mislead — the
module header, the wire contract description of opencode:run-streaming, and serve-runner own
header, which still announced itself as off by default.

One difference worth stating: shutdown no longer kills anything. Turns run inside the serve,
which is a separate process that survives us, so officer stops routing them and says so in
the transcript. When a subprocess ran the turn, failing to kill it orphaned it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 19:33:58 +01:00
co-authored by Claude Opus 5
parent b79eca45fb
commit a3dbda7d3b
5 changed files with 44 additions and 818 deletions
+29 -12
View File
@@ -1,19 +1,17 @@
import type { ChatEvent, MessageCost } from '../../api/chat/types';
import type { OpenCodeRunParams } from '../protocol';
import type { RunnerMessage } from './runner';
import { mapServeEvent } from './serve-events';
// Phase B: drive a turn through the serve instead of spawning `opencode run`.
// How an OpenCode turn runs. The only way, since 2026-08-10.
//
// OFF BY DEFAULT. `index.ts` picks between this and `runner.ts` on `OPENCODE_TURNS`, and the subprocess
// stays the default until this has run for a while — a bad day should be one restart from the path that
// has worked all along, not a rollback.
// It used to be `opencode run --format json`, a subprocess per turn with `stdin: 'ignore'`. Everything
// that path could not do followed from that one closed pipe: no token streaming, no mid-turn injection,
// no queue, and a stop that could only kill the session rather than interrupt it. The serve offers all
// four as primitives, each verified end to end before the subprocess was deleted
// (docs/opencode-fork-decision.md, docs/opencode-serve-migration-plan.md).
//
// ── Why bother, given the subprocess works ──
//
// Everything the subprocess cannot do is a consequence of `stdin: 'ignore'`: no token streaming, no
// mid-turn injection, no queue, no interrupt that leaves the session alive. The serve offers all four as
// primitives, verified against 1.18.16 (docs/opencode-fork-decision.md).
// There is no fallback engine any more. If this path breaks, the recovery is git, not a config flag —
// a deliberate choice made while nothing depended on OpenCode.
//
// ── One global stream, demultiplexed ──
//
@@ -39,6 +37,18 @@ type ServeConfig = {
fallbackCwd: string;
};
/**
* What a turn reports to the sidecar it runs in.
*
* Lived in `runner.ts` until the subprocess path was deleted (Phase D). `opencode:event` is not a wire
* event: the sidecar translates each one into a TurnMessage and commits it to `chat_session_events`
* before officer sees anything, so the durable record does not depend on officer being up.
* `opencode:session` is the routing fact — which `ses_…` to resume — and goes over the wire live.
*/
export type RunnerMessage =
| { type: 'opencode:event'; sessionKey: string; event: ChatEvent }
| { type: 'opencode:session'; sessionKey: string; sessionId: string };
type Emit = (msg: RunnerMessage) => void;
type ServeTurn = {
@@ -301,7 +311,7 @@ function promptFiles(images: OpenCodeRunParams['images']): { uri: string; name:
}));
}
/** The serve analog of `listRunningOpenCodeTurns`. Same shape, so the Live panel needs no changes. */
/** The turns running right now, for `opencode:list` and the Live panel. */
export function listRunningServeTurns(): { sessionKey: string }[] {
return [...bySessionKey.keys()].map((sessionKey) => ({ sessionKey }));
}
@@ -326,7 +336,14 @@ export async function killServeTurn(sessionKey: string, config: ServeConfig): Pr
retire(turn, { type: 'stopped' });
}
/** Retire every live turn, for shutdown. Mirrors `stopAllOpenCodeTurns` on the subprocess path. */
/**
* Retire every live turn, for shutdown.
*
* Note what this does NOT do, and why it is right: the turns keep running inside the serve, which is a
* separate process and survives us. Officer stops routing them and says so in the transcript. When the
* subprocess ran turns, shutdown had to kill children or they were orphaned; here the work is somebody
* else's and killing it would be the wrong call.
*/
export function stopAllServeTurns(message: string): number {
const turns = [...bySessionKey.values()];
for (const turn of turns) retire(turn, { type: 'error', message });