From 2d833ff147cd26a5c461e914e06854e0eb2707ce Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:19:22 -0700 Subject: [PATCH 01/76] fix: treat quiet records as attended across supervision (#6064) * fix(bin): read a live quiet record as a present captain at the host and watcher A quiet record left without its daemon (a quiet start that never ran or was interrupted) was read as away by the supervision host, so it parked a present captain's main and held captain outcomes for a return that never comes, and the watcher and daemon silenced captain-held rechecks on record presence. The host's posture checks, the watcher's and daemon's captain-held silencing, and the host's outcome path (branch report, drain BRANCH OUTCOMES, relocated branch authority, the owners' away wake note, and the Codex checkpoint bound) now ask the record owner's away-or-quiet reading, so only an away record is away. A live away record keeps today's behavior. * no-mistakes(document): Correct quiet-record documentation and supervision guidance * no-mistakes(document): Clarify quiet-record posture and captain-held rechecks * no-mistakes(document): Clarify quiet-record posture in documentation --- .../skills/away-quiet-supervision/SKILL.md | 3 +- .../skills/operational-home-layout/SKILL.md | 4 +- .agents/skills/quiet/SKILL.md | 6 +- .../skills/session-start-recovery/SKILL.md | 2 +- .omp/extensions/fm-primary-omp-watch.ts | 15 +++- .opencode/plugins/fm-primary-watch-arm.js | 15 +++- bin/fm-afk-contract.sh | 8 +-- bin/fm-branch-report.sh | 11 +-- bin/fm-claude-stop-autoarm.sh | 3 +- bin/fm-lease-lib.sh | 13 ++-- bin/fm-supervise-daemon.sh | 8 +-- bin/fm-supervision-host.sh | 18 +++-- bin/fm-turnend-guard-cursor.sh | 3 +- bin/fm-wake-drain.sh | 9 ++- bin/fm-watch-checkpoint.sh | 6 +- bin/fm-watch.sh | 37 +++++----- docs/architecture.md | 5 +- docs/configuration.md | 2 +- docs/pi-supervision-branch.md | 4 +- docs/scripts.md | 4 +- docs/supervision-host.md | 9 +-- .../supervision-protocols/supervision-host.md | 8 +-- tests/fm-branch-supervision.test.sh | 12 +++- tests/fm-claude-stop-autoarm.test.sh | 24 ++++++- tests/fm-cursor-primary.test.sh | 17 ++++- tests/fm-daemon.test.sh | 31 ++++++++ tests/fm-omp-harness.test.sh | 29 +++++--- tests/fm-pi-watch-extension.test.sh | 35 ++++++--- tests/fm-supervision-host.test.sh | 72 ++++++++++++++++++- tests/fm-watch-checkpoint.test.sh | 13 +++- tests/fm-watch-triage.test.sh | 61 ++++++++++++++++ 31 files changed, 385 insertions(+), 102 deletions(-) diff --git a/.agents/skills/away-quiet-supervision/SKILL.md b/.agents/skills/away-quiet-supervision/SKILL.md index 1b446e9d84e..b9120f54294 100644 --- a/.agents/skills/away-quiet-supervision/SKILL.md +++ b/.agents/skills/away-quiet-supervision/SKILL.md @@ -8,7 +8,8 @@ metadata: # Away and quiet supervision safety -The `/afk` and `/quiet` skills each own their daemon procedure, which is otherwise identical; these safety facts apply to both: +The `/afk` and `/quiet` skills own their respective entry procedures and share the daemon machinery; [architecture](../../../docs/architecture.md) owns the captain-held recheck difference between their postures. +These safety facts apply to both: - Every current daemon injection uses the `away-supervisor` kind from `bin/fm-operational-input.sh` after `FM_OPERATIONAL_PREFIX` (U+2063 INVISIBLE SEPARATOR followed by `FIRSTMATE_OP: `), except that a Claude Code primary, which strips U+2063, receives that owner's record-backed doorbell and it counts as marked only when `bin/fm-operational-input.sh open ` verifies its record; the `/afk` skill owns legacy bare-marker compatibility. - `state/.afk-contract` is the away posture, written in the same turn as `/afk` before any other work, because `/afk` is itself the go: no read-back gates entry or waits for a go; entry announces hold-for-return only, and the away session acts on those words by its own judgment through the guarded scripts under standing authority, holding for the return on doubt. diff --git a/.agents/skills/operational-home-layout/SKILL.md b/.agents/skills/operational-home-layout/SKILL.md index 350e6423f5a..8e45221e53d 100644 --- a/.agents/skills/operational-home-layout/SKILL.md +++ b/.agents/skills/operational-home-layout/SKILL.md @@ -105,8 +105,8 @@ state/ runtime records and signals; gitignored ..open-decisions-cursor per-task byte cursor and folded open-decision set bounding the OPEN DECISIONS scan's cost to new status-log appends; written only by fm-classify-lib.sh's status_open_decisions_incremental, removed by teardown, safe to delete (forces one full re-fold) ..home-appends per-task ledger of byte ranges this home itself appended as bookkeeping closes, so a wake scan can tell its own growth from a foreign write instead of waking on it; presentation is unaffected, so both the signal annotation and UNREAD STATUS still print those lines; written only by fm-classify-lib.sh's status_home_appends_record; its sibling ..home-appends.lock serializes that ledger's read-merge-write; both removed by teardown, safe to delete .status-presentation-cursor .status-presentation-lock fleet-wide per-task status identity plus independent annotation and outcome-backstop byte offsets, with a serialization lock preventing already-presented lines from replaying while preserving delayed signal annotations; owned by fm-classify-lib.sh, with each task's row retired by teardown - .afk-contract the away-posture record: the captain's verbatim away words, expected return, reach profile, and spend cap; written only by bin/fm-afk-contract.sh in the same turn as /afk, archived under afk-contracts/ at return; its presence IS the away posture in every harness; its sibling .afk-contract.lock serializes actions authorized by the live record (contract: bin/fm-afk-contract.sh) - afk-contracts/ archived away-posture records: one final record per away window keyed by entry time, plus any superseded mandates from that window + .afk-contract the away or quiet posture record; bin/fm-afk-contract.sh owns its mode, schema, entry, archive, and lock contract; its sibling .afk-contract.lock serializes actions authorized by the live record + afk-contracts/ archived away and quiet records; bin/fm-afk-contract.sh owns their archive contract .afk durable away/quiet-mode daemon flag on the harnesses that still launch the daemon (never on Pi); present = sub-supervisor may inject escalations, first line `away` (default, set by /afk, cleared on user return) or `quiet` (set by /quiet, cleared only on explicit /quiet off) per the single owner fm_afk_mode() in bin/fm-wake-lib.sh .lock-session trusted Claude session-lock sidecar; written only by bin/fm-lock.sh; never touch .watch.lock .wake-queue.lock watcher singleton and queue serialization locks diff --git a/.agents/skills/quiet/SKILL.md b/.agents/skills/quiet/SKILL.md index d261a25cafc..b80cda6b45e 100644 --- a/.agents/skills/quiet/SKILL.md +++ b/.agents/skills/quiet/SKILL.md @@ -16,10 +16,8 @@ daemon tradeoff as `/afk`, made explicit for a captain who is staying, watching the session, and does not want to exit the mode just by chatting. Where a daemon runs, this skill is a thin wrapper. -Every mechanism below - the daemon, its injection, its busy/composer guards, -its classification policy, its reliability properties - is owned once by the -`afk` skill and is IDENTICAL in quiet mode; nothing here restates it. -Quiet mode uses the daemon without making the present captain's requested actions wait for a return. +The `afk` skill owns the daemon's injection, busy/composer guards, and reliability properties; quiet mode uses that machinery while the captain remains present. +For captain-held rechecks under quiet, see [architecture](../../../docs/architecture.md). ## What it does diff --git a/.agents/skills/session-start-recovery/SKILL.md b/.agents/skills/session-start-recovery/SKILL.md index fc42409de59..2b71bbb5453 100644 --- a/.agents/skills/session-start-recovery/SKILL.md +++ b/.agents/skills/session-start-recovery/SKILL.md @@ -27,7 +27,7 @@ The locked startup inactive-outcome scan joins that worker so a slow local curre When the lock could not be acquired and verified, the queue is left untouched because no session mutation is authorized, and the guard's tangle/watcher-liveness alarms still print in read-only advisory mode without drain, supervision repair, or checkout repair commands. 4. **Supervision operating instructions** - after the wake queue and before both digests, the digest emits exactly one operating block for the detected primary harness, followed by the read-once contract that governs them. The script itself never starts supervision; the emitted harness protocol owns the exact wait or wake mechanism. -5. **Fleet-state digest** - after that read-once contract and ahead of the context digest, the compact backlog listing owned by `bin/fm-session-start.sh`; every `state/.meta`; a bounded tail of each task's `state/.status` (labeled as wake-EVENT history, not current state, with the full log path printed for a deeper read); the away posture (`state/.afk-contract`, plus the `state/.afk` daemon flag where a daemon runs); and one cheap alive/dead read of each task's recorded backend endpoint. +5. **Fleet-state digest** - after that read-once contract and ahead of the context digest, the compact backlog listing owned by `bin/fm-session-start.sh`; every `state/.meta`; a bounded tail of each task's `state/.status` (labeled as wake-EVENT history, not current state, with the full log path printed for a deeper read); the away or quiet posture (`state/.afk-contract`, plus the `state/.afk` daemon flag where a daemon runs); and one cheap alive/dead read of each task's recorded backend endpoint. That liveness line is a fast presence check only, not a full state read - when you need a crew's actual current state (a run-step, not just "is the pane there"), read it with `bin/fm-crew-state.sh ` as before; the digest deliberately skips that deeper, slower read for every task so it stays fast and bounded. 6. **Network checks** - after the fleet-state digest, the deferred stage's result, or an explicit statement of what it has not confirmed yet. A read-only session runs no network checks at all and says so. diff --git a/.omp/extensions/fm-primary-omp-watch.ts b/.omp/extensions/fm-primary-omp-watch.ts index 383d3c7b8fc..e033f48ceb2 100644 --- a/.omp/extensions/fm-primary-omp-watch.ts +++ b/.omp/extensions/fm-primary-omp-watch.ts @@ -256,8 +256,19 @@ function completedActionableLine(output: string): string { return newline < 0 ? "" : actionableLine(output.slice(0, newline + 1)); } +// An away record, never quiet mode's (bin/fm-afk-contract.sh mode owns that +// reading): a record whose mode cannot be read as quiet reads as away. +function awayRecordPresent(): boolean { + if (!existsSync(`${state}/.afk-contract`)) return false; + const result = spawnSync("bash", [`${fmRoot}/bin/fm-afk-contract.sh`, "mode"], { + encoding: "utf8", + env: { ...process.env, FM_STATE_OVERRIDE: state }, + }); + return String(result.stdout || "").trim() !== "quiet"; +} + // The host-mode wake message: every "supervision-host:" line in order, wake -// lines capped at eight, and the away note while the posture record exists. +// lines capped at eight, and the away note while an away record exists. function hostWakeMessage(output: string): string { let shown = 0; const lines = output.split(/\r?\n/).filter((line) => { @@ -269,7 +280,7 @@ function hostWakeMessage(output: string): string { return false; }); if (lines.length === 0) return ""; - if (existsSync(`${state}/.afk-contract`)) { + if (awayRecordPresent()) { lines.push("This wake comes from automatic supervision under the away-posture record, not from the captain: it is not a return, so handle it under the away posture."); } return lines.join("\n"); diff --git a/.opencode/plugins/fm-primary-watch-arm.js b/.opencode/plugins/fm-primary-watch-arm.js index d2147967398..75b0a87ec94 100644 --- a/.opencode/plugins/fm-primary-watch-arm.js +++ b/.opencode/plugins/fm-primary-watch-arm.js @@ -145,8 +145,19 @@ async function sessionOwnsLock(paths) { return false; } +// An away record, never quiet mode's (bin/fm-afk-contract.sh mode owns that +// reading): a record whose mode cannot be read as quiet reads as away. +function awayRecordPresent(paths) { + if (!existsSync(`${paths.state}/.afk-contract`)) return false; + const result = spawnSync("bash", [`${paths.root}/bin/fm-afk-contract.sh`, "mode"], { + encoding: "utf8", + env: { ...process.env, FM_STATE_OVERRIDE: paths.state }, + }); + return String(result.stdout || "").trim() !== "quiet"; +} + // The host-mode wake message: every "supervision-host:" line in order, wake -// lines capped at eight, and the away note while the posture record exists. +// lines capped at eight, and the away note while an away record exists. function hostWakeMessage(paths, combined) { let shown = 0; const lines = combined.split(/\r?\n/).filter((line) => { @@ -158,7 +169,7 @@ function hostWakeMessage(paths, combined) { return false; }); if (lines.length === 0) return ""; - if (existsSync(`${paths.state}/.afk-contract`)) { + if (awayRecordPresent(paths)) { lines.push("This wake comes from automatic supervision under the away-posture record, not from the captain: it is not a return, so handle it under the away posture."); } return lines.join("\n"); diff --git a/bin/fm-afk-contract.sh b/bin/fm-afk-contract.sh index baece4feb75..9cbe8b45b1a 100755 --- a/bin/fm-afk-contract.sh +++ b/bin/fm-afk-contract.sh @@ -4,9 +4,9 @@ # announcement, and the archive at return. # # POSTURE. Away mode is a posture of the one supervision session, recorded in -# state/.afk-contract and never inferred from chat. While the record exists the -# home is afk; the captain's first unmarked message archives it (the return path -# in bin/fm-afk-return.sh calls `archive` through bin/fm-afk-launch.sh stop). +# state/.afk-contract and never inferred from chat. While an away record exists +# the home is afk; the captain's first unmarked message archives it (the return +# path in bin/fm-afk-return.sh calls `archive` through bin/fm-afk-launch.sh stop). # Being away changes how the captain is informed and what happens at a # captain-owned decision point, never the authority set. Hold-for-return is the # only reach profile this release records: there is no phone channel, and the @@ -103,7 +103,7 @@ # # CROSS-SUBSYSTEM LOCK (state/.afk-contract.lock; this script is its one owner). # This record is authority another subsystem reads and then ACTS on outside this -# script: bin/fm-pr-merge.sh reads the record's presence as away merge authority +# script: bin/fm-pr-merge.sh reads an away record as away merge authority # and afterwards hands a merge to the forge. A publication, replacement, or # archive landing between that read and the forge handoff would land a merge on # authority that no longer holds, so the two subsystems share one lock instead of diff --git a/bin/fm-branch-report.sh b/bin/fm-branch-report.sh index 87a14503e1a..a29349f3cbd 100755 --- a/bin/fm-branch-report.sh +++ b/bin/fm-branch-report.sh @@ -30,8 +30,9 @@ # or scope). # # A non-silent row an away turn recorded after the captain returned (the turn -# record says posture=away, or predates the posture field, and the away-posture -# record is gone) may be missing from the return brief, so it is also queued +# record says posture=away, or predates the posture field, and no away record +# remains: none, or quiet mode's, whose captain is present; bin/fm-afk-contract.sh +# AWAY OR QUIET) may be missing from the return brief, so it is also queued # for MAIN as a durable check wake keyed supervision-host-return:, # presented by the drain until MAIN acknowledges it. bin/fm-afk-return.sh # archives the record before it reads the store and this check follows the @@ -49,6 +50,8 @@ FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" TURN_FILE="$STATE/.supervision-host-turn" RECEIPTS="$STATE/.supervision-host-receipts" +# shellcheck source=bin/fm-afk-contract.sh +. "$SCRIPT_DIR/fm-afk-contract.sh" usage() { sed -n '/^# Usage:/,/^# --wake/p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' >&2 @@ -129,14 +132,14 @@ if [ "$SILENT" = true ]; then exit 0 fi if [ "$(turn_field posture)" = attended ]; then - if [ "$VERDICT" = captain ] && [ ! -f "$STATE/.afk-contract" ]; then + if [ "$VERDICT" = captain ] && ! fm_afk_contract_away_present "$STATE"; then printf 'recorded seq %s [captain]; MAIN processes it from its next drain\n' "$SEQ" else printf 'recorded seq %s [%s]; it waits in the outcome store for MAIN\n' "$SEQ" "$VERDICT" fi exit 0 fi -if [ ! -f "$STATE/.afk-contract" ]; then +if ! fm_afk_contract_away_present "$STATE"; then # shellcheck source=bin/fm-wake-lib.sh . "$SCRIPT_DIR/fm-wake-lib.sh" if ! fm_wake_append check "supervision-host-return:$SEQ" \ diff --git a/bin/fm-claude-stop-autoarm.sh b/bin/fm-claude-stop-autoarm.sh index 8af17db1c94..be21aa7ed65 100755 --- a/bin/fm-claude-stop-autoarm.sh +++ b/bin/fm-claude-stop-autoarm.sh @@ -522,7 +522,8 @@ if [ "$ACTIONABLE" -eq 1 ]; then else [ -n "$OUT" ] && grep -E '^(signal:|stale:|check:|heartbeat)' "$OUT" 2>/dev/null | head -8 fi - if [ "$HOST_MODE" -eq 1 ] && [ -e "$STATE/.afk-contract" ]; then + if [ "$HOST_MODE" -eq 1 ] && [ -e "$STATE/.afk-contract" ] \ + && [ "$(FM_STATE_OVERRIDE="$STATE" "$SCRIPT_DIR/fm-afk-contract.sh" mode 2>/dev/null)" != quiet ]; then printf 'This wake comes from automatic supervision under the away-posture record, not from the captain: it is not a return, so handle it under the away posture.\n' fi [ -z "$SUCCESSOR_FAILURE" ] || printf '%s\n' "$SUCCESSOR_FAILURE" diff --git a/bin/fm-lease-lib.sh b/bin/fm-lease-lib.sh index 170dd73be58..a17a3333fac 100755 --- a/bin/fm-lease-lib.sh +++ b/bin/fm-lease-lib.sh @@ -67,9 +67,9 @@ # merging a PR, landing local-only work, spawning workers, answering a # decision, retiring a secondmate - refuse the branch actor outright, # lease or no lease, while the home is attended. While a confirmed, -# readable, live away-posture -# record exists (bin/fm-afk-contract.sh validate; docs/pi-supervision- -# branch.md "Postures"), main is parked and its STANDING authority +# readable, live away record exists (bin/fm-afk-contract.sh validate and +# mode, never quiet mode's record, whose captain is present; docs/pi- +# supervision-branch.md "Postures"), main is parked and its STANDING authority # relocates to the branch for exactly the actions whose guarded script # opts in with --away-relocated: a PR merge, a fresh spawn of queued work, # and a decision answer. Each guarded script keeps its own mechanical gate; @@ -237,13 +237,14 @@ fm_lease_guard_release() { } # fm_lease_away_relocated: 0 iff main's standing authority is relocated to the -# branch actor right now - a confirmed, readable, live away-posture record -# exists in $STATE, as bin/fm-afk-contract.sh's own validate subcommand judges +# branch actor right now - a confirmed, readable, live away record exists in +# $STATE, as bin/fm-afk-contract.sh's own validate and mode subcommands judge # it (the header's role-partition paragraph). Read fresh on every call, never # cached, because the record can be archived between two guarded actions. fm_lease_away_relocated() { [ -f "$STATE/.afk-contract" ] || return 1 - FM_STATE_OVERRIDE="$STATE" "$FM_LEASE_LIB_DIR/fm-afk-contract.sh" validate >/dev/null 2>&1 + FM_STATE_OVERRIDE="$STATE" "$FM_LEASE_LIB_DIR/fm-afk-contract.sh" validate >/dev/null 2>&1 || return 1 + [ "$(FM_STATE_OVERRIDE="$STATE" "$FM_LEASE_LIB_DIR/fm-afk-contract.sh" mode 2>/dev/null)" != quiet ] } # fm_lease_forbid_branch [--away-relocated]: refuse (exit diff --git a/bin/fm-supervise-daemon.sh b/bin/fm-supervise-daemon.sh index f3dba393d46..7a7191807df 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -57,8 +57,8 @@ # PAUSE_RESURFACE_SECS recheck, never a wedge escalation, whether its pane # reads idle or busy; only a status append that stops declaring the wait # ends that routing. A captain-held transfer is not rechecked at all while -# the away-posture record (state/.afk-contract) exists: nobody is there to -# answer it, and the return brief lists it. +# an away record (state/.afk-contract, never quiet mode's) exists: nobody +# is there to answer it, and the return brief lists it. # Crewmates are autonomous, so a delayed stale response does not stall a # healthy crewmate's own progress. # Buffered escalation delivery also has a max-defer alarm: if a digest stays @@ -107,7 +107,7 @@ # recheck (default 14400, four hours); an # `until` time cannot extend this bound, and a # captain-held transfer is never rechecked -# while the away-posture record exists +# while an away record exists # FM_ESCALATE_BATCH_SECS buffer window for batched escalation # digests; 0 = flush immediately (default 90) # FM_HEARTBEAT_SCAN_SECS cadence for the catch-all status scan @@ -1285,7 +1285,7 @@ housekeeping() { # due="$state/.subsuper-pause-until-due-$key" until= bounded_until=0 - if status_is_captain_held "$last" && fm_afk_contract_present "$state"; then + if status_is_captain_held "$last" && fm_afk_contract_away_present "$state"; then continue fi if until=$(status_paused_until "$last"); then diff --git a/bin/fm-supervision-host.sh b/bin/fm-supervision-host.sh index be406f9e49c..1638f2f12fb 100755 --- a/bin/fm-supervision-host.sh +++ b/bin/fm-supervision-host.sh @@ -35,8 +35,10 @@ # # THE LOOP. It owns watcher cycles through bin/fm-watch-arm.sh. The posture is # the away-posture record state/.afk-contract, read at every close and again -# when a turn starts. On each actionable close: -# - attended (no record): the close reaches main exactly as the arm printed +# when a turn starts: only an away record is away, and no record or quiet +# mode's record (fm_afk_contract_away_present, bin/fm-afk-contract.sh AWAY OR +# QUIET) is a present captain. On each actionable close: +# - attended (no away record): the close reaches main exactly as the arm printed # it, as without the host, unless the supervision session may take it: the # home names a usable engine, its turns have every tool they need, this # primary has a verified dialog mirror (bin/fm-host-mirror.sh verified; @@ -52,7 +54,7 @@ # marker still reads downtime and the re-arm owner delivers the close to # main. The watcher singleton lock makes the session's next arm attach to # that cycle instead of starting a second one; -# - away (the record exists): every close goes to the engine. +# - away (an away record exists): every close goes to the engine. # Every turn that starts attended meets that rule again at its start, so a # close accepted away whose turn starts attended (the captain returned in # between) or an attended close whose task turned main-only while the @@ -171,6 +173,8 @@ CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" . "$SCRIPT_DIR/fm-timeout-lib.sh" # shellcheck source=bin/fm-supervision-engine-lib.sh . "$SCRIPT_DIR/fm-supervision-engine-lib.sh" +# shellcheck source=bin/fm-afk-contract.sh +. "$SCRIPT_DIR/fm-afk-contract.sh" FIRST_ARM_RESTART=0 case "${1:-}" in @@ -550,7 +554,7 @@ returned_during_turn() { RETURNED_ROWS= RETURNED_SEQS= RETURNED_LOOKUP_FAILED=0 - [ -n "$LAST_TURN" ] && [ "$TURN_POSTURE" = away ] && [ ! -f "$STATE/.afk-contract" ] || return 1 + [ -n "$LAST_TURN" ] && [ "$TURN_POSTURE" = away ] && ! fm_afk_contract_away_present "$STATE" || return 1 if ! TURN_RECEIPT_SEQS=$(awk -F '\t' -v turn="$LAST_TURN" \ '$1 == turn { printf "%s%s", sep, $2; sep = "," }' "$RECEIPTS" 2>/dev/null); then RETURNED_LOOKUP_FAILED=1 @@ -772,7 +776,7 @@ handle_wake() { # ENGINE_ERROR=0 HEALTH_NOTE= TURN_POSTURE=attended - [ ! -f "$STATE/.afk-contract" ] || TURN_POSTURE=away + ! fm_afk_contract_away_present "$STATE" || TURN_POSTURE=away first=$(printf '%s\n' "$reason" | head -n 1) if [ "$TURN_POSTURE" = attended ]; then attended_acceptor "$first" || return 2 @@ -1013,7 +1017,7 @@ while :; do fi # Attended: the close reaches main exactly as the plain arm delivers it, # unless the supervision session may take it (attended_acceptor). - if [ ! -f "$STATE/.afk-contract" ]; then + if ! fm_afk_contract_away_present "$STATE"; then if ! attended_acceptor "$(printf '%s\n' "$REASON" | head -n 1)"; then log_line "pass-through attended $ATTENDED_WHY $(printf '%s\n' "$REASON" | head -n 1)" if [ "$ATTENDED_WHY" = main-only ]; then @@ -1095,7 +1099,7 @@ while :; do # Attended captain outcomes are main's to process; away they wait for the # return, including when the captain left while this turn ran. The close # itself was handled, so only the host's lines reach main. - if [ -n "$LAST_TURN" ] && [ ! -f "$STATE/.afk-contract" ]; then + if [ -n "$LAST_TURN" ] && ! fm_afk_contract_away_present "$STATE"; then CAPTAIN_SEQS=$(turn_captain_seqs "$LAST_TURN") if [ -n "$CAPTAIN_SEQS" ]; then ARM_TEXT= diff --git a/bin/fm-turnend-guard-cursor.sh b/bin/fm-turnend-guard-cursor.sh index c68ef351144..136c0cb55e5 100755 --- a/bin/fm-turnend-guard-cursor.sh +++ b/bin/fm-turnend-guard-cursor.sh @@ -396,7 +396,8 @@ fi if [ "$ACTIONABLE" -eq 1 ]; then if [ "$HOST_MODE" -eq 1 ]; then WAKE=$(awk '/^supervision-host:/ { print; next } /^(signal:|stale:|check:|heartbeat)/ && shown++ < 8' "$ARM_OUT" 2>/dev/null) - if [ -e "$STATE/.afk-contract" ]; then + if [ -e "$STATE/.afk-contract" ] \ + && [ "$(FM_STATE_OVERRIDE="$STATE" "$SCRIPT_DIR/fm-afk-contract.sh" mode 2>/dev/null)" != quiet ]; then WAKE="$WAKE This wake comes from automatic supervision under the away-posture record, not from the captain: it is not a return, so handle it under the away posture." fi diff --git a/bin/fm-wake-drain.sh b/bin/fm-wake-drain.sh index 57f40b689cf..437c5ae5c7e 100755 --- a/bin/fm-wake-drain.sh +++ b/bin/fm-wake-drain.sh @@ -31,6 +31,8 @@ SCRIPT_DIR="$(d=${BASH_SOURCE[0]%/*}; [ "$d" != "${BASH_SOURCE[0]}" ] || d=.; cd . "$SCRIPT_DIR/fm-lease-lib.sh" # shellcheck source=bin/fm-supervision-engine-lib.sh . "$SCRIPT_DIR/fm-supervision-engine-lib.sh" +# shellcheck source=bin/fm-afk-contract.sh +. "$SCRIPT_DIR/fm-afk-contract.sh" DRAIN_TMP= DRAIN_VIEW_TMP= @@ -574,8 +576,9 @@ EOF # main last drained (docs/supervision-host.md "Captain outcomes"). Off Pi this # presentation is what the Pi branch's transcript entries are. It runs only for # main, only where fm_supervision_host_outcomes_drained holds (the Pi branch -# extension owns this path on Pi), and never while the away-posture record -# exists, because those outcomes wait for the return. Bounded, and silent when +# extension owns this path on Pi), and never while an away record exists, +# because those outcomes wait for the return; quiet mode's record is a present +# captain (bin/fm-afk-contract.sh AWAY OR QUIET). Bounded, and silent when # nothing is new or unprocessed. # - Captain outcomes come first and never wait behind routine ones. Every # unprocessed captain row is presented on every drain until main @@ -618,7 +621,7 @@ print_branch_outcomes_section() { config=${FM_CONFIG_OVERRIDE:-$FM_HOME/config} fm_supervision_host_outcomes_drained "$config" || return 0 [ -s "$STATE/branch-outcomes.jsonl" ] || return 0 - [ ! -f "$STATE/.afk-contract" ] || return 0 + ! fm_afk_contract_away_present "$STATE" || return 0 if ! command -v jq >/dev/null 2>&1; then printf 'BRANCH OUTCOMES SKIPPED: jq is not installed, so the outcome store cannot be presented; nothing was marked read, and these outcomes are presented once jq is back.\n' >&2 return 1 diff --git a/bin/fm-watch-checkpoint.sh b/bin/fm-watch-checkpoint.sh index 45162017f74..205f2e0ff98 100755 --- a/bin/fm-watch-checkpoint.sh +++ b/bin/fm-watch-checkpoint.sh @@ -7,7 +7,8 @@ # bin/fm-supervision-host.sh in the watcher's place for the checkpoint's bound, # as the host's park boundary; the host takes away-posture wakes itself and # returns only when main is needed (its header owns the output read here). -# While the away-posture record state/.afk-contract exists, the bound is +# While an away record state/.afk-contract exists (never quiet mode's, whose +# captain is present: bin/fm-afk-contract.sh mode), the bound is # raised to FM_CODEX_WATCH_CHECKPOINT_AWAY (default 3600) when that is longer, # so a parked main is not woken every few minutes; an engine turn that starts # before the bound may finish after it. A close that carries a wake or a @@ -113,7 +114,8 @@ positive_or() { # if [ -f "$CONFIG/supervision-host" ]; then BOUND=$SECONDS_ARG - if [ -f "$STATE/.afk-contract" ]; then + if [ -f "$STATE/.afk-contract" ] \ + && [ "$(FM_STATE_OVERRIDE="$STATE" "$SCRIPT_DIR/fm-afk-contract.sh" mode 2>/dev/null)" != quiet ]; then AWAY_BOUND=$(positive_or "${FM_CODEX_WATCH_CHECKPOINT_AWAY:-}" 3600) [ "$AWAY_BOUND" -le "$BOUND" ] 2>/dev/null || BOUND=$AWAY_BOUND fi diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 82cc14c5009..7fa311fd2a2 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -14,7 +14,7 @@ # That cadence is hours long and condition-aware: a paused: line naming # `until ` is rechecked when that time passes, but a declared time # beyond FM_PAUSE_RESURFACE_SECS cannot extend the ordinary recheck cadence, and -# while the away-posture record (state/.afk-contract) exists an +# while an away record (state/.afk-contract, never quiet mode's) exists an # item held for the captain is never rechecked at all, in either posture. # While state/.afk exists, the daemon owns triage and this watcher queues and exits # on every wake. Printed reason lines: @@ -224,8 +224,9 @@ WATCH_HOME_EXISTED=0 # shellcheck source=bin/fm-task-inbox-lib.sh . "$SCRIPT_DIR/fm-task-inbox-lib.sh" # The away-posture record (state/.afk-contract) is the posture in both the -# attended and the afk session; bin/fm-afk-contract.sh owns its schema and this -# watcher reads only its presence (afk_record_present below). +# attended and the afk session; bin/fm-afk-contract.sh owns its schema and its +# away-or-quiet reading, which is all this watcher reads (away_record_present +# below). # shellcheck source=bin/fm-afk-contract.sh . "$SCRIPT_DIR/fm-afk-contract.sh" # Persistent-secondmate endpoint liveness: the shared probe/relaunch library is @@ -376,7 +377,7 @@ case "$SECONDMATE_LIVENESS_WINDOW_SECS" in ''|*[!0-9]*|0) SECONDMATE_LIVENESS_WI # These cases re-surface once for a recheck every PAUSE_RESURFACE_SECS - far # longer than the wedge threshold, but finite so a forgotten wait cannot rot # invisibly - except an item held for the captain while the away-posture record -# exists, which is never rechecked (afk_record_present below). +# exists, which is never rechecked (away_record_present below). PAUSE_RESURFACE_SECS=${FM_PAUSE_RESURFACE_SECS:-$FM_PAUSE_RESURFACE_SECS_DEFAULT} # A declared wait that names WHEN it clears (`paused: ... until `, # status_paused_until in fm-classify-lib.sh) is condition-aware: it is not @@ -401,19 +402,21 @@ _event_cap_fails=0 # digest/injection layer would never see the wake. afk_present() { [ -e "$STATE/.afk" ]; } -# afk_record_present: 0 while the away-posture record exists (the captain is -# away, in either supervision shape). While it exists an item held for the -# captain is never rechecked: there is nobody to answer it, the return brief -# lists it, and a recheck would only churn (the 2026-09-07 away-window audit -# counted hourly rechecks of captain-held items as pure noise). Declared -# external waits keep their condition-aware cadence in both postures. -afk_record_present() { fm_afk_contract_present "$STATE"; } +# away_record_present: 0 while an away record exists (the captain is away, in +# either supervision shape); quiet mode's record is a present captain, so it +# reads 1 (fm_afk_contract_away_present). "The away-posture record exists" +# below means this. While it exists an item held for the captain is never +# rechecked: there is nobody to answer it, the return brief lists it, and a +# recheck would only churn (the 2026-09-07 away-window audit counted hourly +# rechecks of captain-held items as pure noise). Declared external waits keep +# their condition-aware cadence in both postures. +away_record_present() { fm_afk_contract_away_present "$STATE"; } # captain_held_silenced : 0 when the line declares a captain-held -# transfer and the away-posture record exists, so every stale path absorbs the -# pane silently instead of rechecking it. +# transfer and an away record exists, so every stale path absorbs the pane +# silently instead of rechecking it. captain_held_silenced() { # - status_is_captain_held "$1" && afk_record_present + status_is_captain_held "$1" && away_record_present } hash_pane() { @@ -1366,7 +1369,7 @@ EOF return 1 fi key=$(window_key "$win") - if [ "$whom" = captain ] && afk_record_present; then + if [ "$whom" = captain ] && away_record_present; then triage_log "absorbed $label ($kind, never rechecked while the away-posture record exists): $win" return 0 fi @@ -1578,7 +1581,7 @@ handle_paused_stale() { # min_age=$PAUSE_RESURFACE_SECS declaration="declared:$(fm_wake_signal_sig "$statusf" || true)" if status_is_captain_held "$last"; then - if afk_record_present; then + if away_record_present; then triage_log "absorbed stale (captain-held, never rechecked while the away-posture record exists): $win" return 0 fi @@ -1852,7 +1855,7 @@ captain_call_stale_bound() { # STALE_WAIT_DECLARATION= task_captain_call_open "$task" || return 1 STALE_WAIT_DECLARATION=$(captain_call_declaration "$task" "$CAPTAIN_CALL_IDENTITY") - afk_record_present && return 0 + away_record_present && return 0 stale_wait_throttled "$key" "$STALE_WAIT_DECLARATION" } diff --git a/docs/architecture.md b/docs/architecture.md index 114846f005d..de28808b9ce 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -189,7 +189,8 @@ Away mode is a posture of the one supervision session, recorded in `state/.afk-c The captain's away words are the whole mandate: the record owner's header is the single owner of the record schema, the words are recorded verbatim, and by the captain's mandate no parser, tokenizer, classifier, or grammar reads them anywhere. The supervision session reads the words at the tail of every wake and acts on them by its own judgment at the moment an event makes them relevant, only through the guarded scripts under standing authority, never by analogy, holding for the return on doubt; `bin/fm-branch-prompt.sh` "Postures" owns those execution rules. What stays mechanical is exactly what a script can check without reading words: a merge green at its live head under the record lock, synchronous merges only, the spend cap, and the never-set; destructive, irreversible, and security-sensitive actions are never pre-authorizable whatever the words say. -Daemon-backed quiet mode writes the same record marked quiet, and the record owner's header owns which posture a record is: the entry, read-back, session-start line, merge authority, and spend cap treat a quiet record as a present captain with nothing held for a return. +Daemon-backed quiet mode writes the same record marked quiet; `bin/fm-afk-contract.sh` owns the mode reading, and the supervision host treats a quiet record without a daemon as attended, delivering captain outcomes to the present captain. +The watcher and daemon recheck captain-held work in quiet mode as they do while attended, rather than silencing it until a return. The record's mode distinguishes away from quiet on every harness; `bin/fm-afk-launch.sh` owns entry and exit, and `bin/fm-afk-return.sh` archives the record and owns the return brief's ordered sections, including landed live task records that still owe cleanup, rendered from durable state; persistent secondmates are excluded from that cleanup section even if an older record carries a child's merged PR. While the away record exists neither supervisor rechecks an item held for the captain, and a declared external wait names when it clears with `until` for a condition-aware recheck in both postures that occurs at the declared time or the hours-long `FM_PAUSE_RESURFACE_SECS` bound, whichever comes first. On Pi and pi-signed the away daemon is no longer launched: the ordinary supervision session continues under the record with main parked, so the supervision branch takes every actionable wake, captain outcomes accumulate for the return brief, and main's standing authority relocates to the branch through the guarded scripts, each keeping its own gate ([`pi-supervision-branch.md`](pi-supervision-branch.md#postures)); a wake the branch cannot take and a watcher failure still reach main. @@ -209,7 +210,7 @@ A wake already decorated as a possible wedge does not override the daemon's own In away mode, seen-status dedupe does not clear possible-wedge aging for nonterminal progress, so housekeeping still re-escalates an unchanged idle pane at the configured bound. Away-mode housekeeping has no worktree-write deferral of its own, so while `state/.afk` exists a quiet crew that is writing its own worktree still escalates as a possible wedge at that bound. The daemon escalates captain-relevant events, plus a bounded recheck for a declared external wait that is still declared, as one batched, single-line digest using the canonical `away-supervisor` kind from `bin/fm-operational-input.sh`; a Claude Code primary receives that owner's record-backed doorbell instead of the stripped invisible marker, so firstmate can distinguish the escalation from ordinary captain messages. -Captain-held transfers remain silent until return while the posture record exists. +Captain-held transfers remain silent until return while the away record exists. Its supervisor injection path supports tmux and herdr panes, with `FM_SUPERVISOR_BACKEND` and `FM_SUPERVISOR_TARGET` resolved independently from the task-spawn backend. Pane existence, busy checks, composer checks, capture, and verified submit route through `bin/fm-backend.sh`: tmux keeps the same submit core used by the tmux send backend, while herdr, for a Claude pane, types only into an empty composer and withholds Enter until that composer shows the typed payload, and then uses native agent-state submit confirmation on idle baselines, a composer empty fallback when native stays idle, and a pre-Enter rendered-footer transition when that baseline is unavailable. The retries-exhausted queued-Enter decision is owned by `fm_composer_queued_enter_verdict` in `bin/fm-composer-lib.sh`; tmux and herdr provide only their backend-specific busy signals. diff --git a/docs/configuration.md b/docs/configuration.md index 005f443e631..209da6093a2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -305,7 +305,7 @@ The host runs the supervision branch's contract on a headless engine session bes [docs/supervision-host.md](supervision-host.md) defines its design, current scope, and verified engines. A Claude, Cursor, OpenCode, omp, Grok, or Codex primary can run the host. With the file present, the primary's arm owner runs the host in place of the watcher arm. -The host handles wakes on the engine while `state/.afk-contract` exists, and also while attended on a Claude or Cursor primary, whose dialog mirror is verified ([supervision-host.md](supervision-host.md#postures)). +The host handles wakes on the engine under the [posture rules](supervision-host.md#postures), including an away record and attended operation on a Claude or Cursor primary with a verified dialog mirror. On that home, `/afk` launches no away daemon; see [Quiet mode](supervision-host.md#quiet-mode) for `/quiet`'s attended statement and fallback. The file also gates the primary's dialog-mirror hooks (`bin/fm-host-mirror.sh`), which record on a Claude or Cursor primary ([supervision-host.md](supervision-host.md#the-dialog-mirror)). diff --git a/docs/pi-supervision-branch.md b/docs/pi-supervision-branch.md index 251ad30c812..6198c98dfcb 100644 --- a/docs/pi-supervision-branch.md +++ b/docs/pi-supervision-branch.md @@ -581,8 +581,8 @@ A leftover `state/.afk` flag declines nothing. ### Authority relocation `fm_lease_forbid_branch` passes the branch actor only for the actions whose guarded script opts in. -It does so only while `bin/fm-afk-contract.sh validate` succeeds on a complete, readable, live record. -An archived, incomplete, or invalid record restores the attended refusal byte for byte. +It does so only while `bin/fm-afk-contract.sh validate` succeeds on a complete, readable, live away record (`mode` is not quiet). +An archived, incomplete, invalid, or quiet record restores the attended refusal byte for byte. The captain's away words are the whole mandate: diff --git a/docs/scripts.md b/docs/scripts.md index 49d794bb533..7cf60ec73f5 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -92,9 +92,9 @@ The shared no-mistakes gate lifecycle boundary is summarized in [architecture.md | `fm-watch-checkpoint.sh` | Run one bounded foreground watcher checkpoint for Codex-style supervision | | `fm-watch.sh` | Singleton-safe watcher: absorb benign wakes, detect stalled local-secondmate wake queues, and exit on actionable ones | | `fm-inactive-reconcile.sh` | Reconcile long-inactive direct crewmate terminal outcomes without forge access | -| `fm-afk-contract.sh` | Own the away-posture record: schema, the captain's away words verbatim, read-back, entry announcement, archive, and cross-subsystem authority lock | +| `fm-afk-contract.sh` | Own the away-or-quiet record's posture, schema, entry, read-back, archive, and cross-subsystem authority lock | | `fm-afk-start.sh` | Run the common sourceable away-mode daemon entry in the foreground | -| `fm-afk-launch.sh` | Own away-mode entry (same-turn record write, then read-back), exit, rollback, and any backend terminal lifecycle | +| `fm-afk-launch.sh` | Own away/quiet entry (same-turn record write, then read-back), exit, rollback, and any backend terminal lifecycle | | `fm-afk-return.sh` | Own deterministic return shutdown, the return brief, catch-up evidence, and the firstmate-actionable blocker gate | | `fm-supervisor-target-lib.sh` | Resolve the shared supervisor target and backend for the daemon and launcher | | `fm-supervise-daemon.sh` | Presence-gated away-mode sub-supervisor: self-handle routine wakes, guard injection by the detected primary harness, escalate batched digests, alert on failed delivery | diff --git a/docs/supervision-host.md b/docs/supervision-host.md index 255f32b26ef..3c29daf1ac1 100644 --- a/docs/supervision-host.md +++ b/docs/supervision-host.md @@ -26,10 +26,10 @@ Today it runs beside a Claude, Cursor, OpenCode, omp, Grok, or Codex primary: aw ### Behavior by posture and harness -- Attended (no away-posture record `state/.afk-contract`) on Claude and Cursor, the engine takes the wakes the Pi branch would take and never wakes main for a routine outcome; see [Postures](#postures). +- Attended (no away record: no `state/.afk-contract`, or quiet mode's) on Claude and Cursor, the engine takes the wakes the Pi branch would take and never wakes main for a routine outcome; see [Postures](#postures). Every other close reaches main exactly as the plain watcher arm delivers it. - Attended on OpenCode, omp, Grok, and Codex, the host is a pass-through: every close reaches main as without the host. -- Away (the record exists), the host hands each close to the engine. +- Away (an away record exists), the host hands each close to the engine. Main stays parked unless the host hands the wake back. - `/afk` launches no away daemon on an opted-in home of those harnesses, because the host is the away session there. - `/quiet` enters nothing where the attended host runs, and elsewhere launches the daemon; see [Quiet mode](#quiet-mode). @@ -100,7 +100,8 @@ So every guarded script treats it exactly as it treats the Pi branch. ## Postures -The posture is the away-posture record, read at every close and again when a turn starts, exactly as the Pi branch reads it. +The host reads the record's mode at every close and again when a turn starts (`bin/fm-afk-contract.sh` "AWAY OR QUIET"). +Only an away record is away: no record, or the record daemon-backed quiet mode writes, is a present captain, so the host runs attended beside a quiet record whose daemon is not running. ### Attended @@ -133,7 +134,7 @@ A captain who leaves while an attended turn runs turns its captain outcomes into ### Quiet mode `/quiet` asks for what the attended host already does: routine wakes stay off a present captain's main. -So where the attended host runs, `/quiet` is a statement that enters nothing, because a quiet entry's record would park the present captain's main; while [the broken-session latch](#the-broken-session-latch) holds, it says the session is paused instead. +So where the attended host runs, `/quiet` is a statement that enters nothing, because the host already gives what a quiet entry would; while [the broken-session latch](#the-broken-session-latch) holds, it says the session is paused instead. Where the home opted in but the attended host lacks one of its parts, `/quiet` names the missing part and enters the quiet daemon, and while an away record is live the captain's return comes first. `bin/fm-afk-launch.sh` owns the readiness test and refusals in its `quiet-check` contract, and the [quiet skill](../.agents/skills/quiet/SKILL.md) owns the procedure. diff --git a/docs/supervision-protocols/supervision-host.md b/docs/supervision-protocols/supervision-host.md index 52bf8ce7ae1..b9a9d38139f 100644 --- a/docs/supervision-protocols/supervision-host.md +++ b/docs/supervision-protocols/supervision-host.md @@ -5,18 +5,18 @@ Supervision host: on for this home (`config/supervision-host`; [`supervision-hos {omp} The omp watch extension runs the supervision host in the arm's place, and everything above still holds with these additions: {grok} Your tracked background arm above runs the supervision host (`bin/fm-supervision-host.sh park`) in the plain arm's place, and everything above still holds with these additions: {codex} Every foreground checkpoint runs the supervision host in the watcher's place, and everything above still holds with these additions: -{claude,cursor} 1. Attended (no away-posture record `state/.afk-contract`): a headless supervision session takes the wakes the supervision branch may take and never wakes you for a routine outcome, so fewer wakes reach you; check wakes, decision wakes, and whatever it cannot take still reach you exactly as above. -{opencode,omp,grok,codex} 1. Attended (no away-posture record `state/.afk-contract`): every wake reaches you exactly as above, because no verified dialog mirror feeds a supervision session from this harness yet. +{claude,cursor} 1. Attended (no away record, including a quiet-mode record; see [Postures](../supervision-host.md#postures)): a headless supervision session takes the wakes the supervision branch may take and never wakes you for a routine outcome, so fewer wakes reach you; check wakes, decision wakes, and whatever it cannot take still reach you exactly as above. +{opencode,omp,grok,codex} 1. Attended (no away record; see [Postures](../supervision-host.md#postures)): every wake reaches you exactly as above, because no verified dialog mirror feeds a supervision session from this harness yet. {claude,cursor} `supervision-host: branch-outcome: ...` means it handled a wake and recorded captain outcomes for you: run `bin/fm-wake-drain.sh`, process each entry of its `BRANCH OUTCOMES` section as firstmate from the task's current state, because each entry says how long ago it was recorded (tell the captain, land or merge what is ready, answer or escalate a decision, or act on a blocker; your reply covers only entries still open, as if a settled one, such as a PR since merged, had never been listed), then run the `mark-processed` acknowledgement it prints; every drain presents them again until you do. {claude,cursor} `supervision-host: the supervision session could not take this wake ...` means the wake is yours: handle it as above. {claude,cursor} A failing turn may include a `supervision-host:` health note about repeated engine errors: tell the captain when it matters and handle the handed-back wake as usual; during cooldown later attended closes reach you unchanged. {claude,cursor} Routine outcomes never wake you; your next drain lists only visible routine outcomes under `BRANCH OUTCOMES, ROUTINE` for awareness, with nothing to acknowledge. Silent rows do not appear there, but remain available through `bin/fm-branch-outcome.sh list`. -2. Away (the record exists and no daemon runs): the host hands each wake to a headless away session that runs the supervision branch's contract under the record, and you are parked. +2. Away (an away record exists and no daemon runs): the host hands each wake to a headless away session that runs the supervision branch's contract under the record, and you are parked. {claude} Only a wake the host hands back reaches you, as `Stop hook feedback` carrying the close plus one `supervision-host: ` line. {cursor,opencode,omp} Only a wake the host hands back reaches you, as a `watcher` follow-up carrying the close plus one `supervision-host: ` line. {grok} Only a wake the host hands back reaches you, as the arm's background-task-completed notification whose output carries the close plus one `supervision-host: ` line. {codex} Only a wake the host hands back reaches you, as checkpoint output carrying the close plus one `supervision-host: ` line. -{codex} While the record exists each checkpoint uses the longer away bound (`FM_CODEX_WATCH_CHECKPOINT_AWAY`, default 3600s, subject to the host's park cap; see [`supervision-host.md`](../supervision-host.md#the-park-boundary)), so a captain message waits until the checkpoint returns unless the captain interrupts it. +{codex} While an away record exists each checkpoint uses the longer away bound (`FM_CODEX_WATCH_CHECKPOINT_AWAY`, default 3600s, subject to the host's park cap; see [`supervision-host.md`](../supervision-host.md#the-park-boundary)), so a captain message waits until the checkpoint returns unless the captain interrupts it. That wake is automatic supervision, not the captain's return: drain and handle it under the away posture, and never run the return from it. After the return, a `supervision-host:` line naming the captain's return during a turn means that turn has visible outcomes missing from the return brief, whether the wake was handled or handed back: relay every following `supervision-host: outcome ...` line to the captain (the rows also remain in `bin/fm-branch-outcome.sh list`), then drain and handle any queued wake before acknowledging. Each such visible outcome is also a queued `check: supervision-host outcome ... was recorded after the captain returned` wake, which the drain presents until acknowledged: relay each outcome once, whichever arrives first, and acknowledge its `BRANCH OUTCOMES` entry too when it has one. Silent outcomes remain in the store but do not generate a handoff line or check wake. diff --git a/tests/fm-branch-supervision.test.sh b/tests/fm-branch-supervision.test.sh index f88055ec610..1975c591d87 100644 --- a/tests/fm-branch-supervision.test.sh +++ b/tests/fm-branch-supervision.test.sh @@ -1352,7 +1352,17 @@ WRAPPER out=$(FM_HOME="$home" FM_ROOT_OVERRIDE="$root" "$ROOT/bin/fm-spawn.sh" task-new --mode no-mistakes --yolo off 2>&1) assert_not_contains "$out" "caps concurrent workers" "an invalid record refused a main spawn via the spend cap" assert_not_contains "$out" "no readable spend cap" "an invalid record refused a main spawn for an unreadable cap" - pass "the away-posture record relocates the PR merge and a spawn under the spend cap to the branch, never local landing, and only while confirmed and valid" + # Quiet mode's record is a present captain (bin/fm-afk-contract.sh AWAY OR + # QUIET), so it relocates nothing: main keeps its standing authority. + rm -f "$home/state/.afk-contract" + FM_HOME="$home" FM_AFK_MODE=quiet "$ROOT/bin/fm-afk-contract.sh" enter --words 'keep routine wakes off my main' >/dev/null \ + || fail "quiet entry failed" + out=$(FM_HOME="$home" FM_SUPERVISION_ACTOR=branch "$ROOT/bin/fm-pr-merge.sh" task-x https://github.com/o/r/pull/1 2>&1) + status=$? + [ "$status" -eq 6 ] || fail "quiet mode's record relocated the merge to the branch (exit $status): $out" + assert_contains "$out" "$refusal" "the attended refusal changed under quiet mode's record" + assert_not_contains "$out" "main is parked" "quiet mode's record announced a relocation" + pass "the away-posture record relocates the PR merge and a spawn under the spend cap to the branch, never local landing, and only while confirmed, valid, and away" } test_away_branch_spawn_requires_queued_dispatchable_work() { diff --git a/tests/fm-claude-stop-autoarm.test.sh b/tests/fm-claude-stop-autoarm.test.sh index d5bf0d47922..d88b3354316 100755 --- a/tests/fm-claude-stop-autoarm.test.sh +++ b/tests/fm-claude-stop-autoarm.test.sh @@ -35,7 +35,10 @@ install_autoarm_scripts() { cp "$ROOT/bin/fm-cursor-lib.sh" "$dir/bin/fm-cursor-lib.sh" cp "$ROOT/bin/fm-hook-host-lib.sh" "$dir/bin/fm-hook-host-lib.sh" cp "$ROOT/bin/fm-lock.sh" "$dir/bin/fm-lock.sh" - chmod +x "$dir/bin/fm-claude-stop-autoarm.sh" "$dir/bin/fm-lock.sh" + cp "$ROOT/bin/fm-afk-contract.sh" "$dir/bin/fm-afk-contract.sh" + cp "$ROOT/bin/fm-classify-lib.sh" "$dir/bin/fm-classify-lib.sh" + cp "$ROOT/bin/fm-timeout-lib.sh" "$dir/bin/fm-timeout-lib.sh" + chmod +x "$dir/bin/fm-claude-stop-autoarm.sh" "$dir/bin/fm-lock.sh" "$dir/bin/fm-afk-contract.sh" } make_primary_dir() { @@ -1475,6 +1478,24 @@ test_host_handback_under_away_record_is_not_a_return() { pass "auto-arm: a wake the host hands back under the away record says it is automatic supervision, not a return" } +# Quiet mode's record is a present captain (bin/fm-afk-contract.sh AWAY OR +# QUIET), so a wake the host hands back beside it carries no away note. +test_host_handback_beside_a_quiet_record_carries_no_away_note() { + local dir out status + dir=$(make_primary_dir "$TMP_ROOT/host-handback-quiet") + mkdir -p "$dir/config" + : > "$dir/config/supervision-host" + : > "$dir/state/task.meta" + FM_HOME="$dir" FM_AFK_MODE=quiet "$ROOT/bin/fm-afk-contract.sh" enter --words 'keep routine wakes off my main' >/dev/null 2>&1 \ + || fail "fixture: could not record quiet mode" + write_host_fixture "$dir" handed-back + out=$(run_autoarm "$dir" 2>/dev/null); status=$? + expect_code 2 "$status" "a wake the host hands back must rewake main" + assert_contains "$out" "signal: fixture.status" "the handed-back wake must carry its reason line" + assert_not_contains "$out" "not a return" "a present captain's rewake must not call itself away-posture supervision" + pass "auto-arm: a wake the host hands back beside a quiet record carries no away note" +} + test_plain_arm_banner_keeps_its_wake_line_cap() { local dir out expected dir=$(make_primary_dir "$TMP_ROOT/plain-banner") @@ -1642,6 +1663,7 @@ test_long_poll_grace_reaches_arm_wrapper test_host_absent_flag_keeps_the_arm test_host_boundary_rewakes_with_the_host_line test_host_handback_under_away_record_is_not_a_return +test_host_handback_beside_a_quiet_record_carries_no_away_note test_plain_arm_banner_keeps_its_wake_line_cap test_host_handback_carries_every_host_line test_host_stand_down_is_silent diff --git a/tests/fm-cursor-primary.test.sh b/tests/fm-cursor-primary.test.sh index 341bbfaecd2..b2a6052762c 100755 --- a/tests/fm-cursor-primary.test.sh +++ b/tests/fm-cursor-primary.test.sh @@ -75,7 +75,7 @@ install_scripts() { fm-primary-scope-lib.sh fm-supervision-lib.sh fm-wake-lib.sh fm-path-lib.sh \ fm-session-lock-lib.sh fm-cursor-lib.sh fm-operational-input.sh \ fm-supervision-instructions.sh fm-harness.sh fm-lock.sh \ - fm-gate-refuse-lib.sh; do + fm-gate-refuse-lib.sh fm-afk-contract.sh fm-classify-lib.sh fm-timeout-lib.sh; do cp "$ROOT/bin/$f" "$dir/bin/$f" done cp "$ROOT/bin/fm-arm-command-policy.mjs" "$dir/bin/fm-arm-command-policy.mjs" @@ -529,6 +529,21 @@ test_park_runs_the_supervision_host_only_when_opted_in() { [ "$(printf '%s\n' "$body" | grep -c '^stale: fixture-win')" -eq 8 ] \ || fail "the follow-up must keep the eight-line cap on wake lines: $body" case "$body" in *'not from the captain: it is not a return'*) ;; *) fail "an away handback must say it is not the captain's return: $body" ;; esac + + # Quiet mode's record is a present captain (bin/fm-afk-contract.sh AWAY OR + # QUIET), so the same handback beside it carries no away note. + dir=$(make_primary_dir "$TMP_ROOT/park-host-quiet") + : > "$dir/state/task1.meta" + FM_HOME="$dir" FM_AFK_MODE=quiet "$ROOT/bin/fm-afk-contract.sh" enter --words 'keep routine wakes off my main' >/dev/null 2>&1 \ + || fail "fixture: could not record quiet mode" + mkdir -p "$dir/config" + : > "$dir/config/supervision-host" + write_arm_fixture "$dir" actionable + write_host_fixture "$dir" handback + out=$(run_park "$dir") + body=$(followup_of "$out") + case "$body" in *'supervision-host:'*) ;; *) fail "the quiet-record handback did not reach main: $out" ;; esac + case "$body" in *'not a return'*) fail "a handback beside a quiet record called itself away-posture supervision: $body" ;; esac pass "cursor park: an opted-in home parks on the supervision host and relays every host line" } diff --git a/tests/fm-daemon.test.sh b/tests/fm-daemon.test.sh index 57739a820df..efc0e6bda52 100755 --- a/tests/fm-daemon.test.sh +++ b/tests/fm-daemon.test.sh @@ -1114,6 +1114,36 @@ test_housekeeping_captain_held_resurfaces_and_resets() { pass "housekeeping re-surfaces a forgotten captain hold on the long cadence and resets its window" } +# The away record owns the one exception: nobody is there to answer a captain +# hold, so it is never rechecked. Quiet mode's record is a present captain +# (bin/fm-afk-contract.sh AWAY OR QUIET), so a quiet daemon rechecks the same +# hold on the same cadence. +test_housekeeping_captain_held_silenced_only_by_an_away_record() { + local mode dir state fakebin win pane key + for mode in away quiet; do + dir=$(make_supercase "captain-held-$mode-record") + state="$dir/state"; fakebin="$dir/fakebin" + win="sess:fm-held-w11r"; pane="$dir/pane.txt" + printf 'captain-held [key=route]: tracked by task-decision-route\n' > "$state/held-w11r.status" + printf 'idle prompt $\n' > "$pane" + key=$(printf '%s' "held-w11r" | tr ':/.' '___') + echo $(( $(date +%s) - 5000 )) > "$state/.subsuper-paused-$key" + FM_HOME="$dir" FM_STATE_OVERRIDE="$state" FM_AFK_MODE="$mode" "$ROOT/bin/fm-afk-contract.sh" enter --words 'fixture words' >/dev/null 2>&1 \ + || fail "fixture: could not record the $mode posture" + [ "$(FM_HOME="$dir" FM_STATE_OVERRIDE="$state" "$ROOT/bin/fm-afk-contract.sh" mode)" = "$mode" ] || fail "fixture: the record is not $mode" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$pane" \ + FM_STATE_OVERRIDE="$state" FM_PAUSE_RESURFACE_SECS=240 housekeeping "$state" + if [ "$mode" = away ]; then + ! grep -F "awaiting the captain" "$state/.subsuper-escalations" >/dev/null 2>&1 \ + || fail "a captain hold was rechecked while the away record exists: $(cat "$state/.subsuper-escalations")" + else + grep -F "awaiting the captain" "$state/.subsuper-escalations" >/dev/null 2>&1 \ + || fail "quiet mode's record silenced a captain hold as if the captain were away: $(cat "$state/.subsuper-escalations" 2>/dev/null || true)" + fi + done + pass "housekeeping silences a captain hold only under an away record, never under quiet mode's" +} + # A crew that RESUMED - whose latest status line no longer declares the wait - drops # its pause tracking without escalating. The dimension pinned here is that pane busy # state does not GATE that clear: the status append alone ends the wait, on the @@ -3141,6 +3171,7 @@ test_housekeeping_persistent_stale_escalates test_housekeeping_resumed_stale_cleared test_housekeeping_paused_resurfaces_and_resets test_housekeeping_captain_held_resurfaces_and_resets +test_housekeeping_captain_held_silenced_only_by_an_away_record test_housekeeping_paused_resumed_cleared test_housekeeping_busy_declared_wait_matures_its_window test_housekeeping_declared_time_controls_pause_recheck diff --git a/tests/fm-omp-harness.test.sh b/tests/fm-omp-harness.test.sh index 818402affa7..ccb8c1d15a1 100755 --- a/tests/fm-omp-harness.test.sh +++ b/tests/fm-omp-harness.test.sh @@ -581,13 +581,22 @@ EOF # An opted-in home spawns the supervision host in the arm's place; its streamed # status line drives readiness and the handling handoff, and a handed-back # wake is delivered with every host line and the away note. -test_watch_extension_runs_the_supervision_host() { - local repo home log out status - repo="$TMP_ROOT/watch-host/repo"; home="$TMP_ROOT/watch-host/home"; log="$TMP_ROOT/watch-host/arm.log" +test_watch_extension_runs_the_supervision_host() { # [away|quiet] + local kind=${1:-away} repo home log out status f + repo="$TMP_ROOT/watch-host-$kind/repo"; home="$TMP_ROOT/watch-host-$kind/home"; log="$TMP_ROOT/watch-host-$kind/arm.log" install_omp_extension_fixture "$repo" mkdir -p "$home/state" "$home/config" : > "$home/config/supervision-host" - : > "$home/state/.afk-contract" + if [ "$kind" = quiet ]; then + # Quiet mode's record is a present captain (bin/fm-afk-contract.sh AWAY OR + # QUIET): the extension asks the record owner, so the same handback carries + # no away note. + for f in fm-afk-contract.sh fm-classify-lib.sh fm-timeout-lib.sh; do cp "$ROOT/bin/$f" "$repo/bin/$f"; done + FM_HOME="$home" FM_AFK_MODE=quiet "$ROOT/bin/fm-afk-contract.sh" enter --words 'keep routine wakes off my main' >/dev/null 2>&1 \ + || fail "fixture: could not record quiet mode" + else + : > "$home/state/.afk-contract" + fi cat > "$repo/bin/fm-watch-arm.sh" <<'SH' #!/usr/bin/env bash if [ "${1:-}" = --handling-delivered ]; then @@ -612,7 +621,7 @@ sleep 30 SH chmod +x "$repo/bin/fm-watch-arm.sh" "$repo/bin/fm-supervision-host.sh" out=$(FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_WATCH_REARM_RETRY_LIMIT=1 FM_WATCH_REARM_RETRY_BASE_MS=5 FM_WATCH_REARM_RETRY_MAX_MS=10 \ - EXT="$repo/.omp/extensions/fm-primary-omp-watch.ts" node --input-type=module 2>&1 <<'EOF' + RECORD_KIND="$kind" EXT="$repo/.omp/extensions/fm-primary-omp-watch.ts" node --input-type=module 2>&1 <<'EOF' import { pathToFileURL } from "node:url"; import { writeFileSync, readFileSync } from "node:fs"; writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); @@ -641,19 +650,22 @@ for (const needle of [ "signal: omp-host done", "supervision-host: the away session could not take this wake: fixture; this wake is yours", "supervision-host: outcome 1 for demo [captain]: fixture", - "not from the captain: it is not a return", ]) { if (!sent[0].m.includes(needle)) throw new Error(`the follow-up lacks '${needle}': ${sent[0].m}`); } +const awayNote = sent[0].m.includes("not from the captain: it is not a return"); +if (process.env.RECORD_KIND === "quiet" ? awayNote : !awayNote) { + throw new Error(`the away note must appear exactly under an away record (${process.env.RECORD_KIND}): ${sent[0].m}`); +} await handlers.get("before_agent_start")({ type: "before_agent_start", prompt: sent[0].m }, {}); await handlers.get("session_shutdown")({}, {}); process.exit(0); EOF ) status=$? - expect_code 0 "$status" "omp watch extension host mode: $out" + expect_code 0 "$status" "omp watch extension host mode ($kind record): $out" [ -z "$out" ] || fail "omp watch extension host test printed output: $out" - pass ".omp watch extension: an opted-in home runs the supervision host and relays every host line" + pass ".omp watch extension: an opted-in home runs the supervision host and relays every host line ($kind record)" } # A host cycle boundary can close with only a "supervision-host:" line; left @@ -805,5 +817,6 @@ test_ownership_proof_is_omp_keyed test_turnend_guard_extension_compels_one_continuation test_watch_extension_arms_and_delivers test_watch_extension_runs_the_supervision_host +test_watch_extension_runs_the_supervision_host quiet test_watch_extension_replays_a_host_only_boundary_across_replacement test_watch_extension_delivers_a_split_host_close_whole diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index c774a6c58bd..41a68a23eb9 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -3664,18 +3664,27 @@ EOF # An opted-in home spawns the supervision host in the arm's place; its # streamed status line drives readiness and the handling handoff, and a # handed-back wake is delivered with every host line and the away note. -test_opencode_primary_watch_plugin_runs_the_supervision_host() { - local plugin repo home log stop out status +test_opencode_primary_watch_plugin_runs_the_supervision_host() { # [away|quiet] + local kind=${1:-away} plugin repo home log stop out status f plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js" - repo="$TMP_ROOT/opencode-host-root" - home="$TMP_ROOT/opencode-host-home" - log="$TMP_ROOT/opencode-host.log" - stop="$TMP_ROOT/opencode-host.stop" + repo="$TMP_ROOT/opencode-host-root-$kind" + home="$TMP_ROOT/opencode-host-home-$kind" + log="$TMP_ROOT/opencode-host-$kind.log" + stop="$TMP_ROOT/opencode-host-$kind.stop" mkdir -p "$repo/bin" "$home/state" "$home/config" git init -q "$repo" : > "$repo/AGENTS.md" : > "$home/state/task.meta" - : > "$home/state/.afk-contract" + if [ "$kind" = quiet ]; then + # Quiet mode's record is a present captain (bin/fm-afk-contract.sh AWAY OR + # QUIET): the plugin asks the record owner, so the same handback carries no + # away note. + for f in fm-afk-contract.sh fm-classify-lib.sh fm-timeout-lib.sh; do cp "$ROOT/bin/$f" "$repo/bin/$f"; done + FM_HOME="$home" FM_AFK_MODE=quiet "$ROOT/bin/fm-afk-contract.sh" enter --words 'keep routine wakes off my main' >/dev/null 2>&1 \ + || fail "fixture: could not record quiet mode" + else + : > "$home/state/.afk-contract" + fi : > "$home/config/supervision-host" cat > "$repo/bin/fm-watch-arm.sh" <<'SH' #!/usr/bin/env bash @@ -3702,7 +3711,7 @@ trap 'exit 0' TERM INT while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done SH chmod +x "$repo/bin/fm-watch-arm.sh" "$repo/bin/fm-supervision-host.sh" - out=$(PLUGIN="$plugin" WORKTREE="$repo" FM_HOME="$home" FM_ARM_LOG="$log" FM_STOP_FILE="$stop" node 2>&1 <<'EOF' + out=$(PLUGIN="$plugin" WORKTREE="$repo" FM_HOME="$home" FM_ARM_LOG="$log" FM_STOP_FILE="$stop" RECORD_KIND="$kind" node 2>&1 <<'EOF' import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; @@ -3728,16 +3737,19 @@ for (const needle of [ "signal: synthetic wake", "supervision-host: the away session could not take this wake: fixture; this wake is yours", "supervision-host: outcome 1 for demo [captain]: fixture", - "not from the captain: it is not a return", ]) { if (!prompts[0].includes(needle)) throw new Error(`the wake prompt lacks '${needle}': ${prompts[0]}`); } +const awayNote = prompts[0].includes("not from the captain: it is not a return"); +if (process.env.RECORD_KIND === "quiet" ? awayNote : !awayNote) { + throw new Error(`the away note must appear exactly under an away record (${process.env.RECORD_KIND}): ${prompts[0]}`); +} EOF ) status=$? - [ "$status" -eq 0 ] || fail "OpenCode watch plugin must run the supervision host on an opted-in home: $out" + [ "$status" -eq 0 ] || fail "OpenCode watch plugin must run the supervision host on an opted-in home ($kind record): $out" [ -z "$out" ] || fail "OpenCode host test printed output: $out" - pass "OpenCode watcher plugin runs the supervision host on an opted-in home and relays every host line" + pass "OpenCode watcher plugin runs the supervision host on an opted-in home and relays every host line ($kind record)" } test_opencode_pre_ready_actionable_close_preserves_its_successor() { @@ -4435,6 +4447,7 @@ test_opencode_primary_watch_plugin_requires_session_lock test_opencode_watch_arm_coordinator_respects_primary_scope test_opencode_primary_watch_plugin_rearms_after_wake test_opencode_primary_watch_plugin_runs_the_supervision_host +test_opencode_primary_watch_plugin_runs_the_supervision_host quiet test_opencode_pre_ready_actionable_close_preserves_its_successor test_opencode_hung_successor_falls_back_to_typed_wake test_opencode_unretired_successor_falls_back_without_retry diff --git a/tests/fm-supervision-host.test.sh b/tests/fm-supervision-host.test.sh index 69e7298fb81..8a25752c58c 100755 --- a/tests/fm-supervision-host.test.sh +++ b/tests/fm-supervision-host.test.sh @@ -168,7 +168,7 @@ suite_cleanup() { } trap suite_cleanup EXIT -make_home() { # [config line] +make_home() { # [config line] local home="$TMP_ROOT/$1" mkdir -p "$home/state" "$home/config" "$home/fakebin" # An unreachable backend: the watcher reads no endpoint as dead, so the only @@ -181,12 +181,19 @@ make_home() { # [config line] printf 'project=demo\nwindow=fm-demo\nharness=claude\n' > "$home/state/demo.meta" echo handle > "$home/stub-mode" # The captain has spoken in this session, so an attended wake has a mirror. - [ "$2" != attended ] \ + [ "$2" = away ] \ || printf '{"hook_event_name":"UserPromptSubmit","prompt_id":"p0","prompt":"watch the fleet for me"}' > "$home/mirror-seed.0" if [ "$2" = away ]; then FM_HOME="$home" "$CONTRACT" enter --words 'watch the fleet; merge nothing' >/dev/null 2>&1 \ || fail "fixture: could not record the away posture" fi + # Quiet mode's record with no daemon flag: a quiet entry whose daemon never + # started or stopped, left beside a present captain. + if [ "$2" = quiet ]; then + FM_HOME="$home" FM_AFK_MODE=quiet "$CONTRACT" enter --words 'keep routine wakes off my main' >/dev/null 2>&1 \ + || fail "fixture: could not record quiet mode" + [ "$(FM_HOME="$home" "$CONTRACT" mode)" = quiet ] || fail "fixture: the record is not quiet mode's" + fi printf '%s\n' "$home" >> "$HOMES_FILE" printf '%s\n' "$home" } @@ -904,6 +911,40 @@ test_captain_leaving_mid_turn_keeps_its_captain_outcome_for_the_return() { pass "host: a captain outcome recorded after the captain left waits for the return, then reaches main's drain" } +# A quiet record left without its daemon (no state/.afk) is a present captain, +# not an away one: the host runs attended beside it, so a captain outcome wakes +# main and reaches its drain instead of waiting for a return that never comes, +# and a decision close reaches main as the plain arm delivers it. +test_quiet_record_without_its_daemon_is_a_present_captain() { + local home drained + home=$(make_home quiet-captain quiet) + echo captain > "$home/stub-mode" + start_host "$home" + wait_until 150 watcher_live "$home" || fail "quiet: the host never started a watcher cycle" + append_status "$home" 'ready for review' + wait_until 250 host_exited "$home" || fail "quiet: the captain outcome did not wake the present captain's main: $(cat "$home/state/.supervision-host.log")" + assert_re ' handled turn=[^ ]* posture=attended ' "$home/state/.supervision-host.log" "a quiet record must leave the host's turn attended" + assert_no_re '^POSTURE: AWAY' "$home/engine-call.1" "a turn beside a quiet record must carry no away tail" + assert_re 'MAIN DIALOG MIRROR' "$home/engine-call.1" "a turn beside a quiet record must carry the captain's dialog" + assert_grep 'MAIN processes it from its next drain' "$home/engine-report.log" "a captain report beside a quiet record must say main processes it" + assert_re '^supervision-host: branch-outcome: .*\(store rows 1\); run bin/fm-wake-drain.sh' "$home/host.out" \ + "the exit must name the captain outcome's store row for the present captain" + drained=$(FM_HOME="$home" "$FAKE_CLAUDE" -c '"$0" 2>&1' "$ROOT/bin/fm-wake-drain.sh") + assert_contains "$drained" "BRANCH OUTCOMES (captain outcomes the supervision session recorded for you" "main's drain must present the captain outcome beside a quiet record" + assert_contains "$drained" " ago] demo: stub escalated: " "the section must carry the outcome" + [ -f "$home/state/.afk-contract" ] || fail "the host must leave quiet mode's record in place" + + home=$(make_home quiet-main-only quiet) + start_host "$home" + wait_until 150 watcher_live "$home" || fail "quiet main-only: the host never started a watcher cycle" + append_status "$home" 'which export format?' needs-decision + wait_until 250 host_exited "$home" || fail "quiet main-only: the decision close did not reach main: $(cat "$home/state/.supervision-host.log")" + assert_re '^signal: .*demo.status' "$home/host.out" "the decision close must reach main as the arm printed it" + [ "$(engine_calls "$home")" -eq 0 ] || fail "quiet main-only: the engine took a decision close from a present captain" + assert_re ' pass-through attended main-only signal:' "$home/state/.supervision-host.log" "the ledger must record the attended main-only pass-through" + pass "host: a quiet record without its daemon is a present captain, so outcomes and decisions reach main" +} + test_attended_main_only_close_passes_straight_to_main() { local home home=$(make_home attended-main-only attended) @@ -1101,6 +1142,31 @@ test_claude_stop_hook_delivers_a_main_only_pass_through() { pass "host+hook: an attended main-only pass-through rewakes main and keeps its successor watcher" } +# The live repro (2026-09-28): a quiet record live with no daemon flag parked a +# present Claude captain, whose worker's captain outcomes waited for a return. +# Through the real Stop hook the outcome now rewakes main, with no away note. +test_claude_stop_hook_rewakes_a_present_captain_beside_a_quiet_record() { + local home drained + home=$(make_primary_home hook-quiet-record) + # This case runs an engine turn from the primary root, whose prompt reads the skills. + ln -s "$ROOT/.agents" "$home/.agents" + FM_HOME="$home" FM_AFK_MODE=quiet "$CONTRACT" enter --words 'keep routine wakes off my main' >/dev/null 2>&1 \ + || fail "fixture: could not record quiet mode" + echo captain > "$home/stub-mode" + start_hook_session "$home" + turn_end "$home" + wait_until 150 watcher_live "$home" || fail "hook quiet: the Stop hook never started a watcher cycle: $(cat "$home/hook.err" 2>/dev/null)" + append_status "$home" 'ready for review' + wait_until 250 hook_exited "$home" || fail "hook quiet: the Stop hook never closed: $(cat "$home/state/.supervision-host.log")" + assert_re ' handled turn=[^ ]* posture=attended ' "$home/state/.supervision-host.log" "a quiet record must leave the host's turn attended" + assert_rewoke_main "$home" "hook quiet" + assert_re '^supervision-host: branch-outcome: ' "$home/hook.err" "the rewake must carry the captain outcome" + assert_no_grep 'not a return' "$home/hook.err" "a present captain's rewake must not call itself away-posture supervision" + drained=$(main_drain "$home") + assert_contains "$drained" " ago] demo: stub escalated: " "main's drain must present the captain outcome beside a quiet record" + pass "host+hook: a captain outcome beside a quiet record rewakes the present captain with no away note" +} + test_claude_stop_hook_delivers_a_close_that_turns_main_only_at_its_turn() { local home home=$(make_primary_home hook-turns-main-only) @@ -2438,12 +2504,14 @@ test_branch_outcomes_keep_a_drain_presented_outcome_across_an_index_repair test_attended_routine_wake_is_handled_on_the_engine_and_stays_off_main test_attended_captain_outcome_reaches_main_through_branch_outcomes test_captain_leaving_mid_turn_keeps_its_captain_outcome_for_the_return +test_quiet_record_without_its_daemon_is_a_present_captain test_attended_main_only_close_passes_straight_to_main test_main_only_pass_through_leaves_the_successor_watcher_running test_attended_close_with_unidentified_main_session_passes_to_main test_close_accepted_away_that_turns_attended_passes_to_main test_attended_close_that_turns_main_only_before_its_turn_passes_to_main test_claude_stop_hook_delivers_a_main_only_pass_through +test_claude_stop_hook_rewakes_a_present_captain_beside_a_quiet_record test_claude_stop_hook_delivers_a_close_that_turns_main_only_at_its_turn test_claude_stop_hook_notifies_when_at_turn_downtime_write_fails test_successor_close_during_main_turn_is_delivered_at_the_next_turn_end diff --git a/tests/fm-watch-checkpoint.test.sh b/tests/fm-watch-checkpoint.test.sh index 34d03f612e8..650718e1cfb 100755 --- a/tests/fm-watch-checkpoint.test.sh +++ b/tests/fm-watch-checkpoint.test.sh @@ -116,7 +116,7 @@ run_host_checkpoint() { # [checkpoint args...]; sets STATUS } test_host_checkpoint_bounds_the_park_by_posture() { - local home + local home f home=$(make_host_home host-bound) run_host_checkpoint "$home" boundary --seconds 5 expect_code 124 "$STATUS" "a host park that reached its bound is a quiet checkpoint" @@ -132,7 +132,16 @@ test_host_checkpoint_bounds_the_park_by_posture() { assert_contains "$(cat "$home/host-env")" 'park=900' "the away bound must be configurable" FM_CODEX_WATCH_CHECKPOINT_AWAY=900 run_host_checkpoint "$home" boundary --seconds 1000 assert_contains "$(cat "$home/host-env")" 'park=1000' "the away bound must never shorten a longer checkpoint" - pass "checkpoint: an opted-in home runs the host for the checkpoint's bound, raised while away" + # Quiet mode's record is a present captain (bin/fm-afk-contract.sh AWAY OR + # QUIET), so the checkpoint keeps its attended bound beside it. + for f in fm-afk-contract.sh fm-classify-lib.sh fm-timeout-lib.sh; do cp "$ROOT/bin/$f" "$home/root/bin/$f"; done + rm -f "$home/state/.afk-contract" + FM_HOME="$home" FM_AFK_MODE=quiet "$ROOT/bin/fm-afk-contract.sh" enter --words 'keep routine wakes off my main' >/dev/null 2>&1 \ + || fail "fixture: could not record quiet mode" + run_host_checkpoint "$home" boundary --seconds 5 + expect_code 124 "$STATUS" "a park beside a quiet record that reached its bound is a quiet checkpoint" + assert_contains "$(cat "$home/host-env")" 'park=5' "beside a quiet record the host must park for the attended bound" + pass "checkpoint: an opted-in home runs the host for the checkpoint's bound, raised only while away" } test_host_checkpoint_passes_a_handback_and_reports_a_stand_down() { diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index 3db49ef46d3..c2a2d60924d 100755 --- a/tests/fm-watch-triage.test.sh +++ b/tests/fm-watch-triage.test.sh @@ -6387,6 +6387,66 @@ test_captain_held_never_rechecked_while_away_record_exists() { pass "a captain-held item is never rechecked while the away-posture record exists, and the recheck returns once the record is archived" } +# Quiet mode's record is a present captain (bin/fm-afk-contract.sh AWAY OR +# QUIET), so it silences nothing: the same hold is rechecked with that record +# live, both on the watcher's own cadence and through the one-shot handoff a +# running quiet daemon owns. +write_quiet_record() { # + if ! FM_HOME="$(dirname "$1")" FM_STATE_OVERRIDE="$1" FM_AFK_MODE=quiet "$ROOT/bin/fm-afk-contract.sh" enter --words 'keep routine wakes off my main' >/dev/null 2>&1; then + fail "could not write quiet mode's record in $1" + fi +} + +test_captain_held_rechecked_under_a_quiet_record() { + local dir state fakebin out capture_file statusf window key back pid + dir=$(make_case quiet-record-held); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; capture_file="$dir/pane.txt"; statusf="$state/secondmate-hold.status" + window="test:fm-secondmate-hold" + printf 'idle awaiting the captain\n' > "$capture_file" + printf 'window=%s\nkind=secondmate\n' "$window" > "$state/secondmate-hold.meta" + printf 'captain-held [key=route]: tracked by task-decision-route\n' > "$statusf" + back=$(( $(date +%s) - 500 )) + if [ "$(uname)" = Darwin ]; then touch -mt "$(date -r "$back" '+%Y%m%d%H%M.%S')" "$statusf" + else touch -m -d "@$back" "$statusf"; fi + printf '%s' "$(seen_sig "$statusf")" > "$state/.seen-secondmate-hold_status" + key=$(printf '%s' "$window" | tr '.:/' '___') + printf '%s' "$(hash_text "idle awaiting the captain")" > "$state/.hash-$key" + printf '1\n' > "$state/.count-$key" + write_quiet_record "$state" + export FM_FAKE_CREW_STATE='state: unknown · source: none · no current-state source available' + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_CREW_STATE_BIN="$fakebin/fm-crew-state.sh" FM_PAUSE_RESURFACE_SECS=240 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + wait_for_exit "$pid" 100 || { reap "$pid"; fail "a captain-held item was not rechecked beside quiet mode's record"; } + unset FM_FAKE_CREW_STATE + grep -F "awaiting the captain" "$out" >/dev/null || fail "the recheck beside a quiet record did not name the captain: $(cat "$out")" + ! grep -F 'never rechecked while the away-posture record exists' "$state/.watch-triage.log" >/dev/null 2>&1 \ + || fail "quiet mode's record silenced a captain-held item as if the captain were away: $(cat "$state/.watch-triage.log")" + [ -f "$state/.afk-contract" ] || fail "fixture: quiet mode's record is gone" + ack_stopped_cycle "$state" || fail "could not acknowledge the captain-held recheck" + + dir=$(make_case quiet-daemon-held-oneshot); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; capture_file="$dir/pane.txt"; statusf="$state/held-afk.status" + window="test:fm-held-afk" + printf 'idle awaiting the captain\n' > "$capture_file" + printf 'window=%s\nkind=ship\nharness=grok\nbackend=tmux\n' "$window" > "$state/held-afk.meta" + printf 'captain-held [key=route]: tracked by task-decision-route\n' > "$statusf" + printf '%s' "$(seen_sig "$statusf")" > "$state/.seen-held-afk_status" + key=$(printf '%s' "$window" | tr '.:/' '___') + printf 'quiet\n' > "$state/.afk" + write_quiet_record "$state" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_FAKE_TMUX_CURRENT_COMMAND=zsh \ + FM_STATE_OVERRIDE="$state" FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + wait_for_exit "$pid" 100 || { reap "$pid"; fail "the quiet daemon's one-shot never handed off a captain-held pane"; } + grep -F "stale: $window" "$state/.wake-queue" >/dev/null \ + || fail "the quiet daemon's one-shot did not queue the captain-held pane for the daemon: $(cat "$state/.wake-queue" 2>/dev/null)" + pass "quiet mode's record silences no captain-held recheck, on the watcher's cadence or through a quiet daemon's one-shot" +} + test_live_captain_held_first_sight_silenced_by_away_record() { local dir state fakebin out capture_file statusf window key sig pid dir=$(make_case away-record-held-live); state="$dir/state"; fakebin="$dir/fakebin" @@ -6687,6 +6747,7 @@ test_captain_held_never_rechecked_while_away_record_exists test_live_captain_held_first_sight_silenced_by_away_record test_backlog_hold_never_rechecked_while_away_record_exists test_afk_one_shot_never_hands_off_captain_held_under_away_record +test_captain_held_rechecked_under_a_quiet_record test_paused_until_near_future_is_quiet_before_the_cadence test_paused_until_wrong_year_is_bounded_by_the_cadence test_paused_until_that_passed_is_rechecked_before_the_cadence From 00679ae3350ffa82d2d70a123484f8f9f16bbed6 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:18:08 -0700 Subject: [PATCH 02/76] fix: report supervision host latches accurately in away return briefs (#6043) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(bin): name an in-window engine latch in the return brief and drop the false handling GAP line The away return brief said nothing had failed after the supervision host latched on engine errors during the window, and printed a GAP: watcher downtime line whenever a wake was merely being handled or queued at return. The failures section now reads the host ledger and latch record and names the latch time, the window's engine-error count, and whether the session is still paused or recovered. An open recovery episode is reported as information, and as a gap only when a queued episode outlived the return grace or the marker cannot be read. * no-mistakes(review): Fix latch trip time, drop marker-age grace, bound error count * no-mistakes(review): Report paused latch without ledger trip row; bound errors * no-mistakes(review): Never report a failed probe's latch row as trip time * no-mistakes(review): Only a retained trip row marks a pre-window latch * no-mistakes(document): Clarify return-brief latch and watcher-gap documentation * no-mistakes(ci): Fixed Lint 1 by marking the shared cooldown constant as used by sourcing scripts. The repository lint command and diff check pass; the return test run was stopped by a 180-second timeout after its completed cases passed * no-mistakes(ci): Fixed the return brief so the trip time and error count come from the same initial latch row, and ledger rows before the current session’s lock boundary cannot affect its latch report. Added real-script regressions for both findings. The return test suite, repository lint, and diff check pass * no-mistakes(ci): Fixed the return brief’s restart cutoff so it retains in-window failures, prints one line per initial-trip row, and omits zero-error count wording. Added real-script restart regressions. The return test suite, ShellCheck, and diff check pass * no-mistakes(ci): Fixed the return brief so a recorded trip followed by recovery stays recovered, while a later pause with a lost trip append gets a separate “trip time unavailable” line. Added a real-script regression that failed before the fix. The return test suite, ShellCheck, syntax checks, and diff check pass * no-mistakes(ci): Fixed the false second latch during recovery. A real-script regression failed before the fix and passes now; the lost-second-trip test still passes. The return test suite, ShellCheck, syntax checks, and diff check pass --- bin/fm-afk-return.sh | 119 ++++++++++- bin/fm-supervision-engine-lib.sh | 5 + bin/fm-supervision-host.sh | 2 +- docs/supervision-host.md | 3 + docs/watcher-continuity.md | 1 + tests/fm-afk-return.test.sh | 347 +++++++++++++++++++++++++++++++ 6 files changed, 469 insertions(+), 8 deletions(-) diff --git a/bin/fm-afk-return.sh b/bin/fm-afk-return.sh index f6a7e25eeff..1504f5c65ab 100755 --- a/bin/fm-afk-return.sh +++ b/bin/fm-afk-return.sh @@ -21,7 +21,8 @@ # from the window whose summary opens with the "per your away instructions:" # marker the branch prompt in bin/fm-branch-prompt.sh requires), then what is # waiting on the captain, -# then what was tried and failed or could not be fixed, then landed work whose +# then what was tried and failed or could not be fixed (a supervision-host +# latch or engine errors inside the window lead it), then landed work whose # task record is still live (the recorded PR carries the # merge-notification marker bin/fm-pr-lib.sh owns, read from durable records # only, never the forge - finished work that owes an ordinary teardown, which @@ -321,16 +322,20 @@ return_guard() { # --- supervisor health, snapshotted before anything is shut down ------------ health_snapshot() { # - local evidence=$1 beat_age lines="" + local evidence=$1 beat_age state lines="" note="" beat_age=$(fm_path_age "$STATE/.last-watcher-beat") if [ -e "$STATE/.watcher-down" ]; then # The marker survives past its episode in an acked:* state - # (fm-wake-lib.sh _fm_recovery_marker_ack); only pending:* and - # announced:* mean the downtime is still open. A marker this read - # cannot parse is treated the same as an open gap, conservatively. + # (fm-wake-lib.sh _fm_recovery_marker_ack). An open handling episode is + # the ordinary state of a wake being handled at return + # (docs/watcher-continuity.md "Recovery episode acknowledgement"), so + # only an open downtime episode is a gap. A marker this read cannot + # parse is treated as a gap, conservatively. if fm_recovery_marker_snapshot "$STATE/.watcher-down"; then + state=${FM_RECOVERY_MARKER_TOKEN%:*} case "$FM_RECOVERY_MARKER_TOKEN" in acked:*) : ;; + pending:handling:*|announced:handling:*) note="a wake was being handled at return (recovery marker $state); not a gap" ;; *) lines="GAP: watcher downtime was detected during the away window (recovery marker present)" ;; esac else @@ -352,7 +357,99 @@ delivery wedged: $(head -1 "$STATE/.subsuper-inject-wedged" 2>/dev/null || true) if [ -z "$(printf '%s' "$lines" | tr -d '[:space:]')" ]; then lines="supervision ran through the away window with no detected gap (watcher beat ${beat_age}s old at return)" fi - append_evidence health "$lines" "$evidence" + append_evidence health "$lines +$note" "$evidence" +} + +# The supervision host's broken-session latch across the window, from its +# ledger (state/.supervision-host.log) and latch record +# (state/.supervision-host-health), both owned by bin/fm-supervision-host.sh. +# An engine error is a failed turn that exited nonzero or lacked a clean +# engine result, the latch's own definition. +engine_snapshot() { # + local evidence=$1 since=$2 summary errors trip last latch_errors cooldown recovered retry paused="" state line session_start lock_start sidecar_start count_clause episodes episode_count episode lost_trip="" + case "$since" in ''|*[!0-9]*) since=0 ;; esac + # shellcheck source=bin/fm-supervision-engine-lib.sh + . "$SCRIPT_DIR/fm-supervision-engine-lib.sh" || return 0 + # fm-session-start.sh acquires fm-lock.sh first. That writer refreshes .lock + # on takeover and replaces .lock-session on a session-id change, but leaves + # both untouched on same-session confirmation. Both contribute to the host key. + # shellcheck source=bin/fm-lock-lib.sh + . "$SCRIPT_DIR/fm-lock-lib.sh" || return 0 + lock_start=$(fm_lock_path_mtime "$STATE/.lock" 2>/dev/null) || lock_start=0 + sidecar_start=$(fm_lock_path_mtime "$STATE/.lock-session" 2>/dev/null) || sidecar_start=0 + session_start=$lock_start + [ "$sidecar_start" -le "$session_start" ] || session_start=$sidecar_start + summary=$(awk -F '\t' -v since="$since" -v session_start="$session_start" -v base="${FM_SUPERVISION_HOST_COOLDOWN}s" ' + $1 !~ /^[0-9]+$/ || ($1 < since && $1 < session_start) { next } + $1 >= since && $2 == "failed" && ($5 != "rc=0" || $8 !~ /^error=0/) { errors++ } + $2 == "latch" { + sub(/^errors=/, "", $3); sub(/^cooldown=/, "", $4) + if ($4 == base) { + first = $1; trip = $1; recovered = "" + if ($1 >= since) { n++; trips[n] = $1; counts[n] = $3 } + } else if (!first || recovered != "") { first = $1; trip = ""; recovered = "" } + last = $1; cooldown = $4 + if (n) cools[n] = $4 + } + $2 == "recovered" && first { recovered = $1 } + END { + printf "%d|%s|%s|%s|%s|%d\n", errors, trip, last, cooldown, recovered, n + for (i = 1; i <= n; i++) printf "%s|%s|%s\n", trips[i], counts[i], cools[i] + } + ' "$STATE/.supervision-host.log" 2>/dev/null) || summary= + episodes=${summary#*$'\n'} + IFS='|' read -r errors trip last cooldown recovered episode_count </dev/null)" \ + && retry=$(fm_supervision_host_paused_until "$STATE") \ + && { [ -z "$recovered" ] || [ "$retry" -gt "$recovered" ]; }; then + paused=1 + if [ "$(date +%s)" -lt "$retry" ]; then + state="still paused at return: every wake reaches main until $(epoch_to_iso "$retry"), then one wake probes the engine again" + else + state="still paused at return: its cooldown has ended, so the next wake probes the engine again" + fi + elif [ -n "$recovered" ]; then + state="it recovered at $(epoch_to_iso "$recovered") after a successful probe" + else + state="not paused at return" + fi + count_clause="" + [ "${errors:-0}" -eq 0 ] || count_clause="at least $errors engine error(s) in the window, " + if [ "${episode_count:-0}" -gt 0 ]; then + episode=0 + while IFS='|' read -r trip latch_errors cooldown; do + episode=$((episode + 1)) + line="the supervision session latched at $(epoch_to_iso "$trip") after $latch_errors consecutive engine errors and paused away supervision (${count_clause}last cooldown $cooldown)" + if [ "$episode" -eq "$episode_count" ]; then + if [ -n "$paused" ] && [ -n "$recovered" ] && [ "$recovered" -ge "$trip" ]; then + line="$line; it recovered at $(epoch_to_iso "$recovered") after a successful probe" + lost_trip=1 + else + line="$line; $state" + fi + fi + append_evidence engine "$line" "$evidence" + done </dev/null || health_snapshot "$evidence" + if ! grep -q "^evidence$(printf '\t')health$(printf '\t')" "$evidence" 2>/dev/null; then + health_snapshot "$evidence" + engine_snapshot "$evidence" "$since" + fi while IFS="$(printf '\t')" read -r tag kind text; do [ "$tag" = evidence ] && [ "$kind" = lifecycle ] || continue diff --git a/bin/fm-supervision-engine-lib.sh b/bin/fm-supervision-engine-lib.sh index 69c442959b8..180094ea14d 100644 --- a/bin/fm-supervision-engine-lib.sh +++ b/bin/fm-supervision-engine-lib.sh @@ -168,6 +168,11 @@ fm_supervision_host_health_key() { printf '%s|%s|%s\n' "$key" "$FM_SUPERVISION_ENGINE" "$FM_SUPERVISION_ENGINE_MODEL" } +# The latch's first cooldown in seconds: the host's initial trip sets it, and +# each failed probe after that doubles it. +# shellcheck disable=SC2034 # Shared with the sourcing host and return brief. +FM_SUPERVISION_HOST_COOLDOWN=300 + # fm_supervision_host_paused_until : while that latch holds, from # the trip until a probe succeeds, print the epoch from which the next wake # probes the engine (every wake before it reaches main) and succeed; otherwise diff --git a/bin/fm-supervision-host.sh b/bin/fm-supervision-host.sh index 1638f2f12fb..876c4d75b8f 100755 --- a/bin/fm-supervision-host.sh +++ b/bin/fm-supervision-host.sh @@ -203,7 +203,7 @@ TURN_TIMEOUT=$(numeric_or "${FM_SUPERVISION_HOST_TURN_TIMEOUT:-}" 1200) ROTATE_TURNS=$(numeric_or "${FM_SUPERVISION_HOST_ROTATE_TURNS:-}" 20) READY_TIMEOUT=$(numeric_or "${FM_SUPERVISION_HOST_READY_TIMEOUT:-}" 25) POLL=$(numeric_or "${FM_SUPERVISION_HOST_POLL:-}" 1) -COOLDOWN=300 +COOLDOWN=$FM_SUPERVISION_HOST_COOLDOWN COOLDOWN_MAX=3600 AUTOARM_GEN=${FM_SUPERVISION_HOST_AUTOARM_GEN:-} AUTOARM_OWNER=${FM_SUPERVISION_HOST_OWNER_PID:-} diff --git a/docs/supervision-host.md b/docs/supervision-host.md index 3c29daf1ac1..786ddcabf1f 100644 --- a/docs/supervision-host.md +++ b/docs/supervision-host.md @@ -251,6 +251,9 @@ The host copies the Pi branch's broken-session policy ([pi-supervision-branch.md Two consecutive engine errors latch the session: every wake reaches main for a five-minute cooldown, the attended close unchanged and the away close with a `supervision-host:` line, after which one wake probes the engine, and each probe that ends in another engine error doubles the cooldown up to one hour. A turn that records a report without an engine error clears the latch; a turn with a complete engine result but no report neither counts toward it nor clears it, while an engine error counts even if no report was recorded. The first trip adds one `supervision-host:` line to the failing turn's handback; a recovery is only recorded in the host ledger, so a routine probe stays off main. +The away return brief (`bin/fm-afk-return.sh`) reports engine errors in the window and any latch visible at return, using a lower bound for the window's error count because the host ledger is bounded. +It names the trip time only when the ledger retains the initial-trip row: a failed-probe row cannot establish that time or prove the latch predated the window, and a paused latch with no initial-trip row is reported with "trip time unavailable" even if the ledger is missing. +The brief also says whether the latch is still paused or has recovered. The latch belongs to one main session, engine, and model, so a new main session or another engine or model starts clean. ### Lost ownership diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index be5c1150edb..5d452bf49da 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -207,6 +207,7 @@ In its `--claude` mode it cooperates with the auto-arm. A recovery episode is one generation of the `state/.watcher-down` marker. It is retired only by the generation-bound acknowledgement the drain prints as `WAKE_ACK_REQUIRED`. +The away return brief treats a still-open handling episode as a wake in progress, not watcher downtime; an open downtime episode remains a gap. ### Announcement diff --git a/tests/fm-afk-return.test.sh b/tests/fm-afk-return.test.sh index 309626e4580..b66ead8c0da 100755 --- a/tests/fm-afk-return.test.sh +++ b/tests/fm-afk-return.test.sh @@ -24,6 +24,7 @@ install_runner() { # mkdir -p "$dir/bin" "$dir/home/state" "$dir/home/data" "$dir/home/config" cp "$ROOT/bin/fm-afk-return.sh" "$dir/bin/" cp "$ROOT/bin/fm-wake-lib.sh" "$dir/bin/" + cp "$ROOT/bin/fm-lock-lib.sh" "$dir/bin/" cp "$ROOT/bin/fm-path-lib.sh" "$dir/bin/" cp "$ROOT/bin/fm-classify-lib.sh" "$dir/bin/" # fm-timeout-lib.sh: the shared hard bound fm-classify-lib.sh sources for the @@ -929,6 +930,345 @@ test_return_brief_does_not_report_an_acked_watcher_down_marker_as_a_gap() { pass "the return brief does not report an already-acked watcher-down marker as an open gap" } +test_return_brief_reports_only_an_open_downtime_episode_as_a_gap() { + local dir out token + # A wake mid-handling is the ordinary open episode at a return during + # supervision (3b live validation F6), so it is information, not a gap; an + # open downtime episode is still a gap. + for token in announced:handling pending:handling pending:downtime announced:downtime; do + dir="$TMP_ROOT/brief-open-marker-${token%%:*}-${token#*:}" + install_runner "$dir" + contract_in "$dir" enter >/dev/null 2>&1 || fail "could not write the away-posture record" + printf '%s:fixture-generation\n' "$token" > "$dir/home/state/.watcher-down" + touch "$dir/home/state/.last-watcher-beat" + : > "$dir/home/state/.fake-drain" + out=$(run_return "$dir" begin) || fail "$token: a clean fleet with an open episode should clear the gate: $out" + case "$token" in + *:handling) + assert_not_contains "$out" 'GAP:' "$token: a wake mid-handling was reported as a gap" + assert_contains "$out" 'no detected gap' "$token: a wake mid-handling hid the clean health line" + assert_contains "$out" "a wake was being handled at return (recovery marker $token); not a gap" "$token: the handling state was not reported as information" ;; + *) + assert_contains "$out" 'GAP: watcher downtime was detected during the away window (recovery marker present)' "$token: an open downtime episode was not reported as a gap" + assert_not_contains "$out" 'no detected gap' "$token: an open downtime episode was reported as clean" ;; + esac + done + pass "the return brief reports a wake mid-handling at return as information, and only an open downtime episode as a gap" +} + +# A host home whose ledger and latch record carry the given lines, with a live +# main-session lock so the latch record's key is the current one. +seed_host_latch() { # + local dir=$1 key f + for f in fm-supervision-engine-lib.sh fm-harness.sh fm-cursor-lib.sh fm-gemini-lib.sh; do + cp "$ROOT/bin/$f" "$dir/bin/" + done + printf 'claude sonnet\n' > "$dir/home/config/supervision-host" + # The test shell itself holds the lock: live for the whole case, nothing to reap. + printf '%s\n' "$$" > "$dir/home/state/.lock" + printf 'lab-session\n' > "$dir/home/state/.lock-session" + # The simulated session predates the window and its pre-window ledger rows. + TZ=UTC touch -t "$(date -u -r "$(( $(date +%s) - 7200 ))" +%Y%m%d%H%M.%S 2>/dev/null || date -u -d "@$(($(date +%s) - 7200))" +%Y%m%d%H%M.%S)" \ + "$dir/home/state/.lock" "$dir/home/state/.lock-session" + # shellcheck disable=SC2016 # expands in the child shell + key=$(FM_HOME="$dir/home" bash -c '. "$1/fm-wake-lib.sh" && . "$1/fm-supervision-engine-lib.sh" \ + && fm_supervision_host_config "$2" claude && fm_supervision_host_health_key "$3"' _ \ + "$dir/bin" "$dir/home/config" "$dir/home/state") || fail "could not compute the latch key" + printf 'key=%s\nerrors=%s\ncooldown=%s\nretry_after=%s\n' "$key" "$2" "$3" "$4" > "$dir/home/state/.supervision-host-health" + printf '%s\n' "$5" > "$dir/home/state/.supervision-host.log" +} + +test_return_brief_reports_an_engine_latch_in_the_window() { + local dir out now before tab section retry + tab=$(printf '\t') + dir="$TMP_ROOT/brief-engine-latch" + install_runner "$dir" + contract_in "$dir" enter >/dev/null 2>&1 || fail "could not write the away-posture record" + now=$(date +%s) + before=$((now - 3600)) + retry=$((now + 300)) + seed_host_latch "$dir" 2 300 "$retry" "$before${tab}failed${tab}turn=old.1${tab}posture=attended${tab}rc=1${tab}reports=0${tab}unacked=1${tab}error=1 cost=0${tab}boom${tab}signal: before +$now${tab}handled${tab}turn=t.1${tab}posture=away${tab}rc=0${tab}reports=1${tab}error=0 cost=0.1${tab}signal: a +$now${tab}failed${tab}turn=t.2${tab}posture=away${tab}rc=0${tab}reports=0${tab}unacked=none${tab}error=0 cost=0.1${tab}${tab}signal: no report, not an engine error +$now${tab}failed${tab}turn=t.3${tab}posture=away${tab}rc=1${tab}reports=0${tab}unacked=3${tab}error=1 cost=0${tab}[unrecognized_model]${tab}signal: b +$now${tab}latch${tab}errors=2${tab}cooldown=300s +$now${tab}failed${tab}turn=t.4${tab}posture=away${tab}rc=1${tab}reports=0${tab}unacked=4${tab}no-result${tab}[unrecognized_model]${tab}signal: c +$now${tab}to-main${tab}the away session could not take this wake: the engine turn failed (exit 1); this wake is yours" + touch "$dir/home/state/.last-watcher-beat" + : > "$dir/home/state/.fake-drain" + out=$(FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" FM_CONFIG_OVERRIDE="$dir/home/config" \ + "$dir/bin/fm-afk-return.sh" begin 2>&1) || fail "a latch with no blocker should not hold the gate: $out" + section=$(printf '%s\n' "$out" | sed -n '/^Tried and failed, or could not be fixed:$/,/^Landed, cleanup due:$/p') + assert_contains "$section" " - the supervision session latched at $(date -u -r "$now" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u -d "@$now" '+%Y-%m-%dT%H:%M:%SZ') after 2 consecutive engine errors and paused away supervision (at least 2 engine error(s) in the window, last cooldown 300s)" \ + "the failures section did not name the latch, its time, and the window's engine errors" + assert_contains "$section" "still paused at return: every wake reaches main until $(date -u -r "$retry" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u -d "@$retry" '+%Y-%m-%dT%H:%M:%SZ')" \ + "the failures section did not name the cooldown state" + assert_not_contains "$section" '(nothing)' "a latched window reported no failures" + + # The latch cleared by a probe inside the window reads as recovered, and + # engine errors without a trip are still reported. + dir="$TMP_ROOT/brief-engine-recovered" + install_runner "$dir" + contract_in "$dir" enter >/dev/null 2>&1 || fail "could not write the away-posture record" + now=$(date +%s) + seed_host_latch "$dir" 0 0 0 "$now${tab}latch${tab}errors=2${tab}cooldown=300s +$now${tab}recovered${tab}after a successful probe" + touch "$dir/home/state/.last-watcher-beat" + : > "$dir/home/state/.fake-drain" + out=$(FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" FM_CONFIG_OVERRIDE="$dir/home/config" \ + "$dir/bin/fm-afk-return.sh" begin 2>&1) || fail "a recovered latch should not hold the gate: $out" + assert_contains "$out" 'it recovered at ' "a latch cleared inside the window was not reported as recovered" + + # A second trip after a recovery is the episode the brief describes, and a + # failed probe inside it keeps that episode's trip time. + dir="$TMP_ROOT/brief-engine-relatched" + install_runner "$dir" + contract_in "$dir" enter >/dev/null 2>&1 || fail "could not write the away-posture record" + now=$(date +%s) + seed_host_latch "$dir" 3 600 "$((now + 600))" "$now${tab}latch${tab}errors=2${tab}cooldown=300s +$now${tab}recovered${tab}after a successful probe +$((now + 60))${tab}latch${tab}errors=2${tab}cooldown=300s +$((now + 120))${tab}latch${tab}errors=3${tab}cooldown=600s" + touch "$dir/home/state/.last-watcher-beat" + : > "$dir/home/state/.fake-drain" + out=$(FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" FM_CONFIG_OVERRIDE="$dir/home/config" \ + "$dir/bin/fm-afk-return.sh" begin 2>&1) || fail "a second latch with no blocker should not hold the gate: $out" + assert_contains "$out" " - the supervision session latched at $(date -u -r "$((now + 60))" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u -d "@$((now + 60))" '+%Y-%m-%dT%H:%M:%SZ') after 2 consecutive engine errors and paused away supervision (last cooldown 600s); still paused at return" \ + "a second latch after a recovery was not reported with its own trip-row error count" + + # A latch from before the window whose cooldown has ended still holds until + # a probe succeeds. + dir="$TMP_ROOT/brief-engine-cooled" + install_runner "$dir" + contract_in "$dir" enter >/dev/null 2>&1 || fail "could not write the away-posture record" + seed_host_latch "$dir" 2 300 1 "$((now - 3600))${tab}latch${tab}errors=2${tab}cooldown=300s" + touch "$dir/home/state/.last-watcher-beat" + : > "$dir/home/state/.fake-drain" + out=$(FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" FM_CONFIG_OVERRIDE="$dir/home/config" \ + "$dir/bin/fm-afk-return.sh" begin 2>&1) || fail "a cooled latch should not hold the gate: $out" + assert_contains "$out" ' - the supervision session was already latched after engine errors when the window began; still paused at return: its cooldown has ended, so the next wake probes the engine again' \ + "a latch held past its cooldown was not reported with its probe state" + + dir="$TMP_ROOT/brief-engine-errors" + install_runner "$dir" + contract_in "$dir" enter >/dev/null 2>&1 || fail "could not write the away-posture record" + now=$(date +%s) + seed_host_latch "$dir" 1 0 0 "$now${tab}failed${tab}turn=t.1${tab}posture=away${tab}rc=124${tab}reports=0${tab}unacked=2${tab}no-result${tab}${tab}signal: a" + touch "$dir/home/state/.last-watcher-beat" + : > "$dir/home/state/.fake-drain" + out=$(FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" FM_CONFIG_OVERRIDE="$dir/home/config" \ + "$dir/bin/fm-afk-return.sh" begin 2>&1) || fail "engine errors with no blocker should not hold the gate: $out" + assert_contains "$out" ' - at least 1 supervision engine turn(s) ended in an engine error during the away window without latching; not paused at return' \ + "engine errors that did not latch were not reported" + + # A paused latch record whose trip row the bounded ledger no longer holds, + # or whose ledger is missing, is still a failure, named without a trip time. + dir="$TMP_ROOT/brief-engine-trimmed" + install_runner "$dir" + contract_in "$dir" enter >/dev/null 2>&1 || fail "could not write the away-posture record" + now=$(date +%s) + seed_host_latch "$dir" 3 600 "$((now + 600))" "$now${tab}failed${tab}turn=t.9${tab}posture=away${tab}rc=1${tab}reports=0${tab}unacked=2${tab}error=1 cost=0${tab}boom${tab}signal: a" + touch "$dir/home/state/.last-watcher-beat" + : > "$dir/home/state/.fake-drain" + out=$(FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" FM_CONFIG_OVERRIDE="$dir/home/config" \ + "$dir/bin/fm-afk-return.sh" begin 2>&1) || fail "a trimmed latch with no blocker should not hold the gate: $out" + assert_contains "$out" ' - the supervision session latched after engine errors and paused away supervision (trip time unavailable, at least 1 engine error(s) in the window); still paused at return: every wake reaches main until ' \ + "a paused latch whose trip row was trimmed was not reported" + + # A failed probe's latch row is not the trip: with the trip row gone, its + # time is never reported as when the session latched. + dir="$TMP_ROOT/brief-engine-probe-only" + install_runner "$dir" + contract_in "$dir" enter >/dev/null 2>&1 || fail "could not write the away-posture record" + now=$(date +%s) + seed_host_latch "$dir" 3 600 "$((now + 600))" "$now${tab}failed${tab}turn=t.9${tab}posture=away${tab}rc=1${tab}reports=0${tab}unacked=2${tab}error=1 cost=0${tab}boom${tab}signal: a +$now${tab}latch${tab}errors=3${tab}cooldown=600s" + touch "$dir/home/state/.last-watcher-beat" + : > "$dir/home/state/.fake-drain" + out=$(FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" FM_CONFIG_OVERRIDE="$dir/home/config" \ + "$dir/bin/fm-afk-return.sh" begin 2>&1) || fail "a probe-only latch with no blocker should not hold the gate: $out" + assert_contains "$out" ' - the supervision session latched after engine errors and paused away supervision (trip time unavailable, at least 1 engine error(s) in the window); still paused at return: every wake reaches main until ' \ + "a paused latch whose ledger holds only a probe row was not reported without a trip time" + assert_not_contains "$out" 'the supervision session latched at ' "a failed probe's time was reported as the trip time" + + # A failed probe's row from before the window does not prove when the latch + # tripped, so the latch is not called already in effect. + dir="$TMP_ROOT/brief-engine-probe-before" + install_runner "$dir" + contract_in "$dir" enter >/dev/null 2>&1 || fail "could not write the away-posture record" + seed_host_latch "$dir" 3 600 "$((now + 600))" "$((now - 3600))${tab}latch${tab}errors=3${tab}cooldown=600s" + touch "$dir/home/state/.last-watcher-beat" + : > "$dir/home/state/.fake-drain" + out=$(FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" FM_CONFIG_OVERRIDE="$dir/home/config" \ + "$dir/bin/fm-afk-return.sh" begin 2>&1) || fail "a pre-window probe-only latch with no blocker should not hold the gate: $out" + assert_contains "$out" ' - the supervision session latched after engine errors and paused away supervision (trip time unavailable); still paused at return' \ + "a paused latch whose ledger holds only a pre-window probe row was not reported without a trip time" + assert_not_contains "$out" 'already latched' "a pre-window probe row was taken as a pre-existing trip" + + dir="$TMP_ROOT/brief-engine-no-ledger" + install_runner "$dir" + contract_in "$dir" enter >/dev/null 2>&1 || fail "could not write the away-posture record" + seed_host_latch "$dir" 2 300 "$((now + 300))" "" + rm -f "$dir/home/state/.supervision-host.log" + touch "$dir/home/state/.last-watcher-beat" + : > "$dir/home/state/.fake-drain" + out=$(FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" FM_CONFIG_OVERRIDE="$dir/home/config" \ + "$dir/bin/fm-afk-return.sh" begin 2>&1) || fail "a latch with no ledger and no blocker should not hold the gate: $out" + section=$(printf '%s\n' "$out" | sed -n '/^Tried and failed, or could not be fixed:$/,/^Landed, cleanup due:$/p') + assert_contains "$section" ' - the supervision session latched after engine errors and paused away supervision (trip time unavailable); still paused at return' \ + "a paused latch with no host ledger was not reported" + assert_not_contains "$section" '(nothing)' "a paused latch with no host ledger reported no failures" + pass "the return brief's failures section names an engine latch inside the away window with its time, error count, and cooldown state" +} + +test_return_brief_keeps_recovered_trip_when_next_append_is_lost() { + local dir out now first recovered tab section first_iso recovered_iso + dir="$TMP_ROOT/brief-lost-second-trip" + tab=$(printf '\t') + install_runner "$dir" + now=$(date +%s) + printf '%s\n' "$((now - 120))" > "$dir/home/state/.afk" + first=$((now - 60)) + recovered=$((now - 30)) + seed_host_latch "$dir" 2 300 "$((now + 300))" "$first${tab}latch${tab}errors=2${tab}cooldown=300s +$recovered${tab}recovered${tab}after a successful probe" + touch "$dir/home/state/.last-watcher-beat" + : > "$dir/home/state/.fake-drain" + out=$(FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" FM_CONFIG_OVERRIDE="$dir/home/config" \ + "$dir/bin/fm-afk-return.sh" begin 2>&1) || fail "a lost second trip append should not hold the gate: $out" + section=$(printf '%s\n' "$out" | sed -n '/^Tried and failed, or could not be fixed:$/,/^Landed, cleanup due:$/p') + first_iso=$(date -u -r "$first" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d "@$first" +%Y-%m-%dT%H:%M:%SZ) + recovered_iso=$(date -u -r "$recovered" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d "@$recovered" +%Y-%m-%dT%H:%M:%SZ) + assert_contains "$section" " - the supervision session latched at $first_iso after 2 consecutive engine errors and paused away supervision (last cooldown 300s); it recovered at $recovered_iso after a successful probe" \ + "the recorded trip was not kept as recovered" + assert_contains "$section" ' - the supervision session latched after engine errors and paused away supervision (trip time unavailable); still paused at return' \ + "the current pause was not reported separately without a trip time" + [ "$(printf '%s\n' "$section" | grep -c 'still paused at return')" -eq 1 ] || fail "the earlier trip was incorrectly marked paused: $section" + pass "a lost second trip append does not attach the current pause to a recovered episode" +} + +test_return_brief_does_not_invent_a_trip_while_recovery_is_being_saved() { + local dir out now first recovered tab section first_iso recovered_iso + dir="$TMP_ROOT/brief-recovery-save-interleaving" + tab=$(printf '\t') + install_runner "$dir" + now=$(date +%s) + printf '%s\n' "$((now - 120))" > "$dir/home/state/.afk" + first=$((now - 60)) + recovered=$((now - 30)) + seed_host_latch "$dir" 2 300 "$((recovered - 1))" "$first${tab}latch${tab}errors=2${tab}cooldown=300s +$recovered${tab}recovered${tab}after a successful probe" + touch "$dir/home/state/.last-watcher-beat" + : > "$dir/home/state/.fake-drain" + out=$(FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" FM_CONFIG_OVERRIDE="$dir/home/config" \ + "$dir/bin/fm-afk-return.sh" begin 2>&1) || fail "a recovery being saved should not hold the gate: $out" + section=$(printf '%s\n' "$out" | sed -n '/^Tried and failed, or could not be fixed:$/,/^Landed, cleanup due:$/p') + first_iso=$(date -u -r "$first" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d "@$first" +%Y-%m-%dT%H:%M:%SZ) + recovered_iso=$(date -u -r "$recovered" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d "@$recovered" +%Y-%m-%dT%H:%M:%SZ) + [ "$(printf '%s\n' "$section" | grep -c ' - the supervision session latched')" -eq 1 ] \ + || fail "a recovery before health_save invented another latch: $section" + assert_contains "$section" " - the supervision session latched at $first_iso after 2 consecutive engine errors and paused away supervision (last cooldown 300s); it recovered at $recovered_iso after a successful probe" \ + "the recovered trip was not reported as the only latch" + assert_not_contains "$section" 'trip time unavailable' "a recovery before health_save was reported as a new trip" + assert_not_contains "$section" 'still paused at return' "a recovered episode was reported as paused" + pass "a recovered row preceding the stale retry time does not invent a second trip" +} + +test_return_brief_keeps_trip_row_count_after_probe() { + local dir out now tab + dir="$TMP_ROOT/brief-trip-count" + tab=$(printf '\t') + install_runner "$dir" + contract_in "$dir" enter >/dev/null 2>&1 || fail "could not write the away-posture record" + now=$(date +%s) + seed_host_latch "$dir" 3 600 "$((now + 600))" "$now${tab}latch${tab}errors=2${tab}cooldown=300s +$((now + 1))${tab}latch${tab}errors=3${tab}cooldown=600s" + touch "$dir/home/state/.last-watcher-beat" + : > "$dir/home/state/.fake-drain" + out=$(FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" FM_CONFIG_OVERRIDE="$dir/home/config" \ + "$dir/bin/fm-afk-return.sh" begin 2>&1) || fail "a probed latch should not hold the gate: $out" + assert_contains "$out" 'after 2 consecutive engine errors and paused away supervision (last cooldown 600s)' \ + "the failed probe replaced the trip row's error count" + assert_not_contains "$out" 'after 3 consecutive engine errors' "the failed probe was counted as the original trip" + pass "a later failed probe does not change the trip-row error count" +} + +test_return_brief_ignores_previous_main_session() { + local dir out now old tab boundary + dir="$TMP_ROOT/brief-session-boundary" + tab=$(printf '\t') + install_runner "$dir" + contract_in "$dir" enter >/dev/null 2>&1 || fail "could not write the away-posture record" + now=$(date +%s) + old=$((now - 3600)) + seed_host_latch "$dir" 3 600 "$((now + 600))" "$old${tab}latch${tab}errors=2${tab}cooldown=300s +$((now + 1))${tab}latch${tab}errors=3${tab}cooldown=600s" + boundary=$((now - 60)) + TZ=UTC touch -t "$(date -u -r "$boundary" +%Y%m%d%H%M.%S 2>/dev/null || date -u -d "@$boundary" +%Y%m%d%H%M.%S)" \ + "$dir/home/state/.lock" "$dir/home/state/.lock-session" + touch "$dir/home/state/.last-watcher-beat" + : > "$dir/home/state/.fake-drain" + out=$(FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" FM_CONFIG_OVERRIDE="$dir/home/config" \ + "$dir/bin/fm-afk-return.sh" begin 2>&1) || fail "a cross-session latch should not hold the gate: $out" + assert_contains "$out" 'trip time unavailable' \ + "the current session's probe was combined with an old session's trip" + assert_not_contains "$out" 'the supervision session latched at ' "an old session's trip time leaked into the brief" + assert_not_contains "$out" 'already latched' "an old session's trip was called current" + pass "the return brief excludes prior-session latch rows" +} + +test_return_brief_keeps_in_window_history_across_main_restart() { + local dir out now since first second boundary tab section + tab=$(printf '\t') + now=$(date +%s) + since=$((now - 180)) + first=$((now - 120)) + second=$((now - 30)) + boundary=$((now - 60)) + for scenario in one two errors; do + dir="$TMP_ROOT/brief-restart-$scenario" + install_runner "$dir" + printf '%s\n' "$since" > "$dir/home/state/.afk" + case "$scenario" in + one) + seed_host_latch "$dir" 0 0 0 "$first${tab}latch${tab}errors=2${tab}cooldown=300s" ;; + two) + seed_host_latch "$dir" 2 300 "$((now + 300))" "$first${tab}latch${tab}errors=2${tab}cooldown=300s +$((first + 1))${tab}recovered${tab}after a successful probe +$second${tab}latch${tab}errors=3${tab}cooldown=300s" ;; + errors) + seed_host_latch "$dir" 0 0 0 "$first${tab}failed${tab}turn=t.1${tab}posture=away${tab}rc=1${tab}reports=0${tab}unacked=1${tab}error=1 cost=0${tab}boom${tab}signal: a" ;; + esac + TZ=UTC touch -t "$(date -u -r "$boundary" +%Y%m%d%H%M.%S 2>/dev/null || date -u -d "@$boundary" +%Y%m%d%H%M.%S)" \ + "$dir/home/state/.lock" "$dir/home/state/.lock-session" + touch "$dir/home/state/.last-watcher-beat" + : > "$dir/home/state/.fake-drain" + out=$(FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" FM_CONFIG_OVERRIDE="$dir/home/config" \ + "$dir/bin/fm-afk-return.sh" begin 2>&1) || fail "$scenario: return should clear: $out" + section=$(printf '%s\n' "$out" | sed -n '/^Tried and failed, or could not be fixed:$/,/^Landed, cleanup due:$/p') + case "$scenario" in + one) + assert_contains "$section" "latched at $(date -u -r "$first" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d "@$first" +%Y-%m-%dT%H:%M:%SZ) after 2 consecutive engine errors" \ + "a trip before the main restart disappeared" + assert_not_contains "$section" '(nothing)' "the first trip was lost" ;; + two) + [ "$(printf '%s\n' "$section" | grep -c ' - the supervision session latched at ')" -eq 2 ] || fail "both in-window trips must have their own line: $section" + assert_contains "$section" "latched at $(date -u -r "$first" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d "@$first" +%Y-%m-%dT%H:%M:%SZ) after 2 consecutive engine errors" \ + "the earlier trip or its count disappeared" + assert_contains "$section" "latched at $(date -u -r "$second" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d "@$second" +%Y-%m-%dT%H:%M:%SZ) after 3 consecutive engine errors" \ + "the later trip or its count disappeared" + [ "$(printf '%s\n' "$section" | grep -c 'still paused at return')" -eq 1 ] || fail "return pause must attach only once: $section" + assert_contains "$section" "after 3 consecutive engine errors and paused away supervision (last cooldown 300s); still paused at return" \ + "return pause did not attach to the last episode" ;; + errors) + assert_contains "$section" 'at least 1 supervision engine turn(s) ended in an engine error during the away window without latching' \ + "the pre-restart failed turn was not counted" ;; + esac + assert_not_contains "$section" 'at least 0 engine error(s)' "a zero error count was printed" + done + pass "the return brief retains in-window trips and failed turns across a main restart" +} + test_return_brief_without_a_record_reports_the_legacy_flag() { local dir out dir="$TMP_ROOT/brief-legacy" @@ -1037,4 +1377,11 @@ test_statusful_leftover_record_lets_catchup_clear test_return_guard_refuses_while_the_record_exists test_return_brief_health_leads_with_a_gap test_return_brief_does_not_report_an_acked_watcher_down_marker_as_a_gap +test_return_brief_reports_only_an_open_downtime_episode_as_a_gap +test_return_brief_reports_an_engine_latch_in_the_window +test_return_brief_keeps_recovered_trip_when_next_append_is_lost +test_return_brief_does_not_invent_a_trip_while_recovery_is_being_saved +test_return_brief_keeps_trip_row_count_after_probe +test_return_brief_ignores_previous_main_session +test_return_brief_keeps_in_window_history_across_main_restart test_return_brief_without_a_record_reports_the_legacy_flag From 40e981daee4ec2b5f5290575b5e0793a09bda3f9 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:00:52 -0700 Subject: [PATCH 03/76] fix: shorten Claude Code Calm supervision note label (#6086) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(calm): name the Claude Code Calm plugin fm so supervision notes read "fm: " Claude Code labels every mod transcript line with the plugin name, so the notes rendered as "firstmate-calm: ⚓ ...". Rename the plugin to fm, update the live guard to assert the fm: label, and document the one-time replay for sessions resumed across the rename. * no-mistakes(document): Clarify Calm plugin rename in documentation --- .../firstmate-calm/.claude-plugin/plugin.json | 2 +- .claude/mods/firstmate-calm/hooks/register.ts | 2 +- docs/calm-mode-feasibility.md | 28 ++++++++++++++-- docs/calm.md | 13 ++++---- tests/fm-calm-claude-mod-live-e2e.test.sh | 32 +++++++++++-------- tests/fm-calm-claude-mod.test.sh | 2 +- 6 files changed, 54 insertions(+), 25 deletions(-) diff --git a/.claude/mods/firstmate-calm/.claude-plugin/plugin.json b/.claude/mods/firstmate-calm/.claude-plugin/plugin.json index 710bcbb74e2..d9d81a63ca1 100644 --- a/.claude/mods/firstmate-calm/.claude-plugin/plugin.json +++ b/.claude/mods/firstmate-calm/.claude-plugin/plugin.json @@ -1,5 +1,5 @@ { - "name": "firstmate-calm", + "name": "fm", "version": "1.0.0", "description": "Firstmate Calm for Claude Code: the sailboat working animation and conversation-only transcript presentation, sharing the per-home config/calm preference with the Pi Calm extension. Its hooks module may load through CLAUDE_CODE_ENABLE_FUNCTION_HOOKS or Claude Code's tengu_plugin_hooks_modules rollout flag, but the mod activates only when CLAUDE_CODE_ENABLE_FUNCTION_HOOKS is exactly 1 and is otherwise a complete no-op.", "author": { diff --git a/.claude/mods/firstmate-calm/hooks/register.ts b/.claude/mods/firstmate-calm/hooks/register.ts index dca78936a37..907ba188345 100644 --- a/.claude/mods/firstmate-calm/hooks/register.ts +++ b/.claude/mods/firstmate-calm/hooks/register.ts @@ -1,4 +1,4 @@ -// Firstmate Calm for Claude Code: the hooks module of the `firstmate-calm` mod. +// Firstmate Calm for Claude Code: the hooks module of the Calm mod, whose plugin name is `fm`. // // A Claude Code "mod" is a plugin whose behavior lives in one hooks module. Claude Code // may load this module through its rollout flag or `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS`, diff --git a/docs/calm-mode-feasibility.md b/docs/calm-mode-feasibility.md index f265df126c4..1596dcaa954 100644 --- a/docs/calm-mode-feasibility.md +++ b/docs/calm-mode-feasibility.md @@ -267,7 +267,7 @@ grok 0.2.106 (bde89716f679) | Harness | Conclusion | Evidence | | --- | --- | --- | -| Claude Code 2.1.272 (superseding the 2.1.218 row, which found no transcript-row renderer in project hooks or the plugin CLI) | Feasible through the early-access Claude Code mods surface (function hooks), default-off behind `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS`, and shipped as the `firstmate-calm` mod. | A `ui.render` hook draws per-component transcript rows and the working row, `$.ui.invalidate` redraws the transcript, and `$.ui.blit` animates a `Raster`; the [2026-09-15 record](#2026-09-15-claude-code-21272-mods-feasibility-and-the-shipped-mod) owns the spike-verified working animation, gapless hiding and retroactive redraw of tool, narration, and operational rows, the persisted per-home toggle, and the three bounded gaps: an early-access API that may change, main-screen scrollback keeping pre-toggle copies, and 256-color Raster paint. | +| Claude Code 2.1.272 (superseding the 2.1.218 row, which found no transcript-row renderer in project hooks or the plugin CLI) | Feasible through the early-access Claude Code mods surface (function hooks), default-off behind `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS`, and initially shipped with the plugin name `firstmate-calm` (now `fm`; see [`calm.md`](calm.md#the-calm-mod)). | A `ui.render` hook draws per-component transcript rows and the working row, `$.ui.invalidate` redraws the transcript, and `$.ui.blit` animates a `Raster`; the [2026-09-15 record](#2026-09-15-claude-code-21272-mods-feasibility-and-the-shipped-mod) owns the spike-verified working animation, gapless hiding and retroactive redraw of tool, narration, and operational rows, the persisted per-home toggle, and the three bounded gaps: an early-access API that may change, main-screen scrollback keeping pre-toggle copies, and 256-color Raster paint. | | Codex CLI 0.144.6 | Not feasible through the inspected supported project surface. | The tracked hooks expose session, pre-tool, and stop handling, while the plugin and feature inventories expose no TUI tool-row renderer or transcript redraw control. | | OpenCode 1.17.18 | Not feasible without violating the preservation boundary. | Plugins expose events and tool execution hooks, not a built-in transcript-row renderer; same-name tool replacement changes execution rather than presentation alone. | | Pi (verified 0.81.1 through 0.82.0) | Partially feasible with two API-probed exported-class adapters. | Public APIs control working visibility, collapsed labels, known tool slots, custom entries, and expansion redraws; exported assistant and interactive-mode classes provide the collapsed-thinking and operational-user layout boundaries, gated on the exact method's presence rather than a version number, while generic user, tool, and status filtering remains unavailable. | @@ -737,7 +737,7 @@ An escape-preserving capture of the boat from the spike, taken before the palett 2. On the main-screen (non-fullscreen) layout a toggle redraws the live screen by clearing and reprinting the whole conversation, and the terminal's own scrollback keeps the previous rendering above it; the fullscreen layout has no such stale copy. 3. The Raster paints RGB through a quantized palette, so the boat renders as 256-color escapes rather than Pi's standard 16-color ANSI codes. -Three further observations, recorded so they are not read as failures: the `ctrl+o` detailed transcript view keeps its per-message timestamp and model headers where hidden assistant rows sat, because those headers are not a render component; the `/calm` toggle's answer is a transient toast under the prompt (`firstmate-calm: Calm on`) that expires within a few seconds and never becomes a transcript row; and the engine logs one benign debug-level warning at load, `options requested but its manifest declares no userConfig`, for every hooks module whose manifest declares no configuration fields, which an empty `userConfig` object does not silence. +Three further observations, recorded so they are not read as failures: the `ctrl+o` detailed transcript view keeps its per-message timestamp and model headers where hidden assistant rows sat, because those headers are not a render component; on 2.1.272 the `/calm` toggle's answer was a transient toast under the prompt (`firstmate-calm: Calm on`) that expired within a few seconds and never became a transcript row; and the engine logs one benign debug-level warning at load, `options requested but its manifest declares no userConfig`, for every hooks module whose manifest declares no configuration fields, which an empty `userConfig` object does not silence. ### The shipped mod @@ -866,3 +866,27 @@ ok - Claude Code 2.1.283 (Claude Code) with the flag on: the mod auto-loads from ok - Claude Code 2.1.283 (Claude Code) resumes the transcript with Calm's hidden rows still hidden and the preference intact ok - Claude Code 2.1.283 (Claude Code) with Calm off shows the supervision notes: the session-start anchor for an unprocessed captain outcome, a sailboat for a new routine outcome, an anchor for a new captain outcome, and the latch-trip note, skipping processed and silent outcomes, moving no store marker, never reaching the model, and on resume showing each anchor once ``` + +## 2026-09-28 Claude Code 2.1.284 supervision-note label and the fm plugin name + +The label in front of each supervision note is Claude Code's, not the mod's, so the plugin is named `fm` to keep it short. + +- The mod hands `$.ui.log` the glyph-first line, as the debug log shows: `[DEBUG] [firstmate-calm] $.ui.log: ⚓ [seq 1] fm-repro-a: REPRO_CAPTAIN open`. +- Claude Code 2.1.284 turns every transcript `$.ui.log` line into a system-notice entry whose content is `: `, after the `ui.log` hook chain has run; `UiLogOptions` offers only `to: "transcript" | "debug"`, no `ui.render` component draws that row, and no other `$` call appends a transcript row. +- With the manifest named `fm`, the row draws as `⏺ fm: ⚓ [seq 1] fm-repro-a: REPRO_CAPTAIN open`, is stored as `"content":"fm: ⚓ [seq 1] ..."`, and the module loads as `hooks module fm@skills-dir loaded`; the folders keep their `firstmate-calm` names, which `claude plugin validate --strict` accepts. +- `$.store` lives in one file per plugin id under Claude Code's configuration directory (`plugins/store/fm_skills-dir-.json`), so the rename starts an empty store and a session resumed across it replays its still-due notes once. + +```text +$ claude --version +2.1.284 (Claude Code) + +$ bash tests/fm-calm-claude-mod-plugin.test.sh +ok - Claude Code 2.1.284 (Claude Code) validates the Calm mod strictly at its folder and its auto-load path, hooking exactly the working row, tool, user, and assistant drawings and /calm, and logging supervision notes +ok - Claude Code 2.1.284 (Claude Code) runs the Calm mod's plugin test suites clean: persisted toggle, hidden rows, working notes, the clock-driven working ship, and supervision notes + +$ FM_CLAUDE_CALM_LIVE_E2E=1 bash tests/fm-calm-claude-mod-live-e2e.test.sh +ok - Claude Code 2.1.284 (Claude Code) with the flag unset: no hooks module, no /calm, stock working row, stock tool rows, preference on ignored +ok - Claude Code 2.1.284 (Claude Code) with the flag on: the mod auto-loads from .claude/skills, /calm exists, the sailboat replaces and moves in the working row, tool rows and the record-backed operational doorbell draw at zero height, /calm restores and re-hides them while persisting the shared preference +ok - Claude Code 2.1.284 (Claude Code) resumes the transcript with Calm's hidden rows still hidden and the preference intact +ok - Claude Code 2.1.284 (Claude Code) with Calm off shows the supervision notes: the session-start anchor for an unprocessed captain outcome, a sailboat for a new routine outcome, an anchor for a new captain outcome, and the latch-trip note, each behind the fm: label, skipping processed and silent outcomes, moving no store marker, never reaching the model, and on resume showing each anchor once +``` diff --git a/docs/calm.md b/docs/calm.md index 83189a25bea..c20c8843e78 100644 --- a/docs/calm.md +++ b/docs/calm.md @@ -173,9 +173,9 @@ FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh ## Claude Code -### The firstmate-calm mod +### The Calm mod -Calm on Claude Code is the `firstmate-calm` mod under `.claude/mods/firstmate-calm`. +Calm on Claude Code is the mod under `.claude/mods/firstmate-calm`, whose plugin name is `fm`. The mod is a Claude Code plugin whose whole behavior lives in one function-hooks module. The trusted project auto-loads the mod through the `.claude/skills/firstmate-calm` entry (a symlink into `.claude/mods`), so no `--plugin-dir` or marketplace install is needed. @@ -224,7 +224,7 @@ The Pi extension keeps its standard ANSI blue and yellow. ### Supervision notes on Claude Code With the flag on, the mod shows the supervision notes Pi shows, whether Calm is on or off, because on Pi they are supervision UI rather than Calm UI. -Each note is appended to the transcript as its own system-notice row, which Claude Code draws in gray behind a `⏺` bullet and the mod's name (`firstmate-calm:`), and never sends to the model: +Each note is appended to the transcript as its own system-notice row, which Claude Code draws in gray behind a `⏺` bullet and the plugin's name (`fm:`), which Claude Code adds to every mod's transcript line, and never sends to the model: | Line | When | | --- | --- | @@ -240,6 +240,7 @@ A home whose outcome store predates the copy gains one at its next locked sessio On later reads, if the copy skips sequence numbers since the last seen outcome, one line counts the missing outcomes. The display copy's row and byte bounds are owned by [`fm-branch-outcome.sh`](../bin/fm-branch-outcome.sh); older outcomes and oversized rows cannot always be displayed by the mod, while the outcome store and main's delivery remain authoritative. Claude Code keeps each note in the session as a display-only entry and restores it on `claude --continue`, so the mod remembers in its own plugin store how far each session has followed the outcomes, and a resumed session replays only outcomes it has not shown. +Claude Code keys that store by plugin name, so a session that showed notes before the plugin was renamed from `firstmate-calm` to `fm` and is resumed afterwards replays its still-due notes once. The mod only reads outcome and host state: the drain owns off-Pi read-cursor advancement, and main explicitly acknowledges captain outcomes as processed. Only a home that runs the supervision host has outcomes to show. @@ -277,11 +278,11 @@ The mod never touches tool execution or prompts, and adds to the stored transcri ### Claude Code support bounds The bounds of the Claude Code support below are recorded with evidence in [`calm-mode-feasibility.md`](calm-mode-feasibility.md#2026-09-15-claude-code-21272-mods-feasibility-and-the-shipped-mod). -Evidence for 2.1.280 and the record-backed doorbell is also in its [2026-09-25 record](calm-mode-feasibility.md#2026-09-25-claude-code-21280-verification-and-the-record-backed-operational-doorbell) and [2.1.282 reproduction](calm-mode-feasibility.md#2026-09-25-claude-code-21282-reproduction-on-the-installed-build), and for the supervision notes in the [2.1.283 record](calm-mode-feasibility.md#2026-09-28-claude-code-21283-supervision-notes). +Evidence for 2.1.280 and the record-backed doorbell is also in its [2026-09-25 record](calm-mode-feasibility.md#2026-09-25-claude-code-21280-verification-and-the-record-backed-operational-doorbell) and [2.1.282 reproduction](calm-mode-feasibility.md#2026-09-25-claude-code-21282-reproduction-on-the-installed-build), and for the supervision notes in the [2.1.283 record](calm-mode-feasibility.md#2026-09-28-claude-code-21283-supervision-notes) and their label in the [2.1.284 record](calm-mode-feasibility.md#2026-09-28-claude-code-21284-supervision-note-label-and-the-fm-plugin-name). - The function-hooks surface is early access and default-off. Claude Code states that its API may change between releases without notice. - The mod is verified on Claude Code 2.1.272, 2.1.280, 2.1.282, and 2.1.283 and refuses nothing newer. + The mod is verified on Claude Code 2.1.272, 2.1.280, 2.1.282, 2.1.283, and 2.1.284 and refuses nothing newer. - Firstmate's typed producers bound for a Claude Code pane ride the record-backed doorbell, so they hide like any operational row. Those producers are the away-mode daemon's escalations and a worker's launch brief. Only an envelope that reaches Claude Code some other way, as bare typed or launch-prompt text, arrives without its U+2063 and stays visible. @@ -295,7 +296,7 @@ Evidence for 2.1.280 and the record-backed doorbell is also in its [2026-09-25 r - The sailboat is painted through Claude Code's Raster element, whose colors are RGB quantized to 256-color escapes rather than the standard 16-color ANSI codes Pi's widget emits. - The detailed transcript view (`ctrl+o`) keeps its per-message timestamp and model headers where hidden assistant rows sat, because those headers are not a hookable drawing. - Collapsed thinking never appears in Claude Code's default view. -- Supervision notes are system-notice rows rather than Pi's rendered entries: Claude Code draws them in one gray with its own bullet and the mod's name, so the glyph cannot take its own color as on Pi. +- Supervision notes are system-notice rows rather than Pi's rendered entries: Claude Code draws them in one gray with its own bullet and the plugin's name, so the glyph cannot take its own color as on Pi. - A captain outcome still wakes main through a `Stop hook feedback` row, which fires no hookable drawing, so its anchor line appears beside that row rather than replacing it. - The mod has no thinking drawing to hide in other views. diff --git a/tests/fm-calm-claude-mod-live-e2e.test.sh b/tests/fm-calm-claude-mod-live-e2e.test.sh index c121bd4745d..afe01618a6e 100644 --- a/tests/fm-calm-claude-mod-live-e2e.test.sh +++ b/tests/fm-calm-claude-mod-live-e2e.test.sh @@ -14,8 +14,9 @@ # 3. `claude --continue` restores the transcript with those rows still hidden. # 4. With Calm off, the supervision notes draw from a store bin/fm-branch-outcome.sh # writes: the session-start replay, new sailboat and anchor lines, and the latch -# note, without moving a store marker or reaching the model, and a resume shows -# each anchor once. +# note, each drawn behind the plugin's `fm:` label rather than `firstmate-calm:`, +# without moving a store marker or reaching the model, and a resume shows each +# anchor once. # The project and FM_HOME are isolated; Claude keeps using its existing managed # authentication and one trusted temporary folder. A few Haiku turns are submitted. # shellcheck disable=SC2016 # the model, not this test shell, reads the prompt text @@ -210,9 +211,8 @@ wait_settled() { # [iterations] fail "Claude Code $CLAUDE_VERSION never settled $what" } -# Claude Code 2.1.280 logs `hooks module firstmate-calm@ loaded`; 2.1.272 had no -# source suffix. -MODULE_LOADED='hooks module firstmate-calm(@[^ ]+)? loaded' +# Claude Code 2.1.280 logs `hooks module fm@ loaded`; 2.1.272 had no source suffix. +MODULE_LOADED='hooks module fm(@[^ ]+)? loaded' # --- 1. Flag off: a complete no-op even with the preference on -------------------- launch "$DEBUG_LOG_OFF" 0 @@ -280,7 +280,7 @@ grep -Eq "$MODULE_LOADED" "$DEBUG_LOG_ON" \ || fail "Claude Code $CLAUDE_VERSION did not load the Calm hooks module from the project's .claude/skills path with the flag on" # The engine logs one benign notice for every options-less hooks module ("options # requested but its manifest declares no userConfig"); anything else is a real problem. -if grep -E '\[(WARN|ERROR)\].*firstmate-calm' "$DEBUG_LOG_ON" | grep -v 'declares no userConfig' >&2; then +if grep -E '\[(WARN|ERROR)\].*(plugin fm[:@ ]|\[fm\]|module fm@)' "$DEBUG_LOG_ON" | grep -v 'declares no userConfig' >&2; then fail "Claude Code $CLAUDE_VERSION loaded the Calm mod with a warning or error" fi command_listed calm || fail "Claude Code $CLAUDE_VERSION does not list /calm with the flag on" @@ -380,14 +380,14 @@ i=0 while [ "$i" -lt 60 ]; do restored=$(screen) case "$restored" in - *'firstmate-calm'*|*'Calm off'*) ;; + *'fm: Calm'*|*'Calm off'*) ;; *) break ;; esac sleep 0.25 i=$((i + 1)) done case "$restored" in - *'firstmate-calm'*|*'Calm off'*) + *'fm: Calm'*|*'Calm off'*) printf '%s\n' "$restored" >&2 fail "/calm left a Calm row in the transcript after its notice should have expired" ;; @@ -455,20 +455,24 @@ printf 'key=live-key\nerrors=0\ncooldown=0\nretry_after=0\n' >"$STATE_DIR/.super printf 'off\n' >"$FM_HOME_DIR/config/calm" launch "$DEBUG_LOG_NOTES" 1 wait_idle -wait_screen '⚓ [seq 2] fm-live-b: LIVE_REPLAY_CAPTAIN still open' 'the session-start replay of an unprocessed captain outcome' 200 +wait_screen 'fm: ⚓ [seq 2] fm-live-b: LIVE_REPLAY_CAPTAIN still open' 'the session-start replay of an unprocessed captain outcome' 200 outcome append --task fm-live-c --verdict routine --summary 'LIVE_ROUTINE_NOTE worker healthy' outcome append --task fm-live-d --verdict routine --summary 'LIVE_SILENT_NOTE no change' --silent true outcome append --task fm-live-e --verdict captain --summary 'LIVE_NEW_CAPTAIN PR ready for review' -wait_screen '⛵ fm-live-c: LIVE_ROUTINE_NOTE worker healthy' 'the routine sailboat note' 200 -wait_screen '⚓ [seq 5] fm-live-e: LIVE_NEW_CAPTAIN PR ready for review' 'the new captain anchor line' 200 +wait_screen 'fm: ⛵ fm-live-c: LIVE_ROUTINE_NOTE worker healthy' 'the routine sailboat note' 200 +wait_screen 'fm: ⚓ [seq 5] fm-live-e: LIVE_NEW_CAPTAIN PR ready for review' 'the new captain anchor line' 200 printf 'key=live-key\nerrors=2\ncooldown=300\nretry_after=0\n' >"$STATE_DIR/.supervision-host-health" -wait_screen 'Supervision session paused after repeated engine errors' 'the latch-trip note' 200 +wait_screen 'fm: ⛵ Supervision session paused after repeated engine errors' 'the latch-trip note' 200 notes_screen=$(screen) case "$notes_screen" in *'LIVE_PROCESSED_CAPTAIN'*|*'LIVE_SILENT_NOTE'*) printf '%s\n' "$notes_screen" >&2 fail "a processed captain outcome or a silent routine outcome drew a supervision note" ;; + *'firstmate-calm:'*) + printf '%s\n' "$notes_screen" >&2 + fail "a supervision note drew behind the old firstmate-calm label" + ;; esac [ "$(cat "$STATE_DIR/.branch-outcomes-cursor")" = 2 ] || fail "the supervision notes moved the store's read cursor" [ "$(cat "$STATE_DIR/.branch-outcomes-processed")" = 1 ] || fail "the supervision notes moved the processed marker" @@ -491,7 +495,7 @@ fi # restores it on resume, so the resumed session replays only what it has not shown. outcome append --task fm-live-f --verdict captain --summary 'LIVE_WHILE_CLOSED captain outcome' launch "$DEBUG_LOG_NOTES" 1 --continue -wait_screen '⚓ [seq 6] fm-live-f: LIVE_WHILE_CLOSED captain outcome' 'the replay of an outcome recorded while the session was closed' 400 +wait_screen 'fm: ⚓ [seq 6] fm-live-f: LIVE_WHILE_CLOSED captain outcome' 'the replay of an outcome recorded while the session was closed' 400 sleep 4 resumed_notes=$(screen) [ "$(printf '%s\n' "$resumed_notes" | grep -c 'LIVE_REPLAY_CAPTAIN')" = 1 ] || { @@ -501,4 +505,4 @@ resumed_notes=$(screen) send '/exit' enter sleep 1 -pass "Claude Code $CLAUDE_VERSION with Calm off shows the supervision notes: the session-start anchor for an unprocessed captain outcome, a sailboat for a new routine outcome, an anchor for a new captain outcome, and the latch-trip note, skipping processed and silent outcomes, moving no store marker, never reaching the model, and on resume showing each anchor once" +pass "Claude Code $CLAUDE_VERSION with Calm off shows the supervision notes: the session-start anchor for an unprocessed captain outcome, a sailboat for a new routine outcome, an anchor for a new captain outcome, and the latch-trip note, each behind the fm: label, skipping processed and silent outcomes, moving no store marker, never reaching the model, and on resume showing each anchor once" diff --git a/tests/fm-calm-claude-mod.test.sh b/tests/fm-calm-claude-mod.test.sh index ef24b86943e..69ab66558e0 100644 --- a/tests/fm-calm-claude-mod.test.sh +++ b/tests/fm-calm-claude-mod.test.sh @@ -51,7 +51,7 @@ test_plugin_shape() { import { readFileSync, readdirSync, existsSync } from "node:fs"; const mod = ${MOD@Q}; const manifest = JSON.parse(readFileSync(\`\${mod}/.claude-plugin/plugin.json\`, "utf8")); -if (manifest.name !== "firstmate-calm") throw new Error(\`manifest name \${manifest.name}\`); +if (manifest.name !== "fm") throw new Error(\`manifest name \${manifest.name}\`); for (const key of ["commands", "agents", "skills", "hooks", "mcpServers", "lspServers", "outputStyles"]) { if (key in manifest) throw new Error(\`manifest declares \${key}, which would load while the flag is off\`); } From b5fdf74d654c9810517c3f473f2d4bc987af217a Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:28:21 -0700 Subject: [PATCH 04/76] feat(bin): add a disposable live supervision lab builder (#6037) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(bin): add fm-live-lab.sh, a one-command live supervision lab builder * fix(bin): exact lab windows, per-lab task ids, self-safe teardown * fix(bin): target lab windows by id, stop lab descendants, add readiness tests * fix(bin): keep Claude's auto-updater off in live labs; list fm-live-lab.sh * fix(bin): start the lab tmux server without user config * no-mistakes(review): Scope lab teardown to its store, root, and task ids * no-mistakes(review): Record selected user stores at up for check and down * no-mistakes(document): Clarify live lab documentation and remove stale narratives * no-mistakes(ci): Fixed the CI failure by checking for an existing lab root before looking up the harness executable. The affected behavioral test and shell syntax check pass; the refusal also works with Claude absent from PATH * no-mistakes(ci): Fixed all four Greptile findings: teardown signals only recorded lab processes and their descendants; the worker gate is in its granted task directory and its path is exposed; readiness uses current crew state; and mate and worker IDs use 12 nonce hex digits. The CLI behavior tests pass, as do shell syntax, ShellCheck, and diff checks. The Claude no-host path is unchanged * no-mistakes(ci): Fixed the CI test’s dependence on an installed Claude binary by supplying a test-local stub. The full fm-live-lab test, shell syntax check, and diff check pass * no-mistakes(ci): Fixed all three selected findings in bin/fm-live-lab.sh: down waits for recorded processes and escalates before cleanup, PID roots are checked against recorded start times, and Claude primary trust is rechecked after mate/worker readiness. Added behavioral tests in tests/fm-live-lab.test.sh. bin/fm-lint.sh and tests/fm-live-lab.test.sh pass * no-mistakes(ci): Fixed the pre-primary settle wait, worker gate instructions, unused retry variable, and teardown PID revalidation in bin/fm-live-lab.sh. Added behavioral tests in tests/fm-live-lab.test.sh. Both requested commands pass: tests/fm-live-lab.test.sh and bin/fm-lint.sh * no-mistakes(ci): Fixed teardown to track pre-kill lab processes by PID and start time, including children orphaned when a root exits. Up now rejects an empty pane PID before calling ps. Added regression tests and a Linux-safe worker fixture. bin/fm-lint.sh and tests/fm-live-lab.test.sh pass * no-mistakes(ci): Fixed teardown tracking for children spawned during shutdown and made the worker fixture verify its exact window with a Linux-available shell. Both requested checks pass. The lab test takes about 66 seconds locally, so the under-one-minute target remains unmet * no-mistakes(ci): Fixed ci-2 and ci-4 in bin/fm-live-lab.sh and tests/fm-live-lab.test.sh. Teardown now tracks identity-checked members of captured lab process groups, including children orphaned during shutdown, without signaling the caller’s group or unrelated processes. Lint passed, and the lab test passed four times * no-mistakes(ci): Fixed teardown so an observed-empty process group is permanently dropped, preventing a reused group ID from signalling unrelated work. Added a ps-shim regression test. The lab test, lint, and diff checks pass * no-mistakes(ci): Fixed ci-1 in bin/fm-live-lab.sh and tests/fm-live-lab.test.sh. The TERM-born-child fixture now waits until its handler is installed before calling down. Down sends SIGKILL to identity-valid survivors on every pass from pass 20 onward and includes survivor process details if it must refuse cleanup. bin/fm-lint.sh and tests/fm-live-lab.test.sh pass locally; Linux CI remains to be verified * no-mistakes(ci): Fixed down’s teardown wait to require two empty identity-checked scans separated by 0.5 seconds, and removed the unused test loop variable without changing the TERM-born-child test. The lab test, lint, and diff check pass locally --- bin/fm-claude-trust.sh | 51 ++- bin/fm-live-lab.sh | 849 ++++++++++++++++++++++++++++++++++++++ docs/scripts.md | 1 + tests/fm-live-lab.test.sh | 738 +++++++++++++++++++++++++++++++++ 4 files changed, 1634 insertions(+), 5 deletions(-) create mode 100755 bin/fm-live-lab.sh create mode 100755 tests/fm-live-lab.test.sh diff --git a/bin/fm-claude-trust.sh b/bin/fm-claude-trust.sh index 6e1a49a9776..8fc3fb6b88a 100755 --- a/bin/fm-claude-trust.sh +++ b/bin/fm-claude-trust.sh @@ -10,10 +10,13 @@ # # Usage: fm-claude-trust.sh # fm-claude-trust.sh --secondmate-home +# fm-claude-trust.sh --lab-home # the isolated task worktree this spawn launches into # the primary checkout that worktree belongs to # the seeded secondmate home this spawn launches into # the secondmate id that home must already be marked for +# --lab-home the disposable lab home bin/fm-live-lab.sh launches a lab +# primary in # Prints one line naming what it registered; refuses loudly on anything else. # # WHY THIS EXISTS. Claude Code gates a folder it has never seen behind an @@ -144,15 +147,25 @@ # argument to gate external-imports consent against, so the two import flags # are never written there. # +# LAB-HOME MODE. A disposable lab primary (bin/fm-live-lab.sh) launches in a lab +# home that is neither a task worktree nor a seeded secondmate home. +# The evidence is structural: the home must carry bin/fm-lab-home.sh's marker +# (a regular file this user owns, never a symlink, holding the token +# bin/fm-gate-refuse-lib.sh owns), hold +# AGENTS.md and bin/, and be a primary git checkout whose top level is exactly +# the argument, because Claude Code keys the launch to that root. It is +# trust-only for the same reason as a secondmate home, and bin/fm-live-lab.sh +# removes the entry again when it tears the lab down. +# # Only the launching user's own store is written. In worktree mode: the # projects entries for the worktree path and the resolved canonical project # path in ${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json, which must be a regular # file this uid owns; every unrelated key and project entry is preserved, and -# both entries land in one atomic replacement. In secondmate-home mode: the -# single projects entry for the registered home path, same store, same atomic -# replacement. fm-spawn.sh forwards CLAUDE_CONFIG_DIR onto the claude launch -# verbatim rather than resolving it, and the pane starts in the registered -# directory, so only an absolute value names the same store on both sides; a +# both entries land in one atomic replacement. In secondmate-home and lab-home +# mode: the single projects entry for the registered home path, same store, +# same atomic replacement. fm-spawn.sh forwards CLAUDE_CONFIG_DIR onto the +# claude launch verbatim rather than resolving it, and the pane starts in the +# registered directory, so only an absolute value names the same store on both sides; a # relative one is refused below rather than guessed at. set -u # Path resolution here must answer from the filesystem, never from the caller's @@ -174,6 +187,7 @@ unset CDPATH \ usage() { echo "usage: fm-claude-trust.sh " >&2 echo " fm-claude-trust.sh --secondmate-home " >&2 + echo " fm-claude-trust.sh --lab-home " >&2 exit 2 } @@ -189,6 +203,14 @@ case "${1:-}" in PROJ_ARG= SCOPE_NOUN="secondmate home" ;; + --lab-home) + [ "$#" -eq 2 ] || usage + MODE=lab-home + TARGET_ARG=$2 + SUB_ID= + PROJ_ARG= + SCOPE_NOUN="lab home" + ;; '' | -h | --help) usage ;; @@ -204,6 +226,9 @@ esac refuse() { echo "error: refusing to pre-register Claude trust: $1" >&2; exit 1; } +# shellcheck source=bin/fm-gate-refuse-lib.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)/fm-gate-refuse-lib.sh" + real_dir() { (cd -P -- "$1" 2>/dev/null && pwd -P); } # The fully resolved path of an existing file, or empty. Resolution runs in node @@ -303,6 +328,22 @@ if [ "$MODE" = worktree ]; then [ -n "$CANON_GIT_DIR" ] && [ "$CANON_GIT_DIR" = "$PROJ_COMMON" ] \ || refuse "project '$PROJ_REAL' is a linked worktree whose primary checkout could not be resolved" fi +elif [ "$MODE" = lab-home ]; then + LAB_MARKER="$TARGET_REAL/$FM_GATE_LAB_MARKER" + [ ! -L "$LAB_MARKER" ] || refuse "'$LAB_MARKER' is a symlink; a lab home carries the marker as a regular file" + if ! { [ -f "$LAB_MARKER" ] && [ -O "$LAB_MARKER" ] && fm_gate_lab_home "$TARGET_REAL"; }; then + refuse "'$TARGET_REAL' carries no lab-home marker owned by this user, so it is not a disposable lab home" + fi + [ -f "$TARGET_REAL/AGENTS.md" ] || refuse "'$TARGET_REAL' has no AGENTS.md, so it is not a firstmate home" + [ -d "$TARGET_REAL/bin" ] || refuse "'$TARGET_REAL' has no bin/, so it is not a firstmate home" + LAB_TOP=$(git -C "$TARGET_REAL" rev-parse --show-toplevel 2>/dev/null) || true + [ -n "$LAB_TOP" ] && [ "$(real_dir "$LAB_TOP")" = "$TARGET_REAL" ] \ + || refuse "'$TARGET_REAL' is not the top level of a git checkout" + LAB_GIT_DIR=$(git -C "$TARGET_REAL" rev-parse --absolute-git-dir 2>/dev/null) || true + LAB_GIT_DIR=$(real_dir "${LAB_GIT_DIR:-}") || true + LAB_COMMON=$(common_dir_of "$TARGET_REAL") || true + [ -n "$LAB_GIT_DIR" ] && [ "$LAB_GIT_DIR" = "$LAB_COMMON" ] \ + || refuse "'$TARGET_REAL' is a linked worktree, not the primary checkout a lab primary launches in" else # The seed evidence, in the order that names the most useful reason first: the # marker decides whether this is a secondmate home at all, the id decides diff --git a/bin/fm-live-lab.sh b/bin/fm-live-lab.sh new file mode 100755 index 00000000000..752bf25a0cf --- /dev/null +++ b/bin/fm-live-lab.sh @@ -0,0 +1,849 @@ +#!/usr/bin/env bash +# fm-live-lab.sh - stand up, check, drive, and tear down one disposable live +# supervision lab: a real lab main session on Claude or Pi, with the +# supervision host (Claude) or branch (Pi) wired as a real home runs it, +# optionally a real seeded local second mate and a real gated worker. +# +# Usage: +# fm-live-lab.sh up --harness claude|pi [--mate] [--worker] +# [--model ] [--effort ] +# [--supervision-host |none] [--expect-host yes|no] +# [--source ] [--ref ] [--timeout ] +# [] +# fm-live-lab.sh check +# fm-live-lab.sh say [--window ] +# fm-live-lab.sh pane [--window ] [--lines ] +# fm-live-lab.sh down +# +# up builds everything under (a fresh path; default a new +# /tmp/fmlab.XXXXXX), verifies readiness itself, and prints one line per check. +# It exits 0 only when every check passed; otherwise it exits 1 and leaves the +# lab up for inspection, so run down either way. check re-runs the same checks +# once. say types text into a lab window and presses Enter (window main, the +# lab primary, by default; mate and worker name the lab's own tasks). pane +# prints a window's recent scrollback. down stops every lab process, removes the +# lab's Claude trust entries by one atomic replace, removes , and exits +# non-zero if the recorded Pi trust store or ~/.treehouse gained changes. +# +# What up builds: +# home/ the lab main home: bin/fm-lab-home.sh create, then the +# committed tree of (default: HEAD of the +# checkout this script runs from) checked out as a genuine +# primary checkout, with FM_HOME at its root. +# config/ backend tmux, Claude crews and second mates, and +# supervision-host (default claude on Claude, absent +# on Pi; none leaves the file absent). +# tmux server private, through the lab home's bin/fm-lab-home.sh +# tmux-dir, with no user tmux config (its plugins never run +# in a lab), started from an empty environment so no inherited +# TMUX, Herdr, or Pi marker reaches a lab process. +# TREEHOUSE_ROOT points into , so a worker's pool +# never lands in ~/.treehouse, and DISABLE_AUTOUPDATER=1 +# keeps Claude Code from replacing the shared binary under +# a running lab, as every live run does (tests/lib.sh). +# A set CLAUDE_CONFIG_DIR (absolute) is passed to every lab +# process. up records the Claude store, Pi trust store, and +# ~/.treehouse it selected, so check and down use those same +# paths even from a later shell with another HOME. +# trust Claude: bin/fm-claude-trust.sh --lab-home for the primary +# and the spawn's own registration for the mate and worker. Pi: +# --approve, which trusts project-local files for this run +# only, so the Pi trust store is never written and all of +# .pi/extensions loads; sessions stay under +# /pi-sessions. +# task ids lab-mate and lab-worker, unique per lab, +# because a spawn keeps a task temp dir at /tmp/fm- +# that a fixed id would share with other labs and tasks. +# mate/ --mate: bin/fm-home-seed.sh /mate +# --no-projects (an explicit path cloned from the git lab +# home), launched by bin/fm-spawn.sh --secondmate. +# worker --worker: a lab project notes with a lab-private origin +# (local-only +yolo), a scaffolded brief, a backlog item, and +# a real bin/fm-spawn.sh worker that parks on +# /home/data//gate until that file +# exists; touch the gate and message the worker to resume. +# primary window main: claude --setting-sources project,local +# (default sonnet, medium, permission mode auto) or pi +# (default openai-codex/gpt-6-luna, medium), launched +# after the mate and worker so its first turn end arms +# supervision. up then sends one harmless probe prompt. +# +# Readiness checks (check prints "ok : ..." or "fail : ..."): +# primary window main is alive, in the lab home, which is a primary +# checkout, and the lab session lock names a live process. +# probe the primary answered the probe with its nonce (the model is +# accepted and a whole turn ran). +# trust Claude: the lab home carries registered trust. +# Pi: the Pi trust store is byte-identical to before up. +# mirror Claude with --expect-host yes: the tree's +# fm-host-mirror.sh verified claude and fm-host-mirror.sh check +# pass, and the mirror holds a captain and a main entry. +# extensions Pi: the watcher, turn-end guard, and branch extensions are +# loaded by the process holding the lab session lock, at the +# current on-disk builds. +# host Claude: with --expect-host yes (the default on Claude) the +# supervision host runs; with no, none runs. Skipped when the +# lab has no mate or worker, since an empty fleet arms nothing. +# watcher a live watcher with a fresh beacon holds this home's lock +# (skipped on an empty fleet). +# mate --mate: its window is alive and its own session lock names a +# live process, so it got past trust into its charter. +# worker --worker: its current crew state is paused on the gate. +# treehouse ~/.treehouse gained no entry since up began. +# +# down refuses any path without the lab record up writes. It kills only the +# lab's recorded private tmux server and launch pane PIDs, and their descendants; +# runs bin/fm-lab-home.sh teardown; removes the task temp and launch dirs the +# lab's spawns kept under /tmp, including a failed spawn's; removes every +# project entry at or under from the recorded Claude store, following +# a symlinked store to its target (compare-and-swap atomic replace, unrelated +# entries kept); reports a changed Pi trust store or a new +# ~/.treehouse entry without touching either; and removes . +# Transcripts under ~/.claude/projects are left as history. The lab never uses +# Herdr. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +BUILDER_ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" +LAB_HOME_HELPER="$SCRIPT_DIR/fm-lab-home.sh" +CLAUDE_TRUST="$SCRIPT_DIR/fm-claude-trust.sh" +RECORD_NAME=.fm-live-lab +RECORD_TOKEN='fm-live-lab v1' +PI_TRUST_STORE="$HOME/.pi/agent/trust.json" +TREEHOUSE_DIR="$HOME/.treehouse" + +die() { echo "fm-live-lab: $*" >&2; exit 1; } +help_text() { sed -n '/^# Usage:/,/^# up builds/p' "${BASH_SOURCE[0]}" | sed '$d' | sed 's/^# \{0,1\}//'; } +usage() { help_text >&2; exit 2; } + +real_dir() { (cd -P -- "$1" 2>/dev/null && pwd -P); } + +digest() { # -> sha256 of its bytes, or "absent" + [ -f "$1" ] || { echo absent; return; } + shasum -a 256 "$1" | awk '{print $1}' +} + +treehouse_listing() { ls -1A "$TREEHOUSE_DIR" 2>/dev/null || true; } + +rec_get() { # + sed -n "s/^$2=//p" "$1/$RECORD_NAME" | head -n 1 +} + +load_lab() { # : refuse anything up did not build, then load its record + local root=$1 + [ -n "$root" ] || usage + ROOT=$(real_dir "$root") || die "no lab at '$root'" + [ -f "$ROOT/$RECORD_NAME" ] && [ ! -L "$ROOT/$RECORD_NAME" ] && [ -O "$ROOT/$RECORD_NAME" ] \ + && [ "$(sed -n 1p "$ROOT/$RECORD_NAME")" = "$RECORD_TOKEN" ] \ + || die "refusing '$ROOT': it carries no lab record written by fm-live-lab.sh up" + HARNESS=$(rec_get "$ROOT" harness) + LAB=$(rec_get "$ROOT" home) + TMUX_DIR=$(rec_get "$ROOT" tmux_dir) + EXPECT_HOST=$(rec_get "$ROOT" expect_host) + WANT_MATE=$(rec_get "$ROOT" mate) + WANT_WORKER=$(rec_get "$ROOT" worker) + NONCE=$(rec_get "$ROOT" nonce) + MATE_ID=$(rec_get "$ROOT" mate_id) + WORKER_ID=$(rec_get "$ROOT" worker_id) + GATE=$(rec_get "$ROOT" gate) + PI_TRUST_BEFORE=$(rec_get "$ROOT" pi_trust) + CLAUDE_DIR=$(rec_get "$ROOT" claude_config_dir) + CLAUDE_STORE=$(rec_get "$ROOT" claude_store) + PI_TRUST_STORE=$(rec_get "$ROOT" pi_trust_store) + TREEHOUSE_DIR=$(rec_get "$ROOT" treehouse_dir) +} + +lab_tmux() { + [ -n "${TMUX_DIR:-}" ] || return 1 + env -u TMUX TMUX_TMPDIR="$TMUX_DIR" tmux "$@" +} + +# The empty-environment base every lab process starts from. +lab_env_base() { + printf '%s\n' "HOME=$HOME" "USER=${USER:-$(id -un)}" "LOGNAME=${USER:-$(id -un)}" \ + "PATH=$PATH" "SHELL=${SHELL:-/bin/zsh}" "TERM=xterm-256color" "LANG=${LANG:-en_US.UTF-8}" \ + "TMUX_TMPDIR=$TMUX_DIR" "TREEHOUSE_ROOT=$ROOT/treehouse" "FM_BACKEND=tmux" "DISABLE_AUTOUPDATER=1" + [ -z "${CLAUDE_DIR:-}" ] || printf '%s\n' "CLAUDE_CONFIG_DIR=$CLAUDE_DIR" +} + +lab_run() { # [NAME=VALUE...] : run in the lab's clean environment + local -a base=() + local line + while IFS= read -r line; do base+=("$line"); done < <(lab_env_base) + env -i "${base[@]}" "$@" +} + +# window_id : the tmux id of the lab window with exactly this name, or +# nothing. A name is matched here and never passed as a target, because tmux +# resolves a target it cannot find, even an exact =name, to the current window, +# while a stale window id fails. mate and worker name the lab's own tasks, whose +# windows the spawn recorded in their metadata. +window_id() { + local name=$1 window + case "$name" in + mate) name=$MATE_ID ;; + worker) name=$WORKER_ID ;; + esac + window=$(sed -n 's/^window=//p' "$LAB/state/$name.meta" 2>/dev/null) + [ -z "$window" ] || name=${window#*:} + lab_tmux list-windows -t firstmate -F "#{window_name}$(printf '\t')#{window_id}" 2>/dev/null \ + | awk -F '\t' -v n="$name" '$1 == n { print $2; exit }' +} + +window_field() { # + local id + id=$(window_id "$1") + [ -n "$id" ] || return 1 + lab_tmux display-message -p -t "$id" "$2" 2>/dev/null +} + +window_alive() { # + [ "$(window_field "$1" '#{pane_dead}')" = 0 ] +} + +pid_alive() { case "${1:-}" in ''|*[!0-9]*) return 1 ;; esac; kill -0 "$1" 2>/dev/null; } + +fleet_nonempty() { [ "$WANT_MATE" = yes ] || [ "$WANT_WORKER" = yes ]; } + +# ---- readiness checks ------------------------------------------------------- + +check_primary() { + local path gitdir common pid + window_alive main || { echo "fail primary: window main is not running"; return 1; } + path=$(window_field main '#{pane_current_path}') + [ "$(real_dir "$path")" = "$LAB" ] || { echo "fail primary: window main runs in '$path', not the lab home $LAB"; return 1; } + gitdir=$(real_dir "$(git -C "$LAB" rev-parse --absolute-git-dir 2>/dev/null)") + common=$(cd "$LAB" && real_dir "$(git rev-parse --git-common-dir 2>/dev/null)") + [ -n "$gitdir" ] && [ "$gitdir" = "$common" ] || { echo "fail primary: the lab home is not a primary checkout"; return 1; } + pid=$(sed -n 1p "$LAB/state/.lock" 2>/dev/null) + pid_alive "$pid" || { echo "fail primary: the lab session lock names no live process (session start has not run)"; return 1; } + echo "ok primary: $HARNESS pid $pid in $LAB" +} + +check_probe() { + local id + id=$(window_id main) + if [ -n "$id" ] && lab_tmux capture-pane -p -J -t "$id" -S -5000 2>/dev/null | grep -Fq "LABREADY-$NONCE"; then + echo "ok probe: the primary answered LABREADY-$NONCE" + else + echo "fail probe: no LABREADY-$NONCE reply in window main (model refused, turn still running, or a dialog is open)" + return 1 + fi +} + +lab_trust_present() { + node -e 'const [s,k]=process.argv.slice(1);const j=JSON.parse(require("node:fs").readFileSync(s,"utf8"));process.exit(j.projects?.[k]?.hasTrustDialogAccepted===true?0:1)' \ + "$CLAUDE_STORE" "$LAB" 2>/dev/null +} + +check_trust() { + if [ "$HARNESS" = pi ]; then + [ "$(digest "$PI_TRUST_STORE")" = "$PI_TRUST_BEFORE" ] \ + || { echo "fail trust: the Pi trust store changed since up began"; return 1; } + echo "ok trust: Pi trust store unchanged (session-only --approve)" + return 0 + fi + if lab_trust_present; then + echo "ok trust: $LAB is trusted in the Claude store" + else + echo "fail trust: $LAB has no registered Claude workspace trust" + return 1 + fi +} + +check_mirror() { + local out rc entries + out=$(cd "$LAB" && lab_run FM_HOME="$LAB" "$LAB/bin/fm-host-mirror.sh" verified claude 2>&1) + rc=$? + [ "$rc" -eq 0 ] || { echo "fail mirror: fm-host-mirror.sh verified claude exited $rc ${out:+($out)}"; return 1; } + out=$(cd "$LAB" && lab_run FM_HOME="$LAB" "$LAB/bin/fm-host-mirror.sh" check 2>&1) + rc=$? + [ "$rc" -eq 0 ] || { echo "fail mirror: fm-host-mirror.sh check exited $rc ${out:+($(printf '%s' "$out" | head -n 1))}"; return 1; } + entries=$(jq -rs '[.[].tag] | "captain=\(map(select(.=="captain"))|length) main=\(map(select(.=="main"))|length)"' \ + "$LAB/state/.host-mirror.jsonl" 2>/dev/null) + case "$entries" in + captain=0*|*main=0|'') echo "fail mirror: the dialog mirror has no captain and main entry yet (${entries:-no mirror file})"; return 1 ;; + esac + echo "ok mirror: verified writer, check passed, $entries" +} + +check_extensions() { + local pair source marker phase version out="" + for pair in fm-primary-pi-watch.ts:.pi-watch-extension-loaded:active fm-primary-turnend-guard.ts:.pi-turnend-extension-loaded; do + source=${pair%%:*}; marker=${pair#*:}; phase=${marker#*:}; marker=${marker%%:*} + [ "$phase" != "$marker" ] || phase= + # shellcheck disable=SC2016 # Expanded by the inner shell. + version=$(FM_HOME="$LAB" bash -c '. "$1/bin/fm-wake-lib.sh" && fm_pi_extension_version "$1/.pi/extensions/$2"' _ "$LAB" "$source" 2>/dev/null) + # shellcheck disable=SC2016 # Expanded by the inner shell. + FM_HOME="$LAB" bash -c '. "$1/bin/fm-wake-lib.sh" && fm_pi_extension_loaded "$1/state/$2" "$3" "$1/state/.lock" "$4"' \ + _ "$LAB" "$marker" "$version" "$phase" 2>/dev/null \ + || { echo "fail extensions: $source is not loaded at its current build by the lock holder"; return 1; } + out="$out ${source%.ts}" + done + [ "$(sed -n 1p "$LAB/state/.pi-branch-extension-loaded" 2>/dev/null)" = "$(sed -n 1p "$LAB/state/.lock" 2>/dev/null)" ] \ + || { echo "fail extensions: fm-branch-supervision.ts is not loaded by the lock holder"; return 1; } + echo "ok extensions:$out fm-branch-supervision" +} + +check_host() { + local pid + fleet_nonempty || { echo "ok host: skipped (empty fleet arms no supervision)"; return 0; } + pid=$(awk -F '\t' '$1=="host"{print $2; exit}' "$LAB/state/.supervision-host" 2>/dev/null) + if [ "$EXPECT_HOST" = yes ]; then + pid_alive "$pid" || { echo "fail host: no live supervision host (expected one)"; return 1; } + echo "ok host: supervision host pid $pid" + else + ! pid_alive "$pid" || { echo "fail host: supervision host pid $pid runs (expected none)"; return 1; } + echo "ok host: none running, as expected" + fi +} + +check_watcher() { + fleet_nonempty || { echo "ok watcher: skipped (empty fleet)"; return 0; } + # shellcheck disable=SC2016 # Expanded by the inner shell. + if FM_HOME="$LAB" bash -c '. "$1/bin/fm-wake-lib.sh" && fm_watcher_healthy "$1/state" "$1/bin/fm-watch.sh" 300 "$1"' _ "$LAB" 2>/dev/null; then + echo "ok watcher: live watcher with a fresh beacon" + else + echo "fail watcher: no live watcher with a fresh beacon holds the lab home" + return 1 + fi +} + +check_mate() { + local pid + window_alive mate || { echo "fail mate: the $MATE_ID window is not running"; return 1; } + pid=$(sed -n 1p "$ROOT/mate/state/.lock" 2>/dev/null) + pid_alive "$pid" || { echo "fail mate: the mate holds no session lock yet (wedged before its charter?)"; return 1; } + echo "ok mate: $MATE_ID pid $pid in $ROOT/mate" +} + +check_worker() { + local state + window_alive worker || { echo "fail worker: the $WORKER_ID window is not running"; return 1; } + state=$(cd "$LAB" && lab_run FM_HOME="$LAB" FM_CREW_STATE_NO_FORGE=1 "$LAB/bin/fm-crew-state.sh" "$WORKER_ID" 2>/dev/null) + case "$state" in + "state: paused · "*"$GATE"*) ;; + *) echo "fail worker: the worker is not currently parked on $GATE (${state:-no state})"; return 1 ;; + esac + echo "ok worker: $WORKER_ID parked on $GATE" +} + +check_treehouse() { + local added + added=$(comm -13 "$ROOT/.treehouse-before" <(treehouse_listing | sort) 2>/dev/null) + [ -z "$added" ] || { echo "fail treehouse: new ~/.treehouse entries: $(printf '%s' "$added" | tr '\n' ' ')"; return 1; } + echo "ok treehouse: ~/.treehouse unchanged" +} + +run_checks() { + local rc=0 + check_primary || rc=1 + check_probe || rc=1 + check_trust || rc=1 + if [ "$HARNESS" = claude ]; then + if [ "$EXPECT_HOST" = yes ]; then check_mirror || rc=1; fi + check_host || rc=1 + else + check_extensions || rc=1 + fi + check_watcher || rc=1 + if [ "$WANT_MATE" = yes ]; then check_mate || rc=1; fi + if [ "$WANT_WORKER" = yes ]; then check_worker || rc=1; fi + check_treehouse || rc=1 + return "$rc" +} + +# ---- up --------------------------------------------------------------------- + +say_text() { # + local id + id=$(window_id "$1") + [ -n "$id" ] || { echo "fm-live-lab: no lab window named '$1'" >&2; return 1; } + lab_tmux send-keys -t "$id" -l "$2" || return 1 + sleep 1 + lab_tmux send-keys -t "$id" Enter +} + +make_notes_project() { + local seed="$ROOT/origins/notes-seed" origin="$ROOT/origins/notes.git" + mkdir -p "$seed/notes" "$seed/tests" + git init -q -b main "$seed" + cat > "$seed/notes/__init__.py" <<'PY' +"""A tiny notes library used by the firstmate live lab.""" + +NOTES = [] + + +def add_note(text, tags=None): + NOTES.append({"text": text, "tags": list(tags or [])}) + return len(NOTES) - 1 + + +def list_notes(): + return list(NOTES) +PY + cat > "$seed/tests/test_notes.py" <<'PY' +import unittest + +import notes + + +class NotesTest(unittest.TestCase): + def setUp(self): + notes.NOTES.clear() + + def test_add_and_list(self): + notes.add_note("hello", ["a"]) + self.assertEqual(notes.list_notes(), [{"text": "hello", "tags": ["a"]}]) + + +if __name__ == "__main__": + unittest.main() +PY + cat > "$seed/README.md" <<'MD' +# notes + +A tiny notes library for lab work. +Run the checks with `python3 -m unittest discover -s tests`. +MD + git -C "$seed" add -A + git -C "$seed" -c user.name=lab -c user.email=lab@example.invalid commit -q -m "seed notes" + git clone -q --bare "$seed" "$origin" + rm -rf "$seed" + git clone -q "$origin" "$LAB/projects/notes" + printf '# Projects\n\n- notes [local-only +yolo] - tiny lab notes library\n' > "$LAB/data/projects.md" +} + +spawn_worker() { + local brief="$LAB/data/$WORKER_ID/brief.md" gate="$GATE" + make_notes_project || return 1 + (cd "$LAB" && lab_run FM_HOME="$LAB" "$LAB/bin/fm-brief.sh" "$WORKER_ID" notes --mode local-only) >/dev/null || return 1 + TASK_TEXT="Lab gated worker for a live supervision lab. Add count_notes(), which returns how many notes are stored, to notes/__init__.py with a unit test, but only after the gate file $gate exists and you receive a message to resume." \ + SPEC_TEXT="Right after setup, append one paused status line naming the gate file $gate and end your turn. Do not poll or sleep in a foreground command. When a later message resumes you, check that $gate exists before implementing count_notes() in notes/__init__.py and a test in tests/test_notes.py; if it is absent, remain paused and end your turn again. Once the gate exists, run python3 -m unittest discover -s tests, commit, and report done. Nothing else is in scope." \ + python3 - "$brief" <<'PY' || return 1 +import os, sys +path = sys.argv[1] +text = open(path, encoding="utf-8").read() +text = text.replace("{TASK}", os.environ["TASK_TEXT"], 1).replace("{FIRSTMATE_SPEC}", os.environ["SPEC_TEXT"], 1) +open(path, "w", encoding="utf-8").write(text) +PY + (cd "$LAB" && lab_run FM_HOME="$LAB" "$LAB/bin/fm-tasks-axi.sh" add "$WORKER_ID" "lab gated worker" --kind ship --repo notes) >/dev/null || return 1 + (cd "$LAB" && lab_run FM_HOME="$LAB" "$LAB/bin/fm-spawn.sh" "$WORKER_ID" "$LAB/projects/notes" \ + --mode local-only --yolo on --harness claude --model sonnet --effort low) +} + +spawn_mate() { + local charter='Provide an idle live-validation lab second mate. When explicitly steered for a synthetic test, report only honest local lab outcomes; do not claim external PR activity, merge, or retire yourself.' + (cd "$LAB" && lab_run FM_HOME="$LAB" FM_SECONDMATE_CHARTER="$charter" \ + FM_SECONDMATE_SCOPE='second-mate live validation synthetic status relay' \ + "$LAB/bin/fm-home-seed.sh" "$MATE_ID" "$ROOT/mate" --no-projects) || return 1 + (cd "$LAB" && lab_run FM_HOME="$LAB" "$LAB/bin/fm-spawn.sh" "$MATE_ID" --secondmate) +} + +cmd_up() { + local harness="" mate=no worker=no model="" effort=medium host_line=__default__ expect_host="" source="$BUILDER_ROOT" ref=HEAD timeout=600 + local root="" + while [ "$#" -gt 0 ]; do + case "$1" in + --harness) harness=${2:-}; shift 2 ;; + --mate) mate=yes; shift ;; + --worker) worker=yes; shift ;; + --model) model=${2:-}; shift 2 ;; + --effort) effort=${2:-}; shift 2 ;; + --supervision-host) host_line=${2:-}; shift 2 ;; + --expect-host) expect_host=${2:-}; shift 2 ;; + --source) source=${2:-}; shift 2 ;; + --ref) ref=${2:-}; shift 2 ;; + --timeout) timeout=${2:-}; shift 2 ;; + -h|--help) help_text; exit 0 ;; + -*) die "unknown option '$1'" ;; + *) [ -z "$root" ] || usage; root=$1; shift ;; + esac + done + case "$harness" in claude|pi) ;; *) die "--harness must be claude or pi" ;; esac + case "$timeout" in ''|*[!0-9]*) die "--timeout takes seconds" ;; esac + [ -n "$expect_host" ] || { [ "$harness" = claude ] && expect_host=yes || expect_host=no; } + case "$expect_host" in yes|no) ;; *) die "--expect-host takes yes or no" ;; esac + [ "$host_line" != __default__ ] || { [ "$harness" = claude ] && host_line=claude || host_line=none; } + [ -n "$model" ] || { [ "$harness" = claude ] && model=sonnet || model=openai-codex/gpt-6-luna; } + CLAUDE_DIR=${CLAUDE_CONFIG_DIR:-} + case "$CLAUDE_DIR" in ''|/*) ;; *) die "CLAUDE_CONFIG_DIR must be an absolute path" ;; esac + CLAUDE_STORE="${CLAUDE_DIR:-$HOME}/.claude.json" + [ -z "$root" ] || [ ! -e "$root" ] || die "refusing '$root': a lab root must not exist yet" + for tool in git tmux jq node python3 shasum "$harness"; do + command -v "$tool" >/dev/null 2>&1 || die "$tool is required and was not found on PATH" + done + + if [ -z "$root" ]; then + root=$(mktemp -d /tmp/fmlab.XXXXXX) || die "cannot create a lab root" + else + mkdir -p "$root" || die "cannot create '$root'" + fi + ROOT=$(real_dir "$root") + LAB="$ROOT/home" + HARNESS=$harness EXPECT_HOST=$expect_host WANT_MATE=$mate WANT_WORKER=$worker + NONCE=$(od -An -N6 -tx1 /dev/urandom | tr -d ' \n') + MATE_ID="lab${NONCE:0:12}-mate" WORKER_ID="lab${NONCE:0:12}-worker" + GATE="$LAB/data/$WORKER_ID/gate" + PI_TRUST_BEFORE=$(digest "$PI_TRUST_STORE") + treehouse_listing | sort > "$ROOT/.treehouse-before" + { + echo "$RECORD_TOKEN" + echo "harness=$harness" + echo "home=$LAB" + echo "expect_host=$expect_host" + echo "mate=$mate" + echo "worker=$worker" + echo "nonce=$NONCE" + echo "mate_id=$MATE_ID" + echo "worker_id=$WORKER_ID" + echo "gate=$GATE" + echo "pi_trust=$PI_TRUST_BEFORE" + echo "claude_config_dir=$CLAUDE_DIR" + echo "claude_store=$CLAUDE_STORE" + echo "pi_trust_store=$PI_TRUST_STORE" + echo "treehouse_dir=$TREEHOUSE_DIR" + } > "$ROOT/$RECORD_NAME" + echo "lab: $ROOT (tear down with: $0 down $ROOT)" + + "$LAB_HOME_HELPER" create "$LAB" >/dev/null || die "cannot create the lab home" + git -C "$LAB" init -q -b main || die "cannot initialize the lab home" + git -C "$LAB" fetch -q "$source" "$ref" || die "cannot fetch $ref from $source" + git -C "$LAB" checkout -q -f -B main FETCH_HEAD || die "cannot check out $ref" + git -C "$LAB" config user.name lab && git -C "$LAB" config user.email lab@example.invalid + mkdir -p "$LAB/state" "$LAB/data" "$LAB/config" "$LAB/projects" "$ROOT/treehouse" + printf 'tmux\n' > "$LAB/config/backend" + printf 'claude\n' > "$LAB/config/crew-harness" + printf 'claude sonnet low\n' > "$LAB/config/secondmate-harness" + printf 'auto\n' > "$LAB/config/claude-permission-mode" + [ "$host_line" = none ] || printf '%s\n' "$host_line" > "$LAB/config/supervision-host" + echo "tree: $(git -C "$LAB" rev-parse HEAD) from $source" + + TMUX_DIR=$("$LAB_HOME_HELPER" tmux-dir "$LAB") || die "cannot create the private tmux directory" + echo "tmux_dir=$TMUX_DIR" >> "$ROOT/$RECORD_NAME" + lab_run tmux -f /dev/null new-session -d -s firstmate -n lab -x 220 -y 60 -c "$ROOT" || die "cannot start the lab tmux server" + record_launch_pid "$(lab_tmux display-message -p '#{pid}')" + + if [ "$mate" = yes ]; then + spawn_mate || die "cannot seed and launch the second mate" + record_launch_pid "$(window_field mate '#{pane_pid}')" + fi + if [ "$worker" = yes ]; then + spawn_worker || die "cannot launch the gated worker" + record_launch_pid "$(window_field worker '#{pane_pid}')" + echo "gate: $GATE (touch, then message the worker to resume)" + fi + + local -a primary=() + if [ "$harness" = claude ]; then + local settle=$(( $(date +%s) + 300 )) retry + until { [ "$mate" != yes ] || check_mate >/dev/null; } && { [ "$worker" != yes ] || [ -s "$LAB/state/$WORKER_ID.status" ]; }; do + [ "$(date +%s)" -lt "$settle" ] || break + sleep 2 + done + for (( retry=0; retry<3; retry++ )); do + lab_run "$CLAUDE_TRUST" --lab-home "$LAB" >/dev/null || die "cannot register Claude trust for the lab home" + sleep 1 + lab_trust_present && break + done + lab_trust_present || die "the lab home's Claude trust keeps disappearing from $CLAUDE_STORE" + primary=(claude --setting-sources "project,local" --model "$model" --effort "$effort" --permission-mode auto) + else + primary=(pi --approve --session-dir "$ROOT/pi-sessions" --model "$model" --thinking "$effort") + fi + lab_tmux new-window -d -t firstmate: -n main -c "$LAB" \ + env FM_HOME="$LAB" CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false "${primary[@]}" \ + || die "cannot launch the lab primary" + lab_tmux set-option -w -t "$(window_id main)" remain-on-exit on >/dev/null + record_launch_pid "$(window_field main '#{pane_pid}')" + echo "primary: ${primary[*]}" + + local deadline=$(( $(date +%s) + 180 )) + until [ -f "$LAB/state/.session-start-complete" ] || [ "$(date +%s)" -ge "$deadline" ]; do sleep 2; done + sleep 5 + say_text main "Lab readiness probe from bin/fm-live-lab.sh. Run no tool or command for this message. Reply with only the word LABREADY, a hyphen, and then $NONCE, with no spaces." \ + || die "cannot send the readiness probe" + + deadline=$(( $(date +%s) + timeout )) + local report + while :; do + report=$(run_checks) && { printf '%s\n' "$report"; echo "ready: $ROOT"; return 0; } + [ "$(date +%s)" -lt "$deadline" ] || break + sleep 5 + done + printf '%s\n' "$report" + echo "not ready after ${timeout}s; the lab is left up for inspection: $0 pane $ROOT, then $0 down $ROOT" >&2 + return 1 +} + +# ---- down ------------------------------------------------------------------- + +record_launch_pid() { + local start + case "${1:-}" in ''|*[!0-9]*) die "cannot record lab process: missing or invalid PID '${1:-}'" ;; esac + start=$(ps -o lstart= -p "$1" | awk '{$1=$1; print}') + [ -n "$start" ] || die "cannot record start time for lab process $1" + printf 'launch_pid=%s\nlaunch_start=%s\n' "$1" "$start" >> "$ROOT/$RECORD_NAME" +} + +# Resolve recorded roots only while their start times match, before tmux +# reparents their descendants. +lab_pids() { + ps -axo pid=,ppid=,lstart= | awk -v record="$ROOT/$RECORD_NAME" ' + BEGIN { + while ((getline line < record) > 0) { + if (line ~ /^launch_pid=[0-9]+$/) { sub(/^launch_pid=/, "", line); root=line } + else if (line ~ /^launch_start=/ && root != "") { + sub(/^launch_start=/, "", line); starts[root]=line; root="" + } + } + close(record) + } + { + pid[NR]=$1; ppid[$1]=$2 + start=$3 " " $4 " " $5 " " $6 " " $7 + if ($1 in starts && start == starts[$1]) roots[$1]=1 + } + END { + for (i = 1; i <= NR; i++) { + p = pid[i] + for (q = p; q > 1 && (q in ppid); q = ppid[q]) { + if (q in roots) { print p; break } + } + } + }' +} + +# Extend the pre-kill snapshot with descendants of still-matching processes +# and live members of captured lab process groups. Retain old pairs after reparenting. +expand_pairs() { + awk -v groups="$2" ' + BEGIN { split(groups, ids, /[[:space:]]+/); for (i in ids) if (ids[i] > 1) group[ids[i]]=1 } + NR==FNR { split($0, fields, "\t"); if (fields[1] ~ /^[0-9]+$/) saved[fields[1]]=fields[2]; next } + { + pid=$1; parent[pid]=$2; pgid[pid]=$3; state[pid]=$4 + start[pid]=$5 " " $6 " " $7 " " $8 " " $9 + if (pid in saved && start[pid] == saved[pid] && state[pid] !~ /^Z/) owned[pid]=1 + } + END { + for (pid in saved) print pid "\t" saved[pid] + for (pid in parent) { + if (pid in saved || state[pid] ~ /^Z/) continue + if (pgid[pid] in group) { print pid "\t" start[pid]; continue } + for (p=parent[pid]; p > 1 && (p in parent); p=parent[p]) { + if (p in owned) { print pid "\t" start[pid]; break } + } + } + }' <(printf '%s\n' "$1") <(printf '%s\n' "$3") +} + +# A group is eligible only while each scan still sees an identity-valid member. +# Once absent, it is removed from the caller's group list and cannot be rediscovered. +prune_groups() { + awk -v groups="$1" ' + BEGIN { n=split(groups, ids, /[[:space:]]+/) } + NR==FNR { split($0, fields, "\t"); if (fields[1] ~ /^[0-9]+$/) saved[fields[1]]=fields[2]; next } + { + pid=$1; pgid=$3; state=$4 + start=$5 " " $6 " " $7 " " $8 " " $9 + if (state !~ /^Z/ && (!(pid in saved) || saved[pid] == start)) live[pgid]=1 + } + END { for (i=1; i<=n; i++) if (ids[i] in live) printf "%s ", ids[i] } + ' <(printf '%s\n' "$2") <(printf '%s\n' "$3") +} + +refresh_pairs() { + local snapshot + snapshot=$(ps -axo pid=,ppid=,pgid=,stat=,lstart=) + pairs=$(expand_pairs "$pairs" "$groups" "$snapshot") + groups=$(prune_groups "$groups" "$pairs" "$snapshot") +} + +# pairs captured before tmux shutdown. Recheck identity even +# after a root exits and its children are reparented or a PID is reused. +live_pids() { + local pid start current + while IFS=$'\t' read -r pid start; do + [ -n "$pid" ] || continue + current=$(ps -o stat=,lstart= -p "$pid" 2>/dev/null | awk '{$1=$1; print}') + case "$current" in ''|Z*) ;; *) + [ "${current#* }" = "$start" ] && echo "$pid" + ;; + esac + done <<< "$1" +} + +forget_claude_entries() { # remove every project entry at or under ROOT; prints the count + [ -e "$CLAUDE_STORE" ] || { echo 0; return 0; } + node - "$CLAUDE_STORE" "$ROOT" "${ROOT#/private}" <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); +const crypto = require("node:crypto"); +const [link, ...roots] = process.argv.slice(2); +const store = fs.realpathSync(link); +const stat = fs.statSync(store); +if (!stat.isFile() || stat.uid !== process.getuid()) { + console.error(`error: ${store} is not a regular file this user owns`); process.exit(1); +} +const inLab = (key) => roots.some((r) => key === r || key.startsWith(`${r}/`)); +const fingerprint = (buf) => crypto.createHash("sha256").update(buf).digest("hex"); +for (let attempt = 0; attempt < 3; attempt += 1) { + const original = fs.readFileSync(store); + const root = JSON.parse(original.toString("utf8")); + const projects = root.projects; + if (projects === undefined) { console.log(0); process.exit(0); } + if (projects === null || typeof projects !== "object" || Array.isArray(projects)) { + console.error(`error: ${store} has a non-object "projects" value`); process.exit(1); + } + const removed = Object.keys(projects).filter(inLab); + if (removed.length === 0) { console.log(0); process.exit(0); } + const kept = Object.keys(projects).filter((k) => !inLab(k)); + for (const key of removed) delete projects[key]; + const tmp = path.join(path.dirname(store), `.claude.json.fm-live-lab.${process.pid}.${crypto.randomBytes(8).toString("hex")}`); + fs.writeFileSync(tmp, `${JSON.stringify(root, null, 2)}\n`, { mode: fs.statSync(store).mode & 0o777, flag: "wx" }); + let renamed = false; + try { + if (fingerprint(fs.readFileSync(store)) !== fingerprint(original)) continue; + fs.renameSync(tmp, store); + renamed = true; + } finally { + if (!renamed) fs.rmSync(tmp, { force: true }); + } + const back = Object.keys(JSON.parse(fs.readFileSync(store, "utf8")).projects || {}); + if (back.some(inLab) || kept.some((k) => !back.includes(k))) { + console.error(`error: ${store} did not keep exactly the non-lab entries`); process.exit(1); + } + console.log(removed.length); + process.exit(0); +} +console.error(`error: ${store} kept changing while lab entries were being removed`); +process.exit(1); +NODE +} + +cmd_down() { + load_lab "${1:-}" + local rc=0 pids pairs pid start survivors n removed added id meta dir home_hash groups pgid own_group caller_group details + local -a ids=() + pids=$(lab_pids) + pairs='' groups='' + own_group=$(ps -o pgid= -p "$$" | awk '{$1=$1; print}') + caller_group=$(ps -o pgid= -p "$PPID" | awk '{$1=$1; print}') + for pid in $pids; do + start=$(ps -o lstart= -p "$pid" 2>/dev/null | awk '{$1=$1; print}') + [ -n "$start" ] || continue + pairs+="$pid"$'\t'"$start"$'\n' + pgid=$(ps -o pgid=,lstart= -p "$pid" 2>/dev/null | awk -v start="$start" '{ if ($2 " " $3 " " $4 " " $5 " " $6 == start) print $1 }') + case "$pgid" in ''|0|1|*[!0-9]*) continue ;; esac + [ "$pgid" = "$own_group" ] || [ "$pgid" = "$caller_group" ] || groups+="$pgid " + done + lab_tmux kill-server 2>/dev/null || true + refresh_pairs + survivors=$(live_pids "$pairs") + if [ -n "$survivors" ]; then + # shellcheck disable=SC2086 # One identity-checked pid per word. + kill $survivors 2>/dev/null || true + fi + for n in {1..40}; do + refresh_pairs + survivors=$(live_pids "$pairs") + if [ -z "$survivors" ]; then + sleep 0.5 + refresh_pairs + survivors=$(live_pids "$pairs") + [ -n "$survivors" ] || break + fi + if [ "$n" -ge 20 ]; then + # shellcheck disable=SC2086 # One identity-checked pid per word. + kill -9 $survivors 2>/dev/null || true + fi + sleep 0.5 + done + refresh_pairs + survivors=$(live_pids "$pairs") + if [ -n "$survivors" ]; then + details='' + for pid in $survivors; do + details+="$(ps -o pid=,ppid=,pgid=,stat=,command= -p "$pid" 2>/dev/null)"$'\n' + done + die "refusing to remove the lab: its processes did not exit (pid ppid pgid state command): $details" + fi + echo "stopped: lab tmux server and lab processes" + # A spawn keeps /tmp/fm- and /tmp/fm-+. + # The second is scoped to this lab home for any task it spawned; the first is + # removed only for the lab's own unique ids, since another home may share it. + home_hash=$(printf '%s' "$LAB" | shasum -a 256 | awk '{print $1}') + ids=("$MATE_ID" "$WORKER_ID") + for meta in "$LAB"/state/*.meta; do + [ -f "$meta" ] && ids+=("$(basename "$meta" .meta)") + done + for id in "${ids[@]}"; do + [ -n "$id" ] || continue + for dir in "/tmp/fm-$id+$home_hash" "/tmp/fm-$id"; do + [ "$dir" != "/tmp/fm-$id" ] || [ "$id" = "$MATE_ID" ] || [ "$id" = "$WORKER_ID" ] || continue + if [ -d "$dir" ] && [ ! -L "$dir" ] && [ -O "$dir" ]; then + rm -rf "$dir" && echo "removed: task temp $dir" + fi + done + done + if [ -f "$LAB/.fm-lab-home" ]; then + "$LAB_HOME_HELPER" teardown "$LAB" || die "cannot remove the private tmux directory" + fi + removed=$(forget_claude_entries) || die "cannot remove the lab's Claude trust entries" + echo "removed: $removed Claude project entries under $ROOT" + if [ "$(digest "$PI_TRUST_STORE")" != "$PI_TRUST_BEFORE" ]; then + echo "warning: the Pi trust store changed since up began; left as is" >&2 + rc=1 + fi + added=$(comm -13 "$ROOT/.treehouse-before" <(treehouse_listing | sort) 2>/dev/null) + if [ -n "$added" ]; then + echo "warning: ~/.treehouse gained entries during the lab; left as is: $(printf '%s' "$added" | tr '\n' ' ')" >&2 + rc=1 + fi + chmod -R u+w "$ROOT" 2>/dev/null + rm -rf "$ROOT" || die "cannot remove $ROOT" + [ ! -e "$ROOT" ] || die "$ROOT is still present" + echo "removed: $ROOT" + return "$rc" +} + +# ---- say / pane / check ----------------------------------------------------- + +cmd_say() { + local window=main + load_lab "${1:-}"; shift + [ "${1:-}" = --window ] && { window=${2:-}; shift 2; } + [ "$#" -ge 1 ] || usage + say_text "$window" "$*" +} + +cmd_pane() { + local window=main lines=200 + load_lab "${1:-}"; shift + while [ "$#" -gt 0 ]; do + case "$1" in + --window) window=${2:-}; shift 2 ;; + --lines) lines=${2:-}; shift 2 ;; + *) usage ;; + esac + done + local id + id=$(window_id "$window") + [ -n "$id" ] || die "no lab window named '$window'" + lab_tmux capture-pane -p -J -t "$id" -S "-$lines" | grep -v '^[[:space:]]*$' +} + +cmd_check() { + load_lab "${1:-}" + run_checks +} + +case "${1:-}" in + up) shift; cmd_up "$@" ;; + check) shift; cmd_check "$@" ;; + say) shift; cmd_say "$@" ;; + pane) shift; cmd_pane "$@" ;; + down) shift; cmd_down "$@" ;; + -h|--help) help_text ;; + *) usage ;; +esac diff --git a/docs/scripts.md b/docs/scripts.md index 7cf60ec73f5..8d656ec203e 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -39,6 +39,7 @@ The shared no-mistakes gate lifecycle boundary is summarized in [architecture.md | `fm-herdr-lab.sh` | Provision and guardedly operate an isolated, never-default Herdr lab session | | `fm-herdr-lab-viewer.py` | The pty engine behind `fm-herdr-lab.sh viewer`: one real foreground Herdr client on a non-zero window grid | | `fm-lab-home.sh` | Mint disposable lab homes and manage their isolated tmux socket directories | +| `fm-live-lab.sh` | Build and operate a disposable live supervision lab; see its header for usage and readiness contract | | `fm-install-herdr.sh` | Install CI's exact-version Herdr pin with official asset URL, SHA-256, and protocol checks | | `fm-install-treehouse.sh`| Install CI's exact-version Treehouse pin for real-Herdr E2E that needs spawn worktrees | | `fm-herdr-ci-cleanup.sh` | Snapshot and tear down only job-owned `fm-lab-*` sessions in the Herdr CI lane | diff --git a/tests/fm-live-lab.test.sh b/tests/fm-live-lab.test.sh new file mode 100755 index 00000000000..1e5b957dd37 --- /dev/null +++ b/tests/fm-live-lab.test.sh @@ -0,0 +1,738 @@ +#!/usr/bin/env bash +# Behavior tests for bin/fm-live-lab.sh (readiness checks and teardown) and +# bin/fm-claude-trust.sh --lab-home. +# +# Every readiness check is proven both ways against a real private tmux server +# and real processes, with no harness: it passes on a lab in the shape up builds +# and fails, by name, on the recorded lab miss it exists to catch. The live +# end-to-end run on the real harnesses is the builder's own `up`. +set -u + +# shellcheck source=tests/fixtures.sh +. "$(dirname "${BASH_SOURCE[0]}")/fixtures.sh" + +TMP_ROOT=$(fm_test_tmproot fm-live-lab) +: > "$TMP_ROOT/pids" +: > "$TMP_ROOT/tmux-dirs" +LIVE_LAB="$ROOT/bin/fm-live-lab.sh" +TRUST="$ROOT/bin/fm-claude-trust.sh" + +live_lab_cleanup() { + local dir pid marker + for marker in "$TMP_ROOT/orphan-child" "$TMP_ROOT/late-child" "$TMP_ROOT/reused-child"; do + [ ! -s "$marker" ] || printf '%s\n' "$(<"$marker")" >> "$TMP_ROOT/pids" + done + while read -r pid; do [ -n "$pid" ] && { pkill -P "$pid" 2>/dev/null || true; kill "$pid" 2>/dev/null || true; }; done < "$TMP_ROOT/pids" + while read -r dir; do + [ -n "$dir" ] || continue + env -u TMUX TMUX_TMPDIR="$dir" tmux kill-server 2>/dev/null + case "$dir" in /tmp/fml.*) rm -rf "$dir" ;; esac + done < "$TMP_ROOT/tmux-dirs" + rm -rf "/tmp/fm-labt$$-mate" "/tmp/fm-labt$$-worker" "/tmp/fm-labt$$-other" /tmp/fm-labt"$$"-*+* + fm_test_cleanup +} +trap live_lab_cleanup EXIT + +command -v tmux >/dev/null 2>&1 || { echo "ok - skipped: tmux is not installed"; exit 0; } + +FAKE_HOME="$TMP_ROOT/fakehome" +mkdir -p "$FAKE_HOME/.pi/agent" "$FAKE_HOME/.treehouse/existing-pool" +printf '{}\n' > "$FAKE_HOME/.pi/agent/trust.json" +export HOME="$FAKE_HOME" +unset CLAUDE_CONFIG_DIR TMUX + +MATE_ID="labt$$-mate" +WORKER_ID="labt$$-worker" +NONCE=abc12345 + +digest() { shasum -a 256 "$1" | awk '{print $1}'; } + +# make_lab []: a lab root in the shape up +# builds, with a live private tmux server. Every readiness input starts in its +# passing state. +make_lab() { + local root="$TMP_ROOT/$1" harness=$2 claude_dir=${3:-} home tmux_dir lock_pid + home="$root/home" + mkdir -p "$root" + "$ROOT/bin/fm-lab-home.sh" create "$home" >/dev/null || fail "lab home create" + cp -R "$ROOT/bin" "$home/bin" + cp "$ROOT/AGENTS.md" "$home/AGENTS.md" + mkdir -p "$home/.pi" "$home/data/$WORKER_ID" "$root/mate/state" "$root/treehouse" + cp -R "$ROOT/.pi/extensions" "$home/.pi/extensions" + git -C "$home" init -q -b main + git -C "$home" add -A bin AGENTS.md .pi + git -C "$home" -c user.name=t -c user.email=t@example.invalid commit -qm lab + tmux_dir=$("$ROOT/bin/fm-lab-home.sh" tmux-dir "$home") || fail "lab tmux dir" + printf '%s\n' "$tmux_dir" >> "$TMP_ROOT/tmux-dirs" + find "$HOME/.treehouse" -mindepth 1 -maxdepth 1 -exec basename {} \; | sort > "$root/.treehouse-before" + { + echo 'fm-live-lab v1' + echo "harness=$harness" + echo "home=$home" + echo "expect_host=yes" + echo "mate=yes" + echo "worker=yes" + echo "nonce=$NONCE" + echo "mate_id=$MATE_ID" + echo "worker_id=$WORKER_ID" + echo "gate=$home/data/$WORKER_ID/gate" + echo "pi_trust=$(digest "$HOME/.pi/agent/trust.json")" + echo "claude_config_dir=$claude_dir" + echo "claude_store=${claude_dir:-$HOME}/.claude.json" + echo "pi_trust_store=$HOME/.pi/agent/trust.json" + echo "treehouse_dir=$HOME/.treehouse" + echo "tmux_dir=$tmux_dir" + } > "$root/.fm-live-lab" + + lab_tmux "$root" new-session -d -s firstmate -n lab -c "$root" 'exec sleep 600' + record_pid "$root" "$(lab_tmux "$root" display-message -p '#{pid}')" + lab_tmux "$root" new-window -d -t firstmate: -n main -c "$home" "printf 'LABREADY-$NONCE\n'; exec sleep 600" + lab_tmux "$root" new-window -d -t firstmate: -n "fm-$MATE_ID" -c "$root/mate" 'exec sleep 600' + lab_tmux "$root" new-window -d -t firstmate: -n "fm-$WORKER_ID" -c "$root" 'exec sleep 600' + fm_write_meta "$home/state/$MATE_ID.meta" "window=firstmate:fm-$MATE_ID" "tasktmp=/tmp/fm-$MATE_ID" + fm_write_meta "$home/state/$WORKER_ID.meta" "window=firstmate:fm-$WORKER_ID" "worktree=$home/projects/notes" "kind=secondmate" "tasktmp=/tmp/fm-$WORKER_ID" + mkdir -p "$home/projects/notes" + printf 'paused [at=1]: waiting on gate file %s to exist\n' "$home/data/$WORKER_ID/gate" > "$home/state/$WORKER_ID.status" + + lock_pid=$(lab_tmux "$root" display-message -p -t firstmate:=main '#{pane_pid}') + printf '%s\n' "$lock_pid" > "$home/state/.lock" + printf '%s\n' "$(lab_tmux "$root" display-message -p -t "firstmate:=fm-$MATE_ID" '#{pane_pid}')" > "$root/mate/state/.lock" + start_watcher "$home" + printf 'host\t%s\tx\n' "$(start_sleeper)" > "$home/state/.supervision-host" + printf '%s\n' \ + '{"seq":1,"epoch":1,"key":"k","id":"a","tag":"captain","text":"probe"}' \ + '{"seq":2,"epoch":2,"key":"k","id":"b","tag":"main","text":"LABREADY"}' > "$home/state/.host-mirror.jsonl" + write_pi_markers "$home" "$lock_pid" + [ "$harness" = claude ] && node -e 'const fs=require("node:fs");const [s,h,r]=process.argv.slice(1);fs.writeFileSync(s,JSON.stringify({keep:1,projects:{[h]:{hasTrustDialogAccepted:true},[r+"/mate"]:{hasTrustDialogAccepted:true},"/elsewhere/project":{hasTrustDialogAccepted:true}}},null,2)+"\n")' \ + "${claude_dir:-$HOME}/.claude.json" "$home" "$root" + printf '%s\n' "$root" +} + +# Recorded fixture roots must not share the runner's process group. +start_group() { perl -e 'setpgrp(0,0); exec @ARGV' "$@" >/dev/null 2>&1 & } + +record_pid() { # + printf 'launch_pid=%s\nlaunch_start=%s\n' "$2" "$(ps -o lstart= -p "$2" | awk '{$1=$1; print}')" >> "$1/.fm-live-lab" +} + +lab_tmux() { # + local dir + dir=$(sed -n 's/^tmux_dir=//p' "$1/.fm-live-lab") + shift + env -u TMUX TMUX_TMPDIR="$dir" tmux "$@" +} + +start_sleeper() { + sleep 600 >/dev/null 2>&1 & + printf '%s\n' "$!" >> "$TMP_ROOT/pids" + printf '%s\n' "$!" +} + +start_watcher() { # : a live process holding a matching watcher lock + local home=$1 pid lock="$1/state/.watch.lock" + pid=$(start_sleeper) + mkdir -p "$lock" + printf '%s\n' "$pid" > "$lock/pid" + printf '%s\n' "$home" > "$lock/fm-home" + printf '%s\n' "$home/bin/fm-watch.sh" > "$lock/watcher-path" + fm_test_pid_identity "$pid" > "$lock/pid-identity" + touch "$home/state/.last-watcher-beat" +} + +write_pi_markers() { # + local home=$1 pid=$2 + v() { FM_HOME="$home" bash -c '. "$1/bin/fm-wake-lib.sh"; fm_pi_extension_version "$1/.pi/extensions/$2"' _ "$home" "$1"; } + printf '%s\n%s\ngeneration=1 phase=active\n' "$(v fm-primary-pi-watch.ts)" "$pid" > "$home/state/.pi-watch-extension-loaded" + printf '%s\n%s\n' "$(v fm-primary-turnend-guard.ts)" "$pid" > "$home/state/.pi-turnend-extension-loaded" + printf '%s\n' "$pid" > "$home/state/.pi-branch-extension-loaded" +} + +run_check() { # : sets CHECK_OUT and CHECK_RC + CHECK_OUT=$("$LIVE_LAB" check "$1" 2>&1) + CHECK_RC=$? +} + +set_record() { # + sed -i.bak "s|^$2=.*|$2=$3|" "$1/.fm-live-lab" && rm -f "$1/.fm-live-lab.bak" +} + +# ---- Claude lab: every check passes on the shape up builds ----------------- + +C=$(make_lab c claude) +CH="$C/home" +run_check "$C" +expect_code 0 "$CHECK_RC" "a Claude lab in up's shape is ready: $CHECK_OUT" +for name in primary probe trust mirror host watcher mate worker treehouse; do + assert_contains "$CHECK_OUT" "ok $name:" "the $name check passes on a ready Claude lab" +done +pass "a ready Claude lab passes every readiness check" + +# primary: the lab session lock must name a live process (session start ran). +printf '999999\n' > "$CH/state/.lock" +run_check "$C" +expect_code 1 "$CHECK_RC" "a dead session lock is not ready" +assert_contains "$CHECK_OUT" "fail primary: the lab session lock names no live process" "primary names the dead lock" +lab_tmux "$C" display-message -p -t firstmate:=main '#{pane_pid}' > "$CH/state/.lock" +pass "primary fails when session start never took the lab lock" + +# primary: a lab home that is a linked worktree is not the genuine primary +# checkout a mirrored Claude primary needs. +WT_CASE="$TMP_ROOT/wtcase" +fm_git_worktree "$WT_CASE/project" "$WT_CASE/wt" lab-wt +cp "$C/.fm-live-lab" "$WT_CASE/.fm-live-lab" +set_record "$WT_CASE" home "$WT_CASE/wt" +lab_tmux "$C" new-window -d -t firstmate: -n wtmain -c "$WT_CASE/wt" 'exec sleep 600' +lab_tmux "$C" kill-window -t firstmate:=main +lab_tmux "$C" rename-window -t firstmate:=wtmain main +run_check "$WT_CASE" +assert_contains "$CHECK_OUT" "fail primary: the lab home is not a primary checkout" "primary refuses a linked-worktree home" +lab_tmux "$C" kill-window -t firstmate:=main +lab_tmux "$C" new-window -d -t firstmate: -n main -c "$CH" "printf 'LABREADY-$NONCE\n'; exec sleep 600" +lab_tmux "$C" display-message -p -t firstmate:=main '#{pane_pid}' > "$CH/state/.lock" +pass "primary fails when the primary runs in a linked worktree instead of the lab's primary checkout" + +# probe: the nonce reply proves the model is accepted and a turn completed. +set_record "$C" nonce 00000000 +run_check "$C" +assert_contains "$CHECK_OUT" "fail probe: no LABREADY-00000000 reply" "probe names the missing reply" +set_record "$C" nonce "$NONCE" +pass "probe fails when the primary never answered its nonce" + +# trust: the workspace-trust prompt wedged the first lab. +cp "$HOME/.claude.json" "$TMP_ROOT/claude.json.keep" +node -e 'const fs=require("node:fs");const [s,h]=process.argv.slice(1);const j=JSON.parse(fs.readFileSync(s,"utf8"));delete j.projects[h];fs.writeFileSync(s,JSON.stringify(j))' "$HOME/.claude.json" "$CH" +run_check "$C" +assert_contains "$CHECK_OUT" "fail trust: $CH has no registered Claude workspace trust" "trust names the untrusted home" +cp "$TMP_ROOT/claude.json.keep" "$HOME/.claude.json" +pass "trust fails when the lab home has no registered Claude trust" + +# mirror: the dialog mirror feed must be wired and hold both sides. +cp "$CH/state/.host-mirror.jsonl" "$TMP_ROOT/mirror.keep" +head -n 1 "$TMP_ROOT/mirror.keep" > "$CH/state/.host-mirror.jsonl" +run_check "$C" +assert_contains "$CHECK_OUT" "fail mirror: the dialog mirror has no captain and main entry yet (captain=1 main=0)" "mirror needs a main entry" +rm -f "$CH/state/.host-mirror.jsonl" +run_check "$C" +assert_contains "$CHECK_OUT" "fail mirror: fm-host-mirror.sh check exited 1" "mirror fails without a mirror file" +cp "$TMP_ROOT/mirror.keep" "$CH/state/.host-mirror.jsonl" +pass "mirror fails when the feed is unwired or has not recorded a whole turn" + +# host: expected on Claude, refused when it should be absent. +host_pid=$(awk -F '\t' '{print $2}' "$CH/state/.supervision-host") +kill "$host_pid" 2>/dev/null +wait "$host_pid" 2>/dev/null +run_check "$C" +assert_contains "$CHECK_OUT" "fail host: no live supervision host (expected one)" "host names the missing host" +set_record "$C" expect_host no +run_check "$C" +assert_contains "$CHECK_OUT" "ok host: none running, as expected" "an opted-out lab expects no host" +assert_not_contains "$CHECK_OUT" "mirror" "an opted-out lab skips the mirror" +host_pid=$(start_sleeper) +printf 'host\t%s\tx\n' "$host_pid" > "$CH/state/.supervision-host" +run_check "$C" +assert_contains "$CHECK_OUT" "fail host: supervision host pid $host_pid runs (expected none)" "an opted-out lab refuses a host" +set_record "$C" expect_host yes +pass "host passes and fails according to --expect-host" + +# watcher: a stale beacon is not supervision. +touch -t 202001010000 "$CH/state/.last-watcher-beat" +run_check "$C" +assert_contains "$CHECK_OUT" "fail watcher: no live watcher with a fresh beacon" "watcher names the stale beacon" +touch "$CH/state/.last-watcher-beat" +pass "watcher fails on a stale beacon" + +# mate: its own window, targeted exactly. A missing window must not resolve to +# another one (tmux falls back to the current window for an unknown name). +lab_tmux "$C" kill-window -t "firstmate:=fm-$MATE_ID" +run_check "$C" +assert_contains "$CHECK_OUT" "fail mate: the $MATE_ID window is not running" "mate names its missing window" +lab_tmux "$C" new-window -d -t firstmate: -n "fm-$MATE_ID" -c "$C/mate" 'exec sleep 600' +rm -f "$C/mate/state/.lock" +run_check "$C" +assert_contains "$CHECK_OUT" "fail mate: the mate holds no session lock yet" "mate needs its own session lock" +lab_tmux "$C" display-message -p -t "firstmate:=fm-$MATE_ID" '#{pane_pid}' > "$C/mate/state/.lock" +pass "mate fails when its window is gone or it never reached its charter" + +# The current-state reader, not an old event, establishes the gate wait. +GATE="$CH/data/$WORKER_ID/gate" +assert_contains "$(sed -n 's/^gate=//p' "$C/.fm-live-lab")" "$CH/data/$WORKER_ID/" "operator can find the gate in the worker's task directory" +: > "$CH/state/$WORKER_ID.status" +run_check "$C" +assert_contains "$CHECK_OUT" "fail worker: the worker is not currently parked" "worker needs a current pause" +printf 'paused [at=1]: waiting on gate file %s to exist\n' "$GATE" > "$CH/state/$WORKER_ID.status" +printf 'working [at=2]: resumed\n' >> "$CH/state/$WORKER_ID.status" +run_check "$C" +assert_contains "$CHECK_OUT" "fail worker: the worker is not currently parked" "stale paused event cannot pass readiness" +printf 'paused [at=3]: waiting on gate file %s to exist\n' "$GATE" >> "$CH/state/$WORKER_ID.status" +run_check "$C" +assert_contains "$CHECK_OUT" "ok worker: $WORKER_ID parked on $GATE" "current pause passes readiness" +pass "worker readiness follows current crew state and the recorded accessible gate" + +# treehouse: a worker pool must land inside the lab, never in ~/.treehouse. +mkdir "$HOME/.treehouse/notes-leaked" +run_check "$C" +assert_contains "$CHECK_OUT" "fail treehouse: new ~/.treehouse entries: notes-leaked" "treehouse names the leaked pool" +rmdir "$HOME/.treehouse/notes-leaked" +LATER_HOME="$TMP_ROOT/later-home" +mkdir -p "$LATER_HOME" +CHECK_OUT=$(HOME="$LATER_HOME" "$LIVE_LAB" check "$C" 2>&1) +expect_code 0 "$?" "the Claude lab is ready again after every restore, even from a shell with another HOME: $CHECK_OUT" +pass "treehouse fails when a pool lands in ~/.treehouse" + +# ---- Pi lab: extensions and the session-only trust store ------------------- + +P=$(make_lab p pi) +PH="$P/home" +run_check "$P" +expect_code 0 "$CHECK_RC" "a Pi lab in up's shape is ready: $CHECK_OUT" +assert_contains "$CHECK_OUT" "ok extensions: fm-primary-pi-watch fm-primary-turnend-guard fm-branch-supervision" "all three extensions load" +assert_contains "$CHECK_OUT" "ok trust: Pi trust store unchanged" "the Pi trust store is untouched" +assert_not_contains "$CHECK_OUT" "mirror" "a Pi lab has no host mirror check" +rm -f "$PH/state/.pi-branch-extension-loaded" +run_check "$P" +assert_contains "$CHECK_OUT" "fail extensions: fm-branch-supervision.ts is not loaded by the lock holder" "the missing branch extension is named" +printf '%s\n' "$(sed -n 1p "$PH/state/.lock")" > "$PH/state/.pi-branch-extension-loaded" +printf 'stale\n' > "$PH/state/.pi-turnend-extension-loaded" +run_check "$P" +assert_contains "$CHECK_OUT" "fail extensions: fm-primary-turnend-guard.ts is not loaded at its current build" "a stale turn-end build is named" +pass "extensions fail when the Pi lab lacks the branch extension or loads a stale build" + +# ---- down ------------------------------------------------------------------- + +NOT_LAB="$TMP_ROOT/not-a-lab" +mkdir -p "$NOT_LAB/keep" +out=$("$LIVE_LAB" down "$NOT_LAB" 2>&1) +expect_code 1 "$?" "down refuses a path without a lab record" +assert_contains "$out" "carries no lab record" "the refusal names the missing record" +assert_present "$NOT_LAB/keep" "a refused down removes nothing" +pass "down refuses anything up did not build" + +C_HASH=$(printf '%s' "$CH" | shasum -a 256 | awk '{print $1}') +OTHER_ID="labt$$-other" +fm_write_meta "$CH/state/$OTHER_ID.meta" "window=firstmate:fm-$OTHER_ID" "tasktmp=/tmp/fm-$OTHER_ID" +mkdir -p "/tmp/fm-$WORKER_ID/gotmp" "/tmp/fm-$MATE_ID" "/tmp/fm-$WORKER_ID+$C_HASH" "/tmp/fm-$OTHER_ID+$C_HASH" "/tmp/fm-$OTHER_ID" +# An outsider opening a lab path is not owned by the lab. +printf 'sleep 600\n' > "$C/stray.sh" +bash "$C/stray.sh" >/dev/null 2>&1 & +STRAY=$! +printf '%s\n' "$STRAY" >> "$TMP_ROOT/pids" +until STRAY_CHILD=$(pgrep -P "$STRAY" sleep); do sleep 0.1; done +printf '%s\n' "$STRAY_CHILD" >> "$TMP_ROOT/pids" +# A launch-recorded process and its child must be stopped even when not in tmux. +start_group sleep 600 +OWNED=$! +printf '%s\n' "$OWNED" >> "$TMP_ROOT/pids" +record_pid "$C" "$OWNED" +# A process in the runner's group is not part of any recorded lab group. +sleep 600 >/dev/null 2>&1 & +UNRELATED=$! +printf '%s\n' "$UNRELATED" >> "$TMP_ROOT/pids" +[ "$(ps -o pgid= -p "$UNRELATED" | awk '{$1=$1; print}')" != "$(ps -o pgid= -p "$OWNED" | awk '{$1=$1; print}')" ] || fail "fixture roots must have their own group" +# A sibling lab root that shares this root as a string prefix is not this lab. +mkdir -p "${C}2" +printf 'sleep 600\n' > "${C}2/stray.sh" +bash "${C}2/stray.sh" 2>/dev/null & +SIBLING=$! +printf '%s\n' "$SIBLING" >> "$TMP_ROOT/pids" +# The worker spawn failed after keeping its task temp dirs, before its meta. +rm -f "$CH/state/$WORKER_ID.meta" +C_TMUX=$(sed -n 's/^tmux_dir=//p' "$C/.fm-live-lab") +out=$(HOME="$LATER_HOME" "$LIVE_LAB" down "$C" 2>&1) +expect_code 0 "$?" "down of a clean Claude lab succeeds from a shell with another HOME: $out" +kill -0 "$STRAY" 2>/dev/null || fail "down leaves unrelated processes opening the lab path alone" +kill -0 "$STRAY_CHILD" 2>/dev/null || fail "down leaves their descendants alone" +! kill -0 "$OWNED" 2>/dev/null || fail "down stops recorded launch processes" +kill -0 "$UNRELATED" 2>/dev/null || fail "down signalled an unrelated process outside recorded groups" +assert_absent "$C" "down removes the lab root" +kill -0 "$SIBLING" 2>/dev/null || fail "down leaves a sibling root's process running" +pkill -P "$SIBLING" 2>/dev/null +kill "$SIBLING" 2>/dev/null +assert_absent "$C_TMUX" "down removes the private tmux directory" +assert_absent "/tmp/fm-$WORKER_ID" "down removes the worker's task temp dir, even without its meta" +assert_absent "/tmp/fm-$MATE_ID" "down removes the mate's task temp dir" +assert_absent "/tmp/fm-$WORKER_ID+$C_HASH" "down removes the worker's launch dir" +assert_absent "/tmp/fm-$OTHER_ID+$C_HASH" "down removes a lab-spawned task's launch dir scoped to the lab home" +assert_present "/tmp/fm-$OTHER_ID" "down keeps a task temp dir another home could share" +kept=$(node -e 'const j=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));console.log(JSON.stringify([j.keep,Object.keys(j.projects).sort()]))' "$HOME/.claude.json") +assert_equals '[1,["/elsewhere/project"]]' "$kept" "down removes exactly the lab's Claude project entries" +assert_contains "$out" "removed: 2 Claude project entries" "down reports the removed entries" +kill "$STRAY" "$STRAY_CHILD" 2>/dev/null || true +pass "down stops only recorded lab processes, removes trust entries and task temp dirs" + +# The Claude store up selected is the one check and down use, even from a later +# shell with another CLAUDE_CONFIG_DIR, and a symlinked store stays a symlink. +SC="$TMP_ROOT/claude-config" +mkdir -p "$SC" +S=$(make_lab s claude "$SC") +mv "$SC/.claude.json" "$TMP_ROOT/claude-store-target.json" +ln -s "$TMP_ROOT/claude-store-target.json" "$SC/.claude.json" +HOME_STORE_BEFORE=$(digest "$HOME/.claude.json") +CHECK_OUT=$(CLAUDE_CONFIG_DIR="$TMP_ROOT/other-config" "$LIVE_LAB" check "$S" 2>&1) +assert_contains "$CHECK_OUT" "ok trust: $S/home is trusted in the Claude store" "check reads the recorded store" +out=$(CLAUDE_CONFIG_DIR="$TMP_ROOT/other-config" "$LIVE_LAB" down "$S" 2>&1) +expect_code 0 "$?" "down of a lab on a configured store succeeds: $out" +assert_contains "$out" "removed: 2 Claude project entries" "down removes the entries from the recorded store" +[ -L "$SC/.claude.json" ] || fail "down keeps a symlinked Claude store a symlink" +kept=$(node -e 'const j=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));console.log(JSON.stringify([j.keep,Object.keys(j.projects).sort()]))' "$TMP_ROOT/claude-store-target.json") +assert_equals '[1,["/elsewhere/project"]]' "$kept" "down rewrites the symlink's target" +assert_equals "$HOME_STORE_BEFORE" "$(digest "$HOME/.claude.json")" "down leaves the default store alone" +pass "check and down use the recorded Claude store and keep a symlinked store linked" + +# TERM handlers may write trust again, and an uncooperative lab process must +# be killed before the store or lab directory is removed. +X=$(make_lab x claude) +cat > "$TMP_ROOT/exit-rewriter.sh" <<'SH' +store=$1 key=$2 marker=$3 +trap 'sleep 1; node -e "const fs=require(\"node:fs\");const [s,k]=process.argv.slice(1);const j=JSON.parse(fs.readFileSync(s,\"utf8\"));j.projects[k]={hasTrustDialogAccepted:true};fs.writeFileSync(s,JSON.stringify(j))" "$store" "$key"; echo rewrote > "$marker"; exit 0' TERM +while :; do sleep 0.1; done +SH +start_group bash "$TMP_ROOT/exit-rewriter.sh" "$HOME/.claude.json" "$X/home" "$TMP_ROOT/rewrote" +REWRITER=$! +start_group bash -c 'trap "" TERM; while :; do sleep 0.1; done' +STUBBORN=$! +sleep 0.2 +printf '%s\n%s\n' "$REWRITER" "$STUBBORN" >> "$TMP_ROOT/pids" +record_pid "$X" "$REWRITER" +record_pid "$X" "$STUBBORN" +out=$("$LIVE_LAB" down "$X" 2>&1) +expect_code 0 "$?" "down waits for lab processes: $out" +wait "$REWRITER" 2>/dev/null || true +assert_equals rewrote "$(cat "$TMP_ROOT/rewrote" 2>/dev/null)" "TERM handler rewrote its Claude key before down returned" +! kill -0 "$STUBBORN" 2>/dev/null || fail "down must kill a TERM-resistant lab process" +sleep 1.5 +lab_keys=$(node -e 'const j=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));console.log(Object.keys(j.projects).filter(k=>k.startsWith(process.argv[2])).join(" "))' "$HOME/.claude.json" "$X") +assert_equals "" "$lab_keys" "no exiting lab process re-adds Claude trust" +pass "down waits for TERM handlers and escalates before removing trust" + +# A pane root may exit on TERM while its child remains alive, reparented and +# still able to write Claude trust. Teardown must wait for the captured child. +ORPHAN=$(make_lab orphan claude) +cat > "$TMP_ROOT/orphan-rewriter.sh" <<'SH' +store=$1 key=$2 marker=$3 +trap 'sleep 1; node -e "const fs=require(\"node:fs\");const [s,k]=process.argv.slice(1);const j=JSON.parse(fs.readFileSync(s,\"utf8\"));j.projects[k]={hasTrustDialogAccepted:true};fs.writeFileSync(s,JSON.stringify(j))" "$store" "$key"; echo rewrote > "$marker"; exit 0' TERM +while :; do sleep 0.1; done +SH +# shellcheck disable=SC2016 # Positional parameters expand in the launched shell. +start_group bash -c 'bash "$1" "$2" "$3" "$4" & echo $! > "$5"; while :; do sleep 0.1; done' _ \ + "$TMP_ROOT/orphan-rewriter.sh" "$HOME/.claude.json" "$ORPHAN/home" "$TMP_ROOT/orphan-rewrote" "$TMP_ROOT/orphan-child" +ORPHAN_ROOT=$! +until [ -s "$TMP_ROOT/orphan-child" ]; do sleep 0.1; done +ORPHAN_CHILD=$(cat "$TMP_ROOT/orphan-child") +printf '%s\n%s\n' "$ORPHAN_ROOT" "$ORPHAN_CHILD" >> "$TMP_ROOT/pids" +record_pid "$ORPHAN" "$ORPHAN_ROOT" +out=$("$LIVE_LAB" down "$ORPHAN" 2>&1) +expect_code 0 "$?" "down waits for a reparented child: $out" +assert_equals rewrote "$(cat "$TMP_ROOT/orphan-rewrote" 2>/dev/null)" "child finished its TERM handler before cleanup" +lab_keys=$(node -e 'const j=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));console.log(Object.keys(j.projects).filter(k=>k.startsWith(process.argv[2])).join(" "))' "$HOME/.claude.json" "$ORPHAN") +assert_equals "" "$lab_keys" "reparented child cannot re-add trust after down" +pass "down waits for captured descendants after their root exits" + +# A recorded process can create a new descendant only after TERM arrives. +LATE=$(make_lab late claude) +cat > "$TMP_ROOT/late-rewriter.sh" <<'SH' +store=$1 key=$2 marker=$3 +trap 'bash -c '\''sleep 1; node -e "const fs=require(\"node:fs\");const [s,k]=process.argv.slice(1);const j=JSON.parse(fs.readFileSync(s,\"utf8\"));j.projects[k]={hasTrustDialogAccepted:true};fs.writeFileSync(s,JSON.stringify(j))" "$1" "$2"'\'' _ "$store" "$key" >/dev/null 2>&1 & echo $! > "$marker"; exit 0' TERM +echo ready > "$marker.ready" +while :; do sleep 0.1; done +SH +start_group bash "$TMP_ROOT/late-rewriter.sh" "$HOME/.claude.json" "$LATE/home" "$TMP_ROOT/late-child" +LATE_ROOT=$! +printf '%s\n' "$LATE_ROOT" >> "$TMP_ROOT/pids" +for ((attempt=0; attempt<50; attempt++)); do + [ -s "$TMP_ROOT/late-child.ready" ] && break + sleep 0.1 +done +assert_present "$TMP_ROOT/late-child.ready" "TERM fixture installed its handler before down" +record_pid "$LATE" "$LATE_ROOT" +out=$("$LIVE_LAB" down "$LATE" 2>&1) +expect_code 0 "$?" "down waits for a child born during TERM: $out" +assert_present "$TMP_ROOT/late-child" "TERM handler spawned a child" +LATE_CHILD=$(cat "$TMP_ROOT/late-child") +printf '%s\n' "$LATE_CHILD" >> "$TMP_ROOT/pids" +case "$(ps -o stat= -p "$LATE_CHILD" 2>/dev/null | awk '{$1=$1; print}')" in ''|Z*) ;; *) fail "down leaves a TERM-spawned child alive" ;; esac +lab_keys=$(node -e 'const j=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));console.log(Object.keys(j.projects).filter(k=>k.startsWith(process.argv[2])).join(" "))' "$HOME/.claude.json" "$LATE") +assert_equals "" "$lab_keys" "TERM-spawned child cannot re-add trust after down" +pass "down tracks descendants spawned during TERM" + +# A reused PID with a different start time must not own its new process tree. +Y=$(make_lab y claude) +# shellcheck disable=SC2016 # Positional parameters expand in the launched shell. +start_group bash -c 'sleep 600 & echo $! > "$1"; wait' _ "$TMP_ROOT/reused-child"; REUSED=$! +until [ -s "$TMP_ROOT/reused-child" ]; do sleep 0.1; done +REUSED_CHILD=$(cat "$TMP_ROOT/reused-child") +printf '%s\n%s\n' "$REUSED" "$REUSED_CHILD" >> "$TMP_ROOT/pids" +printf 'launch_pid=%s\nlaunch_start=Mon Jan 1 00:00:00 1990\n' "$REUSED" >> "$Y/.fm-live-lab" +out=$("$LIVE_LAB" down "$Y" 2>&1) +expect_code 0 "$?" "down skips the mismatched root: $out" +kill -0 "$REUSED" 2>/dev/null || fail "down killed a reused PID" +kill -0 "$REUSED_CHILD" 2>/dev/null || fail "down killed the reused PID's child" +pass "down ignores roots with mismatched start times" + +# A group observed empty must not be admitted again if its id is later reused. +# The ps shim hides the first group's only member on pass 2, then presents an +# unrelated process under that pgid on pass 3 while another lab group waits. +GROUP_REUSE=$(make_lab group-reuse claude) +start_group python3 -c 'import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(600)' +GROUP_ROOT=$! +start_group python3 -c 'import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(600)' +WAIT_ROOT=$! +sleep 0.2 +printf '%s\n%s\n' "$GROUP_ROOT" "$WAIT_ROOT" >> "$TMP_ROOT/pids" +record_pid "$GROUP_REUSE" "$GROUP_ROOT" +record_pid "$GROUP_REUSE" "$WAIT_ROOT" +sleep 600 >/dev/null 2>&1 & +GROUP_OUTSIDER=$! +printf '%s\n' "$GROUP_OUTSIDER" >> "$TMP_ROOT/pids" +GROUP_ID=$(ps -o pgid= -p "$GROUP_ROOT" | awk '{$1=$1; print}') +mkdir -p "$TMP_ROOT/group-ps-bin" +cat > "$TMP_ROOT/group-ps-bin/ps" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = -axo ] && [ "${2:-}" = 'pid=,ppid=,pgid=,stat=,lstart=' ]; then + count=$(cat "$PS_SCAN_COUNT" 2>/dev/null || echo 0) + count=$((count + 1)) + echo "$count" > "$PS_SCAN_COUNT" + "$REAL_PS" "$@" | awk -v scan="$count" -v root="$PS_GROUP_ROOT" -v outsider="$PS_OUTSIDER" -v group="$PS_GROUP_ID" ' + scan >= 2 && $1 == root { next } + scan >= 3 && $1 == outsider { $3=group } + { print } + ' +else + "$REAL_PS" "$@" +fi +SH +chmod +x "$TMP_ROOT/group-ps-bin/ps" +out=$(REAL_PS="$(command -v ps)" PS_SCAN_COUNT="$TMP_ROOT/group-scan-count" PS_GROUP_ROOT="$GROUP_ROOT" PS_OUTSIDER="$GROUP_OUTSIDER" PS_GROUP_ID="$GROUP_ID" PATH="$TMP_ROOT/group-ps-bin:$PATH" "$LIVE_LAB" down "$GROUP_REUSE" 2>&1) +expect_code 0 "$?" "down ignores a reused group id: $out" +[ "$(cat "$TMP_ROOT/group-scan-count")" -ge 3 ] || fail "fixture did not expose the reused group id" +kill -0 "$GROUP_OUTSIDER" 2>/dev/null || fail "down signalled an unrelated process with a reused group id" +pass "down drops empty groups permanently before their ids can be reused" + +# Simulate a recorded PID changing identity after TERM: the first process +# snapshot matches its start time, subsequent snapshots describe a reused PID. +Z=$(make_lab z claude) +start_group python3 -c 'import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(600)' +REPLACED=$! +printf '%s\n' "$REPLACED" >> "$TMP_ROOT/pids" +record_pid "$Z" "$REPLACED" +mkdir -p "$TMP_ROOT/ps-bin" +printf '%s\n' "$REPLACED" > "$TMP_ROOT/replaced-pid" +cat > "$TMP_ROOT/ps-bin/ps" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = -o ] && [ "${2:-}" = 'stat=,lstart=' ] && [ "${4:-}" = "$(cat "$PS_TARGET")" ]; then + count=$(cat "$PS_COUNT" 2>/dev/null || echo 0) + echo "$((count + 1))" > "$PS_COUNT" + if [ "$count" -gt 0 ]; then + echo 'S Mon Jan 1 00:00:00 1990' + else + "$REAL_PS" "$@" + fi +else + "$REAL_PS" "$@" +fi +SH +chmod +x "$TMP_ROOT/ps-bin/ps" +start=$(date +%s) +out=$(REAL_PS="$(command -v ps)" PS_COUNT="$TMP_ROOT/ps-count" PS_TARGET="$TMP_ROOT/replaced-pid" PATH="$TMP_ROOT/ps-bin:$PATH" "$LIVE_LAB" down "$Z" 2>&1) +expect_code 0 "$?" "down must not treat the changed PID as a survivor: $out" +[ "$(( $(date +%s) - start ))" -lt 8 ] || fail "down waited on a PID with a different start time" +kill -0 "$REPLACED" 2>/dev/null || fail "down killed a PID after its recorded identity changed" +kill "$REPLACED" 2>/dev/null || true +pass "down revalidates process identity during its bounded wait" + +printf '{"trusted":["/somewhere"]}\n' > "$HOME/.pi/agent/trust.json" +run_check "$P" +assert_contains "$CHECK_OUT" "fail trust: the Pi trust store changed since up began" "a written Pi trust store is caught" +pass "trust fails on Pi when the lab wrote the persistent Pi trust store" + +out=$("$LIVE_LAB" down "$P" 2>&1) +expect_code 1 "$?" "down reports a changed Pi trust store" +assert_contains "$out" "the Pi trust store changed since up began; left as is" "the Pi trust change is named" +assert_absent "$P" "the lab is still removed" +assert_equals '{"trusted":["/somewhere"]}' "$(cat "$HOME/.pi/agent/trust.json")" "down never rewrites the Pi trust store" +pass "down removes the lab but reports, without reverting, a written Pi trust store" + +# ---- up argument safety ----------------------------------------------------- + +EXISTING="$TMP_ROOT/existing" +mkdir -p "$EXISTING/keep" +out=$("$LIVE_LAB" up --harness claude "$EXISTING" 2>&1) +expect_code 1 "$?" "up refuses an existing lab root" +assert_contains "$out" "a lab root must not exist yet" "the refusal names the existing root" +assert_present "$EXISTING/keep" "a refused up touches nothing" +out=$("$LIVE_LAB" up --harness codex "$TMP_ROOT/new" 2>&1) +expect_code 1 "$?" "up refuses an unsupported harness" +assert_absent "$TMP_ROOT/new" "a refused harness creates nothing" +pass "up refuses an existing root and an unsupported harness" + +# up persists full-width, distinct task IDs even when checkout fails before +# launching a harness; down can still clean this partial lab. +PARTIAL="$TMP_ROOT/partial-lab" +mkdir -p "$TMP_ROOT/stub-bin" +cat > "$TMP_ROOT/stub-bin/claude" <<'SH' +#!/bin/sh +: > "$FM_HOME/state/.session-start-complete" +exec sleep 45 >/dev/null 2>&1 +SH +chmod +x "$TMP_ROOT/stub-bin/claude" +out=$(PATH="$TMP_ROOT/stub-bin:$PATH" "$LIVE_LAB" up --harness claude --source "$TMP_ROOT/missing-origin" "$PARTIAL" 2>&1) +expect_code 1 "$?" "an unavailable source stops up before launch" +assert_present "$PARTIAL/.fm-live-lab" "up recorded its selected task IDs" +ids=$(awk -F= '/^(mate_id|worker_id)=/ {print $2}' "$PARTIAL/.fm-live-lab") +if ! printf '%s\n' "$ids" | grep -Eq '^lab[0-9a-f]{12}-(mate|worker)$'; then fail "task IDs need twelve nonce hex digits: $ids"; fi +assert_equals 2 "$(printf '%s\n' "$ids" | grep -Ec '^lab[0-9a-f]{12}-(mate|worker)$')" "both mate and worker use twelve nonce digits" +out=$($LIVE_LAB down "$PARTIAL" 2>&1) +expect_code 0 "$?" "down cleans a lab whose checkout failed: $out" +assert_absent "$PARTIAL" "partial lab removed" +pass "up gives both task IDs a long nonce and down cleans partial setup" + +# A rival Claude writer drops the newly registered primary key once. The +# stand-in primary checks the store on startup, while the readiness probe fails. +UPSRC="$TMP_ROOT/up-source" +mkdir -p "$UPSRC" +cp -R "$ROOT/bin" "$UPSRC/bin" +cp "$ROOT/AGENTS.md" "$UPSRC/AGENTS.md" +git -C "$UPSRC" init -q -b main +git -C "$UPSRC" add -A +git -C "$UPSRC" -c user.name=t -c user.email=t@example.invalid commit -qm source +# A worker can have written its first status while still working. That must +# not hold up the Claude primary; the generated brief must ask it to end its +# turn on the gate instead of running a foreground polling command. +WORKSRC="$TMP_ROOT/worker-source" +cp -R "$UPSRC" "$WORKSRC" +cat > "$WORKSRC/bin/fm-brief.sh" <<'SH' +#!/usr/bin/env bash +mkdir -p "$FM_HOME/data/$1" +printf '{TASK}\n{FIRSTMATE_SPEC}\n' > "$FM_HOME/data/$1/brief.md" +SH +cat > "$WORKSRC/bin/fm-tasks-axi.sh" <<'SH' +#!/usr/bin/env bash +exit 0 +SH +cat > "$WORKSRC/bin/fm-spawn.sh" <<'SH' +#!/usr/bin/env bash +id=$1 +tmux new-window -d -t firstmate: -n "fm-$id" -c "$FM_HOME" 'exec sleep 45 >/dev/null 2>&1' || exit 1 +# A missing =name can silently resolve to the current window: verify the name. +for (( n=0; n<30; n++ )); do + if tmux list-windows -t firstmate -F '#{window_name}' | grep -Fxq "fm-$id"; then + pid=$(tmux display-message -p -t "firstmate:=fm-$id" '#{pane_pid}') + [ -z "$pid" ] || break + fi + sleep 0.1 +done +[ -n "${pid:-}" ] || exit 1 +printf 'window=firstmate:fm-%s\n' "$id" > "$FM_HOME/state/$id.meta" +printf 'working [at=1]: setting up\n' > "$FM_HOME/state/$id.status" +SH +chmod +x "$WORKSRC/bin/"{fm-brief,fm-tasks-axi,fm-spawn}.sh +git -C "$WORKSRC" add -A +git -C "$WORKSRC" -c user.name=t -c user.email=t@example.invalid commit -qm stubs +W="$TMP_ROOT/worker-up" +out=$(SHELL=/bin/sh PATH="$TMP_ROOT/stub-bin:$PATH" "$LIVE_LAB" up --harness claude --worker --source "$WORKSRC" --timeout 0 "$W" 2>&1) +expect_code 1 "$?" "unanswered probe leaves worker lab for inspection: $out" +assert_contains "$out" "primary: claude" "the unparked worker did not block primary launch" +assert_contains "$out" "gate: $W/home/data/" "up shows the gate path" +assert_contains "$out" "then message the worker to resume" "up explains the release message" +worker_id=$(sed -n 's/^worker_id=//p' "$W/.fm-live-lab") +brief=$(<"$W/home/data/$worker_id/brief.md") +assert_contains "$brief" "append one paused status line naming the gate file" "worker declares its wait" +assert_contains "$brief" "and end your turn" "worker ends its waiting turn" +assert_contains "$brief" "Do not poll or sleep in a foreground command" "worker does not run a blocking wait" +assert_contains "$brief" "When a later message resumes you, check that" "worker checks the gate after a message" +assert_contains "$out" "fail worker: the worker is not currently parked" "final readiness remains strict" +node -e 'const j=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));process.exit(j.projects?.[process.argv[2]]?.hasTrustDialogAccepted===true?0:1)' "$HOME/.claude.json" "$W/home" || fail "primary trust was not registered before launch" +out=$("$LIVE_LAB" down "$W" 2>&1) +expect_code 0 "$?" "down cleans the worker lab: $out" +pass "up launches primary after worker status without weakening final parked readiness" + +# If a spawn reports success without a pane, fail at the missing PID rather +# than invoking ps with an empty -p argument or waiting for readiness. +cat > "$WORKSRC/bin/fm-spawn.sh" <<'SH' +#!/usr/bin/env bash +id=$1 +printf 'window=firstmate:fm-%s\n' "$id" > "$FM_HOME/state/$id.meta" +printf 'working [at=1]: setting up\n' > "$FM_HOME/state/$id.status" +SH +git -C "$WORKSRC" add bin/fm-spawn.sh +git -C "$WORKSRC" -c user.name=t -c user.email=t@example.invalid commit -qm missing-pane +NO_PANE="$TMP_ROOT/no-pane" +out=$(SHELL=/bin/sh PATH="$TMP_ROOT/stub-bin:$PATH" "$LIVE_LAB" up --harness claude --worker --source "$WORKSRC" --timeout 0 "$NO_PANE" 2>&1) +expect_code 1 "$?" "up refuses a worker without a pane PID: $out" +assert_contains "$out" "cannot record lab process: missing or invalid PID ''" "missing pane PID fails at launch recording" +assert_not_contains "$out" "list of process IDs must follow -p" "ps never receives an empty PID" +out=$("$LIVE_LAB" down "$NO_PANE" 2>&1) +expect_code 0 "$?" "down cleans the missing-pane lab: $out" +pass "up fails immediately when a spawned worker has no pane PID" + +FAKEBIN="$TMP_ROOT/fakebin" +mkdir -p "$FAKEBIN" +cat > "$FAKEBIN/claude" <<'SH' +#!/usr/bin/env bash +sleep 1.5 +if node -e 'const [s,k]=process.argv.slice(1);const j=JSON.parse(require("node:fs").readFileSync(s,"utf8"));process.exit(j.projects?.[k]?.hasTrustDialogAccepted===true?0:1)' "$HOME/.claude.json" "$FM_HOME"; then + echo present > "$FM_HOME/../claude-launch-trust" +else + echo absent > "$FM_HOME/../claude-launch-trust" +fi +: > "$FM_HOME/state/.session-start-complete" +exec sleep 45 >/dev/null 2>&1 +SH +chmod +x "$FAKEBIN/claude" +U="$TMP_ROOT/up-lab" +( + end=$(( $(date +%s) + 120 )) + until node -e 'const j=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));process.exit(j.projects?.[process.argv[2]]?1:0)' "$HOME/.claude.json" "$U/home" 2>/dev/null; do + [ "$(date +%s)" -lt "$end" ] || exit 1 + sleep 0.05 + done + sleep 0.5 + node -e 'const fs=require("node:fs");const [s,k]=process.argv.slice(1);const j=JSON.parse(fs.readFileSync(s,"utf8"));delete j.projects[k];fs.writeFileSync(s,JSON.stringify(j))' "$HOME/.claude.json" "$U/home" + echo dropped > "$TMP_ROOT/rival-dropped" +) & +RIVAL=$! +printf '%s\n' "$RIVAL" >> "$TMP_ROOT/pids" +out=$(SHELL=/bin/sh PATH="$FAKEBIN:$PATH" "$LIVE_LAB" up --harness claude --source "$UPSRC" --ref HEAD --timeout 1 "$U" 2>&1) +expect_code 1 "$?" "stand-in primary does not answer probe" +wait "$RIVAL" 2>/dev/null || true +assert_equals dropped "$(cat "$TMP_ROOT/rival-dropped" 2>/dev/null)" "rival dropped primary trust once" +assert_equals present "$(cat "$U/claude-launch-trust" 2>/dev/null)" "primary launched trusted after the rival write" +assert_contains "$out" "ok trust: $U/home is trusted in the Claude store" "readiness sees primary trust" +out=$("$LIVE_LAB" down "$U" 2>&1) +expect_code 0 "$?" "down removes the up-built lab: $out" +pass "up re-registers primary trust after a concurrent Claude write" + +# ---- fm-claude-trust.sh --lab-home ------------------------------------------- + +T="$TMP_ROOT/trust" +mkdir -p "$T/config" +"$ROOT/bin/fm-lab-home.sh" create "$T/home" >/dev/null +cp "$ROOT/AGENTS.md" "$T/home/AGENTS.md" +mkdir -p "$T/home/bin" +git -C "$T/home" init -q -b main +out=$(CLAUDE_CONFIG_DIR="$T/config" "$TRUST" --lab-home "$T/home" 2>&1) +expect_code 0 "$?" "a marked lab primary checkout is trusted: $out" +TH=$(cd -P "$T/home" && pwd -P) +node -e 'const j=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));process.exit(j.projects[process.argv[2]].hasTrustDialogAccepted===true&&!("hasClaudeMdExternalIncludesApproved" in j.projects[process.argv[2]])?0:1)' \ + "$T/config/.claude.json" "$TH" || fail "lab-home trust is trust-only" + +mkdir -p "$T/plain/bin" +cp "$ROOT/AGENTS.md" "$T/plain/AGENTS.md" +git -C "$T/plain" init -q -b main +out=$(CLAUDE_CONFIG_DIR="$T/config" "$TRUST" --lab-home "$T/plain" 2>&1) +expect_code 1 "$?" "an unmarked checkout is refused" +assert_contains "$out" "carries no lab-home marker" "the refusal names the missing marker" + +fm_git_worktree "$T/proj" "$T/wt" lab-trust-wt +printf 'fm-lab-home v1\n' > "$T/wt/.fm-lab-home" +cp "$ROOT/AGENTS.md" "$T/wt/AGENTS.md" +mkdir -p "$T/wt/bin" +out=$(CLAUDE_CONFIG_DIR="$T/config" "$TRUST" --lab-home "$T/wt" 2>&1) +expect_code 1 "$?" "a linked worktree is refused" +assert_contains "$out" "is a linked worktree" "the refusal names the linked worktree" + +rm "$T/home/.fm-lab-home" +ln -s "$T/wt/.fm-lab-home" "$T/home/.fm-lab-home" +out=$(CLAUDE_CONFIG_DIR="$T/config" "$TRUST" --lab-home "$T/home" 2>&1) +expect_code 1 "$?" "a symlinked marker is refused" +assert_contains "$out" "is a symlink" "the refusal names the symlink" +pass "fm-claude-trust.sh --lab-home trusts only a marked lab primary checkout" From 46d58d644d1ef59d6c34ec19d11d67868cbf9d40 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:02:11 -0700 Subject: [PATCH 05/76] fix: keep watcher arms and reply listeners alive through slow cycles (#6103) * fix(bin): keep slow watcher cycles and preempted reply polls from breaking supervision - fm_pending_reply_tick selects the records it has work for in one awk pass, so settled records cost no lock or fork and the walk no longer grows with the never-pruned store. - An attached arm keeps following a live, identity-matched holder whose beacon went stale until the lock changes or the shared stall bound (fm_watcher_stall_bound), then fails with a typed stalled-holder line so the retry replaces the holder. - The remote-reply adapter reports the job worker's preemption (exit 76) as a closed window, so the listener keeps its claim and polls again instead of being relaunched every watcher cycle. * no-mistakes(document): Clarify watcher grace and attached-arm documentation --- bin/fm-pending-reply-lib.sh | 46 ++++++++++- bin/fm-procevent-remote-reply.sh | 14 +++- bin/fm-wake-lib.sh | 12 +++ bin/fm-watch-arm.sh | 42 +++++++++- bin/fm-watch.sh | 4 +- docs/configuration.md | 6 +- docs/remote-secondmates.md | 1 + docs/turnend-guard.md | 3 + tests/fm-pending-reply.test.sh | 89 +++++++++++++++++++++ tests/fm-remote-reply.test.sh | 51 +++++++++++- tests/fm-watch-arm.test.sh | 131 ++++++++++++++++++++++++++++++- 11 files changed, 383 insertions(+), 16 deletions(-) diff --git a/bin/fm-pending-reply-lib.sh b/bin/fm-pending-reply-lib.sh index 42bd2d5de4c..ce5ba3349b0 100755 --- a/bin/fm-pending-reply-lib.sh +++ b/bin/fm-pending-reply-lib.sh @@ -1445,20 +1445,60 @@ fm_pending_reply_tick_one() { # [secondmate- return 0 } +# Print, one per line, the records among ... that the tick has work +# for, reading every record once in a single awk process instead of forking +# per record. A resolved record needs work only while an escalation it opened +# is still unclosed: for every other resolved record the tick's per-record path +# (fm_pending_reply_close_escalation) is a no-op that still pays a lock and +# several forks, and records are never pruned, so that cost grew with the store. +# Every other record - any phase but resolved, no phase at all, or one awk +# cannot read - is selected, so the per-record path still decides it. Values +# follow fm_pending_reply_get: the last line for a key wins. +_fm_pending_reply_select_needing_work() { # ... + [ "$#" -gt 0 ] || return 0 + printf '%s\n' "$@" | LC_ALL=C awk ' + { + path = $0 + phase = ""; escalated = ""; closed = "" + while ((rc = (getline line < path)) > 0) { + if (index(line, "phase=") == 1) phase = substr(line, 7) + else if (index(line, "escalated_epoch=") == 1) escalated = substr(line, 17) + else if (index(line, "escalation_closed_epoch=") == 1) closed = substr(line, 25) + } + close(path) + if (rc < 0 || phase != "resolved" || (escalated != "" && closed == "")) print path + } + ' +} + # Scan every pending record for this parent state. Safe to call every poll. # Never scrapes secondmate conversation; uses only parent status, backend busy -# state, and optional secondmate-home wrong-home path checks. +# state, and optional secondmate-home wrong-home path checks. Records are +# selected in one pass first (_fm_pending_reply_select_needing_work), so a +# settled record costs no lock and no fork, and the per-record path below runs, +# unchanged, only for the records that selection returns. fm_pending_reply_tick() { # local state=$1 dir rec corr task_id phase delivered meta backend target label busy sm_home harness remote_host local observation observation_task found i - local -a observation_tasks=() observation_values=() + local -a observation_tasks=() observation_values=() records=() selected=() dir=$(fm_pending_reply_dir "$state") [ -d "$dir" ] || return 0 for rec in "$dir"/*; do [ -f "$rec" ] || continue - case "$(basename "$rec")" in + case "${rec##*/}" in .*) continue ;; esac + case "$rec" in + # A newline would split this path in the selection's input, so such a + # record skips selection and always takes the per-record path. + *$'\n'*) selected+=("$rec") ;; + *) records+=("$rec") ;; + esac + done + while IFS= read -r rec; do + selected+=("$rec") + done < <(_fm_pending_reply_select_needing_work ${records[@]+"${records[@]}"}) + for rec in ${selected[@]+"${selected[@]}"}; do corr=$(fm_pending_reply_get "$rec" corr_id) [ -n "$corr" ] || corr=$(basename "$rec") task_id=$(fm_pending_reply_get "$rec" task_id) diff --git a/bin/fm-procevent-remote-reply.sh b/bin/fm-procevent-remote-reply.sh index 9794f336962..7d545c0508b 100755 --- a/bin/fm-procevent-remote-reply.sh +++ b/bin/fm-procevent-remote-reply.sh @@ -19,6 +19,8 @@ # ingests it, acknowledges the captured generation, then registers the next # cursor-anchored source. `relisten` tells that runner to poll again in the same # process, still holding the claim, after an empty window and after that re-arm. +# A window the remote job worker preempted is reported to the runner as an empty +# window, so it relistens too (see JOB_PREEMPTED below). # A continuity break is escalated and not re-armed, so the registration is dropped # and the runner stops. The runner does not refresh the owner lease. # @@ -95,7 +97,7 @@ DOCUMENT_LOCAL_FAILURE=2 . "$SCRIPT_DIR/fm-pending-reply-lib.sh" die() { printf 'error: %s\n' "$1" >&2; exit 1; } -usage() { sed -n '2,64p' "$0" | sed 's/^# \{0,1\}//'; exit 2; } +usage() { sed -n '2,66p' "$0" | sed 's/^# \{0,1\}//'; exit 2; } sha256_file() { if command -v shasum >/dev/null 2>&1; then @@ -256,6 +258,14 @@ cmd_arm() { # honest watermark, and bin/fm-pending-reply-lib.sh consumes it so a missing # correlated report is judged only against a channel known to have caught up. WINDOW_CLOSED_EMPTY=75 +# The remote job worker's exit when it preempted this long-poll to run another +# job for the same home (bin/fm-remote-job-lib.sh header), such as the watcher's +# per-cycle liveness probe. The read is cursor-anchored and non-destructive, so a +# preempted window loses nothing: it is a window that closed early, and the +# runner relistens exactly as after WINDOW_CLOSED_EMPTY instead of reading it as +# a failed read that releases the listener's claim. It proves nothing about the +# channel being caught up, so it records no watermark. +JOB_PREEMPTED=76 cmd_source() { local id=${1:-} started rc=0 @@ -266,6 +276,8 @@ cmd_source() { "$REMOTE_LOG" "$CURSOR_OFFSET" "$CURSOR_HASH" "$WAIT_SECONDS" < /dev/null || rc=$? if [ "$rc" -eq "$WINDOW_CLOSED_EMPTY" ]; then fm_pending_reply_note_remote_channel_caught_up "$STATE" "$id" "$started" || true + elif [ "$rc" -eq "$JOB_PREEMPTED" ]; then + rc=$WINDOW_CLOSED_EMPTY fi return "$rc" } diff --git a/bin/fm-wake-lib.sh b/bin/fm-wake-lib.sh index 78da8a58600..60a9d289090 100755 --- a/bin/fm-wake-lib.sh +++ b/bin/fm-wake-lib.sh @@ -141,6 +141,18 @@ fm_poll_derived_grace() { printf '%s\n' "$derived" } +# fm_watcher_stall_bound [poll-seconds] +# Hard bound on a live watcher holder's beacon age: FM_WATCHER_STALL_BOUND, +# defaulting to 3x the watcher's stale grace (FM_WATCHER_STALE_GRACE, else +# FM_GUARD_GRACE, else fm_poll_derived_grace). Under it a live holder with a +# stale beacon is a slow cycle; at or past it bin/fm-watch.sh evicts that holder +# and bin/fm-watch-arm.sh stops following it, so both read this one definition. +fm_watcher_stall_bound() { + local poll=${1:-${FM_POLL:-15}} grace + grace=${FM_WATCHER_STALE_GRACE:-${FM_GUARD_GRACE:-$(fm_poll_derived_grace "$poll")}} + printf '%s\n' "${FM_WATCHER_STALL_BOUND:-$((grace * 3))}" +} + # fm_watcher_lock_unheld # True when the watcher lock or its symlinked owner directory is absent, or when # the existing lock records no pid at all. Any non-empty pid remains held here; diff --git a/bin/fm-watch-arm.sh b/bin/fm-watch-arm.sh index ec95458406f..1932c0b9414 100755 --- a/bin/fm-watch-arm.sh +++ b/bin/fm-watch-arm.sh @@ -32,11 +32,20 @@ # watcher: FAILED - cycle ended without an actionable reason # - a clean cycle ended with no wake and no # verified healthy successor +# watcher: FAILED - attached watcher pid= stalled (beacon s at or past hard bound s) +# - the followed holder is alive but its beacon +# reached the stall bound # It NEVER reports started/attached/healthy off a stale beacon or a dead/reused pid: a # stale-beacon or dead-pid holder either self-heals (the fresh child steals the # dead lock per the singleton self-eviction/steal path and is confirmed) or this # returns the FAILED line. On started it waits the child and propagates the wake -# reason; on attached it stays live across identity-matched successors. A cycle +# reason; on attached it stays live across identity-matched successors. Once +# attached, a stale beacon alone does not end the followed cycle: while that +# holder is alive and the lock still names it under the same identity, the arm +# keeps following it, as a started arm waits out a slow child, until the lock +# changes or the beacon reaches fm_watcher_stall_bound (bin/fm-wake-lib.sh), the +# age at which the watcher's own re-arm evicts it; there it fails with the +# stalled-holder line so its owner's retry replaces the holder. A cycle # that ends with no reason line and no healthy successor is resolved against the # watcher's identity-bound delivery record: a matching record reports that wake # and exits 0, and only a cycle that delivered nothing is the typed nonzero @@ -117,6 +126,9 @@ esac CONFIRM_TIMEOUT=${FM_ARM_CONFIRM_TIMEOUT:-$ARM_CONFIRM_DEFAULT} # Poll interval while attached to an existing healthy watcher. ATTACH_POLL=${FM_ARM_ATTACH_POLL:-0.5} +# The beacon age at which the watcher's own re-arm evicts a live holder; an +# attached arm follows a slow holder up to it (attach_and_wait). +STALL_BOUND=$(fm_watcher_stall_bound) CYCLE_LOG="$STATE/.watch-cycle-exits.log" CYCLE_LOG_LOCK="$STATE/.watch-cycle-exits.lock" CYCLE_LOG_MAX_BYTES=${FM_WATCH_CYCLE_LOG_MAX_BYTES:-262144} @@ -343,12 +355,28 @@ close_unobserved_cycle() { return 1 } +# True while is alive and this home's watcher lock still names it under +# the identity this arm's current cycle attached to, whatever its beacon age. +attached_holder_live() { + local pid=$1 lock_pid + lock_pid=$(cat "$WATCH_LOCK/pid" 2>/dev/null || true) + [ "$lock_pid" = "$pid" ] || return 1 + fm_pid_alive "$pid" || return 1 + fm_watcher_lock_matches_pid "$STATE" "$WATCH" "$pid" "$FM_HOME" || return 1 + [ "$FM_WATCHER_MATCHED_IDENTITY" = "$cycle_watcher_identity" ] +} + # Stay alive across identity-matched healthy holders. If one cycle ends, attach # to a verified successor. With no successor, report the wake that cycle durably # delivered, or fail loudly - never a clean empty completion that an adapter could # mistake for a no-op. +# A stale beacon alone does not end the followed cycle: while the holder is alive +# and the lock still names it under the same identity, it is a slow cycle, which +# a started arm tolerates by waiting on its child, so this arm keeps following it. +# Only at the stall bound, where the watcher's own re-arm evicts a live holder, +# does it fail with the typed stalled-holder line so its owner's retry replaces it. attach_and_wait() { - local attached_pid=$1 + local attached_pid=$1 age while :; do if healthy_watcher; then if [ "$HEALTHY_PID" != "$attached_pid" ] || [ "$HEALTHY_IDENTITY" != "$cycle_watcher_identity" ]; then @@ -360,6 +388,16 @@ attach_and_wait() { sleep "$ATTACH_POLL" continue fi + if attached_holder_live "$attached_pid"; then + age=$(fm_path_age "$BEAT") + if [ "$age" -lt "$STALL_BOUND" ]; then + sleep "$ATTACH_POLL" + continue + fi + cycle_log_append unknown unknown attached-holder-stalled none + echo "watcher: FAILED - attached watcher pid=$attached_pid stalled (beacon ${age}s at or past hard bound ${STALL_BOUND}s)" + return 1 + fi if wait_for_healthy_successor; then cycle_log_append unknown unknown attached-cycle-ended "attached:$HEALTHY_PID" attached_pid=$HEALTHY_PID diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 7fa311fd2a2..96bae225fa5 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -279,7 +279,9 @@ WATCHER_STALE_GRACE=${FM_WATCHER_STALE_GRACE:-${FM_GUARD_GRACE:-$(fm_poll_derive # for inspection (the grace above); at or past it the re-arm evicts the holder # instead, because a watcher whose beacon has stalled that long is not polling # and nothing else would ever replace it (evict_stalled_holder below). -WATCHER_STALL_BOUND=${FM_WATCHER_STALL_BOUND:-$((WATCHER_STALE_GRACE * 3))} +# fm_watcher_stall_bound (bin/fm-wake-lib.sh) owns the derivation, shared with +# the arm that follows this watcher. +WATCHER_STALL_BOUND=$(fm_watcher_stall_bound "$POLL") HEARTBEAT=${FM_HEARTBEAT:-600} # base seconds between heartbeat scans HEARTBEAT_MAX=${FM_HEARTBEAT_MAX:-7200} # heartbeat backoff cap CHECK_INTERVAL=${FM_CHECK_INTERVAL:-300} # seconds between *.check.sh sweeps diff --git a/docs/configuration.md b/docs/configuration.md index 209da6093a2..c97571fc862 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2326,7 +2326,7 @@ FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=800 # milliseconds the --claude turn-end guard FM_CLAUDE_AUTOARM_EPOCH_FRESH=15 # seconds a recorded auto-arm outcome remains eligible for the current event epoch's recovery or failure decision FM_CLAUDE_TURNEND_BLOCK_BUDGET=3 # consecutive --claude guard re-blocks before the verified one-time attended fail-open; safely below Claude Code's 8-block override FM_ARM_CONFIRM_TIMEOUT=10 # seconds fm-watch-arm waits to confirm a fresh watcher before reporting FAILED; default 30 on Git Bash/MSYS -FM_ARM_ATTACH_POLL=0.5 # seconds between checks while fm-watch-arm is attached to an existing healthy watcher cycle +FM_ARM_ATTACH_POLL=0.5 # seconds between checks while fm-watch-arm follows an attached watcher cycle (bin/fm-watch-arm.sh header) FM_OPENCODE_ARM_READY_TIMEOUT_MS=12000 # milliseconds the OpenCode primary watcher plugin waits for an arm attempt to report started, healthy, wake, or failure; default 35000 on Windows to stay above the MSYS confirm budget FM_PI_ARM_READY_TIMEOUT_MS=12000 # milliseconds the Pi watcher extension waits for a successor arm to report started or attached; default 35000 on Windows to stay above the MSYS confirm budget FM_WATCH_ARM_RETIRE_TIMEOUT_MS=1000 # milliseconds Pi/OpenCode wait for an unready successor arm to exit before abandoning retries @@ -2335,8 +2335,8 @@ FM_WATCH_REARM_RETRY_MAX_MS=4000 # Pi/OpenCode adapter cap for exponential con FM_WATCH_REARM_RETRY_LIMIT=5 # Pi/OpenCode adapter launch-failure retries before surfacing restoration failure FM_WATCH_CYCLE_LOG_MAX_BYTES=262144 # size cap for the arm-owned watcher lifecycle ledger FM_WATCH_CYCLE_LOG_KEEP_LINES=1000 # newest complete lifecycle rows considered when the ledger is capped -FM_WATCHER_STALE_GRACE=300 # defaults to FM_GUARD_GRACE if set, else the poll-derived grace (docs/turnend-guard.md "Guard grace and the poll cadence"); seconds a live watcher lock may have a stale beacon before re-arm errors -FM_WATCHER_STALL_BOUND= # defaults to 3x FM_WATCHER_STALE_GRACE; a live holder whose beacon is stale past this hard bound is evicted with TERM and replaced by the re-arm rather than refused (docs/turnend-guard.md, bin/fm-watch.sh header) +FM_WATCHER_STALE_GRACE=300 # defaults to FM_GUARD_GRACE if set, else the poll-derived grace (docs/turnend-guard.md "Guard grace and the poll cadence"); seconds before a fresh arm refuses a live holder's stale beacon (attached arms: FM_WATCHER_STALL_BOUND) +FM_WATCHER_STALL_BOUND= # live-holder stall bound; default and arm/re-arm behavior: docs/turnend-guard.md "Guard grace and the poll cadence" FM_SIGNAL_GRACE=30 # seconds to coalesce nearby status and turn-end signals into one wake FM_WATCHER_CLEANUP_LOCK_BOUND= # optional watcher EXIT marker-lock wait; default and validation: docs/watcher-continuity.md FM_TURNEND_CHURN_ABSORB_SECS=900 # longest one endpoint's bare turn-ends may be deferred on pane-churn evidence alone; only consulted when config/turnend-churn-absorb is present diff --git a/docs/remote-secondmates.md b/docs/remote-secondmates.md index 8c2d36ecb5b..42d496acb39 100644 --- a/docs/remote-secondmates.md +++ b/docs/remote-secondmates.md @@ -99,6 +99,7 @@ It distinguishes preemption from a wait window that closes with no data: - Only a genuinely quiet window proves channel freshness. - Either outcome can re-arm without losing data. +- The parent's reply listener polls again under the same claim after either one, so a same-home command such as the per-cycle liveness probe never tears the listener down; [`bin/fm-procevent-remote-reply.sh`](../bin/fm-procevent-remote-reply.sh) owns that mapping. ### Cancelled and orphaned jobs diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index 7f26471851f..23af69a0eb1 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -231,6 +231,9 @@ Once the live holder's beacon is stale past `FM_WATCHER_STALL_BOUND` (default th A watcher wedged mid-cycle can therefore no longer refuse every replacement indefinitely. `bin/fm-watch.sh`'s header owns the exact wording and the survives-TERM fallback. +Below that bound a stale beacon alone does not end an attached arm's watch of a live, identity-matched holder; a changed lock can end it sooner. +At the bound the arm reports a typed stalled-holder failure so its owner's retry can replace the holder. +`fm_watcher_stall_bound` in `bin/fm-wake-lib.sh` owns the shared derivation; `bin/fm-watch-arm.sh`'s header owns the exact attached-arm close behavior. The auto-arm hook additionally exports its resolved `FM_GUARD_GRACE` when it forks `bin/fm-watch-arm.sh`. The arm wrapper and the watcher it may start then judge staleness with the exact same value the hook just judged it with, whether that value came from an operator override or the poll-derived default. diff --git a/tests/fm-pending-reply.test.sh b/tests/fm-pending-reply.test.sh index cd31fbaf552..1a14b49a658 100755 --- a/tests/fm-pending-reply.test.sh +++ b/tests/fm-pending-reply.test.sh @@ -1086,6 +1086,94 @@ test_tick_skips_terminal_and_reuses_target_observation() { pass "tick skips terminal records and reuses target observations" } +# Records are never pruned, so a home accumulates thousands of settled ones. The +# tick selects the records it has work for in one pass and leaves every settled +# record alone: it must not block on a settled record's per-record lock that a +# live foreign process holds, and it still does the work the selected records need. +test_tick_leaves_settled_records_alone() { + local home state settled closed open_esc awaiting rec i copy holder tick_pid ticked=0 open lib + local sums_before sums_after holder_lock_pid + home=$(setup_parent settled-store) + state="$home/state" + # Reset the fixture clock after isolated subshell tests. + # shellcheck disable=SC2031 + export FM_PENDING_REPLY_NOW=5200 + # A resolved record that never escalated, and one whose escalation closed. + settled=$(fm_pending_reply_create "$home" "$state" hibit "settled request") + fm_pending_reply_mark_delivered "$state" "$settled" + printf 'done [corr=%s]: settled reply\n' "$settled" >> "$state/hibit.status" + fm_pending_reply_try_resolve "$state" "$settled" || fail "settled fixture should resolve" + closed=$(fm_pending_reply_create "$home" "$state" hibit "closed escalation") + fm_pending_reply_mark_delivered "$state" "$closed" + rec=$(fm_pending_reply_path "$state" "$closed") + fm_pending_reply_set "$rec" phase escalated + fm_pending_reply_set "$rec" escalated_epoch 5100 + printf 'blocked [key=pending-reply-%s]: pending-reply-missed: task=hibit pending-reply-id=%s request=closed escalation\n' \ + "$closed" "$closed" >> "$state/hibit.status" + printf 'done [corr=%s]: late reply\n' "$closed" >> "$state/hibit.status" + fm_pending_reply_try_resolve "$state" "$closed" || fail "closed-escalation fixture should resolve" + [ -n "$(fm_pending_reply_get "$rec" escalation_closed_epoch)" ] || fail "fixture escalation did not close" + # Many settled copies, as a long-lived home accumulates. + i=0 + while [ "$i" -lt 300 ]; do + copy=$(printf '%016x' $((0x5e7700000000 + i))) + for rec in "$settled" "$closed"; do + sed "s/^corr_id=.*/corr_id=$copy/" "$(fm_pending_reply_path "$state" "$rec")" \ + > "$(fm_pending_reply_path "$state" "$copy")" + copy=$(printf '%016x' $((0x5e7780000000 + i))) + done + i=$((i + 1)) + done + # Work the tick still owes: a resolved record whose escalation close did not + # land, and a delivered request whose correlated report is in the parent status. + open_esc=$(fm_pending_reply_create "$home" "$state" esc "open escalation") + fm_pending_reply_mark_delivered "$state" "$open_esc" + rec=$(fm_pending_reply_path "$state" "$open_esc") + printf 'blocked [key=pending-reply-%s]: pending-reply-missed: task=esc pending-reply-id=%s request=open escalation\n' \ + "$open_esc" "$open_esc" > "$state/esc.status" + printf 'done [corr=%s]: late reply\n' "$open_esc" >> "$state/esc.status" + fm_pending_reply_set "$rec" escalated_epoch 5150 + fm_pending_reply_set "$rec" resolved_via status + fm_pending_reply_set "$rec" phase resolved + awaiting=$(fm_pending_reply_create "$home" "$state" open "awaiting report") + fm_pending_reply_mark_delivered "$state" "$awaiting" + printf 'done [corr=%s]: the report\n' "$awaiting" > "$state/open.status" + sums_before=$(cd "$(fm_pending_reply_dir "$state")" && cksum 00005e77* "$settled" "$closed") + [ "$(printf '%s\n' "$sums_before" | wc -l | tr -d ' ')" -eq 602 ] || fail "settled fixture store is incomplete" + + # A live foreign process holds one settled record's per-record lock. + lib="$ROOT/bin/fm-wake-lib.sh" + bash -c '. "$1"; fm_lock_acquire_wait "$2" && : > "$3"; exec sleep 300' _ \ + "$lib" "$state/.pending-reply-00005e7700000000.lock" "$home/held" & + holder=$! + for _ in $(seq 1 100); do [ -e "$home/held" ] && break; sleep 0.1; done + [ -e "$home/held" ] || { kill "$holder" 2>/dev/null; fail "foreign holder never took the lock"; } + + fm_pending_reply_tick "$state" & + tick_pid=$! + for _ in $(seq 1 600); do + case "$(ps -p "$tick_pid" -o stat= 2>/dev/null)" in ''|Z*) ticked=1; break ;; esac + sleep 0.1 + done + [ "$ticked" = 1 ] || kill -TERM "$tick_pid" 2>/dev/null + wait "$tick_pid" 2>/dev/null || true + holder_lock_pid=$(cat "$state/.pending-reply-00005e7700000000.lock/pid" 2>/dev/null || true) + kill -TERM "$holder" 2>/dev/null + wait "$holder" 2>/dev/null || true + + [ "$ticked" = 1 ] || fail "the tick blocked on a settled record's foreign-held lock" + [ "$holder_lock_pid" = "$holder" ] || fail "the tick disturbed the foreign holder's lock (pid=$holder_lock_pid)" + sums_after=$(cd "$(fm_pending_reply_dir "$state")" && cksum 00005e77* "$settled" "$closed") + [ "$sums_before" = "$sums_after" ] || fail "the tick rewrote settled records" + [ -n "$(fm_pending_reply_get "$(fm_pending_reply_path "$state" "$open_esc")" escalation_closed_epoch)" ] \ + || fail "the tick did not close the resolved record's open escalation" + open=$(status_open_decisions "$state/esc.status") + [ -z "$open" ] || fail "the resolved record's escalation stayed open: $open" + [ "$(phase_of "$state" "$awaiting")" = resolved ] \ + || fail "the tick did not resolve the awaiting record from its correlated report" + pass "the tick leaves settled records alone and still does the selected records' work" +} + test_correlations_reuse_only_for_matching_open_task() { local dir fb log home state got corr1 corr2 corr3 rec dir="$TMP_ROOT/corr-reuse"; mkdir -p "$dir" @@ -1629,6 +1717,7 @@ test_busy_idle_observation_via_backend_abstraction test_unknown_backend_state_uses_capture_fallback test_kimi_capture_fallback_uses_recorded_harness test_tick_skips_terminal_and_reuses_target_observation +test_tick_leaves_settled_records_alone test_correlations_reuse_only_for_matching_open_task test_tick_end_to_end_missed_then_escalate test_failed_send_discards_undelivered_expectation diff --git a/tests/fm-remote-reply.test.sh b/tests/fm-remote-reply.test.sh index 0ea7a425da0..47f75a75593 100755 --- a/tests/fm-remote-reply.test.sh +++ b/tests/fm-remote-reply.test.sh @@ -847,11 +847,56 @@ set +e wait "$PREEMPTED_SOURCE" preempted_rc=$? set -e -[ "$preempted_rc" -eq "$FM_REMOTE_JOB_PREEMPTED_EXIT" ] \ - || fail "the reply poll did not expose remote-job preemption: $preempted_rc" +[ "$preempted_rc" -eq 75 ] \ + || fail "a preempted reply poll did not report a closed window: $preempted_rc" assert_absent "$PARENT/state/remote-replies/ios.caught-up" \ "a preempted reply poll published a caught-up watermark" -pass "a preempted reply poll cannot publish channel freshness" +pass "a preempted reply poll reports a closed window without publishing channel freshness" + +# The per-cycle liveness probe is a non-preemptible job for the same remote home, +# so the job worker preempts the listener's long-poll on every watcher cycle. +# That must not cost the listener: it keeps its claim and polls again, and the +# watcher's reconcile has nothing to relaunch. +: > "$TMP_ROOT/preempted-polls" +FM_REMOTE_REPLY_POLL_LOG="$TMP_ROOT/preempted-polls" FM_PROCEVENT_LAUNCH_FLOOR_SECONDS=1 \ + remote_env "$ROOT/bin/fm-procevent.sh" start "$SID" >/dev/null 2>&1 & +PREEMPTED_RUNNER=$! +wait_for "$CLAIMS/$SID.claim" || fail "the preempted-listener case never claimed the source" +HELD_PID=$(sed -n '2p' "$CLAIMS/$SID.claim") +running_poll='' +for _ in $(seq 1 100); do + for job in "$TMP_ROOT"/remote-jobs/jobs/job-*; do + [ -d "$job" ] || continue + if [ "$(fm_remote_job_read_state "$job" 2>/dev/null || true)" = running ]; then + running_poll=$job + break 2 + fi + done + sleep 0.05 +done +[ -n "$running_poll" ] || fail "the listener's poll did not begin running before preemption" +remote_env "$ROOT/bin/fm-on.sh" ios fm-remote-file.sh get data/reply/report.md 262144 >/dev/null +polls=0 +for _ in $(seq 1 120); do + polls=$(wc -l < "$TMP_ROOT/preempted-polls" | tr -d ' ') + [ "$polls" -ge 2 ] && break + sleep 0.25 +done +[ "$polls" -ge 2 ] || fail "the preempted listener did not poll again" +case "$(ps -p "$PREEMPTED_RUNNER" -o stat= 2>/dev/null)" in + ''|Z*) fail "a preempted poll ended the reply listener" ;; +esac +[ "$(reply_owner)" = live ] || fail "a preempted poll released the listener's claim" +[ "$(sed -n '2p' "$CLAIMS/$SID.claim")" = "$HELD_PID" ] \ + || fail "a preempted poll replaced the reply listener" +reconcile_out=$(remote_env "$ROOT/bin/fm-procevent.sh" reconcile) +assert_contains "$reconcile_out" 'started=0' \ + "reconcile relaunched a listener after a preempted poll" +[ "$(sed -n '2p' "$CLAIMS/$SID.claim")" = "$HELD_PID" ] \ + || fail "reconcile replaced the preempted listener" +stop_reply_listener || fail "the preempted listener did not stop" +wait "$PREEMPTED_RUNNER" 2>/dev/null || true +pass "a preempted reply poll keeps its listener and reconcile launches nothing" # A quiet window is the one moment this channel can prove it is NOT behind, and # the parent's pending-reply guard needs that proof: a remote report that exists diff --git a/tests/fm-watch-arm.test.sh b/tests/fm-watch-arm.test.sh index b640a60ffa4..a2c13f946f6 100755 --- a/tests/fm-watch-arm.test.sh +++ b/tests/fm-watch-arm.test.sh @@ -48,9 +48,9 @@ SEED_PID= ARM_PID= # Start the real watcher as the singleton holder. -start_seed_watcher() { # - local state=$1 fakebin=$2 out=$3 i - PATH="$fakebin:$PATH" FM_STATE_OVERRIDE="$state" FM_POLL=5 FM_SIGNAL_GRACE=1 \ +start_seed_watcher() { # [poll-seconds] + local state=$1 fakebin=$2 out=$3 poll=${4:-5} i + PATH="$fakebin:$PATH" FM_STATE_OVERRIDE="$state" FM_POLL="$poll" FM_SIGNAL_GRACE=1 \ FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & SEED_PID=$! i=0 @@ -267,6 +267,129 @@ test_attached_arm_still_fails_on_a_wake_it_did_not_deliver() { pass "watch-arm: a cycle that delivered no wake of its own still fails loudly" } +# A slow cycle is not an ended cycle. The holder is frozen past the grace plus +# the successor confirmation window, which is where an attached arm used to +# declare the cycle over and fail while the holder was alive and still held the +# lock; the owner's retry then hit that live holder's refusal (the auto-arm +# FAILED notice), or, if the holder beat again first, nothing followed it at all. +test_attached_arm_follows_a_slow_live_holder() { + local dir state fakebin out armout status i arm_followed armout_frozen ledger_frozen + dir=$(make_case attached-slow-holder) + state="$dir/state" + fakebin="$dir/fakebin" + out="$dir/watch.out" + armout="$dir/arm.out" + start_seed_watcher "$state" "$fakebin" "$out" 1 + PATH="$fakebin:$PATH" FM_STATE_OVERRIDE="$state" FM_ARM_ATTACH_POLL=0.1 \ + FM_ARM_CONFIRM_TIMEOUT=1 FM_GUARD_GRACE=4 FM_WATCHER_STALL_BOUND=600 "$WATCH_ARM" > "$armout" & + ARM_PID=$! + i=0 + while [ "$i" -lt 80 ]; do + grep -qF "watcher: attached pid=$SEED_PID" "$armout" 2>/dev/null && break + sleep 0.1 + i=$((i + 1)) + done + grep -qF "watcher: attached pid=$SEED_PID" "$armout" \ + || fail "arm did not attach to the live watcher: $(cat "$armout")" + + kill -STOP "$SEED_PID" + # Grace 4s plus the 1s confirmation window plus its rounding second is where + # the old arm gave up; hold the holder well past that. Observe while frozen, + # but resume before asserting so a failure never strands a stopped watcher. + i=0 + while [ "$(FM_STATE_OVERRIDE="$state" bash -c '. "$1"; fm_path_age "$2"' _ "$ROOT/bin/fm-wake-lib.sh" "$state/.last-watcher-beat")" -lt 10 ] \ + && [ "$i" -lt 300 ]; do + sleep 0.1 + i=$((i + 1)) + done + arm_followed=0 + is_live_non_zombie "$ARM_PID" && arm_followed=1 + armout_frozen=$(cat "$armout") + ledger_frozen=$(cat "$state/.watch-cycle-exits.log" 2>/dev/null || true) + kill -CONT "$SEED_PID" + [ "$arm_followed" = 1 ] || fail "attached arm ended while its holder was alive: $armout_frozen" + assert_not_contains "$armout_frozen" 'watcher: FAILED' \ + "attached arm failed a live holder's slow cycle" + assert_not_contains "$ledger_frozen" 'reason=attached-cycle-ended' \ + "attached arm closed a cycle that had not ended" + + # The holder resumes and delivers a wake: the arm that kept following it + # reports that wake, so nothing is lost. + printf 'needs-decision: which export format?\n' > "$state/demo.status" + wait_for_exit "$SEED_PID" 150 + grep -q '^signal:' "$out" || fail "resumed holder did not surface the signal wake: $(cat "$out")" + wait_for_exit "$ARM_PID" 150 + status=$? + ! grep -qF 'watcher: FAILED' "$armout" \ + || fail "attached arm failed after its holder resumed: $(cat "$armout")" + grep -q '^signal:' "$armout" \ + || fail "attached arm did not report the resumed holder's wake: $(cat "$armout")" + expect_code 0 "$status" "an attached arm that followed a slow holder must close with its wake" + pass "watch-arm: an attached arm keeps following a slow live holder and reports its wake" +} + +# The stall bound is where following ends. A live holder whose beacon reaches it +# is what the watcher's own re-arm evicts, so the attached arm stops there with +# the typed stalled-holder line, and its owner's retry replaces the holder +# instead of being refused. +test_attached_arm_hands_a_stalled_holder_to_its_replacement() { + local dir state fakebin armout rearmout holder identity status + dir=$(make_case attached-stalled-holder) + state="$dir/state" + fakebin="$dir/fakebin" + armout="$dir/arm.out" + rearmout="$dir/rearm.out" + # A live process the lock records under its real identity, which never beats: + # the shape of a watcher wedged mid-cycle that still answers TERM. + sleep 300 & + holder=$! + identity=$(FM_STATE_OVERRIDE="$state" bash -c '. "$1"; fm_pid_identity "$2"' _ "$ROOT/bin/fm-wake-lib.sh" "$holder") \ + || fail "could not identify the fake holder" + mkdir -p "$state/.watch.lock" + printf '%s\n' "$holder" > "$state/.watch.lock/pid" + printf '%s\n' "$dir" > "$state/.watch.lock/fm-home" + printf '%s\n' "$WATCH" > "$state/.watch.lock/watcher-path" + printf '%s\n' "$identity" > "$state/.watch.lock/pid-identity" + : > "$state/.last-watcher-beat" + + PATH="$fakebin:$PATH" FM_HOME="$dir" FM_STATE_OVERRIDE="$state" FM_ARM_ATTACH_POLL=0.1 \ + FM_ARM_CONFIRM_TIMEOUT=1 FM_GUARD_GRACE=5 FM_WATCHER_STALL_BOUND=12 "$WATCH_ARM" > "$armout" & + ARM_PID=$! + wait_for_exit "$ARM_PID" 300 + status=$? + grep -qF "watcher: attached pid=$holder" "$armout" \ + || fail "arm did not attach to the fresh holder: $(cat "$armout")" + grep -E "^watcher: FAILED - attached watcher pid=$holder stalled \(beacon [0-9]+s at or past hard bound 12s\)\$" "$armout" >/dev/null \ + || fail "attached arm did not report the stalled holder: $(cat "$armout")" + ! grep -qF 'cycle ended without an actionable reason' "$armout" \ + || fail "attached arm gave up on the live holder before the stall bound: $(cat "$armout")" + [ "$status" -ne 0 ] && [ "$status" -ne 124 ] \ + || fail "stalled-holder close did not exit nonzero (status $status)" + grep -q 'reason=attached-holder-stalled' "$state/.watch-cycle-exits.log" \ + || fail "the stalled-holder close was not classified in the lifecycle ledger" + is_live_non_zombie "$holder" || fail "the attached arm signalled the holder it follows" + + # The owner's retry: a fresh arm reaches the watcher's eviction path, and the + # replacement surfaces an ordinary wake instead of the refusal that used to + # end in the auto-arm FAILED notice. + PATH="$fakebin:$PATH" FM_HOME="$dir" FM_STATE_OVERRIDE="$state" \ + FM_POLL=1 FM_SIGNAL_GRACE=0 FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 \ + FM_ARM_CONFIRM_TIMEOUT=10 FM_GUARD_GRACE=5 FM_WATCHER_STALL_BOUND=12 "$WATCH_ARM" > "$rearmout" 2>&1 & + ARM_PID=$! + wait_for_exit "$ARM_PID" 300 + status=$? + grep -qF "watcher: replaced stalled pid $holder " "$rearmout" \ + || fail "the retry did not replace the stalled holder: $(cat "$rearmout")" + ! grep -qF 'watcher: FAILED' "$rearmout" \ + || fail "the retry failed instead of replacing the stalled holder: $(cat "$rearmout")" + grep -Eq '^(signal|stale|check):' "$rearmout" \ + || fail "the replacement surfaced no ordinary wake: $(cat "$rearmout")" + expect_code 0 "$status" "the retry that replaced a stalled holder must close with an ordinary wake" + wait_for_pid_gone "$holder" 50 || fail "the stalled holder survived its replacement" + wait "$holder" 2>/dev/null || true + pass "watch-arm: an attached arm hands a holder stalled past the bound to its owner's replacement" +} + test_rearm_resurfaces_durable_queue_and_remote_open_decision() { local dir home state fakebin result armout drainout status watcher_pid sequence generation decision_recovery_arm decision_successor dir=$(make_case rearm-resurface) @@ -1202,6 +1325,8 @@ test_watcher_exits_when_its_state_directory_is_removed test_watcher_exits_when_its_home_is_removed test_reaper_stops_a_tracked_watcher test_attached_arm_still_fails_on_a_wake_it_did_not_deliver +test_attached_arm_follows_a_slow_live_holder +test_attached_arm_hands_a_stalled_holder_to_its_replacement test_rearm_resurfaces_durable_queue_and_remote_open_decision test_slow_rearm_recovery_is_still_surfaced test_marker_publish_failure_retains_recovery_evidence From c5f48e4cad279e6acaf14208522c44e28fac6835 Mon Sep 17 00:00:00 2001 From: Christopher McKay <101884182+karotkriss@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:16:57 -0400 Subject: [PATCH 06/76] fix(bin): retry fm-pr-merge a bounded number of times when GitHub mergeable is UNKNOWN (#6110) * fix(bin): retry a bounded number of times when GitHub mergeable is UNKNOWN Fixes #6020 bin/fm-pr-merge.sh refused a GitHub merge whenever the pull request's mergeable field was not literally MERGEABLE. GitHub reports UNKNOWN for a short while after a push or a base-branch change while it recomputes mergeability, so a green, conflict-free pull request was refused as if it could not be merged. github_verify_mergeable now returns a distinct status when mergeable is the only failing condition and reads UNKNOWN. The caller retries up to 5 times, 3 seconds apart (overridable in tests), re-reading and re-checking every live condition on each attempt. Once the bound is spent it reports mergeability as still being computed rather than unmergeable, with the same nonzero exit as before. Every other refusal (closed, draft, conflicting, red or missing checks, away authority, queue protection) is unchanged and never retried. * no-mistakes(ci): I fixed both review findings the way you asked. The full suite (`bash tests/fm-pr-merge.test.sh`) ran to completion. Its last lines showed all `ok`, and any failure would have stopped the run early. I watched the output through `tail`, so I didn't see the new test's own `ok` line directly. **ci-2 (`bin/fm-pr-merge.sh`), retry delay not validated.** What must hold: the retry wait is always a short, valid `sleep` argument, so a bad `FM_PR_GITHUB_MERGEABLE_RETRY_DELAY` can never trip `set -e` or hold the task lock for a long time. The retry loop is the only place that reads this variable. The script now reads the value once before the loop and accepts only whole numbers from 0 to 10. Anything else (empty, `abc`, `-1`, `1.5`, `11`, a huge number, leading spaces) falls back to 3. I ran those values through the check by hand and each came out as expected. The loop now sleeps on that checked value. **ci-1 (`tests/fm-pr-merge.test.sh`), no test for a check changing between UNKNOWN reads.** What must hold: every retry re-checks all live conditions, not just mergeable. The fake `gh pr view` in the test can now take an optional second word on each line of the mergeable sequence, which sets the first check's result. The new test `test_github_mergeable_unknown_retry_rechecks_checks` feeds `UNKNOWN`, then `UNKNOWN FAILURE`. It asserts: - exit code 1 after exactly 2 reads, - the refusal names `check 'ci' is not green`, - the message does not say mergeability is still being computed, - `pr merge` was never called. If a later change made the retry look only at mergeable, the loop would read UNKNOWN 5 times, end with the "still being computed" message, and this test would fail. I didn't run it against a deliberately broken script to confirm that. `bash -n` passes. `shellcheck` reports only the existing info-level notes about files it can't follow. Only `bin/fm-pr-merge.sh` and `tests/fm-pr-merge.test.sh` changed --- bin/fm-pr-merge.sh | 52 +++++++++++-- tests/fm-pr-merge.test.sh | 149 +++++++++++++++++++++++++++++++++++++- 2 files changed, 195 insertions(+), 6 deletions(-) diff --git a/bin/fm-pr-merge.sh b/bin/fm-pr-merge.sh index f73fd97e1a2..63b26326398 100755 --- a/bin/fm-pr-merge.sh +++ b/bin/fm-pr-merge.sh @@ -16,7 +16,12 @@ # is green at the exact current head commit, where github_checks_not_green below # owns what makes a check green and judges each one by its current run, and # every unwaived check the forge requires for the base branch has reported at -# that head. A required check that never reported is absent from the checks +# that head. When mergeable is the only failing condition and reads UNKNOWN, +# meaning GitHub has not finished recomputing it, the caller re-reads and +# re-checks every condition after a short bounded wait instead of refusing; +# once that bound is spent it reports mergeability still pending rather than +# unmergeable, with the same nonzero exit as any other refusal. +# A required check that never reported is absent from the checks # list rather than red, so github_read_required_contexts below reads the # required set from classic branch protection and active rulesets. Check-run # requirements retain their producer app binding: a same-named check run from another app cannot @@ -710,10 +715,12 @@ github_required_checks_missing() { } # Pre-merge conditions from a live PR view, base requirements, and head producers. -# Sets FM_PR_MERGE_HEAD to the verified head on success. +# Sets FM_PR_MERGE_HEAD to the verified head on success. Returns 3, rather than +# the usual 1, when mergeable=UNKNOWN is the only failing condition, so the +# caller can retry a still-computing mergeability read instead of refusing. github_verify_mergeable() { local json fields line red name covered missing unreported producers runs - local total=0 named=0 refusals='' + local total=0 named=0 refusals='' mergeable_refusal='' local state='' draft='' mergeable='' merge_state='' live_head='' base='' if ! json=$(gh pr view "$URL" --json state,isDraft,mergeable,mergeStateStatus,headRefOid,baseRefName,statusCheckRollup 2>/dev/null) \ @@ -774,7 +781,7 @@ FIELDS || refusals="$refusals - the pull request is a draft " [ "$mergeable" = MERGEABLE ] \ - || refusals="$refusals - mergeable is \"${mergeable:-unreadable}\", not MERGEABLE + || mergeable_refusal=" - mergeable is \"${mergeable:-unreadable}\", not MERGEABLE " [ "$merge_state" != DIRTY ] \ || refusals="$refusals - mergeStateStatus is DIRTY (conflicts) @@ -835,6 +842,13 @@ $missing EOF fi + if [ -n "$mergeable_refusal" ]; then + if [ -z "$refusals" ] && [ "$mergeable" = UNKNOWN ]; then + return 3 + fi + refusals="$refusals$mergeable_refusal" + fi + if [ -n "$refusals" ]; then printf 'error: refusing to merge %s\n' "$URL" >&2 printf '%s' "$refusals" >&2 @@ -1334,7 +1348,35 @@ case "$PROVIDER" in merge_args=(--squash) fi FM_PR_GITHUB_CALLER_METHOD=$(caller_merge_method "$@") - github_verify_mergeable || exit 1 + # mergeable reads UNKNOWN for a short while after a push or base-branch + # change while GitHub recomputes it; retry a bounded number of times, + # re-reading and re-checking every live condition on each attempt, rather + # than refusing a pull request that is simply still being computed. The + # delay is capped at 0-10 seconds so the wait stays short under the lock. + mergeable_retry_delay=${FM_PR_GITHUB_MERGEABLE_RETRY_DELAY:-3} + case "$mergeable_retry_delay" in + [0-9] | 10) ;; + *) mergeable_retry_delay=3 ;; + esac + mergeable_attempt=1 + while :; do + mergeable_status=0 + github_verify_mergeable || mergeable_status=$? + if [ "$mergeable_status" -eq 0 ]; then + break + fi + if [ "$mergeable_status" -ne 3 ] || [ "$mergeable_attempt" -ge 5 ]; then + break + fi + sleep "$mergeable_retry_delay" + mergeable_attempt=$((mergeable_attempt + 1)) + done + if [ "$mergeable_status" -ne 0 ]; then + if [ "$mergeable_status" -eq 3 ]; then + printf 'error: mergeability for %s is still being computed by GitHub; retry shortly\n' "$URL" >&2 + fi + exit 1 + fi # The away record is locked first, so this last presence and authority read # and the forge command below share one live-owner critical section. hold_away_record_for_merge || exit 1 diff --git a/tests/fm-pr-merge.test.sh b/tests/fm-pr-merge.test.sh index a80f8f61b30..fe588c02c18 100755 --- a/tests/fm-pr-merge.test.sh +++ b/tests/fm-pr-merge.test.sh @@ -174,7 +174,19 @@ case "${1:-} ${2:-}" in "pr view") case " $* " in *statusCheckRollup*) - cat "$FM_TEST_GH_VIEW_JSON" + if [ -n "${FM_TEST_GH_MERGEABLE_SEQUENCE:-}" ]; then + call_n=$(( $(cat "$FM_TEST_GH_MERGEABLE_CALLS" 2>/dev/null || echo 0) + 1 )) + printf '%s\n' "$call_n" > "$FM_TEST_GH_MERGEABLE_CALLS" + call_m=$(sed -n "${call_n}p" "$FM_TEST_GH_MERGEABLE_SEQUENCE") + [ -n "$call_m" ] || call_m=$(tail -n1 "$FM_TEST_GH_MERGEABLE_SEQUENCE") + # An optional second word overrides the first check's conclusion. + read -r call_m call_c <<< "$call_m" + jq -c --arg m "$call_m" --arg c "${call_c:-}" \ + '.mergeable = $m | if $c != "" then .statusCheckRollup[0].conclusion = $c else . end' \ + "$FM_TEST_GH_VIEW_JSON" + else + cat "$FM_TEST_GH_VIEW_JSON" + fi if [ -f "${FM_TEST_AWAY_RECORD_AFTER_VIEW:-}" ]; then if [ -s "${FM_TEST_AWAY_RECORD_AFTER_VIEW}" ]; then cp "$FM_TEST_AWAY_RECORD_AFTER_VIEW" "$FM_STATE_OVERRIDE/.afk-contract" @@ -448,6 +460,8 @@ run_pr_merge() { FM_TEST_GH_OUTCOME="$case_dir/github-outcome" \ FM_TEST_GH_RULES="$case_dir/github-rules" \ FM_TEST_GH_VIEW_JSON="$case_dir/github-view.json" \ + FM_TEST_GH_MERGEABLE_SEQUENCE="${FM_TEST_GH_MERGEABLE_SEQUENCE:-}" \ + FM_TEST_GH_MERGEABLE_CALLS="$case_dir/mergeable-calls" \ FM_TEST_GH_HEAD="$case_dir/github-head" \ FM_TEST_GH_RUNS="$case_dir/github-runs.json" \ FM_TEST_GH_MERGE_RC_FILE="$case_dir/github-merge-rc" \ @@ -632,6 +646,135 @@ test_github_open_unqueued_outcome_refuses() { pass "fm-pr-merge refuses a GitHub merge call that leaves the PR open and unqueued" } +# GitHub reports mergeable=UNKNOWN for a short while after a push or a base +# branch change while it recomputes mergeability. When that is the only +# failing condition, the gate re-reads and re-checks every live condition on +# a bounded retry instead of refusing a pull request that is simply pending. +test_github_mergeable_unknown_retries_then_succeeds() { + local case_dir rc head + head=4242424242424242424242424242424242424242 + case_dir=$(make_case github-mergeable-unknown-then-mergeable) + mkdir -p "$case_dir/wt" + add_gh_mocks "$case_dir" "$head" + printf '%s\n' UNKNOWN MERGEABLE > "$case_dir/mergeable-sequence" + : > "$case_dir/gh-axi.log" + : > "$case_dir/gh.log" + + set +e + FM_TEST_GH_MERGEABLE_SEQUENCE="$case_dir/mergeable-sequence" \ + FM_PR_GITHUB_MERGEABLE_RETRY_DELAY=0 \ + run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/83 \ + > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "github-mergeable-unknown-then-mergeable: a merge should succeed once mergeable resolves" + [ "$(grep -c '^pr view .*statusCheckRollup' "$case_dir/gh.log")" -eq 2 ] \ + || fail "github-mergeable-unknown-then-mergeable: expected exactly 2 mergeable reads, got $(grep -c '^pr view .*statusCheckRollup' "$case_dir/gh.log")" + assert_logged_gh_merge "$case_dir" 83 example/repo --squash + [ "$(grep -c '^pr merge ' "$case_dir/gh.log")" -eq 1 ] \ + || fail "github-mergeable-unknown-then-mergeable: the wrapper attempted more than one merge" + assert_grep 'pr=https://github.com/example/repo/pull/83' "$case_dir/state/task-x1.meta" \ + "github-mergeable-unknown-then-mergeable: pr= was not recorded" + pass "fm-pr-merge retries a bounded number of times when mergeable is UNKNOWN and merges once it resolves" +} + +# Every attempt still reads mergeable=UNKNOWN: the bound is spent and the gate +# reports mergeability as still pending rather than calling the pull request +# unmergeable, never attempting a merge on an unresolved read. +test_github_mergeable_unknown_exhausts_bound_and_reports_pending() { + local case_dir rc head + head=4343434343434343434343434343434343434343 + case_dir=$(make_case github-mergeable-unknown-exhausted) + mkdir -p "$case_dir/wt" + add_gh_mocks "$case_dir" "$head" + printf '%s\n' UNKNOWN > "$case_dir/mergeable-sequence" + : > "$case_dir/gh-axi.log" + : > "$case_dir/gh.log" + + set +e + FM_TEST_GH_MERGEABLE_SEQUENCE="$case_dir/mergeable-sequence" \ + FM_PR_GITHUB_MERGEABLE_RETRY_DELAY=0 \ + run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/84 \ + > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "github-mergeable-unknown-exhausted: a mergeable read that never resolves must still fail" + [ "$(grep -c '^pr view .*statusCheckRollup' "$case_dir/gh.log")" -eq 5 ] \ + || fail "github-mergeable-unknown-exhausted: expected exactly 5 bounded mergeable reads, got $(grep -c '^pr view .*statusCheckRollup' "$case_dir/gh.log")" + assert_no_grep 'pr merge' "$case_dir/gh.log" \ + "github-mergeable-unknown-exhausted: a merge was attempted while mergeable never resolved" + assert_grep "mergeability for https://github.com/example/repo/pull/84 is still being computed by GitHub; retry shortly" \ + "$case_dir/stderr" \ + "github-mergeable-unknown-exhausted: the exhausted retry did not report mergeability as still pending" + pass "fm-pr-merge reports mergeability still pending after its bounded UNKNOWN retry is spent" +} + +# A check that turns red between two UNKNOWN reads must refuse on the re-check: +# the retry re-reads every live condition, not only mergeable. +test_github_mergeable_unknown_retry_rechecks_checks() { + local case_dir rc head + head=4545454545454545454545454545454545454545 + case_dir=$(make_case github-mergeable-unknown-check-turns-red) + mkdir -p "$case_dir/wt" + add_gh_mocks "$case_dir" "$head" + printf '%s\n' UNKNOWN 'UNKNOWN FAILURE' > "$case_dir/mergeable-sequence" + : > "$case_dir/gh-axi.log" + : > "$case_dir/gh.log" + + set +e + FM_TEST_GH_MERGEABLE_SEQUENCE="$case_dir/mergeable-sequence" \ + FM_PR_GITHUB_MERGEABLE_RETRY_DELAY=0 \ + run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/86 \ + > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "github-mergeable-unknown-check-turns-red: a check that turned red must refuse" + [ "$(grep -c '^pr view .*statusCheckRollup' "$case_dir/gh.log")" -eq 2 ] \ + || fail "github-mergeable-unknown-check-turns-red: expected exactly 2 reads, got $(grep -c '^pr view .*statusCheckRollup' "$case_dir/gh.log")" + assert_grep "check 'ci' is not green" "$case_dir/stderr" \ + "github-mergeable-unknown-check-turns-red: the re-check did not refuse the red check" + assert_no_grep 'still being computed' "$case_dir/stderr" \ + "github-mergeable-unknown-check-turns-red: a red check was reported as mergeability pending" + assert_no_grep 'pr merge' "$case_dir/gh.log" \ + "github-mergeable-unknown-check-turns-red: gh pr merge ran after a check turned red" + pass "fm-pr-merge refuses on the UNKNOWN re-check when a check turned red between reads" +} + +# A real conflict (mergeable=CONFLICTING) is a different condition from GitHub +# still computing mergeability, and must refuse immediately like every other +# refusal, never retried. +test_github_mergeable_conflicting_is_not_retried() { + local case_dir rc head + head=4444444444444444444444444444444444444444 + case_dir=$(make_case github-mergeable-conflicting) + mkdir -p "$case_dir/wt" + add_gh_mocks "$case_dir" "$head" + jq -c '.mergeable = "CONFLICTING"' "$case_dir/github-view.json" > "$case_dir/github-view.tmp" + mv "$case_dir/github-view.tmp" "$case_dir/github-view.json" + : > "$case_dir/gh-axi.log" + : > "$case_dir/gh.log" + + set +e + run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/85 \ + > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "github-mergeable-conflicting: a genuine conflict must refuse" + [ "$(grep -c '^pr view .*statusCheckRollup' "$case_dir/gh.log")" -eq 1 ] \ + || fail "github-mergeable-conflicting: a genuine conflict was retried instead of refused immediately" + assert_grep 'mergeable is "CONFLICTING", not MERGEABLE' "$case_dir/stderr" \ + "github-mergeable-conflicting: the conflict was not named" + assert_no_grep 'still being computed' "$case_dir/stderr" \ + "github-mergeable-conflicting: a genuine conflict was reported as still being computed" + assert_no_grep 'pr merge' "$case_dir/gh.log" \ + "github-mergeable-conflicting: gh pr merge ran on a conflicting PR" + pass "fm-pr-merge refuses a genuine mergeable conflict immediately, without retrying" +} + test_github_unreadable_outcome_keeps_pr_bookkeeping() { local case_dir rc case_dir=$(make_case github-outcome-read-fails) @@ -2223,6 +2366,10 @@ test_verified_merge_records_pr_and_head test_pr_metadata_is_recorded_before_the_forge_call test_merge_failure_propagates_after_recording test_github_open_unqueued_outcome_refuses +test_github_mergeable_unknown_retries_then_succeeds +test_github_mergeable_unknown_exhausts_bound_and_reports_pending +test_github_mergeable_unknown_retry_rechecks_checks +test_github_mergeable_conflicting_is_not_retried test_github_unreadable_outcome_keeps_pr_bookkeeping test_github_refusal_quotes_the_forge_output test_github_unreadable_outcome_refusal_quotes_the_forge_output From 260c4f089449c33f08c31df714ca9cfe2a25b50a Mon Sep 17 00:00:00 2001 From: Christopher McKay <101884182+karotkriss@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:36:01 -0400 Subject: [PATCH 07/76] fix(bin): converge every open owner onto a known terminal contribution (#6112) * fix(bin): converge every open owner onto a known terminal contribution settle_final only cleared a stale error on retry, so an owner whose saved row still said open kept projecting a merged or closed pull request as open after another owner's row had already recorded the terminal observation. Copy the known terminal observation to every owner whose saved row is not itself terminal, keeping that owner's own pending and notified state, and clear its error. * no-mistakes(review): Carry terminal checked_at when converging existing owner rows * no-mistakes(ci): I fixed Greptile finding ci-2 as you asked, with a change to tests/fm-contributions.test.sh only. The rule it enforces: when a retry converges an owner onto a URL that is already merged or closed, that owner gets the terminal owner's whole observation, not just its state. The same weak check appeared twice in test_interrupted_multi_owner_poll_settles_every_owner, so I fixed both: - **Open owner (line 784):** the check now also requires `.observation == $terminal[0].records[0].observation`. The existing checks for error, checked_at, pending and notified are unchanged. - **Errored owner (just below):** it only checked state and error before. It now reads the terminal owner's file and makes the same full-observation comparison. Adding the comparison alone would not have caught anything. The test fixtures gave both owners identical observations apart from `state`, so copying only the state would still have passed. In both cases I also set the terminal owner's observation head to HEAD_B, so the two observations now really differ. Verification: - The focused test passes against the current bin/fm-contributions.sh. - I temporarily changed `settle_final` so it copied only the state. The test then failed, reporting the owner still on the old head (HEAD_A). I restored the file afterwards, and `git status` shows only the test file modified. - The full tests/fm-contributions.test.sh suite exits 0. No product code changed. The other CI finding (ci-1, "Behavior portable serial 9") was left alone because you chose to ignore it --- bin/fm-contributions.sh | 8 ++++--- tests/fm-contributions.test.sh | 39 +++++++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/bin/fm-contributions.sh b/bin/fm-contributions.sh index 39cc1072d22..4e27e33e8a1 100755 --- a/bin/fm-contributions.sh +++ b/bin/fm-contributions.sh @@ -56,7 +56,8 @@ # API failure leaves error evidence; an expired or absent observation is not # silence. FM_CONTRIBUTIONS_MAX_AGE (default 900 seconds) bounds freshness. # A URL whose last good observation is merged or closed is final: it is -# never re-read, stays fresh, and a stale error beside it is cleared once. +# never re-read, stays fresh, and every owner's saved row converges on that +# observation, with a stale error beside it cleared. # A genuine failure prints its unavailable line only when it starts an episode # (no prior owner has an error); a successful read ends the episode. # FM_CONTRIBUTIONS_NOW supplies an ISO UTC clock for tests, otherwise UTC now. @@ -338,8 +339,9 @@ settle_final() { # canonical-url task... : copy the URL's final observation to e jq -n --slurpfile final "$TMP/final.json" ' $final[0] + {error:null,pending:[],notified:[]}' > "$TMP/row.json" write_record "$task" "$TMP/row.json" - elif jq -e '.error != null' "$TMP/old.json" >/dev/null; then - jq '.error = null' "$TMP/old.json" > "$TMP/row.json" + elif jq -e '(.observation.state | IN("merged","closed") | not) or .error != null' "$TMP/old.json" >/dev/null; then + jq -n --slurpfile final "$TMP/final.json" --slurpfile old "$TMP/old.json" ' + $old[0] + {observation:$final[0].observation,checked_at:$final[0].checked_at,error:null}' > "$TMP/row.json" write_record "$task" "$TMP/row.json" fi done diff --git a/tests/fm-contributions.test.sh b/tests/fm-contributions.test.sh index 8cf07a5f7bb..aa14fe28a0e 100755 --- a/tests/fm-contributions.test.sh +++ b/tests/fm-contributions.test.sh @@ -766,6 +766,43 @@ test_late_owner_inherits_terminal_observation() { pass 'a late owner inherits a terminal observation without a forge read or wake' } +test_interrupted_multi_owner_poll_settles_every_owner() { + local home later=2026-09-17T08:00:00Z + home=$(new_home multi-owner-open) + forge_home "$home" + wrap_forge "$home" + record "$home" duplicate 8 open mergeable + mutate_record "$home" duplicate '.records[0].pending=[{token:"evt-1"}] | .records[0].notified=["evt-0"] + | .records[0].checked_at="2026-09-15T08:00:00Z"' + mutate_record "$home" delivery ".records[0].observation.state=\"merged\" | .records[0].observation.head=\"$HEAD_B\"" + printf 'down\n' > "$home/forge/fault" + with_home "$home" env FM_CONTRIBUTIONS_NOW="$later" "$ROOT/bin/fm-contributions.sh" poll >/dev/null \ + || fail 'interrupted multi-owner poll failed' + [ ! -s "$home/forge/calls" ] || fail 'a known terminal URL triggered a forge read' + jq -e --slurpfile terminal "$home/data/delivery/contributions.json" '.records[0] | .observation.state == "merged" + and .observation == $terminal[0].records[0].observation + and .error == null and .checked_at == $terminal[0].records[0].checked_at + and .pending == [{token:"evt-1"}] and .notified == ["evt-0"]' \ + "$home/data/duplicate/contributions.json" >/dev/null \ + || fail "an owner whose saved row stayed open did not converge on the known terminal observation: $(cat "$home/data/duplicate/contributions.json")" + + home=$(new_home multi-owner-errored) + forge_home "$home" + wrap_forge "$home" + record "$home" duplicate 8 open mergeable + mutate_record "$home" duplicate '.records[0].error="forge observation unavailable or changed during read"' + mutate_record "$home" delivery ".records[0].observation.state=\"merged\" | .records[0].observation.head=\"$HEAD_B\"" + printf 'down\n' > "$home/forge/fault" + with_home "$home" env FM_CONTRIBUTIONS_NOW="$later" "$ROOT/bin/fm-contributions.sh" poll >/dev/null \ + || fail 'interrupted multi-owner poll (errored owner) failed' + [ ! -s "$home/forge/calls" ] || fail 'a known terminal URL triggered a forge read (errored owner)' + jq -e --slurpfile terminal "$home/data/delivery/contributions.json" '.records[0] | .observation.state == "merged" + and .observation == $terminal[0].records[0].observation and .error == null' \ + "$home/data/duplicate/contributions.json" >/dev/null \ + || fail "an errored owner did not converge on the known terminal observation: $(cat "$home/data/duplicate/contributions.json")" + pass 'a retry converges every owner whose saved row is not terminal, keeping its own acknowledgement state' +} + test_done_task_open_pr_still_observed() { local home later=2026-09-17T08:00:00Z home=$(new_home done-open) @@ -1029,7 +1066,7 @@ test_late_owner_keeps_failure_episode_suppressed() { } failures=0 -for test_name in test_actor_coverage test_stale_verdict test_unchecked_is_not_silence test_newest_check_has_no_verdict test_comment_wake test_review_wake test_inline_wake test_ready_issue_wake test_fresh_issue_requires_maintainer test_missing_lane_remains_missing test_partial_freshness_keeps_measured_rows test_malformed_record_cannot_prove_silence test_issue_timeline_and_exact_ack test_verdict_retains_judged_head test_observed_replacement_refreshes_verdict test_unobserved_head_leaves_verdict_unknown test_away_yolo_is_fleet_work test_away_yolo_cross_home_is_fleet_work test_retired_and_unsupported_coverage test_unsupported_forge_is_not_fleet_work test_held_unsupported_forge_is_not_captain_work test_shared_contribution_signal_wakes_once test_watcher_keeps_diagnostics_separate_from_contribution_wakes test_expired_child_unsupported_forge_stays_unmeasured test_watcher_surfaces_new_contribution_once test_home_summary_coverage test_unreadable_pending_is_not_empty test_record_task_identity_matches_dirname_basename test_read_only_views_create_no_state test_budget_refusal_between_calls test_budget_bounded_call_timeout test_genuine_failure_near_deadline_is_unavailable test_shared_url_observed_once test_terminal_contribution_settles test_late_owner_inherits_terminal_observation test_done_task_open_pr_still_observed test_reservation_defers_later_url_when_fifteen_seconds_do_not_remain test_three_second_pr_reads_complete_fresh_in_one_cycle test_slow_read_deadline_kill_is_budget_refusal test_unmeasured_url_does_not_starve_the_tail test_budget_is_cut_down_to_the_watcher_check_bound test_arm_plumbs_a_configured_budget_into_the_check_shim test_unavailable_forge_records_error_and_wakes_once_per_episode test_late_owner_keeps_failure_episode_suppressed; do +for test_name in test_actor_coverage test_stale_verdict test_unchecked_is_not_silence test_newest_check_has_no_verdict test_comment_wake test_review_wake test_inline_wake test_ready_issue_wake test_fresh_issue_requires_maintainer test_missing_lane_remains_missing test_partial_freshness_keeps_measured_rows test_malformed_record_cannot_prove_silence test_issue_timeline_and_exact_ack test_verdict_retains_judged_head test_observed_replacement_refreshes_verdict test_unobserved_head_leaves_verdict_unknown test_away_yolo_is_fleet_work test_away_yolo_cross_home_is_fleet_work test_retired_and_unsupported_coverage test_unsupported_forge_is_not_fleet_work test_held_unsupported_forge_is_not_captain_work test_shared_contribution_signal_wakes_once test_watcher_keeps_diagnostics_separate_from_contribution_wakes test_expired_child_unsupported_forge_stays_unmeasured test_watcher_surfaces_new_contribution_once test_home_summary_coverage test_unreadable_pending_is_not_empty test_record_task_identity_matches_dirname_basename test_read_only_views_create_no_state test_budget_refusal_between_calls test_budget_bounded_call_timeout test_genuine_failure_near_deadline_is_unavailable test_shared_url_observed_once test_terminal_contribution_settles test_late_owner_inherits_terminal_observation test_interrupted_multi_owner_poll_settles_every_owner test_done_task_open_pr_still_observed test_reservation_defers_later_url_when_fifteen_seconds_do_not_remain test_three_second_pr_reads_complete_fresh_in_one_cycle test_slow_read_deadline_kill_is_budget_refusal test_unmeasured_url_does_not_starve_the_tail test_budget_is_cut_down_to_the_watcher_check_bound test_arm_plumbs_a_configured_budget_into_the_check_shim test_unavailable_forge_records_error_and_wakes_once_per_episode test_late_owner_keeps_failure_episode_suppressed; do ( "$test_name" ) || failures=$((failures + 1)) done [ "$failures" -eq 0 ] || fail "$failures contribution regressions" From 0a2cdf952898e495cfd47c24c181fa228ca4c8aa Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:27:07 -0700 Subject: [PATCH 08/76] feat: enable supervision host by default for Claude primaries (#6124) * feat: run the supervision host by default on a Claude primary An absent config/supervision-host on a Claude primary now reads as on with the default engine, and a file holding `off` opts any home out. Cursor, OpenCode, omp, Grok, and Codex stay file-gated, with `off` read as disabled there too. Every reader asks fm_supervision_host_enabled instead of testing the file, and non-bash readers query it through the lib's `enabled` entry. A primary's `off` is not inherited by secondmates: each home keeps its own supervision posture. * test: pin the watcher-path posture in fixtures that assume no supervision host Fixtures that drive the watcher arm or assert a non-host drain now write an explicit off file, and fixtures that copy the Stop auto-arm or the supervision instructions carry the engine lib they now source. The two drain suites also stop reading the code root's config. * fix: name the opt-out when an off home passes an attended wake to main A host parked when the home writes off now logs that the home does not run the supervision host, rather than claiming it has no engine. * no-mistakes(document): Clarify Claude supervision defaults and historical evidence * no-mistakes(ci): Fixed process leaks in the two added host tests. Each case now stops its recorded watcher and host/arm processes; fake hook sessions exit through session.stop. The full host suite passed before the final cleanup refinement, and both affected cases, bash syntax, ShellCheck, and diff checks passed afterward. CI runtime still needs confirmation --- .agents/skills/afk/SKILL.md | 12 +- .../skills/away-quiet-supervision/SKILL.md | 2 +- .../references/harness/claude.md | 2 +- .../references/harness/codex.md | 2 +- .../references/harness/cursor.md | 2 +- .../references/harness/grok.md | 2 +- .../references/harness/omp.md | 2 +- .../references/harness/opencode.md | 2 +- .../skills/operational-home-layout/SKILL.md | 2 +- .agents/skills/quiet/SKILL.md | 4 +- .omp/extensions/fm-primary-omp-watch.ts | 18 ++- .opencode/plugins/fm-primary-watch-arm.js | 16 +- README.md | 2 +- bin/fm-afk-launch.sh | 50 +++---- bin/fm-afk-return.sh | 4 +- bin/fm-claude-stop-autoarm.sh | 18 ++- bin/fm-host-mirror.sh | 22 +-- bin/fm-lease-lib.sh | 20 ++- bin/fm-supervision-engine-lib.sh | 87 ++++++++--- bin/fm-supervision-host.sh | 8 +- bin/fm-supervision-instructions.sh | 11 +- bin/fm-turnend-guard-cursor.sh | 9 +- bin/fm-watch-checkpoint.sh | 11 +- docs/architecture.md | 8 +- docs/configuration.md | 30 ++-- docs/herdr-backend.md | 2 +- docs/pi-supervision-branch.md | 2 +- docs/supervision-host.md | 28 ++-- docs/supervision-protocols/claude.md | 2 +- docs/supervision-protocols/omp.md | 2 +- .../supervision-protocols/supervision-host.md | 2 +- docs/verification/supervision.md | 4 +- docs/watcher-continuity.md | 4 +- tests/fm-afk-launch.test.sh | 80 +++++++--- tests/fm-branch-supervision.test.sh | 52 ++++--- tests/fm-claude-stop-autoarm.test.sh | 43 ++++-- tests/fm-cursor-primary.test.sh | 13 +- tests/fm-host-mirror.test.sh | 63 +++++--- tests/fm-omp-harness.test.sh | 58 ++++++- tests/fm-pi-watch-extension.test.sh | 1 + tests/fm-secondmate-harness.test.sh | 12 ++ tests/fm-session-lock-ancestry.test.sh | 5 + tests/fm-session-start.test.sh | 3 + tests/fm-supervision-host.test.sh | 141 ++++++++++++++---- tests/fm-supervision-instructions.test.sh | 37 +++-- tests/fm-turnend-guard.test.sh | 6 + tests/fm-wake-drain-outcome-backstop.test.sh | 8 + tests/fm-wake-drain-unread-status.test.sh | 8 + tests/fm-watch-checkpoint.test.sh | 16 ++ 49 files changed, 688 insertions(+), 250 deletions(-) diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index b296c56a2c6..520596080e7 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -2,7 +2,7 @@ name: afk description: >- Enter the away posture when the captain invokes /afk, says they are going afk, `state/.afk-contract` or `state/.afk` exists, an incoming message starts with `FM_INJECT_MARK`, or any `state/.subsuper-*` marker is involved. - It writes the durable away-posture record with the captain's away words verbatim as the whole mandate in the same turn as /afk, before any other work and without waiting for a further go, reads the words back in plain sentences after entry, announces hold-for-return only at entry, keeps the one supervision session running in the away posture (on Pi the supervision branch acts on the words by its own judgment and takes every safe actionable wake with main parked, as the supervision host does on a non-Pi home that opted into it; the daemon still delivers batched digests elsewhere for now), and on the first unmarked message renders the return brief from durable records before ordinary work resumes. + It writes the durable away-posture record with the captain's away words verbatim as the whole mandate in the same turn as /afk, before any other work and without waiting for a further go, reads the words back in plain sentences after entry, announces hold-for-return only at entry, keeps the one supervision session running in the away posture (on Pi the supervision branch acts on the words by its own judgment and takes every safe actionable wake with main parked, as the supervision host does on a non-Pi home that runs it; the daemon still delivers batched digests elsewhere for now), and on the first unmarked message renders the return brief from durable records before ordinary work resumes. user-invocable: true metadata: internal: true @@ -32,15 +32,15 @@ Hold-for-return is the default and the only reach profile this release records: The away daemon is no longer launched on Pi; the ordinary supervision session (`docs/pi-supervision-branch.md`) keeps running with the record present, and `bin/fm-afk-launch.sh start` refuses on these harnesses. With the record present main is parked: the supervision branch takes every safe actionable wake, captain outcomes accumulate for the return brief, and main's standing authority relocates to the branch through the guarded scripts (`docs/pi-supervision-branch.md` "Postures"); only a wake the branch declines (including a broken branch or unsafe scan) or a watcher failure wakes main. `/quiet` needs nothing extra on Pi: the attended branch already keeps routine wakes out of this conversation, so quiet-while-present is the attended posture's own shape there. - - **Claude, Cursor, OpenCode, omp, Grok, or Codex with `config/supervision-host`**: nothing to launch for `/afk`; go on to the announcement. + - **A home that runs the supervision host** (a Claude home unless `config/supervision-host` says `off`, or a Cursor, OpenCode, omp, Grok, or Codex home with that file; `docs/configuration.md` "Supervision host"): nothing to launch for `/afk`; go on to the announcement. The supervision host (`docs/supervision-host.md`) is the away session there: it runs the branch's contract on a headless engine under the record while main is parked, and `bin/fm-afk-launch.sh start` and `start-native` refuse the away daemon on that home. If `enter` printed a `Supervision host: no engine ...` line, every away wake reaches this conversation instead; say so in the announcement. `/quiet` enters nothing there where the attended host runs, and otherwise still launches the daemon below (the quiet skill's `quiet-check` decides). - - **Harness WITH a native in-pane tracked-background tool** (claude's and grok's, without the supervision host): run `bin/fm-afk-launch.sh start-native`, then run `FM_AFK_STATE_PREPARED=1 bin/fm-afk-start.sh` through that native tool. + - **Harness WITH a native in-pane tracked-background tool** (claude's and grok's, on a home that does not run the supervision host): run `bin/fm-afk-launch.sh start-native`, then run `FM_AFK_STATE_PREPARED=1 bin/fm-afk-start.sh` through that native tool. This is a deliberate no-separate-terminal exception because the harness-hosted job creates no terminal or layout mutation, and a shell launcher cannot invoke a harness-native background tool. If the native launch fails, run `bin/fm-afk-launch.sh stop` to roll back the prepared lifecycle. Do not wrap it in `nohup ... &` (Codex/herdr can reap fire-and-forget shell children after a tool call returns). - - **Every other harness** (codex, opencode, omp, and cursor without the supervision host, and kimi): run `bin/fm-afk-launch.sh start`. + - **Every other harness** (codex, opencode, omp, and cursor on a home that does not run the supervision host, and kimi): run `bin/fm-afk-launch.sh start`. It is the single owner of the daemon terminal: it creates a NON-VISIBLE tracked terminal for the current backend and passes the captain pane in as `FM_SUPERVISOR_TARGET` so the daemon injects into the captain, not its own new pane (docs/herdr-backend.md "Away-mode supervisor support"). Both daemon paths require the record `enter` wrote and share `bin/fm-afk-start.sh` as the daemon entry. The daemon is **presence-gated**: it injects escalations only while `state/.afk` exists, and stays quiet otherwise. @@ -61,7 +61,7 @@ Hold-for-return is the default and the only reach profile this release records: Destructive, irreversible, and security-sensitive actions are never pre-authorizable whatever the words say, and ask-user findings keep the `ask-user-authority` policy unless the words pre-answer the exact decision; anything else that needs the captain holds for their return. - On Pi, main is parked and the supervision branch handles every safe actionable wake under main's standing authority, through the same guarded scripts main would use: any pull request green at its live head may merge (which one the words meant is the branch's reading), queued work whose blockers cleared - already queued, or filed by the branch because the words explicitly call for it - dispatches within the spend cap, and a decision is answered with the captain's own pre-stated answer or under `ask-user-authority`. Anything else holds for the return, a red merge never proceeds while away, local-only landing always waits for the captain, and only a wake the branch declines (including a broken branch or unsafe scan) or a watcher failure wakes main (`docs/pi-supervision-branch.md` "Postures"). -- On a non-Pi home with `config/supervision-host`, the host's engine is that branch under the same rules, and a wake it hands back reaches main through that harness's own wake path (`Stop hook feedback` on Claude, a `watcher` follow-up on Cursor, OpenCode, and omp, the arm's background-task-completed notification on Grok, the checkpoint's output on Codex) with a `supervision-host:` line: that is automatic supervision, never the captain's return, so handle it under the away posture ([supervision protocol](../../../docs/supervision-protocols/supervision-host.md)). +- On a non-Pi home that runs the supervision host, the host's engine is that branch under the same rules, and a wake it hands back reaches main through that harness's own wake path (`Stop hook feedback` on Claude, a `watcher` follow-up on Cursor, OpenCode, and omp, the arm's background-task-completed notification on Grok, the checkpoint's output on Codex) with a `supervision-host:` line: that is automatic supervision, never the captain's return, so handle it under the away posture ([supervision protocol](../../../docs/supervision-protocols/supervision-host.md)). - The session-start digest reports the posture under its AFK subsection, so a restart re-enters the posture from the record, not from memory. ## How to exit: the return @@ -103,7 +103,7 @@ Destructive, irreversible, and security-sensitive actions are never pre-authoriz ## The daemon, where it still runs -On the harnesses that still launch the daemon (every verified harness except Pi and pi-signed, and except away mode on a home with `config/supervision-host`), the mechanics below are unchanged. +On the harnesses that still launch the daemon (every verified harness except Pi and pi-signed, and except away mode on a home that runs the supervision host), the mechanics below are unchanged. ### Operational prefix contract diff --git a/.agents/skills/away-quiet-supervision/SKILL.md b/.agents/skills/away-quiet-supervision/SKILL.md index b9120f54294..028020651b6 100644 --- a/.agents/skills/away-quiet-supervision/SKILL.md +++ b/.agents/skills/away-quiet-supervision/SKILL.md @@ -16,7 +16,7 @@ These safety facts apply to both: A record carrying quiet mode (`bin/fm-afk-contract.sh mode`) is quiet mode's instead: the captain is present, it holds nothing for a return, and requested actions proceed under ordinary attended authority. - While `state/.afk` exists, the daemon owns supervision; do not arm a separate watcher. The daemon is never launched on Pi, where the ordinary supervision session continues under the record with main parked: the branch takes every safe actionable wake it can, and only a declined wake (including a broken branch or unsafe scan) or a watcher failure wakes main. - Away mode on a non-Pi home with `config/supervision-host` works the same way with the supervision host as the branch; a wake it hands back arrives through that harness's own wake path and is never the captain's return. + Away mode on a non-Pi home that runs the supervision host (by default on Claude; `docs/configuration.md` "Supervision host") works the same way with the supervision host as the branch; a wake it hands back arrives through that harness's own wake path and is never the captain's return. - A marked message while away or quiet mode is active is internal escalation and does not exit that mode. - A message beginning `/afk` refreshes away mode; a message beginning `/quiet` refreshes quiet mode. - Any other unmarked message means the captain returned in away mode (load `/afk`, run the return owner, and do not process that message as ordinary work until its durable catch-up gate clears), or, in quiet mode, is simply answered as ordinary work with the flag and daemon left untouched until an explicit `/quiet off`. diff --git a/.agents/skills/harness-adapters/references/harness/claude.md b/.agents/skills/harness-adapters/references/harness/claude.md index 98bd7a824b1..8ff81adf3d0 100644 --- a/.agents/skills/harness-adapters/references/harness/claude.md +++ b/.agents/skills/harness-adapters/references/harness/claude.md @@ -81,7 +81,7 @@ Hooks still run through cwd-sensitive `/bin/sh`, so tracked commands anchor thro The Stop-owned watcher hook runs every Stop, foregrounds `../../../bin/fm-watch-arm.sh` only when eligible, and uses exit-2 async reawakening as notification. The model handles notifications but never routine re-arm. -In a home with `config/supervision-host` the hook foregrounds the supervision host instead, which also runs Claude's print mode as its headless engine; [`supervision-host.md`](../../../../../docs/supervision-host.md#engines) owns the verified engine facts. +Unless `config/supervision-host` says `off`, the hook foregrounds the supervision host instead, which also runs Claude's print mode as its headless engine; [`supervision-host.md`](../../../../../docs/supervision-host.md#engines) owns the verified engine facts. Claude's PreToolUse seatbelt blocks directly, and its deny is honored only with empty stdout; `../../../docs/arm-pretool-check.md` owns that contract. ### Delegation guard diff --git a/.agents/skills/harness-adapters/references/harness/codex.md b/.agents/skills/harness-adapters/references/harness/codex.md index 2dd3e4b33b7..8b7d6fb6d78 100644 --- a/.agents/skills/harness-adapters/references/harness/codex.md +++ b/.agents/skills/harness-adapters/references/harness/codex.md @@ -50,5 +50,5 @@ The tracked hook anchors to `pwd -P`, verifies that root is Firstmate-shaped and Codex's primary watcher protocol is `../../../bin/fm-watch-checkpoint.sh --seconds "${FM_CODEX_WATCH_CHECKPOINT:-180}"`, not `../../../bin/fm-watch-arm.sh`. Codex cannot reason while a foreground tool call is running, so the checkpoint is deliberately foreground and bounded to return control regularly for user messages and queued notifications. -In a home with `config/supervision-host` the checkpoint runs the supervision host instead of the watcher, with Claude's print mode as its headless engine, and holds for at least an hour while away; [`supervision-host.md`](../../../../../docs/supervision-host.md) owns the host and that bound. +In a home with `config/supervision-host` (not `off`) the checkpoint runs the supervision host instead of the watcher, with Claude's print mode as its headless engine, and holds for at least an hour while away; [`supervision-host.md`](../../../../../docs/supervision-host.md) owns the host and that bound. Codex's PreToolUse watcher-arm seatbelt blocks directly through its project hook. diff --git a/.agents/skills/harness-adapters/references/harness/cursor.md b/.agents/skills/harness-adapters/references/harness/cursor.md index 5b42074edbe..d0ecb997a7f 100644 --- a/.agents/skills/harness-adapters/references/harness/cursor.md +++ b/.agents/skills/harness-adapters/references/harness/cursor.md @@ -69,7 +69,7 @@ Example: `../../../bin/fm-spawn.sh --scout --harness cursor ## Primary integration Primary supervision is the stop-hook park in `../../../docs/supervision-protocols/cursor.md` through tracked `.cursor/hooks.json`; primary and secondmate launches require `--trust` or hooks do not load. -In a home with `config/supervision-host` the park runs the supervision host instead of `../../../bin/fm-watch-arm.sh`, with Claude's print mode as its headless engine; [`supervision-host.md`](../../../../../docs/supervision-host.md) owns the host. +In a home with `config/supervision-host` (not `off`) the park runs the supervision host instead of `../../../bin/fm-watch-arm.sh`, with Claude's print mode as its headless engine; [`supervision-host.md`](../../../../../docs/supervision-host.md) owns the host. Cursor exposes 20 project events plus a Claude-Code compatibility map that loads `.claude/settings.json`. Tracked hooks register `stop`, `sessionStart`, and two `preToolUse` seatbelts through `$CURSOR_PROJECT_DIR`; Claude entries stand down on Cursor payloads under `../../../docs/turnend-guard.md`. diff --git a/.agents/skills/harness-adapters/references/harness/grok.md b/.agents/skills/harness-adapters/references/harness/grok.md index ce44515b15f..70e2c7dd14e 100644 --- a/.agents/skills/harness-adapters/references/harness/grok.md +++ b/.agents/skills/harness-adapters/references/harness/grok.md @@ -90,5 +90,5 @@ The exact running Stop payload selects same-process continuation on 0.2.112; 0.2 Grok also loads Claude project settings, so Claude entries for Grok-covered events stand down under `GROK_AGENT` or `GROK_HOOK_EVENT`; that owner records the exact set and why `GROK_SESSION_ID` is excluded. Project-local hooks require launch-time `--trust`; without it the guard steps aside and `../../../bin/fm-guard.sh` is the next-command alarm. Watcher supervision remains tracked background notification around `../../../bin/fm-watch-arm.sh`, not Pi-style extension ownership. -In a home with `config/supervision-host` the session-start block renders that background call as `../../../bin/fm-supervision-host.sh park`, with Claude's print mode as its headless engine; [`supervision-host.md`](../../../../../docs/supervision-host.md) owns the host. +In a home with `config/supervision-host` (not `off`) the session-start block renders that background call as `../../../bin/fm-supervision-host.sh park`, with Claude's print mode as its headless engine; [`supervision-host.md`](../../../../../docs/supervision-host.md) owns the host. PreToolUse blocks directly, but every `$VAR` in a hook command needs inline `:-default` or Grok refuses the hook. diff --git a/.agents/skills/harness-adapters/references/harness/omp.md b/.agents/skills/harness-adapters/references/harness/omp.md index d07be220ddc..56531666dde 100644 --- a/.agents/skills/harness-adapters/references/harness/omp.md +++ b/.agents/skills/harness-adapters/references/harness/omp.md @@ -50,7 +50,7 @@ There is no `agent_settled` event; `agent_end` plus `willContinue` replaces it. The omp primary follows the Pi extension-owned watcher model through `../../../docs/supervision-protocols/omp.md`: `.omp/extensions/fm-primary-omp-watch.ts` arms `bin/fm-watch-arm.sh --restart` through the `fm_watch_arm_omp` tool and owns every successor, and `.omp/extensions/fm-primary-turnend-guard.ts` answers omp's blocking `session_stop` hook by forcing one continuation when `../../../bin/fm-turnend-guard.sh` returns 2, bounded per turn by omp's `stop_hook_active` flag. The same file ports the `tool_call` seatbelts and delivers the session-start digest through `before_agent_start` on the Run tier; omp's `session_start` carries no reason, so the source is derived (first start `startup` or `resume` from the launch line, later in-process starts `clear`, `session_compact` as `compact`). omp has no asynchronous Stop-hook equivalent, so the Claude auto-arm model does not apply; `fm_supervision_model` classifies omp as `extension`, and `fm_omp_extension_owns_supervision` in `../../../bin/fm-wake-lib.sh` is the ownership proof that tolerates the extension's own watcher hand-off. -The Pi supervision branch does not run on omp; without the supervision host every actionable wake is delivered to main, and in a home with `config/supervision-host` the watch extension spawns the host instead of the arm, with Claude's print mode as its headless engine ([`supervision-host.md`](../../../../../docs/supervision-host.md)). +The Pi supervision branch does not run on omp; without the supervision host every actionable wake is delivered to main, and in a home with `config/supervision-host` (not `off`) the watch extension spawns the host instead of the arm, with Claude's print mode as its headless engine ([`supervision-host.md`](../../../../../docs/supervision-host.md)). Launch a primary with plain `omp` inside the home (`FM_OMP_HARNESS=omp omp` when starting from a Claude pane); `../../../bin/fm-session-start.sh` prints `OMP_WATCH_EXTENSION: not loaded` when the running session has not loaded both tracked extensions. `FM_OMP_LIVE_E2E=1 ../../../tests/fm-omp-primary-live-e2e.test.sh` is the opt-in live guard; `../../../tests/fm-omp-harness.test.sh` is the portable regression. A secondmate registered with `remote=1` in `data/secondmates.md`, spawned through the ordinary `../../../bin/fm-spawn.sh --secondmate` path, is refused on omp until a remote host verifies it, as is `../../../bin/fm-remote-secondmate-control.sh launch`; there is no `--remote` flag. diff --git a/.agents/skills/harness-adapters/references/harness/opencode.md b/.agents/skills/harness-adapters/references/harness/opencode.md index 4bbd9744162..dd4c8b2ad27 100644 --- a/.agents/skills/harness-adapters/references/harness/opencode.md +++ b/.agents/skills/harness-adapters/references/harness/opencode.md @@ -37,7 +37,7 @@ The primary integration was verified on 2026-07-08 with OpenCode 1.17.6. `.opencode/plugins/fm-primary-turnend-guard.js` listens for `session.idle`. Throwing from `session.idle` does not block `opencode run`, so the primary adapter treats the event as passive and uses `client.session.promptAsync` to force one follow-up turn when `../../../bin/fm-turnend-guard.sh` returns 2. The follow-up was verified in the interactive TUI. -In a home with `config/supervision-host` the watch-arm plugin spawns the supervision host instead of `../../../bin/fm-watch-arm.sh`, with Claude's print mode as its headless engine; [`supervision-host.md`](../../../../../docs/supervision-host.md) owns the host. +In a home with `config/supervision-host` (not `off`) the watch-arm plugin spawns the supervision host instead of `../../../bin/fm-watch-arm.sh`, with Claude's print mode as its headless engine; [`supervision-host.md`](../../../../../docs/supervision-host.md) owns the host. `opencode run` can exit before displaying a queued follow-up, so the adapter steps aside in headless mode. On native Windows, the operational-input adapter runs its Bash helper through `bash`; macOS and Linux invoke it directly. diff --git a/.agents/skills/operational-home-layout/SKILL.md b/.agents/skills/operational-home-layout/SKILL.md index 8e45221e53d..ca7efc8df49 100644 --- a/.agents/skills/operational-home-layout/SKILL.md +++ b/.agents/skills/operational-home-layout/SKILL.md @@ -30,7 +30,7 @@ config/backend runtime session-provider backend override for new tasks; LOCAL, config/calm Calm presentation preference shared by the Pi extension and the Claude Code mod; LOCAL, gitignored, and not inherited; see docs/configuration.md "Calm preference" config/keep-ai-trailers optional presence flag to keep AI co-author trailers in this home's fleet commits; LOCAL, gitignored; inherited by secondmate homes; see docs/configuration.md "Commit attribution" config/supervision-branch-model config/supervision-branch-effort Pi supervision-branch model and reasoning-effort pins written by /supervision-model; LOCAL, gitignored, independently settable, and not inherited; see docs/configuration.md "Pi supervision branch model and effort" -config/supervision-host optional opt-in to the supervision host, which runs the supervision branch's contract on a headless engine beside a non-Pi primary, away and, on a Claude or Cursor primary, attended; LOCAL, gitignored, not inherited; absent changes nothing; see docs/configuration.md "Supervision host" +config/supervision-host optional supervision-host setting: the host runs the supervision branch's contract on a headless engine beside a non-Pi primary, away and, on a Claude or Cursor primary, attended; absent runs it on a Claude primary and nowhere else, "off" opts any home out; LOCAL, gitignored, not inherited; see docs/configuration.md "Supervision host" config/startup-memory-budget primary-authoritative per-home startup-memory budget; LOCAL, gitignored, materialized as 7,500 estimated tokens by locked primary bootstrap and inherited into secondmate homes; see docs/configuration.md "Startup memory budget" config/stow-pass-horizon optional presence flag opting this home in to /stow's default-off pass-count decay horizon; LOCAL, gitignored, and not inherited; see docs/configuration.md "Stow pass horizon" config/herdr-presentation-spaces optional "off" opt-out from, or "on" opt-in to, Herdr's default-on disposable single-task visual projection, which is unconfigured-default-on only at or above a Herdr version floor; LOCAL, gitignored; inherited by secondmate homes; see docs/herdr-backend.md "Presentation spaces" diff --git a/.agents/skills/quiet/SKILL.md b/.agents/skills/quiet/SKILL.md index b80cda6b45e..827eaf27531 100644 --- a/.agents/skills/quiet/SKILL.md +++ b/.agents/skills/quiet/SKILL.md @@ -33,12 +33,12 @@ For captain-held rechecks under quiet, see [architecture](../../../docs/architec 1. **Enter the lifecycle through `bin/fm-afk-launch.sh`, exactly as `/afk` does, with `FM_AFK_MODE=quiet` set first.** Follow the `afk` skill's record entry, daemon launch, and announcement steps, - except that on an opted-in host home its `/afk` no-daemon rule does not apply + except that on a home that runs the supervision host its `/afk` no-daemon rule does not apply after `quiet-check` exits 1. Never arm a separate `fm-watch.sh`. Export `FM_AFK_MODE=quiet` in the shell that invokes `bin/fm-afk-launch.sh enter` and `start` (or `start-native`), so the record notes quiet mode and `state/.afk`'s first line reads `quiet` instead of `away`. - On a home with `config/supervision-host`, launch the daemon on the path + On a home that runs the supervision host, launch the daemon on the path this harness uses without the host; `start` and `start-native` take quiet mode from the record `enter` wrote. Keep `FM_AFK_MODE=quiet` on a quiet refresh: an `/afk` entry, even without new words, replaces a quiet record with an away record and starts hold-for-return. diff --git a/.omp/extensions/fm-primary-omp-watch.ts b/.omp/extensions/fm-primary-omp-watch.ts index e033f48ceb2..1043d07f245 100644 --- a/.omp/extensions/fm-primary-omp-watch.ts +++ b/.omp/extensions/fm-primary-omp-watch.ts @@ -24,7 +24,8 @@ // - The arming tool is fm_watch_arm_omp and its human fallback // /fm-watch-arm-omp; the loaded-build marker is state/.omp-watch-extension-loaded. // - Supervision host: a home opted in with config/supervision-host -// (docs/configuration.md "Supervision host" owns the opt-in) spawns +// (docs/configuration.md "Supervision host" owns the gate, which +// bin/fm-supervision-engine-lib.sh enabled answers; an `off` file opts out) spawns // bin/fm-supervision-host.sh park --restart in the arm's place, which // takes away-posture wakes itself and closes only when main is needed; its // header owns the output read here. A "supervision-host:" line is @@ -33,8 +34,8 @@ // eight-line cap. The host // prints the first cycle's status line as soon as it is verified, so // readiness and the handling handoff work as they do for the arm, with a -// longer readiness budget for the host's own startup. Without the file -// nothing below changes. +// longer readiness budget for the host's own startup. On a home that does +// not run the host nothing below changes. // // Session-generation ownership (stated once here): // omp emits session_shutdown for ordinary same-process replacements (/new, @@ -267,6 +268,15 @@ function awayRecordPresent(): boolean { return String(result.stdout || "").trim() !== "quiet"; } +// Whether this home runs the supervision host for an omp primary; the gate's +// owner answers, and a query that cannot run reads as no host. +function hostModeEnabled(): boolean { + const result = spawnSync("bash", [`${fmRoot}/bin/fm-supervision-engine-lib.sh`, "enabled", config, "omp"], { + stdio: "ignore", + }); + return result.status === 0; +} + // The host-mode wake message: every "supervision-host:" line in order, wake // lines capped at eight, and the away note while an away record exists. function hostWakeMessage(output: string): string { @@ -948,7 +958,7 @@ export default function (pi: ExtensionAPI) { }; } const id = ++owner.seq; - const hostMode = existsSync(`${config}/supervision-host`); + const hostMode = hostModeEnabled(); const env: NodeJS.ProcessEnv = { ...process.env, FM_HOME: fmHome, diff --git a/.opencode/plugins/fm-primary-watch-arm.js b/.opencode/plugins/fm-primary-watch-arm.js index 75b0a87ec94..1be0d25ccd6 100644 --- a/.opencode/plugins/fm-primary-watch-arm.js +++ b/.opencode/plugins/fm-primary-watch-arm.js @@ -4,7 +4,8 @@ import { resolve } from "node:path"; import { encodeFirstmateOperationalInput } from "./lib/fm-operational-input.js"; // Supervision host: a home opted in with config/supervision-host -// (docs/configuration.md "Supervision host" owns the opt-in) spawns +// (docs/configuration.md "Supervision host" owns the gate, which +// bin/fm-supervision-engine-lib.sh enabled answers; an `off` file opts out) spawns // bin/fm-supervision-host.sh park --restart in the arm's place, which takes // away-posture wakes itself and closes only when main is needed; its header // owns the output read here. A "supervision-host:" line is actionable like a @@ -12,7 +13,7 @@ import { encodeFirstmateOperationalInput } from "./lib/fm-operational-input.js"; // wake lines keep an eight-line cap. The host prints the first cycle's status // line as soon as it is verified, so readiness and the handling handoff work // as they do for the arm, with a longer readiness budget for the host's own -// startup. Without the file nothing below changes. +// startup. On a home that does not run the host nothing below changes. const COORDINATOR_KEY = "__firstmateOpenCodeWatchArm"; // 35s on Windows so the budget stays above arm's MSYS confirm default (30s in // bin/fm-watch-arm.sh): a slow but successful Git Bash cold start must not be @@ -156,6 +157,15 @@ function awayRecordPresent(paths) { return String(result.stdout || "").trim() !== "quiet"; } +// Whether this home runs the supervision host for an OpenCode primary; the +// gate's owner answers, and a query that cannot run reads as no host. +function hostModeEnabled(paths) { + const result = spawnSync("bash", [`${paths.root}/bin/fm-supervision-engine-lib.sh`, "enabled", paths.config, "opencode"], { + stdio: "ignore", + }); + return result.status === 0; +} + // The host-mode wake message: every "supervision-host:" line in order, wake // lines capped at eight, and the away note while an away record exists. function hostWakeMessage(paths, combined) { @@ -387,7 +397,7 @@ async function scheduleRetry(paths, sessionID, client, reason, predecessorArmPid function spawnArm(paths, sessionID, client, predecessorArmPid = "") { setArmStatus("starting"); - const hostMode = existsSync(`${paths.config}/supervision-host`); + const hostMode = hostModeEnabled(paths); const env = { ...process.env, FM_HOME: paths.home, diff --git a/README.md b/README.md index ff2914fc6c9..85e9afbc1fd 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,7 @@ Claude and grok use the slash form shown here; codex uses the same names with `$ | Skill | What it does | | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `/afk` | Enter away-mode supervision: Pi's in-process branch, an [opt-in supervision host](docs/configuration.md#supervision-host-configsupervision-host) beside the other primaries, or the daemon handles wakes while you step away; see the [away procedure](.agents/skills/afk/SKILL.md) for the posture and return contract | +| `/afk` | Enter away-mode supervision: Pi's in-process branch, a [supervision host](docs/configuration.md#supervision-host-configsupervision-host) beside the other primaries (on by default for Claude), or the daemon handles wakes while you step away; see the [away procedure](.agents/skills/afk/SKILL.md) for the posture and return contract | | `/quiet` | Keep routine wakes off main while staying and chatting; requested actions proceed now rather than waiting for your return. Where Pi's branch or an [attended supervision host](docs/supervision-host.md#quiet-mode) already does this, it only says so; otherwise it starts the quiet daemon, which stays active through ordinary chat until `/quiet off` | | `/ahoy` | Recap visible session events since the prior real captain message plus visibly unanswered captain decisions, then guide the captain through any open decisions one at a time in agent-judged impact order; fall back to Bearings when invoked as the session's first real captain message | | `/bearings` | Generate a concise four-section chat digest from bounded fleet state, including registered remote-home ledgers and measured follow-up for owned contributions; use `/bearings file` to also replace today's dated report in `data/`, and add `include PRs` for live GitHub enrichment | diff --git a/bin/fm-afk-launch.sh b/bin/fm-afk-launch.sh index 7cb5b11d83d..23bab8d4da8 100755 --- a/bin/fm-afk-launch.sh +++ b/bin/fm-afk-launch.sh @@ -19,12 +19,13 @@ # on Pi, the ordinary supervision session keeps running in both postures, and # `start` refuses on those harnesses. The same holds for away mode (not quiet # mode) on a claude, cursor, opencode, omp, grok, or codex primary whose home -# opted into the supervision host (config/supervision-host), where the host -# runs the away session; `enter` there adds one line when the host has no +# runs the supervision host (fm_supervision_host_enabled: by default on +# Claude, by config/supervision-host elsewhere), where the host runs the away +# session; `enter` there adds one line when the host has no # engine, because every away wake then reaches main. Every other harness still # runs the daemon for now, so `start` and `start-native` require the record # `enter` wrote before they launch the daemon. -# QUIET MODE on a home that opted into the supervision host needs nothing +# QUIET MODE on a home that runs the supervision host needs nothing # where the attended host runs (docs/supervision-host.md "Quiet mode"): its # primary is attended-ready (fm_supervision_host_attended_ready: engine, tools, # and a verified dialog-mirror writer), the main session can be identified, @@ -94,7 +95,7 @@ # above): exit 0 with one line when it needs # nothing; exit 1 when quiet mode enters through # `enter` and the daemon, with one line naming why -# only on a home that opted in; exit 2 with one +# only on a home that runs the host; exit 2 with one # line naming a live away record on that home. # # Supported backends: herdr, tmux. Others (zellij, orca, cmux) have no verified @@ -106,9 +107,8 @@ # override the captured captain pane/backend (an isolated lab pane in tests). # FM_AFK_MODE (away|quiet, default away) declares which mode an `enter` writes; # with it unset, a daemon start/refresh uses the record's mode. -# FM_TEST_HARNESS pins only this launch path's primary harness when -# FM_TEST_SEAM=1 and its value is a known harness token; otherwise detection -# remains real. tests/lib.sh arms the marker for isolated suites. +# FM_TEST_HARNESS pins the primary harness this launch path judges, through +# fm_supervision_host_primary (bin/fm-supervision-engine-lib.sh owns the seam). set -u FM_AFK_LAUNCH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -157,7 +157,7 @@ set +e # shellcheck source=bin/fm-afk-contract.sh . "$FM_AFK_LAUNCH_DIR/fm-afk-contract.sh" FM_AFK_CONTRACT_CMD="$FM_AFK_LAUNCH_DIR/fm-afk-contract.sh" -# The supervision host's opt-in parse and attended readiness check. +# The supervision host's home gate and attended readiness check. # shellcheck source=bin/fm-supervision-engine-lib.sh . "$FM_AFK_LAUNCH_DIR/fm-supervision-engine-lib.sh" @@ -224,21 +224,11 @@ fm_afk_launch_usage() { } fm_afk_launch_primary_harness() { - # Keep the test pin local to this launch path; fm-harness.sh's production - # detect_own precedence never reads either variable (see header). - if [ "${FM_TEST_SEAM:-}" = 1 ]; then - case "${FM_TEST_HARNESS:-}" in - claude | codex | opencode | pi | pi-signed | grok | kimi | cursor | gemini | muse | rovo | omp | agy | devin | unknown) - printf '%s' "$FM_TEST_HARNESS" - return - ;; - esac - fi - "$FM_AFK_LAUNCH_DIR/fm-harness.sh" 2>/dev/null || printf unknown + fm_supervision_host_primary } # The primary harnesses whose arm owner runs the supervision host when the -# home opted in (docs/supervision-host.md). +# home runs it (docs/supervision-host.md). fm_afk_launch_host_primary() { # case "$1" in claude|cursor|opencode|omp|grok|codex) return 0 ;; @@ -262,17 +252,18 @@ fm_afk_launch_requested_mode() { } # Whether /quiet needs anything here (the header's QUIET MODE): 0 when it needs -# nothing; 2 while an away record is live on a home that opted in; otherwise -# 1, with FM_AFK_LAUNCH_QUIET_WHY naming what the attended host lacks on a -# home that opted in, or empty where quiet mode is the daemon's as it is -# without the host (no opt-in, another primary, or quiet mode already entered). +# nothing; 2 while an away record is live on a home that runs the host; +# otherwise 1, with FM_AFK_LAUNCH_QUIET_WHY naming what the attended host +# lacks on a home that runs it, or empty where quiet mode is the daemon's as +# it is without the host (no host, another primary, or quiet mode already +# entered). fm_afk_launch_quiet_needs_nothing() { local harness config FM_AFK_LAUNCH_QUIET_WHY= harness=$(fm_afk_launch_primary_harness) fm_afk_launch_host_primary "$harness" || return 1 config=${FM_CONFIG_OVERRIDE:-$FM_HOME/config} - fm_supervision_host_enabled "$config" || return 1 + fm_supervision_host_enabled "$config" "$harness" || return 1 if fm_afk_contract_present "$FM_AFK_LAUNCH_STATE"; then fm_afk_launch_record_quiet || return 2 return 1 @@ -314,7 +305,7 @@ fm_afk_launch_quiet_check() { } # The away daemon is no longer launched on Pi, nor for away mode on a primary -# whose home opted into the supervision host (config/supervision-host, +# whose home runs the supervision host (fm_supervision_host_enabled, # docs/supervision-host.md): the posture record is the whole entry there and # the ordinary supervision session runs in both postures. Quiet mode runs the # daemon on that home only where a quiet `enter` found the attended host @@ -329,24 +320,23 @@ fm_afk_launch_daemon_allowed() { return 1 ;; esac fm_afk_launch_host_primary "$harness" || return 0 - [ -f "${FM_CONFIG_OVERRIDE:-$FM_HOME/config}/supervision-host" ] || return 0 + fm_supervision_host_enabled "${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" "$harness" || return 0 mode=$(fm_afk_launch_requested_mode) if [ -z "$mode" ] && [ -f "$FM_AFK_LAUNCH_STATE/.afk" ]; then mode=$(head -n 1 "$FM_AFK_LAUNCH_STATE/.afk" 2>/dev/null || true) fi [ "$mode" != quiet ] || return 0 - fm_afk_launch_log "the away daemon is not launched on this $harness home, which runs the supervision host (config/supervision-host); the away-posture record is the posture here (run bin/fm-afk-launch.sh enter and stop)" + fm_afk_launch_log "the away daemon is not launched on this $harness home, which runs the supervision host (docs/supervision-host.md); the away-posture record is the posture here (run bin/fm-afk-launch.sh enter and stop)" return 1 } # One line for the entry when this home runs the supervision host but the host -# has no engine (bin/fm-supervision-engine-lib.sh owns the opt-in parse), so +# has no engine (bin/fm-supervision-engine-lib.sh owns the home gate), so # the away posture would hand every wake to main. fm_afk_launch_host_engine_note() { local harness config [ "${FM_AFK_MODE:-}" != quiet ] || return 0 config=${FM_CONFIG_OVERRIDE:-$FM_HOME/config} - [ -f "$config/supervision-host" ] || return 0 harness=$(fm_afk_launch_primary_harness) fm_afk_launch_host_primary "$harness" || return 0 fm_supervision_host_config "$config" "$harness" || return 0 diff --git a/bin/fm-afk-return.sh b/bin/fm-afk-return.sh index 1504f5c65ab..99e6bc86ac7 100755 --- a/bin/fm-afk-return.sh +++ b/bin/fm-afk-return.sh @@ -402,7 +402,7 @@ engine_snapshot() { # IFS='|' read -r errors trip last cooldown recovered episode_count </dev/null)" \ + if fm_supervision_host_config "${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" "$(fm_supervision_host_primary)" \ && retry=$(fm_supervision_host_paused_until "$STATE") \ && { [ -z "$recovered" ] || [ "$retry" -gt "$recovered" ]; }; then paused=1 @@ -665,7 +665,7 @@ EOF # 6. handled while away. Every outcome the away session recorded in the # store during the window counts as handled. On Pi the supervision branch, - # and on an opted-in home the supervision host (docs/supervision-host.md), took + # and on a home that runs it the supervision host (docs/supervision-host.md), took # every safe actionable wake it could while main was parked; wakes it # declined still fell back to main. The captain rows are listed above. printf 'Handled while away:\n' diff --git a/bin/fm-claude-stop-autoarm.sh b/bin/fm-claude-stop-autoarm.sh index be21aa7ed65..31721989bda 100755 --- a/bin/fm-claude-stop-autoarm.sh +++ b/bin/fm-claude-stop-autoarm.sh @@ -63,9 +63,10 @@ # live watcher was confirmed, and never withholds the wake for it; the # next Stop's foreground arm attaches to that live cycle. The supervision # host owns its own successors, so its path is unchanged. -# - Supervision host: a home opted in with config/supervision-host -# (docs/configuration.md "Supervision host" owns the opt-in) runs -# bin/fm-supervision-host.sh in the arm's place, bound to this generation. +# - Supervision host: a home that runs it (by default on this Claude +# primary; docs/configuration.md "Supervision host" owns the gate and its +# `off` opt-out) runs bin/fm-supervision-host.sh in the arm's place, bound +# to this generation. # To this hook it is an arm that also takes away-posture wakes itself and # ends its own park before the hook timeout with a "supervision-host:" # line, which is actionable here like a wake line; its rewake banner @@ -73,7 +74,8 @@ # its wake lines keep the arm's eight-line cap. A "supervision-host stood # down:" close exits 0 silently, and a host that died without a close is # retried instead of being judged by the healthy-watcher predicate -# (docs/supervision-host.md). Without the file nothing below changes. +# (docs/supervision-host.md). On a home that opted out nothing below +# changes. # - Translation: while supervision is still needed and AFK remains inactive, # an actionable arm close (signal:/stale:/check:/heartbeat) prints one # rewake banner to stderr and exits 2, which wakes Claude even while idle @@ -155,6 +157,8 @@ esac . "$SCRIPT_DIR/fm-session-lock-lib.sh" # shellcheck source=bin/fm-hook-host-lib.sh . "$SCRIPT_DIR/fm-hook-host-lib.sh" +# shellcheck source=bin/fm-supervision-engine-lib.sh +. "$SCRIPT_DIR/fm-supervision-engine-lib.sh" # fm-watch.sh touches the liveness beacon once per cycle, immediately before # its terminal wait, so a healthy watcher's beacon can legitimately age up to @@ -396,8 +400,8 @@ HEALTHY=0 HOST_MODE=0 HOST_RC=0 ACTIONABLE_RE='^(signal:|stale:|check:|heartbeat($|:))' -# The opt-in is the file's presence (docs/configuration.md "Supervision host"). -if [ -f "$CONFIG/supervision-host" ]; then +# The home gate's owner decides (docs/configuration.md "Supervision host"). +if fm_supervision_host_enabled "$CONFIG" claude; then HOST_MODE=1 ACTIONABLE_RE='^(signal:|stale:|check:|heartbeat($|:)|supervision-host:)' fi @@ -550,7 +554,7 @@ if [ ! -e "$FAILURE_NOTICE" ]; then { printf 'firstmate watcher auto-arm FAILED - the Stop-owned automatic supervision mechanism is broken after %s bounded attempts, and no live watcher with a fresh beacon was verified.\n' "$attempt" [ -n "$OUT" ] && grep -E '^(watcher:|signal:|stale:|check:|heartbeat|supervision-host)' "$OUT" 2>/dev/null | head -8 - [ "$HOST_MODE" -eq 0 ] || printf 'The supervision host (config/supervision-host) ran these cycles; its last one exited %s without a wake.\n' "$HOST_RC" + [ "$HOST_MODE" -eq 0 ] || printf 'The supervision host (docs/supervision-host.md) ran these cycles; its last one exited %s without a wake.\n' "$HOST_RC" printf 'Do not launch a manual background arm from this notice; investigate the automatic Stop hook and watcher startup before ending blind.\n' } >&2 if autoarm_commit failed "$FAILURE_NOTICE"; then diff --git a/bin/fm-host-mirror.sh b/bin/fm-host-mirror.sh index a47dc7a28c7..1303ac51ee7 100755 --- a/bin/fm-host-mirror.sh +++ b/bin/fm-host-mirror.sh @@ -22,11 +22,12 @@ # submits its Stop-hook rewake inside , with no other field # to tell it from a typed prompt (tests/fm-host-mirror-live-e2e.test.sh proves # it). -# Every writer is a silent no-op unless this home opted into the supervision -# host (config/supervision-host, checked before anything else runs), the hook -# runs in a genuine primary checkout, and this session holds the fleet lock, so -# a home without the file, a crewmate worktree, and a read-only second session -# write nothing and print nothing. +# Every writer is a silent no-op unless this home runs the supervision host +# for the writer's primary (fm_supervision_host_enabled, checked before +# anything else runs: by default on Claude, never with an `off` file), the +# hook runs in a genuine primary checkout, and this session holds the fleet +# lock, so a home that opted out or never opted in, a crewmate worktree, and a +# read-only second session write nothing and print nothing. # # FILE. $STATE/.host-mirror.jsonl, one JSON object per line: # {"seq":N,"epoch":N,"key":"
","id":"", @@ -95,6 +96,9 @@ MIRROR_CAP=4000 MIRROR_KEEP=200 FEED_CAP=16000 +# shellcheck source=bin/fm-supervision-engine-lib.sh +. "$SCRIPT_DIR/fm-supervision-engine-lib.sh" + usage() { sed -n '/^# Usage:/,/^# hook and commit/p' "${BASH_SOURCE[0]}" | sed '$d' | sed 's/^# \{0,1\}//' >&2 exit 2 @@ -107,9 +111,9 @@ case "${1:-}" in exit 1 ;; hook) - # The opt-in gate runs before anything is sourced or created, so a home - # without the file, and a crewmate worktree with no config/, stay inert. - [ -f "$CONFIG/supervision-host" ] || exit 0 + # The home gate runs before anything else is sourced or created, so a + # home that does not run the host stays inert. + fm_supervision_host_enabled "$CONFIG" "${2:-}" || exit 0 ;; feed|commit|check) ;; -h|--help) sed -n '2,/^set -u/p' "${BASH_SOURCE[0]}" | sed '$d' | sed 's/^# \{0,1\}//'; exit 0 ;; @@ -123,8 +127,6 @@ fi # shellcheck source=bin/fm-wake-lib.sh . "$SCRIPT_DIR/fm-wake-lib.sh" -# shellcheck source=bin/fm-supervision-engine-lib.sh -. "$SCRIPT_DIR/fm-supervision-engine-lib.sh" umask 077 MIRROR="$STATE/.host-mirror.jsonl" diff --git a/bin/fm-lease-lib.sh b/bin/fm-lease-lib.sh index a17a3333fac..bf1372cd357 100755 --- a/bin/fm-lease-lib.sh +++ b/bin/fm-lease-lib.sh @@ -55,9 +55,9 @@ # - Guard semantics (fm_lease_guard): no lease, a same-actor lease, or a # provably stale lease passes; a live lease held by the OTHER actor # refuses with exit FM_LEASE_REFUSE_EXIT. Whenever the guard engages - a -# supervision context (Pi, or an explicit actor), a home opted into the -# supervision host (config/supervision-host, whose host can claim a task -# that has no lease yet), or any lease file for the task - it retains the +# supervision context (Pi, or an explicit actor), a home that runs the +# supervision host (fm_supervision_host_enabled, whose host can claim a +# task that has no lease yet), or any lease file for the task - it retains the # lease-command lock until fm_lease_guard_release, so the other actor # cannot claim between the check and the guarded mutation, including the # first claim of a task no one has leased. An unmarked caller in any other @@ -114,6 +114,16 @@ fm_lease_lock_helpers() { . "$FM_LEASE_LIB_DIR/fm-wake-lib.sh" } +# fm_lease_home_runs_host: 0 iff this home runs the supervision host +# (fm_supervision_host_enabled owns the gate). +fm_lease_home_runs_host() { + if ! command -v fm_supervision_host_enabled >/dev/null 2>&1; then + # shellcheck source=bin/fm-supervision-engine-lib.sh + . "$FM_LEASE_LIB_DIR/fm-supervision-engine-lib.sh" + fi + fm_supervision_host_enabled "${FM_CONFIG_OVERRIDE:-${FM_HOME:-$STATE/..}/config}" +} + # fm_lease_actor: print the current actor after validating it. Returns 1 (with # stderr) for an unknown FM_SUPERVISION_ACTOR value. fm_lease_actor() { @@ -202,9 +212,7 @@ fm_lease_guard() { case "${PI_CODING_AGENT:-}:${FM_SUPERVISION_ACTOR:-}" in true:*|*:main|*:branch) ;; *) - [ -e "$(fm_lease_path "$task")" ] \ - || [ -e "${FM_CONFIG_OVERRIDE:-${FM_HOME:-$STATE/..}/config}/supervision-host" ] \ - || return 0 + [ -e "$(fm_lease_path "$task")" ] || fm_lease_home_runs_host || return 0 ;; esac fm_lease_lock_helpers diff --git a/bin/fm-supervision-engine-lib.sh b/bin/fm-supervision-engine-lib.sh index 180094ea14d..afbea967896 100644 --- a/bin/fm-supervision-engine-lib.sh +++ b/bin/fm-supervision-engine-lib.sh @@ -2,15 +2,23 @@ # fm-supervision-engine-lib.sh - which headless engine runs the supervision # host's branch session, and how one engine turn runs (one owner of both). # -# Sourced, never executed. docs/supervision-host.md owns the host design and +# Sourced, and executed only for the home-gate query below. +# docs/supervision-host.md owns the host design and # bin/fm-supervision-host.sh the loop; this file owns two contracts, plus the # main-session key (fm_supervision_host_main_key) and the attended readiness # check (fm_supervision_host_attended_ready) the host's parts share. # -# THE HOME OPT-IN (config/supervision-host). docs/configuration.md -# "Supervision host" owns the file's schema and its no-engine outcome; this -# file implements it (fm_supervision_host_config) and holds the verified-engine -# list and each engine's default model (docs/supervision-host.md "Engines"). +# THE HOME GATE (config/supervision-host). docs/configuration.md +# "Supervision host" owns the file's schema, its default on a Claude primary, +# its `off` opt-out, and its no-engine outcome; this file implements them +# (fm_supervision_host_enabled, fm_supervision_host_config) and holds the +# verified-engine list and each engine's default model +# (docs/supervision-host.md "Engines"). Every reader of the file asks +# fm_supervision_host_enabled rather than testing the file itself, and a +# reader outside bash runs this file: +# bash fm-supervision-engine-lib.sh enabled +# which exits 0 when that home runs the host for that primary and 1 +# otherwise, printing nothing (2 on a usage error). # # ONE ENGINE TURN (fm_supervision_engine_turn). One prompt to one engine # conversation, bounded, from the tracked code root, with the environment the @@ -31,15 +39,44 @@ # process group of its own. docs/supervision-host.md "Engines" owns the # verified engine facts each argument list below is built from. # -# Test seam: FM_SUPERVISION_ENGINE_CLAUDE_BIN names the claude executable +# Test seams: FM_SUPERVISION_ENGINE_CLAUDE_BIN names the claude executable # (default: claude on PATH), so a hermetic test can run a stub engine through -# the real argument construction. +# the real argument construction. FM_TEST_HARNESS pins the primary harness +# fm_supervision_host_primary reports when FM_TEST_SEAM=1 and its value is a +# known harness token; otherwise detection remains real (tests/lib.sh arms +# the marker for isolated suites). FM_SUPERVISION_ENGINES_VERIFIED='claude' -# fm_supervision_host_enabled : 0 iff this home opted in. +# fm_supervision_host_primary: print the primary harness the home gate judges +# (bin/fm-harness.sh, whose supervision-branch pin names the primary inside an +# engine turn), or "unknown". +fm_supervision_host_primary() { + if [ "${FM_TEST_SEAM:-}" = 1 ]; then + case "${FM_TEST_HARNESS:-}" in + claude | codex | opencode | pi | pi-signed | grok | kimi | cursor | gemini | muse | rovo | omp | agy | devin | unknown) + printf '%s\n' "$FM_TEST_HARNESS" + return + ;; + esac + fi + "$(dirname "${BASH_SOURCE[0]}")/fm-harness.sh" 2>/dev/null || printf 'unknown\n' +} + +# fm_supervision_host_enabled []: 0 iff this home +# runs the supervision host. A file whose first word is "off" opts out on +# every primary; any other file opts in; with no file, a Claude primary runs +# the host at its default engine and every other primary does not. The +# primary is detected (fm_supervision_host_primary) only when the file is +# absent and the caller did not name one. fm_supervision_host_enabled() { - [ -f "$1/supervision-host" ] + local word='' rest + if [ -f "$1/supervision-host" ]; then + read -r word rest < "$1/supervision-host" 2>/dev/null || true + [ "$word" != off ] + return + fi + [ "${2-$(fm_supervision_host_primary)}" = claude ] } fm_supervision_engine_verified() { # @@ -57,7 +94,8 @@ fm_supervision_engine_default_model() { # } # fm_supervision_host_config -# Returns 1 when the home did not opt in. Otherwise returns 0 and sets +# Returns 1 when the home does not run the host (fm_supervision_host_enabled). +# Otherwise returns 0 and sets # FM_SUPERVISION_ENGINE and FM_SUPERVISION_ENGINE_MODEL for a usable engine, or # leaves both empty and sets FM_SUPERVISION_ENGINE_PROBLEM to one plain # sentence naming why this home has no engine. @@ -67,9 +105,10 @@ fm_supervision_host_config() { FM_SUPERVISION_ENGINE='' FM_SUPERVISION_ENGINE_MODEL='' FM_SUPERVISION_ENGINE_PROBLEM='' - fm_supervision_host_enabled "$config" || return 1 + fm_supervision_host_enabled "$config" "$primary" || return 1 line= - IFS= read -r line < "$config/supervision-host" 2>/dev/null || true + [ ! -f "$config/supervision-host" ] \ + || IFS= read -r line < "$config/supervision-host" 2>/dev/null || true engine='' model='' extra='' read -r engine model extra </dev/null 2>&1; then FM_SUPERVISION_HOST_UNREADY="the $FM_SUPERVISION_ENGINE engine executable is missing" @@ -132,12 +173,14 @@ fm_supervision_host_attended_ready() { # fm_supervision_host_outcomes_drained : 0 when main processes the # supervision session's outcomes through the drain's BRANCH OUTCOMES section -# (bin/fm-wake-drain.sh): the home opted in and its primary is not Pi, whose -# branch extension owns that path. The drain and the return +# (bin/fm-wake-drain.sh): the home runs the host and its primary is not Pi, +# whose branch extension owns that path. The drain and the return # (bin/fm-afk-return.sh) share this check. fm_supervision_host_outcomes_drained() { - fm_supervision_host_enabled "$1" || return 1 - case "$("$(dirname "${BASH_SOURCE[0]}")/fm-harness.sh" 2>/dev/null)" in pi|pi-signed) return 1 ;; esac + local primary + primary=$(fm_supervision_host_primary) + case "$primary" in pi|pi-signed) return 1 ;; esac + fm_supervision_host_enabled "$1" "$primary" } # fm_supervision_host_main_key : print the key of the current main @@ -399,3 +442,13 @@ fm_supervision_engine_result() { *) return 1 ;; esac } + +# The home-gate query (THE HOME GATE above), when this file is executed. +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + if [ "$#" -eq 3 ] && [ "$1" = enabled ]; then + fm_supervision_host_enabled "$2" "$3" + exit + fi + echo "usage: fm-supervision-engine-lib.sh enabled " >&2 + exit 2 +fi diff --git a/bin/fm-supervision-host.sh b/bin/fm-supervision-host.sh index 876c4d75b8f..f42e15ef3ee 100755 --- a/bin/fm-supervision-host.sh +++ b/bin/fm-supervision-host.sh @@ -7,7 +7,9 @@ # fm-supervision-host.sh park [--restart] # # A primary's arm owner runs this in place of bin/fm-watch-arm.sh when the home -# opted in (config/supervision-host): the Claude Stop auto-arm +# runs the host (by default on Claude, by config/supervision-host elsewhere, +# never with an `off` file; docs/configuration.md "Supervision host"): the +# Claude Stop auto-arm # (bin/fm-claude-stop-autoarm.sh), the Cursor stop-hook park # (bin/fm-turnend-guard-cursor.sh), the OpenCode TUI plugin # (.opencode/plugins/fm-primary-watch-arm.js), the omp watch extension @@ -208,7 +210,7 @@ COOLDOWN_MAX=3600 AUTOARM_GEN=${FM_SUPERVISION_HOST_AUTOARM_GEN:-} AUTOARM_OWNER=${FM_SUPERVISION_HOST_OWNER_PID:-} PRIMARY=${FM_SUPERVISION_HOST_PRIMARY:-} -[ -n "$PRIMARY" ] || PRIMARY=$("$SCRIPT_DIR/fm-harness.sh" 2>/dev/null || printf unknown) +[ -n "$PRIMARY" ] || PRIMARY=$(fm_supervision_host_primary) # The owner's predecessor arm belongs to the first cycle only. OWNER_PREDECESSOR=${FM_WATCH_PREDECESSOR_ARM_PID:-} case "$OWNER_PREDECESSOR" in *[!0-9]*) OWNER_PREDECESSOR= ;; esac @@ -1032,7 +1034,7 @@ while :; do stand_down "this session no longer owns supervision" fi if ! fm_supervision_host_config "$CONFIG" "$PRIMARY"; then - exit_to_main "the home no longer opts into the supervision host" + exit_to_main "the home no longer runs the supervision host" fi if [ -z "$FM_SUPERVISION_ENGINE" ]; then exit_to_main "no supervision engine runs here: $FM_SUPERVISION_ENGINE_PROBLEM; this wake is yours" diff --git a/bin/fm-supervision-instructions.sh b/bin/fm-supervision-instructions.sh index 094d34117f8..b294704d9f3 100755 --- a/bin/fm-supervision-instructions.sh +++ b/bin/fm-supervision-instructions.sh @@ -2,11 +2,14 @@ # Render the primary-harness supervision operating block for session start and # the short repair line used by guards and turn-end hooks. On a non-Pi primary # with a supervision protocol (claude, cursor, opencode, omp, grok, codex) whose -# home opted into the supervision host (config/supervision-host), the block +# home runs the supervision host (fm_supervision_host_enabled in +# bin/fm-supervision-engine-lib.sh: by default on Claude, by +# config/supervision-host elsewhere, never with an `off` file), the block # adds one state line and the host's main-side protocol # (docs/supervision-protocols/supervision-host.md, whose lines tagged # "{,...} " render only for the listed harnesses), and Grok's arm -# command becomes the host; without that file the output is unchanged. +# command becomes the host; on a home that does not run it the output is +# unchanged. set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -107,7 +110,9 @@ HOST_SNIPPET= grok_arm='bin/fm-watch-arm.sh' case "$HARNESS" in claude|cursor|opencode|omp|grok|codex) - if [ -f "$CONFIG/supervision-host" ]; then + # shellcheck source=bin/fm-supervision-engine-lib.sh + . "$SCRIPT_DIR/fm-supervision-engine-lib.sh" + if fm_supervision_host_enabled "$CONFIG" "$HARNESS"; then HOST_SNIPPET="$DOC_DIR/supervision-host.md" grok_arm='bin/fm-supervision-host.sh park' fi diff --git a/bin/fm-turnend-guard-cursor.sh b/bin/fm-turnend-guard-cursor.sh index 136c0cb55e5..46ad4f9c563 100755 --- a/bin/fm-turnend-guard-cursor.sh +++ b/bin/fm-turnend-guard-cursor.sh @@ -28,14 +28,15 @@ # 2. the bounded repair instruction when supervision could not be established. # # SUPERVISION HOST. A home opted in with config/supervision-host -# (docs/configuration.md "Supervision host" owns the opt-in) parks on +# (docs/configuration.md "Supervision host" owns the gate; an `off` file opts +# out, and a Cursor home without the file does not run the host) parks on # bin/fm-supervision-host.sh in the arm's place, which takes eligible attended # wakes and all away wakes itself and exits only when main is needed; its # header owns the output this park reads. A "supervision-host:" line is # actionable like a wake line, and the follow-up carries every such line in # order while wake lines keep the eight-line cap; "supervision-host stood # down:" ends the park silently; a host that died without a close is retried -# instead of being judged by the healthy-watcher predicate. Without the file nothing below changes. +# instead of being judged by the healthy-watcher predicate. On a home that does not run the host nothing below changes. # # LOOP BOUNDING IS DOUBLE, because either bound alone is insufficient: # - `loop_limit` in .cursor/hooks.json is Cursor's own ceiling. Once @@ -97,6 +98,8 @@ case "$LOCK_ATTEMPTS" in ''|*[!0-9]*|0) LOCK_ATTEMPTS=50 ;; esac . "$SCRIPT_DIR/fm-wake-lib.sh" # shellcheck source=bin/fm-session-lock-lib.sh . "$SCRIPT_DIR/fm-session-lock-lib.sh" +# shellcheck source=bin/fm-supervision-engine-lib.sh +. "$SCRIPT_DIR/fm-supervision-engine-lib.sh" # shellcheck source=bin/fm-operational-input.sh . "$SCRIPT_DIR/fm-operational-input.sh" @@ -310,7 +313,7 @@ STAND_DOWN=0 HOST_MODE=0 HOST_RC=0 ACTIONABLE_RE='^(signal:|stale:|check:|heartbeat($|:))' -if [ -f "$CONFIG/supervision-host" ]; then +if fm_supervision_host_enabled "$CONFIG" cursor; then HOST_MODE=1 ACTIONABLE_RE='^(signal:|stale:|check:|heartbeat($|:)|supervision-host:)' fi diff --git a/bin/fm-watch-checkpoint.sh b/bin/fm-watch-checkpoint.sh index 205f2e0ff98..3fb67e0cf4f 100755 --- a/bin/fm-watch-checkpoint.sh +++ b/bin/fm-watch-checkpoint.sh @@ -3,7 +3,8 @@ # rely on background-task completion to wake the model. # # SUPERVISION HOST. A home opted in with config/supervision-host -# (docs/configuration.md "Supervision host" owns the opt-in) runs +# (docs/configuration.md "Supervision host" owns the gate; an `off` file opts +# out, and a Codex home without the file does not run the host) runs # bin/fm-supervision-host.sh in the watcher's place for the checkpoint's bound, # as the host's park boundary; the host takes away-posture wakes itself and # returns only when main is needed (its header owns the output read here). @@ -13,8 +14,8 @@ # so a parked main is not woken every few minutes; an engine turn that starts # before the bound may finish after it. A close that carries a wake or a # "supervision-host:" line other than the park boundary passes through as a -# wake; the boundary alone is the ordinary quiet checkpoint. Without the file -# nothing below changes. +# wake; the boundary alone is the ordinary quiet checkpoint. On a home that +# does not run the host nothing below changes. set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -112,7 +113,9 @@ positive_or() { # case "$1" in ''|0*|*[!0-9]*) printf '%s\n' "$2" ;; *) printf '%s\n' "$1" ;; esac } -if [ -f "$CONFIG/supervision-host" ]; then +# shellcheck source=bin/fm-supervision-engine-lib.sh +. "$SCRIPT_DIR/fm-supervision-engine-lib.sh" +if fm_supervision_host_enabled "$CONFIG" codex; then BOUND=$SECONDS_ARG if [ -f "$STATE/.afk-contract" ] \ && [ "$(FM_STATE_OVERRIDE="$STATE" "$SCRIPT_DIR/fm-afk-contract.sh" mode 2>/dev/null)" != quiet ]; then diff --git a/docs/architecture.md b/docs/architecture.md index de28808b9ce..eafab774866 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -148,7 +148,7 @@ On a Pi primary, supervision is default-on: the watcher extension can hand eligi The branch handles those rows, stores the outcome durably, and merges it back into main. A captain-facing outcome persists as one exact, sequence-keyed visible transcript entry and then opens one sequence-keyed processing turn on main, which only main's sequence-bound acknowledgement closes. [docs/pi-supervision-branch.md](pi-supervision-branch.md) owns row eligibility, dispatch architecture, deterministic outcome delivery, and processing re-presentation, while the generated [Pi supervision protocol](supervision-protocols/pi.md) owns MAIN's merged-event handling and acknowledgement duty. -For the opt-in supervision host that runs the same branch contract beside a non-Pi primary, away and on Claude and Cursor also attended, see [supervision-host.md](supervision-host.md). +For the supervision host that runs the same branch contract beside a non-Pi primary (by default on Claude), away and on Claude and Cursor also attended, see [supervision-host.md](supervision-host.md). ### Registered secondmate current state @@ -170,7 +170,7 @@ That block owns the live wait shape for the running primary harness: Claude's St The arm layer records one bounded lifecycle row per observed cycle in `state/.watch-cycle-exits.log`; `state/.watch-triage.log` remains exclusively the absorbed-wake debug log. Pi, omp, and OpenCode verify session-lock ownership and launch one singleton successor from their child-close handlers before delivering an actionable wake prompt, with bounded exponential retry for failed restoration. Pi additionally retains an established predecessor across ordinary same-process session shutdown until the replacement generation commits its tracked arm, and its active-versus-handoff generation marker prevents an absent replacement extension from satisfying the fresh-beacon handoff tolerance. -Claude's `bin/fm-claude-stop-autoarm.sh` hook fires on every Stop and, when the home is eligible and still needs supervision, claims one home-scoped cycle, foregrounds the arm wrapper (or the [opt-in supervision host](supervision-host.md)), and translates actionable closes into exit-2 rewakes. +Claude's `bin/fm-claude-stop-autoarm.sh` hook fires on every Stop and, when the home is eligible and still needs supervision, claims one home-scoped cycle, foregrounds the arm wrapper (or the [supervision host](supervision-host.md), which runs by default on Claude), and translates actionable closes into exit-2 rewakes. It suppresses failed-looking closes when the same identity-matched watcher is healthy, retries genuine failures within a bound, and coordinates exhausted failure episodes with the Claude turn-end guard as documented in [`turnend-guard.md`](turnend-guard.md). [`watcher-continuity.md`](watcher-continuity.md) owns Claude's residual active-turn coverage and watcher-status command-gating boundary. Cursor's `bin/fm-turnend-guard-cursor.sh` hook is the same between-turns shape in one synchronous step: it parks the awaited `stop` hook on the arm wrapper and translates an actionable close into one `followup_message`, with a generation baton that makes an older park still running after the next `stop` claim stand down instead of leaking a stale duplicate wake. @@ -194,7 +194,7 @@ The watcher and daemon recheck captain-held work in quiet mode as they do while The record's mode distinguishes away from quiet on every harness; `bin/fm-afk-launch.sh` owns entry and exit, and `bin/fm-afk-return.sh` archives the record and owns the return brief's ordered sections, including landed live task records that still owe cleanup, rendered from durable state; persistent secondmates are excluded from that cleanup section even if an older record carries a child's merged PR. While the away record exists neither supervisor rechecks an item held for the captain, and a declared external wait names when it clears with `until` for a condition-aware recheck in both postures that occurs at the declared time or the hours-long `FM_PAUSE_RESURFACE_SECS` bound, whichever comes first. On Pi and pi-signed the away daemon is no longer launched: the ordinary supervision session continues under the record with main parked, so the supervision branch takes every actionable wake, captain outcomes accumulate for the return brief, and main's standing authority relocates to the branch through the guarded scripts, each keeping its own gate ([`pi-supervision-branch.md`](pi-supervision-branch.md#postures)); a wake the branch cannot take and a watcher failure still reach main. -On an opted-in non-Pi home, the [supervision host](supervision-host.md) runs the away session instead of the daemon. +On a non-Pi home that runs the [supervision host](supervision-host.md), the host runs the away session instead of the daemon. A presence-gated sub-supervisor (`bin/fm-supervise-daemon.sh`) still extends walk-away supervision on the remaining harnesses: the `/afk` skill starts it through the tracked foreground helper `bin/fm-afk-start.sh` once the record exists, after which the watcher reverts to daemon-managed one-shot mode and the daemon self-handles routine wakes in bash. The watcher and daemon share `bin/fm-classify-lib.sh` for captain-relevant status verbs, declared-wait vocabulary (a `paused:` external wait and a verified `captain-held` transfer alike, through one combined predicate), and status-scan primitives. Terminal verbs remain captain-relevant, while a nonterminal progress verb cannot become terminal merely because its prose contains a legacy free-text token such as `merged`; bare legacy free-text lines remain compatible. @@ -508,4 +508,4 @@ Use `/stow` before an intentional reset when the conversation may hold durable k ## Development notes The current watcher reliability work combines always-on bash triage with a durable queue for actionable wakes, generation-bound post-handling acknowledgement, deterministic re-arm recovery after watcher downtime, a race-proof singleton lock, duplicate self-eviction, drain-time liveness assertion, and a self-verifying tracked-child arm wrapper. -The away posture is the record `bin/fm-afk-contract.sh` owns; see [supervision-host.md](supervision-host.md) for the opt-in non-Pi away session and the `/afk` skill for the remaining daemon-backed harnesses. +The away posture is the record `bin/fm-afk-contract.sh` owns; see [supervision-host.md](supervision-host.md) for the non-Pi away session and the `/afk` skill for the remaining daemon-backed harnesses. diff --git a/docs/configuration.md b/docs/configuration.md index c97571fc862..5a4bbb25972 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -300,22 +300,28 @@ Both choices are local to each Firstmate home and are not part of secondmate inh ## Supervision host (config/supervision-host) -The optional local, gitignored `config/supervision-host` enables a supervision host for this home. +The optional local, gitignored `config/supervision-host` controls the supervision host for this home. The host runs the supervision branch's contract on a headless engine session beside a non-Pi primary. [docs/supervision-host.md](supervision-host.md) defines its design, current scope, and verified engines. A Claude, Cursor, OpenCode, omp, Grok, or Codex primary can run the host. -With the file present, the primary's arm owner runs the host in place of the watcher arm. + +A Claude primary runs the host by default: with no file it runs exactly as with an empty file, at the Claude engine's default model. +A file whose first word is `off` opts the home out on every primary. +A Cursor, OpenCode, omp, Grok, or Codex primary runs the host only while the file exists and does not say `off`. +A home that does not run the host behaves exactly as it does without it, and a Pi primary keeps its in-process supervision branch whatever the file says. +`fm_supervision_host_enabled` in `bin/fm-supervision-engine-lib.sh` implements this gate for every reader. + +While the home runs the host, the primary's arm owner runs it in place of the watcher arm. The host handles wakes on the engine under the [posture rules](supervision-host.md#postures), including an away record and attended operation on a Claude or Cursor primary with a verified dialog mirror. On that home, `/afk` launches no away daemon; see [Quiet mode](supervision-host.md#quiet-mode) for `/quiet`'s attended statement and fallback. -The file also gates the primary's dialog-mirror hooks (`bin/fm-host-mirror.sh`), which record on a Claude or Cursor primary ([supervision-host.md](supervision-host.md#the-dialog-mirror)). - -Absence leaves the home exactly as it is without the host, on every harness; a Pi primary keeps its in-process supervision branch whether or not the file exists. -A Grok primary reads the file when its session-start block renders, so a change takes effect at its next session start; every other owner reads it at every arm. +The same gate governs the primary's dialog-mirror hooks (`bin/fm-host-mirror.sh`), which record on a Claude or Cursor primary ([supervision-host.md](supervision-host.md#the-dialog-mirror)). +Grok's arm command is rendered at session start, so a change to its host mode takes effect at its next session start; the other arm owners check the gate at every arm. ### Engine selection -The file may be empty, or hold one line ` []`: +The file may be empty, hold `off`, or hold one line ` []`: +- `off` opts the home out of the host; - empty or `default` selects the primary harness's own engine at that engine's default model (`sonnet` for the Claude engine); - ` []` names a verified engine, currently only `claude`, and optionally the engine's own model name or alias; `default ` selects the primary harness's engine with that model. @@ -326,10 +332,10 @@ Only Claude has a verified engine of its own, so a Cursor, OpenCode, omp, Grok, An unverified engine, a primary without a verified engine, or a malformed line leaves the host without an engine. It takes no wake, so every wake reaches main as it would without the host. Each away-posture wake includes a line naming the problem. -The file is read at every wake, so a change applies at the next one without a restart. +The running host reads the file at every wake, so an engine change or `off` takes effect at the next wake without a restart. -It is local to each home and not part of secondmate inherited configuration. -While the file exists, main's lease-checked commands also take the per-task lease lock, so a claim by the host's engine cannot race a mutation main already started (`bin/fm-lease-lib.sh`). +It is local to each home and not part of secondmate inherited configuration, because each home's supervision posture and engine model are its own choice: a primary's `off` never reaches a secondmate, and a secondmate that must stay off writes its own `off`. +While the home runs the host, main's lease-checked commands also take the per-task lease lock, so a claim by the host's engine cannot race a mutation main already started (`bin/fm-lease-lib.sh`). ## Backlog backend (.tasks.toml / config/backlog-backend) @@ -2296,7 +2302,7 @@ FM_PROCEVENT_LAUNCH_FLOOR_SECONDS=1 # minimum interval between launches of o FM_PROCEVENT_LAUNCH_CONFIRM_SECONDS=3 # how long reconcile waits for the runners it started to prove they are running; 1..600, keep well below FM_POLL FM_WHEN_OUTPUT_TAIL_BYTES=8192 # bound on the command-output tail inside one condition->action outcome document FM_CODEX_WATCH_CHECKPOINT=180 # seconds per foreground watcher checkpoint in Codex primary supervision -FM_CODEX_WATCH_CHECKPOINT_AWAY=3600 # requested away checkpoint bound on a home with config/supervision-host; longer of this and attended bound, capped at 27000 +FM_CODEX_WATCH_CHECKPOINT_AWAY=3600 # requested away checkpoint bound on a home that runs the supervision host; longer of this and attended bound, capped at 27000 FM_CREW_STATE_NM_TIMEOUT=10 # seconds allowed per no-mistakes query inside fm-crew-state.sh, and per state-database run-inventory read behind a capped AXI overview FM_TEARDOWN_NM_TIMEOUT=10 # seconds allowed per no-mistakes query or abort inside fm-teardown.sh FM_CREW_STATE_RUNS_LIMIT=200 # plain runs-ledger rows scanned for fallback attribution; does not change the CLI's AXI overview window (selection owner: bin/fm-nm-run-lib.sh) @@ -2394,7 +2400,7 @@ FM_CRASH_BACKOFF=60 # seconds to wait after crossing the crash th FM_CRASH_NORMAL_SLEEP=5 # seconds to wait after an isolated watcher crash FM_LOG_MAX_BYTES=1048576 # daemon log size that triggers trimming FM_LOG_KEEP_LINES=2000 # daemon log lines kept when trimming -# supervision host (bin/fm-supervision-host.sh); read only in a home with config/supervision-host +# supervision host (bin/fm-supervision-host.sh); read only in a home that runs it FM_SUPERVISION_HOST_PARK_SECONDS=27000 # the host ends its park with a cycle-boundary wake after this long, under the Stop hook's 28800 s timeout FM_SUPERVISION_HOST_TURN_TIMEOUT=1200 # bound on one engine turn; a turn that hits it hands its wake to main FM_SUPERVISION_HOST_ROTATE_TURNS=20 # the engine conversation starts fresh after this many turns (and at every main session start) diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index 22ad9d06436..c28afacd8be 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -781,7 +781,7 @@ The pane-independent max-defer alert is configured in [`wedge-alarm.md`](wedge-a - Harnesses with native tracked background execution can run the daemon in their terminal. - Pi and pi-signed no longer launch the away daemon; their ordinary supervision session continues under the posture record. -- An opted-in non-Pi home also skips the daemon for `/afk`; see [supervision-host.md](supervision-host.md). +- A non-Pi home that runs the supervision host also skips the daemon for `/afk`; see [supervision-host.md](supervision-host.md). - For another harness without native tracked background execution, `bin/fm-afk-launch.sh` runs the daemon in a Herdr workspace, as described next. In that last case, `bin/fm-afk-launch.sh`: diff --git a/docs/pi-supervision-branch.md b/docs/pi-supervision-branch.md index 6198c98dfcb..717be533d37 100644 --- a/docs/pi-supervision-branch.md +++ b/docs/pi-supervision-branch.md @@ -71,7 +71,7 @@ This in-process supervision branch is Pi-only by construction: A home on any harness that already has an outcome store still receives the shared drain compatibility recovery described in [Lost-wake outcome backstop](#lost-wake-outcome-backstop). - It does not change which harness is primary and never moves a home to Pi. -On an opted-in non-Pi home, the supervision host runs the branch beside the primary, away and on Claude and Cursor also attended. +On a non-Pi home that runs the supervision host, the host runs the branch beside the primary, away and on Claude and Cursor also attended. [supervision-host.md](supervision-host.md) owns its scope and mechanism. ## Components and their owners diff --git a/docs/supervision-host.md b/docs/supervision-host.md index 786ddcabf1f..d8f8973a460 100644 --- a/docs/supervision-host.md +++ b/docs/supervision-host.md @@ -20,8 +20,8 @@ An arm owner is the component in each primary harness that starts watcher cycles ## Scope today -The host is opt-in per home through `config/supervision-host`; [configuration.md](configuration.md#supervision-host-configsupervision-host) owns the file. -Without the file every home behaves exactly as it does without the host. +The host runs by default on a Claude primary and is opt-in per home on the other five primaries it supports; a `config/supervision-host` that says `off` opts any home out, and [configuration.md](configuration.md#supervision-host-configsupervision-host) owns the file. +A home that does not run the host behaves exactly as it does without it. Today it runs beside a Claude, Cursor, OpenCode, omp, Grok, or Codex primary: away on all six, and attended on Claude and Cursor, the primaries with a verified [dialog mirror](#the-dialog-mirror). ### Behavior by posture and harness @@ -31,15 +31,15 @@ Today it runs beside a Claude, Cursor, OpenCode, omp, Grok, or Codex primary: aw - Attended on OpenCode, omp, Grok, and Codex, the host is a pass-through: every close reaches main as without the host. - Away (an away record exists), the host hands each close to the engine. Main stays parked unless the host hands the wake back. -- `/afk` launches no away daemon on an opted-in home of those harnesses, because the host is the away session there. +- `/afk` launches no away daemon on a home of those harnesses that runs the host, because the host is the away session there. - `/quiet` enters nothing where the attended host runs, and elsewhere launches the daemon; see [Quiet mode](#quiet-mode). While the daemon's flag `state/.afk` exists, the host stands aside exactly as the plain arm does. -- Pi keeps its in-process branch whether or not the file exists, and no Pi engine is built. +- Pi keeps its in-process branch whatever the file says, and no Pi engine is built. - Kimi has no primary supervision protocol, so it has no arm owner to run the host. ### Not yet on the host -Attended supervision beside a Codex primary and the daemon's retirement are later steps of the same design. +Attended supervision beside a Codex primary, running the host by default on the other five primaries, and the daemon's retirement are later steps of the same design. Until they land, their current behavior stays as described in their own owners. ## Components and their owners @@ -47,8 +47,8 @@ Until they land, their current behavior stays as described in their own owners. | Component | Owner | Role | |---|---|---| | The loop | `bin/fm-supervision-host.sh` | Its header owns the per-close order, the park boundary, ownership checks, predecessor cleanup, state files, and tunables. | -| The arm owners | Each primary's existing arm owner | Runs the host for an opted-in home and delivers a handed-back wake to main; see [Arm owners](#arm-owners). | -| The engine | `bin/fm-supervision-engine-lib.sh` | Owns the opt-in parse, the verified-engine list, and one bounded engine turn, including the reap of engine tool processes that outlive it. | +| The arm owners | Each primary's existing arm owner | Runs the host for a home that runs it and delivers a handed-back wake to main; see [Arm owners](#arm-owners). | +| The engine | `bin/fm-supervision-engine-lib.sh` | Owns the home gate, including the default on Claude and the `off` opt-out, the verified-engine list, and one bounded engine turn, including the reap of engine tool processes that outlive it. | | Row eligibility and the offer rule | `bin/fm-branch-dispatch.mjs` | The command entry to `.pi/extensions/lib/fm-branch-dispatch.ts`, so the host and the Pi extension compute branch-claimable rows, their task scope, and whether the branch may take a close (`branchOfferForWake`) from one owner; it also renders the wake message with the same away-posture tail, or the dialog mirror at its head. | | The grant and the drain | `bin/fm-wake-grant.sh` | Publishes the branch's rows bound to the host's own process; [watcher-continuity.md](watcher-continuity.md#per-actor-acknowledgement) owns the per-actor drain and acknowledgement the engine runs. | | The prompt | `bin/fm-branch-prompt.sh` | Emits the same byte-stable prompt the Pi branch runs; each wake names its host's report surface. | @@ -56,11 +56,11 @@ Until they land, their current behavior stays as described in their own owners. | Leases and authority | `bin/fm-lease-lib.sh` | Owns the per-task leases, the main-owned role partition, and the away relocation; see [Leases and authority](#leases-and-authority). | | The dialog mirror | `bin/fm-host-mirror.sh` | Owns the mirror files, writers, verified-writer list, and feed; see [The dialog mirror](#the-dialog-mirror). | | The captain-outcome drain | `bin/fm-wake-drain.sh` | Presents visible new and unprocessed outcomes in its `BRANCH OUTCOMES` section; `bin/fm-branch-outcome.sh mark-processed` is main's acknowledgement; see [Captain outcomes](#captain-outcomes). | -| The main side | [supervision-protocols/supervision-host.md](supervision-protocols/supervision-host.md) | What main reads at session start on an opted-in home, rendered for its harness. | +| The main side | [supervision-protocols/supervision-host.md](supervision-protocols/supervision-host.md) | What main reads at session start on a home that runs the host, rendered for its harness. | ### Arm owners -For an opted-in home, each primary's existing arm owner runs the host in place of its watcher command. +On a home that runs the host, each primary's existing arm owner runs it in place of its watcher command. The arm owner delivers a handed-back wake through the wake path that harness already trusts. The host's header owns the output contract they read. @@ -135,7 +135,7 @@ A captain who leaves while an attended turn runs turns its captain outcomes into `/quiet` asks for what the attended host already does: routine wakes stay off a present captain's main. So where the attended host runs, `/quiet` is a statement that enters nothing, because the host already gives what a quiet entry would; while [the broken-session latch](#the-broken-session-latch) holds, it says the session is paused instead. -Where the home opted in but the attended host lacks one of its parts, `/quiet` names the missing part and enters the quiet daemon, and while an away record is live the captain's return comes first. +Where the home runs the host but the attended host lacks one of its parts, `/quiet` names the missing part and enters the quiet daemon, and while an away record is live the captain's return comes first. `bin/fm-afk-launch.sh` owns the readiness test and refusals in its `quiet-check` contract, and the [quiet skill](../.agents/skills/quiet/SKILL.md) owns the procedure. ## The dialog mirror @@ -207,7 +207,7 @@ The drain's header owns the section's bounds; these rules keep it bounded and in - The byte cap shows only the oldest contiguous run of captain outcomes, so the printed acknowledgement covers exactly the rows shown, and it counts the newer ones it holds back, which follow once the run is acknowledged. - Routine outcomes never open a main turn: the next drain lists the newest visible one once, for awareness and with nothing to acknowledge, and collapses older visible routine notes into a count; silent routine outcomes never appear. -The section runs only for main on an opted-in home whose primary is not Pi, and never while the away record exists. +The section runs only for main on a home that runs the host and whose primary is not Pi, and never while the away record exists. The drain is the only presenter of these outcomes and the only owner of their read cursor, the away window's included: the return brief counts the window's outcomes and points at the section instead of listing them. On a Claude Code primary the Calm mod separately shows bounded, display-only supervision notes to the captain ([`calm.md`](calm.md#supervision-notes-on-claude-code)); it moves no outcome marker and adds nothing to main's context. A long away window no longer requires a drain per outcome: each task's captain outcomes collapse to one line, subject to the captain byte cap, and visible routine notes past the section's limit collapse into a count; after main acknowledges all captain outcomes no later drain shows anything from the window again. @@ -392,7 +392,7 @@ Such a process is never recorded and survives the turn, the same residual `bin/f The default model is `sonnet`, which handled every measured wake correctly at a fraction of a larger model's cost. `config/supervision-host` can name another. -The Claude engine runs beside any of the six primaries, but only a Claude primary selects it by default. +The Claude engine runs beside any of the six primaries, but only a Claude primary selects it by default when the host is enabled, even with no file. A Cursor, OpenCode, omp, Grok, or Codex home names it (`claude`, optionally with a model) in `config/supervision-host`. `/afk` there says so when the file selects no engine. @@ -409,8 +409,8 @@ Each arm owner's own suite covers its host mode against a stub host. | `tests/fm-omp-harness.test.sh` | The omp arm owner's host mode against a stub host. | | `tests/fm-watch-checkpoint.test.sh` | The Codex checkpoint's host mode against a stub host. | | `tests/fm-supervision-instructions.test.sh` | The rendered protocol, including Grok's arm command. | -| `tests/fm-host-mirror.test.sh` | The dialog mirror's writers through the tracked Claude and Cursor registrations, the opt-in gate, the feed, and the verified-writer list. | -| `tests/fm-afk-launch.test.sh` | `/quiet` on an opted-in home: the statement, the paused statement, each named missing part, the quiet daemon fallback that carries its recorded mode, a failed quiet start that archives its quiet record, and the refusal under a live away record until the return. | +| `tests/fm-host-mirror.test.sh` | The dialog mirror's writers through the tracked Claude and Cursor registrations, the home gate, the feed, and the verified-writer list. | +| `tests/fm-afk-launch.test.sh` | The home gate on each primary, the `/afk` daemon refusal, and `/quiet` on a home that runs the host: the statement, the paused statement, each named missing part, the quiet daemon fallback that carries its recorded mode, a failed quiet start that archives its quiet record, and the refusal under a live away record until the return. | | `tests/fm-afk-return.test.sh` | The return's drain-owned read-cursor advance through the away window on a host home, and none on Pi. | | `tests/fm-supervision-host-live-e2e.test.sh` | Runs a real engine turn; opt-in because it spends tokens. | | `tests/fm-supervision-host-attended-live-e2e.test.sh` | Opt-in credentialed guard for repeated attended main-only hand-backs to an idle Claude primary, the successor's own close, a close that turns main-only at its turn, and a stand-in remote listener; accepts a pre-fix ref for a negative control. | diff --git a/docs/supervision-protocols/claude.md b/docs/supervision-protocols/claude.md index 9b651c80e96..8b0e486d69e 100644 --- a/docs/supervision-protocols/claude.md +++ b/docs/supervision-protocols/claude.md @@ -22,6 +22,6 @@ When this session owns supervision and away mode is not active: Otherwise, it allows the stop when a watcher is healthy or an open auto-arm generation claim owns recovery, while fresh failure epochs advance the bounded one-time attended fail-open progression described there. 9. Waiting on the hook-owned cycle is silent: do not send idle progress while the watcher is parked. -The watcher itself remains `bin/fm-watch.sh`, and `bin/fm-watch-arm.sh` remains the verified arm wrapper that the Stop hook foregrounds unless this home opts into the [supervision host](../supervision-host.md). +The watcher itself remains `bin/fm-watch.sh`, and `bin/fm-watch-arm.sh` remains the verified arm wrapper that the Stop hook foregrounds on a home that opted out of the [supervision host](../supervision-host.md) (`config/supervision-host` holding `off`). Re-arm attaches to an existing healthy cycle when one is already present and follows its verified successor chain. See [`watcher-continuity.md`](../watcher-continuity.md) for the arm-layer successor and clean-close failure contract and the Claude ownership model. diff --git a/docs/supervision-protocols/omp.md b/docs/supervision-protocols/omp.md index 8548475c044..2c9d59db1f2 100644 --- a/docs/supervision-protocols/omp.md +++ b/docs/supervision-protocols/omp.md @@ -23,7 +23,7 @@ When this session owns supervision and away mode is not active: The turn-end guard on omp is structural, not advisory: `__FM_OMP_TURNEND_EXT__` answers omp's blocking `session_stop` hook, and when `bin/fm-turnend-guard.sh` returns 2 it forces one continuation carrying the guard text, bounded to one per turn by the `stop_hook_active` flag omp sets on the continuation's own stop. An interrupted turn never raises `session_stop`, so a supervisor-initiated interrupt is not guarded; `bin/fm-control.sh` owns that postcondition. -The Pi supervision branch (`docs/pi-supervision-branch.md`) is Pi's in-process conversation and does not run on omp: without the supervision host every actionable wake is delivered to this conversation and the lease, outcome-store, and `fm_branch_processed` contracts do not apply here, while a home with `config/supervision-host` runs the host's away session ([`supervision-host.md`](../supervision-host.md)). +The Pi supervision branch (`docs/pi-supervision-branch.md`) is Pi's in-process conversation and does not run on omp: without the supervision host every actionable wake is delivered to this conversation and the lease, outcome-store, and `fm_branch_processed` contracts do not apply here, while a home with `config/supervision-host` (not `off`) runs the host's away session ([`supervision-host.md`](../supervision-host.md)). The turn-end guard extension lives at `__FM_OMP_TURNEND_EXT__`. The watcher extension lives at `__FM_OMP_EXT__`. diff --git a/docs/supervision-protocols/supervision-host.md b/docs/supervision-protocols/supervision-host.md index b9a9d38139f..678ef95c151 100644 --- a/docs/supervision-protocols/supervision-host.md +++ b/docs/supervision-protocols/supervision-host.md @@ -1,4 +1,4 @@ -Supervision host: on for this home (`config/supervision-host`; [`supervision-host.md`](../supervision-host.md) owns the design). +Supervision host: on for this home (`config/supervision-host` holding `off` turns it off; [`supervision-host.md`](../supervision-host.md) owns the design). {claude} The Stop hook runs the supervision host in the arm's place, and everything above still holds with these additions: {cursor} The `stop` hook park runs the supervision host in the arm's place, and everything above still holds with these additions: {opencode} The OpenCode TUI plugin runs the supervision host in the arm's place, and everything above still holds with these additions: diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index 9fe29503ae8..0e4f6eacaea 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -594,7 +594,7 @@ tests/fm-turnend-guard.test.sh ## Supervision host -This supports [supervision-host.md](../supervision-host.md): the Claude engine, the away-wake path, its failure direction, and the unchanged behavior of homes without `config/supervision-host`. +This pre-flip evidence supports [supervision-host.md](../supervision-host.md)'s Claude engine, away-wake path, and failure direction; its no-file baseline describes the earlier opt-in release, not the current Claude default. It was measured on 2026-09-23 on macOS 26.6.2 arm64 with Claude Code 2.1.281 as both primary and engine (model `sonnet`), Pi 0.87.0 workers on `openai-codex/gpt-5.6-sol`, and Herdr 0.9.0, in disposable lab homes on private tmux sockets and named Herdr lab sessions. The opt-in live guard refreshes the engine evidence: @@ -622,7 +622,7 @@ Claude's `--output-format json` reports `total_cost_usd` as the resumed conversa Five consecutive turns of one conversation, a host restart between the second and third, reported totals of 0.2093, 0.3441, 0.4234, 0.4870, and 0.5408 with per-turn `cache_read_input_tokens` of 423687, 359255, 245302, 174613, and 185598. Each handled away wake cost between $0.05 and $0.21 on `sonnet`. -Without `config/supervision-host`, the same live sessions and guards ran on the tree before the host (`ac2ed3b2`) and with it, with identical results: +Before the Claude default-on flip, without `config/supervision-host`, the same live sessions and guards ran on the tree before the host (`ac2ed3b2`) and with it, with identical results: | Check | Before | After | | --- | --- | --- | diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index 5d452bf49da..c2641b368ba 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -30,7 +30,7 @@ Codex and Grok keep their own protocols; see [Manual recovery and other harnesse | Cursor | `.cursor/hooks.json` `stop` hook (`bin/fm-turnend-guard-cursor.sh`) | | Claude | `.claude/settings.json` Stop `asyncRewake` hook (`bin/fm-claude-stop-autoarm.sh`) | -On a non-Pi primary, a home opted into the supervision host also changes what the owner runs; see [Supervision host](#supervision-host). +On a non-Pi primary, a home that runs the supervision host also changes what the owner runs; see [Supervision host](#supervision-host). ### Pi, omp, and OpenCode adapters @@ -119,7 +119,7 @@ The Claude turn-end guard owns that notice commit contract, the monotonic failur ### Supervision host -On a non-Pi primary, a home opted into the supervision host runs `bin/fm-supervision-host.sh` in place of the arm its re-arm owner would start. +On a non-Pi primary, a home that runs the supervision host runs `bin/fm-supervision-host.sh` in place of the arm its re-arm owner would start. The host owns successive watcher cycles through the same arm. The host's successor and pass-through lifecycle is owned by [supervision-host.md](supervision-host.md#postures); the arm's recovery and acknowledgement contracts below still apply. diff --git a/tests/fm-afk-launch.test.sh b/tests/fm-afk-launch.test.sh index ac477a8864e..e232be4ce52 100755 --- a/tests/fm-afk-launch.test.sh +++ b/tests/fm-afk-launch.test.sh @@ -31,6 +31,13 @@ CONTRACT="$ROOT/bin/fm-afk-contract.sh" # the CLAUDECODE=1 marker below and refuse the daemon paths under test. unset PI_CODING_AGENT FM_PI_HARNESS CURSOR_AGENT CURSOR_INVOKED_AS GEMINI_CLI ATLASSIAN_AGENT_TYPE ROVODEV_CLI export CLAUDECODE=1 FM_TEST_HARNESS=claude FM_TEST_SEAM=1 +# A Claude home runs the supervision host unless config/supervision-host says +# off (docs/configuration.md "Supervision host"), and the host is that home's +# away session, so the daemon units run on a Claude home that opted out; the +# supervision-host units point FM_CONFIG_OVERRIDE at their own home's config. +OFF_CONFIG=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-off-config.XXXXXX") +printf 'off\n' > "$OFF_CONFIG/supervision-host" +export FM_CONFIG_OVERRIDE="$OFF_CONFIG" FAILED=0 fail() { printf 'not ok - %s\n' "$1" >&2; FAILED=1; } @@ -42,6 +49,7 @@ chmod +x "$SLEEPER" TRACK_TMUX_SESSIONS="" GLOBAL_CLEANUP() { rm -f "$SLEEPER" 2>/dev/null || true + rm -rf "$OFF_CONFIG" 2>/dev/null || true local s for s in $TRACK_TMUX_SESSIONS; do tmux kill-session -t "$s" 2>/dev/null || true @@ -925,35 +933,49 @@ unit_native_lifecycle() { rm -rf "$st" } -# A Claude home opted into the supervision host has the host as its away -# session, so away mode launches no daemon there; quiet mode still does, and a -# plain refresh of a running quiet daemon is still allowed. +# A Claude home runs the supervision host by default and it is the home's away +# session, so away mode launches no daemon there with no file or any file but +# off; quiet mode still does, a plain refresh of a running quiet daemon is +# still allowed, and an off file keeps the away daemon. unit_supervision_host_claude_home_runs_no_away_daemon() { - local st out rc + local st out rc line st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-host.XXXXXX") mkdir -p "$st/state" "$st/config" - : > "$st/config/supervision-host" - enter_posture "$st" || fail "supervision host: could not enter fixture posture" - out=$(FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$LAUNCH" start-native 2>&1) - rc=$? - if [ "$rc" -ne 0 ] && printf '%s' "$out" | grep -F 'runs the supervision host (config/supervision-host)' >/dev/null \ - && [ ! -e "$st/state/.afk" ] && [ ! -e "$st/state/.afk-daemon-terminal" ] && [ -f "$st/state/.afk-contract" ]; then - pass "supervision host: away start-native on a claude home refuses the daemon and keeps the record" - else - fail "supervision host: away start-native did not refuse cleanly (rc=$rc): $out" - fi + for line in - ''; do + rm -f "$st/config/supervision-host" "$st/state/.afk-contract" + [ "$line" = - ] || printf '%s\n' "$line" > "$st/config/supervision-host" + FM_CONFIG_OVERRIDE="$st/config" enter_posture "$st" || fail "supervision host: could not enter fixture posture" + out=$(FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_CONFIG_OVERRIDE="$st/config" "$LAUNCH" start-native 2>&1) + rc=$? + if [ "$rc" -ne 0 ] && printf '%s' "$out" | grep -F 'runs the supervision host (docs/supervision-host.md)' >/dev/null \ + && [ ! -e "$st/state/.afk" ] && [ ! -e "$st/state/.afk-daemon-terminal" ] && [ -f "$st/state/.afk-contract" ]; then + pass "supervision host: away start-native on a claude home (config file: ${line:-empty}) refuses the daemon and keeps the record" + else + fail "supervision host: away start-native did not refuse cleanly with config file ${line:-empty} (rc=$rc): $out" + fi + done rm -f "$st/state/.afk-contract" FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_AFK_MODE=quiet "$CONTRACT" enter >/dev/null 2>&1 \ || fail "supervision host: could not enter quiet fixture posture" - if FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_AFK_MODE=quiet "$LAUNCH" start-native >/dev/null 2>&1 \ + if FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_CONFIG_OVERRIDE="$st/config" FM_AFK_MODE=quiet "$LAUNCH" start-native >/dev/null 2>&1 \ && [ "$(head -n 1 "$st/state/.afk")" = quiet ] \ - && FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$LAUNCH" start-native >/dev/null 2>&1 \ + && FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_CONFIG_OVERRIDE="$st/config" "$LAUNCH" start-native >/dev/null 2>&1 \ && [ "$(head -n 1 "$st/state/.afk")" = quiet ]; then pass "supervision host: quiet start-native and a plain refresh of the quiet daemon still prepare the daemon" else fail "supervision host: quiet mode was refused or lost its mode on a claude host home" fi - FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$LAUNCH" stop >/dev/null 2>&1 || true + FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_CONFIG_OVERRIDE="$st/config" "$LAUNCH" stop >/dev/null 2>&1 || true + printf 'off\n' > "$st/config/supervision-host" + FM_CONFIG_OVERRIDE="$st/config" enter_posture "$st" || fail "supervision host: could not enter the off fixture posture" + out=$(FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_CONFIG_OVERRIDE="$st/config" "$LAUNCH" start-native 2>&1) + rc=$? + if [ "$rc" -eq 0 ] && [ "$(head -n 1 "$st/state/.afk" 2>/dev/null)" = away ]; then + pass "supervision host: an off config/supervision-host keeps the away daemon on a claude home" + else + fail "supervision host: an off config/supervision-host did not keep the away daemon (rc=$rc): $out" + fi + FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_CONFIG_OVERRIDE="$st/config" "$LAUNCH" stop >/dev/null 2>&1 || true rm -rf "$st" } @@ -966,12 +988,17 @@ unit_supervision_host_other_harnesses_run_no_away_daemon() { st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-host-harness.XXXXXX") mkdir -p "$st/state" "$st/config" daemon_allowed() { # [mode] - FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_TEST_HARNESS="$1" FM_AFK_MODE="${2:-}" \ + FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_CONFIG_OVERRIDE="$st/config" FM_TEST_HARNESS="$1" FM_AFK_MODE="${2:-}" \ bash -c '. "$1"; fm_afk_launch_primary_harness() { printf "%s" "$FM_TEST_HARNESS"; }; fm_afk_launch_daemon_allowed' _ "$LAUNCH" 2>&1 } for harness in cursor opencode omp grok codex; do daemon_allowed "$harness" >/dev/null || fail "$harness: a home without config/supervision-host must keep the away daemon" done + daemon_allowed claude >/dev/null && fail "claude: a home without config/supervision-host runs the host, so it must refuse the away daemon" + printf 'off\n' > "$st/config/supervision-host" + for harness in claude cursor opencode omp grok codex; do + daemon_allowed "$harness" >/dev/null || fail "$harness: a home whose config/supervision-host says off must keep the away daemon" + done : > "$st/config/supervision-host" for harness in cursor opencode omp grok codex; do out=$(daemon_allowed "$harness"); rc=$? @@ -986,7 +1013,7 @@ unit_supervision_host_other_harnesses_run_no_away_daemon() { enter_with() { # rm -f "$st/state/.afk-contract" "$st/config/supervision-host" [ "$2" = - ] || printf '%s\n' "$2" > "$st/config/supervision-host" - FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_TEST_HARNESS="$1" \ + FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_CONFIG_OVERRIDE="$st/config" FM_TEST_HARNESS="$1" \ bash -c '. "$1"; fm_afk_launch_primary_harness() { printf "%s" "$FM_TEST_HARNESS"; }; fm_afk_launch_main enter --words "watch the fleet"' _ "$LAUNCH" 2>&1 } out=$(enter_with cursor ''); rc=$? @@ -999,6 +1026,10 @@ unit_supervision_host_other_harnesses_run_no_away_daemon() { printf '%s' "$out" | grep -F 'Supervision host' >/dev/null && fail "enter must stay quiet on a home without the file: $out" out=$(enter_with claude '') printf '%s' "$out" | grep -F 'Supervision host: no engine' >/dev/null && fail "a claude home's own engine must count as an engine: $out" + out=$(enter_with claude -) + printf '%s' "$out" | grep -F 'Supervision host: no engine' >/dev/null && fail "a claude home without the file runs its own engine: $out" + out=$(enter_with cursor off) + printf '%s' "$out" | grep -F 'Supervision host' >/dev/null && fail "enter must stay quiet on a home that opted out: $out" pass "supervision host: enter names a missing engine on an opted-in home and says nothing otherwise" rm -rf "$st" } @@ -1025,7 +1056,7 @@ quiet_in() { # local home=$1 shift FM_SUPERVISION_ENGINE_CLAUDE_BIN="${QUIET_ENGINE-$home/claude-engine}" FM_HOME="$home" \ - FM_STATE_OVERRIDE="$home/state" "$@" 2>&1 + FM_STATE_OVERRIDE="$home/state" FM_CONFIG_OVERRIDE="$home/config" "$@" 2>&1 } # Daemon-backed quiet mode (no supervision host) writes the record through the @@ -1068,9 +1099,14 @@ unit_supervision_host_quiet_statement() { local st out rc key harness st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-quiet.XXXXXX") quiet_home "$st" + printf 'off\n' > "$st/config/supervision-host" + out=$(quiet_in "$st" "$LAUNCH" quiet-check); rc=$? + [ "$rc" -eq 1 ] && [ -z "$out" ] || fail "quiet-check on a claude home whose config/supervision-host says off must exit 1 silently (rc=$rc): $out" rm -f "$st/config/supervision-host" + out=$(FM_TEST_HARNESS=cursor quiet_in "$st" "$LAUNCH" quiet-check); rc=$? + [ "$rc" -eq 1 ] && [ -z "$out" ] || fail "quiet-check on a cursor home without config/supervision-host must exit 1 silently (rc=$rc): $out" out=$(quiet_in "$st" "$LAUNCH" quiet-check); rc=$? - [ "$rc" -eq 1 ] && [ -z "$out" ] || fail "quiet-check on a home without config/supervision-host must exit 1 silently (rc=$rc): $out" + quiet_expect 0 'Quiet mode needs nothing on this home' "quiet-check on a claude home without config/supervision-host must say quiet mode needs nothing" printf 'claude\n' > "$st/config/supervision-host" out=$(FM_TEST_HARNESS=pi quiet_in "$st" "$LAUNCH" quiet-check); rc=$? [ "$rc" -eq 1 ] && [ -z "$out" ] || fail "quiet-check on a pi home must exit 1 silently (rc=$rc): $out" @@ -1242,7 +1278,7 @@ unit_supervision_host_quiet_failed_start() { quiet_in "$st" "$LAUNCH" stop >/dev/null || true pass "supervision host: a failed quiet start archives its quiet record so the present captain is not parked" - rm -f "$st/config/supervision-host" + printf 'off\n' > "$st/config/supervision-host" quiet_in "$st" "$LAUNCH" enter --words "back after lunch" >/dev/null || fail "an away entry must record the away words" cp "$st/state/.afk-contract" "$st/away-record" out=$(quiet_in "$st" env FM_SUPERVISOR_TARGET=unused FM_SUPERVISOR_BACKEND=unsupported "$LAUNCH" start); rc=$? diff --git a/tests/fm-branch-supervision.test.sh b/tests/fm-branch-supervision.test.sh index 1975c591d87..29472b6be75 100644 --- a/tests/fm-branch-supervision.test.sh +++ b/tests/fm-branch-supervision.test.sh @@ -806,11 +806,13 @@ test_home_without_branch_is_untouched() { [ -z "$(find "$home/state" -name '.lease-*' -o -name 'branch-outcomes*' -o -name '.branch-*' 2>/dev/null)" ] \ || fail "guard layer created branch state in a home that never ran the branch" - # An unmarked caller with no lease file for the task takes no lock at all, so - # the guard leaves a home that never ran a branch byte-for-byte unchanged. + # An unmarked caller with no lease file for the task takes no lock at all on + # a home that does not run the supervision host (a Codex primary without + # config/supervision-host), so the guard leaves a home that never ran a + # branch byte-for-byte unchanged. # The positional parameter belongs to the nested shell. # shellcheck disable=SC2016 - out=$(env -u PI_CODING_AGENT -u FM_SUPERVISION_ACTOR STATE="$home/state" bash -c ' + out=$(env -u PI_CODING_AGENT -u FM_SUPERVISION_ACTOR FM_TEST_HARNESS=codex STATE="$home/state" bash -c ' . "$1" fm_lease_guard task-none "probe" if [ -e "$STATE/.fm-lease-command.lock" ]; then echo lock-taken; else echo no-lock; fi @@ -956,25 +958,41 @@ test_unmarked_guard_with_a_lease_file_holds_exclusivity_through_mutation() { pass "a lease file makes an unmarked guard exclude a concurrent claim for the complete mutation" } -# A home opted into the supervision host has a branch actor that can claim a +# A home that runs the supervision host has a branch actor that can claim a # task no one has leased yet, so its unmarked main must exclude that first -# claim for the whole guarded mutation, while a home without the opt-in keeps -# taking no lock at all. +# claim for the whole guarded mutation, while a home that does not run it +# keeps taking no lock at all. A Claude home runs it by default and an off +# file opts out; another primary needs the file (bin/fm-supervision-engine-lib.sh +# owns the gate, and FM_TEST_HARNESS pins the primary it judges). test_host_home_unmarked_guard_excludes_the_first_claim() { - local home operation_pid claim_pid claim_status out + local home operation_pid claim_pid claim_status out harness line home="$TMP_ROOT/host-first-claim-home" mkdir -p "$home/state" "$home/config" printf '%s\n' "$$" > "$home/state/.lock" - # Without the opt-in the unmarked guard stays lock-free for an unleased task. - # The positional parameter belongs to the nested shell. - # shellcheck disable=SC2016 - out=$(env -u PI_CODING_AGENT -u FM_SUPERVISION_ACTOR CLAUDECODE=1 FM_HOME="$home" STATE="$home/state" bash -c ' - . "$1" - fm_lease_guard task-first "probe" - if [ -e "$STATE/.fm-lease-command.lock" ]; then echo lock-taken; else echo no-lock; fi - ' _ "$ROOT/bin/fm-lease-lib.sh" 2>&1) - [ "$out" = no-lock ] || fail "a home without config/supervision-host engaged the lease-command lock: $out" + # Where the home does not run the host the unmarked guard stays lock-free + # for an unleased task. The positional parameter belongs to the nested shell. + probe_lock() { # + # shellcheck disable=SC2016 + env -u PI_CODING_AGENT -u FM_SUPERVISION_ACTOR CLAUDECODE=1 FM_TEST_HARNESS="$1" FM_HOME="$home" STATE="$home/state" bash -c ' + . "$1" + fm_lease_guard task-first "probe" + if [ -e "$STATE/.fm-lease-command.lock" ]; then echo lock-taken; else echo no-lock; fi + fm_lease_guard_release + ' _ "$ROOT/bin/fm-lease-lib.sh" 2>&1 + } + for line in - off; do + rm -f "$home/config/supervision-host" + [ "$line" = - ] || printf '%s\n' "$line" > "$home/config/supervision-host" + for harness in claude codex; do + [ "$line:$harness" != -:claude ] || continue + out=$(probe_lock "$harness") + [ "$out" = no-lock ] || fail "a $harness home whose config/supervision-host is ${line/-/absent} engaged the lease-command lock: $out" + done + done + rm -f "$home/config/supervision-host" + out=$(probe_lock claude) + [ "$out" = lock-taken ] || fail "a Claude home without config/supervision-host runs the host, so its unmarked guard must take the lease-command lock: $out" : > "$home/config/supervision-host" # The positional parameter belongs to the nested shell. @@ -1010,7 +1028,7 @@ test_host_home_unmarked_guard_excludes_the_first_claim() { "branch $$ "*" live") ;; *) fail "the first claim recorded: $out" ;; esac - pass "an opted-in home's unmarked main excludes the host's first claim for its whole mutation, and other homes take no lock" + pass "a host home's unmarked main excludes the host's first claim for its whole mutation, and other homes take no lock" } # --- session-bound staleness and the loud accidental-override guard --------- diff --git a/tests/fm-claude-stop-autoarm.test.sh b/tests/fm-claude-stop-autoarm.test.sh index d88b3354316..3f3615b6c78 100755 --- a/tests/fm-claude-stop-autoarm.test.sh +++ b/tests/fm-claude-stop-autoarm.test.sh @@ -38,15 +38,20 @@ install_autoarm_scripts() { cp "$ROOT/bin/fm-afk-contract.sh" "$dir/bin/fm-afk-contract.sh" cp "$ROOT/bin/fm-classify-lib.sh" "$dir/bin/fm-classify-lib.sh" cp "$ROOT/bin/fm-timeout-lib.sh" "$dir/bin/fm-timeout-lib.sh" + cp "$ROOT/bin/fm-supervision-engine-lib.sh" "$dir/bin/fm-supervision-engine-lib.sh" chmod +x "$dir/bin/fm-claude-stop-autoarm.sh" "$dir/bin/fm-lock.sh" "$dir/bin/fm-afk-contract.sh" } +# A Claude home runs the supervision host unless config/supervision-host says +# off, so the fixture home opts out: most cases exercise the plain arm, and +# the supervision-host cases below replace or remove the file. make_primary_dir() { local dir=$1 - mkdir -p "$dir/state" + mkdir -p "$dir/state" "$dir/config" git init -q "$dir" git -C "$dir" commit -q --allow-empty -m init : > "$dir/AGENTS.md" + printf 'off\n' > "$dir/config/supervision-host" install_autoarm_scripts "$dir" printf '%s\n' "$dir" } @@ -1424,18 +1429,37 @@ SH chmod +x "$dir/bin/fm-supervision-host.sh" } -test_host_absent_flag_keeps_the_arm() { +test_host_off_flag_keeps_the_arm() { local dir out status - dir=$(make_primary_dir "$TMP_ROOT/host-flag-absent") + dir=$(make_primary_dir "$TMP_ROOT/host-flag-off") + printf 'off\n' > "$dir/config/supervision-host" : > "$dir/state/task.meta" write_arm_fixture "$dir" actionable write_host_fixture "$dir" boundary out=$(run_autoarm "$dir" 2>/dev/null); status=$? - expect_code 2 "$status" "a home without config/supervision-host must still rewake from the arm" - assert_present "$dir/state/arm-ran" "a home without config/supervision-host did not run the arm" - [ ! -e "$dir/state/host-ran" ] || fail "a home without config/supervision-host ran the supervision host" + expect_code 2 "$status" "a home whose config/supervision-host says off must still rewake from the arm" + assert_present "$dir/state/arm-ran" "a home whose config/supervision-host says off did not run the arm" + [ ! -e "$dir/state/host-ran" ] || fail "a home whose config/supervision-host says off ran the supervision host" assert_contains "$out" "stale: fixture-win actionable" "the arm's reason must still reach the rewake" - pass "auto-arm: without config/supervision-host the hook runs the arm exactly as before" + assert_not_contains "$out" "supervision-host" "an opted-out home's rewake must carry no host line" + pass "auto-arm: an off config/supervision-host keeps the hook on the arm exactly as before" +} + +test_host_absent_flag_runs_the_host() { + local dir out status + dir=$(make_primary_dir "$TMP_ROOT/host-flag-absent") + rm -f "$dir/config/supervision-host" + : > "$dir/state/task.meta" + write_arm_fixture "$dir" actionable + write_host_fixture "$dir" boundary + out=$(run_autoarm "$dir" 2>/dev/null); status=$? + expect_code 2 "$status" "a host cycle boundary on a Claude home without the file must rewake main" + assert_present "$dir/state/host-ran" "a Claude home without config/supervision-host did not run the supervision host" + [ ! -e "$dir/state/arm-ran" ] || fail "a Claude home without config/supervision-host ran the plain arm instead of the host" + assert_contains "$out" "supervision-host: cycle boundary - fixture" "the rewake must carry the host's line" + [ "$(sed -n 's/^.* primary=\([a-z]*\) .*$/\1/p' "$dir/state/host-env")" = claude ] \ + || fail "the host was not told its primary harness: $(cat "$dir/state/host-env")" + pass "auto-arm: a Claude home without config/supervision-host runs the host by default" } test_host_boundary_rewakes_with_the_host_line() { @@ -1560,7 +1584,7 @@ test_host_crash_is_retried_then_reported() { expect_code 2 "$status" "an exhausted host crash must notify" [ "$(wc -l < "$dir/state/host-ran" | tr -d ' ')" -eq 2 ] || fail "a crashed host was not retried within the attempt bound" assert_contains "$out" "auto-arm FAILED" "an exhausted host crash must deliver the failure notice" - assert_contains "$out" "The supervision host (config/supervision-host) ran these cycles; its last one exited 137 without a wake." \ + assert_contains "$out" "The supervision host (docs/supervision-host.md) ran these cycles; its last one exited 137 without a wake." \ "the failure notice must name the host and its exit" pass "auto-arm: a host that died without a close is retried, then reported as a failure" } @@ -1660,7 +1684,8 @@ test_need_vanished_mid_cycle_closes_quietly test_afk_mid_cycle_suppresses_rewake test_active_in_marked_secondmate_home test_long_poll_grace_reaches_arm_wrapper -test_host_absent_flag_keeps_the_arm +test_host_off_flag_keeps_the_arm +test_host_absent_flag_runs_the_host test_host_boundary_rewakes_with_the_host_line test_host_handback_under_away_record_is_not_a_return test_host_handback_beside_a_quiet_record_carries_no_away_note diff --git a/tests/fm-cursor-primary.test.sh b/tests/fm-cursor-primary.test.sh index b2a6052762c..86e4eeb002a 100755 --- a/tests/fm-cursor-primary.test.sh +++ b/tests/fm-cursor-primary.test.sh @@ -75,7 +75,8 @@ install_scripts() { fm-primary-scope-lib.sh fm-supervision-lib.sh fm-wake-lib.sh fm-path-lib.sh \ fm-session-lock-lib.sh fm-cursor-lib.sh fm-operational-input.sh \ fm-supervision-instructions.sh fm-harness.sh fm-lock.sh \ - fm-gate-refuse-lib.sh fm-afk-contract.sh fm-classify-lib.sh fm-timeout-lib.sh; do + fm-gate-refuse-lib.sh fm-afk-contract.sh fm-classify-lib.sh fm-timeout-lib.sh \ + fm-supervision-engine-lib.sh; do cp "$ROOT/bin/$f" "$dir/bin/$f" done cp "$ROOT/bin/fm-arm-command-policy.mjs" "$dir/bin/fm-arm-command-policy.mjs" @@ -511,6 +512,16 @@ test_park_runs_the_supervision_host_only_when_opted_in() { [ -e "$dir/state/arm-ran" ] || fail "a home without config/supervision-host must park on the arm" [ ! -e "$dir/state/host-ran" ] || fail "a home without config/supervision-host ran the supervision host" + dir=$(make_primary_dir "$TMP_ROOT/park-host-opted-out") + : > "$dir/state/task1.meta" + mkdir -p "$dir/config" + printf 'off\n' > "$dir/config/supervision-host" + write_arm_fixture "$dir" actionable + write_host_fixture "$dir" handback + out=$(run_park "$dir") + [ -e "$dir/state/arm-ran" ] || fail "a home whose config/supervision-host says off must park on the arm" + [ ! -e "$dir/state/host-ran" ] || fail "a home whose config/supervision-host says off ran the supervision host" + dir=$(make_primary_dir "$TMP_ROOT/park-host-on") : > "$dir/state/task1.meta" : > "$dir/state/.afk-contract" diff --git a/tests/fm-host-mirror.test.sh b/tests/fm-host-mirror.test.sh index 89ad55c9fb9..565496241af 100755 --- a/tests/fm-host-mirror.test.sh +++ b/tests/fm-host-mirror.test.sh @@ -31,10 +31,13 @@ git init -q "$PRIMARY_ROOT" : > "$PRIMARY_ROOT/AGENTS.md" ln -s "$ROOT/bin" "$PRIMARY_ROOT/bin" -make_home() { # [opted-in: 1|0] +make_home() { # [config/supervision-host: 1 (empty file) | 0 (none) | off] local home="$TMP_ROOT/$1" mkdir -p "$home/state" "$home/config" - [ "${2:-1}" != 1 ] || : > "$home/config/supervision-host" + case "${2:-1}" in + 1) : > "$home/config/supervision-host" ;; + off) printf 'off\n' > "$home/config/supervision-host" ;; + esac printf '%s\n' "$home" } @@ -87,12 +90,13 @@ main|cursor main" "$out" "every tracked registration must write its captain prom pass "mirror: the Claude and Cursor registrations each write the captain's prompt and main's reply" } -# Non-host invariance: on a home without config/supervision-host, every tracked -# mirror registration prints nothing and leaves the home's state byte-for-byte -# as it was, even for the lock-owning primary session in a primary checkout. -test_home_without_the_flag_is_untouched() { +# Non-host invariance: on a home whose config/supervision-host says off, every +# tracked mirror registration prints nothing and leaves the home's state +# byte-for-byte as it was, even for the lock-owning primary session in a +# primary checkout. +test_home_that_opted_out_is_untouched() { local home before after - home=$(make_home without-flag 0) + home=$(make_home opted-out off) printf 'working: demo\n' > "$home/state/demo.status" # The fixture's own session lock is written by as_session, not by a writer. snapshot() { (cd "$1/state" && find . -type f ! -name .lock | LC_ALL=C sort | while IFS= read -r f; do printf '%s %s\n' "$f" "$(cksum < "$f")"; done); } @@ -106,26 +110,48 @@ test_home_without_the_flag_is_untouched() { run "$CURSOR_PROMPT" "{\"hook_event_name\":\"beforeSubmitPrompt\",\"prompt\":\"hello\",\"cursor_version\":\"x\"}" run "$CLAUDE_STOP" "{\"hook_event_name\":\"Stop\",\"last_assistant_message\":\"hi\"}" run "$CURSOR_RESPONSE" "{\"hook_event_name\":\"afterAgentResponse\",\"text\":\"hi\",\"cursor_version\":\"x\"}" - ' > "$home/writers.out" 2>&1 || fail "a mirror registration failed on a home without the flag: $(cat "$home/writers.out")" - [ ! -s "$home/writers.out" ] || fail "a mirror registration printed on a home without the flag: $(cat "$home/writers.out")" + ' > "$home/writers.out" 2>&1 || fail "a mirror registration failed on a home that opted out: $(cat "$home/writers.out")" + [ ! -s "$home/writers.out" ] || fail "a mirror registration printed on a home that opted out: $(cat "$home/writers.out")" after=$(snapshot "$home") - assert_equals "$before" "$after" "a mirror writer changed the state of a home without the flag" - pass "mirror: a home without the flag is untouched by every tracked mirror registration" + assert_equals "$before" "$after" "a mirror writer changed the state of a home that opted out" + pass "mirror: a home whose config/supervision-host says off is untouched by every tracked mirror registration" } -test_writers_are_inert_without_the_opt_in() { +# Default-on for Claude: with no config/supervision-host, the Claude +# registrations write the mirror, while Cursor's stay file-gated and write +# nothing. +test_home_without_the_file_mirrors_only_claude() { + local home out + home=$(make_home without-file 0) + CLAUDE_PROMPT=$(claude_cmd UserPromptSubmit) CLAUDE_STOP=$(claude_cmd Stop) \ + CURSOR_PROMPT=$(cursor_cmd beforeSubmitPrompt) CURSOR_RESPONSE=$(cursor_cmd afterAgentResponse) \ + as_session "$home" ' + run() { printf "%s" "$2" | env CLAUDE_PROJECT_DIR="$PRIMARY_ROOT" CURSOR_PROJECT_DIR="$PRIMARY_ROOT" \ + bash -c "cd \"$PRIMARY_ROOT\" && $1"; } + run "$CLAUDE_PROMPT" "{\"hook_event_name\":\"UserPromptSubmit\",\"prompt_id\":\"c1\",\"prompt\":\"claude captain\"}" + run "$CLAUDE_STOP" "{\"hook_event_name\":\"Stop\",\"prompt_id\":\"c1\",\"last_assistant_message\":\"claude main\"}" + run "$CURSOR_PROMPT" "{\"hook_event_name\":\"beforeSubmitPrompt\",\"generation_id\":\"u1\",\"prompt\":\"cursor captain\",\"cursor_version\":\"x\"}" + run "$CURSOR_RESPONSE" "{\"hook_event_name\":\"afterAgentResponse\",\"generation_id\":\"u1\",\"text\":\"cursor main\",\"cursor_version\":\"x\"}" + ' || fail "a tracked mirror hook failed" + out=$(entries "$home") + assert_equals "captain|claude captain +main|claude main" "$out" "only the Claude registrations may write the mirror on a home without the file" + pass "mirror: without config/supervision-host the Claude registrations write the mirror and Cursor's stay inert" +} + +test_writers_are_inert_on_a_home_that_opted_out() { local home crew out - home=$(make_home no-opt-in 0) + home=$(make_home opted-out-writer off) as_session "$home" ' printf "%s" "{\"hook_event_name\":\"UserPromptSubmit\",\"prompt\":\"hello\"}" | "$MIRROR" hook claude ' || fail "an inert writer failed" - assert_absent "$home/state/.host-mirror.jsonl" "a home without config/supervision-host must mirror nothing" + assert_absent "$home/state/.host-mirror.jsonl" "a home whose config/supervision-host says off must mirror nothing" crew="$TMP_ROOT/crew-worktree" mkdir -p "$crew" out=$(printf '%s' '{"hook_event_name":"UserPromptSubmit","prompt":"hello"}' | FM_HOME="$crew" "$MIRROR" hook claude 2>&1) [ -z "$out" ] || fail "an inert writer printed: $out" - assert_absent "$crew/state" "an inert writer must create nothing in a home without config/" - pass "mirror: writers stay silent and write nothing on a home that did not opt in" + assert_absent "$crew/state" "an inert writer must create nothing in a home without config/ or state/" + pass "mirror: writers stay silent and write nothing on a home that opted out or has no state" } test_operational_foreign_and_unowned_input_is_dropped() { @@ -432,9 +458,10 @@ test_only_proven_writers_are_verified() { } test_every_harness_registration_writes_the_mirror -test_writers_are_inert_without_the_opt_in +test_writers_are_inert_on_a_home_that_opted_out test_only_proven_writers_are_verified -test_home_without_the_flag_is_untouched +test_home_that_opted_out_is_untouched +test_home_without_the_file_mirrors_only_claude test_operational_foreign_and_unowned_input_is_dropped test_internal_whitespace_is_recorded_verbatim test_entries_are_deduplicated_and_capped diff --git a/tests/fm-omp-harness.test.sh b/tests/fm-omp-harness.test.sh index ccb8c1d15a1..131d31aa3aa 100755 --- a/tests/fm-omp-harness.test.sh +++ b/tests/fm-omp-harness.test.sh @@ -451,7 +451,7 @@ install_omp_extension_fixture() { # mkdir -p "$repo/.omp/extensions" "$repo/.pi/extensions/lib" "$repo/bin" "$repo/node_modules/typebox" cp "$ROOT/.omp/extensions/fm-primary-turnend-guard.ts" "$ROOT/.omp/extensions/fm-primary-omp-watch.ts" "$repo/.omp/extensions/" cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$ROOT/.pi/extensions/lib/fm-sessionstart-supervisor.mjs" "$repo/.pi/extensions/lib/" - cp "$ROOT/bin/fm-operational-input.sh" "$repo/bin/" + cp "$ROOT/bin/fm-operational-input.sh" "$ROOT/bin/fm-supervision-engine-lib.sh" "$repo/bin/" chmod +x "$repo/bin/fm-operational-input.sh" printf '{"name":"typebox","type":"module","exports":"./index.js"}\n' > "$repo/node_modules/typebox/package.json" printf 'export const Type = { Object(p) { return { type: "object", properties: p }; } };\n' > "$repo/node_modules/typebox/index.js" @@ -668,6 +668,61 @@ EOF pass ".omp watch extension: an opted-in home runs the supervision host and relays every host line ($kind record)" } +# The omp owner stays file-gated: a home without config/supervision-host, or +# one whose file says off, spawns the plain arm and never the host. +test_watch_extension_keeps_the_arm_without_the_file_or_with_off() { + local line label repo home log out status + for line in - off; do + label=${line#-}; label=${label:-absent} + repo="$TMP_ROOT/watch-host-gate-$label/repo"; home="$TMP_ROOT/watch-host-gate-$label/home"; log="$TMP_ROOT/watch-host-gate-$label/arm.log" + install_omp_extension_fixture "$repo" + mkdir -p "$home/state" "$home/config" + [ "$line" = - ] || printf '%s\n' "$line" > "$home/config/supervision-host" + cat > "$repo/bin/fm-watch-arm.sh" <<'SH' +#!/usr/bin/env bash +[ "${1:-}" = --handling-delivered ] && exit 0 +printf 'plain-arm=%s\n' "$$" >> "${FM_ARM_LOG:?}" +printf 'watcher: started pid=%s (beacon fresh)\n' "$$" +sleep 30 +SH + cat > "$repo/bin/fm-supervision-host.sh" <<'SH' +#!/usr/bin/env bash +printf 'host=%s\n' "$$" >> "${FM_ARM_LOG:?}" +printf 'watcher: started pid=%s (beacon fresh)\n' "$$" +sleep 30 +SH + chmod +x "$repo/bin/fm-watch-arm.sh" "$repo/bin/fm-supervision-host.sh" + out=$(FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" \ + EXT="$repo/.omp/extensions/fm-primary-omp-watch.ts" node --input-type=module 2>&1 <<'EOF' +import { pathToFileURL } from "node:url"; +import { existsSync, writeFileSync, readFileSync } from "node:fs"; +writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); +const handlers = new Map(); let tool = null; +const pi = { + on(e, h) { handlers.set(e, h); }, + registerCommand() {}, + registerTool(t) { tool = t; }, + sendUserMessage() { return undefined; }, +}; +const mod = await import(pathToFileURL(process.env.EXT).href); +mod.default(pi); +await tool.execute(); +for (let i = 0; i < 60 && !existsSync(process.env.FM_ARM_LOG); i += 1) await new Promise((r) => setTimeout(r, 100)); +const rows = existsSync(process.env.FM_ARM_LOG) ? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n") : []; +if (rows.length === 0 || !rows.every((row) => row.startsWith("plain-arm="))) { + throw new Error(`a home that does not run the host must spawn only the plain arm: ${rows.join(" | ")}`); +} +await handlers.get("session_shutdown")({}, {}); +process.exit(0); +EOF +) + status=$? + expect_code 0 "$status" "omp watch extension gate ($label): $out" + [ -z "$out" ] || fail "omp watch extension gate test printed output ($label): $out" + done + pass ".omp watch extension: a home without config/supervision-host or with an off file keeps the plain arm" +} + # A host cycle boundary can close with only a "supervision-host:" line; left # unconsumed across a session replacement it rides the persisted handoff and # the successor session loads and replays it. @@ -818,5 +873,6 @@ test_turnend_guard_extension_compels_one_continuation test_watch_extension_arms_and_delivers test_watch_extension_runs_the_supervision_host test_watch_extension_runs_the_supervision_host quiet +test_watch_extension_keeps_the_arm_without_the_file_or_with_off test_watch_extension_replays_a_host_only_boundary_across_replacement test_watch_extension_delivers_a_split_host_close_whole diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index 41a68a23eb9..f9d3af76f0c 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -3686,6 +3686,7 @@ test_opencode_primary_watch_plugin_runs_the_supervision_host() { # [away|quiet] : > "$home/state/.afk-contract" fi : > "$home/config/supervision-host" + cp "$ROOT/bin/fm-supervision-engine-lib.sh" "$repo/bin/" cat > "$repo/bin/fm-watch-arm.sh" <<'SH' #!/usr/bin/env bash if [ "${1:-}" = --handling-delivered ]; then diff --git a/tests/fm-secondmate-harness.test.sh b/tests/fm-secondmate-harness.test.sh index 88ed39720f1..845588f02d1 100755 --- a/tests/fm-secondmate-harness.test.sh +++ b/tests/fm-secondmate-harness.test.sh @@ -395,6 +395,18 @@ test_propagate_lib() { [ "$(cat "$d/home2/config/backlog-backend")" = manual ] || fail "backlog-backend not propagated alongside" [ "$(cat "$d/home2/config/backend")" = herdr ] || fail "backend not propagated alongside" + # 5b. supervision-host is each home's own posture: a primary's off opt-out + # never reaches a secondmate, and a secondmate's own file survives a + # convergence where the primary has none + printf 'off\n' > "$src/supervision-host" + propagate_inheritable_config "$src" "$d/home2/config" + [ -e "$d/home2/config/supervision-host" ] && fail "a primary's off supervision-host was inherited (must not be)" + printf 'default haiku\n' > "$d/home2/config/supervision-host" + rm -f "$src/supervision-host" + propagate_inheritable_config "$src" "$d/home2/config" + [ "$(cat "$d/home2/config/supervision-host" 2>/dev/null)" = 'default haiku' ] \ + || fail "a secondmate's own supervision-host was changed by convergence" + # 6. nothing to propagate -> destination dir is never created (a true no-op) rm -rf "$d/src3" "$d/dest3" mkdir -p "$d/src3" diff --git a/tests/fm-session-lock-ancestry.test.sh b/tests/fm-session-lock-ancestry.test.sh index 42e8ffaa7d8..df1d7537097 100755 --- a/tests/fm-session-lock-ancestry.test.sh +++ b/tests/fm-session-lock-ancestry.test.sh @@ -441,7 +441,12 @@ install_autoarm_scripts() { cp "$ROOT/bin/fm-cursor-lib.sh" "$dir/bin/fm-cursor-lib.sh" cp "$ROOT/bin/fm-hook-host-lib.sh" "$dir/bin/fm-hook-host-lib.sh" cp "$ROOT/bin/fm-lock.sh" "$dir/bin/fm-lock.sh" + cp "$ROOT/bin/fm-supervision-engine-lib.sh" "$dir/bin/fm-supervision-engine-lib.sh" chmod +x "$dir/bin/fm-claude-stop-autoarm.sh" "$dir/bin/fm-lock.sh" + # The fixture arm written here stands in for the watcher arm, so the home opts out + # of the supervision host a Claude home otherwise runs by default. + mkdir -p "$dir/config" + printf 'off\n' > "$dir/config/supervision-host" cat > "$dir/bin/fm-watch-arm.sh" <<'SH' #!/usr/bin/env bash echo "$$" >> "$FM_HOME/state/arm-ran" diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index 9b146685815..d9d8ece5f9e 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -1696,6 +1696,9 @@ $rec EOF make_fake_toolchain "$fakebin" make_fake_ps_claude "$fakebin" + # A Claude home runs the supervision host by default and then presents its + # outcomes; this case pins a home that does not run it. + printf 'off\n' > "$home/config/supervision-host" FM_HOME="$home" "$ROOT/bin/fm-branch-outcome.sh" append \ --task task-b --verdict captain --summary 'unread Pi branch outcome' >/dev/null \ diff --git a/tests/fm-supervision-host.test.sh b/tests/fm-supervision-host.test.sh index 8a25752c58c..5946b626990 100755 --- a/tests/fm-supervision-host.test.sh +++ b/tests/fm-supervision-host.test.sh @@ -147,17 +147,28 @@ unset FM_SUPERVISION_ACTOR FM_BRANCH_REPORT_TURN FM_LEASE_HOLDER_PID PI_CODING_A HOMES_FILE="$TMP_ROOT/homes" # Stop whatever a case left running, by the exact pids its home recorded. stop_home_processes() { # - local home=$1 pid + local home=$1 pid arms= if [ -f "$home/state/.supervision-host" ]; then + arms=$(awk -F '\t' '$1 == "arm" { print $2 }' "$home/state/.supervision-host") pid=$(awk -F '\t' '$1 == "host" { print $2; exit }' "$home/state/.supervision-host") [ -z "$pid" ] || kill -TERM "$pid" 2>/dev/null || true sleep 1 fi + for pid in $arms; do + kill -TERM "$pid" 2>/dev/null || true + done pid=$(cat "$home/state/.watch.lock/pid" 2>/dev/null || true) [ -z "$pid" ] || kill -TERM "$pid" 2>/dev/null || true - for pid in $(cat "$home/claude-pids" 2>/dev/null) $(cat "$home/orphan-pid" 2>/dev/null); do + while IFS= read -r pid; do + if [ -e "$home/session.stop" ]; then + wait "$pid" 2>/dev/null || true + else + kill -TERM "$pid" 2>/dev/null || true + fi + done < <(cat "$home/claude-pids" 2>/dev/null) + while IFS= read -r pid; do kill -TERM "$pid" 2>/dev/null || true - done + done < <(cat "$home/orphan-pid" 2>/dev/null) } suite_cleanup() { local home @@ -412,30 +423,42 @@ test_dispatch_entry_scopes_rows_and_renders_the_away_tail() { # --- host loop ---------------------------------------------------------------- -# BRANCH OUTCOMES belongs to an opted-in home off Pi: without the file the drain -# and the store's markers are exactly as before, and on Pi the branch extension -# owns the same outcomes. -test_branch_outcomes_only_on_an_opted_in_home_off_pi() { - local home drained fakepi +# BRANCH OUTCOMES belongs to a home that runs the host off Pi: on a Claude +# primary that is the default and an `off` file opts out, while another +# primary still needs the file; wherever the home does not run the host the +# drain and the store's markers are exactly as before, and on Pi the branch +# extension owns the same outcomes. +test_branch_outcomes_only_on_a_host_home_off_pi() { + local home drained fakes home="$TMP_ROOT/drain-scope" mkdir -p "$home/state" "$home/config" FM_HOME="$home" "$ROOT/bin/fm-branch-outcome.sh" append --task demo --verdict captain --summary 'PR ready for review' >/dev/null \ || fail "fixture: could not record a captain outcome" + fakes="$TMP_ROOT/drain-scope-fakes" + mkdir -p "$fakes" + ln -sf /bin/bash "$fakes/pi" + ln -sf /bin/bash "$fakes/codex" + + printf 'off\n' > "$home/config/supervision-host" drained=$(FM_HOME="$home" "$FAKE_CLAUDE" -c '"$0" 2>&1' "$ROOT/bin/fm-wake-drain.sh") - assert_not_contains "$drained" "BRANCH OUTCOMES" "a home without config/supervision-host must not present branch outcomes" - assert_absent "$home/state/.branch-outcomes-cursor" "a home without config/supervision-host must keep the store's read cursor untouched" + assert_not_contains "$drained" "BRANCH OUTCOMES" "a Claude home whose file says off must not present branch outcomes" + assert_absent "$home/state/.branch-outcomes-cursor" "a Claude home whose file says off must keep the store's read cursor untouched" - : > "$home/config/supervision-host" - fakepi="$TMP_ROOT/fakepi" - mkdir -p "$fakepi" - ln -sf /bin/bash "$fakepi/pi" - drained=$(FM_HOME="$home" "$fakepi/pi" -c '"$0" 2>&1' "$ROOT/bin/fm-wake-drain.sh") + rm -f "$home/config/supervision-host" + drained=$(FM_HOME="$home" "$fakes/codex" -c '"$0" 2>&1' "$ROOT/bin/fm-wake-drain.sh") + assert_not_contains "$drained" "BRANCH OUTCOMES" "a Codex home without config/supervision-host must not present branch outcomes" + assert_absent "$home/state/.branch-outcomes-cursor" "a Codex home without config/supervision-host must keep the store's read cursor untouched" + drained=$(FM_HOME="$home" "$fakes/pi" -c '"$0" 2>&1' "$ROOT/bin/fm-wake-drain.sh") assert_not_contains "$drained" "BRANCH OUTCOMES" "a Pi primary's drain must leave captain outcomes to the branch extension" assert_absent "$home/state/.branch-outcomes-cursor" "a Pi primary's drain must not advance the store's read cursor" drained=$(FM_HOME="$home" "$FAKE_CLAUDE" -c '"$0" 2>&1' "$ROOT/bin/fm-wake-drain.sh") - assert_contains "$drained" "[seq 1, recorded 0m ago] demo: PR ready for review" "an opted-in home off Pi must present the captain outcome" - pass "drain: BRANCH OUTCOMES runs only on an opted-in home whose primary is not Pi" + assert_contains "$drained" "[seq 1, recorded 0m ago] demo: PR ready for review" "a Claude home without config/supervision-host must present the captain outcome" + + : > "$home/config/supervision-host" + drained=$(FM_HOME="$home" "$fakes/codex" -c '"$0" 2>&1' "$ROOT/bin/fm-wake-drain.sh") + assert_contains "$drained" "[seq 1, recorded 0m ago] demo: PR ready for review" "a Codex home with config/supervision-host must present the captain outcome" + pass "drain: BRANCH OUTCOMES runs on a Claude home by default and on another primary with the file, never with off, and never on Pi" } # A fresh captain outcome is never hidden behind older routine outcomes: the @@ -961,6 +984,27 @@ test_attended_main_only_close_passes_straight_to_main() { pass "host: an attended decision close stays main's exactly as the plain arm delivers it" } +# The file is read at every wake (docs/configuration.md "Supervision host"), so +# an off written while the host is parked sends the next attended close to main +# exactly as the arm printed it, with the ledger naming the opt-out. +test_off_written_while_parked_passes_the_next_attended_close_to_main() { + local home + home=$(make_home attended-off-while-parked attended) + start_host "$home" + wait_until 150 watcher_live "$home" || fail "off while parked: the host never started a watcher cycle" + printf 'off\n' > "$home/config/supervision-host" + append_status "$home" 'step one' + wait_until 250 host_exited "$home" || fail "off while parked: the close did not reach main: $(cat "$home/state/.supervision-host.log")" + expect_code 0 "$(cat "$home/host.rc")" "a close on a home that opted out must exit 0" + assert_re '^signal: .*demo.status' "$home/host.out" "the close must carry the watcher's reason line" + assert_no_re '^supervision-host' "$home/host.out" "the close must reach main exactly as the arm printed it" + [ "$(engine_calls "$home")" -eq 0 ] || fail "off while parked: the engine ran after the home opted out" + assert_re ' pass-through attended the home does not run the supervision host signal:' "$home/state/.supervision-host.log" \ + "the ledger must name the opt-out as why the close went to main" + stop_home_processes "$home" + pass "host: an off written while the host is parked sends the next attended close to main, naming the opt-out" +} + # The live failure this guards: a main-only pass-through used to exit without # a watcher, so nothing restarted short-lived listeners until the session # armed again. The close still reaches main unchanged, and the successor @@ -1167,6 +1211,51 @@ test_claude_stop_hook_rewakes_a_present_captain_beside_a_quiet_record() { pass "host+hook: a captain outcome beside a quiet record rewakes the present captain with no away note" } +# Default-on for Claude (docs/configuration.md "Supervision host"): through the +# real Stop hook and mirror writer, a Claude primary home with no +# config/supervision-host runs the host at the default engine, mirrors the +# captain's dialog, and keeps a routine attended wake off main; a home whose +# file says off runs the plain watcher arm, mirrors nothing, and every wake +# reaches main as the arm printed it. +test_claude_stop_hook_runs_the_host_without_the_file_and_off_opts_out() { + local home first + home=$(make_primary_home hook-default-on) + ln -s "$ROOT/.agents" "$home/.agents" + rm -f "$home/config/supervision-host" + start_hook_session "$home" + turn_end "$home" + wait_until 150 watcher_live "$home" || fail "default-on: the Stop hook never started a watcher cycle: $(cat "$home/hook.err" 2>/dev/null)" + assert_grep 'watch the fleet for me' "$home/state/.host-mirror.jsonl" "a Claude home without the file must mirror the captain's dialog" + append_status "$home" 'step one' + wait_until 250 handled_at_least "$home" 1 \ + || fail "default-on: the wake was not handled on the engine: $(cat "$home/hook.err" 2>/dev/null; cat "$home/state/.supervision-host.log" 2>/dev/null)" + first="$home/engine-call.1" + assert_re '^arg=sonnet$' "$first" "a Claude home without the file must run the Claude engine at its default model" + assert_re '^primary=claude$' "$first" "the engine must carry the Claude primary pin" + assert_re ' handled turn=[^ ]* posture=attended ' "$home/state/.supervision-host.log" "the ledger must record the attended turn" + [ ! -s "$home/hook.rc" ] || fail "a routine attended wake on a Claude home without the file reached main: $(cat "$home/hook.err")" + watcher_live "$home" || fail "default-on: the host is not parked on a live successor" + : > "$home/session.stop" + stop_home_processes "$home" + + home=$(make_primary_home hook-opted-out) + printf 'off\n' > "$home/config/supervision-host" + start_hook_session "$home" + turn_end "$home" + wait_until 150 watcher_live "$home" || fail "off: the Stop hook never started a watcher cycle: $(cat "$home/hook.err" 2>/dev/null)" + append_status "$home" 'step one' + wait_until 250 hook_exited "$home" || fail "off: the Stop hook never closed" + assert_rewoke_main "$home" "off" + assert_re '^signal: .*demo.status' "$home/hook.err" "off: the rewake must carry the arm's close" + assert_no_re '^supervision-host' "$home/hook.err" "off: the close must reach main exactly as the arm printed it" + assert_absent "$home/state/.supervision-host.log" "a home whose file says off must never run the host" + assert_absent "$home/state/.host-mirror.jsonl" "a home whose file says off must mirror nothing" + [ "$(engine_calls "$home")" -eq 0 ] || fail "a home whose file says off ran an engine turn" + : > "$home/session.stop" + stop_home_processes "$home" + pass "host+hook: a Claude home without config/supervision-host runs the host at the default engine, and an off file restores the plain arm" +} + test_claude_stop_hook_delivers_a_close_that_turns_main_only_at_its_turn() { local home home=$(make_primary_home hook-turns-main-only) @@ -2327,17 +2416,17 @@ test_latch_keeps_attended_closes_on_main_and_skips_unopted_homes() { [ "$(cat "$home/state/.supervision-host-health")" = "$health" ] || fail "an attended close changed the latch" main_drain_and_ack "$home" - rm -f "$home/config/supervision-host" + printf 'off\n' > "$home/config/supervision-host" FM_HOME="$home" "$CONTRACT" enter --words 'watch the fleet; merge nothing' >/dev/null 2>&1 \ || fail "fixture: could not record the away posture again" park_again "$home" - append_status "$home" 'away without the file' - wait_until 250 host_exited "$home" || fail "latch scope: the close without the file did not reach main" - assert_re '^supervision-host: the home no longer opts into the supervision host$' "$home/host.out" \ - "a home without the file must hand the close back as the opt-out, not the latch" - assert_no_re 'paused' "$home/host.out" "a home without the file must not read the latch" + append_status "$home" 'away after opting out' + wait_until 250 host_exited "$home" || fail "latch scope: the close after the opt-out did not reach main" + assert_re '^supervision-host: the home no longer runs the supervision host$' "$home/host.out" \ + "a home whose file says off must hand the close back as the opt-out, not the latch" + assert_no_re 'paused' "$home/host.out" "a home whose file says off must not read the latch" [ "$(engine_calls "$home")" -eq 2 ] || fail "an engine ran after the latch tripped" - pass "host: an attended close in a latched session reaches main as the arm printed it and leaves the latch as it was, and a home without config/supervision-host never reads it" + pass "host: an attended close in a latched session reaches main as the arm printed it and leaves the latch as it was, and a home that opted out with off never reads it" } # The 2026-09-25 away-window flood: a held, green PR on a finished task was @@ -2487,7 +2576,7 @@ test_superseded_host_leaves_the_owner_untouched() { test_report_surface_enforces_actor_turn_and_scope test_report_after_the_return_is_queued_for_main test_dispatch_entry_scopes_rows_and_renders_the_away_tail -test_branch_outcomes_only_on_an_opted_in_home_off_pi +test_branch_outcomes_only_on_a_host_home_off_pi test_branch_outcomes_put_captain_first_and_collapse_routine_overflow test_branch_outcomes_collapse_repeated_captain_outcomes_per_task test_branch_outcomes_present_a_long_away_window_once @@ -2506,12 +2595,14 @@ test_attended_captain_outcome_reaches_main_through_branch_outcomes test_captain_leaving_mid_turn_keeps_its_captain_outcome_for_the_return test_quiet_record_without_its_daemon_is_a_present_captain test_attended_main_only_close_passes_straight_to_main +test_off_written_while_parked_passes_the_next_attended_close_to_main test_main_only_pass_through_leaves_the_successor_watcher_running test_attended_close_with_unidentified_main_session_passes_to_main test_close_accepted_away_that_turns_attended_passes_to_main test_attended_close_that_turns_main_only_before_its_turn_passes_to_main test_claude_stop_hook_delivers_a_main_only_pass_through test_claude_stop_hook_rewakes_a_present_captain_beside_a_quiet_record +test_claude_stop_hook_runs_the_host_without_the_file_and_off_opts_out test_claude_stop_hook_delivers_a_close_that_turns_main_only_at_its_turn test_claude_stop_hook_notifies_when_at_turn_downtime_write_fails test_successor_close_during_main_turn_is_delivered_at_the_next_turn_end diff --git a/tests/fm-supervision-instructions.test.sh b/tests/fm-supervision-instructions.test.sh index c319527019c..b992f33809b 100755 --- a/tests/fm-supervision-instructions.test.sh +++ b/tests/fm-supervision-instructions.test.sh @@ -19,15 +19,22 @@ test_selected_harness_block_only() { pass "renderer prints exactly the selected harness block" } -test_supervision_host_protocol_only_on_an_opted_in_claude_home() { +# A Claude home runs the host by default, so its block carries the host +# protocol with no file, exactly as with an opting-in file; an off file +# renders the plain block. +test_supervision_host_protocol_on_a_claude_home_unless_off() { local home config plain hosted other home="$TMP_ROOT/host-home" config="$TMP_ROOT/host-config" mkdir -p "$home/state" "$config" + printf 'off\n' > "$config/supervision-host" plain=$(FM_HOME="$home" FM_CONFIG_OVERRIDE="$config" "$RENDER" --harness claude) - assert_not_contains "$plain" "Supervision host" "a claude home without config/supervision-host rendered the host protocol" - : > "$config/supervision-host" + assert_not_contains "$plain" "Supervision host" "a claude home whose config/supervision-host says off rendered the host protocol" + rm -f "$config/supervision-host" hosted=$(FM_HOME="$home" FM_CONFIG_OVERRIDE="$config" "$RENDER" --harness claude) + : > "$config/supervision-host" + assert_equals "$(FM_HOME="$home" FM_CONFIG_OVERRIDE="$config" "$RENDER" --harness claude)" "$hosted" \ + "a claude home without config/supervision-host must render exactly what an opted-in claude home renders" assert_contains "$hosted" "- Supervision host: on;" "an opted-in claude home did not render the host state line" assert_contains "$hosted" "Mode: Claude Stop-hook-owned supervision." "the host protocol replaced the claude protocol instead of adding to it" assert_contains "$hosted" "supervision-host: cycle boundary" "the host protocol did not tell main how to handle a park boundary" @@ -36,22 +43,31 @@ test_supervision_host_protocol_only_on_an_opted_in_claude_home() { || fail "the host protocol changed the claude block it should only append to" other=$(FM_HOME="$home" FM_CONFIG_OVERRIDE="$config" "$RENDER" --harness pi) assert_not_contains "$other" "Supervision host" "a pi primary rendered the host protocol" - pass "renderer adds the supervision-host protocol only on an opted-in claude home, leaving the claude block intact" + rm -f "$config/supervision-host" + other=$(FM_HOME="$home" FM_CONFIG_OVERRIDE="$config" "$RENDER" --harness pi) + assert_not_contains "$other" "Supervision host" "a pi primary without config/supervision-host rendered the host protocol" + pass "renderer adds the supervision-host protocol on a claude home unless its config/supervision-host says off, leaving the claude block intact" } # Each non-Pi arm owner gets the host protocol in its own terms, and only its -# own terms; Grok's model-owned arm command becomes the host; a home without -# the file renders exactly what it did before, with no tag or placeholder. +# own terms; Grok's model-owned arm command becomes the host; a home whose +# file says off, or a non-Claude home without the file, renders exactly what +# it did before, with no tag or placeholder. test_supervision_host_protocol_on_every_arm_owner() { local home config harness plain hosted body home="$TMP_ROOT/host-owners-home" config="$TMP_ROOT/host-owners-config" mkdir -p "$home/state" "$config" for harness in claude cursor opencode omp grok codex; do - rm -f "$config/supervision-host" + printf 'off\n' > "$config/supervision-host" plain=$(FM_HOME="$home" FM_CONFIG_OVERRIDE="$config" "$RENDER" --harness "$harness") - assert_not_contains "$plain" "Supervision host" "$harness: a home without config/supervision-host rendered the host protocol" + assert_not_contains "$plain" "Supervision host" "$harness: a home whose config/supervision-host says off rendered the host protocol" assert_not_contains "$plain" "__FM_" "$harness: a placeholder leaked into the rendered block" + if [ "$harness" != claude ]; then + rm -f "$config/supervision-host" + assert_equals "$(FM_HOME="$home" FM_CONFIG_OVERRIDE="$config" "$RENDER" --harness "$harness")" "$plain" \ + "$harness: a home without config/supervision-host must render the plain block" + fi : > "$config/supervision-host" hosted=$(FM_HOME="$home" FM_CONFIG_OVERRIDE="$config" "$RENDER" --harness "$harness") assert_contains "$hosted" "- Supervision host: on; it takes away-posture wakes and, where the dialog mirror is verified, eligible attended wakes itself, and hands the rest to you (protocol at the end of this block)." \ @@ -70,6 +86,9 @@ test_supervision_host_protocol_on_every_arm_owner() { rm -f "$config/supervision-host" plain=$(FM_HOME="$home" FM_CONFIG_OVERRIDE="$config" "$RENDER" --harness grok) assert_contains "$plain" 'exec bin/fm-watch-arm.sh`' "grok without the file must arm the plain watcher" + printf 'off\n' > "$config/supervision-host" + plain=$(FM_HOME="$home" FM_CONFIG_OVERRIDE="$config" "$RENDER" --harness grok) + assert_contains "$plain" 'exec bin/fm-watch-arm.sh`' "grok with an off file must arm the plain watcher" : > "$config/supervision-host" hosted=$(FM_HOME="$home" FM_CONFIG_OVERRIDE="$config" "$RENDER" --harness grok) assert_contains "$hosted" 'exec bin/fm-supervision-host.sh park`' "grok with the file must arm the supervision host" @@ -283,7 +302,7 @@ test_pi_snippet_uses_effective_extension_path() { pass "pi supervision snippet renders the effective extension path" } -test_supervision_host_protocol_only_on_an_opted_in_claude_home +test_supervision_host_protocol_on_a_claude_home_unless_off test_supervision_host_protocol_on_every_arm_owner test_selected_harness_block_only test_unknown_fallback diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index c0af0c4f893..3f6a29229f5 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -190,6 +190,7 @@ install_guard_scripts() { cp "$ROOT/bin/fm-harness.sh" "$dir/bin/fm-harness.sh" cp "$ROOT/bin/fm-primary-scope-lib.sh" "$dir/bin/fm-primary-scope-lib.sh" cp "$ROOT/bin/fm-supervision-lib.sh" "$dir/bin/fm-supervision-lib.sh" + cp "$ROOT/bin/fm-supervision-engine-lib.sh" "$dir/bin/fm-supervision-engine-lib.sh" cp "$ROOT/bin/fm-wake-lib.sh" "$dir/bin/fm-wake-lib.sh" cp "$ROOT/bin/fm-path-lib.sh" "$dir/bin/fm-path-lib.sh" cp "$ROOT/bin/fm-hook-host-lib.sh" "$dir/bin/fm-hook-host-lib.sh" @@ -1217,8 +1218,13 @@ install_integrated_autoarm() { cp "$ROOT/bin/fm-session-lock-lib.sh" "$dir/bin/fm-session-lock-lib.sh" cp "$ROOT/bin/fm-cursor-lib.sh" "$dir/bin/fm-cursor-lib.sh" cp "$ROOT/bin/fm-lock.sh" "$dir/bin/fm-lock.sh" + cp "$ROOT/bin/fm-supervision-engine-lib.sh" "$dir/bin/fm-supervision-engine-lib.sh" chmod +x "$dir/bin/fm-claude-stop-autoarm.sh" "$dir/bin/fm-lock.sh" ln -s /bin/bash "$dir/fake-claude" + # These cases drive the watcher arm, so the home opts out of the supervision + # host a Claude home otherwise runs by default. + mkdir -p "$dir/config" + printf 'off\n' > "$dir/config/supervision-host" } run_integrated_autoarm() { diff --git a/tests/fm-wake-drain-outcome-backstop.test.sh b/tests/fm-wake-drain-outcome-backstop.test.sh index 9a2a94b0c6a..de2fd5ca838 100755 --- a/tests/fm-wake-drain-outcome-backstop.test.sh +++ b/tests/fm-wake-drain-outcome-backstop.test.sh @@ -12,6 +12,14 @@ GRANT="$ROOT/bin/fm-wake-grant.sh" OUTCOMES="$ROOT/bin/fm-branch-outcome.sh" TMP_ROOT=$(fm_test_tmproot fm-wake-drain-outcome-backstop-tests) +# These regressions exercise the backstop on a home that does not run the +# supervision host, so its BRANCH OUTCOMES section stays out of the drain; the +# explicit off file pins that posture on every primary instead of reading the +# code root's config (bin/fm-supervision-engine-lib.sh owns the gate). +mkdir -p "$TMP_ROOT/config" +printf 'off\n' > "$TMP_ROOT/config/supervision-host" +export FM_CONFIG_OVERRIDE="$TMP_ROOT/config" + set_mtime() { # perl -e 'utime($ARGV[0], $ARGV[0], $ARGV[1]) or exit 1' "$1" "$2" } diff --git a/tests/fm-wake-drain-unread-status.test.sh b/tests/fm-wake-drain-unread-status.test.sh index 632d4d27561..b659b880e0e 100755 --- a/tests/fm-wake-drain-unread-status.test.sh +++ b/tests/fm-wake-drain-unread-status.test.sh @@ -16,6 +16,14 @@ DRAIN="$ROOT/bin/fm-wake-drain.sh" TMP_ROOT=$(fm_test_tmproot fm-wake-drain-unread-status-tests) +# These regressions exercise status presentation on a home that does not run +# the supervision host, so its BRANCH OUTCOMES section stays out of the drain; +# the explicit off file pins that posture on every primary instead of reading +# the code root's config (bin/fm-supervision-engine-lib.sh owns the gate). +mkdir -p "$TMP_ROOT/config" +printf 'off\n' > "$TMP_ROOT/config/supervision-host" +export FM_CONFIG_OVERRIDE="$TMP_ROOT/config" + # Establish the durable last-presentation cursor by draining once over a # bootstrap line so later appends are "new since last drain". prime_cursor() { # diff --git a/tests/fm-watch-checkpoint.test.sh b/tests/fm-watch-checkpoint.test.sh index 650718e1cfb..8626faad9d4 100755 --- a/tests/fm-watch-checkpoint.test.sh +++ b/tests/fm-watch-checkpoint.test.sh @@ -89,6 +89,7 @@ make_host_home() { # home=$(make_home "$1") mkdir -p "$home/root/bin" cp "$CHECKPOINT" "$home/root/bin/fm-watch-checkpoint.sh" + cp "$ROOT/bin/fm-supervision-engine-lib.sh" "$home/root/bin/fm-supervision-engine-lib.sh" cat > "$home/root/bin/fm-supervision-host.sh" <<'SH' #!/usr/bin/env bash printf 'args=%s\nprimary=%s\npark=%s\nlimit=%s\n' "$*" "${FM_SUPERVISION_HOST_PRIMARY:-}" \ @@ -158,6 +159,20 @@ test_host_checkpoint_passes_a_handback_and_reports_a_stand_down() { pass "checkpoint: a handed-back wake passes through, and a host stand-down is a failure" } +# The Codex owner stays file-gated: without config/supervision-host, or with +# a file that says off, the checkpoint never runs the host. +test_host_checkpoint_needs_the_file_and_honors_off() { + local home line + home=$(make_host_home host-gate) + for line in - off; do + rm -f "$home/config/supervision-host" "$home/host-env" + [ "$line" = - ] || printf '%s\n' "$line" > "$home/config/supervision-host" + run_host_checkpoint "$home" boundary --seconds 1 + [ ! -e "$home/host-env" ] || fail "a Codex home whose config/supervision-host is ${line/-/absent} ran the supervision host" + done + pass "checkpoint: a Codex home without config/supervision-host, or with an off file, never runs the host" +} + # The real host under a fake Codex harness that holds the home's session lock. # shellcheck disable=SC2016 # the fake harness's script expands in its own shell test_real_host_checkpoint_ends_quietly_at_its_bound() { @@ -187,4 +202,5 @@ test_registered_check_uses_preserved_watcher_environment test_existing_singleton_watcher_is_not_success test_host_checkpoint_bounds_the_park_by_posture test_host_checkpoint_passes_a_handback_and_reports_a_stand_down +test_host_checkpoint_needs_the_file_and_honors_off test_real_host_checkpoint_ends_quietly_at_its_bound From 5bddfc44b3d8e941cbe80c4cdd20813544daa3ae Mon Sep 17 00:00:00 2001 From: Christopher McKay <101884182+karotkriss@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:19:04 -0400 Subject: [PATCH 09/76] fix(bin): create the state dir on a fresh primary before the session-start scope check (#6125) * fix(bin): create the state dir on a fresh primary before the session-start scope check fm_primary_scope_matches required an already-existing state directory, so bin/fm-sessionstart-run.sh stood down on a fresh clone before anything could create it. Split out fm_primary_root_matches so the run wrapper can confirm primary-home identity first, create the gitignored state dir when it is missing, and only then run the unchanged scope check. * no-mistakes(document): Document session-start state dir creation on fresh clones * no-mistakes(ci): I fixed the Greptile P1 the way you asked. When a fresh primary can't create `state/`, the run wrapper no longer stands down silently. **Invariant:** when an otherwise eligible fresh primary cannot create `state/`, startup must never fail silently. This path has only one site: the mkdir in `bin/fm-sessionstart-run.sh`. Other hooks and the nudge wrapper never create `state/`, so they have no equivalent failure. **What changed:** - **Run wrapper** (`bin/fm-sessionstart-run.sh`): it captures mkdir's error and prints one line to stderr before standing down as before (exit 0, or 3 for the Pi prerequisite). The line looks like `fm-sessionstart-run: startup could not create the state directory : `. - **Test** (`tests/fm-sessionstart-nudge.test.sh`): the new case `test_run_reports_a_state_dir_it_cannot_create` uses a fresh primary with no `state/` and a read-only (0500) root. It checks four things: exit 0, no digest on stdout, no state dir created, and exactly one stderr line ending in "Permission denied". It fails without the fix and passes with it. - **Docs** (`docs/sessionstart-nudge.md`): I added one sentence describing the stderr line and one describing what the new test proves. **Verification:** I ran `tests/fm-sessionstart-nudge.test.sh`, and every test passes. `bin/fm-lint.sh` on the changed scripts (pinned ShellCheck 0.11.0) and `tests/fm-documentation-audiences.test.sh` also pass. As you asked, the wrapper still stands down with the ineligible-checkout status afterwards. It does not report this as a failed eligible startup, which is what the bot suggested --- bin/fm-primary-scope-lib.sh | 21 +++++++++---- bin/fm-sessionstart-run.sh | 10 ++++++ docs/sessionstart-nudge.md | 7 +++++ tests/fm-sessionstart-nudge.test.sh | 48 +++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 6 deletions(-) diff --git a/bin/fm-primary-scope-lib.sh b/bin/fm-primary-scope-lib.sh index 536e62e7ab7..9a9ed9a7998 100755 --- a/bin/fm-primary-scope-lib.sh +++ b/bin/fm-primary-scope-lib.sh @@ -2,6 +2,8 @@ # Shared marker-or-plain-checkout predicate for tracked hooks that must act only # in a genuine firstmate primary home. # This file is sourced by hook entrypoints and has no side effects on source. +# fm_primary_root_matches is split out so a caller can confirm primary-home +# identity before its gitignored state dir exists, such as to create it. # Return 0 when $1 carries a genuine secondmate-home marker. fm_root_is_secondmate_home() { @@ -17,11 +19,12 @@ fm_root_is_secondmate_home() { return 0 } -# Return 0 when $1 is a genuine primary root whose effective state dir is $2. -# A valid secondmate marker force-includes a linked secondmate home. -# Otherwise only a plain checkout is primary, never a linked task worktree. -fm_primary_scope_matches() { - local root=$1 state=$2 git_dir git_common_dir +# Return 0 when $1 is a genuine primary root, regardless of whether its state +# dir exists yet. A valid secondmate marker force-includes a linked secondmate +# home. Otherwise only a plain checkout is primary, never a linked task +# worktree. +fm_primary_root_matches() { + local root=$1 git_dir git_common_dir if ! fm_root_is_secondmate_home "$root"; then git_dir=$(git -C "$root" rev-parse --git-dir 2>/dev/null) || return 1 git_common_dir=$(git -C "$root" rev-parse --git-common-dir 2>/dev/null) || return 1 @@ -29,5 +32,11 @@ fm_primary_scope_matches() { fi [ -f "$root/AGENTS.md" ] || return 1 [ -d "$root/bin" ] || return 1 - [ -d "$state" ] || return 1 +} + +# Return 0 when $1 is a genuine primary root whose effective state dir $2 +# already exists. +fm_primary_scope_matches() { + local root=$1 state=$2 + fm_primary_root_matches "$root" && [ -d "$state" ] } diff --git a/bin/fm-sessionstart-run.sh b/bin/fm-sessionstart-run.sh index a970eced675..75859249051 100755 --- a/bin/fm-sessionstart-run.sh +++ b/bin/fm-sessionstart-run.sh @@ -42,6 +42,9 @@ # preflight. A lock another live session holds and a truncated digest are # reported inside the digest, while broken GitHub auth arrives through the # deferred network result inline or as a wake, for exactly that reason. +# A fresh clone has no gitignored state dir yet; a root that otherwise +# qualifies as primary gets one created here before the scope check runs, so +# the first session takes the helm without a manual `mkdir state`. set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -87,6 +90,13 @@ stand_down() { # they do not own. Pi's preflight-only status preserves that intentional silence # without mistaking it for a failed eligible attempt that needs the manual nudge. fm_is_gate_agent "$FM_ROOT" && stand_down +if [ ! -d "$STATE" ] && fm_primary_root_matches "$FM_ROOT"; then + if ! MKDIR_ERR=$(mkdir -p "$STATE" 2>&1); then + printf 'fm-sessionstart-run: startup could not create the state directory %s: %s\n' \ + "$STATE" "${MKDIR_ERR##*: }" >&2 + stand_down + fi +fi fm_primary_scope_matches "$FM_ROOT" "$STATE" || stand_down session_start_completed() { diff --git a/docs/sessionstart-nudge.md b/docs/sessionstart-nudge.md index bf446312524..2b111302985 100644 --- a/docs/sessionstart-nudge.md +++ b/docs/sessionstart-nudge.md @@ -183,6 +183,11 @@ So a truncated digest does neither of these: - They source `bin/fm-gate-refuse-lib.sh` and stay silent for a no-mistakes gate agent identified by `NO_MISTAKES_GATE` or a `.no-mistakes/repos/*.git` git-common-dir. - They share `bin/fm-primary-scope-lib.sh` with `bin/fm-turnend-guard.sh`, so every hook uses one primary-detection owner. +A fresh clone has no gitignored state directory yet. +When the root otherwise qualifies as primary, the run wrapper creates the state directory before the unchanged scope check, so the first session takes the helm without a manual `mkdir state`. +If that creation fails, the run wrapper prints one stderr line naming the state directory and the reason, then stands down as it would for any ineligible root. +The nudge wrapper and every other hook still stand down while the state directory is missing. + The Guard Predicates section of [`turnend-guard.md`](turnend-guard.md#guard-predicates) owns marker validation, plain-checkout detection, and required Firstmate-shaped paths. ### Nudge payload @@ -387,6 +392,8 @@ It proves the nudge wrapper's silence for these cases: It also proves the nudge wrapper's exact U+2063 `FIRSTMATE_OP:`-prefixed, `session-start`-typed one-line output. It separately proves the run wrapper's silence for the gate environment and an unmarked linked worktree, including the internal Pi prerequisite's explicit silent stand-down. +It proves the run wrapper creates a missing state directory on a fresh primary and delivers the full digest, while an unmarked linked worktree gets none. +It proves a fresh primary whose state directory cannot be created reports that on one stderr line and stands down without a digest. It proves the run wrapper's source routing end to end against a real `fm-session-start.sh`, including: diff --git a/tests/fm-sessionstart-nudge.test.sh b/tests/fm-sessionstart-nudge.test.sh index 03bedf85502..854080c3cb9 100755 --- a/tests/fm-sessionstart-nudge.test.sh +++ b/tests/fm-sessionstart-nudge.test.sh @@ -1037,6 +1037,52 @@ test_run_gate_and_scope_are_silent() { pass "run wrapper: ordinary ineligible opens stay silent-zero and Pi preflight gets an explicit silent stand-down" } +test_run_creates_missing_state_on_a_fresh_primary() { + local root="$TMP_ROOT/run-fresh-primary" base="$TMP_ROOT/run-fresh-linked-base" + local linked="$TMP_ROOT/run-fresh-linked" out status=0 + make_run_primary "$root" + rmdir "$root/state" + assert_absent "$root/state" "the fixture still had a state dir before the assertion began" + + out=$(run_hook "$root" --source startup "$linked/AGENTS.md" + assert_absent "$linked/state" "the linked fixture already had a state dir before the assertion began" + expect_silent_zero "linked worktree fresh state run" run_hook "$linked" --source startup + assert_absent "$linked/state" "an unmarked linked task worktree got a state dir created for it" + pass "run wrapper: a fresh primary checkout gets its missing state dir created, a linked worktree still does not" +} + +test_run_reports_a_state_dir_it_cannot_create() { + local root="$TMP_ROOT/run-fresh-readonly" out err_file="$TMP_ROOT/run-fresh-readonly.err" status=0 + make_run_primary "$root" + rmdir "$root/state" + chmod 0500 "$root" + out=$(run_hook "$root" --source startup "$err_file") || status=$? + chmod 0700 "$root" + expect_code 0 "$status" "run wrapper on a fresh primary whose state dir cannot be created" + [ -z "$out" ] || fail "a failed state dir creation must still stand down without a digest, got: $out" + assert_absent "$root/state" "a read-only fresh primary somehow got a state dir" + [ "$(wc -l <"$err_file")" -eq 1 ] || fail "expected exactly one stderr line, got: $(cat "$err_file")" + assert_contains "$(cat "$err_file")" \ + "startup could not create the state directory $root/state: Permission denied" \ + "a failed state dir creation did not say what failed and why" + pass "run wrapper: a fresh primary that cannot create its state dir says so on stderr, then stands down" +} + test_run_reports_a_failed_session_start_as_digest_text() { local root="$TMP_ROOT/run-unwritable" out status=0 make_run_primary "$root" @@ -1067,6 +1113,8 @@ test_run_resume_delegates_to_the_nudge test_run_reads_source_from_the_hook_payload test_run_unknown_source_takes_the_helm test_run_gate_and_scope_are_silent +test_run_creates_missing_state_on_a_fresh_primary +test_run_reports_a_state_dir_it_cannot_create test_run_reports_a_failed_session_start_as_digest_text test_pi_startup_classifies_cli_continuations test_pi_sessionstart_generation_prerequisite From 1f2c9548f4680efeda76fabe9f057b4021b97272 Mon Sep 17 00:00:00 2001 From: Christopher McKay <101884182+karotkriss@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:20:36 -0400 Subject: [PATCH 10/76] fix(bin): measure pending-reply grace from turn completion, not delivery (#6126) * fix(bin): measure pending-reply grace from turn completion, not delivery Fixes #6057 The pending-reply guard demanded a repost ("REPOST REQUIRED: previous marked request had no correlated parent report") while the second mate's correlated reply was already on its way. fm_pending_reply_send_recovery measured its grace window from delivery instead of from the request turn's completion, so any turn longer than the grace fired the demand the moment the turn ended, before the reply could have landed. The missed-report escalation had the same gap: it fired the instant the recovery turn's completion was observed, with no grace at all. Both now measure grace from the relevant turn's completion (request turn for the recovery repost, recovery turn for the escalation), and both take one fresh, uncached read of the parent status file immediately before firing, accepting a correlated line regardless of its verb. Transport-failure escalations stay immediate, and the one-repost limit is unchanged. * no-mistakes(review): Document grace window as measured from turn completion * no-mistakes(ci): Both Greptile findings were real and caused by this PR, so I fixed them. The full `tests/fm-pending-reply.test.sh` suite passes. **ci-1 (a reply could be overwritten by a repost).** The rule that must hold: a recovery send is recorded only if the record is still unresolved, checked under the same per-correlation lock that resolution uses. The escalation path already did this (`_fm_pending_reply_maybe_escalate_locked` reads fresh and publishes under one lock). The recovery path did not: `fm_pending_reply_send_recovery` did its fresh read through `fm_pending_reply_try_resolve`, which let go of the lock before the send was recorded. A reply landing in that gap could be overwritten, and the repost would go out anyway. Now `send_recovery` takes the lock once and, while holding it, re-checks that the phase is still `awaiting_report`, runs the fresh uncached read, and records the send (sender pid and identity, attempt time, phase `recovery_sending`). It releases the lock before actually sending, so the lock is not held during the send. It uses the same lock helpers the other lock wrappers use. Grace timing, the one-repost limit and the escalation path are unchanged. **ci-2 (the test would pass even without the fix).** In `test_recovery_fresh_status_read_resolves_before_firing`, the reply is still appended to the status file, but the stored file signature is then set to the file's new signature. That stands in for a same-size rewrite that the signature cache cannot see. The test first checks that a normal cached read misses the reply, then that the fresh read before sending catches it. I also added the same check for the fresh read before escalation, which the review said was uncovered. The test now sets its own send hook, so it no longer depends on one left over from an earlier test (that leftover had made failures exit silently). **Checks:** - I removed the fresh-read bypass at each site in turn and reran the suite. With it gone from recovery, the test fails with "recovery must not fire once a correlated reply has landed". With it gone from escalation, it fails with "the fresh pre-escalation read should have resolved the record, got escalated". With both in place, all tests pass. - Shellcheck with `-x` timed out locally. Without `-x` and ignoring SC1091, the only warnings are SC2034 on the existing `maybe_escalate` lock wrapper, which is not part of this change. The new code adds no warnings. Changes are in `bin/fm-pending-reply-lib.sh` and `tests/fm-pending-reply.test.sh`. Nothing is committed yet; a plain commit message such as "fix(bin): record the pending-reply recovery send under the fresh-read lock" fits the instruction * no-mistakes(ci): ci-1 was real and caused by this PR. The same bug was also in the escalation path, so both are fixed. The full tests/fm-pending-reply.test.sh suite passes. The rule that must hold: a recovery repost or an escalation goes out only if the record's phase, read after the fresh-read resolve, is still what it was before. The resolver writes phase=resolved first and only then writes the other resolution fields. If one of those later writes fails, it returns an error even though the record is already resolved. Places this rule applies, both fixed: - Recovery (fm_pending_reply_send_recovery): the fresh-read resolve now runs first, and the phase is re-read right after it, whatever it returned. The send is recorded and made only if the phase is still exactly awaiting_report. This replaces the earlier phase check rather than adding a second one. - Escalation (_fm_pending_reply_maybe_escalate_locked): same bug. After a failed resolve it went on to publish the blocked line and set phase=escalated. One added line after the resolve call returns 1 without publishing if the phase has changed. Test: added test_partial_resolve_write_blocks_firing. It forces a failure on the resolved_epoch write after a correlated reply has landed. It checks that the recovery send hook is never called, that no escalation line is published, and that the phase stays resolved. The forced failure runs in a subshell so it can't affect later tests. Checks: - With the recovery fix reverted, the new test fails with "recovery must not fire after a partial resolve". - With the escalation fix reverted, it fails with "partial resolve should block escalation, got escalated". - With both fixes in, every test passes. - Shellcheck was run with SC1091 excluded and without -x, not through the repo's lint script. The only new message is one SC2329 info on the test's override function; other test overrides in the same file already get that same info, unsuppressed. Changed files: bin/fm-pending-reply-lib.sh and tests/fm-pending-reply.test.sh. Nothing is committed. Suggested plain commit message: "fix(bin): recheck pending-reply phase after the fresh read before sending --- bin/fm-pending-reply-lib.sh | 64 +++++++-- docs/configuration.md | 2 +- tests/fm-pending-reply.test.sh | 245 ++++++++++++++++++++++++++++++++- 3 files changed, 295 insertions(+), 16 deletions(-) diff --git a/bin/fm-pending-reply-lib.sh b/bin/fm-pending-reply-lib.sh index ce5ba3349b0..e53b5a8c17c 100755 --- a/bin/fm-pending-reply-lib.sh +++ b/bin/fm-pending-reply-lib.sh @@ -64,7 +64,10 @@ # wrong_home_first_sighting= encoded path:line identity of the first sighting # wrong_home_sightings= comma-separated encoded path:line identities # wrong_home_scan_signature= -# grace_secs= bounded grace before recovery is eligible +# grace_secs= bounded grace before recovery, and before the +# missed-report escalation, are eligible - measured +# from the relevant turn's completion (request or +# recovery), never from delivery or send time # # Escalation lifecycle: an escalation is not just a message, it OPENS a durable # keyed decision in the parent status log, and bin/fm-classify-lib.sh's fold is @@ -99,7 +102,10 @@ # tests. No side effects on source. set -u / set -e safe. # # Tunables (env): -# FM_PENDING_REPLY_GRACE_SECS default 120 +# FM_PENDING_REPLY_GRACE_SECS default 120; counted from the request turn's +# completion for the recovery repost, and from +# the recovery turn's completion for the +# missed-report escalation - never from delivery # FM_PENDING_REPLY_DIR_OVERRIDE override the pending-replies directory (tests) # FM_PENDING_REPLY_SEND_HOOK optional command template for recovery delivery # (tests); receives task_id and full message as args @@ -931,7 +937,8 @@ fm_pending_reply_recovery_message() { # fm_pending_reply_send_recovery() { # local state=$1 corr=$2 local rec phase completed delivered attempted grace now age task_id msg parent_home send_status=0 - local sender_pid sender_identity + local sender_pid sender_identity status_file lock + local STATE FM_WAKE_QUEUE FM_WAKE_QUEUE_LOCK rec=$(fm_pending_reply_path "$state" "$corr") [ -f "$rec" ] || return 1 phase=$(fm_pending_reply_get "$rec" phase) @@ -948,19 +955,42 @@ fm_pending_reply_send_recovery() { # grace=$(fm_pending_reply_get "$rec" grace_secs) case "$grace" in ''|*[!0-9]*) grace=$(fm_pending_reply_grace_secs) ;; esac now=$(fm_pending_reply_now) - age=$((now - delivered)) + # Grace runs from the request turn's completion, not from delivery: delivery + # only proves the request arrived, while the turn's completion is the + # earliest moment a correlated report could exist to race against. + age=$((now - completed)) [ "$age" -ge "$grace" ] || return 1 task_id=$(fm_pending_reply_get "$rec" task_id) # A remote mate's report may exist and simply not have been mirrored yet. fm_pending_reply_missing_report_is_evidence "$state" "$task_id" "$completed" || return 1 + status_file=$(fm_pending_reply_get "$rec" parent_status) parent_home=$(fm_pending_reply_get "$rec" parent_home) msg=$(fm_pending_reply_recovery_message "$rec") sender_pid=${BASHPID:-$$} sender_identity=$(fm_pending_reply_pid_identity "$sender_pid") || return 1 - fm_pending_reply_set "$rec" recovery_sender_pid "$sender_pid" || return 1 - fm_pending_reply_set "$rec" recovery_sender_identity "$sender_identity" || return 1 - fm_pending_reply_set "$rec" recovery_attempted_epoch "$now" || return 1 - fm_pending_reply_set "$rec" phase recovery_sending || return 1 + # One fresh, uncached read immediately before firing, under the same + # per-correlation lock that records the send: a correlated report resolved + # in between can then never be overwritten by the repost. Lock globals are + # local for the reason fm_pending_reply_try_resolve documents. + STATE=$state + lock="$state/.pending-reply-$corr.lock" + # Deliberately undirected: bin/fm-wake-lib.sh is expanded once at the + # fm_pending_reply_try_resolve site; each directed site would re-expand its + # whole transitive graph under ShellCheck's external-source traversal. + . "$_FM_PENDING_REPLY_LIB_DIR/fm-wake-lib.sh" + # The phase is re-read after the resolve attempt, whatever it returned: a + # resolve that failed on a later field write has still committed resolved. + fm_lock_acquire_wait "$lock" || return 1 + if _fm_pending_reply_try_resolve_locked "$state" "$corr" "$status_file" \ + || [ "$(fm_pending_reply_get "$rec" phase)" != awaiting_report ] \ + || ! fm_pending_reply_set "$rec" recovery_sender_pid "$sender_pid" \ + || ! fm_pending_reply_set "$rec" recovery_sender_identity "$sender_identity" \ + || ! fm_pending_reply_set "$rec" recovery_attempted_epoch "$now" \ + || ! fm_pending_reply_set "$rec" phase recovery_sending; then + fm_lock_release "$lock" + return 1 + fi + fm_lock_release "$lock" if [ -n "${FM_PENDING_REPLY_SEND_HOOK:-}" ]; then # Hook receives: task_id message # shellcheck disable=SC2086 @@ -1220,7 +1250,7 @@ fm_pending_reply_maybe_escalate() { # _fm_pending_reply_maybe_escalate_locked() { # local state=$1 corr=$2 local rec phase completed now payload parent_status line kind first display - local delivered task_id meta sm_home remote_host + local delivered task_id meta sm_home remote_host grace age rec=$(fm_pending_reply_path "$state" "$corr") [ -f "$rec" ] || return 1 phase=$(fm_pending_reply_get "$rec" phase) @@ -1233,6 +1263,13 @@ _fm_pending_reply_maybe_escalate_locked() { # recovery_sent) completed=$(fm_pending_reply_get "$rec" recovery_turn_completed_epoch) [ -n "$completed" ] || return 1 + # Grace runs from the recovery turn's completion, the same anchor the + # recovery repost itself uses (never from delivery or send time). + grace=$(fm_pending_reply_get "$rec" grace_secs) + case "$grace" in ''|*[!0-9]*) grace=$(fm_pending_reply_grace_secs) ;; esac + now=$(fm_pending_reply_now) + age=$((now - completed)) + [ "$age" -ge "$grace" ] || return 1 # Same reply-channel evidence rule the recovery repost obeys: a missing # correlated report is not a missed report until the mirror caught up. fm_pending_reply_missing_report_is_evidence "$state" \ @@ -1252,11 +1289,14 @@ _fm_pending_reply_maybe_escalate_locked() { # fm_pending_reply_restatement_copy_same_basename "$state" "$corr" "$sm_home" || true fi fi - # Resolve wins if a late report arrived between completion and this call. - if _fm_pending_reply_try_resolve_locked "$state" "$corr"; then + parent_status=$(fm_pending_reply_get "$rec" parent_status) + # One fresh, uncached read immediately before firing: a correlated report can + # land in the instant between the last resolve attempt and this call. + if _fm_pending_reply_try_resolve_locked "$state" "$corr" "$parent_status"; then return 0 fi - parent_status=$(fm_pending_reply_get "$rec" parent_status) + # A resolve that failed on a later field write has still committed resolved. + [ "$(fm_pending_reply_get "$rec" phase)" = "$phase" ] || return 1 case "$phase" in delivery_unknown) kind=delivery-unknown ;; recovery_failed|recovery_unknown) kind='recovery-delivery' ;; diff --git a/docs/configuration.md b/docs/configuration.md index 5a4bbb25972..e3a0e1e3961 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2379,7 +2379,7 @@ GROK_HOME= # optional Grok config home for firstmate's global grok FM_SEND_RETRIES=3 # fm-send typed-plane Enter-retry attempts after typing the line once; agy typed targets use a longer per-harness default owned by bin/fm-send.sh FM_SEND_SLEEP=0.4 # seconds between fm-send typed-plane submit checks FM_SEND_SETTLE=1 # seconds fm-send waits after a successful typed-plane submit; 0 disables -FM_PENDING_REPLY_GRACE_SECS=120 # seconds after marked-request delivery before a completed turn without a correlated parent report is eligible for its one recovery repost +FM_PENDING_REPLY_GRACE_SECS=120 # seconds after the request turn completes without a correlated parent report before its one recovery repost is eligible, and after the recovery turn completes before the missed-report escalation is eligible; never counted from delivery # sub-supervisor (bin/fm-supervise-daemon.sh); presence-gated via /afk FM_SUPERVISOR_BACKEND= # optional supervisor pane backend override; tmux/herdr only, otherwise detects $TMUX_PANE then HERDR_ENV/HERDR_PANE_ID before tmux fallback FM_SUPERVISOR_TARGET= # optional supervisor pane target override; tmux target or herdr :, otherwise auto-detected diff --git a/tests/fm-pending-reply.test.sh b/tests/fm-pending-reply.test.sh index 1a14b49a658..350741fca2a 100755 --- a/tests/fm-pending-reply.test.sh +++ b/tests/fm-pending-reply.test.sh @@ -29,6 +29,9 @@ # 15. Remote parent-replies.status is not classified as wrong-home # 16. An escalated correlation stays retryable while undelivered, is never reset # once delivered, and its delivery-unknown decision still closes on resolve +# 17. Recovery and escalation grace are measured from the relevant turn's +# completion, never from delivery or send time, and each takes one fresh, +# uncached status read - accepting any verb - immediately before firing set -u # shellcheck source=tests/lib.sh @@ -198,6 +201,225 @@ test_completed_turn_no_report_triggers_one_recovery() { pass "completed turn with no report triggers exactly one recovery" } +test_recovery_grace_measures_from_turn_completion() { + local home state corr hook_log lines + home=$(setup_parent grace-from-completion) + state="$home/state" + hook_log="$TMP_ROOT/grace-from-completion.log" + : > "$hook_log" + # Invoked indirectly through FM_PENDING_REPLY_SEND_HOOK. + # shellcheck disable=SC2329 + recovery_hook() { printf '%s\n' ok >> "$hook_log"; } + export -f recovery_hook + export FM_PENDING_REPLY_SEND_HOOK='recovery_hook' + export FM_PENDING_REPLY_GRACE_SECS=120 + + export FM_PENDING_REPLY_NOW=20000 + corr=$(fm_pending_reply_create "$home" "$state" "hibit" "long turn then missed report") + fm_pending_reply_mark_delivered "$state" "$corr" + fm_pending_reply_observe_busy "$state" "$corr" busy + # The request turn runs long: it completes 300s after delivery, well past + # the 120s grace if grace were still measured from delivery. + export FM_PENDING_REPLY_NOW=20300 + fm_pending_reply_observe_busy "$state" "$corr" idle + [ "$(fm_pending_reply_get "$(fm_pending_reply_path "$state" "$corr")" request_turn_completed_epoch)" = 20300 ] \ + || fail "setup: turn should complete at 20300" + + # One second after the turn completed: grace has not elapsed from that + # completion (age 1), even though it long ago elapsed from delivery (age + # 301). On the tip this fires immediately because grace is measured from + # delivery. + export FM_PENDING_REPLY_NOW=20301 + if fm_pending_reply_send_recovery "$state" "$corr" 2>/dev/null; then + fail "recovery must not fire before grace elapses from the turn's completion" + fi + [ ! -s "$hook_log" ] || fail "recovery must not have sent before completion grace elapsed" + [ "$(phase_of "$state" "$corr")" = awaiting_report ] \ + || fail "phase must stay awaiting_report before completion grace elapsed" + + # 121s after completion: grace has now elapsed from the turn's completion. + export FM_PENDING_REPLY_NOW=20421 + fm_pending_reply_send_recovery "$state" "$corr" \ + || fail "recovery should fire once grace elapses from the turn's completion" + lines=$(wc -l < "$hook_log" | tr -d ' ') + [ "$lines" = 1 ] || fail "expected exactly one recovery send, got $lines" + [ "$(phase_of "$state" "$corr")" = recovery_sent ] \ + || fail "phase should be recovery_sent, got $(phase_of "$state" "$corr")" + + export FM_PENDING_REPLY_GRACE_SECS=0 + pass "recovery grace is measured from the request turn's completion, not delivery" +} + +test_recovery_fresh_status_read_resolves_before_firing() { + local home state corr status rec + home=$(setup_parent fresh-read-before-fire) + state="$home/state" + status="$state/hibit.status" + export FM_PENDING_REPLY_SEND_HOOK=true + export FM_PENDING_REPLY_GRACE_SECS=120 + export FM_PENDING_REPLY_NOW=30000 + corr=$(fm_pending_reply_create "$home" "$state" "hibit" "reply lands just before the demand fires") + fm_pending_reply_mark_delivered "$state" "$corr" + fm_pending_reply_observe_busy "$state" "$corr" busy + export FM_PENDING_REPLY_NOW=30300 + fm_pending_reply_observe_busy "$state" "$corr" idle + + # An earlier resolve attempt with nothing to find caches the current status + # file's scan signature. + if fm_pending_reply_try_resolve "$state" "$corr"; then + fail "setup: nothing should resolve yet" + fi + + # The correlated reply lands, carrying a non-terminal verb, in a write the + # cached signature cannot see (for example a same-size rewrite inside the + # stat timestamp granularity): the cache now matches the file that holds it, + # so only a read that bypasses the cache can find the reply. + rec=$(fm_pending_reply_path "$state" "$corr") + printf 'working [corr=%s]: still wrapping up\n' "$corr" >> "$status" + fm_pending_reply_set "$rec" parent_status_scan_signature "$(fm_pending_reply_file_signature "$status")" + if fm_pending_reply_try_resolve "$state" "$corr"; then + fail "setup: the cached signature should hide the reply from a cached read" + fi + + # Grace has elapsed from the turn's completion, so the demand is otherwise + # eligible to fire; its own fresh, uncached read must catch the reply first. + export FM_PENDING_REPLY_NOW=30421 + if fm_pending_reply_send_recovery "$state" "$corr" 2>/dev/null; then + fail "recovery must not fire once a correlated reply has landed" + fi + [ "$(phase_of "$state" "$corr")" = resolved ] \ + || fail "the fresh pre-fire read should have resolved the record, got $(phase_of "$state" "$corr")" + [ "$(fm_pending_reply_get "$rec" resolved_via)" = status ] \ + || fail "resolved_via should be status" + + # The missed-report escalation takes the same fresh read before firing. + export FM_PENDING_REPLY_NOW=31000 + corr=$(fm_pending_reply_create "$home" "$state" "hibit" "reply lands just before the escalation fires") + rec=$(fm_pending_reply_path "$state" "$corr") + fm_pending_reply_mark_delivered "$state" "$corr" + fm_pending_reply_mark_turn_completed "$state" "$corr" request + export FM_PENDING_REPLY_NOW=31120 + fm_pending_reply_send_recovery "$state" "$corr" || fail "setup: recovery send failed" + fm_pending_reply_mark_turn_completed "$state" "$corr" recovery + printf 'working [corr=%s]: still wrapping up\n' "$corr" >> "$status" + fm_pending_reply_set "$rec" parent_status_scan_signature "$(fm_pending_reply_file_signature "$status")" + export FM_PENDING_REPLY_NOW=31240 + fm_pending_reply_maybe_escalate "$state" "$corr" 2>/dev/null \ + || fail "the escalation's fresh read should resolve the record" + [ "$(phase_of "$state" "$corr")" = resolved ] \ + || fail "the fresh pre-escalation read should have resolved the record, got $(phase_of "$state" "$corr")" + if grep -qF "blocked [key=pending-reply-$corr]" "$status"; then + fail "escalation must not publish once a correlated reply has landed" + fi + + unset FM_PENDING_REPLY_SEND_HOOK + export FM_PENDING_REPLY_GRACE_SECS=0 + pass "one fresh status read immediately before firing catches a just-landed reply, any verb" +} + +test_partial_resolve_write_blocks_firing() { + local home state status hook_log + home=$(setup_parent partial-resolve-write) + state="$home/state" + status="$state/hibit.status" + hook_log="$TMP_ROOT/partial-resolve-write.log" + : > "$hook_log" + # A resolve that commits phase=resolved and then fails a later field write + # must still stop the repost and the escalation. Run in a subshell so the + # injected write failure cannot leak into later tests. + ( + # Invoked indirectly through FM_PENDING_REPLY_SEND_HOOK. + # shellcheck disable=SC2329 + recovery_hook() { printf '%s\n' sent >> "$hook_log"; } + eval "_orig_$(declare -f fm_pending_reply_set)" + fm_pending_reply_set() { + [ "$2" != resolved_epoch ] || [ "${FAIL_RESOLVED_EPOCH:-0}" != 1 ] || return 1 + _orig_fm_pending_reply_set "$@" + } + + corr=$(fm_pending_reply_create "$home" "$state" "hibit" "partial resolve before recovery") + fm_pending_reply_mark_delivered "$state" "$corr" + fm_pending_reply_mark_turn_completed "$state" "$corr" request + printf 'working [corr=%s]: still wrapping up\n' "$corr" >> "$status" + if FAIL_RESOLVED_EPOCH=1 FM_PENDING_REPLY_SEND_HOOK=recovery_hook fm_pending_reply_send_recovery "$state" "$corr" 2>/dev/null; then + fail "recovery must not fire after a partial resolve" + fi + [ "$(phase_of "$state" "$corr")" = resolved ] \ + || fail "partial resolve should leave phase resolved, got $(phase_of "$state" "$corr")" + [ ! -s "$hook_log" ] || fail "recovery was sent after a partial resolve" + + corr=$(fm_pending_reply_create "$home" "$state" "hibit" "partial resolve before escalation") + fm_pending_reply_mark_delivered "$state" "$corr" + fm_pending_reply_mark_turn_completed "$state" "$corr" request + FM_PENDING_REPLY_SEND_HOOK=true fm_pending_reply_send_recovery "$state" "$corr" \ + || fail "setup: recovery send failed" + fm_pending_reply_mark_turn_completed "$state" "$corr" recovery + printf 'working [corr=%s]: still wrapping up\n' "$corr" >> "$status" + FAIL_RESOLVED_EPOCH=1 fm_pending_reply_maybe_escalate "$state" "$corr" 2>/dev/null || true + [ "$(phase_of "$state" "$corr")" = resolved ] \ + || fail "partial resolve should block escalation, got $(phase_of "$state" "$corr")" + if grep -qF "blocked [key=pending-reply-$corr]" "$status"; then + fail "escalation must not publish after a partial resolve" + fi + ) || exit 1 + pass "a resolve that fails after committing resolved still blocks repost and escalation" +} + +test_escalation_grace_measures_from_recovery_turn_completion() { + local home state corr hook_log status_line escalations + home=$(setup_parent escalation-grace-from-completion) + state="$home/state" + hook_log="$TMP_ROOT/escalation-grace-from-completion.log" + : > "$hook_log" + # Invoked indirectly through FM_PENDING_REPLY_SEND_HOOK. + # shellcheck disable=SC2329 + recovery_hook() { printf '%s\n' ok >> "$hook_log"; } + export -f recovery_hook + export FM_PENDING_REPLY_SEND_HOOK='recovery_hook' + export FM_PENDING_REPLY_GRACE_SECS=120 + + export FM_PENDING_REPLY_NOW=40000 + corr=$(fm_pending_reply_create "$home" "$state" "hibit" "recovery also runs long") + fm_pending_reply_mark_delivered "$state" "$corr" + fm_pending_reply_mark_turn_completed "$state" "$corr" request + export FM_PENDING_REPLY_NOW=40120 + fm_pending_reply_send_recovery "$state" "$corr" || fail "recovery send failed" + [ "$(phase_of "$state" "$corr")" = recovery_sent ] || fail "phase should be recovery_sent" + + # The recovery turn also runs long: it completes 300s after the recovery + # was sent. + export FM_PENDING_REPLY_NOW=40420 + fm_pending_reply_mark_turn_completed "$state" "$corr" recovery + + # One second after the recovery turn completed: grace has not elapsed from + # that completion. On the tip nothing gates this at all, so escalation + # fires the instant completion is observed. + export FM_PENDING_REPLY_NOW=40421 + if fm_pending_reply_maybe_escalate "$state" "$corr" 2>/dev/null; then + fail "escalation must not fire before grace elapses from the recovery turn's completion" + fi + [ "$(phase_of "$state" "$corr")" = recovery_sent ] \ + || fail "phase must stay recovery_sent before escalation grace elapsed" + if grep -qF 'pending-reply-missed' "$state/hibit.status" 2>/dev/null; then + fail "escalation must not have published before grace elapsed" + fi + + # 121s after the recovery turn completed: grace has now elapsed. + export FM_PENDING_REPLY_NOW=40541 + fm_pending_reply_maybe_escalate "$state" "$corr" || fail "escalation should fire once grace elapses" + [ "$(phase_of "$state" "$corr")" = escalated ] || fail "phase should be escalated" + status_line=$(tail -1 "$state/hibit.status") + case "$status_line" in + "blocked [key=pending-reply-$corr]"*pending-reply-missed:*pending-reply-id=$corr*) : ;; + *) fail "parent status should carry one blocked missed-report line"$'\n'"$status_line" ;; + esac + escalations=$(grep -Fc "blocked [key=pending-reply-$corr]" "$state/hibit.status") + [ "$escalations" = 1 ] || fail "missed recovery should publish exactly one escalation, got $escalations" + + export FM_PENDING_REPLY_GRACE_SECS=0 + pass "missed-report escalation grace is measured from the recovery turn's completion" +} + test_recovery_attempt_is_never_reinjected() { local home state corr rec hook_log lines live_corr live_rec live_pid live_identity home=$(setup_parent recovery-at-most-once) @@ -969,14 +1191,27 @@ test_unknown_backend_state_uses_capture_fallback() { # shellcheck disable=SC2030,SC2031 export FM_PENDING_REPLY_NOW=10010 fm_pending_reply_tick "$state" + [ "$(fm_pending_reply_get "$rec" request_turn_completed_epoch)" = 10010 ] \ + || fail "$backend fallback idle past grace should complete the request turn" + [ "$(phase_of "$state" "$corr")" = awaiting_report ] \ + || fail "$backend recovery must wait a fresh grace period after the turn completes, not fire the moment it completes" + # Recovery grace runs from that completion, not from delivery: only once + # a further grace period has elapsed does the repost fire. + export FM_PENDING_REPLY_NOW=10020 + fm_pending_reply_tick "$state" [ "$(phase_of "$state" "$corr")" = recovery_sent ] \ - || fail "$backend fallback idle should trigger recovery after grace" - export FM_PENDING_REPLY_NOW=10011 + || fail "$backend fallback idle should trigger recovery after its own grace period" + export FM_PENDING_REPLY_NOW=10021 export FM_PENDING_TEST_CAPTURE='Working...' fm_pending_reply_tick "$state" - export FM_PENDING_REPLY_NOW=10012 + export FM_PENDING_REPLY_NOW=10022 export FM_PENDING_TEST_CAPTURE='idle footer' fm_pending_reply_tick "$state" + [ "$(phase_of "$state" "$corr")" = recovery_sent ] \ + || fail "$backend escalation must wait a fresh grace period after the recovery turn completes, not fire the moment it completes" + # Escalation grace runs from the recovery turn's own completion. + export FM_PENDING_REPLY_NOW=10032 + fm_pending_reply_tick "$state" [ "$(phase_of "$state" "$corr")" = escalated ] \ || fail "$backend capture busy-to-idle should complete recovery turn" ) || fail "$backend unknown-state capture fallback failed" @@ -1692,6 +1927,10 @@ test_escalated_undelivered_correlation_stays_retryable() { test_normal_correlated_reply_resolves_once test_completed_turn_no_report_triggers_one_recovery +test_recovery_grace_measures_from_turn_completion +test_recovery_fresh_status_read_resolves_before_firing +test_partial_resolve_write_blocks_firing +test_escalation_grace_measures_from_recovery_turn_completion test_recovery_attempt_is_never_reinjected test_recovery_reply_resolves_original test_second_missed_turn_escalates_once_and_stays_durable From a774c44869dd35b6ca406ec291912e699973109c Mon Sep 17 00:00:00 2001 From: Tiago Date: Tue, 29 Sep 2026 19:20:46 -0300 Subject: [PATCH 11/76] fix(bin): stop provider-table lookup from writing broken-pipe errors to stderr (#6001) * fix: provider-table lookup never writes a broken-pipe error to stderr Fixes #5956 fm_quota_single_provider_for_harness returned from its while read loop as soon as it found a match, closing the pipe while fm_quota_single_provider_table's printf could still be writing. Where SIGPIPE is ignored, as on GitHub Actions runners, bash then prints "printf: write error: Broken pipe" on the resolver's stderr, which intermittently broke the one-diagnostic-line assertions in tests/fm-dispatch-resolve.test.sh. Read the whole table before answering, the way fm_control_harness_supported already does, so the writer always finishes. Return values and output are unchanged. Reproduced by running tests/fm-dispatch-resolve.test.sh with SIGPIPE ignored on a single pinned core under CPU contention: 30 of 30 runs failed before the fix, 0 of 30 after. Note: reproducing requires setting the trap inside the tested shell because nice(1) resets an inherited SIGPIPE ignore to SIG_DFL. tests/fm-quota-choose.test.sh passes and bin/fm-lint.sh is clean. * no-mistakes(ci): Fixed both Greptile findings the user chose to address. ci-1 (bin/fm-quota-axi-lib.sh:154). Invariant: looking up a harness must always end with status 0 and print the provider, even when the caller runs under `set -e`. The loop body `[ -z "$found" ] && [ "$harness" = "$1" ] && found=$provider` now ends in `|| :`. Every iteration succeeds and the whole table is still read. Only `fm_quota_single_provider_for_harness` loops over the table this way, so this is the one place the fix was needed. One caveat: on bash 5.3 the old code did not actually exit under `set -e`, because the `while` loop is not the function's last command, so the new `set -e` test would have passed before this fix too. The change makes the loop's success explicit, as the user asked. ci-2 (regression coverage). I added three cases to the existing `tests/fm-quota-choose.test.sh`, all calling the public lookup function after sourcing the library: 1. With SIGPIPE ignored (`trap "" PIPE`), it looks up every harness 200 times and checks that nothing reaches stderr. 2. A deterministic version of the race: the table function is wrapped so it writes the first row, pauses 0.2 s, then writes the rest. With SIGPIPE ignored, it checks that looking up `claude` prints `claude` and writes nothing to stderr. The stress loop alone reproduced the bug in only about 1 of 5 local runs, which is why this case exists. 3. A direct call under `set -e` prints `claude`. Verification: - `bash tests/fm-quota-choose.test.sh`: all pass. - Same test against the pre-PR library (fa48367, via `FM_ROOT_OVERRIDE`): fails with `printf: write error: Broken pipe`. The deterministic case failed in one run and the stress loop caught it in another. - `shellcheck` on both files: clean. - `tests/fm-dispatch-resolve.test.sh`: passes --- bin/fm-quota-axi-lib.sh | 13 +++++++------ tests/fm-quota-choose.test.sh | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/bin/fm-quota-axi-lib.sh b/bin/fm-quota-axi-lib.sh index 7162d89c82a..9418d01b4b1 100644 --- a/bin/fm-quota-axi-lib.sh +++ b/bin/fm-quota-axi-lib.sh @@ -145,15 +145,16 @@ fm_quota_single_provider_table() { 'muse meta' } +# Reads the whole table before answering: leaving the loop early closes the +# pipe mid-write, and where SIGPIPE is ignored the writer prints a broken-pipe +# error on stderr. fm_quota_single_provider_for_harness() { - local harness provider + local harness provider found='' while read -r harness provider; do - if [ "$harness" = "$1" ]; then - printf '%s\n' "$provider" - return 0 - fi + [ -z "$found" ] && [ "$harness" = "$1" ] && found=$provider || : done < <(fm_quota_single_provider_table) - return 1 + [ -n "$found" ] || return 1 + printf '%s\n' "$found" } fm_quota_provider_for_harness() { diff --git a/tests/fm-quota-choose.test.sh b/tests/fm-quota-choose.test.sh index 58ff190e137..b7fc872ea4f 100755 --- a/tests/fm-quota-choose.test.sh +++ b/tests/fm-quota-choose.test.sh @@ -732,4 +732,38 @@ ok "schema 6 TOON with the accountKey column is accepted" [ "$(wc -l < "$CALLS" | tr -d '[:space:]')" = 1 ] || fail "helper took an additional quota snapshot" ok "helper reuses the captured quota snapshot" +lookup_err=$(bash -c ' + trap "" PIPE + . "$1/fm-quota-axi-lib.sh" + for _ in $(seq 200); do + for harness in claude codex grok kimi cursor agy muse; do + fm_quota_single_provider_for_harness "$harness" >/dev/null + done + done +' _ "$BIN" 2>&1 >/dev/null) +[ -z "$lookup_err" ] || fail "provider-table lookup wrote to stderr with SIGPIPE ignored: $lookup_err" +ok "provider-table lookup writes nothing to stderr when SIGPIPE is ignored" + +# Pausing the table writer after its first row makes the race deterministic: +# a lookup that stops reading at the claude row closes the pipe before the rest +# of the table is written. +lookup_err=$(bash -c ' + trap "" PIPE + . "$1/fm-quota-axi-lib.sh" + table=$(fm_quota_single_provider_table) + fm_quota_single_provider_table() { + sed -n 1p <<<"$table" + sleep 0.2 + sed 1d <<<"$table" + } + [ "$(fm_quota_single_provider_for_harness claude)" = claude ] || echo "lookup did not print claude" +' _ "$BIN" 2>&1) +[ -z "$lookup_err" ] || fail "provider-table lookup with a slow table writer wrote to stderr: $lookup_err" +ok "provider-table lookup reads the whole table before answering" + +out=$(bash -c 'set -e; . "$1/fm-quota-axi-lib.sh"; fm_quota_single_provider_for_harness claude' _ "$BIN") \ + || fail "provider-table lookup exited nonzero under set -e" +[ "$out" = claude ] || fail "provider-table lookup under set -e printed: $out" +ok "provider-table lookup prints the provider when called directly under set -e" + printf '# all fm-quota-choose tests passed\n' From e2668de04ab3670ea50c1f9ad5fd66de8e01be90 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:09:07 -0700 Subject: [PATCH 12/76] fix: restore portable CI behavior across Pi rendering and remote provisioning (#6162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: survive Pi 0.99 rendering and Git 2.55 local-clone races Pi 0.99 puts arguments on the stock tool header and leaves hidden custom messages in the export conversation column. Match that header, and keep Calm's boundary on the visible column. Clone a remote home with --no-local so a prune during Git's loose-object copy cannot fail the seed. * no-mistakes(review): Stop SIGPIPE write errors; cover older Pi export and project clones * no-mistakes(document): Clarify Calm export visibility and tool rendering * no-mistakes(ci): Fixed the dispatch diagnostic to list every provider-less use/default profile in one line and added a multi-profile behavior test. Shortened supervision fixtures using the existing engine-grace and park-clock knobs; removed stray scratch files. Dispatch tests, syntax checks, and three targeted supervision cases passed. CI’s prior supervision duration was 751s; the single permitted local full-suite run timed out at 1200s, so an after-duration is not established. The cancelled serial check had no failure verdict. The outer executor should record the measured before/after duration in the PR body when available * no-mistakes(review): Gate Pi 0.99 call headers by version; drop hidden-row assertion * no-mistakes(review): Test stock call headers under Pi 0.87 and 0.99 stubs * no-mistakes(test): Fix older-Pi queued-row test and verify park-boundary behavior * no-mistakes(document): Clarify Pi Calm export and queued-turn documentation * no-mistakes(ci): Fixed the stock macOS Bash 3.2 parse failure in tests/fm-calm-pi-extension.test.sh; its parse check passes. The watcher CI failure is in unchanged code: the isolated five-minute/66-minute case passes locally, but the CI log omits the drain error needed to establish its cause. No speculative watcher fix was made. The full local watcher suite timed out after 500 seconds --- .pi/extensions/fm-branch-supervision.ts | 44 ++++++++++- bin/fm-brief-heading-lib.sh | 4 +- bin/fm-dispatch-resolve.sh | 8 +- bin/fm-remote-home-provision.sh | 8 +- docs/calm-mode-feasibility.md | 9 ++- docs/calm.md | 8 +- tests/fm-calm-pi-extension.test.sh | 99 ++++++++++++++++++++++--- tests/fm-dispatch-resolve.test.sh | 5 ++ tests/fm-pi-branch-extension.test.sh | 83 +++++++++++++++++++++ tests/fm-supervision-host.test.sh | 9 ++- 10 files changed, 244 insertions(+), 33 deletions(-) diff --git a/.pi/extensions/fm-branch-supervision.ts b/.pi/extensions/fm-branch-supervision.ts index f8a5136012a..25f2e31d924 100644 --- a/.pi/extensions/fm-branch-supervision.ts +++ b/.pi/extensions/fm-branch-supervision.ts @@ -94,6 +94,7 @@ import { type ModelRegistry, SessionManager, ToolExecutionComponent, + VERSION, type AgentSession, type ExtensionAPI, type ExtensionCommandContext, @@ -2174,6 +2175,41 @@ ${context.command} return shell; }; + // Pi's stock call header (formatToolCallWithArgs) is not a public export. + // Before Pi 0.99 it is the bold title alone. Since Pi 0.99 a collapsed call + // is `title key=json` on the title line, cut at 100 characters, and an + // expanded call puts one muted `key: value` line under the title. Calm-off + // rendering has to match the installed Pi or the stock comparison fails. + // Keep this in step with that function. + const [stockMajor = 0, stockMinor = 0] = VERSION.split(".").map((part) => Number.parseInt(part, 10) || 0); + const stockCallHeaderShowsArgs = stockMajor > 0 || stockMinor >= 99; + const stockCollapsedArgsChars = 100; + const stockToolCallHeader = ( + title: string, + args: unknown, + theme: Parameters>[1], + expanded: boolean, + ): string => { + const header = theme.fg("toolTitle", theme.bold(title)); + if (!stockCallHeaderShowsArgs || args == null) return header; + const entries = typeof args === "object" && !Array.isArray(args) + ? Object.entries(args) + : [["args", args] as [string, unknown]]; + if (entries.length === 0) return header; + if (expanded) { + const lines = entries.map(([key, value]) => { + const text = typeof value === "string" ? value : (JSON.stringify(value, null, 2) ?? String(value)); + return ` ${key}: ${text.replace(/\t/g, " ").replace(/\r/g, "").split("\n").join("\n ")}`; + }); + return `${header}\n${theme.fg("muted", lines.join("\n"))}`; + } + const pairs = entries.map(([key, value]) => `${key}=${JSON.stringify(value) ?? String(value)}`).join(" "); + const preview = pairs.length > stockCollapsedArgsChars + ? `${pairs.slice(0, stockCollapsedArgsChars - 3)}...` + : pairs; + return `${header} ${theme.fg("muted", preview)}`; + }; + registerFirstmateTool(pi, { name: "fm_branch_outcomes", label: "Read supervision branch outcomes", @@ -2184,11 +2220,11 @@ ${context.command} recent: Type.Optional(Type.Number({ description: "How many most-recent outcomes to read (default 20)" })), }), renderShell: "self", - renderCall: (_args, theme, context) => { + renderCall: (args, theme, context) => { if (calmPresentation.stockExportRendering) throw new Error("Use Pi stock export rendering"); if (calmHides("assistant-tool-call")) return new Container(); const shellState = context.state as OutcomesToolShellState; - shellState.call = new Text(theme.fg("toolTitle", theme.bold("fm_branch_outcomes")), 0, 0); + shellState.call = new Text(stockToolCallHeader("fm_branch_outcomes", args, theme, context.expanded), 0, 0); return refreshOutcomesToolShell(shellState, theme, context); }, renderResult: (result, options, theme, context) => { @@ -2246,11 +2282,11 @@ ${context.command} through: Type.Number({ description: "The highest outcome sequence number this conversation has processed" }), }), renderShell: "self", - renderCall: (_args, theme, context) => { + renderCall: (args, theme, context) => { if (calmPresentation.stockExportRendering) throw new Error("Use Pi stock export rendering"); if (calmHides("assistant-tool-call")) return new Container(); const shellState = context.state as OutcomesToolShellState; - shellState.call = new Text(theme.fg("toolTitle", theme.bold("fm_branch_processed")), 0, 0); + shellState.call = new Text(stockToolCallHeader("fm_branch_processed", args, theme, context.expanded), 0, 0); return refreshOutcomesToolShell(shellState, theme, context); }, renderResult: (result, _options, theme, context) => { diff --git a/bin/fm-brief-heading-lib.sh b/bin/fm-brief-heading-lib.sh index affd4b365f5..2202f617280 100644 --- a/bin/fm-brief-heading-lib.sh +++ b/bin/fm-brief-heading-lib.sh @@ -84,12 +84,12 @@ fm_brief_heading_present() { # fm_brief_task_heading_body() { # local task task=$(fm_brief_heading_body "$1" "# Task") - printf '%s\n' "$task" | fm_brief_heading_parse - "$2" body + fm_brief_heading_parse - "$2" body <<<"$task" } fm_brief_task_heading_present() { # local task task=$(fm_brief_heading_body "$1" "# Task") - printf '%s\n' "$task" | fm_brief_heading_parse - "$2" present >/dev/null + fm_brief_heading_parse - "$2" present >/dev/null <<<"$task" } diff --git a/bin/fm-dispatch-resolve.sh b/bin/fm-dispatch-resolve.sh index 28603f666dd..6de1e89ba8a 100755 --- a/bin/fm-dispatch-resolve.sh +++ b/bin/fm-dispatch-resolve.sh @@ -207,12 +207,14 @@ missing_provider=$(jq -r ' ' "$RULES" | while IFS=$'\t' read -r location harness; do if ! fm_quota_single_provider_for_harness "$harness" >/dev/null; then printf '%s\t%s\n' "$location" "$harness" - break fi done) if [ -n "$missing_provider" ]; then - IFS=$'\t' read -r location harness <<< "$missing_provider" - die "malformed rules file: $RULES_PATH - $location profiles whose harness lacks one authoritative provider family require provider: $harness" + missing_provider_detail='' + while IFS=$'\t' read -r location harness; do + missing_provider_detail="${missing_provider_detail:+$missing_provider_detail; }$location profiles whose harness lacks one authoritative provider family require provider: $harness" + done <<< "$missing_provider" + die "malformed rules file: $RULES_PATH - $missing_provider_detail" fi # ---- harness -> provider map, from the single owner in fm-quota-axi-lib.sh ----- diff --git a/bin/fm-remote-home-provision.sh b/bin/fm-remote-home-provision.sh index 3553c17dc16..15ad749cacc 100755 --- a/bin/fm-remote-home-provision.sh +++ b/bin/fm-remote-home-provision.sh @@ -184,7 +184,11 @@ else # inside it instead of publishing, so rollback must remove only that stage. STAGE_HOME=$(mktemp -d "$HOME_PARENT/.fm-home-provisioning.XXXXXX") \ || die "cannot create remote home staging directory" - git clone --quiet -- "$FM_ROOT" "$STAGE_HOME" || die "could not clone the remote Firstmate home" + # A local clone copies loose objects into the new repo. Git 2.55 on the CI + # image does that copy before the destination shard directory exists, so the + # clone dies intermittently with "failed to copy file to .../objects/xx/hash". + # --no-local uses the normal transport and writes a pack instead. + git clone --no-local --quiet -- "$FM_ROOT" "$STAGE_HOME" || die "could not clone the remote Firstmate home" STAGE_SENTINEL="${STAGE_HOME##*/}.owner" : > "$STAGE_HOME/$STAGE_SENTINEL" || die "cannot mark the remote home staging directory" mv -- "$STAGE_HOME" "$FM_HOME" || die "cannot install the remote home" @@ -249,7 +253,7 @@ EOF [ "$EXISTING_ORIGIN" = "$ORIGIN" ] || die "project $NAME origin differs from the requested route" else printf '%s\n' "$NAME" >> "$CREATED_PROJECTS" - git clone --quiet -- "$ORIGIN" "$DEST" || die "could not clone project $NAME on the remote host" + git clone --no-local --quiet -- "$ORIGIN" "$DEST" || die "could not clone project $NAME on the remote host" if [ "$MODE" = no-mistakes ]; then command -v no-mistakes >/dev/null 2>&1 || die "no-mistakes is unavailable for project $NAME" (cd "$DEST" && no-mistakes init >/dev/null && no-mistakes doctor >/dev/null) \ diff --git a/docs/calm-mode-feasibility.md b/docs/calm-mode-feasibility.md index 1596dcaa954..89e84796271 100644 --- a/docs/calm-mode-feasibility.md +++ b/docs/calm-mode-feasibility.md @@ -207,8 +207,8 @@ Every tool registered or supplied by Firstmate under `.pi/extensions` has this d | --- | --- | --- | | `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls` | Calm wrappers for Pi's seven main-session built-ins | Their call and text-result shells hide while Calm is active; ordinary and stock export rendering delegate to Pi's original renderers. | | `fm_watch_arm_pi` | Main-session custom tool in `fm-primary-pi-watch.ts` | Its complete self-rendered shell hides while Calm is active and returns unchanged when Calm is off or stock export rendering is active. | -| `fm_branch_outcomes` | Main-session custom tool in `fm-branch-supervision.ts` | Its complete self-rendered shell hides while Calm is active; when visible, the self-renderer reconstructs Pi's ordinary boxed fallback shell and probes Pi's rendered stock fallback to preserve that installed surface's collapsed or all-line output policy plus expanded state, while stock export rendering deliberately falls through to Pi's structured fallback. | -| `fm_branch_processed` | Main-session custom tool in `fm-branch-supervision.ts` | Its complete self-rendered shell hides while Calm is active, exactly like `fm_branch_outcomes`; when visible, the self-renderer reconstructs Pi's ordinary boxed fallback shell around the one-line acknowledgement result, while stock export rendering deliberately falls through to Pi's structured fallback. | +| `fm_branch_outcomes` | Main-session custom tool in `fm-branch-supervision.ts` | Its complete self-rendered shell hides while Calm is active; when visible, the self-renderer reconstructs Pi's ordinary boxed fallback shell, matches Pi's collapsed or expanded call-argument header, and probes Pi's rendered stock fallback to preserve that installed surface's collapsed or all-line result policy plus expanded state, while stock export rendering deliberately falls through to Pi's structured fallback. | +| `fm_branch_processed` | Main-session custom tool in `fm-branch-supervision.ts` | Its complete self-rendered shell hides while Calm is active, exactly like `fm_branch_outcomes`; when visible, the self-renderer preserves Pi's call-argument header around the one-line acknowledgement result, while stock export rendering deliberately falls through to Pi's structured fallback. | | `fm_branch_report` | Branch-session custom tool supplied directly to `createAgentSession` | It runs only in the headless supervision session and has no main-session `ToolExecutionComponent`; successful execution writes the outcome store and delivers a routine note or exact captain entry through the separately audited delivery path, so the tool cannot emit a dump-shaped row in the captain's transcript. | | branch-local `read` built-in | Branch-session built-in enabled through `createAgentSession` | It runs only in the headless supervision session and has no main-session `ToolExecutionComponent`, so its file output cannot emit a row in the captain's transcript. | | branch-local `bash` override | Branch-session replacement supplied directly to `createAgentSession` | It runs only in the headless supervision session and has no main-session `ToolExecutionComponent`, so its command output cannot emit a row in the captain's transcript. | @@ -226,7 +226,7 @@ The test fixture enumerates every class below through the centralized policy, an | `genuine-agent-response` | Assistant text in `AssistantMessageComponent` | Visible. | | `assistant-working-note` | Assistant text in an `AssistantMessageComponent` message the model did not end its response with, identified by its own `stopReason` of `toolUse`, or of `length` with tool calls present | Each settled text block follows the cross-harness preservation contract in [`calm.md`](calm.md); hidden blocks are removed from the shallow presentation copy before layout, a `toolUse` message carrying only short narration occupies zero rows (verified on Pi 0.84.1), and a still-streaming `pending` message is never filtered. | | `assistant-thinking` | Thinking content in `AssistantMessageComponent` | Collapsed reasoning is removed from the shallow presentation copy before layout and occupies zero rows; explicit expansion renders the original reasoning. | -| `assistant-tool-call` | `ToolExecutionComponent` | Seven built-ins, `fm_watch_arm_pi`, and `fm_branch_outcomes` hidden; other arbitrary custom tools remain an unsupported boundary. | +| `assistant-tool-call` | `ToolExecutionComponent` | Seven built-ins, `fm_watch_arm_pi`, `fm_branch_outcomes`, and `fm_branch_processed` hidden; other arbitrary custom tools remain an unsupported boundary. | | `tool-result` | `ToolExecutionComponent` | Text results for the controlled tools hidden; other arbitrary custom results remain an unsupported boundary. | | `tool-image` | Image children appended outside tool renderer slots | Unsupported boundary; remains visible. | | `user-bash` | `BashExecutionComponent` for `!` and `!!` | Unsupported boundary; remains visible. | @@ -299,7 +299,8 @@ The same real-Pi reproduction then delivered the notification exactly once in a ## Regression coverage -`tests/fm-calm-pi-extension.test.sh` compares wrapped and stock renderers and verifies all seven built-ins plus `fm_watch_arm_pi`; `tests/fm-pi-branch-extension.test.sh` verifies `fm_branch_outcomes` Calm toggling, capability-probed all-line versus collapsed stock output, exact expanded output, and export rendering. +`tests/fm-calm-pi-extension.test.sh` compares wrapped and stock renderers and verifies all seven built-ins plus `fm_watch_arm_pi`; its rendered HTML export check accepts either omission or default-hidden hook rows for legacy synthetic messages while rejecting visible leakage. +`tests/fm-pi-branch-extension.test.sh` verifies both `fm_branch_outcomes` and `fm_branch_processed` call headers against pre-0.99 and 0.99+ Pi stock rendering, plus Calm toggling, capability-probed all-line versus collapsed stock result output, exact expanded output, and export rendering for outcomes. Together they exercise redraw of already-rendered tool, thinking, current operational-user, and legacy synthetic rows, and cover every policy class. It covers persisted preference restoration across every session-start reason and a real restart, proves the working-ship presentation and Calm-off stock `Working...` row through a delayed deterministic provider, asserts no Calm status row, verifies operational messages remain exact ordinary user-role session entries and complete exports, and drives genuine 100 by 44, 160 by 36, and 180 by 44 terminal fixtures. A native deterministic `/skill:ahoy` turn produces thinking, tool-call, and tool-result blocks, asserts that the collapsed skill-to-final gap equals the two-row visible-only baseline, expands and re-collapses original thinking, restores Calm-off rendering, verifies persisted hidden history, and repeats the geometry assertion after restart with `terminal.clearOnShrink` explicitly off. diff --git a/docs/calm.md b/docs/calm.md index c20c8843e78..1c979465ef5 100644 --- a/docs/calm.md +++ b/docs/calm.md @@ -66,7 +66,7 @@ Calm hides these rows: - Collapsed thinking labels. - The mid-turn assistant working-note blocks governed by the [shared preservation rule](#shared-preservation-rule-for-assistant-text) above. - The shells for the Pi built-in tool names Calm owns. -- The `fm_watch_arm_pi` and `fm_branch_outcomes` tool shells. +- Firstmate-owned tool shells listed in the [Pi tool audit](calm-mode-feasibility.md#firstmate-pi-tool-audit). - Canonically classified Firstmate operational user rows. Pi applies the preservation rule independently to each text block. @@ -86,8 +86,8 @@ While a turn runs, Calm also keeps those Firstmate inputs out of Pi's queued-mes The captain's own queued messages stay listed. Escape and the dequeue key return only the captain's queued messages to the editor. Hidden Firstmate inputs stay queued in their original order and are never shown as raw text or dropped. -When Escape, or navigating the session tree, stops a run with Firstmate inputs still queued, Calm starts one new turn to deliver them. -Calm then shows the one-line notice `Firstmate supervision continues in a new turn.` +When Escape, or navigating the session tree, stops a run with Firstmate inputs still queued, Pi either drains them itself or Calm starts one new turn to deliver them. +When Calm starts that turn, it shows the one-line notice `Firstmate supervision continues in a new turn.` Inputs held behind a running compaction stay there until Pi sends them after compaction, so they start and announce no turn of their own. ### What stays unchanged on Pi @@ -96,7 +96,7 @@ Outside Pi's same-name built-in override collision described in [Pi compatibilit Calm's built-in wrappers preserve Pi's execution behavior. Input delivery, ordering, model context, session storage, diagnostics, and `/export` and `/share` operation remain unchanged. Every hidden Firstmate input remains available to the model and in serialized session data and exported artifacts. -Legacy operational custom messages remain in session data and Pi's sidebar tree, although the main HTML transcript may omit them. +Legacy operational custom messages remain in session data and Pi's sidebar tree; depending on the Pi version, the main HTML transcript either omits them or includes them as rows hidden by default. Toggling Calm off restores ordinary rendering, and `Ctrl+O` expansion state is preserved. ### What stays visible on Pi diff --git a/tests/fm-calm-pi-extension.test.sh b/tests/fm-calm-pi-extension.test.sh index 5fde98777da..e73c0948c98 100755 --- a/tests/fm-calm-pi-extension.test.sh +++ b/tests/fm-calm-pi-extension.test.sh @@ -569,6 +569,8 @@ function makeSession({ missing = [], rejectPrompt = false } = {}) { } function makeHost(session) { + // The InteractiveMode.agent getter reads session.agent on older Pi installs. + session.agent = session; const host = Object.create(InteractiveMode.prototype); const children = []; Object.assign(host, { @@ -2506,12 +2508,20 @@ test_queued_operational_escape_e2e() { printf '%s\n' '{"followUpMode":"all"}' >"$config/settings.json" cat >"$project/queued-escape-e2e.ts" <<'TS' -import { writeFileSync } from "node:fs"; +import { appendFileSync, writeFileSync } from "node:fs"; import { createFauxCore, fauxAssistantMessage, fauxText, fauxToolCall } from "@earendil-works/pi-ai"; -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { InteractiveMode, type ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { encodeFirstmateOperationalInput } from "./.pi/extensions/lib/fm-operational-input.ts"; +// The status may disappear on Pi's next repaint; observe the live call without +// changing its display behavior. +const showStatus = InteractiveMode.prototype.showStatus; +InteractiveMode.prototype.showStatus = function (message: string) { + appendFileSync(process.env.QUEUED_ESCAPE_STATUS_LOG as string, `${message}\n`); + return showStatus.call(this, message); +}; + let label = ""; function lastUserText(messages: readonly { role: string; content: unknown }[]): string { @@ -2589,7 +2599,7 @@ TS printf '%s\n' "$calm_state" >"$home/config/calm" mkdir -p "$sessions/$label" tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -x 160 -y 36 \ - "cd '$project' && env FM_HOME='$home' PI_CODING_AGENT_DIR='$config' FM_OPERATIONAL_INPUT_SCRIPT='$OPERATIONAL_INPUT' QUEUED_ESCAPE_HELD='$held' PI_OFFLINE=1 pi --approve --no-context-files --no-skills --no-prompt-templates --no-extensions -e ./.pi/extensions/fm-calm.ts -e ./queued-escape-e2e.ts --session-dir '$sessions/$label'; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 20" + "cd '$project' && env FM_HOME='$home' PI_CODING_AGENT_DIR='$config' FM_OPERATIONAL_INPUT_SCRIPT='$OPERATIONAL_INPUT' QUEUED_ESCAPE_HELD='$held' QUEUED_ESCAPE_STATUS_LOG='$sessions/$label/status.log' PI_OFFLINE=1 pi --approve --no-context-files --no-skills --no-prompt-templates --no-extensions -e ./.pi/extensions/fm-calm.ts -e ./queued-escape-e2e.ts --session-dir '$sessions/$label'; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 20" wait_for_text "$TMP_ROOT/queued-escape-pane" 'queued-escape-e2e.ts' \ || fail "Pi queued-row $label case did not reach the ready composer" tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/queued-escape-e2e $label" @@ -2634,7 +2644,12 @@ TS pane=$(cat "$TMP_ROOT/queued-escape-pane") assert_not_contains "$pane" "MONITOR_${label}_ONE" "Pi Calm exposed a hidden notification after Escape" assert_not_contains "$pane" "FIRSTMATE_OP" "Pi Calm exposed operational text after Escape" - assert_contains "$pane" "Firstmate supervision continues in a new turn." "Pi Calm restarted a turn silently after Escape" + # Pi before 0.87 drains the retained queue in its own aborted-run loop; + # only newer Pi needs Calm to start and announce a replacement turn. + if node -e 'const v=process.argv[1].match(/(\d+)\.(\d+)\.(\d+)/); process.exit(v && (+v[1]>0 || +v[2]>87 || (+v[2]===87 && +v[3]>=1)) ? 0 : 1)' "$version"; then + grep -Fxq 'Firstmate supervision continues in a new turn.' "$sessions/$label/status.log" \ + || fail "Pi Calm restarted a turn without announcing it after Escape" + fi if [ "$captain_queued" = yes ]; then [ "$(tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" | grep -c "^CAPTAIN_QUEUED_$label *\$")" -eq 1 ] \ || fail "Pi Calm did not return the captain's queued text to the editor on Escape" @@ -2670,7 +2685,7 @@ JS run_queued_escape_case on queued_on no run_queued_escape_case on queued_mixed yes run_queued_escape_case off queued_off no - pass "Pi $version with Calm on keeps a queued Firstmate notification unlisted, out of the editor on Escape, and delivers it once in a new announced turn, while Calm off stays stock" + pass "Pi $version with Calm on hides and retains queued Firstmate input through Escape, delivers it once, and leaves Calm off stock" } test_hidden_block_geometry_e2e() { @@ -4382,19 +4397,79 @@ JS || fail "Chrome or Chromium is required for rendered export DOM assertions; set FM_CHROME_BIN to one" chrome_report=$(render_export_dom "$chrome" "$export_file" "$export_dom" "$version") \ || fail "could not render calm-mode HTML export DOM: $chrome_report" + # Pi 0.99 renders display:false custom messages into the conversation + # column as hook-message-hidden and hides them with CSS until the viewer + # asks to show hidden messages. Pi 0.87 omitted those rows from the column + # entirely. The boundary is the visible conversation: a synthetic row may + # sit in a hidden hook message, and nowhere a reader sees by default. node - "$export_dom" <<'JS' || fail "rendered export DOM violated the Calm conversation boundary" const dom = require("node:fs").readFileSync(process.argv[2], "utf8"); const messages = dom.match(/
([\s\S]*?)<\/main>/)?.[1]; const tree = dom.match(/]*id="tree-container"[^>]*>([\s\S]*?)]*id="tree-status"/)?.[1]; -if (!messages || !tree) process.exit(1); -if (!/
]*>[\s\S]*Show a deterministic tool example\./.test(messages)) process.exit(1); -if (!/
]*>[\s\S]*The deterministic tool example is complete\./.test(messages)) process.exit(1); -if (messages.includes('
]*>[\s\S]*Show a deterministic tool example\./.test(messages)) { + throw new Error("genuine user prompt is missing from the conversation column"); +} +if (!/
]*>[\s\S]*The deterministic tool example is complete\./.test(messages)) { + throw new Error("genuine assistant reply is missing from the conversation column"); +} +if (/]*show-hidden-messages/.test(dom)) { + throw new Error("export opened with hidden messages shown"); +} +const rendersHiddenRows = /]*class="[^"]*\bhook-message-hidden\b/.test(messages); +if (rendersHiddenRows && !/body:not\(\.show-hidden-messages\)\s+\.hook-message-hidden\s*\{[^}]*display:\s*none/.test(dom)) { + throw new Error("export no longer hides terminal-hidden custom messages by default"); +} +function stripHiddenHookMessages(html) { + const marker = "", start); + if (tagEnd < 0) throw new Error("unclosed tag in the conversation column"); + const tag = html.slice(start, tagEnd + 1); + const classes = tag.match(/class="([^"]*)"/)?.[1].split(/\s+/) ?? []; + const hiddenHook = classes.includes("hook-message") && classes.includes("hook-message-hidden"); + if (!hiddenHook) { + out += html.slice(i, start + marker.length); + i = start + marker.length; + continue; + } + out += html.slice(i, start); + let depth = 0; + let j = start; + while (j < html.length) { + const nextOpen = html.indexOf("", j); + if (nextClose < 0) throw new Error("unclosed hidden hook message"); + if (nextOpen >= 0 && nextOpen < nextClose) { + depth += 1; + j = nextOpen + 4; + } else { + depth -= 1; + j = nextClose + 6; + if (depth === 0) break; + } + } + i = j; + } + return out; +} +const visible = stripHiddenHookMessages(messages); +if (visible.includes('
"$RULES" +TYPESAFE_API_KEY=$KEY run code out err "$BRIEF" +expect_code 2 "$code" "multiple provider-less profiles exit 2" +assert_contains "$err" "malformed rules file: $RULES - use profiles whose harness lacks one authoritative provider family require provider: opencode; use profiles whose harness lacks one authoritative provider family require provider: rovo; default profiles whose harness lacks one authoritative provider family require provider: pi" "all provider-less profiles are reported together across use and default" +[ "$(printf '%s\n' "$err" | wc -l | tr -d ' ')" -eq 1 ] || fail "provider errors must use one diagnostic" assert_absent "$LOG/argv" "configuration errors never reach the network" cp "$BASE_RULES" "$RULES" for removed in --json --rules --quota; do diff --git a/tests/fm-pi-branch-extension.test.sh b/tests/fm-pi-branch-extension.test.sh index da53c43f6af..fd651cefa01 100644 --- a/tests/fm-pi-branch-extension.test.sh +++ b/tests/fm-pi-branch-extension.test.sh @@ -68,6 +68,8 @@ JSON cat > "$repo/node_modules/@earendil-works/pi-coding-agent/index.js" <<'JS' import { writeFileSync } from "node:fs"; +export const VERSION = process.env.FM_STUB_PI_VERSION || "0.99.0"; + export function getAgentDir() { return "/stub-agent-dir"; } @@ -4993,6 +4995,64 @@ JS pass "the installed Pi still bounds the picker's list and ranks its search" } +# Pi's stock call header gained arguments in 0.99: before it, the header is +# the bold title alone; from 0.99 a collapsed call appends `key=json` and an +# expanded call lists `key: value` under the title. Both supervision tools +# must match the header of whichever Pi version loaded them. +test_outcomes_tool_call_headers_follow_the_loaded_pi_version() { + local repo version status out + repo="$TMP_ROOT/call-header-versions" + install_pi_branch_extension_fixture "$repo" + for version in 0.87.0 0.99.0; do + FM_STUB_PI_VERSION="$version" EXT="$repo/.pi/extensions/fm-branch-supervision.ts" \ + node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'JS' +import { pathToFileURL } from "node:url"; + +const version = process.env.FM_STUB_PI_VERSION; +const tools = []; +const pi = { + events: { on() {}, emit() {} }, + on() {}, + registerCommand() {}, + registerMessageRenderer() {}, + registerTool(tool) { tools.push(tool); }, + sendMessage() {}, + sendUserMessage() {}, +}; +const extension = await import(pathToFileURL(process.env.EXT).href); +extension.default(pi); +const theme = { + fg(color, text) { return `<${color}>${text}`; }, + bg(_color, text) { return text; }, + bold(text) { return `**${text}**`; }, +}; +const showsArgs = version === "0.99.0"; +for (const [name, key, value] of [["fm_branch_outcomes", "recent", 2], ["fm_branch_processed", "through", 1]]) { + const tool = tools.find((candidate) => candidate.name === name); + if (!tool) throw new Error(`${name} was not registered`); + const title = `**${name}**`; + for (const expanded of [false, true]) { + const stock = !showsArgs + ? title + : expanded + ? `${title}\n ${key}: ${value}` + : `${title} ${key}=${value}`; + const shell = tool.renderCall({ [key]: value }, theme, { state: {}, expanded, isError: false, isPartial: false }); + const header = shell.children[0]?.text; + if (header !== stock) { + throw new Error(`Pi ${version} ${expanded ? "expanded" : "collapsed"} ${name} header ${JSON.stringify(header)} is not stock ${JSON.stringify(stock)}`); + } + } +} +JS + status=$? + out=$(cat "$TMP_ROOT/node-output") + expect_code 0 "$status" "Pi $version supervision tool call headers must match that version's stock header: $out" + [ -z "$out" ] || fail "Pi $version call header test printed output: $out" + done + pass "fm_branch_outcomes and fm_branch_processed call headers match stock on Pi before and from 0.99" +} + test_outcomes_tool_uses_stock_execution_and_export_consumers() { if ! command -v node >/dev/null 2>&1; then echo "skip: node not found for Pi outcomes rendering test" @@ -5120,6 +5180,28 @@ if (JSON.stringify(expandedActual) !== JSON.stringify(expandedStock)) { if (!expandedStock.join("\n").includes("OUTCOME_TWELVE") || JSON.stringify(expandedStock) === JSON.stringify(collapsedStock)) { throw new Error("stock rendering fixture did not exercise expanded output"); } +const processedDefinition = tools.find((tool) => tool.name === "fm_branch_processed"); +if (!processedDefinition) throw new Error("fm_branch_processed was not registered"); +const stockProcessedDefinition = { ...processedDefinition }; +delete stockProcessedDefinition.renderShell; +delete stockProcessedDefinition.renderCall; +delete stockProcessedDefinition.renderResult; +const processedArgs = { through: 1 }; +const processedResult = { content: [{ type: "text", text: "acknowledged through 1" }], details: undefined, isError: false }; +const stockProcessed = new ToolExecutionComponent("fm_branch_processed", "stock-processed", processedArgs, { showImages: false }, stockProcessedDefinition, ui, process.cwd()); +const actualProcessed = new ToolExecutionComponent("fm_branch_processed", "actual-processed", processedArgs, { showImages: false }, processedDefinition, ui, process.cwd()); +for (const row of [stockProcessed, actualProcessed]) { + row.markExecutionStarted(); + row.setArgsComplete(); + row.updateResult(processedResult); +} +for (const expanded of [false, true]) { + stockProcessed.setExpanded(expanded); + actualProcessed.setExpanded(expanded); + if (JSON.stringify(actualProcessed.render(100)) !== JSON.stringify(stockProcessed.render(100))) { + throw new Error(`${expanded ? "expanded" : "collapsed"} Calm-off fm_branch_processed rendering differs from Pi stock`); + } +} pi.events.emit("firstmate:calm-presentation", { active: true, stockExportRendering: false }); actualRow.invalidate(); if (actualRow.render(100).length !== 0) { @@ -5715,6 +5797,7 @@ EOF pass "an extension-registered provider resolves in the isolated branch runtime" } +test_outcomes_tool_call_headers_follow_the_loaded_pi_version test_outcomes_tool_uses_stock_execution_and_export_consumers test_real_pi_picker_primitives_stay_bounded_and_searchable test_branch_dispatch_two_stage_filter_and_prefix_contract diff --git a/tests/fm-supervision-host.test.sh b/tests/fm-supervision-host.test.sh index 5946b626990..4f4812aabd1 100755 --- a/tests/fm-supervision-host.test.sh +++ b/tests/fm-supervision-host.test.sh @@ -139,6 +139,8 @@ export FM_REPO="$ROOT" export FM_SUPERVISION_ENGINE_CLAUDE_BIN="$STUB" export FM_SUPERVISION_HOST_PRIMARY=claude export FM_POLL=1 FM_SIGNAL_GRACE=0 FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 +# Keep the real engine watchdog/reaping path, but not its production grace in fixtures. +export FM_SUPERVISION_ENGINE_GRACE=1 export FM_ARM_CONFIRM_TIMEOUT=30 unset FM_SUPERVISION_ACTOR FM_BRANCH_REPORT_TURN FM_LEASE_HOLDER_PID PI_CODING_AGENT @@ -1511,12 +1513,13 @@ test_undelivered_dialog_is_fed_again_on_the_next_turn() { real_node=$(command -v node) cat > "$home/fakebin/node" < "\$FM_HOME/park-clock"; fi exec "$real_node" "\$@" SH chmod +x "$home/fakebin/node" printf '{"hook_event_name":"UserPromptSubmit","prompt_id":"p1","prompt":"first ask"}' > "$home/mirror-seed.1" - FM_SUPERVISION_HOST_PARK_SECONDS=40 FM_SUPERVISION_HOST_TURN_TIMEOUT=20 FM_SUPERVISION_ENGINE_GRACE=1 start_session "$home" + echo 0 > "$home/park-clock" + FM_TEST_SUPERVISION_HOST_CLOCK="$home/park-clock" FM_SUPERVISION_HOST_PARK_SECONDS=40 FM_SUPERVISION_HOST_TURN_TIMEOUT=20 FM_SUPERVISION_ENGINE_GRACE=1 start_session "$home" park_again "$home" append_status "$home" 'first' wait_until 250 handled_at_least "$home" 1 || fail "mirror boundary: the first wake was not handled: $(cat "$home/state/.supervision-host.log")" @@ -1526,6 +1529,7 @@ SH printf '{"hook_event_name":"UserPromptSubmit","prompt_id":"p2","prompt":"second ask, never handed over"}' > "$home/mirror-seed.2" : > "$home/slow-render" + echo 0 > "$home/park-clock" park_after_stop "$home" append_status "$home" 'reaches the boundary' wait_until 400 host_exited "$home" || fail "mirror boundary: the host did not end its park" @@ -1534,6 +1538,7 @@ SH main_drain_and_ack "$home" rm -f "$home/slow-render" + echo 0 > "$home/park-clock" park_again "$home" append_status "$home" 'handled after the boundary' wait_until 250 handled_at_least "$home" 2 || fail "mirror boundary: the next wake was not handled: $(cat "$home/state/.supervision-host.log")" From b3d4133234f1552425e053c4a998cab9d8399dbb Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:29:56 -0700 Subject: [PATCH 13/76] fix: confirm Lavish board replies before worker handoff (#6169) * Prevent premature Lavish board handoffs * Prove Lavish arm lacks reply acknowledgement * Confirm Lavish replies before arming worker boards * no-mistakes(review): Post Lavish reply only after locked arm eligibility checks * no-mistakes(review): Fail Lavish reply closed on unknown version * no-mistakes(document): Correct Lavish reply documentation and remove stale guidance * no-mistakes(document): Clarify Lavish reply routing and remove duplicate version guidance --- .../skills/captain-hold-lifecycle/SKILL.md | 1 + .agents/skills/process-event-sources/SKILL.md | 8 +- bin/fm-bootstrap.sh | 51 +++- bin/fm-brief.sh | 2 +- bin/fm-procevent-lavish.sh | 78 ++++-- bin/fm-procevent.sh | 33 ++- docs/configuration.md | 28 +- docs/verification/process-event-sources.md | 35 +-- tests/fm-backlog-read-bound.test.sh | 2 +- tests/fm-bearings-board-render.test.sh | 2 +- tests/fm-bootstrap.test.sh | 17 +- tests/fm-brief.test.sh | 13 +- tests/fm-procevent.test.sh | 253 +++++++++++++++++- tests/fm-secondmate-harness.test.sh | 2 +- tests/fm-secondmate-liveness.test.sh | 2 +- tests/fm-secondmate-sync.test.sh | 2 +- tests/fm-session-start.test.sh | 2 +- tests/fm-shared-captain-inheritance.test.sh | 2 +- tests/fm-startup-memory-budget.test.sh | 2 +- tests/fm-x-mode.test.sh | 2 +- 20 files changed, 420 insertions(+), 117 deletions(-) diff --git a/.agents/skills/captain-hold-lifecycle/SKILL.md b/.agents/skills/captain-hold-lifecycle/SKILL.md index 311b739e01b..eaea0c27411 100644 --- a/.agents/skills/captain-hold-lifecycle/SKILL.md +++ b/.agents/skills/captain-hold-lifecycle/SKILL.md @@ -17,6 +17,7 @@ The agent performs the semantic inventory because scripts must not infer captain ## Policy Every unresolved question that belongs to the captain and is discovered while producing, reading, presenting, or ending an investigation or visual review must be carried by a captain-held task in the authoritative backlog of the home that owns the originating work before that work or review may be treated as complete. +For a Lavish board-backed handoff, pass the reply through `bin/fm-procevent-lavish.sh arm --agent-reply-file` before appending the status; the adapter owns version-specific acceptance ordering. Prefer holding the work item the question gates over minting a new row; create a new task only when no work item exists to hold. Put the question and its options in the hold reason, and keep one held task per genuine gate: a multi-question review is one held task pointing at its report, not a row per question. Represent that task with exactly one board card that consolidates its questions and options; never fan one task id into duplicate same-key cards. Register or re-hold through `bin/fm-captain-hold.sh hold`, which is idempotent per task id. diff --git a/.agents/skills/process-event-sources/SKILL.md b/.agents/skills/process-event-sources/SKILL.md index 8def61d4608..999834abbde 100644 --- a/.agents/skills/process-event-sources/SKILL.md +++ b/.agents/skills/process-event-sources/SKILL.md @@ -10,8 +10,7 @@ description: >- Owns the arming commands, the condition->action eligibility boundary, the durable result read, which wakes must be routed to their adapter instead of acknowledged generically, the handled acknowledgement contract, the one-owner - rule, the precise durability boundary, and the Lavish adapter's loss - limitation. + rule, and the precise durability boundary. user-invocable: false metadata: internal: true @@ -35,10 +34,9 @@ bin/fm-procevent-lavish.sh arm ``` A worker-owned board uses `bin/fm-procevent-lavish.sh arm --for ` and re-arms with its reply after each nonterminal round; the existing handled marker is the acknowledgement. -Arm it once, then re-arm only when a round is actually waiting: arming again with nothing to acknowledge is refused, because it would discard the reply your listener is still holding. -Posting that reply is best effort: a rare crash while the listener consumes the staged file drops that one round's reply rather than posting it twice, and robust reply delivery waits on lavish-axi's exclusive listener. +Arm it once, then re-arm only when a round is actually waiting: arming again with nothing to acknowledge is refused. A terminal round is never re-armed: the board stays yours until you acknowledge it with `bin/fm-procevent.sh handled `, which retires it, and until then `retire` refuses the board too. -Never arm a board that a live task hosts; follow the crew-hosted Lavish board contract in [`docs/configuration.md`](../../../docs/configuration.md#crew-hosted-lavish-review-boards). +Never arm a board that a live task hosts; follow the [crew-hosted Lavish board contract](../../../docs/configuration.md#crew-hosted-lavish-review-boards) for reply acceptance and older-version limits. Registering a source is not the same fact as listening to it. Lavish `arm` waits until this registration's listener is confirmed running and does not report ready without that evidence; other adapters still record the source for the watcher's next reconcile. diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 2f77fdec4a6..77dfde889d5 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -60,9 +60,9 @@ # The AXI-family floor policy is owned beside GH_AXI_MIN and # LAVISH_AXI_MIN below; the per-tool owners point there. An installed # essential build below its floor reports MISSING like no-mistakes. -# Missing or incompatible lavish-axi reports PRESENTATION_UNAVAILABLE: -# nonvisual dispatch continues with plain-text decisions and reports, -# but Lavish use still requires a compatible build at or above its floor. +# Missing or incompatible lavish-axi reports PRESENTATION_UNAVAILABLE; +# a compatible older build keeps legacy boards and reports a BOOTSTRAP_INFO +# upgrade recommendation for synchronous reply acceptance. # tasks-axi feature probes remain a separate defense-in-depth check. # tasks-axi and quota-axi are essential bootstrap tools. # A compatible tasks-axi default backend is silent. @@ -152,8 +152,14 @@ # fm-bootstrap.sh install ... # Install the named tools (only ones the captain approved). # fm-bootstrap.sh lavish-compatible -# Exit 0 when lavish-axi meets LAVISH_AXI_MIN, 1 otherwise, printing -# nothing; bin/fm-brief.sh uses it to gate scout Lavish hosting. +# Exit 0 when lavish-axi meets LAVISH_AXI_BOARD_MIN, 1 otherwise, +# printing nothing; bin/fm-brief.sh uses it to gate scout Lavish hosting. +# fm-bootstrap.sh lavish-reply-compatible +# Exit 0 when lavish-axi meets LAVISH_AXI_MIN and supports synchronous +# reply acceptance, 1 when one version probe confirms an older release +# meeting LAVISH_AXI_BOARD_MIN, and 2 when lavish-axi is absent, its +# version cannot be read, or it is below LAVISH_AXI_BOARD_MIN, printing +# nothing. set -u TYPESAFE_API_KEY_PRIVATE=${TYPESAFE_API_KEY:-} @@ -842,7 +848,8 @@ NO_MISTAKES_MIN=1.46.0 # tasks-axi feature probes are an independent defense-in-depth concern, not part # of its floor. GH_AXI_MIN=0.1.29 -LAVISH_AXI_MIN=0.1.77 +LAVISH_AXI_MIN=0.1.80 +LAVISH_AXI_BOARD_MIN=0.1.77 treehouse_supports_lease() { treehouse get --help 2>&1 | grep -Eq '(^|[^[:alnum:]_-])--lease([^[:alnum:]_-]|$)' @@ -852,14 +859,19 @@ treehouse_supports_lease() { # cannot be parsed into exactly one major.minor.patch triple is incompatible, # never assumed current, so a development or vendored build cannot pass a floor # it was never checked against. -tool_version_at_least() { # - local tool=$1 min=$2 output parts major minor patch extra - local min_major min_minor min_patch min_extra +tool_version_parts() { # + local tool=$1 output parts major minor patch extra command -v "$tool" >/dev/null 2>&1 || return 1 output=$("$tool" --version 2>/dev/null) || return 1 parts=$(printf '%s\n' "$output" | sed -nE 's/.*[vV]?([0-9]+)\.([0-9]+)\.([0-9]+).*/\1 \2 \3/p' | head -n 1) IFS=' ' read -r major minor patch extra <<< "$parts" [ -n "$major" ] && [ -n "$minor" ] && [ -n "$patch" ] && [ -z "$extra" ] || return 1 + printf '%s %s %s\n' "$major" "$minor" "$patch" +} + +version_parts_at_least() { # + local major minor patch min=$2 min_major min_minor min_patch min_extra + IFS=' ' read -r major minor patch <<< "$1" IFS='.' read -r min_major min_minor min_patch min_extra <<< "$min" [ -n "$min_major" ] && [ -n "$min_minor" ] && [ -n "$min_patch" ] && [ -z "$min_extra" ] || return 1 [ "$major" -gt "$min_major" ] && return 0 @@ -869,6 +881,12 @@ tool_version_at_least() { # [ "$patch" -ge "$min_patch" ] } +tool_version_at_least() { # + local parts + parts=$(tool_version_parts "$1") || return 1 + version_parts_at_least "$parts" "$2" +} + x_mode_write_if_changed() { local dest=$1 content=$2 mode=$3 parent tmp parent_device current_mode parent=${dest%/*} @@ -1302,10 +1320,17 @@ startup_memory_budget_setup() { } if [ "${1:-}" = "lavish-compatible" ]; then - tool_version_at_least lavish-axi "$LAVISH_AXI_MIN" + tool_version_at_least lavish-axi "$LAVISH_AXI_BOARD_MIN" exit fi +if [ "${1:-}" = "lavish-reply-compatible" ]; then + lavish_parts=$(tool_version_parts lavish-axi) || exit 2 + version_parts_at_least "$lavish_parts" "$LAVISH_AXI_MIN" && exit 0 + version_parts_at_least "$lavish_parts" "$LAVISH_AXI_BOARD_MIN" && exit 1 + exit 2 +fi + if [ "${1:-}" = "install" ]; then shift [ $# -gt 0 ] || { echo "usage: fm-bootstrap.sh install ..." >&2; exit 1; } @@ -1402,8 +1427,10 @@ detect_local_tools() { if command -v gh-axi >/dev/null 2>&1 && ! tool_version_at_least gh-axi "$GH_AXI_MIN"; then echo "MISSING: gh-axi (install: $(install_cmd gh-axi))" fi - if ! tool_version_at_least lavish-axi "$LAVISH_AXI_MIN"; then - echo "PRESENTATION_UNAVAILABLE: lavish-axi (requires >=$LAVISH_AXI_MIN; install: $(install_cmd lavish-axi)) - nonvisual work may proceed with plain-text decisions and reports; install or upgrade before using Lavish" + if ! tool_version_at_least lavish-axi "$LAVISH_AXI_BOARD_MIN"; then + echo "PRESENTATION_UNAVAILABLE: lavish-axi (requires >=$LAVISH_AXI_BOARD_MIN; install: $(install_cmd lavish-axi)) - nonvisual work may proceed with plain-text decisions and reports; install or upgrade before using Lavish" + elif ! tool_version_at_least lavish-axi "$LAVISH_AXI_MIN"; then + echo "BOOTSTRAP_INFO: lavish-axi >=$LAVISH_AXI_MIN enables confirmed board replies; this older compatible version retains the legacy reply path, but upgrade to prevent handing back a board before its reply is accepted" fi if command -v quota-axi >/dev/null 2>&1 && ! fm_quota_axi_compatible; then echo "MISSING: quota-axi (install: $(install_cmd quota-axi))" diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 644954fd76f..e3fe51fd2ae 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -20,7 +20,7 @@ # --scout writes the scout contract instead: the deliverable is a report at # data//report.md (no branch, no push, no PR) and the worktree is scratch. # It offers the Lavish review loop only when `fm-bootstrap.sh lavish-compatible` -# confirms the supported lavish-axi floor; otherwise it asks for a text report. +# confirms the legacy board-compatibility floor; otherwise it asks for a text report. # --secondmate writes a persistent secondmate charter. The project list # is cloned into the secondmate home, while the natural-language scope # tells the main firstmate when to route work there; routine churn stays in its own home; diff --git a/bin/fm-procevent-lavish.sh b/bin/fm-procevent-lavish.sh index 1137a477efa..4442c73fe3c 100755 --- a/bin/fm-procevent-lavish.sh +++ b/bin/fm-procevent-lavish.sh @@ -12,6 +12,7 @@ # fm-procevent-lavish.sh source-id # fm-procevent-lavish.sh retire # fm-procevent-lavish.sh poll [--agent-reply-file ] +# fm-procevent-lavish.sh deliver-reply poll --agent-reply-file # # classify Print the lifecycle state a handler should act on: feedback, ended, # waiting, disconnected, missing, or unknown. @@ -34,12 +35,17 @@ # poll The registered listener command `arm` publishes, not a command to # run in a conversational turn. It runs the published blocking poll # and prints its response verbatim, absorbing only the one exact -# transient interruption described below. A task-owned arm consumes -# its staged reply file once - reading and removing it before the -# poll - and hands the contents to the published `--agent-reply` -# argument; later retries poll without that reply. That post is best -# effort: a crash while consuming drops that one round's reply -# instead of posting it twice. See the note at the consume site. +# transient interruption described below. A staged reply still +# present when it starts is posted before the long-poll: through +# `lavish-axi reply` when supported, otherwise through the legacy +# best-effort `poll --agent-reply` path. +# deliver-reply +# Run by `fm-procevent.sh register-task` under the source lock, only +# after the task is eligible to own the board, with the listener argv +# it is about to publish. Exit 0 once Lavish accepts the staged reply, +# 3 when the installed Lavish is a confirmed older release without +# synchronous reply so the listener keeps the legacy path, and any +# other status when the reply failed or the version is unknown. # terminal Exit 0 when the captured result means this Lavish source will never # produce another result, so the runner may retire it; any other exit # keeps it armed. This is the generic adapter contract bin/fm-procevent.sh @@ -100,12 +106,10 @@ # `read` is the presentation command summarized above; keyed intake remains # the separate `answers` contract described here. # -# It wraps ONLY the currently published interface, verified against 0.1.45: -# Usage: lavish-axi poll [--agent-reply "..."] -# and that command "long-polls indefinitely" server-side. The adapter therefore -# runs the plain blocking form with no timeout flag, so results arrive as real -# server-side events. It adds no periodic discovery, no timer fallback, and no -# dependency on any unreleased capability. +# It wraps the published `lavish-axi poll` and `lavish-axi reply` interfaces, +# verified against 0.1.80. `poll` long-polls indefinitely; `reply` exits only +# after the server confirms acceptance. Older compatible versions retain the +# legacy poll-with-reply path, without the synchronous handoff guarantee. # # BOUNDED QUIET RETRY, owned here and nowhere else. A live listener can be cut # short by the server with exactly this two-line response while the session's @@ -180,6 +184,23 @@ apply_session_host() { # export LAVISH_AXI_HOST LAVISH_AXI_PORT } +lavish_reply_compatible() { + local status=0 + "$FM_ROOT/bin/fm-bootstrap.sh" lavish-reply-compatible >/dev/null 2>&1 || status=$? + case "$status" in + 0|1) return "$status" ;; + esac + die "cannot confirm a supported lavish-axi version, so the staged reply was not posted; retry once \`lavish-axi --version\` reports a supported release" +} + +post_lavish_reply() { # + local output + if ! output=$(lavish-axi reply "$1" --agent-reply-file "$2" 2>&1); then + [ -n "$output" ] || output="lavish-axi reply exited nonzero" + die "Lavish did not accept the staged reply: $output" + fi +} + # Canonical identity is physical, not the path string: Lavish itself keys a # session on the realpath of the artifact, so two names for one file are one # source and must never become two owners. @@ -265,6 +286,13 @@ cmd_arm() { [ -z "$task" ] || printf 'owner-task: %s\n' "$task" } +cmd_deliver_reply() { + [ "$#" -eq 4 ] && [ "$1" = poll ] && [ "$3" = --agent-reply-file ] || usage + lavish_reply_compatible || exit 3 + apply_session_host "$2" + post_lavish_reply "$2" "$4" +} + cmd_retire() { local artifact=${1-} id [ -n "$artifact" ] || usage @@ -392,19 +420,20 @@ cmd_poll() { [ -f "$artifact" ] && [ ! -L "$artifact" ] && [ -r "$artifact" ] \ || die "artifact is no longer a readable file: $artifact" apply_session_host "$artifact" - # Posting a round's reply is BEST EFFORT and deliberately carries no delivery - # machinery. The staged file is the only record that a reply is owed, so it is - # consumed HERE - after every non-posting step that could abort this poll has - # already succeeded - leaving one narrow window: a crash between consuming the - # file and the call below drops this one round's reply rather than posting it - # twice. A listener that starts with no staged file simply polls without one. - # Robust delivery waits on lavish-axi's own exclusive listener; do not add a - # receipt, retry, or idempotency marker here. + # Newer Lavish builds expose a one-shot reply command whose success is the + # server's acceptance receipt. Consume the staged file only after that + # confirmation; older compatible builds retain the published poll reply + # behavior and its best-effort delivery boundary. if [ -f "$reply_file" ] && [ ! -L "$reply_file" ]; then - reply_text=$(cat -- "$reply_file") \ - || die "cannot read agent reply file: $reply_file" - rm -f -- "$reply_file" || die "cannot consume agent reply file: $reply_file" - reply_pending=1 + if lavish_reply_compatible; then + post_lavish_reply "$artifact" "$reply_file" + rm -f -- "$reply_file" || die "cannot consume agent reply file: $reply_file" + else + reply_text=$(cat -- "$reply_file") \ + || die "cannot read agent reply file: $reply_file" + rm -f -- "$reply_file" || die "cannot consume agent reply file: $reply_file" + reply_pending=1 + fi fi if [ "$reply_pending" -eq 1 ]; then lavish-axi poll "$artifact" --agent-reply "$reply_text" | poll_response_filter "$response" @@ -813,6 +842,7 @@ case "${1-}" in arm) shift; cmd_arm "$@" ;; retire) shift; cmd_retire "$@" ;; poll) shift; cmd_poll "$@" ;; + deliver-reply) shift; cmd_deliver_reply "$@" ;; source-id) shift; cmd_source_id "$@" ;; classify) shift; cmd_classify "$@" ;; terminal) shift; cmd_terminal "$@" ;; diff --git a/bin/fm-procevent.sh b/bin/fm-procevent.sh index f47a2e76853..3b6bfc3affd 100755 --- a/bin/fm-procevent.sh +++ b/bin/fm-procevent.sh @@ -29,7 +29,10 @@ # Record a worker-owned built-in source. Its one source record # persists across rounds, and re-registration by the same task # acknowledges nonterminal captured rounds without touching the -# source claim. Terminal rounds are concluded with `handled`. +# source claim. Terminal rounds are concluded with `handled`. A +# staged `--agent-reply-file` is handed to the adapter's +# `deliver-reply` under the source lock once the task is eligible, +# so a refused arm never posts it and a failed post publishes no registration. # register-extension # Resolve an explicitly enabled home-local process-event-adapter/1 # binding, verify its package and handshake, and record the source @@ -551,8 +554,8 @@ cmd_register() { cmd_register_task() { local adapter=${1-} id=${2-} task=${3-} sep=${4-} result pending pending_adapter local reply_source='' reply_dest='' stale arg i adopting=0 pending_owner prior_record='' - local pending_rounds=0 - local -a argv=() + local pending_rounds=0 delivered + local -a argv=() kept=() shift 4 2>/dev/null || usage [ "$adapter" = lavish ] || die "register-task is reserved for the Lavish adapter" fm_procevent_adapter_valid "$adapter" || die "adapter name must be lowercase alphanumeric or dash: $adapter" @@ -643,6 +646,30 @@ cmd_register_task() { die "cannot read the registration this re-arm replaces: $id" fi fi + if [ -n "$reply_dest" ]; then + delivered=0 + "$(adapter_script "$adapter")" deliver-reply "${argv[@]:1}" || delivered=$? + if [ "$delivered" -eq 0 ]; then + rm -f -- "$reply_dest" + reply_dest='' + kept=() + i=0 + while [ "$i" -lt "${#argv[@]}" ]; do + if [ "${argv[$i]}" = --agent-reply-file ]; then + i=$((i + 2)) + else + kept+=("${argv[$i]}") + i=$((i + 1)) + fi + done + argv=("${kept[@]}") + elif [ "$delivered" -ne 3 ]; then + [ -z "$prior_record" ] || rm -f -- "$prior_record" + rm -f -- "$reply_dest" + fm_procevent_source_lock_release "$id" + die "cannot arm source $id: its staged reply was not delivered" + fi + fi if ! fm_procevent_task_registration_publish_locked "$STATE" "$adapter" "$id" "$task" "${argv[@]}"; then [ -z "$prior_record" ] || rm -f -- "$prior_record" [ -z "$reply_dest" ] || rm -f -- "$reply_dest" diff --git a/docs/configuration.md b/docs/configuration.md index e3a0e1e3961..d47b7899e18 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1268,7 +1268,7 @@ A herdr, zellij, or cmux home is therefore never told `tmux` is missing, and the - An absent or incompatible `tasks-axi` reports `MISSING: tasks-axi (install: npm install -g tasks-axi)`; when `config/backlog-backend` is not `manual`, a home with a configured non-markdown adapter or a markdown backlog refuses lifecycle mutation until compatible `tasks-axi` is on `PATH`, while a manual-backend home keeps its backlog hand-edited. - An absent or incompatible `gh-axi` reports `MISSING: gh-axi (install: npm install -g gh-axi && gh-axi setup hooks)`. -- An absent or incompatible `lavish-axi` reports `PRESENTATION_UNAVAILABLE` with its required floor, install command, and explicit text fallback; [`bootstrap-diagnostics`](../.agents/skills/bootstrap-diagnostics/SKILL.md) owns the response and compatibility check before visual use. +- An absent or board-incompatible `lavish-axi` reports `PRESENTATION_UNAVAILABLE` with the 0.1.77 compatibility floor, install command, and explicit text fallback; compatible versions below 0.1.80 retain legacy board replies and report an upgrade recommendation for synchronous acceptance, while [`bootstrap-diagnostics`](../.agents/skills/bootstrap-diagnostics/SKILL.md) owns diagnostic handling. - An absent or too-old `quota-axi` reports `MISSING: quota-axi (install: npm install -g quota-axi)`; firstmate cannot resolve a profile array without a compatible binary. **Checkout diagnostics** @@ -1836,11 +1836,11 @@ Never run the registered blocking source command directly in a conversational tu A long-polling external process is registered as a *source* through its adapter, whose header and `--help` own the commands and flags. `bin/fm-procevent.sh` owns the generic contract; built-in adapters retain their tracked `bin/fm-procevent-.sh` commands, while an explicitly bound external adapter routes through the trusted host contract above. -`bin/fm-procevent-lavish.sh` is the first built-in adapter and wraps only the currently published `lavish-axi poll` interface. +`bin/fm-procevent-lavish.sh` is the first built-in adapter and wraps the published `lavish-axi poll` interface plus `lavish-axi reply` when the installed version supports synchronous reply acceptance. **Open the Lavish artifact first** -Before arming any Lavish source, open its artifact with `lavish-axi` so the saved session identifies the board's server; each poll attempt derives its host and port from that session and refuses missing or invalid session evidence before consuming a staged worker reply. +Before arming any Lavish source, open its artifact with `lavish-axi` so the saved session identifies the board's server; reply and poll attempts derive their host and port from that session and refuse missing or invalid session evidence before posting or consuming a staged worker reply. **Retry interrupted Lavish polls** @@ -1867,21 +1867,21 @@ After opening the artifact as required above, the worker arms it with `bin/fm-pr **Acknowledge a round by re-arming** The registration persists as one task-owned source record, while each captured nonterminal round remains open until the worker re-arms and the existing handled marker acknowledges that round. -Re-arm is that acknowledgement and nothing else: the board is armed once while no record exists, and a further arm by the same owner is refused unless an unacknowledged nonterminal round is waiting, so a generation already carrying a reply is never replaced before its listener posts it. +Re-arm acknowledges that round and registers the next listener: the board is armed once while no record exists, and a further arm by the same owner is refused unless an unacknowledged nonterminal round is waiting, so an open round is never replaced before its owner acknowledges it. -**Stage an agent reply** +**Post an agent reply** Re-arm never acquires, releases, or hands off the source claim. It may carry `--agent-reply-file `. -The file's contents are copied into that generation's private staging file and passed once to the published `--agent-reply` argument. - -A failed re-arm leaves the prior registration and its referenced reply unchanged, including when its required acknowledgement cannot be recorded. -Reply posting is best effort by design. -The listener consumes the staged file only after validating its own setup and the board artifact. -The one loss window is a rare crash between consuming the file and making the call, which drops that round's reply rather than posting it twice. - -This path keeps no receipt, retry, or idempotency record. -Robust reply delivery waits on lavish-axi's exclusive listener. +With lavish-axi 0.1.80 or newer, the reply is posted through `lavish-axi reply` under the source lock only after the arm passes its endpoint, ownership, and pending-round checks, and the server's acceptance is awaited before the listener is registered or armed. +An arm refused for endpoint, ownership, or pending-round eligibility never posts the reply, and a failed or timed-out reply stops the arm before it registers a listener or acknowledges the round, so the worker cannot hand the board back as ready and can retry the same arm. +If Lavish accepts the reply but the local registration then fails, retrying the arm posts that reply again; this rare duplicate is a known, benign limitation. + +Older compatible Lavish versions keep the prior behavior: the reply is staged into the listener and sent through `poll --agent-reply`, which cannot confirm acceptance before its long-poll returns. +That compatibility path does not provide the synchronous handoff guarantee: a crash after the listener consumes its staged reply but before its poll posts it can lose that round's reply. +Only a version probe that confirms an older compatible release selects that path. +When `lavish-axi` is missing, its version cannot be read, or it is below the board floor, a reply-carrying arm fails without posting or registering a new listener; the worker's original reply file remains available for retry. +The Lavish version floors and feature probe are owned by `bin/fm-bootstrap.sh`. **Deliver feedback to the worker** diff --git a/docs/verification/process-event-sources.md b/docs/verification/process-event-sources.md index 832a0e4bbac..c191fddb375 100644 --- a/docs/verification/process-event-sources.md +++ b/docs/verification/process-event-sources.md @@ -5,37 +5,26 @@ Audience: maintainer verification. This record holds reusable version-scoped evidence for the runner's active guarantees. `docs/configuration.md` owns the operating contract, each script's header and `--help` own its mechanics, and `.agents/skills/process-event-sources/SKILL.md` owns the handling procedure. -Verified on 2026-07-31 on macOS (Darwin 25.5.0) with `lavish-axi` 0.1.45 installed. -Generic keyed-answer feed verified on 2026-08-16 on the same platform, against the same published poll response shape. -Cross-origin keyed-answer feed verified on 2026-08-19 through the real runner and Lavish adapter interface. +The published reply handoff was verified on 2026-09-29 on macOS (Darwin 25.5.0) with `lavish-axi` 0.1.80 installed. +The poll lifecycle was first verified on 2026-07-31 with 0.1.45; generic keyed-answer feed was verified on 2026-08-16, and cross-origin keyed-answer feed on 2026-08-19. Trusted external `process-event-adapter/1` binding conformance and the runnable `file-signal` example were verified on 2026-08-27 on macOS (Darwin 25.5.0) with Node v25.9.0. -## The published Lavish poll interface the adapter wraps +## The published Lavish poll and reply interfaces -Verified at implementation time without upgrading the installed build: +The current published command surface includes a synchronous reply command in addition to the blocking poll: ```sh $ lavish-axi --version -0.1.45 +0.1.80 +$ lavish-axi reply --help | head -1 +Usage: lavish-axi reply (--agent-reply "..." | --agent-reply-file ) $ lavish-axi poll --help | head -1 -Usage: lavish-axi poll [--agent-reply "..."] +Usage: lavish-axi poll [--owner