pause, resume and restart a background job without asking the agent

Three buttons in the expanded task panel, beside Stop. Not on the chip: the panel
is where you decide what to do with a job, and a row of unlabelled glyphs on a pill
is where you misclick.

── Pause / Resume ──

SIGSTOP and SIGCONT — the only pair that means "stop doing things and keep
everything". The process keeps its memory, its open files and its bound port, and
picks up mid-instruction. Signals go to every pid holding the job's output file,
because for a shell job that is the shell AND what it spawned, and freezing one of
them would be worse than freezing neither.

Verified by watching a job that writes a line a second: 3 lines, frozen at 3 across
three seconds of pause, 6 after resume.

Worth knowing before pressing it: a paused server does not REFUSE connections, it
stops answering them, so a browser pointed at it hangs rather than erroring. That is
what pausing is, not a shortcoming here.

── Restart, and the cost it accepts ──

The command is recovered by joining the transcript to itself: the harness records
the `tool_use` carrying `command`, and separately its result — the text
`Command running in background with ID: <id>`. Neither alone is enough; together
they are a join on `tool_use_id`, and that is the only place the command survives.
The transcript is found FROM the output path rather than searched for, since a task
file at `<tmp>/claude-<uid>/<slug>/<session>/tasks/<id>.output` names both.

The respawned process appends to that same output file. That is what keeps
everything else working untouched — the log view reads that file, and Stop finds the
process by asking which pids hold it open.

The real cost, chosen deliberately: the platform becomes a SECOND owner of a task.
The harness stops watching when its own child dies, so the platform supervises what
it spawned and emits the same `task:started` / `task:notification` pair everything
downstream is built on. `emitToSession` is the seam, and it is the only one — nothing
else learns that a task can have two kinds of parent.

A restart re-announces an id already in the transcript, so `task:started` now RESETS
an existing task row rather than appending a second one. Without that the tray showed
the job twice, once finished and once running.

Restart is offered for a finished job too, which is the case that matters most: a dev
server that fell over is exactly what you want back without typing a sentence at an
agent that may be mid-turn.

Verified end to end against a planted task: command recovered, old process killed,
new one spawned, output APPENDED (`first run output` then `RESTARTED-OK`), exit
reported through the callback, supervision registry released afterwards.

`[open]` A restarted job does not survive an `officer` restart — the platform is its
parent now. The harness's own jobs never did either.

tsgo clean, frontend builds, 890 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 13:26:07 +00:00
co-authored by Claude Opus 5
parent 7d64b0a0d6
commit 73eb128fb1
7 changed files with 386 additions and 18 deletions
+19
View File
@@ -318,6 +318,25 @@ function createMessageHandler(sessionId: string, model: string) {
};
}
/**
* Push an event into a live session from OUTSIDE a turn.
*
* 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.
*
* Returns false when the session is not live — a restart is still legitimate then, it simply has nobody to
* tell.
*/
export function emitToSession(sessionId: string, msg: TurnMessage): boolean {
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);
return true;
}
async function handleChat(
ws: ServerWebSocket<WSData>,
msg: {