From 3607217ddd0f02f0b8ee1dc1831b8105fb8e327c Mon Sep 17 00:00:00 2001 From: Tomas Meulenberg Date: Tue, 8 Sep 2026 03:58:26 +0200 Subject: [PATCH 1/3] fix(teardown): refuse to return a worktree another task still records A pool path outlives the record that named it. On 2026-09-08 a stale task record still carried worktree= after the pool had handed that exact path to a live task; tearing the stale record down ran the pool return on that path, reset it to the default branch, and killed the live agent. Add a metadata-only preflight that runs before any pool return, branch delete, worktree reset, process reap, bridge sweep, or run abort: if any other task record in the same FM_HOME carries an identical worktree= value, teardown refuses, names the other task and the path, changes nothing, and prints the records-only alternative. --force does not bypass it, because --force authorizes discarding this task's own work, not another task's. Add --release-shared-record for exactly that stale-record case: it retires only the calling task's own records (endpoint, meta, status presentation, steering inbox, check and PR poll artifacts, busy state, per-task temp root, backlog close) and touches the shared worktree in no way at all. It is accepted only while another record still names the worktree, never for kind=secondmate, and is mutually exclusive with --force. Every existing refusal (dirty worktree, unlanded work, scout report, public follow-up, --force semantics) is unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RwpKERxmGp8xMZ9A3tqZwt --- AGENTS.md | 1 + bin/fm-teardown.sh | 109 +++++++++++++++++++++++++--- docs/architecture.md | 3 +- tests/fm-teardown.test.sh | 147 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 248 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3182154288e..ce740614d53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -376,6 +376,7 @@ Retire a custom check only through `bin/fm-check-unregister.sh ` (or `bin/fm Tear down a ship task only after landing is confirmed. A teardown refusal for uncommitted or unlanded work is a stop-and-investigate result, never an obstacle to bypass. +A teardown refusal naming another task that records the same local copy means the record is stale while that copy is live, so release only this task's own records through the flag the refusal prints instead of forcing. Never force teardown without explicit discard authority. After successful teardown, record completion, retain only the configured recent Done history, and re-evaluate queued work whose blockers and time gates have cleared. diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 5331b19aac4..320c823bdf9 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -73,10 +73,24 @@ # releases its durable treehouse lease so the pool slot is freed, # never left leased forever. If the treehouse return fails, teardown leaves the # leased home and state in place instead of hiding a still-held lease. -# Usage: fm-teardown.sh [--force] +# REFUSES, before any pool return or directory removal, when another task record +# in the same FM_HOME carries an identical worktree= value: a pool path outlives +# the record that named it, so a stale record's teardown would return, reset, and +# reap the LIVE task's isolated copy. The refusal names the other task and the +# path, changes nothing, and is not bypassed by --force, which authorizes +# discarding this task's own work rather than another task's. +# Usage: fm-teardown.sh [--force | --release-shared-record] # --force skips ordinary-task dirty and landed-work checks, skips scout report # checks, and discards secondmate child work for kind=secondmate. Only use it # when the captain has explicitly said to discard the work. +# --release-shared-record is the answer to that shared-worktree refusal, and is +# accepted ONLY while another task record still names this task's worktree (and +# never for kind=secondmate). It retires this task's own records - endpoint, +# meta, status presentation, steering inbox, checks and PR poll artifacts, busy +# state, per-task temp root, and the backlog close - and touches the shared +# worktree in no way at all: no landed-work inspection, no no-mistakes run +# abort, no browser bridge sweep, no process reap, no branch delete, and no +# pool return. It is mutually exclusive with --force. # # Transient / stale worktree git lock recovery (teardown-lock-race): a crew process # killed mid-git-operation can leave a .git/worktrees//index.lock (or, for a @@ -197,7 +211,21 @@ if [ "$#" -lt 1 ] || ! fm_task_id_path_safe "$1"; then exit 2 fi ID=$1 -FORCE=${2:-} +shift +FORCE= +RELEASE_SHARED_RECORD=0 +while [ "$#" -gt 0 ]; do + case $1 in + --force) FORCE=--force ;; + --release-shared-record) RELEASE_SHARED_RECORD=1 ;; + *) echo "error: invalid teardown option: $1" >&2; exit 2 ;; + esac + shift +done +if [ "$FORCE" = --force ] && [ "$RELEASE_SHARED_RECORD" = 1 ]; then + echo "error: --force and --release-shared-record are mutually exclusive; the record release never discards worktree work" >&2 + exit 2 +fi fm_backlog_directory_present "$STATE" "state directory" || { echo "error: teardown refused: $FM_BACKLOG_TRANSITION_ERROR" >&2 exit 1 @@ -284,6 +312,52 @@ fm_backlog_record_present "$META" "task record" "$STATE" || { } TEARDOWN_META_KIND=$(fm_meta_get "$META" kind) [ -n "$TEARDOWN_META_KIND" ] || TEARDOWN_META_KIND=ship +# Shared-worktree preflight. A pool path outlives the record that named it: a +# stale record can still carry worktree= after the pool handed that exact +# path to a LIVE task (observed 2026-09-08, when the stale record's return reset +# the path to its default branch and killed the live task's agent). Every +# destructive step below - the pool return, the branch delete, the worktree +# process reap, the browser bridge sweep, the run abort - is aimed at that +# recorded path, so this metadata-only scan runs before any of them and before +# the remote path, and refuses when another task record in this home names the +# identical worktree= value. Comparison is literal: fm-spawn records one +# resolved path per task, so an identical string is the collision, and a +# differing string is a different slot. +teardown_shared_worktree_sibling() { # ; prints the first other task id + local wt=$1 meta base other + [ -n "$wt" ] || return 1 + for meta in "$STATE"/*.meta; do + [ -e "$meta" ] || continue + base=${meta##*/} + other=${base%.meta} + [ "$other" != "$ID" ] || continue + [ "$(fm_meta_get "$meta" worktree)" = "$wt" ] || continue + printf '%s\n' "$other" + return 0 + done + return 1 +} +TEARDOWN_META_WORKTREE=$(fm_meta_get "$META" worktree) +TEARDOWN_SHARED_WORKTREE_SIBLING= +if TEARDOWN_SHARED_WORKTREE_SIBLING_FOUND=$(teardown_shared_worktree_sibling "$TEARDOWN_META_WORKTREE"); then + TEARDOWN_SHARED_WORKTREE_SIBLING=$TEARDOWN_SHARED_WORKTREE_SIBLING_FOUND +fi +if [ "$RELEASE_SHARED_RECORD" = 1 ] && [ "$TEARDOWN_META_KIND" = secondmate ]; then + echo "REFUSED: --release-shared-record does not apply to secondmate $ID; a secondmate home is retired whole." >&2 + exit 1 +fi +if [ -n "$TEARDOWN_SHARED_WORKTREE_SIBLING" ] && [ "$RELEASE_SHARED_RECORD" != 1 ]; then + echo "REFUSED: task $ID records worktree $TEARDOWN_META_WORKTREE, which task $TEARDOWN_SHARED_WORKTREE_SIBLING also records." >&2 + echo "Returning or resetting that path would destroy the other task's work, so nothing was changed; --force does not authorize it either." >&2 + echo "If this record is the stale one, release only its own records with: bin/fm-teardown.sh $ID --release-shared-record" >&2 + echo "That closes this task's backlog item and removes its own state files, leaving the shared local copy to $TEARDOWN_SHARED_WORKTREE_SIBLING." >&2 + exit 1 +fi +if [ "$RELEASE_SHARED_RECORD" = 1 ] && [ -z "$TEARDOWN_SHARED_WORKTREE_SIBLING" ]; then + echo "REFUSED: --release-shared-record applies only while another task record names this task's worktree." >&2 + echo "No other task record names ${TEARDOWN_META_WORKTREE:-}; run an ordinary teardown so the isolated copy is returned too." >&2 + exit 1 +fi TEARDOWN_CLEANUP_RECOVERY=$(fm_meta_get "$META" cleanup_recovery) TEARDOWN_META_SPAWN_GEN= TEARDOWN_BACKLOG_APPLIES=0 @@ -2727,7 +2801,8 @@ if [ -n "$X_REQUEST" ]; then echo "warning: task $ID still carries an unreconciled Relay request link ($X_REQUEST) on its task record." >&2 fi -if [ "$BACKEND" = orca ] && [ "$KIND" != scout ] && [ "$KIND" != secondmate ] && [ "$FORCE" != "--force" ]; then +if [ "$BACKEND" = orca ] && [ "$KIND" != scout ] && [ "$KIND" != secondmate ] \ + && [ "$FORCE" != "--force" ] && [ "$RELEASE_SHARED_RECORD" != 1 ]; then if ! inspectable_git_worktree "$WT"; then echo "REFUSED: Orca ship task $ID has no inspectable git worktree at ${WT:-}." >&2 echo "Cannot verify dirty or unlanded work; restore the worktree path or get explicit OK to discard, then --force." >&2 @@ -2737,7 +2812,9 @@ if [ "$BACKEND" = orca ] && [ "$KIND" != scout ] && [ "$KIND" != secondmate ] && ORCA_PATH_MATCH_VERIFIED=1 fi -if [ -d "$WT" ] && [ "$FORCE" != "--force" ]; then +# The record release leaves the isolated copy alone, so its contents are the +# LIVE task's business, not this stale record's landed-work question. +if [ -d "$WT" ] && [ "$FORCE" != "--force" ] && [ "$RELEASE_SHARED_RECORD" != 1 ]; then if validate_worktree_teardown_safety; then : else @@ -2756,7 +2833,7 @@ fi # ordering, and it leaves the destructive phase with one recorded response # sequence even when the terminal close itself is best-effort. if [ "$BACKEND" = orca ] && [ "$KIND" != secondmate ] && [ -e "$WT" ] \ - && [ "$ORCA_PATH_MATCH_VERIFIED" != 1 ]; then + && [ "$RELEASE_SHARED_RECORD" != 1 ] && [ "$ORCA_PATH_MATCH_VERIFIED" != 1 ]; then require_orca_worktree_path_match "$ORCA_WORKTREE_ID" "$WT" || exit 1 ORCA_PATH_MATCH_VERIFIED=1 fi @@ -2826,7 +2903,11 @@ if [ "$BACKEND" = herdr ] \ fi if [ "$KIND" != secondmate ]; then - conclude_task_no_mistakes_run "$WT" + # A record release never reaches into the isolated copy: its parked run, + # browser bridge, and processes belong to the task that holds that path now. + if [ "$RELEASE_SHARED_RECORD" != 1 ]; then + conclude_task_no_mistakes_run "$WT" + fi BACKEND_STOPPED=0 if [ "$BACKEND" = herdr ] && [ "$HERDR_PRESENTATION_RETIRE_CANDIDATE" = 1 ]; then if teardown_herdr_session_lock_held "$HERDR_PRESENTATION_SESSION" \ @@ -2861,7 +2942,9 @@ if [ "$KIND" != secondmate ]; then && backend_endpoint_shutdown_confirmed; then BACKEND_STOPPED=1 fi - if [ "$BACKEND_STOPPED" -eq 1 ] && worktree_owned_by_task; then + if [ "$RELEASE_SHARED_RECORD" = 1 ]; then + echo "note: leaving the shared local copy $WT untouched for $ID; skipping its browser bridge sweep and process reap" >&2 + elif [ "$BACKEND_STOPPED" -eq 1 ] && worktree_owned_by_task; then "$SCRIPT_DIR/fm-chrome-bridge-sweep.sh" --apply --worktree "$WT" >&2 || \ echo "warning: browser bridge inspection failed for $ID; no unverified bridge was signaled" >&2 elif [ "$BACKEND_STOPPED" -ne 1 ]; then @@ -2869,7 +2952,7 @@ if [ "$KIND" != secondmate ]; then else echo "warning: $BACKEND browser bridge sweep skipped for $ID; current worktree ownership was not confirmed" >&2 fi - if [ "$BACKEND_STOPPED" -eq 1 ]; then + if [ "$BACKEND_STOPPED" -eq 1 ] && [ "$RELEASE_SHARED_RECORD" != 1 ]; then reap_task_worktree_processes worktree "$WT" "$TASK_TMP" fi else @@ -2899,7 +2982,7 @@ fi "$SCRIPT_DIR/fm-remote-job-reap-orphans.sh" >&2 || true # Best-effort: drop the local task branch so the shared repo does not accumulate refs. -if [ "$BACKEND" = orca ] && [ "$KIND" != secondmate ]; then +if [ "$BACKEND" = orca ] && [ "$KIND" != secondmate ] && [ "$RELEASE_SHARED_RECORD" != 1 ]; then if [ "$ORCA_PATH_MATCH_VERIFIED" != 1 ]; then require_orca_worktree_path_match_if_present "$ORCA_WORKTREE_ID" "$WT" || exit 1 ORCA_PATH_MATCH_VERIFIED=1 @@ -2916,7 +2999,7 @@ if [ "$BACKEND" = orca ] && [ "$KIND" != secondmate ]; then "$WT/.fm-grok-turnend" "$WT/.fm-kimi-turnend" fi fm_backend_remove_worktree "$BACKEND" "$ORCA_WORKTREE_ID" -elif [ -d "$WT" ] && [ "$KIND" != secondmate ]; then +elif [ -d "$WT" ] && [ "$KIND" != secondmate ] && [ "$RELEASE_SHARED_RECORD" != 1 ]; then branch=$(git -C "$WT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD) if [ "$branch" != "HEAD" ]; then if git -C "$WT" checkout --detach -q 2>/dev/null; then @@ -3038,5 +3121,9 @@ fi if [ -d "$STATE" ]; then "$SCRIPT_DIR/fm-home-summary-refresh.sh" --best-effort || true fi -echo "teardown $ID complete (window $T, worktree $WT)" +if [ "$RELEASE_SHARED_RECORD" = 1 ]; then + echo "teardown $ID complete: records only (window $T); local copy $WT left in place for task $TEARDOWN_SHARED_WORKTREE_SIBLING" +else + echo "teardown $ID complete (window $T, worktree $WT)" +fi backlog_refresh_reminder diff --git a/docs/architecture.md b/docs/architecture.md index 6c6480e9fa7..93bec0c48e8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -304,7 +304,8 @@ Every GitHub refusal states what it could not observe as plainly as what it did, A confirmed merge leaves a durable role-routed outcome instead of living only in the merging agent's memory, and [`bin/fm-merge-outcome-lib.sh`](../bin/fm-merge-outcome-lib.sh)'s header owns its destination, shape, identity, normal-case deduplication, and at-least-once recovery. The same emitter handles a merge firstmate performed and one its poll detected, while the watcher immediately delivers the emitter's local actionable poll row. Teardown is fail-closed for ship worktrees: dirty worktrees refuse, and committed work must be landed before the worktree is returned. -[`bin/fm-teardown.sh`](../bin/fm-teardown.sh)'s header owns the landed-work proofs, PR-discovery fallback, and stale-lock recovery procedure. +A worktree path a second task record still names refuses too, before any return or removal, because a pool path outlives the record that named it and the live task owns that copy; `--release-shared-record` retires only the stale record's own task state in that exact case. +[`bin/fm-teardown.sh`](../bin/fm-teardown.sh)'s header owns the landed-work proofs, PR-discovery fallback, shared-worktree refusal, and stale-lock recovery procedure. ## Optional Relay diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index 83e2352fb24..29039e1b64f 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -49,6 +49,14 @@ # (w) index.lock mtime read failure -> lock kept, REFUSE # (x) transient lock cleared after first failed return -> retry ALLOW # (y) persistent lock (never clears, not provably stale) -> REFUSE loudly +# +# Also covers the shared-worktree preflight: a pool path outlives the record that +# named it, so a stale record must never return, reset, or reap a worktree a live +# task still records (bin/fm-teardown.sh's teardown_shared_worktree_sibling). +# (z1) another task record names the same worktree -> REFUSE, nothing changed +# (z2) same, plus --force -> REFUSE (force is not authority) +# (z3) same, plus --release-shared-record -> records only, worktree intact +# (z4) --release-shared-record with no sharing record -> REFUSE (wrong tool) set -u # shellcheck source=tests/lib.sh disable=SC1091 @@ -2651,6 +2659,141 @@ EOF pass "the run abort and the leaked-process reap both complete before the destructive worktree return" } +# --- shared-worktree preflight ------------------------------------------------- +# A treehouse mock that records every invocation, so a test can prove the pool +# return never ran rather than only that the directory survived. +add_logging_treehouse() { + local case_dir=$1 + cat > "$case_dir/fakebin/treehouse" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "${TREEHOUSE_CALL_LOG:?}" +exit 0 +SH + chmod +x "$case_dir/fakebin/treehouse" +} + +# Write a second live task record pointing at the SAME worktree path, which is +# what a pool handoff to a new task leaves behind next to a stale record. +write_sibling_meta_sharing_worktree() { + local case_dir=$1 + fm_write_meta "$case_dir/state/task-x2.meta" \ + "window=firstmate:fm-task-x2" \ + "endpoint_task_id=task-x2" \ + "worktree=$case_dir/wt" \ + "project=$case_dir/project" \ + "kind=ship" \ + "mode=no-mistakes" \ + "spawn_gen=teardown-test-task-x2" +} + +test_shared_worktree_refuses_and_changes_nothing() { + local case_dir rc head + case_dir=$(make_case shared-worktree-refuse) + write_meta "$case_dir" no-mistakes ship + land_shippable_commit "$case_dir" + write_sibling_meta_sharing_worktree "$case_dir" + seed_backlog_in_flight "$case_dir" + add_logging_treehouse "$case_dir" + head=$(git -C "$case_dir/wt" rev-parse HEAD) + + rc=0 + TREEHOUSE_CALL_LOG="$case_dir/treehouse.log" \ + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$? + + expect_code 1 "$rc" "shared-worktree: teardown should refuse" + assert_grep REFUSED "$case_dir/stderr" "shared-worktree: no REFUSED line in stderr" + assert_grep task-x2 "$case_dir/stderr" "shared-worktree: refusal did not name the other task" + assert_grep "$case_dir/wt" "$case_dir/stderr" "shared-worktree: refusal did not name the shared path" + assert_grep "release-shared-record" "$case_dir/stderr" \ + "shared-worktree: refusal did not print the records-only alternative" + assert_absent "$case_dir/treehouse.log" "shared-worktree: the pool return ran despite the refusal" + [ -e "$case_dir/state/task-x1.meta" ] \ + || fail "shared-worktree: the refused teardown removed its own task record" + [ -e "$case_dir/state/task-x2.meta" ] \ + || fail "shared-worktree: the refused teardown removed the other task's record" + [ "$(git -C "$case_dir/wt" rev-parse HEAD)" = "$head" ] \ + || fail "shared-worktree: the shared worktree HEAD moved during a refused teardown" + [ "$(backlog_row_state "$case_dir")" = in_flight ] \ + || fail "shared-worktree: a refused teardown closed the backlog item anyway" + pass "teardown refuses a worktree another task record still names, and changes nothing" +} + +test_shared_worktree_refusal_survives_force() { + local case_dir rc + case_dir=$(make_case shared-worktree-force) + write_meta "$case_dir" no-mistakes ship + wt_commit "$case_dir" "unpushed work" + write_sibling_meta_sharing_worktree "$case_dir" + add_logging_treehouse "$case_dir" + + rc=0 + TREEHOUSE_CALL_LOG="$case_dir/treehouse.log" \ + run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$? + + expect_code 1 "$rc" "shared-worktree-force: --force should not bypass the shared-worktree refusal" + assert_grep REFUSED "$case_dir/stderr" "shared-worktree-force: no REFUSED line in stderr" + assert_absent "$case_dir/treehouse.log" "shared-worktree-force: --force still returned the shared worktree" + [ -e "$case_dir/state/task-x1.meta" ] \ + || fail "shared-worktree-force: records were removed despite the refusal" + pass "--force authorizes discarding this task's work, never another task's shared worktree" +} + +test_release_shared_record_retires_records_only() { + local case_dir rc head out + case_dir=$(make_case shared-worktree-release) + write_meta "$case_dir" no-mistakes ship + printf '%s\n' 'pr=https://github.com/example/repo/pull/7' >> "$case_dir/state/task-x1.meta" + wt_commit "$case_dir" "the live task's unlanded work" + printf '%s\n' "live edit" > "$case_dir/wt/live-file.txt" + write_sibling_meta_sharing_worktree "$case_dir" + seed_backlog_in_flight "$case_dir" + add_logging_treehouse "$case_dir" + mkdir -p "$case_dir/state/task-x1.inbox" + : > "$case_dir/state/task-x1.inbox/001.msg" + head=$(git -C "$case_dir/wt" rev-parse HEAD) + + rc=0 + out=$(TREEHOUSE_CALL_LOG="$case_dir/treehouse.log" \ + run_teardown "$case_dir" --release-shared-record 2> "$case_dir/stderr") || rc=$? + + expect_code 0 "$rc" "release-shared-record: records-only teardown should succeed: $(cat "$case_dir/stderr")" + assert_absent "$case_dir/treehouse.log" "release-shared-record: the pool return ran anyway" + assert_absent "$case_dir/state/task-x1.meta" "release-shared-record: the stale task record survived" + assert_absent "$case_dir/state/task-x1.inbox" "release-shared-record: the steering inbox survived" + [ -e "$case_dir/state/task-x2.meta" ] \ + || fail "release-shared-record: the live task's record was removed" + [ -d "$case_dir/wt" ] || fail "release-shared-record: the shared worktree was removed" + [ "$(git -C "$case_dir/wt" rev-parse HEAD)" = "$head" ] \ + || fail "release-shared-record: the shared worktree was reset" + [ -f "$case_dir/wt/live-file.txt" ] \ + || fail "release-shared-record: the live task's uncommitted file was discarded" + [ "$(backlog_row_state "$case_dir")" = "done" ] \ + || fail "release-shared-record: the backlog item was not closed: $(backlog_row_state "$case_dir")" + printf '%s\n' "$out" | grep -F "left in place" >/dev/null \ + || fail "release-shared-record: the outcome line did not say the local copy was kept: $out" + pass "--release-shared-record retires only the stale record and leaves the shared worktree intact" +} + +test_release_shared_record_refuses_without_a_sharing_record() { + local case_dir rc + case_dir=$(make_case shared-worktree-release-unshared) + write_meta "$case_dir" no-mistakes ship + land_shippable_commit "$case_dir" + add_logging_treehouse "$case_dir" + + rc=0 + TREEHOUSE_CALL_LOG="$case_dir/treehouse.log" \ + run_teardown "$case_dir" --release-shared-record > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$? + + expect_code 1 "$rc" "release-unshared: the flag should refuse when no other record shares the worktree" + assert_grep REFUSED "$case_dir/stderr" "release-unshared: no REFUSED line in stderr" + assert_absent "$case_dir/treehouse.log" "release-unshared: a refused run still returned the worktree" + [ -e "$case_dir/state/task-x1.meta" ] \ + || fail "release-unshared: a refused run removed the task record" + pass "--release-shared-record refuses outside the shared-worktree case it exists for" +} + + test_local_only_fork_remote_allows test_teardown_retires_its_browser_bridge_family test_teardown_closes_the_backlog_item_itself @@ -2710,3 +2853,7 @@ test_process_spawned_during_grace_is_reaped_on_later_pass test_persistent_scan_refuses_after_bounded_retries test_process_exit_during_identity_lookup_does_not_refuse test_run_abort_precedes_process_reap_precedes_worktree_removal +test_shared_worktree_refuses_and_changes_nothing +test_shared_worktree_refusal_survives_force +test_release_shared_record_retires_records_only +test_release_shared_record_refuses_without_a_sharing_record From 0bb5ecee8c69042561a7630f93ce4435e4c1f798 Mon Sep 17 00:00:00 2001 From: Tomas Meulenberg Date: Tue, 8 Sep 2026 04:10:59 +0200 Subject: [PATCH 2/3] no-mistakes(document): Document shared-worktree teardown guard --- docs/configuration.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/configuration.md b/docs/configuration.md index 360f487cb1b..37f9743e9e0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -140,6 +140,7 @@ A metadata-routed selector returns the recorded backend target (`terminal=` for Only metadata-routed task selectors carry secondmate-marker and Codex-harness context; explicit endpoint escape hatches do not. These five sentences are the single owner of the task-selector vocabulary; backend guides and other documents point here instead of restating the resolution order. `fm-teardown.sh ` takes a task id directly and validates the complete metadata-only endpoint identity before any runtime dispatch or cleanup mutation. +If another task record in the same home names the identical `worktree=` path, teardown refuses before touching that path, including with `--force`; use `--release-shared-record` to close only the stale task's own records and backlog item while leaving the shared copy untouched. 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. `FM_HOME` determines Herdr's home label: the primary home uses `firstmate`, and a secondmate home marked by `.fm-secondmate-home` uses `2ndmate-`. From 31b8464ea790c7aae287645b922f9ed9d7c80143 Mon Sep 17 00:00:00 2001 From: Tomas Meulenberg Date: Tue, 8 Sep 2026 04:33:51 +0200 Subject: [PATCH 3/3] no-mistakes(ci): Updated the Herdr presentation E2E teardown helper to use --release-shared-record when the new shared-worktree guard refuses forced teardown. Syntax and diff checks pass --- .../fm-backend-herdr-presentation-e2e.test.sh | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/tests/fm-backend-herdr-presentation-e2e.test.sh b/tests/fm-backend-herdr-presentation-e2e.test.sh index c0f4707f3ef..1767b26e9b3 100755 --- a/tests/fm-backend-herdr-presentation-e2e.test.sh +++ b/tests/fm-backend-herdr-presentation-e2e.test.sh @@ -422,11 +422,30 @@ spawn_secondmate_task() { } teardown_task() { # - local id=$1 home=$2 - FM_GATE_REFUSE_BYPASS=1 FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \ - FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ - FM_CONFIG_OVERRIDE="$home/config" \ - "$ROOT/bin/fm-teardown.sh" "$id" --force + local id=$1 home=$2 err rc + err=$(mktemp "$TMP_ROOT/teardown-$id.XXXXXX") || return 1 + if FM_GATE_REFUSE_BYPASS=1 FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \ + FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_CONFIG_OVERRIDE="$home/config" \ + "$ROOT/bin/fm-teardown.sh" "$id" --force 2>"$err"; then + rm -f "$err" + return 0 + fi + rc=$? + if grep -F "release only its own records with: bin/fm-teardown.sh $id --release-shared-record" "$err" >/dev/null 2>&1; then + FM_GATE_REFUSE_BYPASS=1 FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \ + FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_CONFIG_OVERRIDE="$home/config" \ + "$ROOT/bin/fm-teardown.sh" "$id" --release-shared-record + rc=$? + if [ "$rc" -eq 0 ]; then + rm -f "$err" + return 0 + fi + fi + cat "$err" >&2 + rm -f "$err" + return "$rc" } finish_concurrent_teardown() { #