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:
@@ -41,9 +41,12 @@ import {
|
||||
setCommanderSessionId,
|
||||
listCommandHqSessions,
|
||||
getCommandHqSession,
|
||||
getCommandHqSessionById,
|
||||
setCommandHqSessionCategory,
|
||||
purgeCommandHqSessions,
|
||||
deleteCommandHqSession,
|
||||
armCommandHqWatcher,
|
||||
recordCommandHqWatcherFired,
|
||||
disarmCommandHqWatcher,
|
||||
getCommandHqCostSummary,
|
||||
listCommandHqTurns,
|
||||
@@ -721,9 +724,9 @@ commandHqRouter.post('/commanders', async (ctx) => {
|
||||
* `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.
|
||||
* `false` is accepted here: arming through THIS route would need a live task id, which only the turn
|
||||
* that starts the watcher has. A loop reporting its own task id arms through `POST .../dir/:dirName/arm`
|
||||
* below instead — that route exists precisely because this one can't take it.
|
||||
*/
|
||||
commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
@@ -751,6 +754,88 @@ commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* The self-report counterpart to `armCommandHqWatcher`/`recordCommandHqWatcherFired`'s harness-driven
|
||||
* calls in `websocket.ts`. Those fire off a `task:started`/`task:notification` event from the harness's
|
||||
* OWN background-task tracking on the live session that submitted the arm dialog — which never happens
|
||||
* for a loop that outlives its turn (`setsid`/`nohup`, or a tracked background job that dies with the
|
||||
* turn anyway, which is most of them). `watcher_armed` stayed false and `command_hq_watcher_fires` stayed
|
||||
* empty forever for exactly that class of commander — see `docs/command-hq.md` → "Self-reported arm".
|
||||
*
|
||||
* Authenticated with the commander's own `OFFICER_API_KEY` (minted per-commander in `credential.ts`), the
|
||||
* same door its loop already uses for `/api/notify` and `/api/tasks/*/run`. Keyed on `:dirName`, not
|
||||
* `:sessionId` — a loop knows its own directory name (it's the folder it's running from; `dirName` is
|
||||
* literally `String(id)`, see `createCommander`) and has no reason to also know its transcript's uuid.
|
||||
*/
|
||||
commandHqRouter.post('/commanders/dir/:dirName/arm', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const id = Number(ctx.req.param('dirName'));
|
||||
if (!Number.isInteger(id)) throw errors.NOT_FOUND('not a commander');
|
||||
const row = await getCommandHqSessionById(user.id, id);
|
||||
if (!row?.sessionId) throw errors.NOT_FOUND('not a commander');
|
||||
|
||||
const body = await ctx.req.json<{ taskId?: string }>().catch(() => ({}) as { taskId?: string });
|
||||
const taskId = body.taskId?.trim();
|
||||
if (!taskId) throw errors.BAD_REQUEST('taskId required');
|
||||
|
||||
await armCommandHqWatcher(user.id, row.sessionId, taskId);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* `fire`'s own guard mirrors the harness-driven path exactly: only records a trigger when this row is
|
||||
* CURRENTLY armed with a matching task id, so an old instance of a loop that a fresh restart superseded
|
||||
* (new pid, new self-generated task id) can't keep writing fires for a watch it no longer is — the same
|
||||
* protection `websocket.ts`'s `task:notification` handler gets from checking `watcherTaskId === taskId`.
|
||||
*/
|
||||
commandHqRouter.post('/commanders/dir/:dirName/fire', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const id = Number(ctx.req.param('dirName'));
|
||||
if (!Number.isInteger(id)) throw errors.NOT_FOUND('not a commander');
|
||||
const row = await getCommandHqSessionById(user.id, id);
|
||||
if (!row) throw errors.NOT_FOUND('not a commander');
|
||||
|
||||
const body = await ctx.req.json<{ taskId?: string }>().catch(() => ({}) as { taskId?: string });
|
||||
const taskId = body.taskId?.trim();
|
||||
if (!taskId) throw errors.BAD_REQUEST('taskId required');
|
||||
|
||||
if (!row.watcherArmed || row.watcherTaskId !== taskId) {
|
||||
return ctx.json({ ok: false, reason: 'not the current armed task' });
|
||||
}
|
||||
await recordCommandHqWatcherFired(row.id, taskId);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* The loop's own clean-exit counterpart to `arm` — best-effort, same as every other disarm path: a loop
|
||||
* killed without running its trap still leaves `watcher_armed: true` until the next arm or an explicit
|
||||
* disarm, exactly as it already could before this route existed.
|
||||
*
|
||||
* Takes the SAME `taskId` guard as `fire`, and for the same reason: a restart replaces the old instance
|
||||
* with a fresh arm before the old one's process necessarily notices it's been superseded (an inotify
|
||||
* loop's kill can outrace the new one's `arm` call by seconds). An unguarded disarm from the OLD
|
||||
* instance's exit trap would then clear the NEW instance's `watcher_armed` a moment after it was set —
|
||||
* caught in exactly this shape while restarting a real duplicate on this machine, 2026-09-14. `taskId`
|
||||
* is optional so an out-of-band disarm (nothing self-reported ever armed this row) still works — the
|
||||
* guard only applies once there is a task id on the row to disagree with.
|
||||
*/
|
||||
commandHqRouter.post('/commanders/dir/:dirName/disarm', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const id = Number(ctx.req.param('dirName'));
|
||||
if (!Number.isInteger(id)) throw errors.NOT_FOUND('not a commander');
|
||||
const row = await getCommandHqSessionById(user.id, id);
|
||||
if (!row?.sessionId) throw errors.NOT_FOUND('not a commander');
|
||||
|
||||
const body = await ctx.req.json<{ taskId?: string }>().catch(() => ({}) as { taskId?: string });
|
||||
const taskId = body.taskId?.trim();
|
||||
if (taskId && row.watcherTaskId && row.watcherTaskId !== taskId) {
|
||||
return ctx.json({ ok: false, reason: 'not the current armed task' });
|
||||
}
|
||||
|
||||
await disarmCommandHqWatcher(user.id, row.sessionId);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /command-hq/commanders/:sessionId
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user