watcher loop: arming state and end-to-end plumbing, item 3's mechanism found wrong before writing it

item 4 first (schema + plumbing), ahead of the proposal's own suggested
order, because item 3's trigger needed somewhere to write to before it could
be tested at all.

command_hq_sessions gains watcherArmed/watcherTaskId/watcherLastFiredAt.
watcherArmed is allowed to be stored unlike `running` — it's the owner's own
intent, not a live-process fact nothing external can silently falsify the
way a sidecar restart falsifies running. armCommandHqWatcher/
recordCommandHqWatcherFired/disarmCommandHqWatcher in queries.ts, exposed on
GET /commanders, GET /commanders/:sessionId, and PATCH accepts
{watcherArmed:false} to disarm (arming itself has no route — needs a live
task id only a running turn has).

armWatcher threaded the full client-to-server path: BackgroundJobDialog ->
sendWithHiddenPrefix -> QueuedPrompt -> sendPrompt's new last param -> the
chat websocket message -> ClientMessage -> startClaudeTurn's msg. typechecks
end to end, consumed NOWHERE yet — safely inert, not a shipped mechanism.

the real finding: my plan to reuse task-restart.ts's restartBackgroundTask
for the actual re-arm trigger is wrong, and I caught it before writing it
rather than after. that function respawns a process OUTSIDE the sdk's own
Monitor tracking — chat.ts's restart route emits its own task:notification
via emitToSession specifically because the harness stopped watching that
process. it updates the ui; it cannot reach back into the agent's context,
because there's no mechanism by which it could. a handoff watcher needs the
opposite — the agent must be TOLD again when it fires again, which only
happens through the sdk's own notification delivery, which only exists for
a job the sdk itself is still tracking.

so re-arming can't be a silent platform respawn. it has to drive the AGENT
to reissue its own run_in_background/Monitor call — the platform's job is
making that automatic and correct (right command via commandForTask, right
moment, no double-arm), not doing the reissuing itself. likely shape: a
synthetic follow-up turn, not from the user, once the acting turn's result
lands. not implemented — raised with the owner before building around a
wrong assumption a second time tonight.

docs/command-hq.md corrected to match — the earlier "reuse restartBackgroundTask"
claim removed and replaced with what's actually true.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 08:05:21 +00:00
co-authored by Claude Opus 5
parent 7229ce021d
commit f369b4ca18
11 changed files with 204 additions and 58 deletions
+7
View File
@@ -76,6 +76,13 @@ export type ClientMessage =
* first turn is the first moment the preference can be acted on OR written down.
*/
planMode?: boolean;
/**
* This turn is arming a Command HQ watcher — see `docs/command-hq.md` → "The watcher loop". Set only
* by `BackgroundJobDialog` submitting a `handoff` job inside a commander (`isCommander` true); every
* other caller omits it. The server correlates it to the NEXT `task:started` this turn produces, so
* it must ride the exact message that starts the watching turn.
*/
armWatcher?: boolean;
}
| {
type: 'resume';
+3
View File
@@ -601,6 +601,7 @@ async function handleChat(
contextId?: string;
resumeSummary?: string;
resumeSessionId?: string;
armWatcher?: boolean;
},
): Promise<void> {
const { userId } = ws.data;
@@ -723,6 +724,7 @@ export async function startClaudeTurn(params: {
images?: PromptImage[];
resumeSessionId?: string;
planMode?: boolean;
armWatcher?: boolean;
};
effectivePrompt: string;
onOutcome?: (outcome: ClaudeTurnOutcome) => void;
@@ -893,6 +895,7 @@ async function handleClaudeCodeChat(
images?: PromptImage[];
resumeSessionId?: string;
planMode?: boolean;
armWatcher?: boolean;
},
effectivePrompt: string,
): Promise<void> {
+23 -3
View File
@@ -24,6 +24,7 @@ import {
getCommandHqSession,
setCommandHqSessionCategory,
purgeCommandHqSessions,
disarmCommandHqWatcher,
listCommandHqSessionTags,
listCommandHqTags,
createCommandHqTag,
@@ -147,6 +148,14 @@ type CommanderSummary = ClaudeSessionSummary & {
* `docs/command-hq.md` for why a status column would go stale exactly when this feature matters most.
*/
running: boolean;
/**
* Whether the platform is keeping this commander's watcher re-armed — see `docs/command-hq.md` → "The
* watcher loop". Unlike `running`, this IS stored: it is the owner's own intent, not a live-process fact
* the platform is reporting on. `watcherTaskId` itself is not exposed — an internal correlation id with
* no display meaning, the join key `armCommandHqWatcher` uses and nothing a card needs to show.
*/
watcherArmed: boolean;
watcherLastFiredAt: Date | null;
};
async function isRunning(sessionId: string): Promise<boolean> {
@@ -189,6 +198,8 @@ commandHqRouter.get('/commanders', async (ctx) => {
categoryId: row.categoryId,
tagIds: tagsByRow.get(row.id) ?? [],
running: runningById.get(row.sessionId) ?? false,
watcherArmed: row.watcherArmed,
watcherLastFiredAt: row.watcherLastFiredAt,
}))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
@@ -299,6 +310,8 @@ commandHqRouter.get('/commanders/:sessionId', async (ctx) => {
categoryId: row.categoryId,
tagIds: tagsByRow.get(row.id) ?? [],
running,
watcherArmed: row.watcherArmed,
watcherLastFiredAt: row.watcherLastFiredAt,
createdAt: row.createdAt,
});
});
@@ -337,18 +350,22 @@ commandHqRouter.post('/commanders', async (ctx) => {
/**
* PATCH /command-hq/commanders/:sessionId
*
* Body may carry either or both:
* Body may carry any of:
* `categoryId: number | null` — move to a category, or `null` to uncategorise
* `name: string` — rename. Proxies straight to `renameClaudeSession`, the SAME function `/chat`'s own
* rename action calls — there is no separate name field on this table to disagree with it.
* `watcherArmed: false` — disarm a watcher, so the harness stops re-arming it on its next fire. Only
* `false` is accepted here: arming requires a live task id (`armCommandHqWatcher`), which only the turn
* that starts the watcher has — there is no product path to arm one from outside a running turn, and
* this route is not it.
*/
commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
const user = ctx.get('user');
const who = await commandHqIdentity(user);
const sessionId = ctx.req.param('sessionId');
const body = await ctx.req
.json<{ categoryId?: number | null; name?: string }>()
.catch(() => ({}) as { categoryId?: number | null; name?: string });
.json<{ categoryId?: number | null; name?: string; watcherArmed?: false }>()
.catch(() => ({}) as { categoryId?: number | null; name?: string; watcherArmed?: false });
const row = await getCommandHqSession(user.id, sessionId);
if (!row) throw errors.NOT_FOUND('not a commander');
@@ -360,6 +377,9 @@ commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
const ok = renameClaudeSession(who, getCommandersDir(who.email), sessionId, body.name.trim());
if (!ok) throw errors.NOT_FOUND('transcript not found');
}
if (body.watcherArmed === false) {
await disarmCommandHqWatcher(user.id, sessionId);
}
return ctx.json({ ok: true });
});