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
+42 -15
View File
@@ -335,23 +335,50 @@ only Command HQ, so it is not optional groundwork the way most of this document
Known gap: this is in-memory client state, so it does not survive a page refresh between clicking Stop
and the notification arriving — an acceptable, narrow window, not attempted to be closed tonight.
**Not yet built — the harder half:**
**Built, item 4 (ahead of item 3 — plumbing needed to exist before the trigger could be tested):**
- **Harness-owned re-arm.** The proposal's actual infrastructure ask: once the agent's turn responding to a
firing ends, the PLATFORM (not the model) automatically respawns the original watch command as a fresh
background task. The natural implementation reuses `task-restart.ts`'s existing `commandForTask` (already
reconstructs a shell command from a transcript's tool_use/tool_result pairing) and its detached-spawn
machinery — re-arming becomes "run the restart path again, automatically, keyed off a `result` event for
a commander whose watcher just fired," rather than new command-recovery logic.
- **Arming state on the commander row.** `armed`, the current watch task's id, `lastFiredAt` — the
proposal's own suggested order puts this LAST, "once 3 exists to write it." Settles "one armed watcher
per commander" as a natural side effect (the row can refuse a second) and "stopped vs died" for commanders
specifically (the thing that re-arms already knows which it was).
- **`command_hq_sessions` gained `watcherArmed`, `watcherTaskId`, `watcherLastFiredAt`.** Unlike `running`,
`watcherArmed` is allowed to be stored — it is the owner's INTENT ("keep re-arming this"), not a live
process fact the platform is reporting on, so nothing external can silently falsify it the way a sidecar
restart falsifies `running`. `watcherTaskId` is closer to that rejected shape and accepts the same known
gap `running` already lives with — see the schema file's docstring.
`armCommandHqWatcher` / `recordCommandHqWatcherFired` / `disarmCommandHqWatcher` in `queries.ts`; exposed
read-only on `GET /commanders` and `GET /commanders/:sessionId`, and `PATCH /commanders/:sessionId` accepts
`{watcherArmed: false}` to disarm (arming itself has no route — it needs a live task id, which only a
running turn has).
- **`armWatcher` threaded end-to-end, client to server, and CONSUMED NOWHERE YET.** `BackgroundJobDialog`
computes it (`watching && onFire === 'handoff'`) → `sendWithHiddenPrefix` → `QueuedPrompt.armWatcher` →
`sendPrompt`'s new last parameter → the chat websocket message → `ClientMessage`'s `'chat'` variant →
`startClaudeTurn`'s `msg.armWatcher`. Every step type-checks and the full suite is unaffected, because
nothing reads the field on the server yet — safely inert plumbing, not a shipped mechanism.
Suggested order from the proposal, followed here: (1) prompt + dialog — done, (2) stopped-vs-died — done,
(3) harness-owned re-arm, (4) arming state. "Nothing here blocks the first live test of the apps; all of it
blocks trusting a watcher that is meant to run for months" — the proposal's own words, and the reason (3)
and (4) are still open rather than rushed.
**Not yet built — item 3, and a real design question found while trying to build it:**
The original plan here was wrong and is worth recording as wrong rather than quietly fixed: `task-restart.ts`'s
`restartBackgroundTask` looked like the obvious reuse — it already recovers a command from a transcript and
respawns it detached. **It is not reusable for this.** A restarted job is deliberately 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 path updates the UI; it does not reach back into
the agent's own context, because there is no mechanism by which it could. A `handoff` watcher's whole
point is 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 tracking.
So harness-owned re-arm cannot be a silent platform-side respawn. It has to drive the AGENT to reissue its
own `run_in_background`/`Monitor` call — the platform's job is to make that reissue AUTOMATIC and
CORRECT (the right command, the right moment, no double-arming) rather than something the model has to
remember across turns, not to do the reissuing itself. Concretely, likely shape: once a fired watcher's
"acting" turn ends (`result`, for a commander whose `watcherTaskId` just produced a `task:notification`),
the platform sends a SYNTHETIC follow-up turn — not from the user — instructing the agent to re-arm using
the exact command `commandForTask` recovers from the original transcript. `commandForTask` is still the
right reuse; `restartBackgroundTask` is not. Not implemented — raised with the owner before writing it, for
the second time tonight, because getting the mechanism itself wrong is more expensive to discover after
the fact than before.
Suggested order from the proposal: (1) prompt + dialog — done, (2) stopped-vs-died — done, (4) arming
state and plumbing — done ahead of schedule, (3) the actual re-arm trigger — open, blocked on confirming
the synthetic-turn design above. "Nothing here blocks the first live test of the apps; all of it blocks
trusting a watcher that is meant to run for months" — the proposal's own words, and the reason this is
still open rather than rushed.
## Later — explicitly deferred, not forgotten