a restarted background job survives a refresh

Reported as "I refreshed the page and the pill was gone, but the server is still
running and now I have no way to control it". Not a design choice — a bug, and it
came in with the restart button.

The tray is not stored anywhere. It is a fold over the durable chat event log:
`task:started` minus `task:notification`, per `collectRunningTasks`. That is what
rebuilds it after a refresh.

The restart route announced its respawn with `emitToSession`, which folds into the
in-memory session and writes to open sockets — and never called `appendChatEvent`.
So the pill existed only in the RAM of the browser tab that clicked the button. The
log still read:

    seq 10979  task:started       "Run dev server"
    seq 11362  task:notification  failed          <- the ORIGINAL dying

Folded on refresh that says "not running", so the chip vanished — while the process
it described was up and serving on 3001. And every task route takes an id, so the
job became unreachable from the UI at the moment the browser forgot the id. Stop,
Pause and Restart would all still have worked; nothing could name the task.

The second half was quieter: `emitToSession` returned false when the session was not
in memory, and the route ignored the return. A platform restart drops the in-memory
session while the job keeps running, so the announcement went nowhere AND was never
written down.

Now it appends first and unconditionally — persistence does not need a listener,
only delivery does — and sends with the seq the log assigned, so a client's replay
cursor stays correct. Both events: the `task:started` is awaited so it is durable
before the client is told the restart worked, and the exit notification is
fire-and-forget because it lands long after the response.

This makes officer a writer of `chat_session_events`, which the note in websocket.ts
said it no longer was. `cut-off` had already been that exception. The real rule is
narrower and is now written down: officer writes events it ORIGINATES, because no
sidecar will. Turn output still belongs to the harness that produced it.

── How it was verified ──

End-to-end, out of band: a synthetic task with a real process holding a real output
file, a transcript in the harness's shape, and the two durable events that reproduce
the reported log. Driven through `buildHonoApp` so the permission gate and auth
middleware were in the path — the last two bugs of this kind both passed tests that
mounted the router alone.

    BEFORE restart: 2 durable events -> tray would show 0 running   (bug reproduced)
    POST /restart  -> 200
    AFTER restart : 3 durable events -> tray would show 1 running

Not added to `bun test`: nothing in the suite touches Postgres, and this claim is
untestable without it. So this is a probe that ran once, not coverage.

Needs a server restart. tsgo clean, 919 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 17:20:11 +00:00
co-authored by Claude Opus 5
parent eaa38c7d43
commit b7d26a3350
2 changed files with 46 additions and 10 deletions
+10 -2
View File
@@ -296,7 +296,9 @@ chatRouter.post('/tasks/:id/restart', async (ctx) => {
const result = await restartBackgroundTask(who, taskId, (code: number) => {
if (!body.sessionId) return;
emitToSession(body.sessionId, {
// Fire-and-forget because the exit arrives long after this request answered; `emitToSession` swallows
// its own failures, so there is nothing here to await for.
void emitToSession(body.sessionId, {
type: 'task:notification',
taskId,
status: code === 0 ? 'completed' : 'failed',
@@ -306,8 +308,14 @@ chatRouter.post('/tasks/:id/restart', async (ctx) => {
// Announced only on success, and only after the process exists — a `task:started` for something that
// failed to spawn would leave a chip running forever with nothing behind it.
//
// Awaited, so the event is in the durable log before the client is told the restart worked. The tray is
// rebuilt from that log on every refresh, and a client that reloaded in the gap would have folded the
// ORIGINAL task's `task:notification` as the last word and shown nothing — which is precisely the bug
// this replaced: a live dev server with no chip and no way to reach it, since every task route needs an
// id the browser had just thrown away.
if (result.ok && body.sessionId) {
emitToSession(body.sessionId, {
await emitToSession(body.sessionId, {
type: 'task:started',
taskId,
description: result.command?.split('\n')[0]?.slice(0, 80) ?? 'restarted job',
+36 -8
View File
@@ -174,7 +174,8 @@ function sendToClient(ws: ServerWebSocket<WSData> | null, msg: ServerMessage, se
}
}
// Nothing in officer writes to chat_session_events any more. Both harnesses commit their own turn
// Officer writes to chat_session_events only for events it ORIGINATES — `cut-off` and the restart
// button's task pair (`emitToSession`). Both harnesses commit their own turn
// output in the sidecar that produced it (`sidecar/claude/session-log.ts`), which is the whole point:
// the durable record does not travel over the socket between the two processes, so officer can restart
// mid-turn without losing it. Officer reads the table on `resume` (getChatEventsSince) and relays what
@@ -320,21 +321,48 @@ function createMessageHandler(sessionId: string, model: string) {
}
/**
* Push an event into a live session from OUTSIDE a turn.
* Push an event into a session from OUTSIDE a turn, durably.
*
* The restart button is the only caller: the platform respawns a process the harness is no longer watching,
* so nothing else would emit the `task:started` / `task:notification` pair that the tray, the log view and
* every reconnect are built on. Folded into the session first and then sent, in that order, so a browser
* that reconnects a second later sees the same thing as one that was already listening.
* every reconnect are built on.
*
* Returns false when the session is not live — a restart is still legitimate then, it simply has nobody to
* tell.
* ── Why this writes to the log, against the note above ──
*
* It used to only fold and send, and that made the tray a fact about one browser tab. A refresh rebuilds
* the tray by folding the durable log — `task:started` minus `task:notification`, see
* `collectRunningTasks` — so a restart announced only over sockets left the log reading "started, then
* failed" while the process it described was still up and serving. The pill vanished on refresh and the
* job became uncontrollable from the UI: every task route takes an id, and the id existed nowhere else.
*
* `cut-off` is the same exception for the same reason, and the rule behind both is narrower than "officer
* does not write here": officer writes events it ORIGINATES, because no sidecar will. Turn output still
* belongs to the harness that produced it.
*
* ── The append is unconditional ──
*
* It happens whether or not a session is live in memory, and that is the half that was silently missing:
* a platform restart drops the in-memory session while the job keeps running, and the old early return
* meant the announcement went nowhere and was never written down either. Persistence does not need a
* listener. Delivery does, and only delivery is conditional.
*
* Returns whether it reached a live socket — false is ordinary, not an error.
*/
export function emitToSession(sessionId: string, msg: TurnMessage): boolean {
export async function emitToSession(sessionId: string, msg: TurnMessage): Promise<boolean> {
let seq: number | undefined;
try {
seq = await appendChatEvent(sessionId, msg);
} catch (err) {
// Still tell whoever is listening. A pill that cannot survive a refresh beats no pill at all, and the
// process is running either way.
logger.error('Failed to persist an out-of-turn event', { sessionId, type: msg.type, error: String(err) });
}
const session = sessionManager.getSession(sessionId);
if (!session) return false;
foldIntoSession(session, msg, session.meta.model ?? '');
for (const socket of session.sockets) sendToClient(socket as ServerWebSocket<WSData>, msg);
// The seq is what a client records as its replay cursor, so it must be the one the log assigned.
for (const socket of session.sockets) sendToClient(socket as ServerWebSocket<WSData>, msg, seq);
return true;
}