Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 78 additions & 40 deletions bin/fm-fleet-snapshot.sh
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,39 @@ 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.
# 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() { # <json>...
printf '%s\n' "$@" 2>/dev/null
}

path_present_json() { # <path>
local present=0
[ -e "$1" ] && present=1
Expand Down Expand Up @@ -503,7 +536,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" \
Expand All @@ -527,11 +560,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 // ""),
Expand Down Expand Up @@ -580,10 +613,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() { # <backlog-json> <tasks-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
Expand All @@ -608,16 +640,15 @@ main_inventory_json() { # <backlog-json> <tasks-json>
# This mode never reads parent events or terminal text and never aggregates
# nested secondmates.
secondmate_home_summary_json() { # <backlog-json> <tasks-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[]?
Expand Down Expand Up @@ -1027,8 +1058,9 @@ terminal_evidence_json() { # <parent-task-json> <event-note> <evidence-contradi
}

parent_evidence_reconciliation_json() { # <summary-json> <activities-json> <decisions-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"
Expand Down Expand Up @@ -1092,8 +1124,9 @@ secondmate_current_json() { # <parent-tasks-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)} ]
Expand Down Expand Up @@ -1216,13 +1249,14 @@ secondmate_current_json() { # <parent-tasks-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"},
Expand All @@ -1246,36 +1280,37 @@ secondmate_current_json() { # <parent-tasks-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},
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}')
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 <<EOF
$rows
EOF
jq -n \
--argjson registry "$(printf '%s' "$union" | jq '.registry')" \
--argjson records "$records" \
json_stdin "$(printf '%s' "$union" | jq '.registry')" "$records" | jq -n \
--argjson total_registered "$total_registered" \
--argjson total "$total" \
--argjson shown "$shown" \
--argjson truncated "$truncated" \
'{registry:$registry,records:$records,total_registered:$total_registered,total:$total,shown:$shown,truncated:$truncated}'
'input as $registry | input as $records
| {registry:$registry,records:$records,total_registered:$total_registered,total:$total,shown:$shown,truncated:$truncated}'
}

secondmate_landed_from_current_json() { # <secondmate-current-json>
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}],
Expand Down Expand Up @@ -1329,21 +1364,24 @@ 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" \
--arg state "$STATE" \
--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");
{
Expand Down
3 changes: 2 additions & 1 deletion bin/fm-test-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading