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`
|
||||
is the same shape of fact, read the same way — from `chat_session_events`, not a column that can go stale
|
||||
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
|
||||
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:**
|
||||
|
||||
- **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
|
||||
contract (`handoffContract` in `background-job-prompt.ts`) verbatim: _"When the condition fires you will
|
||||
be handed back, with the change. Do the work. The harness re-arms you — do not re-arm yourself, and do
|
||||
not build your own polling loop on top of this one."_ 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.
|
||||
boolean). `handoff` — "Exit and hand back to the agent, then re-arm" — carries the contract
|
||||
(`handoffContract` in `background-job-prompt.ts`) verbatim: _"When the condition fires you will be handed
|
||||
back, with the change. Do the work — and before you end 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."_ 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
|
||||
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
|
||||
@@ -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
|
||||
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`,
|
||||
`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
|
||||
@@ -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
|
||||
`{watcherArmed: false}` to disarm (arming itself has no route — it needs a live task id, which only a
|
||||
running turn has).
|
||||
- **`armWatcher` threaded end-to-end, client to server, and CONSUMED NOWHERE YET.** `BackgroundJobDialog`
|
||||
computes it (`watching && onFire === 'handoff'`) → `sendWithHiddenPrefix` → `QueuedPrompt.armWatcher` →
|
||||
`sendPrompt`'s new last parameter → the chat websocket message → `ClientMessage`'s `'chat'` variant →
|
||||
`startClaudeTurn`'s `msg.armWatcher`. Every step type-checks and the full suite is unaffected, because
|
||||
nothing reads the field on the server yet — safely inert plumbing, not a shipped mechanism.
|
||||
- **`armWatcher` threaded end-to-end, client to server.** `BackgroundJobDialog` computes it (`watching &&
|
||||
onFire === 'handoff'`) → `sendWithHiddenPrefix` → `QueuedPrompt.armWatcher` → `sendPrompt`'s new last
|
||||
parameter → the chat websocket message → `ClientMessage`'s `'chat'` variant → `startClaudeTurn`'s
|
||||
`msg.armWatcher`, which sets `session.pendingArmWatcher = true` — on the SESSION object, not captured by
|
||||
`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
|
||||
`restartBackgroundTask` looked like the obvious reuse — it already recovers a command from a transcript and
|
||||
respawns it detached. **It is not reusable for this.** A restarted job is deliberately 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 path updates the UI; it does not reach back into
|
||||
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.
|
||||
**Deliberately not built: the platform verifying re-arm happened.** Raised as a possible cheap addition —
|
||||
since the platform already knows `watcherLastFiredAt` and can already compute `running` from the same fold
|
||||
`GET /commanders` uses for every card, a client could flag "fired N ago, nothing has run since" without
|
||||
any new mechanism, purely by reading what already exists. Not built tonight; noted here so it is not lost,
|
||||
not because it was rejected.
|
||||
|
||||
## 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
|
||||
* 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.
|
||||
* Arm (or re-arm) a commander's watcher — called on the first `task:started` after the dialog submits a
|
||||
* `handoff` job, and again on every SUBSEQUENT `task:started` while already armed, which is how the
|
||||
* 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> {
|
||||
const rows = await db
|
||||
|
||||
@@ -421,6 +421,15 @@ export type UserSession = {
|
||||
meta: SessionMeta;
|
||||
_sidecarUnsub?: () => 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 = {
|
||||
|
||||
@@ -30,6 +30,9 @@ import {
|
||||
setChatSessionModel,
|
||||
setChatSessionPlanMode,
|
||||
createCommandHqSession,
|
||||
getCommandHqSession,
|
||||
armCommandHqWatcher,
|
||||
recordCommandHqWatcherFired,
|
||||
} from 'officerdb';
|
||||
import { resolveHomeDir } from '@@/user-home';
|
||||
import { claudeLoginState } from '@@/os-user-claude';
|
||||
@@ -513,6 +516,74 @@ function createMessageHandler(
|
||||
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.
|
||||
|
||||
@@ -768,6 +839,9 @@ export async function startClaudeTurn(params: {
|
||||
|
||||
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug, msg.context, msg.contextId);
|
||||
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,
|
||||
// through the ordinary `/chat` websocket `attach` path, exactly as a reconnect already does.
|
||||
if (ws) {
|
||||
|
||||
@@ -11,21 +11,21 @@ const base: BackgroundJobSpec = {
|
||||
};
|
||||
|
||||
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);
|
||||
expect(prompt).toContain('7. EXIT when it fires');
|
||||
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');
|
||||
});
|
||||
|
||||
test("handoff's closing contract does not contradict rule 7 by telling the model to stay stopped", () => {
|
||||
const prompt = buildBackgroundJobPrompt(base);
|
||||
// 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
|
||||
// distrust whichever one it read second.
|
||||
// mechanism whose entire point is that the AGENT brings it back itself — asserting both would tell
|
||||
// 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).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', () => {
|
||||
|
||||
@@ -18,9 +18,14 @@
|
||||
* — 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.
|
||||
*
|
||||
* `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,
|
||||
* which is the part `watchers-what-broke.md` found the model could not reliably hold across turns anyway.
|
||||
* `handoff` is the fix: exit is still the notification (the one part of `keep-going` that always worked).
|
||||
* Re-arming is still the AGENT's own act — a platform-silent respawn was considered and rejected, because
|
||||
* 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';
|
||||
|
||||
@@ -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,
|
||||
* 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 —
|
||||
* the day the harness actually owns re-arming — and a prompt asserting that has to say the true thing.
|
||||
* so it is asserted in exactly one place rather than paraphrased differently wherever it is needed.
|
||||
*
|
||||
* 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 =
|
||||
'When the condition fires you will be handed back, with the change. Do the work. The harness re-arms ' +
|
||||
'you — do not re-arm yourself, and do not build your own polling loop on top of this one.';
|
||||
'When the condition fires you will be handed back, with the change. Do the work — and before you end ' +
|
||||
'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 {
|
||||
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
|
||||
* means STOP and never comes back on its own, which is exactly backwards for a job whose whole design is
|
||||
* that it comes back on its own. Two different promises, so two different closings — asserting the wrong
|
||||
* one would tell the model to distrust the very mechanism rule 7 just described.
|
||||
* means STOP, which is exactly backwards for a job whose whole design is that ITS OWN action, taken
|
||||
* immediately per rule 7, is what brings it back. Two different promises, so two different closings —
|
||||
* asserting the wrong one would tell the model to distrust the very mechanism rule 7 just described.
|
||||
*/
|
||||
function exitContract(isHandoff: boolean): string[] {
|
||||
if (isHandoff) {
|
||||
return [
|
||||
`IF IT EXITS:`,
|
||||
``,
|
||||
`That is either the condition firing — expected, and rule 7 already told you what to do — or me`,
|
||||
`stopping it by hand from its chip. Either way, do not restart it yourself: the harness re-arms a`,
|
||||
`firing on its own, and a stop I asked for staying stopped is the whole point of the button.`,
|
||||
`That is either the condition firing — rule 7 already told you what to do, and re-arming is the`,
|
||||
`last step of that, not a separate decision — or me stopping it by hand from its chip. If it was`,
|
||||
`me, do not start it again: I will, if I want it back.`,
|
||||
];
|
||||
}
|
||||
return [
|
||||
|
||||
Reference in New Issue
Block a user