a nudge always arrives
three refusals, all gone. the row stayed enabled, sentCount stayed 0, and whatever scheduled it reported success — so "hand this category to a chat you already have" could not work and said nothing. NOT LIVE was fixed elsewhere and the guard outlived it: fire() passes resumeSessionId and claude-manager resolves it into the SDK's `resume`, so a collected session resumes its own transcript instead of starting a second agent. that was the 2026-08-20 incident and it cannot recur through this path. ANYTHING ELSE LIVE refused on any agent anywhere on the machine, 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 box running commanders that is always true. a ban on the feature wearing the clothes of a safety check. GENERATING was refusing to do something that already works. a session is one long-lived query() driven by a streaming input queue; a message to a busy one is queued and delivered when the current turn ends. waiting is fine, disappearing is not, and refusing meant disappearing. what it costs, and the owner's call: nudging conversation A while B is live in the same tree still puts two agents in one directory. a message that must arrive is worth more than a guard that could only ever have been right by accident, since it never knew the directories either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -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<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.
|
||||
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<void> {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user