From 0619120741a265a7871dc72397110119f6088555 Mon Sep 17 00:00:00 2001 From: rovermike Date: Fri, 18 Sep 2026 23:27:09 -0400 Subject: [PATCH 01/19] fix(bin): treat a live no-mistakes run as current after rebase A running run on the task's branch is authoritative regardless of head. Matching only the local head made a rebased in-flight run look failed. --- bin/fm-crew-state.sh | 57 +++++---- bin/fm-nm-run-lib.sh | 55 ++++++-- tests/fm-crew-state.test.sh | 246 +++++++++++++++++++++++++++++++----- 3 files changed, 289 insertions(+), 69 deletions(-) diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 160c729ed67..79d999cec33 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -37,20 +37,27 @@ # active or terminal (from `axi status`, or the coarse `no-mistakes runs` # fallback)? Branch name alone is not enough: a historical run on a reused # branch whose head was rewritten or diverged must not be attributed. -# A run matches when its head equals the worktree HEAD, or the worktree HEAD -# is an ancestor of the run head (pipeline fix commits advanced the run on -# the same line of history). Local work that advanced past the run head, or -# diverged from it, invalidates attribution. While the pipeline owns the -# branch (branch_sync.state=pipeline_owned), its own custody attribution -# binds an ACTIVE run without head equality (fm_nm_run_is_pipeline_owned_active -# in bin/fm-nm-run-lib.sh). -# A run head whose commit object the task copy never fetched (the pipeline -# committed its fix round in its own checkout) cannot be verified locally; -# that row is recognized only as a provable pipeline-owned continuation - -# the branch's ACTIVE newest ledger row, anchored by the row immediately -# before it having ended at exactly this worktree's head - so an active fix -# round never reads as an older failed run (rule owned by -# fm_nm_runs_status_for_worktree in bin/fm-nm-run-lib.sh). +# A run EXECUTING on this crew's branch (pending, running, fixing, or ci) +# is authoritative REGARDLESS of head (fm_nm_run_is_executing in +# bin/fm-nm-run-lib.sh): the pipeline rebases the branch and commits its +# fix rounds in its own checkout, so a live run's head routinely differs +# from the local head, and reading an older run that still matches the +# local head would report a working crew as failed. Every other run - +# terminal, or parked at a gate - matches only when its head equals the +# worktree HEAD, or the worktree HEAD is an ancestor of the run head +# (pipeline fix commits advanced the run on the same line of history); +# local work that advanced past the run head, or diverged from it, +# invalidates attribution. While the pipeline owns the branch +# (branch_sync.state=pipeline_owned), its own custody attribution also +# binds an ACTIVE parked run without head equality +# (fm_nm_run_is_pipeline_owned_active in bin/fm-nm-run-lib.sh). +# A parked run head whose commit object the task copy never fetched cannot +# be verified locally; that row is recognized only as a provable +# pipeline-owned continuation - the branch's ACTIVE newest ledger row, +# anchored by the row immediately before it having ended at exactly this +# worktree's head (rule owned by fm_nm_runs_status_for_worktree in +# bin/fm-nm-run-lib.sh). In the coarse runs-ledger fallback, a newest +# same-branch row that is running or pending answers whatever its head. # fm_nm_select_run in bin/fm-nm-run-lib.sh owns complete run selection # and ambiguity reporting. The selected run's id-addressed status must # agree on id, branch, and live/terminal class before attribution; @@ -650,7 +657,8 @@ nm_ci_checks_state() { # matching run: either it names another branch (routine once several crews # validate the same underlying repo concurrently - a worktree with its own # active run reliably gets that run answered, even under concurrent load), or -# it names this branch's run but the strict head rule rejected it. The real +# it names this branch's run but the strict head rule rejected it (an +# executing same-branch row still answers through live-any-head). The real # run-listing command is the top-level `no-mistakes runs` (the `axi` surface # has no runs-listing subcommand; tests/fm-crew-state.test.sh owns the # 2026-07-02 dead-code incident history this fallback replaced). @@ -731,7 +739,8 @@ if [ "$KIND" = ship ] && [ -n "$CREW_BRANCH" ] && command -v no-mistakes >/dev/n if [ "$(fm_nm_run_status_class "$selected_status")" != "$current_class" ]; then emit unknown run-step "selected run status disagrees with inventory; run ids: $candidate_ids" fi - if nm_run_head_matches_worktree || fm_nm_run_is_pipeline_owned_active "$RUN_OUT"; then + if fm_nm_run_is_executing "$RUN_OUT" \ + || nm_run_head_matches_worktree || fm_nm_run_is_pipeline_owned_active "$RUN_OUT"; then HAVE_RUN=1 elif [ -z "$(fm_nm_resolve_commit "$WT" "$(strip_quotes "$(nm_field head)")")" ]; then if fm_nm_run_is_active "$RUN_OUT" \ @@ -746,12 +755,14 @@ if [ "$KIND" = ship ] && [ -n "$CREW_BRANCH" ] && command -v no-mistakes >/dev/n esac if [ "$HAVE_RUN" = 0 ] && [ -z "$SELECTED_RUN_ID" ]; then run_branch=$(strip_quotes "$(nm_field branch)") - # Head equality, or the pipeline-owned-active exemption: while the - # pipeline owns this branch, the daemon's own branch attribution is - # authoritative and the lane head need not be a git object here - # (fm_nm_run_is_pipeline_owned_active in bin/fm-nm-run-lib.sh). + # Executing-regardless-of-head, head equality, or the pipeline-owned + # parked-run exemption: a live run on this branch is current even after + # a rebase, and while the pipeline owns this branch a parked run binds + # without the lane head being a git object here (fm_nm_run_is_executing + # and fm_nm_run_is_pipeline_owned_active in bin/fm-nm-run-lib.sh). if [ -n "$run_branch" ] && [ "$run_branch" = "$CREW_BRANCH" ] \ - && { nm_run_head_matches_worktree || fm_nm_run_is_pipeline_owned_active "$RUN_OUT"; }; then + && { fm_nm_run_is_executing "$RUN_OUT" \ + || nm_run_head_matches_worktree || fm_nm_run_is_pipeline_owned_active "$RUN_OUT"; }; then HAVE_RUN=1 # Without run ids, contradictory liveness cannot prove precedence. # A live replacement also needs an id-addressed status read: a bare @@ -778,7 +789,7 @@ if [ "$KIND" = ship ] && [ -n "$CREW_BRANCH" ] && command -v no-mistakes >/dev/n # `[ -n "$RUN_OUT" ]`: an empty/timed-out 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=$(fm_nm_runs_status_for_worktree "$WT" "$CREW_BRANCH" "$(nm_runs_list)") + COARSE_STATUS=$(fm_nm_runs_status_for_worktree "$WT" "$CREW_BRANCH" "$(nm_runs_list)" "" live-any-head) if [ -n "$COARSE_STATUS" ]; then HAVE_RUN=1 # A branch-matching answer the strict rule rejected is this branch's @@ -807,7 +818,7 @@ if [ "$HAVE_RUN" = 1 ]; then # read above. The status event span remains independently available to the # supervisor through fm-classify-lib.sh's status_span_first_actionable. case "$COARSE_STATUS" in - running) RUN_STATE=working; RUN_DETAIL="validating (background run)" ;; + pending|running) RUN_STATE=working; RUN_DETAIL="validating (background run)" ;; completed) RUN_STATE="done"; RUN_DETAIL="run completed" ;; failed) # The ledger row is terminal but the coarse path has no steps table diff --git a/bin/fm-nm-run-lib.sh b/bin/fm-nm-run-lib.sh index 3377dffa4cf..1166218139a 100644 --- a/bin/fm-nm-run-lib.sh +++ b/bin/fm-nm-run-lib.sh @@ -3,11 +3,13 @@ # # ONE owner for the no-mistakes run-attribution primitives used by # fm-crew-state.sh (read-only current-state reporting) and fm-teardown.sh -# (pre-teardown run abort, see its "Fix 1" header comment). Both bind a run -# by strict branch-and-head identity first, and both then recognize a provable +# (pre-teardown run abort, see its "Fix 1" header comment). Crew-state binds +# an EXECUTING run (pending, running, fixing, or ci) on the task's branch +# regardless of head (fm_nm_run_is_executing); every other run still needs +# strict branch-and-head identity. Both callers then recognize a provable # pipeline-owned continuation through fm_nm_runs_status_for_worktree below: -# crew-state for an ACTIVE run, so a fix round never reads as an older failed -# run, and teardown for a run PARKED at a gate, so cleanup concludes it +# crew-state for an ACTIVE parked run, so a fix round never reads as an older +# failed run, and teardown for a run PARKED at a gate, so cleanup concludes it # instead of orphaning it. Getting this wrong in either # direction is unsafe: a false negative hides a genuinely parked run, and a # false positive lets teardown act on a run it does not own. @@ -76,9 +78,11 @@ fm_nm_resolve_commit() { # # (local work advanced outside the run, or the branch tip was rewritten) # A run head whose object this copy does not have cannot be proven here and is # rejected; fm_nm_runs_status_for_worktree below owns the one ledger-anchored -# recognition for that case, and fm_nm_run_is_pipeline_owned_active below -# carries the custody exemption: a live run whose pipeline currently owns the -# branch binds without head equality. +# recognition for that case, fm_nm_run_is_executing below is the current-state +# exemption for a live run on this branch regardless of head, and +# fm_nm_run_is_pipeline_owned_active below carries the custody exemption: a +# parked run whose pipeline currently owns the branch binds without head +# equality. # # This predicate binds one run at a time, and MORE THAN ONE recorded run can # bind to the same worktree at once: a run that died at the worktree's exact @@ -125,9 +129,10 @@ fm_nm_run_status_class() { # # live run must not hide a newer failure. If the newest is live and another # same-branch live run exists, neither has exclusive authority: report all # candidate ids as unknown. A newer live row can replace cancelled history, -# but the caller must fetch its full status BY ID and prove branch/head or -# active pipeline custody before using its steps. Never reuse another run's -# gate detail. This is a read-only selection, not teardown authorization. +# but the caller must fetch its full status BY ID and prove branch/head, +# executing status, or active pipeline custody before using its steps. +# Never reuse another run's gate detail. +# This is a read-only selection, not teardown authorization. # # Prints selected|id|status|candidate-ids, unknown|reason, absent (no row # for this branch), or unavailable (CLI has no overview table). Malformed or @@ -307,6 +312,24 @@ fm_nm_run_is_pipeline_owned_active() { # fm_nm_run_is_active "$1" } +# 0 if the run in captured `axi status` TOON $1 is EXECUTING: in flight and +# actively working (pending, running, fixing, or ci), not parked at a gate. +# Read-only current-state reporting (fm-crew-state.sh) treats an executing run +# on the task's own branch as authoritative REGARDLESS of head: the pipeline +# rebases the branch and commits fix rounds in its own checkout, so a live run's +# head routinely differs from the task worktree's local head, and falling back +# to an older run that matches the local head reads a working crew as failed. +# A run parked at a gate keeps the strict head rule, and no destructive caller +# uses this predicate: teardown stays on fm_nm_head_matches_worktree and the +# ledger rule below. +fm_nm_run_is_executing() { # + fm_nm_run_is_active "$1" || return 1 + case "$(fm_nm_strip_quotes "$(fm_nm_field "$1" status)")" in + pending|running|fixing|ci) return 0 ;; + esac + return 1 +} + # ONE owner for attribution from the pipeline's own runs ledger, replacing a # per-row scan-and-skip. The ledger is the real top-level `no-mistakes runs # --limit N` listing (plain text, no run id, no quoting, newest-first, columns @@ -332,9 +355,14 @@ fm_nm_run_is_pipeline_owned_active() { # # ancestor, a terminal unresolvable row) prints nothing, so branch-name # coincidence, arbitrary remote state, and other tasks' runs never match. # An older live row never displaces a newer terminal result. +# When optional $5 is `live-any-head`, a newest same-branch row that is ACTIVE +# (running or pending) is the answer whatever its head: the pipeline rebases the +# branch, so a live row's head need not resolve to or descend from the worktree +# head, and the older row that does match the local head is history. Only the +# read-only current-state report passes it; teardown never does. # Read-only: git reads resolve objects in place; custody never changes. -fm_nm_runs_status_for_worktree() { # [expected-head] - local wt=$1 branch=$2 list=$3 expected_head=${4:-} +fm_nm_runs_status_for_worktree() { # [expected-head] [live-any-head] + local wt=$1 branch=$2 list=$3 expected_head=${4:-} live_any_head=${5:-} local local_full row_full row st br sha day clock pr extra year_num month_num day_num max_day pending_st='' local decided='' local_full=$(git -C "$wt" rev-parse HEAD 2>/dev/null) || return 0 @@ -386,6 +414,9 @@ fm_nm_runs_status_for_worktree() { # [ex *) case "$sha" in "$expected_head"*) ;; *) break ;; esac ;; esac fi + if [ "$live_any_head" = live-any-head ]; then + case "$st" in running|pending) decided=$st; break ;; esac + fi row_full=$(fm_nm_resolve_commit "$wt" "$sha") if [ -n "$row_full" ]; then if fm_nm_head_matches_worktree "$wt" "$sha"; then diff --git a/tests/fm-crew-state.test.sh b/tests/fm-crew-state.test.sh index a51574f0f14..695ef12e0d7 100755 --- a/tests/fm-crew-state.test.sh +++ b/tests/fm-crew-state.test.sh @@ -2699,12 +2699,12 @@ EOF pass "coarse scan anchors the unresolvable active row instead of falling to an older one" } -# Coarse negative control: the anchor must end at EXACTLY this worktree's -# head. The newest same-branch row is active at an unresolvable head, but the -# row immediately before it sits at an OLDER local commit, so the ledger -# proves nothing - unknown attribution stops the scan, never falls to the -# older failed row, and the busy pane answers instead. -test_coarse_mismatched_anchor_falls_to_pane_not_older_row() { +# The newest same-branch ledger row is ACTIVE at an unresolvable head and the +# row before it sits at an OLDER local commit, so the ledger anchor proves +# nothing. A running row on the task's branch is authoritative regardless of +# head (the pipeline rebases and commits in its own checkout), so it still binds +# through the coarse list and the older failed row never surfaces. +test_coarse_live_row_binds_without_head_anchor() { reset_fakes local d old_short; d=$(new_case f10-coarse-no-anchor) make_repo_on_branch "$d/wt" fm/feat-f10g @@ -2719,21 +2719,44 @@ test_coarse_mismatched_anchor_falls_to_pane_not_older_row() { failed fm/feat-f10g ${old_short} 2026-08-27 12:09 EOF )" - FM_FAKE_BUSY=1 - local gen; gen=$("$ROOT/bin/fm-busy-event.sh" arm "$d/state" feat-f10g) - "$ROOT/bin/fm-busy-event.sh" apply "$d/state" feat-f10g busy --gen "$gen" \ - --source claude-hook --event user-prompt-submit + FM_FAKE_BUSY=0 + arm_idle_record "$d/state" feat-f10g local out; out=$(run_crew_state "$d" feat-f10g) - assert_not_contains "$out" "state: failed" "a mismatched anchor must not fall to the older failed row" - assert_not_contains "$out" "source: run-step" "unknown attribution must not bind a run" - assert_contains "$out" "state: working" "the busy crew still reads working through the pane fallback" - assert_contains "$out" "source: pane" "without an exact anchor the pane answers, not the runs rows" - pass "coarse scan with a mismatched anchor stays unknown and lets the pane answer" + assert_not_contains "$out" "state: failed" "a live newest row must not fall to the older failed row" + assert_contains "$out" "source: run-step" "the live newest row binds through the runs list without an anchor" + assert_contains "$out" "state: working" "the live run reads working" + assert_contains "$out" "validating (background run)" "coarse resolution keeps coarse run detail" + pass "coarse scan binds the newest live row regardless of head" +} + +# The same ledger with the newest row TERMINAL keeps the strict rule: a finished +# run on a diverged head is history, not this worktree's current run. +test_coarse_terminal_row_at_foreign_head_not_attributed() { + reset_fakes + local d; d=$(new_case f10-coarse-terminal) + make_repo_on_branch "$d/wt" fm/feat-f10h + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-f10h.meta" "window=fm:fm-feat-f10h" "worktree=$d/wt" "kind=ship" "harness=claude" + printf 'working: implementing\n' > "$d/state/feat-f10h.status" + FM_FAKE_AXI_STATUS="$(run_running fm/other-crew)" + FM_FAKE_RUNS_LIST="$(cat </dev/null + fm_write_meta "$d/state/feat-f10p.meta" "window=fm:fm-feat-f10p" "worktree=$d/wt" "kind=ship" "harness=claude" + printf 'working: implementing\n' > "$d/state/feat-f10p.status" + FM_FAKE_RUN_HEAD=f0f0f0f0 + FM_FAKE_AXI_STATUS="$(run_parked fm/feat-f10p) +branch_sync: + state: synced" + FM_FAKE_RUNS_LIST="" + FM_FAKE_BUSY=0 + arm_idle_record "$d/state" feat-f10p + local out; out=$(run_crew_state "$d" feat-f10p) + assert_not_contains "$out" "source: run-step" "a non-pipeline-owned parked run at an unresolvable head must not bind" + assert_contains "$out" "source: status-log" "falls back to the status log for the unbound parked run" + pass "a parked run keeps the strict head rule without pipeline_owned" } # Negative control: the exemption also requires an ACTIVE run - a terminal run @@ -2839,11 +2884,11 @@ EOF pass "active fix round with an unfetched pipeline head reads working" } -# Negative control for the ledger continuation rule: without the anchor row -# ending at exactly this worktree's head, an active row with an unverifiable -# head is branch-name coincidence and must stay unattributed - the historical -# status-log fallback answers instead, never the runs rows. -test_unanchored_unfetched_active_row_does_not_match() { +# A live run on the task's branch is authoritative regardless of head, so an +# active row with an unverifiable head binds even when the ledger cannot anchor +# it to this worktree's head: the older row and the historical status-log +# `failed:` event never answer for the live run. +test_unanchored_unfetched_active_row_still_binds() { reset_fakes local d h2 out d=$(new_case unfetched-no-anchor) @@ -2858,7 +2903,7 @@ test_unanchored_unfetched_active_row_does_not_match() { FM_FAKE_RUN_HEAD="$h2" FM_FAKE_AXI_STATUS="$(run_fixing fm/feat-noanchor)" # The row before the active one is an OLDER commit, not this worktree's - # head: the ledger proves nothing about whose run the active row is. + # head: the ledger anchor proves nothing, and the live run binds anyway. FM_FAKE_RUNS_LIST="$(cat < -> echoes the diverged commit's short sha + local wt=$1 tree commit + tree=$(git -C "$wt" hash-object -t tree -w /dev/null) + commit=$(git -C "$wt" commit-tree "$tree" -m 'pipeline rebased head') + git -C "$wt" merge-base --is-ancestor HEAD "$commit" && fail "rebased head must not descend from local head" + git -C "$wt" merge-base --is-ancestor "$commit" HEAD && fail "rebased head must not be an ancestor of local head" + git -C "$wt" rev-parse --short=8 "$commit" +} + +# A live run whose head diverged from the local head because the pipeline +# rebased the branch is this task's current run. The newest overview row is the +# live run, and an older FAILED run still matches the local head; the failed run +# must not be read as the task's state (2026-08-23 billing-cycle-crash-safety). +test_live_rebased_run_beats_older_failed_run_at_local_head() { + make_competing_runs_case live-rebased running failed + local d=$TMP_ROOT/live-rebased out rebased + rebased=$(make_rebased_head "$d/wt") + FM_FAKE_AXI_HOME=$(printf '%s\n' "$FM_FAKE_AXI_HOME" | sed "/01NEW/s/,[a-f0-9]*,\"\"\$/,$rebased,\"\"/") + FM_FAKE_RUN_HEAD=$rebased + FM_FAKE_AXI_STATUS="$(run_running fm/competing | sed 's/01RUN/01NEW/') +branch_sync: + state: synced" + FM_FAKE_AXI_STATUS_RUN=$FM_FAKE_AXI_STATUS + printf 'working: validating\n' > "$d/state/competing.status" + out=$(run_crew_state "$d" competing) + assert_contains "$out" 'state: working' 'a live run on the branch reads working despite its rebased head' + assert_contains "$out" 'source: run-step' 'the live run is the authoritative source' + assert_not_contains "$out" 'state: failed' 'the older failed run must not be read as current' + pass 'a live rebased run beats an older failed run at the local head' +} + +# The same live run reads working for every executing status word. +test_live_rebased_run_reads_working_for_every_executing_status() { + local status d rebased out + for status in pending running fixing ci; do + make_competing_runs_case "live-rebased-$status" running failed + d=$TMP_ROOT/live-rebased-$status + rebased=$(make_rebased_head "$d/wt") + FM_FAKE_AXI_HOME=$(printf '%s\n' "$FM_FAKE_AXI_HOME" | sed "/01NEW/s/,[a-f0-9]*,\"\"\$/,$rebased,\"\"/") + FM_FAKE_RUN_HEAD=$rebased + FM_FAKE_AXI_STATUS="$(run_running fm/competing | sed "s/01RUN/01NEW/; s/status: running/status: $status/")" + FM_FAKE_AXI_STATUS_RUN=$FM_FAKE_AXI_STATUS + FM_FAKE_CI_LOGS="CI checks running" + out=$(run_crew_state "$d" competing) + assert_contains "$out" 'state: working' "$status run with a rebased head reads working" + assert_contains "$out" 'source: run-step' "$status run with a rebased head is run-step sourced" + assert_not_contains "$out" 'state: failed' "$status run with a rebased head is never failed" + pass "$status run with a rebased head reads working" + done +} + +# Legacy CLI surface (no overview table): the bare `axi status` run is live on +# this branch with a rebased head, while the runs ledger still holds an older +# failed row at the local head. +test_legacy_live_rebased_run_is_authoritative() { + reset_fakes + local d rebased short out; d=$(new_case legacy-live-rebased) + make_repo_on_branch "$d/wt" fm/feat-rebased + short=$(git -C "$d/wt" rev-parse --short=8 HEAD) + rebased=$(make_rebased_head "$d/wt") + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-rebased.meta" "window=fm:fm-feat-rebased" "worktree=$d/wt" "kind=ship" "harness=claude" + printf 'working: validating\n' > "$d/state/feat-rebased.status" + FM_FAKE_RUN_HEAD=$rebased + FM_FAKE_AXI_STATUS="$(run_running fm/feat-rebased) +branch_sync: + state: synced" + FM_FAKE_RUNS_LIST="$(cat </dev/null + fm_write_meta "$d/state/feat-rebased2.meta" "window=fm:fm-feat-rebased2" "worktree=$d/wt" "kind=ship" "harness=claude" + FM_FAKE_AXI_STATUS="$(run_running fm/other-crew)" + FM_FAKE_RUNS_LIST="$(cat < "$d/state/competing.status" + FM_FAKE_BUSY=0 + arm_idle_record "$d/state" competing + out=$(run_crew_state "$d" competing) + assert_not_contains "$out" 'source: run-step' 'a terminal run on a diverged head is not attributed' + assert_contains "$out" 'source: status-log' 'the status log answers when only a foreign terminal run exists' + pass 'a terminal run at a diverged head keeps the strict head rule' +} + test_competing_live_runs_report_unknown_with_both_ids() { make_competing_runs_case ambiguous-runs running running local d=$TMP_ROOT/ambiguous-runs out @@ -3621,12 +3792,14 @@ test_local_advanced_past_run_head_invalidates test_pipeline_owned_active_run_beats_superseded_failed_row test_failed_run_with_no_later_run_still_surfaces test_coarse_unresolvable_active_row_never_falls_to_older_row -test_coarse_mismatched_anchor_falls_to_pane_not_older_row -test_non_pipeline_owned_unresolvable_head_not_attributed +test_coarse_live_row_binds_without_head_anchor +test_coarse_terminal_row_at_foreign_head_not_attributed +test_executing_run_binds_without_pipeline_owned_sync +test_non_pipeline_owned_parked_unresolvable_head_not_attributed test_pipeline_owned_terminal_run_not_exempt test_missing_run_head_falls_back_to_current_state test_active_fix_round_unfetched_pipeline_head_reports_current -test_unanchored_unfetched_active_row_does_not_match +test_unanchored_unfetched_active_row_still_binds test_unresolved_terminal_row_is_history_not_current test_runs_list_continuation_found_when_axi_answers_other_branch test_no_run_herdr_stale_registration_over_shell_reads_agent_gone @@ -3651,6 +3824,11 @@ test_uninitialized_idle_worker_uses_status test_historical_inventory_uses_current_pane test_historical_inventory_uses_current_status test_superseded_cancelled_run_preserves_replacement_gate +test_live_rebased_run_beats_older_failed_run_at_local_head +test_live_rebased_run_reads_working_for_every_executing_status +test_legacy_live_rebased_run_is_authoritative +test_coarse_live_rebased_row_is_authoritative +test_terminal_rebased_run_is_not_attributed test_competing_live_runs_report_unknown_with_both_ids test_newer_failed_run_is_not_hidden_by_older_live_run test_unverifiable_run_selection_reports_unknown From a8d0fc7961d9fc90bc7de5421fb61baf5a7a5bd8 Mon Sep 17 00:00:00 2001 From: rovermike Date: Sat, 19 Sep 2026 09:43:14 -0400 Subject: [PATCH 02/19] no-mistakes(review): restrict coarse live-any-head to foreign-branch answers --- bin/fm-crew-state.sh | 12 +++++++++--- bin/fm-nm-run-lib.sh | 5 ++++- tests/fm-crew-state.test.sh | 8 ++++++-- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 79d999cec33..5aabbb3d260 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -56,8 +56,9 @@ # pipeline-owned continuation - the branch's ACTIVE newest ledger row, # anchored by the row immediately before it having ended at exactly this # worktree's head (rule owned by fm_nm_runs_status_for_worktree in -# bin/fm-nm-run-lib.sh). In the coarse runs-ledger fallback, a newest -# same-branch row that is running or pending answers whatever its head. +# bin/fm-nm-run-lib.sh). In the coarse runs-ledger fallback, and only +# when `axi status` answered ANOTHER branch's run, a newest same-branch +# row that is running or pending answers whatever its head. # fm_nm_select_run in bin/fm-nm-run-lib.sh owns complete run selection # and ambiguity reporting. The selected run's id-addressed status must # agree on id, branch, and live/terminal class before attribution; @@ -789,7 +790,12 @@ if [ "$KIND" = ship ] && [ -n "$CREW_BRANCH" ] && command -v no-mistakes >/dev/n # `[ -n "$RUN_OUT" ]`: an empty/timed-out 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=$(fm_nm_runs_status_for_worktree "$WT" "$CREW_BRANCH" "$(nm_runs_list)" "" live-any-head) + # `live-any-head` only for a foreign-branch answer: a same-branch run + # that reached here is parked or terminal, and a bare live ledger row + # can neither tell those apart nor license reusing that run's detail. + coarse_mode=live-any-head + [ "$run_branch" != "$CREW_BRANCH" ] || coarse_mode="" + COARSE_STATUS=$(fm_nm_runs_status_for_worktree "$WT" "$CREW_BRANCH" "$(nm_runs_list)" "" "$coarse_mode") if [ -n "$COARSE_STATUS" ]; then HAVE_RUN=1 # A branch-matching answer the strict rule rejected is this branch's diff --git a/bin/fm-nm-run-lib.sh b/bin/fm-nm-run-lib.sh index 1166218139a..ce03aed3eff 100644 --- a/bin/fm-nm-run-lib.sh +++ b/bin/fm-nm-run-lib.sh @@ -359,7 +359,10 @@ fm_nm_run_is_executing() { # # (running or pending) is the answer whatever its head: the pipeline rebases the # branch, so a live row's head need not resolve to or descend from the worktree # head, and the older row that does match the local head is history. Only the -# read-only current-state report passes it; teardown never does. +# read-only current-state report passes it, and only when `axi status` answered +# ANOTHER branch's run: a same-branch run the strict head rule rejected is +# parked or terminal, and this coarse row cannot tell those apart. Teardown +# never passes it. # Read-only: git reads resolve objects in place; custody never changes. fm_nm_runs_status_for_worktree() { # [expected-head] [live-any-head] local wt=$1 branch=$2 list=$3 expected_head=${4:-} live_any_head=${5:-} diff --git a/tests/fm-crew-state.test.sh b/tests/fm-crew-state.test.sh index 695ef12e0d7..a308fd36b70 100755 --- a/tests/fm-crew-state.test.sh +++ b/tests/fm-crew-state.test.sh @@ -2774,7 +2774,10 @@ test_executing_run_binds_without_pipeline_owned_sync() { } # Negative control: a run PARKED at a gate keeps the strict head rule, so a -# non-pipeline_owned parked run at an unresolvable head is not attributed. +# non-pipeline_owned parked run at an unresolvable head is not attributed. The +# ledger carries a live same-branch row at that same unresolvable head - the +# coarse fallback must not revive the rejected run's gate detail through it, +# because a bare `running` row cannot tell working from waiting at a gate. test_non_pipeline_owned_parked_unresolvable_head_not_attributed() { reset_fakes local d; d=$(new_case f10-parked-not-owned) @@ -2786,11 +2789,12 @@ test_non_pipeline_owned_parked_unresolvable_head_not_attributed() { FM_FAKE_AXI_STATUS="$(run_parked fm/feat-f10p) branch_sync: state: synced" - FM_FAKE_RUNS_LIST="" + FM_FAKE_RUNS_LIST=" running fm/feat-f10p f0f0f0f0 2026-08-27 13:53" FM_FAKE_BUSY=0 arm_idle_record "$d/state" feat-f10p local out; out=$(run_crew_state "$d" feat-f10p) assert_not_contains "$out" "source: run-step" "a non-pipeline-owned parked run at an unresolvable head must not bind" + assert_not_contains "$out" "parked at" "a live ledger row must not revive the rejected run's gate detail" assert_contains "$out" "source: status-log" "falls back to the status log for the unbound parked run" pass "a parked run keeps the strict head rule without pipeline_owned" } From 550510fe225ac303f217df9104cf46461975c759 Mon Sep 17 00:00:00 2001 From: rovermike Date: Sat, 19 Sep 2026 09:56:52 -0400 Subject: [PATCH 03/19] no-mistakes(review): reject gate-parked runs from the executing predicate --- bin/fm-crew-state.sh | 6 ++++-- bin/fm-nm-run-lib.sh | 13 +++++++++++++ tests/fm-crew-state.test.sh | 30 ++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 5aabbb3d260..607f8cc5039 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -658,8 +658,10 @@ nm_ci_checks_state() { # matching run: either it names another branch (routine once several crews # validate the same underlying repo concurrently - a worktree with its own # active run reliably gets that run answered, even under concurrent load), or -# it names this branch's run but the strict head rule rejected it (an -# executing same-branch row still answers through live-any-head). The real +# it names this branch's run but the strict head rule rejected it - a run that +# is parked or terminal, since an executing same-branch run binds before this +# fallback is reached, so the ledger resolves it STRICTLY and only a +# foreign-branch answer gets live-any-head. The real # run-listing command is the top-level `no-mistakes runs` (the `axi` surface # has no runs-listing subcommand; tests/fm-crew-state.test.sh owns the # 2026-07-02 dead-code incident history this fallback replaced). diff --git a/bin/fm-nm-run-lib.sh b/bin/fm-nm-run-lib.sh index ce03aed3eff..b9e45a11eda 100644 --- a/bin/fm-nm-run-lib.sh +++ b/bin/fm-nm-run-lib.sh @@ -312,6 +312,18 @@ fm_nm_run_is_pipeline_owned_active() { # fm_nm_run_is_active "$1" } +# 0 if the run in captured `axi status` TOON $1 carries a PARKED marker, using +# the same gate evidence fm-crew-state.sh renders `parked at ` from: a +# top-level `gate:` line, an `awaiting_agent:` line, an awaiting_approval or +# fix_review `status:`/`state:` scalar, or a steps/gate table row whose status +# column is one of those. The top-level `status:` word alone does NOT decide +# this: the CLI leaves it at `running` while a run waits at a gate, so the word +# and the gate markers routinely disagree. +fm_nm_run_is_parked() { # + printf '%s\n' "$1" | grep -Eq \ + '^[[:space:]]*(gate|awaiting_agent):[[:space:]]*|^[[:space:]]*(status|state):[[:space:]]*"?(awaiting_approval|fix_review)"?[[:space:]]*$|^[[:space:]]*[^,]+,[[:space:]]*"?(awaiting_approval|fix_review)"?[[:space:]]*,' +} + # 0 if the run in captured `axi status` TOON $1 is EXECUTING: in flight and # actively working (pending, running, fixing, or ci), not parked at a gate. # Read-only current-state reporting (fm-crew-state.sh) treats an executing run @@ -324,6 +336,7 @@ fm_nm_run_is_pipeline_owned_active() { # # ledger rule below. fm_nm_run_is_executing() { # fm_nm_run_is_active "$1" || return 1 + fm_nm_run_is_parked "$1" && return 1 case "$(fm_nm_strip_quotes "$(fm_nm_field "$1" status)")" in pending|running|fixing|ci) return 0 ;; esac diff --git a/tests/fm-crew-state.test.sh b/tests/fm-crew-state.test.sh index a308fd36b70..d06d94cfe66 100755 --- a/tests/fm-crew-state.test.sh +++ b/tests/fm-crew-state.test.sh @@ -2799,6 +2799,35 @@ branch_sync: pass "a parked run keeps the strict head rule without pipeline_owned" } +# The CLI leaves the top-level `status:` word at `running` while a run WAITS at +# a gate, so the word alone cannot decide "executing". A gate-parked run at an +# unresolvable head, on a branch the pipeline has released, must keep the strict +# head rule in both gate shapes - otherwise the crew reports a stale +# `parked at ` from a run whose code identity was never verified. +test_gate_parked_run_with_live_status_word_not_attributed() { + local fixture d out + for fixture in run_parked_scalar_gate_running run_parked_in_gate_block; do + reset_fakes + d=$(new_case "f10-gate-parked-$fixture") + make_repo_on_branch "$d/wt" fm/feat-f10q + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-f10q.meta" "window=fm:fm-feat-f10q" "worktree=$d/wt" "kind=ship" "harness=claude" + printf 'working: implementing\n' > "$d/state/feat-f10q.status" + FM_FAKE_RUN_HEAD=f0f0f0f0 + FM_FAKE_AXI_STATUS="$($fixture fm/feat-f10q) +branch_sync: + state: synced" + FM_FAKE_RUNS_LIST="" + FM_FAKE_BUSY=0 + arm_idle_record "$d/state" feat-f10q + out=$(run_crew_state "$d" feat-f10q) + assert_not_contains "$out" "source: run-step" "$fixture: a gate-parked run at an unresolvable head must not bind" + assert_not_contains "$out" "parked at" "$fixture: no gate detail may come from an unverified run" + assert_contains "$out" "source: status-log" "$fixture: the status log answers for the unbound parked run" + pass "$fixture keeps the strict head rule despite its live status word" + done +} + # Negative control: the exemption also requires an ACTIVE run - a terminal run # released the branch, so an inconsistent pipeline_owned label must not bind a # terminal run by branch name alone. @@ -3800,6 +3829,7 @@ test_coarse_live_row_binds_without_head_anchor test_coarse_terminal_row_at_foreign_head_not_attributed test_executing_run_binds_without_pipeline_owned_sync test_non_pipeline_owned_parked_unresolvable_head_not_attributed +test_gate_parked_run_with_live_status_word_not_attributed test_pipeline_owned_terminal_run_not_exempt test_missing_run_head_falls_back_to_current_state test_active_fix_round_unfetched_pipeline_head_reports_current From e5f55b79989f9b0cf18bae2b35ec97fd5aaa30f2 Mon Sep 17 00:00:00 2001 From: rovermike Date: Sat, 19 Sep 2026 10:10:11 -0400 Subject: [PATCH 04/19] no-mistakes(review): hoist gate-marker patterns into single run-lib owner --- bin/fm-crew-state.sh | 8 ++++---- bin/fm-nm-run-lib.sh | 24 ++++++++++++++++-------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 607f8cc5039..6be0758f07a 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -407,7 +407,7 @@ nm_findings_count() { } nm_gate_step_row() { local row step rest status findings - row=$(printf '%s\n' "$RUN_OUT" | grep -E '^[[:space:]]*[^,]+,[[:space:]]*"?(awaiting_approval|fix_review)"?[[:space:]]*,' | head -1) + row=$(printf '%s\n' "$RUN_OUT" | grep -E "$FM_NM_GATE_ROW_RE" | head -1) [ -n "$row" ] || return 0 row=$(trim "$row") step=$(trim "${row%%,*}") @@ -419,7 +419,7 @@ nm_gate_step_row() { } nm_gate_status() { local s row - s=$(printf '%s\n' "$RUN_OUT" | grep -E '^[[:space:]]*(status|state):[[:space:]]*"?(awaiting_approval|fix_review)"?[[:space:]]*$' | head -1) + s=$(printf '%s\n' "$RUN_OUT" | grep -E "$FM_NM_GATE_SCALAR_RE" | head -1) if [ -n "$s" ]; then s=$(strip_quotes "$(trim "${s#*:}")") printf '%s' "$s" @@ -429,7 +429,7 @@ nm_gate_status() { [ -n "$row" ] && { row=${row#*|}; printf '%s' "${row%%|*}"; } } nm_has_gate() { - printf '%s\n' "$RUN_OUT" | grep -Eq '^[[:space:]]*gate:[[:space:]]*' + printf '%s\n' "$RUN_OUT" | grep -Eq "$FM_NM_GATE_LINE_RE" } nm_gate_line_name() { local gate step @@ -846,7 +846,7 @@ if [ "$HAVE_RUN" = 1 ]; then status=$(strip_quotes "$(nm_field status)") RUN_STATUS=$status outcome=$(strip_quotes "$(nm_field outcome)") - awaiting=$(printf '%s\n' "$RUN_OUT" | grep -E '^[[:space:]]*awaiting_agent:' | head -1 || true) + awaiting=$(printf '%s\n' "$RUN_OUT" | grep -E "$FM_NM_AWAITING_AGENT_RE" | head -1 || true) gate_status=$(nm_gate_status) has_gate=0 nm_has_gate && has_gate=1 diff --git a/bin/fm-nm-run-lib.sh b/bin/fm-nm-run-lib.sh index b9e45a11eda..5118c225c8f 100644 --- a/bin/fm-nm-run-lib.sh +++ b/bin/fm-nm-run-lib.sh @@ -312,16 +312,24 @@ fm_nm_run_is_pipeline_owned_active() { # fm_nm_run_is_active "$1" } -# 0 if the run in captured `axi status` TOON $1 carries a PARKED marker, using -# the same gate evidence fm-crew-state.sh renders `parked at ` from: a -# top-level `gate:` line, an `awaiting_agent:` line, an awaiting_approval or -# fix_review `status:`/`state:` scalar, or a steps/gate table row whose status -# column is one of those. The top-level `status:` word alone does NOT decide -# this: the CLI leaves it at `running` while a run waits at a gate, so the word -# and the gate markers routinely disagree. +# The gate evidence in an `axi status` TOON, as ONE set of patterns. Both +# readers must agree exactly: fm_nm_run_is_parked below decides whether a run +# keeps the strict head rule, and fm-crew-state.sh's nm_gate_step_row / +# nm_gate_status / nm_has_gate render the `parked at ` detail from the +# same evidence. If a new parked marker is added to one reader only, an +# unverified run's gate detail reaches the crew report. +FM_NM_GATE_LINE_RE='^[[:space:]]*gate:[[:space:]]*' +FM_NM_AWAITING_AGENT_RE='^[[:space:]]*awaiting_agent:' +FM_NM_GATE_SCALAR_RE='^[[:space:]]*(status|state):[[:space:]]*"?(awaiting_approval|fix_review)"?[[:space:]]*$' +FM_NM_GATE_ROW_RE='^[[:space:]]*[^,]+,[[:space:]]*"?(awaiting_approval|fix_review)"?[[:space:]]*,' + +# 0 if the run in captured `axi status` TOON $1 carries any of those PARKED +# markers. The top-level `status:` word alone does NOT decide this: the CLI +# leaves it at `running` while a run waits at a gate, so the word and the gate +# markers routinely disagree. fm_nm_run_is_parked() { # printf '%s\n' "$1" | grep -Eq \ - '^[[:space:]]*(gate|awaiting_agent):[[:space:]]*|^[[:space:]]*(status|state):[[:space:]]*"?(awaiting_approval|fix_review)"?[[:space:]]*$|^[[:space:]]*[^,]+,[[:space:]]*"?(awaiting_approval|fix_review)"?[[:space:]]*,' + "$FM_NM_GATE_LINE_RE|$FM_NM_AWAITING_AGENT_RE|$FM_NM_GATE_SCALAR_RE|$FM_NM_GATE_ROW_RE" } # 0 if the run in captured `axi status` TOON $1 is EXECUTING: in flight and From 3c59616a0b2b2bbe52883383a058ff72b072a787 Mon Sep 17 00:00:00 2001 From: rovermike Date: Sat, 19 Sep 2026 10:31:02 -0400 Subject: [PATCH 05/19] no-mistakes(review): require live daemon for head-free run binding --- bin/fm-crew-state.sh | 40 +++++++++++++------ bin/fm-nm-run-lib.sh | 4 ++ tests/fm-crew-state.test.sh | 80 +++++++++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 13 deletions(-) diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 6be0758f07a..992e25198e0 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -39,10 +39,15 @@ # branch whose head was rewritten or diverged must not be attributed. # A run EXECUTING on this crew's branch (pending, running, fixing, or ci) # is authoritative REGARDLESS of head (fm_nm_run_is_executing in -# bin/fm-nm-run-lib.sh): the pipeline rebases the branch and commits its -# fix rounds in its own checkout, so a live run's head routinely differs -# from the local head, and reading an older run that still matches the -# local head would report a working crew as failed. Every other run - +# bin/fm-nm-run-lib.sh) as long as an explicit probe does not prove the +# daemon down (nm_daemon_probe_down): the pipeline rebases the branch and +# commits its fix rounds in its own checkout, so a live run's head +# routinely differs from the local head, and reading an older run that +# still matches the local head would report a working crew as failed - but +# a record still saying `running` because the daemon died under it is +# evidence from a dead instrument, exactly as for a terminal record, and +# must not answer once the worktree has moved off the run head. Every +# other run - # terminal, or parked at a gate - matches only when its head equals the # worktree HEAD, or the worktree HEAD is an ancestor of the run head # (pipeline fix commits advanced the run on the same line of history); @@ -742,8 +747,8 @@ if [ "$KIND" = ship ] && [ -n "$CREW_BRANCH" ] && command -v no-mistakes >/dev/n if [ "$(fm_nm_run_status_class "$selected_status")" != "$current_class" ]; then emit unknown run-step "selected run status disagrees with inventory; run ids: $candidate_ids" fi - if fm_nm_run_is_executing "$RUN_OUT" \ - || nm_run_head_matches_worktree || fm_nm_run_is_pipeline_owned_active "$RUN_OUT"; then + if nm_run_head_matches_worktree || fm_nm_run_is_pipeline_owned_active "$RUN_OUT" \ + || { fm_nm_run_is_executing "$RUN_OUT" && ! nm_daemon_probe_down; }; then HAVE_RUN=1 elif [ -z "$(fm_nm_resolve_commit "$WT" "$(strip_quotes "$(nm_field head)")")" ]; then if fm_nm_run_is_active "$RUN_OUT" \ @@ -758,14 +763,17 @@ if [ "$KIND" = ship ] && [ -n "$CREW_BRANCH" ] && command -v no-mistakes >/dev/n esac if [ "$HAVE_RUN" = 0 ] && [ -z "$SELECTED_RUN_ID" ]; then run_branch=$(strip_quotes "$(nm_field branch)") - # Executing-regardless-of-head, head equality, or the pipeline-owned - # parked-run exemption: a live run on this branch is current even after - # a rebase, and while the pipeline owns this branch a parked run binds + # Head equality, the pipeline-owned parked-run exemption, or executing + # regardless of head: a live run on this branch is current even after a + # rebase, and while the pipeline owns this branch a parked run binds # without the lane head being a git object here (fm_nm_run_is_executing - # and fm_nm_run_is_pipeline_owned_active in bin/fm-nm-run-lib.sh). + # and fm_nm_run_is_pipeline_owned_active in bin/fm-nm-run-lib.sh). The + # head-free route additionally needs the daemon not provably down, so a + # record left saying `running` by a dead daemon stops answering once the + # worktree moves off the run head. if [ -n "$run_branch" ] && [ "$run_branch" = "$CREW_BRANCH" ] \ - && { fm_nm_run_is_executing "$RUN_OUT" \ - || nm_run_head_matches_worktree || fm_nm_run_is_pipeline_owned_active "$RUN_OUT"; }; then + && { nm_run_head_matches_worktree || fm_nm_run_is_pipeline_owned_active "$RUN_OUT" \ + || { fm_nm_run_is_executing "$RUN_OUT" && ! nm_daemon_probe_down; }; }; then HAVE_RUN=1 # Without run ids, contradictory liveness cannot prove precedence. # A live replacement also needs an id-addressed status read: a bare @@ -950,7 +958,13 @@ if [ "$HAVE_RUN" = 1 ]; then fi if [ "$RUN_STATE" != parked ]; then if [ "$RUN_STATE" = working ]; then - if [ "$LOG_VERB" = blocked ] \ + if [ "$RUN_SOURCE" = coarse ]; then + # The runs ledger keeps a parked run's status word at `running` + # (tests/captures/no-mistakes-v1.70.1/parked.toon), so a coarse + # live row is equally consistent with the gate still being open + # and cannot establish that this event resolved. + RUN_DETAIL="$RUN_DETAIL${SEP}status-log not superseded: a coarse run record cannot tell working from parked" + elif [ "$LOG_VERB" = blocked ] \ && log_claims_pipeline_unreachable "$LOG_LINE" \ && { [ "$RUN_STATUS" = running ] || [ "$RUN_STATUS" = fixing ]; } \ && nm_run_activity_is_recent; then diff --git a/bin/fm-nm-run-lib.sh b/bin/fm-nm-run-lib.sh index 5118c225c8f..ac54b18747a 100644 --- a/bin/fm-nm-run-lib.sh +++ b/bin/fm-nm-run-lib.sh @@ -342,6 +342,10 @@ fm_nm_run_is_parked() { # # A run parked at a gate keeps the strict head rule, and no destructive caller # uses this predicate: teardown stays on fm_nm_head_matches_worktree and the # ledger rule below. +# This predicate reads the RECORD only; it cannot tell a live run from one whose +# daemon died still saying `running`. The head-free route through it is the +# caller's to license, and fm-crew-state.sh pairs it with an explicit +# daemon-down probe for exactly that reason. fm_nm_run_is_executing() { # fm_nm_run_is_active "$1" || return 1 fm_nm_run_is_parked "$1" && return 1 diff --git a/tests/fm-crew-state.test.sh b/tests/fm-crew-state.test.sh index d06d94cfe66..73d06af5bc8 100755 --- a/tests/fm-crew-state.test.sh +++ b/tests/fm-crew-state.test.sh @@ -3502,6 +3502,83 @@ EOF pass 'legacy live rebased run is authoritative over an older failed row' } +# The head-free route is licensed by the daemon being reachable. A record left +# saying `running` by a daemon that died under it is evidence from a dead +# instrument: once the worktree moves off the run head, nothing corroborates it, +# so it must stop answering and the status log takes over. +test_live_record_at_diverged_head_needs_a_live_daemon() { + reset_fakes + local d rebased out; d=$(new_case zombie-daemon-down) + make_repo_on_branch "$d/wt" fm/feat-zombie + rebased=$(make_rebased_head "$d/wt") + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-zombie.meta" "window=fm:fm-feat-zombie" "worktree=$d/wt" "kind=ship" "harness=claude" + printf 'working: validating\n' > "$d/state/feat-zombie.status" + FM_FAKE_RUN_HEAD=$rebased + FM_FAKE_AXI_STATUS="$(run_running fm/feat-zombie) +branch_sync: + state: synced" + FM_FAKE_RUNS_LIST="" + FM_FAKE_DAEMON_DOWN=1 + FM_FAKE_BUSY=0 + arm_idle_record "$d/state" feat-zombie + out=$(run_crew_state "$d" feat-zombie) + assert_not_contains "$out" "source: run-step" "a live record at a diverged head must not answer while the daemon is provably down" + assert_contains "$out" "source: status-log" "the status log answers for the unbound record" + pass "a live record at a diverged head needs a reachable daemon to bind" +} + +# The head-free route still binds while the daemon answers: the daemon probe +# narrows the zombie case only, it does not undo the rebase fix. +test_live_record_at_diverged_head_binds_while_daemon_answers() { + reset_fakes + local d rebased out; d=$(new_case live-daemon-up) + make_repo_on_branch "$d/wt" fm/feat-livedaemon + rebased=$(make_rebased_head "$d/wt") + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-livedaemon.meta" "window=fm:fm-feat-livedaemon" "worktree=$d/wt" "kind=ship" "harness=claude" + printf 'working: validating\n' > "$d/state/feat-livedaemon.status" + FM_FAKE_RUN_HEAD=$rebased + FM_FAKE_AXI_STATUS="$(run_running fm/feat-livedaemon) +branch_sync: + state: synced" + FM_FAKE_RUNS_LIST="" + FM_FAKE_DAEMON_DOWN=0 + FM_FAKE_BUSY=0 + arm_idle_record "$d/state" feat-livedaemon + out=$(run_crew_state "$d" feat-livedaemon) + assert_contains "$out" "source: run-step" "a reachable daemon keeps the rebased live run authoritative" + assert_contains "$out" "state: working" "the live rebased run still reads working" + pass "a live record at a diverged head binds while the daemon answers" +} + +# A coarse ledger row keeps a PARKED run's status word at `running` +# (tests/captures/no-mistakes-v1.70.1/parked.toon), so it is equally consistent +# with the gate still being open and must never claim the crew's own +# needs-decision event was superseded. +test_coarse_live_row_does_not_claim_gate_superseded() { + reset_fakes + local d rebased out; d=$(new_case coarse-gate-signal) + make_repo_on_branch "$d/wt" fm/feat-cg + rebased=$(make_rebased_head "$d/wt") + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-cg.meta" "window=fm:fm-feat-cg" "worktree=$d/wt" "kind=ship" "harness=claude" + printf 'needs-decision: review gate has an ask-user finding\n' > "$d/state/feat-cg.status" + FM_FAKE_AXI_STATUS="$(run_running fm/other-crew)" + FM_FAKE_RUNS_LIST="$(cat < Date: Sat, 19 Sep 2026 10:53:29 -0400 Subject: [PATCH 06/19] no-mistakes(review): require answered daemon-down before unbinding live runs --- bin/fm-crew-state.sh | 50 ++++++++++++++++++---- tests/fm-crew-state.test.sh | 82 ++++++++++++++++++++++++++++++++++++- 2 files changed, 122 insertions(+), 10 deletions(-) diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 992e25198e0..3e18029841c 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -39,8 +39,9 @@ # branch whose head was rewritten or diverged must not be attributed. # A run EXECUTING on this crew's branch (pending, running, fixing, or ci) # is authoritative REGARDLESS of head (fm_nm_run_is_executing in -# bin/fm-nm-run-lib.sh) as long as an explicit probe does not prove the -# daemon down (nm_daemon_probe_down): the pipeline rebases the branch and +# bin/fm-nm-run-lib.sh) as long as an explicit probe has not ANSWERED that +# the daemon is down (nm_daemon_answered_down): the pipeline rebases the +# branch and # commits its fix rounds in its own checkout, so a live run's head # routinely differs from the local head, and reading an older run that # still matches the local head would report a working crew as failed - but @@ -62,8 +63,9 @@ # anchored by the row immediately before it having ended at exactly this # worktree's head (rule owned by fm_nm_runs_status_for_worktree in # bin/fm-nm-run-lib.sh). In the coarse runs-ledger fallback, and only -# when `axi status` answered ANOTHER branch's run, a newest same-branch -# row that is running or pending answers whatever its head. +# when `axi status` answered ANOTHER named branch's run, a newest +# same-branch row that is running or pending answers whatever its head, +# under the same answered-down rule as the terminal row below. # fm_nm_select_run in bin/fm-nm-run-lib.sh owns complete run selection # and ambiguity reporting. The selected run's id-addressed status must # agree on id, branch, and live/terminal class before attribution; @@ -604,6 +606,25 @@ nm_daemon_probe_down() { return 1 } +# 0 only when the probe ANSWERED and that answer was "down". Suppressing a LIVE +# record needs this stricter question: `not provably up` above is fail-closed, +# which is safe when it degrades a terminal record to unknown, but on a live +# record it would drop a working crew back to a possibly-stale status log every +# time the probe merely ran slow - the crew would flap between working and +# failed on probe latency alone. 124 is the bounded call's own did-not-answer +# code (both the timeout and perl arms of fm_nm_run_bounded use it), and proves +# nothing about the daemon. The no-timeout-tool return of 1 cannot reach here: +# without a timeout tool the `axi status` read above is empty too, so this whole +# block is skipped. +nm_daemon_answered_down() { + local rc=0 + fm_nm_run_checked "$WT" "$NM_TIMEOUT" daemon status >/dev/null || rc=$? + case "$rc" in + 0|124) return 1 ;; + *) return 0 ;; + esac +} + nm_ci_step_status() { local row rest row=$(printf '%s\n' "$RUN_OUT" | grep -E '^[[:space:]]*ci,[[:space:]]*"?(running|fixing)"?[[:space:]]*,' | head -1) @@ -748,7 +769,7 @@ if [ "$KIND" = ship ] && [ -n "$CREW_BRANCH" ] && command -v no-mistakes >/dev/n emit unknown run-step "selected run status disagrees with inventory; run ids: $candidate_ids" fi if nm_run_head_matches_worktree || fm_nm_run_is_pipeline_owned_active "$RUN_OUT" \ - || { fm_nm_run_is_executing "$RUN_OUT" && ! nm_daemon_probe_down; }; then + || { fm_nm_run_is_executing "$RUN_OUT" && ! nm_daemon_answered_down; }; then HAVE_RUN=1 elif [ -z "$(fm_nm_resolve_commit "$WT" "$(strip_quotes "$(nm_field head)")")" ]; then if fm_nm_run_is_active "$RUN_OUT" \ @@ -773,7 +794,7 @@ if [ "$KIND" = ship ] && [ -n "$CREW_BRANCH" ] && command -v no-mistakes >/dev/n # worktree moves off the run head. if [ -n "$run_branch" ] && [ "$run_branch" = "$CREW_BRANCH" ] \ && { nm_run_head_matches_worktree || fm_nm_run_is_pipeline_owned_active "$RUN_OUT" \ - || { fm_nm_run_is_executing "$RUN_OUT" && ! nm_daemon_probe_down; }; }; then + || { fm_nm_run_is_executing "$RUN_OUT" && ! nm_daemon_answered_down; }; }; then HAVE_RUN=1 # Without run ids, contradictory liveness cannot prove precedence. # A live replacement also needs an id-addressed status read: a bare @@ -803,8 +824,10 @@ if [ "$KIND" = ship ] && [ -n "$CREW_BRANCH" ] && command -v no-mistakes >/dev/n # `live-any-head` only for a foreign-branch answer: a same-branch run # that reached here is parked or terminal, and a bare live ledger row # can neither tell those apart nor license reusing that run's detail. - coarse_mode=live-any-head - [ "$run_branch" != "$CREW_BRANCH" ] || coarse_mode="" + coarse_mode="" + if [ -n "$run_branch" ] && [ "$run_branch" != "$CREW_BRANCH" ]; then + coarse_mode=live-any-head + fi COARSE_STATUS=$(fm_nm_runs_status_for_worktree "$WT" "$CREW_BRANCH" "$(nm_runs_list)" "" "$coarse_mode") if [ -n "$COARSE_STATUS" ]; then HAVE_RUN=1 @@ -834,7 +857,16 @@ if [ "$HAVE_RUN" = 1 ]; then # read above. The status event span remains independently available to the # supervisor through fm-classify-lib.sh's status_span_first_actionable. case "$COARSE_STATUS" in - pending|running) RUN_STATE=working; RUN_DETAIL="validating (background run)" ;; + pending|running) + # Same instrument as the failed row below: a live row the daemon has + # answered it is not running is unverified evidence, and a never + # finalized record is the weaker of the two, not the stronger. + if nm_daemon_answered_down; then + RUN_STATE=unknown + RUN_DETAIL="no-mistakes daemon unreachable; last ledger record $COARSE_STATUS - unverified" + else + RUN_STATE=working; RUN_DETAIL="validating (background run)" + fi ;; completed) RUN_STATE="done"; RUN_DETAIL="run completed" ;; failed) # The ledger row is terminal but the coarse path has no steps table diff --git a/tests/fm-crew-state.test.sh b/tests/fm-crew-state.test.sh index 73d06af5bc8..862273b85da 100755 --- a/tests/fm-crew-state.test.sh +++ b/tests/fm-crew-state.test.sh @@ -105,6 +105,9 @@ case "${1:-}" in daemon) # FM_FAKE_DAEMON_DOWN: the explicit down-probe fails, as the real # `no-mistakes daemon status` does when the daemon is not running. + # FM_FAKE_DAEMON_TIMEOUT: the probe does not answer at all, which is what + # the bounded call reports as 124 when `timeout` kills a slow daemon status. + [ "${FM_FAKE_DAEMON_TIMEOUT:-0}" = 1 ] && exit 124 [ "${FM_FAKE_DAEMON_DOWN:-0}" = 1 ] && exit 1 printf '%s\n' 'daemon running (pid 4242)' exit 0 ;; @@ -298,6 +301,7 @@ reset_fakes() { FM_FAKE_HERDR_SHELL_PID=$$ FM_FAKE_CI_LOGS="" FM_FAKE_DAEMON_DOWN=0 + FM_FAKE_DAEMON_TIMEOUT=0 FM_FAKE_PR_STATE=MERGED FM_FAKE_PR_MERGED=true FM_FAKE_PR_READ_FAIL=0 @@ -309,7 +313,7 @@ reset_fakes() { unset FM_FAKE_PR_47_STATE FM_FAKE_PR_47_MERGED FM_FAKE_PR_48_STATE FM_FAKE_PR_48_MERGED 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 FM_FAKE_TMUX_UNREADABLE export FM_FAKE_HERDR_BUSY FM_FAKE_HERDR_MISSING FM_FAKE_HERDR_READ_FAIL FM_FAKE_HERDR_HUSK FM_FAKE_HERDR_AGENT_STATUS FM_FAKE_HERDR_PROCESS FM_FAKE_HERDR_SHELL_PID FM_FAKE_CI_LOGS - export FM_FAKE_DAEMON_DOWN FM_FAKE_AXI_HOME + export FM_FAKE_DAEMON_DOWN FM_FAKE_DAEMON_TIMEOUT FM_FAKE_AXI_HOME export FM_FAKE_AXI_HOME_ERROR FM_FAKE_AXI_STATUS_RUN_ERROR FM_FAKE_AXI_STATUS_ERROR export FM_FAKE_PR_STATE FM_FAKE_PR_MERGED FM_FAKE_PR_READ_FAIL FM_FAKE_PR_READ_LOG FM_FAKE_PR_STATE_AXI export FM_FAKE_GLAB_STATE FM_FAKE_GLAB_READ_FAIL FM_FAKE_GLAB_READ_LOG @@ -3552,6 +3556,79 @@ branch_sync: pass "a live record at a diverged head binds while the daemon answers" } +# A probe that does not ANSWER proves nothing about the daemon, so it must not +# suppress a live rebased run: otherwise a slow `daemon status` on a busy fleet +# drops the crew back to a stale `failed:` log line, and the crew flaps between +# working and failed on probe latency alone. +test_unanswered_daemon_probe_does_not_suppress_live_run() { + reset_fakes + local d rebased out; d=$(new_case probe-timeout) + make_repo_on_branch "$d/wt" fm/feat-probeto + rebased=$(make_rebased_head "$d/wt") + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-probeto.meta" "window=fm:fm-feat-probeto" "worktree=$d/wt" "kind=ship" "harness=claude" + printf 'failed: earlier run failed\n' > "$d/state/feat-probeto.status" + FM_FAKE_RUN_HEAD=$rebased + FM_FAKE_AXI_STATUS="$(run_running fm/feat-probeto) +branch_sync: + state: synced" + FM_FAKE_RUNS_LIST="" + FM_FAKE_DAEMON_TIMEOUT=1 + FM_FAKE_BUSY=0 + arm_idle_record "$d/state" feat-probeto + out=$(run_crew_state "$d" feat-probeto) + assert_contains "$out" "source: run-step" "an unanswered probe must not unbind the live run" + assert_contains "$out" "state: working" "the live rebased run still reads working" + assert_not_contains "$out" "state: failed" "the stale failed event must not answer on probe latency" + pass "an unanswered daemon probe leaves a live rebased run bound" +} + +# The coarse live row is evidence from the same instrument as the coarse failed +# row, so an answered-down daemon must unverify it the same way. +test_coarse_live_row_with_daemon_down_is_unverified() { + reset_fakes + local d rebased out; d=$(new_case coarse-live-daemon-down) + make_repo_on_branch "$d/wt" fm/feat-cldd + rebased=$(make_rebased_head "$d/wt") + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-cldd.meta" "window=fm:fm-feat-cldd" "worktree=$d/wt" "kind=ship" "harness=claude" + printf 'working: validating\n' > "$d/state/feat-cldd.status" + FM_FAKE_AXI_STATUS="$(run_running fm/other-crew)" + FM_FAKE_RUNS_LIST="$(cat </dev/null + fm_write_meta "$d/state/feat-bl.meta" "window=fm:fm-feat-bl" "worktree=$d/wt" "kind=ship" "harness=claude" + printf 'working: implementing\n' > "$d/state/feat-bl.status" + FM_FAKE_AXI_STATUS="$(run_running fm/feat-bl | grep -v '^ branch:')" + FM_FAKE_RUNS_LIST=" running fm/feat-bl f0f0f0f0 2026-08-27 13:53" + FM_FAKE_BUSY=0 + arm_idle_record "$d/state" feat-bl + out=$(run_crew_state "$d" feat-bl) + assert_not_contains "$out" "source: run-step" "a branchless answer must not license the coarse live-any-head route" + assert_contains "$out" "source: status-log" "the status log answers instead" + pass "a branchless axi answer keeps the coarse route strict" +} + # A coarse ledger row keeps a PARKED run's status word at `running` # (tests/captures/no-mistakes-v1.70.1/parked.toon), so it is equally consistent # with the gate still being open and must never claim the crew's own @@ -3940,6 +4017,9 @@ test_live_rebased_run_reads_working_for_every_executing_status test_legacy_live_rebased_run_is_authoritative test_live_record_at_diverged_head_needs_a_live_daemon test_live_record_at_diverged_head_binds_while_daemon_answers +test_unanswered_daemon_probe_does_not_suppress_live_run +test_coarse_live_row_with_daemon_down_is_unverified +test_branchless_status_does_not_enable_live_any_head test_coarse_live_row_does_not_claim_gate_superseded test_coarse_live_rebased_row_is_authoritative test_terminal_rebased_run_is_not_attributed From 04dcf951e9c63b5f3922cf8c053342696c2ce2ae Mon Sep 17 00:00:00 2001 From: rovermike Date: Sat, 19 Sep 2026 11:18:45 -0400 Subject: [PATCH 07/19] no-mistakes(review): extend daemon guard to anchored continuation routes --- bin/fm-crew-state.sh | 38 +++---- tests/fm-crew-state.test.sh | 191 +++++++++++++++++++++++++++++++++++- 2 files changed, 206 insertions(+), 23 deletions(-) diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 3e18029841c..b1067c0cd80 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -64,8 +64,10 @@ # worktree's head (rule owned by fm_nm_runs_status_for_worktree in # bin/fm-nm-run-lib.sh). In the coarse runs-ledger fallback, and only # when `axi status` answered ANOTHER named branch's run, a newest -# same-branch row that is running or pending answers whatever its head, -# under the same answered-down rule as the terminal row below. +# same-branch row that is running or pending answers whatever its head - +# but, like every other head-free route, only while the daemon has not +# answered that it is down, so a live ledger row never binds on a dead +# instrument and the crew's own status log keeps the answer. # fm_nm_select_run in bin/fm-nm-run-lib.sh owns complete run selection # and ambiguity reporting. The selected run's id-addressed status must # agree on id, branch, and live/terminal class before attribution; @@ -618,11 +620,14 @@ nm_daemon_probe_down() { # block is skipped. nm_daemon_answered_down() { local rc=0 - fm_nm_run_checked "$WT" "$NM_TIMEOUT" daemon status >/dev/null || rc=$? - case "$rc" in - 0|124) return 1 ;; - *) return 0 ;; - esac + if [ -z "$NM_DAEMON_ANSWER" ]; then + fm_nm_run_checked "$WT" "$NM_TIMEOUT" daemon status >/dev/null || rc=$? + case "$rc" in + 0|124) NM_DAEMON_ANSWER=other ;; + *) NM_DAEMON_ANSWER=down ;; + esac + fi + [ "$NM_DAEMON_ANSWER" = down ] } nm_ci_step_status() { @@ -724,6 +729,7 @@ HAVE_RUN=0 # word came back from the runs-list fallback, so the run-step block below skips # the TOON field parsing entirely for this crew. RUN_SOURCE=full +NM_DAEMON_ANSWER="" COARSE_STATUS="" SELECTED_RUN_ID="" # Scouts and secondmates never drive a no-mistakes validation of their own @@ -773,6 +779,7 @@ if [ "$KIND" = ship ] && [ -n "$CREW_BRANCH" ] && command -v no-mistakes >/dev/n HAVE_RUN=1 elif [ -z "$(fm_nm_resolve_commit "$WT" "$(strip_quotes "$(nm_field head)")")" ]; then if fm_nm_run_is_active "$RUN_OUT" \ + && ! nm_daemon_answered_down \ && [ "$(fm_nm_runs_status_for_worktree "$WT" "$CREW_BRANCH" "$(nm_runs_list)" "$(strip_quotes "$(nm_field head)")")" = running ]; then HAVE_RUN=1 else @@ -829,7 +836,8 @@ if [ "$KIND" = ship ] && [ -n "$CREW_BRANCH" ] && command -v no-mistakes >/dev/n coarse_mode=live-any-head fi COARSE_STATUS=$(fm_nm_runs_status_for_worktree "$WT" "$CREW_BRANCH" "$(nm_runs_list)" "" "$coarse_mode") - if [ -n "$COARSE_STATUS" ]; then + if [ -n "$COARSE_STATUS" ] \ + && { [ "$(fm_nm_run_status_class "$COARSE_STATUS")" != live ] || ! nm_daemon_answered_down; }; then HAVE_RUN=1 # A branch-matching answer the strict rule rejected is this branch's # own current run once the ledger proves the pipeline-owned @@ -857,16 +865,7 @@ if [ "$HAVE_RUN" = 1 ]; then # read above. The status event span remains independently available to the # supervisor through fm-classify-lib.sh's status_span_first_actionable. case "$COARSE_STATUS" in - pending|running) - # Same instrument as the failed row below: a live row the daemon has - # answered it is not running is unverified evidence, and a never - # finalized record is the weaker of the two, not the stronger. - if nm_daemon_answered_down; then - RUN_STATE=unknown - RUN_DETAIL="no-mistakes daemon unreachable; last ledger record $COARSE_STATUS - unverified" - else - RUN_STATE=working; RUN_DETAIL="validating (background run)" - fi ;; + pending|running) RUN_STATE=working; RUN_DETAIL="validating (background run)" ;; completed) RUN_STATE="done"; RUN_DETAIL="run completed" ;; failed) # The ledger row is terminal but the coarse path has no steps table @@ -988,7 +987,8 @@ if [ "$HAVE_RUN" = 1 ]; then && log_reports_daemon_socket_down "$LOG_LATEST"; then emit blocked status-log "$(status_line_note "$LOG_LATEST")${SEP}daemon socket down despite attributed run record" fi - if [ "$RUN_STATE" != parked ]; then + if [ "$RUN_STATE" != parked ] \ + && ! { [ "$RUN_SOURCE" = coarse ] && [ "$RUN_STATE" = unknown ]; }; then if [ "$RUN_STATE" = working ]; then if [ "$RUN_SOURCE" = coarse ]; then # The runs ledger keeps a parked run's status word at `running` diff --git a/tests/fm-crew-state.test.sh b/tests/fm-crew-state.test.sh index 862273b85da..bcb354f8f2e 100755 --- a/tests/fm-crew-state.test.sh +++ b/tests/fm-crew-state.test.sh @@ -3556,6 +3556,183 @@ branch_sync: pass "a live record at a diverged head binds while the daemon answers" } +# The daemon rule must hold for the head shape the module calls ROUTINE: a run +# head the pipeline committed in its own checkout, which this copy never +# fetched. That binds through the ledger's anchored-continuation rule, which +# proves IDENTITY (the previous row ended at exactly this worktree's head) but +# not LIVENESS - the ledger is written by the same daemon, so its `running` row +# goes stale exactly as the axi record does. +test_anchored_continuation_still_needs_a_live_daemon() { + reset_fakes + local d local_short out; d=$(new_case anchored-daemon-down) + make_repo_on_branch "$d/wt" fm/feat-anchor + local_short=$(git -C "$d/wt" rev-parse --short=8 HEAD) + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-anchor.meta" "window=fm:fm-feat-anchor" "worktree=$d/wt" "kind=ship" "harness=claude" + printf 'working: validating\n' > "$d/state/feat-anchor.status" + FM_FAKE_RUN_HEAD=f0f0f0f0 + FM_FAKE_AXI_STATUS="$(run_running fm/feat-anchor) +branch_sync: + state: synced" + FM_FAKE_RUNS_LIST="$(cat </dev/null + fm_write_meta "$d/state/feat-anchorup.meta" "window=fm:fm-feat-anchorup" "worktree=$d/wt" "kind=ship" "harness=claude" + printf 'working: validating\n' > "$d/state/feat-anchorup.status" + FM_FAKE_RUN_HEAD=f0f0f0f0 + FM_FAKE_AXI_STATUS="$(run_running fm/feat-anchorup) +branch_sync: + state: synced" + FM_FAKE_RUNS_LIST="$(cat </dev/null + fm_write_meta "$d/state/feat-cus.meta" "window=fm:fm-feat-cus" "worktree=$d/wt" "kind=ship" "harness=claude" + printf 'needs-decision: approve the schema change\n' > "$d/state/feat-cus.status" + FM_FAKE_AXI_STATUS="$(run_running fm/other-crew)" + FM_FAKE_RUNS_LIST="$(cat </dev/null + fm_write_meta "$d/state/selanchor.meta" "window=fm:fm-selanchor" "worktree=$d/wt" "kind=ship" "harness=claude" + printf 'working: implementing\n' > "$d/state/selanchor.status" + FM_FAKE_RUN_HEAD="$h2" + FM_FAKE_AXI_HOME="count: 1 of 1 total +runs[1]{id,branch,status,head,pr}: + \"01RUN\",fm/feat-selanchor,running,$h2,\"\"" + FM_FAKE_AXI_STATUS="$(run_running fm/feat-selanchor)" + FM_FAKE_AXI_STATUS_RUN="$FM_FAKE_AXI_STATUS" + FM_FAKE_RUNS_LIST="$(cat </dev/null + fm_write_meta "$d/state/selanchorup.meta" "window=fm:fm-selanchorup" "worktree=$d/wt" "kind=ship" "harness=claude" + printf 'working: implementing\n' > "$d/state/selanchorup.status" + FM_FAKE_RUN_HEAD="$h2" + FM_FAKE_AXI_HOME="count: 1 of 1 total +runs[1]{id,branch,status,head,pr}: + \"01RUN\",fm/feat-selanchorup,running,$h2,\"\"" + FM_FAKE_AXI_STATUS="$(run_running fm/feat-selanchorup)" + FM_FAKE_AXI_STATUS_RUN="$FM_FAKE_AXI_STATUS" + FM_FAKE_RUNS_LIST="$(cat </dev/null + fm_write_meta "$d/state/feat-cfs.meta" "window=fm:fm-feat-cfs" "worktree=$d/wt" "kind=ship" "harness=claude" + printf 'needs-decision: approve the schema change\n' > "$d/state/feat-cfs.status" + FM_FAKE_AXI_STATUS="$(run_running fm/other-crew)" + FM_FAKE_RUNS_LIST="$(cat </dev/null fm_write_meta "$d/state/feat-cldd.meta" "window=fm:fm-feat-cldd" "worktree=$d/wt" "kind=ship" "harness=claude" - printf 'working: validating\n' > "$d/state/feat-cldd.status" + printf 'working: implementing\n' > "$d/state/feat-cldd.status" FM_FAKE_AXI_STATUS="$(run_running fm/other-crew)" FM_FAKE_RUNS_LIST="$(cat < Date: Sat, 19 Sep 2026 13:14:59 -0400 Subject: [PATCH 08/19] no-mistakes(review): delete live-any-head; restore dead-daemon verdict --- bin/fm-crew-state.sh | 55 ++++++++------ bin/fm-nm-run-lib.sh | 33 +++++---- tests/fm-crew-state.test.sh | 142 +++++++++++++++++++++--------------- 3 files changed, 134 insertions(+), 96 deletions(-) diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index b1067c0cd80..1e97f9b0962 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -37,7 +37,9 @@ # active or terminal (from `axi status`, or the coarse `no-mistakes runs` # fallback)? Branch name alone is not enough: a historical run on a reused # branch whose head was rewritten or diverged must not be attributed. -# A run EXECUTING on this crew's branch (pending, running, fixing, or ci) +# A run EXECUTING on this crew's branch (pending or running through the +# overview-backed route; also fixing or ci on the legacy bare-status +# surface, whose detail object carries those words) # is authoritative REGARDLESS of head (fm_nm_run_is_executing in # bin/fm-nm-run-lib.sh) as long as an explicit probe has not ANSWERED that # the daemon is down (nm_daemon_answered_down): the pipeline rebases the @@ -62,17 +64,18 @@ # pipeline-owned continuation - the branch's ACTIVE newest ledger row, # anchored by the row immediately before it having ended at exactly this # worktree's head (rule owned by fm_nm_runs_status_for_worktree in -# bin/fm-nm-run-lib.sh). In the coarse runs-ledger fallback, and only -# when `axi status` answered ANOTHER named branch's run, a newest -# same-branch row that is running or pending answers whatever its head - -# but, like every other head-free route, only while the daemon has not -# answered that it is down, so a live ledger row never binds on a dead -# instrument and the crew's own status log keeps the answer. +# bin/fm-nm-run-lib.sh). The coarse runs-ledger fallback has NO +# branch-name-only acceptance: an executing `axi status` record is the one +# live bind, so a ledger row that cannot be tied to this worktree's head +# never answers on branch name alone. A live coarse answer whose daemon has +# ANSWERED down reads unknown and names the dead instrument, exactly as the +# terminal record below does. # fm_nm_select_run in bin/fm-nm-run-lib.sh owns complete run selection # and ambiguity reporting. The selected run's id-addressed status must # agree on id, branch, and live/terminal class before attribution; # disagreement reports unknown with available candidate ids. -# The run-step is AUTHORITATIVE: running/fixing -> working, ci -> working, +# The run-step is AUTHORITATIVE: running/fixing -> working, ci -> working +# (the id-addressed detail read carries step words the overview does not), # awaiting_approval/fix_review -> parked (with gate findings), terminal # passed/checks-passed -> done, failed/cancelled -> failed. EXCEPT: while # the active step is ci, `axi status` alone cannot tell "still waiting on @@ -604,6 +607,7 @@ nm_reclassify_failed_run_as_held_green() { # refused socket, timeout, non-zero answer - means the daemon is not provably # up, which is the only fact the coarse fallback needs. nm_daemon_probe_down() { + [ "$NM_DAEMON_ANSWER" = down ] && return 0 fm_nm_run_checked "$WT" "$NM_TIMEOUT" daemon status >/dev/null || return 0 return 1 } @@ -691,8 +695,8 @@ nm_ci_checks_state() { # active run reliably gets that run answered, even under concurrent load), or # it names this branch's run but the strict head rule rejected it - a run that # is parked or terminal, since an executing same-branch run binds before this -# fallback is reached, so the ledger resolves it STRICTLY and only a -# foreign-branch answer gets live-any-head. The real +# fallback is reached. The ledger resolves every answer STRICTLY: it never +# accepts a row on branch name alone. The real # run-listing command is the top-level `no-mistakes runs` (the `axi` surface # has no runs-listing subcommand; tests/fm-crew-state.test.sh owns the # 2026-07-02 dead-code incident history this fallback replaced). @@ -828,23 +832,20 @@ if [ "$KIND" = ship ] && [ -n "$CREW_BRANCH" ] && command -v no-mistakes >/dev/n # `[ -n "$RUN_OUT" ]`: an empty/timed-out 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. - # `live-any-head` only for a foreign-branch answer: a same-branch run - # that reached here is parked or terminal, and a bare live ledger row - # can neither tell those apart nor license reusing that run's detail. - coarse_mode="" - if [ -n "$run_branch" ] && [ "$run_branch" != "$CREW_BRANCH" ]; then - coarse_mode=live-any-head - fi - COARSE_STATUS=$(fm_nm_runs_status_for_worktree "$WT" "$CREW_BRANCH" "$(nm_runs_list)" "" "$coarse_mode") - if [ -n "$COARSE_STATUS" ] \ - && { [ "$(fm_nm_run_status_class "$COARSE_STATUS")" != live ] || ! nm_daemon_answered_down; }; then + COARSE_STATUS=$(fm_nm_runs_status_for_worktree "$WT" "$CREW_BRANCH" "$(nm_runs_list)") + if [ -n "$COARSE_STATUS" ]; then HAVE_RUN=1 # A branch-matching answer the strict rule rejected is this branch's # own current run once the ledger proves the pipeline-owned # continuation, so its axi TOON is the authoritative run detail # (RUN_SOURCE stays full); only a foreign-branch answer leaves - # coarse status-word detail. + # coarse status-word detail. A live answer with the daemon answered + # down keeps the coarse status-word detail either way: the dead + # instrument has to be named, and the full TOON would print `working`. [ "$run_branch" = "$CREW_BRANCH" ] || RUN_SOURCE=coarse + if [ "$(fm_nm_run_status_class "$COARSE_STATUS")" = live ] && nm_daemon_answered_down; then + RUN_SOURCE=coarse + fi fi fi fi @@ -865,7 +866,17 @@ if [ "$HAVE_RUN" = 1 ]; then # read above. The status event span remains independently available to the # supervisor through fm-classify-lib.sh's status_span_first_actionable. case "$COARSE_STATUS" in - pending|running) RUN_STATE=working; RUN_DETAIL="validating (background run)" ;; + pending|running) + # Same instrument as the failed row below, and the weaker record of the + # two: never finalized. With the daemon answered down nothing is + # executing this row, and the verdict has to say so rather than let a + # dead instrument read as work in progress. + if nm_daemon_answered_down; then + RUN_STATE=unknown + RUN_DETAIL="no-mistakes daemon unreachable; last ledger record $COARSE_STATUS - unverified" + else + RUN_STATE=working; RUN_DETAIL="validating (background run)" + fi ;; completed) RUN_STATE="done"; RUN_DETAIL="run completed" ;; failed) # The ledger row is terminal but the coarse path has no steps table diff --git a/bin/fm-nm-run-lib.sh b/bin/fm-nm-run-lib.sh index ac54b18747a..4602909df24 100644 --- a/bin/fm-nm-run-lib.sh +++ b/bin/fm-nm-run-lib.sh @@ -4,7 +4,8 @@ # ONE owner for the no-mistakes run-attribution primitives used by # fm-crew-state.sh (read-only current-state reporting) and fm-teardown.sh # (pre-teardown run abort, see its "Fix 1" header comment). Crew-state binds -# an EXECUTING run (pending, running, fixing, or ci) on the task's branch +# an EXECUTING run (pending or running; also fixing or ci on the legacy +# bare-status surface) on the task's branch # regardless of head (fm_nm_run_is_executing); every other run still needs # strict branch-and-head identity. Both callers then recognize a provable # pipeline-owned continuation through fm_nm_runs_status_for_worktree below: @@ -346,6 +347,16 @@ fm_nm_run_is_parked() { # # daemon died still saying `running`. The head-free route through it is the # caller's to license, and fm-crew-state.sh pairs it with an explicit # daemon-down probe for exactly that reason. +# The accepted words span BOTH surfaces, which do not share a vocabulary. The +# overview table fm_nm_select_run validates carries only +# pending|running|completed|failed|cancelled (:196), so on the modern +# selected-run route only pending and running ever reach here. The id-addressed +# `axi status` DETAIL object also reports `fixing` and `ci`, which +# fm-crew-state.sh has always classified at its full path, and on the legacy +# bare-status route (no overview table, so the selector answers `unavailable` +# and never validates a word) those two reach here as the crew's own live run. +# Dropping them would report a fix round or a ci wait on a legacy surface as +# idle, which is the misreport this predicate exists to prevent. fm_nm_run_is_executing() { # fm_nm_run_is_active "$1" || return 1 fm_nm_run_is_parked "$1" && return 1 @@ -380,17 +391,14 @@ fm_nm_run_is_executing() { # # ancestor, a terminal unresolvable row) prints nothing, so branch-name # coincidence, arbitrary remote state, and other tasks' runs never match. # An older live row never displaces a newer terminal result. -# When optional $5 is `live-any-head`, a newest same-branch row that is ACTIVE -# (running or pending) is the answer whatever its head: the pipeline rebases the -# branch, so a live row's head need not resolve to or descend from the worktree -# head, and the older row that does match the local head is history. Only the -# read-only current-state report passes it, and only when `axi status` answered -# ANOTHER branch's run: a same-branch run the strict head rule rejected is -# parked or terminal, and this coarse row cannot tell those apart. Teardown -# never passes it. +# There is no branch-name-only acceptance here: a live row whose head this copy +# cannot tie to the worktree is not this worktree's run just because the branch +# name matches. The one live bind is the EXECUTING record on the `axi status` +# route (fm_nm_run_is_executing above), which the caller pairs with its own +# liveness evidence. # Read-only: git reads resolve objects in place; custody never changes. -fm_nm_runs_status_for_worktree() { # [expected-head] [live-any-head] - local wt=$1 branch=$2 list=$3 expected_head=${4:-} live_any_head=${5:-} +fm_nm_runs_status_for_worktree() { # [expected-head] + local wt=$1 branch=$2 list=$3 expected_head=${4:-} local local_full row_full row st br sha day clock pr extra year_num month_num day_num max_day pending_st='' local decided='' local_full=$(git -C "$wt" rev-parse HEAD 2>/dev/null) || return 0 @@ -442,9 +450,6 @@ fm_nm_runs_status_for_worktree() { # [ex *) case "$sha" in "$expected_head"*) ;; *) break ;; esac ;; esac fi - if [ "$live_any_head" = live-any-head ]; then - case "$st" in running|pending) decided=$st; break ;; esac - fi row_full=$(fm_nm_resolve_commit "$wt" "$sha") if [ -n "$row_full" ]; then if fm_nm_head_matches_worktree "$wt" "$sha"; then diff --git a/tests/fm-crew-state.test.sh b/tests/fm-crew-state.test.sh index bcb354f8f2e..cb69835db18 100755 --- a/tests/fm-crew-state.test.sh +++ b/tests/fm-crew-state.test.sh @@ -2703,12 +2703,12 @@ EOF pass "coarse scan anchors the unresolvable active row instead of falling to an older one" } -# The newest same-branch ledger row is ACTIVE at an unresolvable head and the -# row before it sits at an OLDER local commit, so the ledger anchor proves -# nothing. A running row on the task's branch is authoritative regardless of -# head (the pipeline rebases and commits in its own checkout), so it still binds -# through the coarse list and the older failed row never surfaces. -test_coarse_live_row_binds_without_head_anchor() { +# Coarse negative control: the anchor must end at EXACTLY this worktree's +# head. The newest same-branch row is active at an unresolvable head, but the +# row immediately before it sits at an OLDER local commit, so the ledger +# proves nothing - unknown attribution stops the scan, never falls to the +# older failed row, and the busy pane answers instead. +test_coarse_mismatched_anchor_falls_to_pane_not_older_row() { reset_fakes local d old_short; d=$(new_case f10-coarse-no-anchor) make_repo_on_branch "$d/wt" fm/feat-f10g @@ -2723,14 +2723,16 @@ test_coarse_live_row_binds_without_head_anchor() { failed fm/feat-f10g ${old_short} 2026-08-27 12:09 EOF )" - FM_FAKE_BUSY=0 - arm_idle_record "$d/state" feat-f10g + FM_FAKE_BUSY=1 + local gen; gen=$("$ROOT/bin/fm-busy-event.sh" arm "$d/state" feat-f10g) + "$ROOT/bin/fm-busy-event.sh" apply "$d/state" feat-f10g busy --gen "$gen" \ + --source claude-hook --event user-prompt-submit local out; out=$(run_crew_state "$d" feat-f10g) - assert_not_contains "$out" "state: failed" "a live newest row must not fall to the older failed row" - assert_contains "$out" "source: run-step" "the live newest row binds through the runs list without an anchor" - assert_contains "$out" "state: working" "the live run reads working" - assert_contains "$out" "validating (background run)" "coarse resolution keeps coarse run detail" - pass "coarse scan binds the newest live row regardless of head" + assert_not_contains "$out" "state: failed" "a mismatched anchor must not fall to the older failed row" + assert_not_contains "$out" "source: run-step" "unknown attribution must not bind a run" + assert_contains "$out" "state: working" "the busy crew still reads working through the pane fallback" + assert_contains "$out" "source: pane" "without an exact anchor the pane answers, not the runs rows" + pass "coarse scan with a mismatched anchor stays unknown and lets the pane answer" } # The same ledger with the newest row TERMINAL keeps the strict rule: a finished @@ -3458,18 +3460,21 @@ branch_sync: pass 'a live rebased run beats an older failed run at the local head' } -# The same live run reads working for every executing status word. +# The same live run reads working for every EXECUTING status word the CLI can +# actually deliver here. `fm_nm_select_run` validates the overview status column +# against pending|running|completed|failed|cancelled, so those are the only live +# words that reach the predicate; the overview and the id-addressed detail read +# the same runs.status column, so the fixture carries one word in BOTH surfaces. test_live_rebased_run_reads_working_for_every_executing_status() { local status d rebased out - for status in pending running fixing ci; do - make_competing_runs_case "live-rebased-$status" running failed + for status in pending running; do + make_competing_runs_case "live-rebased-$status" "$status" failed d=$TMP_ROOT/live-rebased-$status rebased=$(make_rebased_head "$d/wt") FM_FAKE_AXI_HOME=$(printf '%s\n' "$FM_FAKE_AXI_HOME" | sed "/01NEW/s/,[a-f0-9]*,\"\"\$/,$rebased,\"\"/") FM_FAKE_RUN_HEAD=$rebased FM_FAKE_AXI_STATUS="$(run_running fm/competing | sed "s/01RUN/01NEW/; s/status: running/status: $status/")" FM_FAKE_AXI_STATUS_RUN=$FM_FAKE_AXI_STATUS - FM_FAKE_CI_LOGS="CI checks running" out=$(run_crew_state "$d" competing) assert_contains "$out" 'state: working' "$status run with a rebased head reads working" assert_contains "$out" 'source: run-step' "$status run with a rebased head is run-step sourced" @@ -3478,6 +3483,35 @@ test_live_rebased_run_reads_working_for_every_executing_status() { done } +# The LEGACY bare-status surface carries run-level `fixing` and `ci`, which the +# overview table's vocabulary does not include. The selector never validates a +# word there (it answers `unavailable` with no table), so those runs are the +# crew's own live run and must bind at a rebased head like any other. +test_legacy_surface_binds_fixing_and_ci_at_a_rebased_head() { + local status d rebased out + for status in fixing ci; do + reset_fakes + d=$(new_case "legacy-live-$status") + make_repo_on_branch "$d/wt" fm/feat-legacylive + rebased=$(make_rebased_head "$d/wt") + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-legacylive.meta" "window=fm:fm-feat-legacylive" "worktree=$d/wt" "kind=ship" "harness=claude" + printf 'failed: earlier stage run\n' > "$d/state/feat-legacylive.status" + FM_FAKE_RUN_HEAD=$rebased + FM_FAKE_AXI_STATUS="$(run_running fm/feat-legacylive | sed "s/status: running/status: $status/") +branch_sync: + state: synced" + FM_FAKE_RUNS_LIST="" + FM_FAKE_BUSY=0 + arm_idle_record "$d/state" feat-legacylive + out=$(run_crew_state "$d" feat-legacylive) + assert_contains "$out" "source: run-step" "a legacy $status run at a rebased head binds" + assert_contains "$out" "state: working" "a legacy $status run reads working" + assert_not_contains "$out" "state: failed" "the stale failed event must not answer for a live $status run" + pass "legacy surface binds a $status run at a rebased head" + done +} + # Legacy CLI surface (no overview table): the bare `axi status` run is live on # this branch with a rebased head, while the runs ledger still holds an older # failed row at the local head. @@ -3583,8 +3617,9 @@ EOF FM_FAKE_BUSY=0 arm_idle_record "$d/state" feat-anchor out=$(run_crew_state "$d" feat-anchor) - assert_not_contains "$out" "source: run-step" "an anchored live row must not bind with the daemon answering down" - assert_contains "$out" "source: status-log" "the status log answers for the unbound anchored row" + assert_contains "$out" "state: unknown" "an anchored live row must not read as work with the daemon answering down" + assert_contains "$out" "daemon unreachable" "the dead instrument is named rather than dropped silently" + assert_not_contains "$out" "state: working" "a dead instrument never reads as work in progress" pass "the anchored continuation route obeys the daemon rule too" } @@ -3761,49 +3796,31 @@ branch_sync: } # The coarse live row is evidence from the same instrument as the coarse failed -# row, so an answered-down daemon must unverify it the same way. +# row, so an answered-down daemon must unverify it the same way - and say so. +# The row sits at this worktree's own head, so identity is proven and only +# liveness is in question. test_coarse_live_row_with_daemon_down_is_unverified() { reset_fakes - local d rebased out; d=$(new_case coarse-live-daemon-down) + local d local_short out; d=$(new_case coarse-live-daemon-down) make_repo_on_branch "$d/wt" fm/feat-cldd - rebased=$(make_rebased_head "$d/wt") + local_short=$(git -C "$d/wt" rev-parse --short=8 HEAD) make_fakebin "$d" >/dev/null fm_write_meta "$d/state/feat-cldd.meta" "window=fm:fm-feat-cldd" "worktree=$d/wt" "kind=ship" "harness=claude" printf 'working: implementing\n' > "$d/state/feat-cldd.status" FM_FAKE_AXI_STATUS="$(run_running fm/other-crew)" FM_FAKE_RUNS_LIST="$(cat </dev/null - fm_write_meta "$d/state/feat-bl.meta" "window=fm:fm-feat-bl" "worktree=$d/wt" "kind=ship" "harness=claude" - printf 'working: implementing\n' > "$d/state/feat-bl.status" - FM_FAKE_AXI_STATUS="$(run_running fm/feat-bl | grep -v '^ branch:')" - FM_FAKE_RUNS_LIST=" running fm/feat-bl f0f0f0f0 2026-08-27 13:53" - FM_FAKE_BUSY=0 - arm_idle_record "$d/state" feat-bl - out=$(run_crew_state "$d" feat-bl) - assert_not_contains "$out" "source: run-step" "a branchless answer must not license the coarse live-any-head route" - assert_contains "$out" "source: status-log" "the status log answers instead" - pass "a branchless axi answer keeps the coarse route strict" + assert_contains "$out" "state: unknown" "a live ledger row must not read as work with the daemon answering down" + assert_contains "$out" "daemon unreachable" "the dead instrument is named in the verdict" + assert_contains "$out" "unverified" "the record is reported unverified, not working" + pass "a coarse live row with the daemon down reads unverified" } # A coarse ledger row keeps a PARKED run's status word at `running` @@ -3812,16 +3829,16 @@ test_branchless_status_does_not_enable_live_any_head() { # needs-decision event was superseded. test_coarse_live_row_does_not_claim_gate_superseded() { reset_fakes - local d rebased out; d=$(new_case coarse-gate-signal) + local d local_short out; d=$(new_case coarse-gate-signal) make_repo_on_branch "$d/wt" fm/feat-cg - rebased=$(make_rebased_head "$d/wt") + local_short=$(git -C "$d/wt" rev-parse --short=8 HEAD) make_fakebin "$d" >/dev/null fm_write_meta "$d/state/feat-cg.meta" "window=fm:fm-feat-cg" "worktree=$d/wt" "kind=ship" "harness=claude" printf 'needs-decision: review gate has an ask-user finding\n' > "$d/state/feat-cg.status" FM_FAKE_AXI_STATUS="$(run_running fm/other-crew)" FM_FAKE_RUNS_LIST="$(cat </dev/null fm_write_meta "$d/state/feat-rebased2.meta" "window=fm:fm-feat-rebased2" "worktree=$d/wt" "kind=ship" "harness=claude" + printf 'working: implementing\n' > "$d/state/feat-rebased2.status" FM_FAKE_AXI_STATUS="$(run_running fm/other-crew)" FM_FAKE_RUNS_LIST="$(cat < Date: Sat, 19 Sep 2026 13:37:37 -0400 Subject: [PATCH 09/19] no-mistakes(review): keep parked gates parked; name dead daemon everywhere --- bin/fm-crew-state.sh | 21 ++++++++-- tests/fm-crew-state.test.sh | 83 ++++++++++++++++++++++++++++++++----- 2 files changed, 91 insertions(+), 13 deletions(-) diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 1e97f9b0962..32b453e01a6 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -67,9 +67,13 @@ # bin/fm-nm-run-lib.sh). The coarse runs-ledger fallback has NO # branch-name-only acceptance: an executing `axi status` record is the one # live bind, so a ledger row that cannot be tied to this worktree's head -# never answers on branch name alone. A live coarse answer whose daemon has +# never answers on branch name alone. An EXECUTING record whose daemon has # ANSWERED down reads unknown and names the dead instrument, exactly as the -# terminal record below does. +# terminal record below does, on every route - the head being resolvable, +# diverged or absent changes nothing about a dead instrument, and silently +# dropping to a possibly-stale status log would hide it. A run PARKED at a +# gate is exempt: an open decision stays open when the instrument dies, so +# it keeps its gate and findings. # fm_nm_select_run in bin/fm-nm-run-lib.sh owns complete run selection # and ambiguity reporting. The selected run's id-addressed status must # agree on id, branch, and live/terminal class before attribution; @@ -789,6 +793,8 @@ if [ "$KIND" = ship ] && [ -n "$CREW_BRANCH" ] && command -v no-mistakes >/dev/n else emit unknown run-step "selected run code identity unverified; run ids: $candidate_ids" fi + elif fm_nm_run_is_executing "$RUN_OUT" && nm_daemon_answered_down; then + emit unknown run-step "no-mistakes daemon unreachable; last run record $(strip_quotes "$(nm_field status)") - unverified" fi SELECTED_RUN_ID=$selected_id ;; @@ -843,9 +849,18 @@ if [ "$KIND" = ship ] && [ -n "$CREW_BRANCH" ] && command -v no-mistakes >/dev/n # down keeps the coarse status-word detail either way: the dead # instrument has to be named, and the full TOON would print `working`. [ "$run_branch" = "$CREW_BRANCH" ] || RUN_SOURCE=coarse - if [ "$(fm_nm_run_status_class "$COARSE_STATUS")" = live ] && nm_daemon_answered_down; then + # The ledger word alone cannot tell executing from waiting at a gate, + # so the run's own TOON decides: a PARKED run keeps its gate and + # findings whatever the daemon answers, because an open decision is + # still open when the instrument dies. + if ! fm_nm_run_is_parked "$RUN_OUT" \ + && [ "$(fm_nm_run_status_class "$COARSE_STATUS")" = live ] \ + && nm_daemon_answered_down; then RUN_SOURCE=coarse fi + elif [ "$run_branch" = "$CREW_BRANCH" ] && fm_nm_run_is_executing "$RUN_OUT" \ + && nm_daemon_answered_down; then + emit unknown run-step "no-mistakes daemon unreachable; last run record $(strip_quotes "$(nm_field status)") - unverified" fi fi fi diff --git a/tests/fm-crew-state.test.sh b/tests/fm-crew-state.test.sh index cb69835db18..3b3f1b058e8 100755 --- a/tests/fm-crew-state.test.sh +++ b/tests/fm-crew-state.test.sh @@ -3561,9 +3561,70 @@ branch_sync: FM_FAKE_BUSY=0 arm_idle_record "$d/state" feat-zombie out=$(run_crew_state "$d" feat-zombie) - assert_not_contains "$out" "source: run-step" "a live record at a diverged head must not answer while the daemon is provably down" - assert_contains "$out" "source: status-log" "the status log answers for the unbound record" - pass "a live record at a diverged head needs a reachable daemon to bind" + assert_contains "$out" "state: unknown" "a live record at a diverged head must not read as work with the daemon answering down" + assert_contains "$out" "daemon unreachable" "the dead instrument is named rather than dropped silently" + assert_not_contains "$out" "state: working" "a stale status log must not answer for a dead instrument" + pass "a live record at a diverged head reports the dead daemon" +} + +# A run PARKED at a gate keeps its gate and findings when the daemon dies. The +# ledger word stays `running` while a run waits (parked.toon), so classifying +# off the ledger would relabel an open decision as a dead live record and the +# findings would never reach the supervisor. +test_parked_gate_survives_a_dead_daemon() { + reset_fakes + local d local_short out; d=$(new_case parked-dead-daemon) + make_repo_on_branch "$d/wt" fm/feat-parkdd + local_short=$(git -C "$d/wt" rev-parse --short=8 HEAD) + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-parkdd.meta" "window=fm:fm-feat-parkdd" "worktree=$d/wt" "kind=ship" "harness=claude" + printf 'needs-decision: approve the schema change\n' > "$d/state/feat-parkdd.status" + FM_FAKE_RUN_HEAD=f0f0f0f0 + FM_FAKE_AXI_STATUS="$(run_parked fm/feat-parkdd) +branch_sync: + state: synced" + FM_FAKE_RUNS_LIST="$(cat </dev/null + fm_write_meta "$d/state/seldiv.meta" "window=fm:fm-seldiv" "worktree=$d/wt" "kind=ship" "harness=claude" + printf 'working: validating\n' > "$d/state/seldiv.status" + FM_FAKE_RUN_HEAD=$rebased + FM_FAKE_AXI_HOME="count: 1 of 1 total +runs[1]{id,branch,status,head,pr}: + \"01RUN\",fm/feat-seldiv,running,$rebased,\"\"" + FM_FAKE_AXI_STATUS="$(run_running fm/feat-seldiv)" + FM_FAKE_AXI_STATUS_RUN="$FM_FAKE_AXI_STATUS" + FM_FAKE_RUNS_LIST="" + FM_FAKE_DAEMON_DOWN=1 + FM_FAKE_BUSY=0 + arm_idle_record "$d/state" seldiv + out=$(run_crew_state "$d" seldiv) + assert_contains "$out" "state: unknown" "the selected diverged-head route must not read working with the daemon down" + assert_contains "$out" "daemon unreachable" "the dead instrument is named on the selected route too" + assert_not_contains "$out" "state: working" "a stale status log must not answer for a dead instrument" + pass "the selected-run diverged-head route reports the dead daemon" } # The head-free route still binds while the daemon answers: the daemon probe @@ -3655,26 +3716,26 @@ EOF # decision superseded. test_unverified_coarse_record_makes_no_supersede_claim() { reset_fakes - local d rebased out; d=$(new_case coarse-unknown-supersede) + local d local_short out; d=$(new_case coarse-unknown-supersede) make_repo_on_branch "$d/wt" fm/feat-cus - rebased=$(make_rebased_head "$d/wt") + local_short=$(git -C "$d/wt" rev-parse --short=8 HEAD) make_fakebin "$d" >/dev/null fm_write_meta "$d/state/feat-cus.meta" "window=fm:fm-feat-cus" "worktree=$d/wt" "kind=ship" "harness=claude" printf 'needs-decision: approve the schema change\n' > "$d/state/feat-cus.status" FM_FAKE_AXI_STATUS="$(run_running fm/other-crew)" FM_FAKE_RUNS_LIST="$(cat < Date: Sat, 19 Sep 2026 13:57:18 -0400 Subject: [PATCH 10/19] no-mistakes(review): set dead-daemon verdict instead of emitting early --- bin/fm-crew-state.sh | 53 +++++++++++++++++++------------- tests/fm-crew-state.test.sh | 60 +++++++++++++++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 23 deletions(-) diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 32b453e01a6..4f0173523c4 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -105,7 +105,11 @@ # agree, and are reported as parked. A `blocked:` line that reports a # refused or missing daemon socket remains blocked even if an attributed # run record is stale or terminal, for as long as that blocker is still the -# log's latest event. Other daemon, timeout, or unreachability +# log's latest event. The same holds for any open decision when the run +# record itself is UNVERIFIED (its daemon answered down): the crew saw its +# gate or blocker first hand, so needs-decision stays parked and blocked +# stays blocked, with the unverified record named as the reason. +# Other daemon, timeout, or unreachability # claims are superseded BECAUSE THE RUN IS ALIVE when the run is # running/fixing with recent reported activity: a killed or timed-out drive # call is not daemon death, so that claim is answered by steering the crew @@ -738,6 +742,7 @@ HAVE_RUN=0 # the TOON field parsing entirely for this crew. RUN_SOURCE=full NM_DAEMON_ANSWER="" +RUN_DEAD_DAEMON="" COARSE_STATUS="" SELECTED_RUN_ID="" # Scouts and secondmates never drive a no-mistakes validation of their own @@ -794,7 +799,8 @@ if [ "$KIND" = ship ] && [ -n "$CREW_BRANCH" ] && command -v no-mistakes >/dev/n emit unknown run-step "selected run code identity unverified; run ids: $candidate_ids" fi elif fm_nm_run_is_executing "$RUN_OUT" && nm_daemon_answered_down; then - emit unknown run-step "no-mistakes daemon unreachable; last run record $(strip_quotes "$(nm_field status)") - unverified" + HAVE_RUN=1 + RUN_DEAD_DAEMON="no-mistakes daemon unreachable; last run record $(strip_quotes "$(nm_field status)") - unverified; run ids: $candidate_ids" fi SELECTED_RUN_ID=$selected_id ;; @@ -850,17 +856,19 @@ if [ "$KIND" = ship ] && [ -n "$CREW_BRANCH" ] && command -v no-mistakes >/dev/n # instrument has to be named, and the full TOON would print `working`. [ "$run_branch" = "$CREW_BRANCH" ] || RUN_SOURCE=coarse # The ledger word alone cannot tell executing from waiting at a gate, - # so the run's own TOON decides: a PARKED run keeps its gate and - # findings whatever the daemon answers, because an open decision is - # still open when the instrument dies. - if ! fm_nm_run_is_parked "$RUN_OUT" \ - && [ "$(fm_nm_run_status_class "$COARSE_STATUS")" = live ] \ + # so the run's own TOON decides: a PARKED run of THIS crew keeps its + # gate and findings whatever the daemon answers, because an open + # decision is still open when the instrument dies. A foreign-branch + # answer carries no gate of ours to protect. + if [ "$(fm_nm_run_status_class "$COARSE_STATUS")" = live ] \ + && ! { [ "$run_branch" = "$CREW_BRANCH" ] && fm_nm_run_is_parked "$RUN_OUT"; } \ && nm_daemon_answered_down; then - RUN_SOURCE=coarse + RUN_DEAD_DAEMON="no-mistakes daemon unreachable; last ledger record $COARSE_STATUS - unverified; run id: $(strip_quotes "$(nm_field id)")" fi elif [ "$run_branch" = "$CREW_BRANCH" ] && fm_nm_run_is_executing "$RUN_OUT" \ && nm_daemon_answered_down; then - emit unknown run-step "no-mistakes daemon unreachable; last run record $(strip_quotes "$(nm_field status)") - unverified" + HAVE_RUN=1 + RUN_DEAD_DAEMON="no-mistakes daemon unreachable; last run record $(strip_quotes "$(nm_field status)") - unverified; run id: $(strip_quotes "$(nm_field id)")" fi fi fi @@ -875,23 +883,19 @@ if [ "$HAVE_RUN" = 1 ]; then CI_STEP_STATUS="" CI_LOG_STATE="" RUN_STATUS="" - if [ "$RUN_SOURCE" = coarse ]; then + if [ -n "$RUN_DEAD_DAEMON" ]; then + # ONE dead-instrument verdict for every route that reaches one. It is set, + # not emitted, so the status-log reconciliation below still runs: an + # unverified record must not silence the crew's own open decision. + RUN_STATE=unknown + RUN_DETAIL=$RUN_DEAD_DAEMON + elif [ "$RUN_SOURCE" = coarse ]; then # No step/gate detail is available from the plain runs list - only ever # working, done, failed, or unknown. Gate detail requires the identity-aware # read above. The status event span remains independently available to the # supervisor through fm-classify-lib.sh's status_span_first_actionable. case "$COARSE_STATUS" in - pending|running) - # Same instrument as the failed row below, and the weaker record of the - # two: never finalized. With the daemon answered down nothing is - # executing this row, and the verdict has to say so rather than let a - # dead instrument read as work in progress. - if nm_daemon_answered_down; then - RUN_STATE=unknown - RUN_DETAIL="no-mistakes daemon unreachable; last ledger record $COARSE_STATUS - unverified" - else - RUN_STATE=working; RUN_DETAIL="validating (background run)" - fi ;; + pending|running) RUN_STATE=working; RUN_DETAIL="validating (background run)" ;; completed) RUN_STATE="done"; RUN_DETAIL="run completed" ;; failed) # The ledger row is terminal but the coarse path has no steps table @@ -1013,6 +1017,13 @@ if [ "$HAVE_RUN" = 1 ]; then && log_reports_daemon_socket_down "$LOG_LATEST"; then emit blocked status-log "$(status_line_note "$LOG_LATEST")${SEP}daemon socket down despite attributed run record" fi + # An UNVERIFIED record cannot close an open decision. The crew observed + # its gate or its blocker first hand; a record the dead instrument left + # behind is the weaker witness, so the log answers and the unverified + # record is reported as the reason rather than replacing it. + if [ -n "$RUN_DEAD_DAEMON" ] && [ "$(map_log_state "$LOG_LINE")" != unknown ]; then + emit "$(map_log_state "$LOG_LINE")" status-log "$(status_line_note "$LOG_LINE")${SEP}$RUN_DEAD_DAEMON" + fi if [ "$RUN_STATE" != parked ] \ && ! { [ "$RUN_SOURCE" = coarse ] && [ "$RUN_STATE" = unknown ]; }; then if [ "$RUN_STATE" = working ]; then diff --git a/tests/fm-crew-state.test.sh b/tests/fm-crew-state.test.sh index 3b3f1b058e8..f8b4356a027 100755 --- a/tests/fm-crew-state.test.sh +++ b/tests/fm-crew-state.test.sh @@ -3563,6 +3563,7 @@ branch_sync: out=$(run_crew_state "$d" feat-zombie) assert_contains "$out" "state: unknown" "a live record at a diverged head must not read as work with the daemon answering down" assert_contains "$out" "daemon unreachable" "the dead instrument is named rather than dropped silently" + assert_contains "$out" "run id: 01RUN" "the verdict carries the run identity for a later --run read" assert_not_contains "$out" "state: working" "a stale status log must not answer for a dead instrument" pass "a live record at a diverged head reports the dead daemon" } @@ -3623,10 +3624,63 @@ runs[1]{id,branch,status,head,pr}: out=$(run_crew_state "$d" seldiv) assert_contains "$out" "state: unknown" "the selected diverged-head route must not read working with the daemon down" assert_contains "$out" "daemon unreachable" "the dead instrument is named on the selected route too" + assert_contains "$out" "run ids: 01RUN" "the selected-route verdict carries the candidate run ids" assert_not_contains "$out" "state: working" "a stale status log must not answer for a dead instrument" pass "the selected-run diverged-head route reports the dead daemon" } +# The crew observed the refused socket itself. A run record the dead daemon left +# behind is the weaker witness, so the blocker stays blocked on the diverged-head +# route too - the same invariant the head-match route has always honoured. +test_socket_refused_log_survives_the_dead_daemon_verdict() { + reset_fakes + local d rebased out; d=$(new_case socket-refused-diverged) + make_repo_on_branch "$d/wt" fm/feat-sockdiv + rebased=$(make_rebased_head "$d/wt") + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-sockdiv.meta" "window=fm:fm-feat-sockdiv" "worktree=$d/wt" "kind=ship" "harness=claude" + printf 'blocked: no-mistakes daemon socket refused connections\n' > "$d/state/feat-sockdiv.status" + FM_FAKE_RUN_HEAD=$rebased + FM_FAKE_AXI_STATUS="$(run_running fm/feat-sockdiv) +branch_sync: + state: synced" + FM_FAKE_RUNS_LIST="" + FM_FAKE_DAEMON_DOWN=1 + FM_FAKE_BUSY=0 + arm_idle_record "$d/state" feat-sockdiv + out=$(run_crew_state "$d" feat-sockdiv) + assert_contains "$out" "state: blocked" "a first-hand socket refusal is not demoted to a generic unknown" + assert_contains "$out" "socket refused" "the crew's own blocker reaches the supervisor" + assert_not_contains "$out" "state: unknown" "the unverified record must not replace the blocker" + pass "a socket-refused blocker survives the dead-daemon verdict" +} + +# An open gate is the crew's own first-hand evidence too: an unverified record +# cannot close it, so needs-decision stays parked and names the reason. +test_needs_decision_survives_the_dead_daemon_verdict() { + reset_fakes + local d rebased out; d=$(new_case needs-decision-diverged) + make_repo_on_branch "$d/wt" fm/feat-ndiv + rebased=$(make_rebased_head "$d/wt") + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-ndiv.meta" "window=fm:fm-feat-ndiv" "worktree=$d/wt" "kind=ship" "harness=claude" + printf 'needs-decision: approve the schema change\n' > "$d/state/feat-ndiv.status" + FM_FAKE_RUN_HEAD=$rebased + FM_FAKE_AXI_STATUS="$(run_running fm/feat-ndiv) +branch_sync: + state: synced" + FM_FAKE_RUNS_LIST="" + FM_FAKE_DAEMON_DOWN=1 + FM_FAKE_BUSY=0 + arm_idle_record "$d/state" feat-ndiv + out=$(run_crew_state "$d" feat-ndiv) + assert_contains "$out" "state: parked" "an open decision is not hidden behind a generic unknown" + assert_contains "$out" "approve the schema change" "the crew's own decision note reaches the supervisor" + assert_contains "$out" "daemon unreachable" "the unverified record is named as the reason" + assert_not_contains "$out" "superseded" "an unverified record never supersedes an open decision" + pass "an open decision survives the dead-daemon verdict" +} + # The head-free route still binds while the daemon answers: the daemon probe # narrows the zombie case only, it does not undo the rebase fix. test_live_record_at_diverged_head_binds_while_daemon_answers() { @@ -3732,8 +3786,8 @@ EOF FM_FAKE_BUSY=0 arm_idle_record "$d/state" feat-cus out=$(run_crew_state "$d" feat-cus) - assert_contains "$out" "state: unknown" "the bound coarse record reports itself unverified" - assert_contains "$out" "daemon unreachable" "the dead instrument is named" + assert_contains "$out" "state: parked" "the open decision outranks an unverified coarse record" + assert_contains "$out" "daemon unreachable" "the dead instrument is named as the reason" assert_not_contains "$out" "superseded" "an unverified record makes no supersede claim about the open decision" pass "an unverified coarse record never claims the status log superseded" } @@ -4277,6 +4331,8 @@ test_live_rebased_run_reads_working_for_every_executing_status test_legacy_live_rebased_run_is_authoritative test_legacy_surface_binds_fixing_and_ci_at_a_rebased_head test_live_record_at_diverged_head_needs_a_live_daemon +test_socket_refused_log_survives_the_dead_daemon_verdict +test_needs_decision_survives_the_dead_daemon_verdict test_parked_gate_survives_a_dead_daemon test_selected_run_diverged_head_reports_the_dead_daemon test_live_record_at_diverged_head_binds_while_daemon_answers From 53853e96bb67898ef15b385168ace2de826aa31e Mon Sep 17 00:00:00 2001 From: rovermike Date: Sat, 19 Sep 2026 14:20:59 -0400 Subject: [PATCH 11/19] no-mistakes(review): align selected route with legacy dead-daemon handling --- bin/fm-crew-state.sh | 31 ++++++++--- tests/fm-crew-state.test.sh | 107 +++++++++++++++++++++++++++++++++--- 2 files changed, 122 insertions(+), 16 deletions(-) diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 4f0173523c4..26040ad8c8c 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -108,7 +108,10 @@ # log's latest event. The same holds for any open decision when the run # record itself is UNVERIFIED (its daemon answered down): the crew saw its # gate or blocker first hand, so needs-decision stays parked and blocked -# stays blocked, with the unverified record named as the reason. +# stays blocked, with the unverified record named as the reason. A COARSE +# live row over an open decision answers the same way: the ledger keeps a +# parked run's word at `running`, so it cannot establish that the decision +# resolved, and the decision answers in the state, not only in the detail. # Other daemon, timeout, or unreachability # claims are superseded BECAUSE THE RUN IS ALIVE when the run is # running/fixing with recent reported activity: a killed or timed-out drive @@ -792,15 +795,20 @@ if [ "$KIND" = ship ] && [ -n "$CREW_BRANCH" ] && command -v no-mistakes >/dev/n HAVE_RUN=1 elif [ -z "$(fm_nm_resolve_commit "$WT" "$(strip_quotes "$(nm_field head)")")" ]; then if fm_nm_run_is_active "$RUN_OUT" \ - && ! nm_daemon_answered_down \ && [ "$(fm_nm_runs_status_for_worktree "$WT" "$CREW_BRANCH" "$(nm_runs_list)" "$(strip_quotes "$(nm_field head)")")" = running ]; then + # The anchor PROVED code identity; only liveness can still fail, so + # a dead daemon is reported as such rather than as an identity + # failure, and a parked run keeps its gate and findings. HAVE_RUN=1 + if ! fm_nm_run_is_parked "$RUN_OUT" && nm_daemon_answered_down; then + RUN_DEAD_DAEMON="no-mistakes daemon unreachable; last run record $(strip_quotes "$(nm_field status)") - unverified" + fi else emit unknown run-step "selected run code identity unverified; run ids: $candidate_ids" fi elif fm_nm_run_is_executing "$RUN_OUT" && nm_daemon_answered_down; then HAVE_RUN=1 - RUN_DEAD_DAEMON="no-mistakes daemon unreachable; last run record $(strip_quotes "$(nm_field status)") - unverified; run ids: $candidate_ids" + RUN_DEAD_DAEMON="no-mistakes daemon unreachable; last run record $(strip_quotes "$(nm_field status)") - unverified" fi SELECTED_RUN_ID=$selected_id ;; @@ -863,7 +871,10 @@ if [ "$KIND" = ship ] && [ -n "$CREW_BRANCH" ] && command -v no-mistakes >/dev/n if [ "$(fm_nm_run_status_class "$COARSE_STATUS")" = live ] \ && ! { [ "$run_branch" = "$CREW_BRANCH" ] && fm_nm_run_is_parked "$RUN_OUT"; } \ && nm_daemon_answered_down; then - RUN_DEAD_DAEMON="no-mistakes daemon unreachable; last ledger record $COARSE_STATUS - unverified; run id: $(strip_quotes "$(nm_field id)")" + RUN_DEAD_DAEMON="no-mistakes daemon unreachable; last ledger record $COARSE_STATUS - unverified" + if [ "$run_branch" = "$CREW_BRANCH" ]; then + RUN_DEAD_DAEMON="$RUN_DEAD_DAEMON; run id: $(strip_quotes "$(nm_field id)")" + fi fi elif [ "$run_branch" = "$CREW_BRANCH" ] && fm_nm_run_is_executing "$RUN_OUT" \ && nm_daemon_answered_down; then @@ -895,7 +906,7 @@ if [ "$HAVE_RUN" = 1 ]; then # read above. The status event span remains independently available to the # supervisor through fm-classify-lib.sh's status_span_first_actionable. case "$COARSE_STATUS" in - pending|running) RUN_STATE=working; RUN_DETAIL="validating (background run)" ;; + running) RUN_STATE=working; RUN_DETAIL="validating (background run)" ;; completed) RUN_STATE="done"; RUN_DETAIL="run completed" ;; failed) # The ledger row is terminal but the coarse path has no steps table @@ -1021,8 +1032,9 @@ if [ "$HAVE_RUN" = 1 ]; then # its gate or its blocker first hand; a record the dead instrument left # behind is the weaker witness, so the log answers and the unverified # record is reported as the reason rather than replacing it. - if [ -n "$RUN_DEAD_DAEMON" ] && [ "$(map_log_state "$LOG_LINE")" != unknown ]; then - emit "$(map_log_state "$LOG_LINE")" status-log "$(status_line_note "$LOG_LINE")${SEP}$RUN_DEAD_DAEMON" + LOG_TIP_STATE=$(map_log_state "$LOG_LINE") + if [ -n "$RUN_DEAD_DAEMON" ]; then + emit "$LOG_TIP_STATE" status-log "$(status_line_note "$LOG_LINE")${SEP}$RUN_DEAD_DAEMON" fi if [ "$RUN_STATE" != parked ] \ && ! { [ "$RUN_SOURCE" = coarse ] && [ "$RUN_STATE" = unknown ]; }; then @@ -1031,8 +1043,9 @@ if [ "$HAVE_RUN" = 1 ]; then # The runs ledger keeps a parked run's status word at `running` # (tests/captures/no-mistakes-v1.70.1/parked.toon), so a coarse # live row is equally consistent with the gate still being open - # and cannot establish that this event resolved. - RUN_DETAIL="$RUN_DETAIL${SEP}status-log not superseded: a coarse run record cannot tell working from parked" + # and cannot establish that this event resolved. The open decision + # therefore answers, in the state and not only in the detail. + emit "$LOG_TIP_STATE" status-log "$(status_line_note "$LOG_LINE")${SEP}a coarse run record cannot tell working from parked" elif [ "$LOG_VERB" = blocked ] \ && log_claims_pipeline_unreachable "$LOG_LINE" \ && { [ "$RUN_STATUS" = running ] || [ "$RUN_STATUS" = fixing ]; } \ diff --git a/tests/fm-crew-state.test.sh b/tests/fm-crew-state.test.sh index f8b4356a027..896547555c8 100755 --- a/tests/fm-crew-state.test.sh +++ b/tests/fm-crew-state.test.sh @@ -3624,7 +3624,7 @@ runs[1]{id,branch,status,head,pr}: out=$(run_crew_state "$d" seldiv) assert_contains "$out" "state: unknown" "the selected diverged-head route must not read working with the daemon down" assert_contains "$out" "daemon unreachable" "the dead instrument is named on the selected route too" - assert_contains "$out" "run ids: 01RUN" "the selected-route verdict carries the candidate run ids" + assert_contains "$out" "run: 01RUN" "the selected-route verdict carries the run identity" assert_not_contains "$out" "state: working" "a stale status log must not answer for a dead instrument" pass "the selected-run diverged-head route reports the dead daemon" } @@ -3822,8 +3822,96 @@ EOF arm_idle_record "$d/state" selanchor out=$(run_crew_state "$d" selanchor) assert_not_contains "$out" "state: working" "the selected anchored route must not read working with the daemon answering down" - assert_contains "$out" "code identity unverified" "the unverifiable identity is reported instead" - pass "the selected-run anchored continuation obeys the daemon rule" + assert_contains "$out" "daemon unreachable" "the ledger anchor proved identity, so liveness is what is reported" + assert_not_contains "$out" "code identity unverified" "an anchored run's identity is proven, not unverified" + assert_contains "$out" "run: 01RUN" "the verdict still names the run for a later --run read" + pass "the selected-run anchored continuation reports the dead daemon, not an identity failure" +} + +# The selected route honours the parked exemption too: an anchored PARKED run +# with a dead daemon keeps its gate and findings, exactly as the legacy route +# does on the same evidence. +test_selected_run_anchored_parked_keeps_its_gate_with_a_dead_daemon() { + reset_fakes + local d local_short out; d=$(new_case selected-anchored-parked) + make_repo_on_branch "$d/wt" fm/feat-selpark + local_short=$(git -C "$d/wt" rev-parse --short=8 HEAD) + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/selpark.meta" "window=fm:fm-selpark" "worktree=$d/wt" "kind=ship" "harness=claude" + printf 'needs-decision: approve the schema change\n' > "$d/state/selpark.status" + FM_FAKE_RUN_HEAD=f0f0f0f0 + FM_FAKE_AXI_HOME="count: 1 of 1 total +runs[1]{id,branch,status,head,pr}: + \"01RUN\",fm/feat-selpark,running,f0f0f0f0,\"\"" + FM_FAKE_AXI_STATUS="$(run_parked fm/feat-selpark) +branch_sync: + state: synced" + FM_FAKE_AXI_STATUS_RUN="$FM_FAKE_AXI_STATUS" + FM_FAKE_RUNS_LIST="$(cat </dev/null + fm_write_meta "$d/state/seldec.meta" "window=fm:fm-seldec" "worktree=$d/wt" "kind=ship" "harness=claude" + printf 'needs-decision: approve the schema change\n' > "$d/state/seldec.status" + FM_FAKE_RUN_HEAD=$rebased + FM_FAKE_AXI_HOME="count: 1 of 1 total +runs[1]{id,branch,status,head,pr}: + \"01RUN\",fm/feat-seldec,running,$rebased,\"\"" + FM_FAKE_AXI_STATUS="$(run_running fm/feat-seldec)" + FM_FAKE_AXI_STATUS_RUN="$FM_FAKE_AXI_STATUS" + FM_FAKE_RUNS_LIST="" + FM_FAKE_DAEMON_DOWN=1 + FM_FAKE_BUSY=0 + arm_idle_record "$d/state" seldec + out=$(run_crew_state "$d" seldec) + assert_contains "$out" "state: parked" "the open decision is not hidden behind the unverified record" + assert_contains "$out" "approve the schema change" "the crew's own decision note reaches the supervisor" + assert_contains "$out" "daemon unreachable" "the unverified record is named as the reason" + pass "an open decision survives the dead-daemon verdict on the selected route" +} + +# The coarse ledger word `pending` is not an acceptance: it keeps its unknown +# reading rather than claiming the crew is validating. +test_coarse_pending_ledger_word_reads_unknown() { + reset_fakes + local d local_short out; d=$(new_case coarse-pending) + make_repo_on_branch "$d/wt" fm/feat-cpend + local_short=$(git -C "$d/wt" rev-parse --short=8 HEAD) + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-cpend.meta" "window=fm:fm-feat-cpend" "worktree=$d/wt" "kind=ship" "harness=claude" + printf 'working: implementing\n' > "$d/state/feat-cpend.status" + FM_FAKE_AXI_STATUS="$(run_running fm/other-crew)" + FM_FAKE_RUNS_LIST="$(cat < Date: Sat, 19 Sep 2026 14:41:46 -0400 Subject: [PATCH 12/19] no-mistakes(review): drop unproven-record binds; narrow coarse gate reading --- bin/fm-crew-state.sh | 35 +++++++-------- tests/fm-crew-state.test.sh | 89 +++++++++++++++++++++++++++++-------- 2 files changed, 87 insertions(+), 37 deletions(-) diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 26040ad8c8c..46b4b00523b 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -67,13 +67,15 @@ # bin/fm-nm-run-lib.sh). The coarse runs-ledger fallback has NO # branch-name-only acceptance: an executing `axi status` record is the one # live bind, so a ledger row that cannot be tied to this worktree's head -# never answers on branch name alone. An EXECUTING record whose daemon has -# ANSWERED down reads unknown and names the dead instrument, exactly as the -# terminal record below does, on every route - the head being resolvable, -# diverged or absent changes nothing about a dead instrument, and silently -# dropping to a possibly-stale status log would hide it. A run PARKED at a -# gate is exempt: an open decision stays open when the instrument dies, so -# it keeps its gate and findings. +# never answers on branch name alone. A record whose daemon has ANSWERED +# down reads unknown and names the dead instrument - but only once identity +# is already proven, by head equality/ancestry or by the ledger anchor. A +# record with NEITHER is not this worktree's run to report on: it leaves +# HAVE_RUN=0 so the pane and status log answer, because a stale record +# naming this branch must never override a crew that is visibly working. +# A run PARKED at a gate is exempt from the dead-instrument verdict: an +# open decision stays open when the instrument dies, so it keeps its gate +# and findings. # fm_nm_select_run in bin/fm-nm-run-lib.sh owns complete run selection # and ambiguity reporting. The selected run's id-addressed status must # agree on id, branch, and live/terminal class before attribution; @@ -109,9 +111,10 @@ # record itself is UNVERIFIED (its daemon answered down): the crew saw its # gate or blocker first hand, so needs-decision stays parked and blocked # stays blocked, with the unverified record named as the reason. A COARSE -# live row over an open decision answers the same way: the ledger keeps a +# live row over an open DECISION answers the same way: the ledger keeps a # parked run's word at `running`, so it cannot establish that the decision # resolved, and the decision answers in the state, not only in the detail. +# A blocked tip is not ambiguous that way and keeps the generic reading. # Other daemon, timeout, or unreachability # claims are superseded BECAUSE THE RUN IS ALIVE when the run is # running/fixing with recent reported activity: a killed or timed-out drive @@ -806,9 +809,6 @@ if [ "$KIND" = ship ] && [ -n "$CREW_BRANCH" ] && command -v no-mistakes >/dev/n else emit unknown run-step "selected run code identity unverified; run ids: $candidate_ids" fi - elif fm_nm_run_is_executing "$RUN_OUT" && nm_daemon_answered_down; then - HAVE_RUN=1 - RUN_DEAD_DAEMON="no-mistakes daemon unreachable; last run record $(strip_quotes "$(nm_field status)") - unverified" fi SELECTED_RUN_ID=$selected_id ;; @@ -876,10 +876,6 @@ if [ "$KIND" = ship ] && [ -n "$CREW_BRANCH" ] && command -v no-mistakes >/dev/n RUN_DEAD_DAEMON="$RUN_DEAD_DAEMON; run id: $(strip_quotes "$(nm_field id)")" fi fi - elif [ "$run_branch" = "$CREW_BRANCH" ] && fm_nm_run_is_executing "$RUN_OUT" \ - && nm_daemon_answered_down; then - HAVE_RUN=1 - RUN_DEAD_DAEMON="no-mistakes daemon unreachable; last run record $(strip_quotes "$(nm_field status)") - unverified; run id: $(strip_quotes "$(nm_field id)")" fi fi fi @@ -914,8 +910,9 @@ if [ "$HAVE_RUN" = 1 ]; then # here. With the daemon provably down, the row is unverified evidence # from a dead instrument and must not read as work failure. if nm_daemon_probe_down; then + RUN_DEAD_DAEMON="no-mistakes daemon unreachable; last ledger record failed - unverified" RUN_STATE=unknown - RUN_DETAIL="no-mistakes daemon unreachable; last ledger record failed - unverified" + RUN_DETAIL=$RUN_DEAD_DAEMON else RUN_STATE=failed; RUN_DETAIL="run failed" fi ;; @@ -1039,12 +1036,12 @@ if [ "$HAVE_RUN" = 1 ]; then if [ "$RUN_STATE" != parked ] \ && ! { [ "$RUN_SOURCE" = coarse ] && [ "$RUN_STATE" = unknown ]; }; then if [ "$RUN_STATE" = working ]; then - if [ "$RUN_SOURCE" = coarse ]; then + if [ "$RUN_SOURCE" = coarse ] && [ "$LOG_VERB" = needs-decision ]; then # The runs ledger keeps a parked run's status word at `running` # (tests/captures/no-mistakes-v1.70.1/parked.toon), so a coarse # live row is equally consistent with the gate still being open - # and cannot establish that this event resolved. The open decision - # therefore answers, in the state and not only in the detail. + # and cannot establish that this decision resolved. Only a gate is + # ambiguous this way: a blocker keeps the generic reading below. emit "$LOG_TIP_STATE" status-log "$(status_line_note "$LOG_LINE")${SEP}a coarse run record cannot tell working from parked" elif [ "$LOG_VERB" = blocked ] \ && log_claims_pipeline_unreachable "$LOG_LINE" \ diff --git a/tests/fm-crew-state.test.sh b/tests/fm-crew-state.test.sh index 896547555c8..bfc829c5d63 100755 --- a/tests/fm-crew-state.test.sh +++ b/tests/fm-crew-state.test.sh @@ -3561,11 +3561,9 @@ branch_sync: FM_FAKE_BUSY=0 arm_idle_record "$d/state" feat-zombie out=$(run_crew_state "$d" feat-zombie) - assert_contains "$out" "state: unknown" "a live record at a diverged head must not read as work with the daemon answering down" - assert_contains "$out" "daemon unreachable" "the dead instrument is named rather than dropped silently" - assert_contains "$out" "run id: 01RUN" "the verdict carries the run identity for a later --run read" - assert_not_contains "$out" "state: working" "a stale status log must not answer for a dead instrument" - pass "a live record at a diverged head reports the dead daemon" + assert_not_contains "$out" "source: run-step" "a record with neither head nor anchor identity must not bind" + assert_contains "$out" "source: status-log" "the crew's own evidence answers instead" + pass "an unproven record at a diverged head does not answer for the crew" } # A run PARKED at a gate keeps its gate and findings when the daemon dies. The @@ -3622,11 +3620,9 @@ runs[1]{id,branch,status,head,pr}: FM_FAKE_BUSY=0 arm_idle_record "$d/state" seldiv out=$(run_crew_state "$d" seldiv) - assert_contains "$out" "state: unknown" "the selected diverged-head route must not read working with the daemon down" - assert_contains "$out" "daemon unreachable" "the dead instrument is named on the selected route too" - assert_contains "$out" "run: 01RUN" "the selected-route verdict carries the run identity" - assert_not_contains "$out" "state: working" "a stale status log must not answer for a dead instrument" - pass "the selected-run diverged-head route reports the dead daemon" + assert_not_contains "$out" "source: run-step" "an unproven record must not bind on the selected route either" + assert_contains "$out" "source: status-log" "the crew's own evidence answers instead" + pass "an unproven record at a diverged head does not answer on the selected route" } # The crew observed the refused socket itself. A run record the dead daemon left @@ -3676,9 +3672,62 @@ branch_sync: out=$(run_crew_state "$d" feat-ndiv) assert_contains "$out" "state: parked" "an open decision is not hidden behind a generic unknown" assert_contains "$out" "approve the schema change" "the crew's own decision note reaches the supervisor" - assert_contains "$out" "daemon unreachable" "the unverified record is named as the reason" - assert_not_contains "$out" "superseded" "an unverified record never supersedes an open decision" - pass "an open decision survives the dead-daemon verdict" + assert_not_contains "$out" "superseded" "an unproven record never supersedes an open decision" + pass "an open decision survives an unproven record with a dead daemon" +} + +# A visibly working crew must never be overridden by a stale record that merely +# names its branch. Identity is proven by neither head nor ledger anchor here, +# so the busy pane answers - the base behaviour before the daemon guard existed. +test_unproven_record_with_dead_daemon_does_not_override_a_busy_pane() { + reset_fakes + local d rebased out gen; d=$(new_case unproven-busy-pane) + make_repo_on_branch "$d/wt" fm/feat-unproven + rebased=$(make_rebased_head "$d/wt") + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-unproven.meta" "window=fm:fm-feat-unproven" "worktree=$d/wt" "kind=ship" "harness=claude" + printf 'working: implementing\n' > "$d/state/feat-unproven.status" + FM_FAKE_RUN_HEAD=$rebased + FM_FAKE_AXI_STATUS="$(run_running fm/feat-unproven) +branch_sync: + state: synced" + FM_FAKE_RUNS_LIST="" + FM_FAKE_DAEMON_DOWN=1 + FM_FAKE_BUSY=1 + gen=$("$ROOT/bin/fm-busy-event.sh" arm "$d/state" feat-unproven) + "$ROOT/bin/fm-busy-event.sh" apply "$d/state" feat-unproven busy --gen "$gen" \ + --source claude-hook --event user-prompt-submit + out=$(run_crew_state "$d" feat-unproven) + assert_contains "$out" "state: working" "a busy crew keeps reading working" + assert_contains "$out" "source: pane" "the live pane answers, not the stale record" + assert_not_contains "$out" "state: unknown" "an unproven record must not blank out a working crew" + pass "an unproven record with a dead daemon never overrides a busy pane" +} + +# Only a gate is ambiguous under a coarse live row. An ordinary blocker keeps the +# pre-existing reading, exactly as it does on the full route. +test_coarse_live_row_over_ordinary_blocked_keeps_superseded_reading() { + reset_fakes + local d local_short out; d=$(new_case coarse-ordinary-blocked) + make_repo_on_branch "$d/wt" fm/feat-cob + local_short=$(git -C "$d/wt" rev-parse --short=8 HEAD) + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-cob.meta" "window=fm:fm-feat-cob" "worktree=$d/wt" "kind=ship" "harness=claude" + printf 'blocked: database upload failed with broken pipe\n' > "$d/state/feat-cob.status" + FM_FAKE_AXI_STATUS="$(run_running fm/other-crew)" + FM_FAKE_RUNS_LIST="$(cat <