diff --git a/bin/fm-line-cap-lib.sh b/bin/fm-line-cap-lib.sh new file mode 100644 index 00000000000..8be27955740 --- /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 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]' + +# 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..ef786f9625c 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 +# 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: # 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