From c0dea2154ec5ed91d96d324198faff55d490effe Mon Sep 17 00:00:00 2001 From: Freudator86 <94322668+Freudator86@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:01:32 +0000 Subject: [PATCH 1/4] fix(bin): lift the argv-size ceiling out of the fleet snapshot jq's --argjson hands its value to execve as one argv string, and Linux caps a single argv string at MAX_ARG_STRLEN (131072 bytes) regardless of how much total ARG_MAX is left. bin/fm-fleet-snapshot.sh bound unbounded aggregates - the parsed backlog, the task inventory, a secondmate home summary, the aggregated secondmate records - that way, so once a home outgrew the ceiling every affected composition aborted with "Argument list too long". The failure was total rather than partial: --json, --secondmate-home-summary, and the bearings projection over them all produced no output at all, and the message named jq and an argument list, which reads like corrupt data rather than a size limit. On this vessel a 259 KB rendered backlog already tripped it. Feed every value that grows with the size of a home to jq on stdin instead and bind it in the filter with `input`, which has no argv ceiling. Values go from shell variable straight into the pipeline, so nothing large reaches argv and there are no temp files to create, guard, or clean up on failure paths. bin/fm-backlog-lint.sh already avoided the same hazard with --slurpfile. Small, explicitly bounded values - counts, flags, paths, a single status line - stay on --arg/--argjson. Output is byte-for-byte unchanged for inputs that worked before, and a failed composition still exits non-zero and names its stage rather than degrading to an empty result. Two further sites turned out to be reachable today, not merely latent. A registered secondmate's home summary is accepted up to FM_SNAPSHOT_SECONDMATE_MAX_BYTES (262144), which is above the argv ceiling, so a 140 KB summary passed validation and then broke the reconciliation, record composition, accumulator, and landed roll-up that consume it. That path failed silently - exit 0 with the whole home reported unreadable and its landed work erased - which is worse than the loud abort. bin/fm-bearings-snapshot.sh needed no change: it already pipes the snapshot on stdin and inherited the bug only through this script. Its --argjson PR row set is bounded by the per-repo and per-run PR caps, not by home size. --- bin/fm-fleet-snapshot.sh | 104 +++++++---- bin/fm-test-run.sh | 3 +- tests/fm-fleet-snapshot-argv-limit.test.sh | 207 +++++++++++++++++++++ 3 files changed, 275 insertions(+), 39 deletions(-) create mode 100755 tests/fm-fleet-snapshot-argv-limit.test.sh diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 0f2b26348b..6c293656a9 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -193,6 +193,30 @@ bool_json() { if [ "$1" = 1 ]; then printf 'true'; else printf 'false'; fi } +# Feed large JSON values to jq on stdin instead of on argv. +# +# jq's --argjson hands its value to execve as ONE argv string, and Linux caps a +# single argv string at MAX_ARG_STRLEN (131072 bytes) independently of the much +# larger total ARG_MAX. Once a home's backlog, task inventory, or aggregated +# secondmate state rendered past that ceiling, every composition that bound it +# with --argjson died with "Argument list too long", and the failure was total: +# the snapshot, the secondmate home summary, and the bearings projection over +# them all produced no output at all. +# +# Every value that can grow with the size of a home is piped through here and +# bound in the filter with `input`, which has no such ceiling. The value goes +# from shell variable straight into the pipeline, so nothing large reaches argv +# and there are no temp files to create, guard, or clean up on failure paths. +# jq's own stdin is this pipe, so it also stays safe inside a `while read` loop. +# +# `input` binds in stream order, so each caller's leading `input as $name` lines +# must match its argument order. Small, explicitly bounded values (counts, flags, +# paths, single status lines) stay on --arg/--argjson where they read better. +# Exit status is jq's, so callers keep their existing hard-failure checks. +json_stdin() { # ... + printf '%s\n' "$@" +} + path_present_json() { # local present=0 [ -e "$1" ] && present=1 @@ -503,7 +527,7 @@ task_json_lines() { if [ -n "$worktree" ]; then worktree_json=$(path_present_json "$worktree"); else worktree_json=$(jq -n '{path:null,present:false}'); fi if [ -n "$home" ]; then home_json=$(path_present_json "$home"); else home_json=$(jq -n '{path:null,present:false}'); fi - jq -n \ + json_stdin "$open_decisions_json" | jq -n \ --arg id "$id" \ --arg kind "$kind" \ --arg harness "$harness" \ @@ -527,11 +551,11 @@ task_json_lines() { --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 $open_decisions + | { id:$id, kind:$kind, harness:($harness // ""), @@ -580,10 +604,9 @@ 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_stdin "$1" "$2" | jq -n ' + input as $backlog | input 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 @@ -608,16 +631,15 @@ main_inventory_json() { # # This mode never reads parent events or terminal text and never aggregates # nested secondmates. secondmate_home_summary_json() { # - jq -n \ + json_stdin "$1" "$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" ' - def trunc($n): + --argjson landed_n "$FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME" ' + input as $backlog | input as $tasks + | def trunc($n): tostring | gsub("\\s+"; " ") | if length > $n then .[:$n] + "…" else . end; ([ $backlog.records[]? @@ -1027,8 +1049,9 @@ terminal_evidence_json() { # - jq -n --argjson summary "$1" --argjson activities "$2" --argjson decisions "$3" ' - def keyed: . != null and . != "" and . != "default"; + json_stdin "$1" "$2" "$3" | jq -n ' + input as $summary | input as $activities | input as $decisions + | def keyed: . != null and . != "" and . != "default"; def result($e; $matches; $complete; $surface): $e + { verdict:(if ($e.key | keyed | not) then "inconclusive" @@ -1216,13 +1239,14 @@ 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_stdin "$summary" "$decisions" "$activities" "$activity_scan" "$reconciliation" | jq -n \ --arg id "$id" --arg home "$home" --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 terminal "$terminal" --argjson contradiction "$contradiction" \ --arg event_raw "$event_raw" --arg event_note "$event_note" --argjson event_age "$event_age" ' - {id:$id,home:$home,registered:$registered, + input as $summary | input as $decisions | input as $activities + | input as $activity_scan | input as $reconciliation + | {id:$id,home:$home,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"}, @@ -1246,12 +1270,13 @@ 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_stdin "$activities" "$activity_scan" "$decisions" | jq -n \ --arg id "$id" --arg home "$home" --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),registered:$registered, + --argjson registered "$registered" --argjson event_age "$event_age" \ + --argjson terminal "$terminal" ' + input as $activities | input as $activity_scan | input as $decisions + | {id:$id,home:($home | if . == "" then null else . end),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}, @@ -1259,23 +1284,23 @@ secondmate_current_json() { # 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}') fi - records=$(jq -n --argjson records "$records" --argjson record "$record" '$records + [$record]') + records=$(json_stdin "$records" "$record" | jq -n 'input as $records | input as $record | $records + [$record]') done < - jq -n --argjson current "$1" ' - {records:[ $current.records[] + json_stdin "$1" | jq -n ' + input as $current + | {records:[ $current.records[] | select(.provenance.selected == "structured-home") as $mate | $mate.landed[] | . + {home:$mate.home,home_id:$mate.id}], @@ -1329,7 +1354,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_stdin \ + "$BACKLOG_JSON" \ + "$TASKS_JSON" \ + "$MAIN_INVENTORY_JSON" \ + "$SCOUT_REPORTS_JSON" \ + "$SECONDMATE_CURRENT_JSON" \ + "$SECONDMATE_LANDED_JSON" \ + | jq -n \ --arg generated "$SNAPSHOT_NOW" \ --arg fm_home "$FM_HOME" \ --arg fm_root "$FM_ROOT" \ @@ -1337,13 +1369,9 @@ 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 $backlog | input as $tasks | input as $main_inventory + | input as $scout_reports | input as $secondmate_current | input 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"); { diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 69254ab36f..ba3e966eaf 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -172,7 +172,8 @@ family_for_basename() { fm-afk-inject-e2e.test.sh|fm-afk-return.test.sh) printf '%s\n' afk ;; - fm-bearings-snapshot.test.sh|fm-fleet-snapshot-view.test.sh) + fm-bearings-snapshot.test.sh|fm-fleet-snapshot-argv-limit.test.sh|\ + fm-fleet-snapshot-view.test.sh) printf '%s\n' snapshot-bearings ;; fm-backend-cmux.test.sh|fm-backend-cmux-smoke.test.sh) diff --git a/tests/fm-fleet-snapshot-argv-limit.test.sh b/tests/fm-fleet-snapshot-argv-limit.test.sh new file mode 100755 index 0000000000..bf784bd46b --- /dev/null +++ b/tests/fm-fleet-snapshot-argv-limit.test.sh @@ -0,0 +1,207 @@ +#!/usr/bin/env bash +# Regression tests for the argv-size ceiling in the read-only fleet snapshot. +# +# jq's --argjson hands its value to execve as ONE argv string, and Linux caps a +# single argv string at MAX_ARG_STRLEN (131072 bytes) independently of the much +# larger total ARG_MAX. Every jq composition in bin/fm-fleet-snapshot.sh that +# passed an unbounded aggregate that way died with "Argument list too long" once +# a home outgrew the ceiling, and the failure was total: --json, the secondmate +# home summary, and the bearings projection over them all produced no output. +# +# These tests drive the script with synthetic oversized input. Real captain +# backlogs are private and are never used as fixtures. +# +# On platforms with no per-argument cap (macOS bounds only total ARG_MAX) the +# pre-fix code survives these fixtures, so only the post-fix assertions are +# portable; the failure they pin is observed on Linux. +set -u + +# shellcheck source=tests/lib.sh +# shellcheck disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +SNAPSHOT="$ROOT/bin/fm-fleet-snapshot.sh" +BEARINGS="$ROOT/bin/fm-bearings-snapshot.sh" +fm_test_tmproot TMP_ROOT fm-fleet-snapshot-argv + +command -v jq >/dev/null 2>&1 || { echo "skip: jq not found"; exit 0; } + +# The ceiling this suite exists to defend. Kept as a literal so a fixture that +# stops crossing it is a test failure rather than a silent no-op. +ARGV_STRING_LIMIT=131072 + +make_home() { # + local home=$TMP_ROOT/$1 + mkdir -p "$home/state" "$home/data" "$home/config" "$home/projects" + printf '%s\n' "$home" +} + +make_fakebin() { # + local fb + fb=$(fm_fakebin "$1") + cat > "$fb/tmux" <<'SH' +#!/usr/bin/env bash +set -u +case "${1:-}" in + display-message) + case "$*" in + *pane_current_command*) printf 'codex\n' ;; + *) printf '%%1\n' ;; + esac ;; + capture-pane) printf 'work in progress\nesc to interrupt\n' ;; +esac +exit 0 +SH + chmod +x "$fb/tmux" + printf '%s\n' "$fb" +} + +# Write a backlog with landed rows, each padded so the rendered JSON +# grows well past the argv ceiling. Synthetic throughout: no captain content. +write_oversized_backlog() { # + local home=$1 count=$2 i pad + pad=$(printf 'padding%.0s' $(seq 1 24)) + { + printf '## In flight\n\n## Queued\n\n## Done\n' + for i in $(seq 1 "$count"); do + printf -- '- [x] synth-%04d - Synthetic landed item %04d %s (repo: synth) (kind: ship) (merged 2026-07-0%d)\n' \ + "$i" "$i" "$pad" "$((i % 9 + 1))" + done + } > "$home/data/backlog.md" +} + +assert_over_ceiling() { # + [ "$1" -gt "$ARGV_STRING_LIMIT" ] \ + || fail "$2 is $1 bytes, no longer over the $ARGV_STRING_LIMIT-byte argv ceiling this test defends" +} + +# A backlog whose rendered JSON exceeds MAX_ARG_STRLEN must still produce a +# complete snapshot, home summary, and bearings projection. Before the fix all +# three aborted with "Argument list too long" and emitted nothing at all. +test_oversized_backlog_still_renders() { + local home out rc bytes records summary bearings + home=$(make_home oversized-backlog) + write_oversized_backlog "$home" 400 + + out=$(FM_HOME="$home" "$SNAPSHOT" --backlog-json) \ + || fail "--backlog-json failed on an oversized backlog" + bytes=$(printf '%s' "$out" | LC_ALL=C wc -c | tr -d ' ') + assert_over_ceiling "$bytes" "the rendered backlog fixture" + + out=$(FM_HOME="$home" "$SNAPSHOT" --json) + rc=$? + [ "$rc" -eq 0 ] || fail "--json exited $rc on an oversized backlog" + printf '%s' "$out" | jq -e . >/dev/null || fail "--json output is not valid JSON on an oversized backlog" + records=$(printf '%s' "$out" | jq '.backlog.records | length') + [ "$records" -eq 400 ] \ + || fail "oversized snapshot dropped backlog records: got $records, want 400" + printf '%s' "$out" | jq -e ' + .schema == "fm-fleet-snapshot.v1" + and .main_inventory.valid == true + and .main_inventory.reason == null + and .main_inventory.unstructured_current_count == 0 + ' >/dev/null || fail "oversized snapshot lost its main inventory summary" + + summary=$(FM_HOME="$home" "$SNAPSHOT" --secondmate-home-summary) + rc=$? + [ "$rc" -eq 0 ] || fail "--secondmate-home-summary exited $rc on an oversized backlog" + printf '%s' "$summary" | jq -e ' + .schema == "fm-secondmate-home-summary.v1" and .counts.landed == 400 + ' >/dev/null || fail "oversized home summary is incomplete: $(printf '%s' "$summary" | head -c 200)" + + bearings=$(FM_HOME="$home" "$BEARINGS" --json) + rc=$? + [ "$rc" -eq 0 ] || fail "bearings exited $rc on an oversized backlog" + printf '%s' "$bearings" | jq -e '.schema == "fm-bearings.v1"' >/dev/null \ + || fail "bearings projection lost its schema on an oversized backlog" + + pass "oversized backlog renders a complete snapshot, home summary, and bearings projection" +} + +# The aggregation path has its own ceiling crossing: a registered secondmate's +# home summary is accepted up to FM_SNAPSHOT_SECONDMATE_MAX_BYTES (262144), which +# is ABOVE the 131072-byte argv ceiling. Before the fix such a home failed +# silently - exit 0, but the whole home reported "unknown"/unreadable with its +# landed work erased - which is worse than the loud abort. +test_oversized_secondmate_home_stays_readable() { + local main mate fakebin summary bytes out + main=$(make_home aggregate-main) + mate=$(make_home aggregate-mate) + printf 'mate' > "$mate/.fm-secondmate-home" + cp "$ROOT/AGENTS.md" "$mate/AGENTS.md" + ln -s "$ROOT/bin" "$mate/bin" + printf '## In flight\n\n## Queued\n\n## Done\n' > "$main/data/backlog.md" + printf -- '- mate (home: %s; scope: synthetic)\n' "$mate" > "$main/data/secondmates.md" + fm_write_meta "$main/state/mate.meta" \ + "window=firstmate:fm-mate" \ + "worktree=$mate" \ + "project=$mate" \ + "harness=codex" \ + "kind=secondmate" \ + "mode=secondmate" \ + "home=$mate" \ + "state=active" + printf 'working: watching delegated scope\n' > "$main/state/mate.status" + write_oversized_backlog "$mate" 400 + fakebin=$(make_fakebin "$main") + + # An unbounded per-home landed cap is what lets one home's summary cross the + # argv ceiling while still passing the snapshot's own byte validation. + summary=$(PATH="$fakebin:$PATH" FM_HOME="$mate" FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME=0 \ + "$SNAPSHOT" --secondmate-home-summary) || fail "child home summary failed" + bytes=$(printf '%s' "$summary" | LC_ALL=C wc -c | tr -d ' ') + assert_over_ceiling "$bytes" "the child home summary fixture" + [ "$bytes" -lt 262144 ] \ + || fail "child home summary is $bytes bytes, past the snapshot's own 262144-byte validation cap" + + out=$(PATH="$fakebin:$PATH" FM_HOME="$main" FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME=0 \ + "$SNAPSHOT" --json) || fail "--json failed with an oversized secondmate home" + printf '%s' "$out" | jq -e ' + .secondmate_current.records | length == 1 + ' >/dev/null || fail "the registered secondmate vanished from the aggregation" + printf '%s' "$out" | jq -e ' + .secondmate_current.records[0] + | .provenance.selected == "structured-home" + and .current.state != "unknown" + and (.landed | length) == 400 + ' >/dev/null || fail "oversized secondmate home degraded to a fallback instead of structured state: $( + printf '%s' "$out" | jq -c '.secondmate_current.records[0] | {state:.current.state,reason:.current.reason,selected:.provenance.selected}')" + printf '%s' "$out" | jq -e ' + (.secondmate_landed.records | length) == 400 + and (.secondmate_landed.unreadable | length) == 0 + and (.secondmate_landed.partial | length) == 0 + ' >/dev/null || fail "landed roll-up lost an oversized secondmate home: $( + printf '%s' "$out" | jq -c '.secondmate_landed | {n:(.records|length),unreadable,partial}')" + + pass "oversized secondmate home summary stays structured and keeps its landed work" +} + +# Plumbing the values through stdin must not soften the contract: a jq failure +# still has to surface as a non-zero exit and a named stage, never as a silent +# empty result. +test_hard_failure_still_surfaces() { + local home fakebin fb err rc out + home=$(make_home failure-semantics) + printf '## In flight\n\n## Queued\n\n## Done\n' > "$home/data/backlog.md" + fakebin=$(make_fakebin "$home") + # A jq that refuses every composition is the cheapest way to prove the callers + # still check status. It must not shadow the real jq during the fixture build. + fb=$fakebin + cat > "$fb/jq" <<'SH' +#!/usr/bin/env bash +echo "jq: simulated composition failure" >&2 +exit 5 +SH + chmod +x "$fb/jq" + err=$(PATH="$fb:$PATH" FM_HOME="$home" "$SNAPSHOT" --json 2>&1 >/dev/null) + rc=$? + [ "$rc" -ne 0 ] || fail "a failing jq must not produce a successful snapshot" + assert_contains "$err" "fm-fleet-snapshot:" "a failing composition must name the stage that failed" + out=$(PATH="$fb:$PATH" FM_HOME="$home" "$SNAPSHOT" --json 2>/dev/null || true) + [ -z "$out" ] || fail "a failing snapshot must not print a partial result" + pass "a failed composition still exits non-zero and names its stage" +} + +test_oversized_backlog_still_renders +test_oversized_secondmate_home_stays_readable +test_hard_failure_still_surfaces From 8375673913696c57503973c1ace581a27e5ae8a1 Mon Sep 17 00:00:00 2001 From: Freudator86 <94322668+Freudator86@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:08:19 +0000 Subject: [PATCH 2/4] fix(bin): route the task inventory and harden the argv-limit tests Follow-up to the argv-size ceiling fix, closing findings the review raised. secondmate_current_json still bound the full task inventory on argv in its union composition, so the same total failure stayed reachable through --json. That site has its own trigger, independent of backlog size: TASKS_JSON grows with the number of live tasks. Measured on a 130-task home, TASKS_JSON renders 243177 bytes and, with only that one site left on argv, --json aborts with "fm-fleet-snapshot: registered secondmate aggregation failed" and no output. Audited every remaining --argjson afterwards. All that are left carry a boolean, an integer bound, a path-presence object, or a single parsed status line, and stay on argv deliberately. Two test corrections: The failure-semantics case claimed to prove that piping through stdin does not soften the contract, but its stub failed the first jq the script reached - backlog_json, an untouched call site - so it never got as far as a converted one. It now passes -Rn through to the real jq and fails only -n, which lands on main_inventory_json with the whole rendered backlog in flight, and it asserts the specific stage name so a regression to the earlier shape is visible. It also asserts no writer-side pipe noise leaks when the consumer exits without draining. This case passes before and after by design; it is a non-regression guard, not one of the three that pin the bug. The secondmate case drove the child home summary through the production 8-second timeout while rendering a ~140 KB summary. A slow runner would have degraded the record to the parent-event fallback and reported it in the exact words this suite uses for a real regression, so the timeout is pinned. Added a fourth case for the task-inventory axis. It inflates per-task rows rather than writing the ~90 rows real growth needs, because per-task state reconciliation dominates the runtime and the axis under test is the size of the rendered inventory either way. Output stays byte-for-byte identical to the pre-fix baseline on a fixture that exercises the structured-home path, reconciliation, the landed roll-up, and a captain hold. bin/fm-lint.sh is clean and the snapshot-bearings family passes. --- bin/fm-fleet-snapshot.sh | 5 +- tests/fm-fleet-snapshot-argv-limit.test.sh | 92 ++++++++++++++++++---- 2 files changed, 80 insertions(+), 17 deletions(-) diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 6c293656a9..70be4b9c01 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -1115,8 +1115,9 @@ secondmate_current_json() { # 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='' registry=$(registry_secondmates_json) || return 1 - union=$(jq -n --argjson registry "$registry" --argjson tasks "$tasks" ' - ($registry.records // []) as $registered + union=$(json_stdin "$registry" "$tasks" | jq -n ' + input as $registry | input 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)} ] diff --git a/tests/fm-fleet-snapshot-argv-limit.test.sh b/tests/fm-fleet-snapshot-argv-limit.test.sh index bf784bd46b..5b9ca20d25 100755 --- a/tests/fm-fleet-snapshot-argv-limit.test.sh +++ b/tests/fm-fleet-snapshot-argv-limit.test.sh @@ -30,6 +30,12 @@ command -v jq >/dev/null 2>&1 || { echo "skip: jq not found"; exit 0; } # stops crossing it is a test failure rather than a silent no-op. ARGV_STRING_LIMIT=131072 +# Rendering a 400-row backlog into a ~140 KB summary is slower than the child +# read's 8-second production default, and a timeout there degrades the record to +# the parent-event fallback - the exact shape this suite reports as a genuine +# regression. Pin a generous bound so a loaded runner cannot forge that signal. +CHILD_SUMMARY_TIMEOUT=${FM_TEST_CHILD_SUMMARY_TIMEOUT:-120} + make_home() { # local home=$TMP_ROOT/$1 mkdir -p "$home/state" "$home/data" "$home/config" "$home/projects" @@ -148,6 +154,7 @@ test_oversized_secondmate_home_stays_readable() { # An unbounded per-home landed cap is what lets one home's summary cross the # argv ceiling while still passing the snapshot's own byte validation. summary=$(PATH="$fakebin:$PATH" FM_HOME="$mate" FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME=0 \ + FM_SNAPSHOT_SECONDMATE_TIMEOUT="$CHILD_SUMMARY_TIMEOUT" \ "$SNAPSHOT" --secondmate-home-summary) || fail "child home summary failed" bytes=$(printf '%s' "$summary" | LC_ALL=C wc -c | tr -d ' ') assert_over_ceiling "$bytes" "the child home summary fixture" @@ -155,6 +162,7 @@ test_oversized_secondmate_home_stays_readable() { || fail "child home summary is $bytes bytes, past the snapshot's own 262144-byte validation cap" out=$(PATH="$fakebin:$PATH" FM_HOME="$main" FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME=0 \ + FM_SNAPSHOT_SECONDMATE_TIMEOUT="$CHILD_SUMMARY_TIMEOUT" \ "$SNAPSHOT" --json) || fail "--json failed with an oversized secondmate home" printf '%s' "$out" | jq -e ' .secondmate_current.records | length == 1 @@ -176,32 +184,86 @@ test_oversized_secondmate_home_stays_readable() { pass "oversized secondmate home summary stays structured and keeps its landed work" } -# Plumbing the values through stdin must not soften the contract: a jq failure -# still has to surface as a non-zero exit and a named stage, never as a silent -# empty result. +# The task inventory crosses the ceiling on its own axis: it grows with the +# number of live tasks, with no backlog involvement at all, and it reaches jq at +# four separate consumers. A home with enough tasks must still render. +# +# The fixture inflates each task row rather than writing the ~90 rows real growth +# would need, because per-task state reconciliation dominates the runtime; the +# axis under test is the size of the rendered inventory either way. Tasks carry +# no endpoint on purpose, which keeps the fixture from paying for backend probes. +test_oversized_task_inventory_still_renders() { + local home i pad out bytes + home=$(make_home oversized-tasks) + printf '## In flight\n\n## Queued\n\n## Done\n' > "$home/data/backlog.md" + pad=$(printf 'p%.0s' $(seq 1 2400)) + for i in $(seq 1 30); do + fm_write_meta "$home/state/bulk-$(printf '%03d' "$i").meta" \ + "worktree=$home/projects/w-$pad$i" \ + "project=alpha-$pad$i" \ + "harness=codex" \ + "kind=ship" \ + "mode=ship" + done + + out=$(FM_HOME="$home" "$SNAPSHOT" --json) \ + || fail "--json failed on a home with an oversized task inventory" + bytes=$(printf '%s' "$out" | jq -c '.tasks' | LC_ALL=C wc -c | tr -d ' ') + assert_over_ceiling "$bytes" "the rendered task inventory fixture" + printf '%s' "$out" | jq -e ' + (.tasks | length) == 30 + and .secondmate_current.total == 0 + and (.secondmate_current.records | length) == 0 + and (.secondmate_landed.records | length) == 0 + ' >/dev/null || fail "oversized task inventory broke the secondmate aggregation: $( + printf '%s' "$out" | jq -c '{tasks:(.tasks|length),sm:.secondmate_current.total}')" + pass "oversized task inventory renders through every consumer that binds it" +} + +# Plumbing the values through stdin must not soften the contract. The property +# that is genuinely new here is that a two-stage `json_stdin | jq` pipeline, with +# no `set -o pipefail`, still reports jq's non-zero status rather than the +# writer's - and does it while a large value is mid-flight, where the consumer +# exits without draining and the writer takes SIGPIPE. +# +# So the stub must fail a converted site, not the first jq the script happens to +# reach. It passes `-Rn` through to the real jq (that is backlog_json, an +# untouched call site that must succeed to build the oversized value) and fails +# only `-n`, the first of which is main_inventory_json - a converted site fed the +# whole rendered backlog. test_hard_failure_still_surfaces() { - local home fakebin fb err rc out + local home fb err rc out home=$(make_home failure-semantics) - printf '## In flight\n\n## Queued\n\n## Done\n' > "$home/data/backlog.md" - fakebin=$(make_fakebin "$home") - # A jq that refuses every composition is the cheapest way to prove the callers - # still check status. It must not shadow the real jq during the fixture build. - fb=$fakebin - cat > "$fb/jq" <<'SH' + write_oversized_backlog "$home" 400 + fb=$(fm_fakebin "$home") + cat > "$fb/jq" <&2 -exit 5 +if [ "\${1:-}" = "-n" ]; then + echo "jq: simulated composition failure" >&2 + exit 5 +fi +exec $(command -v jq) "\$@" SH chmod +x "$fb/jq" + err=$(PATH="$fb:$PATH" FM_HOME="$home" "$SNAPSHOT" --json 2>&1 >/dev/null) rc=$? - [ "$rc" -ne 0 ] || fail "a failing jq must not produce a successful snapshot" - assert_contains "$err" "fm-fleet-snapshot:" "a failing composition must name the stage that failed" + [ "$rc" -ne 0 ] || fail "a failing composition must not produce a successful snapshot" + # Naming the stage proves execution reached a CONVERTED site: backlog_json is + # untouched and would have reported "backlog read failed" instead. + assert_contains "$err" "fm-fleet-snapshot: main inventory summary failed" \ + "a failing converted composition must name its own stage" + # The writer dying on SIGPIPE must not leak shell diagnostics into the report. + case "$err" in + *[Bb]roken*pipe*|*"write error"*) + fail "a failed composition leaked writer-side pipe noise: $err" ;; + esac out=$(PATH="$fb:$PATH" FM_HOME="$home" "$SNAPSHOT" --json 2>/dev/null || true) [ -z "$out" ] || fail "a failing snapshot must not print a partial result" - pass "a failed composition still exits non-zero and names its stage" + pass "a failed composition on a large piped value still exits non-zero and names its stage" } test_oversized_backlog_still_renders +test_oversized_task_inventory_still_renders test_oversized_secondmate_home_stays_readable test_hard_failure_still_surfaces From 346498e26d1bf685c69d1b53136f6f4522328510 Mon Sep 17 00:00:00 2001 From: Freudator86 <94322668+Freudator86@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:32:42 +0000 Subject: [PATCH 3/4] fix(bin): silence expected snapshot pipe closure --- bin/fm-fleet-snapshot.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 70be4b9c01..12d6c41764 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -212,9 +212,12 @@ bool_json() { # `input` binds in stream order, so each caller's leading `input as $name` lines # must match its argument order. Small, explicitly bounded values (counts, flags, # paths, single status lines) stay on --arg/--argjson where they read better. -# Exit status is jq's, so callers keep their existing hard-failure checks. +# When jq fails before draining stdin, Bash's builtin printf reports the expected +# EPIPE as a second error. The consumer owns the pipeline failure, so suppress +# that writer diagnostic; exit status remains jq's and callers keep their +# existing hard-failure checks. json_stdin() { # ... - printf '%s\n' "$@" + printf '%s\n' "$@" 2>/dev/null } path_present_json() { # From 3bc5da4e8bafd5063b9b81199db092ec79ce1554 Mon Sep 17 00:00:00 2001 From: Freudator86 <94322668+Freudator86@users.noreply.github.com> Date: Thu, 30 Jul 2026 03:01:25 +0000 Subject: [PATCH 4/4] no-mistakes(document): clarify SIGPIPE condition in json_stdin doc comment --- bin/fm-fleet-snapshot.sh | 14 ++++++++++---- tests/fm-fleet-snapshot-argv-limit.test.sh | 21 +++++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 12d6c41764..0416039bb5 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -212,10 +212,16 @@ bool_json() { # `input` binds in stream order, so each caller's leading `input as $name` lines # must match its argument order. Small, explicitly bounded values (counts, flags, # paths, single status lines) stay on --arg/--argjson where they read better. -# When jq fails before draining stdin, Bash's builtin printf reports the expected -# EPIPE as a second error. The consumer owns the pipeline failure, so suppress -# that writer diagnostic; exit status remains jq's and callers keep their -# existing hard-failure checks. +# When jq fails before draining stdin the writer hits EPIPE, and whether that is +# visible depends on the inherited SIGPIPE disposition: at the default the writer +# is killed silently, but where SIGPIPE is ignored - systemd services default to +# IgnoreSIGPIPE=yes and the watcher units drive this script - Bash's builtin +# printf outlives the closed pipe and reports the write error itself, wedging +# writer noise into an otherwise clean failure report. The consumer owns the +# pipeline failure, so the redirect below suppresses that writer diagnostic +# unconditionally; exit status remains jq's and callers keep their existing +# hard-failure checks. tests/fm-fleet-snapshot-argv-limit.test.sh pins both +# dispositions. json_stdin() { # ... printf '%s\n' "$@" 2>/dev/null } diff --git a/tests/fm-fleet-snapshot-argv-limit.test.sh b/tests/fm-fleet-snapshot-argv-limit.test.sh index 5b9ca20d25..a376114147 100755 --- a/tests/fm-fleet-snapshot-argv-limit.test.sh +++ b/tests/fm-fleet-snapshot-argv-limit.test.sh @@ -260,6 +260,27 @@ SH esac out=$(PATH="$fb:$PATH" FM_HOME="$home" "$SNAPSHOT" --json 2>/dev/null || true) [ -z "$out" ] || fail "a failing snapshot must not print a partial result" + + # With SIGPIPE at its default disposition the writer is killed silently, so the + # clean report above does not actually pin the suppression. Production often + # runs with SIGPIPE ignored - systemd services default to IgnoreSIGPIPE=yes and + # the watcher units drive this script - and an inherited SIG_IGN survives + # execve, so there Bash's builtin printf outlives the closed pipe and reports + # the EPIPE itself. Re-run the same failure under that disposition: the report + # must still be jq plus the named stage, with no writer diagnostic wedged + # between them. + err=$(PATH="$fb:$PATH" FM_HOME="$home" \ + bash -c 'trap "" PIPE; exec "$1" --json' _ "$SNAPSHOT" 2>&1 >/dev/null) + rc=$? + [ "$rc" -ne 0 ] \ + || fail "a failing composition must not produce a successful snapshot with SIGPIPE ignored" + assert_contains "$err" "fm-fleet-snapshot: main inventory summary failed" \ + "a failing converted composition must name its own stage with SIGPIPE ignored" + case "$err" in + *[Bb]roken*pipe*|*"write error"*) + fail "a failed composition leaked writer-side pipe noise with SIGPIPE ignored: $err" ;; + esac + pass "a failed composition on a large piped value still exits non-zero and names its stage" }