diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index 1762f551..5068537c 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -357,7 +357,17 @@ chatRouter.delete('/tasks/:id', async (ctx) => { // the same thing, differing only in how hard it asks. const force = ctx.req.query('force') === '1'; const result = await stopBackgroundTask(who, ctx.req.param('id'), force); - return ctx.json(result, result.stopped ? 200 : 400); + // "no such task" and "already stopped" are 200, not 400. + // + // DELETE is idempotent: asking for a job to stop when it is already gone has achieved what was asked. + // Reporting it as an error made the tray shout `{"stopped":false,"reason":"no such task"}` at somebody + // pressing Stop on a chip that could not be stopped, killed or dismissed — and because `useClient` + // raises a GLOBAL toast before the caller ever sees the failure, no amount of handling at the call + // site could quiet it. + // + // A genuine refusal — a malformed id, a subagent that has no fd to trace — is still 400. + const gone = result.reason === 'no such task' || result.reason === 'already stopped'; + return ctx.json({ ...result, gone }, result.stopped || gone ? 200 : 400); }); // POST /chat/tasks/:id/restart — run this job's command again, without involving the agent. diff --git a/src/workspaces/officerdev/src/apps/Chat/components/BackgroundTaskTray.tsx b/src/workspaces/officerdev/src/apps/Chat/components/BackgroundTaskTray.tsx index a26a2f24..664624c3 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/BackgroundTaskTray.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/BackgroundTaskTray.tsx @@ -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;