watcher loop: the agent re-arms itself, same turn — final mechanism, confirmed against real usage
second correction to the re-arm design tonight, and this one came from the owner, not from me. my plan (platform sends a synthetic follow-up turn once the acting turn's result lands, instructing re-arm) was technically sound — it keeps the agent inside the sdk's own Monitor tracking, which is the thing the FIRST wrong plan (silent respawn) broke — but it costs a real extra turn every cycle. pushed back on with hard evidence: the mobile dev's own watcher, on this repo, this session, had been running with THEM re-arming themselves, same turn, for hours already — zero failures. that beats a reliability concern I raised without having tested it. so: handoffContract now instructs the agent to re-arm itself, as the last action of the SAME turn that handles the notification — no synthetic turn, no platform spawn, no extra cost. exitContract's handoff branch corrected to match (was still saying "the harness re-arms a firing on its own", exactly backwards now). server side simplifies to match: createMessageHandler's task:started hook now handles both cases with one rule — pendingArmWatcher (session-object flag, not closure-captured, since arming happens turns after the closure was built) claims the FIRST task:started as the watcher; every SUBSEQUENT task:started while already armed is read as the agent's own self-re-arm and just updates watcherTaskId to match. task:notification records the fire when it matches the tracked id. the platform never drives anything — it only notices what the agent already did. pulled COMMS/command-hq/mobile-3.md, which answered the PREVIOUS (synthetic turn) version — crossed in transit. their transcript-bloat concern is moot now (no synthetic turn exists to bloat anything). their `watcherArmed && !running` detectable-failure pair stands regardless of which mechanism won and is exactly right — kept as-is, matches their own three-state dot (running/armed-but-not/neither). replied in platform.md. all four proposal items now built: prompt+dialog, stopped-vs-died, the re-arm mechanism (this commit), arming state+plumbing. bunx tsgo clean, suite unchanged (1038 pass, same 7 pre-existing baseline failures). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -98,3 +98,66 @@ session rather than starting it off the strength of a COMMS note alone. If it's
|
|||||||
seam is real: `running` is already derived on the commander row, and `armed → fired → acting → re-armed`
|
seam is real: `running` is already derived on the commander row, and `armed → fired → acting → re-armed`
|
||||||
is the same shape of fact, read the same way — from `chat_session_events`, not a column that can go stale
|
is the same shape of fact, read the same way — from `chat_session_events`, not a column that can go stale
|
||||||
on its own.
|
on its own.
|
||||||
|
|
||||||
|
## Update — confirmed, built, and the mechanism changed once more before it landed
|
||||||
|
|
||||||
|
Confirmed with the owner. Built all four items from the proposal's own order. Worth relaying exactly what
|
||||||
|
shipped, because the re-arm mechanism went through a second correction after the first (the
|
||||||
|
`restartBackgroundTask` dead end noted above) — this one caught by the owner, not by me, and it is the
|
||||||
|
more important of the two.
|
||||||
|
|
||||||
|
My second plan was a platform-driven synthetic follow-up turn: wait for the acting turn's `result`, then
|
||||||
|
send a new message instructing re-arm. Technically sound — it keeps the agent inside the SDK's own
|
||||||
|
`Monitor` tracking, which is the thing the FIRST wrong plan broke — but it costs a real extra turn every
|
||||||
|
cycle, and the owner pushed back on exactly that with something I could not argue with: **your own
|
||||||
|
watcher, on this repo, this session, had been running with YOU re-arming yourself, same turn, for hours by
|
||||||
|
that point — zero failures.** That is hard evidence beating a hypothetical reliability concern I raised
|
||||||
|
without having tested it.
|
||||||
|
|
||||||
|
So what is actually built is closer to what you were already doing: the agent re-arms itself, as the last
|
||||||
|
step of the same turn that handles the notification — no separate turn, no platform spawn. `handoffContract`
|
||||||
|
now says exactly that: _"Do the work — and before you end this turn, start the SAME watch again yourself,
|
||||||
|
exactly as you did the first time."_ The platform's role shrank to matching reality — it does not drive
|
||||||
|
the re-arm, it only NOTICES: `task:started` while a commander is already armed is read as the self-re-arm
|
||||||
|
your pattern produces, and the tracked `watcherTaskId` updates to match, ready for the next fire.
|
||||||
|
|
||||||
|
One thing worth flagging for your app specifically, since I don't know what mobile's own dialog currently
|
||||||
|
offers: if `apps/commandhq` has its own background-job composer rather than reusing
|
||||||
|
`BackgroundJobDialog.tsx`, the SAME correction applies there — the preamble text your side sends for a
|
||||||
|
`handoff` job needs to say self-re-arm, same turn, not "the harness re-arms you" (which was my error, now
|
||||||
|
fixed on the web side, and never something you should copy if you were building against my first
|
||||||
|
description of it).
|
||||||
|
|
||||||
|
Also built, same pass: `command_hq_sessions.watcherArmed` / `watcherTaskId` / `watcherLastFiredAt`, exposed
|
||||||
|
on `GET /commanders` and `GET /commanders/:sessionId`, and `PATCH {watcherArmed: false}` to disarm. A card
|
||||||
|
can show "armed, last fired 3h ago" whenever either of us gets to building one. Full detail, including the
|
||||||
|
two rejected designs recorded rather than erased: `docs/command-hq.md` → "The watcher loop".
|
||||||
|
|
||||||
|
## Reply to `mobile-3.md`
|
||||||
|
|
||||||
|
Crossed in transit — you answered the synthetic-turn version, which is already the one that got replaced
|
||||||
|
above. Both your points, in light of that:
|
||||||
|
|
||||||
|
**§1, transcript bloat from ~700 re-arm turns over six months — moot, and worth saying why rather than
|
||||||
|
just dropping it.** There is no synthetic turn any more. The re-arm is the SAME `run_in_background` call
|
||||||
|
inside the SAME turn that already handles the notification — one Bash tool_use, indistinguishable from any
|
||||||
|
other tool call the agent makes, not a new user-role message. Nothing accumulates that was not already
|
||||||
|
going to be there. Good catch on a real cost of the design it was aimed at, and it stopped applying for an
|
||||||
|
unrelated reason (cost, not correctness) before you'd have needed to build the marking you suggested.
|
||||||
|
|
||||||
|
**§2, `watcherArmed && !running` as the detectable-failure pair — keeping this exactly as proposed, it
|
||||||
|
does not depend on which re-arm mechanism won.** Confirmed it is genuinely new information, not
|
||||||
|
reconstructable another way: `running` alone cannot distinguish "never meant to be watching" from "was
|
||||||
|
watching and stopped", and `watcherArmed` alone cannot distinguish "currently firing/acting" (armed, briefly
|
||||||
|
not running, entirely normal) from "silently dead". Only the PAIR says the thing worth knowing. Your
|
||||||
|
three-state dot is the right read of it and I'd rather match that shape than invent a second one — green
|
||||||
|
running, amber armed-but-not, grey neither.
|
||||||
|
|
||||||
|
Your suggestion to use it as a RETRY trigger, not just a display state, is the natural next step once
|
||||||
|
something is watching the pair server-side rather than only a card rendering it on open. Not built tonight
|
||||||
|
— the platform is deliberately passive right now (notices, does not act) — but it is exactly the seam
|
||||||
|
where "notice" could become "notice and retry" later without changing the fields, which is worth having
|
||||||
|
picked the right two columns for now.
|
||||||
|
|
||||||
|
Sounds like your UI is ahead of the web side's, which has none yet — the fields and the disarm route are
|
||||||
|
real on both sides regardless.
|
||||||
|
|||||||
+52
-38
@@ -311,14 +311,33 @@ process reaches the model only when it EXITS. The owner's call, confirmed 2026-0
|
|||||||
proposal as written, before the first real tests** — this governs the agent's behaviour generally, not
|
proposal as written, before the first real tests** — this governs the agent's behaviour generally, not
|
||||||
only Command HQ, so it is not optional groundwork the way most of this document is.
|
only Command HQ, so it is not optional groundwork the way most of this document is.
|
||||||
|
|
||||||
|
**The re-arm mechanism, settled 2026-08-18, after two false starts recorded here rather than quietly
|
||||||
|
fixed:**
|
||||||
|
|
||||||
|
1. First instinct: the PLATFORM silently respawns the watch command when it fires, no agent turn involved.
|
||||||
|
Wrong — the SDK only ever notifies the agent about a process it is itself tracking via `Monitor`. A
|
||||||
|
platform-side respawn exists outside that tracking, so it would fire once, work, and then go dark
|
||||||
|
forever — the agent is never told about the second fire. Not a cost tradeoff, a dead end.
|
||||||
|
2. Second instinct: keep the agent in the loop, but have the PLATFORM drive it — detect the acting turn's
|
||||||
|
`result` and send a synthetic follow-up message telling the agent to re-arm. Technically correct, but
|
||||||
|
costs a real extra turn every single cycle, and the owner pushed back on that cost before it was built:
|
||||||
|
an actual multi-hour, real-world watcher (the mobile side's own, on this repo, this session) had
|
||||||
|
already been running with the agent re-arming ITSELF, same turn, for hours — zero failures. That is
|
||||||
|
hard evidence to override with a hypothetical reliability argument.
|
||||||
|
3. **What is actually built:** the agent re-arms itself, as the LAST action of the SAME turn that handles
|
||||||
|
the notification — no separate turn, no platform-side spawn. The platform's role is narrower than
|
||||||
|
either false start: it only WATCHES for this happening and can say so if it doesn't (see "Built" below)
|
||||||
|
— it does not drive the re-arm and does not respawn anything itself.
|
||||||
|
|
||||||
**Built:**
|
**Built:**
|
||||||
|
|
||||||
- **A third dialog option**, `FireBehavior = 'stop' | 'handoff' | 'keep-going'` (was a `stopOnFire`
|
- **A third dialog option**, `FireBehavior = 'stop' | 'handoff' | 'keep-going'` (was a `stopOnFire`
|
||||||
boolean). `handoff` — "Exit and hand back to the agent, then re-arm" — carries the proposal's one-sentence
|
boolean). `handoff` — "Exit and hand back to the agent, then re-arm" — carries the contract
|
||||||
contract (`handoffContract` in `background-job-prompt.ts`) verbatim: _"When the condition fires you will
|
(`handoffContract` in `background-job-prompt.ts`) verbatim: _"When the condition fires you will be handed
|
||||||
be handed back, with the change. Do the work. The harness re-arms you — do not re-arm yourself, and do
|
back, with the change. Do the work — and before you end this turn, start the SAME watch again yourself,
|
||||||
not build your own polling loop on top of this one."_ The closing "IF IT EXITS" section is rewritten for
|
exactly as you did the first time: same condition, same command. That is the whole loop, every time: act,
|
||||||
`handoff` too, so it does not contradict rule 7 by telling the model an exit means stay-stopped.
|
then re-arm, in the one turn."_ The closing "IF IT EXITS" section is rewritten for `handoff` too, so it
|
||||||
|
does not contradict rule 7 by telling the model an exit means stay-stopped.
|
||||||
- **`isCommander`, gating the promise to where it can be kept.** The proposal frames this entirely in terms
|
- **`isCommander`, gating the promise to where it can be kept.** The proposal frames this entirely in terms
|
||||||
of commanders — "a commander already has a row," arming state's only home — and outside one there is
|
of commanders — "a commander already has a row," arming state's only home — and outside one there is
|
||||||
nowhere to persist "this should keep re-arming itself," so nothing would fulfil the promise. Rather than
|
nowhere to persist "this should keep re-arming itself," so nothing would fulfil the promise. Rather than
|
||||||
@@ -335,8 +354,6 @@ only Command HQ, so it is not optional groundwork the way most of this document
|
|||||||
Known gap: this is in-memory client state, so it does not survive a page refresh between clicking Stop
|
Known gap: this is in-memory client state, so it does not survive a page refresh between clicking Stop
|
||||||
and the notification arriving — an acceptable, narrow window, not attempted to be closed tonight.
|
and the notification arriving — an acceptable, narrow window, not attempted to be closed tonight.
|
||||||
|
|
||||||
**Built, item 4 (ahead of item 3 — plumbing needed to exist before the trigger could be tested):**
|
|
||||||
|
|
||||||
- **`command_hq_sessions` gained `watcherArmed`, `watcherTaskId`, `watcherLastFiredAt`.** Unlike `running`,
|
- **`command_hq_sessions` gained `watcherArmed`, `watcherTaskId`, `watcherLastFiredAt`.** Unlike `running`,
|
||||||
`watcherArmed` is allowed to be stored — it is the owner's INTENT ("keep re-arming this"), not a live
|
`watcherArmed` is allowed to be stored — it is the owner's INTENT ("keep re-arming this"), not a live
|
||||||
process fact the platform is reporting on, so nothing external can silently falsify it the way a sidecar
|
process fact the platform is reporting on, so nothing external can silently falsify it the way a sidecar
|
||||||
@@ -346,39 +363,36 @@ only Command HQ, so it is not optional groundwork the way most of this document
|
|||||||
read-only on `GET /commanders` and `GET /commanders/:sessionId`, and `PATCH /commanders/:sessionId` accepts
|
read-only on `GET /commanders` and `GET /commanders/:sessionId`, and `PATCH /commanders/:sessionId` accepts
|
||||||
`{watcherArmed: false}` to disarm (arming itself has no route — it needs a live task id, which only a
|
`{watcherArmed: false}` to disarm (arming itself has no route — it needs a live task id, which only a
|
||||||
running turn has).
|
running turn has).
|
||||||
- **`armWatcher` threaded end-to-end, client to server, and CONSUMED NOWHERE YET.** `BackgroundJobDialog`
|
- **`armWatcher` threaded end-to-end, client to server.** `BackgroundJobDialog` computes it (`watching &&
|
||||||
computes it (`watching && onFire === 'handoff'`) → `sendWithHiddenPrefix` → `QueuedPrompt.armWatcher` →
|
onFire === 'handoff'`) → `sendWithHiddenPrefix` → `QueuedPrompt.armWatcher` → `sendPrompt`'s new last
|
||||||
`sendPrompt`'s new last parameter → the chat websocket message → `ClientMessage`'s `'chat'` variant →
|
parameter → the chat websocket message → `ClientMessage`'s `'chat'` variant → `startClaudeTurn`'s
|
||||||
`startClaudeTurn`'s `msg.armWatcher`. Every step type-checks and the full suite is unaffected, because
|
`msg.armWatcher`, which sets `session.pendingArmWatcher = true` — on the SESSION object, not captured by
|
||||||
nothing reads the field on the server yet — safely inert plumbing, not a shipped mechanism.
|
`createMessageHandler`'s closure, because the dialog is routinely submitted turns after that closure was
|
||||||
|
built (the session-scoped event subscription wires ONCE, on the session's first-ever turn) and a value
|
||||||
|
captured then would never see a later arm.
|
||||||
|
- **The noticing, in `createMessageHandler` (`websocket.ts`).** On `task:started`: if `pendingArmWatcher`
|
||||||
|
is set, consume it (one-shot, the same arm/claim shape `background-job-origin.ts` already uses
|
||||||
|
client-side for the identical problem) and call `armCommandHqWatcher` — this is what turns "some task
|
||||||
|
started" into "this is the watcher" in the first place. If the commander is ALREADY armed instead, a new
|
||||||
|
task starting is what the agent's own self-re-arm produces, so the tracked `watcherTaskId` is simply
|
||||||
|
updated to it — the platform is not driving anything here, only keeping up with what the agent already
|
||||||
|
did. On `task:notification`: if it matches the tracked `watcherTaskId` for an armed commander, record the
|
||||||
|
fire (`watcherLastFiredAt`). Both read `getCommandHqSession` live rather than trusting any closure-local
|
||||||
|
state, for the same reason as above.
|
||||||
|
- **Known, accepted imprecision:** the "already armed → treat the next task:started as the re-arm" rule
|
||||||
|
cannot tell a genuine self-re-arm apart from an unrelated background task the agent happens to start in
|
||||||
|
the same commander conversation. A commander is one watcher by design, so this is a narrow gap rather
|
||||||
|
than the common case, and not attempted to be closed tonight.
|
||||||
|
|
||||||
**Not yet built — item 3, and a real design question found while trying to build it:**
|
Suggested order from the proposal, all four items now done: (1) prompt + dialog, (2) stopped-vs-died, (3)
|
||||||
|
the re-arm mechanism itself — landed as agent-driven, same-turn, after two rejected designs recorded above
|
||||||
|
— (4) arming state and the plumbing that watches for it.
|
||||||
|
|
||||||
The original plan here was wrong and is worth recording as wrong rather than quietly fixed: `task-restart.ts`'s
|
**Deliberately not built: the platform verifying re-arm happened.** Raised as a possible cheap addition —
|
||||||
`restartBackgroundTask` looked like the obvious reuse — it already recovers a command from a transcript and
|
since the platform already knows `watcherLastFiredAt` and can already compute `running` from the same fold
|
||||||
respawns it detached. **It is not reusable for this.** A restarted job is deliberately outside the SDK's
|
`GET /commanders` uses for every card, a client could flag "fired N ago, nothing has run since" without
|
||||||
own `Monitor` tracking — `chat.ts`'s restart route emits its own `task:notification` via `emitToSession`
|
any new mechanism, purely by reading what already exists. Not built tonight; noted here so it is not lost,
|
||||||
specifically because the harness stopped watching. That path updates the UI; it does not reach back into
|
not because it was rejected.
|
||||||
the agent's own context, because there is no mechanism by which it could. A `handoff` watcher's whole
|
|
||||||
point is 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 tracking.
|
|
||||||
|
|
||||||
So harness-owned re-arm cannot be a silent platform-side respawn. It has to drive the AGENT to reissue its
|
|
||||||
own `run_in_background`/`Monitor` call — the platform's job is to make that reissue AUTOMATIC and
|
|
||||||
CORRECT (the right command, the right moment, no double-arming) rather than something the model has to
|
|
||||||
remember across turns, not to do the reissuing itself. Concretely, likely shape: once a fired watcher's
|
|
||||||
"acting" turn ends (`result`, for a commander whose `watcherTaskId` just produced a `task:notification`),
|
|
||||||
the platform sends a SYNTHETIC follow-up turn — not from the user — instructing the agent to re-arm using
|
|
||||||
the exact command `commandForTask` recovers from the original transcript. `commandForTask` is still the
|
|
||||||
right reuse; `restartBackgroundTask` is not. Not implemented — raised with the owner before writing it, for
|
|
||||||
the second time tonight, because getting the mechanism itself wrong is more expensive to discover after
|
|
||||||
the fact than before.
|
|
||||||
|
|
||||||
Suggested order from the proposal: (1) prompt + dialog — done, (2) stopped-vs-died — done, (4) arming
|
|
||||||
state and plumbing — done ahead of schedule, (3) the actual re-arm trigger — open, blocked on confirming
|
|
||||||
the synthetic-turn design above. "Nothing here blocks the first live test of the apps; all of it blocks
|
|
||||||
trusting a watcher that is meant to run for months" — the proposal's own words, and the reason this is
|
|
||||||
still open rather than rushed.
|
|
||||||
|
|
||||||
## Later — explicitly deferred, not forgotten
|
## Later — explicitly deferred, not forgotten
|
||||||
|
|
||||||
|
|||||||
@@ -133,9 +133,11 @@ export async function getCommandHqSession(userId: number, sessionId: string): Pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Arm (or re-arm) a commander's watcher — called both the first time a `handoff` job starts and every
|
* Arm (or re-arm) a commander's watcher — called on the first `task:started` after the dialog submits a
|
||||||
* time the harness re-arms it after acting on a fire. Idempotent in effect: arming with the same task id
|
* `handoff` job, and again on every SUBSEQUENT `task:started` while already armed, which is how the
|
||||||
* twice just writes the same values again.
|
* AGENT's own self-re-arm (same turn, per `handoffContract`) is picked up: the platform does not drive
|
||||||
|
* the re-arm, it only notices the new task id the agent's own next watch produced. 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> {
|
export async function armCommandHqWatcher(userId: number, sessionId: string, taskId: string): Promise<boolean> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
|
|||||||
@@ -421,6 +421,15 @@ export type UserSession = {
|
|||||||
meta: SessionMeta;
|
meta: SessionMeta;
|
||||||
_sidecarUnsub?: () => void;
|
_sidecarUnsub?: () => void;
|
||||||
_claudeKill?: () => void;
|
_claudeKill?: () => void;
|
||||||
|
/**
|
||||||
|
* A `handoff` watcher was just requested and has not yet been claimed by a `task:started`. See
|
||||||
|
* `docs/command-hq.md` → "The watcher loop". Lives on the session object rather than being captured by
|
||||||
|
* `createMessageHandler`'s closure, because arming routinely happens turns after that closure was first
|
||||||
|
* built — the session-scoped event subscription is wired ONCE, on the session's first-ever turn
|
||||||
|
* (`websocket.ts`, the `!session._claudeKill` branch), so a closure built on a LATER turn is never
|
||||||
|
* actually the one receiving events. The session object is the one thing every turn shares.
|
||||||
|
*/
|
||||||
|
pendingArmWatcher?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ModelInfo = {
|
export type ModelInfo = {
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ import {
|
|||||||
setChatSessionModel,
|
setChatSessionModel,
|
||||||
setChatSessionPlanMode,
|
setChatSessionPlanMode,
|
||||||
createCommandHqSession,
|
createCommandHqSession,
|
||||||
|
getCommandHqSession,
|
||||||
|
armCommandHqWatcher,
|
||||||
|
recordCommandHqWatcherFired,
|
||||||
} from 'officerdb';
|
} from 'officerdb';
|
||||||
import { resolveHomeDir } from '@@/user-home';
|
import { resolveHomeDir } from '@@/user-home';
|
||||||
import { claudeLoginState } from '@@/os-user-claude';
|
import { claudeLoginState } from '@@/os-user-claude';
|
||||||
@@ -513,6 +516,74 @@ function createMessageHandler(
|
|||||||
fileAsCommander();
|
fileAsCommander();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The watcher loop — see `docs/command-hq.md` → "The watcher loop" and `handoffContract` in
|
||||||
|
`background-job-prompt.ts`. The platform does not DRIVE re-arming; the agent does, itself, same turn,
|
||||||
|
per that prompt. All the platform does is NOTICE: which task is the current watcher, and when it
|
||||||
|
fired. Both read `session` live (never a value captured when this closure was built) because arming
|
||||||
|
routinely happens turns after the closure was created — the dialog is submitted well into an
|
||||||
|
already-open commander conversation, not necessarily on its first message.
|
||||||
|
*/
|
||||||
|
if (msg.type === 'task:started' && commandHq && transcriptId) {
|
||||||
|
const userId = session.userId;
|
||||||
|
if (userId) {
|
||||||
|
// Consumed here, one-shot — the SAME arm/claim shape `background-job-origin.ts` already uses
|
||||||
|
// client-side for the identical problem (which task is "the one", among however many a turn
|
||||||
|
// might start). Only the FIRST arm needs this: it is what turns "some task started" into "this is
|
||||||
|
// the watcher" in the first place. Every later re-arm is recognised the fallback way, below,
|
||||||
|
// because by then the row already says armed and there is nothing left to claim.
|
||||||
|
if (session.pendingArmWatcher) {
|
||||||
|
session.pendingArmWatcher = false;
|
||||||
|
const taskId = msg.taskId;
|
||||||
|
void armCommandHqWatcher(userId, transcriptId, taskId).catch((err: unknown) => {
|
||||||
|
logger.warn('Could not arm commander watcher', { sessionId, transcriptId, error: String(err) });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Not a fresh arm — but if this commander is ALREADY armed, a new task starting is exactly what
|
||||||
|
// the agent's own self-re-arm produces (`handoffContract`: same turn, same command, new task id).
|
||||||
|
// Update the tracked id so the NEXT fire is still recognised as this commander's watcher.
|
||||||
|
//
|
||||||
|
// Known imprecision, accepted rather than solved tonight: this cannot tell a genuine re-arm apart
|
||||||
|
// from an unrelated background task the agent happens to start in the same commander conversation
|
||||||
|
// — a commander is one watcher by design, so this is a real but narrow gap, not the common case.
|
||||||
|
const capturedTranscriptId = transcriptId;
|
||||||
|
const taskId = msg.taskId;
|
||||||
|
void getCommandHqSession(userId, capturedTranscriptId)
|
||||||
|
.then((row) => {
|
||||||
|
if (row?.watcherArmed) return armCommandHqWatcher(userId, capturedTranscriptId, taskId);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
logger.warn('Could not check commander watcher state', {
|
||||||
|
sessionId,
|
||||||
|
transcriptId: capturedTranscriptId,
|
||||||
|
error: String(err),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'task:notification' && commandHq && transcriptId) {
|
||||||
|
const userId = session.userId;
|
||||||
|
if (userId) {
|
||||||
|
const capturedTranscriptId = transcriptId;
|
||||||
|
const taskId = msg.taskId;
|
||||||
|
void getCommandHqSession(userId, capturedTranscriptId)
|
||||||
|
.then((row) => {
|
||||||
|
if (row?.watcherArmed && row.watcherTaskId === taskId) {
|
||||||
|
return recordCommandHqWatcherFired(userId, capturedTranscriptId);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
logger.warn('Could not record commander watcher fire', {
|
||||||
|
sessionId,
|
||||||
|
transcriptId: capturedTranscriptId,
|
||||||
|
error: String(err),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
A turn ended — take a usage snapshot while the person is reading the answer.
|
A turn ended — take a usage snapshot while the person is reading the answer.
|
||||||
|
|
||||||
@@ -768,6 +839,9 @@ export async function startClaudeTurn(params: {
|
|||||||
|
|
||||||
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug, msg.context, msg.contextId);
|
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug, msg.context, msg.contextId);
|
||||||
session.userId = userId;
|
session.userId = userId;
|
||||||
|
// Set on the SESSION, not captured by this turn's `createMessageHandler` closure below — see
|
||||||
|
// `pendingArmWatcher`'s own docstring for why that distinction matters here specifically.
|
||||||
|
if (msg.armWatcher) session.pendingArmWatcher = true;
|
||||||
// No live socket to attach for an HTTP-originated turn — the caller attaches later, by transcript id,
|
// No live socket to attach for an HTTP-originated turn — the caller attaches later, by transcript id,
|
||||||
// through the ordinary `/chat` websocket `attach` path, exactly as a reconnect already does.
|
// through the ordinary `/chat` websocket `attach` path, exactly as a reconnect already does.
|
||||||
if (ws) {
|
if (ws) {
|
||||||
|
|||||||
@@ -11,21 +11,21 @@ const base: BackgroundJobSpec = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('buildBackgroundJobPrompt — fire behavior', () => {
|
describe('buildBackgroundJobPrompt — fire behavior', () => {
|
||||||
test('handoff exits AND carries the harness-owns-re-arm contract verbatim', () => {
|
test('handoff exits AND carries the self-re-arm contract verbatim', () => {
|
||||||
const prompt = buildBackgroundJobPrompt(base);
|
const prompt = buildBackgroundJobPrompt(base);
|
||||||
expect(prompt).toContain('7. EXIT when it fires');
|
expect(prompt).toContain('7. EXIT when it fires');
|
||||||
expect(prompt).toContain(handoffContract);
|
expect(prompt).toContain(handoffContract);
|
||||||
// The whole point: the model must not be told to re-arm itself.
|
// The fix this exists for: re-arming is immediate, same turn — not deferred to be remembered later.
|
||||||
expect(prompt).not.toContain('Re-arm only if I ask');
|
expect(prompt).not.toContain('Re-arm only if I ask');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("handoff's closing contract does not contradict rule 7 by telling the model to stay stopped", () => {
|
test("handoff's closing contract does not contradict rule 7 by telling the model to stay stopped", () => {
|
||||||
const prompt = buildBackgroundJobPrompt(base);
|
const prompt = buildBackgroundJobPrompt(base);
|
||||||
// The generic closing ("Do not restart it... I will start it again") would directly contradict a
|
// The generic closing ("Do not restart it... I will start it again") would directly contradict a
|
||||||
// mechanism whose entire point is coming back on its own — asserting both would tell the model to
|
// mechanism whose entire point is that the AGENT brings it back itself — asserting both would tell
|
||||||
// distrust whichever one it read second.
|
// the model to distrust whichever one it read second.
|
||||||
expect(prompt).not.toContain('If I want it running again I will start it again');
|
expect(prompt).not.toContain('If I want it running again I will start it again');
|
||||||
expect(prompt).toContain('the harness re-arms a');
|
expect(prompt).toContain('re-arming is the');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stop exits once and says so — no handoff language, no auto-restart language', () => {
|
test('stop exits once and says so — no handoff language, no auto-restart language', () => {
|
||||||
|
|||||||
@@ -18,9 +18,14 @@
|
|||||||
* — a background PROCESS reaches the model only when it EXITS, so a loop that never exits can do shell
|
* — a background PROCESS reaches the model only when it EXITS, so a loop that never exits can do shell
|
||||||
* work and nothing else. It cannot read a diff, judge whether a push matters, or write code.
|
* work and nothing else. It cannot read a diff, judge whether a push matters, or write code.
|
||||||
*
|
*
|
||||||
* `handoff` is the fix: exit is still the notification (the one part of `keep-going` that always worked),
|
* `handoff` is the fix: exit is still the notification (the one part of `keep-going` that always worked).
|
||||||
* but re-arming is no longer the model's job. See `handoffContract` below — the harness owns the loop now,
|
* Re-arming is still the AGENT's own act — a platform-silent respawn was considered and rejected, because
|
||||||
* which is the part `watchers-what-broke.md` found the model could not reliably hold across turns anyway.
|
* the harness only ever notifies about a process IT is tracking, so anything spawned outside that tracking
|
||||||
|
* would fire exactly once and then go dark forever. What changes from the ORIGINAL two-option prompt is
|
||||||
|
* WHEN the agent is told to re-arm: immediately, as the last step of the SAME turn that is already
|
||||||
|
* handling the notification, spelled out explicitly in `handoffContract` — not left to be remembered
|
||||||
|
* unprompted across a later turn, which is the part `watchers-what-broke.md` found unreliable. Confirmed
|
||||||
|
* against real usage 2026-08-18: run this way for hours, self-re-armed every time, no failures.
|
||||||
*/
|
*/
|
||||||
export type FireBehavior = 'stop' | 'handoff' | 'keep-going';
|
export type FireBehavior = 'stop' | 'handoff' | 'keep-going';
|
||||||
|
|
||||||
@@ -53,12 +58,18 @@ const durationLine = (d: BackgroundJobSpec['duration']): string =>
|
|||||||
/**
|
/**
|
||||||
* Rule 7 — what happens on fire. `stop` and `handoff` both exit; only the sentence after that differs,
|
* Rule 7 — what happens on fire. `stop` and `handoff` both exit; only the sentence after that differs,
|
||||||
* and that sentence is the entire fix `watcher-loop-proposal.md` asked for. Kept as one exported constant
|
* and that sentence is the entire fix `watcher-loop-proposal.md` asked for. Kept as one exported constant
|
||||||
* (`handoffContract`) rather than inlined, because it is meant to change meaning at exactly one place —
|
* so it is asserted in exactly one place rather than paraphrased differently wherever it is needed.
|
||||||
* the day the harness actually owns re-arming — and a prompt asserting that has to say the true thing.
|
*
|
||||||
|
* The re-arm is explicitly the AGENT's own action, same turn, last step — not deferred, not something a
|
||||||
|
* later turn has to remember unprompted. That immediacy is the whole fix: the failure mode observed live
|
||||||
|
* was a model losing the thread across a context gap, not a model declining an instruction it was just
|
||||||
|
* given. Confirmed against real usage: run this way for hours, self-re-armed every time.
|
||||||
*/
|
*/
|
||||||
export const handoffContract =
|
export const handoffContract =
|
||||||
'When the condition fires you will be handed back, with the change. Do the work. The harness re-arms ' +
|
'When the condition fires you will be handed back, with the change. Do the work — and before you end ' +
|
||||||
'you — do not re-arm yourself, and do not build your own polling loop on top of this one.';
|
'this turn, start the SAME watch again yourself, exactly as you did the first time: same condition, ' +
|
||||||
|
'same command. That is the whole loop, every time: act, then re-arm, in the one turn. Never build a ' +
|
||||||
|
'separate polling loop on top of this — the exit is still what tells me it fired.';
|
||||||
|
|
||||||
function fireRule(onFire: FireBehavior): string {
|
function fireRule(onFire: FireBehavior): string {
|
||||||
if (onFire === 'keep-going') {
|
if (onFire === 'keep-going') {
|
||||||
@@ -167,18 +178,18 @@ export function buildBackgroundJobPrompt(spec: BackgroundJobSpec): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The closing contract differs for `handoff`, and has to: the generic version tells the model an exit
|
* The closing contract differs for `handoff`, and has to: the generic version tells the model an exit
|
||||||
* means STOP and never comes back on its own, which is exactly backwards for a job whose whole design is
|
* means STOP, which is exactly backwards for a job whose whole design is that ITS OWN action, taken
|
||||||
* that it comes back on its own. Two different promises, so two different closings — asserting the wrong
|
* immediately per rule 7, is what brings it back. Two different promises, so two different closings —
|
||||||
* one would tell the model to distrust the very mechanism rule 7 just described.
|
* asserting the wrong one would tell the model to distrust the very mechanism rule 7 just described.
|
||||||
*/
|
*/
|
||||||
function exitContract(isHandoff: boolean): string[] {
|
function exitContract(isHandoff: boolean): string[] {
|
||||||
if (isHandoff) {
|
if (isHandoff) {
|
||||||
return [
|
return [
|
||||||
`IF IT EXITS:`,
|
`IF IT EXITS:`,
|
||||||
``,
|
``,
|
||||||
`That is either the condition firing — expected, and rule 7 already told you what to do — or me`,
|
`That is either the condition firing — rule 7 already told you what to do, and re-arming is the`,
|
||||||
`stopping it by hand from its chip. Either way, do not restart it yourself: the harness re-arms a`,
|
`last step of that, not a separate decision — or me stopping it by hand from its chip. If it was`,
|
||||||
`firing on its own, and a stop I asked for staying stopped is the whole point of the button.`,
|
`me, do not start it again: I will, if I want it back.`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
|
|||||||
Reference in New Issue
Block a user