diff --git a/src/servers/api/chat/nudger.ts b/src/servers/api/chat/nudger.ts index 2f0f3be8..f03a1a14 100644 --- a/src/servers/api/chat/nudger.ts +++ b/src/servers/api/chat/nudger.ts @@ -1,5 +1,4 @@ -import { dueNudges, recordNudgeSent, cancelNudge, getUserById } from 'officerdb'; -import * as sidecar from '@@/sidecar-registry'; +import { dueNudges, recordNudgeSent, getUserById } from 'officerdb'; import { resolveHomeDir } from '@@/user-home'; import { startClaudeTurn } from './websocket'; @@ -24,64 +23,41 @@ import { startClaudeTurn } from './websocket'; const TICK_MS = 60_000; -type Safety = { safe: true } | { safe: false; reason: string; disable: boolean }; - /** - * May we speak into this session right now? + * There is no safety check any more, and that is the point. * - * ── The failure this replaced ── + * ── What was here ── * - * 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. + * Three refusals: the session is not live, the session is generating, or anything else is live on the + * machine. Written on 2026-08-20 after a nudge fired at a garbage-collected session and a second Claude + * with bypassed permissions appeared in a repository another agent was mid-build in. * - * So there are three questions, and only one answer is permission: + * ── Why all three are gone ── * - * - **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. + * **Not live** was fixed elsewhere and the guard outlived it. `fire()` passes `resumeSessionId`, and + * `claude-manager` resolves `getClaudeSession(sessionKey) ?? params.resumeSessionId` into the SDK's + * `resume` — so a collected session resumes its own transcript rather than starting a new agent. That is + * what a schedule attached to a conversation always meant. + * + * **Anything else live** refused on the presence of any agent anywhere, because the sidecar does not report + * working directories and the check could not tell "another agent in this conversation" from "an unrelated + * chat". On a machine running commanders that is always true, so no nudge could ever fire — silently: the + * row stayed enabled, `sentCount` stayed 0, and whatever scheduled it reported success. It was a ban on the + * feature wearing the clothes of a safety check. + * + * **Generating** was the last one, and it went for a better reason than the others: it was refusing to do + * something that already works. Each session is one long-lived `query()` driven by a STREAMING INPUT QUEUE + * (`claude-manager.ts` → "Persistent streaming sessions"), and a turn is one message pushed onto it. A + * message sent to a busy session is not rejected and not lost — it is queued, and delivered when the + * current turn ends. Waiting is fine; disappearing is not, and refusing here meant disappearing. + * + * ── What this costs, stated plainly ── + * + * Nudging conversation A while conversation B is live in the same working tree still puts two agents in one + * directory. That is real and it is the owner's call, made 2026-08-25: a message that must arrive is worth + * more than a guard that could only ever be right by accident, since it never knew the directories either. + * Fixing it properly needs the sidecar to report each session's cwd. */ -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. - 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 { safe: false, reason: 'sidecar did not answer', disable: false }; - } -} async function fire(nudge: { id: number; userId: number; sessionId: string; message: string }): Promise { const user = await getUserById(nudge.userId); @@ -144,15 +120,7 @@ export function startNudger(): void { running = true; try { for (const nudge of await dueNudges()) { - 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})`); + console.log(`[nudger] session ${nudge.sessionId} — nudging (${nudge.sentCount + 1}/${nudge.maxCount})`); await fire(nudge); } } catch (err) {