From 5ce926cc011ebb608cb715c7b1872c70912f5c94 Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Thu, 8 Oct 2026 18:07:53 -0500 Subject: [PATCH 01/14] fix: retire Lavish listeners whose boards are finished A listener lives as long as its Lavish session, and a choice answer never ends the session, so every board ever armed kept its resident processes. Add fm-procevent-lavish.sh sweep: it retires a listener whose board file or session is gone, or whose board has been idle past FM_BOARD_LISTENER_IDLE_HOURS with no open captain call, and keeps worker-owned, standing, queued-feedback and unacknowledged boards. A board rebuild runs the sweep. --- bin/fm-bearings-board.sh | 9 + bin/fm-procevent-lavish.sh | 238 +++++++++++++++++++++ docs/configuration.md | 10 + docs/verification/process-event-sources.md | 1 + tests/fm-captain-hold-lifecycle.test.sh | 232 ++++++++++++++++++++ 5 files changed, 490 insertions(+) diff --git a/bin/fm-bearings-board.sh b/bin/fm-bearings-board.sh index e4f2fee88e1..2839949ac78 100755 --- a/bin/fm-bearings-board.sh +++ b/bin/fm-bearings-board.sh @@ -46,6 +46,9 @@ # live owner also gets a replacement before build returns, because # `already-armed` is not the same fact as `listening`. # +# LISTENER HYGIENE. A successful build ends by running `fm-procevent-lavish.sh +# sweep`, which owns the rules for retiring the listeners of finished boards. +# # CAPTAIN'S CALL HYGIENE. A decision card is dropped when its work item, PR, or # structured artifact/version subject appears among the payload's own landed # rows, or when `bin/fm-captain-hold.sh open` reports the task is no longer an @@ -469,6 +472,12 @@ command_build() { fi printf 'listening: live\n' fi + # Boards other workers armed and the captain has since left idle keep their + # listeners until someone retires them; a rebuild is the one point this script + # owns where the live set can be trimmed. Best effort, and silent: the + # retirement rules live in bin/fm-procevent-lavish.sh, and a sweep failure + # never fails the board that was just built. + "$SCRIPT_DIR/fm-procevent-lavish.sh" sweep >/dev/null 2>&1 || true } case "${1-}" in diff --git a/bin/fm-procevent-lavish.sh b/bin/fm-procevent-lavish.sh index e232e38d16c..8e1755c7503 100755 --- a/bin/fm-procevent-lavish.sh +++ b/bin/fm-procevent-lavish.sh @@ -11,6 +11,7 @@ # fm-procevent-lavish.sh read # fm-procevent-lavish.sh source-id # fm-procevent-lavish.sh retire +# fm-procevent-lavish.sh sweep [--dry-run] # fm-procevent-lavish.sh poll [--agent-reply-file ] # fm-procevent-lavish.sh deliver-reply poll --agent-reply-file # fm-procevent-lavish.sh check @@ -35,6 +36,36 @@ # Captain-supplied body lines are visibly prefixed so they cannot # forge structural labels. Empty message and annotation sections # are reported explicitly. +# sweep Retire this home's Lavish listeners whose boards are finished, and +# print one `retired:`, `kept:` or (with --dry-run) `would-retire:` +# line per registration plus a `sweep:` total. A listener lives as +# long as its Lavish session, and the captain answers a board with +# choice forms that never end the session, so without a sweep every +# board ever armed keeps its resident processes and its owner guard +# until the session is ended by hand. A board is FINISHED, in this +# order, when: +# 1. its artifact file is gone, or Lavish holds no session or an +# ended session for it, so the poll can never produce a result +# (the exact case a task-owned source may also retire on); or +# 2. it has been idle for FM_BOARD_LISTENER_IDLE_HOURS (default 48, +# whole hours 1..8760) - no change to the artifact, the Lavish +# session or a captured round - and carries no open captain call. +# A board is KEPT while any of these holds: it is the standing +# Bearings board; Lavish reports queued feedback for it; a captured +# round of it is unacknowledged; it is owned by a worker task (only +# rule 1 retires those); or one of its `data-lavish-question` card +# keys names a task `bin/fm-captain-hold.sh open` reports as an open +# captain call (exit 0) or cannot establish (exit 2). A card key that +# names no task in this home's backlog (exit 3) or a closed one +# (exit 1) is not an open call: the durable record of a captain call +# is its held task, and the standing board lists every held call. +# A call held in another home's backlog is invisible here, which is +# why dormancy is required as well as the absence of an open hold. +# Retiring stops the listener and releases its claim through the +# generic `retire`; it never ends the Lavish session, so the board +# stays readable and `arm` brings the listener back. Cost: one perl +# pass over every registration, plus one `fm-captain-hold.sh open` +# per distinct card key of an otherwise-finished board. # poll The registered listener command `arm` publishes, not a command to # run in a conversational turn. It runs the published blocking poll # and prints its response verbatim, absorbing only the one exact @@ -1008,9 +1039,216 @@ cmd_read() { ' "$file" "$lifecycle" "$session_ended" } +# --- sweep: retire listeners whose boards are finished ----------------------- +# The header owns the rules. This section only reads facts and applies them; the +# generic `retire` in bin/fm-procevent.sh stays the one place a registration and +# its runner are actually removed. +BOARD_IDLE_HOURS_DEFAULT=48 +BOARD_IDLE_HOURS_MAX=8760 + +board_idle_seconds() { + local value=${FM_BOARD_LISTENER_IDLE_HOURS-$BOARD_IDLE_HOURS_DEFAULT} + case "$value" in ''|*[!0-9]*) return 1 ;; esac + value=$((10#$value)) + [ "$value" -ge 1 ] && [ "$value" -le "$BOARD_IDLE_HOURS_MAX" ] || return 1 + printf '%s\n' $((value * 3600)) +} + +# One perl pass for every registration: TAB lines on +# stdin, one fact row per line out: +# id, artifact, real path ("-" when the file is gone), sessions (count, or +# "unknown"), status, queued prompts, last-activity epoch, card keys. +# `sessions` is "unknown" when Lavish's store cannot be read, so an unreadable +# store keeps every board instead of reading as "no session". +sweep_facts() { # + perl -MJSON::PP -MCwd=realpath -MEncode=decode,FB_DEFAULT -MTime::Local=timegm -e ' + use strict; use warnings; + my ($state, $store) = @ARGV; + my (%by, $store_ok); + if (open my $sf, "<", $store) { + local $/; + my $doc = eval { decode_json(<$sf>) }; + if (ref($doc) eq "HASH" && ref($doc->{sessions}) eq "HASH") { + $store_ok = 1; + for my $s (values %{$doc->{sessions}}) { + push @{$by{$s->{file}}}, $s if ref($s) eq "HASH" && defined $s->{file} && !ref($s->{file}); + } + } + } + my $newest = sub { my $m = 0; for (@_) { my $t = (stat $_)[9]; $m = $t if defined $t && $t > $m } $m }; + while (my $line = ) { + chomp $line; + my ($id, $art) = split /\t/, $line, 2; + next unless defined $art; + my $real = -f $art ? realpath($art) : undef; + my ($sessions, $status, $pending, @keys) = ("unknown", "", 0); + my $activity = $newest->(glob("$state/procevent-inbox/$id.*")); + if (defined $real) { + my $t = $newest->($real); + $activity = $t if $t > $activity; + if (open my $in, "<:raw", $real) { + local $/; + my $html = <$in>; + close $in; + my %seen; + while ($html =~ /\bdata-lavish-question\s*=\s*(?:"([^"]*)"|\x27([^\x27]*)\x27)/g) { + my $k = $1 // $2; + push @keys, $k if $k =~ /\A[A-Za-z0-9._-]{1,128}\z/ && !$seen{$k}++; + } + } + } + if ($store_ok) { + my $list = defined $real ? ($by{decode("UTF-8", $real, FB_DEFAULT)} // []) : []; + $sessions = scalar @$list; + if (@$list == 1) { + my $s = $list->[0]; + $status = $s->{status} // ""; + $pending = $s->{pending_prompts} // 0; + $pending = 0 unless $pending =~ /\A[0-9]+\z/; + if (($s->{updated_at} // "") =~ /\A(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)/) { + my $t = eval { timegm($6, $5, $4, $3, $2 - 1, $1) } // 0; + $activity = $t if $t > $activity; + } + } + } + print join("\t", $id, $art, defined $real ? $real : "-", $sessions, $status || "-", $pending, $activity, join(",", @keys) || "-"), "\n"; + } + ' "$1" "$2" +} + +cmd_sweep() { + local dry=0 idle state reg store now rec id adapter kind artifact standing line n in_argv argv_poll + local pending_ids facts real sessions status queued activity keys reason verdict key rc out + local open_cache=$'\n' retired=0 kept=0 idx saved_ifs + local -a ids=() kinds=() + case "${1-}" in + '') ;; + --dry-run) dry=1; shift ;; + *) usage ;; + esac + [ "$#" -eq 0 ] || usage + idle=$(board_idle_seconds) \ + || die "FM_BOARD_LISTENER_IDLE_HOURS must be whole hours from 1 to $BOARD_IDLE_HOURS_MAX" + state=${FM_STATE_OVERRIDE:-$FM_HOME/state} + reg=$(fm_procevent_registry_dir "$state") + store=${LAVISH_AXI_STATE_DIR:-$HOME/.lavish-axi}/state.json + now=$(date +%s) + standing=$(perl -MCwd=realpath -e '$p = realpath($ARGV[0]); print $p if defined $p' \ + "$FM_HOME/.lavish/bearings-board.html" 2>/dev/null || true) + pending_ids=$(fm_procevent_pending "$state" | sed -n 's|.*/\([^/.]*\)\.[0-9][0-9]*\.result$|\1|p') + + # Read every registration first so one perl pass can answer for all of them. + facts='' + for rec in "$reg"/lavish-*.source; do + [ -f "$rec" ] && [ ! -L "$rec" ] || continue + id=${rec##*/}; id=${id%.source} + fm_procevent_source_id_valid "$id" || continue + adapter=''; kind=plain; argv_poll=''; artifact=''; n=0; in_argv=0 + while IFS= read -r line || [ -n "$line" ]; do + if [ "$in_argv" = 1 ]; then + n=$((n + 1)) + [ "$n" -ne 2 ] || argv_poll=$line + [ "$n" -ne 3 ] || artifact=$line + continue + fi + case "$line" in + adapter=*) adapter=${line#adapter=} ;; + kind=*) kind=${line#kind=} ;; + argv:) in_argv=1 ;; + esac + done < "$rec" + [ "$adapter" = lavish ] && [ "$argv_poll" = poll ] && [ -n "$artifact" ] || continue + ids+=("$id"); kinds+=("$kind") + facts="$facts$id"$'\t'"$artifact"$'\n' + done + if [ "${#ids[@]}" -eq 0 ]; then + printf 'sweep: retired=0 kept=0\n' + return 0 + fi + facts=$(printf '%s' "$facts" | sweep_facts "$state" "$store") || die "cannot read the Lavish board facts" + + while IFS=$'\t' read -r id artifact real sessions status queued activity keys; do + [ -n "$id" ] || continue + kind=plain + for idx in "${!ids[@]}"; do + [ "${ids[$idx]}" = "$id" ] && { kind=${kinds[$idx]}; break; } + done + verdict=keep + if [ "$real" = - ]; then + verdict=retire; reason='the board file is gone' + elif [ "$sessions" = 0 ]; then + verdict=retire; reason='Lavish holds no session for the board' + elif [ "$status" = ended ]; then + verdict=retire; reason='the Lavish session has ended' + elif [ -n "$standing" ] && [ "$real" = "$standing" ]; then + reason='the standing Bearings board' + elif [ "$sessions" = unknown ]; then + reason='the Lavish session store cannot be read' + elif [ "$status" = feedback ] || [ "$queued" -gt 0 ]; then + reason='Lavish holds queued feedback for the board' + elif [ "$kind" = task-owned ]; then + reason='owned by a worker task' + elif [ $((now - activity)) -lt "$idle" ]; then + reason="active within the last $((idle / 3600)) hours" + else + verdict=retire + reason="idle for $(((now - activity) / 3600)) hours with no open captain call" + saved_ifs=$IFS + IFS=, + for key in $keys; do + [ "$key" != - ] || continue + case "$open_cache" in + *$'\n'"$key="[0-3]$'\n'*) ;; + *) + FM_HOME="$FM_HOME" "$SCRIPT_DIR/fm-captain-hold.sh" open "$key" --distinguish-absent \ + >/dev/null 2>&1 &1 ` brings the listener back. + ### Crew-hosted Lavish review boards **Arm and confirm a listener** diff --git a/docs/verification/process-event-sources.md b/docs/verification/process-event-sources.md index 518865cae6c..41ba0ea5581 100644 --- a/docs/verification/process-event-sources.md +++ b/docs/verification/process-event-sources.md @@ -92,6 +92,7 @@ Exercised by `tests/fm-procevent.test.sh` against a fake blocking source whose c | adapter-owned application of a captured result | a remote-secondmate reply captured through the real relay in an isolated home reaches that secondmate's local status mirror, settles its correlated pending-reply expectation, re-arms the next cursor-anchored source, and is acknowledged, with no handler step or duplicate `check` wake; its new mirrored bytes remain visible to the watcher's signal gate, while exact source-line replay identity keeps a commit-failure retry or cursor-loss whole-log recapture from duplicating a decision when document availability changes, and a recapture that adds no bytes is acknowledged quietly; for an already-escalated request, the same path closes the exact decision so the open-decision fold clears and remains clear; a capture whose adapter application fails because local storage for a referenced remote document is obstructed is left unacknowledged and receives the fallback `check` wake, and the handler's own `handle` still applies it in full after storage recovers; a document offered through a structured `report=` pointer that the reader cannot deliver fails open, mirroring its line with the original pointer, advancing the cursor, and appending one unkeyed note with the reader's own reason that opens no decision, while a path merely mentioned in prose is never fetched and the reported announce-then-explain incident leaves no standing decision yet still delivers its report through the later structured offer | | generic built-in keyed-answer feed | `tests/fm-captain-hold-lifecycle.test.sh` drives a bound built-in source through the real runner with a fixture adapter that only prints keyed lines, proving any bound built-in channel reaches the one keyed-answer intake: named captain-held tasks close at capture time, a card-declared release mode frees held work, keys naming no captain-held task skip, freeform prose forges nothing, matching answer-and-mode replays are idempotent while mode mismatches refuse, an unbound source closes nothing, and capture remains independent of the handler wake. | | structured reconcile feed | `tests/fm-captain-hold-lifecycle.test.sh` covers the [captured-choice and reconcile contract](../captain-hold-lifecycle.md#how-a-board-selection-creates-a-request); its [reconcile coverage index](../captain-hold-lifecycle.md#reconcile-coverage) names the focused legacy-format and last-choice regressions. | +| finished-board sweep | `test_sweep_retires_only_finished_board_listeners` in `tests/fm-captain-hold-lifecycle.test.sh` registers boards with a missing file, an ended session, no session, an idle board with no or only closed captain calls, an idle board with an open held captain call, a recently touched board, queued feedback, a worker-owned board, the standing Bearings board and an unread captured answer; `bin/fm-procevent-lavish.sh sweep` retires exactly the first five, keeps the rest, leaves the Lavish session store byte-identical, is idempotent, keeps every board while the session store is unreadable, and refuses an unusable idle window; `test_sweep_stops_a_live_listener_of_a_finished_board` proves the resident poll of a finished board's armed listener is gone after the sweep | | adapter-owned silence verdict | an ordinary firstmate-owned Lavish source driven against a stand-in poll that returns an empty ended session captures its result, records it durably handled, appends no wake, and stays silent through a later `reconcile` that would otherwise republish it, while still retiring its ended source; the same real path with a `Send & End` response carrying the captain's choice still publishes its `check` wake and is left unacknowledged for the handler | | worker-owned Lavish rounds | one three-round fixture arms a board for an identity-matched task endpoint, delivers nonterminal and terminal captures directly to that task's steering inbox without a firstmate `check` wake, acknowledges each nonterminal round through a successful re-arm, rings the owner's doorbell once when the capture writes a fresh inbox note and never re-rings or resurrects a note the owner has filed into `handled/` across repeated reconciles, refuses a second armer and every early retirement, and concludes the terminal round through `handled` without another poll; focused fixtures also pin synchronous reply acceptance before modern arm returns, failed reply refusal before registration, refused-arm reply isolation, direct poll reply ordering, the legacy poll-with-reply fallback, failed re-arm rollback, one legacy reply post across transient retries, unreachable-owner refusal, interrupted conclusion recovery, and repeat acknowledgement isolation | | Lavish handled-status classification | an executable fixture table pins exact `feedback`, `ended`, `waiting`, and `browser_disconnected` mappings, including `browser_disconnected` to `disconnected`; the same suite proves that status is nonterminal and receives a zero-answer silence verdict | diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index 3d2b0e9d389..33a40ef1d1f 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -4803,7 +4803,239 @@ test_internal_retention_preserves_active_drop_provenance() ( pass "internal retention and read-only admission preserve active captain drop classification" ) +# A Lavish listener lives as long as its Lavish session, and a captain answers a +# board with choice forms that never end the session, so every board ever armed +# kept its resident processes until someone retired it by hand: 25 were live on +# a host at load 102, most of them for boards eight days idle. The sweep retires +# a listener only when its board is provably finished, and keeps everything that +# could still carry a captain answer. Each scenario below is a board the sweep +# must keep or retire for a different reason; the live-listener case proves the +# retirement actually stops the resident poll, not just the registration. +sweep_session() { # + mkdir -p "$1" + perl -MJSON::PP -MCwd=realpath -MDigest::SHA=sha256_hex -e ' + my ($dir, $art, $status, $queued, $updated) = @ARGV; + my $path = "$dir/state.json"; + my $doc = { sessions => {} }; + if (-f $path) { open my $in, "<", $path or die $!; local $/; $doc = decode_json(<$in>); } + my $real = realpath($art) // die "missing artifact"; + my $key = substr(sha256_hex($real), 0, 16); + my $session = { + key => $key, file => $real, url => "http://127.0.0.1:14387/session/$key", + status => $status, pending_prompts => $queued + 0, + }; + $session->{updated_at} = $updated unless $updated eq "-"; + $doc->{sessions}{$key} = $session; + open my $out, ">", $path or die $!; + print $out encode_json($doc); + ' "$@" +} + +sweep_board() { # : write one board, print its path + local home=$1 name=$2 age=$3 file key + shift 3 + mkdir -p "$home/boards" + file="$home/boards/$name.html" + { + printf '

%s

\n' "$name" + for key in "$@"; do + printf '
\n' "$key" + done + } > "$file" + [ "$age" != old ] || touch -t 200001010000 "$file" + printf '%s\n' "$file" +} + +sweep_register() { # : register its listener without starting it, print the source id + local home=$1 artifact=$2 sid + sid=$(run_lavish "$home" source-id "$artifact") || fail "could not derive a source id" + run_procevent "$home" register lavish "$sid" -- \ + "$ROOT/bin/fm-procevent-lavish.sh" poll "$artifact" >/dev/null \ + || fail "could not register the board listener" + printf '%s\n' "$sid" +} + +test_sweep_retires_only_finished_board_listeners() { + local home store out rc list before after unreadable unreadable_id + local gone ended nosession dormant closed openheld fresh queued owned standing pending + local gone_id ended_id nosession_id dormant_id closed_id openheld_id fresh_id queued_id owned_id standing_id pending_id + home=$(make_home board-sweep) + fm_test_track_procevent_home "$home" "$home/procevent-claims" + store="$home/lavish-state" + fm_fake_exit0 "$home/fakebin" lavish-axi + + run_captain "$home" hold sweep-open-call --title "Choose the sweep route" \ + --reason "captain sweep route pending" --repo sample >/dev/null \ + || fail "could not hold the open captain call" + run_captain "$home" hold sweep-closed-call --title "Choose the closed route" \ + --reason "captain closed route pending" --repo sample >/dev/null \ + || fail "could not hold the call that is answered below" + printf 'north\n' > "$home/answer.txt" + run_captain "$home" answer sweep-closed-call --decision-file "$home/answer.txt" >/dev/null \ + || fail "could not answer the call" + + gone=$(sweep_board "$home" gone old) + ended=$(sweep_board "$home" ended new) + nosession=$(sweep_board "$home" nosession new) + dormant=$(sweep_board "$home" dormant old sweep-absent-card) + closed=$(sweep_board "$home" closed old sweep-closed-call) + openheld=$(sweep_board "$home" openheld old sweep-open-call) + fresh=$(sweep_board "$home" fresh new) + queued=$(sweep_board "$home" queued old) + owned=$(sweep_board "$home" owned old) + standing="$home/.lavish/bearings-board.html" + mkdir -p "$home/.lavish" + printf '

standing

\n' > "$standing" + touch -t 200001010000 "$standing" + pending=$(sweep_board "$home" pending old) + + gone_id=$(sweep_register "$home" "$gone") + ended_id=$(sweep_register "$home" "$ended") + nosession_id=$(sweep_register "$home" "$nosession") + dormant_id=$(sweep_register "$home" "$dormant") + closed_id=$(sweep_register "$home" "$closed") + openheld_id=$(sweep_register "$home" "$openheld") + fresh_id=$(sweep_register "$home" "$fresh") + queued_id=$(sweep_register "$home" "$queued") + standing_id=$(sweep_register "$home" "$standing") + pending_id=$(sweep_register "$home" "$pending") + owned_id=$(run_lavish "$home" source-id "$owned") || fail "could not derive the owned source id" + printf 'window=fmtest:fm-sweep-owner\nworktree=%s/worktree-sweep-owner\nproject=fmtest\n' "$home" \ + > "$home/state/sweep-owner.meta" + run_procevent "$home" register-task lavish "$owned_id" sweep-owner -- \ + "$ROOT/bin/fm-procevent-lavish.sh" poll "$owned" >/dev/null \ + || fail "could not register the worker-owned board" + + sweep_session "$store" "$gone" open 0 2000-01-01T00:00:00.000Z + sweep_session "$store" "$ended" ended 0 - + sweep_session "$store" "$dormant" open 0 2000-01-01T00:00:00.000Z + sweep_session "$store" "$closed" open 0 2000-01-01T00:00:00.000Z + sweep_session "$store" "$openheld" open 0 2000-01-01T00:00:00.000Z + sweep_session "$store" "$fresh" open 0 - + sweep_session "$store" "$queued" open 2 2000-01-01T00:00:00.000Z + sweep_session "$store" "$owned" open 0 2000-01-01T00:00:00.000Z + sweep_session "$store" "$standing" open 0 2000-01-01T00:00:00.000Z + sweep_session "$store" "$pending" open 0 2000-01-01T00:00:00.000Z + mkdir -p "$home/state/procevent-inbox" + printf 'session:\n status: feedback\n' > "$home/state/procevent-inbox/$pending_id.1.result" + printf 'lavish\n' > "$home/state/procevent-inbox/$pending_id.1.adapter" + touch -t 200001010000 "$home/state/procevent-inbox/$pending_id.1.result" \ + "$home/state/procevent-inbox/$pending_id.1.adapter" + rm -f "$gone" + before=$(cksum < "$store/state.json") + + # A dry run reports the verdicts and changes nothing. + out=$(LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep --dry-run) \ + || fail "the dry-run sweep failed: $out" + assert_contains "$out" "would-retire: $gone_id" "the dry run missed a board whose file is gone" + assert_contains "$out" "sweep: would-retire=5 kept=6" "the dry run counted the wrong verdicts: $out" + list=$(run_procevent "$home" list) + assert_contains "$list" "$dormant_id" "a dry run retired a registration" + + out=$(LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep) \ + || fail "the sweep failed: $out" + assert_contains "$out" "retired: $gone_id" "a board whose file is gone kept its listener" + assert_contains "$out" "retired: $ended_id" "an ended Lavish session kept its listener" + assert_contains "$out" "retired: $nosession_id" "a board Lavish has no session for kept its listener" + assert_contains "$out" "retired: $dormant_id" "an idle board with no open call kept its listener" + assert_contains "$out" "retired: $closed_id" "an idle board whose only call is answered kept its listener" + assert_contains "$out" "kept: $openheld_id" "an idle board with an open captain call lost its listener" + assert_contains "$out" "kept: $fresh_id" "a recently touched board lost its listener" + assert_contains "$out" "kept: $queued_id" "a board holding queued feedback lost its listener" + assert_contains "$out" "kept: $owned_id" "a worker-owned board lost its listener to a dormancy rule" + assert_contains "$out" "kept: $standing_id" "the standing Bearings board lost its listener" + assert_contains "$out" "kept: $pending_id" "a board with an unread captured answer lost its listener" + assert_contains "$out" "sweep: retired=5 kept=6" "the sweep counted the wrong verdicts: $out" + + list=$(run_procevent "$home" list) + for after in "$gone_id" "$ended_id" "$nosession_id" "$dormant_id" "$closed_id"; do + assert_not_contains "$list" "$after" "a retired board is still registered" + done + for after in "$openheld_id" "$fresh_id" "$queued_id" "$owned_id" "$standing_id" "$pending_id"; do + assert_contains "$list" "$after" "a kept board lost its registration" + done + [ "$before" = "$(cksum < "$store/state.json")" ] \ + || fail "the sweep changed the Lavish session store; retiring a listener must not end a board" + + out=$(LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep) || fail "the repeat sweep failed: $out" + assert_contains "$out" "sweep: retired=0 kept=6" "a repeat sweep retired more than the first" + + # A store the sweep cannot read proves nothing about any board's session, so + # a board that would otherwise be retired keeps its listener. + unreadable=$(sweep_board "$home" unreadable old) + unreadable_id=$(sweep_register "$home" "$unreadable") + sweep_session "$store" "$unreadable" open 0 2000-01-01T00:00:00.000Z + cp "$store/state.json" "$home/state-good.json" + printf 'not json\n' > "$store/state.json" + out=$(LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep) || fail "the unreadable-store sweep failed: $out" + assert_contains "$out" "kept: $unreadable_id" "an unreadable Lavish store retired a listener" + assert_contains "$out" "sweep: retired=0 kept=7" "an unreadable Lavish store changed the verdicts: $out" + cp "$home/state-good.json" "$store/state.json" + out=$(LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep) || fail "the restored-store sweep failed: $out" + assert_contains "$out" "retired: $unreadable_id" "the same board was not retired once its session was readable" + + set +e + out=$(FM_BOARD_LISTENER_IDLE_HOURS=0 LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep 2>&1) + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "an unusable idle window was accepted" + assert_contains "$out" "FM_BOARD_LISTENER_IDLE_HOURS" "the refusal did not name the setting" + pass "the sweep retires finished boards and keeps every board that could still carry an answer" +} + +# The point of the sweep is the resident processes: a live listener's board is +# finished, and after the sweep neither its runner nor its blocked poll remains. +# Count of resident `lavish-axi poll` processes serving the live-idle board. +live_idle_polls() { + pgrep -f "lavish-axi poll .*board-sweep-live/boards/live-idle.html" | wc -l | tr -d ' ' +} + +test_sweep_stops_a_live_listener_of_a_finished_board() { + local home store artifact sid out list tries + home=$(make_home board-sweep-live) + fm_test_track_procevent_home "$home" "$home/procevent-claims" + store="$home/lavish-state" + artifact=$(sweep_board "$home" live-idle old) + cat > "$home/fakebin/lavish-axi" <<'SH' +#!/usr/bin/env bash +# A poll that blocks until it is killed; bounded so an escaped stub cannot linger. +for _ in $(seq 1 1800); do sleep 0.1; done +SH + chmod +x "$home/fakebin/lavish-axi" + sweep_session "$store" "$artifact" open 0 2000-01-01T00:00:00.000Z + sid=$(run_lavish "$home" source-id "$artifact") || fail "could not derive the live source id" + out=$(FM_PROCEVENT_LAUNCH_CONFIRM_SECONDS=60 LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" arm "$artifact") \ + || fail "could not arm the live listener: $out" + list=$(run_procevent "$home" list) + assert_contains "$list" "$sid" "the armed listener is not registered" + assert_contains "$list" "live" "the armed listener is not running" + # Armed means claimed; the poll process follows the claim, so wait for it. + tries=0 + while [ "$(live_idle_polls)" -eq 0 ] \ + && [ "$tries" -lt 300 ]; do + tries=$((tries + 1)) + sleep 0.1 + done + [ "$(live_idle_polls)" -ge 1 ] \ + || fail "the armed listener has no resident poll to retire" + + out=$(LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep) || fail "the sweep failed: $out" + assert_contains "$out" "retired: $sid" "the idle board's live listener was kept" + tries=0 + while [ "$(live_idle_polls)" -ne 0 ] && [ "$tries" -lt 100 ]; do + tries=$((tries + 1)) + sleep 0.1 + done + [ "$(live_idle_polls)" -eq 0 ] \ + || fail "the retired listener's poll is still resident" + list=$(run_procevent "$home" list) + assert_not_contains "$list" "$sid" "the retired listener is still registered" + pass "the sweep stops the resident poll of a finished board's live listener" +} + tests=( +test_sweep_retires_only_finished_board_listeners +test_sweep_stops_a_live_listener_of_a_finished_board test_hold_reason_round_trips_awkward_characters test_hold_origins_precede_backend_holds test_historical_self_inventory_has_workable_repair From d647a73601e22844d7913d71534f92ed12334b13 Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Fri, 9 Oct 2026 02:39:59 -0500 Subject: [PATCH 02/14] no-mistakes(review): Preserve sweep keep guards and fail-closed captain card checks --- bin/fm-procevent-lavish.sh | 144 +++++++++++++-------- docs/configuration.md | 5 + docs/verification/process-event-sources.md | 2 +- tests/fm-captain-hold-lifecycle.test.sh | 51 +++++++- 4 files changed, 140 insertions(+), 62 deletions(-) diff --git a/bin/fm-procevent-lavish.sh b/bin/fm-procevent-lavish.sh index 8e1755c7503..8d132b6dc87 100755 --- a/bin/fm-procevent-lavish.sh +++ b/bin/fm-procevent-lavish.sh @@ -42,25 +42,22 @@ # long as its Lavish session, and the captain answers a board with # choice forms that never end the session, so without a sweep every # board ever armed keeps its resident processes and its owner guard -# until the session is ended by hand. A board is FINISHED, in this -# order, when: -# 1. its artifact file is gone, or Lavish holds no session or an -# ended session for it, so the poll can never produce a result -# (the exact case a task-owned source may also retire on); or -# 2. it has been idle for FM_BOARD_LISTENER_IDLE_HOURS (default 48, -# whole hours 1..8760) - no change to the artifact, the Lavish -# session or a captured round - and carries no open captain call. -# A board is KEPT while any of these holds: it is the standing -# Bearings board; Lavish reports queued feedback for it; a captured -# round of it is unacknowledged; it is owned by a worker task (only -# rule 1 retires those); or one of its `data-lavish-question` card -# keys names a task `bin/fm-captain-hold.sh open` reports as an open -# captain call (exit 0) or cannot establish (exit 2). A card key that -# names no task in this home's backlog (exit 3) or a closed one -# (exit 1) is not an open call: the durable record of a captain call -# is its held task, and the standing board lists every held call. -# A call held in another home's backlog is invisible here, which is -# why dormancy is required as well as the absence of an open hold. +# until the session is ended by hand. Keep guards run first: the +# standing Bearings board, an unreadable Lavish store, queued +# feedback or status feedback, then worker-task ownership. +# Otherwise a board is FINISHED when its artifact file is gone, or +# when Lavish holds no session or an ended session for it and every +# card key is known not to name an open captain call. Other boards +# must also be idle for FM_BOARD_LISTENER_IDLE_HOURS (default 48, +# whole hours 1..8760): no change to the artifact, the Lavish session +# or a captured round. Unreadable card keys keep a present board. +# `bin/fm-captain-hold.sh open` exit 0 (open), exit 2 (cannot tell), +# or any unexpected exit keeps the board; exit 1 (closed) and exit 3 +# (absent from this home's backlog) permit retirement. The durable +# record of a captain call is its held task, and the standing board +# lists every held call. A call held in another home's backlog is +# invisible here, so dormancy is the safeguard for live sessions. +# An unacknowledged captured round blocks every retirement. # Retiring stops the listener and releases its claim through the # generic `retire`; it never ends the Lavish session, so the board # stays readable and `arm` brings the listener back. Cost: one perl @@ -1076,25 +1073,50 @@ sweep_facts() { # } } my $newest = sub { my $m = 0; for (@_) { my $t = (stat $_)[9]; $m = $t if defined $t && $t > $m } $m }; + sub unescape { + my ($v) = @_; + $v =~ s/&#[xX]([0-9a-fA-F]+);/chr(hex($1))/ge; + $v =~ s/&#([0-9]+);/chr($1)/ge; + $v =~ s/"/"/g; + $v =~ s/'/\x27/g; + $v =~ s/<//g; + $v =~ s/&/&/g; + return $v; + } while (my $line = ) { chomp $line; my ($id, $art) = split /\t/, $line, 2; next unless defined $art; my $real = -f $art ? realpath($art) : undef; my ($sessions, $status, $pending, @keys) = ("unknown", "", 0); + my $keys_unknown = 0; my $activity = $newest->(glob("$state/procevent-inbox/$id.*")); if (defined $real) { my $t = $newest->($real); $activity = $t if $t > $activity; if (open my $in, "<:raw", $real) { local $/; + local $! = 0; my $html = <$in>; - close $in; - my %seen; - while ($html =~ /\bdata-lavish-question\s*=\s*(?:"([^"]*)"|\x27([^\x27]*)\x27)/g) { - my $k = $1 // $2; - push @keys, $k if $k =~ /\A[A-Za-z0-9._-]{1,128}\z/ && !$seen{$k}++; + my $read_ok = !$!; + my $close_ok = close $in; + if ($read_ok && $close_ok) { + $html //= ""; + my %seen; + while ($html =~ /\bdata-lavish-question\s*=\s*(?:"([^"]*)"|\x27([^\x27]*)\x27|([^\s"\x27=<>`]+))/gi) { + my $k = unescape($1 // $2 // $3); + if ($k !~ /\A[A-Za-z0-9._-]{1,128}\z/) { + $keys_unknown = 1; + last; + } + push @keys, $k unless $seen{$k}++; + } + } else { + $keys_unknown = 1; } + } else { + $keys_unknown = 1; } } if ($store_ok) { @@ -1111,15 +1133,40 @@ sweep_facts() { # } } } - print join("\t", $id, $art, defined $real ? $real : "-", $sessions, $status || "-", $pending, $activity, join(",", @keys) || "-"), "\n"; + print join("\t", $id, $art, defined $real ? $real : "-", $sessions, $status || "-", $pending, $activity, $keys_unknown ? "?" : (join(",", @keys) || "-")), "\n"; } ' "$1" "$2" } +sweep_cards_closed() { + local keys=$1 key rc IFS=, + if [ "$keys" = '?' ]; then + reason="the board's card keys cannot be read" + return 1 + fi + for key in $keys; do + [ "$key" != - ] || continue + case "$open_cache" in + *$'\n'"$key="[0-3]$'\n'*) ;; + *) + FM_HOME="$FM_HOME" "$SCRIPT_DIR/fm-captain-hold.sh" open "$key" --distinguish-absent \ + >/dev/null 2>&1 /dev/null 2>&1 /dev/null \ || fail "could not register the worker-owned board" + for session in ended nosession idle; do + for syntax in quoted single unquoted uppercase hex decimal invalid unreadable; do + board=$(sweep_board "$home" "$session-$syntax" new) + case "$syntax" in + quoted) printf '
\n' > "$board" ;; + single) printf "
\n" > "$board" ;; + unquoted) printf '
\n' > "$board" ;; + uppercase) printf '
\n' > "$board" ;; + hex) printf '
\n' > "$board" ;; + decimal) printf '
\n' > "$board" ;; + invalid) printf '
\n' > "$board" ;; + esac + [ "$session" != idle ] || touch -t 200001010000 "$board" + sid=$(sweep_register "$home" "$board") + protected_ids+=("$sid") + case "$session" in + ended) sweep_session "$store" "$board" ended 0 - ;; + idle) sweep_session "$store" "$board" open 0 2000-01-01T00:00:00.000Z ;; + esac + [ "$syntax" != unreadable ] || chmod 000 "$board" + done + done + kept_count=$((6 + ${#protected_ids[@]})) + sweep_session "$store" "$gone" open 0 2000-01-01T00:00:00.000Z sweep_session "$store" "$ended" ended 0 - sweep_session "$store" "$dormant" open 0 2000-01-01T00:00:00.000Z @@ -4928,7 +4954,7 @@ test_sweep_retires_only_finished_board_listeners() { out=$(LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep --dry-run) \ || fail "the dry-run sweep failed: $out" assert_contains "$out" "would-retire: $gone_id" "the dry run missed a board whose file is gone" - assert_contains "$out" "sweep: would-retire=5 kept=6" "the dry run counted the wrong verdicts: $out" + assert_contains "$out" "sweep: would-retire=5 kept=$kept_count" "the dry run counted the wrong verdicts: $out" list=$(run_procevent "$home" list) assert_contains "$list" "$dormant_id" "a dry run retired a registration" @@ -4945,34 +4971,49 @@ test_sweep_retires_only_finished_board_listeners() { assert_contains "$out" "kept: $owned_id" "a worker-owned board lost its listener to a dormancy rule" assert_contains "$out" "kept: $standing_id" "the standing Bearings board lost its listener" assert_contains "$out" "kept: $pending_id" "a board with an unread captured answer lost its listener" - assert_contains "$out" "sweep: retired=5 kept=6" "the sweep counted the wrong verdicts: $out" + for after in "${protected_ids[@]}"; do + assert_contains "$out" "kept: $after" "a board with an open or unreadable captain card lost its listener: $out" + done + assert_contains "$out" "sweep: retired=5 kept=$kept_count" "the sweep counted the wrong verdicts: $out" list=$(run_procevent "$home" list) for after in "$gone_id" "$ended_id" "$nosession_id" "$dormant_id" "$closed_id"; do assert_not_contains "$list" "$after" "a retired board is still registered" done - for after in "$openheld_id" "$fresh_id" "$queued_id" "$owned_id" "$standing_id" "$pending_id"; do + for after in "$openheld_id" "$fresh_id" "$queued_id" "$owned_id" "$standing_id" "$pending_id" "${protected_ids[@]}"; do assert_contains "$list" "$after" "a kept board lost its registration" done [ "$before" = "$(cksum < "$store/state.json")" ] \ || fail "the sweep changed the Lavish session store; retiring a listener must not end a board" + sweep_session "$store" "$owned" ended 0 - + sweep_session "$store" "$standing" ended 0 - + sweep_session "$store" "$queued" ended 2 - out=$(LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep) || fail "the repeat sweep failed: $out" - assert_contains "$out" "sweep: retired=0 kept=6" "a repeat sweep retired more than the first" + assert_contains "$out" "kept: $owned_id" "a worker-owned board with an ended session lost its listener" + assert_contains "$out" "kept: $standing_id" "the standing board with an ended session lost its listener" + assert_contains "$out" "kept: $queued_id" "an ended board with queued feedback lost its listener" + assert_contains "$out" "sweep: retired=0 kept=$kept_count" "a repeat sweep retired more than the first" # A store the sweep cannot read proves nothing about any board's session, so # a board that would otherwise be retired keeps its listener. unreadable=$(sweep_board "$home" unreadable old) unreadable_id=$(sweep_register "$home" "$unreadable") sweep_session "$store" "$unreadable" open 0 2000-01-01T00:00:00.000Z + gone=$(sweep_board "$home" gone-store-unreadable old) + gone_id=$(sweep_register "$home" "$gone") + sweep_session "$store" "$gone" open 0 2000-01-01T00:00:00.000Z + rm -f "$gone" cp "$store/state.json" "$home/state-good.json" printf 'not json\n' > "$store/state.json" out=$(LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep) || fail "the unreadable-store sweep failed: $out" assert_contains "$out" "kept: $unreadable_id" "an unreadable Lavish store retired a listener" - assert_contains "$out" "sweep: retired=0 kept=7" "an unreadable Lavish store changed the verdicts: $out" + assert_contains "$out" "kept: $gone_id" "a missing-file board was retired while the Lavish store was unreadable" + assert_contains "$out" "sweep: retired=0 kept=$((kept_count + 2))" "an unreadable Lavish store changed the verdicts: $out" cp "$home/state-good.json" "$store/state.json" out=$(LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep) || fail "the restored-store sweep failed: $out" assert_contains "$out" "retired: $unreadable_id" "the same board was not retired once its session was readable" + assert_contains "$out" "retired: $gone_id" "a missing-file board was not retired once the store was readable" set +e out=$(FM_BOARD_LISTENER_IDLE_HOURS=0 LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep 2>&1) From 658dce26ab32a9dd3df674b91c3d7611d2b00ede Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Fri, 9 Oct 2026 03:36:38 -0500 Subject: [PATCH 03/14] no-mistakes(review): Preserve listener keep rules and registration generations --- bin/fm-procevent-lavish.sh | 63 ++++--- bin/fm-procevent.sh | 21 ++- docs/configuration.md | 5 +- docs/verification/process-event-sources.md | 3 +- tests/fm-captain-hold-lifecycle.test.sh | 183 ++++++++++++++++++++- 5 files changed, 243 insertions(+), 32 deletions(-) diff --git a/bin/fm-procevent-lavish.sh b/bin/fm-procevent-lavish.sh index 8d132b6dc87..848fa3842df 100755 --- a/bin/fm-procevent-lavish.sh +++ b/bin/fm-procevent-lavish.sh @@ -1053,12 +1053,12 @@ board_idle_seconds() { # One perl pass for every registration: TAB lines on # stdin, one fact row per line out: -# id, artifact, real path ("-" when the file is gone), sessions (count, or +# id, artifact, file state (present, missing, or unknown), sessions (count, or # "unknown"), status, queued prompts, last-activity epoch, card keys. # `sessions` is "unknown" when Lavish's store cannot be read, so an unreadable # store keeps every board instead of reading as "no session". sweep_facts() { # - perl -MJSON::PP -MCwd=realpath -MEncode=decode,FB_DEFAULT -MTime::Local=timegm -e ' + perl -MJSON::PP -MCwd=realpath -MErrno=ENOENT -MEncode=decode,FB_DEFAULT -MTime::Local=timegm -e ' use strict; use warnings; my ($state, $store) = @ARGV; my (%by, $store_ok); @@ -1088,14 +1088,16 @@ sweep_facts() { # chomp $line; my ($id, $art) = split /\t/, $line, 2; next unless defined $art; - my $real = -f $art ? realpath($art) : undef; + my @stat = stat $art; + my $file_state = @stat ? (-f _ && defined realpath($art) ? "present" : "unknown") + : ($! == ENOENT ? "missing" : "unknown"); my ($sessions, $status, $pending, @keys) = ("unknown", "", 0); my $keys_unknown = 0; my $activity = $newest->(glob("$state/procevent-inbox/$id.*")); - if (defined $real) { - my $t = $newest->($real); + if ($file_state eq "present") { + my $t = $stat[9]; $activity = $t if $t > $activity; - if (open my $in, "<:raw", $real) { + if (open my $in, "<:raw", $art) { local $/; local $! = 0; my $html = <$in>; @@ -1104,8 +1106,15 @@ sweep_facts() { # if ($read_ok && $close_ok) { $html //= ""; my %seen; - while ($html =~ /\bdata-lavish-question\s*=\s*(?:"([^"]*)"|\x27([^\x27]*)\x27|([^\s"\x27=<>`]+))/gi) { - my $k = unescape($1 // $2 // $3); + while ($html =~ m{<([A-Za-z][\w:-]*)(?=[\s/>])}g) { + my %attr; + while ($html =~ m{\G\s*([^\s"\x27<>/=]+)(?:\s*=\s*(?:"([^"]*)"|\x27([^\x27]*)\x27|([^\s"\x27=<>`]+)))?}gc) { + my $name = lc $1; + $attr{$name} //= unescape($2 // $3 // $4 // ""); + } + $html =~ m{\G\s*/?>}gc; + next unless exists $attr{"data-lavish-question"}; + my $k = $attr{"data-lavish-question"}; if ($k !~ /\A[A-Za-z0-9._-]{1,128}\z/) { $keys_unknown = 1; last; @@ -1120,8 +1129,8 @@ sweep_facts() { # } } if ($store_ok) { - my $list = defined $real ? ($by{decode("UTF-8", $real, FB_DEFAULT)} // []) : []; - $sessions = scalar @$list; + my $list = $by{decode("UTF-8", $art, FB_DEFAULT)} // []; + $sessions = @$list > 1 ? "unknown" : scalar @$list; if (@$list == 1) { my $s = $list->[0]; $status = $s->{status} // ""; @@ -1133,7 +1142,7 @@ sweep_facts() { # } } } - print join("\t", $id, $art, defined $real ? $real : "-", $sessions, $status || "-", $pending, $activity, $keys_unknown ? "?" : (join(",", @keys) || "-")), "\n"; + print join("\t", $id, $art, $file_state, $sessions, $status || "-", $pending, $activity, $keys_unknown ? "?" : (join(",", @keys) || "-")), "\n"; } ' "$1" "$2" } @@ -1165,9 +1174,9 @@ sweep_cards_closed() { cmd_sweep() { local dry=0 idle state reg store now rec id adapter kind artifact standing line n in_argv argv_poll - local pending_ids facts real sessions status queued activity keys reason verdict out + local pending_ids facts file_state sessions status queued activity keys reason verdict out identity current_identity local open_cache=$'\n' retired=0 kept=0 idx - local -a ids=() kinds=() + local -a ids=() kinds=() identities=() case "${1-}" in '') ;; --dry-run) dry=1; shift ;; @@ -1180,8 +1189,11 @@ cmd_sweep() { reg=$(fm_procevent_registry_dir "$state") store=${LAVISH_AXI_STATE_DIR:-$HOME/.lavish-axi}/state.json now=$(date +%s) - standing=$(perl -MCwd=realpath -e '$p = realpath($ARGV[0]); print $p if defined $p' \ - "$FM_HOME/.lavish/bearings-board.html" 2>/dev/null || true) + standing=$(perl -MCwd=realpath -e ' + my $home = realpath($ARGV[0]) // exit 1; + my $dir = realpath("$home/.lavish") // "$home/.lavish"; + print realpath("$home/.lavish/bearings-board.html") // "$dir/bearings-board.html"; + ' "$FM_HOME" 2>/dev/null || true) pending_ids=$(fm_procevent_pending "$state" | sed -n 's|.*/\([^/.]*\)\.[0-9][0-9]*\.result$|\1|p') # Read every registration first so one perl pass can answer for all of them. @@ -1190,6 +1202,7 @@ cmd_sweep() { [ -f "$rec" ] && [ ! -L "$rec" ] || continue id=${rec##*/}; id=${id%.source} fm_procevent_source_id_valid "$id" || continue + identity=$(fm_pr_file_identity "$rec" 2>/dev/null) || continue adapter=''; kind=plain; argv_poll=''; artifact=''; n=0; in_argv=0 while IFS= read -r line || [ -n "$line" ]; do if [ "$in_argv" = 1 ]; then @@ -1204,8 +1217,10 @@ cmd_sweep() { argv:) in_argv=1 ;; esac done < "$rec" + current_identity=$(fm_pr_file_identity "$rec" 2>/dev/null) || continue + [ "$current_identity" = "$identity" ] || continue [ "$adapter" = lavish ] && [ "$argv_poll" = poll ] && [ -n "$artifact" ] || continue - ids+=("$id"); kinds+=("$kind") + ids+=("$id"); kinds+=("$kind"); identities+=("$identity") facts="$facts$id"$'\t'"$artifact"$'\n' done if [ "${#ids[@]}" -eq 0 ]; then @@ -1214,22 +1229,24 @@ cmd_sweep() { fi facts=$(printf '%s' "$facts" | sweep_facts "$state" "$store") || die "cannot read the Lavish board facts" - while IFS=$'\t' read -r id artifact real sessions status queued activity keys; do + while IFS=$'\t' read -r id artifact file_state sessions status queued activity keys; do [ -n "$id" ] || continue - kind=plain + kind=plain; identity='' for idx in "${!ids[@]}"; do - [ "${ids[$idx]}" = "$id" ] && { kind=${kinds[$idx]}; break; } + [ "${ids[$idx]}" = "$id" ] && { kind=${kinds[$idx]}; identity=${identities[$idx]}; break; } done verdict=keep - if [ -n "$standing" ] && [ "$real" = "$standing" ]; then + if [ -n "$standing" ] && [ "$artifact" = "$standing" ]; then reason='the standing Bearings board' elif [ "$sessions" = unknown ]; then - reason='the Lavish session store cannot be read' + reason='Lavish session evidence cannot be read unambiguously' elif [ "$status" = feedback ] || [ "$queued" -gt 0 ]; then reason='Lavish holds queued feedback for the board' elif [ "$kind" = task-owned ]; then reason='owned by a worker task' - elif [ "$real" = - ]; then + elif [ "$file_state" = unknown ]; then + reason='the board file cannot be checked' + elif [ "$file_state" = missing ]; then verdict=retire; reason='the board file is gone' elif [ "$sessions" != 0 ] && [ "$status" != ended ] && [ $((now - activity)) -lt "$idle" ]; then reason="active within the last $((idle / 3600)) hours" @@ -1251,7 +1268,7 @@ cmd_sweep() { esac fi if [ "$verdict" = retire ] && [ "$dry" = 0 ]; then - if ! out=$(FM_HOME="$FM_HOME" "$SCRIPT_DIR/fm-procevent.sh" retire "$id" 2>&1 &1 # fm-procevent.sh handled -# fm-procevent.sh retire [--if-absent|--if-matches -- ...|--if-owner ] +# fm-procevent.sh retire [--if-absent|--if-matches -- ...|--if-owner |--if-identity ] # fm-procevent.sh sweep-home [--preflight] # fm-procevent.sh binding-retirement-preflight # fm-procevent.sh extension-retirement @@ -108,7 +108,8 @@ # built-in registration, --if-absent refuses while any registration # exists, and --if-owner removes only the exact extension registration # token printed by register-extension, so a stale owner cannot retire -# a replacement generation. +# a replacement generation. --if-identity compares the built-in +# registration file identity under the source lock before stopping it. # sweep-home Retire a bounded snapshot of this home's registrations and owned # claims, then refuse unless no registration, runner record, or owned # claim remains. Used by supported Firstmate home retirement. @@ -2157,11 +2158,16 @@ cmd_handled() { cmd_retire() { local id=${1-} condition=${2-} adapter='' sep='' expected_owner='' owner='' pid='' token='' identity='' stop_state owner_state - local extension_binding_digest='' round_owner='' + local extension_binding_digest='' round_owner='' expected_identity='' current_identity='' fm_procevent_source_id_valid "$id" || die "source id must be path-safe: $id" case "$condition" in '') [ "$#" -eq 1 ] || usage ;; --if-absent) [ "$#" -eq 2 ] || usage ;; + --if-identity) + [ "$#" -eq 3 ] || usage + expected_identity=${3-} + [[ "$expected_identity" =~ ^[0-9]+:[0-9]+$ ]] || die "registration identity must be device:inode" + ;; --if-owner) [ "$#" -eq 3 ] || usage expected_owner=${3-} @@ -2179,13 +2185,20 @@ cmd_retire() { *) usage ;; esac fm_procevent_source_lock_acquire "$id" || die "cannot lock source: $id" + if [ "$condition" = --if-identity ]; then + current_identity=$(fm_pr_file_identity "$(source_file "$id")" 2>/dev/null) || current_identity='' + if [ "$current_identity" != "$expected_identity" ]; then + fm_procevent_source_lock_release "$id" + die "source registration generation changed: $id" + fi + fi if source_retirement_blocked_locked "$id"; then round_owner=$(source_owner_task "$id") fm_procevent_source_lock_release "$id" die "cannot retire task-owned source $id while a captured round for task $round_owner is unacknowledged; acknowledge it with bin/fm-procevent.sh handled $id " fi if [ -e "$(source_file "$id")" ] || [ -L "$(source_file "$id")" ]; then - if [ -z "$condition" ]; then + if [ -z "$condition" ] || [ "$condition" = --if-identity ]; then fm_procevent_extension_registration_load_locked "$STATE" "$id" owner_state=$? case "$owner_state" in diff --git a/docs/configuration.md b/docs/configuration.md index 49aad69d7ef..9a3f2254c8e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2442,15 +2442,16 @@ An already-armed Lavish source keeps its registered listener command until it is `bin/fm-procevent-lavish.sh sweep [--dry-run]` retires the listeners of boards that are finished, and its header owns the rules. A listener otherwise lives as long as its Lavish session, and a captain's choice answers never end the session. `bin/fm-bearings-board.sh build` runs the sweep after every successful build, and it can be run by hand at any time. -The sweep first keeps the standing Bearings board, every board when the Lavish store is unreadable, boards with queued feedback or status `feedback`, and worker-owned boards. +The sweep first keeps the standing Bearings board, every board when the Lavish store is unreadable, boards with queued feedback or status `feedback`, and worker-owned boards; these guards also apply after the artifact is deleted. After those guards, a missing board file permits retirement; a present board with a missing or ended session permits retirement only when every card key is known not to name an open captain call. Other present boards must also pass the idle window below. -Unquoted and quoted card keys, case-insensitive attribute names, and HTML entities are recognized; an unreadable board or invalid decoded key keeps the listener. +Unquoted and quoted card keys, case-insensitive attribute names, and HTML entities are recognized; an unreadable board, valueless question attribute, or invalid decoded key keeps the listener. Ambiguous session records also keep the listener. Only confirmed file absence permits missing-file retirement; filesystem permission and canonicalization errors keep the listener. An open or uncheckable captain call keeps the listener, and an unacknowledged captured round blocks every retirement. `FM_BOARD_LISTENER_IDLE_HOURS` (default 48, whole hours 1..8760) sets how long a board must sit untouched before the sweep may retire it. An unusable value makes the sweep refuse by name. Retiring a listener never ends the Lavish session, and `bin/fm-procevent-lavish.sh arm ` brings the listener back. +Retirement compares the snapshotted registration's device/inode identity under the source lock, so a concurrent retire-and-rearm preserves the replacement listener. ### Crew-hosted Lavish review boards diff --git a/docs/verification/process-event-sources.md b/docs/verification/process-event-sources.md index a31b7b09ba9..53c88513fa2 100644 --- a/docs/verification/process-event-sources.md +++ b/docs/verification/process-event-sources.md @@ -92,7 +92,8 @@ Exercised by `tests/fm-procevent.test.sh` against a fake blocking source whose c | adapter-owned application of a captured result | a remote-secondmate reply captured through the real relay in an isolated home reaches that secondmate's local status mirror, settles its correlated pending-reply expectation, re-arms the next cursor-anchored source, and is acknowledged, with no handler step or duplicate `check` wake; its new mirrored bytes remain visible to the watcher's signal gate, while exact source-line replay identity keeps a commit-failure retry or cursor-loss whole-log recapture from duplicating a decision when document availability changes, and a recapture that adds no bytes is acknowledged quietly; for an already-escalated request, the same path closes the exact decision so the open-decision fold clears and remains clear; a capture whose adapter application fails because local storage for a referenced remote document is obstructed is left unacknowledged and receives the fallback `check` wake, and the handler's own `handle` still applies it in full after storage recovers; a document offered through a structured `report=` pointer that the reader cannot deliver fails open, mirroring its line with the original pointer, advancing the cursor, and appending one unkeyed note with the reader's own reason that opens no decision, while a path merely mentioned in prose is never fetched and the reported announce-then-explain incident leaves no standing decision yet still delivers its report through the later structured offer | | generic built-in keyed-answer feed | `tests/fm-captain-hold-lifecycle.test.sh` drives a bound built-in source through the real runner with a fixture adapter that only prints keyed lines, proving any bound built-in channel reaches the one keyed-answer intake: named captain-held tasks close at capture time, a card-declared release mode frees held work, keys naming no captain-held task skip, freeform prose forges nothing, matching answer-and-mode replays are idempotent while mode mismatches refuse, an unbound source closes nothing, and capture remains independent of the handler wake. | | structured reconcile feed | `tests/fm-captain-hold-lifecycle.test.sh` covers the [captured-choice and reconcile contract](../captain-hold-lifecycle.md#how-a-board-selection-creates-a-request); its [reconcile coverage index](../captain-hold-lifecycle.md#reconcile-coverage) names the focused legacy-format and last-choice regressions. | -| finished-board sweep | `test_sweep_retires_only_finished_board_listeners` in `tests/fm-captain-hold-lifecycle.test.sh` proves keep guards precede retirement: the standing Bearings board, every board under an unreadable Lavish store (including a missing file), queued feedback, and worker ownership (including ended sessions). After those guards, missing files retire; present boards with missing/ended sessions or past-window idleness retire only with no open or uncheckable captain card. Quoted, unquoted, case-insensitive and entity-encoded keys preserve open holds across all three present-board paths; unreadable files and invalid decoded keys keep listeners. Unacknowledged captures and recent activity also keep listeners; dry runs do not retire registrations, the Lavish store remains byte-identical, repeated sweeps are idempotent, and unusable idle windows are refused. `test_sweep_stops_a_live_listener_of_a_finished_board` proves the resident poll of a finished board's armed listener is gone after the sweep | +| finished-board sweep | `test_sweep_retires_only_finished_board_listeners` in `tests/fm-captain-hold-lifecycle.test.sh` proves keep guards precede retirement: the standing Bearings board (including a symlink), every board under an unreadable Lavish store (including a missing file), queued feedback, and worker ownership (including ended sessions). After those guards, missing files retire; present boards with missing/ended sessions or past-window idleness retire only with no open or uncheckable captain card. Quoted, unquoted, case-insensitive and entity-encoded keys preserve open holds across all three present-board paths; unreadable files, valueless question attributes and invalid decoded keys keep listeners. Unacknowledged captures and recent activity also keep listeners; dry runs do not retire registrations, the Lavish store remains byte-identical, repeated sweeps are idempotent, and unusable idle windows are refused. `test_sweep_stops_a_live_listener_of_a_finished_board` proves the resident poll of a finished board's armed listener is gone after the sweep | +| sweep uncertainty and generation safety | `test_sweep_preserves_deleted_board_keep_guards` covers deleted standing, feedback, queued-feedback, worker-owned and unacknowledged-result boards, including ambiguous session records. `test_sweep_keeps_boards_with_unsearchable_parents` exercises actual EACCES in ended, sessionless and idle states. `test_sweep_preserves_rearmed_registration_generations` pauses a sweep after its registration snapshot, retires and re-arms the board through the public interfaces, and verifies both plain and worker-owned replacement registrations and live polls survive the stale retirement. | | adapter-owned silence verdict | an ordinary firstmate-owned Lavish source driven against a stand-in poll that returns an empty ended session captures its result, records it durably handled, appends no wake, and stays silent through a later `reconcile` that would otherwise republish it, while still retiring its ended source; the same real path with a `Send & End` response carrying the captain's choice still publishes its `check` wake and is left unacknowledged for the handler | | worker-owned Lavish rounds | one three-round fixture arms a board for an identity-matched task endpoint, delivers nonterminal and terminal captures directly to that task's steering inbox without a firstmate `check` wake, acknowledges each nonterminal round through a successful re-arm, rings the owner's doorbell once when the capture writes a fresh inbox note and never re-rings or resurrects a note the owner has filed into `handled/` across repeated reconciles, refuses a second armer and every early retirement, and concludes the terminal round through `handled` without another poll; focused fixtures also pin synchronous reply acceptance before modern arm returns, failed reply refusal before registration, refused-arm reply isolation, direct poll reply ordering, the legacy poll-with-reply fallback, failed re-arm rollback, one legacy reply post across transient retries, unreachable-owner refusal, interrupted conclusion recovery, and repeat acknowledgement isolation | | Lavish handled-status classification | an executable fixture table pins exact `feedback`, `ended`, `waiting`, and `browser_disconnected` mappings, including `browser_disconnected` to `disconnected`; the same suite proves that status is nonterminal and receives a zero-answer silence verdict | diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index 5f8ff5c1bb6..3eb090bd085 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -4885,9 +4885,10 @@ test_sweep_retires_only_finished_board_listeners() { fresh=$(sweep_board "$home" fresh new) queued=$(sweep_board "$home" queued old) owned=$(sweep_board "$home" owned old) - standing="$home/.lavish/bearings-board.html" + standing="$home/boards/standing.html" mkdir -p "$home/.lavish" printf '

standing

\n' > "$standing" + ln -s "$standing" "$home/.lavish/bearings-board.html" touch -t 200001010000 "$standing" pending=$(sweep_board "$home" pending old) @@ -4909,7 +4910,7 @@ test_sweep_retires_only_finished_board_listeners() { || fail "could not register the worker-owned board" for session in ended nosession idle; do - for syntax in quoted single unquoted uppercase hex decimal invalid unreadable; do + for syntax in quoted single unquoted uppercase hex decimal invalid valueless unreadable; do board=$(sweep_board "$home" "$session-$syntax" new) case "$syntax" in quoted) printf '
\n' > "$board" ;; @@ -4919,6 +4920,7 @@ test_sweep_retires_only_finished_board_listeners() { hex) printf '
\n' > "$board" ;; decimal) printf '
\n' > "$board" ;; invalid) printf '
\n' > "$board" ;; + valueless) printf '
\n' > "$board" ;; esac [ "$session" != idle ] || touch -t 200001010000 "$board" sid=$(sweep_register "$home" "$board") @@ -5074,9 +5076,186 @@ SH pass "the sweep stops the resident poll of a finished board's live listener" } +test_sweep_preserves_deleted_board_keep_guards() { + local home store guard board sid out list before + local -a kept_ids=() + home=$(make_home board-sweep-deleted) + fm_test_track_procevent_home "$home" "$home/procevent-claims" + store="$home/lavish-state" + for guard in standing feedback queued owned pending ambiguous; do + board=$(sweep_board "$home" "$guard" old) + if [ "$guard" = standing ]; then + mkdir -p "$home/.lavish" + mv "$board" "$home/.lavish/bearings-board.html" + board="$home/.lavish/bearings-board.html" + fi + if [ "$guard" = owned ]; then + sid=$(run_lavish "$home" source-id "$board") || fail "could not derive the owned source id" + printf 'window=fmtest:fm-deleted-owner\nworktree=%s/worktree-deleted-owner\nproject=fmtest\n' "$home" \ + > "$home/state/deleted-owner.meta" + run_procevent "$home" register-task lavish "$sid" deleted-owner -- \ + "$ROOT/bin/fm-procevent-lavish.sh" poll "$board" >/dev/null \ + || fail "could not register the deleted worker-owned board" + else + sid=$(sweep_register "$home" "$board") + fi + kept_ids+=("$sid") + case "$guard" in + feedback|ambiguous) sweep_session "$store" "$board" feedback 0 - ;; + queued) sweep_session "$store" "$board" open 2 - ;; + *) sweep_session "$store" "$board" ended 0 - ;; + esac + if [ "$guard" = ambiguous ]; then + perl -MJSON::PP -e ' + my ($path, $board) = @ARGV; + open my $in, "<", $path or die $!; + local $/; + my $doc = decode_json(<$in>); + close $in; + $doc->{sessions}{duplicate} = { file => $board, status => "ended", pending_prompts => 0 }; + open my $out, ">", $path or die $!; + print $out encode_json($doc); + close $out or die $!; + ' "$store/state.json" "$board" + fi + if [ "$guard" = pending ]; then + mkdir -p "$home/state/procevent-inbox" + printf 'session:\n status: feedback\n' > "$home/state/procevent-inbox/$sid.1.result" + printf 'lavish\n' > "$home/state/procevent-inbox/$sid.1.adapter" + fi + rm "$board" + done + before=$(cksum < "$store/state.json") + out=$(LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep --dry-run) \ + || fail "the deleted-board dry run failed: $out" + assert_contains "$out" 'sweep: would-retire=0 kept=6' "deleted boards bypassed keep guards: $out" + out=$(LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep) \ + || fail "the deleted-board sweep failed: $out" + list=$(run_procevent "$home" list) + for sid in "${kept_ids[@]}"; do + assert_contains "$out" "kept: $sid" "a deleted protected board was retired: $out" + assert_contains "$list" "$sid" "a deleted protected board lost its registration" + done + [ "$before" = "$(cksum < "$store/state.json")" ] || fail "the deleted-board sweep changed Lavish sessions" + pass "deleted boards retain standing, feedback, ownership and captured-result protections" +} + +test_sweep_keeps_boards_with_unsearchable_parents() { + local home store session board parent sid out rc list + home=$(make_home board-sweep-inaccessible) + fm_test_track_procevent_home "$home" "$home/procevent-claims" + store="$home/lavish-state" + mkdir -p "$store" + printf '{"sessions":{}}\n' > "$store/state.json" + run_captain "$home" hold sweep-inaccessible-call --title "Choose the inaccessible route" \ + --reason "captain route pending" --repo sample >/dev/null || fail "could not hold the inaccessible call" + for session in ended nosession idle; do + board=$(sweep_board "$home/$session" inaccessible old sweep-inaccessible-call) + parent=${board%/*} + sid=$(sweep_register "$home" "$board") + case "$session" in + ended) sweep_session "$store" "$board" ended 0 - ;; + idle) sweep_session "$store" "$board" open 0 2000-01-01T00:00:00.000Z ;; + esac + chmod 000 "$parent" + perl -MErrno=EACCES -e 'stat($ARGV[0]); exit($! == EACCES ? 0 : 1)' "$board" + rc=$? + if [ "$rc" -ne 0 ]; then + chmod 700 "$parent" + fail "the unsearchable-parent fixture did not produce EACCES" + fi + out=$(LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep) + rc=$? + chmod 700 "$parent" + [ "$rc" -eq 0 ] || fail "the inaccessible-board sweep failed: $out" + assert_contains "$out" "kept: $sid" "an inaccessible $session board was treated as missing: $out" + assert_contains "$out" 'the board file cannot be checked' "the filesystem error lost its unknown verdict: $out" + list=$(run_procevent "$home" list) + assert_contains "$list" "$sid" "an inaccessible board lost its registration" + done + pass "filesystem permission errors keep ended, sessionless and idle board listeners" +} + +test_sweep_preserves_rearmed_registration_generations() ( + local mode home store board sid real_perl sweep_pid poll_pid out list rc + real_perl=$(command -v perl) + for mode in plain worker; do + home=$(make_home "board-sweep-rearmed-$mode") + fm_test_track_procevent_home "$home" "$home/procevent-claims" + store="$home/lavish-state" + board=$(sweep_board "$home" rearmed old) + sid=$(sweep_register "$home" "$board") + sweep_session "$store" "$board" open 0 2000-01-01T00:00:00.000Z + printf '#!/usr/bin/env bash\nreal_perl=%q\n' "$real_perl" > "$home/fakebin/perl" + cat >> "$home/fakebin/perl" <<'SH' +if [ "${FM_TEST_SWEEP_PAUSE:-}" = 1 ] \ + && [ "${!#}" = "$FM_HOME/lavish-state/state.json" ] \ + && [ ! -e "$FM_HOME/sweep-ready" ]; then + : > "$FM_HOME/sweep-ready" + while [ ! -e "$FM_HOME/sweep-release" ] && [ "$SECONDS" -lt 120 ]; do sleep 0.01; done +fi +exec "$real_perl" "$@" +SH + cat > "$home/fakebin/lavish-axi" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$$" > "$FM_HOME/poll.pid" +exec sleep 120 +SH + chmod +x "$home/fakebin/perl" "$home/fakebin/lavish-axi" + exec 7< "$home/state/procevent/$sid.source" + FM_TEST_SWEEP_PAUSE=1 LAVISH_AXI_STATE_DIR="$store" \ + run_lavish "$home" sweep > "$home/sweep.out" 2> "$home/sweep.err" & + sweep_pid=$! + if ! wait_for_test_file "$home/sweep-ready" "$sweep_pid"; then + : > "$home/sweep-release" + wait "$sweep_pid" 2>/dev/null || true + fail "the sweep did not reach its post-snapshot barrier" + fi + run_procevent "$home" retire "$sid" >/dev/null \ + || { : > "$home/sweep-release"; wait "$sweep_pid"; fail "could not retire the snapshotted board"; } + if [ "$mode" = worker ]; then + printf 'window=fmtest:fm-rearmed-owner\nworktree=%s/worktree-rearmed-owner\nproject=fmtest\n' "$home" \ + > "$home/state/rearmed-owner.meta" + out=$(FM_PROCEVENT_LAUNCH_CONFIRM_SECONDS=60 LAVISH_AXI_STATE_DIR="$store" \ + run_lavish "$home" arm "$board" --for rearmed-owner) + rc=$? + else + out=$(FM_PROCEVENT_LAUNCH_CONFIRM_SECONDS=60 LAVISH_AXI_STATE_DIR="$store" \ + run_lavish "$home" arm "$board") + rc=$? + fi + if [ "$rc" -ne 0 ]; then + : > "$home/sweep-release" + wait "$sweep_pid" 2>/dev/null || true + fail "could not re-arm the $mode replacement: $out" + fi + if ! wait_for_test_file "$home/poll.pid" "$sweep_pid"; then + : > "$home/sweep-release" + wait "$sweep_pid" 2>/dev/null || true + fail "the replacement listener did not start polling" + fi + poll_pid=$(cat "$home/poll.pid") + : > "$home/sweep-release" + wait "$sweep_pid" || fail "the stale sweep failed: $(cat "$home/sweep.err")" + exec 7<&- + out=$(cat "$home/sweep.out") + assert_contains "$out" "kept: $sid" "the stale sweep retired the $mode replacement: $out" + assert_contains "$out" 'source registration generation changed' "the stale retirement was not generation-gated: $out" + list=$(run_procevent "$home" list) + assert_contains "$list" "$sid" "the replacement registration was deleted" + kill -0 "$poll_pid" 2>/dev/null || fail "the stale sweep stopped the replacement poll" + run_procevent "$home" retire "$sid" >/dev/null \ + || fail "ordinary retirement refused the replacement listener" + done + pass "stale sweeps preserve rearmed plain and worker-owned listener generations" +) + tests=( test_sweep_retires_only_finished_board_listeners test_sweep_stops_a_live_listener_of_a_finished_board +test_sweep_preserves_deleted_board_keep_guards +test_sweep_keeps_boards_with_unsearchable_parents +test_sweep_preserves_rearmed_registration_generations test_hold_reason_round_trips_awkward_characters test_hold_origins_precede_backend_holds test_historical_self_inventory_has_workable_repair From 51ef6283236884ed2b9f88f77560ae6470df84b7 Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Fri, 9 Oct 2026 04:33:48 -0500 Subject: [PATCH 04/14] no-mistakes(review): Preserve pending captures and deleted standing-board symlink identities --- bin/fm-procevent-lavish.sh | 19 ++++-- bin/fm-procevent.sh | 9 ++- docs/configuration.md | 3 +- docs/verification/process-event-sources.md | 2 +- tests/fm-captain-hold-lifecycle.test.sh | 79 +++++++++++++++++++++- 5 files changed, 102 insertions(+), 10 deletions(-) diff --git a/bin/fm-procevent-lavish.sh b/bin/fm-procevent-lavish.sh index 848fa3842df..f81560f715f 100755 --- a/bin/fm-procevent-lavish.sh +++ b/bin/fm-procevent-lavish.sh @@ -1189,10 +1189,19 @@ cmd_sweep() { reg=$(fm_procevent_registry_dir "$state") store=${LAVISH_AXI_STATE_DIR:-$HOME/.lavish-axi}/state.json now=$(date +%s) - standing=$(perl -MCwd=realpath -e ' + standing=$(perl -MCwd=realpath -MFile::Basename=dirname,basename -e ' my $home = realpath($ARGV[0]) // exit 1; - my $dir = realpath("$home/.lavish") // "$home/.lavish"; - print realpath("$home/.lavish/bearings-board.html") // "$dir/bearings-board.html"; + my $p = "$home/.lavish/bearings-board.html"; + if (defined(my $real = realpath($p))) { print $real; exit } + my $hops = 0; + while (-l $p) { + if ($hops++ >= 40) { print "?"; exit } + my $target = readlink($p); + if (!defined $target) { print "?"; exit } + $p = $target =~ m{\A/} ? $target : dirname($p) . "/$target"; + } + my $dir = realpath(dirname($p)); + print defined($dir) ? "$dir/" . basename($p) : $p; ' "$FM_HOME" 2>/dev/null || true) pending_ids=$(fm_procevent_pending "$state" | sed -n 's|.*/\([^/.]*\)\.[0-9][0-9]*\.result$|\1|p') @@ -1236,7 +1245,9 @@ cmd_sweep() { [ "${ids[$idx]}" = "$id" ] && { kind=${kinds[$idx]}; identity=${identities[$idx]}; break; } done verdict=keep - if [ -n "$standing" ] && [ "$artifact" = "$standing" ]; then + if [ "$standing" = '?' ]; then + reason='the standing Bearings board path cannot be resolved' + elif [ -n "$standing" ] && [ "$artifact" = "$standing" ]; then reason='the standing Bearings board' elif [ "$sessions" = unknown ]; then reason='Lavish session evidence cannot be read unambiguously' diff --git a/bin/fm-procevent.sh b/bin/fm-procevent.sh index aab28276070..d937ad1166d 100755 --- a/bin/fm-procevent.sh +++ b/bin/fm-procevent.sh @@ -109,7 +109,10 @@ # exists, and --if-owner removes only the exact extension registration # token printed by register-extension, so a stale owner cannot retire # a replacement generation. --if-identity compares the built-in -# registration file identity under the source lock before stopping it. +# registration file identity under the source lock before stopping it +# and refuses while any captured round of the source is unacknowledged. +# Built-in captures do not take this lock: a capture can still arrive +# between the check and runner stop; retirement never deletes inbox results. # sweep-home Retire a bounded snapshot of this home's registrations and owned # claims, then refuse unless no registration, runner record, or owned # claim remains. Used by supported Firstmate home retirement. @@ -2191,6 +2194,10 @@ cmd_retire() { fm_procevent_source_lock_release "$id" die "source registration generation changed: $id" fi + if [ -n "$(source_pending "$id" | head -1)" ]; then + fm_procevent_source_lock_release "$id" + die "cannot retire source $id while a captured round is unacknowledged" + fi fi if source_retirement_blocked_locked "$id"; then round_owner=$(source_owner_task "$id") diff --git a/docs/configuration.md b/docs/configuration.md index 9a3f2254c8e..08e72b7376d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2443,6 +2443,7 @@ An already-armed Lavish source keeps its registered listener command until it is A listener otherwise lives as long as its Lavish session, and a captain's choice answers never end the session. `bin/fm-bearings-board.sh build` runs the sweep after every successful build, and it can be run by hand at any time. The sweep first keeps the standing Bearings board, every board when the Lavish store is unreadable, boards with queued feedback or status `feedback`, and worker-owned boards; these guards also apply after the artifact is deleted. +The standing-board exemption survives deleted symlink targets; an unresolved symlink chain keeps every listener until its identity can be resolved. After those guards, a missing board file permits retirement; a present board with a missing or ended session permits retirement only when every card key is known not to name an open captain call. Other present boards must also pass the idle window below. Unquoted and quoted card keys, case-insensitive attribute names, and HTML entities are recognized; an unreadable board, valueless question attribute, or invalid decoded key keeps the listener. Ambiguous session records also keep the listener. Only confirmed file absence permits missing-file retirement; filesystem permission and canonicalization errors keep the listener. @@ -2451,7 +2452,7 @@ An open or uncheckable captain call keeps the listener, and an unacknowledged ca `FM_BOARD_LISTENER_IDLE_HOURS` (default 48, whole hours 1..8760) sets how long a board must sit untouched before the sweep may retire it. An unusable value makes the sweep refuse by name. Retiring a listener never ends the Lavish session, and `bin/fm-procevent-lavish.sh arm ` brings the listener back. -Retirement compares the snapshotted registration's device/inode identity under the source lock, so a concurrent retire-and-rearm preserves the replacement listener. +Retirement compares the snapshotted registration's device/inode identity under the source lock, so a concurrent retire-and-rearm preserves the replacement listener, and refuses while any captured round of the source is unacknowledged. ### Crew-hosted Lavish review boards diff --git a/docs/verification/process-event-sources.md b/docs/verification/process-event-sources.md index 53c88513fa2..853201c1f6f 100644 --- a/docs/verification/process-event-sources.md +++ b/docs/verification/process-event-sources.md @@ -93,7 +93,7 @@ Exercised by `tests/fm-procevent.test.sh` against a fake blocking source whose c | generic built-in keyed-answer feed | `tests/fm-captain-hold-lifecycle.test.sh` drives a bound built-in source through the real runner with a fixture adapter that only prints keyed lines, proving any bound built-in channel reaches the one keyed-answer intake: named captain-held tasks close at capture time, a card-declared release mode frees held work, keys naming no captain-held task skip, freeform prose forges nothing, matching answer-and-mode replays are idempotent while mode mismatches refuse, an unbound source closes nothing, and capture remains independent of the handler wake. | | structured reconcile feed | `tests/fm-captain-hold-lifecycle.test.sh` covers the [captured-choice and reconcile contract](../captain-hold-lifecycle.md#how-a-board-selection-creates-a-request); its [reconcile coverage index](../captain-hold-lifecycle.md#reconcile-coverage) names the focused legacy-format and last-choice regressions. | | finished-board sweep | `test_sweep_retires_only_finished_board_listeners` in `tests/fm-captain-hold-lifecycle.test.sh` proves keep guards precede retirement: the standing Bearings board (including a symlink), every board under an unreadable Lavish store (including a missing file), queued feedback, and worker ownership (including ended sessions). After those guards, missing files retire; present boards with missing/ended sessions or past-window idleness retire only with no open or uncheckable captain card. Quoted, unquoted, case-insensitive and entity-encoded keys preserve open holds across all three present-board paths; unreadable files, valueless question attributes and invalid decoded keys keep listeners. Unacknowledged captures and recent activity also keep listeners; dry runs do not retire registrations, the Lavish store remains byte-identical, repeated sweeps are idempotent, and unusable idle windows are refused. `test_sweep_stops_a_live_listener_of_a_finished_board` proves the resident poll of a finished board's armed listener is gone after the sweep | -| sweep uncertainty and generation safety | `test_sweep_preserves_deleted_board_keep_guards` covers deleted standing, feedback, queued-feedback, worker-owned and unacknowledged-result boards, including ambiguous session records. `test_sweep_keeps_boards_with_unsearchable_parents` exercises actual EACCES in ended, sessionless and idle states. `test_sweep_preserves_rearmed_registration_generations` pauses a sweep after its registration snapshot, retires and re-arms the board through the public interfaces, and verifies both plain and worker-owned replacement registrations and live polls survive the stale retirement. | +| sweep uncertainty and generation safety | `test_sweep_preserves_deleted_board_keep_guards` covers deleted standing, feedback, queued-feedback, worker-owned and unacknowledged-result boards, including ambiguous session records, deleted standing targets behind absolute, relative and chained symlinks, and unresolved standing symlink cycles or over-limit chains. `test_sweep_keeps_boards_with_unsearchable_parents` exercises actual EACCES in ended, sessionless and idle states. `test_sweep_preserves_rearmed_registration_generations` pauses a sweep after its registration snapshot, retires and re-arms the board through the public interfaces, and verifies both plain and worker-owned replacement registrations and live polls survive the stale retirement. It also creates an unacknowledged capture after the snapshot and verifies locked conditional retirement preserves both the registration and pending result, while ordinary plain retirement remains available and preserves the result. | | adapter-owned silence verdict | an ordinary firstmate-owned Lavish source driven against a stand-in poll that returns an empty ended session captures its result, records it durably handled, appends no wake, and stays silent through a later `reconcile` that would otherwise republish it, while still retiring its ended source; the same real path with a `Send & End` response carrying the captain's choice still publishes its `check` wake and is left unacknowledged for the handler | | worker-owned Lavish rounds | one three-round fixture arms a board for an identity-matched task endpoint, delivers nonterminal and terminal captures directly to that task's steering inbox without a firstmate `check` wake, acknowledges each nonterminal round through a successful re-arm, rings the owner's doorbell once when the capture writes a fresh inbox note and never re-rings or resurrects a note the owner has filed into `handled/` across repeated reconciles, refuses a second armer and every early retirement, and concludes the terminal round through `handled` without another poll; focused fixtures also pin synchronous reply acceptance before modern arm returns, failed reply refusal before registration, refused-arm reply isolation, direct poll reply ordering, the legacy poll-with-reply fallback, failed re-arm rollback, one legacy reply post across transient retries, unreachable-owner refusal, interrupted conclusion recovery, and repeat acknowledgement isolation | | Lavish handled-status classification | an executable fixture table pins exact `feedback`, `ended`, `waiting`, and `browser_disconnected` mappings, including `browser_disconnected` to `disconnected`; the same suite proves that status is nonterminal and receives a zero-answer silence verdict | diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index 3eb090bd085..520bfa63ace 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -5077,7 +5077,7 @@ SH } test_sweep_preserves_deleted_board_keep_guards() { - local home store guard board sid out list before + local home store guard board sid out list before hop next local -a kept_ids=() home=$(make_home board-sweep-deleted) fm_test_track_procevent_home "$home" "$home/procevent-claims" @@ -5137,6 +5137,56 @@ test_sweep_preserves_deleted_board_keep_guards() { assert_contains "$list" "$sid" "a deleted protected board lost its registration" done [ "$before" = "$(cksum < "$store/state.json")" ] || fail "the deleted-board sweep changed Lavish sessions" + printf '#!/usr/bin/env bash\nexec sleep 120\n' > "$home/fakebin/lavish-axi" + chmod +x "$home/fakebin/lavish-axi" + for guard in absolute relative chain cycle limit; do + board=$(sweep_board "$home" "standing-$guard" old) + case "$guard" in + relative) ln -s "../boards/standing-$guard.html" "$home/.lavish/bearings-board.html" ;; + chain) + ln -s "standing-$guard.html" "$home/boards/standing-link.html" + ln -s ../boards/standing-link.html "$home/.lavish/bearings-board.html" + ;; + *) ln -s "$board" "$home/.lavish/bearings-board.html" ;; + esac + sid=$(run_lavish "$home" source-id "$board") || fail "could not derive the standing target source id" + sweep_session "$store" "$board" open 0 2000-01-01T00:00:00.000Z + out=$(FM_PROCEVENT_LAUNCH_CONFIRM_SECONDS=60 LAVISH_AXI_STATE_DIR="$store" \ + run_lavish "$home" arm "$home/.lavish/bearings-board.html") \ + || fail "could not arm the $guard standing symlink: $out" + assert_contains "$out" "artifact: $board" "arm did not record the standing board's physical target: $out" + rm "$board" + case "$guard" in + cycle) + rm "$home/.lavish/bearings-board.html" + ln -s bearings-board.html "$home/.lavish/bearings-board.html" + ;; + limit) + rm "$home/.lavish/bearings-board.html" + ln -s standing-hop-1 "$home/.lavish/bearings-board.html" + for hop in {1..40}; do + next=$((hop + 1)) + ln -s "standing-hop-$next" "$home/.lavish/standing-hop-$hop" + done + ;; + esac + out=$(LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep --dry-run) \ + || fail "the deleted $guard standing symlink dry run failed: $out" + assert_contains "$out" "kept: $sid $board - the standing Bearings board" \ + "the deleted $guard standing board was not protected in the dry run: $out" + out=$(LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep) \ + || fail "the deleted $guard standing symlink sweep failed: $out" + assert_contains "$out" "kept: $sid $board - the standing Bearings board" \ + "the deleted $guard standing board was not protected: $out" + case "$guard" in + cycle|limit) assert_contains "$out" 'the standing Bearings board path cannot be resolved' \ + "the unresolved standing symlink did not keep listeners: $out" ;; + esac + list=$(run_procevent "$home" list) + assert_contains "$list" "$sid" "the deleted $guard standing board lost its registration" + run_procevent "$home" retire "$sid" >/dev/null || fail "could not retire the standing symlink fixture" + rm "$home/.lavish/bearings-board.html" + done pass "deleted boards retain standing, feedback, ownership and captured-result protections" } @@ -5179,7 +5229,7 @@ test_sweep_keeps_boards_with_unsearchable_parents() { test_sweep_preserves_rearmed_registration_generations() ( local mode home store board sid real_perl sweep_pid poll_pid out list rc real_perl=$(command -v perl) - for mode in plain worker; do + for mode in plain worker pending; do home=$(make_home "board-sweep-rearmed-$mode") fm_test_track_procevent_home "$home" "$home/procevent-claims" store="$home/lavish-state" @@ -5211,6 +5261,29 @@ SH wait "$sweep_pid" 2>/dev/null || true fail "the sweep did not reach its post-snapshot barrier" fi + if [ "$mode" = pending ]; then + mkdir -p "$home/state/procevent-inbox" + printf 'session:\n status: feedback\n' > "$home/state/procevent-inbox/$sid.1.result" + printf 'lavish\n' > "$home/state/procevent-inbox/$sid.1.adapter" + touch -t 200001010000 "$home/state/procevent-inbox/$sid.1.result" \ + "$home/state/procevent-inbox/$sid.1.adapter" + : > "$home/sweep-release" + wait "$sweep_pid" || fail "the capture-race sweep failed: $(cat "$home/sweep.err")" + exec 7<&- + out=$(cat "$home/sweep.out") + assert_contains "$out" "kept: $sid" "the stale sweep retired a newly captured round: $out" + assert_contains "$out" 'retire refused' "the new capture did not block conditional retirement: $out" + assert_contains "$out" 'while a captured round is unacknowledged' "retirement refused for the wrong reason: $out" + list=$(run_procevent "$home" list) + assert_contains "$list" "$sid" "the source with a newly captured round lost its registration" + assert_present "$home/state/procevent-inbox/$sid.1.result" "the new captured round was deleted" + assert_absent "$home/state/procevent-inbox/$sid.1.handled" "the new captured round was acknowledged" + run_procevent "$home" retire "$sid" >/dev/null \ + || fail "ordinary retirement refused the plain source's captured round" + assert_present "$home/state/procevent-inbox/$sid.1.result" "ordinary retirement deleted the captured round" + assert_absent "$home/state/procevent-inbox/$sid.1.handled" "ordinary retirement acknowledged the captured round" + continue + fi run_procevent "$home" retire "$sid" >/dev/null \ || { : > "$home/sweep-release"; wait "$sweep_pid"; fail "could not retire the snapshotted board"; } if [ "$mode" = worker ]; then @@ -5247,7 +5320,7 @@ SH run_procevent "$home" retire "$sid" >/dev/null \ || fail "ordinary retirement refused the replacement listener" done - pass "stale sweeps preserve rearmed plain and worker-owned listener generations" + pass "stale sweeps preserve replacement generations and newly captured rounds" ) tests=( From 8dae0eb6ed8a9b6cbe90583ac014736e07381e15 Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Fri, 9 Oct 2026 04:54:58 -0500 Subject: [PATCH 05/14] no-mistakes(review): Fix literal inbox activity scanning with regression coverage --- bin/fm-procevent-lavish.sh | 8 ++++- docs/verification/process-event-sources.md | 1 + tests/fm-captain-hold-lifecycle.test.sh | 38 ++++++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/bin/fm-procevent-lavish.sh b/bin/fm-procevent-lavish.sh index f81560f715f..75f2f0c8dce 100755 --- a/bin/fm-procevent-lavish.sh +++ b/bin/fm-procevent-lavish.sh @@ -1073,6 +1073,12 @@ sweep_facts() { # } } my $newest = sub { my $m = 0; for (@_) { my $t = (stat $_)[9]; $m = $t if defined $t && $t > $m } $m }; + my $inbox = "$state/procevent-inbox"; + my @inbox_files; + if (opendir my $dir, $inbox) { + @inbox_files = readdir $dir; + closedir $dir; + } sub unescape { my ($v) = @_; $v =~ s/&#[xX]([0-9a-fA-F]+);/chr(hex($1))/ge; @@ -1093,7 +1099,7 @@ sweep_facts() { # : ($! == ENOENT ? "missing" : "unknown"); my ($sessions, $status, $pending, @keys) = ("unknown", "", 0); my $keys_unknown = 0; - my $activity = $newest->(glob("$state/procevent-inbox/$id.*")); + my $activity = $newest->(map { "$inbox/$_" } grep { /\A\Q$id\E\./ } @inbox_files); if ($file_state eq "present") { my $t = $stat[9]; $activity = $t if $t > $activity; diff --git a/docs/verification/process-event-sources.md b/docs/verification/process-event-sources.md index 853201c1f6f..91b9d46b18b 100644 --- a/docs/verification/process-event-sources.md +++ b/docs/verification/process-event-sources.md @@ -94,6 +94,7 @@ Exercised by `tests/fm-procevent.test.sh` against a fake blocking source whose c | structured reconcile feed | `tests/fm-captain-hold-lifecycle.test.sh` covers the [captured-choice and reconcile contract](../captain-hold-lifecycle.md#how-a-board-selection-creates-a-request); its [reconcile coverage index](../captain-hold-lifecycle.md#reconcile-coverage) names the focused legacy-format and last-choice regressions. | | finished-board sweep | `test_sweep_retires_only_finished_board_listeners` in `tests/fm-captain-hold-lifecycle.test.sh` proves keep guards precede retirement: the standing Bearings board (including a symlink), every board under an unreadable Lavish store (including a missing file), queued feedback, and worker ownership (including ended sessions). After those guards, missing files retire; present boards with missing/ended sessions or past-window idleness retire only with no open or uncheckable captain card. Quoted, unquoted, case-insensitive and entity-encoded keys preserve open holds across all three present-board paths; unreadable files, valueless question attributes and invalid decoded keys keep listeners. Unacknowledged captures and recent activity also keep listeners; dry runs do not retire registrations, the Lavish store remains byte-identical, repeated sweeps are idempotent, and unusable idle windows are refused. `test_sweep_stops_a_live_listener_of_a_finished_board` proves the resident poll of a finished board's armed listener is gone after the sweep | | sweep uncertainty and generation safety | `test_sweep_preserves_deleted_board_keep_guards` covers deleted standing, feedback, queued-feedback, worker-owned and unacknowledged-result boards, including ambiguous session records, deleted standing targets behind absolute, relative and chained symlinks, and unresolved standing symlink cycles or over-limit chains. `test_sweep_keeps_boards_with_unsearchable_parents` exercises actual EACCES in ended, sessionless and idle states. `test_sweep_preserves_rearmed_registration_generations` pauses a sweep after its registration snapshot, retires and re-arms the board through the public interfaces, and verifies both plain and worker-owned replacement registrations and live polls survive the stale retirement. It also creates an unacknowledged capture after the snapshot and verifies locked conditional retirement preserves both the registration and pending result, while ordinary plain retirement remains available and preserves the result. | +| handled-board inbox activity | `test_sweep_keeps_recently_handled_boards_in_literal_home_paths` creates old plain boards and sessions, then acknowledges an old captured round through `handled`. Dry-run and actual sweeps preserve that source's registration in homes containing spaces and glob characters, while an unrelated idle board still retires. | | adapter-owned silence verdict | an ordinary firstmate-owned Lavish source driven against a stand-in poll that returns an empty ended session captures its result, records it durably handled, appends no wake, and stays silent through a later `reconcile` that would otherwise republish it, while still retiring its ended source; the same real path with a `Send & End` response carrying the captain's choice still publishes its `check` wake and is left unacknowledged for the handler | | worker-owned Lavish rounds | one three-round fixture arms a board for an identity-matched task endpoint, delivers nonterminal and terminal captures directly to that task's steering inbox without a firstmate `check` wake, acknowledges each nonterminal round through a successful re-arm, rings the owner's doorbell once when the capture writes a fresh inbox note and never re-rings or resurrects a note the owner has filed into `handled/` across repeated reconciles, refuses a second armer and every early retirement, and concludes the terminal round through `handled` without another poll; focused fixtures also pin synchronous reply acceptance before modern arm returns, failed reply refusal before registration, refused-arm reply isolation, direct poll reply ordering, the legacy poll-with-reply fallback, failed re-arm rollback, one legacy reply post across transient retries, unreachable-owner refusal, interrupted conclusion recovery, and repeat acknowledgement isolation | | Lavish handled-status classification | an executable fixture table pins exact `feedback`, `ended`, `waiting`, and `browser_disconnected` mappings, including `browser_disconnected` to `disconnected`; the same suite proves that status is nonterminal and receives a zero-answer silence verdict | diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index 520bfa63ace..53076ac5777 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -5026,6 +5026,43 @@ test_sweep_retires_only_finished_board_listeners() { pass "the sweep retires finished boards and keeps every board that could still carry an answer" } +test_sweep_keeps_recently_handled_boards_in_literal_home_paths() { + local name home store board sid idle_board idle_sid out list + for name in board-sweep-handled 'board sweep handled' 'board sweep [literal]*?'; do + home=$(make_home "$name") + fm_test_track_procevent_home "$home" "$home/procevent-claims" + store="$home/lavish-state" + board=$(sweep_board "$home" recently-handled old) + idle_board=$(sweep_board "$home" idle old) + sid=$(sweep_register "$home" "$board") + idle_sid=$(sweep_register "$home" "$idle_board") + sweep_session "$store" "$board" open 0 2000-01-01T00:00:00.000Z + sweep_session "$store" "$idle_board" open 0 2000-01-01T00:00:00.000Z + mkdir -p "$home/state/procevent-inbox" + printf 'session:\n status: feedback\n' > "$home/state/procevent-inbox/$sid.1.result" + printf 'lavish\n' > "$home/state/procevent-inbox/$sid.1.adapter" + touch -t 200001010000 "$home/state/procevent-inbox/$sid.1.result" \ + "$home/state/procevent-inbox/$sid.1.adapter" + run_procevent "$home" handled "$sid" 1 >/dev/null \ + || fail "could not acknowledge the captured board round" + assert_present "$home/state/procevent-inbox/$sid.1.handled" \ + "the captured round has no durable acknowledgement" + + out=$(LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep --dry-run) \ + || fail "the handled-board dry run failed: $out" + assert_contains "$out" "kept: $sid" "the dry run missed recent handling in $home: $out" + assert_contains "$out" "would-retire: $idle_sid" "another board inherited recent handling: $out" + out=$(LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep) \ + || fail "the handled-board sweep failed: $out" + assert_contains "$out" "kept: $sid" "recent handling did not keep the listener in $home: $out" + assert_contains "$out" "retired: $idle_sid" "another board inherited recent handling: $out" + list=$(run_procevent "$home" list) + assert_contains "$list" "$sid" "the recently handled listener lost its registration" + assert_not_contains "$list" "$idle_sid" "the idle listener kept its registration" + done + pass "recent acknowledgements keep only their own listeners in literal home paths" +} + # The point of the sweep is the resident processes: a live listener's board is # finished, and after the sweep neither its runner nor its blocked poll remains. # Count of resident `lavish-axi poll` processes serving the live-idle board. @@ -5329,6 +5366,7 @@ test_sweep_stops_a_live_listener_of_a_finished_board test_sweep_preserves_deleted_board_keep_guards test_sweep_keeps_boards_with_unsearchable_parents test_sweep_preserves_rearmed_registration_generations +test_sweep_keeps_recently_handled_boards_in_literal_home_paths test_hold_reason_round_trips_awkward_characters test_hold_origins_precede_backend_holds test_historical_self_inventory_has_workable_repair From 8b5d679bdd41423735516a8e5491d1c3bc6ec4f5 Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Fri, 9 Oct 2026 05:15:25 -0500 Subject: [PATCH 06/14] no-mistakes(review): Keep board listeners when retirement evidence is unreadable --- bin/fm-procevent-lavish.sh | 66 +++++++-------- bin/fm-procevent-lib.sh | 52 ++++++++++++ bin/fm-procevent.sh | 12 ++- docs/verification/process-event-sources.md | 1 + tests/fm-captain-hold-lifecycle.test.sh | 94 ++++++++++++++++++++++ 5 files changed, 189 insertions(+), 36 deletions(-) diff --git a/bin/fm-procevent-lavish.sh b/bin/fm-procevent-lavish.sh index 75f2f0c8dce..807ce4ac061 100755 --- a/bin/fm-procevent-lavish.sh +++ b/bin/fm-procevent-lavish.sh @@ -60,9 +60,7 @@ # An unacknowledged captured round blocks every retirement. # Retiring stops the listener and releases its claim through the # generic `retire`; it never ends the Lavish session, so the board -# stays readable and `arm` brings the listener back. Cost: one perl -# pass over every registration, plus one `fm-captain-hold.sh open` -# per distinct card key of an otherwise-finished board. +# stays readable and `arm` brings the listener back. # poll The registered listener command `arm` publishes, not a command to # run in a conversational turn. It runs the published blocking poll # and prints its response verbatim, absorbing only the one exact @@ -1051,34 +1049,33 @@ board_idle_seconds() { printf '%s\n' $((value * 3600)) } -# One perl pass for every registration: TAB lines on -# stdin, one fact row per line out: -# id, artifact, file state (present, missing, or unknown), sessions (count, or -# "unknown"), status, queued prompts, last-activity epoch, card keys. # `sessions` is "unknown" when Lavish's store cannot be read, so an unreadable # store keeps every board instead of reading as "no session". sweep_facts() { # + local inbox_facts + inbox_facts=$(fm_procevent_inbox_facts "$1" "${@:3}") || return 1 perl -MJSON::PP -MCwd=realpath -MErrno=ENOENT -MEncode=decode,FB_DEFAULT -MTime::Local=timegm -e ' use strict; use warnings; - my ($state, $store) = @ARGV; - my (%by, $store_ok); + my ($inbox_facts, $store) = @ARGV; + my (%by, $store_ok, %inbox); + for my $row (split /\n/, $inbox_facts) { + my ($id, @facts) = split /\t/, $row, 4; + $inbox{$id} = \@facts; + } if (open my $sf, "<", $store) { local $/; - my $doc = eval { decode_json(<$sf>) }; - if (ref($doc) eq "HASH" && ref($doc->{sessions}) eq "HASH") { + local $! = 0; + my $json = <$sf>; + my $read_ok = !$!; + my $close_ok = close $sf; + my $doc = eval { decode_json($json) }; + if ($read_ok && $close_ok && ref($doc) eq "HASH" && ref($doc->{sessions}) eq "HASH") { $store_ok = 1; for my $s (values %{$doc->{sessions}}) { push @{$by{$s->{file}}}, $s if ref($s) eq "HASH" && defined $s->{file} && !ref($s->{file}); } } } - my $newest = sub { my $m = 0; for (@_) { my $t = (stat $_)[9]; $m = $t if defined $t && $t > $m } $m }; - my $inbox = "$state/procevent-inbox"; - my @inbox_files; - if (opendir my $dir, $inbox) { - @inbox_files = readdir $dir; - closedir $dir; - } sub unescape { my ($v) = @_; $v =~ s/&#[xX]([0-9a-fA-F]+);/chr(hex($1))/ge; @@ -1099,7 +1096,8 @@ sweep_facts() { # : ($! == ENOENT ? "missing" : "unknown"); my ($sessions, $status, $pending, @keys) = ("unknown", "", 0); my $keys_unknown = 0; - my $activity = $newest->(map { "$inbox/$_" } grep { /\A\Q$id\E\./ } @inbox_files); + my ($activity, $captured, $evidence) = @{$inbox{$id} // [0, 0, "inbox evidence cannot be read"]}; + $evidence = "the board file cannot be checked" if $file_state eq "unknown"; if ($file_state eq "present") { my $t = $stat[9]; $activity = $t if $t > $activity; @@ -1128,10 +1126,12 @@ sweep_facts() { # push @keys, $k unless $seen{$k}++; } } else { - $keys_unknown = 1; + $evidence = "the board file cannot be read"; } + } elsif ($! == ENOENT) { + $file_state = "missing"; } else { - $keys_unknown = 1; + $evidence = "the board file cannot be opened"; } } if ($store_ok) { @@ -1148,9 +1148,9 @@ sweep_facts() { # } } } - print join("\t", $id, $art, $file_state, $sessions, $status || "-", $pending, $activity, $keys_unknown ? "?" : (join(",", @keys) || "-")), "\n"; + print join("\t", $id, $art, $file_state, $sessions, $status || "-", $pending, $activity, $keys_unknown ? "?" : (join(",", @keys) || "-"), $captured, $evidence), "\n"; } - ' "$1" "$2" + ' "$inbox_facts" "$2" } sweep_cards_closed() { @@ -1180,7 +1180,7 @@ sweep_cards_closed() { cmd_sweep() { local dry=0 idle state reg store now rec id adapter kind artifact standing line n in_argv argv_poll - local pending_ids facts file_state sessions status queued activity keys reason verdict out identity current_identity + local facts file_state sessions status queued activity keys captured evidence reason verdict out identity current_identity local open_cache=$'\n' retired=0 kept=0 idx local -a ids=() kinds=() identities=() case "${1-}" in @@ -1209,9 +1209,7 @@ cmd_sweep() { my $dir = realpath(dirname($p)); print defined($dir) ? "$dir/" . basename($p) : $p; ' "$FM_HOME" 2>/dev/null || true) - pending_ids=$(fm_procevent_pending "$state" | sed -n 's|.*/\([^/.]*\)\.[0-9][0-9]*\.result$|\1|p') - # Read every registration first so one perl pass can answer for all of them. facts='' for rec in "$reg"/lavish-*.source; do [ -f "$rec" ] && [ ! -L "$rec" ] || continue @@ -1242,16 +1240,18 @@ cmd_sweep() { printf 'sweep: retired=0 kept=0\n' return 0 fi - facts=$(printf '%s' "$facts" | sweep_facts "$state" "$store") || die "cannot read the Lavish board facts" + facts=$(printf '%s' "$facts" | sweep_facts "$state" "$store" "${ids[@]}") || die "cannot read the Lavish board facts" - while IFS=$'\t' read -r id artifact file_state sessions status queued activity keys; do + while IFS=$'\t' read -r id artifact file_state sessions status queued activity keys captured evidence; do [ -n "$id" ] || continue kind=plain; identity='' for idx in "${!ids[@]}"; do [ "${ids[$idx]}" = "$id" ] && { kind=${kinds[$idx]}; identity=${identities[$idx]}; break; } done verdict=keep - if [ "$standing" = '?' ]; then + if [ "$evidence" != - ]; then + reason=$evidence + elif [ "$standing" = '?' ]; then reason='the standing Bearings board path cannot be resolved' elif [ -n "$standing" ] && [ "$artifact" = "$standing" ]; then reason='the standing Bearings board' @@ -1261,8 +1261,6 @@ cmd_sweep() { reason='Lavish holds queued feedback for the board' elif [ "$kind" = task-owned ]; then reason='owned by a worker task' - elif [ "$file_state" = unknown ]; then - reason='the board file cannot be checked' elif [ "$file_state" = missing ]; then verdict=retire; reason='the board file is gone' elif [ "$sessions" != 0 ] && [ "$status" != ended ] && [ $((now - activity)) -lt "$idle" ]; then @@ -1279,10 +1277,8 @@ cmd_sweep() { verdict=retire fi fi - if [ "$verdict" = retire ]; then - case $'\n'"$pending_ids"$'\n' in - *$'\n'"$id"$'\n'*) verdict=keep; reason='a captured round of the board is unacknowledged' ;; - esac + if [ "$verdict" = retire ] && [ "$captured" = 1 ]; then + verdict=keep; reason='a captured round of the board is unacknowledged' fi if [ "$verdict" = retire ] && [ "$dry" = 0 ]; then if ! out=$(FM_HOME="$FM_HOME" "$SCRIPT_DIR/fm-procevent.sh" retire "$id" --if-identity "$identity" 2>&1 $activity; + next unless !$symlink && -f _ && $name =~ /\A\Q$id\E\.[0-9]+\.result\z/; + (my $handled = $path) =~ s/\.result\z/.handled/; + my @handled = stat $handled; + if (!@handled) { + if ($! == ENOENT) { + $pending = 1; + } else { + $unknown = "inbox acknowledgement cannot be checked: $!"; + last; + } + } + } + } + print join("\t", $id, $activity, $pending, $unknown || "-"), "\n"; + } + ' "$(fm_procevent_inbox_dir "$state")" "$@" +} + # fm_procevent_event_line # The complete normalized event. Bounded by construction: a fixed verb, a # validated adapter name, and a validated id. No source output, path, or diff --git a/bin/fm-procevent.sh b/bin/fm-procevent.sh index d937ad1166d..71bd41d2a3e 100755 --- a/bin/fm-procevent.sh +++ b/bin/fm-procevent.sh @@ -2162,6 +2162,7 @@ cmd_handled() { cmd_retire() { local id=${1-} condition=${2-} adapter='' sep='' expected_owner='' owner='' pid='' token='' identity='' stop_state owner_state local extension_binding_digest='' round_owner='' expected_identity='' current_identity='' + local inbox_facts inbox_id activity captured evidence fm_procevent_source_id_valid "$id" || die "source id must be path-safe: $id" case "$condition" in '') [ "$#" -eq 1 ] || usage ;; @@ -2194,7 +2195,16 @@ cmd_retire() { fm_procevent_source_lock_release "$id" die "source registration generation changed: $id" fi - if [ -n "$(source_pending "$id" | head -1)" ]; then + if ! inbox_facts=$(fm_procevent_inbox_facts "$STATE" "$id"); then + fm_procevent_source_lock_release "$id" + die "cannot read source $id inbox evidence" + fi + IFS=$'\t' read -r inbox_id activity captured evidence <<< "$inbox_facts" + if [ "$inbox_id" != "$id" ] || [ "$evidence" != - ]; then + fm_procevent_source_lock_release "$id" + die "cannot retire source $id: ${evidence:-inbox evidence cannot be read}" + fi + if [ "$captured" = 1 ]; then fm_procevent_source_lock_release "$id" die "cannot retire source $id while a captured round is unacknowledged" fi diff --git a/docs/verification/process-event-sources.md b/docs/verification/process-event-sources.md index 91b9d46b18b..da2ebe159aa 100644 --- a/docs/verification/process-event-sources.md +++ b/docs/verification/process-event-sources.md @@ -95,6 +95,7 @@ Exercised by `tests/fm-procevent.test.sh` against a fake blocking source whose c | finished-board sweep | `test_sweep_retires_only_finished_board_listeners` in `tests/fm-captain-hold-lifecycle.test.sh` proves keep guards precede retirement: the standing Bearings board (including a symlink), every board under an unreadable Lavish store (including a missing file), queued feedback, and worker ownership (including ended sessions). After those guards, missing files retire; present boards with missing/ended sessions or past-window idleness retire only with no open or uncheckable captain card. Quoted, unquoted, case-insensitive and entity-encoded keys preserve open holds across all three present-board paths; unreadable files, valueless question attributes and invalid decoded keys keep listeners. Unacknowledged captures and recent activity also keep listeners; dry runs do not retire registrations, the Lavish store remains byte-identical, repeated sweeps are idempotent, and unusable idle windows are refused. `test_sweep_stops_a_live_listener_of_a_finished_board` proves the resident poll of a finished board's armed listener is gone after the sweep | | sweep uncertainty and generation safety | `test_sweep_preserves_deleted_board_keep_guards` covers deleted standing, feedback, queued-feedback, worker-owned and unacknowledged-result boards, including ambiguous session records, deleted standing targets behind absolute, relative and chained symlinks, and unresolved standing symlink cycles or over-limit chains. `test_sweep_keeps_boards_with_unsearchable_parents` exercises actual EACCES in ended, sessionless and idle states. `test_sweep_preserves_rearmed_registration_generations` pauses a sweep after its registration snapshot, retires and re-arms the board through the public interfaces, and verifies both plain and worker-owned replacement registrations and live polls survive the stale retirement. It also creates an unacknowledged capture after the snapshot and verifies locked conditional retirement preserves both the registration and pending result, while ordinary plain retirement remains available and preserves the result. | | handled-board inbox activity | `test_sweep_keeps_recently_handled_boards_in_literal_home_paths` creates old plain boards and sessions, then acknowledges an old captured round through `handled`. Dry-run and actual sweeps preserve that source's registration in homes containing spaces and glob characters, while an unrelated idle board still retires. | +| unknown sweep inbox evidence | `test_sweep_keeps_unknown_inbox_evidence` exercises non-listable mode-0300 inboxes containing unacknowledged captures across idle, ended, sessionless and missing-file boards. Dry-run and actual sweeps keep registrations and log the read error; identity-conditional retirement also refuses under the source lock. Result, adapter and handled-marker stat errors keep the affected source without preventing an unrelated idle board from retiring. Sweeps neither acknowledge nor delete the captures; confirmed inbox absence permits retirement again. | | adapter-owned silence verdict | an ordinary firstmate-owned Lavish source driven against a stand-in poll that returns an empty ended session captures its result, records it durably handled, appends no wake, and stays silent through a later `reconcile` that would otherwise republish it, while still retiring its ended source; the same real path with a `Send & End` response carrying the captain's choice still publishes its `check` wake and is left unacknowledged for the handler | | worker-owned Lavish rounds | one three-round fixture arms a board for an identity-matched task endpoint, delivers nonterminal and terminal captures directly to that task's steering inbox without a firstmate `check` wake, acknowledges each nonterminal round through a successful re-arm, rings the owner's doorbell once when the capture writes a fresh inbox note and never re-rings or resurrects a note the owner has filed into `handled/` across repeated reconciles, refuses a second armer and every early retirement, and concludes the terminal round through `handled` without another poll; focused fixtures also pin synchronous reply acceptance before modern arm returns, failed reply refusal before registration, refused-arm reply isolation, direct poll reply ordering, the legacy poll-with-reply fallback, failed re-arm rollback, one legacy reply post across transient retries, unreachable-owner refusal, interrupted conclusion recovery, and repeat acknowledgement isolation | | Lavish handled-status classification | an executable fixture table pins exact `feedback`, `ended`, `waiting`, and `browser_disconnected` mappings, including `browser_disconnected` to `disconnected`; the same suite proves that status is nonterminal and receives a zero-answer silence verdict | diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index 53076ac5777..6ecd7424a0d 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -5263,6 +5263,99 @@ test_sweep_keeps_boards_with_unsearchable_parents() { pass "filesystem permission errors keep ended, sessionless and idle board listeners" } +test_sweep_keeps_unknown_inbox_evidence() { + local home store board sid inbox session identity dry_out out locked_out rc dry_rc sweep_rc fixture_rc list + local role blocked healthy healthy_id + for session in idle ended nosession missing; do + home=$(make_home "board-sweep-inbox-unreadable-$session") + fm_test_track_procevent_home "$home" "$home/procevent-claims" + store="$home/lavish-state" + mkdir -p "$store" + printf '{"sessions":{}}\n' > "$store/state.json" + board=$(sweep_board "$home" unreadable-inbox old) + sid=$(sweep_register "$home" "$board") + case "$session" in + ended) sweep_session "$store" "$board" ended 0 - ;; + idle|missing) sweep_session "$store" "$board" open 0 2000-01-01T00:00:00.000Z ;; + esac + [ "$session" != missing ] || rm -f "$board" + identity=$(perl -e 'my @s = stat $ARGV[0]; die $! unless @s; print "$s[0]:$s[1]"' "$home/state/procevent/$sid.source") + inbox="$home/state/procevent-inbox" + mkdir -p "$inbox" + printf 'session:\n status: feedback\n' > "$inbox/$sid.1.result" + printf 'lavish\n' > "$inbox/$sid.1.adapter" + touch -t 200001010000 "$inbox/$sid.1.result" "$inbox/$sid.1.adapter" + chmod 0300 "$inbox" + perl -MErrno=EACCES -e 'opendir(my $d, $ARGV[0]); exit($! == EACCES ? 0 : 1)' "$inbox" + fixture_rc=$? + dry_out=$(LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep --dry-run) + dry_rc=$? + out=$(LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep) + sweep_rc=$? + if locked_out=$(run_procevent "$home" retire "$sid" --if-identity "$identity" 2>&1); then rc=0; else rc=$?; fi + chmod 0700 "$inbox" + [ "$fixture_rc" -eq 0 ] || fail "the non-listable inbox did not produce EACCES" + [ "$dry_rc" -eq 0 ] && [ "$sweep_rc" -eq 0 ] || fail "the unreadable-inbox sweep failed: $dry_out $out" + assert_contains "$dry_out" "kept: $sid" "a dry run would retire an unreadable $session inbox: $dry_out" + assert_contains "$out" "kept: $sid" "an unreadable $session inbox lost its listener: $out" + assert_contains "$out" 'inbox cannot be opened' "the inbox read error was not logged: $out" + [ "$rc" -ne 0 ] || fail "locked retirement accepted an unreadable inbox" + assert_contains "$locked_out" 'inbox cannot be opened' "locked retirement lost the inbox error: $locked_out" + list=$(run_procevent "$home" list) + assert_contains "$list" "$sid" "an unreadable inbox lost its registration" + assert_present "$inbox/$sid.1.result" "the unacknowledged capture was deleted" + assert_absent "$inbox/$sid.1.handled" "the unreadable capture was acknowledged" + rm -f "$inbox/$sid.1.result" "$inbox/$sid.1.adapter" + rmdir "$inbox" + out=$(LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep) || fail "the absent-inbox sweep failed: $out" + assert_contains "$out" "retired: $sid" "confirmed inbox absence kept a finished $session board: $out" + assert_absent "$home/state/procevent/$sid.source" "the absent-inbox source remains registered" + done + for role in result adapter handled; do + home=$(make_home "board-sweep-inbox-stat-$role") + fm_test_track_procevent_home "$home" "$home/procevent-claims" + store="$home/lavish-state" + board=$(sweep_board "$home" stat-error old) + sid=$(sweep_register "$home" "$board") + sweep_session "$store" "$board" open 0 2000-01-01T00:00:00.000Z + healthy=$(sweep_board "$home" healthy old) + healthy_id=$(sweep_register "$home" "$healthy") + sweep_session "$store" "$healthy" open 0 2000-01-01T00:00:00.000Z + identity=$(perl -e 'my @s = stat $ARGV[0]; die $! unless @s; print "$s[0]:$s[1]"' "$home/state/procevent/$sid.source") + inbox="$home/state/procevent-inbox" + blocked="$home/blocked" + mkdir -p "$inbox" "$blocked" + printf 'captured\n' > "$blocked/entry" + [ "$role" != handled ] || printf 'session:\n status: feedback\n' > "$inbox/$sid.1.result" + ln -s "$blocked/entry" "$inbox/$sid.1.$role" + [ "$role" != handled ] || touch -t 200001010000 "$inbox/$sid.1.result" + chmod 000 "$blocked" + perl -MErrno=EACCES -e 'stat($ARGV[0]); exit($! == EACCES ? 0 : 1)' "$inbox/$sid.1.$role" + fixture_rc=$? + dry_out=$(LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep --dry-run) + dry_rc=$? + out=$(LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep) + sweep_rc=$? + if locked_out=$(run_procevent "$home" retire "$sid" --if-identity "$identity" 2>&1); then rc=0; else rc=$?; fi + chmod 0700 "$blocked" + [ "$fixture_rc" -eq 0 ] || fail "the inbox $role fixture did not produce a stat error" + [ "$dry_rc" -eq 0 ] && [ "$sweep_rc" -eq 0 ] || fail "the stat-error sweep failed: $dry_out $out" + assert_contains "$dry_out" "kept: $sid" "a dry run ignored an inbox $role stat error: $dry_out" + assert_contains "$out" "kept: $sid" "an inbox $role stat error lost its listener: $out" + assert_contains "$out" 'cannot be checked' "the inbox stat error was not logged: $out" + assert_contains "$dry_out" "would-retire: $healthy_id" "one source's stat error kept an unrelated idle board: $dry_out" + assert_contains "$out" "retired: $healthy_id" "one source's stat error kept an unrelated idle board: $out" + [ "$rc" -ne 0 ] || fail "locked retirement accepted an inbox $role stat error" + assert_contains "$locked_out" 'cannot be checked' "locked retirement lost the inbox stat error: $locked_out" + assert_present "$home/state/procevent/$sid.source" "an uncertain inbox source lost its registration" + rm -f "$inbox/$sid.1.$role" "$inbox/$sid.1.result" + rmdir "$inbox" + out=$(LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep) || fail "the recovered-inbox sweep failed: $out" + assert_contains "$out" "retired: $sid" "the recovered idle board was not retired: $out" + done + pass "unknown inbox evidence keeps listeners at sweep and locked retirement boundaries" +} + test_sweep_preserves_rearmed_registration_generations() ( local mode home store board sid real_perl sweep_pid poll_pid out list rc real_perl=$(command -v perl) @@ -5367,6 +5460,7 @@ test_sweep_preserves_deleted_board_keep_guards test_sweep_keeps_boards_with_unsearchable_parents test_sweep_preserves_rearmed_registration_generations test_sweep_keeps_recently_handled_boards_in_literal_home_paths +test_sweep_keeps_unknown_inbox_evidence test_hold_reason_round_trips_awkward_characters test_hold_origins_precede_backend_holds test_historical_self_inventory_has_workable_repair From ec81295a3b1be7a74b3eebfc55c5d06c27ed06c9 Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Fri, 9 Oct 2026 05:38:29 -0500 Subject: [PATCH 07/14] no-mistakes(review): Scope live-listener test observations to recorded poll PID --- tests/fm-captain-hold-lifecycle.test.sh | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index 6ecd7424a0d..bd2c601da95 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -5065,13 +5065,8 @@ test_sweep_keeps_recently_handled_boards_in_literal_home_paths() { # The point of the sweep is the resident processes: a live listener's board is # finished, and after the sweep neither its runner nor its blocked poll remains. -# Count of resident `lavish-axi poll` processes serving the live-idle board. -live_idle_polls() { - pgrep -f "lavish-axi poll .*board-sweep-live/boards/live-idle.html" | wc -l | tr -d ' ' -} - test_sweep_stops_a_live_listener_of_a_finished_board() { - local home store artifact sid out list tries + local home store artifact sid out list tries poll_pid home=$(make_home board-sweep-live) fm_test_track_procevent_home "$home" "$home/procevent-claims" store="$home/lavish-state" @@ -5079,6 +5074,7 @@ test_sweep_stops_a_live_listener_of_a_finished_board() { cat > "$home/fakebin/lavish-axi" <<'SH' #!/usr/bin/env bash # A poll that blocks until it is killed; bounded so an escaped stub cannot linger. +printf '%s\n' "$$" > "$FM_HOME/poll.pid" for _ in $(seq 1 1800); do sleep 0.1; done SH chmod +x "$home/fakebin/lavish-axi" @@ -5091,22 +5087,24 @@ SH assert_contains "$list" "live" "the armed listener is not running" # Armed means claimed; the poll process follows the claim, so wait for it. tries=0 - while [ "$(live_idle_polls)" -eq 0 ] \ - && [ "$tries" -lt 300 ]; do + while [ ! -s "$home/poll.pid" ] && [ "$tries" -lt 300 ]; do tries=$((tries + 1)) sleep 0.1 done - [ "$(live_idle_polls)" -ge 1 ] \ + [ -s "$home/poll.pid" ] \ || fail "the armed listener has no resident poll to retire" + poll_pid=$(cat "$home/poll.pid") + kill -0 "$poll_pid" 2>/dev/null \ + || fail "the armed listener's poll is not running" out=$(LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep) || fail "the sweep failed: $out" assert_contains "$out" "retired: $sid" "the idle board's live listener was kept" tries=0 - while [ "$(live_idle_polls)" -ne 0 ] && [ "$tries" -lt 100 ]; do + while kill -0 "$poll_pid" 2>/dev/null && [ "$tries" -lt 100 ]; do tries=$((tries + 1)) sleep 0.1 done - [ "$(live_idle_polls)" -eq 0 ] \ + ! kill -0 "$poll_pid" 2>/dev/null \ || fail "the retired listener's poll is still resident" list=$(run_procevent "$home" list) assert_not_contains "$list" "$sid" "the retired listener is still registered" From c750997b2d64b99cbd40f96389a77eb1cce82f23 Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Fri, 9 Oct 2026 05:57:34 -0500 Subject: [PATCH 08/14] no-mistakes(review): Wait for nonempty poll PID before generation assertions --- tests/fm-captain-hold-lifecycle.test.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index bd2c601da95..ee39613b9de 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -5355,7 +5355,7 @@ test_sweep_keeps_unknown_inbox_evidence() { } test_sweep_preserves_rearmed_registration_generations() ( - local mode home store board sid real_perl sweep_pid poll_pid out list rc + local mode home store board sid real_perl sweep_pid poll_pid out list rc tries real_perl=$(command -v perl) for mode in plain worker pending; do home=$(make_home "board-sweep-rearmed-$mode") @@ -5430,7 +5430,12 @@ SH wait "$sweep_pid" 2>/dev/null || true fail "could not re-arm the $mode replacement: $out" fi - if ! wait_for_test_file "$home/poll.pid" "$sweep_pid"; then + tries=0 + while [ ! -s "$home/poll.pid" ] && [ "$tries" -lt 300 ]; do + tries=$((tries + 1)) + sleep 0.1 + done + if [ ! -s "$home/poll.pid" ]; then : > "$home/sweep-release" wait "$sweep_pid" 2>/dev/null || true fail "the replacement listener did not start polling" From a0ca95035a6fa6368e51c5f4a8246faf8600cd24 Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Fri, 9 Oct 2026 07:56:10 -0500 Subject: [PATCH 09/14] no-mistakes(review): Keep uncertain decks and resolve bound captain keys safely --- bin/fm-captain-hold.sh | 53 ++-- bin/fm-procevent-lavish.sh | 42 +-- docs/captain-hold-lifecycle.md | 1 + docs/configuration.md | 4 +- docs/verification/process-event-sources.md | 1 + tests/fm-captain-hold-lifecycle.test.sh | 286 ++++++++++++++++++++- 6 files changed, 343 insertions(+), 44 deletions(-) diff --git a/bin/fm-captain-hold.sh b/bin/fm-captain-hold.sh index c80b9979c34..a7fd8a13f34 100755 --- a/bin/fm-captain-hold.sh +++ b/bin/fm-captain-hold.sh @@ -31,6 +31,7 @@ # fm-captain-hold.sh complete (--none | ...) # fm-captain-hold.sh verify # fm-captain-hold.sh open [--identity] [--distinguish-absent] +# fm-captain-hold.sh open-bound # fm-captain-hold.sh diverged # fm-captain-hold.sh reconcile list # fm-captain-hold.sh reconcile close --evidence-file @@ -650,7 +651,7 @@ captain_migration_scan_load() { # # guess, so it only runs when no marker line matches any identity and it accepts # a row solely when that row is itself still held for the captain. resolve_migrated_entry() { # - local origin=$1 entry=$2 data root entries prefix derived show backend + local origin=$1 entry=$2 data root entries prefix derived backend local candidate candidate_matches prefixed matches count prefixed_matches prefixed_count data=$(fm_backlog_data_absolute "$DATA") || { printf 'fm-captain-hold: the migrated hold of %s cannot be resolved: %s\n' \ @@ -711,14 +712,15 @@ resolve_migrated_entry() { # *-) prefixed="$prefix$candidate" ;; *) prefixed="$prefix-$candidate" ;; esac - # Same shell rule as task_show_or_fail: the row is read out of - # TASK_SHOW_OUTPUT, so the read cannot sit inside a command substitution. - task_show "$prefixed" 2>/dev/null || { - [ "$?" -ne 124 ] || return 124 + if ! fm_backlog_row_probe "$data" "$prefixed"; then + [ "$FM_BACKLOG_ROW_SHOW_WEDGED" != 1 ] || return 124 + [ "$FM_BACKLOG_ROW_RESULT" = not_found ] || { + printf 'fm-captain-hold: %s\n' "$FM_BACKLOG_ROW_ERROR" >&2 + return 2 + } continue - } - show=$TASK_SHOW_OUTPUT - [ "$(show_field_value "$show" hold_kind)" = captain ] || continue + fi + [ "$FM_BACKLOG_ROW_HOLD_KIND" = captain ] || continue prefixed_matches="${prefixed_matches}${prefixed_matches:+$NL_SEP}$prefixed" done prefixed_count=$(printf '%s\n' "$prefixed_matches" | sed '/^$/d' | wc -l | tr -d ' ') @@ -737,17 +739,28 @@ resolve_migrated_entry() { # # Prints " ", where is exact, legacy, migrated-note or # migrated-prefix, so a caller can record which evidence carried the attestation. resolve_entry() { # ; prints " " or fails - local origin=$1 entry=$2 legacy migrated rc - if task_show "$entry"; then + local origin=$1 entry=$2 legacy migrated rc data + data=$(fm_backlog_data_absolute "$DATA") || return 2 + if fm_backlog_row_probe "$data" "$entry"; then printf '%s exact' "$entry" return 0 fi + [ "$FM_BACKLOG_ROW_SHOW_WEDGED" != 1 ] || return 124 + [ "$FM_BACKLOG_ROW_RESULT" = not_found ] || { + printf 'fm-captain-hold: %s\n' "$FM_BACKLOG_ROW_ERROR" >&2 + return 2 + } if [ -n "$origin" ] && [ "$origin" != "$BINDING_ANY" ]; then legacy=$(legacy_hold_id "$origin" "$entry") - if task_show "$legacy"; then + if fm_backlog_row_probe "$data" "$legacy"; then printf '%s legacy' "$legacy" return 0 fi + [ "$FM_BACKLOG_ROW_SHOW_WEDGED" != 1 ] || return 124 + [ "$FM_BACKLOG_ROW_RESULT" = not_found ] || { + printf 'fm-captain-hold: %s\n' "$FM_BACKLOG_ROW_ERROR" >&2 + return 2 + } fi rc=0 migrated=$(resolve_migrated_entry "$origin" "$entry") || rc=$? @@ -1377,10 +1390,6 @@ command_answers() { continue fi if [ "$resolve_rc" -ne 0 ]; then - # resolve_entry runs in a command substitution, so task_show's exit - # cannot stop this loop; only its status crosses back. 124 means the - # backend never answered, which is not the same as an unknown key and - # must not be spent as a skip. [ "$resolve_rc" -ne 124 ] \ || fail "the backlog backend exceeded its read bound resolving $key" printf 'skipped: %s (no captain-held task with that id)\n' "$key" @@ -2038,6 +2047,19 @@ command_open() { # [--identity] [--distinguish-absent] exit 2 } +command_open_bound() { + local source=${1:-} key=${2:-} origin resolved rc=0 + [ "$#" -eq 2 ] || return 2 + (validate_source_id "$source"; validate_slug card-key "$key") || return 2 + origin=$(read_binding "$source") || return 2 + resolved=$(resolve_entry "$origin" "$key") || return 2 + command_open "${resolved%% *}" --distinguish-absent || rc=$? + case "$rc" in + 0|1) return "$rc" ;; + *) return 2 ;; + esac +} + case "${1:-}" in hold) shift; command_hold "$@" ;; answer) shift; command_answer "$@" ;; @@ -2049,6 +2071,7 @@ case "${1:-}" in complete) shift; command_complete "$@" ;; verify) shift; command_verify "$@" ;; open) shift; command_open "$@" ;; + open-bound) shift; command_open_bound "$@" ;; diverged) shift; command_diverged "$@" ;; reconcile) shift; command_reconcile "$@" ;; -h|--help) usage ;; diff --git a/bin/fm-procevent-lavish.sh b/bin/fm-procevent-lavish.sh index 807ce4ac061..4c18f673d54 100755 --- a/bin/fm-procevent-lavish.sh +++ b/bin/fm-procevent-lavish.sh @@ -50,10 +50,11 @@ # card key is known not to name an open captain call. Other boards # must also be idle for FM_BOARD_LISTENER_IDLE_HOURS (default 48, # whole hours 1..8760): no change to the artifact, the Lavish session -# or a captured round. Unreadable card keys keep a present board. -# `bin/fm-captain-hold.sh open` exit 0 (open), exit 2 (cannot tell), -# or any unexpected exit keeps the board; exit 1 (closed) and exit 3 -# (absent from this home's backlog) permit retirement. The durable +# or a captured round. Empty, unreadable or unsupported card discovery +# keeps a present board, including scripts and event handlers. +# `bin/fm-captain-hold.sh open-bound` checks keys in source-binding +# context; only exit 1 (resolved and closed) permits retirement. +# Open calls, unresolved keys and read errors keep the board. The durable # record of a captain call is its held task, and the standing board # lists every held call. A call held in another home's backlog is # invisible here, so dormancy is the safeguard for live sessions. @@ -1110,14 +1111,24 @@ sweep_facts() { # if ($read_ok && $close_ok) { $html //= ""; my %seen; + $html =~ s{|\z)}{}gs; while ($html =~ m{<([A-Za-z][\w:-]*)(?=[\s/>])}g) { + my $tag = lc $1; my %attr; while ($html =~ m{\G\s*([^\s"\x27<>/=]+)(?:\s*=\s*(?:"([^"]*)"|\x27([^\x27]*)\x27|([^\s"\x27=<>`]+)))?}gc) { my $name = lc $1; $attr{$name} //= unescape($2 // $3 // $4 // ""); } - $html =~ m{\G\s*/?>}gc; - next unless exists $attr{"data-lavish-question"}; + if ($html !~ m{\G\s*/?>}gc + || $tag =~ /\A(?:script|iframe|object|embed|template)\z/ + || grep { /\Aon/i || lc($_) eq "srcdoc" || $attr{$_} =~ /\A\s*javascript:/i } keys %attr) { + $keys_unknown = 1; + last; + } + if (!exists $attr{"data-lavish-question"}) { + $keys_unknown = 1 if $tag eq "form"; + next; + } my $k = $attr{"data-lavish-question"}; if ($k !~ /\A[A-Za-z0-9._-]{1,128}\z/) { $keys_unknown = 1; @@ -1125,6 +1136,7 @@ sweep_facts() { # } push @keys, $k unless $seen{$k}++; } + $keys_unknown = 1 unless @keys; } else { $evidence = "the board file cannot be read"; } @@ -1154,24 +1166,24 @@ sweep_facts() { # } sweep_cards_closed() { - local keys=$1 key rc IFS=, - if [ "$keys" = '?' ]; then - reason="the board's card keys cannot be read" + local source=$1 keys=$2 key cache_key rc IFS=, + if [ "$keys" = '?' ] || [ "$keys" = '-' ] || [ -z "$keys" ]; then + reason="the board's complete card-key set cannot be established" return 1 fi for key in $keys; do - [ "$key" != - ] || continue + cache_key="$source:$key" case "$open_cache" in - *$'\n'"$key="[0-3]$'\n'*) ;; + *$'\n'"$cache_key="[0-2]$'\n'*) ;; *) - FM_HOME="$FM_HOME" "$SCRIPT_DIR/fm-captain-hold.sh" open "$key" --distinguish-absent \ + FM_HOME="$FM_HOME" "$SCRIPT_DIR/fm-captain-hold.sh" open-bound "$source" "$key" \ >/dev/null 2>&1 -decision-`. - A channel key that names no task resolves the same way when the source's binding carries a concrete legacy origin. +- The read-only `bin/fm-captain-hold.sh open-bound ` predicate applies that same source binding and resolution order before checking the resolved task: exit 0 is open, exit 1 is known closed or no longer captain-held, and exit 2 is unresolved or unreadable. It changes no task or binding; listener sweeps permit retirement only on exit 1. - Resolution records written by the old script are recognized wherever a record is read. ### Legacy ids on the Beads backend diff --git a/docs/configuration.md b/docs/configuration.md index 08e72b7376d..24876c63dda 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2446,8 +2446,8 @@ The sweep first keeps the standing Bearings board, every board when the Lavish s The standing-board exemption survives deleted symlink targets; an unresolved symlink chain keeps every listener until its identity can be resolved. After those guards, a missing board file permits retirement; a present board with a missing or ended session permits retirement only when every card key is known not to name an open captain call. Other present boards must also pass the idle window below. -Unquoted and quoted card keys, case-insensitive attribute names, and HTML entities are recognized; an unreadable board, valueless question attribute, or invalid decoded key keeps the listener. Ambiguous session records also keep the listener. Only confirmed file absence permits missing-file retirement; filesystem permission and canonicalization errors keep the listener. -An open or uncheckable captain call keeps the listener, and an unacknowledged captured round blocks every retirement. +Unquoted and quoted card keys, case-insensitive attribute names, and HTML entities are recognized. Zero static keys, scripts, event handlers, embedded surfaces, unkeyed forms, malformed markup, an unreadable board, a valueless question attribute, or an invalid decoded key leave discovery unknown and keep the listener; static matches never establish completeness alongside unsupported markup. Ambiguous session records also keep the listener. Only confirmed file absence permits missing-file retirement; filesystem permission and canonicalization errors keep the listener. +For each key, `fm-captain-hold.sh open-bound ` reads the source binding and uses the answer intake's exact, legacy-derived, migration-note, then migration-prefix resolution precedence. Only a resolved task known not to be an open captain call permits retirement; unresolved, ambiguous, or unreadable evidence keeps the listener. Predicate results are cached per source and key. An unacknowledged captured round blocks every retirement. `FM_BOARD_LISTENER_IDLE_HOURS` (default 48, whole hours 1..8760) sets how long a board must sit untouched before the sweep may retire it. An unusable value makes the sweep refuse by name. diff --git a/docs/verification/process-event-sources.md b/docs/verification/process-event-sources.md index da2ebe159aa..7d149a16854 100644 --- a/docs/verification/process-event-sources.md +++ b/docs/verification/process-event-sources.md @@ -93,6 +93,7 @@ Exercised by `tests/fm-procevent.test.sh` against a fake blocking source whose c | generic built-in keyed-answer feed | `tests/fm-captain-hold-lifecycle.test.sh` drives a bound built-in source through the real runner with a fixture adapter that only prints keyed lines, proving any bound built-in channel reaches the one keyed-answer intake: named captain-held tasks close at capture time, a card-declared release mode frees held work, keys naming no captain-held task skip, freeform prose forges nothing, matching answer-and-mode replays are idempotent while mode mismatches refuse, an unbound source closes nothing, and capture remains independent of the handler wake. | | structured reconcile feed | `tests/fm-captain-hold-lifecycle.test.sh` covers the [captured-choice and reconcile contract](../captain-hold-lifecycle.md#how-a-board-selection-creates-a-request); its [reconcile coverage index](../captain-hold-lifecycle.md#reconcile-coverage) names the focused legacy-format and last-choice regressions. | | finished-board sweep | `test_sweep_retires_only_finished_board_listeners` in `tests/fm-captain-hold-lifecycle.test.sh` proves keep guards precede retirement: the standing Bearings board (including a symlink), every board under an unreadable Lavish store (including a missing file), queued feedback, and worker ownership (including ended sessions). After those guards, missing files retire; present boards with missing/ended sessions or past-window idleness retire only with no open or uncheckable captain card. Quoted, unquoted, case-insensitive and entity-encoded keys preserve open holds across all three present-board paths; unreadable files, valueless question attributes and invalid decoded keys keep listeners. Unacknowledged captures and recent activity also keep listeners; dry runs do not retire registrations, the Lavish store remains byte-identical, repeated sweeps are idempotent, and unusable idle windows are refused. `test_sweep_stops_a_live_listener_of_a_finished_board` proves the resident poll of a finished board's armed listener is gone after the sweep | +| sweep card discovery and bound routing | `test_sweep_keeps_unknown_card_discovery` executes dry-run and actual sweeps across ended, sessionless and idle boards: zero static keys, dynamic and mixed decks, scripts, handlers, embedded surfaces, unkeyed forms and malformed markup keep listeners; complete static closed-card sets retire. `test_sweep_resolves_bound_captain_keys` exercises exact-before-legacy resolution, concrete and any-origin bindings, unresolved and corrupt-binding evidence, source-local caching, and retirement after a legacy answer reaches its derived task. `test_sweep_resolves_migrated_captain_keys` uses isolated Beads backend fixtures to exercise note and prefix resolution for raw and derived keys, marker precedence over a held prefix namesake, ambiguity, and exact/derived/prefix read errors. | | sweep uncertainty and generation safety | `test_sweep_preserves_deleted_board_keep_guards` covers deleted standing, feedback, queued-feedback, worker-owned and unacknowledged-result boards, including ambiguous session records, deleted standing targets behind absolute, relative and chained symlinks, and unresolved standing symlink cycles or over-limit chains. `test_sweep_keeps_boards_with_unsearchable_parents` exercises actual EACCES in ended, sessionless and idle states. `test_sweep_preserves_rearmed_registration_generations` pauses a sweep after its registration snapshot, retires and re-arms the board through the public interfaces, and verifies both plain and worker-owned replacement registrations and live polls survive the stale retirement. It also creates an unacknowledged capture after the snapshot and verifies locked conditional retirement preserves both the registration and pending result, while ordinary plain retirement remains available and preserves the result. | | handled-board inbox activity | `test_sweep_keeps_recently_handled_boards_in_literal_home_paths` creates old plain boards and sessions, then acknowledges an old captured round through `handled`. Dry-run and actual sweeps preserve that source's registration in homes containing spaces and glob characters, while an unrelated idle board still retires. | | unknown sweep inbox evidence | `test_sweep_keeps_unknown_inbox_evidence` exercises non-listable mode-0300 inboxes containing unacknowledged captures across idle, ended, sessionless and missing-file boards. Dry-run and actual sweeps keep registrations and log the read error; identity-conditional retirement also refuses under the source lock. Result, adapter and handled-marker stat errors keep the affected source without preventing an unrelated idle board from retiring. Sweeps neither acknowledge nor delete the captures; confirmed inbox absence permits retirement again. | diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index ee39613b9de..c744a8fe4fc 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -4877,9 +4877,9 @@ test_sweep_retires_only_finished_board_listeners() { || fail "could not answer the call" gone=$(sweep_board "$home" gone old) - ended=$(sweep_board "$home" ended new) - nosession=$(sweep_board "$home" nosession new) - dormant=$(sweep_board "$home" dormant old sweep-absent-card) + ended=$(sweep_board "$home" ended new sweep-closed-call) + nosession=$(sweep_board "$home" nosession new sweep-closed-call) + dormant=$(sweep_board "$home" dormant old sweep-closed-call) closed=$(sweep_board "$home" closed old sweep-closed-call) openheld=$(sweep_board "$home" openheld old sweep-open-call) fresh=$(sweep_board "$home" fresh new) @@ -4890,7 +4890,7 @@ test_sweep_retires_only_finished_board_listeners() { printf '

standing

\n' > "$standing" ln -s "$standing" "$home/.lavish/bearings-board.html" touch -t 200001010000 "$standing" - pending=$(sweep_board "$home" pending old) + pending=$(sweep_board "$home" pending old sweep-closed-call) gone_id=$(sweep_register "$home" "$gone") ended_id=$(sweep_register "$home" "$ended") @@ -4999,7 +4999,7 @@ test_sweep_retires_only_finished_board_listeners() { # A store the sweep cannot read proves nothing about any board's session, so # a board that would otherwise be retired keeps its listener. - unreadable=$(sweep_board "$home" unreadable old) + unreadable=$(sweep_board "$home" unreadable old sweep-closed-call) unreadable_id=$(sweep_register "$home" "$unreadable") sweep_session "$store" "$unreadable" open 0 2000-01-01T00:00:00.000Z gone=$(sweep_board "$home" gone-store-unreadable old) @@ -5031,9 +5031,11 @@ test_sweep_keeps_recently_handled_boards_in_literal_home_paths() { for name in board-sweep-handled 'board sweep handled' 'board sweep [literal]*?'; do home=$(make_home "$name") fm_test_track_procevent_home "$home" "$home/procevent-claims" + tasks_in "$home" add sweep-finished-card "Finished board card" >/dev/null \ + || fail "could not create the non-held card" store="$home/lavish-state" - board=$(sweep_board "$home" recently-handled old) - idle_board=$(sweep_board "$home" idle old) + board=$(sweep_board "$home" recently-handled old sweep-finished-card) + idle_board=$(sweep_board "$home" idle old sweep-finished-card) sid=$(sweep_register "$home" "$board") idle_sid=$(sweep_register "$home" "$idle_board") sweep_session "$store" "$board" open 0 2000-01-01T00:00:00.000Z @@ -5068,9 +5070,11 @@ test_sweep_keeps_recently_handled_boards_in_literal_home_paths() { test_sweep_stops_a_live_listener_of_a_finished_board() { local home store artifact sid out list tries poll_pid home=$(make_home board-sweep-live) + tasks_in "$home" add sweep-finished-card "Finished board card" >/dev/null \ + || fail "could not create the non-held card" fm_test_track_procevent_home "$home" "$home/procevent-claims" store="$home/lavish-state" - artifact=$(sweep_board "$home" live-idle old) + artifact=$(sweep_board "$home" live-idle old sweep-finished-card) cat > "$home/fakebin/lavish-axi" <<'SH' #!/usr/bin/env bash # A poll that blocks until it is killed; bounded so an escaped stub cannot linger. @@ -5266,11 +5270,13 @@ test_sweep_keeps_unknown_inbox_evidence() { local role blocked healthy healthy_id for session in idle ended nosession missing; do home=$(make_home "board-sweep-inbox-unreadable-$session") + tasks_in "$home" add sweep-finished-card "Finished board card" >/dev/null \ + || fail "could not create the non-held card" fm_test_track_procevent_home "$home" "$home/procevent-claims" store="$home/lavish-state" mkdir -p "$store" printf '{"sessions":{}}\n' > "$store/state.json" - board=$(sweep_board "$home" unreadable-inbox old) + board=$(sweep_board "$home" unreadable-inbox old sweep-finished-card) sid=$(sweep_register "$home" "$board") case "$session" in ended) sweep_session "$store" "$board" ended 0 - ;; @@ -5311,12 +5317,14 @@ test_sweep_keeps_unknown_inbox_evidence() { done for role in result adapter handled; do home=$(make_home "board-sweep-inbox-stat-$role") + tasks_in "$home" add sweep-finished-card "Finished board card" >/dev/null \ + || fail "could not create the non-held card" fm_test_track_procevent_home "$home" "$home/procevent-claims" store="$home/lavish-state" - board=$(sweep_board "$home" stat-error old) + board=$(sweep_board "$home" stat-error old sweep-finished-card) sid=$(sweep_register "$home" "$board") sweep_session "$store" "$board" open 0 2000-01-01T00:00:00.000Z - healthy=$(sweep_board "$home" healthy old) + healthy=$(sweep_board "$home" healthy old sweep-finished-card) healthy_id=$(sweep_register "$home" "$healthy") sweep_session "$store" "$healthy" open 0 2000-01-01T00:00:00.000Z identity=$(perl -e 'my @s = stat $ARGV[0]; die $! unless @s; print "$s[0]:$s[1]"' "$home/state/procevent/$sid.source") @@ -5359,9 +5367,11 @@ test_sweep_preserves_rearmed_registration_generations() ( real_perl=$(command -v perl) for mode in plain worker pending; do home=$(make_home "board-sweep-rearmed-$mode") + tasks_in "$home" add sweep-finished-card "Finished board card" >/dev/null \ + || fail "could not create the non-held card" fm_test_track_procevent_home "$home" "$home/procevent-claims" store="$home/lavish-state" - board=$(sweep_board "$home" rearmed old) + board=$(sweep_board "$home" rearmed old sweep-finished-card) sid=$(sweep_register "$home" "$board") sweep_session "$store" "$board" open 0 2000-01-01T00:00:00.000Z printf '#!/usr/bin/env bash\nreal_perl=%q\n' "$real_perl" > "$home/fakebin/perl" @@ -5456,6 +5466,255 @@ SH pass "stale sweeps preserve replacement generations and newly captured rounds" ) +test_sweep_keeps_unknown_card_discovery() { + local home store session shape board sid out list before + local -a kept_ids=() retired_ids=() + home=$(make_home board-sweep-card-discovery) + fm_test_track_procevent_home "$home" "$home/procevent-claims" + store="$home/lavish-state" + mkdir -p "$store" + printf '{"sessions":{}}\n' > "$store/state.json" + tasks_in "$home" add sweep-static-finished "Finished static card" >/dev/null \ + || fail "could not create the non-held static card" + run_captain "$home" hold sweep-dynamic-open --title "Dynamic captain call" \ + --reason "captain choice pending" --repo sample >/dev/null \ + || fail "could not create the dynamic captain call" + for session in idle ended nosession; do + for shape in empty dynamic mixed external module handler unkeyed comment malformed frame static; do + board=$(sweep_board "$home" "$session-$shape" new sweep-static-finished) + case "$shape" in + empty) printf '

No static cards

\n' > "$board" ;; + dynamic|mixed) + [ "$shape" != dynamic ] || : > "$board" + cat >> "$board" <<'HTML' + +HTML + ;; + external) printf '\n' >> "$board" ;; + module) printf '\n' >> "$board" ;; + handler) printf '\n' >> "$board" ;; + unkeyed) printf '
\n' >> "$board" ;; + comment) printf '\n' > "$board" ;; + malformed) printf '
\n' >> "$board" ;; + esac + touch -t 200001010000 "$board" + sid=$(sweep_register "$home" "$board") + if [ "$shape" = static ]; then retired_ids+=("$sid"); else kept_ids+=("$sid"); fi + case "$session" in + ended) sweep_session "$store" "$board" ended 0 - ;; + idle) sweep_session "$store" "$board" open 0 2000-01-01T00:00:00.000Z ;; + esac + done + done + before=$(cksum < "$store/state.json") + out=$(LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep --dry-run) \ + || fail "the discovery dry run failed: $out" + for sid in "${kept_ids[@]}"; do + assert_contains "$out" "kept: $sid" "unknown card discovery permitted retirement: $out" + done + for sid in "${retired_ids[@]}"; do + assert_contains "$out" "would-retire: $sid" "a complete static closed-card set was kept: $out" + done + out=$(LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep) \ + || fail "the discovery sweep failed: $out" + list=$(run_procevent "$home" list) + for sid in "${kept_ids[@]}"; do + assert_contains "$out" "kept: $sid" "unknown card discovery lost its listener: $out" + assert_contains "$list" "$sid" "unknown card discovery lost its registration" + done + for sid in "${retired_ids[@]}"; do + assert_contains "$out" "retired: $sid" "a complete static closed-card set was not retired: $out" + assert_not_contains "$list" "$sid" "the closed static board remains registered" + done + assert_contains "$out" 'complete card-key set cannot be established' "unknown discovery was not explained" + assert_equals "$before" "$(cksum < "$store/state.json")" "discovery changed Lavish sessions" + pass "unknown and dynamic card discovery keeps ended, sessionless and idle listeners" +} + +test_sweep_resolves_bound_captain_keys() { + local home store session route key origin board sid expected rc out list before legacy_sid='' + local -a kept_ids=() retired_ids=() + home=$(make_home board-sweep-bound-keys) + fm_test_track_procevent_home "$home" "$home/procevent-claims" + store="$home/lavish-state" + mkdir -p "$store" + printf '{"sessions":{}}\n' > "$store/state.json" + for key in origin-open-decision-third-choice origin-closed-decision-third-choice \ + origin-open-decision-exact-choice sweep-bound-open; do + run_captain "$home" hold "$key" --title "Bound captain call $key" \ + --reason "captain choice pending" --repo sample >/dev/null \ + || fail "could not create the bound call $key" + done + tasks_in "$home" add exact-choice "Non-held exact identity" >/dev/null \ + || fail "could not create the exact precedence fixture" + printf 'north\n' > "$home/answer.txt" + run_captain "$home" answer origin-closed-decision-third-choice --decision-file "$home/answer.txt" >/dev/null \ + || fail "could not answer the legacy closed fixture" + before=$(cksum < "$home/data/backlog.md") + for session in idle ended nosession; do + for route in legacy-open legacy-closed exact-open exact-closed missing any-origin bad-binding; do + origin=origin-open; expected=0 + case "$route" in + legacy-open) key=third-choice ;; + legacy-closed) key=third-choice; origin=origin-closed; expected=1 ;; + exact-open) key=sweep-bound-open; origin=--any-origin ;; + exact-closed) key=exact-choice; expected=1 ;; + missing) key=missing-choice; expected=2 ;; + any-origin) key=third-choice; origin=--any-origin; expected=2 ;; + bad-binding) key=exact-choice; expected=2 ;; + esac + board=$(sweep_board "$home" "$session-$route" old "$key") + sid=$(sweep_register "$home" "$board") + run_captain "$home" bind "$sid" "$origin" >/dev/null || fail "could not bind $route" + if [ "$route" = bad-binding ]; then + printf 'schema=invalid\norigin=origin-open\n' > "$home/state/decision-bindings/$sid.origin" + fi + if out=$(run_captain "$home" open-bound "$sid" "$key" 2> "$home/predicate.err"); then rc=0; else rc=$?; fi + expect_code "$expected" "$rc" "bound predicate $session/$route: $(cat "$home/predicate.err")" + assert_equals '' "$out" "the read-only bound predicate emitted output" + if [ "$expected" = 1 ]; then retired_ids+=("$sid"); else kept_ids+=("$sid"); fi + if [ "$session/$route" = idle/legacy-open ]; then legacy_sid=$sid; fi + case "$session" in + ended) sweep_session "$store" "$board" ended 0 - ;; + idle) sweep_session "$store" "$board" open 0 2000-01-01T00:00:00.000Z ;; + esac + done + done + assert_equals "$before" "$(cksum < "$home/data/backlog.md")" "bound predicates changed captain tasks" + out=$(LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep --dry-run) \ + || fail "the bound-key dry run failed: $out" + for sid in "${kept_ids[@]}"; do assert_contains "$out" "kept: $sid" "the dry run lost a bound or unresolved call: $out"; done + for sid in "${retired_ids[@]}"; do assert_contains "$out" "would-retire: $sid" "the dry run ignored a closed bound identity: $out"; done + out=$(LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep) \ + || fail "the bound-key sweep failed: $out" + list=$(run_procevent "$home" list) + for sid in "${kept_ids[@]}"; do + assert_contains "$out" "kept: $sid" "an open or unresolved bound call lost its listener: $out" + assert_contains "$list" "$sid" "an open or unresolved bound call lost its registration" + done + for sid in "${retired_ids[@]}"; do + assert_contains "$out" "retired: $sid" "a closed bound identity kept its listener: $out" + assert_not_contains "$list" "$sid" "a closed bound identity remains registered" + done + printf 'third-choice\tnorth\tNorth\n' | run_captain "$home" answers origin-open --source "bound sweep fixture" >/dev/null \ + || fail "the preserved legacy call could not receive its answer" + out=$(tasks_in "$home" show origin-open-decision-third-choice) + assert_contains "$out" 'state: done' "the bound answer did not reach the derived task" + out=$(LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep) \ + || fail "the answered bound-key sweep failed: $out" + assert_contains "$out" "retired: $legacy_sid" "the answered legacy card kept its listener: $out" + pass "bound sweep keys share exact and legacy answer precedence without cross-source cache leakage" +} + +test_sweep_resolves_migrated_captain_keys() { + local mode home store board sid key origin identity expected out rc list before + for mode in note derived-note prefix derived-prefix marker-wins ambiguous-note ambiguous-prefix exact-error derived-error prefix-error; do + home=$(make_home "board-sweep-migrated-$mode") + fm_test_track_procevent_home "$home" "$home/procevent-claims" + store="$home/lavish-state" + key=legacy-choice; origin=migration-origin; expected=0; identity=$key + case "$mode" in derived-note|derived-prefix) identity="$origin-decision-$key" ;; esac + cat > "$home/.tasks.toml" < "$home/migration-list.json" + : > "$home/closed-id" + : > "$home/error-id" + case "$mode" in + note|derived-note) + write_known_rows_stub "$home/fakebin" migrated-row + jq -cn --arg identity "$identity" '[{id:"migrated-row", notes:("migrated from data/backlog.md id " + $identity + " on 2026-09-04")}]' > "$home/migration-list.json" + ;; + prefix|derived-prefix) write_known_rows_stub "$home/fakebin" "fm-$identity" ;; + marker-wins) + write_known_rows_stub "$home/fakebin" migrated-row fm-legacy-choice + printf 'migrated-row\n' > "$home/closed-id" + printf '[{"id":"migrated-row","notes":"migrated from data/backlog.md id legacy-choice"}]\n' > "$home/migration-list.json" + expected=1 + ;; + ambiguous-note) + write_known_rows_stub "$home/fakebin" migrated-row other-migrated-row + printf '[{"id":"migrated-row","notes":"migrated from data/backlog.md id legacy-choice"},{"id":"other-migrated-row","notes":"migrated from data/backlog.md id legacy-choice"}]\n' > "$home/migration-list.json" + expected=2 + ;; + ambiguous-prefix) + write_known_rows_stub "$home/fakebin" fm-legacy-choice fm-migration-origin-decision-legacy-choice + expected=2 + ;; + exact-error|derived-error|prefix-error) + write_known_rows_stub "$home/fakebin" migrated-row + case "$mode" in + exact-error) printf '%s\n' "$key" > "$home/error-id" ;; + derived-error) printf '%s\n' "$origin-decision-$key" > "$home/error-id" ;; + prefix-error) printf '%s\n' "fm-$key" > "$home/error-id" ;; + esac + if [ "$mode" != prefix-error ]; then + printf 'migrated-row\n' > "$home/closed-id" + printf '[{"id":"migrated-row","notes":"migrated from data/backlog.md id legacy-choice"}]\n' > "$home/migration-list.json" + fi + expected=2 + ;; + esac + cp "$home/fakebin/tasks-axi" "$home/fakebin/tasks-axi-rows" + cat > "$home/fakebin/tasks-axi" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = show ]; then + if [ "$2" = "$(cat "$FM_HOME/error-id")" ]; then + printf 'error: backlog record cannot be read\n' >&2 + exit 1 + fi + if [ "$2" = "$(cat "$FM_HOME/closed-id")" ]; then + printf 'task:\n id: %s\n state: done\n held: no\n blocked: no\n hold_kind: -\n body: ""\n' "$2" + exit 0 + fi +fi +exec "$FM_HOME/fakebin/tasks-axi-rows" "$@" +SH + cat > "$home/fakebin/bd" <<'SH' +#!/usr/bin/env bash +[ "${1:-}" = list ] || exit 1 +cat "$FM_HOME/migration-list.json" +SH + chmod +x "$home/fakebin/tasks-axi" "$home/fakebin/bd" + board=$(sweep_board "$home" migrated old "$key") + sid=$(sweep_register "$home" "$board") + run_captain "$home" bind "$sid" "$origin" >/dev/null || fail "could not bind the $mode fixture" + sweep_session "$store" "$board" open 0 2000-01-01T00:00:00.000Z + before=$(cksum < "$home/migration-list.json") + if out=$(run_captain "$home" open-bound "$sid" "$key" 2> "$home/predicate.err"); then rc=0; else rc=$?; fi + expect_code "$expected" "$rc" "migrated bound predicate $mode: $(cat "$home/predicate.err")" + assert_equals '' "$out" "the migrated predicate emitted output" + out=$(LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep) \ + || fail "the $mode migration sweep failed: $out" + list=$(run_procevent "$home" list) + if [ "$expected" = 1 ]; then + assert_contains "$out" "retired: $sid" "the closed marker-noted row lost precedence: $out" + assert_not_contains "$list" "$sid" "the closed migrated board remains registered" + else + assert_contains "$out" "kept: $sid" "the migrated or unknown call lost its listener: $out" + assert_contains "$list" "$sid" "the migrated or unknown call lost its registration" + fi + assert_equals "$before" "$(cksum < "$home/migration-list.json")" "the predicate changed migration evidence" + done + pass "sweeps reuse migration note and prefix precedence and keep ambiguous or unreadable identities" +} + tests=( test_sweep_retires_only_finished_board_listeners test_sweep_stops_a_live_listener_of_a_finished_board @@ -5464,6 +5723,9 @@ test_sweep_keeps_boards_with_unsearchable_parents test_sweep_preserves_rearmed_registration_generations test_sweep_keeps_recently_handled_boards_in_literal_home_paths test_sweep_keeps_unknown_inbox_evidence +test_sweep_keeps_unknown_card_discovery +test_sweep_resolves_bound_captain_keys +test_sweep_resolves_migrated_captain_keys test_hold_reason_round_trips_awkward_characters test_hold_origins_precede_backend_holds test_historical_self_inventory_has_workable_repair From ff8bc69ac87e59b4b442216ea92380f1b0fe86c7 Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Fri, 9 Oct 2026 08:23:29 -0500 Subject: [PATCH 10/14] no-mistakes(review): Strengthen board activity regression and remove redundant predicate cache --- bin/fm-procevent-lavish.sh | 25 +++++++++---------------- docs/configuration.md | 2 +- tests/fm-captain-hold-lifecycle.test.sh | 5 +++-- 3 files changed, 13 insertions(+), 19 deletions(-) diff --git a/bin/fm-procevent-lavish.sh b/bin/fm-procevent-lavish.sh index 4c18f673d54..587837a3e7b 100755 --- a/bin/fm-procevent-lavish.sh +++ b/bin/fm-procevent-lavish.sh @@ -1166,26 +1166,19 @@ sweep_facts() { # } sweep_cards_closed() { - local source=$1 keys=$2 key cache_key rc IFS=, + local source=$1 keys=$2 key rc IFS=, if [ "$keys" = '?' ] || [ "$keys" = '-' ] || [ -z "$keys" ]; then reason="the board's complete card-key set cannot be established" return 1 fi for key in $keys; do - cache_key="$source:$key" - case "$open_cache" in - *$'\n'"$cache_key="[0-2]$'\n'*) ;; - *) - FM_HOME="$FM_HOME" "$SCRIPT_DIR/fm-captain-hold.sh" open-bound "$source" "$key" \ - >/dev/null 2>&1 /dev/null 2>&1 ` reads the source binding and uses the answer intake's exact, legacy-derived, migration-note, then migration-prefix resolution precedence. Only a resolved task known not to be an open captain call permits retirement; unresolved, ambiguous, or unreadable evidence keeps the listener. Predicate results are cached per source and key. An unacknowledged captured round blocks every retirement. +For each key, `fm-captain-hold.sh open-bound ` reads the source binding and uses the answer intake's exact, legacy-derived, migration-note, then migration-prefix resolution precedence. Only a resolved task known not to be an open captain call permits retirement; unresolved, ambiguous, or unreadable evidence keeps the listener. An unacknowledged captured round blocks every retirement. `FM_BOARD_LISTENER_IDLE_HOURS` (default 48, whole hours 1..8760) sets how long a board must sit untouched before the sweep may retire it. An unusable value makes the sweep refuse by name. diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index c744a8fe4fc..33216c05229 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -4882,7 +4882,7 @@ test_sweep_retires_only_finished_board_listeners() { dormant=$(sweep_board "$home" dormant old sweep-closed-call) closed=$(sweep_board "$home" closed old sweep-closed-call) openheld=$(sweep_board "$home" openheld old sweep-open-call) - fresh=$(sweep_board "$home" fresh new) + fresh=$(sweep_board "$home" fresh new sweep-closed-call) queued=$(sweep_board "$home" queued old) owned=$(sweep_board "$home" owned old) standing="$home/boards/standing.html" @@ -4956,6 +4956,7 @@ test_sweep_retires_only_finished_board_listeners() { out=$(LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep --dry-run) \ || fail "the dry-run sweep failed: $out" assert_contains "$out" "would-retire: $gone_id" "the dry run missed a board whose file is gone" + assert_contains "$out" "kept: $fresh_id $fresh - active within the last 48 hours" "the dry run did not keep the recently touched board for its activity" assert_contains "$out" "sweep: would-retire=5 kept=$kept_count" "the dry run counted the wrong verdicts: $out" list=$(run_procevent "$home" list) assert_contains "$list" "$dormant_id" "a dry run retired a registration" @@ -4968,7 +4969,7 @@ test_sweep_retires_only_finished_board_listeners() { assert_contains "$out" "retired: $dormant_id" "an idle board with no open call kept its listener" assert_contains "$out" "retired: $closed_id" "an idle board whose only call is answered kept its listener" assert_contains "$out" "kept: $openheld_id" "an idle board with an open captain call lost its listener" - assert_contains "$out" "kept: $fresh_id" "a recently touched board lost its listener" + assert_contains "$out" "kept: $fresh_id $fresh - active within the last 48 hours" "the sweep did not keep the recently touched board for its activity" assert_contains "$out" "kept: $queued_id" "a board holding queued feedback lost its listener" assert_contains "$out" "kept: $owned_id" "a worker-owned board lost its listener to a dormancy rule" assert_contains "$out" "kept: $standing_id" "the standing Bearings board lost its listener" From 800760e7232340f0dc147378143427fa0f5c9e71 Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Fri, 9 Oct 2026 08:37:47 -0500 Subject: [PATCH 11/14] no-mistakes(review): Keep listeners when decision binding reads are uncertain --- bin/fm-captain-hold.sh | 41 ++++++++++----- docs/captain-hold-lifecycle.md | 1 + tests/fm-captain-hold-lifecycle.test.sh | 68 ++++++++++++++++++++++--- 3 files changed, 91 insertions(+), 19 deletions(-) diff --git a/bin/fm-captain-hold.sh b/bin/fm-captain-hold.sh index a7fd8a13f34..1cc6d7f9f38 100755 --- a/bin/fm-captain-hold.sh +++ b/bin/fm-captain-hold.sh @@ -1248,19 +1248,36 @@ binding_path() { printf '%s/%s.origin\n' "$BINDING_DIR" "$1"; } # feeding nothing is the safe direction only when it is a deliberate choice, # never when it is a corrupted record. read_binding() { # - local path origin schema + local path path=$(binding_path "$1") - [ -e "$path" ] || return 0 - [ -f "$path" ] && [ ! -L "$path" ] || fail "decision binding is unsafe: $path" - schema=$(sed -n 's/^schema=//p' "$path" | head -1) - [ "$schema" = "$BINDING_SCHEMA" ] || fail "decision binding has an incompatible schema: $path" - origin=$(sed -n 's/^origin=//p' "$path" | head -1) - if [ "$origin" != "$BINDING_ANY" ]; then - case "$origin" in - ''|*[!A-Za-z0-9._-]*) fail "decision binding has an invalid origin id: $path" ;; - esac - fi - printf '%s\n' "$origin" + perl -MErrno=ENOENT -MFcntl=S_ISREG -e ' + use strict; use warnings; + my ($path, $expected_schema, $any_origin) = @ARGV; + my @stat = lstat $path; + unless (@stat) { + exit 0 if $! == ENOENT; + die "fm-captain-hold: decision binding cannot be checked: $path: $!\n"; + } + S_ISREG($stat[2]) or die "fm-captain-hold: decision binding is unsafe: $path\n"; + open my $file, "<", $path or do { + exit 0 if $! == ENOENT; + die "fm-captain-hold: decision binding cannot be read: $path: $!\n"; + }; + my ($schema, $origin); + $! = 0; + while (my $line = <$file>) { + chomp $line; + $schema = $1 if !defined($schema) && $line =~ /^schema=(.*)$/; + $origin = $1 if !defined($origin) && $line =~ /^origin=(.*)$/; + } + die "fm-captain-hold: decision binding cannot be read: $path: $!\n" if $!; + close $file or die "fm-captain-hold: decision binding cannot be read: $path: $!\n"; + defined($schema) && $schema eq $expected_schema + or die "fm-captain-hold: decision binding has an incompatible schema: $path\n"; + defined($origin) && ($origin eq $any_origin || $origin =~ /\A[A-Za-z0-9._-]+\z/) + or die "fm-captain-hold: decision binding has an invalid origin id: $path\n"; + print "$origin\n"; + ' "$path" "$BINDING_SCHEMA" "$BINDING_ANY" || return 2 } command_bind() { diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index 60a933fb13a..a7f41ae5ebb 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -467,6 +467,7 @@ Three legacy inputs are resolved in place: - A `decision_keys=` metadata entry that names no task resolves through `-decision-`. - A channel key that names no task resolves the same way when the source's binding carries a concrete legacy origin. - The read-only `bin/fm-captain-hold.sh open-bound ` predicate applies that same source binding and resolution order before checking the resolved task: exit 0 is open, exit 1 is known closed or no longer captain-held, and exit 2 is unresolved or unreadable. It changes no task or binding; listener sweeps permit retirement only on exit 1. + Only confirmed binding-file absence is treated as unbound; other lookup or read failures return exit 2 and keep the listener. - Resolution records written by the old script are recognized wherever a record is read. ### Legacy ids on the Beads backend diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index 33216c05229..675a722cba8 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -5619,7 +5619,9 @@ test_sweep_resolves_bound_captain_keys() { test_sweep_resolves_migrated_captain_keys() { local mode home store board sid key origin identity expected out rc list before - for mode in note derived-note prefix derived-prefix marker-wins ambiguous-note ambiguous-prefix exact-error derived-error prefix-error; do + local permission_path permission_mode fixture_rc sweep_rc session predicate_err predicate_out + for mode in note derived-note prefix derived-prefix marker-wins ambiguous-note ambiguous-prefix exact-error derived-error prefix-error \ + binding-lookup-error-idle binding-lookup-error-ended binding-lookup-error-nosession binding-read-error unbound unbound-directory; do home=$(make_home "board-sweep-migrated-$mode") fm_test_track_procevent_home "$home" "$home/procevent-claims" store="$home/lavish-state" @@ -5671,6 +5673,16 @@ EOF fi expected=2 ;; + binding-lookup-error-*|binding-read-error|unbound|unbound-directory) + key=third-choice + write_known_rows_stub "$home/fakebin" migrated-row other-migrated-row + printf 'migrated-row\n' > "$home/closed-id" + jq -cn --arg key "$key" --arg derived "$origin-decision-$key" \ + '[{id:"migrated-row", notes:("migrated from data/backlog.md id " + $key)}, + {id:"other-migrated-row", notes:("migrated from data/backlog.md id " + $derived)}]' \ + > "$home/migration-list.json" + case "$mode" in unbound*) expected=1 ;; *) expected=2 ;; esac + ;; esac cp "$home/fakebin/tasks-axi" "$home/fakebin/tasks-axi-rows" cat > "$home/fakebin/tasks-axi" <<'SH' @@ -5695,14 +5707,56 @@ SH chmod +x "$home/fakebin/tasks-axi" "$home/fakebin/bd" board=$(sweep_board "$home" migrated old "$key") sid=$(sweep_register "$home" "$board") - run_captain "$home" bind "$sid" "$origin" >/dev/null || fail "could not bind the $mode fixture" - sweep_session "$store" "$board" open 0 2000-01-01T00:00:00.000Z + case "$mode" in + unbound) ;; + unbound-directory) mkdir -p "$home/state/decision-bindings" ;; + *) run_captain "$home" bind "$sid" "$origin" >/dev/null || fail "could not bind the $mode fixture" ;; + esac + session=idle + case "$mode" in binding-lookup-error-*) session=${mode##*-} ;; esac + case "$session" in + idle) sweep_session "$store" "$board" open 0 2000-01-01T00:00:00.000Z ;; + ended) sweep_session "$store" "$board" ended 0 - ;; + nosession) mkdir -p "$store"; printf '{"sessions":{}}\n' > "$store/state.json" ;; + esac + permission_path='' + permission_mode=700 + case "$mode" in + binding-lookup-error-*|binding-read-error) + if run_captain "$home" open-bound "$sid" "$key" 2> "$home/predicate.err"; then rc=0; else rc=$?; fi + expect_code 2 "$rc" "the readable binding did not preserve both migration candidates" + permission_path="$home/state/decision-bindings" + if [ "$mode" = binding-read-error ]; then + permission_path="$permission_path/$sid.origin" + permission_mode=600 + fi + chmod 000 "$permission_path" + if perl -MErrno=EACCES -e ' + if ($ARGV[1] eq "binding-read-error") { open my $file, "<", $ARGV[0]; } + else { lstat $ARGV[0]; } + exit($! == EACCES ? 0 : 1); + ' "$home/state/decision-bindings/$sid.origin" "$mode"; then fixture_rc=0; else fixture_rc=$?; fi + if [ "$fixture_rc" -ne 0 ]; then + chmod "$permission_mode" "$permission_path" + fail "the binding permission fixture did not produce EACCES" + fi + ;; + esac before=$(cksum < "$home/migration-list.json") if out=$(run_captain "$home" open-bound "$sid" "$key" 2> "$home/predicate.err"); then rc=0; else rc=$?; fi - expect_code "$expected" "$rc" "migrated bound predicate $mode: $(cat "$home/predicate.err")" - assert_equals '' "$out" "the migrated predicate emitted output" - out=$(LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep) \ - || fail "the $mode migration sweep failed: $out" + predicate_err=$(cat "$home/predicate.err") + predicate_out=$out + if out=$(LAVISH_AXI_STATE_DIR="$store" run_lavish "$home" sweep); then sweep_rc=0; else sweep_rc=$?; fi + [ -z "$permission_path" ] || chmod "$permission_mode" "$permission_path" + expect_code "$expected" "$rc" "migrated bound predicate $mode: $predicate_err" + assert_equals '' "$predicate_out" "the migrated predicate emitted output" + [ "$sweep_rc" -eq 0 ] || fail "the $mode migration sweep failed: $out" + case "$mode" in + binding-lookup-error-*|binding-read-error) + assert_contains "$predicate_err" 'decision binding cannot be' "the binding read error was not reported" + assert_contains "$out" "card $key is an open captain call or cannot be checked" "binding uncertainty was not explained: $out" + ;; + esac list=$(run_procevent "$home" list) if [ "$expected" = 1 ]; then assert_contains "$out" "retired: $sid" "the closed marker-noted row lost precedence: $out" From bd14b88a2f6aca191f58584446c6da0ff23cb3cf Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Fri, 9 Oct 2026 09:10:08 -0500 Subject: [PATCH 12/14] no-mistakes(document): Correct Lavish listener retirement documentation ownership --- bin/fm-captain-hold.sh | 10 ++++ bin/fm-procevent-lavish.sh | 53 ++++++++++++---------- bin/fm-procevent.sh | 3 +- docs/captain-hold-lifecycle.md | 4 +- docs/configuration.md | 17 ++----- docs/verification/process-event-sources.md | 2 +- 6 files changed, 48 insertions(+), 41 deletions(-) diff --git a/bin/fm-captain-hold.sh b/bin/fm-captain-hold.sh index 1cc6d7f9f38..abead7a6737 100755 --- a/bin/fm-captain-hold.sh +++ b/bin/fm-captain-hold.sh @@ -191,6 +191,16 @@ # the task's last status line - and on a 0 bounds repeated alarms from new pane # hashes for the decision. # +# `open-bound` is the read-only board-key predicate. It reads the source binding +# and uses the shared answer/inventory resolver: exact task, legacy-derived task, +# then Beads migration-note or migration-prefix evidence as described above. +# Only confirmed binding-file absence is unbound; other lookup or read failures +# are uncertainty. It prints nothing on stdout and changes no task or binding. +# Exit 0 means the resolved task is an open captain call, 1 means it is known +# closed or no longer captain-held, and 2 means the key is unresolved, ambiguous, +# absent, or unreadable, including binding failures. Listener retirement may use +# only exit 1 as evidence that a card is closed. +# # `diverged` is the read-only guard over the seam between the two records of # one captain call. See "record divergence" beside command_diverged below. # diff --git a/bin/fm-procevent-lavish.sh b/bin/fm-procevent-lavish.sh index 587837a3e7b..551fbf886ea 100755 --- a/bin/fm-procevent-lavish.sh +++ b/bin/fm-procevent-lavish.sh @@ -38,30 +38,37 @@ # are reported explicitly. # sweep Retire this home's Lavish listeners whose boards are finished, and # print one `retired:`, `kept:` or (with --dry-run) `would-retire:` -# line per registration plus a `sweep:` total. A listener lives as -# long as its Lavish session, and the captain answers a board with -# choice forms that never end the session, so without a sweep every -# board ever armed keeps its resident processes and its owner guard -# until the session is ended by hand. Keep guards run first: the -# standing Bearings board, an unreadable Lavish store, queued -# feedback or status feedback, then worker-task ownership. -# Otherwise a board is FINISHED when its artifact file is gone, or -# when Lavish holds no session or an ended session for it and every -# card key is known not to name an open captain call. Other boards -# must also be idle for FM_BOARD_LISTENER_IDLE_HOURS (default 48, -# whole hours 1..8760): no change to the artifact, the Lavish session -# or a captured round. Empty, unreadable or unsupported card discovery -# keeps a present board, including scripts and event handlers. +# line per registration plus a `sweep:` total. Choice answers do not +# end Lavish sessions, so idle boards need explicit listener retirement. +# Keep guards run first: the standing Bearings board, unreadable or +# ambiguous Lavish session evidence, queued feedback or status +# feedback, then worker-task ownership. These guards also apply after +# the artifact is deleted. The standing-board exemption survives a +# deleted symlink target; an unresolved chain keeps every listener. +# Board and inbox lookup, stat, enumeration and read errors keep the +# affected listener with a reason; confirmed absence is not a read error. +# Otherwise a board is FINISHED when its artifact file is confirmed +# gone, or when Lavish holds no session or an ended session for it and +# every card key is known not to name an open captain call. Other +# boards must also be idle for FM_BOARD_LISTENER_IDLE_HOURS (default +# 48, whole hours 1..8760); an unusable value refuses the sweep by name. +# Activity is the latest artifact mtime, session updated_at, or mtime +# of this source's inbox entries, including handled acknowledgements. +# Static discovery recognizes quoted and unquoted question attributes, +# case-insensitive attribute names and decoded HTML entities. Zero keys, +# scripts, event handlers, embedded surfaces, unkeyed forms, malformed +# markup, valueless question attributes and invalid decoded keys leave +# discovery unknown and keep a present board, even alongside static keys. # `bin/fm-captain-hold.sh open-bound` checks keys in source-binding -# context; only exit 1 (resolved and closed) permits retirement. -# Open calls, unresolved keys and read errors keep the board. The durable -# record of a captain call is its held task, and the standing board -# lists every held call. A call held in another home's backlog is -# invisible here, so dormancy is the safeguard for live sessions. -# An unacknowledged captured round blocks every retirement. -# Retiring stops the listener and releases its claim through the -# generic `retire`; it never ends the Lavish session, so the board -# stays readable and `arm` brings the listener back. +# context; only exit 1 (resolved and not an open captain call) permits +# retirement. Its header owns the binding and key-resolution contract. +# A call held in another home's backlog is invisible here, so dormancy +# is the safeguard for live sessions. +# An unacknowledged captured round blocks every sweep retirement. +# Retirement uses the generic `retire --if-identity` boundary, whose +# header owns generation checks, inbox revalidation and capture limits. +# It stops the listener and releases its claim without ending the +# Lavish session, so the board stays readable and `arm` brings it back. # poll The registered listener command `arm` publishes, not a command to # run in a conversational turn. It runs the published blocking poll # and prints its response verbatim, absorbing only the one exact diff --git a/bin/fm-procevent.sh b/bin/fm-procevent.sh index 71bd41d2a3e..07d4f643cf8 100755 --- a/bin/fm-procevent.sh +++ b/bin/fm-procevent.sh @@ -110,7 +110,8 @@ # token printed by register-extension, so a stale owner cannot retire # a replacement generation. --if-identity compares the built-in # registration file identity under the source lock before stopping it -# and refuses while any captured round of the source is unacknowledged. +# and refuses while any captured round of the source is unacknowledged +# or its inbox evidence cannot be read. # Built-in captures do not take this lock: a capture can still arrive # between the check and runner stop; retirement never deletes inbox results. # sweep-home Retire a bounded snapshot of this home's registrations and owned diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index a7f41ae5ebb..1abbddf676e 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -466,10 +466,10 @@ Three legacy inputs are resolved in place: - A `decision_keys=` metadata entry that names no task resolves through `-decision-`. - A channel key that names no task resolves the same way when the source's binding carries a concrete legacy origin. -- The read-only `bin/fm-captain-hold.sh open-bound ` predicate applies that same source binding and resolution order before checking the resolved task: exit 0 is open, exit 1 is known closed or no longer captain-held, and exit 2 is unresolved or unreadable. It changes no task or binding; listener sweeps permit retirement only on exit 1. - Only confirmed binding-file absence is treated as unbound; other lookup or read failures return exit 2 and keep the listener. - Resolution records written by the old script are recognized wherever a record is read. +Listener retirement uses the shared resolver through the read-only `open-bound` predicate; the [captain-hold header and help](../bin/fm-captain-hold.sh) own its results and binding-read contract. + ### Legacy ids on the Beads backend On the Beads backend, an attested legacy markdown id that resolves to no task is accepted through the row the markdown-to-beads hold migration produced. diff --git a/docs/configuration.md b/docs/configuration.md index 4ed9c13fc45..202c0c9aaa1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2439,20 +2439,9 @@ An already-armed Lavish source keeps its registered listener command until it is **Retire finished Lavish listeners** -`bin/fm-procevent-lavish.sh sweep [--dry-run]` retires the listeners of boards that are finished, and its header owns the rules. -A listener otherwise lives as long as its Lavish session, and a captain's choice answers never end the session. -`bin/fm-bearings-board.sh build` runs the sweep after every successful build, and it can be run by hand at any time. -The sweep first keeps the standing Bearings board, every board when the Lavish store is unreadable, boards with queued feedback or status `feedback`, and worker-owned boards; these guards also apply after the artifact is deleted. -The standing-board exemption survives deleted symlink targets; an unresolved symlink chain keeps every listener until its identity can be resolved. -After those guards, a missing board file permits retirement; a present board with a missing or ended session permits retirement only when every card key is known not to name an open captain call. -Other present boards must also pass the idle window below. -Unquoted and quoted card keys, case-insensitive attribute names, and HTML entities are recognized. Zero static keys, scripts, event handlers, embedded surfaces, unkeyed forms, malformed markup, an unreadable board, a valueless question attribute, or an invalid decoded key leave discovery unknown and keep the listener; static matches never establish completeness alongside unsupported markup. Ambiguous session records also keep the listener. Only confirmed file absence permits missing-file retirement; filesystem permission and canonicalization errors keep the listener. -For each key, `fm-captain-hold.sh open-bound ` reads the source binding and uses the answer intake's exact, legacy-derived, migration-note, then migration-prefix resolution precedence. Only a resolved task known not to be an open captain call permits retirement; unresolved, ambiguous, or unreadable evidence keeps the listener. An unacknowledged captured round blocks every retirement. - -`FM_BOARD_LISTENER_IDLE_HOURS` (default 48, whole hours 1..8760) sets how long a board must sit untouched before the sweep may retire it. -An unusable value makes the sweep refuse by name. -Retiring a listener never ends the Lavish session, and `bin/fm-procevent-lavish.sh arm ` brings the listener back. -Retirement compares the snapshotted registration's device/inode identity under the source lock, so a concurrent retire-and-rearm preserves the replacement listener, and refuses while any captured round of the source is unacknowledged. +Use `bin/fm-procevent-lavish.sh sweep` for manual listener retirement; the [adapter header and help](../bin/fm-procevent-lavish.sh) own dry runs, eligibility and keep guards, activity accounting, `FM_BOARD_LISTENER_IDLE_HOURS`, and re-arming. +For automatic sweeping after a successful Bearings build, see the [builder's listener-hygiene contract](../bin/fm-bearings-board.sh). +The [process-event runner header](../bin/fm-procevent.sh) owns conditional retirement's generation checks, inbox revalidation, and capture-race limits. ### Crew-hosted Lavish review boards diff --git a/docs/verification/process-event-sources.md b/docs/verification/process-event-sources.md index 7d149a16854..9483283d79f 100644 --- a/docs/verification/process-event-sources.md +++ b/docs/verification/process-event-sources.md @@ -93,7 +93,7 @@ Exercised by `tests/fm-procevent.test.sh` against a fake blocking source whose c | generic built-in keyed-answer feed | `tests/fm-captain-hold-lifecycle.test.sh` drives a bound built-in source through the real runner with a fixture adapter that only prints keyed lines, proving any bound built-in channel reaches the one keyed-answer intake: named captain-held tasks close at capture time, a card-declared release mode frees held work, keys naming no captain-held task skip, freeform prose forges nothing, matching answer-and-mode replays are idempotent while mode mismatches refuse, an unbound source closes nothing, and capture remains independent of the handler wake. | | structured reconcile feed | `tests/fm-captain-hold-lifecycle.test.sh` covers the [captured-choice and reconcile contract](../captain-hold-lifecycle.md#how-a-board-selection-creates-a-request); its [reconcile coverage index](../captain-hold-lifecycle.md#reconcile-coverage) names the focused legacy-format and last-choice regressions. | | finished-board sweep | `test_sweep_retires_only_finished_board_listeners` in `tests/fm-captain-hold-lifecycle.test.sh` proves keep guards precede retirement: the standing Bearings board (including a symlink), every board under an unreadable Lavish store (including a missing file), queued feedback, and worker ownership (including ended sessions). After those guards, missing files retire; present boards with missing/ended sessions or past-window idleness retire only with no open or uncheckable captain card. Quoted, unquoted, case-insensitive and entity-encoded keys preserve open holds across all three present-board paths; unreadable files, valueless question attributes and invalid decoded keys keep listeners. Unacknowledged captures and recent activity also keep listeners; dry runs do not retire registrations, the Lavish store remains byte-identical, repeated sweeps are idempotent, and unusable idle windows are refused. `test_sweep_stops_a_live_listener_of_a_finished_board` proves the resident poll of a finished board's armed listener is gone after the sweep | -| sweep card discovery and bound routing | `test_sweep_keeps_unknown_card_discovery` executes dry-run and actual sweeps across ended, sessionless and idle boards: zero static keys, dynamic and mixed decks, scripts, handlers, embedded surfaces, unkeyed forms and malformed markup keep listeners; complete static closed-card sets retire. `test_sweep_resolves_bound_captain_keys` exercises exact-before-legacy resolution, concrete and any-origin bindings, unresolved and corrupt-binding evidence, source-local caching, and retirement after a legacy answer reaches its derived task. `test_sweep_resolves_migrated_captain_keys` uses isolated Beads backend fixtures to exercise note and prefix resolution for raw and derived keys, marker precedence over a held prefix namesake, ambiguity, and exact/derived/prefix read errors. | +| sweep card discovery and bound routing | `test_sweep_keeps_unknown_card_discovery` executes dry-run and actual sweeps across ended, sessionless and idle boards: zero static keys, dynamic and mixed decks, scripts, handlers, embedded surfaces, unkeyed forms and malformed markup keep listeners; complete static closed-card sets retire. `test_sweep_resolves_bound_captain_keys` exercises exact-before-legacy resolution, concrete and any-origin bindings, unresolved and corrupt-binding evidence, and retirement after a legacy answer reaches its derived task. `test_sweep_resolves_migrated_captain_keys` uses isolated Beads backend fixtures to exercise note and prefix resolution for raw and derived keys, marker precedence over a held prefix namesake, ambiguity, and exact/derived/prefix read errors; binding-directory lookup failures keep idle, ended and sessionless listeners, unreadable binding files keep listeners, and confirmed binding absence preserves ordinary unbound resolution. | | sweep uncertainty and generation safety | `test_sweep_preserves_deleted_board_keep_guards` covers deleted standing, feedback, queued-feedback, worker-owned and unacknowledged-result boards, including ambiguous session records, deleted standing targets behind absolute, relative and chained symlinks, and unresolved standing symlink cycles or over-limit chains. `test_sweep_keeps_boards_with_unsearchable_parents` exercises actual EACCES in ended, sessionless and idle states. `test_sweep_preserves_rearmed_registration_generations` pauses a sweep after its registration snapshot, retires and re-arms the board through the public interfaces, and verifies both plain and worker-owned replacement registrations and live polls survive the stale retirement. It also creates an unacknowledged capture after the snapshot and verifies locked conditional retirement preserves both the registration and pending result, while ordinary plain retirement remains available and preserves the result. | | handled-board inbox activity | `test_sweep_keeps_recently_handled_boards_in_literal_home_paths` creates old plain boards and sessions, then acknowledges an old captured round through `handled`. Dry-run and actual sweeps preserve that source's registration in homes containing spaces and glob characters, while an unrelated idle board still retires. | | unknown sweep inbox evidence | `test_sweep_keeps_unknown_inbox_evidence` exercises non-listable mode-0300 inboxes containing unacknowledged captures across idle, ended, sessionless and missing-file boards. Dry-run and actual sweeps keep registrations and log the read error; identity-conditional retirement also refuses under the source lock. Result, adapter and handled-marker stat errors keep the affected source without preventing an unrelated idle board from retiring. Sweeps neither acknowledge nor delete the captures; confirmed inbox absence permits retirement again. | From e03aa4907465cd500caa410c4ad8d5f415cddca0 Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Fri, 9 Oct 2026 09:58:21 -0500 Subject: [PATCH 13/14] no-mistakes(lint): Fix unused activity and ambiguous shell assignments --- bin/fm-procevent.sh | 2 +- tests/fm-captain-hold-lifecycle.test.sh | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bin/fm-procevent.sh b/bin/fm-procevent.sh index 07d4f643cf8..f351b7d06b4 100755 --- a/bin/fm-procevent.sh +++ b/bin/fm-procevent.sh @@ -2200,7 +2200,7 @@ cmd_retire() { fm_procevent_source_lock_release "$id" die "cannot read source $id inbox evidence" fi - IFS=$'\t' read -r inbox_id activity captured evidence <<< "$inbox_facts" + IFS=$'\t' read -r inbox_id activity captured evidence <<< "$inbox_facts" && : "$activity" if [ "$inbox_id" != "$id" ] || [ "$evidence" != - ]; then fm_procevent_source_lock_release "$id" die "cannot retire source $id: ${evidence:-inbox evidence cannot be read}" diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index 675a722cba8..4f081e129f6 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -5564,10 +5564,10 @@ test_sweep_resolves_bound_captain_keys() { before=$(cksum < "$home/data/backlog.md") for session in idle ended nosession; do for route in legacy-open legacy-closed exact-open exact-closed missing any-origin bad-binding; do - origin=origin-open; expected=0 + origin="origin-open"; expected=0 case "$route" in legacy-open) key=third-choice ;; - legacy-closed) key=third-choice; origin=origin-closed; expected=1 ;; + legacy-closed) key="third-choice"; origin="origin-closed"; expected=1 ;; exact-open) key=sweep-bound-open; origin=--any-origin ;; exact-closed) key=exact-choice; expected=1 ;; missing) key=missing-choice; expected=2 ;; @@ -5625,7 +5625,7 @@ test_sweep_resolves_migrated_captain_keys() { home=$(make_home "board-sweep-migrated-$mode") fm_test_track_procevent_home "$home" "$home/procevent-claims" store="$home/lavish-state" - key=legacy-choice; origin=migration-origin; expected=0; identity=$key + key="legacy-choice"; origin="migration-origin"; expected=0; identity=$key case "$mode" in derived-note|derived-prefix) identity="$origin-decision-$key" ;; esac cat > "$home/.tasks.toml" < Date: Fri, 9 Oct 2026 10:36:21 -0500 Subject: [PATCH 14/14] no-mistakes(ci): Fixed ci-1: Behavior portable serial 7 failed because fm-backlog-read-bound.test.sh required incidental diagnostic word order, despite verify correctly rejecting a timed-out read. Removed wording-only assertions across hold, direct verify, reconcile-requests, and migrated-prefix verify; now check bounded completion, failure status, prohibited output/side effects, and no further backend reads after timeout. Updated verification documentation; product code is unchanged. Verification passed: the complete bounded-backlog behavior suite, bash -n, and shellcheck -x. Temporary fixtures were cleaned up. The full CI shard was not rerun --- docs/sessionstart-nudge.md | 2 + tests/fm-backlog-read-bound.test.sh | 77 ++++++++++++++++------------- 2 files changed, 45 insertions(+), 34 deletions(-) diff --git a/docs/sessionstart-nudge.md b/docs/sessionstart-nudge.md index db166f9f678..852d2faf445 100644 --- a/docs/sessionstart-nudge.md +++ b/docs/sessionstart-nudge.md @@ -158,6 +158,8 @@ Each of those reads is bounded by `FM_BACKLOG_ROW_TIMEOUT_SECS` (default 10s) th The first bound hit latches the sweep. Later reads in that sweep then return immediately while still naming their own item. +`tests/fm-backlog-read-bound.test.sh` exercises hanging reads through hold, inventory verification (including migrated-prefix resolution), reconcile-request intake, and session start. It checks bounded completion, rejection without task creation or inventory attestation, and suppression of further backend reads after a timeout; diagnostic word order is not part of those CLI contracts. + When timeout, gtimeout, and perl are unavailable, the shared timeout owner falls back to a pure-Bash process-group watchdog. So no supported host runs the digest unbounded. diff --git a/tests/fm-backlog-read-bound.test.sh b/tests/fm-backlog-read-bound.test.sh index ca9990fba35..cf880a87c88 100755 --- a/tests/fm-backlog-read-bound.test.sh +++ b/tests/fm-backlog-read-bound.test.sh @@ -190,23 +190,25 @@ printf '# Backlog\n' > "$CAPTAIN/data/backlog.md" ADD_LOG="$CAPTAIN/add.log" HOLD_OUT="$CAPTAIN/hold.out" +HOLD_STDOUT="$CAPTAIN/hold.stdout" HOLD_STATUS=0 +HOLD_START=$(date +%s) PATH="$CAPTAIN_FAKEBIN:$BASE_PATH" FM_HOME="$CAPTAIN" \ FM_STATE_OVERRIDE="$CAPTAIN/state" FM_DATA_OVERRIDE="$CAPTAIN/data" \ FM_CONFIG_OVERRIDE="$CAPTAIN/config" FM_BACKLOG_ROW_TIMEOUT_SECS="$BOUND_SECS" \ FM_TEST_TASKS_AXI_ADD_LOG="$ADD_LOG" \ "$ROOT/bin/fm-captain-hold.sh" hold wedged-hold --title 'Wedged hold' --reason 'backend wedged' \ - > "$HOLD_OUT" 2>&1 || HOLD_STATUS=$? + > "$HOLD_STDOUT" 2> "$HOLD_OUT" || HOLD_STATUS=$? [ "$HOLD_STATUS" -ne 0 ] \ || fail "holding a task against a wedged backend must not report success: $(cat "$HOLD_OUT")" [ ! -s "$ADD_LOG" ] \ || fail "a timed-out read was spent as absence: tasks-axi add ran anyway: $(cat "$ADD_LOG")" -case "$(cat "$HOLD_OUT")" in - *wedged-hold*bound*) ;; - *) fail "the refusal must name the item and the bound it hit, got: $(cat "$HOLD_OUT")" ;; -esac -pass "a bound hit stops a captain hold loudly instead of being read as a missing task" +[ "$(elapsed_since "$HOLD_START")" -lt "$BOUND_CEILING" ] \ + || fail "holding a task exceeded the bounded-read ceiling: $(cat "$HOLD_OUT")" +[ ! -s "$HOLD_STDOUT" ] \ + || fail "a timed-out hold reported a task outcome: $(cat "$HOLD_STDOUT")" +pass "a bound hit stops a captain hold without creating a task" # The teardown gate reaches a row read through the same resolver, so the bound # hit has to survive the command substitution that carries the resolved id. @@ -219,22 +221,25 @@ fm_write_meta "$CAPTAIN/state/wedged-origin.meta" \ 'decision_keys=wedged-entry' VERIFY_OUT="$CAPTAIN/verify.out" +VERIFY_STDOUT="$CAPTAIN/verify.stdout" +VERIFY_LOG="$CAPTAIN/verify.log" VERIFY_STATUS=0 +VERIFY_START=$(date +%s) PATH="$CAPTAIN_FAKEBIN:$BASE_PATH" FM_HOME="$CAPTAIN" \ FM_STATE_OVERRIDE="$CAPTAIN/state" FM_DATA_OVERRIDE="$CAPTAIN/data" \ FM_CONFIG_OVERRIDE="$CAPTAIN/config" FM_BACKLOG_ROW_TIMEOUT_SECS="$BOUND_SECS" \ - "$ROOT/bin/fm-captain-hold.sh" verify wedged-origin > "$VERIFY_OUT" 2>&1 || VERIFY_STATUS=$? + FM_TEST_TASKS_AXI_HANG_LOG="$VERIFY_LOG" \ + "$ROOT/bin/fm-captain-hold.sh" verify wedged-origin > "$VERIFY_STDOUT" 2> "$VERIFY_OUT" || VERIFY_STATUS=$? [ "$VERIFY_STATUS" -ne 0 ] \ || fail "verify must not attest an inventory it could not read: $(cat "$VERIFY_OUT")" -case "$(cat "$VERIFY_OUT")" in - *absent*) fail "a bound hit was reported as an absent task: $(cat "$VERIFY_OUT")" ;; -esac -case "$(cat "$VERIFY_OUT")" in - *wedged-entry*bound*) ;; - *) fail "verify must name the entry it could not read and the bound it hit, got: $(cat "$VERIFY_OUT")" ;; -esac -pass "the teardown verify gate reports a bound hit by name instead of as an absent inventory entry" +[ "$(elapsed_since "$VERIFY_START")" -lt "$BOUND_CEILING" ] \ + || fail "verify exceeded the bounded-read ceiling: $(cat "$VERIFY_OUT")" +[ ! -s "$VERIFY_STDOUT" ] \ + || fail "verify attested an unreadable inventory: $(cat "$VERIFY_STDOUT")" +[ "$(cat "$VERIFY_LOG")" = wedged-entry ] \ + || fail "verify continued resolving after a bound hit: $(cat "$VERIFY_LOG")" +pass "the teardown verify gate stops at a bound hit without attesting the inventory" # The reconcile-requests intake reads each row with task_show in this shell and # must stop on a bound hit by name; spending the 124 as 'refused: @@ -250,24 +255,25 @@ printf 'schema=fm-decision-binding.v1\norigin=wedged-origin\n' \ > "$REQ/state/decision-bindings/probe.origin" REQ_OUT="$REQ/req.out" +REQ_STDOUT="$REQ/req.stdout" REQ_STATUS=0 +REQ_START=$(date +%s) printf 'wedged-req\n' \ | PATH="$REQ_FAKEBIN:$BASE_PATH" FM_HOME="$REQ" \ FM_STATE_OVERRIDE="$REQ/state" FM_DATA_OVERRIDE="$REQ/data" \ FM_CONFIG_OVERRIDE="$REQ/config" FM_BACKLOG_ROW_TIMEOUT_SECS="$BOUND_SECS" \ "$ROOT/bin/fm-captain-hold.sh" reconcile-requests --source-id probe --source 'test capture' \ - > "$REQ_OUT" 2>&1 || REQ_STATUS=$? + > "$REQ_STDOUT" 2> "$REQ_OUT" || REQ_STATUS=$? [ "$REQ_STATUS" -ne 0 ] \ || fail "reconcile-requests must not report success against a wedged backend: $(cat "$REQ_OUT")" -case "$(cat "$REQ_OUT")" in - *absent*|*refused*) fail "the reconcile intake spent a bound hit as an absent row: $(cat "$REQ_OUT")" ;; -esac -case "$(cat "$REQ_OUT")" in - *wedged-req*bound*) ;; - *) fail "the reconcile intake must name the row and the bound it hit, got: $(cat "$REQ_OUT")" ;; -esac -pass "the reconcile-requests intake stops loudly on a bound hit instead of refusing the row as absent" +[ "$(elapsed_since "$REQ_START")" -lt "$BOUND_CEILING" ] \ + || fail "the reconcile intake exceeded the bounded-read ceiling: $(cat "$REQ_OUT")" +[ ! -s "$REQ_STDOUT" ] \ + || fail "the reconcile intake classified a timed-out row: $(cat "$REQ_STDOUT")" +assert_absent "$REQ/state/reconcile-requests/wedged-req.request" \ + "a timed-out row must not create a reconcile request" +pass "the reconcile-requests intake stops at a bound hit without classifying or recording the row" # The migrated-prefix scan is the resolution path whose exact and legacy ids # genuinely answer NOT_FOUND: only the prefixed migrated row wedges. A dropped @@ -283,6 +289,7 @@ set -u case "${1:-}" in --version) printf '%s\n' '0.2.6'; exit 0 ;; show) + printf '%s\n' "${2:-}" >> "$FM_TEST_TASKS_AXI_SHOW_LOG" [ -z "${2:-}" ] && { printf 'code: NOT_FOUND\n' >&2; exit 1; } # Only the prefixed migrated candidates wedge; the exact and legacy ids # answer NOT_FOUND promptly, the concrete path the prefix scan exists for. @@ -338,24 +345,26 @@ fm_write_meta "$MIG/state/wedged-origin.meta" \ 'decision_keys=mig-entry' VERIFY_MIG_OUT="$MIG/verify.out" +VERIFY_MIG_STDOUT="$MIG/verify.stdout" +VERIFY_MIG_LOG="$MIG/verify.log" VERIFY_MIG_STATUS=0 +VERIFY_MIG_START=$(date +%s) PATH="$MIG_FAKEBIN:$BASE_PATH" FM_HOME="$MIG" \ FM_STATE_OVERRIDE="$MIG/state" FM_DATA_OVERRIDE="$MIG/data" \ FM_CONFIG_OVERRIDE="$MIG/config" FM_BACKLOG_ROW_TIMEOUT_SECS="$BOUND_SECS" \ FM_TEST_PREFIXED_GLOB='bd-*' \ - "$ROOT/bin/fm-captain-hold.sh" verify wedged-origin > "$VERIFY_MIG_OUT" 2>&1 || VERIFY_MIG_STATUS=$? + FM_TEST_TASKS_AXI_SHOW_LOG="$VERIFY_MIG_LOG" \ + "$ROOT/bin/fm-captain-hold.sh" verify wedged-origin > "$VERIFY_MIG_STDOUT" 2> "$VERIFY_MIG_OUT" || VERIFY_MIG_STATUS=$? [ "$VERIFY_MIG_STATUS" -ne 0 ] \ || fail "verify must not attest an inventory whose migrated-prefix read wedged: $(cat "$VERIFY_MIG_OUT")" -case "$(cat "$VERIFY_MIG_OUT")" in - *'no captain-held task'*|*absent*) - fail "the migrated-prefix bound hit was spent as an unresolved key: $(cat "$VERIFY_MIG_OUT")" ;; -esac -case "$(cat "$VERIFY_MIG_OUT")" in - *'exceeded its read bound resolving mig-entry') ;; - *) fail "verify must name the entry it could not read and the bound it hit, got: $(cat "$VERIFY_MIG_OUT")" ;; -esac -pass "a bound hit in the migrated-prefix scan stops verify by name instead of resolving to nothing" +[ "$(elapsed_since "$VERIFY_MIG_START")" -lt "$BOUND_CEILING" ] \ + || fail "migrated-prefix verify exceeded the bounded-read ceiling: $(cat "$VERIFY_MIG_OUT")" +[ ! -s "$VERIFY_MIG_STDOUT" ] \ + || fail "verify attested an unreadable migrated inventory: $(cat "$VERIFY_MIG_STDOUT")" +[ "$(cat "$VERIFY_MIG_LOG")" = "$(printf '%s\n' mig-entry wedged-origin-decision-mig-entry bd-mig-entry)" ] \ + || fail "verify continued resolving after the migrated-prefix bound hit: $(cat "$VERIFY_MIG_LOG")" +pass "a bound hit in the migrated-prefix scan stops verify without attesting the inventory" # --- half two: the digest still completes end to end ------------------------