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:
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Check, ChevronDown, CircleSlash, Loader2, Pin, Square, X } from 'lucide-react';
|
||||
import { Check, ChevronDown, CircleSlash, Loader2, Pause, Pin, Play, RotateCw, Square, X } from 'lucide-react';
|
||||
import type { Tone } from '@/components/Data';
|
||||
import { toneText } from '@/components/Data';
|
||||
import type { ChatMessage } from '../types';
|
||||
@@ -13,6 +13,8 @@ import { markUserBackgroundJob } from '../../../background-job-origin';
|
||||
|
||||
type BackgroundTaskTrayProps = {
|
||||
messages: ChatMessage[];
|
||||
/** Which conversation the chips belong to. A restart emits its events back into this session. */
|
||||
sessionId: string | null;
|
||||
};
|
||||
|
||||
/** Same vocabulary as the transcript's own task rows — a completed task must not be green here and grey there. */
|
||||
@@ -31,7 +33,7 @@ function statusTone(status: BackgroundTask['status']): Tone {
|
||||
* Clicking a chip opens what it is actually doing right now: a subagent's own tool-by-tool trace, or the
|
||||
* tail of a backgrounded shell's log, both read from the file Claude Code streams the task into.
|
||||
*/
|
||||
export const BackgroundTaskTray = ({ messages }: BackgroundTaskTrayProps) => {
|
||||
export const BackgroundTaskTray = ({ messages, sessionId }: BackgroundTaskTrayProps) => {
|
||||
const { tasks, pinned, unpinned, running, clearable, togglePin, dismiss, dismissFinished } =
|
||||
useBackgroundTasks(messages);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
@@ -56,6 +58,46 @@ export const BackgroundTaskTray = ({ messages }: BackgroundTaskTrayProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Run this job's command again, without asking the agent.
|
||||
*
|
||||
* The platform recovers the command from the transcript, stops whatever is running, and respawns it
|
||||
* appending to the same output file — so the log keeps growing rather than starting a new one, and Stop
|
||||
* keeps working because it finds the process by who holds that file open.
|
||||
*
|
||||
* Offered for a FINISHED job too, which is the case that matters most: a dev server that fell over is
|
||||
* exactly the thing you want back without typing a sentence at an agent that may be mid-turn.
|
||||
*/
|
||||
const restartTask = async (taskId: string) => {
|
||||
try {
|
||||
const result = await client.post<{ ok: boolean; reason?: string }>(`/chat/tasks/${taskId}/restart`, {
|
||||
sessionId,
|
||||
});
|
||||
if (!result.ok) toast.error(result.reason ?? 'Could not restart that job');
|
||||
else markUserBackgroundJob(taskId);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Could not restart that job');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Freeze or thaw the process, without ending it.
|
||||
*
|
||||
* SIGSTOP/SIGCONT, so a dev server keeps its port, its memory and its place — a paused one does not
|
||||
* refuse connections, it stops answering them, which is the honest thing for the button to do and worth
|
||||
* knowing before you wonder why the page hangs.
|
||||
*/
|
||||
const pauseTask = async (taskId: string, paused: boolean): Promise<boolean> => {
|
||||
try {
|
||||
const result = await client.post<{ ok: boolean; reason?: string }>(`/chat/tasks/${taskId}/pause`, { paused });
|
||||
if (!result.ok) toast.error(result.reason ?? 'Could not pause that job');
|
||||
return result.ok;
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Could not pause that job');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Derived, not stored: dismissing the chip that is open closes the panel without a second piece of state
|
||||
// having to be kept in step with the first.
|
||||
const open = tasks.find((t) => t.taskId === openId) ?? null;
|
||||
@@ -80,6 +122,8 @@ export const BackgroundTaskTray = ({ messages }: BackgroundTaskTrayProps) => {
|
||||
task={open}
|
||||
onClose={() => setOpenId(null)}
|
||||
onStop={open.status ? undefined : () => void stopTask(open.taskId)}
|
||||
onPause={(next) => pauseTask(open.taskId, next)}
|
||||
onRestart={() => void restartTask(open.taskId)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -234,12 +278,20 @@ const TaskChip = ({ task, active, pinned, onClick, onTogglePin, onDismiss, onSto
|
||||
type TaskPanelProps = {
|
||||
/** Present only while the task is running — the panel shows a real button rather than a bare glyph. */
|
||||
onStop?: () => void;
|
||||
/** Freeze and thaw the process. Present while it is running; the label follows the state. */
|
||||
onPause?: (paused: boolean) => Promise<boolean>;
|
||||
/** Run the command again, whether it is running or long finished. */
|
||||
onRestart?: () => void;
|
||||
task: BackgroundTask;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const TaskPanel = ({ task, onClose, onStop }: TaskPanelProps) => {
|
||||
const TaskPanel = ({ task, onClose, onStop, onPause, onRestart }: TaskPanelProps) => {
|
||||
const running = !task.status;
|
||||
// Local, because a paused process is a short-lived state somebody is watching. It resets on reload,
|
||||
// which is honest enough: SIGCONT is idempotent, and the button says what it will do rather than what
|
||||
// it believes has happened.
|
||||
const [paused, setPaused] = useState(false);
|
||||
const { data, isLoading } = useTaskDetail(task.taskId, running);
|
||||
const bodyRef = useRef<HTMLDivElement | null>(null);
|
||||
const pinnedRef = useRef(true);
|
||||
@@ -264,6 +316,33 @@ const TaskPanel = ({ task, onClose, onStop }: TaskPanelProps) => {
|
||||
{/* Labelled here, unlike the chip's icon-only version. The panel is where you come to decide
|
||||
whether to keep something running, so the control that ends it should not be a glyph you
|
||||
have to hover to identify. */}
|
||||
{/* Pause and Restart sit beside Stop rather than 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. */}
|
||||
{running && onPause && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const next = !paused;
|
||||
if (await onPause(next)) setPaused(next);
|
||||
}}
|
||||
title={paused ? 'Let it continue where it left off' : 'Freeze the process, keeping its state'}
|
||||
className="flex shrink-0 cursor-pointer items-center gap-1 rounded border border-border px-2 py-1 text-xs text-muted-foreground transition-colors hover:border-warning/40 hover:text-warning"
|
||||
>
|
||||
{paused ? <Play className="h-3 w-3" /> : <Pause className="h-3 w-3" />}
|
||||
{paused ? 'Resume' : 'Pause'}
|
||||
</button>
|
||||
)}
|
||||
{onRestart && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRestart}
|
||||
title="Stop it and run the same command again"
|
||||
className="flex shrink-0 cursor-pointer items-center gap-1 rounded border border-border px-2 py-1 text-xs text-muted-foreground transition-colors hover:border-duck-teal/40 hover:text-duck-teal"
|
||||
>
|
||||
<RotateCw className="h-3 w-3" />
|
||||
Restart
|
||||
</button>
|
||||
)}
|
||||
{running && onStop && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -45,6 +45,7 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
sendQueuedNow,
|
||||
unqueue,
|
||||
sendWithHiddenPrefix,
|
||||
sessionId,
|
||||
} = manager;
|
||||
|
||||
const { recording, transcribing, toggleRecording } = useAudioRecording(appendToInput);
|
||||
@@ -85,7 +86,7 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
<div className="mb-2 px-3 py-1.5 text-xs text-duck-teal bg-duck-teal/10 rounded-md">{commandFeedback}</div>
|
||||
)}
|
||||
|
||||
<BackgroundTaskTray messages={messages} />
|
||||
<BackgroundTaskTray messages={messages} sessionId={sessionId} />
|
||||
|
||||
<QueuedList queued={queued} onRemove={unqueue} onSendNow={sendQueuedNow} />
|
||||
|
||||
|
||||
@@ -436,10 +436,23 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
break;
|
||||
|
||||
case 'task:started':
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: 'task', taskId: msg.taskId, description: msg.description, taskType: msg.taskType },
|
||||
]);
|
||||
setMessages((prev) => {
|
||||
// A RESTART re-announces an id that is already in the transcript — the platform respawned the
|
||||
// job and emits the same two events the harness would. Resetting that row rather than appending
|
||||
// a second one is what makes the chip go back to running instead of the tray showing the job
|
||||
// twice, once finished and once not.
|
||||
const at = prev.findIndex((m) => m.role === 'task' && m.taskId === msg.taskId);
|
||||
const row: ChatMessage = {
|
||||
role: 'task',
|
||||
taskId: msg.taskId,
|
||||
description: msg.description,
|
||||
taskType: msg.taskType,
|
||||
};
|
||||
if (at < 0) return [...prev, row];
|
||||
const next = [...prev];
|
||||
next[at] = row;
|
||||
return next;
|
||||
});
|
||||
// Only jobs the user started from the dialog. An agent starts background jobs constantly while
|
||||
// working — tests, builds, greps — and reloading a preview on those is the same churn this was
|
||||
// built to avoid, only with a different trigger. See `background-job-origin`.
|
||||
|
||||
Reference in New Issue
Block a user