From 4c9cfd437feeb7f250ed2fc870910a52e596552c Mon Sep 17 00:00:00 2001 From: Rene Garza Jr Date: Fri, 14 Aug 2026 16:23:10 -0500 Subject: [PATCH 1/7] fix(herdr): add guarded legacy endpoint-binding repair path Task records written before endpoint_task_id= existed can never pass fm_backend_validate_task_endpoint on an opaque backend, so teardown and control refuse them forever while the watcher keeps alerting on their finished endpoints. Add bin/fm-herdr-legacy-repair.sh: an explicit, deterministic, idempotent bind-only repair for a legacy primary-home Herdr record. It restores the single missing endpoint_task_id= line only when independent evidence all agrees - exact record shape via the shared validator, worktree/project identity and origin agreement, clean worktree, landed work through the shared landed predicates, and live read-only Herdr evidence (structured pane-gone, or exact workspace/tab/pane topology with an fm- tab label, an absent or finished agent, and a matching foreground cwd). Any missing, ambiguous, mismatched, dirty, unlanded, live-working, or unreadable evidence refuses with a concrete diagnostic and preserves everything. It never closes a pane, deletes a record, or issues a mutating Herdr command; cleanup remains fm-teardown.sh, which re-runs its own complete landed-work and confirmed-close safety against the repaired record. Extract teardown's landed-work predicates verbatim into bin/fm-landed-lib.sh so both callers share one owner; teardown keeps its historical wrapper names and messages. The validator's legacy-Herdr refusal now names the repair path. tmux legacy records still self-identify by window name and need no repair; zellij/orca/cmux legacy records share the masking condition but stay out of this narrow path and refuse by name. Regression coverage: tests/fm-herdr-legacy-repair.test.sh (modern bound no-op, repair-exactly-once, idempotent rerun, every mismatch class, dirty/ unlanded, active/nonterminal, missing/ambiguous fields, unrelated-record and default-session preservation, refusal before any lifecycle mutation, lock contention) and tests/fm-herdr-legacy-repair-e2e.test.sh against real Herdr through the guarded lab with the default-session tripwire. --- bin/fm-backend.sh | 1 + bin/fm-herdr-legacy-repair.sh | 323 ++++++++++++++++ bin/fm-landed-lib.sh | 168 +++++++++ bin/fm-teardown.sh | 149 +------- bin/fm-test-isolation-proof.sh | 3 +- bin/fm-test-run.sh | 17 +- docs/configuration.md | 1 + docs/herdr-backend.md | 9 + docs/scripts.md | 2 + docs/verification/runtime-backends.md | 20 + tests/fm-herdr-legacy-repair-e2e.test.sh | 138 +++++++ tests/fm-herdr-legacy-repair.test.sh | 454 +++++++++++++++++++++++ 12 files changed, 1143 insertions(+), 142 deletions(-) create mode 100755 bin/fm-herdr-legacy-repair.sh create mode 100644 bin/fm-landed-lib.sh create mode 100755 tests/fm-herdr-legacy-repair-e2e.test.sh create mode 100755 tests/fm-herdr-legacy-repair.test.sh diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index bd5feb235ff..7f53663a2b9 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -454,6 +454,7 @@ fm_backend_validate_task_endpoint() { # herdr) [ "$binding" = "$id" ] || { echo "REFUSED: legacy Herdr endpoint metadata for task $id lacks an exact task binding; preserving task state." >&2 + echo "A finished legacy record can be bound through the guarded bin/fm-herdr-legacy-repair.sh $id (see its --help); nothing repairs a binding implicitly." >&2 return 1 } recorded_session=$(fm_backend_meta_exact_value "$meta" herdr_session) || recorded_session= diff --git a/bin/fm-herdr-legacy-repair.sh b/bin/fm-herdr-legacy-repair.sh new file mode 100755 index 00000000000..e0c3feaf556 --- /dev/null +++ b/bin/fm-herdr-legacy-repair.sh @@ -0,0 +1,323 @@ +#!/usr/bin/env bash +# bin/fm-herdr-legacy-repair.sh - the one guarded repair path for a legacy +# primary-home Herdr task record that predates endpoint_task_id= and therefore +# can never pass fm_backend_validate_task_endpoint (bin/fm-backend.sh), so +# ordinary teardown and control refuse it forever while the watcher keeps +# alerting on its finished endpoint. +# +# The repair BINDS: after every independent evidence check below agrees, it +# rewrites the task's metadata with the single missing endpoint_task_id= +# line. It never closes a pane, never touches a worktree, never deletes any +# record, and never calls a mutating Herdr command - cleanup remains +# bin/fm-teardown.sh's, which re-runs its own complete landed-work test and +# confirmed-close sequence against the now-valid record. Ordinary teardown and +# control keep refusing unbound records implicitly; this explicit path is the +# only place a binding may be restored. +# +# Usage: fm-herdr-legacy-repair.sh +# +# Exit 0: the record was repaired, or was already bound to exactly this task +# (idempotent rerun; nothing to do). Exit 1: refused, with one concrete +# diagnostic and every record preserved. Exit 2: usage error. +# +# Independent evidence, all required before the one metadata write: +# - The record is a regular, single-link, non-symlink primary-home meta file; +# kind=secondmate, any remote route, and a secondmate FM_HOME all refuse. +# - backend=herdr exactly; tmux legacy records self-identify through their +# fm- window name and need no repair, and zellij/orca/cmux legacy +# records are out of this repair's authorized scope and refuse by name. +# - endpoint_task_id is absent (the legacy shape). A single binding equal to +# the task id is the idempotent no-op; an empty, duplicate, or foreign +# binding refuses. +# - The record plus the one candidate binding line passes +# fm_backend_validate_task_endpoint, proving the window, worktree, project, +# and all four herdr_* fields are present exactly once and consistent. +# - The recorded worktree is a real git worktree at its own toplevel, +# distinct from the recorded project, and both share the same origin (or +# the worktree's origin is the project clone itself). +# - The worktree is clean (same uncommitted-change test as teardown, with the +# same harness-artifact allowances) and its committed work has landed: +# every commit reachable from a remote-tracking ref, or proven landed by +# bin/fm-landed-lib.sh's shared merged-PR/content-in-default predicates - +# the same owner bin/fm-teardown.sh uses. +# - The recorded live endpoint agrees, through read-only Herdr calls against +# the recorded named session only: either the exact pane is structurally +# gone (a structured pane_not_found), or it is present with its live +# tab/workspace topology matching the recorded ids, its tab labeled +# exactly fm-, its agent absent (restored husk) or registered and +# idle/done, and its foreground cwd in the recorded worktree (or, for an +# agent-less restored shell, the recorded creation-time project dir). +# A working or blocked agent, an unreadable response, an unreachable +# session, or any topology mismatch refuses. +# +# Filename/task-id agreement alone, labels alone, cwd alone, or a merged PR +# alone never authorize the repair; only the full conjunction does. Any +# missing, duplicate, stale, ambiguous, mismatched, unreadable, live-working, +# unlanded, or dirty evidence refuses with the concrete reason and changes +# nothing. The write itself is atomic (temp file + rename in state/), guarded +# by the task's control and metadata locks, and re-validated after the rename, +# so a rerun at any interruption point converges to the same repaired record. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" + +# shellcheck source=bin/fm-pr-lib.sh +. "$SCRIPT_DIR/fm-pr-lib.sh" +# shellcheck source=bin/fm-wake-lib.sh +. "$SCRIPT_DIR/fm-wake-lib.sh" +# shellcheck source=bin/fm-gate-refuse-lib.sh +. "$SCRIPT_DIR/fm-gate-refuse-lib.sh" +# shellcheck source=bin/fm-backend.sh +. "$SCRIPT_DIR/fm-backend.sh" +# shellcheck source=bin/fm-landed-lib.sh +. "$SCRIPT_DIR/fm-landed-lib.sh" + +usage() { + sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//' +} + +case "${1:-}" in + -h|--help) + usage + exit 0 + ;; +esac +if [ "$#" -ne 1 ] || ! fm_task_id_path_safe "$1"; then + echo "error: usage: fm-herdr-legacy-repair.sh " >&2 + exit 2 +fi +ID=$1 + +refuse() { + echo "REFUSED: $*" >&2 + exit 1 +} + +fm_refuse_if_gate_agent + +META="$STATE/$ID.meta" +CONTROL_LOCK="$STATE/.control-$ID.lock" +CONTROL_LOCK_HELD=0 +META_LOCK= +META_LOCK_HELD=0 +META_TMP= +repair_cleanup() { + local status=$? + [ -z "$META_TMP" ] || rm -f -- "$META_TMP" + if [ "$META_LOCK_HELD" = 1 ]; then + fm_lock_release "$META_LOCK" || true + META_LOCK_HELD=0 + fi + if [ "$CONTROL_LOCK_HELD" = 1 ]; then + fm_lock_release "$CONTROL_LOCK" || true + CONTROL_LOCK_HELD=0 + fi + return "$status" +} +trap repair_cleanup EXIT +trap 'exit 1' HUP INT TERM + +[ ! -e "$FM_HOME/.fm-secondmate-home" ] \ + || refuse "this is a secondmate home; the legacy repair path covers only primary-home records." + +fm_lock_try_acquire "$CONTROL_LOCK" \ + || refuse "another lifecycle action is already running for task $ID; nothing was changed." +CONTROL_LOCK_HELD=1 + +[ -f "$META" ] || refuse "task $ID has no metadata at $META; nothing to repair." +META_LOCK=$(fm_meta_lock_path "$META") || refuse "task $ID has no resolvable metadata lock." +fm_lock_acquire_wait "$META_LOCK" +META_LOCK_HELD=1 + +[ -f "$META" ] && [ ! -L "$META" ] && [ "$(fm_pr_file_link_count "$META")" = 1 ] \ + || refuse "task $ID metadata at $META is not a regular single-link file." +META_DEVICE=$(fm_pr_file_device "$META") || refuse "task $ID metadata device is unreadable." +STATE_DEVICE=$(fm_pr_file_device "$STATE") || refuse "state directory device is unreadable." +[ "$META_DEVICE" = "$STATE_DEVICE" ] \ + || refuse "task $ID metadata is not on the state directory's device." + +KIND=$(grep '^kind=' "$META" | tail -1 | cut -d= -f2- || true) +[ "$KIND" != secondmate ] \ + || refuse "task $ID is a secondmate record; the legacy repair path never touches secondmates." +[ "$(grep -c '^remote_host=' "$META" 2>/dev/null || true)" -eq 0 ] \ + || refuse "task $ID has a remote route; the legacy repair path covers only local primary-home records." + +BACKEND_COUNT=$(grep -c '^backend=' "$META" 2>/dev/null || true) +[ "$BACKEND_COUNT" -eq 1 ] \ + || refuse "task $ID has $BACKEND_COUNT backend= records; a legacy tmux-default or ambiguous record is not a Herdr repair candidate." +BACKEND=$(fm_backend_meta_exact_value "$META" backend) \ + || refuse "task $ID has an empty backend identity." +[ "$BACKEND" = herdr ] \ + || refuse "task $ID records backend=$BACKEND; this repair path covers only Herdr records (tmux legacy records self-identify by window name, and other opaque backends are out of its authorized scope)." + +BINDING_COUNT=$(grep -c '^endpoint_task_id=' "$META" 2>/dev/null || true) +if [ "$BINDING_COUNT" -gt 1 ]; then + refuse "task $ID has $BINDING_COUNT endpoint task bindings; an ambiguous record is preserved for inspection." +fi +if [ "$BINDING_COUNT" -eq 1 ]; then + BINDING=$(fm_backend_meta_exact_value "$META" endpoint_task_id) \ + || refuse "task $ID has an empty endpoint task binding; preserved for inspection." + [ "$BINDING" = "$ID" ] \ + || refuse "endpoint metadata belongs to task $BINDING, not $ID; preserved for inspection." + fm_backend_validate_task_endpoint "$META" "$ID" || exit 1 + echo "already-bound: task $ID endpoint metadata already carries its exact binding; nothing to do." + exit 0 +fi + +# Candidate validation: the record plus the single missing binding line must +# pass the same validator every cleanup path uses, proving all endpoint fields +# exist exactly once and are consistent before any live evidence is read. +META_TMP=$(mktemp "$STATE/.fm-herdr-legacy-repair.XXXXXX") \ + || refuse "could not create a temporary candidate record in $STATE." +cp -p "$META" "$META_TMP" 2>/dev/null \ + || refuse "could not stage the candidate record." +printf 'endpoint_task_id=%s\n' "$ID" >> "$META_TMP" \ + || refuse "could not stage the candidate binding." +fm_backend_validate_task_endpoint "$META_TMP" "$ID" || exit 1 + +WT=$(fm_backend_meta_exact_value "$META" worktree) || refuse "task $ID worktree identity became unreadable." +PROJ=$(fm_backend_meta_exact_value "$META" project) || refuse "task $ID project identity became unreadable." +SESSION=$(fm_backend_meta_exact_value "$META" herdr_session) || refuse "task $ID herdr_session became unreadable." +WORKSPACE=$(fm_backend_meta_exact_value "$META" herdr_workspace_id) || refuse "task $ID herdr_workspace_id became unreadable." +TAB=$(fm_backend_meta_exact_value "$META" herdr_tab_id) || refuse "task $ID herdr_tab_id became unreadable." +PANE=$(fm_backend_meta_exact_value "$META" herdr_pane_id) || refuse "task $ID herdr_pane_id became unreadable." +PR_URL=$(grep '^pr=' "$META" | tail -1 | cut -d= -f2- || true) + +canonical_dir() { + local target=$1 + [ -n "$target" ] && [ -d "$target" ] || return 1 + ( CDPATH='' cd -- "$target" 2>/dev/null && pwd -P ) +} + +# Worktree and project evidence. +PROJ_REAL=$(canonical_dir "$PROJ") \ + || refuse "recorded project $PROJ does not exist; preserved for inspection." +git -C "$PROJ_REAL" rev-parse --git-dir >/dev/null 2>&1 \ + || refuse "recorded project $PROJ is not a git repository." +WT_REAL=$(canonical_dir "$WT") \ + || refuse "recorded worktree $WT does not exist; preserved for inspection." +WT_TOP=$(git -C "$WT_REAL" rev-parse --show-toplevel 2>/dev/null) \ + || refuse "recorded worktree $WT is not a git worktree." +WT_TOP_REAL=$(canonical_dir "$WT_TOP") \ + || refuse "recorded worktree $WT has an unreadable toplevel." +[ "$WT_TOP_REAL" = "$WT_REAL" ] \ + || refuse "recorded worktree $WT is not its own git toplevel ($WT_TOP_REAL); the record does not match a task worktree." +[ "$WT_REAL" != "$PROJ_REAL" ] \ + || refuse "recorded worktree equals the recorded project checkout; a task record must point at an isolated copy." + +origin_normalize() { + local url=$1 + url=${url%/} + url=${url%.git} + printf '%s' "$url" +} +WT_ORIGIN=$(git -C "$WT_REAL" remote get-url origin 2>/dev/null) \ + || refuse "recorded worktree $WT has no origin remote; cannot tie it to the recorded project." +PROJ_ORIGIN=$(git -C "$PROJ_REAL" remote get-url origin 2>/dev/null) || PROJ_ORIGIN= +WT_ORIGIN_DIR=$(canonical_dir "$WT_ORIGIN" 2>/dev/null) || WT_ORIGIN_DIR= +if [ "$WT_ORIGIN_DIR" != "$PROJ_REAL" ] \ + && { [ -z "$PROJ_ORIGIN" ] \ + || [ "$(origin_normalize "$WT_ORIGIN")" != "$(origin_normalize "$PROJ_ORIGIN")" ]; }; then + refuse "recorded worktree origin ($WT_ORIGIN) does not match the recorded project ($PROJ); the record's worktree and project disagree." +fi + +# Clean-and-landed evidence: the same dirty allowances as teardown, then full +# remote reachability or the shared landed predicates (bin/fm-landed-lib.sh). +DIRTY_RAW=$(git -C "$WT_REAL" status --porcelain 2>/dev/null) \ + || refuse "cannot inspect worktree $WT for uncommitted changes." +DIRTY=$(printf '%s\n' "$DIRTY_RAW" | grep -vE '^\?\? (\.claude/|\.fm-(grok|kimi)-turnend$)' | head -1 || true) +[ -z "$DIRTY" ] \ + || refuse "worktree $WT has uncommitted changes; unlanded work is never repaired away." +git -C "$WT_REAL" rev-parse --verify HEAD >/dev/null 2>&1 \ + || refuse "worktree $WT has no readable HEAD commit." +UNPUSHED=$(git -C "$WT_REAL" log --oneline HEAD --not --remotes -- 2>/dev/null) \ + || refuse "cannot inspect worktree $WT for commits not on a remote." +if [ -n "$UNPUSHED" ]; then + BRANCH=$(git -C "$WT_REAL" rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD) + fm_landed_work_is_landed "$WT_REAL" "$PROJ_REAL" "$PR_URL" "$BRANCH" \ + || refuse "worktree $WT has committed work not on any remote and not proven landed; nothing was changed." +fi + +# Live endpoint evidence, read-only, against the recorded named session only. +fm_backend_source herdr +fm_backend_herdr_tool_check || exit 1 + +PRESENCE=$(fm_backend_herdr_pane_presence_state "$SESSION" "$PANE") +ENDPOINT_EVIDENCE= +case "$PRESENCE" in + dead) + # Structured pane_not_found from the recorded session: the endpoint is + # positively gone, the strongest terminal evidence there is. + ENDPOINT_EVIDENCE="endpoint already gone (structured pane_not_found)" + ;; + present) + PANE_INFO=$(fm_backend_herdr_cli "$SESSION" pane get "$PANE" 2>/dev/null) \ + || refuse "recorded pane $PANE became unreadable in session $SESSION." + LIVE_TAB=$(printf '%s' "$PANE_INFO" | jq -r '.result.pane.tab_id // empty' 2>/dev/null) + [ "$LIVE_TAB" = "$TAB" ] \ + || refuse "live pane $PANE sits in tab ${LIVE_TAB:-}, not the recorded tab $TAB; the record does not match the live endpoint." + TABS=$(fm_backend_herdr_cli "$SESSION" tab list --workspace "$WORKSPACE" 2>/dev/null) \ + || refuse "recorded workspace $WORKSPACE is unreadable in session $SESSION." + printf '%s' "$TABS" | jq -e --arg tab "$TAB" --arg label "fm-$ID" ' + (.result.tabs | type) == "array" + and ([.result.tabs[] | select(.tab_id == $tab)] | length) == 1 + and ([.result.tabs[] | select(.tab_id == $tab and .label == $label)] | length) == 1 + ' >/dev/null 2>&1 \ + || refuse "recorded tab $TAB is missing from workspace $WORKSPACE or is not labeled fm-$ID; the record does not match the live topology." + AGENT_STATE=$(fm_backend_herdr_pane_agent_state "$SESSION" "$PANE") + case "$AGENT_STATE" in + no-agent) + ENDPOINT_EVIDENCE="restored agent-less shell at the recorded endpoint" + ;; + live) + AGENT_RAW=$(fm_backend_herdr_agent_identity_raw "$SESSION" "$PANE") \ + || refuse "registered agent state for pane $PANE became unreadable." + AGENT_STATUS=${AGENT_RAW#*$'\t'} + case "$AGENT_STATUS" in + idle|done) + ENDPOINT_EVIDENCE="finished agent ($AGENT_STATUS) at the recorded endpoint" + ;; + working|blocked) + refuse "the recorded endpoint's agent is $AGENT_STATUS; active or undecided work is never repaired away." + ;; + *) + refuse "the recorded endpoint's agent status is unreadable; preserved for inspection." + ;; + esac + ;; + *) + refuse "the recorded endpoint's agent state is $AGENT_STATE; preserved for inspection." + ;; + esac + FG_CWD=$(printf '%s' "$PANE_INFO" | jq -r '.result.pane.foreground_cwd // empty' 2>/dev/null) + FG_REAL=$(canonical_dir "$FG_CWD" 2>/dev/null) \ + || refuse "the recorded endpoint's foreground working directory is unreadable; preserved for inspection." + if [ "$AGENT_STATE" = no-agent ]; then + # A restored shell starts at the pane's creation cwd (the project); + # a shell still in the task worktree is equally consistent. + [ "$FG_REAL" = "$WT_REAL" ] || [ "$FG_REAL" = "$PROJ_REAL" ] \ + || refuse "the restored shell's working directory ($FG_REAL) matches neither the recorded worktree nor the recorded project; the pane is not provably this task's." + else + [ "$FG_REAL" = "$WT_REAL" ] \ + || refuse "the finished agent's working directory ($FG_REAL) is not the recorded worktree; the pane is not provably this task's." + fi + ;; + *) + refuse "the recorded endpoint in session $SESSION is unreadable or the session is unreachable; start the recorded session and rerun, or inspect manually." + ;; +esac + +# All evidence agrees. The one mutation: atomically install the candidate +# record already validated above, then re-validate what actually landed. +mv -f -- "$META_TMP" "$META" \ + || refuse "could not install the repaired record; nothing usable was changed." +META_TMP= +fm_backend_validate_task_endpoint "$META" "$ID" || { + echo "error: the repaired record failed re-validation; inspect $META before any cleanup." >&2 + exit 1 +} +echo "repaired: task $ID endpoint binding restored ($ENDPOINT_EVIDENCE)." +echo "Cleanup remains bin/fm-teardown.sh $ID, which re-runs its own complete landed-work and confirmed-close safety." diff --git a/bin/fm-landed-lib.sh b/bin/fm-landed-lib.sh new file mode 100644 index 00000000000..7e95916a602 --- /dev/null +++ b/bin/fm-landed-lib.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +# bin/fm-landed-lib.sh - the single owner of the landed-work test's helper +# functions, extracted verbatim from bin/fm-teardown.sh so a second caller +# (bin/fm-herdr-legacy-repair.sh) cannot drift from teardown's semantics. +# bin/fm-teardown.sh remains the owner of the COMPLETE landed-work decision - +# when the test runs, what --force may skip, and every refusal message; this +# lib only holds the shared predicates. Every function takes explicit +# arguments and reads no caller globals. +# +# Landed means: the worktree's committed work is reachable from a +# remote-tracking branch (the caller checks that with `git log HEAD --not +# --remotes` before calling fm_landed_work_is_landed), OR a merged PR's head +# contains the current local work, OR the branch's content is already present +# in the up-to-date default branch (the squash-merge-then-delete-branch flow). +# Uncommitted changes are never landed and are the caller's check. +# Sourced only; not executable on its own. + +# fm_landed_default_branch : the project's default branch name +# from origin/HEAD, falling back to a local main or master head. +fm_landed_default_branch() { # + local proj=$1 ref branch + ref=$(git -C "$proj" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true) + if [ -n "$ref" ]; then + echo "${ref#origin/}" + return 0 + fi + for branch in main master; do + if git -C "$proj" show-ref --verify --quiet "refs/heads/$branch"; then + echo "$branch" + return 0 + fi + done + return 1 +} + +# Resolve the PR number for a worktree branch via gh-axi. Echoes the number on a +# single match and returns 0; returns non-zero on no match or any lookup failure, +# so the caller treats it as "no PR found" (fail-safe). +fm_landed_pr_number_from_branch() { # + local wt=$1 branch=$2 out n + [ -n "$branch" ] && [ "$branch" != HEAD ] || return 1 + out=$( cd "$wt" && gh-axi pr list --state all --head "$branch" --limit 1 2>/dev/null ) || return 1 + n=$(printf '%s\n' "$out" | sed -n 's/^[[:space:]]*\([0-9][0-9]*\),.*/\1/p' | head -1) + [ -n "$n" ] || return 1 + printf '%s' "$n" +} + +fm_landed_pr_number_from_target() { # + local target=$1 n + case "$target" in + '' ) return 1 ;; + *"/pull/"*) + n=${target##*/pull/} + n=${n%%[!0-9]*} + ;; + [0-9]*) + n=${target%%[!0-9]*} + ;; + *) return 1 ;; + esac + [ -n "$n" ] || return 1 + printf '%s' "$n" +} + +fm_landed_ensure_commit_object() { # + local wt=$1 target=$2 commit=$3 n + git -C "$wt" cat-file -e "$commit^{commit}" 2>/dev/null && return 0 + n=$(fm_landed_pr_number_from_target "$target") || return 1 + git -C "$wt" remote get-url origin >/dev/null 2>&1 || return 1 + git -C "$wt" fetch --quiet origin "refs/pull/$n/head" >/dev/null 2>&1 || return 1 + git -C "$wt" cat-file -e "$commit^{commit}" 2>/dev/null +} + +fm_landed_patch_id_for_commit() { # + local wt=$1 commit=$2 + git -C "$wt" show --pretty=medium --no-ext-diff "$commit" 2>/dev/null \ + | git patch-id --stable 2>/dev/null \ + | awk 'NR == 1 { print $1 }' +} + +fm_landed_unpushed_patches_are_in_pr_head() { # + local wt=$1 pr_head=$2 current base pr_patch_ids commit patch_id unpushed + current=$(git -C "$wt" rev-parse --verify HEAD 2>/dev/null) || return 1 + base=$(git -C "$wt" merge-base "$current" "$pr_head" 2>/dev/null) || return 1 + pr_patch_ids=$( + git -C "$wt" log --format=%H "$base..$pr_head" -- 2>/dev/null \ + | while IFS= read -r commit; do + fm_landed_patch_id_for_commit "$wt" "$commit" + done \ + | sed '/^$/d' \ + | sort -u + ) || return 1 + [ -n "$pr_patch_ids" ] || return 1 + unpushed=$(git -C "$wt" log --format=%H HEAD --not --remotes -- 2>/dev/null) || return 1 + [ -n "$unpushed" ] || return 1 + while IFS= read -r commit; do + [ -n "$commit" ] || continue + patch_id=$(fm_landed_patch_id_for_commit "$wt" "$commit") || return 1 + [ -n "$patch_id" ] || return 1 + printf '%s\n' "$pr_patch_ids" | grep -qxF "$patch_id" || return 1 + done < + local wt=$1 pr_url=$2 branch=$3 target view state head current + if [ -n "$pr_url" ]; then + target=$pr_url + else + target=$(fm_landed_pr_number_from_branch "$wt" "$branch") || return 1 + fi + [ -n "$target" ] || return 1 + view=$(cd "$wt" && gh pr view "$target" --json state,headRefOid -q '.state + "\t" + .headRefOid' 2>/dev/null) || return 1 + state=${view%%$'\t'*} + head=${view#*$'\t'} + [ "$state" != "$view" ] || return 1 + case "$state" in + MERGED|merged) ;; + *) return 1 ;; + esac + [ -n "$head" ] || return 1 + fm_landed_ensure_commit_object "$wt" "$target" "$head" || return 1 + current=$(git -C "$wt" rev-parse --verify HEAD 2>/dev/null) || return 1 + git -C "$wt" merge-base --is-ancestor "$current" "$head" 2>/dev/null && return 0 + fm_landed_unpushed_patches_are_in_pr_head "$wt" "$head" +} + +# Is the branch's content already present in the up-to-date default branch? Fetches +# first, then 3-way merges the default branch with HEAD: when HEAD introduces nothing +# the default branch does not already contain (e.g. its change landed via squash) the +# merged tree equals the default branch's tree. This isolates branch-only changes, so +# unrelated commits the default branch gained past the merge-base do not count as +# "added". Returns non-zero when inconclusive (no default ref, or a merge conflict), +# so the caller refuses rather than guesses. +fm_landed_content_in_default() { # + local wt=$1 proj=$2 name ref default_tree merged_tree + name=$(fm_landed_default_branch "$proj") || return 1 + if git -C "$wt" remote get-url origin >/dev/null 2>&1; then + git -C "$wt" fetch --quiet origin "+refs/heads/$name:refs/remotes/origin/$name" >/dev/null 2>&1 || return 1 + ref="refs/remotes/origin/$name" + elif git -C "$wt" rev-parse --quiet --verify "refs/heads/$name" >/dev/null 2>&1; then + ref="refs/heads/$name" + else + return 1 + fi + default_tree=$(git -C "$wt" rev-parse --quiet --verify "$ref^{tree}" 2>/dev/null) || return 1 + [ -n "$default_tree" ] || return 1 + merged_tree=$(git -C "$wt" merge-tree --write-tree "$ref" HEAD 2>/dev/null) || return 1 + merged_tree=$(printf '%s\n' "$merged_tree" | head -1) + [ "$merged_tree" = "$default_tree" ] +} + +# Has the worktree's committed work actually LANDED, though its commits are not +# reachable from any remote-tracking branch? True when a merged PR proves the +# current local work is contained in the PR head, OR the content is already in the +# default branch (fallback, which also covers the no-PR and gh-error paths). False +# only for genuinely unlanded work. +fm_landed_work_is_landed() { # + local wt=$1 proj=$2 pr_url=$3 branch=$4 + fm_landed_pr_is_merged "$wt" "$pr_url" "$branch" && return 0 + fm_landed_content_in_default "$wt" "$proj" +} diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index ba75de31c79..1a8dcdccc34 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -157,6 +157,8 @@ SUB_HOME_PARENT_MARKER=".fm-secondmate-parent" . "$SCRIPT_DIR/fm-gate-refuse-lib.sh" # shellcheck source=bin/fm-pr-lib.sh . "$SCRIPT_DIR/fm-pr-lib.sh" +# shellcheck source=bin/fm-landed-lib.sh +. "$SCRIPT_DIR/fm-landed-lib.sh" # shellcheck source=bin/fm-public-followup-lib.sh . "$SCRIPT_DIR/fm-public-followup-lib.sh" # shellcheck source=bin/fm-secondmate-registry-lib.sh @@ -595,20 +597,11 @@ elif [ "$FORCE" != "--force" ] && fm_pf_relay_active "$FM_HOME"; then PUBLIC_FOLLOWUP_RELAY_ACTIVE=1 fi +# Landed-work predicates live in bin/fm-landed-lib.sh (shared with the guarded +# legacy Herdr repair path); these wrappers keep teardown's historical names +# bound to this task's worktree, project, and recorded PR. default_branch() { - local ref branch - ref=$(git -C "$PROJ" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true) - if [ -n "$ref" ]; then - echo "${ref#origin/}" - return 0 - fi - for branch in main master; do - if git -C "$PROJ" show-ref --verify --quiet "refs/heads/$branch"; then - echo "$branch" - return 0 - fi - done - return 1 + fm_landed_default_branch "$PROJ" } meta_value() { @@ -757,138 +750,16 @@ remove_pr_poll_artifacts() { fi } -# Resolve the PR number for a worktree branch via gh-axi. Echoes the number on a -# single match and returns 0; returns non-zero on no match or any lookup failure, -# so the caller treats it as "no PR found" (fail-safe). -pr_number_from_branch() { - local branch=$1 out n - [ -n "$branch" ] && [ "$branch" != HEAD ] || return 1 - out=$( cd "$WT" && gh-axi pr list --state all --head "$branch" --limit 1 2>/dev/null ) || return 1 - n=$(printf '%s\n' "$out" | sed -n 's/^[[:space:]]*\([0-9][0-9]*\),.*/\1/p' | head -1) - [ -n "$n" ] || return 1 - printf '%s' "$n" -} - -pr_number_from_target() { - local target=$1 n - case "$target" in - '' ) return 1 ;; - *"/pull/"*) - n=${target##*/pull/} - n=${n%%[!0-9]*} - ;; - [0-9]*) - n=${target%%[!0-9]*} - ;; - *) return 1 ;; - esac - [ -n "$n" ] || return 1 - printf '%s' "$n" -} - -ensure_commit_object() { - local target=$1 commit=$2 n - git -C "$WT" cat-file -e "$commit^{commit}" 2>/dev/null && return 0 - n=$(pr_number_from_target "$target") || return 1 - git -C "$WT" remote get-url origin >/dev/null 2>&1 || return 1 - git -C "$WT" fetch --quiet origin "refs/pull/$n/head" >/dev/null 2>&1 || return 1 - git -C "$WT" cat-file -e "$commit^{commit}" 2>/dev/null -} - -patch_id_for_commit() { - local commit=$1 - git -C "$WT" show --pretty=medium --no-ext-diff "$commit" 2>/dev/null \ - | git patch-id --stable 2>/dev/null \ - | awk 'NR == 1 { print $1 }' -} - -unpushed_patches_are_in_pr_head() { - local pr_head=$1 current base pr_patch_ids commit patch_id unpushed - current=$(git -C "$WT" rev-parse --verify HEAD 2>/dev/null) || return 1 - base=$(git -C "$WT" merge-base "$current" "$pr_head" 2>/dev/null) || return 1 - pr_patch_ids=$( - git -C "$WT" log --format=%H "$base..$pr_head" -- 2>/dev/null \ - | while IFS= read -r commit; do - patch_id_for_commit "$commit" - done \ - | sed '/^$/d' \ - | sort -u - ) || return 1 - [ -n "$pr_patch_ids" ] || return 1 - unpushed=$(git -C "$WT" log --format=%H HEAD --not --remotes -- 2>/dev/null) || return 1 - [ -n "$unpushed" ] || return 1 - while IFS= read -r commit; do - [ -n "$commit" ] || continue - patch_id=$(patch_id_for_commit "$commit") || return 1 - [ -n "$patch_id" ] || return 1 - printf '%s\n' "$pr_patch_ids" | grep -qxF "$patch_id" || return 1 - done </dev/null) || return 1 - state=${view%%$'\t'*} - head=${view#*$'\t'} - [ "$state" != "$view" ] || return 1 - case "$state" in - MERGED|merged) ;; - *) return 1 ;; - esac - [ -n "$head" ] || return 1 - ensure_commit_object "$target" "$head" || return 1 - current=$(git -C "$WT" rev-parse --verify HEAD 2>/dev/null) || return 1 - git -C "$WT" merge-base --is-ancestor "$current" "$head" 2>/dev/null && return 0 - unpushed_patches_are_in_pr_head "$head" -} - -# Is the branch's content already present in the up-to-date default branch? Fetches -# first, then 3-way merges the default branch with HEAD: when HEAD introduces nothing -# the default branch does not already contain (e.g. its change landed via squash) the -# merged tree equals the default branch's tree. This isolates branch-only changes, so -# unrelated commits the default branch gained past the merge-base do not count as -# "added". Returns non-zero when inconclusive (no default ref, or a merge conflict), -# so the caller refuses rather than guesses. -content_in_default() { - local name ref default_tree merged_tree - name=$(default_branch) || return 1 - if git -C "$WT" remote get-url origin >/dev/null 2>&1; then - git -C "$WT" fetch --quiet origin "+refs/heads/$name:refs/remotes/origin/$name" >/dev/null 2>&1 || return 1 - ref="refs/remotes/origin/$name" - elif git -C "$WT" rev-parse --quiet --verify "refs/heads/$name" >/dev/null 2>&1; then - ref="refs/heads/$name" - else - return 1 - fi - default_tree=$(git -C "$WT" rev-parse --quiet --verify "$ref^{tree}" 2>/dev/null) || return 1 - [ -n "$default_tree" ] || return 1 - merged_tree=$(git -C "$WT" merge-tree --write-tree "$ref" HEAD 2>/dev/null) || return 1 - merged_tree=$(printf '%s\n' "$merged_tree" | head -1) - [ "$merged_tree" = "$default_tree" ] -} - +# The landed-work predicates (PR-merged proof, content-in-default fallback, and +# their helpers) are owned by bin/fm-landed-lib.sh; these wrappers bind them to +# this task's worktree, project, and recorded PR. # Has the worktree's committed work actually LANDED, though its commits are not # reachable from any remote-tracking branch? True when a merged PR proves the # current local work is contained in the PR head, OR the content is already in the # default branch (fallback, which also covers the no-PR and gh-error paths). False # only for genuinely unlanded work. work_is_landed() { - local branch=$1 - pr_is_merged "$branch" && return 0 - content_in_default + fm_landed_work_is_landed "$WT" "$PROJ" "$PR_URL" "$1" } backlog_refresh_reminder() { diff --git a/bin/fm-test-isolation-proof.sh b/bin/fm-test-isolation-proof.sh index 2a90fde0bd7..3697e09d378 100755 --- a/bin/fm-test-isolation-proof.sh +++ b/bin/fm-test-isolation-proof.sh @@ -127,7 +127,8 @@ exclusion_reason() { fm-backend-autodetect-smoke.test.sh|fm-backend-herdr-eventwait-smoke.test.sh|\ fm-backend-herdr-presentation-e2e.test.sh|fm-backend-herdr-prune-safety-e2e.test.sh|\ fm-backend-herdr-respawn-idem-e2e.test.sh|fm-backend-herdr-smoke.test.sh|\ - fm-backend-herdr-workspace-per-home-e2e.test.sh|fm-herdr-session-cleanup-e2e.test.sh) + fm-backend-herdr-workspace-per-home-e2e.test.sh|fm-herdr-session-cleanup-e2e.test.sh|\ + fm-herdr-legacy-repair-e2e.test.sh) printf '%s\n' 'real Herdr-gated; Herdr lane is a later phase' ;; fm-backend-cmux.test.sh|fm-backend-cmux-smoke.test.sh) diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index b1867c53289..db17e55aa30 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -159,7 +159,7 @@ family_for_basename() { fm-backend-herdr-eventwait-smoke.test.sh|fm-backend-herdr-presentation-e2e.test.sh|\ fm-backend-herdr-launcher-workspace-e2e.test.sh|\ fm-backend-herdr-prune-safety-e2e.test.sh|fm-backend-herdr-respawn-idem-e2e.test.sh|\ - fm-herdr-session-cleanup-e2e.test.sh|\ + fm-herdr-session-cleanup-e2e.test.sh|fm-herdr-legacy-repair-e2e.test.sh|\ fm-backend-herdr-smoke.test.sh|fm-backend-herdr-workspace-per-home-e2e.test.sh|\ fm-control-herdr-smoke.test.sh) printf '%s\n' real-herdr-gated @@ -193,7 +193,8 @@ family_for_basename() { fm-backend-herdr.test.sh|fm-backend-tmux-smoke.test.sh|fm-backend.test.sh|\ fm-tmux-agent-liveness.test.sh|\ fm-control.test.sh|fm-control-relaunch.test.sh|\ - fm-herdr-session-cleanup.test.sh|fm-send-resolve-key.test.sh|fm-send-strict.test.sh|fm-spawn-batch.test.sh|\ + fm-herdr-session-cleanup.test.sh|fm-herdr-legacy-repair.test.sh|\ + fm-send-resolve-key.test.sh|fm-send-strict.test.sh|fm-spawn-batch.test.sh|\ fm-spawn-dispatch-profile.test.sh|\ fm-trace-context-spawn.test.sh|fm-spawn-worktree-settle.test.sh|\ fm-teardown-endpoint-safety.test.sh) @@ -403,6 +404,8 @@ tests/fm-gotmp.test.sh 308 tests/fm-grok-continuity-live-e2e.test.sh 19 tests/fm-grok-stop-live-e2e.test.sh 19 tests/fm-guard-stale-banner.test.sh 2917 +tests/fm-herdr-legacy-repair-e2e.test.sh 21 +tests/fm-herdr-legacy-repair.test.sh 20823 tests/fm-herdr-session-cleanup.test.sh 4802 tests/fm-kimi-harness.test.sh 12590 tests/fm-opencode-primary-live-e2e.test.sh 18 @@ -860,6 +863,16 @@ families_for_changed_path() { printf '%s\n' real-herdr-gated printf '%s\n' backend-dispatch ;; + bin/fm-herdr-legacy-repair.sh) + printf '%s\n' backend-dispatch + printf '%s\n' real-herdr-gated + ;; + bin/fm-landed-lib.sh) + # Shared landed-work predicates, sourced by bin/fm-teardown.sh (pr-forge) + # and bin/fm-herdr-legacy-repair.sh (backend-dispatch). + printf '%s\n' pr-forge + printf '%s\n' backend-dispatch + ;; bin/backends/zellij*|tests/zellij-test-safety.sh) printf '%s\n' zellij printf '%s\n' backend-dispatch diff --git a/docs/configuration.md b/docs/configuration.md index c5a97284f6d..8d80ff0075d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -81,6 +81,7 @@ These five sentences are the single owner of the task-selector vocabulary; backe `fm-teardown.sh ` takes a task id directly and validates the complete metadata-only endpoint identity before any runtime dispatch or cleanup mutation. Missing, empty, duplicate, malformed, backend-inconsistent, or task-mismatched endpoint records are preserved and refused. Legacy tmux metadata remains cleanup-compatible when its exact window name is `fm-`; opaque non-tmux endpoints require their recorded `endpoint_task_id=` binding. +A legacy primary-home Herdr record that predates that binding can be restored only through the guarded evidence-gated `bin/fm-herdr-legacy-repair.sh` ([`herdr-backend.md`](herdr-backend.md#legacy-record-repair)); ordinary cleanup and control never repair a binding implicitly. `FM_HOME` determines Herdr's home label: the primary home uses `firstmate`, and a secondmate home marked by `.fm-secondmate-home` uses `2ndmate-`. [`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. diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index 7eb0f266f8b..bb9ab45ee41 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -200,6 +200,13 @@ A Herdr pane id contains a colon, so the adapter splits `window=` on the first c The recorded pane is the operational fast path. Workspace and tab ids support verification and cleanup but are not inferred from mutable labels during normal operation. +## Legacy record repair + +A Herdr task record written before `endpoint_task_id=` existed can never pass the shared endpoint-identity validation, so teardown and control refuse it forever while the watcher keeps alerting on its finished endpoint. +`bin/fm-herdr-legacy-repair.sh ` is the one guarded path that restores that binding, and only for a legacy primary-home Herdr record whose independent evidence all agrees; its header and `--help` own the exact evidence conjunction, refusal behavior, and idempotence contract. +It binds only - it never closes a pane, deletes a record, or issues a mutating Herdr command - and cleanup afterwards remains ordinary `bin/fm-teardown.sh`, which re-runs its own complete landed-work and confirmed-close safety against the repaired record. +Ordinary cleanup and control keep refusing unbound records implicitly; nothing repairs a binding as a side effect. + ## Current transport behavior The adapter starts and polls a named server before workspace, tab, pane, or agent calls. @@ -327,6 +334,8 @@ tests/fm-backend-herdr-presentation-e2e.test.sh tests/fm-backend-herdr-eventwait-smoke.test.sh tests/fm-herdr-session-cleanup.test.sh tests/fm-herdr-session-cleanup-e2e.test.sh +tests/fm-herdr-legacy-repair.test.sh +tests/fm-herdr-legacy-repair-e2e.test.sh tests/fm-afk-inject-herdr-e2e.test.sh tests/fm-afk-pi-herdr-return-e2e.test.sh ``` diff --git a/docs/scripts.md b/docs/scripts.md index 3dbd15cdbb9..26d15021024 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -106,6 +106,8 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-pr-merge.sh` | Record PR metadata, then merge a task's canonical full GitHub URL | | `fm-promote.sh` | Promote a scout task in place to a protected ship task with an explicit delivery mode | | `fm-teardown.sh` | Fail-closed teardown: return landed ship worktrees, require completed scout deliverables, retire secondmate homes | +| `fm-landed-lib.sh` | Own the shared landed-work predicates (merged-PR proof and content-in-default fallback) teardown and the legacy Herdr repair both use | +| `fm-herdr-legacy-repair.sh` | Bind one provably finished legacy primary-home Herdr record missing `endpoint_task_id=`, refusing on any missing or mismatched evidence | | `fm-harness.sh` | Detect the running harness and resolve crew or secondmate harness, model, and effort | | `fm-lock.sh` | Per-home firstmate session lock; hosted Codex seatbelt sessions use a `codex:` lock owner token when process ancestry cannot be inspected | | `fm-x-lib.sh` | Shared Relay config, relay, and reply-threading helpers | diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index f22b704250d..4f2351f23be 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -180,6 +180,26 @@ Valid cleanup removed only the exact task-bound target and left the control wind The metadata-only validation covers tmux, Herdr, Zellij, Orca, and cmux before backend dispatch. Claude, Codex, OpenCode, Pi, pi-signed, Grok, Kimi, and Muse share that backend cleanup boundary; their harness-specific hook files, tokens, and session-log sidecars are cleaned only after it, so no harness needs a separate endpoint parser. +### Legacy Herdr binding repair + +The guarded `bin/fm-herdr-legacy-repair.sh` evidence conjunction was validated on 2026-08-14 with herdr 0.8.0 through the isolated lab helper. + +```sh +tests/fm-herdr-legacy-repair.test.sh +tests/fm-herdr-legacy-repair-e2e.test.sh +``` + +Bounded output from the real-Herdr run: + +```text +ok - a live-topology mismatch refuses before any change against real Herdr +ok - a fully provable legacy record repairs exactly once against real Herdr evidence +ok - the repair reruns as a no-op with the pane untouched +``` + +The lab session's real workspace/tab/pane ids fed the recorded evidence, a deliberately wrong recorded tab refused with the pane preserved, and the default-session tripwire stayed byte-identical through provision, repair, rerun, and teardown. +The repair is harness-neutral: it reads only the shared metadata shape, git state, and Herdr's structured topology and agent-status responses, never a harness-specific surface. + ## Herdr The compatibility floor is protocol 14. diff --git a/tests/fm-herdr-legacy-repair-e2e.test.sh b/tests/fm-herdr-legacy-repair-e2e.test.sh new file mode 100755 index 00000000000..eeb348d91d6 --- /dev/null +++ b/tests/fm-herdr-legacy-repair-e2e.test.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# Real-Herdr E2E for bin/fm-herdr-legacy-repair.sh: a synthetic legacy record +# pointing at a real pane in a guarded named non-default lab session is +# refused while its recorded topology mismatches, repaired exactly once when +# every evidence check agrees, idempotent on rerun, and never mutates the +# pane. Lab teardown's tripwire verifies the default fleet session stayed +# byte-identical throughout. +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +HERDR_LAB_HELPER=${HERDR_LAB_HELPER:-$ROOT/bin/fm-herdr-lab.sh} + +fail() { printf 'not ok - %s\n' "$1" >&2; exit 1; } +pass() { printf 'ok - %s\n' "$1"; } + +command -v herdr >/dev/null 2>&1 || { echo 'skip: herdr not found'; exit 0; } +command -v jq >/dev/null 2>&1 || { echo 'skip: jq not found'; exit 0; } +[ -x "$HERDR_LAB_HELPER" ] || { echo "skip: Herdr lab helper not executable at $HERDR_LAB_HELPER"; exit 0; } + +REAL_HERDR=$(command -v herdr) +HERDR_ORIGINAL_PATH=$PATH +TMP_ROOT=$(mktemp -d "$(cd "${TMPDIR:-/tmp}" && pwd -P)/fm-herdr-legacy-repair-e2e.XXXXXX") +FAKEBIN="$TMP_ROOT/fakebin" +HOME_DIR="$TMP_ROOT/home" +mkdir -p "$FAKEBIN" "$HOME_DIR/state" + +HERDR_LAB_SESSION=$("$HERDR_LAB_HELPER" name fm-herdr-legacy-repair-e2e) +export HERDR_LAB_HELPER HERDR_LAB_SESSION REAL_HERDR HERDR_ORIGINAL_PATH +cleanup() { + local status=$? + env PATH="$HERDR_ORIGINAL_PATH" "$HERDR_LAB_HELPER" teardown "$HERDR_LAB_SESSION" || status=1 + rm -rf "$TMP_ROOT" + exit "$status" +} +trap cleanup EXIT +"$HERDR_LAB_HELPER" provision "$HERDR_LAB_SESSION" + +# Keep the lab helper as the only CLI transport. Production adapter calls have +# already appended the exact session; this shim strips that pair, refuses every +# other caller-supplied session, and delegates the command to helper run. +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" +export PATH="$FAKEBIN:$PATH" + +# A real landed git triad: origin, project clone, and a clean worktree clone +# sitting on the pushed default branch. +git init -q --bare "$TMP_ROOT/origin.git" +git clone -q "$TMP_ROOT/origin.git" "$TMP_ROOT/project" 2>/dev/null +( cd "$TMP_ROOT/project" \ + && git -c user.email=t@t -c user.name=t commit -q --allow-empty -m init \ + && git push -q origin HEAD:main && git checkout -q main ) 2>/dev/null +git clone -q "$TMP_ROOT/origin.git" "$TMP_ROOT/worktree" 2>/dev/null +( cd "$TMP_ROOT/worktree" && git checkout -q main ) 2>/dev/null + +ID=legacy-e2e-task +# One real workspace and one real fm- tab whose pane holds an agent-less +# shell at the project cwd - the exact restored-husk shape a legacy record +# points at. +WS_OUT=$(herdr workspace create --cwd "$TMP_ROOT/project" --label fm-e2e-home --no-focus --session "$HERDR_LAB_SESSION") +WSID=$(printf '%s' "$WS_OUT" | jq -r '.result.workspace.workspace_id // empty') +[ -n "$WSID" ] || fail "could not create a lab workspace: $WS_OUT" +TAB_OUT=$(herdr tab create --workspace "$WSID" --cwd "$TMP_ROOT/project" --label "fm-$ID" --no-focus --session "$HERDR_LAB_SESSION") +TABID=$(printf '%s' "$TAB_OUT" | jq -r '.result.tab.tab_id // empty') +PANEID=$(printf '%s' "$TAB_OUT" | jq -r '.result.root_pane.pane_id // empty') +[ -n "$TABID" ] && [ -n "$PANEID" ] || fail "could not create a lab task tab: $TAB_OUT" + +write_meta() { # + cat > "$HOME_DIR/state/$ID.meta" </dev/null \ + | jq -e --arg pane "$PANEID" '.result.pane.pane_id == $pane' >/dev/null 2>&1 +} + +run_repair() { + FM_HOME="$HOME_DIR" FM_ROOT_OVERRIDE="$ROOT" \ + "$ROOT/bin/fm-herdr-legacy-repair.sh" "$ID" +} + +# 1. A mismatched recorded tab refuses and mutates nothing. +write_meta "$TABID-wrong" +rc=0 +run_repair > "$TMP_ROOT/out1" 2> "$TMP_ROOT/err1" || rc=$? +[ "$rc" -ne 0 ] || fail "mismatched recorded tab was repaired: $(cat "$TMP_ROOT/out1")" +grep -q 'REFUSED' "$TMP_ROOT/err1" || fail "mismatch refusal missing a diagnostic" +grep -q '^endpoint_task_id=' "$HOME_DIR/state/$ID.meta" && fail "mismatch refusal wrote a binding" +pane_present || fail "mismatch refusal disturbed the live pane" +pass "a live-topology mismatch refuses before any change against real Herdr" + +# 2. The exact record repairs once, against real live evidence. +write_meta "$TABID" +run_repair > "$TMP_ROOT/out2" 2> "$TMP_ROOT/err2" || fail "provable legacy record refused: $(cat "$TMP_ROOT/err2")" +grep -q '^repaired:' "$TMP_ROOT/out2" || fail "repair outcome missing: $(cat "$TMP_ROOT/out2")" +[ "$(grep -c '^endpoint_task_id=' "$HOME_DIR/state/$ID.meta")" -eq 1 ] \ + || fail "repair did not add exactly one binding" +[ "$(grep '^endpoint_task_id=' "$HOME_DIR/state/$ID.meta")" = "endpoint_task_id=$ID" ] \ + || fail "repair bound the wrong task" +pane_present || fail "repair mutated the live pane" +pass "a fully provable legacy record repairs exactly once against real Herdr evidence" + +# 3. Idempotent rerun. +cp "$HOME_DIR/state/$ID.meta" "$TMP_ROOT/meta.repaired" +run_repair > "$TMP_ROOT/out3" 2> "$TMP_ROOT/err3" || fail "idempotent rerun failed: $(cat "$TMP_ROOT/err3")" +grep -q '^already-bound:' "$TMP_ROOT/out3" || fail "rerun did not report already-bound" +cmp -s "$TMP_ROOT/meta.repaired" "$HOME_DIR/state/$ID.meta" || fail "rerun changed the repaired record" +pane_present || fail "rerun disturbed the live pane" +pass "the repair reruns as a no-op with the pane untouched" + +printf 'all fm-herdr-legacy-repair-e2e tests passed\n' diff --git a/tests/fm-herdr-legacy-repair.test.sh b/tests/fm-herdr-legacy-repair.test.sh new file mode 100755 index 00000000000..ea4858deb0c --- /dev/null +++ b/tests/fm-herdr-legacy-repair.test.sh @@ -0,0 +1,454 @@ +#!/usr/bin/env bash +# Regression tests for bin/fm-herdr-legacy-repair.sh, the guarded bind-only +# repair path for legacy Herdr records that predate endpoint_task_id=. +# Covers: a modern bound record left untouched; a fully provable synthetic +# legacy landed task repaired exactly once; idempotent rerun; every +# task/worktree/project/session/workspace/tab/pane mismatch; dirty and +# unlanded work; active and nonterminal agents; missing and ambiguous fields; +# unrelated-record and default-session preservation; and refusal before any +# lifecycle mutation (the script must never issue a mutating Herdr command). +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +REPAIR="$ROOT/bin/fm-herdr-legacy-repair.sh" +TMP_ROOT=$(fm_test_tmproot fm-herdr-legacy-repair) +FM_TEST_CLEANUP_DIRS+=("$TMP_ROOT") +trap fm_test_cleanup EXIT + +SESSION=fm-lab-repair-test +WSID=wG +TABID=wG:t38 +PANEID=wG:p38 + +# One fake herdr for every case: canned JSON per subcommand through env vars, +# and a complete argv log so tests can prove exactly which calls ran. +FAKEBIN="$TMP_ROOT/fakebin" +mkdir -p "$FAKEBIN" +cat > "$FAKEBIN/herdr" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "${FAKE_HERDR_LOG:?}" +default='{"error":{"code":"unexpected"}}' +case "$1 $2" in + "pane get") printf '%s\n' "${FAKE_PANE_GET:-$default}" ;; + "agent get") printf '%s\n' "${FAKE_AGENT_GET:-$default}" ;; + "tab list") printf '%s\n' "${FAKE_TAB_LIST:-$default}" ;; + *) printf '%s\n' "$default" ;; +esac +SH +chmod +x "$FAKEBIN/herdr" + +# make_case : a fresh home plus a real origin/project/worktree git triad +# whose worktree sits clean on the landed default branch. Echoes the case dir. +make_case() { # + local dir="$TMP_ROOT/$1" + mkdir -p "$dir/home/state" + : > "$dir/herdr.log" + git init -q --bare "$dir/origin.git" + git clone -q "$dir/origin.git" "$dir/project" 2>/dev/null + ( cd "$dir/project" \ + && git -c user.email=t@t -c user.name=t commit -q --allow-empty -m init \ + && git push -q origin HEAD:main \ + && git checkout -q main 2>/dev/null || git checkout -q -b main ) 2>/dev/null + git clone -q "$dir/origin.git" "$dir/worktree" 2>/dev/null + ( cd "$dir/worktree" && git checkout -q main ) 2>/dev/null + printf '%s\n' "$dir" +} + +# write_legacy_meta [extra-lines...]: the pre-hardening +# record shape - full Herdr endpoint fields, no endpoint_task_id=. +write_legacy_meta() { + local dir=$1 id=$2 + shift 2 + fm_write_meta "$dir/home/state/$id.meta" \ + "window=$SESSION:$PANEID" \ + "worktree=$dir/worktree" \ + "project=$dir/project" \ + "harness=claude" \ + "kind=ship" \ + "mode=no-mistakes" \ + "backend=herdr" \ + "herdr_session=$SESSION" \ + "herdr_workspace_id=$WSID" \ + "herdr_tab_id=$TABID" \ + "herdr_pane_id=$PANEID" \ + "$@" +} + +run_repair() { # + local dir=$1 id=$2 + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" \ + FAKE_HERDR_LOG="$dir/herdr.log" \ + PATH="$FAKEBIN:$PATH" \ + "$REPAIR" "$id" > "$dir/stdout" 2> "$dir/stderr" +} + +assert_no_lifecycle_mutation() { # + local dir=$1 description=$2 + ! grep -qE '(^| )(close|stop|delete|kill|create|run|send|move)( |$)' "$dir/herdr.log" \ + || fail "$description: a mutating Herdr command ran: $(cat "$dir/herdr.log")" +} + +assert_refused_unchanged() { # + local dir=$1 id=$2 needle=$3 description=$4 rc=0 + cp "$dir/home/state/$id.meta" "$dir/meta.before" + run_repair "$dir" "$id" || rc=$? + [ "$rc" -ne 0 ] || fail "$description: repair unexpectedly succeeded" + cmp -s "$dir/meta.before" "$dir/home/state/$id.meta" \ + || fail "$description: refusal changed the task metadata" + assert_grep "$needle" "$dir/stderr" "$description: missing concrete diagnostic" + assert_no_lifecycle_mutation "$dir" "$description" +} + +pane_present_json() { # + printf '{"result":{"pane":{"pane_id":"%s","tab_id":"%s","foreground_cwd":"%s"}}}' \ + "$PANEID" "$1" "$2" +} + +test_modern_bound_record_untouched() { + local dir rc=0 + dir=$(make_case modern-bound) + write_legacy_meta "$dir" bound-task "endpoint_task_id=bound-task" + cp "$dir/home/state/bound-task.meta" "$dir/meta.before" + run_repair "$dir" bound-task || rc=$? + expect_code 0 "$rc" "modern bound record should be an idempotent no-op" + assert_grep "already-bound" "$dir/stdout" "modern bound record should report already-bound" + cmp -s "$dir/meta.before" "$dir/home/state/bound-task.meta" \ + || fail "modern bound record was modified" + [ ! -s "$dir/herdr.log" ] || fail "modern bound record triggered Herdr calls: $(cat "$dir/herdr.log")" + pass "a proven modern bound record is untouched and reports already-bound" +} + +test_legacy_landed_gone_pane_repaired_once_and_idempotent() { + local dir rc=0 + dir=$(make_case legacy-gone) + write_legacy_meta "$dir" legacy-gone + cp "$dir/home/state/legacy-gone.meta" "$dir/meta.before" + FAKE_PANE_GET='{"error":{"code":"pane_not_found"}}' run_repair "$dir" legacy-gone || rc=$? + expect_code 0 "$rc" "provable legacy landed task with a gone pane should repair" + assert_grep "repaired" "$dir/stdout" "repair outcome not reported" + [ "$(grep -c '^endpoint_task_id=' "$dir/home/state/legacy-gone.meta")" -eq 1 ] \ + || fail "repair did not add exactly one binding" + [ "$(grep '^endpoint_task_id=' "$dir/home/state/legacy-gone.meta")" = "endpoint_task_id=legacy-gone" ] \ + || fail "repair bound the wrong task id" + cmp -s <(grep -v '^endpoint_task_id=' "$dir/home/state/legacy-gone.meta") "$dir/meta.before" \ + || fail "repair changed more than the single binding line" + assert_no_lifecycle_mutation "$dir" "successful repair" + + cp "$dir/home/state/legacy-gone.meta" "$dir/meta.repaired" + : > "$dir/herdr.log" + rc=0 + FAKE_PANE_GET='{"error":{"code":"pane_not_found"}}' run_repair "$dir" legacy-gone || rc=$? + expect_code 0 "$rc" "idempotent rerun should succeed" + assert_grep "already-bound" "$dir/stdout" "rerun should report already-bound" + cmp -s "$dir/meta.repaired" "$dir/home/state/legacy-gone.meta" \ + || fail "idempotent rerun changed the repaired record" + [ ! -s "$dir/herdr.log" ] || fail "idempotent rerun triggered Herdr calls" + pass "a fully provable legacy landed task repairs exactly once and reruns as a no-op" +} + +test_legacy_landed_idle_agent_and_husk_repair() { + local dir rc=0 + dir=$(make_case legacy-idle) + write_legacy_meta "$dir" legacy-idle + rc=0 + FAKE_PANE_GET=$(pane_present_json "$TABID" "$dir/worktree") \ + FAKE_AGENT_GET='{"result":{"agent":{"agent":"claude","agent_status":"idle"}}}' \ + FAKE_TAB_LIST="{\"result\":{\"tabs\":[{\"tab_id\":\"$TABID\",\"label\":\"fm-legacy-idle\"}]}}" \ + run_repair "$dir" legacy-idle || rc=$? + expect_code 0 "$rc" "finished idle agent with exact topology should repair" + assert_no_lifecycle_mutation "$dir" "idle-agent repair" + grep -qE '^pane get ' "$dir/herdr.log" || fail "idle-agent repair read no live pane evidence" + + dir=$(make_case legacy-husk) + write_legacy_meta "$dir" legacy-husk + rc=0 + FAKE_PANE_GET=$(pane_present_json "$TABID" "$dir/project") \ + FAKE_AGENT_GET='{"error":{"code":"agent_not_found"}}' \ + FAKE_TAB_LIST="{\"result\":{\"tabs\":[{\"tab_id\":\"$TABID\",\"label\":\"fm-legacy-husk\"}]}}" \ + run_repair "$dir" legacy-husk || rc=$? + expect_code 0 "$rc" "restored husk shell at the creation cwd should repair" + pass "a live finished agent and a restored husk both repair with exact topology" +} + +test_active_and_nonterminal_agents_refuse() { + local dir status + for status in working blocked; do + dir=$(make_case "agent-$status") + write_legacy_meta "$dir" active-task + FAKE_PANE_GET=$(pane_present_json "$TABID" "$dir/worktree") \ + FAKE_AGENT_GET="{\"result\":{\"agent\":{\"agent\":\"claude\",\"agent_status\":\"$status\"}}}" \ + FAKE_TAB_LIST="{\"result\":{\"tabs\":[{\"tab_id\":\"$TABID\",\"label\":\"fm-active-task\"}]}}" \ + assert_refused_unchanged "$dir" active-task "$status" "$status agent" + done + dir=$(make_case agent-unreadable) + write_legacy_meta "$dir" active-task + FAKE_PANE_GET=$(pane_present_json "$TABID" "$dir/worktree") \ + FAKE_AGENT_GET='not json at all' \ + FAKE_TAB_LIST="{\"result\":{\"tabs\":[{\"tab_id\":\"$TABID\",\"label\":\"fm-active-task\"}]}}" \ + assert_refused_unchanged "$dir" active-task "agent state" "unreadable agent" + pass "working, blocked, and unreadable agents refuse and preserve everything" +} + +test_dirty_and_unlanded_work_refuse() { + local dir + dir=$(make_case dirty) + write_legacy_meta "$dir" dirty-task + echo scratch > "$dir/worktree/scratch.txt" + assert_refused_unchanged "$dir" dirty-task "uncommitted changes" "dirty worktree" + [ ! -s "$dir/herdr.log" ] || fail "dirty refusal still contacted Herdr" + + dir=$(make_case unlanded) + write_legacy_meta "$dir" unlanded-task + ( cd "$dir/worktree" && git checkout -qb fm/unlanded \ + && echo work > w.txt && git add w.txt \ + && git -c user.email=t@t -c user.name=t commit -qm work ) + assert_refused_unchanged "$dir" unlanded-task "not proven landed" "unlanded work" + [ ! -s "$dir/herdr.log" ] || fail "unlanded refusal still contacted Herdr" + pass "dirty and unlanded worktrees refuse before any Herdr call" +} + +test_squash_landed_content_repairs() { + local dir rc=0 + dir=$(make_case squash-landed) + write_legacy_meta "$dir" squash-task + ( cd "$dir/worktree" && git checkout -qb fm/squash \ + && echo squash-content > sq.txt && git add sq.txt \ + && git -c user.email=t@t -c user.name=t commit -qm "feat: sq" ) + ( cd "$dir/project" && git pull -q origin main \ + && echo squash-content > sq.txt && git add sq.txt \ + && git -c user.email=t@t -c user.name=t commit -qm "feat: sq (squash)" \ + && git push -q origin main ) + FAKE_PANE_GET='{"error":{"code":"pane_not_found"}}' run_repair "$dir" squash-task || rc=$? + expect_code 0 "$rc" "squash-landed branch content should prove landed via the shared predicates" + pass "squash-merged content proves landed through the shared landed-work owner" +} + +test_missing_and_ambiguous_fields_refuse() { + local dir + dir=$(make_case missing-pane-field) + fm_write_meta "$dir/home/state/broken-task.meta" \ + "window=$SESSION:$PANEID" "worktree=$dir/worktree" "project=$dir/project" \ + "backend=herdr" "herdr_session=$SESSION" "herdr_workspace_id=$WSID" \ + "herdr_tab_id=$TABID" + assert_refused_unchanged "$dir" broken-task "malformed or inconsistent" "missing herdr_pane_id" + + dir=$(make_case window-mismatch) + write_legacy_meta "$dir" window-task + fm_write_meta "$dir/home/state/window-task.meta" \ + "window=other-session:$PANEID" "worktree=$dir/worktree" "project=$dir/project" \ + "backend=herdr" "herdr_session=$SESSION" "herdr_workspace_id=$WSID" \ + "herdr_tab_id=$TABID" "herdr_pane_id=$PANEID" + assert_refused_unchanged "$dir" window-task "malformed or inconsistent" "window/session mismatch" + + dir=$(make_case duplicate-binding) + write_legacy_meta "$dir" dup-task "endpoint_task_id=dup-task" "endpoint_task_id=dup-task" + assert_refused_unchanged "$dir" dup-task "endpoint task bindings" "duplicate binding" + + dir=$(make_case empty-binding) + write_legacy_meta "$dir" empty-task "endpoint_task_id=" + assert_refused_unchanged "$dir" empty-task "empty endpoint task binding" "empty binding" + + dir=$(make_case foreign-binding) + write_legacy_meta "$dir" mine-task "endpoint_task_id=other-task" + assert_refused_unchanged "$dir" mine-task "belongs to task other-task" "foreign binding" + + dir=$(make_case duplicate-backend) + write_legacy_meta "$dir" dupback-task "backend=herdr" + assert_refused_unchanged "$dir" dupback-task "backend= records" "duplicate backend" + + dir=$(make_case no-meta) + local rc=0 + run_repair "$dir" ghost-task || rc=$? + [ "$rc" -ne 0 ] || fail "missing metadata should refuse" + assert_grep "no metadata" "$dir/stderr" "missing metadata diagnostic" + pass "missing, duplicate, empty, foreign, and ambiguous fields all refuse unchanged" +} + +test_scope_refusals() { + local dir + dir=$(make_case tmux-record) + fm_write_meta "$dir/home/state/tmux-task.meta" \ + "window=iso:fm-tmux-task" "worktree=$dir/worktree" "project=$dir/project" \ + "backend=tmux" "endpoint_task_id=tmux-task" + assert_refused_unchanged "$dir" tmux-task "covers only Herdr records" "tmux record" + + dir=$(make_case zellij-record) + fm_write_meta "$dir/home/state/z-task.meta" \ + "window=zses:1" "worktree=$dir/worktree" "project=$dir/project" \ + "backend=zellij" + assert_refused_unchanged "$dir" z-task "covers only Herdr records" "zellij record" + + dir=$(make_case secondmate-kind) + write_legacy_meta "$dir" sm-task "kind=secondmate" + assert_refused_unchanged "$dir" sm-task "never touches secondmates" "secondmate record" + + dir=$(make_case remote-route) + write_legacy_meta "$dir" remote-task "remote_host=host.example" + assert_refused_unchanged "$dir" remote-task "remote route" "remote record" + + dir=$(make_case secondmate-home) + write_legacy_meta "$dir" home-task + printf 'sm\n' > "$dir/home/.fm-secondmate-home" + assert_refused_unchanged "$dir" home-task "secondmate home" "secondmate home" + pass "tmux, other opaque backends, secondmates, and remote routes refuse by name" +} + +test_worktree_project_mismatches_refuse() { + local dir + dir=$(make_case missing-worktree) + write_legacy_meta "$dir" mw-task + rm -rf "$dir/worktree" + assert_refused_unchanged "$dir" mw-task "does not exist" "missing worktree" + + dir=$(make_case foreign-origin) + git init -q --bare "$dir/other-origin.git" + rm -rf "$dir/worktree" + git clone -q "$dir/other-origin.git" "$dir/worktree" 2>/dev/null + ( cd "$dir/worktree" \ + && git -c user.email=t@t -c user.name=t commit -q --allow-empty -m init \ + && git push -q origin HEAD:main && git checkout -q main ) 2>/dev/null + write_legacy_meta "$dir" fo-task + assert_refused_unchanged "$dir" fo-task "does not match the recorded project" "foreign-origin worktree" + + dir=$(make_case worktree-is-project) + write_legacy_meta "$dir" wp-task + fm_write_meta "$dir/home/state/wp-task.meta" \ + "window=$SESSION:$PANEID" "worktree=$dir/project" "project=$dir/project" \ + "backend=herdr" "herdr_session=$SESSION" "herdr_workspace_id=$WSID" \ + "herdr_tab_id=$TABID" "herdr_pane_id=$PANEID" + assert_refused_unchanged "$dir" wp-task "isolated copy" "worktree equals project" + pass "missing, foreign, and non-isolated worktrees refuse unchanged" +} + +test_live_topology_mismatches_refuse() { + local dir + dir=$(make_case live-tab-mismatch) + write_legacy_meta "$dir" tm-task + FAKE_PANE_GET=$(pane_present_json "wG:t99" "$dir/worktree") \ + assert_refused_unchanged "$dir" tm-task "not the recorded tab" "live tab mismatch" + + dir=$(make_case tab-not-in-workspace) + write_legacy_meta "$dir" tw-task + FAKE_PANE_GET=$(pane_present_json "$TABID" "$dir/worktree") \ + FAKE_TAB_LIST='{"result":{"tabs":[]}}' \ + assert_refused_unchanged "$dir" tw-task "missing from workspace" "tab not in recorded workspace" + + dir=$(make_case wrong-label) + write_legacy_meta "$dir" wl-task + FAKE_PANE_GET=$(pane_present_json "$TABID" "$dir/worktree") \ + FAKE_TAB_LIST="{\"result\":{\"tabs\":[{\"tab_id\":\"$TABID\",\"label\":\"fm-other-task\"}]}}" \ + assert_refused_unchanged "$dir" wl-task "not labeled" "wrong tab label" + + dir=$(make_case cwd-mismatch) + write_legacy_meta "$dir" cw-task + mkdir -p "$dir/elsewhere" + FAKE_PANE_GET=$(pane_present_json "$TABID" "$dir/elsewhere") \ + FAKE_AGENT_GET='{"result":{"agent":{"agent":"claude","agent_status":"idle"}}}' \ + FAKE_TAB_LIST="{\"result\":{\"tabs\":[{\"tab_id\":\"$TABID\",\"label\":\"fm-cw-task\"}]}}" \ + assert_refused_unchanged "$dir" cw-task "not the recorded worktree" "foreground cwd mismatch" + + dir=$(make_case unreadable-endpoint) + write_legacy_meta "$dir" ue-task + FAKE_PANE_GET='total garbage' \ + assert_refused_unchanged "$dir" ue-task "unreadable or the session is unreachable" "unreadable endpoint" + pass "every live session/workspace/tab/pane/cwd mismatch refuses unchanged" +} + +test_unrelated_records_and_default_session_preserved() { + local dir rc=0 + dir=$(make_case unrelated-preserved) + write_legacy_meta "$dir" target-task + # An unrelated modern task on the captain's default session, plus a foreign + # status record: a successful repair of target-task must not touch either, + # and must never address any session but the recorded one. + fm_write_meta "$dir/home/state/other-task.meta" \ + "window=default:wA:p1" "worktree=$dir/worktree" "project=$dir/project" \ + "backend=herdr" "herdr_session=default" "herdr_workspace_id=wA" \ + "herdr_tab_id=wA:t1" "herdr_pane_id=wA:p1" "endpoint_task_id=other-task" + printf 'working: unrelated\n' > "$dir/home/state/other-task.status" + cp "$dir/home/state/other-task.meta" "$dir/other.before" + FAKE_PANE_GET='{"error":{"code":"pane_not_found"}}' run_repair "$dir" target-task || rc=$? + expect_code 0 "$rc" "repair beside unrelated records should succeed" + cmp -s "$dir/other.before" "$dir/home/state/other-task.meta" \ + || fail "repair modified an unrelated task's metadata" + [ "$(cat "$dir/home/state/other-task.status")" = "working: unrelated" ] \ + || fail "repair modified an unrelated task's status record" + ! grep -q 'session default' "$dir/herdr.log" \ + || fail "repair addressed the default session: $(cat "$dir/herdr.log")" + grep -qE -- "--session $SESSION" "$dir/herdr.log" \ + || fail "repair did not scope its Herdr reads to the recorded session" + pass "unrelated records and the default session are preserved by a successful repair" +} + +test_refusal_precedes_lifecycle_and_teardown_accepts_repaired_record() { + local dir rc=0 + dir=$(make_case teardown-after-repair) + write_legacy_meta "$dir" e2e-task + # Before repair: ordinary teardown must refuse the unbound legacy record. + rc=0 + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" FM_TEARDOWN_GUARD_DONE=1 \ + PATH="$FAKEBIN:$PATH" FAKE_HERDR_LOG="$dir/herdr.log" \ + "$ROOT/bin/fm-teardown.sh" e2e-task > "$dir/td.out" 2> "$dir/td.err" || rc=$? + [ "$rc" -ne 0 ] || fail "teardown accepted an unbound legacy Herdr record" + assert_grep "lacks an exact task binding" "$dir/td.err" \ + "teardown's legacy refusal changed shape; update the repair contract" + # After repair: the same record passes teardown's first authorization check + # (it then proceeds into teardown's own later safety machinery). + rc=0 + FAKE_PANE_GET='{"error":{"code":"pane_not_found"}}' run_repair "$dir" e2e-task || rc=$? + expect_code 0 "$rc" "repair of the teardown case failed" + rc=0 + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" FM_TEARDOWN_GUARD_DONE=1 \ + PATH="$FAKEBIN:$PATH" FAKE_HERDR_LOG="$dir/herdr.log" \ + FAKE_PANE_GET='{"error":{"code":"pane_not_found"}}' \ + "$ROOT/bin/fm-teardown.sh" e2e-task > "$dir/td2.out" 2> "$dir/td2.err" || rc=$? + assert_no_grep "lacks an exact task binding" "$dir/td2.err" \ + "teardown still refuses the repaired record's binding" + pass "teardown refuses the legacy record before repair and accepts its binding after" +} + +test_lock_contention_refuses() { + local dir rc=0 holder lock i=0 + dir=$(make_case lock-contention) + write_legacy_meta "$dir" locked-task + lock="$dir/home/state/.control-locked-task.lock" + ( + # shellcheck source=/dev/null + . "$ROOT/bin/fm-wake-lib.sh" + fm_lock_try_acquire "$lock" || exit 1 + sleep 30 + ) & + holder=$! + while [ ! -e "$lock" ] && [ "$i" -lt 100 ]; do + sleep 0.1 + i=$((i + 1)) + done + [ -e "$lock" ] || { kill "$holder" 2>/dev/null || true; fail "could not stage a held control lock"; } + cp "$dir/home/state/locked-task.meta" "$dir/meta.before" + run_repair "$dir" locked-task || rc=$? + [ "$rc" -ne 0 ] || fail "repair ran under a held lifecycle lock" + assert_grep "another lifecycle action" "$dir/stderr" "lock contention diagnostic" + cmp -s "$dir/meta.before" "$dir/home/state/locked-task.meta" \ + || fail "lock-contended repair changed the metadata" + kill "$holder" 2>/dev/null || true + wait "$holder" 2>/dev/null || true + pass "a concurrent lifecycle action refuses the repair before any change" +} + +test_modern_bound_record_untouched +test_legacy_landed_gone_pane_repaired_once_and_idempotent +test_legacy_landed_idle_agent_and_husk_repair +test_active_and_nonterminal_agents_refuse +test_dirty_and_unlanded_work_refuse +test_squash_landed_content_repairs +test_missing_and_ambiguous_fields_refuse +test_scope_refusals +test_worktree_project_mismatches_refuse +test_live_topology_mismatches_refuse +test_unrelated_records_and_default_session_preserved +test_refusal_precedes_lifecycle_and_teardown_accepts_repaired_record +test_lock_contention_refuses + +printf 'all fm-herdr-legacy-repair tests passed\n' From 65cbc16e7e02e7ae4c94d9d815f00ebf857575d3 Mon Sep 17 00:00:00 2001 From: Rene Garza Jr Date: Fri, 14 Aug 2026 17:57:45 -0500 Subject: [PATCH 2/7] test(gotmp): repair teardown fixture sibling links The partial-bin teardown fixture was broken by two upstream reconciles it never followed: it linked fm-wake-lib.sh twice (an ln 'File exists' error on every run), and it lacked fm-session-lock-lib.sh, which the tmux adapter now sources - a missing sourced sibling aborts teardown mid-kill under set -e even inside a guarded call, so the suite failed on main before this branch. Replace the duplicate link with fm-landed-lib.sh (teardown now sources the shared landed-work predicates) and add the missing session-lock lib, restoring all three gotmp assertions. Also restructure the legacy-repair suite's default-branch fallback so the checkout retry is an explicit if rather than an A && B || C chain (shellcheck SC2015). --- tests/fm-gotmp.test.sh | 14 +++++++++++--- tests/fm-herdr-legacy-repair.test.sh | 12 ++++++++---- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/tests/fm-gotmp.test.sh b/tests/fm-gotmp.test.sh index c1f6348bff5..f51f73e5b42 100755 --- a/tests/fm-gotmp.test.sh +++ b/tests/fm-gotmp.test.sh @@ -57,6 +57,10 @@ make_fake_root() { ln -s "$ROOT/bin/fm-backend.sh" "$fake/bin/fm-backend.sh" ln -s "$ROOT/bin/backends/tmux.sh" "$fake/bin/backends/tmux.sh" ln -s "$ROOT/bin/fm-tmux-lib.sh" "$fake/bin/fm-tmux-lib.sh" + # fm-session-lock-lib.sh: the tmux adapter sources it since the upstream + # session-lock reconcile; a missing sourced sibling aborts teardown mid-kill + # under set -e even inside a guarded call. + ln -s "$ROOT/bin/fm-session-lock-lib.sh" "$fake/bin/fm-session-lock-lib.sh" ln -s "$ROOT/bin/fm-composer-lib.sh" "$fake/bin/fm-composer-lib.sh" ln -s "$ROOT/bin/fm-nm-run-lib.sh" "$fake/bin/fm-nm-run-lib.sh" # fm-lock-lib.sh: teardown sources it for the shared lock-staleness proof. @@ -76,8 +80,8 @@ make_fake_root() { ln -s "$ROOT/bin/fm-x-lib.sh" "$fake/bin/fm-x-lib.sh" ln -s "$ROOT/bin/fm-secondmate-registry-lib.sh" "$fake/bin/fm-secondmate-registry-lib.sh" ln -s "$ROOT/bin/fm-secondmate-parent-lib.sh" "$fake/bin/fm-secondmate-parent-lib.sh" - # fm-wake-lib.sh: teardown sources it for serialized secondmate lifecycle locks. - ln -s "$ROOT/bin/fm-wake-lib.sh" "$fake/bin/fm-wake-lib.sh" + # fm-landed-lib.sh: teardown sources the shared landed-work predicates. + ln -s "$ROOT/bin/fm-landed-lib.sh" "$fake/bin/fm-landed-lib.sh" # fm-guard.sh: stub (teardown calls it with `|| true`). cat > "$fake/bin/fm-guard.sh" <<'SH' #!/usr/bin/env bash @@ -138,6 +142,10 @@ test_teardown_skips_gracefully_without_tasktmp() { ln -s "$ROOT/bin/fm-backend.sh" "$fake/bin/fm-backend.sh" ln -s "$ROOT/bin/backends/tmux.sh" "$fake/bin/backends/tmux.sh" ln -s "$ROOT/bin/fm-tmux-lib.sh" "$fake/bin/fm-tmux-lib.sh" + # fm-session-lock-lib.sh: the tmux adapter sources it since the upstream + # session-lock reconcile; a missing sourced sibling aborts teardown mid-kill + # under set -e even inside a guarded call. + ln -s "$ROOT/bin/fm-session-lock-lib.sh" "$fake/bin/fm-session-lock-lib.sh" ln -s "$ROOT/bin/fm-composer-lib.sh" "$fake/bin/fm-composer-lib.sh" ln -s "$ROOT/bin/fm-nm-run-lib.sh" "$fake/bin/fm-nm-run-lib.sh" ln -s "$ROOT/bin/fm-lock-lib.sh" "$fake/bin/fm-lock-lib.sh" @@ -155,7 +163,7 @@ test_teardown_skips_gracefully_without_tasktmp() { ln -s "$ROOT/bin/fm-x-lib.sh" "$fake/bin/fm-x-lib.sh" ln -s "$ROOT/bin/fm-secondmate-registry-lib.sh" "$fake/bin/fm-secondmate-registry-lib.sh" ln -s "$ROOT/bin/fm-secondmate-parent-lib.sh" "$fake/bin/fm-secondmate-parent-lib.sh" - ln -s "$ROOT/bin/fm-wake-lib.sh" "$fake/bin/fm-wake-lib.sh" + ln -s "$ROOT/bin/fm-landed-lib.sh" "$fake/bin/fm-landed-lib.sh" cat > "$fake/bin/fm-guard.sh" <<'SH' #!/usr/bin/env bash exit 0 diff --git a/tests/fm-herdr-legacy-repair.test.sh b/tests/fm-herdr-legacy-repair.test.sh index ea4858deb0c..91124a0f558 100755 --- a/tests/fm-herdr-legacy-repair.test.sh +++ b/tests/fm-herdr-legacy-repair.test.sh @@ -47,10 +47,14 @@ make_case() { # : > "$dir/herdr.log" git init -q --bare "$dir/origin.git" git clone -q "$dir/origin.git" "$dir/project" 2>/dev/null - ( cd "$dir/project" \ - && git -c user.email=t@t -c user.name=t commit -q --allow-empty -m init \ - && git push -q origin HEAD:main \ - && git checkout -q main 2>/dev/null || git checkout -q -b main ) 2>/dev/null + ( + cd "$dir/project" || exit 1 + git -c user.email=t@t -c user.name=t commit -q --allow-empty -m init + git push -q origin HEAD:main + if ! git checkout -q main 2>/dev/null; then + git checkout -q -b main + fi + ) 2>/dev/null git clone -q "$dir/origin.git" "$dir/worktree" 2>/dev/null ( cd "$dir/worktree" && git checkout -q main ) 2>/dev/null printf '%s\n' "$dir" From d8553dd3080fe0b9d27af5457bad85d193cc7211 Mon Sep 17 00:00:00 2001 From: Rene Garza Jr Date: Fri, 14 Aug 2026 18:11:10 -0500 Subject: [PATCH 3/7] no-mistakes(review): Reject ambiguous legacy Herdr task kinds --- bin/fm-herdr-legacy-repair.sh | 17 ++++++++++++++--- tests/fm-herdr-legacy-repair.test.sh | 21 ++++++++++++++++++--- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/bin/fm-herdr-legacy-repair.sh b/bin/fm-herdr-legacy-repair.sh index e0c3feaf556..157d30cf589 100755 --- a/bin/fm-herdr-legacy-repair.sh +++ b/bin/fm-herdr-legacy-repair.sh @@ -139,9 +139,20 @@ STATE_DEVICE=$(fm_pr_file_device "$STATE") || refuse "state directory device is [ "$META_DEVICE" = "$STATE_DEVICE" ] \ || refuse "task $ID metadata is not on the state directory's device." -KIND=$(grep '^kind=' "$META" | tail -1 | cut -d= -f2- || true) -[ "$KIND" != secondmate ] \ - || refuse "task $ID is a secondmate record; the legacy repair path never touches secondmates." +KIND_COUNT=$(grep -c '^kind=' "$META" 2>/dev/null || true) +[ "$KIND_COUNT" -eq 1 ] \ + || refuse "task $ID has $KIND_COUNT kind= records; a missing or ambiguous task kind is not a repair candidate." +KIND=$(fm_backend_meta_exact_value "$META" kind) \ + || refuse "task $ID has an empty task kind; preserved for inspection." +case "$KIND" in + ship|scout) ;; + secondmate) + refuse "task $ID is a secondmate record; the legacy repair path never touches secondmates." + ;; + *) + refuse "task $ID records kind=$KIND; the legacy repair path covers only primary ship and scout tasks." + ;; +esac [ "$(grep -c '^remote_host=' "$META" 2>/dev/null || true)" -eq 0 ] \ || refuse "task $ID has a remote route; the legacy repair path covers only local primary-home records." diff --git a/tests/fm-herdr-legacy-repair.test.sh b/tests/fm-herdr-legacy-repair.test.sh index 91124a0f558..2747ea7b13a 100755 --- a/tests/fm-herdr-legacy-repair.test.sh +++ b/tests/fm-herdr-legacy-repair.test.sh @@ -234,6 +234,7 @@ test_missing_and_ambiguous_fields_refuse() { dir=$(make_case missing-pane-field) fm_write_meta "$dir/home/state/broken-task.meta" \ "window=$SESSION:$PANEID" "worktree=$dir/worktree" "project=$dir/project" \ + "kind=ship" \ "backend=herdr" "herdr_session=$SESSION" "herdr_workspace_id=$WSID" \ "herdr_tab_id=$TABID" assert_refused_unchanged "$dir" broken-task "malformed or inconsistent" "missing herdr_pane_id" @@ -242,6 +243,7 @@ test_missing_and_ambiguous_fields_refuse() { write_legacy_meta "$dir" window-task fm_write_meta "$dir/home/state/window-task.meta" \ "window=other-session:$PANEID" "worktree=$dir/worktree" "project=$dir/project" \ + "kind=ship" \ "backend=herdr" "herdr_session=$SESSION" "herdr_workspace_id=$WSID" \ "herdr_tab_id=$TABID" "herdr_pane_id=$PANEID" assert_refused_unchanged "$dir" window-task "malformed or inconsistent" "window/session mismatch" @@ -262,6 +264,14 @@ test_missing_and_ambiguous_fields_refuse() { write_legacy_meta "$dir" dupback-task "backend=herdr" assert_refused_unchanged "$dir" dupback-task "backend= records" "duplicate backend" + dir=$(make_case duplicate-kind) + fm_write_meta "$dir/home/state/dupkind-task.meta" \ + "window=$SESSION:$PANEID" "worktree=$dir/worktree" "project=$dir/project" \ + "kind=secondmate" "kind=ship" \ + "backend=herdr" "herdr_session=$SESSION" "herdr_workspace_id=$WSID" \ + "herdr_tab_id=$TABID" "herdr_pane_id=$PANEID" + assert_refused_unchanged "$dir" dupkind-task "kind= records" "duplicate kind" + dir=$(make_case no-meta) local rc=0 run_repair "$dir" ghost-task || rc=$? @@ -275,17 +285,21 @@ test_scope_refusals() { dir=$(make_case tmux-record) fm_write_meta "$dir/home/state/tmux-task.meta" \ "window=iso:fm-tmux-task" "worktree=$dir/worktree" "project=$dir/project" \ - "backend=tmux" "endpoint_task_id=tmux-task" + "kind=ship" "backend=tmux" "endpoint_task_id=tmux-task" assert_refused_unchanged "$dir" tmux-task "covers only Herdr records" "tmux record" dir=$(make_case zellij-record) fm_write_meta "$dir/home/state/z-task.meta" \ "window=zses:1" "worktree=$dir/worktree" "project=$dir/project" \ - "backend=zellij" + "kind=ship" "backend=zellij" assert_refused_unchanged "$dir" z-task "covers only Herdr records" "zellij record" dir=$(make_case secondmate-kind) - write_legacy_meta "$dir" sm-task "kind=secondmate" + fm_write_meta "$dir/home/state/sm-task.meta" \ + "window=$SESSION:$PANEID" "worktree=$dir/worktree" "project=$dir/project" \ + "kind=secondmate" \ + "backend=herdr" "herdr_session=$SESSION" "herdr_workspace_id=$WSID" \ + "herdr_tab_id=$TABID" "herdr_pane_id=$PANEID" assert_refused_unchanged "$dir" sm-task "never touches secondmates" "secondmate record" dir=$(make_case remote-route) @@ -320,6 +334,7 @@ test_worktree_project_mismatches_refuse() { write_legacy_meta "$dir" wp-task fm_write_meta "$dir/home/state/wp-task.meta" \ "window=$SESSION:$PANEID" "worktree=$dir/project" "project=$dir/project" \ + "kind=ship" \ "backend=herdr" "herdr_session=$SESSION" "herdr_workspace_id=$WSID" \ "herdr_tab_id=$TABID" "herdr_pane_id=$PANEID" assert_refused_unchanged "$dir" wp-task "isolated copy" "worktree equals project" From aadf8ec2629a6312f847bb69c0527aaa24940d5e Mon Sep 17 00:00:00 2001 From: Rene Garza Jr Date: Fri, 14 Aug 2026 18:17:48 -0500 Subject: [PATCH 4/7] no-mistakes(review): Reject ambiguous legacy Herdr PR evidence --- bin/fm-herdr-legacy-repair.sh | 9 ++++++++- tests/fm-herdr-legacy-repair.test.sh | 7 +++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/bin/fm-herdr-legacy-repair.sh b/bin/fm-herdr-legacy-repair.sh index 157d30cf589..9af1fd324de 100755 --- a/bin/fm-herdr-legacy-repair.sh +++ b/bin/fm-herdr-legacy-repair.sh @@ -195,7 +195,14 @@ SESSION=$(fm_backend_meta_exact_value "$META" herdr_session) || refuse "task $ID WORKSPACE=$(fm_backend_meta_exact_value "$META" herdr_workspace_id) || refuse "task $ID herdr_workspace_id became unreadable." TAB=$(fm_backend_meta_exact_value "$META" herdr_tab_id) || refuse "task $ID herdr_tab_id became unreadable." PANE=$(fm_backend_meta_exact_value "$META" herdr_pane_id) || refuse "task $ID herdr_pane_id became unreadable." -PR_URL=$(grep '^pr=' "$META" | tail -1 | cut -d= -f2- || true) +PR_COUNT=$(grep -c '^pr=' "$META" 2>/dev/null || true) +[ "$PR_COUNT" -le 1 ] \ + || refuse "task $ID has $PR_COUNT pr= records; ambiguous landed-work evidence is preserved for inspection." +PR_URL= +if [ "$PR_COUNT" -eq 1 ]; then + PR_URL=$(fm_backend_meta_exact_value "$META" pr) \ + || refuse "task $ID has an empty PR identity; landed-work evidence is preserved for inspection." +fi canonical_dir() { local target=$1 diff --git a/tests/fm-herdr-legacy-repair.test.sh b/tests/fm-herdr-legacy-repair.test.sh index 2747ea7b13a..bd6b920026b 100755 --- a/tests/fm-herdr-legacy-repair.test.sh +++ b/tests/fm-herdr-legacy-repair.test.sh @@ -272,6 +272,13 @@ test_missing_and_ambiguous_fields_refuse() { "herdr_tab_id=$TABID" "herdr_pane_id=$PANEID" assert_refused_unchanged "$dir" dupkind-task "kind= records" "duplicate kind" + dir=$(make_case duplicate-pr) + write_legacy_meta "$dir" duppr-task \ + "pr=https://github.com/example/project/pull/1" \ + "pr=https://github.com/example/project/pull/2" + FAKE_PANE_GET='{"error":{"code":"pane_not_found"}}' \ + assert_refused_unchanged "$dir" duppr-task "pr= records" "duplicate PR" + dir=$(make_case no-meta) local rc=0 run_repair "$dir" ghost-task || rc=$? From e235bc4a36534cdacf24c06b555ceeae85345fad Mon Sep 17 00:00:00 2001 From: Rene Garza Jr Date: Fri, 14 Aug 2026 18:37:55 -0500 Subject: [PATCH 5/7] no-mistakes(review): Harden legacy Herdr repair preflight and topology --- bin/fm-herdr-legacy-repair.sh | 38 ++++++++++++++++------------ tests/fm-herdr-legacy-repair.test.sh | 37 +++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 16 deletions(-) diff --git a/bin/fm-herdr-legacy-repair.sh b/bin/fm-herdr-legacy-repair.sh index 9af1fd324de..e8bbf05a532 100755 --- a/bin/fm-herdr-legacy-repair.sh +++ b/bin/fm-herdr-legacy-repair.sh @@ -64,19 +64,8 @@ FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" -# shellcheck source=bin/fm-pr-lib.sh -. "$SCRIPT_DIR/fm-pr-lib.sh" -# shellcheck source=bin/fm-wake-lib.sh -. "$SCRIPT_DIR/fm-wake-lib.sh" -# shellcheck source=bin/fm-gate-refuse-lib.sh -. "$SCRIPT_DIR/fm-gate-refuse-lib.sh" -# shellcheck source=bin/fm-backend.sh -. "$SCRIPT_DIR/fm-backend.sh" -# shellcheck source=bin/fm-landed-lib.sh -. "$SCRIPT_DIR/fm-landed-lib.sh" - usage() { - sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//' + sed -n '2,/^set -u$/ { /^set -u$/d; s/^# \{0,1\}//; p; }' "$0" } case "${1:-}" in @@ -85,6 +74,11 @@ case "${1:-}" in exit 0 ;; esac + +# shellcheck source=bin/fm-pr-lib.sh +. "$SCRIPT_DIR/fm-pr-lib.sh" +# shellcheck source=bin/fm-gate-refuse-lib.sh +. "$SCRIPT_DIR/fm-gate-refuse-lib.sh" if [ "$#" -ne 1 ] || ! fm_task_id_path_safe "$1"; then echo "error: usage: fm-herdr-legacy-repair.sh " >&2 exit 2 @@ -98,7 +92,20 @@ refuse() { fm_refuse_if_gate_agent +[ ! -e "$FM_HOME/.fm-secondmate-home" ] && [ ! -L "$FM_HOME/.fm-secondmate-home" ] \ + || refuse "this is a secondmate home; the legacy repair path covers only primary-home records." +[ -d "$STATE" ] \ + || refuse "state directory $STATE does not exist; nothing to repair." META="$STATE/$ID.meta" +[ -f "$META" ] || refuse "task $ID has no metadata at $META; nothing to repair." + +# shellcheck source=bin/fm-wake-lib.sh +. "$SCRIPT_DIR/fm-wake-lib.sh" +# shellcheck source=bin/fm-backend.sh +. "$SCRIPT_DIR/fm-backend.sh" +# shellcheck source=bin/fm-landed-lib.sh +. "$SCRIPT_DIR/fm-landed-lib.sh" + CONTROL_LOCK="$STATE/.control-$ID.lock" CONTROL_LOCK_HELD=0 META_LOCK= @@ -120,14 +127,10 @@ repair_cleanup() { trap repair_cleanup EXIT trap 'exit 1' HUP INT TERM -[ ! -e "$FM_HOME/.fm-secondmate-home" ] \ - || refuse "this is a secondmate home; the legacy repair path covers only primary-home records." - fm_lock_try_acquire "$CONTROL_LOCK" \ || refuse "another lifecycle action is already running for task $ID; nothing was changed." CONTROL_LOCK_HELD=1 -[ -f "$META" ] || refuse "task $ID has no metadata at $META; nothing to repair." META_LOCK=$(fm_meta_lock_path "$META") || refuse "task $ID has no resolvable metadata lock." fm_lock_acquire_wait "$META_LOCK" META_LOCK_HELD=1 @@ -274,6 +277,9 @@ case "$PRESENCE" in present) PANE_INFO=$(fm_backend_herdr_cli "$SESSION" pane get "$PANE" 2>/dev/null) \ || refuse "recorded pane $PANE became unreadable in session $SESSION." + LIVE_PANE=$(printf '%s' "$PANE_INFO" | jq -r '.result.pane.pane_id // empty' 2>/dev/null) + [ "$LIVE_PANE" = "$PANE" ] \ + || refuse "live pane identity ${LIVE_PANE:-} is not the recorded pane $PANE; the record does not match the live endpoint." LIVE_TAB=$(printf '%s' "$PANE_INFO" | jq -r '.result.pane.tab_id // empty' 2>/dev/null) [ "$LIVE_TAB" = "$TAB" ] \ || refuse "live pane $PANE sits in tab ${LIVE_TAB:-}, not the recorded tab $TAB; the record does not match the live endpoint." diff --git a/tests/fm-herdr-legacy-repair.test.sh b/tests/fm-herdr-legacy-repair.test.sh index bd6b920026b..207435c75d5 100755 --- a/tests/fm-herdr-legacy-repair.test.sh +++ b/tests/fm-herdr-legacy-repair.test.sh @@ -105,6 +105,28 @@ assert_refused_unchanged() { # assert_no_lifecycle_mutation "$dir" "$description" } +test_help_and_missing_state_preserve_home() { + local dir="$TMP_ROOT/no-state" rc=0 + mkdir -p "$dir/home" + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" \ + "$REPAIR" --help > "$dir/help.out" 2> "$dir/help.err" || rc=$? + expect_code 0 "$rc" "help should succeed" + assert_grep "Usage: fm-herdr-legacy-repair.sh " "$dir/help.out" \ + "help should expose usage" + assert_grep "Independent evidence" "$dir/help.out" \ + "help should expose evidence mechanics" + assert_grep "atomic" "$dir/help.out" \ + "help should expose mutation and locking mechanics" + [ ! -e "$dir/home/state" ] || fail "help created a state directory" + + rc=0 + run_repair "$dir" ghost-task || rc=$? + [ "$rc" -ne 0 ] || fail "missing state should refuse" + assert_grep "state directory" "$dir/stderr" "missing state diagnostic" + [ ! -e "$dir/home/state" ] || fail "missing-state refusal created durable state" + pass "help exposes mechanics and preflight refusals preserve a missing state directory" +} + pane_present_json() { # printf '{"result":{"pane":{"pane_id":"%s","tab_id":"%s","foreground_cwd":"%s"}}}' \ "$PANEID" "$1" "$2" @@ -317,6 +339,12 @@ test_scope_refusals() { write_legacy_meta "$dir" home-task printf 'sm\n' > "$dir/home/.fm-secondmate-home" assert_refused_unchanged "$dir" home-task "secondmate home" "secondmate home" + + dir=$(make_case dangling-secondmate-home) + write_legacy_meta "$dir" dangling-home-task + ln -s "$dir/missing-secondmate-marker-target" "$dir/home/.fm-secondmate-home" + assert_refused_unchanged "$dir" dangling-home-task "secondmate home" \ + "dangling secondmate home marker" pass "tmux, other opaque backends, secondmates, and remote routes refuse by name" } @@ -350,6 +378,14 @@ test_worktree_project_mismatches_refuse() { test_live_topology_mismatches_refuse() { local dir + dir=$(make_case live-pane-mismatch) + write_legacy_meta "$dir" pm-task + FAKE_PANE_GET="{\"result\":{\"pane\":{\"pane_id\":\"wG:p99\",\"tab_id\":\"$TABID\",\"foreground_cwd\":\"$dir/worktree\"}}}" \ + FAKE_AGENT_GET='{"result":{"agent":{"agent":"claude","agent_status":"idle"}}}' \ + FAKE_TAB_LIST="{\"result\":{\"tabs\":[{\"tab_id\":\"$TABID\",\"label\":\"fm-pm-task\"}]}}" \ + assert_refused_unchanged "$dir" pm-task "unreadable or the session is unreachable" \ + "live pane mismatch" + dir=$(make_case live-tab-mismatch) write_legacy_meta "$dir" tm-task FAKE_PANE_GET=$(pane_present_json "wG:t99" "$dir/worktree") \ @@ -463,6 +499,7 @@ test_lock_contention_refuses() { pass "a concurrent lifecycle action refuses the repair before any change" } +test_help_and_missing_state_preserve_home test_modern_bound_record_untouched test_legacy_landed_gone_pane_repaired_once_and_idempotent test_legacy_landed_idle_agent_and_husk_repair From 166bb99f35599d3577955b7109495fd2d7985b13 Mon Sep 17 00:00:00 2001 From: Rene Garza Jr Date: Fri, 14 Aug 2026 18:47:37 -0500 Subject: [PATCH 6/7] no-mistakes(review): Reject malformed legacy Herdr agent identities --- bin/fm-herdr-legacy-repair.sh | 12 +++++++++++- tests/fm-herdr-legacy-repair.test.sh | 10 +++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/bin/fm-herdr-legacy-repair.sh b/bin/fm-herdr-legacy-repair.sh index e8bbf05a532..fbf5cb7beaf 100755 --- a/bin/fm-herdr-legacy-repair.sh +++ b/bin/fm-herdr-legacy-repair.sh @@ -299,7 +299,17 @@ case "$PRESENCE" in live) AGENT_RAW=$(fm_backend_herdr_agent_identity_raw "$SESSION" "$PANE") \ || refuse "registered agent state for pane $PANE became unreadable." - AGENT_STATUS=${AGENT_RAW#*$'\t'} + case "$AGENT_RAW" in + *$'\t'*) + AGENT_NAME=${AGENT_RAW%%$'\t'*} + AGENT_STATUS=${AGENT_RAW#*$'\t'} + ;; + *) + refuse "the recorded endpoint's registered agent identity is unreadable; preserved for inspection." + ;; + esac + [ -n "$AGENT_NAME" ] \ + || refuse "the recorded endpoint's registered agent identity is unreadable; preserved for inspection." case "$AGENT_STATUS" in idle|done) ENDPOINT_EVIDENCE="finished agent ($AGENT_STATUS) at the recorded endpoint" diff --git a/tests/fm-herdr-legacy-repair.test.sh b/tests/fm-herdr-legacy-repair.test.sh index 207435c75d5..4aa35f984bf 100755 --- a/tests/fm-herdr-legacy-repair.test.sh +++ b/tests/fm-herdr-legacy-repair.test.sh @@ -214,7 +214,15 @@ test_active_and_nonterminal_agents_refuse() { FAKE_AGENT_GET='not json at all' \ FAKE_TAB_LIST="{\"result\":{\"tabs\":[{\"tab_id\":\"$TABID\",\"label\":\"fm-active-task\"}]}}" \ assert_refused_unchanged "$dir" active-task "agent state" "unreadable agent" - pass "working, blocked, and unreadable agents refuse and preserve everything" + + dir=$(make_case agent-missing-identity) + write_legacy_meta "$dir" missing-agent-task + FAKE_PANE_GET=$(pane_present_json "$TABID" "$dir/worktree") \ + FAKE_AGENT_GET='{"result":{"agent":{"agent_status":"idle"}}}' \ + FAKE_TAB_LIST="{\"result\":{\"tabs\":[{\"tab_id\":\"$TABID\",\"label\":\"fm-missing-agent-task\"}]}}" \ + assert_refused_unchanged "$dir" missing-agent-task "agent identity is unreadable" \ + "missing agent identity" + pass "working, blocked, and malformed agents refuse and preserve everything" } test_dirty_and_unlanded_work_refuse() { From c3c62e6e6de86f70f601c9a7802f2bd84f4f708f Mon Sep 17 00:00:00 2001 From: Rene Garza Jr Date: Fri, 14 Aug 2026 18:53:56 -0500 Subject: [PATCH 7/7] no-mistakes(document): Clarify legacy Herdr repair documentation --- bin/fm-herdr-legacy-repair.sh | 6 +++--- docs/herdr-backend.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bin/fm-herdr-legacy-repair.sh b/bin/fm-herdr-legacy-repair.sh index fbf5cb7beaf..abc5530df0e 100755 --- a/bin/fm-herdr-legacy-repair.sh +++ b/bin/fm-herdr-legacy-repair.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash # bin/fm-herdr-legacy-repair.sh - the one guarded repair path for a legacy # primary-home Herdr task record that predates endpoint_task_id= and therefore -# can never pass fm_backend_validate_task_endpoint (bin/fm-backend.sh), so -# ordinary teardown and control refuse it forever while the watcher keeps -# alerting on its finished endpoint. +# cannot pass fm_backend_validate_task_endpoint (bin/fm-backend.sh) until +# repaired, so ordinary teardown and control refuse it and a finished endpoint +# can keep producing watcher alerts. # # The repair BINDS: after every independent evidence check below agrees, it # rewrites the task's metadata with the single missing endpoint_task_id= diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index bb9ab45ee41..6ef0e7ff39e 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -202,7 +202,7 @@ Workspace and tab ids support verification and cleanup but are not inferred from ## Legacy record repair -A Herdr task record written before `endpoint_task_id=` existed can never pass the shared endpoint-identity validation, so teardown and control refuse it forever while the watcher keeps alerting on its finished endpoint. +A Herdr task record written before `endpoint_task_id=` existed cannot pass the shared endpoint-identity validation until repaired, so teardown and control refuse it and a finished endpoint can keep producing watcher alerts. `bin/fm-herdr-legacy-repair.sh ` is the one guarded path that restores that binding, and only for a legacy primary-home Herdr record whose independent evidence all agrees; its header and `--help` own the exact evidence conjunction, refusal behavior, and idempotence contract. It binds only - it never closes a pane, deletes a record, or issues a mutating Herdr command - and cleanup afterwards remains ordinary `bin/fm-teardown.sh`, which re-runs its own complete landed-work and confirmed-close safety against the repaired record. Ordinary cleanup and control keep refusing unbound records implicitly; nothing repairs a binding as a side effect.