From 42ddc2daceaa7f91674ae491436ae13d1be37198 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Tue, 1 Sep 2026 21:25:50 -0700 Subject: [PATCH 1/5] refactor: remove legacy remote summary reads --- bin/fm-bearings-snapshot.sh | 7 +- bin/fm-fleet-snapshot.sh | 178 +++--------------- docs/architecture.md | 7 +- docs/configuration.md | 3 +- tests/fm-bearings-snapshot.test.sh | 141 +++++++------- tests/fm-home-summary-refresh.test.sh | 11 +- ...fm-remote-secondmate-lifecycle-e2e.test.sh | 17 +- 7 files changed, 128 insertions(+), 236 deletions(-) diff --git a/bin/fm-bearings-snapshot.sh b/bin/fm-bearings-snapshot.sh index 3e082d1b840..3d9a2315cc8 100755 --- a/bin/fm-bearings-snapshot.sh +++ b/bin/fm-bearings-snapshot.sh @@ -130,7 +130,7 @@ For every registered secondmate, readable structured facts from its own home are authoritative, including independently trustworthy surfaces from a partial summary. Parent events and bounded terminal reads are labeled fallback or contradiction evidence and never become current work. The provenance and freshness fields - distinguish live ledgers, cached ledgers, and mixed-fleet summary fallbacks. + distinguish live and cached ledgers; a home without either is explicitly unreadable. Opt-in surfaces: --fields bodies|paths|actions|endpoints, --all-in-flight, --all-decisions, --all-secondmates, --all-landed, --all-reports, --all-queued, --all-recorded-prs, --all-unhealthy, --all-pr-repos, --include-prs (adds candidate_prs). @@ -485,7 +485,7 @@ MODEL=$(printf '%s' "$SNAP" | jq \ (if $all_queued == 1 then empty else {surface:"superseded or prose-deferred queued items", reveal:"--all-queued"} end), (if $all_landed == 0 and ($per_home_capped | length) > ($done | length) then {surface:("landed showing \($done | length) of \($per_home_capped | length)" + (($done | map(.home_id) | unique | map(select(. != "(main)")) | length) as $k | if $k > 0 then " (incl. \($k) secondmate home(s))" else "" end)), reveal:"--all-landed"} else empty end), (if $all_landed == 0 and $home_cap_dropped > 0 then {surface:("landed per-home capped at \($landed_per_home_n) for \($home_cap_dropped) home(s)"), reveal:"--all-landed"} else empty end), - (if (($snap.secondmate_landed.unreadable // []) | length) > 0 then {surface:("secondmate home(s) with unreadable backlog: \(($snap.secondmate_landed.unreadable // []) | length)"), reveal:"inspect the listed secondmate home backlogs"} else empty end), + (if (($snap.secondmate_landed.unreadable // []) | length) > 0 then {surface:("secondmate home(s) with unreadable structured state: \(($snap.secondmate_landed.unreadable // []) | length)"), reveal:"inspect the listed secondmate home ledgers"} else empty end), (if $all_landed == 0 and (($snap.secondmate_landed.truncated // []) | length) > 0 then {surface:("secondmate home Done capped at the snapshot layer for \(($snap.secondmate_landed.truncated // []) | length) home(s)"), reveal:"--all-landed"} else empty end), ((($snap.main_inventory.orphan_in_flight // []) | length) as $n | if $n > 0 then {surface:("main in-flight backlog item(s) have no child metadata: \($n)"), reveal:"inspect main data/backlog.md In flight vs state/*.meta"} else empty end), @@ -500,9 +500,6 @@ MODEL=$(printf '%s' "$SNAP" | jq \ (($snap.secondmate_current.records // [])[] | select(.provenance.summary_source == "remote-ledger-cache") | {surface:("secondmate " + .id + " served from cached home ledger"),reveal:"inspect the home ledger publication and remote route"}), - (($snap.secondmate_current.records // [])[] - | select(.provenance.summary_source == "legacy-remote-summary" or .provenance.summary_source == "legacy-local-summary") - | {surface:("secondmate " + .id + " used mixed-fleet summary fallback"),reveal:"publish state/home-summary.json in that home"}), (([($snap.secondmate_current.records // [])[] | select(.parent_event.activity_scan.input_truncated == true or .parent_event.activity_scan.retained_truncated == true)] | length) as $n | if $n > 0 then {surface:("secondmate parent activity evidence truncated for \($n) record(s)"), reveal:"raise FM_SNAPSHOT_PARENT_ACTIVITY_LINES, FM_SNAPSHOT_PARENT_ACTIVITY_BYTES, or FM_SNAPSHOT_PARENT_ACTIVITIES"} else empty end), (([($snap.secondmate_current.records // [])[] | select(.parent_event.activity_scan.available == false)] | length) as $n | if $n > 0 then {surface:("secondmate parent activity evidence unavailable for \($n) record(s)"), reveal:"inspect the parent status logs"} else empty end), (if $all_decisions == 0 and ($decisions_all | length) > $decisions_n then {surface:("decisions_open showing \($decisions_n) of \($decisions_all | length)"), reveal:"--all-decisions"} else empty end), diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 114d5382f8e..fb8c9efb155 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -62,10 +62,9 @@ # untrusted supplements only and never override readable structured-home facts. # Each structured-home record carries active_children, decisions_open, holds, # queued, landed, endpoints, counts, and omitted. provenance.summary_source -# distinguishes "local-ledger", "remote-ledger", "remote-ledger-cache", -# "legacy-local-summary", and "legacy-remote-summary"; freshness is "cached" -# only for the cache source, and observed_at/age_seconds come from the -# selected summary's generation. Every successfully sampled home also carries +# distinguishes "local-ledger", "remote-ledger", and "remote-ledger-cache"; +# freshness is "cached" only for the cache source, and observed_at/age_seconds +# come from the selected summary's generation. Every successfully sampled home also carries # reconcile_inventory independently of projection trust. # Actionable captain holds # appear in decisions_open; blocked captain holds remain queued with metadata. @@ -111,10 +110,8 @@ esac # Cross-home bounds are explicit so one broken or unexpectedly large home cannot # hang or explode the parent snapshot. FM_SNAPSHOT_SECONDMATES=${FM_SNAPSHOT_SECONDMATES:-20} -FM_SNAPSHOT_SECONDMATE_TIMEOUT=${FM_SNAPSHOT_SECONDMATE_TIMEOUT:-8} FM_SNAPSHOT_CREW_STATE_TIMEOUT=${FM_SNAPSHOT_CREW_STATE_TIMEOUT:-10} FM_SNAPSHOT_BUDGET=${FM_SNAPSHOT_BUDGET:-5} -FM_SNAPSHOT_LEDGER_MODE=${FM_SNAPSHOT_LEDGER_MODE:-on} FM_SNAPSHOT_CACHE_DIR=${FM_SNAPSHOT_CACHE_DIR:-$STATE/secondmate-summary-cache} FM_SNAPSHOT_SECONDMATE_MAX_BYTES=${FM_SNAPSHOT_SECONDMATE_MAX_BYTES:-262144} FM_SNAPSHOT_SECONDMATE_CHILDREN=${FM_SNAPSHOT_SECONDMATE_CHILDREN:-20} @@ -145,13 +142,8 @@ case "$FM_SNAPSHOT_SECONDMATES" in exit 2 ;; esac -validate_positive_bound FM_SNAPSHOT_SECONDMATE_TIMEOUT "$FM_SNAPSHOT_SECONDMATE_TIMEOUT" validate_positive_bound FM_SNAPSHOT_CREW_STATE_TIMEOUT "$FM_SNAPSHOT_CREW_STATE_TIMEOUT" validate_positive_bound FM_SNAPSHOT_BUDGET "$FM_SNAPSHOT_BUDGET" -case "$FM_SNAPSHOT_LEDGER_MODE" in - on|off) : ;; - *) echo "fm-fleet-snapshot: FM_SNAPSHOT_LEDGER_MODE must be on or off" >&2; exit 2 ;; -esac validate_positive_bound FM_SNAPSHOT_SECONDMATE_MAX_BYTES "$FM_SNAPSHOT_SECONDMATE_MAX_BYTES" validate_positive_bound FM_SNAPSHOT_SECONDMATE_CHILDREN "$FM_SNAPSHOT_SECONDMATE_CHILDREN" validate_positive_bound FM_SNAPSHOT_SECONDMATE_QUEUED "$FM_SNAPSHOT_SECONDMATE_QUEUED" @@ -201,14 +193,11 @@ blocker fields for downstream projections. A captain hold is actionable only when every blocker is Done and any hold-until date has arrived. Cross-home collection uses FM_SNAPSHOT_SECONDMATES (default 20, 0 lifts the count bound) and FM_SNAPSHOT_SECONDMATE_MAX_BYTES. -FM_SNAPSHOT_LEDGER_MODE defaults to on. In that mode every sampled remote home's -state/home-summary.json is fetched concurrently under one FM_SNAPSHOT_BUDGET -(default 5 seconds), with a valid prior copy under FM_SNAPSHOT_CACHE_DIR used -when the live read fails, is invalid, or consumes the budget. A live read that -fails or validates malformed before consuming the budget can start the legacy -summary fallback inside that same total budget for mixed-fleet compatibility. -FM_SNAPSHOT_SECONDMATE_TIMEOUT bounds local summary fallback and the diagnostic -legacy mode selected with FM_SNAPSHOT_LEDGER_MODE=off. +Every sampled remote home's state/home-summary.json is fetched concurrently +under one FM_SNAPSHOT_BUDGET (default 5 seconds), with a valid prior copy under +FM_SNAPSHOT_CACHE_DIR used when the live read fails, is invalid, or consumes the +budget. A home with neither a valid ledger nor a valid cached copy is reported +unreadable with the reason; collection never computes a summary in that home. Each local per-task current-state read is bounded by FM_SNAPSHOT_CREW_STATE_TIMEOUT (default 10 seconds); a read that hits the bound reports state unknown. Remote secondmate endpoint liveness is not probed by this command. @@ -254,13 +243,9 @@ last_nonempty_line() { # grep -v '^[[:space:]]*$' "$1" 2>/dev/null | tail -1 } -# A crew-state read is bounded like every other cross-home read here. For a -# remote secondmate fm-crew-state.sh reaches its host over ssh, and ssh's own -# dead-peer detection deliberately never kills a slow-but-alive remote command, -# so without this bound one unreachable or slow host extends the whole snapshot -# without limit - and this snapshot is also the producer behind the repeatedly -# published home ledger. A read that hits the bound is indistinguishable from -# the already-handled unreadable case: empty output folds to state unknown. +# A local crew-state read is bounded so one slow child cannot extend this +# snapshot without limit. Remote secondmate endpoint liveness is never read here. +# A local read that hits the bound folds to state unknown. crew_state_json() { # local id=$1 raw rest state source detail sep raw=$( @@ -472,7 +457,7 @@ backlog_json() { # [] - defaults to this home's $BACKLOG task_json_lines() { local meta id kind harness mode yolo project worktree home projects spawn_gen backend target status_log report_path - local remote_host remote_root remote_home_present + local remote_host remote_root local pr pr_source event_json current_json endpoint_exists agent_alive meta_json status_json report_json worktree_json home_json local last_event_raw current_state current_source pending_decision blocked_event report_present=0 pr_from_status local open_decisions_tsv open_decisions_json @@ -492,7 +477,6 @@ task_json_lines() { spawn_gen=$(meta_value "$meta" spawn_gen) remote_host=$(meta_value "$meta" remote_host) remote_root=$(meta_value "$meta" remote_root) - remote_home_present=null if [ -n "$remote_host" ]; then backend=$(meta_value "$meta" remote_backend) [ -n "$backend" ] || backend=unknown @@ -515,9 +499,8 @@ task_json_lines() { fi if [ -n "$remote_host" ]; then - # Remote endpoint liveness belongs to supervision. The default snapshot - # path consumes one home ledger read instead of probing each persistent - # endpoint while assembling the parent task inventory. + # Remote endpoint liveness belongs to supervision. The snapshot never + # probes a persistent remote endpoint while assembling parent inventory. current_json=$(jq -n '{state:"unknown",source:"none",detail:"remote endpoint liveness not collected by fleet snapshot",raw:""}') else current_json=$(crew_state_json "$id") @@ -561,7 +544,6 @@ task_json_lines() { endpoint_exists=null agent_alive=not_checked if [ -n "$remote_host" ]; then - remote_home_present=null agent_alive=unknown else if [ -n "$target" ]; then @@ -582,7 +564,7 @@ task_json_lines() { report_json=$(path_present_json "$report_path") if [ -n "$worktree" ]; then worktree_json=$(path_present_json "$worktree"); else worktree_json=$(jq -n '{path:null,present:false}'); fi if [ -n "$home" ] && [ -n "$remote_host" ]; then - home_json=$(jq -n --arg path "$home" --argjson present "$remote_home_present" '{path:$path,present:$present}') + home_json=$(jq -n --arg path "$home" '{path:$path,present:null}') elif [ -n "$home" ]; then home_json=$(path_present_json "$home") else @@ -1024,17 +1006,6 @@ summary_file_oversized() { # [ "$bytes" -gt "$FM_SNAPSHOT_SECONDMATE_MAX_BYTES" ] } -legacy_summary_capture() { # - local output=$1 timeout=$2 - shift 2 - fm_run_timed "$timeout" bash -c " - limit=\$1 - shift - set -o pipefail - \"\$@\" | LC_ALL=C head -c \"\$limit\" - " fm-legacy-summary "$((FM_SNAPSHOT_SECONDMATE_MAX_BYTES + 1))" "$@" > "$output" -} - snapshot_cache_prepare() { local mode SNAPSHOT_CACHE_AVAILABLE=0 @@ -1149,13 +1120,12 @@ bounded_collect() { # } collect_one() { # - local row=$1 id home cache slot fetch fallback status + local row=$1 id home cache slot fetch status id=$(printf '%s' "$row" | jq -r '.id') || return home=$(printf '%s' "$row" | jq -r '.home') || return cache=$(printf '%s' "$row" | jq -r '.cache') || return slot=$(printf '%s' "$row" | jq -r '.slot') || return fetch="$out_dir/$slot.fetch" - fallback="$out_dir/$slot.fallback" status="$out_dir/$slot.status" if bounded_collect "$fetch" "$out_dir/$slot.fetch.err" \ "$script_dir/fm-on.sh" "$id" fm-remote-file.sh get state/home-summary.json "$max_bytes" \ @@ -1167,12 +1137,6 @@ collect_one() { # printf 'cached\n' > "$status" return fi - if bounded_collect "$fallback" "$out_dir/$slot.fallback.err" \ - "$script_dir/fm-on.sh" "$id" fm-fleet-snapshot.sh --secondmate-home-summary \ - && valid_summary "$fallback" "$home"; then - printf 'fallback\n' > "$status" - return - fi printf 'failed\n' > "$status" } @@ -1414,8 +1378,8 @@ parent_evidence_reconciliation_json() { # local tasks=$1 registry union rows total_registered total shown truncated local row id home host remote registered registry_error task sampled_spawn_gen status_file event_raw event_note event_epoch event_age - local activity_scan activities decisions reconciliation provenance freshness reason summary summary_rc summary_sampled summary_valid summary_reason summary_invalidity state current_reason terminal terminal_contradiction contradiction - local summary_source summary_age summary_observed summary_freshness cache_path collection_status collection_slot fallback_file legacy_file + local activity_scan activities decisions reconciliation provenance freshness reason summary summary_sampled summary_valid summary_reason summary_invalidity state current_reason terminal terminal_contradiction contradiction + local summary_source summary_age summary_observed summary_freshness cache_path collection_status collection_slot local records='[]' seen_homes='' registry=$(registry_secondmates_json) || return 1 union=$(jq -n --argjson registry "$registry" --argjson tasks "$tasks" ' @@ -1439,11 +1403,7 @@ secondmate_current_json() { # shown=$(printf '%s\n' "$rows" | grep -c . || true) truncated=$((total - shown)) if [ -n "$rows" ]; then - if [ "$FM_SNAPSHOT_LEDGER_MODE" = on ]; then - prepare_remote_summary_collection "$rows" || return 1 - else - SNAPSHOT_COLLECT_DIR=$(umask 077; mktemp -d "${TMPDIR:-/tmp}/fm-fleet-legacy.XXXXXX") || return 1 - fi + prepare_remote_summary_collection "$rows" || return 1 fi while IFS= read -r row; do @@ -1501,7 +1461,7 @@ secondmate_current_json() { # summary_age=0 summary_observed=$SNAPSHOT_NOW summary_freshness=fresh - if [ -z "$reason" ] && [ "$FM_SNAPSHOT_LEDGER_MODE" = on ]; then + if [ -z "$reason" ]; then if [ "$remote" = true ]; then cache_path=$(snapshot_route_cache_path "$id" "$host" "$home" 2>/dev/null || true) collection_slot=$(jq -r --arg id "$id" 'select(.id == $id) | .slot' "$SNAPSHOT_COLLECT_DIR/manifest.jsonl" 2>/dev/null | head -1) @@ -1512,104 +1472,28 @@ secondmate_current_json() { # elif [ -n "$cache_path" ] && summary=$(summary_file_read "$cache_path" "$home"); then summary_source='remote-ledger-cache' summary_freshness=cached - elif summary=$(summary_file_read "$SNAPSHOT_COLLECT_DIR/$collection_slot.fallback" "$home"); then - summary_source='legacy-remote-summary' - summary_freshness=fresh - elif summary_file_oversized "$SNAPSHOT_COLLECT_DIR/$collection_slot.fallback"; then - reason="structured home snapshot exceeded byte limit" + elif summary_file_oversized "$SNAPSHOT_COLLECT_DIR/$collection_slot.fetch"; then + reason="structured home ledger exceeded byte limit and no valid cached copy is available" elif [ "$SNAPSHOT_COLLECTION_TIMED_OUT" -eq 1 ] && [ -z "$collection_status" ]; then - reason="structured home snapshot timed out" + reason="structured home ledger collection timed out and no valid cached copy is available" else - reason="structured home snapshot failed" + reason="structured home ledger is missing, unreadable, or invalid and no valid cached copy is available" fi + elif summary=$(summary_file_read "$home/state/home-summary.json" "$home"); then + summary_source='local-ledger' + elif summary_file_oversized "$home/state/home-summary.json"; then + reason="structured home ledger exceeded byte limit" else - if summary=$(summary_file_read "$home/state/home-summary.json" "$home"); then - summary_source='local-ledger' - else - fallback_file=$(mktemp "$SNAPSHOT_COLLECT_DIR/local-summary.XXXXXX") || return 1 - summary_rc=0 - fm_run_timed "$FM_SNAPSHOT_SECONDMATE_TIMEOUT" env \ - FM_ROOT_OVERRIDE="$FM_ROOT" \ - FM_HOME="$home" \ - FM_STATE_OVERRIDE="$home/state" \ - FM_DATA_OVERRIDE="$home/data" \ - FM_CONFIG_OVERRIDE="$home/config" \ - FM_PROJECTS_OVERRIDE="$home/projects" \ - FM_SNAPSHOT_NOW="$SNAPSHOT_NOW" \ - FM_SNAPSHOT_NOW_EPOCH="$SNAPSHOT_EPOCH" \ - FM_SNAPSHOT_SECONDMATE_CHILDREN="$FM_SNAPSHOT_SECONDMATE_CHILDREN" \ - FM_SNAPSHOT_SECONDMATE_QUEUED="$FM_SNAPSHOT_SECONDMATE_QUEUED" \ - FM_SNAPSHOT_SECONDMATE_DECISIONS="$FM_SNAPSHOT_SECONDMATE_DECISIONS" \ - FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME="$FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME" \ - "$SCRIPT_DIR/fm-fleet-snapshot.sh" --secondmate-home-summary \ - > "$fallback_file" 2>/dev/null || summary_rc=$? - if [ "$summary_rc" -eq 0 ] && summary=$(summary_file_read "$fallback_file" "$home"); then - summary_source='legacy-local-summary' - summary_freshness=fresh - elif summary_file_oversized "$fallback_file"; then - reason="structured home snapshot exceeded byte limit" - elif [ "$summary_rc" -eq 124 ]; then - reason="structured home snapshot timed out" - else - reason="structured home snapshot failed" - fi - fi + reason="structured home ledger is missing, unreadable, or invalid" fi if [ -z "$reason" ]; then summary_age=$(snapshot_summary_age "$summary") summary_observed=$(printf '%s' "$summary" | jq -r '.generated') fi - elif [ -z "$reason" ]; then - legacy_file=$(umask 077; mktemp "$SNAPSHOT_COLLECT_DIR/legacy-summary.XXXXXX") || return 1 - if [ "$remote" = true ]; then - legacy_summary_capture "$legacy_file" "$FM_SNAPSHOT_SECONDMATE_TIMEOUT" \ - "$SCRIPT_DIR/fm-on.sh" "$id" fm-fleet-snapshot.sh --secondmate-home-summary \ - < /dev/null 2>/dev/null - summary_rc=$? - summary_source='legacy-remote-summary' - else - legacy_summary_capture "$legacy_file" "$FM_SNAPSHOT_SECONDMATE_TIMEOUT" env \ - FM_ROOT_OVERRIDE="$FM_ROOT" FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ - FM_DATA_OVERRIDE="$home/data" FM_CONFIG_OVERRIDE="$home/config" FM_PROJECTS_OVERRIDE="$home/projects" \ - FM_SNAPSHOT_NOW="$SNAPSHOT_NOW" FM_SNAPSHOT_NOW_EPOCH="$SNAPSHOT_EPOCH" \ - FM_SNAPSHOT_SECONDMATE_CHILDREN="$FM_SNAPSHOT_SECONDMATE_CHILDREN" \ - FM_SNAPSHOT_SECONDMATE_QUEUED="$FM_SNAPSHOT_SECONDMATE_QUEUED" \ - FM_SNAPSHOT_SECONDMATE_DECISIONS="$FM_SNAPSHOT_SECONDMATE_DECISIONS" \ - FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME="$FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME" \ - "$SCRIPT_DIR/fm-fleet-snapshot.sh" --secondmate-home-summary 2>/dev/null - summary_rc=$? - summary_source='legacy-local-summary' - fi - if summary_file_oversized "$legacy_file"; then - reason="structured home snapshot exceeded byte limit" - elif [ "$summary_rc" -ne 0 ]; then - [ "$summary_rc" -eq 124 ] && reason="structured home snapshot timed out" || reason="structured home snapshot failed" - elif ! jq -e -s --arg home "$home" ' - length == 1 and (.[0] | - .schema == "fm-secondmate-home-summary.v1" and .home == $home - and (.generated_epoch | type) == "number" - and (.valid | type) == "boolean" and (.state | type) == "string" - and (.invalidity | type) == "object" and (.invalidity.ids | type) == "array" - and (.active_children | type) == "array" and (.decisions_open | type) == "array" - and (.holds | type) == "array" and (.queued | type) == "array" - and (.landed | type) == "array" and (.endpoints | type) == "array" - and (.counts | type) == "object" and (.omitted | type) == "array" - ) - ' "$legacy_file" >/dev/null 2>&1; then - reason="structured home snapshot was malformed or stale" - else - summary=$(jq -c -s '.[0]' "$legacy_file") || reason="structured home snapshot was malformed or stale" - if [ -z "$reason" ]; then - summary_age=$(snapshot_summary_age "$summary") - summary_observed=$(printf '%s' "$summary" | jq -r '.generated') - summary_freshness=fresh - fi - fi - rm -f -- "$legacy_file" fi # Failed command substitutions clear their assignment target. Keep the - # unsampled fallback record's --argjson input valid without retaining any - # rejected or oversized summary fragment. + # unsampled record's --argjson input valid without retaining any rejected + # or oversized summary fragment. if [ -n "$reason" ]; then summary='{}'; fi if [ -z "$reason" ]; then summary_sampled=true diff --git a/docs/architecture.md b/docs/architecture.md index 83126a8f5ba..ab28aba2bf2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -74,7 +74,7 @@ In that status-log fallback, a declared external wait reports the distinct `paus The semantic branch reports working only on an exact busy verdict and names the source that produced it; an unknown verdict never becomes working, never permits the status-log fallback, and never becomes a silent idle. For whole-fleet review, `bin/fm-fleet-snapshot.sh --json` emits schema `fm-fleet-snapshot.v1` from the backlog, task metadata, local current crew state, supervision-owned endpoint evidence, PR/report pointers, scout reports, bounded current summaries from registered secondmate homes, and secondmate return-channel guidance. Each home atomically publishes that bounded home summary with freshness epoch metadata at `state/home-summary.json` after a locked session start, a watcher-observed status change, task spawn, task teardown, and on a recurring live-watcher cadence; `bin/fm-home-summary-refresh.sh` owns the publication mechanics. -The fleet snapshot and Bearings paths use the concurrent remote-ledger collection, cache, mixed-fleet fallback, and remote-liveness boundary owned by `bin/fm-fleet-snapshot.sh`'s header. +The fleet snapshot and Bearings paths use the concurrent remote-ledger collection, cache, unreadable-home disclosure, and remote-liveness boundary owned by `bin/fm-fleet-snapshot.sh`'s header. `bin/fm-fleet-view.sh` renders that snapshot as Markdown for humans, while `bin/fm-bearings-snapshot.sh` provides the bounded bearings projection, so both views consume one structured contract instead of reparsing raw fleet files. The script header owns the exact JSON schema. @@ -89,11 +89,12 @@ A registered secondmate's validated home is the authority for bearings current s The original cross-home projection instead treated the secondmate agent as an ordinary parent task, so an idle secondmate's `fm-crew-state` fallback selected the latest append-only parent status event even when structured state in the registered home contradicted it. The parent-status contract also required explicit keyed resolution for decisions and blockers but not for a material `working` phase, so a start event could remain unsuperseded after the corresponding home backlog had moved the work to Done. Generated secondmate charters reject generic receipt or start acknowledgements, key only supervisor-actionable material phase reports, and close an opened phase with a same-key later state or `resolved` event, while the structured home remains authoritative even if that closure is missing. -Cross-home reads validate the seeded identity and operational-directory boundaries, use per-home time and output bounds, and classify unavailable, malformed, or inconsistent structured state as unknown rather than reviving a parent event as current work. +Cross-home reads validate the seeded identity and operational-directory boundaries, collect remote ledgers under one fleet-wide time budget, bound every ledger's bytes, and classify unavailable, malformed, or inconsistent structured state as unknown rather than reviving a parent event as current work. When only an owned child's current classification is unavailable, the home classification stays unknown while independently trustworthy structured decisions, holds, queued and landed records, endpoint identities, counts, and provenance remain available; every other invalid path stays strict and exposes none of those child-derived surfaces. A bounded direct-report terminal tail can help diagnose a mismatch by showing that historical parent wording is still visible, but it is untrusted supplemental evidence because scrollback, prompts, copied output, idle shells, and agent prose are not durable state. The snapshot strips control sequences, retains only capture metadata and literal event-corroboration flags, and never lets terminal evidence override a valid structured classification. -The default path concurrently collects registered remote-home ledgers under one shared bound and may refresh their parent-side cache; live GitHub enrichment exists only behind the bearings `--include-prs` opt-in. +The default path concurrently collects registered remote-home ledgers under one shared bound and may refresh their parent-side cache; a home with neither a valid ledger nor a valid cached copy stays explicitly unreadable and never triggers remote summary computation. +Live GitHub enrichment exists only behind the bearings `--include-prs` opt-in. Optional Relay integrates with the watcher only after explicit opt-in; [configuration.md](configuration.md#relay-env) owns its generated-artifact and dispatch mechanics. At session start, `bin/fm-session-start.sh` emits exactly one primary-harness supervision block rendered by `bin/fm-supervision-instructions.sh` from `docs/supervision-protocols/`. diff --git a/docs/configuration.md b/docs/configuration.md index 15859ac1d87..8c3e30ce37a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -804,8 +804,7 @@ FM_HOME_SUMMARY_TIMEOUT=60 # seconds bounding the complete best-effort home- FM_HOME_SUMMARY_ERROR_LOG_MAX_BYTES=65536 # approximate size cap for state/.home-summary-refresh.log before it is trimmed to the newest 200 lines; invalid or zero values use 65536 FM_HOME_SUMMARY_FAILURE_REPORT=2 # recorded publication failures since the ledger's own last publication before session start reports a HOME_SUMMARY line; invalid or zero values use 2 FM_SNAPSHOT_CREW_STATE_TIMEOUT=10 # seconds bounding each local per-task current-state read inside bin/fm-fleet-snapshot.sh; remote endpoint liveness is not probed on the snapshot path -FM_SNAPSHOT_BUDGET=5 # one total seconds budget for all concurrent remote home-ledger reads and any mixed-fleet fallback they start -FM_SNAPSHOT_LEDGER_MODE=on # on consumes home ledgers with cache/fallback behavior; off retains the bounded legacy per-home summary path for diagnosis +FM_SNAPSHOT_BUDGET=5 # one total seconds budget for all concurrent remote home-ledger reads FM_SNAPSHOT_CACHE_DIR=$FM_HOME/state/secondmate-summary-cache # private parent-side cache of successfully fetched remote home ledgers FM_RECONCILE_REQUEST_MAX_BYTES=1048576 # maximum captured Bearings or fleet snapshot accepted for durable reconcile-notify request publication FM_HEARTBEAT=600 # base seconds between heartbeat scans; no-change heartbeats are absorbed while idle diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index cc8455b9323..8f9f7634954 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -9,6 +9,9 @@ set -u # shellcheck source=tests/lib.sh # shellcheck disable=SC1091 . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +# shellcheck source=bin/fm-secondmate-registry-lib.sh +# shellcheck disable=SC1091 +. "$ROOT/bin/fm-secondmate-registry-lib.sh" BEARINGS="$ROOT/bin/fm-bearings-snapshot.sh" TMP_ROOT=$(fm_test_tmproot fm-bearings) @@ -181,8 +184,28 @@ EOF printf 'needs-decision [key=race]: pick subscribe order\n' > "$mate/state/mate.status" } +refresh_local_secondmate_ledgers() { # + local parent=$1 registry line mate + registry="$parent/data/secondmates.md" + [ -f "$registry" ] && [ -r "$registry" ] || return 0 + while IFS= read -r line || [ -n "$line" ]; do + secondmate_registry_parse_line "$line" || continue + [ "$SECONDMATE_REGISTRY_REMOTE" -eq 0 ] || continue + mate=$SECONDMATE_REGISTRY_HOME + [ -f "$mate/.fm-secondmate-home" ] && [ -f "$mate/AGENTS.md" ] \ + && [ -d "$mate/bin" ] && [ -d "$mate/data" ] && [ -d "$mate/state" ] || continue + FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$mate" \ + FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z FM_SNAPSHOT_NOW_EPOCH=1783792800 \ + "$ROOT/bin/fm-home-summary-refresh.sh" >/dev/null 2>&1 || true + done < "$registry" +} + run() { # local home=$1 fakebin=$2; shift 2 + case " $* " in + *" --all-landed "*) FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME=0 refresh_local_secondmate_ledgers "$home" ;; + *) refresh_local_secondmate_ledgers "$home" ;; + esac PATH="$fakebin:$PATH" FM_HOME="$home" FM_BEARINGS_NOW=2026-07-11T18:00:00Z NET_LOG="$home/net.log" "$BEARINGS" "$@" } @@ -249,10 +272,6 @@ case "${args[0]:-}" in cat "$remote_home/state/home-summary.json" fi ;; - fm-fleet-snapshot.sh) - [ -f "$remote_home/state/fallback-summary.json" ] || exit 1 - cat "$remote_home/state/fallback-summary.json" - ;; *) exit 91 ;; esac SH @@ -296,6 +315,7 @@ EOF "$i" "$i" "$i" >> "$mate/data/backlog.md" i=$((i + 1)) done + refresh_local_secondmate_ledgers "$home" } # This is the Domain Alpha failure shape exactly: the structured home says Phase 7 is Done @@ -524,14 +544,14 @@ write_parent_secondmate_event() { # } test_bad_secondmate_homes_never_revive_parent_work() { - local home fakebin missing invalid unreadable malformed timedout wt json + local home fakebin missing invalid unreadable malformed unknown_child wt json home=$(make_home bad-homes) : > "$home/data/secondmates.md" missing="$TMP_ROOT/missing-home" invalid="$TMP_ROOT/invalid-home" unreadable="$TMP_ROOT/unreadable-home" malformed="$TMP_ROOT/malformed-home" - timedout="$TMP_ROOT/timedout-home" + unknown_child="$TMP_ROOT/unknown-child-home" append_secondmate_registry "$home" missing "$missing" @@ -550,40 +570,43 @@ test_bad_secondmate_homes_never_revive_parent_work() { append_secondmate_registry "$home" malformed "$malformed" write_parent_secondmate_event "$home" malformed "$malformed" "old malformed work" - make_valid_secondmate_home timedout "$timedout" - wt="$timedout/projects/slow" + make_valid_secondmate_home unknown-child "$unknown_child" + wt="$unknown_child/projects/slow" fm_git_init_commit "$wt" git -C "$wt" checkout -q -b fm/slow - printf '## In flight\n- [ ] slow - Slow child (repo: sample) (kind: ship) (since 2026-07-13)\n\n## Queued\n\n## Done\n' > "$timedout/data/backlog.md" - fm_write_meta "$timedout/state/slow.meta" \ + printf '## In flight\n- [ ] slow - Slow child (repo: sample) (kind: ship) (since 2026-07-13)\n\n## Queued\n\n## Done\n' > "$unknown_child/data/backlog.md" + fm_write_meta "$unknown_child/state/slow.meta" \ "window=firstmate:fm-slow" "worktree=$wt" "project=sample" \ "harness=codex" "kind=ship" "mode=no-mistakes" - append_secondmate_registry "$home" timedout "$timedout" - write_parent_secondmate_event "$home" timedout "$timedout" "old timed work" + append_secondmate_registry "$home" unknown-child "$unknown_child" + write_parent_secondmate_event "$home" unknown-child "$unknown_child" "old unknown work" fakebin=$(make_fakebin "$home") - json=$(FAKE_NM_SLEEP=1 FM_SNAPSHOT_SECONDMATE_TIMEOUT=1 run "$home" "$fakebin" --json) + json=$(run "$home" "$fakebin" --json) chmod 700 "$unreadable/data" printf '%s' "$json" | jq -e ' (.secondmates | length) == 5 and all(.secondmates[]; .state == "unknown") - and (.in_flight | map(.id) | all(. != "invalid" and . != "unreadable" and . != "malformed" and . != "timedout")) + and (.in_flight | map(.id) | all(. != "invalid" and . != "unreadable" and . != "malformed" and . != "unknown-child")) and (.secondmates | any(.[]; .id == "missing" and .provenance == "unknown" and .freshness == "unknown" and (.reason | contains("invalid home")))) - and ([.secondmates[] | select(.id != "missing")] + and ([.secondmates[] | select(.id == "invalid" or .id == "unreadable" or .id == "malformed")] | all(.provenance == "parent-event-fallback" and .freshness == "historical-event")) + and (.secondmates | any(.[]; .id == "unknown-child" and .provenance == "structured-home" + and .freshness == "fresh")) and (.secondmates | any(.[]; .id == "invalid" and (.reason | contains("marked for")))) and (.secondmates | any(.[]; .id == "unreadable" and (.reason | test("invalid home|unreadable")))) and (.secondmates | any(.[]; .id == "malformed" and (.reason | contains("unstructured current backlog row")))) - and (.secondmates | any(.[]; .id == "timedout" and (.reason | contains("timed out")))) - and ([.secondmate_reconcile[].id] == ["malformed"]) + and (.secondmates | any(.[]; .id == "unknown-child" and (.reason | contains("child current state unavailable")))) + and ([.secondmate_reconcile[].id] == ["malformed", "unknown-child"]) and (.secondmate_reconcile[0].kind == "unstructured_current") + and (.secondmate_reconcile[1].kind == "child_current_unavailable") ' >/dev/null || fail "bad home outcomes revived stale work or lacked provenance: $json" - pass "missing, invalid, unreadable, malformed, and timed-out homes stay explicit unknowns" + pass "missing, invalid, unreadable, malformed, and unavailable-child homes stay explicit unknowns" } test_oversized_secondmate_summary_stays_strict_unknown() { - local home mate fakebin json legacy i + local home mate fakebin json i home=$(make_home oversized-home) mate="$TMP_ROOT/oversized-secondmate-home" make_valid_secondmate_home oversized "$mate" @@ -613,14 +636,7 @@ EOF and (.decisions_open | any(.owner == "oversized") | not) and (.landed | any(.owner == "oversized") | not) ' >/dev/null || fail "oversized summary revived or retained unvalidated surfaces: $json" - legacy=$(FM_SNAPSHOT_LEDGER_MODE=off FM_SNAPSHOT_SECONDMATE_MAX_BYTES=512 run "$home" "$fakebin" --json) - printf '%s' "$legacy" | jq -e ' - (.secondmates | any(.id == "oversized" and .state == "unknown" - and (.reason | contains("exceeded byte limit")))) - and (.in_flight | any(.id == "oversized") | not) - and (.landed | any(.owner == "oversized") | not) - ' >/dev/null || fail "legacy mode accepted an oversized structured summary: $legacy" - pass "oversized summaries stay strict unknown in ledger and compatibility modes" + pass "oversized ledgers stay strict unknown" } test_secondmate_and_child_bounds_are_disclosed() { @@ -649,6 +665,7 @@ test_secondmate_and_child_bounds_are_disclosed() { done printf '\n## Queued\n\n## Done\n' >> "$mate/data/backlog.md" fakebin=$(make_fakebin "$home") + FM_SNAPSHOT_SECONDMATE_CHILDREN=2 refresh_local_secondmate_ledgers "$home" canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ FM_SNAPSHOT_SECONDMATES=2 FM_SNAPSHOT_SECONDMATE_CHILDREN=2 "$ROOT/bin/fm-fleet-snapshot.sh" --json) printf '%s' "$canonical" | jq -e ' @@ -685,6 +702,7 @@ test_parent_decision_is_untrusted_contradiction_only() { fm_write_secondmate_meta "$home/state/authority.meta" "$mate" "firstmate:fm-authority" sample printf 'needs-decision [key=stale]: old parent question\n' > "$home/state/authority.status" fakebin=$(make_fakebin "$home") + refresh_local_secondmate_ledgers "$home" canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ "$ROOT/bin/fm-fleet-snapshot.sh" --json) printf '%s' "$canonical" | jq -e ' @@ -756,6 +774,7 @@ EOF record_claude_state "$decision/state" "$child" idle printf 'needs-decision [key=live-route]: choose the current route\n' > "$decision/state/$child.status" fakebin=$(make_fakebin "$home") + refresh_local_secondmate_ledgers "$home" canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ "$ROOT/bin/fm-fleet-snapshot.sh" --json) printf '%s' "$canonical" | jq -e ' @@ -809,6 +828,7 @@ EOF record_claude_state "$mate/state" parked idle printf 'needs-decision [key=parked]: choose a route\n' > "$mate/state/parked.status" fakebin=$(make_fakebin "$home") + refresh_local_secondmate_ledgers "$home" canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ "$ROOT/bin/fm-fleet-snapshot.sh" --json) printf '%s' "$canonical" | jq -e ' @@ -824,6 +844,7 @@ EOF ## Done EOF + refresh_local_secondmate_ledgers "$home" canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ "$ROOT/bin/fm-fleet-snapshot.sh" --json) printf '%s' "$canonical" | jq -e ' @@ -856,6 +877,7 @@ EOF printf 'done: complete\n' > "$mate/state/done.status" printf 'failed: stopped\n' > "$mate/state/failed.status" rm "$mate/state/parked.meta" "$mate/state/parked.status" + refresh_local_secondmate_ledgers "$home" canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ "$ROOT/bin/fm-fleet-snapshot.sh" --json) printf '%s' "$canonical" | jq -e ' @@ -903,6 +925,7 @@ test_registry_unavailability_and_bounds_are_explicit() { append_secondmate_registry "$home" "$id" "$mate" done fakebin=$(make_fakebin "$home") + refresh_local_secondmate_ledgers "$home" canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ FM_SNAPSHOT_REGISTRY_RECORDS=2 "$ROOT/bin/fm-fleet-snapshot.sh" --json) printf '%s' "$canonical" | jq -e ' @@ -943,6 +966,7 @@ test_registry_unavailability_and_bounds_are_explicit() { make_valid_secondmate_home z-hidden "$mate" append_secondmate_registry "$home" z-hidden "$mate" fm_write_secondmate_meta "$home/state/z-hidden.meta" "$mate" "firstmate:fm-z-hidden" sample + refresh_local_secondmate_ledgers "$home" canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ FM_SNAPSHOT_REGISTRY_RECORDS=3 "$ROOT/bin/fm-fleet-snapshot.sh" --json) printf '%s' "$canonical" | jq -e ' @@ -1791,6 +1815,7 @@ EOF printf 'working: preparing canary\n' > "$ha/state/prep.status" fakebin=$(make_fakebin "$home") + refresh_local_secondmate_ledgers "$home" canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ "$ROOT/bin/fm-fleet-snapshot.sh" --json) printf '%s' "$canonical" | jq -e ' @@ -1849,6 +1874,7 @@ EOF - [ ] ordinary-orphan - Unowned release task (repo: sshhip) (kind: ship)' \ "$sshhip/data/backlog.md" > "$sshhip/data/backlog.next" mv "$sshhip/data/backlog.next" "$sshhip/data/backlog.md" + refresh_local_secondmate_ledgers "$home" canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ "$ROOT/bin/fm-fleet-snapshot.sh" --json) printf '%s' "$canonical" | jq -e ' @@ -1869,6 +1895,7 @@ EOF sed '/unreadable-child/d' "$sshhip/data/backlog.md" > "$sshhip/data/backlog.next" mv "$sshhip/data/backlog.next" "$sshhip/data/backlog.md" + refresh_local_secondmate_ledgers "$home" canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ "$ROOT/bin/fm-fleet-snapshot.sh" --json) printf '%s' "$canonical" | jq -e ' @@ -1893,6 +1920,7 @@ EOF "harness=claude" "kind=scout" "mode=scout" record_claude_state "$wheel/state" production-observation idle printf 'paused: observation is deliberately held\n' > "$wheel/state/production-observation.status" + refresh_local_secondmate_ledgers "$home" canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ "$ROOT/bin/fm-fleet-snapshot.sh" --json) printf '%s' "$canonical" | jq -e ' @@ -1956,6 +1984,7 @@ EOF sed 's/(kind: program)/(kind: mystery)/' "$hibit/data/backlog.md" > "$hibit/data/backlog.next" mv "$hibit/data/backlog.next" "$hibit/data/backlog.md" + refresh_local_secondmate_ledgers "$home" canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ "$ROOT/bin/fm-fleet-snapshot.sh" --json) printf '%s' "$canonical" | jq -e ' @@ -2136,61 +2165,33 @@ test_remote_ledgers_share_one_concurrent_budget_and_fall_back_to_cache() { pass "remote ledgers collect concurrently under one budget, reuse aged cache, and cancel wedged collectors" } -test_a_remote_home_without_any_ledger_uses_the_mixed_fleet_fallback() { - local parent fakebin remote_home json oversized trailing bytes max_bytes - parent=$(make_home remote-ledger-fallback) +test_a_remote_home_without_any_ledger_is_explicitly_unreadable_without_remote_compute() { + local parent fakebin remote_home json + parent=$(make_home remote-ledger-missing) make_remote_ledger_fleet "$parent" 1 remote_home="$TMP_ROOT/remote-ledger-home-1" - cp "$remote_home/state/home-summary.json" "$remote_home/state/fallback-summary.json" rm -f "$remote_home/state/home-summary.json" "$remote_home/state/slow-ledger-read" fakebin=$(make_remote_ledger_ssh "$parent/remote-ssh") : > "$parent/ledger-calls.log" : > "$parent/ledger-pids.log" + json=$(run_remote_ledger_bearings "$parent" "$fakebin" 1100) printf '%s' "$json" | jq -e ' (.secondmates | length) == 1 - and .secondmates[0].state == "no_active_work" - and (.omitted | any(.surface == "secondmate ledger-1 used mixed-fleet summary fallback")) - ' >/dev/null || fail "a no-ledger remote home did not use and disclose the compatibility fallback: $json" - [ "$(wc -l < "$parent/ledger-calls.log" | tr -d ' ')" -eq 2 ] \ - || fail "the no-ledger home did not perform one file read followed by one compatibility summary" - - cp "$remote_home/state/fallback-summary.json" "$remote_home/state/fallback-summary.base" - bytes=$(LC_ALL=C wc -c < "$remote_home/state/fallback-summary.json" | tr -d ' ') - max_bytes=$((bytes + 4)) - printf '\n\n\n\n\n\n\n\n' >> "$remote_home/state/fallback-summary.json" - trailing=$(FM_SNAPSHOT_LEDGER_MODE=off FM_SNAPSHOT_SECONDMATE_MAX_BYTES="$max_bytes" \ - run_remote_ledger_bearings "$parent" "$fakebin" 1100) - printf '%s' "$trailing" | jq -e ' - .secondmates[0].state == "unknown" - and (.secondmates[0].reason | contains("exceeded byte limit")) - ' >/dev/null || fail "legacy mode ignored trailing bytes beyond the summary bound: $trailing" - mv "$remote_home/state/fallback-summary.base" "$remote_home/state/fallback-summary.json" - - cp "$remote_home/state/fallback-summary.json" "$remote_home/state/fallback-summary.single" - cat "$remote_home/state/fallback-summary.single" "$remote_home/state/fallback-summary.single" \ - > "$remote_home/state/fallback-summary.json" - trailing=$(FM_SNAPSHOT_LEDGER_MODE=off run_remote_ledger_bearings "$parent" "$fakebin" 1100) - printf '%s' "$trailing" | jq -e ' - .secondmates[0].state == "unknown" - and (.secondmates[0].reason | contains("malformed or stale")) - ' >/dev/null || fail "legacy mode accepted multiple summary documents: $trailing" - mv "$remote_home/state/fallback-summary.single" "$remote_home/state/fallback-summary.json" - - jq '.padding = ("x" * 2048)' "$remote_home/state/fallback-summary.json" \ - > "$remote_home/state/fallback-summary.next" - mv "$remote_home/state/fallback-summary.next" "$remote_home/state/fallback-summary.json" - : > "$parent/ledger-calls.log" - oversized=$(FM_SNAPSHOT_SECONDMATE_MAX_BYTES=512 run_remote_ledger_bearings "$parent" "$fakebin" 1100) - printf '%s' "$oversized" | jq -e ' - .secondmates[0].state == "unknown" - and (.secondmates[0].reason | contains("exceeded byte limit")) - ' >/dev/null || fail "an oversized remote compatibility fallback was accepted: $oversized" - pass "a mixed-version remote fallback is bounded before validation" + and .secondmates[0].state == "unknown" + and .secondmates[0].provenance == "unknown" + and (.secondmates[0].reason | contains("home ledger is missing, unreadable, or invalid")) + and (.omitted | any(.surface == "secondmate home(s) with unreadable structured state: 1")) + ' >/dev/null || fail "a no-ledger remote home was not explicitly disclosed as unreadable: $json" + [ "$(wc -l < "$parent/ledger-calls.log" | tr -d ' ')" -eq 1 ] \ + || fail "a no-ledger remote home issued more than its single ledger read" + [ "$(awk -F '\t' 'NR == 1 { print $2 }' "$parent/ledger-calls.log")" = "fm-remote-file.sh" ] \ + || fail "a no-ledger remote home triggered remote summary computation: $(cat "$parent/ledger-calls.log")" + pass "a missing remote ledger stays explicitly unreadable without remote summary computation" } test_remote_ledgers_share_one_concurrent_budget_and_fall_back_to_cache -test_a_remote_home_without_any_ledger_uses_the_mixed_fleet_fallback +test_a_remote_home_without_any_ledger_is_explicitly_unreadable_without_remote_compute test_domain_alpha_stale_parent_event_does_not_become_current_work test_gnu_stat_uses_file_formats_without_bsd_fallback_pollution test_parent_activity_evidence_is_bounded_and_disclosed diff --git a/tests/fm-home-summary-refresh.test.sh b/tests/fm-home-summary-refresh.test.sh index 862d7436181..9f06f44672e 100755 --- a/tests/fm-home-summary-refresh.test.sh +++ b/tests/fm-home-summary-refresh.test.sh @@ -604,20 +604,23 @@ fm_write_meta "$REMOTE_HOME/state/rsm.meta" \ "remote_target=fm-remote:w1:p1" cat > "$TMP_ROOT/sshbin/stalled-ssh" <<'SH' #!/usr/bin/env bash +: > "$FM_TEST_SSH_CALLED" cat > /dev/null sleep 60 SH chmod +x "$TMP_ROOT/sshbin/stalled-ssh" started=$(date +%s) PATH="$FAKEBIN:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$REMOTE_HOME" \ - FM_SSH_BIN="$TMP_ROOT/sshbin/stalled-ssh" \ + FM_SSH_BIN="$TMP_ROOT/sshbin/stalled-ssh" FM_TEST_SSH_CALLED="$TMP_ROOT/stalled-ssh.called" \ FM_SNAPSHOT_NOW="$NOW_TWO" FM_SNAPSHOT_NOW_EPOCH="$EPOCH_TWO" \ - FM_SNAPSHOT_CREW_STATE_TIMEOUT=2 FM_SNAPSHOT_SECONDMATE_TIMEOUT=2 \ + FM_SNAPSHOT_CREW_STATE_TIMEOUT=2 \ "$SNAPSHOT" --secondmate-home-summary > "$TMP_ROOT/stalled-summary.json" \ || fail "an unreachable remote home failed the whole producer" elapsed=$(( $(date +%s) - started )) [ "$elapsed" -lt 40 ] \ - || fail "the producer waited $elapsed seconds on one unreachable remote home" + || fail "the producer waited $elapsed seconds despite skipping remote endpoint state" +[ ! -e "$TMP_ROOT/stalled-ssh.called" ] \ + || fail "the producer issued a remote per-task state probe" jq -e ' .schema == "fm-secondmate-home-summary.v1" and .valid == false @@ -627,7 +630,7 @@ jq -e ' and any(.endpoints[]; .id == "rsm" and .state == "unknown") ' "$TMP_ROOT/stalled-summary.json" >/dev/null \ || fail "an unreachable remote task was not reported as unknown" -pass "producer bounds each per-task current-state read" +pass "producer skips remote per-task state probes" # The watcher's beacon is what the rest of supervision reads as proof it is # alive. Publication is side-band, so no matter how long it takes, the beacon diff --git a/tests/fm-remote-secondmate-lifecycle-e2e.test.sh b/tests/fm-remote-secondmate-lifecycle-e2e.test.sh index 3367404d2f1..5873f441f49 100755 --- a/tests/fm-remote-secondmate-lifecycle-e2e.test.sh +++ b/tests/fm-remote-secondmate-lifecycle-e2e.test.sh @@ -1024,8 +1024,13 @@ resolve_ios_pending() { } resolve_ios_pending -# Structured fleet state comes from each home's own snapshot. The remote host is -# explicit, and the local route remains alongside it. +# Structured fleet state comes from each home's published ledger. The remote +# host is explicit, and the local route remains alongside it. +FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$LOCAL_HOME" \ + "$ROOT/bin/fm-home-summary-refresh.sh" >/dev/null \ + || fail "local fixture did not publish its home ledger" +remote_env "$ROOT/bin/fm-on.sh" ios fm-home-summary-refresh.sh >/dev/null \ + || fail "remote fixture did not publish its home ledger" SNAPSHOT=$(remote_env "$ROOT/bin/fm-fleet-snapshot.sh" --json) if ! printf '%s' "$SNAPSHOT" | jq -e '.secondmate_current.records | any(.id == "ios" and .remote == true and .host == "remote-mac" and .provenance.selected == "structured-home")' >/dev/null; then printf 'secondmate projection:\n%s\n' "$(printf '%s' "$SNAPSHOT" | jq '.secondmate_current')" >&2 @@ -1107,9 +1112,11 @@ mv -f "$TMP_ROOT/remote-ios-before-liveness-legacy.meta" "$remote_route_meta" rm -f "$TMUX_STATE" pass "startup reports alive legacy backends without changing their routes" -# Host loss never creates a local replacement. This legacy fixture has no -# published ledger to cache, so the structured-home read degrades explicitly; +# Host loss never creates a local replacement. Remove both the published ledger +# and its parent-side cache so the structured-home read degrades explicitly; # endpoint liveness remains the startup supervisor's concern. +rm -f -- "$REMOTE_HOME/state/home-summary.json" +rm -rf -- "$PARENT/state/secondmate-summary-cache" launches_before=$(grep -c '^tab create' "$HERDR_LOG" || true) rm -rf -- "$PARENT/state/.watch.lock" rm -f -- "$PARENT/state/.last-watcher-beat" @@ -1119,7 +1126,7 @@ assert_contains "$BOOT_UNAVAILABLE" 'SECONDMATE_LIVENESS: secondmate ios: skippe UNAVAILABLE=$(FM_FAKE_SSH_MODE=unreachable remote_env "$ROOT/bin/fm-fleet-snapshot.sh" --json) printf '%s' "$UNAVAILABLE" | jq -e '.secondmate_current.records | any(.id == "ios" and .current.state == "unknown" and .provenance.selected != "structured-home" - and (.current.reason | test("failed|timed out")))' >/dev/null \ + and (.current.reason | test("home ledger.*(timed out|missing|unreadable|invalid)")))' >/dev/null \ || fail "unreachable no-ledger remote home did not degrade to explicit unknown state" printf '%s' "$UNAVAILABLE" | jq -e '.tasks[] | select(.id == "ios") | .paths.home.present == null and .endpoint.agent_alive == "unknown"' >/dev/null \ || fail "unreachable remote endpoint liveness was not left to supervision" From 772446f615b383a0d811605301deb60c435c09b7 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Tue, 1 Sep 2026 21:36:06 -0700 Subject: [PATCH 2/5] no-mistakes(document): Document ledger-only snapshot reads --- bin/fm-fleet-snapshot.sh | 2 +- docs/architecture.md | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index fb8c9efb155..cd0ff8f4c47 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -179,7 +179,7 @@ usage: fm-fleet-snapshot.sh --json fm-fleet-snapshot.sh --secondmate-home-summary Print a structured snapshot of the firstmate fleet. -JSON is the stable machine-readable output contract. The default ledger mode +JSON is the stable machine-readable output contract. The default snapshot refreshes only its parent-side remote-summary cache as an observational side effect. --secondmate-home-summary emits the bounded structured summary used after a diff --git a/docs/architecture.md b/docs/architecture.md index ab28aba2bf2..244d8acf8eb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -89,11 +89,10 @@ A registered secondmate's validated home is the authority for bearings current s The original cross-home projection instead treated the secondmate agent as an ordinary parent task, so an idle secondmate's `fm-crew-state` fallback selected the latest append-only parent status event even when structured state in the registered home contradicted it. The parent-status contract also required explicit keyed resolution for decisions and blockers but not for a material `working` phase, so a start event could remain unsuperseded after the corresponding home backlog had moved the work to Done. Generated secondmate charters reject generic receipt or start acknowledgements, key only supervisor-actionable material phase reports, and close an opened phase with a same-key later state or `resolved` event, while the structured home remains authoritative even if that closure is missing. -Cross-home reads validate the seeded identity and operational-directory boundaries, collect remote ledgers under one fleet-wide time budget, bound every ledger's bytes, and classify unavailable, malformed, or inconsistent structured state as unknown rather than reviving a parent event as current work. +Cross-home reads validate the seeded identity and operational-directory boundaries and classify unavailable, malformed, or inconsistent structured state as unknown rather than reviving a parent event as current work; `bin/fm-fleet-snapshot.sh`'s header owns collection, cache selection, and unreadable-home behavior. When only an owned child's current classification is unavailable, the home classification stays unknown while independently trustworthy structured decisions, holds, queued and landed records, endpoint identities, counts, and provenance remain available; every other invalid path stays strict and exposes none of those child-derived surfaces. A bounded direct-report terminal tail can help diagnose a mismatch by showing that historical parent wording is still visible, but it is untrusted supplemental evidence because scrollback, prompts, copied output, idle shells, and agent prose are not durable state. The snapshot strips control sequences, retains only capture metadata and literal event-corroboration flags, and never lets terminal evidence override a valid structured classification. -The default path concurrently collects registered remote-home ledgers under one shared bound and may refresh their parent-side cache; a home with neither a valid ledger nor a valid cached copy stays explicitly unreadable and never triggers remote summary computation. Live GitHub enrichment exists only behind the bearings `--include-prs` opt-in. Optional Relay integrates with the watcher only after explicit opt-in; [configuration.md](configuration.md#relay-env) owns its generated-artifact and dispatch mechanics. From a75608973d3da6effeccef50134eba8c84c6c488 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Tue, 1 Sep 2026 22:01:50 -0700 Subject: [PATCH 3/5] no-mistakes(ci): Fixed the snapshot test fixture so ledger refreshes use the same fake executable PATH as the snapshot consumer. This preserves observable endpoint freshness after removing legacy summary computation. Verified stock Bash parsing and all 44 Bearings tests pass under /bin/bash; git diff checks pass --- tests/fm-bearings-snapshot.test.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index 8f9f7634954..e7d811cb1ec 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -203,8 +203,8 @@ refresh_local_secondmate_ledgers() { # run() { # local home=$1 fakebin=$2; shift 2 case " $* " in - *" --all-landed "*) FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME=0 refresh_local_secondmate_ledgers "$home" ;; - *) refresh_local_secondmate_ledgers "$home" ;; + *" --all-landed "*) PATH="$fakebin:$PATH" FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME=0 refresh_local_secondmate_ledgers "$home" ;; + *) PATH="$fakebin:$PATH" refresh_local_secondmate_ledgers "$home" ;; esac PATH="$fakebin:$PATH" FM_HOME="$home" FM_BEARINGS_NOW=2026-07-11T18:00:00Z NET_LOG="$home/net.log" "$BEARINGS" "$@" } From f0d96ec57123b0445fd2cc5f0809853031cc43a2 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Tue, 1 Sep 2026 22:27:22 -0700 Subject: [PATCH 4/5] no-mistakes(ci): Fixed the CI-only snapshot fixture failure by ensuring the bounded-ledger refresh uses its fake tmux backend. This removes host tmux availability as a source of nondeterminism. Verified all 44 Bearings tests pass, Bash syntax passes, and git diff checks are clean --- tests/fm-bearings-snapshot.test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index e7d811cb1ec..5c684289669 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -665,7 +665,7 @@ test_secondmate_and_child_bounds_are_disclosed() { done printf '\n## Queued\n\n## Done\n' >> "$mate/data/backlog.md" fakebin=$(make_fakebin "$home") - FM_SNAPSHOT_SECONDMATE_CHILDREN=2 refresh_local_secondmate_ledgers "$home" + PATH="$fakebin:$PATH" FM_SNAPSHOT_SECONDMATE_CHILDREN=2 refresh_local_secondmate_ledgers "$home" canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ FM_SNAPSHOT_SECONDMATES=2 FM_SNAPSHOT_SECONDMATE_CHILDREN=2 "$ROOT/bin/fm-fleet-snapshot.sh" --json) printf '%s' "$canonical" | jq -e ' From ab40183aeeeb7ad183cf7328c357fa26e23f6d7d Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Tue, 1 Sep 2026 22:55:59 -0700 Subject: [PATCH 5/5] =?UTF-8?q?no-mistakes(ci):=20Fixed=20CI=20nondetermin?= =?UTF-8?q?ism=20in=20the=20Bearings=20fixture:=20all=20local=20ledger=20r?= =?UTF-8?q?efreshes=20now=20use=20the=20fixture=E2=80=99s=20fake=20tmux=20?= =?UTF-8?q?backend=20when=20available,=20instead=20of=20depending=20on=20h?= =?UTF-8?q?ost=20tmux=20state.=20Verified=20stock=20/bin/bash=20syntax,=20?= =?UTF-8?q?git=20diff=20checks,=20and=20all=2044=20Bearings=20tests=20with?= =?UTF-8?q?=20a=20deliberately=20failing=20host=20tmux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/fm-bearings-snapshot.test.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index 5c684289669..840ee3bed24 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -185,16 +185,19 @@ EOF } refresh_local_secondmate_ledgers() { # - local parent=$1 registry line mate + local parent=$1 registry line mate refresh_path=$PATH registry="$parent/data/secondmates.md" [ -f "$registry" ] && [ -r "$registry" ] || return 0 + # Once this fixture's fake backend exists, ledger production must use it too; + # otherwise child state depends on whether the CI host has a live tmux server. + [ ! -x "$parent/fakebin/tmux" ] || refresh_path="$parent/fakebin:$refresh_path" while IFS= read -r line || [ -n "$line" ]; do secondmate_registry_parse_line "$line" || continue [ "$SECONDMATE_REGISTRY_REMOTE" -eq 0 ] || continue mate=$SECONDMATE_REGISTRY_HOME [ -f "$mate/.fm-secondmate-home" ] && [ -f "$mate/AGENTS.md" ] \ && [ -d "$mate/bin" ] && [ -d "$mate/data" ] && [ -d "$mate/state" ] || continue - FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$mate" \ + PATH="$refresh_path" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$mate" \ FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z FM_SNAPSHOT_NOW_EPOCH=1783792800 \ "$ROOT/bin/fm-home-summary-refresh.sh" >/dev/null 2>&1 || true done < "$registry"