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:
@@ -76,6 +76,13 @@ export type ClientMessage =
|
||||
* first turn is the first moment the preference can be acted on OR written down.
|
||||
*/
|
||||
planMode?: boolean;
|
||||
/**
|
||||
* This turn is arming a Command HQ watcher — see `docs/command-hq.md` → "The watcher loop". Set only
|
||||
* by `BackgroundJobDialog` submitting a `handoff` job inside a commander (`isCommander` true); every
|
||||
* other caller omits it. The server correlates it to the NEXT `task:started` this turn produces, so
|
||||
* it must ride the exact message that starts the watching turn.
|
||||
*/
|
||||
armWatcher?: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'resume';
|
||||
|
||||
@@ -601,6 +601,7 @@ async function handleChat(
|
||||
contextId?: string;
|
||||
resumeSummary?: string;
|
||||
resumeSessionId?: string;
|
||||
armWatcher?: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
const { userId } = ws.data;
|
||||
@@ -723,6 +724,7 @@ export async function startClaudeTurn(params: {
|
||||
images?: PromptImage[];
|
||||
resumeSessionId?: string;
|
||||
planMode?: boolean;
|
||||
armWatcher?: boolean;
|
||||
};
|
||||
effectivePrompt: string;
|
||||
onOutcome?: (outcome: ClaudeTurnOutcome) => void;
|
||||
@@ -893,6 +895,7 @@ async function handleClaudeCodeChat(
|
||||
images?: PromptImage[];
|
||||
resumeSessionId?: string;
|
||||
planMode?: boolean;
|
||||
armWatcher?: boolean;
|
||||
},
|
||||
effectivePrompt: string,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
getCommandHqSession,
|
||||
setCommandHqSessionCategory,
|
||||
purgeCommandHqSessions,
|
||||
disarmCommandHqWatcher,
|
||||
listCommandHqSessionTags,
|
||||
listCommandHqTags,
|
||||
createCommandHqTag,
|
||||
@@ -147,6 +148,14 @@ type CommanderSummary = ClaudeSessionSummary & {
|
||||
* `docs/command-hq.md` for why a status column would go stale exactly when this feature matters most.
|
||||
*/
|
||||
running: boolean;
|
||||
/**
|
||||
* Whether the platform is keeping this commander's watcher re-armed — see `docs/command-hq.md` → "The
|
||||
* watcher loop". Unlike `running`, this IS stored: it is the owner's own intent, not a live-process fact
|
||||
* the platform is reporting on. `watcherTaskId` itself is not exposed — an internal correlation id with
|
||||
* no display meaning, the join key `armCommandHqWatcher` uses and nothing a card needs to show.
|
||||
*/
|
||||
watcherArmed: boolean;
|
||||
watcherLastFiredAt: Date | null;
|
||||
};
|
||||
|
||||
async function isRunning(sessionId: string): Promise<boolean> {
|
||||
@@ -189,6 +198,8 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
categoryId: row.categoryId,
|
||||
tagIds: tagsByRow.get(row.id) ?? [],
|
||||
running: runningById.get(row.sessionId) ?? false,
|
||||
watcherArmed: row.watcherArmed,
|
||||
watcherLastFiredAt: row.watcherLastFiredAt,
|
||||
}))
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
|
||||
@@ -299,6 +310,8 @@ commandHqRouter.get('/commanders/:sessionId', async (ctx) => {
|
||||
categoryId: row.categoryId,
|
||||
tagIds: tagsByRow.get(row.id) ?? [],
|
||||
running,
|
||||
watcherArmed: row.watcherArmed,
|
||||
watcherLastFiredAt: row.watcherLastFiredAt,
|
||||
createdAt: row.createdAt,
|
||||
});
|
||||
});
|
||||
@@ -337,18 +350,22 @@ commandHqRouter.post('/commanders', async (ctx) => {
|
||||
/**
|
||||
* PATCH /command-hq/commanders/:sessionId
|
||||
*
|
||||
* Body may carry either or both:
|
||||
* Body may carry any of:
|
||||
* `categoryId: number | null` — move to a category, or `null` to uncategorise
|
||||
* `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.
|
||||
*/
|
||||
commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await commandHqIdentity(user);
|
||||
const sessionId = ctx.req.param('sessionId');
|
||||
const body = await ctx.req
|
||||
.json<{ categoryId?: number | null; name?: string }>()
|
||||
.catch(() => ({}) as { categoryId?: number | null; name?: string });
|
||||
.json<{ categoryId?: number | null; name?: string; watcherArmed?: false }>()
|
||||
.catch(() => ({}) as { categoryId?: number | null; name?: string; watcherArmed?: false });
|
||||
|
||||
const row = await getCommandHqSession(user.id, sessionId);
|
||||
if (!row) throw errors.NOT_FOUND('not a commander');
|
||||
@@ -360,6 +377,9 @@ commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
|
||||
const ok = renameClaudeSession(who, getCommandersDir(who.email), sessionId, body.name.trim());
|
||||
if (!ok) throw errors.NOT_FOUND('transcript not found');
|
||||
}
|
||||
if (body.watcherArmed === false) {
|
||||
await disarmCommandHqWatcher(user.id, sessionId);
|
||||
}
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user