stop orphaning opencode turns and serves on restart
B8, both halves. They share index.ts, so they share a commit. In-flight turns: `opencode run` is spawned, not supervised, so pm2 restart officer-opencode left every turn ALIVE — reparented, still spending tokens, still writing files as the agent, with the only reader of its stdout gone. The transcript stopped mid-tool-call, which reads as the agent hanging. stopAllOpenCodeTurns kills them and settles each synchronously, because the caller is about to process.exit and nothing waiting on proc.exited would ever run. Settling writes a reason, so a reload after a restart explains itself instead of trailing off. Turns are stopped BEFORE the connection is destroyed — that write travels over it — and the flush is bounded, since losing the explanation is bad but hanging the restart is worse. Stale serves: the sweep read /proc, so it was a no-op on macOS and orphaned serves piled up, one per unclean exit, each holding a port. Added a pidfile sweep alongside it. A pid we wrote ourselves needs no cwd guard to prove it is ours, which is the part ps cannot answer portably (macOS would need lsof), and a serve started by hand is never in the file. The guard checks command AND subcommand: matching the word serve anywhere in the line would sweep a running turn whose prompt merely mentioned it. Fixtures are real ps output from both machines, not invented. Split into serve-sweep.ts because index.ts spawns a serve at module scope, so a test importing it would start one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,15 @@ type RunHandle = {
|
||||
* stop button a no-op, and orphaned a process nothing could reach.
|
||||
*/
|
||||
superseded: boolean;
|
||||
/**
|
||||
* End this turn from outside the closure that owns it, with a reason.
|
||||
*
|
||||
* `killOpenCodeTurn` can kill a process and let `proc.exited` do the rest, because it has time.
|
||||
* Shutdown does not: the sidecar is about to call `process.exit`, so nothing asynchronous will ever
|
||||
* run again and a turn killed that way would simply stop mid-sentence, leaving a transcript that
|
||||
* trails off. Settling synchronously is what puts the explanation in the log before we go.
|
||||
*/
|
||||
finish: (event: ChatEvent) => void;
|
||||
};
|
||||
|
||||
// One turn per sessionKey; a new turn supersedes any stale process for that key.
|
||||
@@ -97,7 +106,9 @@ export function runOpenCodeTurn(params: OpenCodeRunParams, config: RunnerConfig,
|
||||
stderr: 'pipe',
|
||||
});
|
||||
|
||||
const handle: RunHandle = { proc, killedByUser: false, superseded: false };
|
||||
// `finish` is a placeholder for the few synchronous lines until the real one below exists — it closes
|
||||
// over `handle`, so the two cannot both be defined first. Nothing can call it in between.
|
||||
const handle: RunHandle = { proc, killedByUser: false, superseded: false, finish: () => {} };
|
||||
running.set(sessionKey, handle);
|
||||
|
||||
let done = false;
|
||||
@@ -128,6 +139,7 @@ export function runOpenCodeTurn(params: OpenCodeRunParams, config: RunnerConfig,
|
||||
};
|
||||
|
||||
const finish = (event: ChatEvent) => settle(event);
|
||||
handle.finish = finish;
|
||||
|
||||
// ── Watchdogs ──
|
||||
const hardTimer = setTimeout(() => {
|
||||
@@ -253,6 +265,35 @@ export function listRunningOpenCodeTurns(): { sessionKey: string }[] {
|
||||
return Array.from(running.keys()).map((sessionKey) => ({ sessionKey }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill every turn this process is running, because the process itself is going away.
|
||||
*
|
||||
* A turn is a child of this sidecar only in the bookkeeping sense: `opencode run` is spawned, not
|
||||
* supervised, so `pm2 restart officer-opencode` used to leave every in-flight turn ALIVE — reparented,
|
||||
* still spending tokens, and still writing files as the agent, while the only reader of its stdout had
|
||||
* exited. The turn's output went nowhere and the transcript simply stopped mid-tool-call, which is
|
||||
* indistinguishable from the agent hanging.
|
||||
*
|
||||
* Both halves matter. Killing the children stops the invisible work; settling them synchronously writes
|
||||
* a reason into the transcript, so a reload after a restart explains itself instead of trailing off.
|
||||
* Returns how many were stopped, so the caller can skip the flush wait when there were none.
|
||||
*/
|
||||
export function stopAllOpenCodeTurns(message: string): number {
|
||||
const handles = [...running.values()];
|
||||
for (const handle of handles) {
|
||||
// Suppress the exit handler's own error: this death is accounted for, and `finish` below is the
|
||||
// account. Without it a late `proc.exited` would be a second, less accurate ending.
|
||||
handle.killedByUser = true;
|
||||
try {
|
||||
handle.proc.kill();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
handle.finish({ type: 'error', message });
|
||||
}
|
||||
return handles.length;
|
||||
}
|
||||
|
||||
export function killOpenCodeTurn(sessionKey: string): void {
|
||||
const handle = running.get(sessionKey);
|
||||
if (!handle) return;
|
||||
|
||||
Reference in New Issue
Block a user