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:
@@ -1,4 +1,6 @@
|
||||
import type { Context } from 'hono';
|
||||
import { emitToSession } from './websocket';
|
||||
import { pauseBackgroundTask, restartBackgroundTask } from './task-restart';
|
||||
import { createRouter } from '../../create-router';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
@@ -281,6 +283,49 @@ chatRouter.delete('/tasks/:id', async (ctx) => {
|
||||
return ctx.json(result, result.stopped ? 200 : 400);
|
||||
});
|
||||
|
||||
// POST /chat/tasks/:id/restart — run this job's command again, without involving the agent.
|
||||
//
|
||||
// The platform supervises what it respawns, because the harness stopped watching when its own child died.
|
||||
// It emits the same two events a harness-owned task emits, so the tray, the log view and reconnects need to
|
||||
// know nothing about the difference. `sessionId` comes from the client because that is who knows which
|
||||
// conversation the chip is being looked at in.
|
||||
chatRouter.post('/tasks/:id/restart', async (ctx) => {
|
||||
const who = await chatIdentity(ctx.get('user'));
|
||||
const taskId = ctx.req.param('id');
|
||||
const body = await ctx.req.json<{ sessionId?: string }>().catch(() => ({}) as { sessionId?: string });
|
||||
|
||||
const result = await restartBackgroundTask(who, taskId, (code: number) => {
|
||||
if (!body.sessionId) return;
|
||||
emitToSession(body.sessionId, {
|
||||
type: 'task:notification',
|
||||
taskId,
|
||||
status: code === 0 ? 'completed' : 'failed',
|
||||
summary: code === 0 ? 'Restarted job finished' : `Restarted job exited with code ${code}`,
|
||||
});
|
||||
});
|
||||
|
||||
// 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.
|
||||
if (result.ok && body.sessionId) {
|
||||
emitToSession(body.sessionId, {
|
||||
type: 'task:started',
|
||||
taskId,
|
||||
description: result.command?.split('\n')[0]?.slice(0, 80) ?? 'restarted job',
|
||||
taskType: 'local_bash',
|
||||
});
|
||||
}
|
||||
|
||||
return ctx.json(result, result.ok ? 200 : 400);
|
||||
});
|
||||
|
||||
// POST /chat/tasks/:id/pause — freeze or thaw a running job, keeping everything it holds.
|
||||
chatRouter.post('/tasks/:id/pause', async (ctx) => {
|
||||
const who = await chatIdentity(ctx.get('user'));
|
||||
const body = await ctx.req.json<{ paused?: boolean }>().catch(() => ({}) as { paused?: boolean });
|
||||
const result = await pauseBackgroundTask(who, ctx.req.param('id'), body.paused !== false);
|
||||
return ctx.json(result, result.ok ? 200 : 400);
|
||||
});
|
||||
|
||||
// GET /chat/models — Claude tiers only (the runner is the `claude` CLI).
|
||||
chatRouter.get('/models', async (ctx: Context) => {
|
||||
const who = await chatIdentity(ctx.get('user'));
|
||||
|
||||
Reference in New Issue
Block a user