a dead watch wakes its commander instead of staying dead

the machine rebooted 2026-08-24 at 12:40 and took the deploy commander's loop
with it. a release was tagged the next day and sat there. the only evidence
was a word missing from a card.

the obvious fix is for the platform to respawn the loop, and docs/command-hq.md
settled on 2026-08-18 that it must not: the agent is only notified about a
process it started and is tracking, so a platform-spawned watch would run
correctly, fire, and reach nobody. that trades a visible failure for an
invisible one, which is worse — not watching is at least legible.

so the platform notices and hands the turn back. the agent re-arms itself, the
way it already does after every fire, and the process it starts is tracked.
the owner's two conditions hold by construction: the commander is still the
only thing that starts a watch, and the indicator still reads the pid file.

DETECTION is the pid file, not watcher_armed. that column is a claim only one
path sets and most commanders never take it — both commanders here read false
while one was demonstrably watching. a pid file that exists with nothing alive
behind it means a watch that was running and is not.

which made stop ambiguous, so stopCommanderProcesses now removes the file.
without that, a deliberate stop looks exactly like a crash and the reviver
would ask the commander to re-arm what the owner just switched off.

the commander now keeps WATCHERS.md — what each watch is, the exact command,
the interval, what it does when it fires — because when the wake arrives the
transcript is long past the turn that armed it and the platform deliberately
does not remember the command. a commander predating the file may rebuild from
JOB.md, a script in its directory, or the turn that armed it; inventing one it
cannot find is forbidden, since a wrong watch looks like a working one.

the owner is notified separately from the commander being woken. they are
different audiences and only one of them can act when the wake does not work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-25 16:27:39 +00:00
co-authored by Claude Opus 5
parent 16da10150b
commit e1f5d01b90
6 changed files with 280 additions and 0 deletions
@@ -9,6 +9,7 @@ export {
listCommandHqSessions,
listCommandHqSessionIds,
listCommandHqOwnedSessionIds,
listAllCommanders,
getCommandHqSession,
setCommandHqSessionCategory,
deleteCommandHqSession,
@@ -213,6 +213,28 @@ export async function listCommandHqOwnedSessionIds(userId: number): Promise<Set<
// drizzle's `eq` cannot express `IS NOT NULL` either.
const isNotNullSessionId = () => sql`${commandHqSessions.sessionId} is not null`;
/**
* Every commander on the machine, across every account — id, owner and directory only.
*
* The one query here that is not scoped to a user, and it exists for a caller that is not a request:
* `servers/api/command-hq/watcher-revive.ts` ticks over all of them looking for a watch whose process
* has gone. There is no `userId` to scope by at that point, and the reviver needs the owner's id in
* order to resolve their email and their home — so it is returned rather than looked up per row.
*
* Narrow on purpose. A background sweep has no business reading names, categories or cost, and the next
* caller that wants those should say so by widening deliberately.
*/
export async function listAllCommanders(): Promise<{ userId: number; sessionId: string; dirName: string }[]> {
const rows = await db
.select({
userId: commandHqSessions.userId,
sessionId: commandHqSessions.sessionId,
dirName: commandHqSessions.dirName,
})
.from(commandHqSessions);
return rows.filter((row): row is { userId: number; sessionId: string; dirName: string } => row.sessionId !== null);
}
export async function getCommandHqSession(userId: number, sessionId: string): Promise<CommandHqSessionRow | null> {
const [row] = await db
.select(SESSION_ROW_COLUMNS)