deleting a bot stops it, and a dead bot wakes up

a bot was deleted tonight and its watcher was still polling a website eleven
minutes later. nothing in the product could find it: a bot's watch is a
detached shell process, killing the harness ends the agent, and the loop it
started is not its child.

so a bot writes <sessionId>.bot.json beside its script — what it watches, the
script, the state file, the pids. that record is the ONLY thing connecting a
process to the bot that owns it, and the brief now says so in those words
rather than as bookkeeping.

the id is in the FILENAME because bots share a directory: deleting knows only
an id, so this is a lookup rather than a scan, and a record whose contents are
damaged still gets found and cleaned up.

DELETE /chat/sessions/:id tears it down after forgetting the tables — children
first then the recorded pid, same ordering as stopCommanderProcesses, so the
loop cannot spawn a replacement between the two. every transcript in the
returned chain, not just the id asked for. unconditional rather than gated on
"was this a bot", because the record only exists in the bots directory and a
branch on cwd is a second place to get it wrong. script and state go too, and
only if they are inside the bots directory — an agent wrote that path.

and the reviver sweeps bots on the same tick. a record with pids and none of
them alive is a stopped watch; there is no armed flag and there should not be,
since the record is written when it arms, so its existence IS the intent. the
three-wake bound is shared, keyed bot-<sessionId>.

the wake prompt forbids reconstructing a watch from memory when the record is
gone: a watch subtly different from the one agreed is worse than none, because
it looks like it is working.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-26 08:04:49 +00:00
co-authored by Claude Opus 5
parent 513a50f2e8
commit 84d9c9d2d8
4 changed files with 282 additions and 5 deletions
+21
View File
@@ -1,4 +1,5 @@
import { getPluginChatsDir, getBotsDir } from '@@/data-path';
import { tearDownBot } from '../bots/teardown';
import type { Context } from 'hono';
import { emitToSession, sessionKeyForTranscript } from './websocket';
import { pauseBackgroundTask, restartBackgroundTask } from './task-restart';
@@ -470,6 +471,26 @@ chatRouter.delete('/sessions/:id', async (ctx) => {
conversation — no live session holds it, so nothing is keyed on a key to remove.
*/
await forgetChatSessions({ transcriptIds: removed, sessionKeys: liveKey ? [liveKey] : [] });
/*
And stop anything a BOT armed, which nothing above would have touched.
A bot's watch is a detached shell process. Killing the harness ends the agent; the loop it started is
not its child and carries on. That gap was paid for on 2026-08-26: a bot was deleted and its watcher
was still polling a website eleven minutes later, with nothing in the product able to find it.
Every transcript removed, not just the id asked for — `deleteClaudeSession` returns a chain, and a
resumed conversation's earlier transcripts can each carry their own record.
Unconditional rather than gated on "was this a bot": the record only exists in the bots directory and
is keyed by session id, so for anything else this reads a path that is not there and returns 0. A
branch on the cwd would be a second place to get the question wrong.
*/
for (const transcriptId of removed) {
const stopped = tearDownBot(who.email, transcriptId);
if (stopped > 0) console.log(`[bots] ${transcriptId} deleted — stopped ${stopped} process(es)`);
}
return ctx.json({ ok: true });
});