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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,7 @@ Retire a custom check only through `bin/fm-check-unregister.sh <id>` (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.

Expand Down
109 changes: 98 additions & 11 deletions bin/fm-teardown.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <task-id> [--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 <task-id> [--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/<wt>/index.lock (or, for a
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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=<path> 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() { # <worktree-path>; 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:-<no recorded 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
Expand Down Expand Up @@ -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:-<missing>}." >&2
echo "Cannot verify dirty or unlanded work; restore the worktree path or get explicit OK to discard, then --force." >&2
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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" \
Expand Down Expand Up @@ -2861,15 +2942,17 @@ 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
echo "warning: $BACKEND endpoint shutdown or current worktree ownership was not confirmed; skipping browser bridge sweep for $ID" >&2
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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` 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-<id>`; 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-<secondmate-id>`.
Expand Down
29 changes: 24 additions & 5 deletions tests/fm-backend-herdr-presentation-e2e.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -422,11 +422,30 @@ spawn_secondmate_task() {
}

teardown_task() { # <id> <home>
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() { # <id> <status> <stdout> <stderr>
Expand Down
Loading
Loading