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:
@@ -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)],
|
||||
|
||||
Reference in New Issue
Block a user