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() { #
- 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() { #
+ 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() { #
+ 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() { #
+ 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() { #
- 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() { #
+ 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() { #
+ 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() { #
+ 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() { #
+ 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() { #
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() { #
@@ -204,7 +323,7 @@ fm_herdr_cleanup_one() { #
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() { #
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"