diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index 520596080e7..68b7bf1b7cd 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -32,7 +32,7 @@ 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. - - **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. + - **A home that runs the supervision host** (a Claude home unless `config/supervision-host-off` opts it out, or a Cursor, OpenCode, omp, Grok, or Codex home with `config/supervision-host` and no opt-out; `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). diff --git a/.agents/skills/harness-adapters/references/harness/claude.md b/.agents/skills/harness-adapters/references/harness/claude.md index 6765b06bfb5..05fdfe68670 100644 --- a/.agents/skills/harness-adapters/references/harness/claude.md +++ b/.agents/skills/harness-adapters/references/harness/claude.md @@ -95,7 +95,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. -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. +Unless `config/supervision-host-off` opts the home out, 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 8b7d6fb6d78..d7d7012f49c 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` (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. +In a home with `config/supervision-host` and no `config/supervision-host-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 d0ecb997a7f..0df472ae073 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` (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. +In a home with `config/supervision-host` and no `config/supervision-host-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 70e2c7dd14e..8779fe49258 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` (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. +In a home with `config/supervision-host` and no `config/supervision-host-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 56531666dde..09874d53439 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` (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)). +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` and no `config/supervision-host-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 0be6f85bcb2..ec51ce78827 100644 --- a/.agents/skills/harness-adapters/references/harness/opencode.md +++ b/.agents/skills/harness-adapters/references/harness/opencode.md @@ -38,7 +38,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` (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. +In a home with `config/supervision-host` and no `config/supervision-host-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 ca7efc8df49..bf17f233ea3 100644 --- a/.agents/skills/operational-home-layout/SKILL.md +++ b/.agents/skills/operational-home-layout/SKILL.md @@ -30,7 +30,8 @@ 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 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/supervision-host optional supervision-host engine 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; LOCAL, gitignored, not inherited; see docs/configuration.md "Supervision host" +config/supervision-host-off optional presence flag opting this home out of the supervision host on every primary; LOCAL, gitignored; inherited by secondmate homes under the primary-authoritative contract; 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/secondmate-provisioning/SKILL.md b/.agents/skills/secondmate-provisioning/SKILL.md index d13b94357f4..cab5ba034ce 100644 --- a/.agents/skills/secondmate-provisioning/SKILL.md +++ b/.agents/skills/secondmate-provisioning/SKILL.md @@ -115,6 +115,8 @@ Inheritance copies the literal `config/crew-harness` file, so a secondmate's own Inherited `config/backend` becomes that secondmate home's local runtime-backend default for future spawns only; it never retargets, rewrites, migrates, stops, or restarts an already-live worker endpoint. A present primary value always converges byte-exact into validated secondmate homes, and primary absence removes the destination so those homes keep runtime auto-detection. Explicit per-spawn `--backend` and `FM_BACKEND` remain stronger than every home's local `config/backend`, including an inherited default. +The declared `config/supervision-host-off` opt-out follows the same primary-authoritative propagation: its presence opts secondmate homes out even if they have their own engine setting, and its absence removes their copy at convergence. +`config/supervision-host` itself is not inherited; each home selects its own engine. `config/secondmate-harness` is not inherited because it is only the primary's knob for launching secondmate agents. `config/claude-account` and `config/pi-account` are not inherited: a local secondmate agent launches on the launching home's worker account pin, and a secondmate home that should pin its own workers needs its own file ([`docs/configuration.md`](../../../docs/configuration.md) "Worker account pin"). `data/captain-shared.md` is main-authoritative in the primary home and read-only in secondmate homes. diff --git a/.omp/extensions/fm-primary-omp-watch.ts b/.omp/extensions/fm-primary-omp-watch.ts index 1043d07f245..dc78dc6d982 100644 --- a/.omp/extensions/fm-primary-omp-watch.ts +++ b/.omp/extensions/fm-primary-omp-watch.ts @@ -25,7 +25,7 @@ // /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 gate, which -// bin/fm-supervision-engine-lib.sh enabled answers; an `off` file opts out) spawns +// bin/fm-supervision-engine-lib.sh enabled answers; config/supervision-host-off 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 diff --git a/.opencode/plugins/fm-primary-watch-arm.js b/.opencode/plugins/fm-primary-watch-arm.js index 1be0d25ccd6..a846736308b 100644 --- a/.opencode/plugins/fm-primary-watch-arm.js +++ b/.opencode/plugins/fm-primary-watch-arm.js @@ -5,7 +5,7 @@ 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 gate, which -// bin/fm-supervision-engine-lib.sh enabled answers; an `off` file opts out) spawns +// bin/fm-supervision-engine-lib.sh enabled answers; config/supervision-host-off 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 diff --git a/bin/fm-claude-stop-autoarm.sh b/bin/fm-claude-stop-autoarm.sh index 31721989bda..69785abfc30 100755 --- a/bin/fm-claude-stop-autoarm.sh +++ b/bin/fm-claude-stop-autoarm.sh @@ -65,7 +65,7 @@ # host owns its own successors, so its path is unchanged. # - 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 +# 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:" diff --git a/bin/fm-config-inherit-lib.sh b/bin/fm-config-inherit-lib.sh index b037b21588c..00e0216930a 100644 --- a/bin/fm-config-inherit-lib.sh +++ b/bin/fm-config-inherit-lib.sh @@ -25,6 +25,9 @@ # secondmate's own claude crewmates launch on the same permission posture. # Primary config/keep-ai-trailers is a home-wide commit-attribution choice, so # a secondmate's own crewmates keep AI co-author trailers too. +# Primary config/supervision-host-off is the fleet's supervision-host opt-out, +# so a primary that opts out opts every secondmate home out too, while each +# home's config/supervision-host engine line stays its own. # It also pushes # the one primary-authoritative shared captain-preference file, # data/captain-shared.md, into each secondmate home's data/ as a read-only copy. @@ -79,7 +82,7 @@ FM_SHARED_CAPTAIN_MODE="444" # The declared inheritable set (space-separated, config-dir-relative item paths). # Extend here to inherit more of the primary's local config; override via the # environment only in tests. Items must not contain whitespace. -FM_INHERITABLE_CONFIG="${FM_INHERITABLE_CONFIG:-crew-dispatch.json dispatch-never-send crew-harness backlog-backend backend herdr-presentation-spaces startup-memory-budget trace-context launch-env-allowlist claude-permission-mode lavish-axi-host keep-ai-trailers}" +FM_INHERITABLE_CONFIG="${FM_INHERITABLE_CONFIG:-crew-dispatch.json dispatch-never-send crew-harness backlog-backend backend herdr-presentation-spaces startup-memory-budget trace-context launch-env-allowlist claude-permission-mode lavish-axi-host keep-ai-trailers supervision-host-off}" # Items whose value is a home-SESSION enablement decision rather than durable # local configuration. They are inherited at the launch convergence point, where diff --git a/bin/fm-live-lab.sh b/bin/fm-live-lab.sh index 752bf25a0cf..65462181a02 100755 --- a/bin/fm-live-lab.sh +++ b/bin/fm-live-lab.sh @@ -7,7 +7,7 @@ # Usage: # fm-live-lab.sh up --harness claude|pi [--mate] [--worker] # [--model ] [--effort ] -# [--supervision-host |none] [--expect-host yes|no] +# [--supervision-host |none|off] [--expect-host yes|no] # [--source ] [--ref ] [--timeout ] # [] # fm-live-lab.sh check @@ -32,7 +32,9 @@ # 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). +# on Pi; none leaves the file absent; off writes the +# inherited supervision-host-off opt-out instead, so the +# mate spawn inherits it). # 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 @@ -81,13 +83,16 @@ # 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. +# host Claude: with --expect-host yes (the default on Claude unless +# --supervision-host off) 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. +# live process, so it got past trust into its charter. With +# --supervision-host off, its inherited flag and disabled host +# gate are also required. # worker --worker: its current crew state is paused on the gate. # treehouse ~/.treehouse gained no entry since up began. # @@ -140,6 +145,7 @@ load_lab() { # : refuse anything up did not build, then load its record LAB=$(rec_get "$ROOT" home) TMUX_DIR=$(rec_get "$ROOT" tmux_dir) EXPECT_HOST=$(rec_get "$ROOT" expect_host) + HOST_OFF=$(rec_get "$ROOT" host_off) WANT_MATE=$(rec_get "$ROOT" mate) WANT_WORKER=$(rec_get "$ROOT" worker) NONCE=$(rec_get "$ROOT" nonce) @@ -310,10 +316,17 @@ check_watcher() { } check_mate() { - local pid + local pid gate_rc 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; } + if [ "$HOST_OFF" = yes ]; then + [ -f "$ROOT/mate/config/supervision-host-off" ] \ + || { echo "fail mate: the inherited supervision-host-off flag is missing"; return 1; } + bash "$ROOT/mate/bin/fm-supervision-engine-lib.sh" enabled "$ROOT/mate/config" claude + gate_rc=$? + [ "$gate_rc" -eq 1 ] || { echo "fail mate: the supervision-host gate did not read off (exit $gate_rc)"; return 1; } + fi echo "ok mate: $MATE_ID pid $pid in $ROOT/mate" } @@ -462,9 +475,11 @@ cmd_up() { 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; } + [ -n "$expect_host" ] || { [ "$harness" = claude ] && [ "$host_line" != off ] && 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; } + HOST_OFF=no + [ "$host_line" != off ] || HOST_OFF=yes [ -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 @@ -492,6 +507,7 @@ cmd_up() { echo "harness=$harness" echo "home=$LAB" echo "expect_host=$expect_host" + if [ "$host_line" = off ]; then echo 'host_off=yes'; else echo 'host_off=no'; fi echo "mate=$mate" echo "worker=$worker" echo "nonce=$NONCE" @@ -516,7 +532,11 @@ cmd_up() { 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" + case "$host_line" in + none) ;; + off) : > "$LAB/config/supervision-host-off" ;; + *) printf '%s\n' "$host_line" > "$LAB/config/supervision-host" ;; + esac 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" diff --git a/bin/fm-supervision-engine-lib.sh b/bin/fm-supervision-engine-lib.sh index fe05fe09881..4685aba170b 100644 --- a/bin/fm-supervision-engine-lib.sh +++ b/bin/fm-supervision-engine-lib.sh @@ -8,13 +8,14 @@ # 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 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 +# THE HOME GATE (config/supervision-host-off, config/supervision-host). +# docs/configuration.md "Supervision host" owns both files: the inherited +# opt-out flag, the home-local engine line's schema, the default on a Claude +# primary, and the 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 +# (docs/supervision-host.md "Engines"). Every reader of either file asks +# fm_supervision_host_enabled rather than testing the files 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 @@ -64,18 +65,14 @@ fm_supervision_host_primary() { } # 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. +# runs the supervision host. A present supervision-host-off opts out on every +# primary; otherwise a supervision-host file opts in, and with neither 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 +# both files are absent and the caller did not name one. fm_supervision_host_enabled() { - local word='' rest - if [ -f "$1/supervision-host" ]; then - read -r word rest < "$1/supervision-host" 2>/dev/null || true - [ "$word" != off ] - return - fi + [ ! -e "$1/supervision-host-off" ] && [ ! -L "$1/supervision-host-off" ] || return 1 + [ ! -f "$1/supervision-host" ] || return 0 [ "${2-$(fm_supervision_host_primary)}" = claude ] } @@ -341,7 +338,7 @@ _fm_engine_reap() { # an engine its crashed predecessor left running. fm_supervision_engine_turn() { local engine=$1 model=$2 prompt=$3 message=$4 session=$5 mode=$6 timeout=$7 result=$8 errors=$9 - local pid_file=${10:-} bin grace ledger watched rc home_phys root_phys state_phys identity recorded + local pid_file=${10:-} bin grace i ledger watched rc home_phys root_phys state_phys identity recorded local -a args bin=$(fm_supervision_engine_bin "$engine" 2>"$errors") || return 127 case "$timeout" in ''|0*|*[!0-9]*) timeout=1200 ;; esac @@ -395,7 +392,14 @@ fm_supervision_engine_turn() { fi fi _fm_engine_snapshot_descendants "$watched" "$ledger" - sleep 1 + # Between the one-second snapshots the engine's exit is probed at a tenth + # of a second: the turn closes promptly when the engine dies while the + # process-table scans keep their one-second cadence. + i=0 + while [ "$i" -lt 10 ] && fm_pid_alive "$watched"; do + sleep 0.1 + i=$((i + 1)) + done done wait "$watched" rc=$? diff --git a/bin/fm-supervision-host.sh b/bin/fm-supervision-host.sh index 7acbe148c8a..fd33c9bf63e 100755 --- a/bin/fm-supervision-host.sh +++ b/bin/fm-supervision-host.sh @@ -8,8 +8,8 @@ # # A primary's arm owner runs this in place of bin/fm-watch-arm.sh when the home # 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 +# never with config/supervision-host-off; 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 @@ -491,11 +491,19 @@ stream_ready_line() { # Wait for the current arm to close. Returns 0 with ARM_TEXT set, # or 1 when the park boundary arrives first. await_close() { + local i while fm_pid_alive "$ARM_PID"; do refresh_process "$ARM_PID" [ "$READY_PENDING" -eq 0 ] || stream_ready_line boundary_reached && return 1 - sleep "$POLL" + # The arm's exit is probed at a tenth of a second between POLL-cadence + # checks: the close is read as soon as the arm dies instead of up to POLL + # seconds late, while refresh keeps its per-second cadence. + i=$((POLL * 10)) + while [ "$i" -gt 0 ] && fm_pid_alive "$ARM_PID"; do + sleep 0.1 + i=$((i - 1)) + done done wait "$ARM_PID" 2>/dev/null || true ARM_TEXT=$(cat "$ARM_OUT" 2>/dev/null || true) diff --git a/bin/fm-supervision-instructions.sh b/bin/fm-supervision-instructions.sh index b294704d9f3..30e4fbdefcb 100755 --- a/bin/fm-supervision-instructions.sh +++ b/bin/fm-supervision-instructions.sh @@ -4,7 +4,7 @@ # with a supervision protocol (claude, cursor, opencode, omp, grok, codex) whose # 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 +# config/supervision-host elsewhere, never with config/supervision-host-off), 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 diff --git a/bin/fm-turnend-guard-cursor.sh b/bin/fm-turnend-guard-cursor.sh index 46ad4f9c563..a87d86a7c57 100755 --- a/bin/fm-turnend-guard-cursor.sh +++ b/bin/fm-turnend-guard-cursor.sh @@ -28,8 +28,8 @@ # 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 gate; an `off` file opts -# out, and a Cursor home without the file does not run the host) parks on +# (docs/configuration.md "Supervision host" owns the gate; +# config/supervision-host-off 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 diff --git a/bin/fm-watch-checkpoint.sh b/bin/fm-watch-checkpoint.sh index 3fb67e0cf4f..0e238d1ff9b 100755 --- a/bin/fm-watch-checkpoint.sh +++ b/bin/fm-watch-checkpoint.sh @@ -3,8 +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 gate; an `off` file opts -# out, and a Codex home without the file does not run the host) runs +# (docs/configuration.md "Supervision host" owns the gate; +# config/supervision-host-off 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). diff --git a/docs/configuration.md b/docs/configuration.md index 9fb57115e54..d22b42471e8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -300,15 +300,15 @@ 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` controls the supervision host for this home. +Two optional local, gitignored files control the supervision host for this home: `config/supervision-host-off` opts the home out, and `config/supervision-host` opts a home in and selects its engine. 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. -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. +A present `config/supervision-host-off`, whatever it holds, opts the home out on every primary. +Otherwise a Claude primary runs the host by default: with no `config/supervision-host` it runs exactly as with an empty one, at the Claude engine's default model. +A Cursor, OpenCode, omp, Grok, or Codex primary runs the host only while `config/supervision-host` exists and the home is not opted out. +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 either 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. @@ -319,22 +319,23 @@ Grok's arm command is rendered at session start, so a change to its host mode ta ### Engine selection -The file may be empty, hold `off`, or hold one line ` []`: +`config/supervision-host` may be empty 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. Only Claude has a verified engine of its own, so a Cursor, OpenCode, omp, Grok, or Codex home names `claude` in the file. -### Failures and when changes apply +### Failures, when changes apply, and inheritance 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 running host reads the file at every wake, so an engine change or `off` takes effect at the next wake without a restart. +The running host reads both files at every wake, so an engine change or an opt-out takes effect at the next wake without a restart. -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`. +The opt-out is inherited into secondmate homes: a primary that opts out also opts its secondmates out, and clearing it restores each mate's own host setting at its next spawn or convergence. +The primary-authoritative propagation contract, including removal of a mate's local opt-out when the primary has none, is owned by [`secondmate-provisioning`](../.agents/skills/secondmate-provisioning/SKILL.md). +`config/supervision-host` is local to each home and not inherited, because each home's engine and model are its own choice. 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) diff --git a/docs/supervision-host.md b/docs/supervision-host.md index d8f8973a460..583925c1a10 100644 --- a/docs/supervision-host.md +++ b/docs/supervision-host.md @@ -20,7 +20,7 @@ An arm owner is the component in each primary harness that starts watcher cycles ## Scope today -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. +The host runs by default on a Claude primary and is opt-in per home on the other five primaries it supports; [configuration.md](configuration.md#supervision-host-configsupervision-host) owns the home gate and inherited opt-out. 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). @@ -48,7 +48,7 @@ Until they land, their current behavior stays as described in their own owners. |---|---|---| | 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 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. | +| The engine | `bin/fm-supervision-engine-lib.sh` | Owns the home gate, including the default on Claude and the 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. | @@ -382,7 +382,7 @@ Today the only verified engine is Claude's print mode, measured on Claude Code 2 **Tool process reaping** Tool commands run in process groups of their own, which a bound's group signal cannot reach. -So the engine lib records the engine's descendants once a second and reaps them by recorded identity after every turn. +The engine lib records the engine's descendants while it runs and reaps them by recorded identity after every turn; its [header](../bin/fm-supervision-engine-lib.sh) owns the snapshot cadence. The reap is best-effort for what it observed, not a bound. A process escapes it when a tool detaches it into a process group of its own and it loses its ancestry to the engine between two snapshots. Such a process is never recorded and survives the turn, the same residual `bin/fm-timeout-lib.sh` names. diff --git a/docs/supervision-protocols/claude.md b/docs/supervision-protocols/claude.md index 8b0e486d69e..95e2b71adf1 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 on a home that opted out of the [supervision host](../supervision-host.md) (`config/supervision-host` holding `off`). +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-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 2c9d59db1f2..64097f81b0f 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` (not `off`) 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` and no `config/supervision-host-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 678ef95c151..ff847ec489f 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` holding `off` turns it off; [`supervision-host.md`](../supervision-host.md) owns the design). +Supervision host: on for this home (`config/supervision-host-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/tests/fm-afk-launch.test.sh b/tests/fm-afk-launch.test.sh index e232be4ce52..4cf07950437 100755 --- a/tests/fm-afk-launch.test.sh +++ b/tests/fm-afk-launch.test.sh @@ -31,12 +31,12 @@ 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 +# A Claude home runs the supervision host unless config/supervision-host-off +# opts it out (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" +: > "$OFF_CONFIG/supervision-host-off" export FM_CONFIG_OVERRIDE="$OFF_CONFIG" FAILED=0 @@ -966,14 +966,14 @@ unit_supervision_host_claude_home_runs_no_away_daemon() { 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" FM_CONFIG_OVERRIDE="$st/config" "$LAUNCH" stop >/dev/null 2>&1 || true - printf 'off\n' > "$st/config/supervision-host" + : > "$st/config/supervision-host-off" 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" + pass "supervision host: config/supervision-host-off 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" + fail "supervision host: config/supervision-host-off 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" @@ -995,10 +995,11 @@ unit_supervision_host_other_harnesses_run_no_away_daemon() { 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" + : > "$st/config/supervision-host-off" 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" + daemon_allowed "$harness" >/dev/null || fail "$harness: a home opted out by config/supervision-host-off must keep the away daemon" done + rm -f "$st/config/supervision-host-off" : > "$st/config/supervision-host" for harness in cursor opencode omp grok codex; do out=$(daemon_allowed "$harness"); rc=$? @@ -1010,9 +1011,13 @@ unit_supervision_host_other_harnesses_run_no_away_daemon() { daemon_allowed kimi >/dev/null || fail "kimi has no arm owner to run the host, so it must keep the away daemon" pass "supervision host: away mode on an opted-in cursor, opencode, omp, grok, or codex home launches no daemon" - enter_with() { # - rm -f "$st/state/.afk-contract" "$st/config/supervision-host" - [ "$2" = - ] || printf '%s\n' "$2" > "$st/config/supervision-host" + enter_with() { # + rm -f "$st/state/.afk-contract" "$st/config/supervision-host" "$st/config/supervision-host-off" + case "$2" in + -) ;; + off) : > "$st/config/supervision-host-off" ;; + *) printf '%s\n' "$2" > "$st/config/supervision-host" ;; + esac 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 } @@ -1099,10 +1104,10 @@ 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" + : > "$st/config/supervision-host-off" 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" + [ "$rc" -eq 1 ] && [ -z "$out" ] || fail "quiet-check on a claude home opted out by config/supervision-host-off must exit 1 silently (rc=$rc): $out" + rm -f "$st/config/supervision-host" "$st/config/supervision-host-off" 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=$? @@ -1278,7 +1283,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" - printf 'off\n' > "$st/config/supervision-host" + : > "$st/config/supervision-host-off" 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 29472b6be75..26499a4c350 100644 --- a/tests/fm-branch-supervision.test.sh +++ b/tests/fm-branch-supervision.test.sh @@ -982,15 +982,15 @@ test_host_home_unmarked_guard_excludes_the_first_claim() { ' _ "$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" + rm -f "$home/config/supervision-host" "$home/config/supervision-host-off" + [ "$line" = - ] || : > "$home/config/supervision-host-off" 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" + rm -f "$home/config/supervision-host" "$home/config/supervision-host-off" 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" diff --git a/tests/fm-claude-stop-autoarm.test.sh b/tests/fm-claude-stop-autoarm.test.sh index 3f3615b6c78..7b47c25e854 100755 --- a/tests/fm-claude-stop-autoarm.test.sh +++ b/tests/fm-claude-stop-autoarm.test.sh @@ -42,16 +42,16 @@ install_autoarm_scripts() { 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. +# A Claude home runs the supervision host unless config/supervision-host-off +# opts it out, so the fixture home opts out: most cases exercise the plain arm, +# and the supervision-host cases below remove the opt-out. make_primary_dir() { local dir=$1 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" + : > "$dir/config/supervision-host-off" install_autoarm_scripts "$dir" printf '%s\n' "$dir" } @@ -1432,23 +1432,23 @@ SH test_host_off_flag_keeps_the_arm() { local dir out status dir=$(make_primary_dir "$TMP_ROOT/host-flag-off") - printf 'off\n' > "$dir/config/supervision-host" + : > "$dir/config/supervision-host-off" : > "$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 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" + expect_code 2 "$status" "a home opted out by config/supervision-host-off must still rewake from the arm" + assert_present "$dir/state/arm-ran" "a home opted out by config/supervision-host-off did not run the arm" + [ ! -e "$dir/state/host-ran" ] || fail "a home opted out by config/supervision-host-off ran the supervision host" assert_contains "$out" "stale: fixture-win actionable" "the arm's reason must still reach the rewake" 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" + pass "auto-arm: config/supervision-host-off 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" + rm -f "$dir/config/supervision-host-off" : > "$dir/state/task.meta" write_arm_fixture "$dir" actionable write_host_fixture "$dir" boundary @@ -1466,7 +1466,7 @@ test_host_boundary_rewakes_with_the_host_line() { local dir out status dir=$(make_primary_dir "$TMP_ROOT/host-boundary") mkdir -p "$dir/config" - : > "$dir/config/supervision-host" + rm -f "$dir/config/supervision-host-off" : > "$dir/state/task.meta" write_arm_fixture "$dir" actionable write_host_fixture "$dir" boundary @@ -1490,7 +1490,7 @@ test_host_handback_under_away_record_is_not_a_return() { local dir out status dir=$(make_primary_dir "$TMP_ROOT/host-handback") mkdir -p "$dir/config" - : > "$dir/config/supervision-host" + rm -f "$dir/config/supervision-host-off" : > "$dir/state/task.meta" : > "$dir/state/.afk-contract" write_host_fixture "$dir" handed-back @@ -1508,7 +1508,7 @@ 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" + rm -f "$dir/config/supervision-host-off" : > "$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" @@ -1539,7 +1539,7 @@ test_host_handback_carries_every_host_line() { local dir out status expected dir=$(make_primary_dir "$TMP_ROOT/host-many") mkdir -p "$dir/config" - : > "$dir/config/supervision-host" + rm -f "$dir/config/supervision-host-off" : > "$dir/state/task.meta" write_host_fixture "$dir" handed-back-many out=$(run_autoarm "$dir" 2>/dev/null); status=$? @@ -1559,7 +1559,7 @@ test_host_stand_down_is_silent() { local dir out status dir=$(make_primary_dir "$TMP_ROOT/host-stand-down") mkdir -p "$dir/config" - : > "$dir/config/supervision-host" + rm -f "$dir/config/supervision-host-off" : > "$dir/state/task.meta" write_host_fixture "$dir" stood-down out=$(run_autoarm "$dir" 2>/dev/null); status=$? @@ -1574,7 +1574,7 @@ test_host_crash_is_retried_then_reported() { local dir out status dir=$(make_primary_dir "$TMP_ROOT/host-crash") mkdir -p "$dir/config" - : > "$dir/config/supervision-host" + rm -f "$dir/config/supervision-host-off" : > "$dir/state/task.meta" write_host_fixture "$dir" crash # A live watcher with a fresh beacon would pass the plain arm's benign-close @@ -1597,7 +1597,7 @@ test_arguments_never_arm() { local dir arg rc out before after before_contents after_contents status dir=$(make_primary_dir "$TMP_ROOT/help-mode") mkdir -p "$dir/config" - : > "$dir/config/supervision-host" + rm -f "$dir/config/supervision-host-off" : > "$dir/state/task.meta" write_arm_fixture "$dir" actionable write_host_fixture "$dir" boundary diff --git a/tests/fm-cursor-primary.test.sh b/tests/fm-cursor-primary.test.sh index 86e4eeb002a..df024fe3ae2 100755 --- a/tests/fm-cursor-primary.test.sh +++ b/tests/fm-cursor-primary.test.sh @@ -515,12 +515,12 @@ test_park_runs_the_supervision_host_only_when_opted_in() { 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" + : > "$dir/config/supervision-host-off" 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" + [ -e "$dir/state/arm-ran" ] || fail "a home opted out by config/supervision-host-off must park on the arm" + [ ! -e "$dir/state/host-ran" ] || fail "a home opted out by config/supervision-host-off ran the supervision host" dir=$(make_primary_dir "$TMP_ROOT/park-host-on") : > "$dir/state/task1.meta" diff --git a/tests/fm-host-mirror.test.sh b/tests/fm-host-mirror.test.sh index 565496241af..6d33ad950ae 100755 --- a/tests/fm-host-mirror.test.sh +++ b/tests/fm-host-mirror.test.sh @@ -31,12 +31,12 @@ git init -q "$PRIMARY_ROOT" : > "$PRIMARY_ROOT/AGENTS.md" ln -s "$ROOT/bin" "$PRIMARY_ROOT/bin" -make_home() { # [config/supervision-host: 1 (empty file) | 0 (none) | off] +make_home() { # [1 (empty config/supervision-host) | 0 (none) | off (config/supervision-host-off)] local home="$TMP_ROOT/$1" mkdir -p "$home/state" "$home/config" case "${2:-1}" in 1) : > "$home/config/supervision-host" ;; - off) printf 'off\n' > "$home/config/supervision-host" ;; + off) : > "$home/config/supervision-host-off" ;; esac printf '%s\n' "$home" } @@ -90,7 +90,7 @@ 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 whose config/supervision-host says off, every +# Non-host invariance: on a home opted out by config/supervision-host-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. @@ -114,7 +114,7 @@ test_home_that_opted_out_is_untouched() { [ ! -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 that opted out" - pass "mirror: a home whose config/supervision-host says off is untouched by every tracked mirror registration" + pass "mirror: a home opted out by config/supervision-host-off is untouched by every tracked mirror registration" } # Default-on for Claude: with no config/supervision-host, the Claude @@ -145,7 +145,7 @@ test_writers_are_inert_on_a_home_that_opted_out() { 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 whose config/supervision-host says off must mirror nothing" + assert_absent "$home/state/.host-mirror.jsonl" "a home opted out by config/supervision-host-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) diff --git a/tests/fm-live-lab-up-mate.test.sh b/tests/fm-live-lab-up-mate.test.sh new file mode 100644 index 00000000000..016b79b8cf5 --- /dev/null +++ b/tests/fm-live-lab-up-mate.test.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Exercise up's mate readiness path with both supervision-host settings. +set -u +# shellcheck source=tests/fixtures.sh +. "$(dirname "${BASH_SOURCE[0]}")/fixtures.sh" + +command -v tmux >/dev/null 2>&1 || { echo 'ok - skipped: tmux is not installed'; exit 0; } +TMP_ROOT=$(fm_test_tmproot fm-live-up-mate) +export HOME="$TMP_ROOT/user" +mkdir -p "$HOME/.pi/agent" "$HOME/.treehouse" "$TMP_ROOT/source/bin" "$TMP_ROOT/fakebin" +printf '{}\n' > "$HOME/.pi/agent/trust.json" +unset CLAUDE_CONFIG_DIR TMUX + +# A source checkout with a stubbed mate launch: it creates the same observable +# mate window/lock and opt-out material, without contacting a model. +cp -R "$ROOT/bin/." "$TMP_ROOT/source/bin/" +cp "$ROOT/AGENTS.md" "$TMP_ROOT/source/AGENTS.md" +cat > "$TMP_ROOT/source/bin/fm-home-seed.sh" <<'SH' +#!/usr/bin/env bash +mkdir -p "$2/state" "$2/config" "$2/bin" +cp "$FM_HOME/bin/fm-supervision-engine-lib.sh" "$2/bin/" +if [ -f "$FM_HOME/config/supervision-host-off" ]; then + : > "$2/config/supervision-host-off" +fi +SH +cat > "$TMP_ROOT/source/bin/fm-spawn.sh" <<'SH' +#!/usr/bin/env bash +tmux new-window -d -t firstmate: -n "fm-$1" -c "$FM_HOME/../mate" 'exec sleep 45' || exit 1 +pid=$(tmux display-message -p -t "firstmate:=fm-$1" '#{pane_pid}') +printf '%s\n' "$pid" > "$FM_HOME/../mate/state/.lock" +printf 'window=firstmate:fm-%s\n' "$1" > "$FM_HOME/state/$1.meta" +SH +cat > "$TMP_ROOT/fakebin/claude" <<'SH' +#!/usr/bin/env bash +: > "$FM_HOME/state/.session-start-complete" +exec sleep 45 +SH +chmod +x "$TMP_ROOT/source/bin/"{fm-home-seed,fm-spawn}.sh "$TMP_ROOT/fakebin/claude" +git -C "$TMP_ROOT/source" init -q -b main +git -C "$TMP_ROOT/source" add -A +git -C "$TMP_ROOT/source" -c user.name=t -c user.email=t@example.invalid commit -qm stub + +cleanup_labs() { + local root + for root in "$TMP_ROOT"/lab-*; do + [ -f "$root/.fm-live-lab" ] || continue + PATH="$TMP_ROOT/fakebin:$PATH" "$ROOT/bin/fm-live-lab.sh" down "$root" >/dev/null 2>&1 || true + done + fm_test_cleanup +} +trap cleanup_labs EXIT + +for mode in off on; do + lab="$TMP_ROOT/lab-$mode" + host=claude + [ "$mode" = off ] && host=off + out=$(PATH="$TMP_ROOT/fakebin:$PATH" SHELL=/bin/sh "$ROOT/bin/fm-live-lab.sh" up --harness claude --mate --supervision-host "$host" --source "$TMP_ROOT/source" --timeout 0 "$lab" 2>&1) + rc=$? + expect_code 1 "$rc" "unanswered probe leaves the $mode mate lab for inspection" + assert_not_contains "$out" 'HOST_OFF: unbound variable' "up $mode sets mate readiness state" + assert_contains "$out" 'ok mate:' "up $mode checks the launched mate" + assert_contains "$out" 'primary: claude' "up $mode reaches primary launch" + if [ "$mode" = off ]; then + assert_present "$lab/mate/config/supervision-host-off" "off mate receives inherited opt-out" + else + assert_absent "$lab/mate/config/supervision-host-off" "on mate has no opt-out" + fi + pass "up --mate with supervision host $mode reaches readiness" +done diff --git a/tests/fm-live-lab.test.sh b/tests/fm-live-lab.test.sh index 1e5b957dd37..023bdc69dc3 100755 --- a/tests/fm-live-lab.test.sh +++ b/tests/fm-live-lab.test.sh @@ -70,6 +70,7 @@ make_lab() { echo "harness=$harness" echo "home=$home" echo "expect_host=yes" + echo "host_off=no" echo "mate=yes" echo "worker=yes" echo "nonce=$NONCE" @@ -253,6 +254,31 @@ assert_contains "$CHECK_OUT" "fail mate: the mate holds no session lock yet" "ma 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" +# Opt-out readiness must observe the mate's inherited material and its real +# home gate, rather than just the primary's absent host. +set_record "$C" host_off yes +set_record "$C" expect_host no +host_pid=$(awk -F '\t' '{print $2}' "$CH/state/.supervision-host") +kill "$host_pid" 2>/dev/null +wait "$host_pid" 2>/dev/null +mkdir -p "$C/mate/config" "$C/mate/bin" +cp "$ROOT/bin/fm-supervision-engine-lib.sh" "$C/mate/bin/" +run_check "$C" +expect_code 1 "$CHECK_RC" "off readiness refuses a mate without its inherited flag" +assert_contains "$CHECK_OUT" "fail mate: the inherited supervision-host-off flag is missing" "mate names the missing opt-out" +: > "$C/mate/config/supervision-host-off" +run_check "$C" +expect_code 0 "$CHECK_RC" "off readiness accepts the mate's inherited flag and disabled gate: $CHECK_OUT" +printf '#!/usr/bin/env bash\nexit 0\n' > "$C/mate/bin/fm-supervision-engine-lib.sh" +run_check "$C" +expect_code 1 "$CHECK_RC" "off readiness refuses a mate whose gate reads on" +assert_contains "$CHECK_OUT" "fail mate: the supervision-host gate did not read off" "mate names the enabled gate" +cp "$ROOT/bin/fm-supervision-engine-lib.sh" "$C/mate/bin/" +set_record "$C" host_off no +set_record "$C" expect_host yes +printf 'host\t%s\tx\n' "$(start_sleeper)" > "$CH/state/.supervision-host" +pass "mate off readiness requires inherited material and a disabled home gate" + # 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" diff --git a/tests/fm-omp-harness.test.sh b/tests/fm-omp-harness.test.sh index 131d31aa3aa..5af5106fb70 100755 --- a/tests/fm-omp-harness.test.sh +++ b/tests/fm-omp-harness.test.sh @@ -669,7 +669,7 @@ EOF } # 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. +# one opted out by config/supervision-host-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 @@ -677,7 +677,7 @@ test_watch_extension_keeps_the_arm_without_the_file_or_with_off() { 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" + [ "$line" = - ] || : > "$home/config/supervision-host-off" cat > "$repo/bin/fm-watch-arm.sh" <<'SH' #!/usr/bin/env bash [ "${1:-}" = --handling-delivered ] && exit 0 diff --git a/tests/fm-secondmate-harness.test.sh b/tests/fm-secondmate-harness.test.sh index eb99413fedf..ddacc09e006 100755 --- a/tests/fm-secondmate-harness.test.sh +++ b/tests/fm-secondmate-harness.test.sh @@ -15,7 +15,8 @@ # B) Inheritance. The primary pushes a declared, extensible set of LOCAL # (gitignored) config items - config/crew-dispatch.json, config/crew-harness, # config/backlog-backend, config/backend, config/herdr-presentation-spaces, -# config/startup-memory-budget, and config/trace-context - +# config/startup-memory-budget, config/trace-context, and +# config/supervision-host-off - # down into each secondmate home's config/, so the secondmate's OWN crewmates, # dispatch profiles, backlog backend, runtime-backend default, Herdr # presentation choice, startup-memory budget, and trace context inherit the @@ -395,17 +396,26 @@ 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)" + # 5b. the supervision-host opt-out is inherited and primary-authoritative, + # while each home's engine line stays its own: the primary's off reaches the + # secondmate and the real gate reads that home as off on a Claude primary + # despite its own engine line; clearing the primary's off converges it back on. + printf 'claude sonnet\n' > "$src/supervision-host" printf 'default haiku\n' > "$d/home2/config/supervision-host" - rm -f "$src/supervision-host" + : > "$src/supervision-host-off" + propagate_inheritable_config "$src" "$d/home2/config" + [ -f "$d/home2/config/supervision-host-off" ] || fail "a primary's supervision-host-off was not inherited" + if bash "$ROOT/bin/fm-supervision-engine-lib.sh" enabled "$d/home2/config" claude; then + fail "a secondmate that inherited the primary's opt-out still runs the supervision host" + fi + rm -f "$src/supervision-host-off" propagate_inheritable_config "$src" "$d/home2/config" + [ -e "$d/home2/config/supervision-host-off" ] && fail "clearing the primary's supervision-host-off was not mirrored downstream" + bash "$ROOT/bin/fm-supervision-engine-lib.sh" enabled "$d/home2/config" claude \ + || fail "a secondmate did not converge back on once the primary cleared its opt-out" [ "$(cat "$d/home2/config/supervision-host" 2>/dev/null)" = 'default haiku' ] \ - || fail "a secondmate's own supervision-host was changed by convergence" + || fail "a secondmate's own supervision-host engine line was changed by convergence" + rm -f "$src/supervision-host" # 6. nothing to propagate -> destination dir is never created (a true no-op) rm -rf "$d/src3" "$d/dest3" @@ -508,6 +518,7 @@ test_spawn_split_and_inherit() { printf 'codex\n' > "$w/home/config/secondmate-harness" printf 'manual\n' > "$w/home/config/backlog-backend" printf 'zellij\n' > "$w/home/config/backend" + : > "$w/home/config/supervision-host-off" make_seeded_home "$sm" sm spawn_secondmate "$w" sm "$sm" @@ -526,6 +537,11 @@ test_spawn_split_and_inherit() { || fail "split: home backend not inherited as zellij" [ -e "$sm/config/secondmate-harness" ] \ && fail "split: secondmate-harness leaked into the secondmate home" + [ -f "$sm/config/supervision-host-off" ] \ + || fail "split: home supervision-host-off not inherited" + if bash "$ROOT/bin/fm-supervision-engine-lib.sh" enabled "$sm/config" claude; then + fail "split: a secondmate spawned under an opted-out primary still runs the supervision host" + fi pass "B2 spawn: secondmate runs the secondmate harness; its home inherits declared config" } diff --git a/tests/fm-session-lock-ancestry.test.sh b/tests/fm-session-lock-ancestry.test.sh index df1d7537097..6076057bb17 100755 --- a/tests/fm-session-lock-ancestry.test.sh +++ b/tests/fm-session-lock-ancestry.test.sh @@ -446,7 +446,7 @@ install_autoarm_scripts() { # 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" + : > "$dir/config/supervision-host-off" 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 376716d1762..2e76714a07e 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -1723,7 +1723,7 @@ EOF 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" + : > "$home/config/supervision-host-off" 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 4f4812aabd1..13509f74d4f 100755 --- a/tests/fm-supervision-host.test.sh +++ b/tests/fm-supervision-host.test.sh @@ -149,12 +149,15 @@ 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 arms= + local home=$1 pid arms='' i=0 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 + while [ "$i" -lt 50 ] && [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; do + sleep 0.1 + i=$((i + 1)) + done fi for pid in $arms; do kill -TERM "$pid" 2>/dev/null || true @@ -441,12 +444,12 @@ test_branch_outcomes_only_on_a_host_home_off_pi() { ln -sf /bin/bash "$fakes/pi" ln -sf /bin/bash "$fakes/codex" - printf 'off\n' > "$home/config/supervision-host" + : > "$home/config/supervision-host-off" drained=$(FM_HOME="$home" "$FAKE_CLAUDE" -c '"$0" 2>&1' "$ROOT/bin/fm-wake-drain.sh") - 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" + assert_not_contains "$drained" "BRANCH OUTCOMES" "a Claude home opted out by config/supervision-host-off must not present branch outcomes" + assert_absent "$home/state/.branch-outcomes-cursor" "a Claude home opted out by config/supervision-host-off must keep the store's read cursor untouched" - rm -f "$home/config/supervision-host" + rm -f "$home/config/supervision-host" "$home/config/supervision-host-off" 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" @@ -994,7 +997,7 @@ test_off_written_while_parked_passes_the_next_attended_close_to_main() { 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" + : > "$home/config/supervision-host-off" 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" @@ -1216,8 +1219,8 @@ test_claude_stop_hook_rewakes_a_present_captain_beside_a_quiet_record() { # 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 +# captain's dialog, and keeps a routine attended wake off main; a home with +# config/supervision-host-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 @@ -1241,7 +1244,7 @@ test_claude_stop_hook_runs_the_host_without_the_file_and_off_opts_out() { stop_home_processes "$home" home=$(make_primary_home hook-opted-out) - printf 'off\n' > "$home/config/supervision-host" + : > "$home/config/supervision-host-off" 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)" @@ -1250,9 +1253,9 @@ test_claude_stop_hook_runs_the_host_without_the_file_and_off_opts_out() { 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" + assert_absent "$home/state/.supervision-host.log" "a home opted out by config/supervision-host-off must never run the host" + assert_absent "$home/state/.host-mirror.jsonl" "a home opted out by config/supervision-host-off must mirror nothing" + [ "$(engine_calls "$home")" -eq 0 ] || fail "a home opted out by config/supervision-host-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" @@ -1513,13 +1516,15 @@ test_undelivered_dialog_is_fed_again_on_the_next_turn() { real_node=$(command -v node) cat > "$home/fakebin/node" < "\$FM_HOME/park-clock"; fi +if [ "\${2:-}" = wake-prompt ] && [ -e "\$FM_HOME/slow-render" ]; then echo 120 > "\$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" 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" + # The park bound sits past every wall-clock check below, so a host that + # ignored the test clock could never reach a boundary inside this case. + FM_TEST_SUPERVISION_HOST_CLOCK="$home/park-clock" FM_SUPERVISION_HOST_PARK_SECONDS=120 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")" @@ -2035,8 +2040,12 @@ test_restarted_host_stops_what_a_killed_predecessor_left() { test_park_boundary_ends_the_park_before_the_hook_timeout() { local home token home=$(make_home boundary attended) - FM_SUPERVISION_HOST_PARK_SECONDS=3 start_host "$home" + # The wall-clock bound must sit past the exit check: only the injected clock + # can reach the boundary in time, so a host ignoring it fails instead of + # passing on real elapsed seconds. + FM_SUPERVISION_HOST_PARK_SECONDS=60 FM_TEST_SUPERVISION_HOST_CLOCK="$home/park-clock" start_host "$home" wait_until 150 watcher_live "$home" || fail "boundary: the host never started a watcher cycle" + echo 60 > "$home/park-clock" wait_until 150 host_exited "$home" || fail "boundary: the host did not end its park" assert_re '^supervision-host: cycle boundary - ' "$home/host.out" "the park boundary must reach main as a host line" watcher_live "$home" && fail "the park boundary left the watcher running" @@ -2053,11 +2062,13 @@ test_park_boundary_ends_the_park_before_the_hook_timeout() { # park runs on the test clock (FM_TEST_SUPERVISION_HOST_CLOCK), which the test # moves to the refusal window's opening (park bound minus the turn bound and # grace) before it releases the held turn, so the second close can never take -# a turn of its own on any machine speed. +# a turn of its own on any machine speed. The bound also stays well past every +# wall-clock check in the case: a host that ignored the test clock would start +# the second turn instead of silently passing at a wall-clock boundary. test_park_boundary_holds_under_back_to_back_closes() { # The turn bound is the one wall-clock bound left: it must cover the stub's # report work after release, so the product never kills the held turn. - local home park=36 turn=19 grace=1 + local home park=300 turn=19 grace=1 home=$(make_home boundary-busy away) echo held > "$home/stub-mode" mkfifo "$home/stub-release" @@ -2093,9 +2104,11 @@ test_park_boundary_holds_under_back_to_back_closes() { # shim holds the render on a FIFO, and the test moves the park's test clock to # the refusal window's opening before releasing it, so the close passes the # arrival check and the pre-turn recheck must refuse on any machine speed. The -# snapshot proves the successor arm it started can be checked afterwards. +# snapshot proves the successor arm it started can be checked afterwards. The +# park bound stays past the case's wall-clock checks, so an ignored test clock +# would let the turn run and the engine-call assertions catch it. test_park_boundary_rechecked_just_before_the_engine_turn() { - local home real_node pid park=14 turn=3 grace=1 + local home real_node pid park=120 turn=3 grace=1 home=$(make_home boundary-late away) real_node=$(command -v node) mkfifo "$home/render-release" @@ -2421,15 +2434,15 @@ 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" - printf 'off\n' > "$home/config/supervision-host" + : > "$home/config/supervision-host-off" 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 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" + "a home opted out by config/supervision-host-off must hand the close back as the opt-out, not the latch" + assert_no_re 'paused' "$home/host.out" "a home opted out by config/supervision-host-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 that opted out with off never reads it" } @@ -2445,6 +2458,9 @@ scan_marker_age() { # -> seconds since the last inactive-outcome scan perl -e 'my @s = stat $ARGV[0] or exit 1; print time - $s[9]' "$1/state/.inactive-outcome-reconcile" } scan_ran() { [ "$(scan_marker_age "$1" 2>/dev/null || echo 999999)" -lt 60 ]; } +scan_idle() { # + [ ! -e "$1/state/.inactive-outcome-reconcile.lock" ] && [ ! -L "$1/state/.inactive-outcome-reconcile.lock" ] +} captain_rows() { # local rows rows=$(grep -c '"verdict":"captain"' "$1/state/branch-outcomes.jsonl" 2>/dev/null) @@ -2488,7 +2504,8 @@ test_unchanged_held_outcome_reaches_the_captain_once_until_a_new_event() { perl -e 'my $t = shift; utime $t, $t, @ARGV or exit 1' "$old" "$home/state/.inactive-outcome-reconcile" \ || fail "held: could not age the scan marker before cadence $cycle" wait_until 150 scan_ran "$home" || fail "held: cadence $cycle never rescanned" - ! wait_until 30 flood_signal "$home" \ + wait_until 150 scan_idle "$home" || fail "held: cadence $cycle never finished its scan" + ! wait_until 10 flood_signal "$home" \ || fail "held: cadence $cycle re-escalated the unchanged held outcome: $(cat "$home/state/branch-outcomes.jsonl")" done [ "$(captain_rows "$home")" -eq 1 ] || fail "held: the unchanged situation reached the captain $(captain_rows "$home") times" diff --git a/tests/fm-supervision-instructions.test.sh b/tests/fm-supervision-instructions.test.sh index b992f33809b..d2094157ed7 100755 --- a/tests/fm-supervision-instructions.test.sh +++ b/tests/fm-supervision-instructions.test.sh @@ -27,10 +27,10 @@ test_supervision_host_protocol_on_a_claude_home_unless_off() { home="$TMP_ROOT/host-home" config="$TMP_ROOT/host-config" mkdir -p "$home/state" "$config" - printf 'off\n' > "$config/supervision-host" + : > "$config/supervision-host-off" plain=$(FM_HOME="$home" FM_CONFIG_OVERRIDE="$config" "$RENDER" --harness claude) - assert_not_contains "$plain" "Supervision host" "a claude home whose config/supervision-host says off rendered the host protocol" - rm -f "$config/supervision-host" + assert_not_contains "$plain" "Supervision host" "a claude home opted out by config/supervision-host-off rendered the host protocol" + rm -f "$config/supervision-host-off" 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" \ @@ -46,12 +46,12 @@ test_supervision_host_protocol_on_a_claude_home_unless_off() { 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" + pass "renderer adds the supervision-host protocol on a claude home unless config/supervision-host-off opts it out, 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 whose -# file says off, or a non-Claude home without the file, renders exactly what +# own terms; Grok's model-owned arm command becomes the host; a home with +# config/supervision-host-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 @@ -59,15 +59,16 @@ test_supervision_host_protocol_on_every_arm_owner() { config="$TMP_ROOT/host-owners-config" mkdir -p "$home/state" "$config" for harness in claude cursor opencode omp grok codex; do - printf 'off\n' > "$config/supervision-host" + : > "$config/supervision-host-off" plain=$(FM_HOME="$home" FM_CONFIG_OVERRIDE="$config" "$RENDER" --harness "$harness") - assert_not_contains "$plain" "Supervision host" "$harness: a home whose config/supervision-host says off rendered the host protocol" + assert_not_contains "$plain" "Supervision host" "$harness: a home opted out by config/supervision-host-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" + rm -f "$config/supervision-host" "$config/supervision-host-off" 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 + rm -f "$config/supervision-host-off" : > "$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)." \ @@ -86,9 +87,10 @@ 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" + : > "$config/supervision-host-off" 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" + rm -f "$config/supervision-host-off" : > "$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" diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index 3f6a29229f5..8da2671a405 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -1224,7 +1224,7 @@ install_integrated_autoarm() { # 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" + : > "$dir/config/supervision-host-off" } run_integrated_autoarm() { diff --git a/tests/fm-wake-drain-outcome-backstop.test.sh b/tests/fm-wake-drain-outcome-backstop.test.sh index de2fd5ca838..2850a664607 100755 --- a/tests/fm-wake-drain-outcome-backstop.test.sh +++ b/tests/fm-wake-drain-outcome-backstop.test.sh @@ -17,7 +17,7 @@ TMP_ROOT=$(fm_test_tmproot fm-wake-drain-outcome-backstop-tests) # 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" +: > "$TMP_ROOT/config/supervision-host-off" export FM_CONFIG_OVERRIDE="$TMP_ROOT/config" set_mtime() { # diff --git a/tests/fm-wake-drain-unread-status.test.sh b/tests/fm-wake-drain-unread-status.test.sh index b659b880e0e..f28aeb1ac82 100755 --- a/tests/fm-wake-drain-unread-status.test.sh +++ b/tests/fm-wake-drain-unread-status.test.sh @@ -21,7 +21,7 @@ TMP_ROOT=$(fm_test_tmproot fm-wake-drain-unread-status-tests) # 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" +: > "$TMP_ROOT/config/supervision-host-off" export FM_CONFIG_OVERRIDE="$TMP_ROOT/config" # Establish the durable last-presentation cursor by draining once over a diff --git a/tests/fm-watch-checkpoint.test.sh b/tests/fm-watch-checkpoint.test.sh index 8626faad9d4..a7d2dd802ab 100755 --- a/tests/fm-watch-checkpoint.test.sh +++ b/tests/fm-watch-checkpoint.test.sh @@ -160,13 +160,13 @@ test_host_checkpoint_passes_a_handback_and_reports_a_stand_down() { } # The Codex owner stays file-gated: without config/supervision-host, or with -# a file that says off, the checkpoint never runs the host. +# config/supervision-host-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" + rm -f "$home/config/supervision-host" "$home/config/supervision-host-off" "$home/host-env" + [ "$line" = - ] || : > "$home/config/supervision-host-off" 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