watcher loop: the prompt/dialog half, and stopped no longer reads as failed

owner's call, confirmed 2026-08-18: implement watcher-loop-proposal.md
exactly as written, before the first real tests — this governs agent
behavior generally, not just command hq. items 1-2 of the proposal's own
suggested order, done; items 3-4 (harness-owned re-arm, arming state on the
commander row) are the harder half, next.

FireBehavior replaces the stopOnFire boolean: 'stop' | 'handoff' |
'keep-going'. handoff carries the proposal's one-sentence contract verbatim
("the harness re-arms you — do not re-arm yourself, and do not build your
own polling loop on top of this one") and rewrites the closing IF IT EXITS
block so it stops contradicting rule 7 (the old universal "do not restart
it, i will start it again" directly lied about what handoff jobs actually
do).

isCommander gates the option to where the promise can be kept. the proposal
frames this entirely in terms of commanders — arming state's only home is
the commander row — so outside one there's nowhere to persist "keep
re-arming this," and offering the option there would make a promise nothing
fulfills. not wired to true anywhere yet: command hq's own composer, which
would set it, doesn't exist. every existing /chat caller is unaffected —
two options, stop default, unchanged from before tonight.

stopped-vs-died (§5): a job officer had just SIGTERM'd came back
status:'failed' (exit code 144) from the harness's own classification,
which this codebase does not control. fixed client-side — the tray already
knows the instant stop is clicked, so it now also marks that, and
useChat's task:notification handler overrides the displayed status
regardless of what the harness reported. known gap, stated in both the code
and the doc: in-memory, so it doesn't survive a refresh in the narrow window
between clicking stop and the notification landing.

docs/command-hq.md gains "the watcher loop" section: what's built, what
isn't, the proposal's own suggested order followed as given.

bunx tsgo clean. suite: 1038 pass (+9 new tests), same 7 pre-existing
baseline failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 07:52:06 +00:00
co-authored by Claude Opus 5
parent f810bdc48e
commit 7229ce021d
8 changed files with 299 additions and 33 deletions
+57 -5
View File
@@ -287,13 +287,12 @@ an independent record of what sessions exist. Consequences the owner confirmed e
is a commander for life, and (given commanders live in their own cwd, never a `/chat` cwd) the two spaces
never actually overlap.
## Built on, unchanged
## Built on, mostly unchanged
Everything about _how a background job runs_ is out of scope — this only adds a place to browse, name and
categorise the sessions the existing system produces. In particular, unmodified:
Everything about _how a background job runs_ was out of scope for the original build — a place to browse,
name and categorise the sessions the existing system produces. That held until real testing found the
mechanism itself wanting; see "The watcher loop" below for what changed and why. Otherwise, unmodified:
- The Background Job dialog and `buildBackgroundJobPrompt` — Command vs Prompt, the watch/stop-on-fire
rules, "run until stopped" vs "run for N minutes" (both prose in the prompt, no server-side timer).
- The whole lifecycle: `run_in_background`/`Monitor`, `task:started`/`task:notification`,
`chat_session_events` as the durable log, `collectRunningTasks` as the fold, the tray.
- Stop/pause/restart routes under `/chat/tasks/:id`.
@@ -301,6 +300,59 @@ categorise the sessions the existing system produces. In particular, unmodified:
- `officer-notify` — assume it works and is wired up separately; do not build a parallel notification path
for this feature.
## The watcher loop — exit, act, drain, re-arm
`COMMS/command-hq/watchers-what-broke.md` and `watcher-loop-proposal.md`, both 2026-08-18: the mobile side
actually ran a watcher (poll `git ls-remote`, react to each push) through the dialog, and the mechanism
broke in ways no amount of reading the code would have found — two watchers alive at once because a new
one was armed before the old was reaped, a watcher firing on the agent's own push to the branch it was
watching, "keep going" silently meaning the agent would never see a firing again because a background
process reaches the model only when it EXITS. The owner's call, confirmed 2026-08-18: **implement the
proposal as written, before the first real tests** — this governs the agent's behaviour generally, not
only Command HQ, so it is not optional groundwork the way most of this document is.
**Built:**
- **A third dialog option**, `FireBehavior = 'stop' | 'handoff' | 'keep-going'` (was a `stopOnFire`
boolean). `handoff` — "Exit and hand back to the agent, then re-arm" — carries the proposal's one-sentence
contract (`handoffContract` in `background-job-prompt.ts`) verbatim: _"When the condition fires you will
be handed back, with the change. Do the work. The harness re-arms you — do not re-arm yourself, and do
not build your own polling loop on top of this one."_ The closing "IF IT EXITS" section is rewritten for
`handoff` too, so it does not contradict rule 7 by telling the model an exit means stay-stopped.
- **`isCommander`, gating the promise to where it can be kept.** The proposal frames this entirely in terms
of commanders — "a commander already has a row," arming state's only home — and outside one there is
nowhere to persist "this should keep re-arming itself," so nothing would fulfil the promise. Rather than
make it anyway, `BackgroundJobDialog` only offers `handoff` when `isCommander` is true, defaulting to it
there and to the pre-existing `stop` everywhere else. **Not yet wired to `true` anywhere** — Command HQ's
own composer, which would set it, does not exist yet (web) or does not use this dialog (mobile has its
own). Every existing `/chat` caller is unaffected: two options, `stop` default, exactly as before tonight.
- **Stopped no longer reads as failed.** `watchers-what-broke.md` §5: a job Officer had just SIGTERM'd came
back `status: 'failed'` (exit code 144) from the harness's own classification, which this codebase does
not control. Fixed client-side, at the one place it can be fixed cheaply: `stopTask` in
`BackgroundTaskTray.tsx` now also calls `markStopRequested(taskId)`
(`background-job-origin.ts`), and `useChat.ts`'s `task:notification` handler overrides the displayed
status to `stopped` when a stop was just requested for that id, regardless of what the harness reported.
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:**
- **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).
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.
## Later — explicitly deferred, not forgotten
Raised during spec and set aside for a second version, in the owner's words: _"that's not... that's out of