Skip to content

fix(bin): render fleet snapshots past the argv size ceiling - #37

Merged
Freudator86 merged 4 commits into
mainfrom
fm/fm-snapshot-argv-limit-pin-source
Jul 30, 2026
Merged

fix(bin): render fleet snapshots past the argv size ceiling#37
Freudator86 merged 4 commits into
mainfrom
fm/fm-snapshot-argv-limit-pin-source

Conversation

@Freudator86

Copy link
Copy Markdown
Owner

Intent

Land the already-built fleet snapshot argv-limit fix at the firstmate pin source as a transplant, preserving its eleven audited stdin conversions and strict four-case regression coverage. Revalidate that the three oversized cases fail before and pass after, while the hard-failure guard passes both ways and remains strict. Correct the newly discovered producer-side EPIPE regression so a jq failure reports only jq and the named stage; the supplied report was wrong because Bash 5.2.21 builtin printf emits Broken pipe when the new stdin consumer exits early. Preserve byte-identical successful small outputs, do not change the out-of-scope 262144-byte cap, and ship through the normal PR path.

What Changed

  • bin/fm-fleet-snapshot.sh now feeds every home-sized aggregate (backlog, task inventory, scout reports, secondmate current/landed, and the top-level composition) to jq on stdin via a new json_stdin helper bound with input, instead of --argjson. Values that crossed Linux's per-argument MAX_ARG_STRLEN (131072 bytes) previously failed the whole run with "Argument list too long", taking --json, the secondmate home summary, and the bearings projection over them down together. Small bounded values (counts, flags, paths) stay on --arg/--argjson, and the out-of-scope 262144-byte FM_SNAPSHOT_SECONDMATE_MAX_BYTES cap is untouched.
  • The writer side of that pipe suppresses its own diagnostic, so when jq fails before draining stdin the report names only jq and the failing stage. Under an ignored SIGPIPE disposition — how systemd services run these watchers — Bash's builtin printf outlived the closed pipe and leaked printf: write error: Broken pipe into an otherwise clean failure. Exit status is still jq's and callers keep their existing hard-failure checks.
  • Adds tests/fm-fleet-snapshot-argv-limit.test.sh, a four-case suite over synthetic oversized homes covering backlog, task inventory, and secondmate home rendering plus a hard-failure guard that pins both SIGPIPE dispositions; bin/fm-test-run.sh routes it into the snapshot-bearings family. The three oversized cases fail on the base commit and pass on the target; the hard-failure guard passes both ways.

Risk Assessment

✅ Low: The change is a mechanical, semantics-preserving transport swap whose 11 conversions I verified site-by-site for argument/input ordering, single-output filters, unchanged ., and intact exit-status propagation under set -u without pipefail, backed by new targeted regression coverage and with the out-of-scope byte cap and all remaining bounded --argjson sites untouched.

Testing

I ran the change's own four-case suite (all pass, also through bin/fm-test-run.sh, which confirms the new snapshot-bearings routing and the runner's coverage guard), then rebuilt the base 8502757 and intermediate 8375673 trees from git and drove each case individually to show the three oversized cases fail before and pass after while the hard-failure guard passes both ways. Beyond unit results I verified the intent through the real CLI: on a synthetic 400-row home the base tree emits nothing and dies with jq's "Argument list too long" across --json, --secondmate-home-summary and bearings, while the target returns complete valid snapshots with all 400 records; small-home output, including the rendered fm-fleet-view table and the secondmate aggregation path, is byte-identical between base and target with the clock pinned and both trees staged at the same path, the only apparent diff being bearings' own wall-clock stamp, which I proved is noise by re-running the same tree. I reproduced the producer-side EPIPE regression on 8375673 under an ignored SIGPIPE, the disposition systemd services inherit, where "printf: write error: Broken pipe" wedges into the captain-facing report, and confirmed the target reports only jq's error plus the named stage while the large-value success path stays clean. A trace of an instrumented throwaway copy shows all eleven stdin conversion sites executing successfully, and the 262144-byte cap is untouched. The one gap I found is that the suite only exercised the default SIGPIPE disposition, so it could not guard the EPIPE fix; I extended the existing fourth case with a SIGPIPE-ignored re-run that passes on base, fails on the regressed tree, and passes on the target. Transient scratch trees were removed; only the test file change remains in the worktree.

Evidence: Oversized-home CLI transcript: base 8502757 aborts, target renders

--- tree: 8502757 fm-fleet-snapshot.sh --json rc=1 stdout=0 bytes stderr| bin/fm-fleet-snapshot.sh: line 583: /usr/bin/jq: Argument list too long stderr| fm-fleet-snapshot: main inventory summary failed fm-fleet-snapshot.sh --secondmate-home-summary rc=1 stdout=0 bytes stderr| bin/fm-fleet-snapshot.sh: line 611: /usr/bin/jq: Argument list too long stderr| fm-fleet-snapshot: secondmate home summary failed fm-bearings-snapshot.sh --json rc=1 stdout=0 bytes stderr| bin/fm-fleet-snapshot.sh: line 583: /usr/bin/jq: Argument list too long stderr| fm-fleet-snapshot: main inventory summary failed --- tree: after (target) fm-fleet-snapshot.sh --json rc=0 stdout=547601 bytes json | {"schema":"fm-fleet-snapshot.v1","backlog_records":400,"landed":0,"inventory_valid":true} fm-fleet-snapshot.sh --secondmate-home-summary rc=0 stdout=3999 bytes fm-bearings-snapshot.sh --json rc=0 stdout=2111 bytes json | {"schema":"fm-bearings.v1"}

work=/tmp/argv-limit-manual.ljQiif
==================================================================
A. Byte-identical small-home output (base 8502757 vs target 346498e)
==================================================================
  fm-fleet-snapshot.sh --backlog-json                  rc 0 -> 0   IDENTICAL (2863 bytes)
  fm-fleet-snapshot.sh --json                          rc 0 -> 0   DIFFERS
124c124
<     "fm_root": "/tmp/argv-limit-before.LKdHq5/8502757",
---
>     "fm_root": "/tmp/argv-limit-before.LKdHq5/after",
  fm-fleet-snapshot.sh --secondmate-home-summary       rc 0 -> 0   IDENTICAL (1485 bytes)
  fm-bearings-snapshot.sh --json                       rc 0 -> 0   IDENTICAL (1348 bytes)

  Sample of the small-home snapshot a captain sees (target tree):
    {"schema":"fm-fleet-snapshot.v1","generated":"2026-07-30T12:00:00Z","tasks":["alpha-0001"],"backlog_records":3,"inventory":true}

==================================================================
B. Oversized home (400 landed rows) through the real CLI
==================================================================
--- tree: 8502757
  fm-fleet-snapshot.sh --json                     rc=1 stdout=0 bytes
      stderr| /tmp/argv-limit-before.LKdHq5/8502757/bin/fm-fleet-snapshot.sh: line 583: /usr/bin/jq: Argument list too long
      stderr| fm-fleet-snapshot: main inventory summary failed
  fm-fleet-snapshot.sh --secondmate-home-summary  rc=1 stdout=0 bytes
      stderr| /tmp/argv-limit-before.LKdHq5/8502757/bin/fm-fleet-snapshot.sh: line 611: /usr/bin/jq: Argument list too long
      stderr| fm-fleet-snapshot: secondmate home summary failed
  fm-bearings-snapshot.sh --json                  rc=1 stdout=0 bytes
      stderr| /tmp/argv-limit-before.LKdHq5/8502757/bin/fm-fleet-snapshot.sh: line 583: /usr/bin/jq: Argument list too long
      stderr| fm-fleet-snapshot: main inventory summary failed
--- tree: after
  fm-fleet-snapshot.sh --json                     rc=0 stdout=547601 bytes
      json  | {"schema":"fm-fleet-snapshot.v1","backlog_records":400,"landed":0,"inventory_valid":true}
  fm-fleet-snapshot.sh --secondmate-home-summary  rc=0 stdout=3999 bytes
  fm-bearings-snapshot.sh --json                  rc=0 stdout=2111 bytes
      json  | {"schema":"fm-bearings.v1"}

==================================================================
C. Producer-side EPIPE: what a captain sees when jq fails
==================================================================
--- tree: 8375673 (pre-EPIPE-fix)
  rc=1 stdout=0 bytes
  stderr as reported to the captain:
      | jq: simulated composition failure
      | fm-fleet-snapshot: main inventory summary failed
--- tree: after (target)
  rc=1 stdout=0 bytes
  stderr as reported to the captain:
      | jq: simulated composition failure
      | fm-fleet-snapshot: main inventory summary failed
Evidence: Producer-side EPIPE: captain-facing stderr before vs after

=== pre-EPIPE-fix (8375673) --- SIGPIPE ignored (exit=1) | jq: simulated composition failure | 8375673/bin/fm-fleet-snapshot.sh: line 217: printf: write error: Broken pipe | fm-fleet-snapshot: main inventory summary failed === target (346498e) --- SIGPIPE ignored (exit=1) | jq: simulated composition failure | fm-fleet-snapshot: main inventory summary failed === target (346498e): success path with SIGPIPE ignored, oversized home exit=0 stdout=547572 bytes stderr=0 bytes json | {"schema":"fm-fleet-snapshot.v1","backlog_records":400,"inventory_valid":true}

Captain-facing stderr when a converted jq composition fails, with SIGPIPE
ignored the way systemd services run (IgnoreSIGPIPE=yes is the unit default,
and fm-watch/fm-frequency-monitor drive the snapshot from such a unit).

=== pre-EPIPE-fix (8375673)
--- SIGPIPE default  (exit=1)
      | jq: simulated composition failure
      | fm-fleet-snapshot: main inventory summary failed
--- SIGPIPE ignored  (exit=1)
      | jq: simulated composition failure
      | /tmp/argv-limit-before.LKdHq5/8375673/bin/fm-fleet-snapshot.sh: line 217: printf: write error: Broken pipe
      | fm-fleet-snapshot: main inventory summary failed

=== target (346498e)
--- SIGPIPE default  (exit=1)
      | jq: simulated composition failure
      | fm-fleet-snapshot: main inventory summary failed
--- SIGPIPE ignored  (exit=1)
      | jq: simulated composition failure
      | fm-fleet-snapshot: main inventory summary failed

=== target (346498e): success path with SIGPIPE ignored, oversized home
      exit=0 stdout=547572 bytes stderr=0 bytes
      json  | {"schema":"fm-fleet-snapshot.v1","backlog_records":400,"inventory_valid":true}
Evidence: Byte-identical small-home output plus the rendered fleet view

fm-fleet-snapshot.sh --json rc 0 -> 0 BYTE-IDENTICAL (13676 bytes stdout, 0 stderr) fm-fleet-snapshot.sh --backlog-json rc 0 -> 0 BYTE-IDENTICAL (2861 bytes stdout, 0 stderr) fm-fleet-snapshot.sh --secondmate-home-summary rc 0 -> 0 BYTE-IDENTICAL (1799 bytes stdout, 0 stderr) fm-bearings-snapshot.sh --json rc 0 -> 0 DIFFERS (only its own wall-clock generated field; see bearings-timestamp-noise.txt) fm-fleet-view.sh rc 0 -> 0 BYTE-IDENTICAL (1217 bytes stdout, 0 stderr) fm-fleet-view.sh as the captain reads it on the target tree: # Fleet View Schema: fm-fleet-snapshot.v1 ## Under Way | alpha-0001 | unknown / none | ship | alpha | tmux | ... | | mate | working / status-log | secondmate | ... | present / alive | ... | ## Queued | alpha-0002 | Repaint the hull | alpha | ship | - | - | ## Done | alpha-0000 | Splice the mainbrace | alpha | ship | - | - |

Small-home CLI output, base 8502757 vs target 346498e. Both trees are staged at
the identical path /tmp/argv-identical2.lu5g50/repo and the clock is pinned, so any
difference in these bytes would be the transplant's doing.
Home has one live task plus one registered secondmate home, so the converted
task, inventory, aggregation and roll-up sites all run.

  fm-fleet-snapshot.sh --json                    rc 0 -> 0   BYTE-IDENTICAL (13676 bytes stdout, 0 stderr)
  fm-fleet-snapshot.sh --backlog-json            rc 0 -> 0   BYTE-IDENTICAL (2861 bytes stdout, 0 stderr)
  fm-fleet-snapshot.sh --secondmate-home-summary rc 0 -> 0   BYTE-IDENTICAL (1799 bytes stdout, 0 stderr)
  fm-bearings-snapshot.sh --json                 rc 0 -> 0   DIFFERS
      4c4
      <   "generated": "2026-07-30T02:50:28Z",
      ---
      >   "generated": "2026-07-30T02:50:30Z",
  fm-fleet-view.sh                               rc 0 -> 0   BYTE-IDENTICAL (1217 bytes stdout, 0 stderr)

  fm-fleet-view.sh as the captain reads it on the target tree:
    # Fleet View
    
    Schema: fm-fleet-snapshot.v1
    Home: /tmp/argv-identical2.lu5g50/small
    
    ## Under Way
    | ID | Current | Kind | Repo/Project | Backend | Endpoint | Artifact | Path | Watch / return channel |
    | --- | --- | --- | --- | --- | --- | --- | --- | --- |
    | alpha-0001 | unknown / none | ship | alpha | tmux | unknown | - | /tmp/argv-identical2.lu5g50/small/projects/alpha (absent) | bin/fm-peek.sh fm-alpha-0001 |
    | mate | working / status-log | secondmate | /tmp/argv-identical2.lu5g50/mate | tmux | present / alive | - | /tmp/argv-identical2.lu5g50/mate | bin/fm-send.sh fm-mate '<request>' - read status/doc return channel; do not routinely fm-peek a secondmate for answers |
    
    ## Queued
    | ID | Title | Repo | Kind | Blocked By | Artifact |
    | --- | --- | --- | --- | --- | --- |
    | alpha-0002 | Repaint the hull | alpha | ship | - | - |
    
    ## Done
    | ID | Title | Repo | Kind | Blocked By | Artifact |
    | --- | --- | --- | --- | --- | --- |
    | alpha-0000 | Splice the mainbrace | alpha | ship | - | - |
    
    ## Secondmates
    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.
Evidence: Per-case before/after verdicts (base 8502757 vs target)

BEFORE :: not ok - --json exited 1 on an oversized backlog BEFORE :: not ok - --json failed on a home with an oversized task inventory BEFORE :: not ok - child home summary failed BEFORE :: ok - a failed composition on a large piped value still exits non-zero and names its stage AFTER :: ok - oversized backlog renders a complete snapshot, home summary, and bearings projection AFTER :: ok - oversized task inventory renders through every consumer that binds it AFTER :: ok - oversized secondmate home summary stays structured and keeps its landed work AFTER :: ok - a failed composition on a large piped value still exits non-zero and names its stage

=== BEFORE (base 8502757, pre-fix) :: test_oversized_backlog_still_renders
/tmp/argv-limit-before.LKdHq5/8502757/bin/fm-fleet-snapshot.sh: line 583: /usr/bin/jq: Argument list too long
fm-fleet-snapshot: main inventory summary failed
not ok - --json exited 1 on an oversized backlog
--- exit=0

=== BEFORE (base 8502757, pre-fix) :: test_oversized_task_inventory_still_renders
/tmp/argv-limit-before.LKdHq5/8502757/bin/fm-fleet-snapshot.sh: line 583: /usr/bin/jq: Argument list too long
fm-fleet-snapshot: main inventory summary failed
not ok - --json failed on a home with an oversized task inventory
--- exit=0

=== BEFORE (base 8502757, pre-fix) :: test_oversized_secondmate_home_stays_readable
/tmp/argv-limit-before.LKdHq5/8502757/bin/fm-fleet-snapshot.sh: line 611: /usr/bin/jq: Argument list too long
fm-fleet-snapshot: secondmate home summary failed
not ok - child home summary failed
--- exit=0

=== BEFORE (base 8502757, pre-fix) :: test_hard_failure_still_surfaces
ok - a failed composition on a large piped value still exits non-zero and names its stage
--- exit=0

=== AFTER  (target 346498e) :: test_oversized_backlog_still_renders
ok - oversized backlog renders a complete snapshot, home summary, and bearings projection
--- exit=0

=== AFTER  (target 346498e) :: test_oversized_task_inventory_still_renders
ok - oversized task inventory renders through every consumer that binds it
--- exit=0

=== AFTER  (target 346498e) :: test_oversized_secondmate_home_stays_readable
ok - oversized secondmate home summary stays structured and keeps its landed work
--- exit=0

=== AFTER  (target 346498e) :: test_hard_failure_still_surfaces
ok - a failed composition on a large piped value still exits non-zero and names its stage
--- exit=0


Note: each suite case is a separate driver that runs only that one test function;
"ok -" / "not ok -" is the authoritative per-case verdict (tests/lib.sh fail()
exits 1 immediately). The trailing "--- exit=" line in this capture reports the
status of the tee'd capture pipeline, not the driver, so read the ok/not ok
markers.
Evidence: Hardened hard-failure guard across three trees

=== base 8502757 (pre-argv-fix: no json_stdin at all) ok - a failed composition ... names its stage === 8375673 (argv fix, EPIPE regression present) not ok - a failed composition leaked writer-side pipe noise with SIGPIPE ignored: jq: simulated composition failure 8375673/bin/fm-fleet-snapshot.sh: line 217: printf: write error: Broken pipe fm-fleet-snapshot: main inventory summary failed === target 346498e (argv fix + EPIPE suppression) ok - a failed composition ... names its stage

=== hardened hard-failure guard vs base 8502757 (pre-argv-fix: no json_stdin at all)
ok - a failed composition on a large piped value still exits non-zero and names its stage
--- exit=0

=== hardened hard-failure guard vs 8375673 (argv fix, EPIPE regression present)
not ok - a failed composition leaked writer-side pipe noise with SIGPIPE ignored: jq: simulated composition failure
/tmp/argv-limit-before.LKdHq5/8375673/bin/fm-fleet-snapshot.sh: line 217: printf: write error: Broken pipe
fm-fleet-snapshot: main inventory summary failed
--- exit=1

=== hardened hard-failure guard vs target 346498e (argv fix + EPIPE suppression)
ok - a failed composition on a large piped value still exits non-zero and names its stage
--- exit=0
Evidence: All eleven stdin conversion sites exercised (traced)

line 534 calls=5 task_json_lines() line 611 calls=2 main_inventory_json() line 638 calls=2 secondmate_home_summary_json() line 1056 calls=1 parent_evidence_reconciliation_json() line 1137 calls=2 secondmate_current_json() line 1263 calls=1 secondmate_current_json() line 1290 calls=1 secondmate_current_json() line 1292 calls=2 secondmate_current_json() line 1296 calls=2 secondmate_current_json() line 1306 calls=2 secondmate_landed_from_current_json() line 1363 calls=2 top-level snapshot composition total distinct conversion sites exercised: 11 of 11

The eleven --argjson -> stdin conversions in bin/fm-fleet-snapshot.sh and how
often each call site actually executed across three real snapshot runs
(structured secondmate + live task, fallback-provenance secondmate, home summary):

  line 1056   calls=1    parent_evidence_reconciliation_json()
  line 1137   calls=2    secondmate_current_json()
  line 1263   calls=1    secondmate_current_json()
  line 1290   calls=1    secondmate_current_json()
  line 1292   calls=2    secondmate_current_json()
  line 1296   calls=2    secondmate_current_json()
  line 1306   calls=2    secondmate_landed_from_current_json()
  line 1363   calls=2    scout_report_lines()
  line 534    calls=5    task_json_lines()
  line 611    calls=2    main_inventory_json()
  line 638    calls=2    secondmate_home_summary_json()

  total distinct conversion sites exercised: 11 of 11
  sites declared in the target tree:
    533:    json_stdin "$open_decisions_json" | jq -n \
    610:  json_stdin "$1" "$2" | jq -n '
    637:  json_stdin "$1" "$2" | jq -n \
    1055:  json_stdin "$1" "$2" "$3" | jq -n '
    1121:  union=$(json_stdin "$registry" "$tasks" | jq -n '
    1246:      record=$(json_stdin "$summary" "$decisions" "$activities" "$activity_scan" "$reconciliation" | 
    1277:      record=$(json_stdin "$activities" "$activity_scan" "$decisions" | jq -n \
    1291:    records=$(json_stdin "$records" "$record" | jq -n 'input as $records | input as $record | $record
    1295:  json_stdin "$(printf '%s' "$union" | jq '.registry')" "$records" | jq -n \
    1305:  json_stdin "$1" | jq -n '
    1361:json_stdin \

  Note: the traced line is where Bash reports the call finishing, so a site
  wrapped in a multi-line command substitution logs its LAST line (1137->1121,
  1263->1246, 1290->1277) and the injected trace line shifts every number after
  220 by one. Mapped back, the eleven traced sites are exactly the eleven
  declared sites; line 1363 is the top-level snapshot composition at 1361, not
  scout_report_lines (crude awk attribution in this trace).
Evidence: Runner routing and suite run through bin/fm-test-run.sh

=== --list --family snapshot-bearings tests/fm-bearings-snapshot.test.sh tests/fm-fleet-snapshot-argv-limit.test.sh tests/fm-fleet-snapshot-view.test.sh === --check-coverage FM_TEST_COVERAGE ok total=114 parallel=30 serial=75 herdr=9 FM_TEST_BEGIN tests/fm-fleet-snapshot-argv-limit.test.sh family=snapshot-bearings expected_gate_skip=optional-binary ok - oversized backlog renders a complete snapshot, home summary, and bearings projection ok - oversized task inventory renders through every consumer that binds it ok - oversized secondmate home summary stays structured and keeps its landed work ok - a failed composition on a large piped value still exits non-zero and names its stage FM_TEST_END exit=0 duration_ms=11649

=== bin/fm-test-run.sh --list --family snapshot-bearings
tests/fm-bearings-snapshot.test.sh
tests/fm-fleet-snapshot-argv-limit.test.sh
tests/fm-fleet-snapshot-view.test.sh

=== bin/fm-test-run.sh --check-coverage (runner's own completeness guard)
FM_TEST_COVERAGE ok total=114 parallel=30 serial=75 herdr=9
exit=0

=== bin/fm-test-run.sh tests/fm-fleet-snapshot-argv-limit.test.sh
FM_TEST_BEGIN 2026-07-30T02:51:20Z tests/fm-fleet-snapshot-argv-limit.test.sh family=snapshot-bearings expected_gate_skip=optional-binary
ok - oversized backlog renders a complete snapshot, home summary, and bearings projection
ok - oversized task inventory renders through every consumer that binds it
ok - oversized secondmate home summary stays structured and keeps its landed work
ok - a failed composition on a large piped value still exits non-zero and names its stage
FM_TEST_END 2026-07-30T02:51:32Z tests/fm-fleet-snapshot-argv-limit.test.sh exit=0 duration_ms=11649 gate_skip=false
FM_TEST_SUMMARY total=1 failed=0 skipped_gate=0 duration_ms=11712
FM_TEST_SUMMARY_FAMILY family=snapshot-bearings count=1 duration_ms=11649 failed=0
FM_TEST_SLOWEST rank=1 script=tests/fm-fleet-snapshot-argv-limit.test.sh duration_ms=11649
exit=0
Evidence: Manual verification script used for the CLI transcripts
#!/usr/bin/env bash
# Manual end-to-end verification of the fleet-snapshot argv-limit transplant.
#
# Drives the real CLI the way a captain does (fm-fleet-snapshot.sh / fm-bearings-snapshot.sh)
# against three synthetic homes, comparing the pre-fix tree (base 8502757), the
# pre-EPIPE-fix tree (8375673) and the target tree (346498e).
#
# Usage: manual-verify.sh <scratch-root> <evidence-dir>
set -u

SCRATCH=$1
EV=$2
BEFORE=$SCRATCH/8502757
MID=$SCRATCH/8375673
AFTER=$SCRATCH/after

export FM_SNAPSHOT_NOW=2026-07-30T12:00:00Z
export FM_SNAPSHOT_NOW_EPOCH=1785585600

WORK=$(mktemp -d /tmp/argv-limit-manual.XXXXXX)
echo "work=$WORK"

# ---------------------------------------------------------------- small home
small=$WORK/small
mkdir -p "$small/state" "$small/data" "$small/config" "$small/projects"
cat > "$small/data/backlog.md" <<'MD'
## In flight

- [ ] alpha-0001 - Wire the harbour light (repo: alpha) (kind: ship)

## Queued

- [ ] alpha-0002 - Repaint the hull (repo: alpha) (kind: ship)

## Done

- [x] alpha-0000 - Splice the mainbrace (repo: alpha) (kind: ship) (merged 2026-07-01)
MD
printf 'worktree=%s\nproject=alpha\nharness=codex\nkind=ship\nmode=ship\n' \
  "$small/projects/alpha" > "$small/state/alpha-0001.meta"
printf 'working: wiring the harbour light\n' > "$small/state/alpha-0001.status"

# --------------------------------------------------------- oversized home
big=$WORK/big
mkdir -p "$big/state" "$big/data" "$big/config" "$big/projects"
pad=$(printf 'padding%.0s' $(seq 1 24))
{
  printf '## In flight\n\n## Queued\n\n## Done\n'
  for i in $(seq 1 400); 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
} > "$big/data/backlog.md"

# ------------------------------------------------- failing-jq home (EPIPE)
fail_home=$WORK/failjq
mkdir -p "$fail_home/state" "$fail_home/data" "$fail_home/config" "$fail_home/projects" "$fail_home/fakebin"
cp "$big/data/backlog.md" "$fail_home/data/backlog.md"
cat > "$fail_home/fakebin/jq" <<SH
#!/usr/bin/env bash
if [ "\${1:-}" = "-n" ]; then
  echo "jq: simulated composition failure" >&2
  exit 5
fi
exec $(command -v jq) "\$@"
SH
chmod +x "$fail_home/fakebin/jq"

run() {  # <tree> <label> <home> <args...>
  local tree=$1 label=$2 home=$3; shift 3
  local out rc
  out=$(FM_HOME="$home" "$tree/bin/$label" "$@" 2>"$WORK/err.$$")
  rc=$?
  printf '%s' "$out" > "$WORK/out.$$"
  printf 'rc=%s\n' "$rc"
}

echo "=================================================================="
echo "A. Byte-identical small-home output (base 8502757 vs target 346498e)"
echo "=================================================================="
for cmd in "fm-fleet-snapshot.sh --backlog-json" "fm-fleet-snapshot.sh --json" \
           "fm-fleet-snapshot.sh --secondmate-home-summary" "fm-bearings-snapshot.sh --json"; do
  set -- $cmd
  script=$1; shift
  FM_HOME="$small" "$BEFORE/bin/$script" "$@" > "$WORK/before.out" 2>"$WORK/before.err"; rcb=$?
  FM_HOME="$small" "$AFTER/bin/$script"  "$@" > "$WORK/after.out"  2>"$WORK/after.err";  rca=$?
  if cmp -s "$WORK/before.out" "$WORK/after.out"; then verdict="IDENTICAL ($(wc -c < "$WORK/after.out" | tr -d ' ') bytes)"
  else verdict="DIFFERS"; fi
  printf '  %-52s rc %s -> %s   %s\n' "$script $*" "$rcb" "$rca" "$verdict"
  if [ "$verdict" = DIFFERS ]; then diff <(jq -S . "$WORK/before.out" 2>/dev/null || cat "$WORK/before.out") \
      <(jq -S . "$WORK/after.out" 2>/dev/null || cat "$WORK/after.out") | head -20; fi
  cmp -s "$WORK/before.err" "$WORK/after.err" || { echo "    stderr differs:"; cat "$WORK/after.err"; }
done
echo
echo "  Sample of the small-home snapshot a captain sees (target tree):"
FM_HOME="$small" "$AFTER/bin/fm-fleet-snapshot.sh" --json \
  | jq -c '{schema,generated,tasks:[.tasks[].id],backlog_records:(.backlog.records|length),inventory:.main_inventory.valid}' \
  | sed 's/^/    /'

echo
echo "=================================================================="
echo "B. Oversized home (400 landed rows) through the real CLI"
echo "=================================================================="
for tree in "$BEFORE" "$AFTER"; do
  name=$(basename "$tree")
  echo "--- tree: $name"
  for c in "--json" "--secondmate-home-summary"; do
    FM_HOME="$big" "$tree/bin/fm-fleet-snapshot.sh" $c > "$WORK/o.out" 2>"$WORK/o.err"; rc=$?
    printf '  fm-fleet-snapshot.sh %-26s rc=%s stdout=%s bytes\n' "$c" "$rc" "$(wc -c < "$WORK/o.out" | tr -d ' ')"
    sed -n 1,3p "$WORK/o.err" | sed 's/^/      stderr| /'
    if [ "$rc" -eq 0 ] && [ "$c" = "--json" ]; then
      jq -c '{schema,backlog_records:(.backlog.records|length),landed:(.backlog.records|map(select(.state=="landed"))|length),inventory_valid:.main_inventory.valid}' \
        "$WORK/o.out" | sed 's/^/      json  | /'
    fi
  done
  FM_HOME="$big" "$tree/bin/fm-bearings-snapshot.sh" --json > "$WORK/b.out" 2>"$WORK/b.err"; rc=$?
  printf '  fm-bearings-snapshot.sh %-23s rc=%s stdout=%s bytes\n' "--json" "$rc" "$(wc -c < "$WORK/b.out" | tr -d ' ')"
  sed -n 1,3p "$WORK/b.err" | sed 's/^/      stderr| /'
  [ "$rc" -eq 0 ] && jq -c '{schema}' "$WORK/b.out" | sed 's/^/      json  | /'
done

echo
echo "=================================================================="
echo "C. Producer-side EPIPE: what a captain sees when jq fails"
echo "=================================================================="
for tree in "$MID" "$AFTER"; do
  name=$(basename "$tree")
  case $name in 8375673) note="(pre-EPIPE-fix)";; *) note="(target)";; esac
  echo "--- tree: $name $note"
  PATH="$fail_home/fakebin:$PATH" FM_HOME="$fail_home" "$tree/bin/fm-fleet-snapshot.sh" --json \
    > "$WORK/f.out" 2>"$WORK/f.err"; rc=$?
  printf '  rc=%s stdout=%s bytes\n' "$rc" "$(wc -c < "$WORK/f.out" | tr -d ' ')"
  echo "  stderr as reported to the captain:"
  sed 's/^/      | /' "$WORK/f.err"
done

rm -rf "$WORK"
Evidence: Bearings timestamp-noise control run

target vs target: DIFFERS on generated only (02:50:53Z vs 02:50:55Z) base vs target, generated normalized: BYTE-IDENTICAL (1060 bytes)

fm-bearings-snapshot.sh stamps its own 'generated' from the wall clock (it does
not take FM_SNAPSHOT_NOW), so raw bytes drift between any two runs. Two runs of
the SAME target tree, then base vs target with only that field normalized:

  target vs target: DIFFERS ->< "generated": "2026-07-30T02:50:53Z", > "generated": "2026-07-30T02:50:55Z", 
  base vs target, 'generated' normalized: BYTE-IDENTICAL (1060 bytes)
- Outcome: ⚠️ 1 info across 1 run (13m33s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 2 infos
  • ℹ️ tests/fm-fleet-snapshot-argv-limit.test.sh:254 - The hard-failure guard asserts the stage name is present and that no writer-side pipe noise leaks, but never asserts that jq's own diagnostic actually reaches stderr. The intent's criterion is that a jq failure reports "only jq and the named stage" — the "jq" half is unpinned, so a future change that swallowed the consumer's stderr along with the writer's would still pass this case. The stub already writes "jq: simulated composition failure" and err is captured with 2&gt;&amp;1, so adding assert_contains &#34;$err&#34; &#34;jq: simulated composition failure&#34; &#34;...&#34; next to the existing stage-name assertion closes the gap with no fixture change.
  • ℹ️ bin/fm-fleet-snapshot.sh:1291 - The per-home loop rebuilds the whole records array with a fresh jq process each iteration, so N homes cost N jq spawns and O(N^2) bytes re-serialized — now also pushed through the new pipe. With the defaults (FM_SNAPSHOT_SECONDMATES=20) this is bounded and harmless, but under FM_SNAPSHOT_SECONDMATES=0 combined with FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME=0 — the unbounded configuration the new suite deliberately exercises — it is the next scaling wall right beside the one being fixed. Appending each $record to a newline-delimited accumulator and folding once with a single jq -s after the loop removes the inner jq entirely and is behavior-preserving. Pre-existing shape, touched but not worsened by this diff; noted rather than blocking.
⚠️ **Test** - 1 info
  • ℹ️ tests/fm-fleet-snapshot-argv-limit.test.sh:264 - The suite's fourth case only exercised the default SIGPIPE disposition, where Bash silently kills the pipeline writer, so it passed identically on the pre-EPIPE-fix tree (8375673) and could not guard the producer-side EPIPE correction this change ships. I extended that same case (still four cases, no fifth added) with a second run of the identical failing-jq scenario under an ignored SIGPIPE, matching how systemd-run services inherit SIG_IGN. Verified: passes on base 8502757, fails on 8375673 with the leaked "printf: write error: Broken pipe", passes on target 346498e. The change is uncommitted in the worktree at tests/fm-fleet-snapshot-argv-limit.test.sh.
  • bash tests/fm-fleet-snapshot-argv-limit.test.sh on the target tree - all four cases ok in 11s
  • bash bin/fm-test-run.sh tests/fm-fleet-snapshot-argv-limit.test.sh - confirms the new routing resolves family=snapshot-bearings, exit=0
  • bash bin/fm-test-run.sh --list --family snapshot-bearings and bash bin/fm-test-run.sh --check-coverage
  • Per-case before/after: git archive 8502757 | tar -x into a scratch tree, then single-function drivers for test_oversized_backlog_still_renders, test_oversized_task_inventory_still_renders, test_oversized_secondmate_home_stays_readable, test_hard_failure_still_surfaces run against base and target
  • Manual CLI verification on a synthetic 400-row home: bin/fm-fleet-snapshot.sh --json, bin/fm-fleet-snapshot.sh --secondmate-home-summary, bin/fm-bearings-snapshot.sh --json on base 8502757 versus target
  • Byte-identity check: both trees staged at one identical path with FM_SNAPSHOT_NOW and FM_SNAPSHOT_NOW_EPOCH pinned, comparing --json, --backlog-json, --secondmate-home-summary, bin/fm-fleet-view.sh and bearings via cmp, on a home with a live task plus a registered secondmate
  • Bearings control run: two consecutive target-tree bin/fm-bearings-snapshot.sh --json runs to prove the only diff is its own wall-clock generated field
  • EPIPE reproduction: failing-jq PATH stub plus a wrapper that ignores SIGPIPE via trap then exec bin/fm-fleet-snapshot.sh --json, run against 8375673 and against the target, plus the large-value success path under the same disposition
  • Conversion-site trace: instrumented throwaway copy logging BASH_LINENO per json_stdin call across structured-secondmate, fallback-provenance and home-summary runs - 11 of 11 sites executed, all rc=0
  • grep -n 262144 bin/fm-fleet-snapshot.sh and git diff 8502757..346498e - the FM_SNAPSHOT_SECONDMATE_MAX_BYTES cap is unchanged
  • Hardened fourth case re-run against all three trees: ok on 8502757, not ok on 8375673, ok on 346498e
⚠️ **Document** - 1 info
  • ℹ️ docs/herdr-backend.md:280 - Repo-wide jq usage invariants have no single owner: the reserved-keyword guard for jq variable names lives in docs/herdr-backend.md:280 (a backend-verification doc), while this change's argv-ceiling invariant now lives in a bin/fm-fleet-snapshot.sh code comment. A future session adding a jq call site has no one place to consult. Consolidating both into .agents/skills/firstmate-coding-guidelines/SKILL.md (with pointers left behind) is a follow-up; doing it here would be an unrelated documentation migration, so this change was left safe instead.
✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

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.
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.
@Freudator86
Freudator86 merged commit 7dd7be5 into main Jul 30, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant