From 69d247a724c106cec0683258e4095fd5bb638e88 Mon Sep 17 00:00:00 2001 From: QuinnBot Date: Mon, 27 Jul 2026 21:37:13 -0700 Subject: [PATCH 01/70] fix(supervision): restore automatic closeout and refill --- AGENTS.md | 7 +++ bin/fm-supervision-instructions.sh | 27 +++++++++ docs/configuration.md | 9 +++ tests/fm-supervision-instructions.test.sh | 70 +++++++++++++++++++++++ 4 files changed, 113 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index f838dfb27ca..8d9807b270f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,6 +65,7 @@ bin/ helper scripts, committed; read each script's header before .env optional X-mode pairing token; LOCAL, gitignored; presence-gates section 14 config/crew-harness crewmate harness override; LOCAL, gitignored; absent or "default" = same as firstmate. Inherited as the literal file: a concrete primary adapter value also controls a secondmate home's own crewmates (section 4) config/crew-dispatch.json optional crewmate dispatch profiles; LOCAL, gitignored; firstmate-maintained but human-editable natural-language rules that choose a per-task harness/model/effort profile (section 4). Inherited by secondmate homes +config/supervision-capacity optional per-home direct-lifecycle ordinary-worker capacity; LOCAL, gitignored, not inherited; see docs/configuration.md "Direct supervision capacity" config/secondmate-harness harness the PRIMARY uses to launch SECONDMATE agents, optionally followed by a model and effort token on the same line (" [] []"; section 4); LOCAL, gitignored; absent or "default" harness falls back to config/crew-harness then firstmate's own. The primary's own setting; NOT inherited into secondmate homes (secondmates do not spawn secondmates) config/backlog-backend backlog backend override; LOCAL, gitignored; absent or "tasks-axi" = default tasks-axi backend, "manual" = force routine backlog updates to hand-editing; inherited by secondmate homes (section 10) config/backend runtime session-provider backend override for new tasks; LOCAL, gitignored; absent = falls through to runtime auto-detection (the runtime firstmate itself is executing inside), then tmux; tmux is the verified reference backend (docs/tmux-backend.md), while herdr, zellij, orca, and cmux are experimental spawn backends (docs/herdr-backend.md, docs/zellij-backend.md, docs/orca-backend.md, docs/cmux-backend.md) - herdr and cmux can also be selected by runtime auto-detection, zellij and orca never are (always explicit), and codex-app is not accepted; see docs/codex-app-backend.md; not inherited into secondmate homes @@ -350,6 +351,12 @@ Handle actionable wakes as follows: 3. For `check:`, act on the named poll result, including merges and X-mode events. 4. For `heartbeat:`, review the whole fleet from the structured fleet view, reconcile suspicious tasks and PR state, update the backlog, and never report an unchanged fleet as progress. +Every mutable wake is one complete closeout-and-refill transaction before the next wait or turn boundary. +Reconcile every terminal ordinary task, and when a routine ship is green or locally ready with `yolo=on`, select its existing guarded landing owner by metadata mode: `bin/fm-merge-local.sh` for `local-only`, or `bin/fm-pr-merge.sh` for `no-mistakes` and `direct-PR`; preserve every gated, ambiguous, parked, or security-sensitive lane. +Run `bin/fm-teardown.sh` only after landing is confirmed, and treat a refusal as preservation of that lane rather than authority to bypass the guard. +After every successful teardown, update the backlog and launch dependency-cleared and date-eligible ready work through the normal intake and `bin/fm-spawn.sh` path until the applicable configured or captain-recorded capacity is full. +When no explicit capacity applies, preserve section 7's no-arbitrary-cap rule. + When any wake reports a merged PR for a project cloned in this home, refresh that clone through the guarded fleet-sync path. When X-linked work reaches a milestone or terminal state, load `fmx-respond`; before terminal teardown, always post the final completion follow-up so the link clears even if earlier follow-ups were spent. diff --git a/bin/fm-supervision-instructions.sh b/bin/fm-supervision-instructions.sh index 6cd87699b0b..f2f11849fe8 100755 --- a/bin/fm-supervision-instructions.sh +++ b/bin/fm-supervision-instructions.sh @@ -178,6 +178,32 @@ ordinary_wake_line() { esac } +direct_lifecycle_line() { + local capacity capacity_file="$CONFIG/supervision-capacity" + if [ "$READ_ONLY" -eq 1 ]; then + printf '%s\n' '- Direct lifecycle: unavailable in this read-only session; preserve every lane without merge, teardown, or refill.' + return 0 + fi + if [ -e "$capacity_file" ] || [ -L "$capacity_file" ]; then + if [ ! -f "$capacity_file" ] || [ ! -r "$capacity_file" ] \ + || ! capacity=$(<"$capacity_file"); then + printf '%s%s%s\n' '- Direct lifecycle: ' "$capacity_file" ' is invalid; complete guarded closeout before the next wait or turn boundary, but do not refill until the capacity source contains one integer from 1 through 64.' + return 0 + fi + case "$capacity" in + [1-9]|[1-5][0-9]|6[0-4]) + ;; + *) + printf '%s%s%s\n' '- Direct lifecycle: ' "$capacity_file" ' is invalid; complete guarded closeout before the next wait or turn boundary, but do not refill until the capacity source contains one integer from 1 through 64.' + return 0 + ;; + esac + printf '%s%s%s\n' '- Direct lifecycle: before the next wait or turn boundary, complete the AGENTS.md section 8 transaction: reconcile terminal work, use the guarded landing owner for its task mode, teardown only after landed proof, and launch eligible ready work to configured capacity ' "$capacity" ' while preserving every gated or ambiguous lane.' + return 0 + fi + printf '%s\n' '- Direct lifecycle: before the next wait or turn boundary, complete the AGENTS.md section 8 transaction: reconcile terminal work, use the guarded landing owner for its task mode, teardown only after landed proof, and launch eligible ready work to the applicable captain-recorded capacity while preserving every gated or ambiguous lane and imposing no arbitrary cap when none applies.' +} + if [ "$REPAIR_LINE" -eq 1 ]; then repair_line exit 0 @@ -204,6 +230,7 @@ else printf '%s\n' '- X mode: inactive; use the default watcher cadence.' fi ordinary_wake_line +direct_lifecycle_line printf '\n' render_snippet printf '\n' diff --git a/docs/configuration.md b/docs/configuration.md index 7f23866c334..351b1f67864 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -246,6 +246,15 @@ Malformed JSON, an empty or malformed rule/default array, an unverified harness, While the file remains present, no crewmate or scout spawn may proceed without an explicit resolved harness; malformed configuration must be reported and corrected rather than selected around. Secondmate homes inherit this file from the primary, so a secondmate's own crewmates apply the same dispatch profile behavior. +## Direct supervision capacity (config/supervision-capacity) + +`config/supervision-capacity` is the optional local, gitignored capacity source for the primary agent's direct closeout-and-refill transaction. +It contains one decimal integer from 1 through 64 and is not inherited by secondmate homes. +Every ordinary task metadata record occupies one slot until guarded teardown removes it, including terminal, parked, ambiguous, and captain-gated lanes. +An absent file leaves section 7's no-arbitrary-cap rule and any explicit captain-recorded capacity in force. +An unreadable, empty, non-integer, or out-of-range file blocks refill without authorizing cleanup or mutation of an existing lane. +The capacity file provides only a bound; `AGENTS.md` section 8 owns the primary-agent lifecycle transaction, including work selection and launch. + ## Toolchain On session start the first mate detects what its required toolchain is missing or too old and lists each problem with either an exact install command or manual instructions. diff --git a/tests/fm-supervision-instructions.test.sh b/tests/fm-supervision-instructions.test.sh index e8e5f4f919b..f24ac6c103d 100755 --- a/tests/fm-supervision-instructions.test.sh +++ b/tests/fm-supervision-instructions.test.sh @@ -115,6 +115,74 @@ test_cross_harness_ordinary_continuation_and_repair_matrix() { pass "renderer preserves every harness ordinary-continuation and missing-cycle repair path" } +test_cross_harness_direct_lifecycle_matrix() { + local harness home lifecycle out + home="$TMP_ROOT/direct-lifecycle-home" + mkdir -p "$home/config" "$home/state" + printf '%s\n' 3 > "$home/config/supervision-capacity" + + for harness in claude codex opencode pi pi-signed grok kimi unknown; do + out=$(FM_HOME="$home" "$RENDER" --harness "$harness") + lifecycle=$(printf '%s\n' "$out" | grep -F -- '- Direct lifecycle:') + assert_contains "$lifecycle" "before the next wait or turn boundary" \ + "$harness direct lifecycle can lapse at a primary-turn boundary" + assert_contains "$lifecycle" "guarded landing owner for its task mode" \ + "$harness direct lifecycle lost mode-specific guarded landing" + assert_contains "$lifecycle" "teardown only after landed proof" \ + "$harness direct lifecycle lost the landed-work cleanup gate" + assert_contains "$lifecycle" "launch eligible ready work" \ + "$harness direct lifecycle lost immediate ready-work refill" + assert_contains "$lifecycle" "configured capacity 3" \ + "$harness direct lifecycle lost the capacity boundary" + assert_contains "$lifecycle" "preserving every gated or ambiguous lane" \ + "$harness direct lifecycle lost the preservation rule" + done + + out=$(FM_HOME="$home" "$RENDER" --harness codex --read-only 1) + lifecycle=$(printf '%s\n' "$out" | grep -F -- '- Direct lifecycle:') + assert_contains "$lifecycle" "unavailable in this read-only session" \ + "read-only supervision did not disable lifecycle mutation" + assert_contains "$lifecycle" "preserve every lane without merge, teardown, or refill" \ + "read-only supervision did not preserve all lanes" + + pass "every primary harness and fallback render one guarded closeout-and-refill transaction" +} + +test_direct_lifecycle_capacity_fails_closed() { + local capacity error_file home lifecycle out + home="$TMP_ROOT/invalid-capacity-home" + mkdir -p "$home/config" "$home/state" + printf '%s\n' 64 > "$home/config/supervision-capacity" + out=$(FM_HOME="$home" "$RENDER" --harness codex) + lifecycle=$(printf '%s\n' "$out" | grep -F -- '- Direct lifecycle:') + assert_contains "$lifecycle" "configured capacity 64" \ + "valid upper capacity boundary was rejected" + + for capacity in unlimited 0 65 999999999999999999999999999999999999999999; do + printf '%s\n' "$capacity" > "$home/config/supervision-capacity" + error_file="$home/capacity-$capacity.err" + out=$(FM_HOME="$home" "$RENDER" --harness codex 2>"$error_file") + lifecycle=$(printf '%s\n' "$out" | grep -F -- '- Direct lifecycle:') + assert_contains "$lifecycle" "$home/config/supervision-capacity is invalid" \ + "invalid configured capacity $capacity was not identified" + assert_contains "$lifecycle" "do not refill" \ + "invalid configured capacity $capacity did not block refill" + assert_contains "$lifecycle" "complete guarded closeout" \ + "invalid configured capacity $capacity blocked safe closeout" + [ ! -s "$error_file" ] \ + || fail "invalid configured capacity $capacity emitted an arithmetic diagnostic" + done + + rm "$home/config/supervision-capacity" + out=$(FM_HOME="$home" "$RENDER" --harness codex) + lifecycle=$(printf '%s\n' "$out" | grep -F -- '- Direct lifecycle:') + assert_contains "$lifecycle" "applicable captain-recorded capacity" \ + "absent capacity source lost an explicit captain capacity" + assert_contains "$lifecycle" "no arbitrary cap when none applies" \ + "absent capacity source imposed an arbitrary default cap" + pass "direct lifecycle capacity accepts 1-64 and rejects invalid forms without diagnostics" +} + test_pi_signed_preserves_identity_with_pi_supervision_protocol() { local out ordinary out=$("$RENDER" --harness pi-signed) @@ -176,6 +244,8 @@ test_unknown_fallback test_conditional_stanzas test_repair_lines test_cross_harness_ordinary_continuation_and_repair_matrix +test_cross_harness_direct_lifecycle_matrix +test_direct_lifecycle_capacity_fails_closed test_pi_signed_preserves_identity_with_pi_supervision_protocol test_grok_is_background_notify test_grok_command_sources_effective_config From aa97f60d274d98f6950214f7f191743eb94b9d65 Mon Sep 17 00:00:00 2001 From: QuinnBot Date: Mon, 27 Jul 2026 21:42:36 -0700 Subject: [PATCH 02/70] docs(verification): record upstream closeout audit --- docs/verification/supervision.md | 45 ++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index 6945b3491dc..59232cb071e 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -98,6 +98,51 @@ tests/fm-supervision-instructions.test.sh FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh ``` +## Direct closeout and capacity refill + +The pure-upstream baseline `b29621ba19a4d15b688ae277ca06b67f80baa365` was audited on 2026-07-27. +At that baseline, an ordinary terminal wake did not initiate a complete guarded landing, teardown, and ready-work refill transaction. +Manual operator intervention could mask the missing transaction, while the visible unattended symptom was a completed metadata record retaining capacity and eligible work remaining unlaunched. +The upstream renderer emitted no direct-lifecycle instruction for Claude, Codex, OpenCode, Pi, pi-signed, Grok, Kimi, or the unknown-harness fallback. +The production scripts had no ordinary-terminal caller of `bin/fm-teardown.sh`, and their only non-batch caller of `bin/fm-spawn.sh` was bootstrap recovery for a secondmate. +Either an existing cross-harness transaction instruction or an ordinary-terminal production caller would have disconfirmed the finding, but neither existed. + +The smallest current carry is commit `9d45f011cd17b45d4ed4d0f99ecd75bc0c4b2cdf`, which changes only `AGENTS.md`, `bin/fm-supervision-instructions.sh`, `docs/configuration.md`, and `tests/fm-supervision-instructions.test.sh`. +It makes every mutable primary wake own one closeout-and-refill transaction, preserves read-only and ambiguous lanes, uses the existing guarded landing and teardown owners, and fails refill closed when an optional local capacity source is invalid. +It does not add a coordinator, daemon, teardown implementation, migration, or runtime-backend mechanism. +Per Firstmate's 2026-07-27 decision `[key=upstream-closeout-refill]` under the captain's pure-upstream rule, this commit is a temporary local carry required by the fleet and a queued upstream proposal, not a permanent fork delta. +The clean contribution shape is a fresh topic from current upstream with this dependency-free commit plus the end-to-end regression, followed by the current changed-path and full validation gates. + +Focused verification commands: + +```sh +bin/fm-test-run.sh \ + tests/fm-supervision-instructions.test.sh \ + tests/fm-instruction-owners.test.sh \ + tests/fm-documentation-audiences.test.sh +bin/fm-doc-audience-check.sh +bin/fm-lint.sh +git show 152b0fd:tests/fm-direct-lifecycle.test.sh | + sed '15s@.*@. "$PWD/tests/lib.sh"@' | + bash -s +``` + +Observed results: + +```text +ok - every primary harness and fallback render one guarded closeout-and-refill transaction +ok - direct lifecycle capacity accepts 1-64 and rejects invalid forms without diagnostics +ok - all instruction-owner tests passed +ok - all documentation-audience tests passed +fm-doc-audience-check: ok surfaces=55 local_links=151 +fm-lint.sh: ShellCheck 0.11.0 (pinned 0.11.0) +ok - routine PR work lands, cleans, and visibly refills in the same transaction +ok - local-only work uses its guarded owner and refills configured capacity +ok - unlanded work is preserved +ok - captain-gated completed work is preserved +ok - all direct lifecycle tests passed +``` + ## Watcher continuity The cross-harness evidence combines the 2026-07-17 live pass with Claude's replacement Stop-owned path revalidated on 2026-07-24, all against isolated project and home state. From 8a41ccb3eabb1a91824ef56a89613b6f991bc38a Mon Sep 17 00:00:00 2001 From: QuinnBot Date: Mon, 27 Jul 2026 22:18:30 -0700 Subject: [PATCH 03/70] fix(supervision): complete temporary capacity carry --- .gitignore | 1 + AGENTS.md | 1 + docs/configuration.md | 2 +- docs/verification/supervision.md | 16 +- tests/fm-direct-lifecycle.test.sh | 455 ++++++++++++++++++++++++++++++ 5 files changed, 465 insertions(+), 10 deletions(-) create mode 100755 tests/fm-direct-lifecycle.test.sh diff --git a/.gitignore b/.gitignore index 372af4735f3..6ce5f5eb344 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ __pycache__/ .env config/crew-harness config/crew-dispatch.json +config/supervision-capacity config/secondmate-harness config/backlog-backend config/backend diff --git a/AGENTS.md b/AGENTS.md index 8d9807b270f..7894e8250cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -354,6 +354,7 @@ Handle actionable wakes as follows: Every mutable wake is one complete closeout-and-refill transaction before the next wait or turn boundary. Reconcile every terminal ordinary task, and when a routine ship is green or locally ready with `yolo=on`, select its existing guarded landing owner by metadata mode: `bin/fm-merge-local.sh` for `local-only`, or `bin/fm-pr-merge.sh` for `no-mistakes` and `direct-PR`; preserve every gated, ambiguous, parked, or security-sensitive lane. Run `bin/fm-teardown.sh` only after landing is confirmed, and treat a refusal as preservation of that lane rather than authority to bypass the guard. +Count every ordinary `state/*.meta` lane as occupied until safe teardown removes it, including terminal, parked, ambiguous, and captain-gated lanes. After every successful teardown, update the backlog and launch dependency-cleared and date-eligible ready work through the normal intake and `bin/fm-spawn.sh` path until the applicable configured or captain-recorded capacity is full. When no explicit capacity applies, preserve section 7's no-arbitrary-cap rule. diff --git a/docs/configuration.md b/docs/configuration.md index 351b1f67864..bcb57b414a2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -252,7 +252,7 @@ Secondmate homes inherit this file from the primary, so a secondmate's own crewm It contains one decimal integer from 1 through 64 and is not inherited by secondmate homes. Every ordinary task metadata record occupies one slot until guarded teardown removes it, including terminal, parked, ambiguous, and captain-gated lanes. An absent file leaves section 7's no-arbitrary-cap rule and any explicit captain-recorded capacity in force. -An unreadable, empty, non-integer, or out-of-range file blocks refill without authorizing cleanup or mutation of an existing lane. +An unreadable, empty, non-integer, or out-of-range file blocks refill but does not block guarded closeout or grant authority beyond the existing landing and teardown guards. The capacity file provides only a bound; `AGENTS.md` section 8 owns the primary-agent lifecycle transaction, including work selection and launch. ## Toolchain diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index 59232cb071e..fbd1d5a254a 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -111,7 +111,7 @@ The smallest current carry is commit `9d45f011cd17b45d4ed4d0f99ecd75bc0c4b2cdf`, It makes every mutable primary wake own one closeout-and-refill transaction, preserves read-only and ambiguous lanes, uses the existing guarded landing and teardown owners, and fails refill closed when an optional local capacity source is invalid. It does not add a coordinator, daemon, teardown implementation, migration, or runtime-backend mechanism. Per Firstmate's 2026-07-27 decision `[key=upstream-closeout-refill]` under the captain's pure-upstream rule, this commit is a temporary local carry required by the fleet and a queued upstream proposal, not a permanent fork delta. -The clean contribution shape is a fresh topic from current upstream with this dependency-free commit plus the end-to-end regression, followed by the current changed-path and full validation gates. +The clean contribution shape is a fresh topic from current canonical upstream proposed back to canonical upstream, never to the old fork `main`, with this dependency-free commit plus the end-to-end regression followed by the current changed-path and full validation gates. Focused verification commands: @@ -122,9 +122,7 @@ bin/fm-test-run.sh \ tests/fm-documentation-audiences.test.sh bin/fm-doc-audience-check.sh bin/fm-lint.sh -git show 152b0fd:tests/fm-direct-lifecycle.test.sh | - sed '15s@.*@. "$PWD/tests/lib.sh"@' | - bash -s +bin/fm-test-run.sh tests/fm-direct-lifecycle.test.sh ``` Observed results: @@ -136,11 +134,11 @@ ok - all instruction-owner tests passed ok - all documentation-audience tests passed fm-doc-audience-check: ok surfaces=55 local_links=151 fm-lint.sh: ShellCheck 0.11.0 (pinned 0.11.0) -ok - routine PR work lands, cleans, and visibly refills in the same transaction -ok - local-only work uses its guarded owner and refills configured capacity -ok - unlanded work is preserved -ok - captain-gated completed work is preserved -ok - all direct lifecycle tests passed +ok - routine PR work lands on default, cleans safely, and visibly refills in one transaction +ok - local-only yolo work uses its guarded owner and refills to configured capacity +ok - unlanded work fails closed and remains recoverable +ok - captain-gated completed work remains parked without merge or cleanup +# all direct lifecycle tests passed ``` ## Watcher continuity diff --git a/tests/fm-direct-lifecycle.test.sh b/tests/fm-direct-lifecycle.test.sh new file mode 100755 index 00000000000..68ffe0af947 --- /dev/null +++ b/tests/fm-direct-lifecycle.test.sh @@ -0,0 +1,455 @@ +#!/usr/bin/env bash +# End-to-end regression for the primary agent's direct closeout-and-refill +# transaction. +# +# The primary agent remains the one lifecycle owner; there is intentionally no +# production coordinator to test here. +# This test stitches together the existing guarded owners exactly as that agent +# must: standing yolo authority selects the existing merge owner by task mode, +# fm-teardown proves landing before cleanup, and fm-spawn fills the newly open +# visible slot in the same transaction. +# It also proves that unlanded and captain-gated lanes remain intact. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +fm_git_identity fmtest fmtest@example.invalid + +PR_MERGE="$ROOT/bin/fm-pr-merge.sh" +LOCAL_MERGE="$ROOT/bin/fm-merge-local.sh" +TEARDOWN="$ROOT/bin/fm-teardown.sh" +SPAWN="$ROOT/bin/fm-spawn.sh" +RENDER="$ROOT/bin/fm-supervision-instructions.sh" +TMP_ROOT=$(fm_test_tmproot fm-direct-lifecycle) +CASE="$TMP_ROOT/case" +HOME_DIR="$CASE/home" +PROJECT="$HOME_DIR/projects/project" +ORIGIN="$CASE/origin.git" +COMPLETED_WT="$CASE/completed-wt" +READY_WT="$CASE/ready-wt" +READY_WT2="$CASE/ready-wt-2" +LOCAL_WT="$CASE/local-wt" +UNLANDED_WT="$CASE/unlanded-wt" +GATED_WT="$CASE/gated-wt" +FAKEBIN="$CASE/fakebin" +GH_AXI_LOG="$CASE/gh-axi.log" +TREEHOUSE_LOG="$CASE/treehouse.log" +TMUX_LOG="$CASE/tmux.log" +HEAD_FILE="$CASE/completed-head" +BASE_PATH=$PATH +REAL_GIT=$(command -v git) +ACTIVE_READY_ID=ready-r1 +ACTIVE_READY_WT=$READY_WT + +mkdir -p "$HOME_DIR/data" "$HOME_DIR/state" "$HOME_DIR/config" \ + "$HOME_DIR/projects" "$FAKEBIN" +touch "$HOME_DIR/state/.last-watcher-beat" "$GH_AXI_LOG" \ + "$TREEHOUSE_LOG" "$TMUX_LOG" + +git init -q --bare "$ORIGIN" +git -C "$ORIGIN" symbolic-ref HEAD refs/heads/main +git clone -q "$ORIGIN" "$CASE/seed" 2>/dev/null +git -C "$CASE/seed" commit -q --allow-empty -m "origin baseline" +git -C "$CASE/seed" push -q origin main +git clone -q "$ORIGIN" "$PROJECT" +git -C "$PROJECT" remote set-head origin main + +git -C "$PROJECT" worktree add -q -b fm/completed-y1 "$COMPLETED_WT" main +printf '%s\n' "completed work" > "$COMPLETED_WT/completed.txt" +git -C "$COMPLETED_WT" add completed.txt +git -C "$COMPLETED_WT" commit -qm "complete routine work" +git -C "$COMPLETED_WT" rev-parse HEAD > "$HEAD_FILE" + +git -C "$PROJECT" worktree add -q -b fm/ready-r1 "$READY_WT" main + +printf '%s\n' '- project [direct-PR +yolo] - lifecycle fixture (added 2026-07-27)' \ + > "$HOME_DIR/data/projects.md" +printf '%s\n' 1 > "$HOME_DIR/config/supervision-capacity" +mkdir -p "$HOME_DIR/data/ready-r1" +printf '%s\n' '# Task' 'Run the ready lifecycle fixture.' \ + > "$HOME_DIR/data/ready-r1/brief.md" + +cat > "$FAKEBIN/gh-axi" <<'SH' +#!/usr/bin/env bash +set -u +printf '%s\n' "$*" >> "$FM_TEST_GH_AXI_LOG" +case "${1:-} ${2:-}" in + "pr merge") + "$FM_TEST_REAL_GIT" -C "$FM_TEST_COMPLETED_WT" \ + push -q origin HEAD:refs/heads/main + exit 0 + ;; + "pr list") + printf '%s\n' "count: 0 (showing first 0)" "pull_requests[]: []" + exit 0 + ;; + "pr view") + echo "error: pull request not found" >&2 + exit 1 + ;; +esac +exit 0 +SH + +cat > "$FAKEBIN/gh" <<'SH' +#!/usr/bin/env bash +set -u +case "${1:-} ${2:-}" in + "pr view") + case " $* " in + *" --json headRefOid "*) + cat "$FM_TEST_HEAD_FILE" + exit 0 + ;; + esac + ;; +esac +echo "error: pull request not found" >&2 +exit 1 +SH + +cat > "$FAKEBIN/treehouse" <<'SH' +#!/usr/bin/env bash +set -u +case "${1:-}" in + get) + if [ "${2:-}" = --help ]; then + printf '%s\n' 'Usage: treehouse get [--lease] [--lease-holder ]' + else + printf '%s\n' "$FM_TEST_READY_WT" + fi + exit 0 + ;; + return) + printf '%s\n' "$*" >> "$FM_TEST_TREEHOUSE_LOG" + "$FM_TEST_REAL_GIT" -C "$FM_TEST_PROJECT" worktree remove --force "$3" + exit $? + ;; + status) + exit 0 + ;; +esac +exit 0 +SH + +cat > "$FAKEBIN/tmux" <<'SH' +#!/usr/bin/env bash +set -u +printf '%s\n' "$*" >> "$FM_TEST_TMUX_LOG" +if [ "${1:-}" = display-message ]; then + case "$*" in + *"#{pane_current_path}"*) + printf '%s\n' "$FM_TEST_READY_WT" + exit 0 + ;; + *"#{window_name}"*) + printf '%s\n' "fm-$FM_TEST_READY_ID" + exit 0 + ;; + *"#{pane_id}"*) + printf '%s\n' '%1' + exit 0 + ;; + *"#S"*) + printf '%s\n' firstmate + exit 0 + ;; + esac +fi +case "${1:-}" in + new-window) + case "$FM_TEST_READY_ID" in + ready-r1) printf '%s\n' '@17' ;; + ready-r2) printf '%s\n' '@18' ;; + *) exit 1 ;; + esac + exit 0 + ;; + list-windows|has-session|new-session|set-window-option|kill-window|send-keys) + exit 0 + ;; +esac +exit 0 +SH + +chmod +x "$FAKEBIN/gh-axi" "$FAKEBIN/gh" "$FAKEBIN/treehouse" "$FAKEBIN/tmux" + +fixture_cmd() { + FM_ROOT_OVERRIDE="$ROOT" \ + 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_TEST_COMPLETED_WT="$COMPLETED_WT" \ + FM_TEST_READY_WT="$ACTIVE_READY_WT" \ + FM_TEST_PROJECT="$PROJECT" \ + FM_TEST_HEAD_FILE="$HEAD_FILE" \ + FM_TEST_GH_AXI_LOG="$GH_AXI_LOG" \ + FM_TEST_TREEHOUSE_LOG="$TREEHOUSE_LOG" \ + FM_TEST_TMUX_LOG="$TMUX_LOG" \ + FM_TEST_READY_ID="$ACTIVE_READY_ID" \ + FM_TEST_REAL_GIT="$REAL_GIT" \ + TMUX="fake,1,0" \ + PATH="$FAKEBIN:$BASE_PATH" \ + "$@" +} + +write_ship_meta() { + local id=$1 worktree=$2 yolo=$3 mode=$4 + fm_write_meta "$HOME_DIR/state/$id.meta" \ + "window=firstmate:fm-$id" \ + "worktree=$worktree" \ + "project=$PROJECT" \ + "harness=opencode" \ + "kind=ship" \ + "treehouse_lease=1" \ + "mode=$mode" \ + "yolo=$yolo" \ + "model=default" \ + "effort=default" + chmod 0600 "$HOME_DIR/state/$id.meta" +} + +configured_capacity() { + local capacity rendered + rendered=$(fixture_cmd "$RENDER" --harness codex) + capacity=$(printf '%s\n' "$rendered" \ + | sed -n 's/.*configured capacity \([1-9][0-9]*\) .*/\1/p') + [ -n "$capacity" ] || { + echo "configured capacity unavailable" >&2 + return 1 + } + printf '%s\n' "$capacity" +} + +ordinary_lane_count() { + local count=0 kind meta + for meta in "$HOME_DIR"/state/*.meta; do + [ -f "$meta" ] || continue + kind=$(sed -n 's/^kind=//p' "$meta" | tail -1) + [ "$kind" = secondmate ] || count=$(( count + 1 )) + done + printf '%s\n' "$count" +} + +select_ready_fixture() { + ACTIVE_READY_ID=$1 + case "$1" in + ready-r1) ACTIVE_READY_WT=$READY_WT ;; + ready-r2) ACTIVE_READY_WT=$READY_WT2 ;; + *) return 1 ;; + esac +} + +refill_to_configured_capacity() { + local candidate capacity count out + capacity=$(configured_capacity) || return + for candidate in "$@"; do + count=$(ordinary_lane_count) + [ "$count" -lt "$capacity" ] || break + select_ready_fixture "$candidate" || return + out=$(fixture_cmd "$SPAWN" "$candidate" "$PROJECT" --harness opencode) \ + || return + printf '%s\n' "$out" + done + count=$(ordinary_lane_count) + [ "$count" -eq "$capacity" ] || { + printf 'refill-incomplete: running=%s capacity=%s\n' "$count" "$capacity" >&2 + return 5 + } +} + +closeout_with_standing_authority() { + local id=$1 url=$2 meta="$HOME_DIR/state/$1.meta" kind mode status yolo + shift 2 + kind=$(sed -n 's/^kind=//p' "$meta" | tail -1) + mode=$(sed -n 's/^mode=//p' "$meta" | tail -1) + yolo=$(sed -n 's/^yolo=//p' "$meta" | tail -1) + status=$(tail -1 "$HOME_DIR/state/$id.status" 2>/dev/null || true) + if [ "$kind" != ship ]; then + printf 'ambiguous-or-incomplete: %s\n' "$id" + return 4 + fi + case "$mode" in + local-only) + printf '%s\n' "$status" | grep -Fq 'done: local branch ready' || { + printf 'ambiguous-or-incomplete: %s\n' "$id" + return 4 + } + ;; + no-mistakes|direct-PR) + printf '%s\n' "$status" | grep -Fq 'done: PR checks green' || { + printf 'ambiguous-or-incomplete: %s\n' "$id" + return 4 + } + ;; + *) + printf 'ambiguous-mode: %s\n' "$id" + return 4 + ;; + esac + if [ "$yolo" != on ]; then + printf 'captain-gated: %s\n' "$id" + return 3 + fi + case "$mode" in + local-only) + fixture_cmd "$LOCAL_MERGE" "$id" || return + ;; + no-mistakes|direct-PR) + fixture_cmd "$PR_MERGE" "$id" "$url" || return + ;; + esac + fixture_cmd "$TEARDOWN" "$id" || return + refill_to_configured_capacity "$@" +} + +test_complete_land_cleanup_and_visible_refill() { + local id=completed-y1 out rc + write_ship_meta "$id" "$COMPLETED_WT" on direct-PR + printf '%s\n' 'done: PR checks green; routine change complete' \ + > "$HOME_DIR/state/$id.status" + + set +e + out=$(closeout_with_standing_authority \ + "$id" https://github.com/example/project/pull/41 ready-r1 2>&1) + rc=$? + set -e + expect_code 0 "$rc" "routine yolo closeout should land and clean up" + assert_contains "$out" "teardown $id complete" \ + "successful closeout did not run the guarded teardown owner" + assert_grep 'pr merge 41 --repo example/project --squash' "$GH_AXI_LOG" \ + "successful closeout did not run the guarded merge owner" + [ "$("$REAL_GIT" --git-dir="$ORIGIN" rev-parse refs/heads/main)" \ + = "$(cat "$HEAD_FILE")" ] \ + || fail "successful PR closeout did not land work on the default branch" + assert_absent "$HOME_DIR/state/$id.meta" \ + "landed work retained task metadata after safe teardown" + assert_absent "$COMPLETED_WT" \ + "landed leased worktree was not returned" + assert_grep "return --force $COMPLETED_WT" "$TREEHOUSE_LOG" \ + "safe teardown did not return the exact landed worktree" + + assert_contains "$out" "spawned ready-r1" \ + "same-transaction ready refill was not surfaced as a visible worker" + assert_grep "worktree=$READY_WT" "$HOME_DIR/state/ready-r1.meta" \ + "refill did not record the isolated ready worktree" + assert_grep 'yolo=on' "$HOME_DIR/state/ready-r1.meta" \ + "refill did not inherit standing project authority" + assert_grep ' -n fm-ready-r1 ' "$TMUX_LOG" \ + "refill did not create a visible task window" + pass "routine PR work lands on default, cleans safely, and visibly refills in one transaction" +} + +test_local_only_uses_local_merge_owner_and_refills_to_capacity() { + local before id=local-y2 local_head out rc + git -C "$PROJECT" fetch -q origin main + git -C "$PROJECT" merge -q --ff-only origin/main + git -C "$PROJECT" worktree add -q -b fm/local-y2 "$LOCAL_WT" main + printf '%s\n' "local-only work" > "$LOCAL_WT/local.txt" + git -C "$LOCAL_WT" add local.txt + git -C "$LOCAL_WT" commit -qm "complete local-only work" + local_head=$(git -C "$LOCAL_WT" rev-parse HEAD) + git -C "$PROJECT" worktree add -q -b fm/ready-r2 "$READY_WT2" main + printf '%s\n' '- project [local-only +yolo] - lifecycle fixture (added 2026-07-27)' \ + > "$HOME_DIR/data/projects.md" + mkdir -p "$HOME_DIR/data/ready-r2" + printf '%s\n' '# Task' 'Run the second ready lifecycle fixture.' \ + > "$HOME_DIR/data/ready-r2/brief.md" + printf '%s\n' 2 > "$HOME_DIR/config/supervision-capacity" + write_ship_meta "$id" "$LOCAL_WT" on local-only + printf '%s\n' 'done: local branch ready; routine local change complete' \ + > "$HOME_DIR/state/$id.status" + before=$(wc -l < "$GH_AXI_LOG") + + set +e + out=$(closeout_with_standing_authority "$id" "" ready-r2 2>&1) + rc=$? + set -e + expect_code 0 "$rc" "local-only yolo closeout should land and refill" + assert_contains "$out" "merged fm/$id into local main" \ + "local-only closeout did not run the guarded local merge owner" + assert_contains "$out" "teardown $id complete" \ + "local-only closeout did not run guarded teardown" + assert_contains "$out" "spawned ready-r2" \ + "local-only closeout did not visibly refill in the same transaction" + [ "$(git -C "$PROJECT" rev-parse main)" = "$local_head" ] \ + || fail "local-only closeout did not land on local main" + [ "$(wc -l < "$GH_AXI_LOG")" -eq "$before" ] \ + || fail "local-only closeout called the PR merge owner" + assert_absent "$HOME_DIR/state/$id.meta" \ + "local-only landed work retained task metadata" + assert_absent "$LOCAL_WT" \ + "local-only landed worktree was not returned" + assert_grep "return --force $LOCAL_WT" "$TREEHOUSE_LOG" \ + "local-only safe teardown did not return the exact worktree" + assert_grep 'mode=local-only' "$HOME_DIR/state/ready-r2.meta" \ + "capacity refill did not inherit the local-only project mode" + assert_grep ' -n fm-ready-r2 ' "$TMUX_LOG" \ + "capacity refill did not create the second visible task window" + pass "local-only yolo work uses its guarded owner and refills to configured capacity" +} + +test_unlanded_work_is_preserved() { + local id=unlanded-u1 before out rc + git -C "$PROJECT" worktree add -q -b fm/unlanded-u1 "$UNLANDED_WT" main + printf '%s\n' "unlanded work" > "$UNLANDED_WT/unlanded.txt" + git -C "$UNLANDED_WT" add unlanded.txt + git -C "$UNLANDED_WT" commit -qm "unlanded routine work" + write_ship_meta "$id" "$UNLANDED_WT" on direct-PR + before=$(wc -l < "$TREEHOUSE_LOG") + + set +e + out=$(fixture_cmd "$TEARDOWN" "$id" 2>&1) + rc=$? + set -e + expect_code 1 "$rc" "unlanded teardown should refuse" + assert_contains "$out" "REFUSED:" \ + "unlanded teardown did not report its safety refusal" + assert_contains "$out" "not landed" \ + "unlanded teardown did not identify the failed proof" + assert_present "$HOME_DIR/state/$id.meta" \ + "unlanded teardown removed recovery metadata" + assert_present "$UNLANDED_WT" \ + "unlanded teardown removed the worktree" + [ "$(wc -l < "$TREEHOUSE_LOG")" -eq "$before" ] \ + || fail "unlanded teardown called treehouse return" + pass "unlanded work fails closed and remains recoverable" +} + +test_captain_gated_work_is_untouched() { + local id=gated-g1 before out rc + git -C "$PROJECT" worktree add -q -b fm/gated-g1 "$GATED_WT" main + printf '%s\n' "captain-gated work" > "$GATED_WT/gated.txt" + git -C "$GATED_WT" add gated.txt + git -C "$GATED_WT" commit -qm "captain-gated work" + write_ship_meta "$id" "$GATED_WT" off direct-PR + printf '%s\n' 'done: PR checks green; waiting for captain authority' \ + > "$HOME_DIR/state/$id.status" + before=$(wc -l < "$GH_AXI_LOG") + + set +e + out=$(closeout_with_standing_authority \ + "$id" https://github.com/example/project/pull/42 2>&1) + rc=$? + set -e + expect_code 3 "$rc" "captain-gated closeout should remain parked" + assert_contains "$out" "captain-gated: $id" \ + "captain-gated closeout did not identify the authority boundary" + assert_present "$HOME_DIR/state/$id.meta" \ + "captain-gated closeout removed task metadata" + assert_present "$GATED_WT" \ + "captain-gated closeout removed its worktree" + [ "$(wc -l < "$GH_AXI_LOG")" -eq "$before" ] \ + || fail "captain-gated closeout called the merge owner" + pass "captain-gated completed work remains parked without merge or cleanup" +} + +test_complete_land_cleanup_and_visible_refill +test_local_only_uses_local_merge_owner_and_refills_to_capacity +test_unlanded_work_is_preserved +test_captain_gated_work_is_untouched + +echo "# all direct lifecycle tests passed" From 738e189f9a744355648ad53cb23b69935a8d2ef1 Mon Sep 17 00:00:00 2001 From: quinnbot-ai Date: Tue, 28 Jul 2026 10:54:14 -0700 Subject: [PATCH 04/70] fix(herdr): serialize concurrent presentation recovery (#54) * fix(herdr): serialize concurrent presentation recovery * no-mistakes(review): Captain, serialize recovery waits across lock-owner waves * no-mistakes(document): Clarify Herdr recovery lock documentation --------- Co-authored-by: QuinnBot --- bin/fm-spawn.sh | 51 +++++++++++++++++++++------ docs/herdr-backend.md | 5 +-- docs/verification/runtime-backends.md | 36 +++++++++++++++++++ tests/fm-backend-herdr.test.sh | 42 ++++++++++++++++++++++ 4 files changed, 121 insertions(+), 13 deletions(-) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index fe659b54b6c..22bcbba5621 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -38,14 +38,17 @@ # authority, and every ambiguous recovery stays on the flat fallback after # duplicate-agent risk is independently absent. Treehouse allocation and task # metadata are unchanged. -# A clean projected create or exact resume makes one bounded attempt to hold -# the one session-scoped presentation-order lock (keyed by named session plus -# canonical socket, outside any home's state/) through launch handoff. Lock -# contention warns and falls back to the ordinary flat layout before any -# projection mutation. The exact response-derived new workspace is inserted -# immediately after its owning parent (firstmate or 2ndmate-) contiguous -# child block. Ordering never authorizes lifecycle cleanup, and any -# unavailable, ambiguous, or failed move warns while the spawn continues. +# A clean projected create makes one short bounded attempt to hold the +# session-scoped presentation-order lock (keyed by named session plus +# canonical socket, outside any home's state/) through launch handoff. +# Contention warns and falls back to the ordinary flat layout before any +# projection mutation. Exact recovery instead grants each distinct lock owner +# one longer bounded handoff budget and refuses the concurrent resume if the +# progressing owner wave does not drain. The exact response-derived new +# workspace is inserted immediately after its owning parent (firstmate or +# 2ndmate-) contiguous child block. Ordering never authorizes lifecycle +# cleanup, and any unavailable, ambiguous, or failed move warns while the +# spawn continues. # Every projected create, prune, and move captures and verifies the named # session's exact active workspace and tab. A detected focus change restores # only that exact tab id; an ambiguous pre-operation snapshot refuses the @@ -231,6 +234,12 @@ HERDR_PROJECTION_ABORT_TASK_PANE= HERDR_PROJECTION_ABORT_SEEDED_PANE= HERDR_PRESENTATION_ORDER_LOCK= HERDR_PRESENTATION_ORDER_LOCK_HELD=0 +# Fresh best-effort projection gives a contended session lock five seconds +# before falling back to the ordinary flat layout. +HERDR_PRESENTATION_ORDER_LOCK_ATTEMPTS=50 +# Exact recovery gives each distinct lock owner enough time for its permitted +# 60-second Treehouse handoff plus bounded projection and launch-settle work. +HERDR_PRESENTATION_RECOVERY_LOCK_ATTEMPTS=700 SPAWN_TASK_LOCK= SPAWN_TASK_LOCK_HELD=0 CONFIG_INHERIT_LOCK= @@ -317,16 +326,35 @@ trap spawn_abort_cleanup EXIT # is required so secondmate and primary spawns serialize against the # same session without writing any other home's state directory. spawn_herdr_presentation_order_lock_acquire() { - local session=${1:-} attempt lock_path + local session=${1:-} max_attempts=${2:-$HERDR_PRESENTATION_ORDER_LOCK_ATTEMPTS} + local reset_on_owner_change=${3:-0} attempt lock_path owner observed_owner [ -n "$session" ] || session=$(fm_backend_herdr_session) + case "$max_attempts" in + ''|*[!0-9]*) return 1 ;; + esac + [ "$max_attempts" -gt 0 ] || return 1 + case "$reset_on_owner_change" in + 0|1) ;; + *) return 1 ;; + esac lock_path=$(fm_backend_herdr_presentation_session_lock_path "$session") || return 1 HERDR_PRESENTATION_ORDER_LOCK="$lock_path" attempt=0 - while [ "$attempt" -lt 50 ]; do + observed_owner= + while [ "$attempt" -lt "$max_attempts" ]; do if fm_lock_try_acquire "$HERDR_PRESENTATION_ORDER_LOCK"; then HERDR_PRESENTATION_ORDER_LOCK_HELD=1 return 0 fi + if [ "$reset_on_owner_change" = 1 ]; then + owner=$(fm_lock_link_owner "$HERDR_PRESENTATION_ORDER_LOCK" 2>/dev/null || true) + if [ -n "$owner" ]; then + if [ -n "$observed_owner" ] && [ "$owner" != "$observed_owner" ]; then + attempt=0 + fi + observed_owner=$owner + fi + fi sleep 0.1 attempt=$((attempt + 1)) done @@ -979,7 +1007,8 @@ case "$BACKEND" in echo "error: herdr presentation recovery could not ensure its exact named session" >&2 exit 1 } - spawn_herdr_presentation_order_lock_acquire "$HERDR_SES" || { + spawn_herdr_presentation_order_lock_acquire \ + "$HERDR_SES" "$HERDR_PRESENTATION_RECOVERY_LOCK_ATTEMPTS" 1 || { echo "error: herdr presentation recovery could not acquire its session lock; refusing a concurrent resume" >&2 exit 1 } diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index 91047bcc6f3..dd02e92a069 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -77,8 +77,8 @@ Projected children are placed in one contiguous block immediately after their ow Existing legacy child labels may extend an already adjacent block read-only but are never renamed or migrated. A foreign, ambiguous, detached, or manually interleaved child makes ordering skip with a warning rather than rewriting the layout. -Ordering failure never fails the task spawn. -Firstmate does not retry, adopt, reuse, close, delete, or rename anything in response to an unavailable method, lock contention, ambiguous socket, lost response, failed move, or verification mismatch. +Fresh projected ordering failure never fails the task spawn. +On that fresh path, Firstmate does not retry projection, adopt, reuse, close, delete, or rename anything in response to an unavailable method, lock contention, ambiguous socket, lost response, failed move, or verification mismatch. The worker remains on the ordinary flat or Herdr-current-order path. Normal task metadata remains the sole endpoint authority after creation. @@ -89,6 +89,7 @@ If lock, snapshot, pane identity, or restoration is ambiguous, cleanup warns and Recovery is deliberately conservative and presentation-only. An existing journal suppresses another projected create. Before any recovery mutation, Firstmate holds both the task spawn lock and the named-session presentation lock. +Exact recovery gives each distinct session-lock owner one complete bounded Treehouse handoff budget, so a progressing recovery wave can drain while fresh best-effort projection retains its shorter fallback-to-flat lock budget. A same-identity version 2 binding may replace one exact agent-free restart husk in place only when the physical home, session, metadata endpoint, unique token match, workspace shape and labels, parent identity and placement, and non-target focus snapshot all agree. The replacement tab and pane are created and verified before the old pane is rechecked and closed, then the journal advances atomically to the replacement endpoint before metadata publication. The reclaim path never moves, closes, deletes, or renames a workspace and never touches a parent, sibling, captain, or foreign pane. diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index a711d84ee5b..2dd9101fdfc 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -193,6 +193,42 @@ ok - real Herdr lab: missing, renamed, and duplicate tokens trigger zero destruc ok - real Herdr lab validation completed on Herdr 0.7.5 with the default-session tripwire intact ``` +The concurrent recovery stress gate was repeated on 2026-07-27 against Herdr 0.7.3 protocol 16 after an isolated baseline reproduced a five-second session-lock refusal. +The causal boundary was a second exact recovery holding the shared presentation lock through its permitted 60-second Treehouse handoff, while the peer exhausted the fresh projection's shorter best-effort lock budget. +The recovery-only budget was extended per distinct lock owner so a progressing wave can drain without changing the fresh projection fallback. + +The following guarded command was run three times after the change: + +```sh +# HERDR_LAB_HELPER is the Firstmate-owned helper supplied by the guarded brief. +HERDR_LAB_SESSION=$("$HERDR_LAB_HELPER" name fm-pure-kun-herdr-validation) +trap '"$HERDR_LAB_HELPER" teardown "$HERDR_LAB_SESSION"' EXIT +"$HERDR_LAB_HELPER" provision "$HERDR_LAB_SESSION" +HERDR_LAB_HELPER="$HERDR_LAB_HELPER" \ + tests/fm-backend-herdr-presentation-e2e.test.sh +"$HERDR_LAB_HELPER" teardown "$HERDR_LAB_SESSION" +trap - EXIT +``` + +All three post-change runs observed: + +```text +ok - real Herdr lab: concurrent cross-home recoveries replace exact husks under one session lock with no focus drift +ok - real Herdr lab validation completed on Herdr 0.7.3 with the default-session tripwire intact +``` + +The deterministic counterfactual kept the fresh path at 50 attempts, modeled two consecutive 600-attempt predecessors with distinct lock ownership, and acquired the recovery path on attempt 1201: + +```sh +tests/fm-backend-herdr.test.sh +``` + +Observed regression: + +```text +ok - herdr presentation lock: recovery drains progressing predecessor waves while fresh projection stays best-effort +``` + The restored-shell session-start cleanup ran on 2026-07-24 against Herdr 0.7.5 protocol 17: ```sh diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index b2e980d9644..97182a90b0f 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -1241,6 +1241,47 @@ test_presentation_lock_malformed_socket_falls_back() { pass "herdr presentation lock: malformed socket metadata degrades to flat" } +test_presentation_recovery_lock_budget_covers_recovery_wave() { + local lock_source default_attempts recovery_attempts out + lock_source=$(sed -n '/^spawn_herdr_presentation_order_lock_acquire()/,/^spawn_herdr_presentation_order_lock_release()/p' "$ROOT/bin/fm-spawn.sh" | sed '$d') + default_attempts=$(sed -n 's/^HERDR_PRESENTATION_ORDER_LOCK_ATTEMPTS=//p' "$ROOT/bin/fm-spawn.sh") + recovery_attempts=$(sed -n 's/^HERDR_PRESENTATION_RECOVERY_LOCK_ATTEMPTS=//p' "$ROOT/bin/fm-spawn.sh") + out=$(LOCK_SOURCE="$lock_source" DEFAULT_ATTEMPTS="$default_attempts" RECOVERY_ATTEMPTS="$recovery_attempts" \ + bash -c ' + HERDR_PRESENTATION_ORDER_LOCK= + HERDR_PRESENTATION_ORDER_LOCK_HELD=0 + eval "$LOCK_SOURCE" + fm_backend_herdr_presentation_session_lock_path() { printf "%s" /tmp/fm-herdr-budget.lock; } + sleep() { :; } + tries=0 + owner= + fm_lock_try_acquire() { + tries=$((tries + 1)) + if [ "$tries" -le 600 ]; then + owner=/tmp/fm-herdr-budget.lock.owner.first + return 1 + fi + if [ "$tries" -le 1200 ]; then + owner=/tmp/fm-herdr-budget.lock.owner.second + return 1 + fi + return 0 + } + fm_lock_link_owner() { printf "%s\n" "$owner"; } + if spawn_herdr_presentation_order_lock_acquire fmtest "$DEFAULT_ATTEMPTS"; then + exit 1 + fi + printf "%s " "$tries" + tries=0 + owner= + spawn_herdr_presentation_order_lock_acquire fmtest "$RECOVERY_ATTEMPTS" 1 || exit 1 + printf "%s %s" "$tries" "$HERDR_PRESENTATION_ORDER_LOCK_HELD" + ') + [ "$out" = "50 1201 1" ] \ + || fail "recovery lock budget did not drain two complete predecessor handoffs: $out" + pass "herdr presentation lock: recovery drains progressing predecessor waves while fresh projection stays best-effort" +} + test_projection_order_rejects_malformed_socket() { local dir log resp fb mover out status dir="$TMP_ROOT/projection-order-malformed-socket"; mkdir -p "$dir/responses" @@ -3026,6 +3067,7 @@ test_projection_order_missing_parent_is_read_only test_presentation_session_lock_path_is_shared_across_homes test_presentation_session_lock_path_rejects_malformed_socket test_presentation_lock_malformed_socket_falls_back +test_presentation_recovery_lock_budget_covers_recovery_wave test_projection_order_rejects_malformed_socket test_presentation_lock_insecure_namespace_falls_back test_spawn_task_lock_covers_all_backend_creation_and_metadata_publication From 0197b584ea7bbdc00dbc8ca514e807f8672b90b7 Mon Sep 17 00:00:00 2001 From: quinnbot-ai Date: Tue, 28 Jul 2026 10:54:19 -0700 Subject: [PATCH 05/70] fix(teardown): recognize content-landed local work (#55) * fix(teardown): recognize content-landed local work Add the conservative zero-context proof for histories that preserve the complete task outcome without preserving ancestry or exact patch identity, and take the branch ref as an argument so the exact-tree proof runs first. When later default-branch edits make that 3-way merge conflict, the fallback accepts only when the task delta cannot still be applied forward and can be applied completely in reverse against the default tree. Dirty and incomplete task content stay refused. Ported from the pure-Kun reconciliation onto the upstream teardown owner. * no-mistakes(review): Harden teardown content-landed proof boundaries * no-mistakes(review): Reject relocated teardown content proof hunks * no-mistakes(document): Document hardened teardown content-landed proof --------- Co-authored-by: QuinnBot --- bin/fm-teardown.sh | 132 ++++++++++++--- tests/fm-teardown.test.sh | 332 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 431 insertions(+), 33 deletions(-) diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 550a09a39a0..327d61654a5 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -19,8 +19,12 @@ # up a merged PR whose head branch matches the worktree's branch, fetching its head # via refs/pull//head when the branch itself was deleted. So a missing pr= never # by itself causes a false refusal of landed work. -# A gh lookup error falls back to the content check; if that is also inconclusive, -# teardown refuses rather than risk discarding unlanded work. +# A gh lookup error falls back to the content checks; if they are also inconclusive, +# teardown refuses rather than risk discarding unlanded work. The exact-tree proof +# runs first. When later default-branch edits make its 3-way merge conflict, a +# conservative zero-context proof accepts only when the unique merge-base task delta +# cannot still be applied forward and can be applied completely in reverse at its +# original task locations against the default tree. # Uncommitted changes are never landed. # local-only projects additionally accept work merged into the local default # branch (firstmate performs that merge after configured approval) as a fallback @@ -383,40 +387,119 @@ pr_is_merged() { unpushed_patches_are_in_pr_head "$head" } -# Is the branch's content already present in the up-to-date default branch? Fetches -# first, then 3-way merges the default branch with HEAD: when HEAD introduces nothing -# the default branch does not already contain (e.g. its change landed via squash) the -# merged tree equals the default branch's tree. This isolates branch-only changes, so +# Is the branch's content already present in the resolved default branch? A 3-way +# merge of that ref with HEAD has the default tree when HEAD introduces nothing the +# default branch does not already contain. This isolates branch-only changes, so # unrelated commits the default branch gained past the merge-base do not count as -# "added". Returns non-zero when inconclusive (no default ref, or a merge conflict), -# so the caller refuses rather than guesses. +# "added". Returns 0 for an exact match, 2 for a merge conflict, and 1 otherwise. content_in_default() { - local name ref default_tree merged_tree - name=$(default_branch) || return 1 - if git -C "$WT" remote get-url origin >/dev/null 2>&1; then - git -C "$WT" fetch --quiet origin "+refs/heads/$name:refs/remotes/origin/$name" >/dev/null 2>&1 || return 1 - ref="refs/remotes/origin/$name" - elif git -C "$WT" rev-parse --quiet --verify "refs/heads/$name" >/dev/null 2>&1; then - ref="refs/heads/$name" + local ref=$1 default_tree merged_tree merge_rc + [ -n "$ref" ] || return 1 + default_tree=$(git -C "$WT" rev-parse --quiet --verify "$ref^{tree}" 2>/dev/null) || return 1 + [ -n "$default_tree" ] || return 1 + if merged_tree=$(git -C "$WT" merge-tree --write-tree "$ref" HEAD 2>/dev/null); then + : else + merge_rc=$? + [ "$merge_rc" -eq 1 ] && return 2 return 1 fi - default_tree=$(git -C "$WT" rev-parse --quiet --verify "$ref^{tree}" 2>/dev/null) || return 1 - [ -n "$default_tree" ] || return 1 - merged_tree=$(git -C "$WT" merge-tree --write-tree "$ref" HEAD 2>/dev/null) || return 1 merged_tree=$(printf '%s\n' "$merged_tree" | head -1) [ "$merged_tree" = "$default_tree" ] } +unique_merge_base() { + local left=$1 right=$2 bases + bases=$(git -C "$WT" merge-base --all "$left" "$right" 2>/dev/null) || return 1 + [ -n "$bases" ] || return 1 + case "$bases" in + *$'\n'*) return 1 ;; + esac + printf '%s\n' "$bases" +} + +reverse_patch_applies_at_task_locations() { + local proof_index=$1 proof_patch=$2 output + output=$(LC_ALL=C GIT_INDEX_FILE="$proof_index" git -C "$WT" apply \ + --cached --reverse --check --verbose --unidiff-zero "$proof_patch" 2>&1) || return 1 + [[ ! "$output" =~ offset[[:space:]]-?[1-9][0-9]*[[:space:]]lines? ]] +} + +# Does the task's complete net delta already exist in an evolved default tree even +# though later edits made the 3-way proof above conflict? Build a private temporary +# index at the authoritative default ref, then require both sides of the proof: +# the task delta must not still apply forward, and its exact zero-context form must +# apply completely in reverse at its original task locations. Any unsupported diff, +# ambiguous application, relocated hunk, or missing task hunk fails closed. +content_delta_in_default() { + local ref=$1 base proof_dir proof_index proof_patch rc=1 + [ -n "$ref" ] || return 1 + base=$(unique_merge_base "$ref" HEAD) || return 1 + if git -C "$WT" diff --quiet "$base" HEAD -- 2>/dev/null; then + return 0 + fi + + proof_dir=$(mktemp -d "${TMPDIR:-/tmp}/fm-teardown-content.XXXXXX") || return 1 + proof_index="$proof_dir/index" + proof_patch="$proof_dir/task.patch" + if git -C "$WT" diff --no-ext-diff --no-textconv --binary --full-index \ + --no-renames --unified=0 "$base" HEAD -- > "$proof_patch" 2>/dev/null \ + && GIT_INDEX_FILE="$proof_index" git -C "$WT" read-tree "$ref" 2>/dev/null \ + && ! GIT_INDEX_FILE="$proof_index" git -C "$WT" apply --cached --check \ + --unidiff-zero "$proof_patch" >/dev/null 2>&1 \ + && reverse_patch_applies_at_task_locations "$proof_index" "$proof_patch"; then + rc=0 + fi + rm -f "$proof_index" "$proof_index.lock" "$proof_patch" + rmdir "$proof_dir" 2>/dev/null || true + return "$rc" +} + +content_landed_in_ref() { + local ref=$1 content_rc + if content_in_default "$ref"; then + return 0 + else + content_rc=$? + fi + [ "$content_rc" -eq 2 ] || return 1 + content_delta_in_default "$ref" +} + +# Local-only cleanup has historically required a real task change before treating +# an unmerged commit as content-landed. Preserve that fail-closed boundary: an +# empty or unreadable task delta is not enough to retire the only local copy. +nonempty_content_landed_in_ref() { + local ref=$1 base diff_rc + base=$(git -C "$WT" merge-base "$ref" HEAD 2>/dev/null) || return 1 + if git -C "$WT" diff --quiet "$base" HEAD -- 2>/dev/null; then + return 1 + else + diff_rc=$? + [ "$diff_rc" -eq 1 ] || return 1 + fi + content_landed_in_ref "$ref" +} + # Has the worktree's committed work actually LANDED, though its commits are not # reachable from any remote-tracking branch? True when a merged PR proves the -# current local work is contained in the PR head, OR the content is already in the -# default branch (fallback, which also covers the no-PR and gh-error paths). False -# only for genuinely unlanded work. +# current local work is contained in the PR head, OR exact-tree or conservative +# net-delta proof shows the content is already in the default branch. False only for +# genuinely unlanded work or an inconclusive proof. work_is_landed() { - local branch=$1 + local branch=$1 name ref pr_is_merged "$branch" && return 0 - content_in_default + name=$(default_branch) || return 1 + if git -C "$WT" remote get-url origin >/dev/null 2>&1; then + git -C "$WT" fetch --quiet origin \ + "+refs/heads/$name:refs/remotes/origin/$name" >/dev/null 2>&1 || return 1 + ref="refs/remotes/origin/$name" + elif git -C "$WT" rev-parse --quiet --verify "refs/heads/$name" >/dev/null 2>&1; then + ref="refs/heads/$name" + else + return 1 + fi + content_landed_in_ref "$ref" } backlog_refresh_reminder() { @@ -703,7 +786,8 @@ validate_worktree_teardown_safety() { return 1 fi unmerged=$(printf '%s\n' "$unmerged_raw" | head -5) - if [ -n "$dirty" ] || [ -n "$unmerged" ]; then + if [ -n "$dirty" ] \ + || { [ -n "$unmerged" ] && ! nonempty_content_landed_in_ref "$DEFAULT"; }; then echo "REFUSED: local-only worktree $WT has work not yet merged into $DEFAULT and not on any remote." >&2 [ -n "$dirty" ] && echo "uncommitted changes present" >&2 [ -n "$unmerged" ] && printf 'commits not yet on %s:\n%s\n' "$DEFAULT" "$unmerged" >&2 diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index c3fa616ac8d..626301d32d1 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -7,7 +7,7 @@ # and GitHub reports a PR head that contains the current local work, or its content # is already in the up-to-date default branch. # -# Covers three fixes: +# Covers four landed-work and lock safeguards: # - local-only fork-remote: a fork IS a remote, so fork-pushed upstream- # contribution PRs are teardown-eligible (the pre-fix code false-refused them). # - squash-merge-then-delete-branch: the branch's own commits live nowhere on a @@ -15,6 +15,10 @@ # main. Reachability alone false-refused this common GitHub flow; the check now # recognizes a merged PR head containing the local work (or the content already # in main) as landed. +# - content-landed default evolution: later default-branch edits can conflict with +# the exact-tree proof even when the full task result landed through different +# commits. The conflict-only fallback accepts the unique merge-base task delta +# only when it cannot apply forward and reverses at its original task locations. # - teardown-lock-race: a killed crew process can leave a transient worktree # git index.lock that blocks teardown. The return path retries on the lock # error signature (even if the lock self-clears mid-check), then only removes a @@ -38,17 +42,22 @@ # (o) fm-pr-check rerun after HEAD moved -> no stale pr_head # (p) fm-pr-check when local HEAD lags -> record remote PR head # (q) no-mistakes + NO pr= recorded, PR discovered by branch -> ALLOW (yolo/no-CI merge) +# (r) local-only + content split across evolved main history -> ALLOW (content proof) +# (s) local-only + one task outcome line absent from main -> REFUSE (content safety) +# (t) local-only + clean partial task outcome on main -> REFUSE (exact proof) +# (u) local-only + ambiguous merge bases -> REFUSE (history safety) +# (v) local-only + relocated reverse hunk -> REFUSE (location safety) # # Also covers backlog teardown-lock-race: a git index.lock left in the worktree by a # killed crew process (bin/fm-teardown.sh's teardown_treehouse_return). -# (r) provably-stale index.lock (old mtime, no live holder) -> lock removed, ALLOW -# (s) index.lock with a live holder, any age -> lock kept, REFUSE -# (t) lsof error while checking index.lock -> lock kept, REFUSE -# (u) dirty worktree after stale lock cleanup -> lock removed, REFUSE -# (v) non-linked repo index.lock -> lock removed, ALLOW -# (w) index.lock mtime read failure -> lock kept, REFUSE -# (x) transient lock cleared after first failed return -> retry ALLOW -# (y) persistent lock (never clears, not provably stale) -> REFUSE loudly +# (w) provably-stale index.lock (old mtime, no live holder) -> lock removed, ALLOW +# (x) index.lock with a live holder, any age -> lock kept, REFUSE +# (y) lsof error while checking index.lock -> lock kept, REFUSE +# (z) dirty worktree after stale lock cleanup -> lock removed, REFUSE +# (aa) non-linked repo index.lock -> lock removed, ALLOW +# (ab) index.lock mtime read failure -> lock kept, REFUSE +# (ac) transient lock cleared after first failed return -> retry ALLOW +# (ad) persistent lock (never clears, not provably stale) -> REFUSE loudly set -u # shellcheck source=tests/lib.sh disable=SC1091 @@ -822,6 +831,306 @@ test_content_in_default_fallback_allows() { pass "worktree whose content already landed in the default branch is torn down (content fallback)" } +test_content_split_across_evolved_default_allows_cleanup_and_refill() { + local case_dir rc base_head branch_head main_head cherry + case_dir=$(make_case content-split-evolved) + write_meta "$case_dir" local-only ship + add_compatible_tasks_axi "$case_dir" + + wt_commit_file "$case_dir" outcome.txt 'before +old +after' "shared outcome baseline" + base_head=$(git -C "$case_dir/wt" rev-parse HEAD) + git -C "$case_dir/project" merge -q --ff-only "$base_head" + + wt_commit_file "$case_dir" outcome.txt 'before +alpha +bravo +charlie +delta +after' "task four-line outcome" + branch_head=$(git -C "$case_dir/wt" rev-parse HEAD) + printf '%s\n' "done: implementation complete" > "$case_dir/state/task-x1.status" + + printf '%s\n' 'BEFORE-LATER +alpha +bravo +after' > "$case_dir/project/outcome.txt" + git -C "$case_dir/project" add outcome.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t \ + commit -q -m "land first outcome half with context" + printf '%s\n' 'BEFORE-LATER +alpha +bravo +charlie +delta +AFTER-LATER' > "$case_dir/project/outcome.txt" + git -C "$case_dir/project" add outcome.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t \ + commit -q -m "land second outcome half and evolve context" + main_head=$(git -C "$case_dir/project" rev-parse main) + + ! git -C "$case_dir/wt" merge-base --is-ancestor "$branch_head" "$main_head" \ + || fail "content-split-evolved: task unexpectedly became an ancestor of main" + cherry=$(git -C "$case_dir/wt" cherry main fm/task-x1) + printf '%s\n' "$cherry" | grep -Eq '^\+ [0-9a-f]+$' \ + || fail "content-split-evolved: fixture unexpectedly became patch-equivalent: $cherry" + ! git -C "$case_dir/wt" merge-tree --write-tree main fm/task-x1 >/dev/null 2>&1 \ + || fail "content-split-evolved: fixture must diverge first at the 3-way content proof" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "content-split-evolved: fully landed task content should clean up" + assert_absent "$case_dir/state/task-x1.meta" \ + "content-split-evolved: cleanup left the completed worker lane occupied" + assert_absent "$case_dir/state/task-x1.status" \ + "content-split-evolved: cleanup retained the completed status" + assert_grep 'tasks-axi done task-x1' "$case_dir/stdout" \ + "content-split-evolved: completion did not emit the guarded landing transition" + assert_grep 'tasks-axi ready' "$case_dir/stdout" \ + "content-split-evolved: cleanup did not immediately request ready-work refill" + pass "complete -> content-landed proof -> cleanup -> ready-work refill accepts split non-patch-equivalent history" +} + +test_content_split_across_evolved_default_with_absent_change_refuses() { + local case_dir rc base_head + case_dir=$(make_case content-split-missing) + write_meta "$case_dir" local-only ship + add_compatible_tasks_axi "$case_dir" + + wt_commit_file "$case_dir" outcome.txt 'before +old +after' "shared outcome baseline" + base_head=$(git -C "$case_dir/wt" rev-parse HEAD) + git -C "$case_dir/project" merge -q --ff-only "$base_head" + + wt_commit_file "$case_dir" outcome.txt 'before +alpha +bravo +charlie +delta +after' "task four-line outcome" + printf '%s\n' "done: implementation complete" > "$case_dir/state/task-x1.status" + + printf '%s\n' 'BEFORE-LATER +alpha +bravo +delta +AFTER-LATER' > "$case_dir/project/outcome.txt" + git -C "$case_dir/project" add outcome.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t \ + commit -q -m "land incomplete outcome and evolve context" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "content-split-missing: absent task content must preserve the lane" + assert_grep 'REFUSED:' "$case_dir/stderr" \ + "content-split-missing: refusal did not report the guarded landing failure" + [ -f "$case_dir/state/task-x1.meta" ] \ + || fail "content-split-missing: cleanup discarded metadata for unlanded work" + [ -f "$case_dir/state/task-x1.status" ] \ + || fail "content-split-missing: cleanup discarded status for unlanded work" + assert_no_grep 'tasks-axi ready' "$case_dir/stdout" \ + "content-split-missing: refused cleanup incorrectly requested refill" + pass "content proof preserves the completed worker lane when one task outcome line is absent" +} + +test_clean_nonmatching_merge_does_not_use_delta_fallback() { + local case_dir rc base merged_tree default_tree proof_index proof_patch + case_dir=$(make_case content-clean-partial) + write_meta "$case_dir" local-only ship + + wt_commit_file "$case_dir" outcome.txt 'anchor-a +old-a +anchor-b +old-b +spare +landed' "shared outcome baseline" + base=$(git -C "$case_dir/wt" rev-parse HEAD) + git -C "$case_dir/project" merge -q --ff-only "$base" + + wt_commit_file "$case_dir" outcome.txt 'anchor-a +landed +anchor-b +landed +spare +landed' "task multi-hunk outcome" + + printf '%s\n' 'anchor-a +landed +anchor-b +old-b +spare +landed' > "$case_dir/project/outcome.txt" + git -C "$case_dir/project" add outcome.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t \ + commit -q -m "land only the first task hunk" + + merged_tree=$(git -C "$case_dir/wt" merge-tree --write-tree main HEAD) + merged_tree=$(printf '%s\n' "$merged_tree" | head -1) + default_tree=$(git -C "$case_dir/wt" rev-parse "main^{tree}") + [ "$merged_tree" != "$default_tree" ] \ + || fail "content-clean-partial: fixture unexpectedly matched the default tree" + + proof_index="$case_dir/proof.index" + proof_patch="$case_dir/task.patch" + git -C "$case_dir/wt" diff --no-ext-diff --no-textconv --binary --full-index \ + --no-renames --unified=0 "$base" HEAD -- > "$proof_patch" + GIT_INDEX_FILE="$proof_index" git -C "$case_dir/wt" read-tree main + ! GIT_INDEX_FILE="$proof_index" git -C "$case_dir/wt" apply --cached --check \ + --unidiff-zero "$proof_patch" >/dev/null 2>&1 \ + || fail "content-clean-partial: task delta unexpectedly still applies forward" + GIT_INDEX_FILE="$proof_index" git -C "$case_dir/wt" apply --cached --reverse --check \ + --unidiff-zero "$proof_patch" >/dev/null 2>&1 \ + || fail "content-clean-partial: fixture does not expose the unsafe fallback" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "content-clean-partial: a clean non-match must preserve the lane" + assert_grep 'REFUSED:' "$case_dir/stderr" \ + "content-clean-partial: refusal did not report the guarded landing failure" + [ -f "$case_dir/state/task-x1.meta" ] \ + || fail "content-clean-partial: cleanup discarded metadata for partial content" + pass "clean non-matching content proof does not invoke the conflict-only fallback" +} + +test_ambiguous_merge_bases_refuse_delta_fallback() { + local case_dir rc root left right bases base proof_index proof_patch + case_dir=$(make_case content-ambiguous-base) + write_meta "$case_dir" local-only ship + + wt_commit_file "$case_dir" outcome.txt old "shared outcome baseline" + root=$(git -C "$case_dir/wt" rev-parse HEAD) + git -C "$case_dir/project" merge -q --ff-only "$root" + + wt_commit_file "$case_dir" left.txt left "left history" + left=$(git -C "$case_dir/wt" rev-parse HEAD) + printf '%s\n' right > "$case_dir/project/right.txt" + git -C "$case_dir/project" add right.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t \ + commit -q -m "right history" + right=$(git -C "$case_dir/project" rev-parse HEAD) + + git -C "$case_dir/wt" -c user.email=t@t -c user.name=t \ + merge -q --no-ff "$right" -m "merge right into task" + git -C "$case_dir/project" -c user.email=t@t -c user.name=t \ + merge -q --no-ff "$left" -m "merge left into main" + + wt_commit_file "$case_dir" outcome.txt task "task outcome" + printf '%s\n' 'before +task +after' > "$case_dir/project/outcome.txt" + git -C "$case_dir/project" add outcome.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t \ + commit -q -m "land task outcome with later context" + + bases=$(git -C "$case_dir/wt" merge-base --all main HEAD) + expect_code 2 "$(printf '%s\n' "$bases" | grep -c .)" \ + "content-ambiguous-base: fixture must have two best merge bases" + ! git -C "$case_dir/wt" merge-tree --write-tree main HEAD >/dev/null 2>&1 \ + || fail "content-ambiguous-base: fixture must conflict at the exact-tree proof" + + base=$(git -C "$case_dir/wt" merge-base main HEAD) + proof_index="$case_dir/proof.index" + proof_patch="$case_dir/task.patch" + git -C "$case_dir/wt" diff --no-ext-diff --no-textconv --binary --full-index \ + --no-renames --unified=0 "$base" HEAD -- > "$proof_patch" + GIT_INDEX_FILE="$proof_index" git -C "$case_dir/wt" read-tree main + ! GIT_INDEX_FILE="$proof_index" git -C "$case_dir/wt" apply --cached --check \ + --unidiff-zero "$proof_patch" >/dev/null 2>&1 \ + || fail "content-ambiguous-base: arbitrary delta unexpectedly applies forward" + GIT_INDEX_FILE="$proof_index" git -C "$case_dir/wt" apply --cached --reverse --check \ + --unidiff-zero "$proof_patch" >/dev/null 2>&1 \ + || fail "content-ambiguous-base: fixture does not expose arbitrary-base acceptance" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "content-ambiguous-base: ambiguous history must preserve the lane" + assert_grep 'REFUSED:' "$case_dir/stderr" \ + "content-ambiguous-base: refusal did not report the guarded landing failure" + [ -f "$case_dir/state/task-x1.meta" ] \ + || fail "content-ambiguous-base: cleanup discarded metadata under ambiguous history" + pass "content delta proof fails closed when history has multiple merge bases" +} + +test_conflicting_merge_rejects_relocated_reverse_hunk() { + local case_dir rc base proof_index proof_patch proof_output + case_dir=$(make_case content-relocated-reverse) + write_meta "$case_dir" local-only ship + + wt_commit_file "$case_dir" outcome.txt 'before +old +after +separator +missing-old +spare +landed' "shared outcome baseline" + base=$(git -C "$case_dir/wt" rev-parse HEAD) + git -C "$case_dir/project" merge -q --ff-only "$base" + + wt_commit_file "$case_dir" outcome.txt 'before +alpha +bravo +after +separator +landed +spare +landed' "task multi-hunk outcome" + + printf '%s\n' 'BEFORE-LATER +alpha +bravo +AFTER-LATER +separator +missing-old +spare +landed' > "$case_dir/project/outcome.txt" + git -C "$case_dir/project" add outcome.txt + git -C "$case_dir/project" -c user.email=t@t -c user.name=t \ + commit -q -m "land incomplete outcome and evolve conflict context" + + ! git -C "$case_dir/wt" merge-tree --write-tree main HEAD >/dev/null 2>&1 \ + || fail "content-relocated-reverse: fixture must conflict at the exact-tree proof" + + proof_index="$case_dir/proof.index" + proof_patch="$case_dir/task.patch" + git -C "$case_dir/wt" diff --no-ext-diff --no-textconv --binary --full-index \ + --no-renames --unified=0 "$base" HEAD -- > "$proof_patch" + GIT_INDEX_FILE="$proof_index" git -C "$case_dir/wt" read-tree main + ! GIT_INDEX_FILE="$proof_index" git -C "$case_dir/wt" apply --cached --check \ + --unidiff-zero "$proof_patch" >/dev/null 2>&1 \ + || fail "content-relocated-reverse: task delta unexpectedly still applies forward" + proof_output=$(LC_ALL=C GIT_INDEX_FILE="$proof_index" git -C "$case_dir/wt" apply \ + --cached --reverse --check --verbose --unidiff-zero "$proof_patch" 2>&1) \ + || fail "content-relocated-reverse: fixture does not expose reverse relocation" + [[ "$proof_output" =~ offset[[:space:]]-?[1-9][0-9]*[[:space:]]lines? ]] \ + || fail "content-relocated-reverse: reverse proof unexpectedly stayed at task locations" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "content-relocated-reverse: relocated content must preserve the lane" + assert_grep 'REFUSED:' "$case_dir/stderr" \ + "content-relocated-reverse: refusal did not report the guarded landing failure" + [ -f "$case_dir/state/task-x1.meta" ] \ + || fail "content-relocated-reverse: cleanup discarded metadata for incomplete content" + pass "conflict fallback rejects reverse hunks relocated away from task locations" +} + test_content_fallback_refreshes_stale_origin_ref() { local case_dir rc case_dir=$(make_case content-stale-ref) @@ -1390,6 +1699,11 @@ test_merged_pr_with_later_local_commit_refuses test_pr_check_does_not_refresh_stale_pr_head test_pr_check_records_remote_head_when_local_lags test_content_in_default_fallback_allows +test_content_split_across_evolved_default_allows_cleanup_and_refill +test_content_split_across_evolved_default_with_absent_change_refuses +test_clean_nonmatching_merge_does_not_use_delta_fallback +test_ambiguous_merge_bases_refuse_delta_fallback +test_conflicting_merge_rejects_relocated_reverse_hunk test_content_fallback_refreshes_stale_origin_ref test_dirty_worktree_refuses test_gh_error_and_content_absent_refuses From 4d10def1a529b3cbe83c213c9dd82cfa67a60ebf Mon Sep 17 00:00:00 2001 From: quinnbot-ai Date: Tue, 28 Jul 2026 10:54:24 -0700 Subject: [PATCH 06/70] fix(watch): fence watcher arm and cycles to the session-lock owner (#56) * fix(watch): fence watcher arm and cycles to the session-lock owner An orphaned watcher from a dead harness session retained the supervision singleton (fresh beacon, held watch.lock) while a new harness session held state/.lock, so completed-crewmate wakes were absorbed into a dead stdout and the active session was never woken. Add fm_session_owner_fence to bin/fm-session-lock-lib.sh, the one owner of the may-this-process-supervise decision, and enforce it at watcher startup, every watcher cycle, arm start, and every arm attach iteration. A live foreign harness holding state/.lock fences supervision with a typed watcher: FAILED line; no lock, a malformed lock, a dead or non-harness holder, the owning session's own descendants, and away mode all pass, so recovery arming, adapter arms, and the afk daemon are unchanged. tests/fm-session-owner-fence.test.sh reproduces the cross-harness takeover end to end and covers the refusal, restart, dead-owner, and afk paths. * no-mistakes(review): Captain, harden session-owner fencing against handoff races * no-mistakes(document): Document supervision session-owner fence --------- Co-authored-by: QuinnBot --- bin/fm-session-lock-lib.sh | 81 ++++- bin/fm-watch-arm.sh | 59 +++- bin/fm-watch.sh | 23 ++ docs/architecture.md | 1 + docs/verification/supervision.md | 3 + docs/watcher-continuity.md | 9 + tests/fm-session-owner-fence.test.sh | 437 +++++++++++++++++++++++++++ 7 files changed, 611 insertions(+), 2 deletions(-) create mode 100755 tests/fm-session-owner-fence.test.sh diff --git a/bin/fm-session-lock-lib.sh b/bin/fm-session-lock-lib.sh index 90303cda1c9..740a7a31e36 100644 --- a/bin/fm-session-lock-lib.sh +++ b/bin/fm-session-lock-lib.sh @@ -5,7 +5,10 @@ # lock, and does the current process descend from that same harness?" decision. # bin/fm-lock.sh uses it to acquire and inspect state/.lock; # bin/fm-claude-stop-autoarm.sh uses it to prove a Stop hook fires inside the -# lock-owning primary session before it may arm or rewake. +# lock-owning primary session before it may arm or rewake; and the watcher core +# (bin/fm-watch.sh, bin/fm-watch-arm.sh) uses fm_session_owner_fence below so +# supervision can never be started, retained, or attached by a process from a +# session that no longer owns the home. # This file is sourced by scripts and has no side effects on source. # Known harness command names; extend when a new adapter is verified. @@ -62,3 +65,79 @@ fm_session_lock_owned_by_self() { my_pid=$(fm_harness_ancestry_pid) || return 1 [ "$my_pid" = "$lock_pid" ] } + +# Memoized-per-process harness-ancestor resolution. The ancestry walk shells out +# to ps several times, and the owner fence below runs once per watcher cycle, so +# resolve the harness pid and its stable process identity once and reuse both. +# Call this in the CURRENT shell (never inside a command substitution, which +# would discard the memo) and read FM_SESSION_SELF_HARNESS_PID and +# FM_SESSION_SELF_HARNESS_IDENTITY after it returns; an unresolvable ancestry +# leaves both empty. +FM_SESSION_SELF_HARNESS_PID= +FM_SESSION_SELF_HARNESS_IDENTITY= +FM_SESSION_SELF_HARNESS_RESOLVED=0 +fm_session_process_identity() { + local pid=$1 out + if [ "$(type -t fm_pid_identity 2>/dev/null)" = function ]; then + fm_pid_identity "$pid" + return + fi + out=$(LC_ALL=C ps -p "$pid" -o lstart= -o command= 2>/dev/null) || return 1 + [ -n "$out" ] || return 1 + printf '%s\n' "$out" | sed 's/^[[:space:]]*//' +} + +fm_session_self_harness_resolve() { + local pid identity + [ "$FM_SESSION_SELF_HARNESS_RESOLVED" -eq 1 ] && return 0 + pid=$(fm_harness_ancestry_pid 2>/dev/null || true) + identity= + if [ -n "$pid" ]; then + identity=$(fm_session_process_identity "$pid" 2>/dev/null || true) + fi + if [ -n "$identity" ]; then + FM_SESSION_SELF_HARNESS_PID=$pid + FM_SESSION_SELF_HARNESS_IDENTITY=$identity + fi + FM_SESSION_SELF_HARNESS_RESOLVED=1 +} + +# Supervision session-owner fence: ONE owner of the "may this process start or +# keep supervising this home" decision for the watcher core (bin/fm-watch.sh, +# bin/fm-watch-arm.sh) and any future supervision entry point. Returns 0 when +# supervision may proceed and 1 when it is fenced because the home's session +# lock names a live verified-harness process this process does not descend +# from; on 1, FM_SESSION_OWNER_FOREIGN_PID names that foreign owner. +# Pass cases keep every supported supervision path working: +# - no or malformed state/.lock: tests, manual runs, a home between sessions; +# - state/.afk present: the away-mode daemon owns supervision and is not +# harness-descended; +# - the lock pid is this process's own harness ancestor: the owning session +# (Codex checkpoints, Claude Stop-hook and recovery arms, Pi/OpenCode +# adapter spawns, Grok background arms all descend from it); +# - the lock pid is dead or not a harness: a stale lock, so recovery +# proceeds unchanged and bin/fm-lock.sh's takeover path stays authoritative. +FM_SESSION_OWNER_FOREIGN_PID= +fm_session_owner_fence() { # + local state=$1 lock_pid lock_identity + FM_SESSION_OWNER_FOREIGN_PID= + lock_pid=$(cat "$state/.lock" 2>/dev/null || true) + case "$lock_pid" in + ''|*[!0-9]*) return 0 ;; + esac + if [ -e "$state/.afk" ]; then + return 0 + fi + fm_session_self_harness_resolve + if [ "$lock_pid" = "$FM_SESSION_SELF_HARNESS_PID" ] && [ -n "$FM_SESSION_SELF_HARNESS_IDENTITY" ]; then + lock_identity=$(fm_session_process_identity "$lock_pid" 2>/dev/null || true) + if [ "$lock_identity" = "$FM_SESSION_SELF_HARNESS_IDENTITY" ]; then + return 0 + fi + fi + fm_harness_pid_alive "$lock_pid" || return 0 + # Consumed by callers (bin/fm-watch.sh, bin/fm-watch-arm.sh) after a fenced return. + # shellcheck disable=SC2034 + FM_SESSION_OWNER_FOREIGN_PID=$lock_pid + return 1 +} diff --git a/bin/fm-watch-arm.sh b/bin/fm-watch-arm.sh index 0a783ce2874..0790b04a4e6 100755 --- a/bin/fm-watch-arm.sh +++ b/bin/fm-watch-arm.sh @@ -61,6 +61,8 @@ set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=bin/fm-wake-lib.sh . "$SCRIPT_DIR/fm-wake-lib.sh" +# shellcheck source=bin/fm-session-lock-lib.sh +. "$SCRIPT_DIR/fm-session-lock-lib.sh" WATCH="$SCRIPT_DIR/fm-watch.sh" WATCH_LOCK="$STATE/.watch.lock" @@ -76,9 +78,19 @@ CYCLE_LOG_LOCK="$STATE/.watch-cycle-exits.lock" CYCLE_LOG_MAX_BYTES=${FM_WATCH_CYCLE_LOG_MAX_BYTES:-262144} CYCLE_LOG_KEEP_LINES=${FM_WATCH_CYCLE_LOG_KEEP_LINES:-1000} ARM_PID=${BASHPID:-$$} +SESSION_CLAIM_LOCK="$STATE/.lock.acquire" +SESSION_CLAIM_LOCK_HELD=0 case "$CYCLE_LOG_MAX_BYTES" in ''|*[!0-9]*|0) CYCLE_LOG_MAX_BYTES=262144 ;; esac case "$CYCLE_LOG_KEEP_LINES" in ''|*[!0-9]*|0) CYCLE_LOG_KEEP_LINES=1000 ;; esac +release_session_claim_lock() { + if [ "$SESSION_CLAIM_LOCK_HELD" -eq 1 ]; then + fm_lock_release "$SESSION_CLAIM_LOCK" + SESSION_CLAIM_LOCK_HELD=0 + fi +} +trap release_session_claim_lock EXIT + # The lifecycle ledger is diagnostic evidence, not a supervision dependency. # Writes are bounded and best-effort so an observability failure cannot stall an # otherwise healthy watcher cycle. @@ -255,12 +267,27 @@ fail_unexplained_cycle() { return 1 } +# Session-owner fence (bin/fm-session-lock-lib.sh): an arm whose process does +# not descend from the harness session holding this home's state/.lock must not +# start, restart, or stay attached to supervision here. This is what stops an +# orphaned arm from a previous harness session retaining or reacquiring the +# watcher singleton after another live harness session takes over the home. +require_session_owner() { # + fm_session_owner_fence "$STATE" && return 0 + echo "watcher: FAILED - session-owner fence: home session lock is held by live harness pid $FM_SESSION_OWNER_FOREIGN_PID outside this process's session; $1" + return 1 +} + # Stay alive across identity-matched healthy holders. If one cycle ends, attach # to a verified successor. With no successor, fail loudly instead of returning a # clean empty completion that an adapter could mistake for a no-op. attach_and_wait() { local attached_pid=$1 while :; do + if ! require_session_owner "detaching"; then + cycle_log_append unknown unknown session-owner-fenced none + return 1 + fi if healthy_watcher; then if [ "$HEALTHY_PID" != "$attached_pid" ]; then cycle_log_append unknown unknown lock-replaced "attached:$HEALTHY_PID" @@ -272,6 +299,10 @@ attach_and_wait() { continue fi if wait_for_healthy_successor; then + if ! require_session_owner "not attaching to successor"; then + cycle_log_append unknown unknown session-owner-fenced none + return 1 + fi cycle_log_append unknown unknown attached-cycle-ended "attached:$HEALTHY_PID" attached_pid=$HEALTHY_PID cycle_begin "$attached_pid" attached @@ -325,7 +356,20 @@ case "${1:-}" in *) echo "usage: $(basename "$0") [--restart]" >&2; exit 2 ;; esac -if [ "$mode" = restart ]; then +# Fence before anything else: a non-owner arm must never stop the owner's +# watcher (--restart), attach to it, or start a competitor. The owned-child +# path needs no further arm-side check because the child watcher re-fences at +# startup and per cycle, and this arm propagates its typed nonzero failure. +require_session_owner "not arming" || exit 1 + +restart_watcher_if_owned() { + local lock_pid i + fm_lock_acquire_wait "$SESSION_CLAIM_LOCK" + SESSION_CLAIM_LOCK_HELD=1 + if ! require_session_owner "not restarting"; then + release_session_claim_lock + return 1 + fi # Home-scoped stop: only the watcher pid recorded in THIS home's lock. lock_pid=$(cat "$WATCH_LOCK/pid" 2>/dev/null || true) if fm_pid_alive "$lock_pid"; then @@ -343,6 +387,11 @@ if [ "$mode" = restart ]; then clear_stale_recorded_watcher_lock fi fi + release_session_claim_lock +} + +if [ "$mode" = restart ]; then + restart_watcher_if_owned || exit 1 fi # If a genuinely live+fresh watcher already holds the lock, do not start a second @@ -413,6 +462,14 @@ owned_child_finished() { if [ "$rc" -eq 0 ]; then if wait_for_healthy_successor; then + if ! require_session_owner "not attaching to successor"; then + cycle_log_append "$rc" "$signal" session-owner-fenced none + print_watch_output "$child_out" + rm -f "$child_out" 2>/dev/null || true + child= + child_out= + return 1 + fi cycle_log_append "$rc" "$signal" unexpected-clean-exit "attached:$HEALTHY_PID" print_watch_output "$child_out" rm -f "$child_out" 2>/dev/null || true diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index b7006e6362a..689ac45626f 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -66,6 +66,10 @@ mkdir -p "$STATE" # cheap when no records exist and never scrapes secondmate conversation. # shellcheck source=bin/fm-pending-reply-lib.sh . "$SCRIPT_DIR/fm-pending-reply-lib.sh" +# Session-owner fence (fm_session_owner_fence): supervision here must belong to +# the harness session holding state/.lock. Sourcing is side-effect free. +# shellcheck source=bin/fm-session-lock-lib.sh +. "$SCRIPT_DIR/fm-session-lock-lib.sh" WATCH_LOCK="$STATE/.watch.lock" WATCH_PATH="$SCRIPT_DIR/fm-watch.sh" @@ -623,6 +627,16 @@ if [ "${BASH_SOURCE[0]}" != "$0" ]; then return 0 fi +# Session-owner fence, at startup: a watcher whose process does not descend +# from the harness session holding this home's state/.lock must not start. +# Without this, an arm surviving from a previous harness session could take the +# singleton and absorb wakes no live conversation ever reads. The typed FAILED +# line goes to stdout so fm-watch-arm.sh and fm-watch-checkpoint.sh propagate it. +if ! fm_session_owner_fence "$STATE"; then + echo "watcher: FAILED - session-owner fence: home session lock is held by live harness pid $FM_SESSION_OWNER_FOREIGN_PID outside this process's session; not starting" + exit 1 +fi + # Before acquiring the watcher lock or enumerating any runnable check, replace # or quarantine checks created by older versions. The migration compares bytes # and reads data only; it never invokes legacy check files through Bash. @@ -689,6 +703,15 @@ while :; do exit 0 fi + # Session-owner fence, per cycle: if the home's session lock moved to another + # live harness session (a harness transition happened under this watcher), + # stand down so the new session's own supervision can take the singleton + # within one poll. The EXIT trap releases this process's lock on the way out. + if ! fm_session_owner_fence "$STATE"; then + echo "watcher: FAILED - session-owner fence: home session lock moved to live harness pid $FM_SESSION_OWNER_FOREIGN_PID outside this process's session; standing down" + exit 1 + fi + # Liveness beacon for fm-guard.sh: a fresh mtime here means a watcher is # alive. Supervision scripts warn when this goes stale with tasks in flight. touch "$STATE/.last-watcher-beat" diff --git a/docs/architecture.md b/docs/architecture.md index da1519a0d44..dd22068be05 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -57,6 +57,7 @@ At session start, `bin/fm-session-start.sh` emits exactly one primary-harness su 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 shared session-owner fence binds normal-mode watcher and arm operations to the live harness session that owns the home; [`watcher-continuity.md`](watcher-continuity.md#session-owner-fence) owns the exact handoff, recovery, and away-mode boundary. 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. diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index fbd1d5a254a..bd9d250a2bc 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -176,11 +176,14 @@ Observed guarantee: after ordinary `session_shutdown` for `/new`, `/resume`, and 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. +`tests/fm-session-owner-fence.test.sh` verifies direct watcher and arm refusal under a foreign live owner, serialized restart isolation, process-identity reuse rejection, attached-successor rechecks, cross-harness handoff, dead-owner recovery, and the away-mode exemption. + Deterministic entry points: ```sh tests/fm-pi-watch-extension.test.sh tests/fm-pi-primary-types.test.sh +tests/fm-session-owner-fence.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 52b3a9eec3a..0a46cba222e 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -15,6 +15,15 @@ A numeric session-lock owner that fails the shared `fm_harness_pid_alive` predic The stale-owner claim occurs only after the existing AFK and supervision-need gates pass. While supervision is still needed and away mode remains inactive, an actionable close or typed failure wakes the idle session through exit 2. +## Session-owner fence + +Normal-mode supervision belongs to the live verified harness process recorded in the home's `state/.lock`. +The shared fence in `bin/fm-session-lock-lib.sh` is enforced before watcher startup and every watcher cycle, and before an arm may start, restart, retain, or attach to a watcher. +If a different live harness owns the session lock, the old watcher stands down within one poll and releases the singleton, while arm operations return a typed nonzero `watcher: FAILED - session-owner fence: ...` result without disturbing the new owner's watcher. +Restart serializes the ownership recheck with watcher termination through `state/.lock.acquire`, so a session handoff cannot race a stale arm into stopping the successor session's watcher. +Missing, malformed, dead-owner, and non-harness session locks remain recovery cases rather than fences, and stable process identity prevents a reused owner PID from being trusted. +While `state/.afk` exists, the fence is intentionally bypassed because the away-mode daemon owns supervision without descending from the interactive harness. + ## Actionable wake ordering After an actionable Pi or OpenCode child close, the adapter starts and verifies one singleton successor before it delivers the original wake. diff --git a/tests/fm-session-owner-fence.test.sh b/tests/fm-session-owner-fence.test.sh new file mode 100755 index 00000000000..17892527338 --- /dev/null +++ b/tests/fm-session-owner-fence.test.sh @@ -0,0 +1,437 @@ +#!/usr/bin/env bash +# tests/fm-session-owner-fence.test.sh - supervision session-owner fence +# (bin/fm-session-lock-lib.sh fm_session_owner_fence, enforced by +# bin/fm-watch.sh and bin/fm-watch-arm.sh). +# +# Reproduces the cross-harness ownership bug: a watcher armed by one harness +# session survived that session's death and retained the singleton after a +# different harness acquired the home's state/.lock, absorbing wakes no live +# conversation could read. Fake harnesses are bash symlinked as "claude" and +# "codex" (the established fm-claude-stop-autoarm fixture pattern), so the +# ancestry walk and fm_harness_pid_alive see real harness-named processes. +set -u + +# shellcheck source=tests/wake-helpers.sh +. "$(dirname "${BASH_SOURCE[0]}")/wake-helpers.sh" + +WATCH="$ROOT/bin/fm-watch.sh" +WATCH_ARM="$ROOT/bin/fm-watch-arm.sh" +SESSION_LIB="$ROOT/bin/fm-session-lock-lib.sh" +WAKE_LIB="$ROOT/bin/fm-wake-lib.sh" + +TMP_ROOT=$(fm_test_tmproot fm-session-owner-fence) +FAKEBIN=$(fm_fakebin "$TMP_ROOT/harness") +ln -s /bin/bash "$FAKEBIN/claude" +ln -s /bin/bash "$FAKEBIN/codex" +FAKE_CLAUDE="$FAKEBIN/claude" +FAKE_CODEX="$FAKEBIN/codex" + +mark_pr_check_migration_complete() { + local state=$1 + printf '%s\n' fm-pr-check-migration-scan-v1 > "$state/.pr-check-migration-scan-v1" + printf '%s\n' fm-pr-check-migration-v1 > "$state/.pr-check-migration-v1" + chmod 0600 "$state/.pr-check-migration-scan-v1" "$state/.pr-check-migration-v1" +} + +# Start a live fake harness that idles until appears. Compound +# command on purpose: a single command would be exec-optimized away and the +# process would stop looking like a harness. Echoes nothing; caller reads the +# harness pid from . +start_idle_harness() { # + # shellcheck disable=SC2016 # the body must expand inside the fake harness's shell + "$1" -c ' + printf "%s\n" "$$" > "$1" + while [ ! -f "$2" ]; do sleep 0.1; done + ' _ "$2" "$3" & +} + +wait_for_file() { # [limit] + local i=0 limit=${2:-80} + while [ "$i" -lt "$limit" ] && [ ! -s "$1" ]; do + sleep 0.1 + i=$((i + 1)) + done + [ -s "$1" ] +} + +write_watcher_lock() { + local state=$1 home=$2 pid=$3 identity + identity=$(FM_STATE_OVERRIDE="$state" bash -c '. "$1"; fm_pid_identity "$2"' _ "$WAKE_LIB" "$pid") \ + || fail "could not resolve watcher identity for pid $pid" + mkdir "$state/.watch.lock" + printf '%s\n' "$pid" > "$state/.watch.lock/pid" + printf '%s\n' "$home" > "$state/.watch.lock/fm-home" + printf '%s\n' "$WATCH" > "$state/.watch.lock/watcher-path" + printf '%s\n' "$identity" > "$state/.watch.lock/pid-identity" +} + +start_session_claim_holder() { + local state=$1 ready=$2 stop=$3 + FM_STATE_OVERRIDE="$state" bash -c ' + . "$1" + fm_lock_acquire_wait "$2/.lock.acquire" + printf "%s\n" ready > "$3" + while [ ! -e "$4" ]; do sleep 0.1; done + fm_lock_release "$2/.lock.acquire" + ' _ "$WAKE_LIB" "$state" "$ready" "$stop" & +} + +# Start a watcher as a child of a live fake claude that first writes its own +# pid into state/.lock (the session-lock owner) and then idles until +# /a-stop appears. Populates /a.pid (harness) and /a-watch.pid +# (watcher); watcher stdout goes to /a.out. +start_owned_watcher() { # + local dir=$1 state=$2 + # shellcheck disable=SC2016 # the body must expand inside the fake harness's shell + PATH="$dir/fakebin:$PATH" FM_STATE_OVERRIDE="$state" FM_POLL=1 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 \ + "$FAKE_CLAUDE" -c ' + printf "%s\n" "$$" > "$1" + printf "%s\n" "$$" > "$2" + "$3" > "$4" 2>&1 & + printf "%s\n" "$!" > "$5" + while [ ! -f "$6" ]; do sleep 0.1; done + ' _ "$dir/a.pid" "$state/.lock" "$WATCH" "$dir/a.out" "$dir/a-watch.pid" "$dir/a-stop" & + wait_for_file "$dir/a-watch.pid" || fail "owned watcher fixture did not start" + local i=0 wpid + wpid=$(cat "$dir/a-watch.pid") + while [ "$i" -lt 80 ]; do + [ "$(cat "$state/.watch.lock/pid" 2>/dev/null || true)" = "$wpid" ] && [ -e "$state/.last-watcher-beat" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "owned watcher did not publish its singleton lock" +} + +test_arm_refuses_foreign_live_owner() { + local dir state out rc foreign + dir=$(make_case fence-arm-refusal) + state="$dir/state" + mark_pr_check_migration_complete "$state" + start_idle_harness "$FAKE_CLAUDE" "$dir/foreign.pid" "$dir/foreign-stop" + wait_for_file "$dir/foreign.pid" || fail "foreign fake harness did not start" + foreign=$(cat "$dir/foreign.pid") + printf '%s\n' "$foreign" > "$state/.lock" + rc=0 + out=$(PATH="$dir/fakebin:$PATH" FM_HOME="$dir" FM_STATE_OVERRIDE="$state" FM_POLL=5 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH_ARM" 2>&1) || rc=$? + touch "$dir/foreign-stop" + [ "$rc" -ne 0 ] || fail "arm exited zero under a live foreign session owner" + assert_contains "$out" "watcher: FAILED - session-owner fence: home session lock is held by live harness pid $foreign" "arm did not print the typed fence refusal" + assert_contains "$out" "not arming" "arm fence refusal missing its action phrase" + [ ! -e "$state/.watch.lock" ] || fail "fenced arm still created a watcher singleton" + pass "arm refuses to start supervision under a live foreign session owner" +} + +test_watcher_refuses_foreign_live_owner_at_startup() { + local dir state out rc foreign + dir=$(make_case fence-watcher-startup-refusal) + state="$dir/state" + mark_pr_check_migration_complete "$state" + start_idle_harness "$FAKE_CLAUDE" "$dir/foreign.pid" "$dir/foreign-stop" + wait_for_file "$dir/foreign.pid" || fail "foreign fake harness did not start" + foreign=$(cat "$dir/foreign.pid") + printf '%s\n' "$foreign" > "$state/.lock" + rc=0 + out=$(PATH="$dir/fakebin:$PATH" FM_HOME="$dir" FM_STATE_OVERRIDE="$state" FM_POLL=5 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" 2>&1) || rc=$? + touch "$dir/foreign-stop" + [ "$rc" -ne 0 ] || fail "watcher exited zero under a live foreign session owner" + assert_contains "$out" "watcher: FAILED - session-owner fence: home session lock is held by live harness pid $foreign" "watcher did not print the typed startup fence refusal" + assert_contains "$out" "not starting" "watcher startup refusal missing its action phrase" + [ ! -e "$state/.watch.lock" ] || fail "fenced watcher still created a singleton" + pass "watcher refuses startup under a live foreign session owner" +} + +test_cached_owner_identity_rejects_reused_pid() { + local dir state rc + dir=$(make_case fence-pid-reuse) + state="$dir/state" + printf '%s\n' 4242 > "$state/.lock" + rc=0 + FM_STATE_OVERRIDE="$state" bash -c ' + . "$1" + fake_identity=original + fm_harness_ancestry_pid() { printf "%s\n" 4242; } + fm_session_process_identity() { printf "%s\n" "$fake_identity"; } + fm_harness_pid_alive() { return 0; } + fm_session_owner_fence "$2" || exit 10 + fake_identity=reused + if fm_session_owner_fence "$2"; then + exit 11 + fi + [ "$FM_SESSION_OWNER_FOREIGN_PID" = 4242 ] || exit 12 + ' _ "$SESSION_LIB" "$state" || rc=$? + [ "$rc" -eq 0 ] || fail "memoized owner identity trusted a reused pid (status $rc)" + pass "memoized harness ownership rejects a reused pid identity" +} + +test_restart_from_non_owner_leaves_owner_watcher() { + local dir state out rc wpid a_pid lock_pid + dir=$(make_case fence-restart-refusal) + state="$dir/state" + mark_pr_check_migration_complete "$state" + start_owned_watcher "$dir" "$state" + wpid=$(cat "$dir/a-watch.pid") + a_pid=$(cat "$dir/a.pid") + rc=0 + out=$(PATH="$dir/fakebin:$PATH" FM_HOME="$dir" FM_STATE_OVERRIDE="$state" FM_POLL=5 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH_ARM" --restart 2>&1) || rc=$? + [ "$rc" -ne 0 ] || fail "non-owner restart exited zero" + assert_contains "$out" 'watcher: FAILED - session-owner fence:' "non-owner restart did not print the typed fence refusal" + is_live_non_zombie "$wpid" || fail "non-owner restart killed the owner's watcher" + lock_pid=$(cat "$state/.watch.lock/pid" 2>/dev/null || true) + [ "$lock_pid" = "$wpid" ] || fail "non-owner restart disturbed the owner's singleton (got '$lock_pid')" + touch "$dir/a-stop" + kill "$wpid" "$a_pid" 2>/dev/null || true + pass "non-owner restart cannot stop the owning session's watcher" +} + +test_restart_serializes_owner_check_and_termination() { + local dir state peer claim_holder foreign arm_pid rc out dead + dir=$(make_case fence-restart-serialization) + state="$dir/state" + mark_pr_check_migration_complete "$state" + dead=$(dead_pid) + printf '%s\n' "$dead" > "$state/.lock" + sleep 300 & + peer=$! + write_watcher_lock "$state" "$dir" "$peer" + touch "$state/.last-watcher-beat" + start_session_claim_holder "$state" "$dir/claim-ready" "$dir/claim-stop" + claim_holder=$! + wait_for_file "$dir/claim-ready" || fail "session claim holder did not acquire" + PATH="$dir/fakebin:$PATH" FM_HOME="$dir" FM_STATE_OVERRIDE="$state" FM_ARM_CONFIRM_TIMEOUT=1 "$WATCH_ARM" --restart > "$dir/restart.out" 2>&1 & + arm_pid=$! + sleep 0.5 + is_live_non_zombie "$peer" || fail "restart terminated the watcher while session ownership could still transfer" + start_idle_harness "$FAKE_CODEX" "$dir/foreign.pid" "$dir/foreign-stop" + wait_for_file "$dir/foreign.pid" || fail "foreign fake harness did not start" + foreign=$(cat "$dir/foreign.pid") + printf '%s\n' "$foreign" > "$state/.lock" + touch "$dir/claim-stop" + wait "$claim_holder" 2>/dev/null || true + rc=0 + wait "$arm_pid" || rc=$? + out=$(cat "$dir/restart.out") + [ "$rc" -ne 0 ] || fail "restart crossed a session-lock handoff" + assert_contains "$out" "not restarting" "serialized restart omitted its ownership refusal" + is_live_non_zombie "$peer" || fail "serialized restart killed the new session's recorded watcher" + touch "$dir/foreign-stop" + kill "$peer" 2>/dev/null || true + wait "$peer" 2>/dev/null || true + pass "restart serializes ownership validation with watcher termination" +} + +test_attached_successor_rechecks_owner_after_wait() { + local dir state first arm_pid peer foreign i rc out + dir=$(make_case fence-attached-successor) + state="$dir/state" + mark_pr_check_migration_complete "$state" + PATH="$dir/fakebin:$PATH" FM_HOME="$dir" FM_STATE_OVERRIDE="$state" FM_POLL=0.2 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$dir/first.out" 2>&1 & + first=$! + i=0 + while [ "$i" -lt 80 ]; do + [ "$(cat "$state/.watch.lock/pid" 2>/dev/null || true)" = "$first" ] && [ -e "$state/.last-watcher-beat" ] && break + sleep 0.1 + i=$((i + 1)) + done + [ "$(cat "$state/.watch.lock/pid" 2>/dev/null || true)" = "$first" ] || fail "first watcher did not become healthy" + PATH="$dir/fakebin:$PATH" FM_HOME="$dir" FM_STATE_OVERRIDE="$state" FM_ARM_ATTACH_POLL=0.1 FM_ARM_CONFIRM_TIMEOUT=3 "$WATCH_ARM" > "$dir/arm.out" 2>&1 & + arm_pid=$! + i=0 + while [ "$i" -lt 80 ]; do + grep -qF "watcher: attached pid=$first" "$dir/arm.out" 2>/dev/null && break + sleep 0.1 + i=$((i + 1)) + done + grep -qF "watcher: attached pid=$first" "$dir/arm.out" || fail "arm did not attach to the first watcher" + kill "$first" 2>/dev/null || true + wait "$first" 2>/dev/null || true + i=0 + while [ "$i" -lt 80 ] && [ -e "$state/.watch.lock" ]; do + sleep 0.1 + i=$((i + 1)) + done + [ ! -e "$state/.watch.lock" ] || fail "first watcher did not release its singleton" + touch -t 200001010000 "$state/.last-watcher-beat" + sleep 300 & + peer=$! + write_watcher_lock "$state" "$dir" "$peer" + start_idle_harness "$FAKE_CODEX" "$dir/foreign.pid" "$dir/foreign-stop" + wait_for_file "$dir/foreign.pid" || fail "foreign fake harness did not start" + foreign=$(cat "$dir/foreign.pid") + printf '%s\n' "$foreign" > "$state/.lock" + touch "$state/.last-watcher-beat" + rc=0 + wait "$arm_pid" || rc=$? + out=$(cat "$dir/arm.out") + [ "$rc" -ne 0 ] || fail "attached arm crossed a session handoff" + assert_contains "$out" "not attaching to successor" "attached successor wait omitted its owner recheck" + ! grep -qF "watcher: attached pid=$peer" "$dir/arm.out" || fail "old arm reported attachment to the new owner's successor" + touch "$dir/foreign-stop" + kill "$peer" 2>/dev/null || true + wait "$peer" 2>/dev/null || true + pass "attached successor wait rechecks ownership before attachment" +} + +test_owned_child_successor_rechecks_owner_after_wait() { + local dir state peer foreign arm_pid i rc out + dir=$(make_case fence-owned-child-successor) + state="$dir/state" + mark_pr_check_migration_complete "$state" + sleep 300 & + peer=$! + write_watcher_lock "$state" "$dir" "$peer" + PATH="$dir/fakebin:$PATH" FM_HOME="$dir" FM_STATE_OVERRIDE="$state" FM_ARM_ATTACH_POLL=0.1 FM_ARM_CONFIRM_TIMEOUT=3 "$WATCH_ARM" > "$dir/arm.out" 2>&1 & + arm_pid=$! + i=0 + while [ "$i" -lt 80 ]; do + grep -qF "watcher: already running pid $peer" "$state"/.watch-arm-output.* 2>/dev/null && break + sleep 0.1 + i=$((i + 1)) + done + grep -qF "watcher: already running pid $peer" "$state"/.watch-arm-output.* 2>/dev/null \ + || fail "owned child did not enter its successor wait" + start_idle_harness "$FAKE_CODEX" "$dir/foreign.pid" "$dir/foreign-stop" + wait_for_file "$dir/foreign.pid" || fail "foreign fake harness did not start" + foreign=$(cat "$dir/foreign.pid") + printf '%s\n' "$foreign" > "$state/.lock" + touch "$state/.last-watcher-beat" + rc=0 + wait "$arm_pid" || rc=$? + out=$(cat "$dir/arm.out") + [ "$rc" -ne 0 ] || fail "owned-child arm crossed a session handoff" + assert_contains "$out" "not attaching to successor" "owned-child successor wait omitted its owner recheck" + ! grep -qF "watcher: attached pid=$peer" "$dir/arm.out" || fail "owned-child arm reported attachment to the new owner's successor" + touch "$dir/foreign-stop" + kill "$peer" 2>/dev/null || true + wait "$peer" 2>/dev/null || true + pass "owned-child successor wait rechecks ownership before attachment" +} + +test_cross_harness_takeover_stands_down_and_hands_over() { + # The reproduced bug end to end: harness A's watcher survives A's death as an + # orphan (dead-owner pass keeps it running - recovery unchanged), then a live + # harness B takes the session lock. The orphan must stand down within one + # poll, release the singleton, and a B-descended arm must then start fresh + # supervision (the self-owned pass). + local dir state wpid a_pid b_pid arm_pid new_pid i + dir=$(make_case fence-cross-harness) + state="$dir/state" + mark_pr_check_migration_complete "$state" + start_owned_watcher "$dir" "$state" + wpid=$(cat "$dir/a-watch.pid") + a_pid=$(cat "$dir/a.pid") + + # Orphan the watcher: harness A exits, its session lock still names dead A. + touch "$dir/a-stop" + i=0 + while [ "$i" -lt 80 ] && is_live_non_zombie "$a_pid"; do + sleep 0.1 + i=$((i + 1)) + done + is_live_non_zombie "$a_pid" && fail "fake harness A did not exit" + sleep 2 + is_live_non_zombie "$wpid" || fail "orphan watcher stood down behind a DEAD owner (recovery pass broken)" + + # Harness B takes over the home. + # shellcheck disable=SC2016 # the body must expand inside the fake harness's shell + PATH="$dir/fakebin:$PATH" FM_HOME="$dir" FM_STATE_OVERRIDE="$state" FM_POLL=1 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 \ + "$FAKE_CODEX" -c ' + printf "%s\n" "$$" > "$1" + while [ ! -f "$2" ]; do sleep 0.1; done + "$3" > "$4" 2>&1 & + printf "%s\n" "$!" > "$5" + wait + ' _ "$dir/b.pid" "$dir/b-go" "$WATCH_ARM" "$dir/b-arm.out" "$dir/b-arm.pid" & + wait_for_file "$dir/b.pid" || fail "fake harness B did not start" + b_pid=$(cat "$dir/b.pid") + printf '%s\n' "$b_pid" > "$state/.lock" + + # The orphan must stand down within one poll and release the singleton. + i=0 + while [ "$i" -lt 150 ]; do + ! is_live_non_zombie "$wpid" && [ ! -e "$state/.watch.lock" ] && break + sleep 0.1 + i=$((i + 1)) + done + ! is_live_non_zombie "$wpid" || fail "orphan watcher retained supervision after a live foreign takeover" + [ ! -e "$state/.watch.lock" ] || fail "fenced orphan watcher did not release the singleton" + grep -qF 'session-owner fence' "$dir/a.out" || fail "orphan watcher stood down without the typed fence line: $(cat "$dir/a.out")" + grep -qF 'standing down' "$dir/a.out" || fail "orphan watcher fence line missing its stand-down phrase" + + # The new owner's own arm now starts fresh supervision unhindered. + touch "$dir/b-go" + i=0 + while [ "$i" -lt 150 ]; do + grep -qF 'watcher: started pid=' "$dir/b-arm.out" 2>/dev/null && break + sleep 0.1 + i=$((i + 1)) + done + grep -qF 'watcher: started pid=' "$dir/b-arm.out" || fail "new owner's arm did not start after the fenced handover: $(cat "$dir/b-arm.out" 2>/dev/null)" + new_pid=$(cat "$state/.watch.lock/pid" 2>/dev/null || true) + [ -n "$new_pid" ] || fail "handed-over singleton records no watcher pid" + kill -0 "$new_pid" 2>/dev/null || fail "handed-over singleton does not name a live watcher" + arm_pid=$(cat "$dir/b-arm.pid" 2>/dev/null || true) + kill "$arm_pid" "$new_pid" "$b_pid" 2>/dev/null || true + pass "cross-harness takeover: orphan watcher stands down within one poll and the new owner arms" +} + +test_dead_owner_lock_still_arms() { + local dir state armpid lock_pid i dead + dir=$(make_case fence-dead-owner) + state="$dir/state" + mark_pr_check_migration_complete "$state" + dead=$(dead_pid) + printf '%s\n' "$dead" > "$state/.lock" + PATH="$dir/fakebin:$PATH" FM_HOME="$dir" FM_STATE_OVERRIDE="$state" FM_POLL=5 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH_ARM" > "$dir/arm.out" & + armpid=$! + i=0 + while [ "$i" -lt 80 ]; do + grep -qF 'watcher: started pid=' "$dir/arm.out" 2>/dev/null && break + sleep 0.1 + i=$((i + 1)) + done + grep -qF 'watcher: started pid=' "$dir/arm.out" || fail "arm did not start behind a dead session-lock owner: $(cat "$dir/arm.out")" + lock_pid=$(cat "$state/.watch.lock/pid" 2>/dev/null || true) + kill "$armpid" "$lock_pid" 2>/dev/null || true + wait "$armpid" 2>/dev/null || true + pass "a dead session-lock owner does not fence recovery arming" +} + +test_afk_daemon_watcher_is_exempt() { + local dir state wpid foreign i + dir=$(make_case fence-afk-exempt) + state="$dir/state" + mark_pr_check_migration_complete "$state" + start_idle_harness "$FAKE_CLAUDE" "$dir/foreign.pid" "$dir/foreign-stop" + wait_for_file "$dir/foreign.pid" || fail "foreign fake harness did not start" + foreign=$(cat "$dir/foreign.pid") + printf '%s\n' "$foreign" > "$state/.lock" + : > "$state/.afk" + # The away-mode daemon's watcher is not harness-descended and the lock may + # name the captain's live session; .afk must exempt it from the fence. + PATH="$dir/fakebin:$PATH" FM_STATE_OVERRIDE="$state" FM_POLL=0.5 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$dir/watch.out" & + wpid=$! + i=0 + while [ "$i" -lt 80 ]; do + [ "$(cat "$state/.watch.lock/pid" 2>/dev/null || true)" = "$wpid" ] && [ -e "$state/.last-watcher-beat" ] && break + sleep 0.1 + i=$((i + 1)) + done + [ "$(cat "$state/.watch.lock/pid" 2>/dev/null || true)" = "$wpid" ] || fail "afk watcher did not start under a live foreign lock" + sleep 1.5 + is_live_non_zombie "$wpid" || fail "afk watcher was fenced despite state/.afk: $(cat "$dir/watch.out")" + touch "$dir/foreign-stop" + kill "$wpid" 2>/dev/null || true + wait "$wpid" 2>/dev/null || true + pass "away-mode supervision is exempt from the session-owner fence" +} + +test_arm_refuses_foreign_live_owner +test_watcher_refuses_foreign_live_owner_at_startup +test_cached_owner_identity_rejects_reused_pid +test_restart_from_non_owner_leaves_owner_watcher +test_restart_serializes_owner_check_and_termination +test_attached_successor_rechecks_owner_after_wait +test_owned_child_successor_rechecks_owner_after_wait +test_cross_harness_takeover_stands_down_and_hands_over +test_dead_owner_lock_still_arms +test_afk_daemon_watcher_is_exempt From 176edadadab28478b4b9adda9efd8b09dca65506 Mon Sep 17 00:00:00 2001 From: quinnbot-ai Date: Tue, 28 Jul 2026 11:09:04 -0700 Subject: [PATCH 07/70] feat(pi): add Firstmate presentation parity (#57) * feat(pi): add Firstmate presentation parity * no-mistakes(review): Fix Pi footer telemetry and responsive field preservation * no-mistakes(review): Preserve complete Pi footer values at narrow widths * no-mistakes(test): Captain, fix Pi footer runtime colors and startup title * no-mistakes(document): Document Pi presentation defaults * no-mistakes(lint): Captain: fix Pi footer test lint * fix(pi): prioritize subscription models in picker --------- Co-authored-by: QuinnBot --- .pi/extensions/fm-primary-footer.ts | 107 ++++++++ .../lib/fm-primary-footer-layout.ts | 80 ++++++ .pi/settings.json | 4 + .pi/themes/firstmate-dark.json | 65 +++++ README.md | 6 + docs/verification/supervision.md | 24 ++ tests/fm-pi-primary-types.test.sh | 231 +++++++++++++++++- 7 files changed, 514 insertions(+), 3 deletions(-) create mode 100644 .pi/extensions/fm-primary-footer.ts create mode 100644 .pi/extensions/lib/fm-primary-footer-layout.ts create mode 100644 .pi/settings.json create mode 100644 .pi/themes/firstmate-dark.json diff --git a/.pi/extensions/fm-primary-footer.ts b/.pi/extensions/fm-primary-footer.ts new file mode 100644 index 00000000000..6d763a14259 --- /dev/null +++ b/.pi/extensions/fm-primary-footer.ts @@ -0,0 +1,107 @@ +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { formatFooterLines, type FooterData } from "./lib/fm-primary-footer-layout.ts"; + +const shortNumber = (value: number): string => + value < 1000 ? String(value) : `${(value / 1000).toFixed(value < 10000 ? 1 : 0)}k`; + +const projectName = (cwd: string): string => cwd.split(/[\\/]/).filter(Boolean).pop() || cwd; + +type SessionEntries = ReturnType; + +export const aggregateSessionUsage = (entries: SessionEntries): { input: number; output: number; cost: number } => { + let input = 0; + let output = 0; + let cost = 0; + for (const entry of entries) { + let usage; + if (entry.type === "message" && entry.message.role === "assistant") { + usage = entry.message.usage; + } else if (entry.type === "message" && entry.message.role === "toolResult") { + usage = entry.message.usage; + } else if (entry.type === "branch_summary" || entry.type === "compaction") { + usage = entry.usage; + } + if (!usage) continue; + input += usage.input; + output += usage.output; + cost += usage.cost.total; + } + return { input, output, cost }; +}; + +export default function (pi: ExtensionAPI) { + let requestRender: (() => void) | undefined; + let currentBranch = ""; + let currentContext: ExtensionContext | undefined; + let refreshTitleOnNextRender = false; + + const refreshTitle = (ctx: typeof currentContext): void => { + if (!ctx) return; + const model = ctx.model?.id || "no-model"; + const branch = currentBranch || "no-branch"; + ctx.ui.setTitle(`Firstmate · ${projectName(ctx.cwd)} · ${branch} · ${model}`); + }; + + const stats = (ctx: NonNullable): FooterData => { + const totals = aggregateSessionUsage(ctx.sessionManager.getEntries()); + const usage = ctx.getContextUsage(); + return { + state: ctx.isIdle() ? "idle" : "running", + model: ctx.model?.id || "no-model", + thinking: ctx.thinkingLevel || "off", + project: projectName(ctx.cwd), + branch: currentBranch, + context: usage?.tokens == null ? "?" : `${shortNumber(usage.tokens)}${usage.percent == null ? "" : ` (${Math.round(usage.percent)}%)`}`, + input: shortNumber(totals.input), + output: shortNumber(totals.output), + cost: totals.cost.toFixed(3), + statuses: [], + }; + }; + + const redraw = (_event: unknown, ctx: ExtensionContext) => { + currentContext = ctx; + refreshTitle(ctx); + requestRender?.(); + }; + pi.on("agent_start", redraw); + pi.on("agent_end", redraw); + pi.on("agent_settled", redraw); + pi.on("message_update", redraw); + pi.on("message_end", redraw); + pi.on("model_select", redraw); + pi.on("thinking_level_select", redraw); + pi.on("session_info_changed", redraw); + + pi.on("session_start", (_event, ctx) => { + currentContext = ctx; + ctx.ui.setTheme("firstmate-dark"); + ctx.ui.setFooter((tui, theme, footerData) => { + currentBranch = footerData.getGitBranch() || ""; + const unsubscribe = footerData.onBranchChange(() => { + currentBranch = footerData.getGitBranch() || ""; + refreshTitle(currentContext); + tui.requestRender(); + }); + requestRender = () => tui.requestRender(); + refreshTitleOnNextRender = true; + return { + dispose() { + unsubscribe(); + requestRender = undefined; + refreshTitleOnNextRender = false; + }, + invalidate() {}, + render(width: number) { + if (refreshTitleOnNextRender) { + refreshTitleOnNextRender = false; + refreshTitle(ctx); + } + const data = stats(ctx); + data.statuses = [...footerData.getExtensionStatuses()].map(([key, value]) => ({ key, value })); + return formatFooterLines(data, width, theme); + }, + }; + }); + }); +} diff --git a/.pi/extensions/lib/fm-primary-footer-layout.ts b/.pi/extensions/lib/fm-primary-footer-layout.ts new file mode 100644 index 00000000000..02d21a16385 --- /dev/null +++ b/.pi/extensions/lib/fm-primary-footer-layout.ts @@ -0,0 +1,80 @@ +import type { ThemeColor } from "@earendil-works/pi-coding-agent"; +import { visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui"; + +export type FooterTheme = { + fg(color: ThemeColor, text: string): string; +}; + +export type FooterStatus = { + key: string; + value: string; +}; + +export type FooterData = { + state: "running" | "idle"; + model: string; + thinking: string; + project: string; + branch: string; + context: string; + input: string; + output: string; + cost: string; + statuses: FooterStatus[]; +}; + +const colorize = (theme: FooterTheme, color: ThemeColor, label: string, value: string): string => + theme.fg(color, label) + theme.fg("text", value); + +const sanitizeSingleLine = (text: string): string => + text + .replace(/[\u0000-\u001f\u007f]/g, " ") + .replace(/ +/g, " ") + .trim(); + +const pack = (segments: string[], width: number): string[] => { + const lines: string[] = []; + const maxWidth = Math.max(1, width); + let line = ""; + for (const segment of segments) { + if (footerVisibleWidth(segment) > maxWidth) { + if (line) lines.push(line); + const wrapped = wrapTextWithAnsi(segment, maxWidth); + lines.push(...wrapped.slice(0, -1)); + line = wrapped.at(-1) || ""; + } else if (!line) { + line = segment; + } else if (footerVisibleWidth(line) + 2 + footerVisibleWidth(segment) <= maxWidth) { + line += ` ${segment}`; + } else { + lines.push(line); + line = segment; + } + } + if (line) lines.push(line); + return lines; +}; + +export function formatFooterLines(data: FooterData, width: number, theme: FooterTheme): string[] { + const state = colorize(theme, data.state === "running" ? "success" : "muted", "● ", data.state); + const model = colorize(theme, "accent", "model ", data.model); + const thinking = colorize(theme, "accent", "think ", data.thinking); + const location = colorize(theme, "borderAccent", "dir ", data.project); + const branch = colorize(theme, "accent", "git ", data.branch || "-"); + const usage = [ + colorize(theme, "warning", "ctx ", data.context), + colorize(theme, "muted", "↑", data.input), + colorize(theme, "muted", "↓", data.output), + colorize(theme, "accent", "$", data.cost), + ]; + const statuses = data.statuses + .map(({ key, value }) => ({ key: sanitizeSingleLine(key), value: sanitizeSingleLine(value) })) + .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : a.value < b.value ? -1 : a.value > b.value ? 1 : 0)) + .map(({ key, value }) => colorize(theme, "muted", `${key}${value ? ": " : ""}`, value)); + + return [...pack([state, model, thinking, location, branch], width), ...pack(usage, width), ...pack(statuses, width)]; +} + +export function footerVisibleWidth(line: string): number { + return visibleWidth(line); +} diff --git a/.pi/settings.json b/.pi/settings.json new file mode 100644 index 00000000000..f2d08069f8d --- /dev/null +++ b/.pi/settings.json @@ -0,0 +1,4 @@ +{ + "enabledModels": ["claude-*", "openai-codex/*"], + "theme": "firstmate-dark" +} diff --git a/.pi/themes/firstmate-dark.json b/.pi/themes/firstmate-dark.json new file mode 100644 index 00000000000..d4cc2390aa9 --- /dev/null +++ b/.pi/themes/firstmate-dark.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json", + "name": "firstmate-dark", + "vars": { + "navy": "#11152b", + "purple": "#8b7cff", + "aqua": "#50e3c2", + "mutedPurple": "#625c91", + "softText": "#c8c7e8" + }, + "colors": { + "accent": "aqua", + "border": "mutedPurple", + "borderAccent": "purple", + "borderMuted": "#302c50", + "success": "aqua", + "error": "#ff6b8a", + "warning": "#f7c873", + "muted": "mutedPurple", + "dim": "#706d94", + "text": "softText", + "thinkingText": "#aaa7d0", + "selectedBg": "#272345", + "userMessageBg": "navy", + "userMessageText": "softText", + "customMessageBg": "#191735", + "customMessageText": "softText", + "customMessageLabel": "aqua", + "toolPendingBg": "#201b3c", + "toolSuccessBg": "#17342f", + "toolErrorBg": "#3d1f31", + "toolTitle": "purple", + "toolOutput": "softText", + "mdHeading": "aqua", + "mdLink": "aqua", + "mdLinkUrl": "mutedPurple", + "mdCode": "#f7c873", + "mdCodeBlock": "#d8d6f0", + "mdCodeBlockBorder": "mutedPurple", + "mdQuote": "#aaa7d0", + "mdQuoteBorder": "purple", + "mdHr": "mutedPurple", + "mdListBullet": "aqua", + "toolDiffAdded": "#50e3c2", + "toolDiffRemoved": "#ff6b8a", + "toolDiffContext": "#aaa7d0", + "syntaxComment": "#706d94", + "syntaxKeyword": "purple", + "syntaxFunction": "aqua", + "syntaxVariable": "#f7c873", + "syntaxString": "#8fd8ff", + "syntaxNumber": "#d7a7ff", + "syntaxType": "#8fd8ff", + "syntaxOperator": "purple", + "syntaxPunctuation": "#aaa7d0", + "thinkingOff": "#706d94", + "thinkingMinimal": "#8fd8ff", + "thinkingLow": "aqua", + "thinkingMedium": "#f7c873", + "thinkingHigh": "#d7a7ff", + "thinkingXhigh": "#ff9bd2", + "thinkingMax": "#ff9bd2", + "bashMode": "#f7c873" + } +} diff --git a/README.md b/README.md index a7f69e39c23..c0f5e659aaf 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,12 @@ 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. For Pi, approve the project trust prompt once per clone on first launch so the tracked `.pi/extensions/*.ts` files auto-load. +Trusted Pi sessions load the tracked navy, purple, and aqua `firstmate-dark` theme and Firstmate footer by default. +Pi's `/model` picker opens on a focused `scoped` view of Claude and ChatGPT subscription-backed models. +Press Tab in the picker to switch to `all` configured providers, including OpenRouter. +The responsive footer uses Pi's native local session data to show run state, model and thinking level, project or directory name, git branch, context tokens and percentage, cumulative input and output tokens, session cost, and extension status text without dropping complete values at narrow widths. +When supported, the terminal title becomes `Firstmate · · · `. +The presentation does not poll quotas or providers, start background processes, or invent Codex-only fields. Pi's `/calm` toggle hides supported transcript chrome, including canonically classified Firstmate operational user rows, while retaining native working activity and all model context and session data. The hidden operational inputs remain ordinary user-role messages with unchanged delivery, ordering, authority, persistence, and exports. The preference persists for the effective Firstmate home, and toggling it off restores ordinary rendering. diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index bd9d250a2bc..1c7288ac4ca 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -190,6 +190,30 @@ tests/fm-claude-stop-autoarm.test.sh tests/fm-turnend-guard.test.sh ``` +## Pure Kun presentation parity + +The Firstmate Pure Kun presentation was verified on 2026-07-28 with Pi 0.82.0. +[The README's Pi launch guidance](../../README.md#install-and-launch) owns the current user-visible presentation contract. +The tracked implementation uses Pi's native theme, footer-data, context-usage, session-branch, extension-status, and terminal-title APIs. + +Exact commands and output: + +```text +pi --version +0.82.0 + +FM_PI_PACKAGE_DIR="$(npm root -g)/@earendil-works/pi-coding-agent" tests/fm-pi-primary-types.test.sh +ok - tracked Pi extensions pass strict no-emit typecheck against Pi 0.82.0 +ok - Firstmate Pi footer preserves fields, statuses, and cumulative native usage +ok - Pi 0.82.0 real TUI rendered the Firstmate footer and retained its project/branch/model title +ok - Pi 0.82.0 model picker defaults to OAuth subscriptions and keeps OpenRouter under all providers +``` + +The focused Pi presentation test resolves the installed compiler and Pi package through the npm environment, covers narrow, medium, and wide footer layouts, asserts that every complete native field and status value remains represented without overflow, verifies cumulative usage across every Pi 0.82.0 usage-bearing entry type, and checks stable single-line keyed status rendering. +Its offline real-TUI check launches only the tracked footer extension, confirms the themed footer renders without provider traffic, and reads tmux's terminal title after startup to verify the project, branch, and model title survives Pi's default rebind update. +The isolated model-picker check supplies fixture OAuth credentials for Anthropic and OpenAI Codex alongside an OpenRouter API key, verifies the picker opens on the subscription-backed scoped providers, and verifies Tab still reaches OpenRouter through the all-provider view. +The tracked model scope uses family and provider wildcards rather than a fixed model catalog, so Pi catalog refreshes can add subscription-backed models without a Firstmate settings update. + ## Wedge-alarm channels The two real notification channels were bounded manually on 2026-07-10 on macOS 26.5.2 with Herdr 0.7.3. diff --git a/tests/fm-pi-primary-types.test.sh b/tests/fm-pi-primary-types.test.sh index 3ff81f63ef8..ce019c1d610 100755 --- a/tests/fm-pi-primary-types.test.sh +++ b/tests/fm-pi-primary-types.test.sh @@ -5,13 +5,21 @@ set -u ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" command -v npm >/dev/null 2>&1 || { echo "skip: npm not found for Pi extension typecheck"; exit 0; } -command -v tsc >/dev/null 2>&1 || { echo "skip: tsc not found for Pi extension typecheck"; exit 0; } -PI_PACKAGE_DIR=${FM_PI_PACKAGE_DIR:-"$(npm root -g)/@earendil-works/pi-coding-agent"} +GLOBAL_NODE_MODULES=$(npm root -g) +PI_PACKAGE_DIR=${FM_PI_PACKAGE_DIR:-"$GLOBAL_NODE_MODULES/@earendil-works/pi-coding-agent"} if [ ! -f "$PI_PACKAGE_DIR/package.json" ]; then echo "skip: installed @earendil-works/pi-coding-agent package not found" exit 0 fi +TSC_BIN=${FM_TSC_BIN:-"$(command -v tsc 2>/dev/null || true)"} +if [ ! -x "$TSC_BIN" ]; then + TSC_BIN="$GLOBAL_NODE_MODULES/openclaw/node_modules/typescript/bin/tsc" +fi +if [ ! -x "$TSC_BIN" ]; then + echo "skip: tsc not found for Pi extension typecheck" + exit 0 +fi if [ ! -d "$PI_PACKAGE_DIR/node_modules/typebox" ] || \ [ ! -d "$PI_PACKAGE_DIR/node_modules/@earendil-works/pi-tui" ] || \ [ ! -d "$PI_PACKAGE_DIR/node_modules/@types/node" ]; then @@ -29,10 +37,12 @@ mkdir -p "$TMP_ROOT/lib" "$TMP_ROOT/node_modules/@earendil-works" "$TMP_ROOT/nod cp "$ROOT/.pi/extensions/fm-calm.ts" "$TMP_ROOT/fm-calm.ts" cp "$ROOT/.pi/extensions/fm-primary-pi-watch.ts" "$TMP_ROOT/fm-primary-pi-watch.ts" cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$TMP_ROOT/fm-primary-turnend-guard.ts" +cp "$ROOT/.pi/extensions/fm-primary-footer.ts" "$TMP_ROOT/fm-primary-footer.ts" cp "$ROOT/.pi/extensions/lib/fm-calm-assistant-layout.ts" "$TMP_ROOT/lib/fm-calm-assistant-layout.ts" cp "$ROOT/.pi/extensions/lib/fm-calm-operational-user-layout.ts" "$TMP_ROOT/lib/fm-calm-operational-user-layout.ts" cp "$ROOT/.pi/extensions/lib/fm-calm-visibility.ts" "$TMP_ROOT/lib/fm-calm-visibility.ts" cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$TMP_ROOT/lib/fm-operational-input.ts" +cp "$ROOT/.pi/extensions/lib/fm-primary-footer-layout.ts" "$TMP_ROOT/lib/fm-primary-footer-layout.ts" ln -s "$PI_PACKAGE_DIR" "$TMP_ROOT/node_modules/@earendil-works/pi-coding-agent" ln -s "$PI_PACKAGE_DIR/node_modules/@earendil-works/pi-tui" "$TMP_ROOT/node_modules/@earendil-works/pi-tui" ln -s "$PI_PACKAGE_DIR/node_modules/typebox" "$TMP_ROOT/node_modules/typebox" @@ -57,6 +67,221 @@ cat > "$TMP_ROOT/tsconfig.json" <<'JSON' } JSON -tsc -p "$TMP_ROOT/tsconfig.json" || exit 1 +"$TSC_BIN" -p "$TMP_ROOT/tsconfig.json" || exit 1 version=$(jq -r '.version' "$PI_PACKAGE_DIR/package.json" 2>/dev/null || printf 'unknown') printf 'ok - tracked Pi extensions pass strict no-emit typecheck against Pi %s\n' "$version" + +if node --experimental-strip-types -e 'process.exit(0)' >/dev/null 2>&1; then + FOOTER_ROOT="$TMP_ROOT" node --experimental-strip-types --input-type=module <<'JS' || exit 1 +import assert from "node:assert/strict"; +const { formatFooterLines, footerVisibleWidth } = await import(`file://${process.env.FOOTER_ROOT}/lib/fm-primary-footer-layout.ts`); +const { aggregateSessionUsage } = await import(`file://${process.env.FOOTER_ROOT}/fm-primary-footer.ts`); + +const theme = { fg: (_color, text) => text }; +const data = { + state: "running", + model: "gpt-5.6-sol", + thinking: "high", + project: "firstmate", + branch: "fm/pi-ui-parity", + context: "12.3k (18%)", + input: "4.5k", + output: "2.1k", + cost: "0.042", + statuses: [ + { key: "watcher\tz", value: "healthy\nnow" }, + { key: "guard", value: "armed\rnow" }, + ], +}; +for (const width of [12, 32, 57, 80, 120]) { + const lines = formatFooterLines(data, width, theme); + const rendered = lines.join("\n"); + const compact = rendered.replace(/\s/g, ""); + for (const line of lines) { + assert.ok(footerVisibleWidth(line) <= width, `footer overflow at ${width}: ${line}`); + } + for (const [field, expected] of [ + ["state", `●${data.state}`], + ["model", `model${data.model}`], + ["thinking", `think${data.thinking}`], + ["directory", `dir${data.project}`], + ["branch", `git${data.branch}`], + ["context", `ctx${data.context}`], + ["input", `↑${data.input}`], + ["output", `↓${data.output}`], + ["cost", `$${data.cost}`], + ["guard status", "guard:armednow"], + ["watcher status", "watcherz:healthynow"], + ]) { + assert.ok(compact.includes(expected.replace(/\s/g, "")), `footer omitted complete ${field} at ${width}: ${rendered}`); + } +} +const statusLines = formatFooterLines(data, 120, theme).join("\n"); +assert.ok(statusLines.indexOf("guard: armed now") < statusLines.indexOf("watcher z: healthy now")); +assert.doesNotMatch(statusLines, /[\r\t]/); + +const usage = (input, output, total) => ({ input, output, cost: { total } }); +assert.deepEqual( + aggregateSessionUsage([ + { type: "message", message: { role: "assistant", usage: usage(10, 2, 1) } }, + { type: "message", message: { role: "toolResult", usage: usage(3, 4, 2) } }, + { type: "compaction", usage: usage(5, 6, 3) }, + { type: "branch_summary", usage: usage(7, 8, 4) }, + { type: "message", message: { role: "user" } }, + ]), + { input: 25, output: 20, cost: 10 }, +); +console.log("ok - Firstmate Pi footer preserves fields, statuses, and cumulative native usage"); +JS +else + printf 'skip: Node type stripping is unavailable for Pi footer formatting test\n' +fi + +run_real_tui_contract() ( + PI_BIN=${FM_PI_BIN:-"$(command -v pi 2>/dev/null || true)"} + if [ ! -x "$PI_BIN" ] || ! command -v tmux >/dev/null 2>&1; then + printf 'skip: pi or tmux not found for real Pi footer contract\n' + exit 0 + fi + + fail_tui() { + printf 'not ok - %s\n' "$1" >&2 + exit 1 + } + + socket="fm-pi-primary-types-$$" + session=pi-footer + pi_home="$TMP_ROOT/pi-home" + mkdir -p "$pi_home" + trap 'tmux -L "$socket" kill-server 2>/dev/null || true' EXIT + + printf -v launch \ + 'cd %q && env PI_OFFLINE=1 PI_CODING_AGENT_DIR=%q %q --approve --no-session --no-context-files --no-skills --no-prompt-templates --no-extensions -e .pi/extensions/fm-primary-footer.ts' \ + "$ROOT" "$pi_home" "$PI_BIN" + tmux -L "$socket" new-session -d -s "$session" -x 120 -y 30 "$launch" \ + || fail_tui "could not launch Pi footer contract" + + pane= + i=0 + while [ "$i" -lt 120 ]; do + pane=$(tmux -L "$socket" capture-pane -p -t "$session" -S -80 2>/dev/null || true) + printf '%s\n' "$pane" | grep -Fq "dir $(basename "$ROOT")" && break + tmux -L "$socket" has-session -t "$session" 2>/dev/null \ + || fail_tui "Pi exited before rendering the Firstmate footer: $pane" + sleep 0.05 + i=$((i + 1)) + done + printf '%s\n' "$pane" | grep -Fq "dir $(basename "$ROOT")" \ + || fail_tui "Pi did not render the Firstmate footer: $pane" + + title=$(tmux -L "$socket" display-message -p -t "$session" '#{pane_title}') \ + || fail_tui "could not read Pi terminal title" + prefix="Firstmate · $(basename "$ROOT") · " + rest=${title#"$prefix"} + branch=${rest%%" · "*} + model=${rest#*" · "} + [ "$rest" != "$title" ] && [ -n "$branch" ] && [ "$model" != "$rest" ] && [ -n "$model" ] \ + || fail_tui "Pi did not retain the project/branch/model title: $title" + + printf 'ok - Pi %s real TUI rendered the Firstmate footer and retained its project/branch/model title\n' "$("$PI_BIN" --version)" +) + +run_real_tui_contract + +run_real_model_picker_contract() ( + PI_BIN=${FM_PI_BIN:-"$(command -v pi 2>/dev/null || true)"} + if [ ! -x "$PI_BIN" ] || ! command -v tmux >/dev/null 2>&1; then + printf 'skip: pi or tmux not found for real Pi model-picker contract\n' + exit 0 + fi + + fail_picker() { + printf 'not ok - %s\n' "$1" >&2 + exit 1 + } + + socket="fm-pi-model-picker-$$" + session=pi-model-picker + pi_home="$TMP_ROOT/pi-model-home" + mkdir -p "$pi_home" + trap 'tmux -L "$socket" kill-server 2>/dev/null || true' EXIT + + cat > "$pi_home/auth.json" <<'JSON' +{ + "anthropic": { + "type": "oauth", + "access": "fixture-access", + "refresh": "fixture-refresh", + "expires": 4102444800000 + }, + "openai-codex": { + "type": "oauth", + "access": "fixture-access", + "refresh": "fixture-refresh", + "expires": 4102444800000, + "accountId": "fixture-account" + }, + "openrouter": { + "type": "api_key", + "key": "fixture-key" + } +} +JSON + chmod 600 "$pi_home/auth.json" + + printf -v launch \ + 'cd %q && env -u ANTHROPIC_API_KEY -u ANTHROPIC_OAUTH_TOKEN -u OPENAI_API_KEY -u OPENROUTER_API_KEY PI_OFFLINE=1 PI_CODING_AGENT_DIR=%q %q --approve --no-session --no-context-files --no-skills --no-prompt-templates --no-extensions' \ + "$ROOT" "$pi_home" "$PI_BIN" + tmux -L "$socket" new-session -d -s "$session" -x 120 -y 34 "$launch" \ + || fail_picker "could not launch Pi model-picker contract" + + pane= + i=0 + while [ "$i" -lt 120 ]; do + pane=$(tmux -L "$socket" capture-pane -p -t "$session" -S -80 2>/dev/null || true) + printf '%s\n' "$pane" | grep -Fq "ctrl+o more" && break + tmux -L "$socket" has-session -t "$session" 2>/dev/null \ + || fail_picker "Pi exited before model-picker startup: $pane" + sleep 0.05 + i=$((i + 1)) + done + printf '%s\n' "$pane" | grep -Fq "ctrl+o more" \ + || fail_picker "Pi did not reach model-picker startup: $pane" + + tmux -L "$socket" send-keys -t "$session" -l '/model' + tmux -L "$socket" send-keys -t "$session" Enter + sleep 0.2 + tmux -L "$socket" send-keys -t "$session" -l 'openai-codex' + + i=0 + while [ "$i" -lt 120 ]; do + pane=$(tmux -L "$socket" capture-pane -p -t "$session" -S -80 2>/dev/null || true) + printf '%s\n' "$pane" | grep -Fq "[openai-codex]" && break + sleep 0.05 + i=$((i + 1)) + done + printf '%s\n' "$pane" | grep -Fq "Scope: all | scoped" \ + || fail_picker "Pi did not open the scoped model-picker view: $pane" + printf '%s\n' "$pane" | grep -Fq "[openai-codex]" \ + || fail_picker "Pi scoped picker did not expose the ChatGPT OAuth model: $pane" + if printf '%s\n' "$pane" | grep -Fq "[openrouter]"; then + fail_picker "Pi scoped picker included OpenRouter models: $pane" + fi + + tmux -L "$socket" send-keys -t "$session" Tab + tmux -L "$socket" send-keys -t "$session" C-u + tmux -L "$socket" send-keys -t "$session" -l 'openrouter' + i=0 + while [ "$i" -lt 120 ]; do + pane=$(tmux -L "$socket" capture-pane -p -t "$session" -S -80 2>/dev/null || true) + printf '%s\n' "$pane" | grep -Fq "[openrouter]" && break + sleep 0.05 + i=$((i + 1)) + done + printf '%s\n' "$pane" | grep -Fq "[openrouter]" \ + || fail_picker "Pi all-provider picker did not preserve OpenRouter access: $pane" + + printf 'ok - Pi %s model picker defaults to OAuth subscriptions and keeps OpenRouter under all providers\n' "$("$PI_BIN" --version)" +) + +run_real_model_picker_contract From c00da8d55b24aae6120d87943ee33c95b9c125bc Mon Sep 17 00:00:00 2001 From: quinnbot-ai Date: Tue, 28 Jul 2026 12:57:30 -0700 Subject: [PATCH 08/70] docs: allow authenticated browser routing (#58) Co-authored-by: QuinnBot --- AGENTS.md | 1 + bin/fm-brief.sh | 2 ++ tests/fm-brief.test.sh | 21 +++++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index e1c8a6eb8e1..18e1df795e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,6 +152,7 @@ A lock-refused session must not spawn, steer, merge, drain the wake queue, repai Bootstrap detects first, asks for consent, and installs only after the captain approves in the current session. Do not dispatch until the required tools are present and GitHub authentication is good. Use `gh-axi` for GitHub, `chrome-devtools-axi` for browser work, and `lavish-axi` for structured decisions or reports; consult current help rather than memorizing flags. +Machine-level routing policy may override the browser-tool default for authenticated or account sessions. A silent bootstrap section needs no action; for any printed actionable diagnostic line, load `bootstrap-diagnostics` and follow its owner procedure. `BOOTSTRAP_INFO:` lines are completed no-action facts and do not require loading a skill. `secondmate-provisioning` owns startup secondmate sync, liveness, and inherited local-material convergence. diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 00ea34ddabe..8076ba80180 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -245,6 +245,7 @@ The report is the only thing that survives, so anything worth keeping must be in 1. Never push to any remote and never open a PR. 2. Stay inside this worktree; the only files you may write outside it are the report and the status file below. 3. Use gh-axi for GitHub operations and chrome-devtools-axi for browser operations. + Machine-level routing policy may override the browser-tool default for authenticated or account sessions. 4. Report status by appending one line: \`echo "{state}: {one short line}" >> $STATUS_FILE\` States: working, needs-decision, blocked, $PAUSED_VERB, done, failed. @@ -354,6 +355,7 @@ If the top-level path is the primary checkout or not the worktree you were launc $RULE1 2. Stay inside this worktree; modify nothing outside it. 3. Use gh-axi for GitHub operations and chrome-devtools-axi for browser operations. + Machine-level routing policy may override the browser-tool default for authenticated or account sessions. 4. Report status by appending one line: \`echo "{state}: {one short line}" >> $STATUS_FILE\` States: working, needs-decision, blocked, $PAUSED_VERB, done, failed. diff --git a/tests/fm-brief.test.sh b/tests/fm-brief.test.sh index 74690eab423..d9233268287 100755 --- a/tests/fm-brief.test.sh +++ b/tests/fm-brief.test.sh @@ -204,6 +204,26 @@ test_herdr_lab_omission_is_loud_for_ship_and_scout() { pass "fm-brief.sh: ship and scout scaffolds make omitted Herdr intent fail-visible" } +test_browser_tool_routing_allows_authenticated_session_override() { + local home kind id brief + home="$TMP_ROOT/browser-routing-home" + mkdir -p "$home/data" + for kind in ship scout; do + id="brief-browser-routing-$kind" + if [ "$kind" = scout ]; then + FM_HOME="$home" "$ROOT/bin/fm-brief.sh" "$id" firstmate --scout >/dev/null 2>&1 + else + FM_HOME="$home" "$ROOT/bin/fm-brief.sh" "$id" firstmate >/dev/null 2>&1 + fi + brief="$home/data/$id/brief.md" + assert_grep "Use gh-axi for GitHub operations and chrome-devtools-axi for browser operations." "$brief" \ + "$kind brief did not retain chrome-devtools-axi as the browser default" + assert_grep "Machine-level routing policy may override the browser-tool default for authenticated or account sessions." "$brief" \ + "$kind brief did not allow machine-level authenticated-browser routing" + done + pass "fm-brief.sh: ship and scout briefs allow machine-level authenticated-browser routing" +} + test_secondmate_no_projects_charter() { local home brief status home="$TMP_ROOT/no-projects-home" @@ -391,6 +411,7 @@ test_ship_project_memory_wording test_herdr_lab_contract_is_explicit_and_complete test_herdr_lab_contract_quotes_foreign_firstmate_path test_herdr_lab_omission_is_loud_for_ship_and_scout +test_browser_tool_routing_allows_authenticated_session_override test_herdr_lab_contract_applies_to_scouts_but_not_secondmates test_secondmate_no_projects_charter test_secondmate_marked_request_reporting_contract From 45bce47fc8cceb3fa96e5eb13b471901bce720ef Mon Sep 17 00:00:00 2001 From: quinnbot-ai Date: Tue, 28 Jul 2026 14:29:01 -0700 Subject: [PATCH 09/70] feat(bin): fm-merge-local fast-forward when dirty paths are provably resolved by the incoming branch (#61) * fix: restore stock macOS Bash 3.2 brief scaffolding (#1093) * fix(brief): make DOD scaffolding parse-safe on stock macOS Bash 3.2 fm-brief.sh built each Definition-of-done block and the not-enabled Herdr declaration with `VAR=$(cat < 63/544/4068 (about 63%/60%/60% reduction). * fix: allow resolved dirt in local merges * no-mistakes(review): Harden dirty merge preflight validation * no-mistakes(review): Reject divergent staged state during ignored conversion * no-mistakes(document): Clarify dirty local merge safety contract --------- Co-authored-by: Christopher McKay <101884182+karotkriss@users.noreply.github.com> Co-authored-by: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Co-authored-by: QuinnBot --- .agents/skills/quota-array-dispatch/SKILL.md | 173 ++------ .github/workflows/ci.yml | 9 +- CONTRIBUTING.md | 4 +- bin/fm-brief.sh | 18 +- bin/fm-lint.sh | 33 +- bin/fm-merge-local.sh | 424 +++++++++++++++++- bin/fm-test-run.sh | 2 +- tests/fm-backend.test.sh | 77 +++- tests/fm-brief.test.sh | 174 +++++++- tests/fm-lint.test.sh | 15 + tests/fm-merge-local.test.sh | 437 +++++++++++++++++++ tests/fm-quota-array-dispatch.test.sh | 39 +- 12 files changed, 1206 insertions(+), 199 deletions(-) create mode 100755 tests/fm-merge-local.test.sh diff --git a/.agents/skills/quota-array-dispatch/SKILL.md b/.agents/skills/quota-array-dispatch/SKILL.md index a5fe06d6ec6..d9de90ffba2 100644 --- a/.agents/skills/quota-array-dispatch/SKILL.md +++ b/.agents/skills/quota-array-dispatch/SKILL.md @@ -12,159 +12,52 @@ metadata: # 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. +`AGENTS.md` section 4 owns the always-loaded intake boundary, load trigger, malformed-config refusal, every-candidate accounting, and strongest-reasoning/tie safety rules. +`harness-adapters` owns harness verification, model/provider discovery, and effort fallback. `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 +## Collect facts -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. +Run `quota-axi --json` once per intake and reuse that snapshot for every candidate. +For each candidate, establish the harness/model/provider relationship from `harness-adapters`, then record only inspectable facts: -## Intake boundary this skill does not relax +- task/profile fit and required reasoning class +- raw applicable headroom (`effectivePercentRemaining` or the tightest applicable remaining percentage) +- effective pace status, signed reserve per applicable window, and worst applicable reserve (`worstReservePercentPoints` when present, else the minimum signed reserve) +- whether any applicable window or effective summary is ahead of reset, or any applicable pace is `unknown` +- schema note when pace fields are absent -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. +Stale raw windows are diagnostic only, never current headroom. +Read every bounding window named by `boundedBy`, `limitingWindowIds`, `aheadWindowIds`, `behindWindowIds`, `onPaceWindowIds`, and `unknownWindowIds`. -## Collect inspectable facts for every candidate +## Pace semantics -For each candidate profile: +`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. +Conservation pressure is present when effective pace status is `ahead`, effective pace status is `mixed` and any `aheadWindowIds` remain, or any applicable bounding window itself has pace status `ahead`. +`unknown` is valid explicit uncertainty from quota-axi, not a parser failure and not permission to assume the window is healthy or exhausted. -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 +## Selection order -## Pace signals +Apply only among candidates that already satisfy required fit and the strongest reasoning class the request needs. +Never use pace or raw headroom to silently replace that reasoning class. -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. +1. Unresolved relationship or quota data: stop and report the blocked candidate. +2. All-tight: keep the strongest-reasoning class; dispatch inside it or stop and report that the tight choice cannot proceed. +3. When fit and reasoning 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. +5. Among sustainable candidates, use known behind/on-pace evidence plus raw headroom transparently. 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 collapse those facts into an opaque composite score. +6. If the dispatch choice materially hinges on unresolved pace, report the uncertainty rather than inventing a conclusion. +7. Absent pace or older schema: do not crash, fabricate pace, or silently reinterpret absence as healthy/`on_pace`. + Compare raw applicable headroom only, state that pace is unavailable, and keep every other safety rule. +8. Genuine ties: 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. +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/.github/workflows/ci.yml b/.github/workflows/ci.yml index c04c0965c42..407a29c88f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -322,7 +322,14 @@ jobs: esac /bin/bash --version | head -1 command -v jq >/dev/null || { echo "::error::jq is required"; exit 1; } - /bin/bash -n bin/fm-fleet-snapshot.sh + + shell_inventory="$RUNNER_TEMP/fm-shell-inventory" + bin/fm-lint.sh --list-files > "$shell_inventory" + parse_fail=0 + while IFS= read -r f; do + /bin/bash -n "$f" || { echo "::error::stock macOS Bash 3.2 failed to parse $f"; parse_fail=1; } + done < "$shell_inventory" + [ "$parse_fail" -eq 0 ] || { echo "::error::stock macOS Bash 3.2 parse sweep failed"; exit 1; } snapshot_output=$(/bin/bash tests/fm-fleet-snapshot-view.test.sh) printf '%s\n' "$snapshot_output" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7f90f002029..7c3a1cab183 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,7 +71,7 @@ That is firstmate-specific; do not commit `.no-mistakes/evidence/` here even whe Check and test the toolbelt before pushing: ```sh -for script in bin/*.sh bin/backends/*.sh; do bash -n "$script"; done # syntax-check the toolbelt +while IFS= read -r script; do /bin/bash -n "$script" || exit; done < <(bin/fm-lint.sh --list-files) # syntax-check the canonical shell surface bin/fm-lint.sh # lint the toolbelt and behavior tests; the single owner CI and the no-mistakes gate both run bin/fm-test-run.sh tests/.test.sh # one script (primary local focus path, timed) bin/fm-test-run.sh --family pure-contract-unit # ordinary family-scoped local path (serial, timed) @@ -93,7 +93,7 @@ Its header and `--help` own the flags, family labels, lanes, and changed-file ma Portable shard balance evidence lives in `docs/fm-test-portable-shards.md`. Local no-mistakes Test stays intent-targeted and must not wire `commands.test` to `--all` or a `tests/*.test.sh` walk. Family selection is the ordinary local path; `--all` is deliberate full regression only. -CI owns broad regression across required portable parallel shards, the portable serial lane, the Herdr lane, lint, invariants, the coverage guard, and macOS snapshot compatibility in [`.github/workflows/ci.yml`](.github/workflows/ci.yml). +CI owns broad regression across required portable parallel shards, the portable serial lane, the Herdr lane, lint, invariants, the coverage guard, and stock macOS Bash compatibility in [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Use `bin/fm-test-run.sh --help` for lane names, `--jobs` rules, and required gate-skip flags when reproducing a lane locally. Discover tests by listing `tests/*.test.sh`: each is a self-contained bash script named `.test.sh`, and its header comment describes what it covers, so pass one to `bin/fm-test-run.sh` to focus on a subject with canonical timing output. Tests that need a real optional backend or an explicit opt-in (real herdr/zellij/cmux smoke tests, the live Pi regression) skip themselves and print the tool or environment gate needed to enable them, so the portable suite remains safe on machines without those tools. diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 8076ba80180..a7dcdb339d5 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -217,13 +217,13 @@ HERDR_SECTION=$(printf '%s\n' \ 'Never bypass the helper, even for a read-only lifecycle probe or cleanup after failure.' \ 'The captain fleet uses the running `default` session.') else -HERDR_SECTION=$(cat <<'EOF' +IFS= read -r -d '' HERDR_SECTION <<'EOF' || true # Herdr lifecycle declaration - NOT ENABLED **HARD SAFETY GATE:** this scaffold cannot inspect the task text that replaces `{TASK}` later. If the task will start, stop, delete, restart, profile, or otherwise drive Herdr lifecycle behavior, stop and regenerate the brief with `--herdr-lab` before dispatch. Do not add Herdr lifecycle commands to this unguarded brief by hand. EOF -) +HERDR_SECTION=${HERDR_SECTION%$'\n'} fi if [ "$KIND" = scout ]; then @@ -286,19 +286,18 @@ case "$MODE" in direct-PR) SETUP2="" RULE1='1. Never push to the default branch (push only your `fm/'"$ID"'` branch). Never merge a PR.' - DOD=$(cat < "$BRIEF" < [path]... override bounded worker count # fm-lint.sh --telemetry ... write a quiet metrics snapshot # fm-lint.sh --required-version print the ShellCheck pin +# fm-lint.sh --list-files print the canonical file set # fm-lint.sh --help print this usage set -u @@ -83,11 +84,12 @@ if [ "${1:-}" = "--required-version" ]; then fi fm_lint_usage() { - sed -n '2,25{s/^# \{0,1\}//;p;}' "$SELF" + sed -n '2,26{s/^# \{0,1\}//;p;}' "$SELF" } JOBS=${FM_LINT_JOBS:-2} TELEMETRY=${FM_LINT_TELEMETRY:-} +LIST_FILES=0 while [ "$#" -gt 0 ]; do case "$1" in --jobs) @@ -108,6 +110,10 @@ while [ "$#" -gt 0 ]; do TELEMETRY=${1#*=} shift ;; + --list-files) + LIST_FILES=1 + shift + ;; --help|-h) fm_lint_usage exit 0 @@ -125,6 +131,22 @@ case "$JOBS" in *) printf 'fm-lint.sh: jobs must be 1 or 2, got %s.\n' "$JOBS" >&2; exit 2 ;; esac +if [ "$#" -gt 0 ]; then + ROOTS=("$@") +else + ROOTS=(bin/*.sh bin/backends/*.sh tests/*.sh) +fi +ROOT_COUNT=${#ROOTS[@]} + +if [ "$LIST_FILES" -eq 1 ]; then + [ "$#" -eq 0 ] || { + printf 'fm-lint.sh: --list-files does not accept explicit paths.\n' >&2 + exit 2 + } + printf '%s\n' "${ROOTS[@]}" + exit 0 +fi + if ! command -v shellcheck >/dev/null 2>&1; then printf 'fm-lint.sh: ShellCheck not found; install ShellCheck %s for CI parity.\n' \ "$REQUIRED_SHELLCHECK" >&2 @@ -144,15 +166,6 @@ if [ "$resolved" != "$REQUIRED_SHELLCHECK" ]; then exit 1 fi -if [ "$#" -gt 0 ]; then - ROOTS=("$@") -else - # Canonical file set: the one authoritative definition. Callers never repeat - # these globs, and every adapter and test shell remains an independent root. - ROOTS=(bin/*.sh bin/backends/*.sh tests/*.sh) -fi -ROOT_COUNT=${#ROOTS[@]} - if [ -n "$TELEMETRY" ]; then telemetry_parent=$(dirname "$TELEMETRY") [ -d "$telemetry_parent" ] || { diff --git a/bin/fm-merge-local.sh b/bin/fm-merge-local.sh index fdc8011488b..d9fa0300065 100755 --- a/bin/fm-merge-local.sh +++ b/bin/fm-merge-local.sh @@ -6,9 +6,17 @@ # locally instead of via a GitHub PR). It is the one sanctioned exception to hard # rule #1 "never run state-changing git in projects/", and it is narrow: it only # runs for mode=local-only tasks, only after the captain approves (or yolo=on -# auto-approves), and only as a clean fast-forward - it refuses a diverged branch -# and tells you to have the crewmate rebase. See AGENTS.md prime directives, -# project management, and task lifecycle. +# auto-approves), and only as a fast-forward. A dirty default-branch checkout is +# allowed only when every dirty path is resolved: tracked working-tree and staged +# content must match the incoming blob bytes and tree mode, while +# tracked-to-untracked conversions and untracked files must be ignored by the +# target head's .gitignore rules; info/exclude and global excludes do not count. +# Every unresolved path is diagnosed before advancing. Proven ignored files are +# retained on disk, then every previously dirty path is verified clean. A +# preservation or cleanliness failure after the fast-forward is reported without +# attempting rollback. Diverged branches still refuse and require the crewmate +# to rebase. See AGENTS.md prime directives, project management, and task +# lifecycle. # Usage: fm-merge-local.sh set -eu @@ -46,12 +54,360 @@ git -C "$PROJ" rev-parse --verify --quiet "refs/heads/$BRANCH" >/dev/null || { e DEFAULT=$(default_branch) || { echo "error: cannot determine default branch for $PROJ; expected origin/HEAD, main, or master" >&2; exit 1; } -# The project's main checkout must be on its default branch and clean, so the +# The project's main checkout must be on its default branch so the # fast-forward lands predictably (firstmate never writes here otherwise). cur=$(git -C "$PROJ" symbolic-ref --short HEAD 2>/dev/null || echo "") [ "$cur" = "$DEFAULT" ] || { echo "error: $PROJ is on '$cur', expected default branch '$DEFAULT'; cannot merge safely" >&2; exit 1; } -if [ -n "$(git -C "$PROJ" status --porcelain 2>/dev/null | head -1)" ]; then - echo "error: $PROJ has a dirty working tree; refusing to merge into it" >&2 + +TARGET_VIEW_ROOT= +TARGET_VIEW_TREE= +TARGET_VIEW_INDEX= +TARGET_VIEW_GIT_DIR= +TARGET_GIT_DIR= +PRESERVE_TEMP_ROOT= +MOVED_PATHS=() +MOVED_RESTORED=() + +cleanup_target_view() { + if [ -n "$TARGET_VIEW_ROOT" ] && [ -d "$TARGET_VIEW_ROOT" ]; then + rm -rf -- "$TARGET_VIEW_ROOT" + fi +} + +restore_moved_paths() { + local i path saved destination parent failed=0 + for ((i = 0; i < ${#MOVED_PATHS[@]}; i++)); do + [ "${MOVED_RESTORED[$i]}" -eq 0 ] || continue + path=${MOVED_PATHS[$i]} + saved="$PRESERVE_TEMP_ROOT/files/$path" + destination="$PROJ/$path" + if [ ! -e "$saved" ] && [ ! -L "$saved" ]; then + if [ -e "$destination" ] || [ -L "$destination" ]; then + MOVED_RESTORED[i]=1 + continue + fi + printf 'error: preserved path %q is missing from both the checkout and %q\n' \ + "$path" "$saved" >&2 + failed=1 + continue + fi + if [ -e "$destination" ] || [ -L "$destination" ]; then + printf 'error: cannot restore preserved path %q because the destination now exists; saved copy remains at %q\n' \ + "$path" "$saved" >&2 + failed=1 + continue + fi + parent=${destination%/*} + if ! mkdir -p "$parent" || ! mv -- "$saved" "$destination"; then + printf 'error: could not restore preserved path %q; saved copy remains at %q\n' \ + "$path" "$saved" >&2 + failed=1 + continue + fi + MOVED_RESTORED[i]=1 + done + [ "$failed" -eq 0 ] +} + +cleanup_merge_temps() { + local rc=$? restore_failed=0 + if ! restore_moved_paths; then + restore_failed=1 + fi + cleanup_target_view + if [ -n "$PRESERVE_TEMP_ROOT" ] && [ -d "$PRESERVE_TEMP_ROOT" ]; then + if [ "$restore_failed" -eq 0 ]; then + rm -rf -- "$PRESERVE_TEMP_ROOT" + else + printf 'error: preserved files require manual recovery from %q\n' \ + "$PRESERVE_TEMP_ROOT" >&2 + fi + fi + return "$rc" +} +trap cleanup_merge_temps EXIT + +init_target_view() { + local empty_template tree_paths path + TARGET_VIEW_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/fm-merge-local.XXXXXX") || return 1 + TARGET_VIEW_TREE="$TARGET_VIEW_ROOT/tree" + TARGET_VIEW_INDEX="$TARGET_VIEW_ROOT/index" + empty_template="$TARGET_VIEW_ROOT/empty-template" + tree_paths="$TARGET_VIEW_ROOT/tree-paths" + TARGET_GIT_DIR=$(git -C "$PROJ" rev-parse --absolute-git-dir) || return 1 + mkdir -p "$TARGET_VIEW_TREE" "$empty_template" || return 1 + git -c init.defaultBranch=fm-target-view init -q \ + --template="$empty_template" "$TARGET_VIEW_TREE" || return 1 + TARGET_VIEW_GIT_DIR="$TARGET_VIEW_TREE/.git" + GIT_INDEX_FILE="$TARGET_VIEW_INDEX" git -C "$PROJ" read-tree "$BRANCH" || return 1 + git -C "$PROJ" ls-tree -r -z --name-only "$BRANCH" >"$tree_paths" || return 1 + + while IFS= read -r -d '' path; do + case "$path" in + .gitignore|*/.gitignore) + GIT_DIR="$TARGET_GIT_DIR" \ + GIT_WORK_TREE="$TARGET_VIEW_TREE" \ + GIT_INDEX_FILE="$TARGET_VIEW_INDEX" \ + GIT_LITERAL_PATHSPECS=1 \ + git -C "$TARGET_VIEW_TREE" checkout-index -- "$path" || return 1 + ;; + esac + done <"$tree_paths" +} + +branch_ignores_path() { + GIT_CONFIG_NOSYSTEM=1 \ + GIT_CONFIG_GLOBAL=/dev/null \ + GIT_DIR="$TARGET_VIEW_GIT_DIR" \ + GIT_WORK_TREE="$TARGET_VIEW_TREE" \ + GIT_INDEX_FILE="$TARGET_VIEW_INDEX" \ + git -c core.excludesFile=/dev/null \ + -C "$TARGET_VIEW_TREE" check-ignore --quiet -- "$1" +} + +branch_blob_metadata() { + GIT_LITERAL_PATHSPECS=1 git -C "$PROJ" ls-tree "$BRANCH" -- "$1" \ + | awk 'NR == 1 { print $1, $2, $3 }' +} + +index_blob_metadata() { + GIT_LITERAL_PATHSPECS=1 git -C "$PROJ" ls-files --stage -- "$1" \ + | awk '$3 == 0 { print $1, $2; exit }' +} + +working_tree_mode() { + local mode path=$1 + mode=$( + GIT_LITERAL_PATHSPECS=1 git -c core.fileMode=true \ + -C "$PROJ" diff-files --raw --no-abbrev -- "$path" \ + | awk 'NR == 1 { sub(/^:/, "", $1); print $2 }' + ) || return 1 + if [ -z "$mode" ]; then + mode=$(index_blob_metadata "$path") || return 1 + mode=${mode%% *} + fi + [ -n "$mode" ] && [ "$mode" != "000000" ] || return 1 + printf '%s\n' "$mode" +} + +staged_state_matches_worktree() { + local current_mode current_oid index_meta index_mode index_oid path=$1 + index_meta=$(index_blob_metadata "$path") || return 1 + if [ ! -e "$PROJ/$path" ] && [ ! -L "$PROJ/$path" ]; then + [ -z "$index_meta" ] + return + fi + [ -n "$index_meta" ] || return 1 + index_mode=${index_meta%% *} + index_oid=${index_meta##* } + current_oid=$(git -C "$PROJ" hash-object -- "$path" 2>/dev/null) || return 1 + current_mode=$(working_tree_mode "$path") || return 1 + [ "$index_oid" = "$current_oid" ] && [ "$index_mode" = "$current_mode" ] +} + +DIRTY_PATHS=() +DIRTY_STATES=() +while IFS= read -r -d '' record; do + state=${record:0:2} + path=${record:3} + DIRTY_PATHS+=("$path") + DIRTY_STATES+=("$state") + case "$state" in + *R*|*C*) + if IFS= read -r -d '' source_path; then + DIRTY_PATHS+=("$source_path") + DIRTY_STATES+=("rename-source") + fi + ;; + esac +done < <(git -C "$PROJ" status --porcelain=v1 -z --untracked-files=all) +while IFS= read -r -d '' path; do + DIRTY_PATHS+=("$path") + DIRTY_STATES+=("!!") +done < <( + git -C "$PROJ" ls-files --others --ignored --exclude-standard -z +) + +UNRESOLVED_PATHS=() +UNRESOLVED_REASONS=() +RESOLVED_PATHS=() +RESOLVED_ACTIONS=() +RESOLVED_MODES=() +RESOLVED_OIDS=() +PRESERVE_PATHS=() +PRESERVE_KINDS=() +PRESERVE_OIDS=() + +add_unresolved() { + UNRESOLVED_PATHS+=("$1") + UNRESOLVED_REASONS+=("$2") +} + +remember_preserved_path() { + local path=$1 oid + if oid=$(git -C "$PROJ" hash-object -- "$path" 2>/dev/null); then + PRESERVE_PATHS+=("$path") + PRESERVE_KINDS+=("blob") + PRESERVE_OIDS+=("$oid") + elif [ ! -e "$PROJ/$path" ] && [ ! -L "$PROJ/$path" ]; then + PRESERVE_PATHS+=("$path") + PRESERVE_KINDS+=("absent") + PRESERVE_OIDS+=("") + else + return 1 + fi +} + +move_preserved_path_out_of_merge() { + local path=$1 saved parent index + if [ ! -e "$PROJ/$path" ] && [ ! -L "$PROJ/$path" ]; then + return 0 + fi + if [ -z "$PRESERVE_TEMP_ROOT" ]; then + PRESERVE_TEMP_ROOT=$(mktemp -d "$TARGET_GIT_DIR/fm-merge-local-preserve.XXXXXX") \ + || return 1 + fi + saved="$PRESERVE_TEMP_ROOT/files/$path" + parent=${saved%/*} + mkdir -p "$parent" || return 1 + index=${#MOVED_PATHS[@]} + MOVED_PATHS+=("$path") + MOVED_RESTORED+=("0") + if ! mv -- "$PROJ/$path" "$saved"; then + if [ -e "$PROJ/$path" ] || [ -L "$PROJ/$path" ]; then + MOVED_RESTORED[index]=1 + fi + return 1 + fi +} + +if [ "${#DIRTY_PATHS[@]}" -gt 0 ]; then + if ! init_target_view; then + echo "error: could not construct the $BRANCH ignore view; refusing to merge a dirty checkout" >&2 + exit 1 + fi + + for ((i = 0; i < ${#DIRTY_PATHS[@]}; i++)); do + path=${DIRTY_PATHS[$i]} + state=${DIRTY_STATES[$i]} + + case "$state" in + rename-source|*R*|*C*) + add_unresolved "$path" "rename or copy state '$state' is not a supported resolved form" + continue + ;; + esac + + set +e + branch_ignores_path "$path" + ignore_rc=$? + set -e + case "$ignore_rc" in + 0) + if [ "$state" != "??" ] && [ "$state" != "!!" ] \ + && [ "${state:0:1}" != " " ] \ + && ! staged_state_matches_worktree "$path"; then + add_unresolved "$path" "staged content or mode does not match the working-tree copy preserved by the branch-head ignore rule" + continue + fi + if ! remember_preserved_path "$path"; then + add_unresolved "$path" "ignored path is neither absent nor hashable as a file" + continue + fi + RESOLVED_PATHS+=("$path") + RESOLVED_MODES+=("") + RESOLVED_OIDS+=("") + if [ "$state" = "??" ] || [ "$state" = "!!" ]; then + RESOLVED_ACTIONS+=("keep-untracked") + else + RESOLVED_ACTIONS+=("remove-index") + fi + continue + ;; + 1) + ;; + *) + add_unresolved "$path" "could not evaluate branch-head ignore rules" + continue + ;; + esac + + if [ "$state" = "??" ] || [ "$state" = "!!" ]; then + add_unresolved "$path" "untracked path is not ignored at the branch head" + continue + fi + case "$state" in + *U*|*D*|AA) + add_unresolved "$path" "status '$state' is not modified or added tracked content" + continue + ;; + *M*|*A*) + ;; + *) + add_unresolved "$path" "status '$state' is not a supported resolved form" + continue + ;; + esac + + blob_meta=$(branch_blob_metadata "$path") + if [ -z "$blob_meta" ]; then + add_unresolved "$path" "the branch head has no entry for the modified or added path" + continue + fi + target_mode=${blob_meta%% *} + blob_meta=${blob_meta#* } + target_type=${blob_meta%% *} + target_oid=${blob_meta##* } + if [ "$target_type" != "blob" ]; then + add_unresolved "$path" "the branch-head entry is '$target_type', not a file blob" + continue + fi + if [ "${state:0:1}" != " " ]; then + index_meta=$(index_blob_metadata "$path") + if [ -z "$index_meta" ]; then + add_unresolved "$path" "staged content has no ordinary index entry" + continue + fi + index_mode=${index_meta%% *} + index_oid=${index_meta##* } + if [ "$index_oid" != "$target_oid" ]; then + add_unresolved "$path" "staged content does not match the branch-head blob" + continue + fi + if [ "$index_mode" != "$target_mode" ]; then + add_unresolved "$path" "staged mode $index_mode does not match branch-head mode $target_mode" + continue + fi + fi + if ! current_oid=$(git -C "$PROJ" hash-object -- "$path" 2>/dev/null); then + add_unresolved "$path" "working-tree content cannot be hashed as a file" + continue + fi + if [ "$current_oid" != "$target_oid" ]; then + add_unresolved "$path" "working-tree content does not match the branch-head blob" + continue + fi + if ! current_mode=$(working_tree_mode "$path"); then + add_unresolved "$path" "working-tree mode cannot be determined" + continue + fi + if [ "$current_mode" != "$target_mode" ]; then + add_unresolved "$path" "working-tree mode $current_mode does not match branch-head mode $target_mode" + continue + fi + + RESOLVED_PATHS+=("$path") + RESOLVED_ACTIONS+=("set-index") + RESOLVED_MODES+=("$target_mode") + RESOLVED_OIDS+=("$target_oid") + done +fi + +if [ "${#UNRESOLVED_PATHS[@]}" -gt 0 ]; then + echo "error: $PROJ has dirty paths not resolved by $BRANCH; refusing to merge:" >&2 + for ((i = 0; i < ${#UNRESOLVED_PATHS[@]}; i++)); do + printf ' - %q: %s\n' "${UNRESOLVED_PATHS[$i]}" "${UNRESOLVED_REASONS[$i]}" >&2 + done exit 1 fi @@ -62,7 +418,63 @@ if ! git -C "$PROJ" merge-base --is-ancestor "$DEFAULT" "$BRANCH"; then exit 1 fi +for ((i = 0; i < ${#RESOLVED_PATHS[@]}; i++)); do + path=${RESOLVED_PATHS[$i]} + case "${RESOLVED_ACTIONS[$i]}" in + set-index) + git -C "$PROJ" update-index --add \ + --cacheinfo "${RESOLVED_MODES[$i]},${RESOLVED_OIDS[$i]},$path" + ;; + remove-index) + git -C "$PROJ" update-index --force-remove -- "$path" + move_preserved_path_out_of_merge "$path" + ;; + keep-untracked) + ;; + esac +done + before=$(git -C "$PROJ" rev-parse --short "$DEFAULT") git -C "$PROJ" merge --ff-only "$BRANCH" >/dev/null after=$(git -C "$PROJ" rev-parse --short "$DEFAULT") +if ! restore_moved_paths; then + printf 'error: fast-forward completed, but preserved files require manual recovery from %q\n' \ + "$PRESERVE_TEMP_ROOT" >&2 + exit 1 +fi + +for ((i = 0; i < ${#PRESERVE_PATHS[@]}; i++)); do + path=${PRESERVE_PATHS[$i]} + if [ "${PRESERVE_KINDS[$i]}" = "absent" ]; then + if [ -e "$PROJ/$path" ] || [ -L "$PROJ/$path" ]; then + printf 'error: fast-forward completed, but ignored path %q was absent before the merge and now exists\n' "$path" >&2 + exit 1 + fi + continue + fi + if ! preserved_oid=$(git -C "$PROJ" hash-object -- "$path" 2>/dev/null); then + printf 'error: fast-forward completed, but ignored path %q was blob %s before the merge and is now absent or unreadable\n' \ + "$path" "${PRESERVE_OIDS[$i]}" >&2 + exit 1 + fi + if [ "$preserved_oid" != "${PRESERVE_OIDS[$i]}" ]; then + printf 'error: fast-forward completed, but ignored path %q changed from blob %s to blob %s\n' \ + "$path" "${PRESERVE_OIDS[$i]}" "$preserved_oid" >&2 + exit 1 + fi +done + +if [ "${#DIRTY_PATHS[@]}" -gt 0 ]; then + POST_PATHS=() + for path in "${DIRTY_PATHS[@]}"; do + POST_PATHS+=(":(literal)$path") + done + post_status=$(git -C "$PROJ" status --porcelain=v1 --untracked-files=all -- "${POST_PATHS[@]}") + if [ -n "$post_status" ]; then + echo "error: fast-forward completed, but previously dirty resolved paths are not clean:" >&2 + printf '%s\n' "$post_status" >&2 + exit 1 + fi +fi + echo "merged $BRANCH into local $DEFAULT ($before -> $after) in $PROJ" diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 255c1cdc31d..bc42dc8b469 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -166,7 +166,7 @@ family_for_basename() { 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|\ + fm-merge-local.test.sh|fm-pr-check-security.test.sh|fm-pr-merge.test.sh|fm-review-diff.test.sh|\ fm-teardown.test.sh|fm-x-mode.test.sh) printf '%s\n' pr-forge ;; diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 323cd4f5e51..922b227cec8 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -12,7 +12,10 @@ # binaries and fixtures as the REFACTORED versions in this checkout, then # diffs the two command logs byte-for-byte - the report's P1 checklist # item "run current main scripts and refactored scripts against the same -# fake tools and compare command logs". +# fake tools and compare command logs". The teardown old-vs-new case also +# overlays a content-historical permissive tmux kill fixture: after the +# exact-selector change lands on the default branch, merge-base with main +# collapses to HEAD and can no longer supply that baseline. # 3. Asserts the `--backend`/`FM_BACKEND` selection refuses unknown backends # and the blocked `codex-app` backend loudly. # @@ -80,6 +83,9 @@ SH } # The commit this branch started from - the P1 "current main" baseline. +# Suitable for byte-identical old-vs-new checks while a branch still diverges +# from main. After a squash lands, merge-base(HEAD, main) collapses to HEAD, so +# callers that need a true pre-change fixture must not rely on this alone. resolve_base_ref() { local ref base for ref in main refs/heads/main origin/main refs/remotes/origin/main origin/HEAD refs/remotes/origin/HEAD; do @@ -95,6 +101,30 @@ resolve_base_ref() { BASE_REF=$(resolve_base_ref) \ || fail "fm-backend baseline requires local main or origin/main; fetch the default branch before running this test" +# Newest first-parent revision whose bin/backends/tmux.sh still uses the +# pre-exact permissive kill-window target. Content-addressed from history so the +# fixture stays historical on default-branch CI and on branches cut after the +# exact-selector change, where merge-base with main is self-referential. +resolve_permissive_tmux_kill_ref() { + local commit body + while IFS= read -r commit; do + [ -n "$commit" ] || continue + body=$(git -C "$ROOT" show "$commit:bin/backends/tmux.sh" 2>/dev/null) || continue + # shellcheck disable=SC2016 + case "$body" in + *'tmux kill-window -t "=$session:=$window"'*) continue ;; + esac + # shellcheck disable=SC2016 + case "$body" in + *'tmux kill-window -t "$1"'*|*'tmux kill-window -t "$target"'*) + printf '%s\n' "$commit" + return 0 + ;; + esac + done < <(git -C "$ROOT" log --first-parent --format='%H' HEAD -- bin/backends/tmux.sh) + return 1 +} + # --- shared: a pre-refactor bin/ shim -------------------------------------- # # build_old_bin echoes a directory whose bin/ subdir holds the PRE-REFACTOR @@ -929,10 +959,52 @@ run_teardown_case() { "$script" "$id" } +test_permissive_tmux_kill_ref_stays_historical() { + local ref body_hist body_head head + head=$(git -C "$ROOT" rev-parse HEAD) + ref=$(resolve_permissive_tmux_kill_ref) \ + || fail "unable to locate a historical bin/backends/tmux.sh with permissive kill-window selectors" + body_hist=$(git -C "$ROOT" show "$ref:bin/backends/tmux.sh") \ + || fail "could not read historical tmux adapter at $ref" + body_head=$(cat "$ROOT/bin/backends/tmux.sh") + + # shellcheck disable=SC2016 + case "$body_hist" in + *'tmux kill-window -t "=$session:=$window"'*) + fail "resolve_permissive_tmux_kill_ref returned exact selectors at $ref" + ;; + esac + # shellcheck disable=SC2016 + case "$body_hist" in + *'tmux kill-window -t "$1"'*|*'tmux kill-window -t "$target"'*) ;; + *) fail "historical tmux adapter at $ref lacks a permissive kill-window target" ;; + esac + # shellcheck disable=SC2016 + case "$body_head" in + *'tmux kill-window -t "=$session:=$window"'*) ;; + *) fail "current tmux adapter lost exact kill-window selectors" ;; + esac + [ "$ref" != "$head" ] \ + || fail "permissive tmux baseline collapsed to HEAD; fixture is no longer historical" + + pass "historical permissive tmux kill baseline stays distinct from current exact selectors" +} + test_teardown_conformance_old_vs_new() { - local old_bin fb proj wt id + local old_bin fb proj wt id old_tmux_ref saved_base_ref local state_old state_new config_old config_new data log_old log_new out_old out_new rc_old rc_new + # Force the post-squash topology inside this case: merge-base with main may + # equal HEAD on default-branch CI, and that must not make the legacy kill + # fixture self-referential. build_old_bin still uses BASE_REF for entrypoints; + # only the tmux kill adapter is pinned to the content-historical permissive ref. + saved_base_ref=$BASE_REF + BASE_REF=$(git -C "$ROOT" rev-parse HEAD) + old_tmux_ref=$(resolve_permissive_tmux_kill_ref) \ + || { BASE_REF=$saved_base_ref; fail "unable to locate a historical bin/backends/tmux.sh with permissive kill-window selectors"; } old_bin=$(build_old_bin teardown-old) + git -C "$ROOT" show "$old_tmux_ref:bin/backends/tmux.sh" > "$old_bin/bin/backends/tmux.sh" \ + || { BASE_REF=$saved_base_ref; fail "could not materialize historical tmux adapter from $old_tmux_ref"; } + BASE_REF=$saved_base_ref proj="$TMP_ROOT/teardown-project"; wt="$TMP_ROOT/teardown-wt" id="teardownconform1" fm_git_worktree "$proj" "$wt" "fm/$id" @@ -1106,6 +1178,7 @@ test_backend_of_selector_matches_explicit_target_meta test_send_conformance_old_vs_new test_peek_conformance_old_vs_new test_spawn_symlinked_project_prefix_avoids_false_refusal +test_permissive_tmux_kill_ref_stays_historical test_teardown_conformance_old_vs_new test_spawn_refuses_unknown_backend_flag test_spawn_refuses_codex_app_backend_flag diff --git a/tests/fm-brief.test.sh b/tests/fm-brief.test.sh index d9233268287..4ff3dbb44a0 100755 --- a/tests/fm-brief.test.sh +++ b/tests/fm-brief.test.sh @@ -1,14 +1,18 @@ #!/usr/bin/env bash # Behavior tests for bin/fm-brief.sh. # -# Regression coverage for the heredoc-in-command-substitution parse bug (issue -# #166): each ship-mode branch builds its Definition-of-done text with -# `VAR=$(cat < file <&1); rc=$? @@ -29,6 +34,142 @@ test_script_parses() { pass "fm-brief.sh: bash -n succeeds" } +# Structural class guard (issues #166, #958, #1069): never build a variable by +# wrapping a heredoc in a command substitution (`VAR=$(cat < "$unsafe" + # shellcheck disable=SC2016 # Literal shell fixtures must remain unexpanded. + printf '%s\n' 'cat < "$safe" + if no_heredoc_in_command_substitution "$unsafe"; then + fail "structural guard accepted a multiline heredoc nested in a command substitution" + fi + no_heredoc_in_command_substitution "$safe" \ + || fail "structural guard treated heredoc body prose as shell structure" + no_heredoc_in_command_substitution "$ROOT/bin/fm-brief.sh" \ + || fail "fm-brief.sh wraps a heredoc in a command substitution (breaks Bash 3.2 parsing)" + pass "fm-brief.sh: no heredoc is nested inside a command substitution (Bash 3.2 parse-safe)" +} + +no_heredoc_in_command_substitution() { + perl - "$1" <<'PERL' +use strict; +use warnings; + +my $path = shift; +open my $source, '<', $path or die "$path: $!\n"; +my @frames; +my @heredocs; +my $quote = ''; +my $line_number = 0; + +while (my $line = <$source>) { + $line_number++; + if (@heredocs) { + my $candidate = $line; + $candidate =~ s/\r?\n\z//; + $candidate =~ s/^\t+// if $heredocs[0]{strip_tabs}; + shift @heredocs if $candidate eq $heredocs[0]{delimiter}; + next; + } + + my $length = length $line; + for (my $i = 0; $i < $length; $i++) { + my $char = substr($line, $i, 1); + if ($quote eq "'") { + $quote = '' if $char eq "'"; + next; + } + if ($char eq '\\') { + $i++; + next; + } + if ($quote eq '"' && $char eq '"') { + $quote = ''; + next; + } + if ($char eq "'" && $quote eq '') { + $quote = "'"; + next; + } + if ($char eq '"' && $quote eq '') { + $quote = '"'; + next; + } + if ($char eq '#' && $quote eq '' && ($i == 0 || substr($line, $i - 1, 1) =~ /[\s;|&()]/)) { + last; + } + if ($char eq '$' && substr($line, $i + 1, 1) eq '(') { + push @frames, { depth => 1, quote => $quote }; + $quote = ''; + $i++; + next; + } + if (@frames && $quote eq '' && $char eq '(') { + $frames[-1]{depth}++; + next; + } + if (@frames && $quote eq '' && $char eq ')') { + $frames[-1]{depth}--; + if ($frames[-1]{depth} == 0) { + my $frame = pop @frames; + $quote = $frame->{quote}; + } + next; + } + next unless $quote eq '' && $char eq '<' && substr($line, $i + 1, 1) eq '<'; + if (@frames) { + print STDERR "$path:$line_number\n"; + exit 1; + } + + my $j = $i + 2; + my $strip_tabs = substr($line, $j, 1) eq '-'; + $j++ if $strip_tabs; + $j++ while substr($line, $j, 1) =~ /[ \t]/; + my $delimiter = ''; + my $delimiter_quote = ''; + for (; $j < $length; $j++) { + my $token = substr($line, $j, 1); + if ($delimiter_quote) { + if ($token eq $delimiter_quote) { + $delimiter_quote = ''; + } elsif ($token eq '\\' && $delimiter_quote eq '"') { + $j++; + $delimiter .= substr($line, $j, 1); + } else { + $delimiter .= $token; + } + next; + } + if ($token eq "'" || $token eq '"') { + $delimiter_quote = $token; + next; + } + if ($token eq '\\') { + $j++; + $delimiter .= substr($line, $j, 1); + next; + } + last if $token =~ /[\s;|&()<>]/; + $delimiter .= $token; + } + push @heredocs, { delimiter => $delimiter, strip_tabs => $strip_tabs }; + $i = $j - 1; + } +} + +exit 0; +PERL +} + test_help_includes_entire_header() { local help help=$("$ROOT/bin/fm-brief.sh" --help) @@ -114,9 +255,13 @@ test_no_mistakes_dod_wording() { # shellcheck disable=SC2016 # single quotes are deliberate: the backticks must stay literal assert_grep '`help`' "$brief" \ "no-mistakes DOD must render literal backticks around help" - assert_no_grep "no-mistakes' own guidance" "$brief" \ - "no-mistakes DOD regressed to the apostrophe form that breaks bash -n" - pass "fm-brief.sh: no-mistakes DOD wording avoids the apostrophe regression" + # The apostrophe in "firstmate's authority check" is now structurally safe + # (no `$(...)` wrapper around the heredoc), so it renders verbatim instead of + # being reworded or escaped away. test_no_heredoc_in_command_substitution + # guards the structure that makes it safe. + assert_grep "firstmate's authority check" "$brief" \ + "no-mistakes DOD lost the apostrophe prose that the structural fix makes parse-safe" + pass "fm-brief.sh: no-mistakes DOD keeps its apostrophe prose, now parse-safe" } test_ship_project_memory_wording() { @@ -403,6 +548,7 @@ test_scout_and_secondmate_scaffold() { } test_script_parses +test_no_heredoc_in_command_substitution test_help_includes_entire_header test_ship_modes_generate_clean_briefs test_faster_paths_use_configured_authority_without_stacked_review diff --git a/tests/fm-lint.test.sh b/tests/fm-lint.test.sh index 4a1b18d7dcb..a2b3c8fb296 100755 --- a/tests/fm-lint.test.sh +++ b/tests/fm-lint.test.sh @@ -58,6 +58,20 @@ test_ci_invokes_the_owner() { pass "CI lint job calls the one-owner script, not an inline command" } +test_stock_bash_parse_uses_owner_inventory() { + local listed expected + listed=$("$LINT" --list-files) + expected=$(find bin bin/backends tests -maxdepth 1 -type f -name '*.sh' -print | LC_ALL=C sort) + [ "$(printf '%s\n' "$listed" | LC_ALL=C sort)" = "$expected" ] \ + || fail "fm-lint.sh --list-files did not return the complete canonical shell inventory" + # shellcheck disable=SC2016 # Literal assertion must remain unexpanded. + assert_grep 'bin/fm-lint.sh --list-files > "$shell_inventory"' "$CI" \ + "stock macOS Bash parse sweep must consume fm-lint.sh's canonical inventory" + assert_no_grep 'for f in bin/*.sh bin/backends/*.sh tests/*.sh' "$CI" \ + "stock macOS Bash parse sweep must not duplicate the canonical inventory" + pass "stock macOS Bash parse sweep consumes the canonical lint inventory" +} + test_nomistakes_invokes_the_owner() { grep -Fqx " lint: 'bin/fm-lint.sh'" "$NM" || fail "no-mistakes commands.lint must map exactly to the one-owner script" pass "no-mistakes pre-push lint calls the one-owner script" @@ -485,6 +499,7 @@ SH test_owner_exists_and_executable test_owner_defines_canonical_set test_ci_invokes_the_owner +test_stock_bash_parse_uses_owner_inventory test_nomistakes_invokes_the_owner test_pins_an_explicit_version test_ci_installs_and_logs_the_pinned_version diff --git a/tests/fm-merge-local.test.sh b/tests/fm-merge-local.test.sh new file mode 100755 index 00000000000..c9875a82a65 --- /dev/null +++ b/tests/fm-merge-local.test.sh @@ -0,0 +1,437 @@ +#!/usr/bin/env bash +# Tests for bin/fm-merge-local.sh: a local-only fast-forward may reconcile +# working-tree dirt only when every dirty path is proven to match the incoming +# branch content or becomes ignored and untracked at that branch head. +# +# Matrix: +# (a) byte-identical tracked content permits the fast-forward +# (b) one unresolved modified file refuses and names the path +# (c) tracked-to-ignored conversion permits and preserves on-disk content +# (d) branch-ignored untracked content permits and is preserved +# (e) a diverged branch still refuses +# (f) a non-default checkout still refuses +# (g) mixed resolved and unresolved dirt refuses and names the blocker +# (h) staged content and executable modes must also match the branch +# (i) repository and global excludes cannot stand in for branch ignore rules +# (j) already-ignored untracked files are enumerated and retained or refused +set -u + +# shellcheck disable=SC1091 +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +fm_git_identity fmtest fmtest@example.invalid + +MERGE_LOCAL="$ROOT/bin/fm-merge-local.sh" +TMP_ROOT=$(fm_test_tmproot fm-merge-local-tests) + +make_case() { + local name=$1 case_dir + case_dir="$TMP_ROOT/$name" + mkdir -p "$case_dir/state" "$case_dir/project" + + git -C "$case_dir/project" init -q + printf 'tracked base\n' >"$case_dir/project/tracked.txt" + printf 'resolved base\n' >"$case_dir/project/resolved.txt" + printf 'unresolved base\n' >"$case_dir/project/unresolved.txt" + printf 'runtime base\n' >"$case_dir/project/runtime-state.txt" + git -C "$case_dir/project" add \ + tracked.txt resolved.txt unresolved.txt runtime-state.txt + git -C "$case_dir/project" commit -qm "base" + git -C "$case_dir/project" branch -m main + git -C "$case_dir/project" worktree add -q -b fm/task-x1 "$case_dir/branch" main + + fm_write_meta "$case_dir/state/task-x1.meta" \ + "window=fm-task-x1" \ + "worktree=$case_dir/branch" \ + "project=$case_dir/project" \ + "kind=ship" \ + "mode=local-only" \ + "yolo=off" + printf '%s\n' "$case_dir" +} + +run_merge_local() { + local case_dir=$1 + FM_ROOT_OVERRIDE="$ROOT" \ + FM_STATE_OVERRIDE="$case_dir/state" \ + "$MERGE_LOCAL" task-x1 +} + +assert_main_reached_branch() { + local case_dir=$1 + [ "$(git -C "$case_dir/project" rev-parse main)" = \ + "$(git -C "$case_dir/project" rev-parse fm/task-x1)" ] \ + || fail "$2" +} + +assert_path_clean() { + local repo=$1 path=$2 + [ -z "$(git -C "$repo" status --porcelain=v1 --untracked-files=all -- "$path")" ] \ + || fail "$3" +} + +test_byte_identical_tracked_content_permits() { + local case_dir + case_dir=$(make_case byte-identical) + printf 'tracked target\n' >"$case_dir/branch/tracked.txt" + git -C "$case_dir/branch" add tracked.txt + git -C "$case_dir/branch" commit -qm "resolve tracked drift" + printf 'tracked target\n' >"$case_dir/project/tracked.txt" + + run_merge_local "$case_dir" >"$case_dir/stdout" 2>"$case_dir/stderr" \ + || fail "byte-identical: merge should succeed" + + assert_main_reached_branch "$case_dir" \ + "byte-identical: main did not fast-forward to the task branch" + assert_path_clean "$case_dir/project" tracked.txt \ + "byte-identical: previously dirty tracked path was not clean after merge" + pass "fm-merge-local permits byte-identical tracked dirt resolved by the branch" +} + +test_unresolved_modified_file_refuses_with_path() { + local case_dir before rc + case_dir=$(make_case unresolved-modified) + printf 'branch target\n' >"$case_dir/branch/unresolved.txt" + git -C "$case_dir/branch" add unresolved.txt + git -C "$case_dir/branch" commit -qm "incoming change" + printf 'different local drift\n' >"$case_dir/project/unresolved.txt" + before=$(git -C "$case_dir/project" rev-parse main) + + set +e + run_merge_local "$case_dir" >"$case_dir/stdout" 2>"$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "unresolved-modified: merge should refuse" + assert_grep 'unresolved.txt' "$case_dir/stderr" \ + "unresolved-modified: refusal did not name the unresolved path" + [ "$(git -C "$case_dir/project" rev-parse main)" = "$before" ] \ + || fail "unresolved-modified: refusal advanced main" + pass "fm-merge-local refuses unresolved modified dirt and names the path" +} + +test_untracked_conversion_permits_and_preserves_file() { + local case_dir before_hash after_hash + case_dir=$(make_case untracked-conversion) + git -C "$case_dir/branch" rm -q --cached runtime-state.txt + printf 'runtime-state.txt\n' >"$case_dir/branch/.gitignore" + git -C "$case_dir/branch" add .gitignore + git -C "$case_dir/branch" commit -qm "leave runtime log untracked" + printf 'runtime base\nruntime append\n' >"$case_dir/project/runtime-state.txt" + before_hash=$(git -C "$case_dir/project" hash-object -- runtime-state.txt) + + run_merge_local "$case_dir" >"$case_dir/stdout" 2>"$case_dir/stderr" \ + || fail "untracked-conversion: merge should succeed" + + after_hash=$(git -C "$case_dir/project" hash-object -- runtime-state.txt) + [ "$after_hash" = "$before_hash" ] \ + || fail "untracked-conversion: merge changed the on-disk runtime file" + [ -f "$case_dir/project/runtime-state.txt" ] \ + || fail "untracked-conversion: merge removed the on-disk runtime file" + ! git -C "$case_dir/project" ls-files --error-unmatch runtime-state.txt >/dev/null 2>&1 \ + || fail "untracked-conversion: runtime file remained tracked" + assert_path_clean "$case_dir/project" runtime-state.txt \ + "untracked-conversion: ignored runtime file remained dirty after merge" + assert_main_reached_branch "$case_dir" \ + "untracked-conversion: main did not fast-forward to the task branch" + pass "fm-merge-local preserves a tracked file converted to ignored-untracked" +} + +test_untracked_conversion_refuses_divergent_staged_content() { + local after_hash before before_hash before_index case_dir rc + case_dir=$(make_case untracked-conversion-staged-content) + git -C "$case_dir/branch" rm -q --cached runtime-state.txt + printf 'runtime-state.txt\n' >"$case_dir/branch/.gitignore" + git -C "$case_dir/branch" add .gitignore + git -C "$case_dir/branch" commit -qm "leave runtime log untracked" + printf 'staged runtime state\n' >"$case_dir/project/runtime-state.txt" + git -C "$case_dir/project" add runtime-state.txt + printf 'working runtime state\n' >"$case_dir/project/runtime-state.txt" + before=$(git -C "$case_dir/project" rev-parse main) + before_index=$(git -C "$case_dir/project" rev-parse :runtime-state.txt) + before_hash=$(git -C "$case_dir/project" hash-object -- runtime-state.txt) + + set +e + run_merge_local "$case_dir" >"$case_dir/stdout" 2>"$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "untracked-conversion-staged-content: merge should refuse" + assert_grep 'staged content or mode does not match the working-tree copy' \ + "$case_dir/stderr" \ + "untracked-conversion-staged-content: refusal did not diagnose divergent state" + [ "$(git -C "$case_dir/project" rev-parse main)" = "$before" ] \ + || fail "untracked-conversion-staged-content: refusal advanced main" + [ "$(git -C "$case_dir/project" rev-parse :runtime-state.txt)" = "$before_index" ] \ + || fail "untracked-conversion-staged-content: refusal changed the index" + after_hash=$(git -C "$case_dir/project" hash-object -- runtime-state.txt) + [ "$after_hash" = "$before_hash" ] \ + || fail "untracked-conversion-staged-content: refusal changed working content" + pass "fm-merge-local preserves divergent staged content by refusing conversion" +} + +test_untracked_conversion_refuses_divergent_staged_mode() { + local before before_index case_dir rc + case_dir=$(make_case untracked-conversion-staged-mode) + git -C "$case_dir/branch" rm -q --cached runtime-state.txt + printf 'runtime-state.txt\n' >"$case_dir/branch/.gitignore" + git -C "$case_dir/branch" add .gitignore + git -C "$case_dir/branch" commit -qm "leave runtime log untracked" + chmod +x "$case_dir/project/runtime-state.txt" + git -C "$case_dir/project" add runtime-state.txt + chmod -x "$case_dir/project/runtime-state.txt" + before=$(git -C "$case_dir/project" rev-parse main) + before_index=$(git -C "$case_dir/project" ls-files --stage -- runtime-state.txt) + + set +e + run_merge_local "$case_dir" >"$case_dir/stdout" 2>"$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "untracked-conversion-staged-mode: merge should refuse" + assert_grep 'staged content or mode does not match the working-tree copy' \ + "$case_dir/stderr" \ + "untracked-conversion-staged-mode: refusal did not diagnose divergent mode" + [ "$(git -C "$case_dir/project" rev-parse main)" = "$before" ] \ + || fail "untracked-conversion-staged-mode: refusal advanced main" + [ "$(git -C "$case_dir/project" ls-files --stage -- runtime-state.txt)" = \ + "$before_index" ] \ + || fail "untracked-conversion-staged-mode: refusal changed the index mode" + pass "fm-merge-local preserves divergent staged mode by refusing conversion" +} + +test_branch_ignored_untracked_file_permits() { + local case_dir before_hash after_hash + case_dir=$(make_case ignored-untracked) + printf 'scratch-state.txt\n' >"$case_dir/branch/.gitignore" + git -C "$case_dir/branch" add .gitignore + git -C "$case_dir/branch" commit -qm "ignore runtime scratch" + printf 'runtime scratch\n' >"$case_dir/project/scratch-state.txt" + before_hash=$(git -C "$case_dir/project" hash-object -- scratch-state.txt) + + run_merge_local "$case_dir" >"$case_dir/stdout" 2>"$case_dir/stderr" \ + || fail "ignored-untracked: merge should succeed" + + after_hash=$(git -C "$case_dir/project" hash-object -- scratch-state.txt) + [ "$after_hash" = "$before_hash" ] \ + || fail "ignored-untracked: merge changed the untracked file" + assert_path_clean "$case_dir/project" scratch-state.txt \ + "ignored-untracked: branch-ignored file remained dirty after merge" + assert_main_reached_branch "$case_dir" \ + "ignored-untracked: main did not fast-forward to the task branch" + pass "fm-merge-local permits an untracked path ignored at the branch head" +} + +test_diverged_branch_still_refuses() { + local case_dir rc + case_dir=$(make_case diverged) + printf 'branch-only\n' >"$case_dir/branch/branch.txt" + git -C "$case_dir/branch" add branch.txt + git -C "$case_dir/branch" commit -qm "branch change" + printf 'main-only\n' >"$case_dir/project/main.txt" + git -C "$case_dir/project" add main.txt + git -C "$case_dir/project" commit -qm "main change" + + set +e + run_merge_local "$case_dir" >"$case_dir/stdout" 2>"$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "diverged: merge should refuse" + assert_grep 'is not a fast-forward of main (it has diverged)' "$case_dir/stderr" \ + "diverged: fast-forward refusal changed or disappeared" + pass "fm-merge-local still refuses a diverged task branch" +} + +test_non_default_checkout_still_refuses() { + local case_dir rc + case_dir=$(make_case non-default) + printf 'branch target\n' >"$case_dir/branch/tracked.txt" + git -C "$case_dir/branch" add tracked.txt + git -C "$case_dir/branch" commit -qm "branch change" + git -C "$case_dir/project" checkout -qb other + + set +e + run_merge_local "$case_dir" >"$case_dir/stdout" 2>"$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "non-default: merge should refuse" + assert_grep "is on 'other', expected default branch 'main'; cannot merge safely" \ + "$case_dir/stderr" \ + "non-default: checkout refusal changed or disappeared" + pass "fm-merge-local still refuses a non-default checkout" +} + +test_mixed_resolved_and_unresolved_refuses() { + local case_dir before rc + case_dir=$(make_case mixed) + printf 'resolved target\n' >"$case_dir/branch/resolved.txt" + printf 'unresolved target\n' >"$case_dir/branch/unresolved.txt" + git -C "$case_dir/branch" add resolved.txt unresolved.txt + git -C "$case_dir/branch" commit -qm "incoming mixed changes" + printf 'resolved target\n' >"$case_dir/project/resolved.txt" + printf 'different local drift\n' >"$case_dir/project/unresolved.txt" + before=$(git -C "$case_dir/project" rev-parse main) + + set +e + run_merge_local "$case_dir" >"$case_dir/stdout" 2>"$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "mixed: merge should refuse" + assert_grep 'unresolved.txt' "$case_dir/stderr" \ + "mixed: refusal did not name the unresolved path" + [ "$(git -C "$case_dir/project" rev-parse main)" = "$before" ] \ + || fail "mixed: refusal advanced main despite one unresolved path" + pass "fm-merge-local refuses a mixed set when any dirty path is unresolved" +} + +test_divergent_staged_content_refuses() { + local case_dir before before_index rc + case_dir=$(make_case divergent-staged) + printf 'tracked target\n' >"$case_dir/branch/tracked.txt" + git -C "$case_dir/branch" add tracked.txt + git -C "$case_dir/branch" commit -qm "incoming tracked content" + printf 'staged local content\n' >"$case_dir/project/tracked.txt" + git -C "$case_dir/project" add tracked.txt + printf 'tracked target\n' >"$case_dir/project/tracked.txt" + before=$(git -C "$case_dir/project" rev-parse main) + before_index=$(git -C "$case_dir/project" rev-parse :tracked.txt) + + set +e + run_merge_local "$case_dir" >"$case_dir/stdout" 2>"$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "divergent-staged: merge should refuse" + assert_grep 'staged content does not match the branch-head blob' \ + "$case_dir/stderr" \ + "divergent-staged: refusal did not diagnose the staged content" + [ "$(git -C "$case_dir/project" rev-parse main)" = "$before" ] \ + || fail "divergent-staged: refusal advanced main" + [ "$(git -C "$case_dir/project" rev-parse :tracked.txt)" = "$before_index" ] \ + || fail "divergent-staged: refusal changed the staged content" + pass "fm-merge-local refuses divergent staged content" +} + +test_working_tree_mode_must_match() { + local case_dir before rc + case_dir=$(make_case mode-mismatch) + printf 'tracked target\n' >"$case_dir/branch/tracked.txt" + chmod +x "$case_dir/branch/tracked.txt" + git -C "$case_dir/branch" add tracked.txt + git -C "$case_dir/branch" commit -qm "incoming executable" + printf 'tracked target\n' >"$case_dir/project/tracked.txt" + chmod -x "$case_dir/project/tracked.txt" + before=$(git -C "$case_dir/project" rev-parse main) + + set +e + run_merge_local "$case_dir" >"$case_dir/stdout" 2>"$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "mode-mismatch: merge should refuse" + assert_grep 'working-tree mode 100644 does not match branch-head mode 100755' \ + "$case_dir/stderr" \ + "mode-mismatch: refusal did not diagnose the executable mode" + [ "$(git -C "$case_dir/project" rev-parse main)" = "$before" ] \ + || fail "mode-mismatch: refusal advanced main" + pass "fm-merge-local refuses a working-tree mode mismatch" +} + +test_ambient_excludes_do_not_approve_untracked_files() { + local case_dir before info_exclude rc + case_dir=$(make_case ambient-excludes) + info_exclude=$(git -C "$case_dir/project" rev-parse --absolute-git-dir) + info_exclude="$info_exclude/info/exclude" + printf 'info-only.txt\n' >>"$info_exclude" + printf 'global-only.txt\n' >"$case_dir/global-excludes" + git -C "$case_dir/project" config core.excludesFile "$case_dir/global-excludes" + printf 'repository exclude\n' >"$case_dir/project/info-only.txt" + printf 'global exclude\n' >"$case_dir/project/global-only.txt" + before=$(git -C "$case_dir/project" rev-parse main) + + set +e + run_merge_local "$case_dir" >"$case_dir/stdout" 2>"$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "ambient-excludes: merge should refuse" + assert_grep 'info-only.txt' "$case_dir/stderr" \ + "ambient-excludes: repository-excluded path was not diagnosed" + assert_grep 'global-only.txt' "$case_dir/stderr" \ + "ambient-excludes: globally excluded path was not diagnosed" + [ "$(git -C "$case_dir/project" rev-parse main)" = "$before" ] \ + || fail "ambient-excludes: refusal advanced main" + pass "fm-merge-local ignores ambient exclude sources in the target view" +} + +test_already_ignored_untracked_file_is_preserved() { + local case_dir before_hash after_hash + case_dir=$(make_case already-ignored) + printf 'generated-state.txt\n' >"$case_dir/project/.gitignore" + git -C "$case_dir/project" add .gitignore + git -C "$case_dir/project" commit -qm "ignore generated state" + git -C "$case_dir/branch" merge -q --ff-only main + printf 'branch-only\n' >"$case_dir/branch/branch.txt" + git -C "$case_dir/branch" add branch.txt + git -C "$case_dir/branch" commit -qm "incoming branch change" + printf 'generated state\n' >"$case_dir/project/generated-state.txt" + before_hash=$(git -C "$case_dir/project" hash-object -- generated-state.txt) + + run_merge_local "$case_dir" >"$case_dir/stdout" 2>"$case_dir/stderr" \ + || fail "already-ignored: merge should succeed" + + after_hash=$(git -C "$case_dir/project" hash-object -- generated-state.txt) + [ "$after_hash" = "$before_hash" ] \ + || fail "already-ignored: merge changed the ignored untracked file" + assert_main_reached_branch "$case_dir" \ + "already-ignored: main did not fast-forward to the task branch" + pass "fm-merge-local preserves an already-ignored untracked file" +} + +test_already_ignored_file_tracked_by_target_refuses() { + local case_dir before rc + case_dir=$(make_case ignored-to-tracked) + printf 'generated-state.txt\n' >"$case_dir/project/.gitignore" + git -C "$case_dir/project" add .gitignore + git -C "$case_dir/project" commit -qm "ignore generated state" + git -C "$case_dir/branch" merge -q --ff-only main + rm "$case_dir/branch/.gitignore" + printf 'incoming generated state\n' >"$case_dir/branch/generated-state.txt" + git -C "$case_dir/branch" add -f generated-state.txt + git -C "$case_dir/branch" add -u + git -C "$case_dir/branch" commit -qm "track generated state" + printf 'local generated state\n' >"$case_dir/project/generated-state.txt" + before=$(git -C "$case_dir/project" rev-parse main) + + set +e + run_merge_local "$case_dir" >"$case_dir/stdout" 2>"$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "ignored-to-tracked: merge should refuse" + assert_grep 'generated-state.txt' "$case_dir/stderr" \ + "ignored-to-tracked: refusal did not name the ignored local file" + [ "$(git -C "$case_dir/project" rev-parse main)" = "$before" ] \ + || fail "ignored-to-tracked: refusal advanced main" + assert_grep 'local generated state' "$case_dir/project/generated-state.txt" \ + "ignored-to-tracked: refusal overwrote the ignored local file" + pass "fm-merge-local refuses an ignored file tracked by the target" +} + +test_byte_identical_tracked_content_permits +test_unresolved_modified_file_refuses_with_path +test_untracked_conversion_permits_and_preserves_file +test_untracked_conversion_refuses_divergent_staged_content +test_untracked_conversion_refuses_divergent_staged_mode +test_branch_ignored_untracked_file_permits +test_diverged_branch_still_refuses +test_non_default_checkout_still_refuses +test_mixed_resolved_and_unresolved_refuses +test_divergent_staged_content_refuses +test_working_tree_mode_must_match +test_ambient_excludes_do_not_approve_untracked_files +test_already_ignored_untracked_file_is_preserved +test_already_ignored_file_tracked_by_target_refuses diff --git a/tests/fm-quota-array-dispatch.test.sh b/tests/fm-quota-array-dispatch.test.sh index a958e56c306..0c0d848fa05 100755 --- a/tests/fm-quota-array-dispatch.test.sh +++ b/tests/fm-quota-array-dispatch.test.sh @@ -153,8 +153,8 @@ test_owner_and_always_loaded_boundary() { pass "quota-array-dispatch has one conditional owner and a concise always-loaded boundary" } -test_owner_contains_acceptance_procedure() { - local phrase +test_owner_contains_selection_procedure() { + local phrase lines words bytes for phrase in \ 'reservePercentPoints = percentRemaining - timeRemainingPercent' \ 'Negative reserve means usage is ahead of reset pace and creates conservation pressure' \ @@ -163,30 +163,39 @@ test_owner_contains_acceptance_procedure() { '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' \ + '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' \ + '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 + 'Do not add a daemon, opaque composite score, routing wrapper, hard-coded model-specific policy' \ + 'Report duplicate concrete profiles as a configuration error' \ + 'Name the inspectable facts used for every candidate'; do assert_grep "$phrase" "$OWNER" "quota-array-dispatch procedure lost '$phrase'" done + # Expanded acceptance scenarios live in deterministic fixtures, not runtime prose. 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" + 'Sanitized producer shape' \ + '## When to load' \ + '## Intake boundary this skill does not relax'; do + if grep -Fq -- "$phrase" "$OWNER"; then + fail "quota-array-dispatch should not keep removed runtime prose: $phrase" + fi done - pass "quota-array-dispatch owns the full pace procedure and acceptance scenarios" + + lines=$(wc -l < "$OWNER" | tr -d ' ') + words=$(wc -w < "$OWNER" | tr -d ' ') + bytes=$(wc -c < "$OWNER" | tr -d ' ') + [ "$lines" -le 65 ] || fail "quota-array-dispatch skill is too long: $lines lines (want <= 65)" + [ "$words" -le 550 ] || fail "quota-array-dispatch skill is too wordy: $words words (want <= 550)" + [ "$bytes" -le 4600 ] || fail "quota-array-dispatch skill is too large: $bytes bytes (want <= 4600)" + pass "quota-array-dispatch owns the compact pace procedure ($lines lines, $words words, $bytes bytes)" } test_cross_references_stay_pointers() { @@ -271,7 +280,7 @@ test_no_duplicate_procedure_in_agents() { } test_owner_and_always_loaded_boundary -test_owner_contains_acceptance_procedure +test_owner_contains_selection_procedure test_cross_references_stay_pointers test_schema_v3_shape_fixture test_deterministic_acceptance_cases From c3922935a41868b8c6a7c7e3b4aea65abe2596c8 Mon Sep 17 00:00:00 2001 From: quinnbot-ai Date: Tue, 28 Jul 2026 15:26:23 -0700 Subject: [PATCH 10/70] feat: add durable link intake with process-crash-atomic publication (#60) * feat: add durable link intake * no-mistakes(review): Fix intake atomicity and composer safety contracts * no-mistakes(review): Make link intake publication crash-safe * no-mistakes(review): Harden portable process-crash atomic link intake * no-mistakes(review): Harden lock recovery and retrieval date safety * no-mistakes(review): Stabilize lock identity across upgrades * no-mistakes(document): Correct link intake verification output --------- Co-authored-by: QuinnBot --- AGENTS.md | 1 + README.md | 3 + bin/fm-link-intake.sh | 731 ++++++++++++++++++++++++++++++ docs/documentation-audiences.json | 8 + docs/link-intake.md | 20 + docs/scripts.md | 1 + docs/verification/link-intake.md | 23 + tests/fm-link-intake.test.sh | 482 ++++++++++++++++++++ 8 files changed, 1269 insertions(+) create mode 100755 bin/fm-link-intake.sh create mode 100644 docs/link-intake.md create mode 100644 docs/verification/link-intake.md create mode 100755 tests/fm-link-intake.test.sh diff --git a/AGENTS.md b/AGENTS.md index 18e1df795e7..c9d7ff87dd6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -239,6 +239,7 @@ Send in-scope work to the fitting secondmate unless it is blocked or the captain If no secondmate scope fits, use the main home or discuss creating an appropriate persistent secondmate. For one-off or infrequent operational work, start with the simplest direct end-to-end path. Do not build wrappers, control planes, policy layers, custom verifiers, or automation unless the direct path exposes a concrete blocker or repeated need that justifies the added machinery. +Link intake: whenever the captain sends meaningful URL input, inspect each link with the suitable existing browser or media tool and record its normalized result or visible failure through `bin/fm-link-intake.sh`. Before commissioning an investigation, consult existing reports and established evidence. Classify the deliverable: diff --git a/README.md b/README.md index c0f5e659aaf..7daaaa8813d 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ Launching a supported harness inside it instantiates your first mate - and makes - **A visible crew** - every crewmate works in its own tmux window, experimental herdr/zellij tab, cmux workspace, or Orca terminal you can watch or type into; the first mate reconciles. - **Disposable worktrees** - each task runs in a clean [treehouse](https://github.com/kunchenguid/treehouse) git worktree, or an Orca-managed worktree when `backend=orca`, so parallel work on one repo never collides. - **Two task shapes** - ship tasks deliver authorized changes; scout tasks leave standalone investigation reports when the intake contract warrants separate research. +- **Durable link intake** - meaningful URLs are inspected and retained privately as searchable evidence without granting external-action authority. - **Explicit project modes** - each project ships via `no-mistakes`, `direct-PR`, or `local-only`, with an optional `+yolo` autonomy flag. - **Optional secondmates** - opt in to persistent second mates that run from isolated firstmate homes with their own `FM_HOME`, state, projects, and session lock, supervising project clones or a project-less firstmate-repo domain, kept on the primary firstmate version by guarded local fast-forwards and checked for live agent processes at session start. - **Event-driven, zero-token supervision** - a bash watcher sleeps on the fleet and wakes the first mate only when something needs you; verified primary harnesses also get a turn-end backstop that blocks or follows up on a blind stop when work is under way and supervision is not live. @@ -205,10 +206,12 @@ Firstmate's skills live in two separate places with different audiences: - [docs/orca-backend.md](docs/orca-backend.md) - current setup and limits for the experimental Orca backend. - [docs/cmux-backend.md](docs/cmux-backend.md) - current setup, socket security, and limits for the experimental cmux backend. - [docs/codex-app-backend.md](docs/codex-app-backend.md) - the current blocked Codex App backend boundary and rollout contract. +- [docs/link-intake.md](docs/link-intake.md) - current private link-intake behavior, safety boundary, and verification entry point. - [docs/verification/runtime-backends.md](docs/verification/runtime-backends.md) - active maintainer verification for runtime backend guarantees. - [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/verification/link-intake.md](docs/verification/link-intake.md) - active maintainer verification for durable link-intake records. - [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. diff --git a/bin/fm-link-intake.sh b/bin/fm-link-intake.sh new file mode 100755 index 00000000000..7f6077585d8 --- /dev/null +++ b/bin/fm-link-intake.sh @@ -0,0 +1,731 @@ +#!/usr/bin/env bash +# fm-link-intake.sh - idempotently store validated, private Firstmate link-intake records. +# +# Usage: +# bin/fm-link-intake.sh upsert --url URL --source-type TYPE --title TITLE --summary SUMMARY --terms TERMS --claim TEXT [--claim TEXT ...] [--canonical-url URL] [--retrieved-at YYYY-MM-DD] [--transcript-file PATH | --transcript-unavailable REASON] [--failure REASON] +# bin/fm-link-intake.sh validate +# bin/fm-link-intake.sh validate --all +# bin/fm-link-intake.sh --help +# +# Records and the searchable index live in the generation selected by +# $FM_HOME/data/link-intake/current. +# The helper owns their exact Markdown and TSV formats, canonicalization, validation, +# history snapshots, and single-switch process-crash atomic publication. +# It does not issue filesystem sync barriers, so power-loss durability depends on +# the host filesystem. +# It never retrieves pages, downloads media, authenticates, or makes external changes. +# A caller supplies only normalized public or otherwise authorized metadata after using +# the appropriate existing browser or media tool. +# Video and audio require either a transcript file to retain privately or an explicit +# unavailable reason, unless the retrieval itself failed, in which case that failure is +# recorded as the transcript reason too. +# Bash 3.2 compatible. + +set -u + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +FM_HOME=${FM_HOME:-$(cd "$SCRIPT_DIR/.." && pwd)} +LINK_ROOT="$FM_HOME/data/link-intake" +GENERATIONS_DIR="$LINK_ROOT/generations" +CURRENT_LINK="$LINK_ROOT/current" +LOCK_DIR="$LINK_ROOT/.update-lock" +LOCK_HELD=0 +LOCK_OWNER_DIR= +STAGED_GENERATION= +STAGED_LINK= +CURRENT_GENERATION= +QUIET_CURRENT_GENERATION= + +remove_generation() { + local generation=$1 + case "$generation" in + "$GENERATIONS_DIR"/.generation.*) + rm -rf -- "$generation" + ;; + *) + return 1 + ;; + esac +} + +resolve_current_quiet() { + local target name + QUIET_CURRENT_GENERATION= + [ -L "$CURRENT_LINK" ] || return 1 + target=$(readlink "$CURRENT_LINK") || return 1 + case "$target" in + generations/.generation.*) ;; + *) return 1 ;; + esac + name=${target#generations/} + case "$name" in + */*) return 1 ;; + esac + [ -d "$LINK_ROOT/$target" ] || return 1 + QUIET_CURRENT_GENERATION="$LINK_ROOT/$target" +} + +cleanup() { + local status=$? lock_target owner_name + trap - EXIT HUP INT TERM + if [ -n "$STAGED_LINK" ] && ! rm -f "$STAGED_LINK"; then + printf 'error: temporary state link retained: %s\n' "$STAGED_LINK" >&2 + [ "$status" -ne 0 ] || status=2 + fi + if [ -n "$STAGED_GENERATION" ]; then + if resolve_current_quiet; then + if [ "$QUIET_CURRENT_GENERATION" = "$STAGED_GENERATION" ]; then + STAGED_GENERATION= + elif remove_generation "$STAGED_GENERATION"; then + STAGED_GENERATION= + else + printf 'error: recoverable staged generation retained: %s\n' "$STAGED_GENERATION" >&2 + [ "$status" -ne 0 ] || status=2 + fi + else + printf 'error: recoverable staged generation retained: %s\n' "$STAGED_GENERATION" >&2 + [ "$status" -ne 0 ] || status=2 + fi + fi + if [ -n "$LOCK_OWNER_DIR" ]; then + owner_name=${LOCK_OWNER_DIR##*/} + lock_target=$(readlink "$LOCK_DIR" 2>/dev/null || true) + if [ "$LOCK_HELD" = 1 ]; then + if [ "$lock_target" = "$owner_name" ]; then + if rm -f "$LOCK_DIR"; then + LOCK_HELD=0 + else + printf 'error: link-intake lock retained: %s\n' "$LOCK_DIR" >&2 + [ "$status" -ne 0 ] || status=2 + fi + else + printf 'error: link-intake lock ownership retained: %s\n' "$LOCK_OWNER_DIR" >&2 + [ "$status" -ne 0 ] || status=2 + fi + fi + if [ "$LOCK_HELD" = 0 ]; then + rm -f "$LOCK_OWNER_DIR/owner" 2>/dev/null || true + rmdir "$LOCK_OWNER_DIR" 2>/dev/null || true + fi + fi + exit "$status" +} + +trap cleanup EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +die() { + printf 'error: %s\n' "$*" >&2 + exit 2 +} + +usage() { + cat <<'EOF' +Usage: + bin/fm-link-intake.sh upsert --url URL --source-type TYPE --title TITLE --summary SUMMARY --terms TERMS --claim TEXT [--claim TEXT ...] [--canonical-url URL] [--retrieved-at YYYY-MM-DD] [--transcript-file PATH | --transcript-unavailable REASON] [--failure REASON] + bin/fm-link-intake.sh validate + bin/fm-link-intake.sh validate --all + +Source types: article, web, video, audio, document, image, other. + +`upsert` preserves every supplied original URL, snapshots a replaced record under +data/link-intake/current/history/, and prints the current record path. +`--failure` creates a visible inaccessible record when title, summary, claims, and +terms cannot be obtained. +For video or audio, provide a legally accessible `--transcript-file` or explain why +it is unavailable with `--transcript-unavailable`. +EOF +} + +require_one_line() { + local field=$1 value=$2 + [ -n "$value" ] || die "$field is required" + case "$value" in + *$'\n'*|*$'\r'*|*$'\t'*) die "$field must be one line without tabs" ;; + esac +} + +sha256() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum | awk '{print $1}' + else + die 'shasum or sha256sum is required' + fi +} + +canonicalize_url() { + local input=$1 without_fragment scheme rest authority suffix lowered_authority + require_one_line URL "$input" + without_fragment=${input%%#*} + scheme=${without_fragment%%://*} + case "$(printf '%s' "$scheme" | tr '[:upper:]' '[:lower:]')" in + http|https) ;; + *) die 'URL must use http or https' ;; + esac + rest=${without_fragment#*://} + authority=${rest%%[/?]*} + suffix=${rest#"$authority"} + [ -n "$authority" ] || die 'URL must include a host' + case "$authority" in + *@*|*' '*|*\\*) die 'URL authority is not accepted' ;; + esac + scheme=$(printf '%s' "$scheme" | tr '[:upper:]' '[:lower:]') + lowered_authority=$(printf '%s' "$authority" | tr '[:upper:]' '[:lower:]') + case "$scheme:$lowered_authority" in + http:*:80) lowered_authority=${lowered_authority%:80} ;; + https:*:443) lowered_authority=${lowered_authority%:443} ;; + esac + printf '%s://%s%s\n' "$scheme" "$lowered_authority" "$suffix" +} + +url_host() { + local rest authority + rest=${1#*://} + authority=${rest%%/*} + authority=${authority%%\?*} + printf '%s\n' "${authority%%:*}" +} + +record_id_for() { + printf '%s' "$1" | sha256 +} + +logical_record_path_for() { + local id + id=$(record_id_for "$1") + printf '%s/records/%s.md\n' "$CURRENT_LINK" "$id" +} + +is_calendar_date() { + local value=$1 year month day maximum + case "$value" in + [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]) ;; + *) return 1 ;; + esac + year=${value%%-*} + month=${value#*-} + month=${month%%-*} + day=${value##*-} + year=$((10#$year)) + month=$((10#$month)) + day=$((10#$day)) + [ "$year" -ge 1 ] && [ "$month" -ge 1 ] && [ "$month" -le 12 ] && [ "$day" -ge 1 ] || return 1 + case "$month" in + 1|3|5|7|8|10|12) maximum=31 ;; + 4|6|9|11) maximum=30 ;; + 2) + maximum=28 + if [ $((year % 400)) -eq 0 ] || { [ $((year % 4)) -eq 0 ] && [ $((year % 100)) -ne 0 ]; }; then + maximum=29 + fi + ;; + esac + [ "$day" -le "$maximum" ] +} + +process_start_identity() { + local identity + identity=$(TZ=UTC0 LC_ALL=C ps -o lstart= -p "$1" 2>/dev/null | awk 'NF { $1=$1; print; exit }') + [ -n "$identity" ] || return 1 + printf '%s\n' "$identity" +} + +LOCK_OWNER_PID= +LOCK_OWNER_START= + +lock_owner_is_live() { + local owner_file=$1 current_start + LOCK_OWNER_PID=$(sed -n '1p' "$owner_file" 2>/dev/null || true) + LOCK_OWNER_START=$(sed -n '2p' "$owner_file" 2>/dev/null || true) + case "$LOCK_OWNER_PID" in + ''|*[!0-9]*) return 2 ;; + esac + kill -0 "$LOCK_OWNER_PID" 2>/dev/null || return 1 + [ -n "$LOCK_OWNER_START" ] || return 2 + current_start=$(process_start_identity "$LOCK_OWNER_PID") || return 2 + [ "$current_start" = "$LOCK_OWNER_START" ] +} + +create_symlink_no_target() { + local source=$1 target=$2 + if ln --help 2>&1 | grep -F -q -- '--no-target-directory'; then + ln -sT "$source" "$target" + else + ln -sh "$source" "$target" + fi +} + +acquire_lock() { + local owner_path target stale stale_lock quarantined_target attempt=0 claim_name path owner_status self_start + mkdir -p "$GENERATIONS_DIR" || die 'could not initialize link-intake storage' + LOCK_OWNER_DIR=$(mktemp -d "$LINK_ROOT/.lock-owner.XXXXXX") \ + || die 'could not create link-intake lock claim' + self_start=$(process_start_identity "$$") \ + || die 'could not identify link-intake lock process' + printf '%s\n%s\n' "$$" "$self_start" > "$LOCK_OWNER_DIR/owner" \ + || die 'could not initialize link-intake lock claim' + claim_name=${LOCK_OWNER_DIR##*/} + while [ "$attempt" -lt 5 ]; do + if create_symlink_no_target "$claim_name" "$LOCK_DIR" 2>/dev/null; then + LOCK_HELD=1 + for path in "$LINK_ROOT"/.lock-owner.*; do + [ -d "$path" ] || continue + [ "$path" = "$LOCK_OWNER_DIR" ] && continue + lock_owner_is_live "$path/owner" + owner_status=$? + [ "$owner_status" = 1 ] || continue + rm -f "$path/owner" 2>/dev/null || true + rmdir "$path" 2>/dev/null || true + done + for path in "$LINK_ROOT"/.stale-update-lock.*; do + [ -L "$path" ] || continue + rm -f "$path" 2>/dev/null || true + done + for path in "$LINK_ROOT"/.stale-lock-owner.*; do + [ -d "$path" ] || continue + rm -f "$path/owner" 2>/dev/null || true + rmdir "$path" 2>/dev/null || true + done + return 0 + fi + [ -L "$LOCK_DIR" ] || die 'link-intake update lock is invalid' + target=$(readlink "$LOCK_DIR") || die 'link-intake update lock is unreadable' + case "$target" in + .lock-owner.*) ;; + *) die 'link-intake update lock target is invalid' ;; + esac + case "$target" in + */*) die 'link-intake update lock target is invalid' ;; + esac + owner_path="$LINK_ROOT/$target" + stale="$LINK_ROOT/.stale-lock-owner.${target#.lock-owner.}" + if [ ! -e "$owner_path" ] && [ ! -L "$owner_path" ]; then + if [ -d "$stale" ] && mv "$stale" "$owner_path" 2>/dev/null; then + attempt=$((attempt + 1)) + continue + fi + die "link-intake update lock owner is absent: $owner_path" + fi + if lock_owner_is_live "$owner_path/owner"; then + die 'another link-intake update is in progress' + else + owner_status=$? + fi + [ "$owner_status" = 1 ] || die 'link-intake update lock owner identity is unreadable' + if mv "$owner_path" "$stale" 2>/dev/null; then + stale_lock="$LINK_ROOT/.stale-update-lock.${target#.lock-owner.}" + [ ! -e "$stale_lock" ] && [ ! -L "$stale_lock" ] \ + || die "stale link-intake lock quarantine retained: $stale" + mv "$LOCK_DIR" "$stale_lock" 2>/dev/null \ + || die "stale link-intake lock recovery retained: $stale" + quarantined_target=$(readlink "$stale_lock" 2>/dev/null || true) + [ "$quarantined_target" = "$target" ] \ + || die "stale link-intake lock quarantine retained: $stale_lock" + rm -f "$stale_lock" || die "stale link-intake lock quarantine retained: $stale_lock" + if [ -L "$stale" ]; then + rm -f "$stale" || die "stale link-intake lock retained: $stale" + else + rm -f "$stale/owner" || die "stale link-intake lock retained: $stale" + rmdir "$stale" || die "stale link-intake lock retained: $stale" + fi + fi + attempt=$((attempt + 1)) + done + die 'could not acquire link-intake update lock' +} + +resolve_current() { + local target name + CURRENT_GENERATION= + if [ ! -e "$CURRENT_LINK" ] && [ ! -L "$CURRENT_LINK" ]; then + return 1 + fi + [ -L "$CURRENT_LINK" ] || die "link-intake current state is not a symbolic link: $CURRENT_LINK" + target=$(readlink "$CURRENT_LINK") || die "link-intake current state is unreadable: $CURRENT_LINK" + case "$target" in + generations/.generation.*) ;; + *) die "link-intake current state target is invalid: $target" ;; + esac + name=${target#generations/} + case "$name" in + */*) die "link-intake current state target is invalid: $target" ;; + esac + CURRENT_GENERATION="$LINK_ROOT/$target" + [ -d "$CURRENT_GENERATION" ] || die "link-intake current generation is absent: $CURRENT_GENERATION" +} + +prune_abandoned_generations() { + local current=${1:-} generation + for generation in "$GENERATIONS_DIR"/.generation.*; do + [ -d "$generation" ] || continue + [ -n "$current" ] && [ "$generation" = "$current" ] && continue + remove_generation "$generation" || die "recoverable staged generation retained: $generation" + done +} + +create_staged_generation() { + local current=${1:-} + STAGED_GENERATION=$(mktemp -d "$GENERATIONS_DIR/.generation.XXXXXX") \ + || die 'could not create staged link-intake generation' + if [ -n "$current" ]; then + cp -R "$current/." "$STAGED_GENERATION/" || die 'could not copy current link-intake generation' + else + mkdir -p "$STAGED_GENERATION/records" "$STAGED_GENERATION/transcripts" "$STAGED_GENERATION/history" \ + || die 'could not initialize staged link-intake generation' + printf 'canonical_url\trecord_id\tretrieved_at\tsource_type\ttitle\tstatus\tsearch_terms\n' \ + > "$STAGED_GENERATION/index.tsv" || die 'could not initialize staged link-intake index' + fi +} + +existing_original_urls() { + awk ' + /^Original URLs:$/ { seen=1; next } + seen && /^- / { sub(/^- /, ""); print; next } + seen { exit } + ' "$1" +} + +unique_original_urls() { + local existing=$1 original=$2 + { + [ -n "$existing" ] && [ -f "$existing" ] && existing_original_urls "$existing" + printf '%s\n' "$original" + } | LC_ALL=C sort -u +} + +validate_record() { + local record=$1 expected=${2:-} generation=$3 + local canonical normalized record_id expected_id source title summary terms transcript status originals claims failure retrieved original + [ -f "$record" ] || die "record is absent: $record" + record_id=$(sed -n 's/^Record ID: //p' "$record" | head -1) + canonical=$(sed -n 's/^Canonical URL: //p' "$record" | head -1) + source=$(sed -n 's/^Source type: //p' "$record" | head -1) + title=$(sed -n 's/^Title: //p' "$record" | head -1) + summary=$(sed -n 's/^Summary: //p' "$record" | head -1) + terms=$(sed -n 's/^Search terms: //p' "$record" | head -1) + transcript=$(sed -n 's/^Transcript: //p' "$record" | head -1) + status=$(sed -n 's/^Retrieval status: //p' "$record" | head -1) + failure=$(sed -n 's/^Failure: //p' "$record" | head -1) + retrieved=$(sed -n 's/^Retrieved at: //p' "$record" | head -1) + [ -n "$canonical" ] || die "record is missing canonical URL: $record" + [ -z "$expected" ] || [ "$canonical" = "$expected" ] || die "record canonical URL does not match: $record" + normalized=$(canonicalize_url "$canonical") + [ "$canonical" = "$normalized" ] || die "record canonical URL is not normalized: $record" + expected_id=$(record_id_for "$canonical") + [ "$record_id" = "$expected_id" ] || die "record ID does not match canonical URL: $record" + case "$source" in article|web|video|audio|document|image|other) ;; *) die "record source type is invalid: $record" ;; esac + case "$status" in captured|inaccessible) ;; *) die "record status is invalid: $record" ;; esac + require_one_line 'record title' "$title" + require_one_line 'record summary' "$summary" + require_one_line 'record search terms' "$terms" + is_calendar_date "$retrieved" || die "record retrieval date is invalid: $record" + [ -n "$transcript" ] || die "record is missing transcript metadata: $record" + originals=$(existing_original_urls "$record") + [ -n "$originals" ] || die "record has no original URL: $record" + while IFS= read -r original; do + canonicalize_url "$original" >/dev/null + done < 1 && $2 == id { count++ } END { print count + 0 }' "$index") + [ "$matches" = 1 ] || die "record is absent from index: $path" + done + for path in "$history"/*; do + [ -e "$path" ] || continue + [ -d "$path" ] || die "unexpected entry in history directory: $path" + id=${path##*/} + for entry in "$path"/*; do + [ -e "$entry" ] || continue + [ -f "$entry" ] || die "unexpected entry in record history: $entry" + basename=${entry##*/} + case "$basename" in *.md) ;; *) die "unexpected entry in record history: $entry" ;; esac + validate_record "$entry" '' "$generation" + record_id=$(sed -n 's/^Record ID: //p' "$entry" | head -1) + [ "$record_id" = "$id" ] || die "history record ID does not match directory: $entry" + done + done + for path in "$transcripts"/*/*.txt; do + [ -f "$path" ] || continue + relative=${path#"$generation/"} + matches=$(grep -R -F -l "Transcript: stored at $relative" "$records" "$history" 2>/dev/null | wc -l | tr -d ' ') + [ "$matches" -gt 0 ] || die "transcript is absent from records and history: $path" + done + VALIDATED_RECORD_COUNT=$count +} + +prepare_index() { + local generation=$1 canonical=$2 id=$3 retrieved=$4 source=$5 title=$6 status=$7 terms=$8 + local index="$generation/index.tsv" tmp + tmp=$(mktemp "$generation/.index.XXXXXX") || die 'could not create staged index update' + { + printf 'canonical_url\trecord_id\tretrieved_at\tsource_type\ttitle\tstatus\tsearch_terms\n' + awk -F '\t' -v id="$id" 'NR > 1 && $2 != id { print }' "$index" + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$canonical" "$id" "$retrieved" "$source" "$title" "$status" "$terms" + } > "$tmp" || die 'could not write staged index update' + mv "$tmp" "$index" || die 'could not install staged index update' +} + +snapshot_existing_record() { + local generation=$1 record=$2 id=$3 + local digest destination tmp previous_retrieved + [ -f "$record" ] || return 0 + digest=$(sha256 < "$record") + previous_retrieved=$(sed -n 's/^Retrieved at: //p' "$record" | head -1) + destination="$generation/history/$id/${previous_retrieved}-${digest}.md" + [ -f "$destination" ] && return 0 + mkdir -p "$generation/history/$id" || die 'could not initialize record history' + tmp=$(mktemp "$generation/history/$id/.snapshot.XXXXXX") || die 'could not create record history snapshot' + cp "$record" "$tmp" || die 'could not stage record history snapshot' + mv "$tmp" "$destination" || die 'could not install staged record history snapshot' +} + +copy_transcript() { + local generation=$1 source=$2 id=$3 + local digest directory destination tmp + [ -f "$source" ] || die "transcript file is absent: $source" + digest=$(sha256 < "$source") + directory="$generation/transcripts/$id" + destination="$directory/$digest.txt" + if [ ! -f "$destination" ]; then + mkdir -p "$directory" || die 'could not initialize transcript storage' + tmp=$(mktemp "$directory/.transcript.XXXXXX") || die 'could not create staged transcript' + cp "$source" "$tmp" || die 'could not stage transcript' + mv "$tmp" "$destination" || die 'could not install staged transcript' + fi + printf 'transcripts/%s/%s.txt\n' "$id" "$digest" +} + +replace_symlink() { + local source=$1 target=$2 + if mv --help 2>&1 | grep -F -q -- '--no-target-directory'; then + mv -fT "$source" "$target" + else + mv -fh "$source" "$target" + fi +} + +publish_generation() { + local target + target="generations/${STAGED_GENERATION##*/}" + STAGED_LINK=$(mktemp "$LINK_ROOT/.current.XXXXXX") || die 'could not create current-state switch' + rm -f "$STAGED_LINK" || die 'could not prepare current-state switch' + ln -s "$target" "$STAGED_LINK" || die 'could not prepare current-state target' + replace_symlink "$STAGED_LINK" "$CURRENT_LINK" || die 'could not publish link-intake generation' + STAGED_LINK= + STAGED_GENERATION= +} + +command_upsert() { + local original='' canonical='' canonical_input='' source='' title='' summary='' terms='' retrieved='' transcript_file='' transcript_unavailable='' failure='' argument claim + local claims='' id current='' record existing transcript note status host path record_tmp logical_record + while [ "$#" -gt 0 ]; do + case "$1" in + --url) shift; original=${1:-} ;; + --canonical-url) shift; canonical_input=${1:-} ;; + --source-type) shift; source=${1:-} ;; + --title) shift; title=${1:-} ;; + --summary) shift; summary=${1:-} ;; + --terms) shift; terms=${1:-} ;; + --claim) shift; claim=${1:-}; require_one_line claim "$claim"; claims="${claims}${claims:+$'\n'}$claim" ;; + --retrieved-at) shift; retrieved=${1:-} ;; + --transcript-file) shift; transcript_file=${1:-} ;; + --transcript-unavailable) shift; transcript_unavailable=${1:-} ;; + --failure) shift; failure=${1:-} ;; + -h|--help|help) usage; return 0 ;; + *) die "unknown argument: $1" ;; + esac + [ "$#" -gt 0 ] || die 'missing value for option' + shift + done + require_one_line URL "$original" + canonical=$(canonicalize_url "${canonical_input:-$original}") || return $? + [ -z "$canonical_input" ] || require_one_line 'canonical URL' "$canonical_input" + case "$source" in article|web|video|audio|document|image|other) ;; *) die 'source type must be article, web, video, audio, document, image, or other' ;; esac + if [ -n "$retrieved" ]; then + is_calendar_date "$retrieved" || die 'retrieved-at must be a real YYYY-MM-DD calendar date' + else + retrieved=$(date -u +%F) + fi + require_one_line 'retrieved-at' "$retrieved" + if [ -n "$failure" ]; then + require_one_line failure "$failure" + status=inaccessible + [ -n "$title" ] || title=Unavailable + [ -n "$summary" ] || summary="Retrieval failed: $failure" + [ -n "$claims" ] || claims="Could not retrieve the source: $failure" + [ -n "$terms" ] || { host=$(url_host "$canonical"); terms="inaccessible,$host"; } + else + status=captured + fi + require_one_line title "$title" + require_one_line summary "$summary" + require_one_line 'search terms' "$terms" + [ -n "$claims" ] || die 'at least one --claim is required unless --failure is given' + [ -z "$transcript_file" ] || [ -z "$transcript_unavailable" ] || die 'provide either transcript-file or transcript-unavailable, not both' + case "$source" in + video|audio) + if [ -z "$transcript_file" ]; then + if [ -z "$transcript_unavailable" ] && [ -n "$failure" ]; then + transcript_unavailable=$failure + fi + require_one_line 'transcript-unavailable reason' "$transcript_unavailable" + else + [ -f "$transcript_file" ] || die "transcript file is absent: $transcript_file" + fi + ;; + *) + [ -z "$transcript_file" ] || die 'transcript-file is only supported for video or audio records' + [ -z "$transcript_unavailable" ] || die 'transcript-unavailable is only supported for video or audio records' + ;; + esac + acquire_lock + if resolve_current; then + current=$CURRENT_GENERATION + validate_state "$current" + fi + prune_abandoned_generations "$current" + create_staged_generation "$current" + id=$(record_id_for "$canonical") + record="$STAGED_GENERATION/records/$id.md" + existing=$record + case "$source" in + video|audio) + if [ -n "$transcript_file" ]; then + transcript=$(copy_transcript "$STAGED_GENERATION" "$transcript_file" "$id") || return $? + note="stored at $transcript" + else + note="unavailable: $transcript_unavailable" + fi + ;; + *) note='not applicable' ;; + esac + record_tmp=$(mktemp "$STAGED_GENERATION/records/.record.XXXXXX") || die 'could not create staged record update' + { + printf '# Link intake record\n\n' + printf 'Record ID: %s\n' "$id" + printf 'Canonical URL: %s\n' "$canonical" + printf 'Original URLs:\n' + unique_original_urls "$existing" "$original" | while IFS= read -r argument; do printf '%s\n' "- $argument"; done + printf 'Retrieved at: %s\n' "$retrieved" + printf 'Retrieval status: %s\n' "$status" + printf 'Source type: %s\n' "$source" + printf 'Title: %s\n' "$title" + printf 'Summary: %s\n' "$summary" + printf 'Search terms: %s\n' "$terms" + printf 'Transcript: %s\n' "$note" + printf 'Failure: %s\n\n' "${failure:-none}" + printf '## Key claims or takeaways\n' + printf '%s\n' "$claims" | while IFS= read -r argument; do printf '%s\n' "- $argument"; done + } > "$record_tmp" || die 'could not write staged record update' + validate_record "$record_tmp" "$canonical" "$STAGED_GENERATION" + if [ -f "$record" ] && ! cmp -s "$record" "$record_tmp"; then + snapshot_existing_record "$STAGED_GENERATION" "$record" "$id" + fi + mv "$record_tmp" "$record" || die 'could not install staged record update' + prepare_index "$STAGED_GENERATION" "$canonical" "$id" "$retrieved" "$source" "$title" "$status" "$terms" + validate_state "$STAGED_GENERATION" + publish_generation + logical_record=$(logical_record_path_for "$canonical") + printf '%s\n' "$logical_record" +} + +command_validate() { + local target=${1:-} canonical record + [ "$#" -eq 1 ] || die 'validate accepts one URL or --all' + acquire_lock + resolve_current || die 'link-intake current state is absent' + prune_abandoned_generations "$CURRENT_GENERATION" + validate_state "$CURRENT_GENERATION" + if [ "$target" = --all ]; then + printf 'valid: %s records\n' "$VALIDATED_RECORD_COUNT" + return 0 + fi + canonical=$(canonicalize_url "$target") || return $? + record="$CURRENT_GENERATION/records/$(record_id_for "$canonical").md" + validate_record "$record" "$canonical" "$CURRENT_GENERATION" + printf 'valid: %s\n' "$(logical_record_path_for "$canonical")" +} + +main() { + case "${1:-}" in + upsert) shift; command_upsert "$@" ;; + validate) shift; command_validate "$@" ;; + -h|--help|help) usage ;; + *) usage >&2; exit 2 ;; + esac +} + +main "$@" diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index 54b2190f6c8..4726358ce97 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -255,6 +255,10 @@ "path": "docs/herdr-backend.md", "audience": "operator-current" }, + { + "path": "docs/link-intake.md", + "audience": "operator-current" + }, { "path": "docs/orca-backend.md", "audience": "operator-current" @@ -307,6 +311,10 @@ "path": "docs/verification/runtime-backends.md", "audience": "maintainer-verification" }, + { + "path": "docs/verification/link-intake.md", + "audience": "maintainer-verification" + }, { "path": "docs/verification/supervision.md", "audience": "maintainer-verification" diff --git a/docs/link-intake.md b/docs/link-intake.md new file mode 100644 index 00000000000..9ba5aabe4a9 --- /dev/null +++ b/docs/link-intake.md @@ -0,0 +1,20 @@ +# Link intake + +When the captain sends one or more meaningful URLs, the active Firstmate inspects every link and retains a private searchable record for each result. +Records live in the generation selected by `FM_HOME/data/link-intake/current`, which remains gitignored with the rest of `data/`. +The record keeps the canonical URL, every supplied original URL, title, retrieval date, source type, summary, claims or takeaways, searchable terms, transcript outcome, and a visible failure reason when retrieval is unavailable. + +The active agent uses the existing browser or media tool suitable for the source, then gives the normalized result to [`bin/fm-link-intake.sh`](scripts.md#the-bin-toolbelt). +The helper's header and `--help` own the record format, exact flags, canonicalization, validation, history snapshots, and process-crash atomic-write behavior. +Each update stages and validates a complete generation, then publishes its records, searchable index, history, and transcripts through one process-crash atomic `current` switch. +An interrupted updater leaves the prior or next complete generation selected, and the next helper invocation reclaims stale lock and staging state. +The helper does not issue filesystem sync barriers, so power-loss durability depends on the host filesystem. +Repeated intake of a canonical URL updates one current record while retaining original URLs and preserving the replaced record as private history. + +Video and audio intake retains a legally and technically accessible transcript under the same private location or records why no transcript can be obtained. +An inaccessible, private, deleted, or otherwise unreadable source still receives a visible record with its failure reason. +Link intake preserves attribution and verification evidence only. +It never authorizes publishing, messaging, buying, sensitive-account login, or any other external mutation. + +Run `bin/fm-link-intake.sh validate --all` to verify the selected generation's records, index, history, and transcript references in both directions. +[`verification/link-intake.md`](verification/link-intake.md) carries the active maintainer evidence. diff --git a/docs/scripts.md b/docs/scripts.md index 6a10d1310ae..c50490eeeae 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -10,6 +10,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-session-start.sh` | Compose lock, bootstrap, and wake drain into the single ordered session-start digest | | `fm-sessionstart-nudge.sh` | Print the native session-start hook nudge when the primary has not already run the digest | | `fm-operational-input.sh` | Construct and parse the canonical cross-language operational-input protocol | +| `fm-link-intake.sh` | Atomically retain and validate private searchable link-intake records | | `fm-bootstrap.sh` | Detect toolchain and fleet problems, run the locked session-start sweeps, and install approved tools | | `fm-fleet-sync.sh` | Refresh project clones with safe fast-forwards, self-heals, `STUCK:` reports, branch pruning, and bounded recovery from an orphaned `.git/packed-refs.lock` | | `fm-fleet-snapshot.sh` | Print the read-only structured fleet snapshot JSON (schema `fm-fleet-snapshot.v1`) | diff --git a/docs/verification/link-intake.md b/docs/verification/link-intake.md new file mode 100644 index 00000000000..34fca3277bf --- /dev/null +++ b/docs/verification/link-intake.md @@ -0,0 +1,23 @@ +# Link-intake verification + +This record owns active empirical verification for private durable link intake. + +Verification date: 2026-07-28. +The implementation was verified by the focused behavior suite after the current change. + +```sh +tests/fm-link-intake.test.sh +``` + +Observed guarantees: + +```text +ok - lock claims use portable replacement, atomic quarantine, and process-start identity +ok - lock identity is timezone-stable and legacy recovery is upgrade-safe +ok - retrieval dates are real calendar dates and remain path-safe +ok - one process-crash atomic switch survives failures and process death +ok - validation rejects record, index, and transcript divergence +``` + +The focused suite covers canonical duplicate convergence, searchable title and summary fields, inaccessible records, transaction-staged transcripts, an atomic initialized lock claim, UTC-normalized process-start ownership, upgrade-safe legacy lock recovery, stale-lock quarantine, BSD and GNU symlink replacement, real calendar dates, a process-crash atomic state switch, conservative retained state, bidirectional consistency, odd URLs with query slashes, and the one-line `AGENTS.md` trigger. +Filesystem sync barriers and power-loss durability are explicitly outside this focused process-crash guarantee. diff --git a/tests/fm-link-intake.test.sh b/tests/fm-link-intake.test.sh new file mode 100755 index 00000000000..36e91e2ddb4 --- /dev/null +++ b/tests/fm-link-intake.test.sh @@ -0,0 +1,482 @@ +#!/usr/bin/env bash +# Behavior tests for durable, private, idempotent link-intake records. +set -u + +# shellcheck source=tests/lib.sh +# shellcheck disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +INTAKE="$ROOT/bin/fm-link-intake.sh" +TMP_ROOT=$(fm_test_tmproot fm-link-intake) +REAL_MV=$(command -v mv) +REAL_RM=$(command -v rm) +REAL_LN=$(command -v ln) +REAL_READLINK=$(command -v readlink) + +make_home() { # + local home="$TMP_ROOT/$1" + mkdir -p "$home/data" "$home/state" "$home/config" "$home/projects" + printf '%s\n' "$home" +} + +run_intake() { # + local home=$1 + shift + FM_HOME="$home" "$INTAKE" "$@" +} + +make_failing_tools() { # + local fakebin="$1/fakebin" + mkdir -p "$fakebin" + cat > "$fakebin/mv" <<'SH' +#!/usr/bin/env bash +set -u +if [ "${1:-}" = --help ] && [ "${FM_EMULATE_GNU_MV:-}" = 1 ]; then + printf '%s\n' ' -T, --no-target-directory' + exit 0 +fi +if [ "${1:-}" = -fT ] && [ "${FM_EMULATE_GNU_MV:-}" = 1 ]; then + if "${FM_REAL_MV:?}" --help 2>&1 | grep -F -q -- '--no-target-directory'; then + exec "$FM_REAL_MV" "$@" + fi + shift + exec "$FM_REAL_MV" -fh "$@" +fi +destination= +source= +for argument in "$@"; do + case "$argument" in + -*) ;; + *) [ -n "$source" ] || source=$argument ;; + esac + destination=$argument +done +if [ "$destination" = "${FM_FAIL_MOVE_DEST:-}" ] && [ ! -e "${FM_FAIL_MOVE_ONCE:?}" ]; then + : > "$FM_FAIL_MOVE_ONCE" + exit 1 +fi +if [ "$destination" = "${FM_KILL_MOVE_DEST:-}" ] && [ ! -e "${FM_KILL_MOVE_ONCE:?}" ]; then + : > "$FM_KILL_MOVE_ONCE" + kill -KILL "$PPID" + exit 1 +fi +if [ "$destination" = "${FM_SIGNAL_MOVE_DEST:-}" ] && [ ! -e "${FM_SIGNAL_MOVE_ONCE:?}" ]; then + : > "$FM_SIGNAL_MOVE_ONCE" + "${FM_REAL_MV:?}" "$@" + status=$? + kill -TERM "$PPID" + exit "$status" +fi +if [ "$source" = "${FM_QUARANTINE_SOURCE:-}" ]; then + case "$destination" in + "${FM_QUARANTINE_PREFIX:-}"*) + "${FM_REAL_MV:?}" "$@" + status=$? + [ "$status" -ne 0 ] || : > "${FM_QUARANTINE_MARKER:?}" + exit "$status" + ;; + esac +fi +exec "${FM_REAL_MV:?}" "$@" +SH + cat > "$fakebin/rm" <<'SH' +#!/usr/bin/env bash +set -u +destination= +for argument in "$@"; do + destination=$argument +done +if [ "$destination" = "${FM_GUARDED_REMOVE_DEST:-}" ] && [ ! -e "${FM_QUARANTINE_MARKER:?}" ]; then + exit 1 +fi +if [ -n "${FM_FAIL_REMOVE_PREFIX:-}" ]; then + case "$destination" in + "$FM_FAIL_REMOVE_PREFIX"*) + if [ ! -e "${FM_FAIL_REMOVE_ONCE:?}" ]; then + : > "$FM_FAIL_REMOVE_ONCE" + exit 1 + fi + ;; + esac +fi +exec "${FM_REAL_RM:?}" "$@" +SH + cat > "$fakebin/ln" <<'SH' +#!/usr/bin/env bash +set -u +destination= +for argument in "$@"; do + destination=$argument +done +if [ "$destination" = "${FM_KILL_LINK_DEST:-}" ] && [ ! -e "${FM_KILL_LINK_ONCE:?}" ]; then + : > "$FM_KILL_LINK_ONCE" + kill -KILL "$PPID" + exit 1 +fi +exec "${FM_REAL_LN:?}" "$@" +SH + cat > "$fakebin/readlink" <<'SH' +#!/usr/bin/env bash +set -u +path= +for argument in "$@"; do + path=$argument +done +if [ "$path" = "${FM_FAIL_READLINK_PATH:-}" ] \ + && [ -e "${FM_FAIL_READLINK_AFTER:-}" ] \ + && [ ! -e "${FM_FAIL_READLINK_ONCE:?}" ]; then + : > "$FM_FAIL_READLINK_ONCE" + exit 1 +fi +exec "${FM_REAL_READLINK:?}" "$@" +SH + chmod +x "$fakebin/mv" + chmod +x "$fakebin/rm" + chmod +x "$fakebin/ln" + chmod +x "$fakebin/readlink" + printf '%s\n' "$fakebin" +} + +current_root() { # + local home=$1 target + target=$(readlink "$home/data/link-intake/current") || fail 'current generation link is absent' + printf '%s/data/link-intake/%s\n' "$home" "$target" +} + +test_process_start_identity() { + TZ=UTC0 LC_ALL=C ps -o lstart= -p "$1" 2>/dev/null | awk 'NF { $1=$1; print; exit }' +} + +record_for() { # + local home=$1 url=$2 result + result=$(run_intake "$home" validate "$url") || fail 'expected record did not validate' + printf '%s\n' "${result#valid: }" +} + +test_canonical_duplicate_converges_and_preserves_evidence() { + local home first second record history count + home=$(make_home duplicate) + first=$(run_intake "$home" upsert --url 'HTTPS://Example.com:443/watch?b=2#chapter' --source-type web --title 'First title' --summary 'A searchable first summary.' --terms 'first,example' --claim 'First claim.') \ + || fail 'first URL intake failed' + second=$(run_intake "$home" upsert --url 'https://example.com/watch?b=2' --source-type web --title 'Updated title' --summary 'A newer searchable summary.' --terms 'updated,example' --claim 'Updated claim.') \ + || fail 'duplicate URL intake failed' + [ "$first" = "$second" ] || fail 'canonical duplicate created a second record path' + count=$(find "$(current_root "$home")/records" -type f -name '*.md' | wc -l | tr -d ' ') + [ "$count" = 1 ] || fail 'canonical duplicate created multiple records' + record=$second + assert_grep 'Canonical URL: https://example.com/watch?b=2' "$record" 'canonical URL was not normalized' + assert_grep '- HTTPS://Example.com:443/watch?b=2#chapter' "$record" 'first original URL was not retained' + assert_grep '- https://example.com/watch?b=2' "$record" 'second original URL was not retained' + assert_grep 'Updated title' "$record" 'current title was not updated' + history=$(find "$(current_root "$home")/history" -type f -name '*.md' | head -1) + assert_present "$history" 'replaced record did not retain an immutable history snapshot' + assert_grep 'First title' "$history" 'history snapshot lost prior evidence' + pass 'canonical duplicate convergence retains original URLs and prior evidence' +} + +test_titles_summaries_claims_and_terms_are_searchable() { + local home record index + home=$(make_home searchable) + run_intake "$home" upsert --url 'https://example.org/research' --source-type article --title 'Research Title' --summary 'Concise searchable summary of the research.' --terms 'research,searchable,summary' --claim 'The source makes a testable claim.' >/dev/null \ + || fail 'searchable intake failed' + record=$(record_for "$home" 'https://example.org/research') + index="$(current_root "$home")/index.tsv" + assert_grep 'Title: Research Title' "$record" 'record title is missing' + assert_grep 'Summary: Concise searchable summary of the research.' "$record" 'record summary is missing' + assert_grep '- The source makes a testable claim.' "$record" 'record claim is missing' + assert_grep 'Search terms: research,searchable,summary' "$record" 'record terms are missing' + assert_grep 'research,searchable,summary' "$index" 'searchable terms are absent from index' + pass 'titles, summaries, claims, and terms are durably searchable' +} + +test_inaccessible_links_remain_visible_and_valid() { + local home record + home=$(make_home inaccessible) + run_intake "$home" upsert --url 'https://private.example.test/item' --source-type document --failure 'HTTP 403 private source' >/dev/null \ + || fail 'inaccessible intake failed' + record=$(record_for "$home" 'https://private.example.test/item') + assert_grep 'Retrieval status: inaccessible' "$record" 'inaccessible status is missing' + assert_grep 'Title: Unavailable' "$record" 'inaccessible record lacks a visible title outcome' + assert_grep 'Failure: HTTP 403 private source' "$record" 'inaccessible reason is missing' + assert_grep 'Canonical URL: https://private.example.test/item' "$record" 'inaccessible original was not retained' + pass 'inaccessible links retain a validated visible failure record' +} + +test_video_transcript_metadata_is_durable() { + local home transcript record transcript_path + home=$(make_home transcript) + transcript="$home/source-transcript.txt" + printf 'A legally accessible transcript.\n' > "$transcript" + run_intake "$home" upsert --url 'https://video.example.test/watch?v=42' --source-type video --title 'Video title' --summary 'A short video summary.' --terms 'video,transcript' --claim 'The video makes a claim.' --transcript-file "$transcript" >/dev/null \ + || fail 'video transcript intake failed' + record=$(record_for "$home" 'https://video.example.test/watch?v=42') + transcript_path=$(sed -n 's/^Transcript: stored at //p' "$record") + [ -n "$transcript_path" ] || fail 'record did not store transcript metadata' + assert_present "$(current_root "$home")/$transcript_path" 'durable transcript path is missing' + assert_grep 'A legally accessible transcript.' "$(current_root "$home")/$transcript_path" 'durable transcript body is wrong' + rm "$(current_root "$home")/$transcript_path" + if run_intake "$home" validate 'https://video.example.test/watch?v=42' > "$home/missing-path.out" 2> "$home/missing-path.err"; then + fail 'validation accepted missing durable transcript content' + fi + assert_grep 'record transcript path is absent' "$home/missing-path.err" 'missing transcript path failure was not visible' + if run_intake "$home" upsert --url 'https://video.example.test/no-transcript' --source-type video --title 'Video without transcript' --summary 'No transcript is accessible.' --terms 'video,unavailable' --claim 'Transcript is unavailable.' > "$home/missing.out" 2> "$home/missing.err"; then + fail 'video intake accepted missing transcript metadata' + fi + assert_grep 'transcript-unavailable reason is required' "$home/missing.err" 'missing transcript failure was not visible' + pass 'video records retain transcripts and reject silent omissions' +} + +test_lock_claim_and_symlink_replacement_are_portable() { + local home fakebin failure_marker rc=0 owners owner_dir owner_start quarantine_marker + home=$(make_home portable-lock) + fakebin=$(make_failing_tools "$home") + failure_marker="$home/lock-link-killed" + PATH="$fakebin:$PATH" FM_HOME="$home" FM_REAL_MV="$REAL_MV" FM_REAL_RM="$REAL_RM" \ + FM_REAL_LN="$REAL_LN" FM_REAL_READLINK="$REAL_READLINK" \ + FM_KILL_LINK_DEST="$home/data/link-intake/.update-lock" FM_KILL_LINK_ONCE="$failure_marker" \ + "$INTAKE" upsert --url 'https://portable.example.test/page' --source-type web --title 'Killed claim' --summary 'The process dies before exposing its lock.' --terms 'lock,killed' --claim 'The fixed lock is never ownerless.' > "$home/killed.out" 2> "$home/killed.err" || rc=$? + [ "$rc" = 137 ] || fail "SIGKILL before lock claim should exit 137, got $rc" + [ ! -e "$home/data/link-intake/.update-lock" ] && [ ! -L "$home/data/link-intake/.update-lock" ] \ + || fail 'SIGKILL exposed an ownerless fixed lock' + run_intake "$home" upsert --url 'https://portable.example.test/page' --source-type web --title 'Recovered claim' --summary 'A later invocation acquires the lock.' --terms 'lock,recovered' --claim 'The abandoned private claim does not block progress.' >/dev/null \ + || fail 'abandoned private lock claim blocked recovery' + owners=$(find "$home/data/link-intake" -mindepth 1 -maxdepth 1 -name '.lock-owner.*' | wc -l | tr -d ' ') + [ "$owners" = 0 ] || fail 'lock recovery left an abandoned owner claim' + PATH="$fakebin:$PATH" FM_HOME="$home" FM_REAL_MV="$REAL_MV" FM_REAL_RM="$REAL_RM" \ + FM_REAL_LN="$REAL_LN" FM_REAL_READLINK="$REAL_READLINK" FM_EMULATE_GNU_MV=1 \ + "$INTAKE" upsert --url 'https://portable.example.test/page' --source-type web --title 'GNU publication' --summary 'GNU no-target replacement commits the generation.' --terms 'gnu,portable' --claim 'The portable selector uses GNU mv syntax.' >/dev/null \ + || fail 'GNU mv publication path failed' + assert_grep 'Title: GNU publication' "$(record_for "$home" 'https://portable.example.test/page')" 'GNU mv publication did not commit' + owner_dir="$home/data/link-intake/.lock-owner.recycled" + owner_start=$(test_process_start_identity "$$") + mkdir "$owner_dir" + printf '%s\n%s\n' "$$" "$owner_start" > "$owner_dir/owner" + ln -s "${owner_dir##*/}" "$home/data/link-intake/.update-lock" + if run_intake "$home" validate --all > "$home/live-lock.out" 2> "$home/live-lock.err"; then + fail 'matching live process identity was reclaimed' + fi + assert_grep 'another link-intake update is in progress' "$home/live-lock.err" 'live lock identity did not block a second updater' + rm "$home/data/link-intake/.update-lock" + printf '%s\n%s\n' "$$" 'Mon Jan 1 00:00:00 2001' > "$owner_dir/owner" + ln -s "${owner_dir##*/}" "$home/data/link-intake/.update-lock" + quarantine_marker="$home/lock-quarantined" + PATH="$fakebin:$PATH" FM_HOME="$home" FM_REAL_MV="$REAL_MV" FM_REAL_RM="$REAL_RM" \ + FM_REAL_LN="$REAL_LN" FM_REAL_READLINK="$REAL_READLINK" \ + FM_QUARANTINE_SOURCE="$home/data/link-intake/.update-lock" \ + FM_QUARANTINE_PREFIX="$home/data/link-intake/.stale-update-lock." \ + FM_QUARANTINE_MARKER="$quarantine_marker" \ + FM_GUARDED_REMOVE_DEST="$home/data/link-intake/.update-lock" \ + "$INTAKE" validate --all >/dev/null \ + || fail 'recycled PID lock recovery failed' + assert_present "$quarantine_marker" 'stale fixed lock was not atomically quarantined' + [ ! -e "$owner_dir" ] || fail 'stale owner claim survived quarantine recovery' + pass 'lock claims use portable replacement, atomic quarantine, and process-start identity' +} + +test_lock_identity_is_timezone_stable_and_upgrade_safe() { + local home owner_dir owner_start + home=$(make_home lock-identity) + run_intake "$home" upsert --url 'https://identity.example.test/page' --source-type web --title 'Identity baseline' --summary 'The lock identity has durable baseline state.' --terms 'lock,identity' --claim 'Process ownership remains exclusive.' >/dev/null \ + || fail 'lock identity baseline intake failed' + owner_dir="$home/data/link-intake/.lock-owner.compatibility" + owner_start=$(TZ=HST10 test_process_start_identity "$$") + mkdir "$owner_dir" + printf '%s\n%s\n' "$$" "$owner_start" > "$owner_dir/owner" + ln -s "${owner_dir##*/}" "$home/data/link-intake/.update-lock" + if TZ=JST-9 FM_HOME="$home" "$INTAKE" validate --all > "$home/timezone-lock.out" 2> "$home/timezone-lock.err"; then + fail 'caller timezone changed a live lock identity' + fi + assert_grep 'another link-intake update is in progress' "$home/timezone-lock.err" 'timezone-stable live lock did not block a second updater' + rm "$home/data/link-intake/.update-lock" + printf '%s\n' "$$" > "$owner_dir/owner" + ln -s "${owner_dir##*/}" "$home/data/link-intake/.update-lock" + if run_intake "$home" validate --all > "$home/live-legacy-lock.out" 2> "$home/live-legacy-lock.err"; then + fail 'live legacy lock was reclaimed without a process-start identity' + fi + assert_grep 'link-intake update lock owner identity is unreadable' "$home/live-legacy-lock.err" 'live legacy lock did not fail closed' + rm "$home/data/link-intake/.update-lock" + printf '%s\n' '99999999' > "$owner_dir/owner" + ln -s "${owner_dir##*/}" "$home/data/link-intake/.update-lock" + run_intake "$home" validate --all >/dev/null \ + || fail 'dead legacy lock was not reclaimed' + [ ! -e "$owner_dir" ] || fail 'dead legacy owner survived recovery' + pass 'lock identity is timezone-stable and legacy recovery is upgrade-safe' +} + +test_retrieval_dates_are_real_and_path_safe() { + local home invalid record history root + home=$(make_home retrieved-date) + for invalid in '../.-..-..' '2025-02-29' '2026-04-31' '0000-01-01'; do + if run_intake "$home" upsert --url 'https://date.example.test/page' --source-type article --title 'Invalid date' --summary 'This record must not publish.' --terms 'date,invalid' --claim 'Invalid dates are rejected.' --retrieved-at "$invalid" > "$home/invalid-date.out" 2> "$home/invalid-date.err"; then + fail "invalid retrieval date was accepted: $invalid" + fi + assert_grep 'retrieved-at must be a real YYYY-MM-DD calendar date' "$home/invalid-date.err" 'invalid retrieval date failure was not visible' + done + [ ! -e "$home/data/link-intake/current" ] || fail 'invalid retrieval date published state' + run_intake "$home" upsert --url 'https://date.example.test/page' --source-type article --title 'Leap date' --summary 'A real leap-day record.' --terms 'date,leap' --claim 'Leap day is valid.' --retrieved-at '2024-02-29' >/dev/null \ + || fail 'real leap-day retrieval date was rejected' + run_intake "$home" upsert --url 'https://date.example.test/page' --source-type article --title 'Leap date updated' --summary 'A repeated leap-day record.' --terms 'date,history' --claim 'History remains record-local.' --retrieved-at '2024-02-29' >/dev/null \ + || fail 'repeated leap-day intake failed' + record=$(record_for "$home" 'https://date.example.test/page') + assert_grep 'Retrieved at: 2024-02-29' "$record" 'valid retrieval date was not retained' + history=$(find "$(current_root "$home")/history" -type f -name '2024-02-29-*.md' | head -1) + assert_present "$history" 'valid retrieval date did not produce record-local history' + root=$(current_root "$home") + printf 'Unscoped history.\n' > "$root/history/escaped.md" + if run_intake "$home" validate --all > "$home/escaped-history.out" 2> "$home/escaped-history.err"; then + fail 'validation accepted history outside a record directory' + fi + assert_grep 'unexpected entry in history directory' "$home/escaped-history.err" 'unscoped history failure was not visible' + rm "$root/history/escaped.md" + pass 'retrieval dates are real calendar dates and remain path-safe' +} + +test_atomic_generation_switch_is_process_crash_safe() { + local home record index root fakebin before_record before_index before_current after_record after_index failure_marker + local old_transcript new_transcript retained generations rc=0 + home=$(make_home atomic) + old_transcript="$home/old-transcript.txt" + new_transcript="$home/new-transcript.txt" + printf 'Original durable transcript.\n' > "$old_transcript" + printf 'New staged transcript.\n' > "$new_transcript" + run_intake "$home" upsert --url 'https://atomic.example.test/page' --source-type video --title 'Atomic title' --summary 'Initial complete summary.' --terms 'atomic,initial' --claim 'Initial claim.' --transcript-file "$old_transcript" >/dev/null \ + || fail 'initial atomic intake failed' + record=$(record_for "$home" 'https://atomic.example.test/page') + root=$(current_root "$home") + index="$root/index.tsv" + fakebin=$(make_failing_tools "$home") + before_record=$(shasum -a 256 "$record" | awk '{print $1}') + before_index=$(shasum -a 256 "$index" | awk '{print $1}') + before_current=$(readlink "$home/data/link-intake/current") + failure_marker="$home/current-move-failed" + if PATH="$fakebin:$PATH" FM_HOME="$home" FM_REAL_MV="$REAL_MV" FM_REAL_RM="$REAL_RM" FM_REAL_LN="$REAL_LN" FM_REAL_READLINK="$REAL_READLINK" \ + FM_FAIL_MOVE_DEST="$home/data/link-intake/current" FM_FAIL_MOVE_ONCE="$failure_marker" \ + "$INTAKE" upsert --url 'https://atomic.example.test/page' --source-type video --title 'Switch failure' --summary 'Valid update that cannot publish.' --terms 'atomic,switch' --claim 'The state switch fails.' --transcript-file "$new_transcript" > "$home/switch-failure.out" 2> "$home/switch-failure.err"; then + fail 'generation publication failure unexpectedly succeeded' + fi + after_record=$(shasum -a 256 "$record" | awk '{print $1}') + after_index=$(shasum -a 256 "$index" | awk '{print $1}') + [ "$before_current" = "$(readlink "$home/data/link-intake/current")" ] || fail 'failed state switch changed the current generation' + [ "$before_record" = "$after_record" ] || fail 'failed state switch changed the prior record' + [ "$before_index" = "$after_index" ] || fail 'failed state switch changed the prior index' + ! grep -R -F 'New staged transcript.' "$home/data/link-intake/generations" >/dev/null 2>&1 \ + || fail 'failed state switch left an orphaned transcript' + generations=$(find "$home/data/link-intake/generations" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ') + [ "$generations" = 1 ] || fail 'failed state switch left an abandoned generation' + failure_marker="$home/current-move-retained" + if PATH="$fakebin:$PATH" FM_HOME="$home" FM_REAL_MV="$REAL_MV" FM_REAL_RM="$REAL_RM" FM_REAL_LN="$REAL_LN" FM_REAL_READLINK="$REAL_READLINK" \ + FM_FAIL_MOVE_DEST="$home/data/link-intake/current" FM_FAIL_MOVE_ONCE="$failure_marker" \ + FM_FAIL_REMOVE_PREFIX="$home/data/link-intake/generations/.generation." FM_FAIL_REMOVE_ONCE="$home/remove-failed" \ + "$INTAKE" upsert --url 'https://atomic.example.test/page' --source-type video --title 'Retained candidate' --summary 'A recoverable candidate remains available.' --terms 'atomic,recoverable' --claim 'Cleanup fails visibly.' --transcript-file "$new_transcript" > "$home/retained.out" 2> "$home/retained.err"; then + fail 'publication with failed candidate cleanup unexpectedly succeeded' + fi + retained=$(sed -n 's/^error: recoverable staged generation retained: //p' "$home/retained.err") + assert_present "$retained" 'failed cleanup did not preserve and report the recoverable generation' + [ "$before_current" = "$(readlink "$home/data/link-intake/current")" ] || fail 'failed cleanup changed the current generation' + run_intake "$home" validate --all >/dev/null || fail 'validation did not recover a retained staged generation' + [ ! -e "$retained" ] || fail 'recovery did not clean the retained staged generation' + failure_marker="$home/current-move-signaled" + PATH="$fakebin:$PATH" FM_HOME="$home" FM_REAL_MV="$REAL_MV" FM_REAL_RM="$REAL_RM" FM_REAL_LN="$REAL_LN" FM_REAL_READLINK="$REAL_READLINK" \ + FM_SIGNAL_MOVE_DEST="$home/data/link-intake/current" FM_SIGNAL_MOVE_ONCE="$failure_marker" \ + FM_FAIL_READLINK_PATH="$home/data/link-intake/current" FM_FAIL_READLINK_AFTER="$failure_marker" FM_FAIL_READLINK_ONCE="$home/readlink-failed" \ + "$INTAKE" upsert --url 'https://atomic.example.test/page' --source-type video --title 'Interrupted update' --summary 'Valid update interrupted at the commit point.' --terms 'atomic,signal' --claim 'The signal follows the atomic switch.' --transcript-file "$new_transcript" > "$home/signal.out" 2> "$home/signal.err" || rc=$? + [ "$rc" = 143 ] || fail "TERM during publication should exit 143, got $rc" + [ ! -d "$home/data/link-intake/.update-lock" ] || fail 'TERM left the update lock held' + retained=$(sed -n 's/^error: recoverable staged generation retained: //p' "$home/signal.err") + assert_present "$retained" 'uncertain cleanup did not retain and report the selected generation' + [ -e "$home/data/link-intake/current/index.tsv" ] || fail 'uncertain cleanup left current state dangling' + run_intake "$home" validate --all >/dev/null || fail 'state committed before TERM failed validation' + assert_grep 'Title: Interrupted update' "$(record_for "$home" 'https://atomic.example.test/page')" 'TERM exposed a partial generation' + before_current=$(readlink "$home/data/link-intake/current") + failure_marker="$home/current-move-killed" + rc=0 + PATH="$fakebin:$PATH" FM_HOME="$home" FM_REAL_MV="$REAL_MV" FM_REAL_RM="$REAL_RM" FM_REAL_LN="$REAL_LN" FM_REAL_READLINK="$REAL_READLINK" \ + FM_KILL_MOVE_DEST="$home/data/link-intake/current" FM_KILL_MOVE_ONCE="$failure_marker" \ + "$INTAKE" upsert --url 'https://atomic.example.test/page' --source-type video --title 'Killed update' --summary 'This update dies before its commit point.' --terms 'atomic,killed' --claim 'SIGKILL precedes the state switch.' --transcript-file "$old_transcript" > "$home/killed.out" 2> "$home/killed.err" || rc=$? + [ "$rc" = 137 ] || fail "SIGKILL before publication should exit 137, got $rc" + [ "$before_current" = "$(readlink "$home/data/link-intake/current")" ] || fail 'SIGKILL before the commit point changed current state' + [ -d "$home/data/link-intake/.update-lock" ] || fail 'SIGKILL fixture did not leave a stale lock' + run_intake "$home" validate --all >/dev/null || fail 'validation did not recover from a killed updater' + [ ! -d "$home/data/link-intake/.update-lock" ] || fail 'stale update lock was not reclaimed' + generations=$(find "$home/data/link-intake/generations" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ') + [ "$generations" = 1 ] || fail 'crash recovery left an abandoned generation' + assert_grep 'power-loss durability depends on the host filesystem' "$ROOT/docs/link-intake.md" 'operator contract overstates the process-crash guarantee' + pass 'one process-crash atomic switch survives failures and process death' +} + +test_validation_rejects_bidirectional_divergence() { + local home other record orphan root saved + home=$(make_home divergence) + other=$(make_home divergence-other) + run_intake "$home" upsert --url 'https://consistent.example.test/page' --source-type web --title 'Consistent' --summary 'Initially consistent state.' --terms 'consistent' --claim 'The state starts consistent.' >/dev/null \ + || fail 'consistent intake failed' + run_intake "$other" upsert --url 'https://orphan.example.test/page' --source-type web --title 'Orphan' --summary 'Record not present in the first index.' --terms 'orphan' --claim 'The copied record is unindexed.' >/dev/null \ + || fail 'orphan fixture intake failed' + orphan=$(find "$(current_root "$other")/records" -type f -name '*.md' | head -1) + root=$(current_root "$home") + cp "$orphan" "$root/records/" + if run_intake "$home" validate --all > "$home/orphan.out" 2> "$home/orphan.err"; then + fail 'validation accepted a record absent from the index' + fi + assert_grep 'record is absent from index' "$home/orphan.err" 'orphan record failure was not visible' + rm "$root/records/${orphan##*/}" + record=$(record_for "$home" 'https://consistent.example.test/page') + saved="$home/saved-record.md" + cp "$record" "$saved" + rm "$record" + if run_intake "$home" validate --all > "$home/missing-record.out" 2> "$home/missing-record.err"; then + fail 'validation accepted an index entry without its record' + fi + assert_grep 'record is absent' "$home/missing-record.err" 'missing indexed record failure was not visible' + cp "$saved" "$record" + mkdir -p "$root/transcripts/orphan" + printf 'Unreferenced transcript.\n' > "$root/transcripts/orphan/content.txt" + if run_intake "$home" validate --all > "$home/orphan-transcript.out" 2> "$home/orphan-transcript.err"; then + fail 'validation accepted a transcript absent from records and history' + fi + assert_grep 'transcript is absent from records and history' "$home/orphan-transcript.err" 'orphan transcript failure was not visible' + pass 'validation rejects record, index, and transcript divergence' +} + +test_odd_urls_never_control_filenames_or_shell() { + local home record files query_record + home=$(make_home odd-url) + run_intake "$home" upsert --url 'https://EXAMPLE.test/a/../../%24%28touch%20nope%29?x=%3B%26' --source-type web --title 'Odd URL title' --summary 'Odd but valid URL summary.' --terms 'odd,url' --claim 'Odd URL remains data.' >/dev/null \ + || fail 'odd URL intake failed' + record=$(record_for "$home" 'https://example.test/a/../../%24%28touch%20nope%29?x=%3B%26') + files=$(find "$(current_root "$home")/records" -type f -name '*.md' -exec basename {} \;) + case "$files" in [0-9a-f][0-9a-f]*) ;; *) fail 'record filename is not a deterministic hexadecimal digest' ;; esac + assert_grep 'Canonical URL: https://example.test/a/../../%24%28touch%20nope%29?x=%3B%26' "$record" 'odd URL was not retained as data' + if run_intake "$home" upsert --url 'https://example.test/@unsafe' --canonical-url 'file:///tmp/nope' --source-type web --title 'Bad canonical' --summary 'Should reject.' --terms 'bad' --claim 'Bad.' > "$home/bad.out" 2> "$home/bad.err"; then + fail 'non-HTTP canonical URL was accepted' + fi + assert_grep 'URL must use http or https' "$home/bad.err" 'unsafe canonical URL failure was not visible' + run_intake "$home" upsert --url 'https://Example.test?Token=ABC/Path' --source-type web --title 'Query slash' --summary 'A slash inside a query remains case-sensitive data.' --terms 'query,slash' --claim 'Only the scheme and host are lowercased.' >/dev/null \ + || fail 'query containing a slash was rejected' + query_record=$(record_for "$home" 'https://example.test?Token=ABC/Path') + assert_grep 'Canonical URL: https://example.test?Token=ABC/Path' "$query_record" 'query content was lowercased as authority' + pass 'odd URLs preserve query case and use digest paths' +} + +test_agents_trigger_is_concise_and_agent_agnostic() { + local line + line=$(grep -F 'Link intake:' "$ROOT/AGENTS.md" || true) + [ -n "$line" ] || fail 'AGENTS link-intake trigger is absent' + assert_contains "$line" 'captain sends meaningful URL input' 'trigger does not cover meaningful captain URLs' + assert_contains "$line" 'bin/fm-link-intake.sh' 'trigger does not name the authoritative helper' + [ "$(printf '%s\n' "$line" | wc -l | tr -d ' ')" = 1 ] || fail 'AGENTS link-intake trigger is not one concise line' + assert_not_contains "$line" 'Claude' 'trigger is not agent-agnostic' + pass 'AGENTS has one concise agent-agnostic link-intake trigger' +} + +test_canonical_duplicate_converges_and_preserves_evidence +test_titles_summaries_claims_and_terms_are_searchable +test_inaccessible_links_remain_visible_and_valid +test_video_transcript_metadata_is_durable +test_lock_claim_and_symlink_replacement_are_portable +test_lock_identity_is_timezone_stable_and_upgrade_safe +test_retrieval_dates_are_real_and_path_safe +test_atomic_generation_switch_is_process_crash_safe +test_validation_rejects_bidirectional_divergence +test_odd_urls_never_control_filenames_or_shell +test_agents_trigger_is_concise_and_agent_agnostic From 43496bc57109c0165956becaa85ab4f26105f74e Mon Sep 17 00:00:00 2001 From: quinnbot-ai Date: Tue, 28 Jul 2026 17:08:00 -0700 Subject: [PATCH 11/70] fix(no-mistakes): reviewer recovery guard + Codex-only reviewer route (#62) * fix: recover from degraded no-mistakes reviewers * no-mistakes(review): Disambiguate crew and reviewer run identifiers * no-mistakes(document): Reviewer recovery documentation verified current * no-mistakes(lint): Fix ShellCheck quoting in reviewer recovery assertions --------- Co-authored-by: QuinnBot --- .../no-mistakes-reviewer-recovery/SKILL.md | 57 +++++++++++++++++ AGENTS.md | 1 + bin/fm-test-run.sh | 1 + docs/documentation-audiences.json | 4 ++ .../fm-no-mistakes-reviewer-recovery.test.sh | 64 +++++++++++++++++++ 5 files changed, 127 insertions(+) create mode 100644 .agents/skills/no-mistakes-reviewer-recovery/SKILL.md create mode 100755 tests/fm-no-mistakes-reviewer-recovery.test.sh diff --git a/.agents/skills/no-mistakes-reviewer-recovery/SKILL.md b/.agents/skills/no-mistakes-reviewer-recovery/SKILL.md new file mode 100644 index 00000000000..cafe7cc0454 --- /dev/null +++ b/.agents/skills/no-mistakes-reviewer-recovery/SKILL.md @@ -0,0 +1,57 @@ +--- +name: no-mistakes-reviewer-recovery +description: >- + Agent-only procedure for preventing and diagnosing no-mistakes review or + document stalls when the shared reviewer is degraded. +user-invocable: false +metadata: + internal: true +--- + +# no-mistakes-reviewer-recovery + +Load before starting no-mistakes validation when Claude is degraded, and on a review or document step that is quiet, failed, or cancelled. +This skill owns reviewer-health preflight, the shared-daemon recovery boundary, and the evidence procedure for reviewer incidents. +It never changes a project branch, responds to a crew-owned gate, or restarts the daemon from a crewmate. + +## Preflight + +Treat the reviewer as independent of the task worker's harness. +Before asking a worker to start validation, inspect the installed surface with `no-mistakes axi run --help`. +If it supports a run-scoped agent override, select Codex for that run when Claude is degraded and retain the help output as the version-matched authority for its exact syntax. +Do not infer an override from a global configuration file or from a newer release announcement. + +When an override is unavailable, establish Claude health from both `claude auth status --json` and `quota-axi --json`. +Authentication alone does not establish available capacity. +Treat an unauthenticated result, an unavailable or stale quota source, an exhausted effective Claude availability, or a weekly window materially ahead of pace as degraded for a new reviewer run. + +If Claude is degraded and no run-scoped override exists, do not start a validation run that may select Claude. +The firstmate may route the shared daemon to Codex only after it has confirmed that no lane has an active pipeline run. +That controlled recovery is: make the global no-mistakes agent setting Codex-only, restart the shared daemon once, run `no-mistakes doctor`, and record that the daemon was restarted to apply the routing change. +Never perform that recovery while any lane is active, and never ask a crewmate to perform it. +If there is active work, surface a blocked reviewer route rather than editing shared configuration or restarting the daemon. + +Configuration written after the daemon started is not proof that its in-memory reviewer changed. +Compare the configuration modification time with the daemon start record when diagnosing a route mismatch, then require the controlled recovery above before relying on the new setting. + +## Detection and evidence + +Read the attributed current-code state with `bin/fm-crew-state.sh `. +It renders a matching no-mistakes review or document failure as `state: failed · source: run-step`; that is the authoritative loud signal, not a quiet pane or a stale status event. +From the crew worktree recorded in `state/.meta`, run `no-mistakes axi status`, require its `branch` and `head` to match that worktree under the same current-code rule as `fm-crew-state.sh`, and capture its `id` as ``. +For the exact evidence, capture `no-mistakes axi status --run ` and `no-mistakes axi logs --run --step review --full` or the corresponding `document` log. +Record the run id, step, start and completion timestamps, selected agent sequence, and the terminal error. + +A log that shows one agent killed and the fallback failing immediately with `context canceled` does not demonstrate that the fallback agent is unhealthy. +It demonstrates that the fallback inherited an already cancelled execution context. +Record the initiating cancellation or timeout separately from the fallback symptom, and do not loop restarts. + +If the step is only quiet, inspect its status and log once at the configured quiet-warning threshold. +If it remains quiet on the next bounded check, report it as blocked with the run id and step rather than cancelling and recreating the run. +For a terminal failed or cancelled run, do not restart it automatically; preserve its logs and follow the task's normal recovery authority. + +## Upstream gap + +If the installed help lacks a run-scoped agent override, the safe fallback is shared-daemon routing only. +That limitation is an upstream capability gap, not a reason to bypass review or skip document validation. +Use the existing [run-scoped agent override proposal](https://github.com/kunchenguid/no-mistakes/issues/474) for retained evidence, and require that capability to preserve concurrent lane isolation. diff --git a/AGENTS.md b/AGENTS.md index c9d7ff87dd6..7b7602724dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -493,6 +493,7 @@ These skills are not captain-invocable; load them only at their precise triggers - `fmx-respond` - load on an `x-mention ` `check:` wake to handle the mention, on an `x-mode-error ...` `check:` wake to report the X-mode configuration blocker, and on any milestone or terminal wake for an X-mode-linked task before posting its completion follow-up; relevant only when X mode is on. - `firstmate-codexapp` - load before coordinating a visible Codex Desktop thread, evaluating a Codex App backend request, or reconciling Codex Desktop host-tool smoke evidence for Firstmate work. - `firstmate-coding-guidelines` - load before changing firstmate's shared, tracked material, as defined by section 1's list, whether editing directly or briefing a crewmate for a firstmate-repo task. +- `no-mistakes-reviewer-recovery` - load before starting no-mistakes validation when Claude is degraded, and on a review or document step that is quiet, failed, or cancelled. ## 14. X mode diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index bc42dc8b469..5fdfdc26e02 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -124,6 +124,7 @@ family_for_basename() { fm-documentation-audiences.test.sh|fm-ensure-agents-md.test.sh|fm-grok-harness.test.sh|\ fm-kimi-harness.test.sh|fm-herdr-lab.test.sh|fm-instruction-owners.test.sh|fm-lint.test.sh|\ fm-install-herdr.test.sh|fm-nm-test-contract.test.sh|fm-no-mistakes-ownership.test.sh|\ + fm-no-mistakes-reviewer-recovery.test.sh|\ fm-operational-input.test.sh|fm-pi-primary-types.test.sh|\ fm-send-popup-settle.test.sh|fm-send-settle.test.sh|fm-stow-contract.test.sh|\ fm-subagent-pretool-check.test.sh|\ diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index 4726358ce97..f8647b28f2e 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -151,6 +151,10 @@ "path": ".agents/skills/harness-adapters/SKILL.md", "audience": "agent-runtime" }, + { + "path": ".agents/skills/no-mistakes-reviewer-recovery/SKILL.md", + "audience": "agent-runtime" + }, { "path": ".agents/skills/project-management/SKILL.md", "audience": "agent-runtime" diff --git a/tests/fm-no-mistakes-reviewer-recovery.test.sh b/tests/fm-no-mistakes-reviewer-recovery.test.sh new file mode 100755 index 00000000000..201b291e5d8 --- /dev/null +++ b/tests/fm-no-mistakes-reviewer-recovery.test.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Static contract tests for reviewer-health routing and incident visibility. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +SKILL="$ROOT/.agents/skills/no-mistakes-reviewer-recovery/SKILL.md" +AGENTS="$ROOT/AGENTS.md" + +test_precise_trigger_and_metadata() { + assert_present "$SKILL" "reviewer recovery skill is missing" + assert_grep 'name: no-mistakes-reviewer-recovery' "$SKILL" "skill metadata has the wrong name" + assert_grep 'user-invocable: false' "$SKILL" "reviewer recovery skill must not be user-invocable" + assert_grep ' internal: true' "$SKILL" "reviewer recovery skill must be internal" + assert_grep "\`no-mistakes-reviewer-recovery\` - load before starting no-mistakes validation when Claude is degraded, and on a review or document step that is quiet, failed, or cancelled." "$AGENTS" \ + "AGENTS.md lost the reviewer recovery trigger" + pass "reviewer recovery skill has one precise firstmate trigger" +} + +test_preflight_preserves_shared_daemon_safety() { + for phrase in \ + 'no-mistakes axi run --help' \ + 'run-scoped agent override' \ + 'claude auth status --json' \ + 'quota-axi --json' \ + 'Codex-only' \ + 'no lane has an active pipeline run' \ + 'restart the shared daemon once' \ + "run \`no-mistakes doctor\`" \ + 'Never perform that recovery while any lane is active' \ + 'never ask a crewmate to perform it' \ + 'Configuration written after the daemon started is not proof' \ + 'Compare the configuration modification time with the daemon start record' \ + 'If the installed help lacks a run-scoped agent override' \ + 'safe fallback is shared-daemon routing only'; do + assert_grep "$phrase" "$SKILL" "reviewer recovery preflight lost '$phrase'" + done + pass "reviewer recovery preflight protects the shared daemon" +} + +test_failure_is_loud_and_diagnosable() { + for phrase in \ + 'bin/fm-crew-state.sh ' \ + 'state: failed · source: run-step' \ + 'state/.meta' \ + "capture its \`id\` as \`\`" \ + 'no-mistakes axi status --run ' \ + 'no-mistakes axi logs --run --step review --full' \ + "or the corresponding \`document\` log" \ + 'start and completion timestamps' \ + 'selected agent sequence' \ + 'terminal error' \ + 'context canceled' \ + 'do not loop restarts' \ + 'github.com/kunchenguid/no-mistakes/issues/474'; do + assert_grep "$phrase" "$SKILL" "reviewer recovery diagnostics lost '$phrase'" + done + pass "reviewer incidents are visible from attributed run state and logs" +} + +test_precise_trigger_and_metadata +test_preflight_preserves_shared_daemon_safety +test_failure_is_loud_and_diagnosable From df0bec79552658e604ec5e39edafcfc83d4f9f02 Mon Sep 17 00:00:00 2001 From: quinnbot-ai Date: Tue, 28 Jul 2026 19:25:29 -0700 Subject: [PATCH 12/70] fix(bin): fm-merge-local ignored-directory preservation semantics, fail-fast classification, bounded enumeration (#63) * fix: preserve ignored directories in local merges * no-mistakes(review): Harden ignored-directory merge preservation * no-mistakes(review): Align target ignore checks with project case semantics * no-mistakes(document): Document ignored-directory local merge safeguards --------- Co-authored-by: QuinnBot --- bin/fm-merge-local.sh | 268 +++++++++++++++++++++++++++++------ tests/fm-merge-local.test.sh | 194 +++++++++++++++++++++++++ 2 files changed, 420 insertions(+), 42 deletions(-) diff --git a/bin/fm-merge-local.sh b/bin/fm-merge-local.sh index d9fa0300065..d779a796ed3 100755 --- a/bin/fm-merge-local.sh +++ b/bin/fm-merge-local.sh @@ -9,14 +9,17 @@ # auto-approves), and only as a fast-forward. A dirty default-branch checkout is # allowed only when every dirty path is resolved: tracked working-tree and staged # content must match the incoming blob bytes and tree mode, while -# tracked-to-untracked conversions and untracked files must be ignored by the -# target head's .gitignore rules; info/exclude and global excludes do not count. -# Every unresolved path is diagnosed before advancing. Proven ignored files are -# retained on disk, then every previously dirty path is verified clean. A -# preservation or cleanliness failure after the fast-forward is reported without -# attempting rollback. Diverged branches still refuse and require the crewmate -# to rebase. See AGENTS.md prime directives, project management, and task -# lifecycle. +# tracked-to-untracked conversions and untracked files or directories must be +# ignored by the target head's .gitignore rules under the project's +# core.ignoreCase semantics; info/exclude and global excludes do not count. +# An ignored directory is allowed only when the target head tracks nothing under +# its prefix. Every unresolved path is diagnosed before advancing. Proven ignored +# files are retained byte-for-byte; proven ignored directories remain directories +# without walking or hashing their contents. Every previously dirty path is then +# verified clean. A preservation or cleanliness failure after the fast-forward is +# reported without attempting rollback. Diverged branches still refuse and +# require the crewmate to rebase. See AGENTS.md prime directives, project +# management, and task lifecycle. # Usage: fm-merge-local.sh set -eu @@ -63,7 +66,9 @@ TARGET_VIEW_ROOT= TARGET_VIEW_TREE= TARGET_VIEW_INDEX= TARGET_VIEW_GIT_DIR= +TARGET_VIEW_PATHS= TARGET_GIT_DIR= +TARGET_IGNORE_CASE=false PRESERVE_TEMP_ROOT= MOVED_PATHS=() MOVED_RESTORED=() @@ -128,19 +133,22 @@ cleanup_merge_temps() { trap cleanup_merge_temps EXIT init_target_view() { - local empty_template tree_paths path + local empty_template path TARGET_VIEW_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/fm-merge-local.XXXXXX") || return 1 TARGET_VIEW_TREE="$TARGET_VIEW_ROOT/tree" TARGET_VIEW_INDEX="$TARGET_VIEW_ROOT/index" empty_template="$TARGET_VIEW_ROOT/empty-template" - tree_paths="$TARGET_VIEW_ROOT/tree-paths" + TARGET_VIEW_PATHS="$TARGET_VIEW_ROOT/tree-paths" TARGET_GIT_DIR=$(git -C "$PROJ" rev-parse --absolute-git-dir) || return 1 + TARGET_IGNORE_CASE=$( + git -C "$PROJ" config --type=bool --default=false --get core.ignoreCase + ) || return 1 mkdir -p "$TARGET_VIEW_TREE" "$empty_template" || return 1 git -c init.defaultBranch=fm-target-view init -q \ --template="$empty_template" "$TARGET_VIEW_TREE" || return 1 TARGET_VIEW_GIT_DIR="$TARGET_VIEW_TREE/.git" GIT_INDEX_FILE="$TARGET_VIEW_INDEX" git -C "$PROJ" read-tree "$BRANCH" || return 1 - git -C "$PROJ" ls-tree -r -z --name-only "$BRANCH" >"$tree_paths" || return 1 + git -C "$PROJ" ls-tree -r -z --name-only "$BRANCH" >"$TARGET_VIEW_PATHS" || return 1 while IFS= read -r -d '' path; do case "$path" in @@ -152,7 +160,7 @@ init_target_view() { git -C "$TARGET_VIEW_TREE" checkout-index -- "$path" || return 1 ;; esac - done <"$tree_paths" + done <"$TARGET_VIEW_PATHS" } branch_ignores_path() { @@ -162,9 +170,23 @@ branch_ignores_path() { GIT_WORK_TREE="$TARGET_VIEW_TREE" \ GIT_INDEX_FILE="$TARGET_VIEW_INDEX" \ git -c core.excludesFile=/dev/null \ + -c core.ignoreCase="$TARGET_IGNORE_CASE" \ -C "$TARGET_VIEW_TREE" check-ignore --quiet -- "$1" } +branch_ignores_directory_path() { + local path + path=${1%/}/ + GIT_CONFIG_NOSYSTEM=1 \ + GIT_CONFIG_GLOBAL=/dev/null \ + GIT_DIR="$TARGET_VIEW_GIT_DIR" \ + GIT_WORK_TREE="$TARGET_VIEW_TREE" \ + GIT_INDEX_FILE="$TARGET_VIEW_INDEX" \ + git -c core.excludesFile=/dev/null \ + -c core.ignoreCase="$TARGET_IGNORE_CASE" \ + -C "$TARGET_VIEW_TREE" check-ignore --no-index --quiet -- "$path" +} + branch_blob_metadata() { GIT_LITERAL_PATHSPECS=1 git -C "$PROJ" ls-tree "$BRANCH" -- "$1" \ | awk 'NR == 1 { print $1, $2, $3 }' @@ -205,6 +227,20 @@ staged_state_matches_worktree() { [ "$index_oid" = "$current_oid" ] && [ "$index_mode" = "$current_mode" ] } +branch_tree_uses_directory_prefix() { + local matches path=$1 pathspec prefix + prefix=${path%/} + pathspec=":(literal)$prefix" + if [ "$TARGET_IGNORE_CASE" = true ]; then + pathspec=":(icase,literal)$prefix" + fi + matches=$( + GIT_INDEX_FILE="$TARGET_VIEW_INDEX" \ + git -C "$PROJ" ls-files -- "$pathspec" + ) || return 2 + [ -n "$matches" ] +} + DIRTY_PATHS=() DIRTY_STATES=() while IFS= read -r -d '' record; do @@ -220,12 +256,9 @@ while IFS= read -r -d '' record; do fi ;; esac -done < <(git -C "$PROJ" status --porcelain=v1 -z --untracked-files=all) -while IFS= read -r -d '' path; do - DIRTY_PATHS+=("$path") - DIRTY_STATES+=("!!") done < <( - git -C "$PROJ" ls-files --others --ignored --exclude-standard -z + git -C "$PROJ" status --porcelain=v1 -z \ + --untracked-files=normal --ignored=matching ) UNRESOLVED_PATHS=() @@ -243,6 +276,51 @@ add_unresolved() { UNRESOLVED_REASONS+=("$2") } +expand_unignored_untracked_directories() { + local i path state record records_path ignore_directory_rc + local -a expanded_paths=() expanded_states=() + for ((i = 0; i < ${#DIRTY_PATHS[@]}; i++)); do + path=${DIRTY_PATHS[$i]} + state=${DIRTY_STATES[$i]} + if [ "$state" != "??" ] || [ ! -d "$PROJ/$path" ] || [ -L "$PROJ/$path" ]; then + expanded_paths+=("$path") + expanded_states+=("$state") + continue + fi + + set +e + branch_ignores_directory_path "$path" + ignore_directory_rc=$? + set -e + case "$ignore_directory_rc" in + 0) + # The later collision proof covers this entire prefix, so do not walk it. + expanded_paths+=("$path") + expanded_states+=("$state") + ;; + 1) + # A whole-directory proof is unavailable. Preserve the established + # regular-file behavior by enumerating this one prefix for target-ignored + # files, rather than recursively walking every untracked directory. + records_path="$TARGET_VIEW_ROOT/untracked-$i" + if ! git -C "$PROJ" status --porcelain=v1 -z --untracked-files=all \ + -- ":(literal)$path" >"$records_path"; then + return 1 + fi + while IFS= read -r -d '' record; do + expanded_paths+=("${record:3}") + expanded_states+=("${record:0:2}") + done <"$records_path" + ;; + *) + return 1 + ;; + esac + done + DIRTY_PATHS=("${expanded_paths[@]}") + DIRTY_STATES=("${expanded_states[@]}") +} + remember_preserved_path() { local path=$1 oid if oid=$(git -C "$PROJ" hash-object -- "$path" 2>/dev/null); then @@ -258,6 +336,17 @@ remember_preserved_path() { fi } +remember_preserved_directory() { + local path=$1 + if [ -d "$PROJ/$path" ] && [ ! -L "$PROJ/$path" ]; then + PRESERVE_PATHS+=("$path") + PRESERVE_KINDS+=("directory") + PRESERVE_OIDS+=("") + return 0 + fi + return 1 +} + move_preserved_path_out_of_merge() { local path=$1 saved parent index if [ ! -e "$PROJ/$path" ] && [ ! -L "$PROJ/$path" ]; then @@ -286,6 +375,10 @@ if [ "${#DIRTY_PATHS[@]}" -gt 0 ]; then echo "error: could not construct the $BRANCH ignore view; refusing to merge a dirty checkout" >&2 exit 1 fi + if ! expand_unignored_untracked_directories; then + echo "error: could not classify untracked directories against $BRANCH; refusing to merge a dirty checkout" >&2 + exit 1 + fi for ((i = 0; i < ${#DIRTY_PATHS[@]}; i++)); do path=${DIRTY_PATHS[$i]} @@ -298,6 +391,57 @@ if [ "${#DIRTY_PATHS[@]}" -gt 0 ]; then ;; esac + if [ -d "$PROJ/$path" ] && [ ! -L "$PROJ/$path" ]; then + set +e + branch_ignores_directory_path "$path" + ignore_directory_rc=$? + set -e + case "$ignore_directory_rc" in + 0) + if [ "$state" != "??" ] && [ "$state" != "!!" ] \ + && [ "${state:0:1}" != " " ]; then + add_unresolved "$path" "staged state cannot be preserved when the working-tree path is a directory" + continue + fi + set +e + branch_tree_uses_directory_prefix "$path" + prefix_collision_rc=$? + set -e + case "$prefix_collision_rc" in + 0) + add_unresolved "$path" "ignored directory has an incoming tracked path under its prefix" + continue + ;; + 1) + ;; + *) + add_unresolved "$path" "could not inspect the branch head for paths under the ignored directory prefix" + continue + ;; + esac + if ! remember_preserved_directory "$path"; then + add_unresolved "$path" "ignored directory disappeared during merge preparation" + continue + fi + RESOLVED_PATHS+=("$path") + RESOLVED_MODES+=("") + RESOLVED_OIDS+=("") + if [ "$state" = "??" ] || [ "$state" = "!!" ]; then + RESOLVED_ACTIONS+=("keep-untracked") + else + RESOLVED_ACTIONS+=("remove-index-preserve-directory") + fi + continue + ;; + 1) + ;; + *) + add_unresolved "$path" "could not evaluate branch-head ignore rules for the directory" + continue + ;; + esac + fi + set +e branch_ignores_path "$path" ignore_rc=$? @@ -310,17 +454,13 @@ if [ "${#DIRTY_PATHS[@]}" -gt 0 ]; then add_unresolved "$path" "staged content or mode does not match the working-tree copy preserved by the branch-head ignore rule" continue fi - if ! remember_preserved_path "$path"; then - add_unresolved "$path" "ignored path is neither absent nor hashable as a file" - continue - fi RESOLVED_PATHS+=("$path") RESOLVED_MODES+=("") RESOLVED_OIDS+=("") if [ "$state" = "??" ] || [ "$state" = "!!" ]; then - RESOLVED_ACTIONS+=("keep-untracked") + RESOLVED_ACTIONS+=("verify-ignored-file") else - RESOLVED_ACTIONS+=("remove-index") + RESOLVED_ACTIONS+=("verify-ignored-file-remove-index") fi continue ;; @@ -379,25 +519,8 @@ if [ "${#DIRTY_PATHS[@]}" -gt 0 ]; then continue fi fi - if ! current_oid=$(git -C "$PROJ" hash-object -- "$path" 2>/dev/null); then - add_unresolved "$path" "working-tree content cannot be hashed as a file" - continue - fi - if [ "$current_oid" != "$target_oid" ]; then - add_unresolved "$path" "working-tree content does not match the branch-head blob" - continue - fi - if ! current_mode=$(working_tree_mode "$path"); then - add_unresolved "$path" "working-tree mode cannot be determined" - continue - fi - if [ "$current_mode" != "$target_mode" ]; then - add_unresolved "$path" "working-tree mode $current_mode does not match branch-head mode $target_mode" - continue - fi - RESOLVED_PATHS+=("$path") - RESOLVED_ACTIONS+=("set-index") + RESOLVED_ACTIONS+=("verify-branch-blob") RESOLVED_MODES+=("$target_mode") RESOLVED_OIDS+=("$target_oid") done @@ -411,6 +534,56 @@ if [ "${#UNRESOLVED_PATHS[@]}" -gt 0 ]; then exit 1 fi +# All dirty paths have now passed their cheap structural checks. Hash only the +# remaining file candidates, after every ignored-directory collision and every +# unsupported status has already failed closed. +for ((i = 0; i < ${#RESOLVED_PATHS[@]}; i++)); do + path=${RESOLVED_PATHS[$i]} + case "${RESOLVED_ACTIONS[$i]}" in + verify-ignored-file|verify-ignored-file-remove-index) + if ! remember_preserved_path "$path"; then + add_unresolved "$path" "ignored path is neither absent nor hashable as a file" + continue + fi + case "${RESOLVED_ACTIONS[$i]}" in + verify-ignored-file) + RESOLVED_ACTIONS[i]="keep-untracked" + ;; + verify-ignored-file-remove-index) + RESOLVED_ACTIONS[i]="remove-index" + ;; + esac + ;; + verify-branch-blob) + if ! current_oid=$(git -C "$PROJ" hash-object -- "$path" 2>/dev/null); then + add_unresolved "$path" "working-tree content cannot be hashed as a file" + continue + fi + if [ "$current_oid" != "${RESOLVED_OIDS[$i]}" ]; then + add_unresolved "$path" "working-tree content does not match the branch-head blob" + continue + fi + if ! current_mode=$(working_tree_mode "$path"); then + add_unresolved "$path" "working-tree mode cannot be determined" + continue + fi + if [ "$current_mode" != "${RESOLVED_MODES[$i]}" ]; then + add_unresolved "$path" "working-tree mode $current_mode does not match branch-head mode ${RESOLVED_MODES[$i]}" + continue + fi + RESOLVED_ACTIONS[i]="set-index" + ;; + esac +done + +if [ "${#UNRESOLVED_PATHS[@]}" -gt 0 ]; then + echo "error: $PROJ has dirty paths not resolved by $BRANCH; refusing to merge:" >&2 + for ((i = 0; i < ${#UNRESOLVED_PATHS[@]}; i++)); do + printf ' - %q: %s\n' "${UNRESOLVED_PATHS[$i]}" "${UNRESOLVED_REASONS[$i]}" >&2 + done + exit 1 +fi + # Clean fast-forward only: DEFAULT must be an ancestor of BRANCH. if ! git -C "$PROJ" merge-base --is-ancestor "$DEFAULT" "$BRANCH"; then echo "REFUSED: $BRANCH is not a fast-forward of $DEFAULT (it has diverged)." >&2 @@ -429,6 +602,9 @@ for ((i = 0; i < ${#RESOLVED_PATHS[@]}; i++)); do git -C "$PROJ" update-index --force-remove -- "$path" move_preserved_path_out_of_merge "$path" ;; + remove-index-preserve-directory) + git -C "$PROJ" update-index --force-remove -- "$path" + ;; keep-untracked) ;; esac @@ -452,6 +628,14 @@ for ((i = 0; i < ${#PRESERVE_PATHS[@]}; i++)); do fi continue fi + if [ "${PRESERVE_KINDS[$i]}" = "directory" ]; then + if [ ! -d "$PROJ/$path" ] || [ -L "$PROJ/$path" ]; then + printf 'error: fast-forward completed, but ignored directory %q is now absent or no longer a directory\n' \ + "$path" >&2 + exit 1 + fi + continue + fi if ! preserved_oid=$(git -C "$PROJ" hash-object -- "$path" 2>/dev/null); then printf 'error: fast-forward completed, but ignored path %q was blob %s before the merge and is now absent or unreadable\n' \ "$path" "${PRESERVE_OIDS[$i]}" >&2 @@ -469,7 +653,7 @@ if [ "${#DIRTY_PATHS[@]}" -gt 0 ]; then for path in "${DIRTY_PATHS[@]}"; do POST_PATHS+=(":(literal)$path") done - post_status=$(git -C "$PROJ" status --porcelain=v1 --untracked-files=all -- "${POST_PATHS[@]}") + post_status=$(git -C "$PROJ" status --porcelain=v1 --untracked-files=normal -- "${POST_PATHS[@]}") if [ -n "$post_status" ]; then echo "error: fast-forward completed, but previously dirty resolved paths are not clean:" >&2 printf '%s\n' "$post_status" >&2 diff --git a/tests/fm-merge-local.test.sh b/tests/fm-merge-local.test.sh index c9875a82a65..59c34d3e6ed 100755 --- a/tests/fm-merge-local.test.sh +++ b/tests/fm-merge-local.test.sh @@ -14,6 +14,10 @@ # (h) staged content and executable modes must also match the branch # (i) repository and global excludes cannot stand in for branch ignore rules # (j) already-ignored untracked files are enumerated and retained or refused +# (k) an ignored untracked directory with no incoming tracked collision stays intact +# (l) an ignored untracked directory with an incoming tracked collision refuses +# (m) staged mode-only dirt matching the incoming mode remains supported +# (n) an ignored file nested under an unignored directory keeps its hash proof set -u # shellcheck disable=SC1091 @@ -222,6 +226,190 @@ test_branch_ignored_untracked_file_permits() { pass "fm-merge-local permits an untracked path ignored at the branch head" } +test_branch_ignored_untracked_directory_permits_without_descending() { + local case_dir before_hash after_hash + case_dir=$(make_case ignored-directory) + printf 'workspace/\n' >"$case_dir/branch/.gitignore" + git -C "$case_dir/branch" add .gitignore + git -C "$case_dir/branch" commit -qm "ignore runtime workspace" + mkdir -p "$case_dir/project/workspace/nested" + git -C "$case_dir/project/workspace" init -q + printf 'runtime state\n' >"$case_dir/project/workspace/nested/state.txt" + before_hash=$(git -C "$case_dir/project" hash-object -- workspace/nested/state.txt) + + run_merge_local "$case_dir" >"$case_dir/stdout" 2>"$case_dir/stderr" \ + || fail "ignored-directory: merge should preserve an ignored directory with no incoming collision" + + after_hash=$(git -C "$case_dir/project" hash-object -- workspace/nested/state.txt) + [ "$after_hash" = "$before_hash" ] \ + || fail "ignored-directory: merge changed content below the preserved directory" + [ -d "$case_dir/project/workspace" ] \ + || fail "ignored-directory: merge removed the preserved directory" + assert_path_clean "$case_dir/project" workspace \ + "ignored-directory: branch-ignored directory remained dirty after merge" + assert_main_reached_branch "$case_dir" \ + "ignored-directory: main did not fast-forward to the task branch" + pass "fm-merge-local preserves a branch-ignored untracked directory without hashing its contents" +} + +test_nested_branch_ignored_untracked_file_permits() { + local case_dir before_hash after_hash + case_dir=$(make_case nested-ignored-untracked) + printf 'workspace/runtime-state.txt\n' >"$case_dir/branch/.gitignore" + git -C "$case_dir/branch" add .gitignore + git -C "$case_dir/branch" commit -qm "ignore nested runtime state" + mkdir -p "$case_dir/project/workspace" + printf 'runtime scratch\n' >"$case_dir/project/workspace/runtime-state.txt" + before_hash=$(git -C "$case_dir/project" hash-object -- workspace/runtime-state.txt) + + run_merge_local "$case_dir" >"$case_dir/stdout" 2>"$case_dir/stderr" \ + || fail "nested-ignored-untracked: merge should preserve an ignored file below an unignored directory" + + after_hash=$(git -C "$case_dir/project" hash-object -- workspace/runtime-state.txt) + [ "$after_hash" = "$before_hash" ] \ + || fail "nested-ignored-untracked: merge changed the ignored file" + assert_path_clean "$case_dir/project" workspace/runtime-state.txt \ + "nested-ignored-untracked: ignored nested file remained dirty after merge" + assert_main_reached_branch "$case_dir" \ + "nested-ignored-untracked: main did not fast-forward to the task branch" + pass "fm-merge-local retains the file proof for nested branch-ignored untracked content" +} + +test_branch_ignored_directory_with_incoming_collision_refuses() { + local case_dir before rc + case_dir=$(make_case ignored-directory-collision) + printf 'workspace/\n' >"$case_dir/branch/.gitignore" + mkdir -p "$case_dir/branch/workspace" + printf 'incoming tracked state\n' >"$case_dir/branch/workspace/config.json" + git -C "$case_dir/branch" add .gitignore + git -C "$case_dir/branch" add -f workspace/config.json + git -C "$case_dir/branch" commit -qm "ignore workspace with tracked config" + mkdir -p "$case_dir/project/workspace" + printf 'local runtime state\n' >"$case_dir/project/workspace/runtime.json" + before=$(git -C "$case_dir/project" rev-parse main) + + set +e + run_merge_local "$case_dir" >"$case_dir/stdout" 2>"$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "ignored-directory-collision: merge should refuse" + assert_grep 'workspace/' "$case_dir/stderr" \ + "ignored-directory-collision: refusal did not name the unsafe directory" + assert_grep 'incoming tracked path' "$case_dir/stderr" \ + "ignored-directory-collision: refusal did not explain the tracked collision" + [ "$(git -C "$case_dir/project" rev-parse main)" = "$before" ] \ + || fail "ignored-directory-collision: refusal advanced main" + [ -f "$case_dir/project/workspace/runtime.json" ] \ + || fail "ignored-directory-collision: refusal changed local runtime state" + pass "fm-merge-local refuses an ignored directory when the incoming tree uses its prefix" +} + +test_branch_ignored_directory_with_casefolded_incoming_collision_refuses() { + local case_dir before fakebin rc real_git + case_dir=$(make_case ignored-directory-casefolded-collision) + git -C "$case_dir/project" config core.ignoreCase true + printf 'workspace/\n' >"$case_dir/branch/.gitignore" + mkdir -p "$case_dir/branch/workspace" + printf 'incoming tracked state\n' >"$case_dir/branch/workspace/config.json" + git -C "$case_dir/branch" add .gitignore + git -C "$case_dir/branch" add -f workspace/config.json + git -C "$case_dir/branch" commit -qm "ignore workspace with tracked config" + mkdir -p "$case_dir/project/Workspace" + printf 'local runtime state\n' >"$case_dir/project/Workspace/runtime.json" + before=$(git -C "$case_dir/project" rev-parse main) + fakebin=$(fm_fakebin "$case_dir") + real_git=$(command -v git) + cat >"$fakebin/git" <<'SH' +#!/usr/bin/env bash +set -eu +target_init=false +last= +for arg in "$@"; do + [ "$arg" = "init.defaultBranch=fm-target-view" ] && target_init=true + last=$arg +done +if [ "$target_init" = true ]; then + "$FM_REAL_GIT" "$@" + "$FM_REAL_GIT" -C "$last" config core.ignoreCase false + exit 0 +fi +exec "$FM_REAL_GIT" "$@" +SH + chmod +x "$fakebin/git" + + set +e + FM_REAL_GIT="$real_git" \ + PATH="$fakebin:$PATH" \ + run_merge_local "$case_dir" >"$case_dir/stdout" 2>"$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "ignored-directory-casefolded-collision: merge should refuse" + assert_grep 'Workspace/' "$case_dir/stderr" \ + "ignored-directory-casefolded-collision: refusal did not name the unsafe directory" + assert_grep 'incoming tracked path' "$case_dir/stderr" \ + "ignored-directory-casefolded-collision: refusal did not explain the tracked collision" + [ "$(git -C "$case_dir/project" rev-parse main)" = "$before" ] \ + || fail "ignored-directory-casefolded-collision: refusal advanced main" + [ -f "$case_dir/project/Workspace/runtime.json" ] \ + || fail "ignored-directory-casefolded-collision: refusal changed local runtime state" + pass "fm-merge-local refuses case-folded incoming collisions for ignored directories" +} + +test_tracked_path_replaced_by_directory_refuses_staged_state() { + local before before_index case_dir rc + case_dir=$(make_case staged-directory-conversion) + git -C "$case_dir/branch" rm -q runtime-state.txt + printf 'runtime-state.txt/\n' >"$case_dir/branch/.gitignore" + git -C "$case_dir/branch" add .gitignore + git -C "$case_dir/branch" commit -qm "leave runtime directory untracked" + printf 'staged runtime state\n' >"$case_dir/project/runtime-state.txt" + git -C "$case_dir/project" add runtime-state.txt + rm "$case_dir/project/runtime-state.txt" + mkdir "$case_dir/project/runtime-state.txt" + printf 'working runtime state\n' >"$case_dir/project/runtime-state.txt/state" + before=$(git -C "$case_dir/project" rev-parse main) + before_index=$(git -C "$case_dir/project" rev-parse :runtime-state.txt) + + set +e + run_merge_local "$case_dir" >"$case_dir/stdout" 2>"$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "staged-directory-conversion: merge should refuse" + assert_grep 'staged state cannot be preserved' "$case_dir/stderr" \ + "staged-directory-conversion: refusal did not diagnose the staged state" + [ "$(git -C "$case_dir/project" rev-parse main)" = "$before" ] \ + || fail "staged-directory-conversion: refusal advanced main" + [ "$(git -C "$case_dir/project" rev-parse :runtime-state.txt)" = "$before_index" ] \ + || fail "staged-directory-conversion: refusal changed the index" + assert_grep 'working runtime state' "$case_dir/project/runtime-state.txt/state" \ + "staged-directory-conversion: refusal changed the working directory" + pass "fm-merge-local refuses to discard staged state behind an ignored directory" +} + +test_staged_mode_only_dirt_matching_branch_permits() { + local case_dir mode + case_dir=$(make_case staged-mode) + chmod +x "$case_dir/branch/tracked.txt" + git -C "$case_dir/branch" add tracked.txt + git -C "$case_dir/branch" commit -qm "make tracked script executable" + chmod +x "$case_dir/project/tracked.txt" + git -C "$case_dir/project" add tracked.txt + + run_merge_local "$case_dir" >"$case_dir/stdout" 2>"$case_dir/stderr" \ + || fail "staged-mode: merge should preserve staged mode-only content matching the branch" + + mode=$(git -C "$case_dir/project" ls-files -s -- tracked.txt | awk '{ print $1 }') + [ "$mode" = 100755 ] || fail "staged-mode: incoming executable mode was not retained (got $mode)" + assert_path_clean "$case_dir/project" tracked.txt \ + "staged-mode: previously staged mode change was not clean after merge" + assert_main_reached_branch "$case_dir" \ + "staged-mode: main did not fast-forward to the task branch" + pass "fm-merge-local retains the incoming mode for staged mode-only dirt" +} + test_diverged_branch_still_refuses() { local case_dir rc case_dir=$(make_case diverged) @@ -427,6 +615,12 @@ test_untracked_conversion_permits_and_preserves_file test_untracked_conversion_refuses_divergent_staged_content test_untracked_conversion_refuses_divergent_staged_mode test_branch_ignored_untracked_file_permits +test_branch_ignored_untracked_directory_permits_without_descending +test_nested_branch_ignored_untracked_file_permits +test_branch_ignored_directory_with_incoming_collision_refuses +test_branch_ignored_directory_with_casefolded_incoming_collision_refuses +test_tracked_path_replaced_by_directory_refuses_staged_state +test_staged_mode_only_dirt_matching_branch_permits test_diverged_branch_still_refuses test_non_default_checkout_still_refuses test_mixed_resolved_and_unresolved_refuses From 808a748052f0263c06f7f98a712f9f0772827fe0 Mon Sep 17 00:00:00 2001 From: quinnbot-ai Date: Tue, 28 Jul 2026 19:25:34 -0700 Subject: [PATCH 13/70] fix(herdr): stop new-task window focus stealing (#64) * test(herdr): guard projected spawn against macOS focus theft * no-mistakes(review): Enforce macOS spawn activation regression in CI * no-mistakes(document): Document macOS Herdr focus regression --------- Co-authored-by: QuinnBot --- .github/workflows/ci.yml | 51 +++++++++ CONTRIBUTING.md | 4 +- docs/herdr-backend.md | 6 +- docs/verification/runtime-backends.md | 17 +++ .../fm-backend-herdr-presentation-e2e.test.sh | 102 ++++++++++++++++++ tests/fm-install-herdr.test.sh | 6 ++ 6 files changed, 182 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 407a29c88f4..e79bb219df6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -265,6 +265,57 @@ jobs: ${{ runner.temp }}/fm-herdr/default-server.log if-no-files-found: warn + tests-herdr-macos-focus: + name: Behavior tests (Herdr macOS focus) + runs-on: macos-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Require macOS focus and Herdr tools + run: | + set -eu + command -v jq >/dev/null || { echo "::error::jq is required"; exit 1; } + command -v python3 >/dev/null || { echo "::error::python3 is required"; exit 1; } + command -v swift >/dev/null || { echo "::error::swift is required"; exit 1; } + command -v osascript >/dev/null || { echo "::error::osascript is required"; exit 1; } + - name: Install pinned Herdr and Treehouse + run: | + set -eu + bin/fm-install-herdr.sh "$RUNNER_TEMP/bin" + bin/fm-install-treehouse.sh "$RUNNER_TEMP/bin" + echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" + - name: Start default Herdr session for fleet-state tripwire + run: | + set -eu + mkdir -p "$RUNNER_TEMP/fm-herdr-macos" + nohup herdr server >"$RUNNER_TEMP/fm-herdr-macos/default-server.log" 2>&1 & + echo $! >"$RUNNER_TEMP/fm-herdr-macos/default-server.pid" + attempt=0 + while [ "$attempt" -lt 150 ]; do + running=$(herdr status --json 2>/dev/null | jq -r '.server.running // false' || echo false) + if [ "$running" = true ]; then + echo "default Herdr session is running" + exit 0 + fi + sleep 0.2 + attempt=$((attempt + 1)) + done + echo "::error::default Herdr server did not become ready" + cat "$RUNNER_TEMP/fm-herdr-macos/default-server.log" || true + exit 1 + - name: Run projected spawn macOS activation regression + env: + FM_REQUIRE_MACOS_FOCUS_AUDIT: 1 + run: tests/fm-backend-herdr-presentation-e2e.test.sh + - name: Stop default Herdr server + if: always() + run: | + if [ -f "$RUNNER_TEMP/fm-herdr-macos/default-server.pid" ]; then + kill "$(cat "$RUNNER_TEMP/fm-herdr-macos/default-server.pid")" 2>/dev/null || true + fi + # Aggregate per-lane timing into one summary artifact for critical-path review. tests-timing-aggregate: name: Behavior timing aggregate diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c3a1cab183..10f498fba02 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -93,11 +93,11 @@ Its header and `--help` own the flags, family labels, lanes, and changed-file ma Portable shard balance evidence lives in `docs/fm-test-portable-shards.md`. Local no-mistakes Test stays intent-targeted and must not wire `commands.test` to `--all` or a `tests/*.test.sh` walk. Family selection is the ordinary local path; `--all` is deliberate full regression only. -CI owns broad regression across required portable parallel shards, the portable serial lane, the Herdr lane, lint, invariants, the coverage guard, and stock macOS Bash compatibility in [`.github/workflows/ci.yml`](.github/workflows/ci.yml). +CI owns broad regression across required portable parallel shards, the portable serial lane, required Herdr coverage, lint, invariants, the coverage guard, and stock macOS Bash compatibility in [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Use `bin/fm-test-run.sh --help` for lane names, `--jobs` rules, and required gate-skip flags when reproducing a lane locally. Discover tests by listing `tests/*.test.sh`: each is a self-contained bash script named `.test.sh`, and its header comment describes what it covers, so pass one to `bin/fm-test-run.sh` to focus on a subject with canonical timing output. Tests that need a real optional backend or an explicit opt-in (real herdr/zellij/cmux smoke tests, the live Pi regression) skip themselves and print the tool or environment gate needed to enable them, so the portable suite remains safe on machines without those tools. -The [Herdr backend guide](docs/herdr-backend.md#destructive-lab-safety) owns the lane's isolation boundary, while [runtime backend verification](docs/verification/runtime-backends.md#herdr) owns active empirical evidence; live harness credential tests remain opt-in. +The [Herdr backend guide](docs/herdr-backend.md#destructive-lab-safety) owns the Herdr tests' isolation boundary, while [runtime backend verification](docs/verification/runtime-backends.md#herdr) owns active empirical evidence; live harness credential tests remain opt-in. ## Questions diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index dd02e92a069..0ba7ff6316c 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -27,7 +27,7 @@ An auto-detected Herdr spawn prints an opt-out notice. Spawn stops before creating a Herdr container or acquiring a task worktree when `herdr`, `jq`, or the protocol floor is unavailable. No separate first-run provisioning is required. -The required CI lane uses the pinned installers in `bin/fm-install-herdr.sh` and `bin/fm-install-treehouse.sh`. +Required Herdr CI coverage uses the pinned installers in `bin/fm-install-herdr.sh` and `bin/fm-install-treehouse.sh`. Those script headers own release assets, checksums, download bounds, and post-install gates. Real harness credential tests remain opt-in rather than part of default CI. @@ -43,6 +43,7 @@ Routine supervision uses `bin/fm-peek.sh ` and `FM_HOME= bin/fm-send.s Workspace and tab creation use `--no-focus`. The first workspace in a completely empty Herdr session must become focused because no prior target exists, but later task creation does not intentionally steal focus. +Herdr workspace and tab focus is distinct from macOS frontmost-app activation. Herdr does not enforce workspace or tab label uniqueness. Firstmate adopts the first workspace matching its derived home label and refuses duplicate task tabs inside it. @@ -80,6 +81,7 @@ A foreign, ambiguous, detached, or manually interleaved child makes ordering ski Fresh projected ordering failure never fails the task spawn. On that fresh path, Firstmate does not retry projection, adopt, reuse, close, delete, or rename anything in response to an unavailable method, lock contention, ambiguous socket, lost response, failed move, or verification mismatch. The worker remains on the ordinary flat or Herdr-current-order path. +The complete projected spawn must also preserve the macOS frontmost app. Normal task metadata remains the sole endpoint authority after creation. Cleanup closes only the exact recorded task pane and never calls `workspace close`. @@ -123,7 +125,7 @@ Operational compromises: - Regaining a dedicated space after degradation requires stopping the flat task, manually checking the stale projection, and clearing its journal before a genuinely fresh launch. - The visible token is only a restart-stable correlator and never substitutes for the exact binding. -`tests/fm-backend-herdr-presentation-e2e.test.sh` covers multi-home ordering, concurrency, lock contention, legacy coexistence, focus preservation, exact same-identity restart replacement, ambiguous bindings and tokens, and exact-pane cleanup through the guarded lab path. +`tests/fm-backend-herdr-presentation-e2e.test.sh` covers multi-home ordering, concurrency, lock contention, legacy coexistence, logical focus and macOS app-activation preservation, exact same-identity restart replacement, ambiguous bindings and tokens, and exact-pane cleanup through the guarded lab path. `tests/fm-herdr-session-cleanup.test.sh` covers every discovery, ownership, topology, process, locking, revalidation, focus, retirement, and continue-on-error boundary. `tests/fm-herdr-session-cleanup-e2e.test.sh` covers the restored-shell cleanup in a guarded non-default named lab; [`verification/runtime-backends.md`](verification/runtime-backends.md#per-home-and-presentation-topology) owns the active versioned evidence. diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index 5928d427faa..57d66af05a0 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -266,6 +266,23 @@ HERDR_LAB_HELPER=bin/fm-herdr-lab.sh \ Observed guarantee: one exact home-local, journal-correlated, one-tab and one-pane childless idle shell was closed after restoration while the exact non-target focus and default fleet session remained unchanged, and a repeat run was a no-op. +#### macOS app activation + +A guarded non-default presentation lab on 2026-07-28 used Herdr 0.7.3 protocol 16 to separate Herdr's logical workspace and tab focus from macOS frontmost-app activation. +Directly observed workspace creation, tab creation, pane execution, and raw projection `workspace.move` operations neither changed `NSWorkspace.frontmostApplication` nor emitted activation for a different bundle identifier. +The same lab retained the existing exact active-workspace and active-tab assertions, so the evidence did not attribute the apparent focus steal to either Firstmate's projection path or the exercised Herdr core operations. + +The active macOS regression is: + +```sh +FM_REQUIRE_MACOS_FOCUS_AUDIT=1 \ + tests/fm-backend-herdr-presentation-e2e.test.sh +``` + +The test starts an `NSWorkspace.didActivateApplicationNotification` watcher before the complete projected spawn and keeps it active through a two-second settle window. +It fails if any activation has an empty or different bundle identifier from the initially frontmost app. +The required macOS Herdr-focus CI job makes an unavailable activation audit a hard failure, while non-macOS runs retain the logical focus checks without claiming app-activation coverage. + ### Composer and operational input Real captures verified these active distinctions: diff --git a/tests/fm-backend-herdr-presentation-e2e.test.sh b/tests/fm-backend-herdr-presentation-e2e.test.sh index 194d2053ce9..4e018900f3f 100755 --- a/tests/fm-backend-herdr-presentation-e2e.test.sh +++ b/tests/fm-backend-herdr-presentation-e2e.test.sh @@ -26,6 +26,9 @@ HERDR_CALL_LOG="$TMP_ROOT/herdr-calls.log" TREEHOUSE_CALL_LOG="$TMP_ROOT/treehouse-calls.log" MOVE_CALL_LOG="$TMP_ROOT/workspace-move-calls.log" FOCUS_AUDIT_LOG="$TMP_ROOT/focus-audit.log" +MACOS_ACTIVATION_LOG="$TMP_ROOT/macos-activation.log" +MACOS_ACTIVATION_READY="$TMP_ROOT/macos-activation.ready" +MACOS_ACTIVATION_WATCHER="$TMP_ROOT/macos-activation-watcher.swift" ACTIVE_SEEDED_CONTROL="$TMP_ROOT/active-seeded-control" POST_CREATE_ABORT_CONTROL="$TMP_ROOT/post-create-abort-control" mkdir -p "$FAKEBIN" @@ -37,6 +40,56 @@ REAL_MOVER="$ROOT/bin/backends/herdr-workspace-move.py" export REAL_HERDR REAL_TREEHOUSE REAL_MOVER HERDR_CALL_LOG TREEHOUSE_CALL_LOG MOVE_CALL_LOG FOCUS_AUDIT_LOG HERDR_ORIGINAL_PATH HERDR_LAB_HELPER export ACTIVE_SEEDED_CONTROL POST_CREATE_ABORT_CONTROL TMP_ROOT +# Herdr's logical workspace/tab focus is distinct from macOS app activation. +# The projection regression observes activation events across the full spawn. +MACOS_ACTIVATION_AUDIT_ENABLED=0 +if [ "$(uname -s)" = Darwin ] \ + && command -v osascript >/dev/null 2>&1 \ + && command -v swift >/dev/null 2>&1 \ + && [ -n "$(osascript -l JavaScript -e 'ObjC.import("AppKit"); $.NSWorkspace.sharedWorkspace.frontmostApplication.bundleIdentifier.js' 2>/dev/null)" ]; then + MACOS_ACTIVATION_AUDIT_ENABLED=1 +fi + +cat > "$MACOS_ACTIVATION_WATCHER" <<'SWIFT' +import AppKit +import Foundation + +guard CommandLine.arguments.count == 3 else { + exit(64) +} + +let logPath = CommandLine.arguments[1] +let readyPath = CommandLine.arguments[2] + +func appendEvent(_ kind: String, _ bundleIdentifier: String?) { + let line = "\(kind)\t\(bundleIdentifier ?? "")\n" + guard let data = line.data(using: .utf8), + let handle = FileHandle(forWritingAtPath: logPath) else { + exit(1) + } + handle.seekToEndOfFile() + handle.write(data) + try? handle.close() +} + +FileManager.default.createFile(atPath: logPath, contents: Data()) +let workspace = NSWorkspace.shared +_ = workspace.notificationCenter.addObserver( + forName: NSWorkspace.didActivateApplicationNotification, + object: nil, + queue: .main +) { notification in + let application = notification.userInfo?[NSWorkspace.applicationUserInfoKey] + as? NSRunningApplication + appendEvent("activated", application?.bundleIdentifier) +} +appendEvent("initial", workspace.frontmostApplication?.bundleIdentifier) +guard FileManager.default.createFile(atPath: readyPath, contents: Data()) else { + exit(1) +} +RunLoop.main.run() +SWIFT + # Log every production-adapter call, remove its already-validated trailing # session flag, and send the operation through the lab helper so that helper # remains the sole process which appends the real trailing session flag. @@ -259,8 +312,14 @@ export HERDR_SESSION="$HERDR_LAB_SESSION" HERDR_LAB_SESSION LAB_READY=0 RECORDED_WORKTREES="" LOCK_CONTENTION_OWNER_PID= +MACOS_ACTIVATION_WATCH_PID= cleanup_all() { local wt + if [ -n "$MACOS_ACTIVATION_WATCH_PID" ]; then + kill "$MACOS_ACTIVATION_WATCH_PID" 2>/dev/null || true + wait "$MACOS_ACTIVATION_WATCH_PID" 2>/dev/null || true + MACOS_ACTIVATION_WATCH_PID= + fi if [ -n "$LOCK_CONTENTION_OWNER_PID" ]; then kill "$LOCK_CONTENTION_OWNER_PID" 2>/dev/null || true wait "$LOCK_CONTENTION_OWNER_PID" 2>/dev/null || true @@ -282,6 +341,11 @@ EOF } trap cleanup_all EXIT +if [ "${FM_REQUIRE_MACOS_FOCUS_AUDIT:-0}" = 1 ] \ + && [ "$MACOS_ACTIVATION_AUDIT_ENABLED" != 1 ]; then + fail "required macOS NSWorkspace activation audit is unavailable" +fi + PATH="$HERDR_ORIGINAL_PATH" \ "$HERDR_LAB_HELPER" provision "$HERDR_LAB_SESSION" \ || fail "could not provision the isolated Herdr lab" @@ -321,6 +385,42 @@ assert_focus_is() { # focus_audit_line_count() { wc -l < "$FOCUS_AUDIT_LOG" | tr -d '[:space:]'; } +start_macos_activation_watch() { + local attempt=0 + [ "$MACOS_ACTIVATION_AUDIT_ENABLED" = 1 ] || return 0 + : > "$MACOS_ACTIVATION_LOG" + rm -f "$MACOS_ACTIVATION_READY" + swift "$MACOS_ACTIVATION_WATCHER" \ + "$MACOS_ACTIVATION_LOG" "$MACOS_ACTIVATION_READY" \ + > "$TMP_ROOT/macos-activation-watcher.out" 2>&1 & + MACOS_ACTIVATION_WATCH_PID=$! + while [ ! -e "$MACOS_ACTIVATION_READY" ] && [ "$attempt" -lt 100 ]; do + kill -0 "$MACOS_ACTIVATION_WATCH_PID" 2>/dev/null \ + || fail "macOS activation watcher exited before readiness: $(cat "$TMP_ROOT/macos-activation-watcher.out")" + sleep 0.1 + attempt=$((attempt + 1)) + done + [ -e "$MACOS_ACTIVATION_READY" ] \ + || fail "macOS activation watcher did not become ready" +} + +assert_macos_activation_preserved() { # + local case_name=$1 initial changed + [ "$MACOS_ACTIVATION_AUDIT_ENABLED" = 1 ] || return 0 + sleep 2 + kill "$MACOS_ACTIVATION_WATCH_PID" 2>/dev/null \ + || fail "macOS activation watcher was not running through the settle window" + wait "$MACOS_ACTIVATION_WATCH_PID" 2>/dev/null || true + MACOS_ACTIVATION_WATCH_PID= + initial=$(awk -F '\t' '$1 == "initial" { print $2; exit }' "$MACOS_ACTIVATION_LOG") + [ -n "$initial" ] || fail "$case_name activation watcher did not capture the initial macOS app" + changed=$(awk -F '\t' -v initial="$initial" ' + $1 == "activated" && ($2 == "" || $2 != initial) { print $0 } + ' "$MACOS_ACTIVATION_LOG") + [ -z "$changed" ] \ + || fail "$case_name activated a different macOS app during spawn or settle: $changed" +} + assert_raw_presentation_mutations_preserved_since() { # local start=$1 case_name=$2 changed changed=$(sed -n "$((start + 1)),\$p" "$FOCUS_AUDIT_LOG" | awk -F '\t' ' @@ -520,8 +620,10 @@ assert_focus_is "$CAPTAIN_FOCUS" "focused secondmate fixture" : > "$TREEHOUSE_CALL_LOG" : > "$HOME_DIR/config/herdr-presentation-spaces" SHAPE_FOCUS_AUDIT_START=$(focus_audit_line_count) +start_macos_activation_watch spawn_task shape "$HOME_DIR" "$PROJECT_DIR" > "$TMP_ROOT/on.out" 2> "$TMP_ROOT/on.err" \ || fail "projected spawn failed: $(cat "$TMP_ROOT/on.err")" +assert_macos_activation_preserved "projected spawn" assert_focus_is "$CAPTAIN_FOCUS" "projected spawn" assert_raw_presentation_mutations_preserved_since "$SHAPE_FOCUS_AUDIT_START" "projected spawn" ON_META="$TMP_ROOT/on.meta" diff --git a/tests/fm-install-herdr.test.sh b/tests/fm-install-herdr.test.sh index cc5a70ada88..bfe7ccbb750 100755 --- a/tests/fm-install-herdr.test.sh +++ b/tests/fm-install-herdr.test.sh @@ -74,6 +74,12 @@ test_cleanup_only_targets_job_owned_lab_sessions() { test_ci_wires_installers_and_required_lane() { assert_grep 'tests-herdr:' "$CI" "CI must define the required Herdr Behavior job" + assert_grep 'tests-herdr-macos-focus:' "$CI" \ + "CI must define the required macOS Herdr focus job" + assert_grep 'FM_REQUIRE_MACOS_FOCUS_AUDIT: 1' "$CI" \ + "macOS Herdr CI must require the NSWorkspace activation audit" + assert_grep 'fm-backend-herdr-presentation-e2e.test.sh' "$CI" \ + "macOS Herdr CI must run the projected spawn regression" assert_grep 'fm-install-herdr.sh' "$CI" "CI must call the Herdr installer" assert_grep 'fm-install-treehouse.sh' "$CI" "CI must call the Treehouse installer" assert_grep 'fm-herdr-ci-cleanup.sh snapshot' "$CI" "CI must snapshot sessions before the suite" From 0062611df45a832b58cfebd0fe7509abffc3e4a7 Mon Sep 17 00:00:00 2001 From: quinnbot-ai Date: Wed, 29 Jul 2026 10:45:05 -0700 Subject: [PATCH 14/70] fix(bin): fm-merge-local proves ignored symlinks and directories with bounded refusal diagnostics (#65) * fix: preserve ignored non-file paths in local merges * no-mistakes(review): fix staged ignored symlink preservation * no-mistakes(review): fix index-flag-safe staged symlink proof * no-mistakes(review): fix staged symlink type-drift proof * no-mistakes(review): bypass replacement refs in symlink proof * no-mistakes(document): Document bounded local-merge preservation diagnostics * no-mistakes(review): Harden symlink and ignored-directory merge proofs * no-mistakes(review): Reclassify ignored parents against target rules --------- Co-authored-by: QuinnBot --- bin/fm-merge-local.sh | 176 ++++++++++++----- tests/fm-merge-local.test.sh | 374 +++++++++++++++++++++++++++++++++++ 2 files changed, 500 insertions(+), 50 deletions(-) diff --git a/bin/fm-merge-local.sh b/bin/fm-merge-local.sh index d779a796ed3..67e43bed01a 100755 --- a/bin/fm-merge-local.sh +++ b/bin/fm-merge-local.sh @@ -13,13 +13,16 @@ # ignored by the target head's .gitignore rules under the project's # core.ignoreCase semantics; info/exclude and global excludes do not count. # An ignored directory is allowed only when the target head tracks nothing under -# its prefix. Every unresolved path is diagnosed before advancing. Proven ignored -# files are retained byte-for-byte; proven ignored directories remain directories -# without walking or hashing their contents. Every previously dirty path is then -# verified clean. A preservation or cleanliness failure after the fast-forward is -# reported without attempting rollback. Diverged branches still refuse and -# require the crewmate to rebase. See AGENTS.md prime directives, project -# management, and task lifecycle. +# its prefix. Unresolved paths refuse before advancing; the first 50 receive +# per-path diagnostics, followed by a +N summary for any remainder. Proven +# ignored files are retained byte-for-byte; proven ignored symlinks retain their +# readlink targets; proven ignored directories remain directories without +# walking or hashing their contents. Other present entry types and entries that +# cannot be inspected refuse. Every previously dirty path is then verified clean. +# A preservation or cleanliness failure after the fast-forward is reported +# without attempting rollback. Diverged branches still refuse and require the +# crewmate to rebase. See AGENTS.md prime directives, project management, and +# task lifecycle. # Usage: fm-merge-local.sh set -eu @@ -213,7 +216,8 @@ working_tree_mode() { } staged_state_matches_worktree() { - local current_mode current_oid index_meta index_mode index_oid path=$1 + local current_mode current_oid current_target index_meta index_mode index_oid + local index_snapshot path=$1 worktree_snapshot index_meta=$(index_blob_metadata "$path") || return 1 if [ ! -e "$PROJ/$path" ] && [ ! -L "$PROJ/$path" ]; then [ -z "$index_meta" ] @@ -222,6 +226,20 @@ staged_state_matches_worktree() { [ -n "$index_meta" ] || return 1 index_mode=${index_meta%% *} index_oid=${index_meta##* } + if [ "$index_mode" = "120000" ]; then + [ -L "$PROJ/$path" ] || return 1 + current_target=$(readlink "$PROJ/$path" && printf x) || return 1 + current_target=${current_target%?} + current_target=${current_target%$'\n'} + worktree_snapshot="$TARGET_VIEW_ROOT/staged-symlink-worktree" + index_snapshot="$TARGET_VIEW_ROOT/staged-symlink-index" + printf '%s' "$current_target" >"$worktree_snapshot" || return 1 + git --no-replace-objects -C "$PROJ" cat-file blob "$index_oid" \ + >"$index_snapshot" || return 1 + cmp -s "$worktree_snapshot" "$index_snapshot" + return + fi + [ ! -L "$PROJ/$path" ] || return 1 current_oid=$(git -C "$PROJ" hash-object -- "$path" 2>/dev/null) || return 1 current_mode=$(working_tree_mode "$path") || return 1 [ "$index_oid" = "$current_oid" ] && [ "$index_mode" = "$current_mode" ] @@ -276,54 +294,92 @@ add_unresolved() { UNRESOLVED_REASONS+=("$2") } -expand_unignored_untracked_directories() { - local i path state record records_path ignore_directory_rc +append_target_classified_untracked_tree() { + local child dotglob_was_set ignore_directory_rc nullglob_was_set path=$1 + local -a children=() + + set +e + branch_ignores_directory_path "$path" + ignore_directory_rc=$? + set -e + case "$ignore_directory_rc" in + 0) + expanded_paths+=("$path") + expanded_states+=("??") + return 0 + ;; + 1) + ;; + *) + return 1 + ;; + esac + + if [ -e "$PROJ/${path%/}/.git" ] || [ -L "$PROJ/${path%/}/.git" ]; then + expanded_paths+=("$path") + expanded_states+=("??") + return 0 + fi + [ -r "$PROJ/$path" ] && [ -x "$PROJ/$path" ] || return 1 + + if shopt -q dotglob; then + dotglob_was_set=1 + else + dotglob_was_set=0 + fi + if shopt -q nullglob; then + nullglob_was_set=1 + else + nullglob_was_set=0 + fi + shopt -s dotglob nullglob + children=("$PROJ/${path%/}"/*) + [ "$dotglob_was_set" -eq 1 ] || shopt -u dotglob + [ "$nullglob_was_set" -eq 1 ] || shopt -u nullglob + + for child in "${children[@]}"; do + child=${child#"$PROJ"/} + if [ -d "$PROJ/$child" ] && [ ! -L "$PROJ/$child" ]; then + append_target_classified_untracked_tree "$child" || return 1 + else + expanded_paths+=("$child") + expanded_states+=("??") + fi + done +} + +expand_untracked_directories_against_target() { + local i path state local -a expanded_paths=() expanded_states=() for ((i = 0; i < ${#DIRTY_PATHS[@]}; i++)); do path=${DIRTY_PATHS[$i]} state=${DIRTY_STATES[$i]} - if [ "$state" != "??" ] || [ ! -d "$PROJ/$path" ] || [ -L "$PROJ/$path" ]; then + if { [ "$state" != "??" ] && [ "$state" != "!!" ]; } \ + || [ ! -d "$PROJ/$path" ] || [ -L "$PROJ/$path" ]; then expanded_paths+=("$path") expanded_states+=("$state") continue fi - set +e - branch_ignores_directory_path "$path" - ignore_directory_rc=$? - set -e - case "$ignore_directory_rc" in - 0) - # The later collision proof covers this entire prefix, so do not walk it. - expanded_paths+=("$path") - expanded_states+=("$state") - ;; - 1) - # A whole-directory proof is unavailable. Preserve the established - # regular-file behavior by enumerating this one prefix for target-ignored - # files, rather than recursively walking every untracked directory. - records_path="$TARGET_VIEW_ROOT/untracked-$i" - if ! git -C "$PROJ" status --porcelain=v1 -z --untracked-files=all \ - -- ":(literal)$path" >"$records_path"; then - return 1 - fi - while IFS= read -r -d '' record; do - expanded_paths+=("${record:3}") - expanded_states+=("${record:0:2}") - done <"$records_path" - ;; - *) - return 1 - ;; - esac + append_target_classified_untracked_tree "$path" || return 1 done DIRTY_PATHS=("${expanded_paths[@]}") DIRTY_STATES=("${expanded_states[@]}") } remember_preserved_path() { - local path=$1 oid - if oid=$(git -C "$PROJ" hash-object -- "$path" 2>/dev/null); then + local path=$1 oid snapshot + if [ -L "$PROJ/$path" ]; then + snapshot="$TARGET_VIEW_ROOT/readlink" + if ! readlink "$PROJ/$path" >"$snapshot" \ + || ! oid=$(git -C "$PROJ" hash-object -- "$snapshot"); then + return 1 + fi + PRESERVE_PATHS+=("$path") + PRESERVE_KINDS+=("symlink") + PRESERVE_OIDS+=("$oid") + elif [ -f "$PROJ/$path" ] \ + && oid=$(git -C "$PROJ" hash-object -- "$path" 2>/dev/null); then PRESERVE_PATHS+=("$path") PRESERVE_KINDS+=("blob") PRESERVE_OIDS+=("$oid") @@ -347,6 +403,17 @@ remember_preserved_directory() { return 1 } +print_unresolved_paths() { + local count=${#UNRESOLVED_PATHS[@]} i limit=50 + echo "error: $PROJ has dirty paths not resolved by $BRANCH; refusing to merge:" >&2 + for ((i = 0; i < count && i < limit; i++)); do + printf ' - %q: %s\n' "${UNRESOLVED_PATHS[$i]}" "${UNRESOLVED_REASONS[$i]}" >&2 + done + if [ "$count" -gt "$limit" ]; then + printf ' +%d more unresolved paths\n' "$((count - limit))" >&2 + fi +} + move_preserved_path_out_of_merge() { local path=$1 saved parent index if [ ! -e "$PROJ/$path" ] && [ ! -L "$PROJ/$path" ]; then @@ -375,7 +442,7 @@ if [ "${#DIRTY_PATHS[@]}" -gt 0 ]; then echo "error: could not construct the $BRANCH ignore view; refusing to merge a dirty checkout" >&2 exit 1 fi - if ! expand_unignored_untracked_directories; then + if ! expand_untracked_directories_against_target; then echo "error: could not classify untracked directories against $BRANCH; refusing to merge a dirty checkout" >&2 exit 1 fi @@ -527,10 +594,7 @@ if [ "${#DIRTY_PATHS[@]}" -gt 0 ]; then fi if [ "${#UNRESOLVED_PATHS[@]}" -gt 0 ]; then - echo "error: $PROJ has dirty paths not resolved by $BRANCH; refusing to merge:" >&2 - for ((i = 0; i < ${#UNRESOLVED_PATHS[@]}; i++)); do - printf ' - %q: %s\n' "${UNRESOLVED_PATHS[$i]}" "${UNRESOLVED_REASONS[$i]}" >&2 - done + print_unresolved_paths exit 1 fi @@ -577,10 +641,7 @@ for ((i = 0; i < ${#RESOLVED_PATHS[@]}; i++)); do done if [ "${#UNRESOLVED_PATHS[@]}" -gt 0 ]; then - echo "error: $PROJ has dirty paths not resolved by $BRANCH; refusing to merge:" >&2 - for ((i = 0; i < ${#UNRESOLVED_PATHS[@]}; i++)); do - printf ' - %q: %s\n' "${UNRESOLVED_PATHS[$i]}" "${UNRESOLVED_REASONS[$i]}" >&2 - done + print_unresolved_paths exit 1 fi @@ -636,6 +697,21 @@ for ((i = 0; i < ${#PRESERVE_PATHS[@]}; i++)); do fi continue fi + if [ "${PRESERVE_KINDS[$i]}" = "symlink" ]; then + snapshot="$TARGET_VIEW_ROOT/readlink" + if [ ! -L "$PROJ/$path" ] || ! readlink "$PROJ/$path" >"$snapshot" \ + || ! preserved_oid=$(git -C "$PROJ" hash-object -- "$snapshot"); then + printf 'error: fast-forward completed, but ignored symlink %q is now absent or unreadable\n' \ + "$path" >&2 + exit 1 + fi + if [ "$preserved_oid" != "${PRESERVE_OIDS[$i]}" ]; then + printf 'error: fast-forward completed, but ignored symlink %q changed target\n' \ + "$path" >&2 + exit 1 + fi + continue + fi if ! preserved_oid=$(git -C "$PROJ" hash-object -- "$path" 2>/dev/null); then printf 'error: fast-forward completed, but ignored path %q was blob %s before the merge and is now absent or unreadable\n' \ "$path" "${PRESERVE_OIDS[$i]}" >&2 diff --git a/tests/fm-merge-local.test.sh b/tests/fm-merge-local.test.sh index 59c34d3e6ed..616366c68e3 100755 --- a/tests/fm-merge-local.test.sh +++ b/tests/fm-merge-local.test.sh @@ -18,6 +18,17 @@ # (l) an ignored untracked directory with an incoming tracked collision refuses # (m) staged mode-only dirt matching the incoming mode remains supported # (n) an ignored file nested under an unignored directory keeps its hash proof +# (o) ignored symlinks preserve their readlink target without following it +# (p) target content changes behind an ignored symlink do not change its proof +# (q) ignored special files still refuse +# (r) large unresolved-path diagnostics are bounded +# (s) staged tracked-to-ignored symlinks are compared without following them +# (t) index flags cannot hide staged symlink target drift +# (u) index flags cannot hide staged symlink type drift +# (v) replacement refs cannot substitute staged symlink bytes +# (w) NUL bytes in a staged symlink blob cannot collapse during comparison +# (x) nested directly ignored directories are preserved without descending +# (y) current parent ignores cannot hide a narrower target directory boundary set -u # shellcheck disable=SC1091 @@ -410,6 +421,358 @@ test_staged_mode_only_dirt_matching_branch_permits() { pass "fm-merge-local retains the incoming mode for staged mode-only dirt" } +test_branch_ignored_symlink_is_preserved() { + local case_dir link_target + case_dir=$(make_case ignored-symlink) + printf 'runtime-link\n' >"$case_dir/branch/.gitignore" + git -C "$case_dir/branch" add .gitignore + git -C "$case_dir/branch" commit -qm "ignore runtime symlink" + ln -s missing-runtime-target "$case_dir/project/runtime-link" + link_target=$(readlink "$case_dir/project/runtime-link") + + run_merge_local "$case_dir" >"$case_dir/stdout" 2>"$case_dir/stderr" \ + || fail "ignored-symlink: merge should preserve an ignored symlink" + + [ -L "$case_dir/project/runtime-link" ] \ + || fail "ignored-symlink: merge removed the ignored symlink" + [ "$(readlink "$case_dir/project/runtime-link")" = "$link_target" ] \ + || fail "ignored-symlink: merge changed the ignored symlink target" + assert_main_reached_branch "$case_dir" \ + "ignored-symlink: main did not fast-forward to the task branch" + pass "fm-merge-local preserves an ignored symlink without following it" +} + +test_branch_ignored_symlink_ignores_target_content_changes() { + local case_dir link_target + case_dir=$(make_case ignored-symlink-target-change) + printf 'runtime-link\n' >"$case_dir/branch/.gitignore" + printf 'tracked target\n' >"$case_dir/branch/tracked.txt" + git -C "$case_dir/branch" add .gitignore tracked.txt + git -C "$case_dir/branch" commit -qm "change target behind ignored symlink" + ln -s tracked.txt "$case_dir/project/runtime-link" + link_target=$(readlink "$case_dir/project/runtime-link") + + run_merge_local "$case_dir" >"$case_dir/stdout" 2>"$case_dir/stderr" \ + || fail "ignored-symlink-target-change: target content changes must not invalidate the link proof" + + [ -L "$case_dir/project/runtime-link" ] \ + || fail "ignored-symlink-target-change: merge removed the ignored symlink" + [ "$(readlink "$case_dir/project/runtime-link")" = "$link_target" ] \ + || fail "ignored-symlink-target-change: merge changed the ignored symlink target" + assert_grep 'tracked target' "$case_dir/project/runtime-link" \ + "ignored-symlink-target-change: incoming tracked target did not land" + assert_main_reached_branch "$case_dir" \ + "ignored-symlink-target-change: main did not fast-forward to the task branch" + pass "fm-merge-local proves an ignored symlink independently of its target content" +} + +test_staged_tracked_to_ignored_symlink_is_preserved() { + local case_dir link_target + case_dir=$(make_case staged-ignored-symlink) + rm "$case_dir/project/runtime-state.txt" + ln -s missing-initial-target "$case_dir/project/runtime-state.txt" + git -C "$case_dir/project" add runtime-state.txt + git -C "$case_dir/project" commit -qm "track runtime symlink" + git -C "$case_dir/branch" merge -q --ff-only main + git -C "$case_dir/branch" rm -q --cached runtime-state.txt + printf 'runtime-state.txt\n' >"$case_dir/branch/.gitignore" + git -C "$case_dir/branch" add .gitignore + git -C "$case_dir/branch" commit -qm "ignore runtime symlink" + mkdir "$case_dir/directory-target" + rm "$case_dir/project/runtime-state.txt" + ln -s ../directory-target "$case_dir/project/runtime-state.txt" + git -C "$case_dir/project" add runtime-state.txt + link_target=$(readlink "$case_dir/project/runtime-state.txt") + + run_merge_local "$case_dir" >"$case_dir/stdout" 2>"$case_dir/stderr" \ + || fail "staged-ignored-symlink: merge should preserve a staged ignored symlink" + + [ -L "$case_dir/project/runtime-state.txt" ] \ + || fail "staged-ignored-symlink: merge removed the ignored symlink" + [ "$(readlink "$case_dir/project/runtime-state.txt")" = "$link_target" ] \ + || fail "staged-ignored-symlink: merge changed the staged symlink target" + assert_path_clean "$case_dir/project" runtime-state.txt \ + "staged-ignored-symlink: preserved symlink remained dirty after merge" + assert_main_reached_branch "$case_dir" \ + "staged-ignored-symlink: main did not fast-forward to the task branch" + pass "fm-merge-local preserves staged ignored symlinks without following them" +} + +test_assume_unchanged_does_not_hide_staged_symlink_drift() { + local before case_dir rc + case_dir=$(make_case staged-ignored-symlink-assume-unchanged) + rm "$case_dir/project/runtime-state.txt" + ln -s missing-initial-target "$case_dir/project/runtime-state.txt" + git -C "$case_dir/project" add runtime-state.txt + git -C "$case_dir/project" commit -qm "track runtime symlink" + git -C "$case_dir/branch" merge -q --ff-only main + git -C "$case_dir/branch" rm -q --cached runtime-state.txt + printf 'runtime-state.txt\n' >"$case_dir/branch/.gitignore" + git -C "$case_dir/branch" add .gitignore + git -C "$case_dir/branch" commit -qm "ignore runtime symlink" + rm "$case_dir/project/runtime-state.txt" + ln -s staged-target "$case_dir/project/runtime-state.txt" + git -C "$case_dir/project" add runtime-state.txt + git -C "$case_dir/project" update-index --assume-unchanged -- runtime-state.txt + rm "$case_dir/project/runtime-state.txt" + ln -s unstaged-target "$case_dir/project/runtime-state.txt" + before=$(git -C "$case_dir/project" rev-parse main) + + set +e + run_merge_local "$case_dir" >"$case_dir/stdout" 2>"$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" \ + "staged-ignored-symlink-assume-unchanged: merge should refuse target drift" + assert_grep 'staged content or mode does not match' "$case_dir/stderr" \ + "staged-ignored-symlink-assume-unchanged: refusal diagnostic changed" + [ "$(git -C "$case_dir/project" rev-parse main)" = "$before" ] \ + || fail "staged-ignored-symlink-assume-unchanged: refusal advanced main" + [ "$(readlink "$case_dir/project/runtime-state.txt")" = "unstaged-target" ] \ + || fail "staged-ignored-symlink-assume-unchanged: refusal changed the link" + pass "fm-merge-local rejects staged symlink drift hidden by index flags" +} + +test_assume_unchanged_does_not_hide_staged_symlink_type_drift() { + local before case_dir rc + case_dir=$(make_case staged-ignored-symlink-type-drift) + rm "$case_dir/project/runtime-state.txt" + ln -s missing-initial-target "$case_dir/project/runtime-state.txt" + git -C "$case_dir/project" add runtime-state.txt + git -C "$case_dir/project" commit -qm "track runtime symlink" + git -C "$case_dir/branch" merge -q --ff-only main + git -C "$case_dir/branch" rm -q --cached runtime-state.txt + printf 'runtime-state.txt\n' >"$case_dir/branch/.gitignore" + git -C "$case_dir/branch" add .gitignore + git -C "$case_dir/branch" commit -qm "ignore runtime symlink" + rm "$case_dir/project/runtime-state.txt" + ln -s staged-target "$case_dir/project/runtime-state.txt" + git -C "$case_dir/project" add runtime-state.txt + git -C "$case_dir/project" update-index --assume-unchanged -- runtime-state.txt + rm "$case_dir/project/runtime-state.txt" + printf '%s' staged-target >"$case_dir/project/runtime-state.txt" + before=$(git -C "$case_dir/project" rev-parse main) + + set +e + run_merge_local "$case_dir" >"$case_dir/stdout" 2>"$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" \ + "staged-ignored-symlink-type-drift: merge should refuse type drift" + assert_grep 'staged content or mode does not match' "$case_dir/stderr" \ + "staged-ignored-symlink-type-drift: refusal diagnostic changed" + [ "$(git -C "$case_dir/project" rev-parse main)" = "$before" ] \ + || fail "staged-ignored-symlink-type-drift: refusal advanced main" + if [ ! -f "$case_dir/project/runtime-state.txt" ] \ + || [ -L "$case_dir/project/runtime-state.txt" ]; then + fail "staged-ignored-symlink-type-drift: refusal changed the file type" + fi + assert_grep 'staged-target' "$case_dir/project/runtime-state.txt" \ + "staged-ignored-symlink-type-drift: refusal changed the file content" + pass "fm-merge-local rejects staged symlink type drift hidden by index flags" +} + +test_replace_ref_does_not_substitute_staged_symlink_blob() { + local before case_dir index_oid rc replacement_oid + case_dir=$(make_case staged-ignored-symlink-replace-ref) + rm "$case_dir/project/runtime-state.txt" + ln -s missing-initial-target "$case_dir/project/runtime-state.txt" + git -C "$case_dir/project" add runtime-state.txt + git -C "$case_dir/project" commit -qm "track runtime symlink" + git -C "$case_dir/branch" merge -q --ff-only main + git -C "$case_dir/branch" rm -q --cached runtime-state.txt + printf 'runtime-state.txt\n' >"$case_dir/branch/.gitignore" + git -C "$case_dir/branch" add .gitignore + git -C "$case_dir/branch" commit -qm "ignore runtime symlink" + rm "$case_dir/project/runtime-state.txt" + ln -s staged-target "$case_dir/project/runtime-state.txt" + git -C "$case_dir/project" add runtime-state.txt + index_oid=$( + git -C "$case_dir/project" ls-files --stage -- runtime-state.txt \ + | awk '{ print $2 }' + ) + replacement_oid=$( + printf '%s' replacement-target \ + | git -C "$case_dir/project" hash-object -w --stdin + ) + git -C "$case_dir/project" replace "$index_oid" "$replacement_oid" + rm "$case_dir/project/runtime-state.txt" + ln -s replacement-target "$case_dir/project/runtime-state.txt" + before=$(git -C "$case_dir/project" rev-parse main) + + set +e + run_merge_local "$case_dir" >"$case_dir/stdout" 2>"$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" \ + "staged-ignored-symlink-replace-ref: merge should refuse substituted bytes" + assert_grep 'staged content or mode does not match' "$case_dir/stderr" \ + "staged-ignored-symlink-replace-ref: refusal diagnostic changed" + [ "$(git -C "$case_dir/project" rev-parse main)" = "$before" ] \ + || fail "staged-ignored-symlink-replace-ref: refusal advanced main" + [ "$(readlink "$case_dir/project/runtime-state.txt")" = "replacement-target" ] \ + || fail "staged-ignored-symlink-replace-ref: refusal changed the link" + pass "fm-merge-local rejects staged symlink substitution by replacement refs" +} + +test_nul_in_staged_symlink_blob_refuses() { + local before case_dir index_oid rc + case_dir=$(make_case staged-ignored-symlink-nul) + rm "$case_dir/project/runtime-state.txt" + ln -s initial-target "$case_dir/project/runtime-state.txt" + git -C "$case_dir/project" add runtime-state.txt + git -C "$case_dir/project" commit -qm "track runtime symlink" + git -C "$case_dir/branch" merge -q --ff-only main + git -C "$case_dir/branch" rm -q --cached runtime-state.txt + printf 'runtime-state.txt\n' >"$case_dir/branch/.gitignore" + git -C "$case_dir/branch" add .gitignore + git -C "$case_dir/branch" commit -qm "ignore runtime symlink" + rm "$case_dir/project/runtime-state.txt" + ln -s staged-target "$case_dir/project/runtime-state.txt" + index_oid=$( + printf 'staged\0-target' \ + | git -C "$case_dir/project" hash-object -w --stdin + ) + git -C "$case_dir/project" update-index --add \ + --cacheinfo "120000,$index_oid,runtime-state.txt" + before=$(git -C "$case_dir/project" rev-parse main) + + set +e + run_merge_local "$case_dir" >"$case_dir/stdout" 2>"$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" \ + "staged-ignored-symlink-nul: merge should refuse distinct binary staged bytes" + assert_grep 'staged content or mode does not match' "$case_dir/stderr" \ + "staged-ignored-symlink-nul: refusal diagnostic changed" + [ "$(git -C "$case_dir/project" rev-parse main)" = "$before" ] \ + || fail "staged-ignored-symlink-nul: refusal advanced main" + [ "$(readlink "$case_dir/project/runtime-state.txt")" = "staged-target" ] \ + || fail "staged-ignored-symlink-nul: refusal changed the link" + pass "fm-merge-local compares staged symlink blobs without Bash byte loss" +} + +test_nested_directly_ignored_directory_preserves_special_entries() { + local case_dir + case_dir=$(make_case nested-ignored-directory-special-entry) + printf 'outer/cache/\n' >"$case_dir/branch/.gitignore" + printf 'incoming\n' >"$case_dir/branch/incoming.txt" + git -C "$case_dir/branch" add .gitignore incoming.txt + git -C "$case_dir/branch" commit -qm "ignore nested runtime cache" + mkdir -p "$case_dir/project/outer/cache" + mkfifo "$case_dir/project/outer/cache/runtime.pipe" + + run_merge_local "$case_dir" >"$case_dir/stdout" 2>"$case_dir/stderr" \ + || fail "nested-ignored-directory-special-entry: merge should preserve the ignored directory" + + [ -d "$case_dir/project/outer/cache" ] \ + || fail "nested-ignored-directory-special-entry: merge removed the ignored directory" + [ -p "$case_dir/project/outer/cache/runtime.pipe" ] \ + || fail "nested-ignored-directory-special-entry: merge inspected or changed the FIFO" + assert_main_reached_branch "$case_dir" \ + "nested-ignored-directory-special-entry: main did not fast-forward to the task branch" + pass "fm-merge-local stops at nested target-ignored directory boundaries" +} + +test_currently_ignored_parent_reclassifies_nested_target_directory() { + local case_dir + case_dir=$(make_case current-ignore-to-nested-target-ignore) + printf 'outer/\n' >"$case_dir/project/.gitignore" + git -C "$case_dir/project" add .gitignore + git -C "$case_dir/project" commit -qm "ignore outer runtime directory" + git -C "$case_dir/branch" merge -q --ff-only main + printf 'outer/cache/\n' >"$case_dir/branch/.gitignore" + printf 'incoming\n' >"$case_dir/branch/incoming.txt" + git -C "$case_dir/branch" add .gitignore incoming.txt + git -C "$case_dir/branch" commit -qm "narrow runtime directory ignore" + mkdir -p "$case_dir/project/outer/cache" + mkfifo "$case_dir/project/outer/cache/runtime.pipe" + + run_merge_local "$case_dir" >"$case_dir/stdout" 2>"$case_dir/stderr" \ + || fail "current-ignore-to-nested-target-ignore: merge should preserve the nested ignored directory" + + [ -d "$case_dir/project/outer/cache" ] \ + || fail "current-ignore-to-nested-target-ignore: merge removed the nested ignored directory" + [ -p "$case_dir/project/outer/cache/runtime.pipe" ] \ + || fail "current-ignore-to-nested-target-ignore: merge inspected or changed the FIFO" + assert_main_reached_branch "$case_dir" \ + "current-ignore-to-nested-target-ignore: main did not fast-forward to the task branch" + pass "fm-merge-local reclassifies currently ignored parents against target rules" +} + +test_branch_ignored_special_file_refuses() { + local before case_dir rc + case_dir=$(make_case ignored-special-file) + git -C "$case_dir/branch" rm -q --cached runtime-state.txt + printf 'runtime-state.txt\n' >"$case_dir/branch/.gitignore" + git -C "$case_dir/branch" add .gitignore + git -C "$case_dir/branch" commit -qm "leave runtime state ignored" + rm "$case_dir/project/runtime-state.txt" + mkfifo "$case_dir/project/runtime-state.txt" + before=$(git -C "$case_dir/project" rev-parse main) + + set +e + run_merge_local "$case_dir" >"$case_dir/stdout" 2>"$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "ignored-special-file: merge should refuse" + assert_grep 'runtime-state.txt' "$case_dir/stderr" \ + "ignored-special-file: refusal did not name the special file" + assert_grep 'ignored path is neither absent nor hashable as a file' \ + "$case_dir/stderr" \ + "ignored-special-file: refusal diagnostic changed" + [ "$(git -C "$case_dir/project" rev-parse main)" = "$before" ] \ + || fail "ignored-special-file: refusal advanced main" + pass "fm-merge-local still refuses an ignored non-file, non-symlink entry" +} + +test_unresolved_path_diagnostic_is_bounded() { + local before case_dir diagnostic_count i rc + case_dir=$(make_case bounded-diagnostic) + for i in $(seq -w 1 55); do + printf 'tracked runtime state\n' >"$case_dir/project/runtime-$i.pipe" + done + git -C "$case_dir/project" add runtime-*.pipe + git -C "$case_dir/project" commit -qm "track runtime state" + git -C "$case_dir/branch" merge -q --ff-only main + git -C "$case_dir/branch" rm -q --cached runtime-*.pipe + printf 'runtime-*.pipe\n' >"$case_dir/branch/.gitignore" + git -C "$case_dir/branch" add .gitignore + git -C "$case_dir/branch" commit -qm "leave runtime pipes ignored" + for i in $(seq -w 1 55); do + rm "$case_dir/project/runtime-$i.pipe" + mkfifo "$case_dir/project/runtime-$i.pipe" + done + before=$(git -C "$case_dir/project" rev-parse main) + + set +e + run_merge_local "$case_dir" >"$case_dir/stdout" 2>"$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "bounded-diagnostic: merge should refuse" + diagnostic_count=$(grep -c '^ - ' "$case_dir/stderr") + [ "$diagnostic_count" -eq 50 ] \ + || fail "bounded-diagnostic: expected 50 printed paths, got $diagnostic_count" + assert_grep 'runtime-01.pipe' "$case_dir/stderr" \ + "bounded-diagnostic: refusal omitted the first unresolved path" + assert_grep 'runtime-50.pipe' "$case_dir/stderr" \ + "bounded-diagnostic: refusal omitted the fiftieth unresolved path" + if grep -q 'runtime-51.pipe' "$case_dir/stderr"; then + fail "bounded-diagnostic: refusal printed paths beyond the cap" + fi + assert_grep '+5 more unresolved paths' "$case_dir/stderr" \ + "bounded-diagnostic: refusal omitted the remaining-path summary" + [ "$(git -C "$case_dir/project" rev-parse main)" = "$before" ] \ + || fail "bounded-diagnostic: refusal advanced main" + pass "fm-merge-local bounds large unresolved-path diagnostics" +} + test_diverged_branch_still_refuses() { local case_dir rc case_dir=$(make_case diverged) @@ -621,6 +984,17 @@ test_branch_ignored_directory_with_incoming_collision_refuses test_branch_ignored_directory_with_casefolded_incoming_collision_refuses test_tracked_path_replaced_by_directory_refuses_staged_state test_staged_mode_only_dirt_matching_branch_permits +test_branch_ignored_symlink_is_preserved +test_branch_ignored_symlink_ignores_target_content_changes +test_staged_tracked_to_ignored_symlink_is_preserved +test_assume_unchanged_does_not_hide_staged_symlink_drift +test_assume_unchanged_does_not_hide_staged_symlink_type_drift +test_replace_ref_does_not_substitute_staged_symlink_blob +test_nul_in_staged_symlink_blob_refuses +test_nested_directly_ignored_directory_preserves_special_entries +test_currently_ignored_parent_reclassifies_nested_target_directory +test_branch_ignored_special_file_refuses +test_unresolved_path_diagnostic_is_bounded test_diverged_branch_still_refuses test_non_default_checkout_still_refuses test_mixed_resolved_and_unresolved_refuses From c059bd0fded26d1edd50bf5cf7f8879c20a37103 Mon Sep 17 00:00:00 2001 From: quinnbot-ai Date: Wed, 29 Jul 2026 20:34:25 -0700 Subject: [PATCH 15/70] fix(bin): resolve publish remote (fork over origin) for self-update and base comparisons (#66) * fix: resolve the publish remote for syncs and landed-work checks Every self-update reported "skipped: diverged from origin/main" once `origin` became the upstream and `fork` the publish target, so running homes never received landed updates while their local main was cleanly behind fork/main. bin/fm-remote-lib.sh is now the single owner of that resolution: `fork` when that remote exists, else `origin`. A single-remote clone keeps today's behavior exactly. Fast-forward-only semantics are unchanged - nothing is forced, stashed, or discarded, and divergence from the publish remote is still refused. The sweep covers every other place that assumed landed work lives on origin: the self-update and secondmate sync (bin/fm-ff-lib.sh base mode, renamed "origin" to "publish"), project clone refresh (bin/fm-fleet-sync.sh plus bootstrap's remote-backed clone count), teardown's landed-work test (PR head fetch and default-branch content check), the review base and PR-head fetch (bin/fm-review-diff.sh), and the changed-test base ref (bin/fm-test-run.sh). bin/fm-review-diff.sh also passes --no-ext-diff so review reads the real patch instead of an operator's configured diff.external renderer, which was making its PR-head assertion fail locally. * no-mistakes(document): Document publish-remote resolution accurately * test: stage fm-remote-lib.sh in the fixtures that build a synthetic bin CI's serial lane failed because fm-teardown.sh now sources fm-remote-lib.sh, while two fixtures build a synthetic bin/ from a hand-listed subset of libs and did not stage it, so teardown aborted under set -eu: - tests/fm-backend.test.sh's old-vs-new conformance old bin (sibling list) - tests/fm-gotmp.test.sh's two fake FM_ROOT builders (symlinked libs) Both now stage the lib, exactly as they already do for fm-pr-lib.sh. bin/fm-test-run.sh's changed-file map also selects backend-dispatch for bin/fm-remote-lib.sh, since that family's fixtures stage fm-teardown.sh; without it a change to this lib never selects the suite that just caught this. --------- Co-authored-by: QuinnBot --- .agents/skills/updatefirstmate/SKILL.md | 5 +- README.md | 2 +- bin/fm-bootstrap.sh | 17 ++-- bin/fm-ff-lib.sh | 43 +++++---- bin/fm-fleet-sync.sh | 30 +++--- bin/fm-remote-lib.sh | 34 +++++++ bin/fm-review-diff.sh | 28 +++--- bin/fm-teardown.sh | 19 ++-- bin/fm-test-run.sh | 15 ++- bin/fm-update.sh | 14 +-- docs/architecture.md | 9 +- docs/configuration.md | 6 +- docs/scripts.md | 5 +- tests/fm-backend.test.sh | 2 +- tests/fm-fleet-sync.test.sh | 32 +++++++ tests/fm-gotmp.test.sh | 6 ++ tests/fm-secondmate-sync.test.sh | 2 +- tests/fm-test-run.test.sh | 4 + tests/fm-update.test.sh | 119 +++++++++++++++++++++++- 19 files changed, 312 insertions(+), 80 deletions(-) create mode 100644 bin/fm-remote-lib.sh diff --git a/.agents/skills/updatefirstmate/SKILL.md b/.agents/skills/updatefirstmate/SKILL.md index de95ed35302..8687445598c 100644 --- a/.agents/skills/updatefirstmate/SKILL.md +++ b/.agents/skills/updatefirstmate/SKILL.md @@ -1,6 +1,6 @@ --- name: updatefirstmate -description: Self-update a running firstmate and its secondmates to the latest from origin. Use when the captain invokes /updatefirstmate (e.g. "/updatefirstmate", "update firstmate", "pull the latest firstmate"). Fast-forwards this firstmate repo's default branch and every secondmate home from origin (fast-forward only, never forced, never disruptive), then re-reads AGENTS.md and nudges each updated secondmate to do the same, so the whole tree runs the latest bin/ and instructions. +description: Self-update a running firstmate and its secondmates to the latest published default branch. Use when the captain invokes /updatefirstmate (e.g. "/updatefirstmate", "update firstmate", "pull the latest firstmate"). Fast-forwards this firstmate repo's default branch and every secondmate home from the publish remote (fast-forward only, never forced, never disruptive), then re-reads AGENTS.md and nudges each updated secondmate to do the same, so the whole tree runs the latest bin/ and instructions. user-invocable: true metadata: internal: true @@ -24,7 +24,8 @@ This touches only the firstmate repo and its own worktrees, never anything under ```sh bin/fm-update.sh ``` - It fast-forwards this firstmate repo's default branch from origin, then fast-forwards every registered secondmate home (each a treehouse worktree of this same repo, leased at a detached HEAD on the default branch) the same way. + It fast-forwards this firstmate repo's default branch from the publish remote resolved by [`bin/fm-remote-lib.sh`](../../../bin/fm-remote-lib.sh), then fast-forwards every registered secondmate home (each a treehouse worktree of this same repo, leased at a detached HEAD on the default branch) the same way. + A checkout whose `origin` is an upstream it was forked from therefore still receives the fleet's own landed updates, instead of skipping forever as diverged. It prints one status line per target (`updated ..` / `already current` / `skipped: `), followed by two action lines that tell you exactly what to do next: - `reread-firstmate: yes|no` - `nudge-secondmates: fm-...|none` diff --git a/README.md b/README.md index 7daaaa8813d..1ab06a4a355 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,7 @@ Claude and grok use the slash form shown here; codex uses the same names with `$ | `/afk` | Enter away-mode supervision: the sub-supervisor self-handles routine notifications in bash, escalates captain-relevant events and bounded declared-external-wait rechecks as batched digests, and actively alerts if delivery gets stuck while you step away | | `/ahoy` | Recap visible session events since the prior real captain message plus visibly unanswered captain decisions, falling back to Bearings when invoked as the session's first real captain message | | `/bearings` | Generate a standalone current-status report from bounded local fleet and registered-secondmate state, with live PR enrichment only when requested, written to a dated file in `data/` and surfaced concisely in chat; read-mostly, mutates no task state | -| `/updatefirstmate` | Self-update the running firstmate and its secondmates to the latest from origin with fast-forward-only pulls, then re-read instructions and nudge secondmates | +| `/updatefirstmate` | Self-update the running firstmate and its secondmates to the latest published default branch with fast-forward-only pulls, then re-read instructions and nudge secondmates | | `/stow` | Sweep the session for uncaptured durable knowledge, route each finding to its disk home per AGENTS.md, file undone next steps to the backlog, and report what is now safe to reset | Agent-only reference skills live under `.agents/skills/` and are loaded by firstmate at the trigger points named in [`AGENTS.md`](AGENTS.md). diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index c86b7e839ab..ecc676c0284 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -19,7 +19,7 @@ # "FMX: X mode on ..." or "FMX: X mode off ...". # When a RUNNING secondmate worktree is fast-forwarded to firstmate's # own current default-branch commit (a purely LOCAL fast-forward, never -# an origin fetch) AND its loaded instruction surface (AGENTS.md, bin/, +# a remote fetch) AND its loaded instruction surface (AGENTS.md, bin/, # or .agents/skills/) actually changed, bootstrap immediately nudges it # via FM_HOME= bin/fm-send.sh fm- so meta resolves the # current backend target and the standard from-firstmate marker is @@ -63,7 +63,7 @@ # bounded by FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT when it is a non-empty # numeric override, while non-numeric values fall back to 20s. # When the override is unset or blank, the timeout is -# max(20, 5 + 3 * origin-backed project clone count). A timed-out +# max(20, 5 + 3 * remote-backed project clone count). A timed-out # refresh relays any completed fm-fleet-sync.sh output before the # aggregate timeout skip line with timeout and elapsed seconds. # Set FM_FLEET_PRUNE=0 to skip branch pruning during that refresh. @@ -102,14 +102,17 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" # shellcheck source=bin/fm-backend.sh disable=SC1091 . "$SCRIPT_DIR/fm-backend.sh" -fleet_sync_origin_backed_project_count() { +# Count the clones fleet-sync will actually fetch: those with a publish remote +# (bin/fm-remote-lib.sh, sourced via fm-ff-lib.sh). A fork-backed clone counts the +# same as an origin-backed one, so the refresh timeout below scales with real work. +fleet_sync_remote_backed_project_count() { local count proj count=0 [ -d "$PROJECTS" ] || { echo 0; return 0; } for proj in "$PROJECTS"/*; do [ -d "$proj" ] || continue git -C "$proj" rev-parse --git-dir >/dev/null 2>&1 || continue - git -C "$proj" remote get-url origin >/dev/null 2>&1 || continue + fm_publish_remote "$proj" >/dev/null 2>&1 || continue count=$((count + 1)) done echo "$count" @@ -125,7 +128,7 @@ fleet_sync_bootstrap_timeout() { return 0 fi - count=$(fleet_sync_origin_backed_project_count) + count=$(fleet_sync_remote_backed_project_count) timeout=$((5 + (3 * count))) [ "$timeout" -ge 20 ] || timeout=20 echo "$timeout" @@ -190,9 +193,9 @@ secondmate_sync() { . "$SCRIPT_DIR/fm-wake-lib.sh" # Local-HEAD secondmate sync: fast-forward every LIVE secondmate home # to the primary checkout's current default-branch commit. Purely LOCAL - no - # fetch, no origin dependency: a linked-worktree home already holds the primary's + # fetch, no remote dependency: a linked-worktree home already holds the primary's # commit (fm-ff-lib.sh), while a standalone clone without it is skipped until - # /updatefirstmate refreshes it from origin. Startup sends reread nudges only + # /updatefirstmate refreshes it from its publish remote. Startup sends reread nudges only # for RUNNING secondmates whose instruction surface (AGENTS.md, bin/, or # .agents/skills/) actually changed, so a secondmate already on the primary's # version is never disturbed (AGENTS.md bootstrap + supervision). Unlike diff --git a/bin/fm-ff-lib.sh b/bin/fm-ff-lib.sh index e6d6801a79f..1bdad4cf033 100644 --- a/bin/fm-ff-lib.sh +++ b/bin/fm-ff-lib.sh @@ -5,15 +5,16 @@ # This is the one implementation of "advance a firstmate checkout to a base by a # clean fast-forward, never forcing, merging, or stashing" used by every sync # path: -# - /updatefirstmate (bin/fm-update.sh) pulls from origin: base_mode "origin". +# - /updatefirstmate (bin/fm-update.sh) pulls from the checkout's PUBLISH remote +# as resolved by bin/fm-remote-lib.sh: base_mode "publish". # - the local-HEAD secondmate sync (bin/fm-spawn.sh on launch, bin/fm-bootstrap.sh # on startup) follows the PRIMARY checkout's current default-branch commit: -# base_mode is that local commit, with NO fetch and no origin dependency. +# base_mode is that local commit, with NO fetch and no remote dependency. # # A linked-worktree secondmate home already holds the primary's commit in the # shared object store, so its local-HEAD sync is a purely local fast-forward that # never touches the network. A standalone clone moves through that path only when -# it already has the target; otherwise it is skipped until the origin path updates it. +# it already has the target; otherwise it is skipped until the publish path updates it. # A tracked-files fast-forward never touches the gitignored operational dirs # (data/, state/, config/, projects/, .no-mistakes/), so it cannot disturb a # secondmate's backlog, projects, or in-flight work. @@ -26,6 +27,9 @@ SUB_HOME_MARKER="${SUB_HOME_MARKER:-.fm-secondmate-home}" +# shellcheck source=bin/fm-remote-lib.sh disable=SC1091 +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/fm-remote-lib.sh" + # --- helpers --------------------------------------------------------------- first_line() { @@ -189,19 +193,20 @@ validate_secondmate_home() { } # A single fetch refreshes every worktree that shares an object store, so fetch -# each distinct git-common-dir at most once. Used ONLY by the origin base mode; -# the local-HEAD sync never fetches. +# each distinct git-common-dir at most once per remote. Used ONLY by the publish +# base mode; the local-HEAD sync never fetches. FETCHED="" fetch_once() { - local dir=$1 common + local dir=$1 remote=$2 common key common=$(git -C "$dir" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true) if [ -n "$common" ]; then + key="$remote:$common" case " $FETCHED " in - *" $common "*) return 0 ;; + *" $key "*) return 0 ;; esac fi - if git -C "$dir" fetch origin --prune --quiet 2>/dev/null; then - [ -n "$common" ] && FETCHED="$FETCHED $common" + if git -C "$dir" fetch "$remote" --prune --quiet 2>/dev/null; then + [ -n "$common" ] && FETCHED="$FETCHED $key" return 0 fi return 1 @@ -271,9 +276,11 @@ live_secondmate_meta_records() { # FF_INSTR = comma list of changed instruction paths (only when updated) # # base_mode selects where the fast-forward base comes from: -# origin - fetch origin and advance to origin/ (the /updatefirstmate -# path); requires an origin remote and network reachability. -# - advance to that LOCAL commit with NO fetch and no origin +# publish - fetch the checkout's publish remote (resolved by +# bin/fm-remote-lib.sh) and advance to / +# (the /updatefirstmate path); requires a resolved remote and +# network reachability. +# - advance to that LOCAL commit with NO fetch and no remote # dependency (the local-HEAD secondmate sync). The commit must # already exist in the target's object store, which it always does # for a worktree of this same repo; a standalone clone that lacks @@ -296,23 +303,25 @@ ff_target() { return 0 fi - local default base cur instr local_rev base_rev before after out + local default base cur instr local_rev base_rev before after out remote default=$(default_branch "$dir") || { echo "$label: skipped: cannot determine default branch" return 0 } # Resolve the fast-forward base from base_mode (see header). - if [ "$base_mode" = origin ]; then - if ! git -C "$dir" remote get-url origin >/dev/null 2>&1; then + if [ "$base_mode" = publish ]; then + if ! remote=$(fm_publish_remote "$dir"); then + # Reached only when the checkout has neither a fork nor an origin remote; + # the wording matches fm-fleet-sync.sh's benign no-remote skip. echo "$label: skipped: no origin remote" return 0 fi - if ! fetch_once "$dir"; then + if ! fetch_once "$dir" "$remote"; then echo "$label: skipped: fetch failed" return 0 fi - base="origin/$default" + base="$remote/$default" else base="$base_mode" fi diff --git a/bin/fm-fleet-sync.sh b/bin/fm-fleet-sync.sh index 5c338edf68f..a36cf68dfac 100755 --- a/bin/fm-fleet-sync.sh +++ b/bin/fm-fleet-sync.sh @@ -1,18 +1,20 @@ #!/usr/bin/env bash # Refresh project clones: fast-forward the checked-out local default branch to -# origin/ when safe, and prune local branches whose upstream tracking +# / when safe, and prune local branches whose upstream tracking # branch is gone (the remote branch was deleted, i.e. its PR merged) and that no # worktree still needs. # Self-heals the one unambiguously safe drift: a clean, detached HEAD that holds -# no unique commits (it is an ancestor of origin/) and whose +# no unique commits (it is an ancestor of /) and whose # branch is free to check out is re-attached and then fast-forwarded ("recovered:"). # Every other off-default state - a non-default named branch, a detached HEAD with # unique commits, a dirty tree, or a diverged default - may hold real work, so it # is left untouched and reported as a quantified, loud "STUCK: ... N commits behind # ... - needs attention" warning rather than a quiet drift. Nothing is ever forced, # stashed, or discarded. -# Still skips (benignly) local-only/no-origin projects, missing remotes/branches, +# Still skips (benignly) local-only/no-remote projects, missing remotes/branches, # and fetch failures. +# The publish remote is resolved per clone by bin/fm-remote-lib.sh, so a +# fork-backed clone follows the remote its own work lands on instead of its upstream. # Pruning never deletes the checked-out branch or a branch that still has a # worktree, so it cannot discard unlanded work; set FM_FLEET_PRUNE=0 to disable it. # When the fetch fails on an orphaned .git/packed-refs.lock (left by a ref rewrite @@ -35,6 +37,8 @@ FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" # shellcheck source=bin/fm-lock-lib.sh . "$SCRIPT_DIR/fm-lock-lib.sh" +# shellcheck source=bin/fm-remote-lib.sh +. "$SCRIPT_DIR/fm-remote-lib.sh" FM_LOCK_LOG_PREFIX=fleet-sync "$FM_ROOT/bin/fm-guard.sh" || true @@ -148,7 +152,7 @@ packed_refs_lock_path() { esac } -# Run `git -C "$PROJ" fetch origin --prune --quiet`, tolerating an orphaned +# Run `git -C "$PROJ" fetch "$REMOTE" --prune --quiet`, tolerating an orphaned # packed-refs.lock left by a killed ref rewrite. Sets FETCH_OUTPUT to the git # command's combined output and returns its exit status. On the packed-refs.lock # signature ONLY: retry up to FLEET_SYNC_PACKED_REFS_LOCK_RETRIES times (a @@ -161,7 +165,7 @@ packed_refs_lock_path() { # a session-start refresh (which discards fleet-sync stderr) still surfaces it. fetch_with_packed_refs_lock_guard() { local rc attempt=0 lock lock_desc - FETCH_OUTPUT=$(git -C "$PROJ" fetch origin --prune --quiet 2>&1); rc=$? + FETCH_OUTPUT=$(git -C "$PROJ" fetch "$REMOTE" --prune --quiet 2>&1); rc=$? [ "$rc" -eq 0 ] && return 0 is_packed_refs_lock_error "$FETCH_OUTPUT" || return "$rc" @@ -171,7 +175,7 @@ fetch_with_packed_refs_lock_guard() { attempt=$(( attempt + 1 )) echo "$label: fetch blocked by packed-refs lock ($lock_desc); waiting ${FLEET_SYNC_PACKED_REFS_LOCK_RETRY_WAIT_SECS}s and retrying ($attempt/${FLEET_SYNC_PACKED_REFS_LOCK_RETRIES}) (owning process may be exiting)" >&2 sleep "$FLEET_SYNC_PACKED_REFS_LOCK_RETRY_WAIT_SECS" - FETCH_OUTPUT=$(git -C "$PROJ" fetch origin --prune --quiet 2>&1); rc=$? + FETCH_OUTPUT=$(git -C "$PROJ" fetch "$REMOTE" --prune --quiet 2>&1); rc=$? if [ "$rc" -eq 0 ]; then echo "$label: fetch succeeded on retry; packed-refs lock cleared on its own" >&2 # One stdout summary so a session-start refresh (which discards fleet-sync @@ -195,7 +199,7 @@ fetch_with_packed_refs_lock_guard() { return "$rc" fi echo "$label: removed provably-stale packed-refs lock $lock (age >= ${FLEET_SYNC_PACKED_REFS_LOCK_AGE_SECS}s, no live holder) and retrying fetch" >&2 - FETCH_OUTPUT=$(git -C "$PROJ" fetch origin --prune --quiet 2>&1); rc=$? + FETCH_OUTPUT=$(git -C "$PROJ" fetch "$REMOTE" --prune --quiet 2>&1); rc=$? if [ "$rc" -eq 0 ]; then echo "$label: fetch succeeded after stale packed-refs lock cleanup" >&2 echo "$label: recovered: removed a stale packed-refs lock (no live holder)" @@ -217,7 +221,7 @@ prune_gone_branches() { # that still has a worktree (a live or not-yet-torn-down task). "Gone" plus # "no worktree" already proves the work landed: teardown removes a branch's # worktree only after confirming the work reached the remote. We deliberately - # do NOT also require the branch to be an ancestor of origin/ - PRs in + # do NOT also require the branch to be an ancestor of / - PRs in # this fleet are squash-merged, so a merged branch is never an ancestor and # such a check would prune nothing. The no-worktree guard is the real safety # net. Set FM_FLEET_PRUNE=0 to skip pruning entirely. @@ -281,7 +285,7 @@ stuck_state() { } # Loud, quantified report for a clone we deliberately leave untouched. Includes -# how far behind origin/ it is, so a chronically-stuck clone is visibly +# how far behind / it is, so a chronically-stuck clone is visibly # distinct from a benign one-off skip. report_stuck() { local state=$1 behind @@ -307,7 +311,9 @@ sync_project() { echo "$label: skipped: local-only project" return 0 fi - if ! git -C "$PROJ" remote get-url origin >/dev/null 2>&1; then + if ! REMOTE=$(fm_publish_remote "$PROJ"); then + # Neither a fork nor an origin remote: a purely local clone, a benign skip + # that bin/fm-bootstrap.sh filters out of the session-start relay. echo "$label: skipped: no origin remote" return 0 fi @@ -327,7 +333,7 @@ sync_project() { echo "$label: skipped: cannot determine default branch" return 0 } - BASE="origin/$DEFAULT" + BASE="$REMOTE/$DEFAULT" if ! git -C "$PROJ" rev-parse --verify --quiet "$BASE^{commit}" >/dev/null; then echo "$label: skipped: $BASE does not exist" return 0 @@ -341,7 +347,7 @@ sync_project() { if [ "$cur" != "$DEFAULT" ]; then # Off the default branch. Auto-recover only the one unambiguously safe drift: # a clean, detached HEAD that holds no unique commits (it is an ancestor of - # origin/) and whose branch is free to check out here. + # /) and whose branch is free to check out here. # Re-attaching to an already-published commit strands nothing, and the # fast-forward path below then catches the clone up. Anything else - a # non-default named branch, a detached HEAD with unique commits, a dirty tree, diff --git a/bin/fm-remote-lib.sh b/bin/fm-remote-lib.sh new file mode 100644 index 00000000000..c7bb757d900 --- /dev/null +++ b/bin/fm-remote-lib.sh @@ -0,0 +1,34 @@ +# shellcheck shell=bash +# Single owner of "which remote holds this checkout's landed work". +# Usage: . bin/fm-remote-lib.sh then remote=$(fm_publish_remote "$dir") || ... +# +# A checkout can track two remotes: `origin`, the upstream it was forked from, and +# `fork`, the publish target this fleet actually pushes branches and PRs to. Work +# lands on the PUBLISH remote, so every script that fast-forwards a checkout, +# computes a review base, or proves a branch landed must resolve that remote +# instead of assuming `origin`. Assuming `origin` in a fork-backed checkout makes a +# self-update or clone refresh either skip as "diverged" forever or, worse, follow +# the upstream's history instead of the fleet's own. +# +# Resolution is deliberately trivial: `fork` when that remote exists, else +# `origin`. A single-remote clone - the common case, including CI checkouts - keeps +# today's behavior byte for byte, and nothing here fetches, writes, or mutates a +# remote, so it is safe to call from a read-only path. +# +# Branch-NAME resolution stays with each caller's own default_branch helper: the +# publish remote and its upstream share the default branch name, and the local +# main/master fallback already covers a checkout with no origin/HEAD. + +# Print the publish remote resolved above for the git repo at . +# Returns 1 without printing when neither candidate exists, so a caller reports a +# remote-less checkout instead of building a ref that cannot resolve. +fm_publish_remote() { + local dir=$1 remote + for remote in fork origin; do + if git -C "$dir" remote get-url "$remote" >/dev/null 2>&1; then + printf '%s\n' "$remote" + return 0 + fi + done + return 1 +} diff --git a/bin/fm-review-diff.sh b/bin/fm-review-diff.sh index 06e0efb5bd7..03449ee5b62 100755 --- a/bin/fm-review-diff.sh +++ b/bin/fm-review-diff.sh @@ -2,8 +2,10 @@ # Review a crewmate branch against the authoritative base. # # Pooled project clones do not keep their local default branch current, so this -# helper compares remote-backed projects against origin/ after fetching -# the default branch, and local-only projects against the local default branch. +# helper compares remote-backed projects against / after +# fetching the default branch, and local-only projects against the local default +# branch. The publish remote comes from bin/fm-remote-lib.sh, so a fork-backed +# clone is reviewed against the branch its own work lands on, not its upstream. # When state/.meta records pr= (URL or number) for an open PR, the compare # side is ALWAYS a freshly fetched refs/pull//head by default so review stays # current after no-mistakes fix rounds push to the PR. A recorded pr_head= is @@ -18,6 +20,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +# shellcheck source=bin/fm-remote-lib.sh +. "$SCRIPT_DIR/fm-remote-lib.sh" "$FM_ROOT/bin/fm-guard.sh" || true usage() { @@ -92,11 +96,11 @@ pr_number_from_target() { } fetch_pull_head() { - local n=$1 resolved - git -C "$WT" remote get-url origin >/dev/null 2>&1 || return 1 + local n=$1 resolved remote + remote=$(fm_publish_remote "$WT") || return 1 # Fetch into a private ref so a later base-branch fetch cannot clobber the # compare tip via FETCH_HEAD, and so we never review a stale local object. - git -C "$WT" fetch --quiet origin \ + git -C "$WT" fetch --quiet "$remote" \ "+refs/pull/$n/head:refs/fm-review/pull/$n/head" >/dev/null 2>&1 || return 1 resolved=$(git -C "$WT" rev-parse --verify "refs/fm-review/pull/$n/head^{commit}" 2>/dev/null) || return 1 [ -n "$resolved" ] || return 1 @@ -133,11 +137,11 @@ if [ -n "$PR_URL" ]; then fi fi -if git -C "$PROJ" remote get-url origin >/dev/null 2>&1; then +if REMOTE=$(fm_publish_remote "$PROJ"); then # Update the remote-tracking ref itself; a bare single-branch fetch can leave - # origin/ stale on some Git versions and only refresh FETCH_HEAD. - git -C "$WT" fetch origin "+refs/heads/$DEFAULT:refs/remotes/origin/$DEFAULT" --quiet - BASE="origin/$DEFAULT" + # / stale on some Git versions and only refresh FETCH_HEAD. + git -C "$WT" fetch "$REMOTE" "+refs/heads/$DEFAULT:refs/remotes/$REMOTE/$DEFAULT" --quiet + BASE="$REMOTE/$DEFAULT" else BASE="$DEFAULT" fi @@ -146,7 +150,7 @@ git -C "$WT" rev-parse --verify --quiet "$BASE^{commit}" >/dev/null || { echo "e git -C "$WT" rev-parse --verify --quiet "$COMPARE_REF^{commit}" >/dev/null || { echo "error: compare ref $COMPARE_REF does not resolve in $WT" >&2; exit 1; } echo "diff base: $BASE" -if git -C "$WT" diff --quiet "$BASE...$COMPARE_REF" --; then +if git -C "$WT" diff --no-ext-diff --quiet "$BASE...$COMPARE_REF" --; then echo "no changes vs $BASE" exit 0 fi @@ -154,5 +158,7 @@ fi git -C "$WT" diff --stat "$BASE...$COMPARE_REF" -- if ! "$STAT_ONLY"; then echo - git -C "$WT" diff "$BASE...$COMPARE_REF" -- + # --no-ext-diff: review reads the real patch, never an operator's configured + # third-party diff renderer (diff.external), same as fm-teardown.sh's patch-id. + git -C "$WT" diff --no-ext-diff "$BASE...$COMPARE_REF" -- fi diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 546a95641ea..42e7539989a 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -110,6 +110,8 @@ SUB_HOME_MARKER=".fm-secondmate-home" . "$SCRIPT_DIR/fm-gate-refuse-lib.sh" # shellcheck source=bin/fm-pr-lib.sh . "$SCRIPT_DIR/fm-pr-lib.sh" +# shellcheck source=bin/fm-remote-lib.sh +. "$SCRIPT_DIR/fm-remote-lib.sh" if [ "$#" -lt 1 ] || ! fm_task_id_path_safe "$1"; then echo "error: invalid teardown request" >&2 exit 2 @@ -320,12 +322,14 @@ pr_number_from_target() { printf '%s' "$n" } +# Fetch a PR head from the remote the PR actually lives on: the publish remote +# (bin/fm-remote-lib.sh), not the upstream a fork-backed clone was forked from. ensure_commit_object() { - local target=$1 commit=$2 n + local target=$1 commit=$2 n remote git -C "$WT" cat-file -e "$commit^{commit}" 2>/dev/null && return 0 n=$(pr_number_from_target "$target") || return 1 - git -C "$WT" remote get-url origin >/dev/null 2>&1 || return 1 - git -C "$WT" fetch --quiet origin "refs/pull/$n/head" >/dev/null 2>&1 || return 1 + remote=$(fm_publish_remote "$WT") || return 1 + git -C "$WT" fetch --quiet "$remote" "refs/pull/$n/head" >/dev/null 2>&1 || return 1 git -C "$WT" cat-file -e "$commit^{commit}" 2>/dev/null } @@ -489,13 +493,12 @@ nonempty_content_landed_in_ref() { # net-delta proof shows the content is already in the default branch. False only for # genuinely unlanded work or an inconclusive proof. work_is_landed() { - local branch=$1 name ref + local branch=$1 name ref remote pr_is_merged "$branch" && return 0 name=$(default_branch) || return 1 - if git -C "$WT" remote get-url origin >/dev/null 2>&1; then - git -C "$WT" fetch --quiet origin \ - "+refs/heads/$name:refs/remotes/origin/$name" >/dev/null 2>&1 || return 1 - ref="refs/remotes/origin/$name" + if remote=$(fm_publish_remote "$WT"); then + git -C "$WT" fetch --quiet "$remote" "+refs/heads/$name:refs/remotes/$remote/$name" >/dev/null 2>&1 || return 1 + ref="refs/remotes/$remote/$name" elif git -C "$WT" rev-parse --quiet --verify "refs/heads/$name" >/dev/null 2>&1; then ref="refs/heads/$name" else diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 5fdfdc26e02..5b391093719 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -26,7 +26,8 @@ # Options: # --json write a deterministic timing artifact after the run # --list print selected script paths (one per line) and exit 0 -# --base with --changed, compare against this ref (default: origin/main) +# --base with --changed, compare against this ref (default: the publish +# remote's main; see bin/fm-remote-lib.sh) # --exclude-family # drop scripts whose primary family matches after selection # (repeatable; portable CI lanes exclude real-herdr-gated so the @@ -66,6 +67,8 @@ set -eu ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" || exit 1 +# shellcheck source=bin/fm-remote-lib.sh +. "$ROOT/bin/fm-remote-lib.sh" MODE= LIST_ONLY=0 @@ -75,7 +78,7 @@ CHECK_COVERAGE=0 AGGREGATE_OUT= FAMILY= LANE= -BASE_REF=origin/main +BASE_REF="$(fm_publish_remote "$ROOT" || echo origin)/main" JSON_PATH= SCRIPTS=() EXCLUDE_FAMILIES=() @@ -661,6 +664,14 @@ families_for_changed_path() { bin/fm-config-inherit-lib.sh|bin/fm-config-push.sh|bin/fm-shared*) printf '%s\n' secondmate ;; + bin/fm-remote-lib.sh) + # Publish-remote resolution feeds self-update/clone refresh, the landed-work + # and review-base checks, and the changed-test base ref. + printf '%s\n' session-bootstrap + printf '%s\n' pr-forge + printf '%s\n' pure-contract-unit + printf '%s\n' backend-dispatch + ;; bin/fm-session-start.sh|bin/fm-bootstrap.sh|bin/fm-fleet-sync.sh|\ bin/fm-sessionstart-nudge.sh|bin/fm-tangle*|bin/fm-update.sh|\ bin/fm-gate-refuse*|bin/fm-lock*) diff --git a/bin/fm-update.sh b/bin/fm-update.sh index c3b0e674f77..7a02821e065 100755 --- a/bin/fm-update.sh +++ b/bin/fm-update.sh @@ -1,8 +1,10 @@ #!/usr/bin/env bash -# Self-update a running firstmate and its secondmates to the latest origin. +# Self-update a running firstmate and its secondmates to the latest published +# default branch. # # Mechanical half of the /updatefirstmate skill. Fast-forwards the running -# firstmate repo's default branch from origin, then fast-forwards every +# firstmate repo's default branch from its PUBLISH remote, as resolved by +# bin/fm-remote-lib.sh, then fast-forwards every # registered secondmate home (each a treehouse worktree of this same repo, or # a standalone clone) the same way. FAST-FORWARD ONLY, exactly like # fm-fleet-sync.sh: never force, never create a merge commit, never stash; @@ -15,7 +17,7 @@ # default branch, so a fast-forward there advances HEAD only and never touches # any other worktree's checkout or the shared `main` branch. # -# The fast-forward mechanics live in bin/fm-ff-lib.sh (base_mode "origin" here); +# The fast-forward mechanics live in bin/fm-ff-lib.sh (base_mode "publish" here); # the same library drives the local-HEAD secondmate sync used by fm-spawn.sh and # fm-bootstrap.sh, so there is one ff implementation, not several. # @@ -50,7 +52,7 @@ fi # --- main firstmate repo --------------------------------------------------- reread_firstmate="no" -ff_target "$FM_ROOT" "firstmate" origin no no +ff_target "$FM_ROOT" "firstmate" publish no no if [ "$FF_STATUS" = "updated" ] && [ -n "$FF_INSTR" ]; then reread_firstmate="yes" fi @@ -65,7 +67,7 @@ FF_SEEN_HOMES="" # Live direct reports first: state/.meta with kind=secondmate carries the # authoritative home= path. -sweep_live_secondmate_metas "$STATE" origin no +sweep_live_secondmate_metas "$STATE" publish no # Registry backstop: a secondmate registered in data/secondmates.md but without # a live meta (e.g. between restarts) is still its persistent on-disk home. @@ -77,7 +79,7 @@ if [ -f "$SECONDMATES_MD" ]; then esac id=$(printf '%s\n' "$line" | sed -n 's/^- \([^ ][^ ]*\) - .*/\1/p') home=$(printf '%s\n' "$line" | sed -n 's/.*(home:[[:space:]]*\([^;]*\);.*/\1/p' | sed 's/[[:space:]]*$//') - process_secondmate "$id" "$home" "" origin no + process_secondmate "$id" "$home" "" publish no done < "$SECONDMATES_MD" fi diff --git a/docs/architecture.md b/docs/architecture.md index 90f5560d50b..5e0e2439522 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -246,17 +246,18 @@ Generalizable firstmate knowledge goes to shared tracked docs through the normal The locked session-start bootstrap step, PR-based teardown, and merged-PR wake handling refresh remote-backed project clones when the clone is safe to move. Wake-time refreshes can target a single clone by project name, so the primary home also catches up when a secondmate reports a merge from its own home. -Clean default-branch clones fast-forward to `origin/`, and a clean detached HEAD that holds no unique commits is re-attached to the default branch before the same fast-forward path runs. +Clean default-branch clones fast-forward to the publish remote's `` branch, and a clean detached HEAD that holds no unique commits is re-attached to the default branch before the same fast-forward path runs. +[`bin/fm-remote-lib.sh`](../bin/fm-remote-lib.sh) owns publish-remote resolution, so a fork-backed clone follows the remote its own work lands on rather than the upstream it was forked from. Dirty clones, non-default branches, detached HEADs with unique commits, diverged defaults, and default branches checked out in another worktree are reported as `STUCK:` with their behind count and left untouched. Fetches blocked by an orphaned `.git/packed-refs.lock` use bounded retries and remove the lock only when the shared staleness proof can prove it abandoned; [configuration.md](configuration.md#toolchain) owns the recovery details and tuning knobs. -Local-only projects, clones without an origin remote, and fetch failures remain benign skips. +Local-only projects, clones with no remote at all, and fetch failures remain benign skips. The refresh also prunes local branches whose remote is gone and that no worktree still needs. ## Self-updates stay safe -`/updatefirstmate` fast-forwards the running firstmate repo and registered secondmate homes from `origin`, then re-reads updated instructions and nudges updated secondmates without touching project clones. +`/updatefirstmate` fast-forwards the running firstmate repo and registered secondmate homes from each checkout's resolved publish remote, then re-reads updated instructions and nudges updated secondmates without touching project clones. The update is fast-forward only: dirty, diverged, offline, and off-default targets are reported and left untouched. -The origin-based updater and the local secondmate sync share the same guarded fast-forward helper; only the origin mode fetches. +The publish-remote updater and the local secondmate sync share the same guarded fast-forward helper; only the publish mode fetches. The mechanics are owned by the `/updatefirstmate` skill and firstmate's operating manual in [`AGENTS.md`](../AGENTS.md) (self-update). ## Restart-proof diff --git a/docs/configuration.md b/docs/configuration.md index 4e2c63dc8fd..0bdc1d8ef3b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -159,7 +159,7 @@ Each seed writes an `.fm-secondmate-home` identity marker at the home root. The tracked root `.gitignore` ignores that marker, so validation can read it without making a freshly seeded home appear dirty to porcelain-based safety checks. This does not relax protection for any other untracked file. An existing linked-worktree home that predates this rule advances through its marker-only state during its next bootstrap or spawn local sync, after which Git ignores the marker normally. -A standalone-clone home cannot receive a primary-local commit through that no-fetch sync, so it receives the rule through `/updatefirstmate`'s origin refresh instead. +A standalone-clone home cannot receive a primary-local commit through that no-fetch sync, so it receives the rule through `/updatefirstmate`'s publish-remote refresh instead. ## FM_HOME @@ -281,7 +281,7 @@ Bootstrap also reports a `TANGLE:` line when `FM_ROOT` is on a named non-default In a read-only session that did not get the fleet lock, the same line is advisory and omits the checkout command. The locked session-start bootstrap step also runs a best-effort project clone refresh through `fm-fleet-sync.sh`. It emits `FLEET_SYNC:` for skipped refreshes that may matter, recovered self-heals, and `STUCK:` alarms. -Normal completed runs keep local-only and no-origin skips silent. +Normal completed runs keep local-only and no-remote skips silent. If bootstrap kills a timed-out refresh, it replays any completed `fm-fleet-sync.sh` output before the aggregate timeout skip so no finished result is lost. A killed refresh (or a teardown process kill) can leave an orphaned `.git/packed-refs.lock` in a clone, which makes the next refresh's fetch fail with Git's `Unable to create '...packed-refs.lock': File exists`. On that signature only, `fm-fleet-sync.sh` retries the fetch with a bounded wait for the lock to self-clear, then removes the lock and retries once more only when it can prove the lock stale, exactly like the `fm-teardown.sh` `index.lock` recovery. @@ -443,7 +443,7 @@ FM_STALE_ESCALATE_SECS=240 # idle seconds before a provably-working stal FM_PAUSE_RESURFACE_SECS=3600 # seconds before an idle declared external wait re-surfaces for a recheck in the watcher or away-mode daemon FM_WEDGE_DEMAND_INSPECT_COUNT=3 # consecutive provably-working stale escalations on the same unchanged pane before demand-deep-inspection is added FM_WATCH_TRIAGE_LOG_MAX_BYTES=262144 # size cap for the watcher's absorbed-wake debug log -FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT= # optional seconds allowed for bootstrap's best-effort clone refresh; unset/blank defaults to max(20, 5 + 3 * origin-backed-project-count) +FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT= # optional seconds allowed for bootstrap's best-effort clone refresh; unset/blank defaults to max(20, 5 + 3 * remote-backed-project-count) FM_FLEET_PRUNE=1 # set to 0 to skip pruning local branches whose upstream is gone FM_STALE_WORKTREE_LOCK_AGE_SECS=30 # min mtime age before fm-teardown.sh treats a leftover worktree git index.lock as provably stale FM_TREEHOUSE_RETURN_LOCK_RETRIES=3 # retries after a treehouse return fails on the transient git index.lock signature diff --git a/docs/scripts.md b/docs/scripts.md index c50490eeeae..fd91d9d06f0 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -16,7 +16,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-fleet-snapshot.sh` | Print the read-only structured fleet snapshot JSON (schema `fm-fleet-snapshot.v1`) | | `fm-fleet-view.sh` | Render the fleet snapshot as a human Markdown view | | `fm-bearings-snapshot.sh` | Project the fleet snapshot to the compact TOON bearings view; local-only unless `--include-prs` | -| `fm-update.sh` | Fast-forward-only self-update of firstmate and secondmate homes from origin | +| `fm-update.sh` | Fast-forward-only self-update of firstmate and secondmate homes from the publish remote | | `fm-backlog-handoff.sh` | Validate and delegate queued backlog-item moves into a secondmate home | | `fm-decision-hold.sh` | Create, verify, complete, and resolve durable captain-held decisions | | `fm-brief.sh` | Scaffold ship, scout, secondmate-charter, and Herdr-lab briefs | @@ -67,7 +67,8 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-crew-state.sh` | Print one deterministic current-state line for a crew | | `fm-tangle-lib.sh` | Shared default-branch resolution and primary-checkout tangle classification | | `fm-supervision-lib.sh` | Shared in-flight-work-without-fresh-watcher-beacon predicate | -| `fm-ff-lib.sh` | Shared guarded fast-forward helper for origin pulls and local secondmate syncs | +| `fm-ff-lib.sh` | Shared guarded fast-forward helper for publish-remote pulls and local secondmate syncs | +| `fm-remote-lib.sh` | Single owner of publish-remote resolution for syncs, landed-work checks, and review bases | | `fm-lock-lib.sh` | Shared "is this git lock provably abandoned?" proof used by teardown and fleet-sync | | `fm-config-inherit-lib.sh` | Shared primary-to-secondmate inherited local-material propagation and config-reread delivery | | `fm-tasks-axi-lib.sh` | Shared backlog-backend selector and `tasks-axi` compatibility probe | diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 922b227cec8..509872ab811 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -141,7 +141,7 @@ resolve_permissive_tmux_kill_ref() { # 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" +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-remote-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" OLD_BIN_REFACTORED="fm-send.sh fm-peek.sh fm-watch.sh fm-spawn.sh fm-teardown.sh fm-marker-lib.sh" diff --git a/tests/fm-fleet-sync.test.sh b/tests/fm-fleet-sync.test.sh index b1fcd0a38e2..365f0398369 100755 --- a/tests/fm-fleet-sync.test.sh +++ b/tests/fm-fleet-sync.test.sh @@ -340,6 +340,37 @@ test_already_current_unchanged() { pass "already-current clone is reported unchanged" } +# A clone whose `origin` is an upstream it was forked from and whose `fork` remote +# is the publish target must refresh from fork/. Following origin would +# report the clone as diverged forever, or pull the upstream's own history. +test_fork_backed_clone_syncs_from_publish_remote() { + local home clone work fork_remote out + home=$(new_home) + clone=$(build_pair "$home" forked) + work="$home/work-forked" + fork_remote="$home/remotes/forked-fork.git" + + git clone --quiet --bare "$clone" "$fork_remote" + git -C "$clone" remote add fork "file://$(cd "$fork_remote" && pwd)" + git -C "$work" remote add fork "file://$(cd "$fork_remote" && pwd)" + # The fleet's own commit lands on the publish remote only; the upstream diverges + # with an unrelated commit of its own. + commit_file "$work" ours.txt v1 OURS1 + git -C "$work" push -q fork main + git -C "$work" reset -q --hard HEAD~1 + commit_file "$work" upstream.txt v1 UPSTREAM1 + git -C "$work" push -q origin main + + out=$(run_sync "$home" "$clone") + + assert_contains "$out" "forked: synced" "fork-backed clone fast-forwards from its publish remote" + assert_not_contains "$out" "STUCK" "fork-backed clone is not reported as drifted" + [ "$(head_sha "$clone")" = "$(git -C "$clone" rev-parse fork/main)" ] \ + || fail "clone was not fast-forwarded to fork/main" + [ ! -e "$clone/upstream.txt" ] || fail "clone followed the upstream instead of the publish remote" + pass "fork-backed clone syncs from fork, not the diverged upstream" +} + test_no_origin_skipped() { local home clone out home=$(new_home) @@ -611,6 +642,7 @@ test_non_default_branch_is_stuck_untouched test_diverged_is_stuck_untouched test_on_default_clean_behind_fast_forwards test_already_current_unchanged +test_fork_backed_clone_syncs_from_publish_remote test_no_origin_skipped test_local_only_skipped test_single_project_by_bare_name_resolves diff --git a/tests/fm-gotmp.test.sh b/tests/fm-gotmp.test.sh index 03f0afafa7c..96c12324db1 100755 --- a/tests/fm-gotmp.test.sh +++ b/tests/fm-gotmp.test.sh @@ -65,6 +65,9 @@ make_fake_root() { ln -s "$ROOT/bin/fm-gate-refuse-lib.sh" "$fake/bin/fm-gate-refuse-lib.sh" # fm-pr-lib.sh: teardown uses its canonical task-ID validator for poll cleanup. ln -s "$ROOT/bin/fm-pr-lib.sh" "$fake/bin/fm-pr-lib.sh" + # fm-remote-lib.sh: teardown sources it to resolve the publish remote for its + # landed-work check. + ln -s "$ROOT/bin/fm-remote-lib.sh" "$fake/bin/fm-remote-lib.sh" # fm-guard.sh: stub (teardown calls it with `|| true`). cat > "$fake/bin/fm-guard.sh" <<'SH' #!/usr/bin/env bash @@ -165,6 +168,9 @@ test_teardown_skips_gracefully_without_tasktmp() { ln -s "$ROOT/bin/fm-gate-refuse-lib.sh" "$fake/bin/fm-gate-refuse-lib.sh" # fm-pr-lib.sh: teardown uses its canonical task-ID validator for poll cleanup. ln -s "$ROOT/bin/fm-pr-lib.sh" "$fake/bin/fm-pr-lib.sh" + # fm-remote-lib.sh: teardown sources it to resolve the publish remote for its + # landed-work check. + ln -s "$ROOT/bin/fm-remote-lib.sh" "$fake/bin/fm-remote-lib.sh" cat > "$fake/bin/fm-guard.sh" <<'SH' #!/usr/bin/env bash exit 0 diff --git a/tests/fm-secondmate-sync.test.sh b/tests/fm-secondmate-sync.test.sh index 79781e28add..5abbb918733 100755 --- a/tests/fm-secondmate-sync.test.sh +++ b/tests/fm-secondmate-sync.test.sh @@ -792,7 +792,7 @@ test_seed_marker_clean_when_gitignored() { # is still untracked-and-unignored, and the fix itself only arrives by fast-forward. # The marker-tolerant ff-skip (ignore_seed_marker=yes) bridges the gap for # linked-worktree homes, which bootstrap/spawn fast-forward from the primary's local HEAD. -# Standalone-clone homes converge through /updatefirstmate's origin fetch instead. +# Standalone-clone homes converge through /updatefirstmate's publish-remote fetch instead. # Once advanced, the now-ignored marker reads clean with no hand intervention. test_seed_marker_converges_existing_home() { local w c0 base diff --git a/tests/fm-test-run.test.sh b/tests/fm-test-run.test.sh index 7c7dbc5d1b3..1ed47ec3202 100755 --- a/tests/fm-test-run.test.sh +++ b/tests/fm-test-run.test.sh @@ -96,6 +96,8 @@ init_changed_fixture_repo() { mkdir -p "$repo/bin" "$repo/tests" cp "$RUNNER" "$repo/bin/fm-test-run.sh" chmod +x "$repo/bin/fm-test-run.sh" + # The runner sources this sibling lib to resolve its changed-file base ref. + cp "$ROOT/bin/fm-remote-lib.sh" "$repo/bin/fm-remote-lib.sh" for script in \ fm-brief.test.sh \ fm-captain-translation-contract.test.sh \ @@ -528,6 +530,8 @@ test_jobs_parallel_scheduler_and_failure_propagation() { d=tests/fm-supervision-instructions.test.sh mkdir -p "$repo/bin" "$repo/tests" "$evidence" "$fake_bin" cp "$RUNNER" "$runner" + # The runner sources this sibling lib on startup. + cp "$ROOT/bin/fm-remote-lib.sh" "$repo/bin/fm-remote-lib.sh" cat >"$fake_bin/stat" <<'SH' #!/usr/bin/env bash if [ "$1" = "-c" ] && [ "$2" = "%a" ]; then diff --git a/tests/fm-update.test.sh b/tests/fm-update.test.sh index 14628e3039d..bda17ea4ed9 100755 --- a/tests/fm-update.test.sh +++ b/tests/fm-update.test.sh @@ -3,9 +3,11 @@ # firstmate repo and every registered secondmate home. # # The guarantees under test mirror fm-fleet-sync.sh and prime directive #3: -# - The running firstmate repo (on its default branch) fast-forwards from -# origin; a leased secondmate home (detached HEAD on the default branch) -# fast-forwards the same way. +# - The running firstmate repo (on its default branch) fast-forwards from its +# PUBLISH remote (bin/fm-remote-lib.sh: `fork` when that remote exists, else +# `origin`); a leased secondmate home (detached HEAD on the default branch) +# fast-forwards the same way. A fork-backed checkout whose `origin` upstream +# has diverged still receives the fleet's own landed updates. # - FAST-FORWARD ONLY: a dirty, diverged, offline, or wrong-branch target is # skipped and reported, never forced or stashed, so unlanded work survives. # - The update is a single-parent fast-forward (never a merge commit) and a @@ -92,6 +94,46 @@ run_update() { FM_ROOT_OVERRIDE="$w/main" FM_HOME="$w/home" "$UPDATE" 2>/dev/null } +# Reshape a world into the fork-backed shape this fleet actually runs: a `fork` +# remote that is the publish target, and an `origin` upstream that has DIVERGED +# from the fleet's history. Local main gains one commit the upstream never took, +# and the upstream gains one commit of its own, so a self-update that still +# followed origin can only report "diverged from origin/main" (or, worse, follow +# the upstream's history). Call before add_sm so a secondmate leases the reshaped +# main. Args: world. +add_publish_fork() { + local w=$1 + git init -q --bare "$w/fork.git" + git -C "$w/fork.git" symbolic-ref HEAD refs/heads/main + git -C "$w/main" remote add fork "$w/fork.git" + git -C "$w/seed" remote add fork "$w/fork.git" + + # One commit that lands on the publish remote and in the local checkout only. + printf 'ours\n' >> "$w/seed/README.md" + git -C "$w/seed" add -A + git -C "$w/seed" commit -qm ours-1 + git -C "$w/seed" push -q fork main + git -C "$w/main" fetch -q fork + git -C "$w/main" merge -q --ff-only fork/main + + # The upstream gains an unrelated commit, so origin/main now diverges and its + # UPSTREAM.md is a witness: it must never appear in a checkout this fleet syncs. + git clone -q "$w/origin.git" "$w/upseed" + printf 'upstream only\n' > "$w/upseed/UPSTREAM.md" + git -C "$w/upseed" add -A + git -C "$w/upseed" commit -qm upstream-1 + git -C "$w/upseed" push -q origin main +} + +# Advance the publish remote by one instruction-surface commit. Args: world. +bump_fork() { + local w=$1 + printf 'v-fork\n' > "$w/seed/AGENTS.md" + git -C "$w/seed" add -A + git -C "$w/seed" commit -qm bump-fork + git -C "$w/seed" push -q fork main +} + # --- T1: main + secondmate behind, instruction change; FF, not a merge ------ # Combines the former T1 (fast-forward + reread + nudge signalling) and T2 # (the advance is a single-parent fast-forward, never a merge commit) into one @@ -291,6 +333,74 @@ test_unsafe_secondmate_home_skipped_before_git_update() { pass "T11 unsafe secondmate home is not fast-forwarded" } +# --- T12: fork present resolves the publish remote, not the upstream --------- +# The pure-fork shape: `fork` is the publish target and `origin` is a diverged +# upstream. Both the firstmate repo and its leased secondmate must follow fork/main. +test_fork_present_resolves_publish_remote() { + local w out + w=$(new_world t12) + add_publish_fork "$w" + add_sm "$w" sm1 + bump_fork "$w" + + out=$(run_update "$w") + + assert_contains "$out" "firstmate: updated " "firstmate fast-forwarded from the publish remote" + assert_contains "$out" "secondmate sm1: updated " "secondmate fast-forwarded from the publish remote" + assert_contains "$out" "reread-firstmate: yes" "instruction change on the publish remote triggers reread" + assert_contains "$out" "nudge-secondmates: fm-sm1" "advanced secondmate is nudged" + assert_not_contains "$out" "diverged from origin/main" "diverged upstream no longer blocks the update" + + [ "$(git -C "$w/main" rev-parse HEAD)" = "$(git -C "$w/main" rev-parse fork/main)" ] \ + || fail "firstmate HEAD not at fork/main" + [ "$(git -C "$w/sm1" rev-parse HEAD)" = "$(git -C "$w/main" rev-parse fork/main)" ] \ + || fail "secondmate HEAD not at fork/main" + # The upstream's own commit must never be pulled into a fleet checkout. + [ ! -e "$w/main/UPSTREAM.md" ] || fail "firstmate followed the upstream instead of the publish remote" + [ ! -e "$w/sm1/UPSTREAM.md" ] || fail "secondmate followed the upstream instead of the publish remote" + pass "T12 fork-backed checkout fast-forwards from fork, ignoring the diverged upstream" +} + +# --- T13: no fork remote falls back to origin ------------------------------- +test_fork_absent_falls_back_to_origin() { + local w out + w=$(new_world t13) + bump_origin "$w" instr + + git -C "$w/main" remote get-url fork >/dev/null 2>&1 \ + && fail "fixture unexpectedly has a fork remote" + + out=$(run_update "$w") + + assert_contains "$out" "firstmate: updated " "single-remote checkout still updates from origin" + [ "$(git -C "$w/main" rev-parse HEAD)" = "$(git -C "$w/main" rev-parse origin/main)" ] \ + || fail "firstmate HEAD not at origin/main when no fork remote exists" + pass "T13 checkout with no fork remote falls back to origin unchanged" +} + +# --- T14: diverged from the publish remote is still refused ----------------- +test_diverged_from_publish_remote_skipped() { + local w out before + w=$(new_world t14) + add_publish_fork "$w" + # Unlanded local commit on the default branch, plus a new publish-remote commit: + # local main and fork/main now diverge, so the update must refuse and preserve it. + printf 'unlanded local work\n' > "$w/main/AGENTS.md" + git -C "$w/main" add -A + git -C "$w/main" commit -qm local-work + before=$(git -C "$w/main" rev-parse HEAD) + bump_fork "$w" + + out=$(run_update "$w") + + assert_contains "$out" "firstmate: skipped: diverged from fork/main" "diverged publish remote refuses" + assert_contains "$out" "reread-firstmate: no" "no reread when the update was refused" + [ "$(git -C "$w/main" rev-parse HEAD)" = "$before" ] \ + || fail "diverged firstmate HEAD moved (unlanded work at risk)" + grep -q 'unlanded local work' "$w/main/AGENTS.md" || fail "unlanded local commit was discarded" + pass "T14 divergence from the publish remote is still refused, never forced" +} + test_updates_main_and_secondmate test_reread_gate_is_instruction_only test_dirty_secondmate_skipped @@ -300,5 +410,8 @@ test_registry_backstop_dedup_and_self_exclusion test_firstmate_wrong_branch_skipped test_firstmate_detached_head_skipped test_unsafe_secondmate_home_skipped_before_git_update +test_fork_present_resolves_publish_remote +test_fork_absent_falls_back_to_origin +test_diverged_from_publish_remote_skipped echo "# all fm-update tests passed" From 27ea42306f76943252e522ade674c5ba1f5234b0 Mon Sep 17 00:00:00 2001 From: quinnbot-ai Date: Thu, 30 Jul 2026 10:08:40 -0700 Subject: [PATCH 16/70] feat(bin): wire the ops-inbox into firstmate's wake path via a registered standing check (#67) * feat(watch): wake firstmate on an unreviewed operational alert backlog A machine that records critical alerts into a durable inbox instead of a chat transport silently disarms every deadman and tripwire feeding it once nobody reads that inbox: each check still fires and still records its alert while no one is told. Wire that inbox into the existing wake path as the reserved standing check state/ops-watch.check.sh, registered through the ordinary custom-check trust binding, so an unreviewed backlog arrives as a normal check: wake carrying a compact triage digest - total, oldest age, and the three busiest classes. - bin/fm-ops-inbox-lib.sh resolves the watch settings, streams the spool and acknowledgement log through a bounded read, and owns the dedupe record. - bin/fm-ops-inbox-poll.sh wakes on a count or age threshold, on a stale, missing, or unreadable review receipt (a dead receipt generator is the failure that hides every other one), on a spool past the read cap, and on a malformed configuration; a standing backlog then stays quiet until it grows materially, gains a new class, changes receipt state, or the re-remind interval passes. - fm-bootstrap.sh arms and disarms the watch at a locked session boundary, only where there is an inbox to watch, never in a secondmate home by default, and never over a live task holding the reserved id. - fm-supervision-lib.sh treats an armed standing poll as a supervision need, since a standing poll only reaches firstmate through a live watcher. Firstmate only reads the inbox: it never writes, rotates, or acknowledges alerts, and alerts route to the first mate rather than straight to the captain. docs/ops-inbox-wake.md owns the contract and reconciles the second, unrelated path that shares the ops-inbox name. * no-mistakes(review): Harden ops inbox fail-closed wake handling * no-mistakes(review): Close ops inbox fail-open paths * no-mistakes(review): Close ops inbox fail-open paths * no-mistakes(review): Canonicalize ops inbox numbers and classify missing jq * no-mistakes(document): Align ops-inbox documentation with wake behavior * no-mistakes(document): Suppress intentional fixture-template SC2016 notes * fix(watch): satisfy new base invariants for the ops-inbox watch The rebase onto the advanced publish base brought two repository invariants that postdate this branch's validation. - tests/fm-lint.test.sh pins an exact allowlist of suites permitted to keep production source context. This suite sources bin/fm-supervision-lib.sh to read FM_SUP_STANDING_DESC and call fm_supervision_needed, which is the callback/variable interop the allowlist exists to track, so record it there rather than hiding the dependency behind a source=/dev/null directive. - Tracked files may not contain user-specific absolute paths. The configuration example now uses an OS-neutral absolute placeholder while still showing that state_dir must be an absolute path. No behavior change: the watch, its poll, its thresholds, and its fail-closed dispositions are untouched. --------- Co-authored-by: QuinnBot --- .agents/skills/bootstrap-diagnostics/SKILL.md | 7 +- .gitignore | 2 + AGENTS.md | 8 +- bin/fm-bootstrap.sh | 167 ++- bin/fm-claude-stop-autoarm.sh | 8 +- bin/fm-ops-inbox-lib.sh | 469 ++++++++ bin/fm-ops-inbox-poll.sh | 204 ++++ bin/fm-session-start.sh | 10 +- bin/fm-supervision-lib.sh | 28 +- bin/fm-test-run.sh | 9 + bin/fm-turnend-guard.sh | 4 +- docs/configuration.md | 10 +- docs/documentation-audiences.json | 4 + docs/ops-inbox-wake.md | 187 +++ docs/scripts.md | 2 + docs/subagent-guard.md | 4 +- docs/turnend-guard.md | 4 +- tests/fm-lint.test.sh | 2 +- tests/fm-ops-inbox-watch.test.sh | 1026 +++++++++++++++++ tests/fm-session-start.test.sh | 2 +- tests/lib.sh | 8 + 21 files changed, 2118 insertions(+), 47 deletions(-) create mode 100644 bin/fm-ops-inbox-lib.sh create mode 100755 bin/fm-ops-inbox-poll.sh create mode 100644 docs/ops-inbox-wake.md create mode 100755 tests/fm-ops-inbox-watch.test.sh diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index 2b708799415..2cee5e51a5a 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -2,7 +2,7 @@ name: bootstrap-diagnostics description: >- Agent-only handling playbook for session-start bootstrap diagnostics. - Use 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 - or when a standalone bin/fm-bootstrap.sh run prints one of those lines. + Use 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, FMX, or OPS_INBOX - or when a standalone bin/fm-bootstrap.sh run prints one of those lines. A silent bootstrap section, or a BOOTSTRAP_INFO fact, means no skill load. user-invocable: false metadata: @@ -50,3 +50,8 @@ When any diagnostic needs captain attention, report the plain consequence and re Inspect the reason, keep the pending marker under `state/.secondmate-nudge-pending/` intact, and rerun session start after the endpoint or metadata issue is fixed so bootstrap can retry the exact same marked send. - `FMX: X mode on ...` / `FMX: X mode off ...` - bootstrap confirmed or removed the local X-mode poll artifacts (`docs/configuration.md` "X mode (.env)"). Only when a running watcher needs the cadence transition applied immediately, restart the home-scoped watcher through the emitted harness supervision protocol; bootstrap deliberately never restarts the watcher itself. +- `OPS_INBOX: ` - bootstrap could not prove that the operational alert inbox watch reached its requested armed or disarmed state. + An arming, configuration, or dependency failure means this machine's critical alerts are not guaranteed to reach Firstmate, so treat it as an alerting outage rather than a cosmetic startup warning. + A disarming or foreign-artifact failure means bootstrap could not safely converge the reserved `ops-watch` state, so leave the named artifacts in place and follow the printed remediation. + Apply the printed remediation, then rerun session start and confirm the line is gone. + `docs/ops-inbox-wake.md` owns the watch contract and its configuration. diff --git a/.gitignore b/.gitignore index 6ce5f5eb344..fab660cd027 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ config/x-mode.env config/cmux-socket-password config/wedge-alarm config/herdr-presentation-spaces +config/ops-inbox.json +ops-inbox/ diff --git a/AGENTS.md b/AGENTS.md index 7b7602724dd..f163a906d62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,6 +74,7 @@ config/herdr-presentation-spaces optional presence flag for Herdr's default-off config/cmux-socket-password optional cmux control-socket password; LOCAL, gitignored; read fresh on every cmux CLI call and passed through without ever overriding an operator's own ambient CMUX_SOCKET_PASSWORD when absent (docs/cmux-backend.md "Setup") config/wedge-alarm optional away-mode wedge-alarm active-alert directives; LOCAL, gitignored; absent means auto (macOS Notification Center when available); see docs/wedge-alarm.md config/x-mode.env generated X-mode watcher cadence; LOCAL, gitignored; source before arming watcher when present +config/ops-inbox.json optional operational alert inbox watch settings; LOCAL, gitignored, and NOT inherited because one machine has one alert inbox and the primary owns watching it; absent means watch the default alert inbox only when it exists and this is not a secondmate home; see docs/ops-inbox-wake.md data/ personal fleet records; LOCAL, gitignored as a whole backlog.md task queue, dependencies, history captain.md this home's domain-local captain preferences and working style; LOCAL, gitignored, canonical even if harness memory mirrors it, and updated with inspect-then-update @@ -100,6 +101,7 @@ state/ volatile runtime signals; gitignored .pr-check-migration.log private per-task outcomes distinguishing rebuilt or canonically registered replacement polls, quarantined unarmed polls, and incomplete migrations .pr-check-migration-scan-v1 private marker proving the non-executing scan disabled every unsafe legacy check; .pr-check-migration-v1 separately records completed private repairs x-watch.check.sh generated X-mode relay poll shim; present only when opted in (section 14) + ops-watch.check.sh generated operational alert inbox watch, registered like any custom check; present when auto mode finds this home's alert inbox or config explicitly enables the watch, with its dedupe record in .ops-inbox-wake (docs/ops-inbox-wake.md) pending-replies/ parent-owned secondmate pending-reply records (correlation id, delivery vs reply, recovery, escalation); fm-pending-reply-lib.sh x-inbox/ generated X-mode pending mention payloads; fmx-respond drains it (section 14) x-context/ generated X-mode durable per-request reply context and one-wake offer markers, keyed by request_id; survives inbox cleanup and expires within seven days (section 14; bin/fm-x-lib.sh) @@ -137,7 +139,7 @@ A lock-refused session must not spawn, steer, merge, drain the wake queue, repai 1. **Lock** - acquires the per-home session lock first, before anything mutates shared state. 2. **Bootstrap** - detect-only checks (tool/version problems, GitHub auth, the worktree-tangle check, harness override, dispatch-profile validation, backlog-backend status) always run, but routine confirmations stay silent by default. When the lock could not be acquired, the worktree-tangle check uses read-only advisory wording without a checkout repair command. - Home-local stale Herdr projection cleanup and the five bootstrap MUTATING sweeps - non-executing legacy PR-check migration, fleet sync, the local secondmate fast-forward sweep, the secondmate liveness sweep, and X-mode artifact writes - run only when this session actually holds the lock from step 1. + Home-local stale Herdr projection cleanup and the six bootstrap MUTATING sweeps - non-executing legacy PR-check migration, fleet sync, the local secondmate fast-forward sweep, the secondmate liveness sweep, X-mode artifact writes, and operational alert watch arming - run only when this session actually holds the lock from step 1. The secondmate liveness sweep deterministically accounts for every registered secondmate: it relaunches only from the recovery-grade `dead` or `missing` states, preserves ambiguous or unreadable targets, and reports skipped or failed guarantees as `SECONDMATE_LIVENESS:` lines (`bin/fm-bootstrap.sh`; `bin/fm-backend.sh`'s `fm_backend_agent_state`). 3. **Wake queue** - when locked, drains the durable wake queue and prints the raw records prominently as this turn's first work queue; a bounded, clearly labeled historical status-event annotation may follow a valid `signal` record but never replaces it or current-state reconciliation, and a lapsed watcher chain still surfaces here via the same guard alarm. When the lock could not be acquired and verified, the queue is left untouched because no session mutation is authorized, and the guard's tangle/watcher-liveness alarms still print in read-only advisory mode without drain, supervision repair, or checkout repair commands. @@ -350,7 +352,7 @@ Handle actionable wakes as follows: 1. For `signal:`, read the listed event lines first, then reconcile current state only where action depends on it. 2. For `stale:`, inspect the recorded endpoint and load `stuck-crewmate-recovery` for a stopped, looping, confused, or unresponsive worker; a deep-inspection reason also requires current-state and validation-log inspection. -3. For `check:`, act on the named poll result, including merges and X-mode events. +3. For `check:`, act on the named poll result, including merges, X-mode events, and an `ops-inbox:` operational alert or monitoring-failure digest, which reports this machine's unreviewed critical alerts or alert-pipeline health for triage rather than any one task's progress. 4. For `heartbeat:`, review the whole fleet from the structured fleet view, reconcile suspicious tasks and PR state, update the backlog, and never report an unchanged fleet as progress. Every mutable wake is one complete closeout-and-refill transaction before the next wait or turn boundary. @@ -480,7 +482,7 @@ It performs guarded fast-forward updates of firstmate and registered secondmate 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. +- `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:`, `FMX:`, or `OPS_INBOX:`); 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. diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index ecc676c0284..9fc7115106f 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -16,7 +16,8 @@ # "NUDGE_SECONDMATES: secondmate : send failed: ", # "BOOTSTRAP_INFO: nudged fm- with ''", # "SECONDMATE_LIVENESS: secondmate : skipped: |respawn failed after : ", -# "FMX: X mode on ..." or "FMX: X mode off ...". +# "FMX: X mode on ..." or "FMX: X mode off ...", +# "OPS_INBOX: ". # When a RUNNING secondmate worktree is fast-forwarded to firstmate's # own current default-branch commit (a purely LOCAL fast-forward, never # a remote fetch) AND its loaded instruction surface (AGENTS.md, bin/, @@ -58,6 +59,17 @@ # 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. +# The operational alert inbox watch follows fm-ops-inbox-lib.sh's +# decision: auto mode arms only when this home has an alert inbox, while +# explicit enablement arms fail-closed even when the spool is absent. +# docs/ops-inbox-wake.md owns the contract. +# Arming registers the reserved standing check state/ops-watch.check.sh +# through the ordinary custom-check trust binding. +# Arming and disarming are silent unless FM_BOOTSTRAP_VERBOSE_FACTS=1 +# requests the BOOTSTRAP_INFO fact. +# Only a failure prints an actionable OPS_INBOX line. +# FM_OPS_INBOX_STATE_DIR overrides the default watched operations state +# directory for tests and specialized setups. # Fleet sync fetches, fast-forwards safe default-branch states, reports # recovered and STUCK clone drift, and prunes gone local branches; it is # bounded by FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT when it is a non-empty @@ -67,15 +79,15 @@ # refresh relays any completed fm-fleet-sync.sh output before the # aggregate timeout skip line with timeout and elapsed seconds. # Set FM_FLEET_PRUNE=0 to skip branch pruning during that refresh. -# Set FM_BOOTSTRAP_DETECT_ONLY=1 to skip the five MUTATING sweeps +# Set FM_BOOTSTRAP_DETECT_ONLY=1 to skip the six MUTATING sweeps # (PR-check migration, secondmate_sync, secondmate_liveness_sweep, -# x_mode_setup, fleet_sync) while still printing every read-only detect line +# x_mode_setup, ops_inbox_setup, fleet_sync) while still printing every read-only detect line # above; the TANGLE line switches to advisory-only wording with no # checkout command. Used by # fm-session-start.sh's read-only path when another live session holds # the fleet lock, so a second concurrent session never race-mutates -# PR-check artifacts, secondmate homes, X-mode artifacts, project -# clones, or repair instructions. +# PR-check artifacts, secondmate homes, X-mode artifacts, the +# operational alert watch, project clones, or repair instructions. # Unset/0 (the default) runs every sweep exactly as before - this flag # is purely additive. # fm-bootstrap.sh install ... @@ -99,9 +111,19 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" . "$SCRIPT_DIR/fm-config-inherit-lib.sh" # shellcheck source=bin/fm-x-lib.sh disable=SC1091 . "$SCRIPT_DIR/fm-x-lib.sh" +# shellcheck source=bin/fm-pr-lib.sh disable=SC1091 +. "$SCRIPT_DIR/fm-pr-lib.sh" +# shellcheck source=bin/fm-check-lib.sh disable=SC1091 +. "$SCRIPT_DIR/fm-check-lib.sh" +# shellcheck source=bin/fm-ops-inbox-lib.sh disable=SC1091 +. "$SCRIPT_DIR/fm-ops-inbox-lib.sh" # shellcheck source=bin/fm-backend.sh disable=SC1091 . "$SCRIPT_DIR/fm-backend.sh" +# Reserved check id for the standing operational alert inbox watch. It is a +# watch, never a work item, so it never appears in the backlog or fleet view. +OPS_INBOX_CHECK_ID=ops-watch + # Count the clones fleet-sync will actually fetch: those with a publish remote # (bin/fm-remote-lib.sh, sourced via fm-ff-lib.sh). A fork-backed clone counts the # same as an origin-backed one, so the refresh timeout below scales with real work. @@ -552,7 +574,7 @@ no_mistakes_compatible() { [ "$patch" -ge "$NO_MISTAKES_MIN_PATCH" ] } -x_mode_write_if_changed() { +bootstrap_artifact_write_if_changed() { local dest=$1 content=$2 mode=$3 parent tmp parent_device current_mode parent=${dest%/*} [ "$parent" != "$dest" ] || return 1 @@ -573,7 +595,7 @@ x_mode_write_if_changed() { return 0 fi fi - tmp=$(umask 077; mktemp "$parent/.fm-x-mode.XXXXXX" 2>/dev/null) || return 1 + tmp=$(umask 077; mktemp "$parent/.fm-bootstrap-artifact.XXXXXX" 2>/dev/null) || return 1 if ! printf '%s\n' "$content" > "$tmp" \ || ! chmod "$mode" "$tmp" \ || ! fmx_single_link_file_mode_valid "$tmp" "$mode" "$parent_device"; then @@ -596,16 +618,25 @@ x_mode_write_if_changed() { fi } -x_mode_artifact_present() { +bootstrap_artifact_present() { [ -e "$1" ] || [ -L "$1" ] } -x_mode_remove_artifact() { +# The absolute home path to bake into a generated artifact. A relative FM_HOME +# must never reach a durable shim, and CDPATH must never redirect the resolution. +bootstrap_home_abs() { + case "$FM_HOME" in + /*) printf '%s\n' "$FM_HOME" ;; + *) CDPATH='' cd -- "$FM_HOME" 2>/dev/null && pwd -P ;; + esac +} + +bootstrap_artifact_remove() { local artifact=$1 parent=${1%/*} - x_mode_artifact_present "$artifact" || return 0 + bootstrap_artifact_present "$artifact" || return 0 [ -d "$parent" ] && [ ! -L "$parent" ] || return 1 rm -f -- "$artifact" 2>/dev/null || return 1 - ! x_mode_artifact_present "$artifact" + ! bootstrap_artifact_present "$artifact" } # X mode (opt-in): when this home's .env carries a non-empty FMX_PAIRING_TOKEN, @@ -633,8 +664,8 @@ x_mode_setup() { x_mode_remove_artifacts() { local failed=0 - x_mode_remove_artifact "$shim" || failed=1 - x_mode_remove_artifact "$cadence" || failed=1 + bootstrap_artifact_remove "$shim" || failed=1 + bootstrap_artifact_remove "$cadence" || failed=1 [ "$failed" -eq 0 ] } @@ -648,7 +679,7 @@ x_mode_setup() { if [ -z "$token" ]; then # Opt-out (or never opted in): drop any X artifacts; stay silent unless we # actually removed something. - if x_mode_artifact_present "$shim" || x_mode_artifact_present "$cadence"; then + if bootstrap_artifact_present "$shim" || bootstrap_artifact_present "$cadence"; then if x_mode_remove_artifacts; then echo "FMX: X mode off - removed relay poll shim and 30s cadence; default cadence applies on the next supervision cycle; $(x_mode_supervision_repair)" else @@ -666,7 +697,7 @@ x_mode_setup() { fi done if [ "$missing" -ne 0 ]; then - if x_mode_artifact_present "$shim" || x_mode_artifact_present "$cadence"; then + if bootstrap_artifact_present "$shim" || bootstrap_artifact_present "$cadence"; then if x_mode_remove_artifacts; then echo "FMX: X mode off - missing relay poll dependencies; install them and rerun bootstrap" else @@ -686,9 +717,10 @@ x_mode_setup() { mkdir -p "$STATE" "$CONFIG" 2>/dev/null || { fmx_arm_failed; return 0; } - shim_body=$(fmx_poll_shim_content "$FM_HOME" "$FM_ROOT") - x_mode_write_if_changed "$shim" "$shim_body" 700 || { fmx_arm_failed; return 0; } - fmx_poll_shim_valid "$shim" "$FM_HOME" "$FM_ROOT" \ + shim_home=$(bootstrap_home_abs) || { fmx_arm_failed; return 0; } + shim_body=$(fmx_poll_shim_content "$shim_home" "$FM_ROOT") + bootstrap_artifact_write_if_changed "$shim" "$shim_body" 700 || { fmx_arm_failed; return 0; } + fmx_poll_shim_valid "$shim" "$shim_home" "$FM_ROOT" \ || { fmx_arm_failed; return 0; } cadence_body=$(cat <<'EOF' @@ -699,11 +731,107 @@ x_mode_setup() { export FM_CHECK_INTERVAL=30 EOF ) - x_mode_write_if_changed "$cadence" "$cadence_body" 600 || { fmx_arm_failed; return 0; } + bootstrap_artifact_write_if_changed "$cadence" "$cadence_body" 600 || { fmx_arm_failed; return 0; } echo "FMX: X mode on - relay poll armed via state/x-watch.check.sh; 30s watcher cadence in config/x-mode.env" } +# Operational alert inbox watch: keep the standing check that turns an unreviewed +# critical alert backlog into an ordinary watcher wake. It is armed exactly when +# fm_ops_inbox_watch_expected says so - by default only when the configured alert +# spool actually exists - so a home with no operations runtime stays inert and +# writes nothing. Arming registers state/ops-watch.check.sh through the normal +# custom-check trust binding, which is what authorizes the watcher to run it. +# Steady state, either armed or inert, is silent; only a real transition prints a +# BOOTSTRAP_INFO fact, and only a failure prints an actionable OPS_INBOX line. +# The check id is reserved, so a task that somehow owns it wins and the watch +# refuses to arm rather than colliding with that task's artifacts. +ops_inbox_setup() { + local check trust sidecar body home_abs + check="$STATE/$OPS_INBOX_CHECK_ID.check.sh" + trust="$STATE/$OPS_INBOX_CHECK_ID.check-trust" + sidecar="$STATE/.ops-inbox-wake" + + ops_inbox_disarm() { # + local reason=$1 failed=0 disarm_home disarm_body + if ! bootstrap_artifact_present "$check"; then + bootstrap_artifact_present "$trust" || return 0 + echo "OPS_INBOX: state/$OPS_INBOX_CHECK_ID.check-trust has no owned operational alert check; inspect and remove or rename it, then rerun bootstrap" + return 0 + fi + disarm_home=$(bootstrap_home_abs) || { + echo "OPS_INBOX: the operational alert watch cannot prove ownership of state/$OPS_INBOX_CHECK_ID.check.sh because the firstmate home path is unresolved; leave it in place and rerun bootstrap after repairing the home" + return 0 + } + disarm_body=$(fm_ops_inbox_shim_content "$disarm_home" "$FM_ROOT") + if [ ! -f "$check" ] || [ -L "$check" ] \ + || ! cmp -s "$check" <(printf '%s\n' "$disarm_body"); then + echo "OPS_INBOX: state/$OPS_INBOX_CHECK_ID.check.sh is not this operational alert watch; leave it in place and retire or rename its owner, then rerun bootstrap" + return 0 + fi + bootstrap_artifact_remove "$check" || failed=1 + bootstrap_artifact_remove "$trust" || failed=1 + bootstrap_artifact_remove "$sidecar" || failed=1 + if [ "$failed" -eq 0 ]; then + [ "${FM_BOOTSTRAP_VERBOSE_FACTS:-0}" = 1 ] \ + && echo "BOOTSTRAP_INFO: operational alert watch disarmed - $reason" + return 0 + else + echo "OPS_INBOX: operational alert watch could not be disarmed; remove state/$OPS_INBOX_CHECK_ID.check.sh and its trust record, then rerun bootstrap" + fi + } + + if bootstrap_artifact_present "$STATE/$OPS_INBOX_CHECK_ID.meta"; then + echo "OPS_INBOX: task id $OPS_INBOX_CHECK_ID is in use by live work, so the operational alert watch cannot arm; retire that task, then rerun bootstrap" + return 0 + fi + + if ! fm_ops_inbox_config_load "$CONFIG"; then + echo "OPS_INBOX: $FM_OPS_INBOX_CONFIG_ERROR; fix config/ops-inbox.json, then rerun bootstrap" + return 0 + fi + + if ! fm_ops_inbox_watch_expected "$FM_HOME"; then + ops_inbox_disarm "no alert inbox at $FM_OPS_INBOX_SPOOL" + return 0 + fi + + if ! command -v jq >/dev/null 2>&1; then + echo "MISSING: jq (install: $(install_cmd jq))" + echo "OPS_INBOX: the operational alert watch cannot read the inbox until jq is installed; install jq, then rerun bootstrap" + ops_inbox_disarm "jq is required to read the alert inbox" + return 0 + fi + + mkdir -p "$STATE" 2>/dev/null || { + echo "OPS_INBOX: state directory is unavailable, so the operational alert watch cannot arm" + return 0 + } + + home_abs=$(bootstrap_home_abs) || { + echo "OPS_INBOX: the firstmate home path could not be resolved, so the operational alert watch cannot arm" + return 0 + } + body=$(fm_ops_inbox_shim_content "$home_abs" "$FM_ROOT") + if [ -f "$check" ] && [ ! -L "$check" ] \ + && cmp -s "$check" <(printf '%s\n' "$body") \ + && fm_custom_check_registered "$STATE" "$OPS_INBOX_CHECK_ID"; then + return 0 + fi + if ! bootstrap_artifact_write_if_changed "$check" "$body" 700 \ + || ! FM_HOME="$FM_HOME" FM_STATE_OVERRIDE="$STATE" \ + "$SCRIPT_DIR/fm-check-register.sh" "$OPS_INBOX_CHECK_ID" >/dev/null 2>&1 \ + || ! fm_custom_check_registered "$STATE" "$OPS_INBOX_CHECK_ID"; then + bootstrap_artifact_remove "$check" || true + bootstrap_artifact_remove "$trust" || true + echo "OPS_INBOX: operational alert watch could not be armed; inspect state/$OPS_INBOX_CHECK_ID.check.sh, then rerun bootstrap" + return 0 + fi + [ "${FM_BOOTSTRAP_VERBOSE_FACTS:-0}" = 1 ] \ + && echo "BOOTSTRAP_INFO: operational alert watch armed for $FM_OPS_INBOX_SPOOL" + return 0 +} + crew_dispatch_validate() { local file err file="$CONFIG/crew-dispatch.json" @@ -875,6 +1003,7 @@ if [ "${FM_BOOTSTRAP_DETECT_ONLY:-0}" != 1 ]; then secondmate_liveness_sweep secondmate_sync x_mode_setup + ops_inbox_setup fleet_sync fi exit 0 diff --git a/bin/fm-claude-stop-autoarm.sh b/bin/fm-claude-stop-autoarm.sh index df9ee1128fc..2dd14bfbcea 100755 --- a/bin/fm-claude-stop-autoarm.sh +++ b/bin/fm-claude-stop-autoarm.sh @@ -18,8 +18,10 @@ # - AFK: while state/.afk exists the away daemon owns the watcher and triage; # this hook exits 0 and NEVER rewakes the primary (checked again at # translation time so a mid-cycle AFK transition is honored). -# - Need: arms only while work is in flight (state/*.meta) or X mode has a -# relay poll to run (state/x-watch.check.sh); an idle home exits 0. +# - Need: arms only while work is in flight (state/*.meta) or a standing poll +# needs a watcher - an X-mode relay poll (state/x-watch.check.sh) or the +# operational alert inbox watch (state/ops-watch.check.sh); an idle home +# with no standing poll exits 0. # - Single-flight: Claude does not dedupe async hooks, so a home-scoped owner # lock (state/.claude-autoarm.lock) admits exactly one owner; every other # concurrent firing exits 0 without translating, which keeps one event @@ -89,7 +91,7 @@ fi # --- AFK: the away daemon owns the watcher and triage; never rewake ---------- [ -e "$STATE/.afk" ] && exit 0 -# --- need: in-flight work or an X-mode relay poll ---------------------------- +# --- need: in-flight work or an armed standing poll -------------------------- need_supervision() { fm_supervision_needed "$STATE" "$GRACE" } diff --git a/bin/fm-ops-inbox-lib.sh b/bin/fm-ops-inbox-lib.sh new file mode 100644 index 00000000000..b3e63d61632 --- /dev/null +++ b/bin/fm-ops-inbox-lib.sh @@ -0,0 +1,469 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# Shared resolution for the operational alert inbox watch. +# Sourced by bin/fm-ops-inbox-poll.sh (the trusted standing poll) and +# fm-bootstrap.sh (the arming sweep). This file is sourced, never executed. +# +# The watched inbox is an append-only JSONL alert spool written by an external +# operations runtime, plus a separate acknowledgement log and a periodic receipt +# that runtime generates. Firstmate only ever READS those three paths; it never +# writes, rotates, or acknowledges them. +# +# It defines: +# fm_ops_inbox_shim_content - the generated standing check body +# fm_ops_inbox_config_load - resolve FM_OPS_INBOX_* settings; +# returns 1 and sets +# FM_OPS_INBOX_CONFIG_ERROR on a +# malformed config +# fm_ops_inbox_watch_expected [] - 0 when that home should keep the +# standing check armed +# fm_ops_inbox_scan [] +# - unacked-critical count, oldest +# timestamp, per-class counts, and +# whether the read hit its cap +# fm_ops_inbox_receipt_count - the receipt's own unacked count +# fm_ops_inbox_epoch_of_iso - epoch seconds for a spool timestamp +# fm_ops_inbox_age_label - compact "13d"/"9h"/"7m" age text +# fm_ops_inbox_sidecar_read - last recorded wake, for dedupe +# fm_ops_inbox_sidecar_write +# +# The check id is the reserved `ops-watch`, so the armed artifacts are +# state/ops-watch.check.sh and its state/ops-watch.check-trust binding. +# docs/ops-inbox-wake.md#exit-dispositions owns the wake contract and exit table; +# this header owns the mechanics. + +FM_OPS_INBOX_SIDECAR_VERSION=fm-ops-inbox-wake-v1 +FM_OPS_INBOX_NUMBER_MAX=999999999999 + +fm_ops_inbox_canonical_number() { # + local value=$1 + case "$value" in + ''|*[!0-9]*) return 1 ;; + esac + while [ "${value#0}" != "$value" ]; do + value=${value#0} + done + [ -n "$value" ] || value=0 + [ "${#value}" -le 12 ] || return 1 + printf '%s' "$value" +} + +# Accepted residuals: the overflow probe and parsed tail are separate append-only spool snapshots; blank complete records do not count toward the cap; a sidecar truncated after its state line reads with an empty class list; and control-character rejection covers only code points below 32, not DEL or C1. +# Each requires pathological configuration or precisely timed concurrency, the worst case is one suppressed or delayed poll, and the 24-hour re-remind bounds all four. + +fm_ops_inbox_set_number() { # + local key=$1 var=$2 value=$3 + if ! value=$(fm_ops_inbox_canonical_number "$value"); then + FM_OPS_INBOX_CONFIG_ERROR="config/ops-inbox.json $key must be a non-negative integer no greater than $FM_OPS_INBOX_NUMBER_MAX" + return 1 + fi + printf -v "$var" '%s' "$value" +} + +fm_ops_inbox_shim_content() { + local home=$1 root=$2 + printf '%s\n' \ + '#!/usr/bin/env bash' \ + '# Auto-generated by fm-bootstrap.sh - operational alert inbox poll shim.' \ + '# Registered by bin/fm-check-register.sh; the watcher runs the bound bytes' \ + '# and turns any output into one check: wake.' \ + "export FM_HOME=$(printf '%q' "$home")" \ + "exec $(printf '%q' "$root/bin/fm-ops-inbox-poll.sh")" +} + +# Resolve the watch settings from optional /ops-inbox.json. +# Absent config means auto: watch the default operations state directory when its +# alert spool actually exists, so a home with no such runtime stays inert. +# That default directory is $HOME/.openclaw/state, overridable for tests and +# specialized setups by FM_OPS_INBOX_STATE_DIR; an explicit config value wins over +# both so a configured home never depends on ambient environment. +# A malformed config is preserved as an error rather than silently defaulted. +# shellcheck disable=SC2034 # Result globals read by callers after this returns. +fm_ops_inbox_config_load() { + local config_dir=$1 file raw + FM_OPS_INBOX_CONFIG_ERROR= + FM_OPS_INBOX_ENABLED=auto + FM_OPS_INBOX_STATE_DIR="${FM_OPS_INBOX_STATE_DIR:-${HOME:-}/.openclaw/state}" + FM_OPS_INBOX_SPOOL= + FM_OPS_INBOX_ACKS= + FM_OPS_INBOX_RECEIPT= + FM_OPS_INBOX_AGE_HOURS=6 + FM_OPS_INBOX_COUNT=25 + FM_OPS_INBOX_REMIND_HOURS=24 + FM_OPS_INBOX_GROWTH=10 + FM_OPS_INBOX_RECEIPT_STALE_HOURS=3 + FM_OPS_INBOX_MAX_LINES=20000 + + file="$config_dir/ops-inbox.json" + if [ -e "$file" ] || [ -L "$file" ]; then + if [ ! -f "$file" ] || [ -L "$file" ]; then + FM_OPS_INBOX_CONFIG_ERROR="config/ops-inbox.json is not an ordinary file" + return 1 + fi + if ! command -v jq >/dev/null 2>&1; then + FM_OPS_INBOX_CONFIG_ERROR="jq is required to read config/ops-inbox.json" + return 1 + fi + if ! jq -e 'type == "object"' "$file" >/dev/null 2>&1; then + FM_OPS_INBOX_CONFIG_ERROR="config/ops-inbox.json is not a JSON object" + return 1 + fi + if ! jq -e ' + [ + "enabled", "state_dir", "spool", "acks", "receipt", "age_hours", + "count", "remind_hours", "growth", "receipt_stale_hours", "max_lines" + ] as $allowed + | all(keys_unsorted[]; . as $key | ($allowed | index($key)) != null) + ' "$file" >/dev/null 2>&1; then + FM_OPS_INBOX_CONFIG_ERROR="config/ops-inbox.json has an unrecognized setting" + # Configuration errors fail closed through the poll's config-error wake. + return 1 + fi + if ! jq -e ' + . as $config + | ( + all(["state_dir", "spool", "acks", "receipt"][]; + . as $key + | $config[$key] == null + or ( + ($config[$key] | type) == "string" + and ($config[$key] | length) > 0 + ) + ) + and all($config | to_entries[]; + .value as $value + | ($value | type) != "string" + or ($value | explode | all(. >= 32)) + ) + ) + ' "$file" >/dev/null 2>&1; then + FM_OPS_INBOX_CONFIG_ERROR="config/ops-inbox.json has an invalid path or control character" + return 1 + fi + # Recognized null settings stay absent so their initialized defaults survive. + raw=$(jq -r 'to_entries[] | select(.value != null) | .key + "=" + (.value | tostring)' \ + "$file" 2>/dev/null) || { + FM_OPS_INBOX_CONFIG_ERROR="config/ops-inbox.json could not be read" + return 1 + } + # One key=value line per non-null recognized setting. + local line key value + while IFS= read -r line; do + [ -n "$line" ] || continue + key=${line%%=*} + value=${line#*=} + case "$key" in + enabled) + case "$value" in + true|on|1) FM_OPS_INBOX_ENABLED=true ;; + false|off|0) FM_OPS_INBOX_ENABLED=false ;; + auto) FM_OPS_INBOX_ENABLED=auto ;; + *) + FM_OPS_INBOX_CONFIG_ERROR="config/ops-inbox.json enabled must be true, false, or auto" + return 1 + ;; + esac + ;; + state_dir) FM_OPS_INBOX_STATE_DIR=$value ;; + spool) FM_OPS_INBOX_SPOOL=$value ;; + acks) FM_OPS_INBOX_ACKS=$value ;; + receipt) FM_OPS_INBOX_RECEIPT=$value ;; + age_hours) fm_ops_inbox_set_number age_hours FM_OPS_INBOX_AGE_HOURS "$value" || return 1 ;; + count) fm_ops_inbox_set_number count FM_OPS_INBOX_COUNT "$value" || return 1 ;; + remind_hours) fm_ops_inbox_set_number remind_hours FM_OPS_INBOX_REMIND_HOURS "$value" || return 1 ;; + growth) fm_ops_inbox_set_number growth FM_OPS_INBOX_GROWTH "$value" || return 1 ;; + receipt_stale_hours) + fm_ops_inbox_set_number receipt_stale_hours FM_OPS_INBOX_RECEIPT_STALE_HOURS "$value" || return 1 + ;; + max_lines) + fm_ops_inbox_set_number max_lines FM_OPS_INBOX_MAX_LINES "$value" || return 1 + if [ "$FM_OPS_INBOX_MAX_LINES" -eq 0 ]; then + FM_OPS_INBOX_CONFIG_ERROR="config/ops-inbox.json max_lines must be greater than zero" + return 1 + fi + ;; + *) + FM_OPS_INBOX_CONFIG_ERROR="config/ops-inbox.json has an unrecognized setting: $key" + return 1 + ;; + esac + done < should keep the standing check armed. `true` arms +# unconditionally, `false` never arms, and `auto` arms only when the configured +# alert spool exists AND this is not a secondmate home. One machine has one alert +# inbox, so auto-arming every secondmate home would raise the same backlog once +# per home; the primary owns the machine's alert watch, and a secondmate that +# genuinely should watch it says so with an explicit `enabled: true`. +fm_ops_inbox_watch_expected() { + local home=${1-} + case "$FM_OPS_INBOX_ENABLED" in + true) return 0 ;; + false) return 1 ;; + *) + [ -z "$home" ] || [ ! -f "$home/.fm-secondmate-home" ] || return 1 + [ -f "$FM_OPS_INBOX_SPOOL" ] + ;; + esac +} + +# Count unacked critical alerts and summarize them by class. +# Sets FM_OPS_INBOX_SCAN_COUNT, FM_OPS_INBOX_SCAN_OLDEST (an ISO-8601 Z string, or +# empty), FM_OPS_INBOX_SCAN_TOP (up to three " " lines, busiest +# first), FM_OPS_INBOX_SCAN_CLASSES (every class name, sorted, space-joined), and +# FM_OPS_INBOX_SCAN_TRUNCATED when the spool is longer than . +# Unacked means exactly what the spool's own reader means: a critical event with +# an inline false ack flag and no entry in the acknowledgement log. +# Samples at most spool lines to detect overflow, then parses only +# the most recent of each input so this stays well inside the watcher's +# per-check timeout no matter how large the spool grows. Hitting that cap is +# reported rather than hidden: a capped read understates the total and the oldest +# age, and the spool needs rotating or triaging. +# A valid non-terminated final record is included, while a malformed final fragment +# is treated as a momentary append in progress. +# Every malformed complete input line fails the scan so the caller wakes fail-closed. +# shellcheck disable=SC2034 # Result globals read by callers after this returns. +fm_ops_inbox_scan() { + local spool=$1 acks=$2 max_lines=${3:-20000} scan classes sample + local ack_stream alert_stream unacked_stream validated_stream + FM_OPS_INBOX_SCAN_COUNT=0 + FM_OPS_INBOX_SCAN_OLDEST= + FM_OPS_INBOX_SCAN_TOP= + FM_OPS_INBOX_SCAN_CLASSES= + FM_OPS_INBOX_SCAN_TRUNCATED=0 + if [ ! -f "$spool" ]; then + # An active scan fails closed; auto mode exits silently before calling it. + return 1 + fi + command -v jq >/dev/null 2>&1 || return 1 + case "$max_lines" in + ''|*[!0-9]*|0) max_lines=20000 ;; + esac + sample=$( + set -o pipefail + head -n "$((max_lines + 1))" "$spool" 2>/dev/null \ + | jq -Rrs ' + (endswith("\n")) as $terminated + | (split("\n") | map(select(test("\\S")))) as $records + | if ( + ($terminated | not) + and ($records | length) > 0 + and (try ($records[-1] | fromjson | false) catch true) + ) + then ($records | length) - 1 + else ($records | length) + end + ' 2>/dev/null + ) || return 1 + case "$sample" in + ''|*[!0-9]*) return 1 ;; + esac + [ "$sample" -le "$max_lines" ] || FM_OPS_INBOX_SCAN_TRUNCATED=1 + ack_stream= + if [ -f "$acks" ]; then + ack_stream=$( + set -o pipefail + tail -n "$max_lines" "$acks" 2>/dev/null \ + | jq -r 'select(has("event_id")) | ["A", (.event_id | tostring)] | @tsv' 2>/dev/null + ) || return 1 + fi + alert_stream=$( + set -o pipefail + # @tsv, not hand-built tabs: an alert field carrying a tab or newline would + # otherwise forge extra columns in this stream. + tail -n "$((max_lines + 1))" "$spool" 2>/dev/null \ + | jq -Rrs --argjson max "$max_lines" ' + def decoded: + try {valid: true, value: fromjson} catch {valid: false}; + (endswith("\n")) as $terminated + | (split("\n") | map(select(test("\\S")))) as $records + | ( + if (($terminated | not) and ($records | length) > 0) then + ($records[-1] | decoded) as $last + | if $last.valid then $records else $records[:-1] end + else + $records + end + ) + | if length > $max then .[-$max:] else . end + | .[] + | fromjson + | select(.severity == "critical" and .ack == false) + | ["E", ((.id // "") | tostring), ((.ts // "") | tostring), ((.source // "") | tostring)] + | @tsv + ' 2>/dev/null + ) || return 1 + unacked_stream=$(awk -F'\t' ' + $1 == "A" { acked[$2] = 1; next } + $1 == "E" { + if ($2 != "" && ($2 in acked)) next + print + } + ' < 0) + | split("\t") as $fields + | $fields[2] as $ts + | if ( + ($fields | length) == 4 + and ($ts | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$")) + and (try ($ts | fromdateiso8601 | strftime("%Y-%m-%dT%H:%M:%SZ") == $ts) catch false) + ) + then . + else error("invalid critical alert timestamp") + end + ' 2>/dev/null + ) || return 1 + scan=$(awk -F'\t' ' + $1 == "E" { + count++ + ts = $3 + # Fixed-format validation above makes this lexicographic minimum sound. + if (ts != "" && (oldest == "" || ts < oldest)) oldest = ts + src = $4 + gsub(/[^A-Za-z0-9._:@\/-]/, "_", src) + if (src == "") src = "unknown" + if (length(src) > 48) src = substr(src, 1, 48) + n[src]++ + } + END { + printf "%d\t%s\n", count + 0, oldest + for (c in n) printf "%d\t%s\n", n[c], c + } + ' </dev/null 2>&1 || return 1 + value=$(jq -r '.unacked_critical_count // empty' "$receipt" 2>/dev/null) || return 1 + value=$(fm_ops_inbox_canonical_number "$value") || return 1 + printf '%s' "$value" +} + +# Epoch seconds for one fixed-format spool timestamp. Rejects anything else +# rather than guessing, because a bad parse would fake an alert age. +fm_ops_inbox_epoch_of_iso() { + local iso=$1 epoch normalized + case "$iso" in + [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z) ;; + *) return 1 ;; + esac + if [ "$(uname)" = Darwin ]; then + epoch=$(date -u -j -f '%Y-%m-%dT%H:%M:%SZ' "$iso" +%s 2>/dev/null) || return 1 + normalized=$(date -u -r "$epoch" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) || return 1 + else + epoch=$(date -u -d "$iso" +%s 2>/dev/null) || return 1 + normalized=$(date -u -d "@$epoch" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) || return 1 + fi + [ "$normalized" = "$iso" ] || return 1 + printf '%s' "$epoch" +} + +fm_ops_inbox_age_label() { + local s=${1:-0} + case "$s" in + ''|*[!0-9]*) s=0 ;; + esac + if [ "$s" -ge 86400 ]; then + printf '%dd' $((s / 86400)) + elif [ "$s" -ge 3600 ]; then + printf '%dh' $((s / 3600)) + elif [ "$s" -ge 60 ]; then + printf '%dm' $((s / 60)) + else + printf '%ds' "$s" + fi +} + +fm_ops_inbox_stat_mtime() { + if [ "$(uname)" = Darwin ]; then + stat -f %m "$1" 2>/dev/null + else + stat -c %Y "$1" 2>/dev/null + fi +} + +# Read the last recorded wake into FM_OPS_INBOX_LAST_EPOCH, _LAST_COUNT, +# _LAST_STATE, and _LAST_CLASSES. A missing, malformed, or version-mismatched +# sidecar reads as "no prior wake", which makes the next qualifying condition +# wake rather than stay silent. +# shellcheck disable=SC2034 # Result globals read by callers after this returns. +fm_ops_inbox_sidecar_read() { + local path=$1 version epoch count state classes + FM_OPS_INBOX_LAST_EPOCH= + FM_OPS_INBOX_LAST_COUNT= + FM_OPS_INBOX_LAST_STATE= + FM_OPS_INBOX_LAST_CLASSES= + [ -f "$path" ] && [ ! -L "$path" ] || return 1 + exec 8< "$path" || return 1 + IFS= read -r version <&8 || { exec 8<&-; return 1; } + IFS= read -r epoch <&8 || { exec 8<&-; return 1; } + IFS= read -r count <&8 || { exec 8<&-; return 1; } + IFS= read -r state <&8 || { exec 8<&-; return 1; } + IFS= read -r classes <&8 || classes= + exec 8<&- + [ "$version" = "$FM_OPS_INBOX_SIDECAR_VERSION" ] || return 1 + epoch=$(fm_ops_inbox_canonical_number "$epoch") || return 1 + count=$(fm_ops_inbox_canonical_number "$count") || return 1 + [ -n "$state" ] || return 1 + FM_OPS_INBOX_LAST_EPOCH=$epoch + FM_OPS_INBOX_LAST_COUNT=$count + FM_OPS_INBOX_LAST_STATE=$state + FM_OPS_INBOX_LAST_CLASSES=$classes +} + +# Record this wake atomically and privately. A failed write is reported to the +# caller instead of being ignored, because a sidecar that never advances would +# repeat the same wake on every poll. +fm_ops_inbox_sidecar_write() { + local path=$1 epoch=$2 count=$3 state=$4 classes=$5 dir tmp + dir=${path%/*} + [ -d "$dir" ] && [ ! -L "$dir" ] || return 1 + if [ -e "$path" ] || [ -L "$path" ]; then + [ -f "$path" ] && [ ! -L "$path" ] || return 1 + fi + tmp=$(umask 077; mktemp "$dir/.fm-ops-inbox-wake.XXXXXX") || return 1 + { + printf '%s\n' "$FM_OPS_INBOX_SIDECAR_VERSION" "$epoch" "$count" "$state" "$classes" + } > "$tmp" || { rm -f -- "$tmp"; return 1; } + chmod 0600 "$tmp" || { rm -f -- "$tmp"; return 1; } + mv -f -- "$tmp" "$path" || { rm -f -- "$tmp"; return 1; } +} diff --git a/bin/fm-ops-inbox-poll.sh b/bin/fm-ops-inbox-poll.sh new file mode 100755 index 00000000000..b2ab445a4e8 --- /dev/null +++ b/bin/fm-ops-inbox-poll.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +# One bounded read of the machine's operational alert inbox. +# Usage: fm-ops-inbox-poll.sh +# +# The watcher runs this through the registered standing check +# state/ops-watch.check.sh, so it obeys the same contract as any custom check: it +# prints ONE line when firstmate should wake, prints nothing otherwise, and always +# finishes well inside FM_CHECK_TIMEOUT. It only reads the alert spool; it never +# writes, rotates, or acknowledges alerts, and it never contacts a network. +# +# Wake conditions (docs/ops-inbox-wake.md owns the contract and the defaults): +# - more unacked critical alerts than the configured count threshold; +# - any unacked critical alert older than the configured age threshold; +# - the spool is longer than the bounded read cap, so it can no longer be read +# whole inside one check and needs rotating or triaging; +# - the periodic receipt is stale, missing, or unreadable, because a dead +# receipt generator is itself the alerting failure; +# - the watch configuration is malformed, so thresholds cannot be trusted. +# +# The wake line is a triage digest: total, oldest age, and the three busiest +# alert classes. +# +# Dedupe: a standing backlog must not re-wake every poll. state/.ops-inbox-wake +# records the last wake's count, receipt state, and class set; a further wake +# needs a materially larger count, a class not seen at the last wake, a changed +# receipt state, or the configured re-remind interval to have passed. A backlog +# below every wake threshold removes that record, so the next qualifying one +# wakes immediately. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +SIDECAR="$STATE/.ops-inbox-wake" + +# shellcheck source=bin/fm-ops-inbox-lib.sh +. "$SCRIPT_DIR/fm-ops-inbox-lib.sh" + +# Emit one deduplicated wake line, then record it. A recording failure is +# reported on stderr and the line is still printed: an unnoticed alert backlog is +# the worse outcome, and the watcher's own cadence bounds the repetition. +wake_once() { # + local state=$1 count=$2 classes=$3 line=$4 class due + fm_ops_inbox_sidecar_read "$SIDECAR" || FM_OPS_INBOX_LAST_EPOCH= + if [ -n "$FM_OPS_INBOX_LAST_EPOCH" ]; then + due=0 + [ "$state" != "$FM_OPS_INBOX_LAST_STATE" ] && due=1 + [ "$count" -ge $((FM_OPS_INBOX_LAST_COUNT + FM_OPS_INBOX_GROWTH)) ] && due=1 + if [ "$FM_OPS_INBOX_REMIND_HOURS" -gt 0 ] \ + && [ $((NOW - FM_OPS_INBOX_LAST_EPOCH)) -ge $((FM_OPS_INBOX_REMIND_HOURS * 3600)) ]; then + due=1 + fi + if [ "$due" -eq 0 ]; then + for class in $classes; do + case " $FM_OPS_INBOX_LAST_CLASSES " in + *" $class "*) ;; + *) due=1; break ;; + esac + done + fi + [ "$due" -eq 1 ] || return 0 + fi + fm_ops_inbox_sidecar_write "$SIDECAR" "$NOW" "$count" "$state" "$classes" \ + || echo "fm-ops-inbox-poll: could not record the wake at $SIDECAR" >&2 + printf '%s\n' "$line" +} + +set_now() { + NOW=$(date +%s 2>/dev/null) || return 1 + case "$NOW" in + ''|*[!0-9]*) return 1 ;; + esac +} + +if ! fm_ops_inbox_config_load "$CONFIG"; then + if ! set_now; then + printf '%s\n' 'ops-inbox: alert watch could not read the current time' + exit 0 + fi + wake_once config-error 0 '' "ops-inbox: alert watch configuration is unusable - $FM_OPS_INBOX_CONFIG_ERROR" + exit 0 +fi + +# Disabled and auto-without-spool homes stay silent; explicit watches continue +# and fail closed below when their required spool cannot be read. +fm_ops_inbox_watch_expected "$FM_HOME" || exit 0 + +if ! set_now; then + printf '%s\n' 'ops-inbox: alert watch could not read the current time' + exit 0 +fi + +if ! command -v jq >/dev/null 2>&1; then + wake_once no-jq 0 '' 'ops-inbox: alert watch cannot read the inbox because jq is not installed' + exit 0 +fi + +# Receipt state first: the receipt is the external runtime's own periodic proof +# that alert review is happening, so its absence or staleness is wake-worthy on +# its own, and it also decides whether its count can be trusted below. +RECEIPT_STATE=fresh +RECEIPT_NOTE= +RECEIPT_COUNT= +if [ ! -f "$FM_OPS_INBOX_RECEIPT" ]; then + RECEIPT_STATE=missing + RECEIPT_NOTE='alert receipt missing' +else + receipt_mtime=$(fm_ops_inbox_stat_mtime "$FM_OPS_INBOX_RECEIPT") + case "$receipt_mtime" in + ''|*[!0-9]*) + RECEIPT_STATE=unreadable + RECEIPT_NOTE='alert receipt unreadable' + ;; + *) + receipt_age=$((NOW - receipt_mtime)) + [ "$receipt_age" -ge 0 ] || receipt_age=0 + if [ "$FM_OPS_INBOX_RECEIPT_STALE_HOURS" -gt 0 ] \ + && [ "$receipt_age" -ge $((FM_OPS_INBOX_RECEIPT_STALE_HOURS * 3600)) ]; then + RECEIPT_STATE=stale + RECEIPT_NOTE="alert receipt stale $(fm_ops_inbox_age_label "$receipt_age")" + fi + ;; + esac +fi + +if [ "$RECEIPT_STATE" = fresh ] || [ "$RECEIPT_STATE" = stale ]; then + if ! RECEIPT_COUNT=$(fm_ops_inbox_receipt_count "$FM_OPS_INBOX_RECEIPT"); then + RECEIPT_COUNT= + RECEIPT_STATE=unreadable + RECEIPT_NOTE='alert receipt unreadable' + fi +fi + +if ! fm_ops_inbox_scan "$FM_OPS_INBOX_SPOOL" "$FM_OPS_INBOX_ACKS" "$FM_OPS_INBOX_MAX_LINES"; then + wake_once scan-failed 0 '' "ops-inbox: alert inbox at $FM_OPS_INBOX_SPOOL could not be read" + exit 0 +fi + +COUNT=$FM_OPS_INBOX_SCAN_COUNT +if [ "$RECEIPT_STATE" = fresh ]; then + COUNT=$RECEIPT_COUNT +fi + +OLDEST_AGE= +if [ -n "$FM_OPS_INBOX_SCAN_OLDEST" ]; then + if oldest_epoch=$(fm_ops_inbox_epoch_of_iso "$FM_OPS_INBOX_SCAN_OLDEST"); then + OLDEST_AGE=$((NOW - oldest_epoch)) + [ "$OLDEST_AGE" -ge 0 ] || OLDEST_AGE=0 + else + wake_once scan-failed 0 '' "ops-inbox: alert inbox at $FM_OPS_INBOX_SPOOL could not be read" + exit 0 + fi +fi + +over_count=0 +[ "$COUNT" -gt "$FM_OPS_INBOX_COUNT" ] && over_count=1 +over_age=0 +if [ -n "$OLDEST_AGE" ] \ + && [ "$OLDEST_AGE" -ge $((FM_OPS_INBOX_AGE_HOURS * 3600)) ]; then + over_age=1 +fi + +if [ "$RECEIPT_STATE" = fresh ] && [ "$over_count" -eq 0 ] && [ "$over_age" -eq 0 ] \ + && [ "$FM_OPS_INBOX_SCAN_TRUNCATED" -eq 0 ]; then + # Nothing to report. Drop any prior wake record so a fresh backlog is not + # silently deduplicated against an already-resolved one. + if ! rm -f -- "$SIDECAR" 2>/dev/null; then + wake_once sidecar-clear-failed "$COUNT" "$FM_OPS_INBOX_SCAN_CLASSES" \ + "ops-inbox: wake dedupe state at $SIDECAR could not be cleared" + fi + exit 0 +fi + +top= +while IFS=$(printf '\t') read -r class_count class_name; do + [ -n "$class_name" ] || continue + if [ -z "$top" ]; then + top="$class_name $class_count" + else + top="$top, $class_name $class_count" + fi +done < after consent, /updatefirstmate, the afk daemon, existing # tests) still call them directly. The one seam this script needed - -# bootstrap running its detect-only diagnostics without its five mutating +# bootstrap running its detect-only diagnostics without its six mutating # sweeps - is an opt-in FM_BOOTSTRAP_DETECT_ONLY=1 flag on fm-bootstrap.sh # itself (default unset/0 = unchanged behavior), not a fork. # @@ -29,10 +29,10 @@ # mutating step runs. # 2. bootstrap - home-local stale Herdr projection cleanup runs only # when this session actually holds the lock. Detect-only -# diagnostics always run. Bootstrap's five MUTATING sweeps +# diagnostics always run. Bootstrap's six MUTATING sweeps # (legacy PR-check migration, secondmate fast-forward, -# secondmate liveness, X-mode artifact writes, fleet sync) -# also run only when locked. +# secondmate liveness, X-mode artifact writes, operational +# alert watch arming, fleet sync) also run only when locked. # 3. wake-drain - mutates the durable wake queue, so it also only runs # when locked. # 4. context digest - data/projects.md, data/secondmates.md, data/captain.md, @@ -65,7 +65,7 @@ # tasks-axi and quota-axi tool checks, and tasks-axi availability - none of # which mutate shared state and all of which are safe to compute without # verified lock ownership. -# Only projection cleanup, the five bootstrap mutating sweeps, and the +# Only projection cleanup, the six bootstrap mutating sweeps, and the # wake-queue drain are skipped. # The context and fleet-state digests # below are always read-only, so they run unconditionally in both modes. diff --git a/bin/fm-supervision-lib.sh b/bin/fm-supervision-lib.sh index 1930700d2af..74d143049e7 100644 --- a/bin/fm-supervision-lib.sh +++ b/bin/fm-supervision-lib.sh @@ -3,9 +3,12 @@ # Usage: . bin/fm-supervision-lib.sh # # Reports whether a firstmate home needs supervision because it has in-flight -# work (a state/.meta exists) or an X-mode relay poll -# (state/x-watch.check.sh), and whether its watcher has a fresh liveness beacon -# (state/.last-watcher-beat, touched every poll cycle, within the grace window). +# work (a state/.meta exists) or an armed standing poll - an X-mode relay +# poll (state/x-watch.check.sh) or the operational alert inbox watch +# (state/ops-watch.check.sh) - and whether its watcher has a fresh liveness +# beacon (state/.last-watcher-beat, touched every poll cycle, within the grace +# window). A standing poll only reaches firstmate through a live watcher, so an +# armed one is a supervision need even with an empty fleet. # bin/fm-guard.sh keeps its task-specific grace-based warning predicate; # bin/fm-turnend-guard.sh uses the status fields here for its banner but performs # its end-of-turn block decision with the live watcher lock check in @@ -23,7 +26,9 @@ fm_sup_stat_mtime() { # fm_supervision_status [grace-seconds] # Populates, for the state dir at $1: # FM_SUP_IN_FLIGHT count of state/*.meta (in-flight tasks) -# FM_SUP_NEEDED true/false - in-flight work or an X-mode relay poll +# FM_SUP_NEEDED true/false - in-flight work or an armed standing poll +# FM_SUP_STANDING_DESC plain-language name of the armed standing poll(s), for +# banners; empty when none is armed # FM_SUP_WATCHER_FRESH true/false - a watcher beacon within the grace window # FM_SUP_BEACON_DESC human-readable beacon age, for banners ("never" if absent) # FM_SUP_QUEUE_PENDING true/false - state/.wake-queue has unread records @@ -33,6 +38,7 @@ fm_supervision_status() { local state=$1 grace=${2:-${FM_GUARD_GRACE:-300}} meta beat m age FM_SUP_IN_FLIGHT=0 FM_SUP_NEEDED=false + FM_SUP_STANDING_DESC= FM_SUP_WATCHER_FRESH=false FM_SUP_BEACON_DESC=never FM_SUP_QUEUE_PENDING=false @@ -41,7 +47,15 @@ fm_supervision_status() { [ -e "$meta" ] || continue FM_SUP_IN_FLIGHT=$((FM_SUP_IN_FLIGHT + 1)) done - if [ "$FM_SUP_IN_FLIGHT" -gt 0 ] || [ -f "$state/x-watch.check.sh" ]; then + [ -f "$state/x-watch.check.sh" ] && FM_SUP_STANDING_DESC="X-mode relay polling" + if [ -f "$state/ops-watch.check.sh" ]; then + if [ -n "$FM_SUP_STANDING_DESC" ]; then + FM_SUP_STANDING_DESC="$FM_SUP_STANDING_DESC and operational alert monitoring" + else + FM_SUP_STANDING_DESC="operational alert monitoring" + fi + fi + if [ "$FM_SUP_IN_FLIGHT" -gt 0 ] || [ -n "$FM_SUP_STANDING_DESC" ]; then FM_SUP_NEEDED=true fi @@ -64,8 +78,8 @@ fm_supervision_status() { } # fm_supervision_needed [grace-seconds] -# Exit 0 (true) exactly when in-flight work or an X-mode relay poll needs a -# watcher. Exit 1 (false) for an idle home. +# Exit 0 (true) exactly when in-flight work or an armed standing poll needs a +# watcher. Exit 1 (false) for an idle home with no standing poll. fm_supervision_needed() { fm_supervision_status "$@" [ "$FM_SUP_NEEDED" = true ] diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 5b391093719..5726ece2214 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -137,6 +137,7 @@ family_for_basename() { ;; fm-daemon.test.sh|fm-guard-stale-banner.test.sh|fm-pi-watch-extension.test.sh|\ fm-supervision-events.test.sh|fm-turnend-guard.test.sh|fm-wake-daemon-lifecycle-e2e.test.sh|\ + fm-ops-inbox-watch.test.sh|\ fm-wake-queue.test.sh|fm-watch-checkpoint.test.sh|fm-watch-triage.test.sh|\ fm-watcher-lock.test.sh) printf '%s\n' watcher-wake-lock @@ -650,6 +651,14 @@ families_for_changed_path() { bin/fm-classify-lib.sh|bin/fm-daemon*|bin/fm-turnend-guard*|bin/fm-guard.sh) printf '%s\n' watcher-wake-lock ;; + bin/fm-ops-inbox-*) + printf '%s\n' watcher-wake-lock + printf '%s\n' session-bootstrap + ;; + bin/fm-supervision-lib.sh) + printf '%s\n' watcher-wake-lock + printf '%s\n' session-bootstrap + ;; bin/fm-afk*) printf '%s\n' afk printf '%s\n' real-herdr-gated diff --git a/bin/fm-turnend-guard.sh b/bin/fm-turnend-guard.sh index 2e96fb33e48..f8769fc5d41 100755 --- a/bin/fm-turnend-guard.sh +++ b/bin/fm-turnend-guard.sh @@ -165,7 +165,7 @@ block_stop() { if [ "$FM_SUP_IN_FLIGHT" -gt 0 ]; then printf '● %s task(s) in flight, but no live watcher holds this home lock (last beat: %s).\n' "$FM_SUP_IN_FLIGHT" "$FM_SUP_BEACON_DESC" else - printf '● X-mode relay polling needs supervision, but no live watcher holds this home lock (last beat: %s).\n' "$FM_SUP_BEACON_DESC" + printf '● %s needs supervision, but no live watcher holds this home lock (last beat: %s).\n' "$FM_SUP_STANDING_DESC" "$FM_SUP_BEACON_DESC" fi if [ "$CLAUDE_MODE" -eq 1 ]; then printf '● The Stop-owned auto-arm did not claim this home either, so recovery is NOT already under way.\n' @@ -230,7 +230,7 @@ if [ "$COUNT" -gt "$BLOCK_BUDGET" ]; then if [ "$FM_SUP_IN_FLIGHT" -gt 0 ]; then NEED_DESC="$FM_SUP_IN_FLIGHT task(s) in flight" else - NEED_DESC="X-mode relay polling active" + NEED_DESC="$FM_SUP_STANDING_DESC active" fi printf '{"systemMessage":"firstmate turn-end guard: %s with no live watcher and no Stop auto-arm claim; block budget exhausted, allowing this stop. Repair supervision (bin/fm-watch-arm.sh as a Claude Code background task) or investigate why bin/fm-claude-stop-autoarm.sh is not claiming this home."}\n' "$NEED_DESC" exit 0 diff --git a/docs/configuration.md b/docs/configuration.md index 0bdc1d8ef3b..f470dc183ad 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -11,7 +11,7 @@ The shared orchestrator behavior lives in [`AGENTS.md`](../AGENTS.md) - edit it This section is the single owner of the top-level operational-home layout; producer script headers and their help own exact child-file fields and mutation contracts. The tracked code root contains the shared instruction, skill, documentation, workflow, and `bin/` surfaces, while each effective `FM_HOME` contains private operational directories. `data/` holds durable private fleet records such as the project and secondmate registries, captain preferences, optional shared captain preferences, learnings, backlog, briefs, and scout reports. -`state/` holds volatile runtime records such as task metadata, append-only status events, endpoint signals, watcher and wake-queue coordination, away-mode state, generated X-mode artifacts, private secondmate config-reread generations with their retry and quarantine state, and parent-owned secondmate pending-reply records under `state/pending-replies/` (`bin/fm-pending-reply-lib.sh`). +`state/` holds volatile runtime records such as task metadata, append-only status events, endpoint signals, watcher and wake-queue coordination, away-mode state, generated X-mode artifacts, the generated operational alert watch and its dedupe record, private secondmate config-reread generations with their retry and quarantine state, and parent-owned secondmate pending-reply records under `state/pending-replies/` (`bin/fm-pending-reply-lib.sh`). `config/` holds local gitignored operating choices, and `projects/` holds the local project clones that Firstmate reads but changes only through the guarded exceptions in `AGENTS.md`. `bin/fm-spawn.sh` owns the base task-metadata fields it emits, while the runtime-backend section below owns backend-specific fields and selector interpretation. @@ -116,6 +116,13 @@ An absent file means `auto`, i.e. default-on on macOS: the alarm exists precisel A missing or failing channel logs and falls through to the next, never crashing the daemon. See [`wedge-alarm.md`](wedge-alarm.md) for the current channel reference, [`verification/supervision.md`](verification/supervision.md#wedge-alarm-channels) for active evidence, and [`examples/wedge-alarm`](examples/wedge-alarm) for a copyable config. +## Operational alert inbox watch (config/ops-inbox.json) + +On a machine whose operations runtime records critical alerts into a durable inbox rather than a chat transport, firstmate watches that inbox so an unreviewed backlog wakes the first mate instead of sitting unread. +Local, gitignored `config/ops-inbox.json` under the effective home holds this watch's operating choices. +This file is deliberately not inherited into secondmate homes, and a secondmate home never auto-arms the watch: one machine has one alert inbox, and the primary owns watching it. +[`ops-inbox-wake.md`](ops-inbox-wake.md) owns the settings, defaults, arming, wake, dedupe, and two-paths-named-ops-inbox contracts; `bin/fm-ops-inbox-lib.sh`'s header owns the exact resolution mechanics. + ## Gate defaults (.no-mistakes.yaml) The tracked `.no-mistakes.yaml` keeps test evidence outside the repo and pins `commands.lint` to `bin/fm-lint.sh` so local lint matches CI. @@ -407,6 +414,7 @@ FM_HEARTBEAT=600 # base seconds between heartbeat scans; no-change heartb FM_HEARTBEAT_MAX=7200 # heartbeat backoff cap FM_CHECK_INTERVAL=300 # seconds between slow checks (authenticated merge polls, custom checks, or X-mode dispatch) FM_CHECK_TIMEOUT=30 # seconds allowed per slow check script +FM_OPS_INBOX_STATE_DIR= # alternate watched operations state dir for the alert inbox watch, mainly for tests; an explicit config/ops-inbox.json value wins (docs/ops-inbox-wake.md) FM_CODEX_WATCH_CHECKPOINT=180 # seconds per foreground watcher checkpoint in Codex primary supervision FM_CREW_STATE_NM_TIMEOUT=10 # seconds allowed per no-mistakes query inside fm-crew-state.sh FM_CREW_STATE_RUNS_LIMIT=200 # recent no-mistakes run rows scanned when axi status cannot be attributed to the current code diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index f8647b28f2e..5333afed8f2 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -263,6 +263,10 @@ "path": "docs/link-intake.md", "audience": "operator-current" }, + { + "path": "docs/ops-inbox-wake.md", + "audience": "operator-current" + }, { "path": "docs/orca-backend.md", "audience": "operator-current" diff --git a/docs/ops-inbox-wake.md b/docs/ops-inbox-wake.md new file mode 100644 index 00000000000..ea7d2b69f08 --- /dev/null +++ b/docs/ops-inbox-wake.md @@ -0,0 +1,187 @@ +# Operational alert inbox wake + +Some machines run an operations runtime that records critical alerts - dead-man timers, tripwires, backup and service health checks - into a durable inbox instead of a chat transport. +An inbox nobody reads disarms every one of those checks silently, because each check still "fires" and still records its alert while no one is told. +This watch closes that gap by turning an unreviewed critical backlog into an ordinary Firstmate wake, so a stalled alert queue reaches the first mate the same way a crew signal or a merged pull request does. + +Alerts route to the first mate, never straight to the captain, and never through any external service. +The first mate decides what an alert backlog means and escalates only what the captain actually needs. + +## Exit dispositions + +The invariant behind every exit is that the poll may stay silent only after it has established that the watch is disabled, absent by auto-mode design, within its thresholds, or already raised and not yet due for another wake. +`FAILS-CLOSED-AND-WAKES` means a genuine inability to establish that fact becomes the one-line wake instead of a silent process failure. +`WAKES` means the condition is an expected wake-worthy state. +`SILENT-BY-DESIGN` means the condition is benign or has already been reported within the dedupe interval. + +| Surface and exit | Disposition | Contract | +|---|---|---| +| `fm_ops_inbox_config_load`: the optional config is absent. | `SILENT-BY-DESIGN` | Defaults are resolved and evaluation continues without output. | +| `fm_ops_inbox_config_load`: the config is not an ordinary file or is a symlink. | `FAILS-CLOSED-AND-WAKES` | The poll emits the configuration-error wake because no configured threshold can be trusted. | +| `fm_ops_inbox_config_load`: the config exists but `jq` is unavailable. | `FAILS-CLOSED-AND-WAKES` | The poll emits the configuration-error wake. | +| `fm_ops_inbox_config_load`: the config is unreadable, invalid JSON, or not an object. | `FAILS-CLOSED-AND-WAKES` | The poll emits the configuration-error wake. | +| `fm_ops_inbox_config_load`: whitelist validation or the final `jq` read fails. | `FAILS-CLOSED-AND-WAKES` | The poll emits the configuration-error wake. | +| `fm_ops_inbox_config_load`: a key is unrecognized, including when its value is null. | `FAILS-CLOSED-AND-WAKES` | The poll emits the configuration-error wake. | +| `fm_ops_inbox_config_load`: `enabled` has an invalid value. | `FAILS-CLOSED-AND-WAKES` | The poll emits the configuration-error wake. | +| `fm_ops_inbox_config_load`: a path setting is empty or non-string, or any string setting contains a code point below 32. | `FAILS-CLOSED-AND-WAKES` | The poll emits the configuration-error wake instead of resolving an unintended inbox path or accepting an injected setting. | +| `fm_ops_inbox_config_load`: a numeric setting is empty, negative, fractional, non-numeric, outside the supported arithmetic range, or a zero `max_lines`. | `FAILS-CLOSED-AND-WAKES` | The poll emits the configuration-error wake. | +| `fm_ops_inbox_config_load`: all settings are valid, with recognized null values omitted. | `SILENT-BY-DESIGN` | Defaults survive null values and evaluation continues without output. | +| `fm_ops_inbox_watch_expected`: `enabled` is explicitly true. | `SILENT-BY-DESIGN` | Evaluation continues even when the spool is absent, so the scan can fail closed. | +| `fm_ops_inbox_watch_expected`: `enabled` is explicitly false. | `SILENT-BY-DESIGN` | The poll exits without reading or waking. | +| `fm_ops_inbox_watch_expected`: auto mode runs in a secondmate home. | `SILENT-BY-DESIGN` | The poll exits because the primary home owns the machine-wide inbox. | +| `fm_ops_inbox_watch_expected`: auto mode finds the spool. | `SILENT-BY-DESIGN` | Evaluation continues without output. | +| `fm_ops_inbox_watch_expected`: auto mode does not find the spool. | `SILENT-BY-DESIGN` | The poll exits because this home has no inbox to watch. | +| `fm_ops_inbox_scan`: the active spool is absent. | `FAILS-CLOSED-AND-WAKES` | The poll emits the scan-failure wake. | +| `fm_ops_inbox_scan`: `jq` is unavailable. | `FAILS-CLOSED-AND-WAKES` | The poll emits the dedicated missing-`jq` wake before scanning. | +| `fm_ops_inbox_scan`: the bounded overflow sample cannot be read or counted. | `FAILS-CLOSED-AND-WAKES` | The poll emits the scan-failure wake. | +| `fm_ops_inbox_scan`: the acknowledgement log is absent. | `SILENT-BY-DESIGN` | The scan treats the inbox as having no external acknowledgements. | +| `fm_ops_inbox_scan`: the acknowledgement log cannot be read or parsed. | `FAILS-CLOSED-AND-WAKES` | The poll emits the scan-failure wake. | +| `fm_ops_inbox_scan`: the spool tail cannot be read. | `FAILS-CLOSED-AND-WAKES` | The poll emits the scan-failure wake. | +| `fm_ops_inbox_scan`: a nonblank complete spool record cannot be parsed. | `FAILS-CLOSED-AND-WAKES` | The poll emits the scan-failure wake because the record cannot be classified safely. | +| `fm_ops_inbox_scan`: the final non-terminated fragment is malformed. | `SILENT-BY-DESIGN` | The fragment is ignored for overflow detection and parsing as a benign append in progress while complete records remain eligible. | +| `fm_ops_inbox_scan`: the final non-terminated record is valid JSON. | `SILENT-BY-DESIGN` | The valid record is included in the bounded scan and normal evaluation continues. | +| `fm_ops_inbox_scan`: a record is excluded by severity, inline acknowledgement, or the acknowledgement log. | `SILENT-BY-DESIGN` | Excluded records cannot trigger timestamp validation or a false scan failure. | +| `fm_ops_inbox_scan`: an included critical record has a malformed or calendar-invalid timestamp. | `FAILS-CLOSED-AND-WAKES` | The poll emits the scan-failure wake before age comparison. | +| `fm_ops_inbox_scan`: acknowledgement filtering, timestamp validation, `awk`, or summary construction fails. | `FAILS-CLOSED-AND-WAKES` | The poll emits the scan-failure wake. | +| `fm_ops_inbox_scan`: the bounded scan succeeds. | `SILENT-BY-DESIGN` | The result is returned to the poll for threshold evaluation without output. | +| `fm_ops_inbox_receipt_count`: the receipt is absent. | `WAKES` | The poll marks the receipt missing and reports that state after a successful scan. | +| `fm_ops_inbox_receipt_count`: `jq` is unavailable. | `FAILS-CLOSED-AND-WAKES` | The poll emits the dedicated missing-`jq` wake before receipt parsing. | +| `fm_ops_inbox_receipt_count`: JSON parsing fails or the count is missing, negative, fractional, non-numeric, or outside the supported arithmetic range. | `WAKES` | The poll marks the receipt unreadable and falls back to the spool count. | +| `fm_ops_inbox_receipt_count`: a usable count is returned. | `SILENT-BY-DESIGN` | A fresh receipt becomes authoritative for the reported total and evaluation continues. | +| `fm_ops_inbox_epoch_of_iso`: the timestamp shape is invalid. | `FAILS-CLOSED-AND-WAKES` | The poll emits the scan-failure wake instead of omitting the oldest age. | +| `fm_ops_inbox_epoch_of_iso`: the timestamp is calendar-invalid. | `FAILS-CLOSED-AND-WAKES` | The poll emits the scan-failure wake instead of omitting the oldest age. | +| `fm_ops_inbox_epoch_of_iso`: date conversion or exact round-trip validation fails. | `FAILS-CLOSED-AND-WAKES` | The poll emits the scan-failure wake instead of omitting the oldest age. | +| `fm_ops_inbox_epoch_of_iso`: conversion succeeds. | `SILENT-BY-DESIGN` | The poll evaluates the exact oldest age without output. | +| `fm_ops_inbox_sidecar_read`: the sidecar is absent, symlinked, unreadable, lacks any of its first four lines, is version-mismatched, has a numeric value outside the supported arithmetic range, or is missing its state. | `WAKES` | A currently qualifying condition bypasses dedupe and wakes as though there were no prior record. | +| `fm_ops_inbox_sidecar_read`: the first four lines are valid. | `SILENT-BY-DESIGN` | The poll evaluates growth, class, state, and re-remind dedupe rules, treating an absent fifth class-list line as empty. | +| `fm_ops_inbox_sidecar_write`: the directory or existing target is invalid. | `WAKES` | The wake line is still printed and the recording failure is reported on standard error. | +| `fm_ops_inbox_sidecar_write`: temporary-file creation, content write, permission setting, or rename fails. | `WAKES` | The wake line is still printed and the recording failure is reported on standard error. | +| `fm_ops_inbox_sidecar_write`: the atomic record succeeds. | `WAKES` | The wake line is printed once and the new dedupe state is durable. | +| Poll: configuration loading fails. | `FAILS-CLOSED-AND-WAKES` | The poll emits the configuration-error wake and exits successfully for the watcher. | +| Poll: an active or malformed watch cannot read the current time as epoch seconds. | `FAILS-CLOSED-AND-WAKES` | The poll emits a direct one-line watch failure because age, receipt staleness, and dedupe cannot be evaluated. | +| Poll: the watch is disabled or auto mode has no owned spool. | `SILENT-BY-DESIGN` | The poll exits with no output. | +| Poll: `jq` is unavailable after default configuration resolution. | `FAILS-CLOSED-AND-WAKES` | The poll emits the dedicated missing-`jq` wake. | +| Poll: the receipt is missing, stale, or unreadable. | `WAKES` | The receipt state is included in the digest after a successful scan. | +| Poll: scanning or oldest-timestamp conversion fails. | `FAILS-CLOSED-AND-WAKES` | The poll emits the scan-failure wake. | +| Poll: the fresh receipt and scan establish that no wake threshold is met. | `SILENT-BY-DESIGN` | The poll clears prior dedupe state and exits with no output. | +| Poll: prior dedupe state cannot be cleared on the threshold-not-met path. | `FAILS-CLOSED-AND-WAKES` | The poll emits a dedupe-state failure wake instead of risking suppression of the next backlog. | +| Poll: a condition qualifies and no valid prior sidecar suppresses it. | `WAKES` | The poll prints the one-line digest. | +| Poll: an unchanged qualifying condition is still inside its dedupe interval. | `SILENT-BY-DESIGN` | The poll exits without repeating the same wake. | +| Poll: count growth, a new class, receipt-state change, or re-remind makes a prior condition due again. | `WAKES` | The poll prints the refreshed one-line digest. | +| Poll: recording a due wake fails. | `WAKES` | The digest is still printed, and standard error explains that dedupe durability failed. | + +## What is watched + +The watch reads three paths and writes to none of them: + +| Path | Role | +|---|---| +| `/ops-inbox.jsonl` | append-only alert spool, one JSON object per line with `id`, `ts`, `source`, `severity`, and an inline `ack` flag | +| `/ops-inbox-acks.jsonl` | acknowledgement log, one JSON object per line with `event_id` | +| `/ops-inbox-receipt.json` | the runtime's own periodic review receipt, carrying `unacked_critical_count` | + +An alert counts as unacked exactly when the spool's own reader counts it: `severity` is `critical`, the inline `ack` flag is `false`, and no acknowledgement entry names its `id`. +Firstmate never acknowledges, rotates, or edits these files; acknowledging remains the operations runtime's own command. + +`` defaults to `$HOME/.openclaw/state` and is overridable per home; see [Configuration](#configuration). + +## When it wakes + +Any one of these conditions makes the backlog wake-worthy: + +- more unacked critical alerts than `count` (default 25); +- at least one unacked critical alert older than `age_hours` (default 6); +- the review receipt is stale, missing, or unreadable, because a dead receipt generator is itself the alerting failure, and it is the failure that hides every other one; +- the spool has grown past `max_lines` (default 20000), so it can no longer be read whole inside one check and needs rotating or triaging; +- the watch configuration is malformed, so no threshold can be trusted. + +A fresh receipt's own `unacked_critical_count` is the reported total; when the receipt is stale, missing, or unreadable, the total is counted from the spool instead and the wake says which case it is. +The oldest age and the alert classes always come from the spool, since the receipt does not carry them. + +The wake is one compact digest line, enough to triage without reading the whole inbox: + +``` +ops-inbox: 621 unacked critical alerts, oldest 13d, top: routine-scheduler 154, pipeline-verifier 96, scheduled-work-inventory 60 +``` + +``` +ops-inbox: alert receipt stale 9h; 3 unacked critical alerts, oldest 8h, top: backup-verify 3 +``` + +Alert class is the spool's `source` field, sanitized and length-capped so a hostile or malformed alert cannot forge extra fields, split the line, or corrupt the durable wake record. + +The read itself is bounded: the check samples at most `max_lines + 1` spool lines to detect overflow, then parses only the most recent `max_lines` of the spool and acknowledgement log, so its work stays bounded however large the inbox grows. +Exceeding that cap is reported without claiming the exact spool length, because a capped read understates both the total and the oldest age: + +``` +ops-inbox: inbox past its 20000-line read cap, so the total and oldest age below are understated; 20000 unacked critical alerts, oldest 6d, top: routine-scheduler 5104 +``` + +A valid final JSON record is included even without a trailing newline, while a malformed non-terminated fragment is ignored as an append in progress. +Malformed nonblank complete records fail closed, blank complete records are ignored, and timestamp validation runs only after severity and acknowledgement exclusions so benign excluded records cannot create false wakes. +Every included critical timestamp must have the exact UTC shape and represent a real calendar instant before the scan can succeed. + +## When it stays quiet + +A standing backlog must not re-wake on every poll, or the wake becomes noise and gets ignored - the same failure in a different shape. +`state/.ops-inbox-wake` privately records the last wake's total, receipt state, and class set. +After a first wake, a further wake needs one of: + +- a total at least `growth` larger than the last wake's total (default 10); +- an alert class that was not present at the last wake; +- a changed receipt state, such as a fresh receipt going stale; +- `remind_hours` elapsed since the last wake (default 24), so an unreviewed backlog is raised again daily rather than once and forgotten. + +When the fresh receipt and scan no longer meet any wake condition, that record is removed, so the next qualifying backlog wakes immediately instead of being deduplicated against an already-resolved one. + +## How it is armed + +The watch is the reserved standing check `state/ops-watch.check.sh`, registered through the ordinary custom-check trust binding in `bin/fm-check-register.sh`. +That shim only exports the home and runs `bin/fm-ops-inbox-poll.sh`; the watcher runs the registered bytes and turns any output into one `check:` wake. +Arming is a session-start bootstrap sweep, so it converges on its own rather than depending on anyone remembering to arm it. + +By default a home arms the watch only when the configured alert spool actually exists, so a machine with no operations runtime writes nothing and prints nothing. +Secondmate homes never auto-arm, because the primary home owns the one machine-wide inbox, but an explicit `enabled: true` still arms a secondmate home. +The local config is not inherited into secondmate homes. +In auto mode, bootstrap disarms it again when the inbox goes away. +Bootstrap also refuses to arm over a live task holding the reserved `ops-watch` id and reports an arming failure as an actionable `OPS_INBOX:` line. +Arming and disarming are otherwise silent; `FM_BOOTSTRAP_VERBOSE_FACTS=1` prints them as `BOOTSTRAP_INFO` facts. + +An armed watch counts as a supervision need in `bin/fm-supervision-lib.sh`, exactly like an X-mode relay poll. +A standing poll only ever reaches the first mate through a live watcher, so a home that is watching alerts keeps supervision alive even with an empty fleet. + +## Configuration + +Local, gitignored `config/ops-inbox.json` under the effective home overrides the defaults. +Every key is optional, and an unrecognized key is refused rather than ignored, so a mistyped threshold can never read as a configured one. + +```json +{ + "enabled": "auto", + "state_dir": "/absolute/path/to/.openclaw/state", + "age_hours": 6, + "count": 25, + "remind_hours": 24, + "growth": 10, + "receipt_stale_hours": 3, + "max_lines": 20000 +} +``` + +- `enabled` is `auto` (default: arm only when the spool exists), `true` (always arm, and treat a missing spool or receipt as an alerting failure), or `false` (never arm). +- `state_dir` selects the watched directory; `spool`, `acks`, and `receipt` override those three paths individually. +- `remind_hours: 0` disables re-reminding, and `receipt_stale_hours: 0` disables the staleness condition. +- `max_lines` bounds how many recent spool and acknowledgement lines one check reads (default 20000). +- `FM_OPS_INBOX_STATE_DIR` overrides the default watched directory for tests and specialized setups; an explicit config value always wins over it. + +`docs/configuration.md` owns where this file sits among the other local operating choices, and `bin/fm-ops-inbox-lib.sh`'s header owns the exact resolution mechanics. + +## Two paths named "ops-inbox" + +The JSONL spool above is the alert inbox this watch reads. +A second, unrelated path shares the name: some operations routines drop plain-text `.event` files into `/ops-inbox//`, a spool with no severity field, no acknowledgement model, and no reader on the Firstmate side. + +Those two are not two views of one inbox; they are one watched inbox and one unread drop directory. +Firstmate treats the JSONL spool plus its receipt as the single alert surface, and gitignores the drop directory so its machine-local files can never be committed into this shared repo. +Converging the routines that write `.event` files onto the JSONL spool belongs to the operations repository that owns those routines, not here: an alert only reaches this watch once its producer records it in the spool. +Until that convergence lands, an alert written only as an `.event` file is not covered by this watch. diff --git a/docs/scripts.md b/docs/scripts.md index fd91d9d06f0..cb602cd7bf3 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -89,6 +89,8 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-teardown.sh` | Fail-closed teardown: return landed ship worktrees, require completed scout deliverables, retire secondmate homes | | `fm-harness.sh` | Detect the running harness and resolve crew or secondmate harness, model, and effort | | `fm-lock.sh` | Per-home firstmate session lock | +| `fm-ops-inbox-lib.sh` | Shared operational alert inbox watch resolution: config, thresholds, scan, dedupe record | +| `fm-ops-inbox-poll.sh` | One bounded read of the operational alert inbox: wake with a digest, or stay silent | | `fm-x-lib.sh` | Shared X-mode config, relay, and reply-threading helpers | | `fm-x-poll.sh` | One bounded X relay poll: stash newly offered mentions and emit their once-only wake | | `fm-x-reply.sh` | Post or dry-run preview a composed X-mode reply or follow-up | diff --git a/docs/subagent-guard.md b/docs/subagent-guard.md index 87f194d9d12..fd7be387db0 100644 --- a/docs/subagent-guard.md +++ b/docs/subagent-guard.md @@ -358,8 +358,8 @@ tests/fm-subagent-pretool-check.test.sh This change does not close the deeper harness-agnostic defect. Every firstmate guard's in-flight-work branch keys off `state/.meta`, and only `bin/fm-spawn.sh` writes that record. -`bin/fm-supervision-lib.sh` also recognizes an X-mode relay poll as supervision need, but unaccounted primary work still contributes nothing to that predicate. -Without an independent X-mode need, unaccounted primary work therefore reads as idle rather than suspicious. +`bin/fm-supervision-lib.sh` also recognizes an armed standing poll (X-mode relay or operational alert inbox watch) as supervision need, but unaccounted primary work still contributes nothing to that predicate. +Without an independent standing-poll need, unaccounted primary work therefore reads as idle rather than suspicious. The durable fix for that class is to make the guards treat "the primary is doing project-shaped work with zero `state/*.meta` files" as a suspicious state rather than an idle one. That would catch this class on any harness, including work created through `Bash`. diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index 8ee750de397..9b9ea2c3bee 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -27,7 +27,7 @@ It also requires `AGENTS.md`, `bin/`, and the effective state directory. For an in-scope primary, the guard counts in-flight work from `state/*.meta`. The default cross-harness mode exits silently with no work in flight. -Claude's `--claude` mode also treats `state/x-watch.check.sh` as supervision need, so X-mode relay polling remains guarded without an in-flight task. +Claude's `--claude` mode also treats an armed standing poll - `state/x-watch.check.sh` or `state/ops-watch.check.sh` - as supervision need, so X-mode relay polling and the operational alert inbox watch remain guarded without an in-flight task. Otherwise it calls `fm_watcher_healthy [grace-seconds] [home]` from `bin/fm-wake-lib.sh`, the same identity-matched lock and fresh-beacon check used by `bin/fm-watch-arm.sh`. A stale beacon blocks even when a watcher pid is live. A fresh leftover beacon blocks when the lock is missing, dead, or identity-mismatched. @@ -76,7 +76,7 @@ That warning uses `bin/fm-supervision-instructions.sh --repair-line`, so it alwa ## Compatibility limits - 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. +- A valid secondmate home is in scope; an idle secondmate endpoint with no armed standing poll remains healthy because it has no supervision need. - 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`. diff --git a/tests/fm-lint.test.sh b/tests/fm-lint.test.sh index a2b3c8fb296..b4c0e014407 100755 --- a/tests/fm-lint.test.sh +++ b/tests/fm-lint.test.sh @@ -267,7 +267,7 @@ test_source_graph_boundaries_keep_every_owner() { grep -q '^[[:space:]]*# shellcheck source=bin/' "$file" || continue production_context_tests="${production_context_tests}$(basename "$file")|" done - [ "$production_context_tests" = 'fm-backend-herdr.test.sh|fm-daemon.test.sh|fm-pending-reply.test.sh|fm-secondmate-sync.test.sh|' ] \ + [ "$production_context_tests" = 'fm-backend-herdr.test.sh|fm-daemon.test.sh|fm-ops-inbox-watch.test.sh|fm-pending-reply.test.sh|fm-secondmate-sync.test.sh|' ] \ || fail "only callback/variable interop tests may retain production source context: $production_context_tests" pass "dispatcher, adapters, production owner, and tests have explicit lint boundaries" } diff --git a/tests/fm-ops-inbox-watch.test.sh b/tests/fm-ops-inbox-watch.test.sh new file mode 100755 index 00000000000..3e7de9fde20 --- /dev/null +++ b/tests/fm-ops-inbox-watch.test.sh @@ -0,0 +1,1026 @@ +#!/usr/bin/env bash +# Behavior tests for the operational alert inbox watch: the standing poll +# (fm-ops-inbox-poll.sh), bootstrap's arming sweep, the supervision-need +# predicate, and one end-to-end watcher dispatch. +# +# Everything here drives the real scripts against a synthetic alert spool in a +# temp directory, so no test depends on this machine actually running an +# operations runtime. The watch must be INERT by default (no inbox -> nothing +# armed, nothing printed) and, when armed, must wake exactly once per genuinely +# new condition rather than on every poll. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +POLL="$ROOT/bin/fm-ops-inbox-poll.sh" +TMP_ROOT=$(fm_test_tmproot fm-ops-inbox-watch) +NOW=$(date -u +%s) + +iso_at() { # + local ago=$1 + if [ "$(uname)" = Darwin ]; then + date -u -r $((NOW - ago)) +%Y-%m-%dT%H:%M:%SZ + else + date -u -d "@$((NOW - ago))" +%Y-%m-%dT%H:%M:%SZ + fi +} + +# make_home : a temp firstmate home plus an empty operations state dir. +make_home() { + local name=$1 home + home="$TMP_ROOT/$name" + mkdir -p "$home/state" "$home/config" "$home/data" "$home/ops" + printf '%s\n' "$home" +} + +# alert [severity] [ack] +alert() { + local home=$1 id=$2 ago=$3 source=$4 severity=${5:-critical} ack=${6:-false} + printf '{"id":"%s","ts":"%s","source":"%s","severity":"%s","message":"synthetic %s","ack":%s}\n' \ + "$id" "$(iso_at "$ago")" "$source" "$severity" "$id" "$ack" \ + >> "$home/ops/ops-inbox.jsonl" +} + +ack_alert() { # + printf '{"event_id":"%s","ts":"%s","acked_by":"firstmate"}\n' "$2" "$(iso_at 0)" \ + >> "$1/ops/ops-inbox-acks.jsonl" +} + +# receipt +receipt() { + local home=$1 count=$2 ago=$3 file + file="$home/ops/ops-inbox-receipt.json" + printf '{"ts":"%s","status":"attention","unacked_critical_count":%s,"unacked_ids":[]}\n' \ + "$(iso_at "$ago")" "$count" > "$file" + touch -t "$(receipt_stamp "$ago")" "$file" +} + +receipt_stamp() { # -> local [[CC]YY]MMDDhhmm.SS for touch -t + local ago=$1 + if [ "$(uname)" = Darwin ]; then + date -r $((NOW - ago)) +%Y%m%d%H%M.%S + else + date -d "@$((NOW - ago))" +%Y%m%d%H%M.%S + fi +} + +# configure [extra-json-pairs]: point the watch at this home's spool. +configure() { + local home=$1 extra=${2:-} + if [ -n "$extra" ]; then + printf '{"state_dir": "%s/ops", %s}\n' "$home" "$extra" > "$home/config/ops-inbox.json" + else + printf '{"state_dir": "%s/ops"}\n' "$home" > "$home/config/ops-inbox.json" + fi +} + +run_poll() { # + FM_HOME="$1" "$POLL" 2>/dev/null +} + +make_fault_path() { # + local name=$1 target=$2 fail_on=$3 dir tool real counter + dir="$TMP_ROOT/path-$name" + mkdir -p "$dir" + for tool in bash dirname date jq head awk tail sort sed cut tr uname stat mktemp chmod mv rm; do + real=$(command -v "$tool") || fail "required test command is unavailable: $tool" + if [ "$tool" = "$target" ]; then + counter="$dir/.$tool-count" + # Single quotes are deliberate: these printf templates are written verbatim into a generated helper script and must not expand here. + # shellcheck disable=SC2016 + { + printf '#!/bin/sh\n' + printf 'count=0\n' + printf '[ ! -f %q ] || read -r count < %q\n' "$counter" "$counter" + printf 'count=$((count + 1))\n' + printf 'printf "%%s\\n" "$count" > %q\n' "$counter" + printf '[ "$count" -ne %q ] || exit 1\n' "$fail_on" + printf 'exec %q "$@"\n' "$real" + } > "$dir/$tool" + chmod 0700 "$dir/$tool" + else + ln -s "$real" "$dir/$tool" + fi + done + printf '%s\n' "$dir" +} + +make_path_without_jq() { # + local name=$1 dir tool real + dir="$TMP_ROOT/path-$name" + mkdir -p "$dir" + for tool in bash dirname date head awk tail sort sed cut tr uname stat mktemp chmod mv rm; do + real=$(command -v "$tool") || fail "required test command is unavailable: $tool" + ln -s "$real" "$dir/$tool" + done + printf '%s\n' "$dir" +} + +run_poll_with_path() { # + PATH="$2" FM_HOME="$1" "$2/bash" "$POLL" 2>/dev/null +} + +# Bootstrap runs many detect steps; these tests only care about its alert-watch +# behavior, so filter its output to the lines this feature owns. +run_bootstrap() { # + FM_HOME="$1" FM_BOOTSTRAP_VERBOSE_FACTS=1 "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null \ + | grep -E 'OPS_INBOX|operational alert watch' || true +} + +run_bootstrap_without_jq() { # + local home=$1 bash_env + bash_env="$home/no-jq-bash-env" + # Single quotes are deliberate: these printf templates are written verbatim into a generated helper script and must not expand here. + # shellcheck disable=SC2016 + { + printf '%s\n' 'command() {' + printf '%s\n' ' if [ "$#" -eq 2 ] && [ "$1" = -v ] && [ "$2" = jq ]; then' + printf '%s\n' ' return 1' + printf '%s\n' ' fi' + printf '%s\n' ' builtin command "$@"' + printf '%s\n' '}' + } > "$bash_env" + BASH_ENV="$bash_env" FM_HOME="$home" FM_OPS_INBOX_STATE_DIR="$home/ops" \ + FM_BOOTSTRAP_VERBOSE_FACTS=1 "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null \ + | grep -E 'MISSING: jq|OPS_INBOX|operational alert watch' || true +} + +# The migration gate refuses to let a watcher run without its completed markers. +seed_migration_markers() { # + local home=$1 + printf '%s\n' fm-pr-check-migration-scan-v1 > "$home/state/.pr-check-migration-scan-v1" + printf '%s\n' fm-pr-check-migration-v1 > "$home/state/.pr-check-migration-v1" + chmod 0600 "$home/state/.pr-check-migration-scan-v1" "$home/state/.pr-check-migration-v1" +} + +test_inert_without_an_inbox() { + local home out + home=$(make_home inert) + out=$(run_poll "$home") + [ -z "$out" ] || fail "the poll must say nothing when this home has no alert inbox (got: $out)" + out=$(run_bootstrap "$home") + [ -z "$out" ] || fail "bootstrap must say nothing about an alert watch it never arms (got: $out)" + assert_absent "$home/state/ops-watch.check.sh" "bootstrap armed a watch with no inbox to watch" + pass "auto mode stays silent and unarmed on a home with no alert inbox" +} + +test_quiet_backlog_is_silent() { + local home out + home=$(make_home quiet) + configure "$home" + alert "$home" q1 600 backup-verify + alert "$home" q2 600 routine-scheduler + receipt "$home" 2 60 + out=$(run_poll "$home") + [ -z "$out" ] || fail "a small, fresh, reviewed-recently backlog must not wake firstmate (got: $out)" + assert_absent "$home/state/.ops-inbox-wake" "a silent poll must not record a wake" + pass "a small backlog under a fresh receipt stays silent" +} + +test_age_threshold_wakes_with_digest() { + local home out + home=$(make_home age) + configure "$home" + alert "$home" a1 32400 backup-verify # 9h old, past the 6h default + alert "$home" a2 600 backup-verify + alert "$home" a3 600 routine-scheduler + alert "$home" a4 600 acked-class + alert "$home" a5 600 warning-class warning + ack_alert "$home" a4 + receipt "$home" 3 60 + out=$(run_poll "$home") + assert_contains "$out" "ops-inbox:" "an aged critical backlog must wake firstmate" + assert_contains "$out" "3 unacked critical alerts" "the digest must carry the unacked count" + assert_contains "$out" "oldest 9h" "the digest must carry the oldest alert age" + assert_contains "$out" "top: backup-verify 2" "the digest must rank the busiest alert class first" + assert_contains "$out" "routine-scheduler 1" "the digest must name the other classes" + assert_not_contains "$out" "acked-class" "an acknowledged alert must not reach the digest" + assert_not_contains "$out" "warning-class" "a non-critical alert must not reach the digest" + [ "$(printf '%s' "$out" | wc -l | tr -d ' ')" = 0 ] \ + || fail "the wake must be exactly one line" + assert_present "$home/state/.ops-inbox-wake" "a wake must be recorded for dedupe" + pass "an alert older than the age threshold wakes with a compact triage digest" +} + +test_count_threshold_wakes_when_all_alerts_are_new() { + local home out i + home=$(make_home count) + configure "$home" + i=0 + while [ "$i" -lt 30 ]; do + alert "$home" "c$i" 60 flood + i=$((i + 1)) + done + receipt "$home" 30 60 + out=$(run_poll "$home") + assert_contains "$out" "30 unacked critical alerts" "a backlog past the count threshold must wake even when every alert is new" + pass "a backlog past the count threshold wakes on volume alone" +} + +test_stale_receipt_is_itself_wake_worthy() { + local home out + home=$(make_home stale-receipt) + configure "$home" + alert "$home" s1 600 backup-verify + receipt "$home" 1 21600 # 6h old, past the 3h default + out=$(run_poll "$home") + assert_contains "$out" "alert receipt stale 6h" "a stale receipt must wake even when the backlog itself is small" + assert_contains "$out" "1 unacked critical alert" "a stale-receipt wake must still carry the current backlog" + pass "a stale review receipt wakes firstmate on its own" +} + +test_missing_receipt_is_wake_worthy() { + local home out + home=$(make_home missing-receipt) + configure "$home" + alert "$home" m1 600 backup-verify + out=$(run_poll "$home") + assert_contains "$out" "alert receipt missing" "a missing receipt must wake firstmate" + pass "a missing review receipt wakes firstmate on its own" +} + +test_fresh_receipt_count_is_authoritative() { + local home out + home=$(make_home receipt-count) + configure "$home" + alert "$home" r1 32400 backup-verify + receipt "$home" 41 60 + out=$(run_poll "$home") + assert_contains "$out" "41 unacked critical alerts" "a fresh receipt's own count must be reported" + pass "a fresh receipt's count is used for the digest total" +} + +test_leading_zero_config_number_is_canonicalized() { + local home out + home=$(make_home leading-zero-config) + configure "$home" '"receipt_stale_hours": "08"' + alert "$home" lzc1 600 backup-verify + receipt "$home" 1 32400 + out=$(run_poll "$home") + assert_contains "$out" "alert receipt stale 9h" \ + "a leading-zero receipt staleness threshold must remain safe for arithmetic" + pass "a leading-zero configuration number is canonicalized" +} + +test_leading_zero_receipt_count_is_canonicalized() { + local home out + home=$(make_home leading-zero-receipt) + configure "$home" '"count": 0, "growth": 1' + alert "$home" lzr1 600 backup-verify + receipt "$home" '"08"' 60 + printf '%s\n%s\n%s\n%s\n%s\n' fm-ops-inbox-wake-v1 "$NOW" 7 fresh backup-verify \ + > "$home/state/.ops-inbox-wake" + out=$(run_poll "$home") + assert_contains "$out" "8 unacked critical alerts" \ + "a leading-zero receipt count must wake through growth evaluation without aborting" + pass "a leading-zero receipt count is canonicalized" +} + +test_leading_zero_sidecar_numbers_are_canonicalized() { + local home out stale_epoch + home=$(make_home leading-zero-sidecar) + configure "$home" + alert "$home" lzs1 32400 backup-verify + receipt "$home" 8 60 + stale_epoch=$((NOW - 90000)) + printf '%s\n%s\n%s\n%s\n%s\n' fm-ops-inbox-wake-v1 "000$stale_epoch" 00000008 fresh backup-verify \ + > "$home/state/.ops-inbox-wake" + out=$(run_poll "$home") + assert_contains "$out" "ops-inbox:" \ + "leading-zero sidecar epoch and count values must permit the due re-remind wake" + pass "leading-zero sidecar numbers are canonicalized" +} + +test_corrupt_recent_receipt_is_unreadable() { + local home out + home=$(make_home corrupt-receipt) + configure "$home" + alert "$home" cr1 600 backup-verify + printf 'not json\n' > "$home/ops/ops-inbox-receipt.json" + touch -t "$(receipt_stamp 60)" "$home/ops/ops-inbox-receipt.json" + out=$(run_poll "$home") + assert_contains "$out" "alert receipt unreadable" \ + "a recent receipt with no usable count must wake as unreadable" + assert_contains "$out" "1 unacked critical alert" \ + "an unreadable receipt must fall back to the spool-scanned count" + pass "a corrupt recent receipt wakes as unreadable" +} + +test_malformed_spool_fails_closed() { + local home out + home=$(make_home malformed-spool) + configure "$home" + printf '{"id":"broken"\n' > "$home/ops/ops-inbox.jsonl" + receipt "$home" 0 60 + out=$(run_poll "$home") + assert_contains "$out" "ops-inbox: alert inbox at $home/ops/ops-inbox.jsonl could not be read" \ + "a malformed spool must wake through the scan-failure path" + assert_not_contains "$out" "0 unacked critical alerts" \ + "a malformed spool must not be reported as a clean zero" + pass "a malformed spool wakes fail-closed" +} + +test_malformed_timestamp_fails_closed() { + local home out + home=$(make_home malformed-timestamp) + configure "$home" + printf '{"id":"bad-ts","ts":"!","source":"routine-scheduler","severity":"critical","message":"bad timestamp","ack":false}\n' \ + >> "$home/ops/ops-inbox.jsonl" + receipt "$home" 1 60 + out=$(run_poll "$home") + assert_contains "$out" "ops-inbox: alert inbox at $home/ops/ops-inbox.jsonl could not be read" \ + "a complete critical alert with a malformed timestamp must wake through scan failure" + pass "a malformed critical timestamp wakes fail-closed" +} + +test_calendar_invalid_timestamp_fails_closed() { + local home out + home=$(make_home calendar-invalid-timestamp) + configure "$home" + printf '{"id":"bad-calendar","ts":"2026-00-00T00:00:00Z","source":"routine-scheduler","severity":"critical","message":"bad timestamp","ack":false}\n' \ + >> "$home/ops/ops-inbox.jsonl" + receipt "$home" 1 60 + out=$(run_poll "$home") + assert_contains "$out" "ops-inbox: alert inbox at $home/ops/ops-inbox.jsonl could not be read" \ + "a calendar-invalid critical timestamp must wake through scan failure" + pass "a calendar-invalid critical timestamp wakes fail-closed" +} + +test_torn_trailing_spool_write_is_tolerated() { + local home out + home=$(make_home torn-spool) + configure "$home" + alert "$home" complete 32400 backup-verify + printf '{"id":"torn"' >> "$home/ops/ops-inbox.jsonl" + receipt "$home" 1 60 + out=$(run_poll "$home") + assert_contains "$out" "1 unacked critical alert" \ + "a torn trailing write must leave complete alert records readable" + assert_not_contains "$out" "could not be read" \ + "a torn trailing write must not cause a spurious scan-failure wake" + pass "a torn trailing spool write is ignored until it completes" +} + +test_torn_trailing_spool_write_does_not_trigger_cap() { + local home out + home=$(make_home torn-at-cap) + configure "$home" '"max_lines": 1' + alert "$home" complete-at-cap 600 backup-verify + printf '{"id":"still-being-written"' >> "$home/ops/ops-inbox.jsonl" + receipt "$home" 1 60 + out=$(run_poll "$home") + [ -z "$out" ] || fail "a torn append at the cap must stay benign (got: $out)" + pass "a torn trailing write does not create a spurious cap wake" +} + +test_valid_unterminated_final_record_is_scanned() { + local home out + home=$(make_home valid-unterminated) + configure "$home" + printf '{"id":"final","ts":"%s","source":"backup-verify","severity":"critical","message":"complete","ack":false}' \ + "$(iso_at 32400)" >> "$home/ops/ops-inbox.jsonl" + receipt "$home" 1 60 + out=$(run_poll "$home") + assert_contains "$out" "1 unacked critical alert" \ + "a valid final record without a newline must remain visible to the scan" + assert_contains "$out" "oldest 9h" \ + "a valid final record without a newline must participate in age evaluation" + assert_not_contains "$out" "could not be read" \ + "a valid final record without a newline must not become a scan failure" + pass "a valid unterminated final record is scanned" +} + +test_acknowledged_garbage_timestamp_is_ignored() { + local home out + home=$(make_home acked-garbage-timestamp) + configure "$home" + alert "$home" quiet 600 backup-verify + printf '{"id":"acked-bad","ts":"!","source":"routine-scheduler","severity":"critical","message":"bad timestamp","ack":false}\n' \ + >> "$home/ops/ops-inbox.jsonl" + ack_alert "$home" acked-bad + receipt "$home" 1 60 + out=$(run_poll "$home") + [ -z "$out" ] || fail "an acknowledged garbage timestamp must stay excluded and silent (got: $out)" + pass "an acknowledged garbage timestamp cannot create a false wake" +} + +test_standing_backlog_does_not_rewake_every_poll() { + local home first second + home=$(make_home dedupe) + configure "$home" + alert "$home" d1 32400 backup-verify + receipt "$home" 1 60 + first=$(run_poll "$home") + assert_contains "$first" "ops-inbox:" "the first sight of a backlog must wake" + second=$(run_poll "$home") + [ -z "$second" ] || fail "an unchanged standing backlog must not wake again (got: $second)" + pass "an unchanged standing backlog wakes once, not every poll" +} + +test_material_growth_wakes_again() { + local home out i + home=$(make_home growth) + configure "$home" '"growth": 5' + alert "$home" g0 32400 backup-verify + receipt "$home" 1 60 + run_poll "$home" >/dev/null + alert "$home" g1 600 backup-verify + receipt "$home" 2 60 + out=$(run_poll "$home") + [ -z "$out" ] || fail "one extra alert is not material growth (got: $out)" + i=0 + while [ "$i" -lt 5 ]; do + alert "$home" "gg$i" 600 backup-verify + i=$((i + 1)) + done + receipt "$home" 7 60 + out=$(run_poll "$home") + assert_contains "$out" "7 unacked critical alerts" "a materially larger backlog must wake again" + pass "the backlog wakes again only after it grows materially" +} + +test_new_alert_class_wakes_again() { + local home out + home=$(make_home new-class) + configure "$home" + alert "$home" n1 32400 backup-verify + receipt "$home" 1 60 + run_poll "$home" >/dev/null + alert "$home" n2 600 disk-pressure + receipt "$home" 2 60 + out=$(run_poll "$home") + assert_contains "$out" "disk-pressure" "a newly failing alert class must wake again immediately" + pass "a new alert class wakes again without waiting for the re-remind interval" +} + +test_reremind_interval_wakes_again() { + local home out stale_epoch + home=$(make_home reremind) + configure "$home" '"remind_hours": 1' + alert "$home" rr1 32400 backup-verify + receipt "$home" 1 60 + run_poll "$home" >/dev/null + out=$(run_poll "$home") + [ -z "$out" ] || fail "the same backlog must stay quiet inside the re-remind interval (got: $out)" + stale_epoch=$((NOW - 7200)) + printf '%s\n%s\n%s\n%s\n%s\n' fm-ops-inbox-wake-v1 "$stale_epoch" 1 fresh backup-verify \ + > "$home/state/.ops-inbox-wake" + out=$(run_poll "$home") + assert_contains "$out" "ops-inbox:" "an unreviewed backlog must be raised again once the re-remind interval passes" + pass "a still-unreviewed backlog is raised again on the re-remind interval" +} + +test_cleared_backlog_resets_dedupe() { + local home out + home=$(make_home cleared) + configure "$home" + alert "$home" x1 32400 backup-verify + receipt "$home" 1 60 + run_poll "$home" >/dev/null + assert_present "$home/state/.ops-inbox-wake" "the first wake must be recorded" + ack_alert "$home" x1 + receipt "$home" 0 60 + out=$(run_poll "$home") + [ -z "$out" ] || fail "a cleared backlog must be silent (got: $out)" + assert_absent "$home/state/.ops-inbox-wake" "a cleared backlog must drop its dedupe record" + alert "$home" x2 32400 backup-verify + receipt "$home" 1 60 + out=$(run_poll "$home") + assert_contains "$out" "ops-inbox:" "a fresh backlog after a cleared one must wake immediately" + pass "clearing the backlog resets dedupe so the next one wakes immediately" +} + +test_unusable_configuration_is_reported_not_guessed() { + local home out + home=$(make_home bad-config) + printf 'not json at all\n' > "$home/config/ops-inbox.json" + out=$(run_poll "$home") + assert_contains "$out" "alert watch configuration is unusable" "a malformed config must be reported, not silently defaulted" + out=$(run_bootstrap "$home") + assert_contains "$out" "OPS_INBOX:" "bootstrap must report a malformed alert-watch config" + assert_absent "$home/state/ops-watch.check.sh" "bootstrap must not arm a watch it cannot configure" + pass "an unusable alert-watch configuration is reported instead of guessed" +} + +test_symlinked_configuration_fails_closed() { + local home out + home=$(make_home symlinked-config) + printf '{"state_dir":"%s/ops"}\n' "$home" > "$home/config/real-ops-inbox.json" + ln -s "$home/config/real-ops-inbox.json" "$home/config/ops-inbox.json" + out=$(run_poll "$home") + assert_contains "$out" "config/ops-inbox.json is not an ordinary file" \ + "a symlinked watch configuration must wake instead of being trusted" + pass "a symlinked watch configuration wakes fail-closed" +} + +test_configuration_jq_read_failure_fails_closed() { + local home out fault_path + home=$(make_home config-jq-read-failure) + configure "$home" + fault_path=$(make_fault_path config-jq-read jq 4) + out=$(run_poll_with_path "$home" "$fault_path") + assert_contains "$out" "alert watch configuration is unusable" \ + "a failed final configuration read must wake instead of using partial settings" + pass "a configuration jq read failure wakes fail-closed" +} + +test_unknown_null_configuration_key_is_refused() { + local home out + home=$(make_home unknown-null-config) + configure "$home" '"coutn": null' + out=$(run_poll "$home") + assert_contains "$out" "has an unrecognized setting" \ + "an unknown setting must be refused even when its value is null" + pass "an unknown null-valued setting is refused" +} + +test_recognized_null_configuration_keeps_default() { + local home out + home=$(make_home recognized-null-config) + configure "$home" '"count": null' + alert "$home" null-default 600 backup-verify + receipt "$home" 1 60 + out=$(run_poll "$home") + [ -z "$out" ] || fail "a recognized null setting must retain its quiet default (got: $out)" + pass "a recognized null-valued setting keeps its default" +} + +test_rejected_threshold_value_is_reported() { + local home out + home=$(make_home bad-threshold) + configure "$home" '"age_hours": "soon"' + out=$(run_poll "$home") + assert_contains "$out" "age_hours must be a non-negative integer" "a non-numeric threshold must be reported" + pass "a non-numeric threshold is refused with a specific reason" +} + +test_empty_threshold_value_is_reported() { + local home out + home=$(make_home empty-threshold) + configure "$home" '"count": ""' + out=$(run_poll "$home") + assert_contains "$out" "count must be a non-negative integer" \ + "an empty threshold must be refused rather than preserving a default silently" + pass "an empty numeric threshold is refused" +} + +test_invalid_enabled_value_is_reported() { + local home out + home=$(make_home invalid-enabled) + configure "$home" '"enabled": "sometimes"' + out=$(run_poll "$home") + assert_contains "$out" "enabled must be true, false, or auto" \ + "an invalid enabled value must wake instead of changing arming behavior silently" + pass "an invalid enabled value is refused" +} + +test_invalid_path_value_is_reported() { + local home out + home=$(make_home invalid-path) + configure "$home" '"spool": 7' + out=$(run_poll "$home") + assert_contains "$out" "has an invalid path or control character" \ + "an invalid path setting must wake instead of resolving an unintended inbox" + pass "an invalid path setting is refused" +} + +test_zero_read_cap_is_reported() { + local home out + home=$(make_home zero-read-cap) + configure "$home" '"max_lines": 0' + out=$(run_poll "$home") + assert_contains "$out" "max_lines must be greater than zero" \ + "a zero-line read cap must wake instead of silently using a different limit" + pass "a zero-line read cap is refused" +} + +test_explicit_disable_wins() { + local home out + home=$(make_home disabled) + configure "$home" '"enabled": false' + alert "$home" o1 32400 backup-verify + out=$(run_poll "$home") + [ -z "$out" ] || fail "an explicitly disabled watch must stay silent (got: $out)" + pass "an explicitly disabled alert watch stays silent" +} + +test_explicit_enable_without_spool_fails_closed() { + local home out + home=$(make_home enabled-missing-spool) + configure "$home" '"enabled": true' + receipt "$home" 0 60 + out=$(run_poll "$home") + assert_contains "$out" "ops-inbox: alert inbox at $home/ops/ops-inbox.jsonl could not be read" \ + "an explicitly enabled watch must wake when its required spool is missing" + pass "an explicitly enabled watch with no spool wakes fail-closed" +} + +test_missing_jq_with_configuration_fails_closed() { + local home out fault_path + home=$(make_home config-missing-jq) + configure "$home" + fault_path=$(make_path_without_jq config-missing-jq) + out=$(run_poll_with_path "$home" "$fault_path") + assert_contains "$out" "jq is required to read config/ops-inbox.json" \ + "a configured watch without jq must wake through configuration failure" + pass "a configured watch without jq wakes fail-closed" +} + +test_missing_jq_after_default_configuration_fails_closed() { + local home out fault_path + home=$(make_home default-missing-jq) + alert "$home" jq1 600 backup-verify + fault_path=$(make_path_without_jq default-missing-jq) + out=$(PATH="$fault_path" FM_HOME="$home" FM_OPS_INBOX_STATE_DIR="$home/ops" \ + "$fault_path/bash" "$POLL" 2>/dev/null) + assert_contains "$out" "jq is not installed" \ + "an auto-armed default watch without jq must emit the dedicated failure wake" + pass "an auto-armed watch without jq wakes fail-closed" +} + +test_bootstrap_missing_jq_reports_alert_watch_outage() { + local home out + home=$(make_home bootstrap-missing-jq) + alert "$home" bootstrap-jq1 600 backup-verify + out=$(run_bootstrap_without_jq "$home") + assert_contains "$out" "MISSING: jq" \ + "bootstrap must retain the jq installation diagnostic" + assert_contains "$out" "OPS_INBOX: the operational alert watch cannot read the inbox until jq is installed; install jq, then rerun bootstrap" \ + "bootstrap must classify the missing-jq alert-watch outage with remediation" + pass "bootstrap classifies a missing-jq alert-watch outage" +} + +test_current_time_failure_fails_closed() { + local home out fault_path + home=$(make_home current-time-failure) + configure "$home" + alert "$home" time1 600 backup-verify + receipt "$home" 1 60 + fault_path=$(make_fault_path current-time-failure date 1) + out=$(run_poll_with_path "$home" "$fault_path") + assert_contains "$out" "alert watch could not read the current time" \ + "a current-time failure must wake because age, receipt, and dedupe cannot be evaluated" + pass "a current-time failure wakes fail-closed" +} + +test_inert_watch_does_not_require_current_time() { + local home out fault_path + home=$(make_home inert-time-failure) + fault_path=$(make_fault_path inert-time-failure date 1) + out=$(run_poll_with_path "$home" "$fault_path") + [ -z "$out" ] || fail "an auto-mode home without a spool must stay inert when time is unavailable (got: $out)" + pass "an inert watch exits before requiring the current time" +} + +test_old_spool_alert_wakes_when_receipt_count_is_zero() { + local home out + home=$(make_home zero-receipt-old-alert) + configure "$home" + alert "$home" receipt-zero-old 32400 backup-verify + receipt "$home" 0 60 + out=$(run_poll "$home") + assert_contains "$out" "oldest 9h" \ + "an old spool alert must remain wake-worthy when the fresh receipt reports zero" + pass "spool age remains authoritative when the receipt count is zero" +} + +test_bounded_sample_failure_fails_closed() { + local home out fault_path + home=$(make_home sample-failure) + configure "$home" + alert "$home" sample1 600 backup-verify + receipt "$home" 1 60 + fault_path=$(make_fault_path sample-failure head 1) + out=$(run_poll_with_path "$home" "$fault_path") + assert_contains "$out" "alert inbox at $home/ops/ops-inbox.jsonl could not be read" \ + "a bounded overflow-sample failure must wake through scan failure" + pass "a bounded overflow-sample failure wakes fail-closed" +} + +test_acknowledgement_parse_failure_fails_closed() { + local home out + home=$(make_home acknowledgement-parse-failure) + configure "$home" + alert "$home" ack-parse1 600 backup-verify + printf '{"event_id":"broken"\n' > "$home/ops/ops-inbox-acks.jsonl" + receipt "$home" 1 60 + out=$(run_poll "$home") + assert_contains "$out" "alert inbox at $home/ops/ops-inbox.jsonl could not be read" \ + "an acknowledgement-log parse failure must wake through scan failure" + pass "an acknowledgement-log parse failure wakes fail-closed" +} + +test_spool_tail_failure_fails_closed() { + local home out fault_path + home=$(make_home spool-tail-failure) + configure "$home" + alert "$home" tail1 600 backup-verify + receipt "$home" 1 60 + fault_path=$(make_fault_path spool-tail-failure tail 1) + out=$(run_poll_with_path "$home" "$fault_path") + assert_contains "$out" "alert inbox at $home/ops/ops-inbox.jsonl could not be read" \ + "a bounded spool-tail failure must wake through scan failure" + pass "a bounded spool-tail failure wakes fail-closed" +} + +test_acknowledgement_filter_failure_fails_closed() { + local home out fault_path + home=$(make_home acknowledgement-filter-failure) + configure "$home" + alert "$home" awk1 600 backup-verify + receipt "$home" 1 60 + fault_path=$(make_fault_path acknowledgement-filter-failure awk 1) + out=$(run_poll_with_path "$home" "$fault_path") + assert_contains "$out" "alert inbox at $home/ops/ops-inbox.jsonl could not be read" \ + "an acknowledgement-filter failure must wake through scan failure" + pass "an acknowledgement-filter failure wakes fail-closed" +} + +test_scan_summary_failure_fails_closed() { + local home out fault_path + home=$(make_home scan-summary-failure) + configure "$home" + alert "$home" sort1 600 backup-verify + receipt "$home" 1 60 + fault_path=$(make_fault_path scan-summary-failure sort 1) + out=$(run_poll_with_path "$home" "$fault_path") + assert_contains "$out" "alert inbox at $home/ops/ops-inbox.jsonl could not be read" \ + "a scan-summary pipeline failure must wake through scan failure" + pass "a scan-summary pipeline failure wakes fail-closed" +} + +test_oldest_epoch_conversion_failure_fails_closed() { + local home out fault_path + home=$(make_home epoch-conversion-failure) + configure "$home" + alert "$home" epoch1 600 backup-verify + receipt "$home" 1 60 + fault_path=$(make_fault_path epoch-conversion-failure date 2) + out=$(run_poll_with_path "$home" "$fault_path") + assert_contains "$out" "alert inbox at $home/ops/ops-inbox.jsonl could not be read" \ + "an oldest-timestamp date failure must wake instead of omitting the age" + pass "an oldest-timestamp conversion failure wakes fail-closed" +} + +test_dedupe_clear_failure_fails_closed() { + local home out + home=$(make_home dedupe-clear-failure) + configure "$home" + alert "$home" clear1 600 backup-verify + receipt "$home" 1 60 + mkdir "$home/state/.ops-inbox-wake" + out=$(run_poll "$home") + assert_contains "$out" "wake dedupe state at $home/state/.ops-inbox-wake could not be cleared" \ + "a dedupe-clear failure must wake instead of risking suppression of the next backlog" + pass "a dedupe-clear failure wakes fail-closed" +} + +test_bootstrap_arms_registers_and_is_idempotent() { + local home out sum1 sum2 + home=$(make_home arm) + configure "$home" + alert "$home" b1 600 backup-verify + out=$(run_bootstrap "$home") + assert_contains "$out" "operational alert watch armed" "bootstrap must report the watch it armed" + assert_present "$home/state/ops-watch.check.sh" "bootstrap must drop the standing check" + [ -x "$home/state/ops-watch.check.sh" ] || fail "the standing check must be executable" + assert_grep "fm-ops-inbox-poll.sh" "$home/state/ops-watch.check.sh" \ + "the standing check must run the trusted poll script" + assert_present "$home/state/ops-watch.check-trust" "bootstrap must bind the check to its bytes" + sum1=$(cat "$home/state/ops-watch.check.sh" "$home/state/ops-watch.check-trust" | shasum) + out=$(run_bootstrap "$home") + [ -z "$out" ] || fail "re-arming an already-armed watch must be silent (got: $out)" + sum2=$(cat "$home/state/ops-watch.check.sh" "$home/state/ops-watch.check-trust" | shasum) + [ "$sum1" = "$sum2" ] || fail "arming the alert watch must be idempotent" + pass "bootstrap arms and registers the standing alert check, idempotently" +} + +test_bootstrap_disarms_when_the_inbox_goes_away() { + local home out + home=$(make_home disarm) + configure "$home" + alert "$home" b2 600 backup-verify + run_bootstrap "$home" >/dev/null + assert_present "$home/state/ops-watch.check.sh" "the watch must be armed before the disarm case" + rm -f "$home/ops/ops-inbox.jsonl" + out=$(run_bootstrap "$home") + assert_contains "$out" "operational alert watch disarmed" "bootstrap must report the disarm" + assert_absent "$home/state/ops-watch.check.sh" "bootstrap must remove the standing check" + assert_absent "$home/state/ops-watch.check-trust" "bootstrap must remove the trust binding" + pass "bootstrap disarms the watch when there is no longer an inbox to watch" +} + +test_bootstrap_refuses_to_take_a_live_task_id() { + local home out + home=$(make_home reserved) + configure "$home" + alert "$home" b3 600 backup-verify + fm_write_meta "$home/state/ops-watch.meta" "window=firstmate:fm-ops-watch" "harness=echo" + out=$(run_bootstrap "$home") + assert_contains "$out" "OPS_INBOX: task id ops-watch is in use" "bootstrap must refuse to collide with live work" + assert_absent "$home/state/ops-watch.check.sh" "bootstrap must not arm over a live task's records" + pass "bootstrap refuses to arm the watch over a live task holding its reserved id" +} + +test_live_task_artifacts_survive_disarm() { + local home out sum_before sum_after + home=$(make_home live-task-disarm) + configure "$home" + printf 'foreign check\n' > "$home/state/ops-watch.check.sh" + chmod 0700 "$home/state/ops-watch.check.sh" + FM_HOME="$home" "$ROOT/bin/fm-check-register.sh" ops-watch >/dev/null + sum_before=$(cat "$home/state/ops-watch.check.sh" "$home/state/ops-watch.check-trust" | shasum) + fm_write_meta "$home/state/ops-watch.meta" "window=firstmate:fm-ops-watch" "harness=echo" + out=$(run_bootstrap "$home") + assert_contains "$out" "OPS_INBOX: task id ops-watch is in use" \ + "live work must win before a disarm-triggering path examines artifacts" + assert_grep "foreign check" "$home/state/ops-watch.check.sh" \ + "bootstrap must not remove a live task's foreign check" + sum_after=$(cat "$home/state/ops-watch.check.sh" "$home/state/ops-watch.check-trust" | shasum) + [ "$sum_before" = "$sum_after" ] \ + || fail "bootstrap must not alter a live task's registered foreign check" + pass "live task artifacts survive a disarm-triggering bootstrap run" +} + +test_foreign_check_survives_disarm() { + local home out sum_before sum_after + home=$(make_home foreign-check-disarm) + configure "$home" + printf 'foreign check\n' > "$home/state/ops-watch.check.sh" + chmod 0700 "$home/state/ops-watch.check.sh" + FM_HOME="$home" "$ROOT/bin/fm-check-register.sh" ops-watch >/dev/null + sum_before=$(cat "$home/state/ops-watch.check.sh" "$home/state/ops-watch.check-trust" | shasum) + out=$(run_bootstrap "$home") + assert_contains "$out" "OPS_INBOX: state/ops-watch.check.sh is not this operational alert watch" \ + "bootstrap must report a foreign reserved-id check instead of deleting it" + assert_grep "foreign check" "$home/state/ops-watch.check.sh" \ + "a foreign reserved-id check must survive disarm" + sum_after=$(cat "$home/state/ops-watch.check.sh" "$home/state/ops-watch.check-trust" | shasum) + [ "$sum_before" = "$sum_after" ] \ + || fail "a foreign registered check and its trust record must survive disarm" + pass "a foreign reserved-id check survives disarm" +} + +test_armed_watch_needs_supervision() { + local home needed desc + home=$(make_home supervision) + configure "$home" + alert "$home" b4 600 backup-verify + needed=$( + # shellcheck source=bin/fm-supervision-lib.sh + . "$ROOT/bin/fm-supervision-lib.sh" + fm_supervision_needed "$home/state" && echo yes || echo no + ) + [ "$needed" = no ] || fail "an idle home with no armed watch must not need supervision" + run_bootstrap "$home" >/dev/null + needed=$( + # shellcheck source=bin/fm-supervision-lib.sh + . "$ROOT/bin/fm-supervision-lib.sh" + fm_supervision_needed "$home/state" && echo yes || echo no + ) + [ "$needed" = yes ] || fail "an armed alert watch must need a live watcher" + desc=$( + # shellcheck source=bin/fm-supervision-lib.sh + . "$ROOT/bin/fm-supervision-lib.sh" + fm_supervision_status "$home/state" + printf '%s' "$FM_SUP_STANDING_DESC" + ) + assert_contains "$desc" "operational alert monitoring" \ + "the supervision banner must name the alert watch in plain language" + pass "an armed alert watch counts as a supervision need with no fleet work" +} + +test_read_cap_is_reported_not_hidden() { + local home out i + home=$(make_home read-cap) + configure "$home" '"max_lines": 5' + i=0 + while [ "$i" -lt 8 ]; do + alert "$home" "cap$i" 600 flood + i=$((i + 1)) + done + receipt "$home" 8 60 + out=$(run_poll "$home") + assert_contains "$out" "inbox past its 5-line read cap" \ + "a spool too long to read inside one check must say so instead of failing quietly" + assert_contains "$out" "understated" "the digest must warn that a capped total understates the backlog" + [ "$(printf '%s' "$out" | wc -l | tr -d ' ')" = 0 ] || fail "the capped wake must still be one line" + pass "a spool past the bounded read cap is reported rather than silently truncated" +} + +test_secondmate_home_does_not_auto_arm() { + local home out + home=$(make_home secondmate-auto) + configure "$home" + alert "$home" sm1 32400 backup-verify + printf '%s\n' alpha > "$home/.fm-secondmate-home" + out=$(run_poll "$home") + [ -z "$out" ] || fail "one machine has one alert inbox, so a secondmate home must not raise it too (got: $out)" + out=$(run_bootstrap "$home") + [ -z "$out" ] || fail "bootstrap must not arm the alert watch in a secondmate home by default (got: $out)" + assert_absent "$home/state/ops-watch.check.sh" "a secondmate home must not auto-arm the alert watch" + configure "$home" '"enabled": true' + out=$(run_bootstrap "$home") + assert_contains "$out" "operational alert watch armed" \ + "an explicit opt-in must still arm the watch in a secondmate home" + pass "a secondmate home never auto-arms the machine's alert watch" +} + +test_watcher_dispatches_the_registered_check() { + local home status wake drained + home=$(make_home watcher) + configure "$home" + alert "$home" w1 32400 backup-verify + alert "$home" w2 600 routine-scheduler + receipt "$home" 2 60 + seed_migration_markers "$home" + run_bootstrap "$home" >/dev/null + assert_present "$home/state/ops-watch.check.sh" "the watch must be armed before the watcher runs" + status=0 + wake=$(FM_HOME="$home" FM_POLL=1 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=1 \ + "$ROOT/bin/fm-watch-checkpoint.sh" --seconds 20 2>/dev/null) || status=$? + expect_code 0 "$status" "alert-watch checkpoint exit" + assert_contains "$wake" "check:" "the alert digest must arrive as an ordinary check wake" + assert_contains "$wake" "ops-inbox:" "the wake must carry the alert digest" + assert_contains "$wake" "2 unacked critical alerts" "the wake must carry the unacked count" + drained=$(FM_HOME="$home" "$ROOT/bin/fm-wake-drain.sh") + assert_contains "$drained" "ops-inbox:" "the alert digest must be queued durably" + pass "the watcher dispatches the registered alert check and queues its digest" +} + +test_hostile_alert_source_cannot_break_the_wake_record() { + local home out + home=$(make_home hostile) + configure "$home" + printf '{"id":"h1","ts":"%s","source":"bad\\tsource with spaces\\nand a newline","severity":"critical","message":"m","ack":false}\n' \ + "$(iso_at 32400)" >> "$home/ops/ops-inbox.jsonl" + receipt "$home" 1 60 + out=$(run_poll "$home") + [ "$(printf '%s' "$out" | wc -l | tr -d ' ')" = 0 ] \ + || fail "a hostile alert source must not split the wake into several lines" + assert_not_contains "$out" "$(printf '\t')" "a hostile alert source must not put a tab in the wake record" + assert_contains "$out" "top: bad" "the hostile source must still be reported as one class" + assert_not_contains "$out" "source with spaces" "the source's own whitespace must be neutralized" + assert_contains "$out" "1 unacked critical alert," "a hostile source must not corrupt the count" + pass "a hostile alert source is sanitized into one safe wake line" +} + +test_inert_without_an_inbox +test_quiet_backlog_is_silent +test_age_threshold_wakes_with_digest +test_count_threshold_wakes_when_all_alerts_are_new +test_stale_receipt_is_itself_wake_worthy +test_missing_receipt_is_wake_worthy +test_fresh_receipt_count_is_authoritative +test_leading_zero_config_number_is_canonicalized +test_leading_zero_receipt_count_is_canonicalized +test_leading_zero_sidecar_numbers_are_canonicalized +test_corrupt_recent_receipt_is_unreadable +test_malformed_spool_fails_closed +test_malformed_timestamp_fails_closed +test_calendar_invalid_timestamp_fails_closed +test_torn_trailing_spool_write_is_tolerated +test_torn_trailing_spool_write_does_not_trigger_cap +test_valid_unterminated_final_record_is_scanned +test_acknowledged_garbage_timestamp_is_ignored +test_standing_backlog_does_not_rewake_every_poll +test_material_growth_wakes_again +test_new_alert_class_wakes_again +test_reremind_interval_wakes_again +test_cleared_backlog_resets_dedupe +test_unusable_configuration_is_reported_not_guessed +test_symlinked_configuration_fails_closed +test_configuration_jq_read_failure_fails_closed +test_unknown_null_configuration_key_is_refused +test_recognized_null_configuration_keeps_default +test_rejected_threshold_value_is_reported +test_empty_threshold_value_is_reported +test_invalid_enabled_value_is_reported +test_invalid_path_value_is_reported +test_zero_read_cap_is_reported +test_explicit_disable_wins +test_explicit_enable_without_spool_fails_closed +test_missing_jq_with_configuration_fails_closed +test_missing_jq_after_default_configuration_fails_closed +test_bootstrap_missing_jq_reports_alert_watch_outage +test_current_time_failure_fails_closed +test_inert_watch_does_not_require_current_time +test_old_spool_alert_wakes_when_receipt_count_is_zero +test_bounded_sample_failure_fails_closed +test_acknowledgement_parse_failure_fails_closed +test_spool_tail_failure_fails_closed +test_acknowledgement_filter_failure_fails_closed +test_scan_summary_failure_fails_closed +test_oldest_epoch_conversion_failure_fails_closed +test_dedupe_clear_failure_fails_closed +test_read_cap_is_reported_not_hidden +test_bootstrap_arms_registers_and_is_idempotent +test_bootstrap_disarms_when_the_inbox_goes_away +test_bootstrap_refuses_to_take_a_live_task_id +test_live_task_artifacts_survive_disarm +test_foreign_check_survives_disarm +test_armed_watch_needs_supervision +test_secondmate_home_does_not_auto_arm +test_watcher_dispatches_the_registered_check +test_hostile_alert_source_cannot_break_the_wake_record diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index 9bcf04eddba..37b2a3c9c6f 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -6,7 +6,7 @@ # Coverage: # - absent-file markers vs empty-but-present files in the context digest # - the lock-refusal read-only path: banner leads, every mutating step is -# skipped (including bootstrap's five mutating sweeps, verified by their +# skipped (including bootstrap's six mutating sweeps, verified by their # ABSENCE), the digest still completes # - output section ordering: diagnostics/banners lead, bulk file dumps follow # - context-aware next-step guidance for read-only, AFK, X mode, and normal diff --git a/tests/lib.sh b/tests/lib.sh index ee3b1d1476c..5f0ecdc4a62 100644 --- a/tests/lib.sh +++ b/tests/lib.sh @@ -34,6 +34,14 @@ FM_TEST_LIB_SOURCED=1 # strips this to verify real refusal. export FM_GATE_REFUSE_BYPASS=1 +# Keep the operational alert inbox watch out of every temp home by default. Its +# arming decision otherwise reads the real machine's alert spool under $HOME, so +# a developer machine that happens to run that operations runtime would arm a +# standing check inside unrelated test homes and change their bootstrap results. +# Point it at a path that cannot exist; a test that exercises the watch sets its +# own FM_OPS_INBOX_STATE_DIR or config/ops-inbox.json explicitly. +export FM_OPS_INBOX_STATE_DIR="/nonexistent/fm-tests-no-ops-inbox" + # Resolve the repo root from this library's own location. Consumed by sourcing # test files, not by this library, so it reads as "unused" here. # shellcheck disable=SC2034 From 26a5300923ff00446c82112a1cb93fc02ad1b5ee Mon Sep 17 00:00:00 2001 From: quinnbot-ai Date: Thu, 30 Jul 2026 10:18:06 -0700 Subject: [PATCH 17/70] feat(bin): pure-Kun convergence scoreboard (#59) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(bin): implemented the first deterministic, read-only Pure-Kun convergence scoreboard slice with focused verification and maintained remeasurement guidance. * feat(bin): corrected the scoreboard’s trailing-newline TOON conformance gap and added byte-level regression coverage. * feat(bin): the scoreboard now rejects ambiguous branch/tag ref names while accepting fully qualified corrections. * feat(bin): hardened the scoreboard so failed diff measurements cannot silently report zero changes. * feat(bin): isolated scoreboard measurement from ambient Git attributes and configuration so identical explicit refs now produce stable metrics. * feat(bin): hardened deterministic TOON error output against raw ASCII control characters. * no-mistakes(review): fix(scoreboard): sanitize preflight Git environment * no-mistakes(review): fix(scoreboard): bind isolated repository object format * no-mistakes(test): Fix scoreboard help when HOME is unset * no-mistakes(document): Document scoreboard hardening contracts * no-mistakes(document): Correct scoreboard help remote refs * no-mistakes(review): Captain, fix scoreboard isolation and edge-case parsing * no-mistakes(review): Captain, isolate scoreboard Git calls with environment allowlist * no-mistakes(review): Captain, document scoreboard preflight ambient-input limitations * no-mistakes(document): Document scoreboard hardening contracts * no-mistakes(lint): Escape fixture variables for ShellCheck compliance --------- Co-authored-by: QuinnBot --- bin/fm-convergence-scoreboard.sh | 372 ++++++++++++++++++++++ docs/scripts.md | 12 + tests/fm-convergence-scoreboard.test.sh | 393 ++++++++++++++++++++++++ 3 files changed, 777 insertions(+) create mode 100755 bin/fm-convergence-scoreboard.sh create mode 100755 tests/fm-convergence-scoreboard.test.sh diff --git a/bin/fm-convergence-scoreboard.sh b/bin/fm-convergence-scoreboard.sh new file mode 100755 index 00000000000..7a9646ecd87 --- /dev/null +++ b/bin/fm-convergence-scoreboard.sh @@ -0,0 +1,372 @@ +#!/usr/bin/env bash +# fm-convergence-scoreboard.sh - measure one local ref against one upstream ref. +# +# Usage: +# bin/fm-convergence-scoreboard.sh +# bin/fm-convergence-scoreboard.sh --help +# +# Both refs are required so reruns never inherit an ambient branch or remote. +# The current worktree must be clean because an uncommitted convergence step +# cannot be represented by either resolved commit identity. +# Each ref name must be unambiguous and resolve locally to a commit. +# The resolved commits must have exactly one merge base. +# Ahead and behind are graph counts from upstream...local. +# First-parent deliveries are local first-parent commits unreachable upstream. +# Diff metrics compare the unique merge base with the resolved local commit. +# Every Git subprocess uses an allowlisted environment with optional locks and +# lazy fetches disabled. +# Measurements ignore ambient config and replacement refs, source attributes +# from the local commit, and use a bare repository pinned to the source storage +# object format, so ambient Git state cannot change the resolved measurements. +# A failed graph or diff measurement exits before emitting partial metrics. +# Renames count as one deletion plus one addition so file grouping is stable. +# Git binary-file numstat markers count as zero lines while the paths still count. +# Every changed path belongs to exactly one of these ordered groups: +# agent-runtime: AGENTS.md, CLAUDE.md, .agents/, and skills/ +# automation: bin/, .github/, .claude/, .codex/, .opencode/, and .pi/ +# tests: tests/ +# documentation: docs/, README.md, and CONTRIBUTING.md +# configuration: .tasks.toml, .no-mistakes.yaml, and .gitignore +# other: every remaining path +# Stdout is deterministic TOON without a trailing newline for help, successful +# results, and errors, and quoted strings escape every ASCII control character. +# Exit 0 means success, 1 means the comparison cannot be measured, and 2 means +# the invocation is invalid. +set -u + +export LC_ALL=C + +SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SELF="$SELF_DIR/$(basename "${BASH_SOURCE[0]}")" + +toon_quote() { + local value=$1 char code escaped='' i + value=${value//\\/\\\\} + value=${value//\"/\\\"} + value=${value//$'\n'/\\n} + value=${value//$'\r'/\\r} + value=${value//$'\t'/\\t} + for ((i = 0; i < ${#value}; i += 1)); do + char=${value:i:1} + if [[ "$char" == [[:cntrl:]] ]]; then + printf -v code '%d' "'$char" + if ((code < 32 || code == 127)); then + printf -v char '\\u%04x' "$code" + fi + fi + escaped+=$char + done + printf '"%s"' "$escaped" +} + +display_bin() { + if [ -n "${HOME-}" ] && [[ "$SELF" == "$HOME"/* ]]; then + printf '%s/%s' '~' "${SELF#"$HOME"/}" + else + printf '%s' "$SELF" + fi +} + +usage() { + printf 'bin: %s\n' "$(toon_quote "$(display_bin)")" + printf 'description: %s\n' "$(toon_quote "Measure a clean local Git ref against an explicit upstream ref without changing either.")" + printf 'usage: %s\n' "$(toon_quote "bin/fm-convergence-scoreboard.sh ")" + printf 'arguments[2]{name,description}:\n' + printf ' %s,%s\n' \ + "$(toon_quote "local-ref")" \ + "$(toon_quote "Local delivery ref to measure.")" + printf ' %s,%s\n' \ + "$(toon_quote "upstream-ref")" \ + "$(toon_quote "Fetched upstream ref to compare against.")" + printf 'examples[2]: %s,%s' \ + "$(toon_quote "bin/fm-convergence-scoreboard.sh origin/main upstream/main")" \ + "$(toon_quote "bin/fm-convergence-scoreboard.sh HEAD upstream/main")" +} + +usage_error() { + printf 'error: %s\n' "$(toon_quote "$1")" + usage + exit 2 +} + +measure_error() { + printf 'error: %s\n' "$(toon_quote "$1")" + printf 'help[1]: %s' "$(toon_quote "$2")" + exit 1 +} + +isolated_git() { + local attr_source=$1 object_directory=$2 + local -a environment + shift 2 + environment=( + env -i + "PATH=$PATH" + "HOME=${HOME-}" + "LC_ALL=C" + "GIT_CONFIG_COUNT=0" + "GIT_CONFIG_GLOBAL=/dev/null" + "GIT_CONFIG_NOSYSTEM=1" + "GIT_NO_LAZY_FETCH=1" + "GIT_NO_REPLACE_OBJECTS=1" + "GIT_OPTIONAL_LOCKS=0" + ) + if [ -n "$attr_source" ]; then + environment+=( + "GIT_ATTR_NOSYSTEM=1" + "GIT_ATTR_SOURCE=$attr_source" + "GIT_OBJECT_DIRECTORY=$object_directory" + ) + fi + "${environment[@]}" git "$@" +} + +preflight_git() { + isolated_git '' '' "$@" +} + +reject_ambiguous_ref() { + local role=$1 ref=$2 diagnostics + diagnostics=$( + preflight_git -c core.warnAmbiguousRefs=true \ + -C "$REPO" rev-parse --symbolic-full-name "$ref" 2>&1 >/dev/null + ) || true + case "$diagnostics" in + *"refname '$ref' is ambiguous"*) + measure_error \ + "$role ref name is ambiguous: $ref" \ + "Use a fully qualified ref such as refs/heads/ or refs/remotes//, then rerun with the same two arguments." + ;; + esac +} + +if [ "$#" -eq 1 ] && { [ "$1" = "--help" ] || [ "$1" = "-h" ]; }; then + usage + exit 0 +fi + +[ "$#" -eq 2 ] \ + || usage_error "expected exactly and " + +case "$1" in + -*) usage_error "unknown flag or invalid local ref: $1" ;; +esac +case "$2" in + -*) usage_error "unknown flag or invalid upstream ref: $2" ;; +esac + +LOCAL_REF=$1 +UPSTREAM_REF=$2 + +if ! REPO=$(preflight_git rev-parse --show-toplevel 2>/dev/null) || [ -z "$REPO" ]; then + measure_error \ + "current directory is not inside a Git worktree" \ + "Run this command from the clean worktree that contains both refs." +fi + +if ! STATUS=$(preflight_git -C "$REPO" status --porcelain --untracked-files=normal 2>/dev/null); then + measure_error \ + "cannot inspect the current worktree state" \ + "Verify the worktree is readable, then rerun the same command." +fi +if [ -n "$STATUS" ]; then + measure_error \ + "current worktree is dirty" \ + "Commit or otherwise resolve every tracked and untracked change, then rerun with the same refs." +fi + +reject_ambiguous_ref "local" "$LOCAL_REF" +reject_ambiguous_ref "upstream" "$UPSTREAM_REF" + +if ! LOCAL_COMMIT=$(preflight_git -C "$REPO" rev-parse --verify --quiet "$LOCAL_REF^{commit}" 2>/dev/null); then + measure_error \ + "local ref is missing, unfetched, or does not resolve to one commit: $LOCAL_REF" \ + "Fetch or create the local ref explicitly, then rerun with the same two arguments." +fi +if ! UPSTREAM_COMMIT=$(preflight_git -C "$REPO" rev-parse --verify --quiet "$UPSTREAM_REF^{commit}" 2>/dev/null); then + measure_error \ + "upstream ref is missing, unfetched, or does not resolve to one commit: $UPSTREAM_REF" \ + "Fetch the upstream ref explicitly, then rerun with the same two arguments." +fi + +if ! OBJECTS_DIR=$(preflight_git -C "$REPO" rev-parse --path-format=absolute --git-path objects 2>/dev/null); then + measure_error \ + "cannot locate the repository object database" \ + "Verify the repository metadata, then rerun with the same refs." +fi +if ! OBJECT_FORMAT=$(preflight_git -C "$REPO" rev-parse --show-object-format=storage 2>/dev/null) \ + || [ -z "$OBJECT_FORMAT" ]; then + measure_error \ + "cannot determine the repository object format" \ + "Verify the repository metadata, then rerun with the same refs." +fi + +if ! MEASURE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/fm-convergence-scoreboard.XXXXXX"); then + measure_error \ + "cannot create isolated storage for measurements" \ + "Verify that the temporary directory is writable, then rerun with the same refs." +fi +MEASURE_GIT_DIR="$MEASURE_DIR/repository.git" +NUMSTAT_FILE="$MEASURE_DIR/numstat" +cleanup_measurement() { + rm -rf -- "$MEASURE_DIR" +} +trap cleanup_measurement EXIT + +if ! preflight_git init --bare --quiet --template= --object-format="$OBJECT_FORMAT" \ + "$MEASURE_GIT_DIR" 2>/dev/null; then + measure_error \ + "cannot initialize isolated storage for measurements" \ + "Verify that the temporary directory is writable, then rerun with the same refs." +fi + +measure_git() { + isolated_git "$LOCAL_COMMIT" "$OBJECTS_DIR" \ + --no-replace-objects --git-dir="$MEASURE_GIT_DIR" \ + -c core.bigFileThreshold=512m "$@" +} + +if ! MERGE_BASES=$(measure_git merge-base --all "$UPSTREAM_COMMIT" "$LOCAL_COMMIT" 2>/dev/null); then + measure_error \ + "the refs have no common commit" \ + "Choose local and upstream refs from the same repository history." +fi +MERGE_BASE_COUNT=$(printf '%s\n' "$MERGE_BASES" | awk 'NF { count += 1 } END { print count + 0 }') +case "$MERGE_BASE_COUNT" in + 1) MERGE_BASE=$MERGE_BASES ;; + 0) + measure_error \ + "the refs have no common commit" \ + "Choose local and upstream refs from the same repository history." + ;; + *) + measure_error \ + "the refs have multiple merge bases, so the diff basis is ambiguous" \ + "Converge the histories to one merge base, then rerun with the same refs." + ;; +esac + +if ! GRAPH_COUNTS=$(measure_git rev-list --left-right --count "$UPSTREAM_COMMIT...$LOCAL_COMMIT" 2>/dev/null); then + measure_error \ + "cannot count commits between the resolved refs" \ + "Verify the repository object database, then rerun with the same refs." +fi +read -r BEHIND AHEAD <<< "$GRAPH_COUNTS" + +if ! FIRST_PARENT_DELIVERIES=$(measure_git rev-list --first-parent --count "$UPSTREAM_COMMIT..$LOCAL_COMMIT" 2>/dev/null); then + measure_error \ + "cannot count first-parent local deliveries" \ + "Verify the repository object database, then rerun with the same refs." +fi + +CHANGED_FILES=0 +INSERTIONS=0 +DELETIONS=0 + +AGENT_FILES=0 +AGENT_INSERTIONS=0 +AGENT_DELETIONS=0 +AUTOMATION_FILES=0 +AUTOMATION_INSERTIONS=0 +AUTOMATION_DELETIONS=0 +TEST_FILES=0 +TEST_INSERTIONS=0 +TEST_DELETIONS=0 +DOC_FILES=0 +DOC_INSERTIONS=0 +DOC_DELETIONS=0 +CONFIG_FILES=0 +CONFIG_INSERTIONS=0 +CONFIG_DELETIONS=0 +OTHER_FILES=0 +OTHER_INSERTIONS=0 +OTHER_DELETIONS=0 + +if ! measure_git diff --numstat -z --no-renames --no-ext-diff --no-textconv \ + --diff-algorithm=myers --no-indent-heuristic "$MERGE_BASE" "$LOCAL_COMMIT" \ + > "$NUMSTAT_FILE" 2>/dev/null; then + measure_error \ + "cannot calculate diff metrics between the resolved refs" \ + "Verify that all repository objects are available locally, then rerun with the same refs." +fi + +while IFS= read -r -d '' record; do + added=${record%%$'\t'*} + remainder=${record#*$'\t'} + deleted=${remainder%%$'\t'*} + path=${remainder#*$'\t'} + [ "$added" = "-" ] && added=0 + [ "$deleted" = "-" ] && deleted=0 + CHANGED_FILES=$((CHANGED_FILES + 1)) + INSERTIONS=$((INSERTIONS + added)) + DELETIONS=$((DELETIONS + deleted)) + case "$path" in + AGENTS.md|CLAUDE.md|.agents/*|skills/*) + AGENT_FILES=$((AGENT_FILES + 1)) + AGENT_INSERTIONS=$((AGENT_INSERTIONS + added)) + AGENT_DELETIONS=$((AGENT_DELETIONS + deleted)) + ;; + bin/*|.github/*|.claude/*|.codex/*|.opencode/*|.pi/*) + AUTOMATION_FILES=$((AUTOMATION_FILES + 1)) + AUTOMATION_INSERTIONS=$((AUTOMATION_INSERTIONS + added)) + AUTOMATION_DELETIONS=$((AUTOMATION_DELETIONS + deleted)) + ;; + tests/*) + TEST_FILES=$((TEST_FILES + 1)) + TEST_INSERTIONS=$((TEST_INSERTIONS + added)) + TEST_DELETIONS=$((TEST_DELETIONS + deleted)) + ;; + docs/*|README.md|CONTRIBUTING.md) + DOC_FILES=$((DOC_FILES + 1)) + DOC_INSERTIONS=$((DOC_INSERTIONS + added)) + DOC_DELETIONS=$((DOC_DELETIONS + deleted)) + ;; + .tasks.toml|.no-mistakes.yaml|.gitignore) + CONFIG_FILES=$((CONFIG_FILES + 1)) + CONFIG_INSERTIONS=$((CONFIG_INSERTIONS + added)) + CONFIG_DELETIONS=$((CONFIG_DELETIONS + deleted)) + ;; + *) + OTHER_FILES=$((OTHER_FILES + 1)) + OTHER_INSERTIONS=$((OTHER_INSERTIONS + added)) + OTHER_DELETIONS=$((OTHER_DELETIONS + deleted)) + ;; + esac +done < "$NUMSTAT_FILE" + +printf 'schema: %s\n' "$(toon_quote "fm-convergence-scoreboard.v1")" +printf 'local:\n' +printf ' ref: %s\n' "$(toon_quote "$LOCAL_REF")" +printf ' commit: %s\n' "$(toon_quote "$LOCAL_COMMIT")" +printf 'upstream:\n' +printf ' ref: %s\n' "$(toon_quote "$UPSTREAM_REF")" +printf ' commit: %s\n' "$(toon_quote "$UPSTREAM_COMMIT")" +printf 'commits:\n' +printf ' ahead: %s\n' "$AHEAD" +printf ' behind: %s\n' "$BEHIND" +printf ' first_parent_deliveries: %s\n' "$FIRST_PARENT_DELIVERIES" +printf 'diff:\n' +printf ' base_commit: %s\n' "$(toon_quote "$MERGE_BASE")" +printf ' changed_files: %s\n' "$CHANGED_FILES" +printf ' insertions: %s\n' "$INSERTIONS" +printf ' deletions: %s\n' "$DELETIONS" +printf ' net_lines: %s\n' "$((INSERTIONS - DELETIONS))" +printf 'file_groups[6]{name,changed_files,insertions,deletions,net_lines}:\n' +printf ' %s,%s,%s,%s,%s\n' \ + "$(toon_quote "agent-runtime")" "$AGENT_FILES" "$AGENT_INSERTIONS" "$AGENT_DELETIONS" \ + "$((AGENT_INSERTIONS - AGENT_DELETIONS))" +printf ' %s,%s,%s,%s,%s\n' \ + "$(toon_quote "automation")" "$AUTOMATION_FILES" "$AUTOMATION_INSERTIONS" "$AUTOMATION_DELETIONS" \ + "$((AUTOMATION_INSERTIONS - AUTOMATION_DELETIONS))" +printf ' %s,%s,%s,%s,%s\n' \ + "$(toon_quote "tests")" "$TEST_FILES" "$TEST_INSERTIONS" "$TEST_DELETIONS" \ + "$((TEST_INSERTIONS - TEST_DELETIONS))" +printf ' %s,%s,%s,%s,%s\n' \ + "$(toon_quote "documentation")" "$DOC_FILES" "$DOC_INSERTIONS" "$DOC_DELETIONS" \ + "$((DOC_INSERTIONS - DOC_DELETIONS))" +printf ' %s,%s,%s,%s,%s\n' \ + "$(toon_quote "configuration")" "$CONFIG_FILES" "$CONFIG_INSERTIONS" "$CONFIG_DELETIONS" \ + "$((CONFIG_INSERTIONS - CONFIG_DELETIONS))" +printf ' %s,%s,%s,%s,%s' \ + "$(toon_quote "other")" "$OTHER_FILES" "$OTHER_INSERTIONS" "$OTHER_DELETIONS" \ + "$((OTHER_INSERTIONS - OTHER_DELETIONS))" diff --git a/docs/scripts.md b/docs/scripts.md index cb602cd7bf3..d4db02373dc 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -16,6 +16,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-fleet-snapshot.sh` | Print the read-only structured fleet snapshot JSON (schema `fm-fleet-snapshot.v1`) | | `fm-fleet-view.sh` | Render the fleet snapshot as a human Markdown view | | `fm-bearings-snapshot.sh` | Project the fleet snapshot to the compact TOON bearings view; local-only unless `--include-prs` | +| `fm-convergence-scoreboard.sh` | Measure one clean local ref against one explicit upstream ref as deterministic TOON | | `fm-update.sh` | Fast-forward-only self-update of firstmate and secondmate homes from the publish remote | | `fm-backlog-handoff.sh` | Validate and delegate queued backlog-item moves into a secondmate home | | `fm-decision-hold.sh` | Create, verify, complete, and resolve durable captain-held decisions | @@ -97,3 +98,14 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-x-dismiss.sh` | Dismiss a skipped X-mode mention at the relay without replying | | `fm-x-link.sh` | Link a spawned task to its originating X-mode mention in task meta | | `fm-x-followup.sh` | Detect, post, and cap completion follow-ups for an X-mode-linked task | + +## Convergence scoreboard + +Run `bin/fm-convergence-scoreboard.sh ` from a clean worktree before convergence starts and after every convergence step. +Keep both ref arguments explicit and record the emitted commit identities with the measurements so a result can be reproduced after branch names move. +Use `--help` for the current origin/upstream examples, and fully qualify any ref name that collides across namespaces. +The command emits deterministic TOON and fails instead of returning partial metrics when it cannot complete a measurement. + +Stated limitation: the cleanliness preflight assumes repo-local `.git/info/attributes`, filter configuration, and fsmonitor configuration are benign ambient inputs; they can affect whether the command proceeds, but they do not enter the isolated ref measurement. + +The script header owns the exact measurement basis, isolation guarantees, metric meanings, path membership, output schema, and exit contract. diff --git a/tests/fm-convergence-scoreboard.test.sh b/tests/fm-convergence-scoreboard.test.sh new file mode 100755 index 00000000000..88d1e4eef19 --- /dev/null +++ b/tests/fm-convergence-scoreboard.test.sh @@ -0,0 +1,393 @@ +#!/usr/bin/env bash +# Behavior tests for the deterministic, read-only convergence scoreboard. +set -u + +# shellcheck source=tests/lib.sh +# shellcheck disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +SCOREBOARD="$ROOT/bin/fm-convergence-scoreboard.sh" +TMP_ROOT=$(fm_test_tmproot fm-convergence-scoreboard) +fm_git_identity + +make_fixture() { + local repo="$TMP_ROOT/repo" + mkdir -p "$repo/bin" "$repo/docs" + git -C "$repo" init -q -b main + printf 'base\n' > "$repo/AGENTS.md" + printf 'old\nstay\n' > "$repo/bin/tool.sh" + printf 'remove\n' > "$repo/docs/guide.md" + git -C "$repo" add AGENTS.md bin/tool.sh docs/guide.md + git -C "$repo" commit -qm "chore: seed fixture" + git -C "$repo" branch upstream + + git -C "$repo" checkout -qb local + printf 'base\nlocal\n' > "$repo/AGENTS.md" + printf 'new\nstay\n' > "$repo/bin/tool.sh" + mkdir -p "$repo/tests" + printf 'one\ntwo\n' > "$repo/tests/example.test.sh" + git -C "$repo" rm -q docs/guide.md + git -C "$repo" add AGENTS.md bin/tool.sh tests/example.test.sh + git -C "$repo" commit -qm "feat: add local delivery" + printf 'version = 1\n' > "$repo/.tasks.toml" + printf 'other\n' > "$repo/misc.txt" + git -C "$repo" add .tasks.toml misc.txt + git -C "$repo" commit -qm "fix: finish local delivery" + + git -C "$repo" checkout -q upstream + printf 'upstream\n' > "$repo/upstream.txt" + git -C "$repo" add upstream.txt + git -C "$repo" commit -qm "feat: advance upstream" + git -C "$repo" checkout -q local + printf '%s\n' "$repo" +} + +run_scoreboard() { + local repo=$1 + shift + ( + cd "$repo" || exit 1 + "$SCOREBOARD" "$@" + ) +} + +assert_no_trailing_newline() { + local file=$1 label=$2 last_byte + [ -s "$file" ] || fail "$label was empty" + last_byte=$(tail -c 1 "$file" | od -An -tuC | tr -d '[:space:]') + [ "$last_byte" != "10" ] || fail "$label ended with a newline" +} + +test_deterministic_scoreboard() { + local repo local_sha upstream_sha base_sha before after out rerun expected + repo=$(make_fixture) + local_sha=$(git -C "$repo" rev-parse local) + upstream_sha=$(git -C "$repo" rev-parse upstream) + base_sha=$(git -C "$repo" merge-base upstream local) + before=$(git -C "$repo" show-ref) + + out=$(run_scoreboard "$repo" local upstream) + rerun=$(run_scoreboard "$repo" local upstream) + after=$(git -C "$repo" show-ref) + + expected=$(printf '%s\n' \ + 'schema: "fm-convergence-scoreboard.v1"' \ + 'local:' \ + ' ref: "local"' \ + " commit: \"$local_sha\"" \ + 'upstream:' \ + ' ref: "upstream"' \ + " commit: \"$upstream_sha\"" \ + 'commits:' \ + ' ahead: 2' \ + ' behind: 1' \ + ' first_parent_deliveries: 2' \ + 'diff:' \ + " base_commit: \"$base_sha\"" \ + ' changed_files: 6' \ + ' insertions: 6' \ + ' deletions: 2' \ + ' net_lines: 4' \ + 'file_groups[6]{name,changed_files,insertions,deletions,net_lines}:' \ + ' "agent-runtime",1,1,0,1' \ + ' "automation",1,1,1,0' \ + ' "tests",1,2,0,2' \ + ' "documentation",1,0,1,-1' \ + ' "configuration",1,1,0,1' \ + ' "other",1,1,0,1') + + [ "$out" = "$expected" ] || fail "scoreboard output differs from the deterministic TOON contract:$'\n'$out" + [ "$rerun" = "$out" ] || fail "identical refs produced different scoreboard output" + [ "$after" = "$before" ] || fail "scoreboard mutated repository refs" + [ -z "$(git -C "$repo" status --porcelain)" ] || fail "scoreboard dirtied the fixture worktree" + pass "scoreboard reports reproducible graph, delivery, diff, and file-group metrics without mutation" +} + +test_attributes_are_scoped_to_local_ref() { + local repo expected from_worktree from_info + repo=$TMP_ROOT/repo + + git -C "$repo" checkout -qb local-attributed local + printf 'misc.txt -diff\n' > "$repo/.gitattributes" + git -C "$repo" add .gitattributes + git -C "$repo" commit -qm "test: add local measurement attributes" + expected=$(run_scoreboard "$repo" local-attributed upstream) + assert_contains "$expected" ' changed_files: 7' \ + "the local commit attribute fixture should add one changed file" + assert_contains "$expected" ' "other",2,1,0,1' \ + "the local commit should classify its marked binary path with zero line changes" + + git -C "$repo" checkout -qb ambient-attributes upstream + printf '* -diff\n' > "$repo/.gitattributes" + git -C "$repo" add .gitattributes + git -C "$repo" commit -qm "test: add unrelated worktree attributes" + from_worktree=$(run_scoreboard "$repo" local-attributed upstream) + [ "$from_worktree" = "$expected" ] \ + || fail "ambient worktree attributes changed metrics for the same explicit refs" + + printf '* diff\n' > "$repo/.git/info/attributes" + from_info=$(run_scoreboard "$repo" local-attributed upstream) + [ "$from_info" = "$expected" ] \ + || fail "ambient info attributes changed metrics for the same explicit refs" + rm -f "$repo/.git/info/attributes" + git -C "$repo" checkout -q local + pass "scoreboard derives attributes from the explicit local ref, not ambient repository state" +} + +test_tab_prefixed_path_is_not_documentation() { + local repo tab_path out + repo=$TMP_ROOT/repo + tab_path=$'\tdocs/tab-prefixed.md' + + git -C "$repo" checkout -qb tab-path local + mkdir -p "$repo/${tab_path%/*}" + printf 'tab path\n' > "$repo/$tab_path" + git -C "$repo" add -- "$tab_path" + git -C "$repo" commit -qm "test: add tab-prefixed path" + + out=$(run_scoreboard "$repo" tab-path upstream) + assert_contains "$out" ' "documentation",1,0,1,-1' \ + "a leading tab must not turn a non-documentation path into documentation" + assert_contains "$out" ' "other",2,2,0,2' \ + "a tab-prefixed docs path should remain in the other group" + git -C "$repo" checkout -q local + pass "scoreboard preserves leading tabs when classifying changed paths" +} + +test_git_environment_is_allowlisted() { + local repo decoy expected out shim_dir real_git source_format ambient_format + local ambient_shallow ambient_graft + repo=$TMP_ROOT/repo + decoy=$TMP_ROOT/decoy + shim_dir=$TMP_ROOT/git-shim + ambient_shallow=$TMP_ROOT/ambient-shallow + ambient_graft=$TMP_ROOT/ambient-graft + real_git=$(command -v git) + source_format=$(git -C "$repo" rev-parse --show-object-format=storage) + case "$source_format" in + sha1) ambient_format=sha256 ;; + sha256) ambient_format=sha1 ;; + *) fail "unsupported fixture object format: $source_format" ;; + esac + + git init -q -b main "$decoy" + printf 'decoy\n' > "$decoy/README.md" + git -C "$decoy" add README.md + git -C "$decoy" commit -qm "test: seed decoy" + git -C "$repo" rev-parse local > "$ambient_shallow" + printf '%s %s\n' \ + "$(git -C "$repo" rev-parse local)" \ + "$(git -C "$repo" rev-parse upstream)" \ + > "$ambient_graft" + mkdir -p "$shim_dir" + { + printf '%s\n' '#!/usr/bin/env bash' + printf '%s\n' "[ \"\${GIT_OPTIONAL_LOCKS-}\" = 0 ] || exit 91" + printf '%s\n' "[ \"\${GIT_NO_LAZY_FETCH-}\" = 1 ] || exit 92" + printf '%s\n' "[ -z \"\${GIT_NAMESPACE-}\" ] || exit 93" + printf '%s\n' "[ -z \"\${SCOREBOARD_AMBIENT_SENTINEL-}\" ] || exit 94" + printf '%s\n' "[ -z \"\${REAL_GIT-}\" ] || exit 95" + printf "[ \"\${HOME-}\" = %q ] || exit 96\n" "${HOME-}" + printf 'exec %q "$@"\n' "$real_git" + } > "$shim_dir/git" + chmod +x "$shim_dir/git" + + expected=$(run_scoreboard "$repo" local upstream) + out=$( + cd "$repo" || exit 1 + PATH="$shim_dir:$PATH" \ + REAL_GIT="$real_git" \ + GIT_DIR="$decoy/.git" \ + GIT_WORK_TREE="$decoy" \ + GIT_CONFIG_PARAMETERS="'core.warnAmbiguousRefs'='false'" \ + GIT_SHALLOW_FILE="$ambient_shallow" \ + GIT_GRAFT_FILE="$ambient_graft" \ + GIT_DEFAULT_HASH="$ambient_format" \ + GIT_NAMESPACE=ambient \ + GIT_OPTIONAL_LOCKS=1 \ + GIT_NO_LAZY_FETCH=0 \ + SCOREBOARD_AMBIENT_SENTINEL=present \ + "$SCOREBOARD" local upstream + ) + + [ "$out" = "$expected" ] \ + || fail "ambient Git repository or mutation settings changed the explicit-ref measurement" + pass "scoreboard allowlists the complete Git execution environment" +} + +test_fail_closed_invocation() { + local repo out err rc + repo=$TMP_ROOT/repo + + set +e + out=$(run_scoreboard "$repo" 2>"$TMP_ROOT/no-args.err") + rc=$? + set -e + err=$(cat "$TMP_ROOT/no-args.err") + expect_code 2 "$rc" "missing refs" + assert_contains "$out" 'error: "expected exactly and "' \ + "missing refs should emit one structured usage error" + assert_contains "$out" 'usage: "bin/fm-convergence-scoreboard.sh "' \ + "usage failure should include the complete correction" + [ -z "$err" ] || fail "usage failure leaked diagnostics to stderr: $err" + + set +e + out=$(run_scoreboard "$repo" --unknown upstream 2>"$TMP_ROOT/unknown.err") + rc=$? + set -e + expect_code 2 "$rc" "unknown flag" + assert_contains "$out" 'error: "unknown flag or invalid local ref: --unknown"' \ + "unknown flag should be named" + + git -C "$repo" branch ambiguous local + git -C "$repo" tag ambiguous local + set +e + out=$(run_scoreboard "$repo" ambiguous upstream 2>"$TMP_ROOT/ambiguous.err") + rc=$? + set -e + err=$(cat "$TMP_ROOT/ambiguous.err") + expect_code 1 "$rc" "ambiguous local ref" + assert_contains "$out" 'error: "local ref name is ambiguous: ambiguous"' \ + "ambiguous local ref should fail instead of following Git ref precedence" + assert_contains "$out" 'Use a fully qualified ref such as refs/heads/' \ + "ambiguous local ref should provide the deterministic correction" + [ -z "$err" ] || fail "ambiguous ref failure leaked diagnostics to stderr: $err" + set +e + out=$( + cd "$repo" || exit 1 + GIT_CONFIG_PARAMETERS="'core.warnAmbiguousRefs'='false'" \ + "$SCOREBOARD" ambiguous upstream + ) + rc=$? + set -e + expect_code 1 "$rc" "ambiguous local ref with warnings disabled" + assert_contains "$out" 'error: "local ref name is ambiguous: ambiguous"' \ + "ambient warning configuration must not suppress ambiguity rejection" + run_scoreboard "$repo" refs/heads/ambiguous upstream >/dev/null \ + || fail "fully qualified local ref did not resolve the reported ambiguity" + + git -C "$repo" branch ambiguous-upstream upstream + git -C "$repo" tag ambiguous-upstream upstream + set +e + out=$(run_scoreboard "$repo" local ambiguous-upstream) + rc=$? + set -e + expect_code 1 "$rc" "ambiguous upstream ref" + assert_contains "$out" 'error: "upstream ref name is ambiguous: ambiguous-upstream"' \ + "ambiguous upstream ref should fail instead of following Git ref precedence" + + set +e + out=$(run_scoreboard "$repo" missing upstream 2>"$TMP_ROOT/missing.err") + rc=$? + set -e + expect_code 1 "$rc" "missing local ref" + assert_contains "$out" 'local ref is missing, unfetched, or does not resolve to one commit: missing' \ + "missing local ref should be actionable" + assert_contains "$out" 'Fetch or create the local ref explicitly' \ + "missing local ref should provide the correction" + + printf 'dirty\n' > "$repo/untracked.txt" + set +e + out=$(run_scoreboard "$repo" local upstream 2>"$TMP_ROOT/dirty.err") + rc=$? + set -e + expect_code 1 "$rc" "dirty worktree" + assert_contains "$out" 'error: "current worktree is dirty"' \ + "dirty invocation should fail before measuring refs" + rm "$repo/untracked.txt" + pass "scoreboard rejects missing, unknown, unfetched, and dirty invocation state" +} + +test_diff_failure_is_not_silent() { + local repo blob object_path backup out rc + repo=$TMP_ROOT/repo + blob=$(git -C "$repo" rev-parse upstream:docs/guide.md) + object_path="$repo/.git/objects/${blob:0:2}/${blob:2}" + backup="$TMP_ROOT/missing-blob.backup" + [ -f "$object_path" ] || fail "diff failure fixture blob is not loose" + mv "$object_path" "$backup" + + set +e + out=$(run_scoreboard "$repo" local upstream) + rc=$? + set -e + + mv "$backup" "$object_path" + expect_code 1 "$rc" "unavailable diff object" + assert_contains "$out" 'error: "cannot calculate diff metrics between the resolved refs"' \ + "a failed diff must not produce plausible zero metrics" + assert_contains "$out" 'Verify that all repository objects are available locally' \ + "a failed diff should provide the deterministic correction" + pass "scoreboard fails loudly when resolved commits cannot produce diff metrics" +} + +test_toon_documents_have_no_trailing_newline() { + local repo rc + repo=$TMP_ROOT/repo + + run_scoreboard "$repo" local upstream > "$TMP_ROOT/success.toon" + assert_no_trailing_newline "$TMP_ROOT/success.toon" "success output" + + run_scoreboard "$repo" --help > "$TMP_ROOT/help.toon" + assert_no_trailing_newline "$TMP_ROOT/help.toon" "help output" + + set +e + run_scoreboard "$repo" > "$TMP_ROOT/usage-error.toon" + rc=$? + set -e + expect_code 2 "$rc" "usage output" + assert_no_trailing_newline "$TMP_ROOT/usage-error.toon" "usage error output" + + set +e + run_scoreboard "$repo" missing upstream > "$TMP_ROOT/measure-error.toon" + rc=$? + set -e + expect_code 1 "$rc" "measurement error output" + assert_no_trailing_newline "$TMP_ROOT/measure-error.toon" "measurement error output" + pass "scoreboard emits every TOON document without a trailing newline" +} + +test_help_without_home() { + local out err rc + + set +e + out=$(env -u HOME "$SCOREBOARD" --help 2>"$TMP_ROOT/help-unset-home.err") + rc=$? + set -e + err=$(cat "$TMP_ROOT/help-unset-home.err") + + expect_code 0 "$rc" "help without HOME" + assert_contains "$out" "bin: \"$SCOREBOARD\"" \ + "help without HOME should report the absolute executable path" + [ -z "$err" ] || fail "help without HOME leaked diagnostics to stderr: $err" + pass "scoreboard help is deterministic when HOME is unset" +} + +test_toon_control_characters_are_escaped() { + local repo control_ref out rc + repo=$TMP_ROOT/repo + control_ref=$'missing\001\b\f\033\177ref' + + set +e + out=$(run_scoreboard "$repo" "$control_ref" upstream) + rc=$? + set -e + + expect_code 1 "$rc" "control characters in a missing ref" + assert_contains "$out" 'missing\u0001\u0008\u000c\u001b\u007fref' \ + "structured errors should escape non-short-form TOON control characters" + [[ "$out" != *$'\001'* && "$out" != *$'\b'* && "$out" != *$'\f'* \ + && "$out" != *$'\033'* && "$out" != *$'\177'* ]] \ + || fail "structured error output retained a raw control character" + pass "scoreboard escapes every unsupported control character in TOON strings" +} + +test_deterministic_scoreboard +test_attributes_are_scoped_to_local_ref +test_tab_prefixed_path_is_not_documentation +test_git_environment_is_allowlisted +test_fail_closed_invocation +test_diff_failure_is_not_silent +test_toon_documents_have_no_trailing_newline +test_help_without_home +test_toon_control_characters_are_escaped From b5667ce15f0c3c4ae4bd38cb70428d22755e4df5 Mon Sep 17 00:00:00 2001 From: quinnbot-ai Date: Thu, 30 Jul 2026 12:35:15 -0700 Subject: [PATCH 18/70] fix(x-mode): commit the offer marker only after its wake is emitted, and answer each request once (#69) * fix(x-mode): commit the offer marker only after its wake is emitted The poll claimed state/x-context/.offered.json before printing the x-mention wake line, and the marker is what makes every later relay re-offer silent. A poll that stopped in that gap left a marker with no wake: the mention stayed pending in the inbox, every re-offer was discarded, and it was never surfaced again for the rest of the retention window. Claim, emit, then commit. The claim still atomically gates concurrent polls but now records wake_emitted:false, only an emitted marker suppresses a later offer, and reaching the claim with an existing marker means an interrupted offer to recover. An interruption now costs at most one repeated wake instead of a lost mention. A marker with no wake_emitted field counts as emitted, so an upgrade cannot replay an answered mention into a public reply. * fix(x-mode): post one public answer per request_id The poll offers a mention at least once: an offer interrupted before its wake is re-offered rather than silenced, because a repeated wake is recoverable and a silenced mention is not. That makes a repeated wake reachable, and nothing local stopped it from becoming a second public reply - only the relay's own 409 stood between a duplicate wake and a duplicate post. Enforce exactly-once where the public action happens. The initial answer path atomically claims state/x-context/.answered.json before posting and refuses with exit 10 when the request was already answered or an earlier attempt's outcome is unknown. The claim is released whenever the answer definitely did not land, so an ordinary failure stays retryable, and follow-ups keep their relay-side cap instead. * no-mistakes(review): Hold ambiguous X answer claims against duplicate posts * no-mistakes(review): Confirm X answers and surface unresolved claims * no-mistakes(review): Align X responder guidance with 409 handling * no-mistakes(document): Align X-mode durability documentation * no-mistakes(lint): Suppress intentional inner-shell expansion warnings --------- Co-authored-by: QuinnBot --- .agents/skills/fmx-respond/SKILL.md | 9 +- AGENTS.md | 2 +- bin/fm-x-lib.sh | 149 ++++++++++++++- bin/fm-x-poll.sh | 32 +++- bin/fm-x-reply.sh | 107 ++++++++++- docs/architecture.md | 2 +- docs/configuration.md | 12 +- tests/fm-x-mode.test.sh | 286 +++++++++++++++++++++++++++- 8 files changed, 572 insertions(+), 27 deletions(-) diff --git a/.agents/skills/fmx-respond/SKILL.md b/.agents/skills/fmx-respond/SKILL.md index 762afb8d76b..01b7a9168df 100644 --- a/.agents/skills/fmx-respond/SKILL.md +++ b/.agents/skills/fmx-respond/SKILL.md @@ -155,6 +155,10 @@ Treat `state/x-inbox/` as the source of truth and process **every** file you fin ``` (`bin/fm-x-reply.sh -`, reading the reply on stdin, is equally fine.) It echoes the `request_id` and exits 0 on success; non-zero on a failed live post or failed dry-run record. + Exit 10 is not a failure to fix: it means a confirmed marker proves that request was already answered, so the reply was deliberately refused rather than posted twice. + Treat it as an answered request - clear the inbox file as in step 2f and move on, without composing another reply. + Exit 11 means the initial answer outcome is unresolved, either because this attempt was ambiguous or because it found an unconfirmed claim, so do not compose or retry a reply. + Leave the inbox file in place and escalate to firstmate immediately with the stderr detail; the durable answer claim stays held, and a later attempt remains exit 11 until the state is resolved. When the reply carries one real visual artifact, add `--image `: the helper reads one local PNG, JPEG, GIF, WebP, BMP, or TIFF, detects the media type, base64-encodes it, and sends it in the relay's optional `image` object without ever inlining image bytes into the shell command. If the reply auto-splits into a thread, the image rides the first/opener message only. e-skip. **For a skip, dismiss it at the relay instead of replying.** A pure acknowledgment gets no reply, but clearing only the local inbox file is not enough: the relay keeps re-offering that request on every poll until it times out to a polite "offline" auto-reply. So before clearing the file, tell the relay to drop the request: @@ -165,9 +169,10 @@ Treat `state/x-inbox/` as the source of truth and process **every** file you fin It posts nothing, stops the re-offer, and prevents the offline auto-reply; it echoes the `request_id` and exits 0 on success (it honors `FMX_DRY_RUN` like `bin/fm-x-reply.sh`, recording the would-be dismiss to `state/x-outbox/` instead of posting). Do **not** call `bin/fm-x-reply.sh` for a skip. f. **On success (a posted reply, or a relay dismiss for a skip), remove that inbox file:** `rm -f state/x-inbox/.json` (and your temporary reply file). - This is the local idempotency guard - a cleared file is never answered twice. + This completes the local inbox drain; the durable initial-answer marker described in `docs/configuration.md` prevents a repeated wake from becoming a duplicate public reply. For an acknowledged actionable request that spawned a task, this cleanup comes **after** the step 2c link, never before, so the link can copy the reply platform and budget directly from the inbox payload. - g. **On failure** (a non-zero exit from `bin/fm-x-reply.sh` or `bin/fm-x-dismiss.sh`), leave that inbox file in place, move on to the next, and do not retry blindly. + g. **On failure** (a non-zero exit from `bin/fm-x-reply.sh` other than the confirmed-answer exit 10, or a non-zero exit from `bin/fm-x-dismiss.sh`), leave that inbox file in place, move on to the next, and do not retry blindly. + Exit 11 follows the immediate escalation rule in step 2e and must never be retried. If you had already acted on this mention in step 2c before the post failed, do **not** redo that work on a later drain - check whether it is already done (e.g. the backlog item exists, the crewmate is already running) and only retry the reply. If a reply or dismiss fails twice, surface it to the captain as a blocker with the stderr detail; for live post failures include the relay's HTTP status when available. The relay posts its own offline reply if no live answer lands in time, so a single miss is not a crisis. diff --git a/AGENTS.md b/AGENTS.md index f163a906d62..fda565f8666 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,7 +104,7 @@ state/ volatile runtime signals; gitignored ops-watch.check.sh generated operational alert inbox watch, registered like any custom check; present when auto mode finds this home's alert inbox or config explicitly enables the watch, with its dedupe record in .ops-inbox-wake (docs/ops-inbox-wake.md) pending-replies/ parent-owned secondmate pending-reply records (correlation id, delivery vs reply, recovery, escalation); fm-pending-reply-lib.sh x-inbox/ generated X-mode pending mention payloads; fmx-respond drains it (section 14) - x-context/ generated X-mode durable per-request reply context and one-wake offer markers, keyed by request_id; survives inbox cleanup and expires within seven days (section 14; bin/fm-x-lib.sh) + x-context/ generated X-mode durable per-request reply context and delivery markers, keyed by request_id; survives inbox cleanup and expires within seven days (section 14; docs/configuration.md) x-outbox/ generated X-mode dry-run reply and dismiss previews; inspect it when FMX_DRY_RUN is set (section 14) x-poll.error x-poll.claim-error generated X-mode relay and offer-claim diagnostic dedupe markers .wake-queue durable queued wakes: epochseqkindkeypayload diff --git a/bin/fm-x-lib.sh b/bin/fm-x-lib.sh index a8ea57991cd..e50e8102f70 100644 --- a/bin/fm-x-lib.sh +++ b/bin/fm-x-lib.sh @@ -23,7 +23,20 @@ # - persist the durable per-request reply context; # refresh=1 resets its retention timestamp # fmx_offer_registry_claim - atomically claim the durable -# one-wake offer marker; 0=new, 1=existing, 2=error +# offer-delivery marker; 0=new, 1=existing, 2=error +# fmx_offer_registry_commit - record that the claimed +# marker's wake was emitted +# fmx_offer_registry_emitted - true only for a marker +# whose wake was emitted +# fmx_answer_registry_claim - atomically claim the right +# to post one public answer; 0=claimed, +# 1=already answered or unresolved, 2=error +# fmx_answer_registry_confirm - record that the claimed +# public answer received a 2xx response +# fmx_answer_registry_answered - true only for a marker +# whose public answer is confirmed +# fmx_answer_registry_release - drop a claim whose post +# definitely did not land # fmx_context_registry_prune - remove records older than seven days # fmx_context_registry_get - read the durable per-request # reply context, or the empty shape when absent @@ -535,11 +548,16 @@ fmx_context_registry_set() { } # fmx_offer_registry_claim : atomically claim the durable -# one-wake marker at state/x-context/.offered.json. The marker uses +# offer-delivery marker at state/x-context/.offered.json. It uses # the context registry's recorded_at retention contract, so its first claim # survives inbox cleanup and expires with the relay's bounded follow-up window. # Returns 0 only to the caller that created the marker, 1 when a valid marker # already exists, and 2 on invalid input or a publication failure. +# +# The claim records wake_emitted:false because claiming is not offering: the +# offer only exists once the wake line is written. fmx_offer_registry_commit +# flips that field afterwards, and only a committed marker suppresses a later +# relay re-offer, so an interrupted claim cannot silence the mention. fmx_offer_registry_claim() { local state=$1 rid=$2 dir now record rc case "$rid" in @@ -552,7 +570,7 @@ fmx_offer_registry_claim() { esac [ "${#now}" -le 18 ] || return 2 record=$(jq -cn --arg rid "$rid" --argjson recorded_at "$now" \ - '{request_id:$rid, recorded_at:$recorded_at}') || return 2 + '{request_id:$rid, recorded_at:$recorded_at, wake_emitted:false}') || return 2 dir="$state/x-context" printf '%s\n' "$record" \ | fmx_private_artifact_publish_stdin_once "$dir" "$rid.offered.json" 600 @@ -560,6 +578,131 @@ fmx_offer_registry_claim() { return "$rc" } +# fmx_offer_registry_commit : record that the claimed +# marker's wake was emitted, preserving the claim's recorded_at so retention +# still runs from the first claim. Callers commit only after the wake line is +# written, so a failure here costs one repeated offer rather than a lost +# mention. Returns non-zero when no valid marker exists or the write fails. +fmx_offer_registry_commit() { + local state=$1 rid=$2 dir file now recorded_at + case "$rid" in + ''|.*|*[!A-Za-z0-9._-]*) return 1 ;; + esac + dir="$state/x-context" + file="$dir/$rid.offered.json" + fmx_private_artifact_file_valid "$dir" "$rid.offered.json" 600 || return 1 + now=${FMX_NOW_OVERRIDE:-$(date +%s)} + case "$now" in + ''|*[!0-9]*) return 1 ;; + esac + [ "${#now}" -le 18 ] || return 1 + recorded_at=$(fmx_context_registry_recorded_at "$file" "$now") || recorded_at=$now + (set -o pipefail; jq -cn --arg rid "$rid" --argjson recorded_at "$recorded_at" \ + '{request_id:$rid, recorded_at:$recorded_at, wake_emitted:true}' \ + | fmx_private_artifact_publish_stdin "$dir" "$rid.offered.json" 600) || return 1 +} + +# fmx_offer_registry_emitted : true only when a valid marker +# records an emitted wake. A marker written before this field existed is treated +# as emitted, so upgrading firstmate never replays a mention that was already +# answered; an unreadable marker is treated as not emitted, which at worst +# repeats one offer and lets the retention prune clear the record. +fmx_offer_registry_emitted() { + local state=$1 rid=$2 dir emitted + case "$rid" in + ''|.*|*[!A-Za-z0-9._-]*) return 1 ;; + esac + dir="$state/x-context" + fmx_private_artifact_file_valid "$dir" "$rid.offered.json" 600 || return 1 + emitted=$(jq -r 'if has("wake_emitted") then (.wake_emitted == true) else true end' \ + "$dir/$rid.offered.json" 2>/dev/null) || return 1 + [ "$emitted" = true ] +} + +# fmx_answer_registry_claim : atomically claim the durable +# answered marker at state/x-context/.answered.json before posting a +# public answer. The poll offers a mention at least once - an interrupted offer +# is deliberately re-offered rather than silenced - so exactly-once has to be +# enforced where the public action happens, not where the wake is produced. +# The initial marker records answered:false, which grants its creator permission +# to post but does not claim the answer landed. Returns 0 only to the caller that +# created the marker, 1 when a request has already been claimed, and 2 on invalid +# input or a publication failure. Callers must refuse to post on anything but 0. +# The marker shares the context registry's retention, so it covers the relay's +# whole follow-up window. +fmx_answer_registry_claim() { + local state=$1 rid=$2 dir now record rc + case "$rid" in + ''|.*|*[!A-Za-z0-9._-]*) return 2 ;; + esac + fmx_context_registry_prune "$state" + now=${FMX_NOW_OVERRIDE:-$(date +%s)} + case "$now" in + ''|*[!0-9]*) return 2 ;; + esac + [ "${#now}" -le 18 ] || return 2 + record=$(jq -cn --arg rid "$rid" --argjson recorded_at "$now" \ + '{request_id:$rid, recorded_at:$recorded_at, answered:false}') || return 2 + dir="$state/x-context" + printf '%s\n' "$record" \ + | fmx_private_artifact_publish_stdin_once "$dir" "$rid.answered.json" 600 + rc=$? + return "$rc" +} + +# fmx_answer_registry_confirm : record that the claimed +# answer received a 2xx response, preserving the claim's recorded_at so +# retention still runs from the first claim. Returns non-zero when no valid +# marker exists or the write fails. +fmx_answer_registry_confirm() { + local state=$1 rid=$2 dir file now recorded_at + case "$rid" in + ''|.*|*[!A-Za-z0-9._-]*) return 1 ;; + esac + dir="$state/x-context" + file="$dir/$rid.answered.json" + fmx_private_artifact_file_valid "$dir" "$rid.answered.json" 600 || return 1 + now=${FMX_NOW_OVERRIDE:-$(date +%s)} + case "$now" in + ''|*[!0-9]*) return 1 ;; + esac + [ "${#now}" -le 18 ] || return 1 + recorded_at=$(fmx_context_registry_recorded_at "$file" "$now") || recorded_at=$now + (set -o pipefail; jq -cn --arg rid "$rid" --argjson recorded_at "$recorded_at" \ + '{request_id:$rid, recorded_at:$recorded_at, answered:true}' \ + | fmx_private_artifact_publish_stdin "$dir" "$rid.answered.json" 600) || return 1 +} + +# fmx_answer_registry_answered : true only when a valid +# marker records a confirmed answer. A missing answered field is unresolved, +# because legacy claim markers cannot prove that their post landed. +fmx_answer_registry_answered() { + local state=$1 rid=$2 dir answered + case "$rid" in + ''|.*|*[!A-Za-z0-9._-]*) return 1 ;; + esac + dir="$state/x-context" + fmx_private_artifact_file_valid "$dir" "$rid.answered.json" 600 || return 1 + answered=$(jq -r '.answered == true' "$dir/$rid.answered.json" 2>/dev/null) || return 1 + [ "$answered" = true ] +} + +# fmx_answer_registry_release : drop a claim whose post +# definitely did not land, so the answer can be retried. Only a definite failure +# releases it; an unknown outcome must keep the claim so the public surface +# never gets a second post from an automatic retry. Idempotent when the marker is +# absent and non-zero when the marker cannot be safely removed. +fmx_answer_registry_release() { + local state=$1 rid=$2 dir + case "$rid" in + ''|.*|*[!A-Za-z0-9._-]*) return 1 ;; + esac + dir="$state/x-context" + [ -e "$dir" ] || return 0 + [ -d "$dir" ] && [ ! -L "$dir" ] || return 1 + rm -f "$dir/$rid.answered.json" 2>/dev/null +} + # fmx_context_registry_get : print the durable per-request # reply context as {"platform":"...","reply_max_chars":"..."} (the same shape as # the inbox and relay extractors), or the empty shape when no record exists. diff --git a/bin/fm-x-poll.sh b/bin/fm-x-poll.sh index db7c1ebba01..fb12e769cf8 100755 --- a/bin/fm-x-poll.sh +++ b/bin/fm-x-poll.sh @@ -14,9 +14,10 @@ # a newly offered mention with non-empty text -> stash the full object to # state/x-inbox/.json, record the durable per-request reply # context to state/x-context/.json (best-effort), atomically -# claim state/x-context/.offered.json, and print one compact -# line "x-mention " (which becomes the watcher wake payload) -# an already offered request_id -> print nothing, exit 0 +# claim state/x-context/.offered.json, print one compact line +# "x-mention " (which becomes the watcher wake payload), and +# only then commit that marker as emitted +# a request_id with an emitted wake marker -> print nothing, exit 0 # The full object is stashed verbatim, so any conversation context the relay # includes (in_reply_to: {author_handle, text}, null for a fresh mention) is # preserved for fmx-respond to handle follow-ups with continuity. The durable @@ -122,7 +123,9 @@ esac # successful answer or dismiss. Checking it before the inbox stash keeps both a # still-pending request and the relay's brief post-answer re-offer silent without # recreating a drained inbox. The startup prune above bounds marker retention. -if fmx_private_artifact_file_valid "$STATE/x-context" "$REQ.offered.json" 600; then +# Only a marker whose wake was actually emitted suppresses the re-offer: a marker +# left behind by an interrupted offer below must fall through to be re-offered. +if fmx_offer_registry_emitted "$STATE" "$REQ"; then clear_error clear_claim_error exit 0 @@ -151,10 +154,27 @@ if [ -n "$POLL_CTX" ]; then fmx_context_registry_set "$STATE" "$REQ" "$POLL_PLATFORM" "$POLL_MAX" 2>/dev/null || true fi +# Claim, emit, then commit. The claim stays first because it is the atomic gate +# that stops two polls from offering one request, but it deliberately records an +# UNEMITTED marker: committing before the wake is written would let any death in +# that gap leave a marker that swallows every later relay re-offer above, losing +# the mention for the rest of the retention window with no trace. In this order +# an interrupted offer costs at most one repeated wake - the noise the marker +# exists to trim - instead of a silently dropped mention. fmx_offer_registry_claim "$STATE" "$REQ" offer_rc=$? case "$offer_rc" in - 0) clear_error; clear_claim_error; printf 'x-mention %s\n' "$REQ" ;; - 1) clear_error; clear_claim_error; exit 0 ;; + # 1 is an unemitted marker from an interrupted earlier offer, because the check + # above already returned for an emitted one; re-offer it rather than exiting. + 0|1) clear_error; clear_claim_error ;; *) emit_claim_error_once "cannot record mention offer"; exit 0 ;; esac +# One line of output per poll is the watcher's wake-payload contract, so a failed +# commit stays silent here: its only cost is the next cycle re-offering. +# This closes the poll's own window, not every window: the watcher reads this +# output, deletes the capture, and only then appends the durable wake record, so +# a watcher that dies in that gap still loses the wake. Closing that one belongs +# in the watcher's check dispatch, not here. Because a wake can also be repeated, +# the single public answer per request is enforced in bin/fm-x-reply.sh. +printf 'x-mention %s\n' "$REQ" || exit 0 +fmx_offer_registry_commit "$STATE" "$REQ" || true diff --git a/bin/fm-x-reply.sh b/bin/fm-x-reply.sh index 44e85cb469e..eee0c179f59 100755 --- a/bin/fm-x-reply.sh +++ b/bin/fm-x-reply.sh @@ -30,8 +30,21 @@ # binds the reply to the exact post it recorded for that request_id, so this # client only ever echoes the relay-issued request_id and NEVER names a platform # message id. -# On success it echoes ONLY that request_id; on a non-2xx (or transport failure) -# it exits non-zero so the caller knows the post did not land. The confirmed +# On success it echoes ONLY that request_id. +# A relay non-2xx other than 409 proves the post did not land, but a 409, +# transport failure, or unreadable HTTP status leaves the outcome unknown. +# +# One public answer per request_id is enforced here, because this is the only +# place an answer is posted. The poll deliberately offers a mention AT LEAST +# once (an interrupted offer is re-offered rather than silenced), so a repeated +# wake can reach this client twice for one request. The initial answer path +# atomically claims state/x-context/.answered.json before posting and +# confirms the marker only after a 2xx response. A confirmed duplicate REFUSES +# with exit 10; an unconfirmed claim REFUSES with exit 11 because the earlier +# attempt's outcome is unknown. The claim is released whenever the answer +# definitely did not land, so an ordinary failure stays retryable; a 2xx and 409 +# keep it. Follow-ups are legitimately repeated and are governed by the relay's +# cap instead. The confirmed # relay contract for an exhausted follow-up binding is HTTP 409 from # /connector/followup, optionally with {"error":"followup_unavailable"} in the # response body. This client always maps a follow-up 409 to exit code 9 so @@ -40,6 +53,10 @@ # relay-side 409 is secondary: after the relay's own cleanup sweep, a very-late # call can instead see a benign no-op 200, so fm-x-followup.sh's local # window/cap pruning remains the primary guard. +# Exit 11 is reserved for an initial answer whose outcome is unknown after an +# ambiguous transport failure, 409 response, unreadable HTTP status, or an +# existing unconfirmed claim. +# Its answer claim stays held, and a later attempt also refuses with exit 11. # # Reply platform + split budget are resolved per axis: an explicit # FMX_REPLY_PLATFORM / FMX_REPLY_MAX_CHARS env override wins (fm-x-followup passes @@ -102,6 +119,12 @@ reply_make_tmp_file() { printf -v "$var_name" '%s' "$file" } +release_answer_claim() { + fmx_answer_registry_release "$STATE" "$REQ" && return 0 + echo "fm-x-reply: the answer claim for $REQ could not be released; a later attempt for this request will refuse until it is cleared" >&2 + return 1 +} + usage() { echo "usage: fm-x-reply.sh [--followup] [--image ] | [--followup] [--image ] --text-file | [--followup] [--image ] -" >&2 } @@ -314,20 +337,77 @@ if [ -z "$FMX_TOKEN" ]; then echo "fm-x-reply: X mode not configured (no FMX_PAIRING_TOKEN)" >&2 exit 1 fi +# One public answer per request_id, enforced here because this is the only place +# a public answer is posted. The poll offers a mention AT LEAST once - an +# interrupted offer is re-offered rather than silenced - so a repeated wake can +# genuinely reach this script twice for one request. Claiming before the post +# turns that into a refusal instead of a second public reply. Follow-ups are +# legitimately repeated and keep their own relay-side cap, so the claim covers +# the initial answer only. +if [ "$FOLLOWUP" = 0 ]; then + fmx_answer_registry_claim "$STATE" "$REQ" + answer_claim_rc=$? + case "$answer_claim_rc" in + 0) : ;; + 1) + if fmx_answer_registry_answered "$STATE" "$REQ"; then + echo "fm-x-reply: $REQ was already answered; refusing to post again" >&2 + exit 10 + fi + echo "fm-x-reply: the answer's outcome is unknown for $REQ; its unconfirmed claim is deliberately held, and this attempt will not post" >&2 + exit 11 + ;; + *) + # Refuse rather than post what cannot be recorded: an unrecorded answer is + # exactly the state that lets a later repeated wake answer twice. + echo "fm-x-reply: cannot record the answer claim for $REQ; refusing to post" >&2 + exit 1 + ;; + esac +fi reply_make_tmp_file RESPONSE_BODY_FILE || { - echo "fm-x-reply: cannot create relay response temp file" >&2; exit 1; } + echo "fm-x-reply: cannot create relay response temp file" >&2 + [ "$FOLLOWUP" = 1 ] || release_answer_claim || true + exit 1 +} code=$(fmx_post_json "$ENDPOINT" "$PAYLOAD_FILE" "$RESPONSE_BODY_FILE") post_rc=$? +# An answer claim is released only when the answer provably did not land. +# Failures before transmission and a readable non-2xx other than 409 are +# definite; transport failures and unreadable statuses are ambiguous and keep +# the claim so a later wake cannot post a second public answer. case "$post_rc" in 0) : ;; - 127) echo "fm-x-reply: curl not found" >&2; exit 1 ;; - 3) echo "fm-x-reply: invalid FMX_PAIRING_TOKEN" >&2; exit 1 ;; - *) echo "fm-x-reply: request to relay failed" >&2; exit 1 ;; + 127) + echo "fm-x-reply: curl not found" >&2 + [ "$FOLLOWUP" = 1 ] || release_answer_claim || true + exit 1 + ;; + 2) + echo "fm-x-reply: request payload is unreadable: $PAYLOAD_FILE" >&2 + [ "$FOLLOWUP" = 1 ] || release_answer_claim || true + exit 1 + ;; + 3) + echo "fm-x-reply: invalid FMX_PAIRING_TOKEN" >&2 + [ "$FOLLOWUP" = 1 ] || release_answer_claim || true + exit 1 + ;; + *) + echo "fm-x-reply: request to relay failed" >&2 + if [ "$FOLLOWUP" = 0 ]; then + echo "fm-x-reply: the answer's outcome is unknown; its claim is deliberately held, and a later attempt for this request will refuse with exit 11" >&2 + exit 11 + fi + exit 1 + ;; esac case "$code" in 2[0-9][0-9]) if [ "$FOLLOWUP" = 0 ]; then + fmx_answer_registry_confirm "$STATE" "$REQ" \ + || echo "fm-x-reply: warning: could not confirm the answer marker for $REQ" >&2 fmx_context_registry_set "$STATE" "$REQ" "$REQ_PLATFORM" "$REQ_EXPLICIT_MAX" 1 2>/dev/null \ || echo "fm-x-reply: warning: could not retain reply context for $REQ" >&2 fi @@ -346,7 +426,20 @@ case "$code" in exit 9 fi echo "fm-x-reply: relay returned HTTP $code" >&2 + echo "fm-x-reply: the answer's outcome is unknown; its claim is deliberately held, and a later attempt for this request will refuse with exit 11" >&2 + exit 11 + ;; + [1-5][0-9][0-9]) + echo "fm-x-reply: relay returned HTTP $code" >&2 + [ "$FOLLOWUP" = 1 ] || release_answer_claim || true + exit 1 + ;; + *) + echo "fm-x-reply: relay returned an unreadable HTTP status" >&2 + if [ "$FOLLOWUP" = 0 ]; then + echo "fm-x-reply: the answer's outcome is unknown; its claim is deliberately held, and a later attempt for this request will refuse with exit 11" >&2 + exit 11 + fi exit 1 ;; - *) echo "fm-x-reply: relay returned HTTP $code" >&2; exit 1 ;; esac diff --git a/docs/architecture.md b/docs/architecture.md index 5e0e2439522..63605fb9157 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -207,7 +207,7 @@ Destructive, irreversible, or security-sensitive asks are escalated for trusted- The relay uses owner-only routing: a mention delivered to a home is from that home's owner, while parent-thread context may still include other public accounts. On the locked session-start bootstrap step, that token creates the local polling and watcher-cadence artifacts described in the [X mode configuration reference](configuration.md#x-mode-env). Without the token, the locked session-start bootstrap step removes those artifacts on opt-out and otherwise stays silent, so non-X users see no behavior change. -Newly offered mentions are stored as `state/x-inbox/.json` and wake firstmate once per retained request ID; the [X mode configuration reference](configuration.md#x-mode-env) owns the durable offer-marker and re-offer contract. +Newly offered mentions are stored as `state/x-inbox/.json`; the [X mode configuration reference](configuration.md#x-mode-env) owns the at-least-once wake, durable marker, re-offer, and initial-answer deduplication contracts. The `fmx-respond` agent-only skill drains that inbox, uses `in_reply_to` parent-post context for conversational continuity, classifies each mention as an actionable request, question, or pure acknowledgment, and submits public-safe replies through `bin/fm-x-reply.sh`. When a reply has a real visual artifact, `--image ` attaches one local PNG, JPEG, GIF, WebP, BMP, or TIFF to the relay's optional `{media_type,data_base64}` image object. Actionable reversible requests run through firstmate's normal intake, backlog, dispatch, investigation, or ship lifecycle. diff --git a/docs/configuration.md b/docs/configuration.md index f470dc183ad..60b73c48be4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -330,8 +330,16 @@ Its request handling remains in X-specific `bin/` scripts and the `fmx-respond` `bin/fm-x-poll.sh` calls `GET /connector/poll` with `Authorization: Bearer `. HTTP 204 is silent. -A newly offered pending mention with non-empty `text` is stored at `state/x-inbox/.json` and wakes firstmate exactly once with `x-mention `. -The poll atomically claims `state/x-context/.offered.json` before emitting that wake, and subsequent offers of the same request stay silent even after the inbox is drained following an answer or dismiss. +A newly offered pending mention with non-empty `text` is stored at `state/x-inbox/.json` and wakes firstmate with `x-mention `. +The poll atomically claims `state/x-context/.offered.json` before emitting that wake and marks the marker `wake_emitted` only after the wake line is written, and subsequent offers of the same request stay silent even after the inbox is drained following an answer or dismiss. +Only an emitted marker suppresses a later offer, so a poll that dies between the claim and the wake re-offers the mention on its next cycle instead of silencing it; a marker written before this field existed counts as emitted. +The wake is therefore at least once rather than exactly once, because a repeated wake is recoverable and a silenced mention is not. +Exactly-once is enforced where the public action happens: `bin/fm-x-reply.sh` atomically writes `state/x-context/.answered.json` with `answered:false` before posting an initial answer, then confirms it with `answered:true` only after a 2xx response. +A repeated wake cannot become a second public reply: a confirmed marker refuses with exit 10, while an unconfirmed or legacy marker refuses with exit 11 and leaves the inbox in place for visible escalation because the earlier outcome cannot be proven. +The claim is released only when the answer provably did not land, including failures before transmission and readable non-2xx responses other than 409. +A 409, transport failure, or unreadable HTTP status holds the unconfirmed claim and exits 11, while a failed release emits a warning that later attempts will refuse until the claim is cleared. +The answer claim does not apply to follow-ups, which the relay caps instead. +A residual delivery window remains outside the poll: the watcher reads the poll's output, deletes the capture, and only then appends the durable wake record, so a watcher that dies in that gap still loses the wake. Offer markers share the context registry's bounded seven-day retention, so losing or expiring the local marker lets a relay offer wake firstmate again. The full relay object is preserved, including `in_reply_to: {author_handle, text}` when the mention is a reply in a conversation or `null` for fresh mentions. At the same time the poll records a durable per-request reply context at `state/x-context/.json` (`{request_id, platform, reply_max_chars, recorded_at}`) from the same authoritative relay payload, best-effort and keyed by `request_id` so concurrent requests never overwrite each other; it survives the inbox cleanup that follows the acknowledgement, so a delayed follow-up can recover the original platform and split budget even with no task link. diff --git a/tests/fm-x-mode.test.sh b/tests/fm-x-mode.test.sh index 505860689de..8724c2b8655 100755 --- a/tests/fm-x-mode.test.sh +++ b/tests/fm-x-mode.test.sh @@ -26,6 +26,8 @@ TMP_ROOT=$(fm_test_tmproot fm-x-mode-tests) # FAKE_REQCTX_CODE/FAKE_REQCTX_BODY for the request-context lookup), records each # call to FAKE_CURL_LOG, writes the poll/lookup body to the script's -o file, and # prints the HTTP code to stdout exactly as the real `-w '%{http_code}'` would. +# FAKE_ANSWER_TRANSPORT_FAIL makes only the answer endpoint fail after recording +# the request, without printing an HTTP status. make_fake_curl() { local dir=$1 fakebin fakebin=$(fm_fakebin "$dir") @@ -69,6 +71,8 @@ case "$url" in ;; */connector/answer) [ -n "$ofile" ] && printf '%s' "${FAKE_ANSWER_BODY:-}" > "$ofile" + [ -n "${FAKE_ANSWER_CHMOD_DIR:-}" ] && chmod 500 "$FAKE_ANSWER_CHMOD_DIR" + [ -n "${FAKE_ANSWER_TRANSPORT_FAIL:-}" ] && exit 28 printf '%s' "${FAKE_ANSWER_CODE:-200}" ;; */connector/followup) @@ -346,6 +350,72 @@ test_poll_mentions_wake_once_per_durable_offer() { pass "fm-x-poll wakes once per durable request offer across inbox cleanup" } +# An uncommitted offer marker must not suppress a later relay re-offer. +# Both halves pin the crash window: an offer whose wake could not be written, +# and the durable state a poll killed at the same point leaves behind. +test_poll_reoffers_a_mention_whose_wake_was_never_delivered() { + local home fakebin out rc body + home="$TMP_ROOT/poll-offer-crash-window"; mkdir -p "$home" + fakebin=$(make_fake_curl "$home") + printf 'FMX_PAIRING_TOKEN=tok-crash\n' > "$home/.env" + body='{"request_id":"req-lost","platform":"discord","reply_max_chars":1900,"text":"status?"}' + # Closing stdout makes the wake line undeliverable exactly where a killed poll + # would stop: after the marker exists, before firstmate ever hears the mention. + PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000000 \ + FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \ + "$ROOT/bin/fm-x-poll.sh" >&- 2>/dev/null + assert_present "$home/state/x-inbox/req-lost.json" \ + "an interrupted offer must still leave its stashed mention pending" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000030 \ + FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \ + "$ROOT/bin/fm-x-poll.sh"); rc=$? + expect_code 0 "$rc" "re-offer after an undelivered wake exit" + [ "$out" = "x-mention req-lost" ] \ + || fail "a mention whose wake was never delivered must be re-offered (got: $out)" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000060 \ + FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \ + "$ROOT/bin/fm-x-poll.sh"); rc=$? + expect_code 0 "$rc" "recovered offer dedupe exit" + [ -z "$out" ] || fail "a recovered offer must still wake only once (got: $out)" + [ "$(jq -r .recorded_at "$home/state/x-context/req-lost.offered.json")" = 1700000000 ] \ + || fail "a recovered offer must keep the first claim's retention timestamp" + # The same durable state a poll killed between the two steps leaves behind. + body='{"request_id":"req-killed","platform":"discord","reply_max_chars":1900,"text":"still there?"}' + FMX_NOW_OVERRIDE=1700000090 bash -c \ + '. "$1/bin/fm-x-lib.sh"; fmx_offer_registry_claim "$2/state" req-killed' _ "$ROOT" "$home" \ + || fail "the interrupted-offer fixture must claim its marker" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000120 \ + FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \ + "$ROOT/bin/fm-x-poll.sh"); rc=$? + expect_code 0 "$rc" "re-offer after an interrupted claim exit" + [ "$out" = "x-mention req-killed" ] \ + || fail "a claimed but never offered mention must be re-offered (got: $out)" + pass "fm-x-poll re-offers a mention whose wake never reached firstmate" +} + +# A marker written before the emission field existed came from a path that +# printed its wake immediately, so an upgraded poll must keep honoring it rather +# than replaying an answered mention into a public reply. +test_poll_treats_a_legacy_offer_marker_as_delivered() { + local home fakebin dir out rc body + home="$TMP_ROOT/poll-offer-legacy-marker"; mkdir -p "$home" + fakebin=$(make_fake_curl "$home") + printf 'FMX_PAIRING_TOKEN=tok-legacy\n' > "$home/.env" + dir="$home/state/x-context" + private_artifact_dir "$dir" + jq -cn '{request_id:"req-legacy-offer",recorded_at:1700000000}' > "$dir/req-legacy-offer.offered.json" + private_artifact_file "$dir/req-legacy-offer.offered.json" + body='{"request_id":"req-legacy-offer","platform":"discord","reply_max_chars":1900,"text":"status?"}' + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000030 \ + FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \ + "$ROOT/bin/fm-x-poll.sh"); rc=$? + expect_code 0 "$rc" "legacy offer marker poll exit" + [ -z "$out" ] || fail "a legacy offer marker must keep suppressing the re-offer (got: $out)" + assert_absent "$home/state/x-inbox/req-legacy-offer.json" \ + "a legacy offer marker must not recreate the drained inbox" + pass "fm-x-poll honors an offer marker written before the emission field" +} + test_poll_offer_claim_failure_reports_once() { local home fakebin out rc body home="$TMP_ROOT/poll-offer-claim-failure"; mkdir -p "$home/state" "$home/external-context" @@ -1437,7 +1507,7 @@ test_reply_followup_409_without_marker_still_exits_distinctly() { pass "fm-x-reply maps every follow-up 409 to exit 9 even without the marker" } -test_reply_answer_409_is_generic_failure() { +test_reply_answer_409_is_unresolved() { local home fakebin out rc err home="$TMP_ROOT/reply-answer-409"; mkdir -p "$home" fakebin=$(make_fake_curl "$home") @@ -1446,10 +1516,207 @@ test_reply_answer_409_is_generic_failure() { out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \ FAKE_ANSWER_CODE=409 FAKE_ANSWER_BODY='{"error":"followup_unavailable"}' \ "$ROOT/bin/fm-x-reply.sh" "req-answer-409" "Normal answer." 2>"$err"); rc=$? - expect_code 1 "$rc" "answer 409 exit" + expect_code 11 "$rc" "answer 409 exit" [ -z "$out" ] || fail "answer 409 must not echo the request_id (got: $out)" - assert_grep "relay returned HTTP 409" "$err" "answer 409 must stay on the generic failure path" - pass "fm-x-reply treats answer-endpoint 409 as a generic failure" + assert_grep "relay returned HTTP 409" "$err" "answer 409 must report the relay response" + assert_grep "answer's outcome is unknown" "$err" "answer 409 must report the unresolved outcome" + assert_present "$home/state/x-context/req-answer-409.answered.json" \ + "an answer 409 must hold its claim" + pass "fm-x-reply holds an answer-endpoint 409 as unresolved" +} + +# The poll offers a mention at least once, so exactly-once has to hold where the +# public action happens. A repeated wake for one request_id must never become a +# second public answer. +test_reply_answers_one_request_id_once() { + local home fakebin log out rc err posts + home="$TMP_ROOT/reply-answer-once"; mkdir -p "$home" + fakebin=$(make_fake_curl "$home") + log="$home/curl.log" + err="$home/err.txt" + printf 'FMX_PAIRING_TOKEN=tok-once\n' > "$home/.env" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \ + FAKE_CURL_LOG="$log" FAKE_ANSWER_CODE=200 \ + "$ROOT/bin/fm-x-reply.sh" "req-once" "First answer." 2>"$err"); rc=$? + expect_code 0 "$rc" "first answer exit" + [ "$out" = "req-once" ] || fail "the first answer must echo the request_id (got: $out)" + [ "$(jq -r '.answered' "$home/state/x-context/req-once.answered.json")" = true ] \ + || fail "a successful answer must confirm its marker" + # The same request offered again: the answer must be refused, not re-posted. + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \ + FAKE_CURL_LOG="$log" FAKE_ANSWER_CODE=200 \ + "$ROOT/bin/fm-x-reply.sh" "req-once" "Second answer." 2>"$err"); rc=$? + expect_code 10 "$rc" "repeated answer exit" + [ -z "$out" ] || fail "a repeated answer must not echo the request_id (got: $out)" + assert_grep "already answered" "$err" "a repeated answer must say why it refused" + posts=$(grep -c 'url=https://relay.test/connector/answer' "$log") + [ "$posts" = 1 ] \ + || fail "a repeated answer must not reach the relay (answer posts: $posts)" + pass "fm-x-reply posts one public answer per request_id" +} + +# A claim only grants permission to post. +# If its owner dies before confirming the answer, the next attempt must escalate +# the unresolved outcome and must not post. +test_reply_unconfirmed_answer_claim_refuses_as_unresolved() { + local home fakebin log out rc err + home="$TMP_ROOT/reply-answer-unconfirmed"; mkdir -p "$home" + fakebin=$(make_fake_curl "$home") + log="$home/curl.log" + err="$home/err.txt" + printf 'FMX_PAIRING_TOKEN=tok-unconfirmed\n' > "$home/.env" + # shellcheck disable=SC2016 # single quotes are deliberate: positional parameters must expand in the inner shell + FMX_NOW_OVERRIDE=1700000000 "$BASH" -c \ + '. "$1"; fmx_answer_registry_claim "$2" "$3"' \ + _ "$ROOT/bin/fm-x-lib.sh" "$home/state" "req-unconfirmed" \ + || fail "the unconfirmed answer claim must be created" + [ "$(jq -r '.answered' "$home/state/x-context/req-unconfirmed.answered.json")" = false ] \ + || fail "a fresh answer claim must remain unconfirmed" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000001 \ + FMX_RELAY_URL="https://relay.test" \ + FAKE_CURL_LOG="$log" FAKE_ANSWER_CODE=200 \ + "$ROOT/bin/fm-x-reply.sh" "req-unconfirmed" "Answer." 2>"$err"); rc=$? + expect_code 11 "$rc" "unconfirmed answer claim exit" + [ -z "$out" ] || fail "an unconfirmed claim must not echo the request_id (got: $out)" + assert_grep "outcome is unknown" "$err" "an unconfirmed claim must report the unresolved outcome" + [ ! -f "$log" ] || fail "an unconfirmed claim must not reach the relay" + pass "fm-x-reply escalates an unconfirmed answer claim without posting" +} + +# Refusing a duplicate must not strand a genuine retry: an answer that did not +# land has to stay postable. +test_reply_failed_answer_stays_retryable() { + local home fakebin log out rc posts + home="$TMP_ROOT/reply-answer-retry"; mkdir -p "$home" + fakebin=$(make_fake_curl "$home") + log="$home/curl.log" + printf 'FMX_PAIRING_TOKEN=tok-retry\n' > "$home/.env" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \ + FAKE_CURL_LOG="$log" FAKE_ANSWER_CODE=500 \ + "$ROOT/bin/fm-x-reply.sh" "req-retry" "Answer." 2>/dev/null); rc=$? + expect_code 1 "$rc" "failed answer exit" + assert_absent "$home/state/x-context/req-retry.answered.json" \ + "an answer that did not land must not keep its claim" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \ + FAKE_CURL_LOG="$log" FAKE_ANSWER_CODE=200 \ + "$ROOT/bin/fm-x-reply.sh" "req-retry" "Answer." 2>/dev/null); rc=$? + expect_code 0 "$rc" "retried answer exit" + [ "$out" = "req-retry" ] || fail "a retried answer must post (got: $out)" + posts=$(grep -c 'url=https://relay.test/connector/answer' "$log") + [ "$posts" = 2 ] || fail "the retry must reach the relay (answer posts: $posts)" + pass "fm-x-reply keeps a failed answer retryable" +} + +# A timeout can happen after the relay accepted an answer. +# Its claim must stay held because retrying could create a second public post. +test_reply_ambiguous_transport_failure_holds_answer_claim() { + local home fakebin log out rc err posts marker + home="$TMP_ROOT/reply-answer-ambiguous"; mkdir -p "$home" + fakebin=$(make_fake_curl "$home") + log="$home/curl.log" + err="$home/err.txt" + marker="$home/state/x-context/req-ambiguous.answered.json" + printf 'FMX_PAIRING_TOKEN=tok-ambiguous\n' > "$home/.env" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \ + FAKE_CURL_LOG="$log" FAKE_ANSWER_TRANSPORT_FAIL=1 \ + "$ROOT/bin/fm-x-reply.sh" "req-ambiguous" "First answer." 2>"$err"); rc=$? + expect_code 11 "$rc" "ambiguous answer transport exit" + [ -z "$out" ] || fail "an ambiguous answer must not echo the request_id (got: $out)" + assert_present "$marker" "an ambiguous answer must keep its durable claim" + assert_grep "answer's outcome is unknown" "$err" \ + "an ambiguous answer must report that its outcome is unknown" + assert_grep "claim is deliberately held" "$err" \ + "an ambiguous answer must report that its claim remains held" + assert_grep "refuse with exit 11" "$err" \ + "an ambiguous answer must explain how a later attempt is refused" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \ + FAKE_CURL_LOG="$log" FAKE_ANSWER_CODE=200 \ + "$ROOT/bin/fm-x-reply.sh" "req-ambiguous" "Second answer." 2>"$err"); rc=$? + expect_code 11 "$rc" "answer retry after ambiguous transport exit" + [ -z "$out" ] || fail "a refused answer retry must not echo the request_id (got: $out)" + posts=$(grep -c 'url=https://relay.test/connector/answer' "$log") + [ "$posts" = 1 ] \ + || fail "an ambiguous answer must reach the relay only once (answer posts: $posts)" + assert_present "$marker" "a refused retry must keep the ambiguous answer claim" + pass "fm-x-reply holds ambiguous answer outcomes against duplicate posts" +} + +# fmx_post_json return 2 is pinned at the library boundary because the reply +# client creates its own readable payload and exposing that file would require a +# production seam. +# This proves curl is never invoked for that status; the reply client's release +# classification remains inspection-covered. +test_reply_unreadable_payload_is_pretransmission_failure() { + local home fakebin log payload response rc + home="$TMP_ROOT/reply-unreadable-payload"; mkdir -p "$home" + fakebin=$(make_fake_curl "$home") + log="$home/curl.log" + payload="$home/payload.json" + response="$home/response.json" + printf '{"request_id":"req-unreadable","text":"Answer."}\n' > "$payload" + chmod 000 "$payload" + # shellcheck disable=SC2016 # single quotes are deliberate: positional parameters must expand in the inner shell + PATH="$fakebin:$BASE_PATH" FMX_RELAY_URL="https://relay.test" FMX_TOKEN=tok \ + FAKE_CURL_LOG="$log" "$BASH" -c \ + '. "$1"; fmx_post_json answer "$2" "$3"' \ + _ "$ROOT/bin/fm-x-lib.sh" "$payload" "$response" >/dev/null 2>&1; rc=$? + chmod 600 "$payload" + expect_code 2 "$rc" "unreadable payload library exit" + [ ! -f "$log" ] || fail "an unreadable payload must fail before curl runs" + pass "fmx_post_json classifies an unreadable payload before transmission" +} + +# A definite non-post remains exit 1 even when its answer claim cannot be +# released, but the stranded claim must be reported. +test_reply_failed_answer_reports_unreleased_claim() { + local home fakebin context_dir out rc err + home="$TMP_ROOT/reply-unreleased-claim"; mkdir -p "$home" + fakebin=$(make_fake_curl "$home") + context_dir="$home/state/x-context" + err="$home/err.txt" + printf 'FMX_PAIRING_TOKEN=tok-unreleased\n' > "$home/.env" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \ + FAKE_ANSWER_CODE=500 FAKE_ANSWER_CHMOD_DIR="$context_dir" \ + "$ROOT/bin/fm-x-reply.sh" "req-unreleased" "Answer." 2>"$err"); rc=$? + chmod 700 "$context_dir" + expect_code 1 "$rc" "failed answer with unreleased claim exit" + [ -z "$out" ] || fail "a failed answer must not echo the request_id (got: $out)" + assert_grep "claim for req-unreleased could not be released" "$err" \ + "a failed claim release must be reported" + assert_grep "later attempt for this request will refuse until it is cleared" "$err" \ + "a failed claim release must explain the stranded claim" + assert_present "$context_dir/req-unreleased.answered.json" \ + "a failed claim release must leave the marker visible" + pass "fm-x-reply reports a claim that could not be released" +} + +# The claim covers the initial answer only: follow-ups are legitimately repeated +# and keep the relay's own cap, and a preview must never consume a real answer. +test_reply_answer_claim_spares_followups_and_dry_runs() { + local home fakebin log out rc posts + home="$TMP_ROOT/reply-answer-claim-scope"; mkdir -p "$home" + fakebin=$(make_fake_curl "$home") + log="$home/curl.log" + printf 'FMX_PAIRING_TOKEN=tok-scope\n' > "$home/.env" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \ + FMX_DRY_RUN=1 "$ROOT/bin/fm-x-reply.sh" "req-scope" "Preview." 2>/dev/null); rc=$? + expect_code 0 "$rc" "dry-run answer exit" + assert_absent "$home/state/x-context/req-scope.answered.json" \ + "a dry-run preview must not consume the answer claim" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \ + FAKE_CURL_LOG="$log" FAKE_ANSWER_CODE=200 \ + "$ROOT/bin/fm-x-reply.sh" "req-scope" "Real answer." 2>/dev/null); rc=$? + expect_code 0 "$rc" "answer after dry-run exit" + [ "$out" = "req-scope" ] || fail "a real answer must still post after a preview (got: $out)" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \ + FMX_REPLY_PLATFORM=x FMX_REPLY_MAX_CHARS=280 \ + FAKE_CURL_LOG="$log" FAKE_FOLLOWUP_CODE=200 \ + "$ROOT/bin/fm-x-reply.sh" "req-scope" --followup "Progress." 2>/dev/null); rc=$? + expect_code 0 "$rc" "follow-up after answer exit" + [ "$out" = "req-scope" ] || fail "a follow-up must not be blocked by the answer claim (got: $out)" + posts=$(grep -c 'url=https://relay.test/connector/followup' "$log") + [ "$posts" = 1 ] || fail "the follow-up must reach the relay (follow-up posts: $posts)" + pass "fm-x-reply's answer claim spares follow-ups and dry-run previews" } test_reply_followup_image_live_posts_image_object() { @@ -2774,6 +3041,8 @@ test_poll_auth_error_reports_once test_poll_error_private_publication_rejects_unsafe_paths test_poll_question_stashes_and_marks test_poll_mentions_wake_once_per_durable_offer +test_poll_reoffers_a_mention_whose_wake_was_never_delivered +test_poll_treats_a_legacy_offer_marker_as_delivered test_poll_offer_claim_failure_reports_once test_poll_preserves_conversation_context test_poll_inbox_commit_failure_reports_error @@ -2810,7 +3079,14 @@ test_reply_image_path_errors_are_clear test_reply_followup_live_posts_to_followup_endpoint test_reply_followup_409_marker_exits_distinctly test_reply_followup_409_without_marker_still_exits_distinctly -test_reply_answer_409_is_generic_failure +test_reply_answer_409_is_unresolved +test_reply_answers_one_request_id_once +test_reply_unconfirmed_answer_claim_refuses_as_unresolved +test_reply_failed_answer_stays_retryable +test_reply_ambiguous_transport_failure_holds_answer_claim +test_reply_unreadable_payload_is_pretransmission_failure +test_reply_failed_answer_reports_unreleased_claim +test_reply_answer_claim_spares_followups_and_dry_runs test_reply_followup_image_live_posts_image_object test_reply_followup_flag_position_is_flexible test_reply_followup_dry_run_marks_endpoint From f994769682047c777314a6f820802ab36d07ea0f Mon Sep 17 00:00:00 2001 From: quinnbot-ai Date: Thu, 30 Jul 2026 14:37:16 -0700 Subject: [PATCH 19/70] feat(bin): daily captain decision board generator + gate-narrowing proposal (#68) * feat(bin): inherit backend config into secondmate homes (#1219) * Inherit config/backend into secondmate homes with deliberate-override preservation Add backend to the shared inheritable config allowlist so launch, locked bootstrap, and config-push converge a primary pin into secondmate homes as each home local future-spawn default. Track last-inherited bytes in a private state provenance marker so deliberate per-home overrides survive present and absent primary convergence, keep --backend and FM_BACKEND stronger, and extend the existing inheritance tests plus docs and skill claims. * no-mistakes(review): Preserve equal unprovenanced backend overrides * no-mistakes(review): Preserve symlink overrides and verify spawn precedence * no-mistakes(review): Snapshot backend inheritance for consistent provenance * no-mistakes(review): Simplify backend inheritance to primary-authoritative convergence * no-mistakes(document): Document inherited backend override preservation * fix: restore primary-authoritative backend inheritance after document regression The document step reintroduced provenance and deliberate per-home override semantics after review had simplified config/backend to plain primary-authoritative allowlist membership. Restore the primary-always-wins path: present overwrites, absent removes, no provenance marker, and docs/tests match that contract. * no-mistakes(review): Add divergent backend precedence regression fixtures * no-mistakes(document): Document backend inheritance contract * fix(pi): remove Calm's upper version ceiling (#1226) * fix(pi): remove Calm's exclusive Pi upper-version ceiling tests/fm-calm-pi-extension.test.sh gated on a closed PI_COMPAT_VERSIONS allowlist ("0.81.1 0.82.0") that refused any other installed Pi, and docs described that range as "supported" rather than verified evidence. The Calm CHANGELOG shows no API introduced at either version, so there is no evidence for a real minimum; the presentation adapters already probe the exact method they patch rather than checking a version. Replace the allowlist with dated version evidence that never rejects a newer Pi, and make each presentation adapter degrade independently with a diagnostic if a future Pi removes its API, instead of the whole Calm extension failing to load. Rewrite the feasibility doc's "Pi 0.81.1 through 0.82.0" phrasing to state it as verified evidence, not a ceiling. * no-mistakes(review): Probe missing Calm adapter exports safely * no-mistakes(document): Document Calm's unbounded Pi compatibility * fix(bin): allow session-local todo tools in the subagent guard (#1204) * fix(guard): allow session-local todo tools in the primary The delegation-shape guard denied TaskCreate and TaskUpdate because their normalized names contain the `task` stem. Those tools write only the harness's session-local todo list, which has no executor: it spawns no agent, allocates no worktree, registers no schedule, and starts nothing that outlives the session. That is not the unaccounted work the guard exists to stop, so the stem match was a false positive, and the deny text told the primary to run bin/fm-brief.sh and bin/fm-spawn.sh to create a todo entry. Add a separately-reasoned PLAN_ONLY_TOOLS exact-name exclusion rather than widening OBSERVE_ONLY_TOOLS, whose documented contract is tools that only observe or stop existing work. Both lists stay exact-name so neither can widen by substring. Tests cover the two allowed names and six near-miss names that a substring or shortened-stem widening would release; both mutations were watched red. * no-mistakes(review): drop session-local todo tools from recommended deny list * no-mistakes: apply CI fixes * fix(session-lock): resolve Claude bg-spare ancestry to the outermost claude pid (#1206) * fix(session-lock): resolve Claude bg-spare ancestry to the outermost claude pid fm_harness_ancestry_pid() previously returned the first ancestor process whose command matched a verified harness name. Claude Code's Stop hook fires as a bg-spare worker several levels below the session's actual lock-owning claude process (hook shell -> claude bg-spare -> claude bg-pty-host -> claude -> claude(lock)), so the first match was the bg-spare worker, not the lock owner. fm_session_lock_owned_by_self() then never matched state/.lock, and the Claude Stop auto-arm silently treated its own primary session as an unrelated live owner and never armed the watcher. The walk now keeps going past a claude-named match, looking for a still more ancestral claude-named match, and stops the instant a non-match follows an already-found match (bounding it to a contiguous run rather than the literal ancestry top, so an unrelated claude-named process further up the real process tree is never mistaken for part of this session's own nested chain). Every other harness keeps the original first-match-wins behavior, since e.g. Pi's shared signed-wrapper ancestry actually holds the session at the inner engine pid, not an outer wrapper pid. Hop limit raised from 8 to 16 to cover the deeper bg-spare chain. * no-mistakes(review): Add nested-claude-ancestry regression test; fix nudge doc depth claim * no-mistakes: apply CI fixes * fix: conferma l'avvio del watcher su Windows/MSYS (#1212) * fix: confirm watcher startup on MSYS * no-mistakes(review): gate MSYS arm ready timeout, cache uname, harden locale test * no-mistakes(review): validate OpenCode ready timeout, make uname cache internal * fix(spawn): forward CLAUDE_CONFIG_DIR to claude crewmates (#1195) * fix(spawn): forward firstmate's CLAUDE_CONFIG_DIR to claude crewmates Crewmate panes are created by a long-lived tmux/herdr daemon that does not inherit firstmate's current environment. When firstmate runs under a non-default CLAUDE_CONFIG_DIR (for example a work-vs-personal subscription split), a bare `claude` in the crewmate pane fell back to the default ~/.claude store and launched unauthenticated, blocking the crewmate before it could do any work. fm-spawn now prefixes the claude launch with firstmate's own resolved CLAUDE_CONFIG_DIR when set, so the crewmate uses the same credential/config store firstmate is authenticated with. An unset value is the single-store default and adds no prefix; non-claude harnesses are unaffected. Adds three tests in fm-spawn-dispatch-profile.test.sh (forwarded-when-set, omitted-when-unset, non-claude-ignored) and pins CLAUDE_CONFIG_DIR in the test helper so launch assertions no longer depend on the developer's environment. * no-mistakes: apply CI fixes * fix: preserve dispatch identity across authentication checks (#1233) * fix: preserve dispatch harness identity * no-mistakes(review): Fix Grok counterfactual tuple validation * no-mistakes(document): Scope dispatch authentication to selected tuple * fix: restore dispatch instruction budget * no-mistakes(review): Scope dispatch authentication after candidate selection * fix(bin): normalize relative durable paths (#1256) * fix(bin): handle dash-leading harness process names (#2) * fix: handle dash-leading harness process names * no-mistakes(review): Make dash-leading harness regression hermetic * fix: preserve secondmate reply routes across relative homes Resolve relative home, data, and state inputs before durable charter generation, and fail when caller-relative directories cannot be resolved. Use absolute paths at the related spawn, AFK daemon, and X-mode cross-process handoffs so later processes cannot reinterpret them from another working directory. * no-mistakes(review): Preserve absolute overrides and normalize relative durable paths * no-mistakes(review): Normalize relative home before deriving durable paths * no-mistakes(document): Document relative durable-path normalization * no-mistakes(review): Captain: Ignore inherited CDPATH during relative path normalization * no-mistakes(lint): Fix empty CDPATH assignments for ShellCheck * refactor(skills): make Bearings chat-only by default (#1136) * Add internal status skill * no-mistakes(document): register /status skill in documentation-audiences inventory * no-mistakes(lint): replace grep|wc -l with grep -c in status skill test * test: silence literal status skill patterns * Refactor bearings default to chat-only --------- Co-authored-by: Kun Chen <3233006+kunchenguid@users.noreply.github.com> * Clarify follow-up routing during validation (#1277) * fix: honor concrete approval for project operations (#1272) * docs: add captain-approved project operation exception to hard rule 1 Firstmate stays read-only over projects by default, but when the captain clearly approves a concrete project operation and scope in the moment, firstmate may perform exactly that approved operation with its own tools. The approval is never inferred, broadened, or standing, and it does not relax the existing force, discard, unlanded-work, or merge-authority boundaries. * no-mistakes(review): Clarify captain-approved project operation boundaries * no-mistakes(document): Clarify captain-approved project operation scope * docs: cover directories and preserve the operation-or-scope alternative Widen the captain-approved project operation exception in AGENTS.md to files or directories, and restore the explicit operation-or-scope alternative that a prior pipeline auto-fix had collapsed into "and". Rework project-management SKILL.md's Remove section, which previously told firstmate to refuse project removal until a guarded helper existed; that helper was never built, so the text directly contradicted the new instruction-only exception. It now points at the exception plus the existing removal preflight it still requires unchanged. Update the one instruction-owners test assertion that hard-coded the sentence removed above, so the suite tracks current, not obsolete, text. * docs: add captain-approved project operation exception to hard rule 1 Firstmate stays read-only over projects by default, but when the captain clearly approves a concrete project operation and scope in the moment, firstmate may perform exactly that approved operation with its own tools. The approval is never inferred, broadened, or standing, and it does not relax the existing force, discard, unlanded-work, or merge-authority boundaries. * no-mistakes(review): Clarify captain-approved project operation boundaries * no-mistakes(document): Clarify captain-approved project operation scope * docs: cover directories and preserve the operation-or-scope alternative Widen the captain-approved project operation exception in AGENTS.md to files or directories, and restore the explicit operation-or-scope alternative that a prior pipeline auto-fix had collapsed into "and". Rework project-management SKILL.md's Remove section, which previously told firstmate to refuse project removal until a guarded helper existed; that helper was never built, so the text directly contradicted the new instruction-only exception. It now points at the exception plus the existing removal preflight it still requires unchanged. Update the one instruction-owners test assertion that hard-coded the sentence removed above, so the suite tracks current, not obsolete, text. * no-mistakes(review): Align project removal preflight with approved exception * no-mistakes(document): Align project removal documentation with approved exception * fix: restore removal test byte-for-byte and preserve the default sentence tests/fm-instruction-owners.test.sh had been changed to assert different text; restore it byte-for-byte to origin/main. project-management SKILL.md's Remove section now keeps the exact default "Never issue a raw removal command from Firstmate." sentence that test still asserts, immediately followed by the already-approved captain-operation-or-scope exception, so the default and the exception both stay explicit and consistent. * no-mistakes(document): Align project-write boundary documentation * fix(skills): route new project intake through secondmate scopes (#1275) * Route project intake through secondmate scopes * no-mistakes(test): Guard all main-home project registry mutations * no-mistakes(document): Consolidate secondmate routing documentation * no-mistakes: apply CI fixes * Restore new-project routing scope * no-mistakes(document): Clarify secondmate routing for new-project intake * no-mistakes: apply CI fixes * fix: scope validation corrections by accepted behavior (#1281) * fix: scope validation corrections by accepted behavior * no-mistakes(review): Classify stale delivery evidence as an autonomous correction * test: replace source assertions with behavioral coverage (#1282) * test: remove source-content assertions * no-mistakes(review): Replace source assertions with runtime behavior coverage * no-mistakes(review): Isolate Kimi task temp runtime coverage * no-mistakes(document): Refresh test cleanup documentation * no-mistakes: apply CI fixes * fix(watch): escalate busy workers with no completed turn (#1286) * fix(watch): bound how long a busy pane may run with no completed turn A busy pane (backend busy state or the harness's rendered footer) was unconditional, unbounded proof of liveness in every escalation path, so a hung foreground tool call behind a busy signature could run for hours undetected (2026-07 hibit-agent-focus-nonsteal-r1 incident: a catastrophic- backtracking regex hung one bash call for 25h behind an unchanging "Working..." footer). FM_BUSY_TURN_MAX_SECS (default 3600s) now bounds how long a busy pane may run with no completed turn (state/.turn-ended, or its spawn record before any turn has completed). Past the bound, busy_turn_over_age routes the pane through the existing wedge_timer_check, reusing the identical stale reason, escalation counter, and demand-deep-inspection marker for human inspection only - never an automatic interrupt, signal, or restart of the worker or its tool process. A completed turn resets the age. Reproduced end-to-end against the real installed Pi TUI: a foreground `sleep 999999` bash call with no timeout renders the actual busy footer, and two captures ~15s apart show the elapsed counter changing the pane hash while the same turn stays unfinished. Running the pre-fix watcher against the real captures showed it never starts a wedge timer no matter how long the pane stays busy; the fixed watcher starts and escalates the timer through the same mechanism, while the real hung process remained untouched and alive throughout. * no-mistakes(review): fix: parse enriched AFK stale reasons * no-mistakes(review): fix: preserve enriched wedges during AFK supervision * no-mistakes(review): fix: route all enriched AFK wedges * no-mistakes(document): Clarify busy-turn age supervision documentation * fix(gitignore): ignore config/ as a directory, not by exact filename (#1261) A name-by-name list of config/ entries silently stops ignoring any new or home-local file placed there, which makes the working tree read as dirty and blocks guarded sync paths that refuse to touch a dirty home. AGENTS.md already documents config/ as captain-private and gitignored as a category; this makes .gitignore match that contract. * fix(tests): replace source-content .gitignore assertion with behavioral coverage (#1304) The second assertion in fm-gitignore-config.test.sh (added by #1261) greps .gitignore for a specific spelling of the config/ ignore pattern. It fails on a semantically equivalent pattern like config/** and does not prove Git actually ignores anything, per the completed source-content-test audit. Replace it with a real git check-ignore control test on a generated unrelated path, and strengthen the existing directory-coverage test with generated unpredictable direct and nested config/ paths. * feat(decision-board): render the captain's daily decision board from the fleet snapshot Adds bin/fm-decision-board.sh, a thin renderer over bin/fm-fleet-snapshot.sh in the same shape as fm-fleet-view.sh and fm-bearings-snapshot.sh. It selects the captain-owned open items the snapshot already normalizes - open backlog entries with hold_kind=captain plus tasks with open ask-user decisions - merges an id present in both into a single entry, splits them by whether his answer alone releases the work, and orders longest-waiting first. Markdown is the drop-in section for the morning packet, HTML is a standalone on-demand surface, and JSON exposes the same model. An unreadable decision queue exits 3 so it can never be mistaken for an empty board. Colocated behavior tests cover source selection, the merge, the ready split including the parked override, ordering, bounding with disclosure, all three formats, HTML escaping of backlog text, and argument validation. * docs(decision-board): state the verbatim-relay boundary in the script header The board relays a durable record's own wording rather than paraphrasing it, so the header now says why: rewording the question a captain is answering would be inventing it. Section 9 translation is a duty of whoever writes the entry; the renderer enforces only what it can carry honestly - project names instead of paths, and plain group wording. * no-mistakes(review): Fix decision numbering and malformed date handling * no-mistakes(review): Fix config ignores, decision filtering, and date validation --------- Co-authored-by: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Co-authored-by: Daniel Kuykendall IV Co-authored-by: Trillium Smith Co-authored-by: Unknownzed <45267749+Unknownzed@users.noreply.github.com> Co-authored-by: lhalbert Co-authored-by: AG Co-authored-by: deeto15 <92119640+deeto15@users.noreply.github.com> Co-authored-by: Christopher McKay <101884182+karotkriss@users.noreply.github.com> Co-authored-by: QuinnBot --- .agents/skills/ask-user-authority/SKILL.md | 4 +- .agents/skills/bearings/SKILL.md | 90 ++-- .../firstmate-coding-guidelines/SKILL.md | 1 + .agents/skills/harness-adapters/SKILL.md | 2 + .agents/skills/project-management/SKILL.md | 17 +- .agents/skills/quota-array-dispatch/SKILL.md | 36 +- .../skills/secondmate-provisioning/SKILL.md | 7 +- .github/workflows/ci.yml | 4 +- .gitignore | 13 +- .opencode/plugins/fm-primary-watch-arm.js | 6 +- .pi/extensions/fm-calm.ts | 25 +- .pi/extensions/fm-primary-pi-watch.ts | 8 +- .../lib/fm-calm-assistant-layout.ts | 13 +- .../lib/fm-calm-operational-user-layout.ts | 25 +- AGENTS.md | 20 +- README.md | 11 +- bin/backends/herdr.sh | 12 +- bin/fm-afk-launch.sh | 22 + bin/fm-brief.sh | 27 +- bin/fm-config-inherit-lib.sh | 12 +- bin/fm-config-push.sh | 4 +- bin/fm-decision-board.sh | 406 ++++++++++++++++++ bin/fm-harness.sh | 2 +- bin/fm-session-lock-lib.sh | 60 ++- bin/fm-spawn.sh | 30 ++ bin/fm-subagent-pretool-check.sh | 15 +- bin/fm-supervise-daemon.sh | 11 +- bin/fm-test-isolation-proof.sh | 5 - bin/fm-test-run.sh | 60 ++- bin/fm-wake-lib.sh | 18 +- bin/fm-watch-arm.sh | 8 +- bin/fm-watch.sh | 68 ++- docs/architecture.md | 1 + docs/calm-mode-feasibility.md | 28 +- docs/calm.md | 6 + docs/configuration.md | 15 +- docs/fm-test-isolation-proof.json | 210 ++------- docs/fm-test-isolation-proof.md | 179 +++----- docs/fm-test-portable-shards.md | 133 +++--- docs/scripts.md | 1 + docs/sessionstart-nudge.md | 4 +- docs/subagent-guard.md | 29 +- docs/verification/supervision.md | 2 +- .../fixtures/quota-array-dispatch/cases.json | 42 ++ tests/fm-afk-launch.test.sh | 49 +++ tests/fm-arm-pretool-check.test.sh | 95 ---- tests/fm-ask-user-authority.test.sh | 128 +----- .../fm-backend-herdr-presentation-e2e.test.sh | 2 +- tests/fm-backend-herdr.test.sh | 158 ------- tests/fm-backend.test.sh | 43 +- tests/fm-bearings-snapshot.test.sh | 30 -- tests/fm-bootstrap.test.sh | 14 - tests/fm-brief.test.sh | 92 ++++ tests/fm-calm-pi-extension.test.sh | 254 ++++++++--- tests/fm-captain-translation-contract.test.sh | 293 ------------- tests/fm-cd-pretool-check.test.sh | 67 --- tests/fm-claude-stop-autoarm.test.sh | 56 ++- tests/fm-daemon.test.sh | 52 +++ tests/fm-decision-board.test.sh | 326 ++++++++++++++ tests/fm-documentation-audiences.test.sh | 19 - tests/fm-gate-refuse.test.sh | 31 -- tests/fm-gitignore-config.test.sh | 47 ++ tests/fm-gotmp.test.sh | 44 +- tests/fm-install-herdr.test.sh | 112 ----- tests/fm-instruction-owners.test.sh | 305 ------------- tests/fm-kimi-harness.test.sh | 58 +-- tests/fm-nm-test-contract.test.sh | 127 ------ tests/fm-no-mistakes-ownership.test.sh | 39 -- tests/fm-pi-watch-extension.test.sh | 81 ---- tests/fm-pr-check-security.test.sh | 5 - tests/fm-quota-array-dispatch.test.sh | 287 ------------- tests/fm-secondmate-harness.test.sh | 225 +++++++++- tests/fm-secondmate-sync.test.sh | 10 - ...m-send-secondmate-marker-herdr-e2e.test.sh | 44 +- tests/fm-sessionstart-nudge.test.sh | 39 -- tests/fm-spawn-dispatch-profile.test.sh | 208 +++++++++ tests/fm-stow-contract.test.sh | 37 -- tests/fm-subagent-pretool-check.test.sh | 71 +-- tests/fm-test-isolation-proof.test.sh | 177 +------- tests/fm-test-run.test.sh | 124 +----- tests/fm-turnend-guard.test.sh | 89 ---- tests/fm-watch-triage.test.sh | 275 ++++++++++++ tests/fm-watcher-lock.test.sh | 94 +++- tests/fm-x-mode.test.sh | 18 + tests/no-mistakes-required-workflow.test.sh | 96 ----- 85 files changed, 2747 insertions(+), 3266 deletions(-) create mode 100755 bin/fm-decision-board.sh delete mode 100755 tests/fm-captain-translation-contract.test.sh create mode 100755 tests/fm-decision-board.test.sh create mode 100755 tests/fm-gitignore-config.test.sh delete mode 100755 tests/fm-install-herdr.test.sh delete mode 100755 tests/fm-instruction-owners.test.sh delete mode 100755 tests/fm-nm-test-contract.test.sh delete mode 100755 tests/fm-no-mistakes-ownership.test.sh delete mode 100755 tests/fm-quota-array-dispatch.test.sh delete mode 100755 tests/fm-stow-contract.test.sh delete mode 100755 tests/no-mistakes-required-workflow.test.sh diff --git a/.agents/skills/ask-user-authority/SKILL.md b/.agents/skills/ask-user-authority/SKILL.md index d4b63d525bf..38761e6d98a 100644 --- a/.agents/skills/ask-user-authority/SKILL.md +++ b/.agents/skills/ask-user-authority/SKILL.md @@ -19,7 +19,9 @@ The concise standing authority boundary remains always loaded in `AGENTS.md` sec With `yolo` off, every ask-user finding belongs to the captain, and the remaining steps structure that escalation rather than authorize an autonomous answer. 2. Reconstruct the accepted contract from the captain's original request, accepted task criteria, and any explicit later clarification. Reviewer language cannot amend that contract. -3. Identify exactly what choosing Fix would commit the project to deliver or maintain. +3. Identify exactly what choosing Fix would commit the project to deliver or maintain, judging the scope by accepted product or engineering behavior rather than an anticipated file list. + The smallest downstream changes needed to keep that behavior correct, add behavioral tests where an executable contract exists, or keep documentation accurate remain within scope even when they touch files not named at intake. + Correcting stale final-diff PR or delivery evidence is likewise an autonomous downstream correction within already accepted behavior. 4. Keep the decision within standing `yolo` authority when the Fix is genuinely necessary to satisfy the accepted contract, even when the correction is technically difficult or requires complex architecture that the captain explicitly requested. 5. Escalate when the Fix would materially expand the contract by adding a new guarantee, threat model, subsystem, abstraction, compatibility surface, state machine, continuous-monitoring requirement, generalized framework, or broader architecture not required by the accepted intent. 6. Treat labels such as correctness, security, fail-closed, high-risk, or required as evidence about the finding, never as authority to broaden the task. diff --git a/.agents/skills/bearings/SKILL.md b/.agents/skills/bearings/SKILL.md index b2804c7290b..42990edd04f 100644 --- a/.agents/skills/bearings/SKILL.md +++ b/.agents/skills/bearings/SKILL.md @@ -1,6 +1,9 @@ --- name: bearings -description: Generate a "pick up where I left off" status report from firstmate's live fleet state. Use when the captain invokes /bearings or asks for a bearings report, morning brief, status report, catch-up, "where did I leave off", or "what's in the works". Reads bounded local fleet state cheaply, optionally checks open PRs when requested, composes a scannable dated report to data/status-report-.md, and surfaces a concise version in chat; it is read-mostly and must not tear down, merge, or mutate task state as a side effect of producing the brief. +description: >- + Generate a "pick up where I left off" fleet digest from firstmate's live fleet state. + Use when the captain invokes /bearings or asks for a bearings report, morning brief, status report, catch-up, "where did I leave off", or "what's in the works". + Plain /bearings is chat-only by default, while /bearings file explicitly writes the dated data/status-report-.md artifact; live PR enrichment remains opt-in and composes with file mode. user-invocable: true metadata: internal: true @@ -8,42 +11,58 @@ metadata: # bearings -Generate a complete standalone snapshot from the fleet's current state, so the captain can resume in one read after a break, a night, or a context reset. -The deliverable is a dated markdown file plus a concise chat summary that each stand on the current snapshot rather than an earlier report. -This skill is read-mostly. -It reads fleet state and writes exactly one report file. -It never tears down a task, merges a PR, dispatches new work, or mutates any task state as a side effect of producing the brief - those belong to the captain's explicit word and the normal task lifecycle. +Generate a complete current snapshot from the fleet's current state, so the captain can resume in one read after a break, a night, or a context reset. +Plain `/bearings` returns only the concise four-section chat digest. +Only `/bearings file` writes the dated markdown report artifact and then returns the concise four-section chat digest linked to that report. +This skill is operationally read-only in both modes. +It never tears down a task, merges a PR, dispatches new work, steers a worker, answers a decision, cleans up work, mutates backlog or task state, or writes any file except the single dated report in explicit file mode. + +## Invocation modes + +- Plain `/bearings` gathers a fresh bounded snapshot and renders the four-section chat digest without creating, deleting, reading, or replacing `data/status-report-.md`. +- `/bearings file` gathers a fresh bounded snapshot, replaces today's `data/status-report-.md` from scratch, and renders the four-section chat digest with a link or path to that report. +- Treat `file` only as an explicit invocation option in the slash command. +- Do not treat natural-language requests such as "write a report", "save this", "persist it", or "make a file" as file mode unless the invocation explicitly includes the standalone `file` option. +- When the captain asks to include PRs, pass the snapshot command's live-PR opt-in. +- `/bearings include PRs` remains chat-only and makes the live-PR opt-in. +- `/bearings file include PRs` writes the dated report and makes the live-PR opt-in. ## What it does 1. **Gather live fleet state with one deterministic command.** - Run `bin/fm-bearings-snapshot.sh` and read its compact output. - It is the single bounded, deterministic source for this report and renders TOON by default. - Do not hand-probe the snapshot schema and do not make ad-hoc `gh-axi`/`gh` calls to assemble fleet facts; this command already assembles them. + Run `bin/fm-bearings-snapshot.sh` at invocation time and read its compact output. + It is the single bounded, deterministic fleet-state source for Bearings and renders TOON by default. + Do not create or consult a second fleet-state reader, parser contract, status-event-tail interpretation, visible-session recap, ad-hoc project probe, or ad-hoc `gh-axi`/`gh` query. The command's header and `--help` output own its exact fields, bounds, opt-ins, and output contract. - When the captain asks to include PRs, use the command's live-PR opt-in; otherwise keep the default local-only read. - If the command is unavailable, fall back to `bin/fm-fleet-snapshot.sh --json` and `bin/fm-crew-state.sh `; never infer current state from a raw `tail` of `state/.status`, which is append-only wake-event history whose last line goes stale. - For registered secondmates, use the snapshot's structured-home classification and provenance; a parent event or bounded terminal contradiction is fallback evidence, never authority over readable structured home state. - Structured captain-held decisions come from `decision-hold-lifecycle` and appear under `decisions_open`; do not scrape reports or visual-review artifacts to supplement them. - A queued item under `gates` only becomes "next work" when its blocker is gone and its time/date gate has arrived; until then it stays queued with the reason. - The `(main-inventory)` gate is an action-free integrity warning rather than queued work: render it under Charted Next with the related `omitted` disclosure, never invent an Underway row from backlog-only state, and never move it into Captain's Call. - -2. **Compose the detailed report file around the four-section spine, adding the richer detail the chat leaves out.** - The gather step is deterministic; your judgment is scoped to the last mile only - ranking the command's facts by what matters right now and writing the scannable prose. + Keep the default local-only read unless the captain asks to include PRs. + For registered secondmates, use the snapshot's structured-home classification and provenance. + A parent event or bounded terminal contradiction is fallback evidence, never authority over readable structured home state. + Structured captain-held decisions come from `decision-hold-lifecycle` and appear under `decisions_open`. + Do not scrape reports, visual-review artifacts, raw status-event tails, or visible conversation history to supplement current state. + A queued item under `gates` only becomes "next work" when its blocker is gone and its time/date gate has arrived. + Until then it stays queued with the reason. + The `(main-inventory)` gate is an action-free integrity warning rather than queued work. + Render it under Charted Next with the related `omitted` disclosure, never invent an Underway row from backlog-only state, and never move it into Captain's Call. + +2. **Compose the four-section chat digest from the fresh snapshot.** + The gather step is deterministic; your judgment is scoped to ranking the command's facts by what matters right now and writing scannable captain-facing prose. + The chat response uses the four complete sections in the chat-response contract below, in the same order, each always present. + Plain mode stops here and writes no report artifact. + +3. **In explicit file mode only, compose and replace the detailed report file.** + The report uses the same four complete sections as the chat, in the same order, and adds the detail the chat omits. Never read an earlier `data/status-report-*.md` to decide what to omit, include, describe as changed, or call current. - The report uses the same four complete sections as the chat (see the chat-response contract below), in the same order, each always present, and adds the detail the chat omits: + Write the full report to `data/status-report-.md` using today's date. + If today's file already exists, delete it first, then create a new file from scratch. + This is the only write allowed by the skill. + The detailed report includes: - **Title** - `# Bearings - ` (use "Morning status" only when the captain specifically asks for a morning brief), followed by two or three sentences framing where things stand. - **Captain's Call** - every open decision summarized with its options from the structured decision record, plus each PR ready to merge and each needed credential or login, every PR with the full `https://...` URL, never a bare `#number`. - **Recently Landed** - the bounded current recent-completions baseline from structured state across the main fleet and every registered secondmate home, rendered in full on every run. - - **Underway** - each live direct report making progress, with its current state, and the plans / main pickup pointers worth reopening (`data//report.md` files, `.lavish/*.html` boards). + - **Underway** - each live direct report making progress, with its current state, and the plans or main pickup pointers worth reopening (`data//report.md` files, `.lavish/*.html` boards). - **Charted Next** - queued or gated work, including any main-inventory integrity warning, with each item's blocker, date, or integrity reason. - -3. **Write the dated report file so it persists, then surface the mandatory four-section digest in chat.** - - Write the full report to `data/status-report-.md` using today's date. - This is the required artifact; it lives in gitignored `data/`. - If today's file already exists, delete it first, then create a new file from scratch. - - The chat response is the concise four-section digest defined by the contract below: materially shorter than the report file, complete as a current snapshot, internally consistent with the file, and linked to that file for the full picture. - - For a richer review surface, optionally offer a Lavish board with `lavish-axi` when the report has enough structure to deserve one, but the markdown file is the required artifact and the four-section chat digest is the required minimum. + After writing the file, return the concise four-section chat digest and include the report path or link without adding a fifth section. + For a richer review surface, optionally offer a Lavish board with `lavish-axi` when the report has enough structure to deserve one, but only after the required digest is ready. ## Chat-response contract @@ -62,22 +81,27 @@ Every `/bearings` chat response renders EXACTLY these four sections, in THIS ord Rules that keep the contract unambiguous: - Every section ALWAYS renders, even when empty, with its short empty-state sentence; never omit a section. -- Every report and chat digest is a complete current snapshot, never a delta against a prior report. +- Every chat digest and file-mode report is a complete current snapshot, never a delta against a prior report. - Recently Landed always renders the bounded current baseline, even when the same completions appeared in an earlier report. - The four buckets are mutually exclusive, so every item is forced into exactly one: needs-your-action is Captain's Call, done is Recently Landed, self-progressing is Underway, and not-yet-started work or an action-free fleet-integrity warning is Charted Next. - The strict boundary keeps action-free items OUT of Captain's Call: a working or validating task, a queued item blocked on another task or a date, landed work, a completed scout's report pointer, a declared `paused:` external wait, and a bare recorded PR with no merge-ready signal each belong to one of the other three sections, never Captain's Call. - A secondmate's own row appears Underway only for `active_child_work`; `externally_held` belongs in Charted Next, and `unknown` belongs there as an unavailable-state gate unless its reason requires the captain's action. - Do not suppress separately projected decisions, landed records, or gates from a `partial-structured` home merely because that secondmate's own row is `unknown`. -- The chat follows `AGENTS.md` section 9 and carries one scannable line per item, each PR as the full `https://...` URL; detailed decisions, plans, full gate reasons, and evidence live only in the report file, which the chat links to, so the chat stays materially shorter than that file. +- Include the required direct address to the captain inside one item or empty-state sentence. +- Every PR appears as the full `https://...` URL; a shorthand `#number` is fine only as a back-reference after the full URL has already appeared in the same digest. +- The chat follows `AGENTS.md` section 9 and carries one scannable line per item. +- Detailed decisions, plans, full gate reasons, and evidence belong in the file only when file mode is explicit, so plain chat stays concise and file-mode chat stays materially shorter than that file. +- In file mode, include the report path or link inside the four-section digest without adding another heading. ## Tone and content rules -- This report is a private, captain-facing internal artifact that lives in gitignored `data/`, so unlike normal captain chat it MAY reference task ids, PR URLs, and repo names - the captain works with these directly and needs them to resume; keep it organized and scannable, not a raw dump. -- Every PR reference is a full `https://...` URL, never a bare `#number`; a shorthand `#number` is fine only as a back-reference after the full URL has already appeared in the same report. +- The optional file-mode report is a private, captain-facing internal artifact that lives in gitignored `data/`, so unlike normal captain chat it MAY reference task ids, PR URLs, and repo names. +- The captain works with those directly and needs them to resume; keep the report organized and scannable, not a raw dump. +- Every PR reference is a full `https://...` URL, never a bare `#number`. - Never include PHI or secret values; the report is an operational artifact, but it is still subject to the same security and compliance rules that govern everything else in this fleet. ## Supervision discipline -This skill is read-mostly and changes no fleet state. -Do not tear down a task, merge a PR, dispatch queued work, or mutate any `state/` or `data/` file other than the single report file as a side effect of generating the brief. +This skill changes no fleet state. +Do not tear down a task, merge a PR, dispatch queued work, steer a worker, answer a queued decision, clean up work, or mutate any `state/` or `data/` file other than the single report file in explicit file mode. If the state you read suggests an action - a PR ready to merge, a queued item whose gate has arrived, or a needs-decision finding - name it in its section and leave the action to the normal lifecycle and configured authority rather than taking it from inside this skill. diff --git a/.agents/skills/firstmate-coding-guidelines/SKILL.md b/.agents/skills/firstmate-coding-guidelines/SKILL.md index c7126ff3583..8bbb275dae8 100644 --- a/.agents/skills/firstmate-coding-guidelines/SKILL.md +++ b/.agents/skills/firstmate-coding-guidelines/SKILL.md @@ -97,6 +97,7 @@ Run `bin/fm-doc-audience-check.sh`; it enforces classification, README setup rou - `bin/*.sh` and `bin/backends/*.sh` must pass `shellcheck`. - Run `bin/fm-lint.sh` before treating a script change as done; it is the single owner of the lint definition (file set, config, and pinned shellcheck version) that CI and the no-mistakes pre-push gate both invoke, and it refuses to run under any other shellcheck version. - Colocate tests with the existing pattern in `tests/`, name them `.test.sh`, and extend an existing script rather than inventing a new runner. +- Tests must exercise behavior through an executable or public interface and must never assert implementation-source bytes, including through parsers, regexes, snapshots, or indirect wrappers. - A maintainer-verification record under `docs/verification/` records active empirical facts, not assumptions or task chronology. - Include the date, version, exact commands run, and exact output needed to support the current guarantee. - Keep incident chronology and delivery evidence in private task reports or PR evidence unless a concise rationale is required to maintain a current safety boundary. diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 429907041a3..0ae4ee05b52 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -127,6 +127,8 @@ The supported launch-profile flags below are verified locally; each row records | 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. | +The concrete `harness` field owns adapter identity independently of the model provider: `harness=pi` with `model=xai/grok-*` is Pi using xAI, not `harness=grok`, and does not require Grok CLI login; `harness=grok` remains the standalone Grok Build CLI adapter. + ### Model support discovery Treat model and provider knowledge as current source-of-truth discovery, not as a permanent namespace or provider mapping. diff --git a/.agents/skills/project-management/SKILL.md b/.agents/skills/project-management/SKILL.md index af35d469ee2..f4c62a57901 100644 --- a/.agents/skills/project-management/SKILL.md +++ b/.agents/skills/project-management/SKILL.md @@ -3,6 +3,7 @@ name: project-management description: >- Agent-only procedure for Firstmate project management. Use before adding, creating, removing, or initializing a project. + Cloning or registering a project is add intake and uses the same trigger. Owns project add, create, clone, remove, initialization, registry, delivery-mode, autonomy, and outward-consent decisions. user-invocable: false metadata: @@ -12,6 +13,7 @@ metadata: # project-management Use this procedure before adding, creating, removing, or initializing a project. +Cloning or registering a project is add intake and uses the same trigger. This skill is the single owner of Firstmate's project-management procedure. It does not replace `secondmate-provisioning`, which owns project clones inside persistent secondmate homes. @@ -22,6 +24,11 @@ Use the registry format and parser contract owned by the header of `bin/fm-proje Keep each registry description useful for identifying the project, but keep delivery posture, captain-private state, and detailed project knowledge in their existing designated homes. Do not turn the registry into project documentation. +Before adding, cloning, creating, or registering any project in the main home, inspect the authoritative `data/secondmates.md` routing table and judge every existing natural-language `scope:` against the proposed project or domain. +Apply `AGENTS.md` section 7's authoritative secondmate routing rules; if an existing scope owns that domain, route the new-project operation or work there instead of creating or registering a duplicate main-home clone. +Absence from the main `data/projects.md` registry is never evidence that no second mate owns the domain. +If the owning second mate cannot accept the route, report that concrete blocker or obtain an explicit captain redirection rather than silently duplicating the project in the main home. + Resolve the project name, destination, delivery mode, and autonomy posture before changing local or remote state. Keep a newly added clone and its registry entry consistent, and roll back only artifacts created by the incomplete operation when a later initialization step fails and that rollback is safe. Do not overwrite or repurpose an existing path. @@ -70,9 +77,9 @@ If doctor reports an environment, authentication, or daemon problem, resolve tha ## Remove -Project removal is destructive and is not one of Firstmate's current direct-write exceptions under `projects/`. -Never issue a raw removal command from Firstmate. +Project removal is destructive. First obtain the captain's explicit removal decision, then inspect the current digest and authoritative repositories for in-flight or queued work, registered secondmate clones, linked worktrees, dirty files, unpushed commits, and any other unlanded work. -If any dependency or unlanded work exists, stop and report it before changing the registry. -Until a guarded removal helper and corresponding prime-directive exception exist, report that implementation gap instead of bypassing the project-write boundary. -When a clone has already been removed through an approved guarded path, or the registry is provably stale because no clone exists, remove its registry line so navigation matches reality. +If any dependency or unlanded work exists, stop and report it before changing anything. +Never issue a raw removal command from Firstmate. +Once that preflight confirms none of the above and the captain's approval is concrete, AGENTS.md hard rule 1's captain-approved project operation exception authorizes firstmate to remove the clone directly and update its registry entry to match. +When a clone has already been removed through an approved removal, or the registry is provably stale because no clone exists, remove its registry line so navigation matches reality. diff --git a/.agents/skills/quota-array-dispatch/SKILL.md b/.agents/skills/quota-array-dispatch/SKILL.md index d9de90ffba2..c384553a859 100644 --- a/.agents/skills/quota-array-dispatch/SKILL.md +++ b/.agents/skills/quota-array-dispatch/SKILL.md @@ -20,16 +20,16 @@ Do not add a daemon, opaque composite score, routing wrapper, hard-coded model-s ## Collect facts Run `quota-axi --json` once per intake and reuse that snapshot for every candidate. -For each candidate, establish the harness/model/provider relationship from `harness-adapters`, then record only inspectable facts: +For each candidate, preserve explicit `harness`, `model`, and `provider`; `harness-adapters` owns identity, and model/provider never infer harness: - task/profile fit and required reasoning class -- raw applicable headroom (`effectivePercentRemaining` or the tightest applicable remaining percentage) -- effective pace status, signed reserve per applicable window, and worst applicable reserve (`worstReservePercentPoints` when present, else the minimum signed reserve) -- whether any applicable window or effective summary is ahead of reset, or any applicable pace is `unknown` +- raw applicable headroom (`effectivePercentRemaining` or tightest applicable percentage) +- effective pace, signed reserve per window, and worst reserve (`worstReservePercentPoints` or minimum signed reserve) +- whether applicable windows/summary are ahead, or pace is `unknown` - schema note when pace fields are absent -Stale raw windows are diagnostic only, never current headroom. -Read every bounding window named by `boundedBy`, `limitingWindowIds`, `aheadWindowIds`, `behindWindowIds`, `onPaceWindowIds`, and `unknownWindowIds`. +Stale raw windows are diagnostic, never headroom. +Read all windows named by `boundedBy`, `limitingWindowIds`, `aheadWindowIds`, `behindWindowIds`, `onPaceWindowIds`, and `unknownWindowIds`. ## Pace semantics @@ -37,27 +37,29 @@ Read every bounding window named by `boundedBy`, `limitingWindowIds`, `aheadWind 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. -Conservation pressure is present when effective pace status is `ahead`, effective pace status is `mixed` and any `aheadWindowIds` remain, or any applicable bounding window itself has pace status `ahead`. -`unknown` is valid explicit uncertainty from quota-axi, not a parser failure and not permission to assume the window is healthy or exhausted. +Conservation pressure is present for effective pace status `ahead`, effective pace status is `mixed` and any `aheadWindowIds` remain, or a bounding window is `ahead`. +`unknown` is valid explicit uncertainty from quota-axi, not parser failure or permission to assume health. ## Selection order -Apply only among candidates that already satisfy required fit and the strongest reasoning class the request needs. +Apply only among candidates satisfying required fit and strongest reasoning class. Never use pace or raw headroom to silently replace that reasoning class. -1. Unresolved relationship or quota data: stop and report the blocked candidate. -2. All-tight: keep the strongest-reasoning class; dispatch inside it or stop and report that the tight choice cannot proceed. -3. When fit and reasoning 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. +1. Unresolved relationship or quota: stop and report the tuple and concrete evidence. +2. All-tight: keep strongest reasoning; dispatch inside it or report if blocked. +3. Comparable fit/reasoning: prefer no ahead pressure over pressure, even with higher raw headroom. 4. Among pressured candidates, prefer the least-negative worst applicable reserve. -5. Among sustainable candidates, use known behind/on-pace evidence plus raw headroom transparently. - Prefer known sustainable evidence over `unknown` pace when otherwise comparable. +5. Sustainable candidates: use known pace plus raw headroom. + Prefer known sustainable evidence over `unknown` when comparable. Do not collapse those facts into an opaque composite score. -6. If the dispatch choice materially hinges on unresolved pace, report the uncertainty rather than inventing a conclusion. -7. Absent pace or older schema: do not crash, fabricate pace, or silently reinterpret absence as healthy/`on_pace`. - Compare raw applicable headroom only, state that pace is unavailable, and keep every other safety rule. +6. If unresolved pace changes the choice, report uncertainty. +7. Absent pace or older schema: do not crash, fabricate pace, or treat absence as healthy/`on_pace`. + Compare raw headroom only, state pace is unavailable, and keep safety rules. 8. Genuine ties: 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. Name the inspectable facts used for every candidate. +After selecting, check auth only through that tuple's surface; another harness CLI cannot block it. +A blocked credential report must name `harness`, `model`, authentication surface, and concrete failure evidence; never emit a bare `Grok unauthenticated` statement. Never conclude with an unexplained "best quota" label. diff --git a/.agents/skills/secondmate-provisioning/SKILL.md b/.agents/skills/secondmate-provisioning/SKILL.md index ecc364939ac..f9e68937ab9 100644 --- a/.agents/skills/secondmate-provisioning/SKILL.md +++ b/.agents/skills/secondmate-provisioning/SKILL.md @@ -78,10 +78,13 @@ This section is the single owner of the secondmate sync and inherited-local-mate Before launch, `fm-spawn.sh --secondmate` locally fast-forwards the home to the primary firstmate checkout's current default-branch commit when it is safe; dirty, diverged, or in-flight homes launch unchanged with a warning. The locked session-start bootstrap sweep runs the same guarded fast-forward for every live secondmate home, discovered from `state/.meta` records with `kind=secondmate` (`data/secondmates.md` only backfills `home=` for older records). That no-fetch path is a purely local fast-forward of tracked files, never an origin fetch, and it never touches the gitignored operational dirs, so a secondmate's backlog, projects, and in-flight work are never disturbed; a linked worktree advances immediately, while a standalone clone that lacks the target receives firstmate updates through `/updatefirstmate`'s origin refresh. -The same launch and the same locked bootstrap sweep also propagate the primary's declared inherited local material: `config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/herdr-presentation-spaces`, and the one shared captain-preference file `data/captain-shared.md`. +The same launch and the same locked bootstrap sweep also propagate the primary's declared inherited local material: `config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/backend`, `config/herdr-presentation-spaces`, and the one shared captain-preference file `data/captain-shared.md`. Because these paths are gitignored, that propagation is a separate, primary-authoritative copy independent of the tracked-files fast-forward: it re-converges every live home whether or not its tracked files advanced, and it touches only the declared items. Propagation failures warn without blocking secondmate launch or session-start continuation, and the destination keeps whatever safely validated state the helper left behind. Inheritance copies the literal `config/crew-harness` file, so a secondmate's own crewmates use the primary's crewmate harness only when it names a concrete adapter such as `codex`; an unset or `default` value has nothing concrete to inherit, and the secondmate's own crewmates fall back to the secondmate's own or detected harness instead. +Inherited `config/backend` becomes that secondmate home's local runtime-backend default for future spawns only; it never retargets, rewrites, migrates, stops, or restarts an already-live worker endpoint. +A present primary value always converges byte-exact into validated secondmate homes, and primary absence removes the destination so those homes keep runtime auto-detection. +Explicit per-spawn `--backend` and `FM_BACKEND` remain stronger than every home's local `config/backend`, including an inherited default. `config/secondmate-harness` is not inherited because it is only the primary's knob for launching secondmate agents. `data/captain-shared.md` is main-authoritative in the primary home and read-only in secondmate homes. Its primary file header must state that the file is main-authoritative, read-only in secondmate homes, must not be edited there, and that new captain-preference discoveries are routed to the main firstmate through marked status or a document pointer. @@ -96,7 +99,7 @@ Keep every `data/learnings.md` fully local by captain decision; route fleet-gene No AGENTS.md reread nudge is needed at spawn or respawn because the agent reads instructions fresh on launch; only the bootstrap sweep's running-home instruction-surface advance needs that AGENTS.md re-read. Bootstrap reports successful AGENTS.md re-read sends as `BOOTSTRAP_INFO:` and only emits `NUDGE_SECONDMATES:` when that send fails and needs retry. A separate, literal-content config reread is required whenever inherited `config/*` material changes under an already-running secondmate. -After each successful allowlisted config write, both the locked bootstrap convergence path and mid-session `bin/fm-config-push.sh` use the shared propagation report to build one per-home generation-specific private instruction file from the validated destination post-write bytes for only the allowlisted config items that actually changed for that home (`config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/herdr-presentation-spaces`), in deterministic allowlist order. +After each successful allowlisted config write, both the locked bootstrap convergence path and mid-session `bin/fm-config-push.sh` use the shared propagation report to build one per-home generation-specific private instruction file from the validated destination post-write bytes for only the allowlisted config items that actually changed for that home (`config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/backend`, `config/herdr-presentation-spaces`), in deterministic allowlist order. Each changed path is printed with clear begin/end delimiters and the destination file's full exact new bytes unparsed, or the explicit token `ABSENT` when propagation removed the destination copy. The instruction uses only minimal framing that these are defaults/rules and do not remove judgment; it never includes SHA values, selected profiles, parsed summaries, or any other generated interpretation. `data/captain-shared.md` is not a config file and is never inlined into this instruction file or message. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e79bb219df6..56bd9860051 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -393,8 +393,8 @@ jobs: bearings_output=$(/bin/bash tests/fm-bearings-snapshot.test.sh) printf '%s\n' "$bearings_output" bearings_count=$(printf '%s\n' "$bearings_output" | grep -c '^ok - ') - [ "$bearings_count" -eq 42 ] || { - echo "::error::expected 42 Bearings tests, got $bearings_count" + [ "$bearings_count" -eq 41 ] || { + echo "::error::expected 41 Bearings tests, got $bearings_count" exit 1 } diff --git a/.gitignore b/.gitignore index fab660cd027..705da6f3153 100644 --- a/.gitignore +++ b/.gitignore @@ -8,16 +8,5 @@ data/ __pycache__/ *.pyc .env -config/crew-harness -config/crew-dispatch.json -config/supervision-capacity -config/secondmate-harness -config/backlog-backend -config/backend -config/calm -config/x-mode.env -config/cmux-socket-password -config/wedge-alarm -config/herdr-presentation-spaces -config/ops-inbox.json +config/ ops-inbox/ diff --git a/.opencode/plugins/fm-primary-watch-arm.js b/.opencode/plugins/fm-primary-watch-arm.js index 8b98340cfa2..433edb80ab4 100644 --- a/.opencode/plugins/fm-primary-watch-arm.js +++ b/.opencode/plugins/fm-primary-watch-arm.js @@ -4,7 +4,11 @@ import { resolve } from "node:path"; import { encodeFirstmateOperationalInput } from "./lib/fm-operational-input.js"; const COORDINATOR_KEY = "__firstmateOpenCodeWatchArm"; -const ARM_READY_TIMEOUT_MS = Number(process.env.FM_OPENCODE_ARM_READY_TIMEOUT_MS || 12000); +// 35s on Windows so the budget stays above arm's MSYS confirm default (30s in +// bin/fm-watch-arm.sh): a slow but successful Git Bash cold start must not be +// SIGTERMed mid-confirmation. Conditioned on win32 so other platforms keep 12s. +const ARM_READY_TIMEOUT_DEFAULT_MS = process.platform === "win32" ? 35000 : 12000; +const ARM_READY_TIMEOUT_MS = positiveInteger("FM_OPENCODE_ARM_READY_TIMEOUT_MS", ARM_READY_TIMEOUT_DEFAULT_MS); const ARM_RETIRE_TIMEOUT_MS = positiveInteger("FM_WATCH_ARM_RETIRE_TIMEOUT_MS", 1000); const REARM_RETRY_BASE_MS = positiveInteger("FM_WATCH_REARM_RETRY_BASE_MS", 250); const REARM_RETRY_MAX_MS = positiveInteger("FM_WATCH_REARM_RETRY_MAX_MS", 4000); diff --git a/.pi/extensions/fm-calm.ts b/.pi/extensions/fm-calm.ts index eb009fd8e3b..f78c1b5acd9 100644 --- a/.pi/extensions/fm-calm.ts +++ b/.pi/extensions/fm-calm.ts @@ -1,11 +1,13 @@ // Firstmate's home-persistent Pi transcript presentation toggle. // -// Compatibility boundary: Pi 0.81.1 and 0.82.0 expose built-in ToolDefinitions, per-slot +// Verified against Pi 0.81.1 and 0.82.0, which expose built-in ToolDefinitions, per-slot // renderers, renderShell: "self", session_start replacement reasons, // ExtensionUIContext.setToolsExpanded(), setWorkingVisible(), and -// setHiddenThinkingLabel(). The focused tests pin those assumptions. Version-bounded -// presentation adapters cover collapsed assistant thinking and operational user rows; -// Pi still exposes no global renderer for arbitrary built-in or custom rows. +// setHiddenThinkingLabel(). The focused tests pin those assumptions but never reject a +// newer Pi solely for its version. The collapsed-thinking and operational-user +// presentation adapters probe the exact API they patch and degrade independently with a +// diagnostic (see installCalmPresentationAdapter below) if a future Pi removes it; Pi +// still exposes no global renderer for arbitrary built-in or custom rows. // docs/configuration.md owns the home-local Calm preference contract. import { randomUUID } from "node:crypto"; import { @@ -74,9 +76,20 @@ const extensionFile = fileURLToPath(import.meta.url); const extensionDir = dirname(extensionFile); const root = resolve(extensionDir, "../.."); +// Each presentation adapter probes the exact Pi API it patches. If a future Pi removes +// that API, only the affected adapter degrades; the rest of Calm keeps working. +function installCalmPresentationAdapter(name: string, install: () => void): void { + try { + install(); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + console.error(`Firstmate Calm: ${name} presentation adapter unavailable, skipping. ${reason}`); + } +} + export default function (pi: ExtensionAPI) { - installCalmAssistantLayout(); - installCalmOperationalUserLayout(); + installCalmPresentationAdapter("collapsed-thinking", installCalmAssistantLayout); + installCalmPresentationAdapter("operational-user-row", installCalmOperationalUserLayout); let exportRendering = false; let removeTerminalInputHandler: (() => void) | undefined; diff --git a/.pi/extensions/fm-primary-pi-watch.ts b/.pi/extensions/fm-primary-pi-watch.ts index 92c39e4fd2f..9d5124aff2d 100644 --- a/.pi/extensions/fm-primary-pi-watch.ts +++ b/.pi/extensions/fm-primary-pi-watch.ts @@ -88,7 +88,13 @@ const extensionVersion = `sha256:${createHash("sha256").update(readFileSync(exte const retryBaseMs = positiveInteger("FM_WATCH_REARM_RETRY_BASE_MS", 250); const retryMaxMs = positiveInteger("FM_WATCH_REARM_RETRY_MAX_MS", 4000); const retryLimit = positiveInteger("FM_WATCH_REARM_RETRY_LIMIT", 5); -const armReadyTimeoutMs = positiveInteger("FM_PI_ARM_READY_TIMEOUT_MS", 12000); +// 35s on Windows so the budget stays above arm's MSYS confirm default (30s in +// bin/fm-watch-arm.sh): a slow but successful Git Bash cold start must not be +// SIGTERMed mid-confirmation. Conditioned on win32 so other platforms keep 12s. +const armReadyTimeoutMs = positiveInteger( + "FM_PI_ARM_READY_TIMEOUT_MS", + process.platform === "win32" ? 35000 : 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"; diff --git a/.pi/extensions/lib/fm-calm-assistant-layout.ts b/.pi/extensions/lib/fm-calm-assistant-layout.ts index dbb5b2dd5f6..33be71095ed 100644 --- a/.pi/extensions/lib/fm-calm-assistant-layout.ts +++ b/.pi/extensions/lib/fm-calm-assistant-layout.ts @@ -1,7 +1,12 @@ -import { AssistantMessageComponent } from "@earendil-works/pi-coding-agent"; +// Verified against Pi 0.81.1 and 0.82.0, which export AssistantMessageComponent with an +// updateContent method. installCalmAssistantLayout() probes that exact method and throws +// if it is missing; fm-calm.ts catches that and skips only this adapter with a diagnostic +// instead of blocking Calm or Pi. +import type { AssistantMessageComponent as PiAssistantMessageComponent } from "@earendil-works/pi-coding-agent"; +import * as PiCodingAgent from "@earendil-works/pi-coding-agent"; import { calmPresentationHides } from "./fm-calm-visibility.ts"; -type AssistantMessage = Parameters[0]; +type AssistantMessage = Parameters[0]; type AssistantMessagePresentationState = { hiddenThinkingLabel: string; @@ -31,6 +36,10 @@ export function installCalmAssistantLayout(): void { } const patch: CalmAssistantLayoutPatch = { hidesThinking }; + const AssistantMessageComponent = PiCodingAgent.AssistantMessageComponent; + if (typeof AssistantMessageComponent !== "function") { + throw new Error("Firstmate Calm requires Pi AssistantMessageComponent"); + } const originalUpdateContent = AssistantMessageComponent.prototype.updateContent; if (typeof originalUpdateContent !== "function") { throw new Error("Firstmate Calm requires Pi AssistantMessageComponent.updateContent"); diff --git a/.pi/extensions/lib/fm-calm-operational-user-layout.ts b/.pi/extensions/lib/fm-calm-operational-user-layout.ts index 82c69eda01f..ca9b0bbcc0a 100644 --- a/.pi/extensions/lib/fm-calm-operational-user-layout.ts +++ b/.pi/extensions/lib/fm-calm-operational-user-layout.ts @@ -1,13 +1,14 @@ -// Pi 0.81.1 and 0.82.0 add the ordinary-user spacer and row together. -// This version-bounded adapter changes only that presentation and never message delivery. -import { - InteractiveMode, - UserMessageComponent, -} from "@earendil-works/pi-coding-agent"; +// Verified against Pi 0.81.1 and 0.82.0, which add the ordinary-user spacer and row +// together via InteractiveMode.addMessageToChat. This adapter probes that exact method +// and throws if it is missing; fm-calm.ts catches that and skips only this adapter with a +// diagnostic instead of blocking Calm or Pi. It changes only that presentation and never +// message delivery. +import type { UserMessageComponent as PiUserMessageComponent } from "@earendil-works/pi-coding-agent"; +import * as PiCodingAgent from "@earendil-works/pi-coding-agent"; import { calmPresentationHides } from "./fm-calm-visibility.ts"; import { classifyFirstmateCurrentOperationalText } from "./fm-operational-input.ts"; -type UserMessageConstructorArgs = ConstructorParameters; +type UserMessageConstructorArgs = ConstructorParameters; type UserMessageLike = { role: string; content: unknown; @@ -18,7 +19,7 @@ type AddMessageOptions = { type InteractiveModePresentation = { chatContainer: { children: unknown[]; - addChild(component: UserMessageComponent): void; + addChild(component: PiUserMessageComponent): void; }; editor: { addToHistory?(text: string): void; @@ -81,12 +82,20 @@ export function installCalmOperationalUserLayout(): void { hidesOperationalInput, isOperationalInput, }; + const InteractiveMode = PiCodingAgent.InteractiveMode; + if (typeof InteractiveMode !== "function") { + throw new Error("Firstmate Calm requires Pi InteractiveMode"); + } const prototype = InteractiveMode.prototype as unknown as InteractiveModePrototype; const originalAddMessageToChat = prototype.addMessageToChat; if (typeof originalAddMessageToChat !== "function") { throw new Error("Firstmate Calm requires Pi InteractiveMode.addMessageToChat"); } + const UserMessageComponent = PiCodingAgent.UserMessageComponent; + if (typeof UserMessageComponent !== "function") { + throw new Error("Firstmate Calm requires Pi UserMessageComponent"); + } class CalmOperationalUserMessageComponent extends UserMessageComponent { private readonly hasLeadingSpacer: boolean; diff --git a/AGENTS.md b/AGENTS.md index fda565f8666..7a6ab150309 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,16 +14,17 @@ For captain-facing escalation style and outcome phrasing, see section 9. ## 1. Identity and prime directives You are the captain's only point of contact for all software work across all of their projects. -You do not do project-specific work yourself. -Delegate coding, investigation, planning, bug reproduction, and audits to a crewmate you spawn and supervise, or to a secondmate whose registered scope fits. +Outside hard rule 1's concrete captain-approved project operation exception, you do not do project-specific work yourself. +For all other project-specific work, delegate coding, investigation, planning, bug reproduction, and audits to a crewmate you spawn and supervise, or to a secondmate whose registered scope fits. A secondmate is a crewmate with an isolated firstmate home and a charter, not a second architecture. Hard rules, in priority order: 1. **Never write to a project.** Do not edit, commit, or run state-changing commands under `projects/` or in any project worktree; firstmate reads projects and crewmates change them. - The only exceptions are the guarded project initialization, fleet sync, secondmate sync and inherited local-material propagation, self-update, and approved `local-only` merge paths owned by their referenced skills and scripts. + The only exceptions are the guarded project initialization, fleet sync, secondmate sync and inherited local-material propagation, self-update, and approved `local-only` merge paths, each owned by its referenced skill or script, plus a concrete captain-approved project operation governed directly by this rule. Those paths never authorize forcing, stashing, discarding unlanded work, or hand-writing a project's `AGENTS.md`. + Firstmate may directly edit, create, move, or delete project files or directories only when the captain clearly and concretely approves, in the moment, for a specific project, either a specific operation or a concrete scope whose authorized action needs no inference; firstmate performs exactly that approval with its own file tools, never infers or broadens it, and gains no standing authority, while the force, discard, unlanded-work, merge-authority, destructive, irreversible, and security-sensitive boundaries remain independently in force. 2. **Never merge a PR without the captain's explicit word.** A project's captain-approved `yolo` posture is the only standing relaxation for routine decisions; section 7 owns its exceptions and preserves the stronger destructive, irreversible, and security-sensitive captain boundaries. 3. **Never tear down unlanded work.** @@ -50,7 +51,7 @@ Never add an agent name as a commit co-author. Each secondmate has a persistent isolated `FM_HOME`, including its own state, backlog, projects, and session lock. `bin/fm-send.sh` fails closed unless `FM_HOME` is explicit, so a steer cannot silently resolve against another home. -Tracked files hold shared instructions and tooling; `data/` holds durable private fleet records; `state/` holds volatile runtime records and append-only status events; `config/` holds local operating choices; and `projects/` contains clones that are read-only to firstmate. +Tracked files hold shared instructions and tooling; `data/` holds durable private fleet records; `state/` holds volatile runtime records and append-only status events; `config/` holds local operating choices; and `projects/` contains clones that are read-only to firstmate except under hard rule 1's concrete captain-approved project operation exception. ``` AGENTS.md this file (CLAUDE.md is a symlink to it) @@ -68,7 +69,7 @@ config/crew-dispatch.json optional crewmate dispatch profiles; LOCAL, gitignore config/supervision-capacity optional per-home direct-lifecycle ordinary-worker capacity; LOCAL, gitignored, not inherited; see docs/configuration.md "Direct supervision capacity" config/secondmate-harness harness the PRIMARY uses to launch SECONDMATE agents, optionally followed by a model and effort token on the same line (" [] []"; section 4); LOCAL, gitignored; absent or "default" harness falls back to config/crew-harness then firstmate's own. The primary's own setting; NOT inherited into secondmate homes (secondmates do not spawn secondmates) config/backlog-backend backlog backend override; LOCAL, gitignored; absent or "tasks-axi" = default tasks-axi backend, "manual" = force routine backlog updates to hand-editing; inherited by secondmate homes (section 10) -config/backend runtime session-provider backend override for new tasks; LOCAL, gitignored; absent = falls through to runtime auto-detection (the runtime firstmate itself is executing inside), then tmux; tmux is the verified reference backend (docs/tmux-backend.md), while herdr, zellij, orca, and cmux are experimental spawn backends (docs/herdr-backend.md, docs/zellij-backend.md, docs/orca-backend.md, docs/cmux-backend.md) - herdr and cmux can also be selected by runtime auto-detection, zellij and orca never are (always explicit), and codex-app is not accepted; see docs/codex-app-backend.md; not inherited into secondmate homes +config/backend runtime session-provider backend override for new tasks; LOCAL, gitignored; absent = falls through to runtime auto-detection (the runtime firstmate itself is executing inside), then tmux; tmux is the verified reference backend (docs/tmux-backend.md), while herdr, zellij, orca, and cmux are experimental spawn backends (docs/herdr-backend.md, docs/zellij-backend.md, docs/orca-backend.md, docs/cmux-backend.md) - herdr and cmux can also be selected by runtime auto-detection, zellij and orca never are (always explicit), and codex-app is not accepted; see docs/codex-app-backend.md; inherited by secondmate homes under the primary-authoritative contract in secondmate-provisioning config/calm Pi Calm presentation preference; LOCAL, gitignored, and not inherited; see docs/configuration.md "Pi Calm preference" config/herdr-presentation-spaces optional presence flag for Herdr's default-off disposable single-task visual projection; LOCAL, gitignored; inherited by secondmate homes; see docs/herdr-backend.md "Optional presentation spaces" config/cmux-socket-password optional cmux control-socket password; LOCAL, gitignored; read fresh on every cmux CLI call and passed through without ever overriding an operator's own ambient CMUX_SOCKET_PASSWORD when absent (docs/cmux-backend.md "Setup") @@ -84,7 +85,7 @@ data/ personal fleet records; LOCAL, gitignored as a whole secondmates.md secondmate routing table; firstmate-private, maintained by fm-home-seed.sh (section 6) /brief.md per-task crewmate brief, or per-secondmate charter brief when kind=secondmate /report.md scout task deliverable, written by the crewmate; survives teardown -projects/ cloned repos; gitignored; READ-ONLY for you +projects/ cloned repos; gitignored; read-only except under hard rule 1's concrete captain-approved project operation exception state/ volatile runtime signals; gitignored .status appended by crewmates: ": " wake-event lines, not current-state truth .turn-ended touched by turn-end hooks @@ -200,8 +201,9 @@ A restart must be a non-event because durable state and live backend inventory, ## 6. Project and knowledge management Load `project-management` before adding, creating, removing, or initializing a project. -That skill owns registry syntax, delivery-mode selection, outward-facing consent, clone and initialization procedure, safe rollback, and removal refusal. -Project creation never authorizes an unmentioned remote, and project removal never bypasses the project-write boundary or unlanded-work checks. +Cloning or registering a project is add intake and uses the same trigger. +That skill owns registry syntax, delivery-mode selection, outward-facing consent, clone and initialization procedure, safe rollback, and removal preflight. +Project creation never authorizes an unmentioned remote, and project removal never bypasses that preflight or unlanded-work checks; hard rule 1's concrete captain-approved project operation exception remains available when its exact conditions are met. Load `secondmate-provisioning` before creating, seeding, validating, launching, handing backlog to, recovering, pushing inherited local material into, or retiring a secondmate home, and before editing `data/secondmates.md`. Its scope field drives routing and its project list is non-exclusive provisioning data, not ownership. @@ -298,6 +300,7 @@ After an autonomous merge, give the captain a one-line full-URL or local-main ou For a no-mistakes ship, trigger validation on the same worker after its implementation commit, using the harness invocation owned by `harness-adapters`. The task worker that starts a no-mistakes run drives the pipeline and owns every `no-mistakes axi run` and `no-mistakes axi respond` call through the next gate or outcome. Firstmate never invokes `no-mistakes axi respond` for a crew-owned run. +Once validation starts, prefer routing new requirements to follow-up work rather than expanding the current task, unless a new requirement completely invalidates the work being validated; however, the smallest downstream changes needed to keep already accepted product or engineering behavior correct, add behavioral tests where an executable contract exists, or keep documentation accurate remain within the current task even when they touch files not named at intake, and corrections required to satisfy already accepted intent are not new requirements. An ask-user finding returns as `needs-decision`; firstmate decides only when the configured authority permits, otherwise escalates to the captain. Send the same worker one exact decision naming the decision key, step, action, affected finding IDs, instructions where needed, and exact response command. @@ -489,6 +492,7 @@ These skills are not captain-invocable; load them only at their precise triggers - `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. + Cloning or registering a project is add intake and uses the same trigger. - `stuck-crewmate-recovery` - load when the session-start digest reports an ordinary direct report's endpoint dead or its metadata has no window, or after a stale wake, looping pane, repeated confusion, an answered-by-brief question, an unresponsive crewmate, or a failed steer. - `secondmate-provisioning` - load before creating, seeding, validating, launching, handing backlog to, recovering, pushing inherited local material into, or retiring a secondmate home, and before editing `data/secondmates.md`. - `decision-hold-lifecycle` - load before treating an investigation or visual review as complete, before ending a visual review that exposed a decision, and when recording or routing the captain's answer. diff --git a/README.md b/README.md index 1ab06a4a355..b2d63640df6 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Launching a supported harness inside it instantiates your first mate - and makes - **Optional secondmates** - opt in to persistent second mates that run from isolated firstmate homes with their own `FM_HOME`, state, projects, and session lock, supervising project clones or a project-less firstmate-repo domain, kept on the primary firstmate version by guarded local fast-forwards and checked for live agent processes at session start. - **Event-driven, zero-token supervision** - a bash watcher sleeps on the fleet and wakes the first mate only when something needs you; verified primary harnesses also get a turn-end backstop that blocks or follows up on a blind stop when work is under way and supervision is not live. - **Optional X mode** - opt in with one local `.env` token so firstmate can answer your public `@myfirstmate` mentions, act on normal reversible mention requests through the same lifecycle as chat requests, acknowledge spawned work, and post up to three public-safe completion follow-ups within seven days for genuine milestones and the final outcome without changing non-X behavior; dry-run preview records would-be replies and dismissals locally before go-live. -- **Guarded by construction** - the first mate is read-only over your projects except for the guarded paths authorized by [hard rule 1](AGENTS.md#1-identity-and-prime-directives), with fleet sync's safe branch pruning remaining part of the fleet-sync exception; crewmates make every project change behind the configured merge authority. +- **Strict project boundary** - the first mate is read-only over your projects except for the narrow guarded and captain-approved operations authorized by [hard rule 1](AGENTS.md#1-identity-and-prime-directives), including fleet sync's guarded safe branch pruning; crewmates make every other project change behind the configured merge authority. - **Restart-proof** - all state lives on disk and in the active session backend (tmux by hard default, herdr or cmux when selected or auto-detected, zellij/orca when explicitly selected); kill the session anytime and the next one reconciles, including confirmed-dead secondmate agents, and carries on. Full detail on every feature lives in [docs/architecture.md](docs/architecture.md). @@ -178,10 +178,17 @@ Claude and grok use the slash form shown here; codex uses the same names with `$ | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | | `/afk` | Enter away-mode supervision: the sub-supervisor self-handles routine notifications in bash, escalates captain-relevant events and bounded declared-external-wait rechecks as batched digests, and actively alerts if delivery gets stuck while you step away | | `/ahoy` | Recap visible session events since the prior real captain message plus visibly unanswered captain decisions, falling back to Bearings when invoked as the session's first real captain message | -| `/bearings` | Generate a standalone current-status report from bounded local fleet and registered-secondmate state, with live PR enrichment only when requested, written to a dated file in `data/` and surfaced concisely in chat; read-mostly, mutates no task state | +| `/bearings` | Generate a concise four-section chat digest from bounded local fleet and registered-secondmate state; use `/bearings file` to also replace today's dated report in `data/`, and add `include PRs` when live PR enrichment is wanted | | `/updatefirstmate` | Self-update the running firstmate and its secondmates to the latest published default branch with fast-forward-only pulls, then re-read instructions and nudge secondmates | | `/stow` | Sweep the session for uncaptured durable knowledge, route each finding to its disk home per AGENTS.md, file undone next steps to the backlog, and report what is now safe to reset | +Bearings invocation examples: + +- `/bearings` returns the fresh four-section digest in chat only. +- `/bearings include PRs` keeps chat-only mode and opts into live PR enrichment. +- `/bearings file` replaces today's `data/status-report-.md` from scratch and links it from the four-section chat digest. +- `/bearings file include PRs` combines the dated report with live PR enrichment. + Agent-only reference skills live under `.agents/skills/` and are loaded by firstmate at the trigger points named in [`AGENTS.md`](AGENTS.md). ### Two-tier skill layout diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 3a28daaa336..237d2348c5a 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -1831,7 +1831,15 @@ FM_BACKEND_HERDR_IDLE_RE=${FM_BACKEND_HERDR_IDLE_RE:-'^Type a message\.\.\.$'} # Known bare (unbordered) prompt glyphs a composer row may start with: ❯ # (claude) and › (codex) only. Generic shell-style glyphs > $ % # are still # recognized after a bordered composer row has already been structurally found. -FM_BACKEND_HERDR_BARE_PROMPT_RE=${FM_BACKEND_HERDR_BARE_PROMPT_RE:-'^[❯›]'} +# Deliberately an alternation, not a `[...]` bracket expression: under a C/POSIX +# locale (LC_CTYPE=C, the fleet default), grep's bracket expressions match +# individual BYTES rather than whole multibyte characters, so `[❯›]` silently +# decomposes into the shared leading UTF-8 byte (0xE2) and spuriously matches +# ANY multibyte glyph in that range - including box-drawing corners like ╰, +# misclassifying a bordered composer's bottom border row as the bare shape. +# An alternation's branches are matched as whole literal byte sequences and +# stay correct regardless of locale. +FM_BACKEND_HERDR_BARE_PROMPT_RE=${FM_BACKEND_HERDR_BARE_PROMPT_RE:-'^(❯|›)'} # Pi allows a multi-line composer between its horizontal separators. Bound the # structural candidate so two unrelated transcript rules with an arbitrarily # large region between them can never be promoted into a composer. @@ -1995,7 +2003,7 @@ EOF fi # Delegate the empty/pending/unknown decision to the shared owner. The bare # shape only ever starts with an AGENT glyph (FM_BACKEND_HERDR_BARE_PROMPT_RE - # is '^[❯›]'), so a bare shell prompt never reaches here - it stays 'unknown' + # is '^(❯|›)'), so a bare shell prompt never reaches here - it stays 'unknown' # via the no-composer-row path above, exactly as before. fm_composer_classify_content "$bordered" "$stripped" "$FM_BACKEND_HERDR_IDLE_RE" } diff --git a/bin/fm-afk-launch.sh b/bin/fm-afk-launch.sh index 57b7f6590db..3bc1a1cbac4 100755 --- a/bin/fm-afk-launch.sh +++ b/bin/fm-afk-launch.sh @@ -48,6 +48,28 @@ set -u FM_AFK_LAUNCH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$FM_AFK_LAUNCH_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +case "$FM_HOME" in + /*) ;; + *) + FM_AFK_LAUNCH_HOME_INPUT=$FM_HOME + FM_HOME=$(CDPATH='' cd -- "$FM_AFK_LAUNCH_HOME_INPUT" 2>/dev/null && pwd -P) || { + echo "error: FM_HOME directory cannot be resolved: $FM_AFK_LAUNCH_HOME_INPUT" >&2 + exit 1 + } + ;; +esac +if [ -n "${FM_STATE_OVERRIDE:-}" ]; then + case "$FM_STATE_OVERRIDE" in + /*) ;; + *) + FM_AFK_LAUNCH_STATE_INPUT=$FM_STATE_OVERRIDE + FM_STATE_OVERRIDE=$(CDPATH='' cd -- "$FM_AFK_LAUNCH_STATE_INPUT" 2>/dev/null && pwd -P) || { + echo "error: FM_STATE_OVERRIDE directory cannot be resolved: $FM_AFK_LAUNCH_STATE_INPUT" >&2 + exit 1 + } + ;; + esac +fi FM_AFK_LAUNCH_STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" FM_AFK_LAUNCH_RECORD="$FM_AFK_LAUNCH_STATE/.afk-daemon-terminal" FM_AFK_LAUNCH_LOCK="$FM_AFK_LAUNCH_STATE/.afk-launch.lock" diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index a7dcdb339d5..3328d893053 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -66,10 +66,31 @@ esac # shellcheck source=bin/fm-classify-lib.sh . "$SCRIPT_DIR/fm-classify-lib.sh" PAUSED_VERB=${FM_CLASSIFY_PAUSED_VERB:-$FM_CLASSIFY_PAUSED_VERB_DEFAULT} + +resolve_directory_input() { + local name=$1 path=$2 resolved + case "$path" in + /*) printf '%s\n' "$path"; return 0 ;; + esac + resolved=$(CDPATH='' cd -- "$path" 2>/dev/null && pwd -P) || { + echo "error: $name directory cannot be resolved: $path" >&2 + return 1 + } + printf '%s\n' "$resolved" +} + FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" -DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" -STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +FM_HOME=$(resolve_directory_input FM_HOME "${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}") || exit 1 +if [ -n "${FM_DATA_OVERRIDE:-}" ]; then + DATA=$(resolve_directory_input FM_DATA_OVERRIDE "$FM_DATA_OVERRIDE") || exit 1 +else + DATA="$FM_HOME/data" +fi +if [ -n "${FM_STATE_OVERRIDE:-}" ]; then + STATE=$(resolve_directory_input FM_STATE_OVERRIDE "$FM_STATE_OVERRIDE") || exit 1 +else + STATE="$FM_HOME/state" +fi KIND=ship HERDR_LAB=0 NO_PROJECTS=0 diff --git a/bin/fm-config-inherit-lib.sh b/bin/fm-config-inherit-lib.sh index 95abba2439a..22109aa87aa 100644 --- a/bin/fm-config-inherit-lib.sh +++ b/bin/fm-config-inherit-lib.sh @@ -5,10 +5,12 @@ # (e.g. primary config/crew-dispatch.json makes a secondmate use the same dispatch # profile rules, primary config/crew-harness=codex makes a secondmate's crewmates # spawn on codex too, primary config/backlog-backend=manual makes that home -# hand-edit backlog files too, and primary config/herdr-presentation-spaces -# enables the same default-off Herdr presentation projection). It also pushes -# the one primary-authoritative shared captain-preference file, -# data/captain-shared.md, into each secondmate home's data/ as a read-only copy. +# hand-edit backlog files too, primary config/backend pins that home's local +# runtime-backend default for future spawns, and primary +# config/herdr-presentation-spaces enables the same default-off Herdr presentation +# projection). It also pushes the one primary-authoritative shared +# captain-preference file, data/captain-shared.md, into each secondmate home's +# data/ as a read-only copy. # # Usage: . bin/fm-config-inherit-lib.sh (no FM_* setup required) # @@ -40,7 +42,7 @@ FM_SHARED_CAPTAIN_MODE="444" # The declared inheritable set (space-separated, config-dir-relative item paths). # Extend here to inherit more of the primary's local config; override via the # environment only in tests. Items must not contain whitespace. -FM_INHERITABLE_CONFIG="${FM_INHERITABLE_CONFIG:-crew-dispatch.json crew-harness backlog-backend herdr-presentation-spaces}" +FM_INHERITABLE_CONFIG="${FM_INHERITABLE_CONFIG:-crew-dispatch.json crew-harness backlog-backend backend herdr-presentation-spaces}" fm_inherit_file_mode() { if [ "$(uname)" = Darwin ]; then diff --git a/bin/fm-config-push.sh b/bin/fm-config-push.sh index b4056744bc7..b760666dd1b 100755 --- a/bin/fm-config-push.sh +++ b/bin/fm-config-push.sh @@ -3,8 +3,8 @@ # Usage: fm-config-push.sh [--help] # # Mid-session convergence for inherited local material such as -# config/crew-dispatch.json edits or data/captain-shared.md updates. This -# discovers live secondmate homes from state/*.meta, backfills +# config/crew-dispatch.json, config/backend, or data/captain-shared.md updates. +# This discovers live secondmate homes from state/*.meta, backfills # home= from data/secondmates.md for older meta records, and reuses the same # propagation machinery as bootstrap, but deliberately does not # fast-forward tracked files. diff --git a/bin/fm-decision-board.sh b/bin/fm-decision-board.sh new file mode 100755 index 00000000000..57ca47bb0e8 --- /dev/null +++ b/bin/fm-decision-board.sh @@ -0,0 +1,406 @@ +#!/usr/bin/env bash +# fm-decision-board.sh - the captain's daily decision board, rendered from the +# canonical fleet snapshot. +# +# A thin renderer OVER bin/fm-fleet-snapshot.sh, in the same shape as +# fm-fleet-view.sh and fm-bearings-snapshot.sh: it never parses the backlog, +# task metadata, or status logs itself. It shells out to +# `fm-fleet-snapshot.sh --json`, selects the captain-owned open items from that +# stable contract, and renders them. Backlog and decision semantics stay owned +# by the snapshot; this script owns only the projection and the presentation. +# +# Two sources feed the board, both already normalized by the snapshot: +# 1. backlog records still open with hold_kind == "captain" - the durable +# captain-gated queue maintained through bin/fm-decision-hold.sh. +# 2. tasks whose hints.open_decisions carries a needs-decision event - a +# worker parked right now on an ask-user finding that the captain (or +# firstmate's configured authority) must answer. +# An id present in both is ONE board entry carrying both facts, never two rows. +# +# Entries are split by whether the captain's answer is enough on its own: +# ready - nothing else is in the way; his answer releases the work. +# gated - captain-owned, but unfinished work must land first. +# A parked worker is always ready: it is stopped on this answer right now, so no +# other unfinished work can release it. Any remaining dependency still prints. +# Within each group the longest-waiting entry sorts first, then id, so the +# staleness that motivates the daily cadence is visible at the top. +# +# Output is captain-facing, so it follows AGENTS.md section 9: plain outcome +# language, no internal vocabulary. Entries are NUMBERED because the captain +# answers these surfaces in bulk by position ("1 yes, 2 yes, 3 sure"); the +# durable id trails each entry so firstmate can act on the answer. +# +# Titles and questions are relayed VERBATIM from the durable record, bounded but +# never paraphrased: a generator that reworded them would be inventing the very +# thing the captain is answering. Section 9 translation is therefore a duty of +# whoever WRITES a backlog entry, and this renderer only enforces the parts it +# can carry honestly - project names instead of paths, and plain group wording. +# +# Usage: +# fm-decision-board.sh [--format markdown|html|json] [--snapshot ] +# [--limit ] [--title ] +# +# --format markdown (default) drop-in section for the morning packet +# --format html standalone page for an on-demand captain surface +# --format json the projected model (schema fm-decision-board.v1) +# --snapshot read an existing fm-fleet-snapshot.v1 document instead of +# running a fresh snapshot; "-" reads standard input +# --limit cap entries per group (default 25; 0 means no cap). +# Anything dropped is disclosed in omitted[] and in the +# rendered footer, so a bounded board never reads complete. +# --title override the rendered heading +# +# Read-only: no locks, no mutation, no network. Exits 3 when the snapshot is +# missing its backlog source, so an empty board is never confused with a +# board that could not be built. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FLEET="$SCRIPT_DIR/fm-fleet-snapshot.sh" + +SCHEMA=fm-decision-board.v1 +FORMAT=markdown +SNAPSHOT_PATH="" +LIMIT=25 +TITLE="Captain decision board" + +usage() { + awk ' + NR == 1 { next } + /^#/ { sub(/^# ?/, ""); print; next } + { exit } + ' "$0" +} + +fail() { + printf 'fm-decision-board: %s\n' "$*" >&2 + exit 1 +} + +while [ $# -gt 0 ]; do + case "$1" in + --format) shift; FORMAT=${1:-} ;; + --format=*) FORMAT=${1#--format=} ;; + --snapshot) shift; SNAPSHOT_PATH=${1:-} ;; + --snapshot=*) SNAPSHOT_PATH=${1#--snapshot=} ;; + --limit) shift; LIMIT=${1:-} ;; + --limit=*) LIMIT=${1#--limit=} ;; + --title) shift; TITLE=${1:-} ;; + --title=*) TITLE=${1#--title=} ;; + -h|--help) usage; exit 0 ;; + *) usage >&2; exit 2 ;; + esac + shift +done + +case "$FORMAT" in + markdown|html|json) ;; + *) fail "unknown --format '$FORMAT' (markdown, html, json)" ;; +esac +case "$LIMIT" in + ''|*[!0-9]*) fail "--limit must be a non-negative integer" ;; +esac +[ -n "$TITLE" ] || fail "--title must not be empty" + +command -v jq >/dev/null 2>&1 || fail "jq not found" + +if [ -n "$SNAPSHOT_PATH" ]; then + if [ "$SNAPSHOT_PATH" = - ]; then + SNAP=$(cat) + else + [ -r "$SNAPSHOT_PATH" ] || fail "cannot read snapshot: $SNAPSHOT_PATH" + SNAP=$(cat "$SNAPSHOT_PATH") + fi +else + SNAP=$("$FLEET" --json) || exit $? +fi + +printf '%s' "$SNAP" | jq -e 'type == "object"' >/dev/null 2>&1 \ + || fail "snapshot is not a JSON object" + +NOW=${FM_DECISION_BOARD_NOW:-$(date -u +%Y-%m-%dT%H:%M:%SZ)} + +# --- projection ------------------------------------------------------------- +# +# One jq program builds the whole model so every renderer below reads the same +# facts. Truncation bounds match the snapshot's own so a long backlog line +# cannot blow up a packet section. +MODEL=$(printf '%s' "$SNAP" | jq \ + --arg schema "$SCHEMA" \ + --arg now "$NOW" \ + --arg title "$TITLE" \ + --argjson limit "$LIMIT" ' + def trunc($n): + if . == null then null + else tostring | gsub("\\s+"; " ") | if length > $n then .[:$n] + "…" else . end + end; + + # A registry entry may name a project by absolute path. The captain reads the + # project, never the path (AGENTS.md section 9), so keep only the last segment. + def project_name: + if . == null then null + else tostring | sub("/+$"; "") | split("/") | last + end; + + def date_prefix: + if type != "string" then null + else try (([capture("^(?\\d{4}-\\d{2}-\\d{2})").d] | first) // null) catch null + end; + + def date_epoch: + if . == null then null + else + . as $date + | (try (strptime("%Y-%m-%d") | mktime) catch null) + | if . == null or (strftime("%Y-%m-%d") != $date) then null else . end + end; + + # Whole days between an ISO date prefix and now; null when either is unusable. + def waiting_days($since; $now): + ($since | date_prefix | date_epoch) as $s + | ($now | date_prefix | date_epoch) as $n + | if $s == null or $n == null then null + else + (($n - $s) / 86400 | floor) + | if . < 0 then null else . end + end; + + def waiting_error($id; $since): + if $since == null or ($since | date_prefix | date_epoch) != null then null + else "Could not read waiting date for \($id): \($since | trunc(120))." + end; + + . as $snap + | ($snap.backlog.present == true) as $backlog_present + + # Captain-owned backlog entries that are still open. + | ([ $snap.backlog.records[]? + | select(.structured == true and .state != "done" and .hold_kind == "captain") + | { + id: (.id // "unknown"), + title: (.title | trunc(200)), + question: (.hold_reason | trunc(320)), + repo: (.repo | project_name | trunc(120)), + since: (.since // null), + blocked_by: ((.unresolved_blocker_ids // []) | map(trunc(120))), + sources: ["queue"] + } ]) as $queue + + # Workers parked on an unanswered decision right now. + | ([ $snap.tasks[]? + | select((.hints.open_decisions // []) | any(.verb == "needs-decision")) + | { + id: (.id // "unknown"), + title: ((.backlog.title // .id) | trunc(200)), + question: (((.hints.open_decisions // []) + | map(select(.verb == "needs-decision")) + | map(.summary // "") | map(select(. != "")) + | join(" · ")) | trunc(320)), + repo: ((.backlog.repo // .project) | project_name | trunc(120)), + since: (.backlog.since // null), + blocked_by: [], + sources: ["parked"] + } ]) as $parked + + # One entry per id. A queue entry keeps its durable wording and gains the + # parked fact; a parked-only worker becomes its own entry. + | ([ $queue[] | .id ]) as $queue_ids + | ($queue + + [ $parked[] | select(.id as $i | $queue_ids | index($i) | not) ]) as $merged + | ([ $parked[] | .id ]) as $parked_ids + | ([ $merged[] + | . as $entry + | .parked = ($parked_ids | index($entry.id) != null) + | .parked_note = (if ($parked_ids | index($entry.id)) == null then null + else ([ $parked[] | select(.id == $entry.id) | .question ] | first) + end) + | .sources = (if ($parked_ids | index($entry.id)) == null then .sources + else (.sources + ["parked"] | unique) end) + | .waiting_days = waiting_days(.since; $now) + | .waiting_error = waiting_error(.id; .since) + | .ready = (.parked or ((.blocked_by | length) == 0)) + ]) as $entries + + | (def order: sort_by([(0 - (.waiting_days // -1)), .id]); + [ $entries[] | select(.ready) ] | order) as $ready_all + | (def order: sort_by([(0 - (.waiting_days // -1)), .id]); + [ $entries[] | select(.ready | not) ] | order) as $gated_all + + | (if $limit == 0 then $ready_all else $ready_all[:$limit] end) as $ready + | (if $limit == 0 then $gated_all else $gated_all[:$limit] end) as $gated + + | { + schema: $schema, + generated: $now, + title: $title, + home: ($snap.fm_home // null), + source_present: $backlog_present, + counts: { + total: ($entries | length), + ready: ($ready_all | length), + gated: ($gated_all | length), + parked: ([ $entries[] | select(.parked) ] | length), + shown: (($ready | length) + ($gated | length)) + }, + ready: $ready, + gated: $gated, + omitted: [ + if ($ready_all | length) > ($ready | length) then + {surface: "ready", count: (($ready_all | length) - ($ready | length)), + reveal: "--limit 0"} + else empty end, + if ($gated_all | length) > ($gated | length) then + {surface: "gated", count: (($gated_all | length) - ($gated | length)), + reveal: "--limit 0"} + else empty end, + if $backlog_present != true then + {surface: "queue", count: 0, reveal: "no readable decision queue"} + else empty end + ] + } + ') || fail "could not project the fleet snapshot" + +printf '%s' "$MODEL" | jq -e '.source_present == true' >/dev/null 2>&1 \ + || { echo "fm-decision-board: no readable decision queue in the fleet snapshot" >&2; exit 3; } + +# --- renderers -------------------------------------------------------------- + +render_markdown() { + printf '%s' "$MODEL" | jq -r ' + def dash($v): if $v == null or $v == "" then null else $v end; + def waited($e): + if $e.waiting_days == null then null + elif $e.waiting_days == 0 then "raised today" + elif $e.waiting_days == 1 then "waiting 1 day" + else "waiting \($e.waiting_days) days" end; + def facts($e): + [waited($e), dash($e.repo), (if $e.parked then "work is parked on it" else null end)] + | map(select(. != null)) | join(" · "); + def entry($e; $n): + "\($n). **\($e.title)**" + + (if dash($e.question) == null then "" else "\n \($e.question)" end) + + (if $e.parked and dash($e.parked_note) != null and $e.parked_note != $e.question + then "\n Worker is stopped here: \($e.parked_note)" else "" end) + + (if ($e.blocked_by | length) > 0 + then "\n Waiting first on: \($e.blocked_by | join(", "))" else "" end) + + "\n _\(facts($e))_ · `\($e.id)`"; + def errors($entries): + [ $entries[] | select(.waiting_error != null) | "_Error: \(.waiting_error)_" ]; + + (.ready | length) as $ready_shown + | ["## \(.title)", ""] + + (if .counts.total == 0 then + ["Nothing is waiting on you."] + else + ["\(.counts.total) open · \(.counts.ready) need only your answer" + + (if .counts.gated > 0 then " · \(.counts.gated) waiting on other work first" else "" end) + + (if .counts.parked > 0 then " · \(.counts.parked) with work stopped on them" else "" end) + + ".", ""] + + (if (.ready | length) > 0 then + ["### Needs your answer (\(.counts.ready))", ""] + + errors(.ready) + + [ .ready | to_entries[] | entry(.value; .key + 1) ] + + [""] + else [] end) + + (if (.gated | length) > 0 then + ["### Waiting on other work first (\(.counts.gated))", ""] + + errors(.gated) + + [ .gated | to_entries[] | entry(.value; .key + $ready_shown + 1) ] + + [""] + else [] end) + end) + + [ .omitted[] | "_\(.count) more \(.surface) not shown - reveal with `\(.reveal)`._" ] + | .[] + ' +} + +render_html() { + printf '%s' "$MODEL" | jq -r ' + def esc: if . == null then "" else tostring | @html end; + def dash($v): if $v == null or $v == "" then null else $v end; + def waited($e): + if $e.waiting_days == null then null + elif $e.waiting_days == 0 then "raised today" + elif $e.waiting_days == 1 then "waiting 1 day" + else "waiting \($e.waiting_days) days" end; + def facts($e): + [waited($e), dash($e.repo)] | map(select(. != null)) | map(esc) | join(" · "); + def entry($e; $n): + ["
  • ", + "
    \($n)\($e.title | esc)" + + (if $e.parked then "work parked" else "" end) + + "
    ", + (if dash($e.question) == null then "" else "

    \($e.question | esc)

    " end), + (if $e.parked and dash($e.parked_note) != null and $e.parked_note != $e.question + then "

    Worker is stopped here: \($e.parked_note | esc)

    " else "" end), + (if ($e.blocked_by | length) > 0 + then "

    Waiting first on: \($e.blocked_by | map(esc) | join(", "))

    " + else "" end), + "

    \(facts($e))\($e.id | esc)

    ", + "
  • "] + | map(select(. != "")) | join("\n"); + def errors($entries): + [ $entries[] | select(.waiting_error != null) + | "

    \(.waiting_error | esc)

    " ]; + + (.ready | length) as $ready_shown + | ["", + "", + "", + "\(.title | esc)", + "
    ", + "

    \(.title | esc)

    ", + "

    \(.generated | esc)

    "] + + (if .counts.total == 0 then + ["

    Nothing is waiting on you.

    "] + else + ["

    \(.counts.total) open · \(.counts.ready) need only your answer" + + (if .counts.gated > 0 then " · \(.counts.gated) waiting on other work first" else "" end) + + ".

    "] + + (if (.ready | length) > 0 then + ["

    Needs your answer (\(.counts.ready))

    "] + + errors(.ready) + ["
      "] + + [ .ready | to_entries[] | entry(.value; .key + 1) ] + ["
    "] + else [] end) + + (if (.gated | length) > 0 then + ["

    Waiting on other work first (\(.counts.gated))

    "] + + errors(.gated) + ["
      "] + + [ .gated | to_entries[] | entry(.value; .key + $ready_shown + 1) ] + ["
    "] + else [] end) + end) + + [ .omitted[] | "

    \(.count) more \(.surface | esc) not shown - reveal with \(.reveal | esc).

    " ] + + ["
    "] + | .[] + ' +} + +case "$FORMAT" in + json) printf '%s' "$MODEL" | jq . ;; + markdown) render_markdown ;; + html) render_html ;; +esac diff --git a/bin/fm-harness.sh b/bin/fm-harness.sh index f2ee8fe7e81..824b95804de 100755 --- a/bin/fm-harness.sh +++ b/bin/fm-harness.sh @@ -48,7 +48,7 @@ detect_own() { local pid=$$ comm args for _ in 1 2 3 4 5 6 7 8; do comm=$(ps -o comm= -p "$pid" 2>/dev/null) || break - case "$(basename "$comm")" in + case "$(basename -- "$comm")" in *claude*) echo claude; return ;; *codex*) echo codex; return ;; *opencode*) echo opencode; return ;; diff --git a/bin/fm-session-lock-lib.sh b/bin/fm-session-lock-lib.sh index 740a7a31e36..0c963685173 100644 --- a/bin/fm-session-lock-lib.sh +++ b/bin/fm-session-lock-lib.sh @@ -14,24 +14,56 @@ # Known harness command names; extend when a new adapter is verified. 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 -# session, unlike the transient subshell pid of any one tool call. +# Walk the current process ancestry (up to 16 hops) and print a harness pid. +# For every harness except Claude, the first match wins (innermost pid), which +# is where e.g. Pi's shared signed-wrapper ancestry actually holds the session: +# a "pi-signed" launcher can be the direct parent of the inner "pi" engine +# pid that owns the lock, and the wrapper pid above it is not that owner. +# Claude Code's bg-spare hook worker chain is the opposite shape: it nests +# several claude-named processes directly parent-child with no non-harness +# process between them, and the lock is held by the outermost pid of that +# run. So once a claude-named match is found, this keeps walking past it +# looking for a still-more-ancestral claude-named match, and stops the +# instant a non-match follows - never walking past that gap to an unrelated +# claude-named process further up the real process tree (e.g. the live +# session that launched a test as its own subprocess). The harness pid lives +# as long as the session, unlike the transient subshell pid of any one tool +# call. fm_harness_ancestry_pid() { - local pid=$$ comm args - for _ in 1 2 3 4 5 6 7 8; do - comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1 + local pid=$$ comm args best='' bc extending=0 hit=0 is_claude=0 + for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16; do + comm=$(ps -o comm= -p "$pid" 2>/dev/null) || break args=$(ps -o args= -p "$pid" 2>/dev/null) - if printf '%s' "$(basename "$comm")" | grep -qE "$FM_HARNESS_RE"; then - echo "$pid"; return 0 + bc=$(basename -- "$comm") + hit=0; is_claude=0 + if printf '%s' "$bc" | grep -qE "$FM_HARNESS_RE"; then + hit=1 + case "$bc" in *claude*) is_claude=1 ;; esac + else + # Bare interpreter (e.g. node): match the harness name in its script path. + case "$comm" in + *node*|*python*) + if printf '%s' "$args" | grep -qE "$FM_HARNESS_RE"; then + hit=1 + case "$args" in *claude*) is_claude=1 ;; esac + fi + ;; + esac + fi + if [ "$hit" -eq 1 ]; then + best="$pid" + if [ "$is_claude" -eq 1 ]; then + extending=1 + else + break + fi + elif [ "$extending" -eq 1 ]; then + break fi - # Bare interpreter (e.g. node): match the harness name in its script path. - case "$comm" in - *node*|*python*) printf '%s' "$args" | grep -qE "$FM_HARNESS_RE" && { echo "$pid"; return 0; } ;; - esac pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') - [ -n "$pid" ] && [ "$pid" -gt 1 ] || return 1 + [ -n "$pid" ] && [ "$pid" -gt 1 ] || break done + [ -n "$best" ] && { echo "$best"; return 0; } return 1 } @@ -40,7 +72,7 @@ fm_harness_pid_alive() { local pid=$1 comm args kill -0 "$pid" 2>/dev/null || return 1 comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1 - if printf '%s' "$(basename "$comm")" | grep -qE "$FM_HARNESS_RE"; then + if printf '%s' "$(basename -- "$comm")" | grep -qE "$FM_HARNESS_RE"; then return 0 fi case "$comm" in diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 18ead35bc2b..2e80a552b9e 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -127,6 +127,26 @@ esac FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" + +resolve_directory_input() { + local name=$1 path=$2 resolved + case "$path" in + /*) printf '%s\n' "$path"; return 0 ;; + esac + resolved=$(CDPATH='' cd -- "$path" 2>/dev/null && pwd -P) || { + echo "error: $name directory cannot be resolved: $path" >&2 + return 1 + } + printf '%s\n' "$resolved" +} + +FM_HOME=$(resolve_directory_input FM_HOME "$FM_HOME") || exit 1 +if [ -n "${FM_STATE_OVERRIDE:-}" ]; then + FM_STATE_OVERRIDE=$(resolve_directory_input FM_STATE_OVERRIDE "$FM_STATE_OVERRIDE") || exit 1 +fi +if [ -n "${FM_DATA_OVERRIDE:-}" ]; then + FM_DATA_OVERRIDE=$(resolve_directory_input FM_DATA_OVERRIDE "$FM_DATA_OVERRIDE") || exit 1 +fi STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" @@ -1520,6 +1540,16 @@ LAUNCH=${LAUNCH//__PIEXT__/$sq_piext} LAUNCH=${LAUNCH//__PITURNEND__/$sq_piturnend} LAUNCH=${LAUNCH//__PIWATCH__/$sq_piwatch} LAUNCH=${LAUNCH//__OPINPUT__/$sq_opinput} +# Crewmate panes are created by a long-lived tmux/herdr daemon that does not +# inherit firstmate's current environment, so a bare `claude` in the pane falls +# back to the default ~/.claude store even when firstmate itself runs under a +# different CLAUDE_CONFIG_DIR (for example a work-vs-personal subscription split). +# Forward firstmate's own resolved store onto the claude launch so the crewmate +# uses the same credential/config firstmate is authenticated with. Only when set; +# an unset value is the single-store default and needs no prefix. +if [ "$HARNESS" = claude ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then + LAUNCH="CLAUDE_CONFIG_DIR=$(shell_quote "$CLAUDE_CONFIG_DIR") $LAUNCH" +fi if [ "$KIND" = secondmate ]; then sq_home=$(shell_quote "$PROJ_ABS") LAUNCH="FM_ROOT_OVERRIDE= FM_STATE_OVERRIDE= FM_DATA_OVERRIDE= FM_PROJECTS_OVERRIDE= FM_CONFIG_OVERRIDE= FM_HOME=$sq_home $LAUNCH" diff --git a/bin/fm-subagent-pretool-check.sh b/bin/fm-subagent-pretool-check.sh index 169e3079d21..8edb507218b 100755 --- a/bin/fm-subagent-pretool-check.sh +++ b/bin/fm-subagent-pretool-check.sh @@ -65,6 +65,19 @@ DELEGATION_STEMS='agent subagent task workflow cron schedul worktree delegate sp # reason a runaway task cannot be stopped. OBSERVE_ONLY_TOOLS='taskoutput taskstop taskget tasklist cronlist bashoutput killshell' +# Exact lowercase tool names that match a stem above but create no RUNNABLE +# work. These write only the harness's session-local todo list, which has no +# executor: it spawns no agent, allocates no worktree, registers no schedule, +# and starts nothing that could outlive the session or escape a firstmate +# guard. Denying them stops the primary tracking its own plan while granting no +# delegation power, and the deny text would tell it to run bin/fm-brief.sh for a +# todo entry, so the stem match here is a false positive rather than a policy. +# This is a separate list from OBSERVE_ONLY_TOOLS on purpose: these tools WRITE, +# so folding them into a list documented as observe-or-stop would make that +# contract untrue. Both lists are exact-name, never substring, so neither can +# widen by accident. +PLAN_ONLY_TOOLS='taskcreate taskupdate' + TOOL="" TOOL_SET=0 CLAUDE_MODE=0 @@ -139,7 +152,7 @@ case "$TOOL" in mcp__*) exit 0 ;; esac -for allowed in $OBSERVE_ONLY_TOOLS; do +for allowed in $OBSERVE_ONLY_TOOLS $PLAN_ONLY_TOOLS; do [ "$NORMALIZED" != "$allowed" ] || exit 0 done diff --git a/bin/fm-supervise-daemon.sh b/bin/fm-supervise-daemon.sh index 6ec14aed4b9..30554edbbd4 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -1181,7 +1181,7 @@ is_wake_reason() { # # --- dispatch one wake reason to self-handle or escalate -------------------- # Side effects: logging, marker records, escalation buffer appends. handle_wake() { # - local reason=$1 state=$2 decision action distilled task last + local reason=$1 state=$2 decision action distilled task last stale_detail local kind="" arg="" if should_force_self "$reason"; then log "wake force-self (FM_INJECT_SKIP): $reason" @@ -1190,8 +1190,13 @@ handle_wake() { # case "$reason" in signal:*) kind=signal; arg="${reason#signal: }" decision=$(classify_signal "$arg" "$state") ;; - stale:*) kind=stale; arg="${reason#stale: }" - decision=$(classify_stale "$arg" "$state") ;; + stale:*) kind=stale; arg="${reason#stale: }"; stale_detail="${arg#"$arg"}" + case "$arg" in *" ("*) stale_detail="${arg#*" ("}"; arg="${arg%% \(*}" ;; esac + decision=$(classify_stale "$arg" "$state") + case "$stale_detail" in + idle\ *s,\ possible\ wedge,\ escalation\ *) + decision="escalate|${reason#stale: }" ;; + esac ;; check:*) decision=$(classify_check "$reason") ;; heartbeat|heartbeat:*) decision=$(classify_heartbeat) ;; *) decision=$(classify_unknown "$reason") ;; diff --git a/bin/fm-test-isolation-proof.sh b/bin/fm-test-isolation-proof.sh index 0a14a48022f..f84f8ed09ef 100755 --- a/bin/fm-test-isolation-proof.sh +++ b/bin/fm-test-isolation-proof.sh @@ -152,7 +152,6 @@ list_parallel_candidates() { tests/fm-arm-pretool-check.test.sh tests/fm-backend-herdr.test.sh tests/fm-brief.test.sh -tests/fm-captain-translation-contract.test.sh tests/fm-cd-pretool-check.test.sh tests/fm-composer-ghost.test.sh tests/fm-composer-lib.test.sh @@ -161,10 +160,7 @@ tests/fm-decision-hold-lifecycle.test.sh tests/fm-ensure-agents-md.test.sh tests/fm-grok-harness.test.sh tests/fm-herdr-lab.test.sh -tests/fm-instruction-owners.test.sh tests/fm-lint.test.sh -tests/fm-nm-test-contract.test.sh -tests/fm-no-mistakes-ownership.test.sh tests/fm-pi-primary-types.test.sh tests/fm-pr-merge.test.sh tests/fm-review-diff.test.sh @@ -172,7 +168,6 @@ tests/fm-send-popup-settle.test.sh tests/fm-send-settle.test.sh tests/fm-send-strict.test.sh tests/fm-spawn-batch.test.sh -tests/fm-stow-contract.test.sh tests/fm-supervision-instructions.test.sh tests/fm-test-run.test.sh tests/fm-tmux-submit-busy.test.sh diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 5726ece2214..71b4df9a658 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -39,7 +39,7 @@ # silently pass as a gate skip. # --jobs N run the selected scripts with up to N concurrent workers. # Default is 1 (serial). N>1 is allowed only when every -# selected script is in the Phase 2 proven-isolated set +# selected script is in the proven-isolated set # (bin/fm-test-isolation-proof.sh --list). Cap is 8. Stateful # families never schedule under --jobs. # -h, --help print this header @@ -121,15 +121,14 @@ now_ms() { family_for_basename() { case "$1" in fm-arm-pretool-check.test.sh|fm-ask-user-authority.test.sh|fm-brief.test.sh|\ - fm-calm-pi-extension.test.sh|fm-captain-translation-contract.test.sh|fm-cd-pretool-check.test.sh|\ + fm-calm-pi-extension.test.sh|fm-cd-pretool-check.test.sh|\ fm-composer-ghost.test.sh|fm-composer-lib.test.sh|\ fm-crew-state.test.sh|fm-decision-hold-lifecycle.test.sh|\ fm-documentation-audiences.test.sh|fm-ensure-agents-md.test.sh|fm-grok-harness.test.sh|\ - fm-kimi-harness.test.sh|fm-herdr-lab.test.sh|fm-instruction-owners.test.sh|fm-lint.test.sh|\ - fm-install-herdr.test.sh|fm-nm-test-contract.test.sh|fm-no-mistakes-ownership.test.sh|\ + fm-kimi-harness.test.sh|fm-herdr-lab.test.sh|fm-lint.test.sh|\ fm-no-mistakes-reviewer-recovery.test.sh|\ fm-operational-input.test.sh|fm-pi-primary-types.test.sh|\ - fm-send-popup-settle.test.sh|fm-send-settle.test.sh|fm-stow-contract.test.sh|\ + fm-send-popup-settle.test.sh|fm-send-settle.test.sh|\ fm-subagent-pretool-check.test.sh|\ fm-supervision-instructions.test.sh|fm-tmux-submit-busy.test.sh|fm-transition-lib.test.sh|\ fm-test-run.test.sh|fm-test-isolation-proof.test.sh) @@ -178,7 +177,7 @@ family_for_basename() { fm-afk-inject-e2e.test.sh|fm-afk-return.test.sh) printf '%s\n' afk ;; - fm-bearings-snapshot.test.sh|fm-fleet-snapshot-view.test.sh) + fm-bearings-snapshot.test.sh|fm-decision-board.test.sh|fm-fleet-snapshot-view.test.sh) printf '%s\n' snapshot-bearings ;; fm-backend-cmux.test.sh|fm-backend-cmux-smoke.test.sh) @@ -234,7 +233,7 @@ real-herdr-gated EOF } -# Exact Phase 2 proven-isolated candidate set (same paths as +# Exact proven-isolated candidate set (same paths as # bin/fm-test-isolation-proof.sh --list). Do not expand without a new concurrent # isolation proof archive. list_proven_isolated() { @@ -242,7 +241,6 @@ list_proven_isolated() { tests/fm-arm-pretool-check.test.sh tests/fm-backend-herdr.test.sh tests/fm-brief.test.sh -tests/fm-captain-translation-contract.test.sh tests/fm-cd-pretool-check.test.sh tests/fm-composer-ghost.test.sh tests/fm-composer-lib.test.sh @@ -251,10 +249,7 @@ tests/fm-decision-hold-lifecycle.test.sh tests/fm-ensure-agents-md.test.sh tests/fm-grok-harness.test.sh tests/fm-herdr-lab.test.sh -tests/fm-instruction-owners.test.sh tests/fm-lint.test.sh -tests/fm-nm-test-contract.test.sh -tests/fm-no-mistakes-ownership.test.sh tests/fm-pi-primary-types.test.sh tests/fm-pr-merge.test.sh tests/fm-review-diff.test.sh @@ -262,7 +257,6 @@ tests/fm-send-popup-settle.test.sh tests/fm-send-settle.test.sh tests/fm-send-strict.test.sh tests/fm-spawn-batch.test.sh -tests/fm-stow-contract.test.sh tests/fm-supervision-instructions.test.sh tests/fm-test-run.test.sh tests/fm-tmux-submit-busy.test.sh @@ -271,47 +265,41 @@ tests/fm-x-mode.test.sh EOF } -# Portable parallel shard 1: LPT balance of the proven-isolated set using -# Phase 1 serial duration averages from CI timing artifacts on main after -# #825/#832/#834 (docs/fm-test-portable-shards.md). Execution order is longest -# first so wall-clock stays near the balanced sum. +# Portable parallel shard 1: LPT balance of the proven-isolated set using the +# current concurrent-proof durations in docs/fm-test-isolation-proof.json. +# Execution order is longest first so wall-clock stays near the balanced sum. list_portable_parallel_1() { cat <<'EOF' -tests/fm-arm-pretool-check.test.sh +tests/fm-x-mode.test.sh tests/fm-cd-pretool-check.test.sh -tests/fm-backend-herdr.test.sh -tests/fm-pr-merge.test.sh +tests/fm-decision-hold-lifecycle.test.sh tests/fm-test-run.test.sh -tests/fm-send-popup-settle.test.sh +tests/fm-composer-ghost.test.sh +tests/fm-grok-harness.test.sh +tests/fm-lint.test.sh +tests/fm-pi-primary-types.test.sh tests/fm-review-diff.test.sh tests/fm-brief.test.sh -tests/fm-ensure-agents-md.test.sh -tests/fm-instruction-owners.test.sh -tests/fm-pi-primary-types.test.sh tests/fm-transition-lib.test.sh -tests/fm-composer-lib.test.sh -tests/fm-stow-contract.test.sh EOF } # Portable parallel shard 2: the complementary LPT half of the proven set. list_portable_parallel_2() { cat <<'EOF' -tests/fm-decision-hold-lifecycle.test.sh -tests/fm-x-mode.test.sh -tests/fm-herdr-lab.test.sh +tests/fm-backend-herdr.test.sh +tests/fm-arm-pretool-check.test.sh tests/fm-crew-state.test.sh -tests/fm-grok-harness.test.sh -tests/fm-spawn-batch.test.sh -tests/fm-send-strict.test.sh +tests/fm-herdr-lab.test.sh +tests/fm-pr-merge.test.sh +tests/fm-send-popup-settle.test.sh tests/fm-tmux-submit-busy.test.sh -tests/fm-composer-ghost.test.sh tests/fm-send-settle.test.sh +tests/fm-send-strict.test.sh +tests/fm-spawn-batch.test.sh tests/fm-supervision-instructions.test.sh -tests/fm-lint.test.sh -tests/fm-nm-test-contract.test.sh -tests/fm-captain-translation-contract.test.sh -tests/fm-no-mistakes-ownership.test.sh +tests/fm-ensure-agents-md.test.sh +tests/fm-composer-lib.test.sh EOF } diff --git a/bin/fm-wake-lib.sh b/bin/fm-wake-lib.sh index 929c7231a47..8cec58bec1d 100755 --- a/bin/fm-wake-lib.sh +++ b/bin/fm-wake-lib.sh @@ -9,6 +9,10 @@ STATE="${FM_STATE_OVERRIDE:-${STATE:-$FM_HOME/state}}" FM_WAKE_QUEUE="${FM_WAKE_QUEUE:-$STATE/.wake-queue}" FM_WAKE_QUEUE_LOCK="${FM_WAKE_QUEUE_LOCK:-$STATE/.wake-queue.lock}" FM_LOCK_STALE_AFTER="${FM_LOCK_STALE_AFTER:-2}" +# Resolved once at source time: fm_pid_identity and fm_path_mtime run inside 0.2s +# confirm and 0.5s attach polls, and forking uname per call is a measurable cost on +# the platform (Git Bash/MSYS) that already pays the highest fork price. +_FM_UNAME=$(uname 2>/dev/null || echo unknown) mkdir -p "$STATE" fm_current_pid() { @@ -24,17 +28,19 @@ fm_pid_alive() { } fm_pid_identity() { - local pid=$1 out proc_root stat_line starttime cmdline_hex + local pid=$1 out proc_root stat_line starttime cmdline_hex identity_key local -a stat_fields case "$pid" in ''|*[!0-9]*) return 1 ;; esac proc_root=${FM_PROC_ROOT_OVERRIDE:-/proc} - # Prefer /proc on Linux: stat field 22 (starttime, clock ticks since boot) is + # Prefer a Linux-compatible /proc when present: stat field 22 (starttime, clock ticks since boot) is # immune to the wall-clock steps that re-render the ps lstart fallback's date # (observed as WSL2 btime drift) and would evict a live watcher; combining the # full NUL-separated cmdline keeps PID reuse a mismatch even on a tick collision. - if [ "$(uname)" = Linux ] && [ -r "$proc_root/$pid/stat" ] && [ -r "$proc_root/$pid/cmdline" ]; then + # Git Bash/MSYS exposes these compatible files but its Cygwin ps rejects the + # portable fallback's -o fields, so capability detection must not key on uname. + if [ -r "$proc_root/$pid/stat" ] && [ -r "$proc_root/$pid/cmdline" ]; then stat_line=$(cat "$proc_root/$pid/stat" 2>/dev/null) || return 1 # After the final comm delimiter, array index 19 is proc stat field 22. read -r -a stat_fields <<< "${stat_line##*)}" @@ -45,7 +51,9 @@ fm_pid_identity() { esac cmdline_hex=$(od -An -v -tx1 "$proc_root/$pid/cmdline" 2>/dev/null | tr -d '[:space:]') || return 1 [ -n "$cmdline_hex" ] || return 1 - printf 'linux-starttime=%s cmdline-hex=%s\n' "$starttime" "$cmdline_hex" + identity_key=proc-starttime + [ "$_FM_UNAME" != Linux ] || identity_key=linux-starttime + printf '%s=%s cmdline-hex=%s\n' "$identity_key" "$starttime" "$cmdline_hex" return 0 fi # Pin LC_ALL=C so lstart's date format is locale-invariant: the identity is @@ -57,7 +65,7 @@ fm_pid_identity() { } fm_path_mtime() { - if [ "$(uname)" = Darwin ]; then + if [ "$_FM_UNAME" = Darwin ]; then stat -f %m "$1" 2>/dev/null else stat -c %Y "$1" 2>/dev/null diff --git a/bin/fm-watch-arm.sh b/bin/fm-watch-arm.sh index 0790b04a4e6..3820a7320f5 100755 --- a/bin/fm-watch-arm.sh +++ b/bin/fm-watch-arm.sh @@ -70,7 +70,13 @@ BEAT="$STATE/.last-watcher-beat" # "Fresh" reuses the guard's threshold so there is one definition of liveness. GRACE=${FM_GUARD_GRACE:-300} # How long to wait for a freshly forked watcher to acquire the lock and beat. -CONFIRM_TIMEOUT=${FM_ARM_CONFIRM_TIMEOUT:-10} +# Git Bash/MSYS pays a much higher fork cost while the watcher completes its +# required pre-lock migration, so its bounded default covers that cold start. +case "${OSTYPE:-}" in + msys*|mingw*|cygwin*) ARM_CONFIRM_DEFAULT=30 ;; + *) ARM_CONFIRM_DEFAULT=10 ;; +esac +CONFIRM_TIMEOUT=${FM_ARM_CONFIRM_TIMEOUT:-$ARM_CONFIRM_DEFAULT} # Poll interval while attached to an existing healthy watcher. ATTACH_POLL=${FM_ARM_ATTACH_POLL:-0.5} CYCLE_LOG="$STATE/.watch-cycle-exits.log" diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 689ac45626f..f9c5cf94768 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -30,7 +30,16 @@ # also carries a "demand-deep-inspection" marker so the # wake payload itself, not just repetition, forces a # closer look instead of another routine supervision -# resume. Unless afk is active. +# resume. Unless afk is active. A genuinely busy pane +# (window_is_busy true) is exempt from the above, but +# only up to BUSY_TURN_MAX_SECS with no completed turn +# (state/.turn-ended, or the spawn record before any +# turn completes); past that bound busy_turn_over_age +# routes it through the same wedge timer, so it surfaces +# with the identical "stale: ..." reason, escalation +# count, and demand-deep-inspection marker, for human +# inspection only - never an automatic interrupt, +# signal, or restart of the worker or its tool process. # check: copy", "repo": "portfolio-site", + "hold_kind": "captain", "hold_reason": "wording uses markup & entities", + "since": "2026-07-28", "unresolved_blocker_ids": [] + }, + { + "id": "external", "structured": true, "state": "queued", "kind": "ship", + "title": "Wait on the upstream release", "repo": "machine", + "hold_kind": "external", "hold_reason": "upstream has not shipped", + "since": "2026-07-01", "unresolved_blocker_ids": [] + }, + { + "id": "settled", "structured": true, "state": "done", "kind": "captain", + "title": "Already answered", "repo": "machine", + "hold_kind": "captain", "hold_reason": "he answered this one", + "since": "2026-07-02", "unresolved_blocker_ids": [] + } + ] + }, + "tasks": [ + { + "id": "both-ask", + "project": "/home/fixture/ventures/x-bookmark-poster", + "backlog": {"title": "Rethink the media model", "repo": "x-bookmark-poster", "since": "2026-07-22"}, + "hints": { + "pending_decision": true, + "open_decisions": [{"key": "default", "verb": "needs-decision", + "summary": "the third review round repeats the same family of findings"}, + {"key": "upstream", "verb": "blocked", + "summary": "firstmate must repair the upstream dependency"}] + } + }, + { + "id": "parked-only", + "project": "/home/fixture/ventures/polymarket-platform-dev", + "backlog": null, + "hints": { + "pending_decision": true, + "open_decisions": [{"key": "default", "verb": "needs-decision", + "summary": "a lost confirm can leave admission released"}] + } + }, + { + "id": "blocker-only", + "project": "/home/fixture/ventures/blocked", + "backlog": null, + "hints": { + "pending_decision": false, + "open_decisions": [{"key": "default", "verb": "blocked", + "summary": "firstmate owns this blocked task"}] + } + }, + { + "id": "quiet-worker", + "project": "/home/fixture/ventures/quiet", + "backlog": null, + "hints": {"pending_decision": false, "open_decisions": []} + } + ] +} +JSON +} + +SNAP="$TMP_ROOT/snapshot.json" +write_snapshot "$SNAP" + +# --- source selection ------------------------------------------------------- + +out=$("$BOARD" --snapshot "$SNAP" --limit 0) +assert_contains "$out" "old-ask" "an open captain-owned entry must reach the board" +assert_not_contains "$out" "external" "a non-captain hold must never reach the board" +assert_not_contains "$out" "settled" "an already-answered entry must never reach the board" +assert_not_contains "$out" "quiet-worker" "a worker with no open decision must not reach the board" +assert_not_contains "$out" "blocker-only" "a firstmate-owned blocker must not reach the board" +assert_not_contains "$out" "firstmate must repair" \ + "a firstmate-owned blocker must not join a captain decision summary" +pass "the board carries only open captain-owned items" + +# --- one entry per id ------------------------------------------------------- + +TICK='`' # the rendered id delimiter, kept in a variable so patterns stay literal +count=$(printf '%s\n' "$out" | grep -c -F "${TICK}both-ask${TICK}") +[ "$count" = 1 ] || fail "an id in both sources must render exactly one entry (got $count)" +assert_contains "$out" "the data model needs a rethink" "the durable wording must survive the merge" +assert_contains "$out" "the third review round repeats" "the parked worker's note must survive the merge" +pass "an id in both sources becomes one entry carrying both facts" + +# --- ready versus waiting-on-other-work ------------------------------------- + +ready_block=${out%%### Waiting on other work first*} +gated_block=${out#*### Waiting on other work first} +assert_contains "$ready_block" "old-ask" "an unblocked captain-owned entry needs only his answer" +assert_contains "$gated_block" "gated-ask" "an entry waiting on unfinished work must be separated" +assert_not_contains "$ready_block" "gated-ask" "a blocked entry must not claim to need only his answer" +assert_contains "$ready_block" "both-ask" "a parked worker needs his answer even with a dependency" +assert_contains "$ready_block" "parked-only" "a parked worker with no queue entry must still reach him" +assert_contains "$out" "quality-program" "a remaining dependency must still be named" +pass "the ready split honors dependencies and the parked override" + +# --- displayed numbering --------------------------------------------------- + +numbers=$(printf '%s\n' "$out" | sed -n 's/^\([0-9][0-9]*\)\. \*\*.*/\1/p') +number_count=$(printf '%s\n' "$numbers" | sed '/^$/d' | wc -l | tr -d ' ') +unique_number_count=$(printf '%s\n' "$numbers" | sed '/^$/d' | sort -nu | wc -l | tr -d ' ') +first_gated_number=$(printf '%s\n' "$gated_block" | sed -n 's/^\([0-9][0-9]*\)\. \*\*.*/\1/p' | head -1) +[ "$first_gated_number" = 6 ] \ + || fail "the gated section must continue after the five ready entries (got $first_gated_number)" +[ "$number_count" = "$unique_number_count" ] \ + || fail "displayed numbers must not repeat across the whole board" +pass "markdown numbering continues across both sections" + +# --- ordering --------------------------------------------------------------- + +order=$(printf '%s\n' "$ready_block" | grep -o -E "${TICK}[a-z-]+${TICK}" | tr -d "$TICK") +first=$(printf '%s\n' "$order" | head -1) +[ "$first" = old-ask ] || fail "the longest-waiting entry must sort first (got $first)" +last=$(printf '%s\n' "$order" | tail -1) +[ "$last" != old-ask ] || fail "ordering must place the newest ask after the longest-waiting one" +assert_contains "$out" "waiting 20 days" "elapsed waiting time must be reported" +pass "entries sort longest-waiting first" + +# --- captain-facing presentation -------------------------------------------- + +assert_not_contains "$out" "/home/fixture/ventures" "a raw path must not reach a captain-facing surface" +assert_contains "$out" "phone-assistant" "the project must still be named" +assert_not_contains "$out" "hold_kind" "internal field names must not reach a captain-facing surface" +pass "presentation stays captain-facing" + +# --- bounding with disclosure ---------------------------------------------- + +bounded=$("$BOARD" --snapshot "$SNAP" --limit 2) +assert_contains "$bounded" "not shown" "a bounded board must disclose what it dropped" +assert_contains "$bounded" "--limit 0" "the disclosure must name how to reveal the rest" +shown=$(printf '%s\n' "$bounded" | grep -c -E '^[0-9]+\. \*\*') +[ "$shown" -le 4 ] || fail "--limit 2 must cap each group at two entries (got $shown)" +assert_contains "$bounded" "Needs your answer (5)" "the true open count must survive bounding" +pass "bounding caps entries while disclosing the remainder" + +# --- json format ------------------------------------------------------------ + +model=$("$BOARD" --snapshot "$SNAP" --format json --limit 0) +schema=$(printf '%s' "$model" | jq -r '.schema') +[ "$schema" = fm-decision-board.v1 ] || fail "json must carry the board schema (got $schema)" +totals=$(printf '%s' "$model" | jq -r '[.counts.total, .counts.ready, .counts.gated, .counts.parked] | join(",")') +[ "$totals" = "6,5,1,2" ] || fail "json counts must match the fixture (got $totals)" +blocked_json=$(printf '%s' "$model" | jq -r ' + [.ready[], .gated[]] + | map(select(.id == "blocker-only" or (.question // "" | contains("firstmate must repair")))) + | length +') +[ "$blocked_json" = 0 ] || fail "json must exclude firstmate-owned blockers (got $blocked_json)" +merged_sources=$(printf '%s' "$model" | jq -r '[.ready[] | select(.id == "both-ask") | .sources[]] | sort | join(",")') +[ "$merged_sources" = "parked,queue" ] || fail "a merged entry must record both sources (got $merged_sources)" +pass "the json model exposes the same facts as the rendered board" + +# --- html format ------------------------------------------------------------ + +html=$("$BOARD" --snapshot "$SNAP" --format html --limit 0) +assert_contains "$html" "" "html must be a standalone page" +assert_contains "$html" "<script>" "backlog markup must be escaped" +assert_not_contains "$html" "" "backlog markup must never render as live markup" +assert_not_contains "$html" "http://" "the page must not reference an external host" +assert_not_contains "$html" "https://" "the page must not reference an external host" +assert_not_contains "$html" "blocker-only" "html must exclude a firstmate-owned blocker" +assert_not_contains "$html" "firstmate must repair" \ + "html must exclude a firstmate-owned blocker from captain decision summaries" +html_numbers=$(printf '%s\n' "$html" | sed -n 's/.*\([0-9][0-9]*\)<\/span>.*/\1/p') +html_number_count=$(printf '%s\n' "$html_numbers" | sed '/^$/d' | wc -l | tr -d ' ') +html_unique_number_count=$(printf '%s\n' "$html_numbers" | sed '/^$/d' | sort -nu | wc -l | tr -d ' ') +html_gated=${html#*

    Waiting on other work first} +html_first_gated_number=$(printf '%s\n' "$html_gated" \ + | sed -n 's/.*\([0-9][0-9]*\)<\/span>.*/\1/p' | head -1) +[ "$html_first_gated_number" = 6 ] \ + || fail "the HTML gated section must continue after the five ready entries (got $html_first_gated_number)" +[ "$html_number_count" = "$html_unique_number_count" ] \ + || fail "HTML numbers must not repeat across the whole board" +pass "the html surface is standalone and escapes backlog text" + +# --- malformed waiting dates ----------------------------------------------- + +malformed="$TMP_ROOT/malformed-date.json" +jq ' + (.backlog.records[] | select(.id == "old-ask") | .since) = "not-a-date" + | (.backlog.records[] | select(.id == "gated-ask") | .since) = "2026-02-31" +' "$SNAP" > "$malformed" +set +e +malformed_out=$("$BOARD" --snapshot "$malformed" --limit 0 2>&1) +malformed_rc=$? +set -e +expect_code 0 "$malformed_rc" "malformed waiting dates must not abort the board" +for id in old-ask new-ask gated-ask both-ask parked-only markup; do + assert_contains "$malformed_out" "$id" "a malformed waiting date must not drop $id" +done +assert_contains "$malformed_out" "Could not read waiting date for old-ask: not-a-date." \ + "a malformed date prefix must surface an error in its section" +assert_contains "$malformed_out" "Could not read waiting date for gated-ask: 2026-02-31." \ + "an impossible calendar date must surface an error in its section" +assert_contains "$malformed_out" "waiting 1 day" \ + "an impossible calendar date must not stop valid waiting ages from rendering" +malformed_model=$("$BOARD" --snapshot "$malformed" --format json --limit 0) +invalid_waiting=$(printf '%s' "$malformed_model" | jq -r ' + .gated[] | select(.id == "gated-ask") + | [.waiting_days, .waiting_error] | @tsv +') +expected_invalid_waiting=$'\tCould not read waiting date for gated-ask: 2026-02-31.' +[ "$invalid_waiting" = "$expected_invalid_waiting" ] \ + || fail "an impossible calendar date must not fabricate a waiting age" +pass "malformed waiting dates stay visible without disrupting the board" + +# --- stdin snapshot --------------------------------------------------------- + +piped=$("$BOARD" --snapshot - --limit 0 < "$SNAP") +[ "$piped" = "$out" ] || fail "a snapshot read from standard input must render identically" +pass "the board reads a snapshot from standard input" + +# --- empty board versus unreadable source ----------------------------------- + +empty="$TMP_ROOT/empty.json" +jq '.backlog.records = [] | .tasks = []' "$SNAP" > "$empty" +set +e +empty_out=$("$BOARD" --snapshot "$empty" 2>&1) +empty_rc=$? +set -e +expect_code 0 "$empty_rc" "an empty board is a success" +assert_contains "$empty_out" "Nothing is waiting on you" "an empty board must say so plainly" + +missing="$TMP_ROOT/missing.json" +jq '.backlog.present = false | .backlog.records = []' "$SNAP" > "$missing" +set +e +missing_out=$("$BOARD" --snapshot "$missing" 2>&1) +missing_rc=$? +set -e +expect_code 3 "$missing_rc" "an unreadable decision queue must refuse rather than print an empty board" +assert_not_contains "$missing_out" "Nothing is waiting on you" \ + "an unreadable queue must never read as an empty board" +pass "an empty board is distinguishable from an unreadable source" + +# --- argument validation ---------------------------------------------------- + +set +e +"$BOARD" --snapshot "$SNAP" --format toon >/dev/null 2>&1 +bad_format=$? +"$BOARD" --snapshot "$SNAP" --limit two >/dev/null 2>&1 +bad_limit=$? +"$BOARD" --snapshot "$TMP_ROOT/does-not-exist.json" >/dev/null 2>&1 +bad_path=$? +"$BOARD" --help >/dev/null 2>&1 +help_rc=$? +set -e +[ "$bad_format" -ne 0 ] || fail "an unsupported format must be refused" +[ "$bad_limit" -ne 0 ] || fail "a non-numeric limit must be refused" +[ "$bad_path" -ne 0 ] || fail "an unreadable snapshot path must be refused" +expect_code 0 "$help_rc" "--help must succeed" +pass "invalid arguments are refused" diff --git a/tests/fm-documentation-audiences.test.sh b/tests/fm-documentation-audiences.test.sh index 11854594afe..90222802f6a 100755 --- a/tests/fm-documentation-audiences.test.sh +++ b/tests/fm-documentation-audiences.test.sh @@ -135,26 +135,7 @@ MD pass "local links resolve while dates, versions, commands, and incident prose remain semantically reviewed" } -test_no_mistakes_document_schema() { - local config="$ROOT/.no-mistakes.yaml" - assert_grep 'document:' "$config" "trusted Document config is missing" - assert_grep ' instructions: |' "$config" "Document instructions use an unsupported shape" - assert_grep 'docs/documentation-audiences.json' "$config" \ - "Document instructions do not point to the audience inventory" - assert_grep 'complete' "$config" \ - "Document instructions do not require a complete branch-diff review" - if command -v ruby >/dev/null 2>&1; then - ruby -e ' - require "yaml" - data = YAML.safe_load(File.read(ARGV.fetch(0))) - abort unless data.dig("document", "instructions").is_a?(String) - ' "$config" || fail ".no-mistakes.yaml did not parse document.instructions" - fi - pass "no-mistakes uses the supported trusted document.instructions schema" -} - test_repository_inventory_passes test_duplicate_and_setup_classification_fail test_required_pointer_fails test_local_links_and_no_keyword_heuristic -test_no_mistakes_document_schema diff --git a/tests/fm-gate-refuse.test.sh b/tests/fm-gate-refuse.test.sh index 478760cb962..aff57ab18e1 100755 --- a/tests/fm-gate-refuse.test.sh +++ b/tests/fm-gate-refuse.test.sh @@ -361,36 +361,6 @@ test_teardown_refuses_and_admits() { pass "fm-teardown: refuses on marker and gate-worktree backstop; a normal teardown is unaffected" } -# --- tracked .no-mistakes.yaml ---------------------------------------------- - -test_no_mistakes_yaml_disables_project_settings() { - local file="$ROOT/.no-mistakes.yaml" val tab - assert_present "$file" "tracked .no-mistakes.yaml is missing" - git -C "$ROOT" ls-files --error-unmatch .no-mistakes.yaml >/dev/null 2>&1 \ - || fail ".no-mistakes.yaml is not tracked by git" - - # Parse with a real YAML loader and assert the field is boolean true, so a - # malformed file or a stringy "true" fails where a naive grep would pass. - if command -v python3 >/dev/null 2>&1 && python3 -c 'import yaml' >/dev/null 2>&1; then - val=$(python3 -c 'import yaml,sys; print(yaml.safe_load(open(sys.argv[1])).get("disable_project_settings"))' "$file") \ - || fail ".no-mistakes.yaml did not parse as YAML (python3)" - [ "$val" = "True" ] || fail "disable_project_settings is not boolean true (python3 read: $val)" - elif command -v ruby >/dev/null 2>&1; then - ruby -ryaml -e 'exit((YAML.safe_load(File.read(ARGV[0]))["disable_project_settings"] == true) ? 0 : 1)' "$file" \ - || fail ".no-mistakes.yaml did not parse or disable_project_settings != true (ruby)" - else - # No YAML loader: fall back to a strict structural check - no tab indentation - # (YAML forbids it) and the top-level key mapped to the bare boolean true. - tab=$(printf '\t') - case "$(cat "$file")" in - *"$tab"*) fail ".no-mistakes.yaml uses a tab (invalid YAML indentation)" ;; - esac - grep -qxE 'disable_project_settings:[[:space:]]+true' "$file" \ - || fail "top-level 'disable_project_settings: true' not found in .no-mistakes.yaml" - fi - pass ".no-mistakes.yaml parses and sets disable_project_settings: true (trusted-only gate opt-out)" -} - test_helper_env_marker_refuses test_helper_empty_env_marker_refuses test_helper_path_backstop_refuses @@ -398,4 +368,3 @@ test_helper_normal_is_noop test_spawn_refuses_and_admits test_send_refuses_and_admits test_teardown_refuses_and_admits -test_no_mistakes_yaml_disables_project_settings diff --git a/tests/fm-gitignore-config.test.sh b/tests/fm-gitignore-config.test.sh new file mode 100755 index 00000000000..5b864dd6466 --- /dev/null +++ b/tests/fm-gitignore-config.test.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# .gitignore must ignore config/ as a directory, not by exact filename. +# +# A name-by-name list silently stops ignoring any new or home-local file under +# config/ (fm-gitignore-config-name-by-name): an unrecognized file there makes +# the working tree read as dirty, which then blocks guarded sync paths that +# refuse to touch a dirty home. +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +fail() { + printf 'not ok - %s\n' "$1" >&2 + exit 1 +} + +pass() { + printf 'ok - %s\n' "$1" +} + +random_leaf() { + printf '%s-%s' "$1" "$$-$RANDOM-$RANDOM" +} + +test_config_dir_ignored_as_category() { + local direct nested sample + direct="$(random_leaf config/unlisted-key)" + nested="config/$(random_leaf nested-dir)/$(random_leaf deep-file)" + for sample in "$direct" "$nested" config/some-new-key.admin; do + git -C "$ROOT" check-ignore -q "$sample" \ + || fail "git does not ignore $sample (config/ must be ignored as a directory)" + done + pass "config/ is ignored as a directory, covering unlisted and nested paths" +} + +test_unrelated_path_stays_visible() { + # Control: a path outside config/ must remain visible to Git, so the + # coverage above is proven by contrast rather than an always-ignoring rule. + local sibling + sibling="$(random_leaf not-config)" + git -C "$ROOT" check-ignore -q "$sibling" \ + && fail "git unexpectedly ignores $sibling (outside config/)" + pass "an unrelated path outside config/ remains visible to git" +} + +test_config_dir_ignored_as_category +test_unrelated_path_stays_visible diff --git a/tests/fm-gotmp.test.sh b/tests/fm-gotmp.test.sh index 96c12324db1..03b66d4c6a1 100755 --- a/tests/fm-gotmp.test.sh +++ b/tests/fm-gotmp.test.sh @@ -5,10 +5,10 @@ # gotmp/, exports GOTMPDIR into the crewmate pane, and records tasktmp= in the task's # meta. fm-teardown reads tasktmp= and removes the whole root on cleanup. # -# These tests exercise behavior directly: fm-teardown is run as a subprocess against a -# fake FM_HOME/FM_ROOT (built so the real script resolves into it), with stub helper scripts. -# Nothing is sourced. The fm-spawn side is verified both structurally (the source has -# the contract lines) and behaviorally (the mkdir + meta-write pattern it uses). +# These tests exercise fm-teardown directly as a subprocess against a fake FM_HOME/FM_ROOT +# built so the real script resolves into it, with stub helper scripts. +# The isolated fm-spawn subprocess in fm-kimi-harness.test.sh covers temp-root creation, +# metadata publication, and the pane environment export. set -u # This suite does not source tests/lib.sh, so exempt its teardown subprocess from @@ -18,7 +18,6 @@ set -u export FM_GATE_REFUSE_BYPASS=1 ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SPAWN="$ROOT/bin/fm-spawn.sh" TEARDOWN="$ROOT/bin/fm-teardown.sh" fail() { @@ -99,40 +98,6 @@ META printf '%s' "$fake" } -# --- fm-spawn side --- - -test_spawn_contract_and_mkdir_pattern() { - # Structural: fm-spawn must create the gotmp dir, record tasktmp in meta, and export - # GOTMPDIR into the pane. Assert the contract lines are present in the source. - # shellcheck disable=SC2016 # single quotes are deliberate: these are literal source strings - grep -F 'mkdir -p "$TASK_TMP/gotmp"' "$SPAWN" >/dev/null \ - || fail "fm-spawn missing: mkdir of gotmp under TASK_TMP" - # shellcheck disable=SC2016 # single quotes are deliberate: literal source string - grep -F 'echo "tasktmp=$TASK_TMP"' "$SPAWN" >/dev/null \ - || fail "fm-spawn missing: tasktmp= line in meta write" - grep -F 'export GOTMPDIR=' "$SPAWN" >/dev/null \ - || fail "fm-spawn missing: GOTMPDIR export into pane" - # Behavioral: the mkdir + meta-write pattern spawn uses must produce a gotmp dir and - # a meta line whose value the teardown grep (tasktmp=, cut -d= -f2-) reads back whole. - local id=spawn-sim-z1 - local sim_root="$TMP_ROOT/$id-root" - local task_tmp="$sim_root/tmp/fm-$id" - mkdir -p "$sim_root/state" - # Replicate spawn's exact mkdir + meta-write lines. - TASK_TMP="$task_tmp" - mkdir -p "$TASK_TMP/gotmp" - { - echo "tasktmp=$TASK_TMP" - } > "$sim_root/state/$id.meta" - [ -d "$task_tmp/gotmp" ] || fail "simulated spawn did not create gotmp dir" - # Teardown reads tasktmp= with `grep '^tasktmp=' | cut -d= -f2-`; round-trip it. - local read_back - read_back=$(grep '^tasktmp=' "$sim_root/state/$id.meta" | cut -d= -f2-) - [ "$read_back" = "$task_tmp" ] \ - || fail "tasktmp value not round-tripped by teardown's grep|cut (got '$read_back')" - pass "fm-spawn creates gotmp dir and records tasktmp in meta" -} - # --- fm-teardown side (real subprocess) --- test_teardown_removes_tasktmp_dir() { @@ -213,7 +178,6 @@ test_teardown_skips_gracefully_when_dir_missing() { pass "fm-teardown skips gracefully when tasktmp= points to a nonexistent dir" } -test_spawn_contract_and_mkdir_pattern test_teardown_removes_tasktmp_dir test_teardown_skips_gracefully_without_tasktmp test_teardown_skips_gracefully_when_dir_missing diff --git a/tests/fm-install-herdr.test.sh b/tests/fm-install-herdr.test.sh deleted file mode 100755 index bfe7ccbb750..00000000000 --- a/tests/fm-install-herdr.test.sh +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env bash -# Contract tests for the pinned Herdr / Treehouse CI installers and the -# bounded Herdr lab cleanup helper. These tests do not download release assets -# and never start or stop the captain's default Herdr session. -set -u - -# shellcheck source=tests/lib.sh -. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" - -HERDR_INSTALL="$ROOT/bin/fm-install-herdr.sh" -TREEHOUSE_INSTALL="$ROOT/bin/fm-install-treehouse.sh" -CLEANUP="$ROOT/bin/fm-herdr-ci-cleanup.sh" -CI="$ROOT/.github/workflows/ci.yml" - -assert_present "$HERDR_INSTALL" "bin/fm-install-herdr.sh is missing" -assert_present "$TREEHOUSE_INSTALL" "bin/fm-install-treehouse.sh is missing" -assert_present "$CLEANUP" "bin/fm-herdr-ci-cleanup.sh is missing" -[ -x "$HERDR_INSTALL" ] || fail "fm-install-herdr.sh must be executable" -[ -x "$TREEHOUSE_INSTALL" ] || fail "fm-install-treehouse.sh must be executable" -[ -x "$CLEANUP" ] || fail "fm-herdr-ci-cleanup.sh must be executable" - -test_herdr_installer_pins_exact_version_and_checksums() { - assert_grep 'FM_HERDR_CI_VERSION=0.7.4' "$HERDR_INSTALL" \ - "Herdr installer must pin suite-verified 0.7.4" - assert_grep 'FM_HERDR_CI_MIN_PROTOCOL=16' "$HERDR_INSTALL" \ - "Herdr installer must require protocol floor 16" - assert_grep 'ogulcancelik/herdr' "$HERDR_INSTALL" \ - "Herdr installer must use the official GitHub release source" - assert_grep 'herdr-linux-x86_64' "$HERDR_INSTALL" \ - "Herdr installer must name the Linux x86_64 release asset" - assert_grep 'bc0fc02d4ba500f9cac2353a43e67fe036785ecca6eb55378e050fac3c103059' "$HERDR_INSTALL" \ - "Herdr installer must pin the Linux x86_64 SHA-256" - assert_grep 'sha256sum' "$HERDR_INSTALL" \ - "Herdr installer must verify a SHA-256 checksum" - assert_grep '--max-filesize' "$HERDR_INSTALL" \ - "Herdr installer must bound the download size" - assert_no_grep 'brew install' "$HERDR_INSTALL" \ - "Herdr installer must not use a floating package-manager install" - assert_no_grep 'apt-get install' "$HERDR_INSTALL" \ - "Herdr installer must not use a floating package-manager install" - pass "Herdr installer pins exact version, asset, checksum, and protocol floor" -} - -test_treehouse_installer_pins_exact_version_and_checksums() { - assert_grep 'FM_TREEHOUSE_CI_VERSION=2.0.1' "$TREEHOUSE_INSTALL" \ - "Treehouse installer must pin the suite-verified 2.0.1 release" - assert_grep 'kunchenguid/treehouse' "$TREEHOUSE_INSTALL" \ - "Treehouse installer must use the official GitHub release source" - assert_grep 'linux-amd64.tar.gz' "$TREEHOUSE_INSTALL" \ - "Treehouse installer must name the Linux amd64 archive" - assert_grep '1d5a32751ab921670103fd201ddb2b91b47338cb13976f45642b827cf8976af2' "$TREEHOUSE_INSTALL" \ - "Treehouse installer must pin the Linux amd64 SHA-256" - assert_grep '--max-filesize' "$TREEHOUSE_INSTALL" \ - "Treehouse installer must bound the download size" - assert_no_grep 'brew install' "$TREEHOUSE_INSTALL" \ - "Treehouse installer must not use a floating package-manager install" - pass "Treehouse installer pins exact version, asset, and checksum" -} - -test_cleanup_only_targets_job_owned_lab_sessions() { - assert_grep 'fm-lab-' "$CLEANUP" \ - "cleanup must only consider fm-lab-* session names" - assert_grep 'default == false' "$CLEANUP" \ - "cleanup must refuse default sessions" - assert_grep 'snapshot' "$CLEANUP" \ - "cleanup must support a pre-suite snapshot" - assert_grep 'teardown' "$CLEANUP" \ - "cleanup must support post-suite teardown of the delta" - # Must not call ambient server stop. - assert_no_grep 'server stop' "$CLEANUP" \ - "cleanup must never call ambient herdr server stop" - pass "cleanup is bounded to job-owned fm-lab-* sessions" -} - -test_ci_wires_installers_and_required_lane() { - assert_grep 'tests-herdr:' "$CI" "CI must define the required Herdr Behavior job" - assert_grep 'tests-herdr-macos-focus:' "$CI" \ - "CI must define the required macOS Herdr focus job" - assert_grep 'FM_REQUIRE_MACOS_FOCUS_AUDIT: 1' "$CI" \ - "macOS Herdr CI must require the NSWorkspace activation audit" - assert_grep 'fm-backend-herdr-presentation-e2e.test.sh' "$CI" \ - "macOS Herdr CI must run the projected spawn regression" - assert_grep 'fm-install-herdr.sh' "$CI" "CI must call the Herdr installer" - assert_grep 'fm-install-treehouse.sh' "$CI" "CI must call the Treehouse installer" - assert_grep 'fm-herdr-ci-cleanup.sh snapshot' "$CI" "CI must snapshot sessions before the suite" - assert_grep 'fm-herdr-ci-cleanup.sh teardown' "$CI" "CI must teardown job-owned sessions after" - assert_grep "fail-on-gate-skip 'herdr not found'" "$CI" \ - "CI Herdr lane must fail on herdr-not-found" - assert_grep 'family real-herdr-gated' "$CI" \ - "CI Herdr lane must run only the real-herdr-gated family" - assert_grep 'lane portable-parallel-1' "$CI" \ - "portable CI must run parallel shard 1" - assert_grep 'lane portable-parallel-2' "$CI" \ - "portable CI must run parallel shard 2" - assert_grep 'lane portable-serial' "$CI" \ - "portable CI must run the serial remainder" - assert_grep 'fm-test-run.sh --check-coverage' "$CI" \ - "CI must prove portable lanes and Herdr partition the complete inventory" - # Live harness credential tests must stay out of the default Herdr lane. - assert_no_grep 'live-harness-optin' "$CI" \ - "CI must not run live-harness-optin in the required Herdr lane" - assert_no_grep 'FM_AFK_PI_HERDR_E2E' "$CI" \ - "CI must not enable live Pi/Herdr credential tests" - assert_no_grep 'FM_SEND_MARKER_HERDR_E2E' "$CI" \ - "CI must not enable live marker Herdr credential tests" - pass "CI wires pinned installers into a required serial Herdr lane" -} - -test_herdr_installer_pins_exact_version_and_checksums -test_treehouse_installer_pins_exact_version_and_checksums -test_cleanup_only_targets_job_owned_lab_sessions -test_ci_wires_installers_and_required_lane diff --git a/tests/fm-instruction-owners.test.sh b/tests/fm-instruction-owners.test.sh deleted file mode 100755 index 754e00ddc83..00000000000 --- a/tests/fm-instruction-owners.test.sh +++ /dev/null @@ -1,305 +0,0 @@ -#!/usr/bin/env bash -# Static contract tests for conditional instruction owners introduced before the -# AGENTS.md reduction pass. -# shellcheck disable=SC2016 -set -u - -# shellcheck source=tests/lib.sh -. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" - -DIAG="$ROOT/.agents/skills/diagnostic-reasoning/SKILL.md" -PROJECT="$ROOT/.agents/skills/project-management/SKILL.md" -HARNESS="$ROOT/.agents/skills/harness-adapters/SKILL.md" -CODING="$ROOT/.agents/skills/firstmate-coding-guidelines/SKILL.md" -RECOVERY="$ROOT/.agents/skills/stuck-crewmate-recovery/SKILL.md" -SECONDMATE="$ROOT/.agents/skills/secondmate-provisioning/SKILL.md" -CONFIG="$ROOT/docs/configuration.md" -AGENTS="$ROOT/AGENTS.md" -BRIEF="$ROOT/bin/fm-brief.sh" -BOOTSTRAP="$ROOT/bin/fm-bootstrap.sh" - -test_new_skill_metadata_and_triggers() { - local skill name count - for pair in "diagnostic-reasoning:$DIAG" "project-management:$PROJECT"; do - name=${pair%%:*} - skill=${pair#*:} - assert_present "$skill" "$name skill is missing" - assert_grep "name: $name" "$skill" "$name skill metadata has the wrong name" - assert_grep "user-invocable: false" "$skill" "$name skill must not be user-invocable" - assert_grep " internal: true" "$skill" "$name skill must be internal" - count=$(grep -Fc -- "- \`$name\` -" "$ROOT/AGENTS.md") - [ "$count" -eq 1 ] || fail "$name must have exactly one AGENTS.md trigger entry, found $count" - done - assert_grep 'Use before scoping a reported bug and before acting on a diagnostic report.' "$DIAG" \ - "diagnostic skill metadata lost its precise load trigger" - assert_grep '`diagnostic-reasoning` - load before scoping a reported bug and before acting on a diagnostic report.' "$ROOT/AGENTS.md" \ - "AGENTS.md lost the diagnostic-reasoning trigger" - assert_grep 'Use before adding, creating, removing, or initializing a project.' "$PROJECT" \ - "project-management skill metadata lost its precise load trigger" - assert_grep '`project-management` - load before adding, creating, removing, or initializing a project.' "$ROOT/AGENTS.md" \ - "AGENTS.md lost the project-management trigger" - pass "new internal skills have one precise AGENTS.md trigger each" -} - -test_diagnostic_owner_covers_causal_procedure() { - assert_grep "single owner of Firstmate's bug-diagnosis reasoning procedure" "$DIAG" \ - "diagnostic skill does not declare ownership" - for phrase in \ - "end-to-end reproduction aligned with the real user path" \ - "initiating trigger" \ - "masking condition" \ - "visible symptom" \ - "proven path" \ - "relevant history" \ - "smallest counterfactual" \ - "disconfirming evidence"; do - assert_grep "$phrase" "$DIAG" "diagnostic owner is missing '$phrase'" - done - assert_grep "evidence, not authorization to change code" "$DIAG" \ - "diagnostic owner lost the diagnosis-only authority boundary" - pass "diagnostic-reasoning owns the approved evidence procedure" -} - -test_project_management_owner_covers_guarded_operations() { - assert_grep "single owner of Firstmate's project-management procedure" "$PROJECT" \ - "project-management skill does not declare ownership" - for phrase in \ - 'bin/fm-project-mode.sh' \ - '`no-mistakes`' \ - '`direct-PR`' \ - '`local-only`' \ - 'Default it off' \ - 'Creating a GitHub repository is outward-facing.' \ - "captain's explicit consent" \ - 'Never issue a raw removal command from Firstmate.' \ - 'no-mistakes init && no-mistakes doctor'; do - assert_grep "$phrase" "$PROJECT" "project-management owner is missing '$phrase'" - done - pass "project-management owns registry, delivery posture, consent, initialization, and removal safety" -} - -test_generic_effort_fallback_respects_precedence() { - local section - section=$(awk ' - /^Effort precedence is / { found = 1 } - found && /^The supported launch-profile flags / { exit } - found { print } - ' "$HARNESS") - assert_contains "$section" "explicit per-task captain instruction first" \ - "effort rubric lost per-task captain precedence" - assert_contains "$section" "standing dispatch profile or secondmate pin" \ - "effort rubric lost standing configuration precedence" - assert_contains "$section" 'Use `low` for well-understood work' \ - "effort rubric lost its low fallback" - assert_contains "$section" '`xhigh` for ambiguous investigation or design' \ - "effort rubric lost its xhigh fallback" - assert_contains "$section" "Choose intermediate levels proportionally" \ - "effort rubric lost proportional intermediate levels" - assert_contains "$section" 'Never select `max` from this fallback' \ - "effort rubric permits max without an explicit captain preference" - if printf '%s\n' "$section" | grep -qi sol; then - fail "generic effort fallback must not contain Sol-specific policy" - fi - pass "generic effort fallback applies only below captain and standing configuration" -} - -test_agent_owned_quota_array_dispatch_contract() { - local phrase - for phrase in \ - 'Firstmate alone resolves a matched profile array' \ - 'run `quota-axi --json` at that intake' \ - 'evaluate every configured candidate against that current output' \ - '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' \ - '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 - - for phrase in \ - '| claude | Open the current interactive session' \ - '| codex | Open the current interactive session' \ - '| opencode | Run `opencode models [provider]`' \ - '| 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 - assert_grep "$phrase" "$HARNESS" "model discovery guidance lost '$phrase'" - done - assert_grep 'not as a permanent namespace or provider mapping' "$HARNESS" \ - "model discovery guidance permits a fixed provider table" - 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" \ - "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" -} - -test_shared_authoring_requirements_are_owned() { - assert_grep "review every affected supported primary harness and runtime backend" "$CODING" \ - "coding guidance lost the supported compatibility matrix review" - assert_grep "prefer deterministic and idempotent enforcement over relying on agent memory alone" "$CODING" \ - "coding guidance lost deterministic idempotent enforcement" - assert_grep "critical safety, routing, startup, and supervision infrastructure" "$CODING" \ - "coding guidance lost the critical infrastructure scope" - pass "firstmate-coding-guidelines owns compatibility review and deterministic enforcement" -} - -test_secondmate_registry_contract_stays_concise() { - local guidance routing_section schema_line - routing_section=$(awk ' - /^## Routing table$/ { found = 1 } - found && /^## Charter and seed$/ { exit } - found { print } - ' "$SECONDMATE") - guidance=$(awk ' - /^## Routing table$/ { found = 1 } - found && /^## Backlog handoff$/ { exit } - found { print } - ' "$SECONDMATE") - schema_line="- - (home: ; scope: ; projects: , ; added )" - assert_contains "$routing_section" "$schema_line" \ - "secondmate routing table lost the parser-compatible single-line schema" - assert_contains "$routing_section" "Each registry entry stays concise and single-line" \ - "secondmate routing table no longer requires concise single-line entries" - assert_contains "$routing_section" "genuinely domain-specific hard rules" \ - "secondmate routing table no longer limits extra prose to domain-specific hard rules" - assert_contains "$routing_section" "The home-seeded \`data/charter.md\` is the sole owner of boilerplate idle-by-default behavior, the normal delegation lifecycle, and standard escalation contracts" \ - "secondmate routing table lost the explicit charter ownership pointer" - assert_contains "$routing_section" "no extra registry pointer field is needed" \ - "secondmate routing table no longer explains why the existing home field is the charter pointer" - for phrase in \ - "go idle and wait silently" \ - "Act only on tasks" \ - "never spawn a survey" \ - "run normal firstmate bootstrap" \ - "escalation back to the main firstmate status file" \ - "requests-from-main-firstmate contract" \ - "waits for routed tasks, never self-initiating a survey or audit" \ - "marked supervisor requests return through status" \ - "unmarked captain messages stay conversational"; do - if printf '%s\n' "$guidance" | grep -F "$phrase" >/dev/null; then - fail "secondmate provisioning guidance restated charter boilerplate: $phrase" - fi - done - pass "secondmate registry guidance keeps concise routes and points to the charter" -} - -test_state_startup_and_ordinary_recovery_placement() { - assert_grep "single owner of the top-level operational-home layout" "$CONFIG" \ - "configuration docs do not own the operational state layout" - assert_grep "header is the single owner of session-start ordering" "$CONFIG" \ - "session-start mechanism is not assigned to the script header" - assert_grep "Ordinary dead-direct-report recovery is owned by \`stuck-crewmate-recovery\`" "$CONFIG" \ - "D05 ordinary recovery placement is missing" - assert_grep "## Session-start reconciliation for a dead ordinary direct report" "$RECOVERY" \ - "stuck-crewmate-recovery lacks the dead ordinary direct-report procedure" - assert_grep "treehouse status" "$RECOVERY" \ - "ordinary recovery lost treehouse inventory inspection" - assert_grep "recorded \`orca_worktree_id=\` and \`terminal=\`" "$RECOVERY" \ - "ordinary recovery lost Orca inventory inspection" - assert_grep "session-start digest reports an ordinary direct report's endpoint dead or its metadata has no window" "$AGENTS" \ - "AGENTS.md does not trigger ordinary dead-report recovery" - pass "state, startup, and ordinary recovery have focused owners and triggers" -} - -test_compressed_agents_owner_map() { - assert_grep '`docs/configuration.md` is the single owner of the top-level operational-home layout' "$AGENTS" \ - "AGENTS.md lost the state-layout owner pointer" - assert_grep 'header is the single owner of composed commands, ordering, and digest contents' "$AGENTS" \ - "AGENTS.md lost the session-start owner pointer" - assert_grep '`docs/configuration.md` owns dispatch-profile and runtime-backend schemas' "$AGENTS" \ - "AGENTS.md lost the dispatch-schema owner pointer" - assert_grep 'That skill owns registry syntax, delivery-mode selection' "$AGENTS" \ - "AGENTS.md lost the project-management owner pointer" - assert_grep 'The delivery lifecycle is an always-loaded operational contract' "$AGENTS" \ - "AGENTS.md no longer owns the delivery lifecycle" - assert_grep 'Fleet supervision is an always-loaded operational contract' "$AGENTS" \ - "AGENTS.md no longer owns fleet supervision" - assert_grep '`.tasks.toml`, `docs/configuration.md`, and current `tasks-axi --help` own the backlog schema' "$AGENTS" \ - "AGENTS.md lost the backlog-mechanics owner pointer" - assert_grep '`bin/fm-brief.sh` and its help own scaffold syntax' "$AGENTS" \ - "AGENTS.md lost the brief-mechanics owner pointer" - assert_grep '`docs/configuration.md` owns activation, generated state, cadence, wire protocol' "$AGENTS" \ - "AGENTS.md lost the X-mode mechanics owner pointer" - pass "compressed AGENTS.md records the approved one-owner map" -} - -test_intake_reuses_evidence_and_parallelizes_safe_work() { - for phrase in \ - 'consult existing reports and established evidence' \ - 'remaining bounded research inside it' \ - 'unresolved uncertainty could materially change whether or what to build' \ - 'relay it without a design-only scout' \ - 'ask one concise implementation question when useful' \ - 'Never both present a likely-enough solution' \ - 'overlap as a risk signal rather than an automatic reason to wait' \ - 'independently implemented and validated' \ - 'selected delivery path can reconcile ordinary rebases or conflicts' \ - 'Serialize only for a true semantic dependency' \ - 'shared mutable external state' \ - 'incompatible concurrent migration' \ - 'same-file editing alone is insufficient' \ - 'genuine blockers remain durable'; do - assert_grep "$phrase" "$AGENTS" "intake contract lost '$phrase'" - done - assert_grep 'dispatch isolated work immediately with no concurrency cap' "$AGENTS" \ - "intake contract lost unbounded safe parallel dispatch" - assert_grep 'captain explicitly requests a separate knowledge or design deliverable' "$AGENTS" \ - "intake contract lost captain-requested separate scouts" - assert_grep 'When implementation is separately authorized, promote the existing scout' "$AGENTS" \ - "intake contract lost genuine scout promotion" - pass "intake reuses evidence, reserves scouts for uncertainty, and parallelizes safe work" -} - -test_compressed_agents_retains_authority_and_supervision_safety() { - for phrase in \ - 'A lock-refused session must not spawn, steer, merge, drain the wake queue' \ - 'A diagnostic request, report, recommendation, or implementation-ready finding is evidence, not authorization to change code.' \ - 'The selected delivery path owns its own rigor.' \ - 'When no-mistakes is selected, no-mistakes alone owns review, fixes, tests, documentation, push, PR, and CI; otherwise follow the faster path without adding an independent reviewer.' \ - 'Never hold work outside no-mistakes for a manual clean verdict, stack serial manual reviews, or infer authority for one from security, architecture, or risk alone.' \ - 'A separate review or audit is allowed only when the captain explicitly requests that deliverable or the authorized task is a knowledge-only review; one named question remains scoped to that question.' \ - 'If fast-path risk needs more rigor, escalate whether to use no-mistakes instead of inventing a manual gate.' \ - '**local-only** has the worker stop with a clean ready branch, then waits for the configured merge authority' \ - 'A status line is a wake event, not current state' \ - 'keep exactly one live supervision cycle' \ - 'Never broadly kill watchers' \ - 'While `state/.afk` exists, the daemon owns supervision' \ - 'post the final completion follow-up before teardown'; do - assert_grep "$phrase" "$AGENTS" "compressed AGENTS.md lost safety phrase '$phrase'" - done - assert_no_grep 'Firstmate does not personally review code or deliverables' "$AGENTS" \ - "AGENTS.md retained the weaker duplicate review prohibition" - assert_no_grep 'firstmate reviews your branch' "$AGENTS" \ - "AGENTS.md retained a personal branch-review requirement" - assert_no_grep 'firstmate reviews, captain approves' "$BRIEF" \ - "generated brief retained a stacked personal-review requirement" - if grep -q "$(printf '\342\200\224')" "$AGENTS"; then - fail "AGENTS.md contains an em dash" - fi - pass "compressed AGENTS.md retains authority, supervision, AFK, and X safety" -} - -test_new_skill_metadata_and_triggers -test_diagnostic_owner_covers_causal_procedure -test_project_management_owner_covers_guarded_operations -test_generic_effort_fallback_respects_precedence -test_agent_owned_quota_array_dispatch_contract -test_shared_authoring_requirements_are_owned -test_secondmate_registry_contract_stays_concise -test_state_startup_and_ordinary_recovery_placement -test_compressed_agents_owner_map -test_intake_reuses_evidence_and_parallelizes_safe_work -test_compressed_agents_retains_authority_and_supervision_safety diff --git a/tests/fm-kimi-harness.test.sh b/tests/fm-kimi-harness.test.sh index 8ac5922ec51..8e27052d8ce 100755 --- a/tests/fm-kimi-harness.test.sh +++ b/tests/fm-kimi-harness.test.sh @@ -9,33 +9,17 @@ SPAWN="$ROOT/bin/fm-spawn.sh" TEARDOWN="$ROOT/bin/fm-teardown.sh" KIMI_HOOK="$ROOT/bin/fm-kimi-turnend-hook.sh" TMP_ROOT=$(fm_test_tmproot fm-kimi-harness) +KIMI_RUNTIME_TASK_TMP= PYTHON_BIN=$(command -v python3) || fail "test needs python3" PYTHON_BIN_DIR=$(dirname "$PYTHON_BIN") JQ_BIN=$(command -v jq) || fail "test needs jq" BASE_PATH=${FM_TEST_BASE_PATH:-$PYTHON_BIN_DIR:/usr/bin:/bin:/usr/sbin:/sbin} -assert_source_line() { - local line=$1 - grep -Fqx -- "$line" "$SPAWN" || fail "existing launch template changed: $line" -} - -test_existing_launch_templates_are_byte_pinned() { - assert_source_line " claude) printf '%s' 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__\"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"' ;;" - 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%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" -} - -test_tracked_files_have_no_user_absolute_paths() { - local pattern="/""Users/" matches - matches=$(git -C "$ROOT" grep -n -F "$pattern" -- . || true) - [ -z "$matches" ] || fail "tracked files contain user-specific absolute paths: $matches" - pass "repository: tracked files contain no user-specific absolute paths" +cleanup_kimi_harness() { + [ -z "$KIMI_RUNTIME_TASK_TMP" ] || rm -rf "$KIMI_RUNTIME_TASK_TMP" + rm -rf "$TMP_ROOT" } +trap cleanup_kimi_harness EXIT make_spawn_fakebin() { local dir=$1 fakebin @@ -193,8 +177,11 @@ EOF } test_kimi_launch_then_send_is_verified() { - local id rec out rc launch pointer brief_real meta - id=kimi-success-z1 + local id rec out rc launch pointer brief_real meta task_tmp + id="kimi-success-z1-$$" + task_tmp="/tmp/fm-$id" + KIMI_RUNTIME_TASK_TMP=$task_tmp + rm -rf "$task_tmp" rec=$(make_spawn_case success "$id") read_spawn_record "$rec" out=$(FM_FAKE_KIMI_SWALLOW_FIRST=yes run_spawn \ @@ -218,6 +205,10 @@ test_kimi_launch_then_send_is_verified() { meta="$HOME_DIR/state/$id.meta" assert_grep 'model=kimi-code/k3' "$meta" "kimi meta lost the requested model" assert_grep 'effort=high' "$meta" "kimi meta did not retain the unsupported effort axis" + assert_grep "tasktmp=$task_tmp" "$meta" "kimi meta did not record its task temp root" + assert_present "$task_tmp/gotmp" "kimi spawn did not create its Go temp directory" + assert_grep "export GOTMPDIR=$task_tmp/gotmp" "$CASE_DIR/tmux-calls.log" \ + "kimi spawn did not export its Go temp directory into the pane" assert_grep 'BEGIN FIRSTMATE KIMI TURN-END HOOK' "$HOME_DIR/.kimi-code/config.toml" \ "kimi spawn did not install its guarded global hook region" assert_grep 'token=' "$WT_DIR/.fm-kimi-turnend" "kimi spawn did not write its token pointer" @@ -575,7 +566,7 @@ SH } test_kimi_busy_signature_is_scoped_to_spinner_lines() { - local capture phase kimi_regex_lines + local capture # shellcheck source=/dev/null . "$ROOT/bin/fm-tmux-lib.sh" unset FM_BUSY_REGEX @@ -589,10 +580,11 @@ test_kimi_busy_signature_is_scoped_to_spinner_lines() { # These fixtures reproduce the observed spinner shape rather than byte-exact # transcriptions. Leading whitespace is deliberately varied; separator whitespace # follows the captured contract. - printf ' 🌑 · Tip: ask Kimi to schedule tasks, e.g. "remind me at 5pm"\n│ > │\n' > "$capture" - fm_pane_is_busy fake kimi || fail "the first real Kimi spinner shape was not recognized as busy" - printf ' 🌗 · Tip: /plugins: manage plugins ...\n│ > │\n' > "$capture" - fm_pane_is_busy fake kimi || fail "the tool-execution Kimi spinner shape was not recognized as busy" + local phase + for phase in 🌑 🌒 🌓 🌔 🌕 🌖 🌗 🌘; do + printf ' %s · Tip: Kimi is working\n│ > │\n' "$phase" > "$capture" + fm_pane_is_busy fake kimi || fail "Kimi spinner phase $phase was not recognized as busy" + done printf 'ordinary response ending with 🌕\n│ > │\n' > "$capture" if fm_pane_is_busy fake kimi; then fail "a moon outside Kimi's spinner-line shape was misread as busy" @@ -617,14 +609,6 @@ test_kimi_busy_signature_is_scoped_to_spinner_lines() { if fm_pane_is_busy fake kimi; then fail "Kimi's idle thinking-effort status label was misread as busy" fi - kimi_regex_lines=$(grep 'KIMI_BUSY_REGEX' "$ROOT/bin/fm-tmux-lib.sh" "$ROOT/bin/fm-watch.sh") - if printf '%s\n' "$kimi_regex_lines" | grep -qi thinking; then - fail "Kimi busy regex still depends on a Thinking or thinking token" - fi - for phase in 🌑 🌒 🌓 🌔 🌕 🌖 🌗 🌘; do - grep -Fq "$phase" "$ROOT/bin/fm-tmux-lib.sh" \ - || fail "shared Kimi matcher is missing moon phase $phase" - done pass "busy detection: real Kimi moon-plus-middot captures require its harness while idle labels stay idle" } @@ -673,8 +657,6 @@ test_kimi_bordered_prompt_needs_no_override() { pass "composer classifier: kimi's existing bordered > shape is already safe without an override" } -test_tracked_files_have_no_user_absolute_paths -test_existing_launch_templates_are_byte_pinned test_kimi_hook_install_is_surgical_idempotent_and_removable test_kimi_hook_remove_preserves_owned_newline_boundary test_kimi_hook_fails_closed_on_missing_malformed_or_partial_config diff --git a/tests/fm-nm-test-contract.test.sh b/tests/fm-nm-test-contract.test.sh deleted file mode 100755 index 54c19eab2f8..00000000000 --- a/tests/fm-nm-test-contract.test.sh +++ /dev/null @@ -1,127 +0,0 @@ -#!/usr/bin/env bash -# Contract: local no-mistakes Test is intent-targeted; CI owns broad regression. -# -# Firstmate must not configure commands.test as a complete tests/*.test.sh walk -# (that duplicated CI and burned local pipeline time). Lint stays pinned to -# bin/fm-lint.sh. Remote CI owns broad regression through separate portable and -# required real-Herdr Behavior lanes composed around bin/fm-test-run.sh. -set -u - -# shellcheck source=tests/lib.sh -. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" - -NM="$ROOT/.no-mistakes.yaml" -CI="$ROOT/.github/workflows/ci.yml" - -test_nm_yaml_tracked() { - assert_present "$NM" "tracked .no-mistakes.yaml is missing" - git -C "$ROOT" ls-files --error-unmatch .no-mistakes.yaml >/dev/null 2>&1 \ - || fail ".no-mistakes.yaml is not tracked by git" - pass ".no-mistakes.yaml is present and tracked" -} - -test_nm_keeps_lint_pin() { - grep -Fqx " lint: 'bin/fm-lint.sh'" "$NM" \ - || fail "commands.lint must remain exactly bin/fm-lint.sh" - pass "commands.lint stays pinned to bin/fm-lint.sh" -} - -# True when the YAML maps a non-empty commands.test (string or mapping value). -# Empty / null / absent is the intended targeted-Test posture. -nm_commands_test_value() { - if command -v python3 >/dev/null 2>&1 && python3 -c 'import yaml' >/dev/null 2>&1; then - python3 -c ' -import yaml, sys -doc = yaml.safe_load(open(sys.argv[1])) or {} -cmds = doc.get("commands") or {} -val = cmds.get("test") if isinstance(cmds, dict) else None -if val is None or val is False: - print("") -elif isinstance(val, str): - print(val) -else: - print(repr(val)) -' "$NM" - return - fi - if command -v ruby >/dev/null 2>&1; then - ruby -ryaml -e ' -doc = YAML.safe_load(File.read(ARGV[0])) || {} -cmds = doc["commands"] || {} -val = cmds.is_a?(Hash) ? cmds["test"] : nil -if val.nil? || val == false - puts "" -elsif val.is_a?(String) - puts val -else - puts val.inspect -end -' "$NM" - return - fi - # Structural fallback: any commands.test line under the commands block. - awk ' - /^commands:[[:space:]]*$/ { in_cmds=1; next } - in_cmds && /^[^[:space:]#]/ { in_cmds=0 } - in_cmds && /^[[:space:]]+test:[[:space:]]*/ { - sub(/^[[:space:]]+test:[[:space:]]*/, "") - gsub(/^['\''"]|['\''"]$/, "") - print - exit - } - ' "$NM" -} - -test_nm_has_no_complete_local_test_command() { - local val - val=$(nm_commands_test_value) || fail "failed to read commands.test from .no-mistakes.yaml" - if [ -n "$val" ]; then - case "$val" in - *'tests/*.test.sh'*|*'tests/'*'.test.sh'*) - fail "commands.test must not walk the complete tests/*.test.sh suite; got: $val" - ;; - *) - # Any non-empty override still steers Test away from intent-targeted default. - fail "commands.test must be absent or empty so Test stays intent-targeted; got: $val" - ;; - esac - fi - # Also refuse a commented-out full-suite remnant that could be re-enabled by habit. - if grep -E '^[[:space:]]*#?[[:space:]]*test:[[:space:]].*tests/\*\.test\.sh' "$NM" >/dev/null 2>&1; then - fail ".no-mistakes.yaml still documents a full-suite commands.test line (active or comment)" - fi - pass "no-mistakes does not configure a complete local Test command" -} - -test_ci_still_runs_broad_behavior_suite() { - assert_present "$CI" "ci.yml is missing" - # Portable shards and the serial remainder cover every portable behavior - # script through the one owner, with a deterministic inventory guard. - grep -Fq 'bin/fm-test-run.sh --lane portable-parallel-1' "$CI" \ - || fail "CI must invoke portable parallel shard 1 through fm-test-run.sh" - grep -Fq 'bin/fm-test-run.sh --lane portable-parallel-2' "$CI" \ - || fail "CI must invoke portable parallel shard 2 through fm-test-run.sh" - grep -Fq 'bin/fm-test-run.sh --lane portable-serial' "$CI" \ - || fail "CI must invoke the portable serial remainder through fm-test-run.sh" - grep -Fq 'bin/fm-test-run.sh --check-coverage' "$CI" \ - || fail "CI must prove complete lane coverage through fm-test-run.sh" - # Guard against regression to an uninstrumented inline loop that drops timing. - if grep -Eq 'for test_script in tests/\*\.test\.sh' "$CI"; then - fail "CI Behavior must not re-spell an inline tests/*.test.sh loop; use fm-test-run.sh" - fi - # Preserve other CI lanes this task must not shrink. - grep -Eq 'name:[[:space:]]*Lint shell scripts' "$CI" \ - || fail "CI must retain the lint job" - grep -Eq 'name:[[:space:]]*Stock macOS Bash snapshot compatibility' "$CI" \ - || fail "CI must retain the macOS stock Bash compatibility job" - grep -Eq 'name:[[:space:]]*Repo invariants' "$CI" \ - || fail "CI must retain the repo invariants job" - grep -Fq 'tests-herdr:' "$CI" \ - || fail "CI must retain the required Herdr Behavior job" - pass "CI still owns partitioned broad behavior coverage and companion jobs" -} - -test_nm_yaml_tracked -test_nm_keeps_lint_pin -test_nm_has_no_complete_local_test_command -test_ci_still_runs_broad_behavior_suite diff --git a/tests/fm-no-mistakes-ownership.test.sh b/tests/fm-no-mistakes-ownership.test.sh deleted file mode 100755 index b7e7fc2a6fd..00000000000 --- a/tests/fm-no-mistakes-ownership.test.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env bash -# Static contract tests for crew-owned no-mistakes validation runs. -set -u - -# shellcheck source=tests/lib.sh disable=SC1091 -. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" - -validate_contract() { - awk ' - /^### Validate$/ { found = 1; next } - found && /^### / { exit } - found { print } - ' "$ROOT/AGENTS.md" -} - -test_worker_owns_synchronous_driver() { - local contract - contract=$(validate_contract) - - assert_contains "$contract" 'The task worker that starts a no-mistakes run drives the pipeline' \ - "Validate contract does not assign the run to its initiating task worker" - assert_contains "$contract" "owns every \`no-mistakes axi run\` and \`no-mistakes axi respond\` call through the next gate or outcome" \ - "Validate contract does not assign every synchronous driver call to the task worker" - assert_contains "$contract" 'process every synchronous return until completion or a genuinely new escalation' \ - "Validate contract does not require the task worker to process every synchronous return" - pass "Validate contract assigns the complete synchronous driver loop to the initiating task worker" -} - -test_firstmate_never_responds_for_crew_run() { - local contract - contract=$(validate_contract) - - assert_contains "$contract" "Firstmate never invokes \`no-mistakes axi respond\` for a crew-owned run." \ - "Validate contract permits Firstmate to respond directly for a crew-owned run" - pass "Validate contract forbids Firstmate from responding directly for a crew-owned run" -} - -test_worker_owns_synchronous_driver -test_firstmate_never_responds_for_crew_run diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index 518df0e874e..f8883194898 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -59,64 +59,6 @@ export const Type = { JS } -test_tracked_extension_present_and_self_hashing() { - local text expected_config_source - expected_config_source="config_dir=\\\"\${FM_CONFIG_OVERRIDE:-\$FM_HOME/config}\\\"" - assert_present "$EXT" "tracked Pi primary watcher extension is missing" - text=$(cat "$EXT") - assert_contains "$text" "fm_watch_arm_pi" "tracked extension missing tool name" - assert_contains "$text" "fm-watch-arm-pi" "tracked extension missing command name" - assert_contains "$text" "fm-watch-arm.sh" "tracked extension missing watcher arm" - assert_contains "$text" "sendUserMessage" "tracked extension missing Pi wake API" - assert_contains "$text" 'encodeFirstmateOperationalInput' "tracked extension does not construct typed synthetic user-role wakes" - assert_contains "$text" "deliverAs: \"followUp\"" "tracked extension missing followUp delivery" - assert_contains "$text" ".pi-watch-extension-loaded" "tracked extension missing loaded marker" - assert_contains "$text" 'createHash("sha256").update(readFileSync(extensionFile)).digest("hex")' "tracked extension does not self-hash its own content for extensionVersion" - assert_contains "$text" 'fileURLToPath(import.meta.url)' "tracked extension does not self-locate via import.meta.url" - assert_contains "$text" 'type LockOwnership = "owned" | "missing" | "other"' "tracked extension does not distinguish missing lock from another owner" - assert_contains "$text" "readFileSync(\`\${state}/.lock\`" "tracked extension does not read the effective session lock" - assert_contains "$text" 'return pidAlive(lockPid) ? "other" : "missing"' "tracked extension does not allow a pre-lock load marker" - assert_contains "$text" 'if (lockOwnership() === "other") return' "tracked extension overwrites another live session marker" - assert_contains "$text" 'const ownership = lockOwnership()' "tracked extension arm does not inspect the distinct lock ownership state" - assert_contains "$text" 'if (ownership === "other") return { ok: false' "tracked extension arm does not preserve the live-other read-only refusal" - assert_contains "$text" 'if (ownership === "missing")' "tracked extension arm collapses a stale or absent lock into the live-other refusal" - assert_contains "$text" "no live session holds the lock" "tracked extension arm missing stale-lock recovery guidance" - assert_contains "$text" "run bin/fm-session-start.sh to reclaim it" "tracked extension arm does not direct stale-lock reclamation" - assert_contains "$text" "call fm_watch_arm_pi to re-arm" "tracked extension arm does not direct supervision re-arm" - assert_contains "$text" "writeFileSync(marker, \`\${extensionVersion}\\n\${process.pid}\\n\`)" "tracked extension does not write the content version and process marker" - assert_contains "$text" "const config = process.env.FM_CONFIG_OVERRIDE" "tracked extension missing effective config resolution" - assert_contains "$text" "FM_CONFIG_OVERRIDE: config" "tracked extension does not pass the effective config to the watcher arm" - assert_contains "$text" "FM_WATCH_ARM_SCRIPT: armScript" "tracked extension does not pass the effective watcher arm script" - assert_contains "$text" "$expected_config_source" "tracked extension does not source the effective x-mode config" - assert_contains "$text" "exec \\\"\$FM_WATCH_ARM_SCRIPT\\\" --restart" "tracked extension does not restart into a Pi-owned watcher child" - assert_contains "$text" 'label: "Arm firstmate watcher"' "tracked extension tool is missing its human-readable label" - assert_not_contains "$text" "Always use this tool" "tracked extension kept broad tool-selection guidance" - assert_contains "$text" "only for the first required cycle or after a notification says the cycle is missing, failed, or unhealthy" "tracked extension tool metadata is missing the Pi first-cycle or explicit-repair rule" - assert_contains "$text" "Do not call it after ordinary work, turn completion, or ordinary signal, stale, check, or heartbeat handling" "tracked extension prompt guidance does not prevent redundant ordinary-notification calls" - assert_contains "$text" 'parameters: Type.Object({})' "tracked extension tool is not using Pi's canonical TypeBox schema" - assert_contains "$text" 'content: [{ type: "text", text: result.message }]' "tracked extension tool is missing Pi text content" - 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" -} - -test_spawn_template_mentions_pi_watch_placeholder() { - local text - text=$(cat "$ROOT/bin/fm-spawn.sh") - assert_contains "$text" "-e __PITURNEND__ -e __PIWATCH__" "Pi secondmate launch template does not include both primary extensions" - assert_contains "$text" "\$PROJ_ABS/.pi/extensions/fm-primary-pi-watch.ts" "fm-spawn does not point the Pi secondmate watch placeholder at the tracked extension" - assert_not_contains "$text" "fm-pi-watch-extension.sh" "fm-spawn should no longer generate the Pi watch extension before launch" - assert_contains "$text" "__PITURNEND__" "fm-spawn does not replace the Pi turn-end guard extension placeholder" - assert_contains "$text" "__PIWATCH__" "fm-spawn does not replace the Pi watch extension placeholder" - pass "Pi secondmate launch wiring includes both tracked primary extensions" -} - test_pi_extension_reports_external_healthy_watcher() { local repo home plugin out status repo="$TMP_ROOT/pi-external-healthy-root" @@ -1234,26 +1176,6 @@ EOF pass "Pi process-exit cleanup stops the attached arm child" } -test_opencode_primary_watch_plugin_static_wiring() { - local plugin module_boundary text - plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js" - module_boundary="$ROOT/.opencode/plugins/package.json" - assert_present "$plugin" "OpenCode primary watch plugin missing" - assert_present "$module_boundary" "OpenCode plugin ESM package boundary missing" - assert_contains "$(cat "$module_boundary")" '"type": "module"' "OpenCode plugin package boundary is not explicitly ESM" - text=$(cat "$plugin") - assert_contains "$text" "session.idle" "OpenCode plugin does not listen for session.idle" - assert_contains "$text" "fm-watch-arm.sh" "OpenCode plugin does not spawn the watcher arm" - assert_contains "$text" "promptAsync" "OpenCode plugin does not wake with promptAsync" - assert_contains "$text" 'encodeFirstmateOperationalInput' "OpenCode plugin does not construct typed synthetic user-role wakes" - assert_contains "$text" ".fm-secondmate-home" "OpenCode plugin does not scope out secondmate homes" - assert_contains "$text" "rev-parse\", \"--git-dir" "OpenCode plugin does not check linked worktree scope" - assert_contains "$text" "sessionOwnsLock" "OpenCode plugin does not gate arm attempts on the session lock" - assert_contains "$text" 'fm-watch-arm.sh" --restart' "OpenCode plugin does not restart into its own watcher child" - assert_contains "$text" 'setArmStatus("external")' "OpenCode plugin still treats an external healthy watcher as armed" - pass "OpenCode primary watcher plugin has the verified TUI wake wiring" -} - test_opencode_plugin_package_boundary_is_explicit_esm() { local fixture plugin out status fixture="$TMP_ROOT/opencode-esm-boundary/.opencode" @@ -2202,8 +2124,6 @@ EOF pass "OpenCode healthy arm output does not suppress the turn-end guard" } -test_tracked_extension_present_and_self_hashing -test_spawn_template_mentions_pi_watch_placeholder test_pi_extension_reports_external_healthy_watcher test_pi_tool_returns_agent_tool_result test_pi_redundant_tool_call_is_owned_noop @@ -2219,7 +2139,6 @@ 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 test_opencode_plugin_package_boundary_is_explicit_esm test_opencode_primary_watch_plugin_uses_effective_state_home test_opencode_primary_watch_plugin_sources_effective_config diff --git a/tests/fm-pr-check-security.test.sh b/tests/fm-pr-check-security.test.sh index 2e813965b40..1b21e5d6a8d 100755 --- a/tests/fm-pr-check-security.test.sh +++ b/tests/fm-pr-check-security.test.sh @@ -2882,11 +2882,6 @@ EOF [ "$rc" -eq 2 ] || fail "merge wrapper did not refuse a GitLab merge request URL" [ ! -s "$dir/gh-axi.log" ] || fail "merge wrapper reached the GitHub CLI for a GitLab URL" - # The instance is data, never a constant, so self-hosted instances work. - ! grep -qF gitlab.com "$ROOT/bin/fm-pr-lib.sh" \ - || fail "the shared PR library hardcodes a GitLab host" - ! grep -qF gitlab.com "$ROOT/bin/fm-pr-poll.sh" \ - || fail "the static poll hardcodes a GitLab host" pass "GitLab merge requests are followed on any instance and never wake falsely" } diff --git a/tests/fm-quota-array-dispatch.test.sh b/tests/fm-quota-array-dispatch.test.sh deleted file mode 100755 index 0c0d848fa05..00000000000 --- a/tests/fm-quota-array-dispatch.test.sh +++ /dev/null @@ -1,287 +0,0 @@ -#!/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_selection_procedure() { - local phrase lines words bytes - 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' \ - 'Report duplicate concrete profiles as a configuration error' \ - 'Name the inspectable facts used for every candidate'; do - assert_grep "$phrase" "$OWNER" "quota-array-dispatch procedure lost '$phrase'" - done - - # Expanded acceptance scenarios live in deterministic fixtures, not runtime prose. - for phrase in \ - 'Higher raw quota but materially ahead vs lower raw quota on/behind pace' \ - 'Sanitized producer shape' \ - '## When to load' \ - '## Intake boundary this skill does not relax'; do - if grep -Fq -- "$phrase" "$OWNER"; then - fail "quota-array-dispatch should not keep removed runtime prose: $phrase" - fi - done - - lines=$(wc -l < "$OWNER" | tr -d ' ') - words=$(wc -w < "$OWNER" | tr -d ' ') - bytes=$(wc -c < "$OWNER" | tr -d ' ') - [ "$lines" -le 65 ] || fail "quota-array-dispatch skill is too long: $lines lines (want <= 65)" - [ "$words" -le 550 ] || fail "quota-array-dispatch skill is too wordy: $words words (want <= 550)" - [ "$bytes" -le 4600 ] || fail "quota-array-dispatch skill is too large: $bytes bytes (want <= 4600)" - pass "quota-array-dispatch owns the compact pace procedure ($lines lines, $words words, $bytes bytes)" -} - -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_selection_procedure -test_cross_references_stay_pointers -test_schema_v3_shape_fixture -test_deterministic_acceptance_cases -test_no_duplicate_procedure_in_agents diff --git a/tests/fm-secondmate-harness.test.sh b/tests/fm-secondmate-harness.test.sh index 39ca2021bf1..ae41c793516 100755 --- a/tests/fm-secondmate-harness.test.sh +++ b/tests/fm-secondmate-harness.test.sh @@ -14,11 +14,12 @@ # explicit per-spawn harness arg still wins. # B) Inheritance. The primary pushes a declared, extensible set of LOCAL # (gitignored) config items - config/crew-dispatch.json, config/crew-harness, -# config/backlog-backend, and config/herdr-presentation-spaces - down into -# each secondmate home's config/, so the secondmate's OWN crewmates, -# dispatch profiles, backlog backend, and Herdr presentation opt-in inherit -# the primary's settings. It is primary-authoritative (re-pushed at -# secondmate spawn, on the bootstrap secondmate sweep, and by config push). +# config/backlog-backend, config/backend, and config/herdr-presentation-spaces - +# down into each secondmate home's config/, so the secondmate's OWN crewmates, +# dispatch profiles, backlog backend, runtime-backend default, and Herdr +# presentation opt-in inherit the primary's settings. It is primary-authoritative +# (re-pushed at secondmate spawn, on the bootstrap secondmate sweep, and by +# config push). # config/secondmate-harness is deliberately NOT inherited (secondmates do # not spawn secondmates). After a successful push that changes allowlisted # config under an already-running home, a literal-content reread instruction @@ -187,20 +188,73 @@ SH pass "pi-signed identity: authoritative launch selection distinguishes shared wrapper ancestry" } +test_dash_leading_process_names_are_basename_operands() { + local dir fakebin got err status + dir="$TMP_ROOT/dash-leading-process-names" + 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" in + 4242:comm=) printf '%s\n' '/opt/test/bin/codex' ;; + 4242:args=) printf '%s\n' 'codex' ;; + 4242:ppid=) printf '%s\n' 1 ;; + 5252:comm=) printf '%s\n' '-codex' ;; + 5252:args=) printf '%s\n' '-codex' ;; + 5252:ppid=) printf '%s\n' 1 ;; + *:comm=) printf '%s\n' '-zsh' ;; + *:args=) printf '%s\n' '-zsh' ;; + *:ppid=) printf '%s\n' 4242 ;; +esac +SH + chmod +x "$fakebin/ps" + + err="$dir/fm-harness.err" + got=$(env -u CLAUDECODE -u PI_CODING_AGENT -u GROK_AGENT \ + PATH="$fakebin:$BASE_PATH" "$ROOT/bin/fm-harness.sh" 2>"$err") + [ "$got" = codex ] || fail "dash-leading shell ancestry resolved '$got', expected codex" + [ ! -s "$err" ] || fail "fm-harness wrote basename option noise for literal -zsh: $(cat "$err")" + + err="$dir/fm-session-lock-ancestry.err" + got=$(PATH="$fakebin:$BASE_PATH" bash -c \ + '. "$0/bin/fm-session-lock-lib.sh"; fm_harness_ancestry_pid' "$ROOT" 2>"$err") + [ "$got" = 4242 ] || fail "session-lock dash-leading ancestry selected '$got', expected pid 4242" + [ ! -s "$err" ] || fail "session-lock ancestry wrote basename option noise for literal -zsh: $(cat "$err")" + + err="$dir/fm-session-lock-alive.err" + PATH="$fakebin:$BASE_PATH" bash -c \ + '. "$0/bin/fm-session-lock-lib.sh"; kill() { return 0; }; fm_harness_pid_alive 5252' \ + "$ROOT" 2>"$err"; status=$? + expect_code 0 "$status" "session-lock liveness should accept literal -codex as a harness process name" + [ ! -s "$err" ] || fail "session-lock liveness wrote basename option noise for literal -codex: $(cat "$err")" + + pass "harness identity: dash-leading ps command names are basename operands, not options" +} + # =========================================================================== # B) propagate_inheritable_config unit behavior # =========================================================================== test_propagate_lib() { - local d src dest m1 m2 outside stdout stderr guard_repo err_text + local d src dest home m1 m2 outside stdout stderr guard_repo err_text d="$TMP_ROOT/prop-lib" src="$d/src" - dest="$d/dest" - mkdir -p "$src" "$dest" + home="$d/home1" + dest="$home/config" + mkdir -p "$src" "$dest" "$home/state" # 1. present source is copied printf '{"default":{"harness":"codex"}}\n' > "$src/crew-dispatch.json" printf 'codex\n' > "$src/crew-harness" printf 'manual\n' > "$src/backlog-backend" + printf 'tmux\n' > "$src/backend" : > "$src/herdr-presentation-spaces" stdout="$d/clean-copy.out" stderr="$d/clean-copy.err" @@ -210,7 +264,11 @@ test_propagate_lib() { [ "$(cat "$dest/crew-dispatch.json")" = '{"default":{"harness":"codex"}}' ] || fail "crew-dispatch.json not propagated" [ "$(cat "$dest/crew-harness")" = codex ] || fail "crew-harness not propagated" [ "$(cat "$dest/backlog-backend")" = manual ] || fail "backlog-backend not propagated" + [ "$(cat "$dest/backend")" = tmux ] || fail "backend not propagated" [ -f "$dest/herdr-presentation-spaces" ] || fail "herdr-presentation-spaces not propagated" + printf 'herdr\n' > "$dest/backend" + propagate_inheritable_config "$src" "$dest" + [ "$(cat "$dest/backend")" = tmux ] || fail "primary backend did not overwrite a divergent destination" # 2. idempotent: an unchanged re-run does not churn the mtime m1=$(date -r "$dest/crew-harness" +%s 2>/dev/null || stat -c %Y "$dest/crew-harness") @@ -227,10 +285,12 @@ test_propagate_lib() { printf '{"default":{"harness":"claude"}}\n' > "$src/crew-dispatch.json" printf 'claude\n' > "$src/crew-harness" printf 'tasks-axi\n' > "$src/backlog-backend" + printf 'zellij\n' > "$src/backend" propagate_inheritable_config "$src" "$dest" [ "$(cat "$dest/crew-dispatch.json")" = '{"default":{"harness":"claude"}}' ] || fail "changed dispatch profile did not converge" [ "$(cat "$dest/crew-harness")" = claude ] || fail "changed value did not converge" [ "$(cat "$dest/backlog-backend")" = tasks-axi ] || fail "changed backlog backend did not converge" + [ "$(cat "$dest/backend")" = zellij ] || fail "changed backend did not converge" outside="$d/outside-target" rm -f "$dest/crew-harness" "$outside" @@ -243,11 +303,14 @@ test_propagate_lib() { [ "$(cat "$outside")" = outside ] || fail "destination symlink target was overwritten" # 4. removing the source mirrors absence downstream (primary-authoritative) - rm -f "$src/crew-dispatch.json" "$src/crew-harness" "$src/backlog-backend" "$src/herdr-presentation-spaces" + printf 'herdr\n' > "$dest/backend" + rm -f "$src/crew-dispatch.json" "$src/crew-harness" "$src/backlog-backend" \ + "$src/backend" "$src/herdr-presentation-spaces" propagate_inheritable_config "$src" "$dest" [ -e "$dest/crew-dispatch.json" ] && fail "dispatch profile absence not mirrored downstream" [ -e "$dest/crew-harness" ] && fail "absence not mirrored downstream" [ -e "$dest/backlog-backend" ] && fail "backlog-backend absence not mirrored downstream" + [ -e "$dest/backend" ] && fail "backend absence not mirrored downstream" [ -e "$dest/herdr-presentation-spaces" ] && fail "herdr-presentation-spaces absence not mirrored downstream" rm -f "$dest/crew-harness" @@ -265,22 +328,25 @@ test_propagate_lib() { [ -d "$dest/crew-harness" ] || fail "failed absence mirror removed the wrong path" rm -rf "$dest/crew-harness" - # 5. secondmate-harness is never inherited + # 5. secondmate-harness is never inherited; backend still is printf 'grok\n' > "$src/secondmate-harness" printf '{"default":{"harness":"codex"}}\n' > "$src/crew-dispatch.json" printf 'codex\n' > "$src/crew-harness" printf 'manual\n' > "$src/backlog-backend" - rm -rf "$d/dest2" - mkdir -p "$d/dest2" - propagate_inheritable_config "$src" "$d/dest2" - [ -e "$d/dest2/secondmate-harness" ] && fail "secondmate-harness was inherited (must not be)" - [ "$(cat "$d/dest2/crew-dispatch.json")" = '{"default":{"harness":"codex"}}' ] || fail "crew-dispatch.json not propagated alongside" - [ "$(cat "$d/dest2/crew-harness")" = codex ] || fail "crew-harness not propagated alongside" - [ "$(cat "$d/dest2/backlog-backend")" = manual ] || fail "backlog-backend not propagated alongside" + printf 'herdr\n' > "$src/backend" + rm -rf "$d/home2" + mkdir -p "$d/home2/config" "$d/home2/state" + propagate_inheritable_config "$src" "$d/home2/config" + [ -e "$d/home2/config/secondmate-harness" ] && fail "secondmate-harness was inherited (must not be)" + [ "$(cat "$d/home2/config/crew-dispatch.json")" = '{"default":{"harness":"codex"}}' ] || fail "crew-dispatch.json not propagated alongside" + [ "$(cat "$d/home2/config/crew-harness")" = codex ] || fail "crew-harness not propagated alongside" + [ "$(cat "$d/home2/config/backlog-backend")" = manual ] || fail "backlog-backend not propagated alongside" + [ "$(cat "$d/home2/config/backend")" = herdr ] || fail "backend not propagated alongside" # 6. nothing to propagate -> destination dir is never created (a true no-op) rm -rf "$d/src3" "$d/dest3" mkdir -p "$d/src3" + # Keep backend out of the empty-source case by clearing it from src3 only. propagate_inheritable_config "$d/src3" "$d/dest3/config" [ -e "$d/dest3/config" ] && fail "empty-source propagation created a destination dir" @@ -371,6 +437,7 @@ test_spawn_split_and_inherit() { printf 'claude\n' > "$w/home/config/crew-harness" printf 'codex\n' > "$w/home/config/secondmate-harness" printf 'manual\n' > "$w/home/config/backlog-backend" + printf 'zellij\n' > "$w/home/config/backend" make_seeded_home "$sm" sm spawn_secondmate "$w" sm "$sm" @@ -385,6 +452,8 @@ test_spawn_split_and_inherit() { || fail "split: home crew-dispatch.json not inherited" [ "$(cat "$sm/config/backlog-backend" 2>/dev/null)" = manual ] \ || fail "split: home backlog-backend not inherited as manual" + [ "$(cat "$sm/config/backend" 2>/dev/null)" = zellij ] \ + || fail "split: home backend not inherited as zellij" [ -e "$sm/config/secondmate-harness" ] \ && fail "split: secondmate-harness leaked into the secondmate home" pass "B2 spawn: secondmate runs the secondmate harness; its home inherits declared config" @@ -537,6 +606,50 @@ spawn_secondmate_capture() { "$ROOT/bin/fm-spawn.sh" "$id" "$home" "$@" --secondmate } +test_spawn_backend_precedence_over_inherited_config() { + local w sm meta launchlog out status + w="$TMP_ROOT/spawn-backend-env-precedence" + sm="$w/sm" + launchlog="$w/launch.log" + mkdir -p "$w/home/config" + printf 'herdr\n' > "$w/home/config/backend" + make_seeded_home "$sm" sm + + out=$(FM_BACKEND=tmux spawn_secondmate_capture \ + "$w" sm "$sm" "$launchlog" 2>&1); status=$? + expect_code 0 "$status" \ + "FM_BACKEND=tmux should beat inherited config/backend=herdr"$'\n'"$out" + + meta="$w/home/state/sm.meta" + [ "$(cat "$sm/config/backend")" = herdr ] \ + || fail "backend precedence fixture did not inherit config/backend=herdr" + assert_no_grep '^backend=' "$meta" \ + "FM_BACKEND=tmux did not beat inherited config/backend=herdr" + pass "B5b spawn: FM_BACKEND wins over inherited config/backend" +} + +test_spawn_explicit_backend_precedence_over_env_and_inherited_config() { + local w sm meta launchlog out status + w="$TMP_ROOT/spawn-backend-flag-precedence" + sm="$w/sm" + launchlog="$w/launch.log" + mkdir -p "$w/home/config" + printf 'herdr\n' > "$w/home/config/backend" + make_seeded_home "$sm" sm + + out=$(FM_BACKEND=zellij spawn_secondmate_capture \ + "$w" sm "$sm" "$launchlog" --backend tmux 2>&1); status=$? + expect_code 0 "$status" \ + "explicit --backend tmux should beat FM_BACKEND=zellij and inherited config/backend=herdr"$'\n'"$out" + + meta="$w/home/state/sm.meta" + [ "$(cat "$sm/config/backend")" = herdr ] \ + || fail "explicit backend precedence fixture did not inherit config/backend=herdr" + assert_no_grep '^backend=' "$meta" \ + "explicit --backend tmux did not beat FM_BACKEND=zellij and inherited config/backend=herdr" + pass "B5c spawn: explicit --backend wins over FM_BACKEND and inherited config/backend" +} + # A bare "" secondmate-harness file (today's format) must launch with # NO --model/--effort flag at all, and meta must keep recording model=default, # effort=default - the core backward-compat requirement of the new format. @@ -770,6 +883,7 @@ new_world() { printf 'projects/\nstate/\ndata/\n.no-mistakes/\n' [ "$dispatch_ignore" = no ] || printf 'config/crew-dispatch.json\n' printf 'config/crew-harness\nconfig/secondmate-harness\nconfig/backlog-backend\n' + printf 'config/backend\nconfig/herdr-presentation-spaces\n' } > "$w/main/.gitignore" printf 'v1\n' > "$w/main/AGENTS.md" printf 'r1\n' > "$w/main/README.md" @@ -965,6 +1079,7 @@ test_bootstrap_sweep_propagates_and_reconverges() { printf '{"default":{"harness":"codex"}}\n' > "$w/home/config/crew-dispatch.json" printf 'codex\n' > "$w/home/config/crew-harness" printf 'manual\n' > "$w/home/config/backlog-backend" + printf 'tmux\n' > "$w/home/config/backend" printf 'grok\n' > "$w/home/config/secondmate-harness" run_bootstrap "$w" >/dev/null [ "$(cat "$w/sm/config/crew-harness" 2>/dev/null)" = codex ] \ @@ -973,6 +1088,8 @@ test_bootstrap_sweep_propagates_and_reconverges() { || fail "sweep: crew-dispatch.json not pushed into the live home" [ "$(cat "$w/sm/config/backlog-backend" 2>/dev/null)" = manual ] \ || fail "sweep: backlog-backend not pushed into the live home" + [ "$(cat "$w/sm/config/backend" 2>/dev/null)" = tmux ] \ + || fail "sweep: backend not pushed into the live home" [ -e "$w/sm/config/secondmate-harness" ] \ && fail "sweep: secondmate-harness was inherited (must not be)" @@ -980,6 +1097,7 @@ test_bootstrap_sweep_propagates_and_reconverges() { printf '{"default":{"harness":"claude"}}\n' > "$w/home/config/crew-dispatch.json" printf 'claude\n' > "$w/home/config/crew-harness" printf 'tasks-axi\n' > "$w/home/config/backlog-backend" + printf 'zellij\n' > "$w/home/config/backend" run_bootstrap "$w" >/dev/null [ "$(cat "$w/sm/config/crew-harness" 2>/dev/null)" = claude ] \ || fail "sweep: home did not re-converge to the primary's new crew-harness" @@ -987,9 +1105,12 @@ test_bootstrap_sweep_propagates_and_reconverges() { || fail "sweep: home did not re-converge to the primary's new crew-dispatch.json" [ "$(cat "$w/sm/config/backlog-backend" 2>/dev/null)" = tasks-axi ] \ || fail "sweep: home did not re-converge to the primary's new backlog-backend" + [ "$(cat "$w/sm/config/backend" 2>/dev/null)" = zellij ] \ + || fail "sweep: home did not re-converge to the primary's new backend" # Mirror absence: primary clears inherited config; the home's copies are removed. - rm -f "$w/home/config/crew-dispatch.json" "$w/home/config/crew-harness" "$w/home/config/backlog-backend" + rm -f "$w/home/config/crew-dispatch.json" "$w/home/config/crew-harness" \ + "$w/home/config/backlog-backend" "$w/home/config/backend" run_bootstrap "$w" >/dev/null [ -e "$w/sm/config/crew-dispatch.json" ] \ && fail "sweep: home crew-dispatch.json not removed after the primary cleared it" @@ -997,6 +1118,8 @@ test_bootstrap_sweep_propagates_and_reconverges() { && fail "sweep: home crew-harness not removed after the primary cleared it" [ -e "$w/sm/config/backlog-backend" ] \ && fail "sweep: home backlog-backend not removed after the primary cleared it" + [ -e "$w/sm/config/backend" ] \ + && fail "sweep: home backend not removed after the primary cleared it" pass "B7 bootstrap sweep pushes, re-converges, and mirrors absence; never inherits secondmate-harness" } @@ -1011,6 +1134,7 @@ test_bootstrap_sweep_propagates_when_tracked_current() { printf '{"default":{"harness":"codex"}}\n' > "$w/home/config/crew-dispatch.json" printf 'codex\n' > "$w/home/config/crew-harness" printf 'manual\n' > "$w/home/config/backlog-backend" + printf 'tmux\n' > "$w/home/config/backend" run_bootstrap "$w" >/dev/null [ "$(cat "$w/sm/config/crew-dispatch.json" 2>/dev/null)" = '{"default":{"harness":"codex"}}' ] \ || fail "crew-dispatch.json did not propagate to a tracked-current home" @@ -1018,6 +1142,8 @@ test_bootstrap_sweep_propagates_when_tracked_current() { || fail "config did not propagate to a tracked-current home" [ "$(cat "$w/sm/config/backlog-backend" 2>/dev/null)" = manual ] \ || fail "backlog-backend did not propagate to a tracked-current home" + [ "$(cat "$w/sm/config/backend" 2>/dev/null)" = tmux ] \ + || fail "backend did not propagate to a tracked-current home" pass "B8 bootstrap sweep propagates config even when the home's tracked files are already current" } @@ -1069,12 +1195,49 @@ test_bootstrap_sweep_no_inheritance_is_noop() { [ -e "$w/sm/config/crew-dispatch.json" ] && fail "no-inheritance sweep created a home crew-dispatch.json" [ -e "$w/sm/config/crew-harness" ] && fail "no-inheritance sweep created a home crew-harness" + [ -e "$w/sm/config/backend" ] && fail "no-inheritance sweep created a home backend" [ -e "$w/sm/config" ] && fail "no-inheritance sweep created a home config/ dir" [ "$(git -C "$w/sm" rev-parse HEAD)" = "$head" ] \ || fail "no-inheritance sweep did not still fast-forward the tracked files" pass "B10 bootstrap sweep with no inherited config is a config no-op and still fast-forwards" } +# config/backend: present and absent primary state converges exactly. +test_backend_inheritance_present_and_absent() { + local w head out err status instruction + w=$(new_world backend-inherit) + head=$(git -C "$w/main" rev-parse HEAD) + add_sm_worktree "$w" sm "$head" + + printf 'tmux\n' > "$w/home/config/backend" + err="$w/backend-inherit.err" + out=$(run_config_push "$w" 2>"$err"); status=$? + expect_code 0 "$status" "backend present push should succeed" + assert_contains "$out" "backend: pushed" "backend present value should report pushed" + [ "$(cat "$w/sm/config/backend")" = tmux ] || fail "backend present value not pushed" + instruction=$(reread_instruction_path "$w/sm") || fail "backend present reread instruction missing" + assert_contains "$(cat "$instruction")" $'-----BEGIN config/backend-----\ntmux\n-----END config/backend-----' \ + "backend present reread must include exact bytes" + + printf 'herdr\n' > "$w/sm/config/backend" + printf 'zellij\n' > "$w/home/config/backend" + out=$(run_config_push "$w" 2>"$err"); status=$? + expect_code 0 "$status" "backend changed push should succeed" + assert_contains "$out" "backend: pushed" "backend changed value should report pushed" + [ "$(cat "$w/sm/config/backend")" = zellij ] \ + || fail "primary backend did not overwrite the divergent destination" + + rm -f "$w/home/config/backend" + out=$(run_config_push "$w" 2>"$err"); status=$? + expect_code 0 "$status" "backend absence push should succeed" + assert_contains "$out" "backend: pushed - mirrored primary absence" "backend should mirror primary absence" + [ -e "$w/sm/config/backend" ] && fail "backend not removed on primary absence" + instruction=$(reread_instruction_path "$w/sm") || fail "backend absence reread instruction missing" + assert_contains "$(cat "$instruction")" $'-----BEGIN config/backend-----\nABSENT\n-----END config/backend-----' \ + "backend absence reread must use ABSENT token" + pass "B12b backend inheritance: present values and primary absence converge exactly" +} + test_bootstrap_sweep_surfaces_config_propagation_failure() { local w c1 out fail_line w=$(new_world boot-prop-fail) @@ -1113,7 +1276,7 @@ test_bootstrap_rereads_after_partial_propagation() { } test_config_push_propagates_reports_without_ff_or_nudge() { - local w c1 sm_real old_head out err status out2 tmp log + local w c1 sm_real old_head out err status out2 tmp log instruction w=$(new_world config-push-basic) c1=$(git -C "$w/main" rev-parse HEAD) add_sm_worktree "$w" sm "$c1" @@ -1131,6 +1294,7 @@ test_config_push_propagates_reports_without_ff_or_nudge() { printf '{"default":{"harness":"codex"}}\n' > "$w/home/config/crew-dispatch.json" printf 'codex\n' > "$w/home/config/crew-harness" printf 'manual\n' > "$w/home/config/backlog-backend" + printf 'tmux\n' > "$w/home/config/backend" err="$w/config-push-basic.err" log="$w/config-push-basic.tmux.log" out=$(run_config_push "$w" "$log" 2>"$err"); status=$? @@ -1146,12 +1310,18 @@ test_config_push_propagates_reports_without_ff_or_nudge() { "config push did not report crew-harness as pushed" assert_contains "$out" "backlog-backend: pushed" \ "config push did not report backlog-backend as pushed" + assert_contains "$out" "backend: pushed" \ + "config push did not report backend as pushed" assert_contains "$out" "config-reread: sent" \ "config push with changed config must send a literal reread instruction" assert_not_contains "$out" "NUDGE_SECONDMATES" \ "config push must not use the AGENTS.md instruction-surface nudge channel" [ "$(git -C "$w/sm" rev-parse HEAD)" = "$old_head" ] \ || fail "config push fast-forwarded tracked files" + [ "$(cat "$w/sm/config/backend")" = tmux ] || fail "config push did not write backend" + instruction=$(reread_instruction_path "$w/sm") || fail "config-push reread instruction missing" + assert_contains "$(cat "$instruction")" $'-----BEGIN config/backend-----\ntmux\n-----END config/backend-----' \ + "config-push reread must include exact backend bytes" [ ! -s "$err" ] || fail "clean config push wrote unexpected stderr: $(cat "$err")" assert_contains "$(cat "$log")" "[fm-from-firstmate]" \ "config reread must use the marked routed secondmate path" @@ -1165,6 +1335,8 @@ test_config_push_propagates_reports_without_ff_or_nudge() { "idempotent config push did not report crew-harness as unchanged" assert_contains "$out2" "backlog-backend: unchanged" \ "idempotent config push did not report backlog-backend as unchanged" + assert_contains "$out2" "backend: unchanged" \ + "idempotent config push did not report backend as unchanged" assert_not_contains "$out2" "config-reread: sent" \ "unchanged config must not send a reread message" [ ! -s "$log" ] || fail "unchanged config push still invoked tmux send: $(cat "$log")" @@ -1301,6 +1473,7 @@ test_config_reread_per_home_changed_sets_and_exact_bytes() { printf '%s' "$multiline_json" > "$w/home/config/crew-dispatch.json" printf 'codex\n' > "$w/home/config/crew-harness" printf 'manual\n' > "$w/home/config/backlog-backend" + printf 'tmux\n' > "$w/home/config/backend" { shared_captain_header_for_tests printf '%s\n' "shared secret preference body that must never appear in a config reread" @@ -1319,6 +1492,7 @@ test_config_reread_per_home_changed_sets_and_exact_bytes() { || fail "beta did not receive multiline dispatch" [ "$(cat "$w/alpha/config/crew-harness")" = codex ] || fail "alpha harness not updated" [ "$(cat "$w/alpha/config/backlog-backend")" = manual ] || fail "alpha backlog-backend not updated" + [ "$(cat "$w/alpha/config/backend")" = tmux ] || fail "alpha backend not updated" instr_a=$(reread_instruction_path "$w/alpha") || fail "alpha instruction missing after config push" instr_b=$(reread_instruction_path "$w/beta") || fail "beta instruction missing after config push" @@ -1328,19 +1502,21 @@ test_config_reread_per_home_changed_sets_and_exact_bytes() { [ "$(reread_mode "$instr_b")" = 600 ] || fail "beta instruction is not private" # Deterministic allowlist path order and exact destination bytes for alpha - # (all three config items were missing/stale and therefore pushed). + # (allowlisted config items were missing/stale and therefore pushed). assert_grep "These inherited config files changed" "$instr_a" "alpha framing missing" assert_grep "defaults/rules" "$instr_a" "alpha must preserve agent judgment framing" assert_contains "$(cat "$instr_a")" "config/crew-dispatch.json" "alpha missing dispatch path" assert_contains "$(cat "$instr_a")" "config/crew-harness" "alpha missing harness path" assert_contains "$(cat "$instr_a")" "config/backlog-backend" "alpha missing backlog path" + assert_contains "$(cat "$instr_a")" "config/backend" "alpha missing backend path" # Path order follows FM_INHERITABLE_CONFIG. awk ' /config\/crew-dispatch\.json/ { d=NR } /config\/crew-harness/ { h=NR } /config\/backlog-backend/ { b=NR } + /config\/backend/ && !/backlog-backend/ { k=NR } END { - if (!(d && h && b && d < h && h < b)) exit 1 + if (!(d && h && b && k && d < h && h < b && b < k)) exit 1 } ' "$instr_a" || fail "alpha instruction path order is not deterministic allowlist order" @@ -1351,6 +1527,8 @@ test_config_reread_per_home_changed_sets_and_exact_bytes() { "alpha instruction must include exact harness scalar bytes" assert_contains "$(cat "$instr_a")" $'-----BEGIN config/backlog-backend-----\nmanual\n-----END config/backlog-backend-----' \ "alpha instruction must include exact backlog-backend scalar bytes" + assert_contains "$(cat "$instr_a")" $'-----BEGIN config/backend-----\ntmux\n-----END config/backend-----' \ + "alpha instruction must include exact backend scalar bytes" # No parsed/effective summary, no SHA, no captain-shared dump. assert_not_contains "$(cat "$instr_a")" "Default worker" "must not emit parsed worker summary" @@ -1434,6 +1612,7 @@ test_config_reread_isolation_and_absent_and_send_failure() { printf '%s\n' $'crew-dispatch.json\tpushed\tmirrored primary absence' printf '%s\n' $'crew-harness\tunchanged\t' printf '%s\n' $'backlog-backend\tunchanged\t' + printf '%s\n' $'backend\tunchanged\t' printf '%s\n' $'data/captain-shared.md\tpushed\t' } > "$report" rm -f "$w/beta/config/crew-dispatch.json" @@ -2118,12 +2297,15 @@ SH test_harness_resolution test_secondmate_model_effort_tokens test_pi_signed_detection_and_session_lock_identity +test_dash_leading_process_names_are_basename_operands test_propagate_lib test_spawn_split_and_inherit test_spawn_backward_compat_crew_fallback test_spawn_bare_backward_compat test_spawn_explicit_harness_wins test_spawn_unverified_secondmate_harness_refused +test_spawn_backend_precedence_over_inherited_config +test_spawn_explicit_backend_precedence_over_env_and_inherited_config test_spawn_bare_harness_no_model_effort_flag test_spawn_secondmate_harness_model_token test_spawn_secondmate_harness_model_and_effort_tokens @@ -2136,6 +2318,7 @@ test_bootstrap_sweep_propagates_and_reconverges test_bootstrap_sweep_propagates_when_tracked_current test_bootstrap_sweep_defers_dispatch_on_stale_unignored_home test_bootstrap_sweep_no_inheritance_is_noop +test_backend_inheritance_present_and_absent test_bootstrap_sweep_surfaces_config_propagation_failure test_bootstrap_rereads_after_partial_propagation test_config_push_propagates_reports_without_ff_or_nudge diff --git a/tests/fm-secondmate-sync.test.sh b/tests/fm-secondmate-sync.test.sh index 5abbb918733..50455dc2c79 100755 --- a/tests/fm-secondmate-sync.test.sh +++ b/tests/fm-secondmate-sync.test.sh @@ -837,15 +837,6 @@ test_seed_marker_does_not_mask_real_dirt() { pass "T14 marker tolerance does not mask a genuinely dirty home" } -# --- T15: the shipped firstmate repo gitignores the seed marker ----------------- -# Pins the actual fix so it cannot silently regress: without this .gitignore entry -# every seeded home would read dirty again the moment it lands on this repo's HEAD. -test_repo_gitignores_seed_marker() { - grep -qxF '.fm-secondmate-home' "$ROOT/.gitignore" \ - || fail "the firstmate repo .gitignore must ignore the seed marker (.fm-secondmate-home)" - pass "T15 the firstmate repo gitignores the secondmate seed marker" -} - test_ff_updated test_ff_current test_ff_dirty @@ -866,6 +857,5 @@ test_spawn_warns_when_sync_skipped_before_launch test_seed_marker_clean_when_gitignored test_seed_marker_converges_existing_home test_seed_marker_does_not_mask_real_dirt -test_repo_gitignores_seed_marker echo "# all fm-secondmate-sync tests passed" diff --git a/tests/fm-send-secondmate-marker-herdr-e2e.test.sh b/tests/fm-send-secondmate-marker-herdr-e2e.test.sh index 528ea49f223..5e28b9aaf8d 100755 --- a/tests/fm-send-secondmate-marker-herdr-e2e.test.sh +++ b/tests/fm-send-secondmate-marker-herdr-e2e.test.sh @@ -27,7 +27,7 @@ if [ "${FM_SEND_MARKER_HERDR_E2E:-0}" != 1 ]; then exit 0 fi -for tool in git herdr jq pi python3; do +for tool in git herdr jq pi; do command -v "$tool" >/dev/null 2>&1 || { echo "skip: $tool not found"; exit 0; } done @@ -39,6 +39,7 @@ SECOND_HOME="$TMP_ROOT/secondmate-home" CAPTURE="$TMP_ROOT/pi-before-agent.jsonl" FAKEBIN="$TMP_ROOT/fakebin" ORIGINAL_PATH=$PATH +REAL_PI=$(command -v pi) ID='marker-pi-sm' REQUEST='FM_MARKER_HERDR_E2E exact-id request' DIRECT='FM_MARKER_HERDR_DIRECT captain input' @@ -93,38 +94,25 @@ You are a task-local secondmate used only for the marker transport regression. Stay idle and do not initiate work. EOF -# The extension is already an explicit Pi -e resource in the real secondmate -# launch template, so its project_trust hook can grant session-only trust before -# project resources load. before_agent_start records the exact prompt bytes and -# aborts before any provider request, keeping this transport regression local. +# A separate explicit Pi extension grants session-only project trust, records +# before_agent_start prompt bytes, and aborts before any provider request. +# The PATH wrapper adds only that test resource while preserving the production +# secondmate launch and its own extension arguments unchanged. CAPTURE_JSON=$(printf '%s' "$CAPTURE" | jq -Rs .) -python3 - "$SECOND_HOME/.pi/extensions/fm-primary-turnend-guard.ts" "$CAPTURE_JSON" <<'PY' -from pathlib import Path -import sys - -path = Path(sys.argv[1]) -capture_json = sys.argv[2] -source = path.read_text() -import_anchor = 'import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";\n' -source = source.replace( - import_anchor, - import_anchor - + 'import { appendFileSync as fmAppendFileSync } from "node:fs";\n' - + f'const fmCapturePath = {capture_json};\n', - 1, -) -factory_anchor = 'export default function (pi: ExtensionAPI) {\n' -replacement = '''export default function (pi: ExtensionAPI) { +CAPTURE_EXTENSION="$TMP_ROOT/fm-send-marker-capture.ts" +cat > "$CAPTURE_EXTENSION" < ({ trusted: "yes", remember: false })); pi.on("before_agent_start", (event, ctx) => { - fmAppendFileSync(fmCapturePath, `${JSON.stringify({ prompt: event.prompt, hex: Buffer.from(event.prompt, "utf8").toString("hex") })}\\n`); + appendFileSync(capturePath, \`\${JSON.stringify({ prompt: event.prompt, hex: Buffer.from(event.prompt, "utf8").toString("hex") })}\\n\`); ctx.abort(); }); -''' -if import_anchor not in source or factory_anchor not in source: - raise SystemExit("Pi extension insertion point missing") -path.write_text(source.replace(factory_anchor, replacement, 1)) -PY +} +EOF +printf '#!/usr/bin/env bash\nexec %q -e %q "$@"\n' "$REAL_PI" "$CAPTURE_EXTENSION" > "$FAKEBIN/pi" +chmod +x "$FAKEBIN/pi" "$LAB_HELPER" provision "$SESSION" PATH="$FAKEBIN:$ORIGINAL_PATH" FM_GATE_REFUSE_BYPASS=1 FM_HOME="$SENDER_HOME" HERDR_SESSION="$SESSION" \ diff --git a/tests/fm-sessionstart-nudge.test.sh b/tests/fm-sessionstart-nudge.test.sh index 28bb3d18b55..878295cba5a 100755 --- a/tests/fm-sessionstart-nudge.test.sh +++ b/tests/fm-sessionstart-nudge.test.sh @@ -148,44 +148,6 @@ EOF pass "OpenCode session.created delivers the exact wrapper nudge once per session" } -test_tracked_harness_registration() { - local command pi_plugin opencode_plugin - jq -e '.hooks.SessionStart | length == 1' "$ROOT/.claude/settings.json" >/dev/null \ - || fail "Claude SessionStart hook is not registered exactly once" - jq -e '.hooks.SessionStart[0].matcher == "startup|resume|clear"' "$ROOT/.claude/settings.json" >/dev/null \ - || fail "Claude SessionStart matcher must include startup/resume/clear and exclude compact" - jq -e 'any(.hooks.SessionStart[]?.hooks[]?.command?; contains("fm-sessionstart-nudge.sh"))' \ - "$ROOT/.claude/settings.json" >/dev/null || fail "Claude SessionStart hook does not invoke the wrapper" - - command=$(jq -r '.hooks.SessionStart[0].hooks[0].command' "$ROOT/.codex/hooks.json") - # shellcheck disable=SC2016 - assert_contains "$command" 'payload=$(cat' "Codex SessionStart hook does not read its payload" - # shellcheck disable=SC2016 - assert_contains "$command" 'root=$(pwd -P)' "Codex SessionStart hook is not pwd-anchored" - assert_contains "$command" 'fm-sessionstart-nudge.sh' "Codex SessionStart hook does not invoke the wrapper" - - command=$(jq -r '.hooks.SessionStart[0].hooks[0].command' "$ROOT/.grok/hooks/fm-primary-sessionstart-nudge.json") - # shellcheck disable=SC2016 - assert_contains "$command" '${GROK_WORKSPACE_ROOT:-}' "Grok SessionStart hook lacks an inline-default workspace root" - # shellcheck disable=SC2016 - assert_not_contains "$command" '${GROK_WORKSPACE_ROOT}' "Grok SessionStart hook contains a bare variable expansion" - assert_contains "$command" 'fm-sessionstart-nudge.sh' "Grok SessionStart hook does not invoke the wrapper" - - pi_plugin=$(cat "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts") - assert_contains "$pi_plugin" '["startup", "new", "resume"]' "Pi SessionStart handler has the wrong reason allowlist" - assert_contains "$pi_plugin" 'fm-sessionstart-nudge.sh' "Pi SessionStart handler does not invoke the wrapper" - assert_contains "$pi_plugin" 'firstmate-sessionstart-nudge' "Pi SessionStart handler does not inject a custom context message" - assert_contains "$pi_plugin" 'details: { kind: "session-start" }' "Pi SessionStart context does not retain its exact structured kind" - assert_contains "$pi_plugin" 'pi.sendMessage' "Pi SessionStart handler does not use the context-safe message API" - - opencode_plugin=$(cat "$ROOT/.opencode/plugins/fm-primary-sessionstart-nudge.js") - assert_contains "$opencode_plugin" 'session.created' "OpenCode plugin does not listen for session.created" - assert_contains "$opencode_plugin" 'fm-sessionstart-nudge.sh' "OpenCode plugin does not invoke the wrapper" - assert_contains "$opencode_plugin" 'promptAsync' "OpenCode plugin does not prompt the nudge turn" - - pass "all five verified harnesses register the shared session-start nudge" -} - test_genuine_primary_nudges test_gate_env_is_silent test_gate_common_dir_is_silent @@ -194,4 +156,3 @@ test_linked_secondmate_primary_nudges test_missing_state_is_silent test_owned_lock_is_silent test_opencode_plugin_delivers_exact_nudge_once -test_tracked_harness_registration diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index 3a8dfb3a4e7..e5f017608dc 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -84,10 +84,15 @@ run_spawn() { local home=$1 wt=$2 fakebin=$3 launchlog=$4 shift 4 : > "$launchlog" + # CLAUDE_CONFIG_DIR is forwarded onto claude launches by fm-spawn, so pin it + # explicitly (empty by default) instead of leaking the invoking shell's value, + # which would make launch assertions depend on the developer's environment. + # A test opts in to the set case via FM_TEST_CLAUDE_CONFIG_DIR. FM_ROOT_OVERRIDE='' FM_HOME="$home" \ FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \ FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$wt" TMUX="fake,1,0" \ + CLAUDE_CONFIG_DIR="${FM_TEST_CLAUDE_CONFIG_DIR:-}" \ FM_FAKE_LAUNCH_LOG="$launchlog" GROK_HOME="$home/grok-home" PATH="$fakebin:$PATH" \ "$SPAWN" "$@" 2>&1 } @@ -123,6 +128,153 @@ test_no_profile_keeps_claude_profile_defaults() { pass "no --model/--effort records defaults and types the claude launch instructions" } +test_relative_home_overrides_launch_with_absolute_cross_process_paths() { + local rec id out status launch home_real + id=profile-relative-paths-z1b + rec=$(make_spawn_case profile-relative-paths pi "$id") + read_case_record "$rec" + home_real=$(cd "$HOME_DIR" && pwd -P) + mkdir -p "$CASE_DIR/cdpath/home/state" "$CASE_DIR/cdpath/home/data" + : > "$LAUNCH_LOG" + + out=$( + cd "$CASE_DIR" || exit 1 + CDPATH="$CASE_DIR/cdpath" FM_ROOT_OVERRIDE='' FM_HOME=home \ + FM_STATE_OVERRIDE=home/state FM_DATA_OVERRIDE=home/data \ + FM_PROJECTS_OVERRIDE=home/projects FM_CONFIG_OVERRIDE=home/config \ + FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$WT_DIR" TMUX="fake,1,0" \ + CLAUDE_CONFIG_DIR='' FM_FAKE_LAUNCH_LOG="$LAUNCH_LOG" \ + GROK_HOME=home/grok-home PATH="$FAKEBIN_DIR:$PATH" \ + "$SPAWN" "$id" "$PROJ_DIR" 2>&1 + ) + status=$? + expect_code 0 "$status" "spawn with relative home overrides should succeed" + launch=$(cat "$LAUNCH_LOG") + assert_contains "$launch" "-e '$home_real/state/$id.pi-ext.ts'" \ + "relative FM_STATE_OVERRIDE leaked into Pi's cross-process extension path" + assert_contains "$launch" "< '$home_real/data/$id/brief.md'" \ + "relative FM_DATA_OVERRIDE leaked into the cross-process brief path" + pass "relative home overrides ignore CDPATH and become absolute before spawn launch construction" +} + +test_home_defaults_preserve_absolute_or_resolve_relative_paths() { + local rec relative_id absolute_id out status launch home_real linked_home + relative_id=profile-relative-home-defaults-z1c + absolute_id=profile-absolute-home-defaults-z1d + rec=$(make_spawn_case profile-home-defaults pi "$relative_id" "$absolute_id") + read_case_record "$rec" + home_real=$(cd "$HOME_DIR" && pwd -P) + + : > "$LAUNCH_LOG" + out=$( + cd "$CASE_DIR" || exit 1 + FM_ROOT_OVERRIDE='' FM_HOME=home \ + FM_STATE_OVERRIDE='' FM_DATA_OVERRIDE='' \ + FM_PROJECTS_OVERRIDE=home/projects FM_CONFIG_OVERRIDE=home/config \ + FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$WT_DIR" TMUX="fake,1,0" \ + CLAUDE_CONFIG_DIR='' FM_FAKE_LAUNCH_LOG="$LAUNCH_LOG" \ + GROK_HOME=home/grok-home PATH="$FAKEBIN_DIR:$PATH" \ + "$SPAWN" "$relative_id" "$PROJ_DIR" 2>&1 + ) + status=$? + expect_code 0 "$status" "spawn with relative FM_HOME defaults should succeed" + launch=$(cat "$LAUNCH_LOG") + assert_contains "$launch" "-e '$home_real/state/$relative_id.pi-ext.ts'" \ + "relative FM_HOME leaked into Pi's default cross-process extension path" + assert_contains "$launch" "< '$home_real/data/$relative_id/brief.md'" \ + "relative FM_HOME leaked into the default cross-process brief path" + + linked_home="$CASE_DIR/home-link" + ln -s "$HOME_DIR" "$linked_home" + : > "$LAUNCH_LOG" + out=$( + FM_ROOT_OVERRIDE='' FM_HOME="$linked_home" \ + FM_STATE_OVERRIDE='' FM_DATA_OVERRIDE='' \ + FM_PROJECTS_OVERRIDE="$linked_home/projects" FM_CONFIG_OVERRIDE="$linked_home/config" \ + FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$WT_DIR" TMUX="fake,1,0" \ + CLAUDE_CONFIG_DIR='' FM_FAKE_LAUNCH_LOG="$LAUNCH_LOG" \ + GROK_HOME="$linked_home/grok-home" PATH="$FAKEBIN_DIR:$PATH" \ + "$SPAWN" "$absolute_id" "$PROJ_DIR" 2>&1 + ) + status=$? + expect_code 0 "$status" "spawn with absolute symlink-spelled FM_HOME defaults should succeed" + launch=$(cat "$LAUNCH_LOG") + assert_contains "$launch" "-e '$linked_home/state/$absolute_id.pi-ext.ts'" \ + "absolute FM_HOME spelling changed in Pi's default cross-process extension path" + assert_contains "$launch" "< '$linked_home/data/$absolute_id/brief.md'" \ + "absolute FM_HOME spelling changed in the default cross-process brief path" + pass "FM_HOME defaults resolve relative paths and preserve absolute spellings" +} + +test_absolute_override_spelling_is_preserved_in_launch_paths() { + local rec id out status launch linked_home + id=profile-absolute-paths-z1c + rec=$(make_spawn_case profile-absolute-paths pi "$id") + read_case_record "$rec" + linked_home="$CASE_DIR/home-link" + ln -s "$HOME_DIR" "$linked_home" + : > "$LAUNCH_LOG" + + out=$( + FM_ROOT_OVERRIDE='' FM_HOME="$linked_home" \ + FM_STATE_OVERRIDE="$linked_home/state" FM_DATA_OVERRIDE="$linked_home/data" \ + FM_PROJECTS_OVERRIDE="$linked_home/projects" FM_CONFIG_OVERRIDE="$linked_home/config" \ + FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$WT_DIR" TMUX="fake,1,0" \ + CLAUDE_CONFIG_DIR='' FM_FAKE_LAUNCH_LOG="$LAUNCH_LOG" \ + GROK_HOME="$linked_home/grok-home" PATH="$FAKEBIN_DIR:$PATH" \ + "$SPAWN" "$id" "$PROJ_DIR" 2>&1 + ) + status=$? + expect_code 0 "$status" "spawn with absolute symlink-spelled overrides should succeed" + launch=$(cat "$LAUNCH_LOG") + assert_contains "$launch" "-e '$linked_home/state/$id.pi-ext.ts'" \ + "absolute FM_STATE_OVERRIDE spelling changed in Pi's cross-process extension path" + assert_contains "$launch" "< '$linked_home/data/$id/brief.md'" \ + "absolute FM_DATA_OVERRIDE spelling changed in the cross-process brief path" + pass "absolute override spellings are preserved in spawn launch paths" +} + +test_unresolvable_relative_overrides_fail_loudly() { + local rec id out status + id=profile-unresolvable-paths-z1d + rec=$(make_spawn_case profile-unresolvable-paths pi "$id") + read_case_record "$rec" + + out=$( + cd "$CASE_DIR" || exit 1 + FM_ROOT_OVERRIDE='' FM_HOME=missing-home \ + FM_STATE_OVERRIDE='' FM_DATA_OVERRIDE='' \ + "$SPAWN" "$id" "$PROJ_DIR" 2>&1 + ) + status=$? + expect_code 1 "$status" "spawn with an unresolvable relative home should fail" + assert_contains "$out" "FM_HOME directory cannot be resolved: missing-home" \ + "spawn did not name the unresolvable FM_HOME" + + out=$( + cd "$CASE_DIR" || exit 1 + FM_ROOT_OVERRIDE='' FM_HOME=home \ + FM_STATE_OVERRIDE=missing-state FM_DATA_OVERRIDE=home/data \ + "$SPAWN" "$id" "$PROJ_DIR" 2>&1 + ) + status=$? + expect_code 1 "$status" "spawn with an unresolvable relative state override should fail" + assert_contains "$out" "FM_STATE_OVERRIDE directory cannot be resolved: missing-state" \ + "spawn did not name the unresolvable FM_STATE_OVERRIDE" + + out=$( + cd "$CASE_DIR" || exit 1 + FM_ROOT_OVERRIDE='' FM_HOME=home \ + FM_STATE_OVERRIDE=home/state FM_DATA_OVERRIDE=missing-data \ + "$SPAWN" "$id" "$PROJ_DIR" 2>&1 + ) + status=$? + expect_code 1 "$status" "spawn with an unresolvable relative data override should fail" + assert_contains "$out" "FM_DATA_OVERRIDE directory cannot be resolved: missing-data" \ + "spawn did not name the unresolvable FM_DATA_OVERRIDE" + pass "unresolvable relative spawn overrides fail with named diagnostics" +} + test_active_dispatch_profile_requires_explicit_harness_for_ship() { local rec id out status id=profile-required-ship-z11 @@ -436,6 +588,55 @@ test_batch_forwards_shared_profile_flags() { pass "batch dispatch forwards shared --harness, --model, and --effort to every pair" } +test_claude_forwards_firstmate_config_dir_when_set() { + local rec id out status launch + id=profile-claude-cfgdir-z17 + rec=$(make_spawn_case profile-claude-cfgdir claude "$id") + read_case_record "$rec" + + out=$(FM_TEST_CLAUDE_CONFIG_DIR="/opt/test/claude-work" \ + run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR") + status=$? + expect_code 0 "$status" "claude spawn with CLAUDE_CONFIG_DIR set should succeed" + launch=$(cat "$LAUNCH_LOG") + assert_contains "$launch" "CLAUDE_CONFIG_DIR='/opt/test/claude-work' CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude" \ + "claude launch did not forward firstmate's CLAUDE_CONFIG_DIR to the crewmate pane" + pass "claude forwards firstmate's CLAUDE_CONFIG_DIR so the crewmate uses the same credential store" +} + +test_claude_omits_config_dir_prefix_when_unset() { + local rec id out status launch + id=profile-claude-nocfgdir-z18 + rec=$(make_spawn_case profile-claude-nocfgdir claude "$id") + read_case_record "$rec" + + # run_spawn pins CLAUDE_CONFIG_DIR empty by default, exercising the single-store + # default path where fm-spawn adds no prefix. + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR") + status=$? + expect_code 0 "$status" "claude spawn without CLAUDE_CONFIG_DIR should succeed" + launch=$(cat "$LAUNCH_LOG") + assert_not_contains "$launch" "CLAUDE_CONFIG_DIR=" \ + "claude launch must not add a config-dir prefix when firstmate has no CLAUDE_CONFIG_DIR set" + pass "claude omits the config-dir prefix when firstmate runs with the single-store default" +} + +test_non_claude_harness_ignores_config_dir() { + local rec id out status launch + id=profile-codex-nocfgdir-z19 + rec=$(make_spawn_case profile-codex-nocfgdir codex "$id") + read_case_record "$rec" + + out=$(FM_TEST_CLAUDE_CONFIG_DIR="/opt/test/claude-work" \ + run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR") + status=$? + expect_code 0 "$status" "codex spawn with CLAUDE_CONFIG_DIR set should succeed" + launch=$(cat "$LAUNCH_LOG") + assert_not_contains "$launch" "CLAUDE_CONFIG_DIR=" \ + "non-claude harness launch must not receive the claude-specific config-dir prefix" + pass "non-claude harnesses do not receive the claude CLAUDE_CONFIG_DIR prefix" +} + test_active_dispatch_profile_does_not_block_secondmate_launch() { local rec id sm out status id=profile-secondmate-z16 @@ -455,6 +656,10 @@ test_active_dispatch_profile_does_not_block_secondmate_launch() { } test_no_profile_keeps_claude_profile_defaults +test_relative_home_overrides_launch_with_absolute_cross_process_paths +test_home_defaults_preserve_absolute_or_resolve_relative_paths +test_absolute_override_spelling_is_preserved_in_launch_paths +test_unresolvable_relative_overrides_fail_loudly test_active_dispatch_profile_requires_explicit_harness_for_ship test_active_dispatch_profile_requires_explicit_harness_for_scout test_active_dispatch_profile_allows_explicit_harness @@ -472,6 +677,9 @@ 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_claude_forwards_firstmate_config_dir_when_set +test_claude_omits_config_dir_prefix_when_unset +test_non_claude_harness_ignores_config_dir test_active_dispatch_profile_does_not_block_secondmate_launch echo "# all fm-spawn-dispatch-profile tests passed" diff --git a/tests/fm-stow-contract.test.sh b/tests/fm-stow-contract.test.sh deleted file mode 100755 index c43f47c4dbb..00000000000 --- a/tests/fm-stow-contract.test.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash -# Behavior tests for /stow's inspect-then-update memory contract. -set -u - -# shellcheck source=tests/lib.sh disable=SC1091 -. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" - -test_stow_skill_task_note_contract() { - local stow="$ROOT/.agents/skills/stow/SKILL.md" - - assert_grep 'tasks-axi show --full' "$stow" "stow skill does not require inspecting task notes first" - assert_grep 'tasks-axi update --body-file ' "$stow" "stow skill does not require task body replacement" - assert_grep '--archive-body' "$stow" "stow skill does not document recoverable task body archival" - assert_grep 'Never append.' "$stow" "stow skill does not forbid append-first task notes" - assert_no_grep 'carry that context into the replacement body' "$stow" "stow skill still preserves archive-only context in the replacement body" - pass "stow skill task-note contract includes recoverable body archival" -} - -test_agents_backlog_task_note_contract() { - local agents="$ROOT/AGENTS.md" - - # shellcheck disable=SC2016 # Literal backticks must remain unexpanded. - assert_grep 'current `tasks-axi --help` own the backlog schema' "$agents" \ - "AGENTS.md does not point exact task-note mechanics to the command owner" - assert_grep 'Inspect the current task note before replacing its considered body' "$agents" \ - "AGENTS.md does not require inspecting task notes before replacement" - assert_grep 'archive the superseded body when recoverability matters rather than appending by default' "$agents" \ - "AGENTS.md lost recoverable replacement and no-append semantics" - assert_no_grep 'tasks-axi show --full' "$agents" \ - "AGENTS.md duplicates exact task-note read syntax from its conditional owner" - assert_no_grep 'tasks-axi update --body-file ' "$agents" \ - "AGENTS.md duplicates exact task-note update syntax from its conditional owner" - pass "AGENTS.md keeps task-note hygiene inline and points exact mechanics to their owner" -} - -test_stow_skill_task_note_contract -test_agents_backlog_task_note_contract diff --git a/tests/fm-subagent-pretool-check.test.sh b/tests/fm-subagent-pretool-check.test.sh index 6b4868b1d64..c1a2115897a 100755 --- a/tests/fm-subagent-pretool-check.test.sh +++ b/tests/fm-subagent-pretool-check.test.sh @@ -7,7 +7,6 @@ set -u . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" CHECK="$ROOT/bin/fm-subagent-pretool-check.sh" -SETTINGS="$ROOT/.claude/settings.json" TMP_ROOT=$(fm_test_tmproot fm-subagent-pretool-tests) PRIMARY="$TMP_ROOT/primary" STATE="$PRIMARY/state" @@ -31,6 +30,17 @@ DELEGATION_TOOLS='Task Agent Workflow RemoteTrigger Monitor ScheduleWakeup SendM # Tools that must stay available: denying these would break ordinary work. PRESERVED_TOOLS='Bash Edit Read Write Skill ToolSearch WebFetch WebSearch NotebookEdit ReportFindings DesignSync PushNotification' +# Session-local todo-list tools. They match a delegation stem but create no +# runnable work, so the guard's plan-only exclusion must allow them. +PLAN_ONLY_TOOLS='TaskCreate TaskUpdate' + +# Names the plan-only exclusion must NOT release. Five of them contain a +# plan-only name as a substring and would be let through by a substring rather +# than exact-name match; bare Task is what a shortened entry of "task" would +# release. Together they make the exact-name contract testable instead of +# assumed. +PLAN_ONLY_NEAR_MISSES='TaskCreateAgent TaskCreateWorktree TaskUpdateAgent RemoteTaskCreate Task TaskCreator' + run_tool() { local tool=$1 rc=0 shift @@ -62,20 +72,15 @@ expect_deny() { } # --------------------------------------------------------------------------- -# Tracked settings boundary and delegation-shape PreToolUse guard. +# Delegation-shape PreToolUse guard. # --------------------------------------------------------------------------- -test_tracked_settings_do_not_ship_permissions_deny() { - jq -e 'keys == ["hooks"] and (has("permissions") | not)' "$SETTINGS" >/dev/null \ - || fail "tracked Claude settings must contain only hooks and no permissions key" - pass "tracked Claude settings do not ship permissions.deny" -} - test_guard_denies_every_currently_known_delegation_tool() { local tool for tool in $DELEGATION_TOOLS; do case "$tool" in TaskOutput|TaskStop|TaskGet|TaskList|CronList) continue ;; + TaskCreate|TaskUpdate) continue ;; esac expect_deny "known delegation tool" "$tool" done @@ -107,6 +112,28 @@ test_guard_allows_ordinary_and_observe_only_tools() { pass "the guard leaves ordinary tools and observe-or-stop operations alone" } +test_guard_allows_session_local_todo_tools() { + # These write, so they are not observe-or-stop, but what they write is the + # harness's session-local todo list: no executor, no agent, no worktree, no + # schedule, nothing that outlives the session. Denying them stops the primary + # tracking its own plan and grants no delegation power in exchange. + local tool + for tool in $PLAN_ONLY_TOOLS; do + expect_allow "session-local todo tool" "$tool" + done + pass "the guard leaves the session-local todo list alone" +} + +test_plan_only_exclusion_is_exact_name() { + # The plan-only exclusion must never widen by substring or by a shorter stem. + # Every name here would be released by such a widening and must stay denied. + local tool + for tool in $PLAN_ONLY_NEAR_MISSES; do + expect_deny "plan-only near miss" "$tool" + done + pass "the plan-only exclusion releases exactly two names and nothing that merely contains them" +} + test_guard_never_classifies_mcp_tools() { # An MCP server names its own tools; a task or agent noun there is common and # has nothing to do with fleet dispatch. @@ -249,34 +276,11 @@ test_missing_jq_stdin_transport_fails_open() { pass "missing jq for stdin transport fails open rather than denying every tool call" } -test_claude_hook_registration_preserves_bash_seatbelts() { - jq -e ' - [.hooks.PreToolUse[] | .hooks[].command] - | any(contains("fm-subagent-pretool-check.sh --claude")) - ' "$SETTINGS" >/dev/null || fail "Claude settings omit the delegation-shape PreToolUse guard" - # A stem-enumerating matcher repeats the fail-open-by-enumeration defect the - # script exists to remove. Match all tools and let the script be the single - # owner of classification. - jq -e ' - [.hooks.PreToolUse[] | select(.hooks[].command | contains("fm-subagent-pretool-check.sh")) | .matcher] | .[0] - | . == ".*" - ' "$SETTINGS" >/dev/null || fail "the guard matcher must match all tools" - jq -e ' - [.hooks.PreToolUse[] | select(.matcher == "Bash") | .hooks[].command] - == [ - "\"$CLAUDE_PROJECT_DIR\"/bin/fm-arm-pretool-check.sh --claude", - "\"$CLAUDE_PROJECT_DIR\"/bin/fm-cd-pretool-check.sh --claude" - ] - ' "$SETTINGS" >/dev/null || fail "Claude Bash PreToolUse must retain only the arm-shape and persistent-cd seatbelts" - jq -e '.hooks.Stop[0].hooks[0].command | contains("fm-turnend-guard.sh")' "$SETTINGS" >/dev/null \ - || fail "the Stop turn-end guard changed" - pass "Claude wires the delegation guard, retains only non-status Bash seatbelts, and preserves the Stop guard" -} - -test_tracked_settings_do_not_ship_permissions_deny test_guard_denies_every_currently_known_delegation_tool test_guard_denies_hypothetical_future_tools test_guard_allows_ordinary_and_observe_only_tools +test_guard_allows_session_local_todo_tools +test_plan_only_exclusion_is_exact_name test_guard_never_classifies_mcp_tools test_deny_message_defers_to_intake_classification test_escape_hatch_allows_deliberate_use @@ -285,4 +289,3 @@ test_secondmate_home_is_in_scope test_stdin_transports_and_output_shapes test_malformed_transport_fails_open test_missing_jq_stdin_transport_fails_open -test_claude_hook_registration_preserves_bash_seatbelts diff --git a/tests/fm-test-isolation-proof.test.sh b/tests/fm-test-isolation-proof.test.sh index 6a11def0eaa..1847338e8cd 100755 --- a/tests/fm-test-isolation-proof.test.sh +++ b/tests/fm-test-isolation-proof.test.sh @@ -1,24 +1,12 @@ #!/usr/bin/env bash -# Contract tests for bin/fm-test-isolation-proof.sh - the Phase 2 pre-shard -# isolation proof harness. -# -# These tests assert the candidate-set contract, serial exclusions, aggregate -# failure reporting, and that Phase 4 production shards consume this exact set. -# They deliberately do NOT re-run the full concurrent candidate matrix on every -# invocation (that matrix is owned by the harness itself and archived under -# docs/fm-test-isolation-proof.md after a deliberate proof run). +# Behavioral tests for the isolation-proof and test-run public interfaces. set -u -# shellcheck disable=SC1091 # shellcheck source=tests/lib.sh . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" PROOF="$ROOT/bin/fm-test-isolation-proof.sh" RUNNER="$ROOT/bin/fm-test-run.sh" -CI="$ROOT/.github/workflows/ci.yml" -CONTRIB="$ROOT/CONTRIBUTING.md" -PROOF_DOC="$ROOT/docs/fm-test-isolation-proof.md" -PROOF_JSON="$ROOT/docs/fm-test-isolation-proof.json" assert_present "$PROOF" "bin/fm-test-isolation-proof.sh is missing" [ -x "$PROOF" ] || fail "bin/fm-test-isolation-proof.sh must be executable" @@ -31,7 +19,6 @@ test_list_candidates_nonempty_and_stable() { [ "$count" -ge 10 ] || fail "expected a bounded non-trivial candidate set, got $count" sorted=$(printf '%s\n' "$listed" | LC_ALL=C sort) [ "$listed" = "$sorted" ] || fail "--list must be sorted for a stable matrix" - # No duplicates. [ "$(printf '%s\n' "$listed" | uniq | wc -l | tr -d ' ')" = "$count" ] \ || fail "--list must not duplicate candidates" while IFS= read -r line; do @@ -47,11 +34,8 @@ test_list_candidates_nonempty_and_stable() { test_candidates_exclude_serial_classes() { local listed listed=$("$PROOF" --list) - # Self must never re-enter the concurrent matrix. - printf '%s\n' "$listed" | grep -Fq 'tests/fm-test-isolation-proof.test.sh' \ - && fail "isolation-proof test must not be a parallel candidate" - # Real tmux smoke, watcher lock, real herdr, AFK, live harnesses stay serial. for banned in \ + tests/fm-test-isolation-proof.test.sh \ tests/fm-backend-tmux-smoke.test.sh \ tests/fm-watcher-lock.test.sh \ tests/fm-wake-queue.test.sh \ @@ -66,16 +50,6 @@ test_candidates_exclude_serial_classes() { pass "serial classes remain excluded from the parallel candidate set" } -test_candidates_match_archived_proof() { - local listed archived - assert_present "$PROOF_JSON" "docs/fm-test-isolation-proof.json missing" - listed=$("$PROOF" --list) - archived=$(jq -r '.scripts[].path' "$PROOF_JSON" | LC_ALL=C sort) - [ "$listed" = "$archived" ] \ - || fail "candidate set must exactly match the archived isolation proof" - pass "candidate set exactly matches the archived isolation proof" -} - test_extra_hermetic_candidates_present() { local listed listed=$("$PROOF" --list) @@ -89,7 +63,7 @@ test_extra_hermetic_candidates_present() { printf '%s\n' "$listed" | grep -Fxq "$want" \ || fail "extra hermetic candidate missing: $want" done - pass "audited fake-backend / stub-network extras are candidates" + pass "audited fake-backend and stub-network extras are candidates" } test_list_exclusions_documents_reasons() { @@ -111,82 +85,7 @@ test_family_map_labels_this_contract() { pass "isolation-proof contract test is family-mapped" } -test_aggregate_failure_under_concurrency() { - local tmp pass_f fail_f harness rc out - tmp=$(mktemp -d "${TMPDIR:-/tmp}/fm-isolation-agg.XXXXXX") - pass_f="$tmp/pass.test.sh" - fail_f="$tmp/fail.test.sh" - cat >"$pass_f" <<'SH' -#!/usr/bin/env bash -echo "ok - pass" -exit 0 -SH - cat >"$fail_f" <<'SH' -#!/usr/bin/env bash -echo "not ok - fail" -exit 1 -SH - chmod +x "$pass_f" "$fail_f" - # Minimal fixture harness mirroring aggregate + concurrent wait semantics. - harness="$tmp/harness.sh" - cat >"$harness" <<'SH' -#!/usr/bin/env bash -set -eu -jobs=$1 -shift -pids=() -rcs=() -paths=() -idx=0 -for s in "$@"; do - idx=$((idx + 1)) - ( - bash "$s" - echo $? >"${TMPDIR:-/tmp}/iso-rc-$idx" - ) & - pids+=("$!") - paths+=("$s") - while [ "${#pids[@]}" -ge "$jobs" ]; do - wait "${pids[0]}" || true - pids=("${pids[@]:1}") - done -done -while [ "${#pids[@]}" -gt 0 ]; do - wait "${pids[0]}" || true - pids=("${pids[@]:1}") -done -failed=0 -for i in $(seq 1 "$idx"); do - rc=$(cat "${TMPDIR:-/tmp}/iso-rc-$i" 2>/dev/null || echo 1) - [ "$rc" -eq 0 ] || failed=$((failed + 1)) - rm -f "${TMPDIR:-/tmp}/iso-rc-$i" -done -echo "FM_ISOLATION_SUMMARY total=$idx failed=$failed" -[ "$failed" -eq 0 ] -SH - chmod +x "$harness" - set +e - out=$(TMPDIR="$tmp" bash "$harness" 2 "$pass_f" "$fail_f" 2>&1) - rc=$? - set -e - [ "$rc" -ne 0 ] || fail "concurrent aggregate must fail when any candidate fails" - printf '%s\n' "$out" | grep -Fq 'FM_ISOLATION_SUMMARY total=2 failed=1' \ - || fail "aggregate summary must report total=2 failed=1: $out" - rm -rf "$tmp" - pass "aggregate failure reporting survives concurrency" -} - -test_phase4_consumes_proven_set_only() { - assert_present "$CI" "ci.yml missing" - assert_present "$RUNNER" "fm-test-run.sh missing" - # Phase 4 portable parallel lanes must exist and use lane selection, not --all. - grep -Fq 'bin/fm-test-run.sh --lane portable-parallel-1' "$CI" \ - || fail "CI portable parallel 1 must use --lane portable-parallel-1" - grep -Fq 'bin/fm-test-run.sh --lane portable-parallel-2' "$CI" \ - || fail "CI portable parallel 2 must use --lane portable-parallel-2" - grep -Fq 'bin/fm-test-run.sh --lane portable-serial' "$CI" \ - || fail "CI portable serial must use --lane portable-serial" - # Shard union must equal this harness's proven list. +test_parallel_shards_consume_the_proven_set() { local proven shards proven=$("$PROOF" --list | LC_ALL=C sort -u) shards=$( @@ -197,76 +96,12 @@ test_phase4_consumes_proven_set_only() { ) [ "$proven" = "$shards" ] \ || fail "portable parallel shards must equal isolation-proof --list exactly" - # Local --jobs is bounded to this proven set (refuse is contract-tested in - # fm-test-run.test.sh); the option must exist. - grep -E '^[[:space:]]*--jobs\)' "$RUNNER" >/dev/null 2>&1 \ - || fail "fm-test-run.sh must expose bounded --jobs after Phase 4" - pass "Phase 4 portable shards consume the proven-isolated set only" -} - -test_docs_record_proof_owner() { - assert_present "$PROOF_DOC" "docs/fm-test-isolation-proof.md missing" - grep -Fq 'bin/fm-test-isolation-proof.sh' "$PROOF_DOC" \ - || fail "proof doc must name the harness owner" - grep -Fq 'production_sharding_enabled' "$PROOF_DOC" \ - || fail "proof doc must record the archived proof-time sharding flag" - grep -Fq 'concurrency' "$PROOF_DOC" \ - || fail "proof doc must record concurrency" - assert_present "$CONTRIB" "CONTRIBUTING.md missing" - grep -Fq 'fm-test-isolation-proof' "$CONTRIB" \ - || fail "CONTRIBUTING must document the isolation-proof entry point" - pass "docs archive the isolation-proof owner and posture" -} - -test_docs_match_archived_proof() { - python3 - "$PROOF_DOC" "$PROOF_JSON" <<'PY' \ - || fail "proof Markdown must match the archived proof JSON" -import json -import re -import sys - -markdown = open(sys.argv[1], encoding="utf-8").read() -with open(sys.argv[2], encoding="utf-8") as stream: - proof = json.load(stream) - -summary = proof["summary"] -posture = [ - f'| `run_id` | `{proof["run_id"]}` |', - f'| `started_at` | `{proof["started_at"]}` |', - f'| `finished_at` | `{proof["finished_at"]}` |', - f'| concurrency | **{proof["concurrency"]}** |', - f'| candidates | **{summary["total"]}** |', - f'| failed | **{summary["failed"]}** |', - f'| wall duration_ms | **{summary["duration_ms"]}** (~{summary["duration_ms"] / 1000:.1f}s) |', - f'| `production_sharding_enabled` | `{str(proof["production_sharding_enabled"]).capitalize()}` |', - f'| `fm_test_run_jobs_enabled` | `{str(proof["fm_test_run_jobs_enabled"]).capitalize()}` |', - f'| host proof date | {proof["finished_at"][:10]} (UTC day of archive write) |', -] -assert all(line in markdown for line in posture) -section = markdown.split("## Per-candidate durations (concurrent run)", 1)[1] -section = section.split("## Audit notes (why this set)", 1)[0] -actual = [ - (int(duration), int(exit_code), int(worker), path) - for duration, exit_code, worker, path in re.findall( - r"^\| (\d+) \| (\d+) \| (\d+) \| `([^`]+)` \|$", section, re.MULTILINE - ) -] -expected = [ - (row["duration_ms"], row["exit"], row["worker"], row["path"]) - for row in sorted(proof["scripts"], key=lambda row: row["duration_ms"], reverse=True) -] -assert actual == expected -PY - pass "proof Markdown matches archived JSON posture and durations" + pass "parallel shards consume the proven-isolated set only" } test_list_candidates_nonempty_and_stable test_candidates_exclude_serial_classes -test_candidates_match_archived_proof test_extra_hermetic_candidates_present test_list_exclusions_documents_reasons test_family_map_labels_this_contract -test_aggregate_failure_under_concurrency -test_phase4_consumes_proven_set_only -test_docs_record_proof_owner -test_docs_match_archived_proof +test_parallel_shards_consume_the_proven_set diff --git a/tests/fm-test-run.test.sh b/tests/fm-test-run.test.sh index 1ed47ec3202..982c3173b8a 100755 --- a/tests/fm-test-run.test.sh +++ b/tests/fm-test-run.test.sh @@ -11,9 +11,6 @@ set -u . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" RUNNER="$ROOT/bin/fm-test-run.sh" -CI="$ROOT/.github/workflows/ci.yml" -CONTRIB="$ROOT/CONTRIBUTING.md" -SHARD_DOC="$ROOT/docs/fm-test-portable-shards.md" assert_present "$RUNNER" "bin/fm-test-run.sh is missing" [ -x "$RUNNER" ] || fail "bin/fm-test-run.sh must be executable" @@ -100,7 +97,7 @@ init_changed_fixture_repo() { cp "$ROOT/bin/fm-remote-lib.sh" "$repo/bin/fm-remote-lib.sh" for script in \ fm-brief.test.sh \ - fm-captain-translation-contract.test.sh \ + fm-ask-user-authority.test.sh \ fm-cd-pretool-check.test.sh \ fm-daemon.test.sh \ fm-backend-herdr-smoke.test.sh \ @@ -169,7 +166,7 @@ test_changed_dependency_selection_and_unmapped_failure() { printf '\n' >>"$repo/.pi/extensions/fm-primary-pi-watch.ts" printf '\n' >>"$repo/.pi/extensions/fm-primary-turnend-guard.ts" listed=$(cd "$repo" && bin/fm-test-run.sh --list --changed --base HEAD) - assert_contains "$listed" "tests/fm-captain-translation-contract.test.sh" "skill source selects pure contract coverage" + assert_contains "$listed" "tests/fm-ask-user-authority.test.sh" "skill source selects pure contract coverage" assert_contains "$listed" "tests/fm-cd-pretool-check.test.sh" "Claude and Pi source selects hook coverage" assert_contains "$listed" "tests/fm-pi-watch-extension.test.sh" "Pi source selects watcher coverage" git -C "$repo" add .agents .claude .pi @@ -355,84 +352,6 @@ test_exclude_family() { pass "exclude-family drops the named primary family after selection" } -test_ci_and_docs_call_the_owner() { - assert_present "$CI" "ci.yml missing" - assert_present "$CONTRIB" "CONTRIBUTING.md missing" - grep -Fq 'tests-portable-parallel-1:' "$CI" \ - || fail "CI must define portable parallel shard 1" - grep -Fq 'tests-portable-parallel-2:' "$CI" \ - || fail "CI must define portable parallel shard 2" - grep -Fq 'tests-portable-serial:' "$CI" \ - || fail "CI must define the portable serial lane" - grep -Fq 'bin/fm-test-run.sh --lane portable-parallel-1' "$CI" \ - || fail "CI shard 1 must invoke --lane portable-parallel-1" - grep -Fq 'bin/fm-test-run.sh --lane portable-parallel-2' "$CI" \ - || fail "CI shard 2 must invoke --lane portable-parallel-2" - local shard job_body - for shard in 1 2; do - job_body=$(awk -v job=" tests-portable-parallel-$shard:" ' - $0 == job { in_job=1; next } - in_job && /^ [a-zA-Z0-9_-]+:/ { exit } - in_job { print } - ' "$CI") - printf '%s\n' "$job_body" | grep -Fq 'npm install -g tasks-axi' \ - || fail "CI portable parallel shard $shard must install tasks-axi" - printf '%s\n' "$job_body" | grep -Fq 'tasks-axi --version' \ - || fail "CI portable parallel shard $shard must verify tasks-axi" - done - grep -Fq 'bin/fm-test-run.sh --lane portable-serial' "$CI" \ - || fail "CI portable serial must invoke --lane portable-serial" - grep -Fq 'bin/fm-test-run.sh --check-coverage' "$CI" \ - || fail "CI must run the coverage guard" - grep -Fq 'tests-herdr:' "$CI" \ - || fail "CI must define the required tests-herdr job" - grep -Fq 'bin/fm-test-run.sh --family real-herdr-gated' "$CI" \ - || fail "Herdr CI job must run the real-herdr-gated family via fm-test-run" - grep -Fq -- "--fail-on-gate-skip 'herdr not found'" "$CI" \ - || fail "Herdr CI job must fail on herdr-not-found skips" - grep -Fq 'bin/fm-install-herdr.sh' "$CI" \ - || fail "Herdr CI job must install via bin/fm-install-herdr.sh" - grep -Fq 'bin/fm-install-treehouse.sh' "$CI" \ - || fail "Herdr CI job must install via bin/fm-install-treehouse.sh" - grep -Fq 'bin/fm-herdr-ci-cleanup.sh' "$CI" \ - || fail "Herdr CI job must use bounded lab cleanup" - grep -Fq 'tests-timing-aggregate:' "$CI" \ - || fail "CI must aggregate per-lane timing artifacts" - grep -Fq 'timeout-minutes: 20' "$CI" \ - || fail "portable serial hang tripwire must be timeout-minutes: 20" - grep -Fq 'timeout-minutes: 10' "$CI" \ - || fail "portable parallel shards must keep a hang tripwire (10m)" - # Interim full-suite 25m portable timeout must not remain after sharding. - if grep -Eq 'timeout-minutes: 25' "$CI"; then - fail "CI still has interim timeout-minutes: 25 after portable sharding" - fi - # Stale "~2-3 minutes" claim must not remain. - if grep -Eq '2-3 minutes' "$CI"; then - fail "CI workflow still claims the suite finishes in ~2-3 minutes" - fi - # No retry-green strategy on Behavior lanes. - if grep -Eqi 'retry:|max-attempts:|continue-on-error:\s*true' "$CI"; then - fail "CI must not use retries or continue-on-error as a green strategy" - fi - grep -Fq 'fm-test-timing' "$CI" \ - || fail "CI must upload timing artifacts" - grep -Fq 'bin/fm-test-run.sh --all' "$CONTRIB" \ - || fail "CONTRIBUTING must document bin/fm-test-run.sh --all" - grep -Fq 'bin/fm-test-run.sh --family' "$CONTRIB" \ - || fail "CONTRIBUTING must document family selection" - grep -Fq 'bin/fm-test-run.sh --changed' "$CONTRIB" \ - || fail "CONTRIBUTING must document changed-file selection" - grep -Fq 'bin/fm-test-run.sh --proven-isolated --jobs' "$CONTRIB" \ - || fail "CONTRIBUTING must document proven-isolated --jobs" - grep -Fq 'intent-targeted' "$CONTRIB" \ - || fail "CONTRIBUTING must document intent-targeted no-mistakes Test" - # Do not restore a complete-suite commands.test. - if grep -E '^[[:space:]]*test:[[:space:]].*tests/\*\.test\.sh' "$ROOT/.no-mistakes.yaml" >/dev/null 2>&1; then - fail ".no-mistakes.yaml must not set a full-suite commands.test" - fi - pass "CI and CONTRIBUTING call the one-owner runner; no full-suite local Test" -} - test_portable_shard_union_and_coverage_guard() { local s1 s2 proven serial herdr all_count union_count overlap out first s1=$("$RUNNER" --list --lane portable-parallel-1) @@ -464,40 +383,11 @@ test_portable_shard_union_and_coverage_guard() { || fail "lanes must not duplicate scripts" # LPT order: first script of shard 1 is the longest proven script. first=$(printf '%s\n' "$s1" | head -n 1) - [ "$first" = "tests/fm-arm-pretool-check.test.sh" ] \ - || fail "shard 1 must start with longest proven script, got $first" + [ "$first" = "tests/fm-x-mode.test.sh" ] \ + || fail "shard 1 must start with the longest proven script, got $first" pass "portable shard union, disjointness, and coverage guard hold" } -test_portable_shard_docs_match_lanes() { - python3 - "$RUNNER" "$SHARD_DOC" <<'PY' \ - || fail "portable shard documentation must match lane counts and timing sums" -import re -import subprocess -import sys - -runner, doc_path = sys.argv[1:3] -markdown = open(doc_path, encoding="utf-8").read() -averages = { - path: int(duration) - for duration, path in re.findall(r"^\| (\d+) \| `([^`]+)` \|$", markdown, re.MULTILINE) -} -totals = {} -for lane in ("portable-parallel-1", "portable-parallel-2"): - scripts = subprocess.check_output( - [runner, "--list", "--lane", lane], text=True - ).splitlines() - totals[lane] = (len(scripts), sum(averages[path] for path in scripts)) - -for lane, (count, duration) in totals.items(): - expected = f"| `{lane}` | {count} | {duration} ms (~{duration / 1000:.1f} s) |" - assert expected in markdown -imbalance = abs(totals["portable-parallel-1"][1] - totals["portable-parallel-2"][1]) -assert f"| imbalance | | {imbalance} ms |" in markdown -PY - pass "portable shard documentation matches lane counts and timing sums" -} - test_jobs_requires_proven_isolated() { local tmp rc tmp=$(mktemp -d "${TMPDIR:-/tmp}/fm-test-run-jobs.XXXXXX") @@ -524,8 +414,8 @@ test_jobs_parallel_scheduler_and_failure_propagation() { runner="$repo/bin/fm-test-run.sh" evidence="$tmp/evidence" fake_bin="$tmp/fake-bin" - a=tests/fm-no-mistakes-ownership.test.sh - b=tests/fm-stow-contract.test.sh + a=tests/fm-brief.test.sh + b=tests/fm-composer-lib.test.sh c=tests/fm-lint.test.sh d=tests/fm-supervision-instructions.test.sh mkdir -p "$repo/bin" "$repo/tests" "$evidence" "$fake_bin" @@ -692,9 +582,7 @@ test_aggregate_exit_behavior test_gate_skip_accounting test_fail_on_gate_skip_token test_exclude_family -test_ci_and_docs_call_the_owner test_portable_shard_union_and_coverage_guard -test_portable_shard_docs_match_lanes test_jobs_requires_proven_isolated test_jobs_parallel_scheduler_and_failure_propagation test_aggregate_json diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index 2b82165178b..242407c1a32 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -706,38 +706,6 @@ test_grok_adapter_missing_jq_and_no_supervision_allow() { pass "fm-turnend-guard-grok: missing jq and no-supervision-needed stops stay silent and bounded" } -test_settings_hook_uses_claude_project_dir() { - local settings command autoarm - settings="$ROOT/.claude/settings.json" - [ -f "$settings" ] || fail "tracked .claude/settings.json is missing" - command=$(jq -r '.hooks.Stop[0].hooks[0].command // empty' "$settings") - autoarm=$(jq -r '.hooks.Stop[0].hooks[1].command // empty' "$settings") - [ -n "$command" ] || fail "Stop hook command is missing from .claude/settings.json" - assert_contains "$command" 'CLAUDE_PROJECT_DIR' "Stop hook must resolve via CLAUDE_PROJECT_DIR, not a cwd-relative path" - assert_contains "$command" 'fm-turnend-guard.sh --claude' "Stop hook must invoke fm-turnend-guard.sh in cooperative --claude mode" - assert_contains "$command" 'GROK_AGENT' "Claude blocking Stop hook must stay inert when Grok loads Claude-compatible settings" - assert_contains "$autoarm" 'GROK_AGENT' "Claude auto-arm Stop hook must stay inert when Grok loads Claude-compatible settings" - case "$command" in - bin/fm-turnend-guard.sh|./bin/fm-turnend-guard.sh) - fail "Stop hook must not use a bare relative path (cwd-dependent): $command" - ;; - esac - pass ".claude/settings.json: Stop hook uses CLAUDE_PROJECT_DIR-anchored --claude guard command" -} - -test_codex_hook_invokes_shared_guard() { - local settings command - settings="$ROOT/.codex/hooks.json" - [ -f "$settings" ] || fail "tracked .codex/hooks.json is missing" - command=$(jq -r '.hooks.Stop[0].hooks[0].command // empty' "$settings") - [ -n "$command" ] || fail "Stop hook command is missing from .codex/hooks.json" - assert_contains "$command" 'pwd -P' "codex hook must anchor from the hook process working directory" - assert_contains "$command" '.codex/hooks.json' "codex hook must verify the hook-loaded firstmate root" - assert_contains "$command" 'fm-turnend-guard.sh' "codex hook must invoke the shared guard" - assert_not_contains "$command" '.cwd' "codex hook must not use payload cwd to select the guard executable" - pass ".codex/hooks.json: Stop hook invokes the shared primary guard" -} - test_codex_hook_uses_process_pwd_when_payload_cwd_is_outside_root() { local settings command dir expected_root outside payload out status settings="$ROOT/.codex/hooks.json" @@ -801,23 +769,6 @@ EOF pass ".codex/hooks.json: Stop hook ignores nested git root guard scripts" } -test_opencode_plugin_forces_followup() { - local plugin content - plugin="$ROOT/.opencode/plugins/fm-primary-turnend-guard.js" - [ -f "$plugin" ] || fail "tracked OpenCode primary plugin is missing" - content=$(cat "$plugin") - assert_contains "$content" 'session.idle' "OpenCode plugin must run on session.idle" - assert_contains "$content" 'fm-turnend-guard.sh' "OpenCode plugin must invoke the shared guard" - assert_contains "$content" 'promptAsync' "OpenCode plugin must force a follow-up turn" - assert_contains "$content" 'encodeFirstmateOperationalInput' "OpenCode plugin must use the typed operational-input constructor" - assert_contains "$content" 'skipNextIdle' "OpenCode plugin must carry a loop guard" - assert_contains "$content" 'worktree' "OpenCode plugin must anchor the guard from the git worktree path" - assert_contains "$content" 'watcher cycle is missing, failed, or unhealthy' "OpenCode plugin must identify a blind turn as watcher recovery" - assert_contains "$content" 'harness recovery instruction below' "OpenCode plugin must delegate recovery action to the shared guard line" - assert_not_contains "$content" 'Resume supervision according to the session-start operating block' "OpenCode plugin must not route a blind turn through ordinary continuity" - pass ".opencode primary plugin: session.idle forces one follow-up through the shared guard" -} - test_opencode_plugin_anchors_guard_to_worktree() { local plugin parent worktree_dir wrong_dir out status plugin="$ROOT/.opencode/plugins/fm-primary-turnend-guard.js" @@ -877,30 +828,6 @@ EOF pass ".opencode primary plugin: guard path is anchored to worktree, not directory" } -test_pi_extension_forces_followup() { - local ext content - ext="$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" - [ -f "$ext" ] || fail "tracked pi primary extension is missing" - content=$(cat "$ext") - assert_contains "$content" 'agent_settled' "pi extension must run after one logical agent run settles" - assert_contains "$content" 'fm-turnend-guard.sh' "pi extension must invoke the shared guard" - assert_contains "$content" 'sendUserMessage' "pi extension must force a follow-up turn" - assert_contains "$content" 'encodeFirstmateOperationalInput' "pi extension must use the typed operational-input constructor" - assert_contains "$content" 'deliverAs: "followUp"' "pi extension must queue the follow-up safely" - assert_contains "$content" 'guardFollowupActive' "pi extension must carry a logical-run loop guard" - assert_not_contains "$content" 'skipNextTurnEnd' "pi extension kept the internal-turn loop guard" - assert_contains "$content" 'watcher cycle is missing, failed, or unhealthy' "pi extension must identify a blind turn as watcher recovery" - assert_contains "$content" 'harness recovery instruction below' "pi extension must delegate recovery action to the shared guard line" - assert_not_contains "$content" 'Resume supervision according to the session-start operating block' "pi extension must not route a blind turn through ordinary continuity" - assert_contains "$content" '.pi-turnend-extension-loaded' "pi extension must write its loaded marker for session-start diagnostics" - assert_contains "$content" 'lockOwnership' "pi extension loaded marker must respect the session lock" - assert_contains "$content" 'const command = String((event.input as { command?: unknown })?.command ?? "")' "pi extension changed bash command extraction for the PreToolUse contract" - assert_contains "$content" 'runPretoolCheck(command)' "pi extension changed the PreToolUse checker invocation" - assert_contains "$content" 'return { block: true, reason:' "pi extension changed the checker exit-2 block result" - assert_not_contains "$content" 'Run bin/fm-watch-arm.sh as a background task' "pi extension must not hardcode the old watcher-arm instruction" - pass ".pi primary extension: agent_settled forces one follow-up through the shared guard" -} - test_pi_extension_injects_once_per_logical_agent_run() { local repo home ext log out status repo="$TMP_ROOT/pi-logical-run-root" @@ -1177,17 +1104,6 @@ test_hook_claude_mode_secondmate_reblocks_like_primary() { pass "fm-turnend-guard --claude: secondmate home re-blocks unclaimed and allows auto-arm-claimed stops" } -test_grok_hook_invokes_adapter() { - local settings command - settings="$ROOT/.grok/hooks/fm-primary-turnend-guard.json" - [ -f "$settings" ] || fail "tracked grok primary hook config is missing" - command=$(jq -r '.hooks.Stop[0].hooks[0].command // empty' "$settings") - [ -n "$command" ] || fail "Stop hook command is missing from grok primary hook config" - assert_contains "$command" 'GROK_WORKSPACE_ROOT' "grok hook must anchor from GROK_WORKSPACE_ROOT" - assert_contains "$command" 'fm-turnend-guard-grok.sh' "grok hook must invoke the adapter" - pass ".grok primary hook: Stop hook invokes the grok adapter" -} - test_predicate_healthy_no_inflight test_predicate_unhealthy_no_beacon test_predicate_unhealthy_stale_beacon @@ -1224,16 +1140,11 @@ test_grok_adapter_native_true_allows_without_resume test_grok_adapter_snake_case_native_and_camel_precedence test_grok_adapter_invalid_inputs_start_neither_path test_grok_adapter_missing_jq_and_no_supervision_allow -test_settings_hook_uses_claude_project_dir -test_codex_hook_invokes_shared_guard test_codex_hook_uses_process_pwd_when_payload_cwd_is_outside_root test_codex_hook_ignores_nested_git_root_guard -test_opencode_plugin_forces_followup test_opencode_plugin_anchors_guard_to_worktree -test_pi_extension_forces_followup test_pi_extension_injects_once_per_logical_agent_run test_pi_extension_retries_after_followup_delivery_failure -test_grok_hook_invokes_adapter test_hook_claude_mode_reblocks_stop_hook_active_when_unhealthy test_hook_claude_mode_reblocks_x_mode_without_tasks test_hook_claude_mode_allows_when_autoarm_owner_alive diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index 19dae9bba36..a14a2923bfd 100755 --- a/tests/fm-watch-triage.test.sh +++ b/tests/fm-watch-triage.test.sh @@ -70,12 +70,38 @@ file_mtime() { if [ "$(uname)" = Darwin ]; then stat -f %m "$1" 2>/dev/null; else stat -c %Y "$1" 2>/dev/null; fi } +# Set 's mtime to exactly seconds, for aging a busy-turn marker by +# a precise amount (touch -t takes a local-time stamp, not an epoch, on both +# platforms, so convert via BSD `date -r` or GNU `date -d @`). +set_mtime() { # + local epoch=$1 f=$2 stamp + if stamp=$(date -r "$epoch" +%Y%m%d%H%M.%S 2>/dev/null); then + touch -t "$stamp" "$f" + else + stamp=$(date -d "@$epoch" +%Y%m%d%H%M.%S) + touch -t "$stamp" "$f" + fi +} + # Signature a primed .seen-* marker must hold so the per-poll signal scan does not # fire on a pre-existing status (mirrors fm-watch.sh's stat_sig exactly). seen_sig() { if [ "$(uname)" = Darwin ]; then stat -f '%z:%Fm' "$1" 2>/dev/null; else stat -c '%s:%Y' "$1" 2>/dev/null; fi } +# Prime 's .seen-* suppressor to its CURRENT signature, so the per-poll +# no-verb signal scan (which watches every *.turn-ended for a size:mtime change) +# treats a just-created or just-backdated turn-ended marker as already seen. +# Busy-turn-age fixtures create/backdate turn-ended directly (there is no real +# harness touching it), so without this the marker's own first sighting would +# fire an unrelated "signal:" wake and mask the busy-turn-age assertion under +# test. Call again after any further touch/set_mtime on the same file. +prime_turnend_seen() { # + local f=$1 base + base=$(basename "$f" | tr '.' '_') + printf '%s' "$(seen_sig "$f")" > "$(dirname "$f")/.seen-$base" +} + reap() { kill "$1" 2>/dev/null || true; wait "$1" 2>/dev/null || true; } # --- pure classifier predicates (fm-classify-lib.sh) ------------------------ @@ -1055,6 +1081,249 @@ test_wedge_escalation_resets_when_pane_becomes_active() { pass "a pane becoming active again resets the consecutive wedge-escalation counter" } +# --- busy pane duration bound: a completed-turn age gate on top of busy ----- +# 2026-07 hibit-agent-focus-nonsteal-r1 incident: a busy pane (herdr "working" +# and/or the harness's rendered busy footer) is unconditional, unbounded proof +# of liveness in every existing classifier, so a genuinely hung foreground tool +# call behind a busy signature ran undetected for 25h. BUSY_TURN_MAX_SECS bounds +# how long a busy pane may run with no completed turn (state/.turn-ended, or +# the task's spawn record before any turn completes); past the bound the SAME +# wedge_timer_check already used for a provably-working non-busy stale takes +# over, so escalation reuses the identical stale reason, escalation counter, and +# demand-deep-inspection marker - never an automatic interrupt or restart. + +test_busy_pane_below_turn_age_bound_is_absorbed() { + local dir state fakebin out capture_file window key sig pid + dir=$(make_case busy-below-turn-age); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; capture_file="$dir/pane.txt"; window="test:fm-busy-fresh" + printf 'Working... (12.3s)' > "$capture_file" + printf 'window=%s\nkind=ship\nharness=pi\n' "$window" > "$state/busy-fresh.meta" + printf 'working: setup complete\n' > "$state/busy-fresh.status" + sig=$(seen_sig "$state/busy-fresh.status"); printf '%s' "$sig" > "$state/.seen-busy-fresh_status" + key=$(printf '%s' "$window" | tr ':/.' '___') + touch "$state/busy-fresh.turn-ended" + prime_turnend_seen "$state/busy-fresh.turn-ended" + + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_BUSY_TURN_MAX_SECS=999 FM_STALE_ESCALATE_SECS=999 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + if ! wait_live "$pid" 30; then + reap "$pid"; fail "a busy pane below the turn-age bound was escalated: $(cat "$out")" + fi + [ ! -s "$out" ] || fail "a busy pane below the turn-age bound printed a wake reason" + [ ! -e "$state/.stale-since-$key" ] || fail "a busy pane below the turn-age bound started a wedge timer" + reap "$pid" + pass "a busy worker below the turn-age bound remains working with no escalation" +} + +test_busy_pane_stable_hash_escalates_past_turn_age_bound() { + local dir state fakebin out capture_file window key pane_hash sig pid + dir=$(make_case busy-stable-hash-turn-age); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; capture_file="$dir/pane.txt"; window="test:fm-busy-stable" + printf 'Working...' > "$capture_file" + printf 'window=%s\nkind=ship\nharness=pi\n' "$window" > "$state/busy-stable.meta" + printf 'working: setup complete\n' > "$state/busy-stable.status" + sig=$(seen_sig "$state/busy-stable.status"); printf '%s' "$sig" > "$state/.seen-busy-stable_status" + key=$(printf '%s' "$window" | tr ':/.' '___') + pane_hash=$(hash_text "Working...") + printf '%s' "$pane_hash" > "$state/.hash-$key" + printf '1\n' > "$state/.count-$key" + # No completed turn ever recorded for this task: age the spawn record itself. + touch -t 200001010000 "$state/busy-stable.meta" + + # Phase A: past the bound, the stable-hash busy pane is absorbed but starts + # the wedge timer (mirrors the existing provably-working-stale Phase A/B). + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_BUSY_TURN_MAX_SECS=1 FM_STALE_ESCALATE_SECS=999 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + if ! wait_live "$pid" 30; then + reap "$pid"; fail "a stable-hash busy pane past the turn-age bound escalated before the wedge threshold: $(cat "$out")" + fi + [ -s "$state/.stale-since-$key" ] || fail "a stable-hash busy pane past the turn-age bound did not start a wedge timer" + reap "$pid" + + # Phase B: backdate the wedge timer past the threshold; the next poll escalates. + echo $(( $(date +%s) - 500 )) > "$state/.stale-since-$key" + : > "$out" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_BUSY_TURN_MAX_SECS=1 FM_STALE_ESCALATE_SECS=240 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + wait_for_exit "$pid" 40 || fail "a stable-hash busy pane did not wedge-escalate past the turn-age bound" + grep -F "stale: $window" "$out" >/dev/null || fail "busy turn-age escalation did not print the stale wake" + grep -F "possible wedge" "$out" >/dev/null || fail "busy turn-age escalation did not flag a possible wedge" + pass "a busy worker with a stable pane hash still escalates once its completed-turn age reaches the bound" +} + +# Regression fixture for the incident's actual masking condition: Pi's rendered +# elapsed-time footer changes every poll, so the pane hash never repeats and the +# watcher always takes the "new hash" branch, never the stable-hash one above. +test_busy_pane_changing_hash_escalates_past_turn_age_bound() { + local dir state fakebin out capture_file window key pid + dir=$(make_case busy-changing-hash-turn-age); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; capture_file="$dir/pane.txt"; window="test:fm-busy-ticking" + printf 'Working... (3600.1s)' > "$capture_file" + printf 'window=%s\nkind=ship\nharness=pi\n' "$window" > "$state/busy-ticking.meta" + printf 'working: setup complete\n' > "$state/busy-ticking.status" + sig=$(seen_sig "$state/busy-ticking.status"); printf '%s' "$sig" > "$state/.seen-busy-ticking_status" + key=$(printf '%s' "$window" | tr ':/.' '___') + touch -t 200001010000 "$state/busy-ticking.meta" + # No pre-seeded .hash-: with a real ticking elapsed footer, every poll + # lands here (h != prev) - the reproduction's actual masking condition. + + # Phase A: first sight past the bound absorbs and starts the wedge timer, + # without ever needing the "genuinely stale" hash-match path. + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_BUSY_TURN_MAX_SECS=1 FM_STALE_ESCALATE_SECS=999 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + if ! wait_live "$pid" 30; then + reap "$pid"; fail "a changing-hash busy pane past the turn-age bound escalated before the wedge threshold: $(cat "$out")" + fi + [ -s "$state/.stale-since-$key" ] || fail "a changing-hash busy pane past the turn-age bound did not start a wedge timer" + reap "$pid" + + # Phase B: another tick (still a fresh, never-before-seen hash) plus a + # backdated wedge timer escalates exactly as the stable-hash case does. + printf 'Working... (3601.2s)' > "$capture_file" + echo $(( $(date +%s) - 500 )) > "$state/.stale-since-$key" + : > "$out" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_BUSY_TURN_MAX_SECS=1 FM_STALE_ESCALATE_SECS=240 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + wait_for_exit "$pid" 40 || fail "a changing-hash busy pane did not wedge-escalate past the turn-age bound" + grep -F "stale: $window" "$out" >/dev/null || fail "busy turn-age escalation (changing hash) did not print the stale wake" + grep -F "possible wedge" "$out" >/dev/null || fail "busy turn-age escalation (changing hash) did not flag a possible wedge" + pass "a busy worker whose pane hash changes every poll still escalates once its completed-turn age reaches the bound" +} + +test_busy_pane_turn_end_touch_resets_age() { + local dir state fakebin out capture_file window key pane_hash sig pid + dir=$(make_case busy-turn-end-resets-age); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; capture_file="$dir/pane.txt"; window="test:fm-busy-reset" + printf 'Working...' > "$capture_file" + printf 'window=%s\nkind=ship\nharness=pi\n' "$window" > "$state/busy-reset.meta" + printf 'working: setup complete\n' > "$state/busy-reset.status" + sig=$(seen_sig "$state/busy-reset.status"); printf '%s' "$sig" > "$state/.seen-busy-reset_status" + key=$(printf '%s' "$window" | tr ':/.' '___') + pane_hash=$(hash_text "Working...") + printf '%s' "$pane_hash" > "$state/.hash-$key" + printf '1\n' > "$state/.count-$key" + # A wedge is already mid-escalation, as if several over-age polls already ran. + echo $(( $(date +%s) - 500 )) > "$state/.stale-since-$key" + printf '1\n' > "$state/.wedge-escalations-$key" + # The worker's most recent turn just completed: touching turn-ended resets age. + touch "$state/busy-reset.turn-ended" + prime_turnend_seen "$state/busy-reset.turn-ended" + + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_BUSY_TURN_MAX_SECS=3600 FM_STALE_ESCALATE_SECS=240 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + if ! wait_live "$pid" 30; then + reap "$pid"; fail "a freshly completed turn on a busy pane was still escalated: $(cat "$out")" + fi + [ ! -s "$out" ] || fail "a freshly completed turn on a busy pane printed a wake reason" + [ ! -e "$state/.stale-since-$key" ] || fail "a freshly completed turn did not clear the wedge timer" + [ ! -e "$state/.wedge-escalations-$key" ] || fail "a freshly completed turn did not clear the escalation counter" + reap "$pid" + pass "touching a busy worker's completed-turn marker resets the age and prevents an old-age escalation" +} + +test_busy_pane_repeated_escalation_reaches_demand_deep_inspection() { + local dir state fakebin out capture_file window key pane_hash sig pid n + dir=$(make_case busy-turn-age-demand-inspect); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; capture_file="$dir/pane.txt"; window="test:fm-busy-demand-inspect" + printf 'Working...' > "$capture_file" + printf 'window=%s\nkind=ship\nharness=pi\n' "$window" > "$state/busy-demand.meta" + printf 'working: setup complete\n' > "$state/busy-demand.status" + sig=$(seen_sig "$state/busy-demand.status"); printf '%s' "$sig" > "$state/.seen-busy-demand_status" + key=$(printf '%s' "$window" | tr ':/.' '___') + pane_hash=$(hash_text "Working...") + printf '%s' "$pane_hash" > "$state/.hash-$key" + printf '1\n' > "$state/.count-$key" + touch -t 200001010000 "$state/busy-demand.turn-ended" + prime_turnend_seen "$state/busy-demand.turn-ended" + + # Priming round: first sighting past the turn-age bound absorbs and starts + # the wedge timer, mirroring the existing provably-working wedge tests. + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_BUSY_TURN_MAX_SECS=1 FM_STALE_ESCALATE_SECS=999 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + if ! wait_live "$pid" 30; then + reap "$pid"; fail "priming round for busy turn-age escalation was not absorbed: $(cat "$out")" + fi + reap "$pid" + + n=1 + while [ "$n" -le 3 ]; do + echo $(( $(date +%s) - 500 )) > "$state/.stale-since-$key" + : > "$out" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_BUSY_TURN_MAX_SECS=1 FM_STALE_ESCALATE_SECS=240 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + wait_for_exit "$pid" 40 || fail "busy turn-age escalation round $n did not escalate: $(cat "$out")" + grep -F "escalation $n" "$out" >/dev/null || fail "busy turn-age round $n did not report escalation count $n: $(cat "$out")" + if [ "$n" -lt 3 ]; then + grep -F "demand-deep-inspection" "$out" >/dev/null && fail "busy turn-age round $n escalated to demand-deep-inspection before the threshold: $(cat "$out")" + else + grep -F "demand-deep-inspection" "$out" >/dev/null || fail "busy turn-age round $n (threshold) did not demand deep inspection: $(cat "$out")" + fi + n=$((n + 1)) + done + [ "$(cat "$state/.wedge-escalations-$key" 2>/dev/null || echo 0)" = 3 ] || fail "busy turn-age escalation counter did not persist across consecutive rounds" + pass "repeated busy turn-age escalations reuse the existing escalation counter and demand deep inspection at the threshold" +} + +# Behavioral proof that the production default (no FM_BUSY_TURN_MAX_SECS override +# anywhere in this env) is 3600s: a completed turn 5 minutes old must not start a +# wedge timer, while one 66 minutes old must - bracketing the default around 3600 +# without waiting a literal hour. +test_busy_pane_default_turn_age_bound_is_3600s() { + local dir state fakebin out capture_file window key pane_hash sig pid + dir=$(make_case busy-default-turn-age); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; capture_file="$dir/pane.txt"; window="test:fm-busy-default" + printf 'Working...' > "$capture_file" + printf 'window=%s\nkind=ship\nharness=pi\n' "$window" > "$state/busy-default.meta" + printf 'working: setup complete\n' > "$state/busy-default.status" + sig=$(seen_sig "$state/busy-default.status"); printf '%s' "$sig" > "$state/.seen-busy-default_status" + key=$(printf '%s' "$window" | tr ':/.' '___') + pane_hash=$(hash_text "Working...") + printf '%s' "$pane_hash" > "$state/.hash-$key" + printf '1\n' > "$state/.count-$key" + + set_mtime $(( $(date +%s) - 300 )) "$state/busy-default.turn-ended" + prime_turnend_seen "$state/busy-default.turn-ended" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_STALE_ESCALATE_SECS=999 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + if ! wait_live "$pid" 30; then + reap "$pid"; fail "a 5-minute-old completed turn tripped the default busy-turn-age bound: $(cat "$out")" + fi + [ ! -e "$state/.stale-since-$key" ] || fail "a 5-minute-old completed turn started a wedge timer under the default bound" + reap "$pid" + + set_mtime $(( $(date +%s) - 4000 )) "$state/busy-default.turn-ended" + prime_turnend_seen "$state/busy-default.turn-ended" + : > "$out" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_STALE_ESCALATE_SECS=999 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + if ! wait_live "$pid" 30; then + reap "$pid"; fail "a 66-minute-old completed turn escalated before the wedge threshold under the default bound: $(cat "$out")" + fi + [ -s "$state/.stale-since-$key" ] || fail "a 66-minute-old completed turn did not start a wedge timer under the default bound (default is not 3600s)" + reap "$pid" + pass "the production default busy-turn-age bound is 3600s (5min under does not wedge, 66min over does)" +} + test_nonterminal_stale_repairs_missing_or_corrupt_timer() { local dir state fakebin out capture_file window key pane_hash sig pid since dir=$(make_case nonterminal-stale-timer-repair); state="$dir/state"; fakebin="$dir/fakebin" @@ -1288,6 +1557,12 @@ test_stale_terminal_status_overridden_by_active_run test_nonterminal_stale_provably_working_absorbed_then_escalated test_wedge_escalation_marks_demand_deep_inspection_after_threshold test_wedge_escalation_resets_when_pane_becomes_active +test_busy_pane_below_turn_age_bound_is_absorbed +test_busy_pane_stable_hash_escalates_past_turn_age_bound +test_busy_pane_changing_hash_escalates_past_turn_age_bound +test_busy_pane_turn_end_touch_resets_age +test_busy_pane_repeated_escalation_reaches_demand_deep_inspection +test_busy_pane_default_turn_age_bound_is_3600s test_nonterminal_stale_not_working_surfaced test_nonterminal_stale_paused_absorbed_then_resurfaced test_exited_declared_pause_is_bounded_but_live_gate_surfaces diff --git a/tests/fm-watcher-lock.test.sh b/tests/fm-watcher-lock.test.sh index 640e9133188..e741ec21e8e 100755 --- a/tests/fm-watcher-lock.test.sh +++ b/tests/fm-watcher-lock.test.sh @@ -901,18 +901,54 @@ test_pid_identity_is_locale_invariant() { # fm_pid_identity, so its output must be byte-identical regardless of the caller's # exported LC_ALL/LC_TIME. This stays deterministic on CI even where an alternate # locale like ko_KR.UTF-8 is not installed (the equality then holds trivially). - local live no_proc baseline via_lc_all via_lc_time + local live no_proc fakebin locale_log baseline via_lc_all via_lc_time + local real_first real_second observed sleep 300 & live=$! no_proc="$TMP_ROOT/no-proc" - baseline=$(FM_PROC_ROOT_OVERRIDE="$no_proc" LC_ALL=C bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null) - via_lc_all=$(FM_PROC_ROOT_OVERRIDE="$no_proc" LC_ALL=ko_KR.UTF-8 bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null) - via_lc_time=$(FM_PROC_ROOT_OVERRIDE="$no_proc" LC_TIME=ko_KR.UTF-8 bash -c 'unset LC_ALL; . "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null) + fakebin="$TMP_ROOT/locale-ps" + locale_log="$TMP_ROOT/locale-ps.observed" + mkdir -p "$fakebin" + : > "$locale_log" + # The stub renders lstart through date under whatever locale it inherits, so its + # output really does change when the caller's locale leaks through. Dropping the + # LC_ALL=C pin in fm_pid_identity therefore breaks the equality assertions below + # on any host with a second locale installed, and the recorded LC_ALL below keeps + # the pin asserted even where ko_KR.UTF-8 is missing and date falls back to C. + cat > "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "${LC_ALL-}" >> "$FAKE_PS_LOCALE_LOG" +stamp=$(date -d @1784094040 '+%a %b %e %H:%M:%S %Y' 2>/dev/null) \ + || stamp=$(date -r 1784094040 '+%a %b %e %H:%M:%S %Y' 2>/dev/null) \ + || stamp='Mon Jul 28 20:00:00 2026' +printf '%s sleep 300\n' "$stamp" +SH + chmod +x "$fakebin/ps" + baseline=$(PATH="$fakebin:$PATH" FAKE_PS_LOCALE_LOG="$locale_log" FM_PROC_ROOT_OVERRIDE="$no_proc" LC_ALL=C bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null) + via_lc_all=$(PATH="$fakebin:$PATH" FAKE_PS_LOCALE_LOG="$locale_log" FM_PROC_ROOT_OVERRIDE="$no_proc" LC_ALL=ko_KR.UTF-8 bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null) + via_lc_time=$(PATH="$fakebin:$PATH" FAKE_PS_LOCALE_LOG="$locale_log" FM_PROC_ROOT_OVERRIDE="$no_proc" LC_TIME=ko_KR.UTF-8 bash -c 'unset LC_ALL; . "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null) + # Keep the real ps fallback exercised wherever it supports the portable -o fields. + real_first= + real_second= + if LC_ALL=C ps -p "$live" -o lstart= -o command= >/dev/null 2>&1; then + real_first=$(FM_PROC_ROOT_OVERRIDE="$no_proc" LC_ALL=C bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null) + real_second=$(FM_PROC_ROOT_OVERRIDE="$no_proc" LC_TIME=ko_KR.UTF-8 bash -c 'unset LC_ALL; . "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null) + fi kill "$live" 2>/dev/null || true wait "$live" 2>/dev/null || true [ -n "$baseline" ] || fail "fm_pid_identity produced no baseline identity under LC_ALL=C" [ "$via_lc_all" = "$baseline" ] || fail "fm_pid_identity varied with exported LC_ALL (got '$via_lc_all', want '$baseline')" [ "$via_lc_time" = "$baseline" ] || fail "fm_pid_identity varied with exported LC_TIME (got '$via_lc_time', want '$baseline')" + while read -r observed; do + [ "$observed" = C ] || fail "fm_pid_identity invoked ps without pinning LC_ALL=C (saw '$observed')" + done < "$locale_log" + if [ -n "$real_first" ]; then + [ "$real_second" = "$real_first" ] \ + || fail "real ps fallback varied with exported LC_TIME (got '$real_second', want '$real_first')" + pass "fm_pid_identity real ps fallback is locale-invariant" + else + pass "real ps fallback locale check skipped where ps -o lstart= is unsupported" + fi pass "fm_pid_identity is locale-invariant across LC_ALL/LC_TIME" } @@ -923,16 +959,14 @@ write_fake_proc_identity() { printf 'bash\0/path with spaces/fm-watch.sh\0--flag\0' > "$proc_root/$pid/cmdline" } -test_linux_pid_identity_ignores_wall_clock_and_detects_pid_reuse() { - local dir state proc_root pid before after_time_jump after_pid_reuse - [ "$(uname)" = Linux ] || { - pass "Linux process identity clock-step regression skipped on non-Linux host" - return - } - dir=$(make_case linux-pid-identity) +test_proc_pid_identity_ignores_wall_clock_and_detects_pid_reuse() { + local dir state proc_root pid identity_key before after_time_jump after_pid_reuse + dir=$(make_case proc-pid-identity) state="$dir/state" proc_root="$dir/proc" pid=4242 + identity_key=proc-starttime + [ "$(uname)" != Linux ] || identity_key=linux-starttime mkdir -p "$proc_root" printf 'btime 1784094040\n' > "$proc_root/stat" write_fake_proc_identity "$proc_root" "$pid" 987654 @@ -944,21 +978,43 @@ test_linux_pid_identity_ignores_wall_clock_and_detects_pid_reuse() { || fail "could not re-read fake Linux process identity after btime change" [ "$after_time_jump" = "$before" ] \ - || fail "Linux process identity changed with btime (before '$before', after '$after_time_jump')" - [ "$before" = 'linux-starttime=987654 cmdline-hex=62617368002f706174682077697468207370616365732f666d2d77617463682e7368002d2d666c616700' ] \ - || fail "Linux process identity did not combine parsed starttime field 22 with the full cmdline ('$before')" - pass "Linux process identity ignores simulated btime changes" + || fail "/proc process identity changed with btime (before '$before', after '$after_time_jump')" + [ "$before" = "$identity_key=987654 cmdline-hex=62617368002f706174682077697468207370616365732f666d2d77617463682e7368002d2d666c616700" ] \ + || fail "/proc process identity did not combine parsed starttime field 22 with the full cmdline ('$before')" + pass "/proc process identity ignores simulated btime changes" write_fake_proc_identity "$proc_root" "$pid" 987655 after_pid_reuse=$(FM_PROC_ROOT_OVERRIDE="$proc_root" FM_STATE_OVERRIDE="$state" bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$pid") \ - || fail "could not read reused fake Linux pid identity" - [ "$after_pid_reuse" != "$before" ] || fail "Linux process identity missed changed starttime for reused pid" - pass "Linux process identity detects pid reuse" + || fail "could not read reused fake /proc pid identity" + [ "$after_pid_reuse" != "$before" ] || fail "/proc process identity missed changed starttime for reused pid" + pass "/proc process identity detects pid reuse" +} + +test_msys_pid_identity_uses_proc() { + local live identity + case "$(uname)" in + MSYS*|MINGW*|CYGWIN*) ;; + *) + pass "MSYS /proc process identity regression skipped on non-Windows host" + return + ;; + esac + sleep 300 & + live=$! + identity=$(bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null) + kill "$live" 2>/dev/null || true + wait "$live" 2>/dev/null || true + case "$identity" in + proc-starttime=*" cmdline-hex="*) ;; + *) fail "MSYS process identity did not use compatible /proc fields ('$identity')" ;; + esac + pass "MSYS process identity uses compatible /proc fields" } test_singleton_start test_pid_identity_is_locale_invariant -test_linux_pid_identity_ignores_wall_clock_and_detects_pid_reuse +test_proc_pid_identity_ignores_wall_clock_and_detects_pid_reuse +test_msys_pid_identity_uses_proc test_stale_watch_lock_reclaimed test_live_stale_watch_lock_is_actionable test_guard_warnings diff --git a/tests/fm-x-mode.test.sh b/tests/fm-x-mode.test.sh index 8724c2b8655..172479d6fa6 100755 --- a/tests/fm-x-mode.test.sh +++ b/tests/fm-x-mode.test.sh @@ -770,6 +770,23 @@ test_bootstrap_activates_on_env_token() { pass "bootstrap activates X mode from an .env token, idempotently" } +test_bootstrap_relative_home_writes_absolute_poll_shim() { + local root home out quoted_home + root="$TMP_ROOT/boot-relative-home" + mkdir -p "$root/home" "$root/cdpath/home" + home=$(cd "$root/home" && pwd -P) + printf 'FMX_PAIRING_TOKEN=tok-relative\n' > "$home/.env" + out=$( + cd "$root" || exit 1 + CDPATH="$root/cdpath" FM_HOME=home "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null + ) + assert_contains "$out" "FMX: X mode on" "relative-home bootstrap must announce X mode" + quoted_home=$(printf '%q' "$home") + assert_grep "export FM_HOME=$quoted_home" "$home/state/x-watch.check.sh" \ + "relative FM_HOME leaked into the durable X-mode poll shim" + pass "bootstrap ignores CDPATH when writing absolute FM_HOME into the durable X-mode poll shim" +} + test_bootstrap_reports_missing_x_dependency() { local home fakebin out tool tool_path home="$TMP_ROOT/boot-missing-x"; mkdir -p "$home" @@ -3138,6 +3155,7 @@ test_followup_post_dry_run_increments_counter_keeps_link test_followup_post_dry_run_final_clears_link test_followup_usage_errors test_bootstrap_activates_on_env_token +test_bootstrap_relative_home_writes_absolute_poll_shim test_bootstrap_reports_missing_x_dependency test_bootstrap_does_not_announce_when_arm_fails test_bootstrap_does_not_follow_x_artifact_symlinks diff --git a/tests/no-mistakes-required-workflow.test.sh b/tests/no-mistakes-required-workflow.test.sh deleted file mode 100755 index dc87c9970f8..00000000000 --- a/tests/no-mistakes-required-workflow.test.sh +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env bash -# Contract and synthetic event replay for the PR body compliance workflow. -# shellcheck disable=SC2016 -set -u - -# shellcheck source=tests/lib.sh -. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" - -WORKFLOW="$ROOT/.github/workflows/no-mistakes-required.yml" -MARKER='Updates from [git push no-mistakes](https://github.com/kunchenguid/no-mistakes)' - -extract_signature_script() { - awk ' - /^ run: \|$/ { capture=1; next } - capture && /^ / { sub(/^ /, ""); print; next } - capture { exit } - ' "$WORKFLOW" -} - -signature_result() { - local body=$1 script - script=$(extract_signature_script) - PR_NUMBER=418 PR_AUTHOR=synthetic-fork-contributor PR_BODY="$body" bash -c "$script" >/dev/null 2>&1 -} - -render_group() { - local action=$1 run_id=$2 - case "$action" in - opened|edited) printf 'no-mistakes-required-418-%s\n' "$run_id" ;; - synchronize|reopened) printf 'no-mistakes-required-418-head-change\n' ;; - esac -} - -render_run_name() { - local action=$1 run_number=$2 run_id=$3 - printf 'PR #418 body compliance - %s - event %s (run %s)\n' "$action" "$run_number" "$run_id" -} - -test_signature_sequence_at_fixed_head() { - signature_result "Synthetic body\n$MARKER" || fail "signed opened event must succeed" - if signature_result 'Synthetic unsigned edit'; then - fail "unsigned edited event must fail" - fi - signature_result "Synthetic signed edit\n$MARKER" || fail "signed edited event must succeed" - pass "fixed-head signed opened, unsigned edited, signed edited yields 0/1/0" -} - -test_event_identity_contract() { - local opened edited_one edited_two synchronize reopened - opened=$(render_group opened 9001) - edited_one=$(render_group edited 9002) - edited_two=$(render_group edited 9003) - synchronize=$(render_group synchronize 9004) - reopened=$(render_group reopened 9005) - [ "$opened" != "$edited_one" ] && [ "$opened" != "$edited_two" ] && [ "$edited_one" != "$edited_two" ] || \ - fail "body events must have distinct immutable groups" - [ "$synchronize" = "$reopened" ] || fail "synchronize and reopened must share head-change" - case "$opened $edited_one $edited_two" in *head-change*) fail "body event reused head-change" ;; esac - - assert_grep "group: no-mistakes-required-\${{ github.event.pull_request.number }}-\${{ (github.event.action == 'opened' || github.event.action == 'edited') && github.run_id || 'head-change' }}" "$WORKFLOW" \ - "workflow does not implement immutable body-event groups" - assert_grep 'cancel-in-progress: true' "$WORKFLOW" "workflow lost cancellation for coalesced head changes" - pass "body event groups are distinct while head changes remain coalesced" -} - -test_run_names_are_ordered_and_unique() { - local first second - first=$(render_run_name edited 73 9002) - second=$(render_run_name edited 74 9003) - [ "$first" = 'PR #418 body compliance - edited - event 73 (run 9002)' ] || fail "first synthetic run name is incomplete" - [ "$second" = 'PR #418 body compliance - edited - event 74 (run 9003)' ] || fail "second synthetic run name is incomplete" - [ "$first" != "$second" ] || fail "distinct events must have unique run names" - assert_grep 'run-name: "PR #${{ github.event.pull_request.number }} body compliance - ${{ github.event.action }} - event ${{ github.run_number }} (run ${{ github.run_id }})"' "$WORKFLOW" \ - "workflow run name does not expose PR, action, monotonic run number, and immutable run ID" - pass "run names expose monotonic numbers and immutable IDs" -} - -test_security_and_signature_contract_is_preserved() { - assert_grep ' pull_request:' "$WORKFLOW" "workflow must use pull_request" - assert_no_grep 'pull_request_target' "$WORKFLOW" "workflow must not use pull_request_target" - assert_grep ' contents: read' "$WORKFLOW" "contents permission must remain read-only" - assert_no_grep 'contents: write' "$WORKFLOW" "workflow must not gain contents write permission" - assert_no_grep 'secrets.' "$WORKFLOW" "workflow must not read secrets" - assert_no_grep 'actions/checkout' "$WORKFLOW" "workflow must not check out fork code" - assert_grep 'name: PR must be raised via no-mistakes' "$WORKFLOW" "stable required check name changed" - assert_grep "$MARKER" "$WORKFLOW" "signature marker changed" - assert_grep "github.event.pull_request.user.login != 'github-actions[bot]'" "$WORKFLOW" "github-actions bot exemption changed" - assert_grep "github.event.pull_request.user.login != 'dependabot[bot]'" "$WORKFLOW" "dependabot bot exemption changed" - assert_no_grep 'release-please[bot]' "$WORKFLOW" "Firstmate must not exempt release-please" - pass "fork, permission, check-name, marker, and bot-exemption contracts are preserved" -} - -test_signature_sequence_at_fixed_head -test_event_identity_contract -test_run_names_are_ordered_and_unique -test_security_and_signature_contract_is_preserved From 5d3101444061e9edbd747c254b6d6f24dba5c3b5 Mon Sep 17 00:00:00 2001 From: quinnbot-ai Date: Sat, 1 Aug 2026 13:41:19 -0700 Subject: [PATCH 20/70] fix(spawn): verify long launch delivery (#71) * fix(spawn): verify long launch delivery * no-mistakes(review): Fix bounded launch verification and submission * no-mistakes(document): Document verified fm-spawn launch delivery * no-mistakes(lint): Fix launch delivery shell lint findings * no-mistakes(document): Refresh spawn launch delivery documentation * no-mistakes(document): Document Herdr launch delivery transport --------- Co-authored-by: QuinnBot --- bin/backends/cmux.sh | 4 +- bin/backends/herdr.sh | 6 +- bin/backends/tmux.sh | 9 +- bin/backends/zellij.sh | 11 +- bin/fm-spawn.sh | 102 +++++++++++- docs/herdr-backend.md | 2 +- tests/fm-backend-herdr-smoke.test.sh | 2 +- tests/fm-backend-orca.test.sh | 48 +++++- tests/fm-backend-tmux-smoke.test.sh | 3 +- tests/fm-backend.test.sh | 30 ++++ tests/fm-kimi-harness.test.sh | 52 +++++- tests/fm-spawn-dispatch-profile.test.sh | 35 ++++ tests/fm-spawn-launch-delivery.test.sh | 213 ++++++++++++++++++++++++ tests/secondmate-helpers.sh | 29 +++- 14 files changed, 511 insertions(+), 35 deletions(-) create mode 100644 tests/fm-spawn-launch-delivery.test.sh diff --git a/bin/backends/cmux.sh b/bin/backends/cmux.sh index 12dc7629eb6..c1f57939323 100644 --- a/bin/backends/cmux.sh +++ b/bin/backends/cmux.sh @@ -504,8 +504,8 @@ fm_backend_cmux_send_key() { # [expected-label] # fm_backend_cmux_send_text_line: send one line of TEXT then submit. cmux has # no single-call atomic "run and submit" primitive (like herdr's `pane run`), # so this composes send (literal) + send-key enter, exactly like zellij's -# equivalent - used for the fixed spawn-time commands (treehouse get, the -# GOTMPDIR export). +# equivalent. fm-spawn uses this primitive for setup and the launch delivery +# protocol owned by bin/fm-spawn.sh's header. fm_backend_cmux_send_text_line() { # [expected-label] fm_backend_cmux_send_literal "$1" "$2" "${3:-}" || return 1 fm_backend_cmux_send_key "$1" Enter "${3:-}" diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 237d2348c5a..8b44b3c6462 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -1676,9 +1676,9 @@ fm_backend_herdr_current_path() { # } # fm_backend_herdr_send_text_line: send one line of TEXT then submit, -# ATOMICALLY - mirrors tmux's `send-keys -t T text Enter`. Used for the fixed -# spawn-time commands (treehouse get, the GOTMPDIR export). `pane run` types -# the command and submits it in one call (verified). +# ATOMICALLY - mirrors tmux's `send-keys -t T text Enter`. fm-spawn uses this +# primitive for setup and the launch delivery protocol owned by bin/fm-spawn.sh's +# header. `pane run` types and submits in one call (verified). fm_backend_herdr_send_text_line() { # fm_backend_herdr_target_ready "$1" || return 1 fm_backend_herdr_cli "$FM_BACKEND_HERDR_SESSION" pane run "$FM_BACKEND_HERDR_PANE" "$2" >/dev/null 2>&1 diff --git a/bin/backends/tmux.sh b/bin/backends/tmux.sh index f8da21bf0de..0433fc587fa 100644 --- a/bin/backends/tmux.sh +++ b/bin/backends/tmux.sh @@ -102,16 +102,15 @@ fm_backend_tmux_current_path() { # } # fm_backend_tmux_send_text_line: send one line of TEXT then Enter, with no -# composer verification - used for the fixed spawn-time commands -# (`treehouse get`, the GOTMPDIR export) that already ran this exact sequence -# inline in fm-spawn.sh. Mirrors `tmux send-keys -t "$T" "" Enter`. +# composer verification. fm-spawn uses this primitive for setup and the launch +# delivery protocol owned by bin/fm-spawn.sh's header. +# Mirrors `tmux send-keys -t "$T" "" Enter`. fm_backend_tmux_send_text_line() { # tmux send-keys -t "$1" "$2" Enter } # fm_backend_tmux_send_literal: send TEXT as literal bytes with no -# submission - the caller sends Enter separately (fm-spawn.sh's launch-command -# send pauses between the literal send and Enter for the harness to settle). +# submission - the caller sends Enter separately. # Mirrors `tmux send-keys -t "$T" -l ""`. fm_backend_tmux_send_literal() { # tmux send-keys -t "$1" -l "$2" diff --git a/bin/backends/zellij.sh b/bin/backends/zellij.sh index 20d53a3c2de..60aea88e79b 100644 --- a/bin/backends/zellij.sh +++ b/bin/backends/zellij.sh @@ -462,11 +462,12 @@ fm_backend_zellij_send_key() { # [expected-label] # fm_backend_zellij_send_text_line: send one line of TEXT then submit, # ATOMICALLY - mirrors tmux's `send-keys -t T text Enter` / herdr's `pane -# run`. Used for the fixed spawn-time commands (treehouse get, the GOTMPDIR -# export). Zellij has no single-call atomic "run and submit" action, so this -# composes paste (literal) + send-keys Enter, exactly like send_literal + -# send_key are composed elsewhere - the two-step form is the ONLY form for -# this adapter, unlike tmux/herdr which have a genuinely atomic primitive. +# run`. fm-spawn uses this primitive for setup and the launch delivery protocol +# owned by bin/fm-spawn.sh's header. Zellij has no single-call atomic "run and +# submit" action, so this composes paste (literal) + send-keys Enter, exactly +# like send_literal + send_key are composed elsewhere - the two-step form is +# the ONLY form for this adapter, unlike tmux/herdr which have a genuinely +# atomic primitive. fm_backend_zellij_send_text_line() { # [expected-label] fm_backend_zellij_send_literal "$1" "$2" "${3:-}" || return 1 fm_backend_zellij_send_key "$1" Enter "${3:-}" diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 2e80a552b9e..1c3a860ae64 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -67,6 +67,14 @@ # whitespace is treated as a RAW launch command - the escape hatch for verifying # 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. +# Before any harness command runs, launch delivery waits for an executed shell +# round-trip, stages the command through bounded submitted assignments using the +# selected backend's send/capture primitives, and verifies the complete staged +# bytes before evaluation. A failed check clears the shell line and retries a +# bounded number of times; exhaustion records a failed task status and exits +# nonzero rather than reporting a spawn. This launch-delivery contract does not +# alter the backend adapters' text-submit verification contracts; its regression +# coverage lives in tests/fm-spawn-launch-delivery.test.sh. # 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 @@ -1236,6 +1244,85 @@ spawn_send_key() { # esac } +spawn_capture() { # + fm_backend_capture "$BACKEND" "$1" 160 "$W" 2>/dev/null || true +} + +spawn_capture_has_line() { # + printf '%s\n' "$1" | grep -Fqx "$2" +} + +spawn_wait_for_marker() { # + local target=$1 marker=$2 pane i=0 max=${FM_SPAWN_LAUNCH_VERIFY_POLLS:-40} + case "$max" in ''|*[!0-9]*) max=40 ;; esac + [ "$max" -gt 0 ] || max=40 + while [ "$i" -lt "$max" ]; do + pane=$(spawn_capture "$target") + spawn_capture_has_line "$pane" "$marker" && return 0 + i=$((i + 1)) + [ "$i" -ge "$max" ] || sleep "${FM_SPAWN_LAUNCH_POLL_INTERVAL:-0.05}" + done + return 1 +} + +spawn_wait_for_shell_ready() { # + local target=$1 token=$2 + local marker="__FM_SPAWN_READY_$token" + # The full marker is intentionally absent from the typed command, so seeing + # an exact marker line proves the shell executed the probe rather than merely + # echoing bytes that arrived before its line editor was ready. + spawn_send_text_line "$target" "printf '%s%s\\n' '__FM_SPAWN_READY_' '$token'" \ + || return 1 + spawn_wait_for_marker "$target" "$marker" +} + +spawn_stage_launch() { # + local target=$1 launch=$2 token=$3 chunk_size=${FM_SPAWN_LAUNCH_CHUNK_BYTES:-160} + local delay=${FM_SPAWN_LAUNCH_CHUNK_DELAY:-0.04} offset=0 chunk quoted expected marker check + case "$chunk_size" in ''|*[!0-9]*) chunk_size=160 ;; esac + [ "$chunk_size" -gt 0 ] || chunk_size=160 + expected=$(printf '%s' "$launch" | cksum) || return 1 + marker="__FM_SPAWN_LAUNCH_OK_$token" + + # C-c clears an abandoned line from a prior failed attempt before short, + # independently submitted assignments rebuild the launch exactly in the + # target shell. This avoids macOS's pre-ZLE canonical-input ceiling while + # retaining the backend adapters' own literal/key submission contracts. + spawn_send_key "$target" C-c || true + spawn_send_text_line "$target" "FM_SPAWN_LAUNCH=''" || return 1 + while [ "$offset" -lt "${#launch}" ]; do + chunk=${launch:offset:chunk_size} + quoted=$(shell_quote "$chunk") + spawn_send_text_line "$target" "FM_SPAWN_LAUNCH=\"\${FM_SPAWN_LAUNCH}\"$quoted" || return 1 + offset=$((offset + chunk_size)) + sleep "$delay" + done + check="if [ \"\$(printf %s \"\$FM_SPAWN_LAUNCH\" | cksum)\" = $(shell_quote "$expected") ]; then printf '%s%s\\n' '__FM_SPAWN_LAUNCH_OK_' '$token'; else printf '%s%s\\n' '__FM_SPAWN_LAUNCH_BAD_' '$token'; fi" + spawn_send_text_line "$target" "$check" || return 1 + spawn_wait_for_marker "$target" "$marker" +} + +spawn_deliver_launch() { # + local target=$1 launch=$2 retries=${FM_SPAWN_LAUNCH_DELIVERY_RETRIES:-3} + local token token_sum attempt=1 + case "$retries" in ''|*[!0-9]*) retries=3 ;; esac + [ "$retries" -gt 0 ] || retries=3 + while [ "$attempt" -le "$retries" ]; do + token_sum=$(printf '%s' "$RANDOM:$attempt" | cksum) || return 1 + token_sum=${token_sum%% *} + printf -v token '%010u' "$token_sum" + if spawn_wait_for_shell_ready "$target" "$token" \ + && spawn_stage_launch "$target" "$launch" "$token"; then + # shellcheck disable=SC2016 # Expand the staged launch in the target shell. + spawn_send_text_line "$target" 'eval "$FM_SPAWN_LAUNCH"' || return 1 + return 0 + fi + spawn_send_key "$target" C-c || true + attempt=$((attempt + 1)) + done + return 1 +} + kimi_capture() { fm_backend_capture "$BACKEND" "$T" 120 "$W" 2>/dev/null || true } @@ -1555,17 +1642,20 @@ if [ "$KIND" = secondmate ]; then LAUNCH="FM_ROOT_OVERRIDE= FM_STATE_OVERRIDE= FM_DATA_OVERRIDE= FM_PROJECTS_OVERRIDE= FM_CONFIG_OVERRIDE= FM_HOME=$sq_home $LAUNCH" fi # Export GOTMPDIR into the crewmate's pane shell so the agent and every child -# process (go build, go test, ...) inherit it. Sent before the launch command so -# the env is set when the agent starts; the brief sleep lets the export land. +# process (go build, go test, ...) inherit it. The verified delivery routine +# below then waits for that shell to round-trip a probe before staging the full +# launch in bounded, independently submitted assignments. spawn_send_text_line "$T" "export GOTMPDIR=$TASK_TMP/gotmp" -sleep 0.3 -spawn_send_literal "$T" "$LAUNCH" -sleep 0.3 +if ! spawn_deliver_launch "$T" "$LAUNCH"; then + printf 'failed: launch command delivery could not be verified after %s attempts\n' \ + "${FM_SPAWN_LAUNCH_DELIVERY_RETRIES:-3}" >> "$STATE/$ID.status" + echo "error: launch command delivery could not be verified after ${FM_SPAWN_LAUNCH_DELIVERY_RETRIES:-3} attempts; inspect window $T" >&2 + exit 1 +fi if [ "${HERDR_PROJECTED:-0}" -eq 1 ]; then HERDR_PROJECTION_ABORT_CLEANUP=0 spawn_herdr_presentation_order_lock_release fi -spawn_send_key "$T" Enter if [ "$HARNESS" = kimi ]; then if ! kimi_wait_for_ready; then kimi_spawn_fail "kimi did not show a verified ready signal before brief delivery" diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index 0ba7ff6316c..07a3b1ae7d8 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -164,7 +164,7 @@ Every Herdr invocation goes through `fm_backend_herdr_cli`, which sets the envir An environment variable alone is not reliable when another Herdr server is running. Literal text and Enter are separate operations for ordinary steers. -Spawn-time fixed commands may use Herdr's atomic run primitive. +Spawn setup and the verified launch-delivery protocol use Herdr's atomic run primitive, while [`bin/fm-spawn.sh`](../bin/fm-spawn.sh) owns the protocol. Enter, Escape, and Ctrl-C are supported. Slash and dollar-prefixed input uses the shared harness-aware settle before the first Enter so a completion popup cannot consume it. Text is typed once; only Enter is retried. diff --git a/tests/fm-backend-herdr-smoke.test.sh b/tests/fm-backend-herdr-smoke.test.sh index 9eef7a90242..466aafd9be5 100755 --- a/tests/fm-backend-herdr-smoke.test.sh +++ b/tests/fm-backend-herdr-smoke.test.sh @@ -255,7 +255,7 @@ case "$out" in esac pass "real herdr: send_text_line runs a command atomically (pane run) and its output is capturable" -# --- send_literal + send_key(Enter), the two-step launch-command form ------- +# --- send_literal + send_key(Enter), the two-step primitive composition ----- fm_backend_herdr_send_literal "$TARGET" 'echo literal-then-key-captain' \ || fail "send_literal failed" diff --git a/tests/fm-backend-orca.test.sh b/tests/fm-backend-orca.test.sh index a54e448d108..17a20971daa 100755 --- a/tests/fm-backend-orca.test.sh +++ b/tests/fm-backend-orca.test.sh @@ -27,6 +27,45 @@ if [ "${1:-}" = status ] && [ "${FM_ORCA_STATUS_RESPONSE:-ready}" != sequence ]; printf '{"ok":true,"result":{"runtime":{"reachable":true,"state":"ready"}}}\n' exit 0 fi +if [ -n "${FM_ORCA_SPAWN_SCREEN:-}" ] && [ "${1:-} ${2:-}" = "terminal send" ]; then + text= + prev= + for arg in "$@"; do + if [ "$prev" = --text ]; then + text=$arg + break + fi + prev=$arg + done + staged="$FM_ORCA_SPAWN_SCREEN.staged" + evaluated="$FM_ORCA_SPAWN_SCREEN.evaluated" + case "$text" in + *"__FM_SPAWN_READY_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$FM_ORCA_SPAWN_SCREEN" + ;; + "FM_SPAWN_LAUNCH=''" ) + : > "$staged" + ;; + FM_SPAWN_LAUNCH=*) + rebuilt=$(FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") + printf '%s' "$rebuilt" > "$staged" + ;; + *"__FM_SPAWN_LAUNCH_OK_"*) + FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text" > "$FM_ORCA_SPAWN_SCREEN" + ;; + 'eval "$FM_SPAWN_LAUNCH"') + cat "$staged" > "$evaluated" + ;; + esac + printf '{"ok":true,"result":{"send":{"accepted":true}}}\n' + exit 0 +fi +if [ -n "${FM_ORCA_SPAWN_SCREEN:-}" ] && [ "${1:-} ${2:-}" = "terminal read" ]; then + marker=$(tail -n 1 "$FM_ORCA_SPAWN_SCREEN" 2>/dev/null || true) + printf '{"ok":true,"result":{"terminal":{"tail":["%s"]}}}\n' "$marker" + exit 0 +fi n=$next echo "$n" > "$COUNT_FILE" if [ -f "$RESP/$n.exit" ]; then @@ -486,7 +525,11 @@ test_spawn_writes_orca_metadata_and_launches_harness() { printf '1\n' > "$RESP/1.exit" printf '{"ok":true,"result":{"repo":{"id":"repo-spawn"}}}\n' > "$RESP/2.out" printf '{"ok":true,"result":{"worktree":{"id":"wt-spawn","path":"%s"},"terminal":{"handle":"term-spawn"}}}\n' "$wt" > "$RESP/3.out" + : > "$CASE_DIR/spawn-screen" + : > "$CASE_DIR/spawn-screen.staged" + : > "$CASE_DIR/spawn-screen.evaluated" out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ + FM_ORCA_SPAWN_SCREEN="$CASE_DIR/spawn-screen" \ FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ FM_PROJECTS_OVERRIDE="$TMP_ROOT/unused-projects" FM_SPAWN_NO_GUARD=1 \ "$ROOT/bin/fm-spawn.sh" "$id" "$proj" claude --backend orca 2>&1 ) @@ -502,8 +545,9 @@ test_spawn_writes_orca_metadata_and_launches_harness() { "spawn should reuse the implicit terminal returned by Orca worktree creation" assert_contains "$(cat "$log")" $'orca\x1f''terminal'$'\x1f''send'$'\x1f''--terminal'$'\x1f''term-spawn'$'\x1f''--text'$'\x1f''export GOTMPDIR=/tmp/fm-orcaspawnz1/gotmp'$'\x1f''--enter'$'\x1f''--json' \ "spawn did not export GOTMPDIR through the Orca terminal" - assert_contains "$(cat "$log")" "CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions" \ - "spawn did not send the selected harness launch command through Orca" + assert_contains "$(cat "$CASE_DIR/spawn-screen.evaluated")" \ + "CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions" \ + "spawn did not verify and evaluate the selected harness launch command through Orca" rm -rf "/tmp/fm-$id" pass "fm-spawn.sh --backend orca: reuses implicit terminal, records metadata, launches harness" } diff --git a/tests/fm-backend-tmux-smoke.test.sh b/tests/fm-backend-tmux-smoke.test.sh index aa1e07c3269..d9a5af774c4 100755 --- a/tests/fm-backend-tmux-smoke.test.sh +++ b/tests/fm-backend-tmux-smoke.test.sh @@ -105,8 +105,7 @@ case "$out" in esac pass "real tmux: fm_backend_tmux_send_text_line sends literal text and submits with Enter" -# --- send_literal + send_key(Enter), the two-step form fm-spawn.sh uses for the -# harness launch command (literal send, settle, then a separate Enter) -------- +# --- send_literal + send_key(Enter), the two-step primitive composition ------ fm_backend_tmux_send_literal "$TARGET" "printf 'literal-then-key-%s\\n' captain" \ || fail "fm_backend_tmux_send_literal failed" diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 1f98176b429..84ba7acad2d 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -797,11 +797,26 @@ make_spawn_fakebin() { # -> echoes fakebin dir #!/usr/bin/env bash set -u { printf 'tmux'; for a in "\$@"; do printf '\\x1f%s' "\$a"; done; printf '\\n'; } >> "\${FM_TMUX_LOG:?}" +screen="\${FM_TMUX_LOG:?}.spawn-screen" case "\${1:-}" in display-message) for a in "\$@"; do case "\$a" in *pane_current_path*) printf '%s\\n' "$wt"; exit 0 ;; esac; done printf 'firstmate\\n'; exit 0 ;; list-windows) exit 0 ;; + capture-pane) cat "\$screen" 2>/dev/null || true; exit 0 ;; + send-keys) + text=\${4:-} + case "\$text" in + *"__FM_SPAWN_READY_"*) + token=\$(printf '%s\\n' "\$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\\([^']*\\)'.*/\\1/p") + [ -z "\$token" ] || printf '__FM_SPAWN_READY_%s\\n' "\$token" > "\$screen" + ;; + *"__FM_SPAWN_LAUNCH_OK_"*) + token=\$(printf '%s\\n' "\$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\\([^']*\\)'.*/\\1/p") + [ -z "\$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\\n' "\$token" > "\$screen" + ;; + esac + exit 0 ;; esac exit 0 SH @@ -859,6 +874,7 @@ make_spawn_symlink_fakebin() { # - #!/usr/bin/env bash set -u { printf 'tmux'; for a in "\$@"; do printf '\\x1f%s' "\$a"; done; printf '\\n'; } >> "\${FM_TMUX_LOG:?}" +screen="\${FM_TMUX_LOG:?}.spawn-screen" case "\${1:-}" in display-message) for a in "\$@"; do case "\$a" in *pane_current_path*) @@ -872,6 +888,20 @@ case "\${1:-}" in ;; esac; done printf 'firstmate\\n'; exit 0 ;; list-windows) exit 0 ;; + capture-pane) cat "\$screen" 2>/dev/null || true; exit 0 ;; + send-keys) + text=\${4:-} + case "\$text" in + *"__FM_SPAWN_READY_"*) + token=\$(printf '%s\\n' "\$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\\([^']*\\)'.*/\\1/p") + [ -z "\$token" ] || printf '__FM_SPAWN_READY_%s\\n' "\$token" > "\$screen" + ;; + *"__FM_SPAWN_LAUNCH_OK_"*) + token=\$(printf '%s\\n' "\$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\\([^']*\\)'.*/\\1/p") + [ -z "\$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\\n' "\$token" > "\$screen" + ;; + esac + exit 0 ;; esac exit 0 SH diff --git a/tests/fm-kimi-harness.test.sh b/tests/fm-kimi-harness.test.sh index 8e27052d8ce..105a5a9219a 100755 --- a/tests/fm-kimi-harness.test.sh +++ b/tests/fm-kimi-harness.test.sh @@ -29,6 +29,8 @@ make_spawn_fakebin() { set -u printf '%s\n' "$*" >> "$FM_FAKE_TMUX_CALL_LOG" state=$(cat "$FM_FAKE_KIMI_STATE" 2>/dev/null || true) +spawn_screen=${FM_FAKE_SPAWN_SCREEN:-"${FM_FAKE_LAUNCH_LOG}.screen"} +staged_launch=${FM_FAKE_STAGED_LAUNCH:-"${FM_FAKE_LAUNCH_LOG}.staged"} fake_screen() { case "$state" in ready) @@ -80,13 +82,43 @@ case "${1:-}" in esac exit 0 fi + text=${4:-} + case "$text" in + *"__FM_SPAWN_READY_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$spawn_screen" + exit 0 + ;; + "FM_SPAWN_LAUNCH=''" ) + : > "$staged_launch" + exit 0 + ;; + FM_SPAWN_LAUNCH=*) + staged=$(FM_SPAWN_LAUNCH="$(cat "$staged_launch")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") + printf '%s' "$staged" > "$staged_launch" + exit 0 + ;; + *"__FM_SPAWN_LAUNCH_OK_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\n' "$token" > "$spawn_screen" + exit 0 + ;; + 'eval "$FM_SPAWN_LAUNCH"') + cat "$staged_launch" >> "$FM_FAKE_LAUNCH_LOG" + printf '\n' >> "$FM_FAKE_LAUNCH_LOG" + if [ "${FM_FAKE_KIMI_READY:-yes}" = yes ]; then + printf 'ready\n' > "$FM_FAKE_KIMI_STATE" + else + printf 'launched\n' > "$FM_FAKE_KIMI_STATE" + fi + exit 0 + ;; + esac case " $* " in *' Enter '*) case "$state" in - launched) - if [ "${FM_FAKE_KIMI_READY:-yes}" = yes ]; then - printf 'ready\n' > "$FM_FAKE_KIMI_STATE" - fi + ready) + printf 'unexpected-enter\n' > "$FM_FAKE_KIMI_STATE" ;; pointer-typed) if [ "${FM_FAKE_KIMI_DELIVERY:-yes}" = yes ]; then @@ -114,9 +146,12 @@ case "${1:-}" in esac case "$arg" in -S|-E) prev=$arg ;; *) prev= ;; esac done - case "$start:$end" in - *[!0-9:]*|'':*|*:'') fake_screen ;; - *) fake_screen | awk -v start="$start" -v end="$end" \ + { + cat "$spawn_screen" 2>/dev/null || true + fake_screen + } | case "$start:$end" in + *[!0-9:]*|'':*|*:'') cat ;; + *) awk -v start="$start" -v end="$end" \ 'NR - 1 >= start && NR - 1 <= end' ;; esac exit 0 @@ -146,6 +181,8 @@ make_spawn_case() { touch "$home/state/.last-watcher-beat" : > "$case_dir/launch.log" : > "$case_dir/pointer.log" + : > "$case_dir/launch.log.screen" + : > "$case_dir/launch.log.staged" : > "$case_dir/kimi.state" : > "$case_dir/tmux-calls.log" printf '%s\n' "$case_dir|$home|$proj|$wt|$fakebin" @@ -165,6 +202,7 @@ run_spawn() { FM_FAKE_KIMI_SWALLOW_FIRST="${FM_FAKE_KIMI_SWALLOW_FIRST:-no}" \ FM_FAKE_TMUX_CALL_LOG="$case_dir/tmux-calls.log" \ FM_FAKE_BRIEF_REAL="$(cd "$home/data/$id" && pwd -P)/brief.md" \ + FM_FAKE_SPAWN_SCREEN="$case_dir/launch.log.screen" FM_FAKE_STAGED_LAUNCH="$case_dir/launch.log.staged" \ FM_KIMI_READY_POLLS=2 FM_KIMI_DELIVERY_POLLS=2 FM_KIMI_POLL_INTERVAL=0 \ PATH="$fakebin:$BASE_PATH" \ "$SPAWN" "$id" "$proj" --harness kimi "$@" 2>&1 diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index e5f017608dc..3d2fbb3c361 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -19,6 +19,8 @@ make_spawn_fakebin() { cat > "$fakebin/tmux" <<'SH' #!/usr/bin/env bash set -u +screen=${FM_FAKE_SPAWN_SCREEN:-"${FM_FAKE_LAUNCH_LOG:?}.screen"} +staged_launch=${FM_FAKE_STAGED_LAUNCH:-"${FM_FAKE_LAUNCH_LOG:?}.staged"} case "$*" in *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; esac @@ -26,7 +28,35 @@ case "${1:-}" in display-message) printf 'firstmate\n'; exit 0 ;; list-windows) exit 0 ;; has-session|new-session|new-window|kill-window) exit 0 ;; + capture-pane) cat "$screen"; exit 0 ;; send-keys) + text=${4:-} + case "$text" in + *"__FM_SPAWN_READY_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$screen" + exit 0 + ;; + "FM_SPAWN_LAUNCH=''" ) + : > "$staged_launch" + exit 0 + ;; + FM_SPAWN_LAUNCH=*) + staged=$(FM_SPAWN_LAUNCH="$(cat "$staged_launch")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") + printf '%s' "$staged" > "$staged_launch" + exit 0 + ;; + *"__FM_SPAWN_LAUNCH_OK_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\n' "$token" > "$screen" + exit 0 + ;; + 'eval "$FM_SPAWN_LAUNCH"') + cat "$staged_launch" >> "$FM_FAKE_LAUNCH_LOG" + printf '\n' >> "$FM_FAKE_LAUNCH_LOG" + exit 0 + ;; + esac if [ -n "${FM_FAKE_LAUNCH_LOG:-}" ]; then prev= for a in "$@"; do @@ -84,6 +114,8 @@ run_spawn() { local home=$1 wt=$2 fakebin=$3 launchlog=$4 shift 4 : > "$launchlog" + : > "$launchlog.screen" + : > "$launchlog.staged" # CLAUDE_CONFIG_DIR is forwarded onto claude launches by fm-spawn, so pin it # explicitly (empty by default) instead of leaking the invoking shell's value, # which would make launch assertions depend on the developer's environment. @@ -94,6 +126,7 @@ run_spawn() { FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$wt" TMUX="fake,1,0" \ CLAUDE_CONFIG_DIR="${FM_TEST_CLAUDE_CONFIG_DIR:-}" \ FM_FAKE_LAUNCH_LOG="$launchlog" GROK_HOME="$home/grok-home" PATH="$fakebin:$PATH" \ + FM_FAKE_SPAWN_SCREEN="$CASE_DIR/spawn.screen" FM_FAKE_STAGED_LAUNCH="$CASE_DIR/staged-launch" \ "$SPAWN" "$@" 2>&1 } @@ -144,6 +177,7 @@ test_relative_home_overrides_launch_with_absolute_cross_process_paths() { FM_PROJECTS_OVERRIDE=home/projects FM_CONFIG_OVERRIDE=home/config \ FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$WT_DIR" TMUX="fake,1,0" \ CLAUDE_CONFIG_DIR='' FM_FAKE_LAUNCH_LOG="$LAUNCH_LOG" \ + FM_FAKE_SPAWN_SCREEN="$CASE_DIR/spawn.screen" FM_FAKE_STAGED_LAUNCH="$CASE_DIR/staged-launch" \ GROK_HOME=home/grok-home PATH="$FAKEBIN_DIR:$PATH" \ "$SPAWN" "$id" "$PROJ_DIR" 2>&1 ) @@ -173,6 +207,7 @@ test_home_defaults_preserve_absolute_or_resolve_relative_paths() { FM_PROJECTS_OVERRIDE=home/projects FM_CONFIG_OVERRIDE=home/config \ FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$WT_DIR" TMUX="fake,1,0" \ CLAUDE_CONFIG_DIR='' FM_FAKE_LAUNCH_LOG="$LAUNCH_LOG" \ + FM_FAKE_SPAWN_SCREEN="$CASE_DIR/spawn.screen" FM_FAKE_STAGED_LAUNCH="$CASE_DIR/staged-launch" \ GROK_HOME=home/grok-home PATH="$FAKEBIN_DIR:$PATH" \ "$SPAWN" "$relative_id" "$PROJ_DIR" 2>&1 ) diff --git a/tests/fm-spawn-launch-delivery.test.sh b/tests/fm-spawn-launch-delivery.test.sh new file mode 100644 index 00000000000..495b82da82c --- /dev/null +++ b/tests/fm-spawn-launch-delivery.test.sh @@ -0,0 +1,213 @@ +#!/usr/bin/env bash +# Behavior tests for fm-spawn's verified long-launch delivery protocol. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +SPAWN="$ROOT/bin/fm-spawn.sh" +TMP_ROOT=$(fm_test_tmproot fm-spawn-launch-delivery) + +make_fakebin() { + local dir=$1 fakebin + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u + +screen=${FM_FAKE_SCREEN:?} +log=${FM_FAKE_TMUX_LOG:?} +attempts=${FM_FAKE_ATTEMPTS:?} +staged=${FM_FAKE_STAGED:?} +evaluated=${FM_FAKE_EVALUATED:?} +screen_history=${FM_FAKE_SCREEN_HISTORY:?} +write_screen_line() { + printf '%s\n' "$1" | fold -w "${FM_FAKE_PANE_COLUMNS:-80}" > "$screen" + cat "$screen" >> "$screen_history" +} +case "${1:-}" in + display-message) + case "$*" in + *'#{pane_current_path}'*) printf '%s\n' "$FM_FAKE_PANE_PATH" ;; + *) printf 'firstmate\n' ;; + esac + exit 0 + ;; + new-window) printf '@7\n'; exit 0 ;; + list-windows|has-session|set-window-option|kill-window) exit 0 ;; + capture-pane) cat "$screen"; exit 0 ;; + send-keys) + text=${4:-} + printf '%s\n' "$text" >> "$log" + case "$text" in + *"__FM_SPAWN_READY_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") + [ -n "$token" ] && write_screen_line "__FM_SPAWN_READY_$token" + ;; + "FM_SPAWN_LAUNCH=''" ) + count=$(($(cat "$attempts" 2>/dev/null || printf 0) + 1)) + printf '%s\n' "$count" > "$attempts" + : > "$staged" + ;; + FM_SPAWN_LAUNCH=*) + rebuilt=$(FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") + if [ "$(cat "$attempts")" -le "${FM_FAKE_TRUNCATE_ATTEMPTS:-0}" ] && [ -n "$rebuilt" ]; then + rebuilt=${rebuilt%?} + fi + printf '%s' "$rebuilt" > "$staged" + ;; + *"__FM_SPAWN_LAUNCH_OK_"*) + result=$(FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text") + if [ "$(cat "$attempts")" -le "${FM_FAKE_TRUNCATE_ATTEMPTS:-0}" ]; then + # The fixed-offset env-scrub prefix is the live truncation signature. + printf '/usr/bin/env -u FM_ROOT_OVERRIDE -u FM_STATE_OVERRIDE -u FM_DATA_OVERRIDE FM_H\n%s\n' "$result" > "$screen" + cat "$screen" >> "$screen_history" + else + write_screen_line "$result" + fi + ;; + 'eval "$FM_SPAWN_LAUNCH"') + FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text" > "$evaluated" + ;; + esac + exit 0 + ;; +esac +exit 0 +SH + chmod +x "$fakebin/tmux" + fm_fake_exit0 "$fakebin" treehouse + printf '%s\n' "$fakebin" +} + +make_case() { + local name=$1 id=$2 case_dir home proj wt fakebin + case_dir="$TMP_ROOT/$name" + home="$case_dir/home" + proj="$case_dir/project" + wt="$case_dir/wt" + fakebin=$(make_fakebin "$case_dir/fake") + mkdir -p "$home/data/$id" "$home/projects" "$home/state" "$home/config" + printf 'claude\n' > "$home/config/crew-harness" + printf 'brief\n' > "$home/data/$id/brief.md" + touch "$home/state/.last-watcher-beat" + fm_git_worktree "$proj" "$wt" "wt-$name" + : > "$case_dir/screen" + : > "$case_dir/tmux.log" + : > "$case_dir/attempts" + : > "$case_dir/staged" + : > "$case_dir/evaluated" + : > "$case_dir/screen-history" + printf '%s\n' "$case_dir|$home|$proj|$wt|$fakebin" +} + +run_spawn() { + local case_dir=$1 home=$2 proj=$3 wt=$4 fakebin=$5 id=$6 + shift 6 + FM_ROOT_OVERRIDE='' FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \ + FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$wt" TMUX="fake,1,0" \ + FM_FAKE_SCREEN="$case_dir/screen" FM_FAKE_TMUX_LOG="$case_dir/tmux.log" \ + FM_FAKE_ATTEMPTS="$case_dir/attempts" FM_FAKE_STAGED="$case_dir/staged" \ + FM_FAKE_EVALUATED="$case_dir/evaluated" FM_FAKE_SCREEN_HISTORY="$case_dir/screen-history" \ + FM_SPAWN_LAUNCH_POLL_INTERVAL=0 FM_SPAWN_LAUNCH_CHUNK_DELAY=0 \ + PATH="$fakebin:$PATH" \ + "$SPAWN" "$id" "$proj" "$@" 2>&1 +} + +read_case() { + IFS='|' read -r CASE_DIR HOME_DIR PROJ_DIR WT_DIR FAKEBIN_DIR < 450 { exit 1 }' "$CASE_DIR/tmux.log" \ + || fail "fm-spawn sent a line larger than the bounded delivery chunk" + assert_grep 'C-c' "$CASE_DIR/tmux.log" "failed delivery did not clear the pending shell line before retry" + [ "$(wc -c < "$CASE_DIR/evaluated" | tr -d ' ')" = 1801 ] \ + || fail "verified retry did not evaluate the complete 1,800-byte payload" + [ -z "$(tr -d 'x\n' < "$CASE_DIR/evaluated")" ] \ + || fail "verified retry evaluated bytes other than the complete launch payload" + assert_contains "$out" "spawned $id" "verified retry did not finish the spawn" + if [ -n "${FM_TEST_EVIDENCE_DIR:-}" ]; then + mkdir -p "$FM_TEST_EVIDENCE_DIR" + { + printf '$ fm-spawn.sh %s <1,800-byte raw launch>\n' "$id" + printf '%s\n' "$out" + printf '\nObserved shell output across delivery attempts:\n' + cat "$CASE_DIR/screen-history" + printf '\nDelivery facts:\n' + printf 'attempts=%s\n' "$(cat "$CASE_DIR/attempts")" + printf 'largest_typed_line_bytes=%s\n' "$(awk '{ if (length > max) max=length } END { print max + 0 }' "$CASE_DIR/tmux.log")" + printf 'evaluated_payload_bytes=%s\n' "$(wc -c < "$CASE_DIR/evaluated" | tr -d ' ')" + printf 'evaluated_payload_non_x_bytes=%s\n' "$(tr -d 'x\n' < "$CASE_DIR/evaluated" | wc -c | tr -d ' ')" + } > "$FM_TEST_EVIDENCE_DIR/launch-retry-transcript.txt" + fi + pass "fm-spawn retries the recorded canonical-buffer truncation and stages only bounded lines" +} + +test_refuses_to_report_success_when_every_delivery_check_is_truncated() { + local id rec out rc + id='launch-refuse-z2' + rec=$(make_case refuse "$id") + read_case "$rec" + out=$(FM_FAKE_TRUNCATE_ATTEMPTS=3 FM_SPAWN_LAUNCH_DELIVERY_RETRIES=2 \ + run_spawn "$CASE_DIR" "$HOME_DIR" "$PROJ_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$id" "$(long_raw_launch)") || rc=$? + [ "${rc:-0}" -ne 0 ] || fail "fm-spawn reported success after every staged launch was truncated" + assert_contains "$out" "launch command delivery could not be verified after 2 attempts" \ + "failed delivery did not name the bounded verification refusal" + assert_grep 'failed: launch command delivery could not be verified after 2 attempts' \ + "$HOME_DIR/state/$id.status" "failed delivery was not recorded for supervision" + [ ! -s "$CASE_DIR/evaluated" ] || fail "failed launch verification still evaluated the staged command" + if [ -n "${FM_TEST_EVIDENCE_DIR:-}" ]; then + mkdir -p "$FM_TEST_EVIDENCE_DIR" + { + printf '$ fm-spawn.sh %s <1,800-byte raw launch>\n' "$id" + printf '%s\n' "$out" + printf '\nPersisted supervision status:\n' + cat "$HOME_DIR/state/$id.status" + printf '\nObserved shell output across delivery attempts:\n' + cat "$CASE_DIR/screen-history" + printf '\nRefusal facts:\n' + printf 'attempts=%s\n' "$(cat "$CASE_DIR/attempts")" + printf 'evaluated_payload_bytes=%s\n' "$(wc -c < "$CASE_DIR/evaluated" | tr -d ' ')" + } > "$FM_TEST_EVIDENCE_DIR/launch-refusal-transcript.txt" + fi + pass "fm-spawn fails loudly when bounded launch verification never succeeds" +} + +test_long_task_id_keeps_verification_markers_on_one_pane_line() { + local id rec out rc + id=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + rec=$(make_case long-id "$id") + read_case "$rec" + out=$(run_spawn "$CASE_DIR" "$HOME_DIR" "$PROJ_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$id" "$(long_raw_launch)") + rc=$? + expect_code 0 "$rc" "64-character task ID should not wrap launch verification markers"$'\n'"$out" + assert_contains "$out" "spawned $id" "long task ID did not complete verified launch delivery" + pass "fm-spawn keeps verification markers bounded independently of task ID length" +} + +test_retries_the_recorded_truncation_signature_and_never_types_a_long_line +test_refuses_to_report_success_when_every_delivery_check_is_truncated +test_long_task_id_keeps_verification_markers_on_one_pane_line + +echo "# all fm-spawn-launch-delivery tests passed" diff --git a/tests/secondmate-helpers.sh b/tests/secondmate-helpers.sh index b80a432fcb9..355610bc455 100644 --- a/tests/secondmate-helpers.sh +++ b/tests/secondmate-helpers.sh @@ -24,10 +24,37 @@ make_fake_tmux() { #!/usr/bin/env bash set -u case "${1:-}" in - has-session|new-session|new-window|send-keys|kill-window) + has-session|new-session|new-window|kill-window) printf '%s\n' "$*" >> "$FM_FAKE_TMUX_LOG" exit 0 ;; + send-keys) + printf '%s\n' "$*" >> "$FM_FAKE_TMUX_LOG" + text=${4:-} + staged="$FM_FAKE_TMUX_CAPTURE.staged" + case "$text" in + *"__FM_SPAWN_READY_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$FM_FAKE_TMUX_CAPTURE" + ;; + "FM_SPAWN_LAUNCH=''" ) + : > "$staged" + ;; + FM_SPAWN_LAUNCH=*) + rebuilt=$(FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") + printf '%s' "$rebuilt" > "$staged" + ;; + *"__FM_SPAWN_LAUNCH_OK_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\n' "$token" > "$FM_FAKE_TMUX_CAPTURE" + ;; + 'eval "$FM_SPAWN_LAUNCH"') + cat "$staged" >> "$FM_FAKE_TMUX_LOG" + printf '\n' >> "$FM_FAKE_TMUX_LOG" + ;; + esac + exit 0 + ;; list-windows) if [ -n "${FM_FAKE_TMUX_WINDOW:-}" ]; then printf '%s\n' "$FM_FAKE_TMUX_WINDOW" From 81cd72f816631c55b28bf17323fe86e177315158 Mon Sep 17 00:00:00 2001 From: quinnbot-ai Date: Sat, 1 Aug 2026 16:46:53 -0700 Subject: [PATCH 21/70] feat(bin): validation-lane scheduler with reservation-bound release (#72) * test: stabilize tmux teardown conformance baseline (#1209) * fix(test): pin teardown tmux baseline to historical kill selectors merge-base HEAD main collapses to HEAD after the exact-selector change lands on the default branch, so the old teardown fixture was accidentally exercising current exact targets. Resolve a content-historical permissive tmux adapter from first-parent history and force that post-squash topology inside the conformance case so main and feature branches keep the same old-vs-new contract. * no-mistakes(lint): Suppress intentional literal-pattern ShellCheck warnings * fix(bin): normalize relative durable paths (#1256) * fix(bin): handle dash-leading harness process names (#2) * fix: handle dash-leading harness process names * no-mistakes(review): Make dash-leading harness regression hermetic * fix: preserve secondmate reply routes across relative homes Resolve relative home, data, and state inputs before durable charter generation, and fail when caller-relative directories cannot be resolved. Use absolute paths at the related spawn, AFK daemon, and X-mode cross-process handoffs so later processes cannot reinterpret them from another working directory. * no-mistakes(review): Preserve absolute overrides and normalize relative durable paths * no-mistakes(review): Normalize relative home before deriving durable paths * no-mistakes(document): Document relative durable-path normalization * no-mistakes(review): Captain: Ignore inherited CDPATH during relative path normalization * no-mistakes(lint): Fix empty CDPATH assignments for ShellCheck * refactor(skills): make Bearings chat-only by default (#1136) * Add internal status skill * no-mistakes(document): register /status skill in documentation-audiences inventory * no-mistakes(lint): replace grep|wc -l with grep -c in status skill test * test: silence literal status skill patterns * Refactor bearings default to chat-only --------- Co-authored-by: Kun Chen <3233006+kunchenguid@users.noreply.github.com> * test: replace source assertions with behavioral coverage (#1282) * test: remove source-content assertions * no-mistakes(review): Replace source assertions with runtime behavior coverage * no-mistakes(review): Isolate Kimi task temp runtime coverage * no-mistakes(document): Refresh test cleanup documentation * no-mistakes: apply CI fixes * fix(gitignore): ignore config/ as a directory, not by exact filename (#1261) A name-by-name list of config/ entries silently stops ignoring any new or home-local file placed there, which makes the working tree read as dirty and blocks guarded sync paths that refuse to touch a dirty home. AGENTS.md already documents config/ as captain-private and gitignored as a category; this makes .gitignore match that contract. * fix(tests): replace source-content .gitignore assertion with behavioral coverage (#1304) The second assertion in fm-gitignore-config.test.sh (added by #1261) greps .gitignore for a specific spelling of the config/ ignore pattern. It fails on a semantically equivalent pattern like config/** and does not prove Git actually ignores anything, per the completed source-content-test audit. Replace it with a real git check-ignore control test on a generated unrelated path, and strengthen the existing directory-coverage test with generated unpredictable direct and nested config/ paths. * feat: bound and consolidate startup memory during stow (#1303) * Add bounded startup memory curation * no-mistakes(review): Record reproducible stow verification evidence * no-mistakes(review): Validate inherited secondmate stow evidence * no-mistakes(document): Document editable startup-memory budget propagation * fix(herdr): place workers in the launching workspace (#1328) * fix(herdr): place workers in the launching agent's exact workspace Herdr enforces no workspace-label uniqueness, and spawn resolved its container by taking the FIRST workspace whose label matched the home label. With two workspaces both labeled "firstmate", a worker launched from the second one was created in the first, so it appeared in a different space than the Firstmate the captain was watching. Reproduced end to end on Herdr 0.7.5 protocol 17 by running the real bin/fm-spawn.sh inside a launcher pane in the second "firstmate" workspace: the worker landed in w1 while its launcher was in w2, with an unrelated third workspace focused throughout, which also rules out any dependence on the focused workspace. Placement now binds to the launching process's own Herdr identity. Herdr injects HERDR_PANE_ID, HERDR_SESSION, and HERDR_SOCKET_PATH into every process it manages a pane for, and fm_backend_herdr_launcher_identity resolves that pane's current owning tab and workspace live from Herdr, cross-checking the pane against its tab and confirming the workspace exists exactly once in the session. The injected HERDR_TAB_ID and HERDR_WORKSPACE_ID are creation-time snapshots and are deliberately not read as current identity. Labels are no longer placement authority. A claimed parent identity that is unreadable, contradictory, stale, or from another named session or Herdr server stops the spawn before any worker endpoint exists, rather than degrading to a label search. A launcher with no Herdr ancestry has no workspace to inherit and keeps the per-home labeled container, which must now resolve to exactly one workspace; two same-labeled candidates refuse instead of adopting either. A --secondmate launch keeps standing up that home's own workspace by design. With presentation spaces enabled, the projected child is created and bound under that same exact parent and anchors its ordering on it, so a duplicated home label no longer makes the layout ambiguous. Projection, focus restoration, restart binding, and quarantine rules are unchanged, and children are never collapsed into the parent. tmux, Zellij, cmux, Orca, and the away-mode daemon terminal were each inspected and are not affected: none resolves a container by searching mutable labels. tests/fm-backend-herdr-launcher-workspace-e2e.test.sh drives the real spawn and teardown against an isolated Herdr lab, with its headline case running fm-spawn.sh inside a real Herdr pane so the identity comes from Herdr's own injection. The refusal matrix and the ordering anchor are covered deterministically in tests/fm-backend-herdr.test.sh. Eight existing real-Herdr suites inherited the developer terminal's own Herdr pane into their isolated lab sessions, which the new cross-session check correctly refuses. tests/herdr-test-safety.sh now owns herdr_forget_inherited_pane and those suites call it, so what they assert no longer depends on where they were launched from. Two unrelated fixes found along the way. tests/fm-secondmate-harness.test.sh had the same class of environment leak through CLAUDECODE, which outranks PI_CODING_AGENT in bin/fm-harness.sh and made its pi-signed ancestry case resolve "claude" whenever the suite ran inside Claude Code. And fm-spawn.sh's usage() printed a fixed line range that had already been truncating its own help mid-sentence. * no-mistakes(review): Enforce exact Herdr launcher and projection identity * no-mistakes(document): Document exact Herdr launcher workspace placement * fix(calm): refine Calm working boat animation (#1339) * feat(calm): replace Pi's working row with an animated ship while Calm is on While Calm is active and one logical agent run is under way, Calm now hides Pi's built-in working row and renders a small two-row SSHHIP-derived boat in its place. When Calm is off, Pi's stock working row is left untouched. The presentation uses only public Pi extension API: setWorkingVisible(false) plus a temporary setWidget() component whose render(width) owns the responsive geometry and whose timer requests a TUI render. Visibility follows agent_start through agent_settled, so the boat does not flicker between tool calls, automatic continuations, retries, or compaction inside the same run, and settle, abort, and failure all reach the same cleanup. fm-calm.ts stays the sole owner of the presentation choice and the only caller of setWorkingVisible(); the new lib owns the sprite geometry and widget. * no-mistakes(review): Guarded Calm-off lifecycle visibility writes; focused tests pass * no-mistakes(test): Fixed Calm E2E wait to include tmux scrollback * no-mistakes(document): Document Calm working boat behavior * no-mistakes: apply CI fixes * feat(calm): slow the Calm boat, animate blue water, and make the sail directional The boat now moves one column every 880ms while a bounded fixed-cell water phase advances every 220ms, so the water ripples several times between boat steps and the presentation reads as calm. One scheduler drives both clocks and disposing the widget stops them together; ticks rather than wall-clock timestamps drive every state change, so tests seek animation time exactly. Colors are standard ANSI foreground codes instead of theme lookups: blue for every water cell and yellow for the complete boat, each run closed with a default-foreground reset so nothing bleeds into padding or later frames. ANSI bytes never enter geometry, so visible width stays exact. The mainsail is directional and trails aft of the mast: <| travelling right and |> travelling left. Direction reverses the moment the boat lands on an endpoint, so the endpoint frame already shows the new heading and no frame at or after a bounce shows the previous sail. * test(calm): wait for the Ctrl+O expansion redraw this block asserts * docs(calm): record the revised working-presentation verification evidence * no-mistakes(document): Fix Calm feasibility document EOF whitespace * fix(dispatch): preflight candidate auth before quota escalation (#1349) * fix(dispatch): scope candidate authentication to its own surface A locally expired timestamp in one credential store was reported to the captain as a sign-out, including for dispatch candidates that never read that store. A `harness=pi, model=xai/grok-*` candidate authenticates through Pi's own xAI credential, but the only Grok quota reading available was gated on the standalone Grok CLI's separate token, whose expiry clock drifts independently. The always-loaded intake rule then turned that unreadable quota into a mandatory captain escalation. Add `bin/fm-auth-preflight.sh` as the deterministic owner of the parts that must not depend on agent memory: it resolves a tuple's authentication surface from quota-axi's own emitted auth sources rather than from a harness or model name, so another harness's CLI can never gate a candidate that does not use it. A vendor CLI is launched only when the tuple's own harness owns the credential store under test and a non-destructive discovery command is registered for it, which today is `grok models` alone. That probe runs at most once with stdin closed and a hard timeout, reads its verdict from the first stdout line because the command exits 0 either way, treats unrecognized output as indeterminate, and never invokes login, logout, or the interactive TUI. Quota is read at most twice, and unknown headroom never makes a candidate ineligible on its own. Update the dispatch procedure to match: usable authentication with unmeasurable headroom stays eligible at lower preference with the unknown disclosed, and stop-and-report is reserved for unresolved authentication, an unresolved relationship, or malformed configuration. Record that Grok's `credits.remaining` is a prepaid balance rather than window headroom. Gate quota-axi at 0.1.16 in bootstrap, the first build reporting per-credential auth sources. A stale install previously passed the presence check silently, which is why a fix published two days earlier was still not in effect. Replace the orphaned quota-array-dispatch fixtures, which encoded a `provider: "xai"` shape the tool never emits and had no consumer, with fixtures shaped like real 0.1.16 output that the new suite drives the script against. The suite asserts the verdict and, separately, which vendor CLIs were launched, so a Pi/xAI candidate reaching the Grok CLI fails. Map `tests/fixtures/` to its consuming suite so a fixture change selects the right tests instead of refusing. * refactor(bootstrap): give the quota-axi floor one owner The floor was stated twice - once in bootstrap's gate and once inline in the auth preflight - so bumping it needed two edits that could drift. Move it to bin/fm-quota-axi-lib.sh alongside its rationale, matching the existing tasks-axi library, and derive the comparison from the constant so the number appears exactly once. Bootstrap turns a failing check into the operator diagnostic; the preflight refuses to emit an unscoped verdict. Map the new library to both consuming suites so a bump re-runs them, and record that any usable source means the surface authenticates. * no-mistakes(review): Captain: bound quota checks and removed Python dependency * no-mistakes(review): Captain: enforce conservative headroom and exact preflight retry * no-mistakes(review): Captain: preserve OpenCode eligibility without auth-surface guessing * no-mistakes(review): Captain: reject malformed OpenCode model relationships * no-mistakes(review): Captain: exempt verified unmodeled tuples from intake escalation * no-mistakes(document): Updated dispatch authentication documentation * no-mistakes: apply CI fixes * feat(x-mode): reconcile promised public replies deterministically (#1350) * feat(x-mode): reconcile promised public replies deterministically A promised final reply in an X or Discord thread was only kept while the primary remembered it. Compaction or restart erased that memory, so a typed public-followup obligation could sit at pending-work after its PR merged and the original thread never got its reply. Make the promise durable state instead: - bin/fm-public-followup-emit.sh reports a typed terminal work result (source home, work id, generation, outcome, safe deliverables, bounded public-safe text) into the owning home's private inbox. The event id is derived from that identity tuple, so duplicate reports and restart replay converge with no coordination, and nothing ever parses a free-form done: sentence. - bin/fm-public-followup.sh registers a commitment, reconciles events through tasks-axi public-followup, and runs the idempotent delivery sequence (begin-delivery with the payload hash, post, record the posted receipt or a typed error) against the stored platform and opaque thread binding. A delivery interrupted between post and receipt refuses rather than risk a second public reply. - Session start surfaces unresolved commitments from disk, the existing relay poll surfaces a new terminal-result set once, and teardown refuses while this home still owes a public reply for that exact work. tasks-axi public-followup remains the only owner of the obligation state machine, state/x-context/ the only owner of the private request context, and fm-x-reply.sh the only thing that posts. Its new optional --receipt-file is the one addition there, so a caller can record how many messages were sent. A home that never opted into the myfirstmate relay gates out on a single [ -f "$FM_HOME/.env" ] test: no tasks-axi call, no backlog or context scan, no output, and no artifact. Evidence in docs/verification/public-followup.md. * no-mistakes(review): Hardened public-followup reconciliation and ownership guards * no-mistakes(review): Hardened typed terminal cleanup and receipt reconciliation * no-mistakes(review): Automated typed-delivery cleanup and strict backlog validation * no-mistakes(review): Fail-closed parent resolution and registration-safe delivery * no-mistakes(review): Harden relay gating and validate secondmate bindings * no-mistakes(review): Use owner-aware single-gate teardown protection * no-mistakes(document): Correct public-followup documentation drift * no-mistakes(lint): Quote done literals to fix ShellCheck warnings * no-mistakes: apply CI fixes * feat(bin): replace busy heuristics with semantic lifecycle state (#1327) * feat: add semantic busy-state contract owner and event writer One owner (bin/fm-busy-lib.sh) for the captain-approved semantic busy-state redesign: a per-task gen-bound record written only by bin/fm-busy-event.sh, per-harness trusted-source classification with explicit source attribution, busy/idle/unknown/dead semantics where missing, malformed, stale, or untrusted semantic data is unknown - never idle - and endpoint death is the only process-level override. The Grok-only rendered-tail fallback and the standalone-Kimi verification gate live behind the same classifier. * feat: arm busy-state at spawn and convert Pi to the semantic extension path fm-spawn arms the busy-state contract for converted adapters and seeds busy/fm-spawn (the launch brief is a submitted turn). The Pi/pi-signed per-task extension now reports agent_start -> busy and agent_settled -> idle confirmed by ctx.isIdle(), covering auto-retries, compaction retries, tool loops, and queued continuations, while turn_end stays a wake notification touch. Teardown removes the new record, gen sidecar, and lock. Live-verified on Pi 0.82.0: seed -> agent-start busy -> agent-settled idle with the marker still touched. * feat: convert OpenCode to the semantic session.status plugin path The per-task plugin (renamed .opencode/plugins/fm-busy-state.js) now classifies from OpenCode's semantic session.status events - busy and retry are active, idle is inactive - latched to the worker's own session so a subagent child session can never clear the worker's busy state. The session.idle marker touch stays a wake notification. Teardown removes both the new and the legacy plugin filenames. Live-verified on OpenCode 1.17.18 in a real TUI pane: seed -> session-busy -> session-status-idle. * feat: convert Claude to the full lifecycle hooks path The per-task settings.local.json now wires UserPromptSubmit -> busy and Stop, StopFailure, and SessionEnd -> idle, so API-error and shutdown turn ends can never strand a busy record; Stop keeps the turn-ended notification touch. A refused (stale-gen) event exits 0 and stays silent so Claude's own lifecycle is never broken. Live-verified on Claude Code 2.1.220: UserPromptSubmit fires for the argv launch prompt, Stop closes each turn, a mid-stream Escape interrupt fires no closing hook, and the firstmate-controlled idle/fm-interrupt clear resolves it. * feat: gate Codex busy state behind verified semantic sources The approved contract prefers Codex's app-server turn lifecycle with capability negotiation and sanctions its lifecycle hooks as the intermediate. Live probes on codex-cli 0.145.0 show neither is usable for a pane worker: the app-server daemon is unreachable for a TUI thread and refuses to start outside the managed standalone install, and firstmate-written project hooks never fired (interactive with directory trust granted, and exec, both with --dangerously-bypass-hook-trust) while global hooks fired in the same runs. Codex therefore classifies unknown codex-unverified behind an explicit probe rather than falling back to idle or footer text, and fm-spawn installs no unverified Codex wiring. * feat: gate standalone Kimi busy state on live verification Standalone Kimi has no installed binary here, so per the approved contract its semantic path stays guarded and it classifies unknown kimi-unverified rather than idle - and never from its locale-sensitive moon-phase spinner, which the redesign forbids inventing as a state source. The gate records the preferred source order (Wire prompt request lifetime, which brackets a turn and reports cancellation, then the documented hooks including Interrupt because Stop does not fire on interrupts) and the exact evidence required to open it. Arming without wiring would seed a busy record nothing could clear, so both land together behind the same gate. * feat: route busy consumers through the contract and drop the global OR The watcher, crew-state reader, and away-mode daemon now decide busy state through bin/fm-busy-lib.sh: only an exact busy verdict counts as working, and unknown never becomes working or a silent idle, so a crew whose semantic state is missing, malformed, stale, or unverified surfaces instead of being absorbed. Crew-state reports the producing source in its detail. The watcher's global OR regex default is gone; Grok keeps its isolated fallback inside the contract. The daemon's supervisor-pane reader stays rendered-text - that pane is not a recorded task - but is now scoped to firstmate's own detected harness instead of every vendor signature. Secondmate pending-reply observation is deliberately unchanged and documented as a delivery-confirmation signal, not task state. * docs: point busy-state documentation at the single contract owner Adds a maintainer-architecture section naming bin/fm-busy-lib.sh as the owner of what busy means, with per-adapter sources, the unknown-never-idle rule, the endpoint-death override, and the two rendered-text readers that deliberately stay outside the contract. Replaces the stale regex-first prose in architecture, tmux-backend, herdr-backend, and configuration; converts the harness-adapters per-harness rows from UI signatures to the semantic source each harness uses; and records the live verification evidence, including why Codex and standalone Kimi stay unknown. * fix: arm away-launch signal handlers before acquiring the lifecycle lock fm_afk_launch_main acquired its lock and only then installed the EXIT, INT, and TERM traps. A signal arriving in that window terminated the process by default action and left the lock directory behind, which blocks the next away-mode launch until the stale-owner reclaim path clears it. The release helper only removes a lock this process owns, so the handlers are now armed first. The accompanying test also killed the child whether or not the lock had appeared and sampled cleanup the instant wait returned; it now requires the lock, then allows a bounded settle, so it proves the guarantee instead of racing it. * test: align fleet, Kimi, lifecycle, and detection suites with the contract The fleet snapshot and wake-daemon lifecycle fixtures now prove a working crew through its own semantic busy-state record instead of rendered pane text, which is what those consumers read. The Kimi watcher test asserts the approved contract directly: a standalone Kimi task classifies unknown rather than matching its moon-phase spinner, while Grok's isolated fallback still classifies only Grok. The pi-signed detection cases clear ambient harness markers, fixing a pre-existing failure where the running session's own CLAUDECODE outranked the fixture's marker. * fix: stop teardown from deleting a project's own Codex hooks file An intermediate revision wired Codex through a firstmate-written /.codex/hooks.json, and teardown removed it alongside the other generated wiring. The Codex wiring was dropped when its probes came back unverified, so that removal now targets a file firstmate never creates - and a project may legitimately track its own .codex/hooks.json, which teardown would then delete from a pooled worktree. * fix: keep busy-record parsing from disturbing its sourcing caller The record parser split fields with set -- under a temporary noglob, which clobbers a sourcing caller's positional parameters and restores glob expansion even when the caller had disabled it. The watcher, the daemon, and the crew-state reader all source this library, so it now reads fields with read -a, which never globs and never touches caller state. * docs: state exactly which Claude hook paths were reproduced live The busy-state record listed all four wired Claude hooks in the source column, which could read as a claim that every one fired during the pass. UserPromptSubmit and Stop did; StopFailure and SessionEnd are wired from hook names confirmed present in the installed binary, but the abnormal turn ends they cover were not reproduced. * test: let reset_fakes own the crew-state busy-text fixture lifecycle The Grok fallback case set FM_FAKE_BUSY_TEXT and cleared it inline, so the variable's lifetime was owned by one test rather than by the shared reset that every other fake already uses. * no-mistakes(review): Fix semantic busy-state lifecycle races * no-mistakes(review): Make busy-state retirement idempotent * no-mistakes(review): Enforce semantic state boundaries for status and injection * no-mistakes(review): Restore harness-scoped away-mode busy guard * no-mistakes(document): Refresh semantic busy-state documentation * no-mistakes: apply CI fixes * fix: preserve Calm boat continuity across working periods (#1356) * fix(calm): resume working boat from frozen column across runs Keep one extension-owned boat animation for the Pi session so settling freezes column and direction, the next working period resumes there without hidden-time jumps, and only a fresh session resets to the left edge. * no-mistakes(review): Freeze Calm boat from last rendered state * no-mistakes(document): Document Calm boat continuity contract * fix: restore evidence-based dispatch eligibility (#1358) * fix(dispatch): judge candidate provider relations instead of rejecting them Firstmate deterministically dropped supported Pi candidates in the openai-codex family. bin/fm-auth-preflight.sh resolved a harness=pi tuple's credential surface by constructing the source id `pi:`, so `pi + openai-codex/gpt-5.6-terra` looked for a `pi:openai-codex` source. That source does not exist, because Pi's Codex family authenticates through the Codex store quota-axi already lists as `auth-json`/`cli-rpc`. The tuple returned `eligible=no reason=surface-unresolved` while the Pi catalog listed the model and the Codex provider reported fresh, usable credentials with 64 effective percent remaining on its all-model scope. The prefix construction was only ever valid where Pi holds its own credential (`pi:xai`, `pi:kimi-coding`), which is why every previously configured Pi tuple resolved and the defect stayed hidden until a Codex-family Pi model was configured. Retire dispatch eligibility from deterministic shell. The dispatching first mate now establishes model support and provider family from each harness's authoritative catalog, applies quota at the granularity the vendor supplies, and shows that reasoning. Provider-level and all-model evidence bounds every model established in that family; a named-model window bounds only its own model. Missing model-level quota, a missing auth source, unmeasurable headroom, and unmodeled authentication are disclosed uncertainty. Only concrete contradictory evidence blocks a candidate. Replace the preflight with bin/fm-vendor-auth-probe.sh, which keeps the captain's approved bounded probe envelope without any routing knowledge: it takes no harness, model, or provider, reads no quota, renders no verdict, and holds only a fixed-argv safety allowlist. Its behavior suite proves the absent identity surface, the untouched quota, the uniform exit status, the fixed argv with stdin closed, and a real bound even when the configured bound is zero. Also fixed along the way: a zero FM_*_TIMEOUT silently removed the hard bound, the pinned Grok version had drifted to 0.2.117, and --changed selection refused outright on any deleted bin/ script. AGENTS.md section 4 and quota-array-dispatch own the corrected policy, harness-adapters gets the catalog-responsibility correction, and docs/verification/dispatch-auth.md records the 2026-07-30 evidence on Pi 0.82.0, quota-axi 0.1.16, and grok 0.2.117. * no-mistakes(review): Reject all-zero vendor probe timeouts * docs: define captain instruction precedence (#1362) * docs: add captain-authorized inherent red-check merge exception Keep the default red-PR ban and own one always-loaded exception in the merge-authority section: captain-explicit PR or bounded batch plus exact check, only when the failure is inherent to the selected delivery path. Yolo cannot activate it; final head and the full current check suite must be verified; other substantive failures remain non-waivable. * docs: replace narrow red-check exception with captain precedence Supersede the inherent failing-check merge exception with one always-loaded Firstmate-local rule: a current explicit concrete captain instruction overrides a conflicting Firstmate-written standing rule only within exact scope, never above platform/system/developer instructions. Keep the ordinary red-PR default and yolo boundary; point section 7 at the section 1 owner. * docs: define validation supersession sequence (#1407) * fix: give validation-time captain overrides a supersession sequence The Validate section let a captain instruction that completely invalidates the work being validated keep the same task and worker, but never said how: the adjacent rule flatly bans hand-editing, committing, aborting, or restarting during an active run with no carve-out, so a worker facing full invalidation had no sanctioned path forward. Add the missing sequence: cancel through no-mistakes axi's abort command, confirm the run has stopped through axi status, recover branch ownership through axi sync's guarded recovery, only then replace the obsolete work, and validate once against the final head. The existing ban on hand-editing an active run now cross-references this sequence instead of contradicting it. * no-mistakes(review): Make validation custody recovery conditional * no-mistakes(document): Clarify validation supersession abort exception * fix: keep obsolete pipeline commits out of the superseded deliverable The review-applied fix made custody recovery conditional on branch_sync.next_action.code, but left an open gap: recovering custody settles who owns the branch, not what content ships. As written, a worker could recover an obsolete run's branch and build the replacement on top of its now-irrelevant commits instead of from the correct pre-invalidation base, carrying obsolete content into the final deliverable. Make that explicit: custody recovery settles ownership, not content, so the worker replaces obsolete work from the correct base and keeps the obsolete run's commits out of what gets validated and shipped. * no-mistakes(test): Restore minimal pre-invalidation replacement instruction * fix: dedupe redundant "replace the obsolete work" restatement Line 309 already says the worker replaces the obsolete work from the correct pre-invalidation base, excluding the obsolete commits. The closing sentence restated "replace the obsolete work" again before gating the final validation run, layering the same fact twice instead of stating it once. Trim the closing sentence to just the ownership gate and the single-run-against-final-head requirement it uniquely adds. * fix: bind backend overrides to exact-task authority (#1413) * fix: bind explicit --backend to exact-task authority A Herdr-backed second mate carried a prior one-task --backend tmux exception forward by analogy, so its child landed in tmux and never appeared under the second mate in Herdr. Runtime detection was correct; the authority surface was not. docs/configuration.md now owns that an explicit --backend is authorized only for that exact task. AGENTS.md and fm-spawn help point there. * no-mistakes(document): Consolidate backend selection authorization documentation * fix(herdr): prevent focus flashes during projected workspace cleanup (#1229) * fix: remove projected workspaces through Herdr's focus-preserving pane-death path Herdr 0.7.5's explicit close of a workspace-emptying last pane moves the attached client's focus to a neighbor workspace, flashing the captain's whole window and routing in-flight keystrokes to the wrong pane until Firstmate's exact-tab restore masks it 56-197 ms later. Teardown and cleanup now plan a workspace-emptying close as a focus-safe removal: verify the close empties the workspace, reposition the doomed workspace behind the focused one through the verified workspace.move transport when it sits before a non-last focused workspace, prove the pane holds one lone idle shell, and end that shell so Herdr removes the emptied workspace through its focus-preserving pane-death path. Any ambiguity or failure falls back to the plain close behind the existing restore backstop, and fm_backend_herdr_kill applies the same plan for non-projected removals. Two conditions proven on real hardware are encoded in the adapter: BSD ps reports a login shell's comm as "-zsh", and an idle shell transiently hosts a prompt helper right after a workspace.move relayout, absorbed by a bounded strict-sample settle window in the idle-shell proof, now the single owner shared with session-start cleanup. An isolated-lab regression reproduces the raw steal on 0.7.5 and proves the plan removes a doomed workspace with zero wrong-focus samples and no corrective focus; unit fixtures cover the position, edge, ambiguity, move and kill failure, escalation, and transient-helper cases. Upstream fixes (#1877 explicit close, #1912 pane death) are merged but unreleased; once released the plan degrades to a harmless reorder-then-remove. * no-mistakes(review): Confirm pane death from structured not-found responses * no-mistakes(review): Serialize Herdr kills and sample focus continuously * no-mistakes(review): Synchronize Herdr focus evidence output * no-mistakes(review): Refuse unlocked Herdr pane closes * no-mistakes(document): Correct Herdr focus-safety documentation * no-mistakes: apply CI fixes * fix: never erase a Herdr task's records while its pane survives a refused close A transient presentation-lock contention could produce a completed teardown while the exact Herdr pane stayed alive as an unowned restored shell: the kill refused the unlocked close (correctly), returned success, the warning was suppressed, and cleanup erased the task's status, turn-end, and metadata records after the isolated copy had already been returned. Teardown now acquires the named-session presentation lock before anything destructive: a contended lock refuses up front while the isolated copy, the task branch, every durable record, and the endpoint are all intact for a plain rerun, and the projected and flat close paths both run under that one held lock instead of acquiring their own. Durable records are erased only once the exact pane is confirmed gone through its structured presence; a refused, skipped, or failed close retains every record with a visible, retryable error, and after a skipped close (unresolvable lock path) only a structured pane_not_found counts as gone - unknown never does. The teardown regression drives a live contending lock holder end to end: the refusal touches nothing (no worktree return, no branch drop, no close attempt), and the retry after release returns the copy, closes the pane under the lock, and removes the records. The unconfirmed projected close now refuses with records retained, and the structured-presence gate has a strict/default unit matrix. * no-mistakes(review): Require structured pane-not-found before Herdr record removal * no-mistakes(document): Correct Herdr record-retention verification date * fix: refuse ambiguity, revalidate SIGKILL ownership, and roll back failed removals Three accepted-contract corrections from the post-CI personal review of the Herdr keep-spaces focus-flash mitigation. Ambiguous endpoint identity no longer counts as a confirmed-gone pane: a missing or malformed target refuses record removal in the structured presence gate, and teardown treats missing confirmation machinery as a refusal instead of skipping the gate, so only an exact structured pane_not_found ever erases durable task records. The pane-death SIGKILL escalation re-reads the exact pane's process information and refuses to signal unless the same shell pid still passes the strict bare-idle ownership proof, so a pid that exited and was reused by an unrelated process is never signaled; the refused escalation falls back to the plain close with the unrelated process untouched. A reposition whose removal is not confirmed no longer outlives the attempt: the emptying-close plan records the verified pre-move order and original index whenever it invokes the mover, and both close owners restore the exact original workspace order through a second verified move, under the same held session lock, before reporting the close as failed. Each defect was reproduced first: the unit matrix documented malformed identity as gone, the PID-reuse regression showed SIGKILL reaching a disowned pid, and the rollback regression showed a single unrestored move. Teardown-level regressions cover unparseable presence retention alongside the strict identity matrix. * no-mistakes(review): Require confirmed Herdr removal and resolvable teardown locks * no-mistakes(review): Enforce structured Herdr closes and teardown preflight * no-mistakes(review): Preflight explicit Herdr close confirmation helper * no-mistakes(document): Document Herdr rollback failure semantics * no-mistakes(review): Captain, harden recursive Herdr teardown safety * no-mistakes(document): Document recursive Herdr teardown evidence * fix: retain nested secondmate home when a recursive child cleanup fails Captain-decided Option A correction for nm-askuser-flash-r6, found during complete-diff rereview of the merged head. cleanup_firstmate_home_children's recursive secondmate branch called itself for a nested child's home without checking the result, then unconditionally removed that home right after. remove_firstmate_home ends in an unconditional recursive delete with no check for leftover records, so a nested secondmate whose own Herdr grandchild failed its confirmed-gone check would have its entire home - retained grandchild records included - erased by the very next line. Guard the recursive call the same way every other fallible call in this function already is: || return 1, skipping remove_firstmate_home and leaving the nested home and its records for a safe rerun. Empirically, fm-teardown.sh's set -eu already halted the script on the prior unguarded call before reaching removal (verified by hand with the guard reverted, under both this session's bash and stock macOS bash 3.2) - the reachable behavior was already correct. The explicit guard is still applied exactly as decided: it matches every sibling call site in the function, and it stops the correctness of this path depending on errexit's well-known fragility under refactors (a wrapping if/&&, or a future subshell) rather than on an explicit check. Adds a teardown-level regression building on the existing direct-child Herdr fixtures: a top-level secondmate contains a nested secondmate, whose own Herdr child's close goes unconfirmed. Proves through the public fm-teardown.sh interface that the nested home, the nested secondmate's own record, and the grandchild's metadata and status all survive, and that the top-level secondmate's record survives too. * no-mistakes(document): Document nested Herdr teardown retention * fix: prioritize completion runway in quota-aware dispatch (#1431) * fix(dispatch): prioritize quota completion runway * no-mistakes(document): Document completion-aware quota runway selection * fix(bin): preserve full task contract in no-mistakes intent (#1447) * Preserve task contract in no-mistakes intent * no-mistakes(review): Preserve complete current task contract in no-mistakes intent * fix(bin): parse punctuated secondmate registry entries safely (#1452) * fix: centralize secondmate registry parsing * no-mistakes(review): Centralize secondmate registry binding validation * no-mistakes(review): Harden registry EOF and symlink validation * no-mistakes(review): Reject unreadable registries before parsing * no-mistakes(document): Document punctuation-safe secondmate registry validation * no-mistakes: apply CI fixes * feat: schedule validation lane releases * no-mistakes(review): Bind validation releases to reservations and serialize delivery * no-mistakes(review): Bind validation completion to reservation-scoped run starts * no-mistakes(review): Fail closed on unavailable reservation evidence * no-mistakes(review): Retain holders on unavailable terminal evidence * no-mistakes(document): Document fail-closed validation lane reservations * no-mistakes(lint): Fix ShellCheck empty assignments and terminal-state comparisons * no-mistakes(review): Harden validation reservation evidence handling * no-mistakes(review): Harden validation lane event and run evidence * no-mistakes(review): Surface and retry validation lane check failures * no-mistakes(document): Clarify fail-closed validation lane evidence * test: mock verified Grok launch delivery * test: mock verified tmux launch delivery * test: mock verified Herdr launch delivery --------- Co-authored-by: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Co-authored-by: AG Co-authored-by: deeto15 <92119640+deeto15@users.noreply.github.com> Co-authored-by: Christopher McKay <101884182+karotkriss@users.noreply.github.com> Co-authored-by: QuinnBot --- .agents/skills/afk/SKILL.md | 9 +- .agents/skills/bootstrap-diagnostics/SKILL.md | 3 +- .../firstmate-coding-guidelines/SKILL.md | 4 +- .agents/skills/fmx-respond/SKILL.md | 47 +- .agents/skills/harness-adapters/SKILL.md | 31 +- .agents/skills/quota-array-dispatch/SKILL.md | 96 +- .../skills/secondmate-provisioning/SKILL.md | 7 +- .agents/skills/stow/SKILL.md | 104 +- .pi/extensions/fm-calm.ts | 68 +- .pi/extensions/lib/fm-calm-working-ship.ts | 243 ++++ AGENTS.md | 54 +- CONTRIBUTING.md | 2 +- README.md | 4 +- bin/backends/herdr.sh | 904 ++++++++++-- bin/fm-afk-launch.sh | 7 +- bin/fm-backlog-handoff.sh | 14 +- bin/fm-bootstrap.sh | 71 +- bin/fm-brief.sh | 1 + bin/fm-busy-event.sh | 216 +++ bin/fm-busy-lib.sh | 376 +++++ bin/fm-config-inherit-lib.sh | 49 +- bin/fm-crew-state.sh | 232 +++- bin/fm-ff-lib.sh | 16 +- bin/fm-fleet-snapshot.sh | 2 +- bin/fm-herdr-session-cleanup.sh | 52 +- bin/fm-home-seed.sh | 106 +- bin/fm-pending-reply-lib.sh | 16 + bin/fm-public-followup-emit.sh | 260 ++++ bin/fm-public-followup-lib.sh | 247 ++++ bin/fm-public-followup.sh | 906 ++++++++++++ bin/fm-quota-axi-lib.sh | 52 + bin/fm-secondmate-registry-lib.sh | 211 +++ bin/fm-send.sh | 21 + bin/fm-session-start.sh | 19 + bin/fm-spawn.sh | 227 ++- bin/fm-startup-memory-budget-lib.sh | 224 +++ bin/fm-startup-memory-budget.sh | 94 ++ bin/fm-supervise-daemon.sh | 81 +- bin/fm-teardown.sh | 374 ++++- bin/fm-test-isolation-proof.sh | 3 +- bin/fm-test-run.sh | 41 +- bin/fm-tmux-lib.sh | 12 +- bin/fm-update.sh | 10 +- bin/fm-validation-lane.sh | 522 +++++++ bin/fm-vendor-auth-probe.sh | 200 +++ bin/fm-watch.sh | 157 ++- bin/fm-x-followup.sh | 22 +- bin/fm-x-poll.sh | 29 +- bin/fm-x-reply.sh | 41 +- docs/architecture.md | 65 +- docs/calm-mode-feasibility.md | 147 +- docs/calm.md | 13 +- docs/cmux-backend.md | 3 +- docs/configuration.md | 60 +- docs/documentation-audiences.json | 12 + docs/examples/crew-dispatch.json | 2 +- docs/herdr-backend.md | 53 +- docs/orca-backend.md | 3 +- docs/scripts.md | 12 +- docs/tmux-backend.md | 8 +- docs/verification/dispatch-auth.md | 178 +++ docs/verification/public-followup.md | 102 ++ docs/verification/runtime-backends.md | 116 +- docs/verification/stow-memory.md | 217 +++ docs/verification/supervision.md | 34 + docs/zellij-backend.md | 3 +- .../fixtures/quota-array-dispatch/cases.json | 394 ------ .../quota-array-dispatch/schema-v3-shape.json | 103 -- tests/fm-afk-inject-herdr-e2e.test.sh | 5 + tests/fm-afk-launch.test.sh | 15 +- tests/fm-backend-autodetect-smoke.test.sh | 8 + .../fm-backend-herdr-eventwait-smoke.test.sh | 5 + .../fm-backend-herdr-focus-flash-e2e.test.sh | 205 +++ ...ckend-herdr-launcher-workspace-e2e.test.sh | 429 ++++++ .../fm-backend-herdr-presentation-e2e.test.sh | 48 +- .../fm-backend-herdr-prune-safety-e2e.test.sh | 5 + .../fm-backend-herdr-respawn-idem-e2e.test.sh | 5 + tests/fm-backend-herdr-smoke.test.sh | 5 + ...ckend-herdr-workspace-per-home-e2e.test.sh | 5 + tests/fm-backend-herdr.test.sh | 1130 ++++++++++++++- tests/fm-backend.test.sh | 2 +- tests/fm-backlog-handoff.test.sh | 6 +- tests/fm-bearings-snapshot.test.sh | 58 +- tests/fm-bootstrap.test.sh | 43 + tests/fm-brief.test.sh | 14 + tests/fm-busy-adapter-wiring.test.sh | 372 +++++ tests/fm-busy-state.test.sh | 380 ++++++ tests/fm-calm-pi-extension.test.sh | 1211 ++++++++++++++++- tests/fm-crew-state.test.sh | 352 ++++- tests/fm-daemon.test.sh | 90 +- tests/fm-direct-lifecycle.test.sh | 27 +- tests/fm-fleet-snapshot-view.test.sh | 44 +- tests/fm-gate-refuse.test.sh | 24 +- tests/fm-gotmp.test.sh | 14 + tests/fm-grok-harness.test.sh | 38 +- tests/fm-herdr-session-cleanup-e2e.test.sh | 2 +- tests/fm-herdr-session-cleanup.test.sh | 34 +- tests/fm-kimi-harness.test.sh | 36 +- tests/fm-lint.test.sh | 64 +- tests/fm-pi-primary-live-e2e.test.sh | 13 +- tests/fm-pi-primary-types.test.sh | 1 + tests/fm-public-followup.test.sh | 1042 ++++++++++++++ .../fm-quota-array-dispatch-live-e2e.test.sh | 109 ++ tests/fm-secondmate-harness.test.sh | 110 +- tests/fm-secondmate-liveness.test.sh | 25 + tests/fm-secondmate-safety.test.sh | 207 +++ tests/fm-secondmate-sync.test.sh | 3 + tests/fm-send-settle.test.sh | 24 + tests/fm-session-start.test.sh | 70 +- tests/fm-spawn-dispatch-profile.test.sh | 12 + tests/fm-spawn-worktree-settle.test.sh | 23 +- tests/fm-startup-memory-budget.test.sh | 317 +++++ tests/fm-tangle-guard.test.sh | 48 +- tests/fm-teardown.test.sh | 468 ++++++- tests/fm-validation-lane.test.sh | 468 +++++++ tests/fm-vendor-auth-probe.test.sh | 395 ++++++ tests/fm-wake-daemon-lifecycle-e2e.test.sh | 16 +- tests/fm-watch-triage.test.sh | 13 + tests/herdr-test-safety.sh | 19 + 119 files changed, 14563 insertions(+), 1508 deletions(-) create mode 100644 .pi/extensions/lib/fm-calm-working-ship.ts create mode 100755 bin/fm-busy-event.sh create mode 100755 bin/fm-busy-lib.sh create mode 100755 bin/fm-public-followup-emit.sh create mode 100644 bin/fm-public-followup-lib.sh create mode 100755 bin/fm-public-followup.sh create mode 100644 bin/fm-quota-axi-lib.sh create mode 100644 bin/fm-secondmate-registry-lib.sh create mode 100644 bin/fm-startup-memory-budget-lib.sh create mode 100755 bin/fm-startup-memory-budget.sh create mode 100755 bin/fm-validation-lane.sh create mode 100755 bin/fm-vendor-auth-probe.sh create mode 100644 docs/verification/dispatch-auth.md create mode 100644 docs/verification/public-followup.md create mode 100644 docs/verification/stow-memory.md delete mode 100644 tests/fixtures/quota-array-dispatch/cases.json delete mode 100644 tests/fixtures/quota-array-dispatch/schema-v3-shape.json create mode 100755 tests/fm-backend-herdr-focus-flash-e2e.test.sh create mode 100755 tests/fm-backend-herdr-launcher-workspace-e2e.test.sh create mode 100755 tests/fm-busy-adapter-wiring.test.sh create mode 100755 tests/fm-busy-state.test.sh create mode 100755 tests/fm-public-followup.test.sh create mode 100755 tests/fm-quota-array-dispatch-live-e2e.test.sh create mode 100755 tests/fm-startup-memory-budget.test.sh create mode 100755 tests/fm-validation-lane.test.sh create mode 100755 tests/fm-vendor-auth-probe.test.sh diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index 95f64b11e03..aba6e3fb00c 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -92,14 +92,15 @@ The daemon never injects into an in-use pane. Two checks run before every injection, dispatched through `bin/fm-backend.sh` for the supervisor's own backend (tmux or herdr; see "Auto-discovered supervisor pane" below): -- **`pane_is_busy`** - the harness shows a busy footer (agent mid-turn) on tmux (shared with `fm-send.sh` via `bin/fm-tmux-lib.sh`); on herdr, tries the native `agent.get`-backed busy state first, trusts only `busy` outright, and corroborates every non-`busy` verdict with the same regex-over-capture reader. +- **Primary-pane busy guard** - `pane_is_busy` trusts Herdr native `busy` when available, otherwise matches rendered output against only the detected primary harness's signature. + This narrow delivery guard never classifies a recorded worker task and never uses a global union of vendor patterns. - **Composer-state guard** - `inject_msg` reads the full `empty`/`pending`/`unknown` verdict from `fm_backend_composer_state` and injects only when it is affirmatively `empty`. `pending` means real unsubmitted text, while `unknown` includes an unreadable pane and a bare shell prompt left after the agent exits, so both defer. The shared `bin/fm-composer-lib.sh` owns the content decision after each backend captures and structurally identifies its own composer row. It preserves idle bordered composers such as claude's `│ > … │` and bare agent glyphs as empty, but a bare shell glyph is unknown unless inside a genuine bordered composer box; see `docs/herdr-backend.md` "Composer and injection safety" for the complete contract. `pane_input_pending` remains the tested predicate for callers that only need to know whether real unsubmitted text is present, but it is insufficient for an injection-safety decision because it cannot distinguish `empty` from `unknown`. -Either condition, or any composer verdict other than `empty`, defers the injection; the buffered escalation survives in `state/.subsuper-escalations` and is retried on the next housekeeping tick. +A busy primary pane, or any composer verdict other than `empty`, defers the injection; the buffered escalation survives in `state/.subsuper-escalations` and is retried on the next housekeeping tick. In afk mode the composer guard is belt-and-suspenders (no human is typing), but it protects against the race window between the captain returning and their message landing, a dead shell, and the daemon's own previous injection sitting unsent. **Max-defer escape (the daemon must never silently wedge).** @@ -181,14 +182,14 @@ the operational prefix lets firstmate distinguish it from a real captain message - **Single-line digest** - embedded newlines are collapsed to a literal separator before injection, so submission is unambiguous regardless of harness. -- **Composer guard on the supervisor pane** - before injecting, the daemon checks `pane_is_busy` (harness busy footer means agent mid-turn) and reads `fm_backend_composer_state` directly. +- **Busy and composer guards on the supervisor pane** - before injecting, the daemon runs the detected-primary-harness rendered busy guard and reads `fm_backend_composer_state` directly. Only `empty` permits injection; `pending` protects half-typed or swallowed input, and `unknown` protects unreadable panes and bare dead-shell prompts. Every other result preserves the buffer for retry, so the daemon never merges its digest into the captain's half-typed line or types it into a shell. - The shared composer classifier receives a candidate row only after the active backend performs its own capture and structural row recognition. tmux and herdr route their raw styled candidate rows through the shared `fm_composer_strip_ghost` extractor, which removes dim/faint and dark-TRUECOLOR ghost/placeholder text before classification. They read the composer shape from a separately ANSI-stripped plain row because a dark TRUECOLOR border can be stripped with ghost content. A ghost-only or idle bordered composer such as claude's `│ > ... │` therefore reads empty without allowing an unbordered shell prompt to do the same. - `FM_COMPOSER_IDLE_RE` still overrides tmux empty-composer matching after shared ghost and border stripping, and `FM_BUSY_REGEX` overrides busy footers. + `FM_COMPOSER_IDLE_RE` still overrides tmux empty-composer matching after shared ghost and border stripping, and `FM_BUSY_REGEX` overrides the rendered delivery guards plus Grok's isolated task-state fallback. - **Max-defer escape** - the daemon must never silently wedge. If anything stays buffered past `FM_MAX_DEFER_SECS` (default 300s), the daemon attempts one normal flush, which still requires an idle pane and an affirmatively empty composer. If that diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index 2cee5e51a5a..c9ca593adb7 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -2,7 +2,7 @@ name: bootstrap-diagnostics description: >- Agent-only handling playbook for session-start bootstrap diagnostics. - Use 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, FMX, or OPS_INBOX - or when a standalone bin/fm-bootstrap.sh run prints one of those lines. + Use whenever the session-start digest's bootstrap section prints an actionable diagnostic line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, TANGLE, STARTUP_MEMORY_BUDGET, CREW_DISPATCH invalid, FLEET_SYNC, PR_CHECK_MIGRATION, SECONDMATE_SYNC, SECONDMATE_LIVENESS, NUDGE_SECONDMATES, FMX, or OPS_INBOX - or when a standalone bin/fm-bootstrap.sh run prints one of those lines. A silent bootstrap section, or a BOOTSTRAP_INFO fact, means no skill load. user-invocable: false metadata: @@ -27,6 +27,7 @@ When any diagnostic needs captain attention, report the plain consequence and re - `TANGLE: ` - the primary checkout is stranded on a feature branch instead of its default branch; `AGENTS.md` section 8 explains why this guard exists and what it protects. The work is safe on that branch ref; restore the primary to its default branch with the printed `git -C checkout `, then re-validate that branch in a proper worktree. This is the only sanctioned firstmate-initiated git write to the primary, and it is a non-destructive branch switch that strands nothing. +- `STARTUP_MEMORY_BUDGET: invalid config/startup-memory-budget - ` - the visible startup-memory budget is not a safe one-line positive decimal file; do not infer the default or propagate it. Correct the local primary file, then rerun session start so the normal convergence path can deliver the validated value to secondmate homes. - `CREW_DISPATCH: invalid config/crew-dispatch.json - ` - the optional dispatch profile file exists but failed low-cost bootstrap validation; stop profile-based dispatch, report the actionable error, and require correction of the malformed schema, unverified harness name, or invalid harness/effort pair rather than falling back around it or selecting a bad profile. - `FLEET_SYNC: : skipped: ` - a benign one-off skip (offline, no origin, local-only); bootstrap continued, investigate only if it blocks work. A skip can also report the bounded fleet-refresh timeout (`FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT`, or a fleet-size-aware default with a 20 second floor); a timeout never blocks startup. diff --git a/.agents/skills/firstmate-coding-guidelines/SKILL.md b/.agents/skills/firstmate-coding-guidelines/SKILL.md index 8bbb275dae8..ac1da34dd84 100644 --- a/.agents/skills/firstmate-coding-guidelines/SKILL.md +++ b/.agents/skills/firstmate-coding-guidelines/SKILL.md @@ -44,7 +44,7 @@ Every contract - a data format, a state machine, a decision procedure - is state Every other mention of it is a one-line cross-reference, never a restatement. A single deliberate one-line reinforcement at a genuine risk point is allowed, for example a "don't forget X" placed exactly where forgetting X is costly. Restating the contract's substance a second time is not allowed: the two copies will drift the moment only one is edited. -When you touch a contract, grep the repo for its other mentions and update the cross-references, not duplicate the change into a second full copy. +When you touch a contract, patch, replace, or prune the owner's existing language rather than appending a new clause or paragraph wherever possible, then grep the repo for its other mentions and update the cross-references, not duplicate the change into a second full copy. ## Inline-stub pattern @@ -61,7 +61,7 @@ Apply the decision tree above to every line you are about to add to `AGENTS.md`. If an addition needs more than a few lines of conditional detail (detail that matters only in a specific situation) or reference detail (a wire format, an exact schema, historical rationale), you are almost certainly adding it to the wrong file. `AGENTS.md`'s token cost is paid by every session of every fleet member, every time, whether or not that session ever hits the situation the new lines describe. A skill's cost is paid only by the sessions that actually load it. -When in doubt, write the fact into the skill or doc first, and add only the one-line trigger to `AGENTS.md`. +When in doubt, write the fact into the skill or doc first by patching that owner's existing language, and add only the one-line trigger to `AGENTS.md`. ## Trigger hygiene diff --git a/.agents/skills/fmx-respond/SKILL.md b/.agents/skills/fmx-respond/SKILL.md index 01b7a9168df..37fceb0bfb6 100644 --- a/.agents/skills/fmx-respond/SKILL.md +++ b/.agents/skills/fmx-respond/SKILL.md @@ -4,7 +4,8 @@ description: >- Agent-only playbook for handling X mode mentions and follow-ups. Use on an "x-mention " check wake to read the stashed mention, classify it, act autonomously on eligible requests, reply or dismiss, and link spawned work. Also use on an "x-mode-error ..." check wake to report the X-mode configuration blocker instead of answering a mention. - Also use on milestone and terminal wakes for an X-mode-linked task before posting completion follow-ups, ending terminal outcomes with --final. + Also use on milestone and terminal wakes for an X-mode-linked task before posting completion follow-ups, using typed promised-final reconciliation when registered and --final otherwise. + Also use on a "public-followup ..." check wake, and whenever a promised final public reply must be created, reconciled, or delivered. Loaded only when X mode is enabled. user-invocable: false metadata: @@ -56,7 +57,7 @@ How the reply lands depends on whether the work finishes during this turn: The exact resolution and fail-safe posting contract is owned by `docs/configuration.md`. If a recovery respawns the same relay request onto a successor task, relink with the paired `--carry-count --carry-ts ` flags plus any prior `x_platform=` and `x_reply_max_chars=` as `--carry-platform --carry-max ` so the successor keeps the consumed follow-up count, original 7-day window, and reply split budget. 4. **Follow up on genuine milestones, sparingly.** Firstmate gets up to **three** follow-ups per mention, within a 7-day window, chained in the same thread - spend them only on changes the captain would actually want to hear about (e.g. investigation done and a build started, work shipped or ready, or the task failing), never on routine internal churn. - The task's final outcome - shipped / reported / merged / failed - is always posted with `--final`, which clears the link regardless of how many follow-ups remain. + A task without a promised-final commitment posts its final outcome - shipped / reported / merged / failed - with `--final`, which clears the link regardless of how many follow-ups remain. A typed promised-final commitment uses the deterministic consumer instead. That posting happens on the task's milestone and completion wakes (see "Completion follow-up" below), not this turn. So every drained mention sorts into one of three cases (the worthiness judgment, widened): @@ -141,7 +142,7 @@ Treat `state/x-inbox/` as the source of truth and process **every** file you fin **If the request spawned a real, longer-running task** (you ran `bin/fm-spawn.sh`), link that task to this mention so milestone and completion follow-ups can be posted: `bin/fm-x-link.sh `. **Link here, in step 2c, before the step 2f inbox cleanup** - `bin/fm-x-link.sh` can copy both the mention's reply platform and explicit budget from the still-present inbox payload without a relay lookup. If that local context is incomplete it uses the durable resolution contract in `docs/configuration.md` and warns loudly, while the follow-up path refuses to post unless both values can be resolved authoritatively. - Then step 2d's reply is an **acknowledgement** ("on it, captain"), and genuine milestone updates plus the final outcome come later as follow-ups (see "Completion follow-up" below), with the terminal one posted using `--final`. + Then step 2d's reply is an **acknowledgement** ("on it, captain"), and genuine milestone updates plus the final outcome come later as follow-ups (see "Completion follow-up" below), with the terminal one posted using `--final` when no typed promised-final commitment exists. If the work completed in this turn (a backlog item filed, a question answered), there is no task to link and step 2d reports the outcome directly. d. **Compose the reply.** For a **question**, answer `.text` from the fleet state gathered in step 1. For an **actionable request that completed now**, report the outcome of step 2c (what was done, or - for escalated work - that it has been flagged for the captain). For an **actionable request that spawned a linked task**, acknowledge that you have the order and are on it - milestone updates and the final outcome follow later as completion follow-ups, so do not promise a result you do not yet have. Either way keep it short, in firstmate's voice, and public-safe. Conversation continuity: when `in_reply_to` is present this is a conversation reply - read `in_reply_to.text` (what `in_reply_to.author_handle` said just before) as **context** and continue that thread, resolving "it", "that", "and then?" against the parent; for a fresh mention (`in_reply_to` is null) answer on its own. @@ -203,14 +204,50 @@ This skill's own responsibility during the mention-handling turn is linking the - On each such milestone, firstmate checks whether a follow-up is still due with `bin/fm-x-followup.sh --check ` (prints the `request_id` when the link exists, the count is under the cap, and the window has not lapsed; silent otherwise, pruning an exhausted or expired link). - If due, it composes a short, public-safe update and posts it with `bin/fm-x-followup.sh --text-file ` (or stdin), which posts via the relay's follow-up endpoint; a successful non-final post increments the counter and keeps the link so a later milestone can still post against it. When the update carries one real visual artifact, add `--image `; the helper forwards it to `bin/fm-x-reply.sh --followup` so the same image contract used for ordinary replies applies here too. -- On a terminal wake (PR merged / scout report / local merge / failed), firstmate posts the task's **final** outcome ("done, here's the result"; for a failure, an honest "this one didn't pan out") with `bin/fm-x-followup.sh --final --text-file `, which always clears the link after that post regardless of how many follow-ups remain under the cap. +- On a terminal wake (PR merged / scout report / local merge / failed), firstmate posts the task's **final** outcome ("done, here's the result"; for a failure, an honest "this one didn't pan out") with `bin/fm-x-followup.sh --final --text-file ` only when no promised-final public commitment is registered for that work. When the promised-final procedure above applies, `bin/fm-public-followup.sh consume` and `deliver` own the terminal reply and clear the legacy link at the validated receipt boundary, so do not call `fm-x-followup.sh --final` for the same outcome. If delivery reports that link cleanup needs reconciliation, do not post anything else; `bin/fm-x-followup.sh --clear ` is the clear-only recovery command in the bound work home. - Every follow-up is held to the exact same public-safety bar as every reply here: outcomes only, no task ids, internals, captain-private material, or secrets. Past the window, past the cap, or on the relay's own rejection of an exhausted binding, a follow-up attempt is skipped silently and the link is cleared - never treated as a failure worth retrying. - If either a follow-up's platform or explicit budget cannot be authoritatively resolved from per-request context, inbox payload, or relay answer, `bin/fm-x-followup.sh` does NOT post it: the fail-safe holds it (the link is kept, exit non-zero) rather than use a local default. This is a retryable hold - a later milestone wake retries it once both values are recoverable. +## Promised final replies (the commitment that must survive compaction) + +The follow-up budget above is a courtesy. +A **promised final reply** - "I'll report back when this lands" - is a commitment, and forgetting it is publicly visible. +Never carry one in your head: the moment you promise a specific outcome in a public thread, turn it into durable state and let the scripts reconcile it. +This section is the sole owner of that procedure. +`tasks-axi public-followup --help` owns the typed obligation, its states, and its file contracts; `bin/fm-public-followup.sh --help` owns firstmate's flags; do not restate either here. + +**When you promise a final:** + +1. Create the typed obligation with `tasks-axi public-followup add` and bind the work with `bind-work`, keeping the public-safe summary and the opaque thread binding in the obligation and the full request context where the poll already put it. +2. Register it with `bin/fm-public-followup.sh register --relation --work-home > --work-id --generation `. + This is what makes the commitment reconcilable without you. +3. Put `bin/fm-public-followup.sh brief ` output straight into the worker's brief. + It prints the exact reporting command for that binding. + Never ask a worker to find the thread or post the reply: only this home holds the relay consent and the thread binding. + +**When work reports back, or on a `public-followup ...` check wake, or when the session-start digest lists a public commitment:** + +1. Run `bin/fm-public-followup.sh consume`. + It reconciles every typed terminal result from disk and prints `ready ` for each commitment that became deliverable. + A refusal prints `rejected : ` and quarantines that event; read the reason rather than re-emitting blindly. +2. For each ready commitment, run `bin/fm-public-followup.sh deliver `. + With no `--text-file` it reuses the accepted terminal outcome exactly, which is the preferred path for a landed result. + Only pass `--text-file` when the outcome genuinely needs composing, and hold it to the same public-safety bar as every other reply here. + Delivery clears the bound task's legacy X link at the validated receipt boundary; if it reports a cleanup failure, use its reconciliation message and do not post a legacy final. +3. Read the outcome and stop guessing at anything it refuses: + - "still waiting on its bound work" means the work has not reported a typed terminal result yet - do not post. + - "recorded as retryable" means nothing was posted; retry on a later wake. + - "held" means the thread's platform or budget is unresolvable right now; retry once it is recoverable. + - "mid-delivery" means a previous post started and its outcome was never recorded. Do NOT deliver again. Establish whether that post landed, then either close it with `record-posted --attempt --chunks ` or escalate. Posting again would put a second reply in a public thread. + - "the relay no longer accepts a follow-up" is a captain decision, not a retry. + +Cleanup refuses while a commitment is still owed for that exact work, so never reach for `--force` to get past it. +Treat a commitment as kept only after a validated posted receipt or an explicit captain waiver. + ## Notes - The direct author is always your own captain (owner-only routing), and in live mode you answer and act on eligible requests **autonomously**: enabling X mode is the captain's standing authorization, so never ask the captain before posting and never hold a worthwhile reply for a chat-side OK. For reply-worthy mentions, dry-run (`FMX_DRY_RUN`) is the only non-posting path; pure acknowledgments use the relay dismiss path instead. -- An actionable mention is **acted on** through the normal lifecycle (intake, backlog, dispatch, investigate, ship), not merely replied to. Work that finishes now gets one outcome reply; work that spawns a real task gets an **acknowledgement now** plus up to three **completion follow-ups** over time, ending with a `--final` one (link the task with `bin/fm-x-link.sh` so those follow-ups can post). A reply alone, with no work behind an actionable ask, is the bug to avoid. +- An actionable mention is **acted on** through the normal lifecycle (intake, backlog, dispatch, investigate, ship), not merely replied to. Work that finishes now gets one outcome reply; work that spawns a real task gets an **acknowledgement now** plus up to three **completion follow-ups** over time, ending with a `--final` one when no typed promised-final commitment exists (link the task with `bin/fm-x-link.sh` so those follow-ups can post). A reply alone, with no work behind an actionable ask, is the bug to avoid. - Destructive, irreversible, or security-sensitive asks are flagged to the captain through the trusted channel first and never run straight from a mention; the public reply says only that it has been flagged. - One answered mention = one reply (plus up to three completion follow-ups for a spawned task, spent only on genuine milestones); a skipped mention posts no reply but is **dismissed at the relay** (`bin/fm-x-dismiss.sh`) so the relay drops it rather than re-offering it (which would otherwise churn every poll and end in an "offline" auto-reply). A single wake may cover several pending mentions - drain them all. - Conversations: `in_reply_to` carries the parent post for continuity; a pure acknowledgment with nothing to answer is dismissed at the relay and skipped, not replied to. The relay already guards against self-replies and caps replies per conversation, so you only judge "is there something to answer here?". diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 0ae4ee05b52..03735fefb19 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -12,7 +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. +When a matched rule or default is a profile array, load `quota-array-dispatch` for the completion-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. @@ -29,12 +29,13 @@ Each adapter splits into mechanics and knowledge. The per-task mechanics, including launch command, autonomy flag, and any enabled crewmate turn-end hook, live in `bin/fm-spawn.sh`. The primary-session "no turn ends blind" guard contract and harness hook installation paths live in `docs/turnend-guard.md`. The primary-session watcher wake protocols are rendered from `docs/supervision-protocols/` by `bin/fm-supervision-instructions.sh`. -The supervision knowledge lives here: busy signature, exit command, interrupt, dialogs, resume behavior, skill invocation, and quirks. +The supervision knowledge lives here: busy state, exit command, interrupt, dialogs, resume behavior, skill invocation, and quirks. +Each adapter's `Busy state` row names only which semantic source that harness uses; `bin/fm-busy-lib.sh` owns the contract itself, including verdicts, source attribution, and the verification gates that keep an unverified harness at unknown. Never dispatch a crewmate or secondmate on an unverified adapter. If `config/crew-harness` or `config/secondmate-harness` names an unverified adapter, tell the captain under `AGENTS.md` section 9 that the requested worker runtime is not verified yet, use firstmate's own verified runtime for current work, and ask only whether to verify the requested runtime before future use. Do not pause current work for that future-verification choice, and never launch an unverified adapter. -If the captain asks for a new harness, propose verifying it first: spawn a trivial supervised task using `fm-spawn`'s raw-launch-command escape hatch, confirm every fact empirically, then record the mechanics in `fm-spawn`, the busy signature in `fm-watch.sh` and `fm-tmux-lib.sh` defaults, any needed `FM_COMPOSER_IDLE_RE` empty-composer override plus any novel bare agent prompt glyph in `bin/fm-composer-lib.sh`'s shared composer classifier (the one fleet-wide owner of the empty/dead-shell/pending decision, so a new harness's own idle composer is not misread as a dead shell), the tmux agent-process liveness classification in `bin/backends/tmux.sh` when the harness can launch a secondmate, and the verified knowledge here. +If the captain asks for a new harness, propose verifying it first: spawn a trivial supervised task using `fm-spawn`'s raw-launch-command escape hatch, confirm every fact empirically, then record the mechanics in `fm-spawn`, its semantic busy source and trust gate in `bin/fm-busy-lib.sh`, any needed `FM_COMPOSER_IDLE_RE` empty-composer override plus any novel bare agent prompt glyph in `bin/fm-composer-lib.sh`'s shared composer classifier (the one fleet-wide owner of the empty/dead-shell/pending decision, so a new harness's own idle composer is not misread as a dead shell), the tmux agent-process liveness classification in `bin/backends/tmux.sh` when the harness can launch a secondmate, and the verified knowledge here. ## Detection @@ -128,6 +129,7 @@ The supported launch-profile flags below are verified locally; each row records | kimi | `--model ` | none | Verified 2026-07-25 on Kimi Code CLI 0.29.1. | The concrete `harness` field owns adapter identity independently of the model provider: `harness=pi` with `model=xai/grok-*` is Pi using xAI, not `harness=grok`, and does not require Grok CLI login; `harness=grok` remains the standalone Grok Build CLI adapter. +No script resolves that split for you: establish which credential store a tuple reads from the discovery surfaces below plus `quota-axi auth --json`'s per-provider sources, and show that reasoning rather than inferring it from a harness, model, or source name. ### Model support discovery @@ -144,7 +146,8 @@ Use the discovery surface in the current authenticated environment because suppo | kimi | Run `kimi provider list --json`, which lists the current provider and model configuration. | 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 from a name or prefix. -If those sources do not establish the relationship needed for dispatch, fail loudly and report the unresolved candidate. +A listing that reaches the account and does not contain the model is concrete evidence the model is unsupported: block that candidate and quote the result. +A discovery surface you could not reach establishes nothing; report that as uncertainty rather than turning it into a supported or unsupported verdict. When a requested effort value is outside the harness-specific accepted set, `fm-spawn` records the requested `effort=` in meta but emits no effort flag for that harness. This preserves launch success instead of passing a known-bad value. @@ -167,11 +170,11 @@ A send or key action reporting success is not proof that the intended action hap OpenCode can accept and queue an Enter while leaving text visible, Grok can consume Enter in its slash popup without submitting, and Kimi can silently drop a message sent before readiness even though the send returns success. The shared symptom is a healthy-looking pane with no work in progress, so each adapter must verify the observable postcondition that is specific to its TUI. -## claude (VERIFIED; busy signature re-verified 2026-07-25 on Claude Code 2.1.220) +## claude (VERIFIED; busy-state hooks live-verified 2026-07-28 on Claude Code 2.1.220) | Fact | Value | |---|---| -| Busy-pane signature | Current turns match the harness-scoped `…[[:space:]]+\([0-9]+[smh]` shape after a rotating glyph and word, for example `✢ Pollinating… (16s · ...)`; legacy `esc to interrupt` remains accepted, while `Worked for 31s` is idle. | +| Busy state | Owned lifecycle hooks: `UserPromptSubmit` opens a turn, `Stop`, `StopFailure`, and `SessionEnd` close it. Claude fires no hook for a manual interrupt, so a firstmate-initiated interrupt must record the clear itself. | | Exit command | `/exit` | | Interrupt | single Escape | | Skill invocation | `/` (e.g. `/no-mistakes`) | @@ -201,7 +204,7 @@ Claude Code's primary watcher protocol is Stop-owned: the auto-arm hook fires on | Fact | Value | |---|---| -| Busy-pane signature | `esc to interrupt` (shown as `• Working (Xs • esc to interrupt)`) | +| Busy state | Unknown until a semantic source is live-verified: the app-server turn lifecycle is unreachable for a pane worker, and project lifecycle hooks did not fire for a firstmate-launched worker. | | Exit command | `/quit` (slash popup needs about 1 second between text and Enter; `fm-send` handles it) | | Interrupt | single Escape | | Skill invocation | `$` (e.g. `$no-mistakes`); `/` is claude-only and codex rejects it as "Unrecognized command" | @@ -232,7 +235,7 @@ The checkpoint is deliberately foreground and bounded so Codex regains control r | Fact | Value | |---|---| -| Busy-pane signature | `esc interrupt` (dotted spinner footer; note no "to") | +| Busy state | The Firstmate-owned plugin's semantic `session.status`: `busy` and `retry` are active, `idle` is inactive, latched to the worker's own session. | | Exit command | `/exit` | | Interrupt | double Escape; known flaky while a long shell command runs, so a wedged pane may need `/exit` and relaunch | @@ -269,7 +272,7 @@ The follow-up was verified in the interactive TUI; `opencode run` can exit befor | Fact | Value | |---|---| -| Busy-pane signature | `Working...` (braille spinner prefix; no `esc to interrupt` text) | +| Busy state | The Firstmate-owned extension's `agent_start` (busy) and `agent_settled` confirmed by `ctx.isIdle()` (idle), which covers retries, compaction, tool loops, and queued continuations. | | Exit command | `/quit` | | Interrupt | single Escape | @@ -306,7 +309,7 @@ For Grok's supported reasoning-effort values and omission behavior, see the [lau | Fact | Value | |---|---| -| Busy-pane signature | `Ctrl+c:cancel` (the mid-turn cancel hint in grok's keybind bar, shown iff a turn is running; the spinner line is a braille glyph + `… N.Ns` + `[stop]`, e.g. `⠹ Thinking… 1.1s … [stop]`). Idle keybind bar shows only `Shift+Tab:mode │ Ctrl+.:shortcuts`. The ASCII `Ctrl+c:cancel` is the busy regex (avoids locale fragility of matching braille). | +| Busy state | The one remaining rendered-tail fallback, isolated to Grok until its structured lifecycle is live-verified: `Ctrl+c:cancel`, the mid-turn cancel hint shown in grok's keybind bar iff a turn is running. The idle bar shows only `Shift+Tab:mode │ Ctrl+.:shortcuts`. ASCII is matched rather than the braille spinner to avoid locale fragility. | | Exit command | `/exit` typed into the composer exits the TUI cleanly and prints `Resume this session with: grok --resume `; `Ctrl+Q` double-press within 1000ms remains a fallback; `Ctrl+D` is the quit key in VS Code family terminals; `Ctrl+C` is the interrupt, not the exit. | | Interrupt | single `Ctrl+C` (cancels the current turn; the footer shows `Ctrl+c:cancel` mid-turn). `Esc` only moves focus to the scrollback, it does NOT interrupt. | | Skill invocation | `/` (e.g. `/no-mistakes`), same as claude. Opens a slash-autocomplete popup, so a too-fast Enter selects the popup entry instead of sending. For an argument-taking command that first Enter does not submit at all - it expands the selection into an argument-hint placeholder in the composer (e.g. `/compact` -> `/compact compaction instructions`, live-verified), leaving real text still sitting there unsubmitted; a genuine second Enter is required. `fm-send`'s retried Enter lands it on BOTH backends, but only because each backend's own submit-verification correctly recognizes that placeholder-filled text as still-pending - see the incident below. | @@ -363,7 +366,7 @@ Kimi Code CLI launches from the absolute path resolved from `PATH`, falling back | Binary | Executable `kimi` from `PATH`, then executable `$HOME/.kimi-code/bin/kimi`; spawning refuses if neither exists. | | Launch | Bare interactive TUI with `--auto`, followed by readiness-gated pointer delivery; positional prompts are rejected. | | Models | `kimi-code/kimi-for-coding` (default), `kimi-code/kimi-for-coding-highspeed`, `kimi-code/k3`, and `kimi-code/k3-256k`. | -| Busy-pane signature | A transient line with optional leading whitespace, a rotating moon-phase glyph, required whitespace on both sides of `·`, and optional trailing content; the line is absent when idle. | +| Busy state | Standalone Kimi is unknown until a semantic source is live-verified; prefer Wire's `prompt` request lifetime, then documented hooks including `Interrupt`. Kimi behind Pi uses Pi's lifecycle. Its moon-phase spinner is not a state source. | | Exit command | `/exit` | | Interrupt | Single Escape, which prints `Interrupted by user`. | | Skill invocation | `/`, for example `/no-mistakes`; firstmate skills are discovered. | @@ -385,12 +388,12 @@ The startup input-readiness window is the established cause of Kimi's first-Ente An early Enter can expand Kimi's composer to multiple content rows, leaving the pointer text on the first row and the cursor on an empty later row, which is the same single-cursor-row reading defect exposed by Grok's bottom-border cursor quirk. The shared tmux reader now locates the complete bordered composer and treats real text on any content row as positive evidence that submission is still pending. No rendering signal is trustworthy for proving that Kimi will accept input during this window, so delivery retries Enter through the shared submit core and retains the existing postcondition verification rather than relaxing readiness or delivery checks. -Kimi's footer tip rotates independently and can display `ctrl+c: cancel` while completely idle, so tip text is never used as its busy signature without the leading moon-plus-middot spinner structure. +Kimi's footer tip rotates independently and can display `ctrl+c: cancel` while completely idle, which is one reason no Kimi rendered signature is a state source. The idle status bar can contain lowercase `thinking`, which is the model's effort label rather than a busy signal. -The spinner match covers the full moon-phase glyph set rather than one frame, but it remains locale- and emoji-font-sensitive because Kimi exposes no stable ASCII busy token. +The delivery-only spinner match covers the full moon-phase glyph set rather than one frame, but it remains locale- and emoji-font-sensitive because Kimi exposes no stable ASCII busy token. [`docs/turnend-guard.md`](../../../docs/turnend-guard.md) owns Kimi's verified global hook surface and captain-approved crew wake integration. `fm-spawn.sh` installs one marker-delimited Firstmate entry in `$HOME/.kimi-code/config.toml`, one silent always-zero hook script, and one private token registry under `$HOME/.kimi-code/fm-turn-end.d/`. Each Kimi crew worktree receives a gitignored `.fm-kimi-turnend` token pointer, and the global hook touches that task's `state/.turn-ended` only when the Stop payload's `cwd`, pointer, and registry entry all agree. A guarded silent hook cannot be verified from absence of effect, so prove invocation with an unguarded probe before concluding that the hook did not fire. -The guarded turn-end signal supplements the pane busy signature, whose locale- and emoji-font-sensitive limits still apply while a turn is running. +The guarded turn-end signal remains a wake notification; standalone Kimi has no busy-state source until one is live-verified. diff --git a/.agents/skills/quota-array-dispatch/SKILL.md b/.agents/skills/quota-array-dispatch/SKILL.md index c384553a859..11b84058125 100644 --- a/.agents/skills/quota-array-dispatch/SKILL.md +++ b/.agents/skills/quota-array-dispatch/SKILL.md @@ -2,7 +2,7 @@ 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. + array from current quota-axi output, including effective headroom and usable-runway evidence. Load when a dispatch rule or default resolves to more than one profile candidate. user-invocable: false metadata: @@ -11,25 +11,64 @@ metadata: # quota-array-dispatch -This skill is the single owner of the pace-aware profile-array selection procedure. +This skill is the single owner of the completion-aware profile-array selection procedure. `AGENTS.md` section 4 owns the always-loaded intake boundary, load trigger, malformed-config refusal, every-candidate accounting, and strongest-reasoning/tie safety rules. `harness-adapters` owns harness verification, model/provider discovery, and effort fallback. -`quota-axi` remains data-only and never recommends a route. +`quota-axi` remains data-only, reports whatever granularity the vendor supplies, and never recommends, selects, ranks, or infers a route. Do not add a daemon, opaque composite score, routing wrapper, hard-coded model-specific policy, or producer-side route recommendation. +Deterministic shell owns only schema, configuration, and version validation plus concrete spawn safeguards; every model-to-provider, provider-to-credential, and quota-applicability relation is yours to establish transparently and to show your evidence for. ## Collect facts Run `quota-axi --json` once per intake and reuse that snapshot for every candidate. +Do not take a second snapshot to settle a candidate, and read `quota-axi auth --json` when a candidate's credential surface is in question. For each candidate, preserve explicit `harness`, `model`, and `provider`; `harness-adapters` owns identity, and model/provider never infer harness: - task/profile fit and required reasoning class -- raw applicable headroom (`effectivePercentRemaining` or tightest applicable percentage) -- effective pace, signed reserve per window, and worst reserve (`worstReservePercentPoints` or minimum signed reserve) -- whether applicable windows/summary are ahead, or pace is `unknown` -- schema note when pace fields are absent +- applicable effective headroom (`effectivePercentRemaining`) from the established provider/model scope +- usable runway status, `usableRunwaySeconds`, `projectedExhaustedAt`, `limitingWindowId`, `projectionConfidence`, `projectionBasis`, and any `unmeasurableWindowIds` +- the task-completion horizon and the evidence and confidence used to estimate it +- effective pace, signed reserve per window, and worst reserve (`worstReservePercentPoints` or minimum signed reserve) for later diagnostic tie-breaking +- schema notes when runway or pace fields are absent -Stale raw windows are diagnostic, never headroom. -Read all windows named by `boundedBy`, `limitingWindowIds`, `aheadWindowIds`, `behindWindowIds`, `onPaceWindowIds`, and `unknownWindowIds`. +Stale raw windows are diagnostic, never headroom or fabricated runway. +Grok's `credits.remaining` is a prepaid balance unrelated to `percentRemaining`; never read it as exhaustion. +Read all windows named by `boundedBy`, `limitingWindowIds`, `aheadWindowIds`, `behindWindowIds`, `onPaceWindowIds`, `unknownWindowIds`, and `unmeasurableWindowIds`. +The compact default output intentionally omits numeric reserve, while `--json` and `--full` retain reserve diagnostics. + +## Establish the provider relation before reading quota + +Deterministic shell must never map a model to a provider, a provider to a credential store, or a name prefix to a family. +You establish those relations yourself, in the open, from the candidate's own authoritative catalog (`harness-adapters` owns the per-harness discovery surface) plus the one intake snapshot. +Name the evidence for each relation you assert so the conclusion is inspectable. + +1. Confirm the catalog lists the candidate's model and record the provider family it reports. + A model the authoritative catalog does not list is concrete contradictory evidence: block that candidate and quote the catalog result. +2. Apply quota at the granularity the vendor actually supplies. + A provider-level or `all_models`/`all_products` scope bounds every model you established in that family, including one with no window of its own. + A named-model or named-product scope is an additional bound for that model alone and is irrelevant to every other model in the family. + Read `quotaSemantics.description`, which states the vendor's own bounding rule. +3. Record what remains unknown instead of converting it into a verdict. + +## Authentication is scoped to the selected surface + +A candidate authenticates through its own tuple's surface; another harness's CLI can never gate it, and `harness=pi` with `model=xai/grok-*` is Pi using xAI rather than the standalone Grok CLI. +`quota-axi auth --json` lists each provider's credential sources independently, so read the one source the candidate actually uses rather than collapsing a provider to a single status. +A provider can carry a healthy source beside a missing or expired one; the unused source's state is not the candidate's state. +A Pi-hosted family may authenticate through the vendor's own store with no `pi:`-prefixed source at all, which is normal and never evidence against the candidate. + +Uncertainty and ineligibility are different findings: + +- No model-level window, no matching auth source, an absent `state.authStatus`, an unmeasurable or `unknown` scope, or a surface quota-axi does not model at all is disclosed uncertainty. + Keep the candidate eligible, state the unknown, and prefer known sustainable evidence when otherwise comparable. +- An expired credential is a short-lived session token the owning vendor renews on next use, not a sign-out. +- Only concrete contradictory evidence blocks: an authoritative catalog proving the model unsupported, or proof that the credential the candidate actually selects is unusable. +- Reserve login wording for that proven-unusable case, and name the harness, model, surface, and evidence. + +When a credential's local classification is the only thing standing between a candidate and a block, get ground truth before blocking. +`bin/fm-vendor-auth-probe.sh` is the only approved vendor-credential probe; its `--help` owns the registered probes and mechanics. +It takes no harness, model, or provider and returns a fact, not a route: only `authenticated` and `unauthenticated` are ground truth, while `indeterminate`, `timeout`, and `unavailable` establish nothing and must never be read as either outcome. +Never launch a vendor CLI yourself, and never probe a credential store the candidate does not use. ## Pace semantics @@ -43,23 +82,32 @@ Conservation pressure is present for effective pace status `ahead`, effective pa ## Selection order Apply only among candidates satisfying required fit and strongest reasoning class. -Never use pace or raw headroom to silently replace that reasoning class. - -1. Unresolved relationship or quota: stop and report the tuple and concrete evidence. -2. All-tight: keep strongest reasoning; dispatch inside it or report if blocked. -3. Comparable fit/reasoning: prefer no ahead pressure over pressure, even with higher raw headroom. -4. Among pressured candidates, prefer the least-negative worst applicable reserve. -5. Sustainable candidates: use known pace plus raw headroom. - Prefer known sustainable evidence over `unknown` when comparable. - Do not collapse those facts into an opaque composite score. -6. If unresolved pace changes the choice, report uncertainty. -7. Absent pace or older schema: do not crash, fabricate pace, or treat absence as healthy/`on_pace`. - Compare raw headroom only, state pace is unavailable, and keep safety rules. -8. Genuine ties: stop and report every tied candidate for captain choice. +Never use headroom, runway, pace, or reserve to silently replace that reasoning class. + +1. Concrete contradictory evidence or malformed configuration: stop and report the tuple and that evidence. + Unmeasurable quota, a missing model-level window, an absent runway field, and a credential surface quota-axi does not model are uncertainty, never this rule. +2. Honor any explicit captain instruction that sets a floor for that candidate before the generic comparison. + Do not invent a generic percentage floor or treat a low percentage as an automatic failure. +3. Keep the strongest-reasoning class when every candidate is tight or completion evidence is poor. + Dispatch inside that class when a candidate can proceed, or report that its strongest-class choice cannot proceed rather than downgrading it to conserve quota. +4. Compare comparable-fit candidates on their applicable effective headroom and usable runway. + Eliminate a candidate only when another candidate Pareto-dominates it on both dimensions, with at least one dimension strictly better. + Establish dominance only from comparable known evidence, never by treating absent, `unknown`, or unmeasurable headroom or runway as zero or as a healthy value. +5. Prefer supported runway evidence that projects availability through the inspectable likely-completion horizon. + Known evidence that does not reach that horizon is inferior to known evidence that does, even when its signed reserve is less negative. + Preserve projection confidence and basis, the limiting window, and the horizon estimate in the rationale rather than hiding them in a score or model-specific heuristic. +6. Resolve remaining uncertainty explicitly. + An authenticated candidate with unknown or unmeasurable headroom or runway stays eligible and cannot be silently excluded or assumed sustainable. + Prefer known viable evidence when otherwise comparable, and report uncertainty or ask the captain when it still prevents a justified choice. +7. Use pace and signed reserve only as later diagnostic tie-break evidence among candidates still unresolved after headroom, runway, likely-completion viability, and uncertainty. + Pace and reserve never rescue a clearly inferior completion prospect. + Do not collapse these facts into an opaque composite score. +8. Older schemas or absent runway/pace fields: do not crash, fabricate runway or pace, treat absence as healthy, or silently exclude a candidate. + State which evidence is unavailable, retain the candidate, and apply only the comparisons the snapshot supports. +9. Genuine ties: 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. -Name the inspectable facts used for every candidate. -After selecting, check auth only through that tuple's surface; another harness CLI cannot block it. +Account for every candidate visibly before selecting or escalating, naming its catalog evidence, provider relation, applicable quota and authentication facts, remaining uncertainty, fit and reasoning class, effective headroom, usable runway, likely-completion reasoning, and later pace or reserve evidence when used. A blocked credential report must name `harness`, `model`, authentication surface, and concrete failure evidence; never emit a bare `Grok unauthenticated` statement. Never conclude with an unexplained "best quota" label. diff --git a/.agents/skills/secondmate-provisioning/SKILL.md b/.agents/skills/secondmate-provisioning/SKILL.md index f9e68937ab9..978f4a954aa 100644 --- a/.agents/skills/secondmate-provisioning/SKILL.md +++ b/.agents/skills/secondmate-provisioning/SKILL.md @@ -24,6 +24,7 @@ Keep the always-inline routing rules in `AGENTS.md` authoritative: route by natu ``` Each registry entry stays concise and single-line: the summary is one sentence naming the durable charter, `scope:` is the natural-language intake responsibility, `projects:` is the non-exclusive clone list, and any extra prose is limited to genuinely domain-specific hard rules that change routing or safety for that secondmate. +Natural-language summary and `scope:` text may contain parentheses and semicolons; keep the generated `(home: ...; scope: ...; projects: ...; added ...)` suffix intact so operational consumers resolve its explicit field markers. The `home:` path points to the seeded home containing `data/charter.md`; no extra registry pointer field is needed. The home-seeded `data/charter.md` is the sole owner of boilerplate idle-by-default behavior, the normal delegation lifecycle, and standard escalation contracts, so point to that charter rather than restating those contracts in the registry entry. The `scope:` field is used during intake. @@ -78,7 +79,7 @@ This section is the single owner of the secondmate sync and inherited-local-mate Before launch, `fm-spawn.sh --secondmate` locally fast-forwards the home to the primary firstmate checkout's current default-branch commit when it is safe; dirty, diverged, or in-flight homes launch unchanged with a warning. The locked session-start bootstrap sweep runs the same guarded fast-forward for every live secondmate home, discovered from `state/.meta` records with `kind=secondmate` (`data/secondmates.md` only backfills `home=` for older records). That no-fetch path is a purely local fast-forward of tracked files, never an origin fetch, and it never touches the gitignored operational dirs, so a secondmate's backlog, projects, and in-flight work are never disturbed; a linked worktree advances immediately, while a standalone clone that lacks the target receives firstmate updates through `/updatefirstmate`'s origin refresh. -The same launch and the same locked bootstrap sweep also propagate the primary's declared inherited local material: `config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/backend`, `config/herdr-presentation-spaces`, and the one shared captain-preference file `data/captain-shared.md`. +The same launch and the same locked bootstrap sweep also propagate the primary's declared inherited local material: `config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/backend`, `config/herdr-presentation-spaces`, `config/startup-memory-budget`, and the one shared captain-preference file `data/captain-shared.md`. Because these paths are gitignored, that propagation is a separate, primary-authoritative copy independent of the tracked-files fast-forward: it re-converges every live home whether or not its tracked files advanced, and it touches only the declared items. Propagation failures warn without blocking secondmate launch or session-start continuation, and the destination keeps whatever safely validated state the helper left behind. Inheritance copies the literal `config/crew-harness` file, so a secondmate's own crewmates use the primary's crewmate harness only when it names a concrete adapter such as `codex`; an unset or `default` value has nothing concrete to inherit, and the secondmate's own crewmates fall back to the secondmate's own or detected harness instead. @@ -99,7 +100,7 @@ Keep every `data/learnings.md` fully local by captain decision; route fleet-gene No AGENTS.md reread nudge is needed at spawn or respawn because the agent reads instructions fresh on launch; only the bootstrap sweep's running-home instruction-surface advance needs that AGENTS.md re-read. Bootstrap reports successful AGENTS.md re-read sends as `BOOTSTRAP_INFO:` and only emits `NUDGE_SECONDMATES:` when that send fails and needs retry. A separate, literal-content config reread is required whenever inherited `config/*` material changes under an already-running secondmate. -After each successful allowlisted config write, both the locked bootstrap convergence path and mid-session `bin/fm-config-push.sh` use the shared propagation report to build one per-home generation-specific private instruction file from the validated destination post-write bytes for only the allowlisted config items that actually changed for that home (`config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/backend`, `config/herdr-presentation-spaces`), in deterministic allowlist order. +After each successful allowlisted config write, both the locked bootstrap convergence path and mid-session `bin/fm-config-push.sh` use the shared propagation report to build one per-home generation-specific private instruction file from the validated destination post-write bytes for only the allowlisted config items that actually changed for that home (`config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/backend`, `config/herdr-presentation-spaces`, `config/startup-memory-budget`), in deterministic allowlist order. Each changed path is printed with clear begin/end delimiters and the destination file's full exact new bytes unparsed, or the explicit token `ABSENT` when propagation removed the destination copy. The instruction uses only minimal framing that these are defaults/rules and do not remove judgment; it never includes SHA values, selected profiles, parsed summaries, or any other generated interpretation. `data/captain-shared.md` is not a config file and is never inlined into this instruction file or message. @@ -116,7 +117,7 @@ It uses the same live-home discovery and propagation helper as bootstrap, report `bin/fm-home-seed.sh` refuses to copy a missing or placeholder charter. Direct seed without a preexisting brief requires `FM_SECONDMATE_CHARTER`. -Run `bin/fm-home-seed.sh validate` when checking registry integrity; it refuses duplicate ids, duplicate homes, and nested or overlapping homes. +Run `bin/fm-home-seed.sh validate` when checking registry integrity; its header owns the complete validation and refusal mechanics. Seeding is transactional. If validation, cloning, no-mistakes initialization, or registry update fails, generated briefs, new homes, new project clones, and registry edits are rolled back. diff --git a/.agents/skills/stow/SKILL.md b/.agents/skills/stow/SKILL.md index 4c2c2a337ae..672894bd56d 100644 --- a/.agents/skills/stow/SKILL.md +++ b/.agents/skills/stow/SKILL.md @@ -10,56 +10,76 @@ metadata: # stow -Sweep this session for durable knowledge that only exists in conversation right now, and write it to the disk locations firstmate already prints in the next session-start context digest. -The goal is a session that is safe to reset or destroy because everything durable has already been captured. +Sweep this session for durable knowledge that exists only in conversation, then leave the next session with a compact current operating map rather than an accumulating journal. +This skill writes only through the existing Firstmate ownership and write boundaries. -## What it does +## Required startup-memory pass -1. **Sweep the session for uncaptured durable knowledge.** - Read back over this conversation and look for: - - Operational learnings: fleet-local facts and gotchas discovered while operating firstmate (a script's sharp edge, a harness quirk, a recurring false alarm and its real cause). - - Captain preferences expressed in passing: a working-style or approval preference the captain stated conversationally rather than through the destination selected by AGENTS.md's knowledge-routing table. - - Project-intrinsic facts discovered: build, test, release, or architecture facts about a project that belong in that project's own `AGENTS.md`. - - Decisions made: a standing choice the captain made this session that should outlive it. - - Undone next steps: anything left open that has not yet been filed as backlog work. +Every `/stow` invocation performs this complete pass, even when the session contains no new finding: -2. **Route each finding using AGENTS.md's knowledge-routing table.** - AGENTS.md (section 6, "Knowledge routing") is the single source of truth for where each kind of knowledge belongs. - Read that table and route each finding there instead of re-deriving the mapping here. +1. Run `bin/fm-startup-memory-budget.sh report` before considering a write. + Record its effective budget and each file's estimated-token total. + The helper's stable estimate is the documented conservative local approximation, not provider-exact accounting. + If it rejects the setting or a memory file, do not infer a default or silently continue. + Report that concrete exception and do not call the session reset-safe. +2. Read every current memory file completely: `data/captain.md`, `data/captain-shared.md`, and `data/learnings.md`. + Treat an absent local file as absent, not as an invitation to manufacture content. + In a primary home, all three are curation inputs under their existing ownership rules. + In a secondmate home, `data/captain-shared.md` is a read-only primary-owned input: count it, never edit it, and curate only the editable local files. +3. Build one whole-file retention plan before editing. + Retain, in order: current captain preferences, authority and safety boundaries, and recurring working style; stable home-local operating facts that repeatedly affect future work and are expensive to rediscover; then concise pointers to an existing authoritative report, project document, configuration, or backlog item. + Retain lower-priority material only while budget remains. +4. Consolidate every editable memory file as needed, not only the file apparently related to a new finding. + Prefer one concise current rule or authoritative pointer over duplicate prose. + Remove, merge, or route completed incident and release chronology, stale versions and paths, transient task state, resolved alternatives, old metrics, superseded claims, duplicates, and report-sized procedures. + Do not remove a unique current fact unless it is preserved directly elsewhere through a stronger existing owner. +5. Run `bin/fm-startup-memory-budget.sh report` again after the complete pass. + Finish at or below the effective budget unless a concrete inability remains. + A secondmate must explicitly report `primary-owned-shared-file-alone-exceeds-budget` when the inherited shared file alone exceeds its allowance, because local curation cannot resolve it. + Any other unresolved excess must identify the fact that cannot safely be removed or routed and why. + +A net increase is allowed only for a genuinely new current fact with no stronger owner. +Before allowing it, consolidate enough lower-priority material to remain within budget. +Never describe the session as reset-safe while the memory total is over budget or an exception is unresolved. + +## Knowledge sweep and routing -3. **Write within firstmate's existing write boundaries.** - This skill does not grant any new write permission; it only prompts firstmate to use the boundaries that already exist (AGENTS.md section 1): - - Captain preferences and fleet-local operational facts: hand-write directly to the destination selected by AGENTS.md's knowledge-routing table, using inspect-then-update every time. - Before writing, inspect the destination, find the existing bullet or section the finding duplicates or supersedes, and rewrite it in place rather than adding a new trailing entry. - `data/learnings.md` may not exist yet; create it on first local learning, in the same dated, evidence-backed, curated style as the captain-preference files. - - Project-intrinsic knowledge: never hand-write a project's `AGENTS.md`. - Route it through a normal ship task so a crewmate records it via `bin/fm-ensure-agents-md.sh` and commits it through that project's delivery pipeline, exactly as section 6 describes. - If the fleet is live, delegate this to a crewmate rather than doing it inline. - - Knowledge generalizable to every firstmate user: this repo's own `AGENTS.md` (or other shared, tracked material), shipped through the normal branch -> no-mistakes -> PR -> captain-merge pipeline for this repo (section 1), never hand-committed straight to `main`. - - Task-scoped notes: inspect the relevant backlog item with `tasks-axi show --full`, judge whether the new note is new, duplicate, superseding, or obsolete, then write a considered replacement body with `tasks-axi update --body-file `. - When the replacement intentionally supersedes prior state that should remain recoverable, add `--archive-body` to that update command so the prior body stays recoverable without copying it into the replacement. +1. **Sweep the session for uncaptured durable knowledge.** + Look for operational learnings, captain preferences expressed in passing, project-intrinsic facts, standing decisions, and undone next steps. +2. **Route each finding using AGENTS.md's knowledge-routing table.** + AGENTS.md section 6 is the source of truth for destinations. + Do not re-derive or duplicate that mapping here. +3. **Write within the existing boundaries.** + - Captain preferences and fleet-local operational facts belong in the destination selected by AGENTS.md after the required whole-file curation pass. + Create `data/learnings.md` only for a genuinely new local learning with no stronger owner. + - In a primary home, curate shared captain preferences only under the existing primary-authoritative shared-preference contract. + In a secondmate home, route a newly discovered shared preference to the main firstmate through marked status or a document pointer instead of editing the inherited file. + - Project-intrinsic knowledge never goes directly into a project's `AGENTS.md`. + Route it through a normal ship task so a crewmate records it with `bin/fm-ensure-agents-md.sh` and the project's delivery path. + - Knowledge general to every Firstmate user belongs in this repo's shared tracked material through the normal branch, no-mistakes, PR, and captain-merge path. + - For task-scoped notes, inspect the item with `tasks-axi show --full`, classify the change as new, duplicate, superseding, or obsolete, then use a considered replacement body through `tasks-axi update --body-file `. + Use `--archive-body` when recoverability matters. Never append. - If hand-editing `data/backlog.md` per the active backend, make the same inspect-then-update edit in place. - - Undone next steps: file each as a queued backlog item (section 10), with `blocked-by` recorded if it genuinely depends on something else. + - File each undone next step as a queued backlog item with a genuine `blocked-by` dependency when applicable. +4. **Use inspect-then-update.** + For every retained fact, ask which current statement it supersedes, whether it can be a one-sentence rewrite, and whether a stale entry should be deleted, retired, or routed to an existing stronger owner. + The only graduation moves are promotion to tracked shared material through a PR, folding a learning into the captain-preference destination selected by AGENTS.md, or deletion of a stale entry. + Do not invent another graduation path. + +## Completion receipt + +Report the outcome in plain captain-facing language with all of these facts: -4. **Curate with inspect-then-update.** - Every write starts by reading the current destination and deciding how the finding changes what is already there. - Use this checklist before writing: - - Which existing bullet, section, or task body does this supersede? - - Can this be a one-sentence rewrite instead of a new entry? - - Should an older bullet or note be deleted, retired, or archived because it is now obsolete? - When a finding overlaps or supersedes something already on disk, rewrite or prune the existing entry instead of piling on a new one. - Graduation moves are limited to exactly three: promote a learning to the shared `AGENTS.md` via PR, fold it into the captain-preference destination selected by AGENTS.md, or delete a stale entry. - Do not invent other graduation paths. +- effective startup-memory budget and total estimated tokens before and after; +- one or more actions for each of `data/captain.md`, `data/captain-shared.md`, and `data/learnings.md`: `unchanged`, `added`, `rewritten`, `pruned`, or `routed`; +- each durable finding filed outside memory and its authoritative owner; +- every unresolved exception, including a primary-owned shared-file constraint in a secondmate home; +- whether the session is safe to reset, only when all durable findings are captured and the post-pass result is within budget with no exception. -5. **Report to the captain.** - Summarize, in plain outcome language (section 9): what was stowed and where, what was filed to the backlog, and whether the session is now safe to reset or destroy - i.e. whether every durable finding from this sweep now lives on disk rather than only in this conversation. - If something could not be captured yet (for example, project-intrinsic knowledge waiting on a crewmate to land it), say so explicitly rather than reporting the session fully safe. +Do not hide an over-budget result behind a reset-safe claim. ## Scope exclusion: no skill storage -`/stow` must **never** store, create, or edit a skill as a destination for any finding. +`/stow` must never store, create, or edit a skill as a destination for any finding. There is no "graduate this to a skill" move in this skill's routing. -This is a deliberate, standing exclusion, not an oversight: even with the two-tier skill layout, a stow sweep is a memory-routing operation, not a way to author or mutate skills. -Writing learnings into either `.agents/skills/` or public `skills/` would still risk mixing fleet-local material with shared firstmate behavior or standalone installer-facing behavior. -Until a human deliberately scopes a skill change as firstmate repo work, route generalizable knowledge to the shared `AGENTS.md` (or other shared, tracked material) via the pipeline, and fleet-local knowledge to `data/`, never to a skill. +Until a human deliberately scopes a skill change as Firstmate repository work, route generalizable knowledge to shared tracked material through its pipeline and fleet-local knowledge to `data/`, never to `.agents/skills/` or public `skills/`. diff --git a/.pi/extensions/fm-calm.ts b/.pi/extensions/fm-calm.ts index f78c1b5acd9..1fb9cf12c48 100644 --- a/.pi/extensions/fm-calm.ts +++ b/.pi/extensions/fm-calm.ts @@ -1,9 +1,11 @@ // Firstmate's home-persistent Pi transcript presentation toggle. // // Verified against Pi 0.81.1 and 0.82.0, which expose built-in ToolDefinitions, per-slot -// renderers, renderShell: "self", session_start replacement reasons, -// ExtensionUIContext.setToolsExpanded(), setWorkingVisible(), and -// setHiddenThinkingLabel(). The focused tests pin those assumptions but never reject a +// renderers, renderShell: "self", session_start replacement reasons, agent_start and +// agent_settled, ExtensionUIContext.setToolsExpanded(), setWorkingVisible(), setWidget() +// with a disposable component factory, and setHiddenThinkingLabel(). +// ./lib/fm-calm-working-ship.ts owns the animated working presentation this file +// installs. The focused tests pin those assumptions but never reject a // newer Pi solely for its version. The collapsed-thinking and operational-user // presentation adapters probe the exact API they patch and degrade independently with a // diagnostic (see installCalmPresentationAdapter below) if a future Pi removes it; Pi @@ -21,6 +23,7 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { ExtensionAPI, + ExtensionUIContext, ToolDefinition, ToolRenderResultOptions, } from "@earendil-works/pi-coding-agent"; @@ -37,6 +40,11 @@ import { Box, Container, getKeybindings, type Component } from "@earendil-works/ import type { TSchema } from "typebox"; import { installCalmAssistantLayout } from "./lib/fm-calm-assistant-layout.ts"; import { installCalmOperationalUserLayout } from "./lib/fm-calm-operational-user-layout.ts"; +import { + CALM_WORKING_SHIP_WIDGET_KEY, + createCalmWorkingShipAnimation, + createCalmWorkingShipWidget, +} from "./lib/fm-calm-working-ship.ts"; import { calmPresentationHides, calmPresentationIsActive, @@ -93,6 +101,36 @@ export default function (pi: ExtensionAPI) { let exportRendering = false; let removeTerminalInputHandler: (() => void) | undefined; + // One logical agent run, tracked from agent_start through agent_settled rather than + // from turns or tool calls, so the boat never flickers between tool calls, automatic + // continuations, retries, or compaction that stay inside the same run. + let agentRunActive = false; + let workingShipShown = false; + // One animation instance per extension lifetime. Hiding the working widget freezes + // this state; the next working period resumes it. session_start resets it so a fresh + // Pi session starts at the normal initial position. Never module-global. + const workingShipAnimation = createCalmWorkingShipAnimation(); + + // Single owner of Calm's working-row presentation choice. The widget is only created + // or removed on a real transition, so repeated starts cannot duplicate its timer. + const applyWorkingPresentation = ( + ui: ExtensionUIContext, + forceStockVisibility = false, + ): void => { + const showShip = agentRunActive && calmPresentationIsActive(); + if (showShip !== workingShipShown) { + workingShipShown = showShip; + ui.setWidget( + CALM_WORKING_SHIP_WIDGET_KEY, + showShip + ? (tui) => createCalmWorkingShipWidget(tui, workingShipAnimation) + : undefined, + ); + ui.setWorkingVisible(!showShip); + } else if (forceStockVisibility && !showShip) { + ui.setWorkingVisible(true); + } + }; const fmHome = process.env.FM_HOME || process.env.FM_ROOT_OVERRIDE || root; const configDirectory = process.env.FM_CONFIG_OVERRIDE || resolve(fmHome, "config"); @@ -241,7 +279,11 @@ export default function (pi: ExtensionAPI) { setCalmPresentation(loadCalmPreference()); setCalmStockExportRendering(false); publishPresentationState(); - ctx.ui.setWorkingVisible(true); + agentRunActive = false; + workingShipShown = false; + // A genuine new session lifetime starts the boat at the normal initial position. + workingShipAnimation.reset(); + applyWorkingPresentation(ctx.ui, true); ctx.ui.setHiddenThinkingLabel(calmPresentationIsActive() ? "" : undefined); ctx.ui.setStatus("firstmate-calm", undefined); removeTerminalInputHandler?.(); @@ -271,6 +313,22 @@ export default function (pi: ExtensionAPI) { }); }); + pi.on("agent_start", (_event, ctx) => { + agentRunActive = true; + applyWorkingPresentation(ctx.ui); + }); + + // agent_settled is emitted from a finally block, so it also covers abort and failure. + pi.on("agent_settled", (_event, ctx) => { + agentRunActive = false; + applyWorkingPresentation(ctx.ui); + }); + + pi.on("session_shutdown", (_event, ctx) => { + agentRunActive = false; + applyWorkingPresentation(ctx.ui); + }); + pi.registerCommand("calm", { description: "Toggle Firstmate's supported conversation-only transcript presentation.", handler: async (_args, ctx) => { @@ -278,7 +336,7 @@ export default function (pi: ExtensionAPI) { persistCalmPreference(active); setCalmPresentation(active); publishPresentationState(); - ctx.ui.setWorkingVisible(true); + applyWorkingPresentation(ctx.ui, true); ctx.ui.setHiddenThinkingLabel(active ? "" : undefined); ctx.ui.setStatus("firstmate-calm", undefined); diff --git a/.pi/extensions/lib/fm-calm-working-ship.ts b/.pi/extensions/lib/fm-calm-working-ship.ts new file mode 100644 index 00000000000..390e28baebf --- /dev/null +++ b/.pi/extensions/lib/fm-calm-working-ship.ts @@ -0,0 +1,243 @@ +// Firstmate's Calm-only animated working presentation. +// +// Calm replaces Pi's stock working row with a tiny SSHHIP-derived boat while one +// logical agent run is active. This module owns only the sprite geometry, the bounce +// track, the two animation cadences, the session-scoped freeze/resume state, and the +// temporary TUI widget; `.pi/extensions/fm-calm.ts` owns when the presentation is +// installed and removed, and stays the sole caller of setWorkingVisible(). +// docs/calm.md owns the captain-facing contract. +// +// Cadence: one scheduler drives two logically independent clocks. Every tick advances +// the water phase, and only every CALM_WORKING_SHIP_TICKS_PER_MOVE-th tick moves the +// boat, so the water visibly ripples several times between boat steps and the boat +// itself reads as calm. Both clocks stop together when the widget is disposed. Ticks, +// not wall-clock timestamps, drive every state change, so tests can seek time exactly. +// +// Continuity: one extension-owned animation instance survives hide/show within the same +// Pi process and Calm extension lifetime. Disposing the widget freezes column, +// direction, water phase, and tick cadence without advancing them for hidden wall +// time. The next working period resumes from that exact logical state. A fresh session +// or new extension lifetime calls reset() and starts at the normal initial position. +// State is never a module-level or process-global singleton. +// +// Verified against Pi 0.81.1 declarations and the Pi 0.82.0 CLI, which expose +// ExtensionUIContext.setWidget() with a component factory, per-widget dispose(), and +// TUI.requestRender(). Pi renders a widget through Component.render(width), so this +// module recomputes its track from that width on every frame instead of caching a +// terminal size that a resize would invalidate. A resize while the boat is hidden is +// applied on the first resumed frame through the same clamp path. +import type { Component, TUI } from "@earendil-works/pi-tui"; + +// The hull is symmetric and replaces waves on its row rather than adding a third row. +const HULL = "\\__/"; +// A mainsail extends aft of the mast, so it trails behind the bow relative to travel. +const SAIL_RIGHT = "<|"; +const SAIL_LEFT = "|>"; +// Centers the two-cell sail over the four-cell hull. +const SAIL_OFFSET = 1; +const HULL_WIDTH = HULL.length; +const SAIL_WIDTH = SAIL_RIGHT.length; + +// Bounded deterministic fixed-cell water phases. Every entry is exactly one column, so +// advancing the phase ripples the surface without changing visible width or row count. +const WAVE_CYCLE = ["~", "~", "-", "~"] as const; + +// Standard ANSI foreground codes only: no theme lookup, bright variant, or 256/RGB. +const BLUE = "\u001b[34m"; +const YELLOW = "\u001b[33m"; +// Restores the default foreground so color never bleeds into padding or later frames. +const RESET = "\u001b[39m"; + +export const CALM_WORKING_SHIP_WIDGET_KEY = "firstmate-calm-working-ship"; +/** Scheduler period. One tick advances the water by one phase. */ +export const CALM_WORKING_SHIP_TICK_MS = 220; +/** Boat moves one column every Nth tick, so it travels at 220 * 4 = 880ms per column. */ +export const CALM_WORKING_SHIP_TICKS_PER_MOVE = 4; + +export type CalmWorkingShipAnimation = { + /** Render one frame that exactly fits `width`, clamping the track to it first. */ + render(width: number): string[]; + /** Advance one scheduler tick: water every tick, boat on its slower cadence. */ + tick(): void; + restoreLastRendered(): void; + /** Restore the normal initial column, direction, water phase, and cadence. */ + reset(): void; + /** + * Clamp the frozen column and direction to `width` without advancing time. + * Used when a terminal resize lands while the working presentation is hidden. + */ + clampToWidth(width: number): void; + /** Current hull column, exposed for deterministic motion assertions. */ + position(): number; + /** Current travel direction: 1 travelling right, -1 travelling left. */ + direction(): number; + /** Current water phase, exposed for deterministic ripple assertions. */ + waterPhase(): number; +}; + +/** Longest hull start column that still fits the sprite in `width` usable cells. */ +function trackSpan(width: number): number { + if (width >= HULL_WIDTH) return width - HULL_WIDTH; + if (width >= SAIL_WIDTH) return width - SAIL_WIDTH; + return 0; +} + +export function createCalmWorkingShipAnimation(): CalmWorkingShipAnimation { + let position = 0; + let direction = 1; + let span = 0; + let phase = 0; + let ticks = 0; + let renderedPosition = position; + let renderedDirection = direction; + let renderedSpan = span; + let renderedPhase = phase; + let renderedTicks = ticks; + + // Reversing the moment the boat lands on an endpoint means the endpoint frame itself + // already shows the new heading, so no frame at or after a bounce shows the old sail. + const settleDirectionAtEdges = (): void => { + if (span <= 0) return; + if (position >= span) direction = -1; + else if (position <= 0) direction = 1; + }; + + const applyWidth = (width: number): void => { + if (width <= 0) { + span = 0; + position = 0; + return; + } + span = trackSpan(width); + position = Math.min(position, span); + settleDirectionAtEdges(); + }; + + const commitRenderedState = (): void => { + renderedPosition = position; + renderedDirection = direction; + renderedSpan = span; + renderedPhase = phase; + renderedTicks = ticks; + }; + + const restoreLastRenderedState = (): void => { + position = renderedPosition; + direction = renderedDirection; + span = renderedSpan; + phase = renderedPhase; + ticks = renderedTicks; + }; + + /** One colored run of water covering absolute columns [from, from + count). */ + const water = (from: number, count: number): string => { + if (count <= 0) return ""; + let cells = ""; + for (let column = from; column < from + count; column += 1) { + cells += WAVE_CYCLE[(column + phase) % WAVE_CYCLE.length]; + } + return `${BLUE}${cells}${RESET}`; + }; + + const boat = (text: string): string => `${YELLOW}${text}${RESET}`; + + return { + position: () => position, + direction: () => direction, + waterPhase: () => phase, + + restoreLastRendered: restoreLastRenderedState, + + reset(): void { + position = 0; + direction = 1; + span = 0; + phase = 0; + ticks = 0; + commitRenderedState(); + }, + + clampToWidth(width: number): void { + applyWidth(width); + }, + + tick(): void { + ticks += 1; + phase = (phase + 1) % WAVE_CYCLE.length; + if (ticks % CALM_WORKING_SHIP_TICKS_PER_MOVE !== 0) return; + if (span <= 0) { + position = 0; + return; + } + position = Math.min(span, Math.max(0, position + direction)); + settleDirectionAtEdges(); + }, + + render(width: number): string[] { + if (width <= 0) return []; + + // A resize lands here before the next frame, so recompute and clamp the track + // immediately rather than trusting a position measured against the old width. + applyWidth(width); + + const sail = direction >= 0 ? SAIL_RIGHT : SAIL_LEFT; + + let frame: string[]; + if (width < SAIL_WIDTH) { + // Too narrow for even the sail: a deterministic single row of water. + frame = [water(0, width)]; + } else if (width < HULL_WIDTH) { + // Too narrow for the hull: the sail alone rides the water row. + frame = [ + water(0, position) + + boat(sail) + + water(position + SAIL_WIDTH, width - position - SAIL_WIDTH), + ]; + } else { + frame = [ + " ".repeat(position + SAIL_OFFSET) + boat(sail), + water(0, position) + + boat(HULL) + + water(position + HULL_WIDTH, width - position - HULL_WIDTH), + ]; + } + + commitRenderedState(); + return frame; + }, + }; +} + +/** + * Build the temporary Calm working widget bound to one caller-owned animation. + * Pi disposes the previous component before installing a replacement under the same + * key and when it clears extension widgets, so the single scheduler driving both + * cadences cannot outlive the widget or duplicate. Disposing freezes the shared + * animation in place; the next widget bound to the same animation resumes without + * applying hidden wall time. + */ +export function createCalmWorkingShipWidget( + tui: TUI, + animation: CalmWorkingShipAnimation = createCalmWorkingShipAnimation(), +): Component & { dispose(): void } { + let disposed = false; + const timer = setInterval(() => { + if (disposed) return; + animation.tick(); + tui.requestRender(); + }, CALM_WORKING_SHIP_TICK_MS); + // The animation must never keep Pi's process alive on its own. + timer.unref?.(); + + return { + render: (width) => (disposed ? [] : animation.render(width)), + // Every frame is rebuilt from fixed standard ANSI codes, so there is no cache. + invalidate: () => {}, + dispose: () => { + if (disposed) return; + disposed = true; + clearInterval(timer); + animation.restoreLastRendered(); + }, + }; +} diff --git a/AGENTS.md b/AGENTS.md index 7a6ab150309..01bbeccf12c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ Hard rules, in priority order: Those paths never authorize forcing, stashing, discarding unlanded work, or hand-writing a project's `AGENTS.md`. Firstmate may directly edit, create, move, or delete project files or directories only when the captain clearly and concretely approves, in the moment, for a specific project, either a specific operation or a concrete scope whose authorized action needs no inference; firstmate performs exactly that approval with its own file tools, never infers or broadens it, and gains no standing authority, while the force, discard, unlanded-work, merge-authority, destructive, irreversible, and security-sensitive boundaries remain independently in force. 2. **Never merge a PR without the captain's explicit word.** - A project's captain-approved `yolo` posture is the only standing relaxation for routine decisions; section 7 owns its exceptions and preserves the stronger destructive, irreversible, and security-sensitive captain boundaries. + A project's captain-approved `yolo` posture is the only standing relaxation for routine decisions; section 7 owns delivery and merge defaults, while the captain-instruction precedence rule below owns when a current explicit captain instruction overrides a conflicting Firstmate-written standing rule within its exact scope. 3. **Never tear down unlanded work.** Uncommitted changes are never landed, and `bin/fm-teardown.sh` owns the complete landed-work test. Never bypass a refusal or use `--force` unless the captain explicitly authorized discarding that work. @@ -71,6 +71,7 @@ config/secondmate-harness harness the PRIMARY uses to launch SECONDMATE agents, config/backlog-backend backlog backend override; LOCAL, gitignored; absent or "tasks-axi" = default tasks-axi backend, "manual" = force routine backlog updates to hand-editing; inherited by secondmate homes (section 10) config/backend runtime session-provider backend override for new tasks; LOCAL, gitignored; absent = falls through to runtime auto-detection (the runtime firstmate itself is executing inside), then tmux; tmux is the verified reference backend (docs/tmux-backend.md), while herdr, zellij, orca, and cmux are experimental spawn backends (docs/herdr-backend.md, docs/zellij-backend.md, docs/orca-backend.md, docs/cmux-backend.md) - herdr and cmux can also be selected by runtime auto-detection, zellij and orca never are (always explicit), and codex-app is not accepted; see docs/codex-app-backend.md; inherited by secondmate homes under the primary-authoritative contract in secondmate-provisioning config/calm Pi Calm presentation preference; LOCAL, gitignored, and not inherited; see docs/configuration.md "Pi Calm preference" +config/startup-memory-budget primary-authoritative per-home startup-memory budget; LOCAL, gitignored, materialized as 7,500 estimated tokens by locked primary bootstrap and inherited into secondmate homes; see docs/configuration.md "Startup memory budget" config/herdr-presentation-spaces optional presence flag for Herdr's default-off disposable single-task visual projection; LOCAL, gitignored; inherited by secondmate homes; see docs/herdr-backend.md "Optional presentation spaces" config/cmux-socket-password optional cmux control-socket password; LOCAL, gitignored; read fresh on every cmux CLI call and passed through without ever overriding an operator's own ambient CMUX_SOCKET_PASSWORD when absent (docs/cmux-backend.md "Setup") config/wedge-alarm optional away-mode wedge-alarm active-alert directives; LOCAL, gitignored; absent means auto (macOS Notification Center when available); see docs/wedge-alarm.md @@ -95,6 +96,7 @@ state/ volatile runtime signals; gitignored .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 + validation-lane private home-local no-mistakes slot holder and FIFO queue; bin/fm-validation-lane.sh owns its format and release mechanics .pr-poll private validated data sidecar for the byte-static PR merge poll .pr-poll-registration private transactional provenance record binding the task, canonical metadata identity, sidecar, and static poll publication .pr-poll-retirement private identity-bound crash-recovery receipt for one exact validated merged result; removed after its poll artifacts retire @@ -107,6 +109,7 @@ state/ volatile runtime signals; gitignored x-inbox/ generated X-mode pending mention payloads; fmx-respond drains it (section 14) x-context/ generated X-mode durable per-request reply context and delivery markers, keyed by request_id; survives inbox cleanup and expires within seven days (section 14; docs/configuration.md) x-outbox/ generated X-mode dry-run reply and dismiss previews; inspect it when FMX_DRY_RUN is set (section 14) + public-followup/ generated private transport for promised public replies: commitment registrations, typed terminal-result inbox, accepted/rejected ledgers (section 14; bin/fm-public-followup.sh) x-poll.error x-poll.claim-error generated X-mode relay and offer-claim diagnostic dedupe markers .wake-queue durable queued wakes: epochseqkindkeypayload .afk durable away-mode flag; present = sub-supervisor may inject escalations (set by /afk, cleared on user return) @@ -169,18 +172,21 @@ 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 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. +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 effective headroom and usable runway, using pace and reserve only later when needed. +Account for every candidate with the catalog evidence, provider relationship, applicable quota and authentication facts, remaining uncertainty, fit and reasoning class, and the headroom, runway, and later pace or reserve evidence used in selection; never omit a candidate, guess, fall back silently, or call the result quota-informed without them. +Establish model support and provider family from that harness's own authoritative catalog, then read `quota-axi` at the granularity the vendor actually supplies: provider-level or all-model evidence applies to every model established in that family, and a named-model window bounds only that model. +Missing model-level quota, a missing authentication source, unmeasurable headroom, or unmodeled authentication is disclosed uncertainty that keeps a candidate eligible, never a credential or login escalation. +Only concrete contradictory evidence blocks a candidate, such as an authoritative catalog proving the model unsupported or proof that the credential selected for that surface is unusable; never infer a credential store, provider family, or quota mapping from a harness, model, or source name, and never launch another harness's CLI to judge a candidate. 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. +Break genuine evidence ties without array-order or harness bias. `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. +Load `quota-array-dispatch` before choosing among a matched profile array; that skill is the single owner of the completion-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. `secondmate-provisioning` owns secondmate harness pins and inherited local material, while `harness-adapters` owns the harness consequences. -Dispatch only on a backend that `fm-spawn` validates as spawn-capable. +Dispatch only on a backend that `fm-spawn` validates as spawn-capable; pass an explicit per-spawn `--backend` only under that exact task's own authority, never as later-task precedent (selection contract: [`docs/configuration.md`](docs/configuration.md) "Runtime backend"). A missing dependency, authentication failure, unsupported backend, or version refusal is a blocker; never silently retry on another backend. ## 5. Recovery @@ -287,21 +293,30 @@ The path's worker, automated gates, and captain approval remain authoritative: Delivery mode and `yolo` are orthogonal. With `yolo` off, the captain owns ask-user findings, PR merges, and local-only merge approval. -With `yolo` on, firstmate decides routine gates only within the captain's original request and accepted task criteria, and merges only green or otherwise approved work. +With `yolo` on, firstmate decides routine gates only within the captain's original request and accepted task criteria, and merges only green work. Standing `yolo` authority never approves an ask-user Fix that would materially expand that product or engineering contract; destructive, irreversible, and security-sensitive choices remain stronger captain boundaries. Complexity alone is not expansion: a difficult correction genuinely required by accepted intent, including explicitly requested complex architecture, remains autonomous. Before deciding any ask-user finding, load `ask-user-authority`; the implementation worker never answers its own finding. Never merge a red PR. +Without a current explicit captain instruction that states the concrete merge, that default stands, and standing `yolo` cannot authorize a red merge; section 1 owns when such an instruction overrides a Firstmate-written standing rule within its exact scope. Use `bin/fm-pr-merge.sh` for every task PR merge so merge metadata is recorded, and use `bin/fm-merge-local.sh` for approved local-only landing; never call a lower-level merge command around their guards. After an autonomous merge, give the captain a one-line full-URL or local-main outcome. ### Validate -For a no-mistakes ship, trigger validation on the same worker after its implementation commit, using the harness invocation owned by `harness-adapters`. +For a no-mistakes ship, enqueue validation on the same worker after its implementation commit with `bin/fm-validation-lane.sh enqueue `. +The scheduler uses a home-local slot and verified `fm-send` release; its header owns the durable state and watcher-check mechanics. The task worker that starts a no-mistakes run drives the pipeline and owns every `no-mistakes axi run` and `no-mistakes axi respond` call through the next gate or outcome. Firstmate never invokes `no-mistakes axi respond` for a crew-owned run. Once validation starts, prefer routing new requirements to follow-up work rather than expanding the current task, unless a new requirement completely invalidates the work being validated; however, the smallest downstream changes needed to keep already accepted product or engineering behavior correct, add behavioral tests where an executable contract exists, or keep documentation accurate remain within the current task even when they touch files not named at intake, and corrections required to satisfy already accepted intent are not new requirements. +Only a current, explicit captain instruction that completely invalidates the work being validated keeps the task with the same worker instead of routing it to follow-up work or handing it to a replacement. +That worker cancels the active run through no-mistakes axi's supported abort command and confirms through axi status that the run has stopped before changing any code. +The worker then follows `branch_sync.next_action` from structured axi status: use axi sync's supported guarded recovery only when its code is `recover_custody`, and otherwise proceed only when structured status confirms that branch ownership is already returned and no recovery is required. +Custody recovery settles branch ownership, not content: the worker must replace the obsolete work from the correct pre-invalidation base rather than building on top of the recovered-but-obsolete head, keeping the obsolete run's own pipeline-fix commits out of what gets validated and shipped. +Apart from that single supported abort, do not hand-edit, commit, restart, or start a second validation run while the obsolete run still owns the branch. +Once ownership is settled, validate exactly once against that final head so no obsolete or intermediate head is ever treated as authoritative. + An ask-user finding returns as `needs-decision`; firstmate decides only when the configured authority permits, otherwise escalates to the captain. Send the same worker one exact decision naming the decision key, step, action, affected finding IDs, instructions where needed, and exact response command. Require the matching `resolved` event, forbid `--yes`, and require the worker to process every synchronous return until completion or a genuinely new escalation. @@ -309,7 +324,7 @@ Resume fleet supervision immediately after the decision lands. Judge validation by the current-code-matched run step through `bin/fm-crew-state.sh`, not by shell liveness or the last status event. Running, fixing, or CI states remain working; parked approval or fix-review states require the worker to follow the active gate help; passed or checks-passed is done; failed or cancelled is failed. -A worker hand-editing, committing, aborting, or restarting during an active validation run duplicates pipeline ownership; steer it back to the gate response flow. +A worker hand-editing, committing, aborting, or restarting during an active validation run duplicates pipeline ownership outside the supersession sequence above; steer it back to the gate response flow. The worker reports the PR when CI first becomes green rather than waiting for merge monitoring to finish. ### PR ready, landing, and teardown @@ -366,7 +381,7 @@ After every successful teardown, update the backlog and launch dependency-cleare When no explicit capacity applies, preserve section 7's no-arbitrary-cap rule. When any wake reports a merged PR for a project cloned in this home, refresh that clone through the guarded fleet-sync path. -When X-linked work reaches a milestone or terminal state, load `fmx-respond`; before terminal teardown, always post the final completion follow-up so the link clears even if earlier follow-ups were spent. +When X-linked work reaches a milestone or terminal state, load `fmx-respond`; before terminal teardown, use its promised-final reconciliation when a typed public commitment exists, otherwise post the final completion follow-up so the link clears even if earlier follow-ups were spent. A secondmate's idle endpoint is healthy, and parent supervision relies on its routed status rather than treating a quiet pane as stale. Waiting on a healthy supervision cycle is silent; empty polls, elapsed time, and no-change updates are not captain-facing progress. @@ -485,7 +500,7 @@ It performs guarded fast-forward updates of firstmate and registered secondmate 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:`, `FMX:`, or `OPS_INBOX:`); silence and `BOOTSTRAP_INFO:` need no load. +- `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:`, `STARTUP_MEMORY_BUDGET:`, `CREW_DISPATCH: invalid`, `FLEET_SYNC:`, `PR_CHECK_MIGRATION:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `NUDGE_SECONDMATES:`, `FMX:`, or `OPS_INBOX:`); 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. @@ -496,7 +511,7 @@ These skills are not captain-invocable; load them only at their precise triggers - `stuck-crewmate-recovery` - load when the session-start digest reports an ordinary direct report's endpoint dead or its metadata has no window, or after a stale wake, looping pane, repeated confusion, an answered-by-brief question, an unresponsive crewmate, or a failed steer. - `secondmate-provisioning` - load before creating, seeding, validating, launching, handing backlog to, recovering, pushing inherited local material into, or retiring a secondmate home, and before editing `data/secondmates.md`. - `decision-hold-lifecycle` - load before treating an investigation or visual review as complete, before ending a visual review that exposed a decision, and when recording or routing the captain's answer. -- `fmx-respond` - load on an `x-mention ` `check:` wake to handle the mention, on an `x-mode-error ...` `check:` wake to report the X-mode configuration blocker, and on any milestone or terminal wake for an X-mode-linked task before posting its completion follow-up; relevant only when X mode is on. +- `fmx-respond` - load on an `x-mention ` `check:` wake to handle the mention, on an `x-mode-error ...` `check:` wake to report the X-mode configuration blocker, on a `public-followup ...` `check:` wake or a startup-surfaced public commitment, and on any milestone or terminal wake for an X-mode-linked task before posting its completion follow-up; relevant only when X mode is on. - `firstmate-codexapp` - load before coordinating a visible Codex Desktop thread, evaluating a Codex App backend request, or reconciling Codex Desktop host-tool smoke evidence for Firstmate work. - `firstmate-coding-guidelines` - load before changing firstmate's shared, tracked material, as defined by section 1's list, whether editing directly or briefing a crewmate for a firstmate-repo task. - `no-mistakes-reviewer-recovery` - load before starting no-mistakes validation when Claude is degraded, and on a review or document step that is quiet, failed, or cancelled. @@ -509,7 +524,20 @@ That token is consent for public replies and normal reversible lifecycle actions An X-only home still requires the live supervision cycle so mentions can wake it without fleet work. On an `x-mention ` or `x-mode-error ...` check wake, load `fmx-respond`, which owns classification, public-safety policy, reply or dismissal, task linking, and follow-ups. -For every X-linked terminal outcome, load that owner and post the final completion follow-up before teardown, regardless of earlier milestone follow-ups. +For every X-linked terminal outcome, load that owner and use the promised-final reconciliation when a typed public commitment exists, otherwise post the final completion follow-up before teardown. + +A promised final public reply is durable state, never conversation memory. +Load `fmx-respond` before promising one, on a `public-followup ...` check wake, and whenever the session-start digest lists a public commitment awaiting delivery. +Only the home holding the relay consent and thread binding ever posts it, so never ask a secondmate or crewmate to find the thread or send the reply, and never recover a terminal result by reading a `done:` sentence. + +## Captain instruction precedence + +A current, explicit, concrete captain instruction overrides any conflicting standing rule written above. +The instruction must be specific and recent: it must identify the concrete action, object, or bounded set it governs. +Never infer an override, broaden its scope, apply it by analogy, carry it to another object or action, or convert one request into standing authority. +Ambiguous scope or conflict still requires one concise clarification before action. +Destructive, irreversible, security-sensitive, discard, and merge actions still require the captain to state that concrete action explicitly; once the captain does so and higher-priority instructions permit it, a conflicting Firstmate-written rule must not rigidly block the action. +Standing `yolo` authority is not a substitute for a current explicit captain instruction where an explicit action is required. ## Maintaining this file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10f498fba02..559f889a829 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,7 +47,7 @@ See the [no-mistakes quick start](https://kunchenguid.github.io/no-mistakes/star Test scripts and helpers in `tests/` are plain bash too. `bin/fm-lint.sh` must pass: it is the single owner of the lint definition (the shellcheck file set, config, and pinned shellcheck version), and both CI and the no-mistakes pre-push gate run it, so local and CI can never diverge. It pins one exact shellcheck version and refuses to run under any other; print it with `bin/fm-lint.sh --required-version` and install that build locally. -- Changes to harness adapters (detection in `bin/fm-harness.sh`, launch and hook mechanics in `bin/fm-spawn.sh`, busy signatures in `bin/fm-watch.sh` and `bin/fm-tmux-lib.sh`, cleanup in `bin/fm-teardown.sh`, and facts in `.agents/skills/harness-adapters/SKILL.md`) must be verified empirically against the real harness, never written from documentation alone. +- Changes to harness adapters (detection in `bin/fm-harness.sh`, launch and hook mechanics in `bin/fm-spawn.sh`, semantic busy sources and trust gates in `bin/fm-busy-lib.sh`, delivery-only rendered guards in `bin/fm-tmux-lib.sh`, cleanup in `bin/fm-teardown.sh`, and facts in `.agents/skills/harness-adapters/SKILL.md`) must be verified empirically against the real harness, never written from documentation alone. - Changes to runtime session backends (`bin/fm-backend.sh`, `bin/backends/`, and the scripts that dispatch through them) keep current setup and limits in the relevant backend guide and active empirical evidence in [`docs/verification/runtime-backends.md`](docs/verification/runtime-backends.md). - [`docs/documentation-audiences.md`](docs/documentation-audiences.md) and its machine-consumed inventory own prose classification; run `bin/fm-doc-audience-check.sh` after documentation changes. - In Markdown, put each full sentence on its own line. diff --git a/README.md b/README.md index b2d63640df6..7a92868f240 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Launching a supported harness inside it instantiates your first mate - and makes - **Explicit project modes** - each project ships via `no-mistakes`, `direct-PR`, or `local-only`, with an optional `+yolo` autonomy flag. - **Optional secondmates** - opt in to persistent second mates that run from isolated firstmate homes with their own `FM_HOME`, state, projects, and session lock, supervising project clones or a project-less firstmate-repo domain, kept on the primary firstmate version by guarded local fast-forwards and checked for live agent processes at session start. - **Event-driven, zero-token supervision** - a bash watcher sleeps on the fleet and wakes the first mate only when something needs you; verified primary harnesses also get a turn-end backstop that blocks or follows up on a blind stop when work is under way and supervision is not live. -- **Optional X mode** - opt in with one local `.env` token so firstmate can answer your public `@myfirstmate` mentions, act on normal reversible mention requests through the same lifecycle as chat requests, acknowledge spawned work, and post up to three public-safe completion follow-ups within seven days for genuine milestones and the final outcome without changing non-X behavior; dry-run preview records would-be replies and dismissals locally before go-live. +- **Optional X mode** - opt in with one local `.env` token so firstmate can answer your public `@myfirstmate` mentions, act on normal reversible mention requests through the same lifecycle as chat requests, acknowledge spawned work, and post up to three public-safe completion follow-ups within seven days for genuine milestones and the final outcome without changing non-X behavior; a final reply promised in a thread becomes durable state that is reconciled from disk, so a restart or a compacted conversation cannot lose it; dry-run preview records would-be replies and dismissals locally before go-live. - **Strict project boundary** - the first mate is read-only over your projects except for the narrow guarded and captain-approved operations authorized by [hard rule 1](AGENTS.md#1-identity-and-prime-directives), including fleet sync's guarded safe branch pruning; crewmates make every other project change behind the configured merge authority. - **Restart-proof** - all state lives on disk and in the active session backend (tmux by hard default, herdr or cmux when selected or auto-detected, zellij/orca when explicitly selected); kill the session anytime and the next one reconciles, including confirmed-dead secondmate agents, and carries on. @@ -113,7 +113,7 @@ Press Tab in the picker to switch to `all` configured providers, including OpenR The responsive footer uses Pi's native local session data to show run state, model and thinking level, project or directory name, git branch, context tokens and percentage, cumulative input and output tokens, session cost, and extension status text without dropping complete values at narrow widths. When supported, the terminal title becomes `Firstmate · · · `. The presentation does not poll quotas or providers, start background processes, or invent Codex-only fields. -Pi's `/calm` toggle hides supported transcript chrome, including canonically classified Firstmate operational user rows, while retaining native working activity and all model context and session data. +Pi's `/calm` toggle hides supported transcript chrome, including canonically classified Firstmate operational user rows, and uses a Calm-only animated working boat during active runs while preserving all model context and session data. The hidden operational inputs remain ordinary user-role messages with unchanged delivery, ordering, authority, persistence, and exports. The preference persists for the effective Firstmate home, and toggling it off restores ordinary rendering. [Calm's current behavior and supported limits](docs/calm.md) are separate from its [version-scoped maintainer evidence](docs/calm-mode-feasibility.md). diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 8b44b3c6462..92e2ce440bf 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -27,9 +27,17 @@ # duplicate-agent risk is independently absent. # Target resolution stays parallel to the tmux adapter in both layouts. # Projected create, move, and cleanup operations capture the named session's -# exact active workspace and tab. Herdr 0.7.4's last-pane close can focus an -# unrelated neighbor, so projected cleanup serializes and restores only the -# exact pre-close tab id, while refusing to close the active tab itself. +# exact active workspace and tab. On Herdr 0.7.5, an explicit close that +# empties a non-focused workspace moves focus to that workspace's neighbor +# (upstream discussion #1328, fixed by PR #1877), while a pane-death removal +# preserves focus exactly when the dying workspace sits behind the focused +# one or the focused one is last (upstream issue #1621, fixed by PR #1912); +# both fixes are merged upstream but in no release. Projected cleanup +# therefore serializes under the session lock, repositions a doomed workspace +# behind the focused one when needed, and ends its verified lone idle shell +# so Herdr removes the emptied workspace through the focus-preserving +# pane-death path, with the exact pre-close tab restore as the backstop and a +# refusal to close the active tab itself. # # Target string shape: ":", e.g. "default:w1:p2" (the # pane id itself contains a colon; the session is always the FIRST field, the @@ -462,18 +470,15 @@ fm_backend_herdr_presentation_lock_namespace_valid() { # it would turn JSON null into the literal string "null"). Canonicalizes the # parent directory when that directory exists so symlink parents such as /tmp # -> /private/tmp cannot yield two lock identities for the same socket. -fm_backend_herdr_presentation_session_socket_path() { # - local session=$1 sessions socket sock_dir sock_base - [ -n "$session" ] || return 1 - sessions=$(fm_backend_herdr_cli "$session" session list --json 2>/dev/null) || return 1 - socket=$(printf '%s' "$sessions" | jq -er --arg want "$session" ' - [.sessions[]? - | select(.name == $want and .running == true) - | select((.socket_path | type) == "string") - | select((.socket_path | length) > 0) - | .socket_path] - | if length == 1 then .[0] else empty end - ' 2>/dev/null) || return 1 +# fm_backend_herdr_canonical_socket_path: normalize one absolute Unix-socket +# path so two spellings of the same socket compare equal. Refuses a relative +# or empty path. An unresolvable directory is left as-is rather than treated as +# a failure, so a socket whose directory was removed still compares by its own +# literal path. Single owner for every socket-identity comparison in this +# adapter (the presentation session lock and the launcher-identity same-session +# proof both use it). +fm_backend_herdr_canonical_socket_path() { # + local socket=$1 sock_dir sock_base [ -n "$socket" ] || return 1 case "$socket" in /*) ;; @@ -489,6 +494,21 @@ fm_backend_herdr_presentation_session_socket_path() { # printf '%s' "$socket" } +fm_backend_herdr_presentation_session_socket_path() { # + local session=$1 sessions socket + [ -n "$session" ] || return 1 + sessions=$(fm_backend_herdr_cli "$session" session list --json 2>/dev/null) || return 1 + socket=$(printf '%s' "$sessions" | jq -er --arg want "$session" ' + [.sessions[]? + | select(.name == $want and .running == true) + | select((.socket_path | type) == "string") + | select((.socket_path | length) > 0) + | .socket_path] + | if length == 1 then .[0] else empty end + ' 2>/dev/null) || return 1 + fm_backend_herdr_canonical_socket_path "$socket" +} + fm_backend_herdr_presentation_session_lock_path() { # local session=$1 socket key dir hash [ -n "$session" ] || return 1 @@ -546,8 +566,11 @@ fm_backend_herdr_projection_focus_snapshot() { # # fm_backend_herdr_projection_focus_restore: verify that one presentation # mutation preserved the exact active workspace and tab captured immediately # before it. -# Herdr 0.7.4's pane.close can focus an unrelated neighboring workspace when -# it removes a non-focused workspace's last pane. +# This is the backstop for every focus-unsafe instant: on Herdr 0.7.5 an +# explicit pane.close that empties a non-focused workspace moves focus to +# that workspace's neighbor (upstream #1328/#1877), and a pane-death removal +# before a non-last focused workspace moves focus to the focused workspace's +# right neighbor (upstream #1621/#1912); both fixes are unreleased. # A single tab.focus on the exact response-independent pre-operation tab id # restores both the workspace and tab atomically. fm_backend_herdr_projection_focus_restore() { # @@ -587,9 +610,17 @@ fm_backend_herdr_projection_focus_restore() { # [required-agent-state] local session=$1 pane_id=$2 required_agent_state=${3:-} - local before active_tab info target_pane target_tab close_status state + local before active_tab info target_pane target_tab target_ws close_status state plan plan_shell_pid plan_move_record workspace_presence FM_BACKEND_HERDR_PROJECTION_CLOSE_AGENT_STATE="" [ -n "$pane_id" ] || return 0 before=$(fm_backend_herdr_projection_focus_snapshot "$session") || { @@ -603,6 +634,7 @@ fm_backend_herdr_projection_close_pane_focus_preserving() { # /dev/null) target_tab=$(printf '%s' "$info" | jq -r '.result.pane.tab_id // empty' 2>/dev/null) + target_ws=$(printf '%s' "$info" | jq -r '.result.pane.workspace_id // empty' 2>/dev/null) if [ "$target_pane" != "$pane_id" ] || [ -z "$target_tab" ]; then echo "warning: herdr presentation cleanup received an ambiguous exact-pane response; refusing focus-unsafe pane close" >&2 return 1 @@ -616,20 +648,380 @@ fm_backend_herdr_projection_close_pane_focus_preserving() { # /dev/null 2>&1; then + plan=plain + plan_shell_pid= + plan_move_record= + if [ -n "$target_ws" ]; then + plan=$(fm_backend_herdr_emptying_close_plan "$session" "$pane_id" "$target_ws" "$target_tab" "${before%%$'\t'*}") + case "$plan" in + moved$'\t'*) + plan_move_record=${plan%%$'\n'*} + plan=${plan##*$'\n'} + ;; + esac + case "$plan" in + death\ *) + plan_shell_pid=${plan#death } + plan=death + ;; + *) + plan=plain + ;; + esac + fi + if [ "$plan" = death ]; then + if fm_backend_herdr_death_close_pane "$session" "$pane_id" "$plan_shell_pid"; then + close_status=0 + elif fm_backend_herdr_explicit_close_pane_confirmed "$session" "$pane_id"; then + close_status=0 + else + close_status=1 + fi + elif fm_backend_herdr_explicit_close_pane_confirmed "$session" "$pane_id"; then close_status=0 else - close_status=$? + close_status=1 + fi + if [ "$close_status" -eq 0 ] && [ -n "$plan_move_record" ]; then + workspace_presence=$(fm_backend_herdr_workspace_presence_state "$session" "$target_ws") + if [ "$workspace_presence" != dead ]; then + echo "warning: herdr presentation cleanup did not confirm removal of the repositioned workspace" >&2 + close_status=1 + fi + fi + if [ "$close_status" -ne 0 ]; then + fm_backend_herdr_emptying_move_rollback "$plan_move_record" || true fi fm_backend_herdr_projection_focus_restore "$session" "$before" "pane close" || return 2 [ "$close_status" -eq 0 ] } +# Herdr 0.7.5 workspace-removal focus rules (verified against the installed +# 0.7.5 binary, its v0.7.5 tag source, and the isolated named lab): +# - An EXPLICIT close that empties a workspace (API pane.close of its last +# pane, tab close, or workspace close) routes through +# close_selected_workspace, which assigns focus to the closing workspace's +# right neighbor (or the new last workspace when it was last), ignoring the +# previously focused workspace entirely (upstream discussion #1328, fixed +# by PR #1877, commit 165dca45). +# - A PANE-DEATH removal (handle_pane_died) keeps the focused index stale, +# which preserves the exact focused workspace whenever the dying workspace +# sat behind it (or the focused workspace was last), and moves focus to the +# focused workspace's right neighbor otherwise (upstream issue #1621, fixed +# by PR #1912, commit a979916). +# Both fixes are merged upstream but in no release as of 2026-07-28. +# Firstmate therefore removes a doomed non-focused workspace by ending its +# verified lone idle shell (the pane-death path), repositioning it behind the +# focused workspace first when needed. Moving it to the end preserves every +# other workspace's relative order, so no presentation ordering change +# persists. A release carrying both fixes preserves focus on both paths, so +# this stays safe without any version gate. + +# fm_backend_herdr_workspace_move_capable: verify that one guarded raw +# workspace.move request is possible in : python3 for the transport, +# the minimum protocol, and the exact whitelisted method and parameter +# schema. Silent; each caller owns its own warning wording. +# Return codes: 1 python3 missing, 2 protocol unreadable, 3 protocol too old, +# 4 schema unreadable, 5 method or parameter schema unsupported. +fm_backend_herdr_workspace_move_capable() { # + local session=$1 protocol schema + command -v python3 >/dev/null 2>&1 || return 1 + protocol=$(fm_backend_herdr_cli "$session" status --json 2>/dev/null | jq -r '.client.protocol // empty' 2>/dev/null) + case "$protocol" in + ''|*[!0-9]*) return 2 ;; + esac + [ "$protocol" -lt "$FM_BACKEND_HERDR_MIN_WORKSPACE_MOVE_PROTOCOL" ] && return 3 + schema=$(fm_backend_herdr_cli "$session" api schema --json 2>/dev/null) || return 4 + printf '%s' "$schema" | jq -e ' + any(.schemas.request.oneOf[]?; .properties.method.const == "workspace.move") + and .schemas.request["$defs"].WorkspaceMoveParams.required == ["workspace_id", "insert_index"] + and .schemas.request["$defs"].WorkspaceMoveParams.properties.insert_index.type == "integer" + ' >/dev/null 2>&1 || return 5 +} + +# fm_backend_herdr_emptying_close_plan: choose the focus-safe removal for one +# exact pane. The LAST echoed line is the plan: "plain" (use the ordinary +# explicit close; the exact-tab restore backstop masks 0.7.5's focus move) +# or "death " (end the proved lone idle shell so Herdr removes +# the emptied workspace through its focus-preserving pane-death path). +# Whenever the repositioning mover was invoked, a preceding +# "moved" +# record line is echoed first so the caller can hand it to +# fm_backend_herdr_emptying_move_rollback when removal is not confirmed. +# Never fails; every ambiguity plans "plain". +# The death plan requires the close to empty the workspace (exactly one tab +# and one pane, both the target), the target workspace to sit behind the +# focused one (repositioned to the end first when it does not, with the move +# verified against the server-returned order and focus), and the exact pane +# to hold one provably lone idle recognized shell. +fm_backend_herdr_emptying_close_plan() { # + local session=$1 pane_id=$2 ws_id=$3 tab_id=$4 focused_ws=$5 + local tabs panes list indices r rest a len capable socket mover response move_status shell_pid before_order + [ -n "$ws_id" ] && [ -n "$tab_id" ] && [ -n "$focused_ws" ] || { printf 'plain\n'; return 0; } + tabs=$(fm_backend_herdr_cli "$session" tab list --workspace "$ws_id" 2>/dev/null) || { printf 'plain\n'; return 0; } + printf '%s' "$tabs" | jq -e --arg tab "$tab_id" ' + (.result.tabs | type) == "array" and (.result.tabs | length) == 1 + and .result.tabs[0].tab_id == $tab + ' >/dev/null 2>&1 || { printf 'plain\n'; return 0; } + panes=$(fm_backend_herdr_cli "$session" pane list --workspace "$ws_id" 2>/dev/null) || { printf 'plain\n'; return 0; } + printf '%s' "$panes" | jq -e --arg pane "$pane_id" ' + (.result.panes | type) == "array" and (.result.panes | length) == 1 + and .result.panes[0].pane_id == $pane + ' >/dev/null 2>&1 || { printf 'plain\n'; return 0; } + list=$(fm_backend_herdr_cli "$session" workspace list 2>/dev/null) || { printf 'plain\n'; return 0; } + indices=$(printf '%s' "$list" | jq -r --arg ws "$ws_id" --arg focused "$focused_ws" ' + (.result.workspaces // null) as $s + | select(($s | type) == "array" and ($s | length) > 1) + | ([range(0; $s | length) | select($s[.].workspace_id == $ws)]) as $w + | ([range(0; $s | length) | select($s[.].workspace_id == $focused)]) as $f + | select(($w | length) == 1 and ($f | length) == 1 and $w[0] != $f[0]) + | "\($w[0])\t\($f[0])\t\($s | length)" + ' 2>/dev/null) || indices= + if [ -z "$indices" ]; then + printf 'plain\n' + return 0 + fi + r=${indices%%$'\t'*} + rest=${indices#*$'\t'} + a=${rest%%$'\t'*} + len=${rest#*$'\t'} + case "$r:$a:$len" in + *[!0-9:]*) + printf 'plain\n' + return 0 + ;; + esac + if [ "$r" -lt "$a" ] && [ "$a" -lt $((len - 1)) ]; then + # The doomed workspace sits before the focused one, where the pane-death + # path would land focus on the focused workspace's right neighbor. + # Reposition it behind everything first: insert_index equal to the list + # length is the verified move-to-last form, and removing the moved + # workspace afterward leaves every other relative order untouched. + if fm_backend_herdr_workspace_move_capable "$session"; then + capable=0 + else + capable=$? + fi + if [ "$capable" -ne 0 ]; then + echo "warning: herdr presentation cleanup could not verify workspace.move support; closing without the focus-safe removal path" >&2 + printf 'plain\n' + return 0 + fi + socket=$(fm_backend_herdr_presentation_session_socket_path "$session") || { + echo "warning: herdr presentation cleanup found an ambiguous named session socket; closing without the focus-safe removal path" >&2 + printf 'plain\n' + return 0 + } + mover=${FM_BACKEND_HERDR_WORKSPACE_MOVER:-$FM_BACKEND_HERDR_ROOT/bin/backends/herdr-workspace-move.py} + before_order=$(printf '%s' "$list" | jq -c '[.result.workspaces[].workspace_id]' 2>/dev/null) + if response=$("$mover" "$socket" "$ws_id" "$len" 2>/dev/null); then + move_status=0 + else + move_status=$? + fi + # Every mover invocation is recorded, even an unverified one, so a later + # unconfirmed removal can restore the exact original order; restoring an + # unmoved workspace to its own position is a verified no-op. + printf 'moved\t%s\t%s\t%s\t%s\t%s\n' "$ws_id" "$r" "$socket" "$focused_ws" "$before_order" + if [ "$move_status" -ne 0 ] \ + || ! printf '%s' "$response" | jq -e --arg ws "$ws_id" --arg focused "$focused_ws" \ + --argjson before "$before_order" ' + ($before | map(select(. != $ws)) + [$ws]) as $expected + | .result.type == "workspace_list" + and ([.result.workspaces[].workspace_id] == $expected) + and ([.result.workspaces[] | select(.focused == true) | .workspace_id] == [$focused]) + ' >/dev/null 2>&1; then + echo "warning: herdr presentation cleanup could not move the doomed workspace behind the focused one; closing without the focus-safe removal path" >&2 + printf 'plain\n' + return 0 + fi + fi + if shell_pid=$(fm_backend_herdr_pane_idle_shell_pid "$session" "$pane_id"); then + printf 'death %s\n' "$shell_pid" + else + printf 'plain\n' + fi +} + +# fm_backend_herdr_emptying_move_rollback: restore the exact pre-move +# workspace order recorded by an emptying-close plan whose removal was not +# confirmed, under the caller's still-held session lock. +# is the plan's tab-separated +# "moved" +# line, or empty for a no-op when no move was attempted. +# The rollback is verified against the mover's returned order and focus and +# warns on any failure, so a lasting reorder is never silent. +fm_backend_herdr_emptying_move_rollback() { # + local record=$1 marker ws index socket focused order mover response + [ -n "$record" ] || return 0 + IFS=$'\t' read -r marker ws index socket focused order <&2 + return 1 + fi + case "$index" in + ''|*[!0-9]*) + echo "warning: herdr presentation cleanup has a malformed move record after a failed removal; the workspace order may remain changed" >&2 + return 1 + ;; + esac + mover=${FM_BACKEND_HERDR_WORKSPACE_MOVER:-$FM_BACKEND_HERDR_ROOT/bin/backends/herdr-workspace-move.py} + if ! response=$("$mover" "$socket" "$ws" "$index" 2>/dev/null) \ + || ! printf '%s' "$response" | jq -e --argjson expected "$order" --arg focused "$focused" ' + .result.type == "workspace_list" + and ([.result.workspaces[].workspace_id] == $expected) + and ([.result.workspaces[] | select(.focused == true) | .workspace_id] == [$focused]) + ' >/dev/null 2>&1; then + echo "warning: herdr presentation cleanup could not restore the original workspace order after a failed removal" >&2 + return 1 + fi +} + +# fm_backend_herdr_death_close_pane: end the exact pane's proved lone idle +# shell so Herdr removes the emptied workspace through its focus-preserving +# pane-death path, then confirm the pane is gone. +# Each signal is sent only while the exact pane still owns the recorded pid +# as its lone idle shell: SIGHUP relies on the proof taken just before, and +# the SIGKILL escalation re-reads the pane's process information and refuses +# unless the same pid is still the pane's strict bare idle shell, so an +# exited or reused pid is never signaled. +# Returns 0 only when the pane is confirmed gone. +fm_backend_herdr_death_close_pane() { # + local session=$1 pane_id=$2 shell_pid=$3 ps_bin attempt max_attempts presence resampled_pid + ps_bin=${FM_HERDR_PS_BIN:-ps} + case "$shell_pid" in + ''|*[!0-9]*) return 1 ;; + esac + command -v "$ps_bin" >/dev/null 2>&1 || return 1 + max_attempts=${FM_BACKEND_HERDR_DEATH_CLOSE_POLLS:-40} + fm_backend_herdr_pid_is_bare_shell "$ps_bin" "$shell_pid" || return 1 + kill -HUP "$shell_pid" 2>/dev/null || true + attempt=0 + while [ "$attempt" -lt "$max_attempts" ]; do + presence=$(fm_backend_herdr_pane_presence_state "$session" "$pane_id") + [ "$presence" = dead ] && return 0 + sleep 0.05 + attempt=$((attempt + 1)) + done + # SIGKILL escalation revalidates exact pane ownership, not just the pid: a + # fresh strict pane sample must still name the SAME shell pid, so a pid + # that exited and was reused by an unrelated process is never signaled. + resampled_pid=$(fm_backend_herdr_pane_idle_shell_sample "$session" "$pane_id") || return 1 + [ "$resampled_pid" = "$shell_pid" ] || return 1 + fm_backend_herdr_pid_is_bare_shell "$ps_bin" "$shell_pid" || return 1 + kill -KILL "$shell_pid" 2>/dev/null || true + attempt=0 + while [ "$attempt" -lt "$max_attempts" ]; do + presence=$(fm_backend_herdr_pane_presence_state "$session" "$pane_id") + [ "$presence" = dead ] && return 0 + sleep 0.05 + attempt=$((attempt + 1)) + done + return 1 +} + +# fm_backend_herdr_pid_is_bare_shell: currently resolves to a bare +# recognized shell process per . +# BSD ps reports comm as argv0, so a login shell arrives as "-zsh"; strip the +# login dash exactly like the idle-shell proof's argv0 normalization. +fm_backend_herdr_pid_is_bare_shell() { # + local comm + comm=$("$1" -p "$2" -o comm= 2>/dev/null) || return 1 + comm=$(printf '%s' "$comm" | tr -d '[:space:]') + comm=${comm#-} + comm=${comm##*/} + case "$comm" in sh|bash|zsh|dash|ksh|fish) return 0 ;; esac + return 1 +} + +# fm_backend_herdr_pane_idle_shell_pid: print the shell pid of only +# when the exact pane provably holds one lone idle recognized shell: pane +# process-info agrees on the pane id, the shell pid is both the foreground +# process group and the sole foreground process, the foreground process name +# and argv0 resolve to the same recognized shell, the operating-system +# process table shows exactly that one shell row with no child process, and +# the shell sits in a sleeping or idle state. +# An idle interactive shell transiently hosts short-lived prompt helpers +# (verified on the real 0.7.5 lab: a workspace.move relayout makes zsh redraw +# its prompt, spawning starship as a second foreground process for a few +# samples), so the proof retries strict single samples for a bounded settle +# window and succeeds on the first fully clean one; a genuinely busy pane +# fails every sample and still refuses. +# This is the single owner of the idle-shell proof; the session-start +# projection cleanup and every pane-death close path both rely on it. +fm_backend_herdr_pane_idle_shell_pid() { # + local attempt=0 max_attempts=${FM_BACKEND_HERDR_IDLE_SHELL_PROOF_POLLS:-10} + while :; do + if fm_backend_herdr_pane_idle_shell_sample "$1" "$2"; then + return 0 + fi + attempt=$((attempt + 1)) + [ "$attempt" -lt "$max_attempts" ] || return 1 + sleep 0.1 + done +} + +# fm_backend_herdr_pane_idle_shell_sample: one strict instantaneous +# observation for fm_backend_herdr_pane_idle_shell_pid, which owns the proof +# contract and the settle retry. +fm_backend_herdr_pane_idle_shell_sample() { # + local session=$1 pane=$2 info shell_pid foreground_pgid count + local process_pid name argv0 shell_name rows stat ps_bin + info=$(fm_backend_herdr_cli "$session" pane process-info --pane "$pane" 2>/dev/null) || return 1 + printf '%s' "$info" | jq -e --arg pane "$pane" ' + .result.type == "pane_process_info" + and .result.process_info.pane_id == $pane + ' >/dev/null 2>&1 || return 1 + shell_pid=$(printf '%s' "$info" | jq -er \ + '.result.process_info.shell_pid | select(type == "number" and . > 1) | floor' 2>/dev/null) || return 1 + foreground_pgid=$(printf '%s' "$info" | jq -er \ + '.result.process_info.foreground_process_group_id | select(type == "number" and . > 1) | floor' 2>/dev/null) || return 1 + [ "$foreground_pgid" = "$shell_pid" ] || return 1 + count=$(printf '%s' "$info" | jq -er \ + '.result.process_info.foreground_processes | select(type == "array") | length' 2>/dev/null) || return 1 + [ "$count" -eq 1 ] || return 1 + process_pid=$(printf '%s' "$info" | jq -er \ + '.result.process_info.foreground_processes[0].pid | select(type == "number") | floor' 2>/dev/null) || return 1 + [ "$process_pid" = "$shell_pid" ] || return 1 + name=$(printf '%s' "$info" | jq -er \ + '.result.process_info.foreground_processes[0].name | select(type == "string" and length > 0)' 2>/dev/null) || return 1 + argv0=$(printf '%s' "$info" | jq -er ' + .result.process_info.foreground_processes[0] as $process + | ($process.argv0 // $process.argv[0]) + | select(type == "string" and length > 0) + ' 2>/dev/null) || return 1 + shell_name=${name##*/} + argv0=${argv0#-} + argv0=${argv0##*/} + [ "$argv0" = "$shell_name" ] || return 1 + case "$shell_name" in sh|bash|zsh|dash|ksh|fish) ;; *) return 1 ;; esac + + ps_bin=${FM_HERDR_PS_BIN:-ps} + command -v "$ps_bin" >/dev/null 2>&1 || return 1 + rows=$("$ps_bin" -axo pid=,ppid= 2>/dev/null) || return 1 + printf '%s\n' "$rows" | awk -v shell="$shell_pid" ' + $1 == shell { found++ } + $2 == shell { child++ } + END { exit(found == 1 && child == 0 ? 0 : 1) } + ' || return 1 + stat=$("$ps_bin" -p "$shell_pid" -o stat= 2>/dev/null | tr -d '[:space:]') || return 1 + case "$stat" in S*|I*) ;; *) return 1 ;; esac + printf '%s\n' "$shell_pid" +} + # fm_backend_herdr_projection_order_best_effort: place the exact workspace id # returned by THIS projected create immediately after its owning parent's # contiguous child block and before the next parent. # # is the owning FM_HOME label (firstmate or 2ndmate-). +# Optional is that parent's EXACT id, which the caller +# already resolved from the launching agent's own herdr identity. When given it +# anchors the owning parent by id, so two workspaces sharing the home label no +# longer make the whole layout ambiguous; when omitted the parent is located by +# label exactly as before. With a unique label the two select the same +# workspace, so ordering behavior is unchanged in the ordinary case. # New-format └ ... · p: children and, for compatibility only, already # adjacent old-format firstmate/... or 2ndmate-/... projections may extend # the block read-only; they are never renamed or moved. @@ -644,8 +1036,8 @@ fm_backend_herdr_projection_close_pane_focus_preserving() { # - local session=$1 created=$2 parent=$3 list analysis current desired protocol schema socket mover response move_status focus_before +fm_backend_herdr_projection_order_best_effort() { # [] + local session=$1 created=$2 parent=$3 parent_ws=${4:-} list analysis current desired socket mover response move_status focus_before move_capable local before_existing after_existing [ -n "$parent" ] || { echo "warning: herdr presentation ordering missing owning parent label; leaving worker in Herdr's current order" >&2 @@ -655,9 +1047,12 @@ fm_backend_herdr_projection_order_best_effort() { # &2 return 0 } - analysis=$(printf '%s' "$list" | jq -c --arg created "$created" --arg parent "$parent" ' + analysis=$(printf '%s' "$list" | jq -c --arg created "$created" --arg parent "$parent" --arg parent_ws "$parent_ws" ' def is_parent: - (.label | type) == "string" and .label == $parent; + if ($parent_ws | length) > 0 + then .workspace_id == $parent_ws + else (.label | type) == "string" and .label == $parent + end; def is_top_level_parent: (.label | type) == "string" and ((.label == "firstmate") or (.label | test("^2ndmate-[^/]+$"))); @@ -732,33 +1127,34 @@ fm_backend_herdr_projection_order_best_effort() { # /dev/null 2>&1 || { - echo "warning: herdr presentation ordering requires python3; leaving worker in Herdr's current order" >&2 - return 0 - } - protocol=$(fm_backend_herdr_cli "$session" status --json 2>/dev/null | jq -r '.client.protocol // empty' 2>/dev/null) - case "$protocol" in - ''|*[!0-9]*) + if fm_backend_herdr_workspace_move_capable "$session"; then + move_capable=0 + else + move_capable=$? + fi + case "$move_capable" in + 0) ;; + 1) + echo "warning: herdr presentation ordering requires python3; leaving worker in Herdr's current order" >&2 + return 0 + ;; + 2) echo "warning: herdr presentation ordering could not verify the client protocol; leaving worker in Herdr's current order" >&2 return 0 ;; + 3) + echo "warning: herdr presentation ordering needs protocol $FM_BACKEND_HERDR_MIN_WORKSPACE_MOVE_PROTOCOL or newer; leaving worker in Herdr's current order" >&2 + return 0 + ;; + 4) + echo "warning: herdr presentation ordering could not read the API schema; leaving worker in Herdr's current order" >&2 + return 0 + ;; + *) + echo "warning: herdr presentation ordering API support is unavailable or ambiguous; leaving worker in Herdr's current order" >&2 + return 0 + ;; esac - if [ "$protocol" -lt "$FM_BACKEND_HERDR_MIN_WORKSPACE_MOVE_PROTOCOL" ]; then - echo "warning: herdr presentation ordering needs protocol $FM_BACKEND_HERDR_MIN_WORKSPACE_MOVE_PROTOCOL or newer; leaving worker in Herdr's current order" >&2 - return 0 - fi - schema=$(fm_backend_herdr_cli "$session" api schema --json 2>/dev/null) || { - echo "warning: herdr presentation ordering could not read the API schema; leaving worker in Herdr's current order" >&2 - return 0 - } - if ! printf '%s' "$schema" | jq -e ' - any(.schemas.request.oneOf[]?; .properties.method.const == "workspace.move") - and .schemas.request["$defs"].WorkspaceMoveParams.required == ["workspace_id", "insert_index"] - and .schemas.request["$defs"].WorkspaceMoveParams.properties.insert_index.type == "integer" - ' >/dev/null 2>&1; then - echo "warning: herdr presentation ordering API support is unavailable or ambiguous; leaving worker in Herdr's current order" >&2 - return 0 - fi socket=$(fm_backend_herdr_presentation_session_socket_path "$session") || { echo "warning: herdr presentation ordering found an ambiguous named session socket; leaving worker in Herdr's current order" >&2 return 0 @@ -779,14 +1175,19 @@ fm_backend_herdr_projection_order_best_effort() { # &2 return 0 fi - if ! printf '%s' "$response" | jq -e --arg created "$created" --arg parent "$parent" --argjson desired "$desired" ' + if ! printf '%s' "$response" | jq -e --arg created "$created" --arg parent "$parent" --arg parent_ws "$parent_ws" --argjson desired "$desired" ' + def is_parent: + if ($parent_ws | length) > 0 + then .workspace_id == $parent_ws + else (.label | type) == "string" and .label == $parent + end; .result.type == "workspace_list" and (.result.workspaces | type) == "array" and .result.workspaces[$desired].workspace_id == $created - and ([.result.workspaces[] | select(.label == $parent)] | length) == 1 + and ([.result.workspaces[] | select(is_parent)] | length) == 1 and ( [range(0; .result.workspaces | length) as $i - | select(.result.workspaces[$i].label == $parent) + | select(.result.workspaces[$i] | is_parent) | $i][0] < $desired ) ' >/dev/null 2>&1; then @@ -821,14 +1222,20 @@ fm_backend_herdr_server_ensure() { # return 1 } -# fm_backend_herdr_workspace_find: this HOME's own workspace id inside -# (fm_backend_herdr_workspace_label), or empty (never creates). -# Read-only, safe for recovery/list paths. Label-collision semantics -# (docs/herdr-backend.md "Label collisions"): herdr enforces no label -# uniqueness at all, so this adopts the FIRST matching workspace `jq` returns -# (list order, normally creation order/oldest) rather than disambiguating - -# identical in spirit to the pre-existing tab duplicate-label check below. -fm_backend_herdr_workspace_find() { # +# fm_backend_herdr_workspace_find_all: EVERY workspace id inside +# whose label equals this HOME's own label (fm_backend_herdr_workspace_label), +# one per line, in herdr's own list order (normally creation order, oldest +# first). Empty when none match. Never creates anything. +# +# Single owner of the home-label workspace query. Herdr enforces no workspace +# label uniqueness at all (docs/herdr-backend.md "Label collisions"), so this +# can legitimately return MORE THAN ONE id: a captain-owned workspace can +# collide by label, a cwd-basename-derived label can coincide, and concurrent +# first spawns can mint two same-labeled home workspaces. Callers decide what a +# duplicate means for them - fm_backend_herdr_workspace_ensure refuses to guess +# which one is the caller's, while the read-only recovery path below keeps its +# historical first-match behavior. +fm_backend_herdr_workspace_find_all() { # local session=$1 label list label=$(fm_backend_herdr_workspace_label) list=$(fm_backend_herdr_cli "$session" workspace list 2>/dev/null) || return 0 @@ -838,7 +1245,143 @@ fm_backend_herdr_workspace_find() { # # ALWAYS return empty and every spawn mint a fresh "firstmate" workspace # (the workspace leak). printf '%s' "$list" | jq -r --arg want "$label" \ - '.result.workspaces[]? | select(.label == $want) | .workspace_id' 2>/dev/null | head -1 + '.result.workspaces[]? | select(.label == $want) | .workspace_id' 2>/dev/null +} + +# fm_backend_herdr_workspace_find: this HOME's own workspace id inside +# , or empty (never creates). Read-only, safe for recovery/list +# paths, which address panes they already recorded and only need a container +# to scan. Keeps the historical FIRST-match behavior on a label collision - +# identical in spirit to the pre-existing tab duplicate-label check below. +# NOT the spawn-time resolver: placing a new worker by first label match is +# exactly the defect fm_backend_herdr_workspace_ensure now refuses. +fm_backend_herdr_workspace_find() { # + fm_backend_herdr_workspace_find_all "$1" | head -1 +} + +# fm_backend_herdr_launcher_identity: the EXACT herdr workspace that the +# process making this spawn is itself running in. +# +# Herdr 0.7.5 injects HERDR_ENV=1, HERDR_PANE_ID, HERDR_SESSION, +# HERDR_SOCKET_PATH, HERDR_TAB_ID, and HERDR_WORKSPACE_ID into every process it +# manages a pane for (docs/verification/runtime-backends.md), and a firstmate +# or secondmate agent's own tool calls inherit them. Older injection shapes are +# unverified and cannot establish launcher ancestry without both pane and +# socket identity. Workspace LABELS are mutable and herdr enforces no +# uniqueness on them, so a label search cannot tell one `firstmate` workspace +# from another, and herdr's globally focused workspace is whatever the captain +# happens to be looking at, not the launcher's. +# +# The injected HERDR_TAB_ID/HERDR_WORKSPACE_ID are deliberately NOT read as the +# answer. They are a snapshot taken when the pane's process started, and herdr +# can move a pane between tabs and workspaces afterwards without being able to +# rewrite a running process's environment. Only a live read is the CURRENT +# parent, which is what placement has to bind to. +# +# Sets, only on a 0 return: +# FM_BACKEND_HERDR_LAUNCHER_PANE_ID +# FM_BACKEND_HERDR_LAUNCHER_TAB_ID +# FM_BACKEND_HERDR_LAUNCHER_WORKSPACE_ID +# +# Returns: +# 0 - one exact, self-consistent launcher pane/tab/workspace in . +# 2 - this process is NOT running in a herdr pane (no HERDR_PANE_ID at all), +# so there is no launcher workspace to inherit and the caller falls back +# to its per-home container. HERDR_ENV=1 on its own is only a backend +# SELECTION marker (bin/fm-backend.sh's fm_backend_detect), never a +# parent binding - herdr always injects the pane id alongside it. +# 1 - a launcher pane IS claimed but its binding is missing, stale, +# contradictory, or belongs to another herdr session. The caller must +# refuse before creating or publishing any worker endpoint rather than +# degrading to a label search. +fm_backend_herdr_launcher_identity() { # + local session=$1 pane=${HERDR_PANE_ID:-} claimed_session claimed_socket session_socket + local pane_out tab_out list tab workspace + FM_BACKEND_HERDR_LAUNCHER_PANE_ID="" + FM_BACKEND_HERDR_LAUNCHER_TAB_ID="" + FM_BACKEND_HERDR_LAUNCHER_WORKSPACE_ID="" + [ -n "$pane" ] || return 2 + + # Same-session proof, before the pane id is trusted at all: herdr pane ids + # ("w2:p1") restart at the same low numbers in every session, so a pane id + # borrowed from another session can silently resolve to a real but unrelated + # workspace here. The injected socket path is the server identity herdr + # exposes, and the session name independently binds the named session. + claimed_session=$(fm_backend_herdr_session) + if [ "$claimed_session" != "$session" ]; then + echo "error: herdr launcher pane '$pane' reports session '$claimed_session' but this spawn targets session '$session'; refusing to place a worker from a cross-session parent identity" >&2 + return 1 + fi + claimed_socket=${HERDR_SOCKET_PATH:-} + if [ -z "$claimed_socket" ]; then + echo "error: herdr launcher pane '$pane' has no injected socket identity; refusing to place a worker from an unverifiable parent identity" >&2 + return 1 + fi + claimed_socket=$(fm_backend_herdr_canonical_socket_path "$claimed_socket") || { + echo "error: herdr launcher pane '$pane' reports an unusable socket path; refusing to place a worker from an unverifiable parent identity" >&2 + return 1 + } + session_socket=$(fm_backend_herdr_presentation_session_socket_path "$session") || { + echo "error: herdr session '$session' has no unambiguous socket to match against the launcher pane's own; refusing to place a worker from an unverifiable parent identity" >&2 + return 1 + } + if [ "$claimed_socket" != "$session_socket" ]; then + echo "error: herdr launcher pane '$pane' belongs to the server at '$claimed_socket', not session '$session' at '$session_socket'; refusing to place a worker from a cross-session parent identity" >&2 + return 1 + fi + + pane_out=$(fm_backend_herdr_cli "$session" pane get "$pane" 2>/dev/null) || { + echo "error: herdr launcher pane '$pane' could not be read in session '$session'; refusing to place a worker without its exact parent workspace" >&2 + return 1 + } + tab=$(printf '%s' "$pane_out" | jq -r --arg pane "$pane" ' + select(.result.pane.pane_id == $pane) + | select((.result.pane.tab_id | type) == "string" and (.result.pane.tab_id | length) > 0) + | .result.pane.tab_id + ' 2>/dev/null) + workspace=$(printf '%s' "$pane_out" | jq -r --arg pane "$pane" ' + select(.result.pane.pane_id == $pane) + | select((.result.pane.workspace_id | type) == "string" and (.result.pane.workspace_id | length) > 0) + | .result.pane.workspace_id + ' 2>/dev/null) + if [ -z "$tab" ] || [ -z "$workspace" ]; then + echo "error: herdr launcher pane '$pane' returned an ambiguous tab or workspace identity in session '$session'; refusing to place a worker without its exact parent workspace" >&2 + return 1 + fi + + # Independent second read: the tab must agree that it lives in the same + # workspace the pane just claimed. A restored-but-stale pane record that + # disagrees with its own tab is exactly the contradictory binding this must + # refuse rather than resolve. + tab_out=$(fm_backend_herdr_cli "$session" tab get "$tab" 2>/dev/null) || { + echo "error: herdr launcher tab '$tab' could not be read in session '$session'; refusing to place a worker without its exact parent workspace" >&2 + return 1 + } + if ! printf '%s' "$tab_out" | jq -e --arg tab "$tab" --arg workspace "$workspace" ' + .result.tab.tab_id == $tab and .result.tab.workspace_id == $workspace + ' >/dev/null 2>&1; then + echo "error: herdr launcher pane '$pane' and tab '$tab' disagree about their workspace in session '$session'; refusing to place a worker from a contradictory parent identity" >&2 + return 1 + fi + + list=$(fm_backend_herdr_cli "$session" workspace list 2>/dev/null) || { + echo "error: could not list herdr workspaces in session '$session' to confirm the launcher's own workspace '$workspace'; refusing to place a worker without its exact parent workspace" >&2 + return 1 + } + if ! printf '%s' "$list" | jq -e --arg workspace "$workspace" ' + (.result.workspaces | type) == "array" + and ([.result.workspaces[] | select(.workspace_id == $workspace)] | length) == 1 + ' >/dev/null 2>&1; then + echo "error: herdr launcher workspace '$workspace' is missing or duplicated in session '$session'; refusing to place a worker from a stale parent identity" >&2 + return 1 + fi + + # shellcheck disable=SC2034 # callers consume the verified binding's parts + FM_BACKEND_HERDR_LAUNCHER_PANE_ID=$pane + # shellcheck disable=SC2034 # callers consume the verified binding's parts + FM_BACKEND_HERDR_LAUNCHER_TAB_ID=$tab + FM_BACKEND_HERDR_LAUNCHER_WORKSPACE_ID=$workspace + return 0 } # fm_backend_herdr_workspace_prune_seeded_default_tab: close EXACTLY @@ -903,11 +1446,13 @@ fm_backend_herdr_workspace_prune_seeded_default_tab() { # , creating it in if absent. Must be called as a PLAIN -# STATEMENT, never through command substitution ($(...)) - it communicates -# through these globals, not solely through stdout, and a command -# substitution forks a subshell that would discard them: +# fm_backend_herdr_workspace_ensure: the workspace this spawn's task tab +# belongs in inside - the launching agent's own exact workspace when +# it has one, otherwise this HOME's persistent workspace, created in if +# absent. Must be called as a PLAIN STATEMENT, never through command +# substitution ($(...)) - it communicates through these globals, not solely +# through stdout, and a command substitution forks a subshell that would +# discard them: # FM_BACKEND_HERDR_WS_ID - the resolved workspace_id (also echoed, # for callers that only need the id) # FM_BACKEND_HERDR_WS_SEEDED_TAB_ID - non-empty ONLY when THIS call just @@ -919,11 +1464,14 @@ fm_backend_herdr_workspace_prune_seeded_default_tab() { # - local session=$1 cwd=$2 wsid out label +# +# (3rd arg, default "launcher-home") says whether the +# container being ensured belongs to the SAME firstmate home as the process +# calling this: +# launcher-home - a crewmate or scout for the caller's own home. When the +# caller is itself running in a herdr pane, the worker MUST +# land in that exact workspace +# (fm_backend_herdr_launcher_identity), never in whichever +# same-labeled workspace happens to sort first. +# other-home - a --secondmate launch, which stands up a DIFFERENT home's +# own per-home workspace by design. The launcher's workspace +# is deliberately not inherited here. +# With no herdr ancestry at all there is no launcher workspace to inherit, so +# the per-home label lookup below stays the resolver - but it must then resolve +# to exactly ONE workspace. Two same-labeled home workspaces with no launcher +# identity to disambiguate them is an unresolvable placement, and adopting +# either one is the very defect this refuses. +# +# Returns 0 on success, 3 for a refusal whose exact reason is already on +# stderr, and 1 for a failed or unparseable herdr call. +fm_backend_herdr_workspace_ensure() { # [] + local session=$1 cwd=$2 relationship=${3:-launcher-home} wsid out label matches count status FM_BACKEND_HERDR_WS_ID="" FM_BACKEND_HERDR_WS_SEEDED_TAB_ID="" - wsid=$(fm_backend_herdr_workspace_find "$session") + if [ "$relationship" = launcher-home ]; then + fm_backend_herdr_launcher_identity "$session" && status=0 || status=$? + case "$status" in + 0) + FM_BACKEND_HERDR_WS_ID=$FM_BACKEND_HERDR_LAUNCHER_WORKSPACE_ID + printf '%s' "$FM_BACKEND_HERDR_WS_ID" + return 0 + ;; + 2) ;; + *) return 3 ;; + esac + fi + label=$(fm_backend_herdr_workspace_label) + matches=$(fm_backend_herdr_workspace_find_all "$session") + count=$(printf '%s' "$matches" | grep -c '[^[:space:]]' || true) + if [ "$count" -gt 1 ]; then + echo "error: ${count} herdr workspaces in session '$session' are labeled '$label' (${matches//$'\n'/ }) and this spawn has no herdr parent pane to identify which one is its own; rename or close the extras, or run firstmate inside the workspace its workers belong in" >&2 + return 3 + fi + wsid=${matches%%$'\n'*} if [ -n "$wsid" ]; then FM_BACKEND_HERDR_WS_ID=$wsid printf '%s' "$wsid" return 0 fi - label=$(fm_backend_herdr_workspace_label) out=$(fm_backend_herdr_cli "$session" workspace create --cwd "$cwd" --label "$label" --no-focus 2>/dev/null) || return 1 wsid=$(printf '%s' "$out" | jq -r '.result.workspace.workspace_id // empty' 2>/dev/null) [ -n "$wsid" ] || return 1 @@ -974,13 +1560,18 @@ fm_backend_herdr_workspace_ensure() { # # CONTAINER=${RAW%%$'\t'*}; SEEDED_TAB_ID=${RAW#*$'\t'}. The seeded tab id # must be threaded through to fm_backend_herdr_create_task, which is the only # function allowed to prune it (fm_backend_herdr_workspace_prune_seeded_default_tab). -fm_backend_herdr_container_ensure() { # - local cwd=${1:-$PWD} session label +# is passed straight through to +# fm_backend_herdr_workspace_ensure, which owns its meaning. +fm_backend_herdr_container_ensure() { # [] + local cwd=${1:-$PWD} relationship=${2:-launcher-home} session label status fm_backend_herdr_version_check || return 1 session=$(fm_backend_herdr_session) fm_backend_herdr_server_ensure "$session" || return 1 - fm_backend_herdr_workspace_ensure "$session" "$cwd" >/dev/null || { label=$(fm_backend_herdr_workspace_label); echo "error: failed to ensure herdr workspace '$label' in session '$session'" >&2; return 1; } - if [ -z "$FM_BACKEND_HERDR_WS_ID" ]; then + fm_backend_herdr_workspace_ensure "$session" "$cwd" "$relationship" >/dev/null && status=0 || status=$? + # A 3 already reported the exact placement it refused to guess at; adding the + # generic message here would bury it. + [ "$status" -ne 3 ] || return 1 + if [ "$status" -ne 0 ] || [ -z "$FM_BACKEND_HERDR_WS_ID" ]; then label=$(fm_backend_herdr_workspace_label) echo "error: failed to ensure herdr workspace '$label' in session '$session'" >&2 return 1 @@ -988,6 +1579,43 @@ fm_backend_herdr_container_ensure() { # printf '%s:%s\t%s' "$session" "$FM_BACKEND_HERDR_WS_ID" "$FM_BACKEND_HERDR_WS_SEEDED_TAB_ID" } +# fm_backend_herdr_pane_presence_state: classify one exact pane get response +# as dead|present|unknown from its JSON body, never from process exit status. +fm_backend_herdr_pane_presence_state() { # + local session=$1 pane_id=$2 out code pid + out=$(fm_backend_herdr_cli "$session" pane get "$pane_id" 2>&1) + code=$(printf '%s' "$out" | jq -r '.error.code // empty' 2>/dev/null) + if [ -n "$code" ]; then + [ "$code" = "pane_not_found" ] && printf 'dead' || printf 'unknown' + return 0 + fi + pid=$(printf '%s' "$out" | jq -r '.result.pane.pane_id // empty' 2>/dev/null) + [ "$pid" = "$pane_id" ] && printf 'present' || printf 'unknown' +} + +fm_backend_herdr_workspace_presence_state() { # + local session=$1 workspace_id=$2 out matches + out=$(fm_backend_herdr_cli "$session" workspace list 2>&1) + matches=$(printf '%s' "$out" | jq -r --arg workspace "$workspace_id" ' + select((.result.workspaces | type) == "array") + | [.result.workspaces[] | select(.workspace_id == $workspace)] | length + ' 2>/dev/null) || matches= + case "$matches" in + 0) printf 'dead' ;; + 1) printf 'present' ;; + *) printf 'unknown' ;; + esac +} + +# fm_backend_herdr_explicit_close_pane_confirmed: issue one explicit close and +# succeed only when a structured follow-up proves the exact pane is gone. +fm_backend_herdr_explicit_close_pane_confirmed() { # + local session=$1 pane_id=$2 presence + fm_backend_herdr_cli "$session" pane close "$pane_id" >/dev/null 2>&1 || return 1 + presence=$(fm_backend_herdr_pane_presence_state "$session" "$pane_id") + [ "$presence" = dead ] +} + # fm_backend_herdr_pane_agent_state: classify in as one of # dead|no-agent|live|unknown, purely from the JSON body of two read-only # calls - never from process exit status, since a business-logic "not found" @@ -1020,24 +1648,13 @@ fm_backend_herdr_container_ensure() { # # refusal here, never toward closing - this is the conservative # backstop the husk check depends on. fm_backend_herdr_pane_agent_state() { # - local session=$1 pane_id=$2 out code pid status - # 2>&1, not 2>/dev/null: verified empirically that real herdr 0.7.1 writes - # an error response's JSON body to STDERR (success bodies go to stdout), so - # discarding stderr here would blind this function to exactly the - # error.code values (pane_not_found, agent_not_found) it exists to read - - # every OTHER call site in this file discards stderr safely only because - # its caller collapses both the error and the not-an-error paths to the - # same final answer, which this function's dead/no-agent/live/unknown - # distinction cannot afford to do. - out=$(fm_backend_herdr_cli "$session" pane get "$pane_id" 2>&1) - code=$(printf '%s' "$out" | jq -r '.error.code // empty' 2>/dev/null) - if [ -n "$code" ]; then - [ "$code" = "pane_not_found" ] && printf 'dead' || printf 'unknown' - return 0 - fi - pid=$(printf '%s' "$out" | jq -r '.result.pane.pane_id // empty' 2>/dev/null) - if [ "$pid" != "$pane_id" ]; then - printf 'unknown' + local session=$1 pane_id=$2 out code presence status + presence=$(fm_backend_herdr_pane_presence_state "$session" "$pane_id") + if [ "$presence" != present ]; then + case "$presence" in + dead|unknown) printf '%s' "$presence" ;; + *) printf 'unknown' ;; + esac return 0 fi out=$(fm_backend_herdr_cli "$session" agent get "$pane_id" 2>&1) @@ -1347,7 +1964,7 @@ fm_backend_herdr_projection_parent_workspace_exact() { # local session=$1 token=$2 workspace=$3 tab=$4 pane=$5 parent_workspace=$6 @@ -1373,7 +1990,6 @@ fm_backend_herdr_projection_live_binding_matches() { # # fm_backend_herdr_kill: remove the task's pane, best-effort (mirrors # tmux-kill-window's `|| true` contract). Verified: closing a tab's only pane # closes the tab too, so a separate tab close is unnecessary. +# When the close would empty a non-focused workspace, Herdr 0.7.5's explicit +# close moves focus to that workspace's neighbor with no restore anywhere in +# this path, so the kill follows the same focus-safe removal plan as +# projected cleanup (a verified pane-death removal with the doomed workspace +# repositioned behind the focused one when needed), keeping the exact-tab +# restore as the backstop. A close that empties the FOCUSED workspace moves +# focus legitimately, and every in-lock planning ambiguity or failure falls +# back to the plain close, matching the pre-hardening contract. +fm_backend_herdr_kill_serialized() { # + local session=$1 pane=$2 + local before active_tab info target_pane target_tab target_ws plan shell_pid plan_move_record close_failed workspace_presence + before=$(fm_backend_herdr_projection_focus_snapshot "$session") || before= + if [ -n "$before" ]; then + active_tab=${before#*$'\t'} + info=$(fm_backend_herdr_cli "$session" pane get "$pane" 2>/dev/null) || info= + target_pane=$(printf '%s' "$info" | jq -r '.result.pane.pane_id // empty' 2>/dev/null) + target_tab=$(printf '%s' "$info" | jq -r '.result.pane.tab_id // empty' 2>/dev/null) + target_ws=$(printf '%s' "$info" | jq -r '.result.pane.workspace_id // empty' 2>/dev/null) + if [ "$target_pane" = "$pane" ] && [ -n "$target_tab" ] && [ "$target_tab" != "$active_tab" ]; then + plan=$(fm_backend_herdr_emptying_close_plan "$session" "$pane" "$target_ws" "$target_tab" "${before%%$'\t'*}") + plan_move_record= + case "$plan" in + moved$'\t'*) + plan_move_record=${plan%%$'\n'*} + plan=${plan##*$'\n'} + ;; + esac + close_failed=0 + case "$plan" in + death\ *) + shell_pid=${plan#death } + if ! fm_backend_herdr_death_close_pane "$session" "$pane" "$shell_pid" \ + && ! fm_backend_herdr_explicit_close_pane_confirmed "$session" "$pane"; then + close_failed=1 + fi + ;; + *) + fm_backend_herdr_explicit_close_pane_confirmed "$session" "$pane" || close_failed=1 + ;; + esac + if [ "$close_failed" = 0 ] && [ -n "$plan_move_record" ]; then + workspace_presence=$(fm_backend_herdr_workspace_presence_state "$session" "$target_ws") + if [ "$workspace_presence" != dead ]; then + echo "warning: herdr task kill did not confirm removal of the repositioned workspace" >&2 + close_failed=1 + fi + fi + if [ "$close_failed" = 1 ]; then + fm_backend_herdr_emptying_move_rollback "$plan_move_record" || true + fi + fm_backend_herdr_projection_focus_restore "$session" "$before" "task kill" || true + return 0 + fi + fi + fm_backend_herdr_explicit_close_pane_confirmed "$session" "$pane" || true +} + fm_backend_herdr_kill() { # fm_backend_herdr_target_ready "$1" || return 0 - fm_backend_herdr_cli "$FM_BACKEND_HERDR_SESSION" pane close "$FM_BACKEND_HERDR_PANE" >/dev/null 2>&1 || true + local session=$FM_BACKEND_HERDR_SESSION pane=$FM_BACKEND_HERDR_PANE + local lock_path attempt=0 lock_held=0 + if ! declare -F fm_lock_try_acquire >/dev/null 2>&1; then + # shellcheck source=bin/fm-wake-lib.sh + . "$FM_BACKEND_HERDR_ROOT/bin/fm-wake-lib.sh" + fi + if lock_path=$(fm_backend_herdr_presentation_session_lock_path "$session"); then + while [ "$attempt" -lt 50 ]; do + if fm_lock_try_acquire "$lock_path"; then + lock_held=1 + break + fi + sleep 0.1 + attempt=$((attempt + 1)) + done + fi + if [ "$lock_held" = 1 ]; then + fm_backend_herdr_kill_serialized "$session" "$pane" + fm_lock_release "$lock_path" || true + else + echo "warning: herdr task kill could not acquire its session presentation lock; refusing an unlocked pane close" >&2 + fi +} + +# fm_backend_herdr_endpoint_confirmed_gone: gate durable-record removal on +# the exact recorded pane's structured presence +# (fm_backend_herdr_pane_presence_state), read-only, so a refused, skipped, +# or failed close never erases a live task's endpoint identity. +# Only a structured pane_not_found proves the endpoint gone; present and +# unknown presence refuse after every close path, and a missing or malformed +# target identity is ambiguity that also refuses, never proof of a gone pane. +fm_backend_herdr_endpoint_confirmed_gone() { # + local presence + fm_backend_herdr_parse_target "$1" || return 1 + presence=$(fm_backend_herdr_pane_presence_state "$FM_BACKEND_HERDR_SESSION" "$FM_BACKEND_HERDR_PANE") + [ "$presence" = dead ] } # fm_backend_herdr_classify_agent_status: map a raw `agent get` agent_status diff --git a/bin/fm-afk-launch.sh b/bin/fm-afk-launch.sh index 3bc1a1cbac4..4be7d6a349f 100755 --- a/bin/fm-afk-launch.sh +++ b/bin/fm-afk-launch.sh @@ -628,10 +628,15 @@ fm_afk_launch_stop() { fm_afk_launch_main() { local result - fm_afk_launch_lock_acquire || return 1 + # Traps first, lock second. Acquiring before the handlers exist leaves a + # window where a signal terminates this process by default action and leaks + # the lock directory, which then blocks the next away-mode launch until the + # stale-owner reclaim path clears it. fm_afk_launch_lock_release only removes + # a lock this process owns, so arming it before acquisition is safe. trap fm_afk_launch_lock_release EXIT trap 'exit 130' INT trap 'exit 143' TERM + fm_afk_launch_lock_acquire || return 1 case "${1:-start}" in start) fm_afk_launch_start ;; start-native) fm_afk_launch_start_native ;; diff --git a/bin/fm-backlog-handoff.sh b/bin/fm-backlog-handoff.sh index 66da67b8a87..29ab9cd2d66 100755 --- a/bin/fm-backlog-handoff.sh +++ b/bin/fm-backlog-handoff.sh @@ -53,21 +53,19 @@ REG="$DATA/secondmates.md" MAIN_BACKLOG="$DATA/backlog.md" # shellcheck source=bin/fm-tasks-axi-lib.sh disable=SC1091 . "$SCRIPT_DIR/fm-tasks-axi-lib.sh" +# shellcheck source=bin/fm-secondmate-registry-lib.sh +. "$SCRIPT_DIR/fm-secondmate-registry-lib.sh" [ $# -ge 2 ] || { echo "usage: fm-backlog-handoff.sh ..." >&2; exit 1; } ID=$1 shift secondmate_home() { - local id=$1 line + local id=$1 home [ -f "$REG" ] || { echo "error: no secondmate registry at $REG" >&2; return 1; } - line=$(grep -E "^- $id( |$)" "$REG" | tail -1 || true) - [ -n "$line" ] || { echo "error: secondmate $id is not registered in $REG" >&2; return 1; } - # Match the (home: ...) field itself; do not require zero parentheses before it. - # Summary/scope prose often contains parentheticals (e.g. "(id is legacy)"), and - # ^[^(]* would leave those entries looking like "has no home". Greedy prefix so the - # last (home: ...) on the line wins. Empty when the field is absent. - printf '%s\n' "$line" | sed -n 's/.*(home:[[:space:]]*\([^;)]*\);.*/\1/p' | sed 's/[[:space:]]*$//' + home=$(secondmate_registry_field "$REG" "$id" home || true) + [ -n "$home" ] || { echo "error: secondmate $id has no home in $REG" >&2; return 1; } + printf '%s\n' "$home" } path_is_ancestor_of() { diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 9fc7115106f..8811980847a 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -8,6 +8,7 @@ # Lines: "MISSING: (install: )", # "MISSING_MANUAL: (instructions: )", "NEEDS_GH_AUTH", # "BACKEND_INVALID: (known: )", +# "STARTUP_MEMORY_BUDGET: invalid config/startup-memory-budget - ", # "CREW_DISPATCH: invalid config/crew-dispatch.json - ", # "FLEET_SYNC: : skipped|recovered|STUCK: ", # "PR_CHECK_MIGRATION: ", @@ -55,7 +56,15 @@ # 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 -# and .agents/skills/quota-array-dispatch/SKILL.md. +# and .agents/skills/quota-array-dispatch/SKILL.md, and is also version +# gated by fm-quota-axi-lib.sh, which owns that floor and its rationale. +# An older build reports MISSING like no-mistakes rather than passing +# silently while emitting auth semantics dispatch cannot scope. +# On a primary home, the locked mutable path materializes the visible +# default config/startup-memory-budget=7500 when absent. It never +# guesses at malformed or unsafe existing files, and secondmate homes +# await the primary-authoritative inherited value instead of creating +# their own. # 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. @@ -103,12 +112,16 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" # shellcheck source=bin/fm-tasks-axi-lib.sh disable=SC1091 . "$SCRIPT_DIR/fm-tasks-axi-lib.sh" +# shellcheck source=bin/fm-quota-axi-lib.sh disable=SC1091 +. "$SCRIPT_DIR/fm-quota-axi-lib.sh" # shellcheck source=bin/fm-tangle-lib.sh disable=SC1091 . "$SCRIPT_DIR/fm-tangle-lib.sh" # shellcheck source=bin/fm-ff-lib.sh disable=SC1091 . "$SCRIPT_DIR/fm-ff-lib.sh" # shellcheck source=bin/fm-config-inherit-lib.sh disable=SC1091 . "$SCRIPT_DIR/fm-config-inherit-lib.sh" +# shellcheck source=bin/fm-startup-memory-budget-lib.sh disable=SC1091 +. "$SCRIPT_DIR/fm-startup-memory-budget-lib.sh" # shellcheck source=bin/fm-x-lib.sh disable=SC1091 . "$SCRIPT_DIR/fm-x-lib.sh" # shellcheck source=bin/fm-pr-lib.sh disable=SC1091 @@ -547,31 +560,31 @@ if ! BACKEND_TOOLS=$(fm_backend_required_tools "$BACKEND"); then BACKEND_TOOLS="" fi TOOLS="$BACKEND_TOOLS $COMMON_TOOLS" -NO_MISTAKES_MIN_MAJOR=1 -NO_MISTAKES_MIN_MINOR=31 -NO_MISTAKES_MIN_PATCH=2 +NO_MISTAKES_MIN=1.31.2 treehouse_supports_lease() { treehouse get --help 2>&1 | grep -Eq '(^|[^[:alnum:]_-])--lease([^[:alnum:]_-]|$)' } -no_mistakes_version_parts() { - local output - command -v no-mistakes >/dev/null 2>&1 || return 1 - output=$(no-mistakes --version 2>/dev/null) || return 1 - printf '%s\n' "$output" | sed -nE 's/.*[vV]?([0-9]+)\.([0-9]+)\.([0-9]+).*/\1 \2 \3/p' | head -n 1 -} - -no_mistakes_compatible() { - local parts major minor patch extra - parts=$(no_mistakes_version_parts) || return 1 +# Shared semantic-version floor for the tool gates below. A version string that +# cannot be parsed into exactly one major.minor.patch triple is incompatible, +# never assumed current, so a development or vendored build cannot pass a floor +# it was never checked against. +tool_version_at_least() { # + local tool=$1 min=$2 output parts major minor patch extra + local min_major min_minor min_patch min_extra + command -v "$tool" >/dev/null 2>&1 || return 1 + output=$("$tool" --version 2>/dev/null) || return 1 + parts=$(printf '%s\n' "$output" | sed -nE 's/.*[vV]?([0-9]+)\.([0-9]+)\.([0-9]+).*/\1 \2 \3/p' | head -n 1) IFS=' ' read -r major minor patch extra <<< "$parts" [ -n "$major" ] && [ -n "$minor" ] && [ -n "$patch" ] && [ -z "$extra" ] || return 1 - [ "$major" -gt "$NO_MISTAKES_MIN_MAJOR" ] && return 0 - [ "$major" -eq "$NO_MISTAKES_MIN_MAJOR" ] || return 1 - [ "$minor" -gt "$NO_MISTAKES_MIN_MINOR" ] && return 0 - [ "$minor" -eq "$NO_MISTAKES_MIN_MINOR" ] || return 1 - [ "$patch" -ge "$NO_MISTAKES_MIN_PATCH" ] + IFS='.' read -r min_major min_minor min_patch min_extra <<< "$min" + [ -n "$min_major" ] && [ -n "$min_minor" ] && [ -n "$min_patch" ] && [ -z "$min_extra" ] || return 1 + [ "$major" -gt "$min_major" ] && return 0 + [ "$major" -eq "$min_major" ] || return 1 + [ "$minor" -gt "$min_minor" ] && return 0 + [ "$minor" -eq "$min_minor" ] || return 1 + [ "$patch" -ge "$min_patch" ] } bootstrap_artifact_write_if_changed() { @@ -654,7 +667,7 @@ bootstrap_artifact_remove() { # applying a cadence transition to a running watcher is the caller's job via # the emitted harness-aware supervision repair instruction. x_mode_setup() { - local env_file token shim cadence shim_body cadence_body tool missing + local env_file token shim cadence shim_body cadence_body tool missing shim_home env_file="$FM_HOME/.env" shim="$STATE/x-watch.check.sh" cadence="$CONFIG/x-mode.env" @@ -929,6 +942,18 @@ crew_dispatch_validate() { fi } +startup_memory_budget_setup() { + # Primary bootstrap owns default publication. A secondmate is deliberately + # passive here because its setting must converge from the primary through the + # inherited-local-material contract rather than becoming a local authority. + if [ -e "$FM_HOME/.fm-secondmate-home" ] || [ -L "$FM_HOME/.fm-secondmate-home" ]; then + return 0 + fi + if ! fm_startup_memory_budget_materialize "$CONFIG"; then + echo "STARTUP_MEMORY_BUDGET: invalid config/$FM_STARTUP_MEMORY_BUDGET_FILE - $FM_STARTUP_MEMORY_BUDGET_ERROR" + fi +} + if [ "${1:-}" = "install" ]; then shift [ $# -gt 0 ] || { echo "usage: fm-bootstrap.sh install ..." >&2; exit 1; } @@ -951,6 +976,7 @@ fi # runnable. Detect-only sessions never touch state. if [ "${FM_BOOTSTRAP_DETECT_ONLY:-0}" != 1 ]; then "$SCRIPT_DIR/fm-pr-check-migrate.sh" || true + startup_memory_budget_setup fi if [ "$BACKEND_VALID" -eq 0 ]; then @@ -970,9 +996,12 @@ if fm_backend_list_contains "$TOOLS" treehouse \ && command -v treehouse >/dev/null 2>&1 && ! treehouse_supports_lease; then echo "MISSING: treehouse (install: $(install_cmd treehouse))" fi -if command -v no-mistakes >/dev/null 2>&1 && ! no_mistakes_compatible; then +if command -v no-mistakes >/dev/null 2>&1 && ! tool_version_at_least no-mistakes "$NO_MISTAKES_MIN"; then echo "MISSING: no-mistakes (install: $(install_cmd no-mistakes))" fi +if command -v quota-axi >/dev/null 2>&1 && ! fm_quota_axi_compatible; then + echo "MISSING: quota-axi (install: $(install_cmd quota-axi))" +fi if command -v tasks-axi >/dev/null 2>&1 && ! fm_tasks_axi_compatible; then echo "MISSING: tasks-axi (install: $(install_cmd tasks-axi))" fi diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 3328d893053..9ce35ebdc5d 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -339,6 +339,7 @@ Firstmate will then instruct you to run /no-mistakes to validate and ship a PR. You drive no-mistakes by responding to its gates, not by implementing fixes. Follow the guidance no-mistakes itself provides for the mechanics: it loads when you invoke /no-mistakes, and \`no-mistakes axi run --help\` plus the \`help\` lines in each \`axi\` response are authoritative and version-matched to the installed binary. +When starting no-mistakes, make \`--intent\` preserve all relevant content from this brief's \`# Task\` section plus every later accepted Firstmate requirement, clarification, constraint, exclusion, and supersession, carrying only each requirement's current accepted form; retain direct requirements instead of substituting a diff summary, and exclude generic operational, status, delivery, and other scaffold boilerplate unless it is task-specific. Do not hand-edit, commit, or fix findings yourself while a run is active - the pipeline applies every fix. Two firstmate-specific rules layer on top of that guidance: diff --git a/bin/fm-busy-event.sh b/bin/fm-busy-event.sh new file mode 100755 index 00000000000..d5484c9d5c4 --- /dev/null +++ b/bin/fm-busy-event.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env bash +# fm-busy-event.sh - the ONLY writer of the semantic busy-state contract +# owned by bin/fm-busy-lib.sh (record format, gen binding, and classification +# live there; this script owns mutation mechanics only). +# +# Subcommands: +# +# arm [--state busy|idle|unknown] [--source S] [--event E] +# Mint a fresh incarnation gen token, write the gen sidecar, and seed +# the record at seq=1 (default: busy, source fm-spawn, event +# launch-brief - the launch prompt IS a submitted turn). Prints the +# minted gen on stdout so the caller can embed it into adapter wiring. +# Arming again replaces the previous incarnation: late events carrying +# the old gen are rejected as stale from then on. +# +# apply (--gen G | --current-gen) +# --source S --event E +# Append one lifecycle event: validate the gen against the armed +# sidecar, advance seq under the lock, atomically replace the record. +# Adapter wiring passes the exact --gen embedded at arm time, so a +# hook that outlives its incarnation fails closed here. Firstmate-owned +# paths (fm-interrupt, fm-recovery) may pass --current-gen to bind to +# whatever incarnation is armed right now. +# +# retire (--gen G | --current-gen) +# Remove one incarnation's sidecar and record while holding the same +# writer lock used by arm and apply. An exact gen prevents teardown for +# an old task from retiring a newly armed incarnation. A missing sidecar +# is already retired, so any orphan record is removed idempotently. +# +# Exit codes: 0 applied; 1 refused (stale gen, unarmed task, lock timeout, +# invalid input); 2 usage. Adapter hook command lines append `|| true` so a +# refusal never breaks the harness's own lifecycle. +set -u + +usage() { + cat >&2 <<'EOF' +usage: + fm-busy-event.sh arm [--state busy|idle|unknown] [--source S] [--event E] + fm-busy-event.sh apply (--gen G | --current-gen) --source S --event E + fm-busy-event.sh retire (--gen G | --current-gen) +See the header comment for the full contract. +EOF + exit 2 +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=bin/fm-busy-lib.sh +. "$SCRIPT_DIR/fm-busy-lib.sh" + +CMD=${1:-} +case "$CMD" in + arm|apply|retire) shift ;; + *) usage ;; +esac + +STATE=${1:-} +ID=${2:-} +[ -n "$STATE" ] && [ -n "$ID" ] || usage +shift 2 +case "$ID" in *[!A-Za-z0-9._-]*) echo "error: invalid task id" >&2; exit 1 ;; esac +[ -d "$STATE" ] || { echo "error: state dir not found: $STATE" >&2; exit 1; } + +NEW_STATE= +GEN= +USE_CURRENT_GEN=0 +SOURCE= +EVENT= +if [ "$CMD" = apply ]; then + NEW_STATE=${1:-} + case "$NEW_STATE" in busy|idle|unknown) shift ;; *) usage ;; esac +elif [ "$CMD" = arm ]; then + NEW_STATE=busy + SOURCE=fm-spawn + EVENT=launch-brief +fi +while [ $# -gt 0 ]; do + case "$1" in + --state) NEW_STATE=${2:-}; shift 2 || usage ;; + --gen) GEN=${2:-}; shift 2 || usage ;; + --current-gen) USE_CURRENT_GEN=1; shift ;; + --source) SOURCE=${2:-}; shift 2 || usage ;; + --event) EVENT=${2:-}; shift 2 || usage ;; + *) usage ;; + esac +done +if [ "$CMD" != retire ]; then + case "$NEW_STATE" in busy|idle|unknown) : ;; *) usage ;; esac + fm_busy_token_valid "$SOURCE" || { echo "error: invalid --source" >&2; exit 1; } + fm_busy_token_valid "$EVENT" || { echo "error: invalid --event" >&2; exit 1; } +fi + +REC=$(fm_busy_record_path "$STATE" "$ID") +GEN_FILE=$(fm_busy_gen_path "$STATE" "$ID") +LOCK="$REC.lock" + +# Serialize writers. The lock protects seq advancement and the sidecar/record +# pair; a holder that died mid-write is broken after FM_BUSY_LOCK_STALE_SECS. +lock_acquire() { + local tries=0 now mtime age + while ! mkdir "$LOCK" 2>/dev/null; do + tries=$((tries + 1)) + if [ "$tries" -ge 40 ]; then + now=$(date +%s) + mtime=$(stat -f %m "$LOCK" 2>/dev/null || stat -c %Y "$LOCK" 2>/dev/null || echo "$now") + age=$((now - mtime)) + if [ "$age" -ge "${FM_BUSY_LOCK_STALE_SECS:-5}" ]; then + rmdir "$LOCK" 2>/dev/null || rm -rf "$LOCK" 2>/dev/null || true + mkdir "$LOCK" 2>/dev/null && break + fi + echo "error: busy-state lock timeout for $ID" >&2 + return 1 + fi + sleep 0.05 + done + return 0 +} +lock_release() { rmdir "$LOCK" 2>/dev/null || true; } + +write_record() { # + local tmp + tmp="$REC.tmp.$$" + printf 'v1 gen=%s seq=%s state=%s source=%s event=%s ts=%s\n' \ + "$1" "$2" "$NEW_STATE" "$SOURCE" "$EVENT" "$(date +%s)" > "$tmp" || return 1 + mv -f "$tmp" "$REC" +} + +old_umask=$(umask) +umask 077 + +if [ "$CMD" = arm ]; then + GEN="g$(date +%s).$$.$RANDOM" + lock_acquire || exit 1 + { + printf '%s\n' "$GEN" > "$GEN_FILE.tmp.$$" && mv -f "$GEN_FILE.tmp.$$" "$GEN_FILE" \ + && write_record "$GEN" 1 + } || { lock_release; umask "$old_umask"; echo "error: arm failed for $ID" >&2; exit 1; } + lock_release + umask "$old_umask" + printf '%s\n' "$GEN" + exit 0 +fi + +# apply / retire +if [ "$USE_CURRENT_GEN" = 1 ] && [ "$CMD" != retire ]; then + GEN=$(fm_busy_current_gen "$STATE" "$ID") || { + umask "$old_umask" + echo "error: no armed busy-state gen for $ID" >&2 + exit 1 + } +fi +if [ "$USE_CURRENT_GEN" != 1 ] || [ "$CMD" != retire ]; then + fm_busy_token_valid "$GEN" || { umask "$old_umask"; echo "error: invalid --gen" >&2; exit 1; } +fi + +lock_acquire || { umask "$old_umask"; exit 1; } +CURRENT=$(fm_busy_current_gen "$STATE" "$ID") || { + if [ "$CMD" = retire ] && [ ! -e "$GEN_FILE" ] && [ ! -L "$GEN_FILE" ]; then + rm -f "$REC" || { + lock_release + umask "$old_umask" + echo "error: busy-state retirement failed for $ID" >&2 + exit 1 + } + lock_release + umask "$old_umask" + exit 0 + fi + lock_release + umask "$old_umask" + echo "error: no armed busy-state gen for $ID" >&2 + exit 1 +} +if [ "$CMD" = retire ] && [ "$USE_CURRENT_GEN" = 1 ]; then + GEN=$CURRENT +fi +if [ "$GEN" != "$CURRENT" ]; then + lock_release + umask "$old_umask" + echo "error: stale busy-state gen for $ID (event rejected)" >&2 + exit 1 +fi +if [ "$CMD" = retire ]; then + rm -f "$GEN_FILE" "$REC" || { + lock_release + umask "$old_umask" + echo "error: busy-state retirement failed for $ID" >&2 + exit 1 + } + lock_release + umask "$old_umask" + exit 0 +fi +OLD_SEQ=0 +if [ -f "$REC" ]; then + old_line=$(head -n 1 "$REC" 2>/dev/null || true) + case "$old_line" in + *" gen=$GEN "*) + old_seq_field=${old_line##* seq=} + old_seq_field=${old_seq_field%% *} + case "$old_seq_field" in + ''|*[!0-9]*) OLD_SEQ=0 ;; + *) OLD_SEQ=$old_seq_field ;; + esac + ;; + esac +fi +write_record "$GEN" $((OLD_SEQ + 1)) || { + lock_release + umask "$old_umask" + echo "error: record write failed for $ID" >&2 + exit 1 +} +lock_release +umask "$old_umask" +exit 0 diff --git a/bin/fm-busy-lib.sh b/bin/fm-busy-lib.sh new file mode 100755 index 00000000000..d12cebc3041 --- /dev/null +++ b/bin/fm-busy-lib.sh @@ -0,0 +1,376 @@ +#!/usr/bin/env bash +# fm-busy-lib.sh - the ONE owner of firstmate's semantic busy-state contract. +# +# Design source: the captain-approved semantic busy-state redesign +# (2026-07-28): each harness adapter reports turn lifecycle through a +# machine-readable semantic source it owns, classification always exposes +# which source produced it, and missing, malformed, stale, unsupported, or +# unverified semantic data is UNKNOWN - never idle. Endpoint death is the only +# process-level override and yields dead, never busy. Child processes, CPU, +# process sleep state, marker mtimes, and the old global UI-regex OR are not +# state signals here; state/.turn-ended files remain wake NOTIFICATIONS +# owned by the watcher, not current-state truth. +# +# Record file: state/.busy-state - exactly one line, atomically replaced +# by bin/fm-busy-event.sh (the only writer): +# +# v1 gen= seq= state= source= event= ts= +# +# Gen sidecar: state/.busy-gen - one token minted when the task's busy +# wiring is armed (fm-spawn, or a documented recovery re-arm). Every event +# must present the current gen; an event or record carrying any other gen is +# a stale incarnation and is rejected (written events) or classified unknown +# (read records). seq is a strictly increasing integer per gen, advanced +# under the writer's lock, so an out-of-order apply can never regress a +# newer record. +# +# Semantic sources written by adapters (fm_busy_sources_for_harness owns the +# per-harness trust table; a record whose source is not trusted for the +# task's recorded harness classifies unknown, so one adapter's writer can +# never classify another adapter): +# pi-ext Pi/pi-signed per-task extension (agent_start/agent_settled) +# opencode-plugin OpenCode per-task plugin (session.status) +# claude-hook Claude lifecycle hooks (UserPromptSubmit/Stop/StopFailure/SessionEnd) +# codex-hook, codex-appserver reserved: Codex, gated by +# fm_busy_codex_semantic_source +# kimi-wire, kimi-hook reserved: standalone Kimi, gated by fm_busy_kimi_verified +# Firstmate-owned sources accepted for every converted adapter: +# fm-spawn the launch-brief turn seeded at spawn +# fm-interrupt a firstmate-controlled interruption of the worker +# fm-recovery a documented recovery reset after relaunch +# Classifier-only sources (never written into a record): +# endpoint-gone, herdr-native, grok-regex, missing, malformed, +# gen-mismatch, source-mismatch, kimi-unverified, codex-unverified, +# capture-failed, no-target +# +# Classification (fm_busy_classify): busy | idle | unknown | dead, always +# with the producing source as the second token. Precedence: +# 1. dead endpoint (fm_busy_classify_live only) -> dead endpoint-gone +# 2. standalone Kimi before verification -> unknown kimi-unverified +# 3. a valid, gen-matching, source-trusted record -> its state and source +# 4. no record at all: herdr's native busy verdict is trusted as busy +# (generation state is sufficient for busy, not for idle), then the +# Grok-only temporary regex fallback classifies a grok task from its +# rendered tail, then unknown missing +# 5. malformed, stale, or untrusted records -> unknown, never a fallback +# The Grok arm is the ONLY rendered-text classification that survives the +# redesign, because Grok's structured lifecycle was not credited-live-verified +# in the approved audit; it is scoped to harness=grok and can never classify +# another adapter. The delivery guards in bin/fm-tmux-lib.sh match rendered +# footers for submit acknowledgement and away-mode supervisor injection only; +# neither is a recorded worker state source. +# +# Codex negotiation (fm_busy_codex_appserver_observable, +# fm_busy_codex_hooks_verified): the approved contract prefers Codex's +# app-server turn lifecycle with capability negotiation, and sanctions its +# stable lifecycle hooks as the intermediate. Neither is usable on the +# installed binary, so Codex classifies unknown codex-unverified rather than +# falling back to idle, and fm-spawn installs no Codex busy wiring. +# docs/verification/supervision.md owns the evidence for both probes. +# +# Sourcing: set -u and set -e safe; no subshell-unfriendly globals. + +FM_BUSY_LIB_VERSION=v1 + +# Standalone-Kimi verification gate. Empty means no installed Kimi version +# has passed live verification, so every standalone Kimi task classifies +# unknown kimi-unverified and fm-spawn wires no Kimi busy events. Kimi's +# rendered moon-phase spinner is deliberately NOT a state source here: the +# approved redesign forbids inventing a Kimi UI signature, and that spinner +# is locale- and emoji-font-sensitive. +# +# Preferred source, in order: Wire mode's JSON-RPC `prompt` request lifetime, +# whose outstanding request exactly brackets a turn and returns finished, +# cancelled, or max_steps_reached (so it covers interruption, which `Stop` +# does not); then the documented lifecycle hooks, which must include +# `Interrupt` because Kimi documents that `Stop` does not fire on interrupts. +# +# To open the gate: install Kimi, live-verify the chosen source brackets a +# real turn on a firstmate-launched worker including the interrupt path, +# record the version, exact commands, and observed output in +# docs/verification/supervision.md, add the verified version string(s) here, +# and land the wiring in fm-spawn behind this same gate in the same change. +FM_BUSY_KIMI_VERIFIED_VERSIONS="" + +fm_busy_kimi_verified() { + [ -n "$FM_BUSY_KIMI_VERIFIED_VERSIONS" ] +} + +# fm_busy_codex_appserver_observable: capability/version negotiation for the +# Codex app-server turn lifecycle. Returns 0 only when a pane worker's turns +# are observable through the app-server protocol on the installed binary. +# codex-cli 0.145.0 verdict (live, 2026-07-28): NOT observable. The v2 +# protocol does define the needed turn lifecycle (turn/started plus a +# turn/completed status of completed, interrupted, failed, or inProgress), +# but an interactive TUI worker neither starts nor attaches to the +# app-server daemon, and `codex app-server daemon start` refuses outside the +# managed standalone install, so no client can observe a pane worker's turns. +fm_busy_codex_appserver_observable() { + return 1 +} + +# fm_busy_codex_hooks_verified: the sanctioned intermediate - Codex's stable +# hooks engine (UserPromptSubmit to open a turn, Stop and SessionEnd to close +# it). Returns 0 only once those hooks are live-verified to fire for a +# firstmate-launched worker. codex-cli 0.145.0 verdict (live, 2026-07-28): +# NOT verified. Firstmate-written project hooks under /.codex/ +# never fired in an interactive pane whose directory trust was granted, nor +# under `codex exec`, in either case with --dangerously-bypass-hook-trust, +# while global hooks fired in the same runs. Codex additionally exposes no +# StopFailure hook, so an API-error turn end would need separate coverage +# even after the discovery problem is solved. +fm_busy_codex_hooks_verified() { + return 1 +} + +# fm_busy_codex_semantic_source: 0 when ANY verified Codex semantic source +# exists. fm-spawn arms and wires Codex only behind this gate, and the +# classifier reports unknown codex-unverified until it opens. +fm_busy_codex_semantic_source() { + fm_busy_codex_appserver_observable || fm_busy_codex_hooks_verified +} + +fm_busy_record_path() { # + printf '%s/%s.busy-state' "$1" "$2" +} + +fm_busy_gen_path() { # + printf '%s/%s.busy-gen' "$1" "$2" +} + +# fm_busy_token_valid: conservative token charset shared by gen, source, and +# event fields. Anything else is malformed. +fm_busy_token_valid() { # + case "${1:-}" in + ''|*[!A-Za-z0-9._-]*) return 1 ;; + esac + return 0 +} + +# fm_busy_current_gen: the task's armed gen token, or failure when the busy +# contract has never been armed for this task. +fm_busy_current_gen() { # + local gen_file gen + gen_file=$(fm_busy_gen_path "$1" "$2") + [ -f "$gen_file" ] || return 1 + IFS= read -r gen < "$gen_file" 2>/dev/null || gen= + fm_busy_token_valid "$gen" || return 1 + printf '%s' "$gen" +} + +# fm_busy_sources_for_harness: the semantic sources trusted to classify a +# task recorded with . One line, space-separated, possibly empty. +# The firstmate-owned sources are appended for every converted adapter. +# Grok deliberately trusts nothing: it has no semantic writer yet, and its +# temporary rendered-tail fallback lives in the classifier, not in records. +fm_busy_sources_for_harness() { # + local adapter= + case "${1:-}" in + claude*) adapter=claude-hook ;; + codex*) + fm_busy_codex_semantic_source || { printf ''; return 0; } + adapter='codex-hook codex-appserver' + ;; + opencode*) adapter=opencode-plugin ;; + pi|pi-signed) adapter=pi-ext ;; + kimi*) + fm_busy_kimi_verified || { printf ''; return 0; } + adapter='kimi-wire kimi-hook' + ;; + *) printf ''; return 0 ;; + esac + printf '%s fm-spawn fm-interrupt fm-recovery' "$adapter" +} + +fm_busy_source_trusted() { # + local trusted + trusted=$(fm_busy_sources_for_harness "$1") + case " $trusted " in + *" $2 "*) return 0 ;; + esac + return 1 +} + +# fm_busy_record_read: parse and validate state/.busy-state against the +# armed gen. Prints " " for a valid record. +# Non-zero returns name the reason on stdout instead: +# missing no record file (or no armed gen and no record) +# malformed unparseable line, bad tokens, or a missing armed gen for an +# existing record +# gen-mismatch a record from a stale incarnation +fm_busy_record_read() { # + local state=$1 id=$2 rec gen line extra ver f + local r_gen='' r_seq='' r_state='' r_source='' r_event='' r_ts='' + rec=$(fm_busy_record_path "$state" "$id") + if [ ! -f "$rec" ]; then + printf 'missing' + return 1 + fi + if ! gen=$(fm_busy_current_gen "$state" "$id"); then + # A record without an armed gen has no incarnation to bind to. + printf 'malformed' + return 1 + fi + # shellcheck disable=SC2034 # extra exists only to prove the record is one line + { IFS= read -r line && ! IFS= read -r extra; } < "$rec" 2>/dev/null || { + printf 'malformed' + return 1 + } + # `read -a` rather than `set --`: it never glob-expands a field and never + # touches the caller's positional parameters or shell options. + local -a fields + IFS=' ' read -r -a fields <<< "$line" + ver=${fields[0]:-} + [ "$ver" = "$FM_BUSY_LIB_VERSION" ] || { printf 'malformed'; return 1; } + for f in "${fields[@]:1}"; do + case "$f" in + gen=*) r_gen=${f#gen=} ;; + seq=*) r_seq=${f#seq=} ;; + state=*) r_state=${f#state=} ;; + source=*) r_source=${f#source=} ;; + event=*) r_event=${f#event=} ;; + ts=*) r_ts=${f#ts=} ;; + *) printf 'malformed'; return 1 ;; + esac + done + fm_busy_token_valid "$r_gen" || { printf 'malformed'; return 1; } + fm_busy_token_valid "$r_source" || { printf 'malformed'; return 1; } + fm_busy_token_valid "$r_event" || { printf 'malformed'; return 1; } + case "$r_seq" in ''|*[!0-9]*) printf 'malformed'; return 1 ;; esac + case "$r_ts" in ''|*[!0-9]*) printf 'malformed'; return 1 ;; esac + case "$r_state" in busy|idle|unknown) : ;; *) printf 'malformed'; return 1 ;; esac + if [ "$r_gen" != "$gen" ]; then + printf 'gen-mismatch' + return 1 + fi + printf '%s %s %s %s' "$r_state" "$r_source" "$r_event" "$r_seq" +} + +# fm_busy_grok_tail_busy: the Grok-only temporary rendered-tail fallback. +# Consumes the tail on stdin; 0 when Grok's verified busy signature matches. +# FM_BUSY_REGEX still globally overrides the signature, mirroring the +# historical operator escape hatch. +fm_busy_grok_tail_busy() { + grep -v '^[[:space:]]*$' | tail -12 \ + | grep -qiE "${FM_BUSY_REGEX:-${FM_TMUX_GROK_BUSY_REGEX_DEFAULT:-Ctrl\\+c:cancel}}" +} + +# fm_busy_classify: semantic classification for a task whose endpoint the +# caller has already established as present. Prints " ": +# busy|idle|unknown plus the producing source (see header). Never probes +# process state. is optional pre-captured plain output used only by +# the Grok arm; when absent the Grok arm captures through fm_backend_capture +# if available, else reports unknown capture-failed. +fm_busy_classify() { # [tail40] + local backend=$1 target=$2 harness=$3 id=$4 state=$5 tail40=${6-} + local out rc r_state r_source native + case "$harness" in + kimi*) + if ! fm_busy_kimi_verified; then + printf 'unknown kimi-unverified' + return 0 + fi + ;; + codex*) + if ! fm_busy_codex_semantic_source; then + printf 'unknown codex-unverified' + return 0 + fi + ;; + esac + out=$(fm_busy_record_read "$state" "$id") && rc=0 || rc=$? + if [ "$rc" = 0 ]; then + r_state=${out%% *} + out=${out#* } + r_source=${out%% *} + if fm_busy_source_trusted "$harness" "$r_source"; then + printf '%s %s' "$r_state" "$r_source" + else + printf 'unknown source-mismatch' + fi + return 0 + fi + case "$out" in + malformed|gen-mismatch) + printf 'unknown %s' "$out" + return 0 + ;; + esac + # No record at all. A native herdr busy verdict is semantic enough to trust + # for BUSY (streaming means a turn is running); native idle is narrower + # than turn state (a long foreground tool call reads idle) and stays + # unknown here. + if [ "$backend" = herdr ] && command -v fm_backend_busy_state >/dev/null 2>&1; then + native=$(fm_backend_busy_state "$backend" "$target" 2>/dev/null || true) + if [ "$native" = busy ]; then + printf 'busy herdr-native' + return 0 + fi + fi + case "$harness" in + grok*) + if [ -z "$tail40" ]; then + if command -v fm_backend_capture >/dev/null 2>&1; then + tail40=$(fm_backend_capture "$backend" "$target" 40 2>/dev/null) || { + printf 'unknown capture-failed' + return 0 + } + else + printf 'unknown capture-failed' + return 0 + fi + fi + if printf '%s' "$tail40" | fm_busy_grok_tail_busy; then + printf 'busy grok-regex' + else + printf 'idle grok-regex' + fi + return 0 + ;; + esac + printf 'unknown missing' +} + +# fm_busy_classify_live: fm_busy_classify behind the one process-level +# override - a gone endpoint is dead, never busy. Requires fm-backend.sh to +# be sourced for fm_backend_target_exists. +fm_busy_classify_live() { # [expected-label] + local backend=$1 target=$2 harness=$3 id=$4 state=$5 label=${6-} + if [ -z "$target" ]; then + printf 'unknown no-target' + return 0 + fi + if ! fm_backend_target_exists "$backend" "$target" "$label" 2>/dev/null; then + printf 'dead endpoint-gone' + return 0 + fi + fm_busy_classify "$backend" "$target" "$harness" "$id" "$state" +} + +# fm_busy_classify_meta: classify a task from its recorded metadata, so every +# consumer resolves backend, target, and harness the same way instead of +# re-deriving them. Requires fm-backend.sh to be sourced. is +# optional pre-captured plain output reused by the Grok arm. +fm_busy_classify_meta() { # [tail40] + local meta=$1 id=$2 state=$3 tail40=${4-} backend target harness + [ -f "$meta" ] || { printf 'unknown missing'; return 0; } + backend=$(fm_backend_of_meta "$meta") + target=$(fm_backend_target_of_meta "$meta") + harness=$(fm_meta_get "$meta" harness) + if [ -z "$target" ]; then + printf 'unknown no-target' + return 0 + fi + fm_busy_classify "$backend" "$target" "$harness" "$id" "$state" "$tail40" +} + +# fm_busy_is_busy: boolean view for callers that only gate on provable +# activity. 0 iff the classification verdict is exactly busy; idle, unknown, +# and dead all return 1, so an unknown can never be silently promoted to +# either boolean pole - callers that must distinguish idle from unknown read +# the full classification instead. +fm_busy_is_busy() { # [tail40] + local verdict + verdict=$(fm_busy_classify "$@") + [ "${verdict%% *}" = busy ] +} diff --git a/bin/fm-config-inherit-lib.sh b/bin/fm-config-inherit-lib.sh index 22109aa87aa..bffbd5234d7 100644 --- a/bin/fm-config-inherit-lib.sh +++ b/bin/fm-config-inherit-lib.sh @@ -6,7 +6,8 @@ # profile rules, primary config/crew-harness=codex makes a secondmate's crewmates # spawn on codex too, primary config/backlog-backend=manual makes that home # hand-edit backlog files too, primary config/backend pins that home's local -# runtime-backend default for future spawns, and primary +# runtime-backend default for future spawns, primary config/startup-memory-budget +# bounds that home's startup-memory curation, and primary # config/herdr-presentation-spaces enables the same default-off Herdr presentation # projection). It also pushes the one primary-authoritative shared # captain-preference file, data/captain-shared.md, into each secondmate home's @@ -32,6 +33,9 @@ # is deliberately NOT in the list: it is the primary's own setting for launching # secondmates, and a secondmate never spawns secondmates, so it must not flow # downstream. +# +# shellcheck source=bin/fm-startup-memory-budget-lib.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/fm-startup-memory-budget-lib.sh" # The one shared data file in this inheritance contract. There is deliberately # no shared learnings file. @@ -42,7 +46,7 @@ FM_SHARED_CAPTAIN_MODE="444" # The declared inheritable set (space-separated, config-dir-relative item paths). # Extend here to inherit more of the primary's local config; override via the # environment only in tests. Items must not contain whitespace. -FM_INHERITABLE_CONFIG="${FM_INHERITABLE_CONFIG:-crew-dispatch.json crew-harness backlog-backend backend herdr-presentation-spaces}" +FM_INHERITABLE_CONFIG="${FM_INHERITABLE_CONFIG:-crew-dispatch.json crew-harness backlog-backend backend herdr-presentation-spaces startup-memory-budget}" fm_inherit_file_mode() { if [ "$(uname)" = Darwin ]; then @@ -401,6 +405,47 @@ propagate_inheritable_config() { esac src="$src_config/$item" dest="$dest_config/$item" + # This one scalar config is consumed as a local safety boundary, so reject + # every unsafe or malformed source/destination artifact before the generic + # byte-copy behavior below can treat it as ordinary inherited material. + if [ "$item" = "$FM_STARTUP_MEMORY_BUDGET_FILE" ]; then + if [ -e "$src_config" ] || [ -L "$src_config" ]; then + if ! fm_startup_memory_budget_config_dir_safe "$src_config"; then + reason="unsafe primary config directory: $FM_STARTUP_MEMORY_BUDGET_ERROR" + warn_inheritable_config_error "$item" "$src_config" "$reason" + record_inheritable_config_result "$item" error "$reason" + rc=1 + continue + fi + fi + if [ -e "$dest_config" ] || [ -L "$dest_config" ]; then + if ! fm_startup_memory_budget_config_dir_safe "$dest_config"; then + reason="unsafe destination config directory: $FM_STARTUP_MEMORY_BUDGET_ERROR" + warn_inheritable_config_error "$item" "$dest_config" "$reason" + record_inheritable_config_result "$item" error "$reason" + rc=1 + continue + fi + fi + if [ -e "$src" ] || [ -L "$src" ]; then + if ! fm_startup_memory_budget_file_valid "$src"; then + reason="unsafe or invalid primary source: $FM_STARTUP_MEMORY_BUDGET_ERROR" + warn_inheritable_config_error "$item" "$src" "$reason" + record_inheritable_config_result "$item" error "$reason" + rc=1 + continue + fi + fi + if [ -e "$dest" ] || [ -L "$dest" ]; then + if ! fm_startup_memory_budget_file_valid "$dest"; then + reason="unsafe or invalid destination: $FM_STARTUP_MEMORY_BUDGET_ERROR" + warn_inheritable_config_error "$item" "$dest" "$reason" + record_inheritable_config_result "$item" error "$reason" + rc=1 + continue + fi + fi + fi if [ -f "$src" ]; then if ! destination_allows_inherited_item "$dest_config" "$item"; then reason=$(inheritable_config_skip_reason) diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 32dff236687..2719cb4aa40 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -18,6 +18,9 @@ # # state: · source: · # +# `--validation-lane ` emits the same verdict as a strict six-line record +# with full, coarse, absent, or unavailable run identity plus run-start evidence. +# # Logic, in order: # 1. Resolve worktree + backend target + kind from state/.meta. # 2. Matching no-mistakes run for this crew's branch AND current code identity, @@ -62,9 +65,18 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" . "$SCRIPT_DIR/fm-backend.sh" # shellcheck source=bin/fm-classify-lib.sh . "$SCRIPT_DIR/fm-classify-lib.sh" - -ID=${1:-} -[ -n "$ID" ] || { echo "usage: fm-crew-state.sh " >&2; exit 2; } +# shellcheck source=bin/fm-busy-lib.sh +. "$SCRIPT_DIR/fm-busy-lib.sh" + +VALIDATION_LANE_MODE=0 +if [ "${1:-}" = --validation-lane ]; then + [ "$#" -eq 2 ] || { echo "usage: fm-crew-state.sh [--validation-lane] " >&2; exit 2; } + VALIDATION_LANE_MODE=1 + ID=$2 +else + ID=${1:-} + [ -n "$ID" ] || { echo "usage: fm-crew-state.sh [--validation-lane] " >&2; exit 2; } +fi META="$STATE/$ID.meta" LOG="$STATE/$ID.status" @@ -77,10 +89,18 @@ case "$NM_TIMEOUT" in ''|*[!0-9]*) NM_TIMEOUT=10 ;; esac FM_CREW_STATE_RUNS_LIMIT=${FM_CREW_STATE_RUNS_LIMIT:-200} case "$FM_CREW_STATE_RUNS_LIMIT" in ''|*[!0-9]*) FM_CREW_STATE_RUNS_LIMIT=200 ;; esac SEP=' · ' +RUN_KIND=unavailable +RUN_ID= +RUN_START= # Emit the one canonical line and exit 0. Detail is optional. emit() { # [detail] local line="state: $1${SEP}source: $2" + if [ "$VALIDATION_LANE_MODE" -eq 1 ]; then + printf 'fm-crew-validation-v2\nstate=%s\nsource=%s\nrun-kind=%s\nrun-id=%s\nrun-start=%s\n' \ + "$1" "$2" "$RUN_KIND" "$RUN_ID" "$RUN_START" + exit 0 + fi [ -n "${3:-}" ] && line="$line${SEP}$3" printf '%s\n' "$line" exit 0 @@ -149,46 +169,19 @@ pane_readable() { # *) fm_backend_capture "$TASK_BACKEND" "$1" 1 "$EXPECTED_LABEL" >/dev/null 2>&1 ;; esac } -# crew_pane_is_busy: the busy-signature fallback, backend-aware the same way - -# fm_backend_busy_state's native semantic state (herdr's agent.get) when -# available, else the shared harness-scoped pane-regex reader -# (fm_pane_is_busy, bin/fm-tmux-lib.sh). -# -# `busy` alone is trusted outright. Both `idle` and unknown/unparseable fall -# through to the shared tail-regex corroboration, NOT just unknown: herdr's -# agent.get reports generation state ("working" while the model is streaming -# a turn, "done"/"idle" once it is not - docs/herdr-backend.md "Busy state"), -# which is a narrower signal than "this crew's turn/tool call is still in -# progress". A crew blocked on its own long-running foreground tool call (e.g. -# `no-mistakes axi run` without --yes, which blocks synchronously until a gate -# or outcome - AGENTS.md section 7) is not generating for that whole span, so -# agent.get can read idle/blocked (bin/backends/herdr.sh maps both to `idle`) -# while the pane's own rendered text still shows that recorded harness's busy -# signature for the entire tool call, exactly like tmux's regex-only reader -# would correctly report. Trusting herdr's `idle` -# outright (skipping that corroboration) is what let a still-working crew read -# as not-busy here, and - combined with a no-mistakes run-step lookup that also -# missed attribution (see nm_runs_status_for_branch) - as not provably working in -# fm-classify-lib.sh, triggering an immediate (non-wedge) stale wake instead of -# the absorb-then-escalate path. A genuinely human-blocked agent (a permission -# dialog, not mid-tool-call) does not render the busy banner, so this -# corroboration does not mask that case: it stays correctly not-busy. -crew_pane_is_busy() { # - case "$TASK_BACKEND" in - tmux) fm_pane_is_busy "$1" "$HARNESS" ;; - *) - local bs tail40 - bs=$(fm_backend_busy_state "$TASK_BACKEND" "$1" 2>/dev/null) - case "$bs" in - busy) return 0 ;; - *) - tail40=$(fm_backend_capture "$TASK_BACKEND" "$1" 40 "$EXPECTED_LABEL" 2>/dev/null) || return 1 - printf '%s' "$tail40" | grep -v '^[[:space:]]*$' | tail -12 \ - | fm_busy_lines_match "$HARNESS" - ;; - esac - ;; +# crew_busy_verdict: the crew's semantic busy state from the one contract +# owner (bin/fm-busy-lib.sh), as " ". A converted +# adapter answers from its own lifecycle record; Grok answers from its +# isolated rendered-tail fallback; a herdr crew's native `busy` is accepted +# when no record exists, but its native `idle` is NOT, because agent.get +# reports generation state (idle while a crew blocks on its own long-running +# foreground tool call) rather than turn state. +crew_busy_verdict() { # + local tail40='' + case "$HARNESS" in + grok*) tail40=$(fm_backend_capture "$TASK_BACKEND" "$1" 40 "$EXPECTED_LABEL" 2>/dev/null) || tail40='' ;; esac + fm_busy_classify "$TASK_BACKEND" "$1" "$HARNESS" "$ID" "$STATE" "$tail40" } # --- no-mistakes run lookup (authoritative when a run matches this branch) -- @@ -208,7 +201,7 @@ strip_quotes() { trim "$s" } -# Bounded no-mistakes call in the worktree; stdout only, never fails the script. +# Bounded no-mistakes call in the worktree; stdout only. HAVE_TIMEOUT=none if command -v timeout >/dev/null 2>&1; then HAVE_TIMEOUT=timeout elif command -v gtimeout >/dev/null 2>&1; then HAVE_TIMEOUT=gtimeout @@ -216,10 +209,10 @@ elif command -v perl >/dev/null 2>&1; then HAVE_TIMEOUT=perl fi nm_run() { # case "$HAVE_TIMEOUT" in - timeout) ( cd "$WT" && timeout "$NM_TIMEOUT" no-mistakes "$@" ) 2>/dev/null || true ;; - gtimeout) ( cd "$WT" && gtimeout "$NM_TIMEOUT" no-mistakes "$@" ) 2>/dev/null || true ;; - perl) ( cd "$WT" && perl -e 'my $t = shift; my $pid = fork; die "fork failed" unless defined $pid; if (!$pid) { setpgrp(0, 0); exec @ARGV } local $SIG{ALRM} = sub { kill "TERM", -$pid; select undef, undef, undef, 0.2; kill "KILL", -$pid; exit 124 }; alarm $t; waitpid $pid, 0; exit($? >> 8)' "$NM_TIMEOUT" no-mistakes "$@" ) 2>/dev/null || true ;; - *) true ;; + timeout) ( cd "$WT" && timeout "$NM_TIMEOUT" no-mistakes "$@" ) 2>/dev/null ;; + gtimeout) ( cd "$WT" && gtimeout "$NM_TIMEOUT" no-mistakes "$@" ) 2>/dev/null ;; + perl) ( cd "$WT" && perl -e 'my $t = shift; my $pid = fork; die "fork failed" unless defined $pid; if (!$pid) { setpgrp(0, 0); exec @ARGV } local $SIG{ALRM} = sub { kill "TERM", -$pid; select undef, undef, undef, 0.2; kill "KILL", -$pid; exit 124 }; alarm $t; waitpid $pid, 0; my $status = $?; exit 125 if $status == -1; exit(128 + ($status & 127)) if $status & 127; exit($status >> 8)' "$NM_TIMEOUT" no-mistakes "$@" ) 2>/dev/null ;; + *) return 127 ;; esac } @@ -378,9 +371,13 @@ nm_ci_checks_state() { # is a run for THIS branch active right now. Echoes the first (most recent) # matching row's status word (running/completed/cancelled/failed), or empty # when the branch has no run within FM_CREW_STATE_RUNS_LIMIT rows. -nm_runs_status_for_branch() { # - local branch=$1 out row st rest br sha - out=$(nm_run runs --limit "$FM_CREW_STATE_RUNS_LIMIT") +nm_runs_observation_for_branch() { # + local branch=$1 out row st rest br sha date_part time_part limit selected_date='' selected_time='' occurrence=0 + COARSE_STATUS= + COARSE_START= + limit=$FM_CREW_STATE_RUNS_LIMIT + [ "$VALIDATION_LANE_MODE" -eq 0 ] || limit=0 + out=$(nm_run runs --limit "$limit") || return 1 [ -n "$out" ] || return 0 while IFS= read -r row; do row=$(trim "$row") @@ -398,10 +395,41 @@ nm_runs_status_for_branch() { # if ! nm_coarse_head_matches_worktree "$sha"; then continue fi - printf '%s' "$st" - return 0 + rest=${rest#* } + rest=$(trim "$rest") + selected_date=${rest%% *} + rest=${rest#* } + rest=$(trim "$rest") + selected_time=${rest%% *} + COARSE_STATUS=$st + break + fi + done <<< "$out" + [ -n "$COARSE_STATUS" ] || return 0 + while IFS= read -r row; do + row=$(trim "$row") + [ -n "$row" ] || continue + rest=${row#* } + rest=$(trim "$rest") + br=${rest%% *} + rest=${rest#* } + rest=$(trim "$rest") + sha=${rest%% *} + rest=${rest#* } + rest=$(trim "$rest") + date_part=${rest%% *} + rest=${rest#* } + rest=$(trim "$rest") + time_part=${rest%% *} + if [ "$br" = "$branch" ] && [ "$date_part" = "$selected_date" ] && [ "$time_part" = "$selected_time" ] \ + && nm_coarse_head_matches_worktree "$sha"; then + occurrence=$((occurrence + 1)) fi done <<< "$out" + if [[ "$selected_date" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] \ + && [[ "$selected_time" =~ ^[0-9]{2}:[0-9]{2}$ ]] && [ "$occurrence" -gt 0 ]; then + COARSE_START="${selected_date}T${selected_time}#${occurrence}" + fi return 0 } @@ -453,14 +481,72 @@ HAVE_RUN=0 # run-step block below skips the TOON field parsing entirely for this crew. RUN_SOURCE=full COARSE_STATUS="" +COARSE_START="" # Scouts and secondmates never drive a no-mistakes validation of their own # worktree, so skip the lookup for them and read state from pane/log directly. if [ "$KIND" = ship ] && [ -n "$CREW_BRANCH" ] && command -v no-mistakes >/dev/null 2>&1; then - RUN_OUT=$(nm_run axi status) - if [ -n "$RUN_OUT" ]; then + status_ok=0 + if RUN_OUT=$(nm_run axi status); then + status_ok=1 + fi + if [ "$status_ok" -eq 1 ] && [ -n "$RUN_OUT" ]; then run_branch=$(strip_quotes "$(nm_field branch)") if [ -n "$run_branch" ] && [ "$run_branch" = "$CREW_BRANCH" ] && nm_run_head_matches_worktree; then - HAVE_RUN=1 + if [ "$VALIDATION_LANE_MODE" -eq 1 ]; then + initial_run_id=$(strip_quotes "$(nm_field id)") + initial_run_status=$(strip_quotes "$(nm_field status)") + initial_run_outcome=$(strip_quotes "$(nm_field outcome)") + initial_run_head=$(strip_quotes "$(nm_field head)") + if nm_runs_observation_for_branch "$CREW_BRANCH"; then + observed_start=$COARSE_START + if recheck_out=$(nm_run axi status); then + RUN_OUT=$recheck_out + recheck_branch=$(strip_quotes "$(nm_field branch)") + recheck_run_id=$(strip_quotes "$(nm_field id)") + recheck_run_status=$(strip_quotes "$(nm_field status)") + recheck_run_outcome=$(strip_quotes "$(nm_field outcome)") + recheck_run_head=$(strip_quotes "$(nm_field head)") + snapshot_matches=0 + if [ "$recheck_branch" = "$CREW_BRANCH" ] && nm_run_head_matches_worktree; then + if [ -n "$initial_run_id" ] && [ "$recheck_run_id" = "$initial_run_id" ]; then + snapshot_matches=1 + elif [ -z "$initial_run_id" ] && [ -z "$recheck_run_id" ] \ + && [ "$recheck_run_head" = "$initial_run_head" ] \ + && [ "$recheck_run_status" = "$initial_run_status" ] \ + && [ "$recheck_run_outcome" = "$initial_run_outcome" ]; then + snapshot_matches=1 + fi + fi + if [ "$snapshot_matches" -eq 1 ]; then + HAVE_RUN=1 + RUN_ID=$recheck_run_id + RUN_START=$observed_start + if [ -n "$RUN_ID" ]; then + RUN_KIND=full + else + RUN_KIND=unavailable + fi + else + RUN_OUT= + RUN_KIND=unavailable + fi + else + RUN_OUT= + RUN_KIND=unavailable + fi + else + RUN_OUT= + RUN_KIND=unavailable + fi + else + HAVE_RUN=1 + RUN_ID=$(strip_quotes "$(nm_field id)") + if [ -n "$RUN_ID" ]; then + RUN_KIND=full + else + RUN_KIND=unavailable + fi + fi else # The active-or-most-recent run is for another branch, or same branch with # a rewritten/diverged head (the CLI is alive and answered; only the @@ -469,11 +555,31 @@ if [ "$KIND" = ship ] && [ -n "$CREW_BRANCH" ] && command -v no-mistakes >/dev/n # primary call means the CLI itself did not respond, so retrying it # immediately with a second bounded call would just double the wait # for no better answer. - COARSE_STATUS=$(nm_runs_status_for_branch "$CREW_BRANCH") + if nm_runs_observation_for_branch "$CREW_BRANCH"; then + if [ -n "$COARSE_STATUS" ]; then + HAVE_RUN=1 + RUN_SOURCE=coarse + RUN_KIND=coarse + RUN_START=$COARSE_START + else + RUN_KIND=absent + fi + else + RUN_KIND=unavailable + fi + fi + elif [ "$VALIDATION_LANE_MODE" -eq 1 ]; then + if nm_runs_observation_for_branch "$CREW_BRANCH"; then if [ -n "$COARSE_STATUS" ]; then HAVE_RUN=1 RUN_SOURCE=coarse + RUN_KIND=coarse + RUN_START=$COARSE_START + else + RUN_KIND=absent fi + else + RUN_KIND=unavailable fi fi fi @@ -605,9 +711,17 @@ fi pane_readable "$BACKEND_TARGET" || emit unknown none "backend target gone: $BACKEND_TARGET" # Secondmates idle on their own watcher (idle pane = healthy), so the busy -# signature is not meaningful for them; read their state from the status log only. -if [ "$KIND" != secondmate ] && crew_pane_is_busy "$BACKEND_TARGET"; then - emit working pane "harness busy" +# state is not meaningful for them; read their state from the status log only. +# Only an exact busy verdict reports working here, and only an exact idle +# verdict permits the status-log fallback below. Missing, malformed, stale, or +# unverified semantic state remains unknown. +if [ "$KIND" != secondmate ]; then + BUSY_VERDICT=$(crew_busy_verdict "$BACKEND_TARGET") + case "${BUSY_VERDICT%% *}" in + busy) emit working pane "harness busy (${BUSY_VERDICT#* })" ;; + idle) ;; + *) emit unknown pane "harness state unavailable ($BUSY_VERDICT)" ;; + esac fi # Fall back to the status log's last line, but ONLY when its verb maps to a real diff --git a/bin/fm-ff-lib.sh b/bin/fm-ff-lib.sh index 1bdad4cf033..39cb7775c26 100644 --- a/bin/fm-ff-lib.sh +++ b/bin/fm-ff-lib.sh @@ -26,6 +26,8 @@ # shared default branch or any other worktree's checkout. SUB_HOME_MARKER="${SUB_HOME_MARKER:-.fm-secondmate-home}" +# shellcheck source=bin/fm-secondmate-registry-lib.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/fm-secondmate-registry-lib.sh" # shellcheck source=bin/fm-remote-lib.sh disable=SC1091 . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/fm-remote-lib.sh" @@ -236,20 +238,6 @@ dirty_status() { fi } -secondmate_registry_field() { - local reg=$1 id=$2 key=$3 line value - [ -f "$reg" ] || return 1 - line=$(grep -E "^- $id( |$)" "$reg" | tail -1 || true) - [ -n "$line" ] || return 1 - case "$key" in - home) value=$(printf '%s\n' "$line" | sed -n 's/.*(home:[[:space:]]*\([^;)]*\);.*/\1/p' | sed 's/[[:space:]]*$//') ;; - projects) value=$(printf '%s\n' "$line" | sed -n 's/.*; projects:[[:space:]]*\([^;)]*\); added .*/\1/p' | sed 's/[[:space:]]*$//') ;; - *) return 1 ;; - esac - [ -n "$value" ] || return 1 - printf '%s\n' "$value" -} - # List this home's LIVE secondmate direct reports from state/.meta records. # The meta file is the liveness signal; data/secondmates.md is only the fallback # for durable fields such as home= when an older/incomplete meta lacks them. diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 1dee81bb84a..f8598d9ea19 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -839,7 +839,7 @@ BASH | select(startswith("- ")) | (capture("^- (?[^[:space:]]+)")?) as $id | select($id != null) - | (capture("\\(home:[[:space:]]*(?[^;)]*);")?) as $home + | (capture("^.*\\(home:[[:space:]]*(?[^;)]*);[[:space:]]*scope:[[:space:]]*.*;[[:space:]]*projects:[[:space:]]*[^;)]*;[[:space:]]*added[[:space:]]+[0-9]{4}-[0-9]{2}-[0-9]{2}\\)[[:space:]]*$")?) as $home | {id:$id.id,home:($home.home // null),registered:true, registry_error:(if $home == null or ($home.home | length) == 0 then "registry entry has no home" else null end)} ] | group_by(.id) diff --git a/bin/fm-herdr-session-cleanup.sh b/bin/fm-herdr-session-cleanup.sh index 05b6db9f947..2dc4c227941 100755 --- a/bin/fm-herdr-session-cleanup.sh +++ b/bin/fm-herdr-session-cleanup.sh @@ -62,14 +62,6 @@ fm_herdr_cleanup_home_identity() { (cd "$FM_HOME" 2>/dev/null && pwd -P) } -fm_herdr_cleanup_process_argv0() { # - printf '%s' "$1" | jq -er ' - .result.process_info.foreground_processes[0] as $process - | ($process.argv0 // $process.argv[0]) - | select(type == "string" and length > 0) - ' 2>/dev/null -} - fm_herdr_cleanup_journal_matches() { # <session> <home-real> local title=$1 session=$2 home_real=$3 journal id expected journal_home [ -d "$STATE" ] && [ ! -L "$STATE" ] || return 1 @@ -122,46 +114,6 @@ fm_herdr_cleanup_unique_match() { # <title> <session> <home-real> fi } -fm_herdr_cleanup_process_is_idle_shell() { # <session> <pane-id> - local session=$1 pane=$2 info shell_pid foreground_pgid count - local process_pid name argv0 shell_name rows stat ps_bin - info=$(fm_backend_herdr_cli "$session" pane process-info --pane "$pane" 2>/dev/null) || return 1 - printf '%s' "$info" | jq -e --arg pane "$pane" ' - .result.type == "pane_process_info" - and .result.process_info.pane_id == $pane - ' >/dev/null 2>&1 || return 1 - shell_pid=$(printf '%s' "$info" | jq -er \ - '.result.process_info.shell_pid | select(type == "number" and . > 1) | floor' 2>/dev/null) || return 1 - foreground_pgid=$(printf '%s' "$info" | jq -er \ - '.result.process_info.foreground_process_group_id | select(type == "number" and . > 1) | floor' 2>/dev/null) || return 1 - [ "$foreground_pgid" = "$shell_pid" ] || return 1 - count=$(printf '%s' "$info" | jq -er \ - '.result.process_info.foreground_processes | select(type == "array") | length' 2>/dev/null) || return 1 - [ "$count" -eq 1 ] || return 1 - process_pid=$(printf '%s' "$info" | jq -er \ - '.result.process_info.foreground_processes[0].pid | select(type == "number") | floor' 2>/dev/null) || return 1 - [ "$process_pid" = "$shell_pid" ] || return 1 - name=$(printf '%s' "$info" | jq -er \ - '.result.process_info.foreground_processes[0].name | select(type == "string" and length > 0)' 2>/dev/null) || return 1 - argv0=$(fm_herdr_cleanup_process_argv0 "$info") || return 1 - shell_name=${name##*/} - argv0=${argv0#-} - argv0=${argv0##*/} - [ "$argv0" = "$shell_name" ] || return 1 - case "$shell_name" in sh|bash|zsh|dash|ksh|fish) ;; *) return 1 ;; esac - - ps_bin=${FM_HERDR_PS_BIN:-ps} - command -v "$ps_bin" >/dev/null 2>&1 || return 1 - rows=$("$ps_bin" -axo pid=,ppid= 2>/dev/null) || return 1 - printf '%s\n' "$rows" | awk -v shell="$shell_pid" ' - $1 == shell { found++ } - $2 == shell { child++ } - END { exit(found == 1 && child == 0 ? 0 : 1) } - ' || return 1 - stat=$("$ps_bin" -p "$shell_pid" -o stat= 2>/dev/null | tr -d '[:space:]') || return 1 - case "$stat" in S*|I*) ;; *) return 1 ;; esac -} - fm_herdr_cleanup_snapshot_candidate() { # <snapshot> <workspace> <title> <token> <bound-workspace> <bound-tab> <bound-pane> local snapshot=$1 workspace=$2 title=$3 token=$4 local bound_workspace=$5 bound_tab=$6 bound_pane=$7 record @@ -242,7 +194,7 @@ fm_herdr_cleanup_revalidate() { # <session> <workspace> <tab> <pane> <title> <to and .result.panes[0].pane_id == $pane ' >/dev/null 2>&1 || return 1 [ "$(fm_backend_herdr_pane_agent_state "$session" "$pane")" = no-agent ] || return 1 - fm_herdr_cleanup_process_is_idle_shell "$session" "$pane" || return 1 + fm_backend_herdr_pane_idle_shell_pid "$session" "$pane" >/dev/null || return 1 focus=$(fm_backend_herdr_projection_focus_snapshot "$session") || return 1 [ "${focus#*$'\t'}" != "$tab" ] } @@ -296,7 +248,7 @@ fm_herdr_cleanup_one() { # <session> <workspace> <title> <home-real> tab=$FM_HERDR_CLEANUP_TAB pane=$FM_HERDR_CLEANUP_PANE if [ "$(fm_backend_herdr_pane_agent_state "$session" "$pane")" != no-agent ] \ - || ! fm_herdr_cleanup_process_is_idle_shell "$session" "$pane"; then + || ! fm_backend_herdr_pane_idle_shell_pid "$session" "$pane" >/dev/null; then fm_herdr_cleanup_warn "$id preserved because its pane is not a provably idle childless shell" fm_lock_release "$presentation_lock" || true fm_lock_release "$task_lock" || true diff --git a/bin/fm-home-seed.sh b/bin/fm-home-seed.sh index d506b95e4f8..4b5b9815bcc 100755 --- a/bin/fm-home-seed.sh +++ b/bin/fm-home-seed.sh @@ -27,8 +27,9 @@ # to override the registry routing scope. Otherwise the registry summary # and scope are derived from the filled charter brief. # fm-home-seed.sh validate -# Refuse duplicate ids, duplicate homes, and nested or overlapping homes in -# data/secondmates.md. +# Refuse records that operational consumers cannot parse, unavailable or +# unsafe registry files when present, non-absolute or unresolvable homes, +# duplicate ids or homes, and nested or overlapping homes. set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -38,16 +39,14 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" REG="$DATA/secondmates.md" SUB_HOME_MARKER=".fm-secondmate-home" +# shellcheck source=bin/fm-secondmate-registry-lib.sh +. "$SCRIPT_DIR/fm-secondmate-registry-lib.sh" usage() { echo "usage: fm-home-seed.sh <id> <home|-> {<project>...|--no-projects}" >&2 echo " fm-home-seed.sh validate" >&2 } -registry_home_for_line() { - sed -n 's/^[^(]*(home: \([^;)]*\);.*/\1/p' -} - normalize_registry_text() { awk ' { @@ -179,13 +178,15 @@ registry_home_conflict_for_assignment() { local id=$1 home=$2 target line registered_id registered_home registered_key [ -f "$REG" ] || return 1 target=$(resolved_path "$home") - while IFS= read -r line; do + while IFS= read -r line || [ -n "$line" ]; do case "$line" in "- "*) - registered_id=${line#- } - registered_id=${registered_id%% *} - registered_home=$(printf '%s\n' "$line" | registry_home_for_line) - [ -n "$registered_home" ] || continue + if ! secondmate_registry_parse_line "$line"; then + echo "error: malformed secondmate registry entry: $line" >&2 + return 1 + fi + registered_id=$SECONDMATE_REGISTRY_ID + registered_home=$SECONDMATE_REGISTRY_HOME registered_key=$(resolved_path "$registered_home") if [ "$registered_key" = "$target" ]; then [ "$registered_id" = "$id" ] && continue @@ -206,14 +207,16 @@ registry_id_conflict_for_assignment() { local id=$1 home=$2 target line registered_id registered_home registered_key [ -f "$REG" ] || return 1 target=$(resolved_path "$home") - while IFS= read -r line; do + while IFS= read -r line || [ -n "$line" ]; do case "$line" in "- "*) - registered_id=${line#- } - registered_id=${registered_id%% *} + secondmate_registry_parse_line "$line" || { + echo "error: malformed secondmate registry entry: $line" >&2 + return 1 + } + registered_id=$SECONDMATE_REGISTRY_ID [ "$registered_id" = "$id" ] || continue - registered_home=$(printf '%s\n' "$line" | registry_home_for_line) - [ -n "$registered_home" ] || continue + registered_home=$SECONDMATE_REGISTRY_HOME registered_key=$(resolved_path "$registered_home") [ "$registered_key" = "$target" ] && continue printf '%s\n' "$registered_key" @@ -225,76 +228,11 @@ registry_id_conflict_for_assignment() { } validate_registry() { - local tmp line id registered_home home_key duplicate_homes duplicate_ids overlaps - tmp=$(mktemp "${TMPDIR:-/tmp}/fm-firstmates.XXXXXX") - if [ -f "$REG" ]; then - while IFS= read -r line; do - case "$line" in - "- "*) - id=${line#- } - id=${id%% *} - registered_home=$(printf '%s\n' "$line" | registry_home_for_line) - [ -n "$registered_home" ] || continue - home_key=$(resolved_path "$registered_home") - printf '%s\t%s\n' "$home_key" "$id" >> "$tmp" - ;; - esac - done < "$REG" - fi - duplicate_homes=$(awk -F '\t' ' - { - if (($1 in owner) && owner[$1] != $2) { - print $1 ": " owner[$1] ", " $2 - bad=1 - } else { - owner[$1]=$2 - } - } - END { exit bad ? 1 : 0 } - ' "$tmp" 2>/dev/null) || { - rm -f "$tmp" - printf 'error: duplicate secondmate home assignment:\n%s\n' "$duplicate_homes" >&2 - return 1 - } - duplicate_ids=$(awk -F '\t' ' - { - if ($2 in home) { - print $2 ": " home[$2] ", " $1 - bad=1 - } else { - home[$2]=$1 - } - } - END { exit bad ? 1 : 0 } - ' "$tmp" 2>/dev/null) || { - rm -f "$tmp" - printf 'error: duplicate secondmate id assignment:\n%s\n' "$duplicate_ids" >&2 - return 1 - } - overlaps=$(awk -F '\t' ' - function ancestor(a, b) { return a != b && index(b, a "/") == 1 } - { - for (i = 1; i <= count; i++) { - if (ancestor($1, path[i])) { - print $1 " (" $2 ") contains " path[i] " (" id[i] ")" - bad=1 - } else if (ancestor(path[i], $1)) { - print path[i] " (" id[i] ") contains " $1 " (" $2 ")" - bad=1 - } - } - count++ - path[count]=$1 - id[count]=$2 - } - END { exit bad ? 1 : 0 } - ' "$tmp" 2>/dev/null) || { - rm -f "$tmp" - printf 'error: overlapping secondmate home assignment:\n%s\n' "$overlaps" >&2 + [ -e "$REG" ] || [ -L "$REG" ] || return 0 + secondmate_registry_validate_bindings "$REG" resolved_path || { + printf 'error: %s\n' "$SECONDMATE_REGISTRY_ERROR" >&2 return 1 } - rm -f "$tmp" - return 0 } join_projects() { diff --git a/bin/fm-pending-reply-lib.sh b/bin/fm-pending-reply-lib.sh index 5d04b65d673..a45c8378ea2 100755 --- a/bin/fm-pending-reply-lib.sh +++ b/bin/fm-pending-reply-lib.sh @@ -573,6 +573,22 @@ fm_pending_reply_fallback_idle_eligible() { # <record-path> [ "$age" -ge "$grace" ] } +# fm_pending_reply_backend_observation: one busy/idle observation of a +# SECONDMATE endpoint, without ever reading its conversation. +# +# Deliberately NOT the semantic busy-state contract (bin/fm-busy-lib.sh). +# That contract covers ordinary task workers, whose turn lifecycle firstmate +# wires at spawn; a secondmate has no such wiring because an idle secondmate +# pane is healthy and it runs no supervised turn sequence of its own. This +# observation exists only to notice a busy-then-idle transition around one +# delivered request, so it is a delivery-confirmation signal in the same +# category as the submit acknowledgement in bin/fm-tmux-lib.sh - never task +# state, and never a source consumers can confuse with semantic state. +# +# It stays harness-scoped (fm_busy_lines_match with the recorded harness, no +# global OR of every vendor signature), so one harness's output cannot make +# another read busy, and a weak rendered idle degrades to `fallback-idle`, +# which the caller accepts as idle only after its grace window. fm_pending_reply_backend_observation() { # <backend> <target> [expected-label] [harness] local backend=$1 target=$2 expected_label=${3-} harness=${4-} native tail40 native=$(fm_backend_busy_state "$backend" "$target" 2>/dev/null || printf 'unknown') diff --git a/bin/fm-public-followup-emit.sh b/bin/fm-public-followup-emit.sh new file mode 100755 index 00000000000..c7510e9b33c --- /dev/null +++ b/bin/fm-public-followup-emit.sh @@ -0,0 +1,260 @@ +#!/usr/bin/env bash +# fm-public-followup-emit.sh - emit ONE structured terminal work result for work +# bound to a public commitment, into the owning home's private event inbox. +# +# WHY THIS EXISTS: a public promise is kept by the home that owns the relay +# consent and the thread binding. The home doing the work only has to report a +# TYPED result. Firstmate must never recover the source home, work id, outcome, +# or deliverables by parsing a free-form "done: ..." status sentence, so this +# script is the structured channel that carries them. +# +# WHAT IT DOES NOT DO: it never posts anything, never reads relay credentials, +# and never resolves a public thread. Outward delivery stays with the owning +# home (bin/fm-public-followup.sh deliver). +# +# Usage: +# fm-public-followup-emit.sh --home <owning-home> \ +# --obligation <obligation-id> --relation <relation-id> \ +# --source-home <main|secondmate:<id>> --work-id <task-id> \ +# --generation <n> --outcome <outcome-type> \ +# [--deliverable <key>=<value>]... \ +# (--outcome-text <text> | --outcome-text-file <path> | --outcome-text -) +# +# Options: +# --home <path> The home that owns the public commitment (the primary +# that took the mention). Must already have a +# registration for --obligation; see +# `fm-public-followup.sh register`. +# --obligation <id> tasks-axi public-followup obligation id. +# --relation <id> The relation_id this work fulfills or contributes to. +# --source-home <id> This worker's stable home identity, exactly as bound: +# "main" or "secondmate:<stable-id>". +# --work-id <id> This worker's exact task id, exactly as bound. +# --generation <n> The bound relation generation (integer >= 1). +# --outcome <type> Typed outcome. tasks-axi owns the vocabulary and +# refuses anything it does not accept; this script only +# checks the token is a safe slug. +# --deliverable k=v Repeatable safe deliverable (for example +# pr_url=https://...). tasks-axi owns which keys a given +# expected-final type permits. +# --outcome-text ... Public-safe outcome sentence, from an argument, a +# file, or stdin ("-"). Collapsed to one line; the +# event builder bounds it by codepoint, so control +# characters cannot survive. +# +# Output: the event id on stdout. Exit 0 on a published or already-present event +# (both are successes: the id is derived, so re-emitting the same terminal result +# is a no-op), 2 on a usage or validation error, 1 on a publication failure. +# +# IDEMPOTENCY: the event id is a digest of the identity tuple (obligation, +# relation, source home, work id, generation, outcome type, deliverables), so a +# retry, a duplicate report, or a rerun after restart resolves to the same file +# and the first published copy wins. Nothing here needs coordination. +# +# SAFETY: the event is published through the shared private-artifact primitive - +# atomic rename into place, single link, mode 0600 (never executable), inside a +# 0700 directory this script refuses to create. The owning home must already have +# registered the obligation, so a home that never opted into the relay can never +# be given public-followup artifacts by a child. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=bin/fm-public-followup-lib.sh +. "$SCRIPT_DIR/fm-public-followup-lib.sh" + +usage() { + cat >&2 <<'EOF' +usage: fm-public-followup-emit.sh --home <owning-home> --obligation <id> --relation <id> + --source-home <main|secondmate:<id>> --work-id <id> --generation <n> + --outcome <type> [--deliverable <key>=<value>]... + (--outcome-text <text> | --outcome-text-file <path> | --outcome-text -) +EOF +} + +# The header comment IS the help text, so the two can never drift apart. +help() { + sed -n '2,/^set -u$/p' "$0" | sed '$d; s/^# \{0,1\}//' +} + +die() { printf 'fm-public-followup-emit: %s\n' "$1" >&2; exit "${2:-2}"; } + +HOME_DIR= +OBLIGATION= +RELATION= +SOURCE_HOME= +WORK_ID= +GENERATION= +OUTCOME= +TEXT_SOURCE= +TEXT_MODE= +DELIVERABLE_KEYS=() +DELIVERABLE_VALUES=() + +case "${1:-}" in + --help|-h) help; exit 0 ;; + '') usage; exit 2 ;; +esac + +while [ "$#" -gt 0 ]; do + case "$1" in + --home) shift; HOME_DIR=${1:-} ;; + --obligation) shift; OBLIGATION=${1:-} ;; + --relation) shift; RELATION=${1:-} ;; + --source-home) shift; SOURCE_HOME=${1:-} ;; + --work-id) shift; WORK_ID=${1:-} ;; + --generation) shift; GENERATION=${1:-} ;; + --outcome) shift; OUTCOME=${1:-} ;; + --outcome-text) shift; TEXT_MODE='inline'; TEXT_SOURCE=${1:-} ;; + --outcome-text-file) shift; TEXT_MODE='file'; TEXT_SOURCE=${1:-} ;; + --deliverable) + shift + case "${1:-}" in + *=*) ;; + *) die "--deliverable needs <key>=<value>, got '${1:-}'" ;; + esac + DELIVERABLE_KEYS+=("${1%%=*}") + DELIVERABLE_VALUES+=("${1#*=}") + ;; + --help|-h) help; exit 0 ;; + *) die "unknown argument '$1'" ;; + esac + shift || true +done + +[ -n "$HOME_DIR" ] || { usage; exit 2; } +[ -n "$OBLIGATION" ] || { usage; exit 2; } +[ -n "$RELATION" ] || { usage; exit 2; } +[ -n "$SOURCE_HOME" ] || { usage; exit 2; } +[ -n "$WORK_ID" ] || { usage; exit 2; } +[ -n "$GENERATION" ] || { usage; exit 2; } +[ -n "$OUTCOME" ] || { usage; exit 2; } +[ -n "$TEXT_MODE" ] || { usage; exit 2; } + +fm_pf_slug_valid "$OBLIGATION" || die "unsafe obligation id: $OBLIGATION" +fm_pf_slug_valid "$RELATION" || die "unsafe relation id: $RELATION" +fm_pf_slug_valid "$WORK_ID" || die "unsafe work id: $WORK_ID" +fm_pf_slug_valid "$OUTCOME" || die "unsafe outcome type: $OUTCOME" +fm_pf_home_id_valid "$SOURCE_HOME" \ + || die "source home must be 'main' or 'secondmate:<stable-id>', got '$SOURCE_HOME'" +case "$GENERATION" in + ''|*[!0-9]*) die "generation must be a positive integer, got '$GENERATION'" ;; +esac +[ "$GENERATION" -ge 1 ] || die "generation must be >= 1, got '$GENERATION'" + +i=0 +while [ "$i" -lt "${#DELIVERABLE_KEYS[@]}" ]; do + key=${DELIVERABLE_KEYS[$i]} + case "$key" in + ''|*[!a-z0-9_]*) die "deliverable key must be lowercase [a-z0-9_], got '$key'" ;; + esac + [ "${#DELIVERABLE_VALUES[$i]}" -le 512 ] \ + || die "deliverable '$key' exceeds 512 characters" + case "${DELIVERABLE_VALUES[$i]}" in + *[[:cntrl:]]*) die "deliverable '$key' must be single-line text with no control characters" ;; + esac + i=$((i + 1)) +done + +# Resolve the owning home to a real absolute directory before composing any path +# under it, so a relative or symlinked argument cannot make the destination +# ambiguous in a later message or write. +case "$HOME_DIR" in + /*) ;; + *) HOME_DIR=$(CDPATH='' cd -- "$HOME_DIR" 2>/dev/null && pwd -P) \ + || die "--home is not a reachable directory: $1" ;; +esac +[ -d "$HOME_DIR" ] && [ ! -L "$HOME_DIR" ] \ + || die "--home must name an existing directory, got '$HOME_DIR'" + +fm_pf_relay_active "$HOME_DIR" || exit 0 +command -v jq >/dev/null 2>&1 || die "jq is required to build a typed terminal event" 1 + +STATE="$HOME_DIR/state" +REGISTRY="$(fm_pf_registry_dir "$STATE")/$OBLIGATION" +if [ ! -f "$REGISTRY" ] || [ -L "$REGISTRY" ]; then + die "home '$HOME_DIR' has no public-followup registration for '$OBLIGATION'; the owning home registers a commitment before its work can report one" 1 +fi + +# The registration is the owning home's own record of what it bound, so checking +# the identity tuple against it catches a mis-briefed worker at the edge with a +# clear message. tasks-axi still re-validates everything at consume time and +# remains the authority; this is a cheap early refusal, not a second gatekeeper. +reg_mismatch() { + local field=$1 expected=$2 got=$3 + [ -z "$expected" ] || [ "$expected" = "$got" ] \ + || die "event $field '$got' does not match this home's registration ('$expected')" +} +reg_mismatch relation "$(fm_pf_registry_get "$STATE" "$OBLIGATION" relation_id)" "$RELATION" +reg_mismatch source-home "$(fm_pf_registry_get "$STATE" "$OBLIGATION" work_home)" "$SOURCE_HOME" +reg_mismatch work-id "$(fm_pf_registry_get "$STATE" "$OBLIGATION" work_id)" "$WORK_ID" +reg_mismatch generation "$(fm_pf_registry_get "$STATE" "$OBLIGATION" generation)" "$GENERATION" + +case "$TEXT_MODE" in + inline) OUTCOME_TEXT=$(printf '%s' "$TEXT_SOURCE" | fm_pf_clean_outcome_text) ;; + file) + if [ "$TEXT_SOURCE" = '-' ]; then + OUTCOME_TEXT=$(fm_pf_clean_outcome_text) + else + [ -f "$TEXT_SOURCE" ] || die "outcome text file not found: $TEXT_SOURCE" + OUTCOME_TEXT=$(fm_pf_clean_outcome_text < "$TEXT_SOURCE") + fi + ;; +esac +[ -n "$OUTCOME_TEXT" ] || die "outcome text is empty once whitespace and control characters are removed" + +# Canonical deliverables object: sorted keys, compact, so the same deliverables +# always hash to the same identity regardless of flag order. +DELIVERABLES_JSON=$( + { + i=0 + while [ "$i" -lt "${#DELIVERABLE_KEYS[@]}" ]; do + printf '%s\n%s\n' "${DELIVERABLE_KEYS[$i]}" "${DELIVERABLE_VALUES[$i]}" + i=$((i + 1)) + done + } | jq -Rsc 'split("\n") | .[:-1] | [range(0; length; 2) as $i | {key: .[$i], value: .[$i+1]}] | from_entries | to_entries | sort_by(.key) | from_entries' +) || die "could not encode deliverables" 1 + +EVENT_ID=$(fm_pf_event_id \ + "$OBLIGATION" "$RELATION" "$SOURCE_HOME" "$WORK_ID" "$GENERATION" "$OUTCOME" \ + "$DELIVERABLES_JSON") || die "sha256 (shasum or sha256sum) is required" 1 +# The derived id becomes a filename, so require the exact digest shape rather +# than trusting whatever the hashing tool printed. +case "$EVENT_ID" in + *[!0-9a-f]*|'') die "could not derive a usable event id" 1 ;; +esac +[ "${#EVENT_ID}" -eq 64 ] || die "could not derive a usable event id" 1 + +# jq bounds the outcome text by codepoint, so a long or non-ASCII sentence is +# capped without ever splitting a multi-byte character. +EVENT_JSON=$(jq -Sc -n \ + --argjson schema_version "$FM_PF_EVENT_SCHEMA_VERSION" \ + --arg event_id "$EVENT_ID" \ + --arg obligation_id "$OBLIGATION" \ + --arg relation_id "$RELATION" \ + --arg work_id "$WORK_ID" \ + --argjson generation "$GENERATION" \ + --arg source_home_id "$SOURCE_HOME" \ + --arg outcome_type "$OUTCOME" \ + --argjson deliverables "$DELIVERABLES_JSON" \ + --arg public_safe_outcome "$OUTCOME_TEXT" \ + --argjson outcome_max "$FM_PF_OUTCOME_TEXT_MAX" \ + --arg occurred_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '{schema_version:$schema_version, event_id:$event_id, obligation_id:$obligation_id, + relation_id:$relation_id, work_id:$work_id, generation:$generation, + source_home_id:$source_home_id, outcome_type:$outcome_type, + deliverables:$deliverables, + public_safe_outcome:($public_safe_outcome[0:$outcome_max]), + occurred_at:$occurred_at, successor:null}') \ + || die "could not build the typed terminal event" 1 + +EVENT_BYTES=$(printf '%s\n' "$EVENT_JSON" | LC_ALL=C wc -c | tr -d ' ') \ + || die "could not measure the typed terminal event" 1 +[ "$EVENT_BYTES" -le "$FM_PF_EVENT_BYTES_MAX" ] \ + || die "typed terminal event exceeds $FM_PF_EVENT_BYTES_MAX bytes" 2 + +printf '%s\n' "$EVENT_JSON" \ + | fmx_private_artifact_publish_stdin_once "$(fm_pf_events_dir "$STATE")" "$EVENT_ID.json" 600 +case $? in + 0|1) printf '%s\n' "$EVENT_ID" ;; + *) die "could not publish the terminal event into $HOME_DIR" 1 ;; +esac diff --git a/bin/fm-public-followup-lib.sh b/bin/fm-public-followup-lib.sh new file mode 100644 index 00000000000..dc7153d53cf --- /dev/null +++ b/bin/fm-public-followup-lib.sh @@ -0,0 +1,247 @@ +# shellcheck shell=bash +# fm-public-followup-lib.sh - shared gating and private-transport helpers for the +# deterministic public-followup consumer. +# +# Firstmate promises a public final reply when a myfirstmate relay mention (X or +# Discord) asks for work. `tasks-axi public-followup` is the sole owner of that +# typed obligation and its state machine; state/x-context/ is the sole owner of +# the private full request context. This library owns only the small Firstmate +# side: the activation gate, the private per-home transport directories, and the +# deterministic terminal-event identity. +# +# Sourced, never executed. No side effects on source (it creates nothing), which +# is what keeps a relay-disabled home free of public-followup artifacts. +# set -u / set -e safe. +# +# GATE ORDER - the acceptance criterion for relay-disabled homes: +# 1. fm_pf_relay_active <home> the authoritative myfirstmate activation +# contract, a non-empty FMX_PAIRING_TOKEN in +# <home>/.env. There is no second flag. When +# <home>/.env is absent this is a single +# [ -f ] test and nothing else runs. +# 2. fm_pf_has_registrations O(1) presence check on the registry created +# / fm_pf_has_events only by the relay path (fm-public-followup.sh +# register). Relay-enabled homes with no +# public commitments stop here, so no +# tasks-axi call and no backlog scan happens. +# +# Private transport layout, all under <home>/state/public-followup (mode 0700, +# created only by `fm-public-followup.sh register`): +# registry/<obligation-id> registration record: the bounded public-safe +# binding (obligation, relation, work ref, +# generation, platform, request id). Presence hint +# and reverse work->obligation index only; the +# obligation itself always remains tasks-axi truth. +# events/<event-id>.json inbound typed terminal events awaiting +# reconciliation, one file per event id. +# consumed/<event-id> idempotency ledger: an accepted event id is never +# replayed, so duplicate emits and restart replay +# are no-ops. +# rejected/<event-id>.json events tasks-axi refused, kept with a +# rejected/<event-id>.reason one-line reason so a refusal is inspectable and +# never retried in a loop. +# surfaced last surfaced pending-event signature, so the +# existing relay poll wakes once per new event set +# instead of every cycle. +# +# Event identity is DERIVED, never random: fm_pf_event_id hashes the canonical +# identity tuple, so re-emitting the same terminal result produces the same +# event id and the same destination path. Idempotency therefore holds across +# retries, restarts, and duplicate child reports without any coordination. +# +# Depends on bin/fm-x-lib.sh for .env reading and the private-artifact +# publication primitives (atomic, single-link, mode-validated, non-executable); +# those remain that file's contract and are not restated here. + +_FM_PF_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd 2>/dev/null)" || _FM_PF_LIB_DIR="." +# shellcheck source=bin/fm-x-lib.sh +. "$_FM_PF_LIB_DIR/fm-x-lib.sh" + +FM_PF_DIRNAME='public-followup' +# Consumed by the sourcing scripts, not by this library. +# shellcheck disable=SC2034 +FM_PF_EVENT_SCHEMA_VERSION=1 +# Bounded so a public-safe outcome line can never carry a raw public message, +# and so one event file stays small enough to read and validate cheaply. +FM_PF_OUTCOME_TEXT_MAX=${FM_PF_OUTCOME_TEXT_MAX:-600} +FM_PF_EVENT_BYTES_MAX=${FM_PF_EVENT_BYTES_MAX:-8192} + +# --- gate 1: the authoritative relay activation contract -------------------- + +# fm_pf_relay_active <home>: 0 when this home has opted into the myfirstmate +# relay, 1 otherwise. Identical contract to bootstrap's X-mode activation - a +# non-empty FMX_PAIRING_TOKEN in <home>/.env - so no second activation flag +# exists to drift. FMX_PAIRING_TOKEN in the environment wins, matching +# fmx_load_config, so a direct client call and this gate agree. +fm_pf_relay_active() { + local home=$1 token + if [ -n "${FMX_PAIRING_TOKEN+x}" ]; then + [ -n "${FMX_PAIRING_TOKEN-}" ] + return $? + fi + [ -f "$home/.env" ] || return 1 + token=$(fmx_env_get FMX_PAIRING_TOKEN "$home/.env") + [ -n "$token" ] +} + +# --- gate 2: O(1) presence checks on relay-path-owned registrations --------- + +fm_pf_root() { printf '%s\n' "$1/$FM_PF_DIRNAME"; } +fm_pf_registry_dir() { printf '%s\n' "$1/$FM_PF_DIRNAME/registry"; } +fm_pf_events_dir() { printf '%s\n' "$1/$FM_PF_DIRNAME/events"; } +fm_pf_consumed_dir() { printf '%s\n' "$1/$FM_PF_DIRNAME/consumed"; } +fm_pf_rejected_dir() { printf '%s\n' "$1/$FM_PF_DIRNAME/rejected"; } + +# fm_pf_dir_has_entry <dir>: 0 when <dir> is a real directory holding at least +# one non-dot entry. Stops at the first hit, so cost does not grow with the +# directory's size. +fm_pf_dir_has_entry() { + local dir=$1 entry + [ -d "$dir" ] && [ ! -L "$dir" ] || return 1 + for entry in "$dir"/*; do + [ -e "$entry" ] || continue + return 0 + done + return 1 +} + +fm_pf_has_registrations() { fm_pf_dir_has_entry "$(fm_pf_registry_dir "$1")"; } +fm_pf_has_events() { fm_pf_dir_has_entry "$(fm_pf_events_dir "$1")"; } + +# fm_pf_active <home> <state>: both gates, in order. The single predicate every +# caller outside the relay path should use before doing any public-followup work. +fm_pf_active() { + fm_pf_relay_active "$1" || return 1 + fm_pf_has_registrations "$2" || fm_pf_has_events "$2" +} + +# --- identifiers ------------------------------------------------------------ + +# fm_pf_slug_valid <value>: obligation ids, relation ids, work ids, and request +# ids all compose filenames. They arrive from tasks-axi, the relay, and child +# homes, so every one is checked against a conservative slug before use. +fm_pf_slug_valid() { + local v=$1 + case "$v" in + ''|.*|*[!A-Za-z0-9._-]*) return 1 ;; + esac + [ "${#v}" -le 128 ] +} + +# fm_pf_home_id_valid <home_id>: tasks-axi accepts "main" or +# "secondmate:<stable-id>" as a work_ref home. Validate the same shape here so a +# malformed source home is refused before it reaches a filename or a CLI call. +fm_pf_home_id_valid() { + local v=$1 + case "$v" in + main) return 0 ;; + secondmate:*) fm_pf_slug_valid "${v#secondmate:}" ;; + *) return 1 ;; + esac +} + +fm_pf_sha256() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 2>/dev/null | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum 2>/dev/null | awk '{print $1}' + else + return 1 + fi +} + +# fm_pf_event_id <obligation> <relation> <source_home> <work_id> <generation> +# <outcome_type> <deliverables-canonical> +# The stable idempotency identity. Derived from the identity tuple only, so the +# same terminal result always yields the same id no matter who emits it or how +# often. Public-safe outcome text is deliberately excluded: rewording the same +# landed outcome must not create a second event. +fm_pf_event_id() { + printf '%s\037%s\037%s\037%s\037%s\037%s\037%s' "$1" "$2" "$3" "$4" "$5" "$6" "$7" \ + | fm_pf_sha256 +} + +# --- bounded public-safe text ---------------------------------------------- + +# fm_pf_clean_outcome_text: read stdin, drop control characters, collapse every +# whitespace run to a single space, and trim. An event line therefore stays +# single-line and a raw pasted public message cannot ride along inside it. +# Deliberately does NOT truncate: a byte-wise cut would split a multi-byte +# character, so length bounding happens where it can count codepoints - jq, at +# the point the typed event is built. +fm_pf_clean_outcome_text() { + LC_ALL=C tr -d '\000-\010\013\014\016-\037\177' \ + | LC_ALL=C tr '\011\012\015' ' ' \ + | LC_ALL=C tr -s ' ' \ + | sed 's/^ //; s/ $//' +} + +# fm_pf_bound_bytes <max>: hard byte cap for text that never becomes JSON, such +# as a quarantined event's one-line refusal reason. +fm_pf_bound_bytes() { + LC_ALL=C cut -b "1-$1" +} + +# --- registry records ------------------------------------------------------- + +# fm_pf_registry_get <state> <obligation-id> <key>: read one key=value line from +# a registration record. Prints nothing and succeeds when absent. +fm_pf_registry_get() { + local state=$1 id=$2 key=$3 file line + fm_pf_slug_valid "$id" || return 1 + file="$(fm_pf_registry_dir "$state")/$id" + [ -f "$file" ] && [ ! -L "$file" ] || return 0 + line=$(grep -E "^${key}=" "$file" 2>/dev/null | tail -n1) || return 0 + printf '%s' "${line#*=}" +} + +# fm_pf_registry_ids <state>: every registered obligation id, one per line. +# The registry only ever holds this home's live public commitments, so this stays +# a bounded listing rather than a backlog scan. +fm_pf_registry_ids() { + local dir entry + dir=$(fm_pf_registry_dir "$1") + [ -d "$dir" ] && [ ! -L "$dir" ] || return 0 + for entry in "$dir"/*; do + [ -f "$entry" ] && [ ! -L "$entry" ] || continue + basename "$entry" + done +} + +# fm_pf_registry_ids_for_work <state> <work_home_id> <work_id>: the obligations +# this home registered against one exact work relation. Used by the completion +# guard so cleanup cannot declare bound work finished while its public promise is +# still open. +fm_pf_registry_ids_for_work() { + local state=$1 home_id=$2 work_id=$3 id + while IFS= read -r id; do + [ -n "$id" ] || continue + [ "$(fm_pf_registry_get "$state" "$id" work_home)" = "$home_id" ] || continue + [ "$(fm_pf_registry_get "$state" "$id" work_id)" = "$work_id" ] || continue + printf '%s\n' "$id" + done <<EOF +$(fm_pf_registry_ids "$state") +EOF +} + +# --- pending-event signature ------------------------------------------------ + +# Consumed by the sourcing scripts, not by this library. +# shellcheck disable=SC2034 +FM_PF_SURFACED_BASENAME=surfaced + +# fm_pf_events_signature <state>: a stable digest of the pending event id set. +# The relay poll compares it against the surfaced record so an unconsumed event +# wakes firstmate once per new event, not once per poll cycle. +fm_pf_events_signature() { + local dir entry names= + dir=$(fm_pf_events_dir "$1") + [ -d "$dir" ] && [ ! -L "$dir" ] || return 1 + for entry in "$dir"/*.json; do + [ -f "$entry" ] && [ ! -L "$entry" ] || continue + names="$names$(basename "$entry") +" + done + [ -n "$names" ] || return 1 + printf '%s' "$names" | LC_ALL=C sort | fm_pf_sha256 +} diff --git a/bin/fm-public-followup.sh b/bin/fm-public-followup.sh new file mode 100755 index 00000000000..aa754d9e646 --- /dev/null +++ b/bin/fm-public-followup.sh @@ -0,0 +1,906 @@ +#!/usr/bin/env bash +# fm-public-followup.sh - the deterministic consumer and delivery owner for +# public commitments made through the myfirstmate relay (X and Discord). +# +# THE PROBLEM THIS SOLVES: firstmate promises a public final reply, routes the +# work out, and then the conversation compacts or the session restarts. Nothing +# in memory survives, so the promise is only kept if reconciling it is a disk +# operation. Every command here reads durable state and nothing else. +# +# OWNERSHIP BOUNDARIES (do not re-implement any of these here): +# tasks-axi public-followup the typed obligation and its state machine. +# state/x-context/ the private full request context (fm-x-lib.sh). +# bin/fm-x-reply.sh posting to the relay, thread splitting, dry run. +# bin/fm-public-followup-lib.sh the activation gate and private transport. +# This script composes them; it never restates their contracts or schemas. +# +# ZERO OVERHEAD FOR HOMES THAT DO NOT USE THE RELAY: every subcommand gates +# first on the authoritative activation contract (a non-empty FMX_PAIRING_TOKEN +# in $FM_HOME/.env). Read-side and cleanup paths then use an O(1) presence check +# for registrations this home actually created. A relay-disabled home therefore +# runs one [ -f ] test before any backlog work: no tasks-axi call, no backlog scan, +# and no file created. Silent read-side commands return without output; commands +# that require an active relay report their configuration error after the same +# gate. A relay-enabled home with no live commitments stops at the second gate +# for the same cost. +# +# Usage: +# fm-public-followup.sh active +# Silent gate probe. Exit 0 when this home has live public-followup work +# worth looking at, 1 otherwise. Safe to call unconditionally. +# +# fm-public-followup.sh register <obligation-id> --relation <relation-id> +# --work-home <main|secondmate:<id>> --work-id <task-id> --generation <n> +# [--platform <x|discord>] [--request <request-id>] +# Record the binding the relay path just created with `tasks-axi +# public-followup add` + `bind-work`. This is the event-driven +# registration: it creates this home's private public-followup directories +# (0700) and the bounded public-safe registration record, which is what +# later makes the presence checks O(1) and lets bound work report a typed +# terminal result. Refuses when the relay is not active for this home. +# +# fm-public-followup.sh brief <obligation-id> +# Print the exact fm-public-followup-emit.sh command line the bound worker +# must run when its work reaches the promised terminal outcome, so the +# binding is copied into a brief instead of hand-assembled. +# +# fm-public-followup.sh consume +# Drain every pending typed terminal event: validate its derived identity, +# skip anything already accepted, apply `tasks-axi public-followup +# work-event`, and quarantine what tasks-axi refuses. Prints one +# "ready <obligation-id> <request-id> <platform>" line per obligation that +# became delivery-ready, and one "rejected <event-id>: <reason>" line per +# refusal. Silent when there is nothing to do. Duplicate events and restart +# replay are no-ops. +# +# fm-public-followup.sh pending +# One bounded public-safe line per unresolved commitment, for the session +# start digest. Prunes registrations whose obligation is already closed. +# Silent when nothing is unresolved. +# +# fm-public-followup.sh deliver <obligation-id> [--text-file <path>] +# Post the final public reply into the ORIGINAL thread and close the +# obligation. Uses the stored platform and opaque context binding, so the +# destination is never guessed. Without --text-file the accepted terminal +# event's bounded public-safe outcome is reused exactly, which keeps the +# common path deterministic. The sequence is begin-delivery with the +# payload hash, post, then record the posted receipt or a typed error. +# A validated receipt also clears any bound legacy X link before the +# registration is removed. +# An already-posted obligation is an idempotent success without another +# post; an obligation left in delivery-posting by a crash is REFUSED +# rather than posted again. +# +# fm-public-followup.sh record-posted <obligation-id> --attempt <n> --chunks <n> +# Close an obligation whose post is known to have landed on exactly +# attempt <n> with exactly <n> messages, without posting anything. This is +# the late-receipt path: use it when a post succeeded but its receipt was +# lost, never to paper over an unknown outcome. +# +# fm-public-followup.sh guard-work <work-home-id> <work-id> +# Exit 3 when this home has an unresolved public commitment bound to that +# exact work, printing one line per blocking obligation. Exit 0 otherwise. +# Cleanup paths call this so bound work is never treated as finished while +# its public promise is still open. +# +# fm-public-followup.sh retire <obligation-id> [--force] +# Drop the registration once its obligation is closed. --force is the +# explicit discard-approved escape hatch for an unresolved or missing +# obligation. +# +# Requires jq and a compatible tasks-axi for registration, reconciliation, +# delivery, cleanup guards, and retirement; `active` and `brief` only inspect +# local state. +# FM_PF_RETRY_BACKOFF_SECS (default 900) sets the next-attempt time recorded with +# a retryable delivery error. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" + +# shellcheck source=bin/fm-public-followup-lib.sh +. "$SCRIPT_DIR/fm-public-followup-lib.sh" +# shellcheck source=bin/fm-secondmate-registry-lib.sh +. "$SCRIPT_DIR/fm-secondmate-registry-lib.sh" + +RETRY_BACKOFF=${FM_PF_RETRY_BACKOFF_SECS:-900} +case "$RETRY_BACKOFF" in ''|*[!0-9]*) RETRY_BACKOFF=900 ;; esac + +usage() { + echo "usage: fm-public-followup.sh <active|register|brief|consume|pending|deliver|record-posted|guard-work|retire> [args]" >&2 +} + +# The header comment IS the help text, so the two can never drift apart. +help() { sed -n '2,/^set -u$/p' "$0" | sed '$d; s/^# \{0,1\}//'; } + +die() { printf 'fm-public-followup: %s\n' "$1" >&2; exit "${2:-2}"; } + +PF_TEMP_FILES=() +pf_cleanup_temp_files() { + [ "${#PF_TEMP_FILES[@]}" -eq 0 ] || rm -f -- "${PF_TEMP_FILES[@]}" +} +trap pf_cleanup_temp_files EXIT + +now_rfc3339() { date -u +%Y-%m-%dT%H:%M:%SZ; } + +# next_attempt_rfc3339: the retry time recorded with a retryable delivery error. +# BSD and GNU date disagree on the flag, so try both and print nothing when +# neither works - the error is still recorded, just without a retry time. +next_attempt_rfc3339() { + local at + at=$(( $(date +%s) + RETRY_BACKOFF )) + date -u -r "$at" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \ + || date -u -d "@$at" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \ + || true +} + +require_tools() { + command -v jq >/dev/null 2>&1 || die "jq is required" 1 + command -v tasks-axi >/dev/null 2>&1 || die "tasks-axi is required" 1 +} + +# Every tasks-axi call runs from the home whose backlog owns the obligation, the +# same convention bin/fm-decision-hold.sh uses for typed backlog state. +tx() { (cd "$FM_HOME" && tasks-axi "$@"); } + +# obligation_json <id>: the complete typed obligation payload on stdout, empty +# when the backlog simply has no such public-followup item, and a non-zero exit +# ONLY when the backlog could not be read at all. Callers depend on that +# distinction to report the right thing, so jq runs without -e here. tasks-axi +# stays the single source of truth; the registration record is never consulted +# for state. +obligation_json() { + local id=$1 out + out=$(tx public-followup list --json 2>/dev/null) || return 1 + [ -n "$out" ] || return 1 + printf '%s' "$out" | jq -c --arg id "$id" \ + '(.public_followups // []) | map(select(.id == $id)) | .[0] // empty' 2>/dev/null \ + || return 1 +} + +pf_field() { printf '%s' "$1" | jq -r "$2 // empty" 2>/dev/null; } + +# --- gates ------------------------------------------------------------------ + +# gate_or_exit: the shared silent gate for every read-side subcommand. Exits 0 +# with no output when this home has no public-followup work, so callers can +# invoke unconditionally without a relay-disabled home paying anything. +gate_or_exit() { + fm_pf_relay_active "$FM_HOME" || exit 0 + fm_pf_has_registrations "$STATE" || fm_pf_has_events "$STATE" || exit 0 +} + +# --- subcommand: active ----------------------------------------------------- + +cmd_active() { + fm_pf_relay_active "$FM_HOME" || exit 1 + fm_pf_has_registrations "$STATE" || fm_pf_has_events "$STATE" || exit 1 + exit 0 +} + +# --- subcommand: register --------------------------------------------------- + +cmd_register() { + local id=${1:-} + local relation='' work_home='' work_id='' generation='' platform='' request='' + [ -n "$id" ] || { usage; exit 2; } + shift + while [ "$#" -gt 0 ]; do + case "$1" in + --relation) shift; relation=${1:-} ;; + --work-home) shift; work_home=${1:-} ;; + --work-id) shift; work_id=${1:-} ;; + --generation) shift; generation=${1:-} ;; + --platform) shift; platform=${1:-} ;; + --request) shift; request=${1:-} ;; + *) die "unknown argument '$1'" ;; + esac + shift || true + done + + fm_pf_relay_active "$FM_HOME" \ + || die "this home has not opted into the myfirstmate relay, so it cannot own a public commitment" 1 + require_tools + + fm_pf_slug_valid "$id" || die "unsafe obligation id: $id" + fm_pf_slug_valid "$relation" || die "unsafe relation id: $relation" + fm_pf_slug_valid "$work_id" || die "unsafe work id: $work_id" + fm_pf_home_id_valid "$work_home" \ + || die "work home must be 'main' or 'secondmate:<stable-id>', got '$work_home'" + case "$generation" in + ''|*[!0-9]*) die "generation must be a positive integer, got '$generation'" ;; + esac + [ "$generation" -ge 1 ] || die "generation must be >= 1" + + local payload + payload=$(obligation_json "$id") \ + || die "could not read the backlog through tasks-axi" 1 + [ -n "$payload" ] \ + || die "no public-followup obligation '$id' in this home's backlog; create it with tasks-axi public-followup add before registering" 1 + + # The relation must already be bound, so a registration can never describe a + # binding tasks-axi does not have. + printf '%s' "$payload" | jq -e --arg r "$relation" --arg h "$work_home" --arg w "$work_id" \ + '(.public_followup.work_relations // []) + | map(select(.relation_id == $r and .work_ref.home_id == $h and .work_ref.task_id == $w)) + | length > 0' >/dev/null 2>&1 \ + || die "obligation '$id' has no bound relation '$relation' for $work_home/$work_id; run tasks-axi public-followup bind-work first" 1 + + [ -n "$platform" ] || platform=$(pf_field "$payload" '.public_followup.request.platform') + [ -n "$request" ] || request=$(pf_field "$payload" '.public_followup.request.request_id') + [ -z "$request" ] || fm_pf_slug_valid "$request" || die "unsafe request id: $request" + + local mkdir_target + for mkdir_target in "$(fm_pf_registry_dir "$STATE")" "$(fm_pf_events_dir "$STATE")" \ + "$(fm_pf_consumed_dir "$STATE")" "$(fm_pf_rejected_dir "$STATE")"; do + fmx_private_artifact_dir_prepare "$mkdir_target" >/dev/null \ + || die "could not prepare $mkdir_target" 1 + done + + printf 'obligation_id=%s\nrelation_id=%s\nwork_home=%s\nwork_id=%s\ngeneration=%s\nplatform=%s\nrequest_id=%s\n' \ + "$id" "$relation" "$work_home" "$work_id" "$generation" "$platform" "$request" \ + | fmx_private_artifact_publish_stdin "$(fm_pf_registry_dir "$STATE")" "$id" 600 \ + || die "could not write the registration record" 1 + + printf 'registered %s %s/%s generation=%s platform=%s\n' \ + "$id" "$work_home" "$work_id" "$generation" "${platform:-unknown}" +} + +# --- subcommand: brief ------------------------------------------------------ + +cmd_brief() { + local id=${1:-} relation work_home work_id generation + [ -n "$id" ] || { usage; exit 2; } + fm_pf_slug_valid "$id" || die "unsafe obligation id: $id" + fm_pf_relay_active "$FM_HOME" || die "the relay is not active for this home" 1 + [ -f "$(fm_pf_registry_dir "$STATE")/$id" ] \ + || die "no registration for '$id' in this home" 1 + + relation=$(fm_pf_registry_get "$STATE" "$id" relation_id) + work_home=$(fm_pf_registry_get "$STATE" "$id" work_home) + work_id=$(fm_pf_registry_get "$STATE" "$id" work_id) + generation=$(fm_pf_registry_get "$STATE" "$id" generation) + + cat <<EOF +When this work reaches its promised terminal outcome, report it as typed data +(never as a sentence for someone to parse) by running exactly: + + $FM_ROOT/bin/fm-public-followup-emit.sh \\ + --home $FM_HOME \\ + --obligation $id \\ + --relation $relation \\ + --source-home $work_home \\ + --work-id $work_id \\ + --generation $generation \\ + --outcome <pr-merged|report-ready|local-main|failed> \\ + --deliverable <key>=<value> \\ + --outcome-text '<one bounded public-safe sentence>' + +Do not post anything publicly yourself and do not look for the public thread: +the home above owns the reply. +EOF +} + +# --- subcommand: consume ---------------------------------------------------- + +# reject_event <file> <event-id> <reason>: quarantine one refused event with an +# inspectable reason so it is never retried in a loop. +reject_event() { + local file=$1 event_id=$2 reason=$3 rejected event_payload + rejected=$(fm_pf_rejected_dir "$STATE") + fmx_private_artifact_dir_prepare "$rejected" >/dev/null \ + || { printf 'rejected %s: %s (quarantine failed; event retained)\n' "$event_id" "$reason"; return 1; } + if ! printf '%s\n' "$reason" \ + | fmx_private_artifact_publish_stdin "$rejected" "$event_id.reason" 600 2>/dev/null; then + printf 'rejected %s: %s (quarantine failed; event retained)\n' "$event_id" "$reason" + return 1 + fi + if ! event_payload=$(cat "$file" 2>/dev/null); then + printf 'rejected %s: %s (quarantine failed; event retained)\n' "$event_id" "$reason" + return 1 + fi + if ! printf '%s' "$event_payload" \ + | fmx_private_artifact_publish_stdin "$rejected" "$event_id.json" 600 2>/dev/null; then + printf 'rejected %s: %s (quarantine failed; event retained)\n' "$event_id" "$reason" + return 1 + fi + if ! rm -f -- "$file" 2>/dev/null; then + printf 'rejected %s: %s (quarantine cleanup failed; event retained)\n' "$event_id" "$reason" + return 1 + fi + printf 'rejected %s: %s\n' "$event_id" "$reason" +} + +cmd_consume() { + gate_or_exit + fm_pf_has_events "$STATE" || exit 0 + require_tools + + local events_dir consumed_dir stderr_file file event_id payload derived out rc reason consume_rc=0 + local obligation delivery request platform + events_dir=$(fm_pf_events_dir "$STATE") + consumed_dir=$(fm_pf_consumed_dir "$STATE") + fmx_private_artifact_dir_prepare "$consumed_dir" >/dev/null \ + || die "could not prepare the consumed-event ledger" 1 + stderr_file=$(mktemp "${TMPDIR:-/tmp}/fm-pf-consume.XXXXXX") \ + || die "could not stage the reconciliation log" 1 + PF_TEMP_FILES+=("$stderr_file") + + for file in "$events_dir"/*.json; do + [ -f "$file" ] && [ ! -L "$file" ] || continue + event_id=$(basename "$file" .json) + + if ! fm_pf_slug_valid "$event_id"; then + printf 'rejected %s: unsafe event filename (event retained)\n' "$event_id" + consume_rc=1 + continue + fi + + # Already accepted on an earlier pass (duplicate emit, or a replay after + # restart): drop the copy without touching the state machine. + if [ -f "$consumed_dir/$event_id" ]; then + rm -f -- "$file" 2>/dev/null || true + continue + fi + + if [ "$(wc -c < "$file" 2>/dev/null || echo 0)" -gt "$FM_PF_EVENT_BYTES_MAX" ]; then + reject_event "$file" "$event_id" "event exceeds $FM_PF_EVENT_BYTES_MAX bytes" || consume_rc=1 + continue + fi + + if ! payload=$(jq -ce . "$file" 2>/dev/null) || [ -z "$payload" ]; then + reject_event "$file" "$event_id" "event is not valid JSON" || consume_rc=1 + continue + fi + + # The filename, the declared event_id, and the identity tuple must all agree. + # A mismatch means the file was hand-edited or built by something other than + # fm-public-followup-emit.sh, so it is refused before tasks-axi sees it. + if [ "$(pf_field "$payload" '.event_id')" != "$event_id" ]; then + reject_event "$file" "$event_id" "declared event_id does not match the filename" || consume_rc=1 + continue + fi + derived=$(fm_pf_event_id \ + "$(pf_field "$payload" '.obligation_id')" \ + "$(pf_field "$payload" '.relation_id')" \ + "$(pf_field "$payload" '.source_home_id')" \ + "$(pf_field "$payload" '.work_id')" \ + "$(pf_field "$payload" '.generation')" \ + "$(pf_field "$payload" '.outcome_type')" \ + "$(printf '%s' "$payload" | jq -Sc '.deliverables // {}' 2>/dev/null)") + if [ -z "$derived" ] || [ "$derived" != "$event_id" ]; then + reject_event "$file" "$event_id" "event id does not match its own identity fields" || consume_rc=1 + continue + fi + + obligation=$(pf_field "$payload" '.obligation_id') + if ! fm_pf_slug_valid "$obligation"; then + reject_event "$file" "$event_id" "unsafe obligation id in event" || consume_rc=1 + continue + fi + + # tasks-axi is the authority on source home, work id, generation, schema, + # outcome, and deliverables. Anything it refuses is quarantined verbatim. + # stderr is captured separately so a warning can never corrupt the JSON that + # the accepted path parses. + if out=$(tx public-followup work-event "$obligation" --event-file "$file" --json 2>"$stderr_file"); then + rc=0 + else + rc=$? + fi + if [ "$rc" -ne 0 ]; then + reason=$( { cat "$stderr_file" 2>/dev/null; printf '%s\n' "$out"; } \ + | grep -v '^[[:space:]]*$' | head -1 | fm_pf_clean_outcome_text | fm_pf_bound_bytes 400) + reject_event "$file" "$event_id" "${reason:-tasks-axi refused the event}" || consume_rc=1 + continue + fi + + if ! printf 'accepted %s\n' "$(now_rfc3339)" \ + | fmx_private_artifact_publish_stdin "$consumed_dir" "$event_id" 600 2>/dev/null; then + printf 'accepted %s: consumed ledger could not be recorded; event retained for reconciliation\n' "$event_id" + consume_rc=1 + continue + fi + if ! rm -f -- "$file" 2>/dev/null; then + printf 'accepted %s: consumed ledger recorded but event could not be removed; event retained for reconciliation\n' "$event_id" + consume_rc=1 + continue + fi + + delivery=$(printf '%s' "$out" | jq -r '.task.public_followup.delivery.state // empty' 2>/dev/null) + if [ "$delivery" = ready ]; then + request=$(printf '%s' "$out" | jq -r '.task.public_followup.request.request_id // empty' 2>/dev/null) + platform=$(printf '%s' "$out" | jq -r '.task.public_followup.request.platform // empty' 2>/dev/null) + printf 'ready %s %s %s\n' "$obligation" "${request:-unknown}" "${platform:-unknown}" + fi + done + + # A fresh event must be able to wake firstmate again, so drop the surfaced + # signature once the inbox has been worked. + rm -f -- "$(fm_pf_root "$STATE")/$FM_PF_SURFACED_BASENAME" 2>/dev/null || true + return "$consume_rc" +} + +# --- subcommand: pending ---------------------------------------------------- + +cmd_pending() { + gate_or_exit + + local listing id payload delivery task_state summary platform request printed=0 + # An unreadable backlog with registrations present is exactly the silence this + # whole path exists to prevent, so say so rather than printing nothing. + if ! command -v jq >/dev/null 2>&1 || ! command -v tasks-axi >/dev/null 2>&1 \ + || ! listing=$(tx public-followup list --json 2>/dev/null) || [ -z "$listing" ] \ + || ! printf '%s' "$listing" | jq -e ' + type == "object" + and (.public_followups | type == "array") + and all(.public_followups[]; + type == "object" + and (.id | type == "string") + and (.public_followup | type == "object") + and (.state | type == "string")) + ' >/dev/null 2>&1; then + if fm_pf_has_registrations "$STATE"; then + printf 'cannot read this home'\''s public commitments through tasks-axi; %s registration(s) are still recorded under state/%s/registry\n' \ + "$(fm_pf_registry_ids "$STATE" | grep -c . || true)" "$FM_PF_DIRNAME" + printed=1 + fi + if fm_pf_has_events "$STATE"; then + printf 'unconsumed terminal results are waiting; run %s/bin/fm-public-followup.sh consume\n' "$FM_ROOT" + printed=1 + fi + [ "$printed" -eq 1 ] || exit 0 + return 0 + fi + + while IFS= read -r id; do + [ -n "$id" ] || continue + payload=$(printf '%s' "$listing" | jq -ce --arg id "$id" \ + '(.public_followups // []) | map(select(.id == $id)) | .[0] // empty' 2>/dev/null) + if [ -z "$payload" ]; then + # The obligation is gone from the backlog (pruned after Done): the + # registration is stale bookkeeping, not evidence, so drop it. + if ! clear_public_followup_link "$id"; then + printf 'cannot clear the legacy X link for closed public commitment %s; registration retained for reconciliation\n' "$id" + printed=1 + continue + fi + rm -f -- "$(fm_pf_registry_dir "$STATE")/$id" 2>/dev/null || true + continue + fi + delivery=$(pf_field "$payload" '.public_followup.delivery.state') + task_state=$(pf_field "$payload" '.state') + if [ "$task_state" = 'done' ] || [ "$delivery" = 'posted' ] || [ "$delivery" = 'waived' ]; then + if ! clear_public_followup_link "$id"; then + printf 'cannot clear the legacy X link for closed public commitment %s; registration retained for reconciliation\n' "$id" + printed=1 + continue + fi + rm -f -- "$(fm_pf_registry_dir "$STATE")/$id" 2>/dev/null || true + continue + fi + summary=$(pf_field "$payload" '.public_followup.request.public_safe_summary' | fm_pf_clean_outcome_text) + platform=$(pf_field "$payload" '.public_followup.request.platform') + request=$(pf_field "$payload" '.public_followup.request.request_id') + printf 'unresolved %s state=%s platform=%s request=%s summary=%s\n' \ + "$id" "${delivery:-unknown}" "${platform:-unknown}" "${request:-unknown}" "$summary" + printed=1 + done <<EOF +$(fm_pf_registry_ids "$STATE") +EOF + + # Events that arrived while no agent was present are actionable on their own, + # so surface them even when every registration currently looks settled. + if fm_pf_has_events "$STATE"; then + printf 'unconsumed terminal results are waiting; run %s/bin/fm-public-followup.sh consume\n' "$FM_ROOT" + printed=1 + fi + [ "$printed" -eq 1 ] || exit 0 +} + +# --- subcommand: deliver ---------------------------------------------------- + +public_followup_registration_valid() { + local id=$1 file relation work_home work_id generation + file="$(fm_pf_registry_dir "$STATE")/$id" + [ -f "$file" ] && [ ! -L "$file" ] || return 1 + relation=$(fm_pf_registry_get "$STATE" "$id" relation_id) + work_home=$(fm_pf_registry_get "$STATE" "$id" work_home) + work_id=$(fm_pf_registry_get "$STATE" "$id" work_id) + generation=$(fm_pf_registry_get "$STATE" "$id" generation) + [ -n "$relation" ] && [ -n "$work_id" ] || return 1 + fm_pf_home_id_valid "$work_home" || return 1 + fm_pf_slug_valid "$work_id" || return 1 + case "$generation" in ''|*[!0-9]*) return 1 ;; esac +} + +public_followup_secondmate_home() { + local id=$1 meta home marker + fm_pf_home_id_valid "secondmate:$id" || return 1 + meta="$STATE/$id.meta" + home=$(fmx_meta_get "$meta" home) + if [ -z "$home" ] && [ -f "$DATA/secondmates.md" ] && [ ! -L "$DATA/secondmates.md" ]; then + home=$(secondmate_registry_field "$DATA/secondmates.md" "$id" home || true) + fi + [ -n "$home" ] || return 1 + case "$home" in /*) ;; *) return 1 ;; esac + home=$(CDPATH='' cd -- "$home" 2>/dev/null && pwd -P) || return 1 + [ -f "$home/.fm-secondmate-home" ] && [ ! -L "$home/.fm-secondmate-home" ] || return 1 + marker=$(sed -n '1p' "$home/.fm-secondmate-home" 2>/dev/null) + [ "$marker" = "$id" ] || return 1 + printf '%s\n' "$home" +} + +clear_public_followup_link() { + local id=$1 work_home work_id home state + public_followup_registration_valid "$id" || return 1 + work_home=$(fm_pf_registry_get "$STATE" "$id" work_home) + work_id=$(fm_pf_registry_get "$STATE" "$id" work_id) + [ -n "$work_home" ] && [ -n "$work_id" ] || return 1 + case "$work_home" in + main) + home=$FM_HOME + state=$STATE + ;; + secondmate:*) + home=$(public_followup_secondmate_home "${work_home#secondmate:}") || return 1 + state="$home/state" + ;; + *) return 1 ;; + esac + FM_HOME="$home" FM_STATE_OVERRIDE="$state" FM_ROOT_OVERRIDE="$FM_ROOT" \ + "$FM_ROOT/bin/fm-x-followup.sh" --clear "$work_id" >/dev/null +} + +public_followup_legacy_link_status() { + local payload=$1 relations work_home work_id home meta + if ! printf '%s' "$payload" | jq -e ' + (.public_followup.work_relations | type == "array") + and all(.public_followup.work_relations[]; + (.work_ref.home_id | type == "string") + and (.work_ref.task_id | type == "string") + ) + ' >/dev/null 2>&1; then + return 2 + fi + relations=$(printf '%s' "$payload" | jq -r ' + .public_followup.work_relations[] + | [.work_ref.home_id, .work_ref.task_id] + | @tsv + ' 2>/dev/null) || return 2 + [ -n "$relations" ] || return 2 + while IFS=$'\t' read -r work_home work_id; do + [ -n "$work_home" ] && [ -n "$work_id" ] || return 2 + case "$work_home" in + main) home=$FM_HOME ;; + secondmate:*) home=$(public_followup_secondmate_home "${work_home#secondmate:}") || return 2 ;; + *) return 2 ;; + esac + meta="$home/state/$work_id.meta" + [ -e "$meta" ] || continue + [ -f "$meta" ] && [ ! -L "$meta" ] || return 2 + [ -n "$(fmx_meta_get "$meta" x_request)" ] && return 0 + done <<EOF +$relations +EOF + return 1 +} + +record_error() { + local id=$1 attempt=$2 state=$3 code=$4 next=$5 tmp rc + tmp=$(mktemp "${TMPDIR:-/tmp}/fm-pf-error.XXXXXX") || return 1 + if [ -n "$next" ]; then + jq -n --argjson a "$attempt" --arg s "$state" --arg c "$code" \ + --arg o "$(now_rfc3339)" --arg n "$next" \ + '{state:$s, attempt_count:$a, error_code:$c, occurred_at:$o, next_attempt_at:$n}' > "$tmp" + else + jq -n --argjson a "$attempt" --arg s "$state" --arg c "$code" --arg o "$(now_rfc3339)" \ + '{state:$s, attempt_count:$a, error_code:$c, occurred_at:$o}' > "$tmp" + fi + tx public-followup record-error "$id" --error-file "$tmp" >/dev/null 2>&1 + rc=$? + rm -f -- "$tmp" + return "$rc" +} + +record_posted() { + local id=$1 attempt=$2 request=$3 platform=$4 chunks=$5 tmp rc + tmp=$(mktemp "${TMPDIR:-/tmp}/fm-pf-receipt.XXXXXX") || return 1 + jq -n --argjson a "$attempt" --arg r "$request" --arg p "$platform" \ + --argjson c "$chunks" --arg t "$(now_rfc3339)" \ + '{state:"posted", request_id:$r, platform:$p, attempt_count:$a, + total_chunks:$c, posted_chunks:$c, posted_at:$t}' > "$tmp" + tx public-followup record-delivery "$id" --receipt-file "$tmp" >/dev/null 2>&1 + rc=$? + rm -f -- "$tmp" + return "$rc" +} + +cmd_deliver() { + local id=${1:-} text_file= + [ -n "$id" ] || { usage; exit 2; } + shift + while [ "$#" -gt 0 ]; do + case "$1" in + --text-file) shift; text_file=${1:-} ;; + *) die "unknown argument '$1'" ;; + esac + shift || true + done + + fm_pf_slug_valid "$id" || die "unsafe obligation id: $id" + fm_pf_relay_active "$FM_HOME" \ + || die "this home has not opted into the myfirstmate relay, so it cannot post a public reply" 1 + require_tools + + local payload delivery attempt request platform text tmp_text hash chunks rc receipt receipt_fields receipt_dry_run link_status + payload=$(obligation_json "$id") || die "could not read the backlog through tasks-axi" 1 + [ -n "$payload" ] || die "no public-followup obligation '$id' in this home's backlog" 1 + + delivery=$(pf_field "$payload" '.public_followup.delivery.state') + request=$(pf_field "$payload" '.public_followup.request.request_id') + platform=$(pf_field "$payload" '.public_followup.request.platform') + attempt=$(pf_field "$payload" '.public_followup.delivery.attempt_count') + case "$attempt" in ''|*[!0-9]*) attempt=0 ;; esac + + case "$delivery" in + posted|waived) + if public_followup_registration_valid "$id"; then + if ! clear_public_followup_link "$id"; then + die "obligation '$id' is already $delivery, but its legacy X link could not be cleared; the registration was retained for reconciliation" 1 + fi + else + link_status=1 + public_followup_legacy_link_status "$payload" || link_status=$? + case "$link_status" in + 0) die "obligation '$id' is already $delivery, but its legacy X link cannot be cleared without a valid registration; reconcile it before any later terminal follow-up" 1 ;; + 1) ;; + *) die "obligation '$id' is already $delivery, but its registration is missing or invalid and the legacy X link cannot be verified; reconcile it before any later terminal follow-up" 1 ;; + esac + fi + rm -f -- "$(fm_pf_registry_dir "$STATE")/$id" 2>/dev/null || true + printf 'already delivered %s state=%s\n' "$id" "$delivery" + return 0 + ;; + ready|retry-due|context-blocked|unknown|partial) + public_followup_registration_valid "$id" \ + || die "public-followup registration for '$id' is missing or invalid; reconcile it before delivery so any legacy X link can be cleared" 1 + ;; + delivery-posting) + die "obligation '$id' is mid-delivery on attempt $attempt: a previous post was started and its outcome was never recorded. Confirm whether that post landed, then close it with 'record-posted $id --attempt $attempt --chunks <exact-count>' or reopen it for retry. Posting again here could duplicate the public reply." 1 + ;; + pending-work) + die "obligation '$id' is still waiting on its bound work; nothing to deliver yet" 1 + ;; + *) + die "obligation '$id' is in delivery state '${delivery:-unknown}', which is not deliverable" 1 + ;; + esac + + [ -n "$request" ] || die "obligation '$id' has no relay request id; its thread binding is unusable" 1 + + if [ -n "$text_file" ]; then + [ -f "$text_file" ] || die "reply text file not found: $text_file" + text=$(cat "$text_file") + else + # Deterministic default: reuse the accepted terminal event's bounded + # public-safe outcome exactly rather than paraphrasing a landed result. + text=$(printf '%s' "$payload" | jq -r ' + [(.public_followup.work_relations // [])[] + | (.accepted_events // [])[] + | .public_safe_outcome // empty] | last // empty' 2>/dev/null) + [ -n "$text" ] \ + || die "obligation '$id' carries no accepted public-safe outcome to reuse; pass --text-file with the reply you composed" 1 + fi + [ -n "$text" ] || die "the reply text is empty" 2 + + tmp_text=$(mktemp "${TMPDIR:-/tmp}/fm-pf-text.XXXXXX") || die "could not stage the reply text" 1 + PF_TEMP_FILES+=("$tmp_text") + receipt=$(mktemp "${TMPDIR:-/tmp}/fm-pf-postreceipt.XXXXXX") || die "could not stage the post receipt" 1 + PF_TEMP_FILES+=("$receipt") + printf '%s' "$text" > "$tmp_text" + + hash=$(fm_pf_sha256 < "$tmp_text") || die "sha256 (shasum or sha256sum) is required" 1 + [ -n "$hash" ] || die "could not hash the reply payload" 1 + + # begin-delivery is what makes a retry safe: it pins the attempt and the exact + # payload before anything leaves the machine. The attempt is read back rather + # than assumed, because every later receipt or error must name it exactly. + local begun + begun=$(tx public-followup begin-delivery "$id" --payload-hash "$hash" --json 2>/dev/null) \ + || die "tasks-axi refused to begin delivery for '$id'" 1 + attempt=$(printf '%s' "$begun" | jq -r '.task.public_followup.delivery.attempt_count // empty' 2>/dev/null) + case "$attempt" in + ''|*[!0-9]*) die "could not read the delivery attempt for '$id' after beginning it; nothing was posted" 1 ;; + esac + + rc=0 + FMX_REPLY_PLATFORM="$platform" FM_HOME="$FM_HOME" \ + "$FM_ROOT/bin/fm-x-reply.sh" "$request" --followup --receipt-file "$receipt" \ + --text-file "$tmp_text" >/dev/null || rc=$? + + if [ "$rc" -eq 0 ]; then + receipt_fields=$(jq -er --arg request "$request" ' + if type != "object" or .request_id != $request or .endpoint != "followup" + or (.chunks | type) != "number" or (.chunks < 1) or (.chunks != (.chunks | floor)) + or (.dry_run | type) != "boolean" then error("invalid receipt") + else [(.chunks | tostring), (.dry_run | tostring)] | @tsv end + ' "$receipt" 2>/dev/null) \ + || die "the public reply for '$id' POSTED but its receipt is missing or invalid; inspect the relay and close it with 'record-posted $id --attempt $attempt --chunks <exact-count>' before any retry" 1 + IFS=$'\t' read -r chunks receipt_dry_run <<EOF +$receipt_fields +EOF + if [ "$receipt_dry_run" = true ]; then + if ! record_error "$id" "$attempt" retry-due dry_run_no_post "$(next_attempt_rfc3339)"; then + die "dry-run for '$id' did not post and its retryable state could not be recorded; the obligation remains mid-delivery and needs explicit reconciliation before retry" 1 + fi + die "dry-run for '$id' did not post; recorded as retryable and left the obligation open" 1 + fi + if record_posted "$id" "$attempt" "$request" "$platform" "$chunks"; then + if ! clear_public_followup_link "$id"; then + die "the public reply for '$id' POSTED and its receipt was recorded, but its legacy X link could not be cleared; the registration was retained for reconciliation" 1 + fi + rm -f -- "$(fm_pf_registry_dir "$STATE")/$id" 2>/dev/null || true + printf 'delivered %s request=%s platform=%s chunks=%s\n' "$id" "$request" "$platform" "$chunks" + return 0 + fi + die "the public reply for '$id' POSTED but its receipt could not be recorded; close it with 'record-posted $id --attempt $attempt --chunks <exact-count>' before any retry, or the thread will get a second reply" 1 + fi + + case "$rc" in + 8) if ! record_error "$id" "$attempt" context-blocked reply_context_unresolved ""; then + die "the public reply for '$id' was not posted, and its held state could not be recorded; the obligation remains mid-delivery and needs explicit reconciliation before retry" 1 + fi + die "held '$id': the original thread's platform or size budget could not be resolved, so nothing was posted. Retry once the request context is recoverable." 1 ;; + 9) if ! record_error "$id" "$attempt" expired-action-required followup_binding_exhausted ""; then + die "the relay rejected '$id', and its expired state could not be recorded; the obligation remains mid-delivery and needs explicit reconciliation before retry" 1 + fi + die "the relay no longer accepts a follow-up for '$id' (window or cap exhausted); nothing was posted and this needs a captain decision" 1 ;; + *) if ! record_error "$id" "$attempt" retry-due relay_post_failed "$(next_attempt_rfc3339)"; then + die "posting the public reply for '$id' failed, and its retryable state could not be recorded; the obligation remains mid-delivery and needs explicit reconciliation before retry" 1 + fi + die "posting the public reply for '$id' failed (exit $rc); recorded as retryable, nothing was delivered" 1 ;; + esac +} + +# --- subcommand: record-posted --------------------------------------------- + +cmd_record_posted() { + local id=${1:-} attempt='' chunks='' + [ -n "$id" ] || { usage; exit 2; } + shift + while [ "$#" -gt 0 ]; do + case "$1" in + --attempt) shift; attempt=${1:-} ;; + --chunks) shift; chunks=${1:-} ;; + *) die "unknown argument '$1'" ;; + esac + shift || true + done + fm_pf_slug_valid "$id" || die "unsafe obligation id: $id" + case "$attempt" in ''|*[!0-9]*) die "--attempt <n> is required and must be an integer" ;; esac + case "$chunks" in ''|*[!0-9]*) die "--chunks <n> is required and must be a positive integer" ;; esac + [ "$chunks" -ge 1 ] 2>/dev/null || die "--chunks <n> is required and must be a positive integer" + fm_pf_relay_active "$FM_HOME" || die "the relay is not active for this home" 1 + public_followup_registration_valid "$id" \ + || die "public-followup registration for '$id' is missing or invalid; reconcile it before recording a receipt so any legacy X link can be cleared" 1 + require_tools + + local payload request platform + payload=$(obligation_json "$id") || die "could not read the backlog through tasks-axi" 1 + [ -n "$payload" ] || die "no public-followup obligation '$id' in this home's backlog" 1 + request=$(pf_field "$payload" '.public_followup.request.request_id') + platform=$(pf_field "$payload" '.public_followup.request.platform') + + record_posted "$id" "$attempt" "$request" "$platform" "$chunks" \ + || die "tasks-axi refused the receipt for '$id' attempt $attempt; the recorded attempt must match exactly" 1 + if ! clear_public_followup_link "$id"; then + die "the receipt for '$id' was recorded, but its legacy X link could not be cleared; the registration was retained for reconciliation" 1 + fi + rm -f -- "$(fm_pf_registry_dir "$STATE")/$id" 2>/dev/null || true + printf 'recorded %s attempt=%s request=%s\n' "$id" "$attempt" "$request" +} + +# --- subcommand: guard-work ------------------------------------------------- + +cmd_guard_work() { + local work_home=${1:-} work_id=${2:-} bound id payload delivery task_state blocked=0 + [ -n "$work_home" ] && [ -n "$work_id" ] || { usage; exit 2; } + fm_pf_relay_active "$FM_HOME" || exit 0 + fm_pf_has_registrations "$STATE" || exit 0 + + # Reading the registration records needs no tools, so establish whether this + # work is bound to any commitment before deciding anything else. + bound=$(fm_pf_registry_ids_for_work "$STATE" "$work_home" "$work_id") + [ -n "$bound" ] || exit 0 + + # From here the work IS bound to a public promise, so an unreadable state is a + # blocking answer, not a pass: cleanup must never proceed on a guess. + if ! command -v jq >/dev/null 2>&1 || ! command -v tasks-axi >/dev/null 2>&1; then + printf 'cannot verify the public commitments bound to %s/%s: jq and tasks-axi are required\n' \ + "$work_home" "$work_id" + exit 3 + fi + + while IFS= read -r id; do + [ -n "$id" ] || continue + if ! payload=$(obligation_json "$id"); then + printf 'cannot read the state of public commitment %s for %s/%s\n' "$id" "$work_home" "$work_id" + blocked=1 + continue + fi + # Gone from the backlog entirely (pruned after Done): nothing left to owe. + [ -n "$payload" ] || continue + delivery=$(pf_field "$payload" '.public_followup.delivery.state') + task_state=$(pf_field "$payload" '.state') + case "$task_state:$delivery" in + done:*|*:posted|*:waived) continue ;; + esac + printf 'public commitment %s is still %s for %s/%s\n' "$id" "${delivery:-unknown}" "$work_home" "$work_id" + blocked=1 + done <<EOF +$bound +EOF + [ "$blocked" -eq 0 ] || exit 3 +} + +# --- subcommand: retire ----------------------------------------------------- + +cmd_retire() { + local id=${1:-} force=0 payload delivery task_state + [ -n "$id" ] || { usage; exit 2; } + shift + while [ "$#" -gt 0 ]; do + case "$1" in + --force) force=1 ;; + *) die "unknown argument '$1'" ;; + esac + shift || true + done + fm_pf_slug_valid "$id" || die "unsafe obligation id: $id" + fm_pf_relay_active "$FM_HOME" || exit 0 + require_tools + + payload=$(obligation_json "$id") || die "could not read the backlog through tasks-axi" 1 + if [ -n "$payload" ]; then + delivery=$(pf_field "$payload" '.public_followup.delivery.state') + task_state=$(pf_field "$payload" '.state') + case "$task_state:$delivery" in + done:*|*:posted|*:waived) ;; + *) + [ "$force" -eq 1 ] \ + || die "obligation '$id' is still ${delivery:-unresolved}; retiring its registration now would hide an open public promise. Deliver it, waive it, or pass --force." 1 + ;; + esac + fi + if ! clear_public_followup_link "$id"; then + die "could not clear the legacy X link for '$id'; its registration was retained for reconciliation" 1 + fi + rm -f -- "$(fm_pf_registry_dir "$STATE")/$id" 2>/dev/null || true + printf 'retired %s\n' "$id" +} + +# --- dispatch --------------------------------------------------------------- + +CMD=${1:-} +case "$CMD" in + --help|-h|help) help; exit 0 ;; + '') usage; exit 2 ;; +esac +shift + +case "$CMD" in + active) cmd_active "$@" ;; + register) cmd_register "$@" ;; + brief) cmd_brief "$@" ;; + consume) cmd_consume "$@" ;; + pending) cmd_pending "$@" ;; + deliver) cmd_deliver "$@" ;; + record-posted) cmd_record_posted "$@" ;; + guard-work) cmd_guard_work "$@" ;; + retire) cmd_retire "$@" ;; + *) usage; exit 2 ;; +esac diff --git a/bin/fm-quota-axi-lib.sh b/bin/fm-quota-axi-lib.sh new file mode 100644 index 00000000000..441c9ce2c92 --- /dev/null +++ b/bin/fm-quota-axi-lib.sh @@ -0,0 +1,52 @@ +# shellcheck shell=bash +# Shared quota-axi compatibility floor for the bootstrap diagnostic. +# Usage: . bin/fm-quota-axi-lib.sh +# +# 0.1.16 is the floor because it is the first build that reports each provider's +# credential sources independently and exposes Grok `state.authStatus`. Without +# those fields a dispatch candidate cannot be checked against the authentication +# surface it actually uses, which is how one harness's expired CLI token used to +# produce a captain-facing sign-out claim for a candidate that never read it. +# +# This file is the single owner of that version number. bin/fm-bootstrap.sh +# turns a failing check into the operator-facing MISSING diagnostic, which is +# what keeps an older build from reaching a dispatch intake at all. + +FM_QUOTA_AXI_MIN=0.1.16 + +fm_quota_axi_compatible() { + local timeout=${1:-} output parts major minor patch extra + local min_major min_minor min_patch min_extra + command -v quota-axi >/dev/null 2>&1 || return 1 + if [ -n "$timeout" ]; then + case "$timeout" in + ''|*[!0-9]*|0) return 1 ;; + esac + if command -v timeout >/dev/null 2>&1; then + output=$(timeout "$timeout" quota-axi --version 2>/dev/null </dev/null) || return 1 + elif command -v gtimeout >/dev/null 2>&1; then + output=$(gtimeout "$timeout" quota-axi --version 2>/dev/null </dev/null) || return 1 + elif command -v perl >/dev/null 2>&1; then + output=$(perl -e 'my $t = shift; my $pid = fork; die "fork failed" unless defined $pid; if (!$pid) { setpgrp(0, 0); exec @ARGV } local $SIG{ALRM} = sub { kill "TERM", -$pid; select undef, undef, undef, 0.2; kill "KILL", -$pid; exit 124 }; alarm $t; waitpid $pid, 0; exit($? >> 8)' "$timeout" quota-axi --version 2>/dev/null </dev/null) || return 1 + else + return 1 + fi + else + output=$(quota-axi --version 2>/dev/null </dev/null) || return 1 + fi + parts=$(printf '%s\n' "$output" | + sed -n 's/.*\([0-9][0-9]*\)\.\([0-9][0-9]*\)\.\([0-9][0-9]*\).*/\1 \2 \3/p' | + head -1) + IFS=' ' read -r major minor patch extra <<< "$parts" + # An unparseable version is incompatible, never assumed current, so a + # development or vendored build cannot pass a floor it was never checked against. + [ -n "$major" ] && [ -n "$minor" ] && [ -n "$patch" ] && [ -z "$extra" ] || return 1 + # The floor is compared from FM_QUOTA_AXI_MIN so bumping it needs one edit. + IFS='.' read -r min_major min_minor min_patch min_extra <<< "$FM_QUOTA_AXI_MIN" + [ -n "$min_major" ] && [ -n "$min_minor" ] && [ -n "$min_patch" ] && [ -z "$min_extra" ] || return 1 + [ "$major" -gt "$min_major" ] && return 0 + [ "$major" -eq "$min_major" ] || return 1 + [ "$minor" -gt "$min_minor" ] && return 0 + [ "$minor" -eq "$min_minor" ] || return 1 + [ "$patch" -ge "$min_patch" ] +} diff --git a/bin/fm-secondmate-registry-lib.sh b/bin/fm-secondmate-registry-lib.sh new file mode 100644 index 00000000000..674df77a6ab --- /dev/null +++ b/bin/fm-secondmate-registry-lib.sh @@ -0,0 +1,211 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2034 # parsed fields are output globals for sourcing callers. +# Shared parser for data/secondmates.md records. +# +# A generated record ends with an explicit structured suffix: +# (home: ...; scope: ...; projects: ...; added YYYY-MM-DD) +# Summary text and scope text are natural language and may contain parentheses +# and semicolons, so field boundaries are anchored to the suffix markers rather +# than to the first incidental punctuation. + +SECONDMATE_REGISTRY_ID= +SECONDMATE_REGISTRY_SUMMARY= +SECONDMATE_REGISTRY_HOME= +SECONDMATE_REGISTRY_SCOPE= +SECONDMATE_REGISTRY_PROJECTS= +SECONDMATE_REGISTRY_ADDED= +SECONDMATE_REGISTRY_LINE= +SECONDMATE_REGISTRY_MATCH_HOME= +SECONDMATE_REGISTRY_MATCH_HOME_KEY= +SECONDMATE_REGISTRY_MATCH_PROJECTS= +SECONDMATE_REGISTRY_ERROR= + +secondmate_registry_parse_line() { + local line=$1 + local record_re='^- ([A-Za-z0-9._-]+) - (.+) \(home:[[:space:]]*([^;)]*);[[:space:]]*scope:[[:space:]]*(.*);[[:space:]]*projects:[[:space:]]*([^;)]*);[[:space:]]*added[[:space:]]+([0-9]{4}-[0-9]{2}-[0-9]{2})\)[[:space:]]*$' + SECONDMATE_REGISTRY_ID= + SECONDMATE_REGISTRY_SUMMARY= + SECONDMATE_REGISTRY_HOME= + SECONDMATE_REGISTRY_SCOPE= + SECONDMATE_REGISTRY_PROJECTS= + SECONDMATE_REGISTRY_ADDED= + if [[ "$line" =~ $record_re ]]; then + SECONDMATE_REGISTRY_ID=${BASH_REMATCH[1]} + SECONDMATE_REGISTRY_SUMMARY=${BASH_REMATCH[2]} + SECONDMATE_REGISTRY_HOME=${BASH_REMATCH[3]} + SECONDMATE_REGISTRY_SCOPE=${BASH_REMATCH[4]} + SECONDMATE_REGISTRY_PROJECTS=${BASH_REMATCH[5]} + SECONDMATE_REGISTRY_ADDED=${BASH_REMATCH[6]} + else + return 1 + fi + [ -n "$SECONDMATE_REGISTRY_HOME" ] || return 1 + [ -n "$SECONDMATE_REGISTRY_SCOPE" ] || return 1 + return 0 +} + +secondmate_registry_line_for_id() { + local reg=$1 id=$2 line count=0 + case "$id" in ''|*[!A-Za-z0-9._-]*) return 1 ;; esac + [ -f "$reg" ] && [ ! -L "$reg" ] || return 1 + while IFS= read -r line || [ -n "$line" ]; do + [ "$line" = "- $id" ] || case "$line" in "- $id "*) ;; *) continue ;; esac + count=$((count + 1)) + [ "$count" -eq 1 ] || return 1 + SECONDMATE_REGISTRY_LINE=$line + done < "$reg" + [ "$count" -eq 1 ] || return 1 + secondmate_registry_parse_line "$SECONDMATE_REGISTRY_LINE" +} + +secondmate_registry_field() { + local reg=$1 id=$2 key=$3 + secondmate_registry_line_for_id "$reg" "$id" || return 1 + case "$key" in + home) printf '%s\n' "$SECONDMATE_REGISTRY_HOME" ;; + projects) printf '%s\n' "$SECONDMATE_REGISTRY_PROJECTS" ;; + *) return 1 ;; + esac +} + +secondmate_registry_path_key() { + local path=$1 parent base + case "$path" in /*) ;; *) return 1 ;; esac + if [ -d "$path" ]; then + cd "$path" && pwd -P + else + parent=$(dirname "$path") + base=$(basename "$path") + cd "$parent" && printf '%s/%s\n' "$(pwd -P)" "$base" + fi +} + +secondmate_registry_validate_bindings() { + local reg=$1 resolver=$2 expected_id=${3:-} expected_home=${4:-} + local tmp snapshot bindings line id home home_key duplicate_homes duplicate_ids overlaps expected_home_key + SECONDMATE_REGISTRY_MATCH_HOME= + SECONDMATE_REGISTRY_MATCH_HOME_KEY= + SECONDMATE_REGISTRY_MATCH_PROJECTS= + SECONDMATE_REGISTRY_ERROR= + case "$expected_id" in *[!A-Za-z0-9._-]*) SECONDMATE_REGISTRY_ERROR="invalid secondmate id: $expected_id"; return 1 ;; esac + if [ ! -f "$reg" ] || [ -L "$reg" ]; then + SECONDMATE_REGISTRY_ERROR="secondmate registry is unavailable or unsafe: $reg" + return 1 + fi + tmp=$(mktemp -d "${TMPDIR:-/tmp}/fm-secondmate-registry.XXXXXX") || { + SECONDMATE_REGISTRY_ERROR="could not create secondmate registry validation state" + return 1 + } + snapshot="$tmp/registry" + bindings="$tmp/bindings" + if ! cat "$reg" > "$snapshot" 2>/dev/null || ! : > "$bindings"; then + rm -rf -- "$tmp" + SECONDMATE_REGISTRY_ERROR="secondmate registry is unavailable or unsafe: $reg" + return 1 + fi + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + "- "*) + if ! secondmate_registry_parse_line "$line"; then + rm -rf -- "$tmp" + SECONDMATE_REGISTRY_ERROR="malformed secondmate registry entry: $line" + return 1 + fi + id=$SECONDMATE_REGISTRY_ID + home=$SECONDMATE_REGISTRY_HOME + case "$home" in + /*) ;; + *) + rm -rf -- "$tmp" + SECONDMATE_REGISTRY_ERROR="unsafe non-absolute secondmate home for $id: $home" + return 1 + ;; + esac + case "$home" in + *$'\t'*) + rm -rf -- "$tmp" + SECONDMATE_REGISTRY_ERROR="unsafe secondmate home for $id" + return 1 + ;; + esac + home_key=$("$resolver" "$home" 2>/dev/null || true) + if [ -z "$home_key" ]; then + rm -rf -- "$tmp" + SECONDMATE_REGISTRY_ERROR="unresolvable secondmate home for $id: $home" + return 1 + fi + printf '%s\t%s\n' "$home_key" "$id" >> "$bindings" + if [ -n "$expected_id" ] && [ "$id" = "$expected_id" ]; then + SECONDMATE_REGISTRY_MATCH_HOME=$home + SECONDMATE_REGISTRY_MATCH_HOME_KEY=$home_key + SECONDMATE_REGISTRY_MATCH_PROJECTS=$SECONDMATE_REGISTRY_PROJECTS + fi + ;; + esac + done < "$snapshot" + duplicate_homes=$(awk -F '\t' ' + { + if ($1 in owner) { + print $1 ": " owner[$1] ", " $2 + bad=1 + } else { + owner[$1]=$2 + } + } + END { exit bad ? 1 : 0 } + ' "$bindings" 2>/dev/null) || { + rm -rf -- "$tmp" + SECONDMATE_REGISTRY_ERROR="duplicate secondmate home assignment: $duplicate_homes" + return 1 + } + duplicate_ids=$(awk -F '\t' ' + { + if ($2 in home) { + print $2 ": " home[$2] ", " $1 + bad=1 + } else { + home[$2]=$1 + } + } + END { exit bad ? 1 : 0 } + ' "$bindings" 2>/dev/null) || { + rm -rf -- "$tmp" + SECONDMATE_REGISTRY_ERROR="duplicate secondmate id assignment: $duplicate_ids" + return 1 + } + overlaps=$(awk -F '\t' ' + function ancestor(a, b) { return a != b && index(b, a "/") == 1 } + { + for (i = 1; i <= count; i++) { + if (ancestor($1, path[i])) { + print $1 " (" $2 ") contains " path[i] " (" id[i] ")" + bad=1 + } else if (ancestor(path[i], $1)) { + print path[i] " (" id[i] ") contains " $1 " (" $2 ")" + bad=1 + } + } + count++ + path[count]=$1 + id[count]=$2 + } + END { exit bad ? 1 : 0 } + ' "$bindings" 2>/dev/null) || { + rm -rf -- "$tmp" + SECONDMATE_REGISTRY_ERROR="overlapping secondmate home assignment: $overlaps" + return 1 + } + rm -rf -- "$tmp" + if [ -n "$expected_id" ] && [ -z "$SECONDMATE_REGISTRY_MATCH_HOME" ]; then + SECONDMATE_REGISTRY_ERROR="no registry binding for secondmate $expected_id" + return 1 + fi + if [ -n "$expected_home" ]; then + expected_home_key=$("$resolver" "$expected_home" 2>/dev/null || true) + if [ -z "$expected_home_key" ] || [ "$expected_home_key" != "$SECONDMATE_REGISTRY_MATCH_HOME_KEY" ]; then + SECONDMATE_REGISTRY_ERROR="secondmate $expected_id is registered at $SECONDMATE_REGISTRY_MATCH_HOME, not $expected_home" + return 1 + fi + fi + return 0 +} diff --git a/bin/fm-send.sh b/bin/fm-send.sh index dfae6f49e64..6755e56c7f3 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -84,6 +84,26 @@ fm_send_id_from_meta() { # <meta-file> printf '%s' "${base%.meta}" } +fm_send_record_interrupt() { # <key> + local key=$1 id gen + [ "$key" = Escape ] || return 0 + case "$TARGET_HARNESS" in claude*) : ;; *) return 0 ;; esac + [ -n "$TARGET_META" ] || return 0 + id=$(fm_send_id_from_meta "$TARGET_META") + [ -f "$STATE/$id.busy-gen" ] || return 0 + gen=$(fm_meta_get "$TARGET_META" busy_gen) + if [ -n "$gen" ]; then + "$FM_ROOT/bin/fm-busy-event.sh" apply "$STATE" "$id" idle \ + --gen "$gen" --source fm-interrupt --event interrupt + else + "$FM_ROOT/bin/fm-busy-event.sh" apply "$STATE" "$id" idle \ + --current-gen --source fm-interrupt --event interrupt + fi || { + echo "error: key '$key' reached $T, but the Claude interrupt state could not be recorded for $id" >&2 + return 1 + } +} + fm_send_meta_for_key_value() { # <state-dir> <key> <value> local state=$1 key=$2 value=$3 meta got for meta in "$state"/*.meta; do @@ -224,6 +244,7 @@ if [ "${1:-}" = "--key" ]; then echo "error: key '$2' not sent to $T ($TARGET_BACKEND send failed; tried $RESOLUTION_TRIED)" >&2 exit 1 fi + fm_send_record_interrupt "$2" || exit 1 else MESSAGE=$* if [ "$MARK_FROM_FIRSTMATE" = 1 ]; then diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index 632aafcd2eb..6cf4b44057e 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -103,6 +103,8 @@ PRIMARY_HARNESS=$("$SCRIPT_DIR/fm-harness.sh" 2>/dev/null || printf unknown) . "$SCRIPT_DIR/fm-backend.sh" # shellcheck source=bin/fm-tasks-axi-lib.sh . "$SCRIPT_DIR/fm-tasks-axi-lib.sh" +# shellcheck source=bin/fm-public-followup-lib.sh +. "$SCRIPT_DIR/fm-public-followup-lib.sh" STATUS_TAIL=${FM_SESSION_START_STATUS_TAIL:-5} case "$STATUS_TAIL" in ''|*[!0-9]*) STATUS_TAIL=5 ;; esac @@ -394,6 +396,23 @@ else printf 'absent\n' fi +# Public commitments made through the myfirstmate relay. A promise to reply in a +# public thread must survive compaction and restart, so it is surfaced from disk +# here rather than from conversation memory. fm-public-followup-lib.sh owns both +# gates: a home that never opted into the relay runs one [ -f ] test, prints no +# subsection, and never reaches fm-public-followup.sh. +if fm_pf_relay_active "$FM_HOME" \ + && { fm_pf_has_registrations "$STATE" || fm_pf_has_events "$STATE"; }; then + PUBLIC_FOLLOWUP=$("$SCRIPT_DIR/fm-public-followup.sh" pending 2>/dev/null) || PUBLIC_FOLLOWUP= + if [ -n "$PUBLIC_FOLLOWUP" ]; then + subsection "Public commitments awaiting delivery" + printf '%s\n' "$PUBLIC_FOLLOWUP" + printf '\nEach line is a public reply this home still owes. Reconcile terminal results with\n' + printf '%s/bin/fm-public-followup.sh consume, then deliver a ready one with\n' "$FM_ROOT" + printf '%s/bin/fm-public-followup.sh deliver <id>. Load fmx-respond for the procedure.\n' "$FM_ROOT" + fi +fi + # --- 6. closing reminder ----------------------------------------------- section "NEXT STEP" if [ "$READ_ONLY" -eq 1 ]; then diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 1c3a860ae64..439a99b74b9 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -10,10 +10,12 @@ # installed CLIs were verified to support that axis; unsupported axes are omitted # from that harness's launch rather than guessed. # --backend <name> is the explicit runtime session-provider backend for this -# spawn. Without it, the script resolves FM_BACKEND, then config/backend, then -# runtime auto-detection (the runtime firstmate itself is executing inside - -# $TMUX, HERDR_ENV=1, or cmux runtime signals; bin/fm-backend.sh's -# fm_backend_detect, with cmux fallback details in docs/cmux-backend.md), +# exact task only (docs/configuration.md "Runtime backend" owns when that flag +# is authorized). Without it, the script resolves FM_BACKEND, then +# config/backend, then runtime auto-detection from the runtime firstmate's +# environment: $TMUX, HERDR_ENV=1, or cmux runtime signals (via +# bin/fm-backend.sh's fm_backend_detect, with cmux fallback details in +# docs/cmux-backend.md), # then tmux. # Spawn-capable backends are the reference tmux adapter and experimental # herdr, zellij, orca, and cmux. Orca owns both the task worktree and @@ -27,6 +29,15 @@ # A backend spawn refusal (missing dependency, version gate, unauthenticated # socket, or unsupported secondmate mode) is terminal for that selected backend; # callers must surface it instead of silently retrying another backend. +# A herdr crewmate or scout is placed in the exact workspace of the firstmate +# or secondmate process launching it, resolved from that process's own herdr +# pane rather than from a workspace label (herdr enforces no label uniqueness, +# so a label cannot tell two "firstmate" workspaces apart). A claimed parent +# identity that is unreadable, contradictory, stale, or from another herdr +# session stops the spawn before any worker endpoint exists. A launcher +# outside herdr has no workspace to inherit and uses this home's own labeled +# workspace, which must then match exactly one. --secondmate is the deliberate +# exception: it stands up that secondmate home's own workspace. # Herdr additionally supports a default-off presentation-only layout when the # local config/herdr-presentation-spaces flag exists. A clean fresh task first # writes state/<id>.herdr-presentation atomically, then creates a disposable @@ -126,7 +137,10 @@ set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" usage() { - sed -n '2,78p' "$0" | sed 's/^# \{0,1\}//' + # The whole leading comment block, ending at the first line that is not a + # comment. Derived rather than a fixed line range, which silently truncated + # this help mid-sentence every time the header above grew. + sed -n '2,${/^#/!q;p;}' "$0" | sed 's/^# \{0,1\}//' } case "${1:-}" in @@ -170,6 +184,8 @@ SUB_HOME_MARKER=".fm-secondmate-home" . "$SCRIPT_DIR/fm-backend.sh" # shellcheck source=bin/fm-gate-refuse-lib.sh . "$SCRIPT_DIR/fm-gate-refuse-lib.sh" +# shellcheck source=bin/fm-busy-lib.sh +. "$SCRIPT_DIR/fm-busy-lib.sh" # shellcheck source=bin/fm-pr-lib.sh . "$SCRIPT_DIR/fm-pr-lib.sh" # Fail closed before any fleet mutation: a no-mistakes gate agent must never spawn @@ -468,7 +484,7 @@ fi [ -z "$HARNESS_ARG" ] || ARG3=$HARNESS_ARG # The verified launch command per adapter. The knowledge half of each adapter -# (busy signature, exit command, dialogs, quirks) lives in the harness-adapters skill. +# (busy-state source, exit command, dialogs, quirks) lives in the harness-adapters skill. launch_template() { local harness=$1 kind=${2:-ship} # shellcheck disable=SC2016 # single quotes are deliberate: $(cat ...) expands in the crewmate pane, not here @@ -586,18 +602,7 @@ if [ "$KIND" = secondmate ] && [ -z "$ARG3" ]; then fi secondmate_registry_value() { - local id=$1 key=$2 reg line value - reg="$DATA/secondmates.md" - [ -f "$reg" ] || return 1 - line=$(grep -E "^- $id( |$)" "$reg" | tail -1 || true) - [ -n "$line" ] || return 1 - case "$key" in - home) value=$(printf '%s\n' "$line" | sed -n 's/^[^(]*(home: \([^;)]*\);.*/\1/p') ;; - projects) value=$(printf '%s\n' "$line" | sed -n 's/^[^(]*(home: [^;)]*; scope: [^;)]*; projects: \([^;)]*\); added .*/\1/p') ;; - *) return 1 ;; - esac - [ -n "$value" ] || return 1 - printf '%s\n' "$value" + secondmate_registry_field "$DATA/secondmates.md" "$1" "$2" } shell_quote() { @@ -820,6 +825,13 @@ fi if [ "$KIND" = secondmate ]; then [ -n "$FIRSTMATE_HOME" ] || { echo "error: no firstmate home supplied or registered for $ID" >&2; exit 1; } PROJ_ABS=$(validate_firstmate_home_for_spawn "$ID" "$FIRSTMATE_HOME") + if [ -e "$DATA/secondmates.md" ] || [ -L "$DATA/secondmates.md" ]; then + if ! secondmate_registry_validate_bindings "$DATA/secondmates.md" resolve_path "$ID" "$FIRSTMATE_HOME"; then + echo "error: $SECONDMATE_REGISTRY_ERROR" >&2 + exit 1 + fi + SECONDMATE_PROJECTS=$SECONDMATE_REGISTRY_MATCH_PROJECTS + fi WT="$PROJ_ABS" # Local-HEAD sync: before launch, fast-forward this secondmate's worktree to the # PRIMARY checkout's current default-branch commit, so a freshly spawned or @@ -1021,9 +1033,18 @@ case "$BACKEND" in # to PROJ_ABS for just these two calls (bash restores it automatically # after each prefixed simple-command call) so the secondmate's tab lands # in the secondmate's own workspace, not the primary's "firstmate" one. + # + # Placement, separately from labeling: a crewmate/scout belongs in the + # EXACT herdr workspace this launching process is itself running in, which + # only its own herdr pane identity can name (a same-labeled sibling + # workspace must never be adopted). A --secondmate launch is the exception - + # it stands up a DIFFERENT home's own workspace by design - so it asks for + # the per-home container instead of inheriting this launcher's. HERDR_LABEL_HOME=$FM_HOME + HERDR_LAUNCHER_RELATIONSHIP=launcher-home if [ "$KIND" = secondmate ]; then HERDR_LABEL_HOME=$PROJ_ABS + HERDR_LAUNCHER_RELATIONSHIP=other-home fi HERDR_PRESENTATION_JOURNAL=$(fm_backend_herdr_projection_journal_path "$STATE" "$ID") HERDR_PROJECTED=0 @@ -1079,8 +1100,21 @@ case "$BACKEND" in if ! fm_backend_herdr_server_ensure "$HERDR_SES"; then echo "warning: herdr presentation could not ensure its session server; using the ordinary flat layout without projection" >&2 elif spawn_herdr_presentation_order_lock_acquire "$HERDR_SES"; then - HERDR_PARENT_WORKSPACE_ID=$(fm_backend_herdr_projection_parent_workspace_exact \ - "$HERDR_SES" "$HERDR_PARENT_LABEL" 2>/dev/null || true) + # The projected child is placed and bound UNDER this launcher's exact + # parent workspace. Its own herdr pane identity names that workspace + # directly; the label lookup is only the fallback for a launcher with + # no herdr ancestry at all. A claimed-but-broken identity refuses here + # rather than projecting under a guessed parent. + set +e + fm_backend_herdr_launcher_identity "$HERDR_SES" + HERDR_LAUNCHER_STATUS=$? + set -e + case "$HERDR_LAUNCHER_STATUS" in + 0) HERDR_PARENT_WORKSPACE_ID=$FM_BACKEND_HERDR_LAUNCHER_WORKSPACE_ID ;; + 2) HERDR_PARENT_WORKSPACE_ID=$(fm_backend_herdr_projection_parent_workspace_exact \ + "$HERDR_SES" "$HERDR_PARENT_LABEL" 2>/dev/null || true) ;; + *) spawn_herdr_presentation_order_lock_release; exit 1 ;; + esac if [ -z "$HERDR_PARENT_WORKSPACE_ID" ]; then echo "warning: herdr presentation parent is absent or ambiguous; using the ordinary flat layout without projection" >&2 spawn_herdr_presentation_order_lock_release @@ -1108,7 +1142,7 @@ case "$BACKEND" in HERDR_PROJECTION_ABORT_TASK_PANE=$HERDR_PANE_ID HERDR_PROJECTION_ABORT_SEEDED_PANE=$FM_BACKEND_HERDR_PROJECTION_SEEDED_PANE_ID fm_backend_herdr_projection_order_best_effort \ - "$HERDR_SES" "$HERDR_WORKSPACE_ID" "$HERDR_PARENT_LABEL" + "$HERDR_SES" "$HERDR_WORKSPACE_ID" "$HERDR_PARENT_LABEL" "$HERDR_PARENT_WORKSPACE_ID" HERDR_HOME_ID=$(fm_backend_herdr_projection_home_identity "$HERDR_LABEL_HOME" 2>/dev/null || true) if [ -n "$HERDR_HOME_ID" ] \ && fm_backend_herdr_projection_live_binding_matches \ @@ -1130,7 +1164,7 @@ case "$BACKEND" in fi fi if [ "$HERDR_PROJECTED" -ne 1 ]; then - HERDR_CONTAINER_RAW=$(FM_HOME="$HERDR_LABEL_HOME" fm_backend_herdr_container_ensure "$PROJ_ABS") || exit 1 + HERDR_CONTAINER_RAW=$(FM_HOME="$HERDR_LABEL_HOME" fm_backend_herdr_container_ensure "$PROJ_ABS" "$HERDR_LAUNCHER_RELATIONSHIP") || exit 1 # fm_backend_herdr_container_ensure echoes "<session>:<workspace_id>\t<seeded_default_tab_id>" # (the second field empty when this call ADOPTED a pre-existing workspace # rather than creating a fresh one). Split on the guaranteed single tab @@ -1447,42 +1481,158 @@ exclude_path() { grep -qxF "$rel" "$EXCL" 2>/dev/null || echo "$rel" >> "$EXCL" } if [ "$KIND" != secondmate ]; then + # Arm the semantic busy-state contract (bin/fm-busy-lib.sh) for every + # adapter with a verified semantic source. The launch brief sent below IS a + # submitted turn, so the seed record is busy/fm-spawn. The minted gen is + # embedded into each adapter's wiring so an event from a superseded + # incarnation is rejected as stale. Grok stays on its isolated rendered-tail + # fallback and standalone Kimi stays unknown until fm_busy_kimi_verified + # opens, so neither is armed here. + BUSY_GEN= + case "$HARNESS" in + codex*) + if fm_busy_codex_semantic_source; then + echo "error: codex semantic busy-state wiring is not implemented; extend the probe only together with verified wiring" >&2 + exit 1 + fi + ;; + esac + case "$HARNESS" in + claude*|opencode*|pi|pi-signed) + BUSY_GEN=$("$FM_ROOT/bin/fm-busy-event.sh" arm "$STATE_REAL" "$ID") || { + echo "error: failed to arm the busy-state contract for $ID" >&2 + exit 1 + } + ;; + kimi*) + # Standalone Kimi stays unknown until fm_busy_kimi_verified opens on a + # live-verified installed version (bin/fm-busy-lib.sh owns the gate and + # the required evidence). Arming without wiring would seed a busy record + # nothing can ever clear, so the arm waits for the wiring. + if fm_busy_kimi_verified; then + echo "error: kimi semantic busy-state wiring is not implemented; open the gate only together with verified wiring" >&2 + exit 1 + fi + ;; + esac case "$HARNESS" in claude*) + # Semantic busy-state hooks (bin/fm-busy-lib.sh): UserPromptSubmit opens + # a turn; Stop (normal completion), StopFailure (API-error turn end), + # and SessionEnd (process shutdown) all close it, so an abnormal end can + # never leave a stale busy record. Claude fires no hook for a manual + # interrupt, so the firstmate-controlled interruption procedure + # (harness-adapters) records idle/fm-interrupt itself. Stop keeps the + # turn-ended NOTIFICATION touch for the watcher. Every hook command + # tolerates a refused event (|| true) so a stale-gen writer can never + # break Claude's own lifecycle. mkdir -p "$WT/.claude" + busy_cmd_prefix="$(shell_quote "$FM_ROOT/bin/fm-busy-event.sh") apply $(shell_quote "$STATE_REAL") $(shell_quote "$ID")" + busy_suffix="--gen $(shell_quote "$BUSY_GEN") --source claude-hook" + j_submit=$(json_escape "$busy_cmd_prefix busy $busy_suffix --event user-prompt-submit 2>/dev/null || true") + j_stop=$(json_escape "touch $(shell_quote "$TURNEND"); $busy_cmd_prefix idle $busy_suffix --event stop 2>/dev/null || true") + j_stopfail=$(json_escape "$busy_cmd_prefix idle $busy_suffix --event stop-failure 2>/dev/null || true") + j_sessionend=$(json_escape "$busy_cmd_prefix idle $busy_suffix --event session-end 2>/dev/null || true") cat > "$WT/.claude/settings.local.json" <<EOF -{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"touch '$TURNEND'"}]}]}} +{"hooks":{"UserPromptSubmit":[{"hooks":[{"type":"command","command":"$j_submit"}]}],"Stop":[{"hooks":[{"type":"command","command":"$j_stop"}]}],"StopFailure":[{"hooks":[{"type":"command","command":"$j_stopfail"}]}],"SessionEnd":[{"hooks":[{"type":"command","command":"$j_sessionend"}]}]}} EOF exclude_path '.claude/settings.local.json' ;; opencode*) mkdir -p "$WT/.opencode/plugins" - cat > "$WT/.opencode/plugins/fm-turn-end.js" <<EOF -export const FmTurnEnd = async ({ \$ }) => ({ - event: async ({ event }) => { - if (event.type === "session.idle") await \$\`touch $TURNEND\` - }, -}) + cat > "$WT/.opencode/plugins/fm-busy-state.js" <<EOF +// Firstmate semantic busy-state events + turn-end notification; written by +// fm-spawn under the contract owned by bin/fm-busy-lib.sh. +// Semantic state comes from OpenCode's session.status events: busy and retry +// are active, idle is inactive. Scoping latches the first session that +// reports activity (the worker's main session - a subagent child session can +// only start while the main session is already busy) and ignores other +// sessions' status until the latched session settles, so a child's idle can +// never clear the worker's busy state. The session.idle touch stays the +// watcher's wake NOTIFICATION, never current-state truth. +import { execFile } from "node:child_process"; +const busyEvent = (state, event) => + new Promise((resolve) => { + execFile("$FM_ROOT/bin/fm-busy-event.sh", [ + "apply", "$STATE_REAL", "$ID", state, + "--gen", "$BUSY_GEN", "--source", "opencode-plugin", "--event", event, + ], () => resolve()); + }); +export const FmBusyState = async () => { + let activeSession = null; + return { + event: async ({ event }) => { + if (event.type === "session.status") { + const sessionID = event.properties.sessionID; + const statusType = event.properties.status && event.properties.status.type; + if (statusType === "busy" || statusType === "retry") { + if (activeSession === null) activeSession = sessionID; + if (sessionID === activeSession) await busyEvent("busy", "session-" + statusType); + return; + } + if (statusType === "idle" && sessionID === activeSession) { + activeSession = null; + await busyEvent("idle", "session-status-idle"); + } + return; + } + if (event.type === "session.idle") { + if (event.properties.sessionID === activeSession) { + activeSession = null; + await busyEvent("idle", "session-idle"); + } + await new Promise((resolve) => { + execFile("touch", ["$TURNEND"], () => resolve()); + }); + } + }, + }; +}; EOF - exclude_path '.opencode/plugins/fm-turn-end.js' + exclude_path '.opencode/plugins/fm-busy-state.js' ;; 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. cat > "$STATE/$ID.pi-ext.ts" <<EOF -// Firstmate turn-end signal; written by fm-spawn. -// Use "turn_end" (fires after each turn the agent finishes), not "agent_end" -// (fires once, only when the whole run exits): the watcher needs a signal at -// every turn boundary so an idle crewmate is surfaced, not just at shutdown. +// Firstmate semantic busy-state events + turn-end notification; written by +// fm-spawn under the contract owned by bin/fm-busy-lib.sh. +// Semantic state: "agent_start" -> busy when a low-level agent run begins; +// "agent_settled" -> idle only when ctx.isIdle() confirms Pi will not +// continue automatically - auto-retries, auto-compaction retries, tool +// loops, and queued continuations all keep the run un-settled, and a settle +// that raced another extension's fresh run keeps state busy via isIdle(). +// "turn_end" fires at every inner turn boundary (one LLM response plus its +// tool calls) and stays a wake NOTIFICATION touch for the watcher, never +// current-state truth. import { execFile } from "node:child_process"; +const busyEvent = (state: string, event: string) => + new Promise<void>((resolve) => { + execFile("$FM_ROOT/bin/fm-busy-event.sh", [ + "apply", "$STATE_REAL", "$ID", state, + "--gen", "$BUSY_GEN", "--source", "pi-ext", "--event", event, + ], () => resolve()); + }); export default function (pi: any) { + pi.on("agent_start", () => busyEvent("busy", "agent-start")); + pi.on("agent_settled", (_event: any, ctx: any) => { + if (ctx && typeof ctx.isIdle === "function" && !ctx.isIdle()) return; + return busyEvent("idle", "agent-settled"); + }); pi.on("turn_end", () => execFile("touch", ["$TURNEND"])); } EOF ;; codex*) - # codex: turn-end rides the launch command via -c notify=[...] and __TURNEND__. + # Semantic busy-state source negotiation (bin/fm-busy-lib.sh owns the + # probes and the evidence). Neither Codex path is usable on the + # installed binary: a pane worker's turns are not observable through + # the app-server protocol, and its lifecycle hooks did not fire for a + # firstmate-launched worker. Codex therefore classifies unknown with + # an explicit reason rather than falling back to idle, and no busy + # wiring is installed. The turn-end NOTIFICATION marker still rides + # the launch command via -c notify=[...] and __TURNEND__. ;; grok*) # grok fires a Stop hook at every turn boundary (verified, grok 0.2.73), the @@ -1555,11 +1705,10 @@ fi # Recorded in meta so fm-teardown's safety check and the validate/merge stages can # branch on them. Mode governs ship tasks; a scout's deliverable is a report, not a # merge, so scout teardown ignores mode. -SECONDMATE_PROJECTS= if [ "$KIND" = secondmate ]; then MODE=secondmate YOLO=off - SECONDMATE_PROJECTS=$(secondmate_registry_value "$ID" projects || true) + : "${SECONDMATE_PROJECTS:=}" else PROJ_NAME=$(basename "$PROJ_ABS") read -r MODE YOLO <<EOF @@ -1581,6 +1730,7 @@ META_WINDOW=$T echo "tasktmp=$TASK_TMP" echo "model=${MODEL:-default}" echo "effort=${EFFORT:-default}" + [ -z "${BUSY_GEN:-}" ] || echo "busy_gen=$BUSY_GEN" # backend= is written only for a non-default (non-tmux) backend, so the # default path's meta stays byte-identical (absent backend= means tmux; # data/fm-backend-design-d7's P1 compatibility contract). @@ -1639,7 +1789,8 @@ if [ "$HARNESS" = claude ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then fi if [ "$KIND" = secondmate ]; then sq_home=$(shell_quote "$PROJ_ABS") - LAUNCH="FM_ROOT_OVERRIDE= FM_STATE_OVERRIDE= FM_DATA_OVERRIDE= FM_PROJECTS_OVERRIDE= FM_CONFIG_OVERRIDE= FM_HOME=$sq_home $LAUNCH" + sq_primary_home=$(shell_quote "$FM_HOME") + LAUNCH="FM_ROOT_OVERRIDE= FM_STATE_OVERRIDE= FM_DATA_OVERRIDE= FM_PROJECTS_OVERRIDE= FM_CONFIG_OVERRIDE= FM_PUBLIC_FOLLOWUP_PRIMARY_HOME=$sq_primary_home FM_HOME=$sq_home $LAUNCH" fi # Export GOTMPDIR into the crewmate's pane shell so the agent and every child # process (go build, go test, ...) inherit it. The verified delivery routine diff --git a/bin/fm-startup-memory-budget-lib.sh b/bin/fm-startup-memory-budget-lib.sh new file mode 100644 index 00000000000..f2c06014b8e --- /dev/null +++ b/bin/fm-startup-memory-budget-lib.sh @@ -0,0 +1,224 @@ +# shellcheck shell=bash +# Startup-memory budget primitives. +# Usage: . bin/fm-startup-memory-budget-lib.sh +# +# The local, primary-authoritative config/startup-memory-budget setting is one +# strictly formatted positive decimal value followed by one newline. The +# locked primary bootstrap owns first materialization. This library owns safe +# parsing, default publication, and the portable prompt-memory estimate used by +# bin/fm-startup-memory-budget.sh and the internal /stow skill. + +FM_STARTUP_MEMORY_BUDGET_FILE="startup-memory-budget" +FM_STARTUP_MEMORY_BUDGET_DEFAULT="7500" +FM_STARTUP_MEMORY_BUDGET_ERROR="" +FM_STARTUP_MEMORY_BUDGET_VALUE="" +FM_STARTUP_MEMORY_MEASURE_BYTES="" +FM_STARTUP_MEMORY_MEASURE_TOKENS="" +FM_STARTUP_MEMORY_MEASURE_PRESENCE="" + +fm_startup_memory_budget_fail() { + FM_STARTUP_MEMORY_BUDGET_ERROR=$1 + return 1 +} + +fm_startup_memory_budget_link_count() { + if [ "$(uname)" = Darwin ]; then + stat -f %l "$1" 2>/dev/null + else + stat -c %h "$1" 2>/dev/null + fi +} + +fm_startup_memory_budget_config_dir_safe() { + local dir=$1 + if [ -L "$dir" ]; then + fm_startup_memory_budget_fail "config directory is symlinked" + return 1 + fi + if [ ! -d "$dir" ]; then + fm_startup_memory_budget_fail "config directory is not a directory" + return 1 + fi + return 0 +} + +# fm_startup_memory_budget_file_valid <path> +# Sets FM_STARTUP_MEMORY_BUDGET_VALUE only for a regular, single-linked file +# containing exactly one positive decimal value and one terminating newline. +fm_startup_memory_budget_file_valid() { + local path=$1 links value + FM_STARTUP_MEMORY_BUDGET_VALUE="" + if [ -L "$path" ]; then + fm_startup_memory_budget_fail "file is symlinked" + return 1 + fi + if [ ! -e "$path" ]; then + fm_startup_memory_budget_fail "file is absent" + return 1 + fi + if [ ! -f "$path" ]; then + fm_startup_memory_budget_fail "file is not a regular file" + return 1 + fi + links=$(fm_startup_memory_budget_link_count "$path") || { + fm_startup_memory_budget_fail "could not inspect file link count" + return 1 + } + if [ "$links" != 1 ]; then + fm_startup_memory_budget_fail "file is hardlinked" + return 1 + fi + value=$(<"$path") || { + fm_startup_memory_budget_fail "could not read file" + return 1 + } + case "$value" in + ''|0|*[!0-9]*|0*) + fm_startup_memory_budget_fail "value must be one positive decimal integer" + return 1 + ;; + esac + if ! printf '%s\n' "$value" | cmp -s "$path" -; then + fm_startup_memory_budget_fail "file must contain exactly one value followed by one newline" + return 1 + fi + FM_STARTUP_MEMORY_BUDGET_VALUE=$value + return 0 +} + +# fm_startup_memory_budget_read <config-dir> +# Prints the validated decimal value. It never treats an absent or unsafe file +# as an implicit default because callers need a visible, auditable setting. +fm_startup_memory_budget_read() { + local config_dir=$1 path + fm_startup_memory_budget_config_dir_safe "$config_dir" || return 1 + path="$config_dir/$FM_STARTUP_MEMORY_BUDGET_FILE" + fm_startup_memory_budget_file_valid "$path" || return 1 + printf '%s\n' "$FM_STARTUP_MEMORY_BUDGET_VALUE" +} + +# fm_startup_memory_budget_materialize <config-dir> +# Atomically publishes the visible default only when the file is absent. A +# concurrent valid creator is accepted; every unsafe or malformed existing +# artifact is rejected without replacement. +fm_startup_memory_budget_materialize() { + local config_dir=$1 path tmp + if [ -e "$config_dir" ] || [ -L "$config_dir" ]; then + fm_startup_memory_budget_config_dir_safe "$config_dir" || return 1 + else + mkdir -p "$config_dir" 2>/dev/null || { + fm_startup_memory_budget_fail "could not create config directory" + return 1 + } + fm_startup_memory_budget_config_dir_safe "$config_dir" || return 1 + fi + + path="$config_dir/$FM_STARTUP_MEMORY_BUDGET_FILE" + if [ -e "$path" ] || [ -L "$path" ]; then + fm_startup_memory_budget_read "$config_dir" >/dev/null || return 1 + return 0 + fi + + tmp=$(umask 077; mktemp "$config_dir/.startup-memory-budget.XXXXXX" 2>/dev/null) || { + fm_startup_memory_budget_fail "could not create default temporary file" + return 1 + } + if ! printf '%s\n' "$FM_STARTUP_MEMORY_BUDGET_DEFAULT" > "$tmp" \ + || ! fm_startup_memory_budget_file_valid "$tmp"; then + rm -f "$tmp" + [ -n "$FM_STARTUP_MEMORY_BUDGET_ERROR" ] \ + || fm_startup_memory_budget_fail "could not write default value" + return 1 + fi + + # link(2) gives no-clobber publication in this directory. Removing the + # temporary name leaves the published file with exactly one link. + if ln "$tmp" "$path" 2>/dev/null; then + rm -f "$tmp" + fm_startup_memory_budget_read "$config_dir" >/dev/null || return 1 + return 0 + fi + rm -f "$tmp" + # Another actor may have created the file. Accept it only if it now meets + # the same safe, exact format - never replace or guess at it. + fm_startup_memory_budget_read "$config_dir" >/dev/null +} + +# fm_startup_memory_estimated_tokens_for_bytes <non-negative bytes> +# The estimate is ceil(UTF-8 bytes / 3): stable, dependency-free, and +# deliberately conservative for ordinary prompt text without claiming provider +# exactness. +fm_startup_memory_estimated_tokens_for_bytes() { + local bytes=$1 tokens + case "$bytes" in + ''|*[!0-9]*) return 1 ;; + esac + tokens=$((bytes / 3)) + if [ $((bytes % 3)) -ne 0 ]; then + tokens=$((tokens + 1)) + fi + printf '%s\n' "$tokens" +} + +# fm_startup_memory_measure_file <path> +# Prints "<bytes> <estimated-tokens> <present|absent>". Memory files must be +# ordinary files when present so a measurement never follows a symlink or reads +# a special file. +fm_startup_memory_measure_file() { + local path=$1 bytes tokens + FM_STARTUP_MEMORY_MEASURE_BYTES="" + FM_STARTUP_MEMORY_MEASURE_TOKENS="" + FM_STARTUP_MEMORY_MEASURE_PRESENCE="" + if [ ! -e "$path" ] && [ ! -L "$path" ]; then + FM_STARTUP_MEMORY_MEASURE_BYTES=0 + FM_STARTUP_MEMORY_MEASURE_TOKENS=0 + FM_STARTUP_MEMORY_MEASURE_PRESENCE=absent + printf '0 0 absent\n' + return 0 + fi + if [ -L "$path" ] || [ ! -f "$path" ]; then + fm_startup_memory_budget_fail "memory file is not an ordinary regular file: $path" + return 1 + fi + bytes=$(LC_ALL=C wc -c < "$path" 2>/dev/null | tr -d '[:space:]') || { + fm_startup_memory_budget_fail "could not measure memory file: $path" + return 1 + } + case "$bytes" in + ''|*[!0-9]*) + fm_startup_memory_budget_fail "invalid byte count for memory file: $path" + return 1 + ;; + esac + tokens=$(fm_startup_memory_estimated_tokens_for_bytes "$bytes") || { + fm_startup_memory_budget_fail "could not estimate memory tokens for: $path" + return 1 + } + # shellcheck disable=SC2034 # Public measurement result consumed by the caller after sourcing. + FM_STARTUP_MEMORY_MEASURE_BYTES=$bytes + # shellcheck disable=SC2034 # Public measurement result consumed by the caller after sourcing. + FM_STARTUP_MEMORY_MEASURE_TOKENS=$tokens + # shellcheck disable=SC2034 # Public measurement result consumed by the caller after sourcing. + FM_STARTUP_MEMORY_MEASURE_PRESENCE=present + printf '%s %s present\n' "$bytes" "$tokens" +} + +# fm_startup_memory_decimal_le <left> <right> +# Decimal comparison without shell arithmetic overflow. Inputs are normalized +# non-negative decimal strings. +fm_startup_memory_decimal_le() { + local left=$1 right=$2 left_len right_len + case "$left:$right" in + *[!0-9:]*|:*|*:) return 1 ;; + esac + left_len=${#left} + right_len=${#right} + if [ "$left_len" -lt "$right_len" ]; then + return 0 + fi + if [ "$left_len" -gt "$right_len" ]; then + return 1 + fi + [ "$left" = "$right" ] && return 0 + [[ "$left" < "$right" ]] +} diff --git a/bin/fm-startup-memory-budget.sh b/bin/fm-startup-memory-budget.sh new file mode 100755 index 00000000000..715da549482 --- /dev/null +++ b/bin/fm-startup-memory-budget.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Read and account for the local startup-memory budget. +# Usage: +# fm-startup-memory-budget.sh read +# fm-startup-memory-budget.sh report +# +# `read` prints the one validated effective budget from +# config/startup-memory-budget. `report` prints the stable local estimate for +# data/captain.md, data/captain-shared.md, and data/learnings.md together. +# Bootstrap owns default materialization; this command never creates or repairs +# configuration, so an absent, malformed, symlinked, hardlinked, or otherwise +# unsafe value is a concrete error rather than an inferred default. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" +DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" + +# shellcheck source=bin/fm-startup-memory-budget-lib.sh +. "$SCRIPT_DIR/fm-startup-memory-budget-lib.sh" + +usage() { + sed -n '2,11{s/^# \{0,1\}//;p;}' "$0" +} + +print_error() { + printf 'startup-memory-budget: %s\n' "$1" >&2 +} + +read_budget() { + if ! fm_startup_memory_budget_read "$CONFIG" >/dev/null; then + print_error "invalid config/$FM_STARTUP_MEMORY_BUDGET_FILE - $FM_STARTUP_MEMORY_BUDGET_ERROR" + return 1 + fi + printf '%s\n' "$FM_STARTUP_MEMORY_BUDGET_VALUE" +} + +report() { + local budget bytes tokens presence total=0 shared_tokens=0 role=primary + if ! budget=$(read_budget); then + return 2 + fi + + if [ -e "$FM_HOME/.fm-secondmate-home" ] || [ -L "$FM_HOME/.fm-secondmate-home" ]; then + role=secondmate + fi + + printf 'estimator=ceil(UTF-8 bytes / 3) conservative-local-estimate\n' + printf 'role=%s\n' "$role" + printf 'effective_budget_tokens=%s\n' "$budget" + for file in captain.md captain-shared.md learnings.md; do + if ! fm_startup_memory_measure_file "$DATA/$file" >/dev/null; then + print_error "$FM_STARTUP_MEMORY_BUDGET_ERROR" + return 2 + fi + bytes=$FM_STARTUP_MEMORY_MEASURE_BYTES + tokens=$FM_STARTUP_MEMORY_MEASURE_TOKENS + presence=$FM_STARTUP_MEMORY_MEASURE_PRESENCE + total=$((total + tokens)) + [ "$file" != captain-shared.md ] || shared_tokens=$tokens + printf 'file=data/%s bytes=%s estimated_tokens=%s status=%s\n' \ + "$file" "$bytes" "$tokens" "$presence" + done + printf 'total_estimated_tokens=%s\n' "$total" + if fm_startup_memory_decimal_le "$total" "$budget"; then + printf 'budget_status=within-budget\n' + else + printf 'budget_status=over-budget\n' + fi + if [ "$role" = secondmate ] \ + && ! fm_startup_memory_decimal_le "$shared_tokens" "$budget"; then + printf 'exception=primary-owned-shared-file-alone-exceeds-budget\n' + fi +} + +case "${1:-}" in + read) + [ "$#" -eq 1 ] || { usage >&2; exit 2; } + read_budget + ;; + report) + [ "$#" -eq 1 ] || { usage >&2; exit 2; } + report + ;; + -h|--help) + usage + ;; + *) + usage >&2 + exit 2 + ;; +esac diff --git a/bin/fm-supervise-daemon.sh b/bin/fm-supervise-daemon.sh index 30554edbbd4..400a8bf5357 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -96,7 +96,8 @@ # (default 300) # FM_HOUSEKEEPING_TICK seconds between housekeeping passes while # the watcher is mid-cycle (default 15) -# FM_BUSY_REGEX optional global busy-signature override +# FM_BUSY_REGEX optional rendered busy-signature override +# for delivery guards and Grok's fallback # FM_COMPOSER_IDLE_RE empty-composer regex applied after dim-ghost # and structural border stripping (default: # bare prompt glyphs plus busy footers) @@ -174,6 +175,11 @@ FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" # shellcheck source=bin/fm-supervisor-target-lib.sh . "$FM_DAEMON_DIR/fm-supervisor-target-lib.sh" +# The single owner of semantic busy state for recorded tasks +# (fm_busy_classify). +# shellcheck source=bin/fm-busy-lib.sh +. "$FM_DAEMON_DIR/fm-busy-lib.sh" + # --- tunables --------------------------------------------------------------- # Supervisor backends this daemon knows how to inject into today. zellij, orca, # and cmux are real backends elsewhere in firstmate (bin/fm-backend.sh) but this @@ -198,8 +204,9 @@ WEDGE_ALARM_NOTIFIER_PID= # The captain-relevant verb set and the status classifiers (last_status_line, # status_is_captain_relevant, window_to_task, scan_captain_relevant_statuses) now # live in bin/fm-classify-lib.sh, shared with the always-on watcher. -# Composer-empty detection and harness-scoped busy-footer matching live in -# bin/fm-tmux-lib.sh; FM_BUSY_REGEX still overrides every fallback here. +# Composer-empty detection, submit acknowledgement, and the harness-scoped +# supervisor-pane busy guard live in bin/fm-tmux-lib.sh. +# FM_BUSY_REGEX also overrides Grok's isolated task-state fallback. INJECT_FAIL_SLEEP_DEFAULT=30 INJECT_CONFIRM_RETRIES_DEFAULT=3 INJECT_CONFIRM_SLEEP_DEFAULT=0.5 @@ -544,34 +551,45 @@ mark_escalated_seen() { # <kind> <arg> <state> esac } -# Busy + composer-empty detection are the shared primitives in fm-tmux-lib.sh -# (one source of truth with fm-send.sh). These thin wrappers keep the daemon's -# call sites and the unit tests stable. +# Busy and composer-empty detection form the injection boundary. +# These thin wrappers keep the daemon's call sites and unit tests stable. # # pane_input_pending returns 0 unless the composer is positively proven empty. # This includes real unsubmitted text, ambiguous structure, unreadable state, # and future verdicts. The detector drops dim/faint ghost text and strips the # harness's composer box borders, so an aligned ghost-only or idle bordered # claude composer ("│ > … │") is correctly proven empty. -# pane_is_busy / pane_input_pending: BACKEND-AWARE now (previously tmux-only -# direct calls). <backend> defaults to tmux when omitted, so every existing -# caller/test that passes only <target> is unaffected. Dispatch goes through -# bin/fm-backend.sh's generic per-backend primitives (fm_backend_busy_state, -# fm_backend_capture, fm_backend_composer_state) rather than hand-rolling a -# case statement here, mirroring the fallback order stale_window_is_busy uses -# for per-task panes: try the backend's native busy state first, then match -# captured output. The supervisor pane has no recorded task harness and uses -# the historical combined fallback; stale task panes select the recorded -# harness's verified signature. +# pane_is_busy / pane_input_pending: BACKEND-AWARE (dispatch goes through +# bin/fm-backend.sh's generic per-backend primitives rather than a hand-rolled +# case statement here). <backend> defaults to tmux when omitted, so every +# existing caller/test that passes only <target> is unaffected. +# +# This rendered reader applies only to the supervisor pane during away-mode +# injection. It never classifies a recorded worker task. The detected primary +# harness selects exactly one signature, so output from another harness cannot +# make the primary read busy. +# +# Resolved lazily and memoized: harness detection walks process ancestry, which +# is too heavy to pay on every source of this library (the unit tests and the +# launcher source it purely for its pure functions). +fm_daemon_primary_harness() { + if [ -z "${FM_DAEMON_PRIMARY_HARNESS:-}" ]; then + FM_DAEMON_PRIMARY_HARNESS=$("$FM_DAEMON_DIR/fm-harness.sh" 2>/dev/null || printf 'unknown') + [ -n "$FM_DAEMON_PRIMARY_HARNESS" ] || FM_DAEMON_PRIMARY_HARNESS=unknown + fi + printf '%s' "$FM_DAEMON_PRIMARY_HARNESS" +} + pane_is_busy() { # <target> [backend] - local target=$1 backend=${2:-tmux} bs tail40 - bs=$(fm_backend_busy_state "$backend" "$target" 2>/dev/null) - case "$bs" in + local target=$1 backend=${2:-tmux} native tail40 harness + harness=$(fm_daemon_primary_harness) + native=$(fm_backend_busy_state "$backend" "$target" 2>/dev/null) + case "$native" in busy) return 0 ;; esac tail40=$(fm_backend_capture "$backend" "$target" 40 2>/dev/null) || return 1 printf '%s' "$tail40" | grep -v '^[[:space:]]*$' | tail -12 \ - | fm_busy_lines_match + | fm_busy_lines_match "$harness" } # pane_input_pending dispatches through fm_backend_composer_state and treats @@ -593,21 +611,23 @@ task_window_harness() { # <window> <state> local win=$1 state=$2 task meta task=$(window_to_task "$win" "$state") meta="$state/$task.meta" - grep '^harness=' "$meta" | cut -d= -f2- || true + grep '^harness=' "$meta" 2>/dev/null | cut -d= -f2- || true } +# stale_window_is_busy: 0 when the task is PROVABLY working through the +# semantic busy-state contract (bin/fm-busy-lib.sh), 1 when it is not, and 2 +# when the endpoint could not be read at all. Only an exact busy verdict is +# working: unknown semantic state never becomes busy and never becomes a +# silent idle, so a stale pane whose state cannot be proven surfaces. stale_window_is_busy() { # <window> <state> - local win=$1 state=$2 backend harness label tail40 bs + local win=$1 state=$2 backend harness label task tail40 verdict backend=$(task_window_backend "$win" "$state") harness=$(task_window_harness "$win" "$state") - label="fm-$(window_to_task "$win" "$state")" + task=$(window_to_task "$win" "$state") + label="fm-$task" tail40=$(fm_backend_capture "$backend" "$win" 40 "$label" 2>/dev/null) || return 2 - bs=$(fm_backend_busy_state "$backend" "$win" 2>/dev/null) - case "$bs" in - busy) return 0 ;; - esac - printf '%s' "$tail40" | grep -v '^[[:space:]]*$' | tail -12 \ - | fm_busy_lines_match "$harness" + verdict=$(fm_busy_classify "$backend" "$win" "$harness" "$task" "$state" "$tail40") + [ "${verdict%% *}" = busy ] } escalate_add() { # <state> <distilled-item> @@ -1113,8 +1133,7 @@ inject_msg() { # <message> [state] # discovery), matching this function's pre-existing default assumption. backend="${FM_SUPERVISOR_BACKEND:-tmux}" fm_backend_target_exists "$backend" "$target" || return 1 - # (3) Busy-guard: never inject into an in-use pane. - # a) pane_is_busy: the harness shows a busy footer (agent mid-turn). + # (3) Busy-guard: never inject into an in-use supervisor pane. if pane_is_busy "$target" "$backend"; then log "inject deferred: supervisor pane busy (agent mid-turn)" return 1 diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 42e7539989a..b2e9d76ab14 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -112,6 +112,10 @@ SUB_HOME_MARKER=".fm-secondmate-home" . "$SCRIPT_DIR/fm-pr-lib.sh" # shellcheck source=bin/fm-remote-lib.sh . "$SCRIPT_DIR/fm-remote-lib.sh" +# shellcheck source=bin/fm-public-followup-lib.sh +. "$SCRIPT_DIR/fm-public-followup-lib.sh" +# shellcheck source=bin/fm-secondmate-registry-lib.sh +. "$SCRIPT_DIR/fm-secondmate-registry-lib.sh" if [ "$#" -lt 1 ] || ! fm_task_id_path_safe "$1"; then echo "error: invalid teardown request" >&2 exit 2 @@ -141,6 +145,10 @@ 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 # (/tmp/fm-<id>/); absent for tasks spawned before that change, so tolerate empty. TASK_TMP=$(grep '^tasktmp=' "$META" | cut -d= -f2- || true) +BUSY_GEN=$(fm_meta_get "$META" busy_gen) +if [ -z "$BUSY_GEN" ]; then + BUSY_GEN=$(cat "$STATE/$ID.busy-gen" 2>/dev/null || true) +fi ORCA_WORKTREE_ID=$(fm_meta_get "$META" orca_worktree_id) ORCA_PATH_MATCH_VERIFIED=0 @@ -148,6 +156,70 @@ KIND=$(grep '^kind=' "$META" | cut -d= -f2- || true) [ -n "$KIND" ] || KIND=ship MODE=$(grep '^mode=' "$META" | cut -d= -f2- || true) [ -n "$MODE" ] || MODE=no-mistakes +PUBLIC_FOLLOWUP_HOME=$FM_HOME +PUBLIC_FOLLOWUP_STATE=$STATE +PUBLIC_FOLLOWUP_WORK_HOME=main +PUBLIC_FOLLOWUP_PARENT_UNRESOLVED=0 +PUBLIC_FOLLOWUP_PARENT_RELAY_ACTIVE=0 +PUBLIC_FOLLOWUP_RELAY_ACTIVE=0 +public_followup_resolve_primary_home() { + local parent=$1 child=$2 id=$3 parent_meta registry meta_home + fm_pf_home_id_valid "secondmate:$id" || return 1 + case "$parent" in /*) ;; *) return 1 ;; esac + parent=$(CDPATH='' cd -- "$parent" 2>/dev/null && pwd -P) || return 1 + child=$(CDPATH='' cd -- "$child" 2>/dev/null && pwd -P) || return 1 + [ "$parent" != "$child" ] || return 1 + parent_meta="$parent/state/$id.meta" + [ -f "$parent_meta" ] && [ ! -L "$parent_meta" ] || return 1 + [ "$(fm_meta_get "$parent_meta" kind)" = secondmate ] || return 1 + meta_home=$(fm_meta_get "$parent_meta" home) + meta_home=$(CDPATH='' cd -- "$meta_home" 2>/dev/null && pwd -P) || return 1 + [ "$meta_home" = "$child" ] || return 1 + registry="$parent/data/secondmates.md" + secondmate_registry_validate_bindings "$registry" secondmate_registry_path_key "$id" "$child" || return 1 + printf '%s\n' "$parent" +} +if [ -f "$FM_HOME/$SUB_HOME_MARKER" ]; then + SECOND_MATE_ID=$(sed -n '1p' "$FM_HOME/$SUB_HOME_MARKER") + # A marked child only enters the primary-binding path when the authoritative + # parent relay is active. A child that has not opted into the relay must + # retain the old teardown path, even without a durable parent registry. + if [ -n "${FM_PUBLIC_FOLLOWUP_PRIMARY_HOME:-}" ]; then + if fm_pf_relay_active "$FM_PUBLIC_FOLLOWUP_PRIMARY_HOME"; then + PUBLIC_FOLLOWUP_PARENT_RELAY_ACTIVE=1 + fi + elif fm_pf_relay_active "$FM_HOME"; then + PUBLIC_FOLLOWUP_PARENT_RELAY_ACTIVE=1 + fi + if [ "$PUBLIC_FOLLOWUP_PARENT_RELAY_ACTIVE" = 1 ]; then + PUBLIC_FOLLOWUP_PARENT_UNRESOLVED=1 + if fm_pf_home_id_valid "secondmate:$SECOND_MATE_ID"; then + PUBLIC_FOLLOWUP_WORK_HOME="secondmate:$SECOND_MATE_ID" + if PUBLIC_FOLLOWUP_HOME=$(public_followup_resolve_primary_home \ + "${FM_PUBLIC_FOLLOWUP_PRIMARY_HOME:-}" "$FM_HOME" "$SECOND_MATE_ID"); then + PUBLIC_FOLLOWUP_STATE="$PUBLIC_FOLLOWUP_HOME/state" + PUBLIC_FOLLOWUP_PARENT_UNRESOLVED=0 + if [ "$FORCE" != "--force" ] \ + && fm_pf_relay_active "$PUBLIC_FOLLOWUP_HOME"; then + PUBLIC_FOLLOWUP_RELAY_ACTIVE=1 + fi + else + PUBLIC_FOLLOWUP_HOME= + PUBLIC_FOLLOWUP_STATE= + fi + fi + else + PUBLIC_FOLLOWUP_HOME= + PUBLIC_FOLLOWUP_STATE= + fi +elif [ "$KIND" = secondmate ]; then + PUBLIC_FOLLOWUP_WORK_HOME="secondmate:$ID" + if [ "$FORCE" != "--force" ] && fm_pf_relay_active "$FM_HOME"; then + PUBLIC_FOLLOWUP_RELAY_ACTIVE=1 + fi +elif [ "$FORCE" != "--force" ] && fm_pf_relay_active "$FM_HOME"; then + PUBLIC_FOLLOWUP_RELAY_ACTIVE=1 +fi default_branch() { local ref branch @@ -212,6 +284,15 @@ remove_kimi_turnend_auth() { rm -f "$hooks_dir/$token" } +retire_busy_state() { + local state_dir=$1 id=$2 gen=${3:-} + if [ -n "$gen" ]; then + "$SCRIPT_DIR/fm-busy-event.sh" retire "$state_dir" "$id" --gen "$gen" + elif [ -f "$state_dir/$id.busy-gen" ]; then + "$SCRIPT_DIR/fm-busy-event.sh" retire "$state_dir" "$id" --current-gen + fi +} + validate_pr_poll_cleanup() { local state_dir=$1 id=$2 quarantine state_device artifact has_artifact=0 fm_task_id_path_safe "$id" || return 0 @@ -535,10 +616,6 @@ backlog_refresh_reminder() { fi } -registry_home_for_line() { - sed -n 's/^[^(]*(home: \([^;)]*\);.*/\1/p' -} - path_is_ancestor_of() { local ancestor=$1 path=$2 [ -n "$ancestor" ] || return 1 @@ -895,13 +972,19 @@ validate_removal_target() { registered_descendant_home_for_removal() { local reg=$1 target=$2 line id registered_home registered_abs [ -f "$reg" ] || return 1 - while IFS= read -r line; do + if ! secondmate_registry_validate_bindings "$reg" secondmate_registry_path_key; then + echo "REFUSED: $SECONDMATE_REGISTRY_ERROR" >&2 + return 2 + fi + while IFS= read -r line || [ -n "$line" ]; do case "$line" in "- "*) - id=${line#- } - id=${id%% *} - registered_home=$(printf '%s\n' "$line" | registry_home_for_line) - [ -n "$registered_home" ] || continue + secondmate_registry_parse_line "$line" || { + echo "REFUSED: malformed secondmate registry entry: $line" >&2 + return 2 + } + id=$SECONDMATE_REGISTRY_ID + registered_home=$SECONDMATE_REGISTRY_HOME registered_abs=$(removal_target_abs_path "$registered_home" 2>/dev/null || true) [ -n "$registered_abs" ] || continue [ "$registered_abs" = "$target" ] && continue @@ -990,11 +1073,33 @@ validate_firstmate_home_for_removal() { echo "REFUSED: unsafe $label removal target $home is marked for secondmate ${marker_id:-unknown}, expected $expected_id" >&2 return 1 fi + if [ -e "$SECONDMATE_REG" ] || [ -L "$SECONDMATE_REG" ]; then + if ! secondmate_registry_validate_bindings "$SECONDMATE_REG" secondmate_registry_path_key "$expected_id" "$abs_home_path"; then + case "$SECONDMATE_REGISTRY_ERROR" in + overlapping\ secondmate\ home\ assignment:*) + echo "REFUSED: unsafe $label removal target $home contains registered secondmate home; $SECONDMATE_REGISTRY_ERROR" >&2 + ;; + *) echo "REFUSED: $SECONDMATE_REGISTRY_ERROR" >&2 ;; + esac + return 1 + fi + fi fi validate_firstmate_operational_dirs_for_removal "$abs_home_path" "$label" || return 1 - conflict=$(registered_descendant_home_for_removal "$SECONDMATE_REG" "$abs_home_path" || true) + conflict= + if conflict=$(registered_descendant_home_for_removal "$SECONDMATE_REG" "$abs_home_path"); then + : + else + conflict_rc=$? + [ "$conflict_rc" -eq 1 ] || return 1 + fi if [ -z "$conflict" ]; then - conflict=$(registered_descendant_home_for_removal "$abs_home_path/data/secondmates.md" "$abs_home_path" || true) + if conflict=$(registered_descendant_home_for_removal "$abs_home_path/data/secondmates.md" "$abs_home_path"); then + : + else + conflict_rc=$? + [ "$conflict_rc" -eq 1 ] || return 1 + fi fi if [ -n "$conflict" ]; then IFS=$'\t' read -r child_id child_home <<EOF @@ -1058,8 +1163,144 @@ validate_firstmate_home_children_removal() { done } +TEARDOWN_HERDR_LOCK_RECORDS= +teardown_release_herdr_locks() { + local lock_session lock_path + [ -n "$TEARDOWN_HERDR_LOCK_RECORDS" ] || return 0 + while IFS=$'\t' read -r lock_session lock_path; do + [ -n "$lock_path" ] || continue + fm_lock_release "$lock_path" || true + done <<FMEOF +$TEARDOWN_HERDR_LOCK_RECORDS +FMEOF + TEARDOWN_HERDR_LOCK_RECORDS= +} + +teardown_herdr_session_lock_held() { # <session> + local session=$1 lock_session lock_path + [ -n "$TEARDOWN_HERDR_LOCK_RECORDS" ] || return 1 + while IFS=$'\t' read -r lock_session lock_path; do + [ "$lock_session" != "$session" ] || return 0 + done <<FMEOF +$TEARDOWN_HERDR_LOCK_RECORDS +FMEOF + return 1 +} + +teardown_herdr_require_prerequisites() { # <task-id> + local task_id=$1 prerequisite + if ! fm_backend_source herdr; then + echo "error: herdr teardown prerequisites are unavailable for $task_id; nothing was changed - restore the adapter and rerun teardown" >&2 + return 1 + fi + for prerequisite in \ + fm_backend_herdr_parse_target \ + fm_backend_herdr_pane_presence_state \ + fm_backend_herdr_workspace_presence_state \ + fm_backend_herdr_endpoint_confirmed_gone \ + fm_backend_herdr_explicit_close_pane_confirmed \ + fm_backend_herdr_presentation_session_lock_path; do + if ! declare -F "$prerequisite" >/dev/null 2>&1; then + echo "error: herdr teardown prerequisites are unavailable for $task_id; nothing was changed - restore the adapter and rerun teardown" >&2 + return 1 + fi + done + if ! declare -F fm_lock_try_acquire >/dev/null 2>&1; then + # shellcheck source=bin/fm-wake-lib.sh + . "$SCRIPT_DIR/fm-wake-lib.sh" + fi + if ! declare -F fm_lock_try_acquire >/dev/null 2>&1 \ + || ! declare -F fm_lock_release >/dev/null 2>&1; then + echo "error: herdr teardown lock machinery is unavailable for $task_id; nothing was changed - restore the lock support and rerun teardown" >&2 + return 1 + fi +} + +teardown_herdr_preflight_target() { # <target> <task-id> + local target=$1 task_id=$2 session pane presence lock_path verified_lock_path lock_session held_path attempt + teardown_herdr_require_prerequisites "$task_id" || return 1 + if ! fm_backend_herdr_parse_target "$target"; then + echo "error: herdr endpoint $target for $task_id could not be parsed exactly; nothing was changed - repair the endpoint metadata and rerun teardown" >&2 + return 1 + fi + session=$FM_BACKEND_HERDR_SESSION + pane=$FM_BACKEND_HERDR_PANE + presence=$(fm_backend_herdr_pane_presence_state "$session" "$pane") + case "$presence" in + dead|present) ;; + *) + echo "error: herdr endpoint $target for $task_id has ambiguous structured presence; nothing was changed - restore reliable endpoint inspection and rerun teardown" >&2 + return 1 + ;; + esac + if ! lock_path=$(fm_backend_herdr_presentation_session_lock_path "$session"); then + echo "error: herdr session presentation lock could not be resolved for $task_id; nothing was changed - rerun teardown once the session is reachable and unambiguous" >&2 + return 1 + fi + if [ -n "$TEARDOWN_HERDR_LOCK_RECORDS" ]; then + while IFS=$'\t' read -r lock_session held_path; do + if [ "$lock_session" = "$session" ]; then + if [ "$held_path" != "$lock_path" ]; then + echo "error: herdr session presentation lock changed during preflight for $task_id; nothing was changed - rerun teardown once session identity is stable" >&2 + return 1 + fi + return 0 + fi + done <<FMEOF +$TEARDOWN_HERDR_LOCK_RECORDS +FMEOF + fi + attempt=0 + while [ "$attempt" -lt 50 ]; do + if fm_lock_try_acquire "$lock_path"; then + if ! verified_lock_path=$(fm_backend_herdr_presentation_session_lock_path "$session") \ + || [ "$verified_lock_path" != "$lock_path" ]; then + fm_lock_release "$lock_path" || true + echo "error: herdr session presentation lock changed during preflight for $task_id; nothing was changed - rerun teardown once session identity is stable" >&2 + return 1 + fi + if [ -n "$TEARDOWN_HERDR_LOCK_RECORDS" ]; then + TEARDOWN_HERDR_LOCK_RECORDS="$TEARDOWN_HERDR_LOCK_RECORDS +$session $lock_path" + else + TEARDOWN_HERDR_LOCK_RECORDS="$session $lock_path" + fi + trap teardown_release_herdr_locks EXIT + return 0 + fi + sleep 0.1 + attempt=$((attempt + 1)) + done + echo "error: herdr session presentation lock is contended for $task_id; nothing was changed - rerun teardown once the contention clears" >&2 + return 1 +} + +preflight_firstmate_home_herdr_children() { # <home> + local home=$1 sub_state child_meta child_id child_backend child_target child_kind child_home child_wt + sub_state="$home/state" + [ -d "$sub_state" ] || return 0 + 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 + child_backend=$FM_BACKEND_VALIDATED_BACKEND + child_target=$FM_BACKEND_VALIDATED_TARGET + if [ "$child_backend" = herdr ]; then + teardown_herdr_preflight_target "$child_target" "$child_id" || return 1 + fi + child_kind=$(meta_value "$child_meta" kind) + [ -n "$child_kind" ] || child_kind=ship + if [ "$child_kind" = secondmate ]; then + child_wt=$(meta_value "$child_meta" worktree) + child_home=$(meta_value "$child_meta" home) + [ -n "$child_home" ] || child_home=$child_wt + preflight_firstmate_home_herdr_children "$child_home" || return 1 + fi + done +} + cleanup_firstmate_home_children() { - local home=$1 sub_state child_meta child_id child_t child_wt child_proj child_kind child_home child_backend child_orca_worktree_id child_return_rc + local home=$1 sub_state child_meta child_id child_t child_wt child_proj child_kind child_home child_backend child_orca_worktree_id child_return_rc child_busy_gen sub_state="$home/state" [ -d "$sub_state" ] || return 0 for child_meta in "$sub_state"/*.meta; do @@ -1082,7 +1323,18 @@ cleanup_firstmate_home_children() { fi fi if [ -n "$child_t" ]; then - if [ "$child_backend" = zellij ]; then + if [ "$child_backend" = herdr ]; then + fm_backend_herdr_parse_target "$child_t" || return 1 + if ! teardown_herdr_session_lock_held "$FM_BACKEND_HERDR_SESSION"; then + echo "error: herdr session presentation lock is not held for child $child_id; retaining that child's durable identity records and stopping forced cleanup" >&2 + return 1 + fi + fm_backend_herdr_kill_serialized "$FM_BACKEND_HERDR_SESSION" "$FM_BACKEND_HERDR_PANE" 2>/dev/null || true + if ! fm_backend_herdr_endpoint_confirmed_gone "$child_t"; then + echo "error: herdr pane $child_t for child $child_id is not confirmed gone; retaining that child's durable identity records and stopping forced cleanup" >&2 + return 1 + fi + elif [ "$child_backend" = zellij ]; then # Zellij titles are scoped by the owning home tag, so forced secondmate # cleanup must verify child tabs as that child home, not the parent. ( unset FM_ROOT_OVERRIDE; FM_HOME=$home FM_ROOT=$home fm_backend_kill "$child_backend" "$child_t" "$(meta_value "$child_meta" zellij_tab_id)" "fm-$child_id" ) 2>/dev/null || true @@ -1094,7 +1346,7 @@ cleanup_firstmate_home_children() { child_home=$(meta_value "$child_meta" home) [ -n "$child_home" ] || child_home=$child_wt if [ -n "$child_home" ] && [ -d "$child_home" ]; then - cleanup_firstmate_home_children "$child_home" + cleanup_firstmate_home_children "$child_home" || return 1 remove_firstmate_home "$child_home" "child firstmate home" "$child_id" fi elif [ "$child_backend" = orca ]; then @@ -1107,6 +1359,7 @@ cleanup_firstmate_home_children() { elif [ -n "$child_wt" ] && [ -d "$child_wt" ]; then validate_child_worktree_for_removal "$child_wt" "$child_proj" >/dev/null || return 1 rm -f "$child_wt/.claude/settings.local.json" "$child_wt/.opencode/plugins/fm-turn-end.js" \ + "$child_wt/.opencode/plugins/fm-busy-state.js" \ "$child_wt/.fm-grok-turnend" "$child_wt/.fm-kimi-turnend" if [ -n "$child_proj" ] && [ -d "$child_proj" ] && command -v treehouse >/dev/null 2>&1; then if teardown_treehouse_return "$child_wt" "$child_proj" "child worktree"; then @@ -1125,6 +1378,11 @@ cleanup_firstmate_home_children() { remove_grok_turnend_auth "$sub_state" "$child_id" remove_kimi_turnend_auth "$sub_state" "$child_id" remove_pr_poll_artifacts "$sub_state" "$child_id" || return 1 + child_busy_gen=$(meta_value "$child_meta" busy_gen) + if [ -z "$child_busy_gen" ]; then + child_busy_gen=$(cat "$sub_state/$child_id.busy-gen" 2>/dev/null || true) + fi + retire_busy_state "$sub_state" "$child_id" "$child_busy_gen" || return 1 rm -f "$sub_state/$child_id.status" "$sub_state/$child_id.turn-ended" \ "$sub_state/$child_id.meta" "$sub_state/$child_id.pi-ext.ts" \ "$sub_state/$child_id.grok-turnend-token" "$sub_state/$child_id.kimi-turnend-token" @@ -1146,6 +1404,10 @@ if [ "$KIND" = secondmate ]; then validate_firstmate_home_for_removal "$HOME_PATH" "secondmate home" "$ID" >/dev/null || exit 1 if [ "$FORCE" = "--force" ]; then validate_firstmate_home_children_removal "$HOME_PATH" || exit 1 + if [ "$BACKEND" = herdr ]; then + teardown_herdr_preflight_target "$T" "$ID" || exit 1 + fi + preflight_firstmate_home_herdr_children "$HOME_PATH" || exit 1 fi fi @@ -1180,6 +1442,28 @@ if [ "$KIND" = scout ] && [ "$FORCE" != "--force" ]; then fi fi +# A public commitment is not kept until its final reply lands in the ORIGINAL +# thread, and this cleanup removes the task records that make the promise +# reconcilable. Refuse while this home still owes a public reply for exactly this +# work. Both gates live in bin/fm-public-followup-lib.sh, so a home that never +# opted into the myfirstmate relay runs one [ -f ] test and nothing else here. +if [ "$FORCE" != "--force" ] && [ "$PUBLIC_FOLLOWUP_PARENT_UNRESOLVED" = 1 ]; then + echo "REFUSED: cannot resolve the primary home for marked secondmate $SECOND_MATE_ID; refusing cleanup without its durable parent binding." >&2 + exit 1 +fi +if [ "$FORCE" != "--force" ] \ + && [ -n "$PUBLIC_FOLLOWUP_STATE" ] \ + && [ "$PUBLIC_FOLLOWUP_RELAY_ACTIVE" = 1 ] \ + && fm_pf_has_registrations "$PUBLIC_FOLLOWUP_STATE"; then + if ! PUBLIC_FOLLOWUP_BLOCKING=$(FM_HOME="$PUBLIC_FOLLOWUP_HOME" FM_STATE_OVERRIDE="$PUBLIC_FOLLOWUP_STATE" \ + "$SCRIPT_DIR/fm-public-followup.sh" guard-work "$PUBLIC_FOLLOWUP_WORK_HOME" "$ID" 2>/dev/null); then + echo "REFUSED: task $ID still owes a public reply through the myfirstmate relay." >&2 + printf '%s\n' "$PUBLIC_FOLLOWUP_BLOCKING" >&2 + echo "Deliver it with bin/fm-public-followup.sh deliver <obligation-id>, waive it with tasks-axi public-followup waive, or use --force after explicit discard approval." >&2 + exit 1 + fi +fi + if [ "$BACKEND" = orca ] && [ "$KIND" != scout ] && [ "$KIND" != secondmate ] && [ "$FORCE" != "--force" ]; then if ! inspectable_git_worktree "$WT"; then echo "REFUSED: Orca ship task $ID has no inspectable git worktree at ${WT:-<missing>}." >&2 @@ -1204,6 +1488,22 @@ if [ -d "$WT" ] && [ "$FORCE" != "--force" ]; then fi fi +# A Herdr close may reposition shared workspace order, so the whole +# destructive sequence below (worktree return, pane close, record removal) +# runs under the named-session presentation lock, acquired BEFORE anything is +# returned or erased: a contended lock refuses here while the isolated copy, +# every durable record, and the endpoint are all still intact for a plain +# rerun. An unresolvable lock path (for example an unreachable server) also +# refuses before any destructive step. +TEARDOWN_HERDR_SESSION= +TEARDOWN_HERDR_PANE= +if [ "$BACKEND" = herdr ]; then + teardown_herdr_preflight_target "$T" "$ID" || exit 1 + fm_backend_herdr_parse_target "$T" || exit 1 + TEARDOWN_HERDR_SESSION=$FM_BACKEND_HERDR_SESSION + TEARDOWN_HERDR_PANE=$FM_BACKEND_HERDR_PANE +fi + # Best-effort: drop the local task branch so the shared repo does not accumulate refs. if [ "$BACKEND" = orca ] && [ "$KIND" != secondmate ]; then if [ "$ORCA_PATH_MATCH_VERIFIED" != 1 ]; then @@ -1218,6 +1518,7 @@ if [ "$BACKEND" = orca ] && [ "$KIND" != secondmate ]; then fi fi rm -f "$WT/.claude/settings.local.json" "$WT/.opencode/plugins/fm-turn-end.js" \ + "$WT/.opencode/plugins/fm-busy-state.js" \ "$WT/.fm-grok-turnend" "$WT/.fm-kimi-turnend" fi [ -z "$T_ORCA" ] || fm_backend_kill "$BACKEND" "$T" "$(meta_value "$META" zellij_tab_id)" "fm-$ID" 2>/dev/null || true @@ -1268,29 +1569,20 @@ if [ "$BACKEND" = herdr ] \ fi if [ "$HERDR_PRESENTATION_RETIRE_CANDIDATE" = 1 ]; then - # shellcheck source=bin/fm-wake-lib.sh - . "$SCRIPT_DIR/fm-wake-lib.sh" - HERDR_PRESENTATION_FOCUS_LOCK= - HERDR_PRESENTATION_FOCUS_LOCK_HELD=0 - HERDR_PRESENTATION_FOCUS_LOCK_ATTEMPT=0 - if HERDR_PRESENTATION_FOCUS_LOCK=$(fm_backend_herdr_presentation_session_lock_path "$HERDR_PRESENTATION_SESSION"); then - while [ "$HERDR_PRESENTATION_FOCUS_LOCK_ATTEMPT" -lt 50 ]; do - if fm_lock_try_acquire "$HERDR_PRESENTATION_FOCUS_LOCK"; then - HERDR_PRESENTATION_FOCUS_LOCK_HELD=1 - break - fi - sleep 0.1 - HERDR_PRESENTATION_FOCUS_LOCK_ATTEMPT=$((HERDR_PRESENTATION_FOCUS_LOCK_ATTEMPT + 1)) - done - fi - if [ "$HERDR_PRESENTATION_FOCUS_LOCK_HELD" = 1 ]; then + # The presentation lock was acquired before the worktree return above; a + # contended lock already refused this teardown while everything was intact. + if teardown_herdr_session_lock_held "$HERDR_PRESENTATION_SESSION"; then fm_backend_herdr_projection_close_pane_focus_preserving \ "$HERDR_PRESENTATION_SESSION" "$HERDR_PRESENTATION_PANE" 2>/dev/null || true - HERDR_PRESENTATION_FOCUS_LOCK_HELD=0 - fm_lock_release "$HERDR_PRESENTATION_FOCUS_LOCK" || true else echo "warning: herdr presentation focus lock unavailable; refusing a concurrent focus-unsafe pane close" >&2 fi +elif [ "$BACKEND" = herdr ]; then + if teardown_herdr_session_lock_held "$TEARDOWN_HERDR_SESSION"; then + fm_backend_herdr_kill_serialized "$TEARDOWN_HERDR_SESSION" "$TEARDOWN_HERDR_PANE" 2>/dev/null || true + else + echo "warning: herdr session presentation lock path is unavailable; skipping the pane close rather than closing unlocked" >&2 + fi elif [ "$BACKEND" != orca ]; then fm_backend_kill "$BACKEND" "$T" "$(meta_value "$META" zellij_tab_id)" "fm-$ID" 2>/dev/null || true fi @@ -1304,6 +1596,23 @@ elif [ "$BACKEND" = herdr ] \ && { [ -e "$HERDR_PRESENTATION_JOURNAL" ] || [ -L "$HERDR_PRESENTATION_JOURNAL" ]; }; then echo "warning: herdr presentation journal for $ID remains quarantined; no workspace cleanup was attempted" >&2 fi +# A refused, skipped, or failed Herdr close must never erase a live task's +# durable endpoint identity: unless the exact pane is confirmed gone, retain +# every record and stop before any removal below so a later rerun can retry +# the locked close. Only a structured not-found proves the pane gone; unknown +# presence, missing or malformed endpoint identity, and missing confirmation +# machinery all refuse. +if [ "$BACKEND" = herdr ]; then + fm_backend_source herdr || true + if ! declare -F fm_backend_herdr_endpoint_confirmed_gone >/dev/null 2>&1; then + echo "error: herdr endpoint confirmation is unavailable for $ID; retaining every durable task record" >&2 + exit 1 + fi + if ! fm_backend_herdr_endpoint_confirmed_gone "$T"; then + echo "error: herdr pane $T for $ID is not confirmed gone after its close was refused, skipped, or failed; retaining every durable task record - rerun teardown once the close can run under the session lock" >&2 + exit 1 + fi +fi if [ "$KIND" = secondmate ]; then [ -n "$HOME_PATH" ] || HOME_PATH=$WT remove_firstmate_home "$HOME_PATH" "secondmate home" "$ID" @@ -1316,6 +1625,7 @@ fm_backend_clear_transition "$BACKEND" "$STATE" "$T" || true # Read before the state-file rm below; empty (pre-fix tasks without tasktmp=) is a no-op. [ -n "$TASK_TMP" ] && rm -rf "$TASK_TMP" remove_pr_poll_artifacts "$STATE" "$ID" || exit 1 +retire_busy_state "$STATE" "$ID" "$BUSY_GEN" || exit 1 rm -f "$STATE/$ID.status" "$STATE/$ID.turn-ended" "$STATE/$ID.meta" \ "$STATE/$ID.pi-ext.ts" "$STATE/$ID.grok-turnend-token" \ "$STATE/$ID.kimi-turnend-token" diff --git a/bin/fm-test-isolation-proof.sh b/bin/fm-test-isolation-proof.sh index f84f8ed09ef..7c0eb75d9d3 100755 --- a/bin/fm-test-isolation-proof.sh +++ b/bin/fm-test-isolation-proof.sh @@ -121,7 +121,7 @@ exclusion_reason() { 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-send-secondmate-marker-herdr-e2e.test.sh) + fm-quota-array-dispatch-live-e2e.test.sh|fm-send-secondmate-marker-herdr-e2e.test.sh) printf '%s\n' 'live harness opt-in; never default parallel CI' ;; fm-backend-autodetect-smoke.test.sh|fm-backend-herdr-eventwait-smoke.test.sh|\ @@ -198,6 +198,7 @@ fm-afk-inject-e2e.test.sh fm-backend-herdr-smoke.test.sh fm-backend-cmux-smoke.test.sh fm-pi-primary-live-e2e.test.sh +fm-quota-array-dispatch-live-e2e.test.sh EOF } diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 71b4df9a658..4aced69d68a 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -120,7 +120,8 @@ now_ms() { # unclassified so new tests are still runnable and visible in summaries. family_for_basename() { case "$1" in - fm-arm-pretool-check.test.sh|fm-ask-user-authority.test.sh|fm-brief.test.sh|\ + fm-arm-pretool-check.test.sh|fm-ask-user-authority.test.sh|\ + fm-brief.test.sh|fm-vendor-auth-probe.test.sh|\ fm-calm-pi-extension.test.sh|fm-cd-pretool-check.test.sh|\ fm-composer-ghost.test.sh|fm-composer-lib.test.sh|\ fm-crew-state.test.sh|fm-decision-hold-lifecycle.test.sh|\ @@ -143,6 +144,7 @@ family_for_basename() { ;; fm-afk-inject-herdr-e2e.test.sh|fm-afk-launch.test.sh|fm-backend-autodetect-smoke.test.sh|\ fm-backend-herdr-eventwait-smoke.test.sh|fm-backend-herdr-presentation-e2e.test.sh|\ + fm-backend-herdr-launcher-workspace-e2e.test.sh|\ fm-backend-herdr-prune-safety-e2e.test.sh|fm-backend-herdr-respawn-idem-e2e.test.sh|\ fm-herdr-session-cleanup-e2e.test.sh|\ fm-backend-herdr-smoke.test.sh|fm-backend-herdr-workspace-per-home-e2e.test.sh) @@ -150,6 +152,7 @@ family_for_basename() { ;; fm-backlog-handoff.test.sh|fm-secondmate-harness.test.sh|fm-secondmate-lifecycle-e2e.test.sh|\ fm-secondmate-liveness.test.sh|fm-secondmate-safety.test.sh|fm-secondmate-sync.test.sh|\ + fm-startup-memory-budget.test.sh|\ fm-send-secondmate-marker.test.sh|fm-shared-captain-inheritance.test.sh) printf '%s\n' secondmate ;; @@ -161,7 +164,7 @@ 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-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) + fm-quota-array-dispatch-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|\ @@ -592,7 +595,7 @@ families_for_test_reference() { # Conservative path → family map. Over-selects rather than under-selects. # Never expands to the complete suite. families_for_changed_path() { - local path=$1 + local path=$1 fixture_ref case "$path" in tests/fm-test-run.test.sh) printf '%s\n' pure-contract-unit @@ -657,6 +660,10 @@ families_for_changed_path() { printf '%s\n' live-harness-optin printf '%s\n' afk ;; + bin/fm-startup-memory-budget.sh|bin/fm-startup-memory-budget-lib.sh) + printf '%s\n' secondmate + printf '%s\n' session-bootstrap + ;; bin/fm-secondmate*|bin/fm-home-seed.sh|bin/fm-backlog-handoff.sh|\ bin/fm-config-inherit-lib.sh|bin/fm-config-push.sh|bin/fm-shared*) printf '%s\n' secondmate @@ -671,7 +678,7 @@ families_for_changed_path() { ;; bin/fm-session-start.sh|bin/fm-bootstrap.sh|bin/fm-fleet-sync.sh|\ bin/fm-sessionstart-nudge.sh|bin/fm-tangle*|bin/fm-update.sh|\ - bin/fm-gate-refuse*|bin/fm-lock*) + bin/fm-gate-refuse*|bin/fm-lock*|bin/fm-quota-axi-lib.sh) printf '%s\n' session-bootstrap ;; bin/fm-pr-*|bin/fm-merge-local.sh|bin/fm-teardown.sh|bin/fm-review-diff.sh|\ @@ -696,10 +703,15 @@ families_for_changed_path() { bin/fm-brief.sh|bin/fm-ensure-agents-md.sh|bin/fm-crew-state.sh|\ bin/fm-decision-hold.sh|bin/fm-supervision*|bin/fm-transition-lib.sh|\ bin/fm-tmux-lib.sh|bin/fm-marker-lib.sh|bin/fm-operational-input.sh|bin/fm-tasks-axi-lib.sh|\ + bin/fm-vendor-auth-probe.sh|\ bin/fm-primary-scope-lib.sh|bin/fm-project-mode.sh|bin/fm-promote.sh|\ bin/fm-ff-lib.sh|bin/fm-gotmp*|bin/*pretool*) printf '%s\n' pure-contract-unit ;; + .agents/skills/quota-array-dispatch/SKILL.md) + printf '%s\n' pure-contract-unit + printf '%s\n' live-harness-optin + ;; .agents/skills/*/SKILL.md) printf '%s\n' pure-contract-unit ;; @@ -719,9 +731,26 @@ families_for_changed_path() { families_for_test_reference "$(basename "$path")" \ || printf '%s\n' "__unmapped__:$path" ;; + tests/fixtures/*/*) + # A fixture belongs to whichever suite reads its directory, found by the + # same reference scan used for shared helpers. Keyed on the directory + # rather than the file so adding a fixture selects the same suite. + # A removed fixture directory has no consuming suite left to select. + fixture_ref=${path#tests/fixtures/} + fixture_ref=${fixture_ref%%/*} + if [ -d "tests/fixtures/$fixture_ref" ]; then + families_for_test_reference "fixtures/$fixture_ref" \ + || printf '%s\n' "__unmapped__:$path" + fi + ;; bin/*) - families_for_test_reference "$(basename "$path")" \ - || printf '%s\n' "__unmapped__:$path" + # A deleted script has no consuming suite left to select, the same rule + # the fixture case above applies. Refusing on its absent mapping would + # make every retirement branch unable to select its changed tests. + if [ -e "$path" ]; then + families_for_test_reference "$(basename "$path")" \ + || printf '%s\n' "__unmapped__:$path" + fi ;; tests/*) printf '%s\n' "__unmapped__:$path" diff --git a/bin/fm-tmux-lib.sh b/bin/fm-tmux-lib.sh index cf8c3f7fa5e..e8284ba1e01 100755 --- a/bin/fm-tmux-lib.sh +++ b/bin/fm-tmux-lib.sh @@ -46,8 +46,14 @@ # tmux adapter does not paper over a herdr-specific shape. # # Overrides: FM_COMPOSER_IDLE_RE matches an empty composer after ghost and -# structural border stripping. FM_BUSY_REGEX globally overrides harness-scoped -# busy-footer matching (mirrors fm-watch.sh / the daemon). +# structural border stripping. FM_BUSY_REGEX overrides the rendered busy-footer +# matching used here. +# +# NOT a task-state source: task busy state is owned by bin/fm-busy-lib.sh's +# semantic contract. The matching below serves only delivery guards: the submit +# acknowledgement and the away-mode supervisor-pane busy guard. Both ask about +# the pane receiving input, not the state of a recorded worker task. Matching +# stays harness-scoped so one harness's output cannot make another read busy. # # All functions are `set -u` and `set -e` safe (guarded tmux calls, explicit # returns) so they can be sourced into either context. @@ -60,7 +66,7 @@ # shellcheck source=bin/fm-composer-lib.sh . "$(dirname -- "${BASH_SOURCE[0]}")/fm-composer-lib.sh" -# Busy footers per harness (mirror fm-watch.sh). claude/codex: "esc to +# Delivery-only rendered busy footers per harness. claude/codex: "esc to # interrupt"; opencode: "esc interrupt"; pi: "Working..."; grok: "Ctrl+c:cancel". # Claude's current spinner has a rotating glyph and word, but every active-turn # line has an ellipsis followed by a parenthesized elapsed duration. Keep this diff --git a/bin/fm-update.sh b/bin/fm-update.sh index 7a02821e065..b13f3294b45 100755 --- a/bin/fm-update.sh +++ b/bin/fm-update.sh @@ -72,13 +72,17 @@ sweep_live_secondmate_metas "$STATE" publish no # Registry backstop: a secondmate registered in data/secondmates.md but without # a live meta (e.g. between restarts) is still its persistent on-disk home. if [ -f "$SECONDMATES_MD" ]; then - while IFS= read -r line; do + while IFS= read -r line || [ -n "$line" ]; do case "$line" in "- "*) ;; *) continue ;; esac - id=$(printf '%s\n' "$line" | sed -n 's/^- \([^ ][^ ]*\) - .*/\1/p') - home=$(printf '%s\n' "$line" | sed -n 's/.*(home:[[:space:]]*\([^;]*\);.*/\1/p' | sed 's/[[:space:]]*$//') + if ! secondmate_registry_parse_line "$line"; then + echo "secondmate registry: skipped malformed entry: $line" >&2 + continue + fi + id=$SECONDMATE_REGISTRY_ID + home=$SECONDMATE_REGISTRY_HOME process_secondmate "$id" "$home" "" publish no done < "$SECONDMATES_MD" fi diff --git a/bin/fm-validation-lane.sh b/bin/fm-validation-lane.sh new file mode 100755 index 00000000000..63706df9895 --- /dev/null +++ b/bin/fm-validation-lane.sh @@ -0,0 +1,522 @@ +#!/usr/bin/env bash +# Coordinate one home-local no-mistakes validation slot without a daemon. +# Usage: fm-validation-lane.sh enqueue <task-id> +# fm-validation-lane.sh check +# fm-validation-lane.sh show +# +# State lives in state/validation-lane as fm-validation-lane-v2. It has one +# optional holder=<task-id>, one optional release=<task-id>, and zero or more +# queued=<task-id> records in FIFO order. An owner also has reservation-kind, +# reservation-run, reservation-start, reservation-state, and +# reservation-started records binding completion to run evidence observed after +# that reservation. release is the queue head reserved for delivery but not +# yet confirmed by fm-send; keeping it durable means a failed delivery is +# retried and never silently skipped. +# +# enqueue records a task, installs the authenticated watcher check, and releases +# immediately when the slot is free. check is executed through that registered +# check: it reads the holder's authoritative fm-crew-state result, frees only a +# reservation-bound terminal no-mistakes run-step, then sends the next +# reservation through fm-send. The task receiving that message starts and owns +# its own pipeline. Unavailable identity without comparable run-start evidence +# leaves a new head queued or an existing holder held instead of guessing. +# +# A failed send leaves release intact and prints one diagnostic so the watcher +# emits a check wake. This script never invokes no-mistakes, responds to its +# gates, or changes a task worktree. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +LANE="$STATE/validation-lane" +LOCK="$STATE/.validation-lane.lock" +CHECK_ID=validation-lane +CHECK="$STATE/$CHECK_ID.check.sh" +TRUST="$STATE/$CHECK_ID.check-trust" +SEND_BIN="${FM_VALIDATION_LANE_SEND_BIN:-$SCRIPT_DIR/fm-send.sh}" +CREW_STATE_BIN="${FM_VALIDATION_LANE_CREW_STATE_BIN:-$SCRIPT_DIR/fm-crew-state.sh}" +REGISTER_BIN="${FM_VALIDATION_LANE_REGISTER_BIN:-$SCRIPT_DIR/fm-check-register.sh}" + +# shellcheck source=bin/fm-pr-lib.sh +. "$SCRIPT_DIR/fm-pr-lib.sh" +# shellcheck source=bin/fm-wake-lib.sh +. "$SCRIPT_DIR/fm-wake-lib.sh" +# shellcheck source=bin/fm-gate-refuse-lib.sh +. "$SCRIPT_DIR/fm-gate-refuse-lib.sh" + +usage() { + sed -n '2,${/^#/!q;p;}' "$0" | sed 's/^# \{0,1\}//' +} + +lane_error() { + printf 'validation-lane: %s\n' "$*" >&2 +} + +require_state() { + [ -d "$STATE" ] && [ ! -L "$STATE" ] || { + lane_error "state directory is unavailable" + return 1 + } + LANE_DEVICE=$(fm_pr_file_device "$STATE") || { + lane_error "cannot read state device" + return 1 + } +} + +lane_lock() { + local tries=0 + while ! fm_lock_try_acquire "$LOCK"; do + tries=$((tries + 1)) + if [ "$tries" -ge 50 ]; then + lane_error "lock remained busy" + return 1 + fi + sleep 0.1 + done +} + +lane_unlock() { + fm_lock_release "$LOCK" || true +} + +LANE_HOLDER= +LANE_RELEASE= +LANE_RESERVATION_KIND= +LANE_RESERVATION_RUN= +LANE_RESERVATION_START= +LANE_RESERVATION_STATE= +LANE_RESERVATION_STARTED= +LANE_QUEUE=() + +reset_lane() { + LANE_HOLDER= + LANE_RELEASE= + LANE_RESERVATION_KIND= + LANE_RESERVATION_RUN= + LANE_RESERVATION_START= + LANE_RESERVATION_STATE= + LANE_RESERVATION_STARTED= + LANE_QUEUE=() +} + +clear_reservation() { + LANE_RESERVATION_KIND= + LANE_RESERVATION_RUN= + LANE_RESERVATION_START= + LANE_RESERVATION_STATE= + LANE_RESERVATION_STARTED= +} + +lane_id_seen() { # <id> + local id=$1 queued + [ "$LANE_HOLDER" = "$id" ] && return 0 + [ "$LANE_RELEASE" = "$id" ] && return 0 + for queued in "${LANE_QUEUE[@]}"; do + [ "$queued" = "$id" ] && return 0 + done + return 1 +} + +read_lane() { + local line value first=1 holder_seen=0 release_seen=0 + local reservation_kind_seen=0 reservation_run_seen=0 reservation_start_seen=0 reservation_state_seen=0 reservation_started_seen=0 + reset_lane + [ -e "$LANE" ] || return 0 + fm_pr_private_file_valid "$LANE" 600 "$LANE_DEVICE" || { + lane_error "state file is invalid" + return 1 + } + while IFS= read -r line || [ -n "$line" ]; do + if [ "$first" -eq 1 ]; then + first=0 + [ "$line" = fm-validation-lane-v2 ] || { + lane_error "state file has an unknown format" + return 1 + } + continue + fi + case "$line" in + holder=*) + [ "$holder_seen" -eq 0 ] || { lane_error "state has duplicate holder"; return 1; } + value=${line#holder=} + fm_pr_task_id_valid "$value" || { lane_error "state has invalid holder"; return 1; } + lane_id_seen "$value" && { lane_error "state repeats task $value"; return 1; } + LANE_HOLDER=$value + holder_seen=1 + ;; + release=*) + [ "$release_seen" -eq 0 ] || { lane_error "state has duplicate release"; return 1; } + value=${line#release=} + fm_pr_task_id_valid "$value" || { lane_error "state has invalid release"; return 1; } + lane_id_seen "$value" && { lane_error "state repeats task $value"; return 1; } + LANE_RELEASE=$value + release_seen=1 + ;; + reservation-kind=*) + [ "$reservation_kind_seen" -eq 0 ] || { lane_error "state has duplicate reservation kind"; return 1; } + value=${line#reservation-kind=} + case "$value" in full|coarse|absent|unavailable) ;; *) lane_error "state has invalid reservation kind"; return 1 ;; esac + LANE_RESERVATION_KIND=$value + reservation_kind_seen=1 + ;; + reservation-run=*) + [ "$reservation_run_seen" -eq 0 ] || { lane_error "state has duplicate reservation run"; return 1; } + value=${line#reservation-run=} + case "$value" in none) ;; *) [[ "$value" =~ ^[0-9a-f]{64}$ ]] || { lane_error "state has invalid reservation run"; return 1; } ;; esac + LANE_RESERVATION_RUN=$value + reservation_run_seen=1 + ;; + reservation-start=*) + [ "$reservation_start_seen" -eq 0 ] || { lane_error "state has duplicate reservation start evidence"; return 1; } + value=${line#reservation-start=} + case "$value" in none) ;; *) [[ "$value" =~ ^[0-9a-f]{64}$ ]] || { lane_error "state has invalid reservation start evidence"; return 1; } ;; esac + LANE_RESERVATION_START=$value + reservation_start_seen=1 + ;; + reservation-state=*) + [ "$reservation_state_seen" -eq 0 ] || { lane_error "state has duplicate reservation state"; return 1; } + value=${line#reservation-state=} + case "$value" in active|terminal|other) ;; *) lane_error "state has invalid reservation state"; return 1 ;; esac + LANE_RESERVATION_STATE=$value + reservation_state_seen=1 + ;; + reservation-started=*) + [ "$reservation_started_seen" -eq 0 ] || { lane_error "state has duplicate reservation start"; return 1; } + value=${line#reservation-started=} + case "$value" in 0|1) ;; *) lane_error "state has invalid reservation start"; return 1 ;; esac + LANE_RESERVATION_STARTED=$value + reservation_started_seen=1 + ;; + queued=*) + value=${line#queued=} + fm_pr_task_id_valid "$value" || { lane_error "state has invalid queue entry"; return 1; } + lane_id_seen "$value" && { lane_error "state repeats task $value"; return 1; } + LANE_QUEUE+=("$value") + ;; + *) lane_error "state has an invalid record"; return 1 ;; + esac + done < "$LANE" + [ "$first" -eq 0 ] || { lane_error "state file is empty"; return 1; } + [ -z "$LANE_HOLDER" ] || [ -z "$LANE_RELEASE" ] || { + lane_error "state has both holder and pending release" + return 1 + } + if [ -n "$LANE_HOLDER" ] || [ -n "$LANE_RELEASE" ]; then + [ "$reservation_kind_seen" -eq 1 ] && [ "$reservation_run_seen" -eq 1 ] && [ "$reservation_start_seen" -eq 1 ] \ + && [ "$reservation_state_seen" -eq 1 ] && [ "$reservation_started_seen" -eq 1 ] || { + lane_error "state has an owner without reservation evidence" + return 1 + } + if [ "$LANE_RESERVATION_KIND" = full ]; then + [ "$LANE_RESERVATION_RUN" != none ] || { lane_error "state has a full reservation without a run"; return 1; } + else + [ "$LANE_RESERVATION_RUN" = none ] || { lane_error "state has a non-full reservation with a run"; return 1; } + fi + [ -n "$LANE_HOLDER" ] || [ "$LANE_RESERVATION_STARTED" = 0 ] || { + lane_error "pending release claims a started run" + return 1 + } + elif [ "$reservation_kind_seen" -ne 0 ] || [ "$reservation_run_seen" -ne 0 ] || [ "$reservation_start_seen" -ne 0 ] \ + || [ "$reservation_state_seen" -ne 0 ] || [ "$reservation_started_seen" -ne 0 ]; then + lane_error "state has reservation evidence without an owner" + return 1 + fi +} + +write_lane() { + local tmp queued + if [ -z "$LANE_HOLDER" ] && [ -z "$LANE_RELEASE" ] && [ "${#LANE_QUEUE[@]}" -eq 0 ]; then + if [ -e "$LANE" ] || [ -L "$LANE" ]; then + fm_pr_private_file_valid "$LANE" 600 "$LANE_DEVICE" || { + lane_error "state file is invalid" + return 1 + } + rm -f -- "$LANE" || return 1 + fi + return 0 + fi + fm_pr_regular_destination_on_device_or_absent "$LANE" "$LANE_DEVICE" || { + lane_error "state destination is unavailable" + return 1 + } + umask 077 + tmp=$(mktemp "$STATE/.fm-validation-lane.XXXXXX") || return 1 + trap 'rm -f -- "${tmp:-}"' RETURN + { + printf '%s\n' fm-validation-lane-v2 + [ -z "$LANE_HOLDER" ] || printf 'holder=%s\n' "$LANE_HOLDER" + [ -z "$LANE_RELEASE" ] || printf 'release=%s\n' "$LANE_RELEASE" + if [ -n "$LANE_HOLDER" ] || [ -n "$LANE_RELEASE" ]; then + printf 'reservation-kind=%s\n' "$LANE_RESERVATION_KIND" + printf 'reservation-run=%s\n' "$LANE_RESERVATION_RUN" + printf 'reservation-start=%s\n' "$LANE_RESERVATION_START" + printf 'reservation-state=%s\n' "$LANE_RESERVATION_STATE" + printf 'reservation-started=%s\n' "$LANE_RESERVATION_STARTED" + fi + for queued in "${LANE_QUEUE[@]}"; do + printf 'queued=%s\n' "$queued" + done + } > "$tmp" || return 1 + chmod 0600 "$tmp" || return 1 + fm_pr_private_file_valid "$tmp" 600 "$LANE_DEVICE" || return 1 + fm_pr_regular_destination_on_device_or_absent "$LANE" "$LANE_DEVICE" || return 1 + mv -f -- "$tmp" "$LANE" || return 1 + tmp= + trap - RETURN +} + +install_check() { + local tmp + fm_pr_regular_destination_on_device_or_absent "$CHECK" "$LANE_DEVICE" || { + lane_error "watcher check destination is unavailable" + return 1 + } + umask 077 + tmp=$(mktemp "$STATE/.fm-validation-lane-check.XXXXXX") || return 1 + trap 'rm -f -- "${tmp:-}"' RETURN + printf '#!/usr/bin/env bash\nexec %q check\n' "$SCRIPT_DIR/fm-validation-lane.sh" > "$tmp" || return 1 + chmod 0700 "$tmp" || return 1 + [ -f "$tmp" ] && [ ! -L "$tmp" ] || return 1 + [ "$(fm_pr_file_mode "$tmp")" = 700 ] || return 1 + [ "$(fm_pr_file_device "$tmp")" = "$LANE_DEVICE" ] || return 1 + [ "$(fm_pr_file_link_count "$tmp")" = 1 ] || return 1 + mv -f -- "$tmp" "$CHECK" || return 1 + tmp= + trap - RETURN + FM_HOME="$FM_HOME" FM_STATE_OVERRIDE="$STATE" "$REGISTER_BIN" "$CHECK_ID" >/dev/null || { + lane_error "could not register watcher check" + return 1 + } +} + +remove_check() { + local path + for path in "$CHECK" "$TRUST"; do + [ ! -e "$path" ] && [ ! -L "$path" ] && continue + fm_pr_private_file_valid "$path" "$( [ "$path" = "$CHECK" ] && printf 700 || printf 600 )" "$LANE_DEVICE" || { + lane_error "watcher artifact is invalid" + return 1 + } + rm -f -- "$path" || return 1 + done +} + +refresh_check() { + if [ -n "$LANE_HOLDER" ] || [ -n "$LANE_RELEASE" ] || [ "${#LANE_QUEUE[@]}" -gt 0 ]; then + install_check + else + remove_check + fi +} + +reserve_next() { + local task + [ -n "$LANE_HOLDER" ] && return 0 + [ -n "$LANE_RELEASE" ] && return 0 + [ "${#LANE_QUEUE[@]}" -gt 0 ] || return 0 + task=${LANE_QUEUE[0]} + capture_reservation "$task" || return 1 + LANE_RELEASE=$task + LANE_QUEUE=("${LANE_QUEUE[@]:1}") +} + +CREW_OBS_STATE= +CREW_OBS_SOURCE= +CREW_OBS_KIND= +CREW_OBS_RUN= +CREW_OBS_START= + +hash_run_id() { # <run-id> + if command -v shasum >/dev/null 2>&1; then + printf '%s' "$1" | shasum -a 256 | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + printf '%s' "$1" | sha256sum | awk '{print $1}' + else + return 1 + fi +} + +read_crew_observation() { # <task-id> + local task=$1 out lines run_id run_start + CREW_OBS_STATE= + CREW_OBS_SOURCE= + CREW_OBS_KIND= + CREW_OBS_RUN=none + CREW_OBS_START=none + out=$(FM_HOME="$FM_HOME" FM_STATE_OVERRIDE="$STATE" "$CREW_STATE_BIN" --validation-lane "$task" 2>/dev/null) || { + lane_error "cannot read reservation state for $task" + return 1 + } + lines=$(printf '%s\n' "$out" | wc -l | tr -d '[:space:]') + [ "$lines" = 6 ] || { lane_error "reservation state for $task is malformed"; return 1; } + [ "$(printf '%s\n' "$out" | sed -n '1p')" = fm-crew-validation-v2 ] \ + || { lane_error "reservation state for $task has an unknown format"; return 1; } + CREW_OBS_STATE=$(printf '%s\n' "$out" | sed -n '2s/^state=//p') + CREW_OBS_SOURCE=$(printf '%s\n' "$out" | sed -n '3s/^source=//p') + CREW_OBS_KIND=$(printf '%s\n' "$out" | sed -n '4s/^run-kind=//p') + run_id=$(printf '%s\n' "$out" | sed -n '5s/^run-id=//p') + run_start=$(printf '%s\n' "$out" | sed -n '6s/^run-start=//p') + case "$CREW_OBS_STATE" in working|parked|done|blocked|paused|failed|unknown) ;; *) lane_error "reservation state for $task has an invalid state"; return 1 ;; esac + case "$CREW_OBS_SOURCE" in run-step|pane|status-log|none) ;; *) lane_error "reservation state for $task has an invalid source"; return 1 ;; esac + case "$CREW_OBS_KIND" in full|coarse|absent|unavailable) ;; *) lane_error "reservation state for $task has an invalid run kind"; return 1 ;; esac + if [ "$CREW_OBS_KIND" = full ]; then + [ -n "$run_id" ] || { lane_error "reservation state for $task is missing its run id"; return 1; } + CREW_OBS_RUN=$(hash_run_id "$run_id") || { lane_error "cannot bind reservation run for $task"; return 1; } + [[ "$CREW_OBS_RUN" =~ ^[0-9a-f]{64}$ ]] || { lane_error "cannot bind reservation run for $task"; return 1; } + else + [ -z "$run_id" ] || { lane_error "reservation state for $task has an unexpected run id"; return 1; } + fi + if [ -n "$run_start" ]; then + [[ "$run_start" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}#[1-9][0-9]*$ ]] \ + || { lane_error "reservation state for $task has invalid run-start evidence"; return 1; } + CREW_OBS_START=$(hash_run_id "$run_start") || { lane_error "cannot bind reservation start for $task"; return 1; } + [[ "$CREW_OBS_START" =~ ^[0-9a-f]{64}$ ]] || { lane_error "cannot bind reservation start for $task"; return 1; } + fi +} + +crew_observation_is_unavailable_empty() { + [ "$CREW_OBS_KIND" = unavailable ] && [ "$CREW_OBS_START" = none ] +} + +capture_reservation() { # <task-id> + read_crew_observation "$1" || return 1 + if crew_observation_is_unavailable_empty; then + lane_error "cannot reserve validation slot for $1 without comparable run evidence" + return 1 + fi + LANE_RESERVATION_KIND=$CREW_OBS_KIND + LANE_RESERVATION_RUN=$CREW_OBS_RUN + LANE_RESERVATION_START=$CREW_OBS_START + case "$CREW_OBS_SOURCE:$CREW_OBS_STATE" in + run-step:done|run-step:failed) LANE_RESERVATION_STATE=terminal ;; + run-step:*) LANE_RESERVATION_STATE=active ;; + *) LANE_RESERVATION_STATE=other ;; + esac + LANE_RESERVATION_STARTED=0 +} + +deliver_release() { + local task=$1 out rc + [ -n "$task" ] || return 0 + lane_lock || return 1 + if ! read_lane; then lane_unlock; return 1; fi + if [ "$LANE_RELEASE" != "$task" ]; then + lane_unlock + return 0 + fi + if out=$(FM_HOME="$FM_HOME" FM_STATE_OVERRIDE="$STATE" "$SEND_BIN" "$task" \ + 'Validation slot reserved. Start the no-mistakes validation pipeline now and follow its active gates.' 2>&1); then + LANE_RELEASE= + LANE_HOLDER=$task + if ! write_lane || ! refresh_check; then + lane_unlock + return 1 + fi + lane_unlock + printf 'released %s\n' "$task" + return 0 + else + rc=$? + fi + lane_unlock + out=$(printf '%s' "$out" | tr '\r\n' ' ' | cut -c1-500) + [ -n "$out" ] || out="fm-send exited $rc" + printf 'release failed for %s: %s\n' "$task" "$out" + return 0 +} + +enqueue() { # <task-id> + local task=$1 release='' reserve_ok=1 + fm_pr_task_id_valid "$task" || { lane_error "invalid task id"; return 2; } + lane_lock || return 1 + if ! read_lane; then lane_unlock; return 1; fi + if lane_id_seen "$task"; then + release=$LANE_RELEASE + if ! refresh_check; then lane_unlock; return 1; fi + lane_unlock + if [ -n "$release" ]; then + deliver_release "$release" + else + printf 'already queued %s\n' "$task" + fi + return 0 + fi + LANE_QUEUE+=("$task") + reserve_next || reserve_ok=0 + release=$LANE_RELEASE + if ! write_lane || ! refresh_check; then lane_unlock; return 1; fi + lane_unlock + [ "$reserve_ok" -eq 1 ] || return 1 + if [ -n "$release" ]; then + deliver_release "$release" + else + printf 'queued %s\n' "$task" + fi +} + +holder_terminal() { + local task=$1 identity_started=0 + read_crew_observation "$task" || return 2 + crew_observation_is_unavailable_empty && return 1 + if [ "$LANE_RESERVATION_KIND" = absent ]; then + case "$CREW_OBS_KIND" in full|coarse) identity_started=1 ;; esac + elif [ "$LANE_RESERVATION_KIND" = full ] && [ "$CREW_OBS_KIND" = full ] \ + && [ "$CREW_OBS_RUN" != "$LANE_RESERVATION_RUN" ]; then + identity_started=1 + fi + if [ "$CREW_OBS_START" != none ] && [ "$CREW_OBS_START" != "$LANE_RESERVATION_START" ]; then + identity_started=1 + fi + if [ "$identity_started" -eq 1 ]; then + LANE_RESERVATION_STARTED=1 + fi + [ "$LANE_RESERVATION_STARTED" = 1 ] \ + && [ "$CREW_OBS_SOURCE" = run-step ] \ + && { [ "$CREW_OBS_STATE" = "done" ] || [ "$CREW_OBS_STATE" = "failed" ]; } +} + +check() { + local release='' reserve_ok=1 terminal_rc + lane_lock || return 1 + if ! read_lane; then lane_unlock; return 1; fi + if [ -n "$LANE_HOLDER" ]; then + holder_terminal "$LANE_HOLDER" + terminal_rc=$? + if [ "$terminal_rc" -eq 0 ]; then + LANE_HOLDER= + clear_reservation + elif [ "$terminal_rc" -eq 2 ]; then + lane_unlock + return 1 + fi + fi + reserve_next || reserve_ok=0 + release=$LANE_RELEASE + if ! write_lane || ! refresh_check; then lane_unlock; return 1; fi + lane_unlock + [ "$reserve_ok" -eq 1 ] || return 1 + [ -z "$release" ] || deliver_release "$release" +} + +show() { + lane_lock || return 1 + if ! read_lane; then lane_unlock; return 1; fi + printf 'holder=%s\nrelease=%s\n' "${LANE_HOLDER:--}" "${LANE_RELEASE:--}" + printf 'queued=%s\n' "${LANE_QUEUE[@]:--}" + lane_unlock +} + +case "${1:-}" in + -h|--help) usage; exit 0 ;; +esac + +fm_refuse_if_gate_agent +require_state || exit 1 +case "${1:-}" in + enqueue) [ "$#" -eq 2 ] || { usage >&2; exit 2; }; enqueue "$2" ;; + check) [ "$#" -eq 1 ] || { usage >&2; exit 2; }; check ;; + show) [ "$#" -eq 1 ] || { usage >&2; exit 2; }; show ;; + *) usage >&2; exit 2 ;; +esac diff --git a/bin/fm-vendor-auth-probe.sh b/bin/fm-vendor-auth-probe.sh new file mode 100755 index 00000000000..1593fe7ae4b --- /dev/null +++ b/bin/fm-vendor-auth-probe.sh @@ -0,0 +1,200 @@ +#!/usr/bin/env bash +# fm-vendor-auth-probe.sh - one hard-bounded, non-destructive authentication +# probe of a named vendor CLI. +# +# This script collects a FACT and renders no verdict. It takes no harness, model, +# or provider, reads no quota, and never decides whether a dispatch candidate is +# eligible. The dispatching first mate owns that judgment from `quota-axi`'s data +# plus each harness's authoritative model catalog; the decision procedure is +# owned once by .agents/skills/quota-array-dispatch/SKILL.md. +# +# Why it exists rather than the agent running the vendor CLI itself: the +# captain's 2026-07-30 `firstmate-grok-auth-preflight` decision approved exactly +# one bounded, non-interactive probe, and that safety envelope must not depend on +# agent memory. It is enforced here deterministically: +# - the argv is fixed in this file and never composed from input, so no caller +# can turn the probe into a login, logout, or interactive TUI launch; +# - stdin is closed, so caller input can never reach the vendor CLI; +# - a hard positive timeout bounds every command, so a hung CLI cannot wedge an +# intake; +# - raw vendor output is classified here and never printed, logged, or passed +# in an argument. +# +# The probe registry is a fixed-argv safety allowlist, not a routing table. It +# carries no harness, model, provider, credential-store, or provider-family +# relationship, and asking for a probe is always the caller's own explicit +# decision. A probe is registered only after its non-destructive discovery +# command and its output discriminators are verified first-hand and recorded in +# docs/verification/dispatch-auth.md. +# +# Registered probes: +# grok `grok models` - the standalone Grok Build CLI. Verified on grok +# 0.2.117: the command exits 0 in BOTH the authenticated and the +# unauthenticated case, so only the literal first stdout line +# discriminates and the exit status is never a verdict. +# +# Output: exactly one sanitized `key=value` line on stdout. No token, refresh +# token, header, path, length, prefix, hash, or raw vendor output is ever +# printed, logged, or passed in an argument. +# +# probe= the requested probe name +# status= authenticated | unauthenticated | indeterminate | +# timeout | unavailable +# version= the probed CLI's version, or none +# versionVerified= yes | no | none - whether the running CLI matches the +# version whose discriminator strings were verified +# +# `status` is evidence, never eligibility. Only `authenticated` and +# `unauthenticated` are ground truth. `indeterminate`, `timeout`, and +# `unavailable` mean the probe established nothing and must never be read as +# either outcome; unrecognized output is `indeterminate`, never authenticated. +# +# Exit status: 0 whenever the line is printed, 2 on a usage error. The exit +# status deliberately does not encode the probe result, because this script +# renders no verdict for a caller to branch on. +# +# Usage: +# fm-vendor-auth-probe.sh <probe> +# +# Environment: +# FM_VENDOR_AUTH_PROBE_TIMEOUT hard per-command bound in seconds; must be a +# positive integer, otherwise the default 20 is +# used. Zero is rejected because `timeout 0` and +# `alarm 0` both mean "no deadline". +set -u + +VERIFIED_GROK_VERSION=0.2.117 + +usage() { + cat <<'EOF' +fm-vendor-auth-probe.sh - one hard-bounded, non-destructive authentication probe +of a named vendor CLI. It collects a fact and renders no verdict: it takes no +harness, model, or provider, reads no quota, and never decides dispatch +eligibility. The dispatching first mate owns that judgment. + +Usage: + fm-vendor-auth-probe.sh <probe> + +Registered probes: + grok `grok models` on the standalone Grok Build CLI + +Prints one sanitized key=value line: probe, status, version, versionVerified. + +status is evidence, never eligibility: + authenticated the vendor CLI reports an authenticated session + unauthenticated the vendor CLI reports no authenticated session + indeterminate output the verified discriminators do not cover + timeout the hard bound was hit + unavailable the vendor CLI is not on PATH +Only authenticated and unauthenticated are ground truth; the other three +establish nothing and must never be read as either outcome. + +The argv is fixed in the script, stdin is closed, and raw vendor output is never +printed. Login, logout, and the interactive TUI are never invoked. + +Exit status: 0 whenever the line is printed, 2 on a usage error. + +Environment: + FM_VENDOR_AUTH_PROBE_TIMEOUT hard per-command bound in seconds (default 20); + a non-positive or non-numeric value is rejected + in favor of the default +EOF +} + +die_usage() { + printf 'fm-vendor-auth-probe: %s\n' "$1" >&2 + printf 'usage: fm-vendor-auth-probe.sh <probe> (registered probes: grok)\n' >&2 + exit 2 +} + +PROBE= +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) usage; exit 0 ;; + -*) die_usage "unknown option: $1" ;; + *) + [ -z "$PROBE" ] || die_usage "only one probe may be requested at a time" + PROBE=$1 + shift + ;; + esac +done + +[ -n "$PROBE" ] || die_usage "a probe name is required" + +# A non-positive bound is not a bound: `timeout 0` and the Perl fallback's +# `alarm 0` both disable the deadline, so a hung vendor CLI would run unbounded. +TIMEOUT=${FM_VENDOR_AUTH_PROBE_TIMEOUT:-20} +case "$TIMEOUT" in + ''|*[!0-9]*|0*) TIMEOUT=20 ;; +esac + +# Bounded execution, mirroring bin/fm-fleet-snapshot.sh's run_timed selection so +# a macOS host without coreutils still gets a hard bound instead of an unbounded +# vendor CLI call. Exit 124 means the bound was hit. +run_timed() { # <seconds> <command...> + local seconds=$1 + shift + if command -v timeout >/dev/null 2>&1; then + timeout "$seconds" "$@" + elif command -v gtimeout >/dev/null 2>&1; then + gtimeout "$seconds" "$@" + elif command -v perl >/dev/null 2>&1; then + perl -e 'my $t = shift; my $pid = fork; die "fork failed" unless defined $pid; if (!$pid) { setpgrp(0, 0); exec @ARGV } local $SIG{ALRM} = sub { kill "TERM", -$pid; select undef, undef, undef, 0.2; kill "KILL", -$pid; exit 124 }; alarm $t; waitpid $pid, 0; exit($? >> 8)' "$seconds" "$@" + else + return 124 + fi +} + +STATUS=unavailable +VERSION=none +VERSION_VERIFIED=none + +emit() { + printf 'probe=%s status=%s version=%s versionVerified=%s\n' \ + "$PROBE" "$STATUS" "$VERSION" "$VERSION_VERIFIED" + exit 0 +} + +# The two argv forms below are literals in this file. Nothing the caller supplies +# reaches the vendor CLI's argv or stdin. +grok_version() { + local output + output=$(run_timed "$TIMEOUT" grok --version 2>/dev/null </dev/null) || { printf 'none\n'; return 0; } + printf '%s\n' "$output" | sed -nE 's/.*[^0-9]([0-9]+\.[0-9]+\.[0-9]+).*/\1/p' | head -n 1 | grep . || printf 'none\n' +} + +probe_grok() { + local output first rc=0 + output=$(run_timed "$TIMEOUT" grok models 2>/dev/null </dev/null) || rc=$? + if [ "$rc" -eq 124 ]; then + printf 'timeout\n' + return 0 + fi + # The exit status is deliberately ignored: grok 0.2.117 exits 0 in both the + # authenticated and unauthenticated cases, so only the first stdout line + # discriminates. Raw output is classified here and never printed. + first=$(printf '%s\n' "$output" | head -n 1) + case "$first" in + "You are logged in with "*) printf 'authenticated\n' ;; + "You are not authenticated."*) printf 'unauthenticated\n' ;; + *) printf 'indeterminate\n' ;; + esac +} + +case "$PROBE" in + grok) + command -v grok >/dev/null 2>&1 || emit + VERSION=$(grok_version) + if [ "$VERSION" = "$VERIFIED_GROK_VERSION" ]; then + VERSION_VERIFIED=yes + else + VERSION_VERIFIED=no + fi + STATUS=$(probe_grok) + emit + ;; + *) + die_usage "no probe is registered for '$PROBE'" + ;; +esac diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index f9c5cf94768..385eed62b9c 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -79,6 +79,8 @@ mkdir -p "$STATE" # the harness session holding state/.lock. Sourcing is side-effect free. # shellcheck source=bin/fm-session-lock-lib.sh . "$SCRIPT_DIR/fm-session-lock-lib.sh" +# shellcheck source=bin/fm-busy-lib.sh +. "$SCRIPT_DIR/fm-busy-lib.sh" WATCH_LOCK="$STATE/.watch.lock" WATCH_PATH="$SCRIPT_DIR/fm-watch.sh" @@ -113,14 +115,9 @@ CHECK_TIMEOUT=${FM_CHECK_TIMEOUT:-30} # seconds allowed per *.check.sh SIGNAL_GRACE=${FM_SIGNAL_GRACE:-30} # seconds to linger after a signal so trailing # signals (a status write, then the same turn's # turn-end hook) coalesce into one wake -# Busy signatures are selected by recorded harness unless FM_BUSY_REGEX globally -# overrides them. -# claude/codex: "esc to interrupt"; opencode: "esc interrupt"; pi: "Working..."; -# grok: "Ctrl+c:cancel". Claude's current spinner signature is matched only for -# a recorded Claude task because an ellipsis followed by elapsed time is not a -# safe shared signature for arbitrary harness output. Kimi's moon-plus-middot -# spinner signature is likewise matched only for a recorded Kimi task. -BUSY_REGEX=${FM_BUSY_REGEX:-'esc (to )?interrupt|Working\.\.\.|Ctrl\+c:cancel'} +# Busy state is decided by the semantic contract in bin/fm-busy-lib.sh, which +# is the single owner of per-harness sources, source attribution, and the one +# remaining rendered-text fallback (Grok only). # Always-on wake triage: most wakes during a long crew validation are benign (a # working: note or turn-end while a pipeline runs, a no-change heartbeat). Rather # than wake firstmate's LLM for each, this watcher classifies every wake in bash @@ -183,29 +180,24 @@ hash_pane() { if command -v md5 >/dev/null 2>&1; then md5 -q; else md5sum | cut -d' ' -f1; fi } -# window_is_busy: 0 (busy) iff the task's harness is actively working. Prefers -# a backend's native semantic busy state (fm_backend_busy_state - herdr's -# agent.get; herdr-addendum "busy state" row, "the first backend where -# fm_session_busy_state gets real semantics"); when the backend reports unknown, -# falls back to the recorded harness's verified pane-tail signature. <tail40> is -# the same bounded capture already read for hashing, so this adds no extra -# backend calls on the regex-fallback path. +# window_is_busy: 0 (busy) iff the task's harness is PROVABLY working, through +# the semantic busy-state contract (bin/fm-busy-lib.sh). Only an exact busy +# verdict returns 0: idle, unknown, and dead all return 1, so a converted +# adapter whose semantic state is missing, malformed, stale, or unverified is +# treated as not-provably-working and surfaces rather than being absorbed. +# <tail40> is the same bounded capture already read for hashing and is +# consumed only by the Grok-scoped fallback inside the contract. window_is_busy() { # <window> <tail40> - local w=$1 tail40=$2 bs harness lines - bs=$(fm_backend_busy_state "$(window_backend "$w")" "$w" 2>/dev/null) - case "$bs" in - busy) return 0 ;; - idle) return 1 ;; - *) - lines=$(printf '%s' "$tail40" | grep -v '^[[:space:]]*$' | tail -12) - harness=$(window_harness "$w") - if [ -n "${FM_BUSY_REGEX:-}" ]; then - printf '%s' "$lines" | grep -qiE "$BUSY_REGEX" - else - printf '%s' "$lines" | fm_busy_lines_match "$harness" - fi - ;; - esac + local w=$1 tail40=$2 task meta verdict + task=$(window_to_task "$w" "$STATE") + meta="$STATE/$task.meta" + if [ -n "$task" ] && [ -f "$meta" ]; then + verdict=$(fm_busy_classify_meta "$meta" "$task" "$STATE" "$tail40") + else + verdict=$(fm_busy_classify "$(window_backend "$w")" "$w" "$(window_harness "$w")" \ + "${task:-unknown}" "$STATE" "$tail40") + fi + [ "${verdict%% *}" = busy ] } window_kind() { @@ -475,7 +467,7 @@ run_check_process() { exec gtimeout "$CHECK_TIMEOUT" bash "$c" "$@" else # shellcheck disable=SC2016 # single quotes are deliberate: Perl expands its own variables. - exec perl -e 'my $t = shift; my $owned = shift; my $pid = fork; die "fork failed" unless defined $pid; if (!$pid) { setpgrp(0, 0) unless $owned; exec @ARGV } my $group = $owned ? getpgrp(0) : $pid; my $stop = sub { $SIG{HUP} = $SIG{INT} = $SIG{TERM} = "IGNORE"; kill "TERM", -$group; select undef, undef, undef, 0.2; kill "KILL", -$group; waitpid $pid, 0; exit 124 }; local $SIG{ALRM} = $stop; local $SIG{HUP} = $stop; local $SIG{INT} = $stop; local $SIG{TERM} = $stop; alarm $t; waitpid $pid, 0; exit($? >> 8)' "$CHECK_TIMEOUT" "${FM_CHECK_OWNED_GROUP:-0}" bash "$c" "$@" + exec perl -e 'my $t = shift; my $owned = shift; my $pid = fork; die "fork failed" unless defined $pid; if (!$pid) { setpgrp(0, 0) unless $owned; exec @ARGV } my $group = $owned ? getpgrp(0) : $pid; my $stop = sub { $SIG{HUP} = $SIG{INT} = $SIG{TERM} = "IGNORE"; kill "TERM", -$group; select undef, undef, undef, 0.2; kill "KILL", -$group; waitpid $pid, 0; exit 124 }; local $SIG{ALRM} = $stop; local $SIG{HUP} = $stop; local $SIG{INT} = $stop; local $SIG{TERM} = $stop; alarm $t; waitpid $pid, 0; my $status = $?; exit 125 if $status == -1; exit(128 + ($status & 127)) if $status & 127; exit($status >> 8)' "$CHECK_TIMEOUT" "${FM_CHECK_OWNED_GROUP:-0}" bash "$c" "$@" fi } @@ -486,12 +478,17 @@ run_check() { FM_ACTIVE_CHECK_PID= FM_ACTIVE_CHECK_PGID= FM_CHECK_OUTPUT= +FM_CHECK_ERROR_OUTPUT= FM_CHECK_RESULT= +FM_CHECK_ERROR= +FM_CHECK_STATUS=0 FM_CHECK_SIGNAL_PENDING= fm_check_output_cleanup() { [ -z "$FM_CHECK_OUTPUT" ] || rm -f -- "$FM_CHECK_OUTPUT" + [ -z "$FM_CHECK_ERROR_OUTPUT" ] || rm -f -- "$FM_CHECK_ERROR_OUTPUT" FM_CHECK_OUTPUT= + FM_CHECK_ERROR_OUTPUT= } fm_active_check_stop() { @@ -520,15 +517,21 @@ fm_active_check_stop() { } run_check_capture() { - local pgid + local pgid check_status=0 fm_check_output_cleanup FM_CHECK_RESULT= + FM_CHECK_ERROR= + FM_CHECK_STATUS=0 FM_CHECK_OUTPUT=$(mktemp "$STATE/.fm-check-output.XXXXXX") || return 1 - chmod 0600 "$FM_CHECK_OUTPUT" || { fm_check_output_cleanup; return 1; } + FM_CHECK_ERROR_OUTPUT=$(mktemp "$STATE/.fm-check-error.XXXXXX") \ + || { fm_check_output_cleanup; return 1; } + chmod 0600 "$FM_CHECK_OUTPUT" "$FM_CHECK_ERROR_OUTPUT" \ + || { fm_check_output_cleanup; return 1; } FM_CHECK_SIGNAL_PENDING= trap 'FM_CHECK_SIGNAL_PENDING=1' HUP INT TERM set -m - ( FM_CHECK_OWNED_GROUP=1 run_check_process "$@" ) > "$FM_CHECK_OUTPUT" 2>/dev/null & + ( FM_CHECK_OWNED_GROUP=1 run_check_process "$@" ) \ + > "$FM_CHECK_OUTPUT" 2> "$FM_CHECK_ERROR_OUTPUT" & FM_ACTIVE_CHECK_PID=$! FM_ACTIVE_CHECK_PGID=$FM_ACTIVE_CHECK_PID set +m @@ -540,13 +543,48 @@ run_check_capture() { return 1 fi [ -z "$FM_CHECK_SIGNAL_PENDING" ] || exit 1 - wait "$FM_ACTIVE_CHECK_PID" 2>/dev/null || true + wait "$FM_ACTIVE_CHECK_PID" 2>/dev/null || check_status=$? FM_ACTIVE_CHECK_PID= fm_active_check_stop || return 1 + FM_CHECK_STATUS=$check_status FM_CHECK_RESULT=$(cat "$FM_CHECK_OUTPUT" 2>/dev/null || true) + FM_CHECK_ERROR=$(head -c 512 "$FM_CHECK_ERROR_OUTPUT" 2>/dev/null \ + | tr '\r\n\t' ' ' \ + | sed 's/[[:space:]][[:space:]]*/ /g; s/^ //; s/ $//' || true) fm_check_output_cleanup } +fm_check_failure_reason() { + local check=$1 status=$2 detail=$3 + [ -n "$detail" ] || detail="no stderr" + printf 'check: %s failed (exit %s): %s' "$check" "$status" "$detail" +} + +FM_VALIDATION_LANE_EVENT_RESULT= +FM_VALIDATION_LANE_EVENT_ERROR= +FM_VALIDATION_LANE_EVENT_STATUS=0 +run_validation_lane_event_check() { + local c="$STATE/validation-lane.check.sh" custom_snapshot + FM_VALIDATION_LANE_EVENT_RESULT= + FM_VALIDATION_LANE_EVENT_ERROR= + FM_VALIDATION_LANE_EVENT_STATUS=0 + [ -e "$c" ] || return 0 + if ! fm_custom_check_snapshot_prepare "$STATE" validation-lane; then + fm_custom_check_snapshot_cleanup + return 2 + fi + custom_snapshot=$FM_CUSTOM_CHECK_SNAPSHOT + run_check_capture "$custom_snapshot" || { + fm_custom_check_snapshot_cleanup + return 1 + } + FM_VALIDATION_LANE_EVENT_RESULT=$FM_CHECK_RESULT + FM_VALIDATION_LANE_EVENT_ERROR=$FM_CHECK_ERROR + FM_VALIDATION_LANE_EVENT_STATUS=$FM_CHECK_STATUS + fm_custom_check_snapshot_cleanup + [ "$FM_VALIDATION_LANE_EVENT_STATUS" -eq 0 ] || return 3 +} + # Surfaced-marker bookkeeping for the heartbeat backstop is owned by # fm-push-transition-lib.sh because push and poll paths must write one format. # Mark every current captain-relevant status as surfaced. Called after the @@ -770,6 +808,7 @@ while :; do for c in "$STATE"/*.check.sh; do [ -e "$c" ] || continue is_pr_poll=0 + id= if [ "$(basename "$c")" = x-watch.check.sh ]; then if fmx_poll_shim_valid "$c" "$FM_HOME" "$FM_ROOT" \ && [ -f "$FM_ROOT/bin/fm-x-poll.sh" ] && [ ! -L "$FM_ROOT/bin/fm-x-poll.sh" ]; then @@ -802,6 +841,12 @@ while :; do continue fi fi + if [ "$id" = validation-lane ] && [ "$FM_CHECK_STATUS" -ne 0 ]; then + reason=$(fm_check_failure_reason "$c" "$FM_CHECK_STATUS" "$FM_CHECK_ERROR") + fm_wake_append check "$c" "$reason" || exit 1 + touch "$STATE/.last-check" + wake "$reason" + fi if [ -n "$out" ]; then reason="check: $c: $out" fm_wake_append check "$c" "$reason" || exit 1 @@ -833,12 +878,18 @@ while :; do # signature for an already-pending file (last write wins below). pending=$(scan_signals) if [ -n "$pending" ]; then + terminal_status_event=0 sleep "$SIGNAL_GRACE" pending=$(printf '%s\n%s' "$pending" "$(scan_signals)") files="" while IFS=$(printf '\t') read -r sf sig f; do [ -n "$sf" ] || continue case " $files " in *" $f "*) ;; *) files="$files $f" ;; esac + case "$f" in + *.status) + status_is_terminal_verb "$(last_status_line "$f")" && terminal_status_event=1 + ;; + esac done <<EOF $pending EOF @@ -858,6 +909,30 @@ EOF # ordering evaluates it ONLY for a non-afk, no-captain-verb signal. # shellcheck disable=SC2086 # $files is a space-separated status-path list (ids carry no spaces) if afk_present || signal_reason_is_actionable $files || ! signal_crew_provably_working $files; then + if [ "$terminal_status_event" -eq 1 ]; then + terminal_check_rc=0 + run_validation_lane_event_check || terminal_check_rc=$? + check_path="$STATE/validation-lane.check.sh" + case "$terminal_check_rc" in + 0) ;; + 2) + reason="check: rejected unauthenticated state checks: $check_path" + fm_wake_append check unauthenticated-state-checks "$reason" || exit 1 + wake "$reason" + ;; + 3) + reason=$(fm_check_failure_reason "$check_path" \ + "$FM_VALIDATION_LANE_EVENT_STATUS" "$FM_VALIDATION_LANE_EVENT_ERROR") + fm_wake_append check "$check_path" "$reason" || exit 1 + wake "$reason" + ;; + *) + reason="check: $check_path failed before execution completed" + fm_wake_append check "$check_path" "$reason" || exit 1 + wake "$reason" + ;; + esac + fi while IFS=$(printf '\t') read -r sf sig f; do [ -n "$sf" ] || continue fm_wake_append signal "$(basename "$f")" "$reason" || exit 1 @@ -871,6 +946,14 @@ EOF done <<EOF $pending EOF + if [ "$terminal_status_event" -eq 1 ]; then + if [ -n "$FM_VALIDATION_LANE_EVENT_RESULT" ]; then + check_path="$STATE/validation-lane.check.sh" + reason="check: $check_path: $FM_VALIDATION_LANE_EVENT_RESULT" + fm_wake_append check "$check_path" "$reason" || exit 1 + wake "$reason" + fi + fi wake "$reason" else while IFS=$(printf '\t') read -r sf sig f; do @@ -981,8 +1064,8 @@ EOF # - paused: the crew declared an external wait, or a declared pause or # captain hold is paired with a confidently dead agent, so absorb on # the long PAUSE_RESURFACE_SECS cadence instead of wedge-escalating; - # - none: no running pipeline, idle pane, no busy signature, no declared - # pause - the crew has STOPPED. Surface immediately so firstmate peeks + # - none: no running pipeline, no exact busy verdict, no declared pause. + # Surface immediately so firstmate inspects the inconclusive state # (it may be done via an interactive menu that wrote no done: status, # waiting on a decision, or wedged) instead of leaving the finish to # wait out the timer. diff --git a/bin/fm-x-followup.sh b/bin/fm-x-followup.sh index 603678cfca0..9a51c3883b6 100755 --- a/bin/fm-x-followup.sh +++ b/bin/fm-x-followup.sh @@ -17,6 +17,11 @@ # exit 1, silent -> not linked, or window/cap exhausted (link # pruned) # +# Clear a legacy link without posting: +# fm-x-followup.sh --clear <task-id> +# idempotently removes only the X follow-up metadata for a typed terminal +# outcome. +# # Post (after composing the reply to a file or stdin): # fm-x-followup.sh <task-id> [--image <path>] [--final] --text-file <path> # fm-x-followup.sh <task-id> [--image <path>] [--final] - @@ -65,12 +70,13 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" . "$SCRIPT_DIR/fm-x-lib.sh" usage() { - echo "usage: fm-x-followup.sh --check <task-id> | <task-id> [--image <path>] [--final] --text-file <path> | <task-id> [--image <path>] [--final] -" >&2 + echo "usage: fm-x-followup.sh --check <task-id> | --clear <task-id> | <task-id> [--image <path>] [--final] --text-file <path> | <task-id> [--image <path>] [--final] -" >&2 } help() { cat <<'EOF' usage: fm-x-followup.sh --check <task-id> + fm-x-followup.sh --clear <task-id> fm-x-followup.sh <task-id> [--image <path>] [--final] --text-file <path> fm-x-followup.sh <task-id> [--image <path>] [--final] - @@ -79,6 +85,7 @@ X-mode-linked task and manage the link's follow-up counter. Options: --check Print the request_id when a follow-up is due. + --clear Clear only the X follow-up link; never post. --image <path> Attach one local image file; threaded replies attach it to the opener tweet or message. --final Clear the link after this post regardless of the remaining count. --text-file <path> @@ -108,7 +115,11 @@ case "${1:-}" in esac FINAL=0 -if [ "${1:-}" = --check ]; then +if [ "${1:-}" = --clear ]; then + MODE=clear + ID=${2:-} + if [ -z "$ID" ] || [ "$#" -gt 2 ]; then usage; exit 2; fi +elif [ "${1:-}" = --check ]; then MODE=check ID=${2:-} if [ -z "$ID" ] || [ "$#" -gt 2 ]; then usage; exit 2; fi @@ -144,6 +155,13 @@ case "$ID" in esac META="$STATE/$ID.meta" +if [ "$MODE" = clear ]; then + fmx_meta_link_clear "$META" \ + || { echo "fm-x-followup: could not clear the link in state/$ID.meta" >&2; exit 1; } + printf '%s\n' "$ID" + exit 0 +fi + RID=$(fmx_meta_get "$META" x_request) TS=$(fmx_meta_get "$META" x_request_ts) COUNT=$(fmx_meta_get "$META" x_followups) diff --git a/bin/fm-x-poll.sh b/bin/fm-x-poll.sh index fb12e769cf8..31cf97537e4 100755 --- a/bin/fm-x-poll.sh +++ b/bin/fm-x-poll.sh @@ -18,6 +18,14 @@ # "x-mention <request_id>" (which becomes the watcher wake payload), and # only then commit that marker as emitted # a request_id with an emitted wake marker -> print nothing, exit 0 +# a new set of unreconciled public-followup terminal results -> print one +# "public-followup ..." line BEFORE the relay call, so a promised final +# reply is surfaced through this same wake path +# +# The public-followup line rides here rather than on a new poll of its own: this +# check only exists in a home that opted into the relay, and it is an O(1) +# directory presence test plus a signature compare, with no tasks-axi call and no +# backlog scan. A home with no pending terminal results pays nothing for it. # The full object is stashed verbatim, so any conversation context the relay # includes (in_reply_to: {author_handle, text}, null for a fresh mention) is # preserved for fmx-respond to handle follow-ups with continuity. The durable @@ -33,13 +41,30 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" -# shellcheck source=bin/fm-x-lib.sh -. "$SCRIPT_DIR/fm-x-lib.sh" +# shellcheck source=bin/fm-public-followup-lib.sh +# Also brings in bin/fm-x-lib.sh, which this script's relay client uses. +. "$SCRIPT_DIR/fm-public-followup-lib.sh" fmx_load_config # Hard no-op when X mode is off: this is what keeps the check shim inert. [ -n "$FMX_TOKEN" ] || exit 0 +# Unreconciled terminal results for a public commitment are actionable even when +# the relay has no new mention, and they outlive any session, so surface them +# first. The signature compare keeps this to one wake per new result set instead +# of one per cycle; bin/fm-public-followup.sh consume clears it. +if fm_pf_has_events "$STATE"; then + PF_ROOT=$(fm_pf_root "$STATE") + PF_SIG=$(fm_pf_events_signature "$STATE" 2>/dev/null) || PF_SIG= + if [ -n "$PF_SIG" ] \ + && [ "$(cat "$PF_ROOT/$FM_PF_SURFACED_BASENAME" 2>/dev/null || true)" != "$PF_SIG" ]; then + if printf '%s\n' "$PF_SIG" \ + | fmx_private_artifact_publish_stdin "$PF_ROOT" "$FM_PF_SURFACED_BASENAME" 600 2>/dev/null; then + printf 'public-followup terminal results are waiting to be reconciled\n' + fi + fi +fi + ERROR_FILE="$STATE/x-poll.error" CLAIM_ERROR_FILE="$STATE/x-poll.claim-error" diff --git a/bin/fm-x-reply.sh b/bin/fm-x-reply.sh index eee0c179f59..b5e2e161bc9 100755 --- a/bin/fm-x-reply.sh +++ b/bin/fm-x-reply.sh @@ -5,6 +5,13 @@ # fm-x-reply.sh <request_id> [--image <path>] --text-file <path> # fm-x-reply.sh <request_id> [--image <path>] - # fm-x-reply.sh <request_id> --followup [--image <path>] ... +# fm-x-reply.sh <request_id> ... --receipt-file <path> +# +# --receipt-file <path> writes {request_id, endpoint, chunks, dry_run} to <path> +# after the reply lands, so a caller that must record HOW MANY messages were +# posted (bin/fm-public-followup.sh, building a typed delivery receipt) does not +# have to re-derive the split. Omitted by default and never written on failure, +# so stdout, exit codes, and every existing caller stay unchanged. # # The --text-file / stdin forms exist so a caller never has to inline reply text # (which may be influenced by a public mention) into a shell command, where shell @@ -125,21 +132,35 @@ release_answer_claim() { return 1 } +# write_reply_receipt <chunks> <dry-run-0|1>: record what this reply actually +# sent, for a caller that has to build a typed delivery receipt. Only ever called +# on success. A write failure is reported but never changes the exit status: the +# reply already landed, and claiming otherwise would invite a duplicate post. +write_reply_receipt() { + [ -n "$RECEIPT_FILE" ] || return 0 + if ! (umask 077; jq -n --arg r "$REQ" --arg e "$ENDPOINT" --argjson c "$1" --argjson d "$2" \ + '{request_id:$r, endpoint:$e, chunks:$c, dry_run:($d == 1)}' > "$RECEIPT_FILE"); then + echo "fm-x-reply: warning: posted but could not write the receipt to $RECEIPT_FILE" >&2 + fi +} + usage() { - echo "usage: fm-x-reply.sh <request_id> [--followup] [--image <path>] <text> | [--followup] [--image <path>] --text-file <path> | [--followup] [--image <path>] -" >&2 + echo "usage: fm-x-reply.sh <request_id> [--followup] [--image <path>] [--receipt-file <path>] <text> | ... --text-file <path> | ... -" >&2 } help() { cat <<'EOF' -usage: fm-x-reply.sh <request_id> [--followup] [--image <path>] <text> - fm-x-reply.sh <request_id> [--followup] [--image <path>] --text-file <path> - fm-x-reply.sh <request_id> [--followup] [--image <path>] - +usage: fm-x-reply.sh <request_id> [--followup] [--image <path>] [--receipt-file <path>] <text> + fm-x-reply.sh <request_id> [--followup] [--image <path>] [--receipt-file <path>] --text-file <path> + fm-x-reply.sh <request_id> [--followup] [--image <path>] [--receipt-file <path>] - Post a public-safe X-mode answer to the relay, or a completion follow-up with --followup. Options: --followup POST to /connector/followup instead of /connector/answer. --image <path> Attach one local image file; threaded replies attach it to the opener tweet or message. + --receipt-file <path> + After a successful reply, write {request_id, endpoint, chunks, dry_run} to <path>. --text-file <path> Read reply text from a file instead of the command line. - Read reply text from stdin. @@ -164,6 +185,7 @@ shift # the answer path always has. FOLLOWUP=0 IMAGE_PATH= +RECEIPT_FILE= ARGS=() while [ "$#" -gt 0 ]; do case "$1" in @@ -177,6 +199,15 @@ while [ "$#" -gt 0 ]; do fi IMAGE_PATH=$1 ;; + --receipt-file) + shift + if [ "$#" -lt 1 ] || [ -z "$1" ]; then + echo "fm-x-reply: missing --receipt-file path" >&2 + usage + exit 2 + fi + RECEIPT_FILE=$1 + ;; *) ARGS+=("$1") ;; esac shift @@ -329,6 +360,7 @@ if [ -n "$FMX_DRY" ]; then "$N" "$FMX_RELAY" "$ENDPOINT" "$REQ" >&2 printf '%s' "$CHUNKS" | jq -r '.[]' | while IFS= read -r __chunk; do printf ' %s\n' "$__chunk" >&2; done fi + write_reply_receipt "$N" 1 printf '%s\n' "$REQ" exit 0 fi @@ -411,6 +443,7 @@ case "$code" in fmx_context_registry_set "$STATE" "$REQ" "$REQ_PLATFORM" "$REQ_EXPLICIT_MAX" 1 2>/dev/null \ || echo "fm-x-reply: warning: could not retain reply context for $REQ" >&2 fi + write_reply_receipt "$N" 0 printf '%s\n' "$REQ" ;; 409) diff --git a/docs/architecture.md b/docs/architecture.md index 12738123099..07c2ff5cd2d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,12 +17,12 @@ When a canonical validated PR poll returns exactly `merged`, the watcher appends The receipt makes retirement safely retryable across restarts: fixed-path recovery revalidates the same evidence, removes the runnable check first, removes its registration and data sidecars, removes the receipt last, and preserves task metadata including `pr=` and `pr_head=`. A concurrent replacement remains armed, every non-merged or invalid observation remains unchanged, and retirement never performs task or persistent-secondmate cleanup. `bin/fm-pr-lib.sh` owns the receipt format and strict identity mechanics, while `bin/fm-watch.sh` owns queue-before-retirement ordering. -No-verb wakes, such as `working:` notes and bare turn-ended signals, are benign only when `bin/fm-crew-state.sh` reports positive evidence that the crew is still working: an actively running no-mistakes step attributed to that crew's current code or a backend busy signature. +No-verb wakes, such as `working:` notes and bare turn-ended signals, are benign only when `bin/fm-crew-state.sh` reports positive evidence that the crew is still working: an actively running no-mistakes step attributed to that crew's current code, or an exact busy verdict from the semantic busy-state contract. A crew that declares `paused:` for a known external wait is separately absorbed while idle and re-surfaced only on the longer pause cadence, rather than being treated as a possible wedge. For an ordinary crew that has stopped, the normal-mode watcher first surfaces one stale wake, then applies that same cadence to an unchanged `paused:` or durable `captain-held` endpoint only when the backend confidently reports its agent dead. Live or inconclusive liveness remains fail-open at that initial surface, and the secondmate idle-endpoint exemption is unchanged. Its initial normal-mode status signal still surfaces through the no-verb path, while away mode self-handles that routine signal and owns the later recheck. -Fresh stale panes use the same current-state read before trusting the status log, so an active run or busy pane outranks an old captain-relevant status-log line left behind before validation. +Fresh stale panes use the same current-state read before trusting the status log, so an active run or a proven busy worker outranks an old captain-relevant status-log line left behind before validation. No-change heartbeats are also benign. Absorbed wakes advance their suppression markers, log to `state/.watch-triage.log`, and keep the watcher blocking without a queue record or LLM turn. After each drain, `fm-wake-drain.sh` runs the same liveness guard as the supervision scripts, so a lapsed watcher chain surfaces even on a turn that only drains and handles queued wakes. @@ -33,10 +33,10 @@ Crew status files are append-only wake-event logs, not current-state fields. The script header owns the exact run-head ancestry rules. During no-mistakes' `ci` monitor phase, it also reads the ci step log tail because `axi status` reports both "still waiting on checks" and "checks green, waiting on merge" as `ci,running`. The most recent recognized ci log marker wins, so checks-green monitoring reports done while a later re-arm, failed-check, or issue marker returns the crew to working. -Only when no matching run exists does it fall back to the pane busy-signature and then a status-log event whose verb maps to a recognized run-state; a dead pane without a run reports unknown instead of trusting a stale log. +Only when no matching run exists does it consult semantic busy state; exact busy reports working, exact idle permits fallback to a status-log event whose verb maps to a recognized run-state, and unknown or a dead pane stays unknown instead of trusting a stale log. Decision-only events such as `resolved` never become current state or leak their prose into the current-state detail. In that status-log fallback, a declared external wait reports the distinct `paused` state with its reason. -For herdr, that pane fallback trusts a native `busy` verdict outright, but corroborates native `idle` or unknown verdicts against the recorded harness's rendered busy signature before deciding the crew is not working. +The semantic branch reports working only on an exact busy verdict and names the source that produced it; an unknown verdict never becomes working, never permits the status-log fallback, and never becomes a silent idle. For whole-fleet read-only review, `bin/fm-fleet-snapshot.sh --json` emits schema `fm-fleet-snapshot.v1` from the backlog, task metadata, current crew state, endpoint probes, PR/report pointers, scout reports, bounded current summaries from registered secondmate homes, and secondmate return-channel guidance. `bin/fm-fleet-view.sh` renders that snapshot as Markdown for humans, while `bin/fm-bearings-snapshot.sh` provides the bounded bearings projection, so both views consume one structured contract instead of reparsing raw fleet files. The script header owns the exact JSON schema. @@ -87,23 +87,41 @@ Stalled escalation delivery writes `state/.subsuper-inject-wedged` and attempts On an unmarked return, `bin/fm-afk-return.sh` owns ordered shutdown, durable catch-up evidence, and the fail-closed gate that keeps ordinary work behind every live firstmate-actionable blocker. `fm-send.sh` selects a pre-Enter popup-settle for slash commands and for codex `$...` skill invocations using metadata-routed target `harness=` values, then adds its own `FM_SEND_SETTLE` pause after successful text sends so immediate peeks catch the receiving turn starting; the sub-supervisor uses only the shared submit core and does not pay that post-submit pause. +## Busy state is semantic, per adapter + +`bin/fm-busy-lib.sh` is the single owner of what "this worker is busy" means, and `bin/fm-busy-event.sh` is the only writer of the per-task records it reads. +Every classification returns a verdict of busy, idle, unknown, or dead together with the source that produced it, so a consumer or a diagnostic can never confuse semantic state with a fallback. + +Each converted adapter reports its own turn lifecycle through a machine-readable contract the vendor already exposes, rather than through rendered footer text: Pi and pi-signed through the Firstmate-owned extension's `agent_start` and `agent_settled` confirmed by `ctx.isIdle()`, OpenCode through its plugin's semantic `session.status`, and Claude through owned `UserPromptSubmit`, `Stop`, `StopFailure`, and `SessionEnd` hooks. +Kimi behind Pi inherits Pi's lifecycle. +Codex and standalone Kimi classify unknown behind explicit probes until a semantic source is live-verified for them, and Grok keeps one clearly isolated rendered-tail fallback that can only ever classify a Grok task. + +Missing, malformed, stale, untrusted, or unverified semantic state is unknown, never idle, and unknown is never promoted to busy either. +Ordinary task-state consumers act only on an exact busy verdict, so an unreadable worker surfaces for a closer look instead of being absorbed as still-working or written off as finished. +Endpoint death is the only process-level override and yields dead; child processes, CPU, process sleep state, and marker modification times are not state signals. +`state/<id>.turn-ended` files remain wake notifications, not current state. + +Each record is bound to an incarnation token minted when the task's wiring is armed, so an event from a superseded incarnation is rejected rather than applied, and a record left behind by one classifies unknown. +Three rendered-text readers deliberately remain outside this contract because they answer delivery questions: the submit acknowledgement and away-mode supervisor-pane busy guard in `bin/fm-tmux-lib.sh`, and the secondmate delivery-confirmation observation in `bin/fm-pending-reply-lib.sh`. +All are harness-scoped rather than a global pattern union, and none is a recorded worker state source. + ## Runtime session backends 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/<id>.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`. +[`configuration.md`](configuration.md#runtime-backend-configbackend--fm_backend) owns new-spawn backend selection precedence and authorization. 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. For compatibility, default tmux tasks do not write `backend=tmux`; every reader treats a missing `backend=` field as `tmux`. -`fm-watch.sh` polls each window's backend for a busy state: tmux, zellij, orca, and cmux have no native primitive and always report unknown, so their pane-tail fallback matches only the recorded harness's verified signature; herdr's `agent.get` semantic state (working/idle/done/blocked) is consulted first for stale detection, with unknown native states using the same harness-scoped fallback. -This scope prevents cross-harness false positives such as Kimi's rotating idle tip `ctrl+c: cancel` borrowing Grok's busy token, and keeps Claude's broader elapsed-spinner shape from matching ordinary output in other panes. -Unknown supplied harnesses match no default signature, while callers that have no harness metadata retain the historical combined-pattern compatibility fallback. -That poll loop is the default event source for backends with no native push events, so this stays an extraction of the abstraction rather than a watcher rewrite. +`fm-watch.sh` decides each window's busy state through the semantic contract above rather than by polling the backend for rendered text. +Herdr's native `agent.get` verdict still participates, but only as evidence of activity: a native `busy` is accepted when the task has no record of its own, while a native `idle` is not, because `agent.get` reports generation state and reads idle while a worker blocks on its own long-running foreground tool call. +tmux, zellij, orca, and cmux expose no native busy primitive at all, so a task on those backends is classified purely from its adapter's own lifecycle record. +That poll loop is still the default event source for backends with no native push events, so this stays an extraction of the abstraction rather than a watcher rewrite. For capable Herdr sessions, the same watcher replaces its terminal sleep with a bounded native event wait that immediately surfaces `blocked`; [Push events and polling fallback](herdr-backend.md#push-events-and-polling-fallback) owns the current mechanism and capability gates, while [runtime backend verification](verification/runtime-backends.md#native-blocked-event) owns the active evidence. The deeper session-start agent-process liveness probe is separate from that busy-state poll: tmux and Herdr have verified classifiers for secondmate recovery, Zellij remains unverified, and Orca and cmux do not support secondmate spawns. Herdr is experimental and can be selected explicitly or by runtime auto-detection: Treehouse remains its worktree provider, [`herdr-backend.md`](herdr-backend.md) owns current setup and safety limits, and [`verification/runtime-backends.md`](verification/runtime-backends.md#herdr) owns active empirical evidence. -Herdr's durable default container shape is workspace-per-home plus tab-per-task: the primary home uses workspace label `firstmate`, secondmate homes use `2ndmate-<secondmate-id>`, and recovery/list-live scopes to the current `FM_HOME`'s workspace. +Herdr uses one tab per task; [Watching and task containers](herdr-backend.md#watching-and-task-containers) owns launcher-bound workspace placement, the label-only fallback, and recovery scope. Its optional default-off presentation projection may place one clean new task in a disposable workspace without changing endpoint authority or lifecycle ownership; [Optional presentation spaces](herdr-backend.md#optional-presentation-spaces) owns that conditional design and its narrow home-local restored-shell cleanup at locked session start. Zellij is experimental and selected only explicitly: Treehouse remains its worktree provider, [`zellij-backend.md`](zellij-backend.md) owns current setup and limits, and [`verification/runtime-backends.md`](verification/runtime-backends.md#zellij) owns active empirical evidence. Zellij's container shape is simpler than herdr's: one shared `firstmate` session, one tab per task, with no per-home workspace split; visible tab titles are scoped by the active home label plus a short hash of the resolved `FM_ROOT` path. @@ -136,6 +154,17 @@ Independently, `fm-spawn.sh`, `fm-send.sh`, and `fm-teardown.sh` source `bin/fm- A normal primary checkout or crewmate worktree has neither signal and remains unaffected. The helper's header owns the exact signal detection, relocated-home limitation, test-harness bypass, and relationship to no-mistakes' HEAD-continuity guard. +## Validation lane + +Each Firstmate home has one no-mistakes validation slot so the shared validator is not started concurrently by multiple crews. +After an implementation commit, Firstmate enqueues the task through `bin/fm-validation-lane.sh`; a terminal crew-status event makes the watcher run the registered lane check immediately, and the scheduler releases the next FIFO task through `fm-send` only after observing a reservation-bound authoritative terminal run-step. +`bin/fm-crew-state.sh` owns current-run attribution and exposes comparable run-start evidence so a run that completes between watcher checks remains bound to its reservation. +A stale terminal observation from before delivery cannot release a new reservation; only an authoritative terminal run-step carrying affirmative evidence that its run started after the reservation can do so. +Unavailable run identity with empty run-start evidence always fails closed: an unreserved FIFO head stays queued and an existing holder stays held, even after the scheduler previously observed that holder's run as active. +The released crew starts and owns its own no-mistakes run, including every later gate response, while the scheduler only reserves and delivers the slot. +Failed delivery remains a durable pending release and emits a check diagnostic, so the queue head is never silently skipped. +`bin/fm-validation-lane.sh` owns the state format, locking, check registration, and exact delivery mechanics. + ## Two task shapes Ship tasks change projects and ship by project mode (`no-mistakes`, `direct-PR`, or `local-only`); scout tasks leave standalone investigation reports at `data/<id>/report.md` and never push. @@ -158,7 +187,7 @@ That keeps spawn launch compatible across claude, codex, grok, pi, opencode, and `fm-home-seed.sh` provisions the isolated home, clones the listed PR-based projects into it, initializes newly cloned `no-mistakes` projects, copies the charter to `data/charter.md`, and `fm-spawn.sh --secondmate` launches it through the same session-provider and status-file path as any direct report. For a domain whose subject is the firstmate repo itself, a deliberate `--no-projects` seed creates a project-less home whose crews take pooled worktrees of that repo instead of separate clones. The signal cannot be mixed with project names or omitted accidentally, and a populated home cannot be converted in place; the full seed contract is in [configuration.md](configuration.md#secondmate-routes-datasecondmatesmd). -On the herdr backend, a secondmate launch lands in that secondmate home's labeled workspace, and crewmates spawned from that home land in the same workspace. +Herdr secondmate and child placement follows the launcher-binding contract in [Watching and task containers](herdr-backend.md#watching-and-task-containers). When seeded with `-`, the home is a durable treehouse lease under the secondmate id, so it survives with no live process and is not recycled by later `treehouse get` or pruning. Retirement or seed rollback returns the leased home; normal restart/recovery keeps it leased. If returning the lease fails during teardown, firstmate leaves the route and home intact instead of hiding a still-held lease. @@ -214,7 +243,7 @@ When a reply has a real visual artifact, `--image <path>` attaches one local PNG Actionable reversible requests run through firstmate's normal intake, backlog, dispatch, investigation, or ship lifecycle. Work that completes in the answering turn gets one outcome reply. Work that spawns a longer-running task gets an acknowledgement reply first; `bin/fm-x-link.sh` records `x_request=`, `x_request_ts=`, `x_followups=0`, and optional reply-platform context in that task's `state/<id>.meta`, while durable per-request context preserves the original platform and budget independently of task links and inbox cleanup. -Later milestone and completion wakes use `bin/fm-x-followup.sh` to post up to three public-safe follow-ups through the relay's `connector/followup` endpoint, ending with a `--final` one that always clears the link. +Later milestone wakes use `bin/fm-x-followup.sh` to post up to three public-safe follow-ups through the relay's `connector/followup` endpoint, ending with a `--final` one for ordinary X-linked work. A typed promised-final commitment owns its terminal reply through `bin/fm-public-followup.sh`; after its receipt is validated, `bin/fm-x-followup.sh --clear <task-id>` removes any legacy link without posting another reply. The [X mode configuration reference](configuration.md#x-mode-env) owns the exact context retention, platform-resolution, and fail-safe posting contract. If recovery relinks the same relay request onto a successor task, `fm-x-link.sh --carry-count <n> --carry-ts <epoch> --carry-platform <x|discord> --carry-max <n>` preserves the consumed follow-up count, original 7-day window, and reply split budget instead of granting a fresh local budget or falling back to the wrong platform. The follow-up helper forwards `--image <path>` to the same reply client when a follow-up needs an image. @@ -227,6 +256,18 @@ For preview testing, `FMX_DRY_RUN` makes `fm-x-reply.sh` and `fm-x-dismiss.sh` s Attached images are recorded as compact `{media_type, bytes, source_path}` metadata in dry-run instead of base64 bytes. X mode remains layered on top of the existing check mechanism without changing its request-handling behavior. +A promised *final* public reply is a stronger commitment than a milestone follow-up, because forgetting it is publicly visible. +It is therefore not carried in conversation memory at all: intake turns it into a typed `kind=public-followup` obligation owned by `tasks-axi public-followup`, and every later step reads that obligation from disk. +The mechanism boundary is deliberately narrow. +`tasks-axi` owns the obligation state machine and is the only thing that validates a terminal result's source home, work id, generation, schema, outcome, and deliverables. +`state/x-context/` remains the only owner of the private full request context. +`bin/fm-x-reply.sh` remains the only thing that posts. +`bin/fm-public-followup.sh` composes those three and adds nothing of its own beyond the activation gate, a private terminal-event inbox, and the idempotent delivery sequence. +Work routed to another home reports a *typed* terminal result through `bin/fm-public-followup-emit.sh`; firstmate never recovers the source home, work id, outcome, or deliverables by parsing a free-form `done:` sentence, and the child never learns the thread. +Because a terminal event's id is derived from its identity tuple rather than generated, duplicate reports and restart replay converge without coordination. +Reconciliation rides the existing relay poll and the session-start digest instead of a new watcher, daemon, or timer, and both are gated on the same `.env` activation contract so a home that never opted into the relay executes none of it. +The [X mode configuration reference](configuration.md#promised-public-replies-statepublic-followup) owns the operator-facing contract, and the `fmx-respond` skill owns the procedure. + ## Project memory belongs to projects Durable project-intrinsic agent knowledge lives in each project's committed `AGENTS.md`, with `CLAUDE.md` as a symlink. diff --git a/docs/calm-mode-feasibility.md b/docs/calm-mode-feasibility.md index b94b6a6aef9..3893b60c76d 100644 --- a/docs/calm-mode-feasibility.md +++ b/docs/calm-mode-feasibility.md @@ -5,8 +5,9 @@ This document owns the version-scoped feasibility evidence, Pi transcript taxono ## Required extension surface -A qualifying implementation must auto-load from the trusted project, persist the toggle choice for the effective Firstmate home across Pi session starts and resumes, keep Pi's built-in working activity visible, emit no Calm status row, redraw already-rendered controllable rows, remove supported hidden rows without gaps, restore ordinary rendering, and leave delivery, tool execution, model context, session storage, export and share operation, diagnostics, and expansion state unchanged. -The governing presentation policy allows genuine original user prompts, genuine user-facing assistant text, and Pi's native working activity. +A qualifying implementation must auto-load from the trusted project, persist the toggle choice for the effective Firstmate home across Pi session starts and resumes, keep working activity visible, emit no Calm status row, redraw already-rendered controllable rows, remove supported hidden rows without gaps, restore ordinary rendering, and leave delivery, tool execution, model context, session storage, export and share operation, diagnostics, and expansion state unchanged. +The governing presentation policy allows genuine original user prompts, genuine user-facing assistant text, and working activity. +Working activity may be presented through Pi's stock row or through a supported Calm-owned widget, but Calm must leave the stock row untouched whenever Calm is off. Changing persisted context to remove hidden content, filtering provider context, patching installed harness code, or claiming coverage outside a supported renderer does not satisfy that boundary. ## Compatibility evidence @@ -134,6 +135,35 @@ An adjacent two-notification run retained the same two-row neighboring-assistant Calm off, an absent Calm preference, and an absent Calm extension retained ordinary rows. The current exact marker and the narrow bare-U+2063 `Supervisor escalate (` compatibility shape hid under Calm, while quoted markers, ASCII `FIRSTMATE_OP:` without U+2063, ordinary text before the current marker, unrelated text after U+2063, and image-bearing input remained visible. +## Calm working presentation + +Calm replaces Pi's stock working row with a small animated boat while Calm is on and one logical agent run is active. +This path uses only public extension API and patches nothing: `ExtensionUIContext.setWorkingVisible(false)` hides the stock row, and `setWidget()` installs a temporary component factory above the editor. +Pi's documented custom working-indicator frames are static and width-blind, so they cannot own responsive geometry; a widget component receives `render(width)` and can. + +`.pi/extensions/fm-calm.ts` remains the sole owner of the presentation choice and the only caller of `setWorkingVisible()`, while `.pi/extensions/lib/fm-calm-working-ship.ts` owns the sprite geometry, the bounce track, and the widget. +Visibility follows `agent_start` through `agent_settled` rather than turns or tool calls. +Pi emits `agent_settled` from a `finally` block once a run will not continue automatically, so retries, automatic continuations, queued follow-ups, and compaction inside one run never remove the boat, while settle, abort, and failure all reach the same cleanup. +Repeated `agent_start` events inside one run are idempotent, and Pi disposes the previous component before installing a replacement under the same key and when it clears extension widgets, so the frame timer cannot duplicate or outlive the widget. +Pi's above-editor widget container reserves one spacer row whether or not a widget is present, so removing the boat leaves no residual blank row. + +The sprite is two rows when the usable width admits the complete hull: a two-cell mainsail centered over a symmetric `\__/` hull that replaces water on its row rather than adding a third row. +The sail is directional because a mainsail extends aft of the mast, so it renders `<|` while travelling right and `|>` while travelling left. +Direction reverses the moment the boat lands on an endpoint, so the endpoint frame itself already shows the new heading and no frame at or after a bounce shows the previous sail. +The water row fills the complete supplied width, the track is recomputed and clamped from that width on every frame so a resize cannot wrap or strand the boat offscreen, and widths too narrow for the hull fall back to a deterministic single row. + +One scheduler drives two logically independent clocks. +Every tick advances a bounded fixed-cell water phase, and only every fourth tick moves the boat, so at a 220ms tick the water ripples several times between boat steps and the boat travels one column every 880ms. +Ticks rather than wall-clock timestamps drive every state change, so tests seek animation time exactly, and disposing the widget stops both clocks together. +Water phases are single-column ASCII, so advancing them never changes visible width, adds a row, or moves the hull column. + +Colors are standard ANSI foreground codes rather than theme lookups: blue for every water cell and yellow for the complete boat, with no bright variant, 256-color, or RGB escape. +Each colored run is closed with a default-foreground reset so styling cannot bleed into the sail row's padding, neighbouring UI, or a later frame, and geometry is always computed from visible cells rather than escape bytes. + +The presentation is TUI-only and visual-only. +It adds no session entry, transcript row, model context, or export or share content, and its widget takes no keyboard input, so editor focus and Escape abort are unchanged. +Compaction and retry loaders remain stock because Pi exposes no supported replacement for them. + ## Central visibility and input policy `.pi/extensions/lib/fm-calm-visibility.ts` owns only the allowlist-style transcript presentation policy. @@ -172,7 +202,7 @@ The test fixture enumerates every class below through the centralized policy, an | `custom-entry` | `CustomEntryComponent` with a registered renderer | Legacy Calm presentation entries rebuild to zero children without a residual spacer and restore through ordinary expansion redraw when mounted; arbitrary extension entries remain an unsupported boundary. | | `compaction-summary` | `CompactionSummaryMessageComponent` | Unsupported boundary; remains visible. | | `branch-summary` | `BranchSummaryMessageComponent` | Unsupported boundary; remains visible. | -| `working-status` | `WorkingStatusIndicator` | Visible through Pi's unchanged built-in row while Calm is active. | +| `working-status` | `WorkingStatusIndicator`, or the Calm working-ship widget while Calm is active | Always visible. Calm off leaves Pi's stock row untouched; Calm on hides that row for the duration of one logical agent run and renders the working ship instead. | | `command-status` | Interactive command result and status rows | Calm emits no enable notice, but generic Pi command rows remain an unsupported boundary. | | `system-notice` | `showStatus`, `showError`, compaction, retry, and startup warning rows | Unsupported boundary; remains visible. | | `cache-notice` | Non-persisted cache-miss `Text` row | Unsupported boundary; remains visible. | @@ -219,12 +249,12 @@ Only Pi's Calm presentation implementation changed; every producer and non-Pi tr ## Regression coverage `tests/fm-calm-pi-extension.test.sh` compares wrapped and stock renderers, verifies all seven built-ins plus `fm_watch_arm_pi`, exercises redraw of already-rendered tool, thinking, current operational-user, and legacy synthetic rows, and covers every policy class. -It covers persisted preference restoration across every session-start reason and a real restart, proves Pi's native `Working...` row through a delayed deterministic provider, asserts no Calm status row, verifies operational messages remain exact ordinary user-role session entries and complete exports, and drives genuine 100 by 44, 160 by 36, and 180 by 44 terminal fixtures. +It covers persisted preference restoration across every session-start reason and a real restart, proves the working-ship presentation and Calm-off stock `Working...` row through a delayed deterministic provider, asserts no Calm status row, verifies operational messages remain exact ordinary user-role session entries and complete exports, and drives genuine 100 by 44, 160 by 36, and 180 by 44 terminal fixtures. A native deterministic `/skill:ahoy` turn produces thinking, tool-call, and tool-result blocks, asserts that the collapsed skill-to-final gap equals the two-row visible-only baseline, expands and re-collapses original thinking, restores Calm-off rendering, verifies persisted hidden history, and repeats the geometry assertion after restart with `terminal.clearOnShrink` explicitly off. The operational provider path covers Calm loaded on, loaded off, default preference, extension absent, exact watcher delivery, narrow bare-marker legacy input, persisted restart replay, a genuine captain prompt, and adjacent notifications coalesced into one intended processing turn. It asserts one persisted and rendered captain answer, exact user-role operational envelopes in order, no replacement custom messages, one processing result, zero operational transcript rows, and the two-row neighboring-assistant geometry for live, adjacent, and restart paths. Quoted current markers, ASCII-only labels, ordinary text before a marker, unrelated U+2063 placement, and image-bearing input remain visible in component and native transcript checks. -`tests/fm-pi-primary-live-e2e.test.sh` also proves the unchanged built-in `Working...` row while Calm is active on the credentialed provider path before continuing its ordinary watcher lifecycle. +`tests/fm-pi-primary-live-e2e.test.sh` also proves the working ship replaces the built-in `Working...` row while Calm is active on the credentialed provider path, and that it clears when the run settles, before continuing its ordinary watcher lifecycle. `tests/fm-pi-primary-types.test.sh` performs strict no-emit TypeScript checking against the installed Pi declarations, currently package version 0.81.1. The relevant commands are: @@ -287,3 +317,110 @@ ok - Pi calm native E2E keeps Working and captain turns visible, hides exact ope $ tests/fm-pi-primary-types.test.sh ok - tracked Pi extensions pass strict no-emit typecheck against Pi 0.81.1 ``` + +## 2026-07-30 Calm working-presentation verification (superseded) + +This record captures the first working-presentation implementation and is retained as pipeline history. +Its same-orientation sail, theme-derived colors, and single-cadence motion were all replaced later the same day; the revision record at the end of this document owns current behavior. + +The working ship was verified against the installed Pi 0.82.0 CLI with a deterministic in-process provider and no credentials. +The globally installed declaration package remained 0.81.1, so the strict typecheck continued to cover that declaration-evidence version while the real CLI exercised 0.82.0. +The real-TUI regression captures two frames at different hull columns, resizes the same running TUI, asserts the reflowed water row equals the new width on a single wave row, types into the editor while the animation runs, aborts with Escape, and then proves Pi's stock `Working...` row returns with Calm off. + +```text +$ pi --version +0.82.0 + +$ tests/fm-calm-pi-extension.test.sh +ok - Pi calm resolves its persistent home independently of Pi's launch directory +ok - Pi calm compatibility evidence never rejects a Pi version for being newer than 0.82.0, and still fails closed on a missing or malformed version +ok - a missing collapsed-thinking presentation API degrades only that Calm adapter with a clear skip reason, while the rest of Calm still registers +ok - missing Pi presentation class exports reach the independent adapter degradation path +ok - Pi calm centralizes transcript visibility, preserves execution/export data, keeps Pi's stock working row visible while no run is active, and persists its choice across session starts +ok - Pi operational follow-up E2E processes exact user-role notifications once while Calm hides current and adjacent rows, Calm off and absent render them, and restart preserves semantics +ok - Pi Calm native /skill:ahoy geometry keeps every collapsed thinking and tool block at zero height while preserving expansion, history, restart, and Calm-off rendering +ok - Pi Calm working ship renders an exact two-row full-width sprite, clamps every resize, bounces at both edges, falls back deterministically when narrow, and installs and removes one timer-owning widget across starts, settle, abort, failure, shutdown, reload, replacement, and Calm toggles +ok - Pi calm native E2E replaces the stock working row with a moving, resize-clamped working ship that clears on abort, keeps captain turns visible, hides exact operational user rows without changing persistence, restores stock rendering Calm-off, survives restart, and preserves export plus Ctrl+O behavior + +$ tests/fm-pi-primary-types.test.sh +ok - tracked Pi extensions pass strict no-emit typecheck against Pi 0.81.1 + +$ bin/fm-lint.sh +fm-lint.sh: ShellCheck 0.11.0 (pinned 0.11.0) + +$ bin/fm-doc-audience-check.sh +fm-doc-audience-check: ok surfaces=57 local_links=160 + +$ bin/fm-test-run.sh --changed --base origin/main +FM_TEST_SUMMARY total=32 failed=0 skipped_gate=7 duration_ms=196009 +FM_TEST_SUMMARY_FAMILY family=live-harness-optin count=7 duration_ms=202 failed=0 +FM_TEST_SUMMARY_FAMILY family=pure-contract-unit count=25 duration_ms=194670 failed=0 +``` + +One rendered frame at 120 columns, with Pi's stock working row hidden and the boat directly above the editor: + +```text + |> +\__/~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +The same run after resizing that TUI to 64 columns, showing the waves refilled to the new width on one row with the boat still on screen: + +```text + |> +~~~~~~~~~~~~~~~~~~~~~~~~\__/~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +Colors at that time were confirmed from an escape-preserving capture as theme-derived entries; the revision below replaced them with standard ANSI blue and yellow. +Pressing Escape during a run left `Operation aborted` with no boat and no residual blank row, and toggling Calm off restored Pi's stock `⠴ Working...` row on the next run. + +## 2026-07-30 Calm working-presentation revision verification + +The revision replaced the single-cadence, theme-colored, same-orientation sprite with a slower boat over independently animated water, standard ANSI colors, and a directional mainsail. +It was verified against the installed Pi 0.82.0 CLI with a deterministic in-process provider and no credentials. + +```text +$ pi --version +0.82.0 + +$ tests/fm-pi-primary-types.test.sh +ok - tracked Pi extensions pass strict no-emit typecheck against Pi 0.81.1 + +$ bin/fm-lint.sh +fm-lint.sh: ShellCheck 0.11.0 (pinned 0.11.0) + +$ bin/fm-doc-audience-check.sh +fm-doc-audience-check: ok surfaces=57 local_links=163 + +$ bin/fm-test-run.sh --changed --base origin/main +FM_TEST_SUMMARY total=32 failed=0 skipped_gate=7 duration_ms=386738 +FM_TEST_SUMMARY_FAMILY family=live-harness-optin count=7 duration_ms=257 failed=0 +FM_TEST_SUMMARY_FAMILY family=pure-contract-unit count=25 duration_ms=383010 failed=0 +``` + +Real Pi TUI observations from the isolated deterministic trial at 100 columns. +The hull column held steady across consecutive samples while the water pattern shifted, then advanced about one column every 880ms, which separates the two cadences: + +```text +hull_col=12 water=~-~~~-~~~-~\__/~~-~~~-~~~-~~~-~~~-~~~-~~~-~~~-~~~-~~~-~~ +hull_col=12 water=~~~-~~~-~~~\__/-~~~-~~~-~~~-~~~-~~~-~~~-~~~-~~~-~~~-~~~- +hull_col=13 water=~~-~~~-~~~-~\__/~~-~~~-~~~-~~~-~~~-~~~-~~~-~~~-~~~-~~~-~ +hull_col=16 (about 2.6s later) +``` + +An escape-preserving capture confirmed standard ANSI foreground codes only, blue water and yellow boat, with a default-foreground reset closing each run: + +```text +^[[34m~~~-~~~-~~~-~~~^[[33m\__/^[[34m-~~~-~~~-~~~-~~~-... +^[[33m<|^[[39m +``` + +Resizing the same running TUI to 12 columns shortened the track enough to observe both reversals, each already showing the heading it was about to travel: + +```text +left-heading : |> over ~-~~~-~~\__/ +right-heading: <| over \__/~~-~~~-~ +``` + +At 3 columns the sprite fell back to a single exact-width row, `<|~`. +Escape aborted the run leaving `Operation aborted`, no boat, and no stale sprite rows, and the trial exited 0 after deleting its temporary state. diff --git a/docs/calm.md b/docs/calm.md index 8d63b6d0b56..1018b818b93 100644 --- a/docs/calm.md +++ b/docs/calm.md @@ -3,7 +3,16 @@ Calm is a Pi-only conversation presentation toggle. It is off by default, and the last `/calm` choice persists for the effective Firstmate home across Pi session starts and resumes. -While Calm is active, Pi's built-in `Working...` activity remains visible and no separate Calm status row is added. +While Calm is active and an agent run is under way, Calm hides Pi's built-in `Working...` row and shows a small two-row animated boat in its place, and no separate Calm status row is added. +The water fills the usable width in standard ANSI blue and the complete boat is standard ANSI yellow. +The boat is deliberately calm: it moves one column every 880ms, while the water ripples on its own faster cadence so the surface stays alive between boat steps. +Its mainsail is directional, showing `<|` while travelling right and `|>` while travelling left, and it flips on the exact frame the boat turns at either edge. +Every resize reflows the sprite without wrapping, and it disappears when the run settles, aborts, or fails. +Within one Pi session and Calm extension lifetime, the next working period resumes the boat from its last rendered column and travel direction rather than restarting at the left edge. +Hidden elapsed time does not advance the animation, and a resize while hidden clamps the frozen boat to the new width without changing its valid travel direction. +A fresh Pi session or new Calm extension lifetime starts at the normal initial position. +Very narrow terminals fall back to a smaller deterministic sprite. +While Calm is off, Pi's stock working row is left exactly as Pi renders it. Calm hides collapsed thinking labels, the shells for Pi's seven built-in tools, the `fm_watch_arm_pi` tool shell, and canonically classified Firstmate operational user rows. The operational inputs remain ordinary user-role messages, while Pi's transcript layout renders their complete rows at zero height. The session-start nudge remains on its existing non-displayed custom-message path. @@ -26,7 +35,7 @@ If Pi removes one of those seams, Calm logs a diagnostic naming the unavailable [`calm-mode-feasibility.md`](calm-mode-feasibility.md) owns the version-scoped renderer taxonomy and empirical evidence. [`configuration.md`](configuration.md#pi-calm-preference-configcalm) owns the persisted preference file and resolution rules. -`.pi/extensions/lib/fm-calm-visibility.ts` owns the visibility policy, and `.pi/extensions/lib/fm-calm-operational-user-layout.ts` owns the zero-height operational-user row adapter. +`.pi/extensions/lib/fm-calm-visibility.ts` owns the visibility policy, `.pi/extensions/lib/fm-calm-operational-user-layout.ts` owns the zero-height operational-user row adapter, and `.pi/extensions/lib/fm-calm-working-ship.ts` owns the animated working presentation. Regression entry points: diff --git a/docs/cmux-backend.md b/docs/cmux-backend.md index 4bd438bc74e..ac39d630fcf 100644 --- a/docs/cmux-backend.md +++ b/docs/cmux-backend.md @@ -94,7 +94,8 @@ Literal send and Enter are separate calls. Enter, Escape, and Ctrl-C are supported. The composer verifier locates the last bordered composer row and delegates the content decision to `bin/fm-composer-lib.sh`. A bare shell prompt is `unknown`, and a slash-popup placeholder remains `pending`, so only Enter is retried and text is never retyped. -cmux exposes no native generic agent busy signal, so supervision uses the shared capture/hash and busy-regex path. +cmux exposes no native generic agent busy signal, so supervision uses capture/hash polling for screen changes and each harness adapter's semantic lifecycle for worker state. +Grok alone retains its isolated rendered-tail fallback. A task workspace's last surface cannot be closed directly. Cleanup owns the whole workspace and uses `close-workspace`. diff --git a/docs/configuration.md b/docs/configuration.md index 3a881f1c1e2..53a2b65f800 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -51,7 +51,7 @@ The file format is unchanged in both modes; tasks-axi and manual edits produce t For spawn-capable adapters, the runtime session-provider backend controls where task windows/endpoints are created, captured, sent to, watched, and killed. `tmux` is the verified reference backend (see [`docs/tmux-backend.md`](tmux-backend.md)); `herdr`, `zellij`, `orca`, and `cmux` are experimental spawn backends (see [`docs/herdr-backend.md`](herdr-backend.md), [`docs/zellij-backend.md`](zellij-backend.md), [`docs/orca-backend.md`](orca-backend.md), and [`docs/cmux-backend.md`](cmux-backend.md)). Treehouse remains the worktree provider for tmux, herdr, zellij, and cmux, since herdr, zellij, and cmux are session providers only; Orca provides both the task worktree and terminal endpoint. -New spawns choose the backend in this order: an explicit `--backend` flag firstmate passes when it spawns a task, then `FM_BACKEND`, then the first non-empty line of local gitignored `config/backend`, then runtime auto-detection from `$TMUX`, `HERDR_ENV=1`, or cmux runtime signals, then default `tmux`. +New spawns choose the backend in this order: an explicit `--backend` flag that current authority for that exact task alone has authorized (a present captain instruction or the task's own accepted brief; never later-task precedent by analogy), then `FM_BACKEND`, then the first non-empty line of local gitignored `config/backend`, then runtime auto-detection from `$TMUX`, `HERDR_ENV=1`, or cmux runtime signals, then default `tmux`. If more than one runtime marker is present, detection resolves innermost-first: `$TMUX` is checked before `HERDR_ENV=1`, which is checked before cmux's primary `CMUX_WORKSPACE_ID` marker and its documented fallback signals - tmux or herdr started from inside a cmux terminal is the innermost, currently-executing layer, while cmux itself (a terminal application, not a nestable multiplexer) is always checked last. See [`docs/cmux-backend.md`](cmux-backend.md#runtime-detection) for why cmux can be selected when `CMUX_WORKSPACE_ID` is absent. Auto-detected herdr or cmux prints a stderr notice naming `config/backend` and `--backend tmux` as opt-outs; auto-detected tmux stays silent to preserve existing default behavior. @@ -81,8 +81,8 @@ These five sentences are the single owner of the task-selector vocabulary; backe `fm-teardown.sh <id>` 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-<id>`; 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-<secondmate-id>`. -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. +`FM_HOME` determines Herdr's home label: the primary home uses `firstmate`, and a secondmate home marked by `.fm-secondmate-home` uses `2ndmate-<secondmate-id>`. +[`herdr-backend.md`](herdr-backend.md#watching-and-task-containers) owns launcher-bound workspace placement, the label-only fallback, collision handling, and recovery behavior. 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. The flag is default-off and inherited into secondmate homes under the primary-authoritative contract owned by [`secondmate-provisioning`](../.agents/skills/secondmate-provisioning/SKILL.md). For normal herdr operations, `HERDR_SESSION` selects the named session, but destructive test cleanup must not rely on `HERDR_SESSION` alone. @@ -144,11 +144,25 @@ Fleet-local operational facts and gotchas live locally in `data/learnings.md`; i The file is created lazily on first learning and follows the same dated, evidence-backed, curated style as `data/captain.md`: inspect the current file first, then rewrite or prune stale entries instead of appending forever. There is no shared learnings file by captain decision. +## Startup memory budget (config/startup-memory-budget) + +`config/startup-memory-budget` is the primary-authoritative per-home allowance for the startup prompt-memory surface: `data/captain.md`, `data/captain-shared.md`, and `data/learnings.md` together. +The locked mutable bootstrap path materializes its visible default of `7500` estimated tokens in a primary home when the file is absent. +To select another allowance, replace the primary home's file with one valid positive value in the exact format below; the next locked bootstrap convergence or `bin/fm-config-push.sh` propagates it to registered secondmates. +A secondmate does not create an independent default and instead receives the primary value through the inherited-local-material contract in [`secondmate-provisioning`](../.agents/skills/secondmate-provisioning/SKILL.md). +The file must be one positive base-10 integer followed by exactly one newline in a regular, single-linked file beneath a non-symlinked `config/` directory. +Malformed, multi-line, symlinked, hardlinked, special, or otherwise unsafe values are rejected rather than treated as a default. +Use `bin/fm-startup-memory-budget.sh read` to validate and print the effective value, or `bin/fm-startup-memory-budget.sh report` to account for the three files. +The stable local estimate is `ceil(UTF-8 bytes / 3)` per file, a conservative portable approximation rather than a provider-exact tokenizer. +An inherited `data/captain-shared.md` counts in a secondmate's total but remains primary-owned and read-only there. +The internal `/stow` skill curates only the editable local files in that case and reports the primary-owned shared file as a concrete exception if it alone exceeds the budget. +The helper's header owns exact parsing, publication, and report output mechanics. + ## Secondmate routes (data/secondmates.md) Persistent secondmate routes live locally in `data/secondmates.md`. The concise single-line route contract is owned by the [`secondmate-provisioning` skill](../.agents/skills/secondmate-provisioning/SKILL.md#routing-table), including the parser-compatible fields, one-sentence summary requirement, `home:` pointer to the seeded charter, and limit on extra registry prose. -`fm-home-seed.sh validate` refuses duplicate ids, duplicate homes, and nested or overlapping homes. +Use `fm-home-seed.sh validate` to check the complete operational registry contract documented by the command itself. The main first mate routes by reading those scopes with judgment; the project list is provisioning data, not exclusive ownership. Use `fm-home-seed.sh <id> - {<project>...|--no-projects}` to lease a fresh firstmate worktree for the secondmate home. Use the deliberate `--no-projects` signal only for a firstmate-repo domain that needs no separate project clones. @@ -188,7 +202,7 @@ The full cmux home label also includes a short hash of the resolved `FM_ROOT` pa 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). +The verified adapter knowledge - each harness's busy-state source, 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. @@ -224,7 +238,7 @@ When the file exists, `fm-spawn.sh` enforces that contract by refusing crewmate 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 always-loaded dispatch intake boundary, and `quota-array-dispatch` owns the pace-aware profile-array selection procedure. +`AGENTS.md` section 4 owns the always-loaded dispatch intake boundary, and `quota-array-dispatch` owns the completion-aware profile-array selection procedure. ```json { @@ -272,7 +286,7 @@ The capacity file provides only a bound; `AGENTS.md` section 8 owns the primary- On session start the first mate detects what its required toolchain is missing or too old and lists each problem with either an exact install command or manual instructions. It installs automatically supported tools only after you say go; manual-only tools remain for you to install from the printed instructions. Required tools come in two parts: a universal toolchain every home needs regardless of backend, and a per-backend delta that follows the runtime backend actually resolved for this home. -The universal toolchain is node, git, gh with GitHub auth via `gh auth login`, no-mistakes v1.31.2 or newer, gh-axi, chrome-devtools-axi, lavish-axi, compatible tasks-axi per "Backlog backend" above, and quota-axi. +The universal toolchain is node, git, gh with GitHub auth via `gh auth login`, no-mistakes v1.31.2 or newer, gh-axi, chrome-devtools-axi, lavish-axi, compatible tasks-axi per "Backlog backend" above, and quota-axi v0.1.16 or newer. This section is the single owner of that universal toolchain list; backend guides' prerequisites point here and add only their backend-specific tools. In that list, no-mistakes runs the validation pipeline, gh-axi, chrome-devtools-axi, and lavish-axi cover GitHub, browser, and rich-review operations, and tasks-axi plus quota-axi back backlog mutations and quota-aware array dispatch. The per-backend delta is required only for the backend resolved from `FM_BACKEND`, then `config/backend`, then runtime auto-detection, then default `tmux`, so a home is never told to install a tool an inactive backend or feature would need. @@ -285,7 +299,8 @@ When `config/crew-dispatch.json` exists, bootstrap also requires `jq` for dispat When X mode is opted in, bootstrap also requires `curl` and `jq` before arming the relay poll shim. `tasks-axi` and `quota-axi` are required bootstrap tools in every profile, the same class as `lavish-axi`. An absent or incompatible `tasks-axi` reports `MISSING: tasks-axi (install: npm install -g tasks-axi)`; when `config/backlog-backend` is not `manual` and compatible `tasks-axi` is on `PATH`, bootstrap stays silent and firstmate uses its verbs for routine backlog mutations, otherwise it hand-edits `data/backlog.md` until installation is approved and completed. -An absent `quota-axi` reports `MISSING: quota-axi (install: npm install -g quota-axi)`; firstmate cannot resolve a profile array until current quota output is available for every candidate. +An absent or too-old `quota-axi` reports `MISSING: quota-axi (install: npm install -g quota-axi)`; firstmate cannot resolve a profile array without a compatible binary. +That floor exists because it is the first build reporting per-credential auth sources, without which a candidate cannot be judged against the authentication surface it actually uses. Bootstrap also reports a `TANGLE:` line when `FM_ROOT` is on a named non-default branch; follow the printed checkout remediation rather than treating it as an installable tool problem. In a read-only session that did not get the fleet lock, the same line is advisory and omits the checkout command. The locked session-start bootstrap step also runs a best-effort project clone refresh through `fm-fleet-sync.sh`. @@ -301,7 +316,7 @@ When a running home advances and its loaded instruction surface (`AGENTS.md`, `b If that send fails, bootstrap keeps an idempotent retry marker and emits `NUDGE_SECONDMATES:` with the failure reason. The same bootstrap run emits `SECONDMATE_LIVENESS:` only when a registered secondmate is skipped or its relaunch fails; already-live and successfully relaunched secondmates are handled silently. For a mid-session inherited local-material edit where tracked-file sync is not needed, run `bin/fm-config-push.sh`. -It uses the same live secondmate discovery and propagation helper as bootstrap, prints each live home's `crew-dispatch.json`, `crew-harness`, `backlog-backend`, `backend`, `herdr-presentation-spaces`, and `data/captain-shared.md` result as `pushed`, `unchanged`, `skipped`, or `error`, and exits non-zero for real propagation errors or config-reread send failures. +It uses the same live secondmate discovery and propagation helper as bootstrap, prints each live home's `crew-dispatch.json`, `crew-harness`, `backlog-backend`, `backend`, `herdr-presentation-spaces`, `startup-memory-budget`, and `data/captain-shared.md` result as `pushed`, `unchanged`, `skipped`, or `error`, and exits non-zero for real propagation errors or config-reread send failures. When an allowlisted config item changes for an already-running home, it sends the literal-content reread pointer described in [`secondmate-provisioning`](../.agents/skills/secondmate-provisioning/SKILL.md); unchanged allowlisted config sends no pointer unless a previous delivery is pending. The locked bootstrap inheritance pass uses the same per-home changed-set and reread path for already-running homes; see `secondmate-provisioning` for the single contract owner. That live discovery starts from `state/*.meta` records with `kind=secondmate`; `data/secondmates.md` only backfills `home=` for older or incomplete meta records. @@ -352,7 +367,7 @@ The record is written only when a platform or explicit budget is actually known, The `fmx-respond` skill decides whether the stashed mention is an actionable request, a question, or a pure acknowledgment. Actionable reversible requests are run through intake, backlog, dispatch, investigation, or ship flow as appropriate. If the work completes in that turn, the public reply reports the outcome. -If the request spawns a longer-running task, firstmate posts an acknowledgement through the normal answer endpoint, links the task to the mention with `bin/fm-x-link.sh`, and posts up to three completion follow-ups on genuine milestones, always finishing with a `--final` one when the task reaches a terminal state. +If the request spawns a longer-running task, firstmate posts an acknowledgement through the normal answer endpoint, links the task to the mention with `bin/fm-x-link.sh`, and posts up to three completion follow-ups on genuine milestones, finishing with a `--final` one for ordinary X-linked work. When a typed promised-final commitment is registered, `bin/fm-public-followup.sh` owns the terminal reply and clears the legacy link after its receipt is validated. That link stores optional reply-platform context so Discord-originated follow-ups keep Discord's larger message budget after the inbox file has been drained. Platform/budget resolution is layered and independent of the task link: a per-axis `FMX_REPLY_PLATFORM` / `FMX_REPLY_MAX_CHARS` override (how `bin/fm-x-followup.sh` passes a recorded link's context) wins. For either axis without an override, `bin/fm-x-lib.sh:fmx_resolve_reply_context` owns the source order: the durable per-request registry is consulted first, then the still-present inbox payload, then - for a follow-up posted live by request_id - an authoritative relay lookup via `POST /connector/request-context` (`{request_id}` in, `{platform, reply_max_chars}` back). @@ -389,6 +404,28 @@ In dry-run, `fm-x-dismiss.sh` records `{request_id, endpoint:"dismiss"}` to the The live answer and follow-up bodies intentionally stay the same shape, including optional `image`; the relay distinguishes them by endpoint, and dismiss stays `{request_id}`. These paths need `jq` to build the JSON payload, but they run before token and network checks, so they need neither `FMX_PAIRING_TOKEN` nor `curl`. +### Promised public replies (state/public-followup) + +A relay request that spawns real work can leave firstmate owing a specific public reply in a specific thread. +That promise is a typed `kind=public-followup` obligation owned entirely by `tasks-axi public-followup`, with the full private request context staying in `state/x-context/`; firstmate keeps no parallel copy of either. +`bin/fm-public-followup.sh` is firstmate's side: it registers a commitment, reconciles typed terminal work results into it, and posts the final reply through `bin/fm-x-reply.sh --followup`. +Run `bin/fm-public-followup.sh --help` for the exact subcommands and flags. + +Registration is what creates this home's private transport under `state/public-followup/` (mode 0700): `registry/` for the bounded public-safe binding of each live commitment, `events/` for typed terminal results awaiting reconciliation, `consumed/` for the accepted-event ledger, `rejected/` for refusals kept with a one-line reason, and `surfaced` for the poll's last-surfaced signature. +The home that owns the commitment also owns the outward post, because only it holds the relay consent, the request context, and the opaque thread binding. +Work routed elsewhere reports a typed terminal result with `bin/fm-public-followup-emit.sh` and never looks for the thread; that emitter refuses to write into a home with no registration for the named obligation. +A terminal event's id is derived from its identity tuple, so a duplicate report, a retry, or a replay after restart resolves to the same event and changes nothing. + +Activation is the same `.env` `FMX_PAIRING_TOKEN` contract as the rest of X mode, with no second flag. +A home without that token runs one file test and stops: no `tasks-axi` call, no backlog or request-context scan, and no `state/public-followup/` directory. +Ordinary startup, polling, cleanup, and silent read-side subcommands also produce no output; commands that require an active relay report that configuration error after the same gate. +A relay-enabled home with no registered commitment stops at an O(1) directory presence check, so the empty state costs no CLI call and adds no periodic scan. +Unreconciled terminal results ride the existing 30-second relay poll rather than a new process or timer: `bin/fm-x-poll.sh` compares the pending-event signature against `surfaced` and wakes firstmate once per new result set. +The session-start digest separately prints an "Public commitments awaiting delivery" subsection from disk when, and only when, this home is relay-active and still owes a reply, so compaction and restart are non-events. +`bin/fm-teardown.sh` refuses to clean up a task while this home still owes a public reply for exactly that work, unless `--force` carries explicit discard approval. +`FM_PF_RETRY_BACKOFF_SECS` (default 900) sets the next-attempt time recorded with a retryable delivery error. +See [verification/public-followup.md](verification/public-followup.md) for the current maintainer evidence behind the restart end-to-end and the relay-disabled zero-overhead guarantee. + ## Environment variables Runtime tuning via environment variables (defaults shown): @@ -438,6 +475,7 @@ FMX_DISCORD_REPLY_MAX_CHARS=1900 # Discord reply per-message split budget; val FMX_X_THREAD_MAX=25 # maximum messages in one auto-split reply thread FMX_FOLLOWUP_MAX_AGE_SECS=604800 # local window for posting X-mode completion follow-ups (7 days) FMX_FOLLOWUP_MAX_COUNT=3 # local cap on X-mode completion follow-ups per linked mention +FM_PF_RETRY_BACKOFF_SECS=900 # seconds before the next attempt after a retryable promised-public-reply delivery error FM_LOCK_STALE_AFTER=2 # seconds before dead-pid lock records can be reclaimed; mid-acquire locks keep at least 2s grace FM_GUARD_GRACE=300 # seconds before guard warnings, arm health checks, and the primary turn-end guard treat a watcher beacon as stale FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=800 # milliseconds the --claude turn-end guard waits for the Stop auto-arm's claim, health, or fresh rewake epoch before re-blocking @@ -471,7 +509,7 @@ FM_STALE_WORKTREE_LOCK_RETRY_WAIT_SECS= # legacy alias for FM_TREEHOUSE_RETURN FM_FLEET_SYNC_PACKED_REFS_LOCK_RETRIES=3 # fetch retries after fm-fleet-sync.sh hits the orphaned .git/packed-refs.lock signature FM_FLEET_SYNC_PACKED_REFS_LOCK_RETRY_WAIT_SECS=1 # seconds fm-fleet-sync.sh waits before each of those retries FM_FLEET_SYNC_PACKED_REFS_LOCK_AGE_SECS=30 # min mtime age before fm-fleet-sync.sh treats a leftover packed-refs.lock as provably stale -FM_BUSY_REGEX= # optional global override for every harness-scoped busy-pane matcher; unset uses each recorded harness's verified signature +FM_BUSY_REGEX= # optional override for rendered delivery guards and Grok's isolated task-state fallback; converted worker state ignores it FM_COMPOSER_IDLE_RE= # optional empty-composer regex, applied after ghost and border stripping FM_COMPOSER_GHOST_LUMA_MAX=128 # fleet-wide: max perceived luminance (0.299R+0.587G+0.114B, 0-255) for a TRUECOLOR foreground to count as de-emphasised ghost/placeholder text and be stripped; dim/faint (SGR 2) is stripped regardless. Assumes a dark terminal theme (bin/fm-composer-lib.sh's fm_composer_strip_ghost, shared by the tmux and herdr composer readers) GROK_HOME= # optional Grok config home for firstmate's global grok turn-end hook; defaults to ~/.grok diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index 5333afed8f2..19dd1aa387e 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -315,10 +315,22 @@ "path": "docs/turnend-guard.md", "audience": "operator-current" }, + { + "path": "docs/verification/dispatch-auth.md", + "audience": "maintainer-verification" + }, + { + "path": "docs/verification/public-followup.md", + "audience": "maintainer-verification" + }, { "path": "docs/verification/runtime-backends.md", "audience": "maintainer-verification" }, + { + "path": "docs/verification/stow-memory.md", + "audience": "maintainer-verification" + }, { "path": "docs/verification/link-intake.md", "audience": "maintainer-verification" diff --git a/docs/examples/crew-dispatch.json b/docs/examples/crew-dispatch.json index 23a5391d20a..b404e95e777 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 and pace before dispatch, so use a strong coding profile." + "why": "Use a strong coding profile for big, ambiguous work; resolve the alternatives through quota-array-dispatch." } ], "default": [ diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index 07a3b1ae7d8..7de16aef413 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -33,10 +33,11 @@ Real harness credential tests remain opt-in rather than part of default CI. ## Watching and task containers -Each Firstmate home gets one durable workspace with one task tab per endpoint. -The primary workspace is `firstmate`. -A secondmate home uses `2ndmate-<secondmate-id>`, derived from its validated `.fm-secondmate-home` marker. -The secondmate process and every child it launches resolve the same home label; a secondmate launched by the primary receives a narrowly scoped home override during container creation. +The ordinary topology puts one task tab per endpoint in the exact workspace of the Firstmate or secondmate that launches it. +When the launcher has no Herdr workspace to inherit, the adapter maintains one durable home-labeled workspace instead. +The primary home label is `firstmate`. +A secondmate home label is `2ndmate-<secondmate-id>`, derived from its validated `.fm-secondmate-home` marker. +A secondmate launched by the primary receives a narrowly scoped home override during container creation. Attach to the selected named Herdr session and switch to the relevant home workspace to watch its task tabs. Routine supervision uses `bin/fm-peek.sh <id>` and `FM_HOME=<home> bin/fm-send.sh <id> '<text>'` without attaching. @@ -45,10 +46,21 @@ Workspace and tab creation use `--no-focus`. The first workspace in a completely empty Herdr session must become focused because no prior target exists, but later task creation does not intentionally steal focus. Herdr workspace and tab focus is distinct from macOS frontmost-app activation. -Herdr does not enforce workspace or tab label uniqueness. -Firstmate adopts the first workspace matching its derived home label and refuses duplicate task tabs inside it. -Avoid naming a personal workspace `firstmate` or `2ndmate-<id>` because the adapter cannot distinguish that label collision from its own container. +Herdr does not enforce workspace or tab label uniqueness, so a label can never decide where a worker goes. +Herdr 0.7.5 exports `HERDR_ENV`, `HERDR_PANE_ID`, `HERDR_SESSION`, `HERDR_SOCKET_PATH`, `HERDR_TAB_ID`, and `HERDR_WORKSPACE_ID` into every process it manages a pane for, and a Firstmate or secondmate agent's own commands inherit them. +Older injection shapes are unverified, so a claimed launcher pane without the injected socket identity cannot be trusted. +With presentation spaces disabled, a crewmate or scout is created in the exact workspace that identity currently resolves to, read live from Herdr rather than from the injected snapshot, so the worker always appears beside the agent that launched it. +Duplicate labels elsewhere in the session are irrelevant, and the globally focused workspace is never the target. +A `--secondmate` launch is the deliberate exception: it stands up that secondmate home's own workspace instead of joining the launcher's. + +A claimed parent identity that cannot be resolved exactly stops the spawn before any worker endpoint exists, rather than falling back to a label search. +That covers a missing or unusable socket identity, a closed or unreadable launcher pane, a pane and tab that disagree about their workspace, a workspace missing from the session, and a pane belonging to another named session or Herdr server. + +Firstmate running outside Herdr entirely has no launcher workspace to inherit, so its workers use this home's own labeled workspace, created on first use. +That path needs the home label to identify exactly one workspace: two workspaces sharing it are an unresolvable placement and refuse rather than adopting either. +Avoid naming a personal workspace `firstmate` or `2ndmate-<id>` for that reason, and because the adapter cannot distinguish that label collision from its own container. An older secondmate workspace using `firstmate-<id>` is not migrated automatically; rename it manually before expecting new tasks or recovery to use it. +Recovery and list-live still scan the first workspace matching the home label, because they address panes they already recorded rather than choosing where new work goes. Existing task operations use recorded endpoint ids and do not move a live task when labels change. The per-home workspace is reused while it has task tabs. @@ -64,9 +76,12 @@ An absent or unconverged setting keeps the flat default. Presentation is a best-effort visual projection, never task ownership or lifecycle authority. Only a fresh task with neither metadata nor an existing presentation journal is eligible for projected creation. Firstmate atomically publishes a three-field version 1 journal containing a random 128-bit base64url token before asking Herdr to create anything. -After the new workspace converges to one exact task endpoint beneath one exact parent, the journal advances to a version 2 binding that records the physical home, named session, endpoint, parent, and immutable expected labels. +After the new workspace converges to one exact task endpoint beneath one exact parent workspace id, the journal advances to a version 2 binding that records the physical home, named session, endpoint, parent, and immutable expected labels. +Another parent with the same presentation label does not prevent publication or participate in restart reclaim. The token is visible in the workspace title because Herdr exposes no verified hidden persistent field, but neither token, title, nor journal authorizes send, capture, task ownership, Treehouse return, or general recovery. +The owning parent is the launcher's own exact workspace, resolved from the same identity the flat path uses, and falls back to a unique home-label lookup only for a Firstmate outside Herdr. +Projected children are never collapsed back into that parent; it is the placement and ordering reference the projection is bound under. The normal `fm-<id>` task tab is created in the exact new workspace returned by Herdr. Only the exact seeded default tab returned by the same workspace-create response can be pruned. Before and after create, prune, order, abort cleanup, and normal cleanup, Firstmate verifies exact workspace, tab, pane, and active-focus ids. @@ -85,7 +100,17 @@ The complete projected spawn must also preserve the macOS frontmost app. Normal task metadata remains the sole endpoint authority after creation. Cleanup closes only the exact recorded task pane and never calls `workspace close`. -Herdr can move focus when closing the last pane of a non-focused projected workspace, so projected cleanup runs under the same session lock, captures the exact active tab, refuses to delete the active tab, closes the exact task pane, and restores only the exact prior tab when needed. +Herdr 0.7.5's explicit close moves focus to a neighbor whenever it empties a non-focused workspace, while its pane-death removal preserves the focused workspace whenever the dying workspace sits behind it or the focused workspace is last; both behaviors are fixed on the upstream default branch but in no release, and the exact rules live in the adapter header of `bin/backends/herdr.sh`. +Projected cleanup therefore runs under the same session lock, captures the exact active tab, refuses to delete the active tab, and treats a workspace-emptying close as a focus-safe removal: it verifies the close would empty the workspace, repositions the doomed workspace behind the focused one through the verified `workspace.move` transport when needed, proves the pane holds one lone idle shell, and ends that shell so Herdr removes the emptied workspace through its focus-preserving pane-death path. +The repositioning move-to-last preserves every surviving workspace's relative order, and removal is confirmed against the exact moved workspace rather than inferred from pane disappearance before an unconfirmed removal makes one verified attempt under the same session lock to roll the doomed workspace back to its exact original position. +If that rollback cannot restore the verified original order, cleanup warns loudly and leaves the retained records for inspection rather than retrying the shared-layout mutation. +The pane-death signals are pid-exact: the escalation re-reads the pane's process information and refuses unless the same shell pid still passes the strict bare-idle ownership proof, so an exited and reused pid is never signaled. +Any ambiguity, unsupported or failed move, or unproved shell falls back to the plain explicit close, and the exact prior-tab restore remains the backstop behind every close, so degraded behavior is never worse than the pre-mitigation sub-second restore. +Ordinary non-projected task removal serializes through the same session lock, applies the same focus-safe plan when its close would empty a non-focused workspace, keeps the legitimate plain close when the target is the active tab, and refuses an unlocked close if the lock cannot be acquired. +Task cleanup acquires that session lock before the task's isolated copy is returned, so a contended lock refuses up front while the copy, every durable record, and the endpoint are all intact for a plain rerun. +Forced secondmate cleanup recursively preflights every Herdr child endpoint and acquires every affected named-session lock before mutating any child, then retains each child's durable identity unless that exact pane returns structured not-found after its close. +Durable task records are erased only once the exact pane is confirmed gone through its structured presence: after every close path, only a structured not-found response counts as gone, while a present or unknown result retains every record with a visible, retryable error. +Missing or malformed endpoint identity and missing confirmation machinery are ambiguity, never proof of a gone pane, and refuse record removal the same way. If lock, snapshot, pane identity, or restoration is ambiguous, cleanup warns and preserves the journal for manual inspection. Recovery is deliberately conservative and presentation-only. @@ -107,6 +132,7 @@ The task's ordinary metadata must be absent, and the candidate must have exactly Before cleanup, Firstmate acquires the existing task-id spawn lock and then the shared named-session presentation lock. Inside both locks it takes one exact snapshot, requires one unambiguous non-target focus and the exact title, token, tab, and pane shape, positively confirms no registered agent, and reads Herdr's process information for the exact named-session pane. The process proof requires one recognized idle shell as both the shell process and the sole foreground process-group member, an operating-system process-table row for that shell, no child process, and a sleeping or idle shell state. +The proof retries strict single samples for a bounded settle window because an idle interactive shell transiently hosts short-lived prompt helpers; a genuinely busy pane fails every sample. Any foreground command, child process, active shell job, unknown shell, unreadable process table, missing field, or API error preserves the pane. Firstmate immediately revalidates the same journal, metadata absence, workspace title and token uniqueness, one-tab and one-pane topology, exact pane relationship, absent agent, process proof, and non-target focus before calling the existing exact-pane focus-preserving close helper. It closes only that pane, never a workspace. @@ -127,7 +153,8 @@ Operational compromises: `tests/fm-backend-herdr-presentation-e2e.test.sh` covers multi-home ordering, concurrency, lock contention, legacy coexistence, logical focus and macOS app-activation preservation, exact same-identity restart replacement, ambiguous bindings and tokens, and exact-pane cleanup through the guarded lab path. `tests/fm-herdr-session-cleanup.test.sh` covers every discovery, ownership, topology, process, locking, revalidation, focus, retirement, and continue-on-error boundary. -`tests/fm-herdr-session-cleanup-e2e.test.sh` covers the restored-shell cleanup in a guarded non-default named lab; [`verification/runtime-backends.md`](verification/runtime-backends.md#per-home-and-presentation-topology) owns the active versioned evidence. +`tests/fm-herdr-session-cleanup-e2e.test.sh` covers the restored-shell cleanup in a guarded non-default named lab. +`tests/fm-backend-herdr-focus-flash-e2e.test.sh` reproduces the raw explicit-close focus steal on the installed release and proves the focus-safe emptying-close plan removes a doomed workspace with no wrong-focus interval; [`verification/runtime-backends.md`](verification/runtime-backends.md#workspace-removal-focus-safety) owns the active versioned evidence. ## Default-tab prune safety @@ -179,7 +206,7 @@ The capture owner requests at least 200 lines from Herdr and trims locally to th This generous floor is required for small composer and peek reads. Herdr's native agent state can read idle while a harness waits on its own long foreground tool. -The shared crew-state path therefore corroborates every native non-busy or unreadable result with the recorded harness's rendered busy signature before concluding that a pane is not working. +The shared crew-state path therefore accepts a native `busy` as evidence of activity but never a native `idle` as evidence that a worker has stopped; the task's own semantic busy state (`bin/fm-busy-lib.sh`) decides that. A human-blocked permission dialog has no busy banner and still surfaces. ## Composer and injection safety @@ -262,7 +289,8 @@ Tests use thin compatibility wrappers in `tests/herdr-test-safety.sh` and never - Herdr remains experimental. - Presentation ordering needs protocol 16 and Python and is best-effort only. -- Mutable labels can collide; they are never destructive authority. +- Mutable labels can collide; they are never placement or destructive authority. +- A Firstmate outside Herdr cannot resolve a launcher workspace, so a colliding home label refuses new spawns until the collision is cleared. - Ghost and placeholder recognition depends on ANSI de-emphasis and fails safely to pending when unavailable. - Mid-session secondmate liveness is not implemented. - OpenCode 1.18.4 can accept Enter while busy without clearing the composer. @@ -277,6 +305,7 @@ tests/fm-backend-herdr-smoke.test.sh tests/fm-backend-herdr-prune-safety-e2e.test.sh tests/fm-backend-herdr-respawn-idem-e2e.test.sh tests/fm-backend-herdr-workspace-per-home-e2e.test.sh +tests/fm-backend-herdr-launcher-workspace-e2e.test.sh tests/fm-backend-herdr-presentation-e2e.test.sh tests/fm-backend-herdr-eventwait-smoke.test.sh tests/fm-herdr-session-cleanup.test.sh diff --git a/docs/orca-backend.md b/docs/orca-backend.md index 9812993e830..42b9815cec5 100644 --- a/docs/orca-backend.md +++ b/docs/orca-backend.md @@ -51,7 +51,8 @@ Exact command flags and response parsing are owned by `bin/backends/orca.sh` and `fm-peek.sh` reads with `orca terminal read`. `fm-send.sh` types and verifies composer clearance, follows `oldestCursor` when Orca returns a limited page, and retries Enter without retyping when a slash popup first fills an argument placeholder. A bare shell row is `unknown`, not an empty agent composer. -The watcher has no native Orca busy signal and uses the shared terminal-tail fallback. +The watcher has no native Orca busy signal, so each harness adapter's semantic lifecycle supplies worker state. +Grok alone retains its isolated rendered-tail fallback. Cleanup keeps all shared Firstmate safety checks. A scout still requires its report and completed decision inventory. diff --git a/docs/scripts.md b/docs/scripts.md index ffeff190768..07d93416392 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -65,7 +65,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-afk-launch.sh` | Own away-mode entry, exit, rollback, and any backend terminal lifecycle | | `fm-afk-return.sh` | Own deterministic return shutdown, catch-up evidence, and the firstmate-actionable blocker gate | | `fm-supervisor-target-lib.sh` | Resolve the shared supervisor target and backend for the daemon and launcher | -| `fm-supervise-daemon.sh` | Presence-gated away-mode sub-supervisor: self-handle routine wakes, escalate batched digests, alert on failed delivery | +| `fm-supervise-daemon.sh` | Presence-gated away-mode sub-supervisor: self-handle routine wakes, guard injection by the detected primary harness, escalate batched digests, alert on failed delivery | | `fm-crew-state.sh` | Print one deterministic current-state line for a crew | | `fm-tangle-lib.sh` | Shared default-branch resolution and primary-checkout tangle classification | | `fm-supervision-lib.sh` | Shared in-flight-work-without-fresh-watcher-beacon predicate | @@ -74,14 +74,19 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-lock-lib.sh` | Shared "is this git lock provably abandoned?" proof used by teardown and fleet-sync | | `fm-config-inherit-lib.sh` | Shared primary-to-secondmate inherited local-material propagation and config-reread delivery | | `fm-tasks-axi-lib.sh` | Shared backlog-backend selector and `tasks-axi` compatibility probe | +| `fm-quota-axi-lib.sh` | Shared `quota-axi` compatibility floor for the bootstrap diagnostic | +| `fm-vendor-auth-probe.sh`| Run one hard-bounded, non-destructive authentication probe of a named vendor CLI and report the fact | | `fm-wake-drain.sh` | Atomically drain queued watcher wakes, emit bounded best-effort status-event annotations, then assert watcher liveness | | `fm-wake-lib.sh` | Shared durable wake queue, portable locks, and watcher identity/health helpers | | `fm-classify-lib.sh` | Shared captain-relevant and declared-external-wait wake classification vocabulary | | `fm-send.sh` | Send one verified literal line or supported key through the target's recorded backend | -| `fm-tmux-lib.sh` | Shared tmux pane primitives for busy detection, composer capture, and verified submit | +| `fm-busy-lib.sh` | Single owner of the semantic busy-state contract: verdicts, source attribution, and per-harness sources | +| `fm-busy-event.sh` | The only writer of a task's semantic busy-state record; arms an incarnation and applies lifecycle events | +| `fm-tmux-lib.sh` | Shared tmux pane primitives for composer capture, verified submit, and the submit-time busy check | | `fm-peek.sh` | Print a bounded tail of a crewmate endpoint | | `fm-check-register.sh` | Bind an intentional custom watcher check to its current bytes | | `fm-check-lib.sh` | Validate custom-check registrations and prepare private execution snapshots | +| `fm-validation-lane.sh` | Queue and release one home-local no-mistakes validation slot | | `fm-pr-lib.sh` | Own canonical task and PR validation plus private atomic PR-poll publication and identity-bound retirement | | `fm-pr-poll.sh` | Provide the byte-static watcher program for validated PR/MR-poll sidecars | | `fm-pr-check-migrate.sh` | Quarantine older task polls without execution and rebuild only canonical polls | @@ -99,6 +104,9 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-x-dismiss.sh` | Dismiss a skipped X-mode mention at the relay without replying | | `fm-x-link.sh` | Link a spawned task to its originating X-mode mention in task meta | | `fm-x-followup.sh` | Detect, post, and cap completion follow-ups for an X-mode-linked task | +| `fm-public-followup-lib.sh` | Shared relay-activation gate, O(1) presence checks, and private transport paths for promised public replies | +| `fm-public-followup.sh` | Reconcile typed terminal work results into a public commitment and deliver its final reply once | +| `fm-public-followup-emit.sh` | Report one typed terminal work result into the home that owes the public reply | ## Convergence scoreboard diff --git a/docs/tmux-backend.md b/docs/tmux-backend.md index 3bf20fe9d1a..936e0e7b47d 100644 --- a/docs/tmux-backend.md +++ b/docs/tmux-backend.md @@ -59,9 +59,11 @@ Unreadable, incomplete, or structurally ambiguous boxes fail closed, and panes w The shared classifier accepts a shell glyph as an empty agent composer only inside a verified bordered composer. A bare shell prompt is `unknown`, so away-mode escalation is never injected into a dead shell. -Rendered busy detection is also harness-scoped. -Task metadata selects only that harness's verified signature, so output from one harness cannot make another harness appear busy. -The exact selection contract and safety rationale live in [architecture](architecture.md#runtime-session-backends), while the signatures live in [the harness-adapters skill](../.agents/skills/harness-adapters/SKILL.md). +Busy state is not read from rendered text on this backend. +A task's busy, idle, unknown, or dead verdict comes from the semantic busy-state contract owned by `bin/fm-busy-lib.sh`; [architecture](architecture.md#busy-state-is-semantic-per-adapter) owns its boundaries. +The one remaining rendered-tail reader is Grok's isolated fallback inside that contract, which can only classify a Grok task. +The submit acknowledgement and away-mode supervisor-pane busy guard below still consult rendered output, but only to decide whether input can be delivered, never to decide recorded task state. +The supervisor guard selects only the detected primary harness's signature rather than a global union of vendor patterns. `bin/fm-tmux-lib.sh` owns exact type-and-submit mechanics. It types a message once and retries Enter only until the composer clears. diff --git a/docs/verification/dispatch-auth.md b/docs/verification/dispatch-auth.md new file mode 100644 index 00000000000..86b9f4795df --- /dev/null +++ b/docs/verification/dispatch-auth.md @@ -0,0 +1,178 @@ +# Dispatch authentication verification + +Audience: maintainer verification. + +This record supports the dispatch judgment rules in `.agents/skills/quota-array-dispatch/SKILL.md` and the bounded vendor probe in `bin/fm-vendor-auth-probe.sh`. +It records only facts that must be re-established when a producer or vendor version changes. +Task chronology, incident transcripts, and credential metadata stay in private reports or PR evidence. + +Firstmate resolves a candidate's provider family, credential surface, and applicable quota by reading the evidence below and reasoning in the open. +No script maps a model to a provider, a provider to a credential store, or a name prefix to a family, so the facts here are what that reasoning rests on. +Credential paths below are shown with the home directory replaced by `<home>`. + +## Quota granularity the judgment depends on + +Verified 2026-07-30 against quota-axi 0.1.16. + +`quota-axi --json` reports availability at whatever granularity the vendor supplies, and states the vendor's own bounding rule in `quotaSemantics.description`. + +```json +{ + "provider": "codex", + "state": { "status": "fresh", "stale": false }, + "quotaSemantics": { + "status": "known", + "description": "Codex base account windows bound every model. Named model windows add bounds for that model; code-review windows describe a separate workload and are not included in model availability.", + "effectiveAvailability": [ + { "scope": "all_models", "status": "known", "effectivePercentRemaining": 64, "boundedBy": ["weekly"] }, + { "scope": "model:codex_bengalfox", "status": "known", "effectivePercentRemaining": 64, "boundedBy": ["weekly", "model:codex_bengalfox:7d"] } + ] + } +} +``` + +Three properties follow and are load-bearing for dispatch: + +- An `all_models` (or `all_products`) scope is real evidence for every model in that provider family, including a model with no window of its own. +- A `model:`-scoped entry is an additional bound for that one model. `model:codex_bengalfox` is the GPT-5.3-Codex-Spark window and bounds nothing else. +- A named-model window can be tighter than the account bound, so it must not be read across models. In the same snapshot Claude reported `all_models` with `effectivePercentRemaining` 10 while `model:fable` reported 4, limited by the `model:fable` window itself. A non-Fable Claude model reads 10, not 4. + +`quotaSemantics.status` is `unknown` with no `effectiveAvailability` entries at all for providers whose vendor exposes no window (observed for `cursor` and `copilot`). +`state.authStatus` is present only for some providers (observed for `grok` alone), so its absence is missing evidence, not a credential fault. + +## Completion-runway shape the judgment depends on + +Verified 2026-07-31 against quota-axi 0.1.17 schema 3. +The command below records the producer shape without persisting account-specific quota values: + +```sh +quota-axi --json | jq '{schemaVersion, effectiveAvailabilityFields: ([.providers[]?.quotaSemantics.effectiveAvailability[]? | keys] | unique), runwayFields: ([.providers[]?.quotaSemantics.effectiveAvailability[]?.runway? | select(type == "object") | keys] | unique)}' +``` + +```json +{ + "schemaVersion": 3, + "effectiveAvailabilityFields": [ + [ + "boundedBy", + "effectivePercentRemaining", + "limitingWindowIds", + "pace", + "runway", + "scope", + "status" + ] + ], + "runwayFields": [ + [ + "limitingWindowId", + "projectedExhaustedAt", + "projectionBasis", + "projectionConfidence", + "status", + "usableRunwaySeconds" + ], + [ + "limitingWindowId", + "projectedExhaustedAt", + "status", + "usableRunwaySeconds" + ] + ] +} +``` + +`runway` is nested under each effective-availability scope, so the same provider/model applicability rules govern both effective headroom and runway. +Projection confidence and basis are not present on every known runway, so selection must preserve their absence as uncertainty rather than fabricate them. +The older-schema fallback contract is owned by `quota-array-dispatch`; this evidence does not reinterpret an absent runway or pace field. + +## Provider-family counterfactual that this producer schema supports + +Verified 2026-07-30 on Pi 0.82.0 and quota-axi 0.1.16. + +```sh +pi --list-models terra +``` + +```text +provider model context max-out thinking images +openai-codex gpt-5.6-terra 272K 128K yes yes +``` + +The Pi catalog is authoritative for Pi model support and reports the provider family in its own column. +For `harness=pi`, `model=openai-codex/gpt-5.6-terra` the catalog establishes the model is supported and belongs to the `openai-codex` family, and the Codex `all_models` scope above supplies fresh, known 64 effective remaining for every model in that family. +No Terra-specific window exists in the snapshot, and `quota-axi auth --json` lists no `pi:openai-codex` source. +Both absences are missing model-level and source-level detail, not contradictory evidence, so this candidate is dispatchable with the model-level uncertainty disclosed. + +```sh +pi --list-models gpt-9.9-nonexistent +``` + +```text +No models matching "gpt-9.9-nonexistent" +``` + +A listing that reaches the account and returns no row is the authoritative negative that does block a candidate. + +## Credential sources are independent per provider + +Verified 2026-07-30 against quota-axi 0.1.16. + +`quota-axi auth --json` reports each provider's credential sources separately, which is what lets a candidate be scoped to the one surface it actually authenticates through: + +```json +[ + { "provider": "claude", "sources": [ + { "source": "oauth-file", "path": "<home>/.claude/.credentials.json", "status": "missing" }, + { "source": "keychain", "status": "available" } ] }, + { "provider": "codex", "sources": [ + { "source": "auth-json", "path": "<home>/.codex/auth.json", "status": "available" }, + { "source": "cli-rpc", "path": "<path-to>/codex", "status": "available" } ] }, + { "provider": "grok", "sources": [ + { "source": "auth-json", "path": "<home>/.grok/auth.json", "status": "available" }, + { "source": "pi:xai", "status": "available" } ] }, + { "provider": "kimi", "sources": [ + { "source": "pi:kimi-coding", "status": "available" }, + { "source": "kimi-code-cli", "status": "expired", "error": "kimi_code_cli_credential_expired" } ] } +] +``` + +Observed source statuses are `available`, `expired` (with an `error` slug), and `missing`. + +- A provider can carry a healthy source beside a missing or expired one, so a provider must not be collapsed to a single status. Claude's `oauth-file` is missing while its keychain source is available, and Kimi's standalone CLI credential is expired while its Pi source is available. +- A `pi:`-prefixed source exists only where Pi holds its own credential for that family (`pi:xai`, `pi:kimi-coding`). Pi's `openai-codex` family has none, because it authenticates through the Codex store that the `codex` provider already lists. A missing `pi:` source is therefore never evidence against a Pi candidate. + +Neither this per-source shape nor `state.authStatus` exists before quota-axi 0.1.16. +`bin/fm-bootstrap.sh` enforces that floor through `bin/fm-quota-axi-lib.sh`. + +Grok also reports `credits.remaining: 0` alongside `percentRemaining: 41` on a healthy account. +That zero is a prepaid balance, not the subscription window, and is never headroom. + +## Standalone Grok discovery probe + +Verified 2026-07-30 on `grok 0.2.117 (f1c06093089f) [stable]`. + +```sh +grok --version +grok models # stdin closed, single attempt, hard-bounded +``` + +Observed: + +- `grok models` exits `0` and its first stdout line is `You are logged in with grok.com.` for an authenticated session. +- With a home directory holding no Grok credential, the first stdout line is `You are not authenticated.`, also with exit status `0`. +- Because the status is `0` in both cases, the exit status is not a verdict; only the literal first stdout line is examined, and a blank first line does not authenticate. +- `<home>/.grok/auth.json` was byte-identical across the authenticated run (`mtime`, `size`, and mode `0600` unchanged), so the probe is a read in that path. + +These discriminator strings are un-owned vendor UI text. +`bin/fm-vendor-auth-probe.sh` pins the verified version, reports `versionVerified=no` when the running CLI differs, and classifies any unrecognized first line as `indeterminate` rather than authenticated. +Re-run the two commands above and update this section and the pinned version together when the vendor CLI changes. + +## Regression coverage + +`tests/fm-vendor-auth-probe.test.sh` drives the real script against a fake vendor CLI that records every invocation's argv and anything readable on stdin. +It asserts that the script accepts no harness, model, or provider input, never calls `quota-axi`, exits alike for every probe result because it renders no verdict, invokes only the two fixed non-destructive argv forms with stdin closed, holds a real bound even when the configured bound is zero or malformed, and never echoes raw vendor output. +`tests/fm-spawn-dispatch-profile.test.sh` owns spawn's deterministic profile and harness refusals. +`tests/fm-bootstrap.test.sh` owns the quota-axi version-floor diagnostic. +`tests/fm-quota-array-dispatch-live-e2e.test.sh` drives the public Pi skill-loading interface against one fake `quota-axi --json` snapshot per case. +It covers the Claude 1 percent versus Codex 55 percent reserve regression, explicit accounting for unmeasurable runway, and the strongest-reasoning constraint. diff --git a/docs/verification/public-followup.md b/docs/verification/public-followup.md new file mode 100644 index 00000000000..48f9f6d39e1 --- /dev/null +++ b/docs/verification/public-followup.md @@ -0,0 +1,102 @@ +# Promised public reply verification + +Audience: maintainer verification. + +This record supports two active guarantees for promised public replies made through the myfirstmate relay: + +1. A promised final reply survives compaction and restart, reconciles from disk alone, and lands in the original thread exactly once. +2. A home that never opted into the relay pays nothing for any of it. + +[`docs/configuration.md`](../configuration.md#promised-public-replies-statepublic-followup) owns the operator-facing contract, [`docs/architecture.md`](../architecture.md#optional-x-mode) owns the mechanism boundary, and `tasks-axi public-followup --help` owns the typed obligation schema. +Task chronology and delivery evidence stay outside this record. + +## Environment + +Recorded 2026-07-30 on Darwin 25.5.0 (arm64) with GNU bash 5.3.9, tasks-axi 0.2.3, jq 1.8.1, and ShellCheck 0.11.0 (the version `bin/fm-lint.sh` pins). +The relay is a fakebin `curl` in every case, so no public post is ever made; `tasks-axi` and `jq` are the real tools, because stubbing the obligation state machine would verify nothing. + +## Restart end-to-end and regressions + +```sh +bash tests/fm-public-followup.test.sh +``` + +``` +ok - outcome text is collapsed to one line, bounded by codepoint, and never corrupts characters +ok - restart end-to-end: typed result reconciles from disk and delivers one reply to the original thread +ok - duplicate terminal results, restart replay, and repeated delivery are all no-ops +ok - wrong source, wrong work id, stale generation, malformed, unsupported deliverable, and forged identity are all refused +ok - a relay transport failure is held as retryable with no false completion, and the retry posts once +ok - a late success receipt closes the exact attempt with no second post, and a mismatched attempt is refused +ok - a delivery interrupted between post and receipt refuses to repost +ok - a child home reports typed results but can never become the outward-post owner +ok - the retained private request context keeps the original thread deliverable after inbox cleanup +ok - cleanup refuses while a public reply is owed and proceeds once it has landed +ok - a relay-disabled home runs no tasks-axi call, prints nothing, and gains no artifact +ok - a relay-enabled home with no commitments makes no backlog call and stays silent +ok - a relay-exhausted follow-up binding is escalated rather than retried into the thread +ok - the relay poll stays inert without a token, silent with no commitments, and surfaces a new result once +ok - startup surfaces unresolved public commitments only in a relay home that owes one +ok - typed public-followup records carry only public-safe summaries and deliverables +``` + +The first case is the end-to-end proof. +It reproduces the stranded state first (work bound, no reconciled terminal result, delivery refused with "still waiting on its bound work" and zero posts), then has a secondmate-shaped child report a typed `pr-merged` result, deletes the drained inbox payload, reconciles from disk, and asserts exactly one `connector/followup` call carrying the original `request_id`, a validated `posted` receipt, and a Done obligation. + +The existing X-mode suite is unchanged by this work: + +```sh +bash tests/fm-x-mode.test.sh | grep -c '^ok -' +``` + +``` +103 +``` + +## Relay-disabled zero overhead + +A home with no `.env` at all, a `tasks-axi` shim that logs every invocation, and a full session-start run: + +```sh +find "$HOME_DIR/state" | LC_ALL=C sort > state-before.txt +FAKE_TASKS_AXI_LOG=tasks-axi.log bin/fm-session-start.sh > session-start.out 2>&1 +find "$HOME_DIR/state" | LC_ALL=C sort > state-after.txt +grep -c 'public-followup' tasks-axi.log +grep -ci 'public commitment' session-start.out +diff state-before.txt state-after.txt | grep '^>' +``` + +``` +0 +0 +> <home>/state/.lock +> <home>/state/.pr-check-migration-scan-v1 +> <home>/state/.pr-check-migration-v1 +> <home>/state/.wake-queue +``` + +No `tasks-axi public-followup` invocation, no public-commitments output, and no `state/public-followup` directory. +The four created paths are session-start's pre-existing session lock, PR-check migration markers, and wake queue, none of which this work touches. + +The whole added cost in that home is the activation predicate, measured over 1000 in-process calls including loop overhead: + +```sh +. bin/fm-public-followup-lib.sh +for i in $(seq 1 1000); do fm_pf_relay_active "$HOME_DIR" || true; done +``` + +``` +total_ns=69694000 per_call_us=69 +``` + +Roughly 0.07 ms per session start, from a single `[ -f "$FM_HOME/.env" ]` test that returns false before anything else runs. + +## Compatibility axes reviewed + +Primary harnesses (`claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, `kimi`): not applicable after inspection. +Nothing here reads or renders harness-specific state. +The only supervision surfaces touched are the session-start digest, which `bin/fm-supervision-instructions.sh` already renders per harness without knowing this section exists, and the wake payload produced by the existing relay poll, which every harness protocol consumes identically. + +Runtime backends (tmux, herdr, zellij, orca, cmux): not applicable after inspection. +No command here reads `state/<id>.meta`'s backend fields, resolves an endpoint, or captures a pane. +The one lifecycle integration is `bin/fm-teardown.sh`'s refusal, which runs before any backend command and keys only on the task id, so it behaves identically on every backend. diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index 57d66af05a0..34cd8f6e9c5 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -120,7 +120,8 @@ Claude, Codex, OpenCode, Pi, pi-signed, Grok, and Kimi share that backend cleanu ## Herdr The compatibility floor is protocol 14. -The latest active verification uses Herdr 0.7.5 protocol 16 on macOS aarch64, with earlier 0.7.4, protocol-14, and 0.7.3 evidence retained where they define current behavior or fallbacks. +The latest active verification uses Herdr 0.7.5 protocol 17 on macOS aarch64, with earlier 0.7.5 protocol-16, 0.7.4, protocol-14, and 0.7.3 evidence retained where they define current behavior or fallbacks. +Protocol 17 keeps every protocol-16 feature gate satisfied; the event and workspace-move floors remain 16. Core read-only probes: @@ -130,11 +131,11 @@ herdr status --json | jq -c '{client:.client.protocol,server:.server.protocol}' herdr api schema --json | jq -c '.schemas.subscription_event["$defs"].SubscriptionEventKind.enum' ``` -Observed current shapes: +Observed protocol-16 compatibility shapes: ```text herdr 0.7.5 -{"client":16,"server":16} +{"client":17,"server":17} ["pane.output_matched","pane.agent_status_changed","pane.scroll_changed"] ``` @@ -146,7 +147,7 @@ The CLI matrix was checked directly: | Literal send | `herdr pane send-text <pane> <text> --session <name>` | Left text unsubmitted until Enter. | | Keys | `herdr pane send-keys <pane> enter|escape|ctrl+c --session <name>` | Enter and Escape worked; Ctrl-C interrupted foreground work. | | Capture | `herdr pane read <pane> --source recent --lines N` | Small N could return empty below viewport height; a 200-line request plus local trim was stable. | -| Native state | `herdr agent get <pane>` | Working and done transitions were visible; long foreground tool waits required rendered-busy corroboration. | +| Native state | `herdr agent get <pane>` | Working and done transitions were visible; native `busy` remains positive activity evidence, while native `idle` cannot close a turn and the adapter's semantic lifecycle decides worker state. | | Restart | guarded named-session stop then start | Workspace, tab, pane, and labels persisted; the agent process and registration did not. | | Close | `herdr pane close <pane> --session <name>` | The exact one-pane task tab closed; closing a final tab could remove the workspace. | @@ -173,6 +174,63 @@ HERDR_LAB_HELPER=bin/fm-herdr-lab.sh \ Observed guarantee: a restored no-agent tab was replaced create-before-close, while a registered live agent caused refusal. +### Launcher workspace placement + +Herdr exports its pane identity into every process it manages, checked on 2026-07-30 against Herdr 0.7.5 protocol 17 inside a guarded lab pane: + +```sh +HERDR_LAB_HELPER=bin/fm-herdr-lab.sh +"$HERDR_LAB_HELPER" run "$LAB" pane run "$PANE" "sh -c 'env | grep ^HERDR | sort > /tmp/env.txt'" +``` + +```text +HERDR_ENV=1 +HERDR_PANE_ID=w1:p1 +HERDR_SESSION=fm-lab-fm-herdr-env-pro-65961-25535 +HERDR_SOCKET_PATH=/Users/kunchen/.config/herdr/sessions/fm-lab-fm-herdr-env-pro-65961-25535/herdr.sock +HERDR_TAB_ID=w1:t1 +HERDR_WORKSPACE_ID=w1 +``` + +This complete injection shape is verified only for Herdr 0.7.5. +Firstmate requires both `HERDR_PANE_ID` and `HERDR_SOCKET_PATH` before accepting claimed launcher ancestry. + +`pane get` reports the pane's current owning tab and workspace, which is what placement resolves from; the injected `HERDR_TAB_ID` and `HERDR_WORKSPACE_ID` are creation-time snapshots and are not read as current identity: + +```sh +"$HERDR_LAB_HELPER" run "$LAB" pane get w1:p1 | jq -c '.result.pane | {pane_id,tab_id,workspace_id}' +``` + +```text +{"pane_id":"w1:p1","tab_id":"w1:t1","workspace_id":"w1"} +``` + +Placement is owned by: + +```sh +HERDR_LAB_HELPER=bin/fm-herdr-lab.sh \ + tests/fm-backend-herdr-launcher-workspace-e2e.test.sh +``` + +Observed guarantees on 2026-07-30 against Herdr 0.7.5 protocol 17: + +```text +ok - real herdr E2E: with one 'firstmate' workspace and no herdr parent, a crewmate still lands in this home's own workspace without stealing focus +ok - real herdr E2E: the normal unique-label path is unchanged when the launcher's own pane identifies the workspace +ok - real herdr E2E: presentation spaces still create the isolated child workspace and bind it under the launcher's exact parent, without stealing focus +ok - real herdr E2E: with two 'firstmate' workspaces, a worker spawned from inside the second one lands in that exact workspace +ok - real herdr E2E: the duplicate-labeled sibling workspace is left entirely untouched and focus is preserved +ok - real herdr E2E: with a duplicated home label, a projected worker still hangs off the launcher's exact workspace and the sibling stays untouched +ok - real herdr E2E: an ambiguous home label with no launcher identity refuses before any worker endpoint exists +ok - real herdr E2E: a launcher pane that no longer exists refuses before any worker endpoint exists +ok - real herdr E2E: a secondmate launching its own worker gets the same exact-workspace guarantee, and its same-labeled sibling is untouched +ok - real herdr E2E: a --secondmate launch still stands up that secondmate's own workspace instead of inheriting the launcher's +ok - real herdr E2E: teardown closes only the worker's own pane and leaves the launcher, its workspace, and the same-labeled sibling intact +``` + +That suite's headline case runs `bin/fm-spawn.sh` inside a real Herdr pane, so the parent identity comes from Herdr's own injection rather than a composed environment. +Cross-session and contradictory bindings are covered deterministically in `tests/fm-backend-herdr.test.sh`, which can script a second server's socket without provisioning one. + ### Per-home and presentation topology Per-home behavior is owned by: @@ -282,6 +340,56 @@ FM_REQUIRE_MACOS_FOCUS_AUDIT=1 \ The test starts an `NSWorkspace.didActivateApplicationNotification` watcher before the complete projected spawn and keeps it active through a two-second settle window. It fails if any activation has an empty or different bundle identifier from the initially frontmost app. The required macOS Herdr-focus CI job makes an unavailable activation audit a hard failure, while non-macOS runs retain the logical focus checks without claiming app-activation coverage. +### Workspace-removal focus safety + +The focus-flash regression ran on 2026-07-28 against Herdr 0.7.5 protocol 17 on macOS aarch64: + +```sh +HERDR_LAB_HELPER=bin/fm-herdr-lab.sh \ + tests/fm-backend-herdr-focus-flash-e2e.test.sh +``` + +Observed output: + +```text +ok - old path: the explicit last-pane close of a non-focused workspace stole focus (w3 w3:t1 -> w2 w2:t1) +ok - mitigation: every in-operation sample preserved exact focus while the doomed workspace was removed +ok - mitigation: no explicit close and no corrective focus were needed on the defective release +evidence: herdr=0.7.5 protocol=17 steal_live=1 default-session-tripwire=armed +``` + +Direct lab probes on the same day established the removal rules the emptying-close plan relies on, each verified with `workspace list` focus reads around one mutation in a guarded `fm-lab-` session: + +- An explicit `pane close` that emptied a non-focused workspace moved focus off the focused workspace in both before-focus and after-focus geometries. +- Ending a workspace's lone shell preserved the focused workspace exactly when the dying workspace sat behind it or the focused workspace was last, and moved focus to the focused workspace's right neighbor otherwise. +- The production focus-preserving close in the dangerous geometry repositioned the doomed workspace, ended its proved shell, and left every concurrent focus sample on the exact anchor with no corrective `tab focus` issued. + +Two real-hardware conditions were required for the pane-death path to engage and are now encoded in the adapter and its unit fixtures: BSD `ps` reports a login shell's `comm` as `-zsh`, and an idle shell transiently hosts a prompt helper (starship) as a second foreground process immediately after a `workspace.move` relayout, which the bounded settle window absorbs. + +The rules match the v0.7.5 tag source (`close_selected_workspace` reassigns focus from the closing workspace's index; `handle_pane_died` only clamps the stale focused index), and the upstream default branch resolves both paths by workspace id (PR #1877, commit `165dca45`, for the explicit close; PR #1912, commit `a979916`, for pane death), so the plan degrades to a harmless reorder-then-remove once a release carries them. + +The full projection and restored-shell suites were re-run the same day on the same version with the updated close path; the presentation suite completed with `real Herdr lab validation completed on Herdr 0.7.5 with the default-session tripwire intact`, and the restored-shell cleanup guarantee above was unchanged. + +The teardown-level record-retention gate was verified on 2026-07-28 with metadata fixtures and a live contending lock holder: + +```sh +tests/fm-teardown.test.sh +tests/fm-backend-herdr.test.sh +``` + +Observed guarantees: a contended presentation lock refused the teardown before the isolated copy was returned, with the task branch, every durable record, and the endpoint intact and no pane close attempted; the retry after the contention cleared returned the copy, closed the pane under the lock, and removed the records; an unknown structured-presence result after an attempted projected close retained the journal and every record with a nonzero exit; and every presence-gate mode accepted only a structured not-found as gone. + +The same fixtures verified three further boundaries on 2026-07-29: missing or malformed endpoint identity and an unparseable pane presence refused record removal with everything retained; the SIGKILL escalation re-read the exact pane's process information and refused to signal when a different shell pid owned the pane, falling back to the plain close with the original process untouched; and a reposition whose removal then failed on every path restored the exact original workspace order through a second verified move and reported the close as failed. + +The teardown fixture was re-run on 2026-07-31 after extending the same fail-closed boundary through forced secondmate cleanup, including recursive cleanup of a nested secondmate whose Herdr grandchild close remains unconfirmed. + +Observed output: + +```text +ok - forced secondmate teardown preflights every Herdr child before cleanup mutation +ok - forced secondmate teardown retains Herdr child identity until exact pane disappearance +ok - forced teardown retains a nested secondmate home and its grandchild's Herdr identity when the grandchild close is unconfirmed +``` ### Composer and operational input diff --git a/docs/verification/stow-memory.md b/docs/verification/stow-memory.md new file mode 100644 index 00000000000..39e61eac4fb --- /dev/null +++ b/docs/verification/stow-memory.md @@ -0,0 +1,217 @@ +# Startup-memory `/stow` verification + +Audience: maintainer verification. + +This record supports the active bounded-memory and whole-file curation guarantees for Firstmate's internal `/stow` skill. +[`docs/configuration.md`](../configuration.md) owns the current operator-facing setting and estimate. +The internal skill owns curation and completion-receipt behavior. +Task chronology, fixture paths, and delivery evidence remain outside this record. + +## Synthetic real-agent pass + +The development-only real-agent pass ran on 2026-07-30 with Pi 0.82.0 on `openai-codex/gpt-5.6-terra` at medium thinking. +It used disposable primary and secondmate-shaped `FM_HOME` directories under the repository worktree only. +No live Firstmate memory, project data, credential content, or external system was placed in either fixture or prompt. +The following exact Bash shell body created the sanitized fixtures, invoked the model-qualified skill twice per home, and captured reports, hashes, and file modes: + +```bash +set -eu +VERIFY_ROOT=$(mktemp -d "$PWD/.stow-verification.XXXXXX") +RUNTIME_ROOT="$VERIFY_ROOT/runtime-root" +PRIMARY="$VERIFY_ROOT/primary" +SECONDMATE="$VERIFY_ROOT/secondmate" +SECONDMATE_ID=stow-verification +mkdir -p "$RUNTIME_ROOT" "$PRIMARY/config" "$PRIMARY/data" \ + "$SECONDMATE/bin" "$SECONDMATE/config" "$SECONDMATE/data" +printf '%s\n' 350 >"$PRIMARY/config/startup-memory-budget" +printf '%s\n' "$SECONDMATE_ID" >"$SECONDMATE/.fm-secondmate-home" +printf '%s\n' '# Synthetic Firstmate home' >"$SECONDMATE/AGENTS.md" + +file_mode() { + if [ "$(uname)" = Darwin ]; then + stat -f %Lp "$1" + else + stat -c %a "$1" + fi +} + +record_shared_state() { + label=$1 + path=$2 + printf '%s sha256=%s mode=%s\n' "$label" \ + "$(shasum -a 256 "$path" | awk '{print $1}')" \ + "$(file_mode "$path")" +} + +cat >"$PRIMARY/data/captain.md" <<'EOF' +# Captain + +## Current preferences + +- Prefer the simplest direct end-to-end operational path. +- Preserve unique current facts when compacting memory. +- Use plain dashes in prose. + +## Duplicate and superseded material + +- Prefer the simplest direct end-to-end operational path. +- Old policy: build a wrapper before every one-off operation. +- Old policy copy: always build a wrapper for one-off work. +- Stale tool path: `/opt/old-firstmate/bin/fm`. +- Stale release version: 0.41.0. +- Completed task: migrated the demo fixture on Monday. +- Completed task detail: checked the demo fixture again on Tuesday. +- Metric from the completed task: 47 records moved. +EOF + +cat >"$PRIMARY/data/captain-shared.md" <<'EOF' +# Shared captain preferences + +This file is main-authoritative in the main firstmate home. +In secondmate homes it is read-only in secondmate homes and must not be edited there. +Route new captain-preference discoveries to the main firstmate through marked status or a document pointer. + +- Never expose secrets or weaken an accepted safety boundary. +- Prefer the simplest direct end-to-end operational path. +- Superseded policy: secondmates may rewrite shared memory when convenient. +- Duplicate safety note: do not expose secrets. +EOF + +cat >"$PRIMARY/data/learnings.md" <<'EOF' +# Learnings + +- Stable fact: startup-memory configuration is documented in `docs/configuration.md`. +- Authoritative pointer: incident detail belongs in `data/reports/synthetic-incident.md`. +- Stable fact copy: consult `docs/configuration.md` for startup-memory configuration. +- Completed chronology: first the synthetic incident was detected, then triaged, then assigned. +- Completed chronology continued: a patch was drafted, reviewed, merged, and announced. +- Old metric: the discarded prototype used 812 estimated tokens. +- Stale path: the discarded prototype lived at `/tmp/old-memory-prototype`. +- Superseded alternative: maintain both a JSON memory database and Markdown files. +- Report-sized procedure: create a staging directory, enumerate every file, copy each file, compare every line, write a status ledger, notify all operators, archive the ledger, and repeat the entire sequence after every prompt. +EOF + +FM_HOME="$PRIMARY" bin/fm-startup-memory-budget.sh report \ + >"$VERIFY_ROOT/primary.before.report" +for file in captain.md captain-shared.md learnings.md; do + shasum -a 256 "$PRIMARY/data/$file" +done >"$VERIFY_ROOT/primary.before.sha256" + +FM_HOME="$PRIMARY" pi -p --no-session --no-extensions --no-context-files \ + --model openai-codex/gpt-5.6-terra --thinking medium \ + --skill .agents/skills/stow/SKILL.md \ + 'Invoke /stow now against only the disposable synthetic Firstmate home in $FM_HOME. There are no new session facts to file. Follow every requirement in the loaded stow skill. Run the repository-owned bin/fm-startup-memory-budget.sh report command, with the existing FM_HOME environment, before and after curation; that executable is the only permitted path outside $FM_HOME. Retain the exact before total, preserve the complete main-authoritative routing header in data/captain-shared.md, and make the completion receipt state the effective budget, exact before and after totals, an action for each of the three files, every exception, and reset safety. Inspect all three startup-memory files completely, preserve every unique current preference, authority or safety boundary, stable fact, and authoritative pointer, and consolidate the supplied duplicate, superseded, stale, chronological, metric, and report-sized material. Do not access or modify any other home, credential, project data, or external system.' \ + >"$VERIFY_ROOT/primary.pass1.out" +FM_HOME="$PRIMARY" bin/fm-startup-memory-budget.sh report \ + >"$VERIFY_ROOT/primary.after.report" +for file in captain.md captain-shared.md learnings.md; do + shasum -a 256 "$PRIMARY/data/$file" +done >"$VERIFY_ROOT/primary.after.sha256" + +FM_HOME="$PRIMARY" pi -p --no-session --no-extensions --no-context-files \ + --model openai-codex/gpt-5.6-terra --thinking medium \ + --skill .agents/skills/stow/SKILL.md \ + 'Invoke /stow now against only the disposable synthetic Firstmate home in $FM_HOME. There are no new session facts to file. Follow every requirement in the loaded stow skill. Run the repository-owned bin/fm-startup-memory-budget.sh report command, with the existing FM_HOME environment, before and after curation; that executable is the only permitted path outside $FM_HOME. Retain the exact before total, preserve the complete main-authoritative routing header in data/captain-shared.md, and make the completion receipt state the effective budget, exact before and after totals, an action for each of the three files, every exception, and reset safety. Inspect all three startup-memory files completely, preserve every unique current preference, authority or safety boundary, stable fact, and authoritative pointer, and consolidate the supplied duplicate, superseded, stale, chronological, metric, and report-sized material. Do not access or modify any other home, credential, project data, or external system.' \ + >"$VERIFY_ROOT/primary.pass2.out" +FM_HOME="$PRIMARY" bin/fm-startup-memory-budget.sh report \ + >"$VERIFY_ROOT/primary.repeat.report" +for file in captain.md captain-shared.md learnings.md; do + shasum -a 256 "$PRIMARY/data/$file" +done >"$VERIFY_ROOT/primary.repeat.sha256" + +cat >"$SECONDMATE/data/captain.md" <<'EOF' +# Secondmate captain memory + +- Current preference: report concrete blockers instead of guessing. +- Current preference copy: never guess when a concrete blocker can be reported. +- Shared overlap: never expose secrets. +- Superseded preference: silently infer missing configuration. +- Stale version: the fleet uses 0.41.0. +- Completed task: inspected the synthetic queue yesterday. +- Completed task detail: closed the synthetic queue inspection after 19 checks. +EOF + +cat >"$SECONDMATE/data/learnings.md" <<'EOF' +# Secondmate learnings + +- Unique current learning: inherited shared memory counts against the local total. +- Authoritative pointer: startup-memory behavior is documented in `docs/configuration.md`. +- Duplicate learning: include inherited shared memory in the local total. +- Stale path: `/tmp/secondmate-memory-v1`. +- Superseded alternative: copy shared facts into every local file. +- Completed chronology: opened the sample, measured it, discussed it, revised it, remeasured it, and closed it. +- Old metric: the sample once measured 604 estimated tokens. +- Report-sized procedure: take a snapshot, copy it to a ledger, annotate every old measurement, preserve every discarded alternative, append a timestamp, and repeat after each completed task. +EOF + +FM_ROOT="$RUNTIME_ROOT" +FM_HOME="$PRIMARY" +. bin/fm-ff-lib.sh +. bin/fm-config-inherit-lib.sh +validate_secondmate_home "$SECONDMATE_ID" "$SECONDMATE" +printf 'secondmate_validation=accepted id=%s home=%s\n' \ + "$SECONDMATE_ID" "$VALIDATED_HOME" >"$VERIFY_ROOT/inheritance.out" +FM_CONFIG_INHERIT_REPORT="$VERIFY_ROOT/inheritance.report" \ + propagate_secondmate_inheritance \ + "$PRIMARY" "$VALIDATED_HOME" "$PRIMARY/config" "$PRIMARY/data" +cat "$VERIFY_ROOT/inheritance.report" >>"$VERIFY_ROOT/inheritance.out" +cmp -s "$PRIMARY/data/captain-shared.md" \ + "$SECONDMATE/data/captain-shared.md" +record_shared_state inherited "$SECONDMATE/data/captain-shared.md" \ + >>"$VERIFY_ROOT/inheritance.out" + +FM_HOME="$SECONDMATE" bin/fm-startup-memory-budget.sh report \ + >"$VERIFY_ROOT/secondmate.before.report" +for file in captain.md captain-shared.md learnings.md; do + shasum -a 256 "$SECONDMATE/data/$file" +done >"$VERIFY_ROOT/secondmate.before.sha256" +record_shared_state before "$SECONDMATE/data/captain-shared.md" \ + >"$VERIFY_ROOT/secondmate.shared-state" + +FM_HOME="$SECONDMATE" pi -p --no-session --no-extensions --no-context-files \ + --model openai-codex/gpt-5.6-terra --thinking medium \ + --skill .agents/skills/stow/SKILL.md \ + 'Invoke /stow now against only the validated disposable synthetic secondmate home in $FM_HOME. There are no new session facts to file. Follow every requirement in the loaded stow skill. Run the repository-owned bin/fm-startup-memory-budget.sh report command, with the existing FM_HOME environment, before and after curation; that executable is the only permitted path outside $FM_HOME. Retain the exact before total, and make the completion receipt state the effective budget, exact before and after totals, an action for each of the three files, every exception, and reset safety. Inspect all three startup-memory files completely, keep data/captain-shared.md byte-identical and filesystem read-only because it was installed through primary-authoritative inheritance, preserve every unique current preference, stable learning, and authoritative pointer, and consolidate the supplied duplicate, superseded, stale, chronological, metric, overlap, and report-sized material in editable local memory. Do not access or modify any other home, credential, project data, or external system.' \ + >"$VERIFY_ROOT/secondmate.pass1.out" +FM_HOME="$SECONDMATE" bin/fm-startup-memory-budget.sh report \ + >"$VERIFY_ROOT/secondmate.after.report" +for file in captain.md captain-shared.md learnings.md; do + shasum -a 256 "$SECONDMATE/data/$file" +done >"$VERIFY_ROOT/secondmate.after.sha256" +record_shared_state after "$SECONDMATE/data/captain-shared.md" \ + >>"$VERIFY_ROOT/secondmate.shared-state" + +FM_HOME="$SECONDMATE" pi -p --no-session --no-extensions --no-context-files \ + --model openai-codex/gpt-5.6-terra --thinking medium \ + --skill .agents/skills/stow/SKILL.md \ + 'Invoke /stow now against only the validated disposable synthetic secondmate home in $FM_HOME. There are no new session facts to file. Follow every requirement in the loaded stow skill. Run the repository-owned bin/fm-startup-memory-budget.sh report command, with the existing FM_HOME environment, before and after curation; that executable is the only permitted path outside $FM_HOME. Retain the exact before total, and make the completion receipt state the effective budget, exact before and after totals, an action for each of the three files, every exception, and reset safety. Inspect all three startup-memory files completely, keep data/captain-shared.md byte-identical and filesystem read-only because it was installed through primary-authoritative inheritance, preserve every unique current preference, stable learning, and authoritative pointer, and consolidate the supplied duplicate, superseded, stale, chronological, metric, overlap, and report-sized material in editable local memory. Do not access or modify any other home, credential, project data, or external system.' \ + >"$VERIFY_ROOT/secondmate.pass2.out" +FM_HOME="$SECONDMATE" bin/fm-startup-memory-budget.sh report \ + >"$VERIFY_ROOT/secondmate.repeat.report" +for file in captain.md captain-shared.md learnings.md; do + shasum -a 256 "$SECONDMATE/data/$file" +done >"$VERIFY_ROOT/secondmate.repeat.sha256" +record_shared_state repeat "$SECONDMATE/data/captain-shared.md" \ + >>"$VERIFY_ROOT/secondmate.shared-state" +``` + +Bounded observed output: + +```text +secondmate_validation=accepted id=stow-verification +startup-memory-budget pushed +data/captain-shared.md pushed +inherited sha256=d08ce8e35b17c8342773d551b5c1551a5a6ded5f45ab0f7ed5b6ef91ea1d408c mode=444 +primary: 699 -> 219 estimated tokens against a 350-token budget +primary repeat: 219 -> 219; all three files byte-identical +secondmate: 518 -> 192 estimated tokens against a 350-token budget +secondmate repeat: 192 -> 192; all three files byte-identical +before sha256=d08ce8e35b17c8342773d551b5c1551a5a6ded5f45ab0f7ed5b6ef91ea1d408c mode=444 +after sha256=d08ce8e35b17c8342773d551b5c1551a5a6ded5f45ab0f7ed5b6ef91ea1d408c mode=444 +repeat sha256=d08ce8e35b17c8342773d551b5c1551a5a6ded5f45ab0f7ed5b6ef91ea1d408c mode=444 +``` + +The first pass preserved current preferences, shared-memory and safety authority, a stable operating fact, and authoritative configuration and incident-report pointers while removing duplicate, superseded, stale, and chronological material. +The secondmate fixture passed the production home validator before the existing inheritance owner installed the main-authoritative file read-only. +Both secondmate passes preserved its unique local preference and learning while leaving those inherited bytes and mode untouched. +This verifies the real instruction path consolidates to budget, reports truthful deltas, preserves the primary-owned shared boundary, and does not grow on an identical second pass. diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index ff3b8d6ac8c..15e5d4c5339 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -60,6 +60,40 @@ The Ahoy first-message boundary was reverified on 2026-07-22 with Pi 0.81.1 and Marked current operational input and the two exact legacy compatibility shapes selected Bearings, while genuine near-miss captain messages remained real boundaries. The detailed reconciliation and task chronology stay in the private audit report and PR evidence. +## Semantic busy state + +The per-adapter semantic sources behind [`bin/fm-busy-lib.sh`](../../bin/fm-busy-lib.sh) were live-verified on 2026-07-28 against firstmate-launched workers wired exactly as `fm-spawn` writes them. +Each pass polled `state/<id>.busy-state` while a real turn ran. + +| Harness | Version verified | Semantic source | Observed result | +| --- | --- | --- | --- | +| Pi | 0.82.0 | Extension `agent_start` / `agent_settled` with `ctx.isIdle()` | The spawn seed `busy source=fm-spawn`, then `busy source=pi-ext event=agent-start`, then `idle source=pi-ext event=agent-settled`; the turn-end marker was still touched. | +| OpenCode | 1.17.18 | Plugin `session.status` | In a real TUI pane: seed, then `busy source=opencode-plugin event=session-busy`, then `idle source=opencode-plugin event=session-status-idle`. | +| Claude | 2.1.220 (Claude Code) | Hooks `UserPromptSubmit`, `Stop`, `StopFailure`, `SessionEnd` | `UserPromptSubmit` fired for the argv launch prompt and each steer, and `Stop` closed every completed turn. A mid-stream Escape interrupt fired no closing hook, which is why the firstmate-controlled clear exists. `StopFailure` and `SessionEnd` are wired from the four hook names present in the installed binary; only the abnormal paths they cover were not reproduced live. | +| Codex | codex-cli 0.145.0 | None usable | See below; classifies `unknown codex-unverified`. | +| Kimi (standalone) | not installed | None usable | No binary on `PATH`, so the gate stays closed and it classifies `unknown kimi-unverified`. | +| Grok | 0.2.112 | Isolated rendered-tail fallback | Retained unconverted; the approved audit could not credit a live structured-lifecycle run. | + +Codex was probed two ways, both refused: + +```sh +codex app-server daemon start +codex exec --dangerously-bypass-approvals-and-sandbox --dangerously-bypass-hook-trust 'Reply with exactly PROBE2.' +``` + +The daemon refused with `managed standalone Codex install not found`, and an interactive TUI worker neither starts nor attaches to the app-server control socket, so no client can observe its turns. +Firstmate-written project hooks under `<worktree>/.codex/hooks.json` fired for neither an interactive pane whose directory trust was granted nor `codex exec`, in both cases with `--dangerously-bypass-hook-trust`, while global `~/.codex/hooks.json` `SessionStart` hooks fired in the same runs. +Codex also exposes no `StopFailure` hook, so an API-error turn end would need separate coverage even after hook discovery works. +The app-server protocol schema does define the required lifecycle (`turn/started`, plus a `turn/completed` status of `completed`, `interrupted`, `failed`, or `inProgress`), so the gate is a reachability problem rather than a protocol gap. + +Deterministic entry points: + +```sh +tests/fm-busy-state.test.sh +tests/fm-busy-adapter-wiring.test.sh +tests/fm-crew-state.test.sh +``` + ## Turn-end guard The direct and passive mechanisms were validated across all five harnesses on 2026-07-08 through 2026-07-12, with Claude's replacement Stop-owned path revalidated on 2026-07-24. diff --git a/docs/zellij-backend.md b/docs/zellij-backend.md index 367da98ebbd..c9f440b468e 100644 --- a/docs/zellij-backend.md +++ b/docs/zellij-backend.md @@ -91,7 +91,8 @@ Real test cleanup uses only an isolated non-`firstmate` session and the guard in - Zellij is experimental and explicit-only. - All homes share one session and tab bar; scoped titles prevent cross-home identity collisions but do not create per-home visual containers. -- There is no native busy or push-event signal, so supervision uses capture/hash and busy-regex polling. +- There is no native busy or push-event signal, so supervision uses capture/hash polling for screen changes and each harness adapter's semantic lifecycle for worker state. + Grok alone retains its isolated rendered-tail fallback. - There is no verified agent-process liveness signal, so a dead Zellij secondmate is reported inconclusive rather than auto-respawned. - New-tab focus restoration has a narrow visible race. - CLI exit status is not meaningful; a target can still disappear after structural readiness checks. diff --git a/tests/fixtures/quota-array-dispatch/cases.json b/tests/fixtures/quota-array-dispatch/cases.json deleted file mode 100644 index c6fc3c3a867..00000000000 --- a/tests/fixtures/quota-array-dispatch/cases.json +++ /dev/null @@ -1,394 +0,0 @@ -{ - "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": "select-pi-xai-before-authentication", - "expect": "pi-xai", - "reason": "an unauthenticated standalone Grok candidate cannot block selected authenticated Pi/xAI", - "candidates": [ - { - "id": "pi-xai", - "harness": "pi", - "model": "xai/grok-4.5", - "provider": "xai", - "authenticationSurface": "Pi xAI OAuth", - "authAvailable": true, - "fit": "comparable", - "reasoningClass": "strong", - "tight": false, - "rawHeadroom": 55, - "paceStatus": "behind", - "aheadWindowIds": [], - "worstReserve": 15.0, - "unknownPace": false, - "paceAvailable": true - }, - { - "id": "standalone-grok", - "harness": "grok", - "model": "grok-4.5", - "provider": "grok", - "authenticationSurface": "Grok Build CLI", - "authAvailable": false, - "authFailure": "Grok Build CLI login missing", - "fit": "comparable", - "reasoningClass": "strong", - "tight": false, - "rawHeadroom": 80, - "paceStatus": "ahead", - "aheadWindowIds": ["weekly"], - "worstReserve": -12.0, - "unknownPace": false, - "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 deleted file mode 100644 index a79f86aca32..00000000000 --- a/tests/fixtures/quota-array-dispatch/schema-v3-shape.json +++ /dev/null @@ -1,103 +0,0 @@ -{ - "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-afk-inject-herdr-e2e.test.sh b/tests/fm-afk-inject-herdr-e2e.test.sh index 644d015086f..9c5c66c5e88 100755 --- a/tests/fm-afk-inject-herdr-e2e.test.sh +++ b/tests/fm-afk-inject-herdr-e2e.test.sh @@ -41,6 +41,11 @@ command -v jq >/dev/null 2>&1 || { echo "skip: jq not found (required by the her # shellcheck source=tests/herdr-test-safety.sh . "$ROOT/tests/herdr-test-safety.sh" +# This suite runs against its own isolated lab session, so a Herdr pane +# inherited from the terminal it was launched in must not follow spawn into it +# as a cross-session parent identity (tests/herdr-test-safety.sh). +herdr_forget_inherited_pane + fail() { printf 'not ok - %s\n' "$1" >&2; cleanup_all; exit 1; } pass() { printf 'ok - %s\n' "$1"; } diff --git a/tests/fm-afk-launch.test.sh b/tests/fm-afk-launch.test.sh index b65bd9cdc26..de6b827aa85 100755 --- a/tests/fm-afk-launch.test.sh +++ b/tests/fm-afk-launch.test.sh @@ -296,12 +296,23 @@ unit_signal_exits_with_lock_cleanup() { : > "$2" ' _ "$LAUNCH" "$marker" & child=$! - for _ in $(seq 1 40); do - [ -d "$st/state/.afk-launch.lock" ] && break + # Signal only once the lifecycle actually holds its lock. Killing before the + # lock exists tests nothing, and on a loaded machine it used to race: the + # lock could be created just after the kill and outlive the process. + local locked=0 _ + for _ in $(seq 1 100); do + if [ -d "$st/state/.afk-launch.lock" ]; then locked=1; break; fi sleep 0.05 done + [ "$locked" = 1 ] || fail "launcher signal: lifecycle never acquired its lock to interrupt" kill -TERM "$child" 2>/dev/null || true wait "$child" 2>/dev/null || true + # The signal handler releases the lock as it exits; give that removal a + # bounded settle rather than sampling the instant `wait` returns. + for _ in $(seq 1 100); do + [ -e "$st/state/.afk-launch.lock" ] || break + sleep 0.05 + done if [ ! -e "$marker" ] && [ ! -e "$st/state/.afk-launch.lock" ]; then pass "launcher signal: TERM exits and releases the lifecycle lock" else diff --git a/tests/fm-backend-autodetect-smoke.test.sh b/tests/fm-backend-autodetect-smoke.test.sh index b4c8c887fbf..17fe88f6171 100755 --- a/tests/fm-backend-autodetect-smoke.test.sh +++ b/tests/fm-backend-autodetect-smoke.test.sh @@ -43,6 +43,14 @@ command -v treehouse >/dev/null 2>&1 || { echo "skip: treehouse not found (requi export FM_GATE_REFUSE_BYPASS=1 +# shellcheck source=tests/herdr-test-safety.sh +. "$ROOT/tests/herdr-test-safety.sh" +# This suite asserts that HERDR_ENV=1 alone selects the backend, and it runs +# against its own isolated lab session. A Herdr pane inherited from the terminal +# it was launched in must not follow spawn into that session as a cross-session +# parent identity; the spawn below sets HERDR_ENV explicitly. +herdr_forget_inherited_pane + # TMP_ROOT is physically resolved (mktemp -d "$(pwd -P)"-relative) to keep this # real-herdr smoke fixture free of unrelated OS symlink noise. # The old fm-spawn bug that originally motivated this fixture shape was fixed in diff --git a/tests/fm-backend-herdr-eventwait-smoke.test.sh b/tests/fm-backend-herdr-eventwait-smoke.test.sh index 5616a5bc751..b383176d7d1 100755 --- a/tests/fm-backend-herdr-eventwait-smoke.test.sh +++ b/tests/fm-backend-herdr-eventwait-smoke.test.sh @@ -25,6 +25,11 @@ command -v python3 >/dev/null 2>&1 || { echo "skip: python3 not found (required # shellcheck source=tests/herdr-test-safety.sh . "$ROOT/tests/herdr-test-safety.sh" +# This suite runs against its own isolated lab session, so a Herdr pane +# inherited from the terminal it was launched in must not follow spawn into it +# as a cross-session parent identity (tests/herdr-test-safety.sh). +herdr_forget_inherited_pane + SESSION="fm-lab-eventwait-smoke-$$" export HERDR_SESSION="$SESSION" SCRATCH= diff --git a/tests/fm-backend-herdr-focus-flash-e2e.test.sh b/tests/fm-backend-herdr-focus-flash-e2e.test.sh new file mode 100755 index 00000000000..6145dec365b --- /dev/null +++ b/tests/fm-backend-herdr-focus-flash-e2e.test.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env bash +# Real-Herdr regression for the projected-cleanup focus flash (upstream +# ogulcancelik/herdr#1621 family, live on 0.7.5 stable). +# Part A reproduces the OLD path: an explicit last-pane close that empties a +# non-focused workspace steals the focused workspace. +# Part B proves the mitigation: the focus-safe emptying-close plan +# (repositioning move plus pane-death removal) removes the doomed workspace +# with no focus change and no corrective tab focus at all. +# On a future release whose explicit close preserves focus, Part A records +# that and Part B keeps outcome-only assertions, so no version is guessed. +# Every CLI operation is routed through one guarded named non-default lab, and +# lab teardown verifies that the default fleet session is byte-identical. +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +HERDR_LAB_HELPER=${HERDR_LAB_HELPER:-$ROOT/bin/fm-herdr-lab.sh} + +fail() { printf 'not ok - %s\n' "$1" >&2; exit 1; } +pass() { printf 'ok - %s\n' "$1"; } + +command -v herdr >/dev/null 2>&1 || { echo 'skip: herdr not found'; exit 0; } +command -v jq >/dev/null 2>&1 || { echo 'skip: jq not found'; exit 0; } +command -v python3 >/dev/null 2>&1 || { echo 'skip: python3 not found'; exit 0; } +[ -x "$HERDR_LAB_HELPER" ] || { echo "skip: Herdr lab helper not executable at $HERDR_LAB_HELPER"; exit 0; } + +HERDR_ORIGINAL_PATH=$PATH +TMP_ROOT=$(mktemp -d "$(cd "${TMPDIR:-/tmp}" && pwd -P)/fm-herdr-focus-flash-e2e.XXXXXX") +FAKEBIN="$TMP_ROOT/fakebin" +mkdir -p "$FAKEBIN" + +HERDR_LAB_SESSION=$("$HERDR_LAB_HELPER" name fm-herdr-focus-flash-regression-r1) +export HERDR_LAB_HELPER HERDR_LAB_SESSION HERDR_ORIGINAL_PATH +B_SAMPLER_PID= +B_SAMPLER_STOP= +cleanup() { + local status=$? + if [ -n "$B_SAMPLER_STOP" ]; then + : > "$B_SAMPLER_STOP" + fi + if [ -n "$B_SAMPLER_PID" ]; then + wait "$B_SAMPLER_PID" 2>/dev/null || true + fi + env PATH="$HERDR_ORIGINAL_PATH" "$HERDR_LAB_HELPER" teardown "$HERDR_LAB_SESSION" || status=1 + rm -rf "$TMP_ROOT" + exit "$status" +} +trap cleanup EXIT +"$HERDR_LAB_HELPER" provision "$HERDR_LAB_SESSION" + +# Keep the lab helper as the only CLI transport. Production adapter calls have +# already appended the exact session; this shim strips that pair, refuses every +# other caller-supplied session, and delegates the command to helper run. +cat > "$FAKEBIN/herdr" <<'SH' +#!/usr/bin/env bash +set -u +args=("$@") +last=$((${#args[@]} - 1)) +flag=$((last - 1)) +if [ "${#args[@]}" -ge 2 ] \ + && [ "${args[$flag]}" = --session ] \ + && [ "${args[$last]}" = "$HERDR_LAB_SESSION" ]; then + unset "args[$last]" "args[$flag]" +fi +set -- "${args[@]}" +for arg in "$@"; do + case "$arg" in --session|--session=*) exit 9 ;; esac +done +exec env PATH="$HERDR_ORIGINAL_PATH" "$HERDR_LAB_HELPER" run "$HERDR_LAB_SESSION" "$@" +SH +chmod +x "$FAKEBIN/herdr" + +lab() { env PATH="$HERDR_ORIGINAL_PATH" "$HERDR_LAB_HELPER" run "$HERDR_LAB_SESSION" "$@"; } +mkws() { # <label> -> "<workspace_id> <tab_id> <pane_id>" + lab workspace create --cwd "$ROOT" --label "$1" --no-focus \ + | jq -er '"\(.result.workspace.workspace_id) \(.result.tab.tab_id) \(.result.root_pane.pane_id)"' +} +focus_snapshot() { + local list workspace tab tabs + list=$(lab workspace list) || return 1 + workspace=$(printf '%s' "$list" | jq -er '[.result.workspaces[] | select(.focused == true)] | select(length == 1) | .[0].workspace_id') || return 1 + tab=$(printf '%s' "$list" | jq -er --arg workspace "$workspace" '[.result.workspaces[] | select(.workspace_id == $workspace)] | select(length == 1) | .[0].active_tab_id') || return 1 + tabs=$(lab tab list --workspace "$workspace") || return 1 + printf '%s' "$tabs" | jq -e --arg tab "$tab" '([.result.tabs[] | select(.focused == true)] | length) == 1 and ([.result.tabs[] | select(.focused == true)][0].tab_id == $tab)' >/dev/null || return 1 + printf '%s\t%s' "$workspace" "$tab" +} +ws_order() { lab workspace list | jq -er '[.result.workspaces[].workspace_id] | join(",")'; } +wait_ws_gone() { # <workspace_id> + local i=0 + while [ "$i" -lt 80 ]; do + lab workspace get "$1" >/dev/null 2>&1 || return 0 + sleep 0.1 + i=$((i + 1)) + done + return 1 +} + +# --- Part A: the OLD path (plain explicit close) steals focus on 0.7.5 ----- +# The spacer keeps the focused anchor away from the doomed workspace's right +# neighbor, where the 0.7.5 explicit close would land by coincidence. +read -r A_DOOMED_WS _ A_DOOMED_PANE <<<"$(mkws flash-a-doomed)" || fail 'could not create the Part A doomed workspace' +read -r _ _ _ <<<"$(mkws flash-a-spacer)" || fail 'could not create the Part A spacer workspace' +read -r A_ANCHOR_WS A_ANCHOR_TAB _ <<<"$(mkws flash-a-anchor)" || fail 'could not create the Part A anchor workspace' +read -r _ _ _ <<<"$(mkws flash-a-tail)" || fail 'could not create the Part A tail workspace' +lab tab focus "$A_ANCHOR_TAB" >/dev/null || fail 'could not focus the Part A anchor' +A_BEFORE=$(focus_snapshot) || fail 'could not capture the Part A pre-close focus' +[ "$A_BEFORE" = "$(printf '%s\t%s' "$A_ANCHOR_WS" "$A_ANCHOR_TAB")" ] \ + || fail 'Part A anchor focus does not match the intended workspace and tab' +lab pane close "$A_DOOMED_PANE" >/dev/null || fail 'Part A explicit close failed' +wait_ws_gone "$A_DOOMED_WS" || fail 'Part A doomed workspace survived the explicit close' +A_AFTER=$(focus_snapshot) || fail 'could not capture the Part A post-close focus' +STEAL_LIVE=0 +if [ "$A_AFTER" != "$A_BEFORE" ]; then + STEAL_LIVE=1 + pass "old path: the explicit last-pane close of a non-focused workspace stole focus ($A_BEFORE -> $A_AFTER)" + lab tab focus "$A_ANCHOR_TAB" >/dev/null || fail 'could not restore the Part A anchor focus' +else + pass 'old path note: this Herdr release preserves focus across the explicit close; continuing with outcome-only assertions' +fi + +# --- Part B: the mitigation in the dangerous geometry ---------------------- +# The doomed workspace sits BEFORE the focused anchor and the anchor is not +# last, the exact shape where an unrepositioned pane death also steals focus. +read -r B_DOOMED_WS _ B_DOOMED_PANE <<<"$(mkws flash-b-doomed)" || fail 'could not create the Part B doomed workspace' +read -r B_ANCHOR_WS B_ANCHOR_TAB _ <<<"$(mkws flash-b-anchor)" || fail 'could not create the Part B anchor workspace' +read -r _ _ _ <<<"$(mkws flash-b-tail)" || fail 'could not create the Part B tail workspace' +lab tab focus "$B_ANCHOR_TAB" >/dev/null || fail 'could not focus the Part B anchor' +B_BEFORE=$(focus_snapshot) || fail 'could not capture the Part B pre-close focus' +[ "$B_BEFORE" = "$(printf '%s\t%s' "$B_ANCHOR_WS" "$B_ANCHOR_TAB")" ] \ + || fail 'Part B anchor focus does not match the intended workspace and tab' +B_SURVIVOR_ORDER=$(ws_order | tr ',' '\n' | grep -v "^$B_DOOMED_WS\$" | paste -sd, -) \ + || fail 'could not capture the Part B survivor order' + +CALL_LOG="$TMP_ROOT/call.log" +B_FOCUS_SAMPLES="$TMP_ROOT/focus.samples" +B_OPERATION_ACTIVE="$TMP_ROOT/operation.active" +B_SAMPLER_READY="$TMP_ROOT/sampler.ready" +B_SAMPLER_STOP="$TMP_ROOT/sampler.stop" +: > "$CALL_LOG" +: > "$B_FOCUS_SAMPLES" +( + : > "$B_SAMPLER_READY" + while [ ! -e "$B_SAMPLER_STOP" ]; do + if [ -e "$B_OPERATION_ACTIVE" ]; then + if B_SAMPLE=$(focus_snapshot); then + printf '%s\n' "$B_SAMPLE" >> "$B_FOCUS_SAMPLES" + else + printf '%s\n' UNREADABLE >> "$B_FOCUS_SAMPLES" + fi + fi + done +) & +B_SAMPLER_PID=$! +B_READY_ATTEMPT=0 +while [ ! -e "$B_SAMPLER_READY" ] && [ "$B_READY_ATTEMPT" -lt 100 ]; do + sleep 0.01 + B_READY_ATTEMPT=$((B_READY_ATTEMPT + 1)) +done +[ -e "$B_SAMPLER_READY" ] || fail 'the Part B focus sampler did not start' +: > "$B_OPERATION_ACTIVE" +B_OUT=$(PATH="$FAKEBIN:$HERDR_ORIGINAL_PATH" FM_FLASH_CALL_LOG="$CALL_LOG" bash -c ' + . "$1/bin/backends/herdr.sh" + fm_backend_herdr_cli() { + local session=$1 + shift + printf "%s\n" "$*" >> "$FM_FLASH_CALL_LOG" + HERDR_SESSION="$session" herdr "$@" --session "$session" + } + fm_backend_herdr_projection_close_pane_focus_preserving "$2" "$3" +' _ "$ROOT" "$HERDR_LAB_SESSION" "$B_DOOMED_PANE" 2>&1) +B_STATUS=$? +rm -f "$B_OPERATION_ACTIVE" +: > "$B_SAMPLER_STOP" +wait "$B_SAMPLER_PID" 2>/dev/null || true +B_SAMPLER_PID= +[ "$B_STATUS" -eq 0 ] || fail "the production focus-preserving close failed (status $B_STATUS): $B_OUT" +[ -s "$B_FOCUS_SAMPLES" ] || fail 'the Part B sampler captured no focus sample during the production close' +B_WRONG_SAMPLE=$(grep -Fvx -- "$B_BEFORE" "$B_FOCUS_SAMPLES" | head -1) +if [ -n "$B_WRONG_SAMPLE" ]; then + fail "the mitigation exposed a wrong or unreadable in-operation focus sample ($B_BEFORE -> $B_WRONG_SAMPLE)" +fi +wait_ws_gone "$B_DOOMED_WS" || fail 'the mitigation left the doomed workspace behind' +if lab pane get "$B_DOOMED_PANE" >/dev/null 2>&1; then + fail 'the mitigation left the doomed pane behind' +fi +B_AFTER=$(focus_snapshot) || fail 'could not capture the Part B post-close focus' +[ "$B_AFTER" = "$B_BEFORE" ] \ + || fail "the mitigation changed the exact focused workspace or tab ($B_BEFORE -> $B_AFTER)" +[ "$(ws_order)" = "$B_SURVIVOR_ORDER" ] \ + || fail "the mitigation left a lasting workspace order change ($B_SURVIVOR_ORDER -> $(ws_order))" +grep -q '^pane process-info' "$CALL_LOG" || fail 'the idle-shell proof never ran' +pass 'mitigation: every in-operation sample preserved exact focus while the doomed workspace was removed' + +if [ "$STEAL_LIVE" = 1 ]; then + grep -q '^tab focus' "$CALL_LOG" \ + && fail 'the corrective tab focus fired, so a wrong-focus interval existed on the defective release' + grep -q '^pane close' "$CALL_LOG" \ + && fail 'the focus-unsafe explicit close was used on the defective release' + pass 'mitigation: no explicit close and no corrective focus were needed on the defective release' +fi + +STATUS=$(lab status --json) || fail 'could not read final named-lab version evidence' +printf 'evidence: herdr=%s protocol=%s steal_live=%s default-session-tripwire=armed\n' \ + "$(printf '%s' "$STATUS" | jq -r '.client.version')" \ + "$(printf '%s' "$STATUS" | jq -r '.client.protocol')" \ + "$STEAL_LIVE" diff --git a/tests/fm-backend-herdr-launcher-workspace-e2e.test.sh b/tests/fm-backend-herdr-launcher-workspace-e2e.test.sh new file mode 100755 index 00000000000..ca5cc4575e9 --- /dev/null +++ b/tests/fm-backend-herdr-launcher-workspace-e2e.test.sh @@ -0,0 +1,429 @@ +#!/usr/bin/env bash +# tests/fm-backend-herdr-launcher-workspace-e2e.test.sh - mandatory ISOLATED +# end-to-end real-Herdr test for worker PLACEMENT with presentation spaces +# disabled. +# +# The guarantee under test: a crewmate or scout is created in the exact Herdr +# workspace of the firstmate or secondmate process that launched it, identified +# from that process's own Herdr pane rather than from a workspace label. Herdr +# enforces no workspace-label uniqueness, so two workspaces can both be labeled +# "firstmate", and the previous label-first-match resolution put the worker in +# whichever one sorted first - visibly the wrong space whenever the launcher was +# not in it. +# +# This drives the REAL bin/fm-spawn.sh and bin/fm-teardown.sh, because the +# guarantee spans the whole spawn handoff (fm-spawn.sh's herdr arm -> +# fm_backend_herdr_container_ensure -> fm_backend_herdr_workspace_ensure -> +# fm_backend_herdr_launcher_identity) and no adapter primitive holds it alone. +# The headline duplicate-label case additionally runs fm-spawn.sh INSIDE a real +# Herdr pane, so the pane identity comes from Herdr's own injection rather than +# from an environment this test composed. +# +# Safety (2026-07-02 incident, see tests/herdr-test-safety.sh): every lifecycle +# operation goes through bin/fm-herdr-lab.sh, which appends the named session +# flag and verifies the default fleet session is unchanged after teardown. +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +fail() { printf 'not ok - %s\n' "$1" >&2; cleanup_all; exit 1; } +pass() { printf 'ok - %s\n' "$1"; } +assert_contains_local() { # <haystack> <needle> <msg> + case "$1" in + *"$2"*) : ;; + *) fail "$3"$'\n'"--- got ---"$'\n'"$1" ;; + esac +} + +command -v herdr >/dev/null 2>&1 || { echo "skip: herdr not found"; exit 0; } +command -v jq >/dev/null 2>&1 || { echo "skip: jq not found (required by the herdr adapter)"; exit 0; } +command -v treehouse >/dev/null 2>&1 || { echo "skip: treehouse not found (required by fm-spawn.sh)"; exit 0; } + +# shellcheck source=tests/herdr-test-safety.sh +. "$ROOT/tests/herdr-test-safety.sh" + +# Every spawn below states its own launcher identity, so a pane inherited from +# the terminal this suite was started in must not leak into any of them. +herdr_forget_inherited_pane + +TMP_ROOT=$(mktemp -d "$(cd "${TMPDIR:-/tmp}" && pwd -P)/fm-herdr-launcher-e2e.XXXXXX") +HERDR_LAB_HELPER="$ROOT/bin/fm-herdr-lab.sh" +HERDR_LAB_SESSION=$("$HERDR_LAB_HELPER" name fm-herdr-launcher-ws) || { + rm -rf "$TMP_ROOT" + printf 'not ok - could not generate an isolated Herdr lab session name\n' >&2 + exit 1 +} +export HERDR_SESSION="$HERDR_LAB_SESSION" + +WORKTREES=() +CLEANED=0 +# Idempotent: fail() cleans up before exiting and the EXIT trap fires after it, +# so a second teardown would otherwise report the already-consumed fleet-state +# tripwire as if the lab had gone wrong. +cleanup_all() { + local wt status=0 + [ "$CLEANED" = 0 ] || return 0 + CLEANED=1 + for wt in ${WORKTREES[@]+"${WORKTREES[@]}"}; do + [ -n "$wt" ] && treehouse return --force "$wt" >/dev/null 2>&1 + done + WORKTREES=() + "$HERDR_LAB_HELPER" teardown "$HERDR_LAB_SESSION" || status=$? + rm -rf "$TMP_ROOT" + return "$status" +} +trap cleanup_all EXIT +"$HERDR_LAB_HELPER" provision "$HERDR_LAB_SESSION" || fail "could not provision isolated Herdr lab session" + +lab() { "$HERDR_LAB_HELPER" run "$HERDR_LAB_SESSION" "$@"; } + +# --- helpers ---------------------------------------------------------------- + +make_scratch_project() { # <dir> + local dir=$1 + mkdir -p "$dir" + git -C "$dir" init -q + printf '# scratch\n' > "$dir/README.md" + git -C "$dir" add README.md + git -C "$dir" -c user.name='Firstmate Tests' -c user.email='tests@example.invalid' commit -qm initial +} + +# make_workspace <label> -> "<workspace_id> <tab_id> <root_pane_id>" +make_workspace() { # <label> + local out + out=$(lab workspace create --cwd "$TMP_ROOT" --label "$1" --no-focus 2>/dev/null) || return 1 + printf '%s' "$out" | jq -r ' + [.result.workspace.workspace_id, .result.tab.tab_id, .result.root_pane.pane_id] | @tsv + ' 2>/dev/null | tr '\t' ' ' +} + +workspace_of_pane() { # <pane_id> + lab pane get "$1" 2>/dev/null | jq -r '.result.pane.workspace_id // empty' 2>/dev/null +} + +label_of_workspace() { # <workspace_id> + lab workspace list 2>/dev/null \ + | jq -r --arg id "$1" '.result.workspaces[]? | select(.workspace_id == $id) | .label' 2>/dev/null +} + +tab_labels_of_workspace() { # <workspace_id> + lab tab list --workspace "$1" 2>/dev/null \ + | jq -r '[.result.tabs[]?.label] | sort | join(",")' 2>/dev/null +} + +journal_field() { # <presentation-journal> <key> + grep "^$2=" "$1" 2>/dev/null | head -1 | cut -d= -f2- +} + +# spawn_from_launcher <launcher-pane|""> <home> <task-id> <project> [extra fm-spawn args...] +# Composes exactly the Herdr identity Herdr itself injects into a pane's +# processes. An empty launcher pane means "this firstmate is not running inside +# Herdr at all". +SPAWN_OUT=; SPAWN_ERR=; SPAWN_RC= +spawn_from_launcher() { + local pane=$1 home=$2 id=$3 proj=$4 + shift 4 + SPAWN_OUT="$TMP_ROOT/$id.out"; SPAWN_ERR="$TMP_ROOT/$id.err" + if [ -n "$pane" ]; then + env HERDR_ENV=1 HERDR_PANE_ID="$pane" HERDR_SESSION="$HERDR_LAB_SESSION" \ + HERDR_SOCKET_PATH="$LAB_SOCKET" \ + FM_SPAWN_NO_GUARD=1 FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \ + "$ROOT/bin/fm-spawn.sh" "$id" "$proj" "sh -c 'echo launcher-ws-ok'" --backend herdr "$@" \ + >"$SPAWN_OUT" 2>"$SPAWN_ERR" + else + env -u HERDR_ENV -u HERDR_PANE_ID -u HERDR_SOCKET_PATH HERDR_SESSION="$HERDR_LAB_SESSION" \ + FM_SPAWN_NO_GUARD=1 FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \ + "$ROOT/bin/fm-spawn.sh" "$id" "$proj" "sh -c 'echo launcher-ws-ok'" --backend herdr "$@" \ + >"$SPAWN_OUT" 2>"$SPAWN_ERR" + fi + SPAWN_RC=$? + return 0 +} + +record_worktree() { # <meta> + local wt + wt=$(grep '^worktree=' "$1" 2>/dev/null | cut -d= -f2-) + [ -n "$wt" ] && WORKTREES+=("$wt") + return 0 +} + +LAB_SOCKET=$(lab session list --json 2>/dev/null \ + | jq -r --arg s "$HERDR_LAB_SESSION" '.sessions[]? | select(.name == $s) | .socket_path' 2>/dev/null) +[ -n "$LAB_SOCKET" ] || fail "could not read the isolated lab session's socket path" + +# --- scratch world ---------------------------------------------------------- + +PRIMARY_HOME="$TMP_ROOT/primary-home" +mkdir -p "$PRIMARY_HOME/state" "$PRIMARY_HOME/config" +SM_ID="lwsm1" +SM_HOME="$TMP_ROOT/secondmate-home" +mkdir -p "$SM_HOME/state" "$SM_HOME/config" "$SM_HOME/projects" "$SM_HOME/bin" "$SM_HOME/data" +printf '# scratch secondmate home AGENTS.md placeholder\n' > "$SM_HOME/AGENTS.md" +printf '%s\n' "$SM_ID" > "$SM_HOME/.fm-secondmate-home" +printf 'trivial e2e secondmate charter: nothing to do.\n' > "$SM_HOME/data/charter.md" + +SM2_ID="lwsm2" +SM2_HOME="$TMP_ROOT/secondmate-home-2" +mkdir -p "$SM2_HOME/state" "$SM2_HOME/config" "$SM2_HOME/projects" "$SM2_HOME/bin" "$SM2_HOME/data" +printf '# scratch secondmate home AGENTS.md placeholder\n' > "$SM2_HOME/AGENTS.md" +printf '%s\n' "$SM2_ID" > "$SM2_HOME/.fm-secondmate-home" +printf 'trivial e2e secondmate charter: nothing to do.\n' > "$SM2_HOME/data/charter.md" + +# A third primary-shaped home with presentation spaces ON, so the flat-path +# homes above stay flag-free and each layout is asserted in isolation. +PRES_HOME="$TMP_ROOT/presentation-home" +mkdir -p "$PRES_HOME/state" "$PRES_HOME/config" +: > "$PRES_HOME/config/herdr-presentation-spaces" + +for id in uniqA uniqB dupC dupD staleF smE presU presD; do + mkdir -p "$PRIMARY_HOME/data/$id" "$SM_HOME/data/$id" "$PRES_HOME/data/$id" + printf 'trivial launcher-placement brief: nothing to do.\n' > "$PRIMARY_HOME/data/$id/brief.md" + printf 'trivial launcher-placement brief: nothing to do.\n' > "$SM_HOME/data/$id/brief.md" + printf 'trivial launcher-placement brief: nothing to do.\n' > "$PRES_HOME/data/$id/brief.md" +done +mkdir -p "$PRIMARY_HOME/data/$SM2_ID" +printf 'trivial secondmate charter brief: nothing to do.\n' > "$PRIMARY_HOME/data/$SM2_ID/brief.md" + +PROJ="$TMP_ROOT/scratch-project"; make_scratch_project "$PROJ" + +# One unrelated workspace, kept FOCUSED throughout, so every placement result +# below is also evidence that the globally focused workspace is never the target. +read -r WS_OTHER WS_OTHER_TAB _ <<EOF +$(make_workspace captain-other) +EOF +[ -n "$WS_OTHER" ] || fail "could not create the unrelated captain workspace" +lab tab focus "$WS_OTHER_TAB" >/dev/null 2>&1 || fail "could not focus the unrelated captain workspace" + +focused_workspace() { + lab workspace list 2>/dev/null | jq -r '[.result.workspaces[]? | select(.focused == true) | .workspace_id][0] // empty' 2>/dev/null +} +[ "$(focused_workspace)" = "$WS_OTHER" ] || fail "the unrelated captain workspace did not take focus" + +# --- 1. unique label, no herdr ancestry: the per-home container still works -- + +spawn_from_launcher "" "$PRIMARY_HOME" uniqA "$PROJ" +[ "$SPAWN_RC" -eq 0 ] || fail "a primary-shaped spawn with no herdr parent failed"$'\n'"$(cat "$SPAWN_ERR")" +UNIQA_META="$PRIMARY_HOME/state/uniqA.meta" +record_worktree "$UNIQA_META" +UNIQA_PANE=$(grep '^herdr_pane_id=' "$UNIQA_META" | cut -d= -f2-) +[ -n "$UNIQA_PANE" ] || fail "uniqA meta is missing herdr_pane_id" +WS_PRIMARY=$(workspace_of_pane "$UNIQA_PANE") +[ -n "$WS_PRIMARY" ] || fail "could not read uniqA's workspace" +[ "$(label_of_workspace "$WS_PRIMARY")" = firstmate ] || fail "uniqA did not land in a 'firstmate' workspace" +[ "$(focused_workspace)" = "$WS_OTHER" ] || fail "the spawn stole focus from the captain's workspace" +pass "real herdr E2E: with one 'firstmate' workspace and no herdr parent, a crewmate still lands in this home's own workspace without stealing focus" + +# --- 2. unique label, WITH a launcher pane: same workspace, now by identity -- + +read -r _ _ LAUNCH_PRIMARY_PANE <<EOF +$(lab tab create --workspace "$WS_PRIMARY" --cwd "$TMP_ROOT" --label captain-shell --no-focus 2>/dev/null \ + | jq -r '["x","x", .result.root_pane.pane_id] | @tsv' | tr '\t' ' ') +EOF +[ -n "$LAUNCH_PRIMARY_PANE" ] || fail "could not create a launcher pane inside the 'firstmate' workspace" + +spawn_from_launcher "$LAUNCH_PRIMARY_PANE" "$PRIMARY_HOME" uniqB "$PROJ" +[ "$SPAWN_RC" -eq 0 ] || fail "a primary spawn from a launcher pane failed"$'\n'"$(cat "$SPAWN_ERR")" +UNIQB_META="$PRIMARY_HOME/state/uniqB.meta" +record_worktree "$UNIQB_META" +UNIQB_PANE=$(grep '^herdr_pane_id=' "$UNIQB_META" | cut -d= -f2-) +[ "$(workspace_of_pane "$UNIQB_PANE")" = "$WS_PRIMARY" ] \ + || fail "a crewmate launched from the 'firstmate' workspace must stay in it" +pass "real herdr E2E: the normal unique-label path is unchanged when the launcher's own pane identifies the workspace" + +# --- 2b. presentation spaces ON: the projected child is created and bound +# UNDER the launcher's exact workspace, not collapsed into it --------- + +spawn_from_launcher "$LAUNCH_PRIMARY_PANE" "$PRES_HOME" presU "$PROJ" +[ "$SPAWN_RC" -eq 0 ] || fail "a presentation-enabled spawn from a launcher pane failed"$'\n'"$(cat "$SPAWN_ERR")" +PRESU_META="$PRES_HOME/state/presU.meta" +record_worktree "$PRESU_META" +PRESU_PANE=$(grep '^herdr_pane_id=' "$PRESU_META" | cut -d= -f2-) +PRESU_WS=$(workspace_of_pane "$PRESU_PANE") +[ -n "$PRESU_WS" ] || fail "could not read presU's workspace" +[ "$PRESU_WS" != "$WS_PRIMARY" ] \ + || fail "a projected worker must get its own disposable workspace, not be collapsed into its parent" +case "$(label_of_workspace "$PRESU_WS")" in + "└ "*" · p:"*) : ;; + *) fail "presU's workspace is not a presentation projection: '$(label_of_workspace "$PRESU_WS")'" ;; +esac +PRESU_JOURNAL="$PRES_HOME/state/presU.herdr-presentation" +[ -f "$PRESU_JOURNAL" ] || fail "a projected spawn did not leave its presentation journal" +[ "$(journal_field "$PRESU_JOURNAL" version)" = 2 ] \ + || fail "the projection did not publish an exact restart binding"$'\n'"$(cat "$PRESU_JOURNAL")" +[ "$(journal_field "$PRESU_JOURNAL" parent_workspace_id)" = "$WS_PRIMARY" ] \ + || fail "the projection bound a parent other than the launcher's own workspace ($WS_PRIMARY)" +[ "$(journal_field "$PRESU_JOURNAL" workspace_id)" = "$PRESU_WS" ] \ + || fail "the projection journal does not name its own workspace" +[ "$(focused_workspace)" = "$WS_OTHER" ] || fail "a projected spawn stole focus from the captain's workspace" +pass "real herdr E2E: presentation spaces still create the isolated child workspace and bind it under the launcher's exact parent, without stealing focus" + +# --- 3. duplicate label, launcher in the NON-first match, driven from a real +# Herdr pane so the identity comes from Herdr's own injection ---------- + +read -r WS_PRIMARY_DUP _ LAUNCH_DUP_PANE <<EOF +$(make_workspace firstmate) +EOF +[ -n "$WS_PRIMARY_DUP" ] || fail "could not create the second 'firstmate' workspace" +[ "$WS_PRIMARY_DUP" != "$WS_PRIMARY" ] || fail "the two 'firstmate' workspaces must be distinct" +DUP_COUNT=$(lab workspace list 2>/dev/null | jq -r '[.result.workspaces[]? | select(.label == "firstmate")] | length') +[ "$DUP_COUNT" = 2 ] || fail "expected exactly two 'firstmate' workspaces, got $DUP_COUNT" +WS_PRIMARY_TABS_BEFORE=$(tab_labels_of_workspace "$WS_PRIMARY") + +cat > "$TMP_ROOT/spawn-in-pane.sh" <<SPAWN +#!/usr/bin/env bash +set -u +FM_SPAWN_NO_GUARD=1 FM_HOME="$PRIMARY_HOME" FM_ROOT_OVERRIDE="$ROOT" \\ + "$ROOT/bin/fm-spawn.sh" dupC "$PROJ" "sh -c 'echo launcher-ws-ok'" --backend herdr \\ + > "$TMP_ROOT/dupC.out" 2> "$TMP_ROOT/dupC.err" +echo \$? > "$TMP_ROOT/dupC.rc" +SPAWN +chmod +x "$TMP_ROOT/spawn-in-pane.sh" +lab pane run "$LAUNCH_DUP_PANE" "$TMP_ROOT/spawn-in-pane.sh" >/dev/null 2>&1 \ + || fail "could not run fm-spawn.sh inside the launcher's herdr pane" +i=0 +while [ ! -f "$TMP_ROOT/dupC.rc" ] && [ "$i" -lt 120 ]; do sleep 2; i=$((i + 1)); done +[ -f "$TMP_ROOT/dupC.rc" ] || fail "fm-spawn.sh never finished inside the launcher's herdr pane" +[ "$(cat "$TMP_ROOT/dupC.rc")" = 0 ] \ + || fail "the in-pane spawn failed"$'\n'"$(cat "$TMP_ROOT/dupC.err" 2>/dev/null)" + +DUPC_META="$PRIMARY_HOME/state/dupC.meta" +record_worktree "$DUPC_META" +DUPC_PANE=$(grep '^herdr_pane_id=' "$DUPC_META" | cut -d= -f2-) +DUPC_WS=$(workspace_of_pane "$DUPC_PANE") +[ "$DUPC_WS" = "$WS_PRIMARY_DUP" ] \ + || fail "a worker launched from the second 'firstmate' workspace ($WS_PRIMARY_DUP) landed in '$DUPC_WS' instead" +[ "$DUPC_WS" != "$WS_PRIMARY" ] || fail "the worker was placed in the first label match, the defect under test" +[ "$DUPC_WS" != "$WS_OTHER" ] || fail "the worker was placed in the globally focused workspace" +[ "$(grep '^herdr_workspace_id=' "$DUPC_META" | cut -d= -f2-)" = "$WS_PRIMARY_DUP" ] \ + || fail "the recorded endpoint workspace does not match the launcher's workspace" +pass "real herdr E2E: with two 'firstmate' workspaces, a worker spawned from inside the second one lands in that exact workspace" + +[ "$(tab_labels_of_workspace "$WS_PRIMARY")" = "$WS_PRIMARY_TABS_BEFORE" ] \ + || fail "the other same-labeled workspace's tabs changed; it must never be adopted or mutated" +[ "$(label_of_workspace "$WS_PRIMARY")" = firstmate ] \ + || fail "the other same-labeled workspace was renamed" +[ "$(focused_workspace)" = "$WS_OTHER" ] || fail "the in-pane spawn stole focus from the captain's workspace" +pass "real herdr E2E: the duplicate-labeled sibling workspace is left entirely untouched and focus is preserved" + +# --- 3b. presentation spaces ON with a duplicated parent label: the projection +# still hangs off the launcher's exact workspace --------------------- + +spawn_from_launcher "$LAUNCH_DUP_PANE" "$PRES_HOME" presD "$PROJ" +[ "$SPAWN_RC" -eq 0 ] || fail "a projected spawn under a duplicated parent label failed"$'\n'"$(cat "$SPAWN_ERR")" +PRESD_META="$PRES_HOME/state/presD.meta" +record_worktree "$PRESD_META" +PRESD_PANE=$(grep '^herdr_pane_id=' "$PRESD_META" | cut -d= -f2-) +PRESD_WS=$(workspace_of_pane "$PRESD_PANE") +[ -n "$PRESD_WS" ] || fail "could not read presD's workspace" +PRESD_JOURNAL="$PRES_HOME/state/presD.herdr-presentation" +[ "$(journal_field "$PRESD_JOURNAL" version)" = 2 ] \ + || fail "the duplicate-label projection did not publish a version 2 binding"$'\n'"$(cat "$PRESD_JOURNAL" 2>/dev/null)" +[ "$(journal_field "$PRESD_JOURNAL" parent_workspace_id)" = "$WS_PRIMARY_DUP" ] \ + || fail "the duplicate-label projection journal did not bind the launcher's exact parent workspace" +[ "$PRESD_WS" != "$WS_PRIMARY" ] && [ "$PRESD_WS" != "$WS_PRIMARY_DUP" ] \ + || fail "a projected worker must not be collapsed into either same-labeled parent workspace" +PRESD_ORDER=$(lab workspace list 2>/dev/null | jq -r --arg dup "$WS_PRIMARY_DUP" --arg child "$PRESD_WS" ' + [range(0; (.result.workspaces | length)) as $i + | {i: $i, id: .result.workspaces[$i].workspace_id}] + | ((map(select(.id == $child)) | .[0].i) - (map(select(.id == $dup)) | .[0].i))') +[ "$PRESD_ORDER" = 1 ] \ + || fail "the projected child should sit immediately after the launcher's own workspace, offset was '$PRESD_ORDER'" +[ "$(tab_labels_of_workspace "$WS_PRIMARY")" = "$WS_PRIMARY_TABS_BEFORE" ] \ + || fail "the other same-labeled workspace was mutated by a projected spawn" +[ "$(focused_workspace)" = "$WS_OTHER" ] || fail "a projected spawn stole focus from the captain's workspace" +pass "real herdr E2E: with a duplicated home label, a projected worker still hangs off the launcher's exact workspace and the sibling stays untouched" + +# --- 4. duplicate label with NO launcher identity refuses before publishing -- + +spawn_from_launcher "" "$PRIMARY_HOME" dupD "$PROJ" +[ "$SPAWN_RC" -ne 0 ] || fail "a duplicate-labeled home workspace with no herdr parent must refuse, not guess" +assert_contains_local "$(cat "$SPAWN_ERR")" "labeled 'firstmate'" \ + "the refusal did not name the duplicated home label" +[ ! -e "$PRIMARY_HOME/state/dupD.meta" ] || fail "a refused spawn must not publish task metadata" +DUP_TABS=$(lab tab list --workspace "$WS_PRIMARY" 2>/dev/null | jq -r '[.result.tabs[]? | select(.label == "fm-dupD")] | length') +DUP_TABS2=$(lab tab list --workspace "$WS_PRIMARY_DUP" 2>/dev/null | jq -r '[.result.tabs[]? | select(.label == "fm-dupD")] | length') +[ "$DUP_TABS" = 0 ] && [ "$DUP_TABS2" = 0 ] || fail "a refused spawn created a worker endpoint anyway" +pass "real herdr E2E: an ambiguous home label with no launcher identity refuses before any worker endpoint exists" + +# --- 5. a STALE launcher pane refuses, even though the home label is +# unambiguous from the launcher's own (now closed) workspace ----------- +# A firstmate whose own pane was closed under it has an identity that no longer +# resolves. Guessing a workspace from the label is exactly what must not happen. + +read -r _ _ STALE_PANE <<EOF +$(make_workspace stale-parent) +EOF +[ -n "$STALE_PANE" ] || fail "could not create the workspace whose pane goes stale" +lab pane close "$STALE_PANE" >/dev/null 2>&1 +if lab pane get "$STALE_PANE" >/dev/null 2>&1; then + fail "the launcher pane did not actually go away" +fi + +spawn_from_launcher "$STALE_PANE" "$PRIMARY_HOME" staleF "$PROJ" +[ "$SPAWN_RC" -ne 0 ] || fail "a launcher pane that no longer exists must refuse, not fall back to a label search" +assert_contains_local "$(cat "$SPAWN_ERR")" "$STALE_PANE" \ + "the stale-identity refusal did not name the launcher pane it could not resolve" +[ ! -e "$PRIMARY_HOME/state/staleF.meta" ] || fail "a refused spawn must not publish task metadata" +STALE_TABS=$(lab tab list --workspace "$WS_PRIMARY_DUP" 2>/dev/null | jq -r '[.result.tabs[]? | select(.label == "fm-staleF")] | length') +[ "$STALE_TABS" = 0 ] || fail "a refused spawn created a worker endpoint anyway" +pass "real herdr E2E: a launcher pane that no longer exists refuses before any worker endpoint exists" + +# --- 6. a secondmate launching its own worker gets the same guarantee ------- + +read -r WS_SM_DECOY _ _ <<EOF +$(make_workspace "2ndmate-$SM_ID") +EOF +read -r WS_SM_LAUNCH _ LAUNCH_SM_PANE <<EOF +$(make_workspace "2ndmate-$SM_ID") +EOF +[ -n "$WS_SM_DECOY" ] && [ -n "$WS_SM_LAUNCH" ] || fail "could not create the two secondmate-labeled workspaces" +WS_SM_DECOY_TABS_BEFORE=$(tab_labels_of_workspace "$WS_SM_DECOY") + +spawn_from_launcher "$LAUNCH_SM_PANE" "$SM_HOME" smE "$PROJ" +[ "$SPAWN_RC" -eq 0 ] || fail "a secondmate-owned crewmate spawn failed"$'\n'"$(cat "$SPAWN_ERR")" +SME_META="$SM_HOME/state/smE.meta" +record_worktree "$SME_META" +SME_PANE=$(grep '^herdr_pane_id=' "$SME_META" | cut -d= -f2-) +SME_WS=$(workspace_of_pane "$SME_PANE") +[ "$SME_WS" = "$WS_SM_LAUNCH" ] \ + || fail "a secondmate's own worker must land in the secondmate's exact workspace ($WS_SM_LAUNCH), got '$SME_WS'" +[ "$(tab_labels_of_workspace "$WS_SM_DECOY")" = "$WS_SM_DECOY_TABS_BEFORE" ] \ + || fail "the duplicate secondmate-labeled workspace was mutated" +pass "real herdr E2E: a secondmate launching its own worker gets the same exact-workspace guarantee, and its same-labeled sibling is untouched" + +# --- 7. a --secondmate launch is NOT collapsed into the launcher's workspace - + +spawn_from_launcher "$LAUNCH_DUP_PANE" "$PRIMARY_HOME" "$SM2_ID" "$SM2_HOME" --secondmate +[ "$SPAWN_RC" -eq 0 ] || fail "the primary's --secondmate launch failed"$'\n'"$(cat "$SPAWN_ERR")" +SM2_META="$PRIMARY_HOME/state/$SM2_ID.meta" +SM2_PANE=$(grep '^herdr_pane_id=' "$SM2_META" | cut -d= -f2-) +SM2_WS=$(workspace_of_pane "$SM2_PANE") +[ "$SM2_WS" != "$WS_PRIMARY_DUP" ] \ + || fail "a --secondmate launch must stand up the secondmate's own workspace, not join the launcher's" +[ "$(label_of_workspace "$SM2_WS")" = "2ndmate-$SM2_ID" ] \ + || fail "a --secondmate launch should land in '2ndmate-$SM2_ID', got '$(label_of_workspace "$SM2_WS")'" +pass "real herdr E2E: a --secondmate launch still stands up that secondmate's own workspace instead of inheriting the launcher's" + +# --- 8. teardown closes only the worker's own pane -------------------------- + +FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$PRIMARY_HOME/state" FM_DATA_OVERRIDE="$PRIMARY_HOME/data" \ + FM_CONFIG_OVERRIDE="$PRIMARY_HOME/config" \ + "$ROOT/bin/fm-teardown.sh" dupC >"$TMP_ROOT/teardown.out" 2>&1 +status=$? +[ "$status" -eq 0 ] || fail "fm-teardown.sh failed for dupC"$'\n'"$(cat "$TMP_ROOT/teardown.out")" +[ ! -f "$DUPC_META" ] || fail "fm-teardown.sh did not remove dupC's meta" +if lab pane get "$DUPC_PANE" >/dev/null 2>&1; then + fail "fm-teardown.sh did not close dupC's own pane" +fi +lab pane get "$LAUNCH_DUP_PANE" >/dev/null 2>&1 || fail "teardown closed the launcher's own pane" +lab pane get "$UNIQB_PANE" >/dev/null 2>&1 || fail "teardown closed an unrelated worker's pane in the other same-labeled workspace" +[ "$(label_of_workspace "$WS_PRIMARY_DUP")" = firstmate ] || fail "teardown removed or renamed the launcher's workspace" +pass "real herdr E2E: teardown closes only the worker's own pane and leaves the launcher, its workspace, and the same-labeled sibling intact" + +if ! cleanup_all; then + trap - EXIT + printf 'not ok - isolated Herdr lab teardown failed or the default fleet session changed\n' >&2 + exit 1 +fi +trap - EXIT +pass "real herdr E2E: isolated lab session removed and default fleet session unchanged" diff --git a/tests/fm-backend-herdr-presentation-e2e.test.sh b/tests/fm-backend-herdr-presentation-e2e.test.sh index d95ddbeb4ea..5dcd6165d93 100755 --- a/tests/fm-backend-herdr-presentation-e2e.test.sh +++ b/tests/fm-backend-herdr-presentation-e2e.test.sh @@ -306,6 +306,14 @@ chmod +x "$FAKEBIN/herdr-workspace-mover" export PATH="$FAKEBIN:$PATH" export FM_BACKEND_HERDR_WORKSPACE_MOVER="$FAKEBIN/herdr-workspace-mover" +# shellcheck source=tests/herdr-test-safety.sh +. "$ROOT/tests/herdr-test-safety.sh" +# This suite runs against its own isolated lab session, so a Herdr pane +# inherited from the terminal it was launched in must not follow spawn into it +# as a cross-session parent identity. Every projection below is anchored on the +# parent this suite sets up, not on the developer's own workspace. +herdr_forget_inherited_pane + HERDR_LAB_SESSION=$(PATH="$HERDR_ORIGINAL_PATH" \ "$HERDR_LAB_HELPER" name fm-herdr-presentation-projection) export HERDR_SESSION="$HERDR_LAB_SESSION" HERDR_LAB_SESSION @@ -431,38 +439,28 @@ assert_raw_presentation_mutations_preserved_since() { # <line-count> <case-name [ -z "$changed" ] || fail "$case_name changed active workspace/tab inside a create, move, or seeded cleanup: $changed" } -assert_cleanup_focus_steal_was_restored() { # <line-count> <pane-id> <expected-focus> - local start=$1 pane_id=$2 expected=$3 - sed -n "$((start + 1)),\$p" "$FOCUS_AUDIT_LOG" | awk -F '\t' -v pane="$pane_id" -v expected="$expected" ' - $1 == "pane-close" && $4 == pane && $2 == expected && $3 != expected { - drift = $3 - saw_close = 1 - next - } - saw_close && $1 == "tab-focus" && $2 == drift && $3 == expected { - restored = 1 - } - END { exit(restored ? 0 : 1) } - ' || fail "projected task-pane close did not demonstrate and immediately restore the exact focus-steal regression" -} - +# The focus-safe emptying-close plan removes a last pane through Herdr's +# pane-death path with no pane.close mutation at all (the raw explicit-close +# defect is demonstrated by tests/fm-backend-herdr-focus-flash-e2e.test.sh); +# a fallback plain close must preserve or immediately restore exact focus. assert_cleanup_focus_preserved() { # <line-count> <pane-id> <expected-focus> local start=$1 pane_id=$2 expected=$3 sed -n "$((start + 1)),\$p" "$FOCUS_AUDIT_LOG" | awk -F '\t' -v pane="$pane_id" -v expected="$expected" ' - $1 == "pane-close" && $4 == pane && $2 == expected { + $1 == "pane-close" && $4 == pane { saw_close = 1 - if ($3 == expected) { - preserved = 1 - } else { - drift = $3 - } + if ($2 != expected) { bad = 1 } + else if ($3 == expected) { preserved = 1 } + else { drift = $3 } next } saw_close && drift != "" && $1 == "tab-focus" && $2 == drift && $3 == expected { preserved = 1 } - END { exit(saw_close && preserved ? 0 : 1) } + END { exit(bad || (saw_close && !preserved) ? 1 : 0) } ' || fail "projected pane close did not preserve or restore the exact active workspace and tab" + if lab pane get "$pane_id" >/dev/null 2>&1; then + fail "projected cleanup left exact pane $pane_id alive" + fi } remember_meta_worktree() { # <meta> @@ -890,7 +888,7 @@ SHAPE_CLEANUP_AUDIT_START=$(focus_audit_line_count) teardown_task shape "$HOME_DIR" > "$TMP_ROOT/on-teardown.out" 2> "$TMP_ROOT/on-teardown.err" \ || fail "projected teardown failed: $(cat "$TMP_ROOT/on-teardown.err")" assert_focus_is "$CAPTAIN_FOCUS" "projected teardown" -assert_cleanup_focus_steal_was_restored "$SHAPE_CLEANUP_AUDIT_START" "$PROJECTED_PANE" "$CAPTAIN_FOCUS" +assert_cleanup_focus_preserved "$SHAPE_CLEANUP_AUDIT_START" "$PROJECTED_PANE" "$CAPTAIN_FOCUS" pass "real Herdr lab: Treehouse commands and metadata shape are byte-identical except for Herdr container IDs" if lab workspace get "$PROJECTED_WSID" >/dev/null 2>&1; then fail "closing the exact projected task pane did not remove its last-tab workspace" @@ -898,7 +896,7 @@ fi lab pane get "$SECOND_TWO_PANE" >/dev/null 2>&1 \ || fail "projected teardown affected the focused secondmate workspace" [ ! -e "$JOURNAL" ] || fail "confirmed projected teardown did not retire its presentation journal" -pass "real Herdr lab: exact task-pane close restores the exact captain workspace/tab after Herdr's raw focus steal" +pass "real Herdr lab: exact task-pane close removes the projected workspace with no unrestored wrong-focus interval" teardown_task order-a "$HOME_DIR" > "$TMP_ROOT/order-a-teardown.out" 2> "$TMP_ROOT/order-a-teardown.err" & ORDER_A_TEARDOWN_PID=$! @@ -968,7 +966,7 @@ touch "$SECOND_HOME_A/state/.last-watcher-beat" "$SECOND_HOME_B/state/.last-watc # may write config/herdr-presentation-spaces. git -C "$SECOND_HOME_A" init -q git -C "$SECOND_HOME_B" init -q -printf 'config/herdr-presentation-spaces\nconfig/crew-harness\nconfig/crew-dispatch.json\nconfig/backlog-backend\nconfig/backend\n' \ +printf 'config/herdr-presentation-spaces\nconfig/crew-harness\nconfig/crew-dispatch.json\nconfig/backlog-backend\nconfig/backend\nconfig/startup-memory-budget\n' \ > "$SECOND_HOME_A/.gitignore" cp "$SECOND_HOME_A/.gitignore" "$SECOND_HOME_B/.gitignore" git -C "$SECOND_HOME_A" add .gitignore diff --git a/tests/fm-backend-herdr-prune-safety-e2e.test.sh b/tests/fm-backend-herdr-prune-safety-e2e.test.sh index eca742fc4bc..f69160e0bde 100755 --- a/tests/fm-backend-herdr-prune-safety-e2e.test.sh +++ b/tests/fm-backend-herdr-prune-safety-e2e.test.sh @@ -33,6 +33,11 @@ command -v jq >/dev/null 2>&1 || { echo "skip: jq not found (required by the her # shellcheck source=tests/herdr-test-safety.sh . "$ROOT/tests/herdr-test-safety.sh" +# This suite runs against its own isolated lab session, so a Herdr pane +# inherited from the terminal it was launched in must not follow spawn into it +# as a cross-session parent identity (tests/herdr-test-safety.sh). +herdr_forget_inherited_pane + SESSION="fm-lab-prune-safety-e2e-$$" export HERDR_SESSION="$SESSION" SCRATCH=$(mktemp -d "${TMPDIR:-/tmp}/fm-herdr-prune-safety.XXXXXX") diff --git a/tests/fm-backend-herdr-respawn-idem-e2e.test.sh b/tests/fm-backend-herdr-respawn-idem-e2e.test.sh index 1274cde5876..382fb72d979 100755 --- a/tests/fm-backend-herdr-respawn-idem-e2e.test.sh +++ b/tests/fm-backend-herdr-respawn-idem-e2e.test.sh @@ -45,6 +45,11 @@ command -v jq >/dev/null 2>&1 || { echo "skip: jq not found (required by the her # shellcheck source=tests/herdr-test-safety.sh . "$ROOT/tests/herdr-test-safety.sh" +# This suite runs against its own isolated lab session, so a Herdr pane +# inherited from the terminal it was launched in must not follow spawn into it +# as a cross-session parent identity (tests/herdr-test-safety.sh). +herdr_forget_inherited_pane + SESSION="fm-lab-respawn-idem-e2e-$$" export HERDR_SESSION="$SESSION" SCRATCH=$(mktemp -d "${TMPDIR:-/tmp}/fm-herdr-respawn-idem.XXXXXX") diff --git a/tests/fm-backend-herdr-smoke.test.sh b/tests/fm-backend-herdr-smoke.test.sh index 466aafd9be5..bbacd088b9c 100755 --- a/tests/fm-backend-herdr-smoke.test.sh +++ b/tests/fm-backend-herdr-smoke.test.sh @@ -27,6 +27,11 @@ command -v jq >/dev/null 2>&1 || { echo "skip: jq not found (required by the her # shellcheck source=tests/herdr-test-safety.sh . "$ROOT/tests/herdr-test-safety.sh" +# This suite runs against its own isolated lab session, so a Herdr pane +# inherited from the terminal it was launched in must not follow spawn into it +# as a cross-session parent identity (tests/herdr-test-safety.sh). +herdr_forget_inherited_pane + SESSION="fm-lab-backend-smoke-$$" export HERDR_SESSION="$SESSION" SM_SCRATCH= diff --git a/tests/fm-backend-herdr-workspace-per-home-e2e.test.sh b/tests/fm-backend-herdr-workspace-per-home-e2e.test.sh index ac09c016072..110017e9b84 100755 --- a/tests/fm-backend-herdr-workspace-per-home-e2e.test.sh +++ b/tests/fm-backend-herdr-workspace-per-home-e2e.test.sh @@ -54,6 +54,11 @@ command -v treehouse >/dev/null 2>&1 || { echo "skip: treehouse not found (requi # shellcheck source=tests/herdr-test-safety.sh . "$ROOT/tests/herdr-test-safety.sh" +# This suite runs against its own isolated lab session, so a Herdr pane +# inherited from the terminal it was launched in must not follow spawn into it +# as a cross-session parent identity (tests/herdr-test-safety.sh). +herdr_forget_inherited_pane + # TMP_ROOT is physically resolved (mktemp -d "$(pwd -P)"-relative) for the same # low-noise scratch fixture shape used by # tests/fm-backend-autodetect-smoke.test.sh. diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index 1a8dff7c6ee..2c08f409fb1 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -12,9 +12,16 @@ set -u # shellcheck source=tests/lib.sh . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +# shellcheck source=tests/herdr-test-safety.sh +. "$(dirname "${BASH_SOURCE[0]}")/herdr-test-safety.sh" command -v jq >/dev/null 2>&1 || { echo "skip: jq not found (required by the herdr adapter)"; exit 0; } +# These cases script a canned fake CLI; a Herdr pane identity leaked in from the +# developer's own terminal would make the adapter resolve a launcher that this +# fake never models. The launcher cases below set HERDR_PANE_ID themselves. +herdr_forget_inherited_pane + TMP_ROOT=$(fm_test_tmproot fm-backend-herdr-tests) export FM_BACKEND_HERDR_SUBMIT_MIN_SLEEP=0 @@ -282,6 +289,214 @@ test_cli_helper_sets_env_and_appends_trailing_session_flag() { pass "fm_backend_herdr_cli: sets HERDR_SESSION AND appends a trailing --session flag on every call" } +# --- launcher_identity: the exact workspace a worker must be placed in ------- +# +# Herdr injects HERDR_ENV/HERDR_PANE_ID/HERDR_SESSION/HERDR_SOCKET_PATH into +# every process it manages a pane for, so a firstmate or secondmate agent's own +# tool calls carry the identity of the workspace the captain is watching it in. +# Placement resolves from that identity because workspace labels are mutable and +# non-unique, and the globally focused workspace is unrelated to the launcher. +# The refusal cases matter as much as the resolution: a broken binding must stop +# the spawn, never quietly degrade back to picking a workspace by label. + +test_launcher_identity_absent_without_a_herdr_pane() { + local dir log resp fb status + dir="$TMP_ROOT/launcher-none"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + fb=$(make_herdr_fakebin "$dir") + ( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_launcher_identity fmtest' "$ROOT" ) + status=$? + expect_code 2 "$status" "a process with no herdr pane must report 'no launcher to inherit' (2), not a refusal" + [ ! -s "$log" ] || fail "resolving an absent launcher identity must not call herdr at all"$'\n'"$(cat "$log")" + pass "fm_backend_herdr_launcher_identity: a firstmate not running inside herdr has no launcher workspace to inherit" +} + +test_launcher_identity_absent_when_herdr_env_alone_is_set() { + local dir log resp fb status + dir="$TMP_ROOT/launcher-env-only"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + fb=$(make_herdr_fakebin "$dir") + ( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" HERDR_ENV=1 \ + \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_launcher_identity fmtest' "$ROOT" ) + status=$? + expect_code 2 "$status" "HERDR_ENV=1 alone is a backend-selection marker, not a parent binding" + pass "fm_backend_herdr_launcher_identity: HERDR_ENV=1 without a pane id selects the backend but binds no parent" +} + +test_launcher_identity_resolves_the_exact_pane_tab_and_workspace() { + local dir log resp fb out + dir="$TMP_ROOT/launcher-ok"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '{"sessions":[{"name":"fmtest","running":true,"socket_path":"/tmp/fm-herdr-unit/fmtest.sock"}]}\n' > "$resp/1.out" + printf '{"result":{"pane":{"pane_id":"w7:p3","tab_id":"w7:t3","workspace_id":"w7"}}}\n' > "$resp/2.out" + printf '{"result":{"tab":{"tab_id":"w7:t3","workspace_id":"w7"}}}\n' > "$resp/3.out" + printf '{"result":{"workspaces":[{"workspace_id":"w1","label":"firstmate"},{"workspace_id":"w7","label":"firstmate"}]}}\n' > "$resp/4.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + HERDR_ENV=1 HERDR_PANE_ID=w7:p3 HERDR_SESSION=fmtest HERDR_SOCKET_PATH=/tmp/fm-herdr-unit/fmtest.sock \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_launcher_identity fmtest || exit 1 + printf "%s|%s|%s" "$FM_BACKEND_HERDR_LAUNCHER_PANE_ID" "$FM_BACKEND_HERDR_LAUNCHER_TAB_ID" "$FM_BACKEND_HERDR_LAUNCHER_WORKSPACE_ID"' "$ROOT" ) + [ "$out" = 'w7:p3|w7:t3|w7' ] \ + || fail "launcher_identity should resolve the launcher's own pane, tab, and workspace, got '$out'" + assert_contains "$(cat "$log")" $'\x1f''pane'$'\x1f''get'$'\x1f''w7:p3' "launcher_identity did not read its own pane" + assert_contains "$(cat "$log")" $'\x1f''tab'$'\x1f''get'$'\x1f''w7:t3' "launcher_identity did not cross-check the owning tab" + pass "fm_backend_herdr_launcher_identity: resolves the launcher's exact workspace even when a same-labeled workspace sorts first" +} + +test_launcher_identity_refuses_a_pane_from_another_session_name() { + local dir log resp fb out status + dir="$TMP_ROOT/launcher-xsession"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + HERDR_ENV=1 HERDR_PANE_ID=w7:p3 HERDR_SESSION=someother \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_launcher_identity fmtest' "$ROOT" 2>&1 ) + status=$? + expect_code 1 "$status" "a launcher pane naming another herdr session must refuse" + assert_contains "$out" "cross-session parent identity" "the cross-session refusal did not explain itself" + [ ! -s "$log" ] || fail "a cross-session launcher identity must be refused before any herdr call" + pass "fm_backend_herdr_launcher_identity: refuses a launcher pane that names a different herdr session" +} + +test_launcher_identity_refuses_a_missing_server_socket() { + local dir log resp fb out status + dir="$TMP_ROOT/launcher-no-socket"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + HERDR_ENV=1 HERDR_PANE_ID=w7:p3 HERDR_SESSION=fmtest \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_launcher_identity fmtest' "$ROOT" 2>&1 ) + status=$? + expect_code 1 "$status" "a launcher pane without an injected server socket must refuse" + assert_contains "$out" "no injected socket identity" "the missing-socket refusal did not explain itself" + [ ! -s "$log" ] || fail "a missing-socket launcher identity must be refused before any herdr call" + pass "fm_backend_herdr_launcher_identity: refuses a claimed pane without exact server identity" +} + +test_launcher_identity_refuses_a_pane_from_another_server_socket() { + local dir log resp fb out status + dir="$TMP_ROOT/launcher-xsocket"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + # 1: session list --json, resolving THIS session's own socket. + printf '{"sessions":[{"name":"fmtest","running":true,"socket_path":"/tmp/fm-herdr-unit/fmtest.sock"}]}\n' > "$resp/1.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + HERDR_ENV=1 HERDR_PANE_ID=w7:p3 HERDR_SESSION=fmtest HERDR_SOCKET_PATH=/tmp/fm-herdr-unit/other.sock \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_launcher_identity fmtest' "$ROOT" 2>&1 ) + status=$? + expect_code 1 "$status" "a launcher pane on a different herdr server socket must refuse" + assert_contains "$out" "cross-session parent identity" "the cross-socket refusal did not explain itself" + assert_not_contains "$(cat "$log")" $'\x1f''pane'$'\x1f''get' "a cross-server launcher identity must be refused before its pane is trusted" + pass "fm_backend_herdr_launcher_identity: refuses a launcher pane whose injected socket belongs to another herdr server" +} + +test_launcher_identity_refuses_an_unreadable_pane() { + local dir log resp fb out status + dir="$TMP_ROOT/launcher-stale"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '{"sessions":[{"name":"fmtest","running":true,"socket_path":"/tmp/fm-herdr-unit/fmtest.sock"}]}\n' > "$resp/1.out" + printf '1\n' > "$resp/2.exit" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + HERDR_ENV=1 HERDR_PANE_ID=w7:p3 HERDR_SESSION=fmtest HERDR_SOCKET_PATH=/tmp/fm-herdr-unit/fmtest.sock \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_launcher_identity fmtest' "$ROOT" 2>&1 ) + status=$? + expect_code 1 "$status" "a launcher pane that no longer reads must refuse, not fall back to a label search" + assert_contains "$out" "w7:p3" "the stale-pane refusal did not name the pane it could not resolve" + pass "fm_backend_herdr_launcher_identity: refuses when the launcher's own pane no longer resolves" +} + +test_launcher_identity_refuses_a_pane_and_tab_that_disagree() { + local dir log resp fb out status + dir="$TMP_ROOT/launcher-contradictory"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '{"sessions":[{"name":"fmtest","running":true,"socket_path":"/tmp/fm-herdr-unit/fmtest.sock"}]}\n' > "$resp/1.out" + printf '{"result":{"pane":{"pane_id":"w7:p3","tab_id":"w7:t3","workspace_id":"w7"}}}\n' > "$resp/2.out" + # The tab claims a DIFFERENT owning workspace than the pane just did. + printf '{"result":{"tab":{"tab_id":"w7:t3","workspace_id":"w9"}}}\n' > "$resp/3.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + HERDR_ENV=1 HERDR_PANE_ID=w7:p3 HERDR_SESSION=fmtest HERDR_SOCKET_PATH=/tmp/fm-herdr-unit/fmtest.sock \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_launcher_identity fmtest' "$ROOT" 2>&1 ) + status=$? + expect_code 1 "$status" "a pane and tab that disagree about their workspace must refuse" + assert_contains "$out" "contradictory parent identity" "the contradictory-identity refusal did not explain itself" + pass "fm_backend_herdr_launcher_identity: refuses when the launcher's pane and tab disagree about their workspace" +} + +test_launcher_identity_refuses_a_workspace_missing_from_the_session() { + local dir log resp fb out status + dir="$TMP_ROOT/launcher-gone"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '{"sessions":[{"name":"fmtest","running":true,"socket_path":"/tmp/fm-herdr-unit/fmtest.sock"}]}\n' > "$resp/1.out" + printf '{"result":{"pane":{"pane_id":"w7:p3","tab_id":"w7:t3","workspace_id":"w7"}}}\n' > "$resp/2.out" + printf '{"result":{"tab":{"tab_id":"w7:t3","workspace_id":"w7"}}}\n' > "$resp/3.out" + printf '{"result":{"workspaces":[{"workspace_id":"w1","label":"firstmate"}]}}\n' > "$resp/4.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + HERDR_ENV=1 HERDR_PANE_ID=w7:p3 HERDR_SESSION=fmtest HERDR_SOCKET_PATH=/tmp/fm-herdr-unit/fmtest.sock \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_launcher_identity fmtest' "$ROOT" 2>&1 ) + status=$? + expect_code 1 "$status" "a launcher workspace absent from the session listing must refuse" + assert_contains "$out" "stale parent identity" "the stale-workspace refusal did not explain itself" + pass "fm_backend_herdr_launcher_identity: refuses when the launcher's workspace is gone from its own session" +} + +# --- workspace_ensure placement --------------------------------------------- + +test_workspace_ensure_prefers_the_launcher_over_the_first_label_match() { + local dir log resp fb out + dir="$TMP_ROOT/ensure-launcher"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '{"sessions":[{"name":"fmtest","running":true,"socket_path":"/tmp/fm-herdr-unit/fmtest.sock"}]}\n' > "$resp/1.out" + printf '{"result":{"pane":{"pane_id":"w7:p3","tab_id":"w7:t3","workspace_id":"w7"}}}\n' > "$resp/2.out" + printf '{"result":{"tab":{"tab_id":"w7:t3","workspace_id":"w7"}}}\n' > "$resp/3.out" + printf '{"result":{"workspaces":[{"workspace_id":"w1","label":"firstmate"},{"workspace_id":"w7","label":"firstmate"}]}}\n' > "$resp/4.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + HERDR_ENV=1 HERDR_PANE_ID=w7:p3 HERDR_SESSION=fmtest HERDR_SOCKET_PATH=/tmp/fm-herdr-unit/fmtest.sock \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_workspace_ensure fmtest /tmp' "$ROOT" ) + [ "$out" = w7 ] || fail "workspace_ensure should place the worker in the launcher's own workspace w7, got '$out'" + assert_not_contains "$(cat "$log")" $'\x1f''workspace'$'\x1f''create' "the launcher's existing workspace must be reused, not duplicated" + pass "fm_backend_herdr_workspace_ensure: places a worker in the launcher's exact workspace, not the first same-labeled one" +} + +test_workspace_ensure_refuses_an_ambiguous_label_with_no_launcher() { + local dir log resp fb out status + dir="$TMP_ROOT/ensure-ambiguous"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '{"result":{"workspaces":[{"workspace_id":"w1","label":"firstmate"},{"workspace_id":"w7","label":"firstmate"}]}}\n' > "$resp/1.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" HERDR_SESSION=fmtest \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_workspace_ensure fmtest /tmp' "$ROOT" 2>&1 ) + status=$? + expect_code 3 "$status" "two same-labeled home workspaces with no launcher identity must refuse" + assert_contains "$out" "labeled 'firstmate'" "the ambiguity refusal did not name the duplicated label" + assert_contains "$out" "w1 w7" "the ambiguity refusal did not name the candidate workspaces" + assert_not_contains "$(cat "$log")" $'\x1f''workspace'$'\x1f''create' "an ambiguous placement must not mint a third same-labeled workspace" + pass "fm_backend_herdr_workspace_ensure: refuses to guess between two same-labeled home workspaces" +} + +test_workspace_ensure_other_home_ignores_the_launcher_identity() { + local dir log resp fb out + dir="$TMP_ROOT/ensure-other-home"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + # Only a workspace list: the launcher's own pane is never consulted, because a + # --secondmate launch stands up a different home's workspace by design. + printf '{"result":{"workspaces":[{"workspace_id":"w1","label":"firstmate"}]}}\n' > "$resp/1.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + HERDR_ENV=1 HERDR_PANE_ID=w7:p3 HERDR_SESSION=fmtest \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_workspace_ensure fmtest /tmp other-home' "$ROOT" ) + [ "$out" = w1 ] || fail "an other-home container should resolve by this home's own label, got '$out'" + assert_not_contains "$(cat "$log")" $'\x1f''pane'$'\x1f''get' "an other-home container must not inherit the launcher's workspace" + pass "fm_backend_herdr_workspace_ensure: a --secondmate container resolves that home's own workspace, not the launcher's" +} + +test_container_ensure_refuses_an_ambiguous_home_label() { + local dir log resp fb out status + dir="$TMP_ROOT/container-ambiguous"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '{"result":{"workspaces":[{"workspace_id":"w1","label":"firstmate"},{"workspace_id":"w7","label":"firstmate"}]}}\n' > "$resp/1.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" HERDR_SESSION=fmtest \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_container_ensure /tmp' "$ROOT" 2>&1 ) + status=$? + [ "$status" -ne 0 ] || fail "container_ensure must fail when the home workspace is ambiguous" + assert_contains "$out" "labeled 'firstmate'" "container_ensure buried the specific ambiguity it refused" + assert_not_contains "$out" "failed to ensure herdr workspace" "container_ensure added a generic message over the specific one" + pass "fm_backend_herdr_container_ensure: surfaces the exact ambiguous-placement refusal instead of a generic failure" +} + # --- container_ensure / create_task ------------------------------------------ test_container_ensure_starts_server_and_workspace() { @@ -692,8 +907,12 @@ test_projection_create_uses_exact_response_ids_and_leaves_one_task_pane() { printf '{"result":{"panes":[{"pane_id":"w9:p1","tab_id":"w9:t1"},{"pane_id":"w9:p2","tab_id":"w9:t2"}]}}\n' > "$resp/4.out" printf '{"error":{"code":"agent_not_found"}}\n' > "$resp/5.out" printf '{"result":{"pane":{"pane_id":"w9:p1","tab_id":"w9:t1","workspace_id":"w9"}}}\n' > "$resp/6.out" - printf '{"result":{"tabs":[{"tab_id":"w9:t2","label":"fm-task-p2","workspace_id":"w9"}]}}\n' > "$resp/8.out" - printf '{"result":{"panes":[{"pane_id":"w9:p2","tab_id":"w9:t2"}]}}\n' > "$resp/9.out" + # The emptying-close plan's tab list proves the seeded prune is NOT + # workspace-emptying (the task tab remains), so the close stays plain. + printf '{"result":{"tabs":[{"tab_id":"w9:t1","label":"1","workspace_id":"w9"},{"tab_id":"w9:t2","label":"fm-task-p2","workspace_id":"w9"}]}}\n' > "$resp/7.out" + printf '{"error":{"code":"pane_not_found"}}\n' > "$resp/9.out" + printf '{"result":{"tabs":[{"tab_id":"w9:t2","label":"fm-task-p2","workspace_id":"w9"}]}}\n' > "$resp/10.out" + printf '{"result":{"panes":[{"pane_id":"w9:p2","tab_id":"w9:t2"}]}}\n' > "$resp/11.out" fb=$(make_herdr_fakebin "$dir") out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" HERDR_SESSION=fmtest \ bash -c ' @@ -735,8 +954,10 @@ test_projection_create_never_closes_a_concurrent_same_label_tab() { printf '{"result":{"panes":[{"pane_id":"w9:p1","tab_id":"w9:t1"},{"pane_id":"w9:p2","tab_id":"w9:t2"},{"pane_id":"w9:p3","tab_id":"w9:t3"}]}}\n' > "$resp/4.out" printf '{"error":{"code":"agent_not_found"}}\n' > "$resp/5.out" printf '{"result":{"pane":{"pane_id":"w9:p1","tab_id":"w9:t1","workspace_id":"w9"}}}\n' > "$resp/6.out" - printf '{"result":{"tabs":[{"tab_id":"w9:t2","label":"fm-task-p2","workspace_id":"w9"},{"tab_id":"w9:t3","label":"fm-task-p2","workspace_id":"w9"}]}}\n' > "$resp/8.out" - printf '{"result":{"panes":[{"pane_id":"w9:p2","tab_id":"w9:t2"},{"pane_id":"w9:p3","tab_id":"w9:t3"}]}}\n' > "$resp/9.out" + printf '{"result":{"tabs":[{"tab_id":"w9:t1","label":"1","workspace_id":"w9"},{"tab_id":"w9:t2","label":"fm-task-p2","workspace_id":"w9"},{"tab_id":"w9:t3","label":"fm-task-p2","workspace_id":"w9"}]}}\n' > "$resp/7.out" + printf '{"error":{"code":"pane_not_found"}}\n' > "$resp/9.out" + printf '{"result":{"tabs":[{"tab_id":"w9:t2","label":"fm-task-p2","workspace_id":"w9"},{"tab_id":"w9:t3","label":"fm-task-p2","workspace_id":"w9"}]}}\n' > "$resp/10.out" + printf '{"result":{"panes":[{"pane_id":"w9:p2","tab_id":"w9:t2"},{"pane_id":"w9:p3","tab_id":"w9:t3"}]}}\n' > "$resp/11.out" fb=$(make_herdr_fakebin "$dir") out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" HERDR_SESSION=fmtest \ bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_focus_snapshot() { printf "captain-ws\tcaptain-tab"; }; fm_backend_herdr_projection_focus_restore() { return 0; }; fm_backend_herdr_projection_create_task /tmp/proj label fm-task-p2' "$ROOT" 2>&1) @@ -772,12 +993,16 @@ test_projection_close_restores_exact_prior_focus() { printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":false},{"workspace_id":"w2","active_tab_id":"w2:t2","focused":true},{"workspace_id":"w9","active_tab_id":"w9:t2","focused":false}]}}' > "$resp/1.out" printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t1","focused":false},{"tab_id":"w2:t2","focused":true}]}}' > "$resp/2.out" printf '%s\n' '{"result":{"pane":{"pane_id":"w9:p2","tab_id":"w9:t2","workspace_id":"w9"}}}' > "$resp/3.out" - printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":false},{"workspace_id":"w2","active_tab_id":"w2:t1","focused":false},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":true}]}}' > "$resp/5.out" - printf '%s\n' '{"result":{"tabs":[{"tab_id":"w3:t1","focused":true}]}}' > "$resp/6.out" - printf '%s\n' '{"result":{"tab":{"tab_id":"w2:t2","workspace_id":"w2"}}}' > "$resp/7.out" - printf '%s\n' '{"result":{"tab":{"tab_id":"w2:t2","workspace_id":"w2","focused":true}}}' > "$resp/8.out" - printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":false},{"workspace_id":"w2","active_tab_id":"w2:t2","focused":true},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":false}]}}' > "$resp/9.out" - printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t1","focused":false},{"tab_id":"w2:t2","focused":true}]}}' > "$resp/10.out" + # The emptying-close plan sees a second tab in w9, so the close stays plain + # and the exact-tab restore backstop is what reclaims the stolen focus. + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w9:t1","workspace_id":"w9"},{"tab_id":"w9:t2","workspace_id":"w9"}]}}' > "$resp/4.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/6.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":false},{"workspace_id":"w2","active_tab_id":"w2:t1","focused":false},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":true}]}}' > "$resp/7.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w3:t1","focused":true}]}}' > "$resp/8.out" + printf '%s\n' '{"result":{"tab":{"tab_id":"w2:t2","workspace_id":"w2"}}}' > "$resp/9.out" + printf '%s\n' '{"result":{"tab":{"tab_id":"w2:t2","workspace_id":"w2","focused":true}}}' > "$resp/10.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":false},{"workspace_id":"w2","active_tab_id":"w2:t2","focused":true},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":false}]}}' > "$resp/11.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t1","focused":false},{"tab_id":"w2:t2","focused":true}]}}' > "$resp/12.out" fb=$(make_herdr_fakebin "$dir") out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_close_pane_focus_preserving fmtest w9:p2' "$ROOT" 2>&1) @@ -818,13 +1043,15 @@ test_projection_close_reports_focus_restore_failure() { printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true},{"workspace_id":"w9","active_tab_id":"w9:t2","focused":false}]}}' > "$resp/1.out" printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/2.out" printf '%s\n' '{"result":{"pane":{"pane_id":"w9:p2","tab_id":"w9:t2","workspace_id":"w9"}}}' > "$resp/3.out" - : > "$resp/4.out" - printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":false},{"workspace_id":"w2","active_tab_id":"w2:t1","focused":true}]}}' > "$resp/5.out" - printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t1","focused":true}]}}' > "$resp/6.out" - printf '%s\n' '{"result":{"tab":{"tab_id":"w1:t1","workspace_id":"w1"}}}' > "$resp/7.out" - : > "$resp/8.out" - cp "$resp/5.out" "$resp/9.out" - cp "$resp/6.out" "$resp/10.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w9:t1","workspace_id":"w9"},{"tab_id":"w9:t2","workspace_id":"w9"}]}}' > "$resp/4.out" + : > "$resp/5.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/6.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":false},{"workspace_id":"w2","active_tab_id":"w2:t1","focused":true}]}}' > "$resp/7.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t1","focused":true}]}}' > "$resp/8.out" + printf '%s\n' '{"result":{"tab":{"tab_id":"w1:t1","workspace_id":"w1"}}}' > "$resp/9.out" + : > "$resp/10.out" + cp "$resp/7.out" "$resp/11.out" + cp "$resp/8.out" "$resp/12.out" fb=$(make_herdr_fakebin "$dir") out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_close_pane_focus_preserving fmtest w9:p2' "$ROOT" 2>&1) @@ -865,6 +1092,743 @@ test_projection_close_rechecks_required_agent_state_at_boundary() { pass "herdr presentation reclaim: live agent state at the close boundary refuses mutation" } +# --- emptying-close focus-safe removal (Herdr 0.7.5 #1621 mitigation) ------ +# +# The fixtures below model the verified 0.7.5 rules: an explicit close that +# empties a non-focused workspace moves focus to that workspace's neighbor, +# while a pane-death removal preserves focus whenever the dying workspace +# sits behind the focused one (or the focused one is last). + +# make_death_lab <dir> <shell-pid>: a fake ps and a fake workspace mover for +# the pane-death close fixtures. The mover appends to $FM_FAKE_MOVER_LOG and +# exits 9 unless $FM_FAKE_MOVER_RESPONSE names a readable response file. +make_death_lab() { # <dir> <shell-pid> + local dir=$1 pid=$2 + mkdir -p "$dir" + cat > "$dir/ps" <<SH +#!/usr/bin/env bash +case "\$*" in + "-axo pid=,ppid=") printf '1 0\n$pid 1\n' ;; + "-p $pid -o stat=") printf 'Ss+\n' ;; + "-p $pid -o comm=") printf -- '-zsh\n' ;; + *) exit 1 ;; +esac +SH + cat > "$dir/mover" <<'SH' +#!/usr/bin/env bash +printf '%s\t%s\t%s\n' "$1" "$2" "$3" >> "$FM_FAKE_MOVER_LOG" +calls=$(wc -l < "$FM_FAKE_MOVER_LOG" | tr -d ' ') +if [ "$calls" -ge 2 ] && [ -f "${FM_FAKE_MOVER_RESPONSE_2:-}" ]; then + cat "$FM_FAKE_MOVER_RESPONSE_2" + exit 0 +fi +if [ -f "$FM_FAKE_MOVER_RESPONSE" ]; then + cat "$FM_FAKE_MOVER_RESPONSE" + exit 0 +fi +exit 9 +SH + chmod +x "$dir/ps" "$dir/mover" + : > "$dir/mover.log" +} + +death_process_info_fixture() { # <pane> <pid> + printf '{"result":{"type":"pane_process_info","process_info":{"pane_id":"%s","shell_pid":%s,"foreground_process_group_id":%s,"foreground_processes":[{"pid":%s,"name":"zsh","argv0":"zsh"}]}}}\n' "$1" "$2" "$2" "$2" +} + +test_projection_close_emptying_after_focus_uses_pane_death_without_move() { + local dir log resp fb out status bgpid + dir="$TMP_ROOT/close-death-after"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + # w1 focused; target w2 sits after it (r > a), so no repositioning is needed. + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true},{"workspace_id":"w2","active_tab_id":"w2:t2","focused":false},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":false}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/2.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2","tab_id":"w2:t2","workspace_id":"w2"}}}' > "$resp/3.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t2","workspace_id":"w2"}]}}' > "$resp/4.out" + printf '%s\n' '{"result":{"panes":[{"pane_id":"w2:p2","tab_id":"w2:t2"}]}}' > "$resp/5.out" + cp "$resp/1.out" "$resp/6.out" + sleep 300 & bgpid=$! + death_process_info_fixture w2:p2 "$bgpid" > "$resp/7.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/8.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":false}]}}' > "$resp/9.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/10.out" + make_death_lab "$dir" "$bgpid" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_HERDR_PS_BIN="$dir/ps" FM_BACKEND_HERDR_WORKSPACE_MOVER="$dir/mover" \ + FM_FAKE_MOVER_LOG="$dir/mover.log" FM_FAKE_MOVER_RESPONSE="$dir/no-response" \ + FM_BACKEND_HERDR_DEATH_CLOSE_POLLS=2 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_close_pane_focus_preserving fmtest w2:p2' "$ROOT" 2>&1) + status=$? + kill "$bgpid" 2>/dev/null || true; wait "$bgpid" 2>/dev/null || true + [ "$status" -eq 0 ] || fail "emptying close behind focus should succeed through the pane-death path: $out" + [ ! -s "$dir/mover.log" ] || fail "a close already behind focus invoked the workspace mover" + assert_contains "$(cat "$log")" $'pane\x1fprocess-info' "pane-death close skipped the idle-shell proof" + assert_not_contains "$(cat "$log")" $'pane\x1fclose' "emptying close behind focus used the focus-unsafe explicit close" + assert_not_contains "$(cat "$log")" $'tab\x1ffocus' "focus moved despite the pane-death removal" + pass "herdr presentation cleanup: emptying close behind focus ends the exact shell without a move or focus change" +} + +test_projection_close_emptying_before_focus_repositions_then_uses_pane_death() { + local dir log resp fb out status bgpid mover_line + dir="$TMP_ROOT/close-death-before"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + # Target w1 sits BEFORE the focused w2, which is not last: reposition first. + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":false},{"workspace_id":"w2","active_tab_id":"w2:t1","focused":true},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":false}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t1","focused":true}]}}' > "$resp/2.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w1:p1","tab_id":"w1:t1","workspace_id":"w1"}}}' > "$resp/3.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","workspace_id":"w1"}]}}' > "$resp/4.out" + printf '%s\n' '{"result":{"panes":[{"pane_id":"w1:p1","tab_id":"w1:t1"}]}}' > "$resp/5.out" + cp "$resp/1.out" "$resp/6.out" + printf '%s\n' '{"client":{"version":"0.7.5","protocol":16},"server":{"running":true}}' > "$resp/7.out" + # shellcheck disable=SC2016 # $defs is a literal JSON Schema key. + printf '%s\n' '{"schemas":{"request":{"oneOf":[{"properties":{"method":{"const":"workspace.move"}}}],"$defs":{"WorkspaceMoveParams":{"required":["workspace_id","insert_index"],"properties":{"insert_index":{"type":"integer"}}}}}}}' > "$resp/8.out" + printf '%s\n' '{"sessions":[{"name":"fmtest","running":true,"socket_path":"/tmp/fmtest.sock"}]}' > "$resp/9.out" + sleep 300 & bgpid=$! + death_process_info_fixture w1:p1 "$bgpid" > "$resp/10.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/11.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w2","active_tab_id":"w2:t1","focused":true},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":false}]}}' > "$resp/12.out" + cp "$resp/12.out" "$resp/13.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t1","focused":true}]}}' > "$resp/14.out" + make_death_lab "$dir" "$bgpid" + printf '%s\n' '{"id":"fm-workspace-move","result":{"type":"workspace_list","workspaces":[{"workspace_id":"w2","focused":true},{"workspace_id":"w3","focused":false},{"workspace_id":"w1","focused":false}]}}' > "$dir/mover-response" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_HERDR_SCRIPT_STATUS=1 \ + FM_HERDR_PS_BIN="$dir/ps" FM_BACKEND_HERDR_WORKSPACE_MOVER="$dir/mover" \ + FM_FAKE_MOVER_LOG="$dir/mover.log" FM_FAKE_MOVER_RESPONSE="$dir/mover-response" \ + FM_BACKEND_HERDR_DEATH_CLOSE_POLLS=2 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_close_pane_focus_preserving fmtest w1:p1' "$ROOT" 2>&1) + status=$? + kill "$bgpid" 2>/dev/null || true; wait "$bgpid" 2>/dev/null || true + [ "$status" -eq 0 ] || fail "repositioned emptying close should succeed through the pane-death path: $out" + [ "$(cat "$dir/mover.log")" = "$(cd /tmp && pwd -P)/fmtest.sock"$'\t'"w1"$'\t'"3" ] \ + || fail "the repositioning move did not target the exact doomed workspace at the list length: $(cat "$dir/mover.log")" + mover_line=$(grep -n $'pane\x1fprocess-info' "$log" | head -1 | cut -d: -f1) + [ -n "$mover_line" ] || fail "repositioned close skipped the idle-shell proof" + assert_not_contains "$(cat "$log")" $'pane\x1fclose' "repositioned emptying close used the focus-unsafe explicit close" + assert_not_contains "$(cat "$log")" $'tab\x1ffocus' "focus moved despite the repositioned pane-death removal" + pass "herdr presentation cleanup: emptying close before focus moves the doomed workspace to the end and ends its exact shell" +} + +test_projection_close_emptying_before_last_focus_needs_no_move() { + local dir log resp fb out status bgpid + dir="$TMP_ROOT/close-death-focus-last"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + # Focused w3 is LAST, so the pane-death clamp preserves it without a move. + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":false},{"workspace_id":"w2","active_tab_id":"w2:t1","focused":false},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":true}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w3:t1","focused":true}]}}' > "$resp/2.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w1:p1","tab_id":"w1:t1","workspace_id":"w1"}}}' > "$resp/3.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","workspace_id":"w1"}]}}' > "$resp/4.out" + printf '%s\n' '{"result":{"panes":[{"pane_id":"w1:p1","tab_id":"w1:t1"}]}}' > "$resp/5.out" + cp "$resp/1.out" "$resp/6.out" + sleep 300 & bgpid=$! + death_process_info_fixture w1:p1 "$bgpid" > "$resp/7.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/8.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w2","active_tab_id":"w2:t1","focused":false},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":true}]}}' > "$resp/9.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w3:t1","focused":true}]}}' > "$resp/10.out" + make_death_lab "$dir" "$bgpid" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_HERDR_PS_BIN="$dir/ps" FM_BACKEND_HERDR_WORKSPACE_MOVER="$dir/mover" \ + FM_FAKE_MOVER_LOG="$dir/mover.log" FM_FAKE_MOVER_RESPONSE="$dir/no-response" \ + FM_BACKEND_HERDR_DEATH_CLOSE_POLLS=2 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_close_pane_focus_preserving fmtest w1:p1' "$ROOT" 2>&1) + status=$? + kill "$bgpid" 2>/dev/null || true; wait "$bgpid" 2>/dev/null || true + [ "$status" -eq 0 ] || fail "emptying close with last focus should succeed through the pane-death path: $out" + [ ! -s "$dir/mover.log" ] || fail "a last-focused close invoked the workspace mover" + assert_not_contains "$(cat "$log")" $'pane\x1fclose' "last-focused emptying close used the focus-unsafe explicit close" + assert_not_contains "$(cat "$log")" $'tab\x1ffocus' "focus moved despite the pane-death removal" + pass "herdr presentation cleanup: emptying close with the focused workspace last skips the move" +} + +test_projection_close_emptying_last_workspace_needs_no_move() { + local dir log resp fb out status bgpid + dir="$TMP_ROOT/close-death-target-last"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + # Target w3 is already last (r > a), so no repositioning is needed. + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true},{"workspace_id":"w2","active_tab_id":"w2:t1","focused":false},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":false}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/2.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w3:p1","tab_id":"w3:t1","workspace_id":"w3"}}}' > "$resp/3.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w3:t1","workspace_id":"w3"}]}}' > "$resp/4.out" + printf '%s\n' '{"result":{"panes":[{"pane_id":"w3:p1","tab_id":"w3:t1"}]}}' > "$resp/5.out" + cp "$resp/1.out" "$resp/6.out" + sleep 300 & bgpid=$! + death_process_info_fixture w3:p1 "$bgpid" > "$resp/7.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/8.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true},{"workspace_id":"w2","active_tab_id":"w2:t1","focused":false}]}}' > "$resp/9.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/10.out" + make_death_lab "$dir" "$bgpid" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_HERDR_PS_BIN="$dir/ps" FM_BACKEND_HERDR_WORKSPACE_MOVER="$dir/mover" \ + FM_FAKE_MOVER_LOG="$dir/mover.log" FM_FAKE_MOVER_RESPONSE="$dir/no-response" \ + FM_BACKEND_HERDR_DEATH_CLOSE_POLLS=2 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_close_pane_focus_preserving fmtest w3:p1' "$ROOT" 2>&1) + status=$? + kill "$bgpid" 2>/dev/null || true; wait "$bgpid" 2>/dev/null || true + [ "$status" -eq 0 ] || fail "last-workspace emptying close should succeed through the pane-death path: $out" + [ ! -s "$dir/mover.log" ] || fail "an already-last close invoked the workspace mover" + assert_not_contains "$(cat "$log")" $'pane\x1fclose' "last-workspace emptying close used the focus-unsafe explicit close" + pass "herdr presentation cleanup: emptying close of the last workspace skips the move" +} + +test_projection_close_non_emptying_stays_plain_without_proof_or_move() { + local dir log resp fb out status bgpid + dir="$TMP_ROOT/close-non-emptying"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true},{"workspace_id":"w2","active_tab_id":"w2:t2","focused":false}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/2.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2","tab_id":"w2:t2","workspace_id":"w2"}}}' > "$resp/3.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t1","workspace_id":"w2"},{"tab_id":"w2:t2","workspace_id":"w2"}]}}' > "$resp/4.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/6.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true},{"workspace_id":"w2","active_tab_id":"w2:t1","focused":false}]}}' > "$resp/7.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/8.out" + sleep 300 & bgpid=$! + make_death_lab "$dir" "$bgpid" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_HERDR_PS_BIN="$dir/ps" FM_BACKEND_HERDR_WORKSPACE_MOVER="$dir/mover" \ + FM_FAKE_MOVER_LOG="$dir/mover.log" FM_FAKE_MOVER_RESPONSE="$dir/no-response" \ + FM_BACKEND_HERDR_DEATH_CLOSE_POLLS=2 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_close_pane_focus_preserving fmtest w2:p2' "$ROOT" 2>&1) + status=$? + [ "$status" -eq 0 ] || fail "non-emptying close should succeed through the plain close: $out" + assert_contains "$(cat "$log")" $'pane\x1fclose\x1fw2:p2' "non-emptying close did not use the plain close" + assert_not_contains "$(cat "$log")" $'pane\x1fprocess-info' "non-emptying close ran the idle-shell proof" + [ ! -s "$dir/mover.log" ] || fail "non-emptying close invoked the workspace mover" + kill -0 "$bgpid" 2>/dev/null || fail "non-emptying close signaled the pane's shell" + kill "$bgpid" 2>/dev/null || true; wait "$bgpid" 2>/dev/null || true + pass "herdr presentation cleanup: a non-emptying close stays plain with no proof, move, or signal" +} + +test_projection_close_plain_without_move_requires_structured_removal() { + local dir log out status + dir="$TMP_ROOT/close-plain-unconfirmed"; mkdir -p "$dir" + log="$dir/log"; : > "$log" + out=$(ROOT="$ROOT" LOG="$log" bash -c ' + . "$ROOT/bin/backends/herdr.sh" + fm_backend_herdr_projection_focus_snapshot() { printf "w1\tw1:t1"; } + fm_backend_herdr_emptying_close_plan() { printf "plain\n"; } + fm_backend_herdr_projection_focus_restore() { return 0; } + fm_backend_herdr_cli() { + printf "%s\n" "$*" >> "$LOG" + case "$2 $3" in + "pane get") printf "{\"result\":{\"pane\":{\"pane_id\":\"w2:p2\",\"tab_id\":\"w2:t2\",\"workspace_id\":\"w2\"}}}\n" ;; + esac + } + fm_backend_herdr_projection_close_pane_focus_preserving fmtest w2:p2 + ' 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "a no-move plain close must fail while structured presence remains present: $out" + assert_contains "$(cat "$log")" "pane close w2:p2" \ + "the no-move unconfirmed regression did not reach the explicit close" + pass "herdr presentation cleanup: no-move plain close requires structured pane removal" +} + +test_projection_close_ambiguous_positions_fall_back_to_plain_close() { + local dir log resp fb out status bgpid + dir="$TMP_ROOT/close-ambiguous-positions"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/2.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2","tab_id":"w2:t2","workspace_id":"w2"}}}' > "$resp/3.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t2","workspace_id":"w2"}]}}' > "$resp/4.out" + printf '%s\n' '{"result":{"panes":[{"pane_id":"w2:p2","tab_id":"w2:t2"}]}}' > "$resp/5.out" + # The position snapshot is ambiguous: the target workspace is absent. + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true}]}}' > "$resp/6.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/8.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true}]}}' > "$resp/9.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/10.out" + sleep 300 & bgpid=$! + make_death_lab "$dir" "$bgpid" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_HERDR_PS_BIN="$dir/ps" FM_BACKEND_HERDR_WORKSPACE_MOVER="$dir/mover" \ + FM_FAKE_MOVER_LOG="$dir/mover.log" FM_FAKE_MOVER_RESPONSE="$dir/no-response" \ + FM_BACKEND_HERDR_DEATH_CLOSE_POLLS=2 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_close_pane_focus_preserving fmtest w2:p2' "$ROOT" 2>&1) + status=$? + [ "$status" -eq 0 ] || fail "an ambiguous position snapshot should fall back to the plain close: $out" + assert_contains "$(cat "$log")" $'pane\x1fclose\x1fw2:p2' "ambiguous positions did not use the plain close" + assert_not_contains "$(cat "$log")" $'pane\x1fprocess-info' "ambiguous positions ran the idle-shell proof" + [ ! -s "$dir/mover.log" ] || fail "ambiguous positions invoked the workspace mover" + kill -0 "$bgpid" 2>/dev/null || fail "ambiguous positions signaled the pane's shell" + kill "$bgpid" 2>/dev/null || true; wait "$bgpid" 2>/dev/null || true + pass "herdr presentation cleanup: an ambiguous workspace layout falls back to the plain close" +} + +test_projection_close_move_failure_falls_back_to_plain_close() { + local dir log resp fb out status bgpid + dir="$TMP_ROOT/close-move-failure"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":false},{"workspace_id":"w2","active_tab_id":"w2:t1","focused":true},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":false}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t1","focused":true}]}}' > "$resp/2.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w1:p1","tab_id":"w1:t1","workspace_id":"w1"}}}' > "$resp/3.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","workspace_id":"w1"}]}}' > "$resp/4.out" + printf '%s\n' '{"result":{"panes":[{"pane_id":"w1:p1","tab_id":"w1:t1"}]}}' > "$resp/5.out" + cp "$resp/1.out" "$resp/6.out" + printf '%s\n' '{"client":{"version":"0.7.5","protocol":16},"server":{"running":true}}' > "$resp/7.out" + # shellcheck disable=SC2016 # $defs is a literal JSON Schema key. + printf '%s\n' '{"schemas":{"request":{"oneOf":[{"properties":{"method":{"const":"workspace.move"}}}],"$defs":{"WorkspaceMoveParams":{"required":["workspace_id","insert_index"],"properties":{"insert_index":{"type":"integer"}}}}}}}' > "$resp/8.out" + printf '%s\n' '{"sessions":[{"name":"fmtest","running":true,"socket_path":"/tmp/fmtest.sock"}]}' > "$resp/9.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/11.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w2","active_tab_id":"w2:t1","focused":true},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":false}]}}' > "$resp/12.out" + cp "$resp/12.out" "$resp/13.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t1","focused":true}]}}' > "$resp/14.out" + sleep 300 & bgpid=$! + make_death_lab "$dir" "$bgpid" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_HERDR_SCRIPT_STATUS=1 \ + FM_HERDR_PS_BIN="$dir/ps" FM_BACKEND_HERDR_WORKSPACE_MOVER="$dir/mover" \ + FM_FAKE_MOVER_LOG="$dir/mover.log" FM_FAKE_MOVER_RESPONSE="$dir/no-response" \ + FM_BACKEND_HERDR_DEATH_CLOSE_POLLS=2 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_close_pane_focus_preserving fmtest w1:p1' "$ROOT" 2>&1) + status=$? + [ "$status" -eq 0 ] || fail "a failed repositioning move should fall back to the plain close: $out" + assert_contains "$out" "could not move the doomed workspace behind the focused one" \ + "a failed repositioning move did not warn about losing the focus-safe path" + assert_contains "$(cat "$log")" $'pane\x1fclose\x1fw1:p1' "move failure did not use the plain close" + assert_not_contains "$(cat "$log")" $'pane\x1fprocess-info' "move failure ran the idle-shell proof" + kill -0 "$bgpid" 2>/dev/null || fail "move failure signaled the pane's shell" + kill "$bgpid" 2>/dev/null || true; wait "$bgpid" 2>/dev/null || true + pass "herdr presentation cleanup: a failed repositioning move falls back to the plain close with a warning" +} + +test_projection_close_busy_pane_falls_back_to_plain_close() { + local dir log resp fb out status bgpid + dir="$TMP_ROOT/close-busy-pane"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true},{"workspace_id":"w2","active_tab_id":"w2:t2","focused":false}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/2.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2","tab_id":"w2:t2","workspace_id":"w2"}}}' > "$resp/3.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t2","workspace_id":"w2"}]}}' > "$resp/4.out" + printf '%s\n' '{"result":{"panes":[{"pane_id":"w2:p2","tab_id":"w2:t2"}]}}' > "$resp/5.out" + cp "$resp/1.out" "$resp/6.out" + sleep 300 & bgpid=$! + # The pane still has a foreground agent, so the idle-shell proof refuses. + printf '{"result":{"type":"pane_process_info","process_info":{"pane_id":"w2:p2","shell_pid":%s,"foreground_process_group_id":%s,"foreground_processes":[{"pid":%s,"name":"zsh","argv0":"zsh"},{"pid":99999,"name":"pi","argv0":"pi"}]}}}\n' "$bgpid" "$bgpid" "$bgpid" > "$resp/7.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/9.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true}]}}' > "$resp/10.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/11.out" + make_death_lab "$dir" "$bgpid" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_HERDR_PS_BIN="$dir/ps" FM_BACKEND_HERDR_WORKSPACE_MOVER="$dir/mover" \ + FM_FAKE_MOVER_LOG="$dir/mover.log" FM_FAKE_MOVER_RESPONSE="$dir/no-response" \ + FM_BACKEND_HERDR_DEATH_CLOSE_POLLS=2 FM_BACKEND_HERDR_IDLE_SHELL_PROOF_POLLS=1 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_close_pane_focus_preserving fmtest w2:p2' "$ROOT" 2>&1) + status=$? + [ "$status" -eq 0 ] || fail "a busy pane should fall back to the plain close: $out" + assert_contains "$(cat "$log")" $'pane\x1fclose\x1fw2:p2' "a busy pane did not use the plain close" + kill -0 "$bgpid" 2>/dev/null || fail "a busy pane close signaled the pane's shell" + kill "$bgpid" 2>/dev/null || true; wait "$bgpid" 2>/dev/null || true + pass "herdr presentation cleanup: a pane with a live foreground process falls back to the plain close" +} + +test_projection_close_transient_prompt_helper_settles_then_uses_pane_death() { + local dir log resp fb out status bgpid + dir="$TMP_ROOT/close-transient-helper"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true},{"workspace_id":"w2","active_tab_id":"w2:t2","focused":false}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/2.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2","tab_id":"w2:t2","workspace_id":"w2"}}}' > "$resp/3.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t2","workspace_id":"w2"}]}}' > "$resp/4.out" + printf '%s\n' '{"result":{"panes":[{"pane_id":"w2:p2","tab_id":"w2:t2"}]}}' > "$resp/5.out" + cp "$resp/1.out" "$resp/6.out" + sleep 300 & bgpid=$! + # Sample 1: the shell is transiently redrawing its prompt (real 0.7.5 shape: + # a helper such as starship rides along as a second foreground process). + printf '{"result":{"type":"pane_process_info","process_info":{"pane_id":"w2:p2","shell_pid":%s,"foreground_process_group_id":%s,"foreground_processes":[{"pid":99998,"name":"starship","argv":["/usr/local/bin/starship","prompt","--continuation"]},{"pid":%s,"name":"zsh","argv0":"zsh"}]}}}\n' "$bgpid" "$bgpid" "$bgpid" > "$resp/7.out" + # Sample 2: the helper finished; the shell is provably alone and idle. + death_process_info_fixture w2:p2 "$bgpid" > "$resp/8.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/9.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true}]}}' > "$resp/10.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/11.out" + make_death_lab "$dir" "$bgpid" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_HERDR_PS_BIN="$dir/ps" FM_BACKEND_HERDR_WORKSPACE_MOVER="$dir/mover" \ + FM_FAKE_MOVER_LOG="$dir/mover.log" FM_FAKE_MOVER_RESPONSE="$dir/no-response" \ + FM_BACKEND_HERDR_DEATH_CLOSE_POLLS=2 FM_BACKEND_HERDR_IDLE_SHELL_PROOF_POLLS=3 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_close_pane_focus_preserving fmtest w2:p2' "$ROOT" 2>&1) + status=$? + kill "$bgpid" 2>/dev/null || true; wait "$bgpid" 2>/dev/null || true + [ "$status" -eq 0 ] || fail "a transient prompt helper should settle into the pane-death path: $out" + [ "$(grep -c $'pane\x1fprocess-info' "$log")" -ge 2 ] \ + || fail "the settle window did not retry the idle-shell proof" + assert_not_contains "$(cat "$log")" $'pane\x1fclose' "a transient prompt helper forced the focus-unsafe explicit close" + assert_not_contains "$(cat "$log")" $'tab\x1ffocus' "focus moved despite the settled pane-death removal" + pass "herdr presentation cleanup: a transient prompt helper settles into the pane-death path instead of the plain close" +} + +test_projection_close_death_escalates_sigkill_after_sighup_survival() { + local dir log resp fb out status bgpid + dir="$TMP_ROOT/close-death-escalate"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true},{"workspace_id":"w2","active_tab_id":"w2:t2","focused":false}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/2.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2","tab_id":"w2:t2","workspace_id":"w2"}}}' > "$resp/3.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t2","workspace_id":"w2"}]}}' > "$resp/4.out" + printf '%s\n' '{"result":{"panes":[{"pane_id":"w2:p2","tab_id":"w2:t2"}]}}' > "$resp/5.out" + cp "$resp/1.out" "$resp/6.out" + bash -c 'trap "" HUP; sleep 300' & bgpid=$! + death_process_info_fixture w2:p2 "$bgpid" > "$resp/7.out" + printf '%s\n' '{"error":{"code":"internal_error","message":"transient failure"}}' > "$resp/8.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2"}}}' > "$resp/9.out" + death_process_info_fixture w2:p2 "$bgpid" > "$resp/10.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/11.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true}]}}' > "$resp/12.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/13.out" + make_death_lab "$dir" "$bgpid" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_HERDR_PS_BIN="$dir/ps" FM_BACKEND_HERDR_WORKSPACE_MOVER="$dir/mover" \ + FM_FAKE_MOVER_LOG="$dir/mover.log" FM_FAKE_MOVER_RESPONSE="$dir/no-response" \ + FM_BACKEND_HERDR_DEATH_CLOSE_POLLS=2 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_close_pane_focus_preserving fmtest w2:p2' "$ROOT" 2>&1) + status=$? + [ "$status" -eq 0 ] || fail "a SIGHUP-surviving shell should be finished by the SIGKILL escalation: $out" + assert_not_contains "$(cat "$log")" $'pane\x1fclose' "the SIGKILL escalation used the focus-unsafe explicit close" + if kill -0 "$bgpid" 2>/dev/null; then + kill "$bgpid" 2>/dev/null || true; wait "$bgpid" 2>/dev/null || true + fail "the SIGKILL escalation left the trapped shell alive" + fi + wait "$bgpid" 2>/dev/null || true + pass "herdr presentation cleanup: a SIGHUP-surviving shell is escalated to SIGKILL before giving up" +} + +test_projection_close_death_failure_falls_back_to_plain_close() { + local dir log resp fb out status bgpid + dir="$TMP_ROOT/close-death-fallback"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true},{"workspace_id":"w2","active_tab_id":"w2:t2","focused":false}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/2.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2","tab_id":"w2:t2","workspace_id":"w2"}}}' > "$resp/3.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t2","workspace_id":"w2"}]}}' > "$resp/4.out" + printf '%s\n' '{"result":{"panes":[{"pane_id":"w2:p2","tab_id":"w2:t2"}]}}' > "$resp/5.out" + cp "$resp/1.out" "$resp/6.out" + bash -c 'trap "" HUP; sleep 300' & bgpid=$! + death_process_info_fixture w2:p2 "$bgpid" > "$resp/7.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2"}}}' > "$resp/8.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2"}}}' > "$resp/9.out" + death_process_info_fixture w2:p2 "$bgpid" > "$resp/10.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2"}}}' > "$resp/11.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2"}}}' > "$resp/12.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/14.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true}]}}' > "$resp/15.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/16.out" + make_death_lab "$dir" "$bgpid" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_HERDR_PS_BIN="$dir/ps" FM_BACKEND_HERDR_WORKSPACE_MOVER="$dir/mover" \ + FM_FAKE_MOVER_LOG="$dir/mover.log" FM_FAKE_MOVER_RESPONSE="$dir/no-response" \ + FM_BACKEND_HERDR_DEATH_CLOSE_POLLS=2 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_close_pane_focus_preserving fmtest w2:p2' "$ROOT" 2>&1) + status=$? + [ "$status" -eq 0 ] || fail "an unkillable shell should fall back to the plain close: $out" + assert_contains "$(cat "$log")" $'pane\x1fclose\x1fw2:p2' "a failed pane-death close did not use the plain close fallback" + kill "$bgpid" 2>/dev/null || true; wait "$bgpid" 2>/dev/null || true + pass "herdr presentation cleanup: a failed pane-death close falls back to the plain close" +} + +test_projection_close_death_still_restores_a_stolen_focus() { + local dir log resp fb out status bgpid + dir="$TMP_ROOT/close-death-restore"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true},{"workspace_id":"w2","active_tab_id":"w2:t2","focused":false},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":false}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/2.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2","tab_id":"w2:t2","workspace_id":"w2"}}}' > "$resp/3.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t2","workspace_id":"w2"}]}}' > "$resp/4.out" + printf '%s\n' '{"result":{"panes":[{"pane_id":"w2:p2","tab_id":"w2:t2"}]}}' > "$resp/5.out" + cp "$resp/1.out" "$resp/6.out" + sleep 300 & bgpid=$! + death_process_info_fixture w2:p2 "$bgpid" > "$resp/7.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/8.out" + # The backstop still fires when the post-close snapshot disagrees. + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":false},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":true}]}}' > "$resp/9.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w3:t1","focused":true}]}}' > "$resp/10.out" + printf '%s\n' '{"result":{"tab":{"tab_id":"w1:t1","workspace_id":"w1"}}}' > "$resp/11.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":false}]}}' > "$resp/13.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/14.out" + make_death_lab "$dir" "$bgpid" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_HERDR_PS_BIN="$dir/ps" FM_BACKEND_HERDR_WORKSPACE_MOVER="$dir/mover" \ + FM_FAKE_MOVER_LOG="$dir/mover.log" FM_FAKE_MOVER_RESPONSE="$dir/no-response" \ + FM_BACKEND_HERDR_DEATH_CLOSE_POLLS=2 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_close_pane_focus_preserving fmtest w2:p2' "$ROOT" 2>&1) + status=$? + [ "$status" -eq 0 ] || fail "the pane-death close with a restored backstop should succeed: $out" + assert_contains "$(cat "$log")" $'tab\x1ffocus\x1fw1:t1' "the backstop did not restore the exact prior tab" + pass "herdr presentation cleanup: the exact-tab restore remains the backstop behind the pane-death close" +} + +test_projection_close_death_never_sigkills_a_reused_pid() { + local dir log resp fb out status bgpid + dir="$TMP_ROOT/close-death-pid-reuse"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true},{"workspace_id":"w2","active_tab_id":"w2:t2","focused":false}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/2.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2","tab_id":"w2:t2","workspace_id":"w2"}}}' > "$resp/3.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t2","workspace_id":"w2"}]}}' > "$resp/4.out" + printf '%s\n' '{"result":{"panes":[{"pane_id":"w2:p2","tab_id":"w2:t2"}]}}' > "$resp/5.out" + cp "$resp/1.out" "$resp/6.out" + # The original shell survives SIGHUP; by SIGKILL time the pane's process + # information shows a DIFFERENT shell pid, modeling the original pid having + # been reused by an unrelated process the pane no longer owns. + bash -c 'trap "" HUP; sleep 300' & bgpid=$! + death_process_info_fixture w2:p2 "$bgpid" > "$resp/7.out" + cp "$resp/3.out" "$resp/8.out" # SIGHUP poll 1: pane still present + cp "$resp/3.out" "$resp/9.out" # SIGHUP poll 2: pane still present + death_process_info_fixture w2:p2 99997 > "$resp/10.out" + : > "$resp/11.out" # fallback explicit close: pane close ok + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/12.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true}]}}' > "$resp/13.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/14.out" + make_death_lab "$dir" "$bgpid" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_HERDR_PS_BIN="$dir/ps" FM_BACKEND_HERDR_WORKSPACE_MOVER="$dir/mover" \ + FM_FAKE_MOVER_LOG="$dir/mover.log" FM_FAKE_MOVER_RESPONSE="$dir/no-response" \ + FM_BACKEND_HERDR_DEATH_CLOSE_POLLS=2 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_close_pane_focus_preserving fmtest w2:p2' "$ROOT" 2>&1) + status=$? + if ! kill -0 "$bgpid" 2>/dev/null; then + wait "$bgpid" 2>/dev/null || true + fail "the SIGKILL escalation signaled a pid the exact pane no longer owns" + fi + kill -KILL "$bgpid" 2>/dev/null || true; wait "$bgpid" 2>/dev/null || true + [ "$status" -eq 0 ] || fail "the refused escalation should fall back to the plain close: $out" + assert_contains "$(cat "$log")" $'pane\x1fclose\x1fw2:p2' "the refused escalation did not fall back to the plain close" + pass "herdr presentation cleanup: SIGKILL never reaches a pid the exact pane no longer owns" +} + +assert_projection_close_failed_removal_rolls_back_the_reposition() { + local mode=$1 dir log resp fb out status bgpid + dir="$TMP_ROOT/close-move-rollback-$mode"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + # Doomed w1 sits BEFORE the focused w2 (not last): the plan repositions it + # to the end; then every removal path fails, so the exact original order + # must be restored under the same session lock and the close must fail. + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":false},{"workspace_id":"w2","active_tab_id":"w2:t1","focused":true},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":false}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t1","focused":true}]}}' > "$resp/2.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w1:p1","tab_id":"w1:t1","workspace_id":"w1"}}}' > "$resp/3.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","workspace_id":"w1"}]}}' > "$resp/4.out" + printf '%s\n' '{"result":{"panes":[{"pane_id":"w1:p1","tab_id":"w1:t1"}]}}' > "$resp/5.out" + cp "$resp/1.out" "$resp/6.out" + printf '%s\n' '{"client":{"version":"0.7.5","protocol":16},"server":{"running":true}}' > "$resp/7.out" + # shellcheck disable=SC2016 # $defs is a literal JSON Schema key. + printf '%s\n' '{"schemas":{"request":{"oneOf":[{"properties":{"method":{"const":"workspace.move"}}}],"$defs":{"WorkspaceMoveParams":{"required":["workspace_id","insert_index"],"properties":{"insert_index":{"type":"integer"}}}}}}}' > "$resp/8.out" + printf '%s\n' '{"sessions":[{"name":"fmtest","running":true,"socket_path":"/tmp/fmtest.sock"}]}' > "$resp/9.out" + bash -c 'trap "" HUP; sleep 300' & bgpid=$! + death_process_info_fixture w1:p1 "$bgpid" > "$resp/10.out" + if [ "$mode" = pane-gone-workspace-present ]; then + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/11.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w2","active_tab_id":"w2:t1","focused":true},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":false},{"workspace_id":"w1","active_tab_id":"w1:t2","focused":false}]}}' > "$resp/12.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t2","focused":false},{"workspace_id":"w2","active_tab_id":"w2:t1","focused":true},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":false}]}}' > "$resp/13.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t1","focused":true}]}}' > "$resp/14.out" + else + cp "$resp/3.out" "$resp/11.out" # SIGHUP poll 1: pane still present + cp "$resp/3.out" "$resp/12.out" # SIGHUP poll 2: pane still present + death_process_info_fixture w1:p1 "$bgpid" > "$resp/13.out" # escalation resample: same owner + cp "$resp/3.out" "$resp/14.out" # SIGKILL poll 1: pane still present + cp "$resp/3.out" "$resp/15.out" # SIGKILL poll 2: pane still present + fi + if [ "$mode" = command-fails ]; then + printf '9\n' > "$resp/16.exit" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":false},{"workspace_id":"w2","active_tab_id":"w2:t1","focused":true},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":false}]}}' > "$resp/17.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t1","focused":true}]}}' > "$resp/18.out" + else + : > "$resp/16.out" + cp "$resp/3.out" "$resp/17.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":false},{"workspace_id":"w2","active_tab_id":"w2:t1","focused":true},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":false}]}}' > "$resp/18.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t1","focused":true}]}}' > "$resp/19.out" + fi + make_death_lab "$dir" "$bgpid" + printf '%s\n' '{"id":"fm-workspace-move","result":{"type":"workspace_list","workspaces":[{"workspace_id":"w2","focused":true},{"workspace_id":"w3","focused":false},{"workspace_id":"w1","focused":false}]}}' > "$dir/mover-response" + printf '%s\n' '{"id":"fm-workspace-move","result":{"type":"workspace_list","workspaces":[{"workspace_id":"w1","focused":false},{"workspace_id":"w2","focused":true},{"workspace_id":"w3","focused":false}]}}' > "$dir/mover-response-2" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_HERDR_SCRIPT_STATUS=1 \ + FM_HERDR_PS_BIN="$dir/ps" FM_BACKEND_HERDR_WORKSPACE_MOVER="$dir/mover" \ + FM_FAKE_MOVER_LOG="$dir/mover.log" FM_FAKE_MOVER_RESPONSE="$dir/mover-response" \ + FM_FAKE_MOVER_RESPONSE_2="$dir/mover-response-2" \ + FM_BACKEND_HERDR_DEATH_CLOSE_POLLS=2 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_close_pane_focus_preserving fmtest w1:p1' "$ROOT" 2>&1) + status=$? + kill -KILL "$bgpid" 2>/dev/null || true; wait "$bgpid" 2>/dev/null || true + [ "$status" -ne 0 ] || fail "an unconfirmed removal must report failure: $out" + [ "$(wc -l < "$dir/mover.log" | tr -d ' ')" = 2 ] \ + || fail "a failed removal did not roll the reposition back exactly once: $(cat "$dir/mover.log")" + [ "$(sed -n '1p' "$dir/mover.log")" = "$(cd /tmp && pwd -P)/fmtest.sock"$'\t'"w1"$'\t'"3" ] \ + || fail "the reposition did not move the doomed workspace to the end: $(sed -n '1p' "$dir/mover.log")" + [ "$(sed -n '2p' "$dir/mover.log")" = "$(cd /tmp && pwd -P)/fmtest.sock"$'\t'"w1"$'\t'"0" ] \ + || fail "the rollback did not restore the doomed workspace to its exact original position: $(sed -n '2p' "$dir/mover.log")" + assert_not_contains "$(cat "$log")" $'tab\x1ffocus' "a failed rolled-back removal moved focus" +} + +test_projection_close_failed_removal_rolls_back_the_reposition() { + assert_projection_close_failed_removal_rolls_back_the_reposition command-fails + assert_projection_close_failed_removal_rolls_back_the_reposition command-succeeds-pane-present + assert_projection_close_failed_removal_rolls_back_the_reposition pane-gone-workspace-present + pass "herdr presentation cleanup: every unconfirmed removal restores the exact original workspace order and reports failure" +} + +test_kill_emptying_non_focused_uses_pane_death() { + local dir log resp fb out status bgpid lock_log lock_held + dir="$TMP_ROOT/kill-death"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; lock_log="$dir/lock.log"; lock_held="$dir/lock-held" + : > "$log"; : > "$lock_log" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true},{"workspace_id":"w2","active_tab_id":"w2:t2","focused":false}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/2.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2","tab_id":"w2:t2","workspace_id":"w2"}}}' > "$resp/3.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t2","workspace_id":"w2"}]}}' > "$resp/4.out" + printf '%s\n' '{"result":{"panes":[{"pane_id":"w2:p2","tab_id":"w2:t2"}]}}' > "$resp/5.out" + cp "$resp/1.out" "$resp/6.out" + sleep 300 & bgpid=$! + death_process_info_fixture w2:p2 "$bgpid" > "$resp/7.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/8.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true}]}}' > "$resp/9.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/10.out" + make_death_lab "$dir" "$bgpid" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_HERDR_PS_BIN="$dir/ps" FM_BACKEND_HERDR_WORKSPACE_MOVER="$dir/mover" \ + FM_FAKE_MOVER_LOG="$dir/mover.log" FM_FAKE_MOVER_RESPONSE="$dir/no-response" \ + FM_BACKEND_HERDR_DEATH_CLOSE_POLLS=2 FM_FAKE_LOCK_LOG="$lock_log" \ + FM_FAKE_LOCK_HELD="$lock_held" \ + bash -c ' + . "$0/bin/backends/herdr.sh" + fm_backend_herdr_target_ready() { fm_backend_herdr_parse_target "$1"; } + fm_backend_herdr_presentation_session_lock_path() { printf "%s" "$FM_FAKE_LOCK_HELD.lock"; } + fm_lock_try_acquire() { + printf "acquire\n" >> "$FM_FAKE_LOCK_LOG" + : > "$FM_FAKE_LOCK_HELD" + } + fm_lock_release() { + [ -e "$FM_FAKE_LOCK_HELD" ] || return 1 + rm -f "$FM_FAKE_LOCK_HELD" + printf "release\n" >> "$FM_FAKE_LOCK_LOG" + } + eval "$(declare -f fm_backend_herdr_cli | sed "1s/fm_backend_herdr_cli/fm_backend_herdr_cli_locked/")" + fm_backend_herdr_cli() { + [ -e "$FM_FAKE_LOCK_HELD" ] || return 97 + fm_backend_herdr_cli_locked "$@" + } + fm_backend_herdr_kill fmtest:w2:p2 + ' "$ROOT" 2>&1) + status=$? + [ "$status" -eq 0 ] || fail "an emptying non-focused kill should stay best-effort: $out" + [ "$(cat "$lock_log")" = "$(printf 'acquire\nrelease')" ] \ + || fail "the generic kill did not hold one presentation lock across its complete mutation: $(cat "$lock_log")" + [ ! -e "$lock_held" ] || fail "the generic kill retained its presentation lock" + assert_not_contains "$(cat "$log")" $'pane\x1fclose' "an emptying non-focused kill used the focus-unsafe explicit close" + assert_not_contains "$(cat "$log")" $'tab\x1ffocus' "an emptying non-focused kill moved focus" + pass "fm_backend_herdr_kill: one session lock covers the focus-safe emptying removal" +} + +test_kill_focused_workspace_stays_plain_close() { + local dir log resp fb out status bgpid + dir="$TMP_ROOT/kill-focused"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":false},{"workspace_id":"w2","active_tab_id":"w2:t2","focused":true}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t2","focused":true}]}}' > "$resp/2.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2","tab_id":"w2:t2","workspace_id":"w2"}}}' > "$resp/3.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/5.out" + sleep 300 & bgpid=$! + make_death_lab "$dir" "$bgpid" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_HERDR_PS_BIN="$dir/ps" FM_BACKEND_HERDR_WORKSPACE_MOVER="$dir/mover" \ + FM_FAKE_MOVER_LOG="$dir/mover.log" FM_FAKE_MOVER_RESPONSE="$dir/no-response" \ + FM_BACKEND_HERDR_DEATH_CLOSE_POLLS=2 \ + bash -c ' + . "$0/bin/backends/herdr.sh" + fm_backend_herdr_presentation_session_lock_path() { printf "/tmp/fm-herdr-test-lock"; } + fm_lock_try_acquire() { return 0; } + fm_lock_release() { return 0; } + fm_backend_herdr_kill fmtest:w2:p2 + ' "$ROOT" 2>&1) + status=$? + [ "$status" -eq 0 ] || fail "a focused-workspace kill should stay best-effort: $out" + assert_contains "$(cat "$log")" $'pane\x1fclose\x1fw2:p2' "a focused-workspace kill did not use the plain close" + assert_not_contains "$(cat "$log")" $'pane\x1fprocess-info' "a focused-workspace kill ran the idle-shell proof" + kill -0 "$bgpid" 2>/dev/null || fail "a focused-workspace kill signaled the pane's shell" + kill "$bgpid" 2>/dev/null || true; wait "$bgpid" 2>/dev/null || true + pass "fm_backend_herdr_kill: killing the focused workspace's tab keeps the legitimate plain close" +} + +test_kill_refuses_when_presentation_lock_is_unavailable() { + local dir mode out status attempts + dir="$TMP_ROOT/kill-lock-refusal"; mkdir -p "$dir" + for mode in unresolved contended; do + : > "$dir/cli.log" + : > "$dir/attempts" + out=$(ROOT="$ROOT" MODE="$mode" CLI_LOG="$dir/cli.log" ATTEMPTS="$dir/attempts" bash -c ' + . "$ROOT/bin/backends/herdr.sh" + fm_backend_herdr_target_ready() { fm_backend_herdr_parse_target "$1"; } + fm_backend_herdr_presentation_session_lock_path() { + [ "$MODE" = contended ] || return 1 + printf "/tmp/fm-herdr-contended-test-lock" + } + fm_lock_try_acquire() { + printf "x\n" >> "$ATTEMPTS" + return 1 + } + fm_backend_herdr_cli() { + printf "%s\n" "$*" >> "$CLI_LOG" + return 0 + } + sleep() { :; } + fm_backend_herdr_kill fmtest:w2:p2 + ' 2>&1) + status=$? + [ "$status" -eq 0 ] || fail "$mode presentation lock refusal changed best-effort kill status: $status" + [ ! -s "$dir/cli.log" ] || fail "$mode presentation lock refusal still mutated Herdr: $(cat "$dir/cli.log")" + assert_contains "$out" "refusing an unlocked pane close" \ + "$mode presentation lock refusal did not report the deferred close" + attempts=$(wc -l < "$dir/attempts" | tr -d ' ') + if [ "$mode" = contended ]; then + [ "$attempts" = 50 ] || fail "contended presentation lock did not use the bounded wait: $attempts attempts" + else + [ "$attempts" = 0 ] || fail "unresolved presentation lock path attempted acquisition: $attempts" + fi + done + pass "fm_backend_herdr_kill: unavailable session locks defer every pane close" +} + +test_endpoint_confirmed_gone_gates_on_structured_presence() { + local out + out=$(bash -c ' + . "$0/bin/backends/herdr.sh" + fm_backend_herdr_cli() { printf "%s\n" "$FM_FAKE_PRESENCE_RESPONSE"; return "${FM_FAKE_PRESENCE_STATUS:-0}"; } + check() { # <label> <response> <status> <mode> <expected-rc> + FM_FAKE_PRESENCE_RESPONSE=$2 FM_FAKE_PRESENCE_STATUS=$3 + rc=0 + fm_backend_herdr_endpoint_confirmed_gone fmtest:w2:p2 "$4" || rc=$? + [ "$rc" = "$5" ] || printf "MISMATCH %s: rc=%s expected=%s\n" "$1" "$rc" "$5" + } + check present-default "{\"result\":{\"pane\":{\"pane_id\":\"w2:p2\"}}}" 0 "" 1 + check present-strict "{\"result\":{\"pane\":{\"pane_id\":\"w2:p2\"}}}" 0 strict 1 + check notfound-default "{\"error\":{\"code\":\"pane_not_found\"}}" 1 "" 0 + check notfound-strict "{\"error\":{\"code\":\"pane_not_found\"}}" 1 strict 0 + check unknown-default "" 1 "" 1 + check unknown-strict "" 1 strict 1 + check othererror-default "{\"error\":{\"code\":\"internal\"}}" 1 "" 1 + check othererror-strict "{\"error\":{\"code\":\"internal\"}}" 1 strict 1 + # Missing or malformed endpoint identity is ambiguity, never proof of a + # gone pane: it must refuse record removal. + rc=0 + fm_backend_herdr_endpoint_confirmed_gone malformed-target strict || rc=$? + [ "$rc" = 1 ] || printf "MISMATCH malformed-target: rc=%s expected=1\n" "$rc" + rc=0 + fm_backend_herdr_endpoint_confirmed_gone "" || rc=$? + [ "$rc" = 1 ] || printf "MISMATCH empty-target: rc=%s expected=1\n" "$rc" + ' "$ROOT" 2>&1) + [ -z "$out" ] || fail "endpoint confirmed-gone gate matrix mismatch: $out" + pass "endpoint confirmed-gone: only structured not-found permits record removal and ambiguous identity refuses" +} + test_projection_seeded_prune_refuses_active_tab() { local dir log resp fb out status dir="$TMP_ROOT/projection-seeded-focus-active-refusal"; mkdir -p "$dir/responses" @@ -1104,6 +2068,54 @@ SH pass "herdr presentation ordering: an ambiguous existing worker block is warning-only and read-only" } +test_projection_order_anchors_the_parent_by_exact_id() { + local dir log resp fb mover layout out status + layout='{"result":{"workspaces":[{"workspace_id":"w1","label":"firstmate","focused":false},{"workspace_id":"w7","label":"firstmate","focused":false},{"workspace_id":"wH","label":"human-notes","focused":false},{"workspace_id":"w8","label":"└ new · p:ZyXwVuTsRqPoNmLkJiHgFe","focused":false}]}}' + + # Without the exact parent id, two same-labeled parents make the whole layout + # ambiguous and ordering steps aside. + dir="$TMP_ROOT/projection-order-dup-label"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; mover="$dir/mover"; : > "$log" + printf '%s\n' "$layout" > "$resp/1.out" + cat > "$mover" <<'SH' +#!/usr/bin/env bash +echo called > "$FM_FAKE_MOVER_CALLED" +exit 0 +SH + chmod +x "$mover" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_BACKEND_HERDR_WORKSPACE_MOVER="$mover" FM_FAKE_MOVER_CALLED="$dir/called" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_order_best_effort fmtest w8 firstmate' "$ROOT" 2>&1) + status=$? + [ "$status" -eq 0 ] || fail "ambiguous projection ordering must not fail the spawn" + assert_contains "$out" "ambiguous workspace layout" "a duplicated parent label should make label-anchored ordering step aside" + [ ! -e "$dir/called" ] || fail "ambiguous parent label attempted workspace.move" + + # With the launcher's exact parent workspace id, the same layout is no longer + # ambiguous: ordering gets past parent selection and stops later, on this + # fake's protocol, having still moved nothing. + dir="$TMP_ROOT/projection-order-exact-parent"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; mover="$dir/mover"; : > "$log" + printf '%s\n' "$layout" > "$resp/1.out" + cat > "$mover" <<'SH' +#!/usr/bin/env bash +echo called > "$FM_FAKE_MOVER_CALLED" +exit 0 +SH + chmod +x "$mover" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_BACKEND_HERDR_WORKSPACE_MOVER="$mover" FM_FAKE_MOVER_CALLED="$dir/called" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_order_best_effort fmtest w8 firstmate w7' "$ROOT" 2>&1) + status=$? + [ "$status" -eq 0 ] || fail "exact-parent projection ordering must not fail the spawn" + assert_not_contains "$out" "ambiguous workspace layout" "the exact parent id should have resolved the duplicated label" + assert_contains "$out" "protocol" "exact-parent ordering did not reach its protocol gate" + [ ! -e "$dir/called" ] || fail "exact-parent ordering attempted workspace.move below the required protocol" + pass "herdr presentation ordering: the launcher's exact parent workspace id disambiguates a duplicated home label without moving anything" +} + test_projection_order_foreign_new_child_before_parent_is_read_only() { local dir log resp fb mover out status dir="$TMP_ROOT/projection-order-foreign-new"; mkdir -p "$dir/responses" @@ -1350,7 +2362,7 @@ test_projection_reclaim_refusal_matrix_is_non_mutating() { } test_projection_reclaim_replaces_only_exact_husk_and_advances_binding() { - local dir state home home_real log resp fb journal token label out calls create_line close_line + local dir state home home_real log resp fb journal token label out calls create_line close_line agent_line boundary_mutations dir="$TMP_ROOT/projection-reclaim-exact"; state="$dir/state"; home="$dir/home" mkdir -p "$dir/responses" "$state" "$home" home_real=$(cd "$home" && pwd -P) @@ -1366,7 +2378,7 @@ test_projection_reclaim_replaces_only_exact_husk_and_advances_binding() { ' "$ROOT" "$state" "$home_real") || fail "could not create exact reclaim journal fixture" journal="$state/fm-hibit-r1.herdr-presentation" label="└ hibit-r1 · p:$token" - printf '%s\n' "{\"result\":{\"workspaces\":[{\"workspace_id\":\"w1\",\"label\":\"firstmate\",\"focused\":true,\"active_tab_id\":\"w1:t1\"},{\"workspace_id\":\"w2\",\"label\":\"$label\",\"focused\":false,\"active_tab_id\":\"w2:t2\"}]}}" > "$resp/1.out" + printf '%s\n' "{\"result\":{\"workspaces\":[{\"workspace_id\":\"w0\",\"label\":\"firstmate\",\"focused\":false,\"active_tab_id\":\"w0:t1\"},{\"workspace_id\":\"w1\",\"label\":\"firstmate\",\"focused\":true,\"active_tab_id\":\"w1:t1\"},{\"workspace_id\":\"w2\",\"label\":\"$label\",\"focused\":false,\"active_tab_id\":\"w2:t2\"}]}}" > "$resp/1.out" printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t2","label":"fm-fm-hibit-r1"}]}}' > "$resp/2.out" printf '%s\n' '{"result":{"panes":[{"pane_id":"w2:p2","tab_id":"w2:t2"}]}}' > "$resp/3.out" printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2"}}}' > "$resp/4.out" @@ -1385,13 +2397,17 @@ test_projection_reclaim_replaces_only_exact_husk_and_advances_binding() { printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2","tab_id":"w2:t2","workspace_id":"w2"}}}' > "$resp/17.out" printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2"}}}' > "$resp/18.out" printf '%s\n' '{"error":{"code":"agent_not_found"}}' > "$resp/19.out" - : > "$resp/20.out" - cp "$resp/6.out" "$resp/21.out" - cp "$resp/7.out" "$resp/22.out" - printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/23.out" - cp "$resp/1.out" "$resp/24.out" - printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t3","label":"fm-fm-hibit-r1"}]}}' > "$resp/25.out" - printf '%s\n' '{"result":{"panes":[{"pane_id":"w2:p3","tab_id":"w2:t3"}]}}' > "$resp/26.out" + # The emptying-close plan sees the replacement tab alongside the old husk + # tab, so the husk close stays plain. + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t2","label":"fm-fm-hibit-r1"},{"tab_id":"w2:t3","label":"fm-fm-hibit-r1"}]}}' > "$resp/20.out" + : > "$resp/21.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/22.out" + cp "$resp/6.out" "$resp/23.out" + cp "$resp/7.out" "$resp/24.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/25.out" + cp "$resp/1.out" "$resp/26.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t3","label":"fm-fm-hibit-r1"}]}}' > "$resp/27.out" + printf '%s\n' '{"result":{"panes":[{"pane_id":"w2:p3","tab_id":"w2:t3"}]}}' > "$resp/28.out" fb=$(make_herdr_fakebin "$dir") out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ bash -c ' @@ -1409,12 +2425,18 @@ test_projection_reclaim_replaces_only_exact_husk_and_advances_binding() { close_line=$(grep -n $'pane\x1fclose\x1fw2:p2' "$log" | cut -d: -f1) [ -n "$create_line" ] && [ -n "$close_line" ] && [ "$create_line" -lt "$close_line" ] \ || fail "reclaim did not create the exact replacement before closing the old husk" - [ "$(sed -n "$((close_line - 1))p" "$log")" = $'HERDR_SESSION=fmtest\x1fagent\x1fget\x1fw2:p2\x1f--session\x1ffmtest' ] \ - || fail "reclaim did not recheck the old pane agent state at the exact close boundary" + agent_line=$(grep -n $'agent\x1fget\x1fw2:p2' "$log" | tail -1 | cut -d: -f1) + [ -n "$agent_line" ] && [ "$agent_line" -lt "$close_line" ] \ + || fail "reclaim did not recheck the old pane agent state before the close" + boundary_mutations=$(sed -n "$((agent_line + 1)),$((close_line - 1))p" "$log" \ + | grep -Ev $'\x1f(tab\x1flist|pane\x1flist|workspace\x1flist)' || true) + [ -z "$boundary_mutations" ] \ + || fail "reclaim mutated between the old pane agent recheck and the close: $boundary_mutations" assert_not_contains "$calls" $'workspace\x1fclose' "reclaim introduced workspace-close authority" assert_not_contains "$calls" $'workspace\x1frename' "reclaim renamed the projected workspace" assert_not_contains "$calls" $'tab\x1ffocus' "focus-preserving reclaim changed an already-stable focus snapshot" - pass "herdr presentation reclaim: exact agent-free husk is replaced in place and journal/focus identities advance" + assert_not_contains "$calls" $'\x1fw0' "reclaim touched the same-labeled sibling parent" + pass "herdr presentation reclaim: exact agent-free husk survives duplicate parent labels while its sibling stays untouched" } test_projection_recovery_is_read_only_and_refuses_live_duplicate_risk() { @@ -1585,7 +2607,14 @@ test_kill_is_best_effort() { printf '1\n' > "$resp/1.exit" fb=$(make_herdr_fakebin "$dir") PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_kill default:w1:p2' "$ROOT" + bash -c ' + . "$0/bin/backends/herdr.sh" + fm_backend_herdr_target_ready() { fm_backend_herdr_parse_target "$1"; } + fm_backend_herdr_presentation_session_lock_path() { printf "/tmp/fm-herdr-test-lock"; } + fm_lock_try_acquire() { return 0; } + fm_lock_release() { return 0; } + fm_backend_herdr_kill default:w1:p2 + ' "$ROOT" expect_code 0 $? "kill must be best-effort (never fail even when the pane close call itself fails)" assert_contains "$(cat "$log")" $'\x1f''pane'$'\x1f''close'$'\x1f''w1:p2' "kill did not call pane close on the right pane" pass "fm_backend_herdr_kill: calls pane close and stays best-effort on failure" @@ -2408,7 +3437,13 @@ $ids EOF [ -n "$pane" ] || fail "cycle $i: create_task returned no pane id" PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_FAKE_HERDR_STATE="$state" HERDR_SESSION=fmtest \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_kill "$1"' "$ROOT" "fmtest:$pane" \ + bash -c ' + . "$0/bin/backends/herdr.sh" + fm_backend_herdr_presentation_session_lock_path() { printf "/tmp/fm-herdr-cycle-test-lock"; } + fm_lock_try_acquire() { return 0; } + fm_lock_release() { return 0; } + fm_backend_herdr_kill "$1" + ' "$ROOT" "fmtest:$pane" \ || fail "cycle $i: kill failed" done # exactly one firstmate workspace survives three spawn/teardown cycles @@ -2871,6 +3906,19 @@ test_workspace_label_secondmate_marker_trims_whitespace test_workspace_label_empty_marker_falls_back_to_primary test_workspace_label_different_secondmates_get_different_labels test_cli_helper_sets_env_and_appends_trailing_session_flag +test_launcher_identity_absent_without_a_herdr_pane +test_launcher_identity_absent_when_herdr_env_alone_is_set +test_launcher_identity_resolves_the_exact_pane_tab_and_workspace +test_launcher_identity_refuses_a_pane_from_another_session_name +test_launcher_identity_refuses_a_missing_server_socket +test_launcher_identity_refuses_a_pane_from_another_server_socket +test_launcher_identity_refuses_an_unreadable_pane +test_launcher_identity_refuses_a_pane_and_tab_that_disagree +test_launcher_identity_refuses_a_workspace_missing_from_the_session +test_workspace_ensure_prefers_the_launcher_over_the_first_label_match +test_workspace_ensure_refuses_an_ambiguous_label_with_no_launcher +test_workspace_ensure_other_home_ignores_the_launcher_identity +test_container_ensure_refuses_an_ambiguous_home_label test_container_ensure_starts_server_and_workspace test_container_ensure_reuses_existing_workspace test_container_ensure_creates_with_no_focus_flag @@ -2900,6 +3948,25 @@ test_projection_close_restores_exact_prior_focus test_projection_close_refuses_active_tab test_projection_close_reports_focus_restore_failure test_projection_close_rechecks_required_agent_state_at_boundary +test_projection_close_emptying_after_focus_uses_pane_death_without_move +test_projection_close_emptying_before_focus_repositions_then_uses_pane_death +test_projection_close_emptying_before_last_focus_needs_no_move +test_projection_close_emptying_last_workspace_needs_no_move +test_projection_close_non_emptying_stays_plain_without_proof_or_move +test_projection_close_plain_without_move_requires_structured_removal +test_projection_close_ambiguous_positions_fall_back_to_plain_close +test_projection_close_move_failure_falls_back_to_plain_close +test_projection_close_busy_pane_falls_back_to_plain_close +test_projection_close_transient_prompt_helper_settles_then_uses_pane_death +test_projection_close_death_escalates_sigkill_after_sighup_survival +test_projection_close_death_failure_falls_back_to_plain_close +test_projection_close_death_still_restores_a_stolen_focus +test_projection_close_death_never_sigkills_a_reused_pid +test_projection_close_failed_removal_rolls_back_the_reposition +test_kill_emptying_non_focused_uses_pane_death +test_kill_focused_workspace_stays_plain_close +test_endpoint_confirmed_gone_gates_on_structured_presence +test_kill_refuses_when_presentation_lock_is_unavailable test_projection_seeded_prune_refuses_active_tab test_projection_label_builder_uses_corner_and_strips_owner_prefixes test_projection_order_moves_only_exact_new_workspace_and_preserves_relative_order @@ -2909,6 +3976,7 @@ test_projection_order_allows_intervening_parent_child_block test_projection_order_human_spaces_never_move_targets test_projection_order_failure_warns_without_cleanup_or_spawn_failure test_projection_order_ambiguous_existing_block_is_read_only +test_projection_order_anchors_the_parent_by_exact_id test_projection_order_foreign_new_child_before_parent_is_read_only test_projection_order_missing_parent_is_read_only test_presentation_session_lock_path_is_shared_across_homes diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 84ba7acad2d..5657d8fd202 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -141,7 +141,7 @@ resolve_permissive_tmux_kill_ref() { # 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-remote-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" +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-remote-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 fm-public-followup-lib.sh fm-secondmate-registry-lib.sh fm-x-lib.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" OLD_BIN_REFACTORED="fm-send.sh fm-peek.sh fm-watch.sh fm-spawn.sh fm-teardown.sh fm-marker-lib.sh" diff --git a/tests/fm-backlog-handoff.test.sh b/tests/fm-backlog-handoff.test.sh index b0b931d71f6..2efd8dd3d5c 100755 --- a/tests/fm-backlog-handoff.test.sh +++ b/tests/fm-backlog-handoff.test.sh @@ -482,9 +482,11 @@ test_registry_home_with_pre_home_parentheses() { setup_homes "$home" "$sub" "$id" local sub_abs sub_abs=$(cd "$sub" && pwd -P) - # Prose parentheses before (home: ...), matching live registry shape. - printf -- '- %s - issue triage (id is legacy) (home: %s; scope: issue triage; projects: alpha; added 2026-07-09)\n' \ + # Prose parentheses before (home: ...) and punctuation inside scope match the live registry shape. + printf -- '- %s - issue triage (id is legacy) (home: %s; scope: issue triage (child); semicolon is meaningful; projects: alpha; added 2026-07-09)\n' \ "$id" "$sub_abs" > "$home/data/secondmates.md" + FM_HOME="$home" "$ROOT/bin/fm-home-seed.sh" validate >/dev/null \ + || fail "home-seed validation rejected punctuation-bearing registry fields" cat > "$home/data/backlog.md" <<'EOF' ## Queued diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index 31c27a677ae..a67284e56aa 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -75,6 +75,18 @@ make_home() { # <name> printf '%s\n' "$home" } +record_claude_state() { # <state-dir> <id> <busy|idle> + local state=$1 id=$2 semantic_state=$3 gen event + case "$semantic_state" in + busy) event=user-prompt-submit ;; + idle) event=stop ;; + *) fail "unsupported semantic fixture state: $semantic_state" ;; + esac + gen=$("$ROOT/bin/fm-busy-event.sh" arm "$state" "$id") + "$ROOT/bin/fm-busy-event.sh" apply "$state" "$id" "$semantic_state" --gen "$gen" \ + --source claude-hook --event "$event" +} + fixture_mate_home() { # <parent-home> printf '%s/%s-secondmate-home\n' "$TMP_ROOT" "$(basename "$1")" } @@ -108,18 +120,20 @@ EOF "window=firstmate:fm-ship-task" \ "worktree=$home/projects/ship-wt" \ "project=firstmate" \ - "harness=codex" \ + "harness=claude" \ "kind=ship" \ "mode=no-mistakes" \ "pr=https://github.com/kunchenguid/firstmate/pull/9" + record_claude_state "$home/state" ship-task busy printf 'working: building the thing\n' > "$home/state/ship-task.status" fm_write_meta "$home/state/scout-x.meta" \ "window=firstmate:fm-scout-x" \ "worktree=$home/projects/ship-wt" \ "project=firstmate" \ - "harness=codex" \ + "harness=claude" \ "kind=scout" \ "mode=scout" + record_claude_state "$home/state" scout-x idle printf 'done: report ready\n' > "$home/state/scout-x.status" fm_write_meta "$home/state/mate.meta" \ "window=firstmate:fm-mate" \ @@ -136,9 +150,10 @@ EOF "window=firstmate:fm-external-wait" \ "worktree=$home/projects/ship-wt" \ "project=firstmate" \ - "harness=codex" \ + "harness=claude" \ "kind=ship" \ "mode=no-mistakes" + record_claude_state "$home/state" external-wait idle printf 'paused: declared external-wait for upstream release\n' > "$home/state/external-wait.status" # The secondmate's OWN home backlog records a merge it managed. This lands in the # secondmate home, never the main backlog, so landed-work views only see it via the @@ -156,7 +171,8 @@ EOF mkdir -p "$mate/projects/mate" fm_write_meta "$mate/state/mate.meta" \ "window=firstmate:fm-mate" "worktree=$mate/projects/mate" "project=firstmate" \ - "harness=codex" "kind=ship" "mode=no-mistakes" + "harness=claude" "kind=ship" "mode=no-mistakes" + record_claude_state "$mate/state" mate idle printf 'needs-decision [key=race]: pick subscribe order\n' > "$mate/state/mate.status" } @@ -367,7 +383,8 @@ test_structured_child_decision_reaches_captains_call() { EOF fm_write_meta "$mate/state/phase8.meta" \ "window=firstmate:fm-phase8" "worktree=$mate/projects/phase8" "project=sample" \ - "harness=codex" "kind=ship" "mode=no-mistakes" + "harness=claude" "kind=ship" "mode=no-mistakes" + record_claude_state "$mate/state" phase8 idle printf 'needs-decision [key=release]: choose release A or B\n' > "$mate/state/phase8.status" fakebin=$(make_fakebin "$home") json=$(run "$home" "$fakebin" --json) @@ -527,7 +544,8 @@ test_secondmate_and_child_bounds_are_disclosed() { printf -- '- [ ] %s - Active %s (repo: sample) (kind: ship) (since 2026-07-13)\n' "$child" "$child" >> "$mate/data/backlog.md" fm_write_meta "$mate/state/$child.meta" \ "window=firstmate:fm-$child" "worktree=$mate/projects/$child" "project=sample" \ - "harness=codex" "kind=ship" "mode=no-mistakes" + "harness=claude" "kind=ship" "mode=no-mistakes" + record_claude_state "$mate/state" "$child" busy printf 'working [key=%s]: active child %s\n' "$child" "$i" > "$mate/state/$child.status" i=$((i + 1)) done @@ -636,7 +654,8 @@ EOF EOF fm_write_meta "$decision/state/$child.meta" \ "window=firstmate:fm-$child" "worktree=$decision/projects/$child" "project=sample" \ - "harness=codex" "kind=ship" "mode=no-mistakes" + "harness=claude" "kind=ship" "mode=no-mistakes" + record_claude_state "$decision/state" "$child" idle printf 'needs-decision [key=live-route]: choose the current route\n' > "$decision/state/$child.status" fakebin=$(make_fakebin "$home") canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ @@ -688,7 +707,8 @@ test_nonprogressing_child_states_are_explicit() { EOF fm_write_meta "$mate/state/parked.meta" \ "window=firstmate:fm-parked" "worktree=$mate/projects/parked" "project=sample" \ - "harness=codex" "kind=ship" "mode=no-mistakes" + "harness=claude" "kind=ship" "mode=no-mistakes" + record_claude_state "$mate/state" parked idle printf 'needs-decision [key=parked]: choose a route\n' > "$mate/state/parked.status" fakebin=$(make_fakebin "$home") canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ @@ -725,10 +745,12 @@ EOF EOF fm_write_meta "$mate/state/done.meta" \ "window=firstmate:fm-done" "worktree=$mate/projects/done" "project=sample" \ - "harness=codex" "kind=ship" "mode=no-mistakes" + "harness=claude" "kind=ship" "mode=no-mistakes" fm_write_meta "$mate/state/failed.meta" \ "window=firstmate:fm-failed" "worktree=$mate/projects/failed" "project=sample" \ - "harness=codex" "kind=ship" "mode=no-mistakes" + "harness=claude" "kind=ship" "mode=no-mistakes" + record_claude_state "$mate/state" "done" idle + record_claude_state "$mate/state" failed idle printf 'done: complete\n' > "$mate/state/done.status" printf 'failed: stopped\n' > "$mate/state/failed.status" rm "$mate/state/parked.meta" "$mate/state/parked.status" @@ -1365,6 +1387,7 @@ test_live_blocker_is_not_charted_queue_work() { local home fakebin json home=$(make_home live-blocker); write_fixture "$home" printf 'blocked [key=synthetic-dependency]: firstmate can refresh the synthetic token\n' > "$home/state/ship-task.status" + record_claude_state "$home/state" ship-task idle fakebin=$(make_fakebin "$home") json=$(run "$home" "$fakebin" --json) printf '%s' "$json" | jq -e ' @@ -1563,7 +1586,8 @@ test_mixed_secondmate_roles_partial_state_and_captain_readiness() { EOF fm_write_meta "$hibit/state/hibit-worker.meta" \ "window=firstmate:fm-hibit-worker" "worktree=$hibit/projects/worker" "project=hibit" \ - "harness=codex" "kind=ship" "mode=no-mistakes" + "harness=claude" "kind=ship" "mode=no-mistakes" + record_claude_state "$hibit/state" hibit-worker busy printf 'working: finalizing progress\n' > "$hibit/state/hibit-worker.status" cat > "$wheel/data/backlog.md" <<'EOF' @@ -1577,7 +1601,8 @@ EOF EOF fm_write_meta "$wheel/state/wheel-worker.meta" \ "window=firstmate:fm-wheel-worker" "worktree=$wheel/projects/worker" "project=wheelhouse" \ - "harness=codex" "kind=ship" "mode=no-mistakes" + "harness=claude" "kind=ship" "mode=no-mistakes" + record_claude_state "$wheel/state" wheel-worker busy printf 'working: active validation\n' > "$wheel/state/wheel-worker.status" cat > "$sshhip/data/backlog.md" <<'EOF' @@ -1606,7 +1631,8 @@ EOF EOF fm_write_meta "$ha/state/prep.meta" \ "window=firstmate:fm-prep" "worktree=$ha/projects/prep" "project=home-assistant" \ - "harness=codex" "kind=ship" "mode=no-mistakes" + "harness=claude" "kind=ship" "mode=no-mistakes" + record_claude_state "$ha/state" prep busy printf 'working: preparing canary\n' > "$ha/state/prep.status" fakebin=$(make_fakebin "$home") @@ -1710,7 +1736,8 @@ EOF fm_write_meta "$wheel/state/production-observation.meta" \ "window=firstmate:fm-production-observation" "worktree=$wheel/projects/worker" "project=wheelhouse" \ - "harness=codex" "kind=scout" "mode=scout" + "harness=claude" "kind=scout" "mode=scout" + record_claude_state "$wheel/state" production-observation idle printf 'paused: observation is deliberately held\n' > "$wheel/state/production-observation.status" canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ "$ROOT/bin/fm-fleet-snapshot.sh" --json) @@ -1723,7 +1750,8 @@ EOF fm_write_meta "$sshhip/state/unreadable-child.meta" \ "window=firstmate:fm-unreadable-child" "worktree=$sshhip/projects/child" "project=sshhip" \ - "harness=codex" "kind=ship" "mode=no-mistakes" + "harness=claude" "kind=ship" "mode=no-mistakes" + record_claude_state "$sshhip/state" unreadable-child busy printf 'working: app store submission restored\n' > "$sshhip/state/unreadable-child.status" json=$(run "$home" "$fakebin" --json) printf '%s' "$json" | jq -e ' diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index 219d74de58a..6c72c0bbc45 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -78,6 +78,10 @@ add_quota_axi() { local fakebin=$1 cat > "$fakebin/quota-axi" <<'SH' #!/usr/bin/env bash +if [ "${1:-}" = --version ]; then + printf '%s\n' "${FM_FAKE_QUOTA_AXI_VERSION:-0.1.16}" + exit 0 +fi exit 0 SH chmod +x "$fakebin/quota-axi" @@ -322,6 +326,43 @@ ROWS pass "bootstrap enforces no-mistakes minimum version" } +# 0.1.16 is the first quota-axi that reports per-credential auth sources and Grok +# state.authStatus. Before it, a dispatch candidate could not be scoped to its own +# authentication surface, which is exactly how one harness's expired CLI token +# produced a captain-facing "log in" claim for a candidate that never read it. A +# stale install used to pass this check silently, so the fix stayed uninstalled. +test_quota_axi_min_version() { + local label version mode case_dir fakebin out missing n + missing='MISSING: quota-axi (install: npm install -g quota-axi)' + n=0 + while IFS='^' read -r label version mode; do + [ -n "$label" ] || continue + n=$((n + 1)) + case_dir="$TMP_ROOT/quota-axi-$n" + mkdir -p "$case_dir/home/config" + printf '%s\n' manual > "$case_dir/home/config/backlog-backend" + fakebin=$(make_fake_toolchain "$case_dir") + add_tasks_axi "$fakebin" "0.1.1" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$case_dir/home" FM_ROOT_OVERRIDE="$case_dir/home" \ + FM_FAKE_TREEHOUSE_LEASE_HELP=1 FM_FAKE_QUOTA_AXI_VERSION="$version" "$ROOT/bin/fm-bootstrap.sh") + case "$mode" in + empty) + [ -z "$out" ] || fail "$label: expected silence, got: $out" ;; + missing) + [ "$out" = "$missing" ] || fail "$label: expected '$missing', got: $out" ;; + esac + done <<'ROWS' +minimum quota-axi version is accepted^0.1.16^empty +newer quota-axi patch is accepted^0.1.17^empty +newer quota-axi minor is accepted^0.2.0^empty +newer quota-axi major is accepted^1.0.0^empty +older quota-axi patch reports an upgrade^0.1.15^missing +much older quota-axi minor reports an upgrade^0.0.9^missing +unparseable quota-axi version reports an upgrade^quota-axi development build^missing +ROWS + pass "bootstrap enforces quota-axi minimum version" +} + test_git_is_required_with_supported_install_instruction() { local case_dir fakebin bash_env out expected case_dir="$TMP_ROOT/git-required" @@ -655,6 +696,7 @@ make_routine_bootstrap_fixture() { printf '%s\n' '.fm-secondmate-home' printf '%s\n' 'config/crew-harness' printf '%s\n' 'config/crew-dispatch.json' + printf '%s\n' 'config/startup-memory-budget' } > "$root/.gitignore" printf '%s\n' 'instructions' > "$root/AGENTS.md" mkdir -p "$root/bin" "$root/.agents/skills" @@ -792,6 +834,7 @@ ROWS test_bootstrap_reporting test_no_mistakes_min_version +test_quota_axi_min_version test_git_is_required_with_supported_install_instruction test_orca_backend_gates_orca_tool_only_when_selected test_session_provider_backends_do_not_require_tmux diff --git a/tests/fm-brief.test.sh b/tests/fm-brief.test.sh index 86b3a575184..b59e17ad67c 100755 --- a/tests/fm-brief.test.sh +++ b/tests/fm-brief.test.sh @@ -234,6 +234,12 @@ test_faster_paths_use_configured_authority_without_stacked_review() { "local-only brief hard-coded captain-only authority" assert_no_grep "Firstmate then reviews your branch diff" "$brief" \ "local-only brief retained a personal review stacked on the selected delivery path" + assert_no_grep "make \`--intent\` preserve all relevant content from this brief" "$home/data/$id/brief.md" \ + "local-only brief must not include the no-mistakes --intent contract" + id="brief-direct-intent-a4" + FM_HOME="$home" "$ROOT/bin/fm-brief.sh" "$id" direct-proj >/dev/null 2>&1 + assert_no_grep "make \`--intent\` preserve all relevant content from this brief" "$home/data/$id/brief.md" \ + "direct-PR brief must not include the no-mistakes --intent contract" pass "fm-brief.sh: faster paths use configured authority without stacked review" } @@ -255,6 +261,14 @@ test_no_mistakes_dod_wording() { # shellcheck disable=SC2016 # single quotes are deliberate: the backticks must stay literal assert_grep '`help`' "$brief" \ "no-mistakes DOD must render literal backticks around help" + assert_grep "make \`--intent\` preserve all relevant content from this brief" "$brief" \ + "no-mistakes DOD must require --intent to retain the accepted task contract" + assert_grep "carrying only each requirement's current accepted form" "$brief" \ + "no-mistakes DOD must replace superseded requirements with their current accepted form" + assert_grep "retain direct requirements instead of substituting a diff summary" "$brief" \ + "no-mistakes DOD must keep direct requirements and exclude generic scaffold boilerplate from --intent" + assert_grep "exclude generic operational, status, delivery, and other scaffold boilerplate unless it is task-specific" "$brief" \ + "no-mistakes DOD must exclude non-task-specific scaffold boilerplate from --intent" # The apostrophe in "firstmate's authority check" is now structurally safe # (no `$(...)` wrapper around the heredoc), so it renders verbatim instead of # being reworded or escaped away. test_no_heredoc_in_command_substitution diff --git a/tests/fm-busy-adapter-wiring.test.sh b/tests/fm-busy-adapter-wiring.test.sh new file mode 100755 index 00000000000..53894e3a9c8 --- /dev/null +++ b/tests/fm-busy-adapter-wiring.test.sh @@ -0,0 +1,372 @@ +#!/usr/bin/env bash +# Behavior tests for the per-adapter semantic busy-state wiring that +# bin/fm-spawn.sh installs under the contract owned by bin/fm-busy-lib.sh. +# +# These tests run the REAL fm-spawn against a fake tmux pane and an isolated +# git worktree, then drive the generated adapter artifact (the Pi extension, +# the OpenCode plugin) in a plain Node host, so the artifact, the real +# bin/fm-busy-event.sh writer, and the real classifier are exercised together +# with no live harness session. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +# shellcheck source=/dev/null +. "$ROOT/bin/fm-busy-lib.sh" + +SPAWN="$ROOT/bin/fm-spawn.sh" +TMP_ROOT=$(fm_test_tmproot fm-busy-adapter-wiring) + +make_spawn_fakebin() { + local dir=$1 fakebin + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u +case "$*" in + *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; +esac +case "${1:-}" in + display-message) printf 'firstmate\n'; exit 0 ;; + list-windows) exit 0 ;; + has-session|new-session|new-window|kill-window) exit 0 ;; + capture-pane) cat "$(dirname "$0")/.spawn-screen" 2>/dev/null || true; exit 0 ;; + send-keys) + screen="$(dirname "$0")/.spawn-screen" + staged="$screen.staged" + text=${4:-} + case "$text" in + *"__FM_SPAWN_READY_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$screen" + ;; + "FM_SPAWN_LAUNCH=''") : > "$staged" ;; + FM_SPAWN_LAUNCH=*) + rebuilt=$(FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") + printf '%s' "$rebuilt" > "$staged" + ;; + *"__FM_SPAWN_LAUNCH_OK_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\n' "$token" > "$screen" + ;; + esac + exit 0 + ;; +esac +exit 0 +SH + chmod +x "$fakebin/tmux" + fm_fake_exit0 "$fakebin" treehouse pi opencode claude codex + printf '%s\n' "$fakebin" +} + +make_spawn_case() { # <name> <harness> <id> + local name=$1 harness=$2 id=$3 case_dir home proj wt fakebin + case_dir="$TMP_ROOT/$name" + home="$case_dir/home" + proj="$case_dir/project" + wt="$case_dir/wt" + fakebin=$(make_spawn_fakebin "$case_dir/fake") + mkdir -p "$home/data" "$home/projects" "$home/state" "$home/config" + printf '%s\n' "$harness" > "$home/config/crew-harness" + fm_git_worktree "$proj" "$wt" "wt-$name" + touch "$home/state/.last-watcher-beat" + mkdir -p "$home/data/$id" + printf 'brief for %s\n' "$id" > "$home/data/$id/brief.md" + printf '%s\n' "$case_dir|$home|$proj|$wt|$fakebin" +} + +run_spawn() { # <home> <wt> <fakebin> <spawn-args...> + local home=$1 wt=$2 fakebin=$3 + shift 3 + FM_ROOT_OVERRIDE='' FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \ + FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$wt" TMUX="fake,1,0" \ + GROK_HOME="$home/grok-home" PATH="$fakebin:$PATH" \ + "$SPAWN" "$@" 2>&1 +} + +read_case_record() { + # shellcheck disable=SC2034 # CASE_DIR is part of the shared record shape + IFS='|' read -r CASE_DIR HOME_DIR PROJ_DIR WT_DIR FAKEBIN_DIR <<EOF +$1 +EOF +} + +classify() { # <harness> <id> <state-dir> + fm_busy_classify tmux fake:w "$1" "$2" "$3" +} + +# drive_pi_ext <ext-path> <mode>: load the generated Pi extension in a plain +# Node host and fire one lifecycle handler. Modes: agent-start, settle-idle, +# settle-continuing, turn-end. +drive_pi_ext() { + EXT_PATH="$1" MODE="$2" node --input-type=module 2>&1 <<'EOF' +import { pathToFileURL } from "node:url"; +const mod = await import(pathToFileURL(process.env.EXT_PATH).href); +const handlers = {}; +mod.default({ on: (name, fn) => { handlers[name] = fn; } }); +const ctx = { isIdle: () => process.env.MODE !== "settle-continuing" }; +switch (process.env.MODE) { + case "agent-start": await handlers["agent_start"]({}, ctx); break; + case "settle-idle": await handlers["agent_settled"]({}, ctx); break; + case "settle-continuing": await handlers["agent_settled"]({}, ctx); break; + case "settle-then-start": + await handlers["agent_settled"]({}, ctx); + await handlers["agent_start"]({}, ctx); + break; + case "turn-end": await handlers["turn_end"]({}, ctx); break; + default: throw new Error("unknown mode " + process.env.MODE); +} +if (process.env.MODE === "turn-end") { + await new Promise((resolve) => setTimeout(resolve, 200)); +} +EOF +} + +test_pi_extension_semantic_lifecycle() { + local rec id=busy-pi-1 out state ext + rec=$(make_spawn_case pi-lifecycle pi "$id") + read_case_record "$rec" + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$id" "$PROJ_DIR") + expect_code 0 $? "pi spawn should succeed: $out" + state="$HOME_DIR/state" + ext="$state/$id.pi-ext.ts" + assert_present "$ext" "pi spawn did not write the per-task extension" + + out=$(classify pi "$id" "$state") + [ "$out" = "busy fm-spawn" ] || fail "seed after spawn must be 'busy fm-spawn', got '$out'" + + rm -f "$state/$id.turn-ended" + out=$(drive_pi_ext "$ext" turn-end) || fail "turn_end drive failed: $out" + [ -f "$state/$id.turn-ended" ] || fail "turn_end no longer touches the notification marker" + out=$(classify pi "$id" "$state") + [ "$out" = "busy fm-spawn" ] || fail "turn_end must stay a notification, not a state edge, got '$out'" + + out=$(drive_pi_ext "$ext" settle-idle) || fail "agent_settled drive failed: $out" + out=$(classify pi "$id" "$state") + [ "$out" = "idle pi-ext" ] || fail "agent_settled with isIdle must classify 'idle pi-ext', got '$out'" + + out=$(drive_pi_ext "$ext" agent-start) || fail "agent_start drive failed: $out" + out=$(classify pi "$id" "$state") + [ "$out" = "busy pi-ext" ] || fail "agent_start must classify 'busy pi-ext', got '$out'" + + out=$(drive_pi_ext "$ext" settle-continuing) || fail "continuing settle drive failed: $out" + out=$(classify pi "$id" "$state") + [ "$out" = "busy pi-ext" ] || fail "a settle while another run continues must stay busy, got '$out'" + + out=$(drive_pi_ext "$ext" settle-idle) || fail "final settle drive failed: $out" + out=$(classify pi "$id" "$state") + [ "$out" = "idle pi-ext" ] || fail "the final settle must classify idle, got '$out'" + pass "pi extension reports agent_start busy, settles idle only via ctx.isIdle(), and keeps turn_end a notification" +} + +test_pi_extension_serializes_settle_before_next_start() { + local rec id=busy-pi-order out state ext + rec=$(make_spawn_case pi-order pi "$id") + read_case_record "$rec" + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$id" "$PROJ_DIR") + expect_code 0 $? "pi spawn should succeed: $out" + state="$HOME_DIR/state" + ext="$state/$id.pi-ext.ts" + + out=$(drive_pi_ext "$ext" settle-then-start) || fail "settle/start drive failed: $out" + out=$(classify pi "$id" "$state") + [ "$out" = "busy pi-ext" ] || fail "a fresh agent_start after agent_settled must win, got '$out'" + pass "pi extension awaits agent_settled before the next agent_start without a test delay" +} + +test_pi_extension_stale_incarnation_rejected() { + local rec id=busy-pi-2 out state ext + rec=$(make_spawn_case pi-stale pi "$id") + read_case_record "$rec" + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$id" "$PROJ_DIR") + expect_code 0 $? "pi spawn should succeed: $out" + state="$HOME_DIR/state" + ext="$state/$id.pi-ext.ts" + # A re-arm (a rewired incarnation) supersedes the gen embedded in the old + # extension file: its late events must be rejected and never change state. + "$ROOT/bin/fm-busy-event.sh" arm "$state" "$id" >/dev/null + out=$(drive_pi_ext "$ext" settle-idle) || fail "stale settle drive failed: $out" + out=$(classify pi "$id" "$state") + [ "$out" = "busy fm-spawn" ] || fail "a stale extension event must not change state, got '$out'" + pass "pi extension events from a superseded incarnation are rejected as stale" +} + +# drive_oc_plugin <plugin-path> <events-json-lines...>: load the generated +# OpenCode plugin in a plain Node host and feed it one event per argument, in +# order, through the same hooks.event entry OpenCode calls. +drive_oc_plugin() { + local plugin=$1 + shift + PLUGIN_PATH="$plugin" node --input-type=module - "$@" 2>&1 <<'EOF' +import { pathToFileURL } from "node:url"; +const mod = await import(pathToFileURL(process.env.PLUGIN_PATH).href); +const hooks = await mod.FmBusyState({}); +for (const arg of process.argv.slice(2)) { + await hooks.event({ event: JSON.parse(arg) }); +} +EOF +} + +oc_status() { # <sessionID> <type> + printf '{"type":"session.status","properties":{"sessionID":"%s","status":{"type":"%s"}}}' "$1" "$2" +} + +oc_idle() { # <sessionID> + printf '{"type":"session.idle","properties":{"sessionID":"%s"}}' "$1" +} + +test_opencode_plugin_semantic_lifecycle() { + local rec id=busy-oc-1 out state plugin + rec=$(make_spawn_case oc-lifecycle opencode "$id") + read_case_record "$rec" + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$id" "$PROJ_DIR") + expect_code 0 $? "opencode spawn should succeed: $out" + state="$HOME_DIR/state" + plugin="$WT_DIR/.opencode/plugins/fm-busy-state.js" + assert_present "$plugin" "opencode spawn did not write the busy-state plugin" + + out=$(classify opencode "$id" "$state") + [ "$out" = "busy fm-spawn" ] || fail "seed after spawn must be 'busy fm-spawn', got '$out'" + + out=$(drive_oc_plugin "$plugin" "$(oc_status ses_main busy)") || fail "busy drive failed: $out" + out=$(classify opencode "$id" "$state") + [ "$out" = "busy opencode-plugin" ] || fail "session busy must classify 'busy opencode-plugin', got '$out'" + + out=$(drive_oc_plugin "$plugin" \ + "$(oc_status ses_main busy)" \ + "$(oc_status ses_child busy)" \ + "$(oc_status ses_child idle)") || fail "child-session drive failed: $out" + out=$(classify opencode "$id" "$state") + [ "$out" = "busy opencode-plugin" ] || fail "a child session's idle must not clear the worker, got '$out'" + + out=$(drive_oc_plugin "$plugin" \ + "$(oc_status ses_main retry)" \ + "$(oc_status ses_main idle)") || fail "retry/idle drive failed: $out" + out=$(classify opencode "$id" "$state") + [ "$out" = "idle opencode-plugin" ] || fail "the latched session's idle must classify idle, got '$out'" + + rm -f "$state/$id.turn-ended" + out=$(drive_oc_plugin "$plugin" \ + "$(oc_status ses_main busy)" \ + "$(oc_idle ses_main)") || fail "session.idle drive failed: $out" + [ -f "$state/$id.turn-ended" ] || fail "session.idle no longer touches the notification marker" + out=$(classify opencode "$id" "$state") + [ "$out" = "idle opencode-plugin" ] || fail "session.idle for the latched session must classify idle, got '$out'" + + rm -f "$state/$id.turn-ended" + out=$(drive_oc_plugin "$plugin" \ + "$(oc_status ses2 busy)" \ + "$(oc_idle ses_other)") || fail "other-session idle drive failed: $out" + [ -f "$state/$id.turn-ended" ] || fail "the marker touch must stay a notification for every session.idle" + out=$(classify opencode "$id" "$state") + [ "$out" = "busy opencode-plugin" ] || fail "another session's idle must not clear the latched busy, got '$out'" + pass "opencode plugin classifies from session.status, scoped to the latched worker session" +} + +run_claude_hook() { # <settings.json> <hook-event> + local cmd + cmd=$(jq -r ".hooks[\"$2\"][0].hooks[0].command" "$1") + [ -n "$cmd" ] && [ "$cmd" != null ] || fail "no $2 hook command in $1" + sh -c "$cmd" +} + +test_claude_hooks_semantic_lifecycle() { + local rec id=busy-cl-1 out state settings + rec=$(make_spawn_case claude-lifecycle claude "$id") + read_case_record "$rec" + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$id" "$PROJ_DIR") + expect_code 0 $? "claude spawn should succeed: $out" + state="$HOME_DIR/state" + settings="$WT_DIR/.claude/settings.local.json" + assert_present "$settings" "claude spawn did not write hook settings" + jq -e . "$settings" >/dev/null || fail "claude hook settings are not valid JSON" + for ev in UserPromptSubmit Stop StopFailure SessionEnd; do + jq -e ".hooks[\"$ev\"]" "$settings" >/dev/null || fail "claude hook settings lack $ev" + done + + out=$(classify claude "$id" "$state") + [ "$out" = "busy fm-spawn" ] || fail "seed after spawn must be 'busy fm-spawn', got '$out'" + + rm -f "$state/$id.turn-ended" + run_claude_hook "$settings" Stop || fail "Stop hook command failed" + [ -f "$state/$id.turn-ended" ] || fail "Stop no longer touches the notification marker" + out=$(classify claude "$id" "$state") + [ "$out" = "idle claude-hook" ] || fail "Stop must classify 'idle claude-hook', got '$out'" + + run_claude_hook "$settings" UserPromptSubmit || fail "UserPromptSubmit hook command failed" + out=$(classify claude "$id" "$state") + [ "$out" = "busy claude-hook" ] || fail "UserPromptSubmit must classify 'busy claude-hook', got '$out'" + + run_claude_hook "$settings" StopFailure || fail "StopFailure hook command failed" + out=$(classify claude "$id" "$state") + [ "$out" = "idle claude-hook" ] || fail "StopFailure must classify idle so an API error cannot strand busy, got '$out'" + + run_claude_hook "$settings" UserPromptSubmit + run_claude_hook "$settings" SessionEnd || fail "SessionEnd hook command failed" + out=$(classify claude "$id" "$state") + [ "$out" = "idle claude-hook" ] || fail "SessionEnd must classify idle, got '$out'" + pass "claude hooks open on UserPromptSubmit and close on Stop, StopFailure, and SessionEnd" +} + +test_claude_hooks_stale_incarnation_harmless() { + local rec id=busy-cl-2 out state settings + rec=$(make_spawn_case claude-stale claude "$id") + read_case_record "$rec" + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$id" "$PROJ_DIR") + expect_code 0 $? "claude spawn should succeed: $out" + state="$HOME_DIR/state" + settings="$WT_DIR/.claude/settings.local.json" + "$ROOT/bin/fm-busy-event.sh" arm "$state" "$id" >/dev/null + run_claude_hook "$settings" UserPromptSubmit \ + || fail "a stale-gen hook must still exit 0 so Claude's lifecycle is never broken" + out=$(classify claude "$id" "$state") + [ "$out" = "busy fm-spawn" ] || fail "a stale-gen hook event must not change state, got '$out'" + pass "claude hook events from a superseded incarnation are rejected without breaking the hook" +} + +test_codex_unverified_until_a_semantic_source_exists() { + local rec id=busy-cx-1 out state + rec=$(make_spawn_case codex-unverified codex "$id") + read_case_record "$rec" + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$id" "$PROJ_DIR") + expect_code 0 $? "codex spawn should succeed: $out" + state="$HOME_DIR/state" + assert_absent "$state/$id.busy-gen" "codex must not arm a busy contract with no verified semantic source" + assert_absent "$WT_DIR/.codex/hooks.json" "codex must not install unverified busy hooks" + assert_contains "$out" 'spawned '"$id"' harness=codex' "codex spawn did not complete normally" + out=$(classify codex "$id" "$state") + [ "$out" = "unknown codex-unverified" ] || fail "codex must classify 'unknown codex-unverified', got '$out'" + out=$(fm_busy_classify tmux fake:w codex "$id" "$state" '• Working (6s • esc to interrupt)') + [ "$out" = "unknown codex-unverified" ] || fail "codex must not fall back to footer text, got '$out'" + pass "codex classifies unknown until a semantic source is verified, never idle or footer-matched" +} + +test_kimi_and_grok_install_no_unverified_wiring() { + local state out + state="$TMP_ROOT/gates/state" + mkdir -p "$state" + [ -z "$(fm_busy_sources_for_harness kimi)" ] \ + || fail "standalone kimi must trust no semantic source until it is verified" + [ -z "$(fm_busy_sources_for_harness grok)" ] \ + || fail "grok must trust no semantic source while its structured path is unverified" + out=$(fm_busy_classify tmux fake:w kimi gate-k "$state" '🌒 · thinking') + [ "$out" = "unknown kimi-unverified" ] || fail "kimi must classify unknown, not from its spinner, got '$out'" + out=$(fm_busy_classify tmux fake:w grok gate-g "$state" 'Ctrl+c:cancel') + [ "$out" = "busy grok-regex" ] || fail "grok must classify through its isolated fallback, got '$out'" + pass "kimi and grok install no unverified semantic wiring and classify through their own gates" +} + +test_pi_extension_semantic_lifecycle +test_pi_extension_serializes_settle_before_next_start +test_pi_extension_stale_incarnation_rejected +test_kimi_and_grok_install_no_unverified_wiring +test_opencode_plugin_semantic_lifecycle +test_claude_hooks_semantic_lifecycle +test_claude_hooks_stale_incarnation_harmless +test_codex_unverified_until_a_semantic_source_exists + +echo "all fm-busy-adapter-wiring tests passed" diff --git a/tests/fm-busy-state.test.sh b/tests/fm-busy-state.test.sh new file mode 100755 index 00000000000..a6777a6b932 --- /dev/null +++ b/tests/fm-busy-state.test.sh @@ -0,0 +1,380 @@ +#!/usr/bin/env bash +# Behavior tests for the semantic busy-state contract (bin/fm-busy-lib.sh and +# its only writer bin/fm-busy-event.sh). +# +# Covers the captain-approved redesign invariants: busy/idle/unknown/dead with +# explicit source attribution; missing, malformed, stale (gen-mismatch), and +# untrusted (source-mismatch) semantic data classify unknown - never idle; +# adapter isolation (one adapter's writer or Grok's regex can never classify +# another adapter); endpoint death is the only process-level override and +# yields dead, never busy; converted adapters never classify from rendered +# footer text. All hermetic over temp dirs; no real agent session is invoked. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +# shellcheck source=/dev/null +. "$ROOT/bin/fm-busy-lib.sh" + +TMP_ROOT=$(fm_test_tmproot fm-busy-state) +EV="$ROOT/bin/fm-busy-event.sh" + +new_state_dir() { # <name> + local d="$TMP_ROOT/$1/state" + mkdir -p "$d" + printf '%s' "$d" +} + +# --- writer: arm and apply --------------------------------------------------- + +test_arm_seeds_busy_spawn() { + local state gen out + state=$(new_state_dir arm-seed) + gen=$("$EV" arm "$state" t1) || fail "arm failed" + [ -f "$state/t1.busy-gen" ] || fail "arm did not write the gen sidecar" + [ "$(cat "$state/t1.busy-gen")" = "$gen" ] || fail "sidecar gen does not match printed gen" + out=$(fm_busy_classify tmux w1 claude t1 "$state") + [ "$out" = "busy fm-spawn" ] || fail "seed should classify 'busy fm-spawn', got '$out'" + pass "arm mints a gen sidecar and seeds busy fm-spawn at seq=1" +} + +test_apply_advances_seq_and_source() { + local state gen out seq + state=$(new_state_dir apply-seq) + gen=$("$EV" arm "$state" t1) + "$EV" apply "$state" t1 idle --gen "$gen" --source claude-hook --event stop \ + || fail "apply idle failed" + out=$(fm_busy_classify tmux w1 claude t1 "$state") + [ "$out" = "idle claude-hook" ] || fail "expected 'idle claude-hook', got '$out'" + "$EV" apply "$state" t1 busy --gen "$gen" --source claude-hook --event user-prompt-submit \ + || fail "apply busy failed" + out=$(fm_busy_classify tmux w1 claude t1 "$state") + [ "$out" = "busy claude-hook" ] || fail "expected 'busy claude-hook', got '$out'" + seq=$(fm_busy_record_read "$state" t1 | awk '{print $4}') + [ "$seq" = 3 ] || fail "expected seq 3 after seed + two applies, got '$seq'" + pass "apply advances seq under the armed gen and attributes the writing source" +} + +test_apply_current_gen_reset() { + local state out + state=$(new_state_dir apply-current) + "$EV" arm "$state" t1 >/dev/null + "$EV" apply "$state" t1 idle --current-gen --source fm-interrupt --event interrupt \ + || fail "apply --current-gen failed" + out=$(fm_busy_classify tmux w1 pi t1 "$state") + [ "$out" = "idle fm-interrupt" ] || fail "expected 'idle fm-interrupt', got '$out'" + "$EV" apply "$state" t1 unknown --current-gen --source fm-recovery --event relaunch \ + || fail "apply unknown failed" + out=$(fm_busy_classify tmux w1 pi t1 "$state") + [ "$out" = "unknown fm-recovery" ] || fail "expected 'unknown fm-recovery', got '$out'" + pass "firstmate-owned interrupt and recovery events bind to the current gen" +} + +test_apply_unarmed_refused() { + local state + state=$(new_state_dir apply-unarmed) + if "$EV" apply "$state" t1 busy --gen g1.2.3 --source claude-hook --event x 2>/dev/null; then + fail "apply against an unarmed task must be refused" + fi + [ ! -f "$state/t1.busy-state" ] || fail "refused apply must not write a record" + pass "apply is refused for a task whose busy contract was never armed" +} + +test_retire_serializes_and_rejects_stale_gen() { + local state old_gen new_gen out retire_pid i=0 + state=$(new_state_dir retire) + old_gen=$("$EV" arm "$state" t1) + mkdir "$state/t1.busy-state.lock" + "$EV" retire "$state" t1 --gen "$old_gen" >/dev/null 2>&1 & + retire_pid=$! + while [ "$i" -lt 20 ] && ! kill -0 "$retire_pid" 2>/dev/null; do + i=$((i + 1)) + done + [ -e "$state/t1.busy-state" ] || fail "retire bypassed the writer lock" + rmdir "$state/t1.busy-state.lock" + wait "$retire_pid" || fail "retire failed after acquiring the writer lock" + [ ! -e "$state/t1.busy-state" ] || fail "retire left the record behind" + [ ! -e "$state/t1.busy-gen" ] || fail "retire left the gen sidecar behind" + + new_gen=$("$EV" arm "$state" t1) + if "$EV" retire "$state" t1 --gen "$old_gen" 2>/dev/null; then + fail "retire accepted a superseded incarnation" + fi + out=$(fm_busy_classify tmux w1 claude t1 "$state") + [ "$out" = "busy fm-spawn" ] || fail "stale retirement changed the new incarnation, got '$out'" + [ "$(cat "$state/t1.busy-gen")" = "$new_gen" ] || fail "stale retirement changed the new gen" + pass "retire waits for the writer lock and cannot remove a new incarnation" +} + +test_retire_missing_sidecar_is_idempotent() { + local state gen + state=$(new_state_dir retire-missing) + gen=$("$EV" arm "$state" t1) + rm -f "$state/t1.busy-gen" + + "$EV" retire "$state" t1 --gen "$gen" || fail "exact-gen retire rejected a missing sidecar" + [ ! -e "$state/t1.busy-state" ] || fail "retire left an orphan record behind" + "$EV" retire "$state" t1 --gen "$gen" || fail "repeated exact-gen retire was not idempotent" + "$EV" retire "$state" t1 --current-gen || fail "current-gen retire was not idempotent" + + printf 'malformed gen\n' > "$state/t1.busy-gen" + printf 'orphan\n' > "$state/t1.busy-state" + if "$EV" retire "$state" t1 --gen "$gen" 2>/dev/null; then + fail "retire accepted a malformed existing sidecar" + fi + [ -e "$state/t1.busy-state" ] || fail "retire removed the record for a malformed existing sidecar" + pass "retire treats only an absent sidecar as already retired" +} + +# --- stale event rejection ---------------------------------------------------- + +test_stale_gen_event_rejected() { + local state old_gen new_gen out + state=$(new_state_dir stale-event) + old_gen=$("$EV" arm "$state" t1) + new_gen=$("$EV" arm "$state" t1) + [ "$old_gen" != "$new_gen" ] || fail "re-arm must mint a fresh gen" + if "$EV" apply "$state" t1 idle --gen "$old_gen" --source claude-hook --event stop 2>/dev/null; then + fail "an event carrying a stale gen must be rejected" + fi + out=$(fm_busy_classify tmux w1 claude t1 "$state") + [ "$out" = "busy fm-spawn" ] || fail "stale event must not change the record, got '$out'" + pass "a late event from a previous incarnation is rejected, record unchanged" +} + +test_stale_gen_record_unknown() { + local state gen out + state=$(new_state_dir stale-record) + gen=$("$EV" arm "$state" t1) + # Simulate a record left behind by a superseded incarnation. + printf 'g-superseded.1.1\n' > "$state/t1.busy-gen.new" + mv "$state/t1.busy-gen.new" "$state/t1.busy-gen" + out=$(fm_busy_classify tmux w1 claude t1 "$state") + [ "$out" = "unknown gen-mismatch" ] || fail "stale record must classify 'unknown gen-mismatch', got '$out'" + pass "a record from a stale incarnation classifies unknown, never idle" +} + +# --- missing and malformed semantic data -------------------------------------- + +test_missing_record_unknown_not_idle() { + local state out h + state=$(new_state_dir missing) + for h in claude opencode pi pi-signed; do + out=$(fm_busy_classify tmux w1 "$h" t1 "$state") + [ "$out" = "unknown missing" ] || fail "$h with no record must be 'unknown missing', got '$out'" + done + out=$(fm_busy_classify tmux w1 codex t1 "$state") + [ "$out" = "unknown codex-unverified" ] || fail "codex with no verified source must be 'unknown codex-unverified', got '$out'" + pass "a converted adapter with no record classifies unknown, never idle" +} + +test_malformed_record_unknown() { + local state gen out + state=$(new_state_dir malformed) + gen=$("$EV" arm "$state" t1) + for bad in \ + 'garbage' \ + "v0 gen=$gen seq=1 state=busy source=claude-hook event=x ts=1" \ + "v1 gen=$gen seq=NaN state=busy source=claude-hook event=x ts=1" \ + "v1 gen=$gen seq=1 state=frobbing source=claude-hook event=x ts=1" \ + "v1 gen=$gen seq=1 state=busy source=bad source event=x ts=1" \ + "v1 gen=$gen seq=1 state=busy source=claude-hook event=x ts=1 rogue=1"; do + printf '%s\n' "$bad" > "$state/t1.busy-state" + out=$(fm_busy_classify tmux w1 claude t1 "$state") + [ "$out" = "unknown malformed" ] || fail "malformed record '$bad' must be 'unknown malformed', got '$out'" + done + printf 'v1 gen=%s seq=1 state=busy source=claude-hook event=x ts=1\nsecond line\n' "$gen" > "$state/t1.busy-state" + out=$(fm_busy_classify tmux w1 claude t1 "$state") + [ "$out" = "unknown malformed" ] || fail "multi-line record must be 'unknown malformed', got '$out'" + pass "malformed records classify unknown malformed, never busy or idle" +} + +test_record_without_sidecar_unknown() { + local state out + state=$(new_state_dir orphan-record) + printf 'v1 gen=g1.1.1 seq=1 state=busy source=claude-hook event=x ts=1\n' > "$state/t1.busy-state" + out=$(fm_busy_classify tmux w1 claude t1 "$state") + [ "$out" = "unknown malformed" ] || fail "record without an armed gen must be unknown, got '$out'" + pass "a record with no armed gen sidecar classifies unknown" +} + +# --- adapter isolation --------------------------------------------------------- + +test_source_mismatch_cross_adapter() { + local state gen out + state=$(new_state_dir cross-adapter) + gen=$("$EV" arm "$state" t1) + "$EV" apply "$state" t1 busy --gen "$gen" --source pi-ext --event agent-start + out=$(fm_busy_classify tmux w1 claude t1 "$state") + [ "$out" = "unknown source-mismatch" ] || fail "pi-ext record on a claude task must be untrusted, got '$out'" + out=$(fm_busy_classify tmux w1 pi t1 "$state") + [ "$out" = "busy pi-ext" ] || fail "pi-ext record on a pi task must classify, got '$out'" + out=$(fm_busy_classify tmux w1 grok t1 "$state") + [ "$out" = "unknown source-mismatch" ] || fail "grok trusts no semantic source, got '$out'" + pass "a record is trusted only by the adapter whose source wrote it" +} + +test_converted_adapters_ignore_footer_text() { + local state out h + state=$(new_state_dir no-footer) + local tail='• Working (6s • esc to interrupt) + ■■■■⬝⬝⬝⬝ esc interrupt +Working... +Ctrl+c:cancel' + for h in claude opencode pi pi-signed; do + out=$(fm_busy_classify tmux w1 "$h" t1 "$state" "$tail") + [ "$out" = "unknown missing" ] || fail "$h must never classify from footer text, got '$out'" + done + out=$(fm_busy_classify tmux w1 codex t1 "$state" "$tail") + [ "$out" = "unknown codex-unverified" ] || fail "codex must never classify from footer text, got '$out'" + pass "converted adapters never classify busy from rendered footer text" +} + +test_grok_regex_isolated() { + local state out + state=$(new_state_dir grok-arm) + out=$(fm_busy_classify tmux w1 grok t1 "$state" 'thinking hard +Ctrl+c:cancel') + [ "$out" = "busy grok-regex" ] || fail "grok busy tail must classify 'busy grok-regex', got '$out'" + out=$(fm_busy_classify tmux w1 grok t1 "$state" 'done. +> ') + [ "$out" = "idle grok-regex" ] || fail "grok idle tail must classify 'idle grok-regex', got '$out'" + # Another adapter's footer never makes grok busy either. + out=$(fm_busy_classify tmux w1 grok t1 "$state" '• Working (6s • esc to interrupt)') + [ "$out" = "idle grok-regex" ] || fail "a claude footer must not classify grok busy, got '$out'" + pass "the grok fallback is regex-scoped to grok and classifies only grok tasks" +} + +# --- kimi verification gate ----------------------------------------------------- + +test_codex_unverified_gate() { + local state gen out + state=$(new_state_dir codex-gate) + gen=$("$EV" arm "$state" t1) + "$EV" apply "$state" t1 busy --gen "$gen" --source codex-hook --event user-prompt-submit + out=$(fm_busy_classify tmux w1 codex t1 "$state") + [ "$out" = "unknown codex-unverified" ] || fail "unverified codex must classify unknown, got '$out'" + [ -z "$(fm_busy_sources_for_harness codex)" ] \ + || fail "codex must trust no semantic source until one is verified" + pass "codex classifies unknown until a semantic source passes its verification gate" +} + +test_kimi_unverified_gate() { + local state gen out + state=$(new_state_dir kimi-gate) + gen=$("$EV" arm "$state" t1) + "$EV" apply "$state" t1 busy --gen "$gen" --source kimi-hook --event user-prompt-submit + out=$(fm_busy_classify tmux w1 kimi t1 "$state") + [ "$out" = "unknown kimi-unverified" ] || fail "unverified kimi must classify unknown, got '$out'" + out=$(fm_busy_classify tmux w1 kimi t1 "$state" '🌒 · thinking') + [ "$out" = "unknown kimi-unverified" ] || fail "kimi must not classify from footer text, got '$out'" + pass "standalone kimi classifies unknown until the live verification gate opens" +} + +# --- endpoint death and native fallbacks ---------------------------------------- + +test_dead_endpoint_overrides() { + local state gen out + state=$(new_state_dir dead) + gen=$("$EV" arm "$state" t1) + # shellcheck disable=SC2329 # invoked indirectly through fm_busy_classify_live + fm_backend_target_exists() { return 1; } + out=$(fm_busy_classify_live tmux w1 claude t1 "$state") + [ "$out" = "dead endpoint-gone" ] || fail "gone endpoint must classify dead, got '$out'" + # shellcheck disable=SC2329 # invoked indirectly through fm_busy_classify_live + fm_backend_target_exists() { return 0; } + out=$(fm_busy_classify_live tmux w1 claude t1 "$state") + [ "$out" = "busy fm-spawn" ] || fail "live endpoint must fall through to the record, got '$out'" + out=$(fm_busy_classify_live tmux '' claude t1 "$state") + [ "$out" = "unknown no-target" ] || fail "empty target must classify unknown, got '$out'" + unset -f fm_backend_target_exists + pass "endpoint death is the only process-level override and yields dead, never busy" +} + +test_herdr_native_busy_only() { + local state out + state=$(new_state_dir herdr-native) + # shellcheck disable=SC2329 # invoked indirectly through fm_busy_classify + fm_backend_busy_state() { printf '%s' "$FAKE_NATIVE"; } + FAKE_NATIVE=busy + out=$(fm_busy_classify herdr s:p claude t1 "$state") + [ "$out" = "busy herdr-native" ] || fail "native busy with no record must classify busy, got '$out'" + FAKE_NATIVE=idle + out=$(fm_busy_classify herdr s:p claude t1 "$state") + [ "$out" = "unknown missing" ] || fail "native idle must NOT classify idle, got '$out'" + # A valid record outranks the native verdict. + local gen + gen=$("$EV" arm "$state" t1) + "$EV" apply "$state" t1 idle --gen "$gen" --source claude-hook --event stop + FAKE_NATIVE=busy + out=$(fm_busy_classify herdr s:p claude t1 "$state") + [ "$out" = "idle claude-hook" ] || fail "the adapter record must outrank herdr's native verdict, got '$out'" + unset -f fm_backend_busy_state + pass "herdr's native verdict is trusted for busy only, and records outrank it" +} + +# The record parser runs inside sourcing callers (the watcher, the daemon, the +# crew-state reader), so it must not disturb their shell: no clobbered +# positional parameters and no changed glob setting. +test_record_read_leaves_caller_shell_intact() { + local state out + state=$(new_state_dir parser-isolation) + "$EV" arm "$state" t1 >/dev/null + out=$(bash -c ' + set -f + . "$1/bin/fm-busy-lib.sh" + set -- keepme second + fm_busy_record_read "$2" t1 >/dev/null + printf "%s|%s|%s" "$1" "$#" "$-" + ' _ "$ROOT" "$state") + case "$out" in + keepme\|2\|*f*) : ;; + *) fail "record parsing disturbed the caller's shell: $out" ;; + esac + # A glob-shaped field must survive parsing literally rather than expanding. + printf 'v1 gen=%s seq=1 state=busy source=* event=x ts=1\n' "$(cat "$state/t1.busy-gen")" \ + > "$state/t1.busy-state" + out=$(fm_busy_classify tmux w1 claude t1 "$state") + [ "$out" = "unknown malformed" ] || fail "a glob-shaped source must be rejected, not expanded, got '$out'" + pass "record parsing never clobbers the caller's positional parameters, glob setting, or fields" +} + +test_boolean_view_never_promotes_unknown() { + local state gen + state=$(new_state_dir boolean) + gen=$("$EV" arm "$state" t1) + fm_busy_is_busy tmux w1 claude t1 "$state" || fail "busy record must read busy" + "$EV" apply "$state" t1 idle --gen "$gen" --source claude-hook --event stop + if fm_busy_is_busy tmux w1 claude t1 "$state"; then + fail "idle record must not read busy" + fi + printf 'garbage\n' > "$state/t1.busy-state" + if fm_busy_is_busy tmux w1 claude t1 "$state"; then + fail "malformed record must not read busy" + fi + pass "the boolean view reports busy only on an exact busy verdict" +} + +test_arm_seeds_busy_spawn +test_apply_advances_seq_and_source +test_apply_current_gen_reset +test_apply_unarmed_refused +test_retire_serializes_and_rejects_stale_gen +test_retire_missing_sidecar_is_idempotent +test_stale_gen_event_rejected +test_stale_gen_record_unknown +test_missing_record_unknown_not_idle +test_malformed_record_unknown +test_record_without_sidecar_unknown +test_source_mismatch_cross_adapter +test_converted_adapters_ignore_footer_text +test_grok_regex_isolated +test_codex_unverified_gate +test_kimi_unverified_gate +test_dead_endpoint_overrides +test_herdr_native_busy_only +test_record_read_leaves_caller_shell_intact +test_boolean_view_never_promotes_unknown + +echo "all fm-busy-state tests passed" diff --git a/tests/fm-calm-pi-extension.test.sh b/tests/fm-calm-pi-extension.test.sh index a8e09be1456..f1109e787ec 100755 --- a/tests/fm-calm-pi-extension.test.sh +++ b/tests/fm-calm-pi-extension.test.sh @@ -10,6 +10,7 @@ EXT="$ROOT/.pi/extensions/fm-calm.ts" ASSISTANT_LAYOUT="$ROOT/.pi/extensions/lib/fm-calm-assistant-layout.ts" OPERATIONAL_USER_LAYOUT="$ROOT/.pi/extensions/lib/fm-calm-operational-user-layout.ts" VISIBILITY="$ROOT/.pi/extensions/lib/fm-calm-visibility.ts" +WORKING_SHIP="$ROOT/.pi/extensions/lib/fm-calm-working-ship.ts" WATCH_EXT="$ROOT/.pi/extensions/fm-primary-pi-watch.ts" OPERATIONAL_INPUT="$ROOT/bin/fm-operational-input.sh" PI_OPERATIONAL_INPUT="$ROOT/.pi/extensions/lib/fm-operational-input.ts" @@ -38,7 +39,10 @@ trap cleanup EXIT wait_for_text() { local file=$1 text=$2 i=0 while [ "$i" -lt 120 ]; do - tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" -S - >"$file" 2>/dev/null || true + # Include recent scrollback: expanding a long restored transcript can move + # the asserted tool output above the current viewport while the footer and + # editor remain visible. + tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" -S -600 >"$file" 2>/dev/null || true grep -Fq "$text" "$file" 2>/dev/null && return 0 sleep 0.05 i=$((i + 1)) @@ -90,6 +94,7 @@ test_home_resolution() { cp "$ASSISTANT_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-assistant-layout.ts" cp "$OPERATIONAL_USER_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-operational-user-layout.ts" cp "$VISIBILITY" "$fixture/project/.pi/extensions/lib/fm-calm-visibility.ts" + cp "$WORKING_SHIP" "$fixture/project/.pi/extensions/lib/fm-calm-working-ship.ts" cp "$PI_OPERATIONAL_INPUT" "$fixture/project/.pi/extensions/lib/fm-operational-input.ts" ln -s "$PI_PACKAGE_DIR" "$fixture/project/node_modules/@earendil-works/pi-coding-agent" ln -s "$PI_PACKAGE_DIR/node_modules/@earendil-works/pi-tui" "$fixture/project/node_modules/@earendil-works/pi-tui" @@ -207,6 +212,7 @@ test_pi_compat_degraded_adapter() { cp "$ASSISTANT_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-assistant-layout.ts" cp "$OPERATIONAL_USER_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-operational-user-layout.ts" cp "$VISIBILITY" "$fixture/project/.pi/extensions/lib/fm-calm-visibility.ts" + cp "$WORKING_SHIP" "$fixture/project/.pi/extensions/lib/fm-calm-working-ship.ts" cp "$PI_OPERATIONAL_INPUT" "$fixture/project/.pi/extensions/lib/fm-operational-input.ts" ln -s "$PI_PACKAGE_DIR" "$fixture/project/node_modules/@earendil-works/pi-coding-agent" ln -s "$PI_PACKAGE_DIR/node_modules/@earendil-works/pi-tui" "$fixture/project/node_modules/@earendil-works/pi-tui" @@ -305,6 +311,7 @@ test_pi_compat_missing_adapter_exports() { cp "$ASSISTANT_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-assistant-layout.ts" cp "$OPERATIONAL_USER_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-operational-user-layout.ts" cp "$VISIBILITY" "$fixture/project/.pi/extensions/lib/fm-calm-visibility.ts" + cp "$WORKING_SHIP" "$fixture/project/.pi/extensions/lib/fm-calm-working-ship.ts" cp "$PI_OPERATIONAL_INPUT" "$fixture/project/.pi/extensions/lib/fm-operational-input.ts" printf '%s\n' '{"type":"module"}' >"$fixture/project/package.json" printf '%s\n' \ @@ -362,6 +369,7 @@ test_rendering_and_session_lifecycle() { cp "$ASSISTANT_LAYOUT" "$fixture/lib/fm-calm-assistant-layout.ts" cp "$OPERATIONAL_USER_LAYOUT" "$fixture/lib/fm-calm-operational-user-layout.ts" cp "$VISIBILITY" "$fixture/lib/fm-calm-visibility.ts" + cp "$WORKING_SHIP" "$fixture/lib/fm-calm-working-ship.ts" cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$fixture/lib/fm-operational-input.ts" cp "$WATCH_EXT" "$fixture/fm-primary-pi-watch.ts" ln -s "$PI_PACKAGE_DIR" "$fixture/node_modules/@earendil-works/pi-coding-agent" @@ -995,7 +1003,7 @@ JS status=$? [ "$status" -eq 0 ] || fail "Pi calm renderer and lifecycle contract failed: $out" [ -z "$out" ] || fail "Pi calm renderer test printed output: $out" - pass "Pi calm centralizes transcript visibility, preserves execution/export data, keeps native working visible, and persists its choice across session starts" + pass "Pi calm centralizes transcript visibility, preserves execution/export data, keeps Pi's stock working row visible while no run is active, and persists its choice across session starts" } test_operational_followup_turn_e2e() { @@ -1017,6 +1025,7 @@ test_operational_followup_turn_e2e() { cp "$ASSISTANT_LAYOUT" "$project/.pi/extensions/lib/fm-calm-assistant-layout.ts" cp "$OPERATIONAL_USER_LAYOUT" "$project/.pi/extensions/lib/fm-calm-operational-user-layout.ts" cp "$VISIBILITY" "$project/.pi/extensions/lib/fm-calm-visibility.ts" + cp "$WORKING_SHIP" "$project/.pi/extensions/lib/fm-calm-working-ship.ts" cp "$PI_OPERATIONAL_INPUT" "$project/.pi/extensions/lib/fm-operational-input.ts" printf '%s\n' '{"followUpMode":"all"}' >"$config/settings.json" @@ -1379,6 +1388,7 @@ test_hidden_block_geometry_e2e() { cp "$ASSISTANT_LAYOUT" "$project/.pi/extensions/lib/fm-calm-assistant-layout.ts" cp "$OPERATIONAL_USER_LAYOUT" "$project/.pi/extensions/lib/fm-calm-operational-user-layout.ts" cp "$VISIBILITY" "$project/.pi/extensions/lib/fm-calm-visibility.ts" + cp "$WORKING_SHIP" "$project/.pi/extensions/lib/fm-calm-working-ship.ts" cp "$PI_OPERATIONAL_INPUT" "$project/.pi/extensions/lib/fm-operational-input.ts" printf '%s\n' on >"$home/config/calm" printf '%s\n' '{"hideThinkingBlock":true,"terminal":{"clearOnShrink":false}}' >"$config/settings.json" @@ -1591,8 +1601,871 @@ TS pass "Pi Calm native /skill:ahoy geometry keeps every collapsed thinking and tool block at zero height while preserving expansion, history, restart, and Calm-off rendering" } +test_working_ship_geometry_and_lifecycle() { + local fixture out status version + if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then + echo "skip: node or npm not found for Pi Calm working-ship test" + return 0 + fi + if [ ! -f "$PI_PACKAGE_DIR/package.json" ]; then + echo "skip: installed @earendil-works/pi-coding-agent package not found" + return 0 + fi + version=$(node -p "require('$PI_PACKAGE_DIR/package.json').version") + record_pi_version_evidence "$version" "Pi Calm working-ship assumptions" + + fixture="$TMP_ROOT/working-ship" + mkdir -p "$fixture/home" "$fixture/lib" "$fixture/node_modules/@earendil-works" + cp "$EXT" "$fixture/fm-calm.ts" + cp "$ASSISTANT_LAYOUT" "$fixture/lib/fm-calm-assistant-layout.ts" + cp "$OPERATIONAL_USER_LAYOUT" "$fixture/lib/fm-calm-operational-user-layout.ts" + cp "$VISIBILITY" "$fixture/lib/fm-calm-visibility.ts" + cp "$WORKING_SHIP" "$fixture/lib/fm-calm-working-ship.ts" + cp "$PI_OPERATIONAL_INPUT" "$fixture/lib/fm-operational-input.ts" + ln -s "$PI_PACKAGE_DIR" "$fixture/node_modules/@earendil-works/pi-coding-agent" + ln -s "$PI_PACKAGE_DIR/node_modules/@earendil-works/pi-tui" "$fixture/node_modules/@earendil-works/pi-tui" + ln -s "$PI_PACKAGE_DIR/node_modules/typebox" "$fixture/node_modules/typebox" + printf '%s\n' '{"type":"module"}' >"$fixture/package.json" + + out=$(cd "$fixture" && EXT="$fixture/fm-calm.ts" FM_HOME="$fixture/home" PI_PACKAGE_DIR="$PI_PACKAGE_DIR" node --input-type=module 2>&1 <<'JS' +import { pathToFileURL } from "node:url"; + +const packageRoot = process.env.PI_PACKAGE_DIR; +const [{ initTheme, theme }, { visibleWidth, setCapabilities }] = await Promise.all([ + import(pathToFileURL(`${packageRoot}/dist/modes/interactive/theme/theme.js`).href), + import(pathToFileURL(`${packageRoot}/node_modules/@earendil-works/pi-tui/dist/index.js`).href), +]); +initTheme("dark"); +setCapabilities({ images: null, trueColor: true, hyperlinks: false }); + +const ship = await import( + `${pathToFileURL(`${process.cwd()}/lib/fm-calm-working-ship.ts`).href}?ship=${Date.now()}` +); +const { + CALM_WORKING_SHIP_WIDGET_KEY, + CALM_WORKING_SHIP_TICK_MS, + CALM_WORKING_SHIP_TICKS_PER_MOVE, + createCalmWorkingShipAnimation, + createCalmWorkingShipWidget, +} = ship; + +const ESC = "\u001b"; +const BLUE = `${ESC}[34m`; +const YELLOW = `${ESC}[33m`; +const RESET = `${ESC}[39m`; +const strip = (text) => text.replace(new RegExp(`${ESC}\\[[0-9;]*m`, "g"), ""); +const check = (condition, message) => { + if (!condition) throw new Error(message); +}; +const sailOf = (frame) => { + const row = strip(frame[0]); + if (row.includes("<|")) return "<|"; + if (row.includes("|>")) return "|>"; + return "none"; +}; + +// --- Calm cadence: the boat is materially slower than the water ------------------ +{ + // The pre-revision boat moved one column every 140ms. The revised boat must be + // plainly slower in real use while the water keeps rippling between its steps. + const msPerColumn = CALM_WORKING_SHIP_TICK_MS * CALM_WORKING_SHIP_TICKS_PER_MOVE; + check(msPerColumn >= 700, `boat cadence ${msPerColumn}ms per column is not materially slower`); + check( + CALM_WORKING_SHIP_TICKS_PER_MOVE >= 2, + "the water cadence is not independent of and faster than the boat cadence", + ); + check( + CALM_WORKING_SHIP_TICK_MS < msPerColumn, + "the water does not animate faster than the boat moves", + ); +} + +// --- Water phases loop independently while the boat stays put -------------------- +{ + const width = 40; + const animation = createCalmWorkingShipAnimation(); + animation.render(width); + const startPosition = animation.position(); + const waterRows = new Set(); + const phases = new Set(); + for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE - 1; step += 1) { + animation.tick(); + check( + animation.position() === startPosition, + `the boat moved on tick ${step + 1} instead of waiting for its own cadence`, + ); + waterRows.add(strip(animation.render(width)[1])); + phases.add(animation.waterPhase()); + } + check(waterRows.size > 1, "the water did not animate while the boat was stationary"); + check(phases.size > 1, "the water phase did not advance between boat movements"); + // The boat then moves on its own cadence tick. + animation.tick(); + check( + animation.position() !== startPosition, + "the boat never moved on its own cadence tick", + ); + // Water motion alone must not change the hull column. + const beforeHull = strip(animation.render(width)[1]).indexOf("\\__/"); + animation.tick(); + const afterHull = strip(animation.render(width)[1]).indexOf("\\__/"); + check(beforeHull === afterHull, "advancing only the water appeared to move the boat"); +} + +// --- Water phases are bounded, fixed-cell, and never change geometry ------------- +{ + const width = 30; + const animation = createCalmWorkingShipAnimation(); + const seenPhases = new Set(); + for (let step = 0; step < 64; step += 1) { + const frame = animation.render(width); + seenPhases.add(animation.waterPhase()); + check(frame.length === 2, `water phase ${animation.waterPhase()} changed the row count`); + check( + visibleWidth(frame[1]) === width, + `water phase ${animation.waterPhase()} changed the visible width`, + ); + animation.tick(); + } + check(seenPhases.size > 1 && seenPhases.size <= 8, `water phase set is not bounded: ${seenPhases.size}`); +} + +// --- Standard ANSI colors, with resets that prevent bleed ------------------------ +{ + const width = 24; + const animation = createCalmWorkingShipAnimation(); + for (let step = 0; step < 12; step += 1) { + const [sailRow, waterRow] = animation.render(width); + + // Standard codes only: no bright variants, no 256-color, no RGB. + for (const row of [sailRow, waterRow]) { + const codes = row.match(new RegExp(`${ESC}\\[[0-9;]*m`, "g")) ?? []; + for (const code of codes) { + check( + code === BLUE || code === YELLOW || code === RESET, + `non-standard ANSI escape ${JSON.stringify(code)} in ${JSON.stringify(row)}`, + ); + } + check(codes.length > 0, "a rendered row carried no color at all"); + // Every colored run is closed, so nothing bleeds into padding or later frames. + check( + codes.filter((c) => c !== RESET).length === codes.filter((c) => c === RESET).length, + `unbalanced color/reset pairs in ${JSON.stringify(row)}`, + ); + check(codes[codes.length - 1] === RESET, `row does not end color-reset: ${JSON.stringify(row)}`); + } + + // Sail-row padding must be plain spaces outside any color run. + const leading = sailRow.slice(0, sailRow.indexOf(ESC)); + check(/^ *$/.test(leading), `sail row padding was colored: ${JSON.stringify(leading)}`); + + // The complete boat is yellow; every water cell is blue. + for (const piece of [`${YELLOW}<|${RESET}`, `${YELLOW}|>${RESET}`]) { + if (sailRow.includes(piece.slice(0, -RESET.length))) { + check(sailRow.includes(piece), `sail was not a closed yellow run: ${JSON.stringify(sailRow)}`); + } + } + check( + waterRow.includes(`${YELLOW}\\__/${RESET}`), + `hull was not a closed yellow run: ${JSON.stringify(waterRow)}`, + ); + for (const run of waterRow.split(YELLOW)) { + const blueRuns = run.split(BLUE).slice(1); + for (const blueRun of blueRuns) { + const cells = blueRun.slice(0, blueRun.indexOf(RESET)); + check(cells.length > 0, "an empty blue run emitted a bare color escape"); + check( + /^[~-]+$/.test(cells), + `blue run contained a non-water cell: ${JSON.stringify(cells)}`, + ); + } + } + animation.tick(); + } +} + +// --- ANSI-stripped visible width is exact at every width and phase --------------- +for (let width = 1; width <= 120; width += 1) { + const animation = createCalmWorkingShipAnimation(); + animation.render(width); + for (let step = 0; step <= width + 8; step += 1) { + const frame = animation.render(width); + const expectedRows = width >= 4 ? 2 : 1; + check(frame.length === expectedRows, `width ${width} rendered ${frame.length} rows`); + for (const line of frame) { + check( + visibleWidth(line) <= width, + `width ${width} rendered a ${visibleWidth(line)}-cell line and would wrap`, + ); + check( + visibleWidth(line) === strip(line).length, + `width ${width} let ANSI bytes affect the measured geometry`, + ); + } + // The water row always fills the complete usable width. + const waterRow = frame[frame.length - 1]; + check( + visibleWidth(waterRow) === width, + `width ${width} water row was ${visibleWidth(waterRow)} cells instead of full width`, + ); + animation.tick(); + } +} + +// --- Directional sail and exact bounce, including tiny spans --------------------- +for (const width of [40, 16, 8, 6, 5, 4, 3, 2]) { + const animation = createCalmWorkingShipAnimation(); + animation.render(width); + const span = width >= 4 ? width - 4 : Math.max(0, width - 2); + const frames = []; + for (let step = 0; step < span * CALM_WORKING_SHIP_TICKS_PER_MOVE * 3 + 16; step += 1) { + const frame = animation.render(width); + frames.push({ position: animation.position(), sail: sailOf(frame) }); + animation.tick(); + } + for (const frame of frames) { + check( + frame.position >= 0 && frame.position <= span, + `width ${width} left the track at column ${frame.position}`, + ); + } + if (width >= 2) { + // Every frame must already show the heading it is about to travel, so no frame + // at or after a reversal shows the old sail. + for (let index = 1; index < frames.length; index += 1) { + const previous = frames[index - 1]; + const current = frames[index]; + if (current.position > previous.position) { + check( + previous.sail === "<|", + `width ${width} moved right showing ${previous.sail} at column ${previous.position}`, + ); + } + if (current.position < previous.position) { + check( + previous.sail === "|>", + `width ${width} moved left showing ${previous.sail} at column ${previous.position}`, + ); + } + } + } + if (span > 0) { + const sails = new Set(frames.map((frame) => frame.sail)); + check(sails.has("<|") && sails.has("|>"), `width ${width} never showed both headings`); + const positions = frames.map((frame) => frame.position); + check(Math.min(...positions) === 0, `width ${width} never reached the left edge`); + check(Math.max(...positions) === span, `width ${width} never reached the right edge`); + // Both reversals must be covered. + let rightToLeft = false; + let leftToRight = false; + for (let index = 1; index < frames.length; index += 1) { + if (frames[index - 1].sail === "<|" && frames[index].sail === "|>") rightToLeft = true; + if (frames[index - 1].sail === "|>" && frames[index].sail === "<|") leftToRight = true; + } + check(rightToLeft, `width ${width} never reversed from right to left`); + check(leftToRight, `width ${width} never reversed from left to right`); + } +} + +// --- Shrink and grow resize clamping ---------------------------------------------- +{ + const animation = createCalmWorkingShipAnimation(); + animation.render(80); + while (animation.position() < 76) animation.tick(); + check(animation.position() === 76, `boat did not reach the wide right edge: ${animation.position()}`); + + const shrunk = animation.render(20); + check(animation.position() === 16, `shrink did not clamp the track immediately: ${animation.position()}`); + check(visibleWidth(shrunk[1]) === 20, `shrunk water row was ${visibleWidth(shrunk[1])} cells instead of 20`); + check(visibleWidth(shrunk[0]) <= 20, "shrunk sail row would wrap"); + check(sailOf(shrunk) === "|>", "the boat did not turn around after being clamped to the right edge"); + + for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE; step += 1) animation.tick(); + const afterShrink = animation.render(20); + check(animation.position() < 16, "the boat stalled at the edge after a shrink"); + check(visibleWidth(afterShrink[1]) === 20, "motion after a shrink broke the water row width"); + + const grown = animation.render(60); + check(visibleWidth(grown[1]) === 60, `grown water row was ${visibleWidth(grown[1])} cells`); + for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE; step += 1) animation.tick(); + const afterGrow = animation.render(60); + check( + animation.position() >= 0 && animation.position() <= 56, + `motion left the grown track: ${animation.position()}`, + ); + check(visibleWidth(afterGrow[1]) === 60, "motion after a grow broke the water row width"); +} + +// --- Deterministic narrow fallbacks ------------------------------------------------ +{ + const animation = createCalmWorkingShipAnimation(); + check(JSON.stringify(animation.render(0)) === "[]", "zero width rendered a line"); + for (const width of [1, 2, 3]) { + const fallback = createCalmWorkingShipAnimation(); + for (let step = 0; step < 12; step += 1) { + const frame = fallback.render(width); + check(frame.length === 1, `width ${width} fallback was not a single row`); + check(visibleWidth(frame[0]) === width, `width ${width} fallback was not exactly ${width} cells`); + const bare = strip(frame[0]); + if (width === 1) { + check(/^[~-]$/.test(bare), `width 1 fallback was not a single water cell: ${bare}`); + } else { + check( + bare.includes("<|") || bare.includes("|>"), + `width ${width} fallback lost the sail: ${bare}`, + ); + } + fallback.tick(); + } + } +} + +// --- Freeze/resume continuity on one shared animation instance --------------------- +// Hiding the working presentation must freeze column and direction. The next widget +// bound to the same animation resumes exactly there; hidden wall time must not jump. +{ + const animation = createCalmWorkingShipAnimation(); + const tui = { requestRender() {} }; + animation.render(40); + for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE * 7; step += 1) animation.tick(); + animation.render(40); + const frozenColumn = animation.position(); + const frozenDirection = animation.direction(); + const frozenPhase = animation.waterPhase(); + check(frozenColumn > 0, `continuity setup never left the left edge: ${frozenColumn}`); + + const first = createCalmWorkingShipWidget(tui, animation); + check(first.render(40) && animation.position() === frozenColumn, "binding a widget moved the frozen boat"); + first.dispose(); + // Dispose freezes; further wall time without ticks must not change logical state. + check(animation.position() === frozenColumn, "dispose changed the frozen column"); + check(animation.direction() === frozenDirection, "dispose changed the frozen direction"); + check(animation.waterPhase() === frozenPhase, "dispose changed the frozen water phase"); + + const resumed = createCalmWorkingShipWidget(tui, animation); + const firstFrame = resumed.render(40); + check( + animation.position() === frozenColumn && animation.direction() === frozenDirection, + `resume first frame left frozen state: col=${animation.position()} dir=${animation.direction()}`, + ); + check(sailOf(firstFrame) === (frozenDirection >= 0 ? "<|" : "|>"), "resume first frame lost sail heading"); + check(animation.waterPhase() === frozenPhase, "resume advanced water phase without a tick"); + // After resume, motion continues from the frozen state rather than restarting. + for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE; step += 1) animation.tick(); + check( + animation.position() === frozenColumn + frozenDirection, + `post-resume motion did not continue from frozen column: ${animation.position()}`, + ); + resumed.dispose(); + + // Hidden resize clamps without needing a live widget, and preserves a valid heading. + animation.render(80); + while (animation.position() < 76) animation.tick(); + animation.render(80); + check(animation.position() === 76 && animation.direction() === -1, "endpoint setup failed before hidden resize"); + const beforeHiddenResize = { column: animation.position(), direction: animation.direction(), phase: animation.waterPhase() }; + animation.clampToWidth(20); + check(animation.position() === 16, `hidden shrink did not clamp: ${animation.position()}`); + check(animation.direction() === -1, "hidden shrink lost the leftward heading at the right edge"); + check(animation.waterPhase() === beforeHiddenResize.phase, "hidden clamp advanced water phase"); + // Growing while hidden must not invent motion either. + animation.clampToWidth(60); + check(animation.position() === 16, `hidden grow moved the boat: ${animation.position()}`); + check(animation.direction() === -1, "hidden grow changed direction without cause"); + + // Endpoint and bounce continuity: pause immediately before, at, and after each edge. + for (const scenario of [ + { label: "before-right", setup(anim) { + anim.reset(); anim.render(12); + while (anim.position() < 7) anim.tick(); + check(anim.position() === 7 && anim.direction() === 1, "before-right setup"); + }}, + { label: "at-right", setup(anim) { + anim.reset(); anim.render(12); + while (anim.position() < 8) anim.tick(); + check(anim.position() === 8 && anim.direction() === -1, "at-right setup"); + }}, + { label: "after-right", setup(anim) { + anim.reset(); anim.render(12); + while (anim.position() < 8) anim.tick(); + for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE; step += 1) anim.tick(); + check(anim.position() === 7 && anim.direction() === -1, "after-right setup"); + }}, + { label: "before-left", setup(anim) { + anim.reset(); anim.render(12); + while (anim.position() < 8) anim.tick(); + while (!(anim.position() === 1 && anim.direction() === -1)) anim.tick(); + }}, + { label: "at-left", setup(anim) { + anim.reset(); anim.render(12); + while (anim.position() < 8) anim.tick(); + while (!(anim.position() === 0 && anim.direction() === 1)) anim.tick(); + }}, + { label: "after-left", setup(anim) { + anim.reset(); anim.render(12); + while (anim.position() < 8) anim.tick(); + while (!(anim.position() === 0 && anim.direction() === 1)) anim.tick(); + for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE; step += 1) anim.tick(); + check(anim.position() === 1 && anim.direction() === 1, "after-left setup"); + }}, + ]) { + const edge = createCalmWorkingShipAnimation(); + scenario.setup(edge); + edge.render(12); + const frozen = { column: edge.position(), direction: edge.direction(), phase: edge.waterPhase() }; + const paused = createCalmWorkingShipWidget(tui, edge); + paused.dispose(); + const again = createCalmWorkingShipWidget(tui, edge); + again.render(12); + check( + edge.position() === frozen.column && edge.direction() === frozen.direction && edge.waterPhase() === frozen.phase, + `${scenario.label} resume changed frozen edge state`, + ); + for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE; step += 1) edge.tick(); + const expectedColumn = Math.min(8, Math.max(0, frozen.column + frozen.direction)); + let expectedDirection = frozen.direction; + if (expectedColumn >= 8) expectedDirection = -1; + else if (expectedColumn <= 0) expectedDirection = 1; + check( + edge.position() === expectedColumn && edge.direction() === expectedDirection, + `${scenario.label} post-resume bounce drifted: col=${edge.position()} dir=${edge.direction()}`, + ); + again.dispose(); + } + + // reset() returns a genuine fresh-session initial state. + animation.reset(); + check( + animation.position() === 0 && animation.direction() === 1 && animation.waterPhase() === 0, + "reset() did not restore the normal initial boat state", + ); + animation.render(40); + check(sailOf(animation.render(40)) === "<|", "reset() first frame was not the initial rightward sail"); + + // Two controller instances never share motion state. + const left = createCalmWorkingShipAnimation(); + const right = createCalmWorkingShipAnimation(); + left.render(40); + right.render(40); + for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE * 3; step += 1) left.tick(); + check(left.position() === 3 && right.position() === 0, "separate animations leaked motion state"); +} + +{ + const realSetInterval = globalThis.setInterval; + const realClearInterval = globalThis.clearInterval; + const callbacks = []; + const handles = new Set(); + globalThis.setInterval = (callback) => { + callbacks.push(callback); + const handle = { unref() {} }; + handles.add(handle); + return handle; + }; + globalThis.clearInterval = (handle) => { + handles.delete(handle); + }; + + try { + const tui = { renderRequests: 0, requestRender() { this.renderRequests += 1; } }; + const animation = createCalmWorkingShipAnimation(); + const first = createCalmWorkingShipWidget(tui, animation); + first.render(40); + callbacks[callbacks.length - 1](); + callbacks[callbacks.length - 1](); + check(tui.renderRequests === 2, "unpainted timer ticks did not request renders"); + first.dispose(); + check(handles.size === 0, "disposing the unpainted widget left its timer scheduled"); + check( + animation.position() === 0 && animation.direction() === 1 && animation.waterPhase() === 0, + "dispose retained state from unpainted timer ticks", + ); + + const resumed = createCalmWorkingShipWidget(tui, animation); + resumed.render(40); + for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE; step += 1) { + callbacks[callbacks.length - 1](); + } + resumed.render(40); + check(animation.position() === 1, "unpainted ticks leaked into the resumed cadence"); + check(animation.waterPhase() === 0, "resumed cadence did not restore the rendered water phase"); + resumed.dispose(); + + const committed = createCalmWorkingShipAnimation(); + const progressing = createCalmWorkingShipWidget(tui, committed); + progressing.render(40); + callbacks[callbacks.length - 1](); + progressing.render(40); + const renderedPhase = committed.waterPhase(); + callbacks[callbacks.length - 1](); + progressing.dispose(); + check(committed.position() === 0, "dispose changed the committed column after an unpainted tick"); + check(committed.waterPhase() === renderedPhase, "dispose changed the committed phase after an unpainted tick"); + + const committedResume = createCalmWorkingShipWidget(tui, committed); + committedResume.render(40); + for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE - 2; step += 1) { + callbacks[callbacks.length - 1](); + } + check(committed.position() === 0, "serviced render did not preserve the committed cadence"); + callbacks[callbacks.length - 1](); + committedResume.render(40); + check(committed.position() === 1, "serviced render did not commit progress for the next cadence"); + committedResume.dispose(); + + const boundaryCases = [ + [7, 1], [8, -1], [7, -1], [1, -1], [0, 1], [1, 1], + ]; + for (const [targetPosition, targetDirection] of boundaryCases) { + const edge = createCalmWorkingShipAnimation(); + edge.render(12); + let reached = false; + for (let step = 0; step < 160; step += 1) { + if (edge.position() === targetPosition && edge.direction() === targetDirection) { + edge.render(12); + reached = true; + break; + } + edge.tick(); + edge.render(12); + } + check(reached, `could not prepare bounce state ${targetPosition}/${targetDirection}`); + const before = { position: edge.position(), direction: edge.direction(), phase: edge.waterPhase() }; + const paused = createCalmWorkingShipWidget(tui, edge); + paused.render(12); + for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE; step += 1) { + callbacks[callbacks.length - 1](); + } + paused.dispose(); + check( + edge.position() === before.position && + edge.direction() === before.direction && + edge.waterPhase() === before.phase, + `unpainted bounce tick escaped ${targetPosition}/${targetDirection}`, + ); + const resumedEdge = createCalmWorkingShipWidget(tui, edge); + resumedEdge.render(12); + check( + edge.position() === before.position && edge.direction() === before.direction, + `bounce state ${targetPosition}/${targetDirection} changed on resume`, + ); + resumedEdge.dispose(); + } + } finally { + globalThis.setInterval = realSetInterval; + globalThis.clearInterval = realClearInterval; + } +} + +// --- Lifecycle through the Calm extension's registered handlers -------------------- +let liveTimers = 0; +const realSetInterval = globalThis.setInterval; +const realClearInterval = globalThis.clearInterval; +globalThis.setInterval = (...args) => { + liveTimers += 1; + return realSetInterval(...args); +}; +globalThis.clearInterval = (timer) => { + if (timer !== undefined) liveTimers -= 1; + return realClearInterval(timer); +}; + +const sessionWrites = []; +const handlers = new Map(); +let calmCommand; +const pi = { + events: { emit() {}, on() {} }, + on(event, handler) { + const existing = handlers.get(event) ?? []; + existing.push(handler); + handlers.set(event, existing); + }, + registerCommand(name, command) { + if (name === "calm") calmCommand = command; + }, + registerEntryRenderer() {}, + registerTool() {}, + appendEntry: (...args) => sessionWrites.push(["appendEntry", ...args]), + sendMessage: (...args) => sessionWrites.push(["sendMessage", ...args]), + sendUserMessage: (...args) => sessionWrites.push(["sendUserMessage", ...args]), + setSessionName: (...args) => sessionWrites.push(["setSessionName", ...args]), +}; +const extension = await import(`${pathToFileURL(process.env.EXT).href}?ship=${Date.now()}`); +extension.default(pi); +check(!!calmCommand, "Calm command was not registered"); +for (const event of ["session_start", "agent_start", "agent_settled", "session_shutdown"]) { + check(handlers.has(event), `Calm did not register a ${event} handler`); +} + +let renderRequests = 0; +const tui = { requestRender: () => { renderRequests += 1; } }; +const ui = { + workingVisible: [], + visibilityCalls: 0, + widgetOps: [], + widgets: new Map(), + setWorkingVisible(visible) { + this.visibilityCalls += 1; + this.workingVisible.push(visible); + }, + // Mirrors Pi's documented widget contract: the previous component under a key is + // disposed before a replacement is installed, and clearing disposes it too. + setWidget(key, content, options) { + const existing = this.widgets.get(key); + if (existing?.dispose) existing.dispose(); + this.widgets.delete(key); + this.widgetOps.push({ + key, + action: content === undefined ? "clear" : "set", + placement: options?.placement, + }); + if (content === undefined) return; + this.widgets.set(key, typeof content === "function" ? content(tui, theme) : content); + }, + getEditorText: () => "", + getToolsExpanded: () => false, + onTerminalInput: () => () => {}, + setHiddenThinkingLabel() {}, + setStatus() {}, + setToolsExpanded() {}, + theme, +}; +const ctx = { ui }; +const fire = async (event, payload = {}) => { + for (const handler of handlers.get(event) ?? []) await handler(payload, ctx); +}; +const reset = () => { + ui.workingVisible.length = 0; + ui.widgetOps.length = 0; + ui.visibilityCalls = 0; +}; +const shipWidget = () => ui.widgets.get(CALM_WORKING_SHIP_WIDGET_KEY); + +// --- Calm off leaves Pi's stock working behavior completely untouched ------------- +await fire("session_start", { reason: "startup" }); +reset(); +for (const event of ["agent_start", "agent_settled", "session_shutdown"]) { + await fire(event, { reason: "quit" }); +} +check( + ui.visibilityCalls === 0, + `Calm off called setWorkingVisible ${ui.visibilityCalls} times from the run lifecycle`, +); +check(ui.widgetOps.length === 0, `Calm off registered a working widget: ${JSON.stringify(ui.widgetOps)}`); +check(liveTimers === 0, `Calm off started ${liveTimers} animation timers`); + +// --- Turning Calm on while idle shows no boat until a run starts ------------------- +reset(); +await calmCommand.handler("", ctx); +check(ui.widgetOps.length === 0, "toggling Calm on while idle installed a working widget"); +check(liveTimers === 0, "toggling Calm on while idle started an animation timer"); + +// --- Calm on plus an active run shows the boat instead of the stock row ----------- +reset(); +await fire("agent_start"); +check( + ui.widgetOps.length === 1 && + ui.widgetOps[0].key === CALM_WORKING_SHIP_WIDGET_KEY && + ui.widgetOps[0].action === "set", + `Calm on did not install exactly one working widget: ${JSON.stringify(ui.widgetOps)}`, +); +check(ui.widgetOps[0].placement === undefined, "Calm working widget asked for a non-default placement"); +check( + ui.workingVisible[ui.workingVisible.length - 1] === false, + "Calm on did not hide Pi's stock working row", +); +check(liveTimers === 1, `Calm on kept ${liveTimers} animation timers instead of one`); + +const widget = shipWidget(); +check(!!widget, "Calm on did not install the working-ship widget"); +check(typeof widget.render === "function", "working widget has no render(width)"); +check(typeof widget.invalidate === "function", "working widget has no invalidate()"); +check(typeof widget.dispose === "function", "working widget has no dispose()"); +// A focusable widget could steal input or swallow Escape; this one takes no keys. +check(widget.handleInput === undefined, "working widget accepts keyboard input"); +check(widget.wantsKeyRelease === undefined, "working widget asked for key release events"); +check(widget.render(60).length === 2, "installed working widget did not render the two-row sprite"); +check( + widget.render(60).every((line) => visibleWidth(line) <= 60), + "installed working widget rendered a line wider than its viewport", +); + +// --- Repeated low-level starts inside one logical run never duplicate anything ----- +reset(); +for (let repeat = 0; repeat < 5; repeat += 1) await fire("agent_start"); +check(ui.widgetOps.length === 0, `repeated starts churned the working widget: ${JSON.stringify(ui.widgetOps)}`); +check(liveTimers === 1, `repeated starts left ${liveTimers} animation timers`); +check(ui.widgets.size === 1, `repeated starts left ${ui.widgets.size} widgets`); +check(shipWidget() === widget, "repeated starts replaced the running widget"); + +// --- The animation drives Pi's renderer ------------------------------------------- +{ + const before = renderRequests; + await new Promise((resolve) => setTimeout(resolve, CALM_WORKING_SHIP_TICK_MS * 3)); + check(renderRequests > before, "the working animation never requested a TUI render"); +} + +// --- Settling removes the boat, stops the animation, and restores the stock row ---- +// Drive the live widget far enough that a left-edge reset would be observable. +{ + const moving = shipWidget(); + check(!!moving, "continuity setup lost the live working widget"); + moving.render(40); + await new Promise((resolve) => setTimeout(resolve, CALM_WORKING_SHIP_TICK_MS * CALM_WORKING_SHIP_TICKS_PER_MOVE * 5 + 40)); + moving.render(40); +} +const hullColumn = (widget) => strip(widget.render(40)[1]).indexOf("\\__/"); +const freezeColumn = hullColumn(shipWidget()); +const freezeSail = sailOf(shipWidget().render(40)); +check(freezeColumn > 0, `lifecycle continuity setup never left the left edge: ${freezeColumn}`); + +reset(); +await fire("agent_settled"); +check( + ui.widgetOps.length === 1 && + ui.widgetOps[0].key === CALM_WORKING_SHIP_WIDGET_KEY && + ui.widgetOps[0].action === "clear", + `settling did not clear the working widget: ${JSON.stringify(ui.widgetOps)}`, +); +check(liveTimers === 0, `settling left ${liveTimers} animation timers`); +check(ui.widgets.size === 0, "settling left a residual widget"); +check( + ui.workingVisible[ui.workingVisible.length - 1] === true, + "settling did not restore Pi's stock working row", +); +{ + // No stale rows survive the removal: the widget renders nothing once disposed. + const renderRequestsAfterDispose = renderRequests; + await new Promise((resolve) => setTimeout(resolve, CALM_WORKING_SHIP_TICK_MS * CALM_WORKING_SHIP_TICKS_PER_MOVE * 3)); + check( + renderRequests === renderRequestsAfterDispose, + "the animation kept running after the widget was removed", + ); +} + +// --- Later working period resumes the frozen column and direction ----------------- +reset(); +await fire("agent_start"); +check(liveTimers === 1, `resume start left ${liveTimers} animation timers instead of one`); +check(ui.widgets.size === 1, "resume start did not install exactly one working widget"); +const resumedWidget = shipWidget(); +const resumeColumn = hullColumn(resumedWidget); +const resumeSail = sailOf(resumedWidget.render(40)); +check( + resumeColumn === freezeColumn && resumeSail === freezeSail, + `resume reset the boat instead of continuing: froze ${freezeColumn}/${freezeSail}, resumed ${resumeColumn}/${resumeSail}`, +); +// Repeated start/settle cycles must not duplicate scheduler or widget ownership. +for (let cycle = 0; cycle < 3; cycle += 1) { + await fire("agent_settled"); + check(liveTimers === 0, `cycle ${cycle} settle left ${liveTimers} timers`); + check(ui.widgets.size === 0, `cycle ${cycle} settle left a residual widget`); + await fire("agent_start"); + check(liveTimers === 1, `cycle ${cycle} start left ${liveTimers} timers`); + check(ui.widgets.size === 1, `cycle ${cycle} start left ${ui.widgets.size} widgets`); + check( + hullColumn(shipWidget()) >= freezeColumn, + `cycle ${cycle} lost continuity after repeated settle/start`, + ); +} +await fire("agent_settled"); +check(liveTimers === 0 && ui.widgets.size === 0, "repeated continuity cycles did not finish clean"); + +// A genuine fresh session resets to the normal initial position. +reset(); +await fire("session_start", { reason: "new" }); +check(liveTimers === 0 && ui.widgets.size === 0, "fresh session left a stale boat"); +await fire("agent_start"); +check(hullColumn(shipWidget()) === 0, "fresh session did not restart at the left edge"); +check(sailOf(shipWidget().render(40)) === "<|", "fresh session lost the initial rightward sail"); +await fire("agent_settled"); + +// --- Abort and failure share Pi's agent_settled path ------------------------------ +// Pi emits agent_settled from a finally block, so an aborted or failed run reaches +// exactly this handler; the real-TUI regression covers the Escape abort path. +for (const outcome of ["abort", "failure"]) { + reset(); + await fire("agent_start"); + check(liveTimers === 1, `${outcome} setup did not start the animation`); + await fire("agent_settled"); + check(liveTimers === 0, `${outcome} left ${liveTimers} animation timers`); + check(ui.widgets.size === 0, `${outcome} left a residual widget`); + check( + ui.workingVisible[ui.workingVisible.length - 1] === true, + `${outcome} did not restore Pi's stock working row`, + ); +} + +// --- Shutdown, reload, and session replacement all clean up ----------------------- +for (const reason of ["quit", "reload", "new", "resume", "fork"]) { + reset(); + await fire("agent_start"); + check(liveTimers === 1, `${reason} setup did not start the animation`); + await fire("session_shutdown", { reason }); + check(liveTimers === 0, `session_shutdown(${reason}) left ${liveTimers} animation timers`); + check(ui.widgets.size === 0, `session_shutdown(${reason}) left a residual widget`); + check( + ui.workingVisible[ui.workingVisible.length - 1] === true, + `session_shutdown(${reason}) did not restore Pi's stock working row`, + ); + if (reason === "quit") continue; + reset(); + await fire("session_start", { reason }); + check(ui.widgets.size === 0, `session_start(${reason}) installed a stale widget`); + check(liveTimers === 0, `session_start(${reason}) left ${liveTimers} animation timers`); +} + +// --- Toggling Calm off during an active run restores the stock row immediately ----- +await fire("session_start", { reason: "startup" }); +reset(); +await fire("agent_start"); +check(liveTimers === 1, "active-run setup did not start the animation"); +await calmCommand.handler("", ctx); +check(liveTimers === 0, "toggling Calm off during a run left the animation running"); +check(ui.widgets.size === 0, "toggling Calm off during a run left the boat on screen"); +check( + ui.workingVisible[ui.workingVisible.length - 1] === true, + "toggling Calm off during a run did not restore Pi's stock working row", +); + +// Toggling Calm back on during the same run returns the boat. +reset(); +await calmCommand.handler("", ctx); +check(liveTimers === 1, "toggling Calm on during a run did not return the boat"); +check( + ui.workingVisible[ui.workingVisible.length - 1] === false, + "toggling Calm on during a run did not hide Pi's stock working row", +); +await fire("agent_settled"); +check(liveTimers === 0, "the toggled-on run did not clean up"); + +// A run started after toggling Calm on while idle uses the boat. +reset(); +await calmCommand.handler("", ctx); +await calmCommand.handler("", ctx); +await fire("agent_start"); +check(liveTimers === 1, "a later run did not use the boat after an idle Calm toggle"); +await fire("agent_settled"); +check(liveTimers === 0, "the later run did not clean up"); + +// --- The visual-only widget never touches session, transcript, or export data ------ +check( + sessionWrites.length === 0, + `the working presentation wrote session or transcript data: ${JSON.stringify(sessionWrites)}`, +); + +globalThis.setInterval = realSetInterval; +globalThis.clearInterval = realClearInterval; +JS +) + status=$? + [ "$status" -eq 0 ] || fail "Pi Calm working-ship checks failed: $out" + [ -z "$out" ] || fail "Pi Calm working-ship test printed output: $out" + pass "Pi Calm working ship moves on a slow independent cadence over faster fixed-cell blue water, paints the complete boat standard yellow with balanced resets, keeps ANSI-stripped width exact, flips the directional sail on the exact bounce at both edges and every width, clamps visible and hidden resizes, falls back deterministically when narrow, freezes and resumes column/direction across settle/start without hidden-time jumps or duplicate timers, resets only on a fresh session, and installs and removes one scheduler-owning widget across starts, settle, abort, failure, shutdown, reload, replacement, and Calm toggles while leaving Calm-off visibility untouched" +} + test_interactive_terminal_e2e() { - local project config home session_file export_file export_dom default_snapshot expanded_snapshot hidden_snapshot active_before_snapshot active_hidden_snapshot export_snapshot restored_snapshot working_snapshot working_response_snapshot restarted_snapshot resumed_restored_snapshot hash_before hash_after now version chrome chrome_pid chrome_wait active_wait active_screen_wait + local project config home session_file export_file export_dom default_snapshot expanded_snapshot hidden_snapshot active_before_snapshot active_hidden_snapshot export_snapshot restored_snapshot working_snapshot working_response_snapshot restarted_snapshot resumed_restored_snapshot hash_before hash_after now version chrome chrome_pid chrome_wait active_wait active_screen_wait boat_frame_one boat_frame_two boat_resized_snapshot boat_focus_snapshot boat_cleared_snapshot boat_hull_line boat_sail_line boat_column_one boat_column_two boat_line boat_color_snapshot boat_color_line boat_water_snapshot boat_water_line boat_water_first boat_water_changed boat_narrow_snapshot boat_narrow_sails boat_freeze_snapshot boat_resume_snapshot boat_freeze_column boat_freeze_sail boat_resume_column boat_resume_sail if ! command -v pi >/dev/null 2>&1 || ! command -v tmux >/dev/null 2>&1; then echo "skip: pi or tmux not found for Pi calm interactive E2E" return 0 @@ -1615,6 +2488,16 @@ test_interactive_terminal_e2e() { restored_snapshot="$TMP_ROOT/restored.txt" working_snapshot="$TMP_ROOT/working.txt" working_response_snapshot="$TMP_ROOT/working-response.txt" + boat_frame_one="$TMP_ROOT/boat-frame-one.txt" + boat_frame_two="$TMP_ROOT/boat-frame-two.txt" + boat_resized_snapshot="$TMP_ROOT/boat-resized.txt" + boat_focus_snapshot="$TMP_ROOT/boat-focus.txt" + boat_cleared_snapshot="$TMP_ROOT/boat-cleared.txt" + boat_color_snapshot="$TMP_ROOT/boat-color.txt" + boat_water_snapshot="$TMP_ROOT/boat-water.txt" + boat_narrow_snapshot="$TMP_ROOT/boat-narrow.txt" + boat_freeze_snapshot="$TMP_ROOT/boat-freeze.txt" + boat_resume_snapshot="$TMP_ROOT/boat-resume.txt" restarted_snapshot="$TMP_ROOT/restarted.txt" resumed_restored_snapshot="$TMP_ROOT/resumed-restored.txt" mkdir -p "$project/.pi/extensions/lib" "$project/bin" "$project/state" "$config" "$home/config" @@ -1624,6 +2507,7 @@ test_interactive_terminal_e2e() { cp "$ASSISTANT_LAYOUT" "$project/.pi/extensions/lib/fm-calm-assistant-layout.ts" cp "$OPERATIONAL_USER_LAYOUT" "$project/.pi/extensions/lib/fm-calm-operational-user-layout.ts" cp "$VISIBILITY" "$project/.pi/extensions/lib/fm-calm-visibility.ts" + cp "$WORKING_SHIP" "$project/.pi/extensions/lib/fm-calm-working-ship.ts" cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$project/.pi/extensions/lib/fm-operational-input.ts" cp "$WATCH_EXT" "$project/.pi/extensions/fm-primary-pi-watch.ts" cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$project/.pi/extensions/fm-primary-turnend-guard.ts" @@ -1657,6 +2541,15 @@ export default function (pi: ExtensionAPI): void { contextWindow: 4096, maxTokens: 128, }, + { + id: "delayed-boat", + name: "Long-delay Calm working-ship fixture", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 4096, + maxTokens: 128, + }, { id: "operational-error", name: "Calm gapless operational-row fixture", @@ -1695,7 +2588,18 @@ export default function (pi: ExtensionAPI): void { stream.end(); return; } - await new Promise((resolve) => setTimeout(resolve, 1500)); + // Wake as soon as the run is aborted so Escape settles the turn promptly. + await new Promise<void>((resolve) => { + const timer = setTimeout(resolve, model.id === "delayed-boat" ? 90000 : 1500); + options?.signal?.addEventListener( + "abort", + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + }); if (options?.signal?.aborted) { output.stopReason = "aborted"; stream.push({ type: "error", reason: "aborted", error: output }); @@ -1744,6 +2648,16 @@ export default function (pi: ExtensionAPI): void { }); }, }); + pi.registerCommand("calm-boat-e2e", { + description: "Start the long-delay working-ship fixture.", + handler: async (_args, ctx) => { + const model = ctx.modelRegistry.find("calm-e2e", "delayed-boat"); + if (!model || !(await pi.setModel(model))) { + throw new Error("could not select the long-delay Calm E2E model"); + } + await pi.sendUserMessage("CALM_BOAT_E2E_PROMPT"); + }, + }); pi.registerCommand("calm-working-e2e", { description: "Start the delayed native Working-row fixture.", handler: async (_args, ctx) => { @@ -1794,6 +2708,10 @@ JSON tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" C-o wait_for_text "$expanded_snapshot" "escape to interrupt" \ || fail "Ctrl+O did not retain Pi's ordinary startup and tool expansion behavior" + # The expansion redraw lands a frame or two after the footer hint, so wait for the + # tool output this block actually asserts instead of assuming one implies the other. + wait_for_text "$expanded_snapshot" "CALM_E2E_OUTPUT" \ + || fail "ordinary Ctrl+O expansion hid tool activity while calm mode was off" assert_contains "$(cat "$expanded_snapshot")" "CALM_E2E_OUTPUT" "ordinary Ctrl+O expansion hid tool activity while calm mode was off" tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/calm" @@ -1801,8 +2719,13 @@ JSON active_screen_wait=0 while [ "$active_screen_wait" -lt 120 ]; do tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$hidden_snapshot" + # Wait for the redraw this block actually asserts: hidden rows gone AND the + # retained genuine rows back on screen. Breaking on the hidden rows alone can + # observe a half-redrawn transcript. if ! grep -Fq "CALM_E2E_OUTPUT" "$hidden_snapshot" && - ! grep -Fq "/calm" "$hidden_snapshot"; then + ! grep -Fq "/calm" "$hidden_snapshot" && + grep -Fq "FIRSTMATE WATCHER WAKE: can you explain this phrase?" "$hidden_snapshot" && + grep -Fq "The deterministic tool example is complete." "$hidden_snapshot"; then break fi sleep 0.05 @@ -2031,10 +2954,258 @@ JS done [ "$(cat "$home/config/calm")" = on ] || fail "third /calm did not persist the active choice" + # Calm on plus a genuinely active run replaces Pi's stock working row with the boat. + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/calm-boat-e2e" + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s + active_screen_wait=0 + while [ "$active_screen_wait" -lt 200 ]; do + tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$working_snapshot" + if grep -Fq '\__/' "$working_snapshot"; then + break + fi + sleep 0.025 + active_screen_wait=$((active_screen_wait + 1)) + done + cp "$working_snapshot" "$boat_frame_one" + assert_contains "$(cat "$boat_frame_one")" '\__/' "Calm did not show the working ship during a real provider wait" + assert_not_contains "$(cat "$boat_frame_one")" "Working..." "Calm left Pi's stock working row visible while the ship was shown" + assert_not_contains "$(cat "$boat_frame_one")" "calm transcript" "the real provider wait showed a persistent Calm status row" + assert_not_contains "$(cat "$boat_frame_one")" "FIRSTMATE WATCHER WAKE: signal: /tmp/probe.status" "the real provider wait restored a hidden operational row" + boat_hull_line=$(grep -F '\__/' "$boat_frame_one" | head -1) + boat_sail_line=$(grep -E '<\||\|>' "$boat_frame_one" | tail -1) + case "$boat_sail_line" in + *'<|'*|*'|>'*) : ;; + *) fail "the working ship lost its directional mainsail" ;; + esac + assert_not_contains "$boat_hull_line" "Working" "the ship row carried extra status copy" + case "$boat_hull_line" in + *~*) : ;; + *) fail "the working ship rendered no waves" ;; + esac + # Standard ANSI colors: blue water, yellow boat, no theme/bright/256/RGB escapes. + tmux -L "$TMUX_SOCKET" capture-pane -p -e -t "$TMUX_SESSION" >"$boat_color_snapshot" + boat_color_line=$(grep -F '\__/' "$boat_color_snapshot" | head -1) + [ -n "$boat_color_line" ] || fail "could not capture a colored working-ship row" + case "$boat_color_line" in + *'[34m'*) : ;; + *) fail "the water was not rendered with standard ANSI blue" ;; + esac + case "$boat_color_line" in + *'[33m'*) : ;; + *) fail "the boat was not rendered with standard ANSI yellow" ;; + esac + case "$boat_color_line" in + *'[38;2;'*|*'[38;5;'*|*'[9'[0-9]'m'*) fail "the working ship used a non-standard color escape" ;; + *) : ;; + esac + + # The water animates on its own faster cadence while the boat holds its column. + boat_column_one=$(awk 'index($0,"\\__/"){print index($0,"\\__/"); exit}' "$boat_frame_one") + boat_water_changed=0 + boat_water_first=$(grep -F '\__/' "$boat_frame_one" | head -1) + active_screen_wait=0 + while [ "$active_screen_wait" -lt 60 ]; do + tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$boat_water_snapshot" + boat_water_line=$(grep -F '\__/' "$boat_water_snapshot" | head -1) + boat_column_two=$(awk 'index($0,"\\__/"){print index($0,"\\__/"); exit}' "$boat_water_snapshot") + if [ -n "$boat_water_line" ] && [ "$boat_column_two" = "$boat_column_one" ] && + [ "$boat_water_line" != "$boat_water_first" ]; then + boat_water_changed=1 + break + fi + sleep 0.05 + active_screen_wait=$((active_screen_wait + 1)) + done + [ "$boat_water_changed" -eq 1 ] \ + || fail "the water never animated while the working ship held its column" + + # Two frames at different hull columns prove genuine horizontal motion. + boat_column_two="" + active_screen_wait=0 + while [ "$active_screen_wait" -lt 200 ]; do + tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$boat_frame_two" + boat_column_two=$(awk 'index($0,"\\__/"){print index($0,"\\__/"); exit}' "$boat_frame_two") + if [ -n "$boat_column_two" ] && [ "$boat_column_two" != "$boat_column_one" ]; then + break + fi + sleep 0.05 + active_screen_wait=$((active_screen_wait + 1)) + done + [ -n "$boat_column_two" ] || fail "the working ship disappeared between animation frames" + [ "$boat_column_two" != "$boat_column_one" ] \ + || fail "the working ship never moved horizontally (stuck at column $boat_column_one)" + + # The widget owns its own geometry, so resizing the same running TUI must reflow it. + tmux -L "$TMUX_SOCKET" set-option -t "$TMUX_SESSION" window-size manual + tmux -L "$TMUX_SOCKET" resize-window -t "$TMUX_SESSION" -x 100 -y 30 + active_screen_wait=0 + while [ "$active_screen_wait" -lt 200 ]; do + tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$boat_resized_snapshot" + boat_hull_line=$(grep -F '\__/' "$boat_resized_snapshot" | head -1) + if [ -n "$boat_hull_line" ] && [ "${#boat_hull_line}" -eq 100 ]; then + break + fi + sleep 0.05 + active_screen_wait=$((active_screen_wait + 1)) + done + assert_contains "$(cat "$boat_resized_snapshot")" '\__/' "the working ship left the screen after a resize" + boat_hull_line=$(grep -F '\__/' "$boat_resized_snapshot" | head -1) + [ "${#boat_hull_line}" -eq 100 ] \ + || fail "after resizing to 100 columns the ship row was ${#boat_hull_line} cells instead of exactly 100" + # Exactly one wave row means the sprite reflowed rather than wrapping onto extra rows. + [ "$(grep -c -F '\__/' "$boat_resized_snapshot")" -eq 1 ] \ + || fail "the working ship wrapped onto more than one water row after the resize" + while IFS= read -r boat_line; do + [ "${#boat_line}" -le 100 ] \ + || fail "a rendered line was ${#boat_line} cells after resizing to 100 columns" + done <"$boat_resized_snapshot" + boat_column_one=$(awk 'index($0,"\\__/"){print index($0,"\\__/"); exit}' "$boat_resized_snapshot") + [ "$boat_column_one" -le 97 ] \ + || fail "the working ship hull started at column $boat_column_one and cannot fit in 100 columns" + + # Motion continues on-screen after the resize instead of jumping offscreen. + boat_column_two="" + active_screen_wait=0 + while [ "$active_screen_wait" -lt 200 ]; do + tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$boat_resized_snapshot" + boat_column_two=$(awk 'index($0,"\\__/"){print index($0,"\\__/"); exit}' "$boat_resized_snapshot") + if [ -n "$boat_column_two" ] && [ "$boat_column_two" != "$boat_column_one" ]; then + break + fi + sleep 0.05 + active_screen_wait=$((active_screen_wait + 1)) + done + [ -n "$boat_column_two" ] && [ "$boat_column_two" != "$boat_column_one" ] \ + || fail "the working ship stopped moving after the resize" + [ "$boat_column_two" -le 97 ] \ + || fail "the working ship moved offscreen after the resize" + + # A narrow terminal shortens the track enough to observe both bounce directions. + # The sail must show the heading it is about to travel, so a full traverse shows both. + tmux -L "$TMUX_SOCKET" resize-window -t "$TMUX_SESSION" -x 12 -y 20 + boat_narrow_sails="" + active_screen_wait=0 + while [ "$active_screen_wait" -lt 400 ]; do + tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$boat_narrow_snapshot" + if grep -Fq '<|' "$boat_narrow_snapshot"; then + case "$boat_narrow_sails" in *R*) : ;; *) boat_narrow_sails="${boat_narrow_sails}R" ;; esac + fi + if grep -Fq '|>' "$boat_narrow_snapshot"; then + case "$boat_narrow_sails" in *L*) : ;; *) boat_narrow_sails="${boat_narrow_sails}L" ;; esac + fi + case "$boat_narrow_sails" in + *R*L*|*L*R*) break ;; + esac + sleep 0.1 + active_screen_wait=$((active_screen_wait + 1)) + done + case "$boat_narrow_sails" in + *R*L*|*L*R*) : ;; + *) fail "the working ship never showed both sail headings on a narrow track (saw '$boat_narrow_sails')" ;; + esac + boat_hull_line=$(grep -F '\__/' "$boat_narrow_snapshot" | head -1) + [ "${#boat_hull_line}" -eq 12 ] \ + || fail "the narrow working-ship row was ${#boat_hull_line} cells instead of exactly 12" + tmux -L "$TMUX_SOCKET" resize-window -t "$TMUX_SESSION" -x 100 -y 30 + + # Typing still reaches the editor while the animation runs. + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "FOCUSPROBE" + wait_for_text "$boat_focus_snapshot" "FOCUSPROBE" \ + || fail "keyboard input did not reach the editor while the working ship animated" + i=0 + while [ "$i" -lt 10 ]; do + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" BSpace + i=$((i + 1)) + done + + # Capture the last on-screen column and sail before settling so the next working + # period in this same Pi session can prove freeze/resume continuity. + tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$boat_freeze_snapshot" + boat_freeze_column=$(awk 'index($0,"\\__/"){print index($0,"\\__/"); exit}' "$boat_freeze_snapshot") + boat_freeze_sail=$(grep -E '<\||\|>' "$boat_freeze_snapshot" | tail -1 || true) + case "$boat_freeze_sail" in + *'<|'*) boat_freeze_sail='<|' ;; + *'|>'*) boat_freeze_sail='|>' ;; + *) fail "could not read the freeze-frame sail heading" ;; + esac + [ -n "$boat_freeze_column" ] && [ "$boat_freeze_column" -gt 1 ] \ + || fail "freeze frame never left the left edge (column '${boat_freeze_column:-empty}')" + + # Escape aborts the run, and the abort path removes the ship with no residue. + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" Escape + active_screen_wait=0 + while [ "$active_screen_wait" -lt 200 ]; do + tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$boat_cleared_snapshot" + if ! grep -Fq '\__/' "$boat_cleared_snapshot"; then + break + fi + sleep 0.05 + active_screen_wait=$((active_screen_wait + 1)) + done + assert_not_contains "$(cat "$boat_cleared_snapshot")" '\__/' "Escape did not remove the working ship" + assert_not_contains "$(cat "$boat_cleared_snapshot")" "CALM_WORKING_E2E_RESPONSE" "the long-delay fixture settled instead of aborting on Escape" + assert_not_contains "$(cat "$boat_cleared_snapshot")" "FOCUSPROBE" "the editor kept the focus probe text after Escape" + + # A later working period in the same Pi process must resume the frozen column and + # sail rather than recreating the boat at the left edge. Capture the first resumed + # frames quickly so the slow boat cadence cannot advance before the assertion. + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/calm-boat-e2e" + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s + boat_resume_column="" + boat_resume_sail="" + active_screen_wait=0 + while [ "$active_screen_wait" -lt 200 ]; do + tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$boat_resume_snapshot" + if grep -Fq '\__/' "$boat_resume_snapshot"; then + boat_resume_column=$(awk 'index($0,"\\__/"){print index($0,"\\__/"); exit}' "$boat_resume_snapshot") + boat_resume_sail=$(grep -E '<\||\|>' "$boat_resume_snapshot" | tail -1 || true) + case "$boat_resume_sail" in + *'<|'*) boat_resume_sail='<|' ;; + *'|>'*) boat_resume_sail='|>' ;; + esac + break + fi + sleep 0.025 + active_screen_wait=$((active_screen_wait + 1)) + done + [ -n "$boat_resume_column" ] \ + || fail "the second working period never showed the working ship" + [ "$boat_resume_column" -eq "$boat_freeze_column" ] \ + || fail "the second working period reset the boat from column $boat_freeze_column to $boat_resume_column instead of resuming" + [ "$boat_resume_sail" = "$boat_freeze_sail" ] \ + || fail "the second working period changed sail from $boat_freeze_sail to $boat_resume_sail" + assert_not_contains "$(cat "$boat_resume_snapshot")" "Working..." \ + "the second working period left Pi's stock working row visible" + + # Clear the resumed run before the Calm-off stock-row probe. + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" Escape + active_screen_wait=0 + while [ "$active_screen_wait" -lt 200 ]; do + tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$boat_cleared_snapshot" + if ! grep -Fq '\__/' "$boat_cleared_snapshot"; then + break + fi + sleep 0.05 + active_screen_wait=$((active_screen_wait + 1)) + done + assert_not_contains "$(cat "$boat_cleared_snapshot")" '\__/' "Escape did not remove the resumed working ship" + + # Calm off restores Pi's stock working row and never shows the ship. + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/calm" + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s + active_screen_wait=0 + while [ "$active_screen_wait" -lt 200 ]; do + if [ "$(cat "$home/config/calm")" = off ]; then + break + fi + sleep 0.05 + active_screen_wait=$((active_screen_wait + 1)) + done + [ "$(cat "$home/config/calm")" = off ] || fail "the Calm-off working-row probe did not turn Calm off" tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/calm-working-e2e" tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s active_screen_wait=0 - while [ "$active_screen_wait" -lt 120 ]; do + while [ "$active_screen_wait" -lt 200 ]; do tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$working_snapshot" if grep -Fq "Working..." "$working_snapshot"; then break @@ -2042,11 +3213,28 @@ JS sleep 0.025 active_screen_wait=$((active_screen_wait + 1)) done - assert_contains "$(cat "$working_snapshot")" "Working..." "Calm hid Pi's built-in Working row during a real provider wait" - assert_not_contains "$(cat "$working_snapshot")" "calm transcript" "the real provider wait showed a persistent Calm status row" - assert_not_contains "$(cat "$working_snapshot")" "FIRSTMATE WATCHER WAKE: signal: /tmp/probe.status" "the real provider wait restored a hidden operational row" + assert_contains "$(cat "$working_snapshot")" "Working..." "Calm off did not keep Pi's stock working row" + assert_not_contains "$(cat "$working_snapshot")" '\__/' "Calm off showed the working ship" wait_for_text "$working_response_snapshot" "CALM_WORKING_E2E_RESPONSE" \ - || fail "the deterministic provider did not settle after proving Pi's Working row" + || fail "the deterministic provider did not settle after proving Pi's stock working row" + + # No blank-row residue: settling returns to the same layout Calm off started from. + tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$boat_cleared_snapshot" + assert_not_contains "$(cat "$boat_cleared_snapshot")" '\__/' "a settled run left the working ship on screen" + + # Restore Calm for the persistence restart below. + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/calm" + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s + active_screen_wait=0 + while [ "$active_screen_wait" -lt 200 ]; do + if [ "$(cat "$home/config/calm")" = on ]; then + break + fi + sleep 0.05 + active_screen_wait=$((active_screen_wait + 1)) + done + [ "$(cat "$home/config/calm")" = on ] || fail "Calm was not restored before the persistence restart" + tmux -L "$TMUX_SOCKET" resize-window -t "$TMUX_SESSION" -x 180 -y 44 tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/quit" tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s @@ -2082,7 +3270,7 @@ JS [ "$(cat "$home/config/calm")" = off ] || fail "/calm after restart did not persist the inactive choice" tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/quit" tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s - pass "Pi calm native E2E keeps Working and captain turns visible, hides exact operational user rows without changing persistence, restores them Calm-off, survives restart, and preserves export plus Ctrl+O behavior" + pass "Pi calm native E2E replaces the stock working row with a moving, resize-clamped working ship that freezes and resumes across two working periods in one Pi session, clears on abort, keeps captain turns visible, hides exact operational user rows without changing persistence, restores stock rendering Calm-off, survives restart, and preserves export plus Ctrl+O behavior" } test_home_resolution @@ -2092,4 +3280,5 @@ test_pi_compat_missing_adapter_exports test_rendering_and_session_lifecycle test_operational_followup_turn_e2e test_hidden_block_geometry_e2e +test_working_ship_geometry_and_lifecycle test_interactive_terminal_e2e diff --git a/tests/fm-crew-state.test.sh b/tests/fm-crew-state.test.sh index bc0161d624f..c759eca2e95 100755 --- a/tests/fm-crew-state.test.sh +++ b/tests/fm-crew-state.test.sh @@ -5,7 +5,7 @@ # The status file (state/<id>.status) is a best-effort append-only EVENT LOG, so # `tail -1` of it reports the last event, not the current state. fm-crew-state # reads the AUTHORITATIVE source (a matching no-mistakes run-step, else the -# pane busy-signature) and reconciles the possibly-stale log against it. These +# semantic busy-state contract) and reconciles the possibly-stale log against it. These # cases pin every branch of that logic, hermetically, over real throwaway git # repos with a fake `no-mistakes` (run-step source) and a fake `tmux` (pane # source): @@ -14,8 +14,8 @@ # (c) genuine parked run + needs-decision log = NOT superseded -> run-step # (d) terminal run-step (passed/failed) is authoritative -> run-step # (e) cross-branch attribution: this branch's own run found via list lookup -# (f) no run + busy pane -> pane -# (g) no run + idle pane falls to the status-log verb -> status-log +# (f) no run + semantic busy -> pane +# (g) no run + semantic idle falls to the status-log verb -> status-log # (h) dead pane: no run -> unknown/none; with a run -> run-step (not the shell) # (i) kind=scout skips the run lookup -> pane/status-log # (j) torn-down worktree / missing meta -> unknown/none @@ -68,14 +68,31 @@ case "${1:-}" in case "${1:-}" in status) shift - if [ "${1:-}" = --run ]; then printf '%s\n' "${FM_FAKE_AXI_STATUS_RUN:-}" - else printf '%s\n' "${FM_FAKE_AXI_STATUS:-}"; fi ;; + if [ "${1:-}" = --run ]; then + printf '%s\n' "${FM_FAKE_AXI_STATUS_RUN:-}" + elif [ -n "${FM_FAKE_AXI_STATUS_COUNT_FILE:-}" ]; then + count=$(cat "$FM_FAKE_AXI_STATUS_COUNT_FILE" 2>/dev/null || printf 0) + count=$((count + 1)) + printf '%s\n' "$count" > "$FM_FAKE_AXI_STATUS_COUNT_FILE" + if [ "$count" -eq 1 ]; then + printf '%s\n' "${FM_FAKE_AXI_STATUS_FIRST:-}" + else + printf '%s\n' "${FM_FAKE_AXI_STATUS_SECOND:-}" + fi + else + printf '%s\n' "${FM_FAKE_AXI_STATUS:-}" + fi + exit "${FM_FAKE_AXI_STATUS_RC:-0}" ;; logs) printf '%s\n' "${FM_FAKE_CI_LOGS:-}" ;; esac ;; runs) - printf '%s\n' "${FM_FAKE_RUNS_LIST:-}" ;; + if [ "${FM_FAKE_RUNS_TERM:-0}" = 1 ]; then + kill -TERM "$$" + fi + printf '%s\n' "${FM_FAKE_RUNS_LIST:-}" + exit "${FM_FAKE_RUNS_RC:-0}" ;; esac exit 0 SH @@ -88,7 +105,7 @@ case "${1:-}" in printf '%%1\n' ;; capture-pane) [ "${FM_FAKE_TMUX_MISSING:-0}" = 1 ] && exit 1 - if [ "${FM_FAKE_BUSY:-0}" = 1 ]; then printf 'work in progress\nesc to interrupt\n' + if [ "${FM_FAKE_BUSY:-0}" = 1 ]; then printf 'work in progress\n%s\n' "${FM_FAKE_BUSY_TEXT:-esc to interrupt}" else printf 'all quiet\n> \n'; fi ;; esac exit 0 @@ -143,12 +160,23 @@ run_crew_state() { # <case-dir> <id> PATH="$1/fakebin:$PATH" FM_STATE_OVERRIDE="$1/state" "$CREW_STATE" "$2" } +run_crew_validation() { # <case-dir> <id> + PATH="$1/fakebin:$PATH" FM_STATE_OVERRIDE="$1/state" "$CREW_STATE" --validation-lane "$2" +} + new_case() { # <name> -> echoes case dir with an empty state/ local d="$TMP_ROOT/$1" mkdir -p "$d/state" printf '%s\n' "$d" } +arm_idle_record() { # <state-dir> <id> + local state=$1 id=$2 gen + gen=$("$ROOT/bin/fm-busy-event.sh" arm "$state" "$id") + "$ROOT/bin/fm-busy-event.sh" apply "$state" "$id" idle --gen "$gen" \ + --source claude-hook --event stop +} + # Clear the fake-driver vars and (re-)mark them exported, so the per-test plain # assignments below stay exported into the fakes without an `export VAR=$(...)` # command-substitution assignment (SC2155). @@ -157,13 +185,20 @@ reset_fakes() { FM_FAKE_AXI_STATUS_RUN="" FM_FAKE_RUNS_LIST="" FM_FAKE_BUSY=0 + FM_FAKE_BUSY_TEXT= FM_FAKE_TMUX_MISSING=0 FM_FAKE_HERDR_BUSY=0 FM_FAKE_HERDR_MISSING=0 FM_FAKE_HERDR_AGENT_STATUS="" FM_FAKE_CI_LOGS="" - export FM_FAKE_AXI_STATUS FM_FAKE_AXI_STATUS_RUN FM_FAKE_RUNS_LIST FM_FAKE_BUSY FM_FAKE_TMUX_MISSING + FM_FAKE_AXI_STATUS_COUNT_FILE= + FM_FAKE_AXI_STATUS_FIRST= + FM_FAKE_AXI_STATUS_SECOND= + FM_FAKE_AXI_STATUS_RC=0 + FM_FAKE_RUNS_RC=0 + export FM_FAKE_AXI_STATUS FM_FAKE_AXI_STATUS_RUN FM_FAKE_RUNS_LIST FM_FAKE_BUSY FM_FAKE_BUSY_TEXT FM_FAKE_TMUX_MISSING export FM_FAKE_HERDR_BUSY FM_FAKE_HERDR_MISSING FM_FAKE_HERDR_AGENT_STATUS FM_FAKE_CI_LOGS + export FM_FAKE_AXI_STATUS_COUNT_FILE FM_FAKE_AXI_STATUS_FIRST FM_FAKE_AXI_STATUS_SECOND FM_FAKE_AXI_STATUS_RC FM_FAKE_RUNS_RC } # --- run-object fixtures (TOON, as `no-mistakes axi status` emits) ----------- @@ -676,6 +711,106 @@ test_terminal_failed() { pass "terminal failed run is authoritative" } +test_validation_lane_record_binds_full_run_identity() { + reset_fakes + local d short out + d=$(new_case validation-lane-full) + make_repo_on_branch "$d/wt" fm/feat-lane-full + short=$(git -C "$d/wt" rev-parse --short=7 HEAD) + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/lane-full.meta" "window=fm:fm-lane-full" "worktree=$d/wt" "kind=ship" + FM_FAKE_AXI_STATUS="$(run_passed fm/feat-lane-full)" + FM_FAKE_RUNS_LIST="completed fm/feat-lane-full ${short} 2026-08-01 12:00" + out=$(run_crew_validation "$d" lane-full) + [ "$out" = $'fm-crew-validation-v2\nstate=done\nsource=run-step\nrun-kind=full\nrun-id=01RUN\nrun-start=2026-08-01T12:00#1' ] \ + || fail "validation lane record did not expose the full run identity (got: $out)" + pass "validation lane record binds full identity and run-start evidence" +} + +test_validation_lane_record_marks_coarse_run_without_identity() { + reset_fakes + local d short out + d=$(new_case validation-lane-coarse) + make_repo_on_branch "$d/wt" fm/feat-lane-coarse + short=$(git -C "$d/wt" rev-parse --short=7 HEAD) + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/lane-coarse.meta" "window=fm:fm-lane-coarse" "worktree=$d/wt" "kind=ship" + FM_FAKE_AXI_STATUS="$(run_running fm/other-crew)" + FM_FAKE_RUNS_LIST="running fm/feat-lane-coarse ${short} 2026-08-01 12:00" + out=$(run_crew_validation "$d" lane-coarse) + [ "$out" = $'fm-crew-validation-v2\nstate=working\nsource=run-step\nrun-kind=coarse\nrun-id=\nrun-start=2026-08-01T12:00#1' ] \ + || fail "validation lane record did not preserve coarse evidence (got: $out)" + pass "validation lane record marks coarse run evidence without inventing identity" +} + +test_validation_lane_record_starts_unavailable_identity() { + reset_fakes + local d short out + d=$(new_case validation-lane-unavailable) + make_repo_on_branch "$d/wt" fm/feat-lane-unavailable + short=$(git -C "$d/wt" rev-parse --short=7 HEAD) + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/lane-unavailable.meta" "window=fm:fm-lane-unavailable" "worktree=$d/wt" "kind=ship" + FM_FAKE_AXI_STATUS=$(run_passed fm/feat-lane-unavailable | grep -v '^ id:') + FM_FAKE_RUNS_LIST="completed fm/feat-lane-unavailable ${short} 2026-08-01 12:00" + out=$(run_crew_validation "$d" lane-unavailable) + [ "$out" = $'fm-crew-validation-v2\nstate=done\nsource=run-step\nrun-kind=unavailable\nrun-id=\nrun-start=2026-08-01T12:00#1' ] \ + || fail "validation lane record lost run-start evidence for unavailable identity (got: $out)" + pass "validation lane record preserves run-start evidence without identity" +} + +test_validation_lane_record_distinguishes_same_minute_runs() { + reset_fakes + local d short out + d=$(new_case validation-lane-same-minute) + make_repo_on_branch "$d/wt" fm/feat-lane-same-minute + short=$(git -C "$d/wt" rev-parse --short=7 HEAD) + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/lane-same-minute.meta" "window=fm:fm-lane-same-minute" "worktree=$d/wt" "kind=ship" + FM_FAKE_AXI_STATUS="$(run_running fm/other-crew)" + FM_FAKE_RUNS_LIST=$(printf 'completed fm/feat-lane-same-minute %s 2026-08-01 12:00\ncompleted fm/feat-lane-same-minute %s 2026-08-01 12:00' "$short" "$short") + out=$(run_crew_validation "$d" lane-same-minute) + [ "$out" = $'fm-crew-validation-v2\nstate=done\nsource=run-step\nrun-kind=coarse\nrun-id=\nrun-start=2026-08-01T12:00#2' ] \ + || fail "validation lane record did not distinguish same-minute runs (got: $out)" + pass "validation lane record distinguishes same-minute coarse run starts" +} + +test_validation_lane_rejects_changing_full_run_snapshot() { + reset_fakes + local d short out + d=$(new_case validation-lane-changing-snapshot) + make_repo_on_branch "$d/wt" fm/feat-lane-changing + short=$(git -C "$d/wt" rev-parse --short=7 HEAD) + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/lane-changing.meta" "window=fm:fm-lane-changing" "worktree=$d/wt" "kind=ship" + FM_FAKE_AXI_STATUS_COUNT_FILE="$d/status-count" + FM_FAKE_AXI_STATUS_FIRST="$(run_passed fm/feat-lane-changing)" + FM_FAKE_AXI_STATUS_SECOND="$(run_running fm/feat-lane-changing | sed 's/01RUN/02RUN/')" + FM_FAKE_RUNS_LIST="running fm/feat-lane-changing ${short} 2026-08-01 12:01" + out=$(run_crew_validation "$d" lane-changing) + assert_contains "$out" "run-kind=unavailable" "changing full-run snapshot was not rejected" + assert_not_contains "$out" "run-start=2026" "changing full-run snapshot retained mixed start evidence" + assert_not_contains "$out" "source=run-step" "changing full-run snapshot retained stale terminal state" + pass "validation lane rejects a changing full-run snapshot" +} + +test_validation_lane_distinguishes_runs_failure_from_absence() { + reset_fakes + local d out + d=$(new_case validation-lane-runs-failure) + make_repo_on_branch "$d/wt" fm/feat-lane-runs-failure + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/lane-runs-failure.meta" "window=fm:fm-lane-runs-failure" "worktree=$d/wt" "kind=ship" + FM_FAKE_AXI_STATUS="$(run_running fm/other-crew)" + FM_FAKE_RUNS_RC=7 + out=$(run_crew_validation "$d" lane-runs-failure) + assert_contains "$out" "run-kind=unavailable" "failed runs lookup was classified as absent" + FM_FAKE_RUNS_RC=0 + out=$(run_crew_validation "$d" lane-runs-failure) + assert_contains "$out" "run-kind=absent" "successful empty runs lookup was not affirmative absence" + pass "validation lane distinguishes runs lookup failure from absence" +} + # (e) cross-branch attribution: `axi status` returns ANOTHER branch's run (the # routine case once more than one crew validates the same underlying repo # concurrently - they share ONE no-mistakes repo registration), so the helper @@ -759,7 +894,7 @@ test_other_branch_run_ignored() { local d; d=$(new_case otherbranch) make_repo_on_branch "$d/wt" fm/feat-g make_fakebin "$d" >/dev/null - fm_write_meta "$d/state/feat-g.meta" "window=fm:fm-feat-g" "worktree=$d/wt" "kind=ship" + fm_write_meta "$d/state/feat-g.meta" "window=fm:fm-feat-g" "worktree=$d/wt" "kind=ship" "harness=claude" printf 'done: implemented, ready to validate\n' > "$d/state/feat-g.status" FM_FAKE_AXI_STATUS="$(run_running fm/some-other)" FM_FAKE_RUNS_LIST="$(cat <<'EOF' @@ -767,6 +902,7 @@ test_other_branch_run_ignored() { EOF )" FM_FAKE_BUSY=0 + arm_idle_record "$d/state" feat-g local out; out=$(run_crew_state "$d" feat-g) assert_not_contains "$out" "source: run-step" "another branch's run not misattributed" assert_contains "$out" "source: status-log" "no own run -> falls back to status-log" @@ -780,15 +916,60 @@ test_no_run_busy_pane() { local d; d=$(new_case busy) make_repo_on_branch "$d/wt" fm/feat-h make_fakebin "$d" >/dev/null - fm_write_meta "$d/state/feat-h.meta" "window=fm:fm-feat-h" "worktree=$d/wt" "kind=ship" - # No matching run anywhere. + fm_write_meta "$d/state/feat-h.meta" "window=fm:fm-feat-h" "worktree=$d/wt" "kind=ship" "harness=claude" + # No matching run anywhere. The busy verdict comes from the crew's own + # semantic lifecycle record (bin/fm-busy-lib.sh), not from rendered text. FM_FAKE_AXI_STATUS="" FM_FAKE_RUNS_LIST="" FM_FAKE_BUSY=1 + local gen; gen=$("$ROOT/bin/fm-busy-event.sh" arm "$d/state" feat-h) + "$ROOT/bin/fm-busy-event.sh" apply "$d/state" feat-h busy --gen "$gen" \ + --source claude-hook --event user-prompt-submit local out; out=$(run_crew_state "$d" feat-h) - assert_contains "$out" "state: working" "busy pane -> working" - assert_contains "$out" "source: pane" "busy pane -> pane source" - pass "no run + busy pane reads working from the pane" + assert_contains "$out" "state: working" "busy record -> working" + assert_contains "$out" "source: pane" "busy record -> pane source" + assert_contains "$out" "claude-hook" "the working verdict names its semantic source" + pass "no run + a busy semantic record reads working, attributed to its source" +} + +# A converted adapter must NOT read working from rendered footer text: the +# redesign removed that dependency, so a pane painting "esc to interrupt" with +# no semantic record is unknown, never working and never silently idle. +test_no_run_footer_text_alone_is_not_working() { + reset_fakes + local d; d=$(new_case busy-footer-only) + make_repo_on_branch "$d/wt" fm/feat-h2 + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-h2.meta" "window=fm:fm-feat-h2" "worktree=$d/wt" "kind=ship" "harness=claude" + FM_FAKE_AXI_STATUS="" + FM_FAKE_RUNS_LIST="" + FM_FAKE_BUSY=1 + printf 'done: stale completion event\n' > "$d/state/feat-h2.status" + local out; out=$(run_crew_state "$d" feat-h2) + assert_not_contains "$out" "state: working" "a footer alone must not read working for a converted adapter" + assert_contains "$out" "state: unknown" "no semantic record -> unknown" + assert_not_contains "$out" "source: status-log" "unknown semantic state must not fall through to a stale log" + pass "a converted adapter never reads working from rendered footer text" +} + +# Grok keeps its isolated temporary rendered-tail fallback until its structured +# lifecycle is live-verified, so a grok crew still reads working from its own +# verified signature. +test_no_run_grok_uses_isolated_fallback() { + reset_fakes + local d; d=$(new_case busy-grok) + make_repo_on_branch "$d/wt" fm/feat-h3 + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-h3.meta" "window=fm:fm-feat-h3" "worktree=$d/wt" "kind=ship" "harness=grok" + FM_FAKE_AXI_STATUS="" + FM_FAKE_RUNS_LIST="" + FM_FAKE_BUSY=1 + FM_FAKE_BUSY_TEXT='Ctrl+c:cancel' + export FM_FAKE_BUSY_TEXT + local out; out=$(run_crew_state "$d" feat-h3) + assert_contains "$out" "state: working" "grok busy tail -> working" + assert_contains "$out" "grok-regex" "the grok verdict names its isolated fallback source" + pass "grok still reads working through its isolated rendered-tail fallback" } test_no_run_herdr_unknown_uses_backend_capture() { @@ -797,68 +978,76 @@ test_no_run_herdr_unknown_uses_backend_capture() { local d; d=$(new_case herdr-busy) make_repo_on_branch "$d/wt" fm/feat-herdr make_fakebin "$d" >/dev/null - fm_write_meta "$d/state/feat-herdr.meta" "window=default:w1:p2" "worktree=$d/wt" "kind=ship" "backend=herdr" + fm_write_meta "$d/state/feat-herdr.meta" "window=default:w1:p2" "worktree=$d/wt" "kind=ship" \ + "backend=herdr" "harness=claude" FM_FAKE_AXI_STATUS="" FM_FAKE_RUNS_LIST="" FM_FAKE_TMUX_MISSING=1 FM_FAKE_HERDR_BUSY=1 - FM_FAKE_HERDR_AGENT_STATUS="" + FM_FAKE_HERDR_AGENT_STATUS=working local out; out=$(run_crew_state "$d" feat-herdr) - assert_contains "$out" "state: working" "herdr busy pane -> working" - assert_contains "$out" "source: pane" "herdr busy pane -> pane source" - pass "herdr unknown native state falls back to backend capture busy regex" -} - -# Regression: herdr's agent.get reports generation state ("working" only while -# the model is actively streaming a turn - docs/herdr-backend.md "Busy state"), -# not "this crew's tool call is still in progress". A crew blocked on its own -# long-running foreground `no-mistakes axi run` (no --yes; blocks until a gate -# or outcome) is not generating for that whole span, so agent.get can read -# idle while the pane's own rendered text still shows the busy banner -# (BUSY_REGEX) for the entire call. `idle` must be corroborated with that text -# exactly like `unknown` already is, not trusted outright - the bug this -# regression pins: crew_pane_is_busy previously returned "not busy" on a bare -# `idle` verdict without ever looking at the pane. -test_no_run_herdr_idle_agent_status_corroborated_by_busy_pane() { + assert_contains "$out" "state: working" "herdr native busy -> working" + assert_contains "$out" "source: pane" "herdr native busy -> pane source" + assert_contains "$out" "herdr-native" "the herdr verdict names its native source" + pass "herdr's native busy verdict reads working with no record present" +} + +# Regression (2026-07 herdr false-surface incident, now solved semantically): +# herdr's agent.get reports generation state ("working" only while the model is +# actively streaming - docs/herdr-backend.md "Busy state"), not "this crew's +# turn is still in progress". A crew blocked on its own long-running foreground +# `no-mistakes axi run` (no --yes; blocks until a gate or outcome) is not +# generating for that whole span, so agent.get reads idle. The crew's own +# semantic lifecycle record still says busy for the whole turn, and it outranks +# the narrower native verdict - so the crew is no longer misread as not-working. +test_no_run_herdr_idle_agent_status_outranked_by_record() { command -v jq >/dev/null 2>&1 || { pass "herdr idle corroboration skipped without jq"; return; } reset_fakes - local d; d=$(new_case herdr-idle-busy-pane) + local d; d=$(new_case herdr-idle-busy-record) make_repo_on_branch "$d/wt" fm/feat-herdr-idle make_fakebin "$d" >/dev/null - fm_write_meta "$d/state/feat-herdr-idle.meta" "window=default:w1:p3" "worktree=$d/wt" "kind=ship" "backend=herdr" + fm_write_meta "$d/state/feat-herdr-idle.meta" "window=default:w1:p3" "worktree=$d/wt" "kind=ship" \ + "backend=herdr" "harness=claude" # No run attributable (mirrors a no-mistakes run-step lookup that found no - # matching row within the configured runs-list window): the pane fallback is - # the only remaining signal. + # matching row within the configured runs-list window): the crew's semantic + # busy state is the only remaining signal. FM_FAKE_AXI_STATUS="" FM_FAKE_RUNS_LIST="" FM_FAKE_TMUX_MISSING=1 FM_FAKE_HERDR_AGENT_STATUS=idle - FM_FAKE_HERDR_BUSY=1 + FM_FAKE_HERDR_BUSY=0 + local gen; gen=$("$ROOT/bin/fm-busy-event.sh" arm "$d/state" feat-herdr-idle) + "$ROOT/bin/fm-busy-event.sh" apply "$d/state" feat-herdr-idle busy --gen "$gen" \ + --source claude-hook --event user-prompt-submit local out; out=$(run_crew_state "$d" feat-herdr-idle) - assert_contains "$out" "state: working" "herdr idle agent_status with a busy-banner pane -> working" - assert_contains "$out" "source: pane" "herdr idle agent_status with a busy-banner pane -> pane source" - pass "herdr idle agent_status is corroborated by the pane text, not trusted outright" + assert_contains "$out" "state: working" "a busy record with herdr idle agent_status -> working" + assert_contains "$out" "claude-hook" "the record's source outranks herdr's narrower native verdict" + pass "a mid-tool-call crew stays working because its record outranks herdr's generation state" } -# The corroboration must not mask a genuinely idle/human-blocked agent: idle -# agent_status AND an idle-looking pane (no busy banner) still reads not-busy. -test_no_run_herdr_idle_agent_status_and_idle_pane_stays_idle() { - command -v jq >/dev/null 2>&1 || { pass "herdr idle+idle-pane skipped without jq"; return; } +# The record must not mask a genuinely idle or human-blocked agent: an idle +# record with idle agent_status still reads not-busy. +test_no_run_herdr_idle_agent_status_and_idle_record_stays_idle() { + command -v jq >/dev/null 2>&1 || { pass "herdr idle+idle-record skipped without jq"; return; } reset_fakes - local d; d=$(new_case herdr-idle-idle-pane) + local d; d=$(new_case herdr-idle-idle-record) make_repo_on_branch "$d/wt" fm/feat-herdr-stopped make_fakebin "$d" >/dev/null - fm_write_meta "$d/state/feat-herdr-stopped.meta" "window=default:w1:p4" "worktree=$d/wt" "kind=ship" "backend=herdr" + fm_write_meta "$d/state/feat-herdr-stopped.meta" "window=default:w1:p4" "worktree=$d/wt" "kind=ship" \ + "backend=herdr" "harness=claude" printf 'working: implementing\n' > "$d/state/feat-herdr-stopped.status" FM_FAKE_AXI_STATUS="" FM_FAKE_RUNS_LIST="" FM_FAKE_TMUX_MISSING=1 FM_FAKE_HERDR_AGENT_STATUS=idle FM_FAKE_HERDR_BUSY=0 + local gen; gen=$("$ROOT/bin/fm-busy-event.sh" arm "$d/state" feat-herdr-stopped) + "$ROOT/bin/fm-busy-event.sh" apply "$d/state" feat-herdr-stopped idle --gen "$gen" \ + --source claude-hook --event stop local out; out=$(run_crew_state "$d" feat-herdr-stopped) - assert_not_contains "$out" "source: pane" "herdr idle agent_status with an idle pane must not read as busy from the pane" - assert_contains "$out" "source: status-log" "herdr idle agent_status with an idle pane falls to the status log" - pass "herdr idle agent_status with a genuinely idle pane stays not-busy (no regression for a human-blocked agent)" + assert_not_contains "$out" "source: pane" "an idle record must not read as busy" + assert_contains "$out" "source: status-log" "an idle record falls to the status log" + pass "an idle record with idle agent_status stays not-busy (no regression for a human-blocked agent)" } # (g) no run + idle pane -> the status-log verb, as-is @@ -867,10 +1056,11 @@ test_no_run_idle_pane_uses_log() { local d; d=$(new_case idle) make_repo_on_branch "$d/wt" fm/feat-i make_fakebin "$d" >/dev/null - fm_write_meta "$d/state/feat-i.meta" "window=fm:fm-feat-i" "worktree=$d/wt" "kind=ship" + fm_write_meta "$d/state/feat-i.meta" "window=fm:fm-feat-i" "worktree=$d/wt" "kind=ship" "harness=claude" printf 'needs-decision: which database?\n' > "$d/state/feat-i.status" FM_FAKE_AXI_STATUS="" FM_FAKE_BUSY=0 + arm_idle_record "$d/state" feat-i local out; out=$(run_crew_state "$d" feat-i) assert_contains "$out" "state: parked" "needs-decision log -> parked" assert_contains "$out" "source: status-log" "idle pane -> status-log source" @@ -882,10 +1072,11 @@ test_no_run_idle_pane_uses_keyed_log() { local d; d=$(new_case keyed-idle) make_repo_on_branch "$d/wt" fm/feat-keyed make_fakebin "$d" >/dev/null - fm_write_meta "$d/state/feat-keyed.meta" "window=fm:fm-feat-keyed" "worktree=$d/wt" "kind=ship" + fm_write_meta "$d/state/feat-keyed.meta" "window=fm:fm-feat-keyed" "worktree=$d/wt" "kind=ship" "harness=claude" printf 'needs-decision [key=q1]: which database?\n' > "$d/state/feat-keyed.status" FM_FAKE_AXI_STATUS="" FM_FAKE_BUSY=0 + arm_idle_record "$d/state" feat-keyed local out; out=$(run_crew_state "$d" feat-keyed) assert_contains "$out" "state: parked" "keyed needs-decision log -> parked" assert_contains "$out" "which database?" "key token is excluded from status detail" @@ -900,10 +1091,11 @@ test_no_run_idle_pane_paused() { local d; d=$(new_case paused) make_repo_on_branch "$d/wt" fm/feat-pause make_fakebin "$d" >/dev/null - fm_write_meta "$d/state/feat-pause.meta" "window=fm:fm-feat-pause" "worktree=$d/wt" "kind=ship" + fm_write_meta "$d/state/feat-pause.meta" "window=fm:fm-feat-pause" "worktree=$d/wt" "kind=ship" "harness=claude" printf 'paused: holding for the upstream tool release\n' > "$d/state/feat-pause.status" FM_FAKE_AXI_STATUS="" FM_FAKE_BUSY=0 + arm_idle_record "$d/state" feat-pause local out; out=$(run_crew_state "$d" feat-pause) assert_contains "$out" "state: paused" "paused log -> paused" assert_contains "$out" "source: status-log" "idle pause -> status-log source" @@ -916,10 +1108,11 @@ test_no_run_idle_pane_custom_paused_verb() { local d; d=$(new_case custom-paused) make_repo_on_branch "$d/wt" fm/feat-custom-pause make_fakebin "$d" >/dev/null - fm_write_meta "$d/state/feat-custom-pause.meta" "window=fm:fm-feat-custom-pause" "worktree=$d/wt" "kind=ship" + fm_write_meta "$d/state/feat-custom-pause.meta" "window=fm:fm-feat-custom-pause" "worktree=$d/wt" "kind=ship" "harness=claude" printf 'awaiting: vendor maintenance window\n' > "$d/state/feat-custom-pause.status" FM_FAKE_AXI_STATUS="" FM_FAKE_BUSY=0 + arm_idle_record "$d/state" feat-custom-pause local out; out=$(FM_CLASSIFY_PAUSED_VERB=awaiting run_crew_state "$d" feat-custom-pause) assert_contains "$out" "state: paused" "custom paused verb -> paused" assert_contains "$out" "source: status-log" "custom paused verb -> status-log source" @@ -1033,8 +1226,12 @@ while :; do :; done SH chmod +x "$d/fakebin/no-mistakes" toolbin=$(make_no_timeout_toolbin "$d") - fm_write_meta "$d/state/feat-timeout.meta" "window=fm:fm-feat-timeout" "worktree=$d/wt" "kind=ship" + fm_write_meta "$d/state/feat-timeout.meta" "window=fm:fm-feat-timeout" "worktree=$d/wt" "kind=ship" \ + "harness=claude" FM_FAKE_BUSY=1 + local gen; gen=$("$ROOT/bin/fm-busy-event.sh" arm "$d/state" feat-timeout) + "$ROOT/bin/fm-busy-event.sh" apply "$d/state" feat-timeout busy --gen "$gen" \ + --source claude-hook --event user-prompt-submit start=$SECONDS out=$(FM_FAKE_NM_CALLS="$calls_file" PATH="$d/fakebin:$toolbin" FM_STATE_OVERRIDE="$d/state" FM_CREW_STATE_NM_TIMEOUT=1 "$CREW_STATE" feat-timeout) elapsed=$((SECONDS - start)) @@ -1046,19 +1243,40 @@ SH pass "no timeout command uses perl bound" } +test_perl_bound_preserves_signaled_runs_failure() { + reset_fakes + local d toolbin out + d=$(new_case signaled-runs) + make_repo_on_branch "$d/wt" fm/feat-signaled-runs + make_fakebin "$d" >/dev/null + toolbin=$(make_no_timeout_toolbin "$d") + fm_write_meta "$d/state/feat-signaled-runs.meta" "window=fm:fm-feat-signaled-runs" \ + "worktree=$d/wt" "kind=ship" "harness=claude" + FM_FAKE_AXI_STATUS="$(run_running fm/other-crew)" + out=$(FM_FAKE_RUNS_TERM=1 PATH="$d/fakebin:$toolbin" FM_STATE_OVERRIDE="$d/state" \ + "$CREW_STATE" --validation-lane feat-signaled-runs) + assert_contains "$out" "run-kind=unavailable" "signaled runs lookup was not unavailable" + assert_not_contains "$out" "run-kind=absent" "signaled runs lookup was classified as absent" + pass "perl bound preserves signaled runs lookup failure" +} + # (i) kind=scout skips the run lookup entirely (its deliverable is a report). test_scout_skips_run_lookup() { reset_fakes local d; d=$(new_case scout) make_repo_on_branch "$d/wt" fm/scout-j make_fakebin "$d" >/dev/null - fm_write_meta "$d/state/scout-j.meta" "window=fm:fm-scout-j" "worktree=$d/wt" "kind=scout" + fm_write_meta "$d/state/scout-j.meta" "window=fm:fm-scout-j" "worktree=$d/wt" "kind=scout" \ + "harness=claude" # Even if a run existed on this branch, a scout must not read it. FM_FAKE_AXI_STATUS="$(run_running fm/scout-j)" FM_FAKE_BUSY=1 + local gen; gen=$("$ROOT/bin/fm-busy-event.sh" arm "$d/state" scout-j) + "$ROOT/bin/fm-busy-event.sh" apply "$d/state" scout-j busy --gen "$gen" \ + --source claude-hook --event user-prompt-submit local out; out=$(run_crew_state "$d" scout-j) assert_not_contains "$out" "source: run-step" "scout ignores no-mistakes run-step" - assert_contains "$out" "source: pane" "scout reads pane busy-signature" + assert_contains "$out" "source: pane" "scout reads its semantic busy state" pass "scout skips the run lookup" } @@ -1157,12 +1375,13 @@ test_historical_same_branch_rewritten_head_not_current() { new_head=$(git -C "$d/wt" rev-parse HEAD) [ "$old_head" != "$new_head" ] || fail "rewrite did not produce a new head" make_fakebin "$d" >/dev/null - fm_write_meta "$d/state/wishlist.meta" "window=fm:fm-wishlist" "worktree=$d/wt" "kind=ship" + fm_write_meta "$d/state/wishlist.meta" "window=fm:fm-wishlist" "worktree=$d/wt" "kind=ship" "harness=claude" printf 'working: stage 2 setup complete rebased onto merged #76\n' > "$d/state/wishlist.status" # Historical run still reports the pre-rewrite head on the reused branch. FM_FAKE_RUN_HEAD="$old_head" FM_FAKE_AXI_STATUS="$(run_parked fm/todo-flag)" FM_FAKE_BUSY=0 + arm_idle_record "$d/state" wishlist out=$(run_crew_state "$d" wishlist) assert_not_contains "$out" "source: run-step" "historical rewritten head must not use run-step" assert_not_contains "$out" "parked at" "historical parked run must not mask current state" @@ -1202,11 +1421,12 @@ test_local_advanced_past_run_head_invalidates() { run_head=$(git -C "$d/wt" rev-parse HEAD) git -C "$d/wt" commit -q --allow-empty -m 'local stage-2 work after prior run' make_fakebin "$d" >/dev/null - fm_write_meta "$d/state/adv.meta" "window=fm:fm-adv" "worktree=$d/wt" "kind=ship" + fm_write_meta "$d/state/adv.meta" "window=fm:fm-adv" "worktree=$d/wt" "kind=ship" "harness=claude" printf 'working: stage 2 implementation in progress\n' > "$d/state/adv.status" FM_FAKE_RUN_HEAD="$run_head" FM_FAKE_AXI_STATUS="$(run_parked fm/feat-adv)" FM_FAKE_BUSY=0 + arm_idle_record "$d/state" adv out=$(run_crew_state "$d" adv) assert_not_contains "$out" "source: run-step" "local-advanced tip must not use historical run" assert_contains "$out" "source: status-log" "falls back after local advanced past run" @@ -1220,11 +1440,12 @@ test_missing_run_head_falls_back_to_current_state() { d=$(new_case missing-run-head) make_repo_on_branch "$d/wt" fm/feat-no-head make_fakebin "$d" >/dev/null - fm_write_meta "$d/state/no-head.meta" "window=fm:fm-no-head" "worktree=$d/wt" "kind=ship" + fm_write_meta "$d/state/no-head.meta" "window=fm:fm-no-head" "worktree=$d/wt" "kind=ship" "harness=claude" printf 'working: current stage still in progress\n' > "$d/state/no-head.status" FM_FAKE_AXI_STATUS=$(run_parked fm/feat-no-head | grep -v '^ head:') FM_FAKE_RUNS_LIST="" FM_FAKE_BUSY=0 + arm_idle_record "$d/state" no-head out=$(run_crew_state "$d" no-head) assert_not_contains "$out" "source: run-step" "missing run head must not permit branch-only attribution" assert_contains "$out" "source: status-log" "missing run head falls back to current state sources" @@ -1252,14 +1473,22 @@ test_top_level_fixing_ci_running_after_green_stays_working test_top_level_fixing_done_log_stays_working test_terminal_passed test_terminal_failed +test_validation_lane_record_binds_full_run_identity +test_validation_lane_record_marks_coarse_run_without_identity +test_validation_lane_record_starts_unavailable_identity +test_validation_lane_record_distinguishes_same_minute_runs +test_validation_lane_rejects_changing_full_run_snapshot +test_validation_lane_distinguishes_runs_failure_from_absence test_cross_branch_attribution_via_runs_list test_cross_branch_attribution_picks_most_recent_row test_coarse_run_does_not_probe_other_branch_ci_log_for_ready_status test_other_branch_run_ignored test_no_run_busy_pane +test_no_run_footer_text_alone_is_not_working +test_no_run_grok_uses_isolated_fallback test_no_run_herdr_unknown_uses_backend_capture -test_no_run_herdr_idle_agent_status_corroborated_by_busy_pane -test_no_run_herdr_idle_agent_status_and_idle_pane_stays_idle +test_no_run_herdr_idle_agent_status_outranked_by_record +test_no_run_herdr_idle_agent_status_and_idle_record_stays_idle test_no_run_idle_pane_uses_log test_no_run_idle_pane_uses_keyed_log test_no_run_idle_pane_paused @@ -1269,6 +1498,7 @@ test_dead_window_ignores_stale_status_log test_dead_window_still_reports_terminal_run_step test_dead_window_still_reports_active_run_step test_no_timeout_uses_perl_bound +test_perl_bound_preserves_signaled_runs_failure test_scout_skips_run_lookup test_torn_down_worktree test_missing_meta diff --git a/tests/fm-daemon.test.sh b/tests/fm-daemon.test.sh index a1fbd7f5fea..0cadb5af1f6 100755 --- a/tests/fm-daemon.test.sh +++ b/tests/fm-daemon.test.sh @@ -22,6 +22,8 @@ if [ -z "${FM_TEST_DAEMON_SOURCED:-}" ]; then fi TMP_ROOT=$(fm_test_tmproot fm-daemon-tests) +FM_DAEMON_PRIMARY_HARNESS=claude +export FM_DAEMON_PRIMARY_HARNESS test_afk_start_refuses_when_flag_cannot_be_written() { local dir state out status @@ -356,6 +358,10 @@ test_housekeeping_paused_resumed_cleared() { win="sess:fm-held-w12"; pane="$dir/pane.txt" printf 'paused: holding for the upstream tool release\n' > "$state/held-w12.status" printf 'Working...\n' > "$pane" + fm_write_meta "$state/held-w12.meta" "window=$win" "worktree=$dir/wt" "kind=ship" "harness=pi" + local gen; gen=$("$ROOT/bin/fm-busy-event.sh" arm "$state" held-w12) + "$ROOT/bin/fm-busy-event.sh" apply "$state" held-w12 busy --gen "$gen" \ + --source pi-ext --event agent-start key=$(printf '%s' "held-w12" | tr ':/.' '___') echo $(( $(date +%s) - 5000 )) > "$state/.subsuper-paused-$key" PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$pane" \ @@ -443,6 +449,12 @@ test_housekeeping_resumed_stale_cleared() { pane="$dir/pane.txt" printf 'working\n' > "$state/res-w6.status" printf 'Working...\n' > "$pane" + # A resumed crew proves it is working through its own semantic busy-state + # record (bin/fm-busy-lib.sh), not through the pane's rendered footer. + fm_write_meta "$state/res-w6.meta" "window=$win" "worktree=$dir/wt" "kind=ship" "harness=pi" + local gen; gen=$("$ROOT/bin/fm-busy-event.sh" arm "$state" res-w6) + "$ROOT/bin/fm-busy-event.sh" apply "$state" res-w6 busy --gen "$gen" \ + --source pi-ext --event agent-start key=$(printf '%s' "res-w6" | tr ':/.' '___') echo $(( $(date +%s) - 500 )) > "$state/.subsuper-stale-$key" PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$pane" \ @@ -480,19 +492,26 @@ test_housekeeping_herdr_persistent_stale_resolves_meta() { pass "persistent herdr stale resolves the target from metadata and escalates" } -test_housekeeping_herdr_idle_busy_footer_clears_stale() { - local dir state key - dir=$(make_supercase stale-herdr-idle-busy-footer) +# A herdr crew whose native agent.get reads idle (generation state) but whose +# own semantic busy-state record says busy is still working, so its stale +# marker clears without escalating. The record - not the pane's rendered +# footer - is what proves it. +test_housekeeping_herdr_idle_busy_record_clears_stale() { + local dir state key gen + dir=$(make_supercase stale-herdr-idle-busy-record) state="$dir/state" - fm_write_meta "$state/herdr-footer.meta" "window=default:w1:p4" "backend=herdr" + fm_write_meta "$state/herdr-footer.meta" "window=default:w1:p4" "backend=herdr" "harness=claude" printf 'working\n' > "$state/herdr-footer.status" + gen=$("$ROOT/bin/fm-busy-event.sh" arm "$state" herdr-footer) + "$ROOT/bin/fm-busy-event.sh" apply "$state" herdr-footer busy --gen "$gen" \ + --source claude-hook --event user-prompt-submit key=$(printf '%s' "herdr-footer" | tr ':/.' '___') echo $(( $(date +%s) - 500 )) > "$state/.subsuper-stale-$key" ( fm_backend_capture() { [ "$1" = herdr ] || fail "expected herdr capture backend, got $1" [ "$2" = "default:w1:p4" ] || fail "expected herdr window target, got $2" - printf 'esc to interrupt\n' + printf 'quiet\n' } fm_backend_busy_state() { [ "$1" = herdr ] || fail "expected herdr busy backend, got $1" @@ -503,8 +522,8 @@ test_housekeeping_herdr_idle_busy_footer_clears_stale() { [ "$(fm_backend_busy_state herdr default:w1:p4)" = idle ] || fail "herdr busy stub did not report idle" FM_STATE_OVERRIDE="$state" FM_STALE_ESCALATE_SECS=240 housekeeping "$state" ) || fail "herdr idle busy-footer housekeeping failed" - [ ! -e "$state/.subsuper-stale-$key" ] || fail "idle+busy-footer herdr stale marker was not cleared" - [ ! -s "$state/.subsuper-escalations" ] || fail "idle+busy-footer herdr stale was escalated" + [ ! -e "$state/.subsuper-stale-$key" ] || fail "idle-native busy-record herdr stale marker was not cleared" + [ ! -s "$state/.subsuper-escalations" ] || fail "idle-native busy-record herdr stale was escalated" pass "herdr idle busy-footer stale clears through capture corroboration" } @@ -732,7 +751,6 @@ test_busy_guard_defers_when_supervisor_busy() { fakebin="$dir/fakebin" sent="$dir/sent.log"; : > "$sent" capture="$dir/pane.txt" - # pane shows a busy signature (firstmate mid-turn) printf 'esc to interrupt\n' > "$capture" escalate_add "$state" "done: PR 1" afk_enter "$state" @@ -1647,40 +1665,38 @@ test_discover_supervisor_target_herdr() { } test_pane_is_busy_herdr_native_busy_state() { + local dir + dir=$(make_supercase primary-herdr-busy) ( fm_backend_busy_state() { [ "$1" = herdr ] && [ "$2" = "default:w1:p2" ] || fail "unexpected busy_state args: $1 $2"; printf 'busy'; } fm_backend_capture() { fail "capture should not be consulted when busy_state is conclusive"; } - pane_is_busy "default:w1:p2" herdr || fail "pane_is_busy should report busy from herdr's native busy_state" + FM_STATE_OVERRIDE="$dir/state" FM_DAEMON_PRIMARY_HARNESS=claude pane_is_busy "default:w1:p2" herdr \ + || fail "pane_is_busy should report busy from herdr's native busy_state" ) || fail "herdr native-busy pane_is_busy subshell failed" pass "pane_is_busy: herdr native busy_state='busy' short-circuits without a capture fallback" } -test_pane_is_busy_herdr_falls_back_to_capture_regex() { +test_primary_busy_guard_is_harness_scoped() { ( fm_backend_busy_state() { printf 'unknown'; } - fm_backend_capture() { [ "$1" = herdr ] && [ "$2" = "default:w1:p2" ] || fail "unexpected capture args: $1 $2"; printf 'esc to interrupt\n'; } - pane_is_busy "default:w1:p2" herdr || fail "pane_is_busy should fall back to the regex-over-capture reader when busy_state is unknown" - ) || fail "herdr capture-fallback pane_is_busy subshell failed" - pass "pane_is_busy: herdr falls back to the shared regex-over-capture reader when native busy_state is unknown" -} - -test_pane_is_busy_herdr_idle_falls_back_to_capture_regex() { - ( - fm_backend_busy_state() { printf 'idle'; } - fm_backend_capture() { [ "$1" = herdr ] && [ "$2" = "default:w1:p2" ] || fail "unexpected capture args: $1 $2"; printf 'esc to interrupt\n'; } - pane_is_busy "default:w1:p2" herdr || fail "pane_is_busy should fall back to the regex-over-capture reader when busy_state is idle" - ) || fail "herdr idle capture-fallback pane_is_busy subshell failed" - pass "pane_is_busy: herdr corroborates native idle with the shared regex-over-capture reader" + fm_backend_capture() { printf 'esc interrupt\n'; } + if FM_DAEMON_PRIMARY_HARNESS=claude pane_is_busy "default:w1:p2" herdr; then + fail "OpenCode's rendered signature must not classify a Claude primary busy" + fi + FM_DAEMON_PRIMARY_HARNESS=opencode pane_is_busy "default:w1:p2" herdr \ + || fail "OpenCode's rendered signature should classify an OpenCode primary busy" + ) || fail "harness-scoped primary busy guard subshell failed" + pass "primary busy guard isolates rendered signatures by detected harness" } test_pane_is_busy_defaults_to_tmux_when_backend_omitted() { local dir fakebin capture dir=$(make_supercase busy-default-backend) fakebin="$dir/fakebin"; capture="$dir/pane.txt" - printf 'esc to interrupt\n' > "$capture" - PATH="$fakebin:$PATH" FM_FAKE_TMUX_CAPTURE="$capture" pane_is_busy "fakepane" \ + printf 'Ctrl+c:cancel\n' > "$capture" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_CAPTURE="$capture" FM_STATE_OVERRIDE="$dir/state" FM_DAEMON_PRIMARY_HARNESS=grok pane_is_busy "fakepane" \ || fail "pane_is_busy with no backend arg should still default to tmux" - pass "pane_is_busy: omitted backend arg defaults to tmux (pre-existing callers unaffected)" + pass "pane_is_busy: omitted backend defaults to tmux for Grok's isolated fallback" } test_pane_input_pending_herdr_dispatch() { @@ -1709,8 +1725,7 @@ test_inject_msg_herdr_busy_guard_defers() { afk_enter "$state" ( fm_backend_target_exists() { [ "$1" = herdr ] && [ "$2" = "default:w1:p2" ] || fail "unexpected target_exists args: $1 $2"; return 0; } - fm_backend_busy_state() { printf 'busy'; } - fm_backend_capture() { fail "capture should not run when busy_state is conclusive"; } + pane_is_busy() { return 0; } fm_backend_composer_state() { fail "composer_state should not be consulted once the busy-guard already deferred"; } fm_backend_send_text_submit() { fail "send_text_submit should not run when the busy-guard defers"; } if FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="default:w1:p2" inject_msg "hello" "$state"; then @@ -1727,8 +1742,7 @@ test_inject_msg_herdr_composer_guard_defers() { afk_enter "$state" ( fm_backend_target_exists() { return 0; } - fm_backend_busy_state() { printf 'idle'; } - fm_backend_capture() { printf 'idle prompt\n'; } + pane_is_busy() { return 1; } fm_backend_composer_state() { [ "$1" = herdr ] && [ "$2" = "default:w1:p2" ] || fail "unexpected composer_state args: $1 $2"; printf 'pending'; } fm_backend_send_text_submit() { fail "send_text_submit should not run when the composer-guard defers"; } if FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="default:w1:p2" inject_msg "hello" "$state"; then @@ -1745,7 +1759,7 @@ test_inject_msg_herdr_pane_gone_defers() { afk_enter "$state" ( fm_backend_target_exists() { return 1; } - fm_backend_busy_state() { fail "busy_state should not be consulted once the pane-exists check already failed"; } + pane_is_busy() { fail "busy guard should not be consulted once the pane-exists check already failed"; } fm_backend_send_text_submit() { fail "send_text_submit should not run when the pane does not exist"; } if FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="default:w1:gone" inject_msg "hello" "$state"; then fail "inject_msg should defer when the herdr target does not exist" @@ -1761,8 +1775,7 @@ test_inject_msg_herdr_submits_through_backend_dispatch() { afk_enter "$state" ( fm_backend_target_exists() { return 0; } - fm_backend_busy_state() { printf 'idle'; } - fm_backend_capture() { printf 'idle prompt\n'; } + pane_is_busy() { return 1; } fm_backend_composer_state() { printf 'empty'; } fm_backend_send_text_submit() { [ "$1" = herdr ] && [ "$2" = "default:w1:p2" ] || fail "unexpected send_text_submit args: $1 $2" @@ -1787,8 +1800,7 @@ test_inject_msg_defers_on_dead_shell_unknown() { afk_enter "$state" ( fm_backend_target_exists() { return 0; } - fm_backend_busy_state() { printf 'idle'; } - fm_backend_capture() { printf '$ \n'; } + pane_is_busy() { return 1; } fm_backend_composer_state() { printf 'unknown'; } fm_backend_send_text_submit() { fail "send_text_submit must NOT run when the composer is a dead shell (unknown)"; } if FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="default:w1:p2" inject_msg "hello" "$state"; then @@ -1805,8 +1817,7 @@ test_inject_msg_defers_on_unrecognized_composer_state() { afk_enter "$state" ( fm_backend_target_exists() { return 0; } - fm_backend_busy_state() { printf 'idle'; } - fm_backend_capture() { printf 'idle prompt\n'; } + pane_is_busy() { return 1; } fm_backend_composer_state() { printf 'future-state'; } fm_backend_send_text_submit() { fail "send_text_submit must not run for an unrecognized composer state"; } if FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="default:w1:p2" inject_msg "hello" "$state"; then @@ -1841,7 +1852,7 @@ test_housekeeping_paused_unpaused_cleared test_housekeeping_stale_marker_transitions_to_pause test_housekeeping_pause_marker_transitions_to_clear test_housekeeping_herdr_persistent_stale_resolves_meta -test_housekeeping_herdr_idle_busy_footer_clears_stale +test_housekeeping_herdr_idle_busy_record_clears_stale test_housekeeping_herdr_resumed_stale_cleared test_housekeeping_orca_persistent_stale_resolves_terminal test_escalate_batches_into_one_digest @@ -1906,8 +1917,7 @@ test_fm_send_exits_nonzero_on_unproven_submit test_discover_supervisor_backend_precedence test_discover_supervisor_target_herdr test_pane_is_busy_herdr_native_busy_state -test_pane_is_busy_herdr_falls_back_to_capture_regex -test_pane_is_busy_herdr_idle_falls_back_to_capture_regex +test_primary_busy_guard_is_harness_scoped test_pane_is_busy_defaults_to_tmux_when_backend_omitted test_pane_input_pending_herdr_dispatch test_inject_msg_herdr_busy_guard_defers diff --git a/tests/fm-direct-lifecycle.test.sh b/tests/fm-direct-lifecycle.test.sh index 68ffe0af947..d1f175affa6 100755 --- a/tests/fm-direct-lifecycle.test.sh +++ b/tests/fm-direct-lifecycle.test.sh @@ -165,7 +165,32 @@ case "${1:-}" in esac exit 0 ;; - list-windows|has-session|new-session|set-window-option|kill-window|send-keys) + list-windows|has-session|new-session|set-window-option|kill-window) + exit 0 + ;; + capture-pane) + cat "$FM_TEST_TMUX_LOG.spawn-screen" 2>/dev/null || true + exit 0 + ;; + send-keys) + screen="$FM_TEST_TMUX_LOG.spawn-screen" + staged="$screen.staged" + text=${4:-} + case "$text" in + *"__FM_SPAWN_READY_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$screen" + ;; + "FM_SPAWN_LAUNCH=''") : > "$staged" ;; + FM_SPAWN_LAUNCH=*) + rebuilt=$(FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") + printf '%s' "$rebuilt" > "$staged" + ;; + *"__FM_SPAWN_LAUNCH_OK_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\n' "$token" > "$screen" + ;; + esac exit 0 ;; esac diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index 56af41e11c1..f47c70f2fa8 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -62,8 +62,15 @@ make_home() { # <name> printf '%s\n' "$home" } +record_claude_idle() { # <state-dir> <id> + local state=$1 id=$2 gen + gen=$("$ROOT/bin/fm-busy-event.sh" arm "$state" "$id") + "$ROOT/bin/fm-busy-event.sh" apply "$state" "$id" idle --gen "$gen" \ + --source claude-hook --event stop +} + write_fixture() { # <home> - local home=$1 + local home=$1 fixture_gen mkdir -p "$home/projects/alpha-worktree" "$home/projects/scout-worktree" "$home/secondmate-home" cat > "$home/data/backlog.md" <<EOF ## In flight @@ -84,12 +91,18 @@ EOF "window=firstmate:fm-ship-task" \ "worktree=$home/projects/alpha-worktree" \ "project=alpha" \ - "harness=codex" \ + "harness=claude" \ "kind=ship" \ "mode=ship" \ "yolo=off" \ "pr=https://github.com/kunchenguid/firstmate/pull/9" printf 'needs-decision: choose an API shape\n' > "$home/state/ship-task.status" + # A working ship task proves it through its own semantic busy-state record + # (bin/fm-busy-lib.sh), which is what the snapshot's current-state read + # consults; rendered pane text is no longer a state source. + fixture_gen=$("$ROOT/bin/fm-busy-event.sh" arm "$home/state" ship-task) + "$ROOT/bin/fm-busy-event.sh" apply "$home/state" ship-task busy --gen "$fixture_gen" \ + --source claude-hook --event user-prompt-submit fm_write_meta "$home/state/scout-task.meta" \ "window=firstmate:fm-scout-task" \ "worktree=$home/projects/scout-worktree" \ @@ -343,7 +356,7 @@ EOF } test_event_hints_follow_reconciled_current_state() { - local home fakebin out + local home fakebin out hint_gen home=$(make_home event-hints) mkdir -p \ "$home/projects/active-decision" \ @@ -354,33 +367,41 @@ test_event_hints_follow_reconciled_current_state() { "window=firstmate:fm-active-decision" \ "worktree=$home/projects/active-decision" \ "project=alpha" \ - "harness=codex" \ + "harness=claude" \ "kind=ship" \ "mode=ship" + record_claude_idle "$home/state" active-decision printf 'needs-decision: choose an API shape\n' > "$home/state/active-decision.status" fm_write_meta "$home/state/active-blocked.meta" \ "window=firstmate:fm-active-blocked" \ "worktree=$home/projects/active-blocked" \ "project=alpha" \ - "harness=codex" \ + "harness=claude" \ "kind=ship" \ "mode=ship" + record_claude_idle "$home/state" active-blocked printf 'blocked: waiting on access\n' > "$home/state/active-blocked.status" fm_write_meta "$home/state/stale-decision.meta" \ "window=firstmate:fm-stale-decision-ship-task" \ "worktree=$home/projects/stale-decision" \ "project=alpha" \ - "harness=codex" \ + "harness=claude" \ "kind=ship" \ "mode=ship" + hint_gen=$("$ROOT/bin/fm-busy-event.sh" arm "$home/state" stale-decision) + "$ROOT/bin/fm-busy-event.sh" apply "$home/state" stale-decision busy --gen "$hint_gen" \ + --source claude-hook --event user-prompt-submit printf 'needs-decision: already answered\n' > "$home/state/stale-decision.status" fm_write_meta "$home/state/stale-blocked.meta" \ "window=firstmate:fm-stale-blocked-ship-task" \ "worktree=$home/projects/stale-blocked" \ "project=alpha" \ - "harness=codex" \ + "harness=claude" \ "kind=ship" \ "mode=ship" + hint_gen=$("$ROOT/bin/fm-busy-event.sh" arm "$home/state" stale-blocked) + "$ROOT/bin/fm-busy-event.sh" apply "$home/state" stale-blocked busy --gen "$hint_gen" \ + --source claude-hook --event user-prompt-submit printf 'blocked: old failure\n' > "$home/state/stale-blocked.status" fakebin=$(make_fakebin "$home") out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$SNAPSHOT" --json) @@ -447,9 +468,10 @@ EOF "window=firstmate:fm-bold-task" \ "worktree=$projects/bold-worktree" \ "project=alpha" \ - "harness=codex" \ + "harness=claude" \ "kind=scout" \ "mode=scout" + record_claude_idle "$home/state" bold-task printf 'done: report ready\n' > "$home/state/bold-task.status" fakebin=$(make_fakebin "$home") out=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_DATA_OVERRIDE="$data" FM_PROJECTS_OVERRIDE="$projects" "$SNAPSHOT" --json) @@ -709,9 +731,10 @@ test_completed_scout_report_is_pointer_not_pending() { "window=firstmate:fm-lavish-103" \ "worktree=$home/projects/scout-wt" \ "project=firstmate" \ - "harness=codex" \ + "harness=claude" \ "kind=scout" \ "mode=scout" + record_claude_idle "$home/state" lavish-103 # Stale needs-decision, then the scout finished (done). No keyed resolution. printf 'needs-decision: adopt approach A or B for Lavish issue 103\n' > "$home/state/lavish-103.status" printf 'done: report ready at data/lavish-103/report.md\n' >> "$home/state/lavish-103.status" @@ -740,9 +763,10 @@ test_parked_scout_decision_stays_pending() { "window=firstmate:fm-parked-scout" \ "worktree=$home/projects/scout-wt2" \ "project=firstmate" \ - "harness=codex" \ + "harness=claude" \ "kind=scout" \ "mode=scout" + record_claude_idle "$home/state" parked-scout printf 'needs-decision [key=q1]: adopt approach A or B\n' > "$home/state/parked-scout.status" fakebin=$(make_fakebin "$home") out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$SNAPSHOT" --json) diff --git a/tests/fm-gate-refuse.test.sh b/tests/fm-gate-refuse.test.sh index aff57ab18e1..a1b8292786d 100755 --- a/tests/fm-gate-refuse.test.sh +++ b/tests/fm-gate-refuse.test.sh @@ -147,7 +147,29 @@ esac case "${1:-}" in display-message) printf 'firstmate\n'; exit 0 ;; list-windows) exit 0 ;; - has-session|new-session|new-window|send-keys|set-window-option) exit 0 ;; + has-session|new-session|new-window|set-window-option) exit 0 ;; + capture-pane) cat "$(dirname "$0")/.spawn-screen" 2>/dev/null || true; exit 0 ;; + send-keys) + screen="$(dirname "$0")/.spawn-screen" + staged="$screen.staged" + text=${4:-} + case "$text" in + *"__FM_SPAWN_READY_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$screen" + ;; + "FM_SPAWN_LAUNCH=''") : > "$staged" ;; + FM_SPAWN_LAUNCH=*) + rebuilt=$(FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") + printf '%s' "$rebuilt" > "$staged" + ;; + *"__FM_SPAWN_LAUNCH_OK_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\n' "$token" > "$screen" + ;; + esac + exit 0 + ;; esac exit 0 SH diff --git a/tests/fm-gotmp.test.sh b/tests/fm-gotmp.test.sh index 03b66d4c6a1..ed4f2fd5c0d 100755 --- a/tests/fm-gotmp.test.sh +++ b/tests/fm-gotmp.test.sh @@ -67,6 +67,13 @@ make_fake_root() { # fm-remote-lib.sh: teardown sources it to resolve the publish remote for its # landed-work check. ln -s "$ROOT/bin/fm-remote-lib.sh" "$fake/bin/fm-remote-lib.sh" + # fm-public-followup-lib.sh (and the fm-x-lib.sh it sources): teardown sources + # it for the relay-activation gate on the promised-public-reply check. Neither + # does anything in this fixture, which has no .env, but both are real siblings + # teardown now requires. + ln -s "$ROOT/bin/fm-public-followup-lib.sh" "$fake/bin/fm-public-followup-lib.sh" + ln -s "$ROOT/bin/fm-x-lib.sh" "$fake/bin/fm-x-lib.sh" + ln -s "$ROOT/bin/fm-secondmate-registry-lib.sh" "$fake/bin/fm-secondmate-registry-lib.sh" # fm-guard.sh: stub (teardown calls it with `|| true`). cat > "$fake/bin/fm-guard.sh" <<'SH' #!/usr/bin/env bash @@ -136,6 +143,13 @@ test_teardown_skips_gracefully_without_tasktmp() { # fm-remote-lib.sh: teardown sources it to resolve the publish remote for its # landed-work check. ln -s "$ROOT/bin/fm-remote-lib.sh" "$fake/bin/fm-remote-lib.sh" + # fm-public-followup-lib.sh (and the fm-x-lib.sh it sources): teardown sources + # it for the relay-activation gate on the promised-public-reply check. Neither + # does anything in this fixture, which has no .env, but both are real siblings + # teardown now requires. + ln -s "$ROOT/bin/fm-public-followup-lib.sh" "$fake/bin/fm-public-followup-lib.sh" + ln -s "$ROOT/bin/fm-x-lib.sh" "$fake/bin/fm-x-lib.sh" + ln -s "$ROOT/bin/fm-secondmate-registry-lib.sh" "$fake/bin/fm-secondmate-registry-lib.sh" cat > "$fake/bin/fm-guard.sh" <<'SH' #!/usr/bin/env bash exit 0 diff --git a/tests/fm-grok-harness.test.sh b/tests/fm-grok-harness.test.sh index edb5001b9a3..919bb51e605 100755 --- a/tests/fm-grok-harness.test.sh +++ b/tests/fm-grok-harness.test.sh @@ -15,13 +15,42 @@ make_spawn_fakebin() { cat > "$fakebin/tmux" <<'SH' #!/usr/bin/env bash set -u +screen=${FM_FAKE_SPAWN_SCREEN:?} +staged_launch=${FM_FAKE_STAGED_LAUNCH:?} case "$*" in *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; esac case "${1:-}" in display-message) printf 'firstmate\n'; exit 0 ;; list-windows) exit 0 ;; - has-session|new-session|new-window|send-keys|kill-window) exit 0 ;; + has-session|new-session|new-window|kill-window) exit 0 ;; + capture-pane) cat "$screen"; exit 0 ;; + send-keys) + text=${4:-} + case "$text" in + *"__FM_SPAWN_READY_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$screen" + exit 0 + ;; + "FM_SPAWN_LAUNCH=''") + : > "$staged_launch" + exit 0 + ;; + FM_SPAWN_LAUNCH=*) + staged=$(FM_SPAWN_LAUNCH="$(cat "$staged_launch")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") + printf '%s' "$staged" > "$staged_launch" + exit 0 + ;; + *"__FM_SPAWN_LAUNCH_OK_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\n' "$token" > "$screen" + exit 0 + ;; + 'eval "$FM_SPAWN_LAUNCH"') exit 0 ;; + esac + exit 0 + ;; esac exit 0 SH @@ -47,11 +76,16 @@ make_spawn_case() { } run_grok_spawn() { - local home=$1 proj=$2 wt=$3 fakebin=$4 grok_home=$5 id=$6 + local home=$1 proj=$2 wt=$3 fakebin=$4 grok_home=$5 id=$6 screen staged_launch + screen="$home/state/$id.spawn.screen" + staged_launch="$home/state/$id.staged-launch" + : > "$screen" + : > "$staged_launch" FM_ROOT_OVERRIDE='' FM_HOME="$home" \ FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \ FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$wt" TMUX="fake,1,0" \ + FM_FAKE_SPAWN_SCREEN="$screen" FM_FAKE_STAGED_LAUNCH="$staged_launch" \ GROK_HOME="$grok_home" PATH="$fakebin:$PATH" \ "$SPAWN" "$id" "$proj" grok 2>&1 } diff --git a/tests/fm-herdr-session-cleanup-e2e.test.sh b/tests/fm-herdr-session-cleanup-e2e.test.sh index 7a4a49aa008..e45c1018342 100755 --- a/tests/fm-herdr-session-cleanup-e2e.test.sh +++ b/tests/fm-herdr-session-cleanup-e2e.test.sh @@ -64,7 +64,7 @@ lab() { env PATH="$HERDR_ORIGINAL_PATH" "$HERDR_LAB_HELPER" run "$HERDR_LAB_SESS production_process_proof() { FM_HOME="$HOME_DIR" FM_BACKEND=herdr HERDR_SESSION="$HERDR_LAB_SESSION" \ FM_HERDR_SESSION_CLEANUP_SOURCE_ONLY=1 PATH="$FAKEBIN:$HERDR_ORIGINAL_PATH" \ - bash -c '. "$1"; fm_herdr_cleanup_process_is_idle_shell "$2" "$3"' \ + bash -c '. "$1"; fm_backend_herdr_pane_idle_shell_pid "$2" "$3" >/dev/null' \ _ "$ROOT/bin/fm-herdr-session-cleanup.sh" "$HERDR_LAB_SESSION" "$PANE" } focus_snapshot() { diff --git a/tests/fm-herdr-session-cleanup.test.sh b/tests/fm-herdr-session-cleanup.test.sh index f4c1df153b4..99cf4b49a1c 100755 --- a/tests/fm-herdr-session-cleanup.test.sh +++ b/tests/fm-herdr-session-cleanup.test.sh @@ -25,12 +25,32 @@ export FM_HERDR_SESSION_CLEANUP_SOURCE_ONLY=1 . "$ROOT/bin/fm-herdr-session-cleanup.sh" unset FM_HERDR_SESSION_CLEANUP_SOURCE_ONLY -LINUX_PROCESS_INFO='{"result":{"process_info":{"foreground_processes":[{"argv":["/bin/sh"],"name":"sh","pid":67}]}}}' -[ "$(fm_herdr_cleanup_process_argv0 "$LINUX_PROCESS_INFO")" = /bin/sh ] \ - || fail "Linux Herdr process argv array was not accepted" -if fm_herdr_cleanup_process_argv0 \ - '{"result":{"process_info":{"foreground_processes":[{"argv":[67],"name":"sh","pid":67}]}}}' \ - >/dev/null 2>&1; then +# The idle-shell proof now lives in the backend as +# fm_backend_herdr_pane_idle_shell_pid; prove it still reads Linux argv +# arrays (no argv0 field) and rejects malformed executable identities. +FAKE_PS="$TMP_ROOT/fake-ps" +cat > "$FAKE_PS" <<'SH' +#!/usr/bin/env bash +case "$*" in + "-axo pid=,ppid=") printf '1 0\n67 1\n' ;; + "-p 67 -o stat=") printf 'Ss\n' ;; + *) exit 1 ;; +esac +SH +chmod +x "$FAKE_PS" +LINUX_PROCESS_INFO='{"result":{"type":"pane_process_info","process_info":{"pane_id":"w2:p1","shell_pid":67,"foreground_process_group_id":67,"foreground_processes":[{"argv":["/bin/sh"],"name":"sh","pid":67}]}}}' +argv_pid=$( + # shellcheck disable=SC2329 # invoked indirectly by the idle-shell proof. + fm_backend_herdr_cli() { printf '%s\n' "$LINUX_PROCESS_INFO"; } + FM_HERDR_PS_BIN="$FAKE_PS" fm_backend_herdr_pane_idle_shell_pid test w2:p1 +) || fail "Linux Herdr process argv array was not accepted" +[ "$argv_pid" = 67 ] || fail "idle-shell proof printed the wrong shell pid: $argv_pid" +if ( + # shellcheck disable=SC2329 # invoked indirectly by the idle-shell proof. + fm_backend_herdr_cli() { printf '%s\n' '{"result":{"type":"pane_process_info","process_info":{"pane_id":"w2:p1","shell_pid":67,"foreground_process_group_id":67,"foreground_processes":[{"argv":[67],"name":"sh","pid":67}]}}}'; } + FM_HERDR_PS_BIN="$FAKE_PS" FM_BACKEND_HERDR_IDLE_SHELL_PROOF_POLLS=1 \ + fm_backend_herdr_pane_idle_shell_pid test w2:p1 +) >/dev/null 2>&1; then fail "non-string Herdr process argv was accepted" fi pass "process proof reads Linux Herdr argv arrays and rejects malformed executable identities" @@ -54,7 +74,7 @@ fm_lock_try_acquire() { mkdir "$1" 2>/dev/null } fm_lock_release() { rm -rf -- "$1"; } -fm_herdr_cleanup_process_is_idle_shell() { [ ! -e "$FIXTURE_DIR/process-unsafe" ]; } +fm_backend_herdr_pane_idle_shell_pid() { [ ! -e "$FIXTURE_DIR/process-unsafe" ] && printf '67\n'; } fm_backend_herdr_projection_focus_snapshot() { [ ! -e "$FIXTURE_DIR/focus-unreadable" ] || return 1 printf 'w1\t%s' "$(cat "$FIXTURE_DIR/active-tab")" diff --git a/tests/fm-kimi-harness.test.sh b/tests/fm-kimi-harness.test.sh index 105a5a9219a..3a04394586c 100755 --- a/tests/fm-kimi-harness.test.sh +++ b/tests/fm-kimi-harness.test.sh @@ -650,7 +650,7 @@ test_kimi_busy_signature_is_scoped_to_spinner_lines() { pass "busy detection: real Kimi moon-plus-middot captures require its harness while idle labels stay idle" } -test_watcher_scopes_moon_spinner_to_recorded_kimi_task() ( +test_watcher_never_classifies_kimi_from_its_spinner() ( local state="$TMP_ROOT/watch-state" busy_capture=' 🌑 · Tip: ask Kimi to schedule tasks, e.g. "remind me at 5pm"' mkdir -p "$state" printf 'window=fake\nharness=kimi\n' > "$state/kimi-watch.meta" @@ -662,26 +662,26 @@ test_watcher_scopes_moon_spinner_to_recorded_kimi_task() ( . "$ROOT/bin/fm-watch.sh" # shellcheck disable=SC2329 # Runtime override called by the sourced watcher. fm_backend_busy_state() { printf 'unknown'; } - window_is_busy fake "$busy_capture" \ - || fail "fm-watch did not recognize the real Kimi spinner-line shape" - printf 'window=fake\nharness=codex\n' > "$state/kimi-watch.meta" + # Standalone Kimi has no verified semantic busy source, so it classifies + # unknown - and unknown is never working. Its moon-phase spinner is + # deliberately not a state source: the approved redesign forbids inventing a + # Kimi UI signature, and that glyph set is locale- and emoji-font-sensitive. if window_is_busy fake "$busy_capture"; then - fail "fm-watch applied Kimi's real spinner signature to a recorded Codex task" - fi - printf 'window=fake\nharness=kimi\n' > "$state/kimi-watch.meta" - if window_is_busy fake 'ordinary response ending with 🌕'; then - fail "fm-watch treated an ordinary Kimi moon as a spinner line" - fi - if window_is_busy fake '🌕 Full moon details'; then - fail "fm-watch treated moon-led Kimi output without the middot separator as busy" + fail "fm-watch classified a Kimi task busy from its spinner instead of unknown" fi - if window_is_busy fake 'auto K2.7 Coding thinking /some/path'; then - fail "fm-watch treated Kimi's idle thinking-effort status label as busy" + [ "$(fm_busy_classify tmux fake kimi kimi-watch "$state" "$busy_capture")" = "unknown kimi-unverified" ] \ + || fail "a Kimi task must classify unknown kimi-unverified" + printf 'window=fake\nharness=codex\n' > "$state/kimi-watch.meta" + if window_is_busy fake "$busy_capture"; then + fail "fm-watch applied Kimi's spinner to a recorded Codex task" fi - if window_is_busy fake 'Ctrl+c:cancel'; then - fail "fm-watch let Grok's exact busy token classify a recorded Kimi task busy" + printf 'window=fake\nharness=grok\n' > "$state/kimi-watch.meta" + if window_is_busy fake "$busy_capture"; then + fail "Kimi's spinner classified a recorded Grok task through its isolated fallback" fi - pass "fm-watch: Kimi spinner matching is metadata-scoped and ignores Grok's busy token" + window_is_busy fake 'Ctrl+c:cancel' \ + || fail "Grok's own verified token must still classify a recorded Grok task busy" + pass "fm-watch classifies Kimi as unknown rather than from its spinner, and Grok's fallback stays isolated" ) test_kimi_bordered_prompt_needs_no_override() { @@ -710,5 +710,5 @@ test_kimi_readiness_gate_precedes_pointer test_kimi_detection_uses_ancestry_after_markers test_kimi_session_lock_identity test_kimi_busy_signature_is_scoped_to_spinner_lines -test_watcher_scopes_moon_spinner_to_recorded_kimi_task +test_watcher_never_classifies_kimi_from_its_spinner test_kimi_bordered_prompt_needs_no_override diff --git a/tests/fm-lint.test.sh b/tests/fm-lint.test.sh index b4c0e014407..5702dda2d84 100755 --- a/tests/fm-lint.test.sh +++ b/tests/fm-lint.test.sh @@ -18,11 +18,7 @@ set -u . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" LINT="$ROOT/bin/fm-lint.sh" -CI="$ROOT/.github/workflows/ci.yml" -NM="$ROOT/.no-mistakes.yaml" INSTALLER="$ROOT/bin/fm-install-shellcheck.sh" -# The authoritative file set the one owner must run. -CANON='ROOTS=(bin/*.sh bin/backends/*.sh tests/*.sh)' # The pinned version, read from the single source (the one owner itself). REQUIRED=$("$LINT" --required-version) @@ -33,48 +29,13 @@ pinned_ready() { [ "$(shellcheck --version | awk '/^version:/ {print $2; exit}')" = "$REQUIRED" ] } -test_owner_exists_and_executable() { - assert_present "$LINT" "bin/fm-lint.sh is missing" - [ -x "$LINT" ] || fail "bin/fm-lint.sh must be executable so CI/gate can run it directly" - pass "one-owner lint script exists and is executable" -} - -test_owner_defines_canonical_set() { - assert_grep "$CANON" "$LINT" "fm-lint.sh must run the canonical shellcheck file set" - # It must not weaken CI: no severity downgrade and no blanket disable/exclude - # that would hide findings CI fails on. - assert_no_grep '--severity' "$LINT" "fm-lint.sh must not lower severity below the CI default" - assert_no_grep '--exclude' "$LINT" "fm-lint.sh must not blanket-exclude checks CI enforces" - assert_grep "\"\$FM_LINT_SHELLCHECK\" --norc --external-sources -- \"\${roots[@]}\"" "$LINT" "every bounded worker must ignore ambient config and preserve annotated production sources" - [ "$(grep -Fc -- '--norc --external-sources' "$LINT")" -eq 1 ] || fail "the one worker command must own ShellCheck configuration" - assert_grep "JOBS=\${FM_LINT_JOBS:-2}" "$LINT" "canonical lint must default to two bounded workers" - pass "fm-lint.sh is the sole authoritative definition at CI-default severity" -} - -test_ci_invokes_the_owner() { - grep -Eq '^ - run: bin/fm-lint\.sh$' "$CI" || fail "CI lint job must invoke the one-owner script as a run step" - # Guard against regression to an inline re-spelling of the command. - assert_no_grep 'run: shellcheck' "$CI" "CI must call fm-lint.sh, not re-spell shellcheck inline" - pass "CI lint job calls the one-owner script, not an inline command" -} - -test_stock_bash_parse_uses_owner_inventory() { +test_list_files_reports_the_shell_inventory() { local listed expected listed=$("$LINT" --list-files) expected=$(find bin bin/backends tests -maxdepth 1 -type f -name '*.sh' -print | LC_ALL=C sort) [ "$(printf '%s\n' "$listed" | LC_ALL=C sort)" = "$expected" ] \ - || fail "fm-lint.sh --list-files did not return the complete canonical shell inventory" - # shellcheck disable=SC2016 # Literal assertion must remain unexpanded. - assert_grep 'bin/fm-lint.sh --list-files > "$shell_inventory"' "$CI" \ - "stock macOS Bash parse sweep must consume fm-lint.sh's canonical inventory" - assert_no_grep 'for f in bin/*.sh bin/backends/*.sh tests/*.sh' "$CI" \ - "stock macOS Bash parse sweep must not duplicate the canonical inventory" - pass "stock macOS Bash parse sweep consumes the canonical lint inventory" -} - -test_nomistakes_invokes_the_owner() { - grep -Fqx " lint: 'bin/fm-lint.sh'" "$NM" || fail "no-mistakes commands.lint must map exactly to the one-owner script" - pass "no-mistakes pre-push lint calls the one-owner script" + || fail "fm-lint.sh --list-files did not return the complete shell inventory" + pass "fm-lint.sh --list-files reports the complete shell inventory" } test_pins_an_explicit_version() { @@ -85,17 +46,6 @@ test_pins_an_explicit_version() { pass "fm-lint.sh pins an explicit ShellCheck version ($REQUIRED)" } -test_ci_installs_and_logs_the_pinned_version() { - # CI must derive the version from the one owner (never hardcode a divergent - # number) and log the resolved version as parity evidence. - assert_grep "VERSION=\"\$(\"\$ROOT/bin/fm-lint.sh\" --required-version)\"" "$INSTALLER" "installer must read the version fm-lint.sh pins" - [ "$(grep -Fc "bin/fm-install-shellcheck.sh \"\$RUNNER_TEMP/bin\"" "$CI")" -eq 4 ] || fail "lint and all three portable behavior jobs must use the shared ShellCheck installer" - assert_grep "ACTUAL_SHA256=\$(sha256sum" "$INSTALLER" "installer must calculate the ShellCheck archive checksum" - assert_grep "[ \"\$ACTUAL_SHA256\" = \"\$SHA256\" ]" "$INSTALLER" "installer must verify the ShellCheck archive checksum" - assert_grep "\"\$DESTINATION/shellcheck\" --version" "$INSTALLER" "installer must log the resolved ShellCheck version as evidence" - pass "CI installs and logs the pinned ShellCheck version from the one owner" -} - test_installer_retries_transient_download_failure() { local tmp fakebin destination out tmp=$(fm_test_tmproot fm-shellcheck-download) @@ -496,19 +446,13 @@ SH pass "seeded dispatcher, adapter, production-owner, and test-local diagnostics preserve parity" } -test_owner_exists_and_executable -test_owner_defines_canonical_set -test_ci_invokes_the_owner -test_stock_bash_parse_uses_owner_inventory -test_nomistakes_invokes_the_owner +test_list_files_reports_the_shell_inventory test_pins_an_explicit_version -test_ci_installs_and_logs_the_pinned_version test_installer_retries_transient_download_failure test_rejects_wrong_shellcheck_version test_catches_a_real_lint_defect test_ignores_ambient_shellcheck_opts test_clean_fixture_passes -test_source_graph_boundaries_keep_every_owner test_jobs_are_deterministic_and_complete test_worker_trees_stop_on_signal test_seeded_module_boundary_parity diff --git a/tests/fm-pi-primary-live-e2e.test.sh b/tests/fm-pi-primary-live-e2e.test.sh index a365f06b9ca..63f3cb8abb9 100755 --- a/tests/fm-pi-primary-live-e2e.test.sh +++ b/tests/fm-pi-primary-live-e2e.test.sh @@ -254,6 +254,7 @@ cp "$ROOT/.pi/extensions/fm-primary-pi-watch.ts" "$PROJECT/.pi/extensions/fm-pri cp "$ROOT/.pi/extensions/lib/fm-calm-assistant-layout.ts" "$PROJECT/.pi/extensions/lib/fm-calm-assistant-layout.ts" cp "$ROOT/.pi/extensions/lib/fm-calm-operational-user-layout.ts" "$PROJECT/.pi/extensions/lib/fm-calm-operational-user-layout.ts" cp "$ROOT/.pi/extensions/lib/fm-calm-visibility.ts" "$PROJECT/.pi/extensions/lib/fm-calm-visibility.ts" +cp "$ROOT/.pi/extensions/lib/fm-calm-working-ship.ts" "$PROJECT/.pi/extensions/lib/fm-calm-working-ship.ts" cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$PROJECT/.pi/extensions/lib/fm-operational-input.ts" cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$PROJECT/.pi/extensions/fm-primary-turnend-guard.ts" cp "$ROOT/bin/fm-watch-arm.sh" "$PROJECT/bin/fm-watch-arm.sh" @@ -282,17 +283,21 @@ send_prompt "Reply exactly CALM_LIVE_WORKING_VISIBLE" i=0 while [ "$i" -lt 240 ]; do pane=$(capture) - if printf '%s\n' "$pane" | grep -Fq "Working..."; then + if printf '%s\n' "$pane" | grep -Fq '\__/'; then break fi sleep 0.05 i=$((i + 1)) done +printf '%s\n' "$pane" | grep -Fq '\__/' \ + || fail "Calm did not show the working ship on the credentialed provider path" printf '%s\n' "$pane" | grep -Fq "Working..." \ - || fail "Calm hid Pi's built-in Working row on the credentialed provider path" + && fail "Calm left Pi's stock working row visible on the credentialed provider path" wait_for_exact_line "CALM_LIVE_WORKING_VISIBLE" 120 \ - || fail "Pi did not settle the Calm Working-row provider probe" + || fail "Pi did not settle the Calm working-ship provider probe" pane=$(capture) +printf '%s\n' "$pane" | grep -Fq '\__/' \ + && fail "Calm left the working ship on screen after the run settled" printf '%s\n' "$pane" | grep -Fq "calm transcript" \ && fail "Calm added a persistent Calm status row on the credentialed provider path" send_prompt "/calm" @@ -336,4 +341,4 @@ wait_for_text "PI_EXIT=0" 60 || fail "Pi did not exit cleanly" wait_pid_dead "$watcher_pid" || fail "watcher child survived clean Pi exit" wait_pid_dead "$arm_pid" || fail "arm child survived clean Pi exit" -printf 'ok - Pi %s live E2E covered native Calm Working visibility, Ahoy first/later messages, legacy transcripts, near misses, and watcher continuity\n' "$PI_VERSION" +printf 'ok - Pi %s live E2E covered the Calm working ship, Ahoy first/later messages, legacy transcripts, near misses, and watcher continuity\n' "$PI_VERSION" diff --git a/tests/fm-pi-primary-types.test.sh b/tests/fm-pi-primary-types.test.sh index ce019c1d610..336a80b81d3 100755 --- a/tests/fm-pi-primary-types.test.sh +++ b/tests/fm-pi-primary-types.test.sh @@ -41,6 +41,7 @@ cp "$ROOT/.pi/extensions/fm-primary-footer.ts" "$TMP_ROOT/fm-primary-footer.ts" cp "$ROOT/.pi/extensions/lib/fm-calm-assistant-layout.ts" "$TMP_ROOT/lib/fm-calm-assistant-layout.ts" cp "$ROOT/.pi/extensions/lib/fm-calm-operational-user-layout.ts" "$TMP_ROOT/lib/fm-calm-operational-user-layout.ts" cp "$ROOT/.pi/extensions/lib/fm-calm-visibility.ts" "$TMP_ROOT/lib/fm-calm-visibility.ts" +cp "$ROOT/.pi/extensions/lib/fm-calm-working-ship.ts" "$TMP_ROOT/lib/fm-calm-working-ship.ts" cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$TMP_ROOT/lib/fm-operational-input.ts" cp "$ROOT/.pi/extensions/lib/fm-primary-footer-layout.ts" "$TMP_ROOT/lib/fm-primary-footer-layout.ts" ln -s "$PI_PACKAGE_DIR" "$TMP_ROOT/node_modules/@earendil-works/pi-coding-agent" diff --git a/tests/fm-public-followup.test.sh b/tests/fm-public-followup.test.sh new file mode 100755 index 00000000000..4c0571c466b --- /dev/null +++ b/tests/fm-public-followup.test.sh @@ -0,0 +1,1042 @@ +#!/usr/bin/env bash +# End-to-end and regression tests for the deterministic public-followup consumer. +# +# The failure this suite pins: firstmate promises a public final reply in an X or +# Discord thread, routes the work out, and then the session compacts or restarts. +# Nothing in memory survives. The promise is only kept if a terminal work result +# reconciles the typed obligation from DISK and the final reply lands in the +# ORIGINAL thread exactly once. +# +# Everything here is hermetic: the relay is a fakebin `curl`, so no port, no +# server, and no public post. tasks-axi and jq are the real tools, because +# tasks-axi owns the obligation state machine and stubbing it would test nothing. +set -u + +# shellcheck source=tests/lib.sh +# shellcheck disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +PF="$ROOT/bin/fm-public-followup.sh" +EMIT="$ROOT/bin/fm-public-followup-emit.sh" +POLL="$ROOT/bin/fm-x-poll.sh" +TEARDOWN="$ROOT/bin/fm-teardown.sh" +SESSION_START="$ROOT/bin/fm-session-start.sh" +TMP_ROOT=$(fm_test_tmproot fm-public-followup) + +command -v jq >/dev/null 2>&1 || { echo "skip: jq not found"; exit 0; } +command -v tasks-axi >/dev/null 2>&1 || { echo "skip: tasks-axi not found"; exit 0; } + +# A fakebin `curl` standing in for the relay. It logs every call so a test can +# prove exactly how many public posts happened, and honours FAKE_FOLLOWUP_CODE so +# a transport failure can be simulated. +make_fake_curl() { # <home> + local fakebin + fakebin=$(fm_fakebin "$1") + cat > "$fakebin/curl" <<'SH' +#!/usr/bin/env bash +ofile="" url="" data="" +while [ $# -gt 0 ]; do + case "$1" in + -o) ofile=$2; shift 2 ;; + --data-binary) + case "$2" in + @-) data=$(cat) ;; + @*) data=$(cat -- "${2#@}") ;; + *) data=$2 ;; + esac + shift 2 + ;; + -H|-m|-w|-X) shift 2 ;; + -s) shift ;; + http://*|https://*) url=$1; shift ;; + *) shift ;; + esac +done +if [ -n "${FAKE_CURL_LOG:-}" ]; then + { echo "url=$url"; echo "data=$data"; } >> "$FAKE_CURL_LOG" +fi +case "$url" in + */connector/followup) printf '%s' "${FAKE_FOLLOWUP_CODE:-200}" ;; + */connector/answer) printf '200' ;; + */connector/request-context) + [ -n "$ofile" ] && printf '%s' "${FAKE_REQCTX_BODY:-}" > "$ofile" + printf '%s' "${FAKE_REQCTX_CODE:-404}" + ;; + */connector/poll) printf '204' ;; + *) printf '204' ;; +esac +exit 0 +SH + chmod +x "$fakebin/curl" + printf '%s\n' "$fakebin" +} + +# make_home <name> [relay-on|relay-off]: a firstmate home with its own backlog. +# relay-off omits .env entirely, which is exactly what a home that never opted +# into the myfirstmate relay looks like. +make_home() { # <name> [relay-on|relay-off] + local home="$TMP_ROOT/$1" relay=${2:-relay-on} + mkdir -p "$home/data" "$home/state" "$home/config" "$home/projects" + cp "$ROOT/.tasks.toml" "$home/.tasks.toml" + cat > "$home/data/backlog.md" <<'EOF' +## In flight + +## Queued + +## Done +EOF + [ "$relay" = relay-off ] || printf 'FMX_PAIRING_TOKEN=test-token\n' > "$home/.env" + make_fake_curl "$home" >/dev/null + fm_fake_exit0 "$home/fakebin" tmux treehouse no-mistakes gh gh-axi + printf '%s\n' "$home" +} + +run_pf() { # <home> <args...> + local home=$1 + shift + PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FAKE_CURL_LOG="${FAKE_CURL_LOG:-}" \ + FAKE_FOLLOWUP_CODE="${FAKE_FOLLOWUP_CODE:-200}" "$PF" "$@" +} + +tasks_in() { # <home> <tasks-axi args...> + local home=$1 + shift + (cd "$home" && tasks-axi "$@") +} + +# seed_commitment <home> <obligation> <request> <platform> <work-home> <work-id> +# Simulates the intake half that already works today: the relay mention arrives, +# the typed obligation is created with its opaque thread binding, the work is +# bound, and the private request context is retained. +seed_commitment() { + local home=$1 obligation=$2 request=$3 platform=$4 work_home=$5 work_id=$6 + jq -n --arg r "$request" --arg p "$platform" \ + '{request_id:$r, platform:$p, + context_binding:{version:"ctx1", value:("ctx1_" + $r)}, + public_safe_summary:"fix worker placement when two spaces share a name", + received_at:"2026-07-30T10:00:00Z", + followup_expires_at:"2026-08-06T10:00:00Z", + reservation_expires_at:"2026-08-06T10:00:00Z"}' > "$home/request.json" + jq -n '{type:"pr-merged", project:"firstmate", + required_deliverables:["pr_url"], completion_policy:"all-required"}' \ + > "$home/expected.json" + jq -n --arg h "$work_home" --arg w "$work_id" \ + '{relation_id:"rel-code", work_ref:{home_id:$h, task_id:$w}, + role:"fulfills", required:true, generation:1}' > "$home/relation.json" + + tasks_in "$home" public-followup add "$obligation" \ + --request-context-file "$home/request.json" --purpose promised-final \ + --expected-final-file "$home/expected.json" --expires-at 2026-10-01T00:00:00Z >/dev/null \ + || fail "could not create the public commitment" + tasks_in "$home" public-followup bind-work "$obligation" \ + --relation-file "$home/relation.json" >/dev/null \ + || fail "could not bind work to the public commitment" + + # The mention payload and the durable per-request context, exactly as the relay + # poll records them at intake. + mkdir -p "$home/state/x-inbox" + jq -n --arg r "$request" --arg p "$platform" \ + '{request_id:$r, platform:$p, text:"please fix worker placement"}' \ + > "$home/state/x-inbox/$request.json" + chmod 700 "$home/state/x-inbox" + chmod 600 "$home/state/x-inbox/$request.json" + FM_HOME="$home" bash -c \ + ". '$ROOT/bin/fm-x-lib.sh'; fmx_context_registry_set '$home/state' '$request' '$platform' 1900" \ + || fail "could not retain the private request context" + + run_pf "$home" register "$obligation" --relation rel-code \ + --work-home "$work_home" --work-id "$work_id" --generation 1 >/dev/null \ + || fail "could not register the public commitment" +} + +emit_terminal() { # <child-run-dir> <owning-home> <obligation> <work-home> <work-id> [pr-url] [outcome] + local owning=$2 obligation=$3 work_home=$4 work_id=$5 + local pr=${6:-https://github.com/example/repo/pull/7} outcome=${7:-pr-merged} + "$EMIT" --home "$owning" --obligation "$obligation" --relation rel-code \ + --source-home "$work_home" --work-id "$work_id" --generation 1 \ + --outcome "$outcome" --deliverable "pr_url=$pr" \ + --outcome-text 'Fixed: workers now land in the launching workspace even when two spaces share a name.' +} + +delivery_state() { # <home> <obligation> + tasks_in "$1" public-followup list --json 2>/dev/null \ + | jq -r --arg id "$2" '(.public_followups // []) + | map(select(.id == $id)) | .[0].public_followup.delivery.state // "absent"' +} + +task_state() { # <home> <obligation> + tasks_in "$1" public-followup list --json 2>/dev/null \ + | jq -r --arg id "$2" '(.public_followups // []) + | map(select(.id == $id)) | .[0].state // "absent"' +} + +followup_posts() { # <log> + local n + n=$(grep -c 'connector/followup' "$1" 2>/dev/null) || n=0 + printf '%s' "${n:-0}" +} + +# expect_failure <label> <command...>: run <command>, require a non-zero exit, and +# leave its combined output in EXPECT_OUT for the assertions that follow. Keeps +# refusal tests readable without toggling errexit around every case. +EXPECT_OUT= +expect_failure() { + local label=$1 + shift + if EXPECT_OUT=$("$@" 2>&1); then + fail "$label (unexpectedly succeeded)"$'\n'"--- output ---"$'\n'"$EXPECT_OUT" + fi +} + +# --- 0. bounded, single-line, character-safe outcome text ----------------------- + +# The outcome sentence becomes a public reply, so bounding it must not mangle +# non-ASCII characters, and control characters must never survive into the typed +# event or the thread. +test_outcome_text_is_bounded_without_corrupting_characters() { + local home event text long + home=$(make_home outcome-text) + seed_commitment "$home" pf-text req-text discord main work-text + + "$EMIT" --home "$home" --obligation pf-text --relation rel-code \ + --source-home main --work-id work-text --generation 1 --outcome pr-merged \ + --deliverable pr_url=https://github.com/example/repo/pull/3 \ + --outcome-text "$(printf 'Shipped\tthe caf\xc3\xa9 fix \xe2\x80\x94 \xf0\x9f\x9a\xa2\nsecond line')" >/dev/null \ + || fail "emit failed for non-ASCII outcome text" + event=$(find "$home/state/public-followup/events" -name '*.json' | head -1) + text=$(jq -r '.public_safe_outcome' "$event") \ + || fail "the typed event must remain valid JSON with non-ASCII text" + [ "$text" = 'Shipped the café fix — 🚢 second line' ] \ + || fail "non-ASCII outcome text was corrupted or not collapsed: '$text'" + + # A very long sentence is capped by codepoint, so the JSON stays valid. + rm -f "$event" + long=$(python3 -c 'print("é" * 5000, end="")') + "$EMIT" --home "$home" --obligation pf-text --relation rel-code \ + --source-home main --work-id work-text --generation 1 --outcome pr-merged \ + --deliverable pr_url=https://github.com/example/repo/pull/4 \ + --outcome-text "$long" >/dev/null \ + || fail "emit failed for an over-long outcome text" + event=$(find "$home/state/public-followup/events" -name '*.json' | head -1) + text=$(jq -r '.public_safe_outcome' "$event") \ + || fail "an over-long outcome must still produce valid JSON" + [ "${#text}" -le 600 ] || fail "the outcome text was not bounded, got ${#text} characters" + case "$text" in + *[!é]*) fail "codepoint bounding split a multi-byte character" ;; + esac + pass "outcome text is collapsed to one line, bounded by codepoint, and never corrupts characters" +} + +# --- 1. the restart end-to-end ------------------------------------------------- + +# The whole reported failure, start to finish, with no conversation memory +# anywhere: a Discord request becomes a typed commitment, a secondmate child +# lands the work and reports a TYPED terminal result, the session ends, and a +# cold reconciliation from disk delivers exactly one final reply into the +# original thread and closes the obligation. +test_restart_e2e_delivers_exactly_once() { + local home child log out posts receipt + home=$(make_home restart-e2e) + child=$(make_home restart-child relay-off) + log="$home/curl.log"; : > "$log" + seed_commitment "$home" pf-restart req-restart discord secondmate:fmdev work-code-q1 + printf '%s\n' fmdev > "$child/.fm-secondmate-home" + fm_write_meta "$home/state/fmdev.meta" "kind=secondmate" "home=$child" + fm_write_meta "$child/state/work-code-q1.meta" \ + "x_request=req-restart" "x_request_ts=1700000000" "x_followups=1" + + # The reported failure, reproduced: with the work bound but no reconciled + # terminal result, the commitment is stranded at pending-work and nothing can + # be delivered - which is exactly how a promised final reply went unsent. + [ "$(delivery_state "$home" pf-restart)" = pending-work ] \ + || fail "a freshly bound commitment must sit at pending-work" + FAKE_CURL_LOG="$log" expect_failure "a commitment still waiting on its work must not be deliverable" \ + run_pf "$home" deliver pf-restart + assert_contains "$EXPECT_OUT" "still waiting on its bound work" \ + "the stranded state must be reported, not silently skipped" + [ "$(followup_posts "$log")" -eq 0 ] || fail "the stranded state must post nothing" + + # The child home reports its terminal result as typed data. This is the step + # whose absence left the obligation stranded at pending-work. + emit_terminal "$home" "$home" pf-restart secondmate:fmdev work-code-q1 >/dev/null \ + || fail "the child could not report its typed terminal result" + + # Simulate compaction/restart: nothing but disk survives, and the drained inbox + # is gone. The durable private request context is what keeps the thread binding + # resolvable. + rm -f "$home/state/x-inbox/req-restart.json" + + out=$(FAKE_CURL_LOG="$log" run_pf "$home" consume) \ + || fail "cold reconciliation failed" + assert_contains "$out" "ready pf-restart req-restart discord" \ + "reconciliation must report the commitment as delivery-ready" + [ "$(delivery_state "$home" pf-restart)" = ready ] \ + || fail "the typed terminal result must move the commitment to ready" + [ "$(followup_posts "$log")" -eq 0 ] \ + || fail "reconciliation must not post anything by itself" + + out=$(FAKE_CURL_LOG="$log" run_pf "$home" deliver pf-restart) \ + || fail "delivery failed" + assert_contains "$out" "delivered pf-restart request=req-restart platform=discord" \ + "delivery must report the original request binding" + + posts=$(followup_posts "$log") + [ "$posts" -eq 1 ] || fail "expected exactly one public reply, got $posts" + assert_grep 'connector/followup' "$log" "the reply must use the follow-up endpoint" + assert_grep '"request_id":"req-restart"' "$log" \ + "the reply must target the ORIGINAL request binding" + assert_grep 'workers now land in the launching workspace' "$log" \ + "the reply must reuse the accepted terminal outcome verbatim" + + receipt=$(tasks_in "$home" public-followup list --json \ + | jq -r '(.public_followups // []) + | map(select(.id == "pf-restart")) | .[0].public_followup.delivery.receipt.state // "none"') + [ "$receipt" = posted ] || fail "a validated posted receipt must be recorded, got '$receipt'" + [ "$(task_state "$home" pf-restart)" = 'done' ] \ + || fail "the commitment must be Done only after the receipt" + assert_no_grep '^x_request=' "$child/state/work-code-q1.meta" \ + "typed delivery must clear the secondmate's legacy X link" + pass "restart end-to-end: typed result reconciles from disk and delivers one reply to the original thread" +} + +# --- 2. idempotency ------------------------------------------------------------ + +test_duplicate_event_and_replay_are_noops() { + local home log first second out posts + home=$(make_home idempotent) + log="$home/curl.log"; : > "$log" + seed_commitment "$home" pf-dup req-dup discord main work-dup + + first=$(emit_terminal "$home" "$home" pf-dup main work-dup) || fail "first emit failed" + second=$(emit_terminal "$home" "$home" pf-dup main work-dup) || fail "second emit failed" + [ "$first" = "$second" ] \ + || fail "the same terminal result must derive the same event identity" + [ "$(find "$home/state/public-followup/events" -name '*.json' | wc -l | tr -d ' ')" -eq 1 ] \ + || fail "a duplicate emit must not create a second event file" + + FAKE_CURL_LOG="$log" run_pf "$home" consume >/dev/null || fail "first consume failed" + # Replay the identical event after the fact, exactly as a restarted child would. + emit_terminal "$home" "$home" pf-dup main work-dup >/dev/null || fail "replay emit failed" + out=$(FAKE_CURL_LOG="$log" run_pf "$home" consume) || fail "replay consume failed" + [ -z "$out" ] || fail "replaying an accepted event must be silent, got: $out" + [ "$(delivery_state "$home" pf-dup)" = ready ] \ + || fail "replay must not disturb the delivery state" + + FAKE_CURL_LOG="$log" run_pf "$home" deliver pf-dup >/dev/null || fail "delivery failed" + out=$(FAKE_CURL_LOG="$log" run_pf "$home" deliver pf-dup) || fail "second deliver must succeed silently" + assert_contains "$out" "already delivered" "a second delivery must report the existing receipt" + posts=$(followup_posts "$log") + [ "$posts" -eq 1 ] || fail "a repeated delivery must never double-post, got $posts posts" + pass "duplicate terminal results, restart replay, and repeated delivery are all no-ops" +} + +# --- 3. refusals --------------------------------------------------------------- + +# Everything tasks-axi is the authority on - source home, work id, generation, +# schema, and permitted deliverables - must be refused rather than half-applied, +# and quarantined rather than retried forever. +test_invalid_events_are_refused_and_quarantined() { + local home out events rejected + home=$(make_home refusals) + seed_commitment "$home" pf-refuse req-refuse discord secondmate:fmdev work-real + + # Wrong source home and wrong work id are caught at the edge by the emitter, + # because the owning home's own registration disagrees. + expect_failure "a wrong source home must be refused" \ + "$EMIT" --home "$home" --obligation pf-refuse --relation rel-code \ + --source-home secondmate:other --work-id work-real --generation 1 \ + --outcome pr-merged --deliverable pr_url=https://example.invalid/1 \ + --outcome-text 'x' + assert_contains "$EXPECT_OUT" "does not match this home's registration" \ + "the refusal must name the mismatch" + + expect_failure "a wrong work id must be refused" \ + "$EMIT" --home "$home" --obligation pf-refuse --relation rel-code \ + --source-home secondmate:fmdev --work-id work-other --generation 1 \ + --outcome pr-merged --deliverable pr_url=https://example.invalid/1 \ + --outcome-text 'x' + expect_failure "a stale generation must be refused" \ + "$EMIT" --home "$home" --obligation pf-refuse --relation rel-code \ + --source-home secondmate:fmdev --work-id work-real --generation 0 \ + --outcome pr-merged --deliverable pr_url=https://example.invalid/1 \ + --outcome-text 'x' + + events="$home/state/public-followup/events" + rejected="$home/state/public-followup/rejected" + + # A malformed event that bypassed the emitter entirely. + printf 'not json at all\n' > "$events/deadbeef.json" + out=$(run_pf "$home" consume) || fail "consume must survive a malformed event" + assert_contains "$out" "rejected deadbeef" "a malformed event must be refused" + assert_absent "$events/deadbeef.json" "a refused event must leave the pending inbox" + assert_present "$rejected/deadbeef.reason" "a refusal must keep an inspectable reason" + + # A deliverable the expected-final type does not permit. The emitter accepts the + # shape; tasks-axi is the authority that refuses the semantics. + "$EMIT" --home "$home" --obligation pf-refuse --relation rel-code \ + --source-home secondmate:fmdev --work-id work-real --generation 1 \ + --outcome pr-merged --deliverable report_path=data/x/report.md \ + --outcome-text 'wrong deliverable for a merged PR' >/dev/null \ + || fail "the emitter should publish a shape-valid event" + out=$(run_pf "$home" consume) || fail "consume must survive an unsupported deliverable" + assert_contains "$out" "rejected " "an unsupported deliverable must be refused by tasks-axi" + [ "$(delivery_state "$home" pf-refuse)" = pending-work ] \ + || fail "a refused event must leave the commitment untouched" + + # A hand-edited event whose id no longer matches its own identity fields. + jq -n '{schema_version:1, event_id:"forged", obligation_id:"pf-refuse", + relation_id:"rel-code", work_id:"work-real", generation:1, + source_home_id:"secondmate:fmdev", outcome_type:"pr-merged", + deliverables:{pr_url:"https://example.invalid/9"}, + public_safe_outcome:"forged", occurred_at:"2026-07-30T12:00:00Z", + successor:null}' > "$events/forged.json" + out=$(run_pf "$home" consume) || fail "consume must survive a forged event" + assert_contains "$out" "rejected forged" "a forged event identity must be refused" + pass "wrong source, wrong work id, stale generation, malformed, unsupported deliverable, and forged identity are all refused" +} + +# --- 4. transport failure and late receipt ------------------------------------- + +test_relay_failure_holds_without_false_completion() { + local home log out posts + home=$(make_home retryable) + log="$home/curl.log"; : > "$log" + seed_commitment "$home" pf-retry req-retry discord main work-retry + emit_terminal "$home" "$home" pf-retry main work-retry >/dev/null || fail "emit failed" + FAKE_CURL_LOG="$log" run_pf "$home" consume >/dev/null || fail "consume failed" + + FAKE_CURL_LOG="$log" FAKE_FOLLOWUP_CODE=500 \ + expect_failure "a failed relay post must not report success" \ + run_pf "$home" deliver pf-retry + assert_contains "$EXPECT_OUT" "recorded as retryable" "the failure must be typed as retryable" + [ "$(delivery_state "$home" pf-retry)" = retry-due ] \ + || fail "a failed post must leave a retryable state, got $(delivery_state "$home" pf-retry)" + [ "$(task_state "$home" pf-retry)" != 'done' ] \ + || fail "a failed post must never close the commitment" + + # The retry succeeds and closes it, with exactly one successful post. + : > "$log" + FAKE_CURL_LOG="$log" run_pf "$home" deliver pf-retry >/dev/null || fail "the retry should deliver" + posts=$(followup_posts "$log") + [ "$posts" -eq 1 ] || fail "the retry must post exactly once, got $posts" + [ "$(task_state "$home" pf-retry)" = 'done' ] || fail "a successful retry must close the commitment" + pass "a relay transport failure is held as retryable with no false completion, and the retry posts once" +} + +test_dry_run_does_not_close_commitment() { + local home log out posts + home=$(make_home dry-run) + log="$home/curl.log"; : > "$log" + seed_commitment "$home" pf-dry req-dry discord main work-dry + emit_terminal "$home" "$home" pf-dry main work-dry >/dev/null || fail "emit failed" + run_pf "$home" consume >/dev/null || fail "consume failed" + + FMX_DRY_RUN=1 FAKE_CURL_LOG="$log" expect_failure \ + "a dry-run must not close a public commitment" run_pf "$home" deliver pf-dry + assert_contains "$EXPECT_OUT" "recorded as retryable" \ + "a dry-run must leave a retryable typed state" + [ "$(delivery_state "$home" pf-dry)" = retry-due ] \ + || fail "a dry-run must leave the obligation retryable, got $(delivery_state "$home" pf-dry)" + [ "$(task_state "$home" pf-dry)" != 'done' ] \ + || fail "a dry-run must never close the commitment" + posts=$(followup_posts "$log") + [ "$posts" -eq 0 ] || fail "a dry-run must not post to the relay, got $posts posts" + pass "a dry-run records no public delivery and leaves the commitment retryable" +} + +test_late_receipt_closes_the_exact_attempt_without_reposting() { + local home log out posts attempt + home=$(make_home late-receipt) + log="$home/curl.log"; : > "$log" + seed_commitment "$home" pf-late req-late x main work-late + fm_write_meta "$home/state/work-late.meta" \ + "x_request=req-late" "x_request_ts=1700000000" "x_followups=1" + emit_terminal "$home" "$home" pf-late main work-late >/dev/null || fail "emit failed" + FAKE_CURL_LOG="$log" run_pf "$home" consume >/dev/null || fail "consume failed" + + FAKE_CURL_LOG="$log" FAKE_FOLLOWUP_CODE=503 run_pf "$home" deliver pf-late >/dev/null 2>&1 || true + attempt=$(tasks_in "$home" public-followup list --json \ + | jq -r '(.public_followups // []) | map(select(.id == "pf-late")) + | .[0].public_followup.delivery.attempt_count') + [ "$attempt" = 1 ] || fail "the failed attempt must be recorded as attempt 1, got '$attempt'" + + expect_failure "a late receipt must include its exact message count" \ + run_pf "$home" record-posted pf-late --attempt 1 + assert_contains "$EXPECT_OUT" "--chunks <n> is required" \ + "a late receipt without a message count must be refused" + + # The post actually landed; its receipt was simply lost. Close the exact attempt + # without sending anything else. + : > "$log" + out=$(FAKE_CURL_LOG="$log" run_pf "$home" record-posted pf-late --attempt 1 --chunks 1) \ + || fail "recording a late receipt for the exact attempt must succeed" + assert_contains "$out" "recorded pf-late attempt=1" "the late receipt must name its attempt" + posts=$(followup_posts "$log") + [ "$posts" -eq 0 ] || fail "recording a late receipt must post nothing, got $posts posts" + [ "$(task_state "$home" pf-late)" = 'done' ] || fail "a validated late receipt must close the commitment" + assert_no_grep '^x_request=' "$home/state/work-late.meta" \ + "a late receipt must clear the legacy X link" + + FAKE_CURL_LOG="$log" expect_failure "a receipt for a different attempt must be refused" \ + run_pf "$home" record-posted pf-late --attempt 9 --chunks 1 + pass "a late success receipt closes the exact attempt with no second post, and a mismatched attempt is refused" +} + +test_typed_terminal_clear_only_removes_legacy_link() { + local home meta out + home=$(make_home typed-clear) + meta="$home/state/work-clear.meta" + printf '%s\n' 'status=working' 'x_request=req-clear' 'x_request_ts=1700000000' \ + 'x_followups=2' 'x_platform=discord' 'x_reply_max_chars=1900' > "$meta" + + out=$(PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" "$ROOT/bin/fm-x-followup.sh" --clear work-clear) \ + || fail "the typed terminal clear transition must succeed" + [ "$out" = work-clear ] || fail "the clear-only transition must identify the task" + assert_grep 'status=working' "$meta" "clear-only transition must preserve unrelated task metadata" + assert_no_grep '^x_request=' "$meta" "clear-only transition must remove the request link" + assert_no_grep '^x_followups=' "$meta" "clear-only transition must remove the follow-up counter" + assert_no_grep '^x_platform=' "$meta" "clear-only transition must remove platform metadata" + pass "typed terminal cleanup clears the legacy link without posting" +} + +# A crash between the post and its receipt is the one case where we cannot know +# whether the thread already got a reply. Delivery must refuse rather than guess. +test_interrupted_delivery_refuses_to_repost() { + local home log out posts + home=$(make_home interrupted) + log="$home/curl.log"; : > "$log" + seed_commitment "$home" pf-crash req-crash discord main work-crash + emit_terminal "$home" "$home" pf-crash main work-crash >/dev/null || fail "emit failed" + run_pf "$home" consume >/dev/null || fail "consume failed" + + # Reproduce the crash window directly through the state machine. + tasks_in "$home" public-followup begin-delivery pf-crash \ + --payload-hash 0000000000000000000000000000000000000000000000000000000000000000 >/dev/null \ + || fail "could not stage the interrupted attempt" + + FAKE_CURL_LOG="$log" expect_failure "an interrupted delivery must not silently post again" \ + run_pf "$home" deliver pf-crash + assert_contains "$EXPECT_OUT" "mid-delivery" "the refusal must name the interrupted attempt" + posts=$(followup_posts "$log") + [ "$posts" -eq 0 ] || fail "an interrupted delivery must post nothing, got $posts posts" + pass "a delivery interrupted between post and receipt refuses to repost" +} + +# --- 5. ownership -------------------------------------------------------------- + +# The outward post belongs to the home holding the relay consent and the thread +# binding. A child home has neither, and must not be able to acquire them. +test_outward_delivery_stays_with_the_owning_home() { + local owner child log out + owner=$(make_home owner) + child=$(make_home child relay-off) + log="$owner/curl.log"; : > "$log" + seed_commitment "$owner" pf-own req-own discord secondmate:child work-child + printf '%s\n' child > "$child/.fm-secondmate-home" + fm_write_meta "$owner/state/child.meta" "kind=secondmate" "home=$child" + fm_write_meta "$child/state/work-child.meta" \ + "x_request=req-own" "x_request_ts=1700000000" "x_followups=1" + + FAKE_CURL_LOG="$log" emit_terminal "$owner" "$owner" pf-own secondmate:child work-child >/dev/null \ + || fail "the child could not report its typed result" + [ "$(followup_posts "$log")" -eq 0 ] \ + || fail "reporting a terminal result must never post publicly" + run_pf "$owner" consume >/dev/null || fail "the owning home could not consume the child's typed result" + + # The child home has no commitment of its own and no relay consent, so it can + # neither deliver nor even see one. + PATH="$child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$child" \ + FM_STATE_OVERRIDE="$child/state" FAKE_CURL_LOG="$log" \ + expect_failure "a home without relay consent must not deliver a public reply" \ + "$PF" deliver pf-own + assert_contains "$EXPECT_OUT" "has not opted into the myfirstmate relay" \ + "the refusal must name the missing relay consent" + [ "$(followup_posts "$log")" -eq 0 ] || fail "the refused delivery must post nothing" + FAKE_CURL_LOG="$log" run_pf "$owner" deliver pf-own >/dev/null \ + || fail "the owning home must deliver the typed public reply" + assert_no_grep '^x_request=' "$child/state/work-child.meta" \ + "typed delivery must clear the child task's legacy X link" + pass "a child home reports typed results but can never become the outward-post owner" +} + +test_delivery_requires_registration_before_posting() { + local home log out + home=$(make_home missing-registration) + log="$home/curl.log"; : > "$log" + seed_commitment "$home" pf-missing req-missing x main work-missing + fm_write_meta "$home/state/work-missing.meta" \ + "x_request=req-missing" "x_request_ts=1700000000" "x_followups=1" + emit_terminal "$home" "$home" pf-missing main work-missing >/dev/null || fail "emit failed" + run_pf "$home" consume >/dev/null || fail "consume failed" + rm -f "$home/state/public-followup/registry/pf-missing" + + FAKE_CURL_LOG="$log" expect_failure "delivery without a registration must refuse" \ + run_pf "$home" deliver pf-missing + assert_contains "$EXPECT_OUT" "registration for 'pf-missing' is missing or invalid" \ + "missing registration must be an actionable delivery refusal" + [ "$(followup_posts "$log")" -eq 0 ] || fail "missing registration must prevent any public post" + [ "$(task_state "$home" pf-missing)" != 'done' ] \ + || fail "missing registration must not close the obligation" + assert_grep 'x_request=req-missing' "$home/state/work-missing.meta" \ + "missing registration must leave the legacy link for reconciliation" + pass "typed delivery refuses to post when its cleanup registration is missing" +} + +test_secondmate_teardown_requires_parent_binding() { + local parent child registry_before marker_before + parent=$(make_home teardown-parent) + child=$(make_home teardown-child) + printf '%s\n' mate > "$child/.fm-secondmate-home" + seed_commitment "$parent" pf-teardown req-teardown x secondmate:mate work-child + fm_write_meta "$parent/state/mate.meta" "kind=secondmate" "home=$child" + fm_write_meta "$child/state/work-child.meta" \ + "window=firstmate:fm-work-child" "endpoint_task_id=work-child" \ + "worktree=$child" "project=$child" "kind=ship" "mode=local-only" + + PATH="$child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$child" \ + FM_STATE_OVERRIDE="$child/state" FM_DATA_OVERRIDE="$child/data" \ + expect_failure "marked child teardown without a parent must refuse cleanup" \ + "$TEARDOWN" work-child + assert_contains "$EXPECT_OUT" "cannot resolve the primary home for marked secondmate mate" \ + "missing parent binding must be an actionable teardown refusal" + assert_present "$child/state/work-child.meta" \ + "missing parent binding must preserve the child work metadata" + + parent=$(make_home teardown-valid-parent) + child=$(make_home teardown-valid-child) + printf '%s\n' mate > "$child/.fm-secondmate-home" + printf -- '- mate - synthetic (id is legacy); preserve this (home: %s; scope: synthetic (child); semicolon remains meaningful; projects: ; added 2026-07-30)\n' \ + "$child" > "$parent/data/secondmates.md" + FM_HOME="$parent" "$ROOT/bin/fm-home-seed.sh" validate >/dev/null \ + || fail "home-seed validation rejected a punctuation-bearing operational registry record" + registry_before=$(cat "$parent/data/secondmates.md") + marker_before=$(cat "$child/.fm-secondmate-home") + seed_commitment "$parent" pf-teardown-valid req-teardown-valid x secondmate:mate work-child + fm_write_meta "$parent/state/mate.meta" "kind=secondmate" "home=$child" + fm_write_meta "$child/state/work-child.meta" \ + "window=firstmate:fm-work-child" "endpoint_task_id=work-child" \ + "worktree=$child" "project=$child" "kind=ship" "mode=local-only" + + PATH="$child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$child" \ + FM_STATE_OVERRIDE="$child/state" FM_DATA_OVERRIDE="$child/data" \ + FM_PUBLIC_FOLLOWUP_PRIMARY_HOME="$parent" \ + expect_failure "marked child teardown with a valid parent must enforce the parent commitment" \ + "$TEARDOWN" work-child + assert_contains "$EXPECT_OUT" "still owes a public reply" \ + "valid parent binding must route cleanup through the parent commitment" + case "$EXPECT_OUT" in + *"cannot resolve the primary home"*) fail "valid parent binding was reported as unresolved" ;; + esac + assert_present "$child/state/work-child.meta" \ + "an owed parent commitment must preserve the child work metadata" + [ "$registry_before" = "$(cat "$parent/data/secondmates.md")" ] \ + || fail "guarded cleanup refusal changed the parent registry" + [ "$marker_before" = "$(cat "$child/.fm-secondmate-home")" ] \ + || fail "guarded cleanup refusal changed the child identity marker" + pass "marked secondmate teardown resolves its parent and fails closed when unavailable" +} + +test_relay_disabled_unmarked_teardown_skips_public_path() { + local home tasks_log out rc + home=$(make_home teardown-disabled-unmarked relay-off) + fm_git_init_commit "$home/projects/worktree" + tasks_log="$home/tasks-axi.log"; : > "$tasks_log" + printf 'manual\n' > "$home/config/backlog-backend" + cat > "$home/fakebin/tasks-axi" <<'SH' +#!/usr/bin/env bash +echo "$*" >> "$FAKE_TASKS_AXI_LOG" +exit 99 +SH + chmod +x "$home/fakebin/tasks-axi" + fm_write_meta "$home/state/work-disabled.meta" \ + "window=firstmate:fm-work-disabled" "endpoint_task_id=work-disabled" \ + "worktree=$home/projects/worktree" "project=$home/projects/worktree" \ + "kind=ship" "mode=local-only" + + rc=0 + out=$(PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_CONFIG_OVERRIDE="$home/config" FAKE_TASKS_AXI_LOG="$tasks_log" \ + "$TEARDOWN" work-disabled 2>&1) || rc=$? + [ "$rc" -eq 0 ] || fail "relay-disabled unmarked teardown must not refuse public-followup cleanup (rc=$rc): $out" + [ ! -s "$tasks_log" ] || fail "relay-disabled unmarked teardown must not invoke tasks-axi: $(tr '\n' ';' < "$tasks_log")" + assert_not_contains "$out" "still owes a public reply" \ + "relay-disabled unmarked teardown must not run the public commitment guard" + assert_absent "$home/state/public-followup" \ + "relay-disabled unmarked teardown must not create a public-followup artifact" + pass "relay-disabled unmarked teardown runs no public-followup work" +} + +test_relay_disabled_parent_allows_marked_child_teardown() { + local parent child tasks_log out rc + parent=$(make_home teardown-disabled-parent relay-off) + child=$(make_home teardown-disabled-child relay-off) + fm_git_init_commit "$child/projects/worktree" + printf '%s\n' disabled-mate > "$child/.fm-secondmate-home" + printf -- '- disabled-mate - synthetic (home: %s; scope: synthetic; projects: ; added 2026-07-30)\n' \ + "$child" > "$parent/data/secondmates.md" + fm_write_meta "$parent/state/disabled-mate.meta" "kind=secondmate" "home=$child" + tasks_log="$child/tasks-axi.log"; : > "$tasks_log" + printf 'manual\n' > "$child/config/backlog-backend" + cat > "$child/fakebin/tasks-axi" <<'SH' +#!/usr/bin/env bash +echo "$*" >> "$FAKE_TASKS_AXI_LOG" +exit 99 +SH + chmod +x "$child/fakebin/tasks-axi" + fm_write_meta "$child/state/work-disabled.meta" \ + "window=firstmate:fm-work-disabled" "endpoint_task_id=work-disabled" \ + "worktree=$child/projects/worktree" "project=$child/projects/worktree" \ + "kind=ship" "mode=local-only" + + rc=0 + out=$(PATH="$child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$child" \ + FM_STATE_OVERRIDE="$child/state" FM_DATA_OVERRIDE="$child/data" \ + FM_CONFIG_OVERRIDE="$child/config" \ + FM_PUBLIC_FOLLOWUP_PRIMARY_HOME="$parent" FAKE_TASKS_AXI_LOG="$tasks_log" \ + "$TEARDOWN" work-disabled 2>&1) || rc=$? + [ "$rc" -eq 0 ] || fail "relay-disabled parent must allow marked-child teardown (rc=$rc): $out" + [ ! -s "$tasks_log" ] || fail "relay-disabled parent must not invoke tasks-axi for a marked child" + assert_not_contains "$out" "still owes a public reply" \ + "relay-disabled parent must not run the public commitment guard" + assert_absent "$child/state/public-followup" \ + "relay-disabled parent must not create a public-followup artifact" + pass "a marked child proceeds without tasks-axi when its parent relay is disabled" +} + +test_secondmate_parent_binding_matches_literal_id() { + local parent child + parent=$(make_home teardown-literal-parent) + child=$(make_home teardown-literal-child) + printf '%s\n' 'mate.id' > "$child/.fm-secondmate-home" + printf -- '- mateXid - synthetic (home: %s; scope: synthetic; projects: ; added 2026-07-30)\n' \ + "$child" > "$parent/data/secondmates.md" + seed_commitment "$parent" pf-teardown-literal req-teardown-literal x secondmate:mate.id work-literal + fm_write_meta "$parent/state/mate.id.meta" "kind=secondmate" "home=$child" + fm_write_meta "$child/state/work-literal.meta" \ + "window=firstmate:fm-work-literal" "endpoint_task_id=work-literal" \ + "worktree=$child" "project=$child" "kind=ship" "mode=local-only" + + PATH="$child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$child" \ + FM_STATE_OVERRIDE="$child/state" FM_DATA_OVERRIDE="$child/data" \ + FM_CONFIG_OVERRIDE="$child/config" FM_PUBLIC_FOLLOWUP_PRIMARY_HOME="$parent" \ + expect_failure "a near-match registry id must not satisfy a dotted parent binding" \ + "$TEARDOWN" work-literal + assert_contains "$EXPECT_OUT" "cannot resolve the primary home for marked secondmate mate.id" \ + "a dotted id must be matched as an exact registry field" + assert_present "$child/state/work-literal.meta" \ + "a near-match parent binding must preserve the child work metadata" + pass "secondmate parent resolution matches the durable registry id literally" +} + +test_traversal_registration_is_refused_before_delivery() { + local home log out + home=$(make_home traversal-registration) + log="$home/curl.log"; : > "$log" + seed_commitment "$home" pf-traversal req-traversal x main work-traversal + emit_terminal "$home" "$home" pf-traversal main work-traversal >/dev/null \ + || fail "emit failed for traversal registration" + sed -i.bak 's/^work_home=.*/work_home=secondmate:..\/..\/x/' \ + "$home/state/public-followup/registry/pf-traversal" + rm -f "$home/state/public-followup/registry/pf-traversal.bak" + run_pf "$home" consume >/dev/null || fail "consume failed for traversal registration" + + out=$(FAKE_CURL_LOG="$log" run_pf "$home" deliver pf-traversal 2>&1) && \ + fail "a traversal-shaped registration must not be deliverable" + assert_contains "$out" "registration for 'pf-traversal' is missing or invalid" \ + "a traversal-shaped work home must be rejected before delivery" + [ "$(followup_posts "$log")" -eq 0 ] || fail "an invalid work home must not post publicly" + assert_present "$home/state/public-followup/registry/pf-traversal" \ + "an invalid work home must retain its registration for reconciliation" + [ "$(task_state "$home" pf-traversal)" != 'done' ] \ + || fail "an invalid work home must not close the obligation" + pass "traversal-shaped registrations are rejected before path construction or posting" +} + +test_pending_rejects_malformed_listing() { + local home out + home=$(make_home pending-malformed) + seed_commitment "$home" pf-malformed req-malformed discord main work-malformed + cat > "$home/fakebin/tasks-axi" <<'SH' +#!/usr/bin/env bash +printf '%s' '{"public_followups":[' +SH + chmod +x "$home/fakebin/tasks-axi" + + out=$(run_pf "$home" pending) || fail "pending must survive malformed tasks-axi output" + assert_contains "$out" "cannot read this home's public commitments through tasks-axi" \ + "malformed backlog output must use the loud fallback" + assert_present "$home/state/public-followup/registry/pf-malformed" \ + "malformed backlog output must retain the registration" + pass "pending keeps registrations when tasks-axi returns malformed JSON" +} + +test_private_context_survives_inbox_cleanup() { + local home log posts + home=$(make_home context-retention) + log="$home/curl.log"; : > "$log" + seed_commitment "$home" pf-ctx req-ctx discord main work-ctx + emit_terminal "$home" "$home" pf-ctx main work-ctx >/dev/null || fail "emit failed" + run_pf "$home" consume >/dev/null || fail "consume failed" + + # Drain the inbox exactly as answering the original mention does, and make any + # relay fallback fail, so only the retained private context can resolve the + # thread's platform and size budget. + rm -f "$home/state/x-inbox/req-ctx.json" + assert_present "$home/state/x-context/req-ctx.json" \ + "the private request context must outlive the inbox payload" + + FAKE_CURL_LOG="$log" FAKE_REQCTX_CODE=500 run_pf "$home" deliver pf-ctx >/dev/null \ + || fail "delivery must still resolve the thread from retained private context" + posts=$(followup_posts "$log") + [ "$posts" -eq 1 ] || fail "expected one reply after inbox cleanup, got $posts" + assert_grep '"request_id":"req-ctx"' "$log" "the reply must still target the original thread" + pass "the retained private request context keeps the original thread deliverable after inbox cleanup" +} + +# --- 6. completion semantics --------------------------------------------------- + +test_cleanup_refuses_while_a_public_reply_is_owed() { + local home rc + home=$(make_home cleanup-guard) + seed_commitment "$home" pf-guard req-guard discord main ship-task + fm_write_meta "$home/state/ship-task.meta" \ + "window=firstmate:fm-ship-task" \ + "worktree=$home/projects/gone" \ + "project=$home/projects/sample" \ + "harness=codex" \ + "kind=ship" \ + "mode=no-mistakes" + + rc=0 + PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_CONFIG_OVERRIDE="$home/config" "$TEARDOWN" ship-task \ + > "$home/teardown.out" 2> "$home/teardown.err" || rc=$? + [ "$rc" -ne 0 ] || fail "cleanup must refuse while a public reply is still owed" + assert_grep "still owes a public reply" "$home/teardown.err" "the refusal must be explicit" + assert_present "$home/state/ship-task.meta" "a refused cleanup must preserve the task record" + + # Once the reply has landed, the same cleanup is allowed to proceed. + emit_terminal "$home" "$home" pf-guard main ship-task >/dev/null || fail "emit failed" + run_pf "$home" consume >/dev/null || fail "consume failed" + FAKE_CURL_LOG="$home/curl.log" run_pf "$home" deliver pf-guard >/dev/null || fail "delivery failed" + rc=0 + PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_CONFIG_OVERRIDE="$home/config" "$TEARDOWN" ship-task >/dev/null 2>&1 || rc=$? + [ "$rc" -eq 0 ] || fail "cleanup must proceed once the public reply has landed (rc=$rc)" + pass "cleanup refuses while a public reply is owed and proceeds once it has landed" +} + +# --- 7. zero overhead for homes that do not use the relay ---------------------- + +# The hard acceptance criterion. A home that never opted into the myfirstmate +# relay must see no process, no tasks-axi call, no scan, no output, and no file. +test_relay_disabled_home_pays_nothing() { + local home tasks_log out rc before after cmd + home=$(make_home relay-disabled relay-off) + tasks_log="$home/tasks-axi.log"; : > "$tasks_log" + # Any tasks-axi invocation at all is a failure here, so make it loud. + cat > "$home/fakebin/tasks-axi" <<'SH' +#!/usr/bin/env bash +echo "$*" >> "$FAKE_TASKS_AXI_LOG" +exit 0 +SH + chmod +x "$home/fakebin/tasks-axi" + + before=$(find "$home/state" | LC_ALL=C sort) + for cmd in "consume" "pending" "guard-work main any-task" "retire anything"; do + rc=0 + # shellcheck disable=SC2086 # each cmd is a deliberate argument list + out=$(PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FAKE_TASKS_AXI_LOG="$tasks_log" "$PF" $cmd 2>&1) || rc=$? + [ "$rc" -eq 0 ] || fail "'$cmd' must be a silent success in a relay-disabled home (rc=$rc)" + [ -z "$out" ] || fail "'$cmd' must print nothing in a relay-disabled home, got: $out" + done + + rc=0 + PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FAKE_TASKS_AXI_LOG="$tasks_log" "$PF" active || rc=$? + [ "$rc" -eq 1 ] || fail "'active' must report inactive in a relay-disabled home" + + [ ! -s "$tasks_log" ] \ + || fail "a relay-disabled home must never invoke tasks-axi: $(cat "$tasks_log")" + after=$(find "$home/state" | LC_ALL=C sort) + [ "$before" = "$after" ] \ + || fail "a relay-disabled home must gain no public-followup artifact" + assert_absent "$home/state/public-followup" \ + "a relay-disabled home must never get a public-followup directory" + + # A child cannot force artifacts into a home that never opted in either. + rc=0 + out=$("$EMIT" --home "$home" --obligation pf-x --relation rel-code \ + --source-home main --work-id w --generation 1 --outcome pr-merged \ + --outcome-text 'x' 2>&1) || rc=$? + [ "$rc" -eq 0 ] || fail "emitting into a relay-disabled home must be a silent no-op (rc=$rc)" + [ -z "$out" ] || fail "emitting into a relay-disabled home must produce no output: $out" + assert_absent "$home/state/public-followup" \ + "a refused emit must not create a public-followup directory" + pass "a relay-disabled home runs no tasks-axi call, prints nothing, and gains no artifact" +} + +# An opted-in home that has never made a public commitment must not start paying +# either: the second gate is a directory presence check, not a backlog scan. +test_relay_enabled_empty_state_makes_no_calls() { + local home tasks_log out rc cmd + home=$(make_home relay-enabled-empty) + tasks_log="$home/tasks-axi.log"; : > "$tasks_log" + cat > "$home/fakebin/tasks-axi" <<'SH' +#!/usr/bin/env bash +echo "$*" >> "$FAKE_TASKS_AXI_LOG" +exit 0 +SH + chmod +x "$home/fakebin/tasks-axi" + + for cmd in "consume" "pending" "guard-work main any-task"; do + rc=0 + # shellcheck disable=SC2086 # each cmd is a deliberate argument list + out=$(PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FAKE_TASKS_AXI_LOG="$tasks_log" "$PF" $cmd 2>&1) || rc=$? + [ "$rc" -eq 0 ] || fail "'$cmd' must be a silent success with no commitments (rc=$rc)" + [ -z "$out" ] || fail "'$cmd' must print nothing with no commitments, got: $out" + done + [ ! -s "$tasks_log" ] \ + || fail "an empty relay home must not query the backlog: $(cat "$tasks_log")" + pass "a relay-enabled home with no commitments makes no backlog call and stays silent" +} + +# The relay's own refusal of an exhausted follow-up binding is a captain +# decision, not something to retry into a public thread. +test_exhausted_binding_is_not_retried() { + local home log out posts + home=$(make_home exhausted) + log="$home/curl.log"; : > "$log" + seed_commitment "$home" pf-gone req-gone x main work-gone + emit_terminal "$home" "$home" pf-gone main work-gone >/dev/null || fail "emit failed" + run_pf "$home" consume >/dev/null || fail "consume failed" + + FAKE_CURL_LOG="$log" FAKE_FOLLOWUP_CODE=409 \ + expect_failure "an exhausted binding must not be reported as delivered" \ + run_pf "$home" deliver pf-gone + assert_contains "$EXPECT_OUT" "captain decision" "an exhausted binding must be escalated, not retried" + [ "$(delivery_state "$home" pf-gone)" = expired-action-required ] \ + || fail "an exhausted binding must be recorded as needing action, got $(delivery_state "$home" pf-gone)" + [ "$(task_state "$home" pf-gone)" != 'done' ] \ + || fail "an exhausted binding must never close the commitment" + posts=$(followup_posts "$log") + [ "$posts" -eq 1 ] || fail "the refused attempt is one relay call, got $posts" + pass "a relay-exhausted follow-up binding is escalated rather than retried into the thread" +} + +# The relay poll is the only thing that runs on a cadence in an opted-in home, so +# it must stay a hard no-op without a token, and must not start scanning when a +# relay-enabled home has no public commitments at all. +test_relay_poll_stays_inert_and_surfaces_once() { + local off on out first second + off=$(make_home poll-off relay-off) + out=$(PATH="$off/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$off" \ + FM_STATE_OVERRIDE="$off/state" "$POLL" 2>&1) + [ -z "$out" ] || fail "the relay poll must stay silent without a token, got: $out" + assert_absent "$off/state/public-followup" "an inert poll must create nothing" + + on=$(make_home poll-on) + out=$(PATH="$on/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$on" \ + FM_STATE_OVERRIDE="$on/state" "$POLL" 2>&1) + assert_not_contains "$out" "public-followup" \ + "a relay home with no public commitments must not mention public follow-ups" + + seed_commitment "$on" pf-poll req-poll discord main work-poll + out=$(PATH="$on/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$on" \ + FM_STATE_OVERRIDE="$on/state" "$POLL" 2>&1) + assert_not_contains "$out" "public-followup" \ + "a registered commitment with no terminal result yet must not wake the poll" + + emit_terminal "$on" "$on" pf-poll main work-poll >/dev/null || fail "emit failed" + first=$(PATH="$on/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$on" \ + FM_STATE_OVERRIDE="$on/state" "$POLL" 2>&1) + assert_contains "$first" "public-followup terminal results are waiting" \ + "a new terminal result must surface through the existing relay poll" + second=$(PATH="$on/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$on" \ + FM_STATE_OVERRIDE="$on/state" "$POLL" 2>&1) + assert_not_contains "$second" "public-followup" \ + "an unchanged pending set must not wake firstmate again every cycle" + pass "the relay poll stays inert without a token, silent with no commitments, and surfaces a new result once" +} + +# --- 8. startup surfacing ------------------------------------------------------ + +test_session_start_surfaces_only_when_owed() { + local off on out + off=$(make_home startup-off relay-off) + out=$(PATH="$off/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$off" \ + FM_STATE_OVERRIDE="$off/state" FM_DATA_OVERRIDE="$off/data" \ + FM_CONFIG_OVERRIDE="$off/config" "$SESSION_START" 2>&1) + assert_not_contains "$out" "Public commitments" \ + "a relay-disabled home must not gain a public-commitments section at startup" + + on=$(make_home startup-on) + seed_commitment "$on" pf-start req-start discord main work-start + out=$(PATH="$on/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$on" \ + FM_STATE_OVERRIDE="$on/state" FM_DATA_OVERRIDE="$on/data" \ + FM_CONFIG_OVERRIDE="$on/config" "$SESSION_START" 2>&1) + assert_contains "$out" "Public commitments awaiting delivery" \ + "an unresolved commitment must be surfaced at startup" + assert_contains "$out" "unresolved pf-start state=pending-work platform=discord" \ + "the startup summary must be typed and actionable" + assert_contains "$out" "fix worker placement when two spaces share a name" \ + "the startup summary must carry the public-safe summary" + assert_not_contains "$out" "please fix worker placement" \ + "the startup summary must not carry raw request text" + pass "startup surfaces unresolved public commitments only in a relay home that owes one" +} + +# --- 9. typed records stay public-safe ---------------------------------------- + +test_typed_records_exclude_raw_public_material() { + local home backlog event + home=$(make_home privacy) + seed_commitment "$home" pf-priv req-priv discord main work-priv + emit_terminal "$home" "$home" pf-priv main work-priv >/dev/null || fail "emit failed" + event=$(find "$home/state/public-followup/events" -name '*.json' | head -1) + assert_no_grep 'please fix worker placement' "$event" \ + "a terminal event must not carry raw request text" + run_pf "$home" consume >/dev/null || fail "consume failed" + + backlog="$home/data/backlog.md" + assert_no_grep 'please fix worker placement' "$backlog" \ + "the backlog must never carry raw public message text" + # The typed record is base64url canonical JSON, so check the decoded payload too. + tasks_in "$home" public-followup list --json > "$home/typed.json" + assert_no_grep 'please fix worker placement' "$home/typed.json" \ + "the typed obligation must never carry raw public message text" + pass "typed public-followup records carry only public-safe summaries and deliverables" +} + +test_outcome_text_is_bounded_without_corrupting_characters +test_restart_e2e_delivers_exactly_once +test_duplicate_event_and_replay_are_noops +test_invalid_events_are_refused_and_quarantined +test_relay_failure_holds_without_false_completion +test_dry_run_does_not_close_commitment +test_late_receipt_closes_the_exact_attempt_without_reposting +test_typed_terminal_clear_only_removes_legacy_link +test_interrupted_delivery_refuses_to_repost +test_outward_delivery_stays_with_the_owning_home +test_delivery_requires_registration_before_posting +test_secondmate_teardown_requires_parent_binding +test_relay_disabled_unmarked_teardown_skips_public_path +test_relay_disabled_parent_allows_marked_child_teardown +test_secondmate_parent_binding_matches_literal_id +test_traversal_registration_is_refused_before_delivery +test_pending_rejects_malformed_listing +test_private_context_survives_inbox_cleanup +test_cleanup_refuses_while_a_public_reply_is_owed +test_relay_disabled_home_pays_nothing +test_relay_enabled_empty_state_makes_no_calls +test_exhausted_binding_is_not_retried +test_relay_poll_stays_inert_and_surfaces_once +test_session_start_surfaces_only_when_owed +test_typed_records_exclude_raw_public_material diff --git a/tests/fm-quota-array-dispatch-live-e2e.test.sh b/tests/fm-quota-array-dispatch-live-e2e.test.sh new file mode 100755 index 00000000000..0b7f1102aba --- /dev/null +++ b/tests/fm-quota-array-dispatch-live-e2e.test.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Credentialed behavior regression for the agent-owned quota-array-dispatch skill. +# +# This drives the public Pi skill-loading interface against a fake quota-axi +# executable rather than parsing instruction source bytes or recreating the +# selector in test code. +set -u + +if [ "${FM_QUOTA_ARRAY_DISPATCH_LIVE_E2E:-0}" != 1 ]; then + echo "skip: set FM_QUOTA_ARRAY_DISPATCH_LIVE_E2E=1 to run the credentialed Pi dispatch-selection regression" + exit 0 +fi + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OWNER="$ROOT/.agents/skills/quota-array-dispatch/SKILL.md" + +fail() { + printf 'not ok - %s\n' "$1" >&2 + exit 1 +} + +command -v pi >/dev/null 2>&1 || fail "pi not found" +[ -f "$OWNER" ] || fail "quota-array-dispatch skill not found" + +LAB=$(mktemp -d "${TMPDIR:-/tmp}/fm-quota-array-dispatch-live.XXXXXX") +PROJECT="$LAB/project" +FAKEBIN="$LAB/fakebin" +FIXTURE="$LAB/quota.json" +CALLS="$LAB/quota-axi.calls" + +cleanup() { + rm -rf "$LAB" +} +trap cleanup EXIT + +mkdir -p "$PROJECT/.agents/skills/quota-array-dispatch" "$FAKEBIN" +cp "$OWNER" "$PROJECT/.agents/skills/quota-array-dispatch/SKILL.md" + +cat > "$FAKEBIN/quota-axi" <<'SH' +#!/usr/bin/env bash +set -u +if [ "${1:-}" != --json ] || [ "$#" -ne 1 ]; then + printf 'unexpected quota-axi invocation: %s\n' "$*" >&2 + exit 64 +fi +printf '%s\n' "$*" >> "${QUOTA_AXI_CALLS:?}" +cat "${QUOTA_AXI_FIXTURE:?}" +SH +chmod +x "$FAKEBIN/quota-axi" + +write_fixture() { + cat > "$FIXTURE" +} + +run_case() { + local label=$1 expected=$2 prompt=$3 out calls required + shift 3 + : > "$CALLS" + out=$( + cd "$PROJECT" && + PATH="$FAKEBIN:$PATH" QUOTA_AXI_CALLS="$CALLS" QUOTA_AXI_FIXTURE="$FIXTURE" \ + pi --print --approve --no-session --no-context-files --no-extensions \ + --no-skills --skill .agents/skills --tools bash \ + --model openai-codex/gpt-5.6-sol --thinking high \ + "$prompt" + ) || fail "$label: Pi skill run failed: $out" + calls=$(cat "$CALLS") + [ "$calls" = "--json" ] || fail "$label: skill did not use one quota-axi --json snapshot: $calls" + printf '%s\n' "$out" | grep -Fxq "$expected" \ + || fail "$label: expected final line $expected, got: $out" + for required in "$@"; do + printf '%s\n' "$out" | grep -Fxq "$required" \ + || fail "$label: expected accounting line $required, got: $out" + done + printf '%s\n' "$out" + printf 'ok - %s\n' "$label" +} + +write_fixture <<'JSON' +{"schemaVersion":3,"providers":[{"provider":"claude","quotaSemantics":{"description":"The all_models scope bounds every Claude model.","effectiveAvailability":[{"scope":"all_models","status":"known","effectivePercentRemaining":1,"boundedBy":["weekly"],"runway":{"status":"projected_exhaustion","usableRunwaySeconds":600,"projectedExhaustedAt":"2030-01-01T00:10:00Z","limitingWindowId":"weekly","projectionConfidence":"established","projectionBasis":"cycle_average"}}]},"effectivePace":[{"scope":"all_models","pace":"ahead","worstReservePercentPoints":-1}]},{"provider":"codex","quotaSemantics":{"description":"The all_models scope bounds every Codex model.","effectiveAvailability":[{"scope":"all_models","status":"known","effectivePercentRemaining":55,"boundedBy":["weekly"],"runway":{"status":"projected_exhaustion","usableRunwaySeconds":14400,"projectedExhaustedAt":"2030-01-01T04:00:00Z","limitingWindowId":"weekly","projectionConfidence":"established","projectionBasis":"cycle_average"}}]},"effectivePace":[{"scope":"all_models","pace":"ahead","worstReservePercentPoints":-40}]}]} +JSON +run_case \ + "higher headroom and viable runway beat a less-negative reserve" \ + "SELECTED=codex" \ + "Resolve this matched dispatch profile array now. Load quota-array-dispatch and run quota-axi --json exactly once. Both profiles have comparable required task fit and the same strongest reasoning class. The authoritative catalogs already prove Claude/Sonnet and Codex/GPT models supported in their stated provider families, and their selected authentication surfaces are usable. The likely task-completion horizon is two hours with established confidence. Return exact lines FACT=claude|headroom=1|runway_seconds=600|reserve=-1 and FACT=codex|headroom=55|runway_seconds=14400|reserve=-40 to preserve candidate accounting, then an exact final line SELECTED=<claude|codex>. Do not use other vendor or model commands and do not modify files." \ + "FACT=claude|headroom=1|runway_seconds=600|reserve=-1" \ + "FACT=codex|headroom=55|runway_seconds=14400|reserve=-40" + +write_fixture <<'JSON' +{"schemaVersion":3,"providers":[{"provider":"claude","quotaSemantics":{"description":"The all_models scope bounds every Claude model.","effectiveAvailability":[{"scope":"all_models","status":"known","effectivePercentRemaining":55,"boundedBy":["weekly"],"runway":{"status":"unknown","unmeasurableWindowIds":["weekly"]}}]}},{"provider":"codex","quotaSemantics":{"description":"The all_models scope bounds every Codex model.","effectiveAvailability":[{"scope":"all_models","status":"known","effectivePercentRemaining":45,"boundedBy":["weekly"],"runway":{"status":"projected_exhaustion","usableRunwaySeconds":14400,"projectedExhaustedAt":"2030-01-01T04:00:00Z","limitingWindowId":"weekly","projectionConfidence":"established","projectionBasis":"cycle_average"}}]}}]} +JSON +run_case \ + "unmeasurable runway stays eligible and is accounted for explicitly" \ + "DECISION=CODEX" \ + "Resolve this matched dispatch profile array now. Load quota-array-dispatch and run quota-axi --json exactly once. Both profiles have comparable required task fit and the same strongest reasoning class. The authoritative catalogs already prove both models supported in their stated provider families, and their selected authentication surfaces are usable. The likely task-completion horizon is two hours with established confidence. Claude has higher known headroom but explicitly unmeasurable runway, while Codex has lower known headroom and established runway that supports completion. The snapshot cannot prove Pareto dominance in either direction, but the known completion-supporting runway justifies Codex while Claude remains eligible and its uncertainty must be disclosed. Return exact lines FACT=claude|eligible=yes|headroom=55|runway=unknown|unmeasurable=weekly and FACT=codex|eligible=yes|headroom=45|runway_seconds=14400|supports_horizon=yes, then an exact final line DECISION=CODEX. Do not use other vendor or model commands and do not modify files." \ + "FACT=claude|eligible=yes|headroom=55|runway=unknown|unmeasurable=weekly" \ + "FACT=codex|eligible=yes|headroom=45|runway_seconds=14400|supports_horizon=yes" + +write_fixture <<'JSON' +{"schemaVersion":3,"providers":[{"provider":"claude","quotaSemantics":{"description":"The all_models scope bounds every Claude model.","effectiveAvailability":[{"scope":"all_models","status":"known","effectivePercentRemaining":1,"boundedBy":["weekly"],"runway":{"status":"projected_exhaustion","usableRunwaySeconds":10800,"projectedExhaustedAt":"2030-01-01T03:00:00Z","limitingWindowId":"weekly","projectionConfidence":"established","projectionBasis":"cycle_average"}}]}},{"provider":"codex","quotaSemantics":{"description":"The all_models scope bounds every Codex model.","effectiveAvailability":[{"scope":"all_models","status":"known","effectivePercentRemaining":80,"boundedBy":["weekly"],"runway":{"status":"projected_exhaustion","usableRunwaySeconds":28800,"projectedExhaustedAt":"2030-01-01T08:00:00Z","limitingWindowId":"weekly","projectionConfidence":"established","projectionBasis":"cycle_average"}}]}}]} +JSON +run_case \ + "required strongest reasoning class is not downgraded for quota" \ + "SELECTED=claude" \ + "Resolve this matched dispatch profile array now. Load quota-array-dispatch and run quota-axi --json exactly once. The likely task-completion horizon is two hours with established confidence. Claude/Sonnet is catalog-supported with usable authentication and is the only profile that meets the task's required strongest reasoning class. Codex/GPT is catalog-supported with usable authentication but is a weaker reasoning class and cannot meet the requirement. Return exact lines FACT=claude|reasoning=required|headroom=1|runway_seconds=10800 and FACT=codex|reasoning=weaker|headroom=80|runway_seconds=28800, then an exact final line SELECTED=<claude|codex>. Do not use other vendor or model commands and do not modify files." \ + "FACT=claude|reasoning=required|headroom=1|runway_seconds=10800" \ + "FACT=codex|reasoning=weaker|headroom=80|runway_seconds=28800" + +echo "# all quota-array-dispatch live behavior tests passed" diff --git a/tests/fm-secondmate-harness.test.sh b/tests/fm-secondmate-harness.test.sh index ae41c793516..81868a160e0 100755 --- a/tests/fm-secondmate-harness.test.sh +++ b/tests/fm-secondmate-harness.test.sh @@ -14,7 +14,8 @@ # explicit per-spawn harness arg still wins. # B) Inheritance. The primary pushes a declared, extensible set of LOCAL # (gitignored) config items - config/crew-dispatch.json, config/crew-harness, -# config/backlog-backend, config/backend, and config/herdr-presentation-spaces - +# config/backlog-backend, config/backend, config/herdr-presentation-spaces, and +# config/startup-memory-budget - # down into each secondmate home's config/, so the secondmate's OWN crewmates, # dispatch profiles, backlog backend, runtime-backend default, and Herdr # presentation opt-in inherit the primary's settings. It is primary-authoritative @@ -44,6 +45,15 @@ set -u # shellcheck source=/dev/null . "$ROOT/bin/fm-config-inherit-lib.sh" +# The harness-detection cases below fake `ps` so process ancestry is fully +# controlled, but bin/fm-harness.sh checks verified ENV markers before ancestry. +# A suite run from inside one of those harnesses inherits its marker, and the +# highest-precedence one wins over everything these cases set up: with an +# ambient CLAUDECODE=1, the pi-signed ancestry case resolves "claude". Drop the +# ambient markers so what this suite asserts does not depend on which harness it +# was launched from; every case states the marker it means to test. +unset CLAUDECODE PI_CODING_AGENT FM_PI_HARNESS GROK_AGENT + BASE_PATH=${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin} fm_git_identity fmtest fmtest@example.com TMP_ROOT=$(fm_test_tmproot fm-secondmate-harness) @@ -159,19 +169,19 @@ esac SH chmod +x "$fakebin/ps" - got=$(PATH="$fakebin:$BASE_PATH" PI_CODING_AGENT=true "$ROOT/bin/fm-harness.sh") + got=$(env -u CLAUDECODE -u GROK_AGENT 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=$(env -u CLAUDECODE -u GROK_AGENT 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=$(env -u CLAUDECODE -u GROK_AGENT 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=$(env -u CLAUDECODE -u GROK_AGENT 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=$(env -u CLAUDECODE -u GROK_AGENT -u PI_CODING_AGENT 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=$(env -u CLAUDECODE -u GROK_AGENT 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=$(env -u CLAUDECODE -u GROK_AGENT 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 \ @@ -384,6 +394,31 @@ make_noop_tmux() { mkdir -p "$fakebin" cat > "$fakebin/tmux" <<'SH' #!/usr/bin/env bash +set -u +case "${1:-}" in + capture-pane) cat "$(dirname "$0")/.spawn-screen" 2>/dev/null || true; exit 0 ;; + send-keys) + screen="$(dirname "$0")/.spawn-screen" + staged="$screen.staged" + text=${4:-} + case "$text" in + *"__FM_SPAWN_READY_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$screen" + ;; + "FM_SPAWN_LAUNCH=''") : > "$staged" ;; + FM_SPAWN_LAUNCH=*) + rebuilt=$(FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") + printf '%s' "$rebuilt" > "$staged" + ;; + *"__FM_SPAWN_LAUNCH_OK_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\n' "$token" > "$screen" + ;; + esac + exit 0 + ;; +esac exit 0 SH chmod +x "$fakebin/tmux" @@ -570,16 +605,29 @@ case "${1:-}" in display-message) printf 'firstmate\n'; exit 0 ;; list-windows) exit 0 ;; has-session|new-session|new-window|kill-window) exit 0 ;; + capture-pane) cat "$(dirname "$0")/.spawn-screen" 2>/dev/null || true; exit 0 ;; send-keys) - if [ -n "${FM_FAKE_LAUNCH_LOG:-}" ]; then - prev= - for a in "$@"; do - if [ "$prev" = "-l" ]; then - printf '%s\n' "$a" >> "$FM_FAKE_LAUNCH_LOG" - fi - prev=$a - done - fi + screen="$(dirname "$0")/.spawn-screen" + staged="$screen.staged" + text=${4:-} + case "$text" in + *"__FM_SPAWN_READY_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$screen" + ;; + "FM_SPAWN_LAUNCH=''") : > "$staged" ;; + FM_SPAWN_LAUNCH=*) + rebuilt=$(FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") + printf '%s' "$rebuilt" > "$staged" + ;; + *"__FM_SPAWN_LAUNCH_OK_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\n' "$token" > "$screen" + ;; + 'eval "$FM_SPAWN_LAUNCH"') + [ -z "${FM_FAKE_LAUNCH_LOG:-}" ] || cat "$staged" >> "$FM_FAKE_LAUNCH_LOG" + ;; + esac exit 0 ;; esac @@ -883,7 +931,7 @@ new_world() { printf 'projects/\nstate/\ndata/\n.no-mistakes/\n' [ "$dispatch_ignore" = no ] || printf 'config/crew-dispatch.json\n' printf 'config/crew-harness\nconfig/secondmate-harness\nconfig/backlog-backend\n' - printf 'config/backend\nconfig/herdr-presentation-spaces\n' + printf 'config/backend\nconfig/herdr-presentation-spaces\nconfig/startup-memory-budget\n' } > "$w/main/.gitignore" printf 'v1\n' > "$w/main/AGENTS.md" printf 'r1\n' > "$w/main/README.md" @@ -1176,10 +1224,10 @@ test_bootstrap_sweep_defers_dispatch_on_stale_unignored_home() { pass "B9 bootstrap sweep defers new inherited config until the home ignores it" } -# Backward-compat: with no inherited config set, the sweep is a no-op for the -# home's config/ - exactly as before this feature - and ordinary sweep behavior -# (fast-forward) is unaffected. -test_bootstrap_sweep_no_inheritance_is_noop() { +# The primary bootstrap always materializes the startup-memory default, so an +# otherwise empty inherited surface converges that one visible value while +# ordinary tracked-file fast-forward behavior remains unchanged. +test_bootstrap_sweep_materializes_and_inherits_memory_default() { local w c1 w=$(new_world boot-noop) c1=$(git -C "$w/main" rev-parse HEAD) @@ -1193,13 +1241,16 @@ test_bootstrap_sweep_no_inheritance_is_noop() { run_bootstrap "$w" >/dev/null - [ -e "$w/sm/config/crew-dispatch.json" ] && fail "no-inheritance sweep created a home crew-dispatch.json" - [ -e "$w/sm/config/crew-harness" ] && fail "no-inheritance sweep created a home crew-harness" - [ -e "$w/sm/config/backend" ] && fail "no-inheritance sweep created a home backend" - [ -e "$w/sm/config" ] && fail "no-inheritance sweep created a home config/ dir" + [ -e "$w/sm/config/crew-dispatch.json" ] && fail "default-only sweep created a home crew-dispatch.json" + [ -e "$w/sm/config/crew-harness" ] && fail "default-only sweep created a home crew-harness" + [ -e "$w/sm/config/backend" ] && fail "default-only sweep created a home backend" + [ "$(cat "$w/home/config/startup-memory-budget")" = 7500 ] \ + || fail "primary bootstrap did not materialize the startup-memory default" + [ "$(cat "$w/sm/config/startup-memory-budget")" = 7500 ] \ + || fail "default-only sweep did not converge startup-memory-budget" [ "$(git -C "$w/sm" rev-parse HEAD)" = "$head" ] \ - || fail "no-inheritance sweep did not still fast-forward the tracked files" - pass "B10 bootstrap sweep with no inherited config is a config no-op and still fast-forwards" + || fail "default-only sweep did not still fast-forward the tracked files" + pass "B10 bootstrap sweep materializes and inherits the startup-memory default while fast-forwarding" } # config/backend: present and absent primary state converges exactly. @@ -2193,6 +2244,7 @@ cat > "$w/main/bin/fm-spawn.sh" <<SH . '$w/main/bin/fm-config-inherit-lib.sh' printf '%s' spawn >> '$log' printf '%s' codex > '$w/sm/config/crew-harness' +printf '%s\n' 7500 > '$w/sm/config/startup-memory-budget' SH chmod +x "$w/main/bin/fm-spawn.sh" fakebin=$(make_fake_toolchain "$w") @@ -2317,7 +2369,7 @@ test_spawn_fallback_chain_and_crew_scout_unaffected test_bootstrap_sweep_propagates_and_reconverges test_bootstrap_sweep_propagates_when_tracked_current test_bootstrap_sweep_defers_dispatch_on_stale_unignored_home -test_bootstrap_sweep_no_inheritance_is_noop +test_bootstrap_sweep_materializes_and_inherits_memory_default test_backend_inheritance_present_and_absent test_bootstrap_sweep_surfaces_config_propagation_failure test_bootstrap_rereads_after_partial_propagation diff --git a/tests/fm-secondmate-liveness.test.sh b/tests/fm-secondmate-liveness.test.sh index ed356638962..36a2e0c944a 100755 --- a/tests/fm-secondmate-liveness.test.sh +++ b/tests/fm-secondmate-liveness.test.sh @@ -286,6 +286,31 @@ case "${1:-}" in [ "${1:-}" = new-window ] && rm -f "${FM_TMUX_CALL_LOG}.killed" exit 0 ;; + capture-pane) + cat "${FM_TMUX_CALL_LOG:?}.spawn-screen" 2>/dev/null || true + exit 0 + ;; + send-keys) + screen="${FM_TMUX_CALL_LOG:?}.spawn-screen" + staged="$screen.staged" + text=${4:-} + case "$text" in + *"__FM_SPAWN_READY_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$screen" + ;; + "FM_SPAWN_LAUNCH=''") : > "$staged" ;; + FM_SPAWN_LAUNCH=*) + rebuilt=$(FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") + printf '%s' "$rebuilt" > "$staged" + ;; + *"__FM_SPAWN_LAUNCH_OK_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\n' "$token" > "$screen" + ;; + esac + exit 0 + ;; has-session) exit 0 ;; esac exit 0 diff --git a/tests/fm-secondmate-safety.test.sh b/tests/fm-secondmate-safety.test.sh index 31331621c82..8295c21d71b 100755 --- a/tests/fm-secondmate-safety.test.sh +++ b/tests/fm-secondmate-safety.test.sh @@ -109,6 +109,72 @@ EOF pass "seed allows overlapping project clone lists and drops the owns/owner routing" } +test_home_seed_validate_rejects_unparseable_registry_entry() { + local home err + home="$TMP_ROOT/unparseable-registry-home" + err="$TMP_ROOT/unparseable-registry.err" + mkdir -p "$home/data" + printf '%s\n' '- broken - prose (home: /tmp/child; scope: missing projects and date)' > "$home/data/secondmates.md" + if FM_HOME="$home" "$ROOT/bin/fm-home-seed.sh" validate >/dev/null 2>"$err"; then + fail "home-seed validation accepted an operationally unparseable registry record" + fi + grep -F 'malformed secondmate registry entry' "$err" >/dev/null \ + || fail "home-seed validation did not explain the malformed registry record" + pass "home-seed validation rejects registry records no operational parser can consume" +} + +test_home_seed_refuses_broken_registry_symlink() { + local home sub err target + home="$TMP_ROOT/broken-registry-symlink-home" + sub="$TMP_ROOT/broken-registry-symlink-subhome" + err="$TMP_ROOT/broken-registry-symlink.err" + target="$home/data/missing-secondmates.md" + mkdir -p "$home/data" "$home/state" "$home/projects" + ln -s "$target" "$home/data/secondmates.md" + if FM_HOME="$home" "$ROOT/bin/fm-home-seed.sh" validate >/dev/null 2>"$err"; then + fail "home-seed validation accepted a broken registry symlink" + fi + grep -F 'secondmate registry is unavailable or unsafe' "$err" >/dev/null \ + || fail "home-seed validation did not explain the broken registry symlink" + if FM_HOME="$home" FM_SECONDMATE_CHARTER='design domain' \ + "$ROOT/bin/fm-home-seed.sh" design "$sub" alpha >/dev/null 2>"$err"; then + fail "home seeding accepted a broken registry symlink" + fi + [ -L "$home/data/secondmates.md" ] || fail "home seeding replaced the broken registry symlink" + [ ! -e "$target" ] || fail "home seeding wrote through the broken registry symlink" + [ ! -e "$sub" ] || fail "home seeding provisioned a home before broken registry refusal" + [ ! -e "$home/data/design" ] || fail "home seeding created a brief before broken registry refusal" + pass "home seeding refuses broken registry symlinks before provisioning" +} + +test_home_seed_refuses_unreadable_registry() { + local home sub err registry + home="$TMP_ROOT/unreadable-registry-home" + sub="$TMP_ROOT/unreadable-registry-subhome" + err="$TMP_ROOT/unreadable-registry.err" + registry="$home/data/secondmates.md" + mkdir -p "$home/data" "$home/state" "$home/projects" + printf '%s\n' '- design - design domain (home: /tmp/design; scope: design; projects: alpha; added 2026-07-30)' > "$registry" + chmod 000 "$registry" + if FM_HOME="$home" "$ROOT/bin/fm-home-seed.sh" validate >/dev/null 2>"$err"; then + chmod 600 "$registry" + fail "home-seed validation accepted an unreadable registry" + fi + grep -F 'secondmate registry is unavailable or unsafe' "$err" >/dev/null || { + chmod 600 "$registry" + fail "home-seed validation did not explain the unreadable registry" + } + if FM_HOME="$home" FM_SECONDMATE_CHARTER='design domain' \ + "$ROOT/bin/fm-home-seed.sh" design "$sub" alpha >/dev/null 2>"$err"; then + chmod 600 "$registry" + fail "home seeding accepted an unreadable registry" + fi + chmod 600 "$registry" + [ ! -e "$sub" ] || fail "home seeding provisioned a home before unreadable registry refusal" + [ ! -e "$home/data/design" ] || fail "home seeding created a brief before unreadable registry refusal" + pass "home seeding refuses unreadable registries before provisioning" +} + test_home_seed_validate_rejects_duplicate_homes() { local home subhome subhome_abs err home="$TMP_ROOT/duplicate-home" @@ -444,6 +510,94 @@ test_home_seed_no_projects_end_to_end() { pass "home seeding scaffolds, registers, and spawns a project-less home end to end" } +test_secondmate_spawn_resolves_punctuated_registry_projects() { + local home sub sub_abs fakebin log meta projects + home="$TMP_ROOT/punctuated-spawn-home" + sub="$TMP_ROOT/punctuated-spawn-subhome" + mkdir -p "$home/data" "$home/state" "$home/config" "$home/projects" + mkdir -p "$sub/data" "$sub/state" "$sub/config" "$sub/projects" + mark_firstmate_home "$sub" + printf 'punctuated\n' > "$sub/.fm-secondmate-home" + printf '# Charter\n\nHandled work.\n' > "$sub/data/charter.md" + sub_abs=$(cd "$sub" && pwd -P) + printf -- '- punctuated - launch notes (parenthetical) (home: %s; scope: launch (child); semicolon is valid; projects: alpha, beta; added 2026-07-30)' \ + "$sub_abs" > "$home/data/secondmates.md" + FM_HOME="$home" "$ROOT/bin/fm-home-seed.sh" validate >/dev/null \ + || fail "home-seed validation rejected punctuated registry fields before spawn" + fakebin=$(make_fake_tmux "$TMP_ROOT/punctuated-spawn-fake") + log="$TMP_ROOT/punctuated-spawn-fake/tmux.log" + PATH="$fakebin:$PATH" FM_HOME="$home" FM_FAKE_TMUX_LOG="$log" \ + FM_FAKE_TMUX_CAPTURE="$TMP_ROOT/punctuated-spawn-fake/pane.txt" \ + "$ROOT/bin/fm-spawn.sh" punctuated codex --secondmate >/dev/null 2>&1 \ + || fail "secondmate spawn failed for punctuated registry fields" + meta="$home/state/punctuated.meta" + projects=$(grep '^projects=' "$meta" | cut -d= -f2-) + [ "$projects" = 'alpha, beta' ] \ + || fail "secondmate spawn resolved the wrong projects field: '$projects'" + pass "secondmate spawn resolves home validation and projects from punctuated registry fields" +} + +test_secondmate_spawn_refuses_ambiguous_and_mismatched_registry_bindings() { + local row case_name home sub other fakebin log err meta_before + for row in duplicate-id unterminated-duplicate-id duplicate-home supplied-mismatch metadata-mismatch; do + case_name=${row%%|*} + home="$TMP_ROOT/spawn-binding-$case_name-home" + sub="$TMP_ROOT/spawn-binding-$case_name-sub" + other="$TMP_ROOT/spawn-binding-$case_name-other" + mkdir -p "$home/data" "$home/state" "$home/config" "$home/projects" + mark_firstmate_home "$sub" + mark_firstmate_home "$other" + printf 'domain\n' > "$sub/.fm-secondmate-home" + printf 'domain\n' > "$other/.fm-secondmate-home" + case "$case_name" in + duplicate-id) + cat > "$home/data/secondmates.md" <<EOF +- domain - primary route (home: $sub; scope: valid (scope); punctuation; projects: alpha; added 2026-07-30) +- domain - duplicate route (home: $other; scope: duplicate; projects: beta; added 2026-07-30) +EOF + ;; + unterminated-duplicate-id) + printf -- '- domain - primary route (home: %s; scope: valid (scope); punctuation; projects: alpha; added 2026-07-30)\n- domain - duplicate route (home: %s; scope: duplicate; projects: beta; added 2026-07-30)' \ + "$sub" "$other" > "$home/data/secondmates.md" + ;; + duplicate-home) + cat > "$home/data/secondmates.md" <<EOF +- domain - primary route (home: $sub; scope: valid (scope); punctuation; projects: alpha; added 2026-07-30) +- other - duplicate home route (home: $sub; scope: duplicate; projects: beta; added 2026-07-30) +EOF + ;; + supplied-mismatch|metadata-mismatch) + printf -- '- domain - mismatched route (home: %s; scope: valid (scope); punctuation; projects: alpha; added 2026-07-30)\n' \ + "$other" > "$home/data/secondmates.md" + ;; + esac + fakebin=$(make_fake_tmux "$TMP_ROOT/spawn-binding-$case_name-fake") + log="$TMP_ROOT/spawn-binding-$case_name-fake/tmux.log" + err="$TMP_ROOT/spawn-binding-$case_name.err" + if [ "$case_name" = metadata-mismatch ]; then + fm_write_secondmate_meta "$home/state/domain.meta" "$sub" + meta_before="$TMP_ROOT/spawn-binding-$case_name.meta.before" + cp "$home/state/domain.meta" "$meta_before" + if PATH="$fakebin:$PATH" FM_HOME="$home" FM_FAKE_TMUX_LOG="$log" \ + FM_FAKE_TMUX_CAPTURE="$TMP_ROOT/spawn-binding-$case_name-fake/pane.txt" \ + "$ROOT/bin/fm-spawn.sh" domain codex --secondmate >/dev/null 2>"$err"; then + fail "secondmate spawn accepted $case_name registry binding" + fi + cmp -s "$meta_before" "$home/state/domain.meta" || fail "secondmate spawn changed metadata after $case_name refusal" + else + if PATH="$fakebin:$PATH" FM_HOME="$home" FM_FAKE_TMUX_LOG="$log" \ + FM_FAKE_TMUX_CAPTURE="$TMP_ROOT/spawn-binding-$case_name-fake/pane.txt" \ + "$ROOT/bin/fm-spawn.sh" domain "$sub" codex --secondmate >/dev/null 2>"$err"; then + fail "secondmate spawn accepted $case_name registry binding" + fi + [ ! -e "$home/state/domain.meta" ] || fail "secondmate spawn wrote metadata after $case_name refusal" + fi + [ ! -e "$home/state/.spawn-domain.lock" ] || fail "secondmate spawn left a lock after $case_name refusal" + grep -F 'new-window' "$log" >/dev/null && fail "secondmate spawn created an endpoint before $case_name refusal" + done + pass "secondmate spawn refuses ambiguous, supplied-home, and metadata-home registry bindings" +} + test_home_seed_refuses_projectful_reused_charter_for_projectless_home() { local home reusable_sub stale_sub stale_brief stale_brief_before err home="$TMP_ROOT/no-projects-reused-charter-home" @@ -1316,6 +1470,53 @@ EOF pass "secondmate teardown retires empty homes and releases routing" } +test_secondmate_teardown_refuses_ambiguous_and_mismatched_registry_bindings() { + local case_name home sub other fakebin log err meta_before registry_before + for case_name in duplicate-id duplicate-home home-mismatch; do + home="$TMP_ROOT/teardown-binding-$case_name-home" + sub="$TMP_ROOT/teardown-binding-$case_name-sub" + other="$TMP_ROOT/teardown-binding-$case_name-other" + mkdir -p "$home/state" "$home/data" "$sub/state" "$sub/data" "$sub/config" "$sub/projects" "$other" + printf 'domain\n' > "$sub/.fm-secondmate-home" + fm_write_secondmate_meta "$home/state/domain.meta" "$sub" + case "$case_name" in + duplicate-id) + cat > "$home/data/secondmates.md" <<EOF +- domain - primary route (home: $sub; scope: valid (scope); punctuation; projects: alpha; added 2026-07-30) +- domain - duplicate route (home: $other; scope: duplicate; projects: beta; added 2026-07-30) +EOF + ;; + duplicate-home) + cat > "$home/data/secondmates.md" <<EOF +- domain - primary route (home: $sub; scope: valid (scope); punctuation; projects: alpha; added 2026-07-30) +- other - duplicate home route (home: $sub; scope: duplicate; projects: beta; added 2026-07-30) +EOF + ;; + home-mismatch) + printf -- '- domain - mismatched route (home: %s; scope: valid (scope); punctuation; projects: alpha; added 2026-07-30)\n' \ + "$other" > "$home/data/secondmates.md" + ;; + esac + meta_before="$TMP_ROOT/teardown-binding-$case_name.meta.before" + registry_before="$TMP_ROOT/teardown-binding-$case_name.registry.before" + cp "$home/state/domain.meta" "$meta_before" + cp "$home/data/secondmates.md" "$registry_before" + fakebin=$(make_fake_tmux "$TMP_ROOT/teardown-binding-$case_name-fake") + log="$TMP_ROOT/teardown-binding-$case_name-fake/tmux.log" + err="$TMP_ROOT/teardown-binding-$case_name.err" + if PATH="$fakebin:$PATH" FM_HOME="$home" FM_FAKE_TMUX_LOG="$log" \ + FM_FAKE_TMUX_CAPTURE="$TMP_ROOT/teardown-binding-$case_name-fake/pane.txt" \ + "$ROOT/bin/fm-teardown.sh" domain --force >/dev/null 2>"$err"; then + fail "secondmate teardown accepted $case_name registry binding" + fi + [ -d "$sub" ] || fail "secondmate teardown removed the home after $case_name refusal" + cmp -s "$meta_before" "$home/state/domain.meta" || fail "secondmate teardown changed metadata after $case_name refusal" + cmp -s "$registry_before" "$home/data/secondmates.md" || fail "secondmate teardown changed registry after $case_name refusal" + grep -F 'kill-window' "$log" >/dev/null && fail "secondmate teardown killed an endpoint before $case_name refusal" + done + pass "secondmate teardown refuses ambiguous and identity-mismatched registry bindings" +} + test_secondmate_teardown_refuses_failed_leased_home_return() { local home subhome subhome_abs fakebin log fmroot err rc home="$TMP_ROOT/teardown-return-fail-home" @@ -2167,6 +2368,9 @@ EOF test_fm_home_parameterization test_lock_status_is_per_home test_seed_allows_overlapping_clones_and_drops_owner +test_home_seed_validate_rejects_unparseable_registry_entry +test_home_seed_refuses_broken_registry_symlink +test_home_seed_refuses_unreadable_registry test_home_seed_validate_rejects_duplicate_homes test_home_seed_validate_rejects_duplicate_ids test_home_seed_validate_rejects_nested_homes @@ -2179,6 +2383,8 @@ test_home_seed_refuses_missing_filled_charter test_home_seed_refuses_placeholder_charter test_home_seed_refuses_empty_charter_fields test_home_seed_no_projects_end_to_end +test_secondmate_spawn_resolves_punctuated_registry_projects +test_secondmate_spawn_refuses_ambiguous_and_mismatched_registry_bindings test_home_seed_refuses_projectful_reused_charter_for_projectless_home test_home_seed_refuses_projectless_conversion_of_populated_home test_home_seed_refuses_projectless_home_with_uninspectable_projects @@ -2205,6 +2411,7 @@ test_secondmate_spawn_requires_seeded_matching_home test_secondmate_spawn_refuses_operational_dirs_outside_subhome test_fm_send_refuses_bare_window_without_home_meta test_secondmate_teardown_retires_empty_home +test_secondmate_teardown_refuses_ambiguous_and_mismatched_registry_bindings test_secondmate_teardown_refuses_failed_leased_home_return test_secondmate_teardown_removes_plain_clone_home_without_treehouse_return test_secondmate_force_teardown_discards_child_work diff --git a/tests/fm-secondmate-sync.test.sh b/tests/fm-secondmate-sync.test.sh index 50455dc2c79..d613497bece 100755 --- a/tests/fm-secondmate-sync.test.sh +++ b/tests/fm-secondmate-sync.test.sh @@ -347,6 +347,9 @@ SH chmod +x "$fakebin/tasks-axi" cat > "$fakebin/quota-axi" <<'SH' #!/usr/bin/env bash +if [ "${1:-}" = --version ]; then + printf '%s\n' 'quota-axi 0.1.16 (fake)' +fi exit 0 SH chmod +x "$fakebin/quota-axi" diff --git a/tests/fm-send-settle.test.sh b/tests/fm-send-settle.test.sh index 01d2d427e7b..3961bfe85ae 100755 --- a/tests/fm-send-settle.test.sh +++ b/tests/fm-send-settle.test.sh @@ -16,6 +16,8 @@ set -u # shellcheck source=tests/lib.sh . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +# shellcheck source=/dev/null +. "$ROOT/bin/fm-busy-lib.sh" SEND="$ROOT/bin/fm-send.sh" @@ -116,7 +118,29 @@ test_key_path_never_pauses() { pass "fm-send: the --key path never pauses (settle scoped to text submit)" } +test_claude_escape_records_interrupt_idle() { + local dir fb log rc home gen out + dir="$TMP_ROOT/claude-interrupt"; mkdir -p "$dir" + fb=$(make_stubs "$dir"); log="$dir/sleep.log" + home="$dir/home"; mkdir -p "$home/state" + fm_write_meta "$home/state/task.meta" \ + "window=sess:win" "worktree=$home/wt" "project=$home/project" \ + "harness=claude" "kind=ship" "mode=no-mistakes" "yolo=off" + gen=$("$ROOT/bin/fm-busy-event.sh" arm "$home/state" task) + printf 'busy_gen=%s\n' "$gen" >> "$home/state/task.meta" + : > "$log" + + env PATH="$fb:$PATH" FM_HOME="$home" FM_SLEEP_LOG="$log" \ + "$SEND" task --key Escape 2>/dev/null; rc=$? + expect_code 0 "$rc" "Claude Escape send should succeed" + out=$(fm_busy_classify tmux sess:win claude task "$home/state") + [ "$out" = "idle fm-interrupt" ] \ + || fail "Claude Escape must classify idle/fm-interrupt, got '$out'" + pass "fm-send: a successful Claude Escape records the interrupt lifecycle edge" +} + test_default_send_pauses_one_second test_zero_disables_pause test_pause_is_tunable test_key_path_never_pauses +test_claude_escape_records_interrupt_idle diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index 37b2a3c9c6f..f1f74cb1603 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -304,7 +304,32 @@ case "${1:-}" in printf '%%1\n' exit 0 ;; - set-window-option|send-keys) exit 0 ;; + set-window-option) exit 0 ;; + capture-pane) + cat "$spawned.spawn-screen" 2>/dev/null || true + exit 0 + ;; + send-keys) + screen="$spawned.spawn-screen" + staged="$screen.staged" + text=${4:-} + case "$text" in + *"__FM_SPAWN_READY_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$screen" + ;; + "FM_SPAWN_LAUNCH=''") : > "$staged" ;; + FM_SPAWN_LAUNCH=*) + rebuilt=$(FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") + printf '%s' "$rebuilt" > "$staged" + ;; + *"__FM_SPAWN_LAUNCH_OK_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\n' "$token" > "$screen" + ;; + esac + exit 0 + ;; esac exit 0 SH @@ -313,6 +338,9 @@ SH make_fake_herdr_secondmate_recovery() { local fakebin=$1 + # The recovery kill now requires the shared named-session lock and an exact + # focus snapshot. Keep a focused sibling tab so this test's husk close is + # provably non-workspace-emptying and never needs to signal a fake shell pid. cat > "$fakebin/herdr" <<'SH' #!/usr/bin/env bash set -u @@ -321,21 +349,26 @@ state=${FM_FAKE_HERDR_STATE:?} mate_id=${FM_FAKE_SECOND_MATE_ID:?} killed="${state}.killed" spawned="${state}.spawned" +screen="${state}.spawn-screen" +staged="${screen}.staged" printf '%s\n' "$*" >> "$log" case "${1:-} ${2:-}" in "status --json") printf '%s\n' '{"client":{"protocol":14,"version":"test"},"server":{"running":true}}' ;; + "session list") + printf '{"sessions":[{"name":"default","running":true,"socket_path":"%s.sock"}]}\n' "$state" + ;; "workspace list") - printf '{"result":{"workspaces":[{"workspace_id":"ws1","label":"2ndmate-%s"}]}}\n' "$mate_id" + printf '{"result":{"workspaces":[{"workspace_id":"ws1","label":"2ndmate-%s","focused":true,"active_tab_id":"t-focus"}]}}\n' "$mate_id" ;; "tab list") if [ -e "$spawned" ]; then - printf '{"result":{"tabs":[{"tab_id":"t-new","workspace_id":"ws1","label":"fm-%s"}]}}\n' "$mate_id" + printf '{"result":{"tabs":[{"tab_id":"t-focus","workspace_id":"ws1","label":"captain","focused":true},{"tab_id":"t-new","workspace_id":"ws1","label":"fm-%s","focused":false}]}}\n' "$mate_id" elif [ -e "$killed" ]; then - printf '%s\n' '{"result":{"tabs":[]}}' + printf '%s\n' '{"result":{"tabs":[{"tab_id":"t-focus","workspace_id":"ws1","label":"captain","focused":true}]}}' else - printf '{"result":{"tabs":[{"tab_id":"t-old","workspace_id":"ws1","label":"fm-%s"}]}}\n' "$mate_id" + printf '{"result":{"tabs":[{"tab_id":"t-focus","workspace_id":"ws1","label":"captain","focused":true},{"tab_id":"t-old","workspace_id":"ws1","label":"fm-%s","focused":false}]}}\n' "$mate_id" fi ;; "tab create") @@ -354,9 +387,9 @@ case "${1:-} ${2:-}" in "pane get") pane=${3:-} if [ "$pane" = p-new ] && [ -e "$spawned" ]; then - printf '%s\n' '{"result":{"pane":{"pane_id":"p-new"}}}' + printf '%s\n' '{"result":{"pane":{"pane_id":"p-new","tab_id":"t-new","workspace_id":"ws1"}}}' elif [ "$pane" = p-old ] && [ ! -e "$killed" ]; then - printf '%s\n' '{"result":{"pane":{"pane_id":"p-old"}}}' + printf '%s\n' '{"result":{"pane":{"pane_id":"p-old","tab_id":"t-old","workspace_id":"ws1"}}}' else printf '%s\n' '{"error":{"code":"pane_not_found"}}' >&2 exit 1 @@ -373,7 +406,28 @@ case "${1:-} ${2:-}" in "pane close") [ "${3:-}" = p-old ] && : > "$killed" ;; - "pane run"|"pane send-text"|"pane send-keys"|"tab close") + "pane read") + cat "$screen" 2>/dev/null || true + ;; + "pane run"|"pane send-text") + text=${4:-} + case "$text" in + *"__FM_SPAWN_READY_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$screen" + ;; + "FM_SPAWN_LAUNCH=''") : > "$staged" ;; + FM_SPAWN_LAUNCH=*) + rebuilt=$(FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") + printf '%s' "$rebuilt" > "$staged" + ;; + *"__FM_SPAWN_LAUNCH_OK_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\n' "$token" > "$screen" + ;; + esac + ;; + "pane send-keys"|"tab close") ;; *) exit 1 diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index 3d2fbb3c361..ca414e0c225 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -556,6 +556,18 @@ test_pi_signed_threads_shared_pi_profile_and_preserves_identity() { assert_contains "$launch" "fm-operational-input.sh' encode launch-brief" \ "pi-signed launch lost the canonical typed launch-brief envelope" assert_present "$HOME_DIR/state/$id.pi-ext.ts" "pi-signed launch did not install Pi's turn-end extension" + assert_present "$HOME_DIR/state/$id.busy-gen" "pi-signed spawn did not arm the busy-state contract" + assert_contains "$(cat "$HOME_DIR/state/$id.busy-state")" "state=busy source=fm-spawn" \ + "pi-signed spawn did not seed the busy-state record from the launch brief" + local ext gen + ext=$(cat "$HOME_DIR/state/$id.pi-ext.ts") + gen=$(cat "$HOME_DIR/state/$id.busy-gen") + assert_contains "$ext" 'pi.on("agent_start"' "pi extension lost the semantic agent_start busy edge" + assert_contains "$ext" 'pi.on("agent_settled"' "pi extension lost the semantic agent_settled idle edge" + assert_contains "$ext" 'ctx.isIdle()' "pi extension no longer confirms idle with ctx.isIdle()" + assert_contains "$ext" "\"--gen\", \"$gen\"" "pi extension does not carry the armed incarnation gen" + assert_contains "$ext" '"--source", "pi-ext"' "pi extension does not attribute its semantic source" + assert_contains "$ext" 'pi.on("turn_end"' "pi extension lost the turn-end notification touch" pass "pi-signed shares Pi launch semantics while preserving its configured and recorded identity" } diff --git a/tests/fm-spawn-worktree-settle.test.sh b/tests/fm-spawn-worktree-settle.test.sh index a2d8ffbd57c..78eab34cea5 100755 --- a/tests/fm-spawn-worktree-settle.test.sh +++ b/tests/fm-spawn-worktree-settle.test.sh @@ -49,7 +49,28 @@ case "${1:-}" in display-message) printf 'firstmate\n'; exit 0 ;; list-windows) exit 0 ;; has-session|new-session|new-window|kill-window) exit 0 ;; - send-keys) exit 0 ;; + capture-pane) cat "$(dirname "$0")/.spawn-screen" 2>/dev/null || true; exit 0 ;; + send-keys) + screen="$(dirname "$0")/.spawn-screen" + staged="$screen.staged" + text=${4:-} + case "$text" in + *"__FM_SPAWN_READY_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$screen" + ;; + "FM_SPAWN_LAUNCH=''") : > "$staged" ;; + FM_SPAWN_LAUNCH=*) + rebuilt=$(FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") + printf '%s' "$rebuilt" > "$staged" + ;; + *"__FM_SPAWN_LAUNCH_OK_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\n' "$token" > "$screen" + ;; + esac + exit 0 + ;; esac exit 0 SH diff --git a/tests/fm-startup-memory-budget.test.sh b/tests/fm-startup-memory-budget.test.sh new file mode 100755 index 00000000000..59eeb0649b7 --- /dev/null +++ b/tests/fm-startup-memory-budget.test.sh @@ -0,0 +1,317 @@ +#!/usr/bin/env bash +# Behavioral coverage for the visible startup-memory budget, its safe parser, +# accounting command, primary-to-secondmate convergence, and exact reread bytes. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +BASE_PATH=${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin} +TMP_ROOT=$(fm_test_tmproot fm-startup-memory-budget) +BUDGET="$ROOT/bin/fm-startup-memory-budget.sh" +BOOTSTRAP="$ROOT/bin/fm-bootstrap.sh" +CONFIG_PUSH="$ROOT/bin/fm-config-push.sh" + +make_fake_toolchain() { + local dir=$1 fakebin + fakebin=$(fm_fakebin "$dir") + fm_fake_exit0 "$fakebin" node gh-axi chrome-devtools-axi lavish-axi quota-axi + cat > "$fakebin/quota-axi" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = --version ]; then + printf '%s\n' 'quota-axi 0.1.16 (fake)' +fi +exit 0 +SH + cat > "$fakebin/gh" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + cat > "$fakebin/treehouse" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = get ] && [ "${2:-}" = --help ]; then + printf '%s\n' 'Usage: treehouse get [--lease]' +fi +SH + cat > "$fakebin/no-mistakes" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = --version ]; then + printf '%s\n' 'no-mistakes version v1.31.2 (fake)' +fi +SH + cat > "$fakebin/tasks-axi" <<'SH' +#!/usr/bin/env bash +case "${1:-}:${2:-}" in + --version:*) printf '%s\n' '0.2.3' ;; + update:--help) printf '%s\n' '--archive-body' ;; + mv:--help) printf '%s\n' 'usage: tasks-axi mv <id> [<id>...]' ;; +esac +SH + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +[ -z "${FM_FAKE_TMUX_LOG:-}" ] || printf '%s\n' "$*" >> "$FM_FAKE_TMUX_LOG" +case "$*" in + *display-message*'#{pane_current_command}'*) printf '%s\n' codex ;; + *display-message*'#{pane_id}'*) printf '%s\n' '%1' ;; + *display-message*'#{cursor_y}'*) printf '%s\n' 0 ;; + *capture-pane*) printf '\n' ;; +esac +exit 0 +SH + chmod +x "$fakebin"/* + printf '%s\n' "$fakebin" +} + +new_bootstrap_world() { + local name=$1 world root home + world="$TMP_ROOT/$name" + root="$world/root" + home="$world/home" + mkdir -p "$home/config" "$home/data" "$home/state" "$root/bin" + git init -q -b main "$root" + printf '%s\n' 'config/' > "$root/.gitignore" + printf '%s\n' '# Firstmate test root' > "$root/AGENTS.md" + printf '%s\n' '#!/usr/bin/env bash' 'exit 0' > "$root/bin/placeholder.sh" + chmod +x "$root/bin/placeholder.sh" + git -C "$root" add -A + git -C "$root" -c user.name=fmtest -c user.email=fmtest@example.invalid commit -qm initial + printf '%s|%s\n' "$root" "$home" +} + +run_bootstrap() { + local root=$1 home=$2 fakebin=$3 + PATH="$fakebin:$BASE_PATH" FM_BACKEND=tmux FM_HOME="$home" FM_ROOT_OVERRIDE="$root" \ + "$BOOTSTRAP" +} + +test_primary_bootstrap_materializes_visible_default() { + local rec root home fakebin out second + rec=$(new_bootstrap_world materialize) + root=${rec%%|*} + home=${rec#*|} + fakebin=$(make_fake_toolchain "$TMP_ROOT/materialize") + + out=$(run_bootstrap "$root" "$home" "$fakebin") + [ -z "$out" ] || fail "default materialization should stay quiet, got: $out" + [ "$(<"$home/config/startup-memory-budget")" = 7500 ] \ + || fail "bootstrap did not materialize the visible 7500 default" + [ "$(FM_HOME="$home" "$BUDGET" read)" = 7500 ] \ + || fail "read command did not expose the generated default" + + printf '321\n' > "$home/config/startup-memory-budget" + run_bootstrap "$root" "$home" "$fakebin" >/dev/null + [ "$(<"$home/config/startup-memory-budget")" = 321 ] \ + || fail "bootstrap replaced a valid captain-selected budget" + + second="$TMP_ROOT/materialize/secondmate" + mkdir -p "$second/config" "$second/data" "$second/state" + printf '%s\n' sm > "$second/.fm-secondmate-home" + run_bootstrap "$root" "$second" "$fakebin" >/dev/null + [ ! -e "$second/config/startup-memory-budget" ] \ + || fail "secondmate bootstrap created an independent budget instead of awaiting inheritance" + pass "primary bootstrap materializes only the visible default and preserves valid captain choices" +} + +expect_rejected_read() { + local home=$1 expected=$2 out rc + set +e + out=$(FM_HOME="$home" "$BUDGET" read 2>&1) + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "unsafe budget unexpectedly parsed: $expected" + assert_contains "$out" "$expected" "unsafe budget rejection was not specific" +} + +test_safe_parser_rejects_ambiguous_and_unsafe_values() { + local home outside + home="$TMP_ROOT/parser-home" + mkdir -p "$home/config" "$home/data" + printf '42\n' > "$home/config/startup-memory-budget" + [ "$(FM_HOME="$home" "$BUDGET" read)" = 42 ] || fail "valid positive decimal budget was rejected" + + printf '0\n' > "$home/config/startup-memory-budget" + expect_rejected_read "$home" 'value must be one positive decimal integer' + printf '42\nextra\n' > "$home/config/startup-memory-budget" + expect_rejected_read "$home" 'value must be one positive decimal integer' + printf '+42\n' > "$home/config/startup-memory-budget" + expect_rejected_read "$home" 'value must be one positive decimal integer' + + outside="$TMP_ROOT/parser-outside" + printf '77\n' > "$outside" + rm -f "$home/config/startup-memory-budget" + ln -s "$outside" "$home/config/startup-memory-budget" + expect_rejected_read "$home" 'file is symlinked' + [ "$(<"$outside")" = 77 ] || fail "symlink rejection changed its external target" + + rm -f "$home/config/startup-memory-budget" + ln "$outside" "$home/config/startup-memory-budget" + expect_rejected_read "$home" 'file is hardlinked' + [ "$(<"$outside")" = 77 ] || fail "hardlink rejection changed its external source" + + rm -f "$home/config/startup-memory-budget" + rm -rf "$home/config" + ln -s "$TMP_ROOT/parser-config-target" "$home/config" + mkdir -p "$TMP_ROOT/parser-config-target" + printf '88\n' > "$TMP_ROOT/parser-config-target/startup-memory-budget" + expect_rejected_read "$home" 'config directory is symlinked' + pass "budget parser accepts one exact positive value and rejects malformed or unsafe inputs" +} + +test_budget_accounting_reports_all_three_files_and_safe_failure() { + local home out rc outside + home="$TMP_ROOT/accounting-home" + mkdir -p "$home/config" "$home/data" + printf '10\n' > "$home/config/startup-memory-budget" + printf 'abc\n' > "$home/data/captain.md" + printf 'abcdef\n' > "$home/data/captain-shared.md" + + out=$(FM_HOME="$home" "$BUDGET" report) + assert_contains "$out" 'estimator=ceil(UTF-8 bytes / 3) conservative-local-estimate' \ + "report did not name the stable estimator" + assert_contains "$out" 'file=data/captain.md bytes=4 estimated_tokens=2 status=present' \ + "report did not account for captain memory" + assert_contains "$out" 'file=data/captain-shared.md bytes=7 estimated_tokens=3 status=present' \ + "report did not account for shared memory" + assert_contains "$out" 'file=data/learnings.md bytes=0 estimated_tokens=0 status=absent' \ + "report did not account for absent learnings" + assert_contains "$out" 'total_estimated_tokens=5' "report total was not the sum of all three files" + assert_contains "$out" 'budget_status=within-budget' "report did not classify the initial total" + + printf 'abcdefabcdefabcdefabcdef\n' > "$home/data/learnings.md" + out=$(FM_HOME="$home" "$BUDGET" report) + assert_contains "$out" 'budget_status=over-budget' "report did not surface an over-budget total" + + outside="$TMP_ROOT/accounting-outside" + printf 'outside\n' > "$outside" + rm -f "$home/data/captain.md" + ln -s "$outside" "$home/data/captain.md" + set +e + out=$(FM_HOME="$home" "$BUDGET" report 2>&1) + rc=$? + set -e + expect_code 2 "$rc" "unsafe memory input should fail the accounting command" + assert_contains "$out" 'memory file is not an ordinary regular file' \ + "accounting failure did not identify the unsafe memory file" + [ "$(<"$outside")" = outside ] || fail "accounting failure changed a symlink target" + pass "budget accounting sums the three startup files and reports safe failures" +} + +new_propagation_world() { + local world=$1 root="$1/root" home="$1/home" sm="$1/sm" head + mkdir -p "$home/config" "$home/data" "$home/state" "$root/bin" + touch "$home/state/.last-watcher-beat" + git init -q -b main "$root" + printf '%s\n' 'config/' > "$root/.gitignore" + printf '%s\n' '# Firstmate test root' > "$root/AGENTS.md" + printf '%s\n' '#!/usr/bin/env bash' 'exit 0' > "$root/bin/placeholder.sh" + chmod +x "$root/bin/placeholder.sh" + git -C "$root" add -A + git -C "$root" -c user.name=fmtest -c user.email=fmtest@example.invalid commit -qm initial + head=$(git -C "$root" rev-parse HEAD) + git -C "$root" worktree add -q --detach "$sm" "$head" + printf '%s\n' sm > "$sm/.fm-secondmate-home" + mkdir -p "$sm/config" "$sm/data" "$sm/state" "$sm/projects" + { + printf 'window=firstmate:fm-sm\n' + printf 'kind=secondmate\n' + printf 'harness=codex\n' + printf 'home=%s\n' "$sm" + } > "$home/state/sm.meta" + printf '%s|%s|%s\n' "$root" "$home" "$sm" +} + +latest_reread_instruction() { + local home=$1 state path latest= + state=$(cd "$home/state" && pwd -P) || return 1 + for path in "$state"/.fm-inherited-config-reread.*; do + case "$path" in *.pending) continue ;; esac + [ -f "$path" ] && [ ! -L "$path" ] || continue + latest=$path + done + [ -n "$latest" ] || return 1 + printf '%s\n' "$latest" +} + +run_config_push() { + local root=$1 home=$2 fakebin=$3 log=$4 + PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FM_ROOT_OVERRIDE="$root" FM_SEND_SETTLE=0 \ + FM_FAKE_TMUX_LOG="$log" "$CONFIG_PUSH" +} + +test_primary_budget_converges_with_exact_reread_and_safe_failures() { + local world="$TMP_ROOT/propagation" rec root home sm fakebin log out rc instruction expected outside + mkdir -p "$world" + rec=$(new_propagation_world "$world") + root=${rec%%|*} + rec=${rec#*|} + home=${rec%%|*} + sm=${rec#*|} + fakebin=$(make_fake_toolchain "$world") + log="$world/tmux.log" + + printf '321\n' > "$home/config/startup-memory-budget" + out=$(run_config_push "$root" "$home" "$fakebin" "$log") + assert_contains "$out" 'startup-memory-budget: pushed' \ + "config push did not report the new budget as inherited" + [ "$(<"$sm/config/startup-memory-budget")" = 321 ] \ + || fail "secondmate did not receive the primary budget bytes" + instruction=$(latest_reread_instruction "$sm") || fail "budget propagation did not publish a reread instruction" + expected=$(printf '%s\n\n%s\n%s\n321\n%s' \ + 'These inherited config files changed. Re-read and apply their exact contents at every future intake. They are defaults/rules and do not remove your judgment to choose differently when warranted.' \ + 'config/startup-memory-budget' \ + '-----BEGIN config/startup-memory-budget-----' \ + '-----END config/startup-memory-budget-----') + [ "$(<"$instruction")" = "$expected" ] \ + || fail "budget reread payload was not the exact destination bytes" + assert_contains "$(<"$log")" "CONFIG_REREAD: $instruction" \ + "budget propagation did not send the pointer to its exact reread generation" + + outside="$world/unsafe-budget" + printf '555\n' > "$outside" + rm -f "$sm/config/startup-memory-budget" + ln "$outside" "$sm/config/startup-memory-budget" + set +e + out=$(run_config_push "$root" "$home" "$fakebin" "$log" 2>&1) + rc=$? + set -e + expect_code 1 "$rc" "unsafe inherited destination should stop propagation" + assert_contains "$out" 'startup-memory-budget: error - unsafe or invalid destination: file is hardlinked' \ + "unsafe inherited destination did not produce a concrete propagation error" + [ "$(<"$outside")" = 555 ] || fail "unsafe destination handling changed its hardlinked source" + rm -f "$sm/config/startup-memory-budget" + run_config_push "$root" "$home" "$fakebin" "$log" >/dev/null + [ "$(<"$sm/config/startup-memory-budget")" = 321 ] \ + || fail "safe retry did not restore the converged primary budget" + + rm -f "$home/config/startup-memory-budget" + out=$(run_config_push "$root" "$home" "$fakebin" "$log") + assert_contains "$out" 'startup-memory-budget: pushed - mirrored primary absence' \ + "primary absence was not reported as a converging removal" + [ ! -e "$sm/config/startup-memory-budget" ] \ + || fail "primary absence did not remove the inherited budget" + instruction=$(latest_reread_instruction "$sm") || fail "budget absence did not publish a reread instruction" + assert_contains "$(<"$instruction")" $'-----BEGIN config/startup-memory-budget-----\nABSENT\n-----END config/startup-memory-budget-----' \ + "budget absence reread did not use the explicit ABSENT payload" + + rm -f "$sm/config/startup-memory-budget" + printf '555\n' > "$outside" + ln -s "$outside" "$home/config/startup-memory-budget" + set +e + out=$(run_config_push "$root" "$home" "$fakebin" "$log" 2>&1) + rc=$? + set -e + expect_code 1 "$rc" "unsafe primary budget should stop propagation" + assert_contains "$out" 'startup-memory-budget: error - unsafe or invalid primary source: file is symlinked' \ + "unsafe primary budget did not produce a concrete propagation error" + [ ! -e "$sm/config/startup-memory-budget" ] \ + || fail "unsafe primary budget changed the converged secondmate copy" + [ "$(<"$outside")" = 555 ] || fail "unsafe primary budget handling changed its symlink target" + pass "budget propagation converges through config push with exact rereads, absence, and safe rejection" +} + +test_primary_bootstrap_materializes_visible_default +test_safe_parser_rejects_ambiguous_and_unsafe_values +test_budget_accounting_reports_all_three_files_and_safe_failure +test_primary_budget_converges_with_exact_reread_and_safe_failures + +echo '# all fm-startup-memory-budget tests passed' diff --git a/tests/fm-tangle-guard.test.sh b/tests/fm-tangle-guard.test.sh index 4df27061216..1ef46742ab9 100755 --- a/tests/fm-tangle-guard.test.sh +++ b/tests/fm-tangle-guard.test.sh @@ -164,7 +164,29 @@ esac case "${1:-}" in display-message) printf 'firstmate\n'; exit 0 ;; list-windows) exit 0 ;; - has-session|new-session|new-window|send-keys) exit 0 ;; + has-session|new-session|new-window) exit 0 ;; + capture-pane) cat "$(dirname "$0")/.spawn-screen" 2>/dev/null || true; exit 0 ;; + send-keys) + screen="$(dirname "$0")/.spawn-screen" + staged="$screen.staged" + text=${4:-} + case "$text" in + *"__FM_SPAWN_READY_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$screen" + ;; + "FM_SPAWN_LAUNCH=''") : > "$staged" ;; + FM_SPAWN_LAUNCH=*) + rebuilt=$(FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") + printf '%s' "$rebuilt" > "$staged" + ;; + *"__FM_SPAWN_LAUNCH_OK_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\n' "$token" > "$screen" + ;; + esac + exit 0 + ;; esac exit 0 SH @@ -243,7 +265,29 @@ case "${1:-}" in display-message) printf 'firstmate\n'; exit 0 ;; new-window) printf '%s\n' "@spawnwid"; exit 0 ;; list-windows) exit 0 ;; - has-session|new-session|send-keys|set-window-option) exit 0 ;; + has-session|new-session|set-window-option) exit 0 ;; + capture-pane) cat "$(dirname "$0")/.spawn-screen" 2>/dev/null || true; exit 0 ;; + send-keys) + screen="$(dirname "$0")/.spawn-screen" + staged="$screen.staged" + text=${4:-} + case "$text" in + *"__FM_SPAWN_READY_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$screen" + ;; + "FM_SPAWN_LAUNCH=''") : > "$staged" ;; + FM_SPAWN_LAUNCH=*) + rebuilt=$(FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") + printf '%s' "$rebuilt" > "$staged" + ;; + *"__FM_SPAWN_LAUNCH_OK_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\n' "$token" > "$screen" + ;; + esac + exit 0 + ;; esac exit 0 SH diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index 4dd6bfa1a5a..6097a536ee9 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -1552,6 +1552,27 @@ test_local_only_force_overrides_unpushed() { pass "local-only worktree with unpushed work is torn down under --force (escape hatch)" } +test_teardown_missing_busy_sidecar_completes() { + local case_dir gen rc + case_dir=$(make_case missing-busy-sidecar) + write_meta "$case_dir" local-only ship + gen=$("$ROOT/bin/fm-busy-event.sh" arm "$case_dir/state" task-x1) + printf 'busy_gen=%s\n' "$gen" >> "$case_dir/state/task-x1.meta" + rm -f "$case_dir/state/task-x1.busy-gen" + + set +e + run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "missing-busy-sidecar: teardown should treat the incarnation as already retired" + assert_absent "$case_dir/state/task-x1.busy-state" \ + "missing-busy-sidecar: teardown left the orphan busy record" + assert_absent "$case_dir/state/task-x1.meta" \ + "missing-busy-sidecar: teardown remained incomplete" + pass "teardown completes when an exact busy-state sidecar is already absent" +} + test_herdr_teardown_clears_escalation_marker() { local case_dir marker case_dir=$(make_case herdr-marker-cleanup) @@ -1564,20 +1585,434 @@ test_herdr_teardown_clears_escalation_marker() { 'herdr_workspace_id=wG' \ 'herdr_tab_id=wG:tQ' \ 'herdr_pane_id=wG:pQ' >> "$case_dir/state/task-x1.meta" - cat > "$case_dir/fakebin/herdr" <<'SH' + # A reachable session whose exact pane is already structurally gone: the + # locked close is a no-op and the record gate sees a confirmed-gone pane. + cat > "$case_dir/fakebin/herdr" <<SH #!/usr/bin/env bash -exit 0 +case "\${1:-} \${2:-}" in + "session list") printf '%s\n' '{"sessions":[{"name":"default","running":true,"socket_path":"$case_dir/herdr.sock"}]}' ;; + "status --json") printf '%s\n' '{"server":{"running":true}}' ;; + "pane get") printf '%s\n' '{"error":{"code":"pane_not_found"}}'; exit 1 ;; + *) exit 0 ;; +esac SH chmod +x "$case_dir/fakebin/herdr" marker="$case_dir/state/.herdr-escalated-default_wG_pQ" : > "$marker" run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" \ - || fail "herdr-marker-cleanup: forced teardown failed" + || fail "herdr-marker-cleanup: forced teardown failed: $(cat "$case_dir/stderr")" [ ! -e "$marker" ] || fail "herdr-marker-cleanup: teardown left the pane's escalation marker behind" pass "herdr teardown removes pane-owned escalation dedupe state" } +# Flat (non-projected) Herdr endpoint whose fake pane exists until a locked +# close removes it. The socket path is case-local so the derived presentation +# lock never collides with another test or a real fleet session. +configure_flat_herdr_teardown_case() { # <case-dir> + local case_dir=$1 + sed -i.bak 's/^window=.*/window=default:wG:pQ/' "$case_dir/state/task-x1.meta" + rm -f "$case_dir/state/task-x1.meta.bak" + printf '%s\n' \ + 'backend=herdr' \ + 'herdr_session=default' \ + 'herdr_workspace_id=wG' \ + 'herdr_tab_id=wG:tQ' \ + 'herdr_pane_id=wG:pQ' >> "$case_dir/state/task-x1.meta" + cat > "$case_dir/fakebin/herdr" <<SH +#!/usr/bin/env bash +set -u +printf '%s\n' "\$*" >> "\${FM_FAKE_HERDR_LOG:?}" +case "\${1:-} \${2:-}" in + "workspace list") + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"wH","active_tab_id":"wH:t1","focused":true},{"workspace_id":"wG","active_tab_id":"wG:tQ","focused":false}]}}' + ;; + "tab list") + case "\$*" in + *"--workspace wH"*) printf '%s\n' '{"result":{"tabs":[{"tab_id":"wH:t1","focused":true}]}}' ;; + *"--workspace wG"*) printf '%s\n' '{"result":{"tabs":[{"tab_id":"wG:tQ","workspace_id":"wG"}]}}' ;; + *) printf '%s\n' '{"result":{"tabs":[]}}' ;; + esac + ;; + "pane list") + printf '%s\n' '{"result":{"panes":[{"pane_id":"wG:pQ","tab_id":"wG:tQ"}]}}' + ;; + "status --json") + printf '%s\n' '{"server":{"running":true}}' + ;; + "session list") + if [ "\${FM_FAKE_HERDR_SESSION_LIST_GARBAGE:-0}" = 1 ]; then + printf '%s\n' 'not-json' + else + printf '%s\n' '{"sessions":[{"name":"default","running":true,"socket_path":"$case_dir/herdr.sock"}]}' + fi + ;; + "pane close") + : > "\${FM_FAKE_HERDR_CLOSED:?}" + ;; + "pane get") + if [ "\${FM_FAKE_HERDR_PANE_GET_GARBAGE:-0}" = 1 ]; then + printf '%s\n' 'not-json' + exit 0 + fi + if [ -e "\${FM_FAKE_HERDR_CLOSED:?}" ]; then + printf '%s\n' '{"error":{"code":"pane_not_found"}}' >&2 + exit 1 + fi + printf '%s\n' '{"result":{"pane":{"pane_id":"wG:pQ","tab_id":"wG:tQ","workspace_id":"wG"}}}' + ;; + "agent get") + printf '%s\n' '{"error":{"code":"agent_not_found"}}' >&2 + exit 1 + ;; +esac +SH + chmod +x "$case_dir/fakebin/herdr" +} + +test_herdr_flat_teardown_refuses_orphaning_records_then_retry_completes() { + local case_dir log closed lock ready release holder_pid rc thlog + case_dir=$(make_case herdr-orphan-refusal) + write_meta "$case_dir" local-only ship + configure_flat_herdr_teardown_case "$case_dir" + log="$case_dir/herdr.log"; : > "$log" + closed="$case_dir/closed" + : > "$case_dir/state/task-x1.status" + : > "$case_dir/state/task-x1.turn-ended" + # Record every treehouse invocation: the contended-lock refusal must fire + # BEFORE the isolated copy is returned, so phase 1 may not invoke it at all. + thlog="$case_dir/treehouse.log"; : > "$thlog" + cat > "$case_dir/fakebin/treehouse" <<SH +#!/usr/bin/env bash +printf '%s\n' "\$*" >> "$thlog" +exit 0 +SH + chmod +x "$case_dir/fakebin/treehouse" + + lock=$(FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" PATH="$case_dir/fakebin:$PATH" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_presentation_session_lock_path default' "$ROOT") \ + || fail "herdr-orphan-refusal: could not resolve the fixture presentation lock path" + ready="$case_dir/lock-ready"; release="$case_dir/lock-release" + ROOT="$ROOT" LOCK="$lock" READY="$ready" RELEASE="$release" bash -c ' + . "$ROOT/bin/fm-wake-lib.sh" + fm_lock_try_acquire "$LOCK" || exit 1 + : > "$READY" + while [ ! -e "$RELEASE" ]; do sleep 0.1; done + fm_lock_release "$LOCK" + ' & + holder_pid=$! + local waited=0 + while [ ! -e "$ready" ] && [ "$waited" -lt 50 ]; do sleep 0.1; waited=$((waited + 1)); done + [ -e "$ready" ] || fail "herdr-orphan-refusal: the contending lock holder never started" + + rc=0 + FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" \ + run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$? + if [ "$rc" -eq 0 ]; then + : > "$release"; wait "$holder_pid" 2>/dev/null || true + fail "herdr-orphan-refusal: teardown reported success while the exact pane still existed under lock contention" + fi + [ -e "$case_dir/state/task-x1.meta" ] || { : > "$release"; fail "herdr-orphan-refusal: refusal erased the durable endpoint metadata"; } + [ -e "$case_dir/state/task-x1.status" ] || { : > "$release"; fail "herdr-orphan-refusal: refusal erased the task status record"; } + [ -e "$case_dir/state/task-x1.turn-ended" ] || { : > "$release"; fail "herdr-orphan-refusal: refusal erased the turn-end record"; } + assert_grep "presentation lock is contended" "$case_dir/stderr" \ + "herdr-orphan-refusal: the pre-return refusal was not explained visibly" + if [ -s "$thlog" ]; then + : > "$release"; fail "herdr-orphan-refusal: the contended refusal still returned the isolated copy: $(cat "$thlog")" + fi + [ -d "$case_dir/wt" ] || { : > "$release"; fail "herdr-orphan-refusal: the contended refusal removed the isolated copy"; } + if [ "$(git -C "$case_dir/wt" rev-parse --abbrev-ref HEAD 2>/dev/null)" != "fm/task-x1" ]; then + : > "$release"; fail "herdr-orphan-refusal: the contended refusal dropped the task branch before refusing" + fi + if grep -q "teardown task-x1 complete" "$case_dir/stdout"; then + : > "$release"; fail "herdr-orphan-refusal: refusal still reported cleanup complete" + fi + if grep -q "^pane close" "$log"; then + : > "$release"; fail "herdr-orphan-refusal: an unlocked pane close was attempted under contention" + fi + + : > "$release" + wait "$holder_pid" 2>/dev/null || true + FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" FM_BACKEND_HERDR_IDLE_SHELL_PROOF_POLLS=1 \ + run_teardown "$case_dir" --force > "$case_dir/stdout2" 2> "$case_dir/stderr2" \ + || fail "herdr-orphan-refusal: the retry after lock release failed: $(cat "$case_dir/stderr2")" + [ -e "$closed" ] || fail "herdr-orphan-refusal: the retry never closed the pane under the lock" + [ -s "$thlog" ] || fail "herdr-orphan-refusal: the successful retry never returned the isolated copy" + [ ! -e "$case_dir/state/task-x1.meta" ] || fail "herdr-orphan-refusal: the successful retry left the metadata behind" + [ ! -e "$case_dir/state/task-x1.status" ] || fail "herdr-orphan-refusal: the successful retry left the status record behind" + grep -q "teardown task-x1 complete" "$case_dir/stdout2" \ + || fail "herdr-orphan-refusal: the successful retry did not report completion" + pass "herdr flat teardown refuses before returning the isolated copy under lock contention and the retry completes cleanly" +} + +test_herdr_flat_teardown_refuses_records_on_unparseable_presence() { + local case_dir log closed rc + case_dir=$(make_case herdr-garbage-presence) + write_meta "$case_dir" local-only ship + configure_flat_herdr_teardown_case "$case_dir" + log="$case_dir/herdr.log"; : > "$log" + closed="$case_dir/closed" + : > "$case_dir/state/task-x1.status" + rc=0 + FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" FM_FAKE_HERDR_PANE_GET_GARBAGE=1 \ + FM_BACKEND_HERDR_IDLE_SHELL_PROOF_POLLS=1 \ + run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$? + [ "$rc" -ne 0 ] \ + || fail "herdr-garbage-presence: teardown erased records on an unparseable pane presence" + [ -e "$case_dir/state/task-x1.meta" ] \ + || fail "herdr-garbage-presence: ambiguous presence erased the durable endpoint metadata" + [ -e "$case_dir/state/task-x1.status" ] \ + || fail "herdr-garbage-presence: ambiguous presence erased the task status record" + assert_grep "ambiguous structured presence" "$case_dir/stderr" \ + "herdr-garbage-presence: the ambiguity refusal was not explained visibly" + pass "herdr flat teardown never erases records when pane presence is unparseable" +} + +assert_herdr_teardown_preflight_refuses_before_changes() { + local mode=$1 case_dir log closed rc thlog teardown_bin + case_dir=$(make_case "herdr-preflight-$mode") + write_meta "$case_dir" local-only ship + configure_flat_herdr_teardown_case "$case_dir" + log="$case_dir/herdr.log"; : > "$log" + closed="$case_dir/closed" + : > "$case_dir/state/task-x1.status" + : > "$case_dir/state/task-x1.turn-ended" + thlog="$case_dir/treehouse.log"; : > "$thlog" + cat > "$case_dir/fakebin/treehouse" <<SH +#!/usr/bin/env bash +printf '%s\n' "\$*" >> "$thlog" +exit 0 +SH + chmod +x "$case_dir/fakebin/treehouse" + + teardown_bin=$TEARDOWN + case "$mode" in + missing-adapter|missing-parser|missing-explicit-close-helper) + mkdir -p "$case_dir/test-root" + cp -R "$ROOT/bin" "$case_dir/test-root/bin" + if [ "$mode" = missing-adapter ]; then + rm -f "$case_dir/test-root/bin/backends/herdr.sh" + elif [ "$mode" = missing-explicit-close-helper ]; then + sed -i.bak 's/^fm_backend_herdr_explicit_close_pane_confirmed()/fm_backend_herdr_explicit_close_pane_confirmed_unavailable()/' \ + "$case_dir/test-root/bin/backends/herdr.sh" + rm -f "$case_dir/test-root/bin/backends/herdr.sh.bak" + else + sed -i.bak 's/^fm_backend_herdr_parse_target()/fm_backend_herdr_parse_target_unavailable()/' \ + "$case_dir/test-root/bin/backends/herdr.sh" + rm -f "$case_dir/test-root/bin/backends/herdr.sh.bak" + fi + teardown_bin="$case_dir/test-root/bin/fm-teardown.sh" + ;; + esac + rc=0 + FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$case_dir/state" FM_CONFIG_OVERRIDE="$case_dir/config" \ + FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" \ + FM_FAKE_HERDR_SESSION_LIST_GARBAGE="$([ "$mode" = unresolvable-lock ] && printf 1 || printf 0)" \ + PATH="$case_dir/fakebin:$PATH" \ + "$teardown_bin" task-x1 --force > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$? + [ "$rc" -ne 0 ] || fail "herdr-preflight-$mode: teardown continued without its required preflight" + assert_grep "nothing was changed" "$case_dir/stderr" \ + "herdr-preflight-$mode: the retryable pre-return refusal was not explained visibly" + [ -d "$case_dir/wt" ] || fail "herdr-preflight-$mode: refusal removed the isolated copy" + [ "$(git -C "$case_dir/wt" rev-parse --abbrev-ref HEAD 2>/dev/null)" = "fm/task-x1" ] \ + || fail "herdr-preflight-$mode: refusal dropped the task branch" + [ -e "$case_dir/state/task-x1.meta" ] \ + || fail "herdr-preflight-$mode: refusal erased the durable endpoint metadata" + [ -e "$case_dir/state/task-x1.status" ] \ + || fail "herdr-preflight-$mode: refusal erased the task status record" + [ -e "$case_dir/state/task-x1.turn-ended" ] \ + || fail "herdr-preflight-$mode: refusal erased the turn-end record" + [ ! -s "$thlog" ] || fail "herdr-preflight-$mode: refusal returned the isolated copy" + [ ! -e "$closed" ] || fail "herdr-preflight-$mode: refusal attempted an unlocked pane close" +} + +test_herdr_flat_teardown_preflight_refuses_before_changes() { + assert_herdr_teardown_preflight_refuses_before_changes unresolvable-lock + assert_herdr_teardown_preflight_refuses_before_changes missing-adapter + assert_herdr_teardown_preflight_refuses_before_changes missing-parser + assert_herdr_teardown_preflight_refuses_before_changes missing-explicit-close-helper + pass "herdr flat teardown preflight refuses before every destructive change" +} + +configure_secondmate_with_herdr_child() { # <case-dir> + local case_dir=$1 home="$1/secondmate-home" + mkdir -p "$home/state" "$home/data" "$home/config" "$home/projects" + printf '%s\n' task-x1 > "$home/.fm-secondmate-home" + printf '%s\n' "home=$home" >> "$case_dir/state/task-x1.meta" + fm_write_meta "$home/state/child-herdr.meta" \ + "window=childsession:wC:p1" \ + "endpoint_task_id=child-herdr" \ + "worktree=$case_dir/wt" \ + "project=$case_dir/project" \ + "kind=ship" \ + "mode=local-only" \ + "backend=herdr" \ + "herdr_session=childsession" \ + "herdr_workspace_id=wC" \ + "herdr_tab_id=wC:t1" \ + "herdr_pane_id=wC:p1" + : > "$home/state/child-herdr.status" + : > "$home/state/child-herdr.turn-ended" + cat > "$case_dir/fakebin/herdr" <<SH +#!/usr/bin/env bash +set -u +printf '%s\n' "\$*" >> "\${FM_FAKE_HERDR_LOG:?}" +case "\${1:-} \${2:-}" in + "session list") + if [ "\${FM_FAKE_HERDR_SESSION_LIST_GARBAGE:-0}" = 1 ]; then + printf '%s\n' 'not-json' + else + printf '%s\n' '{"sessions":[{"name":"childsession","running":true,"socket_path":"$case_dir/child.sock"}]}' + fi + ;; + "workspace list") exit 1 ;; + "pane get") + if [ -e "\${FM_FAKE_HERDR_CLOSED:?}" ]; then + if [ "\${FM_FAKE_HERDR_PRESENCE_UNKNOWN:-0}" = 1 ]; then + printf '%s\n' 'not-json' + else + printf '%s\n' '{"error":{"code":"pane_not_found"}}' >&2 + exit 1 + fi + else + printf '%s\n' '{"result":{"pane":{"pane_id":"wC:p1","tab_id":"wC:t1","workspace_id":"wC"}}}' + fi + ;; + "pane close") : > "\${FM_FAKE_HERDR_CLOSED:?}" ;; +esac +SH + chmod +x "$case_dir/fakebin/herdr" +} + +test_forced_secondmate_herdr_child_preflight_refuses_before_changes() { + local case_dir home log closed rc thlog + case_dir=$(make_case herdr-child-preflight) + write_meta "$case_dir" local-only secondmate + configure_secondmate_with_herdr_child "$case_dir" + home="$case_dir/secondmate-home" + log="$case_dir/herdr.log"; closed="$case_dir/closed"; thlog="$case_dir/treehouse.log" + : > "$log"; : > "$thlog" + cat > "$case_dir/fakebin/treehouse" <<SH +#!/usr/bin/env bash +printf '%s\n' "\$*" >> "$thlog" +exit 0 +SH + chmod +x "$case_dir/fakebin/treehouse" + rc=0 + FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" \ + FM_FAKE_HERDR_SESSION_LIST_GARBAGE=1 \ + run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$? + [ "$rc" -ne 0 ] || fail "herdr-child-preflight: teardown continued through an unresolvable child lock" + [ -e "$case_dir/state/task-x1.meta" ] || fail "herdr-child-preflight: refusal erased the parent record" + [ -e "$home/state/child-herdr.meta" ] || fail "herdr-child-preflight: refusal erased the child record" + [ -e "$home/state/child-herdr.status" ] || fail "herdr-child-preflight: refusal erased child status" + [ -d "$home" ] || fail "herdr-child-preflight: refusal removed the secondmate home" + [ ! -s "$thlog" ] || fail "herdr-child-preflight: refusal returned work before child preflight" + [ ! -e "$closed" ] || fail "herdr-child-preflight: refusal attempted a child close" + assert_grep "nothing was changed" "$case_dir/stderr" \ + "herdr-child-preflight: refusal did not explain its non-mutating boundary" + pass "forced secondmate teardown preflights every Herdr child before cleanup mutation" +} + +test_forced_secondmate_herdr_child_retains_records_when_close_unconfirmed() { + local case_dir home log closed rc + case_dir=$(make_case herdr-child-unconfirmed-close) + write_meta "$case_dir" local-only secondmate + configure_secondmate_with_herdr_child "$case_dir" + home="$case_dir/secondmate-home" + log="$case_dir/herdr.log"; closed="$case_dir/closed"; : > "$log" + rc=0 + FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" FM_FAKE_HERDR_PRESENCE_UNKNOWN=1 \ + run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$? + [ "$rc" -ne 0 ] || fail "herdr-child-unconfirmed-close: teardown erased records after an ambiguous close" + [ -e "$closed" ] || fail "herdr-child-unconfirmed-close: fixture did not attempt the child close" + [ -e "$home/state/child-herdr.meta" ] || fail "herdr-child-unconfirmed-close: ambiguous close erased child metadata" + [ -e "$home/state/child-herdr.status" ] || fail "herdr-child-unconfirmed-close: ambiguous close erased child status" + [ -e "$case_dir/state/task-x1.meta" ] || fail "herdr-child-unconfirmed-close: failed child cleanup erased parent metadata" + [ -d "$home" ] || fail "herdr-child-unconfirmed-close: failed child cleanup removed the secondmate home" + assert_grep "retaining that child's durable identity records" "$case_dir/stderr" \ + "herdr-child-unconfirmed-close: refusal did not explain child record retention" + pass "forced secondmate teardown retains Herdr child identity until exact pane disappearance" +} + +configure_nested_secondmate_with_herdr_grandchild() { # <case-dir> + local case_dir=$1 home="$1/secondmate-home" nested_home="$1/secondmate-home/nested-home" + mkdir -p "$home/state" "$home/data" "$home/config" "$home/projects" + mkdir -p "$nested_home/state" "$nested_home/data" "$nested_home/config" "$nested_home/projects" + printf '%s\n' task-x1 > "$home/.fm-secondmate-home" + printf '%s\n' nested-sm > "$nested_home/.fm-secondmate-home" + printf '%s\n' "home=$home" >> "$case_dir/state/task-x1.meta" + fm_write_meta "$home/state/nested-sm.meta" \ + "window=firstmate:fm-nested-sm" \ + "endpoint_task_id=nested-sm" \ + "worktree=$case_dir/wt" \ + "project=$case_dir/project" \ + "kind=secondmate" \ + "mode=local-only" \ + "home=$nested_home" + fm_write_meta "$nested_home/state/grandchild-herdr.meta" \ + "window=grandchildsession:wG:p1" \ + "endpoint_task_id=grandchild-herdr" \ + "worktree=$case_dir/wt" \ + "project=$case_dir/project" \ + "kind=ship" \ + "mode=local-only" \ + "backend=herdr" \ + "herdr_session=grandchildsession" \ + "herdr_workspace_id=wG" \ + "herdr_tab_id=wG:t1" \ + "herdr_pane_id=wG:p1" + : > "$nested_home/state/grandchild-herdr.status" + : > "$nested_home/state/grandchild-herdr.turn-ended" + cat > "$case_dir/fakebin/herdr" <<SH +#!/usr/bin/env bash +set -u +printf '%s\n' "\$*" >> "\${FM_FAKE_HERDR_LOG:?}" +case "\${1:-} \${2:-}" in + "session list") + printf '%s\n' '{"sessions":[{"name":"grandchildsession","running":true,"socket_path":"$case_dir/grandchild.sock"}]}' + ;; + "workspace list") exit 1 ;; + "pane get") + if [ -e "\${FM_FAKE_HERDR_CLOSED:?}" ]; then + printf '%s\n' 'not-json' + else + printf '%s\n' '{"result":{"pane":{"pane_id":"wG:p1","tab_id":"wG:t1","workspace_id":"wG"}}}' + fi + ;; + "pane close") : > "\${FM_FAKE_HERDR_CLOSED:?}" ;; +esac +SH + chmod +x "$case_dir/fakebin/herdr" +} + +test_forced_teardown_retains_nested_secondmate_home_when_grandchild_close_unconfirmed() { + local case_dir home nested_home log closed rc + case_dir=$(make_case herdr-grandchild-unconfirmed-close) + write_meta "$case_dir" local-only secondmate + configure_nested_secondmate_with_herdr_grandchild "$case_dir" + home="$case_dir/secondmate-home"; nested_home="$home/nested-home" + log="$case_dir/herdr.log"; closed="$case_dir/closed"; : > "$log" + rc=0 + FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" \ + run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$? + [ "$rc" -ne 0 ] \ + || fail "herdr-grandchild-unconfirmed-close: teardown erased records after an ambiguous grandchild close" + [ -e "$closed" ] \ + || fail "herdr-grandchild-unconfirmed-close: fixture did not attempt the grandchild close" + [ -d "$nested_home" ] \ + || fail "herdr-grandchild-unconfirmed-close: the recursive failure still removed the nested secondmate home" + [ -e "$nested_home/state/grandchild-herdr.meta" ] \ + || fail "herdr-grandchild-unconfirmed-close: ambiguous close erased the grandchild's metadata" + [ -e "$nested_home/state/grandchild-herdr.status" ] \ + || fail "herdr-grandchild-unconfirmed-close: ambiguous close erased the grandchild's status record" + [ -e "$home/state/nested-sm.meta" ] \ + || fail "herdr-grandchild-unconfirmed-close: the recursive failure erased the nested secondmate's own record" + [ -e "$case_dir/state/task-x1.meta" ] \ + || fail "herdr-grandchild-unconfirmed-close: the recursive failure erased the top-level secondmate's record" + pass "forced teardown retains a nested secondmate home and its grandchild's Herdr identity when the grandchild close is unconfirmed" +} + configure_herdr_projection_teardown_case() { # <case-dir> local case_dir=$1 token=AbCdEfGhIjKlMnOpQrStUv sed -i.bak 's/^window=.*/window=fmtest:w1:p2/' "$case_dir/state/task-x1.meta" @@ -1627,6 +2062,10 @@ case "${1:-} ${2:-}" in ;; "pane get") if [ -e "${FM_FAKE_HERDR_CLOSED:?}" ]; then + if [ "${FM_FAKE_HERDR_PRESENCE_UNKNOWN:-0}" = 1 ]; then + printf '%s\n' '{"error":{"code":"internal"}}' >&2 + exit 1 + fi printf '%s\n' '{"error":{"code":"pane_not_found"}}' >&2 exit 1 fi @@ -1674,16 +2113,24 @@ test_herdr_projection_teardown_retains_journal_when_close_unconfirmed() { configure_herdr_projection_teardown_case "$case_dir" log="$case_dir/herdr.log"; closed="$case_dir/closed"; restored="$case_dir/restored"; : > "$log" - FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" FM_FAKE_HERDR_RESTORED="$restored" FM_FAKE_HERDR_CLOSE_FAIL=1 \ - run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" \ - || fail "herdr-projection-unconfirmed-close: teardown should preserve best-effort endpoint semantics" + local rc=0 + FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" FM_FAKE_HERDR_RESTORED="$restored" FM_FAKE_HERDR_PRESENCE_UNKNOWN=1 \ + run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$? + [ "$rc" -ne 0 ] \ + || fail "herdr-projection-unconfirmed-close: teardown reported success after an unknown post-close presence read" + [ -e "$closed" ] \ + || fail "herdr-projection-unconfirmed-close: regression did not exercise an attempted close" [ -e "$case_dir/state/task-x1.herdr-presentation" ] \ || fail "unconfirmed task-pane close incorrectly retired the presentation journal" + [ -e "$case_dir/state/task-x1.meta" ] \ + || fail "unconfirmed task-pane close erased the durable endpoint metadata" assert_grep "close could not be confirmed" "$case_dir/stderr" \ "unconfirmed projected close did not explain why the journal was retained" + assert_grep "not confirmed gone" "$case_dir/stderr" \ + "unconfirmed projected close did not explain why the records were retained" assert_not_contains "$(cat "$log")" "workspace close" \ "unconfirmed projected close must not escalate to workspace cleanup" - pass "herdr projection teardown retains the stale journal and attempts no workspace cleanup when exact-pane close is unconfirmed" + pass "herdr projection teardown retains every record when post-close presence is unknown" } test_local_only_fork_remote_allows @@ -1694,7 +2141,14 @@ test_local_only_merged_to_local_main_allows test_no_mistakes_origin_remote_allows test_no_mistakes_truly_unpushed_refuses test_local_only_force_overrides_unpushed +test_teardown_missing_busy_sidecar_completes test_herdr_teardown_clears_escalation_marker +test_herdr_flat_teardown_refuses_orphaning_records_then_retry_completes +test_herdr_flat_teardown_refuses_records_on_unparseable_presence +test_herdr_flat_teardown_preflight_refuses_before_changes +test_forced_secondmate_herdr_child_preflight_refuses_before_changes +test_forced_secondmate_herdr_child_retains_records_when_close_unconfirmed +test_forced_teardown_retains_nested_secondmate_home_when_grandchild_close_unconfirmed test_herdr_projection_teardown_retires_journal_only_after_confirmed_close test_herdr_projection_teardown_retains_journal_when_close_unconfirmed test_squash_merged_branch_deleted_allows diff --git a/tests/fm-validation-lane.test.sh b/tests/fm-validation-lane.test.sh new file mode 100755 index 00000000000..a55e77b3179 --- /dev/null +++ b/tests/fm-validation-lane.test.sh @@ -0,0 +1,468 @@ +#!/usr/bin/env bash +# Behavior tests for the home-local no-mistakes validation lane. +# +# These cases execute the public scheduler and its generated watcher check in +# disposable state directories. Delivery and crew-state reads are fixture +# binaries, so they never attach to a live runtime or start a validation run. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +LANE="$ROOT/bin/fm-validation-lane.sh" +CHECKPOINT="$ROOT/bin/fm-watch-checkpoint.sh" +TMP_ROOT=$(fm_test_tmproot fm-validation-lane) +PRIOR_START='2026-08-01T12:00#1' +NEXT_START='2026-08-01T12:00#2' + +make_case() { + local name=$1 dir + dir=$TMP_ROOT/$name + mkdir -p "$dir/home/state" "$dir/home/data" "$dir/home/config" "$dir/fakebin" + : > "$dir/send.log" + cat > "$dir/fakebin/send" <<'SH' +#!/usr/bin/env bash +printf '%s|%s\n' "$1" "${*:2}" >> "$FM_TEST_SEND_LOG" +[ -z "${FM_TEST_SEND_DELAY:-}" ] || sleep "$FM_TEST_SEND_DELAY" +printf '%s\n' "${FM_TEST_SEND_OUTPUT:-}" +exit "${FM_TEST_SEND_RC:-0}" +SH +cat > "$dir/fakebin/crew-state" <<'SH' +#!/usr/bin/env bash +if [ "${FM_TEST_CREW_STATE_RC:-0}" -ne 0 ]; then + printf '%s\n' "${FM_TEST_CREW_STATE_ERROR:-fixture crew-state failure}" >&2 + exit "$FM_TEST_CREW_STATE_RC" +fi +line=${FM_TEST_CREW_STATE:-state: done · source: run-step · prior validation} +if [ "${1:-}" = --validation-lane ]; then + state=${line#state: } + state=${state%% ·*} + source=${line#*source: } + source=${source%% ·*} + if [ "$source" = run-step ]; then + kind=${FM_TEST_CREW_RUN_KIND:-full} + else + kind=${FM_TEST_CREW_RUN_KIND:-absent} + fi + if [ "$kind" = full ]; then + run_id=${FM_TEST_CREW_RUN_ID:-prior} + else + run_id= + fi + if [ "${FM_TEST_CREW_RUN_START+x}" = x ]; then + run_start=$FM_TEST_CREW_RUN_START + elif [ "$source" = run-step ]; then + run_start=2026-08-01T12:00#1 + else + run_start= + fi + printf 'fm-crew-validation-v2\nstate=%s\nsource=%s\nrun-kind=%s\nrun-id=%s\nrun-start=%s\n' \ + "$state" "$source" "$kind" "$run_id" "$run_start" +else + printf '%s\n' "$line" +fi +SH + cat > "$dir/fakebin/register" <<'SH' +#!/usr/bin/env bash +if [ "${FM_TEST_REGISTER_RC:-0}" -ne 0 ]; then + exit "$FM_TEST_REGISTER_RC" +fi +exec "$FM_TEST_REGISTER_REAL" "$@" +SH + chmod +x "$dir/fakebin/send" "$dir/fakebin/crew-state" "$dir/fakebin/register" + printf '%s\n' "$dir" +} + +run_lane() { # <case-dir> <args...> + local dir=$1 + shift + FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" \ + FM_VALIDATION_LANE_SEND_BIN="$dir/fakebin/send" \ + FM_VALIDATION_LANE_CREW_STATE_BIN="$dir/fakebin/crew-state" \ + FM_VALIDATION_LANE_REGISTER_BIN="$dir/fakebin/register" \ + FM_TEST_REGISTER_REAL="$ROOT/bin/fm-check-register.sh" \ + FM_TEST_SEND_LOG="$dir/send.log" "$LANE" "$@" +} + +hash_text() { # <text> + if command -v shasum >/dev/null 2>&1; then + printf '%s' "$1" | shasum -a 256 | awk '{print $1}' + else + printf '%s' "$1" | sha256sum | awk '{print $1}' + fi +} + +owner_state() { # <holder|release> <task> <kind> <run-id|none> <run-start|none> <state> <started> [queued...] + local owner=$1 task=$2 kind=$3 run_id=$4 run_start=$5 state=$6 started=$7 queued token=none start_token=none + shift 7 + [ "$kind" != full ] || token=$(hash_text "$run_id") + [ "$run_start" = none ] || start_token=$(hash_text "$run_start") + printf 'fm-validation-lane-v2\n%s=%s\n' "$owner" "$task" + printf 'reservation-kind=%s\nreservation-run=%s\n' "$kind" "$token" + printf 'reservation-start=%s\n' "$start_token" + printf 'reservation-state=%s\nreservation-started=%s\n' "$state" "$started" + for queued in "$@"; do + printf 'queued=%s\n' "$queued" + done +} + +assert_state() { # <case-dir> <expected-content> <message> + local actual + actual=$(cat "$1/home/state/validation-lane" 2>/dev/null || true) + [ "$actual" = "$2" ] || fail "$3 (got: $actual)" +} + +test_enqueue_reserves_and_delivers_first_task() { + local dir out + dir=$(make_case first) + out=$(run_lane "$dir" enqueue alpha) + assert_contains "$out" "released alpha" "first enqueue did not release alpha" + assert_state "$dir" "$(owner_state holder alpha full prior "$PRIOR_START" terminal 0)" "first task was not recorded as holder" + [ -x "$dir/home/state/validation-lane.check.sh" ] || fail "watcher check was not installed" + [ -f "$dir/home/state/validation-lane.check-trust" ] || fail "watcher check was not registered" + assert_contains "$(cat "$dir/send.log")" "alpha|Validation slot reserved." "release did not use the send boundary" + pass "validation lane: first enqueue reserves and delivers through registered watcher state" +} + +test_queue_is_fifo_and_terminal_check_releases_next() { + local dir out + dir=$(make_case fifo) + run_lane "$dir" enqueue alpha >/dev/null + out=$(run_lane "$dir" enqueue beta) + assert_contains "$out" "queued beta" "second task was not queued" + assert_state "$dir" "$(owner_state holder alpha full prior "$PRIOR_START" terminal 0 beta)" "queue did not preserve alpha then beta" + FM_TEST_CREW_STATE='state: working · source: run-step · validating' \ + FM_TEST_CREW_RUN_ID=run-alpha FM_TEST_CREW_RUN_START="$NEXT_START" run_lane "$dir" check > "$dir/check-working.out" + [ ! -s "$dir/check-working.out" ] || fail "working holder released its slot" + FM_TEST_CREW_STATE='state: done · source: run-step · checks green' \ + FM_TEST_CREW_RUN_ID=run-alpha FM_TEST_CREW_RUN_START="$NEXT_START" run_lane "$dir" check > "$dir/check-terminal.out" + assert_contains "$(cat "$dir/check-terminal.out")" "released beta" "terminal holder did not release beta" + assert_state "$dir" "$(owner_state holder beta full run-alpha "$NEXT_START" terminal 0)" "beta was not promoted from the FIFO queue" + [ "$(wc -l < "$dir/send.log" | tr -d '[:space:]')" = 2 ] || fail "release delivered an unexpected number of messages" + pass "validation lane: queued work releases in FIFO order only after a terminal run-step" +} + +test_status_log_terminal_does_not_free_a_slot() { + local dir + dir=$(make_case status-log) + run_lane "$dir" enqueue alpha >/dev/null + run_lane "$dir" enqueue beta >/dev/null + FM_TEST_CREW_STATE='state: done · source: status-log · stale event' run_lane "$dir" check > "$dir/check.out" + [ ! -s "$dir/check.out" ] || fail "status-log terminal result released its slot" + assert_state "$dir" "$(owner_state holder alpha full prior "$PRIOR_START" terminal 0 beta)" "status-log result changed validation ownership" + pass "validation lane: only an authoritative run-step terminal result frees a slot" +} + +test_empty_lane_retires_its_watcher_artifacts() { + local dir + dir=$(make_case retire) + run_lane "$dir" enqueue alpha >/dev/null + FM_TEST_CREW_STATE='state: done · source: run-step · checks green' \ + FM_TEST_CREW_RUN_ID=run-alpha run_lane "$dir" check > "$dir/check.out" + [ ! -e "$dir/home/state/validation-lane" ] || fail "empty lane state was not retired" + [ ! -e "$dir/home/state/validation-lane.check.sh" ] || fail "empty lane check was not retired" + [ ! -e "$dir/home/state/validation-lane.check-trust" ] || fail "empty lane trust was not retired" + pass "validation lane: an empty lane retires its watcher artifacts" +} + +test_failed_delivery_remains_pending_and_is_retried() { + local dir out + dir=$(make_case retry) + FM_TEST_SEND_RC=7 FM_TEST_SEND_OUTPUT='fixture transport down' run_lane "$dir" enqueue alpha > "$dir/enqueue.out" + out=$(cat "$dir/enqueue.out") + assert_contains "$out" "release failed for alpha: fixture transport down" "failed delivery was not loud" + assert_state "$dir" "$(owner_state release alpha full prior "$PRIOR_START" terminal 0)" "failed delivery lost its durable release reservation" + FM_TEST_SEND_RC=7 FM_TEST_SEND_OUTPUT='fixture transport down' run_lane "$dir" enqueue beta >/dev/null + assert_state "$dir" "$(owner_state release alpha full prior "$PRIOR_START" terminal 0 beta)" "failed head was skipped when beta queued" + out=$(run_lane "$dir" check) + assert_contains "$out" "released alpha" "pending release was not retried" + assert_state "$dir" "$(owner_state holder alpha full prior "$PRIOR_START" terminal 0 beta)" "retry did not retain FIFO ordering" + pass "validation lane: failed delivery stays loud and preserves the queue head for retry" +} + +test_generated_check_executes_scheduler_without_live_runtime() { + local dir out + dir=$(make_case generated-check) + run_lane "$dir" enqueue alpha >/dev/null + run_lane "$dir" enqueue beta >/dev/null + out=$(FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" \ + FM_VALIDATION_LANE_SEND_BIN="$dir/fakebin/send" \ + FM_VALIDATION_LANE_CREW_STATE_BIN="$dir/fakebin/crew-state" \ + FM_TEST_SEND_LOG="$dir/send.log" \ + FM_TEST_CREW_STATE='state: failed · source: run-step · validation failed' \ + FM_TEST_CREW_RUN_ID=run-alpha FM_TEST_CREW_RUN_START="$NEXT_START" \ + "$dir/home/state/validation-lane.check.sh") + assert_contains "$out" "released beta" "generated watcher check did not release the next task" + assert_state "$dir" "$(owner_state holder beta full run-alpha "$NEXT_START" terminal 0)" "generated watcher check did not commit beta as holder" + pass "validation lane: registered watcher check releases a terminal holder through fixture transport" +} + +test_terminal_status_event_releases_and_queues_a_wake() { + local dir out status drained + dir=$(make_case watcher) + printf '%s\n' fm-pr-check-migration-scan-v1 > "$dir/home/state/.pr-check-migration-scan-v1" + printf '%s\n' fm-pr-check-migration-v1 > "$dir/home/state/.pr-check-migration-v1" + chmod 0600 "$dir/home/state/.pr-check-migration-scan-v1" "$dir/home/state/.pr-check-migration-v1" + run_lane "$dir" enqueue alpha >/dev/null + run_lane "$dir" enqueue beta >/dev/null + touch "$dir/home/state/.last-check" + printf 'done: implementation complete\n' > "$dir/home/state/alpha.status" + status=0 + FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" \ + FM_VALIDATION_LANE_SEND_BIN="$dir/fakebin/send" \ + FM_VALIDATION_LANE_CREW_STATE_BIN="$dir/fakebin/crew-state" \ + FM_TEST_SEND_LOG="$dir/send.log" \ + FM_TEST_CREW_STATE='state: done · source: run-step · checks green' \ + FM_TEST_CREW_RUN_ID=run-alpha FM_TEST_CREW_RUN_START="$NEXT_START" \ + FM_POLL=1 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 \ + "$CHECKPOINT" --seconds 5 > "$dir/checkpoint.out" 2> "$dir/checkpoint.err" || status=$? + expect_code 0 "$status" "watcher checkpoint exit" + out=$(cat "$dir/checkpoint.out") + assert_contains "$out" "check: $dir/home/state/validation-lane.check.sh: released beta" "watcher did not surface validation release" + assert_state "$dir" "$(owner_state holder beta full run-alpha "$NEXT_START" terminal 0)" "watcher check did not commit beta as holder" + drained=$(FM_HOME="$dir/home" "$ROOT/bin/fm-wake-drain.sh") + assert_contains "$drained" $'\tcheck\t' "watcher release wake was not queued" + assert_contains "$drained" 'released beta' "queued watcher wake lost release diagnostic" + pass "validation lane: terminal status event runs the authenticated release check" +} + +test_terminal_status_event_surfaces_and_retries_lane_failure() { + local dir out status drained + dir=$(make_case watcher-failure) + printf '%s\n' fm-pr-check-migration-scan-v1 > "$dir/home/state/.pr-check-migration-scan-v1" + printf '%s\n' fm-pr-check-migration-v1 > "$dir/home/state/.pr-check-migration-v1" + chmod 0600 "$dir/home/state/.pr-check-migration-scan-v1" "$dir/home/state/.pr-check-migration-v1" + run_lane "$dir" enqueue alpha >/dev/null + run_lane "$dir" enqueue beta >/dev/null + touch "$dir/home/state/.last-check" + printf 'done: implementation complete\n' > "$dir/home/state/alpha.status" + status=0 + FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" \ + FM_VALIDATION_LANE_SEND_BIN="$dir/fakebin/send" \ + FM_VALIDATION_LANE_CREW_STATE_BIN="$dir/fakebin/crew-state" \ + FM_TEST_SEND_LOG="$dir/send.log" FM_TEST_CREW_STATE_RC=7 \ + FM_POLL=1 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 \ + "$CHECKPOINT" --seconds 5 > "$dir/failed.out" 2> "$dir/failed.err" || status=$? + expect_code 0 "$status" "failing lane event checkpoint exit" + out=$(cat "$dir/failed.out") + assert_contains "$out" \ + "check: $dir/home/state/validation-lane.check.sh failed (exit 1): validation-lane: cannot read reservation state for alpha" \ + "watcher did not surface the lane check failure" + assert_state "$dir" "$(owner_state holder alpha full prior "$PRIOR_START" terminal 0 beta)" \ + "lane check failure changed validation ownership" + [ ! -e "$dir/home/state/.seen-alpha_status" ] || fail "lane check failure consumed its retry signal" + drained=$(FM_HOME="$dir/home" "$ROOT/bin/fm-wake-drain.sh") + assert_contains "$drained" $'\tcheck\t' "lane check failure wake was not queued" + assert_contains "$drained" 'cannot read reservation state for alpha' \ + "lane check failure wake lost its diagnostic" + status=0 + FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" \ + FM_VALIDATION_LANE_SEND_BIN="$dir/fakebin/send" \ + FM_VALIDATION_LANE_CREW_STATE_BIN="$dir/fakebin/crew-state" \ + FM_TEST_SEND_LOG="$dir/send.log" \ + FM_TEST_CREW_STATE='state: done · source: run-step · checks green' \ + FM_TEST_CREW_RUN_ID=run-alpha FM_TEST_CREW_RUN_START="$NEXT_START" \ + FM_POLL=1 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 \ + "$CHECKPOINT" --seconds 5 > "$dir/retry.out" 2> "$dir/retry.err" || status=$? + expect_code 0 "$status" "retried lane event checkpoint exit" + assert_contains "$(cat "$dir/retry.out")" "released beta" \ + "unconsumed terminal event did not retry the lane check" + assert_state "$dir" "$(owner_state holder beta full run-alpha "$NEXT_START" terminal 0)" \ + "retried lane check did not transfer validation ownership" + pass "validation lane: terminal event failures stay loud and retryable" +} + +test_periodic_lane_failure_is_loud() { + local dir out status + dir=$(make_case periodic-failure) + printf '%s\n' fm-pr-check-migration-scan-v1 > "$dir/home/state/.pr-check-migration-scan-v1" + printf '%s\n' fm-pr-check-migration-v1 > "$dir/home/state/.pr-check-migration-v1" + chmod 0600 "$dir/home/state/.pr-check-migration-scan-v1" "$dir/home/state/.pr-check-migration-v1" + run_lane "$dir" enqueue alpha >/dev/null + run_lane "$dir" enqueue beta >/dev/null + status=0 + FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" \ + FM_VALIDATION_LANE_SEND_BIN="$dir/fakebin/send" \ + FM_VALIDATION_LANE_CREW_STATE_BIN="$dir/fakebin/crew-state" \ + FM_TEST_SEND_LOG="$dir/send.log" FM_TEST_CREW_STATE_RC=7 \ + FM_POLL=1 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=0 FM_HEARTBEAT=999999 \ + "$CHECKPOINT" --seconds 5 > "$dir/checkpoint.out" 2> "$dir/checkpoint.err" || status=$? + expect_code 0 "$status" "failing periodic lane checkpoint exit" + out=$(cat "$dir/checkpoint.out") + assert_contains "$out" \ + "check: $dir/home/state/validation-lane.check.sh failed (exit 1): validation-lane: cannot read reservation state for alpha" \ + "periodic lane failure was silent" + assert_state "$dir" "$(owner_state holder alpha full prior "$PRIOR_START" terminal 0 beta)" \ + "periodic lane failure changed validation ownership" + pass "validation lane: periodic check failures stay loud" +} + +test_prior_terminal_run_cannot_clear_a_new_reservation() { + local dir out + dir=$(make_case stale-terminal) + run_lane "$dir" enqueue alpha >/dev/null + run_lane "$dir" enqueue beta >/dev/null + out=$(run_lane "$dir" check) + [ -z "$out" ] || fail "prior terminal run released a new reservation" + assert_state "$dir" "$(owner_state holder alpha full prior "$PRIOR_START" terminal 0 beta)" "prior terminal run changed the lane" + out=$(FM_TEST_CREW_RUN_ID=new-run FM_TEST_CREW_RUN_START="$NEXT_START" run_lane "$dir" check) + assert_contains "$out" "released beta" "new terminal run identity did not release the next task" + assert_state "$dir" "$(owner_state holder beta full new-run "$NEXT_START" terminal 0)" "new run did not transfer the slot" + pass "validation lane: completion is bound to a post-reservation run identity" +} + +test_same_run_state_changes_do_not_start_reservation() { + local dir out + dir=$(make_case same-run-state) + run_lane "$dir" enqueue alpha >/dev/null + run_lane "$dir" enqueue beta >/dev/null + FM_TEST_CREW_STATE='state: working · source: run-step · validating' \ + run_lane "$dir" check > "$dir/working.out" + [ ! -s "$dir/working.out" ] || fail "same run working state released the reservation" + assert_state "$dir" "$(owner_state holder alpha full prior "$PRIOR_START" terminal 0 beta)" \ + "same run state change marked the reservation started" + out=$(run_lane "$dir" check) + [ -z "$out" ] || fail "same run terminal state released the reservation" + assert_state "$dir" "$(owner_state holder alpha full prior "$PRIOR_START" terminal 0 beta)" \ + "same run terminal state changed validation ownership" + pass "validation lane: same run state changes cannot prove a new start" +} + +test_post_reservation_transition_binds_coarse_run_completion() { + local dir out + dir=$(make_case transition) + run_lane "$dir" enqueue alpha >/dev/null + run_lane "$dir" enqueue beta >/dev/null + FM_TEST_CREW_STATE='state: working · source: run-step · validating' \ + FM_TEST_CREW_RUN_KIND=coarse FM_TEST_CREW_RUN_START="$NEXT_START" \ + run_lane "$dir" check > "$dir/active.out" + [ ! -s "$dir/active.out" ] || fail "active coarse run released the slot" + assert_state "$dir" "$(owner_state holder alpha full prior "$PRIOR_START" terminal 1 beta)" "post-reservation transition was not recorded" + out=$(FM_TEST_CREW_RUN_KIND=coarse FM_TEST_CREW_RUN_START="$NEXT_START" run_lane "$dir" check) + assert_contains "$out" "released beta" "terminal coarse transition did not release the next task" + assert_state "$dir" "$(owner_state holder beta coarse none "$NEXT_START" terminal 0)" "coarse transition did not transfer the slot" + pass "validation lane: post-reservation run transition binds coarse completion" +} + +test_run_start_binds_coarse_completion_between_checks() { + local dir out + dir=$(make_case coarse-between-checks) + run_lane "$dir" enqueue alpha >/dev/null + run_lane "$dir" enqueue beta >/dev/null + out=$(FM_TEST_CREW_RUN_KIND=coarse FM_TEST_CREW_RUN_START="$NEXT_START" run_lane "$dir" check) + assert_contains "$out" "released beta" "coarse run completed between checks did not release the next task" + assert_state "$dir" "$(owner_state holder beta coarse none "$NEXT_START" terminal 0)" "coarse run-start evidence did not transfer the slot" + pass "validation lane: run-start evidence binds completion between watcher checks" +} + +test_run_start_binds_unavailable_completion_between_checks() { + local dir out + dir=$(make_case unavailable-between-checks) + FM_TEST_CREW_RUN_KIND=unavailable run_lane "$dir" enqueue alpha >/dev/null + run_lane "$dir" enqueue beta >/dev/null + out=$(FM_TEST_CREW_RUN_KIND=unavailable FM_TEST_CREW_RUN_START="$NEXT_START" run_lane "$dir" check) + assert_contains "$out" "released beta" "run with unavailable identity completed between checks did not release the next task" + assert_state "$dir" "$(owner_state holder beta unavailable none "$NEXT_START" terminal 0)" "unavailable run-start evidence did not transfer the slot" + pass "validation lane: run-start evidence binds unavailable completion" +} + +test_absent_reservation_binds_new_unavailable_run_start() { + local dir out + dir=$(make_case absent-to-unavailable) + FM_TEST_CREW_STATE='state: unknown · source: none · no run' \ + FM_TEST_CREW_RUN_KIND=absent FM_TEST_CREW_RUN_START='' run_lane "$dir" enqueue alpha >/dev/null + run_lane "$dir" enqueue beta >/dev/null + out=$(FM_TEST_CREW_RUN_KIND=unavailable FM_TEST_CREW_RUN_START="$NEXT_START" run_lane "$dir" check) + assert_contains "$out" "released beta" "new unavailable run-start did not release an absent reservation" + assert_state "$dir" "$(owner_state holder beta unavailable none "$NEXT_START" terminal 0)" "new unavailable run-start did not transfer the slot" + pass "validation lane: absent reservation binds a new unavailable run start" +} + +test_unavailable_empty_evidence_keeps_head_queued() { + local dir status=0 out + dir=$(make_case unavailable-empty) + FM_TEST_CREW_RUN_KIND=unavailable FM_TEST_CREW_RUN_START='' \ + run_lane "$dir" enqueue alpha > "$dir/enqueue.out" 2> "$dir/enqueue.err" || status=$? + expect_code 1 "$status" "unavailable empty reservation evidence" + assert_contains "$(cat "$dir/enqueue.err")" "without comparable run evidence" "unavailable empty evidence was not diagnosed" + assert_state "$dir" $'fm-validation-lane-v2\nqueued=alpha' "unavailable empty evidence did not retain the FIFO head" + [ ! -s "$dir/send.log" ] || fail "unavailable empty evidence delivered the FIFO head" + [ -f "$dir/home/state/validation-lane.check-trust" ] || fail "unavailable empty evidence did not retain its watcher" + out=$(FM_TEST_CREW_RUN_KIND=unavailable FM_TEST_CREW_RUN_START="$PRIOR_START" run_lane "$dir" check) + assert_contains "$out" "released alpha" "comparable evidence did not release the retained FIFO head" + assert_state "$dir" "$(owner_state holder alpha unavailable none "$PRIOR_START" terminal 0)" "comparable evidence did not preserve reservation ownership" + pass "validation lane: unavailable empty evidence retains the watched FIFO head" +} + +test_unavailable_empty_terminal_retains_started_holder() { + local dir out + dir=$(make_case unavailable-empty-terminal) + run_lane "$dir" enqueue alpha >/dev/null + run_lane "$dir" enqueue beta >/dev/null + FM_TEST_CREW_STATE='state: working · source: run-step · validating' \ + FM_TEST_CREW_RUN_ID=run-alpha FM_TEST_CREW_RUN_START="$NEXT_START" run_lane "$dir" check > "$dir/active.out" + [ ! -s "$dir/active.out" ] || fail "active run released the holder" + assert_state "$dir" "$(owner_state holder alpha full prior "$PRIOR_START" terminal 1 beta)" "active run was not recorded for the holder" + out=$(FM_TEST_CREW_RUN_KIND=unavailable FM_TEST_CREW_RUN_START='' run_lane "$dir" check) + [ -z "$out" ] || fail "unavailable empty terminal evidence released the holder" + assert_state "$dir" "$(owner_state holder alpha full prior "$PRIOR_START" terminal 1 beta)" "unavailable empty terminal evidence changed the holder" + [ "$(wc -l < "$dir/send.log" | tr -d '[:space:]')" = 1 ] || fail "unavailable empty terminal evidence sent the queued task" + out=$(FM_TEST_CREW_RUN_KIND=unavailable FM_TEST_CREW_RUN_START="$NEXT_START" run_lane "$dir" check) + assert_contains "$out" "released beta" "comparable terminal evidence did not release the retained holder" + pass "validation lane: unavailable empty terminal evidence retains a started holder" +} + +test_concurrent_releasers_have_one_sender() { + local dir first_pid second_pid first_status=0 second_status=0 sends out + dir=$(make_case one-sender) + FM_TEST_SEND_RC=7 run_lane "$dir" enqueue alpha >/dev/null + : > "$dir/send.log" + FM_TEST_SEND_DELAY=0.2 run_lane "$dir" check > "$dir/first.out" & + first_pid=$! + FM_TEST_SEND_DELAY=0.2 run_lane "$dir" check > "$dir/second.out" & + second_pid=$! + wait "$first_pid" || first_status=$? + wait "$second_pid" || second_status=$? + expect_code 0 "$first_status" "first concurrent releaser" + expect_code 0 "$second_status" "second concurrent releaser" + sends=$(wc -l < "$dir/send.log" | tr -d '[:space:]') + [ "$sends" = 1 ] || fail "concurrent release sent $sends messages" + out=$(cat "$dir/first.out" "$dir/second.out") + [ "$(printf '%s\n' "$out" | grep -c '^released alpha$')" = 1 ] || fail "concurrent release reported multiple senders" + assert_state "$dir" "$(owner_state holder alpha full prior "$PRIOR_START" terminal 0)" "concurrent release corrupted ownership" + pass "validation lane: concurrent releasers serialize one sender" +} + +test_duplicate_enqueue_repairs_registration_and_pending_delivery() { + local dir status=0 out + dir=$(make_case registration-retry) + FM_TEST_REGISTER_RC=9 run_lane "$dir" enqueue alpha > "$dir/first.out" 2> "$dir/first.err" || status=$? + expect_code 1 "$status" "transient registration failure" + assert_state "$dir" "$(owner_state release alpha full prior "$PRIOR_START" terminal 0)" "registration failure lost the reservation" + [ ! -s "$dir/send.log" ] || fail "registration failure delivered before the watcher was repaired" + out=$(run_lane "$dir" enqueue alpha) + assert_contains "$out" "released alpha" "duplicate enqueue did not resume pending delivery" + [ -f "$dir/home/state/validation-lane.check-trust" ] || fail "duplicate enqueue did not repair watcher registration" + assert_state "$dir" "$(owner_state holder alpha full prior "$PRIOR_START" terminal 0)" "registration retry did not commit the holder" + pass "validation lane: duplicate enqueue repairs registration and resumes release" +} + +test_enqueue_reserves_and_delivers_first_task +test_queue_is_fifo_and_terminal_check_releases_next +test_status_log_terminal_does_not_free_a_slot +test_empty_lane_retires_its_watcher_artifacts +test_failed_delivery_remains_pending_and_is_retried +test_generated_check_executes_scheduler_without_live_runtime +test_terminal_status_event_releases_and_queues_a_wake +test_terminal_status_event_surfaces_and_retries_lane_failure +test_periodic_lane_failure_is_loud +test_prior_terminal_run_cannot_clear_a_new_reservation +test_same_run_state_changes_do_not_start_reservation +test_post_reservation_transition_binds_coarse_run_completion +test_run_start_binds_coarse_completion_between_checks +test_run_start_binds_unavailable_completion_between_checks +test_absent_reservation_binds_new_unavailable_run_start +test_unavailable_empty_evidence_keeps_head_queued +test_unavailable_empty_terminal_retains_started_holder +test_concurrent_releasers_have_one_sender +test_duplicate_enqueue_repairs_registration_and_pending_delivery diff --git a/tests/fm-vendor-auth-probe.test.sh b/tests/fm-vendor-auth-probe.test.sh new file mode 100755 index 00000000000..47367a32682 --- /dev/null +++ b/tests/fm-vendor-auth-probe.test.sh @@ -0,0 +1,395 @@ +#!/usr/bin/env bash +# Behavior tests for fm-vendor-auth-probe.sh - the one hard-bounded, +# non-destructive authentication probe of a named vendor CLI. +# +# Two defects this suite pins: +# +# 1. The script must render no dispatch verdict and hold no routing knowledge. +# Its predecessor resolved a candidate's credential surface from a hard-coded +# harness-to-provider table plus a `pi:<model-prefix>` source-id matcher, and +# emitted `eligible=`. A supported Pi model in a provider family with no such +# prefixed source was therefore dropped as unresolved while the family's own +# quota and credentials were healthy. The tests below prove the script now has +# no harness, model, or provider input surface at all, so no such mapping can +# influence it, and that both probe outcomes exit alike because neither is a +# verdict. +# +# 2. The captain-approved probe envelope must not depend on agent memory: fixed +# argv, stdin closed, a hard positive bound, and raw vendor output never +# printed. The fake grok records every invocation's argv and anything it can +# read from stdin, so "argv is fixed to `models`", "no login or logout", and +# "stdin stays closed" are observable facts rather than comments. +set -u + +# shellcheck source=tests/lib.sh disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +BASE_PATH=${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin} +TMP_ROOT=$(fm_test_tmproot fm-vendor-auth-probe-tests) +SCRIPT="$ROOT/bin/fm-vendor-auth-probe.sh" + +# A stdin payload the script must never leak into a probed vendor CLI. +STDIN_SENTINEL='SENTINEL-STDIN-MUST-NOT-REACH-VENDOR-CLI' + +# --- fake toolchain --------------------------------------------------------- +# +# quota-axi is present on PATH and logs every invocation. The script must never +# call it: reading quota is the dispatch owner's job against one intake snapshot, +# and a probe that re-read it would reintroduce the retired coupling. +make_fakebin() { + local dir=$1 fakebin + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/quota-axi" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$FM_FAKE_QUOTA_LOG" +exit 0 +SH + chmod +x "$fakebin/quota-axi" + + cat > "$fakebin/grok" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$FM_FAKE_GROK_LOG" +# Record whatever is readable on stdin. With stdin correctly closed by the +# caller this reads EOF immediately and records nothing. +if IFS= read -r -t 2 leaked; then + printf '%s\n' "$leaked" >> "$FM_FAKE_GROK_STDIN" +fi +if [ "${1:-}" = --version ]; then + printf 'grok %s (fakebuild) [stable]\n' "${FM_FAKE_GROK_VERSION:-0.2.117}" + exit 0 +fi +case "${FM_FAKE_GROK_MODE:-authenticated}" in + authenticated) + printf '%s\n' 'You are logged in with grok.com.' + printf '\n%s\n' 'Default model: grok-4.5' + ;; + unauthenticated) + printf '%s\n' 'You are not authenticated.' + ;; + garbage) + printf '%s\n' 'Session status: unknown (0.9.0 rewrote this line)' + ;; + leading-blank) + printf '\n%s\n' 'You are logged in with grok.com.' + ;; + empty) : ;; + hang) sleep 30 ;; +esac +# grok 0.2.117 exits 0 whether or not the session authenticates; the fake keeps +# that property so a regression to exit-status reading fails here. +exit 0 +SH + chmod +x "$fakebin/grok" + printf '%s\n' "$fakebin" +} + +# run_probe <case> [args...] -- [env assignments...] +# Sets RUN_LINE, RUN_RC, RUN_GROK_LOG, RUN_GROK_STDIN, RUN_QUOTA_LOG in the +# caller's shell, so it must not be invoked in a command substitution. +RUN_LINE= +RUN_RC=0 +RUN_GROK_LOG= +RUN_GROK_STDIN= +RUN_QUOTA_LOG= +run_probe() { + local case_name=$1 + shift + local case_dir fakebin out rc=0 arg + local -a script_args=() env_pairs=() + case_dir="$TMP_ROOT/$case_name" + mkdir -p "$case_dir" + fakebin=$(make_fakebin "$case_dir") + RUN_GROK_LOG="$case_dir/grok.log" + RUN_GROK_STDIN="$case_dir/grok.stdin" + RUN_QUOTA_LOG="$case_dir/quota.log" + : > "$RUN_GROK_LOG" + : > "$RUN_GROK_STDIN" + : > "$RUN_QUOTA_LOG" + local seen_separator=0 + for arg in "$@"; do + if [ "$seen_separator" -eq 0 ] && [ "$arg" = -- ]; then + seen_separator=1 + continue + fi + if [ "$seen_separator" -eq 0 ]; then + script_args+=("$arg") + else + env_pairs+=("$arg") + fi + done + out=$(env "PATH=$fakebin:$BASE_PATH" \ + "FM_FAKE_GROK_LOG=$RUN_GROK_LOG" \ + "FM_FAKE_GROK_STDIN=$RUN_GROK_STDIN" \ + "FM_FAKE_QUOTA_LOG=$RUN_QUOTA_LOG" \ + "${env_pairs[@]+"${env_pairs[@]}"}" \ + "$SCRIPT" "${script_args[@]+"${script_args[@]}"}" \ + <<<"$STDIN_SENTINEL" 2>/dev/null) || rc=$? + RUN_RC=$rc + RUN_LINE=$out +} + +field() { # <line> <key> + printf '%s\n' "$1" | tr ' ' '\n' | sed -n "s/^$2=//p" +} + +assert_field() { # <line> <key> <expected> <label> + local got + got=$(field "$1" "$2") + [ "$got" = "$3" ] || fail "$4: expected $2=$3, got $2=${got:-<absent>}"$'\n'"--- line ---"$'\n'"$1" +} + +# Every recorded grok invocation must be one of the two fixed, non-destructive +# argv forms. A login, logout, or bare interactive launch fails here. +assert_grok_argv_safe() { # <label> + local line + while IFS= read -r line; do + [ -n "$line" ] || continue + case "$line" in + models|--version) : ;; + *) fail "$1: unexpected Grok CLI invocation 'grok $line'" ;; + esac + done < "$RUN_GROK_LOG" +} + +assert_grok_never_ran() { # <label> + [ ! -s "$RUN_GROK_LOG" ] \ + || fail "$1: no vendor CLI may run, but grok was invoked with: $(tr '\n' '|' < "$RUN_GROK_LOG")" +} + +assert_quota_never_read() { # <label> + [ ! -s "$RUN_QUOTA_LOG" ] \ + || fail "$1: the probe must never call quota-axi, but it ran: $(tr '\n' '|' < "$RUN_QUOTA_LOG")" +} + +# --- the retired dispatch coupling ------------------------------------------ + +# The core retirement: the probe carries no candidate identity, so no +# harness-to-provider table, model prefix matcher, or provider-family mapping can +# exist behind it. Every shape of candidate input is a usage error, and none of +# them reaches a vendor CLI. +test_probe_accepts_no_candidate_identity() { + local label + local -a args + # Both shapes matter. Without a probe name, candidate identity must not stand + # in for one. WITH a valid probe name, candidate identity must still be + # refused rather than quietly accepted and ignored - a silently tolerated + # `--model` is exactly the seam a routing mapping would grow back through. + for label in harness-flag model-flag provider-flag tuple positional-model \ + probe-with-harness probe-with-model probe-with-tuple probe-with-provider; do + case "$label" in + harness-flag) args=(--harness pi) ;; + model-flag) args=(--model openai-codex/gpt-5.6-terra) ;; + provider-flag) args=(--provider codex) ;; + tuple) args=(--harness pi --model openai-codex/gpt-5.6-terra) ;; + positional-model) args=(grok openai-codex/gpt-5.6-terra) ;; + probe-with-harness) args=(grok --harness pi) ;; + probe-with-model) args=(grok --model openai-codex/gpt-5.6-terra) ;; + probe-with-tuple) args=(grok --harness pi --model openai-codex/gpt-5.6-terra) ;; + probe-with-provider) args=(grok --provider codex) ;; + esac + run_probe "identity-$label" "${args[@]}" + expect_code 2 "$RUN_RC" "$label must be a usage error, not a candidate verdict" + [ -z "$RUN_LINE" ] || fail "$label must not emit a fact line: $RUN_LINE" + assert_grok_never_ran "identity-$label" + assert_quota_never_read "identity-$label" + done + pass "the probe accepts no harness, model, or provider and so can hold no routing mapping" +} + +# The retired script read quota to decide eligibility. This one must not, so an +# intake keeps exactly one snapshot and the probe cannot re-derive a route. +test_probe_never_reads_quota() { + local mode + for mode in authenticated unauthenticated; do + run_probe "no-quota-$mode" grok -- "FM_FAKE_GROK_MODE=$mode" + assert_quota_never_read "no-quota-$mode" + done + pass "the probe never reads quota, leaving one intake snapshot to the dispatch owner" +} + +# Neither outcome is a verdict, so neither may be encoded in the exit status. A +# caller that branched on the exit status would be reinventing the eligibility +# gate this script was narrowed to remove. +test_probe_result_is_never_an_exit_status_verdict() { + local mode + for mode in authenticated unauthenticated garbage empty; do + run_probe "rc-$mode" grok -- "FM_FAKE_GROK_MODE=$mode" + expect_code 0 "$RUN_RC" "probe result '$mode' must not be encoded in the exit status" + [ -n "$RUN_LINE" ] || fail "probe result '$mode' must still print its fact line" + done + pass "every probe result exits alike because the script renders no verdict" +} + +test_unregistered_probe_is_a_usage_error() { + local name + for name in openai codex claude pi ''; do + if [ -z "$name" ]; then + run_probe "unregistered-empty" + else + run_probe "unregistered-$name" "$name" + fi + expect_code 2 "$RUN_RC" "an unregistered probe name must be a usage error" + assert_grok_never_ran "unregistered-${name:-empty}" + done + pass "only a registered probe name runs, and an unregistered one is a usage error" +} + +# --- probe classification --------------------------------------------------- + +test_authenticated_session_is_reported() { + run_probe authenticated grok -- "FM_FAKE_GROK_MODE=authenticated" + expect_code 0 "$RUN_RC" "a completed probe prints its fact" + assert_field "$RUN_LINE" probe grok "the probe name must be echoed" + assert_field "$RUN_LINE" status authenticated "an authenticated first line must be recognized" + assert_grok_argv_safe "authenticated case" + pass "an authenticated vendor session is reported as ground truth" +} + +test_unauthenticated_session_is_reported() { + run_probe unauthenticated grok -- "FM_FAKE_GROK_MODE=unauthenticated" + assert_field "$RUN_LINE" status unauthenticated "an unauthenticated first line must be recognized" + assert_grok_argv_safe "unauthenticated case" + pass "an unauthenticated vendor session is reported as ground truth" +} + +# The exit status is deliberately not the verdict, so a rewritten status line +# must read as indeterminate rather than as a successful authentication. +test_unrecognized_output_is_indeterminate() { + local mode + for mode in garbage leading-blank empty; do + run_probe "indeterminate-$mode" grok -- "FM_FAKE_GROK_MODE=$mode" + assert_field "$RUN_LINE" status indeterminate "'$mode' output must never read as authenticated" + done + pass "unrecognized, blank-led, and silent probe output is indeterminate, never authenticated" +} + +test_missing_vendor_cli_is_reported_not_assumed() { + local case_dir fakebin line rc=0 + case_dir="$TMP_ROOT/grok-absent" + mkdir -p "$case_dir" + fakebin=$(make_fakebin "$case_dir") + rm -f "$fakebin/grok" + line=$(env "PATH=$fakebin:$BASE_PATH" \ + "FM_FAKE_QUOTA_LOG=$case_dir/quota.log" \ + "$SCRIPT" grok </dev/null 2>/dev/null) || rc=$? + expect_code 0 "$rc" "an absent vendor CLI is a fact, not a usage error" + assert_field "$line" status unavailable "an absent probe command must be reported" + assert_field "$line" version none "an absent CLI has no version to report" + assert_field "$line" versionVerified none "an absent CLI cannot be version-verified" + pass "an absent vendor CLI is reported rather than assumed authenticated" +} + +# --- the bounded, non-destructive envelope ---------------------------------- + +test_hanging_probe_is_bounded_and_reported() { + local started finished + started=$(date +%s) + run_probe grok-hang grok -- "FM_FAKE_GROK_MODE=hang" "FM_VENDOR_AUTH_PROBE_TIMEOUT=2" + finished=$(date +%s) + assert_field "$RUN_LINE" status timeout "a hit bound must be reported as a timeout" + [ $((finished - started)) -lt 25 ] \ + || fail "the probe was not bounded: took $((finished - started))s against a 2s bound" + pass "a hanging vendor CLI is hard-bounded, reported, and cannot wedge an intake" +} + +# `timeout 0` and the Perl fallback's `alarm 0` both mean "no deadline", so a +# zero bound passed through would silently remove the hard bound entirely. The +# fake hangs for 30s, longer than the 20s default it must fall back to, so the +# two outcomes are distinguishable. +test_zero_bound_falls_back_to_a_real_bound() { + local started finished value + for value in 0 00; do + started=$(date +%s) + run_probe "bound-zero-$value" grok -- "FM_FAKE_GROK_MODE=hang" "FM_VENDOR_AUTH_PROBE_TIMEOUT=$value" + finished=$(date +%s) + assert_field "$RUN_LINE" status timeout "a zero bound must fall back to the default bound, not to no bound" + [ $((finished - started)) -lt 28 ] \ + || fail "a zero bound removed the hard bound: took $((finished - started))s" + done + pass "zero and all-zero bounds fall back to the default instead of removing the hard bound" +} + +# A bogus bound must be replaced, not forwarded: `timeout abc` and `timeout -1` +# fail outright, which would turn a healthy probe into a false indeterminate. +test_malformed_bound_is_replaced_not_forwarded() { + local value + for value in -1 abc 1.5 ' '; do + run_probe "bound-${value// /space}" grok -- "FM_FAKE_GROK_MODE=authenticated" "FM_VENDOR_AUTH_PROBE_TIMEOUT=$value" + assert_field "$RUN_LINE" status authenticated "bound '$value' must be replaced, not forwarded to the bounding command" + done + pass "a malformed bound is replaced by the default rather than forwarded" +} + +test_probe_never_inherits_caller_stdin() { + run_probe grok-stdin grok -- "FM_FAKE_GROK_MODE=authenticated" + [ -n "$RUN_LINE" ] || fail "expected a fact line" + [ ! -s "$RUN_GROK_STDIN" ] \ + || fail "the probe inherited caller stdin: $(cat "$RUN_GROK_STDIN")" + pass "the bounded probe runs with stdin closed and cannot read caller input" +} + +test_probe_argv_is_fixed_and_non_destructive() { + local mode + for mode in authenticated unauthenticated garbage; do + run_probe "argv-$mode" grok -- "FM_FAKE_GROK_MODE=$mode" + assert_grok_argv_safe "argv-$mode" + [ "$(grep -c . "$RUN_GROK_LOG")" -eq 2 ] \ + || fail "argv-$mode: expected exactly one --version and one models call, got: $(tr '\n' '|' < "$RUN_GROK_LOG")" + done + pass "the vendor CLI is invoked only through its two fixed, non-destructive argv forms" +} + +test_fact_line_carries_no_vendor_output_or_credential_material() { + run_probe sanitized grok -- "FM_FAKE_GROK_MODE=authenticated" + assert_not_contains "$RUN_LINE" "You are logged in" "the fact line must not echo raw vendor output" + assert_not_contains "$RUN_LINE" "grok.com" "the fact line must not echo raw vendor output" + assert_not_contains "$RUN_LINE" "auth.json" "the fact line must not name a credential path" + assert_not_contains "$RUN_LINE" "$STDIN_SENTINEL" "the fact line must not echo caller stdin" + case "$RUN_LINE" in + *$'\n'*) fail "the fact line must be exactly one line" ;; + esac + pass "the fact line is one sanitized line with no raw vendor output or credential material" +} + +# --- version disclosure ----------------------------------------------------- + +# The discriminator strings are un-owned vendor UI text. A version change does +# not silently invalidate the fact, but it is disclosed so it can be re-verified. +test_probe_version_change_is_disclosed() { + run_probe version-drift grok -- "FM_FAKE_GROK_MODE=authenticated" "FM_FAKE_GROK_VERSION=0.9.0" + assert_field "$RUN_LINE" version 0.9.0 "the probed CLI version must be recorded" + assert_field "$RUN_LINE" versionVerified no "an unverified version must be disclosed" + pass "a vendor CLI version change is recorded and disclosed for re-verification" +} + +test_probe_version_match_is_recorded() { + run_probe version-pinned grok -- "FM_FAKE_GROK_MODE=authenticated" + assert_field "$RUN_LINE" versionVerified yes "the pinned verified version must be recognized" + pass "the pinned verified vendor version is recognized" +} + +test_help_succeeds_and_names_the_registered_probes() { + local out rc=0 + out=$("$SCRIPT" --help 2>&1) || rc=$? + expect_code 0 "$rc" "--help must succeed" + assert_contains "$out" "grok" "--help must name the registered probes" + pass "--help succeeds and names the registered probes" +} + +test_probe_accepts_no_candidate_identity +test_probe_never_reads_quota +test_probe_result_is_never_an_exit_status_verdict +test_unregistered_probe_is_a_usage_error +test_authenticated_session_is_reported +test_unauthenticated_session_is_reported +test_unrecognized_output_is_indeterminate +test_missing_vendor_cli_is_reported_not_assumed +test_hanging_probe_is_bounded_and_reported +test_zero_bound_falls_back_to_a_real_bound +test_malformed_bound_is_replaced_not_forwarded +test_probe_never_inherits_caller_stdin +test_probe_argv_is_fixed_and_non_destructive +test_fact_line_carries_no_vendor_output_or_credential_material +test_probe_version_change_is_disclosed +test_probe_version_match_is_recorded +test_help_succeeds_and_names_the_registered_probes diff --git a/tests/fm-wake-daemon-lifecycle-e2e.test.sh b/tests/fm-wake-daemon-lifecycle-e2e.test.sh index 639db67b5a3..42f879080bd 100755 --- a/tests/fm-wake-daemon-lifecycle-e2e.test.sh +++ b/tests/fm-wake-daemon-lifecycle-e2e.test.sh @@ -106,7 +106,7 @@ test_routine_then_terminal_after_restart() { # --- Phase 2: stale working-pane transient -> persistent -> resumed ---------- test_stale_pane_transient_persistent_resume() { - local dir state fakebin win key + local dir state fakebin win key resumed_gen dir=$(make_supercase wd-stale) state="$dir/state" fakebin="$dir/fakebin" @@ -128,15 +128,23 @@ test_stale_pane_transient_persistent_resume() { echo $(( $(date +%s) - 500 )) > "$state/.subsuper-stale-$key" : > "$state/.subsuper-escalations" 2>/dev/null || true PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$dir/pane.txt" \ - FM_STATE_OVERRIDE="$state" FM_STALE_ESCALATE_SECS=240 housekeeping "$state" + FM_STATE_OVERRIDE="$state" FM_STALE_ESCALATE_SECS=240 housekeeping "$state" \ + 2>"$dir/housekeeping.err" + [ ! -s "$dir/housekeeping.err" ] \ + || fail "missing task metadata leaked a raw read error: $(cat "$dir/housekeeping.err")" [ -s "$state/.subsuper-escalations" ] || fail "persistent stale did not escalate" [ ! -e "$state/.subsuper-stale-$key" ] || fail "stale marker not cleared after escalation" - # Resumed: a fresh transient marker but the pane is now busy -> housekeeping - # clears the marker without escalating. + # Resumed: a fresh transient marker but the crew is provably working again -> + # housekeeping clears the marker without escalating. The proof is the crew's + # own semantic busy-state record (bin/fm-busy-lib.sh), not rendered pane text. stale_marker_record "$win" "$state" echo $(( $(date +%s) - 500 )) > "$state/.subsuper-stale-$key" printf 'Working...\n' > "$dir/pane.txt" + fm_write_meta "$state/stale-w2.meta" "window=$win" "worktree=$dir/wt" "kind=ship" "harness=pi" + resumed_gen=$("$ROOT/bin/fm-busy-event.sh" arm "$state" stale-w2) + "$ROOT/bin/fm-busy-event.sh" apply "$state" stale-w2 busy --gen "$resumed_gen" \ + --source pi-ext --event agent-start : > "$state/.subsuper-escalations" PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$dir/pane.txt" \ FM_STATE_OVERRIDE="$state" FM_STALE_ESCALATE_SECS=240 housekeeping "$state" diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index a14a2923bfd..c6d7928bb78 100755 --- a/tests/fm-watch-triage.test.sh +++ b/tests/fm-watch-triage.test.sh @@ -102,6 +102,13 @@ prime_turnend_seen() { # <file> printf '%s' "$(seen_sig "$f")" > "$(dirname "$f")/.seen-$base" } +record_pi_busy() { # <state-dir> <id> + local state=$1 id=$2 gen + gen=$("$ROOT/bin/fm-busy-event.sh" arm "$state" "$id") + "$ROOT/bin/fm-busy-event.sh" apply "$state" "$id" busy --gen "$gen" \ + --source pi-ext --event agent-start +} + reap() { kill "$1" 2>/dev/null || true; wait "$1" 2>/dev/null || true; } # --- pure classifier predicates (fm-classify-lib.sh) ------------------------ @@ -1098,6 +1105,7 @@ test_busy_pane_below_turn_age_bound_is_absorbed() { out="$dir/watch.out"; capture_file="$dir/pane.txt"; window="test:fm-busy-fresh" printf 'Working... (12.3s)' > "$capture_file" printf 'window=%s\nkind=ship\nharness=pi\n' "$window" > "$state/busy-fresh.meta" + record_pi_busy "$state" busy-fresh printf 'working: setup complete\n' > "$state/busy-fresh.status" sig=$(seen_sig "$state/busy-fresh.status"); printf '%s' "$sig" > "$state/.seen-busy-fresh_status" key=$(printf '%s' "$window" | tr ':/.' '___') @@ -1123,6 +1131,7 @@ test_busy_pane_stable_hash_escalates_past_turn_age_bound() { out="$dir/watch.out"; capture_file="$dir/pane.txt"; window="test:fm-busy-stable" printf 'Working...' > "$capture_file" printf 'window=%s\nkind=ship\nharness=pi\n' "$window" > "$state/busy-stable.meta" + record_pi_busy "$state" busy-stable printf 'working: setup complete\n' > "$state/busy-stable.status" sig=$(seen_sig "$state/busy-stable.status"); printf '%s' "$sig" > "$state/.seen-busy-stable_status" key=$(printf '%s' "$window" | tr ':/.' '___') @@ -1166,6 +1175,7 @@ test_busy_pane_changing_hash_escalates_past_turn_age_bound() { out="$dir/watch.out"; capture_file="$dir/pane.txt"; window="test:fm-busy-ticking" printf 'Working... (3600.1s)' > "$capture_file" printf 'window=%s\nkind=ship\nharness=pi\n' "$window" > "$state/busy-ticking.meta" + record_pi_busy "$state" busy-ticking printf 'working: setup complete\n' > "$state/busy-ticking.status" sig=$(seen_sig "$state/busy-ticking.status"); printf '%s' "$sig" > "$state/.seen-busy-ticking_status" key=$(printf '%s' "$window" | tr ':/.' '___') @@ -1206,6 +1216,7 @@ test_busy_pane_turn_end_touch_resets_age() { out="$dir/watch.out"; capture_file="$dir/pane.txt"; window="test:fm-busy-reset" printf 'Working...' > "$capture_file" printf 'window=%s\nkind=ship\nharness=pi\n' "$window" > "$state/busy-reset.meta" + record_pi_busy "$state" busy-reset printf 'working: setup complete\n' > "$state/busy-reset.status" sig=$(seen_sig "$state/busy-reset.status"); printf '%s' "$sig" > "$state/.seen-busy-reset_status" key=$(printf '%s' "$window" | tr ':/.' '___') @@ -1239,6 +1250,7 @@ test_busy_pane_repeated_escalation_reaches_demand_deep_inspection() { out="$dir/watch.out"; capture_file="$dir/pane.txt"; window="test:fm-busy-demand-inspect" printf 'Working...' > "$capture_file" printf 'window=%s\nkind=ship\nharness=pi\n' "$window" > "$state/busy-demand.meta" + record_pi_busy "$state" busy-demand printf 'working: setup complete\n' > "$state/busy-demand.status" sig=$(seen_sig "$state/busy-demand.status"); printf '%s' "$sig" > "$state/.seen-busy-demand_status" key=$(printf '%s' "$window" | tr ':/.' '___') @@ -1290,6 +1302,7 @@ test_busy_pane_default_turn_age_bound_is_3600s() { out="$dir/watch.out"; capture_file="$dir/pane.txt"; window="test:fm-busy-default" printf 'Working...' > "$capture_file" printf 'window=%s\nkind=ship\nharness=pi\n' "$window" > "$state/busy-default.meta" + record_pi_busy "$state" busy-default printf 'working: setup complete\n' > "$state/busy-default.status" sig=$(seen_sig "$state/busy-default.status"); printf '%s' "$sig" > "$state/.seen-busy-default_status" key=$(printf '%s' "$window" | tr ':/.' '___') diff --git a/tests/herdr-test-safety.sh b/tests/herdr-test-safety.sh index 984acd02134..59a2bb46cc2 100644 --- a/tests/herdr-test-safety.sh +++ b/tests/herdr-test-safety.sh @@ -14,6 +14,25 @@ HERDR_TEST_SAFETY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # shellcheck source=/dev/null . "$HERDR_TEST_SAFETY_DIR/bin/fm-herdr-lab.sh" +# herdr_forget_inherited_pane: drop the Herdr PANE identity this test process +# inherited from whatever terminal it was started in. +# +# Herdr injects HERDR_ENV, HERDR_PANE_ID, HERDR_TAB_ID, HERDR_WORKSPACE_ID, +# HERDR_SOCKET_PATH, and HERDR_SESSION into every process it manages a pane for +# (verified 0.7.5 - docs/verification/runtime-backends.md), and a test run from +# inside a Herdr pane inherits all of them. Spawn now treats that pane as the +# authoritative parent to place workers next to, so a leaked identity from the +# developer's own session would follow the test into its isolated lab session +# and be refused there as a cross-session parent - a result that depends on +# where the suite was launched from, not on what it asserts. +# +# Call this before exporting the lab HERDR_SESSION in any suite whose subject is +# the per-home container path. A suite that means to exercise a launcher-bound +# spawn sets HERDR_PANE_ID itself, to a pane it created in its own lab session. +herdr_forget_inherited_pane() { + unset HERDR_ENV HERDR_PANE_ID HERDR_TAB_ID HERDR_WORKSPACE_ID HERDR_SOCKET_PATH HERDR_SESSION +} + herdr_refuse_if_default() { # <session> fm_herdr_lab_refuse_if_default "$1" } From 48c0c2d8ffcf34dde36a2b95bb8d561f477b46f9 Mon Sep 17 00:00:00 2001 From: quinnbot-ai <quinnbot.ai@gmail.com> Date: Sat, 1 Aug 2026 19:32:45 -0700 Subject: [PATCH 22/70] refactor(tests): give the launch-delivery pane one verifying owner (#73) * refactor(tests): give the launch-delivery pane one verifying owner Seventeen fake terminals across thirteen suites each carried their own copy of fm-spawn's launch-delivery protocol. Every copy answered the staged-launch check by echoing back the marker it parsed out of the submitted line, so it reported success without ever running the checksum it was meant to verify: corrupt staging would have passed unnoticed in each of them. Add fm_fake_pane_shell to tests/lib.sh as the single owner of a healthy pane's answer, and compose it into every one of those fakes. It executes the submitted check against the bytes the pane actually accumulated, so all thirteen suites now run the real verification. Each fake keeps ownership of everything else it models - its own logging, window inventory, kimi readiness state, and pane content - and a fake for another backend feeds the shared shell directly, the way the herdr fixture answers `pane run` and `pane read`. tests/fm-spawn-launch-delivery.test.sh keeps its own purpose-built fake: it owns the truncation, wrapping, and retry-injection contract, which a healthy pane deliberately does not model. tests/fm-backend-orca.test.sh already ran the real check and is left alone. Net 171 fewer lines of duplicated emulation. * ci: size the serial lane timeout to its measured wall The portable serial cap was a hang tripwire set at 20 minutes against a stale "~13 min wall" note. The lane now measures 16.1 minutes over 78 scripts on one hosted runner and 17.9 over 69 on another, while two same-revision runs exceeded 20 and were killed: runner speed moves this lane by more than a third, so a cap near the typical wall sits inside the variance and reports a healthy suite as red. Both main and every PR were red for that reason, with no failing assertion. Raise it to 40 minutes, roughly 2.5x the measured wall and the value the Herdr lane already uses, and replace the stale comment with the real measurements. Record those measurements in docs/fm-test-portable-shards.md under "Measured serial wall", with the script count and run id behind each one. The lane grows whenever a new script is neither proven-isolated nor Herdr-gated, so the next person to grow it can see the current wall and the runner spread instead of rediscovering both from a cancelled job. No test is weakened, skipped, or reassigned to another lane. --------- Co-authored-by: QuinnBot <quinnbot@proton.me> --- .github/workflows/ci.yml | 12 ++- CONTRIBUTING.md | 1 + docs/fm-test-portable-shards.md | 18 ++++- tests/fm-backend.test.sh | 41 +++------- tests/fm-busy-adapter-wiring.test.sh | 24 ++---- tests/fm-direct-lifecycle.test.sh | 24 ++---- tests/fm-gate-refuse.test.sh | 24 ++---- tests/fm-grok-harness.test.sh | 38 ++------- tests/fm-kimi-harness.test.sh | 37 ++------- tests/fm-secondmate-harness.test.sh | 51 +++--------- tests/fm-secondmate-liveness.test.sh | 24 ++---- tests/fm-session-start.test.sh | 54 ++++--------- tests/fm-spawn-dispatch-profile.test.sh | 44 ++--------- tests/fm-spawn-worktree-settle.test.sh | 24 ++---- tests/fm-tangle-guard.test.sh | 48 +++-------- tests/lib.sh | 101 ++++++++++++++++++++++++ tests/secondmate-helpers.sh | 31 ++------ 17 files changed, 226 insertions(+), 370 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56bd9860051..8a7a4685824 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,10 +107,14 @@ jobs: tests-portable-serial: name: Behavior portable serial runs-on: ubuntu-latest - # Measured serial remainder is ~13 min wall without Herdr. Cap is a hang - # tripwire above observed p99 script cost and suite wall, not the expected - # healthy end (interim 25m full-suite slack reduced after sharding). - timeout-minutes: 20 + # Measured 2026-08-02: 16.1 min wall over 78 scripts on one hosted runner + # and 17.9 min over 69 on another, while two same-revision runs exceeded 20 + # min and were killed by the previous cap. That spread is runner speed, not + # test growth, so a cap set near the typical wall lands inside the variance + # and reads as a red suite. This is a hang tripwire at roughly 2.5x the + # measured wall, matching the Herdr lane. Measurements and dates: + # docs/fm-test-portable-shards.md ("Measured serial wall"). + timeout-minutes: 40 steps: - uses: actions/checkout@v6 with: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 559f889a829..ac60b7d9ee5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,6 +45,7 @@ See the [no-mistakes quick start](https://kunchenguid.github.io/no-mistakes/star - Helper scripts in `bin/` are plain bash. Each starts with a usage header comment; keep it accurate when you change behavior. Test scripts and helpers in `tests/` are plain bash too. + A suite whose fake terminal drives a real `bin/fm-spawn.sh` must answer that script's launch-delivery protocol, so compose `tests/lib.sh`'s `fm_fake_pane_shell` into the fake rather than re-rolling the emulation per suite. `bin/fm-lint.sh` must pass: it is the single owner of the lint definition (the shellcheck file set, config, and pinned shellcheck version), and both CI and the no-mistakes pre-push gate run it, so local and CI can never diverge. It pins one exact shellcheck version and refuses to run under any other; print it with `bin/fm-lint.sh --required-version` and install that build locally. - Changes to harness adapters (detection in `bin/fm-harness.sh`, launch and hook mechanics in `bin/fm-spawn.sh`, semantic busy sources and trust gates in `bin/fm-busy-lib.sh`, delivery-only rendered guards in `bin/fm-tmux-lib.sh`, cleanup in `bin/fm-teardown.sh`, and facts in `.agents/skills/harness-adapters/SKILL.md`) must be verified empirically against the real harness, never written from documentation alone. diff --git a/docs/fm-test-portable-shards.md b/docs/fm-test-portable-shards.md index 0bfa5e6bee4..73e28782cb4 100644 --- a/docs/fm-test-portable-shards.md +++ b/docs/fm-test-portable-shards.md @@ -52,6 +52,21 @@ The two parallel lanes use longest-processing-time assignment from those measure `portable-serial` includes every `tests/*.test.sh` that is neither proven-isolated nor `real-herdr-gated`. It keeps watcher, lock, AFK, real tmux, daemon, secondmate lifecycle, bootstrap, live-harness opt-in, GUI-backend, and other unproven work serial. +### Measured serial wall + +This lane grows whenever a new script is neither proven-isolated nor Herdr-gated, so record a fresh measurement here when you add to it. +Each row is the lane's own `FM_TEST_SUMMARY duration_ms` from that run's uploaded timing artifact. + +| Date | Scripts | Wall | Run | +|---|---:|---:|---| +| 2026-07-30 | 69 | 17.9 min | 30584117685 (green) | +| 2026-08-01 | 78 | 16.1 min | 30723357472 (green) | +| 2026-08-01 | 78 | over 20 min | 30723993373 (killed by the then-current 20 min cap) | +| 2026-08-02 | 78 | over 20 min | 30727407628 (killed by the then-current 20 min cap) | + +The same 78 scripts measured 16.1 minutes on one hosted runner and exceeded 20 on two others, so runner speed moves this lane by more than a third. +Read the wall as a distribution rather than a number, and keep the timeout a multiple of it rather than a margin above the typical case. + ## Coverage guard `bin/fm-test-run.sh --check-coverage` verifies that both parallel lanes partition the proven-isolated set. @@ -73,7 +88,8 @@ Portable shards, the portable serial lane, and the Herdr lane upload runner-gene | Job | timeout-minutes | Rationale | |---|---:|---| | portable parallel 1/2 | 10 | The measured shard sums are about three minutes and the timeout is a hang tripwire. | -| portable serial | 20 | The serial remainder needs a larger hang tripwire. | +| portable serial | 40 | Roughly 2.5x the measured wall above, chosen so ordinary runner variance cannot reach it. | | Herdr | 40 | The real-Herdr lane keeps its dedicated timeout. | Timeouts are hang tripwires rather than expected healthy durations. +A cap set close to a lane's typical wall converts runner variance into a red suite, which is what the previous 20 minute serial cap did on 2026-08-01 and 2026-08-02. diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 5657d8fd202..2188090dd81 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -796,31 +796,21 @@ make_spawn_fakebin() { # <dir> <fake-worktree-path> -> echoes fakebin dir cat > "$fb/tmux" <<SH #!/usr/bin/env bash set -u +# shellcheck source=/dev/null +. "\$(dirname "\$0")/pane-shell.sh" { printf 'tmux'; for a in "\$@"; do printf '\\x1f%s' "\$a"; done; printf '\\n'; } >> "\${FM_TMUX_LOG:?}" -screen="\${FM_TMUX_LOG:?}.spawn-screen" case "\${1:-}" in display-message) for a in "\$@"; do case "\$a" in *pane_current_path*) printf '%s\\n' "$wt"; exit 0 ;; esac; done printf 'firstmate\\n'; exit 0 ;; list-windows) exit 0 ;; - capture-pane) cat "\$screen" 2>/dev/null || true; exit 0 ;; - send-keys) - text=\${4:-} - case "\$text" in - *"__FM_SPAWN_READY_"*) - token=\$(printf '%s\\n' "\$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\\([^']*\\)'.*/\\1/p") - [ -z "\$token" ] || printf '__FM_SPAWN_READY_%s\\n' "\$token" > "\$screen" - ;; - *"__FM_SPAWN_LAUNCH_OK_"*) - token=\$(printf '%s\\n' "\$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\\([^']*\\)'.*/\\1/p") - [ -z "\$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\\n' "\$token" > "\$screen" - ;; - esac - exit 0 ;; + capture-pane) fm_fake_pane_capture; exit 0 ;; + send-keys) fm_fake_pane_send "\$@"; exit 0 ;; esac exit 0 SH chmod +x "$fb/tmux" + fm_fake_pane_shell "$fb" fm_fake_exit0 "$fb" treehouse printf '%s\n' "$fb" } @@ -873,8 +863,9 @@ make_spawn_symlink_fakebin() { # <dir> <initial-project-path> <worktree-path> - cat > "$fb/tmux" <<SH #!/usr/bin/env bash set -u +# shellcheck source=/dev/null +. "\$(dirname "\$0")/pane-shell.sh" { printf 'tmux'; for a in "\$@"; do printf '\\x1f%s' "\$a"; done; printf '\\n'; } >> "\${FM_TMUX_LOG:?}" -screen="\${FM_TMUX_LOG:?}.spawn-screen" case "\${1:-}" in display-message) for a in "\$@"; do case "\$a" in *pane_current_path*) @@ -888,24 +879,13 @@ case "\${1:-}" in ;; esac; done printf 'firstmate\\n'; exit 0 ;; list-windows) exit 0 ;; - capture-pane) cat "\$screen" 2>/dev/null || true; exit 0 ;; - send-keys) - text=\${4:-} - case "\$text" in - *"__FM_SPAWN_READY_"*) - token=\$(printf '%s\\n' "\$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\\([^']*\\)'.*/\\1/p") - [ -z "\$token" ] || printf '__FM_SPAWN_READY_%s\\n' "\$token" > "\$screen" - ;; - *"__FM_SPAWN_LAUNCH_OK_"*) - token=\$(printf '%s\\n' "\$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\\([^']*\\)'.*/\\1/p") - [ -z "\$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\\n' "\$token" > "\$screen" - ;; - esac - exit 0 ;; + capture-pane) fm_fake_pane_capture; exit 0 ;; + send-keys) fm_fake_pane_send "\$@"; exit 0 ;; esac exit 0 SH chmod +x "$fb/tmux" + fm_fake_pane_shell "$fb" fm_fake_exit0 "$fb" treehouse printf '%s\n' "$fb" } @@ -971,6 +951,7 @@ set -u exit 0 SH chmod +x "$fb/tmux" "$fb/treehouse" + fm_fake_pane_shell "$fb" printf '%s\n' "$fb" } diff --git a/tests/fm-busy-adapter-wiring.test.sh b/tests/fm-busy-adapter-wiring.test.sh index 53894e3a9c8..b20010826c4 100755 --- a/tests/fm-busy-adapter-wiring.test.sh +++ b/tests/fm-busy-adapter-wiring.test.sh @@ -24,6 +24,8 @@ make_spawn_fakebin() { cat > "$fakebin/tmux" <<'SH' #!/usr/bin/env bash set -u +# shellcheck source=/dev/null +. "$(dirname "$0")/pane-shell.sh" case "$*" in *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; esac @@ -31,32 +33,16 @@ case "${1:-}" in display-message) printf 'firstmate\n'; exit 0 ;; list-windows) exit 0 ;; has-session|new-session|new-window|kill-window) exit 0 ;; - capture-pane) cat "$(dirname "$0")/.spawn-screen" 2>/dev/null || true; exit 0 ;; + capture-pane) fm_fake_pane_capture; exit 0 ;; send-keys) - screen="$(dirname "$0")/.spawn-screen" - staged="$screen.staged" - text=${4:-} - case "$text" in - *"__FM_SPAWN_READY_"*) - token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") - [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$screen" - ;; - "FM_SPAWN_LAUNCH=''") : > "$staged" ;; - FM_SPAWN_LAUNCH=*) - rebuilt=$(FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") - printf '%s' "$rebuilt" > "$staged" - ;; - *"__FM_SPAWN_LAUNCH_OK_"*) - token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\([^']*\)'.*/\1/p") - [ -z "$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\n' "$token" > "$screen" - ;; - esac + fm_fake_pane_send "$@" exit 0 ;; esac exit 0 SH chmod +x "$fakebin/tmux" + fm_fake_pane_shell "$fakebin" fm_fake_exit0 "$fakebin" treehouse pi opencode claude codex printf '%s\n' "$fakebin" } diff --git a/tests/fm-direct-lifecycle.test.sh b/tests/fm-direct-lifecycle.test.sh index d1f175affa6..fb2aa3f71fc 100755 --- a/tests/fm-direct-lifecycle.test.sh +++ b/tests/fm-direct-lifecycle.test.sh @@ -135,6 +135,8 @@ SH cat > "$FAKEBIN/tmux" <<'SH' #!/usr/bin/env bash set -u +# shellcheck source=/dev/null +. "$(dirname "$0")/pane-shell.sh" printf '%s\n' "$*" >> "$FM_TEST_TMUX_LOG" if [ "${1:-}" = display-message ]; then case "$*" in @@ -169,28 +171,11 @@ case "${1:-}" in exit 0 ;; capture-pane) - cat "$FM_TEST_TMUX_LOG.spawn-screen" 2>/dev/null || true + fm_fake_pane_capture exit 0 ;; send-keys) - screen="$FM_TEST_TMUX_LOG.spawn-screen" - staged="$screen.staged" - text=${4:-} - case "$text" in - *"__FM_SPAWN_READY_"*) - token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") - [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$screen" - ;; - "FM_SPAWN_LAUNCH=''") : > "$staged" ;; - FM_SPAWN_LAUNCH=*) - rebuilt=$(FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") - printf '%s' "$rebuilt" > "$staged" - ;; - *"__FM_SPAWN_LAUNCH_OK_"*) - token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\([^']*\)'.*/\1/p") - [ -z "$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\n' "$token" > "$screen" - ;; - esac + fm_fake_pane_send "$@" exit 0 ;; esac @@ -198,6 +183,7 @@ exit 0 SH chmod +x "$FAKEBIN/gh-axi" "$FAKEBIN/gh" "$FAKEBIN/treehouse" "$FAKEBIN/tmux" +fm_fake_pane_shell "$FAKEBIN" fixture_cmd() { FM_ROOT_OVERRIDE="$ROOT" \ diff --git a/tests/fm-gate-refuse.test.sh b/tests/fm-gate-refuse.test.sh index a1b8292786d..0eb076c26e7 100755 --- a/tests/fm-gate-refuse.test.sh +++ b/tests/fm-gate-refuse.test.sh @@ -141,6 +141,8 @@ make_spawn_fakebin() { cat > "$fakebin/tmux" <<'SH' #!/usr/bin/env bash set -u +# shellcheck source=/dev/null +. "$(dirname "$0")/pane-shell.sh" case "$*" in *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; esac @@ -148,32 +150,16 @@ case "${1:-}" in display-message) printf 'firstmate\n'; exit 0 ;; list-windows) exit 0 ;; has-session|new-session|new-window|set-window-option) exit 0 ;; - capture-pane) cat "$(dirname "$0")/.spawn-screen" 2>/dev/null || true; exit 0 ;; + capture-pane) fm_fake_pane_capture; exit 0 ;; send-keys) - screen="$(dirname "$0")/.spawn-screen" - staged="$screen.staged" - text=${4:-} - case "$text" in - *"__FM_SPAWN_READY_"*) - token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") - [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$screen" - ;; - "FM_SPAWN_LAUNCH=''") : > "$staged" ;; - FM_SPAWN_LAUNCH=*) - rebuilt=$(FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") - printf '%s' "$rebuilt" > "$staged" - ;; - *"__FM_SPAWN_LAUNCH_OK_"*) - token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\([^']*\)'.*/\1/p") - [ -z "$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\n' "$token" > "$screen" - ;; - esac + fm_fake_pane_send "$@" exit 0 ;; esac exit 0 SH chmod +x "$fakebin/tmux" + fm_fake_pane_shell "$fakebin" fm_fake_exit0 "$fakebin" treehouse printf '%s\n' "$fakebin" } diff --git a/tests/fm-grok-harness.test.sh b/tests/fm-grok-harness.test.sh index 919bb51e605..3779fa9995b 100755 --- a/tests/fm-grok-harness.test.sh +++ b/tests/fm-grok-harness.test.sh @@ -15,8 +15,8 @@ make_spawn_fakebin() { cat > "$fakebin/tmux" <<'SH' #!/usr/bin/env bash set -u -screen=${FM_FAKE_SPAWN_SCREEN:?} -staged_launch=${FM_FAKE_STAGED_LAUNCH:?} +# shellcheck source=/dev/null +. "$(dirname "$0")/pane-shell.sh" case "$*" in *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; esac @@ -24,37 +24,16 @@ case "${1:-}" in display-message) printf 'firstmate\n'; exit 0 ;; list-windows) exit 0 ;; has-session|new-session|new-window|kill-window) exit 0 ;; - capture-pane) cat "$screen"; exit 0 ;; + capture-pane) fm_fake_pane_capture; exit 0 ;; send-keys) - text=${4:-} - case "$text" in - *"__FM_SPAWN_READY_"*) - token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") - [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$screen" - exit 0 - ;; - "FM_SPAWN_LAUNCH=''") - : > "$staged_launch" - exit 0 - ;; - FM_SPAWN_LAUNCH=*) - staged=$(FM_SPAWN_LAUNCH="$(cat "$staged_launch")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") - printf '%s' "$staged" > "$staged_launch" - exit 0 - ;; - *"__FM_SPAWN_LAUNCH_OK_"*) - token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\([^']*\)'.*/\1/p") - [ -z "$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\n' "$token" > "$screen" - exit 0 - ;; - 'eval "$FM_SPAWN_LAUNCH"') exit 0 ;; - esac + fm_fake_pane_send "$@" exit 0 ;; esac exit 0 SH chmod +x "$fakebin/tmux" + fm_fake_pane_shell "$fakebin" fm_fake_exit0 "$fakebin" treehouse gh-axi gh printf '%s\n' "$fakebin" } @@ -76,16 +55,11 @@ make_spawn_case() { } run_grok_spawn() { - local home=$1 proj=$2 wt=$3 fakebin=$4 grok_home=$5 id=$6 screen staged_launch - screen="$home/state/$id.spawn.screen" - staged_launch="$home/state/$id.staged-launch" - : > "$screen" - : > "$staged_launch" + local home=$1 proj=$2 wt=$3 fakebin=$4 grok_home=$5 id=$6 FM_ROOT_OVERRIDE='' FM_HOME="$home" \ FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \ FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$wt" TMUX="fake,1,0" \ - FM_FAKE_SPAWN_SCREEN="$screen" FM_FAKE_STAGED_LAUNCH="$staged_launch" \ GROK_HOME="$grok_home" PATH="$fakebin:$PATH" \ "$SPAWN" "$id" "$proj" grok 2>&1 } diff --git a/tests/fm-kimi-harness.test.sh b/tests/fm-kimi-harness.test.sh index 3a04394586c..f4a713ee24d 100755 --- a/tests/fm-kimi-harness.test.sh +++ b/tests/fm-kimi-harness.test.sh @@ -27,10 +27,10 @@ make_spawn_fakebin() { cat > "$fakebin/tmux" <<'SH' #!/usr/bin/env bash set -u +# shellcheck source=/dev/null +. "$(dirname "$0")/pane-shell.sh" printf '%s\n' "$*" >> "$FM_FAKE_TMUX_CALL_LOG" state=$(cat "$FM_FAKE_KIMI_STATE" 2>/dev/null || true) -spawn_screen=${FM_FAKE_SPAWN_SCREEN:-"${FM_FAKE_LAUNCH_LOG}.screen"} -staged_launch=${FM_FAKE_STAGED_LAUNCH:-"${FM_FAKE_LAUNCH_LOG}.staged"} fake_screen() { case "$state" in ready) @@ -82,30 +82,11 @@ case "${1:-}" in esac exit 0 fi - text=${4:-} - case "$text" in - *"__FM_SPAWN_READY_"*) - token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") - [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$spawn_screen" - exit 0 - ;; - "FM_SPAWN_LAUNCH=''" ) - : > "$staged_launch" - exit 0 - ;; - FM_SPAWN_LAUNCH=*) - staged=$(FM_SPAWN_LAUNCH="$(cat "$staged_launch")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") - printf '%s' "$staged" > "$staged_launch" - exit 0 - ;; - *"__FM_SPAWN_LAUNCH_OK_"*) - token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\([^']*\)'.*/\1/p") - [ -z "$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\n' "$token" > "$spawn_screen" - exit 0 - ;; + fm_fake_pane_send "$@" + # The shared pane shell owns the launch-delivery protocol and the launch + # log; this fake still owns kimi's own post-launch readiness state. + case "${4:-}" in 'eval "$FM_SPAWN_LAUNCH"') - cat "$staged_launch" >> "$FM_FAKE_LAUNCH_LOG" - printf '\n' >> "$FM_FAKE_LAUNCH_LOG" if [ "${FM_FAKE_KIMI_READY:-yes}" = yes ]; then printf 'ready\n' > "$FM_FAKE_KIMI_STATE" else @@ -147,7 +128,7 @@ case "${1:-}" in case "$arg" in -S|-E) prev=$arg ;; *) prev= ;; esac done { - cat "$spawn_screen" 2>/dev/null || true + fm_fake_pane_capture 2>/dev/null || true fake_screen } | case "$start:$end" in *[!0-9:]*|'':*|*:'') cat ;; @@ -160,6 +141,7 @@ esac exit 0 SH chmod +x "$fakebin/tmux" + fm_fake_pane_shell "$fakebin" fm_fake_exit0 "$fakebin" treehouse gh-axi gh fm_fake_exit0 "$fakebin" kimi ln -s "$JQ_BIN" "$fakebin/jq" @@ -181,8 +163,6 @@ make_spawn_case() { touch "$home/state/.last-watcher-beat" : > "$case_dir/launch.log" : > "$case_dir/pointer.log" - : > "$case_dir/launch.log.screen" - : > "$case_dir/launch.log.staged" : > "$case_dir/kimi.state" : > "$case_dir/tmux-calls.log" printf '%s\n' "$case_dir|$home|$proj|$wt|$fakebin" @@ -202,7 +182,6 @@ run_spawn() { FM_FAKE_KIMI_SWALLOW_FIRST="${FM_FAKE_KIMI_SWALLOW_FIRST:-no}" \ FM_FAKE_TMUX_CALL_LOG="$case_dir/tmux-calls.log" \ FM_FAKE_BRIEF_REAL="$(cd "$home/data/$id" && pwd -P)/brief.md" \ - FM_FAKE_SPAWN_SCREEN="$case_dir/launch.log.screen" FM_FAKE_STAGED_LAUNCH="$case_dir/launch.log.staged" \ FM_KIMI_READY_POLLS=2 FM_KIMI_DELIVERY_POLLS=2 FM_KIMI_POLL_INTERVAL=0 \ PATH="$fakebin:$BASE_PATH" \ "$SPAWN" "$id" "$proj" --harness kimi "$@" 2>&1 diff --git a/tests/fm-secondmate-harness.test.sh b/tests/fm-secondmate-harness.test.sh index 81868a160e0..65e740dd6be 100755 --- a/tests/fm-secondmate-harness.test.sh +++ b/tests/fm-secondmate-harness.test.sh @@ -395,33 +395,19 @@ make_noop_tmux() { cat > "$fakebin/tmux" <<'SH' #!/usr/bin/env bash set -u +# shellcheck source=/dev/null +. "$(dirname "$0")/pane-shell.sh" case "${1:-}" in - capture-pane) cat "$(dirname "$0")/.spawn-screen" 2>/dev/null || true; exit 0 ;; + capture-pane) fm_fake_pane_capture; exit 0 ;; send-keys) - screen="$(dirname "$0")/.spawn-screen" - staged="$screen.staged" - text=${4:-} - case "$text" in - *"__FM_SPAWN_READY_"*) - token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") - [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$screen" - ;; - "FM_SPAWN_LAUNCH=''") : > "$staged" ;; - FM_SPAWN_LAUNCH=*) - rebuilt=$(FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") - printf '%s' "$rebuilt" > "$staged" - ;; - *"__FM_SPAWN_LAUNCH_OK_"*) - token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\([^']*\)'.*/\1/p") - [ -z "$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\n' "$token" > "$screen" - ;; - esac + fm_fake_pane_send "$@" exit 0 ;; esac exit 0 SH chmod +x "$fakebin/tmux" + fm_fake_pane_shell "$fakebin" printf '%s\n' "$fakebin" } @@ -598,6 +584,8 @@ make_launch_capturing_tmux() { cat > "$fakebin/tmux" <<'SH' #!/usr/bin/env bash set -u +# shellcheck source=/dev/null +. "$(dirname "$0")/pane-shell.sh" case "$*" in *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; esac @@ -605,35 +593,16 @@ case "${1:-}" in display-message) printf 'firstmate\n'; exit 0 ;; list-windows) exit 0 ;; has-session|new-session|new-window|kill-window) exit 0 ;; - capture-pane) cat "$(dirname "$0")/.spawn-screen" 2>/dev/null || true; exit 0 ;; + capture-pane) fm_fake_pane_capture; exit 0 ;; send-keys) - screen="$(dirname "$0")/.spawn-screen" - staged="$screen.staged" - text=${4:-} - case "$text" in - *"__FM_SPAWN_READY_"*) - token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") - [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$screen" - ;; - "FM_SPAWN_LAUNCH=''") : > "$staged" ;; - FM_SPAWN_LAUNCH=*) - rebuilt=$(FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") - printf '%s' "$rebuilt" > "$staged" - ;; - *"__FM_SPAWN_LAUNCH_OK_"*) - token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\([^']*\)'.*/\1/p") - [ -z "$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\n' "$token" > "$screen" - ;; - 'eval "$FM_SPAWN_LAUNCH"') - [ -z "${FM_FAKE_LAUNCH_LOG:-}" ] || cat "$staged" >> "$FM_FAKE_LAUNCH_LOG" - ;; - esac + fm_fake_pane_send "$@" exit 0 ;; esac exit 0 SH chmod +x "$fakebin/tmux" + fm_fake_pane_shell "$fakebin" printf '%s\n' "$fakebin" } diff --git a/tests/fm-secondmate-liveness.test.sh b/tests/fm-secondmate-liveness.test.sh index 36a2e0c944a..9d26c671bda 100755 --- a/tests/fm-secondmate-liveness.test.sh +++ b/tests/fm-secondmate-liveness.test.sh @@ -256,6 +256,8 @@ make_liveness_tmux() { cat > "$fakebin/tmux" <<'SH' #!/usr/bin/env bash set -u +# shellcheck source=/dev/null +. "$(dirname "$0")/pane-shell.sh" mode=${FM_TEST_PANE_CMD:-zsh} case "${1:-}" in display-message) @@ -287,28 +289,11 @@ case "${1:-}" in exit 0 ;; capture-pane) - cat "${FM_TMUX_CALL_LOG:?}.spawn-screen" 2>/dev/null || true + fm_fake_pane_capture exit 0 ;; send-keys) - screen="${FM_TMUX_CALL_LOG:?}.spawn-screen" - staged="$screen.staged" - text=${4:-} - case "$text" in - *"__FM_SPAWN_READY_"*) - token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") - [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$screen" - ;; - "FM_SPAWN_LAUNCH=''") : > "$staged" ;; - FM_SPAWN_LAUNCH=*) - rebuilt=$(FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") - printf '%s' "$rebuilt" > "$staged" - ;; - *"__FM_SPAWN_LAUNCH_OK_"*) - token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\([^']*\)'.*/\1/p") - [ -z "$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\n' "$token" > "$screen" - ;; - esac + fm_fake_pane_send "$@" exit 0 ;; has-session) exit 0 ;; @@ -316,6 +301,7 @@ esac exit 0 SH chmod +x "$fakebin/tmux" + fm_fake_pane_shell "$fakebin" printf '%s\n' "$fakebin" } diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index f1f74cb1603..9346967e59a 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -231,6 +231,8 @@ make_fake_tmux_secondmate_recovery() { cat > "$fakebin/tmux" <<'SH' #!/usr/bin/env bash set -u +# shellcheck source=/dev/null +. "$(dirname "$0")/pane-shell.sh" mode=${FM_FAKE_TMUX_MODE:?} log=${FM_FAKE_TMUX_LOG:?} spawned=${FM_FAKE_TMUX_SPAWNED:?} @@ -306,34 +308,18 @@ case "${1:-}" in ;; set-window-option) exit 0 ;; capture-pane) - cat "$spawned.spawn-screen" 2>/dev/null || true + fm_fake_pane_capture exit 0 ;; send-keys) - screen="$spawned.spawn-screen" - staged="$screen.staged" - text=${4:-} - case "$text" in - *"__FM_SPAWN_READY_"*) - token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") - [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$screen" - ;; - "FM_SPAWN_LAUNCH=''") : > "$staged" ;; - FM_SPAWN_LAUNCH=*) - rebuilt=$(FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") - printf '%s' "$rebuilt" > "$staged" - ;; - *"__FM_SPAWN_LAUNCH_OK_"*) - token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\([^']*\)'.*/\1/p") - [ -z "$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\n' "$token" > "$screen" - ;; - esac + fm_fake_pane_send "$@" exit 0 ;; esac exit 0 SH chmod +x "$fakebin/tmux" + fm_fake_pane_shell "$fakebin" } make_fake_herdr_secondmate_recovery() { @@ -344,13 +330,13 @@ make_fake_herdr_secondmate_recovery() { cat > "$fakebin/herdr" <<'SH' #!/usr/bin/env bash set -u +# shellcheck source=/dev/null +. "$(dirname "$0")/pane-shell.sh" log=${FM_FAKE_HERDR_LOG:?} state=${FM_FAKE_HERDR_STATE:?} mate_id=${FM_FAKE_SECOND_MATE_ID:?} killed="${state}.killed" spawned="${state}.spawned" -screen="${state}.spawn-screen" -staged="${screen}.staged" printf '%s\n' "$*" >> "$log" case "${1:-} ${2:-}" in "status --json") @@ -407,27 +393,14 @@ case "${1:-} ${2:-}" in [ "${3:-}" = p-old ] && : > "$killed" ;; "pane read") - cat "$screen" 2>/dev/null || true + fm_fake_pane_capture ;; - "pane run"|"pane send-text") - text=${4:-} - case "$text" in - *"__FM_SPAWN_READY_"*) - token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") - [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$screen" - ;; - "FM_SPAWN_LAUNCH=''") : > "$staged" ;; - FM_SPAWN_LAUNCH=*) - rebuilt=$(FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") - printf '%s' "$rebuilt" > "$staged" - ;; - *"__FM_SPAWN_LAUNCH_OK_"*) - token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\([^']*\)'.*/\1/p") - [ -z "$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\n' "$token" > "$screen" - ;; - esac + "pane run") + # herdr submits the whole line in one call, so feed the pane shell directly + # rather than through the tmux send-keys argv parser. + fm_fake_pane_line "${4:-}" ;; - "pane send-keys"|"tab close") + "pane send-text"|"pane send-keys"|"tab close") ;; *) exit 1 @@ -436,6 +409,7 @@ esac exit 0 SH chmod +x "$fakebin/herdr" + fm_fake_pane_shell "$fakebin" } # make_fake_herdr <fakebin> <live-pane>: `herdr pane get <pane>` succeeds only diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index ca414e0c225..335e265b2e8 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -19,8 +19,8 @@ make_spawn_fakebin() { cat > "$fakebin/tmux" <<'SH' #!/usr/bin/env bash set -u -screen=${FM_FAKE_SPAWN_SCREEN:-"${FM_FAKE_LAUNCH_LOG:?}.screen"} -staged_launch=${FM_FAKE_STAGED_LAUNCH:-"${FM_FAKE_LAUNCH_LOG:?}.staged"} +# shellcheck source=/dev/null +. "$(dirname "$0")/pane-shell.sh" case "$*" in *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; esac @@ -28,35 +28,9 @@ case "${1:-}" in display-message) printf 'firstmate\n'; exit 0 ;; list-windows) exit 0 ;; has-session|new-session|new-window|kill-window) exit 0 ;; - capture-pane) cat "$screen"; exit 0 ;; + capture-pane) fm_fake_pane_capture; exit 0 ;; send-keys) - text=${4:-} - case "$text" in - *"__FM_SPAWN_READY_"*) - token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") - [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$screen" - exit 0 - ;; - "FM_SPAWN_LAUNCH=''" ) - : > "$staged_launch" - exit 0 - ;; - FM_SPAWN_LAUNCH=*) - staged=$(FM_SPAWN_LAUNCH="$(cat "$staged_launch")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") - printf '%s' "$staged" > "$staged_launch" - exit 0 - ;; - *"__FM_SPAWN_LAUNCH_OK_"*) - token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\([^']*\)'.*/\1/p") - [ -z "$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\n' "$token" > "$screen" - exit 0 - ;; - 'eval "$FM_SPAWN_LAUNCH"') - cat "$staged_launch" >> "$FM_FAKE_LAUNCH_LOG" - printf '\n' >> "$FM_FAKE_LAUNCH_LOG" - exit 0 - ;; - esac + fm_fake_pane_send "$@" if [ -n "${FM_FAKE_LAUNCH_LOG:-}" ]; then prev= for a in "$@"; do @@ -72,6 +46,7 @@ esac exit 0 SH chmod +x "$fakebin/tmux" + fm_fake_pane_shell "$fakebin" fm_fake_exit0 "$fakebin" treehouse pi-signed printf '%s\n' "$fakebin" } @@ -114,8 +89,6 @@ run_spawn() { local home=$1 wt=$2 fakebin=$3 launchlog=$4 shift 4 : > "$launchlog" - : > "$launchlog.screen" - : > "$launchlog.staged" # CLAUDE_CONFIG_DIR is forwarded onto claude launches by fm-spawn, so pin it # explicitly (empty by default) instead of leaking the invoking shell's value, # which would make launch assertions depend on the developer's environment. @@ -126,7 +99,6 @@ run_spawn() { FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$wt" TMUX="fake,1,0" \ CLAUDE_CONFIG_DIR="${FM_TEST_CLAUDE_CONFIG_DIR:-}" \ FM_FAKE_LAUNCH_LOG="$launchlog" GROK_HOME="$home/grok-home" PATH="$fakebin:$PATH" \ - FM_FAKE_SPAWN_SCREEN="$CASE_DIR/spawn.screen" FM_FAKE_STAGED_LAUNCH="$CASE_DIR/staged-launch" \ "$SPAWN" "$@" 2>&1 } @@ -177,8 +149,7 @@ test_relative_home_overrides_launch_with_absolute_cross_process_paths() { FM_PROJECTS_OVERRIDE=home/projects FM_CONFIG_OVERRIDE=home/config \ FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$WT_DIR" TMUX="fake,1,0" \ CLAUDE_CONFIG_DIR='' FM_FAKE_LAUNCH_LOG="$LAUNCH_LOG" \ - FM_FAKE_SPAWN_SCREEN="$CASE_DIR/spawn.screen" FM_FAKE_STAGED_LAUNCH="$CASE_DIR/staged-launch" \ - GROK_HOME=home/grok-home PATH="$FAKEBIN_DIR:$PATH" \ + GROK_HOME=home/grok-home PATH="$FAKEBIN_DIR:$PATH" \ "$SPAWN" "$id" "$PROJ_DIR" 2>&1 ) status=$? @@ -207,8 +178,7 @@ test_home_defaults_preserve_absolute_or_resolve_relative_paths() { FM_PROJECTS_OVERRIDE=home/projects FM_CONFIG_OVERRIDE=home/config \ FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$WT_DIR" TMUX="fake,1,0" \ CLAUDE_CONFIG_DIR='' FM_FAKE_LAUNCH_LOG="$LAUNCH_LOG" \ - FM_FAKE_SPAWN_SCREEN="$CASE_DIR/spawn.screen" FM_FAKE_STAGED_LAUNCH="$CASE_DIR/staged-launch" \ - GROK_HOME=home/grok-home PATH="$FAKEBIN_DIR:$PATH" \ + GROK_HOME=home/grok-home PATH="$FAKEBIN_DIR:$PATH" \ "$SPAWN" "$relative_id" "$PROJ_DIR" 2>&1 ) status=$? diff --git a/tests/fm-spawn-worktree-settle.test.sh b/tests/fm-spawn-worktree-settle.test.sh index 78eab34cea5..2569c16fcb2 100755 --- a/tests/fm-spawn-worktree-settle.test.sh +++ b/tests/fm-spawn-worktree-settle.test.sh @@ -30,6 +30,8 @@ make_settle_fakebin() { cat > "$fakebin/tmux" <<'SH' #!/usr/bin/env bash set -u +# shellcheck source=/dev/null +. "$(dirname "$0")/pane-shell.sh" case "$*" in *"#{pane_current_path}"*) countfile="${FM_FAKE_PANE_COUNTFILE:?FM_FAKE_PANE_COUNTFILE unset}" @@ -49,32 +51,16 @@ case "${1:-}" in display-message) printf 'firstmate\n'; exit 0 ;; list-windows) exit 0 ;; has-session|new-session|new-window|kill-window) exit 0 ;; - capture-pane) cat "$(dirname "$0")/.spawn-screen" 2>/dev/null || true; exit 0 ;; + capture-pane) fm_fake_pane_capture; exit 0 ;; send-keys) - screen="$(dirname "$0")/.spawn-screen" - staged="$screen.staged" - text=${4:-} - case "$text" in - *"__FM_SPAWN_READY_"*) - token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") - [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$screen" - ;; - "FM_SPAWN_LAUNCH=''") : > "$staged" ;; - FM_SPAWN_LAUNCH=*) - rebuilt=$(FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") - printf '%s' "$rebuilt" > "$staged" - ;; - *"__FM_SPAWN_LAUNCH_OK_"*) - token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\([^']*\)'.*/\1/p") - [ -z "$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\n' "$token" > "$screen" - ;; - esac + fm_fake_pane_send "$@" exit 0 ;; esac exit 0 SH chmod +x "$fakebin/tmux" + fm_fake_pane_shell "$fakebin" fm_fake_exit0 "$fakebin" treehouse printf '%s\n' "$fakebin" } diff --git a/tests/fm-tangle-guard.test.sh b/tests/fm-tangle-guard.test.sh index 1ef46742ab9..9a87f3711d2 100755 --- a/tests/fm-tangle-guard.test.sh +++ b/tests/fm-tangle-guard.test.sh @@ -158,6 +158,8 @@ make_spawn_fakebin() { cat > "$fakebin/tmux" <<'SH' #!/usr/bin/env bash set -u +# shellcheck source=/dev/null +. "$(dirname "$0")/pane-shell.sh" case "$*" in *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; esac @@ -165,32 +167,16 @@ case "${1:-}" in display-message) printf 'firstmate\n'; exit 0 ;; list-windows) exit 0 ;; has-session|new-session|new-window) exit 0 ;; - capture-pane) cat "$(dirname "$0")/.spawn-screen" 2>/dev/null || true; exit 0 ;; + capture-pane) fm_fake_pane_capture; exit 0 ;; send-keys) - screen="$(dirname "$0")/.spawn-screen" - staged="$screen.staged" - text=${4:-} - case "$text" in - *"__FM_SPAWN_READY_"*) - token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") - [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$screen" - ;; - "FM_SPAWN_LAUNCH=''") : > "$staged" ;; - FM_SPAWN_LAUNCH=*) - rebuilt=$(FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") - printf '%s' "$rebuilt" > "$staged" - ;; - *"__FM_SPAWN_LAUNCH_OK_"*) - token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\([^']*\)'.*/\1/p") - [ -z "$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\n' "$token" > "$screen" - ;; - esac + fm_fake_pane_send "$@" exit 0 ;; esac exit 0 SH chmod +x "$fakebin/tmux" + fm_fake_pane_shell "$fakebin" fm_fake_exit0 "$fakebin" treehouse printf '%s\n' "$fakebin" } @@ -257,6 +243,8 @@ make_spawn_record_fakebin() { cat > "$fakebin/tmux" <<'SH' #!/usr/bin/env bash set -u +# shellcheck source=/dev/null +. "$(dirname "$0")/pane-shell.sh" [ -n "${FM_TMUX_REC:-}" ] && printf 'tmux %s\n' "$*" >> "$FM_TMUX_REC" case "$*" in *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; @@ -266,32 +254,16 @@ case "${1:-}" in new-window) printf '%s\n' "@spawnwid"; exit 0 ;; list-windows) exit 0 ;; has-session|new-session|set-window-option) exit 0 ;; - capture-pane) cat "$(dirname "$0")/.spawn-screen" 2>/dev/null || true; exit 0 ;; + capture-pane) fm_fake_pane_capture; exit 0 ;; send-keys) - screen="$(dirname "$0")/.spawn-screen" - staged="$screen.staged" - text=${4:-} - case "$text" in - *"__FM_SPAWN_READY_"*) - token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") - [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$screen" - ;; - "FM_SPAWN_LAUNCH=''") : > "$staged" ;; - FM_SPAWN_LAUNCH=*) - rebuilt=$(FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") - printf '%s' "$rebuilt" > "$staged" - ;; - *"__FM_SPAWN_LAUNCH_OK_"*) - token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\([^']*\)'.*/\1/p") - [ -z "$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\n' "$token" > "$screen" - ;; - esac + fm_fake_pane_send "$@" exit 0 ;; esac exit 0 SH chmod +x "$fakebin/tmux" + fm_fake_pane_shell "$fakebin" fm_fake_exit0 "$fakebin" treehouse printf '%s\n' "$fakebin" } diff --git a/tests/lib.sh b/tests/lib.sh index 5f0ecdc4a62..267fa6284b2 100644 --- a/tests/lib.sh +++ b/tests/lib.sh @@ -108,6 +108,107 @@ SH done } +# --- fm-spawn launch-delivery pane shell ------------------------------------ +# +# fm-spawn refuses to report a spawn until the target pane proves it executed a +# probe and rebuilt the complete launch command; bin/fm-spawn.sh's header owns +# that launch-delivery contract. Every suite whose fake terminal drives a real +# spawn therefore needs a pane that behaves like a shell. +# +# fm_fake_pane_shell <fakebin> writes <fakebin>/pane-shell.sh, a helper the +# suite's own fake sources to answer exactly that protocol and nothing else; the +# fake stays the owner of every other subcommand: +# +# . "$(dirname "$0")/pane-shell.sh" +# case "${1:-}" in +# capture-pane) fm_fake_pane_capture; exit 0 ;; +# send-keys) fm_fake_pane_send "$@"; exit 0 ;; +# esac +# +# One shared owner matters here beyond deduplication: a per-suite copy answers +# the staged-launch check by echoing the marker it parsed out of the submitted +# line, so it reports success even when the staging it was meant to verify is +# corrupt. This helper instead executes the submitted checksum against what the +# pane actually accumulated, so every suite runs the real verification. +# +# fm_fake_pane_send parses tmux's send-keys argv; a fake for another backend +# passes the submitted line straight to fm_fake_pane_line instead, the way a +# herdr fake answers `pane run` and `pane read`. +# +# The emulated screen and staged command live beside the helper, so no suite has +# to plumb state through the environment. A fake that also prints its own pane +# content composes it with fm_fake_pane_capture, keeping its own line offsets. When +# FM_FAKE_LAUNCH_LOG is set, the launch the pane finally evaluates is appended to +# it, which is how a suite asserts on the constructed launch command. +# +# This emulates a healthy pane only. Truncation, wrapping, and retry injection +# belong to tests/fm-spawn-launch-delivery.test.sh, which owns that contract and +# keeps its own purpose-built fake. + +fm_fake_pane_shell() { + local fakebin=$1 + mkdir -p "$fakebin" + cat > "$fakebin/pane-shell.sh" <<'SH' +# Sourced by a suite's fake terminal. Owner: fm_fake_pane_shell in tests/lib.sh. +FM_FAKE_PANE_STATE="${BASH_SOURCE[0]%/*}/pane-shell-state" +mkdir -p "$FM_FAKE_PANE_STATE" +FM_FAKE_PANE_SCREEN="$FM_FAKE_PANE_STATE/screen" +FM_FAKE_PANE_STAGED="$FM_FAKE_PANE_STATE/staged" + +fm_fake_pane_capture() { + cat "$FM_FAKE_PANE_SCREEN" 2>/dev/null + return 0 +} + +# fm_fake_pane_line <text>: run one submitted shell line as the pane would. +fm_fake_pane_line() { + local text=${1:-} token staged rebuilt result + staged=$(cat "$FM_FAKE_PANE_STAGED" 2>/dev/null || printf '') + case "$text" in + *__FM_SPAWN_READY_*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") + [ -n "$token" ] && printf '__FM_SPAWN_READY_%s\n' "$token" > "$FM_FAKE_PANE_SCREEN" + ;; + "FM_SPAWN_LAUNCH=''") + : > "$FM_FAKE_PANE_STAGED" + ;; + FM_SPAWN_LAUNCH=*) + rebuilt=$(FM_SPAWN_LAUNCH="$staged" bash -c "$text"'; printf %s "$FM_SPAWN_LAUNCH"') + printf '%s' "$rebuilt" > "$FM_FAKE_PANE_STAGED" + ;; + *__FM_SPAWN_LAUNCH_OK_*) + # Execute the submitted check against the real staged bytes rather than + # echoing the marker back, so corrupt staging cannot report success. + result=$(FM_SPAWN_LAUNCH="$staged" bash -c "$text") + printf '%s\n' "$result" > "$FM_FAKE_PANE_SCREEN" + ;; + 'eval "$FM_SPAWN_LAUNCH"') + [ -n "${FM_FAKE_LAUNCH_LOG:-}" ] && printf '%s\n' "$staged" >> "$FM_FAKE_LAUNCH_LOG" + # The delivery markers scroll away once the launch runs, so a fixture that + # composes its own pane content is not left reading a stale marker line. + : > "$FM_FAKE_PANE_SCREEN" + ;; + esac + return 0 +} + +# fm_fake_pane_send <the fake's full argv>: accept a tmux send-keys call in any +# of the three shapes the backend uses - "-t <target> <text> Enter", +# "-t <target> -l <text>", and "-t <target> <key>". +fm_fake_pane_send() { + local text + shift + [ "${1:-}" = -t ] && shift 2 + if [ "${1:-}" = -l ]; then + text=${2:-} + else + text=${1:-} + fi + fm_fake_pane_line "$text" +} +SH +} + # --- deterministic git identity and fixtures -------------------------------- # fm_git_identity [name] [email]: export a fixed author/committer identity so diff --git a/tests/secondmate-helpers.sh b/tests/secondmate-helpers.sh index 355610bc455..dad4f932945 100644 --- a/tests/secondmate-helpers.sh +++ b/tests/secondmate-helpers.sh @@ -23,6 +23,8 @@ make_fake_tmux() { cat > "$fakebin/tmux" <<'SH' #!/usr/bin/env bash set -u +# shellcheck source=/dev/null +. "$(dirname "$0")/pane-shell.sh" case "${1:-}" in has-session|new-session|new-window|kill-window) printf '%s\n' "$*" >> "$FM_FAKE_TMUX_LOG" @@ -30,29 +32,10 @@ case "${1:-}" in ;; send-keys) printf '%s\n' "$*" >> "$FM_FAKE_TMUX_LOG" - text=${4:-} - staged="$FM_FAKE_TMUX_CAPTURE.staged" - case "$text" in - *"__FM_SPAWN_READY_"*) - token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") - [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$FM_FAKE_TMUX_CAPTURE" - ;; - "FM_SPAWN_LAUNCH=''" ) - : > "$staged" - ;; - FM_SPAWN_LAUNCH=*) - rebuilt=$(FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") - printf '%s' "$rebuilt" > "$staged" - ;; - *"__FM_SPAWN_LAUNCH_OK_"*) - token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\([^']*\)'.*/\1/p") - [ -z "$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\n' "$token" > "$FM_FAKE_TMUX_CAPTURE" - ;; - 'eval "$FM_SPAWN_LAUNCH"') - cat "$staged" >> "$FM_FAKE_TMUX_LOG" - printf '\n' >> "$FM_FAKE_TMUX_LOG" - ;; - esac + # This fixture has no separate launch log, so the launch the pane evaluates + # keeps landing in the tmux log the secondmate suites already assert on. + FM_FAKE_LAUNCH_LOG=${FM_FAKE_LAUNCH_LOG:-$FM_FAKE_TMUX_LOG} + fm_fake_pane_send "$@" exit 0 ;; list-windows) @@ -70,6 +53,7 @@ case "${1:-}" in ;; capture-pane) printf '%s\n' "$*" >> "$FM_FAKE_TMUX_LOG" + fm_fake_pane_capture cat "$FM_FAKE_TMUX_CAPTURE" exit 0 ;; @@ -122,6 +106,7 @@ exit 0 SH chmod +x "$fakebin/tmux" chmod +x "$fakebin/treehouse" + fm_fake_pane_shell "$fakebin" : > "$dir/tmux.log" printf '%s\n' "$fakebin" } From d7ea825e16675233ffeab71958c916e04a2db07d Mon Sep 17 00:00:00 2001 From: quinnbot-ai <quinnbot.ai@gmail.com> Date: Sun, 2 Aug 2026 13:15:02 -0700 Subject: [PATCH 23/70] fix(watch): single owner for watcher arming - end the Stop-hook vs manual-arm collision (#75) * fix(bin): converge watcher arming on one owner per home A Claude primary parks its watcher for the whole handling turn because the Stop-owned auto-arm is its arming owner, but the pull-based mid-turn guard still carried the manual-arm repair line. The model complied, and that second arm became a second relay: when the shared cycle closed on a real wake, the owning arm returned the reason while the following arm reported "watcher: FAILED - cycle ended without an actionable reason", which the Stop hook translated into a supervision-is-down alarm that demanded yet another manual arm. - fm-supervision-instructions.sh gains --owner-absent, so only a caller that has established the arming owner absent asks for a manual arm. fm-turnend-guard.sh is that caller; a mid-turn caller now gets the parked line naming the Stop-owned auto-arm. Model-owned arming harnesses are unchanged. - fm-watch-arm.sh separates a followed cycle's close (watcher: cycle-ended, still typed and nonzero) from an owned child's unexplained close (watcher: FAILED), because only the arm that forked a watcher can see that watcher's reason line. - fm-claude-stop-autoarm.sh rewakes for a drain on a followed close and reserves the supervision-down alarm for the typed failure. No blind turn end, no lost wakes, home-scoped repair, and one watcher per home are unchanged; no backend code is touched. * no-mistakes(review): Captain, align watcher architecture status contract * no-mistakes(review): Deduplicate watcher status contract documentation * no-mistakes(document): Document followed-cycle status across supervision protocols * test(x-mode): track the harness-aware repair line in opt-out remediation The opt-out remediation assertion expected the Claude manual-arm wording, but bootstrap is a mid-turn caller that never establishes the arming owner absent, so on a Claude primary the renderer now names the Stop-owned auto-arm instead. Assert that owner for Claude, and add a Grok round-trip so the case still proves the line tracks the harness rather than being one hardcoded constant - which is what the assertion was always for. --------- Co-authored-by: QuinnBot <quinnbot@proton.me> --- bin/fm-claude-stop-autoarm.sh | 31 ++++++-- bin/fm-supervision-instructions.sh | 31 +++++++- bin/fm-turnend-guard.sh | 7 +- bin/fm-watch-arm.sh | 43 ++++++++--- docs/architecture.md | 7 +- docs/supervision-protocols/claude.md | 4 +- docs/supervision-protocols/grok.md | 5 +- docs/turnend-guard.md | 2 + docs/verification/supervision.md | 32 +++++++- docs/watcher-continuity.md | 23 ++++-- tests/fm-claude-stop-autoarm.test.sh | 31 ++++++++ tests/fm-supervision-instructions.test.sh | 12 ++- tests/fm-watcher-lock.test.sh | 93 ++++++++++++++++++++--- tests/fm-x-mode.test.sh | 18 ++++- 14 files changed, 293 insertions(+), 46 deletions(-) diff --git a/bin/fm-claude-stop-autoarm.sh b/bin/fm-claude-stop-autoarm.sh index 2dd14bfbcea..51a71156a08 100755 --- a/bin/fm-claude-stop-autoarm.sh +++ b/bin/fm-claude-stop-autoarm.sh @@ -30,10 +30,15 @@ # this hook-owned process tree (never shell &); Claude owns the process # group, so its timeout/session teardown kills arm and watcher together. # - Translation: while supervision is still needed and AFK remains inactive, -# an actionable arm close (signal:/stale:/check:/heartbeat) or a typed -# watcher: FAILED prints one rewake banner to stderr and exits 2, which -# wakes Claude even while idle ("Stop hook feedback"). A clean close with -# no actionable reason and no remaining need exits 0 silently. +# an actionable arm close (signal:/stale:/check:/heartbeat), a followed-cycle +# close (watcher: cycle-ended), or a typed watcher: FAILED prints one rewake +# banner to stderr and exits 2, which wakes Claude even while idle ("Stop +# hook feedback"). A clean close with no actionable reason and no remaining +# need exits 0 silently. Only watcher: FAILED gets the supervision-is-down +# alarm: a followed cycle is one this hook attached to rather than forked, so +# its wake reason reached the OWNING arm and lives in the durable queue here, +# and calling that close a failure both alarmed falsely and pushed the model +# into arming a second cycle beside this owner. # # The epoch ledger state/.claude-autoarm-epoch records the latest claim and # outcome so the synchronous Stop guard (bin/fm-turnend-guard.sh --claude) can @@ -158,13 +163,21 @@ fi ACTIONABLE=0 FAILED=0 +# A followed cycle is a cycle this arm attached to rather than forked, so its +# reason line went to the owning arm and only the durable queue carries it here. +# That close is a handling event, not supervision going down, and translating it +# as a failure is what produced the false alarms. +CYCLE_ENDED=0 if [ -n "$OUT" ]; then grep -Eq '^(signal:|stale:|check:|heartbeat($|:))' "$OUT" 2>/dev/null && ACTIONABLE=1 grep -q '^watcher: FAILED' "$OUT" 2>/dev/null && FAILED=1 + grep -q '^watcher: cycle-ended' "$OUT" 2>/dev/null && CYCLE_ENDED=1 +fi +if [ "$RC" -ne 0 ] && [ "$CYCLE_ENDED" -eq 0 ]; then + FAILED=1 fi -[ "$RC" -ne 0 ] && FAILED=1 -if [ "$ACTIONABLE" -eq 0 ] && [ "$FAILED" -eq 0 ]; then +if [ "$ACTIONABLE" -eq 0 ] && [ "$FAILED" -eq 0 ] && [ "$CYCLE_ENDED" -eq 0 ]; then write_epoch clean [ -z "$OUT" ] || rm -f "$OUT" 2>/dev/null || true exit 0 @@ -185,6 +198,12 @@ if [ "$FAILED" -eq 1 ]; then [ -n "$OUT" ] && grep -E '^(watcher:|signal:|stale:|check:|heartbeat)' "$OUT" 2>/dev/null | head -8 printf 'Run bin/fm-wake-drain.sh first. Then repair supervision with bin/fm-watch-arm.sh as its own Claude Code background task (never shell &). If the failure repeats, treat it as a blocker and report it instead of ending blind.\n' } >&2 +elif [ "$ACTIONABLE" -eq 0 ]; then + { + printf 'firstmate watcher cycle closed - it was followed rather than owned, so drain to see what it recorded.\n' + [ -n "$OUT" ] && grep -E '^(watcher:|signal:|stale:|check:|heartbeat)' "$OUT" 2>/dev/null | head -8 + printf 'Run bin/fm-wake-drain.sh first and handle anything it returns. This Stop hook owns watcher continuity: when this turn ends, the next needed cycle arms automatically - do NOT run bin/fm-watch-arm.sh yourself.\n' + } >&2 else { printf 'firstmate watcher wake - one supervision event needs a handling turn now.\n' diff --git a/bin/fm-supervision-instructions.sh b/bin/fm-supervision-instructions.sh index f2f11849fe8..477bb00c011 100755 --- a/bin/fm-supervision-instructions.sh +++ b/bin/fm-supervision-instructions.sh @@ -1,6 +1,16 @@ #!/usr/bin/env bash # Render the primary-harness supervision operating block for session start and # the short repair line used by guards and turn-end hooks. +# +# --repair-line callers differ in what they have established. A pull-based +# caller (bin/fm-guard.sh, bin/fm-bootstrap.sh) runs mid-turn and knows only +# that no watcher is live right now; a turn-boundary caller +# (bin/fm-turnend-guard.sh) has additionally established that the harness's own +# arming owner did not claim this home, and passes --owner-absent 1 to say so. +# The distinction matters because a harness whose arming owner runs at the turn +# boundary parks the watcher for the whole handling turn by design: demanding a +# manual arm there would create a SECOND arming owner whose cycle then collides +# with the owner's own (docs/watcher-continuity.md "Ownership"). set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -16,13 +26,17 @@ AFK=0 X_MODE=0 REPAIR_LINE=0 QUEUE_PENDING=0 +OWNER_ABSENT=0 usage() { cat <<'EOF' -Usage: fm-supervision-instructions.sh [--harness <name>] [--read-only 0|1] [--afk 0|1] [--x-mode 0|1] [--repair-line] [--queue-pending 0|1] +Usage: fm-supervision-instructions.sh [--harness <name>] [--read-only 0|1] [--afk 0|1] [--x-mode 0|1] [--repair-line] [--queue-pending 0|1] [--owner-absent 0|1] Print the current primary harness's supervision operating instructions. With --repair-line, print one concise repair instruction for guard and hook messages. +Pass --owner-absent 1 only when the caller has established that this harness's own +arming owner did not claim the home; the default 0 keeps a mid-turn caller from +asking the model to start a second arming owner. EOF } @@ -64,6 +78,11 @@ while [ "$#" -gt 0 ]; do REPAIR_LINE=1 shift ;; + --owner-absent) + [ "$#" -gt 1 ] || { echo "error: --owner-absent requires 0 or 1" >&2; exit 2; } + OWNER_ABSENT=$(bool_value "$2") + shift 2 + ;; -h|--help) usage exit 0 @@ -129,6 +148,16 @@ repair_line() { if [ "$QUEUE_PENDING" -eq 1 ]; then prefix='After draining queued wakes, ' fi + + # Claude's arming owner is the Stop hook, so between a wake and the next turn + # end the watcher is parked on purpose. A caller that has not established the + # owner absent must not ask for a manual arm here: that second owner is what + # produced colliding cycles and false failure alarms. + if [ "$HARNESS" = claude ] && [ "$OWNER_ABSENT" -eq 0 ]; then + printf '%s%s\n' "$prefix" 'watcher supervision is parked until this turn ends; the Stop-owned auto-arm (bin/fm-claude-stop-autoarm.sh) starts the next cycle then - do not arm one yourself.' + return 0 + fi + if [ "$X_MODE" -eq 1 ]; then prefix="${prefix}source ${x_mode_env_sh} first, then " fi diff --git a/bin/fm-turnend-guard.sh b/bin/fm-turnend-guard.sh index f8769fc5d41..4f1b7caf4c2 100755 --- a/bin/fm-turnend-guard.sh +++ b/bin/fm-turnend-guard.sh @@ -156,7 +156,12 @@ block_stop() { [ -e "$STATE/.afk" ] && afk=1 x_mode=0 [ -f "$CONFIG/x-mode.env" ] && x_mode=1 - reason=$("$SCRIPT_DIR/fm-supervision-instructions.sh" --afk "$afk" --x-mode "$x_mode" --repair-line 2>/dev/null \ + # This guard blocks only after establishing that no watcher is live AND, in + # --claude mode, that the Stop-owned auto-arm did not claim the home either. + # That is the one context where asking the model for a manual arm cannot + # create a second arming owner, so it is the one context that passes + # --owner-absent 1. + reason=$("$SCRIPT_DIR/fm-supervision-instructions.sh" --afk "$afk" --x-mode "$x_mode" --owner-absent 1 --repair-line 2>/dev/null \ || printf '%s\n' 'tasks in flight, no live watcher - repair missing watcher supervision according to the session-start operating block before ending the turn') rule='━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' { diff --git a/bin/fm-watch-arm.sh b/bin/fm-watch-arm.sh index 3820a7320f5..7384610a801 100755 --- a/bin/fm-watch-arm.sh +++ b/bin/fm-watch-arm.sh @@ -30,17 +30,22 @@ # this arm attaches and follows it # watcher: FAILED - no live watcher with a fresh beacon - could not confirm one # watcher: FAILED - cycle ended without an actionable reason -# - a clean cycle ended with no wake and no -# verified healthy successor +# - an OWNED child returned clean and empty +# with no verified healthy successor +# watcher: cycle-ended - the followed watcher cycle closed with no successor; drain the wake queue +# - a FOLLOWED cycle closed; its reason, if +# any, went to the owning arm and is in the +# durable wake queue here # It NEVER reports started/attached/healthy off a stale beacon or a dead/reused pid: a # stale-beacon or dead-pid holder either self-heals (the fresh child steals the # dead lock per the singleton self-eviction/steal path and is confirmed) or this # returns the FAILED line. On started it waits the child and propagates the wake -# reason; on attached it stays live across identity-matched successors. An -# attached cycle that ends without a healthy successor is a typed nonzero failure, -# never a clean empty completion. On FAILED it exits non-zero so the failure is -# loud. A live cycle already present means re-arm attaches - do not start a second -# watcher. +# reason; on attached it stays live across identity-matched successors. Both +# terminal closes above are typed and nonzero, never a clean empty completion, but +# only the OWNED one is a supervision failure: an arm that merely followed another +# arm's cycle cannot see that cycle's reason line and must not report its ordinary +# close as supervision going down. A live cycle already present means re-arm +# attaches - do not start a second watcher. # # Every observed watcher cycle appends one tab-separated lifecycle record to # state/.watch-cycle-exits.log. The arm layer owns that bounded ledger; it records @@ -268,6 +273,9 @@ wait_for_healthy_successor() { done } +# An OWNED child that returns clean and empty is a genuine supervision failure: +# this arm forked it, so any actionable reason would have reached this arm's own +# view of its stdout. fail_unexplained_cycle() { echo "watcher: FAILED - cycle ended without an actionable reason" return 1 @@ -278,15 +286,30 @@ fail_unexplained_cycle() { # start, restart, or stay attached to supervision here. This is what stops an # orphaned arm from a previous harness session retaining or reacquiring the # watcher singleton after another live harness session takes over the home. +# A fenced arm IS a genuine supervision failure for this home, so it keeps the +# FAILED wording rather than the followed-cycle close below. require_session_owner() { # <action-phrase> fm_session_owner_fence "$STATE" && return 0 echo "watcher: FAILED - session-owner fence: home session lock is held by live harness pid $FM_SESSION_OWNER_FOREIGN_PID outside this process's session; $1" return 1 } +# A FOLLOWED cycle is a different fact. This arm never owned the watcher child, +# so the reason line went to the OWNING arm's stdout and reaches this arm only +# through the durable wake queue. Reporting that close as a supervision failure +# was a false alarm on every wake-delivering cycle, and it pushed the model into +# arming a second cycle beside the harness's own arming owner. Report the honest +# close instead, and keep it nonzero so no adapter can read it as a clean empty +# completion. +end_followed_cycle() { + echo "watcher: cycle-ended - the followed watcher cycle closed with no successor; drain the wake queue" + return 1 +} + # Stay alive across identity-matched healthy holders. If one cycle ends, attach -# to a verified successor. With no successor, fail loudly instead of returning a -# clean empty completion that an adapter could mistake for a no-op. +# to a verified successor. With no successor, report the followed close loudly +# instead of returning a clean empty completion that an adapter could mistake for +# a no-op. attach_and_wait() { local attached_pid=$1 while :; do @@ -316,7 +339,7 @@ attach_and_wait() { continue fi cycle_log_append unknown unknown attached-cycle-ended none - fail_unexplained_cycle + end_followed_cycle return 1 done } diff --git a/docs/architecture.md b/docs/architecture.md index 07c2ff5cd2d..2754d222206 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,16 +56,17 @@ Optional X mode integrates with the watcher only after explicit opt-in; [configu 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 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. +`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 typed nonzero terminal result. +On `attached` it stays live across identity-matched successors, and a close with no verified healthy successor becomes a typed nonzero result; [`watcher-continuity.md`](watcher-continuity.md#arm-layer-cycle-contract) owns the exact status semantics. The shared session-owner fence binds normal-mode watcher and arm operations to the live harness session that owns the home; [`watcher-continuity.md`](watcher-continuity.md#session-owner-fence) owns the exact handoff, recovery, and away-mode boundary. 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. +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, a followed-cycle close, or a typed failure into one exit-2 rewake, reserving the supervision-down alarm for the typed failure. [`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-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. +Its warning carries the harness's repair line, and because it runs mid-turn it never establishes that the harness's arming owner is absent, so `bin/fm-supervision-instructions.sh` withholds the manual-arm instruction there and only the turn-end guard passes `--owner-absent 1`. 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. It leads with a prominent bordered tangle banner, while `bin/fm-guard.sh` owns the stale-watcher banner/reminder policy so repeated guarded commands stay noisy without reprinting the full watcher-down banner in the same episode. On every verified primary harness, tracked hook integration gives the primary session a push-based backstop: when work is in flight and no identity-matched watcher lock with a fresh beacon is live, direct Stop hooks block and passive turn-end hooks force one bounded follow-up. diff --git a/docs/supervision-protocols/claude.md b/docs/supervision-protocols/claude.md index c9913553102..2cefbb93857 100644 --- a/docs/supervision-protocols/claude.md +++ b/docs/supervision-protocols/claude.md @@ -9,6 +9,8 @@ When this session owns supervision and away mode is not active: Do not run `bin/fm-watch-arm.sh` after an ordinary wake; the next turn end re-arms automatically when supervision is still needed. Do not invent a wake from an attach-status line alone; drain and act only on real wake records or a real watcher reason line. 4. On a `Stop hook feedback` watcher-failure wake (`watcher: FAILED ...`), treat it as an alarm: drain, then repair supervision before ending the turn. + A `watcher: cycle-ended ...` wake is not that alarm: the hook followed a cycle another arm owned, so drain, handle whatever the queue holds, and let the next turn end re-arm. + Between a wake and that next turn end the watcher is parked on purpose, so a mid-turn watcher-down warning is expected and is not a reason to arm. 5. Manual arm is recovery only. When a repair is genuinely needed - the Stop hook did not claim this home, or a forced restart is required - run `bin/fm-watch-arm.sh` (or `bin/fm-watch-arm.sh --restart`) as its own Claude Code background task, never bundled with other commands, never with shell `&`. Source `__FM_X_MODE_ENV__` first when X mode is active. @@ -24,4 +26,4 @@ When this session owns supervision and away mode is not active: The watcher itself remains `bin/fm-watch.sh`, and `bin/fm-watch-arm.sh` remains the verified arm wrapper that the Stop hook foregrounds. Re-arm attaches to an existing healthy cycle when one is already present and follows its verified successor chain. -See [`watcher-continuity.md`](../watcher-continuity.md) for the arm-layer successor and clean-close failure contract and the Claude ownership model. +See [`watcher-continuity.md`](../watcher-continuity.md) for the arm-layer successor and terminal-close contract and the Claude ownership model. diff --git a/docs/supervision-protocols/grok.md b/docs/supervision-protocols/grok.md index 22444b2bd7f..c4c975e68d7 100644 --- a/docs/supervision-protocols/grok.md +++ b/docs/supervision-protocols/grok.md @@ -12,8 +12,9 @@ When this session owns supervision and away mode is not active: 5. `watcher: started ...` or `watcher: attached ...` means a live cycle exists. On attach, the background task follows verified identity-matched successors instead of exiting when the first cycle ends. 6. Failure or missing cycle only: `watcher: FAILED ...` means supervision is down; fix and re-arm. + `watcher: cycle-ended ...` is a followed-cycle close instead: drain, then take the ordinary re-arm path if supervision is still needed. 7. After a successful start or attach status, end the turn. - The background arm remains the live wait until it returns an actionable wake or failure. + The background arm remains the live wait until it returns an actionable wake, a followed-cycle close, or a failure. 8. Waiting is silent. 9. Never use shell `&` for firstmate supervision. 10. Never bundle the arm onto another command. @@ -28,7 +29,7 @@ When you see a background-task-completed system reminder for the arm: 5. Do not invent a wake from an attach-status line alone. Drain the queue and act only on real wake records or a real watcher reason line. 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. + See [`watcher-continuity.md`](../watcher-continuity.md) for the arm-layer successor and terminal-close contract. 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. diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index 9b9ea2c3bee..922e7991fdd 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -72,6 +72,8 @@ Grok's project hook requires the checkout to be trusted with `/hooks-trust` or l 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. +This guard is the only caller that passes `--owner-absent 1`, because it is the only one that first establishes both a missing watcher and, in `--claude` mode, a missing auto-arm claim. +A pull-based mid-turn caller establishes neither, so on a harness whose arming owner runs at the turn boundary it gets the parked-cycle line instead of a manual-arm instruction; [`watcher-continuity.md`](watcher-continuity.md) owns why a second arming owner is the failure mode being avoided. ## Compatibility limits diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index 15e5d4c5339..4de442c3415 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -232,7 +232,36 @@ Plain Pi and pi-signed share the same tracked `.pi/extensions/fm-primary-pi-watc `tests/fm-session-owner-fence.test.sh` verifies direct watcher and arm refusal under a foreign live owner, serialized restart isolation, process-identity reuse rejection, attached-successor rechecks, cross-harness handoff, dead-owner recovery, and the away-mode exemption. -Deterministic entry points: +### Single arming owner + +Verified on 2026-08-02 with Claude Code 2.1.220 and ShellCheck 0.11.0, against isolated home state under `/tmp`; no live home, watcher, or fleet record was used. + +The mid-turn pull-based guard must name the arming owner rather than ask a Claude primary for a manual arm, because a second arm becomes a second relay: + +```sh +mkdir -p /tmp/fmverify/state /tmp/fmverify/config /tmp/fmverify/root +: > /tmp/fmverify/state/task.meta +CLAUDECODE=1 PI_CODING_AGENT= GROK_AGENT= FM_ROOT_OVERRIDE=/tmp/fmverify/root \ + FM_HOME=/tmp/fmverify FM_GUARD_GRACE=1 bin/fm-guard.sh +``` + +Observed final banner line, with no `bin/fm-watch-arm.sh` anywhere in the output: + +```text +● watcher supervision is parked until this turn ends; the Stop-owned auto-arm (bin/fm-claude-stop-autoarm.sh) starts the next cycle then - do not arm one yourself. +``` + +The turn-end guard is the one caller that has established the owner absent, and it still gets the manual-recovery instruction: + +```sh +bin/fm-supervision-instructions.sh --harness claude --owner-absent 1 --repair-line +``` + +```text +repair missing watcher supervision with bin/fm-watch-arm.sh as its own Claude Code background task, never shell &. +``` + +Deterministic entry points, all passing at this revision: ```sh tests/fm-pi-watch-extension.test.sh @@ -242,6 +271,7 @@ tests/fm-watcher-lock.test.sh tests/fm-subagent-pretool-check.test.sh tests/fm-claude-stop-autoarm.test.sh tests/fm-turnend-guard.test.sh +tests/fm-supervision-instructions.test.sh ``` ## Pure Kun presentation parity diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index 0a46cba222e..79868cbb875 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -5,6 +5,10 @@ Must-work continuity now lives above that process boundary instead of depending ## Ownership +Exactly one component owns arming per home, and that owner is a property of the primary harness rather than of the moment. +Nothing outside that owner may be told to arm unless a caller has established that the owner did not claim the home; `bin/fm-supervision-instructions.sh --owner-absent` is how a caller states that it has, and only `bin/fm-turnend-guard.sh` passes it. +A second arm beside a live owner does not create a second watcher, because the watcher singleton lock still admits one process, but it does create a second relay whose close the owning arm's reason line never reaches. + 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. @@ -13,7 +17,7 @@ Claude's `.claude/settings.json` Stop `asyncRewake` hook (`bin/fm-claude-stop-au 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. The stale-owner claim occurs only after the existing AFK and supervision-need gates pass. -While supervision is still needed and away mode remains inactive, an actionable close or typed failure wakes the idle session through exit 2. +While supervision is still needed and away mode remains inactive, an actionable close, typed followed-cycle close, or typed failure wakes the idle session through exit 2. ## Session-owner fence @@ -34,10 +38,11 @@ After the configured retry bound is exhausted, it delivers the original wake wit This is deliberate Option B ordering: the fleet is protected before the model handles the wake whenever restoration succeeds, but the model is never left blind when it does not. Claude's Stop hook starts the successor arm at the next Stop after the handling turn, rather than before notification as Pi and OpenCode do. +The watcher is therefore parked for the whole handling turn by design, so a mid-turn guard warning on a Claude primary reports a parked cycle and names that owner instead of asking for a manual arm. The durable wake queue preserves actionable events during the residual active-turn window, and the unchanged bounded turn-end guard enforces recovery at Stop when no watcher or auto-arm claim is present. No PreToolUse hook denies fleet commands based on watcher status. The model no longer re-arms after ordinary wakes. -Terminal arm-output classification (`started`, `attached`, or `FAILED`) remains defense in depth for the manual recovery path. +Terminal arm-output classification remains defense in depth for the manual recovery path; [Arm-layer cycle contract](#arm-layer-cycle-contract) owns the exact status semantics. Codex retains its bounded foreground checkpoint protocol. Grok retains its tracked background-task notification protocol. No adapter starts a replacement with shell `&`. @@ -48,8 +53,10 @@ The turn-end guard remains the final backstop rather than the normal continuity `bin/fm-watch-arm.sh` never returns a clean empty success. An actionable child output returns that reason normally. -A zero/empty child return rechecks the home lock and beacon, attaches to a verified healthy successor when one exists, or emits `watcher: FAILED - cycle ended without an actionable reason` and exits nonzero. -An attached arm follows verified identity-matched successors and reports the same typed failure if that chain ends without one. +A zero/empty return from an OWNED child rechecks the home lock and beacon, attaches to a verified healthy successor when one exists, or emits `watcher: FAILED - cycle ended without an actionable reason` and exits nonzero. +An attached arm follows verified identity-matched successors and, when that chain ends without one, emits the separate typed `watcher: cycle-ended - the followed watcher cycle closed with no successor; drain the wake queue` and exits nonzero. +The two typed closes are distinct because only the arm that forked a watcher can see that watcher's reason line: a followed cycle's reason went to the owning arm and reaches this arm only through the durable queue, so reporting that close as a supervision failure alarmed on every wake-delivering cycle and pushed the model into arming a second relay. +Consumers that interpret the arm-layer status line must therefore classify a followed close as a drain-and-handle event and reserve the raw supervision-down alarm for `watcher: FAILED`; `bin/fm-claude-stop-autoarm.sh` does exactly that, and both closes stay nonzero so no adapter can read either as a clean empty completion. The arm layer appends one tab-separated record per observed cycle to `state/.watch-cycle-exits.log`. Each record includes arm and watcher PIDs, start and end timestamps, exit code and signal, classified reason, beacon age, lock identity before and after close, and successor disposition. @@ -63,9 +70,12 @@ Only the watcher process touches `state/.last-watcher-beat`; no helper process c `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-watcher-lock.test.sh` covers verified-successor attach, the typed self-eviction failure, the typed followed-cycle close, bounded and successor-linked lifecycle rows, and a SIGSTOP counterfactual that distinguishes a live PID from a stale beacon before classifying termination. +Its `test_second_arm_does_not_alarm_when_the_owned_cycle_delivers_a_wake` runs the previously-false-alarming sequence end to end - two arms, one watcher, one real wake - and requires the owning arm to relay the reason, the following arm to emit the typed followed-cycle close rather than a failure, and the queue to hold exactly one record. +Its `test_guard_warnings` requires the mid-turn guard banner on a Claude primary to name the Stop-owned arming owner and to mention no manual arm command. `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. +`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, including that a followed-cycle close rewakes for a drain without the supervision-down alarm. +`tests/fm-supervision-instructions.test.sh` covers the `--owner-absent` split between the mid-turn parked line and the turn-end manual-recovery line. `FM_CLAUDE_LIVE_E2E=1 tests/fm-claude-stop-autoarm-live-e2e.test.sh` starts with the reproduced stale-lock state, runs session start first, completes two tokenless cycles, and checks the competing-live-owner negative control. `tests/fm-turnend-guard.test.sh` covers the cooperative `--claude` guard. @@ -74,6 +84,7 @@ The same suite covers ordinary same-process session replacement for `/new`, `/re The goal is continuity without a Pi or OpenCode model-memory re-arm step. No zero-latency guarantee is claimed because lock verification, watcher startup, and bounded retry delays remain deliberate safety work. OpenCode support targets persistent TUI sessions rather than headless `opencode run`. +The Pi and OpenCode adapters deliberately treat any arm child that did not own wake delivery as a continuity failure to retry, so they classify the typed followed-cycle close by its nonzero exit rather than by its own line; that is their existing owning-delivery contract and is unchanged here. Claude depends on the Stop `asyncRewake` rewake, Grok retains native background-completion notifications, and Codex retains bounded foreground checkpoints. [`verification/supervision.md`](verification/supervision.md#watcher-continuity) records the current five-harness live evidence, the 2026-07-24 Stop-owned Claude auto-arm results, and exact opt-in commands. diff --git a/tests/fm-claude-stop-autoarm.test.sh b/tests/fm-claude-stop-autoarm.test.sh index 6be8bc15333..db8fb81a9c8 100755 --- a/tests/fm-claude-stop-autoarm.test.sh +++ b/tests/fm-claude-stop-autoarm.test.sh @@ -104,6 +104,15 @@ SH echo "$$" >> "$FM_HOME/state/arm-ran" printf 'watcher: attached pid=%s (beacon 2s)\n' "$$" exit 0 +SH + ;; + followed-cycle-ended) + cat > "$dir/bin/fm-watch-arm.sh" <<'SH' +#!/usr/bin/env bash +echo "$$" >> "$FM_HOME/state/arm-ran" +printf 'watcher: attached pid=%s (beacon 2s)\n' "$$" +printf 'watcher: cycle-ended - the followed watcher cycle closed with no successor; drain the wake queue\n' +exit 1 SH ;; slow-actionable) @@ -327,6 +336,27 @@ test_failed_close_rewakes_with_failure_banner() { pass "auto-arm: watcher: FAILED translates to an exit-2 alarm rewake" } +test_followed_cycle_close_rewakes_without_the_failure_alarm() { + # The arm attached to a cycle another arm owned, so that cycle's reason went to + # the owner and only the durable queue carries it here. This hook must send the + # model to the drain, not raise a supervision-down alarm that would have it + # start a competing arming owner. + local dir out status + dir=$(make_primary_dir "$TMP_ROOT/followed-cycle") + : > "$dir/state/task.meta" + write_arm_fixture "$dir" followed-cycle-ended + out=$(run_autoarm "$dir" 2>/dev/null); status=$? + expect_code 2 "$status" "a followed-cycle close must still rewake so the queue is drained" + assert_contains "$out" "firstmate watcher cycle closed" "followed-cycle rewake must carry its own banner" + assert_contains "$out" "watcher: cycle-ended" "followed-cycle rewake must carry the arm's typed close" + assert_contains "$out" "bin/fm-wake-drain.sh" "followed-cycle rewake must direct the drain-first protocol" + assert_contains "$out" "do NOT run bin/fm-watch-arm.sh" "followed-cycle rewake must forbid a second arming owner" + assert_not_contains "$out" "watcher cycle FAILED" "followed-cycle close must not raise the supervision-down alarm" + assert_not_contains "$out" "repair supervision" "followed-cycle close must not demand a manual repair" + [ "$(epoch_outcome "$dir")" = rewake ] || fail "epoch must record outcome=rewake, got: $(epoch_outcome "$dir")" + pass "auto-arm: a followed-cycle close rewakes for a drain instead of alarming" +} + test_clean_close_exits_silently() { local dir out status dir=$(make_primary_dir "$TMP_ROOT/clean") @@ -426,6 +456,7 @@ test_resolves_outermost_claude_pid_in_nested_bgspare_chain test_inert_when_fleet_idle test_actionable_close_rewakes_with_reason test_failed_close_rewakes_with_failure_banner +test_followed_cycle_close_rewakes_without_the_failure_alarm test_clean_close_exits_silently test_arms_for_x_mode_poll_need_without_inflight test_single_flight_admits_exactly_one_owner diff --git a/tests/fm-supervision-instructions.test.sh b/tests/fm-supervision-instructions.test.sh index f24ac6c103d..e067b6a62ff 100755 --- a/tests/fm-supervision-instructions.test.sh +++ b/tests/fm-supervision-instructions.test.sh @@ -49,10 +49,15 @@ test_repair_lines() { out=$(FM_HOME="$home" FM_CODEX_WATCH_CHECKPOINT=7 "$RENDER" --harness codex --repair-line) assert_contains "$out" "bin/fm-watch-checkpoint.sh --seconds 7" "codex repair line did not use checkpoint helper and env override" - out=$(FM_HOME="$home" "$RENDER" --harness claude --queue-pending 1 --repair-line) + out=$(FM_HOME="$home" "$RENDER" --harness claude --queue-pending 1 --owner-absent 1 --repair-line) assert_contains "$out" "After draining queued wakes" "queue-pending prefix missing" assert_contains "$out" "Claude Code background task" "claude repair line missing background-task mechanism" + out=$(FM_HOME="$home" "$RENDER" --harness claude --queue-pending 1 --repair-line) + assert_contains "$out" "After draining queued wakes" "queue-pending prefix missing from the parked claude line" + assert_contains "$out" "watcher supervision is parked until this turn ends" "claude mid-turn line did not name the parked state" + assert_not_contains "$out" "bin/fm-watch-arm.sh" "claude mid-turn line asked the model to start a second arming owner" + : > "$home/config/x-mode.env" out=$(FM_HOME="$home" FM_CODEX_WATCH_CHECKPOINT=7 "$RENDER" --harness codex --x-mode 1 --repair-line) assert_contains "$out" "source '$home/config/x-mode.env' first" "x-mode repair line did not source the effective cadence config" @@ -90,9 +95,12 @@ test_cross_harness_ordinary_continuation_and_repair_matrix() { assert_contains "$ordinary" "bin/fm-claude-stop-autoarm.sh" "claude ordinary-wake line lost the auto-arm script name" assert_contains "$ordinary" "do not arm another cycle" "claude ordinary-wake line does not forbid a model re-arm" assert_not_contains "$ordinary" "bin/fm-watch-arm.sh" "claude ordinary-wake line incorrectly calls the manual arm" - out=$("$RENDER" --harness claude --repair-line) + out=$("$RENDER" --harness claude --owner-absent 1 --repair-line) assert_contains "$out" "Claude Code background task" "claude recovery line lost its tracked background repair" assert_contains "$out" "bin/fm-watch-arm.sh" "claude recovery line lost the arm command" + out=$("$RENDER" --harness claude --repair-line) + assert_contains "$out" "bin/fm-claude-stop-autoarm.sh" "claude mid-turn line did not name the arming owner" + assert_not_contains "$out" "bin/fm-watch-arm.sh" "claude mid-turn line still demanded a second arming owner" out=$("$RENDER" --harness grok) ordinary=$(printf '%s\n' "$out" | grep -F -- '- Ordinary wake:') diff --git a/tests/fm-watcher-lock.test.sh b/tests/fm-watcher-lock.test.sh index e741ec21e8e..86abd80b691 100755 --- a/tests/fm-watcher-lock.test.sh +++ b/tests/fm-watcher-lock.test.sh @@ -111,9 +111,11 @@ test_guard_warnings() { # The guard's two operator-visible states, with resilient substrings instead of # four copy-coupled tests: # (1) watcher DOWN + queued wakes: a prominent no-watcher banner leads (alarm - # title, in-flight count, beacon age, fix command), the queued-wakes - # warning follows it, and the guidance is repair-after-drain (never the - # old conflicting "restart NOW first"). + # title, in-flight count, beacon age, harness-aware instruction), the + # queued-wakes warning follows it, and the guidance comes after the drain + # (never the old conflicting "restart NOW first"). This guard is + # pull-based and mid-turn, so on a Claude primary it must name the + # Stop-owned arming owner instead of asking for a second arm. # (2) a fresh watcher and an empty queue: total silence. local dir state err first banner_line queue_line dir=$(make_case guard) @@ -138,23 +140,28 @@ test_guard_warnings() { grep -F 'last beat: never' "$err" >/dev/null || fail "guard banner missing the beacon age" grep -F 'guarded operation WILL still run' "$err" >/dev/null || fail "guard banner missing generic continuation wording" ! grep -F 'requested message WILL still be sent' "$err" >/dev/null || fail "shared guard used send-specific continuation wording" - grep -F 'repair missing watcher supervision' "$err" >/dev/null || fail "guard banner missing the harness-aware fix command" + grep -F 'the Stop-owned auto-arm (bin/fm-claude-stop-autoarm.sh) starts the next cycle then' "$err" >/dev/null || fail "guard banner missing the harness-aware arming-owner instruction" + ! grep -F 'bin/fm-watch-arm.sh' "$err" >/dev/null || fail "mid-turn guard asked a Claude primary to start a second arming owner" grep -F 'queued wakes pending - drain them' "$err" >/dev/null || fail "guard did not warn about pending queue" - grep -F 'After draining queued wakes, repair missing watcher supervision' "$err" >/dev/null || fail "guard did not order supervision repair after drain" + grep -F 'After draining queued wakes, watcher supervision is parked' "$err" >/dev/null || fail "guard did not order the arming-owner instruction after drain" ! grep -F 'Restart it NOW, before anything else' "$err" >/dev/null || fail "guard still gave conflicting restart-first instruction" ! grep -F 'as the harness-tracked background task' "$err" >/dev/null || fail "guard still printed the old universal background-task repair text" banner_line=$(grep -n 'WATCHER DOWN' "$err" | head -1 | cut -d: -f1) queue_line=$(grep -n 'queued wakes pending - drain them' "$err" | head -1 | cut -d: -f1) [ "$banner_line" -lt "$queue_line" ] || fail "queued-wakes warning printed before the no-watcher banner" + # X mode still prefixes the cadence source onto a repair line that really does + # name a command. Grok's primary owns its own arming in every context, so it is + # the harness that keeps proving the prefix. dir=$(make_case guard-xmode) state="$dir/state" err="$dir/guard.err" mkdir -p "$dir/config" printf 'project=x\n' > "$state/task.meta" : > "$dir/config/x-mode.env" - CLAUDECODE=1 PI_CODING_AGENT='' GROK_AGENT='' FM_ROOT_OVERRIDE="$dir" FM_STATE_OVERRIDE="$state" FM_GUARD_GRACE=1 "$ROOT/bin/fm-guard.sh" 2> "$err" >/dev/null || fail "guard failed" + CLAUDECODE='' PI_CODING_AGENT='' GROK_AGENT=1 FM_ROOT_OVERRIDE="$dir" FM_STATE_OVERRIDE="$state" FM_GUARD_GRACE=1 "$ROOT/bin/fm-guard.sh" 2> "$err" >/dev/null || fail "guard failed" grep -F "source '$dir/config/x-mode.env' first" "$err" >/dev/null || fail "guard repair line did not source the X-mode cadence config" + grep -F 'repair missing watcher supervision' "$err" >/dev/null || fail "guard dropped the repair command for a model-owned arming harness" # (2) fresh watcher, empty queue -> silence. dir=$(make_case guard-fresh) @@ -469,7 +476,8 @@ test_watch_restart_attaches_to_healthy_peer() { wait_for_exit "$armpid" 80 status=$? [ "$status" -ne 0 ] && [ "$status" -ne 124 ] || fail "restart arm did not fail after its attached peer ended without a successor (status $status)" - grep -qF 'watcher: FAILED - cycle ended without an actionable reason' "$out" || fail "restart arm did not surface the attached cycle end" + grep -qF 'watcher: cycle-ended - the followed watcher cycle closed with no successor' "$out" || fail "restart arm did not surface the attached cycle end" + ! grep -qF 'watcher: FAILED' "$out" || fail "restart arm called a followed cycle close a supervision failure" pass "watch restart attaches to a verified healthy peer and later surfaces a successor gap" } @@ -571,8 +579,72 @@ test_arm_attaches_and_waits_for_live_fresh_watcher() { wait_for_exit "$armpid" 80 status=$? [ "$status" -ne 0 ] && [ "$status" -ne 124 ] || fail "attached arm did not fail after seed died (status $status)" - grep -qF 'watcher: FAILED - cycle ended without an actionable reason' "$armout" || fail "attached arm did not emit the typed cycle-end failure" - pass "arm attaches to a live fresh watcher and fails loudly when that cycle has no successor" + grep -qF 'watcher: cycle-ended - the followed watcher cycle closed with no successor' "$armout" || fail "attached arm did not emit the typed followed-cycle close" + ! grep -qF 'watcher: FAILED' "$armout" || fail "attached arm called a followed cycle close a supervision failure" + pass "arm attaches to a live fresh watcher and reports a typed nonzero close when that cycle has no successor" +} + +test_second_arm_does_not_alarm_when_the_owned_cycle_delivers_a_wake() { + # The previously-false-alarming sequence: one home, two arms, one watcher, and + # a real wake. The owning arm relays the reason; the second arm only followed + # that cycle, so its close must be the typed followed-cycle line rather than a + # supervision-down alarm that would push the model into arming yet another + # cycle beside the harness's own arming owner. + local dir state fakebin ownerout followout check_file owner follower i orc frc queued + dir=$(make_case two-arms-one-wake) + state="$dir/state" + fakebin="$dir/fakebin" + ownerout="$dir/owner.out" + followout="$dir/follower.out" + check_file="$state/task.check.sh" + mark_pr_check_migration_complete "$state" + cat > "$check_file" <<'SH' +#!/usr/bin/env bash +[ -e "${FM_STATE_OVERRIDE}/fire" ] || exit 0 +printf 'merged: https://example.test/pr/9\n' +SH + chmod 0700 "$check_file" + FM_STATE_OVERRIDE="$state" "$ROOT/bin/fm-check-register.sh" task >/dev/null \ + || fail "could not register the two-arm wake check" + + PATH="$fakebin:$PATH" FM_STATE_OVERRIDE="$state" FM_POLL=0.3 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=1 FM_HEARTBEAT=999999 FM_ARM_CONFIRM_TIMEOUT=5 "$WATCH_ARM" > "$ownerout" & + owner=$! + i=0 + while [ "$i" -lt 100 ]; do + grep -qF 'watcher: started pid=' "$ownerout" 2>/dev/null && break + sleep 0.1 + i=$((i + 1)) + done + grep -qF 'watcher: started pid=' "$ownerout" || fail "owning arm never started a watcher: $(cat "$ownerout")" + + PATH="$fakebin:$PATH" FM_STATE_OVERRIDE="$state" FM_POLL=0.3 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=1 FM_HEARTBEAT=999999 FM_ARM_CONFIRM_TIMEOUT=2 FM_ARM_ATTACH_POLL=0.1 "$WATCH_ARM" > "$followout" & + follower=$! + i=0 + while [ "$i" -lt 100 ]; do + grep -qF 'watcher: attached pid=' "$followout" 2>/dev/null && break + sleep 0.1 + i=$((i + 1)) + done + grep -qF 'watcher: attached pid=' "$followout" || fail "second arm never attached: $(cat "$followout")" + ! grep -qF 'watcher: started' "$followout" || fail "second arm started a competing watcher" + + touch "$state/fire" + wait_for_exit "$owner" 200 + orc=$? + wait_for_exit "$follower" 200 + frc=$? + + [ "$orc" -eq 0 ] || fail "owning arm did not return the wake cleanly (status $orc): $(cat "$ownerout")" + grep -qF "check: $check_file: merged: https://example.test/pr/9" "$ownerout" \ + || fail "owning arm did not relay the wake reason: $(cat "$ownerout")" + [ "$frc" -ne 0 ] && [ "$frc" -ne 124 ] || fail "second arm returned a clean empty completion (status $frc)" + grep -qF 'watcher: cycle-ended - the followed watcher cycle closed with no successor' "$followout" \ + || fail "second arm did not emit the typed followed-cycle close: $(cat "$followout")" + ! grep -qF 'watcher: FAILED' "$followout" \ + || fail "second arm reported supervision down while the owning arm delivered a real wake" + queued=$(grep -c "merged: https://example.test/pr/9" "$state/.wake-queue" 2>/dev/null || true) + [ "$queued" = 1 ] || fail "expected exactly one queued wake record for the shared cycle, got ${queued:-0}" + pass "a second arm follows the owned cycle's wake without a false supervision-down alarm" } test_attached_arm_signal_is_recorded_in_cycle_ledger() { @@ -759,7 +831,7 @@ test_arm_waits_for_peer_beacon_after_child_stands_down() { wait_for_exit "$armpid" 80 status=$? [ "$status" -ne 0 ] && [ "$status" -ne 124 ] || fail "attached arm did not fail after peer died (status $status): $(cat "$armout")" - grep -qF 'watcher: FAILED - cycle ended without an actionable reason' "$armout" || fail "peer-attached arm did not emit the typed cycle-end failure" + grep -qF 'watcher: cycle-ended - the followed watcher cycle closed with no successor' "$armout" || fail "peer-attached arm did not emit the typed followed-cycle close" pass "arm attaches to a peer watcher after child stands down and surfaces a missing successor" } @@ -1031,6 +1103,7 @@ test_watch_restart_attaches_to_healthy_peer test_watcher_self_evicts_on_lock_takeover test_arm_self_eviction_is_loud_without_successor test_arm_attaches_and_waits_for_live_fresh_watcher +test_second_arm_does_not_alarm_when_the_owned_cycle_delivers_a_wake test_attached_arm_signal_is_recorded_in_cycle_ledger test_arm_starts_and_self_heals test_arm_hup_cleans_child_and_temp_output diff --git a/tests/fm-x-mode.test.sh b/tests/fm-x-mode.test.sh index 172479d6fa6..960b48ac3e7 100755 --- a/tests/fm-x-mode.test.sh +++ b/tests/fm-x-mode.test.sh @@ -956,12 +956,24 @@ test_bootstrap_opt_out_cleanup() { assert_present "$home/config/x-mode.env" "opt-in must create the cadence config" # Opt out: empty the token, re-run bootstrap -> artifacts removed + one off line. printf 'FMX_PAIRING_TOKEN=\n' > "$home/.env" - out=$(CLAUDECODE=1 FM_HOME="$home" "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null) + # Bootstrap is a mid-turn caller, so it never establishes that the harness's + # arming owner is absent. On Claude that owner is the Stop hook, which picks up + # the restored default cadence at the next turn end, so the rendered line names + # that owner instead of asking for a second arm. + out=$(CLAUDECODE=1 PI_CODING_AGENT='' GROK_AGENT='' FM_HOME="$home" "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null) assert_contains "$out" "FMX: X mode off" "opt-out must announce X mode off when it removed artifacts" - assert_contains "$out" "Claude Code background task" "opt-out remediation must use the harness-aware repair renderer" - assert_not_contains "$out" "bin/fm-watch-arm.sh --restart" "opt-out remediation must not hardcode a background-arm restart" + assert_contains "$out" "bin/fm-claude-stop-autoarm.sh" "opt-out remediation must use the harness-aware repair renderer" + assert_not_contains "$out" "bin/fm-watch-arm.sh" "opt-out remediation must not ask a Claude primary for a second arming owner" assert_absent "$home/state/x-watch.check.sh" "opt-out must remove the shim" assert_absent "$home/config/x-mode.env" "opt-out must remove the cadence config" + # Re-opt-in then out under a harness that owns its own arming, so this still + # proves the line tracks the harness rather than being one hardcoded constant. + printf 'FMX_PAIRING_TOKEN=tok-out\n' > "$home/.env" + FM_HOME="$home" "$ROOT/bin/fm-bootstrap.sh" >/dev/null 2>&1 + printf 'FMX_PAIRING_TOKEN=\n' > "$home/.env" + out=$(CLAUDECODE='' PI_CODING_AGENT='' GROK_AGENT=1 FM_HOME="$home" "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null) + assert_contains "$out" "Grok tracked background task" "opt-out remediation must render the model-owned arming harness's own repair command" + assert_not_contains "$out" "bin/fm-watch-arm.sh --restart" "opt-out remediation must not hardcode a background-arm restart" # Steady-state off: another run with nothing to remove is silent. out=$(FM_HOME="$home" "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null) assert_not_contains "$out" "FMX:" "steady-state off must be silent" From 8d76cbd5734b7d297aa1528421afca988c843841 Mon Sep 17 00:00:00 2001 From: quinnbot-ai <quinnbot.ai@gmail.com> Date: Sun, 2 Aug 2026 13:46:08 -0700 Subject: [PATCH 24/70] fix(brief): give the ship-brief isolation assertion real path operands (#74) * fix(brief): give the worktree-isolation assertion real paths The ship brief tells a worker to stop unless its top level is "the disposable task worktree you were launched in" rather than "the primary checkout firstmate operates from", and states neither path. Both operands are unbound, and the rule's default on any failure to confirm is STOP, so the check is a judgment call rather than a comparison. It refused a correctly isolated worker in the live fleet: a firstmate-repo crewmate reads the primary checkout's absolute path four times in its own brief and its own worktree path nowhere, and git rev-parse --git-common-dir - the one command that names another checkout - points at the primary checkout too. fm-brief.sh now emits the two paths as {FM_WORKTREE} and {FM_PRIMARY_CHECKOUT} isolation facts, and fm-spawn.sh fills them in from the worktree it has already verified, on both the treehouse and Orca paths, immediately after validate_spawn_worktree. The values are the physically resolved paths because git rev-parse --show-toplevel always reports the resolved one. The rewrite matches on the fact-line prefix rather than swapping the placeholder once, so a relaunch into a different worktree corrects a stale path instead of pinning the brief to the first slot it used. A brief holding a placeholder the fill cannot reach refuses to launch: a worker reading a literal {FM_WORKTREE} has no isolation check at all. Briefs scaffolded before this contract carry neither placeholder nor fact line and are left untouched, so in-flight tasks still relaunch. The safety property is preserved and strengthened: a worker genuinely in the primary checkout compares equal to a named path and stops, and a worker in an unexpected third tree now reports what it actually read instead of being pushed onto the same refusal. * no-mistakes(review): Reject partial isolation fact blocks before spawn * no-mistakes(document): Document named isolation operands * chore: trigger CI * test(spawn): drive isolation-facts spawns through the shared pane owner The isolation-facts suite was written against a base without the launch-delivery verification fm-spawn now performs, so its fake tmux answered send-keys with a bare exit 0 and every spawn in the suite failed with "launch command delivery could not be verified after 3 attempts". Source the shared pane shell from tests/lib.sh and answer capture-pane and send-keys through fm_fake_pane_capture and fm_fake_pane_send, the same owner tests/fm-spawn-worktree-settle.test.sh uses. That owner executes the submitted checksum against the bytes the pane actually accumulated, so this suite exercises the real delivery contract instead of a local imitation that could report success on corrupt staging. * test(brief): guard the isolation assertion by its named operands The tangle guard pinned the literal sentence "The path check is authoritative", which the isolation assertion no longer contains: the check is now an exact comparison against two paths fm-spawn.sh fills in at launch, rather than prose the crewmate has to interpret. Assert the property instead of the removed phrasing - both operands present, and the git-dir/common-dir output explicitly not evidence of being in the primary checkout. That is strictly more than the old assertion checked, since it now requires the operands the comparison needs, and it keeps the existing guards against presenting the git-dir shortcut as decisive. --------- Co-authored-by: QuinnBot <quinnbot@proton.me> --- bin/fm-brief.sh | 16 +- bin/fm-spawn.sh | 55 +++++- docs/architecture.md | 2 +- tests/fm-backend-orca.test.sh | 19 +- tests/fm-brief.test.sh | 51 ++++++ tests/fm-spawn-isolation-facts.test.sh | 244 +++++++++++++++++++++++++ tests/fm-tangle-guard.test.sh | 14 +- 7 files changed, 392 insertions(+), 9 deletions(-) create mode 100755 tests/fm-spawn-isolation-facts.test.sh diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 9ce35ebdc5d..eea4eb3a2ba 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -34,6 +34,10 @@ # local-only implement on branch, stop and report "ready in branch" (no push/PR); # captain approves, firstmate merges to local main # Ship briefs begin with a worktree-isolation assertion before the branch step. +# That assertion carries {FM_WORKTREE} and {FM_PRIMARY_CHECKOUT} placeholders +# because neither path exists yet at scaffold time; bin/fm-spawn.sh fills both in +# at launch, from the worktree it has already verified, and refuses to launch a +# brief left holding either placeholder. # Scout tasks ignore mode - their deliverable is a report, not a merge. # Every scaffold's status protocol distinguishes the configured # declared-external-wait verb (FM_CLASSIFY_PAUSED_VERB, default "paused") from @@ -369,9 +373,15 @@ $HERDR_SECTION # Setup You are in a disposable git worktree of $REPO, at a detached HEAD on a clean default branch. -**Verify isolation before anything else.** Run \`pwd -P\` and \`git rev-parse --show-toplevel\`; both must resolve to the disposable task worktree you were launched in, such as a treehouse pool path or an Orca-managed worktree, not the primary checkout firstmate operates from. -The path check is authoritative: \`git rev-parse --git-dir\` and \`git rev-parse --git-common-dir\` can help inspect the repo, but they do not prove you are outside the primary checkout. -If the top-level path is the primary checkout or not the worktree you were launched in, STOP - do not branch or commit here - append \`blocked: launched in primary checkout, not an isolated worktree\` to the status file and stop. +**Verify isolation before anything else.** Run \`cd "\$(git rev-parse --show-toplevel)" && pwd -P\` and compare what it prints against these two paths, which firstmate resolved and verified at launch: + +- your isolated task worktree: {FM_WORKTREE} +- the primary checkout: {FM_PRIMARY_CHECKOUT} + +If it equals the primary checkout, STOP - do not branch or commit here - append \`blocked: launched in primary checkout, not an isolated worktree\` to the status file and stop. +If it equals your task worktree, you are isolated: proceed. +If it is any third path, or the command fails, append \`blocked: isolation check read {the exact path or error}\` to the status file and stop. +\`git rev-parse --git-dir\` and \`git rev-parse --git-common-dir\` point into the primary checkout's \`.git\` for every linked worktree, including yours; that is expected and is not evidence that you are in the primary checkout. 1. First action: create your branch: \`git checkout -b fm/$ID\`$SETUP2 diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 439a99b74b9..4229403b170 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -106,7 +106,10 @@ # Before a secondmate launch, the home is locally fast-forwarded to the primary # default-branch commit when safe; skipped syncs warn and launch unchanged. # Ship/scout spawns refuse to launch unless the resolved task path is a real -# git worktree root distinct from the primary project checkout. +# git worktree root distinct from the primary project checkout. Once verified, +# both paths are written into the brief's isolation facts +# ({FM_WORKTREE}/{FM_PRIMARY_CHECKOUT} from bin/fm-brief.sh), and a brief still +# holding either placeholder refuses to launch. # Batch dispatch: pass one or more `id=repo` pairs instead of a single <id> <project>, e.g. # fm-spawn.sh fix-a-k3=projects/foo add-b-q7=projects/bar [--scout] # Each pair re-execs this script in single-task mode, so the single path stays the only @@ -929,6 +932,54 @@ validate_spawn_worktree() { # <source> <inspect-target> echo "error: $source did not yield an isolated worktree (resolved '$WT'; worktree root '${wt_top:-none}'; primary '$PROJ_ABS'); refusing to launch to avoid tangling the primary checkout. Inspect target $inspect_target" >&2 exit 1 fi + # The verified physical worktree root, for fill_isolation_facts below. Physical + # because the worker compares against `git rev-parse --show-toplevel`, which + # reports the resolved path even when the shell reached the worktree through a + # symlink, while $WT keeps whatever raw path the backend reported. + WT_REAL=$wt_real +} + +# Fill the ship brief's isolation facts with the two paths the worker cannot +# derive for itself: its own task worktree and the primary checkout. +# bin/fm-brief.sh writes {FM_WORKTREE}/{FM_PRIMARY_CHECKOUT} placeholders because +# neither path exists when the brief is scaffolded, and a worker asked to judge +# in prose whether it is "in the primary checkout" can refuse correct isolation - +# a firstmate-repo crewmate sees the primary checkout's path several times in its +# own brief and its own worktree path nowhere. +# Rewriting by line prefix rather than a one-shot placeholder swap keeps a +# relaunch into a different worktree correct instead of pinning the brief to the +# first slot it ever used. A brief carrying neither placeholder nor fact line - +# every brief scaffolded before this contract, and every secondmate charter - is +# left untouched. A placeholder surviving the rewrite is fatal: a worker reading a +# literal {FM_WORKTREE} has no isolation check at all. +fill_isolation_facts() { # <brief> <worktree-real> <primary-real> + local brief=$1 worktree=$2 primary=$3 tmp worktree_fact=0 primary_fact=0 + grep -q '^- your isolated task worktree: ' "$brief" 2>/dev/null && worktree_fact=1 + grep -q '^- the primary checkout: ' "$brief" 2>/dev/null && primary_fact=1 + if [ "$worktree_fact" = 1 ] && [ "$primary_fact" = 0 ]; then + echo "error: $brief is missing the '- the primary checkout:' isolation fact line; refusing to launch a worker whose isolation check is missing an operand" >&2 + exit 1 + fi + if [ "$worktree_fact" = 0 ] && [ "$primary_fact" = 1 ]; then + echo "error: $brief is missing the '- your isolated task worktree:' isolation fact line; refusing to launch a worker whose isolation check is missing an operand" >&2 + exit 1 + fi + if [ "$worktree_fact" = 1 ]; then + tmp="$brief.fm-isolation-facts.$$" + if ! { FM_FILL_WORKTREE="$worktree" FM_FILL_PRIMARY="$primary" awk ' + /^- your isolated task worktree: / { print "- your isolated task worktree: " ENVIRON["FM_FILL_WORKTREE"]; next } + /^- the primary checkout: / { print "- the primary checkout: " ENVIRON["FM_FILL_PRIMARY"]; next } + { print } + ' "$brief" > "$tmp" && mv "$tmp" "$brief"; }; then + rm -f "$tmp" + echo "error: could not write the isolation facts into $brief; refusing to launch a brief whose isolation check has no paths to compare" >&2 + exit 1 + fi + fi + if grep -q -e '{FM_WORKTREE}' -e '{FM_PRIMARY_CHECKOUT}' "$brief" 2>/dev/null; then + echo "error: $brief still contains an unfilled isolation placeholder; refusing to launch a worker whose isolation check cannot be evaluated" >&2 + exit 1 + fi } herdr_projection_meta_field_exact() { # <meta> <key> @@ -1230,6 +1281,7 @@ EOF exit 1 fi validate_spawn_worktree "orca worktree create" "$W" + fill_isolation_facts "$BRIEF" "$WT_REAL" "$PROJ_ABS_REAL" if [ -z "$ORCA_TERMINAL" ]; then ORCA_TERMINAL=$(fm_backend_orca_terminal_create "$ORCA_WORKTREE_ID" "$W") || exit 1 fi @@ -1456,6 +1508,7 @@ if [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then fi validate_spawn_worktree "treehouse get" "$T" + fill_isolation_facts "$BRIEF" "$WT_REAL" "$PROJ_ABS_REAL" fi # Per-task temp root: /tmp/fm-<id>/ with Go's build temp nested at gotmp/. Go won't diff --git a/docs/architecture.md b/docs/architecture.md index 2754d222206..34e4e118d0d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -145,7 +145,7 @@ Only a named non-default branch checked out in `FM_ROOT` is a worktree tangle. `fm-tangle-lib.sh` resolves the default branch from `origin/HEAD`, then local `main` or `master`, and classifies that named non-default primary branch as the tangle. `fm-guard.sh` prints the repair command on the next mutable fleet action, while `bin/fm-session-start.sh` reports the same condition through bootstrap as a `TANGLE:` line at session start. If another live session holds the fleet lock, both surfaces keep the alarm but switch to read-only wording with no repair command. -Ship briefs also tell the crewmate to verify `pwd -P` and `git rev-parse --show-toplevel` before creating `fm/<id>`, then stop with a blocked status if it landed in the primary checkout. +After validating the task root, `fm-spawn.sh` writes the physically resolved task-worktree and primary-checkout paths into newly scaffolded ship briefs so the crewmate can compare its physical Git top level against named operands before creating `fm/<id>` and stop if it is not in the verified worktree. ## No-mistakes gate authority boundary diff --git a/tests/fm-backend-orca.test.sh b/tests/fm-backend-orca.test.sh index 17a20971daa..cd9b14a52b4 100755 --- a/tests/fm-backend-orca.test.sh +++ b/tests/fm-backend-orca.test.sh @@ -509,7 +509,7 @@ test_spawn_preserves_orca_metadata_when_pathless_worktree_cleanup_fails() { } test_spawn_writes_orca_metadata_and_launches_harness() { - local proj wt data state config id out log + local proj wt data state config id out log proj_real wt_real id="orcaspawnz1" proj="$TMP_ROOT/spawn-project" wt="$TMP_ROOT/spawn-wt" @@ -518,7 +518,12 @@ test_spawn_writes_orca_metadata_and_launches_harness() { config="$TMP_ROOT/spawn-config" fm_git_worktree "$proj" "$wt" "fm/$id" mkdir -p "$data/$id" "$state" "$config" - printf 'brief\n' > "$data/$id/brief.md" + cat > "$data/$id/brief.md" <<'EOF' +brief + +- your isolated task worktree: {FM_WORKTREE} +- the primary checkout: {FM_PRIMARY_CHECKOUT} +EOF touch "$state/.last-watcher-beat" orca_case spawn log="$LOG" @@ -541,6 +546,16 @@ test_spawn_writes_orca_metadata_and_launches_harness() { assert_grep "terminal=term-spawn" "$state/$id.meta" "meta missing terminal handle" assert_grep "orca_worktree_id=wt-spawn" "$state/$id.meta" "meta missing Orca worktree id" assert_grep "worktree=$wt" "$state/$id.meta" "meta missing Orca worktree path" + proj_real=$(cd "$proj" && pwd -P) + wt_real=$(cd "$wt" && pwd -P) + assert_grep "- your isolated task worktree: $wt_real" "$data/$id/brief.md" \ + "Orca spawn did not fill the verified physical worktree into the brief" + assert_grep "- the primary checkout: $proj_real" "$data/$id/brief.md" \ + "Orca spawn did not fill the physical primary checkout into the brief" + assert_no_grep "{FM_WORKTREE}" "$data/$id/brief.md" \ + "Orca spawn left the worktree isolation placeholder unfilled" + assert_no_grep "{FM_PRIMARY_CHECKOUT}" "$data/$id/brief.md" \ + "Orca spawn left the primary-checkout isolation placeholder unfilled" assert_not_contains "$(cat "$log")" $'orca\x1f''terminal'$'\x1f''create' \ "spawn should reuse the implicit terminal returned by Orca worktree creation" assert_contains "$(cat "$log")" $'orca\x1f''terminal'$'\x1f''send'$'\x1f''--terminal'$'\x1f''term-spawn'$'\x1f''--text'$'\x1f''export GOTMPDIR=/tmp/fm-orcaspawnz1/gotmp'$'\x1f''--enter'$'\x1f''--json' \ diff --git a/tests/fm-brief.test.sh b/tests/fm-brief.test.sh index b59e17ad67c..86b930cd254 100755 --- a/tests/fm-brief.test.sh +++ b/tests/fm-brief.test.sh @@ -295,6 +295,56 @@ test_ship_project_memory_wording() { pass "fm-brief.sh: ship project-memory wording carries the AGENTS.md authoring bar" } +# The isolation assertion must ship as an evaluable comparison against two named +# paths, never as prose the worker has to interpret. A worker cannot derive +# either path itself - a firstmate-repo crewmate sees the primary checkout's path +# repeatedly in its own brief and its worktree path nowhere - so the scaffold +# emits placeholders that bin/fm-spawn.sh fills in from the worktree it has +# already verified. Placeholders belong only where something fills them: the +# scout scaffold and the secondmate charter carry no isolation assertion, so a +# leaked placeholder there would be a permanently unfillable literal. +test_ship_isolation_assertion_names_both_paths() { + local home id brief scout charter + home="$TMP_ROOT/isolation-facts-home" + mkdir -p "$home/data" + id="brief-isolation-d1" + FM_HOME="$home" "$ROOT/bin/fm-brief.sh" "$id" some-proj >/dev/null 2>&1 + brief="$home/data/$id/brief.md" + assert_present "$brief" "brief was not scaffolded" + assert_grep "- your isolated task worktree: {FM_WORKTREE}" "$brief" \ + "ship brief lost the fillable task-worktree isolation fact" + assert_grep "- the primary checkout: {FM_PRIMARY_CHECKOUT}" "$brief" \ + "ship brief lost the fillable primary-checkout isolation fact" + assert_grep "If it equals the primary checkout, STOP" "$brief" \ + "ship brief lost the stop branch for the primary checkout" + assert_grep "If it equals your task worktree, you are isolated: proceed." "$brief" \ + "ship brief lost the proceed branch for a correctly isolated worker" + assert_grep "blocked: launched in primary checkout, not an isolated worktree" "$brief" \ + "ship brief lost the isolation refusal status line" + assert_grep "If it is any third path, or the command fails" "$brief" \ + "ship brief lost the third-path branch that reports what was actually read" + assert_no_grep "not the worktree you were launched in, STOP" "$brief" \ + "ship brief kept the unverifiable launched-in referent that refused correct isolation" + + FM_HOME="$home" "$ROOT/bin/fm-brief.sh" brief-isolation-d2 some-proj --scout >/dev/null 2>&1 + scout="$home/data/brief-isolation-d2/brief.md" + assert_present "$scout" "scout brief was not scaffolded" + assert_no_grep "{FM_WORKTREE}" "$scout" \ + "scout brief leaked an isolation placeholder nothing fills" + assert_no_grep "{FM_PRIMARY_CHECKOUT}" "$scout" \ + "scout brief leaked an isolation placeholder nothing fills" + + FM_SECONDMATE_CHARTER="fixture charter" FM_HOME="$home" \ + "$ROOT/bin/fm-brief.sh" brief-isolation-d3 --secondmate --no-projects >/dev/null 2>&1 + charter="$home/data/brief-isolation-d3/brief.md" + assert_present "$charter" "secondmate charter was not scaffolded" + assert_no_grep "{FM_WORKTREE}" "$charter" \ + "secondmate charter leaked an isolation placeholder nothing fills" + assert_no_grep "{FM_PRIMARY_CHECKOUT}" "$charter" \ + "secondmate charter leaked an isolation placeholder nothing fills" + pass "fm-brief.sh: the ship isolation assertion names both paths and placeholders stay where they are filled" +} + test_herdr_lab_contract_is_explicit_and_complete() { local home id brief home="$TMP_ROOT/herdr-lab-home" @@ -659,6 +709,7 @@ test_ship_modes_generate_clean_briefs test_faster_paths_use_configured_authority_without_stacked_review test_no_mistakes_dod_wording test_ship_project_memory_wording +test_ship_isolation_assertion_names_both_paths test_herdr_lab_contract_is_explicit_and_complete test_herdr_lab_contract_quotes_foreign_firstmate_path test_herdr_lab_omission_is_loud_for_ship_and_scout diff --git a/tests/fm-spawn-isolation-facts.test.sh b/tests/fm-spawn-isolation-facts.test.sh new file mode 100755 index 00000000000..31cf2677792 --- /dev/null +++ b/tests/fm-spawn-isolation-facts.test.sh @@ -0,0 +1,244 @@ +#!/usr/bin/env bash +# Behavior tests for the isolation facts bin/fm-spawn.sh writes into a ship +# brief (the fill_isolation_facts step after validate_spawn_worktree). +# +# The generated ship brief tells the worker to stop unless its own top level is +# the isolated task worktree rather than the primary checkout, but bin/fm-brief.sh +# knows neither path when it scaffolds - the worktree does not exist yet - so it +# emits {FM_WORKTREE}/{FM_PRIMARY_CHECKOUT} placeholders. Left unfilled, that +# check is a judgment call with no operands, and it has refused a correctly +# isolated worker in the live fleet: a firstmate-repo crewmate reads the primary +# checkout's absolute path several times in its own brief and its worktree path +# nowhere. fm-spawn.sh fills both in from the worktree it has already verified, +# rewrites a stale value on a later launch instead of pinning the brief to the +# first slot it used, leaves pre-contract briefs untouched, and refuses to launch +# a worker whose isolation check still holds a literal placeholder. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +SPAWN="$ROOT/bin/fm-spawn.sh" +TMP_ROOT=$(fm_test_tmproot fm-spawn-isolation-facts) + +# A fake tmux whose pane_current_path always reports the settled worktree, plus +# a no-op treehouse: the settle loop itself is covered by +# tests/fm-spawn-worktree-settle.test.sh and is not the subject here. The pane +# answers fm-spawn's launch-delivery protocol through the shared owner in +# tests/lib.sh, so this suite verifies real delivery rather than a local +# imitation of it. +make_fakebin() { + local dir=$1 fakebin + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u +# shellcheck source=/dev/null +. "$(dirname "$0")/pane-shell.sh" +case "$*" in + *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; +esac +case "${1:-}" in + display-message) printf 'firstmate\n'; exit 0 ;; + list-windows) exit 0 ;; + has-session|new-session|new-window|kill-window) exit 0 ;; + capture-pane) fm_fake_pane_capture; exit 0 ;; + send-keys) + fm_fake_pane_send "$@" + exit 0 + ;; +esac +exit 0 +SH + chmod +x "$fakebin/tmux" + fm_fake_pane_shell "$fakebin" + fm_fake_exit0 "$fakebin" treehouse + printf '%s\n' "$fakebin" +} + +# make_case <name> <id> builds a home, a primary project checkout and a real +# linked worktree of it, and returns the paths. The brief body is written by +# each test so it can carry placeholders, stale values, or neither. +make_case() { + local name=$1 id=$2 case_dir + case_dir="$TMP_ROOT/$name" + CASE_HOME="$case_dir/home" + CASE_PROJ="$case_dir/project" + CASE_WT="$case_dir/wt" + CASE_FAKEBIN=$(make_fakebin "$case_dir/fake") + mkdir -p "$CASE_HOME/data/$id" "$CASE_HOME/projects" "$CASE_HOME/state" "$CASE_HOME/config" + printf 'codex\n' > "$CASE_HOME/config/crew-harness" + fm_git_worktree "$CASE_PROJ" "$CASE_WT" "wt-$name" + touch "$CASE_HOME/state/.last-watcher-beat" + CASE_BRIEF="$CASE_HOME/data/$id/brief.md" +} + +# The ship-brief isolation block as bin/fm-brief.sh renders it, with <worktree> +# and <primary> supplied by the caller so a test can seed placeholders or a +# stale already-filled value. +write_brief() { + local brief=$1 worktree=$2 primary=$3 + cat > "$brief" <<EOF +task fixture + +**Verify isolation before anything else.** + +- your isolated task worktree: $worktree +- the primary checkout: $primary + +If it equals the primary checkout, STOP. +EOF +} + +run_spawn() { + local id=$1 + FM_ROOT_OVERRIDE='' FM_HOME="$CASE_HOME" \ + FM_STATE_OVERRIDE="$CASE_HOME/state" FM_DATA_OVERRIDE="$CASE_HOME/data" \ + FM_PROJECTS_OVERRIDE="$CASE_HOME/projects" FM_CONFIG_OVERRIDE="$CASE_HOME/config" \ + FM_SPAWN_NO_GUARD=1 TMUX="fake,1,0" \ + FM_FAKE_PANE_PATH="$CASE_WT" \ + PATH="$CASE_FAKEBIN:$PATH" \ + "$SPAWN" "$id" "$CASE_PROJ" 2>&1 +} + +# The paths the worker will compare against must be the physically resolved ones, +# because `git rev-parse --show-toplevel` always reports the resolved path. +resolved() { + (cd "$1" && pwd -P) +} + +test_placeholders_are_filled_with_the_verified_paths() { + local id out status wt_real proj_real + id=isolation-facts-fill-z1 + make_case fill "$id" + write_brief "$CASE_BRIEF" '{FM_WORKTREE}' '{FM_PRIMARY_CHECKOUT}' + wt_real=$(resolved "$CASE_WT") + proj_real=$(resolved "$CASE_PROJ") + + out=$(run_spawn "$id") + status=$? + expect_code 0 "$status" "spawn should succeed and fill the isolation facts (got: $out)" + assert_contains "$out" "spawned $id" "spawn did not report success" + assert_grep "- your isolated task worktree: $wt_real" "$CASE_BRIEF" \ + "brief did not receive the verified task worktree" + assert_grep "- the primary checkout: $proj_real" "$CASE_BRIEF" \ + "brief did not receive the primary checkout" + assert_no_grep "{FM_WORKTREE}" "$CASE_BRIEF" "brief kept an unfilled worktree placeholder" + assert_no_grep "{FM_PRIMARY_CHECKOUT}" "$CASE_BRIEF" "brief kept an unfilled primary placeholder" + pass "fm-spawn.sh: isolation placeholders are filled with the verified worktree and primary checkout" +} + +# A brief that already carries paths - a relaunch, or a task respawned into a +# different pool slot - must be corrected to the worktree this launch verified. +# A stale path would refuse a correctly isolated worker exactly like an unfilled +# placeholder does. +test_stale_isolation_facts_are_rewritten() { + local id out status wt_real + id=isolation-facts-stale-z2 + make_case stale "$id" + write_brief "$CASE_BRIEF" "$TMP_ROOT/stale/some-other-pool-slot" "$TMP_ROOT/stale/some-other-primary" + wt_real=$(resolved "$CASE_WT") + + out=$(run_spawn "$id") + status=$? + expect_code 0 "$status" "spawn should succeed on an already-filled brief (got: $out)" + assert_grep "- your isolated task worktree: $wt_real" "$CASE_BRIEF" \ + "brief kept a stale worktree path from an earlier launch" + assert_no_grep "some-other-pool-slot" "$CASE_BRIEF" \ + "brief still names the worktree from an earlier launch" + assert_no_grep "some-other-primary" "$CASE_BRIEF" \ + "brief still names the primary checkout from an earlier launch" + pass "fm-spawn.sh: a relaunch rewrites stale isolation facts instead of pinning the first slot" +} + +# A placeholder the fill cannot reach - here the fact lines were removed but the +# placeholder text survives elsewhere - must stop the launch. A worker reading a +# literal {FM_WORKTREE} has no isolation check at all, so proceeding would be +# strictly worse than refusing. +test_unfillable_placeholder_refuses_to_launch() { + local id out status + id=isolation-facts-unfillable-z3 + make_case unfillable "$id" + cat > "$CASE_BRIEF" <<'EOF' +task fixture + +Compare your top level against {FM_WORKTREE} before starting. +EOF + + out=$(run_spawn "$id") + status=$? + [ "$status" -ne 0 ] || fail "spawn exited 0 with an unfilled isolation placeholder in the brief" + assert_contains "$out" "unfilled isolation placeholder" \ + "refusal did not name the unfilled isolation placeholder" + assert_not_contains "$out" "spawned $id" "spawn reported success despite refusing" + assert_absent "$CASE_HOME/state/$id.meta" "a refused spawn still recorded task metadata" + pass "fm-spawn.sh: a brief with an unfillable isolation placeholder refuses to launch" +} + +# Briefs scaffolded before this contract carry the old prose assertion and no +# placeholders. They must keep launching unchanged, so an in-flight task can +# still be relaunched after the fleet updates. +test_pre_contract_brief_launches_unchanged() { + local id out status before after + id=isolation-facts-legacy-z4 + make_case legacy "$id" + cat > "$CASE_BRIEF" <<'EOF' +task fixture + +**Verify isolation before anything else.** Run `pwd -P` and `git rev-parse --show-toplevel`. +EOF + before=$(cat "$CASE_BRIEF") + + out=$(run_spawn "$id") + status=$? + after=$(cat "$CASE_BRIEF") + expect_code 0 "$status" "spawn should still launch a pre-contract brief (got: $out)" + assert_contains "$out" "spawned $id" "spawn did not report success for a pre-contract brief" + [ "$before" = "$after" ] || fail "spawn rewrote a pre-contract brief that carries no isolation facts" + pass "fm-spawn.sh: a pre-contract brief launches unchanged" +} + +test_missing_worktree_fact_refuses_to_launch() { + local id out status + id=isolation-facts-missing-worktree-z5 + make_case missing-worktree "$id" + write_brief "$CASE_BRIEF" '{FM_WORKTREE}' '{FM_PRIMARY_CHECKOUT}' + grep -v '^- your isolated task worktree: ' "$CASE_BRIEF" > "$CASE_BRIEF.tmp" + mv "$CASE_BRIEF.tmp" "$CASE_BRIEF" + + out=$(run_spawn "$id") + status=$? + [ "$status" -ne 0 ] || fail "spawn exited 0 with the worktree isolation fact missing" + assert_contains "$out" "missing the '- your isolated task worktree:' isolation fact line" \ + "refusal did not name the missing worktree isolation fact" + assert_not_contains "$out" "spawned $id" "spawn reported success despite the missing worktree fact" + assert_absent "$CASE_HOME/state/$id.meta" "a malformed isolation block still recorded task metadata" + pass "fm-spawn.sh: a brief missing the worktree isolation fact refuses to launch" +} + +test_missing_primary_fact_refuses_to_launch() { + local id out status + id=isolation-facts-missing-primary-z6 + make_case missing-primary "$id" + write_brief "$CASE_BRIEF" '{FM_WORKTREE}' '{FM_PRIMARY_CHECKOUT}' + grep -v '^- the primary checkout: ' "$CASE_BRIEF" > "$CASE_BRIEF.tmp" + mv "$CASE_BRIEF.tmp" "$CASE_BRIEF" + + out=$(run_spawn "$id") + status=$? + [ "$status" -ne 0 ] || fail "spawn exited 0 with the primary-checkout isolation fact missing" + assert_contains "$out" "missing the '- the primary checkout:' isolation fact line" \ + "refusal did not name the missing primary-checkout isolation fact" + assert_not_contains "$out" "spawned $id" "spawn reported success despite the missing primary-checkout fact" + assert_absent "$CASE_HOME/state/$id.meta" "a malformed isolation block still recorded task metadata" + pass "fm-spawn.sh: a brief missing the primary-checkout isolation fact refuses to launch" +} + +test_placeholders_are_filled_with_the_verified_paths +test_stale_isolation_facts_are_rewritten +test_unfillable_placeholder_refuses_to_launch +test_pre_contract_brief_launches_unchanged +test_missing_worktree_fact_refuses_to_launch +test_missing_primary_fact_refuses_to_launch + +echo "# all fm-spawn-isolation-facts tests passed" diff --git a/tests/fm-tangle-guard.test.sh b/tests/fm-tangle-guard.test.sh index 9a87f3711d2..34b4b486c3a 100755 --- a/tests/fm-tangle-guard.test.sh +++ b/tests/fm-tangle-guard.test.sh @@ -123,6 +123,12 @@ test_bootstrap_line() { # The generated ship brief must carry the isolation assertion AHEAD of the # `git checkout -b` step, so the crewmate verifies its worktree before branching. +# The assertion compares the crewmate's resolved top level against two named +# paths that bin/fm-spawn.sh fills in at launch, so both operands must be present +# and the git-dir/common-dir output must stay explicitly non-decisive: those +# commands point into the primary checkout's `.git` for every linked worktree, +# and a brief that let a crewmate read that as membership refused a correctly +# isolated worker in the live fleet. test_brief_assertion_precedes_branch() { local home brief iso br home="$TMP_ROOT/brief-home" @@ -132,8 +138,12 @@ test_brief_assertion_precedes_branch() { assert_present "$brief" "brief was not scaffolded" assert_grep "blocked: launched in primary checkout, not an isolated worktree" "$brief" \ "brief is missing the isolation blocked-status contract" - assert_grep "The path check is authoritative" "$brief" \ - "brief must make the path check authoritative" + assert_grep "- your isolated task worktree:" "$brief" \ + "brief must name the task worktree the path check compares against" + assert_grep "- the primary checkout:" "$brief" \ + "brief must name the primary checkout the path check compares against" + assert_grep "is not evidence that you are in the primary checkout" "$brief" \ + "brief must keep the path check authoritative over git-dir/common-dir output" assert_no_grep "A reliable test that you are in a linked worktree" "$brief" \ "brief must not present git-dir/common-dir as decisive" assert_no_grep "they are identical in the primary checkout" "$brief" \ From c3d6c8b81a06df9431fd75093ee2027b60f7aaf1 Mon Sep 17 00:00:00 2001 From: quinnbot-ai <quinnbot.ai@gmail.com> Date: Sun, 2 Aug 2026 14:31:34 -0700 Subject: [PATCH 25/70] refactor(dispatch): return crew routing to agent judgment with advisory capacity (#76) * refactor(dispatch): return crew routing to agent judgment with advisory capacity Dispatch had accumulated a mechanical control plane in front of the routing decision: a required quota-axi with a version floor, a 113-line selection procedure, and a pre-selection vendor credential probe. Together they gated the start path before any judgment happened, and a capacity or credential reading could refuse a dispatch outright. Section 4 now states the routing decision as agent judgment over task fit and reasoning class. Capacity is advisory: quota-axi is read when installed and may separate otherwise comparable candidates, but headroom, runway, pace, an unmeasurable window, or a missing reading never refuses a dispatch or downgrades the captain's strongest-reasoning class. Selection is followed by exactly one preflight - fm-spawn.sh validating the concrete selected profile at launch - and work stops only when the selected capability actually fails or every remaining fallback would cross an identity boundary. Retires the quota-array-dispatch skill, the pre-selection vendor auth probe, the quota-axi compatibility floor, and their verification record. quota-axi leaves the required universal toolchain, so an absent or older capacity tool no longer emits a start-path blocker; bootstrap tests pin that guarantee directly. Preserved unchanged: fm-spawn.sh's fail-closed launch validation and worktree isolation assertion, secondmate home identity, malformed dispatch config as an actionable error, and the explicit-harness consultation backstop. * no-mistakes(review): Remove residual preselection dispatch gates * no-mistakes(document): Correct stale session-start quota documentation --------- Co-authored-by: QuinnBot <quinnbot@proton.me> --- .agents/skills/bootstrap-diagnostics/SKILL.md | 1 - .agents/skills/harness-adapters/SKILL.md | 6 +- .agents/skills/quota-array-dispatch/SKILL.md | 113 ----- AGENTS.md | 15 +- bin/fm-bootstrap.sh | 26 +- bin/fm-quota-axi-lib.sh | 52 --- bin/fm-session-start.sh | 2 +- bin/fm-test-isolation-proof.sh | 3 +- bin/fm-test-run.sh | 11 +- bin/fm-vendor-auth-probe.sh | 200 --------- docs/architecture.md | 2 +- docs/configuration.md | 16 +- docs/documentation-audiences.json | 8 - docs/examples/crew-dispatch.json | 2 +- docs/scripts.md | 2 - docs/verification/dispatch-auth.md | 178 -------- tests/fm-bootstrap.test.sh | 51 +-- .../fm-quota-array-dispatch-live-e2e.test.sh | 109 ----- tests/fm-vendor-auth-probe.test.sh | 395 ------------------ 19 files changed, 55 insertions(+), 1137 deletions(-) delete mode 100644 .agents/skills/quota-array-dispatch/SKILL.md delete mode 100644 bin/fm-quota-axi-lib.sh delete mode 100755 bin/fm-vendor-auth-probe.sh delete mode 100644 docs/verification/dispatch-auth.md delete mode 100755 tests/fm-quota-array-dispatch-live-e2e.test.sh delete mode 100755 tests/fm-vendor-auth-probe.test.sh diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index c9ca593adb7..eca0755283d 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -20,7 +20,6 @@ When any diagnostic needs captain attention, report the plain consequence and re For `treehouse`, this also covers an installed version whose `treehouse get` lacks `--lease`; treat it as an upgrade request. For `no-mistakes`, this also covers an installed version older than 1.31.2, because crewmate validation briefs delegate gate mechanics to no-mistakes' version-matched guidance. For `tasks-axi`, this also covers an installed build that fails the compatibility probe (`docs/configuration.md` "Backlog backend" owns the definition); `config/backlog-backend=manual` only suppresses the verbose `BOOTSTRAP_INFO: tasks-axi available` fact, not this missing-tool report. - For `quota-axi`, bootstrap requires it because firstmate reads its current output directly before resolving every crew-dispatch profile array; without it, report the missing requirement and do not choose around an unexamined candidate. - `MISSING_MANUAL: <tool> (instructions: <url>)` - tell the captain why the tool is required and give them the printed instructions URL, but do not pass the tool to `bin/fm-bootstrap.sh install`; wait for the captain to complete the manual installation, then rerun session start to confirm the dependency is present. - `BACKEND_INVALID: <name> (known: <names>)` - the resolved runtime backend has no verified dependency or lifecycle contract, so do not dispatch work until the invalid `FM_BACKEND` or `config/backend` value is corrected to one of the listed backends. - `NEEDS_GH_AUTH` - ask the captain to run `! gh auth login` (interactive; you cannot run it for them). diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 03735fefb19..97dae87f952 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -12,7 +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 completion-aware candidate choice after this skill establishes harness and model/provider facts. +When a matched rule or default is a profile array, firstmate chooses among the candidates by judgment under `AGENTS.md` section 4 once 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. @@ -129,7 +129,7 @@ The supported launch-profile flags below are verified locally; each row records | kimi | `--model <model>` | none | Verified 2026-07-25 on Kimi Code CLI 0.29.1. | The concrete `harness` field owns adapter identity independently of the model provider: `harness=pi` with `model=xai/grok-*` is Pi using xAI, not `harness=grok`, and does not require Grok CLI login; `harness=grok` remains the standalone Grok Build CLI adapter. -No script resolves that split for you: establish which credential store a tuple reads from the discovery surfaces below plus `quota-axi auth --json`'s per-provider sources, and show that reasoning rather than inferring it from a harness, model, or source name. +No script resolves that split for you: when available, the discovery surfaces below and `quota-axi auth --json`'s per-provider sources may inform which credential store a tuple reads from, but absent or unreadable optional evidence must never gate or delay dispatch, and identity must never be inferred from a harness, model, or source name. ### Model support discovery @@ -146,7 +146,7 @@ Use the discovery surface in the current authenticated environment because suppo | kimi | Run `kimi provider list --json`, which lists the current provider and model configuration. | 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 from a name or prefix. -A listing that reaches the account and does not contain the model is concrete evidence the model is unsupported: block that candidate and quote the result. +A listing that reaches the account and does not contain the model may inform routing, but it does not create a pre-selection eligibility gate; `fm-spawn.sh` alone owns launch-time refusal of a genuinely unlaunchable selected profile. A discovery surface you could not reach establishes nothing; report that as uncertainty rather than turning it into a supported or unsupported verdict. When a requested effort value is outside the harness-specific accepted set, `fm-spawn` records the requested `effort=` in meta but emits no effort flag for that harness. diff --git a/.agents/skills/quota-array-dispatch/SKILL.md b/.agents/skills/quota-array-dispatch/SKILL.md deleted file mode 100644 index 11b84058125..00000000000 --- a/.agents/skills/quota-array-dispatch/SKILL.md +++ /dev/null @@ -1,113 +0,0 @@ ---- -name: quota-array-dispatch -description: >- - Agent-only decision procedure for resolving a matched crew-dispatch profile - array from current quota-axi output, including effective headroom and usable-runway evidence. - 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 completion-aware profile-array selection procedure. -`AGENTS.md` section 4 owns the always-loaded intake boundary, load trigger, malformed-config refusal, every-candidate accounting, and strongest-reasoning/tie safety rules. -`harness-adapters` owns harness verification, model/provider discovery, and effort fallback. -`quota-axi` remains data-only, reports whatever granularity the vendor supplies, and never recommends, selects, ranks, or infers a route. -Do not add a daemon, opaque composite score, routing wrapper, hard-coded model-specific policy, or producer-side route recommendation. -Deterministic shell owns only schema, configuration, and version validation plus concrete spawn safeguards; every model-to-provider, provider-to-credential, and quota-applicability relation is yours to establish transparently and to show your evidence for. - -## Collect facts - -Run `quota-axi --json` once per intake and reuse that snapshot for every candidate. -Do not take a second snapshot to settle a candidate, and read `quota-axi auth --json` when a candidate's credential surface is in question. -For each candidate, preserve explicit `harness`, `model`, and `provider`; `harness-adapters` owns identity, and model/provider never infer harness: - -- task/profile fit and required reasoning class -- applicable effective headroom (`effectivePercentRemaining`) from the established provider/model scope -- usable runway status, `usableRunwaySeconds`, `projectedExhaustedAt`, `limitingWindowId`, `projectionConfidence`, `projectionBasis`, and any `unmeasurableWindowIds` -- the task-completion horizon and the evidence and confidence used to estimate it -- effective pace, signed reserve per window, and worst reserve (`worstReservePercentPoints` or minimum signed reserve) for later diagnostic tie-breaking -- schema notes when runway or pace fields are absent - -Stale raw windows are diagnostic, never headroom or fabricated runway. -Grok's `credits.remaining` is a prepaid balance unrelated to `percentRemaining`; never read it as exhaustion. -Read all windows named by `boundedBy`, `limitingWindowIds`, `aheadWindowIds`, `behindWindowIds`, `onPaceWindowIds`, `unknownWindowIds`, and `unmeasurableWindowIds`. -The compact default output intentionally omits numeric reserve, while `--json` and `--full` retain reserve diagnostics. - -## Establish the provider relation before reading quota - -Deterministic shell must never map a model to a provider, a provider to a credential store, or a name prefix to a family. -You establish those relations yourself, in the open, from the candidate's own authoritative catalog (`harness-adapters` owns the per-harness discovery surface) plus the one intake snapshot. -Name the evidence for each relation you assert so the conclusion is inspectable. - -1. Confirm the catalog lists the candidate's model and record the provider family it reports. - A model the authoritative catalog does not list is concrete contradictory evidence: block that candidate and quote the catalog result. -2. Apply quota at the granularity the vendor actually supplies. - A provider-level or `all_models`/`all_products` scope bounds every model you established in that family, including one with no window of its own. - A named-model or named-product scope is an additional bound for that model alone and is irrelevant to every other model in the family. - Read `quotaSemantics.description`, which states the vendor's own bounding rule. -3. Record what remains unknown instead of converting it into a verdict. - -## Authentication is scoped to the selected surface - -A candidate authenticates through its own tuple's surface; another harness's CLI can never gate it, and `harness=pi` with `model=xai/grok-*` is Pi using xAI rather than the standalone Grok CLI. -`quota-axi auth --json` lists each provider's credential sources independently, so read the one source the candidate actually uses rather than collapsing a provider to a single status. -A provider can carry a healthy source beside a missing or expired one; the unused source's state is not the candidate's state. -A Pi-hosted family may authenticate through the vendor's own store with no `pi:`-prefixed source at all, which is normal and never evidence against the candidate. - -Uncertainty and ineligibility are different findings: - -- No model-level window, no matching auth source, an absent `state.authStatus`, an unmeasurable or `unknown` scope, or a surface quota-axi does not model at all is disclosed uncertainty. - Keep the candidate eligible, state the unknown, and prefer known sustainable evidence when otherwise comparable. -- An expired credential is a short-lived session token the owning vendor renews on next use, not a sign-out. -- Only concrete contradictory evidence blocks: an authoritative catalog proving the model unsupported, or proof that the credential the candidate actually selects is unusable. -- Reserve login wording for that proven-unusable case, and name the harness, model, surface, and evidence. - -When a credential's local classification is the only thing standing between a candidate and a block, get ground truth before blocking. -`bin/fm-vendor-auth-probe.sh` is the only approved vendor-credential probe; its `--help` owns the registered probes and mechanics. -It takes no harness, model, or provider and returns a fact, not a route: only `authenticated` and `unauthenticated` are ground truth, while `indeterminate`, `timeout`, and `unavailable` establish nothing and must never be read as either outcome. -Never launch a vendor CLI yourself, and never probe a credential store the candidate does not use. - -## Pace semantics - -`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. -Conservation pressure is present for effective pace status `ahead`, effective pace status is `mixed` and any `aheadWindowIds` remain, or a bounding window is `ahead`. -`unknown` is valid explicit uncertainty from quota-axi, not parser failure or permission to assume health. - -## Selection order - -Apply only among candidates satisfying required fit and strongest reasoning class. -Never use headroom, runway, pace, or reserve to silently replace that reasoning class. - -1. Concrete contradictory evidence or malformed configuration: stop and report the tuple and that evidence. - Unmeasurable quota, a missing model-level window, an absent runway field, and a credential surface quota-axi does not model are uncertainty, never this rule. -2. Honor any explicit captain instruction that sets a floor for that candidate before the generic comparison. - Do not invent a generic percentage floor or treat a low percentage as an automatic failure. -3. Keep the strongest-reasoning class when every candidate is tight or completion evidence is poor. - Dispatch inside that class when a candidate can proceed, or report that its strongest-class choice cannot proceed rather than downgrading it to conserve quota. -4. Compare comparable-fit candidates on their applicable effective headroom and usable runway. - Eliminate a candidate only when another candidate Pareto-dominates it on both dimensions, with at least one dimension strictly better. - Establish dominance only from comparable known evidence, never by treating absent, `unknown`, or unmeasurable headroom or runway as zero or as a healthy value. -5. Prefer supported runway evidence that projects availability through the inspectable likely-completion horizon. - Known evidence that does not reach that horizon is inferior to known evidence that does, even when its signed reserve is less negative. - Preserve projection confidence and basis, the limiting window, and the horizon estimate in the rationale rather than hiding them in a score or model-specific heuristic. -6. Resolve remaining uncertainty explicitly. - An authenticated candidate with unknown or unmeasurable headroom or runway stays eligible and cannot be silently excluded or assumed sustainable. - Prefer known viable evidence when otherwise comparable, and report uncertainty or ask the captain when it still prevents a justified choice. -7. Use pace and signed reserve only as later diagnostic tie-break evidence among candidates still unresolved after headroom, runway, likely-completion viability, and uncertainty. - Pace and reserve never rescue a clearly inferior completion prospect. - Do not collapse these facts into an opaque composite score. -8. Older schemas or absent runway/pace fields: do not crash, fabricate runway or pace, treat absence as healthy, or silently exclude a candidate. - State which evidence is unavailable, retain the candidate, and apply only the comparisons the snapshot supports. -9. Genuine ties: 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. - -Account for every candidate visibly before selecting or escalating, naming its catalog evidence, provider relation, applicable quota and authentication facts, remaining uncertainty, fit and reasoning class, effective headroom, usable runway, likely-completion reasoning, and later pace or reserve evidence when used. -A blocked credential report must name `harness`, `model`, authentication surface, and concrete failure evidence; never emit a bare `Grok unauthenticated` statement. -Never conclude with an unexplained "best quota" label. diff --git a/AGENTS.md b/AGENTS.md index 01bbeccf12c..d3da64e4aef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -172,16 +172,12 @@ 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 with inspectable effective headroom and usable runway, using pace and reserve only later when needed. -Account for every candidate with the catalog evidence, provider relationship, applicable quota and authentication facts, remaining uncertainty, fit and reasoning class, and the headroom, runway, and later pace or reserve evidence used in selection; never omit a candidate, guess, fall back silently, or call the result quota-informed without them. -Establish model support and provider family from that harness's own authoritative catalog, then read `quota-axi` at the granularity the vendor actually supplies: provider-level or all-model evidence applies to every model established in that family, and a named-model window bounds only that model. -Missing model-level quota, a missing authentication source, unmeasurable headroom, or unmodeled authentication is disclosed uncertainty that keeps a candidate eligible, never a credential or login escalation. -Only concrete contradictory evidence blocks a candidate, such as an authoritative catalog proving the model unsupported or proof that the credential selected for that surface is unusable; never infer a credential store, provider family, or quota mapping from a harness, model, or source name, and never launch another harness's CLI to judge a candidate. +Firstmate alone resolves a matched profile array, in natural language, from required task fit and reasoning class, and states the chosen profile and the reason for it. +Capacity is advisory: read `quota-axi` when it is installed and let it separate otherwise comparable candidates, but never treat headroom, runway, pace, an unmeasurable window, or a missing capacity reading as a reason to refuse dispatch or to downgrade the captain's strongest-reasoning class. 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 evidence ties without array-order or harness bias. -`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 completion-aware selection procedure. +Selection is followed by exactly one preflight: `fm-spawn.sh` validates the concrete selected profile at launch and refuses only a genuinely unlaunchable one. +Never add a second pre-selection capacity, credential, or eligibility gate in front of that preflight, and never launch another harness's CLI to judge a candidate. +Stop and ask the captain only when the selected capability actually fails at that preflight or in flight, or when every remaining fallback would cross an identity boundary such as another account, credential surface, or home. 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. @@ -503,7 +499,6 @@ 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:`, `STARTUP_MEMORY_BUDGET:`, `CREW_DISPATCH: invalid`, `FLEET_SYNC:`, `PR_CHECK_MIGRATION:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `NUDGE_SECONDMATES:`, `FMX:`, or `OPS_INBOX:`); 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 8811980847a..a1a886d20de 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -50,16 +50,13 @@ # "treehouse get --lease" support. # no-mistakes is also MISSING when its installed version is older than # 1.31.2. -# tasks-axi and quota-axi are required bootstrap tools (same class as -# lavish-axi). tasks-axi is also version and feature gated (0.1.1+ -# with update --archive-body and mv [<id>...]); 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 -# and .agents/skills/quota-array-dispatch/SKILL.md, and is also version -# gated by fm-quota-axi-lib.sh, which owns that floor and its rationale. -# An older build reports MISSING like no-mistakes rather than passing -# silently while emitting auth semantics dispatch cannot scope. +# tasks-axi is a required bootstrap tool (same class as lavish-axi) and +# is version and feature gated (0.1.1+ with update --archive-body and +# mv [<id>...]); an installed but incompatible build reports MISSING +# like no-mistakes. A compatible tasks-axi default backend is silent. +# quota-axi is deliberately NOT a required tool: capacity is advisory to +# dispatch (AGENTS.md section 4), so its absence or age must never +# produce a start-path blocker. # On a primary home, the locked mutable path materializes the visible # default config/startup-memory-budget=7500 when absent. It never # guesses at malformed or unsafe existing files, and secondmate homes @@ -112,8 +109,6 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" # shellcheck source=bin/fm-tasks-axi-lib.sh disable=SC1091 . "$SCRIPT_DIR/fm-tasks-axi-lib.sh" -# shellcheck source=bin/fm-quota-axi-lib.sh disable=SC1091 -. "$SCRIPT_DIR/fm-quota-axi-lib.sh" # shellcheck source=bin/fm-tangle-lib.sh disable=SC1091 . "$SCRIPT_DIR/fm-tangle-lib.sh" # shellcheck source=bin/fm-ff-lib.sh disable=SC1091 @@ -526,7 +521,7 @@ install_cmd() { treehouse) echo "curl -fsSL https://kunchenguid.github.io/treehouse/install.sh | sh" ;; no-mistakes) echo "curl -fsSL https://raw.githubusercontent.com/kunchenguid/no-mistakes/main/docs/install.sh | sh" ;; gh-axi|chrome-devtools-axi|lavish-axi) echo "npm install -g $1 && $1 setup hooks" ;; - tasks-axi|quota-axi) echo "npm install -g $1" ;; + tasks-axi) echo "npm install -g $1" ;; *) return 1 ;; esac } @@ -552,7 +547,7 @@ missing_tool_diagnostic() { # fm_backend_required_tools (bin/fm-backend.sh). So a herdr/zellij/cmux home is # never told tmux is missing, and only orca drops treehouse. A backend value with # no verified dependency set is reported before the universal checks continue. -COMMON_TOOLS="node git gh no-mistakes gh-axi chrome-devtools-axi lavish-axi tasks-axi quota-axi" +COMMON_TOOLS="node git gh no-mistakes gh-axi chrome-devtools-axi lavish-axi tasks-axi" BACKEND=$(fm_backend_name) BACKEND_VALID=1 if ! BACKEND_TOOLS=$(fm_backend_required_tools "$BACKEND"); then @@ -999,9 +994,6 @@ fi if command -v no-mistakes >/dev/null 2>&1 && ! tool_version_at_least no-mistakes "$NO_MISTAKES_MIN"; then echo "MISSING: no-mistakes (install: $(install_cmd no-mistakes))" fi -if command -v quota-axi >/dev/null 2>&1 && ! fm_quota_axi_compatible; then - echo "MISSING: quota-axi (install: $(install_cmd quota-axi))" -fi if command -v tasks-axi >/dev/null 2>&1 && ! fm_tasks_axi_compatible; then echo "MISSING: tasks-axi (install: $(install_cmd tasks-axi))" fi diff --git a/bin/fm-quota-axi-lib.sh b/bin/fm-quota-axi-lib.sh deleted file mode 100644 index 441c9ce2c92..00000000000 --- a/bin/fm-quota-axi-lib.sh +++ /dev/null @@ -1,52 +0,0 @@ -# shellcheck shell=bash -# Shared quota-axi compatibility floor for the bootstrap diagnostic. -# Usage: . bin/fm-quota-axi-lib.sh -# -# 0.1.16 is the floor because it is the first build that reports each provider's -# credential sources independently and exposes Grok `state.authStatus`. Without -# those fields a dispatch candidate cannot be checked against the authentication -# surface it actually uses, which is how one harness's expired CLI token used to -# produce a captain-facing sign-out claim for a candidate that never read it. -# -# This file is the single owner of that version number. bin/fm-bootstrap.sh -# turns a failing check into the operator-facing MISSING diagnostic, which is -# what keeps an older build from reaching a dispatch intake at all. - -FM_QUOTA_AXI_MIN=0.1.16 - -fm_quota_axi_compatible() { - local timeout=${1:-} output parts major minor patch extra - local min_major min_minor min_patch min_extra - command -v quota-axi >/dev/null 2>&1 || return 1 - if [ -n "$timeout" ]; then - case "$timeout" in - ''|*[!0-9]*|0) return 1 ;; - esac - if command -v timeout >/dev/null 2>&1; then - output=$(timeout "$timeout" quota-axi --version 2>/dev/null </dev/null) || return 1 - elif command -v gtimeout >/dev/null 2>&1; then - output=$(gtimeout "$timeout" quota-axi --version 2>/dev/null </dev/null) || return 1 - elif command -v perl >/dev/null 2>&1; then - output=$(perl -e 'my $t = shift; my $pid = fork; die "fork failed" unless defined $pid; if (!$pid) { setpgrp(0, 0); exec @ARGV } local $SIG{ALRM} = sub { kill "TERM", -$pid; select undef, undef, undef, 0.2; kill "KILL", -$pid; exit 124 }; alarm $t; waitpid $pid, 0; exit($? >> 8)' "$timeout" quota-axi --version 2>/dev/null </dev/null) || return 1 - else - return 1 - fi - else - output=$(quota-axi --version 2>/dev/null </dev/null) || return 1 - fi - parts=$(printf '%s\n' "$output" | - sed -n 's/.*\([0-9][0-9]*\)\.\([0-9][0-9]*\)\.\([0-9][0-9]*\).*/\1 \2 \3/p' | - head -1) - IFS=' ' read -r major minor patch extra <<< "$parts" - # An unparseable version is incompatible, never assumed current, so a - # development or vendored build cannot pass a floor it was never checked against. - [ -n "$major" ] && [ -n "$minor" ] && [ -n "$patch" ] && [ -z "$extra" ] || return 1 - # The floor is compared from FM_QUOTA_AXI_MIN so bumping it needs one edit. - IFS='.' read -r min_major min_minor min_patch min_extra <<< "$FM_QUOTA_AXI_MIN" - [ -n "$min_major" ] && [ -n "$min_minor" ] && [ -n "$min_patch" ] && [ -z "$min_extra" ] || return 1 - [ "$major" -gt "$min_major" ] && return 0 - [ "$major" -eq "$min_major" ] || return 1 - [ "$minor" -gt "$min_minor" ] && return 0 - [ "$minor" -eq "$min_minor" ] || return 1 - [ "$patch" -ge "$min_patch" ] -} diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index 6cf4b44057e..ab48b59536b 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -62,7 +62,7 @@ # go dark. So on refusal, bootstrap still runs (in FM_BOOTSTRAP_DETECT_ONLY=1 # mode) for its read-only detect lines - missing tools, gh auth, the # worktree-tangle check, the harness override, crew-dispatch validation, -# tasks-axi and quota-axi tool checks, and tasks-axi availability - none of +# the tasks-axi tool check, and tasks-axi availability - none of # which mutate shared state and all of which are safe to compute without # verified lock ownership. # Only projection cleanup, the six bootstrap mutating sweeps, and the diff --git a/bin/fm-test-isolation-proof.sh b/bin/fm-test-isolation-proof.sh index 7c0eb75d9d3..f84f8ed09ef 100755 --- a/bin/fm-test-isolation-proof.sh +++ b/bin/fm-test-isolation-proof.sh @@ -121,7 +121,7 @@ exclusion_reason() { 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-quota-array-dispatch-live-e2e.test.sh|fm-send-secondmate-marker-herdr-e2e.test.sh) + fm-send-secondmate-marker-herdr-e2e.test.sh) printf '%s\n' 'live harness opt-in; never default parallel CI' ;; fm-backend-autodetect-smoke.test.sh|fm-backend-herdr-eventwait-smoke.test.sh|\ @@ -198,7 +198,6 @@ fm-afk-inject-e2e.test.sh fm-backend-herdr-smoke.test.sh fm-backend-cmux-smoke.test.sh fm-pi-primary-live-e2e.test.sh -fm-quota-array-dispatch-live-e2e.test.sh EOF } diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 4aced69d68a..38788ce5649 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -121,7 +121,7 @@ now_ms() { family_for_basename() { case "$1" in fm-arm-pretool-check.test.sh|fm-ask-user-authority.test.sh|\ - fm-brief.test.sh|fm-vendor-auth-probe.test.sh|\ + fm-brief.test.sh|\ fm-calm-pi-extension.test.sh|fm-cd-pretool-check.test.sh|\ fm-composer-ghost.test.sh|fm-composer-lib.test.sh|\ fm-crew-state.test.sh|fm-decision-hold-lifecycle.test.sh|\ @@ -164,7 +164,7 @@ 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-grok-stop-live-e2e.test.sh|fm-opencode-primary-live-e2e.test.sh|fm-pi-primary-live-e2e.test.sh|\ - fm-quota-array-dispatch-live-e2e.test.sh|fm-send-secondmate-marker-herdr-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|\ @@ -678,7 +678,7 @@ families_for_changed_path() { ;; bin/fm-session-start.sh|bin/fm-bootstrap.sh|bin/fm-fleet-sync.sh|\ bin/fm-sessionstart-nudge.sh|bin/fm-tangle*|bin/fm-update.sh|\ - bin/fm-gate-refuse*|bin/fm-lock*|bin/fm-quota-axi-lib.sh) + bin/fm-gate-refuse*|bin/fm-lock*) printf '%s\n' session-bootstrap ;; bin/fm-pr-*|bin/fm-merge-local.sh|bin/fm-teardown.sh|bin/fm-review-diff.sh|\ @@ -703,15 +703,10 @@ families_for_changed_path() { bin/fm-brief.sh|bin/fm-ensure-agents-md.sh|bin/fm-crew-state.sh|\ bin/fm-decision-hold.sh|bin/fm-supervision*|bin/fm-transition-lib.sh|\ bin/fm-tmux-lib.sh|bin/fm-marker-lib.sh|bin/fm-operational-input.sh|bin/fm-tasks-axi-lib.sh|\ - bin/fm-vendor-auth-probe.sh|\ bin/fm-primary-scope-lib.sh|bin/fm-project-mode.sh|bin/fm-promote.sh|\ bin/fm-ff-lib.sh|bin/fm-gotmp*|bin/*pretool*) printf '%s\n' pure-contract-unit ;; - .agents/skills/quota-array-dispatch/SKILL.md) - printf '%s\n' pure-contract-unit - printf '%s\n' live-harness-optin - ;; .agents/skills/*/SKILL.md) printf '%s\n' pure-contract-unit ;; diff --git a/bin/fm-vendor-auth-probe.sh b/bin/fm-vendor-auth-probe.sh deleted file mode 100755 index 1593fe7ae4b..00000000000 --- a/bin/fm-vendor-auth-probe.sh +++ /dev/null @@ -1,200 +0,0 @@ -#!/usr/bin/env bash -# fm-vendor-auth-probe.sh - one hard-bounded, non-destructive authentication -# probe of a named vendor CLI. -# -# This script collects a FACT and renders no verdict. It takes no harness, model, -# or provider, reads no quota, and never decides whether a dispatch candidate is -# eligible. The dispatching first mate owns that judgment from `quota-axi`'s data -# plus each harness's authoritative model catalog; the decision procedure is -# owned once by .agents/skills/quota-array-dispatch/SKILL.md. -# -# Why it exists rather than the agent running the vendor CLI itself: the -# captain's 2026-07-30 `firstmate-grok-auth-preflight` decision approved exactly -# one bounded, non-interactive probe, and that safety envelope must not depend on -# agent memory. It is enforced here deterministically: -# - the argv is fixed in this file and never composed from input, so no caller -# can turn the probe into a login, logout, or interactive TUI launch; -# - stdin is closed, so caller input can never reach the vendor CLI; -# - a hard positive timeout bounds every command, so a hung CLI cannot wedge an -# intake; -# - raw vendor output is classified here and never printed, logged, or passed -# in an argument. -# -# The probe registry is a fixed-argv safety allowlist, not a routing table. It -# carries no harness, model, provider, credential-store, or provider-family -# relationship, and asking for a probe is always the caller's own explicit -# decision. A probe is registered only after its non-destructive discovery -# command and its output discriminators are verified first-hand and recorded in -# docs/verification/dispatch-auth.md. -# -# Registered probes: -# grok `grok models` - the standalone Grok Build CLI. Verified on grok -# 0.2.117: the command exits 0 in BOTH the authenticated and the -# unauthenticated case, so only the literal first stdout line -# discriminates and the exit status is never a verdict. -# -# Output: exactly one sanitized `key=value` line on stdout. No token, refresh -# token, header, path, length, prefix, hash, or raw vendor output is ever -# printed, logged, or passed in an argument. -# -# probe= the requested probe name -# status= authenticated | unauthenticated | indeterminate | -# timeout | unavailable -# version= the probed CLI's version, or none -# versionVerified= yes | no | none - whether the running CLI matches the -# version whose discriminator strings were verified -# -# `status` is evidence, never eligibility. Only `authenticated` and -# `unauthenticated` are ground truth. `indeterminate`, `timeout`, and -# `unavailable` mean the probe established nothing and must never be read as -# either outcome; unrecognized output is `indeterminate`, never authenticated. -# -# Exit status: 0 whenever the line is printed, 2 on a usage error. The exit -# status deliberately does not encode the probe result, because this script -# renders no verdict for a caller to branch on. -# -# Usage: -# fm-vendor-auth-probe.sh <probe> -# -# Environment: -# FM_VENDOR_AUTH_PROBE_TIMEOUT hard per-command bound in seconds; must be a -# positive integer, otherwise the default 20 is -# used. Zero is rejected because `timeout 0` and -# `alarm 0` both mean "no deadline". -set -u - -VERIFIED_GROK_VERSION=0.2.117 - -usage() { - cat <<'EOF' -fm-vendor-auth-probe.sh - one hard-bounded, non-destructive authentication probe -of a named vendor CLI. It collects a fact and renders no verdict: it takes no -harness, model, or provider, reads no quota, and never decides dispatch -eligibility. The dispatching first mate owns that judgment. - -Usage: - fm-vendor-auth-probe.sh <probe> - -Registered probes: - grok `grok models` on the standalone Grok Build CLI - -Prints one sanitized key=value line: probe, status, version, versionVerified. - -status is evidence, never eligibility: - authenticated the vendor CLI reports an authenticated session - unauthenticated the vendor CLI reports no authenticated session - indeterminate output the verified discriminators do not cover - timeout the hard bound was hit - unavailable the vendor CLI is not on PATH -Only authenticated and unauthenticated are ground truth; the other three -establish nothing and must never be read as either outcome. - -The argv is fixed in the script, stdin is closed, and raw vendor output is never -printed. Login, logout, and the interactive TUI are never invoked. - -Exit status: 0 whenever the line is printed, 2 on a usage error. - -Environment: - FM_VENDOR_AUTH_PROBE_TIMEOUT hard per-command bound in seconds (default 20); - a non-positive or non-numeric value is rejected - in favor of the default -EOF -} - -die_usage() { - printf 'fm-vendor-auth-probe: %s\n' "$1" >&2 - printf 'usage: fm-vendor-auth-probe.sh <probe> (registered probes: grok)\n' >&2 - exit 2 -} - -PROBE= -while [ $# -gt 0 ]; do - case "$1" in - -h|--help) usage; exit 0 ;; - -*) die_usage "unknown option: $1" ;; - *) - [ -z "$PROBE" ] || die_usage "only one probe may be requested at a time" - PROBE=$1 - shift - ;; - esac -done - -[ -n "$PROBE" ] || die_usage "a probe name is required" - -# A non-positive bound is not a bound: `timeout 0` and the Perl fallback's -# `alarm 0` both disable the deadline, so a hung vendor CLI would run unbounded. -TIMEOUT=${FM_VENDOR_AUTH_PROBE_TIMEOUT:-20} -case "$TIMEOUT" in - ''|*[!0-9]*|0*) TIMEOUT=20 ;; -esac - -# Bounded execution, mirroring bin/fm-fleet-snapshot.sh's run_timed selection so -# a macOS host without coreutils still gets a hard bound instead of an unbounded -# vendor CLI call. Exit 124 means the bound was hit. -run_timed() { # <seconds> <command...> - local seconds=$1 - shift - if command -v timeout >/dev/null 2>&1; then - timeout "$seconds" "$@" - elif command -v gtimeout >/dev/null 2>&1; then - gtimeout "$seconds" "$@" - elif command -v perl >/dev/null 2>&1; then - perl -e 'my $t = shift; my $pid = fork; die "fork failed" unless defined $pid; if (!$pid) { setpgrp(0, 0); exec @ARGV } local $SIG{ALRM} = sub { kill "TERM", -$pid; select undef, undef, undef, 0.2; kill "KILL", -$pid; exit 124 }; alarm $t; waitpid $pid, 0; exit($? >> 8)' "$seconds" "$@" - else - return 124 - fi -} - -STATUS=unavailable -VERSION=none -VERSION_VERIFIED=none - -emit() { - printf 'probe=%s status=%s version=%s versionVerified=%s\n' \ - "$PROBE" "$STATUS" "$VERSION" "$VERSION_VERIFIED" - exit 0 -} - -# The two argv forms below are literals in this file. Nothing the caller supplies -# reaches the vendor CLI's argv or stdin. -grok_version() { - local output - output=$(run_timed "$TIMEOUT" grok --version 2>/dev/null </dev/null) || { printf 'none\n'; return 0; } - printf '%s\n' "$output" | sed -nE 's/.*[^0-9]([0-9]+\.[0-9]+\.[0-9]+).*/\1/p' | head -n 1 | grep . || printf 'none\n' -} - -probe_grok() { - local output first rc=0 - output=$(run_timed "$TIMEOUT" grok models 2>/dev/null </dev/null) || rc=$? - if [ "$rc" -eq 124 ]; then - printf 'timeout\n' - return 0 - fi - # The exit status is deliberately ignored: grok 0.2.117 exits 0 in both the - # authenticated and unauthenticated cases, so only the first stdout line - # discriminates. Raw output is classified here and never printed. - first=$(printf '%s\n' "$output" | head -n 1) - case "$first" in - "You are logged in with "*) printf 'authenticated\n' ;; - "You are not authenticated."*) printf 'unauthenticated\n' ;; - *) printf 'indeterminate\n' ;; - esac -} - -case "$PROBE" in - grok) - command -v grok >/dev/null 2>&1 || emit - VERSION=$(grok_version) - if [ "$VERSION" = "$VERIFIED_GROK_VERSION" ]; then - VERSION_VERIFIED=yes - else - VERSION_VERIFIED=no - fi - STATUS=$(probe_grok) - emit - ;; - *) - die_usage "no probe is registered for '$PROBE'" - ;; -esac diff --git a/docs/architecture.md b/docs/architecture.md index 34e4e118d0d..7a3666cf1f4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -174,7 +174,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 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 dispatch file is intentionally judgment-based: firstmate reads the natural-language rules at intake, chooses the best matching rule, resolves profile arrays itself by judgment under the `AGENTS.md` section 4 intake boundary, 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 53a2b65f800..02d8c3e8fec 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -233,12 +233,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 `quota-array-dispatch`, 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 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 always-loaded dispatch intake boundary, and `quota-array-dispatch` owns the completion-aware profile-array selection procedure. +`AGENTS.md` section 4 owns the always-loaded dispatch intake boundary, including the judgment that resolves a profile array and the advisory standing of capacity evidence. ```json { @@ -262,7 +262,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 resolved through `quota-array-dispatch`. +A profile array is a set of acceptable candidates that firstmate chooses among at intake by task fit and reasoning class; the optional rule field `select` accepts only `quota-balanced`, which records that capacity may inform that choice and never that a capacity reading may refuse the 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`. @@ -286,9 +286,10 @@ The capacity file provides only a bound; `AGENTS.md` section 8 owns the primary- On session start the first mate detects what its required toolchain is missing or too old and lists each problem with either an exact install command or manual instructions. It installs automatically supported tools only after you say go; manual-only tools remain for you to install from the printed instructions. Required tools come in two parts: a universal toolchain every home needs regardless of backend, and a per-backend delta that follows the runtime backend actually resolved for this home. -The universal toolchain is node, git, gh with GitHub auth via `gh auth login`, no-mistakes v1.31.2 or newer, gh-axi, chrome-devtools-axi, lavish-axi, compatible tasks-axi per "Backlog backend" above, and quota-axi v0.1.16 or newer. +The universal toolchain is node, git, gh with GitHub auth via `gh auth login`, no-mistakes v1.31.2 or newer, gh-axi, chrome-devtools-axi, lavish-axi, and compatible tasks-axi per "Backlog backend" above. This section is the single owner of that universal toolchain list; backend guides' prerequisites point here and add only their backend-specific tools. -In that list, no-mistakes runs the validation pipeline, gh-axi, chrome-devtools-axi, and lavish-axi cover GitHub, browser, and rich-review operations, and tasks-axi plus quota-axi back backlog mutations and quota-aware array dispatch. +In that list, no-mistakes runs the validation pipeline, gh-axi, chrome-devtools-axi, and lavish-axi cover GitHub, browser, and rich-review operations, and tasks-axi backs backlog mutations. +quota-axi is optional rather than required: dispatch reads it for advisory capacity evidence when it is installed, so a home without it still dispatches and simply routes without that signal. The per-backend delta is required only for the backend resolved from `FM_BACKEND`, then `config/backend`, then runtime auto-detection, then default `tmux`, so a home is never told to install a tool an inactive backend or feature would need. That delta is owned in code by `fm_backend_required_tools` in `bin/fm-backend.sh`: the resolved backend's own session-provider CLI (`tmux`, `herdr`, `zellij`, `orca`, or `cmux`), `jq` for the JSON-emitting experimental adapters (`herdr`, `zellij`, `cmux`) whose spawn and liveness paths parse the backend's JSON output, and the `treehouse` worktree provider for every session-provider-only backend (`tmux`, `herdr`, `zellij`, `cmux`). Backend tool availability uses the adapter's own executable resolver, so bootstrap and spawn agree on supported non-`PATH` locations such as cmux's bundled CLI. @@ -297,10 +298,9 @@ Orca provides both the task worktree and terminal endpoint (see "Runtime backend A herdr, zellij, or cmux home is therefore never told `tmux` is missing, and the `treehouse` durable-lease upgrade check runs only for the backends that actually use treehouse. When `config/crew-dispatch.json` exists, bootstrap also requires `jq` for dispatch profile validation. When X mode is opted in, bootstrap also requires `curl` and `jq` before arming the relay poll shim. -`tasks-axi` and `quota-axi` are required bootstrap tools in every profile, the same class as `lavish-axi`. +`tasks-axi` is a required bootstrap tool in every profile, the same class as `lavish-axi`. An absent or incompatible `tasks-axi` reports `MISSING: tasks-axi (install: npm install -g tasks-axi)`; when `config/backlog-backend` is not `manual` and compatible `tasks-axi` is on `PATH`, bootstrap stays silent and firstmate uses its verbs for routine backlog mutations, otherwise it hand-edits `data/backlog.md` until installation is approved and completed. -An absent or too-old `quota-axi` reports `MISSING: quota-axi (install: npm install -g quota-axi)`; firstmate cannot resolve a profile array without a compatible binary. -That floor exists because it is the first build reporting per-credential auth sources, without which a candidate cannot be judged against the authentication surface it actually uses. +`quota-axi` is deliberately not in that class and has no bootstrap version floor, because capacity is advisory to dispatch and an absent or older capacity reading must never become a start-path blocker. Bootstrap also reports a `TANGLE:` line when `FM_ROOT` is on a named non-default branch; follow the printed checkout remediation rather than treating it as an installable tool problem. In a read-only session that did not get the fleet lock, the same line is advisory and omits the checkout command. The locked session-start bootstrap step also runs a best-effort project clone refresh through `fm-fleet-sync.sh`. diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index 19dd1aa387e..b30f37f2389 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -159,10 +159,6 @@ "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" @@ -315,10 +311,6 @@ "path": "docs/turnend-guard.md", "audience": "operator-current" }, - { - "path": "docs/verification/dispatch-auth.md", - "audience": "maintainer-verification" - }, { "path": "docs/verification/public-followup.md", "audience": "maintainer-verification" diff --git a/docs/examples/crew-dispatch.json b/docs/examples/crew-dispatch.json index b404e95e777..ebc3784f0e0 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": "Use a strong coding profile for big, ambiguous work; resolve the alternatives through quota-array-dispatch." + "why": "Use a strong coding profile for big, ambiguous work; pick between the alternatives at intake." } ], "default": [ diff --git a/docs/scripts.md b/docs/scripts.md index 07d93416392..b9af3578edc 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -74,8 +74,6 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-lock-lib.sh` | Shared "is this git lock provably abandoned?" proof used by teardown and fleet-sync | | `fm-config-inherit-lib.sh` | Shared primary-to-secondmate inherited local-material propagation and config-reread delivery | | `fm-tasks-axi-lib.sh` | Shared backlog-backend selector and `tasks-axi` compatibility probe | -| `fm-quota-axi-lib.sh` | Shared `quota-axi` compatibility floor for the bootstrap diagnostic | -| `fm-vendor-auth-probe.sh`| Run one hard-bounded, non-destructive authentication probe of a named vendor CLI and report the fact | | `fm-wake-drain.sh` | Atomically drain queued watcher wakes, emit bounded best-effort status-event annotations, then assert watcher liveness | | `fm-wake-lib.sh` | Shared durable wake queue, portable locks, and watcher identity/health helpers | | `fm-classify-lib.sh` | Shared captain-relevant and declared-external-wait wake classification vocabulary | diff --git a/docs/verification/dispatch-auth.md b/docs/verification/dispatch-auth.md deleted file mode 100644 index 86b9f4795df..00000000000 --- a/docs/verification/dispatch-auth.md +++ /dev/null @@ -1,178 +0,0 @@ -# Dispatch authentication verification - -Audience: maintainer verification. - -This record supports the dispatch judgment rules in `.agents/skills/quota-array-dispatch/SKILL.md` and the bounded vendor probe in `bin/fm-vendor-auth-probe.sh`. -It records only facts that must be re-established when a producer or vendor version changes. -Task chronology, incident transcripts, and credential metadata stay in private reports or PR evidence. - -Firstmate resolves a candidate's provider family, credential surface, and applicable quota by reading the evidence below and reasoning in the open. -No script maps a model to a provider, a provider to a credential store, or a name prefix to a family, so the facts here are what that reasoning rests on. -Credential paths below are shown with the home directory replaced by `<home>`. - -## Quota granularity the judgment depends on - -Verified 2026-07-30 against quota-axi 0.1.16. - -`quota-axi --json` reports availability at whatever granularity the vendor supplies, and states the vendor's own bounding rule in `quotaSemantics.description`. - -```json -{ - "provider": "codex", - "state": { "status": "fresh", "stale": false }, - "quotaSemantics": { - "status": "known", - "description": "Codex base account windows bound every model. Named model windows add bounds for that model; code-review windows describe a separate workload and are not included in model availability.", - "effectiveAvailability": [ - { "scope": "all_models", "status": "known", "effectivePercentRemaining": 64, "boundedBy": ["weekly"] }, - { "scope": "model:codex_bengalfox", "status": "known", "effectivePercentRemaining": 64, "boundedBy": ["weekly", "model:codex_bengalfox:7d"] } - ] - } -} -``` - -Three properties follow and are load-bearing for dispatch: - -- An `all_models` (or `all_products`) scope is real evidence for every model in that provider family, including a model with no window of its own. -- A `model:`-scoped entry is an additional bound for that one model. `model:codex_bengalfox` is the GPT-5.3-Codex-Spark window and bounds nothing else. -- A named-model window can be tighter than the account bound, so it must not be read across models. In the same snapshot Claude reported `all_models` with `effectivePercentRemaining` 10 while `model:fable` reported 4, limited by the `model:fable` window itself. A non-Fable Claude model reads 10, not 4. - -`quotaSemantics.status` is `unknown` with no `effectiveAvailability` entries at all for providers whose vendor exposes no window (observed for `cursor` and `copilot`). -`state.authStatus` is present only for some providers (observed for `grok` alone), so its absence is missing evidence, not a credential fault. - -## Completion-runway shape the judgment depends on - -Verified 2026-07-31 against quota-axi 0.1.17 schema 3. -The command below records the producer shape without persisting account-specific quota values: - -```sh -quota-axi --json | jq '{schemaVersion, effectiveAvailabilityFields: ([.providers[]?.quotaSemantics.effectiveAvailability[]? | keys] | unique), runwayFields: ([.providers[]?.quotaSemantics.effectiveAvailability[]?.runway? | select(type == "object") | keys] | unique)}' -``` - -```json -{ - "schemaVersion": 3, - "effectiveAvailabilityFields": [ - [ - "boundedBy", - "effectivePercentRemaining", - "limitingWindowIds", - "pace", - "runway", - "scope", - "status" - ] - ], - "runwayFields": [ - [ - "limitingWindowId", - "projectedExhaustedAt", - "projectionBasis", - "projectionConfidence", - "status", - "usableRunwaySeconds" - ], - [ - "limitingWindowId", - "projectedExhaustedAt", - "status", - "usableRunwaySeconds" - ] - ] -} -``` - -`runway` is nested under each effective-availability scope, so the same provider/model applicability rules govern both effective headroom and runway. -Projection confidence and basis are not present on every known runway, so selection must preserve their absence as uncertainty rather than fabricate them. -The older-schema fallback contract is owned by `quota-array-dispatch`; this evidence does not reinterpret an absent runway or pace field. - -## Provider-family counterfactual that this producer schema supports - -Verified 2026-07-30 on Pi 0.82.0 and quota-axi 0.1.16. - -```sh -pi --list-models terra -``` - -```text -provider model context max-out thinking images -openai-codex gpt-5.6-terra 272K 128K yes yes -``` - -The Pi catalog is authoritative for Pi model support and reports the provider family in its own column. -For `harness=pi`, `model=openai-codex/gpt-5.6-terra` the catalog establishes the model is supported and belongs to the `openai-codex` family, and the Codex `all_models` scope above supplies fresh, known 64 effective remaining for every model in that family. -No Terra-specific window exists in the snapshot, and `quota-axi auth --json` lists no `pi:openai-codex` source. -Both absences are missing model-level and source-level detail, not contradictory evidence, so this candidate is dispatchable with the model-level uncertainty disclosed. - -```sh -pi --list-models gpt-9.9-nonexistent -``` - -```text -No models matching "gpt-9.9-nonexistent" -``` - -A listing that reaches the account and returns no row is the authoritative negative that does block a candidate. - -## Credential sources are independent per provider - -Verified 2026-07-30 against quota-axi 0.1.16. - -`quota-axi auth --json` reports each provider's credential sources separately, which is what lets a candidate be scoped to the one surface it actually authenticates through: - -```json -[ - { "provider": "claude", "sources": [ - { "source": "oauth-file", "path": "<home>/.claude/.credentials.json", "status": "missing" }, - { "source": "keychain", "status": "available" } ] }, - { "provider": "codex", "sources": [ - { "source": "auth-json", "path": "<home>/.codex/auth.json", "status": "available" }, - { "source": "cli-rpc", "path": "<path-to>/codex", "status": "available" } ] }, - { "provider": "grok", "sources": [ - { "source": "auth-json", "path": "<home>/.grok/auth.json", "status": "available" }, - { "source": "pi:xai", "status": "available" } ] }, - { "provider": "kimi", "sources": [ - { "source": "pi:kimi-coding", "status": "available" }, - { "source": "kimi-code-cli", "status": "expired", "error": "kimi_code_cli_credential_expired" } ] } -] -``` - -Observed source statuses are `available`, `expired` (with an `error` slug), and `missing`. - -- A provider can carry a healthy source beside a missing or expired one, so a provider must not be collapsed to a single status. Claude's `oauth-file` is missing while its keychain source is available, and Kimi's standalone CLI credential is expired while its Pi source is available. -- A `pi:`-prefixed source exists only where Pi holds its own credential for that family (`pi:xai`, `pi:kimi-coding`). Pi's `openai-codex` family has none, because it authenticates through the Codex store that the `codex` provider already lists. A missing `pi:` source is therefore never evidence against a Pi candidate. - -Neither this per-source shape nor `state.authStatus` exists before quota-axi 0.1.16. -`bin/fm-bootstrap.sh` enforces that floor through `bin/fm-quota-axi-lib.sh`. - -Grok also reports `credits.remaining: 0` alongside `percentRemaining: 41` on a healthy account. -That zero is a prepaid balance, not the subscription window, and is never headroom. - -## Standalone Grok discovery probe - -Verified 2026-07-30 on `grok 0.2.117 (f1c06093089f) [stable]`. - -```sh -grok --version -grok models # stdin closed, single attempt, hard-bounded -``` - -Observed: - -- `grok models` exits `0` and its first stdout line is `You are logged in with grok.com.` for an authenticated session. -- With a home directory holding no Grok credential, the first stdout line is `You are not authenticated.`, also with exit status `0`. -- Because the status is `0` in both cases, the exit status is not a verdict; only the literal first stdout line is examined, and a blank first line does not authenticate. -- `<home>/.grok/auth.json` was byte-identical across the authenticated run (`mtime`, `size`, and mode `0600` unchanged), so the probe is a read in that path. - -These discriminator strings are un-owned vendor UI text. -`bin/fm-vendor-auth-probe.sh` pins the verified version, reports `versionVerified=no` when the running CLI differs, and classifies any unrecognized first line as `indeterminate` rather than authenticated. -Re-run the two commands above and update this section and the pinned version together when the vendor CLI changes. - -## Regression coverage - -`tests/fm-vendor-auth-probe.test.sh` drives the real script against a fake vendor CLI that records every invocation's argv and anything readable on stdin. -It asserts that the script accepts no harness, model, or provider input, never calls `quota-axi`, exits alike for every probe result because it renders no verdict, invokes only the two fixed non-destructive argv forms with stdin closed, holds a real bound even when the configured bound is zero or malformed, and never echoes raw vendor output. -`tests/fm-spawn-dispatch-profile.test.sh` owns spawn's deterministic profile and harness refusals. -`tests/fm-bootstrap.test.sh` owns the quota-axi version-floor diagnostic. -`tests/fm-quota-array-dispatch-live-e2e.test.sh` drives the public Pi skill-loading interface against one fake `quota-axi --json` snapshot per case. -It covers the Claude 1 percent versus Codex 55 percent reserve regression, explicit accounting for unmeasurable runway, and the strongest-reasoning constraint. diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index 6c72c0bbc45..55e767984f6 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -4,8 +4,10 @@ # Bootstrap prints one block or line per actionable problem, optional verbose # BOOTSTRAP_INFO fact, or completed bootstrap no-action fact and is silent when # all is well. firstmate consumes the exact 'MISSING: treehouse (install: ...)', -# 'MISSING: tasks-axi (install: ...)', 'MISSING: quota-axi (install: ...)', and -# 'BOOTSTRAP_INFO: ...' lines, so those contracts are pinned verbatim. The cases +# 'MISSING: tasks-axi (install: ...)', and 'BOOTSTRAP_INFO: ...' lines, so those +# contracts are pinned verbatim. quota-axi is deliberately absent from that set: +# capacity is advisory to dispatch, so its absence or age must never emit a +# start-path blocker. The cases # are table-driven over the inputs that vary: whether `treehouse get --help` # advertises --lease, which (if any) tasks-axi version is on PATH, whether # tasks-axi update advertises --archive-body, whether its mv help advertises @@ -288,11 +290,11 @@ missing tasks-axi is required by default^1^-^1^-^exact^MISSING: tasks-axi (insta incompatible tasks-axi is required by default^1^0.1.0^1^-^exact^MISSING: tasks-axi (install: npm install -g tasks-axi)^ tasks-axi without archive-body is required by default^1^0.1.2:noarchive^1^-^exact^MISSING: tasks-axi (install: npm install -g tasks-axi)^ tasks-axi without multi-id mv is required by default^1^0.2.2:nomulti^1^-^exact^MISSING: tasks-axi (install: npm install -g tasks-axi)^ -missing quota-axi is required by default^1^0.1.1^0^manual^exact^MISSING: quota-axi (install: npm install -g quota-axi)^ +missing quota-axi never blocks the start path^1^0.1.1^0^manual^empty^^ manual backlog backend still requires missing tasks-axi^1^-^1^manual^exact^MISSING: tasks-axi (install: npm install -g tasks-axi)^ manual backlog backend suppresses tasks-axi availability^1^0.1.1^1^manual^empty^^ ROWS - pass "bootstrap reports treehouse lease + tasks-axi/quota-axi bootstrap contracts" + pass "bootstrap reports treehouse lease + tasks-axi bootstrap contracts" } test_no_mistakes_min_version() { @@ -326,16 +328,14 @@ ROWS pass "bootstrap enforces no-mistakes minimum version" } -# 0.1.16 is the first quota-axi that reports per-credential auth sources and Grok -# state.authStatus. Before it, a dispatch candidate could not be scoped to its own -# authentication surface, which is exactly how one harness's expired CLI token -# produced a captain-facing "log in" claim for a candidate that never read it. A -# stale install used to pass this check silently, so the fix stayed uninstalled. -test_quota_axi_min_version() { - local label version mode case_dir fakebin out missing n - missing='MISSING: quota-axi (install: npm install -g quota-axi)' +# Capacity is advisory to dispatch (AGENTS.md section 4), so no quota-axi version +# is a start-path blocker. An old, unparseable, or entirely absent capacity tool +# must leave bootstrap silent; the routing decision then simply proceeds without +# that advisory signal instead of refusing to dispatch. +test_quota_axi_is_never_a_start_path_blocker() { + local label version case_dir fakebin out n n=0 - while IFS='^' read -r label version mode; do + while IFS='^' read -r label version; do [ -n "$label" ] || continue n=$((n + 1)) case_dir="$TMP_ROOT/quota-axi-$n" @@ -343,24 +343,19 @@ test_quota_axi_min_version() { printf '%s\n' manual > "$case_dir/home/config/backlog-backend" fakebin=$(make_fake_toolchain "$case_dir") add_tasks_axi "$fakebin" "0.1.1" + [ "$version" = "-" ] && rm -f "$fakebin/quota-axi" out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$case_dir/home" FM_ROOT_OVERRIDE="$case_dir/home" \ FM_FAKE_TREEHOUSE_LEASE_HELP=1 FM_FAKE_QUOTA_AXI_VERSION="$version" "$ROOT/bin/fm-bootstrap.sh") - case "$mode" in - empty) - [ -z "$out" ] || fail "$label: expected silence, got: $out" ;; - missing) - [ "$out" = "$missing" ] || fail "$label: expected '$missing', got: $out" ;; - esac + [ -z "$out" ] || fail "$label: expected silence, got: $out" done <<'ROWS' -minimum quota-axi version is accepted^0.1.16^empty -newer quota-axi patch is accepted^0.1.17^empty -newer quota-axi minor is accepted^0.2.0^empty -newer quota-axi major is accepted^1.0.0^empty -older quota-axi patch reports an upgrade^0.1.15^missing -much older quota-axi minor reports an upgrade^0.0.9^missing -unparseable quota-axi version reports an upgrade^quota-axi development build^missing +current quota-axi stays silent^0.1.16 +newer quota-axi stays silent^1.0.0 +older quota-axi never reports an upgrade^0.1.15 +much older quota-axi never reports an upgrade^0.0.9 +unparseable quota-axi version never reports an upgrade^quota-axi development build +absent quota-axi never reports a missing tool^- ROWS - pass "bootstrap enforces quota-axi minimum version" + pass "bootstrap keeps quota-axi advisory, never a start-path blocker" } test_git_is_required_with_supported_install_instruction() { @@ -834,7 +829,7 @@ ROWS test_bootstrap_reporting test_no_mistakes_min_version -test_quota_axi_min_version +test_quota_axi_is_never_a_start_path_blocker test_git_is_required_with_supported_install_instruction test_orca_backend_gates_orca_tool_only_when_selected test_session_provider_backends_do_not_require_tmux diff --git a/tests/fm-quota-array-dispatch-live-e2e.test.sh b/tests/fm-quota-array-dispatch-live-e2e.test.sh deleted file mode 100755 index 0b7f1102aba..00000000000 --- a/tests/fm-quota-array-dispatch-live-e2e.test.sh +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env bash -# Credentialed behavior regression for the agent-owned quota-array-dispatch skill. -# -# This drives the public Pi skill-loading interface against a fake quota-axi -# executable rather than parsing instruction source bytes or recreating the -# selector in test code. -set -u - -if [ "${FM_QUOTA_ARRAY_DISPATCH_LIVE_E2E:-0}" != 1 ]; then - echo "skip: set FM_QUOTA_ARRAY_DISPATCH_LIVE_E2E=1 to run the credentialed Pi dispatch-selection regression" - exit 0 -fi - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -OWNER="$ROOT/.agents/skills/quota-array-dispatch/SKILL.md" - -fail() { - printf 'not ok - %s\n' "$1" >&2 - exit 1 -} - -command -v pi >/dev/null 2>&1 || fail "pi not found" -[ -f "$OWNER" ] || fail "quota-array-dispatch skill not found" - -LAB=$(mktemp -d "${TMPDIR:-/tmp}/fm-quota-array-dispatch-live.XXXXXX") -PROJECT="$LAB/project" -FAKEBIN="$LAB/fakebin" -FIXTURE="$LAB/quota.json" -CALLS="$LAB/quota-axi.calls" - -cleanup() { - rm -rf "$LAB" -} -trap cleanup EXIT - -mkdir -p "$PROJECT/.agents/skills/quota-array-dispatch" "$FAKEBIN" -cp "$OWNER" "$PROJECT/.agents/skills/quota-array-dispatch/SKILL.md" - -cat > "$FAKEBIN/quota-axi" <<'SH' -#!/usr/bin/env bash -set -u -if [ "${1:-}" != --json ] || [ "$#" -ne 1 ]; then - printf 'unexpected quota-axi invocation: %s\n' "$*" >&2 - exit 64 -fi -printf '%s\n' "$*" >> "${QUOTA_AXI_CALLS:?}" -cat "${QUOTA_AXI_FIXTURE:?}" -SH -chmod +x "$FAKEBIN/quota-axi" - -write_fixture() { - cat > "$FIXTURE" -} - -run_case() { - local label=$1 expected=$2 prompt=$3 out calls required - shift 3 - : > "$CALLS" - out=$( - cd "$PROJECT" && - PATH="$FAKEBIN:$PATH" QUOTA_AXI_CALLS="$CALLS" QUOTA_AXI_FIXTURE="$FIXTURE" \ - pi --print --approve --no-session --no-context-files --no-extensions \ - --no-skills --skill .agents/skills --tools bash \ - --model openai-codex/gpt-5.6-sol --thinking high \ - "$prompt" - ) || fail "$label: Pi skill run failed: $out" - calls=$(cat "$CALLS") - [ "$calls" = "--json" ] || fail "$label: skill did not use one quota-axi --json snapshot: $calls" - printf '%s\n' "$out" | grep -Fxq "$expected" \ - || fail "$label: expected final line $expected, got: $out" - for required in "$@"; do - printf '%s\n' "$out" | grep -Fxq "$required" \ - || fail "$label: expected accounting line $required, got: $out" - done - printf '%s\n' "$out" - printf 'ok - %s\n' "$label" -} - -write_fixture <<'JSON' -{"schemaVersion":3,"providers":[{"provider":"claude","quotaSemantics":{"description":"The all_models scope bounds every Claude model.","effectiveAvailability":[{"scope":"all_models","status":"known","effectivePercentRemaining":1,"boundedBy":["weekly"],"runway":{"status":"projected_exhaustion","usableRunwaySeconds":600,"projectedExhaustedAt":"2030-01-01T00:10:00Z","limitingWindowId":"weekly","projectionConfidence":"established","projectionBasis":"cycle_average"}}]},"effectivePace":[{"scope":"all_models","pace":"ahead","worstReservePercentPoints":-1}]},{"provider":"codex","quotaSemantics":{"description":"The all_models scope bounds every Codex model.","effectiveAvailability":[{"scope":"all_models","status":"known","effectivePercentRemaining":55,"boundedBy":["weekly"],"runway":{"status":"projected_exhaustion","usableRunwaySeconds":14400,"projectedExhaustedAt":"2030-01-01T04:00:00Z","limitingWindowId":"weekly","projectionConfidence":"established","projectionBasis":"cycle_average"}}]},"effectivePace":[{"scope":"all_models","pace":"ahead","worstReservePercentPoints":-40}]}]} -JSON -run_case \ - "higher headroom and viable runway beat a less-negative reserve" \ - "SELECTED=codex" \ - "Resolve this matched dispatch profile array now. Load quota-array-dispatch and run quota-axi --json exactly once. Both profiles have comparable required task fit and the same strongest reasoning class. The authoritative catalogs already prove Claude/Sonnet and Codex/GPT models supported in their stated provider families, and their selected authentication surfaces are usable. The likely task-completion horizon is two hours with established confidence. Return exact lines FACT=claude|headroom=1|runway_seconds=600|reserve=-1 and FACT=codex|headroom=55|runway_seconds=14400|reserve=-40 to preserve candidate accounting, then an exact final line SELECTED=<claude|codex>. Do not use other vendor or model commands and do not modify files." \ - "FACT=claude|headroom=1|runway_seconds=600|reserve=-1" \ - "FACT=codex|headroom=55|runway_seconds=14400|reserve=-40" - -write_fixture <<'JSON' -{"schemaVersion":3,"providers":[{"provider":"claude","quotaSemantics":{"description":"The all_models scope bounds every Claude model.","effectiveAvailability":[{"scope":"all_models","status":"known","effectivePercentRemaining":55,"boundedBy":["weekly"],"runway":{"status":"unknown","unmeasurableWindowIds":["weekly"]}}]}},{"provider":"codex","quotaSemantics":{"description":"The all_models scope bounds every Codex model.","effectiveAvailability":[{"scope":"all_models","status":"known","effectivePercentRemaining":45,"boundedBy":["weekly"],"runway":{"status":"projected_exhaustion","usableRunwaySeconds":14400,"projectedExhaustedAt":"2030-01-01T04:00:00Z","limitingWindowId":"weekly","projectionConfidence":"established","projectionBasis":"cycle_average"}}]}}]} -JSON -run_case \ - "unmeasurable runway stays eligible and is accounted for explicitly" \ - "DECISION=CODEX" \ - "Resolve this matched dispatch profile array now. Load quota-array-dispatch and run quota-axi --json exactly once. Both profiles have comparable required task fit and the same strongest reasoning class. The authoritative catalogs already prove both models supported in their stated provider families, and their selected authentication surfaces are usable. The likely task-completion horizon is two hours with established confidence. Claude has higher known headroom but explicitly unmeasurable runway, while Codex has lower known headroom and established runway that supports completion. The snapshot cannot prove Pareto dominance in either direction, but the known completion-supporting runway justifies Codex while Claude remains eligible and its uncertainty must be disclosed. Return exact lines FACT=claude|eligible=yes|headroom=55|runway=unknown|unmeasurable=weekly and FACT=codex|eligible=yes|headroom=45|runway_seconds=14400|supports_horizon=yes, then an exact final line DECISION=CODEX. Do not use other vendor or model commands and do not modify files." \ - "FACT=claude|eligible=yes|headroom=55|runway=unknown|unmeasurable=weekly" \ - "FACT=codex|eligible=yes|headroom=45|runway_seconds=14400|supports_horizon=yes" - -write_fixture <<'JSON' -{"schemaVersion":3,"providers":[{"provider":"claude","quotaSemantics":{"description":"The all_models scope bounds every Claude model.","effectiveAvailability":[{"scope":"all_models","status":"known","effectivePercentRemaining":1,"boundedBy":["weekly"],"runway":{"status":"projected_exhaustion","usableRunwaySeconds":10800,"projectedExhaustedAt":"2030-01-01T03:00:00Z","limitingWindowId":"weekly","projectionConfidence":"established","projectionBasis":"cycle_average"}}]}},{"provider":"codex","quotaSemantics":{"description":"The all_models scope bounds every Codex model.","effectiveAvailability":[{"scope":"all_models","status":"known","effectivePercentRemaining":80,"boundedBy":["weekly"],"runway":{"status":"projected_exhaustion","usableRunwaySeconds":28800,"projectedExhaustedAt":"2030-01-01T08:00:00Z","limitingWindowId":"weekly","projectionConfidence":"established","projectionBasis":"cycle_average"}}]}}]} -JSON -run_case \ - "required strongest reasoning class is not downgraded for quota" \ - "SELECTED=claude" \ - "Resolve this matched dispatch profile array now. Load quota-array-dispatch and run quota-axi --json exactly once. The likely task-completion horizon is two hours with established confidence. Claude/Sonnet is catalog-supported with usable authentication and is the only profile that meets the task's required strongest reasoning class. Codex/GPT is catalog-supported with usable authentication but is a weaker reasoning class and cannot meet the requirement. Return exact lines FACT=claude|reasoning=required|headroom=1|runway_seconds=10800 and FACT=codex|reasoning=weaker|headroom=80|runway_seconds=28800, then an exact final line SELECTED=<claude|codex>. Do not use other vendor or model commands and do not modify files." \ - "FACT=claude|reasoning=required|headroom=1|runway_seconds=10800" \ - "FACT=codex|reasoning=weaker|headroom=80|runway_seconds=28800" - -echo "# all quota-array-dispatch live behavior tests passed" diff --git a/tests/fm-vendor-auth-probe.test.sh b/tests/fm-vendor-auth-probe.test.sh deleted file mode 100755 index 47367a32682..00000000000 --- a/tests/fm-vendor-auth-probe.test.sh +++ /dev/null @@ -1,395 +0,0 @@ -#!/usr/bin/env bash -# Behavior tests for fm-vendor-auth-probe.sh - the one hard-bounded, -# non-destructive authentication probe of a named vendor CLI. -# -# Two defects this suite pins: -# -# 1. The script must render no dispatch verdict and hold no routing knowledge. -# Its predecessor resolved a candidate's credential surface from a hard-coded -# harness-to-provider table plus a `pi:<model-prefix>` source-id matcher, and -# emitted `eligible=`. A supported Pi model in a provider family with no such -# prefixed source was therefore dropped as unresolved while the family's own -# quota and credentials were healthy. The tests below prove the script now has -# no harness, model, or provider input surface at all, so no such mapping can -# influence it, and that both probe outcomes exit alike because neither is a -# verdict. -# -# 2. The captain-approved probe envelope must not depend on agent memory: fixed -# argv, stdin closed, a hard positive bound, and raw vendor output never -# printed. The fake grok records every invocation's argv and anything it can -# read from stdin, so "argv is fixed to `models`", "no login or logout", and -# "stdin stays closed" are observable facts rather than comments. -set -u - -# shellcheck source=tests/lib.sh disable=SC1091 -. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" - -BASE_PATH=${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin} -TMP_ROOT=$(fm_test_tmproot fm-vendor-auth-probe-tests) -SCRIPT="$ROOT/bin/fm-vendor-auth-probe.sh" - -# A stdin payload the script must never leak into a probed vendor CLI. -STDIN_SENTINEL='SENTINEL-STDIN-MUST-NOT-REACH-VENDOR-CLI' - -# --- fake toolchain --------------------------------------------------------- -# -# quota-axi is present on PATH and logs every invocation. The script must never -# call it: reading quota is the dispatch owner's job against one intake snapshot, -# and a probe that re-read it would reintroduce the retired coupling. -make_fakebin() { - local dir=$1 fakebin - fakebin=$(fm_fakebin "$dir") - cat > "$fakebin/quota-axi" <<'SH' -#!/usr/bin/env bash -printf '%s\n' "$*" >> "$FM_FAKE_QUOTA_LOG" -exit 0 -SH - chmod +x "$fakebin/quota-axi" - - cat > "$fakebin/grok" <<'SH' -#!/usr/bin/env bash -printf '%s\n' "$*" >> "$FM_FAKE_GROK_LOG" -# Record whatever is readable on stdin. With stdin correctly closed by the -# caller this reads EOF immediately and records nothing. -if IFS= read -r -t 2 leaked; then - printf '%s\n' "$leaked" >> "$FM_FAKE_GROK_STDIN" -fi -if [ "${1:-}" = --version ]; then - printf 'grok %s (fakebuild) [stable]\n' "${FM_FAKE_GROK_VERSION:-0.2.117}" - exit 0 -fi -case "${FM_FAKE_GROK_MODE:-authenticated}" in - authenticated) - printf '%s\n' 'You are logged in with grok.com.' - printf '\n%s\n' 'Default model: grok-4.5' - ;; - unauthenticated) - printf '%s\n' 'You are not authenticated.' - ;; - garbage) - printf '%s\n' 'Session status: unknown (0.9.0 rewrote this line)' - ;; - leading-blank) - printf '\n%s\n' 'You are logged in with grok.com.' - ;; - empty) : ;; - hang) sleep 30 ;; -esac -# grok 0.2.117 exits 0 whether or not the session authenticates; the fake keeps -# that property so a regression to exit-status reading fails here. -exit 0 -SH - chmod +x "$fakebin/grok" - printf '%s\n' "$fakebin" -} - -# run_probe <case> [args...] -- [env assignments...] -# Sets RUN_LINE, RUN_RC, RUN_GROK_LOG, RUN_GROK_STDIN, RUN_QUOTA_LOG in the -# caller's shell, so it must not be invoked in a command substitution. -RUN_LINE= -RUN_RC=0 -RUN_GROK_LOG= -RUN_GROK_STDIN= -RUN_QUOTA_LOG= -run_probe() { - local case_name=$1 - shift - local case_dir fakebin out rc=0 arg - local -a script_args=() env_pairs=() - case_dir="$TMP_ROOT/$case_name" - mkdir -p "$case_dir" - fakebin=$(make_fakebin "$case_dir") - RUN_GROK_LOG="$case_dir/grok.log" - RUN_GROK_STDIN="$case_dir/grok.stdin" - RUN_QUOTA_LOG="$case_dir/quota.log" - : > "$RUN_GROK_LOG" - : > "$RUN_GROK_STDIN" - : > "$RUN_QUOTA_LOG" - local seen_separator=0 - for arg in "$@"; do - if [ "$seen_separator" -eq 0 ] && [ "$arg" = -- ]; then - seen_separator=1 - continue - fi - if [ "$seen_separator" -eq 0 ]; then - script_args+=("$arg") - else - env_pairs+=("$arg") - fi - done - out=$(env "PATH=$fakebin:$BASE_PATH" \ - "FM_FAKE_GROK_LOG=$RUN_GROK_LOG" \ - "FM_FAKE_GROK_STDIN=$RUN_GROK_STDIN" \ - "FM_FAKE_QUOTA_LOG=$RUN_QUOTA_LOG" \ - "${env_pairs[@]+"${env_pairs[@]}"}" \ - "$SCRIPT" "${script_args[@]+"${script_args[@]}"}" \ - <<<"$STDIN_SENTINEL" 2>/dev/null) || rc=$? - RUN_RC=$rc - RUN_LINE=$out -} - -field() { # <line> <key> - printf '%s\n' "$1" | tr ' ' '\n' | sed -n "s/^$2=//p" -} - -assert_field() { # <line> <key> <expected> <label> - local got - got=$(field "$1" "$2") - [ "$got" = "$3" ] || fail "$4: expected $2=$3, got $2=${got:-<absent>}"$'\n'"--- line ---"$'\n'"$1" -} - -# Every recorded grok invocation must be one of the two fixed, non-destructive -# argv forms. A login, logout, or bare interactive launch fails here. -assert_grok_argv_safe() { # <label> - local line - while IFS= read -r line; do - [ -n "$line" ] || continue - case "$line" in - models|--version) : ;; - *) fail "$1: unexpected Grok CLI invocation 'grok $line'" ;; - esac - done < "$RUN_GROK_LOG" -} - -assert_grok_never_ran() { # <label> - [ ! -s "$RUN_GROK_LOG" ] \ - || fail "$1: no vendor CLI may run, but grok was invoked with: $(tr '\n' '|' < "$RUN_GROK_LOG")" -} - -assert_quota_never_read() { # <label> - [ ! -s "$RUN_QUOTA_LOG" ] \ - || fail "$1: the probe must never call quota-axi, but it ran: $(tr '\n' '|' < "$RUN_QUOTA_LOG")" -} - -# --- the retired dispatch coupling ------------------------------------------ - -# The core retirement: the probe carries no candidate identity, so no -# harness-to-provider table, model prefix matcher, or provider-family mapping can -# exist behind it. Every shape of candidate input is a usage error, and none of -# them reaches a vendor CLI. -test_probe_accepts_no_candidate_identity() { - local label - local -a args - # Both shapes matter. Without a probe name, candidate identity must not stand - # in for one. WITH a valid probe name, candidate identity must still be - # refused rather than quietly accepted and ignored - a silently tolerated - # `--model` is exactly the seam a routing mapping would grow back through. - for label in harness-flag model-flag provider-flag tuple positional-model \ - probe-with-harness probe-with-model probe-with-tuple probe-with-provider; do - case "$label" in - harness-flag) args=(--harness pi) ;; - model-flag) args=(--model openai-codex/gpt-5.6-terra) ;; - provider-flag) args=(--provider codex) ;; - tuple) args=(--harness pi --model openai-codex/gpt-5.6-terra) ;; - positional-model) args=(grok openai-codex/gpt-5.6-terra) ;; - probe-with-harness) args=(grok --harness pi) ;; - probe-with-model) args=(grok --model openai-codex/gpt-5.6-terra) ;; - probe-with-tuple) args=(grok --harness pi --model openai-codex/gpt-5.6-terra) ;; - probe-with-provider) args=(grok --provider codex) ;; - esac - run_probe "identity-$label" "${args[@]}" - expect_code 2 "$RUN_RC" "$label must be a usage error, not a candidate verdict" - [ -z "$RUN_LINE" ] || fail "$label must not emit a fact line: $RUN_LINE" - assert_grok_never_ran "identity-$label" - assert_quota_never_read "identity-$label" - done - pass "the probe accepts no harness, model, or provider and so can hold no routing mapping" -} - -# The retired script read quota to decide eligibility. This one must not, so an -# intake keeps exactly one snapshot and the probe cannot re-derive a route. -test_probe_never_reads_quota() { - local mode - for mode in authenticated unauthenticated; do - run_probe "no-quota-$mode" grok -- "FM_FAKE_GROK_MODE=$mode" - assert_quota_never_read "no-quota-$mode" - done - pass "the probe never reads quota, leaving one intake snapshot to the dispatch owner" -} - -# Neither outcome is a verdict, so neither may be encoded in the exit status. A -# caller that branched on the exit status would be reinventing the eligibility -# gate this script was narrowed to remove. -test_probe_result_is_never_an_exit_status_verdict() { - local mode - for mode in authenticated unauthenticated garbage empty; do - run_probe "rc-$mode" grok -- "FM_FAKE_GROK_MODE=$mode" - expect_code 0 "$RUN_RC" "probe result '$mode' must not be encoded in the exit status" - [ -n "$RUN_LINE" ] || fail "probe result '$mode' must still print its fact line" - done - pass "every probe result exits alike because the script renders no verdict" -} - -test_unregistered_probe_is_a_usage_error() { - local name - for name in openai codex claude pi ''; do - if [ -z "$name" ]; then - run_probe "unregistered-empty" - else - run_probe "unregistered-$name" "$name" - fi - expect_code 2 "$RUN_RC" "an unregistered probe name must be a usage error" - assert_grok_never_ran "unregistered-${name:-empty}" - done - pass "only a registered probe name runs, and an unregistered one is a usage error" -} - -# --- probe classification --------------------------------------------------- - -test_authenticated_session_is_reported() { - run_probe authenticated grok -- "FM_FAKE_GROK_MODE=authenticated" - expect_code 0 "$RUN_RC" "a completed probe prints its fact" - assert_field "$RUN_LINE" probe grok "the probe name must be echoed" - assert_field "$RUN_LINE" status authenticated "an authenticated first line must be recognized" - assert_grok_argv_safe "authenticated case" - pass "an authenticated vendor session is reported as ground truth" -} - -test_unauthenticated_session_is_reported() { - run_probe unauthenticated grok -- "FM_FAKE_GROK_MODE=unauthenticated" - assert_field "$RUN_LINE" status unauthenticated "an unauthenticated first line must be recognized" - assert_grok_argv_safe "unauthenticated case" - pass "an unauthenticated vendor session is reported as ground truth" -} - -# The exit status is deliberately not the verdict, so a rewritten status line -# must read as indeterminate rather than as a successful authentication. -test_unrecognized_output_is_indeterminate() { - local mode - for mode in garbage leading-blank empty; do - run_probe "indeterminate-$mode" grok -- "FM_FAKE_GROK_MODE=$mode" - assert_field "$RUN_LINE" status indeterminate "'$mode' output must never read as authenticated" - done - pass "unrecognized, blank-led, and silent probe output is indeterminate, never authenticated" -} - -test_missing_vendor_cli_is_reported_not_assumed() { - local case_dir fakebin line rc=0 - case_dir="$TMP_ROOT/grok-absent" - mkdir -p "$case_dir" - fakebin=$(make_fakebin "$case_dir") - rm -f "$fakebin/grok" - line=$(env "PATH=$fakebin:$BASE_PATH" \ - "FM_FAKE_QUOTA_LOG=$case_dir/quota.log" \ - "$SCRIPT" grok </dev/null 2>/dev/null) || rc=$? - expect_code 0 "$rc" "an absent vendor CLI is a fact, not a usage error" - assert_field "$line" status unavailable "an absent probe command must be reported" - assert_field "$line" version none "an absent CLI has no version to report" - assert_field "$line" versionVerified none "an absent CLI cannot be version-verified" - pass "an absent vendor CLI is reported rather than assumed authenticated" -} - -# --- the bounded, non-destructive envelope ---------------------------------- - -test_hanging_probe_is_bounded_and_reported() { - local started finished - started=$(date +%s) - run_probe grok-hang grok -- "FM_FAKE_GROK_MODE=hang" "FM_VENDOR_AUTH_PROBE_TIMEOUT=2" - finished=$(date +%s) - assert_field "$RUN_LINE" status timeout "a hit bound must be reported as a timeout" - [ $((finished - started)) -lt 25 ] \ - || fail "the probe was not bounded: took $((finished - started))s against a 2s bound" - pass "a hanging vendor CLI is hard-bounded, reported, and cannot wedge an intake" -} - -# `timeout 0` and the Perl fallback's `alarm 0` both mean "no deadline", so a -# zero bound passed through would silently remove the hard bound entirely. The -# fake hangs for 30s, longer than the 20s default it must fall back to, so the -# two outcomes are distinguishable. -test_zero_bound_falls_back_to_a_real_bound() { - local started finished value - for value in 0 00; do - started=$(date +%s) - run_probe "bound-zero-$value" grok -- "FM_FAKE_GROK_MODE=hang" "FM_VENDOR_AUTH_PROBE_TIMEOUT=$value" - finished=$(date +%s) - assert_field "$RUN_LINE" status timeout "a zero bound must fall back to the default bound, not to no bound" - [ $((finished - started)) -lt 28 ] \ - || fail "a zero bound removed the hard bound: took $((finished - started))s" - done - pass "zero and all-zero bounds fall back to the default instead of removing the hard bound" -} - -# A bogus bound must be replaced, not forwarded: `timeout abc` and `timeout -1` -# fail outright, which would turn a healthy probe into a false indeterminate. -test_malformed_bound_is_replaced_not_forwarded() { - local value - for value in -1 abc 1.5 ' '; do - run_probe "bound-${value// /space}" grok -- "FM_FAKE_GROK_MODE=authenticated" "FM_VENDOR_AUTH_PROBE_TIMEOUT=$value" - assert_field "$RUN_LINE" status authenticated "bound '$value' must be replaced, not forwarded to the bounding command" - done - pass "a malformed bound is replaced by the default rather than forwarded" -} - -test_probe_never_inherits_caller_stdin() { - run_probe grok-stdin grok -- "FM_FAKE_GROK_MODE=authenticated" - [ -n "$RUN_LINE" ] || fail "expected a fact line" - [ ! -s "$RUN_GROK_STDIN" ] \ - || fail "the probe inherited caller stdin: $(cat "$RUN_GROK_STDIN")" - pass "the bounded probe runs with stdin closed and cannot read caller input" -} - -test_probe_argv_is_fixed_and_non_destructive() { - local mode - for mode in authenticated unauthenticated garbage; do - run_probe "argv-$mode" grok -- "FM_FAKE_GROK_MODE=$mode" - assert_grok_argv_safe "argv-$mode" - [ "$(grep -c . "$RUN_GROK_LOG")" -eq 2 ] \ - || fail "argv-$mode: expected exactly one --version and one models call, got: $(tr '\n' '|' < "$RUN_GROK_LOG")" - done - pass "the vendor CLI is invoked only through its two fixed, non-destructive argv forms" -} - -test_fact_line_carries_no_vendor_output_or_credential_material() { - run_probe sanitized grok -- "FM_FAKE_GROK_MODE=authenticated" - assert_not_contains "$RUN_LINE" "You are logged in" "the fact line must not echo raw vendor output" - assert_not_contains "$RUN_LINE" "grok.com" "the fact line must not echo raw vendor output" - assert_not_contains "$RUN_LINE" "auth.json" "the fact line must not name a credential path" - assert_not_contains "$RUN_LINE" "$STDIN_SENTINEL" "the fact line must not echo caller stdin" - case "$RUN_LINE" in - *$'\n'*) fail "the fact line must be exactly one line" ;; - esac - pass "the fact line is one sanitized line with no raw vendor output or credential material" -} - -# --- version disclosure ----------------------------------------------------- - -# The discriminator strings are un-owned vendor UI text. A version change does -# not silently invalidate the fact, but it is disclosed so it can be re-verified. -test_probe_version_change_is_disclosed() { - run_probe version-drift grok -- "FM_FAKE_GROK_MODE=authenticated" "FM_FAKE_GROK_VERSION=0.9.0" - assert_field "$RUN_LINE" version 0.9.0 "the probed CLI version must be recorded" - assert_field "$RUN_LINE" versionVerified no "an unverified version must be disclosed" - pass "a vendor CLI version change is recorded and disclosed for re-verification" -} - -test_probe_version_match_is_recorded() { - run_probe version-pinned grok -- "FM_FAKE_GROK_MODE=authenticated" - assert_field "$RUN_LINE" versionVerified yes "the pinned verified version must be recognized" - pass "the pinned verified vendor version is recognized" -} - -test_help_succeeds_and_names_the_registered_probes() { - local out rc=0 - out=$("$SCRIPT" --help 2>&1) || rc=$? - expect_code 0 "$rc" "--help must succeed" - assert_contains "$out" "grok" "--help must name the registered probes" - pass "--help succeeds and names the registered probes" -} - -test_probe_accepts_no_candidate_identity -test_probe_never_reads_quota -test_probe_result_is_never_an_exit_status_verdict -test_unregistered_probe_is_a_usage_error -test_authenticated_session_is_reported -test_unauthenticated_session_is_reported -test_unrecognized_output_is_indeterminate -test_missing_vendor_cli_is_reported_not_assumed -test_hanging_probe_is_bounded_and_reported -test_zero_bound_falls_back_to_a_real_bound -test_malformed_bound_is_replaced_not_forwarded -test_probe_never_inherits_caller_stdin -test_probe_argv_is_fixed_and_non_destructive -test_fact_line_carries_no_vendor_output_or_credential_material -test_probe_version_change_is_disclosed -test_probe_version_match_is_recorded -test_help_succeeds_and_names_the_registered_probes From 6aed90595a397f4358397a04f65b61873dc3cb46 Mon Sep 17 00:00:00 2001 From: quinnbot-ai <quinnbot.ai@gmail.com> Date: Sun, 2 Aug 2026 14:41:10 -0700 Subject: [PATCH 26/70] fix(bin): preserve ignored runtime churn safely (#77) * fix(bin): classify ignored runtime churn as info * no-mistakes(review): Preserve index on pre-merge preservation failures * no-mistakes(document): Clarify ignored-path preservation semantics --------- Co-authored-by: QuinnBot <quinnbot@proton.me> --- bin/fm-merge-local.sh | 50 +++++++++--- tests/fm-merge-local.test.sh | 145 +++++++++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+), 12 deletions(-) diff --git a/bin/fm-merge-local.sh b/bin/fm-merge-local.sh index 67e43bed01a..8b97e173529 100755 --- a/bin/fm-merge-local.sh +++ b/bin/fm-merge-local.sh @@ -14,11 +14,13 @@ # core.ignoreCase semantics; info/exclude and global excludes do not count. # An ignored directory is allowed only when the target head tracks nothing under # its prefix. Unresolved paths refuse before advancing; the first 50 receive -# per-path diagnostics, followed by a +N summary for any remainder. Proven -# ignored files are retained byte-for-byte; proven ignored symlinks retain their -# readlink targets; proven ignored directories remain directories without -# walking or hashing their contents. Other present entry types and entries that -# cannot be inspected refuse. Every previously dirty path is then verified clean. +# per-path diagnostics, followed by a +N summary for any remainder. An ignored +# file or symlink that the merge moves aside must be restored with its exact blob +# bytes or readlink target. Proven ignored directories remain directories without +# walking or hashing their contents. A changed-but-still-present ignored entry +# that the merge did not move aside is reported as live-runtime churn, because +# the target tree cannot touch it; a lost or unreadable entry still fails loudly. +# Other present entry types refuse. Every previously dirty path is then verified clean. # A preservation or cleanliness failure after the fast-forward is reported # without attempting rollback. Diverged branches still refuse and require the # crewmate to rebase. See AGENTS.md prime directives, project management, and @@ -437,6 +439,19 @@ move_preserved_path_out_of_merge() { fi } +preserved_path_was_moved_out_of_merge() { + local moved_path path=$1 + for moved_path in "${MOVED_PATHS[@]}"; do + [ "$moved_path" = "$path" ] && return 0 + done + return 1 +} + +report_ignored_runtime_churn() { + printf 'info: fast-forward completed, but ignored path %q changed during the merge window; the target tree ignores it and did not require a preservation move\n' \ + "$1" +} + if [ "${#DIRTY_PATHS[@]}" -gt 0 ]; then if ! init_target_view; then echo "error: could not construct the $BRANCH ignore view; refusing to merge a dirty checkout" >&2 @@ -652,6 +667,12 @@ if ! git -C "$PROJ" merge-base --is-ancestor "$DEFAULT" "$BRANCH"; then exit 1 fi +for ((i = 0; i < ${#RESOLVED_PATHS[@]}; i++)); do + if [ "${RESOLVED_ACTIONS[$i]}" = "remove-index" ]; then + move_preserved_path_out_of_merge "${RESOLVED_PATHS[$i]}" + fi +done + for ((i = 0; i < ${#RESOLVED_PATHS[@]}; i++)); do path=${RESOLVED_PATHS[$i]} case "${RESOLVED_ACTIONS[$i]}" in @@ -661,7 +682,6 @@ for ((i = 0; i < ${#RESOLVED_PATHS[@]}; i++)); do ;; remove-index) git -C "$PROJ" update-index --force-remove -- "$path" - move_preserved_path_out_of_merge "$path" ;; remove-index-preserve-directory) git -C "$PROJ" update-index --force-remove -- "$path" @@ -706,9 +726,12 @@ for ((i = 0; i < ${#PRESERVE_PATHS[@]}; i++)); do exit 1 fi if [ "$preserved_oid" != "${PRESERVE_OIDS[$i]}" ]; then - printf 'error: fast-forward completed, but ignored symlink %q changed target\n' \ - "$path" >&2 - exit 1 + if preserved_path_was_moved_out_of_merge "$path"; then + printf 'error: fast-forward completed, but ignored symlink %q changed target\n' \ + "$path" >&2 + exit 1 + fi + report_ignored_runtime_churn "$path" fi continue fi @@ -718,9 +741,12 @@ for ((i = 0; i < ${#PRESERVE_PATHS[@]}; i++)); do exit 1 fi if [ "$preserved_oid" != "${PRESERVE_OIDS[$i]}" ]; then - printf 'error: fast-forward completed, but ignored path %q changed from blob %s to blob %s\n' \ - "$path" "${PRESERVE_OIDS[$i]}" "$preserved_oid" >&2 - exit 1 + if preserved_path_was_moved_out_of_merge "$path"; then + printf 'error: fast-forward completed, but ignored path %q changed from blob %s to blob %s\n' \ + "$path" "${PRESERVE_OIDS[$i]}" "$preserved_oid" >&2 + exit 1 + fi + report_ignored_runtime_churn "$path" fi done diff --git a/tests/fm-merge-local.test.sh b/tests/fm-merge-local.test.sh index 616366c68e3..5a2f88dbb12 100755 --- a/tests/fm-merge-local.test.sh +++ b/tests/fm-merge-local.test.sh @@ -29,6 +29,9 @@ # (w) NUL bytes in a staged symlink blob cannot collapse during comparison # (x) nested directly ignored directories are preserved without descending # (y) current parent ignores cannot hide a narrower target directory boundary +# (z) untouched ignored runtime-file churn during the merge window is informational +# (aa) an ignored path that disappears after the fast-forward still fails loudly +# (ab) a pre-merge preservation failure leaves the checkout index unchanged set -u # shellcheck disable=SC1091 @@ -72,6 +75,39 @@ run_merge_local() { "$MERGE_LOCAL" task-x1 } +run_merge_local_with_post_merge_mutation() { + local case_dir=$1 fakebin mutation=$2 real_git + fakebin=$(fm_fakebin "$case_dir") + real_git=$(command -v git) + cat >"$fakebin/git" <<'SH' +#!/usr/bin/env bash +set -eu + +if [ "${1:-}" = -C ] && [ "${2:-}" = "$FM_MERGE_MUTATION_PROJECT" ] \ + && [ "${3:-}" = merge ] && [ "${4:-}" = --ff-only ]; then + "$FM_REAL_GIT" "$@" + case "$FM_MERGE_MUTATION" in + rewrite) + printf 'runtime state rewritten during merge\n' >"$FM_MERGE_MUTATION_PATH" + ;; + remove) + rm -f -- "$FM_MERGE_MUTATION_PATH" + ;; + esac + exit 0 +fi + +exec "$FM_REAL_GIT" "$@" +SH + chmod +x "$fakebin/git" + FM_REAL_GIT="$real_git" \ + FM_MERGE_MUTATION="$mutation" \ + FM_MERGE_MUTATION_PROJECT="$case_dir/project" \ + FM_MERGE_MUTATION_PATH="$case_dir/project/state/breaking-watch-last-run" \ + PATH="$fakebin:$PATH" \ + run_merge_local "$case_dir" +} + assert_main_reached_branch() { local case_dir=$1 [ "$(git -C "$case_dir/project" rev-parse main)" = \ @@ -237,6 +273,112 @@ test_branch_ignored_untracked_file_permits() { pass "fm-merge-local permits an untracked path ignored at the branch head" } +test_branch_ignored_runtime_churn_during_merge_is_info() { + local case_dir + case_dir=$(make_case ignored-runtime-churn) + printf 'state/breaking-watch-last-run\n' >"$case_dir/branch/.gitignore" + printf 'incoming change\n' >"$case_dir/branch/incoming.txt" + git -C "$case_dir/branch" add .gitignore incoming.txt + git -C "$case_dir/branch" commit -qm "ignore live runtime state" + mkdir -p "$case_dir/project/state" + printf 'runtime state before merge\n' \ + >"$case_dir/project/state/breaking-watch-last-run" + + run_merge_local_with_post_merge_mutation "$case_dir" rewrite \ + >"$case_dir/stdout" 2>"$case_dir/stderr" \ + || fail "ignored-runtime-churn: merge should succeed despite live runtime churn" + + assert_main_reached_branch "$case_dir" \ + "ignored-runtime-churn: main did not fast-forward to the task branch" + assert_grep 'info: fast-forward completed, but ignored path state/breaking-watch-last-run changed during the merge window' \ + "$case_dir/stdout" \ + "ignored-runtime-churn: live churn was not classified as info" + assert_grep 'runtime state rewritten during merge' \ + "$case_dir/project/state/breaking-watch-last-run" \ + "ignored-runtime-churn: live runtime rewrite did not occur" + if grep -q 'fast-forward completed, but ignored path' "$case_dir/stderr"; then + fail "ignored-runtime-churn: live churn was still reported as an error" + fi + pass "fm-merge-local reports untouched ignored runtime churn as info" +} + +test_branch_ignored_runtime_loss_after_merge_still_errors() { + local case_dir rc + case_dir=$(make_case ignored-runtime-loss) + printf 'state/breaking-watch-last-run\n' >"$case_dir/branch/.gitignore" + printf 'incoming change\n' >"$case_dir/branch/incoming.txt" + git -C "$case_dir/branch" add .gitignore incoming.txt + git -C "$case_dir/branch" commit -qm "ignore live runtime state" + mkdir -p "$case_dir/project/state" + printf 'runtime state before merge\n' \ + >"$case_dir/project/state/breaking-watch-last-run" + + set +e + run_merge_local_with_post_merge_mutation "$case_dir" remove \ + >"$case_dir/stdout" 2>"$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "ignored-runtime-loss: missing ignored path should still fail" + assert_main_reached_branch "$case_dir" \ + "ignored-runtime-loss: fast-forward did not complete before the loud preservation failure" + assert_grep 'ignored path state/breaking-watch-last-run was blob ' \ + "$case_dir/stderr" \ + "ignored-runtime-loss: loss did not identify the preserved blob" + assert_grep 'before the merge and is now absent or unreadable' \ + "$case_dir/stderr" \ + "ignored-runtime-loss: loss was not reported loudly" + pass "fm-merge-local still errors when an ignored runtime path is lost" +} + +test_preservation_failure_before_merge_leaves_index_unchanged() { + local after_hash before before_hash before_index case_dir fakebin rc real_mv + case_dir=$(make_case preservation-failure-index) + printf 'runtime-state.txt\n' >"$case_dir/branch/.gitignore" + printf 'resolved target\n' >"$case_dir/branch/resolved.txt" + git -C "$case_dir/branch" add .gitignore resolved.txt + git -C "$case_dir/branch" rm -q runtime-state.txt + git -C "$case_dir/branch" commit -qm "ignore runtime state" + printf 'resolved target\n' >"$case_dir/project/resolved.txt" + printf 'runtime base\nruntime append\n' >"$case_dir/project/runtime-state.txt" + before=$(git -C "$case_dir/project" rev-parse main) + before_hash=$(git -C "$case_dir/project" hash-object -- runtime-state.txt) + before_index=$(git -C "$case_dir/project" write-tree) + + fakebin=$(fm_fakebin "$case_dir") + real_mv=$(command -v mv) + cat >"$fakebin/mv" <<'SH' +#!/usr/bin/env bash +set -eu + +if [ "${1:-}" = -- ] && [ "${2:-}" = "$FM_FAIL_MOVE_PATH" ]; then + printf 'injected preservation move failure\n' >&2 + exit 73 +fi + +exec "$FM_REAL_MV" "$@" +SH + chmod +x "$fakebin/mv" + + set +e + FM_FAIL_MOVE_PATH="$case_dir/project/runtime-state.txt" \ + FM_REAL_MV="$real_mv" \ + PATH="$fakebin:$PATH" \ + run_merge_local "$case_dir" >"$case_dir/stdout" 2>"$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "preservation-failure-index: injected move failure should stop the merge" + [ "$(git -C "$case_dir/project" rev-parse main)" = "$before" ] \ + || fail "preservation-failure-index: preservation failure advanced main" + [ "$(git -C "$case_dir/project" write-tree)" = "$before_index" ] \ + || fail "preservation-failure-index: preservation failure changed the index" + after_hash=$(git -C "$case_dir/project" hash-object -- runtime-state.txt) + [ "$after_hash" = "$before_hash" ] \ + || fail "preservation-failure-index: preservation failure changed runtime content" + pass "fm-merge-local preserves the index when pre-merge preservation fails" +} + test_branch_ignored_untracked_directory_permits_without_descending() { local case_dir before_hash after_hash case_dir=$(make_case ignored-directory) @@ -978,6 +1120,9 @@ test_untracked_conversion_permits_and_preserves_file test_untracked_conversion_refuses_divergent_staged_content test_untracked_conversion_refuses_divergent_staged_mode test_branch_ignored_untracked_file_permits +test_branch_ignored_runtime_churn_during_merge_is_info +test_branch_ignored_runtime_loss_after_merge_still_errors +test_preservation_failure_before_merge_leaves_index_unchanged test_branch_ignored_untracked_directory_permits_without_descending test_nested_branch_ignored_untracked_file_permits test_branch_ignored_directory_with_incoming_collision_refuses From 0434b0364dbd10604732cd1bbe8cc0dd6fd7d269 Mon Sep 17 00:00:00 2001 From: quinnbot-ai <quinnbot.ai@gmail.com> Date: Sun, 2 Aug 2026 14:55:59 -0700 Subject: [PATCH 27/70] feat(link-intake): auto-prepare an ingest scout for captain links (#78) * feat(bin): prepare a queued ingest scout for every retrievable captain link Recording a retrievable link now also prepares one ingest scout for it: a scout brief scaffolded through fm-brief.sh and a queued backlog item filed through the configured backlog backend. The scout's instructions have it ingest the source with the existing browser or media tool, keep the ingested text as a durable artifact beside its report, report what the fleet can build from it along with out-of-the-box ideas, blindspots, and other angles mapped to current lanes, and file promising ideas as further queued backlog items. Preparation stops there. Nothing is spawned, started, or dispatched, so firstmate keeps dispatch authority, spawn safety, dispatch-profile consultation, and capacity judgment. The scout task id derives from the canonical URL alone, so re-recording the same link - including the scout's own transcript-retaining upsert - converges on the one brief and backlog item instead of queueing a second scout under a changed title. An inaccessible record prepares no scout, --no-scout skips preparation for one invocation, and prepare-scout prepares or repairs one for a stored record. Preparation runs after publication, so a failure leaves the record stored and exits 3 with the repair command. * no-mistakes(review): Fix link-scout contracts, quoting, routing, and backend failures * no-mistakes(review): Route promising ideas to their mapped lanes * no-mistakes(document): Document link-scout project write boundary * no-mistakes(lint): Suppress intentional SC2016 warnings in link intake tests --------- Co-authored-by: QuinnBot <quinnbot@proton.me> --- AGENTS.md | 2 +- README.md | 2 +- bin/fm-link-intake.sh | 293 ++++++++++++++++++++++++++++++- docs/link-intake.md | 11 ++ docs/scripts.md | 2 +- docs/verification/link-intake.md | 12 +- tests/fm-link-intake.test.sh | 231 +++++++++++++++++++++++- 7 files changed, 544 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d3da64e4aef..b227a18c238 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -245,7 +245,7 @@ Send in-scope work to the fitting secondmate unless it is blocked or the captain If no secondmate scope fits, use the main home or discuss creating an appropriate persistent secondmate. For one-off or infrequent operational work, start with the simplest direct end-to-end path. Do not build wrappers, control planes, policy layers, custom verifiers, or automation unless the direct path exposes a concrete blocker or repeated need that justifies the added machinery. -Link intake: whenever the captain sends meaningful URL input, inspect each link with the suitable existing browser or media tool and record its normalized result or visible failure through `bin/fm-link-intake.sh`. +Link intake: whenever the captain sends meaningful URL input, inspect each link with the suitable existing browser or media tool and record its normalized result or visible failure through `bin/fm-link-intake.sh`, which also prepares one queued ingest scout per retrievable link for you to dispatch under the ordinary intake rules. Before commissioning an investigation, consult existing reports and established evidence. Classify the deliverable: diff --git a/README.md b/README.md index 7a92868f240..650f9f6694b 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Launching a supported harness inside it instantiates your first mate - and makes - **A visible crew** - every crewmate works in its own tmux window, experimental herdr/zellij tab, cmux workspace, or Orca terminal you can watch or type into; the first mate reconciles. - **Disposable worktrees** - each task runs in a clean [treehouse](https://github.com/kunchenguid/treehouse) git worktree, or an Orca-managed worktree when `backend=orca`, so parallel work on one repo never collides. - **Two task shapes** - ship tasks deliver authorized changes; scout tasks leave standalone investigation reports when the intake contract warrants separate research. -- **Durable link intake** - meaningful URLs are inspected and retained privately as searchable evidence without granting external-action authority. +- **Durable link intake** - meaningful URLs are inspected and retained privately as searchable evidence without granting external-action authority, and each retrievable link also prepares a queued ingest scout that only firstmate may dispatch. - **Explicit project modes** - each project ships via `no-mistakes`, `direct-PR`, or `local-only`, with an optional `+yolo` autonomy flag. - **Optional secondmates** - opt in to persistent second mates that run from isolated firstmate homes with their own `FM_HOME`, state, projects, and session lock, supervising project clones or a project-less firstmate-repo domain, kept on the primary firstmate version by guarded local fast-forwards and checked for live agent processes at session start. - **Event-driven, zero-token supervision** - a bash watcher sleeps on the fleet and wakes the first mate only when something needs you; verified primary harnesses also get a turn-end backstop that blocks or follows up on a blind stop when work is under way and supervision is not live. diff --git a/bin/fm-link-intake.sh b/bin/fm-link-intake.sh index 7f6077585d8..29abfc74694 100755 --- a/bin/fm-link-intake.sh +++ b/bin/fm-link-intake.sh @@ -2,7 +2,8 @@ # fm-link-intake.sh - idempotently store validated, private Firstmate link-intake records. # # Usage: -# bin/fm-link-intake.sh upsert --url URL --source-type TYPE --title TITLE --summary SUMMARY --terms TERMS --claim TEXT [--claim TEXT ...] [--canonical-url URL] [--retrieved-at YYYY-MM-DD] [--transcript-file PATH | --transcript-unavailable REASON] [--failure REASON] +# bin/fm-link-intake.sh upsert --url URL --source-type TYPE --title TITLE --summary SUMMARY --terms TERMS --claim TEXT [--claim TEXT ...] [--canonical-url URL] [--retrieved-at YYYY-MM-DD] [--transcript-file PATH | --transcript-unavailable REASON] [--failure REASON] [--no-scout] [--scout-repo NAME] +# bin/fm-link-intake.sh prepare-scout --url URL [--scout-repo NAME] # bin/fm-link-intake.sh validate <URL> # bin/fm-link-intake.sh validate --all # bin/fm-link-intake.sh --help @@ -19,6 +20,19 @@ # Video and audio require either a transcript file to retain privately or an explicit # unavailable reason, unless the retrieval itself failed, in which case that failure is # recorded as the transcript reason too. +# +# Storing a retrievable record also prepares one queued ingest scout for that link: +# a scout brief scaffolded through bin/fm-brief.sh and a queued backlog item filed +# through the configured backlog backend. It never spawns, dispatches, or starts the +# scout, so firstmate keeps dispatch authority, spawn safety, dispatch-profile +# consultation, and capacity judgment. `--no-scout` skips preparation for this +# invocation, and `prepare-scout` prepares or repairs one for an already-stored record. +# The scout task id is derived from the canonical URL alone, so repeated intake of the +# same link converges on the same brief and backlog item instead of duplicating work. +# An inaccessible record prepares no scout because there is nothing to ingest. +# Scout preparation happens after the record is published: when it fails, the record is +# still stored and the command exits 3 so the failure is visible and repairable. Explicit +# manual mode prints the item to add; an unavailable tasks-axi backend fails preparation. # Bash 3.2 compatible. set -u @@ -29,6 +43,7 @@ LINK_ROOT="$FM_HOME/data/link-intake" GENERATIONS_DIR="$LINK_ROOT/generations" CURRENT_LINK="$LINK_ROOT/current" LOCK_DIR="$LINK_ROOT/.update-lock" +SCOUT_REPO_DEFAULT=firstmate LOCK_HELD=0 LOCK_OWNER_DIR= STAGED_GENERATION= @@ -36,6 +51,9 @@ STAGED_LINK= CURRENT_GENERATION= QUIET_CURRENT_GENERATION= +# shellcheck source=bin/fm-tasks-axi-lib.sh disable=SC1091 +. "$SCRIPT_DIR/fm-tasks-axi-lib.sh" + remove_generation() { local generation=$1 case "$generation" in @@ -124,18 +142,29 @@ die() { usage() { cat <<'EOF' Usage: - bin/fm-link-intake.sh upsert --url URL --source-type TYPE --title TITLE --summary SUMMARY --terms TERMS --claim TEXT [--claim TEXT ...] [--canonical-url URL] [--retrieved-at YYYY-MM-DD] [--transcript-file PATH | --transcript-unavailable REASON] [--failure REASON] + bin/fm-link-intake.sh upsert --url URL --source-type TYPE --title TITLE --summary SUMMARY --terms TERMS --claim TEXT [--claim TEXT ...] [--canonical-url URL] [--retrieved-at YYYY-MM-DD] [--transcript-file PATH | --transcript-unavailable REASON] [--failure REASON] [--no-scout] [--scout-repo NAME] + bin/fm-link-intake.sh prepare-scout --url URL [--scout-repo NAME] bin/fm-link-intake.sh validate <URL> bin/fm-link-intake.sh validate --all Source types: article, web, video, audio, document, image, other. `upsert` preserves every supplied original URL, snapshots a replaced record under -data/link-intake/current/history/, and prints the current record path. +data/link-intake/current/history/, and prints the current record path as its first +line; any scout-preparation line follows it. `--failure` creates a visible inaccessible record when title, summary, claims, and terms cannot be obtained. For video or audio, provide a legally accessible `--transcript-file` or explain why it is unavailable with `--transcript-unavailable`. + +A retrievable record also prepares one queued ingest scout: a brief at +data/<task-id>/brief.md and a queued backlog item. Preparation never spawns or +dispatches anything - firstmate decides what runs and when. `--no-scout` skips it +for this invocation, `--scout-repo` names the repo the scout works in (default +firstmate), and `prepare-scout` prepares or repairs one for a stored record. +Preparation is idempotent per canonical URL and exits 3 when the record was stored +but its scout could not be prepared. Explicit manual backlog mode prints the item +to add by hand; a missing or incompatible tasks-axi backend fails preparation. EOF } @@ -588,9 +617,254 @@ publish_generation() { STAGED_GENERATION= } +record_field() { + local field=$1 record=$2 + sed -n "s/^$field: //p" "$record" | head -1 +} + +slugify() { + printf '%s' "$1" | + LC_ALL=C tr '[:upper:]' '[:lower:]' | + LC_ALL=C sed -e 's/[^a-z0-9]\{1,\}/-/g' -e 's/^-*//' -e 's/-*$//' +} + +# The scout task id is derived from the canonical URL alone so that re-recording the +# same link - including the scout's own transcript-retaining upsert - converges on the +# one brief and backlog item instead of queueing a second scout under a changed title. +scout_task_id_for() { + local canonical=$1 slug digest + slug=$(slugify "$(url_host "$canonical")" | cut -c1-24 | LC_ALL=C sed -e 's/-*$//') + [ -n "$slug" ] || slug='link' + digest=$(record_id_for "$canonical" | cut -c1-8) + printf 'ingest-%s-%s\n' "$slug" "$digest" +} + +scout_error() { + printf 'error: %s\n' "$*" >&2 + return 1 +} + +shell_quote() { + printf "'" + printf '%s' "$1" | sed "s/'/'\\\\''/g" + printf "'" +} + +scout_task_text() { + local record=$1 canonical=$2 task_id=$3 data=$4 + local title summary source terms helper_q home_q canonical_q source_q source_file_q + local lane_repo_q backlog_q success_command failure_command idea_command + title=$(record_field Title "$record") + summary=$(record_field Summary "$record") + source=$(record_field 'Source type' "$record") + terms=$(record_field 'Search terms' "$record") + helper_q=$(shell_quote "$SCRIPT_DIR/fm-link-intake.sh") + home_q=$(shell_quote "$FM_HOME") + canonical_q=$(shell_quote "$canonical") + source_q=$(shell_quote "$source") + source_file_q=$(shell_quote "$data/$task_id/source.txt") + lane_repo_q=$(shell_quote '<mapped-lane-repo>') + backlog_q=$(shell_quote "$data/backlog.md") + success_command="FM_HOME=$home_q $helper_q upsert --url $canonical_q --source-type $source_q --title '<title>' --summary '<summary>' --terms '<terms>' --claim '<claim>' --transcript-file $source_file_q --no-scout" + failure_command="FM_HOME=$home_q $helper_q upsert --url $canonical_q --source-type $source_q --failure '<reason>' --no-scout" + idea_command="tasks-axi add '<slug-id>' '<title>' --kind ship --repo $lane_repo_q --queue --file $backlog_q --body $(shell_quote "from $task_id: <one line>; see $data/$task_id/report.md")" + cat <<EOF +Ingest scout for a link the captain sent: $title + +- Source: $canonical +- Source type: $source +- Recorded summary: $summary +- Recorded search terms: $terms +- Link-intake record: $(logical_record_path_for "$canonical") + +Do these three things, in order. + +1. Ingest the source with the existing tool suited to it: \`chrome-devtools-axi\` for + pages and documents, the existing media transcript tooling for video or audio. + Store the full ingested text as a durable artifact at \`$data/$task_id/source.txt\`. + That file survives cleanup alongside the report, so every claim in the report must be + traceable to it; never leave the only copy in a temporary directory. + For video or audio, also retain it in the searchable record: + \`$success_command\` + Keep the existing title, summary, terms, and claims unless the ingest corrects them. + If the source cannot be retrieved, record the visible failure with + \`$failure_command\` + and report what you tried; do not guess at the content. + +2. Write the report at \`$data/$task_id/report.md\`, citing the stored artifact for + every factual claim about the source. Cover these four sections: + - **What we can build from it** - concrete, buildable things, each mapped to a current + lane in \`$data/projects.md\` and the captain's working doctrine in \`$data/captain.md\`. + - **Out-of-the-box ideas** - angles the source does not state outright but that its + material genuinely supports. + - **Potential blindspots** - what the source omits, overstates, or gets wrong, and + where following it would cost us. + - **Other angles worth taking** - adjacent framings, and lanes this does not fit. + Keep what the source actually says separate from what you recommend, and leave out + anything the artifact does not support. + +3. File each promising idea as its own queued backlog item, one per idea: + \`$idea_command\` + Replace \`<mapped-lane-repo>\` with the repository for the current lane that idea is mapped to in the report. + Use \`--kind scout\` for an idea that still needs investigation before it can ship. + Queue them only. Never start, dispatch, promote, or spawn anything, and never edit a + project: firstmate decides what gets worked and when. List every item you filed in + the report so the queue and the report agree. +EOF +} + +specialize_link_intake_brief() { + local brief=$1 task_id=$2 data=$3 directory tmp + directory=${brief%/*} + tmp=$(mktemp "$directory/.brief-contract.XXXXXX") || return 1 + if ! awk -v task_dir="$data/$task_id/" -v status="$FM_HOME/state/$task_id.status" \ + -v helper="$SCRIPT_DIR/fm-link-intake.sh" -v home="$FM_HOME" -v backlog="$data/backlog.md" ' + $0 == "The report is the only thing that survives, so anything worth keeping must be in it." { + print "The durable source artifacts, report, link-intake record, and queued backlog items survive teardown." + print "Scratch work in the disposable worktree does not." + next + } + $0 == "2. Stay inside this worktree; the only files you may write outside it are the report and the status file below." { + print "2. Stay inside this worktree except for the authoritative-home outputs named below." + print " Outside it, you may write only under `" task_dir "`, the status file `" status "`, link-intake record updates performed through `" helper "` with explicit `FM_HOME=" home "`, and queued backlog items filed through `tasks-axi` in `" backlog "`." + print " Every other file must stay inside the disposable worktree." + next + } + { print } + ' "$brief" > "$tmp"; then + rm -f "$tmp" + return 1 + fi + mv "$tmp" "$brief" || { rm -f "$tmp"; return 1; } +} + +fill_brief_task() { + local brief=$1 task=$2 directory tmp + directory=${brief%/*} + tmp=$(mktemp "$directory/.brief.XXXXXX") || return 1 + if ! awk -v taskfile="$task" ' + $0 == "# Task" { + print + while ((getline line < taskfile) > 0) print line + close(taskfile) + replaced = 1 + replacing = 1 + next + } + replacing && /^# Herdr / { + print "" + replacing = 0 + print + next + } + replacing { next } + { print } + END { if (!replaced || replacing) exit 1 } + ' "$brief" > "$tmp"; then + rm -f "$tmp" + return 1 + fi + mv "$tmp" "$brief" || { rm -f "$tmp"; return 1; } +} + +file_scout_backlog_item() { + local task_id=$1 title=$2 canonical=$3 brief=$4 data=$5 repo=$6 + local backlog output + backlog="$data/backlog.md" + if fm_backlog_backend_manual "$FM_HOME/config"; then + printf 'manual backlog: add queued scout item %s "Ingest: %s" for %s to %s with repo %s\n' \ + "$task_id" "$title" "$canonical" "$backlog" "$repo" + return 0 + fi + if ! fm_tasks_axi_backend_available "$FM_HOME/config"; then + scout_error 'the configured tasks-axi backlog backend is unavailable or incompatible' + return 1 + fi + if tasks-axi show "$task_id" --file "$backlog" >/dev/null 2>&1; then + output=$(tasks-axi update "$task_id" --repo "$repo" --file "$backlog" 2>&1) \ + || { scout_error "could not repair the ingest scout backlog repository: $output"; return 1; } + else + output=$(tasks-axi add "$task_id" "Ingest: $title" --kind scout --repo "$repo" --queue --file "$backlog" \ + --body "queued by link intake for $canonical; brief $brief; report $data/$task_id/report.md" 2>&1) \ + || { scout_error "could not file the queued ingest scout backlog item: $output"; return 1; } + fi + printf 'scout backlog: %s queued in %s\n' "$task_id" "$backlog" +} + +prepare_scout() { + local generation=$1 canonical=$2 repo=$3 + local id record status title task_id data brief output task_tmp + id=$(record_id_for "$canonical") + record="$generation/records/$id.md" + if [ ! -f "$record" ]; then + scout_error "link record is absent: $canonical" + return 1 + fi + status=$(record_field 'Retrieval status' "$record") + if [ "$status" != captured ]; then + scout_error "an inaccessible link record has nothing to ingest: $canonical" + return 1 + fi + title=$(record_field Title "$record") + task_id=$(scout_task_id_for "$canonical") + data="$FM_HOME/data" + brief="$data/$task_id/brief.md" + if [ ! -f "$brief" ]; then + output=$(FM_HOME="$FM_HOME" "$SCRIPT_DIR/fm-brief.sh" "$task_id" "$repo" --scout 2>&1) \ + || { scout_error "could not scaffold the ingest scout brief: $output"; return 1; } + fi + specialize_link_intake_brief "$brief" "$task_id" "$data" \ + || { scout_error 'could not apply the link-intake scout write contract'; return 1; } + task_tmp=$(mktemp "$data/$task_id/.task.XXXXXX") \ + || { scout_error 'could not stage the ingest scout task text'; return 1; } + if ! scout_task_text "$record" "$canonical" "$task_id" "$data" > "$task_tmp"; then + rm -f "$task_tmp" + scout_error 'could not write the ingest scout task text' + return 1 + fi + if ! fill_brief_task "$brief" "$task_tmp"; then + rm -f "$task_tmp" + scout_error 'could not fill the ingest scout brief' + return 1 + fi + rm -f "$task_tmp" + grep -F -x -q '{TASK}' "$brief" \ + && { scout_error "the ingest scout brief still has an unfilled task: $brief"; return 1; } + grep -F -q "$canonical" "$brief" \ + || { scout_error "the ingest scout brief does not name its source link: $brief"; return 1; } + grep -F -q "Outside it, you may write only under \`$data/$task_id/\`" "$brief" \ + || { scout_error "the ingest scout brief lacks its authoritative-home write contract: $brief"; return 1; } + file_scout_backlog_item "$task_id" "$title" "$canonical" "$brief" "$data" "$repo" || return 1 + printf 'scout: %s prepared, not dispatched\n' "$task_id" + printf 'scout brief: %s\n' "$brief" +} + +command_prepare_scout() { + local canonical='' original='' repo=$SCOUT_REPO_DEFAULT + while [ "$#" -gt 0 ]; do + case "$1" in + --url) shift; original=${1:-} ;; + --scout-repo) shift; repo=${1:-} ;; + -h|--help|help) usage; return 0 ;; + *) die "unknown argument: $1" ;; + esac + [ "$#" -gt 0 ] || die 'missing value for option' + shift + done + require_one_line URL "$original" + require_one_line 'scout repo' "$repo" + canonical=$(canonicalize_url "$original") || return $? + acquire_lock + resolve_current || die 'link-intake current state is absent' + prune_abandoned_generations "$CURRENT_GENERATION" + validate_state "$CURRENT_GENERATION" + prepare_scout "$CURRENT_GENERATION" "$canonical" "$repo" || return 2 +} + command_upsert() { local original='' canonical='' canonical_input='' source='' title='' summary='' terms='' retrieved='' transcript_file='' transcript_unavailable='' failure='' argument claim local claims='' id current='' record existing transcript note status host path record_tmp logical_record + local scout=1 scout_repo=$SCOUT_REPO_DEFAULT published repair_command while [ "$#" -gt 0 ]; do case "$1" in --url) shift; original=${1:-} ;; @@ -604,6 +878,8 @@ command_upsert() { --transcript-file) shift; transcript_file=${1:-} ;; --transcript-unavailable) shift; transcript_unavailable=${1:-} ;; --failure) shift; failure=${1:-} ;; + --no-scout) scout=0 ;; + --scout-repo) shift; scout_repo=${1:-} ;; -h|--help|help) usage; return 0 ;; *) die "unknown argument: $1" ;; esac @@ -697,9 +973,19 @@ command_upsert() { mv "$record_tmp" "$record" || die 'could not install staged record update' prepare_index "$STAGED_GENERATION" "$canonical" "$id" "$retrieved" "$source" "$title" "$status" "$terms" validate_state "$STAGED_GENERATION" + published=$STAGED_GENERATION publish_generation logical_record=$(logical_record_path_for "$canonical") printf '%s\n' "$logical_record" + if [ "$scout" = 1 ] && [ "$status" = captured ]; then + require_one_line 'scout repo' "$scout_repo" + if ! prepare_scout "$published" "$canonical" "$scout_repo"; then + repair_command="FM_HOME=$(shell_quote "$FM_HOME") $(shell_quote "$SCRIPT_DIR/fm-link-intake.sh") prepare-scout --url $(shell_quote "$canonical") --scout-repo $(shell_quote "$scout_repo")" + printf 'error: the link record is stored, but its ingest scout was not prepared; rerun: %s\n' \ + "$repair_command" >&2 + return 3 + fi + fi } command_validate() { @@ -722,6 +1008,7 @@ command_validate() { main() { case "${1:-}" in upsert) shift; command_upsert "$@" ;; + prepare-scout) shift; command_prepare_scout "$@" ;; validate) shift; command_validate "$@" ;; -h|--help|help) usage ;; *) usage >&2; exit 2 ;; diff --git a/docs/link-intake.md b/docs/link-intake.md index 9ba5aabe4a9..35cdfc949ab 100644 --- a/docs/link-intake.md +++ b/docs/link-intake.md @@ -11,6 +11,17 @@ An interrupted updater leaves the prior or next complete generation selected, an The helper does not issue filesystem sync barriers, so power-loss durability depends on the host filesystem. Repeated intake of a canonical URL updates one current record while retaining original URLs and preserving the replaced record as private history. +Storing a retrievable record also prepares one ingest scout for that link: a scout brief under `FM_HOME/data/<task-id>/` and a queued backlog item. +The scout's own instructions have it ingest the source with the existing browser or media tool, keep the ingested text as a durable artifact beside its report, report what the fleet can build from it along with out-of-the-box ideas, blindspots, and other angles mapped to current lanes, and file promising ideas as further queued backlog items. +Its specialized write contract permits authoritative output only under its own `FM_HOME/data/<task-id>/` directory, link-record updates through the absolute intake helper with explicit `FM_HOME`, queued backlog additions through `tasks-axi`, and its status file. +Every other file stays inside the disposable worktree, and the scout never edits a project. +The selected scout repository is stored on the queued ingest-scout item, while each promising-idea command requires the repository of the current lane that idea is mapped to in the report. +Preparation stops there: the scout is never spawned, started, or dispatched, so firstmate keeps dispatch authority, spawn safety, dispatch-profile consultation, and capacity judgment. +The task id derives from the canonical URL alone, so re-recording the same link converges on the one brief and backlog item instead of queueing a second scout. +An inaccessible record prepares no scout because there is nothing to ingest, `--no-scout` skips preparation for one invocation, and `prepare-scout` prepares or repairs one for an already-stored record. +A home whose backlog backend is manual gets the brief plus the exact queued item to add by hand. +A missing or incompatible `tasks-axi` under the default backend fails scout preparation after the link record is published instead of being treated as manual mode. + Video and audio intake retains a legally and technically accessible transcript under the same private location or records why no transcript can be obtained. An inaccessible, private, deleted, or otherwise unreadable source still receives a visible record with its failure reason. Link intake preserves attribution and verification evidence only. diff --git a/docs/scripts.md b/docs/scripts.md index b9af3578edc..cb609cac171 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -10,7 +10,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-session-start.sh` | Compose lock, bootstrap, and wake drain into the single ordered session-start digest | | `fm-sessionstart-nudge.sh` | Print the native session-start hook nudge when the primary has not already run the digest | | `fm-operational-input.sh` | Construct and parse the canonical cross-language operational-input protocol | -| `fm-link-intake.sh` | Atomically retain and validate private searchable link-intake records | +| `fm-link-intake.sh` | Atomically retain and validate private searchable link-intake records, and prepare their queued ingest scouts | | `fm-bootstrap.sh` | Detect toolchain and fleet problems, run the locked session-start sweeps, and install approved tools | | `fm-fleet-sync.sh` | Refresh project clones with safe fast-forwards, self-heals, `STUCK:` reports, branch pruning, and bounded recovery from an orphaned `.git/packed-refs.lock` | | `fm-fleet-snapshot.sh` | Print the read-only structured fleet snapshot JSON (schema `fm-fleet-snapshot.v1`) | diff --git a/docs/verification/link-intake.md b/docs/verification/link-intake.md index 34fca3277bf..352f5de7b3f 100644 --- a/docs/verification/link-intake.md +++ b/docs/verification/link-intake.md @@ -2,7 +2,7 @@ This record owns active empirical verification for private durable link intake. -Verification date: 2026-07-28. +Verification date: 2026-08-02. The implementation was verified by the focused behavior suite after the current change. ```sh @@ -17,7 +17,17 @@ ok - lock identity is timezone-stable and legacy recovery is upgrade-safe ok - retrieval dates are real calendar dates and remain path-safe ok - one process-crash atomic switch survives failures and process death ok - validation rejects record, index, and transcript divergence +ok - odd URLs preserve query case, use digest paths, and stay data in prepared scouts +ok - generated commands quote arguments and target the authoritative home from scratch +ok - a retrievable link prepares one queued, undispatched ingest scout +ok - repeated intake of one link converges on a single ingest scout +ok - scout preparation is skippable, repairable, and refused for inaccessible records +ok - a failed preparation keeps its record and prints a safe executable repair +ok - an unavailable tasks backend fails preparation after publishing the record +ok - a manual backlog backend prepares the brief and prints the item to add ``` The focused suite covers canonical duplicate convergence, searchable title and summary fields, inaccessible records, transaction-staged transcripts, an atomic initialized lock claim, UTC-normalized process-start ownership, upgrade-safe legacy lock recovery, stale-lock quarantine, BSD and GNU symlink replacement, real calendar dates, a process-crash atomic state switch, conservative retained state, bidirectional consistency, odd URLs with query slashes, and the one-line `AGENTS.md` trigger. +For ingest-scout preparation it covers the specialized authoritative-home write contract, the filled brief, its selected-repository ingest-scout item, the per-idea mapped-lane repository template, the absence of any dispatch, convergence on one scout when the same link is recorded again under a different title, `--no-scout` and `prepare-scout`, refusal for an inaccessible record, executable shell-quoted record and repair commands from a disposable worktree, the exit-3 paths that keep the stored record when brief or backend preparation fails, explicit manual mode, and an odd URL that stays data instead of controlling the task id, brief, or shell. +The scout-preparation cases that file a backlog item skip themselves when `tasks-axi` is absent. Filesystem sync barriers and power-loss durability are explicitly outside this focused process-crash guarantee. diff --git a/tests/fm-link-intake.test.sh b/tests/fm-link-intake.test.sh index 36e91e2ddb4..c2e3faf8988 100755 --- a/tests/fm-link-intake.test.sh +++ b/tests/fm-link-intake.test.sh @@ -19,6 +19,20 @@ make_home() { # <name> printf '%s\n' "$home" } +make_incompatible_tasks_axi() { # <home> + local fakebin="$1/fake-tasks-bin" + mkdir -p "$fakebin" + cat > "$fakebin/tasks-axi" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = --version ]; then + printf '%s\n' '0.0.0' +fi +exit 0 +SH + chmod +x "$fakebin/tasks-axi" + printf '%s\n' "$fakebin" +} + run_intake() { # <home> <arguments...> local home=$1 shift @@ -147,6 +161,10 @@ test_process_start_identity() { TZ=UTC0 LC_ALL=C ps -o lstart= -p "$1" 2>/dev/null | awk 'NF { $1=$1; print; exit }' } +record_path_from() { # <upsert output> + printf '%s\n' "$1" | head -1 +} + record_for() { # <home> <url> local home=$1 url=$2 result result=$(run_intake "$home" validate "$url") || fail 'expected record did not validate' @@ -160,6 +178,8 @@ test_canonical_duplicate_converges_and_preserves_evidence() { || fail 'first URL intake failed' second=$(run_intake "$home" upsert --url 'https://example.com/watch?b=2' --source-type web --title 'Updated title' --summary 'A newer searchable summary.' --terms 'updated,example' --claim 'Updated claim.') \ || fail 'duplicate URL intake failed' + first=$(record_path_from "$first") + second=$(record_path_from "$second") [ "$first" = "$second" ] || fail 'canonical duplicate created a second record path' count=$(find "$(current_root "$home")/records" -type f -name '*.md' | wc -l | tr -d ' ') [ "$count" = 1 ] || fail 'canonical duplicate created multiple records' @@ -439,7 +459,7 @@ test_validation_rejects_bidirectional_divergence() { } test_odd_urls_never_control_filenames_or_shell() { - local home record files query_record + local home record files query_record scout home=$(make_home odd-url) run_intake "$home" upsert --url 'https://EXAMPLE.test/a/../../%24%28touch%20nope%29?x=%3B%26' --source-type web --title 'Odd URL title' --summary 'Odd but valid URL summary.' --terms 'odd,url' --claim 'Odd URL remains data.' >/dev/null \ || fail 'odd URL intake failed' @@ -455,7 +475,45 @@ test_odd_urls_never_control_filenames_or_shell() { || fail 'query containing a slash was rejected' query_record=$(record_for "$home" 'https://example.test?Token=ABC/Path') assert_grep 'Canonical URL: https://example.test?Token=ABC/Path' "$query_record" 'query content was lowercased as authority' - pass 'odd URLs preserve query case and use digest paths' + scout=$(run_intake "$home" prepare-scout --url 'https://EXAMPLE.test/a/../../%24%28touch%20nope%29?x=%3B%26' \ + | sed -n 's/^scout: \(.*\) prepared, not dispatched$/\1/p') || fail 'odd URL scout preparation failed' + case "$scout" in ingest-example-test-[0-9a-f][0-9a-f]*) ;; *) fail "odd URL controlled the scout task id: $scout" ;; esac + assert_absent "$home/nope" 'odd URL executed a command during scout preparation' + assert_grep '%24%28touch%20nope%29' "$home/data/$scout/brief.md" 'odd URL was not carried into the brief as data' + pass 'odd URLs preserve query case, use digest paths, and stay data in prepared scouts' +} + +test_generated_commands_use_the_authoritative_home_and_quote_arguments() { + local home url output task_id brief success_command failure_command scratch record + home=$(make_home generated-commands) + url="https://hostile.example.test/watch?x=\$(touch\${IFS}$home/injected);quote='x'&next=1" + output=$(run_intake "$home" upsert --url "$url" --source-type video \ + --title 'Hostile URL video' --summary 'A URL whose query must remain inert shell data.' \ + --terms 'hostile,commands' --claim 'Rendered commands preserve argument boundaries.' \ + --transcript-unavailable 'not retrieved yet' --scout-repo media-lane) \ + || fail 'hostile URL intake failed' + task_id=$(scout_id_from "$output") + brief=$(scout_brief_for "$home" "$task_id") + printf 'Retained transcript text.\n' > "$home/data/$task_id/source.txt" + # shellcheck disable=SC2016 + success_command=$(sed -n 's/^ `\(FM_HOME=.*--transcript-file.*--no-scout\)`$/\1/p' "$brief") + # shellcheck disable=SC2016 + failure_command=$(sed -n 's/^ `\(FM_HOME=.*--failure.*--no-scout\)`$/\1/p' "$brief") + [ -n "$success_command" ] || fail 'brief has no executable transcript-retaining command' + [ -n "$failure_command" ] || fail 'brief has no executable retrieval-failure command' + scratch="$home/disposable-worktree" + mkdir -p "$scratch" + (cd "$scratch" && bash -c "$success_command") > "$home/success-command.out" \ + || fail 'transcript-retaining command failed outside the authoritative home' + assert_absent "$home/injected" 'hostile URL executed shell syntax in the transcript-retaining command' + record=$(record_for "$home" "$url") + assert_grep 'Transcript: stored at transcripts/' "$record" 'transcript-retaining command wrote outside the authoritative record' + (cd "$scratch" && bash -c "$failure_command") > "$home/failure-command.out" \ + || fail 'retrieval-failure command failed outside the authoritative home' + assert_absent "$home/injected" 'hostile URL executed shell syntax in the retrieval-failure command' + record=$(record_for "$home" "$url") + assert_grep 'Retrieval status: inaccessible' "$record" 'retrieval-failure command missed the authoritative record' + pass 'generated commands quote arguments and target the authoritative home from scratch' } test_agents_trigger_is_concise_and_agent_agnostic() { @@ -464,11 +522,173 @@ test_agents_trigger_is_concise_and_agent_agnostic() { [ -n "$line" ] || fail 'AGENTS link-intake trigger is absent' assert_contains "$line" 'captain sends meaningful URL input' 'trigger does not cover meaningful captain URLs' assert_contains "$line" 'bin/fm-link-intake.sh' 'trigger does not name the authoritative helper' + assert_contains "$line" 'ingest scout' 'trigger does not cover the prepared ingest scout' [ "$(printf '%s\n' "$line" | wc -l | tr -d ' ')" = 1 ] || fail 'AGENTS link-intake trigger is not one concise line' assert_not_contains "$line" 'Claude' 'trigger is not agent-agnostic' pass 'AGENTS has one concise agent-agnostic link-intake trigger' } +scout_brief_for() { # <home> <task-id> + printf '%s\n' "$1/data/$2/brief.md" +} + +scout_id_from() { # <intake output> + printf '%s\n' "$1" | sed -n 's/^scout: \(.*\) prepared, not dispatched$/\1/p' +} + +test_retrievable_link_prepares_one_queued_ingest_scout() { + local home output task_id brief backlog idea_command + command -v tasks-axi >/dev/null 2>&1 \ + || { echo 'skip: tasks-axi not found (required by the queued backlog item)'; return 0; } + home=$(make_home prepared-scout) + output=$(run_intake "$home" upsert --url 'https://lecture.example.test/talk?id=7' --source-type video \ + --title 'A talk worth mining' --summary 'A recorded talk about operating agent fleets.' \ + --terms 'agents,fleet' --claim 'The talk makes a testable claim.' \ + --transcript-unavailable 'captions are not published yet' --scout-repo lecture-lane) \ + || fail 'intake with scout preparation failed' + task_id=$(scout_id_from "$output") + [ -n "$task_id" ] || fail 'intake did not report a prepared ingest scout' + assert_contains "$output" 'not dispatched' 'intake did not state that the scout is undispatched' + brief=$(scout_brief_for "$home" "$task_id") + assert_present "$brief" 'prepared ingest scout has no brief' + ! grep -F -x -q '{TASK}' "$brief" || fail 'ingest scout brief was left unfilled' + assert_grep 'https://lecture.example.test/talk?id=7' "$brief" 'ingest scout brief does not name its source link' + assert_grep "$home/data/$task_id/source.txt" "$brief" 'ingest scout brief does not require a durable artifact' + assert_grep "$home/data/$task_id/report.md" "$brief" 'ingest scout brief does not require a report' + assert_grep 'What we can build from it' "$brief" 'ingest scout brief omits the buildable-ideas section' + assert_grep 'Out-of-the-box ideas' "$brief" 'ingest scout brief omits the out-of-the-box section' + assert_grep 'Potential blindspots' "$brief" 'ingest scout brief omits the blindspots section' + assert_grep 'Other angles worth taking' "$brief" 'ingest scout brief omits the other-angles section' + assert_grep 'tasks-axi add' "$brief" 'ingest scout brief does not have promising ideas filed as backlog items' + # shellcheck disable=SC2016 + idea_command=$(sed -n 's/^ `\(tasks-axi add .*\)`$/\1/p' "$brief") + [ -n "$idea_command" ] || fail 'brief did not render the promising-idea command' + assert_contains "$idea_command" "--repo '<mapped-lane-repo>'" 'promising-idea command did not require its mapped lane repository' + assert_not_contains "$idea_command" "--repo 'lecture-lane'" 'promising-idea command inherited the scout execution repository' + assert_grep 'Never start, dispatch, promote, or spawn anything' "$brief" 'ingest scout brief does not withhold dispatch authority' + assert_grep "Outside it, you may write only under \`$home/data/$task_id/\`" "$brief" 'ingest scout brief does not permit its authoritative artifacts' + assert_grep 'Every other file must stay inside the disposable worktree' "$brief" 'ingest scout brief does not preserve its disposable-worktree boundary' + assert_not_contains "$(cat "$brief")" 'The report is the only thing that survives' 'generic scout survival contract contradicts link-intake artifacts' + assert_not_contains "$(cat "$brief")" 'the only files you may write outside it are the report and the status file' 'generic scout write contract contradicts link-intake artifacts' + backlog="$home/data/backlog.md" + assert_grep "$task_id" "$backlog" 'ingest scout is absent from the backlog' + assert_grep 'Ingest: A talk worth mining' "$backlog" 'queued scout item lost its source title' + [ "$(tasks-axi list --state queued --file "$backlog" 2>/dev/null | grep -c -F "$task_id")" -ge 1 ] \ + || fail 'ingest scout backlog item is not queued' + [ "$(tasks-axi list --state queued --repo lecture-lane --file "$backlog" 2>/dev/null | grep -c -F "$task_id")" -eq 1 ] \ + || fail 'ingest scout backlog item lost its selected repository' + assert_absent "$home/state/$task_id.meta" 'link intake dispatched the ingest scout itself' + pass 'a retrievable link prepares one queued, undispatched ingest scout' +} + +test_repeated_intake_converges_on_one_ingest_scout() { + local home first second briefs queued + command -v tasks-axi >/dev/null 2>&1 \ + || { echo 'skip: tasks-axi not found (required by the queued backlog item)'; return 0; } + home=$(make_home converging-scout) + first=$(run_intake "$home" upsert --url 'https://media.example.test/watch?v=9' --source-type video \ + --title 'Original title' --summary 'First pass summary.' --terms 'media' --claim 'A claim.' \ + --transcript-unavailable 'not retrieved yet') || fail 'first intake failed' + printf 'The retained transcript.\n' > "$home/transcript.txt" + second=$(run_intake "$home" upsert --url 'https://media.example.test/watch?v=9' --source-type video \ + --title 'A completely different title' --summary 'Second pass summary.' --terms 'media' --claim 'A claim.' \ + --transcript-file "$home/transcript.txt") || fail 'second intake failed' + [ "$(scout_id_from "$first")" = "$(scout_id_from "$second")" ] \ + || fail 'a changed title queued a second ingest scout for the same link' + briefs=$(find "$home/data" -mindepth 2 -maxdepth 2 -name brief.md | wc -l | tr -d ' ') + [ "$briefs" = 1 ] || fail "repeated intake left $briefs ingest scout briefs" + queued=$(grep -c -F -- "- [ ] $(scout_id_from "$first") " "$home/data/backlog.md") + [ "$queued" = 1 ] || fail "repeated intake left $queued queued backlog items for one link" + pass 'repeated intake of one link converges on a single ingest scout' +} + +test_scout_preparation_is_skippable_and_repairable() { + local home output task_id + command -v tasks-axi >/dev/null 2>&1 \ + || { echo 'skip: tasks-axi not found (required by the queued backlog item)'; return 0; } + home=$(make_home skipped-scout) + run_intake "$home" upsert --url 'https://forge.example.test/pull/12' --source-type web \ + --title 'A pull request' --summary 'A link that needs no ingest.' --terms 'forge' --claim 'A claim.' \ + --no-scout > "$home/skipped.out" || fail 'intake with --no-scout failed' + assert_not_contains "$(cat "$home/skipped.out")" 'scout:' '--no-scout still prepared a scout' + [ "$(find "$home/data" -mindepth 2 -maxdepth 2 -name brief.md | wc -l | tr -d ' ')" = 0 ] \ + || fail '--no-scout left a scout brief behind' + output=$(run_intake "$home" prepare-scout --url 'https://forge.example.test/pull/12') \ + || fail 'prepare-scout failed for a stored record' + task_id=$(scout_id_from "$output") + [ -n "$task_id" ] || fail 'prepare-scout did not report a prepared ingest scout' + assert_present "$(scout_brief_for "$home" "$task_id")" 'prepare-scout wrote no brief' + run_intake "$home" upsert --url 'https://private.example.test/paywalled' --source-type article \ + --failure 'HTTP 402 paywalled' > "$home/failure.out" || fail 'inaccessible intake failed' + assert_not_contains "$(cat "$home/failure.out")" 'scout:' 'an inaccessible record prepared an ingest scout' + if run_intake "$home" prepare-scout --url 'https://private.example.test/paywalled' \ + > "$home/refused.out" 2> "$home/refused.err"; then + fail 'prepare-scout accepted an inaccessible record' + fi + assert_grep 'nothing to ingest' "$home/refused.err" 'refusal reason for an inaccessible record is not visible' + pass 'scout preparation is skippable, repairable, and refused for inaccessible records' +} + +test_scout_preparation_failure_preserves_the_stored_record() { + local home url task_id repair_command scratch rc=0 + home=$(make_home scout-failure) + url="https://blocked.example.test/item?x=\$(touch\${IFS}$home/repair-injected);quote='x'" + task_id="ingest-blocked-example-test-$(printf '%s' "$url" | shasum -a 256 | cut -c1-8)" + mkdir -p "$home/data/$task_id/brief.md" + run_intake "$home" upsert --url "$url" --source-type article \ + --title 'A blocked scout' --summary 'The scout brief cannot be written.' --terms 'blocked' \ + --claim 'A claim.' > "$home/blocked.out" 2> "$home/blocked.err" || rc=$? + expect_code 3 "$rc" 'a failed scout preparation after a stored record' + assert_grep 'link record is stored' "$home/blocked.err" 'failure did not state that the record survived' + repair_command=$(sed -n 's/.*rerun: //p' "$home/blocked.err") + [ -n "$repair_command" ] || fail 'failure did not name the repair command' + run_intake "$home" validate "$url" >/dev/null \ + || fail 'a failed scout preparation cost the stored link record' + rmdir "$home/data/$task_id/brief.md" + scratch="$home/disposable-worktree" + mkdir -p "$scratch" + (cd "$scratch" && bash -c "$repair_command") > "$home/repaired.out" \ + || fail 'printed repair command failed outside the authoritative home' + assert_grep "scout: $task_id prepared, not dispatched" "$home/repaired.out" 'repair command did not prepare the original scout' + assert_absent "$home/repair-injected" 'hostile URL executed shell syntax in the repair command' + pass 'a failed preparation keeps its record and prints a safe executable repair' +} + +test_unavailable_tasks_backend_fails_after_record_publication() { + local home fakebin record rc=0 + home=$(make_home unavailable-tasks) + fakebin=$(make_incompatible_tasks_axi "$home") + PATH="$fakebin:$PATH" FM_HOME="$home" "$INTAKE" upsert \ + --url 'https://unavailable.example.test/page' --source-type article \ + --title 'Unavailable tasks backend' --summary 'The record must survive preparation failure.' \ + --terms 'backend,unavailable' --claim 'Preparation fails closed.' \ + > "$home/unavailable.out" 2> "$home/unavailable.err" || rc=$? + expect_code 3 "$rc" 'an unavailable configured tasks-axi backend' + assert_grep 'configured tasks-axi backlog backend is unavailable or incompatible' "$home/unavailable.err" 'backend failure was not visible' + assert_not_contains "$(cat "$home/unavailable.out")" 'manual backlog:' 'unavailable tasks-axi was mistaken for explicit manual mode' + record=$(head -1 "$home/unavailable.out") + assert_present "$record" 'backend failure did not preserve the published record path first' + assert_absent "$home/data/backlog.md" 'backend failure falsely filed a queued backlog item' + pass 'an unavailable tasks backend fails preparation after publishing the record' +} + +test_manual_backlog_backend_prepares_the_brief_and_the_exact_item() { + local home fakebin output task_id + home=$(make_home manual-backlog) + fakebin=$(make_incompatible_tasks_axi "$home") + printf 'manual\n' > "$home/config/backlog-backend" + output=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$INTAKE" upsert --url 'https://manual.example.test/page' --source-type article \ + --title 'A manual home' --summary 'This home files backlog items by hand.' --terms 'manual' \ + --claim 'A claim.') || fail 'intake under a manual backlog backend failed' + task_id=$(scout_id_from "$output") + [ -n "$task_id" ] || fail 'manual backend prepared no ingest scout' + assert_present "$(scout_brief_for "$home" "$task_id")" 'manual backend wrote no scout brief' + assert_contains "$output" "manual backlog: add queued scout item $task_id" 'manual backend did not print the exact queued item' + assert_contains "$output" 'with repo firstmate' 'manual backend item lost its selected repository' + assert_absent "$home/data/backlog.md" 'manual backend mutated the backlog itself' + pass 'a manual backlog backend prepares the brief and prints the item to add' +} + test_canonical_duplicate_converges_and_preserves_evidence test_titles_summaries_claims_and_terms_are_searchable test_inaccessible_links_remain_visible_and_valid @@ -479,4 +699,11 @@ test_retrieval_dates_are_real_and_path_safe test_atomic_generation_switch_is_process_crash_safe test_validation_rejects_bidirectional_divergence test_odd_urls_never_control_filenames_or_shell +test_generated_commands_use_the_authoritative_home_and_quote_arguments test_agents_trigger_is_concise_and_agent_agnostic +test_retrievable_link_prepares_one_queued_ingest_scout +test_repeated_intake_converges_on_one_ingest_scout +test_scout_preparation_is_skippable_and_repairable +test_scout_preparation_failure_preserves_the_stored_record +test_unavailable_tasks_backend_fails_after_record_publication +test_manual_backlog_backend_prepares_the_brief_and_the_exact_item From c28c6d5eb2f27a7822d938fdeb24007815ec3ba0 Mon Sep 17 00:00:00 2001 From: quinnbot-ai <quinnbot.ai@gmail.com> Date: Sun, 2 Aug 2026 15:35:25 -0700 Subject: [PATCH 28/70] fix(review-diff): neutralize host external-diff config so reviews never read empty (#81) * test(review-diff): cover host external diff viewers in the regression matrix bin/fm-review-diff.sh already passes --no-ext-diff on both its change predicate and its patch emission, landed in c059bd0, but nothing pinned that behavior. A host diff.external= or GIT_EXTERNAL_DIFF= viewer would otherwise be free to replace the reviewed patch with its own rendering, or to fail where the configured viewer is not installed, and no test would notice. Add case (f) to the matrix: configure both a repository-level diff.external and the environment equivalent to a fake viewer, then assert the review still prints a unified patch and never the viewer's output. Record the flag as a repo style rule so future commands that read git patch output do not repeat the assumption. * no-mistakes(review): extend the external-diff case to trusted exit codes The viewer in case (f) returned a non-zero status, so it only exercised patch emission. With diff.external= plus diff.trustExitCode=true, or the environment equivalents, Git trusts the viewer's exit code, so `git diff --quiet` can report no changes for a branch that genuinely changed - the review then prints "no changes" and ends, hiding the whole diff. That silent-empty-review path was reachable and untested. Have the fake viewer exit 0 and set diff.trustExitCode=true so the case covers the predicate as well as the patch, and widen the style rule from patch output to any git diff whose exit status controls behavior. * test(review-diff): make the trusted-exit coverage actually reach the predicate Case (f) set diff.trustExitCode=true but ran the review with GIT_EXTERNAL_DIFF set, and that environment variable overrides diff.external while diff.trustExitCode does not govern it. Git therefore never trusted the fake viewer's exit code, so the predicate was never falsified and the case only ever proved patch substitution - the silent "no changes" path it was added for went untested. Exercise both mechanisms separately: repository config alone, then GIT_EXTERNAL_DIFF with GIT_EXTERNAL_DIFF_TRUST_EXIT_CODE. Verified on git 2.50.1 that removing --no-ext-diff from the predicate now fails the case with "no changes", and removing it from patch emission fails on the viewer output. Check patch substitution before change detection so each failure names its own cause; a falsified predicate emits no viewer output to match against. --------- Co-authored-by: QuinnBot <quinnbot@proton.me> --- .../firstmate-coding-guidelines/SKILL.md | 3 +- tests/fm-review-diff.test.sh | 43 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/.agents/skills/firstmate-coding-guidelines/SKILL.md b/.agents/skills/firstmate-coding-guidelines/SKILL.md index ac1da34dd84..45e0f7a31bd 100644 --- a/.agents/skills/firstmate-coding-guidelines/SKILL.md +++ b/.agents/skills/firstmate-coding-guidelines/SKILL.md @@ -3,7 +3,7 @@ name: firstmate-coding-guidelines description: >- Agent-only reference for changing firstmate's shared, tracked material per AGENTS.md section 1. Use before editing any of that material, whether working as firstmate directly or as a crewmate briefed on a firstmate-repo task. - Covers the knowledge-placement decision tree, the one-owner rule for contracts, the inline-stub pattern for content moved into a skill, AGENTS.md size discipline, trigger hygiene for new skills, and repo style rules (one sentence per line, plain dash, no agent co-author, shellcheck-clean bin scripts, colocated tests, and maintainer-verification evidence). + Covers the knowledge-placement decision tree, the one-owner rule for contracts, the inline-stub pattern for content moved into a skill, AGENTS.md size discipline, trigger hygiene for new skills, and repo style rules (one sentence per line, plain dash, no agent co-author, shellcheck-clean bin scripts, host-config-independent git diff predicates and patch output, colocated tests, and maintainer-verification evidence). user-invocable: false metadata: internal: true @@ -96,6 +96,7 @@ Run `bin/fm-doc-audience-check.sh`; it enforces classification, README setup rou - Never add an agent name as a commit co-author. - `bin/*.sh` and `bin/backends/*.sh` must pass `shellcheck`. - Run `bin/fm-lint.sh` before treating a script change as done; it is the single owner of the lint definition (file set, config, and pinned shellcheck version) that CI and the no-mistakes pre-push gate both invoke, and it refuses to run under any other shellcheck version. +- Pass `--no-ext-diff` on any `git diff` command whose exit status controls behavior or whose patch output is read or parsed, because a trusted host `diff.external=` or `GIT_EXTERNAL_DIFF=` viewer can otherwise falsify the predicate, substitute its own rendering, or fail when absent. - Colocate tests with the existing pattern in `tests/`, name them `<subject>.test.sh`, and extend an existing script rather than inventing a new runner. - Tests must exercise behavior through an executable or public interface and must never assert implementation-source bytes, including through parsers, regexes, snapshots, or indirect wrappers. - A maintainer-verification record under `docs/verification/` records active empirical facts, not assumptions or task chronology. diff --git a/tests/fm-review-diff.test.sh b/tests/fm-review-diff.test.sh index 2193772b9d9..088f53d35de 100755 --- a/tests/fm-review-diff.test.sh +++ b/tests/fm-review-diff.test.sh @@ -11,6 +11,7 @@ # (d) pr= present but PR head unreachable -> fallback to local branch + warning # (e) pr= + STALE recorded pr_head= + newer remote pull head -> must use fetched head # (this is the class that bit reviewers holding merges over "missing" fixes) +# (f) trusted host diff.external=/GIT_EXTERNAL_DIFF= viewer -> change detection and patch output are unaffected set -u # shellcheck source=tests/lib.sh @@ -133,6 +134,47 @@ test_pr_meta_fetches_pull_head_without_recorded_sha() { pass "fm-review-diff fetches refs/pull/<n>/head when pr_head= is absent" } +test_host_external_diff_cannot_replace_the_patch() { + local case_dir out + case_dir=$(make_case external-diff) + stale_and_pr_commits "$case_dir" + write_task_meta "$case_dir" + + # A host viewer (difftastic, delta, ...) must never stand in for the reviewed + # patch, and a viewer Git is told to trust must never answer the change + # predicate either: exiting 0 there makes the review print "no changes" and + # stop on a branch that did change. Config and environment are independent + # mechanisms - GIT_EXTERNAL_DIFF overrides diff.external, and + # diff.trustExitCode does not govern it - so both are exercised. + cat > "$case_dir/fake-ext-diff" <<'EXT' +#!/usr/bin/env bash +echo "EXTERNAL VIEWER OUTPUT" +exit 0 +EXT + chmod +x "$case_dir/fake-ext-diff" + git -C "$case_dir/project" config diff.external "$case_dir/fake-ext-diff" + git -C "$case_dir/project" config diff.trustExitCode true + + out=$(run_review_diff "$case_dir" task-x1 2> "$case_dir/stderr") + + # Patch substitution is checked first so each failure names its own cause: a + # falsified predicate prints "no changes" with no viewer output at all. + assert_not_contains "$out" 'EXTERNAL VIEWER OUTPUT' \ + "external-diff: trusted diff.external must not replace the patch" + assert_contains "$out" '+stale-local' \ + "external-diff: trusted diff.external must not answer the change predicate" + + out=$(GIT_EXTERNAL_DIFF="$case_dir/fake-ext-diff" \ + GIT_EXTERNAL_DIFF_TRUST_EXIT_CODE=1 \ + run_review_diff "$case_dir" task-x1 2> "$case_dir/stderr") + + assert_not_contains "$out" 'EXTERNAL VIEWER OUTPUT' \ + "external-diff: trusted GIT_EXTERNAL_DIFF must not replace the patch" + assert_contains "$out" '+stale-local' \ + "external-diff: trusted GIT_EXTERNAL_DIFF must not answer the change predicate" + pass "fm-review-diff ignores trusted host external diff viewers" +} + test_no_pr_meta_uses_local_branch() { local case_dir out case_dir=$(make_case no-pr-meta) @@ -174,3 +216,4 @@ test_pr_meta_fetches_pull_head_without_recorded_sha test_stale_recorded_pr_head_loses_to_fetched_pull_head test_no_pr_meta_uses_local_branch test_unreachable_pr_head_falls_back_with_warning +test_host_external_diff_cannot_replace_the_patch From a12ff2ca8515710fd1941f377442a1f726d75e2e Mon Sep 17 00:00:00 2001 From: quinnbot-ai <quinnbot.ai@gmail.com> Date: Sun, 2 Aug 2026 15:58:11 -0700 Subject: [PATCH 29/70] refactor(config): privatize local operating facts (#82) * refactor(config): privatize alert inbox sources * no-mistakes(review): Remove tracked model-provider preferences * no-mistakes(document): Privatize operational inbox documentation --------- Co-authored-by: QuinnBot <quinnbot@proton.me> --- .pi/settings.json | 1 - AGENTS.md | 4 +- README.md | 2 - bin/fm-bootstrap.sh | 18 ++-- bin/fm-ops-inbox-lib.sh | 30 ++++--- docs/configuration.md | 6 +- docs/ops-inbox-wake.md | 41 ++++----- docs/verification/supervision.md | 3 - tests/fm-ops-inbox-watch.test.sh | 140 ++++++++++++++---------------- tests/fm-pi-primary-types.test.sh | 101 --------------------- tests/lib.sh | 8 -- 11 files changed, 115 insertions(+), 239 deletions(-) diff --git a/.pi/settings.json b/.pi/settings.json index f2d08069f8d..2b1dc03dab7 100644 --- a/.pi/settings.json +++ b/.pi/settings.json @@ -1,4 +1,3 @@ { - "enabledModels": ["claude-*", "openai-codex/*"], "theme": "firstmate-dark" } diff --git a/AGENTS.md b/AGENTS.md index b227a18c238..9b8a1a6fcb4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,7 +76,7 @@ config/herdr-presentation-spaces optional presence flag for Herdr's default-off config/cmux-socket-password optional cmux control-socket password; LOCAL, gitignored; read fresh on every cmux CLI call and passed through without ever overriding an operator's own ambient CMUX_SOCKET_PASSWORD when absent (docs/cmux-backend.md "Setup") config/wedge-alarm optional away-mode wedge-alarm active-alert directives; LOCAL, gitignored; absent means auto (macOS Notification Center when available); see docs/wedge-alarm.md config/x-mode.env generated X-mode watcher cadence; LOCAL, gitignored; source before arming watcher when present -config/ops-inbox.json optional operational alert inbox watch settings; LOCAL, gitignored, and NOT inherited because one machine has one alert inbox and the primary owns watching it; absent means watch the default alert inbox only when it exists and this is not a secondmate home; see docs/ops-inbox-wake.md +config/ops-inbox.json optional operational alert inbox watch settings; LOCAL, gitignored, and NOT inherited because one machine has one alert inbox and the primary owns watching it; absent means disabled; see docs/ops-inbox-wake.md data/ personal fleet records; LOCAL, gitignored as a whole backlog.md task queue, dependencies, history captain.md this home's domain-local captain preferences and working style; LOCAL, gitignored, canonical even if harness memory mirrors it, and updated with inspect-then-update @@ -104,7 +104,7 @@ state/ volatile runtime signals; gitignored .pr-check-migration.log private per-task outcomes distinguishing rebuilt or canonically registered replacement polls, quarantined unarmed polls, and incomplete migrations .pr-check-migration-scan-v1 private marker proving the non-executing scan disabled every unsafe legacy check; .pr-check-migration-v1 separately records completed private repairs x-watch.check.sh generated X-mode relay poll shim; present only when opted in (section 14) - ops-watch.check.sh generated operational alert inbox watch, registered like any custom check; present when auto mode finds this home's alert inbox or config explicitly enables the watch, with its dedupe record in .ops-inbox-wake (docs/ops-inbox-wake.md) + ops-watch.check.sh generated operational alert inbox watch, registered like any custom check; present only when its private config enables or auto-detects its selected inbox, with its dedupe record in .ops-inbox-wake (docs/ops-inbox-wake.md) pending-replies/ parent-owned secondmate pending-reply records (correlation id, delivery vs reply, recovery, escalation); fm-pending-reply-lib.sh x-inbox/ generated X-mode pending mention payloads; fmx-respond drains it (section 14) x-context/ generated X-mode durable per-request reply context and delivery markers, keyed by request_id; survives inbox cleanup and expires within seven days (section 14; docs/configuration.md) diff --git a/README.md b/README.md index 650f9f6694b..28a92a79d58 100644 --- a/README.md +++ b/README.md @@ -108,8 +108,6 @@ 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. For Pi, approve the project trust prompt once per clone on first launch so the tracked `.pi/extensions/*.ts` files auto-load. Trusted Pi sessions load the tracked navy, purple, and aqua `firstmate-dark` theme and Firstmate footer by default. -Pi's `/model` picker opens on a focused `scoped` view of Claude and ChatGPT subscription-backed models. -Press Tab in the picker to switch to `all` configured providers, including OpenRouter. The responsive footer uses Pi's native local session data to show run state, model and thinking level, project or directory name, git branch, context tokens and percentage, cumulative input and output tokens, session cost, and extension status text without dropping complete values at narrow widths. When supported, the terminal title becomes `Firstmate · <project> · <branch> · <model>`. The presentation does not poll quotas or providers, start background processes, or invent Codex-only fields. diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index a1a886d20de..07df6f438fc 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -66,16 +66,15 @@ # FMX_PAIRING_TOKEN. When opted in, bootstrap requires curl+jq, writes # the relay poll shim and 30s cadence config, and prints an FMX line. # The operational alert inbox watch follows fm-ops-inbox-lib.sh's -# decision: auto mode arms only when this home has an alert inbox, while -# explicit enablement arms fail-closed even when the spool is absent. +# decision: it arms only from the home's explicit private config; auto +# mode arms when the configured alert inbox exists, while explicit +# enablement arms fail-closed even when the spool is absent. # docs/ops-inbox-wake.md owns the contract. # Arming registers the reserved standing check state/ops-watch.check.sh # through the ordinary custom-check trust binding. # Arming and disarming are silent unless FM_BOOTSTRAP_VERBOSE_FACTS=1 # requests the BOOTSTRAP_INFO fact. # Only a failure prints an actionable OPS_INBOX line. -# FM_OPS_INBOX_STATE_DIR overrides the default watched operations state -# directory for tests and specialized setups. # Fleet sync fetches, fast-forwards safe default-branch states, reports # recovered and STUCK clone drift, and prunes gone local branches; it is # bounded by FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT when it is a non-empty @@ -746,10 +745,10 @@ EOF # Operational alert inbox watch: keep the standing check that turns an unreviewed # critical alert backlog into an ordinary watcher wake. It is armed exactly when -# fm_ops_inbox_watch_expected says so - by default only when the configured alert -# spool actually exists - so a home with no operations runtime stays inert and -# writes nothing. Arming registers state/ops-watch.check.sh through the normal -# custom-check trust binding, which is what authorizes the watcher to run it. +# fm_ops_inbox_watch_expected says so - only after private configuration selects +# an alert inbox - so a home with no configured integration stays inert and writes +# nothing. Arming registers state/ops-watch.check.sh through the normal custom- +# check trust binding, which is what authorizes the watcher to run it. # Steady state, either armed or inert, is silent; only a real transition prints a # BOOTSTRAP_INFO fact, and only a failure prints an actionable OPS_INBOX line. # The check id is reserved, so a task that somehow owns it wins and the watch @@ -795,6 +794,9 @@ ops_inbox_setup() { fi if ! fm_ops_inbox_config_load "$CONFIG"; then + if [ "$FM_OPS_INBOX_CONFIG_ERROR" = "jq is required to read config/ops-inbox.json" ]; then + echo "MISSING: jq (install: $(install_cmd jq))" + fi echo "OPS_INBOX: $FM_OPS_INBOX_CONFIG_ERROR; fix config/ops-inbox.json, then rerun bootstrap" return 0 fi diff --git a/bin/fm-ops-inbox-lib.sh b/bin/fm-ops-inbox-lib.sh index b3e63d61632..b23d34df4ec 100644 --- a/bin/fm-ops-inbox-lib.sh +++ b/bin/fm-ops-inbox-lib.sh @@ -72,18 +72,17 @@ fm_ops_inbox_shim_content() { } # Resolve the watch settings from optional <config-dir>/ops-inbox.json. -# Absent config means auto: watch the default operations state directory when its -# alert spool actually exists, so a home with no such runtime stays inert. -# That default directory is $HOME/.openclaw/state, overridable for tests and -# specialized setups by FM_OPS_INBOX_STATE_DIR; an explicit config value wins over -# both so a configured home never depends on ambient environment. -# A malformed config is preserved as an error rather than silently defaulted. +# An absent config disables the optional integration, so one home's external +# operations runtime can never become another install's implicit default. +# A present config defaults to auto mode and must name its watched state_dir or +# all three paths explicitly. A malformed config is preserved as an error rather +# than silently defaulted. # shellcheck disable=SC2034 # Result globals read by callers after this returns. fm_ops_inbox_config_load() { local config_dir=$1 file raw FM_OPS_INBOX_CONFIG_ERROR= - FM_OPS_INBOX_ENABLED=auto - FM_OPS_INBOX_STATE_DIR="${FM_OPS_INBOX_STATE_DIR:-${HOME:-}/.openclaw/state}" + FM_OPS_INBOX_ENABLED=false + FM_OPS_INBOX_STATE_DIR= FM_OPS_INBOX_SPOOL= FM_OPS_INBOX_ACKS= FM_OPS_INBOX_RECEIPT= @@ -96,6 +95,7 @@ fm_ops_inbox_config_load() { file="$config_dir/ops-inbox.json" if [ -e "$file" ] || [ -L "$file" ]; then + FM_OPS_INBOX_ENABLED=auto if [ ! -f "$file" ] || [ -L "$file" ]; then FM_OPS_INBOX_CONFIG_ERROR="config/ops-inbox.json is not an ordinary file" return 1 @@ -192,9 +192,17 @@ $raw EOF fi - [ -n "$FM_OPS_INBOX_SPOOL" ] || FM_OPS_INBOX_SPOOL="$FM_OPS_INBOX_STATE_DIR/ops-inbox.jsonl" - [ -n "$FM_OPS_INBOX_ACKS" ] || FM_OPS_INBOX_ACKS="$FM_OPS_INBOX_STATE_DIR/ops-inbox-acks.jsonl" - [ -n "$FM_OPS_INBOX_RECEIPT" ] || FM_OPS_INBOX_RECEIPT="$FM_OPS_INBOX_STATE_DIR/ops-inbox-receipt.json" + if [ "$FM_OPS_INBOX_ENABLED" != false ] \ + && [ -z "$FM_OPS_INBOX_STATE_DIR" ] \ + && { [ -z "$FM_OPS_INBOX_SPOOL" ] || [ -z "$FM_OPS_INBOX_ACKS" ] || [ -z "$FM_OPS_INBOX_RECEIPT" ]; }; then + FM_OPS_INBOX_CONFIG_ERROR="config/ops-inbox.json must set state_dir or all of spool, acks, and receipt when enabled" + return 1 + fi + if [ -n "$FM_OPS_INBOX_STATE_DIR" ]; then + [ -n "$FM_OPS_INBOX_SPOOL" ] || FM_OPS_INBOX_SPOOL="$FM_OPS_INBOX_STATE_DIR/ops-inbox.jsonl" + [ -n "$FM_OPS_INBOX_ACKS" ] || FM_OPS_INBOX_ACKS="$FM_OPS_INBOX_STATE_DIR/ops-inbox-acks.jsonl" + [ -n "$FM_OPS_INBOX_RECEIPT" ] || FM_OPS_INBOX_RECEIPT="$FM_OPS_INBOX_STATE_DIR/ops-inbox-receipt.json" + fi [ "$FM_OPS_INBOX_GROWTH" -ge 1 ] || FM_OPS_INBOX_GROWTH=1 } diff --git a/docs/configuration.md b/docs/configuration.md index 02d8c3e8fec..3d8b66955a3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -119,9 +119,10 @@ See [`wedge-alarm.md`](wedge-alarm.md) for the current channel reference, [`veri ## Operational alert inbox watch (config/ops-inbox.json) On a machine whose operations runtime records critical alerts into a durable inbox rather than a chat transport, firstmate watches that inbox so an unreviewed backlog wakes the first mate instead of sitting unread. -Local, gitignored `config/ops-inbox.json` under the effective home holds this watch's operating choices. +Local, gitignored `config/ops-inbox.json` under the effective home holds this watch's operating choices and source paths. +Absent configuration leaves the optional watch disabled. This file is deliberately not inherited into secondmate homes, and a secondmate home never auto-arms the watch: one machine has one alert inbox, and the primary owns watching it. -[`ops-inbox-wake.md`](ops-inbox-wake.md) owns the settings, defaults, arming, wake, dedupe, and two-paths-named-ops-inbox contracts; `bin/fm-ops-inbox-lib.sh`'s header owns the exact resolution mechanics. +[`ops-inbox-wake.md`](ops-inbox-wake.md) owns the settings, defaults, arming, wake, and dedupe contracts; `bin/fm-ops-inbox-lib.sh`'s header owns the exact resolution mechanics. ## Gate defaults (.no-mistakes.yaml) @@ -461,7 +462,6 @@ FM_HEARTBEAT=600 # base seconds between heartbeat scans; no-change heartb FM_HEARTBEAT_MAX=7200 # heartbeat backoff cap FM_CHECK_INTERVAL=300 # seconds between slow checks (authenticated merge polls, custom checks, or X-mode dispatch) FM_CHECK_TIMEOUT=30 # seconds allowed per slow check script -FM_OPS_INBOX_STATE_DIR= # alternate watched operations state dir for the alert inbox watch, mainly for tests; an explicit config/ops-inbox.json value wins (docs/ops-inbox-wake.md) FM_CODEX_WATCH_CHECKPOINT=180 # seconds per foreground watcher checkpoint in Codex primary supervision FM_CREW_STATE_NM_TIMEOUT=10 # seconds allowed per no-mistakes query inside fm-crew-state.sh FM_CREW_STATE_RUNS_LIMIT=200 # recent no-mistakes run rows scanned when axi status cannot be attributed to the current code diff --git a/docs/ops-inbox-wake.md b/docs/ops-inbox-wake.md index ea7d2b69f08..b1a1b74486a 100644 --- a/docs/ops-inbox-wake.md +++ b/docs/ops-inbox-wake.md @@ -1,6 +1,6 @@ # Operational alert inbox wake -Some machines run an operations runtime that records critical alerts - dead-man timers, tripwires, backup and service health checks - into a durable inbox instead of a chat transport. +Some machines run an operations runtime that records critical alerts into a durable inbox instead of a chat transport. An inbox nobody reads disarms every one of those checks silently, because each check still "fires" and still records its alert while no one is told. This watch closes that gap by turning an unreviewed critical backlog into an ordinary Firstmate wake, so a stalled alert queue reaches the first mate the same way a crew signal or a merged pull request does. @@ -16,7 +16,7 @@ The invariant behind every exit is that the poll may stay silent only after it h | Surface and exit | Disposition | Contract | |---|---|---| -| `fm_ops_inbox_config_load`: the optional config is absent. | `SILENT-BY-DESIGN` | Defaults are resolved and evaluation continues without output. | +| `fm_ops_inbox_config_load`: the optional config is absent. | `SILENT-BY-DESIGN` | The optional watch is disabled and evaluation stops without output. | | `fm_ops_inbox_config_load`: the config is not an ordinary file or is a symlink. | `FAILS-CLOSED-AND-WAKES` | The poll emits the configuration-error wake because no configured threshold can be trusted. | | `fm_ops_inbox_config_load`: the config exists but `jq` is unavailable. | `FAILS-CLOSED-AND-WAKES` | The poll emits the configuration-error wake. | | `fm_ops_inbox_config_load`: the config is unreadable, invalid JSON, or not an object. | `FAILS-CLOSED-AND-WAKES` | The poll emits the configuration-error wake. | @@ -25,6 +25,7 @@ The invariant behind every exit is that the poll may stay silent only after it h | `fm_ops_inbox_config_load`: `enabled` has an invalid value. | `FAILS-CLOSED-AND-WAKES` | The poll emits the configuration-error wake. | | `fm_ops_inbox_config_load`: a path setting is empty or non-string, or any string setting contains a code point below 32. | `FAILS-CLOSED-AND-WAKES` | The poll emits the configuration-error wake instead of resolving an unintended inbox path or accepting an injected setting. | | `fm_ops_inbox_config_load`: a numeric setting is empty, negative, fractional, non-numeric, outside the supported arithmetic range, or a zero `max_lines`. | `FAILS-CLOSED-AND-WAKES` | The poll emits the configuration-error wake. | +| `fm_ops_inbox_config_load`: an enabled or auto config omits `state_dir` and any of the three explicit paths. | `FAILS-CLOSED-AND-WAKES` | The poll emits the configuration-error wake because no inbox can be determined safely. | | `fm_ops_inbox_config_load`: all settings are valid, with recognized null values omitted. | `SILENT-BY-DESIGN` | Defaults survive null values and evaluation continues without output. | | `fm_ops_inbox_watch_expected`: `enabled` is explicitly true. | `SILENT-BY-DESIGN` | Evaluation continues even when the spool is absent, so the scan can fail closed. | | `fm_ops_inbox_watch_expected`: `enabled` is explicitly false. | `SILENT-BY-DESIGN` | The poll exits without reading or waking. | @@ -60,7 +61,7 @@ The invariant behind every exit is that the poll may stay silent only after it h | Poll: configuration loading fails. | `FAILS-CLOSED-AND-WAKES` | The poll emits the configuration-error wake and exits successfully for the watcher. | | Poll: an active or malformed watch cannot read the current time as epoch seconds. | `FAILS-CLOSED-AND-WAKES` | The poll emits a direct one-line watch failure because age, receipt staleness, and dedupe cannot be evaluated. | | Poll: the watch is disabled or auto mode has no owned spool. | `SILENT-BY-DESIGN` | The poll exits with no output. | -| Poll: `jq` is unavailable after default configuration resolution. | `FAILS-CLOSED-AND-WAKES` | The poll emits the dedicated missing-`jq` wake. | +| Poll: `jq` is unavailable after active configuration resolution. | `FAILS-CLOSED-AND-WAKES` | The poll emits the dedicated missing-`jq` wake. | | Poll: the receipt is missing, stale, or unreadable. | `WAKES` | The receipt state is included in the digest after a successful scan. | | Poll: scanning or oldest-timestamp conversion fails. | `FAILS-CLOSED-AND-WAKES` | The poll emits the scan-failure wake. | | Poll: the fresh receipt and scan establish that no wake threshold is met. | `SILENT-BY-DESIGN` | The poll clears prior dedupe state and exits with no output. | @@ -83,7 +84,7 @@ The watch reads three paths and writes to none of them: An alert counts as unacked exactly when the spool's own reader counts it: `severity` is `critical`, the inline `ack` flag is `false`, and no acknowledgement entry names its `id`. Firstmate never acknowledges, rotates, or edits these files; acknowledging remains the operations runtime's own command. -`<state_dir>` defaults to `$HOME/.openclaw/state` and is overridable per home; see [Configuration](#configuration). +`<state_dir>` is selected by the home's private configuration; see [Configuration](#configuration). ## When it wakes @@ -101,11 +102,11 @@ The oldest age and the alert classes always come from the spool, since the recei The wake is one compact digest line, enough to triage without reading the whole inbox: ``` -ops-inbox: 621 unacked critical alerts, oldest 13d, top: routine-scheduler 154, pipeline-verifier 96, scheduled-work-inventory 60 +ops-inbox: 621 unacked critical alerts, oldest 13d, top: source-beta 154, source-delta 96, source-epsilon 60 ``` ``` -ops-inbox: alert receipt stale 9h; 3 unacked critical alerts, oldest 8h, top: backup-verify 3 +ops-inbox: alert receipt stale 9h; 3 unacked critical alerts, oldest 8h, top: source-alpha 3 ``` Alert class is the spool's `source` field, sanitized and length-capped so a hostile or malformed alert cannot forge extra fields, split the line, or corrupt the durable wake record. @@ -114,7 +115,7 @@ The read itself is bounded: the check samples at most `max_lines + 1` spool line Exceeding that cap is reported without claiming the exact spool length, because a capped read understates both the total and the oldest age: ``` -ops-inbox: inbox past its 20000-line read cap, so the total and oldest age below are understated; 20000 unacked critical alerts, oldest 6d, top: routine-scheduler 5104 +ops-inbox: inbox past its 20000-line read cap, so the total and oldest age below are understated; 20000 unacked critical alerts, oldest 6d, top: source-beta 5104 ``` A valid final JSON record is included even without a trailing newline, while a malformed non-terminated fragment is ignored as an append in progress. @@ -140,7 +141,8 @@ The watch is the reserved standing check `state/ops-watch.check.sh`, registered That shim only exports the home and runs `bin/fm-ops-inbox-poll.sh`; the watcher runs the registered bytes and turns any output into one `check:` wake. Arming is a session-start bootstrap sweep, so it converges on its own rather than depending on anyone remembering to arm it. -By default a home arms the watch only when the configured alert spool actually exists, so a machine with no operations runtime writes nothing and prints nothing. +Without private configuration a home leaves the watch disabled, so a machine with no selected operations runtime writes nothing and prints nothing. +With `enabled: auto`, a home arms the watch only when its configured alert spool actually exists. Secondmate homes never auto-arm, because the primary home owns the one machine-wide inbox, but an explicit `enabled: true` still arms a secondmate home. The local config is not inherited into secondmate homes. In auto mode, bootstrap disarms it again when the inbox goes away. @@ -152,13 +154,15 @@ A standing poll only ever reaches the first mate through a live watcher, so a ho ## Configuration -Local, gitignored `config/ops-inbox.json` under the effective home overrides the defaults. -Every key is optional, and an unrecognized key is refused rather than ignored, so a mistyped threshold can never read as a configured one. +Local, gitignored `config/ops-inbox.json` under the effective home opts into the watch and holds its source paths and thresholds. +Its absence disables the watch. +The threshold keys are optional, but an active config must provide `state_dir` or all three explicit paths. +An unrecognized key is refused rather than ignored, so a mistyped threshold can never read as a configured one. ```json { "enabled": "auto", - "state_dir": "/absolute/path/to/.openclaw/state", + "state_dir": "/absolute/path/to/operations-state", "age_hours": 6, "count": 25, "remind_hours": 24, @@ -168,20 +172,9 @@ Every key is optional, and an unrecognized key is refused rather than ignored, s } ``` -- `enabled` is `auto` (default: arm only when the spool exists), `true` (always arm, and treat a missing spool or receipt as an alerting failure), or `false` (never arm). -- `state_dir` selects the watched directory; `spool`, `acks`, and `receipt` override those three paths individually. +- `enabled` is `auto` (the default when this config exists: arm only when the spool exists), `true` (always arm, and treat a missing spool or receipt as an alerting failure), or `false` (never arm). +- `state_dir` selects the watched directory; `spool`, `acks`, and `receipt` override those three paths individually. An active config must provide `state_dir` or all three explicit paths. - `remind_hours: 0` disables re-reminding, and `receipt_stale_hours: 0` disables the staleness condition. - `max_lines` bounds how many recent spool and acknowledgement lines one check reads (default 20000). -- `FM_OPS_INBOX_STATE_DIR` overrides the default watched directory for tests and specialized setups; an explicit config value always wins over it. `docs/configuration.md` owns where this file sits among the other local operating choices, and `bin/fm-ops-inbox-lib.sh`'s header owns the exact resolution mechanics. - -## Two paths named "ops-inbox" - -The JSONL spool above is the alert inbox this watch reads. -A second, unrelated path shares the name: some operations routines drop plain-text `.event` files into `<firstmate-home>/ops-inbox/<source>/`, a spool with no severity field, no acknowledgement model, and no reader on the Firstmate side. - -Those two are not two views of one inbox; they are one watched inbox and one unread drop directory. -Firstmate treats the JSONL spool plus its receipt as the single alert surface, and gitignores the drop directory so its machine-local files can never be committed into this shared repo. -Converging the routines that write `.event` files onto the JSONL spool belongs to the operations repository that owns those routines, not here: an alert only reaches this watch once its producer records it in the spool. -Until that convergence lands, an alert written only as an `.event` file is not covered by this watch. diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index 4de442c3415..75053f0cfbf 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -290,13 +290,10 @@ FM_PI_PACKAGE_DIR="$(npm root -g)/@earendil-works/pi-coding-agent" tests/fm-pi-p ok - tracked Pi extensions pass strict no-emit typecheck against Pi 0.82.0 ok - Firstmate Pi footer preserves fields, statuses, and cumulative native usage ok - Pi 0.82.0 real TUI rendered the Firstmate footer and retained its project/branch/model title -ok - Pi 0.82.0 model picker defaults to OAuth subscriptions and keeps OpenRouter under all providers ``` The focused Pi presentation test resolves the installed compiler and Pi package through the npm environment, covers narrow, medium, and wide footer layouts, asserts that every complete native field and status value remains represented without overflow, verifies cumulative usage across every Pi 0.82.0 usage-bearing entry type, and checks stable single-line keyed status rendering. Its offline real-TUI check launches only the tracked footer extension, confirms the themed footer renders without provider traffic, and reads tmux's terminal title after startup to verify the project, branch, and model title survives Pi's default rebind update. -The isolated model-picker check supplies fixture OAuth credentials for Anthropic and OpenAI Codex alongside an OpenRouter API key, verifies the picker opens on the subscription-backed scoped providers, and verifies Tab still reaches OpenRouter through the all-provider view. -The tracked model scope uses family and provider wildcards rather than a fixed model catalog, so Pi catalog refreshes can add subscription-backed models without a Firstmate settings update. ## Wedge-alarm channels diff --git a/tests/fm-ops-inbox-watch.test.sh b/tests/fm-ops-inbox-watch.test.sh index 3e7de9fde20..8ea067dc586 100755 --- a/tests/fm-ops-inbox-watch.test.sh +++ b/tests/fm-ops-inbox-watch.test.sh @@ -141,7 +141,7 @@ run_bootstrap_without_jq() { # <home> printf '%s\n' ' builtin command "$@"' printf '%s\n' '}' } > "$bash_env" - BASH_ENV="$bash_env" FM_HOME="$home" FM_OPS_INBOX_STATE_DIR="$home/ops" \ + BASH_ENV="$bash_env" FM_HOME="$home" \ FM_BOOTSTRAP_VERBOSE_FACTS=1 "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null \ | grep -E 'MISSING: jq|OPS_INBOX|operational alert watch' || true } @@ -169,8 +169,8 @@ test_quiet_backlog_is_silent() { local home out home=$(make_home quiet) configure "$home" - alert "$home" q1 600 backup-verify - alert "$home" q2 600 routine-scheduler + alert "$home" q1 600 source-alpha + alert "$home" q2 600 source-beta receipt "$home" 2 60 out=$(run_poll "$home") [ -z "$out" ] || fail "a small, fresh, reviewed-recently backlog must not wake firstmate (got: $out)" @@ -182,21 +182,21 @@ test_age_threshold_wakes_with_digest() { local home out home=$(make_home age) configure "$home" - alert "$home" a1 32400 backup-verify # 9h old, past the 6h default - alert "$home" a2 600 backup-verify - alert "$home" a3 600 routine-scheduler - alert "$home" a4 600 acked-class - alert "$home" a5 600 warning-class warning + alert "$home" a1 32400 source-alpha # 9h old, past the 6h default + alert "$home" a2 600 source-alpha + alert "$home" a3 600 source-beta + alert "$home" a4 600 source-acked + alert "$home" a5 600 source-warning warning ack_alert "$home" a4 receipt "$home" 3 60 out=$(run_poll "$home") assert_contains "$out" "ops-inbox:" "an aged critical backlog must wake firstmate" assert_contains "$out" "3 unacked critical alerts" "the digest must carry the unacked count" assert_contains "$out" "oldest 9h" "the digest must carry the oldest alert age" - assert_contains "$out" "top: backup-verify 2" "the digest must rank the busiest alert class first" - assert_contains "$out" "routine-scheduler 1" "the digest must name the other classes" - assert_not_contains "$out" "acked-class" "an acknowledged alert must not reach the digest" - assert_not_contains "$out" "warning-class" "a non-critical alert must not reach the digest" + assert_contains "$out" "top: source-alpha 2" "the digest must rank the busiest alert class first" + assert_contains "$out" "source-beta 1" "the digest must name the other classes" + assert_not_contains "$out" "source-acked" "an acknowledged alert must not reach the digest" + assert_not_contains "$out" "source-warning" "a non-critical alert must not reach the digest" [ "$(printf '%s' "$out" | wc -l | tr -d ' ')" = 0 ] \ || fail "the wake must be exactly one line" assert_present "$home/state/.ops-inbox-wake" "a wake must be recorded for dedupe" @@ -209,7 +209,7 @@ test_count_threshold_wakes_when_all_alerts_are_new() { configure "$home" i=0 while [ "$i" -lt 30 ]; do - alert "$home" "c$i" 60 flood + alert "$home" "c$i" 60 source-volume i=$((i + 1)) done receipt "$home" 30 60 @@ -222,7 +222,7 @@ test_stale_receipt_is_itself_wake_worthy() { local home out home=$(make_home stale-receipt) configure "$home" - alert "$home" s1 600 backup-verify + alert "$home" s1 600 source-alpha receipt "$home" 1 21600 # 6h old, past the 3h default out=$(run_poll "$home") assert_contains "$out" "alert receipt stale 6h" "a stale receipt must wake even when the backlog itself is small" @@ -234,7 +234,7 @@ test_missing_receipt_is_wake_worthy() { local home out home=$(make_home missing-receipt) configure "$home" - alert "$home" m1 600 backup-verify + alert "$home" m1 600 source-alpha out=$(run_poll "$home") assert_contains "$out" "alert receipt missing" "a missing receipt must wake firstmate" pass "a missing review receipt wakes firstmate on its own" @@ -244,7 +244,7 @@ test_fresh_receipt_count_is_authoritative() { local home out home=$(make_home receipt-count) configure "$home" - alert "$home" r1 32400 backup-verify + alert "$home" r1 32400 source-alpha receipt "$home" 41 60 out=$(run_poll "$home") assert_contains "$out" "41 unacked critical alerts" "a fresh receipt's own count must be reported" @@ -255,7 +255,7 @@ test_leading_zero_config_number_is_canonicalized() { local home out home=$(make_home leading-zero-config) configure "$home" '"receipt_stale_hours": "08"' - alert "$home" lzc1 600 backup-verify + alert "$home" lzc1 600 source-alpha receipt "$home" 1 32400 out=$(run_poll "$home") assert_contains "$out" "alert receipt stale 9h" \ @@ -267,9 +267,9 @@ test_leading_zero_receipt_count_is_canonicalized() { local home out home=$(make_home leading-zero-receipt) configure "$home" '"count": 0, "growth": 1' - alert "$home" lzr1 600 backup-verify + alert "$home" lzr1 600 source-alpha receipt "$home" '"08"' 60 - printf '%s\n%s\n%s\n%s\n%s\n' fm-ops-inbox-wake-v1 "$NOW" 7 fresh backup-verify \ + printf '%s\n%s\n%s\n%s\n%s\n' fm-ops-inbox-wake-v1 "$NOW" 7 fresh source-alpha \ > "$home/state/.ops-inbox-wake" out=$(run_poll "$home") assert_contains "$out" "8 unacked critical alerts" \ @@ -281,10 +281,10 @@ test_leading_zero_sidecar_numbers_are_canonicalized() { local home out stale_epoch home=$(make_home leading-zero-sidecar) configure "$home" - alert "$home" lzs1 32400 backup-verify + alert "$home" lzs1 32400 source-alpha receipt "$home" 8 60 stale_epoch=$((NOW - 90000)) - printf '%s\n%s\n%s\n%s\n%s\n' fm-ops-inbox-wake-v1 "000$stale_epoch" 00000008 fresh backup-verify \ + printf '%s\n%s\n%s\n%s\n%s\n' fm-ops-inbox-wake-v1 "000$stale_epoch" 00000008 fresh source-alpha \ > "$home/state/.ops-inbox-wake" out=$(run_poll "$home") assert_contains "$out" "ops-inbox:" \ @@ -296,7 +296,7 @@ test_corrupt_recent_receipt_is_unreadable() { local home out home=$(make_home corrupt-receipt) configure "$home" - alert "$home" cr1 600 backup-verify + alert "$home" cr1 600 source-alpha printf 'not json\n' > "$home/ops/ops-inbox-receipt.json" touch -t "$(receipt_stamp 60)" "$home/ops/ops-inbox-receipt.json" out=$(run_poll "$home") @@ -325,7 +325,7 @@ test_malformed_timestamp_fails_closed() { local home out home=$(make_home malformed-timestamp) configure "$home" - printf '{"id":"bad-ts","ts":"!","source":"routine-scheduler","severity":"critical","message":"bad timestamp","ack":false}\n' \ + printf '{"id":"bad-ts","ts":"!","source":"source-beta","severity":"critical","message":"bad timestamp","ack":false}\n' \ >> "$home/ops/ops-inbox.jsonl" receipt "$home" 1 60 out=$(run_poll "$home") @@ -338,7 +338,7 @@ test_calendar_invalid_timestamp_fails_closed() { local home out home=$(make_home calendar-invalid-timestamp) configure "$home" - printf '{"id":"bad-calendar","ts":"2026-00-00T00:00:00Z","source":"routine-scheduler","severity":"critical","message":"bad timestamp","ack":false}\n' \ + printf '{"id":"bad-calendar","ts":"2026-00-00T00:00:00Z","source":"source-beta","severity":"critical","message":"bad timestamp","ack":false}\n' \ >> "$home/ops/ops-inbox.jsonl" receipt "$home" 1 60 out=$(run_poll "$home") @@ -351,7 +351,7 @@ test_torn_trailing_spool_write_is_tolerated() { local home out home=$(make_home torn-spool) configure "$home" - alert "$home" complete 32400 backup-verify + alert "$home" complete 32400 source-alpha printf '{"id":"torn"' >> "$home/ops/ops-inbox.jsonl" receipt "$home" 1 60 out=$(run_poll "$home") @@ -366,7 +366,7 @@ test_torn_trailing_spool_write_does_not_trigger_cap() { local home out home=$(make_home torn-at-cap) configure "$home" '"max_lines": 1' - alert "$home" complete-at-cap 600 backup-verify + alert "$home" complete-at-cap 600 source-alpha printf '{"id":"still-being-written"' >> "$home/ops/ops-inbox.jsonl" receipt "$home" 1 60 out=$(run_poll "$home") @@ -378,7 +378,7 @@ test_valid_unterminated_final_record_is_scanned() { local home out home=$(make_home valid-unterminated) configure "$home" - printf '{"id":"final","ts":"%s","source":"backup-verify","severity":"critical","message":"complete","ack":false}' \ + printf '{"id":"final","ts":"%s","source":"source-alpha","severity":"critical","message":"complete","ack":false}' \ "$(iso_at 32400)" >> "$home/ops/ops-inbox.jsonl" receipt "$home" 1 60 out=$(run_poll "$home") @@ -395,8 +395,8 @@ test_acknowledged_garbage_timestamp_is_ignored() { local home out home=$(make_home acked-garbage-timestamp) configure "$home" - alert "$home" quiet 600 backup-verify - printf '{"id":"acked-bad","ts":"!","source":"routine-scheduler","severity":"critical","message":"bad timestamp","ack":false}\n' \ + alert "$home" quiet 600 source-alpha + printf '{"id":"acked-bad","ts":"!","source":"source-beta","severity":"critical","message":"bad timestamp","ack":false}\n' \ >> "$home/ops/ops-inbox.jsonl" ack_alert "$home" acked-bad receipt "$home" 1 60 @@ -409,7 +409,7 @@ test_standing_backlog_does_not_rewake_every_poll() { local home first second home=$(make_home dedupe) configure "$home" - alert "$home" d1 32400 backup-verify + alert "$home" d1 32400 source-alpha receipt "$home" 1 60 first=$(run_poll "$home") assert_contains "$first" "ops-inbox:" "the first sight of a backlog must wake" @@ -422,16 +422,16 @@ test_material_growth_wakes_again() { local home out i home=$(make_home growth) configure "$home" '"growth": 5' - alert "$home" g0 32400 backup-verify + alert "$home" g0 32400 source-alpha receipt "$home" 1 60 run_poll "$home" >/dev/null - alert "$home" g1 600 backup-verify + alert "$home" g1 600 source-alpha receipt "$home" 2 60 out=$(run_poll "$home") [ -z "$out" ] || fail "one extra alert is not material growth (got: $out)" i=0 while [ "$i" -lt 5 ]; do - alert "$home" "gg$i" 600 backup-verify + alert "$home" "gg$i" 600 source-alpha i=$((i + 1)) done receipt "$home" 7 60 @@ -444,13 +444,13 @@ test_new_alert_class_wakes_again() { local home out home=$(make_home new-class) configure "$home" - alert "$home" n1 32400 backup-verify + alert "$home" n1 32400 source-alpha receipt "$home" 1 60 run_poll "$home" >/dev/null - alert "$home" n2 600 disk-pressure + alert "$home" n2 600 source-gamma receipt "$home" 2 60 out=$(run_poll "$home") - assert_contains "$out" "disk-pressure" "a newly failing alert class must wake again immediately" + assert_contains "$out" "source-gamma" "a newly failing alert class must wake again immediately" pass "a new alert class wakes again without waiting for the re-remind interval" } @@ -458,13 +458,13 @@ test_reremind_interval_wakes_again() { local home out stale_epoch home=$(make_home reremind) configure "$home" '"remind_hours": 1' - alert "$home" rr1 32400 backup-verify + alert "$home" rr1 32400 source-alpha receipt "$home" 1 60 run_poll "$home" >/dev/null out=$(run_poll "$home") [ -z "$out" ] || fail "the same backlog must stay quiet inside the re-remind interval (got: $out)" stale_epoch=$((NOW - 7200)) - printf '%s\n%s\n%s\n%s\n%s\n' fm-ops-inbox-wake-v1 "$stale_epoch" 1 fresh backup-verify \ + printf '%s\n%s\n%s\n%s\n%s\n' fm-ops-inbox-wake-v1 "$stale_epoch" 1 fresh source-alpha \ > "$home/state/.ops-inbox-wake" out=$(run_poll "$home") assert_contains "$out" "ops-inbox:" "an unreviewed backlog must be raised again once the re-remind interval passes" @@ -475,7 +475,7 @@ test_cleared_backlog_resets_dedupe() { local home out home=$(make_home cleared) configure "$home" - alert "$home" x1 32400 backup-verify + alert "$home" x1 32400 source-alpha receipt "$home" 1 60 run_poll "$home" >/dev/null assert_present "$home/state/.ops-inbox-wake" "the first wake must be recorded" @@ -484,7 +484,7 @@ test_cleared_backlog_resets_dedupe() { out=$(run_poll "$home") [ -z "$out" ] || fail "a cleared backlog must be silent (got: $out)" assert_absent "$home/state/.ops-inbox-wake" "a cleared backlog must drop its dedupe record" - alert "$home" x2 32400 backup-verify + alert "$home" x2 32400 source-alpha receipt "$home" 1 60 out=$(run_poll "$home") assert_contains "$out" "ops-inbox:" "a fresh backlog after a cleared one must wake immediately" @@ -539,7 +539,7 @@ test_recognized_null_configuration_keeps_default() { local home out home=$(make_home recognized-null-config) configure "$home" '"count": null' - alert "$home" null-default 600 backup-verify + alert "$home" null-default 600 source-alpha receipt "$home" 1 60 out=$(run_poll "$home") [ -z "$out" ] || fail "a recognized null setting must retain its quiet default (got: $out)" @@ -599,7 +599,7 @@ test_explicit_disable_wins() { local home out home=$(make_home disabled) configure "$home" '"enabled": false' - alert "$home" o1 32400 backup-verify + alert "$home" o1 32400 source-alpha out=$(run_poll "$home") [ -z "$out" ] || fail "an explicitly disabled watch must stay silent (got: $out)" pass "an explicitly disabled alert watch stays silent" @@ -627,35 +627,24 @@ test_missing_jq_with_configuration_fails_closed() { pass "a configured watch without jq wakes fail-closed" } -test_missing_jq_after_default_configuration_fails_closed() { - local home out fault_path - home=$(make_home default-missing-jq) - alert "$home" jq1 600 backup-verify - fault_path=$(make_path_without_jq default-missing-jq) - out=$(PATH="$fault_path" FM_HOME="$home" FM_OPS_INBOX_STATE_DIR="$home/ops" \ - "$fault_path/bash" "$POLL" 2>/dev/null) - assert_contains "$out" "jq is not installed" \ - "an auto-armed default watch without jq must emit the dedicated failure wake" - pass "an auto-armed watch without jq wakes fail-closed" -} - test_bootstrap_missing_jq_reports_alert_watch_outage() { local home out home=$(make_home bootstrap-missing-jq) - alert "$home" bootstrap-jq1 600 backup-verify + configure "$home" + alert "$home" bootstrap-jq1 600 source-alpha out=$(run_bootstrap_without_jq "$home") assert_contains "$out" "MISSING: jq" \ "bootstrap must retain the jq installation diagnostic" - assert_contains "$out" "OPS_INBOX: the operational alert watch cannot read the inbox until jq is installed; install jq, then rerun bootstrap" \ - "bootstrap must classify the missing-jq alert-watch outage with remediation" - pass "bootstrap classifies a missing-jq alert-watch outage" + assert_contains "$out" "OPS_INBOX: jq is required to read config/ops-inbox.json; fix config/ops-inbox.json, then rerun bootstrap" \ + "bootstrap must classify an unreadable private alert-watch configuration" + pass "bootstrap classifies a missing-jq private alert-watch configuration" } test_current_time_failure_fails_closed() { local home out fault_path home=$(make_home current-time-failure) configure "$home" - alert "$home" time1 600 backup-verify + alert "$home" time1 600 source-alpha receipt "$home" 1 60 fault_path=$(make_fault_path current-time-failure date 1) out=$(run_poll_with_path "$home" "$fault_path") @@ -677,7 +666,7 @@ test_old_spool_alert_wakes_when_receipt_count_is_zero() { local home out home=$(make_home zero-receipt-old-alert) configure "$home" - alert "$home" receipt-zero-old 32400 backup-verify + alert "$home" receipt-zero-old 32400 source-alpha receipt "$home" 0 60 out=$(run_poll "$home") assert_contains "$out" "oldest 9h" \ @@ -689,7 +678,7 @@ test_bounded_sample_failure_fails_closed() { local home out fault_path home=$(make_home sample-failure) configure "$home" - alert "$home" sample1 600 backup-verify + alert "$home" sample1 600 source-alpha receipt "$home" 1 60 fault_path=$(make_fault_path sample-failure head 1) out=$(run_poll_with_path "$home" "$fault_path") @@ -702,7 +691,7 @@ test_acknowledgement_parse_failure_fails_closed() { local home out home=$(make_home acknowledgement-parse-failure) configure "$home" - alert "$home" ack-parse1 600 backup-verify + alert "$home" ack-parse1 600 source-alpha printf '{"event_id":"broken"\n' > "$home/ops/ops-inbox-acks.jsonl" receipt "$home" 1 60 out=$(run_poll "$home") @@ -715,7 +704,7 @@ test_spool_tail_failure_fails_closed() { local home out fault_path home=$(make_home spool-tail-failure) configure "$home" - alert "$home" tail1 600 backup-verify + alert "$home" tail1 600 source-alpha receipt "$home" 1 60 fault_path=$(make_fault_path spool-tail-failure tail 1) out=$(run_poll_with_path "$home" "$fault_path") @@ -728,7 +717,7 @@ test_acknowledgement_filter_failure_fails_closed() { local home out fault_path home=$(make_home acknowledgement-filter-failure) configure "$home" - alert "$home" awk1 600 backup-verify + alert "$home" awk1 600 source-alpha receipt "$home" 1 60 fault_path=$(make_fault_path acknowledgement-filter-failure awk 1) out=$(run_poll_with_path "$home" "$fault_path") @@ -741,7 +730,7 @@ test_scan_summary_failure_fails_closed() { local home out fault_path home=$(make_home scan-summary-failure) configure "$home" - alert "$home" sort1 600 backup-verify + alert "$home" sort1 600 source-alpha receipt "$home" 1 60 fault_path=$(make_fault_path scan-summary-failure sort 1) out=$(run_poll_with_path "$home" "$fault_path") @@ -754,7 +743,7 @@ test_oldest_epoch_conversion_failure_fails_closed() { local home out fault_path home=$(make_home epoch-conversion-failure) configure "$home" - alert "$home" epoch1 600 backup-verify + alert "$home" epoch1 600 source-alpha receipt "$home" 1 60 fault_path=$(make_fault_path epoch-conversion-failure date 2) out=$(run_poll_with_path "$home" "$fault_path") @@ -767,7 +756,7 @@ test_dedupe_clear_failure_fails_closed() { local home out home=$(make_home dedupe-clear-failure) configure "$home" - alert "$home" clear1 600 backup-verify + alert "$home" clear1 600 source-alpha receipt "$home" 1 60 mkdir "$home/state/.ops-inbox-wake" out=$(run_poll "$home") @@ -780,7 +769,7 @@ test_bootstrap_arms_registers_and_is_idempotent() { local home out sum1 sum2 home=$(make_home arm) configure "$home" - alert "$home" b1 600 backup-verify + alert "$home" b1 600 source-alpha out=$(run_bootstrap "$home") assert_contains "$out" "operational alert watch armed" "bootstrap must report the watch it armed" assert_present "$home/state/ops-watch.check.sh" "bootstrap must drop the standing check" @@ -800,7 +789,7 @@ test_bootstrap_disarms_when_the_inbox_goes_away() { local home out home=$(make_home disarm) configure "$home" - alert "$home" b2 600 backup-verify + alert "$home" b2 600 source-alpha run_bootstrap "$home" >/dev/null assert_present "$home/state/ops-watch.check.sh" "the watch must be armed before the disarm case" rm -f "$home/ops/ops-inbox.jsonl" @@ -815,7 +804,7 @@ test_bootstrap_refuses_to_take_a_live_task_id() { local home out home=$(make_home reserved) configure "$home" - alert "$home" b3 600 backup-verify + alert "$home" b3 600 source-alpha fm_write_meta "$home/state/ops-watch.meta" "window=firstmate:fm-ops-watch" "harness=echo" out=$(run_bootstrap "$home") assert_contains "$out" "OPS_INBOX: task id ops-watch is in use" "bootstrap must refuse to collide with live work" @@ -866,7 +855,7 @@ test_armed_watch_needs_supervision() { local home needed desc home=$(make_home supervision) configure "$home" - alert "$home" b4 600 backup-verify + alert "$home" b4 600 source-alpha needed=$( # shellcheck source=bin/fm-supervision-lib.sh . "$ROOT/bin/fm-supervision-lib.sh" @@ -897,7 +886,7 @@ test_read_cap_is_reported_not_hidden() { configure "$home" '"max_lines": 5' i=0 while [ "$i" -lt 8 ]; do - alert "$home" "cap$i" 600 flood + alert "$home" "cap$i" 600 source-volume i=$((i + 1)) done receipt "$home" 8 60 @@ -913,7 +902,7 @@ test_secondmate_home_does_not_auto_arm() { local home out home=$(make_home secondmate-auto) configure "$home" - alert "$home" sm1 32400 backup-verify + alert "$home" sm1 32400 source-alpha printf '%s\n' alpha > "$home/.fm-secondmate-home" out=$(run_poll "$home") [ -z "$out" ] || fail "one machine has one alert inbox, so a secondmate home must not raise it too (got: $out)" @@ -931,8 +920,8 @@ test_watcher_dispatches_the_registered_check() { local home status wake drained home=$(make_home watcher) configure "$home" - alert "$home" w1 32400 backup-verify - alert "$home" w2 600 routine-scheduler + alert "$home" w1 32400 source-alpha + alert "$home" w2 600 source-beta receipt "$home" 2 60 seed_migration_markers "$home" run_bootstrap "$home" >/dev/null @@ -1002,7 +991,6 @@ test_zero_read_cap_is_reported test_explicit_disable_wins test_explicit_enable_without_spool_fails_closed test_missing_jq_with_configuration_fails_closed -test_missing_jq_after_default_configuration_fails_closed test_bootstrap_missing_jq_reports_alert_watch_outage test_current_time_failure_fails_closed test_inert_watch_does_not_require_current_time diff --git a/tests/fm-pi-primary-types.test.sh b/tests/fm-pi-primary-types.test.sh index 336a80b81d3..479f4e4801a 100755 --- a/tests/fm-pi-primary-types.test.sh +++ b/tests/fm-pi-primary-types.test.sh @@ -13,9 +13,6 @@ if [ ! -f "$PI_PACKAGE_DIR/package.json" ]; then exit 0 fi TSC_BIN=${FM_TSC_BIN:-"$(command -v tsc 2>/dev/null || true)"} -if [ ! -x "$TSC_BIN" ]; then - TSC_BIN="$GLOBAL_NODE_MODULES/openclaw/node_modules/typescript/bin/tsc" -fi if [ ! -x "$TSC_BIN" ]; then echo "skip: tsc not found for Pi extension typecheck" exit 0 @@ -188,101 +185,3 @@ run_real_tui_contract() ( ) run_real_tui_contract - -run_real_model_picker_contract() ( - PI_BIN=${FM_PI_BIN:-"$(command -v pi 2>/dev/null || true)"} - if [ ! -x "$PI_BIN" ] || ! command -v tmux >/dev/null 2>&1; then - printf 'skip: pi or tmux not found for real Pi model-picker contract\n' - exit 0 - fi - - fail_picker() { - printf 'not ok - %s\n' "$1" >&2 - exit 1 - } - - socket="fm-pi-model-picker-$$" - session=pi-model-picker - pi_home="$TMP_ROOT/pi-model-home" - mkdir -p "$pi_home" - trap 'tmux -L "$socket" kill-server 2>/dev/null || true' EXIT - - cat > "$pi_home/auth.json" <<'JSON' -{ - "anthropic": { - "type": "oauth", - "access": "fixture-access", - "refresh": "fixture-refresh", - "expires": 4102444800000 - }, - "openai-codex": { - "type": "oauth", - "access": "fixture-access", - "refresh": "fixture-refresh", - "expires": 4102444800000, - "accountId": "fixture-account" - }, - "openrouter": { - "type": "api_key", - "key": "fixture-key" - } -} -JSON - chmod 600 "$pi_home/auth.json" - - printf -v launch \ - 'cd %q && env -u ANTHROPIC_API_KEY -u ANTHROPIC_OAUTH_TOKEN -u OPENAI_API_KEY -u OPENROUTER_API_KEY PI_OFFLINE=1 PI_CODING_AGENT_DIR=%q %q --approve --no-session --no-context-files --no-skills --no-prompt-templates --no-extensions' \ - "$ROOT" "$pi_home" "$PI_BIN" - tmux -L "$socket" new-session -d -s "$session" -x 120 -y 34 "$launch" \ - || fail_picker "could not launch Pi model-picker contract" - - pane= - i=0 - while [ "$i" -lt 120 ]; do - pane=$(tmux -L "$socket" capture-pane -p -t "$session" -S -80 2>/dev/null || true) - printf '%s\n' "$pane" | grep -Fq "ctrl+o more" && break - tmux -L "$socket" has-session -t "$session" 2>/dev/null \ - || fail_picker "Pi exited before model-picker startup: $pane" - sleep 0.05 - i=$((i + 1)) - done - printf '%s\n' "$pane" | grep -Fq "ctrl+o more" \ - || fail_picker "Pi did not reach model-picker startup: $pane" - - tmux -L "$socket" send-keys -t "$session" -l '/model' - tmux -L "$socket" send-keys -t "$session" Enter - sleep 0.2 - tmux -L "$socket" send-keys -t "$session" -l 'openai-codex' - - i=0 - while [ "$i" -lt 120 ]; do - pane=$(tmux -L "$socket" capture-pane -p -t "$session" -S -80 2>/dev/null || true) - printf '%s\n' "$pane" | grep -Fq "[openai-codex]" && break - sleep 0.05 - i=$((i + 1)) - done - printf '%s\n' "$pane" | grep -Fq "Scope: all | scoped" \ - || fail_picker "Pi did not open the scoped model-picker view: $pane" - printf '%s\n' "$pane" | grep -Fq "[openai-codex]" \ - || fail_picker "Pi scoped picker did not expose the ChatGPT OAuth model: $pane" - if printf '%s\n' "$pane" | grep -Fq "[openrouter]"; then - fail_picker "Pi scoped picker included OpenRouter models: $pane" - fi - - tmux -L "$socket" send-keys -t "$session" Tab - tmux -L "$socket" send-keys -t "$session" C-u - tmux -L "$socket" send-keys -t "$session" -l 'openrouter' - i=0 - while [ "$i" -lt 120 ]; do - pane=$(tmux -L "$socket" capture-pane -p -t "$session" -S -80 2>/dev/null || true) - printf '%s\n' "$pane" | grep -Fq "[openrouter]" && break - sleep 0.05 - i=$((i + 1)) - done - printf '%s\n' "$pane" | grep -Fq "[openrouter]" \ - || fail_picker "Pi all-provider picker did not preserve OpenRouter access: $pane" - - printf 'ok - Pi %s model picker defaults to OAuth subscriptions and keeps OpenRouter under all providers\n' "$("$PI_BIN" --version)" -) - -run_real_model_picker_contract diff --git a/tests/lib.sh b/tests/lib.sh index 267fa6284b2..497965dfb09 100644 --- a/tests/lib.sh +++ b/tests/lib.sh @@ -34,14 +34,6 @@ FM_TEST_LIB_SOURCED=1 # strips this to verify real refusal. export FM_GATE_REFUSE_BYPASS=1 -# Keep the operational alert inbox watch out of every temp home by default. Its -# arming decision otherwise reads the real machine's alert spool under $HOME, so -# a developer machine that happens to run that operations runtime would arm a -# standing check inside unrelated test homes and change their bootstrap results. -# Point it at a path that cannot exist; a test that exercises the watch sets its -# own FM_OPS_INBOX_STATE_DIR or config/ops-inbox.json explicitly. -export FM_OPS_INBOX_STATE_DIR="/nonexistent/fm-tests-no-ops-inbox" - # Resolve the repo root from this library's own location. Consumed by sourcing # test files, not by this library, so it reads as "unused" here. # shellcheck disable=SC2034 From ffa3723f0c3811411738e9fbe0961361a2cf2bc0 Mon Sep 17 00:00:00 2001 From: quinnbot-ai <quinnbot.ai@gmail.com> Date: Sun, 2 Aug 2026 16:15:06 -0700 Subject: [PATCH 30/70] fix(spawn): recovery lock wait bound to the live owner, with guarded Herdr lifecycle stress evidence (#80) * docs(verification): record Herdr lifecycle stress * no-mistakes(review): Fix exact recovery locking and deterministic regression cleanup * no-mistakes(document): Clarify Herdr recovery lock and stress evidence * chore: retrigger CI * test(herdr): cover live-owner recovery lock wait * fix(spawn): accept wrapped delivery markers --------- Co-authored-by: QuinnBot <quinnbot@proton.me> --- bin/fm-spawn.sh | 45 +++++-------- docs/herdr-backend.md | 2 +- docs/verification/runtime-backends.md | 22 +++++++ .../fm-backend-herdr-presentation-e2e.test.sh | 66 ++++++++++++++++++- tests/fm-backend-herdr.test.sh | 40 +++++------ tests/fm-spawn-launch-delivery.test.sh | 16 +++++ 6 files changed, 138 insertions(+), 53 deletions(-) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 4229403b170..00b3ea9bea0 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -284,9 +284,6 @@ HERDR_PRESENTATION_ORDER_LOCK_HELD=0 # Fresh best-effort projection gives a contended session lock five seconds # before falling back to the ordinary flat layout. HERDR_PRESENTATION_ORDER_LOCK_ATTEMPTS=50 -# Exact recovery gives each distinct lock owner enough time for its permitted -# 60-second Treehouse handoff plus bounded projection and launch-settle work. -HERDR_PRESENTATION_RECOVERY_LOCK_ATTEMPTS=700 SPAWN_TASK_LOCK= SPAWN_TASK_LOCK_HELD=0 CONFIG_INHERIT_LOCK= @@ -369,39 +366,26 @@ spawn_abort_cleanup() { } trap spawn_abort_cleanup EXIT -# One bounded lock per live Herdr session/socket, shared across all homes. +# One lock per live Herdr session/socket, shared across all homes. # <session> is required so secondmate and primary spawns serialize against the # same session without writing any other home's state directory. spawn_herdr_presentation_order_lock_acquire() { - local session=${1:-} max_attempts=${2:-$HERDR_PRESENTATION_ORDER_LOCK_ATTEMPTS} - local reset_on_owner_change=${3:-0} attempt lock_path owner observed_owner + local session=${1:-} policy=${2:-best-effort} attempt lock_path [ -n "$session" ] || session=$(fm_backend_herdr_session) - case "$max_attempts" in - ''|*[!0-9]*) return 1 ;; - esac - [ "$max_attempts" -gt 0 ] || return 1 - case "$reset_on_owner_change" in - 0|1) ;; - *) return 1 ;; - esac lock_path=$(fm_backend_herdr_presentation_session_lock_path "$session") || return 1 HERDR_PRESENTATION_ORDER_LOCK="$lock_path" + if [ "$policy" = exact-recovery ]; then + fm_lock_acquire_wait "$HERDR_PRESENTATION_ORDER_LOCK" || return 1 + HERDR_PRESENTATION_ORDER_LOCK_HELD=1 + return 0 + fi + [ "$policy" = best-effort ] || return 1 attempt=0 - observed_owner= - while [ "$attempt" -lt "$max_attempts" ]; do + while [ "$attempt" -lt "$HERDR_PRESENTATION_ORDER_LOCK_ATTEMPTS" ]; do if fm_lock_try_acquire "$HERDR_PRESENTATION_ORDER_LOCK"; then HERDR_PRESENTATION_ORDER_LOCK_HELD=1 return 0 fi - if [ "$reset_on_owner_change" = 1 ]; then - owner=$(fm_lock_link_owner "$HERDR_PRESENTATION_ORDER_LOCK" 2>/dev/null || true) - if [ -n "$owner" ]; then - if [ -n "$observed_owner" ] && [ "$owner" != "$observed_owner" ]; then - attempt=0 - fi - observed_owner=$owner - fi - fi sleep 0.1 attempt=$((attempt + 1)) done @@ -1107,8 +1091,7 @@ case "$BACKEND" in echo "error: herdr presentation recovery could not ensure its exact named session" >&2 exit 1 } - spawn_herdr_presentation_order_lock_acquire \ - "$HERDR_SES" "$HERDR_PRESENTATION_RECOVERY_LOCK_ATTEMPTS" 1 || { + spawn_herdr_presentation_order_lock_acquire "$HERDR_SES" exact-recovery || { echo "error: herdr presentation recovery could not acquire its session lock; refusing a concurrent resume" >&2 exit 1 } @@ -1335,7 +1318,13 @@ spawn_capture() { # <target> } spawn_capture_has_line() { # <capture> <exact-line> - printf '%s\n' "$1" | grep -Fqx "$2" + # Herdr's pane read exposes terminal-row wrapping literally. A marker can + # therefore be split across adjacent rows even though the shell emitted one + # newline-terminated line. The full marker is deliberately absent from the + # submitted probe, so joining captured rows cannot mistake typed input for + # execution proof. + printf '%s\n' "$1" | grep -Fqx "$2" \ + || printf '%s' "$1" | tr -d '\r\n' | grep -Fq "$2" } spawn_wait_for_marker() { # <target> <marker> diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index 7de16aef413..42c81b5ec03 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -116,7 +116,7 @@ If lock, snapshot, pane identity, or restoration is ambiguous, cleanup warns and Recovery is deliberately conservative and presentation-only. An existing journal suppresses another projected create. Before any recovery mutation, Firstmate holds both the task spawn lock and the named-session presentation lock. -Exact recovery gives each distinct session-lock owner one complete bounded Treehouse handoff budget, so a progressing recovery wave can drain while fresh best-effort projection retains its shorter fallback-to-flat lock budget. +Exact recovery waits until the named-session presentation lock becomes available so it can outwait a peer's bounded Treehouse handoff, while fresh best-effort projection retains its five-second fallback-to-flat lock budget. A same-identity version 2 binding may replace one exact agent-free restart husk in place only when the physical home, session, metadata endpoint, unique token match, workspace shape and labels, parent identity and placement, and non-target focus snapshot all agree. The replacement tab and pane are created and verified before the old pane is rechecked and closed, then the journal advances atomically to the replacement endpoint before metadata publication. The reclaim path never moves, closes, deletes, or renames a workspace and never touches a parent, sibling, captain, or foreign pane. diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index 34cd8f6e9c5..e5bb97ae89e 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -340,6 +340,28 @@ FM_REQUIRE_MACOS_FOCUS_AUDIT=1 \ The test starts an `NSWorkspace.didActivateApplicationNotification` watcher before the complete projected spawn and keeps it active through a two-second settle window. It fails if any activation has an empty or different bundle identifier from the initially frontmost app. The required macOS Herdr-focus CI job makes an unavailable activation audit a hard failure, while non-macOS runs retain the logical focus checks without claiming app-activation coverage. + +The guarded lifecycle stress suite ran on 2026-08-02 against Herdr 0.7.3: + +```sh +HERDR_LAB_HELPER=bin/fm-herdr-lab.sh \ + tests/fm-backend-herdr-presentation-e2e.test.sh +``` + +Observed output: + +```text +ok - real Herdr lab: flag-off spawn retains the Stage 1 Herdr command sequence with zero ordering calls +ok - real Herdr lab: Hi Bit and Wheelhouse-style same-identity restarts reclaim one nested space with exact focus and idempotence +ok - real Herdr lab: secondmate restart binding and reclaim stay isolated to the exact child home and parent +ok - real Herdr lab: concurrent cross-home recoveries replace exact husks under one session lock with no focus drift +ok - real Herdr lab: multi-home exact-pane teardowns restore captain focus without workspace close authority +ok - real Herdr lab validation completed on Herdr 0.7.3 with the default-session tripwire intact +``` + +The runner exited with status 0. +The `fm-hibit-resume-r1` case held the named-session presentation lock for six seconds, beyond fresh projection's five-second budget, and the same-identity recovery completed after release instead of falling back or refusing the resume. +The guard exercised only a generated non-default `fm-lab-` session and captured the server-log window before and after the run. ### Workspace-removal focus safety The focus-flash regression ran on 2026-07-28 against Herdr 0.7.5 protocol 17 on macOS aarch64: diff --git a/tests/fm-backend-herdr-presentation-e2e.test.sh b/tests/fm-backend-herdr-presentation-e2e.test.sh index 5dcd6165d93..aaa2e335627 100755 --- a/tests/fm-backend-herdr-presentation-e2e.test.sh +++ b/tests/fm-backend-herdr-presentation-e2e.test.sh @@ -321,6 +321,8 @@ LAB_READY=0 RECORDED_WORKTREES="" LOCK_CONTENTION_OWNER_PID= MACOS_ACTIVATION_WATCH_PID= +RESTART_RECOVERY_LOCK_OWNER_PID= +RESTART_RECOVERY_PID= cleanup_all() { local wt if [ -n "$MACOS_ACTIVATION_WATCH_PID" ]; then @@ -333,6 +335,16 @@ cleanup_all() { wait "$LOCK_CONTENTION_OWNER_PID" 2>/dev/null || true LOCK_CONTENTION_OWNER_PID= fi + if [ -n "$RESTART_RECOVERY_PID" ]; then + kill "$RESTART_RECOVERY_PID" 2>/dev/null || true + wait "$RESTART_RECOVERY_PID" 2>/dev/null || true + RESTART_RECOVERY_PID= + fi + if [ -n "$RESTART_RECOVERY_LOCK_OWNER_PID" ]; then + kill "$RESTART_RECOVERY_LOCK_OWNER_PID" 2>/dev/null || true + wait "$RESTART_RECOVERY_LOCK_OWNER_PID" 2>/dev/null || true + RESTART_RECOVERY_LOCK_OWNER_PID= + fi while IFS= read -r wt; do [ -n "$wt" ] || continue [ -d "$wt" ] || continue @@ -1191,8 +1203,58 @@ for RESTART_ID in fm-hibit-resume-r1 wheelhouse-healing-r1; do fail "$RESTART_ID restart fixture unexpectedly retained a registered agent" fi RECLAIM_FOCUS=$(focus_snapshot) - spawn_task "$RESTART_ID" "$HOME_DIR" "$PROJECT_DIR" > "$TMP_ROOT/$RESTART_ID-reclaim.out" 2> "$TMP_ROOT/$RESTART_ID-reclaim.err" \ - || fail "$RESTART_ID same-identity reclaim failed: $(cat "$TMP_ROOT/$RESTART_ID-reclaim.err")" + if [ "$RESTART_ID" = fm-hibit-resume-r1 ]; then + RESTART_RECOVERY_LOCK_READY="$TMP_ROOT/restart-recovery-lock-ready" + RESTART_RECOVERY_LOCK_ATTEMPTED="$TMP_ROOT/restart-recovery-lock-attempted" + RESTART_RECOVERY_LOCK_RELEASE="$TMP_ROOT/restart-recovery-lock-release" + RESTART_RECOVERY_LOCK_PATH=$(session_presentation_lock_path) \ + || fail "could not resolve the session lock for restart recovery" + ROOT="$ROOT" READY="$RESTART_RECOVERY_LOCK_READY" ATTEMPTED="$RESTART_RECOVERY_LOCK_ATTEMPTED" RELEASE="$RESTART_RECOVERY_LOCK_RELEASE" LOCK="$RESTART_RECOVERY_LOCK_PATH" bash -c ' + . "$ROOT/bin/fm-wake-lib.sh" + fm_lock_try_acquire "$LOCK" || exit 1 + owner=$(readlink "$LOCK") || exit 1 + holder=${BASHPID:-$$} + cleanup_owner() { + if [ -p "$owner/pid" ]; then + printf "%s\n" "$holder" > "$owner/pid.next" + mv -f "$owner/pid.next" "$owner/pid" + fi + fm_lock_release "$LOCK" + } + trap cleanup_owner EXIT + trap "exit 143" HUP INT TERM + rm -f "$owner/pid" + mkfifo "$owner/pid" || exit 1 + : > "$READY" + printf "%s\n" "$holder" > "$owner/pid" + printf "%s\n" "$holder" > "$owner/pid.next" + mv -f "$owner/pid.next" "$owner/pid" + : > "$ATTEMPTED" + while [ ! -e "$RELEASE" ]; do sleep 0.05; done + fm_lock_release "$LOCK" + trap - EXIT + ' & + RESTART_RECOVERY_LOCK_OWNER_PID=$! + while [ ! -e "$RESTART_RECOVERY_LOCK_READY" ] && kill -0 "$RESTART_RECOVERY_LOCK_OWNER_PID" 2>/dev/null; do sleep 0.01; done + [ -e "$RESTART_RECOVERY_LOCK_READY" ] || fail "could not hold the session lock for restart recovery" + spawn_task "$RESTART_ID" "$HOME_DIR" "$PROJECT_DIR" > "$TMP_ROOT/$RESTART_ID-reclaim.out" 2> "$TMP_ROOT/$RESTART_ID-reclaim.err" & + RESTART_RECOVERY_PID=$! + while [ ! -e "$RESTART_RECOVERY_LOCK_ATTEMPTED" ] \ + && kill -0 "$RESTART_RECOVERY_LOCK_OWNER_PID" 2>/dev/null \ + && kill -0 "$RESTART_RECOVERY_PID" 2>/dev/null; do sleep 0.01; done + [ -e "$RESTART_RECOVERY_LOCK_ATTEMPTED" ] \ + || fail "restart recovery did not attempt the held session lock" + sleep 6 + : > "$RESTART_RECOVERY_LOCK_RELEASE" + wait "$RESTART_RECOVERY_LOCK_OWNER_PID" || fail "restart-recovery lock owner failed" + RESTART_RECOVERY_LOCK_OWNER_PID= + wait "$RESTART_RECOVERY_PID" \ + || fail "$RESTART_ID reclaim gave up before the delayed session lock was released: $(cat "$TMP_ROOT/$RESTART_ID-reclaim.err")" + RESTART_RECOVERY_PID= + else + spawn_task "$RESTART_ID" "$HOME_DIR" "$PROJECT_DIR" > "$TMP_ROOT/$RESTART_ID-reclaim.out" 2> "$TMP_ROOT/$RESTART_ID-reclaim.err" \ + || fail "$RESTART_ID same-identity reclaim failed: $(cat "$TMP_ROOT/$RESTART_ID-reclaim.err")" + fi NEW_RESTART_WT=$(remember_meta_worktree "$RESTART_META") NEW_RESTART_WSID=$(grep '^herdr_workspace_id=' "$RESTART_META" | cut -d= -f2-) NEW_RESTART_PANE=$(grep '^herdr_pane_id=' "$RESTART_META" | cut -d= -f2-) diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index 2c08f409fb1..5caa3ee7f17 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -2229,45 +2229,41 @@ test_presentation_session_lock_path_rejects_malformed_socket() { pass "herdr presentation lock: null and missing socket paths fail closed" } -test_presentation_recovery_lock_budget_covers_recovery_wave() { - local lock_source default_attempts recovery_attempts out +test_presentation_recovery_lock_waits_on_live_owner() { + local lock_source default_attempts expected out lock_source=$(sed -n '/^spawn_herdr_presentation_order_lock_acquire()/,/^spawn_herdr_presentation_order_lock_release()/p' "$ROOT/bin/fm-spawn.sh" | sed '$d') default_attempts=$(sed -n 's/^HERDR_PRESENTATION_ORDER_LOCK_ATTEMPTS=//p' "$ROOT/bin/fm-spawn.sh") - recovery_attempts=$(sed -n 's/^HERDR_PRESENTATION_RECOVERY_LOCK_ATTEMPTS=//p' "$ROOT/bin/fm-spawn.sh") - out=$(LOCK_SOURCE="$lock_source" DEFAULT_ATTEMPTS="$default_attempts" RECOVERY_ATTEMPTS="$recovery_attempts" \ + expected="$default_attempts 0 1 1" + out=$(LOCK_SOURCE="$lock_source" DEFAULT_ATTEMPTS="$default_attempts" \ bash -c ' HERDR_PRESENTATION_ORDER_LOCK= HERDR_PRESENTATION_ORDER_LOCK_HELD=0 + HERDR_PRESENTATION_ORDER_LOCK_ATTEMPTS=$DEFAULT_ATTEMPTS eval "$LOCK_SOURCE" fm_backend_herdr_presentation_session_lock_path() { printf "%s" /tmp/fm-herdr-budget.lock; } sleep() { :; } tries=0 - owner= + waits=0 fm_lock_try_acquire() { tries=$((tries + 1)) - if [ "$tries" -le 600 ]; then - owner=/tmp/fm-herdr-budget.lock.owner.first - return 1 - fi - if [ "$tries" -le 1200 ]; then - owner=/tmp/fm-herdr-budget.lock.owner.second - return 1 - fi + return 1 + } + fm_lock_acquire_wait() { + [ "$1" = /tmp/fm-herdr-budget.lock ] || exit 1 + waits=$((waits + 1)) return 0 } - fm_lock_link_owner() { printf "%s\n" "$owner"; } - if spawn_herdr_presentation_order_lock_acquire fmtest "$DEFAULT_ATTEMPTS"; then + if spawn_herdr_presentation_order_lock_acquire fmtest; then exit 1 fi printf "%s " "$tries" tries=0 - owner= - spawn_herdr_presentation_order_lock_acquire fmtest "$RECOVERY_ATTEMPTS" 1 || exit 1 - printf "%s %s" "$tries" "$HERDR_PRESENTATION_ORDER_LOCK_HELD" + spawn_herdr_presentation_order_lock_acquire fmtest exact-recovery || exit 1 + printf "%s %s %s" "$tries" "$waits" "$HERDR_PRESENTATION_ORDER_LOCK_HELD" ') - [ "$out" = "50 1201 1" ] \ - || fail "recovery lock budget did not drain two complete predecessor handoffs: $out" - pass "herdr presentation lock: recovery drains progressing predecessor waves while fresh projection stays best-effort" + [ "$out" = "$expected" ] \ + || fail "recovery did not use the live-owner wait after fresh projection's bounded fallback: $out" + pass "herdr presentation lock: recovery waits on the live owner while fresh projection stays best-effort" } test_projection_order_rejects_malformed_socket() { @@ -3981,7 +3977,7 @@ test_projection_order_foreign_new_child_before_parent_is_read_only test_projection_order_missing_parent_is_read_only test_presentation_session_lock_path_is_shared_across_homes test_presentation_session_lock_path_rejects_malformed_socket -test_presentation_recovery_lock_budget_covers_recovery_wave +test_presentation_recovery_lock_waits_on_live_owner test_projection_order_rejects_malformed_socket test_projection_reclaim_refusal_matrix_is_non_mutating test_projection_reclaim_replaces_only_exact_husk_and_advances_binding diff --git a/tests/fm-spawn-launch-delivery.test.sh b/tests/fm-spawn-launch-delivery.test.sh index 495b82da82c..0bcdc17de1d 100644 --- a/tests/fm-spawn-launch-delivery.test.sh +++ b/tests/fm-spawn-launch-delivery.test.sh @@ -206,8 +206,24 @@ test_long_task_id_keeps_verification_markers_on_one_pane_line() { pass "fm-spawn keeps verification markers bounded independently of task ID length" } +test_wrapped_delivery_marker_still_proves_shell_execution() { + local id rec out rc + id='launch-wrap-z3' + rec=$(make_case wrapped-marker "$id") + read_case "$rec" + out=$(FM_FAKE_PANE_COLUMNS=20 \ + run_spawn "$CASE_DIR" "$HOME_DIR" "$PROJ_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$id" "$(long_raw_launch)") + rc=$? + expect_code 0 "$rc" "a terminal-wrapped verification marker should still prove launch delivery"$'\n'"$out" + assert_contains "$(cat "$CASE_DIR/screen-history")" "__FM_SPAWN_READY_" \ + "wrapped-marker fixture did not render the shell-ready marker" + assert_contains "$out" "spawned $id" "wrapped marker did not complete verified launch delivery" + pass "fm-spawn accepts a verification marker split across terminal rows" +} + test_retries_the_recorded_truncation_signature_and_never_types_a_long_line test_refuses_to_report_success_when_every_delivery_check_is_truncated test_long_task_id_keeps_verification_markers_on_one_pane_line +test_wrapped_delivery_marker_still_proves_shell_execution echo "# all fm-spawn-launch-delivery tests passed" From 9ca7068f387912a8cb236b9a24600fe0a1cb257b Mon Sep 17 00:00:00 2001 From: quinnbot-ai <quinnbot.ai@gmail.com> Date: Sun, 2 Aug 2026 16:22:28 -0700 Subject: [PATCH 31/70] fix(watch): durable wake-queue cycle classifier - queue as sole truth across generation transitions (#79) * fix(watch): classify wake delivery by cycle identity * no-mistakes(review): Fix watcher cycle attribution and bounded startup health * no-mistakes(document): Document bounded watcher cycle attribution * chore: retrigger CI * fix(watch): fence immediate successor attachment * test(watch): publish cycle identity in guard fixture --------- Co-authored-by: QuinnBot <quinnbot@proton.me> --- AGENTS.md | 2 +- bin/fm-wake-lib.sh | 80 ++++++++- bin/fm-watch-arm.sh | 128 +++++++++++--- bin/fm-watch.sh | 12 +- docs/architecture.md | 1 + docs/watcher-continuity.md | 18 +- tests/fm-session-owner-fence.test.sh | 4 + tests/fm-turnend-guard.test.sh | 1 + tests/fm-wake-queue.test.sh | 10 +- tests/fm-watcher-lock.test.sh | 255 ++++++++++++++++++++++++++- 10 files changed, 473 insertions(+), 38 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9b8a1a6fcb4..6d1fde44018 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,7 @@ state/ volatile runtime signals; gitignored x-outbox/ generated X-mode dry-run reply and dismiss previews; inspect it when FMX_DRY_RUN is set (section 14) public-followup/ generated private transport for promised public replies: commitment registrations, typed terminal-result inbox, accepted/rejected ledgers (section 14; bin/fm-public-followup.sh) x-poll.error x-poll.claim-error generated X-mode relay and offer-claim diagnostic dedupe markers - .wake-queue durable queued wakes: epoch<TAB>seq<TAB>kind<TAB>key<TAB>payload + .wake-queue durable queued wakes: epoch<TAB>seq<TAB>kind<TAB>key<TAB>payload<TAB>cycle-id .afk durable away-mode flag; present = sub-supervisor may inject escalations (set by /afk, cleared on user return) .watch.lock .wake-queue.lock watcher singleton and queue serialization locks .claude-autoarm.lock .claude-autoarm-epoch .turnend-claude-blocks Claude Stop auto-arm single-flight, epoch, and guard-budget records; never touch diff --git a/bin/fm-wake-lib.sh b/bin/fm-wake-lib.sh index 8cec58bec1d..09523ce68a8 100755 --- a/bin/fm-wake-lib.sh +++ b/bin/fm-wake-lib.sh @@ -92,18 +92,27 @@ fm_watcher_lock_matches_pid() { } FM_WATCHER_HEALTHY_PID= +FM_WATCHER_HEALTHY_CYCLE_ID= fm_watcher_healthy() { - local state=$1 watch_path=$2 grace=${3:-${FM_GUARD_GRACE:-300}} home=${4:-$FM_HOME} lockdir beat pid age + local state=$1 watch_path=$2 grace=${3:-${FM_GUARD_GRACE:-300}} home=${4:-$FM_HOME} lockdir beat pid age cycle_id FM_WATCHER_HEALTHY_PID= + FM_WATCHER_HEALTHY_CYCLE_ID= lockdir="$state/.watch.lock" beat="$state/.last-watcher-beat" pid=$(cat "$lockdir/pid" 2>/dev/null || true) fm_pid_alive "$pid" || return 1 fm_watcher_lock_matches_pid "$state" "$watch_path" "$pid" "$home" || return 1 + cycle_id=$(cat "$lockdir/cycle-id" 2>/dev/null || true) + [ -n "$cycle_id" ] || return 1 + # The cycle id is published before pid-identity. Recheck the identity after + # reading it so callers never pair a cycle from one lock owner with another. + fm_watcher_lock_matches_pid "$state" "$watch_path" "$pid" "$home" || return 1 age=$(fm_path_age "$beat") [ "$age" -lt "$grace" ] || return 1 # shellcheck disable=SC2034 # Read by callers after fm_watcher_healthy returns. FM_WATCHER_HEALTHY_PID=$pid + # shellcheck disable=SC2034 # The caller consumes this sourced-library result. + FM_WATCHER_HEALTHY_CYCLE_ID=$cycle_id return 0 } @@ -113,6 +122,7 @@ fm_lock_clean_known_files() { "$lockdir/pid" \ "$lockdir/fm-home" \ "$lockdir/pid-identity" \ + "$lockdir/cycle-id" \ "$lockdir/watcher-path" \ 2>/dev/null || true } @@ -356,6 +366,18 @@ fm_lock_acquire_wait() { done } +# A classifier must never wait forever behind an unrelated queue holder. The +# caller chooses a short attempt budget and handles timeout as its typed failure. +fm_lock_acquire_bounded() { # <lockdir> [attempts] [sleep-seconds] + local lockdir=$1 attempts=${2:-20} delay=${3:-0.05} i=0 + case "$attempts" in ''|*[!0-9]*) return 2 ;; esac + while ! fm_lock_try_acquire "$lockdir"; do + [ "$i" -ge "$attempts" ] && return 1 + sleep "$delay" + i=$((i + 1)) + done +} + fm_lock_release() { local lockdir=$1 pid current ownerdir current=${BASHPID:-$$} @@ -380,7 +402,7 @@ fm_wake_clean_field() { } fm_wake_append() { - local kind=$1 key=$2 payload=$3 clean_key clean_payload epoch seq seq_file status + local kind=$1 key=$2 payload=$3 cycle_id=${4:-${FM_WATCH_CYCLE_ID:-}} clean_key clean_payload clean_cycle epoch seq seq_file status case "$kind" in signal|stale|check|heartbeat) ;; *) printf 'fm_wake_append: invalid wake kind: %s\n' "$kind" >&2; return 2 ;; @@ -388,6 +410,7 @@ fm_wake_append() { clean_key=$(printf '%s' "$key" | fm_wake_clean_field) clean_payload=$(printf '%s' "$payload" | fm_wake_clean_field) + clean_cycle=$(printf '%s' "$cycle_id" | fm_wake_clean_field) epoch=$(date +%s) seq_file="$STATE/.wake-queue.seq" status=0 @@ -400,9 +423,56 @@ fm_wake_append() { seq=$((seq + 1)) printf '%s\n' "$seq" > "$seq_file" || status=$? if [ "$status" -eq 0 ]; then - printf '%s\t%s\t%s\t%s\t%s\n' "$epoch" "$seq" "$kind" "$clean_key" "$clean_payload" >> "$FM_WAKE_QUEUE" || status=$? + printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$epoch" "$seq" "$kind" "$clean_key" "$clean_payload" "$clean_cycle" >> "$FM_WAKE_QUEUE" || status=$? + fi + fm_lock_release "$FM_WAKE_QUEUE_LOCK" + return "$status" +} + +# fm_wake_cycle_has_records <cycle-id> +# Return 0 when the durable queue contains a row produced by exactly this +# watcher cycle, 1 when it does not, and 2 when the queue cannot be locked or +# read. The queue is deliberately the only attribution authority. +fm_wake_cycle_has_records() { + local cycle_id=$1 attempts=${FM_WAKE_CLASSIFY_LOCK_ATTEMPTS:-20} delay=${FM_WAKE_CLASSIFY_LOCK_DELAY:-0.05} status + [ -n "$cycle_id" ] || return 1 + fm_lock_acquire_bounded "$FM_WAKE_QUEUE_LOCK" "$attempts" "$delay" || return 2 + status=2 + if [ -f "$FM_WAKE_QUEUE" ]; then + if awk -F '\t' -v cycle="$cycle_id" 'NF >= 6 && $6 == cycle { found = 1; exit } END { exit !found }' "$FM_WAKE_QUEUE" 2>/dev/null; then + status=0 + else + status=$? + fi + elif [ ! -e "$FM_WAKE_QUEUE" ] && [ ! -L "$FM_WAKE_QUEUE" ]; then + status=1 + fi + fm_lock_release "$FM_WAKE_QUEUE_LOCK" + [ "$status" -le 1 ] || return 2 + return "$status" +} + +# fm_wake_watcher_pid_has_records <watcher-pid> +# An owned child can deliver and exit before it becomes healthy enough for its +# arm to read cycle-id from the lock. Its queue rows still carry the immutable +# cycle token, whose watcher-pid prefix identifies that just-reaped child. +# Return values match fm_wake_cycle_has_records. +fm_wake_watcher_pid_has_records() { + local watcher_pid=$1 attempts=${FM_WAKE_CLASSIFY_LOCK_ATTEMPTS:-20} delay=${FM_WAKE_CLASSIFY_LOCK_DELAY:-0.05} status + case "$watcher_pid" in ''|*[!0-9]*) return 1 ;; esac + fm_lock_acquire_bounded "$FM_WAKE_QUEUE_LOCK" "$attempts" "$delay" || return 2 + status=2 + if [ -f "$FM_WAKE_QUEUE" ]; then + if awk -F '\t' -v prefix="watcher:${watcher_pid}:" 'NF >= 6 && index($6, prefix) == 1 { found = 1; exit } END { exit !found }' "$FM_WAKE_QUEUE" 2>/dev/null; then + status=0 + else + status=$? + fi + elif [ ! -e "$FM_WAKE_QUEUE" ] && [ ! -L "$FM_WAKE_QUEUE" ]; then + status=1 fi fm_lock_release "$FM_WAKE_QUEUE_LOCK" + [ "$status" -le 1 ] || return 2 return "$status" } @@ -468,8 +538,8 @@ fm_wake_status_key_map() { # <queue-key> } fm_wake_annotation_manifest() { # <deduped-raw-rows> - local rows=$1 epoch seq kind key payload - while IFS=$(printf '\t') read -r epoch seq kind key payload; do + local rows=$1 epoch seq kind key payload cycle_id + while IFS=$(printf '\t') read -r epoch seq kind key payload cycle_id; do [ "$kind" = signal ] || continue fm_wake_status_key_map "$key" || continue if [ "$FM_WAKE_STATUS_HISTORICAL" = true ]; then diff --git a/bin/fm-watch-arm.sh b/bin/fm-watch-arm.sh index 7384610a801..8378710bd61 100755 --- a/bin/fm-watch-arm.sh +++ b/bin/fm-watch-arm.sh @@ -32,6 +32,9 @@ # watcher: FAILED - cycle ended without an actionable reason # - an OWNED child returned clean and empty # with no verified healthy successor +# watcher: cycle-ended - the followed watcher cycle delivered an actionable wake; drain the wake queue +# - a FOLLOWED cycle wrote a matching durable row; +# its reason went to the owning arm # watcher: cycle-ended - the followed watcher cycle closed with no successor; drain the wake queue # - a FOLLOWED cycle closed; its reason, if # any, went to the owning arm and is in the @@ -40,12 +43,12 @@ # stale-beacon or dead-pid holder either self-heals (the fresh child steals the # dead lock per the singleton self-eviction/steal path and is confirmed) or this # returns the FAILED line. On started it waits the child and propagates the wake -# reason; on attached it stays live across identity-matched successors. Both -# terminal closes above are typed and nonzero, never a clean empty completion, but -# only the OWNED one is a supervision failure: an arm that merely followed another -# arm's cycle cannot see that cycle's reason line and must not report its ordinary -# close as supervision going down. A live cycle already present means re-arm -# attaches - do not start a second watcher. +# reason; on attached it stays live across identity-matched successors. The owned +# unexplained close and both followed-cycle closes above are typed and nonzero, +# never a clean empty completion, but only the OWNED one is a supervision failure: +# an arm that merely followed another arm's cycle cannot see that cycle's reason +# line and must not report its ordinary close as supervision going down. A live +# cycle already present means re-arm attaches - do not start a second watcher. # # Every observed watcher cycle appends one tab-separated lifecycle record to # state/.watch-cycle-exits.log. The arm layer owns that bounded ledger; it records @@ -118,18 +121,24 @@ lock_snapshot() { cycle_active=0 cycle_watcher_pid=none +cycle_wake_id= cycle_origin=unknown cycle_started_at=0 cycle_lock_before='pid:none|identity:none' -cycle_begin() { +cycle_begin() { # <watcher-pid> <origin> [cycle-id] cycle_watcher_pid=$1 cycle_origin=$2 + cycle_wake_id=${3:-} cycle_started_at=$(date +%s) cycle_lock_before=$(lock_snapshot) cycle_active=1 } +cycle_set_wake_id() { + cycle_wake_id=$1 +} + cycle_refresh_lock_before() { [ "$cycle_active" -eq 1 ] || return 0 cycle_lock_before=$(lock_snapshot) @@ -246,10 +255,13 @@ clear_stale_recorded_watcher_lock() { # single honesty gate: a dead pid, a reused pid, or a stale beacon all fail it, so # this script can never report a watcher that is not really there. HEALTHY_PID= +HEALTHY_CYCLE_ID= healthy_watcher() { HEALTHY_PID= + HEALTHY_CYCLE_ID= fm_watcher_healthy "$STATE" "$WATCH" "$GRACE" "$FM_HOME" || return 1 HEALTHY_PID=$FM_WATCHER_HEALTHY_PID + HEALTHY_CYCLE_ID=$FM_WATCHER_HEALTHY_CYCLE_ID } report_attached() { @@ -301,8 +313,21 @@ require_session_owner() { # <action-phrase> # arming a second cycle beside the harness's own arming owner. Report the honest # close instead, and keep it nonzero so no adapter can read it as a clean empty # completion. +# Return success only when the durable queue proves this generation has not +# delivered a wake. A timed-out queue lock is a typed failure, never a hang. +followed_cycle_may_transition() { # <cycle-id> + fm_wake_cycle_has_records "$1" + case $? in + 1) return 0 ;; + 0) echo "watcher: cycle-ended - the followed watcher cycle delivered an actionable wake; drain the wake queue"; return 1 ;; + *) echo "watcher: FAILED - wake queue attribution lock was unavailable"; return 1 ;; + esac +} + end_followed_cycle() { - echo "watcher: cycle-ended - the followed watcher cycle closed with no successor; drain the wake queue" + if followed_cycle_may_transition "$1"; then + echo "watcher: cycle-ended - the followed watcher cycle closed with no successor; drain the wake queue" + fi return 1 } @@ -311,35 +336,44 @@ end_followed_cycle() { # instead of returning a clean empty completion that an adapter could mistake for # a no-op. attach_and_wait() { - local attached_pid=$1 + local attached_pid=$1 attached_cycle_id=$2 while :; do if ! require_session_owner "detaching"; then cycle_log_append unknown unknown session-owner-fenced none return 1 fi if healthy_watcher; then - if [ "$HEALTHY_PID" != "$attached_pid" ]; then + if [ "$HEALTHY_CYCLE_ID" != "$attached_cycle_id" ]; then + if ! require_session_owner "not attaching to successor"; then + cycle_log_append unknown unknown session-owner-fenced none + return 1 + fi + followed_cycle_may_transition "$attached_cycle_id" || { cycle_log_append unknown unknown attached-cycle-ended wake-delivered; return 1; } cycle_log_append unknown unknown lock-replaced "attached:$HEALTHY_PID" attached_pid=$HEALTHY_PID - cycle_begin "$attached_pid" attached + attached_cycle_id=$HEALTHY_CYCLE_ID + cycle_begin "$attached_pid" attached "$attached_cycle_id" report_attached fi sleep "$ATTACH_POLL" continue fi + followed_cycle_may_transition "$attached_cycle_id" || { cycle_log_append unknown unknown attached-cycle-ended wake-delivered; return 1; } if wait_for_healthy_successor; then if ! require_session_owner "not attaching to successor"; then cycle_log_append unknown unknown session-owner-fenced none return 1 fi + followed_cycle_may_transition "$attached_cycle_id" || { cycle_log_append unknown unknown attached-cycle-ended wake-delivered; return 1; } cycle_log_append unknown unknown attached-cycle-ended "attached:$HEALTHY_PID" attached_pid=$HEALTHY_PID - cycle_begin "$attached_pid" attached + attached_cycle_id=$HEALTHY_CYCLE_ID + cycle_begin "$attached_pid" attached "$attached_cycle_id" report_attached continue fi cycle_log_append unknown unknown attached-cycle-ended none - end_followed_cycle + end_followed_cycle "$attached_cycle_id" return 1 done } @@ -378,6 +412,14 @@ print_watch_output() { [ -s "$out" ] && cat "$out" } +owned_cycle_has_wake() { + if [ -n "$cycle_wake_id" ]; then + fm_wake_cycle_has_records "$cycle_wake_id" + return $? + fi + fm_wake_watcher_pid_has_records "$cycle_watcher_pid" +} + mode=arm case "${1:-}" in ''|arm|--arm) mode=arm ;; @@ -393,7 +435,10 @@ require_session_owner "not arming" || exit 1 restart_watcher_if_owned() { local lock_pid i - fm_lock_acquire_wait "$SESSION_CLAIM_LOCK" + if ! fm_lock_acquire_bounded "$SESSION_CLAIM_LOCK"; then + echo "watcher: FAILED - session claim lock was unavailable" + return 1 + fi SESSION_CLAIM_LOCK_HELD=1 if ! require_session_owner "not restarting"; then release_session_claim_lock @@ -429,9 +474,9 @@ fi # this home's watcher and wants a fresh one.) if [ "$mode" = arm ] && healthy_watcher; then cycle_mark_predecessor_successor "attached:$HEALTHY_PID" - cycle_begin "$HEALTHY_PID" attached + cycle_begin "$HEALTHY_PID" attached "$HEALTHY_CYCLE_ID" report_attached - attach_and_wait "$HEALTHY_PID" + attach_and_wait "$HEALTHY_PID" "$HEALTHY_CYCLE_ID" exit $? fi @@ -477,9 +522,23 @@ cycle_begin "$child" started child_done=0 owned_child_finished() { - local rc=$1 signal reason_type status + local rc=$1 followed_cycle_id=${2:-} signal reason_type status attribution signal=$(cycle_signal_name "$rc") if [ "$rc" -eq 0 ] && watch_output_has_wake "$child_out"; then + owned_cycle_has_wake + attribution=$? + if [ "$attribution" -ne 0 ]; then + cycle_log_append "$rc" "$signal" wake-attribution-missing none + rm -f "$child_out" 2>/dev/null || true + child= + child_out= + if [ "$attribution" -eq 2 ]; then + echo "watcher: FAILED - wake queue attribution lock was unavailable" + return 1 + fi + fail_unexplained_cycle + return 1 + fi reason_type=$(watch_output_reason_type "$child_out") cycle_log_append "$rc" "$signal" "$reason_type" none print_watch_output "$child_out" @@ -490,6 +549,16 @@ owned_child_finished() { fi if [ "$rc" -eq 0 ]; then + if [ -n "$followed_cycle_id" ]; then + followed_cycle_may_transition "$followed_cycle_id" || { + cycle_log_append "$rc" "$signal" child-stood-down-wake-delivered none + print_watch_output "$child_out" + rm -f "$child_out" 2>/dev/null || true + child= + child_out= + return 1 + } + fi if wait_for_healthy_successor; then if ! require_session_owner "not attaching to successor"; then cycle_log_append "$rc" "$signal" session-owner-fenced none @@ -499,6 +568,16 @@ owned_child_finished() { child_out= return 1 fi + if [ -n "$followed_cycle_id" ]; then + followed_cycle_may_transition "$followed_cycle_id" || { + cycle_log_append "$rc" "$signal" child-stood-down-wake-delivered none + print_watch_output "$child_out" + rm -f "$child_out" 2>/dev/null || true + child= + child_out= + return 1 + } + fi cycle_log_append "$rc" "$signal" unexpected-clean-exit "attached:$HEALTHY_PID" print_watch_output "$child_out" rm -f "$child_out" 2>/dev/null || true @@ -506,8 +585,8 @@ owned_child_finished() { child_out= cycle_mark_predecessor_successor "attached:$HEALTHY_PID" report_attached - cycle_begin "$HEALTHY_PID" attached - attach_and_wait "$HEALTHY_PID" + cycle_begin "$HEALTHY_PID" attached "$HEALTHY_CYCLE_ID" + attach_and_wait "$HEALTHY_PID" "$HEALTHY_CYCLE_ID" return $? fi cycle_log_append "$rc" "$signal" unexpected-clean-exit none @@ -544,6 +623,7 @@ while :; do if healthy_watcher; then if [ "$HEALTHY_PID" = "$child" ]; then cycle_refresh_lock_before + cycle_set_wake_id "$HEALTHY_CYCLE_ID" cycle_mark_predecessor_successor "started:$child" echo "watcher: started pid=$child (beacon fresh)" wait "$child" @@ -551,10 +631,18 @@ while :; do owned_child_finished "$rc" exit $? fi + # Another watcher won the singleton. Its generation may already have + # delivered a wake before this child can be reaped, so consult that cycle's + # durable rows before entering the clean-child branch. + if ! followed_cycle_may_transition "$HEALTHY_CYCLE_ID"; then + cycle_log_append 0 none child-stood-down-wake-delivered none + cleanup_child + exit 1 + fi # Another watcher won the singleton; our child stood down. wait "$child" rc=$? - owned_child_finished "$rc" + owned_child_finished "$rc" "$HEALTHY_CYCLE_ID" exit $? fi if [ "$child_done" -eq 0 ] && ! fm_pid_alive "$child"; then diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 385eed62b9c..ebdd617bfe5 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -752,7 +752,11 @@ trap 'exit 1' HUP INT TERM WATCHER_PID=${BASHPID:-$$} printf '%s\n' "$FM_HOME" > "$WATCH_LOCK/fm-home" || true printf '%s\n' "$WATCH_PATH" > "$WATCH_LOCK/watcher-path" || true -fm_pid_identity "$WATCHER_PID" > "$WATCH_LOCK/pid-identity" 2>/dev/null || true +# This token is a watcher-generation identity, not a timing baseline. It is +# published before pid-identity, then stamped on every durable wake row. +FM_WATCH_CYCLE_ID="watcher:${WATCHER_PID}:$(date +%s):${RANDOM}${RANDOM}" +export FM_WATCH_CYCLE_ID +printf '%s\n' "$FM_WATCH_CYCLE_ID" > "$WATCH_LOCK/cycle-id" || true [ -e "$STATE/.last-heartbeat" ] || touch "$STATE/.last-heartbeat" @@ -766,6 +770,12 @@ if ! fm_pr_poll_retirement_recover_all "$STATE" "$SCRIPT_DIR/fm-pr-poll.sh"; the wake "$reason" fi +if ! touch "$STATE/.last-watcher-beat" \ + || ! fm_pid_identity "$WATCHER_PID" > "$WATCH_LOCK/pid-identity" 2>/dev/null; then + echo "watcher: FAILED - could not publish watcher health" + exit 1 +fi + while :; do # Self-eviction: if the singleton lock no longer names this process, a second # watcher has taken over (e.g. a transient duplicate from a racy arm). Stand diff --git a/docs/architecture.md b/docs/architecture.md index 7a3666cf1f4..3b350a33244 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -13,6 +13,7 @@ Actionable wakes include captain-relevant status signals, no-verb signals whose Repeated provably-working stale escalations on the same unchanged pane add an escalation count to the wake reason and, at `FM_WEDGE_DEMAND_INSPECT_COUNT`, a `demand-deep-inspection` marker. A busy pane is otherwise exempt from staleness, but only until its latest `state/<id>.turn-ended` marker reaches `FM_BUSY_TURN_MAX_SECS`, or its `state/<id>.meta` spawn record reaches that age before any turn completes; past that bound it is routed through the same wedge escalation, with the identical reason, escalation count, and `demand-deep-inspection` marker, for inspection only - never an automatic interrupt, signal, or restart. Those actionable wakes are written to a durable local queue (`state/.wake-queue`) before detector state advances, so a missed process exit can be recovered by draining the queue. +Each watcher generation stamps its immutable cycle identity on its own queue rows, and the arm attributes delivery only by that durable identity across a generation handoff rather than by a moving queue baseline. When a canonical validated PR poll returns exactly `merged`, the watcher appends that durable notification before publishing a private receipt bound to the poll's registration, bytes, file identities, metadata, provider, URL, and task ID. The receipt makes retirement safely retryable across restarts: fixed-path recovery revalidates the same evidence, removes the runnable check first, removes its registration and data sidecars, removes the receipt last, and preserves task metadata including `pr=` and `pr_head=`. A concurrent replacement remains armed, every non-merged or invalid observation remains unchanged, and retirement never performs task or persistent-secondmate cleanup. diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index 79868cbb875..1fc22644e87 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -52,10 +52,18 @@ The turn-end guard remains the final backstop rather than the normal continuity ## Arm-layer cycle contract `bin/fm-watch-arm.sh` never returns a clean empty success. -An actionable child output returns that reason normally. +The durable wake queue is the sole authority for whether a watcher generation delivered an actionable wake. +Each watcher creates one immutable `watcher:<pid>:...` cycle identity after taking the singleton lock, publishes it before its lock identity becomes healthy, and stamps it on every row it enqueues. +The watcher publishes its fresh beacon and PID identity only after blocking startup recovery completes, so an arm cannot accept predecessor beacon state as child health while recovery is still running and its startup confirmation remains bounded. +An arm records the exact verified cycle identity with each healthy owned or followed generation and consults queue rows matching that identity before every generation handoff and every close classification. +If an owned child delivers and exits before becoming healthy enough to expose that exact identity to its arm, the arm finds the just-reaped child's immutable token rows by their `watcher:<pid>:` prefix. +Both paths attribute delivery from cycle tokens in the durable queue rather than from a moving queue sequence, a sample time, process liveness, or successor health. +An actionable owned-child output returns its reason only after its matching durable row is present. A zero/empty return from an OWNED child rechecks the home lock and beacon, attaches to a verified healthy successor when one exists, or emits `watcher: FAILED - cycle ended without an actionable reason` and exits nonzero. -An attached arm follows verified identity-matched successors and, when that chain ends without one, emits the separate typed `watcher: cycle-ended - the followed watcher cycle closed with no successor; drain the wake queue` and exits nonzero. -The two typed closes are distinct because only the arm that forked a watcher can see that watcher's reason line: a followed cycle's reason went to the owning arm and reaches this arm only through the durable queue, so reporting that close as a supervision failure alarmed on every wake-delivering cycle and pushed the model into arming a second relay. +An attached arm follows verified identity-matched successors and, when its generation delivered a wake, emits `watcher: cycle-ended - the followed watcher cycle delivered an actionable wake; drain the wake queue` rather than a supervision-down alarm. +When a followed generation did not deliver and has no successor, it emits `watcher: cycle-ended - the followed watcher cycle closed with no successor; drain the wake queue` and exits nonzero. +If the queue-attribution lock cannot be acquired within its bound or the queue cannot be read, the arm emits the typed FAILED result instead of treating the failure as an empty generation or blocking startup. +The two typed closes are distinct because only the arm that forked a watcher can see that watcher's reason line: a followed cycle's reason reaches another arm only through the durable queue, so reporting that close as a supervision failure alarmed on every wake-delivering cycle and pushed the model into arming a second relay. Consumers that interpret the arm-layer status line must therefore classify a followed close as a drain-and-handle event and reserve the raw supervision-down alarm for `watcher: FAILED`; `bin/fm-claude-stop-autoarm.sh` does exactly that, and both closes stay nonzero so no adapter can read either as a clean empty completion. The arm layer appends one tab-separated record per observed cycle to `state/.watch-cycle-exits.log`. @@ -71,7 +79,9 @@ Only the watcher process touches `state/.last-watcher-beat`; no helper process c `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, the typed followed-cycle close, bounded and successor-linked lifecycle rows, and a SIGSTOP counterfactual that distinguishes a live PID from a stale beacon before classifying termination. -Its `test_second_arm_does_not_alarm_when_the_owned_cycle_delivers_a_wake` runs the previously-false-alarming sequence end to end - two arms, one watcher, one real wake - and requires the owning arm to relay the reason, the following arm to emit the typed followed-cycle close rather than a failure, and the queue to hold exactly one record. +Its cycle-attribution cases cover a wake queued before health observation, generation publication mixing, a same-PID successor handoff, a child that stood down to a wake-delivering peer, an unreadable queue, and a stalled queue lock that must not block arm startup. +Its startup-health case holds recovery behind that queue lock and proves the watcher cannot publish health from a predecessor beacon or outlive the arm's bounded confirmation window. +Its `test_second_arm_does_not_alarm_when_the_owned_cycle_delivers_a_wake` runs the previously-false-alarming sequence end to end - two arms, one watcher, one real wake - and requires the owning arm to relay the reason, the following arm to classify the durable wake rather than a failure, and the queue to hold exactly one record. Its `test_guard_warnings` requires the mid-turn guard banner on a Claude primary to name the Stop-owned arming owner and to mention no manual arm command. `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, including that a followed-cycle close rewakes for a drain without the supervision-down alarm. diff --git a/tests/fm-session-owner-fence.test.sh b/tests/fm-session-owner-fence.test.sh index 17892527338..962a99ec742 100755 --- a/tests/fm-session-owner-fence.test.sh +++ b/tests/fm-session-owner-fence.test.sh @@ -62,6 +62,10 @@ write_watcher_lock() { printf '%s\n' "$pid" > "$state/.watch.lock/pid" printf '%s\n' "$home" > "$state/.watch.lock/fm-home" printf '%s\n' "$WATCH" > "$state/.watch.lock/watcher-path" + # A healthy watcher lock includes the immutable generation token. Without it + # the arm correctly rejects this peer before it can exercise the successor + # handoff being tested below. + printf 'test-fence-cycle:%s\n' "$pid" > "$state/.watch.lock/cycle-id" printf '%s\n' "$identity" > "$state/.watch.lock/pid-identity" } diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index 242407c1a32..6cc367c10e3 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -207,6 +207,7 @@ record_watcher_lock() { printf '%s\n' "$pid" > "$dir/state/.watch.lock/pid" printf '%s\n' "$root" > "$dir/state/.watch.lock/fm-home" printf '%s\n' "$bin_dir/fm-watch.sh" > "$dir/state/.watch.lock/watcher-path" + printf 'test-turnend-cycle:%s\n' "$pid" > "$dir/state/.watch.lock/cycle-id" printf '%s\n' "$identity" > "$dir/state/.watch.lock/pid-identity" } diff --git a/tests/fm-wake-queue.test.sh b/tests/fm-wake-queue.test.sh index 569f18b42f0..23492005aba 100755 --- a/tests/fm-wake-queue.test.sh +++ b/tests/fm-wake-queue.test.sh @@ -40,7 +40,7 @@ test_concurrent_append_and_drain() { cat "$out1" "$out2" > "$all" count=$(awk 'NF { count++ } END { print count + 0 }' "$all") [ "$count" -eq 40 ] || fail "expected 40 drained records, got $count" - malformed=$(awk -F '\t' 'NF != 5 { bad++ } END { print bad + 0 }' "$all") + malformed=$(awk -F '\t' 'NF != 6 { bad++ } END { print bad + 0 }' "$all") [ "$malformed" -eq 0 ] || fail "drained records had malformed fields" unique=$(awk -F '\t' '{ keys[$4] = 1 } END { for (k in keys) count++; print count + 0 }' "$all") [ "$unique" -eq 40 ] || fail "expected 40 unique keys, got $unique" @@ -276,7 +276,7 @@ SH PATH="$dir/fakebin:$PATH" FM_STATE_OVERRIDE="$state" FM_WAKE_ENRICH_SWAP_PATH="$state/task.status" \ FM_WAKE_ENRICH_SWAP_TARGET="$outside" FM_WAKE_ENRICH_REAL_PERL="$perl_bin" "$DRAIN" > "$out" \ || fail "structural enrichment drain failed" - awk -F '\t' 'NF == 5 { print }' "$out" > "$actual" + awk -F '\t' 'NF == 6 { print }' "$out" > "$actual" cmp -s "$expected" "$actual" || fail "enrichment changed or reordered an authoritative raw row" annotation_count=$(grep -c '^wake annotation:' "$out" || true) @@ -327,7 +327,7 @@ SH PATH="$dir/fakebin:$PATH" FM_STATE_OVERRIDE="$state" FM_WAKE_ENRICH_PERL_LOG="$fake_perl_log" \ FM_WAKE_ENRICH_REAL_PERL="$perl_bin" "$DRAIN" > "$out" \ || fail "capped enrichment drain failed" - raw_count=$(awk -F '\t' 'NF == 5 { count++ } END { print count + 0 }' "$out") + raw_count=$(awk -F '\t' 'NF == 6 { count++ } END { print count + 0 }' "$out") [ "$raw_count" -eq 13 ] || fail "missing, unreadable, malformed, empty, or oversized status input hid a raw row" grep '^wake annotation:.*\[truncated\]$' "$out" >/dev/null || fail "per-item/input truncation marker was not emitted" grep -E '^wake annotation: [1-9][0-9]* annotations omitted \(global enrichment byte cap\)$' "$out" >/dev/null \ @@ -411,7 +411,7 @@ test_interruption_before_and_after_raw_commit() { set -e [ "$rc" -ne 0 ] || fail "pre-commit interruption unexpectedly succeeded" FM_STATE_OVERRIDE="$state" "$DRAIN" > "$replay_out" || fail "restored pre-commit wake did not drain" - count=$(awk -F '\t' 'NF == 5 { count++ } END { print count + 0 }' "$replay_out") + count=$(awk -F '\t' 'NF == 6 { count++ } END { print count + 0 }' "$replay_out") [ "$count" -eq 1 ] || fail "pre-commit interruption lost or duplicated the restored row" append_wake "$state" signal task.status "signal: task after commit" || fail "post-commit interruption wake append failed" @@ -424,7 +424,7 @@ test_interruption_before_and_after_raw_commit() { wait "$pid" set -e FM_STATE_OVERRIDE="$state" "$DRAIN" > "$empty_out" || fail "drain after post-commit interruption failed" - count=$(awk -F '\t' 'NF == 5 { count++ } END { print count + 0 }' "$after_out" "$empty_out") + count=$(awk -F '\t' 'NF == 6 { count++ } END { print count + 0 }' "$after_out" "$empty_out") [ "$count" -eq 1 ] || fail "post-commit interruption restored or duplicated the consumed row" pass "interruptions restore before commitment and never replay after raw commitment" } diff --git a/tests/fm-watcher-lock.test.sh b/tests/fm-watcher-lock.test.sh index 86abd80b691..7c75186d94c 100755 --- a/tests/fm-watcher-lock.test.sh +++ b/tests/fm-watcher-lock.test.sh @@ -458,6 +458,7 @@ test_watch_restart_attaches_to_healthy_peer() { printf '%s\n' "$peer" > "$state/.watch.lock/pid" printf '%s\n' "$dir" > "$state/.watch.lock/fm-home" printf '%s\n' "$WATCH" > "$state/.watch.lock/watcher-path" + printf '%s\n' 'test-healthy-peer-cycle' > "$state/.watch.lock/cycle-id" printf '%s\n' "$identity" > "$state/.watch.lock/pid-identity" touch "$state/.last-watcher-beat" PATH="$fakebin:$PATH" FM_HOME="$dir" FM_POLL=5 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 FM_ARM_ATTACH_POLL=0.1 FM_ARM_CONFIRM_TIMEOUT=1 "$WATCH_ARM" --restart > "$out" & @@ -638,8 +639,8 @@ SH grep -qF "check: $check_file: merged: https://example.test/pr/9" "$ownerout" \ || fail "owning arm did not relay the wake reason: $(cat "$ownerout")" [ "$frc" -ne 0 ] && [ "$frc" -ne 124 ] || fail "second arm returned a clean empty completion (status $frc)" - grep -qF 'watcher: cycle-ended - the followed watcher cycle closed with no successor' "$followout" \ - || fail "second arm did not emit the typed followed-cycle close: $(cat "$followout")" + grep -qF 'watcher: cycle-ended - the followed watcher cycle delivered an actionable wake' "$followout" \ + || fail "second arm did not classify the durable wake from its followed cycle: $(cat "$followout")" ! grep -qF 'watcher: FAILED' "$followout" \ || fail "second arm reported supervision down while the owning arm delivered a real wake" queued=$(grep -c "merged: https://example.test/pr/9" "$state/.wake-queue" 2>/dev/null || true) @@ -800,6 +801,7 @@ test_arm_waits_for_peer_beacon_after_child_stands_down() { printf '%s\n' "$peer" > "$state/.watch.lock/pid" printf '%s\n' "$dir" > "$state/.watch.lock/fm-home" printf '%s\n' "$WATCH" > "$state/.watch.lock/watcher-path" + printf '%s\n' 'test-peer-startup-cycle' > "$state/.watch.lock/cycle-id" printf '%s\n' "$identity" > "$state/.watch.lock/pid-identity" PATH="$fakebin:$PATH" FM_HOME="$dir" FM_POLL=5 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 FM_ARM_CONFIRM_TIMEOUT=1 FM_ARM_ATTACH_POLL=0.1 "$WATCH_ARM" > "$armout" & armpid=$! @@ -1083,6 +1085,247 @@ test_msys_pid_identity_uses_proc() { pass "MSYS process identity uses compatible /proc fields" } +cycle_has_wake() { # <state> <cycle-id> + FM_STATE_OVERRIDE="$1" bash -c '. "$1"; fm_wake_cycle_has_records "$2"' _ "$LIB" "$2" +} + +test_cycle_identity_covers_pre_health_enqueue() { + local dir state + dir=$(make_case cycle-pre-health) + state="$dir/state" + FM_STATE_OVERRIDE="$state" bash -c '. "$1"; fm_wake_append stale peer "stale: peer" generation-a' _ "$LIB" \ + || fail "could not enqueue pre-health wake" + cycle_has_wake "$state" generation-a || fail "generation identity missed wake queued before health observation" + pass "cycle identity attributes a wake queued before arm health observation" +} + +test_cycle_identity_rejects_generation_mixing() { + local dir state + dir=$(make_case cycle-generation-mixing) + state="$dir/state" + FM_STATE_OVERRIDE="$state" bash -c '. "$1"; fm_wake_append stale old "stale: old" generation-old; fm_wake_append stale new "stale: new" generation-new' _ "$LIB" \ + || fail "could not enqueue mixed-generation wakes" + cycle_has_wake "$state" generation-new || fail "new generation wake was not found" + if cycle_has_wake "$state" generation-missing; then + fail "generation query attributed another generation's wake" + fi + pass "cycle identity never mixes lock-owner generations" +} + +test_cycle_identity_checks_old_generation_before_handoff() { + local dir state peer identity armout armpid status i + dir=$(make_case cycle-handoff) + state="$dir/state" + armout="$dir/arm.out" + sleep 300 & + peer=$! + identity=$(FM_STATE_OVERRIDE="$state" bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$peer") \ + || fail "could not identify handoff peer" + mkdir "$state/.watch.lock" + printf '%s\n' "$peer" > "$state/.watch.lock/pid" + printf '%s\n' "$dir" > "$state/.watch.lock/fm-home" + printf '%s\n' "$WATCH" > "$state/.watch.lock/watcher-path" + printf '%s\n' generation-old > "$state/.watch.lock/cycle-id" + printf '%s\n' "$identity" > "$state/.watch.lock/pid-identity" + touch "$state/.last-watcher-beat" + FM_STATE_OVERRIDE="$state" bash -c '. "$1"; fm_wake_append check old "check: old" generation-old' _ "$LIB" \ + || fail "could not enqueue old-generation wake" + + FM_HOME="$dir" FM_ARM_ATTACH_POLL=0.05 FM_ARM_CONFIRM_TIMEOUT=1 "$WATCH_ARM" > "$armout" & + armpid=$! + i=0 + while [ "$i" -lt 80 ]; do + grep -qF "watcher: attached pid=$peer" "$armout" 2>/dev/null && break + sleep 0.05 + i=$((i + 1)) + done + grep -qF "watcher: attached pid=$peer" "$armout" || fail "arm did not attach before generation handoff" + printf '%s\n' generation-successor > "$state/.watch.lock/cycle-id" + touch "$state/.last-watcher-beat" + wait_for_exit "$armpid" 80 + status=$? + kill "$peer" 2>/dev/null || true + wait "$peer" 2>/dev/null || true + [ "$status" -ne 0 ] && [ "$status" -ne 124 ] || fail "arm did not stop on same-pid generation replacement" + grep -qF 'watcher: cycle-ended - the followed watcher cycle delivered an actionable wake' "$armout" \ + || fail "arm adopted a same-pid successor before classifying the old cycle: $(cat "$armout")" + pass "arm classifies the old cycle before same-pid generation handoff" +} + +test_cycle_identity_covers_stood_down_peer_delivery() { + local dir state peer identity armout armpid coordinator status i output + dir=$(make_case cycle-stood-down) + state="$dir/state" + armout="$dir/arm.out" + mark_pr_check_migration_complete "$state" + sleep 300 & + peer=$! + identity=$(FM_STATE_OVERRIDE="$state" bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$peer") \ + || fail "could not identify stood-down peer" + mkdir "$state/.watch.lock" + printf '%s\n' "$peer" > "$state/.watch.lock/pid" + printf '%s\n' "$dir" > "$state/.watch.lock/fm-home" + printf '%s\n' "$WATCH" > "$state/.watch.lock/watcher-path" + printf '%s\n' peer-generation > "$state/.watch.lock/cycle-id" + printf '%s\n' not-yet-published > "$state/.watch.lock/pid-identity" + touch "$state/.last-watcher-beat" + FM_STATE_OVERRIDE="$state" bash -c '. "$1"; fm_wake_append signal peer.status "signal: peer.status" peer-generation' _ "$LIB" \ + || fail "could not enqueue peer wake" + + ( + i=0 + while [ "$i" -lt 500 ]; do + for output in "$state"/.watch-arm-output.*; do + [ -f "$output" ] || continue + if grep -qF 'watcher: already running' "$output" 2>/dev/null; then + printf '%s\n' "$identity" > "$state/.watch.lock/pid-identity" + touch "$state/.last-watcher-beat" + exit 0 + fi + done + sleep 0.01 + i=$((i + 1)) + done + exit 1 + ) & + coordinator=$! + FM_HOME="$dir" FM_POLL=0.2 FM_ARM_CONFIRM_TIMEOUT=2 "$WATCH_ARM" > "$armout" & + armpid=$! + wait_for_exit "$armpid" 80 + status=$? + wait "$coordinator" || fail "stood-down peer did not become healthy after the child yielded" + kill "$peer" 2>/dev/null || true + wait "$peer" 2>/dev/null || true + [ "$status" -ne 0 ] && [ "$status" -ne 124 ] || fail "stood-down child path returned a clean or timed-out status" + grep -qF 'watcher: cycle-ended - the followed watcher cycle delivered an actionable wake' "$armout" \ + || fail "stood-down child path did not classify the peer cycle: $(cat "$armout")" + pass "arm classifies peer delivery before its owned child stands down" +} + +test_cycle_attribution_read_failure_is_distinct() { + local dir state cycle_rc pid_rc + dir=$(make_case cycle-attribution-read-failure) + state="$dir/state" + mkdir "$state/.wake-queue" + cycle_rc=0 + pid_rc=0 + FM_STATE_OVERRIDE="$state" bash -c '. "$1"; fm_wake_cycle_has_records generation-unreadable' _ "$LIB" || cycle_rc=$? + FM_STATE_OVERRIDE="$state" bash -c '. "$1"; fm_wake_watcher_pid_has_records 4242' _ "$LIB" || pid_rc=$? + [ "$cycle_rc" -eq 2 ] || fail "cycle lookup collapsed queue read failure to status $cycle_rc" + [ "$pid_rc" -eq 2 ] || fail "pid-prefix lookup collapsed queue read failure to status $pid_rc" + pass "cycle attribution distinguishes queue read failure from no match" +} + +test_arm_startup_health_wait_is_bounded() { + local dir state fakebin armout started holder armpid status elapsed + dir=$(make_case arm-startup-health-bound) + state="$dir/state" + fakebin="$dir/fakebin" + armout="$dir/arm.out" + started="$dir/holder.started" + mark_pr_check_migration_complete "$state" + printf '%s\n' invalid-retirement > "$state/task.pr-poll-retirement" + chmod 0600 "$state/task.pr-poll-retirement" + touch "$state/.last-watcher-beat" + FM_STATE_OVERRIDE="$state" bash -c ' + . "$1" + fm_lock_try_acquire "$FM_WAKE_QUEUE_LOCK" || exit 7 + : > "$2" + sleep 6 + ' _ "$LIB" "$started" & + holder=$! + while [ ! -e "$started" ]; do sleep 0.02; done + + elapsed=$(date +%s) + PATH="$fakebin:$PATH" FM_STATE_OVERRIDE="$state" FM_ARM_CONFIRM_TIMEOUT=1 "$WATCH_ARM" > "$armout" & + armpid=$! + wait_for_exit "$armpid" 40 + status=$? + elapsed=$(( $(date +%s) - elapsed )) + kill "$holder" 2>/dev/null || true + wait "$holder" 2>/dev/null || true + [ "$status" -ne 0 ] && [ "$status" -ne 124 ] || fail "arm hung behind startup recovery (status $status)" + [ "$elapsed" -lt 4 ] || fail "arm exceeded its bounded startup confirmation window (${elapsed}s)" + ! grep -qF 'watcher: started pid=' "$armout" || fail "arm accepted predecessor beacon health before startup recovery" + grep -qF 'watcher: FAILED - no live watcher with a fresh beacon' "$armout" \ + || fail "arm did not report bounded startup failure: $(cat "$armout")" + pass "arm does not publish health before blocking startup recovery finishes" +} + +test_cycle_attribution_lock_is_bounded() { + local dir state holder started elapsed rc + dir=$(make_case cycle-attribution-lock) + state="$dir/state" + started="$dir/holder.started" + FM_STATE_OVERRIDE="$state" bash -c ' + . "$1" + fm_lock_try_acquire "$FM_WAKE_QUEUE_LOCK" || exit 7 + : > "$2" + sleep 3 + ' _ "$LIB" "$started" & + holder=$! + while [ ! -e "$started" ]; do sleep 0.02; done + elapsed=$(date +%s) + rc=0 + FM_STATE_OVERRIDE="$state" FM_WAKE_CLASSIFY_LOCK_ATTEMPTS=1 FM_WAKE_CLASSIFY_LOCK_DELAY=0.01 \ + bash -c '. "$1"; fm_wake_cycle_has_records generation-stalled' _ "$LIB" || rc=$? + elapsed=$(( $(date +%s) - elapsed )) + kill "$holder" 2>/dev/null || true + wait "$holder" 2>/dev/null || true + [ "$rc" -eq 2 ] || fail "bounded attribution lock returned $rc instead of timeout" + [ "$elapsed" -lt 2 ] || fail "bounded attribution lock blocked startup for ${elapsed}s" + pass "cycle attribution lock timeout is bounded and non-hanging" +} + +test_arm_attribution_lock_timeout_is_non_hanging() { + local dir state fakebin watchout armout started holder wpid armpid status elapsed i + dir=$(make_case arm-cycle-attribution-lock) + state="$dir/state" + fakebin="$dir/fakebin" + watchout="$dir/watch.out" + armout="$dir/arm.out" + started="$dir/holder.started" + mark_pr_check_migration_complete "$state" + PATH="$fakebin:$PATH" FM_STATE_OVERRIDE="$state" FM_POLL=0.1 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$watchout" & + wpid=$! + i=0 + while [ "$i" -lt 80 ]; do + [ "$(cat "$state/.watch.lock/pid" 2>/dev/null || true)" = "$wpid" ] && [ -s "$state/.watch.lock/cycle-id" ] && break + sleep 0.1 + i=$((i + 1)) + done + [ "$(cat "$state/.watch.lock/pid" 2>/dev/null || true)" = "$wpid" ] || fail "seed watcher did not start for attribution-lock test" + PATH="$fakebin:$PATH" FM_STATE_OVERRIDE="$state" FM_ARM_ATTACH_POLL=0.05 FM_ARM_CONFIRM_TIMEOUT=1 FM_WAKE_CLASSIFY_LOCK_ATTEMPTS=1 FM_WAKE_CLASSIFY_LOCK_DELAY=0.01 "$WATCH_ARM" > "$armout" & + armpid=$! + i=0 + while [ "$i" -lt 80 ]; do + grep -qF "watcher: attached pid=$wpid" "$armout" 2>/dev/null && break + sleep 0.1 + i=$((i + 1)) + done + grep -qF "watcher: attached pid=$wpid" "$armout" || fail "arm did not attach before attribution-lock test" + FM_STATE_OVERRIDE="$state" bash -c ' + . "$1" + fm_lock_try_acquire "$FM_WAKE_QUEUE_LOCK" || exit 7 + : > "$2" + sleep 3 + ' _ "$LIB" "$started" & + holder=$! + while [ ! -e "$started" ]; do sleep 0.02; done + elapsed=$(date +%s) + kill "$wpid" 2>/dev/null || true + wait "$wpid" 2>/dev/null || true + wait_for_exit "$armpid" 80 + status=$? + elapsed=$(( $(date +%s) - elapsed )) + kill "$holder" 2>/dev/null || true + wait "$holder" 2>/dev/null || true + [ "$status" -ne 0 ] && [ "$status" -ne 124 ] || fail "arm returned $status while its attribution lock was held" + [ "$elapsed" -lt 2 ] || fail "arm blocked for ${elapsed}s behind the attribution lock" + grep -qF 'watcher: FAILED - wake queue attribution lock was unavailable' "$armout" || fail "arm did not report its bounded attribution-lock failure" + pass "arm does not hang when startup attribution cannot lock the durable queue" +} + test_singleton_start test_pid_identity_is_locale_invariant test_proc_pid_identity_ignores_wall_clock_and_detects_pid_reuse @@ -1112,3 +1355,11 @@ test_arm_waits_for_peer_beacon_after_child_stands_down test_arm_fails_loud_when_no_fresh_watcher_confirmable test_cycle_exit_ledger_links_successor_and_stays_bounded test_stopped_watcher_is_live_but_stale_then_exit_is_classified +test_cycle_identity_covers_pre_health_enqueue +test_cycle_identity_rejects_generation_mixing +test_cycle_identity_checks_old_generation_before_handoff +test_cycle_identity_covers_stood_down_peer_delivery +test_cycle_attribution_read_failure_is_distinct +test_arm_startup_health_wait_is_bounded +test_cycle_attribution_lock_is_bounded +test_arm_attribution_lock_timeout_is_non_hanging From db5dabc5437bc41c5f8cc6e41a7a947941773383 Mon Sep 17 00:00:00 2001 From: quinnbot-ai <quinnbot.ai@gmail.com> Date: Sun, 2 Aug 2026 17:19:18 -0700 Subject: [PATCH 32/70] test(herdr): stabilize focus-flash E2E sampling (#83) * test(herdr): stabilize focus flash E2E sampling * no-mistakes(review): Surface valid unfocused samples without retry masking * no-mistakes(document): Document exact-tab focus sampling contract --------- Co-authored-by: QuinnBot <quinnbot@proton.me> --- docs/verification/runtime-backends.md | 5 ++ .../fm-backend-herdr-focus-flash-e2e.test.sh | 64 +++++++++++++++---- 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index e5bb97ae89e..751655d5856 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -380,6 +380,11 @@ ok - mitigation: no explicit close and no corrective focus were needed on the de evidence: herdr=0.7.5 protocol=17 steal_live=1 default-session-tripwire=armed ``` +The active sampler reads the operation's exact pre-operation tab identity and focus state rather than reconstructing focus from a multi-object workspace snapshot. +It makes at most eight bounded probes with a 10-millisecond delay after a missing, malformed, or identity-mismatched response; a valid unfocused response is returned immediately and fails the in-operation comparison. +The final sample must still equal the exact pre-operation tab identity and focus state. +If the adapter issues a corrective `tab focus`, the regression records that tab's immediately preceding state and fails when it was genuinely unfocused or unreadable, while a redundant focus of the already focused tab does not masquerade as a wrong-focus interval. + Direct lab probes on the same day established the removal rules the emptying-close plan relies on, each verified with `workspace list` focus reads around one mutation in a guarded `fm-lab-` session: - An explicit `pane close` that emptied a non-focused workspace moved focus off the focused workspace in both before-focus and after-focus geometries. diff --git a/tests/fm-backend-herdr-focus-flash-e2e.test.sh b/tests/fm-backend-herdr-focus-flash-e2e.test.sh index 6145dec365b..561da7e54c7 100755 --- a/tests/fm-backend-herdr-focus-flash-e2e.test.sh +++ b/tests/fm-backend-herdr-focus-flash-e2e.test.sh @@ -5,7 +5,8 @@ # non-focused workspace steals the focused workspace. # Part B proves the mitigation: the focus-safe emptying-close plan # (repositioning move plus pane-death removal) removes the doomed workspace -# with no focus change and no corrective tab focus at all. +# with no wrong-focus interval; on the defective release, any corrective tab +# focus must find the exact prior tab still focused. # On a future release whose explicit close preserves focus, Part A records # that and Part B keeps outcome-only assertions, so no version is guessed. # Every CLI operation is routed through one guarded named non-default lab, and @@ -83,6 +84,32 @@ focus_snapshot() { printf '%s' "$tabs" | jq -e --arg tab "$tab" '([.result.tabs[] | select(.focused == true)] | length) == 1 and ([.result.tabs[] | select(.focused == true)][0].tab_id == $tab)' >/dev/null || return 1 printf '%s\t%s' "$workspace" "$tab" } +# Read only the tab whose focus the operation must preserve. +# workspace list is a multi-object snapshot that can be transiently +# incoherent while Herdr is changing workspaces, whereas tab get returns the +# exact response-derived tab identity and its focus state in one object. +# Only missing, malformed, or identity-mismatched responses retry; a valid +# unfocused sample returns immediately so the regression cannot mask it. +focus_sample_exact_tab() { # <workspace-id> <tab-id> + local workspace=$1 tab=$2 attempt=0 info focused + while [ "$attempt" -lt 8 ]; do + info=$(lab tab get "$tab" 2>/dev/null) || info= + focused=$(printf '%s' "$info" | jq -er --arg workspace "$workspace" --arg tab "$tab" ' + .result.tab + | select(.workspace_id == $workspace and .tab_id == $tab) + | .focused + | select(type == "boolean") + | tostring + ' 2>/dev/null) || focused= + if [ -n "$focused" ]; then + printf '%s\t%s\t%s' "$workspace" "$tab" "$focused" + return 0 + fi + sleep 0.01 + attempt=$((attempt + 1)) + done + return 1 +} ws_order() { lab workspace list | jq -er '[.result.workspaces[].workspace_id] | join(",")'; } wait_ws_gone() { # <workspace_id> local i=0 @@ -124,8 +151,8 @@ read -r B_DOOMED_WS _ B_DOOMED_PANE <<<"$(mkws flash-b-doomed)" || fail 'could n read -r B_ANCHOR_WS B_ANCHOR_TAB _ <<<"$(mkws flash-b-anchor)" || fail 'could not create the Part B anchor workspace' read -r _ _ _ <<<"$(mkws flash-b-tail)" || fail 'could not create the Part B tail workspace' lab tab focus "$B_ANCHOR_TAB" >/dev/null || fail 'could not focus the Part B anchor' -B_BEFORE=$(focus_snapshot) || fail 'could not capture the Part B pre-close focus' -[ "$B_BEFORE" = "$(printf '%s\t%s' "$B_ANCHOR_WS" "$B_ANCHOR_TAB")" ] \ +B_BEFORE=$(focus_sample_exact_tab "$B_ANCHOR_WS" "$B_ANCHOR_TAB") || fail 'could not capture the Part B pre-close focus' +[ "$B_BEFORE" = "$(printf '%s\t%s\ttrue' "$B_ANCHOR_WS" "$B_ANCHOR_TAB")" ] \ || fail 'Part B anchor focus does not match the intended workspace and tab' B_SURVIVOR_ORDER=$(ws_order | tr ',' '\n' | grep -v "^$B_DOOMED_WS\$" | paste -sd, -) \ || fail 'could not capture the Part B survivor order' @@ -141,11 +168,14 @@ B_SAMPLER_STOP="$TMP_ROOT/sampler.stop" : > "$B_SAMPLER_READY" while [ ! -e "$B_SAMPLER_STOP" ]; do if [ -e "$B_OPERATION_ACTIVE" ]; then - if B_SAMPLE=$(focus_snapshot); then + if B_SAMPLE=$(focus_sample_exact_tab "$B_ANCHOR_WS" "$B_ANCHOR_TAB"); then printf '%s\n' "$B_SAMPLE" >> "$B_FOCUS_SAMPLES" else printf '%s\n' UNREADABLE >> "$B_FOCUS_SAMPLES" fi + # Keep this observer from contending with the operation's own exact + # focus read and restore calls. + sleep 0.01 fi done ) & @@ -157,12 +187,23 @@ while [ ! -e "$B_SAMPLER_READY" ] && [ "$B_READY_ATTEMPT" -lt 100 ]; do done [ -e "$B_SAMPLER_READY" ] || fail 'the Part B focus sampler did not start' : > "$B_OPERATION_ACTIVE" -B_OUT=$(PATH="$FAKEBIN:$HERDR_ORIGINAL_PATH" FM_FLASH_CALL_LOG="$CALL_LOG" bash -c ' +# A workspace move can make the doomed pane redraw its idle shell briefly. +# Keep the production proof polling through that bounded settling window. +B_IDLE_SHELL_PROOF_POLLS=${FM_BACKEND_HERDR_IDLE_SHELL_PROOF_POLLS:-50} +B_OUT=$(PATH="$FAKEBIN:$HERDR_ORIGINAL_PATH" FM_FLASH_CALL_LOG="$CALL_LOG" \ + FM_BACKEND_HERDR_IDLE_SHELL_PROOF_POLLS="$B_IDLE_SHELL_PROOF_POLLS" bash -c ' . "$1/bin/backends/herdr.sh" fm_backend_herdr_cli() { local session=$1 + local prefocused shift - printf "%s\n" "$*" >> "$FM_FLASH_CALL_LOG" + if [ "$1 ${2:-}" = "tab focus" ]; then + prefocused=$(HERDR_SESSION="$session" herdr tab get "$3" --session "$session" 2>/dev/null \ + | jq -r ".result.tab.focused // \"UNREADABLE\"" 2>/dev/null) || prefocused=UNREADABLE + printf "tab focus prefocused=%s %s\n" "$prefocused" "$3" >> "$FM_FLASH_CALL_LOG" + else + printf "%s\n" "$*" >> "$FM_FLASH_CALL_LOG" + fi HERDR_SESSION="$session" herdr "$@" --session "$session" } fm_backend_herdr_projection_close_pane_focus_preserving "$2" "$3" @@ -182,20 +223,21 @@ wait_ws_gone "$B_DOOMED_WS" || fail 'the mitigation left the doomed workspace be if lab pane get "$B_DOOMED_PANE" >/dev/null 2>&1; then fail 'the mitigation left the doomed pane behind' fi -B_AFTER=$(focus_snapshot) || fail 'could not capture the Part B post-close focus' +B_AFTER=$(focus_sample_exact_tab "$B_ANCHOR_WS" "$B_ANCHOR_TAB") || fail 'could not capture the Part B post-close focus' [ "$B_AFTER" = "$B_BEFORE" ] \ || fail "the mitigation changed the exact focused workspace or tab ($B_BEFORE -> $B_AFTER)" [ "$(ws_order)" = "$B_SURVIVOR_ORDER" ] \ || fail "the mitigation left a lasting workspace order change ($B_SURVIVOR_ORDER -> $(ws_order))" grep -q '^pane process-info' "$CALL_LOG" || fail 'the idle-shell proof never ran' -pass 'mitigation: every in-operation sample preserved exact focus while the doomed workspace was removed' +pass 'mitigation: every in-operation exact-tab sample preserved focus while the doomed workspace was removed' if [ "$STEAL_LIVE" = 1 ]; then - grep -q '^tab focus' "$CALL_LOG" \ - && fail 'the corrective tab focus fired, so a wrong-focus interval existed on the defective release' + if grep -Eq '^tab focus prefocused=(false|UNREADABLE)' "$CALL_LOG"; then + fail 'the corrective tab focus found the exact prior tab unfocused or unreadable, so a wrong-focus interval existed on the defective release' + fi grep -q '^pane close' "$CALL_LOG" \ && fail 'the focus-unsafe explicit close was used on the defective release' - pass 'mitigation: no explicit close and no corrective focus were needed on the defective release' + pass 'mitigation: no explicit close or wrong-focus corrective action was needed on the defective release' fi STATUS=$(lab status --json) || fail 'could not read final named-lab version evidence' From 19a6ea09bc60393b5a2a97ddb6f4b495c68f08d0 Mon Sep 17 00:00:00 2001 From: quinnbot-ai <quinnbot.ai@gmail.com> Date: Sun, 2 Aug 2026 17:48:25 -0700 Subject: [PATCH 33/70] refactor: retire local controller remnants (#84) * refactor: retire local controller remnants * no-mistakes(document): Documentation already reflects retired controllers --------- Co-authored-by: QuinnBot <quinnbot@proton.me> --- bin/fm-convergence-scoreboard.sh | 372 ---------------------- bin/fm-decision-board.sh | 406 ------------------------ bin/fm-spawn.sh | 10 - bin/fm-test-run.sh | 2 +- docs/scripts.md | 13 - tests/fm-convergence-scoreboard.test.sh | 393 ----------------------- tests/fm-decision-board.test.sh | 326 ------------------- tests/fm-spawn-dispatch-profile.test.sh | 89 ++---- 8 files changed, 29 insertions(+), 1582 deletions(-) delete mode 100755 bin/fm-convergence-scoreboard.sh delete mode 100755 bin/fm-decision-board.sh delete mode 100755 tests/fm-convergence-scoreboard.test.sh delete mode 100755 tests/fm-decision-board.test.sh diff --git a/bin/fm-convergence-scoreboard.sh b/bin/fm-convergence-scoreboard.sh deleted file mode 100755 index 7a9646ecd87..00000000000 --- a/bin/fm-convergence-scoreboard.sh +++ /dev/null @@ -1,372 +0,0 @@ -#!/usr/bin/env bash -# fm-convergence-scoreboard.sh - measure one local ref against one upstream ref. -# -# Usage: -# bin/fm-convergence-scoreboard.sh <local-ref> <upstream-ref> -# bin/fm-convergence-scoreboard.sh --help -# -# Both refs are required so reruns never inherit an ambient branch or remote. -# The current worktree must be clean because an uncommitted convergence step -# cannot be represented by either resolved commit identity. -# Each ref name must be unambiguous and resolve locally to a commit. -# The resolved commits must have exactly one merge base. -# Ahead and behind are graph counts from upstream...local. -# First-parent deliveries are local first-parent commits unreachable upstream. -# Diff metrics compare the unique merge base with the resolved local commit. -# Every Git subprocess uses an allowlisted environment with optional locks and -# lazy fetches disabled. -# Measurements ignore ambient config and replacement refs, source attributes -# from the local commit, and use a bare repository pinned to the source storage -# object format, so ambient Git state cannot change the resolved measurements. -# A failed graph or diff measurement exits before emitting partial metrics. -# Renames count as one deletion plus one addition so file grouping is stable. -# Git binary-file numstat markers count as zero lines while the paths still count. -# Every changed path belongs to exactly one of these ordered groups: -# agent-runtime: AGENTS.md, CLAUDE.md, .agents/, and skills/ -# automation: bin/, .github/, .claude/, .codex/, .opencode/, and .pi/ -# tests: tests/ -# documentation: docs/, README.md, and CONTRIBUTING.md -# configuration: .tasks.toml, .no-mistakes.yaml, and .gitignore -# other: every remaining path -# Stdout is deterministic TOON without a trailing newline for help, successful -# results, and errors, and quoted strings escape every ASCII control character. -# Exit 0 means success, 1 means the comparison cannot be measured, and 2 means -# the invocation is invalid. -set -u - -export LC_ALL=C - -SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SELF="$SELF_DIR/$(basename "${BASH_SOURCE[0]}")" - -toon_quote() { - local value=$1 char code escaped='' i - value=${value//\\/\\\\} - value=${value//\"/\\\"} - value=${value//$'\n'/\\n} - value=${value//$'\r'/\\r} - value=${value//$'\t'/\\t} - for ((i = 0; i < ${#value}; i += 1)); do - char=${value:i:1} - if [[ "$char" == [[:cntrl:]] ]]; then - printf -v code '%d' "'$char" - if ((code < 32 || code == 127)); then - printf -v char '\\u%04x' "$code" - fi - fi - escaped+=$char - done - printf '"%s"' "$escaped" -} - -display_bin() { - if [ -n "${HOME-}" ] && [[ "$SELF" == "$HOME"/* ]]; then - printf '%s/%s' '~' "${SELF#"$HOME"/}" - else - printf '%s' "$SELF" - fi -} - -usage() { - printf 'bin: %s\n' "$(toon_quote "$(display_bin)")" - printf 'description: %s\n' "$(toon_quote "Measure a clean local Git ref against an explicit upstream ref without changing either.")" - printf 'usage: %s\n' "$(toon_quote "bin/fm-convergence-scoreboard.sh <local-ref> <upstream-ref>")" - printf 'arguments[2]{name,description}:\n' - printf ' %s,%s\n' \ - "$(toon_quote "local-ref")" \ - "$(toon_quote "Local delivery ref to measure.")" - printf ' %s,%s\n' \ - "$(toon_quote "upstream-ref")" \ - "$(toon_quote "Fetched upstream ref to compare against.")" - printf 'examples[2]: %s,%s' \ - "$(toon_quote "bin/fm-convergence-scoreboard.sh origin/main upstream/main")" \ - "$(toon_quote "bin/fm-convergence-scoreboard.sh HEAD upstream/main")" -} - -usage_error() { - printf 'error: %s\n' "$(toon_quote "$1")" - usage - exit 2 -} - -measure_error() { - printf 'error: %s\n' "$(toon_quote "$1")" - printf 'help[1]: %s' "$(toon_quote "$2")" - exit 1 -} - -isolated_git() { - local attr_source=$1 object_directory=$2 - local -a environment - shift 2 - environment=( - env -i - "PATH=$PATH" - "HOME=${HOME-}" - "LC_ALL=C" - "GIT_CONFIG_COUNT=0" - "GIT_CONFIG_GLOBAL=/dev/null" - "GIT_CONFIG_NOSYSTEM=1" - "GIT_NO_LAZY_FETCH=1" - "GIT_NO_REPLACE_OBJECTS=1" - "GIT_OPTIONAL_LOCKS=0" - ) - if [ -n "$attr_source" ]; then - environment+=( - "GIT_ATTR_NOSYSTEM=1" - "GIT_ATTR_SOURCE=$attr_source" - "GIT_OBJECT_DIRECTORY=$object_directory" - ) - fi - "${environment[@]}" git "$@" -} - -preflight_git() { - isolated_git '' '' "$@" -} - -reject_ambiguous_ref() { - local role=$1 ref=$2 diagnostics - diagnostics=$( - preflight_git -c core.warnAmbiguousRefs=true \ - -C "$REPO" rev-parse --symbolic-full-name "$ref" 2>&1 >/dev/null - ) || true - case "$diagnostics" in - *"refname '$ref' is ambiguous"*) - measure_error \ - "$role ref name is ambiguous: $ref" \ - "Use a fully qualified ref such as refs/heads/<name> or refs/remotes/<remote>/<name>, then rerun with the same two arguments." - ;; - esac -} - -if [ "$#" -eq 1 ] && { [ "$1" = "--help" ] || [ "$1" = "-h" ]; }; then - usage - exit 0 -fi - -[ "$#" -eq 2 ] \ - || usage_error "expected exactly <local-ref> and <upstream-ref>" - -case "$1" in - -*) usage_error "unknown flag or invalid local ref: $1" ;; -esac -case "$2" in - -*) usage_error "unknown flag or invalid upstream ref: $2" ;; -esac - -LOCAL_REF=$1 -UPSTREAM_REF=$2 - -if ! REPO=$(preflight_git rev-parse --show-toplevel 2>/dev/null) || [ -z "$REPO" ]; then - measure_error \ - "current directory is not inside a Git worktree" \ - "Run this command from the clean worktree that contains both refs." -fi - -if ! STATUS=$(preflight_git -C "$REPO" status --porcelain --untracked-files=normal 2>/dev/null); then - measure_error \ - "cannot inspect the current worktree state" \ - "Verify the worktree is readable, then rerun the same command." -fi -if [ -n "$STATUS" ]; then - measure_error \ - "current worktree is dirty" \ - "Commit or otherwise resolve every tracked and untracked change, then rerun with the same refs." -fi - -reject_ambiguous_ref "local" "$LOCAL_REF" -reject_ambiguous_ref "upstream" "$UPSTREAM_REF" - -if ! LOCAL_COMMIT=$(preflight_git -C "$REPO" rev-parse --verify --quiet "$LOCAL_REF^{commit}" 2>/dev/null); then - measure_error \ - "local ref is missing, unfetched, or does not resolve to one commit: $LOCAL_REF" \ - "Fetch or create the local ref explicitly, then rerun with the same two arguments." -fi -if ! UPSTREAM_COMMIT=$(preflight_git -C "$REPO" rev-parse --verify --quiet "$UPSTREAM_REF^{commit}" 2>/dev/null); then - measure_error \ - "upstream ref is missing, unfetched, or does not resolve to one commit: $UPSTREAM_REF" \ - "Fetch the upstream ref explicitly, then rerun with the same two arguments." -fi - -if ! OBJECTS_DIR=$(preflight_git -C "$REPO" rev-parse --path-format=absolute --git-path objects 2>/dev/null); then - measure_error \ - "cannot locate the repository object database" \ - "Verify the repository metadata, then rerun with the same refs." -fi -if ! OBJECT_FORMAT=$(preflight_git -C "$REPO" rev-parse --show-object-format=storage 2>/dev/null) \ - || [ -z "$OBJECT_FORMAT" ]; then - measure_error \ - "cannot determine the repository object format" \ - "Verify the repository metadata, then rerun with the same refs." -fi - -if ! MEASURE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/fm-convergence-scoreboard.XXXXXX"); then - measure_error \ - "cannot create isolated storage for measurements" \ - "Verify that the temporary directory is writable, then rerun with the same refs." -fi -MEASURE_GIT_DIR="$MEASURE_DIR/repository.git" -NUMSTAT_FILE="$MEASURE_DIR/numstat" -cleanup_measurement() { - rm -rf -- "$MEASURE_DIR" -} -trap cleanup_measurement EXIT - -if ! preflight_git init --bare --quiet --template= --object-format="$OBJECT_FORMAT" \ - "$MEASURE_GIT_DIR" 2>/dev/null; then - measure_error \ - "cannot initialize isolated storage for measurements" \ - "Verify that the temporary directory is writable, then rerun with the same refs." -fi - -measure_git() { - isolated_git "$LOCAL_COMMIT" "$OBJECTS_DIR" \ - --no-replace-objects --git-dir="$MEASURE_GIT_DIR" \ - -c core.bigFileThreshold=512m "$@" -} - -if ! MERGE_BASES=$(measure_git merge-base --all "$UPSTREAM_COMMIT" "$LOCAL_COMMIT" 2>/dev/null); then - measure_error \ - "the refs have no common commit" \ - "Choose local and upstream refs from the same repository history." -fi -MERGE_BASE_COUNT=$(printf '%s\n' "$MERGE_BASES" | awk 'NF { count += 1 } END { print count + 0 }') -case "$MERGE_BASE_COUNT" in - 1) MERGE_BASE=$MERGE_BASES ;; - 0) - measure_error \ - "the refs have no common commit" \ - "Choose local and upstream refs from the same repository history." - ;; - *) - measure_error \ - "the refs have multiple merge bases, so the diff basis is ambiguous" \ - "Converge the histories to one merge base, then rerun with the same refs." - ;; -esac - -if ! GRAPH_COUNTS=$(measure_git rev-list --left-right --count "$UPSTREAM_COMMIT...$LOCAL_COMMIT" 2>/dev/null); then - measure_error \ - "cannot count commits between the resolved refs" \ - "Verify the repository object database, then rerun with the same refs." -fi -read -r BEHIND AHEAD <<< "$GRAPH_COUNTS" - -if ! FIRST_PARENT_DELIVERIES=$(measure_git rev-list --first-parent --count "$UPSTREAM_COMMIT..$LOCAL_COMMIT" 2>/dev/null); then - measure_error \ - "cannot count first-parent local deliveries" \ - "Verify the repository object database, then rerun with the same refs." -fi - -CHANGED_FILES=0 -INSERTIONS=0 -DELETIONS=0 - -AGENT_FILES=0 -AGENT_INSERTIONS=0 -AGENT_DELETIONS=0 -AUTOMATION_FILES=0 -AUTOMATION_INSERTIONS=0 -AUTOMATION_DELETIONS=0 -TEST_FILES=0 -TEST_INSERTIONS=0 -TEST_DELETIONS=0 -DOC_FILES=0 -DOC_INSERTIONS=0 -DOC_DELETIONS=0 -CONFIG_FILES=0 -CONFIG_INSERTIONS=0 -CONFIG_DELETIONS=0 -OTHER_FILES=0 -OTHER_INSERTIONS=0 -OTHER_DELETIONS=0 - -if ! measure_git diff --numstat -z --no-renames --no-ext-diff --no-textconv \ - --diff-algorithm=myers --no-indent-heuristic "$MERGE_BASE" "$LOCAL_COMMIT" \ - > "$NUMSTAT_FILE" 2>/dev/null; then - measure_error \ - "cannot calculate diff metrics between the resolved refs" \ - "Verify that all repository objects are available locally, then rerun with the same refs." -fi - -while IFS= read -r -d '' record; do - added=${record%%$'\t'*} - remainder=${record#*$'\t'} - deleted=${remainder%%$'\t'*} - path=${remainder#*$'\t'} - [ "$added" = "-" ] && added=0 - [ "$deleted" = "-" ] && deleted=0 - CHANGED_FILES=$((CHANGED_FILES + 1)) - INSERTIONS=$((INSERTIONS + added)) - DELETIONS=$((DELETIONS + deleted)) - case "$path" in - AGENTS.md|CLAUDE.md|.agents/*|skills/*) - AGENT_FILES=$((AGENT_FILES + 1)) - AGENT_INSERTIONS=$((AGENT_INSERTIONS + added)) - AGENT_DELETIONS=$((AGENT_DELETIONS + deleted)) - ;; - bin/*|.github/*|.claude/*|.codex/*|.opencode/*|.pi/*) - AUTOMATION_FILES=$((AUTOMATION_FILES + 1)) - AUTOMATION_INSERTIONS=$((AUTOMATION_INSERTIONS + added)) - AUTOMATION_DELETIONS=$((AUTOMATION_DELETIONS + deleted)) - ;; - tests/*) - TEST_FILES=$((TEST_FILES + 1)) - TEST_INSERTIONS=$((TEST_INSERTIONS + added)) - TEST_DELETIONS=$((TEST_DELETIONS + deleted)) - ;; - docs/*|README.md|CONTRIBUTING.md) - DOC_FILES=$((DOC_FILES + 1)) - DOC_INSERTIONS=$((DOC_INSERTIONS + added)) - DOC_DELETIONS=$((DOC_DELETIONS + deleted)) - ;; - .tasks.toml|.no-mistakes.yaml|.gitignore) - CONFIG_FILES=$((CONFIG_FILES + 1)) - CONFIG_INSERTIONS=$((CONFIG_INSERTIONS + added)) - CONFIG_DELETIONS=$((CONFIG_DELETIONS + deleted)) - ;; - *) - OTHER_FILES=$((OTHER_FILES + 1)) - OTHER_INSERTIONS=$((OTHER_INSERTIONS + added)) - OTHER_DELETIONS=$((OTHER_DELETIONS + deleted)) - ;; - esac -done < "$NUMSTAT_FILE" - -printf 'schema: %s\n' "$(toon_quote "fm-convergence-scoreboard.v1")" -printf 'local:\n' -printf ' ref: %s\n' "$(toon_quote "$LOCAL_REF")" -printf ' commit: %s\n' "$(toon_quote "$LOCAL_COMMIT")" -printf 'upstream:\n' -printf ' ref: %s\n' "$(toon_quote "$UPSTREAM_REF")" -printf ' commit: %s\n' "$(toon_quote "$UPSTREAM_COMMIT")" -printf 'commits:\n' -printf ' ahead: %s\n' "$AHEAD" -printf ' behind: %s\n' "$BEHIND" -printf ' first_parent_deliveries: %s\n' "$FIRST_PARENT_DELIVERIES" -printf 'diff:\n' -printf ' base_commit: %s\n' "$(toon_quote "$MERGE_BASE")" -printf ' changed_files: %s\n' "$CHANGED_FILES" -printf ' insertions: %s\n' "$INSERTIONS" -printf ' deletions: %s\n' "$DELETIONS" -printf ' net_lines: %s\n' "$((INSERTIONS - DELETIONS))" -printf 'file_groups[6]{name,changed_files,insertions,deletions,net_lines}:\n' -printf ' %s,%s,%s,%s,%s\n' \ - "$(toon_quote "agent-runtime")" "$AGENT_FILES" "$AGENT_INSERTIONS" "$AGENT_DELETIONS" \ - "$((AGENT_INSERTIONS - AGENT_DELETIONS))" -printf ' %s,%s,%s,%s,%s\n' \ - "$(toon_quote "automation")" "$AUTOMATION_FILES" "$AUTOMATION_INSERTIONS" "$AUTOMATION_DELETIONS" \ - "$((AUTOMATION_INSERTIONS - AUTOMATION_DELETIONS))" -printf ' %s,%s,%s,%s,%s\n' \ - "$(toon_quote "tests")" "$TEST_FILES" "$TEST_INSERTIONS" "$TEST_DELETIONS" \ - "$((TEST_INSERTIONS - TEST_DELETIONS))" -printf ' %s,%s,%s,%s,%s\n' \ - "$(toon_quote "documentation")" "$DOC_FILES" "$DOC_INSERTIONS" "$DOC_DELETIONS" \ - "$((DOC_INSERTIONS - DOC_DELETIONS))" -printf ' %s,%s,%s,%s,%s\n' \ - "$(toon_quote "configuration")" "$CONFIG_FILES" "$CONFIG_INSERTIONS" "$CONFIG_DELETIONS" \ - "$((CONFIG_INSERTIONS - CONFIG_DELETIONS))" -printf ' %s,%s,%s,%s,%s' \ - "$(toon_quote "other")" "$OTHER_FILES" "$OTHER_INSERTIONS" "$OTHER_DELETIONS" \ - "$((OTHER_INSERTIONS - OTHER_DELETIONS))" diff --git a/bin/fm-decision-board.sh b/bin/fm-decision-board.sh deleted file mode 100755 index 57ca47bb0e8..00000000000 --- a/bin/fm-decision-board.sh +++ /dev/null @@ -1,406 +0,0 @@ -#!/usr/bin/env bash -# fm-decision-board.sh - the captain's daily decision board, rendered from the -# canonical fleet snapshot. -# -# A thin renderer OVER bin/fm-fleet-snapshot.sh, in the same shape as -# fm-fleet-view.sh and fm-bearings-snapshot.sh: it never parses the backlog, -# task metadata, or status logs itself. It shells out to -# `fm-fleet-snapshot.sh --json`, selects the captain-owned open items from that -# stable contract, and renders them. Backlog and decision semantics stay owned -# by the snapshot; this script owns only the projection and the presentation. -# -# Two sources feed the board, both already normalized by the snapshot: -# 1. backlog records still open with hold_kind == "captain" - the durable -# captain-gated queue maintained through bin/fm-decision-hold.sh. -# 2. tasks whose hints.open_decisions carries a needs-decision event - a -# worker parked right now on an ask-user finding that the captain (or -# firstmate's configured authority) must answer. -# An id present in both is ONE board entry carrying both facts, never two rows. -# -# Entries are split by whether the captain's answer is enough on its own: -# ready - nothing else is in the way; his answer releases the work. -# gated - captain-owned, but unfinished work must land first. -# A parked worker is always ready: it is stopped on this answer right now, so no -# other unfinished work can release it. Any remaining dependency still prints. -# Within each group the longest-waiting entry sorts first, then id, so the -# staleness that motivates the daily cadence is visible at the top. -# -# Output is captain-facing, so it follows AGENTS.md section 9: plain outcome -# language, no internal vocabulary. Entries are NUMBERED because the captain -# answers these surfaces in bulk by position ("1 yes, 2 yes, 3 sure"); the -# durable id trails each entry so firstmate can act on the answer. -# -# Titles and questions are relayed VERBATIM from the durable record, bounded but -# never paraphrased: a generator that reworded them would be inventing the very -# thing the captain is answering. Section 9 translation is therefore a duty of -# whoever WRITES a backlog entry, and this renderer only enforces the parts it -# can carry honestly - project names instead of paths, and plain group wording. -# -# Usage: -# fm-decision-board.sh [--format markdown|html|json] [--snapshot <path>] -# [--limit <n>] [--title <text>] -# -# --format markdown (default) drop-in section for the morning packet -# --format html standalone page for an on-demand captain surface -# --format json the projected model (schema fm-decision-board.v1) -# --snapshot <path> read an existing fm-fleet-snapshot.v1 document instead of -# running a fresh snapshot; "-" reads standard input -# --limit <n> cap entries per group (default 25; 0 means no cap). -# Anything dropped is disclosed in omitted[] and in the -# rendered footer, so a bounded board never reads complete. -# --title <text> override the rendered heading -# -# Read-only: no locks, no mutation, no network. Exits 3 when the snapshot is -# missing its backlog source, so an empty board is never confused with a -# board that could not be built. -set -u - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -FLEET="$SCRIPT_DIR/fm-fleet-snapshot.sh" - -SCHEMA=fm-decision-board.v1 -FORMAT=markdown -SNAPSHOT_PATH="" -LIMIT=25 -TITLE="Captain decision board" - -usage() { - awk ' - NR == 1 { next } - /^#/ { sub(/^# ?/, ""); print; next } - { exit } - ' "$0" -} - -fail() { - printf 'fm-decision-board: %s\n' "$*" >&2 - exit 1 -} - -while [ $# -gt 0 ]; do - case "$1" in - --format) shift; FORMAT=${1:-} ;; - --format=*) FORMAT=${1#--format=} ;; - --snapshot) shift; SNAPSHOT_PATH=${1:-} ;; - --snapshot=*) SNAPSHOT_PATH=${1#--snapshot=} ;; - --limit) shift; LIMIT=${1:-} ;; - --limit=*) LIMIT=${1#--limit=} ;; - --title) shift; TITLE=${1:-} ;; - --title=*) TITLE=${1#--title=} ;; - -h|--help) usage; exit 0 ;; - *) usage >&2; exit 2 ;; - esac - shift -done - -case "$FORMAT" in - markdown|html|json) ;; - *) fail "unknown --format '$FORMAT' (markdown, html, json)" ;; -esac -case "$LIMIT" in - ''|*[!0-9]*) fail "--limit must be a non-negative integer" ;; -esac -[ -n "$TITLE" ] || fail "--title must not be empty" - -command -v jq >/dev/null 2>&1 || fail "jq not found" - -if [ -n "$SNAPSHOT_PATH" ]; then - if [ "$SNAPSHOT_PATH" = - ]; then - SNAP=$(cat) - else - [ -r "$SNAPSHOT_PATH" ] || fail "cannot read snapshot: $SNAPSHOT_PATH" - SNAP=$(cat "$SNAPSHOT_PATH") - fi -else - SNAP=$("$FLEET" --json) || exit $? -fi - -printf '%s' "$SNAP" | jq -e 'type == "object"' >/dev/null 2>&1 \ - || fail "snapshot is not a JSON object" - -NOW=${FM_DECISION_BOARD_NOW:-$(date -u +%Y-%m-%dT%H:%M:%SZ)} - -# --- projection ------------------------------------------------------------- -# -# One jq program builds the whole model so every renderer below reads the same -# facts. Truncation bounds match the snapshot's own so a long backlog line -# cannot blow up a packet section. -MODEL=$(printf '%s' "$SNAP" | jq \ - --arg schema "$SCHEMA" \ - --arg now "$NOW" \ - --arg title "$TITLE" \ - --argjson limit "$LIMIT" ' - def trunc($n): - if . == null then null - else tostring | gsub("\\s+"; " ") | if length > $n then .[:$n] + "…" else . end - end; - - # A registry entry may name a project by absolute path. The captain reads the - # project, never the path (AGENTS.md section 9), so keep only the last segment. - def project_name: - if . == null then null - else tostring | sub("/+$"; "") | split("/") | last - end; - - def date_prefix: - if type != "string" then null - else try (([capture("^(?<d>\\d{4}-\\d{2}-\\d{2})").d] | first) // null) catch null - end; - - def date_epoch: - if . == null then null - else - . as $date - | (try (strptime("%Y-%m-%d") | mktime) catch null) - | if . == null or (strftime("%Y-%m-%d") != $date) then null else . end - end; - - # Whole days between an ISO date prefix and now; null when either is unusable. - def waiting_days($since; $now): - ($since | date_prefix | date_epoch) as $s - | ($now | date_prefix | date_epoch) as $n - | if $s == null or $n == null then null - else - (($n - $s) / 86400 | floor) - | if . < 0 then null else . end - end; - - def waiting_error($id; $since): - if $since == null or ($since | date_prefix | date_epoch) != null then null - else "Could not read waiting date for \($id): \($since | trunc(120))." - end; - - . as $snap - | ($snap.backlog.present == true) as $backlog_present - - # Captain-owned backlog entries that are still open. - | ([ $snap.backlog.records[]? - | select(.structured == true and .state != "done" and .hold_kind == "captain") - | { - id: (.id // "unknown"), - title: (.title | trunc(200)), - question: (.hold_reason | trunc(320)), - repo: (.repo | project_name | trunc(120)), - since: (.since // null), - blocked_by: ((.unresolved_blocker_ids // []) | map(trunc(120))), - sources: ["queue"] - } ]) as $queue - - # Workers parked on an unanswered decision right now. - | ([ $snap.tasks[]? - | select((.hints.open_decisions // []) | any(.verb == "needs-decision")) - | { - id: (.id // "unknown"), - title: ((.backlog.title // .id) | trunc(200)), - question: (((.hints.open_decisions // []) - | map(select(.verb == "needs-decision")) - | map(.summary // "") | map(select(. != "")) - | join(" · ")) | trunc(320)), - repo: ((.backlog.repo // .project) | project_name | trunc(120)), - since: (.backlog.since // null), - blocked_by: [], - sources: ["parked"] - } ]) as $parked - - # One entry per id. A queue entry keeps its durable wording and gains the - # parked fact; a parked-only worker becomes its own entry. - | ([ $queue[] | .id ]) as $queue_ids - | ($queue - + [ $parked[] | select(.id as $i | $queue_ids | index($i) | not) ]) as $merged - | ([ $parked[] | .id ]) as $parked_ids - | ([ $merged[] - | . as $entry - | .parked = ($parked_ids | index($entry.id) != null) - | .parked_note = (if ($parked_ids | index($entry.id)) == null then null - else ([ $parked[] | select(.id == $entry.id) | .question ] | first) - end) - | .sources = (if ($parked_ids | index($entry.id)) == null then .sources - else (.sources + ["parked"] | unique) end) - | .waiting_days = waiting_days(.since; $now) - | .waiting_error = waiting_error(.id; .since) - | .ready = (.parked or ((.blocked_by | length) == 0)) - ]) as $entries - - | (def order: sort_by([(0 - (.waiting_days // -1)), .id]); - [ $entries[] | select(.ready) ] | order) as $ready_all - | (def order: sort_by([(0 - (.waiting_days // -1)), .id]); - [ $entries[] | select(.ready | not) ] | order) as $gated_all - - | (if $limit == 0 then $ready_all else $ready_all[:$limit] end) as $ready - | (if $limit == 0 then $gated_all else $gated_all[:$limit] end) as $gated - - | { - schema: $schema, - generated: $now, - title: $title, - home: ($snap.fm_home // null), - source_present: $backlog_present, - counts: { - total: ($entries | length), - ready: ($ready_all | length), - gated: ($gated_all | length), - parked: ([ $entries[] | select(.parked) ] | length), - shown: (($ready | length) + ($gated | length)) - }, - ready: $ready, - gated: $gated, - omitted: [ - if ($ready_all | length) > ($ready | length) then - {surface: "ready", count: (($ready_all | length) - ($ready | length)), - reveal: "--limit 0"} - else empty end, - if ($gated_all | length) > ($gated | length) then - {surface: "gated", count: (($gated_all | length) - ($gated | length)), - reveal: "--limit 0"} - else empty end, - if $backlog_present != true then - {surface: "queue", count: 0, reveal: "no readable decision queue"} - else empty end - ] - } - ') || fail "could not project the fleet snapshot" - -printf '%s' "$MODEL" | jq -e '.source_present == true' >/dev/null 2>&1 \ - || { echo "fm-decision-board: no readable decision queue in the fleet snapshot" >&2; exit 3; } - -# --- renderers -------------------------------------------------------------- - -render_markdown() { - printf '%s' "$MODEL" | jq -r ' - def dash($v): if $v == null or $v == "" then null else $v end; - def waited($e): - if $e.waiting_days == null then null - elif $e.waiting_days == 0 then "raised today" - elif $e.waiting_days == 1 then "waiting 1 day" - else "waiting \($e.waiting_days) days" end; - def facts($e): - [waited($e), dash($e.repo), (if $e.parked then "work is parked on it" else null end)] - | map(select(. != null)) | join(" · "); - def entry($e; $n): - "\($n). **\($e.title)**" - + (if dash($e.question) == null then "" else "\n \($e.question)" end) - + (if $e.parked and dash($e.parked_note) != null and $e.parked_note != $e.question - then "\n Worker is stopped here: \($e.parked_note)" else "" end) - + (if ($e.blocked_by | length) > 0 - then "\n Waiting first on: \($e.blocked_by | join(", "))" else "" end) - + "\n _\(facts($e))_ · `\($e.id)`"; - def errors($entries): - [ $entries[] | select(.waiting_error != null) | "_Error: \(.waiting_error)_" ]; - - (.ready | length) as $ready_shown - | ["## \(.title)", ""] - + (if .counts.total == 0 then - ["Nothing is waiting on you."] - else - ["\(.counts.total) open · \(.counts.ready) need only your answer" - + (if .counts.gated > 0 then " · \(.counts.gated) waiting on other work first" else "" end) - + (if .counts.parked > 0 then " · \(.counts.parked) with work stopped on them" else "" end) - + ".", ""] - + (if (.ready | length) > 0 then - ["### Needs your answer (\(.counts.ready))", ""] - + errors(.ready) - + [ .ready | to_entries[] | entry(.value; .key + 1) ] - + [""] - else [] end) - + (if (.gated | length) > 0 then - ["### Waiting on other work first (\(.counts.gated))", ""] - + errors(.gated) - + [ .gated | to_entries[] | entry(.value; .key + $ready_shown + 1) ] - + [""] - else [] end) - end) - + [ .omitted[] | "_\(.count) more \(.surface) not shown - reveal with `\(.reveal)`._" ] - | .[] - ' -} - -render_html() { - printf '%s' "$MODEL" | jq -r ' - def esc: if . == null then "" else tostring | @html end; - def dash($v): if $v == null or $v == "" then null else $v end; - def waited($e): - if $e.waiting_days == null then null - elif $e.waiting_days == 0 then "raised today" - elif $e.waiting_days == 1 then "waiting 1 day" - else "waiting \($e.waiting_days) days" end; - def facts($e): - [waited($e), dash($e.repo)] | map(select(. != null)) | map(esc) | join(" · "); - def entry($e; $n): - ["<li class=\"item\">", - "<div class=\"head\"><span class=\"num\">\($n)</span><span class=\"ttl\">\($e.title | esc)</span>" - + (if $e.parked then "<span class=\"flag\">work parked</span>" else "" end) - + "</div>", - (if dash($e.question) == null then "" else "<p class=\"q\">\($e.question | esc)</p>" end), - (if $e.parked and dash($e.parked_note) != null and $e.parked_note != $e.question - then "<p class=\"q stop\">Worker is stopped here: \($e.parked_note | esc)</p>" else "" end), - (if ($e.blocked_by | length) > 0 - then "<p class=\"q wait\">Waiting first on: \($e.blocked_by | map(esc) | join(", "))</p>" - else "" end), - "<p class=\"meta\">\(facts($e))<span class=\"id\">\($e.id | esc)</span></p>", - "</li>"] - | map(select(. != "")) | join("\n"); - def errors($entries): - [ $entries[] | select(.waiting_error != null) - | "<p class=\"error\">\(.waiting_error | esc)</p>" ]; - - (.ready | length) as $ready_shown - | ["<!doctype html>", - "<html lang=\"en\"><head><meta charset=\"utf-8\">", - "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">", - "<title>\(.title | esc)", - "
    ", - "

    \(.title | esc)

    ", - "

    \(.generated | esc)

    "] - + (if .counts.total == 0 then - ["

    Nothing is waiting on you.

    "] - else - ["

    \(.counts.total) open · \(.counts.ready) need only your answer" - + (if .counts.gated > 0 then " · \(.counts.gated) waiting on other work first" else "" end) - + ".

    "] - + (if (.ready | length) > 0 then - ["

    Needs your answer (\(.counts.ready))

    "] - + errors(.ready) + ["
      "] - + [ .ready | to_entries[] | entry(.value; .key + 1) ] + ["
    "] - else [] end) - + (if (.gated | length) > 0 then - ["

    Waiting on other work first (\(.counts.gated))

    "] - + errors(.gated) + ["
      "] - + [ .gated | to_entries[] | entry(.value; .key + $ready_shown + 1) ] + ["
    "] - else [] end) - end) - + [ .omitted[] | "

    \(.count) more \(.surface | esc) not shown - reveal with \(.reveal | esc).

    " ] - + ["
    "] - | .[] - ' -} - -case "$FORMAT" in - json) printf '%s' "$MODEL" | jq . ;; - markdown) render_markdown ;; - html) render_html ;; -esac diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 00b3ea9bea0..e9b4bf1a3a8 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1819,16 +1819,6 @@ LAUNCH=${LAUNCH//__PIEXT__/$sq_piext} LAUNCH=${LAUNCH//__PITURNEND__/$sq_piturnend} LAUNCH=${LAUNCH//__PIWATCH__/$sq_piwatch} LAUNCH=${LAUNCH//__OPINPUT__/$sq_opinput} -# Crewmate panes are created by a long-lived tmux/herdr daemon that does not -# inherit firstmate's current environment, so a bare `claude` in the pane falls -# back to the default ~/.claude store even when firstmate itself runs under a -# different CLAUDE_CONFIG_DIR (for example a work-vs-personal subscription split). -# Forward firstmate's own resolved store onto the claude launch so the crewmate -# uses the same credential/config firstmate is authenticated with. Only when set; -# an unset value is the single-store default and needs no prefix. -if [ "$HARNESS" = claude ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then - LAUNCH="CLAUDE_CONFIG_DIR=$(shell_quote "$CLAUDE_CONFIG_DIR") $LAUNCH" -fi if [ "$KIND" = secondmate ]; then sq_home=$(shell_quote "$PROJ_ABS") sq_primary_home=$(shell_quote "$FM_HOME") diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 38788ce5649..ef295a7bc38 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -180,7 +180,7 @@ family_for_basename() { fm-afk-inject-e2e.test.sh|fm-afk-return.test.sh) printf '%s\n' afk ;; - fm-bearings-snapshot.test.sh|fm-decision-board.test.sh|fm-fleet-snapshot-view.test.sh) + fm-bearings-snapshot.test.sh|fm-fleet-snapshot-view.test.sh) printf '%s\n' snapshot-bearings ;; fm-backend-cmux.test.sh|fm-backend-cmux-smoke.test.sh) diff --git a/docs/scripts.md b/docs/scripts.md index cb609cac171..ec66c1f3d8e 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -16,8 +16,6 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-fleet-snapshot.sh` | Print the read-only structured fleet snapshot JSON (schema `fm-fleet-snapshot.v1`) | | `fm-fleet-view.sh` | Render the fleet snapshot as a human Markdown view | | `fm-bearings-snapshot.sh` | Project the fleet snapshot to the compact TOON bearings view; local-only unless `--include-prs` | -| `fm-convergence-scoreboard.sh` | Measure one clean local ref against one explicit upstream ref as deterministic TOON | -| `fm-decision-board.sh` | Render the captain's open decisions from the fleet snapshot as Markdown, HTML, or JSON | | `fm-update.sh` | Fast-forward-only self-update of firstmate and secondmate homes from the publish remote | | `fm-backlog-handoff.sh` | Validate and delegate queued backlog-item moves into a secondmate home | | `fm-decision-hold.sh` | Create, verify, complete, and resolve durable captain-held decisions | @@ -105,14 +103,3 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-public-followup-lib.sh` | Shared relay-activation gate, O(1) presence checks, and private transport paths for promised public replies | | `fm-public-followup.sh` | Reconcile typed terminal work results into a public commitment and deliver its final reply once | | `fm-public-followup-emit.sh` | Report one typed terminal work result into the home that owes the public reply | - -## Convergence scoreboard - -Run `bin/fm-convergence-scoreboard.sh ` from a clean worktree before convergence starts and after every convergence step. -Keep both ref arguments explicit and record the emitted commit identities with the measurements so a result can be reproduced after branch names move. -Use `--help` for the current origin/upstream examples, and fully qualify any ref name that collides across namespaces. -The command emits deterministic TOON and fails instead of returning partial metrics when it cannot complete a measurement. - -Stated limitation: the cleanliness preflight assumes repo-local `.git/info/attributes`, filter configuration, and fsmonitor configuration are benign ambient inputs; they can affect whether the command proceeds, but they do not enter the isolated ref measurement. - -The script header owns the exact measurement basis, isolation guarantees, metric meanings, path membership, output schema, and exit contract. diff --git a/tests/fm-convergence-scoreboard.test.sh b/tests/fm-convergence-scoreboard.test.sh deleted file mode 100755 index 88d1e4eef19..00000000000 --- a/tests/fm-convergence-scoreboard.test.sh +++ /dev/null @@ -1,393 +0,0 @@ -#!/usr/bin/env bash -# Behavior tests for the deterministic, read-only convergence scoreboard. -set -u - -# shellcheck source=tests/lib.sh -# shellcheck disable=SC1091 -. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" - -SCOREBOARD="$ROOT/bin/fm-convergence-scoreboard.sh" -TMP_ROOT=$(fm_test_tmproot fm-convergence-scoreboard) -fm_git_identity - -make_fixture() { - local repo="$TMP_ROOT/repo" - mkdir -p "$repo/bin" "$repo/docs" - git -C "$repo" init -q -b main - printf 'base\n' > "$repo/AGENTS.md" - printf 'old\nstay\n' > "$repo/bin/tool.sh" - printf 'remove\n' > "$repo/docs/guide.md" - git -C "$repo" add AGENTS.md bin/tool.sh docs/guide.md - git -C "$repo" commit -qm "chore: seed fixture" - git -C "$repo" branch upstream - - git -C "$repo" checkout -qb local - printf 'base\nlocal\n' > "$repo/AGENTS.md" - printf 'new\nstay\n' > "$repo/bin/tool.sh" - mkdir -p "$repo/tests" - printf 'one\ntwo\n' > "$repo/tests/example.test.sh" - git -C "$repo" rm -q docs/guide.md - git -C "$repo" add AGENTS.md bin/tool.sh tests/example.test.sh - git -C "$repo" commit -qm "feat: add local delivery" - printf 'version = 1\n' > "$repo/.tasks.toml" - printf 'other\n' > "$repo/misc.txt" - git -C "$repo" add .tasks.toml misc.txt - git -C "$repo" commit -qm "fix: finish local delivery" - - git -C "$repo" checkout -q upstream - printf 'upstream\n' > "$repo/upstream.txt" - git -C "$repo" add upstream.txt - git -C "$repo" commit -qm "feat: advance upstream" - git -C "$repo" checkout -q local - printf '%s\n' "$repo" -} - -run_scoreboard() { - local repo=$1 - shift - ( - cd "$repo" || exit 1 - "$SCOREBOARD" "$@" - ) -} - -assert_no_trailing_newline() { - local file=$1 label=$2 last_byte - [ -s "$file" ] || fail "$label was empty" - last_byte=$(tail -c 1 "$file" | od -An -tuC | tr -d '[:space:]') - [ "$last_byte" != "10" ] || fail "$label ended with a newline" -} - -test_deterministic_scoreboard() { - local repo local_sha upstream_sha base_sha before after out rerun expected - repo=$(make_fixture) - local_sha=$(git -C "$repo" rev-parse local) - upstream_sha=$(git -C "$repo" rev-parse upstream) - base_sha=$(git -C "$repo" merge-base upstream local) - before=$(git -C "$repo" show-ref) - - out=$(run_scoreboard "$repo" local upstream) - rerun=$(run_scoreboard "$repo" local upstream) - after=$(git -C "$repo" show-ref) - - expected=$(printf '%s\n' \ - 'schema: "fm-convergence-scoreboard.v1"' \ - 'local:' \ - ' ref: "local"' \ - " commit: \"$local_sha\"" \ - 'upstream:' \ - ' ref: "upstream"' \ - " commit: \"$upstream_sha\"" \ - 'commits:' \ - ' ahead: 2' \ - ' behind: 1' \ - ' first_parent_deliveries: 2' \ - 'diff:' \ - " base_commit: \"$base_sha\"" \ - ' changed_files: 6' \ - ' insertions: 6' \ - ' deletions: 2' \ - ' net_lines: 4' \ - 'file_groups[6]{name,changed_files,insertions,deletions,net_lines}:' \ - ' "agent-runtime",1,1,0,1' \ - ' "automation",1,1,1,0' \ - ' "tests",1,2,0,2' \ - ' "documentation",1,0,1,-1' \ - ' "configuration",1,1,0,1' \ - ' "other",1,1,0,1') - - [ "$out" = "$expected" ] || fail "scoreboard output differs from the deterministic TOON contract:$'\n'$out" - [ "$rerun" = "$out" ] || fail "identical refs produced different scoreboard output" - [ "$after" = "$before" ] || fail "scoreboard mutated repository refs" - [ -z "$(git -C "$repo" status --porcelain)" ] || fail "scoreboard dirtied the fixture worktree" - pass "scoreboard reports reproducible graph, delivery, diff, and file-group metrics without mutation" -} - -test_attributes_are_scoped_to_local_ref() { - local repo expected from_worktree from_info - repo=$TMP_ROOT/repo - - git -C "$repo" checkout -qb local-attributed local - printf 'misc.txt -diff\n' > "$repo/.gitattributes" - git -C "$repo" add .gitattributes - git -C "$repo" commit -qm "test: add local measurement attributes" - expected=$(run_scoreboard "$repo" local-attributed upstream) - assert_contains "$expected" ' changed_files: 7' \ - "the local commit attribute fixture should add one changed file" - assert_contains "$expected" ' "other",2,1,0,1' \ - "the local commit should classify its marked binary path with zero line changes" - - git -C "$repo" checkout -qb ambient-attributes upstream - printf '* -diff\n' > "$repo/.gitattributes" - git -C "$repo" add .gitattributes - git -C "$repo" commit -qm "test: add unrelated worktree attributes" - from_worktree=$(run_scoreboard "$repo" local-attributed upstream) - [ "$from_worktree" = "$expected" ] \ - || fail "ambient worktree attributes changed metrics for the same explicit refs" - - printf '* diff\n' > "$repo/.git/info/attributes" - from_info=$(run_scoreboard "$repo" local-attributed upstream) - [ "$from_info" = "$expected" ] \ - || fail "ambient info attributes changed metrics for the same explicit refs" - rm -f "$repo/.git/info/attributes" - git -C "$repo" checkout -q local - pass "scoreboard derives attributes from the explicit local ref, not ambient repository state" -} - -test_tab_prefixed_path_is_not_documentation() { - local repo tab_path out - repo=$TMP_ROOT/repo - tab_path=$'\tdocs/tab-prefixed.md' - - git -C "$repo" checkout -qb tab-path local - mkdir -p "$repo/${tab_path%/*}" - printf 'tab path\n' > "$repo/$tab_path" - git -C "$repo" add -- "$tab_path" - git -C "$repo" commit -qm "test: add tab-prefixed path" - - out=$(run_scoreboard "$repo" tab-path upstream) - assert_contains "$out" ' "documentation",1,0,1,-1' \ - "a leading tab must not turn a non-documentation path into documentation" - assert_contains "$out" ' "other",2,2,0,2' \ - "a tab-prefixed docs path should remain in the other group" - git -C "$repo" checkout -q local - pass "scoreboard preserves leading tabs when classifying changed paths" -} - -test_git_environment_is_allowlisted() { - local repo decoy expected out shim_dir real_git source_format ambient_format - local ambient_shallow ambient_graft - repo=$TMP_ROOT/repo - decoy=$TMP_ROOT/decoy - shim_dir=$TMP_ROOT/git-shim - ambient_shallow=$TMP_ROOT/ambient-shallow - ambient_graft=$TMP_ROOT/ambient-graft - real_git=$(command -v git) - source_format=$(git -C "$repo" rev-parse --show-object-format=storage) - case "$source_format" in - sha1) ambient_format=sha256 ;; - sha256) ambient_format=sha1 ;; - *) fail "unsupported fixture object format: $source_format" ;; - esac - - git init -q -b main "$decoy" - printf 'decoy\n' > "$decoy/README.md" - git -C "$decoy" add README.md - git -C "$decoy" commit -qm "test: seed decoy" - git -C "$repo" rev-parse local > "$ambient_shallow" - printf '%s %s\n' \ - "$(git -C "$repo" rev-parse local)" \ - "$(git -C "$repo" rev-parse upstream)" \ - > "$ambient_graft" - mkdir -p "$shim_dir" - { - printf '%s\n' '#!/usr/bin/env bash' - printf '%s\n' "[ \"\${GIT_OPTIONAL_LOCKS-}\" = 0 ] || exit 91" - printf '%s\n' "[ \"\${GIT_NO_LAZY_FETCH-}\" = 1 ] || exit 92" - printf '%s\n' "[ -z \"\${GIT_NAMESPACE-}\" ] || exit 93" - printf '%s\n' "[ -z \"\${SCOREBOARD_AMBIENT_SENTINEL-}\" ] || exit 94" - printf '%s\n' "[ -z \"\${REAL_GIT-}\" ] || exit 95" - printf "[ \"\${HOME-}\" = %q ] || exit 96\n" "${HOME-}" - printf 'exec %q "$@"\n' "$real_git" - } > "$shim_dir/git" - chmod +x "$shim_dir/git" - - expected=$(run_scoreboard "$repo" local upstream) - out=$( - cd "$repo" || exit 1 - PATH="$shim_dir:$PATH" \ - REAL_GIT="$real_git" \ - GIT_DIR="$decoy/.git" \ - GIT_WORK_TREE="$decoy" \ - GIT_CONFIG_PARAMETERS="'core.warnAmbiguousRefs'='false'" \ - GIT_SHALLOW_FILE="$ambient_shallow" \ - GIT_GRAFT_FILE="$ambient_graft" \ - GIT_DEFAULT_HASH="$ambient_format" \ - GIT_NAMESPACE=ambient \ - GIT_OPTIONAL_LOCKS=1 \ - GIT_NO_LAZY_FETCH=0 \ - SCOREBOARD_AMBIENT_SENTINEL=present \ - "$SCOREBOARD" local upstream - ) - - [ "$out" = "$expected" ] \ - || fail "ambient Git repository or mutation settings changed the explicit-ref measurement" - pass "scoreboard allowlists the complete Git execution environment" -} - -test_fail_closed_invocation() { - local repo out err rc - repo=$TMP_ROOT/repo - - set +e - out=$(run_scoreboard "$repo" 2>"$TMP_ROOT/no-args.err") - rc=$? - set -e - err=$(cat "$TMP_ROOT/no-args.err") - expect_code 2 "$rc" "missing refs" - assert_contains "$out" 'error: "expected exactly and "' \ - "missing refs should emit one structured usage error" - assert_contains "$out" 'usage: "bin/fm-convergence-scoreboard.sh "' \ - "usage failure should include the complete correction" - [ -z "$err" ] || fail "usage failure leaked diagnostics to stderr: $err" - - set +e - out=$(run_scoreboard "$repo" --unknown upstream 2>"$TMP_ROOT/unknown.err") - rc=$? - set -e - expect_code 2 "$rc" "unknown flag" - assert_contains "$out" 'error: "unknown flag or invalid local ref: --unknown"' \ - "unknown flag should be named" - - git -C "$repo" branch ambiguous local - git -C "$repo" tag ambiguous local - set +e - out=$(run_scoreboard "$repo" ambiguous upstream 2>"$TMP_ROOT/ambiguous.err") - rc=$? - set -e - err=$(cat "$TMP_ROOT/ambiguous.err") - expect_code 1 "$rc" "ambiguous local ref" - assert_contains "$out" 'error: "local ref name is ambiguous: ambiguous"' \ - "ambiguous local ref should fail instead of following Git ref precedence" - assert_contains "$out" 'Use a fully qualified ref such as refs/heads/' \ - "ambiguous local ref should provide the deterministic correction" - [ -z "$err" ] || fail "ambiguous ref failure leaked diagnostics to stderr: $err" - set +e - out=$( - cd "$repo" || exit 1 - GIT_CONFIG_PARAMETERS="'core.warnAmbiguousRefs'='false'" \ - "$SCOREBOARD" ambiguous upstream - ) - rc=$? - set -e - expect_code 1 "$rc" "ambiguous local ref with warnings disabled" - assert_contains "$out" 'error: "local ref name is ambiguous: ambiguous"' \ - "ambient warning configuration must not suppress ambiguity rejection" - run_scoreboard "$repo" refs/heads/ambiguous upstream >/dev/null \ - || fail "fully qualified local ref did not resolve the reported ambiguity" - - git -C "$repo" branch ambiguous-upstream upstream - git -C "$repo" tag ambiguous-upstream upstream - set +e - out=$(run_scoreboard "$repo" local ambiguous-upstream) - rc=$? - set -e - expect_code 1 "$rc" "ambiguous upstream ref" - assert_contains "$out" 'error: "upstream ref name is ambiguous: ambiguous-upstream"' \ - "ambiguous upstream ref should fail instead of following Git ref precedence" - - set +e - out=$(run_scoreboard "$repo" missing upstream 2>"$TMP_ROOT/missing.err") - rc=$? - set -e - expect_code 1 "$rc" "missing local ref" - assert_contains "$out" 'local ref is missing, unfetched, or does not resolve to one commit: missing' \ - "missing local ref should be actionable" - assert_contains "$out" 'Fetch or create the local ref explicitly' \ - "missing local ref should provide the correction" - - printf 'dirty\n' > "$repo/untracked.txt" - set +e - out=$(run_scoreboard "$repo" local upstream 2>"$TMP_ROOT/dirty.err") - rc=$? - set -e - expect_code 1 "$rc" "dirty worktree" - assert_contains "$out" 'error: "current worktree is dirty"' \ - "dirty invocation should fail before measuring refs" - rm "$repo/untracked.txt" - pass "scoreboard rejects missing, unknown, unfetched, and dirty invocation state" -} - -test_diff_failure_is_not_silent() { - local repo blob object_path backup out rc - repo=$TMP_ROOT/repo - blob=$(git -C "$repo" rev-parse upstream:docs/guide.md) - object_path="$repo/.git/objects/${blob:0:2}/${blob:2}" - backup="$TMP_ROOT/missing-blob.backup" - [ -f "$object_path" ] || fail "diff failure fixture blob is not loose" - mv "$object_path" "$backup" - - set +e - out=$(run_scoreboard "$repo" local upstream) - rc=$? - set -e - - mv "$backup" "$object_path" - expect_code 1 "$rc" "unavailable diff object" - assert_contains "$out" 'error: "cannot calculate diff metrics between the resolved refs"' \ - "a failed diff must not produce plausible zero metrics" - assert_contains "$out" 'Verify that all repository objects are available locally' \ - "a failed diff should provide the deterministic correction" - pass "scoreboard fails loudly when resolved commits cannot produce diff metrics" -} - -test_toon_documents_have_no_trailing_newline() { - local repo rc - repo=$TMP_ROOT/repo - - run_scoreboard "$repo" local upstream > "$TMP_ROOT/success.toon" - assert_no_trailing_newline "$TMP_ROOT/success.toon" "success output" - - run_scoreboard "$repo" --help > "$TMP_ROOT/help.toon" - assert_no_trailing_newline "$TMP_ROOT/help.toon" "help output" - - set +e - run_scoreboard "$repo" > "$TMP_ROOT/usage-error.toon" - rc=$? - set -e - expect_code 2 "$rc" "usage output" - assert_no_trailing_newline "$TMP_ROOT/usage-error.toon" "usage error output" - - set +e - run_scoreboard "$repo" missing upstream > "$TMP_ROOT/measure-error.toon" - rc=$? - set -e - expect_code 1 "$rc" "measurement error output" - assert_no_trailing_newline "$TMP_ROOT/measure-error.toon" "measurement error output" - pass "scoreboard emits every TOON document without a trailing newline" -} - -test_help_without_home() { - local out err rc - - set +e - out=$(env -u HOME "$SCOREBOARD" --help 2>"$TMP_ROOT/help-unset-home.err") - rc=$? - set -e - err=$(cat "$TMP_ROOT/help-unset-home.err") - - expect_code 0 "$rc" "help without HOME" - assert_contains "$out" "bin: \"$SCOREBOARD\"" \ - "help without HOME should report the absolute executable path" - [ -z "$err" ] || fail "help without HOME leaked diagnostics to stderr: $err" - pass "scoreboard help is deterministic when HOME is unset" -} - -test_toon_control_characters_are_escaped() { - local repo control_ref out rc - repo=$TMP_ROOT/repo - control_ref=$'missing\001\b\f\033\177ref' - - set +e - out=$(run_scoreboard "$repo" "$control_ref" upstream) - rc=$? - set -e - - expect_code 1 "$rc" "control characters in a missing ref" - assert_contains "$out" 'missing\u0001\u0008\u000c\u001b\u007fref' \ - "structured errors should escape non-short-form TOON control characters" - [[ "$out" != *$'\001'* && "$out" != *$'\b'* && "$out" != *$'\f'* \ - && "$out" != *$'\033'* && "$out" != *$'\177'* ]] \ - || fail "structured error output retained a raw control character" - pass "scoreboard escapes every unsupported control character in TOON strings" -} - -test_deterministic_scoreboard -test_attributes_are_scoped_to_local_ref -test_tab_prefixed_path_is_not_documentation -test_git_environment_is_allowlisted -test_fail_closed_invocation -test_diff_failure_is_not_silent -test_toon_documents_have_no_trailing_newline -test_help_without_home -test_toon_control_characters_are_escaped diff --git a/tests/fm-decision-board.test.sh b/tests/fm-decision-board.test.sh deleted file mode 100755 index 37f1365b4e7..00000000000 --- a/tests/fm-decision-board.test.sh +++ /dev/null @@ -1,326 +0,0 @@ -#!/usr/bin/env bash -# Behavior tests for the captain decision board renderer over the canonical -# fleet snapshot. Covers source selection (captain-owned open items only), the -# one-entry-per-id merge of a durable queue entry with a parked worker, the -# ready/gated split including the parked override, longest-waiting ordering, -# bounding with disclosure, all three output formats, HTML escaping of backlog -# text, captain-facing path presentation, and the difference between an empty -# board and an unreadable source. -# -# Every assertion runs through the script's own interface with a fixture -# snapshot supplied via --snapshot, which is the documented public contract -# boundary; no test reads the implementation source. -set -u - -# shellcheck source=tests/lib.sh -# shellcheck disable=SC1091 -. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" - -BOARD="$ROOT/bin/fm-decision-board.sh" -TMP_ROOT=$(fm_test_tmproot fm-decision-board) -mkdir -p "$TMP_ROOT" - -command -v jq >/dev/null 2>&1 || { echo "skip: jq not found"; exit 0; } - -export FM_DECISION_BOARD_NOW=2026-07-30T09:00:00Z - -# A fixture in the fm-fleet-snapshot.v1 shape, carrying every case the board -# must distinguish: -# old-ask captain-owned, unblocked, longest waiting -# new-ask captain-owned, unblocked, newest -# gated-ask captain-owned but waiting on unfinished work -# both-ask captain-owned AND a worker parked on it despite a blocker -# parked-only a parked worker with no captain-owned queue entry -# blocker-only a firstmate-owned blocked event, never a captain decision -# external a non-captain hold, must never reach the board -# settled a captain-owned entry already done, must never reach the board -# markup backlog text containing HTML, for escaping -write_snapshot() { # - cat > "$1" <<'JSON' -{ - "schema": "fm-fleet-snapshot.v1", - "fm_home": "/home/fixture/firstmate", - "generated": "2026-07-30T09:00:00Z", - "backlog": { - "path": "/home/fixture/firstmate/data/backlog.md", - "present": true, - "records": [ - { - "id": "old-ask", "structured": true, "state": "queued", "kind": "captain", - "title": "Approve the drive prune", "repo": "machine", - "hold_kind": "captain", "hold_reason": "the plan is ready and needs your word", - "since": "2026-07-10", "unresolved_blocker_ids": [] - }, - { - "id": "new-ask", "structured": true, "state": "queued", "kind": "captain", - "title": "Pick the booking stack", "repo": "/home/fixture/ventures/phone-assistant", - "hold_kind": "captain", "hold_reason": "two vendors, one choice", - "since": "2026-07-29", "unresolved_blocker_ids": [] - }, - { - "id": "gated-ask", "structured": true, "state": "queued", "kind": "captain", - "title": "Arm the cadence", "repo": "cartoon-longform", - "hold_kind": "captain", "hold_reason": "cadence arms after the quality bar clears", - "since": "2026-07-20", "unresolved_blocker_ids": ["quality-program"] - }, - { - "id": "both-ask", "structured": true, "state": "in_flight", "kind": "ship", - "title": "Rethink the media model", "repo": "x-bookmark-poster", - "hold_kind": "captain", "hold_reason": "the data model needs a rethink, not another patch", - "since": "2026-07-22", "unresolved_blocker_ids": ["feed-model"] - }, - { - "id": "markup", "structured": true, "state": "queued", "kind": "captain", - "title": "Publish copy", "repo": "portfolio-site", - "hold_kind": "captain", "hold_reason": "wording uses markup & entities", - "since": "2026-07-28", "unresolved_blocker_ids": [] - }, - { - "id": "external", "structured": true, "state": "queued", "kind": "ship", - "title": "Wait on the upstream release", "repo": "machine", - "hold_kind": "external", "hold_reason": "upstream has not shipped", - "since": "2026-07-01", "unresolved_blocker_ids": [] - }, - { - "id": "settled", "structured": true, "state": "done", "kind": "captain", - "title": "Already answered", "repo": "machine", - "hold_kind": "captain", "hold_reason": "he answered this one", - "since": "2026-07-02", "unresolved_blocker_ids": [] - } - ] - }, - "tasks": [ - { - "id": "both-ask", - "project": "/home/fixture/ventures/x-bookmark-poster", - "backlog": {"title": "Rethink the media model", "repo": "x-bookmark-poster", "since": "2026-07-22"}, - "hints": { - "pending_decision": true, - "open_decisions": [{"key": "default", "verb": "needs-decision", - "summary": "the third review round repeats the same family of findings"}, - {"key": "upstream", "verb": "blocked", - "summary": "firstmate must repair the upstream dependency"}] - } - }, - { - "id": "parked-only", - "project": "/home/fixture/ventures/polymarket-platform-dev", - "backlog": null, - "hints": { - "pending_decision": true, - "open_decisions": [{"key": "default", "verb": "needs-decision", - "summary": "a lost confirm can leave admission released"}] - } - }, - { - "id": "blocker-only", - "project": "/home/fixture/ventures/blocked", - "backlog": null, - "hints": { - "pending_decision": false, - "open_decisions": [{"key": "default", "verb": "blocked", - "summary": "firstmate owns this blocked task"}] - } - }, - { - "id": "quiet-worker", - "project": "/home/fixture/ventures/quiet", - "backlog": null, - "hints": {"pending_decision": false, "open_decisions": []} - } - ] -} -JSON -} - -SNAP="$TMP_ROOT/snapshot.json" -write_snapshot "$SNAP" - -# --- source selection ------------------------------------------------------- - -out=$("$BOARD" --snapshot "$SNAP" --limit 0) -assert_contains "$out" "old-ask" "an open captain-owned entry must reach the board" -assert_not_contains "$out" "external" "a non-captain hold must never reach the board" -assert_not_contains "$out" "settled" "an already-answered entry must never reach the board" -assert_not_contains "$out" "quiet-worker" "a worker with no open decision must not reach the board" -assert_not_contains "$out" "blocker-only" "a firstmate-owned blocker must not reach the board" -assert_not_contains "$out" "firstmate must repair" \ - "a firstmate-owned blocker must not join a captain decision summary" -pass "the board carries only open captain-owned items" - -# --- one entry per id ------------------------------------------------------- - -TICK='`' # the rendered id delimiter, kept in a variable so patterns stay literal -count=$(printf '%s\n' "$out" | grep -c -F "${TICK}both-ask${TICK}") -[ "$count" = 1 ] || fail "an id in both sources must render exactly one entry (got $count)" -assert_contains "$out" "the data model needs a rethink" "the durable wording must survive the merge" -assert_contains "$out" "the third review round repeats" "the parked worker's note must survive the merge" -pass "an id in both sources becomes one entry carrying both facts" - -# --- ready versus waiting-on-other-work ------------------------------------- - -ready_block=${out%%### Waiting on other work first*} -gated_block=${out#*### Waiting on other work first} -assert_contains "$ready_block" "old-ask" "an unblocked captain-owned entry needs only his answer" -assert_contains "$gated_block" "gated-ask" "an entry waiting on unfinished work must be separated" -assert_not_contains "$ready_block" "gated-ask" "a blocked entry must not claim to need only his answer" -assert_contains "$ready_block" "both-ask" "a parked worker needs his answer even with a dependency" -assert_contains "$ready_block" "parked-only" "a parked worker with no queue entry must still reach him" -assert_contains "$out" "quality-program" "a remaining dependency must still be named" -pass "the ready split honors dependencies and the parked override" - -# --- displayed numbering --------------------------------------------------- - -numbers=$(printf '%s\n' "$out" | sed -n 's/^\([0-9][0-9]*\)\. \*\*.*/\1/p') -number_count=$(printf '%s\n' "$numbers" | sed '/^$/d' | wc -l | tr -d ' ') -unique_number_count=$(printf '%s\n' "$numbers" | sed '/^$/d' | sort -nu | wc -l | tr -d ' ') -first_gated_number=$(printf '%s\n' "$gated_block" | sed -n 's/^\([0-9][0-9]*\)\. \*\*.*/\1/p' | head -1) -[ "$first_gated_number" = 6 ] \ - || fail "the gated section must continue after the five ready entries (got $first_gated_number)" -[ "$number_count" = "$unique_number_count" ] \ - || fail "displayed numbers must not repeat across the whole board" -pass "markdown numbering continues across both sections" - -# --- ordering --------------------------------------------------------------- - -order=$(printf '%s\n' "$ready_block" | grep -o -E "${TICK}[a-z-]+${TICK}" | tr -d "$TICK") -first=$(printf '%s\n' "$order" | head -1) -[ "$first" = old-ask ] || fail "the longest-waiting entry must sort first (got $first)" -last=$(printf '%s\n' "$order" | tail -1) -[ "$last" != old-ask ] || fail "ordering must place the newest ask after the longest-waiting one" -assert_contains "$out" "waiting 20 days" "elapsed waiting time must be reported" -pass "entries sort longest-waiting first" - -# --- captain-facing presentation -------------------------------------------- - -assert_not_contains "$out" "/home/fixture/ventures" "a raw path must not reach a captain-facing surface" -assert_contains "$out" "phone-assistant" "the project must still be named" -assert_not_contains "$out" "hold_kind" "internal field names must not reach a captain-facing surface" -pass "presentation stays captain-facing" - -# --- bounding with disclosure ---------------------------------------------- - -bounded=$("$BOARD" --snapshot "$SNAP" --limit 2) -assert_contains "$bounded" "not shown" "a bounded board must disclose what it dropped" -assert_contains "$bounded" "--limit 0" "the disclosure must name how to reveal the rest" -shown=$(printf '%s\n' "$bounded" | grep -c -E '^[0-9]+\. \*\*') -[ "$shown" -le 4 ] || fail "--limit 2 must cap each group at two entries (got $shown)" -assert_contains "$bounded" "Needs your answer (5)" "the true open count must survive bounding" -pass "bounding caps entries while disclosing the remainder" - -# --- json format ------------------------------------------------------------ - -model=$("$BOARD" --snapshot "$SNAP" --format json --limit 0) -schema=$(printf '%s' "$model" | jq -r '.schema') -[ "$schema" = fm-decision-board.v1 ] || fail "json must carry the board schema (got $schema)" -totals=$(printf '%s' "$model" | jq -r '[.counts.total, .counts.ready, .counts.gated, .counts.parked] | join(",")') -[ "$totals" = "6,5,1,2" ] || fail "json counts must match the fixture (got $totals)" -blocked_json=$(printf '%s' "$model" | jq -r ' - [.ready[], .gated[]] - | map(select(.id == "blocker-only" or (.question // "" | contains("firstmate must repair")))) - | length -') -[ "$blocked_json" = 0 ] || fail "json must exclude firstmate-owned blockers (got $blocked_json)" -merged_sources=$(printf '%s' "$model" | jq -r '[.ready[] | select(.id == "both-ask") | .sources[]] | sort | join(",")') -[ "$merged_sources" = "parked,queue" ] || fail "a merged entry must record both sources (got $merged_sources)" -pass "the json model exposes the same facts as the rendered board" - -# --- html format ------------------------------------------------------------ - -html=$("$BOARD" --snapshot "$SNAP" --format html --limit 0) -assert_contains "$html" "" "html must be a standalone page" -assert_contains "$html" "<script>" "backlog markup must be escaped" -assert_not_contains "$html" "" "backlog markup must never render as live markup" -assert_not_contains "$html" "http://" "the page must not reference an external host" -assert_not_contains "$html" "https://" "the page must not reference an external host" -assert_not_contains "$html" "blocker-only" "html must exclude a firstmate-owned blocker" -assert_not_contains "$html" "firstmate must repair" \ - "html must exclude a firstmate-owned blocker from captain decision summaries" -html_numbers=$(printf '%s\n' "$html" | sed -n 's/.*\([0-9][0-9]*\)<\/span>.*/\1/p') -html_number_count=$(printf '%s\n' "$html_numbers" | sed '/^$/d' | wc -l | tr -d ' ') -html_unique_number_count=$(printf '%s\n' "$html_numbers" | sed '/^$/d' | sort -nu | wc -l | tr -d ' ') -html_gated=${html#*

    Waiting on other work first} -html_first_gated_number=$(printf '%s\n' "$html_gated" \ - | sed -n 's/.*\([0-9][0-9]*\)<\/span>.*/\1/p' | head -1) -[ "$html_first_gated_number" = 6 ] \ - || fail "the HTML gated section must continue after the five ready entries (got $html_first_gated_number)" -[ "$html_number_count" = "$html_unique_number_count" ] \ - || fail "HTML numbers must not repeat across the whole board" -pass "the html surface is standalone and escapes backlog text" - -# --- malformed waiting dates ----------------------------------------------- - -malformed="$TMP_ROOT/malformed-date.json" -jq ' - (.backlog.records[] | select(.id == "old-ask") | .since) = "not-a-date" - | (.backlog.records[] | select(.id == "gated-ask") | .since) = "2026-02-31" -' "$SNAP" > "$malformed" -set +e -malformed_out=$("$BOARD" --snapshot "$malformed" --limit 0 2>&1) -malformed_rc=$? -set -e -expect_code 0 "$malformed_rc" "malformed waiting dates must not abort the board" -for id in old-ask new-ask gated-ask both-ask parked-only markup; do - assert_contains "$malformed_out" "$id" "a malformed waiting date must not drop $id" -done -assert_contains "$malformed_out" "Could not read waiting date for old-ask: not-a-date." \ - "a malformed date prefix must surface an error in its section" -assert_contains "$malformed_out" "Could not read waiting date for gated-ask: 2026-02-31." \ - "an impossible calendar date must surface an error in its section" -assert_contains "$malformed_out" "waiting 1 day" \ - "an impossible calendar date must not stop valid waiting ages from rendering" -malformed_model=$("$BOARD" --snapshot "$malformed" --format json --limit 0) -invalid_waiting=$(printf '%s' "$malformed_model" | jq -r ' - .gated[] | select(.id == "gated-ask") - | [.waiting_days, .waiting_error] | @tsv -') -expected_invalid_waiting=$'\tCould not read waiting date for gated-ask: 2026-02-31.' -[ "$invalid_waiting" = "$expected_invalid_waiting" ] \ - || fail "an impossible calendar date must not fabricate a waiting age" -pass "malformed waiting dates stay visible without disrupting the board" - -# --- stdin snapshot --------------------------------------------------------- - -piped=$("$BOARD" --snapshot - --limit 0 < "$SNAP") -[ "$piped" = "$out" ] || fail "a snapshot read from standard input must render identically" -pass "the board reads a snapshot from standard input" - -# --- empty board versus unreadable source ----------------------------------- - -empty="$TMP_ROOT/empty.json" -jq '.backlog.records = [] | .tasks = []' "$SNAP" > "$empty" -set +e -empty_out=$("$BOARD" --snapshot "$empty" 2>&1) -empty_rc=$? -set -e -expect_code 0 "$empty_rc" "an empty board is a success" -assert_contains "$empty_out" "Nothing is waiting on you" "an empty board must say so plainly" - -missing="$TMP_ROOT/missing.json" -jq '.backlog.present = false | .backlog.records = []' "$SNAP" > "$missing" -set +e -missing_out=$("$BOARD" --snapshot "$missing" 2>&1) -missing_rc=$? -set -e -expect_code 3 "$missing_rc" "an unreadable decision queue must refuse rather than print an empty board" -assert_not_contains "$missing_out" "Nothing is waiting on you" \ - "an unreadable queue must never read as an empty board" -pass "an empty board is distinguishable from an unreadable source" - -# --- argument validation ---------------------------------------------------- - -set +e -"$BOARD" --snapshot "$SNAP" --format toon >/dev/null 2>&1 -bad_format=$? -"$BOARD" --snapshot "$SNAP" --limit two >/dev/null 2>&1 -bad_limit=$? -"$BOARD" --snapshot "$TMP_ROOT/does-not-exist.json" >/dev/null 2>&1 -bad_path=$? -"$BOARD" --help >/dev/null 2>&1 -help_rc=$? -set -e -[ "$bad_format" -ne 0 ] || fail "an unsupported format must be refused" -[ "$bad_limit" -ne 0 ] || fail "a non-numeric limit must be refused" -[ "$bad_path" -ne 0 ] || fail "an unreadable snapshot path must be refused" -expect_code 0 "$help_rc" "--help must succeed" -pass "invalid arguments are refused" diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index 335e265b2e8..2cfc93539a6 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -89,15 +89,10 @@ run_spawn() { local home=$1 wt=$2 fakebin=$3 launchlog=$4 shift 4 : > "$launchlog" - # CLAUDE_CONFIG_DIR is forwarded onto claude launches by fm-spawn, so pin it - # explicitly (empty by default) instead of leaking the invoking shell's value, - # which would make launch assertions depend on the developer's environment. - # A test opts in to the set case via FM_TEST_CLAUDE_CONFIG_DIR. FM_ROOT_OVERRIDE='' FM_HOME="$home" \ FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \ FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$wt" TMUX="fake,1,0" \ - CLAUDE_CONFIG_DIR="${FM_TEST_CLAUDE_CONFIG_DIR:-}" \ FM_FAKE_LAUNCH_LOG="$launchlog" GROK_HOME="$home/grok-home" PATH="$fakebin:$PATH" \ "$SPAWN" "$@" 2>&1 } @@ -133,6 +128,29 @@ test_no_profile_keeps_claude_profile_defaults() { pass "no --model/--effort records defaults and types the claude launch instructions" } +test_claude_keeps_selected_identity_without_cloning_invoker_config() { + local rec id out status launch expected + id=profile-claude-identity-z1a + rec=$(make_spawn_case profile-claude-identity claude "$id") + read_case_record "$rec" + + out=$(CLAUDE_CONFIG_DIR="/opt/test/claude-work" \ + run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR") + status=$? + expect_code 0 "$status" "claude spawn with an invoker-specific config directory should succeed" + assert_contains "$out" "spawned $id harness=claude" \ + "claude spawn did not preserve the selected harness identity" + assert_meta_profile "$HOME_DIR/state/$id.meta" claude default default + + launch=$(cat "$LAUNCH_LOG") + expected="CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions \"\$('${ROOT}/bin/fm-operational-input.sh' encode launch-brief < '$HOME_DIR/data/$id/brief.md')\"" + [ "$launch" = "$expected" ] \ + || fail "claude launch cloned invoker config or changed the canonical autonomous launch"$'\n'"expected: $expected"$'\n'"actual: $launch" + assert_not_contains "$launch" "CLAUDE_CONFIG_DIR=" \ + "claude launch must not clone the invoker's credential/config store into the worker" + pass "claude preserves selected identity and autonomy without cloning invoker credentials or config" +} + test_relative_home_overrides_launch_with_absolute_cross_process_paths() { local rec id out status launch home_real id=profile-relative-paths-z1b @@ -148,7 +166,7 @@ test_relative_home_overrides_launch_with_absolute_cross_process_paths() { FM_STATE_OVERRIDE=home/state FM_DATA_OVERRIDE=home/data \ FM_PROJECTS_OVERRIDE=home/projects FM_CONFIG_OVERRIDE=home/config \ FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$WT_DIR" TMUX="fake,1,0" \ - CLAUDE_CONFIG_DIR='' FM_FAKE_LAUNCH_LOG="$LAUNCH_LOG" \ + FM_FAKE_LAUNCH_LOG="$LAUNCH_LOG" \ GROK_HOME=home/grok-home PATH="$FAKEBIN_DIR:$PATH" \ "$SPAWN" "$id" "$PROJ_DIR" 2>&1 ) @@ -177,7 +195,7 @@ test_home_defaults_preserve_absolute_or_resolve_relative_paths() { FM_STATE_OVERRIDE='' FM_DATA_OVERRIDE='' \ FM_PROJECTS_OVERRIDE=home/projects FM_CONFIG_OVERRIDE=home/config \ FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$WT_DIR" TMUX="fake,1,0" \ - CLAUDE_CONFIG_DIR='' FM_FAKE_LAUNCH_LOG="$LAUNCH_LOG" \ + FM_FAKE_LAUNCH_LOG="$LAUNCH_LOG" \ GROK_HOME=home/grok-home PATH="$FAKEBIN_DIR:$PATH" \ "$SPAWN" "$relative_id" "$PROJ_DIR" 2>&1 ) @@ -197,7 +215,7 @@ test_home_defaults_preserve_absolute_or_resolve_relative_paths() { FM_STATE_OVERRIDE='' FM_DATA_OVERRIDE='' \ FM_PROJECTS_OVERRIDE="$linked_home/projects" FM_CONFIG_OVERRIDE="$linked_home/config" \ FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$WT_DIR" TMUX="fake,1,0" \ - CLAUDE_CONFIG_DIR='' FM_FAKE_LAUNCH_LOG="$LAUNCH_LOG" \ + FM_FAKE_LAUNCH_LOG="$LAUNCH_LOG" \ GROK_HOME="$linked_home/grok-home" PATH="$FAKEBIN_DIR:$PATH" \ "$SPAWN" "$absolute_id" "$PROJ_DIR" 2>&1 ) @@ -225,7 +243,7 @@ test_absolute_override_spelling_is_preserved_in_launch_paths() { FM_STATE_OVERRIDE="$linked_home/state" FM_DATA_OVERRIDE="$linked_home/data" \ FM_PROJECTS_OVERRIDE="$linked_home/projects" FM_CONFIG_OVERRIDE="$linked_home/config" \ FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$WT_DIR" TMUX="fake,1,0" \ - CLAUDE_CONFIG_DIR='' FM_FAKE_LAUNCH_LOG="$LAUNCH_LOG" \ + FM_FAKE_LAUNCH_LOG="$LAUNCH_LOG" \ GROK_HOME="$linked_home/grok-home" PATH="$FAKEBIN_DIR:$PATH" \ "$SPAWN" "$id" "$PROJ_DIR" 2>&1 ) @@ -605,55 +623,6 @@ test_batch_forwards_shared_profile_flags() { pass "batch dispatch forwards shared --harness, --model, and --effort to every pair" } -test_claude_forwards_firstmate_config_dir_when_set() { - local rec id out status launch - id=profile-claude-cfgdir-z17 - rec=$(make_spawn_case profile-claude-cfgdir claude "$id") - read_case_record "$rec" - - out=$(FM_TEST_CLAUDE_CONFIG_DIR="/opt/test/claude-work" \ - run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR") - status=$? - expect_code 0 "$status" "claude spawn with CLAUDE_CONFIG_DIR set should succeed" - launch=$(cat "$LAUNCH_LOG") - assert_contains "$launch" "CLAUDE_CONFIG_DIR='/opt/test/claude-work' CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude" \ - "claude launch did not forward firstmate's CLAUDE_CONFIG_DIR to the crewmate pane" - pass "claude forwards firstmate's CLAUDE_CONFIG_DIR so the crewmate uses the same credential store" -} - -test_claude_omits_config_dir_prefix_when_unset() { - local rec id out status launch - id=profile-claude-nocfgdir-z18 - rec=$(make_spawn_case profile-claude-nocfgdir claude "$id") - read_case_record "$rec" - - # run_spawn pins CLAUDE_CONFIG_DIR empty by default, exercising the single-store - # default path where fm-spawn adds no prefix. - out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR") - status=$? - expect_code 0 "$status" "claude spawn without CLAUDE_CONFIG_DIR should succeed" - launch=$(cat "$LAUNCH_LOG") - assert_not_contains "$launch" "CLAUDE_CONFIG_DIR=" \ - "claude launch must not add a config-dir prefix when firstmate has no CLAUDE_CONFIG_DIR set" - pass "claude omits the config-dir prefix when firstmate runs with the single-store default" -} - -test_non_claude_harness_ignores_config_dir() { - local rec id out status launch - id=profile-codex-nocfgdir-z19 - rec=$(make_spawn_case profile-codex-nocfgdir codex "$id") - read_case_record "$rec" - - out=$(FM_TEST_CLAUDE_CONFIG_DIR="/opt/test/claude-work" \ - run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR") - status=$? - expect_code 0 "$status" "codex spawn with CLAUDE_CONFIG_DIR set should succeed" - launch=$(cat "$LAUNCH_LOG") - assert_not_contains "$launch" "CLAUDE_CONFIG_DIR=" \ - "non-claude harness launch must not receive the claude-specific config-dir prefix" - pass "non-claude harnesses do not receive the claude CLAUDE_CONFIG_DIR prefix" -} - test_active_dispatch_profile_does_not_block_secondmate_launch() { local rec id sm out status id=profile-secondmate-z16 @@ -673,6 +642,7 @@ test_active_dispatch_profile_does_not_block_secondmate_launch() { } test_no_profile_keeps_claude_profile_defaults +test_claude_keeps_selected_identity_without_cloning_invoker_config test_relative_home_overrides_launch_with_absolute_cross_process_paths test_home_defaults_preserve_absolute_or_resolve_relative_paths test_absolute_override_spelling_is_preserved_in_launch_paths @@ -694,9 +664,6 @@ 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_claude_forwards_firstmate_config_dir_when_set -test_claude_omits_config_dir_prefix_when_unset -test_non_claude_harness_ignores_config_dir test_active_dispatch_profile_does_not_block_secondmate_launch echo "# all fm-spawn-dispatch-profile tests passed" From 3282231834fa4f626b7628880a075d61f74a0f14 Mon Sep 17 00:00:00 2001 From: quinnbot-ai Date: Mon, 3 Aug 2026 09:50:03 -0700 Subject: [PATCH 34/70] docs: repair code and test comments citing renamed upstream doc sections (#70) * fix: restore stock macOS Bash 3.2 brief scaffolding (#1093) * fix(brief): make DOD scaffolding parse-safe on stock macOS Bash 3.2 fm-brief.sh built each Definition-of-done block and the not-enabled Herdr declaration with `VAR=$(cat < * test: replace source assertions with behavioral coverage (#1282) * test: remove source-content assertions * no-mistakes(review): Replace source assertions with runtime behavior coverage * no-mistakes(review): Isolate Kimi task temp runtime coverage * no-mistakes(document): Refresh test cleanup documentation * no-mistakes: apply CI fixes * docs: repair drifted section pointers * no-mistakes(review): Captain, align stale documentation section pointers * no-mistakes(test): Add proposal and restore comment-only pointer updates * fix(test): restore lint test bindings after rebase --------- Co-authored-by: Christopher McKay <101884182+karotkriss@users.noreply.github.com> Co-authored-by: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Co-authored-by: lhalbert Co-authored-by: deeto15 <92119640+deeto15@users.noreply.github.com> Co-authored-by: QuinnBot --- .agents/skills/bootstrap-diagnostics/SKILL.md | 10 +--- bin/backends/cmux.sh | 7 ++- bin/backends/herdr.sh | 54 +++++++++--------- bin/backends/tmux.sh | 2 +- bin/backends/zellij.sh | 16 +++--- bin/fm-afk-launch.sh | 2 +- bin/fm-afk-start.sh | 4 +- bin/fm-backend-hometag-lib.sh | 4 +- bin/fm-backend.sh | 13 +++-- bin/fm-spawn.sh | 18 ++++-- tests/fm-afk-inject-herdr-e2e.test.sh | 4 +- tests/fm-backend-cmux-smoke.test.sh | 4 +- .../fm-backend-herdr-prune-safety-e2e.test.sh | 2 +- .../fm-backend-herdr-respawn-idem-e2e.test.sh | 7 +-- tests/fm-backend-herdr-smoke.test.sh | 24 ++++---- ...ckend-herdr-workspace-per-home-e2e.test.sh | 6 +- tests/fm-backend-herdr.test.sh | 6 +- tests/fm-backend-zellij.test.sh | 4 +- tests/fm-backend.test.sh | 6 +- tests/fm-crew-state.test.sh | 2 +- tests/fm-lint.test.sh | 53 +++++++++++++++++ tests/fm-spawn-dispatch-profile.test.sh | 57 +++++++++++++++++++ 22 files changed, 211 insertions(+), 94 deletions(-) diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index eca0755283d..ec5b4330128 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -2,7 +2,7 @@ name: bootstrap-diagnostics description: >- Agent-only handling playbook for session-start bootstrap diagnostics. - Use whenever the session-start digest's bootstrap section prints an actionable diagnostic line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, TANGLE, STARTUP_MEMORY_BUDGET, CREW_DISPATCH invalid, FLEET_SYNC, PR_CHECK_MIGRATION, SECONDMATE_SYNC, SECONDMATE_LIVENESS, NUDGE_SECONDMATES, FMX, or OPS_INBOX - or when a standalone bin/fm-bootstrap.sh run prints one of those lines. + Use whenever the session-start digest's bootstrap section prints an actionable diagnostic line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, TANGLE, STARTUP_MEMORY_BUDGET, CREW_DISPATCH invalid, FLEET_SYNC, PR_CHECK_MIGRATION, SECONDMATE_SYNC, SECONDMATE_LIVENESS, NUDGE_SECONDMATES, or FMX - or when a standalone bin/fm-bootstrap.sh run prints one of those lines. A silent bootstrap section, or a BOOTSTRAP_INFO fact, means no skill load. user-invocable: false metadata: @@ -19,7 +19,8 @@ When any diagnostic needs captain attention, report the plain consequence and re - `MISSING: (install: )` - list the missing tools to the captain with a one-line purpose each plus the printed install commands, wait for consent (one approval may cover the list), then run `bin/fm-bootstrap.sh install `. For `treehouse`, this also covers an installed version whose `treehouse get` lacks `--lease`; treat it as an upgrade request. For `no-mistakes`, this also covers an installed version older than 1.31.2, because crewmate validation briefs delegate gate mechanics to no-mistakes' version-matched guidance. - For `tasks-axi`, this also covers an installed build that fails the compatibility probe (`docs/configuration.md` "Backlog backend" owns the definition); `config/backlog-backend=manual` only suppresses the verbose `BOOTSTRAP_INFO: tasks-axi available` fact, not this missing-tool report. + For `tasks-axi`, this also covers an installed build that fails the compatibility probe (`docs/configuration.md` "Backlog backend (.tasks.toml / config/backlog-backend)" owns the definition); `config/backlog-backend=manual` only suppresses the verbose `BOOTSTRAP_INFO: tasks-axi available` fact, not this missing-tool report. + For `quota-axi`, bootstrap requires it because firstmate reads its current output directly before resolving every crew-dispatch profile array; without it, report the missing requirement and do not choose around an unexamined candidate. - `MISSING_MANUAL: (instructions: )` - tell the captain why the tool is required and give them the printed instructions URL, but do not pass the tool to `bin/fm-bootstrap.sh install`; wait for the captain to complete the manual installation, then rerun session start to confirm the dependency is present. - `BACKEND_INVALID: (known: )` - the resolved runtime backend has no verified dependency or lifecycle contract, so do not dispatch work until the invalid `FM_BACKEND` or `config/backend` value is corrected to one of the listed backends. - `NEEDS_GH_AUTH` - ask the captain to run `! gh auth login` (interactive; you cannot run it for them). @@ -50,8 +51,3 @@ When any diagnostic needs captain attention, report the plain consequence and re Inspect the reason, keep the pending marker under `state/.secondmate-nudge-pending/` intact, and rerun session start after the endpoint or metadata issue is fixed so bootstrap can retry the exact same marked send. - `FMX: X mode on ...` / `FMX: X mode off ...` - bootstrap confirmed or removed the local X-mode poll artifacts (`docs/configuration.md` "X mode (.env)"). Only when a running watcher needs the cadence transition applied immediately, restart the home-scoped watcher through the emitted harness supervision protocol; bootstrap deliberately never restarts the watcher itself. -- `OPS_INBOX: ` - bootstrap could not prove that the operational alert inbox watch reached its requested armed or disarmed state. - An arming, configuration, or dependency failure means this machine's critical alerts are not guaranteed to reach Firstmate, so treat it as an alerting outage rather than a cosmetic startup warning. - A disarming or foreign-artifact failure means bootstrap could not safely converge the reserved `ops-watch` state, so leave the named artifacts in place and follow the printed remediation. - Apply the printed remediation, then rerun session start and confirm the line is gone. - `docs/ops-inbox-wake.md` owns the watch contract and its configuration. diff --git a/bin/backends/cmux.sh b/bin/backends/cmux.sh index c1f57939323..0c282d862ee 100644 --- a/bin/backends/cmux.sh +++ b/bin/backends/cmux.sh @@ -310,7 +310,7 @@ fm_backend_cmux_container_ensure() { # relocation is already outside the supported recovery contract. Derivation # itself lives in bin/fm-backend-hometag-lib.sh, shared with zellij's # identical shared-namespace collision fix (docs/zellij-backend.md -# "Home-scoped tab titles"). +# "Task shape and home isolation"). fm_backend_cmux_home_label() { fm_backend_hometag } @@ -579,7 +579,7 @@ fm_backend_cmux_composer_state() { # [expected-label] -> empty|pending # classifying the composer row specifically avoids that false positive, so # the retry loop correctly sends a second Enter when needed. Herdr's adapter # has since moved its own confirmation to a native agent-state read instead -# (docs/herdr-backend.md "Native agent-state submit confirmation"); cmux has +# (docs/herdr-backend.md "Current transport behavior"); cmux has # no analogous native primitive, so this composer-row approach remains # cmux's own confirmation strategy. Echoes empty|pending|unknown|send-failed, a # subset of the proof-carrying submit vocabulary. @@ -624,7 +624,8 @@ fm_backend_cmux_window_of_workspace() { # -> " =1 workspace, so # `close-workspace` on the ONLY workspace in its window silently no-ops - it # still returns `OK`, but the workspace stays, which is exactly what left a diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 92e2ce440bf..a9e4c520d69 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -5,15 +5,14 @@ # decisions D1-D6) and the empirical verification recorded in # data/fm-backend-design-d7/herdr-verification-p2.md (real herdr v0.7.1, # protocol 14, macOS aarch64), refined by docs/herdr-backend.md's -# "workspace-per-home" pass (AGENTS.md task herdr-sm-spaces-k4). Herdr is a +# "Watching and task containers" section. Herdr is a # session provider ONLY (D3): the worktree provider stays treehouse, exactly # like tmux. Sourced only through bin/fm-backend.sh's fm_backend_source in # normal operation; the unit tests source it directly, so the FM_HOME fallback # below keeps that path sane without fm-backend.sh's preamble. # -# Default container shape (D4, decided empirically - see -# herdr-verification-p2.md "Task container shape", refined by -# docs/herdr-backend.md "Default task container shape"): ONE herdr workspace PER +# Default container shape (docs/herdr-backend.md "Watching and task containers"): +# ONE herdr workspace PER # FIRSTMATE HOME (the primary, and each secondmate, gets its own), ONE herdr TAB # per task inside its home's workspace. An optional, default-off presentation # flag creates a disposable workspace for a clean fresh task instead. That @@ -119,7 +118,7 @@ FM_BACKEND_HERDR_SECONDMATE_MARKER=".fm-secondmate-home" FM_BACKEND_HERDR_PRESENTATION_JOURNAL_SUFFIX=".herdr-presentation" # fm_backend_herdr_workspace_label: the per-firstmate-HOME herdr workspace -# label (docs/herdr-backend.md "Default task container shape"). The PRIMARY home (no +# label (docs/herdr-backend.md "Watching and task containers"). The PRIMARY home (no # secondmate marker) resolves to the constant "firstmate", byte-identical to # every pre-existing task's recorded label - no forced migration. A SECONDMATE # home resolves to "2ndmate-", so its tasks land in their own @@ -145,7 +144,7 @@ fm_backend_herdr_workspace_label() { # fm_backend_herdr_cli: run `herdr ` scoped to , setting # BOTH the HERDR_SESSION env var AND appending a trailing `--session ` -# CLI flag. Verified empirically (docs/herdr-backend.md "Session targeting: the +# CLI flag. Verified empirically (docs/herdr-backend.md "Current transport behavior": the # --session flag, not HERDR_SESSION alone"): on the installed herdr 0.7.1 # client, the HERDR_SESSION env var is NOT reliably honored by CLI subcommands # once ANY other herdr server is already bound on the machine - queries @@ -1228,7 +1227,7 @@ fm_backend_herdr_server_ensure() { # # first). Empty when none match. Never creates anything. # # Single owner of the home-label workspace query. Herdr enforces no workspace -# label uniqueness at all (docs/herdr-backend.md "Label collisions"), so this +# label uniqueness at all (docs/herdr-backend.md "Watching and task containers"), so this # can legitimately return MORE THAN ONE id: a captain-owned workspace can # collide by label, a cwd-basename-derived label can coincide, and concurrent # first spawns can mint two same-labeled home workspaces. Callers decide what a @@ -1397,7 +1396,7 @@ fm_backend_herdr_launcher_identity() { # # "prunable" at create_task time from a pure label heuristic - exactly one # tab, labeled "1" - run against whatever workspace fm_backend_herdr_workspace_find # had just resolved. Herdr enforces no label uniqueness (docs/herdr-backend.md -# "Label collisions") and derives an unlabeled workspace's DISPLAYED label from +# "Watching and task containers") and derives an unlabeled workspace's DISPLAYED label from # its pane cwd's basename, so a captain launching herdr directly inside a # directory named "firstmate" produces a workspace that looks byte-identical, # by label alone, to firstmate's own auto-created container - one tab, label @@ -1469,8 +1468,8 @@ fm_backend_herdr_workspace_prune_seeded_default_tab() { # # registered in it - exactly what a herdr session-layout restore # produces (verified empirically: `session stop` + fresh `herdr # server` restart leaves the pane alive, agent_status "unknown", -# agent get -> agent_not_found - docs/herdr-backend.md "ID -# stability across a server restart"), and what a future +# agent get -> agent_not_found - docs/herdr-backend.md "Restart +# and liveness behavior"), and what a future # `resume_agents_on_restore = false` restore would produce too # (a plain shell, never an agent). # live - `agent get` succeeds and reports a real agent_status (working, @@ -1729,7 +1728,7 @@ fm_backend_herdr_agent_alive() { # # Ordering is deliberate: the REPLACEMENT tab is created FIRST, and the husk # is closed only AFTER that succeeds - never the reverse. Closing a # workspace's LAST remaining tab deletes the whole workspace on real herdr -# (docs/herdr-backend.md "Default workspace lifecycle"), and a session-restore husk +# (docs/herdr-backend.md "Watching and task containers"), and a session-restore husk # can legitimately be that workspace's only tab (e.g. its own seeded default # tab was already pruned, long before the restart, by a prior real task tab # existing alongside it). Herdr's lack of label-uniqueness enforcement is @@ -2336,7 +2335,7 @@ fm_backend_herdr_send_key() { # # fm-peek.sh's/fm-watch.sh's `tmux capture-pane -p -t T -S -N`. --source recent # is the closest herdr analogue to tmux's scrollback-bounded capture. # -# Verified CLI quirk (herdr-verification-p2.md "pane read --lines bug", v0.7.1): +# Verified CLI quirk (docs/herdr-backend.md "Current transport behavior", v0.7.1): # `pane read --source recent --lines N` returns COMPLETELY EMPTY output when N # is smaller than the pane's current viewport height (observed threshold ~23 # rows for a default-sized pane), instead of clamping to the last N lines - it @@ -2390,7 +2389,7 @@ fm_backend_herdr_strip_ansi() { # # matches either. # bare - an UNBORDERED composer (verified real claude 2.x and codex # 0.142.x, both under herdr 0.7.1, docs/herdr-backend.md -# "Incident (2026-07-07)"): the row's TRIMMED content starts with +# "Composer and injection safety"): the row's TRIMMED content starts with # one of the verified agent-specific prompt glyphs but carries no # closing border at all - claude's own live input row is a bare # "❯ …" with no surrounding │, and codex's is a bare "› …". Both @@ -2627,8 +2626,8 @@ EOF # fm_backend_herdr_send_text_submit: type into once (raw, # unsubmitted, via send_literal), then submit with a named Enter key, retried # (Enter only, never retyped) until herdr's NATIVE agent-state (agent get) -# confirms a real turn started. Verified hazard (herdr-verification-p2.md -# "slash/$ autocomplete popup"): a `/`- or `$`-prefixed send opens a +# confirms a real turn started. Verified hazard (docs/herdr-backend.md +# "Current transport behavior"): a `/`- or `$`-prefixed send opens a # completion popup within ~0.1s, exactly like tmux's claude/codex popups, so # the caller's before the first Enter matters here the same way it # does for tmux. @@ -2663,8 +2662,8 @@ EOF # casing the popup shape. # # Failure-mode analysis (the two directions the caller-facing contract must -# not get wrong - see docs/herdr-backend.md "Native agent-state submit -# confirmation" for the empirical timing behind this): +# not get wrong - see docs/herdr-backend.md "Current transport behavior" +# for the empirical timing behind this): # - Slow transition: fm_backend_herdr_wait_for_working samples repeatedly # across herdr's per-attempt confirmation budget (not once at the end), so a # transition landing partway through a window is still caught before this @@ -2869,8 +2868,8 @@ fm_backend_herdr_busy_state() { # # busy - a submit-active status was observed at least once. This is # confirmation that a real turn started or reached a prompt - # the submit landed - independent of -# whatever the composer's own text happens to show (docs/ -# herdr-backend.md "Incident (2026-07-07)": composer content is +# whatever the composer's own text happens to show +# (docs/herdr-backend.md "Current transport behavior": composer content is # what fooled the OLD confirmation on codex's dynamic idle-tip # text). Returned the INSTANT it is seen, without waiting out the # rest of the budget. @@ -2888,8 +2887,8 @@ fm_backend_herdr_busy_state() { # # several samples across that window instead of a single one, so a transition # that lands partway through is not missed just because it had not landed by # the FIRST sample. -# Empirical evidence (docs/herdr-backend.md "Native agent-state submit -# confirmation"): real claude and codex observed first-working at 90-490ms +# Empirical evidence (docs/herdr-backend.md "Current transport behavior"): real +# claude and codex observed first-working at 90-490ms # after Enter, so a several-hundred-ms budget sampled repeatedly reliably # catches it. The remaining, inherent gap - a turn so fast it starts AND # returns to idle between two samples - is bounded by how tightly is @@ -2981,8 +2980,9 @@ EOF # label looks like a firstmate task window (fm-) in 's, THIS # HOME'S OWN workspace (fm_backend_herdr_workspace_label - never another # home's), by LABEL - never by trusting a stored pane id, since ids are not -# guaranteed stable across every server lifecycle (see herdr-verification-p2.md -# "ID stability"). A caller running as a given home (e.g. a secondmate +# guaranteed stable across every server lifecycle (see +# docs/herdr-backend.md "Restart and liveness behavior"). A caller running as a +# given home (e.g. a secondmate # recovering its own in-flight work) naturally scopes to that home's own # workspace because FM_HOME already names it - no glue needed, unlike the # primary-spawns-a-secondmate path in fm-spawn.sh. Read-only: a session/ @@ -3004,7 +3004,7 @@ fm_backend_herdr_list_live() { # # --- native event push: pane.agent_status_changed subscriber ----------------- # # The push half of the immediate blocked-state escalation (AGENTS.md section 8, -# docs/herdr-backend.md "Native pane.agent_status_changed push escalation"). +# docs/herdr-backend.md "Push events and polling fallback"). # fm_backend_herdr_wait_transition is the watcher's bounded wait primitive for # herdr homes: instead of a blind sleep, it blocks on herdr's native event # stream and returns the instant a subscribed pane transitions to `blocked`, so diff --git a/bin/backends/tmux.sh b/bin/backends/tmux.sh index 0433fc587fa..cd7058402e8 100644 --- a/bin/backends/tmux.sh +++ b/bin/backends/tmux.sh @@ -149,7 +149,7 @@ fm_backend_tmux_current_command() { # # fm_backend_tmux_agent_state: recovery-grade harness-agent state for one # recorded target. See bin/fm-backend.sh's fm_backend_agent_state for the -# shared state vocabulary and docs/tmux-backend.md "Agent liveness probe" for +# shared state vocabulary and docs/tmux-backend.md "Current behavior and safety" for # the empirical basis. Tmux silently falls back to the active window when a # named target is absent, so the exact recorded window must appear in a # successful session inventory before its foreground command can be trusted. diff --git a/bin/backends/zellij.sh b/bin/backends/zellij.sh index 60aea88e79b..073cc3f1fa4 100644 --- a/bin/backends/zellij.sh +++ b/bin/backends/zellij.sh @@ -20,7 +20,7 @@ # embedded colon, so splitting on the FIRST colon is trivially correct and # mirrors herdr's target-string convention). # -# Home-scoped tab titles (closes a cross-home collision gap): because every +# Task shape and home isolation (closes a cross-home collision gap): because every # task in every firstmate home - primary or secondmate - shares this ONE # session's tab bar with no per-home split, and zellij enforces no tab-name # uniqueness at all, two firstmate homes whose task ids happen to collide @@ -35,7 +35,7 @@ # "fm-" title; target_ready, kill, and ad hoc selector fallback still # match it, but ONLY when that bare title is unambiguous (exactly one live tab # in the session carries it) - see fm_backend_zellij_tab_matches_label and -# docs/zellij-backend.md "Home-scoped tab titles" for the full migration +# docs/zellij-backend.md "Task shape and home isolation" for the full migration # posture. Moving/relocating a firstmate installation changes its tag # (acceptable - recorded worktree paths do not survive a move either). # @@ -62,7 +62,7 @@ # contradicts the design report's assumption ("acceptable for tmux and # zellij") and required a different implementation strategy - see # fm_backend_zellij_current_path below and docs/zellij-backend.md -# "Worktree-path discovery: pane_cwd does not track a subshell". +# "Current operation and safety". # 5. `new-tab` DOES steal focus from an attached client with NO flag to # suppress it (unlike herdr's --no-focus and tmux's new-window -d). # Mitigated (fm_backend_zellij_create_task): capture the previously @@ -292,7 +292,7 @@ fm_backend_zellij_pane_exists() { # # same-named tab from a different firstmate home sharing this one zellij # session) refuses rather than silently trusting whichever one happened to # match - the migration posture documented in docs/zellij-backend.md -# "Home-scoped tab titles". One list-tabs call serves every check here (the +# "Task shape and home isolation". One list-tabs call serves every check here (the # scoped check, the bare check, and the ambiguity count all read the SAME # already-fetched JSON), so a caller whose fake-CLI fixture supplies exactly # one list-tabs response keeps working unchanged. @@ -314,7 +314,7 @@ fm_backend_zellij_tab_matches_label() { #