From b29621ba19a4d15b688ae277ca06b67f80baa365 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:28:14 -0700 Subject: [PATCH 01/35] feat(bin): add verified pi-signed runtime adapter (#1145) * feat: add verified pi-signed adapter * no-mistakes(review): Correct pi-signed maintainer verification date * no-mistakes(review): Correct remaining pi-signed verification dates * no-mistakes(review): Preserve authoritative pi-signed runtime identity * no-mistakes(document): Document pi-signed shared adapter semantics * no-mistakes: apply CI fixes --- .agents/skills/afk/SKILL.md | 2 +- .agents/skills/firstmate-orca/SKILL.md | 2 +- .agents/skills/harness-adapters/SKILL.md | 32 ++++++---- AGENTS.md | 2 +- README.md | 8 ++- bin/backends/tmux.sh | 2 +- bin/fm-bootstrap.sh | 6 +- bin/fm-harness.sh | 8 ++- bin/fm-session-lock-lib.sh | 15 ++++- bin/fm-session-start.sh | 6 +- bin/fm-spawn.sh | 31 +++++++--- bin/fm-supervision-instructions.sh | 5 +- bin/fm-tmux-lib.sh | 2 +- docs/architecture.md | 4 +- docs/arm-pretool-check.md | 6 +- docs/cd-guard.md | 6 +- docs/configuration.md | 8 ++- docs/sessionstart-nudge.md | 4 +- docs/supervision-protocols/pi.md | 4 +- docs/tmux-backend.md | 8 +-- docs/turnend-guard.md | 6 +- docs/verification/runtime-backends.md | 48 ++++++++++++++- docs/verification/supervision.md | 2 + tests/fm-bootstrap.test.sh | 1 + tests/fm-composer-ghost.test.sh | 6 +- tests/fm-instruction-owners.test.sh | 2 +- tests/fm-kimi-harness.test.sh | 4 +- tests/fm-secondmate-harness.test.sh | 68 +++++++++++++++++++++ tests/fm-secondmate-liveness.test.sh | 24 +++++++- tests/fm-session-start.test.sh | 41 +++++++++++-- tests/fm-spawn-dispatch-profile.test.sh | 73 ++++++++++++++++++++++- tests/fm-supervision-instructions.test.sh | 17 ++++++ tests/fm-tmux-submit-busy.test.sh | 1 + 33 files changed, 373 insertions(+), 81 deletions(-) diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index 21b7cdd311..95f64b11e0 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -84,7 +84,7 @@ The daemon constructs every current injection as the `away-supervisor` kind owne The bare `FM_INJECT_MARK` form remains accepted for legacy daemon escalations during rollout. U+2063 has no normal keyboard keystroke and survives terminal transport as UTF-8 text. This is how firstmate tells a daemon escalation apart from a real message in the same pane. -The operational prefix travels with the message text; it does not rely on harness-level typed-vs-injected detection, which is not portable across claude, codex, opencode, pi, grok, and kimi. +The operational prefix travels with the message text; it does not rely on harness-level typed-vs-injected detection, which is not portable across claude, codex, opencode, pi, pi-signed, grok, and kimi. ## Busy-guard and composer guard diff --git a/.agents/skills/firstmate-orca/SKILL.md b/.agents/skills/firstmate-orca/SKILL.md index 3db6d22c98..d8d50b07b4 100644 --- a/.agents/skills/firstmate-orca/SKILL.md +++ b/.agents/skills/firstmate-orca/SKILL.md @@ -13,7 +13,7 @@ It does not replace `AGENTS.md`, `docs/orca-backend.md`, or `harness-adapters`. Orca is a runtime backend, not an agent harness. The runtime backend owns the task endpoint and, for Orca, the task worktree. -The harness is the agent process launched inside that endpoint, such as `claude`, `codex`, `opencode`, `pi`, `grok`, or `kimi`. +The harness is the agent process launched inside that endpoint, such as `claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, or `kimi`. Load `harness-adapters` for harness-specific launch, interrupt, resume, trust-dialog, and skill-invocation facts. Implementation details, metadata fields, teardown guarantees, and limitations live in `docs/orca-backend.md`. diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 85cd11c35c..c5ba453f95 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -1,6 +1,6 @@ --- name: harness-adapters -description: Agent-only reference for firstmate harness operations. Use before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. Contains verified facts for claude, codex, opencode, pi, grok, and kimi. +description: Agent-only reference for firstmate harness operations. Use before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. Contains verified facts for claude, codex, opencode, pi, pi-signed, grok, and kimi. user-invocable: false metadata: internal: true @@ -38,6 +38,7 @@ If the captain asks for a new harness, propose verifying it first: spawn a trivi ## Detection `bin/fm-harness.sh` prints firstmate's own harness, using verified env markers first and then process ancestry. +Within the Pi family, only the exact launch-boundary marker `FM_PI_HARNESS=pi-signed` alongside `PI_CODING_AGENT=true` selects the signed identity; unmarked shared launcher ancestry remains `pi`. `bin/fm-harness.sh crew` resolves the effective crewmate harness from `config/crew-harness` (absent or `default` -> own). `bin/fm-harness.sh secondmate` resolves the secondmate-launch harness through the chain `config/secondmate-harness` -> `config/crew-harness` -> own, so an unset `config/secondmate-harness` matches the crew harness. `bin/fm-spawn.sh` uses `crew` mode for a crewmate/scout launch and `secondmate` mode for a `--secondmate` launch, re-resolving on every spawn so the split is durable across respawns; an explicit per-spawn harness arg overrides either. @@ -50,9 +51,9 @@ Use that value for interrupt, exit, resume, and skill-invocation facts. ## Primary turn-end guard -The primary integrations for `claude`, `codex`, `opencode`, `pi`, and `grok` have empirically validated hook paths for the "no turn ends blind" guard. +The primary integrations for `claude`, `codex`, `opencode`, `pi`, `pi-signed`, and `grok` have empirically validated hook paths for the "no turn ends blind" guard. `claude` and `codex` block directly through Stop hooks that preserve exit status 2 and stderr from `bin/fm-turnend-guard.sh`. -`opencode`, `pi`, and `grok` expose passive lifecycle callbacks for this purpose, so their tracked primary adapters force one bounded follow-up or resume when the shared predicate blocks. +`opencode`, `pi`, `pi-signed`, and `grok` expose passive lifecycle callbacks for this purpose, so their tracked primary adapters force one bounded follow-up or resume when the shared predicate blocks. Kimi is outside the primary turn-end guard scope, while `docs/turnend-guard.md` owns its separate guarded global hook for crew wake signals. The exact hook files, commands, scoping rules, and fail-open tradeoffs are owned by `docs/turnend-guard.md`. `docs/verification/supervision.md` "Turn-end guard" owns active validation evidence. @@ -60,9 +61,9 @@ When changing any primary turn-end hook, validate the real harness behavior in a ## Primary pre-arm (PreToolUse) seatbelt -The primary integrations for `claude`, `codex`, `opencode`, `pi`, and `grok` also have wired PreToolUse-equivalent hooks that deny a watcher-arm anti-pattern (shell `&`, truncating pipe, bundling, broad `pkill -f fm-watch`) before it runs. +The primary integrations for `claude`, `codex`, `opencode`, `pi`, `pi-signed`, and `grok` also have wired PreToolUse-equivalent hooks that deny a watcher-arm anti-pattern (shell `&`, truncating pipe, bundling, broad `pkill -f fm-watch`) before it runs. `claude` and `codex` block directly through PreToolUse hooks; `grok` blocks the same way but requires every `$VAR` reference in its hook `command` string to carry an inline `:-default` or it fails to launch the hook entirely. -`opencode` and `pi` block by throwing from `tool.execute.before` / returning `{block: true}` from `tool_call`. +`opencode`, `pi`, and `pi-signed` block by throwing from `tool.execute.before` / returning `{block: true}` from `tool_call`. The exact hook files, commands, output-shaping quirks (Claude Code only honors the deny when stdout is empty), and validation transcripts are owned by `docs/arm-pretool-check.md`. When changing any watcher-arm PreToolUse hook, validate the real harness behavior in a scratch project before trusting it, then update that doc. ## Primary delegation-shape guard @@ -87,7 +88,7 @@ Full mechanics, scoping, and fail-open behavior live in `docs/sessionstart-nudge - `claude`: verified native `SessionStart` stdout injection; `.claude/settings.json` matches `startup`, `resume`, and `clear`, but not `compact`. - `codex`: verified on 0.144.4; `.codex/hooks.json` receives `source=startup`, and wrapper stdout reaches model context. - `opencode`: verified on 1.17.18; `session.created` plus `client.session.promptAsync` starts the nudge turn in the TUI, while `opencode run` remains fail-open headless. -- `pi`: verified native `session_start`; the existing primary extension handles `startup`, `new`, and `resume` and uses `pi.sendMessage` to inject context without racing a positional launch prompt. +- `pi` and `pi-signed`: verified native `session_start`; the existing primary extension handles `startup`, `new`, and `resume` and uses `pi.sendMessage` to inject context without racing a positional launch prompt. - `grok`: the 0.2.103 project `SessionStart` event fires with `source=new`, but stdout does not reach model context; the tracked project hook remains fail-open, and a global token-guarded fallback requires a captain decision. ## Primary watcher supervision @@ -97,7 +98,7 @@ Do not substitute another harness's wait shape when resuming supervision. Claude's Stop `asyncRewake` hook (`bin/fm-claude-stop-autoarm.sh`) owns tokenless re-arm around `bin/fm-watch-arm.sh`, and Grok uses tracked background-notify cycles around `bin/fm-watch-arm.sh`. Codex uses bounded foreground checkpoints through `bin/fm-watch-checkpoint.sh` because Codex cannot reason while a foreground tool call is running. OpenCode uses `.opencode/plugins/fm-primary-watch-arm.js`, which coordinates with the turn-end guard plugin and wakes the TUI with `client.session.promptAsync`. -Pi uses the tracked `.pi/extensions/fm-primary-turnend-guard.ts` plus the tracked `.pi/extensions/fm-primary-pi-watch.ts`, both project-local extensions Pi auto-discovers once trusted. +Pi and pi-signed use the tracked `.pi/extensions/fm-primary-turnend-guard.ts` plus the tracked `.pi/extensions/fm-primary-pi-watch.ts`, both project-local extensions the Pi engine auto-discovers once trusted. When changing any primary watcher adapter, update `docs/supervision-protocols/`, `docs/turnend-guard.md` if a shared idle or turn-end hook changed, and the relevant concise fact below. ## Launch profile axes @@ -120,7 +121,7 @@ The supported launch-profile flags below are verified locally; each row records | claude | `--model ` | `--effort ` | Verified on Claude Code 2.1.196. | | codex | `--model ` | `-c 'model_reasoning_effort=""'` | Verified on codex-cli 0.142.1. The installed binary schema contains `model_reasoning_effort`, the active config uses it, and the bundled model catalog advertises only low/medium/high/xhigh. `max` is omitted. | | grok | `--model ` | `--reasoning-effort ` | Verified on grok 0.2.99 (2026-07-13). `--effort` is an alias, but firstmate's profile axis is reasoning effort. As of 0.2.99 the ceiling is `high`; both `xhigh` and `max` are rejected with `use one of: high, medium, low`, so firstmate omits them. | -| pi | `--model ` | `--thinking ` | Verified 2026-07-13 on Pi 0.80.6. `pi --help` advertises `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`; `pi --print --model openai-codex/gpt-5.6-sol --thinking max 'Reply with exactly OK.'` completed successfully. | +| pi / pi-signed | `--model ` | `--thinking ` | Verified 2026-07-27 on Pi and pi-signed 0.82.0. Both expose the same accepted thinking levels and completed the same model-qualified max-thinking smoke. | | opencode | `--model ` | none for firstmate's interactive launch | Verified on opencode 1.17.6. `opencode run` has `--variant`, but firstmate launches the interactive `opencode --prompt` path, which has no verified effort flag. | | kimi | `--model ` | none | Verified 2026-07-25 on Kimi Code CLI 0.29.1. | @@ -134,7 +135,7 @@ Use the discovery surface in the current authenticated environment because suppo | claude | Open the current interactive session's `/model` picker; `claude --help` documents the accepted alias or full-model-name input shape. | | codex | Open the current interactive session's `/model` picker. | | opencode | Run `opencode models [provider]`, which lists available provider/model identifiers. | -| pi | Run `pi --list-models [search]`; Pi's installed `docs/models.md` owns how built-in, extension-registered, and custom provider/model entries reach that list. | +| pi / pi-signed | Run the selected executable as ` --list-models [search]`; Pi's installed `docs/models.md` owns how built-in, extension-registered, and custom provider/model entries reach that list. | | grok | Run `grok models`, which lists the models available to the current Grok installation and account. | | kimi | Run `kimi provider list --json`, which lists the current provider and model configuration. | @@ -152,7 +153,7 @@ Natural language is acceptable if uncertain. - claude: `/`, for example `/no-mistakes`. - codex: `$`, for example `$no-mistakes`; `/` is claude-only and codex rejects it as "Unrecognized command". - opencode: no separate verified skill invocation beyond normal slash-command behavior; use natural language if the exact skill command is uncertain. -- pi: no separate verified skill invocation beyond normal command behavior; use natural language if the exact skill command is uncertain. +- pi and pi-signed: no separate verified skill invocation beyond normal command behavior; use natural language if the exact skill command is uncertain. - grok: `/`, for example `/no-mistakes` (same form as claude). Verified end to end: grok discovers the user-level `no-mistakes` skill, `/no-mistakes` invokes it, and grok drives a real `no-mistakes axi run`. Like codex's `$`/`/` popups, typing `/` opens grok's slash-autocomplete, so a too-fast Enter selects the popup entry instead of sending, and for an argument-taking command (like `/no-mistakes`'s optional task-first argument) that first Enter only expands the popup selection into an argument-hint placeholder rather than submitting - a genuine second Enter is required (see the grok section below for the 2026-07-03 incident and fix). `fm_tmux_submit_core`'s retried Enter (used by `fm-send` on the tmux backend) handles this through the structural composer reader; the herdr backend needed a dedicated fix (`fm_backend_herdr_composer_state`, docs/herdr-backend.md) because its prior delta-based verification false-positived on that same popup-close content change. - kimi: `/`, for example `/no-mistakes`. @@ -260,7 +261,7 @@ Throwing from `session.idle` does not block `opencode run`, so the primary adapt The companion `.opencode/plugins/fm-primary-watch-arm.js` owns normal TUI watcher wake supervision and coordinates with the guard plugin before the guard tries a blind-turn follow-up. The follow-up was verified in the interactive TUI; `opencode run` can exit before displaying a queued follow-up, so the adapter is fail-open in headless mode. -## pi (VERIFIED 2026-06-11) +## pi and pi-signed (VERIFIED 2026-07-27) | Fact | Value | |---|---| @@ -269,6 +270,11 @@ The follow-up was verified in the interactive TUI; `opencode run` can exit befor | Interrupt | single Escape | Pi has no permission system, so crewmates are always autonomous. +`pi-signed` is the signed wrapper identity verified on version 0.82.0 and exposes the same CLI and TUI behavior as Pi. +Firstmate launches the selected executable name from `PATH`, records `pi-signed` without normalization, and refuses rather than falling back to `pi` when that wrapper is unavailable. +The observed signed process tree is an exact `pi-signed` wrapper parent with the Pi application as its child, while tmux reports the foreground command as the exact `pi-launcher` name for both selected executables. +The installed plain `pi` command also execs that signed launcher, so `FM_PI_HARNESS=pi-signed` is the authoritative selection marker and shared unmarked ancestry remains `pi`. +Firstmate sets `FM_PI_HARNESS` explicitly for both worker launch identities, and a signed primary uses the README launch command to establish the same boundary. Keep the brief as one positional argument. Multiple positional args become separate queued messages; `fm-spawn`'s template already does this correctly. @@ -285,8 +291,8 @@ The firstmate PRIMARY's own `.pi/extensions/fm-primary-turnend-guard.ts` listens Without `deliverAs: "followUp"`, Pi rejects the send while the agent is still processing. Pi's primary watcher protocol also requires the tracked `.pi/extensions/fm-primary-pi-watch.ts` extension, same trust-once discovery as the turn-end guard. The model arms through `fm_watch_arm_pi`, never a foreground bash arm; the watcher tool result and clean-exit fallback are owned by `docs/supervision-protocols/pi.md`. -`bin/fm-session-start.sh` reports when the live Pi session has not loaded both the turn-end guard and watcher extensions, and points at plain `pi` after project trust as the fix, with `-e` as a trust-free fallback. -When a secondmate is launched on Pi, `fm-spawn.sh --secondmate` launches Pi with both `-e .pi/extensions/fm-primary-turnend-guard.ts` and `-e .pi/extensions/fm-primary-pi-watch.ts`, both already present in the secondmate home's git worktree. +`bin/fm-session-start.sh` reports when the live Pi-family session has not loaded both the turn-end guard and watcher extensions, and points at the selected executable after project trust as the fix, with `-e` as a trust-free fallback. +When a secondmate is launched on Pi or pi-signed, `fm-spawn.sh --secondmate` launches the selected executable with both `-e .pi/extensions/fm-primary-turnend-guard.ts` and `-e .pi/extensions/fm-primary-pi-watch.ts`, both already present in the secondmate home's git worktree. ## grok (VERIFIED 2026-06-29, grok 0.2.73; slash-submit re-verified 2026-07-03 on 0.2.82; reasoning-effort ceiling re-verified 2026-07-13 on 0.2.99; exit paths re-verified 2026-07-19 on grok 0.2.103) diff --git a/AGENTS.md b/AGENTS.md index 382413e821..d85e90b8ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -158,7 +158,7 @@ A silent bootstrap section needs no action; for any printed actionable diagnosti ## 4. Harness and runtime dispatch Load `harness-adapters` before every spawn or recovery and before trust handling, skill invocation, interrupt, exit, resume, or adapter verification. -The verified harnesses are `claude`, `codex`, `opencode`, `pi`, `grok`, and `kimi`; never dispatch on an unverified adapter. +The verified harnesses are `claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, and `kimi`; never dispatch on an unverified adapter. If static `config/crew-harness` or `config/secondmate-harness` names an unverified adapter, report it and fall back only to a verified adapter rather than launching it. `docs/configuration.md` owns dispatch-profile and runtime-backend schemas, `bin/fm-harness.sh` owns static resolution, and `bin/fm-spawn.sh` owns launch flags and fail-closed validation. diff --git a/README.md b/README.md index 0546576265..a7f69e39c2 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Full detail on every feature lives in [docs/architecture.md](docs/architecture.m ### Requirements -- A verified primary agent harness: Claude Code, Grok, Pi, Codex, or OpenCode. +- A verified primary agent harness: Claude Code, Grok, Pi, `pi-signed`, Codex, or OpenCode. - Git and the GitHub CLI, authenticated through `gh auth login`. - The CLI and dependencies for your selected runtime backend; tmux is the reference default. @@ -67,7 +67,7 @@ Backend-specific setup is linked in [Documentation](#documentation). ### Recommended harnesses -**Claude Code, Grok, and Pi are equal co-primary recommendations** for running the primary firstmate session. +**Claude Code, Grok, and Pi are equal co-primary recommendations** for running the primary firstmate session, with `pi-signed` supported as Pi's distinct signed-wrapper identity. Claude Code uses a tracked Stop hook for tokenless watcher re-arm and rewake, Grok uses background-notify wake cycles, and Pi uses its tracked primary watcher extension. All three have verified turn-end guard paths when launched with their documented setup. Pick whichever one matches your subscription and workflow. @@ -100,6 +100,8 @@ grok --trust ```sh pi +# or, when the signed wrapper is installed +FM_PI_HARNESS=pi-signed pi-signed ``` For Grok, `--trust` is needed once per clone so project hooks and the turn-end guard load; `/hooks-trust` inside Grok works too. @@ -201,7 +203,7 @@ Firstmate's skills live in two separate places with different audiences: - [docs/gitlab-merge-watch.md](docs/gitlab-merge-watch.md) - maintainer verification for GitLab merge watching on arbitrary instances. - [docs/turnend-guard.md](docs/turnend-guard.md) - the primary session's current "no turn ends blind" backstop, scope, loop safety, and compatibility limits. - [docs/verification/supervision.md](docs/verification/supervision.md) - active maintainer verification for session-start, guard, continuity, and wedge integrations. -- [docs/supervision-protocols/](docs/supervision-protocols/) - rendered primary-harness watcher protocols for Claude, Codex, OpenCode, Pi, Grok, and unknown harness fallback. +- [docs/supervision-protocols/](docs/supervision-protocols/) - rendered primary-harness watcher protocols for Claude, Codex, OpenCode, Pi and `pi-signed`, Grok, and unknown harness fallback. - [docs/scripts.md](docs/scripts.md) - the `bin/` toolbelt reference. - [docs/documentation-audiences.md](docs/documentation-audiences.md) - documentation audiences and the machine-checked placement boundary. - [`AGENTS.md`](AGENTS.md) - the distro's always-loaded operating contract and routing index for conditional procedures. diff --git a/bin/backends/tmux.sh b/bin/backends/tmux.sh index b618e055bc..fe0ed716a4 100644 --- a/bin/backends/tmux.sh +++ b/bin/backends/tmux.sh @@ -181,7 +181,7 @@ fm_backend_tmux_agent_state() { # } comm=${comm#-} case "$comm" in - *claude*|*codex*|*opencode*|*grok*|*kimi*) printf 'alive' ;; + *claude*|*codex*|*opencode*|*grok*|*kimi*|pi|pi-signed|pi-launcher|Pi) printf 'alive' ;; zsh|bash|sh|dash|ash|ksh|mksh|tcsh|csh|fish) printf 'dead' ;; '') printf 'unreadable' ;; *) printf 'ambiguous' ;; diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 1200122335..8685b2e2bb 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -436,7 +436,7 @@ secondmate_liveness_sweep() { [ -n "$target" ] || target="$window" agent_state=$(fm_backend_agent_state "$backend" "$target" 2>/dev/null) || agent_state=unreadable case "$harness" in - claude|codex|opencode|pi|grok|kimi) ;; + claude|codex|opencode|pi|pi-signed|grok|kimi) ;; *) case "$agent_state" in dead|missing) agent_state=unverified-harness ;; esac ;; @@ -713,14 +713,14 @@ crew_dispatch_validate() { return 0 fi err=$(jq -r ' - def verified($h): ["claude","codex","opencode","pi","grok","kimi"] | index($h); + def verified($h): ["claude","codex","opencode","pi","pi-signed","grok","kimi"] | index($h); def effort_ok($h; $e): if $e == null then true elif ($e | type) != "string" then false elif $h == "claude" then (["low","medium","high","xhigh","max"] | index($e)) elif $h == "codex" then (["low","medium","high","xhigh"] | index($e)) elif $h == "grok" then (["low","medium","high"] | index($e)) - elif $h == "pi" then (["low","medium","high","xhigh","max"] | index($e)) + elif $h == "pi" or $h == "pi-signed" then (["low","medium","high","xhigh","max"] | index($e)) elif $h == "opencode" or $h == "kimi" then false else true end; diff --git a/bin/fm-harness.sh b/bin/fm-harness.sh index e9c1e1c24a..f2ee8fe7e8 100755 --- a/bin/fm-harness.sh +++ b/bin/fm-harness.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Detect the agent harness this process tree runs on. -# Usage: fm-harness.sh print own harness: claude|codex|opencode|pi|grok|kimi|unknown +# Usage: fm-harness.sh print own harness: claude|codex|opencode|pi|pi-signed|grok|kimi|unknown # fm-harness.sh crew print the effective CREWMATE harness # (config/crew-harness; "default" resolves to own) # fm-harness.sh secondmate print the harness the PRIMARY uses to launch @@ -36,7 +36,10 @@ detect_own() { # ancestry is consulted. This is a precedence hazard, not evidence that # CLAUDECODE inheritance into a kimi child was observed; it was not observed. [ "${CLAUDECODE:-}" = "1" ] && { echo claude; return; } - [ "${PI_CODING_AGENT:-}" = "true" ] && { echo pi; return; } + if [ "${PI_CODING_AGENT:-}" = "true" ]; then + if [ "${FM_PI_HARNESS:-}" = pi-signed ]; then echo pi-signed; else echo pi; fi + return + fi # grok sets GROK_AGENT=1 for its child/tool processes (verified, grok 0.2.73). # It does NOT set CLAUDECODE despite being Claude-Code-compatible, so this marker # is unambiguous when firstmate runs natively on grok. @@ -51,6 +54,7 @@ detect_own() { *opencode*) echo opencode; return ;; *grok*) echo grok; return ;; kimi) echo kimi; return ;; + pi-signed) echo pi; return ;; pi) echo pi; return ;; node*|python*) # Bare interpreter: match the harness name in its script path. diff --git a/bin/fm-session-lock-lib.sh b/bin/fm-session-lock-lib.sh index 73aab2f213..90303cda1c 100644 --- a/bin/fm-session-lock-lib.sh +++ b/bin/fm-session-lock-lib.sh @@ -9,7 +9,7 @@ # This file is sourced by scripts and has no side effects on source. # Known harness command names; extend when a new adapter is verified. -FM_HARNESS_RE='claude|codex|opencode|grok|kimi|^pi$' +FM_HARNESS_RE='claude|codex|opencode|grok|kimi|^pi$|^pi-signed$' # Walk the current process ancestry (up to 8 hops) and print the first pid whose # command looks like a verified harness. The harness pid lives as long as the @@ -34,10 +34,19 @@ fm_harness_ancestry_pid() { # True if $1 is a live process that looks like a verified harness. fm_harness_pid_alive() { - local pid=$1 comm + local pid=$1 comm args kill -0 "$pid" 2>/dev/null || return 1 comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1 - printf '%s' "$(basename "$comm") $(ps -o args= -p "$pid" 2>/dev/null)" | grep -qE "$FM_HARNESS_RE" + if printf '%s' "$(basename "$comm")" | grep -qE "$FM_HARNESS_RE"; then + return 0 + fi + case "$comm" in + *node*|*python*) + args=$(ps -o args= -p "$pid" 2>/dev/null) + printf '%s' "$args" | grep -qE "$FM_HARNESS_RE" + ;; + *) return 1 ;; + esac } # True when state dir $1 holds a session lock whose pid is the harness ancestor diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index c7dbb13555..1abbace4bf 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -311,17 +311,19 @@ AFK_PRESENT=0 X_MODE_PRESENT=0 [ -f "$CONFIG/x-mode.env" ] && X_MODE_PRESENT=1 -if [ "$PRIMARY_HARNESS" = pi ]; then +if [ "$PRIMARY_HARNESS" = pi ] || [ "$PRIMARY_HARNESS" = pi-signed ]; then PI_EXT="$FM_ROOT/.pi/extensions/fm-primary-pi-watch.ts" PI_TURNEND_EXT="$FM_ROOT/.pi/extensions/fm-primary-turnend-guard.ts" PI_WATCH_MARKER="$STATE/.pi-watch-extension-loaded" PI_TURNEND_MARKER="$STATE/.pi-turnend-extension-loaded" PI_LOCK="$STATE/.lock" + PI_RESTART_COMMAND=$PRIMARY_HARNESS + [ "$PRIMARY_HARNESS" != pi ] || PI_RESTART_COMMAND='plain pi' PI_WATCH_VERSION=$(hash_file "$PI_EXT" || printf '') PI_TURNEND_VERSION=$(hash_file "$PI_TURNEND_EXT" || printf '') if ! pi_extension_loaded "$PI_WATCH_MARKER" "$PI_WATCH_VERSION" "$PI_LOCK" \ || ! pi_extension_loaded "$PI_TURNEND_MARKER" "$PI_TURNEND_VERSION" "$PI_LOCK"; then - printf 'PI_WATCH_EXTENSION: not loaded - approve Pi project trust once per clone, then restart plain pi so %s and %s auto-load for turn-end guard and background wake coverage; use -e %s -e %s only if project hooks are not trusted\n' "$PI_TURNEND_EXT" "$PI_EXT" "$PI_TURNEND_EXT" "$PI_EXT" + printf 'PI_WATCH_EXTENSION: not loaded - approve Pi project trust once per clone, then restart %s so %s and %s auto-load for turn-end guard and background wake coverage; use -e %s -e %s only if project hooks are not trusted\n' "$PI_RESTART_COMMAND" "$PI_TURNEND_EXT" "$PI_EXT" "$PI_TURNEND_EXT" "$PI_EXT" fi fi "$SCRIPT_DIR/fm-supervision-instructions.sh" \ diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 00a2262ef4..fe659b54b6 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -59,10 +59,11 @@ # profile consultation. A --secondmate spawn is exempt and resolves the SECONDMATE # harness (config/secondmate-harness -> config/crew-harness -> own), so the # secondmate-vs-crewmate split is DURABLE across every respawn (recovery, -# /updatefirstmate, restart). A bare adapter name (claude|codex|opencode|pi|grok|kimi) +# /updatefirstmate, restart). A bare adapter name (claude|codex|opencode|pi|pi-signed|grok|kimi) # overrides it for this spawn (either kind). A non-flag string containing # whitespace is treated as a RAW launch command - the escape hatch for verifying -# new adapters. +# new adapters. pi-signed launches that exact executable name from PATH and +# refuses before endpoint creation when it is unavailable; it never falls back to pi. # config/secondmate-harness may also carry an optional model and effort as extra # whitespace-separated tokens (" [] []"). For a # --secondmate spawn, those tokens apply only when this spawn also resolves its @@ -388,7 +389,7 @@ FIRSTMATE_HOME= if [ "$KIND" = secondmate ]; then case "${POS[1]:-}" in - ''|claude|codex|opencode|pi|grok|kimi) + ''|claude|codex|opencode|pi|pi-signed|grok|kimi) ARG3=${POS[1]:-} ;; *' '*) @@ -434,11 +435,11 @@ launch_template() { fi ;; opencode) printf '%s' 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}}'\'' opencode __MODELFLAG__--prompt "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; - pi) + pi|pi-signed) if [ "$kind" = secondmate ]; then - printf '%s' 'pi __MODELFLAG____EFFORTFLAG__-e __PITURNEND__ -e __PIWATCH__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + printf '%s%s' "$harness" ' __MODELFLAG____EFFORTFLAG__-e __PITURNEND__ -e __PIWATCH__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' else - printf '%s' 'pi __MODELFLAG____EFFORTFLAG__-e __PIEXT__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + printf '%s%s' "$harness" ' __MODELFLAG____EFFORTFLAG__-e __PIEXT__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' fi ;; # grok (Grok Build TUI): a positional prompt starts the supervised interactive @@ -494,6 +495,18 @@ case "$ARG3" in ;; esac +case "$HARNESS" in + pi|pi-signed) LAUNCH="FM_PI_HARNESS=$HARNESS $LAUNCH" ;; +esac + +# pi-signed is an explicitly selected executable identity, not an alias that may +# silently fall back to pi. Resolve it from PATH before creating an endpoint and +# retain the literal name in the launch command and task metadata. +if [ "$HARNESS" = pi-signed ] && ! command -v pi-signed >/dev/null 2>&1; then + echo "error: pi-signed executable not found on PATH; install the signed Pi wrapper or select a different verified harness" >&2 + exit 1 +fi + # config/secondmate-harness may carry optional model/effort tokens alongside the # harness (" [] []"). They apply only when this is a # --secondmate spawn and no explicit per-spawn harness/raw launch was supplied, so @@ -565,7 +578,7 @@ model_flag_for_harness() { local harness=$1 model=$2 [ -n "$model" ] && [ "$model" != default ] || return 0 case "$harness" in - claude|codex|opencode|pi|grok|kimi) + claude|codex|opencode|pi|pi-signed|grok|kimi) printf -- '--model %s ' "$(shell_quote "$model")" ;; esac @@ -597,7 +610,7 @@ effort_flag_for_harness() { low|medium|high) printf -- '--reasoning-effort %s ' "$(shell_quote "$effort")" ;; esac ;; - pi) + pi|pi-signed) # Pi 0.80.6 accepts the full shared effort vocabulary, including max, through # its --thinking flag. case "$effort" in @@ -1317,7 +1330,7 @@ export const FmTurnEnd = async ({ \$ }) => ({ EOF exclude_path '.opencode/plugins/fm-turn-end.js' ;; - pi*) + pi|pi-signed) # Written OUTSIDE the worktree: pi's project-trust gate fires on any extension # loaded from inside the project (verified live), but an explicit -e path # elsewhere loads without a dialog. Lives in state/, cleaned by teardown. diff --git a/bin/fm-supervision-instructions.sh b/bin/fm-supervision-instructions.sh index 953cce07ca..6cd87699b0 100755 --- a/bin/fm-supervision-instructions.sh +++ b/bin/fm-supervision-instructions.sh @@ -82,6 +82,7 @@ fi case "$HARNESS" in claude|codex|opencode|pi|grok) SNIPPET="$DOC_DIR/$HARNESS.md" ;; + pi-signed) SNIPPET="$DOC_DIR/pi.md" ;; *) HARNESS=unknown; SNIPPET="$DOC_DIR/unknown.md" ;; esac [ -f "$SNIPPET" ] || SNIPPET="$DOC_DIR/unknown.md" @@ -139,7 +140,7 @@ repair_line() { codex) printf '%s%s%s%s\n' "$prefix" 'repair missing watcher supervision with a foreground checkpoint: bin/fm-watch-checkpoint.sh --seconds ' "$checkpoint_seconds" '.' ;; - pi) + pi|pi-signed) printf '%s%s%s%s%s%s\n' "$prefix" 'repair a missing or failed watcher cycle with the Pi tool fm_watch_arm_pi, or restart Pi with -e ' "$pi_turnend_ext" ' -e ' "$pi_ext" ' if the extensions are not loaded.' ;; opencode) @@ -162,7 +163,7 @@ ordinary_wake_line() { codex) printf '%s\n' '- Ordinary wake: take the next foreground bin/fm-watch-checkpoint.sh checkpoint as directed below.' ;; - pi) + pi|pi-signed) printf '%s\n' '- Ordinary wake: the Pi extension already owns watcher continuity; do not arm another cycle.' ;; opencode) diff --git a/bin/fm-tmux-lib.sh b/bin/fm-tmux-lib.sh index 7eddc323f3..cf8c3f7fa5 100755 --- a/bin/fm-tmux-lib.sh +++ b/bin/fm-tmux-lib.sh @@ -94,7 +94,7 @@ fm_busy_lines_match() { # [harness] claude) regex=$FM_TMUX_CLAUDE_BUSY_REGEX_DEFAULT ;; codex) regex=$FM_TMUX_CODEX_BUSY_REGEX_DEFAULT ;; opencode) regex=$FM_TMUX_OPENCODE_BUSY_REGEX_DEFAULT ;; - pi) regex=$FM_TMUX_PI_BUSY_REGEX_DEFAULT ;; + pi|pi-signed) regex=$FM_TMUX_PI_BUSY_REGEX_DEFAULT ;; grok) regex=$FM_TMUX_GROK_BUSY_REGEX_DEFAULT ;; kimi) regex=$FM_TMUX_KIMI_BUSY_REGEX_DEFAULT ;; '') regex=$FM_TMUX_BUSY_REGEX_DEFAULT ;; diff --git a/docs/architecture.md b/docs/architecture.md index d1bbb1c1ff..4840ef77a0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -54,14 +54,14 @@ The default path remains local-only; live GitHub enrichment exists only behind t Optional X mode integrates with the watcher only after explicit opt-in; [configuration.md](configuration.md#x-mode-env) owns its generated-artifact and dispatch mechanics. At session start, `bin/fm-session-start.sh` emits exactly one primary-harness supervision block rendered by `bin/fm-supervision-instructions.sh` from `docs/supervision-protocols/`. -That block owns the live wait shape for the running primary harness: Claude's Stop `asyncRewake` hook owns tokenless re-arm cycles, Grok uses background-notify cycles, Codex uses bounded foreground checkpoints, Pi uses its two tracked primary extensions, and OpenCode uses its TUI plugin. +That block owns the live wait shape for the running primary harness: Claude's Stop `asyncRewake` hook owns tokenless re-arm cycles, Grok uses background-notify cycles, Codex uses bounded foreground checkpoints, Pi and pi-signed use the same two tracked primary extensions, and OpenCode uses its TUI plugin. `bin/fm-watch-arm.sh` remains the verified arm wrapper for protocols that call it; it forks the watcher as a tracked child, verifies it is genuinely alive with a fresh liveness beacon, and prints an honest `started`, `attached`, or nonzero `FAILED` status. On `attached` it stays live across identity-matched successors, and an unexplained clean child close either attaches to a verified healthy successor or becomes the typed nonzero `watcher: FAILED - cycle ended without an actionable reason` result. The arm layer records one bounded lifecycle row per observed cycle in `state/.watch-cycle-exits.log`; `state/.watch-triage.log` remains exclusively the absorbed-wake debug log. Pi and OpenCode verify session-lock ownership and launch one singleton successor from their child-close handlers before delivering an actionable wake prompt, with bounded exponential retry for failed restoration. Claude's `bin/fm-claude-stop-autoarm.sh` hook fires on every Stop and, when the home is eligible and still needs supervision, claims one home-scoped cycle, foregrounds the arm wrapper, and translates an actionable close or typed failure into one exit-2 rewake. [`watcher-continuity.md`](watcher-continuity.md) owns Claude's residual active-turn coverage and watcher-status command-gating boundary. -The existing turn-end guard remains the final backstop for all five harness protocols, cooperating with the auto-arm claim in its `--claude` mode. +The existing turn-end guard remains the final backstop for all five harness-engine protocols, with pi-signed sharing Pi's protocol and the `--claude` mode cooperating with the auto-arm claim. Its `--restart` mode signals only the watcher recorded in the current home's `state/.watch.lock`, so restarting one home cannot kill sibling secondmate watchers. A pull-based guard (`bin/fm-guard.sh`) warns through supervision tool output if the primary checkout is tangled, or if tasks are in flight and that watcher stops running or queued wakes are waiting to be drained. The drain script calls that guard after emptying the queue, which avoids repeating the queued-wakes warning for records it just consumed while still warning on stale watcher liveness. diff --git a/docs/arm-pretool-check.md b/docs/arm-pretool-check.md index c56f555f7e..f4747e0abd 100644 --- a/docs/arm-pretool-check.md +++ b/docs/arm-pretool-check.md @@ -24,7 +24,7 @@ It tokenizes the bytes and classifies lexical execution positions only. - Stdin JSON at `.tool_input.command` for Claude and Codex. - Stdin JSON at `.toolInput.command` for Grok. -- `--command ` for OpenCode and Pi. +- `--command ` for OpenCode, Pi, and pi-signed. - `--background` as a compatibility-only field that never changes the decision. - `--claude` to preserve Claude's stderr-only deny requirement. @@ -151,7 +151,7 @@ Prose may improve without changing adapter behavior. - `--claude` suppresses stdout completely because Claude ignores a PreToolUse deny when stdout is nonempty. - Codex blocks on exit 2 and displays stderr. - OpenCode throws only when the checker exits 2. -- Pi returns `{block: true}` only when the checker exits 2. +- Pi and pi-signed return `{block: true}` only when the checker exits 2. ## Harness wiring @@ -161,7 +161,7 @@ Prose may improve without changing adapter behavior. | Claude | `.tool_input.command` | `.claude/settings.json` forwards stdin with `--claude`, leaving stdout empty and returning the stderr deny object. | | Grok | `.toolInput.command` | `.grok/hooks/fm-primary-pretool-check.json` forwards stdin and Grok consumes the stdout `decision=deny` object. | | OpenCode | `output.args.command` | `.opencode/plugins/fm-primary-pretool-check.js` passes one `--command` argument and throws only for exit 2. | -| Pi | `event.input.command` | `.pi/extensions/fm-primary-turnend-guard.ts` passes one `--command` argument and returns `{block: true}` only for exit 2. | +| Pi / pi-signed | `event.input.command` | `.pi/extensions/fm-primary-turnend-guard.ts` passes one `--command` argument and returns `{block: true}` only for exit 2. | Grok project hooks require folder trust. Every shell variable reference in a Grok hook command must carry an inline default such as `${GROK_WORKSPACE_ROOT:-}` because Grok expands the raw hook command before `bash -lc` runs it. diff --git a/docs/cd-guard.md b/docs/cd-guard.md index 2d8082e1ce..998a9b540c 100644 --- a/docs/cd-guard.md +++ b/docs/cd-guard.md @@ -74,13 +74,13 @@ It does not permit `cd /home/project`, because an absolute-path `cd` remains a p ## Transport and fail-open behavior -`bin/fm-cd-pretool-check.sh` supports all five harness entry shapes used by the tracked adapters: +`bin/fm-cd-pretool-check.sh` supports all five harness-engine entry shapes used by the tracked adapters, with pi-signed sharing Pi's shape: - Claude sends stdin JSON at `.tool_input.command` and adds `--claude` to preserve Claude's stderr-only deny requirement. - Codex sends stdin JSON at `.tool_input.command` without `--claude`. - Grok sends stdin JSON at `.toolInput.command`. - OpenCode sends the exact command string through `--command `. -- Pi sends the exact command string through `--command `. +- Pi and pi-signed send the exact command string through `--command `. Processing order is cheapest-first: a strict-superset prefilter, then the primary-checkout scope, then the Node policy owner. The prefilter removes ordinary single quotes, double quotes, backslashes, carriage returns, and newlines before fast-allowing any command that carries no `cd`, `pushd`, or `popd` substring and no quoting-decoder marker (`$'` ANSI-C or `$"` locale), so quoted or escaped command-word fragments delegate to the policy while most commands never pay for the git scoping calls or the Node process. @@ -99,7 +99,7 @@ Identical in shape to `docs/arm-pretool-check.md`: - `--claude` suppresses stdout completely because Claude ignores a PreToolUse deny when stdout is nonempty. - Codex blocks on exit 2 and displays stderr. - OpenCode throws only when the checker exits 2. -- Pi returns `{block: true}` only when the checker exits 2. +- Pi and pi-signed return `{block: true}` only when the checker exits 2. ## Shared classifier ownership diff --git a/docs/configuration.md b/docs/configuration.md index 896b98ae31..fed683343e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -174,15 +174,17 @@ The full cmux home label also includes a short hash of the resolved `FM_ROOT` pa ## Harness support -claude, codex, opencode, pi, grok, and kimi are empirically verified for crewmate and secondmate launches; [README requirements](../README.md#requirements) own the narrower set supported for the primary session. +claude, codex, opencode, pi, pi-signed, grok, and kimi are empirically verified for crewmate and secondmate launches; [README requirements](../README.md#requirements) own the set supported for the primary session. New harnesses get verified through a supervised trial task before joining the set. The verified adapter knowledge - busy signatures, interrupt and exit commands, skill-invocation syntax, and per-harness quirks - lives in [`.agents/skills/harness-adapters/SKILL.md`](../.agents/skills/harness-adapters/SKILL.md). Launch mechanics, including the verified command templates, live in [`bin/fm-spawn.sh`](../bin/fm-spawn.sh). Enabled primary-session turn-end guard integrations are tracked as repo-level hook files and documented in [`docs/turnend-guard.md`](turnend-guard.md). Kimi remains outside the primary turn-end guard integrations; [`docs/turnend-guard.md`](turnend-guard.md#compatibility-limits) owns its separate captain-approved crew wake hook. Primary-session watcher wake protocols are rendered at session start by [`bin/fm-supervision-instructions.sh`](../bin/fm-supervision-instructions.sh) from [`docs/supervision-protocols/`](supervision-protocols/). -Claude's Stop `asyncRewake` hook owns tokenless re-arm cycles, Grok uses background-notify cycles, Codex uses bounded foreground checkpoints, Pi uses its two tracked primary extensions, and OpenCode uses its TUI plugin. +Claude's Stop `asyncRewake` hook owns tokenless re-arm cycles, Grok uses background-notify cycles, Codex uses bounded foreground checkpoints, Pi and pi-signed use the same two tracked primary extensions, and OpenCode uses its TUI plugin. `config/crew-harness` is a local, gitignored file containing one adapter name for crewmate and scout launches. +When pi-signed is selected, Firstmate launches the executable named `pi-signed` from `PATH` with `FM_PI_HARNESS=pi-signed` and refuses the launch if it is unavailable rather than falling back to pi. +Plain Pi launches set `FM_PI_HARNESS=pi`, so a signed primary's environment cannot relabel a plain Pi worker. When it is absent or contains `default`, crewmates mirror the firstmate's own harness. `config/secondmate-harness` is a separate local, gitignored file containing the adapter the primary uses to launch secondmate agents, optionally followed by model and effort tokens on the same line. The first non-empty, non-comment line is parsed as ` [] []`. @@ -200,7 +202,7 @@ For Kimi crews, `fm-spawn.sh` runs `fm-kimi-turnend-hook.sh install`, drops a pe Kimi continues to use the captain's normal Kimi home, including the existing config, skills, and memory; Firstmate does not create an isolated Kimi home. The Kimi installer requires an existing regular non-symlink `~/.kimi-code/config.toml`, `python3` with `tomllib`, and `jq`; it validates but never serializes the captain's TOML and refuses before writing when the config is missing, malformed, or surprising or when either tool requirement is unavailable. Its `remove` action excises only the marker-delimited Firstmate region and removes Firstmate's hook files. -For Pi secondmate launches, `fm-spawn.sh` starts Pi with `-e` pointed at the secondmate home's own tracked `.pi/extensions/fm-primary-pi-watch.ts` and `.pi/extensions/fm-primary-turnend-guard.ts`, both already present from the secondmate home's git worktree. +For Pi and pi-signed secondmate launches, `fm-spawn.sh` starts the selected executable with `-e` pointed at the secondmate home's own tracked `.pi/extensions/fm-primary-pi-watch.ts` and `.pi/extensions/fm-primary-turnend-guard.ts`, both already present from the secondmate home's git worktree. ## Crew dispatch profiles (config/crew-dispatch.json) diff --git a/docs/sessionstart-nudge.md b/docs/sessionstart-nudge.md index 1f0ee079f4..ef21cea132 100644 --- a/docs/sessionstart-nudge.md +++ b/docs/sessionstart-nudge.md @@ -23,7 +23,7 @@ Every path exits 0, including malformed state and adapter errors, because a Clau | Claude | `.claude/settings.json` registers `SessionStart` for `startup`, `resume`, and `clear`, excludes `compact`, and invokes the wrapper through `CLAUDE_PROJECT_DIR`. | Native stdout context injection is supported. | | Codex | `.codex/hooks.json` anchors to the hook process working directory, verifies a Firstmate-shaped hook-bearing root, and executes the wrapper. | Native stdout context injection is supported. | | OpenCode | `.opencode/plugins/fm-primary-sessionstart-nudge.js` listens for `session.created`, runs once per session id, and calls `client.session.promptAsync` only when the wrapper prints a nudge. | Interactive TUI delivery is supported; headless `opencode run` is intentionally fail-open because the process can exit before the queued turn. | -| Pi | `.pi/extensions/fm-primary-turnend-guard.ts` handles `session_start` reasons `startup`, `new`, and `resume`, then injects the wrapper output with `pi.sendMessage`. | The custom message reaches model context without racing an initial positional prompt. | +| Pi / pi-signed | `.pi/extensions/fm-primary-turnend-guard.ts` handles `session_start` reasons `startup`, `new`, and `resume`, then injects the wrapper output with `pi.sendMessage`. | The custom message reaches model context without racing an initial positional prompt. | | Grok | `.grok/hooks/fm-primary-sessionstart-nudge.json` registers a project `SessionStart` hook and invokes the wrapper through inline-defaulted `${GROK_WORKSPACE_ROOT:-}`. | The project hook runs when the checkout is trusted, but Grok currently discards hook stdout from model context, so this path is intentionally fail-open. | The OpenCode nudge runs only on `session.created`. @@ -36,7 +36,7 @@ That alternative expands trust and writes outside this repository, so Firstmate `tests/fm-sessionstart-nudge.test.sh` proves wrapper silence for both gate signals, an unmarked linked worktree, a missing state directory, and an already-owned lock. It proves exact U+2063 `FIRSTMATE_OP:`-prefixed, `session-start`-typed one-line output for a plain primary and a marked linked secondmate primary. -It also verifies tracked wrapper registration for Claude, Codex, OpenCode, Pi, and Grok. +It also verifies every tracked transport registration listed above. `tests/fm-captain-translation-contract.test.sh` proves Ahoy's current marker rule, narrow legacy compatibility exclusions, genuine captain-message near misses, and the shared marker on supported user-role operational injections. `tests/fm-pi-primary-live-e2e.test.sh` and `tests/fm-opencode-primary-live-e2e.test.sh` exercise native startup paths with first-message and later-message Ahoy regressions. `tests/fm-turnend-guard.test.sh`, `tests/fm-pi-watch-extension.test.sh`, and `tests/fm-daemon.test.sh` cover marked guard, monitoring, and away-mode delivery. diff --git a/docs/supervision-protocols/pi.md b/docs/supervision-protocols/pi.md index 5c847c050b..30c4aae35f 100644 --- a/docs/supervision-protocols/pi.md +++ b/docs/supervision-protocols/pi.md @@ -2,7 +2,7 @@ Mode: Pi extension background wake. When this session owns supervision and away mode is not active: 1. Drain first with `bin/fm-wake-drain.sh`. -2. Confirm the Pi primary auto-loaded both project extensions (plain `pi`, after approving project trust once per clone); if not, restart with `-e __FM_PI_TURNEND_EXT__ -e __FM_PI_EXT__` as a trust-free fallback. +2. Confirm the Pi primary auto-loaded both project extensions (plain `pi` or `pi-signed`, after approving project trust once per clone); if not, restart the selected executable with `-e __FM_PI_TURNEND_EXT__ -e __FM_PI_EXT__` as a trust-free fallback. 3. First cycle only: make the one required `fm_watch_arm_pi` call. Use `/fm-watch-arm-pi` only as a human-entered fallback. Never run `bin/fm-watch-arm.sh` through Pi's bash tool because that foreground arm can wedge the agent and bypasses extension-owned cleanup. @@ -11,7 +11,7 @@ When this session owns supervision and away mode is not active: 6. After an actionable child close, the extension rechecks session-lock ownership and verifies one successor before it delivers the follow-up wake; its bounded fallback is defined in `docs/watcher-continuity.md`. 7. Ordinary work, turn completion, and ordinary signal, stale, check, heartbeat, or other wake handling: do not call `fm_watch_arm_pi` again because continuity is extension-owned rather than model-memory-owned. 8. An unexpected child close enters bounded exponential retry, and an exhausted retry or lost session lock is surfaced as a watcher failure instead of disappearing. -9. Missing, failed, or unhealthy cycle only: if a later notification explicitly reports one of those repair conditions, drain queued wakes, inspect the failure text, call `fm_watch_arm_pi`, and restart Pi with both extensions loaded if needed. +9. Missing, failed, or unhealthy cycle only: if a later notification explicitly reports one of those repair conditions, drain queued wakes, inspect the failure text, call `fm_watch_arm_pi`, and restart the selected Pi-family executable with both extensions loaded if needed. A redundant call while the extension owns an arm child or scheduled retry is an ownership-based `watcher: unchanged` no-op, not an independent health claim. 10. Never use shell `&` for watcher supervision. The arm mechanism above is extension-owned, not a model tool call, but a manual recovery probe that backgrounds, pipes, or bundles the arm is denied automatically by the PreToolUse seatbelt (`bin/fm-arm-pretool-check.sh`, wired into the turn-end guard extension at `__FM_PI_TURNEND_EXT__`). diff --git a/docs/tmux-backend.md b/docs/tmux-backend.md index ae24507c58..3bf20fe9d1 100644 --- a/docs/tmux-backend.md +++ b/docs/tmux-backend.md @@ -46,12 +46,11 @@ Verify setup by spawning a small task and confirming its `fm-` window appear A target-existence check proves only that the pane exists. The deeper tmux agent-liveness probe first verifies exact window membership, then reads `#{pane_current_command}` to distinguish a running harness process from a bare idle shell. -It classifies recognized Claude, Codex, OpenCode, Grok, and Kimi process names as `alive`, common shells as `dead`, an authoritatively absent window as `missing`, unreadable state as `unreadable`, and every other process as `ambiguous`. +It classifies recognized Claude, Codex, OpenCode, Pi, pi-signed, Grok, and Kimi process names as `alive`, common shells as `dead`, an authoritatively absent window as `missing`, unreadable state as `unreadable`, and every other process as `ambiguous`. Only `dead` and `missing` authorize recovery because a false dead result could launch a duplicate agent. -Pi runs through a generic `node` process name and cannot be attributed confidently from the tmux foreground-process field. -An existing Pi pane is therefore reported as ambiguous rather than auto-healed, while an authoritatively missing Pi window can be relaunched safely. -This is the active tmux liveness limitation. +The verified Pi Launcher path reports the exact foreground command `pi-launcher` for both pi and pi-signed, while direct executable identities `pi`, `pi-signed`, and `Pi` remain accepted exactly. +Similar or prefixed process names are not accepted through those exact Pi-family entries. Agent liveness and composer safety are separate checks. For a bordered composer, the tmux reader locates the complete box structurally and classifies every content row through the shared ANSI and ghost handling in `bin/fm-composer-lib.sh`. @@ -79,7 +78,6 @@ Ambiguous pending text never receives the busy-queue conversion. ## Limits and regression entry points - tmux is the reference path and supports secondmate homes. -- Existing Pi agent-process liveness is inconclusive, while an authoritatively missing Pi window can trigger recovery. - The OpenCode busy-queue exception is tmux-specific; Herdr retains its separately documented gap. ```sh diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index 5589ea2385..30690bb887 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -55,7 +55,7 @@ The Claude mode waits up to `FM_CLAUDE_AUTOARM_SYNC_WAIT_MS` (default 800 millis When none of those proofs appears, it re-blocks up to `FM_CLAUDE_TURNEND_BLOCK_BUDGET` times (default 3, below Claude's 8-block override), then allows degraded with a visible `systemMessage`. Any allow resets the budget. -OpenCode, Pi, and Grok expose passive callbacks for this purpose. +OpenCode, Pi, pi-signed, and Grok expose passive callbacks for this purpose. Their adapters fail open at the hook boundary to protect the user session but schedule one bounded follow-up when the predicate blocks. The generated prompts use the canonical `turn-end-guard` kind after the U+2063 `FIRSTMATE_OP: ` prefix, so Ahoy does not treat them as captain messages. Each adapter owns a loop latch. @@ -70,7 +70,7 @@ That warning uses `bin/fm-supervision-instructions.sh --repair-line`, so it alwa - Child crewmate and scout worktrees are outside scope. - A valid secondmate home is in scope; an idle secondmate endpoint with no X-mode relay poll remains healthy because it has no supervision need. -- Claude and Codex block directly, while OpenCode, Pi, and Grok use bounded passive follow-ups. +- The direct-blocking and bounded passive-follow-up split is limited to the primary integrations listed above. - OpenCode headless mode and untrusted Grok project hooks remain fail-open at the host boundary. - Kimi Code CLI 0.29.1 exposes only global `[[hooks]]` configuration in `~/.kimi-code/config.toml`, including a `Stop` event with snake_case payload fields `hook_event_name`, `session_id`, `cwd`, and `stop_hook_active`. - Kimi has no project-level hook configuration and remains outside the primary guard integrations above. @@ -85,6 +85,6 @@ That warning uses `bin/fm-supervision-instructions.sh --repair-line`, so it alwa `tests/fm-turnend-guard.test.sh` covers the predicate, main and secondmate primary scope, child-worktree exclusion, `FM_HOME` and `FM_STATE_OVERRIDE` precedence, the cooperative `--claude` claim wait, epoch allow, re-block budget, Pi logical-run latching, missing-`jq` behavior, all five primary registrations, and Grok resume permission and recursion safety. `tests/fm-kimi-harness.test.sh` covers the separate Kimi crew hook's format preservation, idempotence, refusal cases, token guard, spawn registration, and teardown cleanup. -`tests/fm-supervision-instructions.test.sh` covers recovery-line ownership. +`tests/fm-supervision-instructions.test.sh` covers recovery-line ownership and pi-signed's identity-preserving reuse of Pi's protocol. `FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh` is the opt-in isolated Pi path. [`verification/supervision.md`](verification/supervision.md#turn-end-guard) records the active cross-harness empirical evidence, including the 2026-07-24 Claude `asyncRewake` revalidation. diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index 0c154c9760..a711d84ee5 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -30,7 +30,53 @@ zsh A persistent parent shell waiting for a child remained reported as the parent process, while a shell that directly execed a simple command changed identity with the process itself. Claude, Codex, OpenCode, and Grok were observed under their own process names. Kimi Code CLI 0.29.1 was observed under `kimi` on 2026-07-25. -Pi remained a generic `node` process and is intentionally inconclusive. +Pi and pi-signed 0.82.0 were reverified on 2026-07-27 through real isolated `fm-spawn.sh` launches. + +Installed-wrapper checks: + +```sh +basename "$(command -v pi-signed)" +pi-signed --version +pi --version +``` + +Observed bounded output: + +```text +pi-signed +0.82.0 +0.82.0 +``` + +The isolated process and endpoint checks used: + +```sh +tmux display-message -p -t "$target" '#{pane_current_command}' +ps -o comm= -p "$wrapper_pid" +ps -o comm= -p "$engine_pid" +FM_HOME="$fixture_home" bin/fm-crew-state.sh "$task_id" +``` + +Observed bounded shapes: + +```text +pi-launcher +.../pi-signed +.../Pi Launcher.app/Contents/Resources/pi/pi +state: done ... +``` + +Both launches executed a submitted tool instruction and touched the generated `turn_end` marker. +The pi-signed launch retained `harness=pi-signed`, while the plain comparison retained `harness=pi`. +The exact wrapper ancestry was `pi-signed` parent to Pi engine child, and the plain Pi Launcher path also traversed the signed wrapper on this installation. +That shared plain-Pi path is retained as disconfirming evidence against using ancestry as runtime-selection authority. +Firstmate therefore sets the exact `FM_PI_HARNESS` selection marker on both worker launch paths, while an unmarked Pi-family process remains `pi`. +Both recorded runtime identities now classify the exact `pi-launcher` foreground command as `alive`. + +Backend applicability was reviewed across every spawn adapter. +Tmux needs the exact `pi-launcher`, `pi-signed`, `pi`, and `Pi` process identities for recovery-grade liveness. +Herdr uses native registered-agent state and needs no process-name branch. +Zellij has no verified recovery-grade agent process probe, while Orca and cmux do not support secondmate spawns, so those three retain their existing generic ordinary-launch semantics without a new liveness matcher. The structural multi-row composer reader, Kimi pointer-delivery path, and OpenCode 1.18.4 busy-queue behavior are pinned by: diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index 4063f5566d..30a98311a4 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -45,6 +45,8 @@ pi -p -e .pi/extensions/fm-primary-turnend-guard.ts \ Observed result: `PI_SMOKE_DONE`, with one session-start execution. The earlier `sendUserMessage` counterfactual raced the positional prompt; the current non-triggering `pi.sendMessage` custom message did not. +The installed pi-signed 0.82.0 wrapper repeated the Pi primary extension and session-start path on 2026-07-27. +[`runtime-backends.md`](runtime-backends.md#tmux) owns the shared-ancestry evidence and authoritative selection-marker boundary. Current deterministic and live entry points: diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index b824c2485f..48cd09a093 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -781,6 +781,7 @@ unsupported codex max effort is flagged^{"rules":[{"when":"big feature","use":{" unsupported grok max effort is flagged^{"rules":[{"when":"deep current work","use":{"harness":"grok","model":"grok-4","effort":"max"}}]}^exact^CREW_DISPATCH: invalid config/crew-dispatch.json - invalid effort: grok:max unsupported grok xhigh effort is flagged^{"rules":[{"when":"deep current work","use":{"harness":"grok","model":"grok-4","effort":"xhigh"}}]}^exact^CREW_DISPATCH: invalid config/crew-dispatch.json - invalid effort: grok:xhigh pi max effort is accepted^{"rules":[{"when":"deep coding","use":{"harness":"pi","model":"openai-codex/gpt-5.6-sol","effort":"max"}}]}^empty^ +pi-signed max effort is accepted^{"rules":[{"when":"signed coding","use":{"harness":"pi-signed","model":"openai-codex/gpt-5.6-sol","effort":"max"}}]}^empty^ unsupported opencode effort is flagged^{"rules":[{"when":"opencode work","use":{"harness":"opencode","model":"anthropic/claude-sonnet-4-5","effort":"high"}}]}^exact^CREW_DISPATCH: invalid config/crew-dispatch.json - invalid effort: opencode:high kimi model profile is accepted^{"rules":[{"when":"kimi work","use":{"harness":"kimi","model":"kimi-code/k3"}}]}^empty^ unsupported kimi effort is flagged^{"rules":[{"when":"kimi work","use":{"harness":"kimi","model":"kimi-code/k3","effort":"high"}}]}^exact^CREW_DISPATCH: invalid config/crew-dispatch.json - invalid effort: kimi:high diff --git a/tests/fm-composer-ghost.test.sh b/tests/fm-composer-ghost.test.sh index d028552889..7249574ea3 100755 --- a/tests/fm-composer-ghost.test.sh +++ b/tests/fm-composer-ghost.test.sh @@ -473,12 +473,12 @@ test_all_tmux_harness_composers_share_classification() { dir="$TMP_ROOT/all-harness-composers"; mkdir -p "$dir" fb=$(make_fake_tmux "$dir") capture="$dir/styled.txt" - for harness in claude codex opencode pi grok; do + for harness in claude codex opencode pi pi-signed grok; do case "$harness" in claude) printf '╭────────────╮\n│ ❯ \033[2mtry\033[0m │\n╰────────────╯\n' > "$capture" ;; codex) printf '╭────────────╮\n│ › \033[2mtip\033[0m │\n╰────────────╯\n' > "$capture" ;; opencode) printf '╭────────────╮\n│ > │\n╰────────────╯\n' > "$capture" ;; - pi) printf '╭────────────╮\n│ │\n╰────────────╯\n' > "$capture" ;; + pi|pi-signed) printf '╭────────────╮\n│ │\n╰────────────╯\n' > "$capture" ;; grok) printf '╭────────────╮\n│ ❯ \033[38;2;50;47;70mType\033[0m │\n╰────────────╯\n' > "$capture" ;; esac out=$(PATH="$fb:$PATH" FM_FAKE_STYLED="$capture" FM_FAKE_CY=1 \ @@ -488,7 +488,7 @@ test_all_tmux_harness_composers_share_classification() { case "$harness" in claude|grok) printf '╭────────────╮\n│ ❯ fix │\n╰────────────╯\n' > "$capture" ;; codex) printf '╭────────────╮\n│ › fix │\n╰────────────╯\n' > "$capture" ;; - opencode|pi) printf '╭────────────╮\n│ > fix │\n╰────────────╯\n' > "$capture" ;; + opencode|pi|pi-signed) printf '╭────────────╮\n│ > fix │\n╰────────────╯\n' > "$capture" ;; esac out=$(PATH="$fb:$PATH" FM_FAKE_STYLED="$capture" FM_FAKE_CY=1 \ fm_tmux_composer_state "fakepane") diff --git a/tests/fm-instruction-owners.test.sh b/tests/fm-instruction-owners.test.sh index 5cb26268f7..f55f3e905f 100755 --- a/tests/fm-instruction-owners.test.sh +++ b/tests/fm-instruction-owners.test.sh @@ -123,7 +123,7 @@ test_agent_owned_quota_array_dispatch_contract() { '| claude | Open the current interactive session' \ '| codex | Open the current interactive session' \ '| opencode | Run `opencode models [provider]`' \ - '| pi | Run `pi --list-models [search]`' \ + '| pi / pi-signed | Run the selected executable as ` --list-models [search]`' \ '| grok | Run `grok models`' \ "For an unfamiliar harness or model namespace, establish support and provider identity from that harness's authoritative CLI help, model listing, or current documentation rather than guessing" \ 'If those sources do not establish the relationship needed for dispatch, fail loudly and report the unresolved candidate.'; do diff --git a/tests/fm-kimi-harness.test.sh b/tests/fm-kimi-harness.test.sh index 9e0f450439..8ac5922ec5 100755 --- a/tests/fm-kimi-harness.test.sh +++ b/tests/fm-kimi-harness.test.sh @@ -24,8 +24,8 @@ test_existing_launch_templates_are_byte_pinned() { assert_source_line " printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox \"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"'" assert_source_line " printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox -c \"notify=[\\\"bash\\\",\\\"-c\\\",\\\"touch __TURNEND__\\\"]\" \"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"'" assert_source_line " opencode) printf '%s' 'OPENCODE_CONFIG_CONTENT='\\''{\"permission\":{\"*\":\"allow\"}}'\\'' opencode __MODELFLAG__--prompt \"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"' ;;" - assert_source_line " printf '%s' 'pi __MODELFLAG____EFFORTFLAG__-e __PITURNEND__ -e __PIWATCH__ \"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"'" - assert_source_line " printf '%s' 'pi __MODELFLAG____EFFORTFLAG__-e __PIEXT__ \"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"'" + assert_source_line " printf '%s%s' \"\$harness\" ' __MODELFLAG____EFFORTFLAG__-e __PITURNEND__ -e __PIWATCH__ \"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"'" + assert_source_line " printf '%s%s' \"\$harness\" ' __MODELFLAG____EFFORTFLAG__-e __PIEXT__ \"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"'" assert_source_line " grok) printf '%s' 'grok --always-approve __MODELFLAG____EFFORTFLAG__\"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"' ;;" pass "fm-spawn: the five pre-existing adapters' launch templates stay byte-pinned" } diff --git a/tests/fm-secondmate-harness.test.sh b/tests/fm-secondmate-harness.test.sh index 87d60b0eb2..39ca2021bf 100755 --- a/tests/fm-secondmate-harness.test.sh +++ b/tests/fm-secondmate-harness.test.sh @@ -76,6 +76,7 @@ both absent -> own (backward-compat)^-^-^claude^claude crew set, secondmate absent -> crew (backward-compat)^codex^-^codex^codex crew set, secondmate set -> secondmate wins, crew untouched^codex^grok^grok^codex crew absent, secondmate set -> secondmate value, crew own^-^grok^grok^claude +signed Pi wrapper remains a distinct secondmate value^codex^pi-signed^pi-signed^codex secondmate=default defers to crew^codex^default^codex^codex crew=default resolves to own, secondmate follows^default^-^claude^claude secondmate=default with crew absent -> own^-^default^claude^claude @@ -113,6 +114,7 @@ absent file -> own harness, empty model/effort^ABSENT^claude^^ bare harness only -> empty model/effort (backward-compat)^claude^claude^^ harness + model -> model only^claude opus^claude^opus^ harness + model + effort -> both^claude opus high^claude^opus^high +signed Pi wrapper + model + effort preserves every token^pi-signed openai-codex/gpt-5.6-sol max^pi-signed^openai-codex/gpt-5.6-sol^max default harness token -> falls back to crew, empty model/effort^default^claude^^ extra whitespace between tokens is tolerated^grok grok-4 xhigh^grok^grok-4^xhigh leading/trailing blank lines and a comment are skipped^# a comment\n\nclaude opus low\n^claude^opus^low @@ -120,6 +122,71 @@ ROWS pass "C1 fm-harness.sh secondmate-model/secondmate-effort resolve the optional tokens; bare harness stays empty (backward-compat)" } +# =========================================================================== +# A/C) pi-signed process identity and shared Pi marker behavior +# =========================================================================== +test_pi_signed_detection_and_session_lock_identity() { + local dir fakebin got + dir="$TMP_ROOT/pi-signed-identity" + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +set -u +field= pid= +while [ "$#" -gt 0 ]; do + case "$1" in + -o) field=$2; shift 2 ;; + -p) pid=$2; shift 2 ;; + *) shift ;; + esac +done +case "$pid:$field:${FM_TEST_SIGNED_SHAPE:-exact}" in + 100:comm=:*) printf '%s\n' '/test/Pi.app/bin/pi' ;; + 100:args=:*) printf '%s\n' 'Pi' ;; + 100:ppid=:*) printf '%s\n' 200 ;; + 200:comm=:exact) printf '%s\n' '/opt/test/bin/pi-signed' ;; + 200:args=:exact) printf '%s\n' 'pi-signed --model test/model' ;; + 200:comm=:helper) printf '%s\n' '/opt/test/bin/pi-signed-helper' ;; + 200:args=:helper) printf '%s\n' 'pi-signed-helper' ;; + 200:comm=:plain) printf '%s\n' '/bin/zsh' ;; + 200:args=:plain) printf '%s\n' 'zsh' ;; + 200:ppid=:*) printf '%s\n' 1 ;; + *:comm=:*) printf '%s\n' bash ;; + *:args=:*) printf '%s\n' bash ;; + *:ppid=:*) printf '%s\n' 100 ;; +esac +SH + chmod +x "$fakebin/ps" + + got=$(PATH="$fakebin:$BASE_PATH" PI_CODING_AGENT=true "$ROOT/bin/fm-harness.sh") + [ "$got" = pi ] || fail "unmarked shared signed-wrapper ancestry resolved '$got', expected pi" + got=$(PATH="$fakebin:$BASE_PATH" PI_CODING_AGENT=true FM_PI_HARNESS=pi-signed "$ROOT/bin/fm-harness.sh") + [ "$got" = pi-signed ] || fail "selected signed wrapper resolved '$got', expected pi-signed" + got=$(PATH="$fakebin:$BASE_PATH" PI_CODING_AGENT=true FM_PI_HARNESS=pi "$ROOT/bin/fm-harness.sh") + [ "$got" = pi ] || fail "selected plain Pi resolved '$got', expected pi" + got=$(PATH="$fakebin:$BASE_PATH" PI_CODING_AGENT=true FM_PI_HARNESS=pi-signed-helper "$ROOT/bin/fm-harness.sh") + [ "$got" = pi ] || fail "inexact signed selection marker resolved '$got', expected pi" + got=$(PATH="$fakebin:$BASE_PATH" FM_PI_HARNESS=pi-signed "$ROOT/bin/fm-harness.sh") + [ "$got" = pi ] || fail "signed selection marker without Pi's family marker resolved '$got', expected pi" + got=$(PATH="$fakebin:$BASE_PATH" PI_CODING_AGENT=true FM_TEST_SIGNED_SHAPE=plain "$ROOT/bin/fm-harness.sh") + [ "$got" = pi ] || fail "plain Pi marker resolved '$got', expected pi" + got=$(PATH="$fakebin:$BASE_PATH" PI_CODING_AGENT=true FM_TEST_SIGNED_SHAPE=helper "$ROOT/bin/fm-harness.sh") + [ "$got" = pi ] || fail "unrelated pi-signed-helper ancestry resolved '$got', expected pi" + + got=$(PATH="$fakebin:$BASE_PATH" bash -c \ + '. "$0/bin/fm-session-lock-lib.sh"; fm_harness_ancestry_pid' "$ROOT") + [ "$got" = 100 ] || fail "session-lock ancestry selected '$got', expected the inner Pi engine pid 100" + PATH="$fakebin:$BASE_PATH" bash -c \ + '. "$0/bin/fm-session-lock-lib.sh"; kill() { return 0; }; fm_harness_pid_alive 200' "$ROOT" \ + || fail "session-lock liveness rejected exact pi-signed holder" + if PATH="$fakebin:$BASE_PATH" FM_TEST_SIGNED_SHAPE=helper bash -c \ + '. "$0/bin/fm-session-lock-lib.sh"; kill() { return 0; }; fm_harness_pid_alive 200' "$ROOT"; then + fail "session-lock liveness accepted unrelated pi-signed-helper" + fi + + pass "pi-signed identity: authoritative launch selection distinguishes shared wrapper ancestry" +} + # =========================================================================== # B) propagate_inheritable_config unit behavior # =========================================================================== @@ -2050,6 +2117,7 @@ SH test_harness_resolution test_secondmate_model_effort_tokens +test_pi_signed_detection_and_session_lock_identity test_propagate_lib test_spawn_split_and_inherit test_spawn_backward_compat_crew_fallback diff --git a/tests/fm-secondmate-liveness.test.sh b/tests/fm-secondmate-liveness.test.sh index 2b57268157..ff5c07a948 100755 --- a/tests/fm-secondmate-liveness.test.sh +++ b/tests/fm-secondmate-liveness.test.sh @@ -97,7 +97,7 @@ SH test_tmux_agent_state_classifies() { local fb out - for harness in claude codex opencode grok kimi; do + for harness in claude codex opencode grok kimi pi pi-signed pi-launcher Pi; do fb=$(make_probe_tmux "$TMP_ROOT/tmux-$harness" "$harness") out=$(PATH="$fb:$BASE_PATH" bash -c '. "$0/bin/fm-backend.sh"; fm_backend_agent_state tmux sess:win' "$ROOT") [ "$out" = alive ] || fail "a live $harness foreground process should classify as alive, got '$out'" @@ -206,7 +206,7 @@ test_agent_state_dispatcher_and_compatibility() { make_toolchain() { local dir=$1 fakebin fakebin=$(fm_fakebin "$dir") - fm_fake_exit0 "$fakebin" node gh-axi chrome-devtools-axi lavish-axi + fm_fake_exit0 "$fakebin" node gh-axi chrome-devtools-axi lavish-axi pi-signed cat > "$fakebin/gh" <<'SH' #!/usr/bin/env bash exit 0 @@ -391,6 +391,25 @@ test_sweep_respawns_authoritatively_missing_pi_secondmate() { pass "sweep: an authoritatively missing Pi secondmate window is relaunched" } +test_sweep_respawns_authoritatively_missing_pi_signed_secondmate() { + local w fb tmuxfb log out + w=$(new_world sweep-missing-pi-signed) + printf '%s\n' pi-signed > "$w/home/config/secondmate-harness" + add_sm_home "$w" sm1 firstmate:fm-sm1 pi-signed + fb=$(make_toolchain "$w"); tmuxfb=$(make_liveness_tmux "$w") + log="$w/calls.log"; : > "$log" + + out=$(run_bootstrap "$tmuxfb:$fb" "$w/home" missing "$log") + + assert_not_contains "$out" "unverified for recovery" \ + "a recorded pi-signed secondmate should be verified for recovery" + assert_contains "$(cat "$log")" "new-window" \ + "an authoritatively missing pi-signed secondmate should be relaunched" + assert_not_contains "$(cat "$log")" "kill-window" \ + "an absent pi-signed window should not need a destructive pre-kill" + pass "sweep: an authoritatively missing pi-signed secondmate window is relaunched" +} + test_sweep_never_acts_on_ambiguous_existing_process() { local w fb tmuxfb log out w=$(new_world sweep-ambiguous) @@ -514,6 +533,7 @@ test_agent_state_dispatcher_and_compatibility test_sweep_respawns_confirmed_dead_secondmate test_sweep_leaves_alive_secondmate_untouched test_sweep_respawns_authoritatively_missing_pi_secondmate +test_sweep_respawns_authoritatively_missing_pi_signed_secondmate test_sweep_never_acts_on_ambiguous_existing_process test_sweep_never_acts_on_transient_unreadability test_sweep_reports_missing_endpoint_relaunch_failure diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index 29cf02d347..69eb5a24bd 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -405,15 +405,22 @@ SH # run_session_start # Drop every harness env marker from bin/fm-harness.sh detect_own so the # surrounding interactive shell cannot leak past the suite's fake ps harness. -# Markers today: CLAUDECODE (claude), PI_CODING_AGENT (pi), GROK_AGENT (grok). +# Markers today: CLAUDECODE (claude), PI_CODING_AGENT plus FM_PI_HARNESS +# (Pi family), GROK_AGENT (grok). # codex and opencode have no env markers (ancestry only). Without this, a local # claude/pi/grok session fails cases that pin a different fake harness while CI # (no ambient markers) still passes. run_session_start() { - local home=$1 root=$2 path=$3 - env -u CLAUDECODE -u PI_CODING_AGENT -u GROK_AGENT \ - FM_HOME="$home" FM_ROOT_OVERRIDE="$root" PATH="$path" \ - "$SESSION_START" + local home=$1 root=$2 path=$3 pi_harness=${4:-} + if [ -n "$pi_harness" ]; then + env -u CLAUDECODE -u GROK_AGENT PI_CODING_AGENT=true FM_PI_HARNESS="$pi_harness" \ + FM_HOME="$home" FM_ROOT_OVERRIDE="$root" PATH="$path" \ + "$SESSION_START" + else + env -u CLAUDECODE -u PI_CODING_AGENT -u FM_PI_HARNESS -u GROK_AGENT \ + FM_HOME="$home" FM_ROOT_OVERRIDE="$root" PATH="$path" \ + "$SESSION_START" + fi } # prepare_session_start_secondmate : a throwaway main home and Pi @@ -1251,6 +1258,29 @@ EOF pass "session start emits exactly one detected harness block and reports Pi extension load state" } +test_pi_signed_primary_uses_pi_extensions_without_identity_normalization() { + local rec root home fakebin out + rec=$(new_world pi-signed-supervision-block) + IFS='|' read -r root home fakebin < "$LAUNCH_LOG" + + out=$(FM_ROOT_OVERRIDE='' FM_HOME="$HOME_DIR" \ + FM_STATE_OVERRIDE="$HOME_DIR/state" FM_DATA_OVERRIDE="$HOME_DIR/data" \ + FM_PROJECTS_OVERRIDE="$HOME_DIR/projects" FM_CONFIG_OVERRIDE="$HOME_DIR/config" \ + FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$WT_DIR" TMUX="fake,1,0" \ + FM_FAKE_LAUNCH_LOG="$LAUNCH_LOG" PATH="$FAKEBIN_DIR:/usr/bin:/bin:/usr/sbin:/sbin" \ + "$SPAWN" "$id" "$PROJ_DIR" 2>&1) + status=$? + expect_code 1 "$status" "a missing pi-signed executable should refuse the spawn" + assert_contains "$out" "pi-signed executable not found on PATH" \ + "missing pi-signed refusal did not name the actionable requirement" + assert_absent "$HOME_DIR/state/$id.meta" "missing pi-signed refusal wrote task metadata" + [ ! -s "$LAUNCH_LOG" ] || fail "missing pi-signed refusal typed a launch command" + pass "pi-signed refuses safely and actionably when the selected executable is unavailable" +} + +test_pi_signed_persistent_secondmate_uses_pi_extensions_and_identity() { + local rec id sm out status launch + id=profile-pi-signed-secondmate-z8d + rec=$(make_spawn_case profile-pi-signed-secondmate codex "$id") + read_case_record "$rec" + printf '%s\n' pi-signed > "$HOME_DIR/config/secondmate-harness" + sm="$CASE_DIR/secondmate-home" + make_seeded_secondmate_home "$sm" "$id" + sm=$(cd "$sm" && pwd -P) + + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$sm" --secondmate) + status=$? + expect_code 0 "$status" "pi-signed persistent secondmate spawn should succeed" + assert_contains "$out" "spawned $id harness=pi-signed kind=secondmate" \ + "pi-signed secondmate spawn did not preserve its runtime identity" + assert_meta_profile "$HOME_DIR/state/$id.meta" pi-signed default default + launch=$(cat "$LAUNCH_LOG") + assert_contains "$launch" "FM_PI_HARNESS=pi-signed pi-signed -e '$sm/.pi/extensions/fm-primary-turnend-guard.ts' -e '$sm/.pi/extensions/fm-primary-pi-watch.ts'" \ + "pi-signed secondmate did not share Pi's primary extension launch shape" + pass "pi-signed is a distinct persistent secondmate runtime with shared Pi supervision semantics" +} + test_batch_forwards_shared_profile_flags() { local rec id1 id2 out status id1=profile-batch-a-z9 @@ -402,6 +468,9 @@ test_grok_omits_invalid_max_reasoning_effort test_grok_omits_invalid_xhigh_reasoning_effort test_opencode_threads_model_and_ignores_effort_axis test_pi_threads_model_and_max_effort +test_pi_signed_threads_shared_pi_profile_and_preserves_identity +test_pi_signed_missing_binary_refuses_before_endpoint_or_metadata +test_pi_signed_persistent_secondmate_uses_pi_extensions_and_identity test_batch_forwards_shared_profile_flags test_active_dispatch_profile_does_not_block_secondmate_launch diff --git a/tests/fm-supervision-instructions.test.sh b/tests/fm-supervision-instructions.test.sh index 8241c3e07a..e8e5f4f919 100755 --- a/tests/fm-supervision-instructions.test.sh +++ b/tests/fm-supervision-instructions.test.sh @@ -115,6 +115,22 @@ test_cross_harness_ordinary_continuation_and_repair_matrix() { pass "renderer preserves every harness ordinary-continuation and missing-cycle repair path" } +test_pi_signed_preserves_identity_with_pi_supervision_protocol() { + local out ordinary + out=$("$RENDER" --harness pi-signed) + assert_contains "$out" "primary harness: pi-signed" \ + "pi-signed supervision normalized the visible runtime identity to pi" + assert_contains "$out" "Mode: Pi extension background wake." \ + "pi-signed did not reuse Pi's authoritative supervision protocol" + ordinary=$(printf '%s\n' "$out" | grep -F -- '- Ordinary wake:') + assert_contains "$ordinary" "Pi extension already owns watcher continuity" \ + "pi-signed ordinary-wake semantics diverged from Pi" + out=$("$RENDER" --harness pi-signed --repair-line) + assert_contains "$out" "Pi tool fm_watch_arm_pi" \ + "pi-signed repair semantics diverged from Pi" + pass "pi-signed keeps its identity while sharing Pi's supervision protocol" +} + test_grok_is_background_notify() { local out out=$("$RENDER" --harness grok) @@ -160,6 +176,7 @@ test_unknown_fallback test_conditional_stanzas test_repair_lines test_cross_harness_ordinary_continuation_and_repair_matrix +test_pi_signed_preserves_identity_with_pi_supervision_protocol test_grok_is_background_notify test_grok_command_sources_effective_config test_pi_snippet_uses_effective_extension_path diff --git a/tests/fm-tmux-submit-busy.test.sh b/tests/fm-tmux-submit-busy.test.sh index 58932509d0..f3eb49a7eb 100755 --- a/tests/fm-tmux-submit-busy.test.sh +++ b/tests/fm-tmux-submit-busy.test.sh @@ -251,6 +251,7 @@ test_claude_busy_signature_uses_real_capture_shapes() { pane_busy old-claude claude || fail "older Claude escape footer should be busy" printf 'Working...\n' > "$composer" pane_busy pi pi || fail "Pi Working footer should be busy" + pane_busy pi-signed pi-signed || fail "pi-signed should share Pi's exact Working footer" printf 'Ctrl+c:cancel\n' > "$composer" pane_busy grok grok || fail "Grok cancel footer should be busy" pass "fm_pane_is_busy: Claude spinner is scoped, multi-frame, and backward-compatible" From 9ea1a1ab4f0071748558b3ef220ba2bc06fdde9d Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:30:47 -0700 Subject: [PATCH 02/35] fix(pi): rearm watcher across session transitions (#1166) * fix(pi): rearm watcher across same-process session transitions Pi emits session_shutdown for ordinary /new, /resume, and /fork replacement as well as terminal quit. The primary watcher extension latched a module-level stopping flag on every shutdown, so a replacement session in the same process could not arm monitoring until Pi restarted. Own arm authority per session generation so only the active live generation may start, stop, or rearm the child. Replacement sessions can arm again without restarting Pi, stale prior-generation callbacks cannot mutate the active cycle, and real quit still blocks late rearm. * no-mistakes(review): Preserve Pi generation isolation and exit cleanup * no-mistakes(document): Correct Pi watcher transition documentation --- .pi/extensions/fm-primary-pi-watch.ts | 162 ++++++++++++------- docs/supervision-protocols/pi.md | 12 +- docs/verification/supervision.md | 13 ++ docs/watcher-continuity.md | 2 + tests/fm-pi-watch-extension.test.sh | 225 ++++++++++++++++++++++++-- 5 files changed, 339 insertions(+), 75 deletions(-) diff --git a/.pi/extensions/fm-primary-pi-watch.ts b/.pi/extensions/fm-primary-pi-watch.ts index 2b8ed99f69..92c39e4fd2 100644 --- a/.pi/extensions/fm-primary-pi-watch.ts +++ b/.pi/extensions/fm-primary-pi-watch.ts @@ -1,4 +1,13 @@ // Firstmate primary watcher bridge for Pi. +// +// Session-generation ownership (stated once here): +// Pi emits session_shutdown for ordinary same-process replacements (/new, /resume, +// /fork, reload) as well as terminal quit. This extension binds one generation per +// session activation. Only the active live generation may start, stop, rearm, or +// clear the arm child. Replacement session_start (or a fresh factory bind) activates +// a new live generation so monitoring can arm again without restarting Pi. Terminal +// quit leaves the final generation stopped so late callbacks cannot rearm. Stale +// callbacks from a prior generation are no-ops against the active replacement. import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; @@ -37,6 +46,16 @@ type WatchToolRenderContext = { isPartial: boolean; }; +type SessionGeneration = { + id: number; + stopping: boolean; + child: ChildProcess | null; + retryTimer: ReturnType | null; + retryFailures: number; + restoring: boolean; + seq: number; +}; + function refreshWatchToolShell( state: WatchToolShellState, theme: Theme, @@ -72,13 +91,10 @@ const retryLimit = positiveInteger("FM_WATCH_REARM_RETRY_LIMIT", 5); const armReadyTimeoutMs = positiveInteger("FM_PI_ARM_READY_TIMEOUT_MS", 12000); const armRetireTimeoutMs = positiveInteger("FM_WATCH_ARM_RETIRE_TIMEOUT_MS", 1000); const repairOnlyHint = "call fm_watch_arm_pi again only after a later notification says the cycle is missing, failed, or unhealthy"; +const shuttingDownMessage = "watcher: not armed - Pi session is shutting down"; -let child: ChildProcess | null = null; -let retryTimer: ReturnType | null = null; -let retryFailures = 0; -let stopping = false; -let seq = 0; -let restoring = false; +let nextGenerationId = 0; +let activeGeneration: SessionGeneration | null = null; const armReadiness = new WeakMap>(); const armClose = new WeakMap>(); @@ -162,7 +178,43 @@ function classifyClose(stdout: string, stderr: string, code: number | null, sign }; } +function createGeneration(): SessionGeneration { + return { + id: ++nextGenerationId, + stopping: false, + child: null, + retryTimer: null, + retryFailures: 0, + restoring: false, + seq: 0, + }; +} + +function activateGeneration(generation: SessionGeneration): void { + activeGeneration = generation; +} + +function generationIsLive(generation: SessionGeneration): boolean { + return activeGeneration === generation && !generation.stopping; +} + +function stopGeneration(generation: SessionGeneration): void { + generation.stopping = true; + if (generation.retryTimer) clearTimeout(generation.retryTimer); + generation.retryTimer = null; + if (generation.child) generation.child.kill("SIGTERM"); + generation.child = null; +} + +const cleanupOnProcessExit = () => { + if (activeGeneration) stopGeneration(activeGeneration); +}; +process.once("exit", cleanupOnProcessExit); + export default function (pi: ExtensionAPI) { + let generation = createGeneration(); + activateGeneration(generation); + let calmPresentation: CalmPresentationState = { active: false, stockExportRendering: false, @@ -179,20 +231,8 @@ export default function (pi: ExtensionAPI) { !calmPresentation.stockExportRendering && !calmTranscriptClassIsVisible(itemClass); - function stopArm(): void { - stopping = true; - if (retryTimer) clearTimeout(retryTimer); - retryTimer = null; - if (child) child.kill("SIGTERM"); - child = null; - } - - const cleanupOnProcessExit = () => { - stopArm(); - }; - process.once("exit", cleanupOnProcessExit); - - async function sendWake(message: string): Promise { + async function sendWake(owner: SessionGeneration, message: string): Promise { + if (!generationIsLive(owner)) return; const content = encodeFirstmateOperationalInput( "watcher", `FIRSTMATE WATCHER WAKE: ${message}\n\nRun bin/fm-wake-drain.sh first and handle the queued wake. Watcher continuity is extension-owned.`, @@ -200,8 +240,8 @@ export default function (pi: ExtensionAPI) { await pi.sendUserMessage(content, { deliverAs: "followUp" }); } - function surfaceFailure(message: string): void { - void sendWake(message).catch(() => { + function surfaceFailure(owner: SessionGeneration, message: string): void { + void sendWake(owner, message).catch(() => { // Pi owns delivery errors; continuity restoration never waits on prompting. }); } @@ -245,12 +285,12 @@ export default function (pi: ExtensionAPI) { }); } - async function restoreAfterActionableClose(predecessorArmPid: string): Promise { + async function restoreAfterActionableClose(owner: SessionGeneration, predecessorArmPid: string): Promise { let failure = ""; for (let attempt = 0; attempt <= retryLimit; attempt += 1) { - if (stopping) return ""; - const replacement = startArm(predecessorArmPid); - const successorChild = child; + if (!generationIsLive(owner)) return ""; + const replacement = startArm(owner, predecessorArmPid); + const successorChild = owner.child; if (replacement.ok && successorChild && await waitForReadiness(successorChild)) return ""; if (replacement.ok) { failure = "watcher: FAILED - Pi extension could not verify a ready successor watcher"; @@ -269,31 +309,32 @@ export default function (pi: ExtensionAPI) { return `${failure}\nwatcher: FAILED - Pi extension could not restore watcher continuity after ${retryLimit} retries`; } - function scheduleRetry(message: string, predecessorArmPid: string): void { - if (stopping || child || retryTimer) return; + function scheduleRetry(owner: SessionGeneration, message: string, predecessorArmPid: string): void { + if (!generationIsLive(owner) || owner.child || owner.retryTimer) return; const ownership = lockOwnership(); if (ownership !== "owned") { - surfaceFailure(`watcher: FAILED - Pi extension cannot restore continuity because this session no longer owns the lock\n${message}`); + surfaceFailure(owner, `watcher: FAILED - Pi extension cannot restore continuity because this session no longer owns the lock\n${message}`); return; } - retryFailures += 1; - if (retryFailures > retryLimit) { - surfaceFailure(`watcher: FAILED - Pi extension could not restore watcher continuity after ${retryLimit} retries\n${message}`); + owner.retryFailures += 1; + if (owner.retryFailures > retryLimit) { + surfaceFailure(owner, `watcher: FAILED - Pi extension could not restore watcher continuity after ${retryLimit} retries\n${message}`); return; } const timer = setTimeout(() => { - if (retryTimer === timer) retryTimer = null; - const result = startArm(predecessorArmPid); + if (owner.retryTimer === timer) owner.retryTimer = null; + if (!generationIsLive(owner)) return; + const result = startArm(owner, predecessorArmPid); if (!result.ok) { - surfaceFailure(`watcher: FAILED - Pi extension could not launch a continuity retry\n${result.message}`); + surfaceFailure(owner, `watcher: FAILED - Pi extension could not launch a continuity retry\n${result.message}`); } - }, retryDelay(retryFailures)); + }, retryDelay(owner.retryFailures)); timer.unref(); - retryTimer = timer; + owner.retryTimer = timer; } - function startArm(predecessorArmPid = ""): ArmResult { - if (stopping) return { ok: false, message: "watcher: not armed - Pi session is shutting down" }; + function startArm(owner: SessionGeneration, predecessorArmPid = ""): ArmResult { + if (!generationIsLive(owner)) return { ok: false, message: shuttingDownMessage }; const ownership = lockOwnership(); if (ownership === "other") return { ok: false, message: "watcher: read-only - session lock is held by another firstmate session" }; if (ownership === "missing") { @@ -303,19 +344,19 @@ export default function (pi: ExtensionAPI) { }; } markLoaded(); - if (child) { + if (owner.child) { return { ok: true, message: `watcher: unchanged - Pi extension already owns an arm child; no manual re-arm needed; ${repairOnlyHint}`, }; } - if (retryTimer) { + if (owner.retryTimer) { return { ok: true, message: `watcher: unchanged - Pi extension already owns a scheduled continuity retry; no manual re-arm needed; ${repairOnlyHint}`, }; } - const id = ++seq; + const id = ++owner.seq; const env = { ...process.env, FM_HOME: fmHome, @@ -329,7 +370,7 @@ export default function (pi: ExtensionAPI) { env, stdio: ["ignore", "pipe", "pipe"], }); - child = armChild; + owner.child = armChild; let stdout = ""; let stderr = ""; let settled = false; @@ -355,7 +396,7 @@ export default function (pi: ExtensionAPI) { } }; const releaseChild = (): void => { - if (child === armChild) child = null; + if (owner.child === armChild) owner.child = null; }; armChild.stdout.on("data", (chunk: Buffer) => { stdout += chunk.toString(); @@ -371,24 +412,24 @@ export default function (pi: ExtensionAPI) { resolveClosed(); settleReadiness(false); releaseChild(); - if (stopping) return; + if (!generationIsLive(owner)) return; const classification = classifyClose(stdout, stderr, code, signal); const predecessor = String(armChild.pid ?? ""); if (classification.kind === "actionable") { - retryFailures = 0; - restoring = true; + owner.retryFailures = 0; + owner.restoring = true; void (async () => { - const failure = await restoreAfterActionableClose(predecessor); - restoring = false; - if (stopping) return; + const failure = await restoreAfterActionableClose(owner, predecessor); + if (generationIsLive(owner)) owner.restoring = false; + if (!generationIsLive(owner)) return; const message = failure ? `${classification.message}\n\n${failure}` : classification.message; - await sendWake(message); + await sendWake(owner, message); })().catch(() => { }); return; } - if (restoring) return; - scheduleRetry(classification.message, predecessor); + if (owner.restoring) return; + scheduleRetry(owner, classification.message, predecessor); }); armChild.on("error", (error: Error) => { if (settled) return; @@ -396,9 +437,9 @@ export default function (pi: ExtensionAPI) { resolveClosed(); settleReadiness(false); releaseChild(); - if (stopping) return; - if (restoring) return; - scheduleRetry(`watcher: FAILED - Pi extension arm child ${id} failed: ${error.message}`, String(armChild.pid ?? "")); + if (!generationIsLive(owner)) return; + if (owner.restoring) return; + scheduleRetry(owner, `watcher: FAILED - Pi extension arm child ${id} failed: ${error.message}`, String(armChild.pid ?? "")); }); return { ok: true, @@ -407,17 +448,18 @@ export default function (pi: ExtensionAPI) { } pi.on?.("session_start", () => { + if (generation.stopping) generation = createGeneration(); + activateGeneration(generation); markLoaded(); }); pi.on?.("session_shutdown", () => { - stopArm(); - process.off("exit", cleanupOnProcessExit); + stopGeneration(generation); }); pi.registerCommand?.("fm-watch-arm-pi", { description: "Arm firstmate watcher supervision through the Pi extension instead of foreground bash.", handler: async (_args, ctx) => { - const result = startArm(); + const result = startArm(generation); ctx.ui.notify(result.message, result.ok ? "info" : "warning"); }, }); @@ -458,7 +500,7 @@ export default function (pi: ExtensionAPI) { return new Container(); }, execute: async () => { - const result = startArm(); + const result = startArm(generation); return { content: [{ type: "text", text: result.message }], details: result, diff --git a/docs/supervision-protocols/pi.md b/docs/supervision-protocols/pi.md index 30c4aae35f..2316428a83 100644 --- a/docs/supervision-protocols/pi.md +++ b/docs/supervision-protocols/pi.md @@ -8,12 +8,14 @@ When this session owns supervision and away mode is not active: Never run `bin/fm-watch-arm.sh` through Pi's bash tool because that foreground arm can wedge the agent and bypasses extension-owned cleanup. 4. If the extension says no live session holds the lock, run `bin/fm-session-start.sh` to reclaim the session lock, then call `fm_watch_arm_pi` again. 5. The extension starts `bin/fm-watch-arm.sh --restart`, keeps the child attached to the live Pi process, and owns every later successor launch. -6. After an actionable child close, the extension rechecks session-lock ownership and verifies one successor before it delivers the follow-up wake; its bounded fallback is defined in `docs/watcher-continuity.md`. -7. Ordinary work, turn completion, and ordinary signal, stale, check, heartbeat, or other wake handling: do not call `fm_watch_arm_pi` again because continuity is extension-owned rather than model-memory-owned. -8. An unexpected child close enters bounded exponential retry, and an exhausted retry or lost session lock is surfaced as a watcher failure instead of disappearing. -9. Missing, failed, or unhealthy cycle only: if a later notification explicitly reports one of those repair conditions, drain queued wakes, inspect the failure text, call `fm_watch_arm_pi`, and restart the selected Pi-family executable with both extensions loaded if needed. +6. Ordinary same-process session replacement (`/new`, `/resume`, `/fork`, reload) retires only the prior generation; call `fm_watch_arm_pi` once for the first cycle of the replacement session without restarting Pi. + The generation-owner contract lives in `.pi/extensions/fm-primary-pi-watch.ts`. +7. After an actionable child close, the extension rechecks session-lock ownership and verifies one successor before it delivers the follow-up wake; its bounded fallback is defined in `docs/watcher-continuity.md`. +8. Ordinary work, turn completion, and ordinary signal, stale, check, heartbeat, or other wake handling: do not call `fm_watch_arm_pi` again because continuity is extension-owned rather than model-memory-owned. +9. An unexpected child close enters bounded exponential retry, and an exhausted retry or lost session lock is surfaced as a watcher failure instead of disappearing. +10. Missing, failed, or unhealthy cycle only: if a later notification explicitly reports one of those repair conditions, drain queued wakes, inspect the failure text, call `fm_watch_arm_pi`, and restart the selected Pi-family executable with both extensions loaded if needed. A redundant call while the extension owns an arm child or scheduled retry is an ownership-based `watcher: unchanged` no-op, not an independent health claim. -10. Never use shell `&` for watcher supervision. +11. Never use shell `&` for watcher supervision. The arm mechanism above is extension-owned, not a model tool call, but a manual recovery probe that backgrounds, pipes, or bundles the arm is denied automatically by the PreToolUse seatbelt (`bin/fm-arm-pretool-check.sh`, wired into the turn-end guard extension at `__FM_PI_TURNEND_EXT__`). The turn-end guard extension lives at `__FM_PI_TURNEND_EXT__`. diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index 30a98311a4..6945b3491d 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -121,10 +121,23 @@ grok 0.2.103 (89c3d36fb6f1) [stable] Pi 0.81.1 repeated the continuity and clean-exit lifecycle on 2026-07-23 after the Calm presentation changes. +Pi same-process session-transition ownership was verified on 2026-07-27 against the tracked extension with a faithful in-process factory rebind (module cache retained, real arm children): + +```sh +pi --version +tests/fm-pi-watch-extension.test.sh +tests/fm-pi-primary-types.test.sh +``` + +Observed guarantee: after ordinary `session_shutdown` for `/new`, `/resume`, and `/fork`, plus same-instance shutdown-plus-start, the replacement generation armed again without a Pi restart and without the `watcher: not armed - Pi session is shutting down` refusal. +Stale prior-generation tool callbacks could not mutate the active child, repeated transitions kept exactly one live arm cycle, and terminal `quit` still refused late rearm. +Plain Pi and pi-signed share the same tracked `.pi/extensions/fm-primary-pi-watch.ts` path, so both inherit the generation owner; other primary harnesses are not applicable because they do not use this Pi extension lifecycle. + Deterministic entry points: ```sh tests/fm-pi-watch-extension.test.sh +tests/fm-pi-primary-types.test.sh tests/fm-watcher-lock.test.sh tests/fm-subagent-pretool-check.test.sh tests/fm-claude-stop-autoarm.test.sh diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index 457c035268..52b3a9eec3 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -8,6 +8,7 @@ Must-work continuity now lives above that process boundary instead of depending Pi's `.pi/extensions/fm-primary-pi-watch.ts` and OpenCode's `.opencode/plugins/fm-primary-watch-arm.js` own continuous re-arm after an actionable child close. Each adapter starts the next arm before delivering the wake prompt, checks current session-lock ownership at launch, preserves one child or scheduled retry at a time, and applies bounded exponential retry after an unexpected or failed close. A failed follow-up never cancels continuity restoration. +Pi same-process session replacement follows the generation-owner contract in `.pi/extensions/fm-primary-pi-watch.ts`. Claude's `.claude/settings.json` Stop `asyncRewake` hook (`bin/fm-claude-stop-autoarm.sh`) owns routine tokenless re-arm. The hook fires on every Stop, and an eligible primary with supervision need admits one home-scoped owner that foregrounds `bin/fm-watch-arm.sh` inside the hook-owned process tree. A numeric session-lock owner that fails the shared `fm_harness_pid_alive` predicate is reclaimed through `bin/fm-lock.sh` before auto-arm state changes, while a live owner, absent lock, or malformed lock keeps the competing hook inert. @@ -52,6 +53,7 @@ Only the watcher process touches `state/.last-watcher-beat`; no helper process c ## Regression coverage `tests/fm-pi-watch-extension.test.sh` checks Pi's first-cycle-or-explicit-repair tool metadata and ownership-based redundant-call no-ops, then simulates actionable and empty child closes against the actual Pi and OpenCode close handlers, blocks prompt delivery to prove the successor launches first, verifies single-flight behavior, changes the session lock before close to prove ownership is rechecked, and hangs each successor arm to prove bounded fallback delivery includes the typed restoration failure. +The same suite covers ordinary same-process session replacement for `/new`, `/resume`, and `/fork`, same-instance shutdown-plus-start, stale prior-generation callbacks, repeated transitions with exactly one live cycle, disappearance of the shutting-down refusal after a valid replacement activates, and terminal quit still refusing late rearm. `tests/fm-watcher-lock.test.sh` covers verified-successor attach, the typed self-eviction failure, bounded and successor-linked lifecycle rows, and a SIGSTOP counterfactual that distinguishes a live PID from a stale beacon before classifying termination. `tests/fm-subagent-pretool-check.test.sh` proves Claude retains only the non-status Bash seatbelts. `tests/fm-claude-stop-autoarm.test.sh` covers the auto-arm's scope, stale and live session owners, unchanged AFK and need boundaries, single-flight, and exit-2 translation. diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index 7347a75ab7..518df0e874 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -98,6 +98,10 @@ test_tracked_extension_present_and_self_hashing() { assert_contains "$text" 'details: result' "tracked extension tool is missing structured result details" assert_contains "$text" 'ctx.ui.notify' "tracked extension command does not notify through Pi's UI" assert_contains "$text" 'process.once("exit", cleanupOnProcessExit)' "tracked extension lacks clean-process-exit cleanup" + assert_contains "$text" "type SessionGeneration" "tracked extension lacks an explicit session-generation owner" + assert_contains "$text" "function activateGeneration" "tracked extension does not activate a live generation for replacement sessions" + assert_contains "$text" "function generationIsLive" "tracked extension does not gate arm mutations on the live generation" + assert_contains "$text" "watcher: not armed - Pi session is shutting down" "tracked extension missing the terminal shutdown refusal" assert_not_contains "$text" "[ -f config/x-mode.env ]" "tracked extension kept a repo-relative x-mode config path" pass "Pi primary watcher extension is tracked, self-hashing, and self-locating" } @@ -934,6 +938,187 @@ EOF pass "Pi watcher arm distinguishes all session lock ownership states" } +test_pi_session_transition_generation_owner() { + local repo home plugin child_pid_file arm_log out status + repo="$TMP_ROOT/pi-session-transition-root" + home="$TMP_ROOT/pi-session-transition-home" + child_pid_file="$TMP_ROOT/pi-session-transition-child.pid" + arm_log="$TMP_ROOT/pi-session-transition-arm.log" + mkdir -p "$repo/bin" "$home/state" "$home/config" + install_pi_watch_extension_fixture "$repo" + plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts" + cat > "$repo/bin/fm-watch-arm.sh" <<'SH' +#!/usr/bin/env bash +printf 'watcher: started pid=%s\n' "$$" +printf '%s\n' "$$" > "${FM_CHILD_PID_FILE:?}" +printf 'arm pid=%s\n' "$$" >> "${FM_ARM_LOG:?}" +trap 'exit 0' TERM INT +while :; do sleep 0.2; done +SH + chmod +x "$repo/bin/fm-watch-arm.sh" + out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_CHILD_PID_FILE="$child_pid_file" FM_ARM_LOG="$arm_log" FM_WATCH_REARM_RETRY_BASE_MS=5 FM_WATCH_REARM_RETRY_MAX_MS=10 FM_WATCH_REARM_RETRY_LIMIT=2 node --input-type=module 2>&1 <<'EOF' +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +function makePi() { + const handlers = new Map(); + let tool = null; + const pi = { + on(event, handler) { + handlers.set(event, handler); + }, + registerCommand() {}, + registerTool(candidate) { + if (candidate.name === "fm_watch_arm_pi") tool = candidate; + }, + sendUserMessage: async () => {}, + events: { on() {} }, + }; + return { pi, handlers, getTool: () => tool }; +} + +function pidAlive(pid) { + try { + process.kill(Number(pid), 0); + return true; + } catch { + return false; + } +} + +async function waitFor(pred, label, attempts = 250) { + for (let i = 0; i < attempts; i += 1) { + if (pred()) return; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error(`timeout waiting for ${label}`); +} + +function liveArmPids() { + if (!existsSync(process.env.FM_ARM_LOG)) return []; + return readFileSync(process.env.FM_ARM_LOG, "utf8") + .trim() + .split(/\n/) + .filter(Boolean) + .map((line) => { + const match = /pid=(\d+)/.exec(line); + return match ? match[1] : ""; + }) + .filter(Boolean) + .filter(pidAlive); +} + +writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); +const mod = await import(pathToFileURL(process.env.PLUGIN).href); + +const startup = makePi(); +mod.default(startup.pi); +await startup.handlers.get("session_start")?.({ type: "session_start", reason: "startup" }, {}); +const first = await startup.getTool().execute("startup", {}, undefined, undefined, {}); +if (!first.details?.ok || !String(first.details.message).includes("started Pi extension arm child")) { + throw new Error(`startup arm failed: ${JSON.stringify(first.details)}`); +} +await waitFor(() => existsSync(process.env.FM_CHILD_PID_FILE), "startup child"); +const startupChild = readFileSync(process.env.FM_CHILD_PID_FILE, "utf8").trim(); +if (!pidAlive(startupChild)) throw new Error("startup child was not alive"); +const staleTool = startup.getTool(); + +async function replaceSession(previous, reason) { + const previousChild = existsSync(process.env.FM_CHILD_PID_FILE) + ? readFileSync(process.env.FM_CHILD_PID_FILE, "utf8").trim() + : ""; + await previous.handlers.get("session_shutdown")?.({ type: "session_shutdown", reason }, {}); + if (previousChild) { + await waitFor(() => !pidAlive(previousChild), `${reason} previous child exit`); + } + const next = makePi(); + mod.default(next.pi); + await next.handlers.get("session_start")?.({ + type: "session_start", + reason, + previousSessionFile: `/tmp/previous-${reason}.jsonl`, + }, {}); + const armed = await next.getTool().execute(`arm-${reason}`, {}, undefined, undefined, {}); + if (!armed.details?.ok) { + throw new Error(`${reason} replacement arm failed: ${JSON.stringify(armed.details)}`); + } + if (String(armed.details.message).includes("shutting down")) { + throw new Error(`${reason} replacement still refused with shutting-down latch`); + } + await waitFor(() => { + if (!existsSync(process.env.FM_CHILD_PID_FILE)) return false; + const child = readFileSync(process.env.FM_CHILD_PID_FILE, "utf8").trim(); + return child && child !== previousChild && pidAlive(child); + }, `${reason} replacement child`); + const live = liveArmPids(); + if (live.length !== 1) { + throw new Error(`${reason} expected exactly one live arm child, got ${live.join(",") || "(none)"}`); + } + return next; +} + +let current = await replaceSession(startup, "new"); +current = await replaceSession(current, "resume"); +current = await replaceSession(current, "fork"); + +// Same bound instance: ordinary shutdown then session_start without a fresh factory. +const sameInstanceChild = readFileSync(process.env.FM_CHILD_PID_FILE, "utf8").trim(); +await current.handlers.get("session_shutdown")?.({ type: "session_shutdown", reason: "new" }, {}); +await current.handlers.get("session_start")?.({ type: "session_start", reason: "new" }, {}); +const sameInstanceArm = await current.getTool().execute("same-instance", {}, undefined, undefined, {}); +if (!sameInstanceArm.details?.ok || String(sameInstanceArm.details.message).includes("shutting down")) { + throw new Error(`same-instance replacement arm failed: ${JSON.stringify(sameInstanceArm.details)}`); +} +await waitFor(() => { + if (!existsSync(process.env.FM_CHILD_PID_FILE)) return false; + const child = readFileSync(process.env.FM_CHILD_PID_FILE, "utf8").trim(); + return child !== sameInstanceChild && pidAlive(child); +}, "same-instance replacement child"); +await waitFor(() => !pidAlive(sameInstanceChild), "same-instance previous child exit"); +if (liveArmPids().length !== 1) { + throw new Error(`same-instance expected one live arm child, got ${liveArmPids().join(",")}`); +} + +// Stale prior-generation callback must not stop, rearm, or clear the active generation. +const activeChild = readFileSync(process.env.FM_CHILD_PID_FILE, "utf8").trim(); +const stale = await staleTool.execute("stale-prior-generation", {}, undefined, undefined, {}); +if (stale.details?.ok !== false || !String(stale.details.message).includes("shutting down")) { + throw new Error(`stale prior generation did not refuse: ${JSON.stringify(stale.details)}`); +} +if (!pidAlive(activeChild)) throw new Error("active generation child died after stale callback"); +if (pidAlive(startupChild)) throw new Error("startup generation child was resurrected"); +if (liveArmPids().length !== 1 || liveArmPids()[0] !== activeChild) { + throw new Error(`stale callback mutated live arm set: ${liveArmPids().join(",")}`); +} +const redundant = await current.getTool().execute("redundant", {}, undefined, undefined, {}); +if (!redundant.details?.ok || !String(redundant.details.message).includes("unchanged")) { + throw new Error(`active generation lost single-flight ownership: ${JSON.stringify(redundant.details)}`); +} + +// Repeated transitions keep exactly one live cycle and never revive the refusal. +for (const reason of ["resume", "fork", "new", "resume"]) { + current = await replaceSession(current, reason); +} + +// Real terminal shutdown still blocks late rearming. +const finalChild = readFileSync(process.env.FM_CHILD_PID_FILE, "utf8").trim(); +await current.handlers.get("session_shutdown")?.({ type: "session_shutdown", reason: "quit" }, {}); +await waitFor(() => !pidAlive(finalChild), "terminal shutdown child exit"); +const quitArm = await current.getTool().execute("after-quit", {}, undefined, undefined, {}); +if (quitArm.details?.ok !== false || quitArm.details.message !== "watcher: not armed - Pi session is shutting down") { + throw new Error(`terminal quit must keep the shutting-down refusal: ${JSON.stringify(quitArm.details)}`); +} +if (liveArmPids().length !== 0) { + throw new Error(`terminal quit left live arm children: ${liveArmPids().join(",")}`); +} +EOF +) + status=$? + expect_code 0 "$status" "Pi session transitions must rearm through an explicit generation owner" + [ -z "$out" ] || fail "Pi session-transition generation owner test printed output: $out" + pass "Pi session transitions use a generation owner across /new /resume /fork, stale callbacks, and quit" +} + test_pi_process_exit_cleanup_listener_lifecycle() { local repo home plugin out status repo="$TMP_ROOT/pi-exit-listener-root" @@ -962,15 +1147,19 @@ if (process.listenerCount("exit") !== before + 1) { throw new Error("Pi extension did not install exactly one process-exit fallback"); } await handlers.get("session_shutdown")?.({ type: "session_shutdown" }, {}); -if (process.listenerCount("exit") !== before) { - throw new Error("session_shutdown did not remove the process-exit fallback"); +if (process.listenerCount("exit") !== before + 1) { + throw new Error("session_shutdown removed the process-lifetime exit fallback"); +} +await handlers.get("session_start")?.({ type: "session_start" }, {}); +if (process.listenerCount("exit") !== before + 1) { + throw new Error("replacement activation duplicated the process-exit fallback"); } EOF ) status=$? - expect_code 0 "$status" "Pi cleanup fallback listener must install once and unregister on session shutdown" + expect_code 0 "$status" "Pi cleanup fallback listener must remain singular across session replacement" [ -z "$out" ] || fail "Pi listener-lifecycle test printed output: $out" - pass "Pi process-exit cleanup listener has a bounded lifecycle" + pass "Pi process-exit cleanup listener remains singular across session replacement" } test_pi_process_exit_cleanup_stops_arm_child() { @@ -984,18 +1173,21 @@ test_pi_process_exit_cleanup_stops_arm_child() { plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts" cat > "$repo/bin/fm-watch-arm.sh" <<'SH' #!/usr/bin/env bash -trap 'printf "cleaned\n" > "$FM_CLEANUP_LOG"; exit 0' TERM +trap 'printf "%s\n" "$$" >> "$FM_CLEANUP_LOG"; exit 0' TERM printf '%s\n' "$$" > "$FM_CHILD_PID_FILE" while :; do sleep 1; done SH chmod +x "$repo/bin/fm-watch-arm.sh" out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_CLEANUP_LOG="$cleanup_log" FM_CHILD_PID_FILE="$pid_file" node --input-type=module 2>&1 <<'EOF' -import { existsSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; let tool = null; +const handlers = new Map(); const pi = { - on() {}, + on(event, handler) { + handlers.set(event, handler); + }, registerCommand() {}, registerTool(candidate) { if (candidate.name === "fm_watch_arm_pi") tool = candidate; @@ -1010,19 +1202,31 @@ for (let i = 0; i < 250 && !existsSync(process.env.FM_CHILD_PID_FILE); i += 1) { await new Promise((resolve) => setTimeout(resolve, 20)); } if (!existsSync(process.env.FM_CHILD_PID_FILE)) throw new Error("arm child did not start"); +const firstChild = readFileSync(process.env.FM_CHILD_PID_FILE, "utf8").trim(); +await handlers.get("session_shutdown")?.({ type: "session_shutdown" }, {}); +await handlers.get("session_start")?.({ type: "session_start" }, {}); +await tool.execute("tool-call-replacement", {}, undefined, undefined, {}); +for (let i = 0; i < 250; i += 1) { + const currentChild = readFileSync(process.env.FM_CHILD_PID_FILE, "utf8").trim(); + if (currentChild !== firstChild) break; + await new Promise((resolve) => setTimeout(resolve, 20)); +} +if (readFileSync(process.env.FM_CHILD_PID_FILE, "utf8").trim() === firstChild) { + throw new Error("replacement arm child did not start"); +} process.exit(0); EOF ) status=$? expect_code 0 "$status" "Pi process exit must run the watcher cleanup fallback" [ -z "$out" ] || fail "Pi process-exit cleanup test printed output: $out" + pid=$(cat "$pid_file") i=0 - while [ "$i" -lt 250 ] && [ ! -f "$cleanup_log" ]; do + while [ "$i" -lt 250 ] && ! grep -qx "$pid" "$cleanup_log" 2>/dev/null; do sleep 0.02 i=$((i + 1)) done - [ -f "$cleanup_log" ] || fail "Pi process-exit fallback did not deliver TERM to the arm child" - pid=$(cat "$pid_file") + grep -qx "$pid" "$cleanup_log" 2>/dev/null || fail "Pi process-exit fallback did not deliver TERM to the replacement arm child" if kill -0 "$pid" 2>/dev/null; then kill -TERM "$pid" 2>/dev/null || true fail "Pi arm child $pid survived process-exit cleanup" @@ -2012,6 +2216,7 @@ test_pi_empty_close_retries_instead_of_disappearing test_pi_established_empty_close_honors_retry_limit test_pi_actionable_close_rechecks_session_lock test_pi_arm_distinguishes_session_lock_ownership +test_pi_session_transition_generation_owner test_pi_process_exit_cleanup_listener_lifecycle test_pi_process_exit_cleanup_stops_arm_child test_opencode_primary_watch_plugin_static_wiring From fa0d85d00be145196d60eee5dffcbe18ab3af901 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:37:11 -0700 Subject: [PATCH 03/35] feat: route crew dispatch using quota-window pace (#1172) * Consume quota-axi pace signals in dispatch profile array selection. Add quota-array-dispatch as the single owner of the pace-aware candidate choice, keep AGENTS.md to the intake boundary and load trigger, and cover the acceptance cases with sanitized schemaVersion 3 fixtures. * no-mistakes(review): Stop and report genuine quota dispatch ties * no-mistakes(document): Document quota pace freshness and uncertainty --- .agents/skills/harness-adapters/SKILL.md | 1 + .agents/skills/quota-array-dispatch/SKILL.md | 170 +++++++++ AGENTS.md | 6 +- bin/fm-bootstrap.sh | 3 +- docs/architecture.md | 2 +- docs/configuration.md | 7 +- docs/documentation-audiences.json | 4 + docs/examples/crew-dispatch.json | 2 +- .../fixtures/quota-array-dispatch/cases.json | 352 ++++++++++++++++++ .../quota-array-dispatch/schema-v3-shape.json | 103 +++++ tests/fm-instruction-owners.test.sh | 16 +- tests/fm-quota-array-dispatch.test.sh | 278 ++++++++++++++ 12 files changed, 931 insertions(+), 13 deletions(-) create mode 100644 .agents/skills/quota-array-dispatch/SKILL.md create mode 100644 tests/fixtures/quota-array-dispatch/cases.json create mode 100644 tests/fixtures/quota-array-dispatch/schema-v3-shape.json create mode 100755 tests/fm-quota-array-dispatch.test.sh diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index c5ba453f95..9ea4112153 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -12,6 +12,7 @@ Use this reference before any harness-specific firstmate operation: spawn, recov Crewmates default to the same harness firstmate is running on unless `config/crew-harness` records an adapter name. Optional dispatch profiles in `config/crew-dispatch.json` can override that static default for one crewmate or scout dispatch by selecting concrete harness, model, and effort axes at intake. +When a matched rule or default is a profile array, load `quota-array-dispatch` for the pace-aware candidate choice after this skill establishes harness and model/provider facts. The captain may override that file at session start or later; a per-task instruction such as "run this one on codex" overrides it for that dispatch only. `default` means mirror firstmate's own harness. diff --git a/.agents/skills/quota-array-dispatch/SKILL.md b/.agents/skills/quota-array-dispatch/SKILL.md new file mode 100644 index 0000000000..a5fe06d6ec --- /dev/null +++ b/.agents/skills/quota-array-dispatch/SKILL.md @@ -0,0 +1,170 @@ +--- +name: quota-array-dispatch +description: >- + Agent-only decision procedure for resolving a matched crew-dispatch profile + array from current quota-axi output, including quota-window pace signals. + Load when a dispatch rule or default resolves to more than one profile candidate. +user-invocable: false +metadata: + internal: true +--- + +# quota-array-dispatch + +This skill is the single owner of the pace-aware profile-array selection procedure. +The concise always-loaded intake boundary remains in `AGENTS.md` section 4. +`docs/configuration.md` owns the `config/crew-dispatch.json` schema only. +`quota-axi` remains data-only and never recommends a route. +Firstmate owns the judgment. +Do not add a daemon, opaque composite score, routing wrapper, hard-coded model-specific policy, or producer-side route recommendation. + +## When to load + +Load this skill whenever a matched dispatch rule or the configured default resolves to a profile array (more than one candidate), before choosing the concrete `--harness`, `--model`, and `--effort` passed to `fm-spawn`. +Keep using `harness-adapters` for harness verification, model/provider discovery, and effort fallback. + +## Intake boundary this skill does not relax + +1. Explicit per-task captain overrides still win over configured profiles. +2. Configured profile matching precedence is unchanged: best-fit rule, then configured default, then static crewmate harness. +3. Malformed `config/crew-dispatch.json` remains an actionable error; never select around it. +4. Every configured candidate in the matched array must be accounted for. +5. If any harness/model/provider relationship, applicable quota data, or interpretation cannot be established, stop and report that candidate instead of omitting it, guessing, falling back, or calling the result quota-informed. +6. When every candidate is tight, preserve the captain's strongest-reasoning class rather than silently downgrading it solely to conserve quota; stop and report the tight choice if that class cannot proceed. +7. Genuine ties must remain free of array-order or harness bias. + +## Collect inspectable facts for every candidate + +For each candidate profile: + +1. Establish the harness/model/provider relationship from current authoritative discovery owned by `harness-adapters`. + Fail loudly on an unresolved relationship. +2. Run `quota-axi --json` once per intake and reuse that snapshot for every candidate. +3. Require a current provider report with known quota semantics and a known applicable effective-availability record for that candidate's provider and model scope. + Stale raw windows remain diagnostic evidence only and are never current headroom. +4. Read every bounding window relevant to that candidate, including windows named by `boundedBy`, `limitingWindowIds`, `aheadWindowIds`, `behindWindowIds`, `onPaceWindowIds`, and `unknownWindowIds` on the effective record. +5. Record these inspectable facts, never a hidden score: + - task/profile fit + - reasoning class required by the captain request or task ambiguity + - raw applicable headroom (`effectivePercentRemaining` or the tightest applicable remaining percentage) + - effective pace status when present + - signed reserve for each applicable window and the effective worst reserve when present + - whether any applicable window or effective summary is ahead of reset + - whether any applicable pace is `unknown` + - schema compatibility note when pace fields are absent + +## Pace signals + +quota-axi `schemaVersion` 3 window pace uses: + +- `reservePercentPoints = percentRemaining - timeRemainingPercent` +- Negative reserve means usage is ahead of reset pace and creates conservation pressure. +- Positive reserve means usage is behind reset pace. +- `on_pace` is neutral. + +Effective-availability pace summaries may report `ahead`, `behind`, `on_pace`, `mixed`, or `unknown`. + +Treat conservation pressure as present when: + +- effective pace status is `ahead`, or +- effective pace status is `mixed` and any `aheadWindowIds` remain, or +- any applicable bounding window itself has pace status `ahead`. + +An effective `mixed` result is never healthy merely because one window is behind. +Any remaining `aheadWindowIds` keep conservation pressure. + +Signed reserve comparison uses the worst applicable reserve, preferring the producer field `worstReservePercentPoints` when present and otherwise the minimum signed reserve across applicable bounding windows. + +## Selection procedure + +Apply these steps only among candidates that already satisfy required task/profile fit and the strongest reasoning class the request genuinely needs. +Never use pace or raw headroom to silently replace that reasoning class with a weaker one. + +1. **Unresolved relationship or quota data** + Stop and report the blocked candidate. +2. **Strongest-reasoning / all-tight** + If every remaining candidate is tight, keep the strongest-reasoning class and either dispatch inside that class or stop and report that the tight choice cannot proceed. + Do not conserve quota through an unapproved downgrade. +3. **Conservation pressure vs sustainable pace** + When fit and reasoning class are comparable, prefer a candidate without ahead-of-reset conservation pressure over one with conservation pressure, even when the pressured candidate has somewhat higher raw remaining percentage. +4. **Among pressured candidates** + Prefer the least-negative worst applicable reserve. + Example: worst reserve `-4` is safer than `-18` when other inspectable facts are comparable. +5. **Among sustainable candidates** + Use known behind/on-pace evidence plus raw headroom transparently. + Do not collapse those facts into an opaque composite score. + Prefer known sustainable evidence over `unknown` pace when otherwise comparable. + Between known sustainable candidates, prefer the clearly better inspectable pair of pace reserve and raw headroom; state both facts in the choice rationale. +6. **Unknown pace** + `unknown` is valid explicit uncertainty from quota-axi, not a parser failure and not permission to assume the window is healthy or exhausted. + Inspect `unknownWindowIds` and each window's pace `reason` so the rationale preserves the producer's stated uncertainty. + Prefer known sustainable evidence when otherwise comparable. + If the dispatch choice materially hinges on unresolved pace, report the uncertainty rather than inventing a conclusion. +7. **Absent pace / older schema** + `schemaVersion` 2 payloads or missing pace fields must degrade explicitly and safely. + Do not crash, fabricate pace, or silently reinterpret absence as healthy/`on_pace`. + Compare raw applicable headroom only, using known effective availability rather than stale or isolated window percentages, state that pace is unavailable, and keep every other safety rule above. +8. **Genuine ties** + If every inspectable selection fact is equal, stop and report every tied candidate for captain choice. + Do not select by array order, harness name, or another arbitrary identity ordering. + Report duplicate concrete profiles as a configuration error. + +The intake rationale must name the inspectable facts used for every candidate. +Never conclude with an unexplained "best quota" label. + +## Acceptance scenarios + +These scenarios are normative examples of the procedure above. + +### Higher raw quota but materially ahead vs lower raw quota on/behind pace + +Candidate A has higher `effectivePercentRemaining` but conservation pressure from an ahead bounding window. +Candidate B has lower raw headroom, no conservation pressure, and known behind or on-pace evidence. +Choose B when fit and reasoning class are comparable. + +### Mixed effective pace with an ahead bound + +Effective pace status is `mixed` and `aheadWindowIds` is non-empty. +Treat the candidate as conservation-pressured even if another window is behind or on pace. + +### Both candidates ahead with different worst reserves + +Both candidates have conservation pressure. +Choose the least-negative worst applicable reserve when fit and reasoning class are comparable. + +### Known sustainable versus unknown + +Candidate A has known behind or on-pace evidence. +Candidate B has comparable fit, reasoning class, and raw headroom but `unknown` pace. +Prefer A. +If the only way to prefer one side depends on unresolved pace and no known sustainable candidate remains, report the uncertainty. + +### Every candidate tight while strongest-reasoning applies + +All candidates are tight on real headroom. +Keep the strongest reasoning class required by the request. +Do not pick a weaker class only to save quota. +Dispatch inside that class or stop and report that the tight strongest-class choice cannot proceed. + +### Genuine tie without array-order or harness bias + +Two candidates match on fit, reasoning class, conservation pressure, worst reserve, pace class, raw headroom, and unknown flags. +Choosing either array order or a standing harness preference is forbidden. +Stop and report both tied candidates for captain choice. + +### schemaVersion 2 or absent-pace compatibility + +Older quota-axi output or missing pace fields still allow array resolution. +Compare raw headroom only, state that pace is unavailable, and do not invent ahead/behind/on_pace. + +## Sanitized producer shape + +Validate consumers against a sanitized `schemaVersion` 3 shape derived from quota-axi 0.1.15: + +- top level: `schemaVersion`, `generatedAt`, `providers[]` +- each provider: `provider`, `state`, `windows[]`, and optional `quotaSemantics` with `status` and `effectiveAvailability[]` +- each window: `id`, `label`, `kind`, and optional `percentRemaining` and `pace`; pace has `status` plus optional `reason`, `timeRemainingPercent`, and `reservePercentPoints` +- each effective-availability entry: `scope`, `status`, `boundedBy`, optional `effectivePercentRemaining`, optional `limitingWindowIds`, and optional pace summary +- each effective pace summary: `status` plus optional `aheadWindowIds`, `behindWindowIds`, `onPaceWindowIds`, `unknownWindowIds`, `worstReservePercentPoints`, and `worstReserveWindowId` + +Never persist live provider balances, reset timestamps, account identifiers, or other private account details in tracked fixtures. diff --git a/AGENTS.md b/AGENTS.md index d85e90b8ea..f838dfb27c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -164,12 +164,13 @@ If static `config/crew-harness` or `config/secondmate-harness` names an unverifi `docs/configuration.md` owns dispatch-profile and runtime-backend schemas, `bin/fm-harness.sh` owns static resolution, and `bin/fm-spawn.sh` owns launch flags and fail-closed validation. When dispatch profiles exist, consult them at every crewmate or scout intake and pass the resolved concrete profile required by `fm-spawn`. Routing precedence is an explicit per-task captain override, then the best-fit configured rule, then the configured default, then the static crewmate harness. -Firstmate alone resolves a matched profile array: run `quota-axi --json` at that intake, evaluate every configured candidate against that current output, and choose the candidate with the most real headroom. +Firstmate alone resolves a matched profile array: run `quota-axi --json` at that intake, evaluate every configured candidate against that current output, and choose with inspectable real headroom including quota-window pace. Account for every candidate; if any harness/model/provider relationship, applicable quota data, or interpretation cannot be established, stop and report that candidate instead of omitting it, guessing, falling back, or calling the result quota-informed. Preserve malformed profile configuration as an actionable error rather than selecting around it. When every candidate is tight, preserve the captain's strongest-reasoning class rather than silently downgrading it solely to conserve quota; stop and report the tight choice if that class cannot proceed. Break genuine headroom ties without array-order or harness bias. -`quota-axi` owns how model or product windows relate to bounding account windows. +`quota-axi` owns how model or product windows relate to bounding account windows and remains data-only. +Load `quota-array-dispatch` before choosing among a matched profile array; that skill is the single owner of the pace-aware selection procedure. The generic effort fallback and its precedence are owned by `harness-adapters`: explicit captain and standing configured effort win; otherwise use low for well-understood explicit work, xhigh for ambiguous investigation or design, intermediate levels proportionally, and never max without explicit captain preference. Do not add model-specific versions of that policy. @@ -472,6 +473,7 @@ These skills are not captain-invocable; load them only at their precise triggers - `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap section prints an actionable diagnostic line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `TANGLE:`, `CREW_DISPATCH: invalid`, `FLEET_SYNC:`, `PR_CHECK_MIGRATION:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `NUDGE_SECONDMATES:`, or `FMX:`); silence and `BOOTSTRAP_INFO:` need no load. - `diagnostic-reasoning` - load before scoping a reported bug and before acting on a diagnostic report. - `ask-user-authority` - load before deciding any ask-user finding, regardless of the project's `yolo` posture. +- `quota-array-dispatch` - load before choosing among a matched crew-dispatch profile array from current quota-axi output. - `harness-adapters` - load before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. - `firstmate-orca` - load before switching to Orca, spawning or supervising Orca-backed work, smoke-testing Orca backend behavior, debugging Orca task state, or reconciling Orca-backed task metadata. - `project-management` - load before adding, creating, removing, or initializing a project. diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 8685b2e2bb..c86b7e839a 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -53,7 +53,8 @@ # with update --archive-body and mv [...]); an installed but # incompatible build reports MISSING like no-mistakes. A compatible # tasks-axi default backend is silent. quota-axi is required for the -# agent-owned dispatch-profile array procedure in AGENTS.md section 4. +# agent-owned dispatch-profile array procedure in AGENTS.md section 4 +# and .agents/skills/quota-array-dispatch/SKILL.md. # X mode is OPTIONAL and inert unless FM_HOME/.env has a non-empty # FMX_PAIRING_TOKEN. When opted in, bootstrap requires curl+jq, writes # the relay poll shim and 30s cadence config, and prints an FMX line. diff --git a/docs/architecture.md b/docs/architecture.md index 4840ef77a0..da1519a0d4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -142,7 +142,7 @@ The intake and authority contract in `AGENTS.md` owns when separate scout resear ## Dispatch profiles Crewmate and scout dispatch can stay on the static crewmate harness resolved by `config/crew-harness`, or it can use local dispatch profiles in `config/crew-dispatch.json`. -The dispatch file is intentionally judgment-based: firstmate reads the natural-language rules at intake, chooses the best matching rule, resolves profile arrays itself from current quota output under `AGENTS.md` section 4, and passes only concrete `--harness`, `--model`, and `--effort` axes to `fm-spawn.sh`. +The dispatch file is intentionally judgment-based: firstmate reads the natural-language rules at intake, chooses the best matching rule, resolves profile arrays itself from current quota output under the `AGENTS.md` section 4 intake boundary and the `quota-array-dispatch` selection procedure, and passes only concrete `--harness`, `--model`, and `--effort` axes to `fm-spawn.sh`. The shell scripts validate the JSON shape and verified harness/effort combinations, but they do not parse task intent, match natural-language rules, or own array selection. The session-start bootstrap step keeps valid dispatch configuration silent unless verbose facts are enabled and surfaces a concise invalid-config line when validation fails. When the file exists, `fm-spawn.sh` refuses crewmate and scout launches without an explicit harness, so `config/crew-harness` is only automatic when no dispatch profile file is active. diff --git a/docs/configuration.md b/docs/configuration.md index fed683343e..7f23866c33 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -207,11 +207,12 @@ For Pi and pi-signed secondmate launches, `fm-spawn.sh` starts the selected exec ## Crew dispatch profiles (config/crew-dispatch.json) `config/crew-dispatch.json` is an optional local, gitignored file containing natural-language rules that firstmate reads before dispatching a crewmate or scout. -The shell scripts do not match those rules; firstmate chooses the best matching rule with judgment, resolves its profile object or array under the operating contract in `AGENTS.md` section 4, and passes only concrete `--harness`, `--model`, and `--effort` flags to `fm-spawn.sh`. +The shell scripts do not match those rules; firstmate chooses the best matching rule with judgment, resolves its profile object or array under the operating contract in `AGENTS.md` section 4 and `quota-array-dispatch`, and passes only concrete `--harness`, `--model`, and `--effort` flags to `fm-spawn.sh`. When the file exists, `fm-spawn.sh` enforces that contract by refusing crewmate and scout spawns that lack an explicit harness (`--harness`, a positional adapter, or a raw launch command). Batch spawns satisfy the same requirement with a shared `--harness`. Secondmate spawns are exempt and still resolve through `config/secondmate-harness` and its optional model and effort tokens. -This section is the single owner of the canonical schema and its per-field semantics; `AGENTS.md` section 4 owns the dispatch and array-selection procedure. +This section is the single owner of the canonical schema and its per-field semantics. +`AGENTS.md` section 4 owns the always-loaded dispatch intake boundary, and `quota-array-dispatch` owns the pace-aware profile-array selection procedure. ```json { @@ -235,7 +236,7 @@ Both `use` and the optional top-level `default` accept either one profile object The single-object form stays fully backward-compatible, and every profile needs `harness`. Profile `model` and `effort` fields and rule `why` are optional. An omitted model or effort means the selected harness uses its own default for that axis. -Every profile array is an implicit quota-aware choice. +Every profile array is an implicit quota-aware choice resolved through `quota-array-dispatch`. If no dispatch rule fits, firstmate resolves `default` through the same object-or-array path before falling back to `config/crew-harness`. If a selected profile carries an effort value the chosen harness does not accept, `fm-spawn.sh` records the requested `effort=` in task meta for traceability but omits the launch flag, and bootstrap reports the invalid harness/effort pair as a `CREW_DISPATCH` diagnostic when it is visible in the file. See [`docs/examples/crew-dispatch.json`](examples/crew-dispatch.json) for a starting point to copy into local `config/crew-dispatch.json`. diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index 60773b0d45..54b2190f6c 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -155,6 +155,10 @@ "path": ".agents/skills/project-management/SKILL.md", "audience": "agent-runtime" }, + { + "path": ".agents/skills/quota-array-dispatch/SKILL.md", + "audience": "agent-runtime" + }, { "path": ".agents/skills/secondmate-provisioning/SKILL.md", "audience": "agent-runtime" diff --git a/docs/examples/crew-dispatch.json b/docs/examples/crew-dispatch.json index 4c8fc36993..23a5391d20 100644 --- a/docs/examples/crew-dispatch.json +++ b/docs/examples/crew-dispatch.json @@ -16,7 +16,7 @@ { "harness": "claude", "model": "claude-sonnet-5", "effort": "high" }, { "harness": "codex", "model": "gpt-5.5", "effort": "high" } ], - "why": "Firstmate compares every candidate with current relevant quota before dispatch, so use a strong coding profile." + "why": "Firstmate compares every candidate with current relevant quota and pace before dispatch, so use a strong coding profile." } ], "default": [ diff --git a/tests/fixtures/quota-array-dispatch/cases.json b/tests/fixtures/quota-array-dispatch/cases.json new file mode 100644 index 0000000000..23d097be46 --- /dev/null +++ b/tests/fixtures/quota-array-dispatch/cases.json @@ -0,0 +1,352 @@ +{ + "cases": [ + { + "id": "higher-raw-ahead-vs-lower-raw-sustainable", + "expect": "B", + "reason": "prefer sustainable pace over higher raw headroom with conservation pressure", + "candidates": [ + { + "id": "A", + "harness": "claude", + "model": "strong-a", + "effort": "high", + "fit": "comparable", + "reasoningClass": "strong", + "tight": false, + "rawHeadroom": 80, + "paceStatus": "ahead", + "aheadWindowIds": ["weekly"], + "worstReserve": -12.0, + "unknownPace": false, + "paceAvailable": true + }, + { + "id": "B", + "harness": "codex", + "model": "strong-b", + "effort": "high", + "fit": "comparable", + "reasoningClass": "strong", + "tight": false, + "rawHeadroom": 55, + "paceStatus": "behind", + "aheadWindowIds": [], + "worstReserve": 18.0, + "unknownPace": false, + "paceAvailable": true + } + ] + }, + { + "id": "mixed-effective-with-ahead-bound", + "expect": "B", + "reason": "mixed with aheadWindowIds is conservation pressure", + "candidates": [ + { + "id": "A", + "harness": "claude", + "model": "mixed-a", + "effort": "high", + "fit": "comparable", + "reasoningClass": "strong", + "tight": false, + "rawHeadroom": 75, + "paceStatus": "mixed", + "aheadWindowIds": ["seven_day"], + "worstReserve": -8.0, + "unknownPace": false, + "paceAvailable": true + }, + { + "id": "B", + "harness": "codex", + "model": "steady-b", + "effort": "high", + "fit": "comparable", + "reasoningClass": "strong", + "tight": false, + "rawHeadroom": 60, + "paceStatus": "on_pace", + "aheadWindowIds": [], + "worstReserve": 0.0, + "unknownPace": false, + "paceAvailable": true + } + ] + }, + { + "id": "both-ahead-least-negative-reserve", + "expect": "B", + "reason": "among pressured candidates prefer least-negative worst reserve", + "candidates": [ + { + "id": "A", + "harness": "claude", + "model": "pressured-a", + "effort": "high", + "fit": "comparable", + "reasoningClass": "strong", + "tight": false, + "rawHeadroom": 50, + "paceStatus": "ahead", + "aheadWindowIds": ["weekly"], + "worstReserve": -22.0, + "unknownPace": false, + "paceAvailable": true + }, + { + "id": "B", + "harness": "codex", + "model": "pressured-b", + "effort": "high", + "fit": "comparable", + "reasoningClass": "strong", + "tight": false, + "rawHeadroom": 48, + "paceStatus": "ahead", + "aheadWindowIds": ["weekly"], + "worstReserve": -5.0, + "unknownPace": false, + "paceAvailable": true + } + ] + }, + { + "id": "ahead-bounding-window-overrides-neutral-effective-summary", + "expect": "B", + "reason": "an ahead applicable bounding window creates conservation pressure even when the effective summary is neutral", + "candidates": [ + { + "id": "A", + "harness": "claude", + "model": "bounded-a", + "effort": "high", + "fit": "comparable", + "reasoningClass": "strong", + "tight": false, + "rawHeadroom": 72, + "paceStatus": "on_pace", + "aheadWindowIds": [], + "boundingWindows": [ + { + "id": "weekly", + "paceStatus": "ahead", + "reservePercentPoints": -9.0 + } + ], + "worstReserve": -9.0, + "unknownPace": false, + "paceAvailable": true + }, + { + "id": "B", + "harness": "codex", + "model": "steady-b", + "effort": "high", + "fit": "comparable", + "reasoningClass": "strong", + "tight": false, + "rawHeadroom": 58, + "paceStatus": "behind", + "aheadWindowIds": [], + "boundingWindows": [ + { + "id": "weekly", + "paceStatus": "behind", + "reservePercentPoints": 7.0 + } + ], + "worstReserve": 7.0, + "unknownPace": false, + "paceAvailable": true + } + ] + }, + { + "id": "known-sustainable-vs-unknown", + "expect": "A", + "reason": "prefer known sustainable evidence over unknown pace", + "candidates": [ + { + "id": "A", + "harness": "codex", + "model": "known-a", + "effort": "medium", + "fit": "comparable", + "reasoningClass": "standard", + "tight": false, + "rawHeadroom": 40, + "paceStatus": "behind", + "aheadWindowIds": [], + "worstReserve": 10.0, + "unknownPace": false, + "paceAvailable": true + }, + { + "id": "B", + "harness": "claude", + "model": "unknown-b", + "effort": "medium", + "fit": "comparable", + "reasoningClass": "standard", + "tight": false, + "rawHeadroom": 42, + "paceStatus": "unknown", + "aheadWindowIds": [], + "worstReserve": null, + "unknownPace": true, + "paceAvailable": true + } + ] + }, + { + "id": "all-tight-strongest-reasoning", + "expect": "A", + "reason": "preserve strongest-reasoning class when every candidate is tight", + "requiredReasoningClass": "strong", + "candidates": [ + { + "id": "A", + "harness": "claude", + "model": "strong-tight", + "effort": "high", + "fit": "comparable", + "reasoningClass": "strong", + "tight": true, + "rawHeadroom": 8, + "paceStatus": "behind", + "aheadWindowIds": [], + "worstReserve": 2.0, + "unknownPace": false, + "paceAvailable": true + }, + { + "id": "B", + "harness": "codex", + "model": "weaker-roomier", + "effort": "medium", + "fit": "comparable", + "reasoningClass": "standard", + "tight": true, + "rawHeadroom": 25, + "paceStatus": "behind", + "aheadWindowIds": [], + "worstReserve": 12.0, + "unknownPace": false, + "paceAvailable": true + } + ] + }, + { + "id": "genuine-tie-captain-choice", + "expectError": "genuine tie requires captain choice", + "reason": "report genuine ties instead of selecting by array order or harness identity", + "candidates": [ + { + "id": "A", + "harness": "claude", + "model": "same-model", + "effort": "high", + "fit": "comparable", + "reasoningClass": "strong", + "tight": false, + "rawHeadroom": 50, + "paceStatus": "on_pace", + "aheadWindowIds": [], + "worstReserve": 0.0, + "unknownPace": false, + "paceAvailable": true + }, + { + "id": "B", + "harness": "codex", + "model": "same-model", + "effort": "high", + "fit": "comparable", + "reasoningClass": "strong", + "tight": false, + "rawHeadroom": 50, + "paceStatus": "on_pace", + "aheadWindowIds": [], + "worstReserve": 0.0, + "unknownPace": false, + "paceAvailable": true + } + ] + }, + { + "id": "genuine-tie-reversed-array-order", + "expectError": "genuine tie requires captain choice", + "reason": "reversing a genuine tie must still require captain choice", + "candidates": [ + { + "id": "B", + "harness": "codex", + "model": "same-model", + "effort": "high", + "fit": "comparable", + "reasoningClass": "strong", + "tight": false, + "rawHeadroom": 50, + "paceStatus": "on_pace", + "aheadWindowIds": [], + "worstReserve": 0.0, + "unknownPace": false, + "paceAvailable": true + }, + { + "id": "A", + "harness": "claude", + "model": "same-model", + "effort": "high", + "fit": "comparable", + "reasoningClass": "strong", + "tight": false, + "rawHeadroom": 50, + "paceStatus": "on_pace", + "aheadWindowIds": [], + "worstReserve": 0.0, + "unknownPace": false, + "paceAvailable": true + } + ] + }, + { + "id": "schema-v2-absent-pace", + "expect": "A", + "reason": "absent pace degrades to raw headroom without fabricating pace health", + "candidates": [ + { + "id": "A", + "harness": "codex", + "model": "legacy-a", + "effort": "medium", + "fit": "comparable", + "reasoningClass": "standard", + "tight": false, + "rawHeadroom": 70, + "paceStatus": null, + "aheadWindowIds": [], + "worstReserve": null, + "unknownPace": false, + "paceAvailable": false + }, + { + "id": "B", + "harness": "claude", + "model": "legacy-b", + "effort": "medium", + "fit": "comparable", + "reasoningClass": "standard", + "tight": false, + "rawHeadroom": 40, + "paceStatus": null, + "aheadWindowIds": [], + "worstReserve": null, + "unknownPace": false, + "paceAvailable": false + } + ] + } + ] +} diff --git a/tests/fixtures/quota-array-dispatch/schema-v3-shape.json b/tests/fixtures/quota-array-dispatch/schema-v3-shape.json new file mode 100644 index 0000000000..a79f86aca3 --- /dev/null +++ b/tests/fixtures/quota-array-dispatch/schema-v3-shape.json @@ -0,0 +1,103 @@ +{ + "schemaVersion": 3, + "generatedAt": "1970-01-01T00:00:00.000Z", + "providers": [ + { + "provider": "claude", + "label": "Claude", + "source": "test", + "plan": "test", + "windows": [ + { + "id": "five_hour", + "label": "session", + "kind": "session", + "percentUsed": 20, + "percentRemaining": 80, + "windowSeconds": 18000, + "pace": { + "status": "behind", + "timeRemainingPercent": 40.0, + "elapsedPercent": 60.0, + "reservePercentPoints": 40.0 + } + }, + { + "id": "seven_day", + "label": "week", + "kind": "weekly", + "percentUsed": 55, + "percentRemaining": 45, + "windowSeconds": 604800, + "pace": { + "status": "ahead", + "timeRemainingPercent": 60.0, + "elapsedPercent": 40.0, + "reservePercentPoints": -15.0 + } + } + ], + "quotaSemantics": { + "status": "known", + "description": "sanitized representative schemaVersion 3 shape", + "effectiveAvailability": [ + { + "scope": "all_models", + "status": "known", + "effectivePercentRemaining": 45, + "boundedBy": ["five_hour", "seven_day"], + "limitingWindowIds": ["seven_day"], + "pace": { + "status": "mixed", + "aheadWindowIds": ["seven_day"], + "behindWindowIds": ["five_hour"], + "worstReservePercentPoints": -15.0, + "worstReserveWindowId": "seven_day" + } + } + ] + } + }, + { + "provider": "codex", + "label": "Codex", + "source": "test", + "plan": "test", + "windows": [ + { + "id": "weekly", + "label": "week", + "kind": "weekly", + "percentUsed": 30, + "percentRemaining": 70, + "windowSeconds": 604800, + "pace": { + "status": "behind", + "timeRemainingPercent": 50.0, + "elapsedPercent": 50.0, + "reservePercentPoints": 20.0 + } + } + ], + "quotaSemantics": { + "status": "known", + "description": "sanitized representative schemaVersion 3 shape", + "effectiveAvailability": [ + { + "scope": "all_models", + "status": "known", + "effectivePercentRemaining": 70, + "boundedBy": ["weekly"], + "limitingWindowIds": ["weekly"], + "pace": { + "status": "behind", + "behindWindowIds": ["weekly"], + "worstReservePercentPoints": 20.0, + "worstReserveWindowId": "weekly" + } + } + ] + } + } + ] +} diff --git a/tests/fm-instruction-owners.test.sh b/tests/fm-instruction-owners.test.sh index f55f3e905f..754e00ddc8 100755 --- a/tests/fm-instruction-owners.test.sh +++ b/tests/fm-instruction-owners.test.sh @@ -109,13 +109,15 @@ test_agent_owned_quota_array_dispatch_contract() { 'Firstmate alone resolves a matched profile array' \ 'run `quota-axi --json` at that intake' \ 'evaluate every configured candidate against that current output' \ - 'choose the candidate with the most real headroom' \ + 'inspectable real headroom including quota-window pace' \ 'if any harness/model/provider relationship, applicable quota data, or interpretation cannot be established, stop and report that candidate' \ 'instead of omitting it, guessing, falling back, or calling the result quota-informed' \ 'Preserve malformed profile configuration as an actionable error' \ "preserve the captain's strongest-reasoning class rather than silently downgrading it" \ 'Break genuine headroom ties without array-order or harness bias' \ - '`quota-axi` owns how model or product windows relate to bounding account windows'; do + '`quota-axi` owns how model or product windows relate to bounding account windows' \ + 'remains data-only' \ + 'Load `quota-array-dispatch` before choosing among a matched profile array'; do assert_grep "$phrase" "$AGENTS" "array-dispatch contract lost '$phrase'" done @@ -131,12 +133,16 @@ test_agent_owned_quota_array_dispatch_contract() { done assert_grep 'not as a permanent namespace or provider mapping' "$HARNESS" \ "model discovery guidance permits a fixed provider table" - assert_grep '`AGENTS.md` section 4 owns the dispatch and array-selection procedure.' "$CONFIG" \ - "configuration docs do not point to the agent-owned array procedure" + assert_grep 'load `quota-array-dispatch` for the pace-aware candidate choice' "$HARNESS" \ + "harness-adapters lost the quota-array-dispatch handoff" + assert_grep '`quota-array-dispatch` owns the pace-aware profile-array selection procedure' "$CONFIG" \ + "configuration docs do not point to quota-array-dispatch" assert_grep 'quota-axi is required for the' "$BOOTSTRAP" \ "bootstrap docs lost the quota-axi dependency pointer" - assert_grep 'agent-owned dispatch-profile array procedure in AGENTS.md section 4.' "$BOOTSTRAP" \ + assert_grep 'agent-owned dispatch-profile array procedure in AGENTS.md section 4' "$BOOTSTRAP" \ "bootstrap docs do not point to the agent-owned array procedure" + assert_grep 'quota-array-dispatch/SKILL.md' "$BOOTSTRAP" \ + "bootstrap docs do not point to quota-array-dispatch" pass "firstmate directly compares every quota candidate with authoritative model discovery" } diff --git a/tests/fm-quota-array-dispatch.test.sh b/tests/fm-quota-array-dispatch.test.sh new file mode 100755 index 0000000000..a958e56c30 --- /dev/null +++ b/tests/fm-quota-array-dispatch.test.sh @@ -0,0 +1,278 @@ +#!/usr/bin/env bash +# Contract and deterministic fixture tests for quota-array-dispatch. +# +# The skill owns the agent-facing decision procedure. +# This test encodes the same inspectable comparison rules against sanitized +# fixtures so acceptance cases stay deterministic without introducing a +# production routing wrapper. +# shellcheck disable=SC2016 +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +AGENTS="$ROOT/AGENTS.md" +OWNER="$ROOT/.agents/skills/quota-array-dispatch/SKILL.md" +HARNESS="$ROOT/.agents/skills/harness-adapters/SKILL.md" +CONFIG="$ROOT/docs/configuration.md" +ARCHITECTURE="$ROOT/docs/architecture.md" +BOOTSTRAP="$ROOT/bin/fm-bootstrap.sh" +AUDIENCES="$ROOT/docs/documentation-audiences.json" +CASES="$ROOT/tests/fixtures/quota-array-dispatch/cases.json" +SHAPE="$ROOT/tests/fixtures/quota-array-dispatch/schema-v3-shape.json" + +intake_boundary() { + awk ' + /^## 4\. Harness and runtime dispatch$/ { found = 1; next } + found && /^## 5\. Recovery$/ { exit } + found { print } + ' "$AGENTS" +} + +select_candidate_py() { + python3 - "$@" <<'PY' +import json, sys + +def conservation_pressure(c): + if not c.get("paceAvailable", True): + return False + status = c.get("paceStatus") + ahead_ids = c.get("aheadWindowIds") or [] + bounding_windows = c.get("boundingWindows") or [] + if status == "ahead": + return True + if status == "mixed" and ahead_ids: + return True + if any(window.get("paceStatus") == "ahead" for window in bounding_windows): + return True + return False + +def select(case): + required = case.get("requiredReasoningClass") + cands = list(case["candidates"]) + if required: + matching = [c for c in cands if c.get("reasoningClass") == required] + if not matching: + return {"error": "required reasoning class unavailable"} + # Strongest-reasoning rule: never drop to a weaker class for quota. + cands = matching + + # Fit filter: fixtures mark comparable; keep only comparable for these cases. + cands = [c for c in cands if c.get("fit") == "comparable"] + if not cands: + return {"error": "no comparable candidates"} + + def sort_key(c): + pressured = conservation_pressure(c) + unknown = bool(c.get("unknownPace")) or c.get("paceStatus") == "unknown" + pace_available = bool(c.get("paceAvailable", True)) + reserve = c.get("worstReserve") + if reserve is None: + reserve_key = float("-inf") + else: + reserve_key = float(reserve) + raw = float(c.get("rawHeadroom") or 0) + # Sort ascending by preference rank components that python min understands + # via a tuple where lower is better only for pressure/unknown flags. + return ( + 1 if pressured else 0, + 1 if (unknown and pace_available) else 0, + 0 if pace_available else 1, # when pace absent, still comparable via raw only + # Among pressured: least-negative reserve => higher reserve first => negate + (-reserve_key if pressured else 0), + # Among sustainable with pace: prefer higher reserve then higher raw + (-reserve_key if (not pressured and pace_available and not unknown) else 0), + -raw, + ) + + # Special-case all-tight already constrained to required class above. + best_key = min(sort_key(c) for c in cands) + winners = [c for c in cands if sort_key(c) == best_key] + if len(winners) > 1: + return { + "error": "genuine tie requires captain choice", + "candidates": sorted(c["id"] for c in winners), + } + winner = winners[0] + return { + "id": winner["id"], + "pressured": conservation_pressure(winner), + } + +case = json.loads(sys.argv[1]) +print(json.dumps(select(case))) +PY +} + +test_owner_and_always_loaded_boundary() { + local boundary trigger_count + boundary=$(intake_boundary) + + assert_present "$OWNER" "quota-array-dispatch owner is missing" + assert_grep 'name: quota-array-dispatch' "$OWNER" "quota-array-dispatch skill has the wrong name" + assert_grep 'user-invocable: false' "$OWNER" "quota-array-dispatch skill must be agent-only" + assert_grep 'single owner of the pace-aware profile-array selection procedure' "$OWNER" \ + "quota-array-dispatch skill does not declare ownership" + + assert_contains "$boundary" 'Firstmate alone resolves a matched profile array' \ + "intake boundary lost agent-owned array resolution" + assert_contains "$boundary" 'run `quota-axi --json` at that intake' \ + "intake boundary lost quota-axi intake read" + assert_contains "$boundary" 'evaluate every configured candidate against that current output' \ + "intake boundary lost full-candidate accounting" + assert_contains "$boundary" 'inspectable real headroom including quota-window pace' \ + "intake boundary lost pace-aware headroom wording" + assert_contains "$boundary" 'if any harness/model/provider relationship, applicable quota data, or interpretation cannot be established, stop and report that candidate' \ + "intake boundary lost unresolved-candidate refusal" + assert_contains "$boundary" 'instead of omitting it, guessing, falling back, or calling the result quota-informed' \ + "intake boundary lost no-guess wording" + assert_contains "$boundary" 'Preserve malformed profile configuration as an actionable error' \ + "intake boundary lost malformed-config refusal" + assert_contains "$boundary" "preserve the captain's strongest-reasoning class rather than silently downgrading it" \ + "intake boundary lost strongest-reasoning rule" + assert_contains "$boundary" 'Break genuine headroom ties without array-order or harness bias' \ + "intake boundary lost genuine-tie rule" + assert_contains "$boundary" '`quota-axi` owns how model or product windows relate to bounding account windows' \ + "intake boundary lost quota-axi window ownership" + assert_contains "$boundary" 'remains data-only' \ + "intake boundary lost data-only producer boundary" + assert_contains "$boundary" 'Load `quota-array-dispatch` before choosing among a matched profile array' \ + "intake boundary lost quota-array-dispatch load trigger" + + trigger_count=$(grep -Fc -- '- `quota-array-dispatch` -' "$AGENTS") + [ "$trigger_count" -eq 1 ] || fail "quota-array-dispatch must have exactly one section 13 trigger, found $trigger_count" + + # Full pace procedure stays out of AGENTS.md. + if printf '%s\n' "$boundary" | grep -q 'reservePercentPoints'; then + fail "AGENTS.md intake boundary duplicated pace formula detail" + fi + if printf '%s\n' "$boundary" | grep -q 'aheadWindowIds'; then + fail "AGENTS.md intake boundary duplicated aheadWindowIds detail" + fi + + pass "quota-array-dispatch has one conditional owner and a concise always-loaded boundary" +} + +test_owner_contains_acceptance_procedure() { + local phrase + for phrase in \ + 'reservePercentPoints = percentRemaining - timeRemainingPercent' \ + 'Negative reserve means usage is ahead of reset pace and creates conservation pressure' \ + 'Positive reserve means usage is behind reset pace' \ + '`on_pace` is neutral' \ + 'effective pace status is `mixed` and any `aheadWindowIds` remain' \ + 'prefer a candidate without ahead-of-reset conservation pressure over one with conservation pressure' \ + 'even when the pressured candidate has somewhat higher raw remaining percentage' \ + 'Prefer the least-negative worst applicable reserve' \ + 'Use known behind/on-pace evidence plus raw headroom transparently' \ + 'Do not collapse those facts into an opaque composite score' \ + '`unknown` is valid explicit uncertainty from quota-axi' \ + 'Prefer known sustainable evidence over `unknown` pace when otherwise comparable' \ + 'If the dispatch choice materially hinges on unresolved pace, report the uncertainty' \ + 'Do not crash, fabricate pace, or silently reinterpret absence as healthy' \ + 'stop and report every tied candidate for captain choice' \ + 'Do not select by array order, harness name, or another arbitrary identity ordering' \ + 'Do not add a daemon, opaque composite score, routing wrapper, hard-coded model-specific policy'; do + assert_grep "$phrase" "$OWNER" "quota-array-dispatch procedure lost '$phrase'" + done + + for phrase in \ + 'Higher raw quota but materially ahead vs lower raw quota on/behind pace' \ + 'Mixed effective pace with an ahead bound' \ + 'Both candidates ahead with different worst reserves' \ + 'Known sustainable versus unknown' \ + 'Every candidate tight while strongest-reasoning applies' \ + 'Genuine tie without array-order or harness bias' \ + 'schemaVersion 2 or absent-pace compatibility'; do + assert_grep "$phrase" "$OWNER" "acceptance scenario missing: $phrase" + done + pass "quota-array-dispatch owns the full pace procedure and acceptance scenarios" +} + +test_cross_references_stay_pointers() { + assert_grep '`quota-array-dispatch` owns the pace-aware profile-array selection procedure' "$CONFIG" \ + "configuration docs do not point to quota-array-dispatch" + assert_no_grep '`AGENTS.md` section 4 owns the dispatch and array-selection procedure.' "$CONFIG" \ + "configuration docs still claim AGENTS.md owns the full array-selection procedure" + assert_grep 'quota-array-dispatch' "$ARCHITECTURE" \ + "architecture docs lost the quota-array-dispatch pointer" + assert_grep 'quota-array-dispatch' "$BOOTSTRAP" \ + "bootstrap header lost the quota-array-dispatch pointer" + assert_grep 'load `quota-array-dispatch` for the pace-aware candidate choice' "$HARNESS" \ + "harness-adapters lost the array-selection handoff" + assert_grep '.agents/skills/quota-array-dispatch/SKILL.md' "$AUDIENCES" \ + "documentation audience inventory missing quota-array-dispatch" + pass "cross-references point at the single procedure owner" +} + +test_schema_v3_shape_fixture() { + python3 - "$SHAPE" <<'PY' || fail "schema v3 shape fixture is invalid" +import json, sys +path = sys.argv[1] +data = json.load(open(path)) +assert data.get("schemaVersion") == 3, data.get("schemaVersion") +assert isinstance(data.get("providers"), list) and data["providers"], "providers" +provider = data["providers"][0] +assert "windows" in provider and provider["windows"], "windows" +window = provider["windows"][0] +assert "pace" in window and "status" in window["pace"], window +eff = provider["quotaSemantics"]["effectiveAvailability"][0] +assert "pace" in eff and "status" in eff["pace"], eff +assert "effectivePercentRemaining" in eff +# Privacy: no live account residue markers. +blob = json.dumps(data) +for bad in ("sk-", "@", "Bearer ", "accountId", "organizationId"): + assert bad not in blob, bad +PY + pass "sanitized schemaVersion 3 fixture preserves producer pace shape without private details" +} + +test_deterministic_acceptance_cases() { + local raw case_json case_id expect expect_error got reason + raw=$(cat "$CASES") + while IFS= read -r case_json; do + case_id=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["id"])' "$case_json") + expect=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1]).get("expect", ""))' "$case_json") + expect_error=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1]).get("expectError", ""))' "$case_json") + reason=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["reason"])' "$case_json") + got=$(select_candidate_py "$case_json") + python3 -c ' +import json,sys +got=json.loads(sys.argv[1]) +expect=sys.argv[2] +expect_error=sys.argv[3] +case_id=sys.argv[4] +err=got.get("error") +if expect_error: + if err != expect_error: + raise SystemExit("%s: expected error %s, got %s" % (case_id, expect_error, got)) +elif err: + raise SystemExit("%s: selector error: %s" % (case_id, err)) +elif got.get("id") != expect: + raise SystemExit("%s: expected %s, got %s" % (case_id, expect, got)) +' "$got" "$expect" "$expect_error" "$case_id" \ + || fail "case $case_id failed ($reason); selector returned $got" + if [ -n "$expect_error" ]; then + pass "case $case_id -> $expect_error ($reason)" + else + pass "case $case_id -> $expect ($reason)" + fi + done < <(python3 -c 'import json,sys; data=json.load(sys.stdin); [print(json.dumps(c, separators=(",", ":"))) for c in data["cases"]]' <<<"$raw") +} + +test_no_duplicate_procedure_in_agents() { + # Guard against re-expanding the full procedure into AGENTS.md. + local count + count=$(grep -c 'conservation pressure' "$AGENTS" || true) + [ "$count" -eq 0 ] || fail "AGENTS.md should not restate conservation-pressure procedure detail" + count=$(grep -c 'worst applicable reserve' "$AGENTS" || true) + [ "$count" -eq 0 ] || fail "AGENTS.md should not restate worst-reserve procedure detail" + pass "AGENTS.md does not duplicate the pace procedure body" +} + +test_owner_and_always_loaded_boundary +test_owner_contains_acceptance_procedure +test_cross_references_stay_pointers +test_schema_v3_shape_fixture +test_deterministic_acceptance_cases +test_no_duplicate_procedure_in_agents From fbece9c394004b48dda7aa77c99be2465b17f2a6 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:39:46 -0700 Subject: [PATCH 04/35] fix: adapt Grok Stop continuation and harden endpoint cleanup (#1171) * fix(grok): adapt Stop continuation to runtime capability * no-mistakes(review): Reject ambiguous Grok Stop payloads * no-mistakes(review): Reject duplicate Grok fields and accept spaced tmux sessions * no-mistakes(review): Enforce exact tmux cleanup selectors * no-mistakes(test): Fix historical tmux fixture and validate Grok Stop * no-mistakes: apply CI fixes --- .agents/skills/harness-adapters/SKILL.md | 13 +- .claude/settings.json | 4 +- AGENTS.md | 2 +- bin/backends/tmux.sh | 18 +- bin/fm-backend.sh | 172 ++++++++++++++ bin/fm-spawn.sh | 1 + bin/fm-teardown.sh | 21 +- bin/fm-test-run.sh | 5 +- bin/fm-turnend-guard-grok.sh | 61 ++++- bin/fm-turnend-guard.sh | 24 +- docs/architecture.md | 2 +- docs/configuration.md | 5 +- docs/supervision-protocols/grok.md | 7 +- docs/turnend-guard.md | 21 +- docs/verification/runtime-backends.md | 28 +++ docs/verification/supervision.md | 22 +- tests/fm-backend-orca.test.sh | 73 +++--- tests/fm-backend-zellij.test.sh | 10 + tests/fm-backend.test.sh | 18 +- tests/fm-gate-refuse.test.sh | 3 +- tests/fm-grok-stop-live-e2e.test.sh | 219 +++++++++++++++++ tests/fm-pr-check-security.test.sh | 27 ++- tests/fm-secondmate-liveness.test.sh | 2 +- tests/fm-secondmate-safety.test.sh | 6 +- tests/fm-session-start.test.sh | 2 +- tests/fm-teardown-endpoint-safety.test.sh | 275 ++++++++++++++++++++++ tests/fm-teardown.test.sh | 10 +- tests/fm-turnend-guard.test.sh | 111 ++++++++- tests/lib.sh | 11 +- 29 files changed, 1049 insertions(+), 124 deletions(-) create mode 100755 tests/fm-grok-stop-live-e2e.test.sh create mode 100755 tests/fm-teardown-endpoint-safety.test.sh diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 9ea4112153..429907041a 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -54,7 +54,8 @@ Use that value for interrupt, exit, resume, and skill-invocation facts. The primary integrations for `claude`, `codex`, `opencode`, `pi`, `pi-signed`, and `grok` have empirically validated hook paths for the "no turn ends blind" guard. `claude` and `codex` block directly through Stop hooks that preserve exit status 2 and stderr from `bin/fm-turnend-guard.sh`. -`opencode`, `pi`, `pi-signed`, and `grok` expose passive lifecycle callbacks for this purpose, so their tracked primary adapters force one bounded follow-up or resume when the shared predicate blocks. +`opencode`, `pi`, and `pi-signed` expose passive lifecycle callbacks and force one bounded follow-up when the shared predicate blocks. +Grok selects native blocking or its pre-native bounded resume fallback from the exact running Stop payload; [`docs/turnend-guard.md`](../../../docs/turnend-guard.md) owns that contract. Kimi is outside the primary turn-end guard scope, while `docs/turnend-guard.md` owns its separate guarded global hook for crew wake signals. The exact hook files, commands, scoping rules, and fail-open tradeoffs are owned by `docs/turnend-guard.md`. `docs/verification/supervision.md` "Turn-end guard" owns active validation evidence. @@ -343,13 +344,13 @@ This keeps the hook outside the worktree, needs no trust grant, and writes only `fm-teardown` removes the worktree pointer before returning a pooled worktree. Secondmate spawns skip the pointer (idle panes are healthy, no stale-pane detection for them). -**Primary-session guard fact (verified 2026-07-08, Grok 0.2.91).** +**Primary-session guard fact (verified 2026-07-28, Grok 0.2.112 and 0.2.73).** The firstmate PRIMARY's own `.grok/hooks/fm-primary-turnend-guard.json` invokes `bin/fm-turnend-guard-grok.sh`. -Grok Stop hooks are passive for this purpose: exit 2 does not make the model continue. -The adapter therefore runs the shared predicate and, when it returns 2, forces one same-session follow-up with `grok --resume -p ` while setting `GROK_TURNEND_GUARD_ACTIVE=1` so the nested Stop hook does not recurse. -It does not pass `--permission-mode`, so the passive hook cannot escalate the primary session's tool permissions. +Grok 0.2.112 exposes native same-process Stop continuation in its running payload, while the genuine pre-native 0.2.73 payload omits that capability and still needs one guarded `grok --resume`. +The exact adaptive and malformed-input contract is owned by `docs/turnend-guard.md`. +The tracked Claude Stop hooks skip themselves under `GROK_AGENT`, because Grok also loads Claude-compatible project settings and otherwise creates a second blocking path. Project-local Grok hooks require folder trust, verified with launch-time `--trust`; if the primary firstmate checkout is not trusted for Grok hooks, this primary guard fails open and `fm-guard.sh` remains the next-command alarm. -Grok's primary watcher protocol is Claude-shaped background-notify around `bin/fm-watch-arm.sh`; the passive Stop hook is only a backstop for blind turn ends. +Grok's primary watcher protocol remains background-notify around `bin/fm-watch-arm.sh`; native Stop continuation does not provide Pi-like extension ownership. ## kimi (VERIFIED 2026-07-25, kimi 0.29.1) diff --git a/.claude/settings.json b/.claude/settings.json index e77613c98a..0be379c46b 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -40,11 +40,11 @@ "hooks": [ { "type": "command", - "command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-turnend-guard.sh --claude" + "command": "[ -z \"${GROK_AGENT:-}\" ] || exit 0; exec \"$CLAUDE_PROJECT_DIR\"/bin/fm-turnend-guard.sh --claude" }, { "type": "command", - "command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-claude-stop-autoarm.sh", + "command": "[ -z \"${GROK_AGENT:-}\" ] || exit 0; exec \"$CLAUDE_PROJECT_DIR\"/bin/fm-claude-stop-autoarm.sh", "asyncRewake": true, "timeout": 28800 } diff --git a/AGENTS.md b/AGENTS.md index f838dfb27c..4991118cb4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,7 +88,7 @@ state/ volatile runtime signals; gitignored .turn-ended touched by turn-end hooks .grok-turnend-token firstmate-owned grok hook registry token for the task; removed by teardown .kimi-turnend-token firstmate-owned Kimi hook registry token for the task; removed by teardown - .meta written by fm-spawn: window=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); fm-pr-check, including through fm-pr-merge, records one canonical pr= and the forge's pr_head= when available (GitHub pull requests and GitLab merge requests; docs/gitlab-merge-watch.md); fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14) + .meta written by fm-spawn: window=, endpoint_task_id=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); fm-pr-check, including through fm-pr-merge, records one canonical pr= and the forge's pr_head= when available (GitHub pull requests and GitLab merge requests; docs/gitlab-merge-watch.md); fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14) .herdr-presentation quarantinable attempt and restart-binding journal for Herdr's optional visual projection; never task or endpoint authority; see docs/herdr-backend.md "Optional presentation spaces" .check.sh authenticated slow poll; the watcher dispatches validated PR data and the byte-identified X shim through trusted repository scripts, runs registered custom checks from hash-validated private snapshots, and rejects every other state check without execution .check-trust private content binding created by fm-check-register.sh for an intentional custom check diff --git a/bin/backends/tmux.sh b/bin/backends/tmux.sh index fe0ed716a4..f8da21bf0d 100644 --- a/bin/backends/tmux.sh +++ b/bin/backends/tmux.sh @@ -117,10 +117,22 @@ fm_backend_tmux_send_literal() { # tmux send-keys -t "$1" -l "$2" } -# fm_backend_tmux_kill: remove the task's window, best-effort. Mirrors -# fm-teardown.sh's `tmux kill-window -t "$T" 2>/dev/null || true`. +# fm_backend_tmux_kill: remove one explicitly named task window, best-effort. +# Empty, omitted, and malformed targets return nonzero before invoking tmux so +# tmux can never interpret an empty target as the caller's current window. fm_backend_tmux_kill() { # - tmux kill-window -t "$1" 2>/dev/null || true + local target=${1:-} session window + case "$target" in + *:*) + session=${target%%:*} + window=${target#*:} + ;; + *) return 1 ;; + esac + case "$session:$window" in + :*|*:|*:*:*) return 1 ;; + esac + tmux kill-window -t "=$session:=$window" 2>/dev/null || true } # fm_backend_tmux_current_command: 's live foreground process name - diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index 2112a53279..e505b99f75 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -360,6 +360,177 @@ fm_backend_target_of_meta() { # [ -n "$window" ] && printf '%s' "$window" } +# fm_backend_validate_task_endpoint: validate a task cleanup record entirely +# from its durable metadata before any runtime command or cleanup mutation. +# The validation binds the exact task id, selected backend, target, project, +# and worktree. New non-tmux records carry endpoint_task_id because their +# opaque runtime ids do not encode the task label. Legacy tmux records remain +# valid only when their window name itself is exactly fm-. +# On success, sets FM_BACKEND_VALIDATED_BACKEND and +# FM_BACKEND_VALIDATED_TARGET. On failure, prints one refusal and returns 1. +fm_backend_meta_exact_value() { # + local meta=$1 key=$2 count value + count=$(grep -c "^$key=" "$meta" 2>/dev/null || true) + [ "$count" -eq 1 ] || return 1 + value=$(grep "^$key=" "$meta" | cut -d= -f2-) + [ -n "$value" ] || return 1 + printf '%s' "$value" +} + +fm_backend_endpoint_atom_valid() { # + case "$1" in + ''|*[!A-Za-z0-9._@%+-]*) return 1 ;; + esac +} + +fm_backend_validate_task_endpoint() { # + local meta=$1 id=$2 backend_count backend window worktree project binding_count binding + local session pane recorded_session workspace tab terminal worktree_id surface + FM_BACKEND_VALIDATED_BACKEND= + FM_BACKEND_VALIDATED_TARGET= + [ -f "$meta" ] && [ ! -L "$meta" ] || { + echo "REFUSED: task $id has no regular endpoint metadata at $meta; preserving task state." >&2 + return 1 + } + case "$id" in ''|*[!A-Za-z0-9._-]*) + echo "REFUSED: task endpoint identity has an invalid task id; preserving task state." >&2 + return 1 + esac + window=$(fm_backend_meta_exact_value "$meta" window) || { + echo "REFUSED: task $id has a missing, empty, or ambiguous window endpoint; preserving task state." >&2 + return 1 + } + worktree=$(fm_backend_meta_exact_value "$meta" worktree) || { + echo "REFUSED: task $id has a missing, empty, or ambiguous worktree identity; preserving task state." >&2 + return 1 + } + project=$(fm_backend_meta_exact_value "$meta" project) || { + echo "REFUSED: task $id has a missing, empty, or ambiguous project identity; preserving task state." >&2 + return 1 + } + case "$worktree$project$window" in *$'\n'*|*$'\r'*|*$'\t'*) + echo "REFUSED: task $id has malformed endpoint metadata; preserving task state." >&2 + return 1 + esac + backend_count=$(grep -c '^backend=' "$meta" 2>/dev/null || true) + case "$backend_count" in + 0) backend=tmux ;; + 1) backend=$(fm_backend_meta_exact_value "$meta" backend) || backend= ;; + *) backend= ;; + esac + if [ -z "$backend" ] || ! fm_backend_is_known "$backend"; then + echo "REFUSED: task $id has a missing, ambiguous, or unknown backend identity; preserving task state." >&2 + return 1 + fi + binding_count=$(grep -c '^endpoint_task_id=' "$meta" 2>/dev/null || true) + case "$binding_count" in + 0) binding= ;; + 1) + binding=$(fm_backend_meta_exact_value "$meta" endpoint_task_id) || { + echo "REFUSED: task $id has an empty endpoint task binding; preserving task state." >&2 + return 1 + } + ;; + *) + echo "REFUSED: task $id has an ambiguous endpoint task binding; preserving task state." >&2 + return 1 + ;; + esac + if [ -n "$binding" ] && [ "$binding" != "$id" ]; then + echo "REFUSED: endpoint metadata belongs to task $binding, not $id; preserving task state." >&2 + return 1 + fi + + case "$backend" in + tmux) + session=${window%%:*} + pane=${window#*:} + if [ "$pane" = "$window" ] || [ "$pane" != "fm-$id" ] \ + || [ -z "$session" ]; then + echo "REFUSED: tmux endpoint '$window' is malformed or does not belong to task $id; preserving task state." >&2 + return 1 + fi + ;; + herdr) + [ "$binding" = "$id" ] || { + echo "REFUSED: legacy Herdr endpoint metadata for task $id lacks an exact task binding; preserving task state." >&2 + return 1 + } + recorded_session=$(fm_backend_meta_exact_value "$meta" herdr_session) || recorded_session= + workspace=$(fm_backend_meta_exact_value "$meta" herdr_workspace_id) || workspace= + tab=$(fm_backend_meta_exact_value "$meta" herdr_tab_id) || tab= + pane=$(fm_backend_meta_exact_value "$meta" herdr_pane_id) || pane= + if [ -z "$recorded_session" ] || [ -z "$workspace" ] || [ -z "$tab" ] || [ -z "$pane" ] \ + || [ "$window" != "$recorded_session:$pane" ] \ + || ! fm_backend_endpoint_atom_valid "$recorded_session" \ + || ! fm_backend_endpoint_atom_valid "$workspace" \ + || ! fm_backend_endpoint_atom_valid "${tab//:/_}" \ + || ! fm_backend_endpoint_atom_valid "${pane//:/_}"; then + echo "REFUSED: Herdr endpoint metadata for task $id is malformed or inconsistent; preserving task state." >&2 + return 1 + fi + ;; + zellij) + [ "$binding" = "$id" ] || { + echo "REFUSED: legacy Zellij endpoint metadata for task $id lacks an exact task binding; preserving task state." >&2 + return 1 + } + recorded_session=$(fm_backend_meta_exact_value "$meta" zellij_session) || recorded_session= + tab=$(fm_backend_meta_exact_value "$meta" zellij_tab_id) || tab= + pane=$(fm_backend_meta_exact_value "$meta" zellij_pane_id) || pane= + case "$tab:$pane" in *[!0-9:]*) tab= ;; esac + if [ -z "$recorded_session" ] || [ -z "$tab" ] || [ -z "$pane" ] \ + || [ "$window" != "$recorded_session:$pane" ] \ + || ! fm_backend_endpoint_atom_valid "$recorded_session"; then + echo "REFUSED: Zellij endpoint metadata for task $id is malformed or inconsistent; preserving task state." >&2 + return 1 + fi + ;; + orca) + [ "$binding" = "$id" ] || { + echo "REFUSED: legacy Orca endpoint metadata for task $id lacks an exact task binding; preserving task state." >&2 + return 1 + } + terminal=$(fm_backend_meta_exact_value "$meta" terminal) || terminal= + worktree_id=$(fm_backend_meta_exact_value "$meta" orca_worktree_id) || worktree_id= + [ -n "$terminal" ] || { + echo "REFUSED: missing terminal in $meta; cannot close Orca endpoint; preserving task state." >&2 + return 1 + } + [ -n "$worktree_id" ] || { + echo "REFUSED: missing orca_worktree_id in $meta; cannot remove Orca worktree; preserving task state." >&2 + return 1 + } + if [ "$window" != "fm-$id" ] \ + || ! fm_backend_endpoint_atom_valid "$terminal" \ + || ! fm_backend_endpoint_atom_valid "$worktree_id"; then + echo "REFUSED: Orca endpoint metadata for task $id is malformed or inconsistent; preserving task state." >&2 + return 1 + fi + window=$terminal + ;; + cmux) + [ "$binding" = "$id" ] || { + echo "REFUSED: legacy cmux endpoint metadata for task $id lacks an exact task binding; preserving task state." >&2 + return 1 + } + workspace=$(fm_backend_meta_exact_value "$meta" cmux_workspace_id) || workspace= + surface=$(fm_backend_meta_exact_value "$meta" cmux_surface_id) || surface= + if [ -z "$workspace" ] || [ -z "$surface" ] || [ "$window" != "$workspace:$surface" ] \ + || ! fm_backend_endpoint_atom_valid "$workspace" \ + || ! fm_backend_endpoint_atom_valid "$surface"; then + echo "REFUSED: cmux endpoint metadata for task $id is malformed or inconsistent; preserving task state." >&2 + return 1 + fi + ;; + esac + # shellcheck disable=SC2034 # Output globals are consumed by sourcing callers. + FM_BACKEND_VALIDATED_BACKEND=$backend + # shellcheck disable=SC2034 # Output globals are consumed by sourcing callers. + FM_BACKEND_VALIDATED_TARGET=$window + return 0 +} + fm_backend_meta_for_window() { # local target=$1 state=$2 meta window terminal for meta in "$state"/*.meta; do @@ -573,6 +744,7 @@ fm_backend_send_text_submit() { # local backend=$1 shift + [ -n "${1:-}" ] || { echo "error: refusing empty backend kill target" >&2; return 1; } fm_backend_source "$backend" || return 1 case "$backend" in tmux) fm_backend_tmux_kill "$@" ;; diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index fe659b54b6..98273f704e 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1435,6 +1435,7 @@ META_WINDOW=$T [ "$BACKEND" = orca ] && META_WINDOW=$W { echo "window=$META_WINDOW" + echo "endpoint_task_id=$ID" echo "worktree=$WT" echo "project=$PROJ_ABS" echo "harness=$HARNESS" diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 550a09a39a..6164ebdd78 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -116,18 +116,20 @@ FORCE=${2:-} # down a worktree (see bin/fm-gate-refuse-lib.sh). fm_refuse_if_gate_agent FM_LOCK_LOG_PREFIX=teardown -"$FM_ROOT/bin/fm-guard.sh" || true META="$STATE/$ID.meta" [ -f "$META" ] || { echo "error: no meta for task $ID at $META" >&2; exit 1; } -WT=$(grep '^worktree=' "$META" | cut -d= -f2-) -T=$(grep '^window=' "$META" | cut -d= -f2-) -PROJ=$(grep '^project=' "$META" | cut -d= -f2-) -BACKEND=$(fm_backend_of_meta "$META") -if [ "$BACKEND" = orca ]; then - T_ORCA=$(grep '^terminal=' "$META" | tail -1 | cut -d= -f2- || true) - [ -n "$T_ORCA" ] && T=$T_ORCA -fi +# This is the first cleanup authorization check. It is metadata-only and must +# complete before fm-guard, a backend command, file removal, branch deletion, +# worktree return, registry change, or process termination can run. +fm_backend_validate_task_endpoint "$META" "$ID" || exit 1 +BACKEND=$FM_BACKEND_VALIDATED_BACKEND +T=$FM_BACKEND_VALIDATED_TARGET +WT=$(fm_meta_get "$META" worktree) +PROJ=$(fm_meta_get "$META" project) +T_ORCA= +[ "$BACKEND" != orca ] || T_ORCA=$T +"$FM_ROOT/bin/fm-guard.sh" || true HOME_PATH=$(grep '^home=' "$META" | cut -d= -f2- || true) PR_URL=$(grep '^pr=' "$META" | tail -1 | cut -d= -f2- || true) # tasktmp is recorded by fm-spawn for tasks that set up a per-task temp root @@ -944,6 +946,7 @@ validate_firstmate_home_children_removal() { for child_meta in "$sub_state"/*.meta; do [ -e "$child_meta" ] || continue child_id=$(basename "$child_meta" .meta) + fm_backend_validate_task_endpoint "$child_meta" "$child_id" || return 1 validate_pr_poll_cleanup "$sub_state" "$child_id" || return 1 child_wt=$(meta_value "$child_meta" worktree) child_kind=$(meta_value "$child_meta" kind) diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index c90d759c0d..255c1cdc31 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -156,13 +156,14 @@ family_for_basename() { ;; fm-afk-pi-herdr-return-e2e.test.sh|\ fm-codex-continuity-live-e2e.test.sh|fm-grok-continuity-live-e2e.test.sh|\ - fm-opencode-primary-live-e2e.test.sh|fm-pi-primary-live-e2e.test.sh|\ + fm-grok-stop-live-e2e.test.sh|fm-opencode-primary-live-e2e.test.sh|fm-pi-primary-live-e2e.test.sh|\ fm-send-secondmate-marker-herdr-e2e.test.sh) printf '%s\n' live-harness-optin ;; fm-backend-herdr.test.sh|fm-backend-tmux-smoke.test.sh|fm-backend.test.sh|\ fm-herdr-session-cleanup.test.sh|fm-send-strict.test.sh|fm-spawn-batch.test.sh|\ - fm-spawn-dispatch-profile.test.sh|fm-spawn-worktree-settle.test.sh) + fm-spawn-dispatch-profile.test.sh|fm-spawn-worktree-settle.test.sh|\ + fm-teardown-endpoint-safety.test.sh) printf '%s\n' backend-dispatch ;; fm-pr-check-security.test.sh|fm-pr-merge.test.sh|fm-review-diff.test.sh|\ diff --git a/bin/fm-turnend-guard-grok.sh b/bin/fm-turnend-guard-grok.sh index 5cc33e9c54..3dcfd6f2f6 100755 --- a/bin/fm-turnend-guard-grok.sh +++ b/bin/fm-turnend-guard-grok.sh @@ -1,30 +1,69 @@ #!/usr/bin/env bash # Grok Stop-hook adapter for the firstmate PRIMARY turn-end guard. # -# Grok Stop hooks are passive: exit 2 does not block or feed stderr back to the -# model. This adapter still uses the shared primary-scoped predicate in -# fm-turnend-guard.sh. When that predicate says the primary would end blind, the -# adapter forces one same-session follow-up by running `grok --resume ` -# with a guard instruction. GROK_TURNEND_GUARD_ACTIVE is the loop guard: the -# nested turn's own Stop hook exits without spawning another nested turn. +# The exact running Stop payload selects one path. A typed native capability +# field delegates the shared guard's exit status and stderr directly back to +# that Grok process. Field absence preserves the pre-native one-resume fallback. +# Invalid or unreadable input starts neither path. Camel case has typed +# precedence over the legacy snake-case spelling when both are present. set -u PAYLOAD=$(cat 2>/dev/null || true) [ -n "$PAYLOAD" ] || exit 0 -[ -n "${GROK_TURNEND_GUARD_ACTIVE:-}" ] && exit 0 +command -v jq >/dev/null 2>&1 || exit 0 +printf '%s' "$PAYLOAD" | jq -n --stream -e ' + reduce inputs as $item ( + {}; + if ( + ($item | length) == 2 + and ($item[0] | length) > 0 + and ( + $item[0][0] == "sessionId" + or $item[0][0] == "stopHookActive" + or $item[0][0] == "stop_hook_active" + ) + ) then + .[$item[0][0]] = ((.[$item[0][0]] // 0) + 1) + else + . + end + ) + | all(.[]; . == 1) +' >/dev/null 2>&1 || exit 0 +CAPABILITY=$(printf '%s' "$PAYLOAD" | jq -ser ' + if length != 1 then error("payload count") + elif ((.[0] | type) != "object") then error("payload") + else .[0] | + if has("stopHookActive") then + if ((.stopHookActive | type) == "boolean") then "native" else error("stopHookActive") end + elif has("stop_hook_active") then + if ((.stop_hook_active | type) == "boolean") then "native" else error("stop_hook_active") end + else "legacy" + end + end +' 2>/dev/null) || exit 0 ROOT=${GROK_WORKSPACE_ROOT:-${CLAUDE_PROJECT_DIR:-}} [ -n "$ROOT" ] || exit 0 ROOT=${ROOT%/} [ -x "$ROOT/bin/fm-turnend-guard.sh" ] || exit 0 -if ! command -v jq >/dev/null 2>&1; then - exit 0 +if [ "$CAPABILITY" = native ]; then + printf '%s' "$PAYLOAD" | "$ROOT/bin/fm-turnend-guard.sh" + RC=$? + case "$RC" in + 0|2) exit "$RC" ;; + *) exit 0 ;; + esac fi -SESSION_ID=$(printf '%s' "$PAYLOAD" | jq -r '.sessionId // empty' 2>/dev/null) || exit 0 -[ -n "$SESSION_ID" ] || exit 0 +# Only a genuine pre-native payload reaches this bounded compatibility path. +[ -n "${GROK_TURNEND_GUARD_ACTIVE:-}" ] && exit 0 +SESSION_ID=$(printf '%s' "$PAYLOAD" | jq -er ' + .sessionId | select(type == "string" and length > 0) +' 2>/dev/null) || exit 0 +command -v grok >/dev/null 2>&1 || exit 0 ERR=$(mktemp "${TMPDIR:-/tmp}/fm-turnend-grok.XXXXXX") || exit 0 trap 'rm -f "$ERR"' EXIT diff --git a/bin/fm-turnend-guard.sh b/bin/fm-turnend-guard.sh index 515a859cd2..2e96fb33e4 100755 --- a/bin/fm-turnend-guard.sh +++ b/bin/fm-turnend-guard.sh @@ -11,8 +11,10 @@ # This script is push-based: verified harness turn-end hooks invoke it every time # the primary is about to end a turn. # Claude and codex can block directly by preserving exit status 2 and stderr. -# OpenCode, pi, and grok adapters use the same predicate and force one bounded -# follow-up because their turn-end events are passive. +# OpenCode and pi adapters use the same predicate and force one bounded +# follow-up because their turn-end events are passive. Grok delegates native +# blocking when its running Stop payload advertises that capability, with one +# bounded resume fallback for payloads from pre-native processes. # See docs/turnend-guard.md for the per-harness mechanics, validation evidence, # and fail-open tradeoffs. # @@ -26,10 +28,10 @@ # primary checkout - the main home or a genuinely marked secondmate home - and # stay a silent, fast no-op inside child task worktrees. # -# Loop-guard, codex (default) mode: never block twice in the same turn. Codex -# Stop payloads carry stop_hook_active=true when the CURRENT stop attempt was -# itself already forced by an earlier block this turn; on that signal we always -# allow the stop, whether or not watcher supervision actually got resumed. +# Loop-guard, codex/Grok (default) mode: never block twice in the same turn. +# Codex uses stop_hook_active and Grok uses stopHookActive; typed camel-case +# takes precedence when both spellings are present. A true value means the +# current stop attempt already follows a block, so this guard always allows it. # Passive harness adapters provide their own one-follow-up guard before calling # this script. # That bounds those harnesses to at most one forced continuation per turn - @@ -94,7 +96,15 @@ PAYLOAD=$(cat 2>/dev/null || true) # loop-guard field, so we must never block - fail open, not noisy. command -v jq >/dev/null 2>&1 || exit 0 -STOP_HOOK_ACTIVE=$(printf '%s' "$PAYLOAD" | jq -r '.stop_hook_active // false' 2>/dev/null) || exit 0 +STOP_HOOK_ACTIVE=$(printf '%s' "$PAYLOAD" | jq -r ' + if type != "object" then error("payload") + elif has("stopHookActive") then + if ((.stopHookActive | type) == "boolean") then .stopHookActive else error("stopHookActive") end + elif has("stop_hook_active") then + if ((.stop_hook_active | type) == "boolean") then .stop_hook_active else error("stop_hook_active") end + else false + end +' 2>/dev/null) || exit 0 if [ "$CLAUDE_MODE" -eq 0 ] && [ "$STOP_HOOK_ACTIVE" = "true" ]; then exit 0 fi diff --git a/docs/architecture.md b/docs/architecture.md index da1519a0d4..d1bcb83c56 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -89,7 +89,7 @@ On an unmarked return, `bin/fm-afk-return.sh` owns ordered shutdown, durable cat The runtime backend is the session-provider layer below firstmate's scripts. It owns task endpoint creation, bounded capture, text/key sends, current-path reads for spawn-time worktree discovery when the backend does not create the worktree itself, live-window fallback lookup, agent-process liveness probes where verified, and endpoint teardown. -`bin/fm-backend.sh` centralizes backend selection, `state/.meta` helpers, selector resolution, and operation dispatch; `bin/backends/tmux.sh` is the verified reference adapter ([`docs/tmux-backend.md`](tmux-backend.md)), and `bin/backends/herdr.sh` (P2), `bin/backends/zellij.sh` (P3), `bin/backends/orca.sh` (P4), and `bin/backends/cmux.sh` (P5) are experimental task-spawn adapters. +`bin/fm-backend.sh` centralizes backend selection, `state/.meta` helpers, metadata-only cleanup identity validation, selector resolution, and operation dispatch; `bin/backends/tmux.sh` is the verified reference adapter ([`docs/tmux-backend.md`](tmux-backend.md)), and `bin/backends/herdr.sh` (P2), `bin/backends/zellij.sh` (P3), `bin/backends/orca.sh` (P4), and `bin/backends/cmux.sh` (P5) are experimental task-spawn adapters. New spawns select a backend from `--backend`, then `FM_BACKEND`, then local `config/backend`, then runtime auto-detection from `$TMUX`, `HERDR_ENV=1`, or cmux runtime signals, then default `tmux`. Runtime auto-detection is innermost-first: `$TMUX` wins over `HERDR_ENV=1`, which wins over cmux's primary `CMUX_WORKSPACE_ID` marker and documented fallback signals; auto-detected herdr or cmux prints a one-time opt-out notice, auto-detected tmux stays silent, and zellij and orca are never auto-detected (only explicit selection). Unknown backend names fail loudly. diff --git a/docs/configuration.md b/docs/configuration.md index 7f23866c33..d9a06bf4ca 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -67,6 +67,7 @@ A zellij spawn additionally version-gates against the installed `zellij` binary' A cmux spawn additionally version-gates against the installed `cmux` binary's version, requires `jq`, and requires the control socket to be reachable and accessible (see [`docs/cmux-backend.md`](cmux-backend.md) "Setup" for the one-time socket-access configuration this needs; Automation mode is the recommended socket control mode, with Password mode supported via `config/cmux-socket-password`), refusing loudly and non-retryably on a `cmuxOnly`/unauthenticated socket. A backend spawn refusal from a missing dependency, version gate, or unauthenticated socket is terminal for that selected backend; firstmate surfaces it as a blocker instead of silently retrying another backend. Task meta records `backend=` only for a non-default backend; an absent `backend=` means `tmux`, preserving existing default-path meta files. +Every new task records `endpoint_task_id=` as the cleanup binding between the metadata filename and its opaque runtime endpoint. A herdr task additionally records `herdr_session=`, `herdr_workspace_id=`, `herdr_tab_id=`, and `herdr_pane_id=`. A zellij task additionally records `zellij_session=`, `zellij_tab_id=`, and `zellij_pane_id=`. An Orca task additionally records `orca_worktree_id=` and `terminal=`, with `window=fm-` kept as the shared firstmate alias. @@ -77,7 +78,9 @@ Otherwise an exact task id matching `state/.meta` wins before the legacy `fm A metadata-routed selector returns the recorded backend target (`terminal=` for Orca, otherwise `window=`), and matching explicit targets can still recover the recorded backend when metadata contains the same endpoint. Only metadata-routed task selectors carry secondmate-marker and Codex-harness context; explicit endpoint escape hatches do not. These five sentences are the single owner of the task-selector vocabulary; backend guides and other documents point here instead of restating the resolution order. -`fm-teardown.sh ` takes a task id directly and uses the same recorded backend target fields after loading `state/.meta`. +`fm-teardown.sh ` takes a task id directly and validates the complete metadata-only endpoint identity before any runtime dispatch or cleanup mutation. +Missing, empty, duplicate, malformed, backend-inconsistent, or task-mismatched endpoint records are preserved and refused. +Legacy tmux metadata remains cleanup-compatible when its exact window name is `fm-`; opaque non-tmux endpoints require their recorded `endpoint_task_id=` binding. By default, Herdr workspaces are derived from `FM_HOME`: the primary home uses `firstmate`, and a secondmate home marked by `.fm-secondmate-home` uses `2ndmate-`. The default-container spawn, list-live, and recovery paths read that label from the active home, so a secondmate's own crewmates stay inside that secondmate home's herdr space. The optional local `config/herdr-presentation-spaces` presence flag instead enables Herdr's default-off disposable single-task visual projection; [Optional presentation spaces](herdr-backend.md#optional-presentation-spaces) owns its behavior, safety limits, recovery contract, and narrow locked session-start cleanup of exact restored idle-shell children. diff --git a/docs/supervision-protocols/grok.md b/docs/supervision-protocols/grok.md index a250edd205..22444b2bd7 100644 --- a/docs/supervision-protocols/grok.md +++ b/docs/supervision-protocols/grok.md @@ -30,10 +30,9 @@ When you see a background-task-completed system reminder for the arm: 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. -Grok Stop hooks are passive. -The primary project hook runs `bin/fm-turnend-guard-grok.sh`, which forces at most one same-session follow-up via `grok --resume` when a turn would end blind. -That is a backstop, not the normal wake path. -After any forced follow-up, arm the watcher with the background protocol above. +The primary project Stop hook runs `bin/fm-turnend-guard-grok.sh` as a backstop, not the normal wake path. +[`turnend-guard.md`](../turnend-guard.md) owns its running-payload capability selection between native same-process blocking and the pre-native bounded resume fallback. +After any forced continuation, arm the watcher with the background protocol above. Interactive TUI primary sessions are the supported supervision host. Headless `grok -p` may wait for background process exit but does not reliably surface full auto-wake model output; do not run the primary firstmate as a one-shot headless process. diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index 30690bb887..8ee750de39 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -42,8 +42,8 @@ If `jq` is missing or hook stdin is empty, the guard exits 0 because it cannot s - Codex registers a `Stop` hook in `.codex/hooks.json`, anchors the executable to the hook process working directory, verifies a Firstmate-shaped hook-bearing root, and passes the original payload to the shared guard. - OpenCode listens for `session.idle` in `.opencode/plugins/fm-primary-turnend-guard.js`, lets the watcher coordinator act first, and calls `client.session.promptAsync` once when the guard returns 2. - Pi listens for `agent_settled` in `.pi/extensions/fm-primary-turnend-guard.ts`, runs once per logical agent run, and calls `pi.sendUserMessage(..., { deliverAs: "followUp" })` once when the guard returns 2. -- Grok registers a `Stop` hook in `.grok/hooks/fm-primary-turnend-guard.json` and uses `bin/fm-turnend-guard-grok.sh` to resume the reported session once when the shared guard returns 2. - The adapter intentionally omits `--permission-mode`, so a passive hook cannot grant stronger permissions than the resumed session default. +- Grok registers a `Stop` hook in `.grok/hooks/fm-primary-turnend-guard.json` and delegates capability selection to `bin/fm-turnend-guard-grok.sh`. + The tracked Claude Stop entries are inert when `GROK_AGENT` is present, so Grok's Claude-compatible settings loading cannot create a second continuation path. Claude and Codex can block a Stop directly with exit status 2 and stderr. Both payloads carry `stop_hook_active`. @@ -55,15 +55,22 @@ The Claude mode waits up to `FM_CLAUDE_AUTOARM_SYNC_WAIT_MS` (default 800 millis When none of those proofs appears, it re-blocks up to `FM_CLAUDE_TURNEND_BLOCK_BUDGET` times (default 3, below Claude's 8-block override), then allows degraded with a visible `systemMessage`. Any allow resets the budget. -OpenCode, Pi, pi-signed, and Grok expose passive callbacks for this purpose. +OpenCode, Pi, and pi-signed expose passive callbacks for this purpose. Their adapters fail open at the hook boundary to protect the user session but schedule one bounded follow-up when the predicate blocks. The generated prompts use the canonical `turn-end-guard` kind after the U+2063 `FIRSTMATE_OP: ` prefix, so Ahoy does not treat them as captain messages. -Each adapter owns a loop latch. +Each passive adapter owns a loop latch. Pi keeps the latch across internal tool turns and clears it only when the generated follow-up settles or delivery fails. -Grok's project hook requires the checkout to be trusted with `/hooks-trust` or launch-time `--trust`. OpenCode's forced follow-up is supported for persistent TUI sessions and remains fail-open in headless `opencode run`. -If a passive adapter cannot invoke its SDK, find `grok`, or recover a Grok session id, the next pull-based `fm-guard.sh` call reports the problem. +Grok makes exactly one typed capability decision from each running Stop payload. +A boolean `stopHookActive` selects native blocking, including both false on the initial stop and true on the bounded continuation. +The camel-case field has precedence when both spellings appear; when it is absent, a boolean `stop_hook_active` selects the same native path for compatibility. +The native path returns the shared guard's status and stderr to the same Grok process and never starts `grok --resume`. +When both capability spellings are absent, the adapter preserves one pre-native `grok --resume` fallback guarded by `GROK_TURNEND_GUARD_ACTIVE` and intentionally omits `--permission-mode`. +Malformed JSON, a selected field with a non-boolean type, missing `jq`, missing hook prerequisites, or an already-active legacy guard allows the stop without starting either continuation path. +Grok's project hook requires the checkout to be trusted with `/hooks-trust` or launch-time `--trust`; genuine pre-native builds can run the same tracked hook from an isolated global hook directory. + +If a passive adapter cannot invoke its SDK, or the Grok legacy fallback cannot find `grok` or a session id, the next pull-based `fm-guard.sh` call reports the problem. That warning uses `bin/fm-supervision-instructions.sh --repair-line`, so it always points to the active harness protocol rather than embedding another repair command. ## Compatibility limits @@ -83,7 +90,7 @@ That warning uses `bin/fm-supervision-instructions.sh --repair-line`, so it alwa ## Regression coverage -`tests/fm-turnend-guard.test.sh` covers the predicate, main and secondmate primary scope, child-worktree exclusion, `FM_HOME` and `FM_STATE_OVERRIDE` precedence, the cooperative `--claude` claim wait, epoch allow, re-block budget, Pi logical-run latching, missing-`jq` behavior, all five primary registrations, and Grok resume permission and recursion safety. +`tests/fm-turnend-guard.test.sh` covers the predicate, main and secondmate primary scope, child-worktree exclusion, `FM_HOME` and `FM_STATE_OVERRIDE` precedence, the cooperative `--claude` claim wait, epoch allow, re-block budget, Pi logical-run latching, missing-`jq` behavior, all five primary registrations, Grok native and legacy selection, typed field precedence, malformed input, and exactly-one-path safety. `tests/fm-kimi-harness.test.sh` covers the separate Kimi crew hook's format preservation, idempotence, refusal cases, token guard, spawn registration, and teardown cleanup. `tests/fm-supervision-instructions.test.sh` covers recovery-line ownership and pi-signed's identity-preserving reuse of Pi's protocol. `FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh` is the opt-in isolated Pi path. diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index a711d84ee5..65152100f4 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -89,6 +89,34 @@ tests/fm-tmux-submit-busy.test.sh Expected structural matrix: real text on any content row is pending; all-empty complete boxes are empty; unreadable, incomplete, or unsafe boxes are unknown; and non-bordered panes retain cursor-row compatibility. Expected submit matrix: proven pending plus busy is accepted as queued; proven pending plus idle remains pending; ambiguous pending is never converted by the busy exception; and only a proven empty composer succeeds directly. +### Cleanup endpoint identity + +The cleanup identity boundary was validated on 2026-07-28 with tmux 3.6a and metadata fixtures for every supported backend. + +```sh +tests/fm-teardown-endpoint-safety.test.sh +tests/fm-teardown.test.sh +tests/fm-backend-herdr.test.sh +tests/fm-backend-zellij.test.sh +tests/fm-backend-orca.test.sh +tests/fm-backend-cmux.test.sh +``` + +Bounded output from the incident regression: + +```text +ok - fm-teardown: missing, empty, malformed, ambiguous, and task-mismatched endpoints refuse before every mutation or runtime call +ok - cleanup identity: valid tmux, Herdr, Zellij, Orca, and cmux records validate while every empty backend target refuses +ok - tmux backend: direct empty target returns nonzero without invoking tmux +ok - process cleanup: creation-time PID identity removes only the exact child and preserves the control child +ok - fm-teardown: dedicated-socket invalid cleanup preserves target/control and valid cleanup removes only the exact target +``` + +The dedicated tmux cell removed ambient tmux variables, required a socket-bound wrapper, kept one target and one independent control window, and proved the wrapper was not called for invalid metadata or a direct empty target. +Valid cleanup removed only the exact task-bound target and left the control window live. +The metadata-only validation covers tmux, Herdr, Zellij, Orca, and cmux before backend dispatch. +Claude, Codex, OpenCode, Pi, pi-signed, Grok, and Kimi share that backend cleanup boundary; their harness-specific hook files and token cleanup run only after it, so no harness needs a separate endpoint parser. + ## Herdr The compatibility floor is protocol 14. diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index 6945b3491d..326d21d73e 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -71,7 +71,26 @@ The direct and passive mechanisms were validated across all five harnesses on 20 | Codex | 0.142.1 | Blocking `Stop` hook | Hook process root stayed anchored to the trusted checkout and one continuation ran. | | OpenCode | 1.17.6 | Passive `session.idle` callback | Throwing could not block, while `promptAsync` scheduled one TUI follow-up; headless remained fail-open. | | Pi | 0.80.5 | Passive `agent_settled` callback | Exactly one guard follow-up ran for an unhealthy cycle, with no recursion across tool turns. | -| Grok | 0.2.93 | Passive `Stop` plus bounded resume | Project hook ran under trust, resumed once without inherited bypass permissions, and the environment latch prevented recursion. | +| Grok | 0.2.112 native and 0.2.73 pre-native | Running-payload adaptive `Stop` | Native false-to-true continuation stayed in one process with two model turns and zero resume launches; the field-absent pre-native process launched exactly one guarded resume. | + +The Grok adaptive matrix ran on 2026-07-28 with separate scratch repositories and homes, dedicated tmux sockets, one target plus one control window, ambient tmux variables removed, and a socket-bound wrapper first in `PATH`. + +```sh +FM_GROK_STOP_LIVE_E2E=1 \ + FM_GROK_NATIVE_BIN="$native_grok_0_2_112" \ + FM_GROK_LEGACY_BIN="$official_pre_native_grok_0_2_73" \ + tests/fm-grok-stop-live-e2e.test.sh +``` + +Observed bounded output: + +```text +ok - grok 0.2.112 (9bbd559437aa) [stable] native Stop kept one session across false->true, two model turns, and zero resume processes +ok - grok 0.2.73 (9ff14c43bbe5) [stable] legacy Stop omitted capability, resumed exactly once, and stopped normally +ok - Grok adaptive Stop real-process matrix passed with exact target cleanup and control-window survival +``` + +The same run proved the Claude-compatible Stop entries stay inert under `GROK_AGENT`, the legacy resume carries `GROK_TURNEND_GUARD_ACTIVE=1`, and every replacement root is removed after exact target cleanup while its control window survives. The secondmate-home scope and manual-repair wake path were measured with Claude Code 2.1.207 on 2026-07-12, when a native background completion re-invoked the idle model with no human input. The current Stop-owned main/secondmate inclusion and child-worktree exclusion are covered deterministically by `tests/fm-claude-stop-autoarm.test.sh`. @@ -96,6 +115,7 @@ Current entry points: tests/fm-turnend-guard.test.sh tests/fm-supervision-instructions.test.sh FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh +FM_GROK_STOP_LIVE_E2E=1 FM_GROK_NATIVE_BIN="$native_grok" FM_GROK_LEGACY_BIN="$pre_native_grok" tests/fm-grok-stop-live-e2e.test.sh ``` ## Watcher continuity diff --git a/tests/fm-backend-orca.test.sh b/tests/fm-backend-orca.test.sh index 66c3dd3653..a54e448d10 100755 --- a/tests/fm-backend-orca.test.sh +++ b/tests/fm-backend-orca.test.sh @@ -702,7 +702,7 @@ test_peek_send_and_crew_state_route_through_orca_meta() { fm_git_init_commit "$wt" state="$TMP_ROOT/io-state"; mkdir -p "$state" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "terminal=term-io" "worktree=$wt" "project=$wt" "harness=claude" "kind=scout" "backend=orca" + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-io" "worktree=$wt" "project=$wt" "harness=claude" "kind=scout" "backend=orca" touch "$state/.last-watcher-beat" orca_case io-path neutral=$(neutral_fm_root "$CASE_DIR/neutral") @@ -739,7 +739,7 @@ test_peek_and_crew_state_fail_closed_on_orca_error_json() { fm_git_init_commit "$wt" state="$TMP_ROOT/read-error-state"; mkdir -p "$state" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "terminal=term-stale" "worktree=$wt" "project=$wt" "harness=claude" "kind=scout" "backend=orca" + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-stale" "worktree=$wt" "project=$wt" "harness=claude" "kind=scout" "backend=orca" touch "$state/.last-watcher-beat" orca_case read-error-json neutral=$(neutral_fm_root "$CASE_DIR/neutral") @@ -785,7 +785,7 @@ test_scout_teardown_removes_orca_worktree_via_helper() { printf 'report\n' > "$data/$id/report.md" touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "terminal=term-teardown" "worktree=$wt" "project=$proj" \ + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-teardown" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=scout" "mode=no-mistakes" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-teardown" \ "decisions_reviewed=1" "decision_keys=" @@ -822,7 +822,7 @@ test_scout_teardown_refuses_orca_id_path_mismatch() { printf 'report\n' > "$data/$id/report.md" touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "terminal=term-scout-mismatch" "worktree=$wt" "project=$proj" \ + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-scout-mismatch" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=scout" "mode=no-mistakes" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-scout-mismatch" \ "decisions_reviewed=1" "decision_keys=" @@ -858,7 +858,7 @@ test_teardown_removes_orca_worktree_when_path_missing() { printf 'report\n' > "$data/$id/report.md" touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "terminal=term-missing-path" "worktree=$wt" "project=$proj" \ + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-missing-path" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=scout" "mode=no-mistakes" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-missing-path" \ "decisions_reviewed=1" "decision_keys=" @@ -891,12 +891,13 @@ test_teardown_preserves_metadata_when_orca_remove_error_json() { printf 'report\n' > "$data/$id/report.md" touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "worktree=$wt" "project=$proj" \ + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-remove-error" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=scout" "mode=no-mistakes" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-remove-error" \ "decisions_reviewed=1" "decision_keys=" orca_case remove-error-teardown - printf '{"ok":false,"error":{"code":"worktree_not_removed","message":"worktree not removed"}}\n' > "$RESP/1.out" + printf '{"ok":true,"result":{}}\n' > "$RESP/1.out" + printf '{"ok":false,"error":{"code":"worktree_not_removed","message":"worktree not removed"}}\n' > "$RESP/2.out" neutral=$(neutral_fm_root "$CASE_DIR/neutral") set +e out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ @@ -921,7 +922,7 @@ test_scout_teardown_refuses_orca_missing_report_when_path_missing() { mkdir -p "$data/$id" "$state" "$config" touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "terminal=term-missing-report" "worktree=$wt" "project=$proj" \ + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-missing-report" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=scout" "mode=no-mistakes" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-missing-report" orca_case missing-report @@ -951,7 +952,7 @@ test_ship_teardown_refuses_orca_missing_worktree_path() { mkdir -p "$data/$id" "$state" "$config" touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "terminal=term-missing-ship" "worktree=$wt" "project=$proj" \ + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-missing-ship" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=ship" "mode=no-mistakes" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-missing-ship" orca_case missing-ship-path @@ -982,7 +983,7 @@ test_ship_teardown_removes_orca_worktree_when_id_path_matches() { mkdir -p "$data/$id" "$state" "$config" touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "terminal=term-ship-match" "worktree=$wt" "project=$proj" \ + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-ship-match" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=ship" "mode=local-only" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-ship-match" orca_case ship-match @@ -1017,7 +1018,7 @@ test_ship_teardown_refuses_orca_unresolvable_worktree_id() { mkdir -p "$data/$id" "$state" "$config" touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "terminal=term-ship-unresolved" "worktree=$wt" "project=$proj" \ + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-ship-unresolved" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=ship" "mode=local-only" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-ship-unresolved" orca_case ship-unresolved @@ -1056,7 +1057,7 @@ test_ship_teardown_refuses_orca_id_path_mismatch() { mkdir -p "$data/$id" "$state" "$config" touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "terminal=term-ship-mismatch" "worktree=$wt" "project=$proj" \ + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-ship-mismatch" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=ship" "mode=local-only" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-ship-mismatch" orca_case ship-mismatch @@ -1094,7 +1095,7 @@ test_teardown_refuses_orca_missing_worktree_id() { printf 'report\n' > "$data/$id/report.md" touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "terminal=term-missing-id" "worktree=$wt" "project=$proj" \ + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-missing-id" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=scout" "mode=no-mistakes" "yolo=off" "backend=orca" \ "decisions_reviewed=1" "decision_keys=" orca_case missing-id @@ -1112,7 +1113,7 @@ test_teardown_refuses_orca_missing_worktree_id() { pass "fm-teardown.sh backend=orca: refuses missing worktree ids before cleanup" } -test_teardown_removes_orca_worktree_without_terminal_handle() { +test_teardown_refuses_orca_worktree_without_terminal_handle() { local proj wt data state config id out rc neutral id="orcanotermz0" proj="$TMP_ROOT/no-terminal-project" @@ -1125,12 +1126,11 @@ test_teardown_removes_orca_worktree_without_terminal_handle() { printf 'report\n' > "$data/$id/report.md" touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "worktree=$wt" "project=$proj" \ + "window=fm-$id" "endpoint_task_id=$id" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=scout" "mode=no-mistakes" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-no-terminal" \ "decisions_reviewed=1" "decision_keys=" orca_case no-terminal - printf '{"ok":true,"result":{"worktree":{"id":"wt-no-terminal","path":"%s"}}}\n' "$wt" > "$RESP/1.out" neutral=$(neutral_fm_root "$CASE_DIR/neutral") set +e out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ @@ -1138,13 +1138,11 @@ test_teardown_removes_orca_worktree_without_terminal_handle() { "$ROOT/bin/fm-teardown.sh" "$id" 2>&1 ) rc=$? set -e - expect_code 0 "$rc" "Orca teardown should remove a worktree even when no terminal was ever recorded"$'\n'"$out" - assert_contains "$(cat "$LOG")" $'orca\x1f''worktree'$'\x1f''rm'$'\x1f''--worktree'$'\x1f''id:wt-no-terminal'$'\x1f''--force'$'\x1f''--json' \ - "teardown did not remove the partial Orca worktree" - assert_not_contains "$(cat "$LOG")" $'orca\x1f''terminal'$'\x1f''close' \ - "teardown should not close a terminal when no terminal handle is recorded" - assert_absent "$state/$id.meta" "successful partial cleanup should remove task metadata" - pass "fm-teardown.sh backend=orca: removes partial worktree-only metadata" + [ "$rc" -ne 0 ] || fail "Orca teardown accepted metadata without a terminal handle" + assert_contains "$out" "missing terminal" "teardown did not explain the incomplete Orca endpoint" + [ ! -s "$LOG" ] || fail "teardown dispatched to Orca before rejecting the incomplete endpoint" + assert_present "$state/$id.meta" "missing-terminal refusal removed task metadata" + pass "fm-teardown.sh backend=orca: refuses incomplete worktree-only endpoint metadata before runtime dispatch" } test_secondmate_force_teardown_removes_orca_child_via_orca() { @@ -1164,7 +1162,8 @@ test_secondmate_force_teardown_removes_orca_child_via_orca() { printf '%s\n' "- domain - Orca child cleanup (home: $subhome; scope: orca cleanup; projects: alpha; added 2026-07-03)" \ > "$home/data/secondmates.md" fm_write_meta "$subhome/state/$child_id.meta" \ - "window=fm-$child_id" "terminal=term-child-cleanup" "worktree=$childwt" "project=$childproj" \ + "window=fm-$child_id" "endpoint_task_id=$child_id" \ + "terminal=term-child-cleanup" "worktree=$childwt" "project=$childproj" \ "harness=claude" "kind=ship" "mode=no-mistakes" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-child-cleanup" orca_case secondmate-child-cleanup @@ -1206,7 +1205,8 @@ test_secondmate_force_teardown_refuses_orca_child_id_path_mismatch() { printf '%s\n' "- domain - Orca child cleanup (home: $subhome; scope: orca cleanup; projects: alpha; added 2026-07-03)" \ > "$home/data/secondmates.md" fm_write_meta "$subhome/state/$child_id.meta" \ - "window=fm-$child_id" "terminal=term-child-mismatch" "worktree=$childwt" "project=$childproj" \ + "window=fm-$child_id" "endpoint_task_id=$child_id" \ + "terminal=term-child-mismatch" "worktree=$childwt" "project=$childproj" \ "harness=claude" "kind=ship" "mode=no-mistakes" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-child-mismatch" orca_case secondmate-child-mismatch @@ -1229,7 +1229,7 @@ test_secondmate_force_teardown_refuses_orca_child_id_path_mismatch() { pass "fm-teardown.sh --force: refuses Orca child id/path mismatches" } -test_secondmate_force_teardown_removes_partial_orca_child() { +test_secondmate_force_teardown_refuses_partial_orca_child() { local home subhome childproj childwt child_id neutral out rc home="$TMP_ROOT/orca-partial-child-parent" subhome="$TMP_ROOT/orca-partial-child-secondmate" @@ -1246,11 +1246,11 @@ test_secondmate_force_teardown_removes_partial_orca_child() { printf '%s\n' "- domain - Orca partial child cleanup (home: $subhome; scope: orca cleanup; projects: alpha; added 2026-07-03)" \ > "$home/data/secondmates.md" fm_write_meta "$subhome/state/$child_id.meta" \ - "window=fm-$child_id" "worktree=$childwt" "project=$childproj" \ + "window=fm-$child_id" "endpoint_task_id=$child_id" \ + "worktree=$childwt" "project=$childproj" \ "harness=claude" "kind=ship" "mode=no-mistakes" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-partial-child" orca_case secondmate-partial-child-cleanup - printf '{"ok":true,"result":{"worktree":{"id":"wt-partial-child","path":"%s"}}}\n' "$childwt" > "$RESP/1.out" add_tmux_fake "$FB" neutral=$(neutral_fm_root "$CASE_DIR/neutral") set +e @@ -1258,13 +1258,12 @@ test_secondmate_force_teardown_removes_partial_orca_child() { FM_ROOT_OVERRIDE="$neutral" FM_HOME="$home" "$ROOT/bin/fm-teardown.sh" domain --force 2>&1 ) rc=$? set -e - expect_code 0 "$rc" "forced secondmate teardown should remove partial Orca child state"$'\n'"$out" - assert_contains "$(cat "$LOG")" $'orca\x1f''worktree'$'\x1f''rm'$'\x1f''--worktree'$'\x1f''id:wt-partial-child'$'\x1f''--force'$'\x1f''--json' \ - "partial child cleanup did not remove the Orca worktree through orca worktree rm" - assert_not_contains "$(cat "$LOG")" $'orca\x1f''terminal'$'\x1f''close' \ - "partial child cleanup should not close a terminal when no terminal handle is recorded" - assert_absent "$home/state/domain.meta" "parent metadata should be removed after forced partial cleanup" - pass "fm-teardown.sh --force: removes partial Orca secondmate children" + [ "$rc" -ne 0 ] || fail "forced secondmate teardown accepted a child with no terminal identity" + assert_contains "$out" "missing terminal" "partial child refusal did not explain the incomplete endpoint" + [ ! -s "$LOG" ] || fail "partial child refusal dispatched to Orca or tmux" + assert_present "$home/state/domain.meta" "partial child refusal removed parent metadata" + assert_present "$subhome/state/$child_id.meta" "partial child refusal removed child metadata" + pass "fm-teardown.sh --force: refuses partial Orca secondmate children before runtime dispatch" } test_dispatcher_sources_orca_and_routes_primitives() { @@ -1323,7 +1322,7 @@ test_ship_teardown_removes_orca_worktree_when_id_path_matches test_ship_teardown_refuses_orca_unresolvable_worktree_id test_ship_teardown_refuses_orca_id_path_mismatch test_teardown_refuses_orca_missing_worktree_id -test_teardown_removes_orca_worktree_without_terminal_handle +test_teardown_refuses_orca_worktree_without_terminal_handle test_secondmate_force_teardown_removes_orca_child_via_orca test_secondmate_force_teardown_refuses_orca_child_id_path_mismatch -test_secondmate_force_teardown_removes_partial_orca_child +test_secondmate_force_teardown_refuses_partial_orca_child diff --git a/tests/fm-backend-zellij.test.sh b/tests/fm-backend-zellij.test.sh index 3844252c93..ae4be257bd 100755 --- a/tests/fm-backend-zellij.test.sh +++ b/tests/fm-backend-zellij.test.sh @@ -796,8 +796,11 @@ test_teardown_passes_recorded_tab_id_to_zellij_kill() { printf 'report\n' > "$data/zghost/report.md" fm_write_meta "$state/zghost.meta" \ "window=firstmate:7" \ + "endpoint_task_id=zghost" \ "backend=zellij" \ + "zellij_session=firstmate" \ "zellij_tab_id=3" \ + "zellij_pane_id=7" \ "worktree=$dir/missing-worktree" \ "project=$project" \ "kind=scout" \ @@ -827,7 +830,11 @@ test_forced_secondmate_teardown_kills_zellij_children_with_child_home_tag() { printf 'smz\n' > "$home/.fm-secondmate-home" fm_write_meta "$state/smz.meta" \ "window=firstmate:99" \ + "endpoint_task_id=smz" \ "backend=zellij" \ + "zellij_session=firstmate" \ + "zellij_tab_id=99" \ + "zellij_pane_id=99" \ "worktree=$home" \ "project=$home" \ "kind=secondmate" \ @@ -835,8 +842,11 @@ test_forced_secondmate_teardown_kills_zellij_children_with_child_home_tag() { "home=$home" fm_write_meta "$home/state/childz.meta" \ "window=firstmate:7" \ + "endpoint_task_id=childz" \ "backend=zellij" \ + "zellij_session=firstmate" \ "zellij_tab_id=4" \ + "zellij_pane_id=7" \ "worktree=$dir/missing-child-worktree" \ "project=$project" \ "kind=scout" diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 74b469a619..323cd4f5e5 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -108,10 +108,9 @@ BASE_REF=$(resolve_base_ref) \ # fm-backend.sh (and its bin/backends/ adapters) is the dispatcher every one # of the five REFACTORED scripts sources; it must be a real, reachable file in # the old bin/ too or `. "$SCRIPT_DIR/fm-backend.sh"` aborts under set -eu - -# hence it is a copied sibling, not an extracted-from-BASE_REF file: for a -# tmux-only conformance run the tmux adapter's behavior is what is under test, -# and that is unchanged by any later (e.g. non-tmux backend) addition to -# fm-backend.sh's own dispatch surface. +# hence the dispatcher is a copied sibling, while the tmux adapter is extracted +# from BASE_REF so conformance tests retain the exact historical behavior even +# when this branch changes tmux dispatch semantics. OLD_BIN_UNCHANGED_SIBLINGS="fm-gate-refuse-lib.sh fm-guard.sh fm-lock-lib.sh fm-tasks-axi-lib.sh fm-pr-lib.sh fm-tangle-lib.sh fm-tmux-lib.sh fm-composer-lib.sh fm-wake-lib.sh fm-classify-lib.sh fm-supervision-lib.sh fm-ff-lib.sh fm-config-inherit-lib.sh fm-project-mode.sh fm-harness.sh fm-crew-state.sh fm-decision-hold.sh fm-backend.sh fm-operational-input.sh" # A pull-request merge may add a new main-only dependency that the branch's older baseline does not have yet. OLD_BIN_OPTIONAL_SIBLINGS="fm-pending-reply-lib.sh" @@ -130,6 +129,7 @@ build_old_bin() { # -> echoes root dir (root/bin/