watcher loop: the agent re-arms itself, same turn — final mechanism, confirmed against real usage

second correction to the re-arm design tonight, and this one came from the
owner, not from me. my plan (platform sends a synthetic follow-up turn once
the acting turn's result lands, instructing re-arm) was technically sound —
it keeps the agent inside the sdk's own Monitor tracking, which is the thing
the FIRST wrong plan (silent respawn) broke — but it costs a real extra turn
every cycle. pushed back on with hard evidence: the mobile dev's own
watcher, on this repo, this session, had been running with THEM re-arming
themselves, same turn, for hours already — zero failures. that beats a
reliability concern I raised without having tested it.

so: handoffContract now instructs the agent to re-arm itself, as the last
action of the SAME turn that handles the notification — no synthetic turn,
no platform spawn, no extra cost. exitContract's handoff branch corrected to
match (was still saying "the harness re-arms a firing on its own", exactly
backwards now).

server side simplifies to match: createMessageHandler's task:started hook
now handles both cases with one rule — pendingArmWatcher (session-object
flag, not closure-captured, since arming happens turns after the closure
was built) claims the FIRST task:started as the watcher; every SUBSEQUENT
task:started while already armed is read as the agent's own self-re-arm and
just updates watcherTaskId to match. task:notification records the fire
when it matches the tracked id. the platform never drives anything — it
only notices what the agent already did.

pulled COMMS/command-hq/mobile-3.md, which answered the PREVIOUS (synthetic
turn) version — crossed in transit. their transcript-bloat concern is moot
now (no synthetic turn exists to bloat anything). their `watcherArmed &&
!running` detectable-failure pair stands regardless of which mechanism won
and is exactly right — kept as-is, matches their own three-state dot
(running/armed-but-not/neither). replied in platform.md.

all four proposal items now built: prompt+dialog, stopped-vs-died, the
re-arm mechanism (this commit), arming state+plumbing.

bunx tsgo clean, suite unchanged (1038 pass, same 7 pre-existing baseline
failures).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 08:33:47 +00:00
co-authored by Claude Opus 5
parent cc5340b8e0
commit 624d67ed52
7 changed files with 232 additions and 59 deletions
+9
View File
@@ -421,6 +421,15 @@ export type UserSession = {
meta: SessionMeta;
_sidecarUnsub?: () => void;
_claudeKill?: () => void;
/**
* A `handoff` watcher was just requested and has not yet been claimed by a `task:started`. See
* `docs/command-hq.md` → "The watcher loop". Lives on the session object rather than being captured by
* `createMessageHandler`'s closure, because arming routinely happens turns after that closure was first
* built — the session-scoped event subscription is wired ONCE, on the session's first-ever turn
* (`websocket.ts`, the `!session._claudeKill` branch), so a closure built on a LATER turn is never
* actually the one receiving events. The session object is the one thing every turn shares.
*/
pendingArmWatcher?: boolean;
};
export type ModelInfo = {
+74
View File
@@ -30,6 +30,9 @@ import {
setChatSessionModel,
setChatSessionPlanMode,
createCommandHqSession,
getCommandHqSession,
armCommandHqWatcher,
recordCommandHqWatcherFired,
} from 'officerdb';
import { resolveHomeDir } from '@@/user-home';
import { claudeLoginState } from '@@/os-user-claude';
@@ -513,6 +516,74 @@ function createMessageHandler(
fileAsCommander();
}
/*
The watcher loop — see `docs/command-hq.md` → "The watcher loop" and `handoffContract` in
`background-job-prompt.ts`. The platform does not DRIVE re-arming; the agent does, itself, same turn,
per that prompt. All the platform does is NOTICE: which task is the current watcher, and when it
fired. Both read `session` live (never a value captured when this closure was built) because arming
routinely happens turns after the closure was created — the dialog is submitted well into an
already-open commander conversation, not necessarily on its first message.
*/
if (msg.type === 'task:started' && commandHq && transcriptId) {
const userId = session.userId;
if (userId) {
// Consumed here, one-shot — the SAME arm/claim shape `background-job-origin.ts` already uses
// client-side for the identical problem (which task is "the one", among however many a turn
// might start). Only the FIRST arm needs this: it is what turns "some task started" into "this is
// the watcher" in the first place. Every later re-arm is recognised the fallback way, below,
// because by then the row already says armed and there is nothing left to claim.
if (session.pendingArmWatcher) {
session.pendingArmWatcher = false;
const taskId = msg.taskId;
void armCommandHqWatcher(userId, transcriptId, taskId).catch((err: unknown) => {
logger.warn('Could not arm commander watcher', { sessionId, transcriptId, error: String(err) });
});
} else {
// Not a fresh arm — but if this commander is ALREADY armed, a new task starting is exactly what
// the agent's own self-re-arm produces (`handoffContract`: same turn, same command, new task id).
// Update the tracked id so the NEXT fire is still recognised as this commander's watcher.
//
// Known imprecision, accepted rather than solved tonight: this cannot tell a genuine re-arm apart
// from an unrelated background task the agent happens to start in the same commander conversation
// — a commander is one watcher by design, so this is a real but narrow gap, not the common case.
const capturedTranscriptId = transcriptId;
const taskId = msg.taskId;
void getCommandHqSession(userId, capturedTranscriptId)
.then((row) => {
if (row?.watcherArmed) return armCommandHqWatcher(userId, capturedTranscriptId, taskId);
})
.catch((err: unknown) => {
logger.warn('Could not check commander watcher state', {
sessionId,
transcriptId: capturedTranscriptId,
error: String(err),
});
});
}
}
}
if (msg.type === 'task:notification' && commandHq && transcriptId) {
const userId = session.userId;
if (userId) {
const capturedTranscriptId = transcriptId;
const taskId = msg.taskId;
void getCommandHqSession(userId, capturedTranscriptId)
.then((row) => {
if (row?.watcherArmed && row.watcherTaskId === taskId) {
return recordCommandHqWatcherFired(userId, capturedTranscriptId);
}
})
.catch((err: unknown) => {
logger.warn('Could not record commander watcher fire', {
sessionId,
transcriptId: capturedTranscriptId,
error: String(err),
});
});
}
}
/*
A turn ended — take a usage snapshot while the person is reading the answer.
@@ -768,6 +839,9 @@ export async function startClaudeTurn(params: {
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug, msg.context, msg.contextId);
session.userId = userId;
// Set on the SESSION, not captured by this turn's `createMessageHandler` closure below — see
// `pendingArmWatcher`'s own docstring for why that distinction matters here specifically.
if (msg.armWatcher) session.pendingArmWatcher = true;
// No live socket to attach for an HTTP-originated turn — the caller attaches later, by transcript id,
// through the ordinary `/chat` websocket `attach` path, exactly as a reconnect already does.
if (ws) {