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