From 471aac568d2f0c85f1e620d72e8cc8a457323a0e Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Thu, 6 Aug 2026 03:04:05 -0700 Subject: [PATCH 1/2] feat(session-start): order the startup digest for truncation safety and bound its bulk The digest is delivered through a harness that truncates an oversized payload from the tail, and it really has been truncated: a 70KB digest arrived as lines 1-435 of 578, cutting off eight lines before the live-task inventory. That session took the helm without ever seeing which tasks were live or where their endpoints were. Three changes, one file's worth of composition: - FLEET STATE is emitted before CONTEXT, so a truncated tail drops curated memory - stable session to session, already governed by a captain-set budget, recoverable with one targeted read - instead of live fleet identity. The LOCK/BOOTSTRAP/WAKE-QUEUE safety preamble keeps its order. The read-once contract moves out of the closing reminder into its own section ahead of both, and now names the condition that voids it: a stage the truncation banner reports as never emitted. - Status-tail lines are capped per line, reusing the cut the wake digest's OPEN DECISIONS section already applies. An observed tail line ran 865 characters and nothing bounded it. The cut and its marker now live in one place, bin/fm-line-cap-lib.sh, so the two digests cannot drift apart; each task's full status log path is still printed beside its tail. - The backlog listing is composed as a recovery input: done rows are never listed, every in-flight, held, and blocked row is shown in full with its hold and blocked-by metadata, and only the dispatchable-now listing is bounded - with an exact remainder count and the command that shows the rest. FM_SESSION_START_QUEUED_LIMIT (default 20) replaces FM_SESSION_START_BACKLOG_LIMIT, which bounded the whole listing indiscriminately and so could drop a held or blocked row. Tests exercise the real digest output: section ordering with the preamble pinned, the per-line cap and its marker, and the backlog composition including the remainder counters on both the tasks-axi and manual paths. --- bin/fm-line-cap-lib.sh | 51 ++++ bin/fm-session-start.sh | 254 +++++++++++++---- bin/fm-wake-drain.sh | 14 +- docs/configuration.md | 3 +- tests/fm-session-start.test.sh | 296 +++++++++++++++++--- tests/fm-shared-captain-inheritance.test.sh | 7 +- tests/fm-wake-drain-open-decisions.test.sh | 38 +++ 7 files changed, 560 insertions(+), 103 deletions(-) create mode 100644 bin/fm-line-cap-lib.sh diff --git a/bin/fm-line-cap-lib.sh b/bin/fm-line-cap-lib.sh new file mode 100644 index 00000000000..46d9d83ef64 --- /dev/null +++ b/bin/fm-line-cap-lib.sh @@ -0,0 +1,51 @@ +# shellcheck shell=bash +# Shared per-line cap for agent-facing digest lines. +# Usage: . bin/fm-line-cap-lib.sh; fm_cap_line "" [] +# +# ONE OWNER for the bounded-line shape both digests use. The wake digest's +# OPEN DECISIONS section (bin/fm-wake-drain.sh) and the session-start digest's +# per-task status tails (bin/fm-session-start.sh) render the same kind of +# content - an agent-written status line, which AGENTS.md section 8 treats as a +# wake EVENT rather than current state - into a size-bounded view. An agent +# reading both must recognize one truncation marker, and the two caps must not +# drift apart, so the cut and its marker live here. +# +# Callers keep their own composite policy: fm-wake-drain.sh still owns the +# OPEN DECISIONS global byte cap and its "N more omitted" disclosure, and +# fm-session-start.sh still owns how many tail lines it prints per task. This +# file owns only the per-line cut. +# +# The cap counts characters, so a plain-ASCII line - what status lines are in +# practice - is bounded to the same number of bytes, and a multibyte character +# is never cut in half into an invalid sequence. +# Truncation is always safe here because both callers print the full source +# alongside the bounded view: the session-start digest prints each task's full +# status log path, and OPEN DECISIONS is folded from those same logs. + +FM_LINE_CAP_DEFAULT=220 +FM_LINE_CAP_SUFFIX=' [truncated]' + +# fm_cap_line_var []: put in FM_LINE_CAP_LINE, cut to +# characters with FM_LINE_CAP_SUFFIX in place of the tail when it is longer. A +# line at or under the cap is kept unchanged, marker and all bytes intact. +# This is the rule itself. It assigns rather than prints so a caller that needs +# the value - the wake digest builds its section in a variable to weigh each +# item against a global budget - never pays a command substitution per item on +# a path that runs at the top of every wake-handling turn. +fm_cap_line_var() { + local line=$1 max=${2:-$FM_LINE_CAP_DEFAULT} keep + if [ "${#line}" -le "$max" ]; then + FM_LINE_CAP_LINE=$line + return 0 + fi + keep=$((max - ${#FM_LINE_CAP_SUFFIX})) + [ "$keep" -ge 0 ] || keep=0 + FM_LINE_CAP_LINE="${line:0:$keep}$FM_LINE_CAP_SUFFIX" +} + +# fm_cap_line []: the same cut, printed on stdout, for a caller that +# is streaming lines rather than accumulating them. +fm_cap_line() { + fm_cap_line_var "$@" + printf '%s\n' "$FM_LINE_CAP_LINE" +} diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index 48b937c5e28..b4d5cd05b26 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -38,20 +38,39 @@ # when locked. # 4. supervision-instructions - the one emitted operating block for the # detected primary harness. -# 5. context digest - data/projects.md, data/secondmates.md, data/captain.md, -# data/captain-shared.md, data/learnings.md: read-only, -# always safe, always runs. +# 5. read-once contract - the do-not-re-read contract covering every source +# the two digests below print in full. # 6. fleet digest - a compact data/backlog.md identity/metadata listing, # every state/*.meta, a bounded state/*.status tail, # state/.afk, and a cheap per-task endpoint-liveness read: # read-only, always runs. -# 7. closing reminder - prints the context-specific watcher next step; this +# 7. context digest - data/projects.md, data/secondmates.md, data/captain.md, +# data/captain-shared.md, data/learnings.md: read-only, +# always safe, always runs. +# 8. closing reminder - prints the context-specific watcher next step; this # script points back to the emitted harness supervision # block and deliberately never arms the watcher itself. # -# Those seven names are also the runtime-bound stage list below, so a truncated +# Those eight names are also the runtime-bound stage list below, so a truncated # startup can name exactly which of them never ran. # +# ORDERING, and why FLEET STATE now runs before CONTEXT: this digest is +# delivered through a harness that truncates an oversized payload from the TAIL, +# and it has really been truncated in practice - a 70KB digest arrived as lines +# 1-435 of 578, cutting off eight lines before the live-task inventory. What a +# truncated tail drops must therefore be the CHEAPEST thing to lose. Curated +# memory is stable session to session, is already governed by a captain-set +# budget (config/startup-memory-budget), and is recoverable with one targeted +# read; live fleet identity - which tasks exist, their windows, worktrees, +# backends, and endpoint liveness - changes every session and is exactly what +# recovery depends on. So fleet state goes first and the memory files absorb the +# truncation. The read-once contract moves ahead of both for the same reason: a +# contract that only arrives after the payload it governs is the first thing a +# truncated digest loses, and it carries the truncation caveat that keeps it +# honest when a stage below it never ran. +# The LOCK/BOOTSTRAP/WAKE-QUEUE safety preamble keeps its order: it establishes +# mutation authority and this turn's work queue before anything else is read. +# # On a Pi primary, the supervision-block step also checks whether Pi's two # tracked primary extensions are loaded and prints a PI_WATCH_EXTENSION # reminder line when one is missing. @@ -77,20 +96,44 @@ # The context and fleet-state digests # below are always read-only, so they run unconditionally in both modes. # -# BACKLOG DIGEST: FM_SESSION_START_BACKLOG_LIMIT bounds the startup backlog -# listing, default 80 items. +# BACKLOG DIGEST: the startup listing is a RECOVERY input, not a reporting +# surface, so it carries what this turn can act on and nothing else. +# - `done` rows are never listed. Retained completion history belongs to the +# reporting surfaces (bin/fm-bearings-snapshot.sh, /ahoy), and at startup it +# is pure weight - 10 done rows cost 3.3KB in an observed main-home digest. +# - Every in-flight, held, and blocked row is listed IN FULL, with its +# hold_kind/hold_reason and blocked_by. Those are the rows AGENTS.md +# sections 7 and 10 make actionable at startup, so they are never bounded +# away. +# - Only the plain queued (dispatchable-now) listing is bounded, by +# FM_SESSION_START_QUEUED_LIMIT, default 20. Anything it omits is disclosed +# with an exact remainder count and the command that shows the rest, so a +# deep queue costs a counter rather than kilobytes. +# (This replaces FM_SESSION_START_BACKLOG_LIMIT, which bounded the whole +# listing indiscriminately and so could drop a held or blocked row.) # When compatible tasks-axi is selected and available, the shared tasks-axi # backend probe remains the compatibility owner and this script asks # `tasks-axi list` for the compact identity fields plus blocked_by, hold_kind, -# and hold_reason, never body. +# and hold_reason, never body. The groups are the tool's own filters +# (`--state in_flight`, `--state held`, `--state queued --blocked`, and +# `tasks-axi ready`), so this script never reimplements task state; the groups +# can overlap, because an in-flight item that is also held appears under both. # When manual mode is selected, or tasks-axi is unavailable or incompatible, # this script prints only backlog section headings and item title lines, so # title-line hold and blocked-by metadata remain visible while indented bodies -# stay out of the startup digest. +# stay out of the startup digest; the same never-bound-a-held-or-blocked-row +# rule applies, recognized there from the title line's own hold/blocked-by +# markers. # Full bodies are targeted follow-up only: `tasks-axi show --full` when # compatible tasks-axi is available, or `data/backlog.md` when the file body is # truly needed. # +# STATUS TAILS: FM_SESSION_START_STATUS_TAIL bounds how many lines each task's +# tail prints, and bin/fm-line-cap-lib.sh bounds how long each of those lines +# may be. Both bounds are safe because the section prints every task's full +# status log path, and AGENTS.md section 8 treats a status line as a wake EVENT +# rather than current state - bin/fm-crew-state.sh owns current state. +# # RUNTIME BOUND: the digest is now executed on a session-open hook (see # bin/fm-sessionstart-run.sh), which blocks session initialization while it # runs, so an unbounded digest is no longer merely slow - it can strand a whole @@ -158,7 +201,7 @@ done # The ordered stage list is the contract behind the truncation banner: the child # names the stage it is entering, and the parent reports every stage at or after # that one as never emitted. Keep it in the exact order the digest prints. -SESSION_START_STAGES='lock bootstrap wake-queue supervision-instructions context fleet-state next-step' +SESSION_START_STAGES='lock bootstrap wake-queue supervision-instructions read-once fleet-state context next-step' stage() { # : breadcrumb for the parent's truncation banner [ -n "${FM_SESSION_START_STAGE_FILE:-}" ] || return 0 @@ -218,11 +261,14 @@ PRIMARY_HARNESS=$("$SCRIPT_DIR/fm-harness.sh" 2>/dev/null || printf unknown) . "$SCRIPT_DIR/fm-public-followup-lib.sh" # shellcheck source=bin/fm-trace-context-lib.sh . "$SCRIPT_DIR/fm-trace-context-lib.sh" +# shellcheck source=bin/fm-line-cap-lib.sh +. "$SCRIPT_DIR/fm-line-cap-lib.sh" STATUS_TAIL=${FM_SESSION_START_STATUS_TAIL:-5} case "$STATUS_TAIL" in ''|*[!0-9]*) STATUS_TAIL=5 ;; esac -BACKLOG_LIMIT=${FM_SESSION_START_BACKLOG_LIMIT:-80} -case "$BACKLOG_LIMIT" in ''|*[!0-9]*|0) BACKLOG_LIMIT=80 ;; esac +QUEUED_LIMIT=${FM_SESSION_START_QUEUED_LIMIT:-20} +case "$QUEUED_LIMIT" in ''|*[!0-9]*|0) QUEUED_LIMIT=20 ;; esac +BACKLOG_FIELDS=blocked_by,hold_kind,hold_reason RULE='================================================================================' SUBRULE='--------------------------------------------------------------------------------' @@ -254,10 +300,18 @@ print_backlog_pointer() { printf 'Full task bodies remain available on demand: tasks-axi show --full when compatible tasks-axi is available, or data/backlog.md.\n' } +# A queued title line whose own text already marks it held or blocked. The +# manual renderer has no task model, so this is the only signal it gets, and it +# is the one tasks-axi's markdown backend writes: "(hold: ...)", "(hold-kind: +# ...)", and "blocked-by: ...". Bracket expressions rather than backslashes, +# because awk's -v applies escape processing before the regex is ever compiled. +MANUAL_KEEP_RE='[(]hold|blocked-by:' + print_backlog_manual_compact() { local path=$1 reason=$2 - printf 'compact backlog listing (%s; max %s item(s); indented task bodies omitted)\n' "$reason" "$BACKLOG_LIMIT" - awk -v max="$BACKLOG_LIMIT" ' + printf 'compact backlog listing (%s; done rows omitted; every in-flight, held, and blocked title line kept; other queued bounded to %s; indented task bodies omitted)\n' \ + "$reason" "$QUEUED_LIMIT" + awk -v max="$QUEUED_LIMIT" -v keep_re="$MANUAL_KEEP_RE" ' function state_for_heading(line, heading) { heading = line sub(/^##[[:space:]]+/, "", heading) @@ -269,42 +323,94 @@ print_backlog_manual_compact() { } /^##[[:space:]]+/ { state = state_for_heading($0) - if (state != "") print $0 + # The Done heading is recognized so its items are skipped, never printed. + if (state != "" && state != "done") print $0 next } - state != "" && /^[-*][[:space:]]+/ { - total++ - if (shown < max) { - print $0 - shown++ - } + state == "in_flight" && /^[-*][[:space:]]+/ { in_flight++; print $0; next } + state == "done" && /^[-*][[:space:]]+/ { done_total++; next } + state == "queued" && /^[-*][[:space:]]+/ { + queued_total++ + if ($0 ~ keep_re) { gated++; print $0; next } + if (plain_shown < max) { plain_shown++; print $0 } next } END { - if (total == 0) { + plain_total = queued_total - gated + if (in_flight + queued_total + done_total == 0) { print "(no backlog item title lines found)" } else { - printf "(shown %d of %d backlog item title line(s))\n", shown, total - if (total > shown) { - printf "(truncated %d item(s); increase FM_SESSION_START_BACKLOG_LIMIT for a larger startup listing)\n", total - shown + printf "(shown %d in-flight, %d held or blocked queued, %d of %d other queued title line(s); %d done row(s) omitted)\n", \ + in_flight, gated, plain_shown, plain_total, done_total + if (plain_total > plain_shown) { + printf "(%d more queued - raise FM_SESSION_START_QUEUED_LIMIT or read data/backlog.md for the rest)\n", plain_total - plain_shown } } } ' "$path" } +# tasks-axi closes every listing with its own help block. This section composes +# four listings, so keeping them would repeat the same pointers four times, once +# per group, each carrying this home's full backlog path. The section prints one +# equivalent pointer of its own (print_backlog_pointer), so the per-group help +# blocks stop at their `help[` header instead. +strip_axi_help() { + awk '/^help\[/ { exit } { print }' +} + +# Bound the dispatchable-now listing without rewriting the tool's own rendering: +# `tasks-axi ready` rows are the indented lines under its ready[N]{...} header, +# and every other line it prints (its count, its public-followup line) passes +# through untouched. Whatever is cut is disclosed exactly. +print_ready_queued_bounded() { + local ready=$1 path=$2 + printf '%s\n' "$ready" | awk -v max="$QUEUED_LIMIT" -v path="$path" ' + /^help\[/ { exit } + /^ready\[/ { rows = 1; print; next } + rows && /^[[:space:]]/ { + total++ + if (shown < max) { print; shown++ } + next + } + { rows = 0; print } + END { + if (total > 0) { + printf "(shown %d of %d ready queued item(s))\n", shown, total + if (total > shown) { + printf "(%d more queued - tasks-axi ready --file %s)\n", total - shown, path + } + } + } + ' +} + print_backlog_tasks_axi_compact() { - local path=$1 out rc - printf 'compact backlog listing (tasks-axi; max %s item(s); task bodies omitted)\n' "$BACKLOG_LIMIT" - out=$(tasks-axi list --file "$path" --limit "$BACKLOG_LIMIT" --fields blocked_by,hold_kind,hold_reason 2>&1) - rc=$? - if [ "$rc" -eq 0 ]; then - printf '%s\n' "$out" + local path=$1 in_flight held blocked ready err + if ! in_flight=$(tasks-axi list --file "$path" --state in_flight --fields "$BACKLOG_FIELDS" 2>&1); then + err=$in_flight + elif ! held=$(tasks-axi list --file "$path" --state held --fields "$BACKLOG_FIELDS" 2>&1); then + err=$held + elif ! blocked=$(tasks-axi list --file "$path" --state queued --blocked --fields "$BACKLOG_FIELDS" 2>&1); then + err=$blocked + elif ! ready=$(tasks-axi ready --file "$path" 2>&1); then + err=$ready else - printf 'tasks-axi compact listing failed; falling back to title-line rendering.\n' - printf '%s\n' "$out" - print_backlog_manual_compact "$path" "fallback" + printf 'compact backlog listing (tasks-axi; done rows omitted; every in-flight, held, and blocked row shown in full; ready queued bounded to %s; task bodies omitted)\n' \ + "$QUEUED_LIMIT" + printf '\nin flight:\n' + printf '%s\n' "$in_flight" | strip_axi_help + printf '\nheld (captain- or time-gated; an in-flight item that is also held appears in both groups):\n' + printf '%s\n' "$held" | strip_axi_help + printf '\nblocked queued:\n' + printf '%s\n' "$blocked" | strip_axi_help + printf '\nready queued (dispatchable now):\n' + print_ready_queued_bounded "$ready" "$path" + return 0 fi + printf 'tasks-axi compact listing failed; falling back to title-line rendering.\n' + printf '%s\n' "$err" + print_backlog_manual_compact "$path" "fallback" } print_backlog_compact() { @@ -329,9 +435,16 @@ print_backlog_compact() { } print_status_tail() { - local status=$1 - printf 'status tail (last %s line(s), wake-EVENT history, not current state; full log: %s):\n' "$STATUS_TAIL" "$status" - tail -n "$STATUS_TAIL" "$status" + local status=$1 line + printf 'status tail (last %s line(s), each capped at %s characters, wake-EVENT history, not current state; full log: %s):\n' \ + "$STATUS_TAIL" "$FM_LINE_CAP_DEFAULT" "$status" + # A crewmate writes its own status lines, so their length is unbounded: one + # observed line ran 865 characters. Cap each one the way the wake digest's + # OPEN DECISIONS section does; the lede carries the state word and the key, + # and the full log path above reaches the rest. + while IFS= read -r line || [ -n "$line" ]; do + fm_cap_line "$line" + done < <(tail -n "$STATUS_TAIL" "$status") } hash_file() { @@ -471,16 +584,38 @@ fi --afk "$AFK_PRESENT" \ --x-mode "$X_MODE_PRESENT" -# --- 5. context digest ----------------------------------------------------- -stage context -section "CONTEXT" -print_file_or_absent "$DATA/projects.md" "data/projects.md" -print_file_or_absent "$DATA/secondmates.md" "data/secondmates.md" -print_file_or_absent "$DATA/captain.md" "data/captain.md" -print_file_or_absent "$DATA/captain-shared.md" "data/captain-shared.md (shared, main-authoritative, read-only in secondmate homes)" -print_file_or_absent "$DATA/learnings.md" "data/learnings.md" +# --- 5. read-once contract ------------------------------------------------- +# Ahead of the two digests it governs, not after them: a truncated tail is +# exactly what drops a closing reminder, and this contract is what stops the +# next turn from re-reading everything the digest just printed. Because it now +# arrives BEFORE its subject, it also names the one condition that voids it - +# a stage that never ran, which the truncation banner names by stage. +stage read-once +section "READ-ONCE CONTRACT" +cat <<'EOF' +Everything below is printed in full for this session start: every state/*.meta, +a compact data/backlog.md listing, a bounded tail of every state/*.status, +data/projects.md, data/secondmates.md, data/captain.md, data/captain-shared.md, +and data/learnings.md. +Do NOT re-read any of them after reading this digest, and do NOT bulk-read +data/backlog.md or state/*.status: re-reading everything defeats the entire +point of this command. + +Go to a source directly only when: + - this digest flagged it ABSENT (then rebuild or create it per AGENTS.md), + - its contents looked unparseable or corrupt, + - an individual full status log is needed for older wake-event history, or a + status line was capped and its tail matters (each task's full log path is + printed with its tail), + - a full task body is needed (tasks-axi show --full, or data/backlog.md), + - the backlog listing disclosed omitted queued items and this turn needs them, + - or a STARTUP TRUNCATED banner named the stage that would have printed it, in + which case that stage's sources were never emitted and must be reconciled. +EOF # --- 6. fleet-state digest --------------------------------------------- +# Before CONTEXT: see this file's ORDERING note. Live fleet identity is what a +# truncated tail must never take. stage fleet-state section "FLEET STATE" print_backlog_compact "$DATA/backlog.md" "data/backlog.md" @@ -552,7 +687,20 @@ if fm_pf_relay_active "$FM_HOME" \ fi fi -# --- 7. closing reminder ----------------------------------------------- +# --- 7. context digest ----------------------------------------------------- +# Last of the bulk sections deliberately: curated memory is stable session to +# session, already governed by config/startup-memory-budget, and recoverable +# with one targeted read, so it is the cheapest thing for a truncated tail to +# take (see this file's ORDERING note). +stage context +section "CONTEXT" +print_file_or_absent "$DATA/projects.md" "data/projects.md" +print_file_or_absent "$DATA/secondmates.md" "data/secondmates.md" +print_file_or_absent "$DATA/captain.md" "data/captain.md" +print_file_or_absent "$DATA/captain-shared.md" "data/captain-shared.md (shared, main-authoritative, read-only in secondmate homes)" +print_file_or_absent "$DATA/learnings.md" "data/learnings.md" + +# --- 8. closing reminder ----------------------------------------------- stage next-step section "NEXT STEP" if [ "$READ_ONLY" -eq 1 ]; then @@ -584,18 +732,8 @@ This script never starts supervision itself. EOF fi cat <<'EOF' -The digest above is complete for this session start. Do NOT re-read -data/projects.md, data/secondmates.md, data/captain.md, -data/captain-shared.md, data/learnings.md, -or state/*.meta now - they were just printed in full. -Do NOT bulk-read data/backlog.md now either: the compact identity/metadata -listing was just printed with a pointer for targeted full-body follow-up. -Do NOT bulk-read state/*.status now either: their bounded tails were just -printed with full log paths for targeted follow-up when older wake-event -history is actually needed. Re-reading everything defeats the entire point -of this command. Re-read a file only if this digest flagged it ABSENT (then -rebuild or create it per AGENTS.md), its contents looked unparseable/corrupt, -or an individual full status log is needed for older wake-event history. +The digest above is complete for this session start. The READ-ONCE CONTRACT +section near the top of it governs what may still be read from disk. EOF if [ "$READ_ONLY" -eq 0 ] && [ "$REEMIT" -eq 0 ]; then diff --git a/bin/fm-wake-drain.sh b/bin/fm-wake-drain.sh index 79497689773..a7ad47082a0 100755 --- a/bin/fm-wake-drain.sh +++ b/bin/fm-wake-drain.sh @@ -8,6 +8,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" . "$SCRIPT_DIR/fm-wake-lib.sh" # shellcheck source=bin/fm-classify-lib.sh . "$SCRIPT_DIR/fm-classify-lib.sh" +# shellcheck source=bin/fm-line-cap-lib.sh +. "$SCRIPT_DIR/fm-line-cap-lib.sh" DRAIN_TMP= DRAIN_LOCK_HELD=false @@ -43,7 +45,7 @@ assert_watcher_liveness() { # common case. print_open_decisions_section() { local open task key verb note line item_bytes=220 global_bytes=4000 - local output='' used=0 shown=0 omitted=0 bytes suffix keep + local output='' used=0 shown=0 omitted=0 bytes open=$(scan_open_decisions_incremental "$STATE") || return 0 [ -n "$open" ] || return 0 @@ -53,11 +55,11 @@ print_open_decisions_section() { line="$task" [ "$key" = default ] || line="$line [key=$key]" line="$line $verb: $note" - if [ $(( ${#line} + 1 )) -gt "$item_bytes" ]; then - suffix=' [truncated]' - keep=$((item_bytes - ${#suffix} - 1)) - line="${line:0:$keep}$suffix" - fi + # The shared cut counts the item's own characters; the trailing newline this + # section's global budget also pays for is this caller's, so the per-item + # allowance passed down is one short of the cap. + fm_cap_line_var "$line" $((item_bytes - 1)) + line=$FM_LINE_CAP_LINE bytes=$(( ${#line} + 1 )) if [ $((used + bytes)) -gt "$global_bytes" ]; then omitted=$((omitted + 1)) diff --git a/docs/configuration.md b/docs/configuration.md index beb7e928446..bea06cb8d0b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -509,7 +509,8 @@ FM_ZELLIJ_SESSION=firstmate # zellij-only: named session for normal backend ops FM_BACKEND_CMUX_COMPOSER_LINES=20 # cmux-only: tail lines scanned to locate the composer row for submit verification FM_BACKEND_CMUX_IDLE_RE='^Type a message\.\.\.$' # cmux-only: empty-composer placeholder regex after border/prompt stripping CMUX_SOCKET_PASSWORD= # cmux-only: socket password fallback when config/cmux-socket-password is absent (docs/cmux-backend.md) -FM_SESSION_START_STATUS_TAIL=5 # state/*.status lines printed per task in the session-start digest +FM_SESSION_START_STATUS_TAIL=5 # state/*.status lines printed per task in the session-start digest; each line is capped by bin/fm-line-cap-lib.sh +FM_SESSION_START_QUEUED_LIMIT=20 # plain queued backlog rows in the session-start digest; in-flight, held, and blocked rows are never bounded and done rows are never listed FM_BOOTSTRAP_DETECT_ONLY=0 # internal/read-only session-start mode: skip bootstrap's mutating sweeps and print advisory TANGLE wording FM_GUARD_READ_ONLY=0 # internal/read-only guard mode: keep alarms but suppress drain, supervision repair, and checkout repair commands FM_GUARD_CONTINUE_LINE='This is a supervision warning only; the guarded operation WILL still run.' # banner continuation line; fm-send.sh overrides it to name the requested message specifically diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index f249d23b396..856770394d6 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -8,10 +8,16 @@ # - the lock-refusal read-only path: banner leads, every mutating step is # skipped (including bootstrap's five mutating sweeps, verified by their # ABSENCE), the digest still completes -# - output section ordering: diagnostics/banners lead, bulk file dumps follow +# - output section ordering: the safety preamble leads unchanged, live fleet +# state precedes the curated memory a truncated tail may take, and the +# read-once contract precedes both # - context-aware next-step guidance for read-only, AFK, X mode, and normal # watcher ownership # - status-tail bounding, default and FM_SESSION_START_STATUS_TAIL override +# - the per-line status-tail cap and its truncation marker +# - startup backlog composition: done rows dropped, every in-flight/held/ +# blocked row kept whole, the dispatchable queued listing bounded with an +# exact disclosed remainder # - orphan status logs whose task meta has already disappeared # - per-task endpoint-liveness lines for a live and a dead recorded target, # tmux and herdr both @@ -96,6 +102,12 @@ SH printf '%s\n' manual > "${fakebin%/*}/home-placeholder" 2>/dev/null || true } +# make_fake_tasks_axi_compact : a tasks-axi boundary that answers the +# four group filters the startup listing composes (in-flight, held, blocked +# queued, and the dispatchable ready set) and REFUSES anything the recovery +# listing must never ask for: a body field, an unfiltered whole-backlog listing, +# or done rows. FM_FAKE_TASKS_AXI_READY sizes the ready set so the queued bound +# can be driven past its limit. make_fake_tasks_axi_compact() { local fakebin=$1 cat > "$fakebin/tasks-axi" <<'SH' @@ -103,6 +115,20 @@ make_fake_tasks_axi_compact() { set -u log=${FM_FAKE_TASKS_AXI_LOG:-} [ -n "$log" ] && printf '%s\n' "$*" >> "$log" +ready_count=${FM_FAKE_TASKS_AXI_READY:-2} +require_file() { + case "$*" in *'--file '*) return 0 ;; esac + printf '%s\n' 'missing explicit backlog file' >&2 + exit 9 +} +task_header() { + printf 'count: %s\n' "$1" + printf 'tasks[%s]{id,state,kind,repo,title,blocked_by,hold_kind,hold_reason}:\n' "$1" +} +list_help() { + printf 'help[1]:\n' + printf '%s\n' ' - Run `tasks-axi show --full` for full notes on a task' +} case "${1:-}" in --version|-v|-V) printf '%s\n' '0.2.4' @@ -120,6 +146,20 @@ case "${1:-}" in exit 0 fi ;; + ready) + require_file "$@" + printf 'count: %s\n' "$ready_count" + printf 'ready[%s]{id,state,kind,repo,title}:\n' "$ready_count" + i=1 + while [ "$i" -le "$ready_count" ]; do + printf ' ready-%s,queued,ship,firstmate,Ready item %s\n' "$i" "$i" + i=$((i + 1)) + done + printf 'ready_public_followups: 0 delivery-ready obligations\n' + printf 'help[1]:\n' + printf '%s\n' ' - Run `tasks-axi start ` to dispatch one of these' + exit 0 + ;; list) case "$*" in *'--fields '*'body'*|*'--fields='*'body'*) @@ -127,17 +167,30 @@ case "${1:-}" in exit 9 ;; esac - case "$*" in *'--limit 80'*) : ;; *) printf '%s\n' 'missing compact limit' >&2; exit 9 ;; esac - case "$*" in *'--file '*) : ;; *) printf '%s\n' 'missing explicit backlog file' >&2; exit 9 ;; esac - cat <<'OUT' -count: 2 -tasks[2]{id,state,kind,repo,title,blocked_by,hold_kind,hold_reason}: - compact-startup,in_flight,ship,firstmate,Compact startup digest,none,captain,captain choice pending - blocked-followup,queued,scout,firstmate,Follow compact startup,compact-startup,"-","-" -help[2]: - - Run `tasks-axi show --full` for full notes on a task - - Run `tasks-axi ready` to see unblocked queued work -OUT + require_file "$@" + case "$*" in + *'--state done'*) + printf '%s\n' 'startup recovery must never list done rows' >&2 + exit 9 + ;; + *'--state in_flight'*) + task_header 1 + printf '%s\n' ' compact-startup,in_flight,ship,firstmate,Compact startup digest,none,captain,captain choice pending' + ;; + *'--state held'*) + task_header 1 + printf '%s\n' ' held-queued,queued,ship,firstmate,Held queued work,none,captain,captain choice pending' + ;; + *'--state queued'*'--blocked'*) + task_header 1 + printf '%s\n' ' blocked-followup,queued,scout,firstmate,Follow compact startup,compact-startup,"-","-"' + ;; + *) + printf '%s\n' 'startup recovery must not request an unfiltered whole-backlog listing' >&2 + exit 9 + ;; + esac + list_help exit 0 ;; esac @@ -802,8 +855,13 @@ SH # --- output ordering ---------------------------------------------------------- +# The digest is delivered through a harness that truncates from the TAIL, so +# section order decides what a truncated startup loses. The safety preamble +# still leads, live fleet identity now outranks curated memory, and the +# read-once contract arrives before the payload it governs. test_output_ordering_diagnostics_lead() { - local rec root home fakebin out lock_line boot_line wake_line context_line fleet_line next_line + local rec root home fakebin out lock_line boot_line wake_line read_once_line + local context_line fleet_line next_line inventory_line missing_line rec=$(new_world ordering) IFS='|' read -r root home fakebin < "$home/state/task-a.meta" + printf 'Captain memory that may be truncated away safely.\n' > "$home/data/captain.md" out=$(run_session_start "$home" "$root" "$fakebin:$BASE_PATH") lock_line=$(printf '%s\n' "$out" | grep -n '^LOCK$' | head -1 | cut -d: -f1) boot_line=$(printf '%s\n' "$out" | grep -n '^BOOTSTRAP$' | head -1 | cut -d: -f1) wake_line=$(printf '%s\n' "$out" | grep -n '^WAKE QUEUE$' | head -1 | cut -d: -f1) + read_once_line=$(printf '%s\n' "$out" | grep -n '^READ-ONCE CONTRACT$' | head -1 | cut -d: -f1) context_line=$(printf '%s\n' "$out" | grep -n '^CONTEXT$' | head -1 | cut -d: -f1) fleet_line=$(printf '%s\n' "$out" | grep -n '^FLEET STATE$' | head -1 | cut -d: -f1) next_line=$(printf '%s\n' "$out" | grep -n '^NEXT STEP$' | head -1 | cut -d: -f1) + inventory_line=$(printf '%s\n' "$out" | grep -n '^--- task-a ---$' | head -1 | cut -d: -f1) - if [ -z "$lock_line" ] || [ -z "$boot_line" ] || [ -z "$wake_line" ] || [ -z "$context_line" ] || [ -z "$fleet_line" ] || [ -z "$next_line" ]; then + if [ -z "$lock_line" ] || [ -z "$boot_line" ] || [ -z "$wake_line" ] \ + || [ -z "$read_once_line" ] || [ -z "$context_line" ] || [ -z "$fleet_line" ] \ + || [ -z "$next_line" ] || [ -z "$inventory_line" ]; then fail "one or more section headers missing from digest: $out" fi + # The safety preamble's order is unchanged: mutation authority, then + # diagnostics, then this turn's work queue, before anything bulky is read. [ "$lock_line" -lt "$boot_line" ] || fail "LOCK did not precede BOOTSTRAP" [ "$boot_line" -lt "$wake_line" ] || fail "BOOTSTRAP did not precede WAKE QUEUE" - [ "$wake_line" -lt "$context_line" ] || fail "WAKE QUEUE did not precede CONTEXT" - [ "$context_line" -lt "$fleet_line" ] || fail "CONTEXT did not precede FLEET STATE" - [ "$fleet_line" -lt "$next_line" ] || fail "FLEET STATE did not precede NEXT STEP" + [ "$wake_line" -lt "$read_once_line" ] || fail "WAKE QUEUE did not precede the read-once contract" + + [ "$read_once_line" -lt "$fleet_line" ] || fail "the read-once contract did not precede FLEET STATE" + [ "$fleet_line" -lt "$context_line" ] || fail "FLEET STATE did not precede CONTEXT" + [ "$context_line" -lt "$next_line" ] || fail "CONTEXT did not precede NEXT STEP" + + # The live-task inventory - the record recovery actually depends on - must sit + # ahead of the curated memory a truncated tail is allowed to take. + [ "$inventory_line" -lt "$context_line" ] \ + || fail "the live-task inventory was buried behind the curated memory files" + assert_contains "$out" "Captain memory that may be truncated away safely." \ + "the ordering fixture did not actually print a memory file" missing_line=$(printf '%s\n' "$out" | grep -n 'MISSING: node' | head -1 | cut -d: -f1) [ -n "$missing_line" ] || fail "MISSING diagnostic did not appear at all" [ "$missing_line" -lt "$fleet_line" ] || fail "actionable MISSING diagnostic was buried after the bulk fleet-state digest" - pass "digest sections are ordered diagnostics-first, bulk-context-last" + pass "digest sections are ordered safety-preamble first, live fleet state before curated memory" +} + +# The contract has to survive tail truncation and stay honest once it precedes +# the sections it governs, so it carries the truncated-stage escape itself. +test_read_once_contract_is_stated_once_before_its_subject() { + local rec root home fakebin out contract_count + rec=$(new_world read-once) + IFS='|' read -r root home fakebin < "$home/state/task-cap.meta" + { + printf '%s' "$lede" + awk 'BEGIN { while (i++ < 400) printf " padding" }' + printf '\n' + printf 'working: short line kept whole\n' + } > "$home/state/task-cap.status" + + out=$(run_session_start "$home" "$root" "$fakebin:$BASE_PATH") + + assert_contains "$out" "$lede" "the cap discarded the lede that carries the state word and decision key" + assert_contains "$out" " [truncated]" "an over-long status line was not marked as truncated" + assert_contains "$out" "working: short line kept whole" "the cap mangled a status line already under it" + assert_contains "$out" "each capped at 220 characters" "the status tail header does not disclose its per-line cap" + assert_contains "$out" "$home/state/task-cap.status" "a capped tail dropped the full log path that recovers the rest" + + # Nothing the tail emits may exceed the cap, and the padded line really was + # long enough to exercise it. + tail_section=$(printf '%s\n' "$out" | awk '/^status tail \(/ { flag = 1; next } flag && /^$/ { flag = 0 } flag') + longest=$(printf '%s\n' "$tail_section" | awk '{ if (length($0) > max) max = length($0) } END { print max + 0 }') + [ "$longest" -le 220 ] || fail "a status tail line ran $longest characters past the 220-character cap" + capped=$(printf '%s\n' "$tail_section" | grep -c ' \[truncated\]$') + [ "$capped" -eq 1 ] || fail "expected exactly one truncated tail line, got $capped: $tail_section" + + pass "status tail lines are capped with a truncation marker while the full log stays reachable" +} + test_orphan_status_logs_are_printed() { local rec root home fakebin out matched_count orphan_count rec=$(new_world orphan-status) @@ -1113,8 +1255,11 @@ EOF # --- fleet-state digest: compact backlog rendering -------------------------- +# A backlog whose Done section, held row, blocked row, and plain queued rows can +# each be told apart in the rendered digest. DONE-ROW-LINE and the *-BODY-LINE +# markers exist so a leak is unmistakable. write_long_body_backlog() { - local path=$1 + local path=$1 i=1 cat > "$path" <<'EOF' # Backlog @@ -1126,8 +1271,16 @@ write_long_body_backlog() { ## Queued - [ ] blocked-followup - Follow compact startup blocked-by: compact-startup - waits for implementation (repo: firstmate) (kind: scout) (since 2026-07-15) QUEUED-BODY-LINE this is another long multiline note. +- [ ] held-queued - Held queued work (repo: firstmate) (kind: ship) (hold: captain choice pending) (hold-kind: captain) +EOF + while [ "$i" -le 25 ]; do + printf -- '- [ ] plain-%s - Plain queued item %s (repo: firstmate) (kind: ship)\n' "$i" "$i" >> "$path" + i=$((i + 1)) + done + cat >> "$path" <<'EOF' ## Done +- [x] landed-earlier - DONE-ROW-LINE already landed and torn down (repo: firstmate) (kind: ship) EOF } @@ -1146,26 +1299,82 @@ EOF > "$home/state/compact-startup.meta" log="$home/tasks-axi.log" - out=$(FM_FAKE_TASKS_AXI_LOG="$log" run_session_start "$home" "$root" "$fakebin:$BASE_PATH") + out=$(FM_FAKE_TASKS_AXI_LOG="$log" FM_FAKE_TASKS_AXI_READY=3 \ + run_session_start "$home" "$root" "$fakebin:$BASE_PATH") - assert_contains "$out" "compact backlog listing (tasks-axi; max 80 item(s); task bodies omitted)" \ + assert_contains "$out" "compact backlog listing (tasks-axi; done rows omitted; every in-flight, held, and blocked row shown in full; ready queued bounded to 20; task bodies omitted)" \ "compatible tasks-axi backend did not render the compact backlog listing" - assert_contains "$out" "tasks[2]{id,state,kind,repo,title,blocked_by,hold_kind,hold_reason}:" \ + assert_contains "$out" "tasks[1]{id,state,kind,repo,title,blocked_by,hold_kind,hold_reason}:" \ "tasks-axi compact listing omitted the expected structured field header" assert_contains "$out" "compact-startup,in_flight,ship,firstmate,Compact startup digest,none,captain,captain choice pending" \ "tasks-axi compact listing omitted in-flight identity, state, or hold metadata" + assert_contains "$out" "held-queued,queued,ship,firstmate,Held queued work,none,captain,captain choice pending" \ + "tasks-axi compact listing omitted a held row or its hold metadata" assert_contains "$out" 'blocked-followup,queued,scout,firstmate,Follow compact startup,compact-startup,"-","-"' \ "tasks-axi compact listing omitted blocked-by metadata" + assert_contains "$out" "ready-3,queued,ship,firstmate,Ready item 3" \ + "tasks-axi compact listing omitted a dispatchable queued row inside the bound" assert_not_contains "$out" "OVERSIZED-BODY-LINE" "tasks-axi compact digest leaked an in-flight task body" assert_not_contains "$out" "QUEUED-BODY-LINE" "tasks-axi compact digest leaked a queued task body" + assert_not_contains "$out" "DONE-ROW-LINE" "tasks-axi compact digest listed a done row at startup" assert_contains "$out" "--- compact-startup ---" "in-flight meta identity disappeared from startup recovery digest" assert_contains "$out" "worktree=$home/projects/firstmate" "in-flight recovery worktree identity disappeared from startup digest" assert_contains "$out" "Full task bodies remain available on demand: tasks-axi show --full" \ "compact digest omitted the full-body lookup pointer" - assert_grep "list --file $home/data/backlog.md --limit 80 --fields blocked_by,hold_kind,hold_reason" "$log" \ - "session start did not ask tasks-axi for the bounded compact field set" + assert_contains "$out" "ready_public_followups: 0 delivery-ready obligations" \ + "the composed listing dropped a real signal from the dispatchable set" + # One section pointer, not one repeated help block per composed group. + assert_not_contains "$out" "help[1]:" \ + "the composed listing repeated tasks-axi's per-group help block" + + # The fake refuses a body field, an unfiltered listing, and a done listing, so + # a clean render already proves those were never asked for; pin the group + # filters the listing is built from. + assert_grep "--state in_flight --fields blocked_by,hold_kind,hold_reason" "$log" \ + "session start did not ask tasks-axi for the in-flight group" + assert_grep "--state held --fields blocked_by,hold_kind,hold_reason" "$log" \ + "session start did not ask tasks-axi for the held group" + assert_grep "--state queued --blocked --fields blocked_by,hold_kind,hold_reason" "$log" \ + "session start did not ask tasks-axi for the blocked queued group" + assert_grep "ready --file $home/data/backlog.md" "$log" \ + "session start did not ask tasks-axi for the dispatchable queued set" + + pass "compatible tasks-axi backlog rendering drops done rows and keeps every in-flight, held, and blocked row" +} - pass "compatible tasks-axi backlog rendering is compact, bounded, and preserves recovery metadata" +# The bound may only ever cut the dispatchable-now listing, and whatever it cuts +# must be disclosed with an exact count and the command that shows the rest. +test_backlog_queued_bound_discloses_its_remainder() { + local rec root home fakebin out + rec=$(new_world backlog-queued-bound) + IFS='|' read -r root home fakebin < "$home/config/backlog-backend" write_long_body_backlog "$home/data/backlog.md" - out=$(run_session_start "$home" "$root" "$fakebin:$BASE_PATH") + out=$(FM_SESSION_START_QUEUED_LIMIT=4 run_session_start "$home" "$root" "$fakebin:$BASE_PATH") - assert_contains "$out" "compact backlog listing (manual backend; max 80 item(s); indented task bodies omitted)" \ + assert_contains "$out" "compact backlog listing (manual backend; done rows omitted; every in-flight, held, and blocked title line kept; other queued bounded to 4; indented task bodies omitted)" \ "manual backend did not use compact title-line rendering" assert_contains "$out" "## In flight" "manual compact rendering omitted the in-flight section heading" assert_contains "$out" "- [ ] compact-startup - Compact startup digest" \ @@ -1190,13 +1399,23 @@ EOF "manual compact rendering omitted hold metadata" assert_contains "$out" "blocked-by: compact-startup - waits for implementation" \ "manual compact rendering omitted blocker metadata" + assert_contains "$out" "- [ ] held-queued - Held queued work" \ + "manual compact rendering dropped a held queued title line" assert_not_contains "$out" "OVERSIZED-BODY-LINE" "manual compact digest leaked an in-flight task body" assert_not_contains "$out" "QUEUED-BODY-LINE" "manual compact digest leaked a queued task body" - assert_contains "$out" "(shown 2 of 2 backlog item title line(s))" \ + assert_not_contains "$out" "DONE-ROW-LINE" "manual compact digest listed a done row at startup" + assert_not_contains "$out" "## Done" "manual compact digest printed the done heading it never fills" + assert_contains "$out" "- [ ] plain-4 - Plain queued item 4" \ + "manual compact rendering dropped a queued title line inside its bound" + assert_not_contains "$out" "- [ ] plain-5 - Plain queued item 5" \ + "manual compact rendering did not bound its plain queued listing" + assert_contains "$out" "(shown 1 in-flight, 2 held or blocked queued, 4 of 25 other queued title line(s); 1 done row(s) omitted)" \ "manual compact rendering did not report its bound accounting" + assert_contains "$out" "(21 more queued - raise FM_SESSION_START_QUEUED_LIMIT or read data/backlog.md for the rest)" \ + "manual compact rendering did not disclose an exact queued remainder" assert_contains "$out" "or data/backlog.md" "manual compact digest omitted the data/backlog.md full-body pointer" - pass "manual backlog rendering prints only title lines with hold and blocker metadata" + pass "manual backlog rendering drops done rows, keeps every held or blocked title line, and bounds the rest" } test_backlog_compact_tasks_axi_unavailable_uses_manual_fallback() { @@ -1211,11 +1430,12 @@ EOF out=$(run_session_start "$home" "$root" "$fakebin:$BASE_PATH") - assert_contains "$out" "compact backlog listing (tasks-axi unavailable or incompatible; max 80 item(s); indented task bodies omitted)" \ + assert_contains "$out" "compact backlog listing (tasks-axi unavailable or incompatible; done rows omitted;" \ "unavailable tasks-axi did not fall back to compact title-line rendering" assert_contains "$out" "- [ ] compact-startup - Compact startup digest" \ "unavailable tasks-axi fallback omitted a backlog title line" assert_not_contains "$out" "OVERSIZED-BODY-LINE" "unavailable tasks-axi fallback leaked an in-flight task body" + assert_not_contains "$out" "DONE-ROW-LINE" "unavailable tasks-axi fallback listed a done row at startup" pass "unavailable or incompatible tasks-axi falls back to compact manual backlog rendering" } @@ -1292,11 +1512,11 @@ EOF expect_code 0 "$status" "a truncated session start must still exit 0 so the session can open" assert_contains "$out" "SESSION START - $home" "the truncated digest lost the output it had already produced" assert_contains "$out" "LOCK" "the truncated digest lost a stage that had completed" - assert_contains "$out" "STARTUP TRUNCATED" "a truncated session start did not say so" + assert_contains "$out" "STARTUP TRUNCATED - SESSION START HIT ITS" "a truncated session start did not say so" assert_contains "$out" "RUNTIME BOUND" "the truncation banner did not name the bound it hit" assert_contains "$out" 'stopped during the "bootstrap" stage' "the truncation banner did not name the incomplete stage" assert_contains "$out" "RECONCILE these stages" "the truncation banner did not tell the agent what to reconcile" - assert_contains "$out" "wake-queue supervision-instructions context fleet-state next-step" \ + assert_contains "$out" "wake-queue supervision-instructions read-once fleet-state context next-step" \ "the truncation banner did not list every stage that never ran" assert_not_contains "$out" "NEXT STEP" "a truncated digest claimed to have reached its closing reminder" assert_absent "$home/state/.session-start-complete" \ @@ -1359,7 +1579,10 @@ EOF out=$(run_session_start "$home" "$root" "$fakebin:$BASE_PATH") - assert_not_contains "$out" "STARTUP TRUNCATED" "a digest that finished in time reported itself truncated" + # The banner line itself, not the phrase: the read-once contract names the + # banner as the condition that voids it, and that mention is not a banner. + assert_not_contains "$out" "STARTUP TRUNCATED - SESSION START HIT ITS" \ + "a digest that finished in time reported itself truncated" assert_contains "$out" "NEXT STEP" "a digest that finished in time lost its closing reminder" assert_absent "${TMPDIR:-/tmp}/fm-session-start-stage" "the stage breadcrumb leaked a fixed-name file" @@ -1723,6 +1946,7 @@ test_lock_write_failure_read_only_path test_trace_context_effective_state_is_frozen_after_lock test_session_lock_concurrent_single_winner test_output_ordering_diagnostics_lead +test_read_once_contract_is_stated_once_before_its_subject test_herdr_backend_diagnostics_follow_real_session_start test_session_start_relaunches_missing_pi_secondmate test_session_start_preserves_ambiguous_pi_process @@ -1730,11 +1954,13 @@ test_session_start_preserves_transiently_unreadable_tmux test_session_start_preserves_proven_bare_shell_recovery test_session_start_relaunches_herdr_husk_secondmate test_status_tail_bounding +test_status_tail_line_cap test_orphan_status_logs_are_printed test_endpoint_liveness_tmux test_endpoint_liveness_herdr test_composition_invokes_real_scripts test_backlog_compact_tasks_axi_omits_bodies_and_keeps_metadata +test_backlog_queued_bound_discloses_its_remainder test_backlog_compact_manual_backend_skips_indented_bodies test_backlog_compact_tasks_axi_unavailable_uses_manual_fallback test_fleet_digest_empty_fleet diff --git a/tests/fm-shared-captain-inheritance.test.sh b/tests/fm-shared-captain-inheritance.test.sh index 5d543665e74..a9504032d06 100755 --- a/tests/fm-shared-captain-inheritance.test.sh +++ b/tests/fm-shared-captain-inheritance.test.sh @@ -371,7 +371,7 @@ EOF } test_session_start_digest_labels_shared_file_and_read_once_rule() { - local rec w root home _sm fakebin out + local rec w root home _sm fakebin out contract rec=$(new_git_world session-start-label) IFS='|' read -r w root home _sm < "$state/task-long.status" + + FM_STATE_OVERRIDE="$state" "$DRAIN" > "$out" || fail "drain failed on an over-long decision note" + + line=$(grep -F 'task-long' "$out") + case "$line" in + 'task-long [key=api-shape] needs-decision: pick REST or RPC'*' [truncated]') : ;; + *) fail "an over-long decision note was not capped with its lede intact: $line" ;; + esac + longest=${#line} + [ "$longest" -le 219 ] || fail "a capped decision item ran $longest characters past its per-item budget" + + printf 'needs-decision [key=short]: brief enough to keep whole\n' > "$state/task-short.status" + FM_STATE_OVERRIDE="$state" "$DRAIN" > "$out" || fail "drain failed on a short decision note" + grep -F 'task-short [key=short] needs-decision: brief enough to keep whole' "$out" >/dev/null \ + || fail "a decision note already under the cap was altered" + if grep -F 'brief enough to keep whole [truncated]' "$out" >/dev/null; then + fail "a decision note already under the cap was marked truncated" + fi + + pass "an over-long open decision is cut to its per-item budget with the shared truncation marker" +} + test_buried_decision_still_surfaces +test_over_long_decision_note_is_capped_with_a_marker test_explicit_resolution_closes_it test_later_unrelated_terminal_line_does_not_close_it test_no_open_decisions_prints_nothing From 2716928bdb3e1ee2745a93aae04389c890e51aa6 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Thu, 6 Aug 2026 03:16:35 -0700 Subject: [PATCH 2/2] no-mistakes(document): Clarify digest source recovery comments --- bin/fm-line-cap-lib.sh | 6 +++--- bin/fm-session-start.sh | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bin/fm-line-cap-lib.sh b/bin/fm-line-cap-lib.sh index 46d9d83ef64..8be27955740 100644 --- a/bin/fm-line-cap-lib.sh +++ b/bin/fm-line-cap-lib.sh @@ -18,9 +18,9 @@ # The cap counts characters, so a plain-ASCII line - what status lines are in # practice - is bounded to the same number of bytes, and a multibyte character # is never cut in half into an invalid sequence. -# Truncation is always safe here because both callers print the full source -# alongside the bounded view: the session-start digest prints each task's full -# status log path, and OPEN DECISIONS is folded from those same logs. +# Truncation stays recoverable because the session-start digest prints each +# task's full status log path, while every OPEN DECISIONS entry begins with the +# task id that identifies its durable state/.status source. FM_LINE_CAP_DEFAULT=220 FM_LINE_CAP_SUFFIX=' [truncated]' diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index b4d5cd05b26..ef786f9625c 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -39,7 +39,7 @@ # 4. supervision-instructions - the one emitted operating block for the # detected primary harness. # 5. read-once contract - the do-not-re-read contract covering every source -# the two digests below print in full. +# represented by the two digests below. # 6. fleet digest - a compact data/backlog.md identity/metadata listing, # every state/*.meta, a bounded state/*.status tail, # state/.afk, and a cheap per-task endpoint-liveness read: