From d58652bf14ee5f23e3c7c322a10eddd8b4c36358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 26 Aug 2026 08:14:46 +0000 Subject: [PATCH] a deleted bot stays deleted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the bot came back three times after being deleted, untitled each time, and the mechanism is worse than an orphan process. its watch had no record, so deleting it stopped nothing. the watch kept polling, fired, and the hand-back resumed the session by transcript id — at which point `claude --resume` RECREATED the transcript that had just been deleted. so the bot reappeared in the list and did it again on the next fire. same shape 22a9d58d fixed for commanders, arriving from a different direction. the record could never have covered this: it depends on the bot having written one, and a bot that did not is precisely the one nobody can stop. a mechanism that only works when the agent remembered is not a mechanism. so deleting also sweeps: any process running a .sh inside the bots directory whose pid appears in NO record is killed. claimed pids are never touched, whatever else is true — a live bot's watch is the one thing this must not reach. reads /proc rather than shelling out, since it runs inside the delete. Co-Authored-By: Claude Opus 5 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- src/servers/api/bots/teardown.ts | 64 ++++++++++++++++++++++++++++++++ src/servers/api/chat/chat.ts | 8 +++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/servers/api/bots/teardown.ts b/src/servers/api/bots/teardown.ts index 99b51329..f0880556 100644 --- a/src/servers/api/bots/teardown.ts +++ b/src/servers/api/bots/teardown.ts @@ -115,6 +115,70 @@ export function tearDownBot(email: string, sessionId: string): number { return signalled; } +/** + * Kill anything running out of the bots directory that no live bot claims. + * + * ── Why a record is not enough on its own ── + * + * `tearDownBot` needs the bot to have written `.bot.json`. A bot that never did — armed before + * the instruction existed, or one that simply did not — cannot be stopped that way, and deleting it leaves + * a process polling for ever. + * + * That is not the worst of it. **The orphan resurrects the bot it belonged to.** Its watch fires, the + * harness hands back to the agent, the agent resumes by transcript id, and `claude --resume` RECREATES the + * transcript that was deleted — so the bot reappears in the list, untitled, and does it again on the next + * fire. Seen on 2026-08-26: a bot deleted three times and back each time. It is the same shape + * `22a9d58d` fixed for commanders. + * + * ── The test ── + * + * A process is an orphan if it is running a script inside the bots directory and its pid appears in NO + * record. Claimed pids are left alone whatever else is true — a live bot's watch is the thing this must + * never touch. Anything else in there belongs to a bot that is gone. + * + * Reads `/proc` rather than shelling out to `ps`: this runs inside the request that deletes a bot, and a + * subprocess per delete is a cost with no benefit. + */ +export function sweepOrphanWatches(email: string): number { + const dir = getBotsDir(email); + const claimed = new Set(listBotRecords(email).flatMap(({ record }) => record.pids ?? [])); + + let killed = 0; + let pids: string[]; + try { + pids = readdirSync('/proc').filter((name) => /^\d+$/.test(name)); + } catch { + return 0; // not Linux, or /proc unreadable — the record path still works + } + + for (const name of pids) { + const pid = Number(name); + if (claimed.has(pid)) continue; + + let cmdline: string; + try { + // NUL-separated argv. A process that exits between the listing and this read is the ordinary case. + cmdline = readFileSync(`/proc/${name}/cmdline`, 'utf-8').replace(/\0/g, ' '); + } catch { + continue; + } + + // The script has to live in the bots directory — the brief requires it — so its path is in the argv of + // anything running it. `.sh` narrows it to the watch itself rather than an editor or a grep over it. + if (!cmdline.includes(dir) || !cmdline.includes('.sh')) continue; + + try { + process.kill(pid, 'SIGTERM'); + killed += 1; + console.warn(`[bots] orphaned watch ${pid} killed — no record claims it`); + } catch { + // Gone already, which is the outcome wanted. + } + } + + return killed; +} + /** Every bot that has a record, for the sweep that revives them. */ export function listBotRecords(email: string): { sessionId: string; record: BotRecord }[] { let names: string[]; diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index f24c7154..c64ac1d8 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -1,5 +1,5 @@ import { getPluginChatsDir, getBotsDir } from '@@/data-path'; -import { tearDownBot } from '../bots/teardown'; +import { tearDownBot, sweepOrphanWatches } from '../bots/teardown'; import type { Context } from 'hono'; import { emitToSession, sessionKeyForTranscript } from './websocket'; import { pauseBackgroundTask, restartBackgroundTask } from './task-restart'; @@ -491,6 +491,12 @@ chatRouter.delete('/sessions/:id', async (ctx) => { if (stopped > 0) console.log(`[bots] ${transcriptId} deleted — stopped ${stopped} process(es)`); } + // And anything left running out of the bots directory that no remaining record claims. This is what + // catches a bot that never wrote one — which cannot be stopped by id, and whose watch RESURRECTS it on + // its next fire, because the hand-back resumes a transcript that `claude --resume` then recreates. + const orphaned = sweepOrphanWatches(who.email); + if (orphaned > 0) console.log(`[bots] swept ${orphaned} orphaned watch process(es)`); + return ctx.json({ ok: true }); });