diff --git a/bin/fm-herdr-session-cleanup.sh b/bin/fm-herdr-session-cleanup.sh index 259969bbf22..1429b125309 100755 --- a/bin/fm-herdr-session-cleanup.sh +++ b/bin/fm-herdr-session-cleanup.sh @@ -15,6 +15,14 @@ # one pane, absent task metadata, no registered agent, and a process proof that # the pane contains only one idle recognized shell with no child process. A # version 2 journal must also bind the exact workspace, tab, and pane. +# Every home-local journal is parsed once per cleanup pass into a title-keyed +# index; the mutation guards re-read live state by digesting once per check +# exactly the ordinary journal files, plus one deliberately unparseable line +# while the state directory is missing or a symlink, and re-parsing exactly +# any journal whose digest changed since the index, so no guard rescans every +# journal; +# fm_herdr_cleanup_index_refresh documents its one deliberate exception, the +# full rebuild on an unparseable digest line. # Topology is first checked from one locked API snapshot, then every mutation # prerequisite is immediately rechecked before the existing exact-pane # focus-preserving close helper is called. @@ -62,29 +70,140 @@ fm_herdr_cleanup_home_identity() { (cd "$FM_HOME" 2>/dev/null && pwd -P) } -fm_herdr_cleanup_journal_matches() { # <session> <home-real> - local title=$1 session=$2 home_real=$3 journal id expected journal_home - [ -d "$STATE" ] && [ ! -L "$STATE" ] || return 1 +# One journal parse per cleanup pass: every candidate journal is snapshotted +# once into a title-keyed in-memory index, and each candidate workspace +# matches against that index instead of rescanning every journal per workspace. +# A matched record's journal stays existence-tested at lookup time, and the +# mutation guards reconcile the index with the live journal directory instead +# of rescanning: the digest checksums only ordinary journal files and gains a +# deliberately unparseable line whenever the state directory stops being an +# ordinary directory, so every file-type or directory transition either +# changes a digest line or forces the full index rebuild on that line. A +# matching digest therefore proves every row still mirrors its journal byte +# for byte, while any divergence names exactly the journals that must be +# re-read, and only those are re-parsed. Anything stale or contested +# degrades to conservative preservation, never to a silently stale row. +fm_herdr_cleanup_fingerprint() { + local journal + set -- + [ -d "$STATE" ] && [ ! -L "$STATE" ] || { + printf '%s\n' 'fm-herdr-session-cleanup: state directory is missing or a symlink' + return 0 + } + for journal in "$STATE"/*"$FM_BACKEND_HERDR_PRESENTATION_JOURNAL_SUFFIX"; do + [ -f "$journal" ] && [ ! -L "$journal" ] || continue + set -- "$@" "$journal" + done + [ "$#" -eq 0 ] || cksum "$@" 2>/dev/null +} + +fm_herdr_cleanup_journal_validate() { # <journal> <id> <session> <home-real> + local journal=$1 id=$2 session=$3 home_real=$4 journal_home + fm_backend_herdr_projection_journal_snapshot "$journal" "$id" || return 1 + if [ "$FM_BACKEND_HERDR_JOURNAL_VERSION" = 2 ]; then + journal_home=$(fm_backend_herdr_projection_home_identity \ + "$FM_BACKEND_HERDR_JOURNAL_HOME" 2>/dev/null) || return 1 + [ "$journal_home" = "$home_real" ] \ + && [ "$FM_BACKEND_HERDR_JOURNAL_SESSION" = "$session" ] || return 1 + fi +} + +# Guard-grade unique match: reconcile the index against the live journal +# directory first (one digest pass, re-parsing only journals whose bytes +# changed so the rows are provably current), then resolve the one journal +# still matching the title from that reconciled index. +fm_herdr_cleanup_unique_match() { # <title> <session> <home-real> + local title=$1 session=$2 home_real=$3 + fm_herdr_cleanup_index_refresh "$session" "$home_real" + fm_herdr_cleanup_index_match "$title" "$session" "$home_real" +} + +fm_herdr_cleanup_index_build() { # <session> <home-real> + local session=$1 home_real=$2 journal id + FM_HERDR_CLEANUP_INDEX= + FM_HERDR_CLEANUP_FINGERPRINT=$(fm_herdr_cleanup_fingerprint) + [ -d "$STATE" ] && [ ! -L "$STATE" ] || return 0 for journal in "$STATE"/*"$FM_BACKEND_HERDR_PRESENTATION_JOURNAL_SUFFIX"; do [ -f "$journal" ] && [ ! -L "$journal" ] || continue id=$(basename "$journal" "$FM_BACKEND_HERDR_PRESENTATION_JOURNAL_SUFFIX") fm_task_id_creation_valid "$id" || continue - fm_backend_herdr_projection_journal_snapshot "$journal" "$id" || continue - if [ "$FM_BACKEND_HERDR_JOURNAL_VERSION" = 2 ]; then - journal_home=$(fm_backend_herdr_projection_home_identity \ - "$FM_BACKEND_HERDR_JOURNAL_HOME" 2>/dev/null) || continue - [ "$journal_home" = "$home_real" ] \ - && [ "$FM_BACKEND_HERDR_JOURNAL_SESSION" = "$session" ] || continue - fi - expected=$(fm_backend_herdr_projection_workspace_label \ - "$id" "$FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID") - [ "$expected" = "$title" ] || continue - printf '%s\t%s\t%s\n' "$journal" "$id" "$FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID" + fm_herdr_cleanup_index_row_append "$journal" "$id" "$session" "$home_real" || continue done } -fm_herdr_cleanup_unique_match() { # <title> <session> <home-real> - local title=$1 session=$2 home_real=$3 matches count record +# Parse one journal once into a fresh index row. Shared by the index build and +# the digest-driven reconciliation so both produce byte-identical rows. +fm_herdr_cleanup_index_row_append() { # <journal> <id> <session> <home-real> + local journal=$1 id=$2 session=$3 home_real=$4 label + fm_herdr_cleanup_journal_validate "$journal" "$id" "$session" "$home_real" || return 1 + label=$(fm_backend_herdr_projection_workspace_label \ + "$id" "$FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID") + FM_HERDR_CLEANUP_INDEX+="$label"$'\t'"$journal"$'\t'"$id"$'\t' + FM_HERDR_CLEANUP_INDEX+="${FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID}"$'\t' + FM_HERDR_CLEANUP_INDEX+="${FM_BACKEND_HERDR_JOURNAL_VERSION}"$'\t' + FM_HERDR_CLEANUP_INDEX+="${FM_BACKEND_HERDR_JOURNAL_WORKSPACE_ID}"$'\t' + FM_HERDR_CLEANUP_INDEX+="${FM_BACKEND_HERDR_JOURNAL_TAB_ID}"$'\t' + FM_HERDR_CLEANUP_INDEX+="${FM_BACKEND_HERDR_JOURNAL_PANE_ID}"$'\n' +} + +# Refresh exactly one journal path from live bytes: its previous rows are +# dropped, then the journal is re-parsed into fresh rows only while it is +# still an ordinary readable file with a valid task id and payload. A removed +# or invalid journal ends with no rows at all. +fm_herdr_cleanup_index_row_replace() { # <journal> <session> <home-real> + local journal=$1 session=$2 home_real=$3 id + local r_label r_journal r_id r_token r_version r_workspace r_tab r_pane + local index= + while IFS=$'\t' read -r r_label r_journal r_id r_token r_version r_workspace r_tab r_pane; do + [ -n "$r_journal" ] || continue + [ "$r_journal" = "$journal" ] && continue + index+="$r_label"$'\t'"$r_journal"$'\t'"$r_id"$'\t'"$r_token"$'\t' + index+="$r_version"$'\t'"$r_workspace"$'\t'"$r_tab"$'\t'"$r_pane"$'\n' + done <<< "$FM_HERDR_CLEANUP_INDEX" + FM_HERDR_CLEANUP_INDEX=$index + [ -f "$journal" ] && [ ! -L "$journal" ] || return 0 + id=$(basename "$journal" "$FM_BACKEND_HERDR_PRESENTATION_JOURNAL_SUFFIX") + fm_task_id_creation_valid "$id" || return 0 + fm_herdr_cleanup_index_row_append "$journal" "$id" "$session" "$home_real" || return 0 + return 0 +} + +# Reconcile the index with the live directory before a guard relies on it. +# A matching digest proves every row still mirrors the live bytes; otherwise +# exactly the digest lines that differ name their journals, and each one is +# re-read. An unparseable digest line falls back to a full index rebuild so a +# change can never be silently missed. +fm_herdr_cleanup_index_refresh() { # <session> <home-real> + local session=$1 home_real=$2 next changed line journal seen + local recorded=${FM_HERDR_CLEANUP_FINGERPRINT:-} + local cksum_re='^([0-9]+)[[:space:]]([0-9]+)[[:space:]]+(.*)$' + local NLPAD=$'\n' + next=$(fm_herdr_cleanup_fingerprint) + [ "$next" = "$recorded" ] && return 0 + changed=$( + { + printf '%s\n' "$recorded" + printf '%s\n' "$next" + } | LC_ALL=C sort | LC_ALL=C uniq -u + ) + FM_HERDR_CLEANUP_FINGERPRINT=$next + seen=$NLPAD + while IFS= read -r line; do + [ -n "$line" ] || continue + if ! [[ "$line" =~ $cksum_re ]]; then + fm_herdr_cleanup_index_build "$session" "$home_real" + return 0 + fi + journal=${BASH_REMATCH[3]} + case "$seen" in *"$NLPAD$journal$NLPAD"*) continue ;; esac + seen+="$journal$NLPAD" + fm_herdr_cleanup_index_row_replace "$journal" "$session" "$home_real" + done <<< "$changed" +} + +fm_herdr_cleanup_index_match() { # <title> <session> <home-real> + local title=$1 session=$2 home_real=$3 + local label journal id token version bound_workspace bound_tab bound_pane matches=0 FM_HERDR_CLEANUP_JOURNAL= FM_HERDR_CLEANUP_ID= FM_HERDR_CLEANUP_TOKEN= @@ -92,26 +211,26 @@ fm_herdr_cleanup_unique_match() { # <title> <session> <home-real> FM_HERDR_CLEANUP_BOUND_WORKSPACE= FM_HERDR_CLEANUP_BOUND_TAB= FM_HERDR_CLEANUP_BOUND_PANE= - matches=$(fm_herdr_cleanup_journal_matches "$title" "$session" "$home_real") || return 1 - count=$(printf '%s\n' "$matches" | awk 'NF { n++ } END { print n+0 }') - [ "$count" -eq 1 ] || return 1 - record=$(printf '%s\n' "$matches" | awk 'NF { print; exit }') - FM_HERDR_CLEANUP_JOURNAL=${record%%$'\t'*} - record=${record#*$'\t'} - FM_HERDR_CLEANUP_ID=${record%%$'\t'*} - FM_HERDR_CLEANUP_TOKEN=${record#*$'\t'} + if [ "${FM_HERDR_CLEANUP_INDEX_READY:-0}" != 1 ]; then + fm_herdr_cleanup_index_build "$session" "$home_real" + FM_HERDR_CLEANUP_INDEX_READY=1 + fi + while IFS=$'\t' read -r label journal id token version bound_workspace bound_tab bound_pane; do + [ "$label" = "$title" ] || continue + [ -f "$journal" ] && [ ! -L "$journal" ] || continue + matches=$((matches + 1)) + FM_HERDR_CLEANUP_JOURNAL=$journal + FM_HERDR_CLEANUP_ID=$id + FM_HERDR_CLEANUP_TOKEN=$token + FM_HERDR_CLEANUP_VERSION=$version + FM_HERDR_CLEANUP_BOUND_WORKSPACE=$bound_workspace + FM_HERDR_CLEANUP_BOUND_TAB=$bound_tab + FM_HERDR_CLEANUP_BOUND_PANE=$bound_pane + done <<< "$FM_HERDR_CLEANUP_INDEX" + [ "$matches" -eq 1 ] || return 1 [ -n "$FM_HERDR_CLEANUP_JOURNAL" ] \ && [ -n "$FM_HERDR_CLEANUP_ID" ] \ && [ -n "$FM_HERDR_CLEANUP_TOKEN" ] || return 1 - fm_backend_herdr_projection_journal_snapshot \ - "$FM_HERDR_CLEANUP_JOURNAL" "$FM_HERDR_CLEANUP_ID" || return 1 - [ "$FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID" = "$FM_HERDR_CLEANUP_TOKEN" ] || return 1 - FM_HERDR_CLEANUP_VERSION=$FM_BACKEND_HERDR_JOURNAL_VERSION - if [ "$FM_HERDR_CLEANUP_VERSION" = 2 ]; then - FM_HERDR_CLEANUP_BOUND_WORKSPACE=$FM_BACKEND_HERDR_JOURNAL_WORKSPACE_ID - FM_HERDR_CLEANUP_BOUND_TAB=$FM_BACKEND_HERDR_JOURNAL_TAB_ID - FM_HERDR_CLEANUP_BOUND_PANE=$FM_BACKEND_HERDR_JOURNAL_PANE_ID - fi } fm_herdr_cleanup_snapshot_candidate() { # <snapshot> <workspace> <title> <token> <bound-workspace> <bound-tab> <bound-pane> @@ -204,7 +323,7 @@ fm_herdr_cleanup_one() { # <session> <workspace> <title> <home-real> local version bound_workspace bound_tab bound_pane presentation_lock snapshot local tab pane state close_status=0 token=$(fm_herdr_cleanup_title_token "$title") || return 0 - if ! fm_herdr_cleanup_unique_match "$title" "$session" "$home_real"; then + if ! fm_herdr_cleanup_index_match "$title" "$session" "$home_real"; then return 0 fi journal=$FM_HERDR_CLEANUP_JOURNAL @@ -294,6 +413,9 @@ fm_herdr_cleanup_one() { # <session> <workspace> <title> <home-real> fm_herdr_session_cleanup() { local session home_real list candidates workspace title journal found=0 + FM_HERDR_CLEANUP_INDEX= + FM_HERDR_CLEANUP_INDEX_READY=0 + FM_HERDR_CLEANUP_FINGERPRINT= [ -d "$STATE" ] && [ ! -L "$STATE" ] || return 0 for journal in "$STATE"/*"$FM_BACKEND_HERDR_PRESENTATION_JOURNAL_SUFFIX"; do if [ -f "$journal" ] && [ ! -L "$journal" ]; then diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index 242e2fcf7dc..56edf934b91 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -481,7 +481,7 @@ Any of these preserves the candidate and lets session startup continue with at m | Test | What it covers | | --- | --- | | `tests/fm-backend-herdr-presentation-e2e.test.sh` | Multi-home ordering, concurrency, lock contention, legacy coexistence, focus preservation, exact same-identity restart replacement, ambiguous bindings and tokens, and exact-pane cleanup through the guarded lab path. | -| `tests/fm-herdr-session-cleanup.test.sh` | Every discovery, ownership, topology, process, locking, revalidation, focus, retirement, and continue-on-error boundary. | +| `tests/fm-herdr-session-cleanup.test.sh` | Every discovery, ownership, topology, process, locking, revalidation, focus, retirement, and continue-on-error boundary, plus the per-pass journal parse budget. | | `tests/fm-herdr-session-cleanup-e2e.test.sh` | The restored-shell cleanup in a guarded non-default named lab. | | `tests/fm-backend-herdr-focus-flash-e2e.test.sh` | Reproduces the raw explicit-close focus steal on the installed release, and proves the focus-safe emptying-close plan removes a doomed workspace with no wrong-focus interval. | | `tests/fm-backend-herdr-stale-active-tab-e2e.test.sh` | Proves a persisted-focused tab still closes when no foreground client is attached. | diff --git a/tests/fm-herdr-session-cleanup.test.sh b/tests/fm-herdr-session-cleanup.test.sh index 99cf4b49a1c..c11bfd71e68 100755 --- a/tests/fm-herdr-session-cleanup.test.sh +++ b/tests/fm-herdr-session-cleanup.test.sh @@ -62,6 +62,7 @@ TAB=w2:t1 PANE=w2:p1 TITLE="└ task · p:$TOKEN" FIXTURE_DIR="$TMP_ROOT/fixture" +OUTSIDE_HOME_STATE="$TMP_ROOT/outside-home-state" LOCK_LOG="$TMP_ROOT/locks.log" CLOSE_LOG="$TMP_ROOT/closes.log" mkdir -p "$FIXTURE_DIR" @@ -128,6 +129,7 @@ fixture_panes() { printf ']' } +# shellcheck disable=SC2329 # Invoked indirectly by the sourced cleanup script; the parse-budget scenario below redefines it. fm_backend_herdr_cli() { local _session=$1 first=${2:-} second=${3:-} title tabs panes shift @@ -170,6 +172,24 @@ fm_backend_herdr_cli() { ;; "api snapshot") : > "$FIXTURE_DIR/snapshotted" + if [ -e "$FIXTURE_DIR/late-duplicate" ]; then + rm -f "$FIXTURE_DIR/late-duplicate" + write_v1 fm-task + fi + if [ -e "$FIXTURE_DIR/late-extra" ]; then + rm -f "$FIXTURE_DIR/late-extra" + write_v1 probe-extra QyQyQyQyQyQyQyQyQyQyQy0 + fi + if [ -e "$FIXTURE_DIR/late-materialized-journal" ]; then + rm -f "$FIXTURE_DIR/late-materialized-journal" + rm -f "$FM_STATE_OVERRIDE/fm-task.herdr-presentation" + cp "$FIXTURE_DIR/mirror-journal" "$FM_STATE_OVERRIDE/fm-task.herdr-presentation" + fi + if [ -e "$FIXTURE_DIR/late-state-symlink" ]; then + rm -f "$FIXTURE_DIR/late-state-symlink" + mv "$FM_STATE_OVERRIDE" "$FM_STATE_OVERRIDE.preserved" + ln -s "$OUTSIDE_HOME_STATE" "$FM_STATE_OVERRIDE" + fi printf '{"result":{"snapshot":{"focused_workspace_id":"w1","focused_tab_id":"%s","focused_pane_id":"w1:p1","workspaces":' "$(cat "$FIXTURE_DIR/active-tab")" fixture_workspaces printf ',"tabs":'; fixture_tabs @@ -282,6 +302,135 @@ reset_fixture; : > "$FIXTURE_DIR/race"; assert_preserved "revalidation race" reset_fixture; printf '%s\n' "$TAB" > "$FIXTURE_DIR/active-tab"; assert_preserved "active target" reset_fixture; : > "$FIXTURE_DIR/focus-refuse"; assert_preserved "focus refusal" +# Wrapping the journal snapshot backend function counts parses through the +# public data path without touching its validated semantics; installed once +# for the mutation-guard and discovery parse budgets below. +eval "$(declare -f fm_backend_herdr_projection_journal_snapshot \ + | sed '1s/^fm_backend_herdr_projection_journal_snapshot/_cleanup_journal_parse_real/')" +_parse_calls=0 +fm_backend_herdr_projection_journal_snapshot() { + _parse_calls=$((_parse_calls + 1)) + _cleanup_journal_parse_real "$@" +} + +# A completed close keeps its whole-pass parse budget at one parse per +# present journal: the index build parses both journals, and the immediate +# revalidation plus post-closure journal recheck re-read live state by +# digesting the journal directory once per check and re-parsing only the +# journals whose bytes changed since the index. +reset_fixture +write_v1 probe-idle XyXyXyXyXyXyXyXyXyXyX0 +_parse_calls=0 +fm_herdr_session_cleanup >/dev/null 2>&1 +[ ! -e "$FM_STATE_OVERRIDE/$ID.herdr-presentation" ] \ + || fail "closed candidate's journal was not retired" +[ "$(wc -l < "$CLOSE_LOG" | tr -d ' ')" = 1 ] \ + || fail "guard-budget close did not close exactly once" +[ -f "$FM_STATE_OVERRIDE/probe-idle.herdr-presentation" ] \ + || fail "guard-budget close removed an unmatched journal" +[ "$_parse_calls" = 2 ] \ + || fail "closing one candidate parsed journals $_parse_calls times instead of once per journal per pass" +pass "closing one candidate guards via digest without a per-mutation rescan" + +# A journal that appears after discovery must still be caught the same way a +# fresh rescan would: the revalidation guard reconciles the index with the live +# directory, so a duplicate title appearing mid-pass preserves the candidate. +reset_fixture +: > "$FIXTURE_DIR/late-duplicate" +fm_herdr_session_cleanup >/dev/null 2>&1 +[ -f "$FM_STATE_OVERRIDE/$ID.herdr-presentation" ] \ + || fail "mid-pass duplicate journal retired the candidate" +[ -f "$FM_STATE_OVERRIDE/fm-task.herdr-presentation" ] \ + || fail "mid-pass duplicate journal was itself retired" +[ ! -s "$CLOSE_LOG" ] \ + || fail "mid-pass duplicate journal closed the pane" +pass "guards catch a journal that appears after discovery" + +# An unrelated journal that appears after discovery is re-parsed by the guards +# alone, and a still-unique candidate completes its exact close anyway. +reset_fixture +write_v1 probe-idle XyXyXyXyXyXyXyXyXyXyX0 +: > "$FIXTURE_DIR/late-extra" +_parse_calls=0 +fm_herdr_session_cleanup >/dev/null 2>&1 +[ ! -e "$FM_STATE_OVERRIDE/$ID.herdr-presentation" ] \ + || fail "mid-pass unrelated journal change preserved a closable candidate" +[ -f "$FM_STATE_OVERRIDE/probe-extra.herdr-presentation" ] \ + || fail "mid-pass unrelated journal change retired the wrong journal" +[ "$(wc -l < "$CLOSE_LOG" | tr -d ' ')" = 1 ] \ + || fail "mid-pass unrelated journal change did not close exactly once" +[ "$_parse_calls" = 3 ] \ + || fail "mid-pass change parsed journals $_parse_calls times instead of exactly the changed journal" +pass "guards re-parse only the changed journal and still close" + +# A journal parked as a symlink at pass start has no row, but the digest must +# make its byte-identical materialization a visible change the same way a +# rewrite is visible, so the guards preserve the pane while two same-titled +# journals exist instead of closing on the one indexed row. +reset_fixture +write_v1 fm-task +cp "$FM_STATE_OVERRIDE/fm-task.herdr-presentation" "$FIXTURE_DIR/mirror-journal" +rm -f "$FM_STATE_OVERRIDE/fm-task.herdr-presentation" +ln -s "$FIXTURE_DIR/mirror-journal" "$FM_STATE_OVERRIDE/fm-task.herdr-presentation" +: > "$FIXTURE_DIR/late-materialized-journal" +fm_herdr_session_cleanup >/dev/null 2>&1 +[ -f "$FM_STATE_OVERRIDE/$ID.herdr-presentation" ] \ + || fail "byte-identical materialized journal retired the candidate" +[ -f "$FM_STATE_OVERRIDE/fm-task.herdr-presentation" ] \ + || fail "byte-identical materialized journal was itself retired" +[ ! -s "$CLOSE_LOG" ] \ + || fail "byte-identical materialized journal closed the pane" +pass "guards catch a byte-identical journal materializing from a symlink" + +# The state directory itself swapping mid-pass for a symlink to byte-identical +# journal copies outside the effective FM_HOME invalidates the reconciled +# index: the guards must preserve the candidate and leave both journal stores +# untouched, the same way the base per-guard live rescan skipped a symlinked +# state directory. +reset_fixture +mkdir -p "$OUTSIDE_HOME_STATE" +cp "$FM_STATE_OVERRIDE/$ID.herdr-presentation" "$OUTSIDE_HOME_STATE/$ID.herdr-presentation" +: > "$FIXTURE_DIR/late-state-symlink" +fm_herdr_session_cleanup >/dev/null 2>&1 +[ -f "$FM_STATE_OVERRIDE.preserved/$ID.herdr-presentation" ] \ + || fail "symlinked state directory retired the home journal" +[ -f "$OUTSIDE_HOME_STATE/$ID.herdr-presentation" ] \ + || fail "symlinked state directory acted on a journal outside the effective FM_HOME" +[ ! -s "$CLOSE_LOG" ] \ + || fail "symlinked state directory closed the pane" +rm -f "$FM_STATE_OVERRIDE" +mv "$FM_STATE_OVERRIDE.preserved" "$FM_STATE_OVERRIDE" +pass "guards preserve both stores when the state directory becomes a symlink" + +# Candidate discovery must parse each home-local journal exactly once per +# cleanup pass however many grammar-matching candidate workspaces exist. +rm -f "${FM_STATE_OVERRIDE:?}"/*herdr-presentation +write_v1 probe-a ZzZzZzZzZzZzZzZzZzZzA0 +write_v1 probe-b ZzZzZzZzZzZzZzZzZzZzB0 +write_v1 probe-c ZzZzZzZzZzZzZzZzZzZzC0 +write_v1 probe-d ZzZzZzZzZzZzZzZzZzZzD0 +fm_backend_herdr_cli() { + local _session=$1; shift + [ "${1:-} ${2:-}" = "workspace list" ] || return 1 + printf '%s\n' '{"result":{"workspaces":[' + printf '%s\n' '{"workspace_id":"w1","label":"firstmate","focused":true,"active_tab_id":"w1:t1","tab_count":1,"pane_count":1},' + printf '%s\n' '{"workspace_id":"wx1","label":"└ sonar-1 · p:Sonar0000000000000000A","focused":false,"active_tab_id":"wx1:t1","tab_count":1,"pane_count":1},' + printf '%s\n' '{"workspace_id":"wx2","label":"└ sonar-2 · p:Sonar0000000000000000B","focused":false,"active_tab_id":"wx2:t1","tab_count":1,"pane_count":1},' + printf '%s\n' '{"workspace_id":"wx3","label":"└ sonar-3 · p:Sonar0000000000000000C","focused":false,"active_tab_id":"wx3:t1","tab_count":1,"pane_count":1},' + printf '%s\n' '{"workspace_id":"wx4","label":"└ sonar-4 · p:Sonar0000000000000000D","focused":false,"active_tab_id":"wx4:t1","tab_count":1,"pane_count":1}]}}' +} +_parse_calls=0 +fm_herdr_session_cleanup >/dev/null 2>&1 +_journals_left=0 +for _journal in "${FM_STATE_OVERRIDE:?}"/*herdr-presentation; do + [ -f "$_journal" ] && _journals_left=$((_journals_left + 1)) +done +[ "$_journals_left" = 4 ] \ + || fail "parse-count probe pass removed journals" +[ "$_parse_calls" = 4 ] \ + || fail "candidate discovery parsed journals per candidate ($_parse_calls) instead of once per pass" +pass "four grammar candidates parse four journals exactly once per pass" + INTEGRATION_ROOT="$TMP_ROOT/bootstrap-integration" mkdir -p "$INTEGRATION_ROOT/home/state" "$INTEGRATION_ROOT/home/data" "$INTEGRATION_ROOT/home/config" cp -R "$ROOT/bin" "$INTEGRATION_ROOT/bin"