From 6db26ae4391567f55c15065bf1143460e7dc8b7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 20 Aug 2026 03:17:39 +0000 Subject: [PATCH] nudger: "not generating" was permission to start a SECOND agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit it fired once, in anger, and put a second claude with bypassed permissions into a repository another agent was mid-build in. nothing was lost only because that agent noticed the collision and stopped rather than committing over the work. it also found a real bug while it was there, which is not a defence of any of this. the guard was exactly backwards. it asked one question — "is this session generating" — and treated NO as permission. but a session the sidecar no longer holds is also not generating, and `startClaudeTurn` given one of those does not resume it, it starts a NEW one. so the check that was meant to prevent interference is the thing that guaranteed a second agent, and the likeliest state for an overnight target — garbage collected after 30 idle minutes — is precisely the dangerous one. three questions now, and only one answer is permission: not live at all refuse AND disarm. nudging it would create an agent, which is not what a schedule attached to a conversation means and never was. live but generating refuse, keep the schedule. it is working. any other live session refuse, keep the schedule. two agents with bypassed permissions in one tree is not something to arrange on purpose. the third is blunter than it needs to be — the sidecar does not report a session's directory, so it refuses on ANY other live session rather than on a comparison it cannot make. that will decline some safe nudges. given what the precise version cost, the blunt one is the right trade until the sidecar can say where a session is running. --- src/servers/api/chat/nudger.ts | 63 ++++++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/src/servers/api/chat/nudger.ts b/src/servers/api/chat/nudger.ts index 4d9ae7e5..2f0f3be8 100644 --- a/src/servers/api/chat/nudger.ts +++ b/src/servers/api/chat/nudger.ts @@ -1,4 +1,4 @@ -import { dueNudges, recordNudgeSent, getUserById } from 'officerdb'; +import { dueNudges, recordNudgeSent, cancelNudge, getUserById } from 'officerdb'; import * as sidecar from '@@/sidecar-registry'; import { resolveHomeDir } from '@@/user-home'; import { startClaudeTurn } from './websocket'; @@ -24,18 +24,62 @@ import { startClaudeTurn } from './websocket'; const TICK_MS = 60_000; -/** Is this session mid-turn right now? Asked of the sidecar, immediately before speaking. */ -async function isGenerating(userId: number, sessionId: string): Promise { +type Safety = { safe: true } | { safe: false; reason: string; disable: boolean }; + +/** + * May we speak into this session right now? + * + * ── The failure this replaced ── + * + * This used to ask one question, "is it generating", and treat NO as permission. That is exactly + * backwards, and on 2026-08-20 it cost a working agent its afternoon: the target session had been + * garbage-collected, so it was not generating, so the nudge fired — and `startClaudeTurn` given a session + * the sidecar no longer holds does not resume it, it starts a NEW one. A second Claude with bypassed + * permissions appeared in a repository another agent was mid-build in. Nothing was lost only because that + * agent noticed the collision and stopped rather than committing over the work. + * + * So there are three questions, and only one answer is permission: + * + * - **Not live at all** → refuse, and disarm. Nudging it would CREATE an agent, which is not what a + * schedule attached to a conversation means and never was. + * - **Live but generating** → refuse, keep the schedule. It is working; that is the whole rule. + * - **Another session live in the same directory** → refuse, keep the schedule. Two agents with + * bypassed permissions in one working tree is not something to arrange on purpose. + */ +async function checkSafe(userId: number, sessionId: string): Promise { try { const live = await sidecar.listLiveClaudeSessions(userId); // Matched on BOTH ids on purpose. `sessionKey` is officer's handle for the conversation; // `claudeSessionId` is the transcript's own filename, and only the agent holds the mapping. A schedule // stored against one would silently never match a live row keyed by the other. - return live.some((s) => (s.sessionKey === sessionId || s.claudeSessionId === sessionId) && s.isGenerating); + const mine = live.find((s) => s.sessionKey === sessionId || s.claudeSessionId === sessionId); + + if (!mine) { + return { + safe: false, + reason: 'the session is no longer live — nudging it would start a NEW agent, not continue this one', + disable: true, + }; + } + if (mine.isGenerating) return { safe: false, reason: 'working', disable: false }; + + // Anything else alive belongs to another conversation. It may or may not share this one's directory — + // the sidecar does not say — so this refuses on the presence of any other live session rather than on + // a directory comparison it cannot actually make. + const others = live.filter((s) => s !== mine).length; + if (others > 0) { + return { + safe: false, + reason: `${others} other live session(s) — refusing to add a second agent`, + disable: false, + }; + } + + return { safe: true }; } catch { // Unknown is not permission. A sidecar that cannot answer might be running a turn, and interrupting one // is the failure this exists to avoid — so silence is the safe answer. - return true; + return { safe: false, reason: 'sidecar did not answer', disable: false }; } } @@ -100,7 +144,14 @@ export function startNudger(): void { running = true; try { for (const nudge of await dueNudges()) { - if (await isGenerating(nudge.userId, nudge.sessionId)) continue; // reconsidered next tick + const check = await checkSafe(nudge.userId, nudge.sessionId); + if (!check.safe) { + if (check.disable) { + console.warn(`[nudger] session ${nudge.sessionId} disarmed — ${check.reason}`); + await cancelNudge(nudge.userId, nudge.sessionId); + } + continue; // otherwise reconsidered next tick + } console.log(`[nudger] session ${nudge.sessionId} idle — nudging (${nudge.sentCount + 1}/${nudge.maxCount})`); await fire(nudge); }