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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user