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 -10
View File
@@ -711,7 +711,7 @@ export type BackgroundTaskDetail =
* `<projects>/<slug>/<session>/subagents/agent-<id>.jsonl` (4). `taskId` is `TASK_ID_RE`-checked above, so
* it carries no glob or shell metacharacter into either.
*/
function findTaskOutput(who: ChatIdentity, taskId: string): string | null {
export function findTaskOutput(who: ChatIdentity, taskId: string): string | null {
// The member's `claude` runs under `--reset-env`, so its TMPDIR is the passwd default, not ours. Both
// resolve to /tmp on Linux; a server started with a TMPDIR of its own would look in the wrong place.
const tmpRoot = process.env.TMPDIR ?? '/tmp';
@@ -733,6 +733,23 @@ function tailFile(osUser: AsUser, filePath: string, bytes: number): { text: stri
return { text: truncated ? text.slice(text.indexOf('\n') + 1) : text, truncated };
}
/**
* The pids with this file open — which is how a job with no recorded pid is found.
*
* `fuser` reads `/proc/<pid>/fd`, which only the process's own user and root may open, so it runs AS the
* job's owner. Run as the platform it would find nothing for a member's job and report it already stopped.
*/
export async function pidsHoldingFile(who: ChatIdentity, path: string): Promise<number[]> {
const argv = who.osUser ? runAsArgv(who.osUser, ['fuser', path]) : ['fuser', path];
const proc = Bun.spawn(argv, { stdout: 'pipe', stderr: 'ignore' });
const out = await new Response(proc.stdout).text();
await proc.exited;
return out
.split(/\s+/)
.map((token) => Number(token.replace(/\D/g, '')))
.filter((n) => Number.isInteger(n) && n > 1);
}
/**
* Stop a running background job.
*
@@ -762,15 +779,7 @@ export async function stopBackgroundTask(
// stopped. And the kill would then be cross-user: EPERM, silently swallowed by the catch below.
const asUser = (command: string[]) => (who.osUser ? runAsArgv(who.osUser, command) : command);
const proc = Bun.spawn(asUser(['fuser', found]), { stdout: 'pipe', stderr: 'ignore' });
const out = await new Response(proc.stdout).text();
await proc.exited;
const pids = out
.split(/\s+/)
.map((t) => Number(t.replace(/\D/g, '')))
.filter((n) => Number.isInteger(n) && n > 1);
const pids = await pidsHoldingFile(who, found);
if (pids.length === 0) return { stopped: false, reason: 'already stopped' };
if (who.osUser) {