stopping a job that is already gone is a success, not an error

The chip could not be cleared by any means, and the toast could not be silenced
from the call site: useClient raises a GLOBAL onError before it throws, so
handling the failure where Stop is pressed was always going to be too late.

DELETE is idempotent. Asking for a job to stop when it is already gone has
achieved what was asked, so "no such task" and "already stopped" now answer 200
with `gone: true` instead of 400. No error, no toast, and the client clears the
chip on the flag.

A genuine refusal — a malformed id, a subagent with no fd to trace — is still
400.

The previous client-side check stays as a fallback, for a browser talking to a
server that has not been updated yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 14:47:28 +00:00
co-authored by Claude Opus 5
parent 0f56aae803
commit 520fad2288
2 changed files with 20 additions and 9 deletions
@@ -80,16 +80,17 @@ export const BackgroundTaskTray = ({ messages, sessionId }: BackgroundTaskTrayPr
markStopRequested(taskId);
setStopAttempted((prev) => ({ ...prev, [taskId]: Date.now() }));
try {
await client.delete(`/chat/tasks/${taskId}${force ? '?force=1' : ''}`);
// `gone` means the server could not find it: no output file, so no pid, so nothing running. That is
// authoritative rather than a failure — the chip was the last thing claiming otherwise, so it goes.
const result = await client.delete<{ stopped: boolean; gone?: boolean }>(
`/chat/tasks/${taskId}${force ? '?force=1' : ''}`,
);
if (result?.gone) dismiss(taskId);
} catch (err) {
// Kept as a fallback for a server that predates the idempotent DELETE: it answers 400 for a job it
// cannot find, and `useClient` has already raised a global toast by the time this runs — but the
// chip can at least still be cleared.
const message = err instanceof Error ? err.message : '';
// The server saying it cannot FIND the job is authoritative, not an error to report: there is no
// output file, therefore no pid, therefore nothing running. The chip was the last thing claiming
// otherwise.
//
// Without this the tray showed a job that could not be stopped, killed or dismissed — a chip with
// no way out, because dismiss is only offered for a task the tray believes has finished. Pressing
// Stop then Kill both answered {"stopped":false,"reason":"no such task"} and changed nothing.
if (message.includes('no such task') || message.includes('already stopped')) {
dismiss(taskId);
return;