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
@@ -10,6 +10,9 @@ export {
setCommandHqSessionCategory,
deleteCommandHqSession,
purgeCommandHqSessions,
armCommandHqWatcher,
recordCommandHqWatcherFired,
disarmCommandHqWatcher,
listCommandHqTags,
createCommandHqTag,
deleteCommandHqTag,
@@ -44,7 +44,26 @@ export async function deleteCommandHqCategory(userId: number, id: number): Promi
// ── Commanders (command_hq_sessions) ──
export type CommandHqSessionRow = { id: number; sessionId: string; categoryId: number | null; createdAt: Date };
export type CommandHqSessionRow = {
id: number;
sessionId: string;
categoryId: number | null;
watcherArmed: boolean;
watcherTaskId: string | null;
watcherLastFiredAt: Date | null;
createdAt: Date;
};
/** One definition of the row shape, reused everywhere it is selected — see the schema file for what each field means. */
const SESSION_ROW_COLUMNS = {
id: commandHqSessions.id,
sessionId: commandHqSessions.sessionId,
categoryId: commandHqSessions.categoryId,
watcherArmed: commandHqSessions.watcherArmed,
watcherTaskId: commandHqSessions.watcherTaskId,
watcherLastFiredAt: commandHqSessions.watcherLastFiredAt,
createdAt: commandHqSessions.createdAt,
};
/**
* File a session as a commander. Upsert on `(user_id, session_id)` — a second call (a retried request, a
@@ -60,22 +79,12 @@ export async function createCommandHqSession(
.insert(commandHqSessions)
.values({ userId, sessionId, categoryId })
.onConflictDoNothing({ target: [commandHqSessions.userId, commandHqSessions.sessionId] })
.returning({
id: commandHqSessions.id,
sessionId: commandHqSessions.sessionId,
categoryId: commandHqSessions.categoryId,
createdAt: commandHqSessions.createdAt,
});
.returning(SESSION_ROW_COLUMNS);
// `onConflictDoNothing` returns nothing on the conflict path, so the existing row is fetched explicitly —
// the caller still needs an id back (to attach tags, say) even when this call raced an earlier one.
if (row) return row;
const [existing] = await db
.select({
id: commandHqSessions.id,
sessionId: commandHqSessions.sessionId,
categoryId: commandHqSessions.categoryId,
createdAt: commandHqSessions.createdAt,
})
.select(SESSION_ROW_COLUMNS)
.from(commandHqSessions)
.where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.sessionId, sessionId)));
return existing!;
@@ -96,16 +105,7 @@ export async function listCommandHqSessions(
? and(eq(commandHqSessions.userId, userId), isCategoryNull())
: and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.categoryId, categoryId));
return db
.select({
id: commandHqSessions.id,
sessionId: commandHqSessions.sessionId,
categoryId: commandHqSessions.categoryId,
createdAt: commandHqSessions.createdAt,
})
.from(commandHqSessions)
.where(where)
.orderBy(commandHqSessions.createdAt);
return db.select(SESSION_ROW_COLUMNS).from(commandHqSessions).where(where).orderBy(commandHqSessions.createdAt);
}
// drizzle's `eq` cannot express `IS NULL`.
@@ -126,17 +126,54 @@ export async function listCommandHqSessionIds(userId: number): Promise<Set<strin
export async function getCommandHqSession(userId: number, sessionId: string): Promise<CommandHqSessionRow | null> {
const [row] = await db
.select({
id: commandHqSessions.id,
sessionId: commandHqSessions.sessionId,
categoryId: commandHqSessions.categoryId,
createdAt: commandHqSessions.createdAt,
})
.select(SESSION_ROW_COLUMNS)
.from(commandHqSessions)
.where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.sessionId, sessionId)));
return row ?? null;
}
/**
* Arm (or re-arm) a commander's watcher — called both the first time a `handoff` job starts and every
* time the harness re-arms it after acting on a fire. Idempotent in effect: arming with the same task id
* twice just writes the same values again.
*/
export async function armCommandHqWatcher(userId: number, sessionId: string, taskId: string): Promise<boolean> {
const rows = await db
.update(commandHqSessions)
.set({ watcherArmed: true, watcherTaskId: taskId })
.where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.sessionId, sessionId)))
.returning({ id: commandHqSessions.id });
return rows.length > 0;
}
/**
* The watcher fired. Records WHEN for display — does not touch `watcherArmed` or `watcherTaskId`, which
* stay exactly as they were through the fired-then-acting window, right up until the next `armCommandHqWatcher`
* replaces the task id on re-arm.
*/
export async function recordCommandHqWatcherFired(userId: number, sessionId: string): Promise<boolean> {
const rows = await db
.update(commandHqSessions)
.set({ watcherLastFiredAt: new Date() })
.where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.sessionId, sessionId)))
.returning({ id: commandHqSessions.id });
return rows.length > 0;
}
/**
* Disarm a commander's watcher. No product path calls this yet — there is no UI for it — but the
* mechanism needs an off switch to exist before it needs a button, and `watcherTaskId` must be cleared
* alongside `watcherArmed`, not left pointing at a task nothing will ever re-arm from again.
*/
export async function disarmCommandHqWatcher(userId: number, sessionId: string): Promise<boolean> {
const rows = await db
.update(commandHqSessions)
.set({ watcherArmed: false, watcherTaskId: null })
.where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.sessionId, sessionId)))
.returning({ id: commandHqSessions.id });
return rows.length > 0;
}
/** Move a commander to a category, or `null` to uncategorise it explicitly (distinct from never having had one). */
export async function setCommandHqSessionCategory(
userId: number,
@@ -1,4 +1,4 @@
import { integer, pgTable, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { boolean, integer, pgTable, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { users } from '../auth/schema';
/**
@@ -12,11 +12,26 @@ import { users } from '../auth/schema';
* ONLY place a commander's name lives. A `name` column here would be a second place the same fact could be
* written, and the day it disagreed with the transcript there would be no way to say which was right.
*
* No `status` column. Whether a commander is running is derived the same way the background-job tray already
* derives it — folding `chat_session_events` for the session (`task:started` minus `task:notification`,
* `collectRunningTasks` in `api/chat/websocket.ts`). A stored status would be a second source of truth that
* goes stale the moment the harness sidecar restarts without emitting a final `task:notification`, and
* nothing would ever correct it.
* Still no `status` column. Whether a commander is CURRENTLY RUNNING is derived the same way the
* background-job tray already derives it — folding `chat_session_events` (`task:started` minus
* `task:notification`, `collectRunningTasks` in `api/chat/websocket.ts`). A stored status would be a
* second source of truth that goes stale the moment the harness sidecar restarts without emitting a final
* `task:notification`, and nothing would ever correct it.
*
* ── `armed`, below, is a different kind of fact and is allowed to be stored ──
*
* `watcherArmed` does not answer "is a process alive right now" — it answers "should the platform keep
* re-arming this watcher on every fire", which is an INTENT the owner set, not an external process state
* the platform is merely reporting. Nothing outside this table can silently flip it the way a sidecar
* restart silently ends a process, so it does not have the staleness problem `status` was rejected for.
* See `COMMS/command-hq/watcher-loop-proposal.md` and `docs/command-hq.md` → "The watcher loop".
*
* `watcherTaskId` is closer to the rejected shape — it names a specific running (or just-fired) task, and
* IS a live-process fact. Kept anyway, deliberately, because there is no way to re-arm without knowing
* which task just fired: it is the join key between "a background job ended" and "which commander's
* watcher that was", and the alternative (scanning every commander's transcript on every task:notification
* to guess) is worse than accepting that a sidecar restart can strand it — which is the SAME documented gap
* `running` already lives with, not a new one. See "Later" in `docs/command-hq.md`.
*/
/**
@@ -63,6 +78,18 @@ export const commandHqSessions = pgTable(
sessionId: text('session_id').notNull(),
/** Nullable — a commander may be uncategorised. `onDelete: 'set null'`: deleting a category uncategorises its commanders rather than deleting them. */
categoryId: integer('category_id').references(() => commandHqCategories.id, { onDelete: 'set null' }),
/** Should the platform keep re-arming this commander's watcher on every fire? See the file docstring. */
watcherArmed: boolean('watcher_armed').notNull().default(false),
/**
* The task id of the watcher currently believed live for this commander — set when armed (first arm,
* and again on every automatic re-arm), read to correlate a `task:notification` back to "this is the
* one this commander is waiting on" and to recover its original command via `commandForTask` when
* re-arming. Null whenever `watcherArmed` is false; not cleared BETWEEN a fire and its re-arm, which is
* exactly the window a card would show as "armed, acting on what just fired" rather than "watching".
*/
watcherTaskId: text('watcher_task_id'),
/** When the watcher last fired. Display only — "Armed, last fired 3h ago". Never read for logic. */
watcherLastFiredAt: timestamp('watcher_last_fired_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [uniqueIndex('uq_command_hq_sessions_user_session').on(table.userId, table.sessionId)],