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 }); });