command-hq: self-reported arm/fire/disarm for loops that outlive their turn
The existing arm path only fires off the harness's own task:started/
task:notification tracking for the live session that submitted the dialog,
so a loop started detached (setsid/nohup) or as a tracked job that dies
with its turn — which is every real commander on this machine — never
arms in Postgres and never records a fire, even after running correctly
for weeks. Adds POST /commanders/dir/:dirName/{arm,fire,disarm}, a second
door to the same armCommandHqWatcher/recordCommandHqWatcherFired/
disarmCommandHqWatcher functions, authenticated with the commander's own
long-lived OFFICER_API_KEY and keyed on dirName since that's what a loop
already knows about itself.
This commit is contained in:
+39
-3
@@ -62,11 +62,14 @@ for exact request/response shapes; the table below is the map, not the full cont
|
||||
| GET | `/commanders/:sessionId/turns` | — | `{turns: [...]}` — the raw per-turn cost ledger, oldest first. What "cost over time" plots from |
|
||||
| GET | `/commanders/:sessionId/fires` | — | `{fires: [...]}` — every trigger, oldest first. The full history behind `watcherLastFiredAt` |
|
||||
| POST | `/commanders` | `{sessionId, categoryId?}` | files an EXISTING session; idempotent; 404 if the session isn't in the commanders directory. Not the creation path — see `/commanders/new` |
|
||||
| PATCH | `/commanders/:sessionId` | `{categoryId?, name?, watcherArmed?}` | `name` proxies to `renameClaudeSession`; `watcherArmed: false` disarms (arming has no route — needs a live task id) |
|
||||
| PATCH | `/commanders/:sessionId` | `{categoryId?, name?, watcherArmed?}` | `name` proxies to `renameClaudeSession`; `watcherArmed: false` disarms. This route still can't arm — see the `dir/:dirName` routes below for the one path that can from outside a running turn |
|
||||
| DELETE | `/commanders/:sessionId` | — | deletes the TRANSCRIPT (`/clear` chain and all) via `deleteClaudeSession`, then the row. Not reversible, and not the same thing `/purge` does — see "The `/chat` ↔ `/command-hq` relationship" below |
|
||||
| POST | `/purge` | — | sweeps rows whose transcript no longer resolves; returns `{removed}` |
|
||||
| POST | `/commanders/:sessionId/tags` | `{tagId}` | |
|
||||
| DELETE | `/commanders/:sessionId/tags/:tagId` | — | |
|
||||
| POST | `/commanders/dir/:dirName/arm` | `{taskId}` | self-reported arm, for a loop the harness never sees fire — see "Self-reported arm" below. `:dirName` is the commander's own numeric id as a string, not the transcript uuid |
|
||||
| POST | `/commanders/dir/:dirName/fire` | `{taskId}` | self-reported trigger; no-ops (`{ok:false}`) unless `taskId` matches the row's current `watcherTaskId` |
|
||||
| POST | `/commanders/dir/:dirName/disarm` | — | self-reported clean exit; same effect as `PATCH {watcherArmed:false}`, resolved by dirName instead |
|
||||
|
||||
**A commander's conversation is `GET /chat/sessions/:id`, unchanged.** This router deliberately does not
|
||||
duplicate it — `GET /commanders/:sessionId` returns Command HQ's own metadata: `{sessionId, categoryId,
|
||||
@@ -463,8 +466,9 @@ occur.
|
||||
`recordCommandHqTurn` in `queries.ts`; exposed read-only on `GET /commanders` and
|
||||
`GET /commanders/:sessionId` (`watcherLastFiredAt` survives as a name in the JSON response — it is just
|
||||
computed there, `MAX(fired_at)`, not read off a column), 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).
|
||||
`{watcherArmed: false}` to disarm. Arming still has no route THERE — it needs a live task id, which only
|
||||
a running turn has — but see "Self-reported arm" below for the route that exists precisely because a
|
||||
loop that outlives its turn doesn't have one.
|
||||
- **`armWatcher` threaded end-to-end, client to server.** `BackgroundJobDialog` computes it (`watching &&
|
||||
onFire === 'handoff'`) → `sendWithHiddenPrefix` → `QueuedPrompt.armWatcher` → `sendPrompt`'s new last
|
||||
parameter → the chat websocket message → `ClientMessage`'s `'chat'` variant → `startClaudeTurn`'s
|
||||
@@ -537,6 +541,38 @@ OWN user id, not the row it belonged to). Found by re-reading every call site th
|
||||
after the fact, not by any tool — worth remembering the next time a signature changes shape rather than
|
||||
just its types: a compiler that stays quiet is not the same as a diff that is correct.
|
||||
|
||||
### Self-reported arm — 2026-09-14
|
||||
|
||||
The mechanism above has a gap it never surfaced until four real commanders on the owner's own machine were
|
||||
checked against it: `pendingArmWatcher` is consumed on the harness's `task:started` for the live session
|
||||
that submitted the arm dialog, and stays valid only as long as that session's own tracked-background-task
|
||||
plumbing does. A loop that needs to outlive its turn — `setsid`/`nohup`/`disown`, or any tracked background
|
||||
job that the harness itself drops when the turn ends, which multiple real commanders here do on purpose,
|
||||
one of them (`docs`-adjacent `WATCHERS.md` for the OCR watcher) as an explicit standing decision favouring
|
||||
job-panel visibility over survival — never produces that event. `watcherArmed` stayed `false` and
|
||||
`command_hq_watcher_fires` stayed empty forever for every one of them, even while the loop genuinely polled
|
||||
and genuinely fired, repeatedly, for weeks. Not cosmetic: `GET /commanders`'s card already papers over the
|
||||
`watcherArmed` boolean with a live pid check (`row.watcherArmed || liveLoopPid(...) !== null`, `router.ts`),
|
||||
so the badge was always right — but nothing papers over an empty `command_hq_watcher_fires`, so
|
||||
`watcherLastFiredAt` read `null` forever on every card for a commander that had fired dozens of times.
|
||||
|
||||
Fixed by giving the loop a second, independent way to say the same thing the harness's own tracking says
|
||||
for a session-scoped commander: three routes under `/commanders/dir/:dirName/{arm,fire,disarm}`,
|
||||
authenticated with the commander's own long-lived `OFFICER_API_KEY` (the same credential its loop already
|
||||
holds and already uses for `/api/notify` and `/api/tasks/*/run` — nothing new minted), keyed on `dirName`
|
||||
because that is what a loop already knows about itself without needing its own transcript uuid. `arm` and
|
||||
`disarm` call `armCommandHqWatcher`/`disarmCommandHqWatcher` directly — the same functions `websocket.ts`
|
||||
calls, just reached from a second door. `fire` keeps the identical staleness guard `task:notification`
|
||||
already has (`watcherTaskId` must match), so a superseded instance of a loop — an old pid a crash left
|
||||
running, now replaced by a freshly-armed one — can't keep writing fires for a watch it no longer is. The
|
||||
task id itself is no longer harness-issued for this path: the loop mints its own (pid + start time is
|
||||
enough) at the top of the script and reuses it for every `fire` call until it restarts.
|
||||
|
||||
This does not change anything about the original harness-driven path — a commander whose loop genuinely is
|
||||
a tracked background job on a live session still arms exactly as before, and most of what's written above
|
||||
about `pendingArmWatcher` and the noticing in `createMessageHandler` is unchanged. It adds a second, parallel
|
||||
way to reach the same two DB functions, for the class of commander the first path structurally can't reach.
|
||||
|
||||
## 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
|
||||
|
||||
Reference in New Issue
Block a user