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:
2026-08-10 13:28:49 +01:00
co-authored by Claude Opus 5
parent 73ccf15a89
commit cdee320fed
6 changed files with 236 additions and 9 deletions
+12
View File
@@ -29,6 +29,11 @@ export type SessionLogStore = {
push: (sessionId: string, event: ChatEvent, durable?: boolean) => void;
/** Forget a session's buffer and cursor chain (on kill / clear-session). */
drop: (sessionId: string) => void;
/**
* Settle every commit queued so far. For shutdown: `push` returns immediately and the durable write
* happens on a queue, so exiting without this drops whatever had not reached Postgres yet.
*/
flush: () => Promise<void>;
};
// The durable store, behind an interface so the ordering guarantee below can be tested against a writer
@@ -117,5 +122,12 @@ export function createSessionLogStore(
drop(sessionId) {
logs.delete(sessionId);
},
async flush() {
// Await the tails as they stand now. A `push` racing this one is not waited for — which is the
// honest contract: this exists for shutdown, where the alternative is exiting on an unresolved
// write and losing the row that explains why the turn ended.
await Promise.allSettled([...logs.values()].map((log) => log.tail));
},
};
}