Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion bin/backends/zellij.sh
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,12 @@ fm_backend_zellij_pane_exists() { # <session> <pane_id>
fm_backend_zellij_tab_matches_label() { # <session> <tab_id> <label>
local session=$1 tab_id=$2 label=$3 scoped tabs count
scoped=$(fm_backend_zellij_scoped_title "$label")
tabs=$(fm_backend_zellij_cli "$session" action list-tabs --json 2>/dev/null)
tabs=$(fm_backend_zellij_cli "$session" action list-tabs --json 2>/dev/null) || return 1
# An absent or unreachable zellij yields NO output, and `jq -e` over empty
# input exits 0 (verified: jq 1.6) - so without this guard the checks below
# would pass vacuously and prove a label nothing was read from. This is an
# identity proof, so no read means not proven.
[ -n "$tabs" ] || return 1
printf '%s' "$tabs" | jq -e --argjson t "$tab_id" --arg want "$scoped" \
'[.[]? | select(.tab_id == $t and .name == $want)] | length > 0' >/dev/null 2>&1 && return 0
printf '%s' "$tabs" | jq -e --argjson t "$tab_id" --arg want "$label" \
Expand Down
92 changes: 63 additions & 29 deletions bin/fm-teardown.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2063,34 +2063,72 @@ preflight_descendant_task_locks() {
done
}

# teardown_holds_descendant_meta_lock: does this teardown already hold that
# exact child record's own metadata lock? preflight_descendant_task_locks
# records every lock it acquires in DESCENDANT_LOCK_PATHS and holds them until
# this teardown exits, so membership of the child's own meta-lock path is the
# structural proof, independent of which call site asks.
teardown_holds_descendant_meta_lock() { # <state> <task-id>
local state=$1 id=$2 want i
want=$(fm_meta_lock_path "$state/$id.meta") || return 1
for ((i=0; i < ${#DESCENDANT_LOCK_PATHS[@]}; i++)); do
[ "${DESCENDANT_LOCK_PATHS[$i]}" != "$want" ] || return 0
done
return 1
}

# validate_child_task_endpoint: the endpoint identity test both recursive
# secondmate preflights run over one child record, so the legacy-record shape is
# settled in one place for both.
#
# Offline by default. The ONE recoverable shape - a record written before the
# endpoint_task_id binding existed, internally consistent in every other respect
# (code 2) - is upgraded through fm_backend_resolve_task_endpoint, but only while
# this teardown already holds THAT child's own metadata lock, which is the
# precondition the resolver's write demands. preflight_descendant_task_locks
# takes exactly those locks, so the pass that runs after it recovers while the
# cheap pre-lock pass defers with code 2 rather than deciding: refusing there
# would strand the record the locked pass can prove, and accepting there would
# authorize an unproven endpoint. Every other refusal, and a legacy record the
# live endpoint cannot prove, still refuses with nothing mutated.
#
# The live label proof is read as the CHILD home, not the parent: zellij and
# cmux scope their runtime titles by owning home tag, exactly as forced cleanup
# already re-scopes its zellij child kills below.
#
# Returns 0 valid (with FM_BACKEND_VALIDATED_* set), 1 refused, 2 deferred to
# the locked pass.
validate_child_task_endpoint() { # <home> <state> <meta> <task-id>
local home=$1 state=$2 meta=$3 id=$4 rc=0
if teardown_holds_descendant_meta_lock "$state" "$id"; then
( unset FM_ROOT_OVERRIDE; FM_HOME=$home FM_ROOT=$home \
fm_backend_resolve_task_endpoint "$meta" "$id" ) || return 1
fm_backend_validate_task_endpoint "$meta" "$id"
return
fi
# Pre-lock pass. Its diagnostics are held back until the code is known, so the
# one recoverable shape defers silently instead of printing a refusal the
# locked pass then goes on to recover; every refusal it does settle is
# re-emitted verbatim by the second call before this returns it.
fm_backend_validate_task_endpoint "$meta" "$id" 2>/dev/null || rc=$?
[ "$rc" -ne 2 ] || return 2
[ "$rc" -eq 0 ] || fm_backend_validate_task_endpoint "$meta" "$id"
return "$rc"
}

# validate_firstmate_home_children_removal: authorize removal of every task
# record inside a secondmate's own home, recursively.
#
# KNOWN LIMITATION, left for a follow-up. This loop deliberately uses the
# OFFLINE fm_backend_validate_task_endpoint, so it does NOT get the legacy-record
# recovery the top-level teardown path has. A secondmate home holding a child
# record written before the endpoint_task_id binding existed (#1171) therefore
# still refuses teardown on the first such child meta, with the same "lacks an
# exact task binding" message. That is PRE-EXISTING behavior, not a regression
# from the recovery work: those records refused identically before it, when the
# same validator returned 1 instead of today's recoverable 2. It is not wired
# here because fm_backend_resolve_task_endpoint requires the caller to hold THAT
# child record's own meta lock before it writes, while teardown holds only the
# parent task's, so correct wiring needs per-child locking inside a recursive
# preflight that currently takes no locks at all - deliberately out of scope.
# Operator workaround: run bin/fm-control.sh <child-id> exit or relaunch against
# the secondmate's own home first, which upgrades that child record through the
# resolver, then tear down.
validate_firstmate_home_children_removal() {
local home=$1 sub_state child_meta child_id child_wt child_proj child_kind child_home child_backend child_orca_worktree_id
local home=$1 sub_state child_meta child_id child_wt child_proj child_kind child_home child_backend child_orca_worktree_id child_rc
sub_state="$home/state"
[ -d "$sub_state" ] || return 0
for child_meta in "$sub_state"/*.meta; do
[ -e "$child_meta" ] || continue
child_id=$(basename "$child_meta" .meta)
# Offline validator by design; a legacy child record refuses here rather
# than being recovered - see this function's known limitation above.
fm_backend_validate_task_endpoint "$child_meta" "$child_id" || return 1
child_rc=0
validate_child_task_endpoint "$home" "$sub_state" "$child_meta" "$child_id" || child_rc=$?
# 2 is the pre-lock deferral; the locked pass below decides that record.
[ "$child_rc" -eq 0 ] || [ "$child_rc" -eq 2 ] || return 1
validate_pr_poll_cleanup "$sub_state" "$child_id" || return 1
child_wt=$(meta_value "$child_meta" worktree)
child_kind=$(meta_value "$child_meta" kind)
Expand Down Expand Up @@ -2229,22 +2267,18 @@ $session $lock_path"
# preflight_firstmate_home_herdr_children: reserve every herdr endpoint inside a
# secondmate's own home before anything is torn down, recursively.
#
# Carries the SAME known limitation as validate_firstmate_home_children_removal,
# for the same reason and with the same operator workaround: this loop uses the
# OFFLINE validator, so a pre-#1171 child record still refuses teardown here with
# "lacks an exact task binding" instead of being recovered, which is pre-existing
# rather than a regression, and wiring the resolver in would need per-child meta
# locking this recursive preflight does not have. Left for a follow-up.
# Shares validate_child_task_endpoint with validate_firstmate_home_children_removal.
# This loop only ever runs after preflight_descendant_task_locks, so that helper
# always takes its recovering path here; a record it could not prove locked
# refuses rather than reserving an endpoint on an unvalidated target.
preflight_firstmate_home_herdr_children() { # <home>
local home=$1 sub_state child_meta child_id child_backend child_target child_kind child_home child_wt
sub_state="$home/state"
[ -d "$sub_state" ] || return 0
for child_meta in "$sub_state"/*.meta; do
[ -e "$child_meta" ] || continue
child_id=$(basename "$child_meta" .meta)
# Offline validator by design; a legacy child record refuses here rather
# than being recovered - see this function's known limitation above.
fm_backend_validate_task_endpoint "$child_meta" "$child_id" || return 1
validate_child_task_endpoint "$home" "$sub_state" "$child_meta" "$child_id" || return 1
child_backend=$FM_BACKEND_VALIDATED_BACKEND
child_target=$FM_BACKEND_VALIDATED_TARGET
if [ "$child_backend" = herdr ]; then
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ The runtime query itself is read-only, and the only write the re-derivation make
It refuses whenever the label cannot be proven to belong to exactly that recorded endpoint.
The three backends are not equally covered: herdr proves the full recorded chain of workspace, pane, owning tab, and label, while zellij and cmux prove only that the recorded tab or workspace carries the label and never that the recorded pane or surface still belongs to it.
A further known limitation is that the cmux proof reads only the currently focused window, so a cmux task whose workspace lives in another window is refused and stays stranded, and a same-titled workspace in another window is invisible to its uniqueness guard.
A third known limitation: the recursive child loops that authorize teardown of a secondmate's own home use the offline validator only, so a child record predating the binding still refuses that teardown with the same message; this is pre-existing rather than new, and the workaround is to run `fm-control.sh <child-id> exit` or `relaunch` against the secondmate's home first, which upgrades that record, and then tear down.
The recursive child loops that authorize teardown of a secondmate's own home settle such a child record through that same re-derivation, read as the child's own home because zellij and cmux scope their runtime titles by owning home, but only while the teardown already holds that exact child record's own metadata lock; the cheap pass that runs before those locks are taken defers the record to the locked pass instead of deciding it, and a child whose live endpoint cannot prove its label still refuses with the child record, the secondmate home, the parent record, and the child worktree's unlanded work all intact and no binding written.
`FM_HOME` determines Herdr's home label: the primary home uses `firstmate`, and a secondmate home marked by `.fm-secondmate-home` uses `2ndmate-<secondmate-id>`.
[`herdr-backend.md`](herdr-backend.md#watching-and-task-containers) owns launcher-bound workspace placement, the label-only fallback, collision handling, and recovery behavior.
The local `config/herdr-presentation-spaces` file instead opts a home out of, or explicitly in to, Herdr's default-on disposable single-task visual projection; [Presentation spaces](herdr-backend.md#presentation-spaces) owns its accepted values, default, Herdr version floor, migration, behavior, safety limits, recovery contract, and narrow locked session-start cleanup of exact restored idle-shell children.
Expand Down
5 changes: 3 additions & 2 deletions docs/verification/runtime-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,9 @@ Its one exception is a record predating the `endpoint_task_id` binding on an opa
A tmux or Orca record needs no such query, because its recorded window name is already `fm-<id>`.
That query proves the full recorded chain only on herdr; on zellij and cmux it proves just the recorded tab or workspace label, not that the recorded pane or surface still belongs to it, and the cmux proof additionally reads only the currently focused window, so a cmux task whose workspace lives elsewhere is refused and stays stranded.
Both are known limitations left for a follow-up; in every unproven case the outcome is refusal, never action on an unproven endpoint.
Only the herdr branch of that proof has executable coverage; the zellij branch and cmux's workspace matcher have none, so their refusal behavior is established by code reading alone and a test for them is left for a follow-up.
The recursive child loops in `fm-teardown.sh` also keep using the offline validator, so a legacy child record inside a secondmate home still refuses that teardown, pre-existing and likewise left for a follow-up.
The zellij branch is covered by the forced-teardown legacy-child cases in `tests/fm-secondmate-safety.test.sh` and by the no-read case in `tests/fm-backend-zellij.test.sh`, which pins that an unreachable or silent zellij returns no tab list and so proves nothing, rather than passing vacuously because `jq -e` over empty input exits 0.
cmux's workspace matcher still has no executable coverage, so its refusal behavior is established by code reading alone and a test for it is left for a follow-up.
The recursive child loops in `fm-teardown.sh` settle a legacy child record inside a secondmate home through that same proof, under the child's own metadata lock; [`docs/configuration.md`](../configuration.md) owns that contract, and the four forced-teardown cases in `tests/fm-secondmate-safety.test.sh` pin recovery, refusal with nothing mutated, the offline current-format path, and refusal under a foreign metadata lock.
Claude, Codex, OpenCode, Pi, pi-signed, Grok, Kimi, Cursor, and Muse share that backend cleanup boundary; their harness-specific hook files, tokens, transcript bindings, and session-log sidecars are cleaned only after it, so no harness needs a separate endpoint parser.

## Composer classification matrix
Expand Down
24 changes: 24 additions & 0 deletions tests/fm-backend-zellij.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1358,3 +1358,27 @@ test_send_text_submit_send_failed_when_pane_absent
test_scripts_route_explicit_target_through_meta_backend
test_scripts_verify_label_for_fm_targets
test_scripts_reject_fm_target_label_mismatch

test_expected_label_refuses_when_no_tab_list_was_read() {
local dir fb status
dir="$TMP_ROOT/label-no-read"; mkdir -p "$dir/responses"
fb=$(make_zellij_fakebin "$dir")
# The label proof must actually READ a tab list before it can prove anything,
# and `jq -e` over EMPTY input exits 0 (verified: jq 1.6) - so without a guard
# a list that was never read would "prove" the label. Both no-read shapes:
# 1. an unreachable zellij that fails outright.
printf '1\n' > "$dir/responses/1.exit"
PATH="$fb:$PATH" FM_ZELLIJ_LOG="$dir/log" FM_ZELLIJ_RESPONSES="$dir/responses" \
bash -c '. "$0/bin/backends/zellij.sh"; fm_backend_zellij_tab_matches_label firstmate 3 fm-task' "$ROOT"
status=$?
[ "$status" -ne 0 ] || fail "an unreachable zellij must not prove a tab label"

# 2. a reachable zellij that answers with nothing at all.
PATH="$fb:$PATH" FM_ZELLIJ_LOG="$dir/log" FM_ZELLIJ_RESPONSES="$dir/responses" \
bash -c '. "$0/bin/backends/zellij.sh"; fm_backend_zellij_tab_matches_label firstmate 3 fm-task' "$ROOT"
status=$?
[ "$status" -ne 0 ] || fail "an empty tab list must not prove a tab label"
pass "fm_backend_zellij_tab_matches_label: refuses when no tab list could be read at all"
}

test_expected_label_refuses_when_no_tab_list_was_read
Loading
Loading