Skip to content

fix(herdr): retire a created workspace a foreign pane keeps alive - #2637

Open
Inthuson wants to merge 4 commits into
kunchenguid:mainfrom
Inthuson:fm/herdr-sidebar-workspace-leak-l3
Open

Inthuson wants to merge 4 commits into
kunchenguid:mainfrom
Inthuson:fm/herdr-sidebar-workspace-leak-l3

Conversation

@Inthuson

Copy link
Copy Markdown
Contributor

Intent

Stop firstmate leaking herdr workspaces. When a plugin keeps a pane in a workspace, firstmate's task teardown no longer removes that workspace, and they accumulate without bound.

The diagnosis was done and confirmed before this work started, and was not to be redone. Firstmate never closed a workspace explicitly; it closed only its task tab and relied on herdr's behaviour that closing a workspace's last tab deletes the workspace itself. The captain installed the herdr-sidebar plugin (https://github.com/alexarthurs/herdr-sidebar), which keeps a permanent pane in every workspace, so the task tab is never the last tab. Confirmed by controlled comparison on 2026-08-19, not just by reading: across eight workspaces, the only one WITHOUT a sidebar pane was removed on teardown, and every one WITH a sidebar pane survived. Same code path, same host, minutes apart.

What to build: make firstmate remove a workspace IT CREATED once no firstmate task pane remains in it, without ever stealing the captain's focus. Most of the machinery already existed (fm_backend_herdr_emptying_close_plan, fm_backend_herdr_workspace_prune_seeded_default_tab, fm_backend_herdr_workspace_presence_state, docs/herdr-backend.md), so the task was to study it first and reuse it rather than build something new.

Five constraints were stated as load-bearing, all deliberate captain decisions rather than incidental preferences:

  1. Only ever close a workspace firstmate CREATED. An ADOPTED workspace is never prunable and the adapter is explicit about that. Getting this wrong closes something the captain owns. The container-ensure path already distinguishes them: an adopted workspace returns an EMPTY seeded tab id.
  2. Never steal focus, and never close the focused workspace or active tab. Teardown already refuses on this and that refusal must survive. Verified on 2026-08-19: closing four non-focused workspaces on herdr 0.8.0 kept focus on the captain's own workspace throughout.
  3. Respect the version difference. docs/herdr-backend.md records that 0.7.5's explicit close moves focus to a neighbour when it empties a non-focused workspace, fixed in 0.8.0. Do not assume 0.8.0.
  4. Match on "no firstmate task pane remains", NOT on the label "Sidebar". The plugin could be renamed, another plugin could do the same thing, or the captain could add a pane by hand. A label-matching fix is a fix that breaks again quietly.
  5. A leftover pane is not permission to close a pane firstmate does not own. Close the WORKSPACE, do not close the plugin's pane to force the last-tab behaviour. Killing a captain's plugin pane to trigger a side effect is the wrong shape and would surprise them.

Required regression coverage: a firstmate-created workspace containing a task pane PLUS one extra non-task pane must be gone after teardown, and the same workspace with only the task pane must also be gone. Both cases, and neither may move focus. tests/fm-backend-herdr-focus-flash-e2e.test.sh already reproduces the focus-steal and was named as the model to follow.

A herdr lab contract was mandatory for this task and was followed for every live check: every create, close, focus or move went through bin/fm-herdr-lab.sh in a named non-default lab, never against the default session where the captain's own session and a live worker are running. Direct herdr server or session lifecycle commands and any call scoped only by ambient HERDR_SESSION were forbidden, including for read-only probes and cleanup after failure. The helper's before/after default-session tripwire stayed armed.

The repo is firstmate's own shared tracked material, so .claude/skills/firstmate-coding-guidelines was read first and applied: knowledge-placement decision tree, one-owner rule, one sentence per line in tracked Markdown, plain dash and never an em dash anywhere including the commit message and PR body, no agent co-author trailer, tests that exercise behaviour through an executable interface and never assert implementation-source bytes, colocated tests extending existing runners, and the harness-dependent-checks rule requiring a portable regression plus a live guard plus dated evidence in docs/verification/runtime-backends.md.

Implementation decisions made while doing the work, which a reviewer reading only the diff would not know:

  • The created-versus-adopted proof is recorded durably at spawn time as herdr_workspace_created=1 in the task's metadata, because only the spawning process knows which happened. It is derived from the create response (the projected create, the validated version 2 reclaim binding, and the flat container-ensure path's non-empty seeded tab id), never inferred later from a label, title, or tab count. It was added to preserve_relaunch_meta's owned key list so a relaunch recomputes it rather than carrying a stale value.
  • The retirement lives in one new adapter function, fm_backend_herdr_workspace_retire_created, that takes the literal string "created" as an explicit proof argument so no call site can reach the close without stating it. Without that proof it returns before making any herdr call at all.
  • Constraint 4 is implemented as a scan of this home's own recorded task metadata (any other task naming the same session and workspace, or whose recorded pane is still in the live pane list), plus a positive agent-state check on every remaining pane. No label is ever matched.
  • Constraint 3 is honoured by running the exact prior-tab restore after every close unconditionally rather than gating the retirement on the 0.8.0 floor. Gating it would leak the workspaces nothing else removes, which is the same authorized-containment argument the adapter header already makes for session-start cleanup.
  • The teardown call site runs after the exact task pane is confirmed gone, requires the session presentation lock to be held, and treats a refusal or failure as a warning rather than a teardown blocker, because the endpoint is already gone and the durable records must still be reclaimed.
  • Deliberately accepted and documented limit: in the flat one-workspace-per-home layout the created proof lives only with the task whose own spawn created the workspace, so a relaunch that adopts it loses the proof and a creating task that tears down while another task still holds a pane there refuses the close. That bounds the flat leak at one workspace per home rather than one per task, and it is recorded under Active limits in docs/herdr-backend.md rather than hidden.
  • A dedicated teardown fixture was written instead of extending the existing herdr projection teardown fixture, because giving that shared fixture one-tab and one-pane responses would have pushed the three existing projection tests onto the workspace.move capability path and changed their behaviour.
  • Non-vacuity was proven by negative control: with the teardown call site disabled the new foreign-pane regression fails, and it passes again with the call site restored.
  • The new live guard tests/fm-backend-herdr-workspace-retire-e2e.test.sh is registered in the real-herdr-gated family in bin/fm-test-run.sh, which is the family the required CI herdr lane runs with --fail-on-gate-skip, so it does not silently skip.

Verification performed: tests/fm-teardown.test.sh and tests/fm-backend-herdr.test.sh both green, tests/fm-test-run.test.sh green, shellcheck 0.11.0 clean, bin/fm-doc-audience-check.sh clean, and the live guard run against real herdr 0.8.0 protocol 19 through the guarded lab producing leak_live=1 (the leak reproduced live before the fix ran) with exact focus preserved in every sample and the default session verified byte-identical afterward. actionlint is not installed on this host, so bin/fm-lint.sh exits nonzero on that step alone; no workflow files were changed.

Delivery: firstmate ships through the full validation pipeline. Do not touch the default herdr session, the captain's workspace w1Q, or the live research workspace w1Z.

What Changed

  • Adds fm_backend_herdr_workspace_retire_created to bin/backends/herdr.sh, which closes the workspace itself only under a literal created proof argument and only after every gate passes: structured workspace presence, an unambiguous focus snapshot, the target not being the focused workspace or holding the captain's active tab, a positively dead or unregistered agent state on every remaining pane, and no other still-recorded task in this home naming that session and workspace or holding a live recorded pane there. It restores the exact prior tab behind the close and confirms removal, and it never closes a tab or pane firstmate does not own.
  • bin/fm-spawn.sh records the durable created-versus-adopted proof herdr_workspace_created=1 from the create response (projected create, validated version 2 reclaim binding, and a non-empty seeded tab id on the flat container-ensure path), and adds the key to preserve_relaunch_meta so a relaunch recomputes it instead of carrying a stale value. bin/fm-teardown.sh calls the retirement after the exact task pane is confirmed gone, requires the session presentation lock, and treats a refusal or failure as a warning rather than a teardown blocker; its journal-quarantine warning now states why the journal was not retired.
  • Adds the live guard tests/fm-backend-herdr-workspace-retire-e2e.test.sh (leak measurement, foreign-pane close, lone-task-pane no-op, adopted-workspace refusal) and registers it in the real-herdr-gated family in bin/fm-test-run.sh, plus portable coverage of the retirement refusals and the teardown call site in tests/fm-backend-herdr.test.sh and tests/fm-teardown.test.sh and of the proof surviving relaunch in tests/fm-control-relaunch.test.sh. docs/herdr-backend.md documents the retirement, the metadata key, and the flat-layout limit; docs/verification/runtime-backends.md records the dated 0.8.0 protocol 19 run.

Risk Assessment

✅ Low: The change introduces a genuinely destructive operation teardown never performed before, but it is narrowly scoped to a durable created-versus-adopted proof recorded at spawn, guarded by seven independent refusals that all fail toward leaving the workspace in place, non-blocking on refusal, and covered by portable adapter, teardown and relaunch regressions plus an unconditional live guard, with only one informational hardening nit remaining.

Testing

Ran the targeted regression set (fm-teardown, fm-backend-herdr, fm-control-relaunch, fm-test-run) all green, then proved the intent live: the registered guard against real herdr 0.8.0 protocol 19 reproduced the leak before the fix path ran (leak_live=1), drove a real workspace close (live_close=1), and kept exact focus in every sample, and a hand-driven lab transcript shows the captain's workspace strip with the task workspace surviving its pane close while a plugin sidebar pane remains and then being removed, the foreign pane never closed, focus unchanged, and an adopted workspace refused before any Herdr call. Non-vacuity was confirmed by restoring the baseline teardown call site in an out-of-tree copy, where the required foreign-pane regression fails with the reported symptom. No visual artifact was captured because the change has no rendered surface of its own: the user-visible surface is herdr's workspace list, captured here as real CLI output, and attaching to a herdr session to screenshot the workspace strip would itself move the captain's focus, which this change exists to prevent. One modified real-Herdr test (presentation e2e) cannot complete on this host due to a pre-existing treehouse logical-versus-physical $HOME path mismatch that aborts teardown before the new code; CI's herdr lane owns that coverage. All Herdr work went through the guarded lab helper, every lab teardown verified the default session byte-identical, and the treehouse pool residue my run created was removed, leaving the worktree clean.

Evidence: Live herdr 0.8.0 transcript: the captain's workspace strip before the leak, at the leak, and after the retirement

CASE 1 firstmate-created task workspace + one extra pane firstmate does not own (plugin sidebar) -- BEFORE teardown -- workspace focused label w1 yes captains-own-workspace w2 no fm-task-leak-demo w3 no bystander-workspace focus: workspace w1 tab w1:t1 -- teardown step 1: close the exact task pane w2:p1 (production path) -- task pane now: pane_not_found -- state after the pane close: THIS IS THE LEAK (pre-fix teardown stopped here) -- workspace focused label w1 yes captains-own-workspace w2 no fm-task-leak-demo w3 no bystander-workspace foreign pane w2:p2: present -- teardown step 2 (the fix): retire the workspace firstmate CREATED -- exit=0 output='' herdr calls the retirement made: herdr workspace list / tab list / pane list / pane get w2:p2 / agent get w2:p2 herdr workspace close w2 herdr workspace list / tab list --workspace w1 -- AFTER teardown -- workspace focused label w1 yes captains-own-workspace w3 no bystander-workspace focus: workspace w1 tab w1:t1 foreign pane w2:p2: gone with its workspace: pane_not_found CASE 2 same workspace with ONLY the task pane -- AFTER teardown (retirement is a silent no-op: exit=0 output='') -- w4 fm-task-lone-demo is gone; focus still workspace w1 tab w1:t1 herdr calls the retirement made (a presence probe only, no close): workspace list CASE 3 an ADOPTED workspace the captain owns retirement without the created proof: exit=2 output='' herdr calls made: 0 (refused before talking to Herdr at all) w6 captains-adopted-workspace still present, its foreign pane w6:p2 present == lab teardown == lab fm-lab-fm-leak-evidence-... torn down; default session verified byte-identical

== lab session: fm-lab-fm-leak-evidence-2941224-14849 (non-default, guarded) ==
herdr client 0.8.0 protocol 19

==========================================================================
CASE 1  firstmate-created task workspace + one extra pane firstmate does
        not own (plugin sidebar). This is the shape that used to leak.
==========================================================================

-- BEFORE teardown --
  workspace  focused  label
  w1           yes    captains-own-workspace
  w2            no    fm-task-leak-demo
  w3            no    bystander-workspace
  focus: workspace w1 tab w1:t1

-- teardown step 1: close the exact task pane w2:p1 (production path) --
  task pane now: pane_not_found

-- state after the pane close: THIS IS THE LEAK (pre-fix teardown stopped here) --
  workspace  focused  label
  w1           yes    captains-own-workspace
  w2            no    fm-task-leak-demo
  w3            no    bystander-workspace
  foreign pane w2:p2: present

-- teardown step 2 (the fix): retire the workspace firstmate CREATED --
  exit=0 output=''
  herdr calls the retirement made:
    herdr workspace list
    herdr workspace list
    herdr tab list --workspace w1
    herdr tab list --workspace w2
    herdr pane list --workspace w2
    herdr pane get w2:p2
    herdr agent get w2:p2
    herdr workspace close w2
    herdr workspace list
    herdr workspace list
    herdr tab list --workspace w1

-- AFTER teardown --
  workspace  focused  label
  w1           yes    captains-own-workspace
  w3            no    bystander-workspace
  focus: workspace w1 tab w1:t1
  foreign pane w2:p2: gone with its workspace: pane_not_found

==========================================================================
CASE 2  same workspace with ONLY the task pane (Herdr's own last-tab rule)
==========================================================================

-- BEFORE teardown --
  workspace  focused  label
  w1           yes    captains-own-workspace
  w3            no    bystander-workspace
  w4            no    fm-task-lone-demo
  w5            no    bystander-workspace-2
  focus: workspace w1 tab w1:t1

-- AFTER teardown (retirement is a silent no-op: exit=0 output='') --
  workspace  focused  label
  w1           yes    captains-own-workspace
  w3            no    bystander-workspace
  w5            no    bystander-workspace-2
  focus: workspace w1 tab w1:t1
  herdr calls the retirement made (a presence probe only, no close):
    herdr workspace list

==========================================================================
CASE 3  an ADOPTED workspace the captain owns: no created proof, no close
==========================================================================
  retirement without the created proof: exit=2 output=''
  herdr calls made: 0 (0 = it refused before talking to Herdr at all)

  workspace  focused  label
  w1           yes    captains-own-workspace
  w3            no    bystander-workspace
  w5            no    bystander-workspace-2
  w6            no    captains-adopted-workspace
  focus: workspace w1 tab w1:t1
  adopted workspace's foreign pane w6:p2: present

== lab teardown (default-session tripwire must verify) ==
lab fm-lab-fm-leak-evidence-2941224-14849 torn down; default session verified byte-identical
Evidence: Live guard against real herdr: leak reproduced live, workspace close driven, focus preserved

ok - leak reproduced: closing the task pane left the whole workspace behind because a foreign pane remained ok - cleanup: the surviving created workspace was closed with exact focus preserved in every sample ok - lone task pane: the workspace goes with it and cleanup stays a silent no-op ok - adopted workspace: cleanup refuses before any Herdr call and leaves it exactly as it was evidence: herdr=0.8.0 protocol=19 leak_live=1 live_close=1 default-session-tripwire=armed

FM_TEST_BEGIN 2026-08-19T17:40:59Z tests/fm-backend-herdr-workspace-retire-e2e.test.sh family=real-herdr-gated expected_gate_skip=herdr
ok - leak reproduced: closing the task pane left the whole workspace behind because a foreign pane remained
ok - cleanup: the surviving created workspace was closed with exact focus preserved in every sample
ok - lone task pane: the workspace goes with it and cleanup stays a silent no-op
ok - adopted workspace: cleanup refuses before any Herdr call and leaves it exactly as it was
evidence: herdr=0.8.0 protocol=19 leak_live=1 live_close=1 default-session-tripwire=armed
FM_TEST_END 2026-08-19T17:41:01Z tests/fm-backend-herdr-workspace-retire-e2e.test.sh exit=0 duration_ms=2113 gate_skip=false
FM_TEST_SUMMARY total=1 failed=0 skipped_gate=0 duration_ms=2167
FM_TEST_SUMMARY_FAMILY family=real-herdr-gated count=1 duration_ms=2113 failed=0
FM_TEST_SLOWEST rank=1 script=tests/fm-backend-herdr-workspace-retire-e2e.test.sh duration_ms=2113
Evidence: Negative control: the required regression fails with the baseline teardown call site restored

# out-of-tree copy of HEAD with only bin/fm-teardown.sh reverted to 03bb1d8 ok - herdr projection teardown surfaces failed focus restoration without turning confirmed cleanup into a hard failure not ok - herdr-retire-foreign-pane: the workspace a foreign pane kept alive survived teardown # exit=1 ; the same case passes with the fix in place

ok - local-only worktree with HEAD on a fork remote is torn down (fix holds)
ok - teardown prompts tasks-axi backlog refresh when compatible
ok - teardown honors config/backlog-backend=manual even when tasks-axi is compatible
ok - local-only worktree with truly unpushed work is refused (safety preserved)
ok - local-only worktree with work merged into local main is torn down (no regression)
ok - no-mistakes worktree with HEAD on origin is torn down (no regression)
ok - no-mistakes worktree with genuinely unlanded work is refused (safety preserved)
ok - local-only worktree with unpushed work is torn down under --force (escape hatch)
ok - teardown completes when an exact busy-state sidecar is already absent
ok - herdr teardown removes pane-owned escalation dedupe state
ok - herdr flat teardown refuses before returning the isolated copy under lock contention and the retry completes cleanly
ok - herdr flat teardown never erases records when pane presence is unparseable
ok - herdr flat teardown preflight refuses before every destructive change
ok - forced secondmate teardown preflights every Herdr child before cleanup mutation
ok - forced secondmate teardown holds every descendant lifecycle and metadata lock
ok - forced secondmate teardown retains Herdr child identity until exact pane disappearance
ok - forced teardown retains a nested secondmate home and its grandchild's Herdr identity when the grandchild close is unconfirmed
ok - herdr projection teardown retires its journal only after confirming the exact recorded pane is gone
ok - herdr projection teardown retains every record when post-close presence is unknown
ok - herdr projection teardown surfaces failed focus restoration without turning confirmed cleanup into a hard failure
not ok - herdr-retire-foreign-pane: the workspace a foreign pane kept alive survived teardown
Evidence: Evidence harness used for the live transcript (all Herdr calls routed through bin/fm-herdr-lab.sh)
#!/usr/bin/env bash
# Evidence harness (not part of the repo): show, on real Herdr, what the captain
# sees before and after firstmate tears a task down.
#
# Every create / close / focus goes through bin/fm-herdr-lab.sh in one named
# non-default lab session. No direct `herdr` call and no HERDR_SESSION-only call
# is made from this script; the shim below strips the adapter's own trailing
# --session pair and delegates to `fm-herdr-lab.sh run`.
set -u

ROOT=${1:?repo root required}
HERDR_LAB_HELPER="$ROOT/bin/fm-herdr-lab.sh"
HERDR_ORIGINAL_PATH=$PATH
TMP=$(mktemp -d "${TMPDIR:-/tmp}/fm-leak-evidence.XXXXXX")
FAKEBIN="$TMP/fakebin"
mkdir -p "$FAKEBIN"

SESSION=$("$HERDR_LAB_HELPER" name fm-leak-evidence)
export HERDR_LAB_HELPER HERDR_ORIGINAL_PATH HERDR_LAB_SESSION="$SESSION"

cleanup() {
  local status=$?
  echo
  echo "== lab teardown (default-session tripwire must verify) =="
  env PATH="$HERDR_ORIGINAL_PATH" "$HERDR_LAB_HELPER" teardown "$SESSION" \
    && echo "lab $SESSION torn down; default session verified byte-identical" \
    || status=1
  rm -rf "$TMP"
  exit "$status"
}
trap cleanup EXIT

"$HERDR_LAB_HELPER" provision "$SESSION" >/dev/null
echo "== lab session: $SESSION (non-default, guarded) =="
env PATH="$HERDR_ORIGINAL_PATH" "$HERDR_LAB_HELPER" run "$SESSION" status --json \
  | jq -r '"herdr client " + .client.version + " protocol " + (.client.protocol|tostring)'

cat > "$FAKEBIN/herdr" <<'SH'
#!/usr/bin/env bash
set -u
args=("$@")
last=$((${#args[@]} - 1))
flag=$((last - 1))
if [ "${#args[@]}" -ge 2 ] \
  && [ "${args[$flag]}" = --session ] \
  && [ "${args[$last]}" = "$HERDR_LAB_SESSION" ]; then
  unset "args[$last]" "args[$flag]"
fi
set -- "${args[@]}"
for arg in "$@"; do
  case "$arg" in --session|--session=*) exit 9 ;; esac
done
exec env PATH="$HERDR_ORIGINAL_PATH" "$HERDR_LAB_HELPER" run "$HERDR_LAB_SESSION" "$@"
SH
chmod +x "$FAKEBIN/herdr"

lab() { env PATH="$HERDR_ORIGINAL_PATH" "$HERDR_LAB_HELPER" run "$SESSION" "$@"; }

strip() {  # print the captain's workspace strip as they would read it
  printf '%s\n' "  workspace  focused  label"
  lab workspace list | jq -r '.result.workspaces[]
    | "  " + .workspace_id + (" " * (9 - (.workspace_id|length)))
      + "  " + (if .focused then "  yes  " else "   no  " end)
      + "  " + .label'
}
focus() {
  lab workspace list | jq -r '[.result.workspaces[] | select(.focused)]
    | if length == 1 then "workspace " + .[0].workspace_id + " tab " + .[0].active_tab_id
      else "AMBIGUOUS" end'
}
mkws() {
  lab workspace create --cwd "$ROOT" --label "$1" --no-focus \
    | jq -er '"\(.result.workspace.workspace_id) \(.result.tab.tab_id) \(.result.root_pane.pane_id)"'
}
mktab() {
  lab tab create --workspace "$1" --cwd "$ROOT" --label "$2" --no-focus \
    | jq -er '"\(.result.tab.tab_id) \(.result.root_pane.pane_id)"'
}
adapter() {  # <call-log> <function> [args...]
  local log=$1; shift
  PATH="$FAKEBIN:$HERDR_ORIGINAL_PATH" FM_CALL_LOG="$log" bash -c '
    . "$1/bin/backends/herdr.sh"
    fm_backend_herdr_cli() {
      local session=$1
      shift
      printf "%s\n" "$*" >> "$FM_CALL_LOG"
      HERDR_SESSION="$session" herdr "$@" --session "$session"
    }
    function=$2
    shift 2
    "$function" "$@"
  ' _ "$ROOT" "$@" 2>&1
}
state_dir_for() {  # <dir> <workspace> <pane>
  local d="$1/state"
  mkdir -p "$d"
  printf '%s\n' backend=herdr "herdr_session=$SESSION" \
    "herdr_workspace_id=$2" "herdr_pane_id=$3" herdr_workspace_created=1 \
    > "$d/task-retiring.meta"
  printf '%s\n' "$d"
}

# ---------------------------------------------------------------------------
# Case 1: the reported leak - a firstmate task workspace that also holds a pane
# firstmate does not own (a plugin sidebar tab stands in for it).
# ---------------------------------------------------------------------------
echo
echo "=========================================================================="
echo "CASE 1  firstmate-created task workspace + one extra pane firstmate does"
echo "        not own (plugin sidebar). This is the shape that used to leak."
echo "=========================================================================="
read -r ANCHOR_WS ANCHOR_TAB _ <<<"$(mkws captains-own-workspace)"
read -r TASK_WS _ TASK_PANE <<<"$(mkws 'fm-task-leak-demo')"
read -r SPACER_WS _ _ <<<"$(mkws bystander-workspace)"
read -r _ FOREIGN_PANE <<<"$(mktab "$TASK_WS" 'Sidebar')"
lab tab focus "$ANCHOR_TAB" >/dev/null
: "$SPACER_WS"

echo
echo "-- BEFORE teardown --"
strip
echo "  focus: $(focus)"

echo
echo "-- teardown step 1: close the exact task pane $TASK_PANE (production path) --"
adapter "$TMP/log1" fm_backend_herdr_projection_close_pane_focus_preserving \
  "$SESSION" "$TASK_PANE" || echo "  (pane close reported failure)"
echo "  task pane now: $(lab pane get "$TASK_PANE" 2>&1 | jq -r '.error.code // "still present"')"
echo
echo "-- state after the pane close: THIS IS THE LEAK (pre-fix teardown stopped here) --"
strip
echo "  foreign pane $FOREIGN_PANE: $(lab pane get "$FOREIGN_PANE" 2>&1 | jq -r 'if .result then "present" else .error.code end')"

echo
echo "-- teardown step 2 (the fix): retire the workspace firstmate CREATED --"
RETIRE_STATE=$(state_dir_for "$TMP/c1" "$TASK_WS" "$TASK_PANE")
: > "$TMP/log2"
OUT=$(adapter "$TMP/log2" fm_backend_herdr_workspace_retire_created \
  "$SESSION" "$TASK_WS" created "$RETIRE_STATE" task-retiring)
echo "  exit=$? output='${OUT}'"
echo "  herdr calls the retirement made:"
sed 's/^/    herdr /' "$TMP/log2"

echo
echo "-- AFTER teardown --"
strip
echo "  focus: $(focus)"
echo "  foreign pane $FOREIGN_PANE: $(lab pane get "$FOREIGN_PANE" 2>&1 | jq -r 'if .result then "STILL OPEN (never closed by firstmate)" else "gone with its workspace: " + .error.code end')"

# ---------------------------------------------------------------------------
# Case 2: the same workspace with only the task pane in it.
# ---------------------------------------------------------------------------
echo
echo "=========================================================================="
echo "CASE 2  same workspace with ONLY the task pane (Herdr's own last-tab rule)"
echo "=========================================================================="
read -r LONE_WS _ LONE_PANE <<<"$(mkws 'fm-task-lone-demo')"
read -r _ _ _ <<<"$(mkws bystander-workspace-2)"
lab tab focus "$ANCHOR_TAB" >/dev/null
echo
echo "-- BEFORE teardown --"
strip
echo "  focus: $(focus)"
adapter "$TMP/log3" fm_backend_herdr_projection_close_pane_focus_preserving \
  "$SESSION" "$LONE_PANE" >/dev/null || echo "  (pane close reported failure)"
: > "$TMP/log4"
OUT=$(adapter "$TMP/log4" fm_backend_herdr_workspace_retire_created \
  "$SESSION" "$LONE_WS" created "$(state_dir_for "$TMP/c2" "$LONE_WS" "$LONE_PANE")" task-retiring)
echo
echo "-- AFTER teardown (retirement is a silent no-op: exit=$? output='${OUT}') --"
strip
echo "  focus: $(focus)"
echo "  herdr calls the retirement made (a presence probe only, no close):"
sed 's/^/    herdr /' "$TMP/log4"

# ---------------------------------------------------------------------------
# Case 3: an ADOPTED workspace (no created proof) is never closed.
# ---------------------------------------------------------------------------
echo
echo "=========================================================================="
echo "CASE 3  an ADOPTED workspace the captain owns: no created proof, no close"
echo "=========================================================================="
read -r ADOPTED_WS _ ADOPTED_PANE <<<"$(mkws captains-adopted-workspace)"
read -r _ ADOPTED_FOREIGN <<<"$(mktab "$ADOPTED_WS" 'Sidebar')"
lab tab focus "$ANCHOR_TAB" >/dev/null
: > "$TMP/log5"
OUT=$(adapter "$TMP/log5" fm_backend_herdr_workspace_retire_created \
  "$SESSION" "$ADOPTED_WS" adopted "$(state_dir_for "$TMP/c3" "$ADOPTED_WS" "$ADOPTED_PANE")" task-retiring)
echo "  retirement without the created proof: exit=$? output='${OUT}'"
echo "  herdr calls made: $(wc -l < "$TMP/log5") (0 = it refused before talking to Herdr at all)"
echo
strip
echo "  focus: $(focus)"
echo "  adopted workspace's foreign pane $ADOPTED_FOREIGN: $(lab pane get "$ADOPTED_FOREIGN" 2>&1 | jq -r 'if .result then "present" else .error.code end')"
Evidence: Targeted regression runs (teardown, adapter, relaunch, runner registration)

tests/fm-teardown.test.sh: ok - herdr teardown closes a created workspace a foreign pane would otherwise keep alive ok - herdr teardown leaves no workspace behind when the task pane was the last one in it ok - herdr teardown restores the captain's exact tab when closing a created workspace moves focus ok - herdr teardown warns and still restores the captain's exact tab when the workspace close fails ok - herdr teardown never closes a workspace it cannot prove firstmate created ok - herdr teardown leaves a created workspace in place while an agent is still registered in it tests/fm-backend-herdr.test.sh: twelve created-workspace retirement gates green tests/fm-control-relaunch.test.sh: created proof carried on relaunch, never invented for an adopted workspace

FM_TEST_BEGIN 2026-08-19T17:38:43Z tests/fm-teardown.test.sh family=pr-forge expected_gate_skip=none
ok - local-only worktree with HEAD on a fork remote is torn down (fix holds)
ok - teardown prompts tasks-axi backlog refresh when compatible
ok - teardown honors config/backlog-backend=manual even when tasks-axi is compatible
ok - local-only worktree with truly unpushed work is refused (safety preserved)
ok - local-only worktree with work merged into local main is torn down (no regression)
ok - no-mistakes worktree with HEAD on origin is torn down (no regression)
ok - no-mistakes worktree with genuinely unlanded work is refused (safety preserved)
ok - local-only worktree with unpushed work is torn down under --force (escape hatch)
ok - teardown completes when an exact busy-state sidecar is already absent
ok - herdr teardown removes pane-owned escalation dedupe state
ok - herdr flat teardown refuses before returning the isolated copy under lock contention and the retry completes cleanly
ok - herdr flat teardown never erases records when pane presence is unparseable
ok - herdr flat teardown preflight refuses before every destructive change
ok - forced secondmate teardown preflights every Herdr child before cleanup mutation
ok - forced secondmate teardown holds every descendant lifecycle and metadata lock
ok - forced secondmate teardown retains Herdr child identity until exact pane disappearance
ok - forced teardown retains a nested secondmate home and its grandchild's Herdr identity when the grandchild close is unconfirmed
ok - herdr projection teardown retires its journal only after confirming the exact recorded pane is gone
ok - herdr projection teardown retains every record when post-close presence is unknown
ok - herdr projection teardown surfaces failed focus restoration without turning confirmed cleanup into a hard failure
ok - herdr teardown closes a created workspace a foreign pane would otherwise keep alive
ok - herdr teardown leaves no workspace behind when the task pane was the last one in it
ok - herdr teardown restores the captain's exact tab when closing a created workspace moves focus
ok - herdr teardown warns and still restores the captain's exact tab when the workspace close fails
ok - herdr teardown never closes a workspace it cannot prove firstmate created
ok - herdr teardown leaves a created workspace in place while an agent is still registered in it
ok - squash-merged + deleted-branch worktree (PR merged) is torn down (the fix)
ok - squash-merged PR accepts a local HEAD that is an ancestor of the final PR head
ok - teardown discovers a merged PR by branch name and tears down when no pr= was ever recorded
ok - squash-merged PR accepts replayed unpushed local patches contained in the PR head
ok - merged PR does not allow teardown after a later local commit
ok - fm-pr-check does not refresh PR head after HEAD moves
ok - fm-pr-check records the remote PR head when the local worktree lags
ok - worktree whose content already landed in the default branch is torn down (content fallback)
ok - content fallback refreshes origin default before comparing trees
ok - dirty worktree is refused even when its committed work has landed (dirty always wins)
ok - gh lookup error with content not in default refuses (fail-safe)
ok - provably-stale worktree index.lock (old, no live holder) is cleared and teardown succeeds
ok - live-held worktree index.lock is never removed and teardown refuses
ok - lsof errors leave worktree index.lock in place and refuse teardown
ok - stale lock cleanup rechecks and refuses dirty worktree before return
ok - normal repo index.lock is resolved from the worktree and cleared when stale
ok - lock mtime read failures leave worktree index.lock in place and refuse teardown
ok - transient index.lock cleared after first failed return is retried successfully without force-remove
ok - persistent index.lock exhausts retries and refuses without force-removing the lock
ok - empty retry wait overrides use the default without aborting teardown
ok - fractional legacy retry wait remains supported without arithmetic
ok - a task's own parked no-mistakes run is aborted, not orphaned, before the worker is removed
ok - teardown refuses before reap or removal when a task-owned run remains parked
ok - a different run cannot confirm the targeted abort
ok - empty post-abort status is not accepted as confirmation
ok - the CLI's exact run-not-found signal confirms completion
ok - a parked run on another branch is never aborted by this task's teardown (ownership is precise)
ok - a task-owned autonomous running step is left alone rather than aborted
ok - a leaked descendant process rooted under the task's worktree is reaped by teardown, not left surviving
ok - a leaked descendant process rooted under the task's per-task tasktmp is reaped by teardown too
ok - missing lsof falls back to reaping the tmux pane process group
ok - an erroring lsof scan refuses teardown and preserves the task
ok - a reused pid with a different start time is never force-killed
ok - an exec change preserves birth identity and the process is reaped
ok - a process spawned during grace is reaped on a later pass
ok - persistent leaked processes refuse teardown after bounded retries
ok - a process exiting during identity lookup does not block teardown
ok - the run abort and the leaked-process reap both complete before the destructive worktree return
FM_TEST_END 2026-08-19T17:39:54Z tests/fm-teardown.test.sh exit=0 duration_ms=70798 gate_skip=false
FM_TEST_SUMMARY total=1 failed=0 skipped_gate=0 duration_ms=70851
FM_TEST_SUMMARY_FAMILY family=pr-forge count=1 duration_ms=70798 failed=0
FM_TEST_SLOWEST rank=1 script=tests/fm-teardown.test.sh duration_ms=70798
Evidence: Unrelated host blocker for the modified presentation e2e

ok - real Herdr lab: an opted-out spawn retains the Stage 1 Herdr command sequence with zero ordering calls not ok - opted-out teardown failed: ... worktree /local/home/inthuson/.treehouse/project-70889f/2/project is not managed by treehouse error: treehouse return failed for worktree ...; teardown aborted # treehouse-state.json recorded the same worktree as /home/inthuson/.treehouse/project-70889f/2/project # /home/inthuson -> /local/home/inthuson ; abort is at bin/fm-teardown.sh:2453, before the new retirement block

FM_TEST_BEGIN 2026-08-19T17:44:17Z tests/fm-backend-herdr-presentation-e2e.test.sh family=real-herdr-gated expected_gate_skip=herdr
ok - real Herdr lab: an opted-out spawn retains the Stage 1 Herdr command sequence with zero ordering calls
not ok - opted-out teardown failed: teardown: reaping leaked worktree process(es) for shape: 2965110 2965553
teardown: force-killing leaked worktree process(es) for shape: 2965110
worktree /local/home/inthuson/.treehouse/project-70889f/2/project is not managed by treehouse
error: treehouse return failed for worktree /local/home/inthuson/.treehouse/project-70889f/2/project; teardown aborted
FM_TEST_END 2026-08-19T17:44:28Z tests/fm-backend-herdr-presentation-e2e.test.sh exit=1 duration_ms=11374 gate_skip=false
FM_TEST_SUMMARY total=1 failed=1 skipped_gate=0 duration_ms=11426
FM_TEST_SUMMARY_FAMILY family=real-herdr-gated count=1 duration_ms=11374 failed=1
FM_TEST_SLOWEST rank=1 script=tests/fm-backend-herdr-presentation-e2e.test.sh duration_ms=11374
- Outcome: ⚠️ 1 warning across 1 run (11m29s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • 🚨 bin/fm-spawn.sh:2681 - HERDR_WORKSPACE_CREATED is only initialized at bin/fm-spawn.sh:1906, inside the case &#34;$BACKEND&#34; block that is skipped entirely when RELAUNCH=1 (the if [ &#34;$RELAUNCH&#34; -eq 1 ]; then ... else case ... esac fi spanning lines 1849-2106). Line 2681 dereferences it unconditionally for BACKEND=herdr, so under set -eu (line 197) every fm-spawn.sh --relaunch of a herdr task dies with "HERDR_WORKSPACE_CREATED: unbound variable" while writing the replacement meta. This is reachable in the product through bin/fm-control.sh:814, and no test covers --relaunch with backend=herdr (tests/fm-control-relaunch.test.sh has no herdr fixture), so nothing catches it. The same omission is also a semantic defect: relaunch re-reads herdr_session/herdr_workspace_id/herdr_tab_id/herdr_pane_id from RELAUNCH_META at lines 1031-1034 but never reads herdr_workspace_created, while preserve_relaunch_meta now strips it as an owned key, so a relaunched task would permanently lose the created proof and leak its workspace. That contradicts the new docs/herdr-backend.md line "it survives a relaunch that rewrites the endpoint". Fix both by reading it from RELAUNCH_META in the herdr block at lines 1030-1035 (normalizing to 0/1); relaunch cannot change the workspace, since a structurally gone pane already refuses the relaunch, so carrying the recorded value forward is never stale.
  • 🚨 tests/fm-backend-herdr-presentation-e2e.test.sh:741 - The new herdr_workspace_created=1 meta line breaks this existing test's byte comparison. The shape task is spawned once with presentation off (flat path: it ADOPTS the workspace anchor created at line 501, so fm_backend_herdr_workspace_ensure returns an empty seeded tab id and no key is written) and once projected (line 601: fm_backend_herdr_projection_create_task sets HERDR_WORKSPACE_CREATED=1, so the key IS written). normalize_meta at line 424 masks only window/herdr_workspace_id/herdr_tab_id/herdr_pane_id/spawn_gen, so the ON meta now carries one extra line and cmp -s at line 741 fails with "metadata changed beyond Herdr container IDs between opted-out and projected paths". This file is registered in the real-herdr-gated family at bin/fm-test-run.sh:162, which is the family the CI herdr lane runs. Fix by adding the new key to normalize_meta (for example -e &#39;/^herdr_workspace_created=/d&#39;) and, if the created-versus-adopted difference is worth pinning there, asserting it explicitly rather than through the byte compare.
  • ⚠️ bin/backends/herdr.sh:1952 - The recorded-task scan reads other tasks' herdr_session, herdr_workspace_id, and herdr_pane_id with sed -n &#39;s/^key=//p&#39; | head -n 1, i.e. first-occurrence-wins. Every other reader of this same meta format uses different semantics: fm_meta_get (bin/fm-backend.sh:338) is documented as last-value-wins, and fm_backend_meta_exact_value (bin/fm-backend.sh:371) refuses outright unless the key appears exactly once, precisely because destructive teardown must treat ambiguity as a refusal. If another task's record ever carries a duplicated herdr_workspace_id or herdr_pane_id, this gate reads a value the rest of the system does not, skips the match at lines 1955 and 1961, and licenses a workspace close on a workspace a live task still holds. Make this scan refuse on a duplicated or ambiguous key (or at minimum match fm_meta_get's last-wins) so the destructive gate fails toward refusal like the endpoint validator does.
  • ℹ️ tests/fm-teardown.test.sh:2104 - The fixture defines FM_FAKE_HERDR_WS_CLOSE_FAIL but no test ever sets it, so the adapter's workspace-close failure branch at bin/backends/herdr.sh:1968-1972 (warn, run the prior-tab restore, return 1) is uncovered in both the portable teardown suite and the adapter suite - the adapter suite covers the unconfirmed-removal branch but not the failed-close branch. Either exercise the knob with a case that asserts the warning plus the restore, or drop the dead knob.

🔧 Fix: carry created-workspace proof through relaunch, refuse ambiguous records
4 issues (1 warning, 3 infos) still open:

  • ⚠️ bin/fm-teardown.sh:2513 - The quarantined-journal warning is now factually wrong because of this change. It prints "herdr presentation journal for $ID remains quarantined; no workspace cleanup was attempted", but the new created-workspace retirement block at bin/fm-teardown.sh:2532 runs unconditionally after it and can issue a real workspace close. Concrete path: a projected task (so its meta carries herdr_workspace_created=1 from bin/fm-spawn.sh:2008) whose journal no longer correlates at teardown, so fm_backend_herdr_projection_endpoint_matches_journal fails and HERDR_PRESENTATION_RETIRE_CANDIDATE stays 0; the elif [ &#34;$BACKEND&#34; = herdr ] branch closes the pane through fm_backend_herdr_kill_serialized, line 2513 prints the warning, fm_backend_herdr_endpoint_confirmed_gone at line 2523 passes, and the retirement then closes the workspace. The behavior itself is correct - the retirement's authority is the task's own validated metadata, not the non-authoritative journal - but the operator is told the opposite in exactly the ambiguity path where the docs tell them to inspect manually. Scope the sentence to the journal-authorized cleanup it actually describes (for example "no journal-authorized workspace cleanup was attempted"), leaving the unrelated line 2509 message alone since the endpoint gate below it does still stop the retirement.
  • ℹ️ bin/backends/herdr.sh:2323 - Sibling path note for the durable-fix question, not a demand to widen scope. fm_backend_herdr_projection_cleanup_exact (the same-process abort cleanup invoked from bin/fm-spawn.sh:728) closes the task pane and the seeded pane but never the workspace, and on that path no task meta is written at all, so no herdr_workspace_created proof ever exists. A workspace a plugin pane keeps alive there is therefore permanently outside the new retirement's reach, and bin/fm-herdr-session-cleanup.sh cannot reclaim it either because its candidate test requires tab_count == 1 and pane_count == 1 (bin/fm-herdr-session-cleanup.sh:134). This is bounded by how often a projected create aborts mid-sequence, and docs/herdr-backend.md already routes "crashes, lost responses, failed exact-pane cleanup" to manual cleanup, so the authorized teardown leak this change targets is genuinely closed. Recording it so the remaining shape is known rather than assumed fixed.
  • ℹ️ tests/fm-backend-herdr-workspace-retire-e2e.test.sh:216 - The live guard's entire fix proof is gated on LEAK_LIVE. Part A closes the task pane and sets LEAK_LIVE=1 only if ws_alive (line 97, workspace get) still reports the doomed workspace; if that probe fails for any reason other than a genuinely removed workspace, the script prints "leak note: ..." and skips all of Part B, so fm_backend_herdr_workspace_retire_created is never once driven to an actual workspace close live - Part C only exercises the already-absent no-op and Part D only the proof refusal - and the run still exits 0 with every ok line. That silently defeats the point of registering the file in the real-herdr-gated family that CI runs with --fail-on-gate-skip. The model test at tests/fm-backend-herdr-focus-flash-e2e.test.sh keeps its mitigation assertions unconditional and gates only the extra defective-release checks. Either create a fresh doomed workspace for Part B so the close is always exercised, or distinguish "workspace absent" from "probe unreadable" and fail on the latter.
  • ℹ️ tests/fm-teardown.test.sh:2034 - configure_herdr_workspace_retire_case exports FM_FAKE_HERDR_EXTRA_PANE into the whole test process, unlike every other knob in this fixture family (FM_FAKE_HERDR_WS_CLOSE_FAIL, FM_FAKE_HERDR_STEAL_FOCUS, FM_FAKE_HERDR_LIVE_AGENT) which callers pass as leading assignments, and unlike run_workspace_retire_teardown's own marker variables. It is the only export FM_FAKE_* in the file, and it stays set at whatever the last retire case chose for every test that runs after it. Nothing downstream reads it today, so this is hygiene rather than a live defect, but a future case that forgets to call configure_herdr_workspace_retire_case would silently inherit a stale foreign-pane setting. Pass it through run_workspace_retire_teardown from a case-scoped variable instead.

🔧 Fix: correct quarantine warning, make live retirement proof unconditional
1 info still open:

  • ℹ️ bin/fm-teardown.sh:2542 - herdr_workspace_created is the single key that authorizes the new destructive workspace close, but it is the one endpoint field on that path still read with last-value-wins semantics: meta_value delegates to fm_meta_get (bin/fm-backend.sh:337-342, documented as last-wins). Every other field that reaches the same close is exact-value validated - fm_backend_validate_task_endpoint runs fm_backend_meta_exact_value over herdr_session, herdr_workspace_id, herdr_tab_id and herdr_pane_id (bin/fm-backend.sh:459-462), which refuses outright unless the key appears exactly once, and the previous round extended that same refuse-on-duplicate contract to other tasks' records inside the adapter (bin/backends/herdr.sh:1957-1964). A record carrying two herdr_workspace_created lines therefore licenses the close on a value the rest of the destructive path would have refused to resolve. This is hardening consistency, not a live defect: spawn writes the key at most once (bin/fm-spawn.sh:2691) and preserve_relaunch_meta owns it, so the only way to produce a duplicate is a partial write or a hand edit - which is exactly the class fm_backend_meta_exact_value exists to refuse before destruction. Reading it through fm_backend_meta_exact_value (or an equivalent exactly-once check) would close the gap.
⚠️ **Test** - 1 warning
  • ⚠️ tests/fm-backend-herdr-presentation-e2e.test.sh:421 - tests/fm-backend-herdr-presentation-e2e.test.sh (modified by this change) cannot complete on this host and is not a product defect: treehouse 2.1.1 stores pool paths as /home/inthuson/... while firstmate records the physical /local/home/inthuson/... (because /home/inthuson is a symlink), so treehouse return refuses with "is not managed by treehouse" and teardown aborts at bin/fm-teardown.sh:2453 - 77 lines before the new created-workspace retirement block, on the opted-out flat path that carries no created proof. I cannot fix this from the test phase (it needs host/treehouse path handling, not a repo change), so the required CI herdr lane owns verifying this file's meta-shape adjustment. The change's own behaviour is covered locally by the live retire guard and the fake-herdr teardown cases.
  • bin/fm-test-run.sh tests/fm-teardown.test.sh - all cases green, including the six new created-workspace cleanup cases (foreign-pane leak, lone task pane, focus-move restore, failed-close restore, adopted refusal, registered-agent refusal)
  • bin/fm-test-run.sh tests/fm-backend-herdr.test.sh tests/fm-control-relaunch.test.sh - green, covering the twelve adapter retirement gates and the relaunch created-proof carry/no-carry pair
  • bin/fm-test-run.sh --fail-on-gate-skip &#39;herdr not found&#39; tests/fm-backend-herdr-workspace-retire-e2e.test.sh - live guard against real herdr 0.8.0 protocol 19, reporting leak_live=1 live_close=1 and exact focus preserved in every sample
  • bin/fm-test-run.sh tests/fm-test-run.test.sh - green, covering the new live guard's real-herdr-gated family registration
  • Negative control: git archive HEAD into an out-of-tree copy, restored baseline bin/fm-teardown.sh (03bb1d8) with the new tests kept, then bash tests/fm-teardown.test.sh - fails at herdr-retire-foreign-pane: the workspace a foreign pane kept alive survived teardown
  • Manual live evidence harness /tmp/no-mistakes-evidence/01M0DCEPAT5QNVW8DYKFJPRMEW/live-workspace-leak-transcript.sh - drives fm_backend_herdr_projection_close_pane_focus_preserving then fm_backend_herdr_workspace_retire_created against real herdr through bin/fm-herdr-lab.sh in a named non-default lab, printing the captain's workspace strip and focus before/leak/after for the foreign-pane, lone-pane, and adopted cases
  • bin/fm-test-run.sh --fail-on-gate-skip &#39;herdr not found&#39; tests/fm-backend-herdr-presentation-e2e.test.sh - fails on this host from a pre-existing treehouse path mismatch, isolated to bin/fm-teardown.sh:2453 (before the new retirement block) by reading ~/.treehouse/project-*/treehouse-state.json against the refused path
⚠️ **Document** - 1 info
  • ℹ️ docs/verification/runtime-backends.md:505 - docs/verification/runtime-backends.md:505 records the dated 2026-08-05 whole-lane evidence as family=real-herdr-gated count=11 failed=0. This change adds tests/fm-backend-herdr-workspace-retire-e2e.test.sh to that family, so the lane now selects 13 scripts (it already selected 12 before this change, so the record was one short already). The line is explicitly scoped to that dated run against Herdr 0.7.4 and 0.8.0, so it is not a false current claim, and refreshing it needs a full live real-herdr lane re-run through the guarded lab, which is outside this documentation phase. Suggested follow-up: refresh that count the next time the whole lane is run.
⚠️ **Lint** - 1 warning
  • ⚠️ linter found issues (exit code 127)
✅ **Push** - passed

✅ No issues found.

Task cleanup closed only its own task pane and relied on Herdr removing a
workspace whose last tab went with it. A workspace that also held a pane
firstmate does not own, such as a plugin sidebar or a tab the captain opened by
hand, therefore survived every cleanup, and projected one-task workspaces
accumulated without bound.

Spawn now records herdr_workspace_created=1 for a workspace it created, taken
from the create response rather than inferred from a label, title, or tab
count, and teardown closes that workspace once the task pane is confirmed gone.

The new adapter helper refuses rather than closing when that proof is absent,
when the workspace is the focused one, when a remaining tab is the captain's
active tab, when a remaining pane still has a live or unreadable agent, or
while any other recorded task in this home names that workspace or still has a
pane in it. That recorded-task scan, not a pane label, is the "no firstmate
task pane remains" test, so a renamed plugin, a second plugin, and a
hand-added pane all read the same way. The workspace is what closes; a pane
firstmate does not own is never closed to force Herdr's last-tab behaviour.
The exact prior tab is restored after the close, because Herdr 0.7.5 moves
focus when an explicit close empties a non-focused workspace and the
retirement does not assume 0.8.0.

Coverage: adapter regressions for every refusal and for the confirmed close,
teardown regressions for the foreign-pane case, the lone-task-pane case, focus
restoration, an adopted workspace, and a registered agent, plus a real-Herdr
guard in a guarded named lab that reproduces the leak live and proves the
removal preserves exact focus. Dated evidence is recorded in
docs/verification/runtime-backends.md.
@Inthuson
Inthuson force-pushed the fm/herdr-sidebar-workspace-leak-l3 branch from a90715f to abb8c63 Compare August 21, 2026 08:14
@Inthuson

Copy link
Copy Markdown
Contributor Author

Ready for a maintainer when you have a moment.

This one is complete on our side with all checks green. Nothing further is pending from this fork.

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate: Corrective — teardown now closes a firstmate-CREATED Herdr workspace once the task pane is gone, so a foreign pane (plugin sidebar, hand-added tab) cannot leak it. Adopted workspaces stay refused before any Herdr call; focus is never stolen; leftover panes firstmate does not own are never closed. I reviewed the full diff on HEAD abb8c63d2deb (adapter retire helper, spawn created-proof, teardown call site, live guard + portable tests). No security risk. VISION: aligns (authorized cleanup of firstmate-created work, explicit created proof, fail toward leaving the workspace). Approved fork CI and Require no-mistakes; waiting on green including no-mistakes. File-level overlap with open #2692 on bin/fm-teardown.sh / tests/fm-teardown.test.sh, and with spawn-freshen #2622/#2693/#2154 on bin/fm-spawn.sh (this PR only records herdr_workspace_created).

This was referenced Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants