From eed857d530b111ec2224193169c5749c56c999e6 Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Wed, 5 Aug 2026 22:50:02 -0400 Subject: [PATCH] fix(bin): keep the fleet view working past the per-argument cap fm-fleet-snapshot.sh handed the whole backlog and task inventory to jq as --argjson command-line arguments. Linux caps a single argv entry at MAX_ARG_STRLEN (131072 bytes) independent of the much larger ARG_MAX total, so the snapshot died with "Argument list too long" once the backlog crossed that cap - a size the fleet had already reached. Bulk JSON now travels on stdin: json_envelope assembles the value with shell builtins and pipes it into `jq -n`, whose filter reads it back with `input`. stdin was chosen over a temp file or a process substitution because this command is read-only and must stay that way - there is no file to create, secure, or leave behind when an interrupted read abandons it. Every unbounded --argjson in the file is converted, not only the one that was failing. The more serious half was a fail-open. task_json_lines built one row per task in a loop piped into `jq -s`, so a row that could not be serialized was dropped while `jq -s` slurped the survivors and exited 0 - a task vanished from a snapshot that still reported success, and a supervisor reviewing the fleet would see a task that does not exist. scout_report_lines had the same shape and its caller checked no status at all, and fm-fleet-view.sh rendered a snapshot that produced no output as a healthy empty fleet at exit 0. A genuine failure now exits nonzero and names what failed, while an empty fleet remains a complete snapshot at exit 0. Tests generate the oversized backlog and the oversized task row programmatically, assert both render in full, and assert that neither a failed snapshot nor an empty one is ever reported as success. The macOS stock-Bash job asserts an exact fleet-view test count, so its expected value moves from 15 to 20 in the same commit as the five new tests to keep the guard and the suite from drifting. The sibling Bearings assertion stays at 41: that suite is unchanged. --- .github/workflows/ci.yml | 4 +- bin/fm-fleet-snapshot.sh | 253 ++++++++++++++++++++------- bin/fm-fleet-view.sh | 22 ++- tests/fm-fleet-snapshot-view.test.sh | 159 +++++++++++++++++ 4 files changed, 368 insertions(+), 70 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 485749d8df..82061b8d5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -360,8 +360,8 @@ jobs: snapshot_output=$(/bin/bash tests/fm-fleet-snapshot-view.test.sh) printf '%s\n' "$snapshot_output" snapshot_count=$(printf '%s\n' "$snapshot_output" | grep -c '^ok - ') - [ "$snapshot_count" -eq 15 ] || { - echo "::error::expected 15 snapshot/fleet-view tests, got $snapshot_count" + [ "$snapshot_count" -eq 20 ] || { + echo "::error::expected 20 snapshot/fleet-view tests, got $snapshot_count" exit 1 } diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index ffa4c639de..88e604abe3 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -55,6 +55,13 @@ # # Compatibility: JSON is the primary machine-readable surface. # Human views must render this output instead of parsing state files again. +# +# Exit status is part of the contract. +# 0 means the snapshot is complete, and an EMPTY fleet is a complete snapshot +# rather than a failure. +# Any read, projection, or assembly failure exits nonzero, names what failed on +# stderr, and prints no snapshot, so a caller that branches on exit status can +# never read a failed read as a healthy empty fleet. set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -142,6 +149,8 @@ usage: fm-fleet-snapshot.sh --json Print a read-only structured snapshot of the firstmate fleet. JSON is the stable machine-readable output contract. +An empty fleet is a complete snapshot and exits 0; a failed read exits nonzero, +says what failed, and prints no snapshot. --secondmate-home-summary emits the bounded structured summary used after a validated registered-home handoff. It is local-only, skips nested secondmate @@ -174,6 +183,42 @@ esac command -v jq >/dev/null 2>&1 || { echo "fm-fleet-snapshot: jq not found" >&2; exit 1; } +# --- bulk JSON transport ---------------------------------------------------- +# +# Linux caps a SINGLE argv entry at MAX_ARG_STRLEN (131072 bytes), independent +# of the much larger ARG_MAX total, so `jq --argjson big "$json"` dies with +# "Argument list too long" as soon as one value crosses that cap - which a +# fleet-sized backlog, a long status stream, or a secondmate roll-up already +# does. Bulk values therefore travel on stdin, which has no per-value cap. +# +# stdin is used rather than a temp file or a process substitution because this +# command is read-only and must stay that way: there is no file to create, +# secure, or leave behind when an interrupted read abandons it, no temp-directory +# dependency, and unlike --slurpfile the filter receives the value itself instead +# of a one-element array it has to unwrap. +# +# Only bulk values move; small scalars stay on argv as --arg/--argjson, where +# they read more clearly at the call site. + +# json_envelope [ ]... +# Assemble the stdin envelope from values that are ALREADY valid JSON, then pipe +# it straight into `jq -n`, whose filter reads it back with `input`. Any `def` +# definitions still come first, exactly as in a normal jq program. +# +# Shell variables and function arguments are ordinary memory rather than execve +# arguments, and printf is a builtin, so assembly never puts bulk data on argv +# either. A malformed envelope - the shape an empty or failed intermediate +# produces - makes jq fail rather than yield a plausible-looking partial record. +json_envelope() { + local out='{' sep='' + while [ "$#" -ge 2 ]; do + out=$out$sep'"'$1'":'$2 + sep=',' + shift 2 + done + printf '%s}' "$out" +} + bool_json() { if [ "$1" = 1 ]; then printf 'true'; else printf 'false'; fi } @@ -401,9 +446,16 @@ task_json_lines() { local meta id kind harness mode yolo project worktree home projects backend target status_log report_path local remote_host remote_root remote_state remote_rc remote_home_present 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 current_state current_source pending_decision blocked_event report_present=0 pr_from_status local open_decisions_tsv open_decisions_json + # pipefail is scoped to this subshell so a row that cannot be serialized fails + # the whole read. Without it the row generator's failure is swallowed by the + # trailing `jq -s`, which happily slurps the surviving rows and exits 0 - so a + # task would disappear from a snapshot that still reported success, and a + # supervisor reviewing the fleet would see a task that does not exist. + ( + set -o pipefail for meta in "$STATE"/*.meta; do [ -e "$meta" ] || continue id=$(basename "$meta" .meta) @@ -442,7 +494,6 @@ task_json_lines() { current_json=$(crew_state_json "$id") event_json=$(status_event_json "$status_log") - last_event_raw=$(printf '%s' "$event_json" | jq -r '.last_event.raw // ""') current_state=$(printf '%s' "$current_json" | jq -r '.state // ""') current_source=$(printf '%s' "$current_json" | jq -r '.source // ""') @@ -525,7 +576,17 @@ task_json_lines() { home_json=$(jq -n '{path:null,present:false}') fi - jq -n \ + # The status stream, its keyed open-decision fold, and the reconciled + # current state are all unbounded, so they travel on stdin. + json_envelope \ + current_state "$current_json" \ + meta_path "$meta_json" \ + status_log "$status_json" \ + report "$report_json" \ + worktree_path "$worktree_json" \ + home_path "$home_json" \ + open_decisions "$open_decisions_json" \ + | jq -n \ --arg id "$id" \ --arg kind "$kind" \ --arg harness "$harness" \ @@ -543,19 +604,19 @@ task_json_lines() { --arg pr_source "$pr_source" \ --arg agent_alive "$agent_alive" \ --arg observed_at "$SNAPSHOT_NOW" \ - --arg last_event_raw "$last_event_raw" \ - --argjson current_state "$current_json" \ - --argjson meta_path "$meta_json" \ - --argjson status_log "$status_json" \ - --argjson report "$report_json" \ - --argjson worktree_path "$worktree_json" \ - --argjson home_path "$home_json" \ --argjson endpoint_exists "$endpoint_exists" \ - --argjson open_decisions "$open_decisions_json" \ --argjson pending_decision "$(bool_json "$pending_decision")" \ --argjson blocked_event "$(bool_json "$blocked_event")" \ --argjson report_present "$(bool_json "$report_present")" \ - '{ + 'input as $in + | $in.current_state as $current_state + | $in.meta_path as $meta_path + | $in.status_log as $status_log + | $in.report as $report + | $in.worktree_path as $worktree_path + | $in.home_path as $home_path + | $in.open_decisions as $open_decisions + | { id:$id, kind:$kind, harness:($harness // ""), @@ -584,7 +645,7 @@ task_json_lines() { blocked_event:$blocked_event, open_decisions:$open_decisions, scout_report_present:$report_present, - last_event_text:$last_event_raw + last_event_text:($status_log.last_event.raw // "") }, actions:( if $kind == "secondmate" then @@ -596,8 +657,9 @@ task_json_lines() { steer:"bin/fm-send.sh fm-\($id) \u0027\u0027", return_channel_note:null} end) - }' + }' || exit 1 done | jq -s 'sort_by(.id)' + ) } # Main-home current-inventory validity: same orphan / unstructured-current checks @@ -605,10 +667,12 @@ task_json_lines() { # Meta inventory remains the sole source of live workers; this object only # discloses backlog↔task inconsistency for renderers (Bearings omitted/gates). main_inventory_json() { # - jq -n \ - --argjson backlog "$1" \ - --argjson tasks "$2" ' - ([ $backlog.records[]? + json_envelope backlog "$1" tasks "$2" \ + | jq -n ' + input as $in + | $in.backlog as $backlog + | $in.tasks as $tasks + | ([ $backlog.records[]? | select((.state == "in_flight" or .state == "queued") and (.structured | not)) ]) as $unstructured_current | ([ $backlog.records[]? | select(.state == "in_flight" and .structured and .requires_child_metadata) ]) as $owned_in_flight @@ -633,19 +697,21 @@ main_inventory_json() { # # This mode never reads parent events or terminal text and never aggregates # nested secondmates. secondmate_home_summary_json() { # - jq -n \ + json_envelope backlog "$1" tasks "$2" \ + | jq -n \ --arg generated "$SNAPSHOT_NOW" \ --arg home "$FM_HOME" \ --argjson child_n "$FM_SNAPSHOT_SECONDMATE_CHILDREN" \ --argjson queued_n "$FM_SNAPSHOT_SECONDMATE_QUEUED" \ --argjson decisions_n "$FM_SNAPSHOT_SECONDMATE_DECISIONS" \ - --argjson landed_n "$FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME" \ - --argjson backlog "$1" \ - --argjson tasks "$2" ' + --argjson landed_n "$FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME" ' def trunc($n): tostring | gsub("\\s+"; " ") | if length > $n then .[:$n] + "…" else . end; - ([ $backlog.records[]? + input as $in + | $in.backlog as $backlog + | $in.tasks as $tasks + | ([ $backlog.records[]? | select((.state == "in_flight" or .state == "queued") and (.structured | not)) ]) as $unstructured_current | ([ $backlog.records[]? | select(.state == "in_flight" and .structured) ]) as $owned_in_flight | ([ $backlog.records[]? @@ -1062,7 +1128,8 @@ terminal_evidence_json() { # - jq -n --argjson summary "$1" --argjson activities "$2" --argjson decisions "$3" ' + json_envelope summary "$1" activities "$2" decisions "$3" \ + | jq -n ' def keyed: . != null and . != "" and . != "default"; def result($e; $matches; $complete; $surface): $e + { @@ -1073,7 +1140,11 @@ 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 status_file event_raw event_note event_epoch event_age local activity_scan activities decisions reconciliation provenance freshness reason summary summary_rc summary_bytes summary_valid summary_reason summary_invalidity state current_reason terminal terminal_contradiction contradiction - local records='[]' seen_homes='' + local records='' records_json seen_homes='' registry=$(registry_secondmates_json) || return 1 - union=$(jq -n --argjson registry "$registry" --argjson tasks "$tasks" ' - ($registry.records // []) as $registered + union=$(json_envelope registry "$registry" tasks "$tasks" \ + | jq -n ' + input as $in + | $in.registry as $registry + | $in.tasks as $tasks + | ($registry.records // []) as $registered | (($registered | map(.id)) // []) as $registered_ids | ([ $registered[] as $r | $r + {parent_task:([$tasks[] | select(.id == $r.id)][0] // null)} ] @@ -1142,9 +1217,9 @@ secondmate_current_json() { # parent_task:$t} ]) | sort_by(.id) | {registry:$registry,records:.}') || return 1 - total_registered=$(printf '%s' "$union" | jq '[.records[] | select(.registered)] | length') - total=$(printf '%s' "$union" | jq '.records | length') - rows=$(printf '%s' "$union" | jq -c --argjson cap "$FM_SNAPSHOT_SECONDMATES" '(if $cap == 0 then .records else .records[:$cap] end)[]') + total_registered=$(printf '%s' "$union" | jq '[.records[] | select(.registered)] | length') || return 1 + total=$(printf '%s' "$union" | jq '.records | length') || return 1 + rows=$(printf '%s' "$union" | jq -c --argjson cap "$FM_SNAPSHOT_SECONDMATES" '(if $cap == 0 then .records else .records[:$cap] end)[]') || return 1 shown=$(printf '%s\n' "$rows" | grep -c . || true) truncated=$((total - shown)) @@ -1266,13 +1341,26 @@ secondmate_current_json() { # '{provenance:"parent-direct-report-terminal",trust:"untrusted-supplement",captured:false,observed_at:$observed,freshness:"not-collected",reason:"no useful contradiction check",lines:0,bytes:0,event_note_seen:false,contradiction:false}') fi if printf '%s' "$terminal" | jq -e '.contradiction == true' >/dev/null; then contradiction=true; fi - record=$(jq -n \ + record=$(json_envelope \ + summary "$summary" \ + decisions "$decisions" \ + activities "$activities" \ + activity_scan "$activity_scan" \ + reconciliation "$reconciliation" \ + terminal "$terminal" \ + | jq -n -c \ --arg id "$id" --arg home "$home" --arg host "$host" --argjson remote "$remote" --arg state "$state" --arg current_reason "$current_reason" --arg observed "$SNAPSHOT_NOW" \ - --argjson registered "$registered" --argjson summary "$summary" --argjson summary_valid "$summary_valid" --argjson decisions "$decisions" \ - --argjson activities "$activities" --argjson activity_scan "$activity_scan" \ - --argjson reconciliation "$reconciliation" --argjson terminal "$terminal" --argjson contradiction "$contradiction" \ + --argjson registered "$registered" --argjson summary_valid "$summary_valid" \ + --argjson contradiction "$contradiction" \ --arg event_raw "$event_raw" --arg event_note "$event_note" --argjson event_age "$event_age" ' - {id:$id,home:$home,host:($host | if . == "" then null else . end),remote:$remote,registered:$registered, + input as $in + | $in.summary as $summary + | $in.decisions as $decisions + | $in.activities as $activities + | $in.activity_scan as $activity_scan + | $in.reconciliation as $reconciliation + | $in.terminal as $terminal + | {id:$id,home:$home,host:($host | if . == "" then null else . end),remote:$remote,registered:$registered, current:{state:$state,reason:($current_reason | if . == "" then null else . end)},invalidity:$summary.invalidity, provenance:{selected:"structured-home",structured_home:$home,summary_valid:$summary_valid, trust:(if $summary_valid then "complete" else "partial-structured" end),parent_event_role:"historical-only"}, @@ -1281,7 +1369,7 @@ secondmate_current_json() { # decisions_open:$summary.decisions_open,holds:$summary.holds,queued:$summary.queued, landed:$summary.landed,endpoints:$summary.endpoints,counts:$summary.counts,omitted:$summary.omitted, parent_event:{raw:$event_raw,note:$event_note,age_seconds:$event_age,open_activities:$activities,open_decisions:$decisions,activity_scan:$activity_scan,reconciliation:$reconciliation}, - terminal_evidence:$terminal,contradiction:$contradiction}') + terminal_evidence:$terminal,contradiction:$contradiction}') || return 1 else if [ -n "$event_raw" ]; then provenance='parent-event-fallback' @@ -1296,36 +1384,54 @@ secondmate_current_json() { # terminal=$(jq -n --arg observed "$SNAPSHOT_NOW" \ '{provenance:"parent-direct-report-terminal",trust:"untrusted-supplement",captured:false,observed_at:$observed,freshness:"not-collected",reason:"no parent event to compare",lines:0,bytes:0,event_note_seen:false,contradiction:false}') fi - record=$(jq -n \ + record=$(json_envelope \ + decisions "$decisions" \ + activities "$activities" \ + activity_scan "$activity_scan" \ + terminal "$terminal" \ + | jq -n -c \ --arg id "$id" --arg home "$home" --arg host "$host" --argjson remote "$remote" --arg reason "$reason" --arg observed "$SNAPSHOT_NOW" \ --arg provenance "$provenance" --arg freshness "$freshness" --arg event_raw "$event_raw" --arg event_note "$event_note" \ - --argjson registered "$registered" --argjson event_age "$event_age" --argjson activities "$activities" --argjson activity_scan "$activity_scan" \ - --argjson decisions "$decisions" --argjson terminal "$terminal" ' - {id:$id,home:($home | if . == "" then null else . end),host:($host | if . == "" then null else . end),remote:$remote,registered:$registered, + --argjson registered "$registered" --argjson event_age "$event_age" ' + input as $in + | $in.decisions as $decisions + | $in.activities as $activities + | $in.activity_scan as $activity_scan + | $in.terminal as $terminal + | {id:$id,home:($home | if . == "" then null else . end),host:($host | if . == "" then null else . end),remote:$remote,registered:$registered, current:{state:"unknown",reason:$reason},invalidity:null, provenance:{selected:$provenance,structured_home:($home | if . == "" then null else . end),parent_event_role:"fallback-only-not-current"}, freshness:{status:$freshness,observed_at:$observed,age_seconds:$event_age}, active_children:[],decisions_open:[],holds:[],queued:[],landed:[],endpoints:[],counts:{active_children:0,decisions_open:0,holds:0,queued:0,landed:0,endpoints:0},omitted:[], parent_event:{raw:$event_raw,note:$event_note,age_seconds:$event_age,open_activities:$activities,open_decisions:$decisions,activity_scan:$activity_scan}, - terminal_evidence:$terminal,contradiction:false}') + terminal_evidence:$terminal,contradiction:false}') || return 1 fi - records=$(jq -n --argjson records "$records" --argjson record "$record" '$records + [$record]') + # Each record is one compact line, collected in the shell and slurped once + # below. Re-parsing the whole accumulated array per row would put it back on + # argv and cost O(n^2) parses for no benefit. + records=$records$record$'\n' done < - jq -n --argjson current "$1" ' - {records:[ $current.records[] + json_envelope current "$1" \ + | jq -n ' + input as $in + | $in.current as $current + | {records:[ $current.records[] | select(.provenance.selected == "structured-home") as $mate | $mate.landed[] | . + {home:$mate.home,home_id:$mate.id}], @@ -1347,13 +1453,19 @@ scout_report_lines() { jq -n '[]' return 0 fi - LC_ALL=C find "$DATA" -mindepth 2 -maxdepth 2 -type f -name report.md -print \ - | sort \ - | while IFS= read -r report; do - id=$(basename "$(dirname "$report")") - jq -n --arg id "$id" --arg path "$report" '{id:$id,path:$path}' - done \ - | jq -s 'sort_by(.id)' + # pipefail again: an unreadable data directory, or a row that cannot be + # serialized, must fail the scan rather than reporting the reports it did + # happen to reach as the complete set. + ( + set -o pipefail + LC_ALL=C find "$DATA" -mindepth 2 -maxdepth 2 -type f -name report.md -print \ + | sort \ + | while IFS= read -r report; do + id=$(basename "$(dirname "$report")") + jq -n --arg id "$id" --arg path "$report" '{id:$id,path:$path}' || exit 1 + done \ + | jq -s 'sort_by(.id)' + ) } BACKLOG_JSON=$(backlog_json) || { echo "fm-fleet-snapshot: backlog read failed" >&2; exit 1; } @@ -1365,7 +1477,8 @@ if [ "$OUTPUT_MODE" = secondmate-home-summary ]; then exit 0 fi -SCOUT_REPORTS_JSON=$(scout_report_lines) +SCOUT_REPORTS_JSON=$(scout_report_lines) \ + || { echo "fm-fleet-snapshot: scout report scan failed" >&2; exit 1; } MAIN_INVENTORY_JSON=$(main_inventory_json "$BACKLOG_JSON" "$TASKS_JSON") \ || { echo "fm-fleet-snapshot: main inventory summary failed" >&2; exit 1; } SECONDMATE_CURRENT_JSON=$(secondmate_current_json "$TASKS_JSON") \ @@ -1373,7 +1486,14 @@ SECONDMATE_CURRENT_JSON=$(secondmate_current_json "$TASKS_JSON") \ SECONDMATE_LANDED_JSON=$(secondmate_landed_from_current_json "$SECONDMATE_CURRENT_JSON") \ || { echo "fm-fleet-snapshot: secondmate landed projection failed" >&2; exit 1; } -jq -n \ +json_envelope \ + backlog "$BACKLOG_JSON" \ + tasks "$TASKS_JSON" \ + main_inventory "$MAIN_INVENTORY_JSON" \ + scout_reports "$SCOUT_REPORTS_JSON" \ + secondmate_current "$SECONDMATE_CURRENT_JSON" \ + secondmate_landed "$SECONDMATE_LANDED_JSON" \ + | jq -n \ --arg generated "$SNAPSHOT_NOW" \ --arg fm_home "$FM_HOME" \ --arg fm_root "$FM_ROOT" \ @@ -1381,13 +1501,14 @@ jq -n \ --arg data "$DATA" \ --arg config "$CONFIG" \ --arg projects "$PROJECTS" \ - --argjson backlog "$BACKLOG_JSON" \ - --argjson tasks "$TASKS_JSON" \ - --argjson main_inventory "$MAIN_INVENTORY_JSON" \ - --argjson scout_reports "$SCOUT_REPORTS_JSON" \ - --argjson secondmate_current "$SECONDMATE_CURRENT_JSON" \ - --argjson secondmate_landed "$SECONDMATE_LANDED_JSON" \ - 'def backlog_by_id($id): ($backlog.records[]? | select(.structured == true and .id == $id) | .) // null; + 'input as $in + | $in.backlog as $backlog + | $in.tasks as $tasks + | $in.main_inventory as $main_inventory + | $in.scout_reports as $scout_reports + | $in.secondmate_current as $secondmate_current + | $in.secondmate_landed as $secondmate_landed + | def backlog_by_id($id): ($backlog.records[]? | select(.structured == true and .id == $id) | .) // null; def task_by_id($id): ($tasks[]? | select(.id == $id) | .) // null; def report_kind($id): (task_by_id($id).kind // backlog_by_id($id).kind // "scout"); { @@ -1404,4 +1525,4 @@ jq -n \ secondmate_guidance:{ note:"For kind=secondmate, bearings selects validated structured state from that registered home; parent events and bounded terminal evidence are fallback-only supplements and never current-state authority." } - }' + }' || { echo "fm-fleet-snapshot: snapshot assembly failed" >&2; exit 1; } diff --git a/bin/fm-fleet-view.sh b/bin/fm-fleet-view.sh index 909c792b29..d30647b085 100755 --- a/bin/fm-fleet-view.sh +++ b/bin/fm-fleet-view.sh @@ -4,6 +4,11 @@ # This command intentionally does not parse fleet state itself. # It shells out to fm-fleet-snapshot.sh --json and renders that stable # structured contract for humans. +# +# It inherits that command's exit-status contract: an empty fleet renders and +# exits 0, while a snapshot that failed or produced nothing exits nonzero and +# says so, because supervision reviews the fleet from this view and must never +# read a failed read as a healthy fleet. set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -26,7 +31,20 @@ esac command -v jq >/dev/null 2>&1 || { echo "fm-fleet-view: jq not found" >&2; exit 1; } -SNAPSHOT=$("$SCRIPT_DIR/fm-fleet-snapshot.sh" --json) || exit $? +# A failed snapshot must never render as a healthy fleet. An empty FLEET is +# valid and still produces a full snapshot object, so empty OUTPUT can only mean +# the snapshot did not complete - and reporting that as success would let fleet +# supervision degrade silently as the fleet and backlog grow. +SNAPSHOT=$("$SCRIPT_DIR/fm-fleet-snapshot.sh" --json) +SNAPSHOT_RC=$? +if [ "$SNAPSHOT_RC" -ne 0 ]; then + echo "fm-fleet-view: fleet snapshot failed (exit $SNAPSHOT_RC); no fleet state was read" >&2 + exit "$SNAPSHOT_RC" +fi +if [ -z "$SNAPSHOT" ]; then + echo "fm-fleet-view: fleet snapshot produced no output; no fleet state was read" >&2 + exit 1 +fi printf '%s\n' "$SNAPSHOT" | jq -r ' def dash($v): if $v == null or $v == "" then "-" else $v end; @@ -93,4 +111,4 @@ printf '%s\n' "$SNAPSHOT" | jq -r ' "", "## Secondmates", .secondmate_guidance.note -' +' || { echo "fm-fleet-view: rendering the fleet snapshot failed" >&2; exit 1; } diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index f47c70f2fa..22d165ccbf 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -779,6 +779,160 @@ test_parked_scout_decision_stays_pending() { pass "a scout still parked at a decision stays pending (terminal clear does not over-fire)" } +# --- scale, and the success/failure signal ---------------------------------- +# +# Two properties are tested together because the fleet view fails at both ends. +# A fleet-sized home must actually render, and a genuine failure must be +# distinguishable from an empty fleet by exit status alone. +# The second is the load-bearing one: supervision reviews the fleet from this +# view, so a snapshot that reports success while producing nothing - or while +# silently dropping rows - degrades supervision with no signal that it has. +# +# Linux caps a single argv entry at MAX_ARG_STRLEN (131072 bytes) regardless of +# the far larger ARG_MAX total, so any bulk value handed to a child process as +# one argument fails once it crosses that cap. +# The fixtures below are generated rather than copied from a real home so the +# size guarantee is explicit and reproducible. +# On platforms with no per-argument cap these cases still assert correct +# rendering and correct exit status; only the argv failure mode is +# Linux-specific. +FM_TEST_MAX_ARG_STRLEN=131072 + +# write_oversized_backlog : a canonical backlog whose +# markdown alone clears the per-argument cap, so its JSON expansion - strictly +# larger - cannot travel as one argv entry. +write_oversized_backlog() { # + local home=$1 count=$2 i pad bytes + pad=$(printf 'holds this record above the per-argument cap. %.0s' $(seq 1 24)) + { + printf '## In flight\n\n## Queued\n' + for i in $(seq 1 "$count"); do + printf -- '- [ ] bulk-%03d - Bulk task %03d (repo: alpha) (kind: ship) (since 2026-07-08)\n' "$i" "$i" + printf ' Retained note %03d: %s\n' "$i" "$pad" + done + printf '\n## Done\n' + } > "$home/data/backlog.md" + bytes=$(LC_ALL=C wc -c < "$home/data/backlog.md" | tr -d ' ') + [ "$bytes" -gt "$FM_TEST_MAX_ARG_STRLEN" ] \ + || fail "fixture is vacuous: backlog is $bytes bytes, must exceed $FM_TEST_MAX_ARG_STRLEN" +} + +test_oversized_backlog_still_renders() { + local home out rc view + home=$(make_home oversized-backlog) + write_oversized_backlog "$home" 120 + out=$(FM_HOME="$home" "$SNAPSHOT" --json) + rc=$? + expect_code 0 "$rc" "an oversized but valid backlog must snapshot successfully" + printf '%s' "$out" | jq -e ' + ([.backlog.records[] | select(.state == "queued")] | length) == 120 + and .backlog.present == true + and .main_inventory.valid == true + and .main_inventory.unstructured_current_count == 0 + ' >/dev/null || fail "every oversized-backlog record must survive the snapshot" + view=$(FM_HOME="$home" "$VIEW") + rc=$? + expect_code 0 "$rc" "an oversized but valid backlog must render successfully" + assert_contains "$view" "bulk-001" "the first oversized-backlog record must render" + assert_contains "$view" "bulk-120" "the last oversized-backlog record must render" + pass "an oversized backlog snapshots and renders every record" +} + +# The serious half: a task row too large to hand a child process as one +# argument must never disappear from a snapshot that still reports success. +# A vanished task reads to a supervisor as a task that does not exist. +test_oversized_task_row_is_never_silently_dropped() { + local home out rc i pad + home=$(make_home oversized-task-row) + printf '## In flight\n\n## Queued\n\n## Done\n' > "$home/data/backlog.md" + fm_write_meta "$home/state/small-task.meta" \ + "project=alpha" "harness=claude" "kind=ship" "mode=ship" + printf 'working: ordinary sized stream\n' > "$home/state/small-task.status" + fm_write_meta "$home/state/wide-task.meta" \ + "project=alpha" "harness=claude" "kind=ship" "mode=ship" + pad=$(printf 'decision detail that widens the open-decision fold. %.0s' $(seq 1 24)) + : > "$home/state/wide-task.status" + for i in $(seq 1 200); do + printf 'needs-decision [key=k%03d]: %s\n' "$i" "$pad" >> "$home/state/wide-task.status" + done + [ "$(LC_ALL=C wc -c < "$home/state/wide-task.status" | tr -d ' ')" -gt "$FM_TEST_MAX_ARG_STRLEN" ] \ + || fail "fixture is vacuous: the wide task stream must exceed $FM_TEST_MAX_ARG_STRLEN bytes" + out=$(FM_HOME="$home" "$SNAPSHOT" --json) + rc=$? + expect_code 0 "$rc" "a large but valid task stream must snapshot successfully" + printf '%s' "$out" | jq -e ' + (.tasks | map(.id) | sort) == ["small-task","wide-task"] + and ((.tasks[] | select(.id == "wide-task") | .hints.open_decisions | length) == 200) + ' >/dev/null \ + || fail "a task with an oversized row must not vanish from a successful snapshot: $(printf '%s' "$out" | jq -c '.tasks | map(.id)')" + pass "an oversized task row stays in the snapshot instead of vanishing silently" +} + +test_empty_fleet_is_success_not_failure() { + local home rc + home=$(make_home empty-exit-status) + FM_HOME="$home" "$SNAPSHOT" --json >/dev/null 2>&1 + rc=$? + expect_code 0 "$rc" "an empty fleet is not a snapshot failure" + FM_HOME="$home" "$VIEW" >/dev/null 2>&1 + rc=$? + expect_code 0 "$rc" "an empty fleet is not a render failure" + pass "an empty fleet exits 0 and is not confused with failure" +} + +# A home whose data directory cannot be read is a genuine failure, not an empty +# fleet. Root ignores the permission bits, so the case reports itself skipped +# rather than passing vacuously. +test_unreadable_home_data_fails_loudly() { + local home out rc + if [ "$(id -u)" = 0 ]; then + pass "skipped: running as root ignores the unreadable-data permission bits" + return 0 + fi + home=$(make_home unreadable-data) + printf '## In flight\n\n## Queued\n\n## Done\n' > "$home/data/backlog.md" + chmod 000 "$home/data" + out=$(FM_HOME="$home" "$SNAPSHOT" --json 2>&1) + rc=$? + chmod 755 "$home/data" + [ "$rc" -ne 0 ] || fail "an unreadable home data directory must not report success: $out" + assert_contains "$out" "fm-fleet-snapshot:" "a snapshot failure must say what failed" + pass "an unreadable home data directory fails loudly instead of reporting an empty fleet" +} + +# The renderer's own half of the contract, driven through a stub snapshot so the +# two failure shapes are exercised independently of any real fleet state: +# an explicit nonzero snapshot, and the measured symptom - a snapshot that +# reports success while producing nothing at all. +test_view_never_reports_success_for_a_failed_snapshot() { + local sandbox out rc + sandbox=$TMP_ROOT/view-failure-sandbox + mkdir -p "$sandbox" + cp "$VIEW" "$sandbox/fm-fleet-view.sh" + + cat > "$sandbox/fm-fleet-snapshot.sh" <<'SH' +#!/usr/bin/env bash +echo "fm-fleet-snapshot: simulated read failure" >&2 +exit 1 +SH + chmod +x "$sandbox/fm-fleet-snapshot.sh" + out=$("$sandbox/fm-fleet-view.sh" 2>&1) + rc=$? + [ "$rc" -ne 0 ] || fail "the fleet view must not report success when the snapshot fails: $out" + assert_contains "$out" "fm-fleet-view:" "a failed snapshot must be reported by the view" + + cat > "$sandbox/fm-fleet-snapshot.sh" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$sandbox/fm-fleet-snapshot.sh" + out=$("$sandbox/fm-fleet-view.sh" 2>&1) + rc=$? + [ "$rc" -ne 0 ] || fail "a snapshot that produced nothing must not render as a healthy empty fleet: $out" + assert_contains "$out" "fm-fleet-view:" "an empty snapshot must be reported by the view" + pass "the fleet view refuses to report success for a failed or empty snapshot" +} + test_empty_fleet_json test_fixture_snapshot_json test_main_inventory_orphan_and_unstructured_disclosure @@ -794,3 +948,8 @@ test_scout_reports_include_teardown_reports test_backlog_tasks_axi_forms_and_overrides test_view_renders_snapshot test_view_renders_dead_secondmate_agent_status +test_oversized_backlog_still_renders +test_oversized_task_row_is_never_silently_dropped +test_empty_fleet_is_success_not_failure +test_unreadable_home_data_fails_loudly +test_view_never_reports_success_for_a_failed_snapshot