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