nudger: "not generating" was permission to start a SECOND agent

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.
This commit is contained in:
2026-08-20 03:17:39 +00:00
parent 18bfff5e74
commit 6db26ae439
+57 -6
View File
@@ -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<boolean> {
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<Safety> {
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);
}