phase B: run a turn through the serve, behind a switch
OPENCODE_TURNS=serve picks the new engine; unset keeps the subprocess, which is the default and stays the default until this has been lived with. A bad evening should cost one restart, not a revert. Claude is a different sidecar and is untouched. Verified end to end through the real chat socket: session:init -> tool:start(bash) -> tool:result -> assistant:delta x3 -> assistant:text -> result, cost in=304 out=73 Those deltas are the first token streaming an opencode turn has ever produced in officer. Stop is now an INTERRUPT: the turn ends and the session survives — verified by sending a second prompt to the same session afterwards and getting an answer, which killing a subprocess could never do. Reads the LIVE global stream rather than the durable per-session one, because it is a strict superset — same tool.called, tool.success, step.ended, text.ended, plus the deltas that are the whole point. Global means one socket carries every session, so everything filters on sessionID; one subscription is shared for the process rather than one per turn. A turn ends on step.ended with finish != tool-calls. tool-calls is a step boundary MID-turn, and treating it as terminal would cut every tool-using conversation in half. delivery is stated explicitly as queue because it DEFAULTS to steer, which injects into a running turn — wrong for an ordinary send, where two quick messages would merge into one. Wiring steer to the button that means it is phase C. What phase B does not do: read the durable stream. The sidecar still commits every event to chat_session_events as it arrives, so durability is unchanged, but recovering a turn this process never saw needs the ?after= cursor and is its own change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,9 @@ import { sweepRecordedServe } from './serve-sweep';
|
||||
import { connectProviderCredential } from './connect-credential';
|
||||
import { createSessionLogStore } from '../claude/session-log';
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import type { RunnerMessage } from './runner';
|
||||
import { runOpenCodeTurn, killOpenCodeTurn, listRunningOpenCodeTurns, stopAllOpenCodeTurns } from './runner';
|
||||
import { runOpenCodeTurnOnServe, killServeTurn, listRunningServeTurns, stopAllServeTurns } from './serve-runner';
|
||||
|
||||
// The OpenCode sidecar (officer-opencode). Same philosophy as officer-claude: a singleton process that
|
||||
// OWNS its runtime — here, an `opencode serve` — registers with the API server, and answers commands. It
|
||||
@@ -143,6 +145,17 @@ void connectProviderCredential(baseUrl);
|
||||
type ReplyFn = (msg: SidecarEvent) => void;
|
||||
|
||||
const RUNNER_CONFIG = { bin: OPENCODE_BIN, fallbackCwd: SERVE_CWD };
|
||||
const SERVE_RUNNER_CONFIG = { baseUrl, fallbackCwd: SERVE_CWD };
|
||||
|
||||
// Which engine runs a turn. `subprocess` (the default) spawns `opencode run`; `serve` drives the
|
||||
// serve's /api/session surface, which is the only way to get streaming, steer, queue and a stop that
|
||||
// leaves the session alive.
|
||||
//
|
||||
// A switch rather than a replacement, and defaulted to the old path on purpose: the subprocess has
|
||||
// worked all day and the serve path has not been lived with yet. A bad evening should cost one restart
|
||||
// with OPENCODE_TURNS unset, not a revert.
|
||||
const USE_SERVE_TURNS = (process.env.OPENCODE_TURNS ?? 'subprocess').toLowerCase() === 'serve';
|
||||
console.log(`[opencode] turn engine: ${USE_SERVE_TURNS ? 'serve (/api/session)' : 'subprocess (opencode run)'}`);
|
||||
|
||||
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||
switch (cmd.type) {
|
||||
@@ -154,23 +167,35 @@ function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||
// Turn output goes through the session log: translated to TurnMessages and committed to
|
||||
// chat_session_events here, in the process that produced it. Officer being down during a turn
|
||||
// no longer costs the transcript — the browser replays it from its cursor.
|
||||
runOpenCodeTurn(cmd.params, RUNNER_CONFIG, (msg) => {
|
||||
// Same emit contract either way, which is what makes the switch a switch: the durable commit and
|
||||
// the routing fact behave identically whether a subprocess or the serve produced the event.
|
||||
const onMessage = (msg: RunnerMessage) => {
|
||||
if (msg.type === 'opencode:event') {
|
||||
sessionLog.push(sessionKey, msg.event, durable);
|
||||
return;
|
||||
}
|
||||
// opencode:session is a routing fact (which `ses_…` to resume), not transcript — send it live.
|
||||
connection.send(msg);
|
||||
});
|
||||
};
|
||||
|
||||
if (USE_SERVE_TURNS) void runOpenCodeTurnOnServe(cmd.params, SERVE_RUNNER_CONFIG, onMessage);
|
||||
else runOpenCodeTurn(cmd.params, RUNNER_CONFIG, onMessage);
|
||||
reply({ type: 'opencode:spawned', id: cmd.id, sessionKey });
|
||||
break;
|
||||
}
|
||||
case 'opencode:list':
|
||||
reply({ type: 'opencode:sessions', id: cmd.id, sessions: listRunningOpenCodeTurns() });
|
||||
reply({
|
||||
type: 'opencode:sessions',
|
||||
id: cmd.id,
|
||||
sessions: USE_SERVE_TURNS ? listRunningServeTurns() : listRunningOpenCodeTurns(),
|
||||
});
|
||||
break;
|
||||
|
||||
case 'opencode:kill':
|
||||
killOpenCodeTurn(cmd.sessionKey);
|
||||
// On the serve this is an INTERRUPT: the turn stops and the session survives, so the conversation
|
||||
// can be continued rather than only re-opened.
|
||||
if (USE_SERVE_TURNS) void killServeTurn(cmd.sessionKey, SERVE_RUNNER_CONFIG);
|
||||
else killOpenCodeTurn(cmd.sessionKey);
|
||||
sessionLog.drop(cmd.sessionKey);
|
||||
break;
|
||||
default:
|
||||
@@ -221,7 +246,8 @@ async function shutdown(signal: string) {
|
||||
// Before the connection goes: killing a turn produces an event, and that event's durable write travels
|
||||
// over this socket. Tearing it down first would stop every turn silently — the exact outcome this is
|
||||
// here to prevent.
|
||||
const stopped = stopAllOpenCodeTurns(`The OpenCode sidecar restarted (${signal}), so this turn stopped.`);
|
||||
const message = `The OpenCode sidecar restarted (${signal}), so this turn stopped.`;
|
||||
const stopped = USE_SERVE_TURNS ? stopAllServeTurns(message) : stopAllOpenCodeTurns(message);
|
||||
if (stopped > 0) {
|
||||
console.log(`[opencode] ${signal} received, stopping ${stopped} in-flight turn(s)...`);
|
||||
await Promise.race([sessionLog.flush(), Bun.sleep(SHUTDOWN_FLUSH_MS)]);
|
||||
|
||||
Reference in New Issue
Block a user