Skip to content
Open
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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -541,7 +541,8 @@ bin/fm-teardown.sh <id>
```

The script refuses if the worktree holds uncommitted changes or committed work that has not landed; treat a refusal as a stop-and-investigate, not an obstacle.
Teardown validates that the recorded project and worktree are exact roots with the expected repository registration, quiesces every ordinary task endpoint, and then runs the final non-destructive safety checks before any Treehouse return.
Teardown normally validates that the recorded project and worktree are exact roots with the expected repository registration, quiesces every ordinary task endpoint, and then runs the final non-destructive safety checks before any Treehouse return.
It can also finish bookkeeping for a provably landed worktree whose Treehouse lease was already cleared; `bin/fm-teardown.sh`'s header owns the fail-closed recovery contract.
For a task whose metadata carries `report_required=1`, teardown also publishes the validated completion report before releasing the account lease or removing the worktree.
A safety refusal after quiescence leaves the endpoint stopped while preserving all task state for repair and retry.
`bin/fm-teardown.sh`'s header owns the full landed-work definition (remote-reachable, merged-PR-head containment for the squash-merge-then-delete-branch flow, content already in the default branch, local-only merges) and the `pr=` discovery fallback for merges that skipped `bin/fm-pr-check.sh`.
Expand Down
11 changes: 11 additions & 0 deletions bin/fm-process-tree-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,17 @@ fm_run_bounded() {
if (!$command) {
close $status_write;
close $finish_read;
# Close inherited fds above stderr so the bounded command starts
# with a clean fd table. The boundary walker in
# fm_checkout_treehouse_return_locked opens one fd per directory
# in the worktree; inherited fds from the parent bash process
# consume headroom and cause EMFILE on large trees.
if (opendir my $devfd, "/dev/fd") {
my @inherited = grep { $_ > 2 }
map { /^(\d+)$/ ? $1 : () } readdir $devfd;
closedir $devfd;
POSIX::close($_) for @inherited;
}
$SIG{HUP} = "DEFAULT";
$SIG{INT} = "DEFAULT";
$SIG{QUIT} = "DEFAULT";
Expand Down
2 changes: 1 addition & 1 deletion bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2701,7 +2701,7 @@ if [ "$KIND" = secondmate ]; then
fi
if [ "$ACCOUNT_EFFECTIVE_MODE" = enforce ]; then
if ! secondmate_home_supports_account_routing "$PROJ_ABS"; then
echo "error: refusing account-routed secondmate launch for $PROJ_ABS: the home lacks Agent Fleet routing support. Fast-forward or otherwise reconcile the home to this Firstmate revision, run bin/fm-config-push.sh, and retry." >&2
echo "error: refusing account-routed secondmate $ID launch for $PROJ_ABS: the home lacks Agent Fleet routing support. Fast-forward or otherwise reconcile the home to this Firstmate revision, run bin/fm-config-push.sh, and retry." >&2
exit 1
fi
elif ! secondmate_home_supports_account_routing "$PROJ_ABS"; then
Expand Down
210 changes: 193 additions & 17 deletions bin/fm-teardown.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@
# Uncommitted changes are never landed.
# Ordinary teardown first proves that metadata names the exact registered project,
# worktree, and task lease, then quiesces the endpoint before its final safety checks.
# If Treehouse already cleared that exact pool entry's lease, teardown treats the
# worktree as a partial or completed external return only when the state has exactly
# one matching entry, leased=false, no lease holder, and destroying is not true.
# It then re-proves landed work under the checkout lock before completing the return
# or cleaning the returned worktree, branch, and task bookkeeping. A missing
# worktree directory is accepted only through the same cleared-lease proof and a
# recovered task branch whose landed state can still be proved; ambiguous lease
# state, branch lookup failure, and unlanded recovered work all retain task state.
# Each locked Treehouse return repeats repository, lease, and landed-work checks
# immediately before the destructive return command.
# local-only projects additionally accept work merged into the local default
Expand Down Expand Up @@ -1034,6 +1042,41 @@ except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError) as error
PY
}

treehouse_lease_is_cleared() {
local worktree=$1 state
state=$(treehouse_state_for_worktree "$worktree") || return 1
python3 - "$state" "$(cd "$worktree" 2>/dev/null && pwd -P || printf '%s' "$worktree")" <<'PY'
import json
import os
import sys

state_path, expected_path = sys.argv[1:]
try:
with open(state_path, encoding="utf-8") as stream:
state = json.load(stream)
worktrees = state.get("worktrees", [])
if not isinstance(worktrees, list):
raise SystemExit(1)
matches = [
e for e in worktrees
if isinstance(e, dict) and isinstance(e.get("path"), str)
and os.path.realpath(e["path"]) == expected_path
]
if len(matches) != 1:
raise SystemExit(1)
entry = matches[0]
if entry.get("leased") is not False:
raise SystemExit(1)
if entry.get("lease_holder") not in ("", None):
raise SystemExit(1)
if entry.get("destroying") is True:
raise SystemExit(1)
raise SystemExit(0)
except (OSError, ValueError, json.JSONDecodeError):
raise SystemExit(1)
PY
}

require_treehouse_return_authority() {
local worktree=$1 project=$2 worktree_root project_root worktree_common project_common
worktree_root=$(exact_git_worktree_root "$worktree") || return 1
Expand All @@ -1059,7 +1102,19 @@ validate_teardown_target_identity() {
echo "error: teardown project metadata is not an exact inspectable repository root: ${PROJ:-<missing>}" >&2
return 1
}
if ! [ -d "$WT" ]; then
if treehouse_lease_is_cleared "$WT"; then
echo "teardown: worktree lease already cleared and directory gone (partial or completed return): $WT" >&2
return "$TEARDOWN_WORKTREE_ALREADY_RETURNED"
fi
echo "error: teardown worktree directory is missing and lease state is indeterminate: ${WT:-<missing>}" >&2
return 1
fi
worktree_root=$(exact_git_worktree_root "$WT") || {
if treehouse_lease_is_cleared "$WT"; then
echo "teardown: worktree lease already cleared (worktree may be a partial-return remnant): $WT" >&2
return "$TEARDOWN_WORKTREE_ALREADY_RETURNED"
fi
echo "error: teardown worktree metadata is not an exact inspectable repository root: ${WT:-<missing>}" >&2
return 1
}
Expand All @@ -1083,7 +1138,14 @@ validate_teardown_target_identity() {
echo "error: teardown worktree is not registered to the recorded project: $worktree_root" >&2
return 1
}
require_treehouse_task_lease "$worktree_root" "firstmate-$ID"
if require_treehouse_task_lease "$worktree_root" "firstmate-$ID"; then
return 0
fi
if treehouse_lease_is_cleared "$worktree_root"; then
echo "teardown: worktree lease already cleared (worktree may have been returned externally): $worktree_root" >&2
return "$TEARDOWN_WORKTREE_ALREADY_RETURNED"
fi
return 1
}

retry_wait_secs_is_valid() {
Expand All @@ -1104,6 +1166,7 @@ fi
STALE_WORKTREE_LOCK_RETRY_WAIT_SECS=$TREEHOUSE_RETURN_LOCK_RETRY_WAIT_SECS
TEARDOWN_TREEHOUSE_LOCK_REFUSED=2
TEARDOWN_WORKTREE_SAFETY_LOCK_BLOCKED=3
TEARDOWN_WORKTREE_ALREADY_RETURNED=4

# True when treehouse/git stderr shows the transient index.lock "File exists" race.
# Other return failures must not enter the retry path.
Expand Down Expand Up @@ -1318,6 +1381,89 @@ cleanup_returned_worktree() {
remove_worktree_compatibility_artifacts "$worktree" "returned worktree"
}

cleanup_recovered_worktree() {
local branch=$1 worktree=$2 project=$3
if [ "$branch" != "HEAD" ]; then
git -C "$project" branch -D "$branch" >/dev/null 2>&1 || {
echo "error: recovered worktree task branch could not be deleted: $branch" >&2
return 1
}
fi
remove_worktree_compatibility_artifacts "$worktree" "returned worktree"
}

cleanup_already_returned_worktree_locked() {
local branch out return_status
treehouse_lease_is_cleared "$WT" || {
echo "error: worktree lease is no longer provably cleared; retaining $WT" >&2
return 1
}
validate_worktree_teardown_safety || return 1
treehouse_lease_is_cleared "$WT" || {
echo "error: worktree lease changed during final safety checks; retaining $WT" >&2
return 1
}
branch=$(git -C "$WT" rev-parse --abbrev-ref HEAD 2>/dev/null) || branch=HEAD
validate_removal_tree_boundaries "$WT" "worktree" || return 1
if out=$(fm_checkout_treehouse_return_locked "$WT" "$CHECKOUT_LOCK_ROOT" "$PROJ" 2>&1); then
[ -n "$out" ] && printf '%s\n' "$out"
else
return_status=$?
[ -n "$out" ] && printf '%s\n' "$out" >&2
echo "error: failed to complete the externally-started Treehouse return for $WT" >&2
return "$return_status"
fi
treehouse_lease_is_cleared "$WT" || {
echo "error: Treehouse return did not leave a provably returned pool slot; retaining task metadata" >&2
return 1
}
cleanup_recovered_worktree "$branch" "$WT" "$PROJ"
}

cleanup_already_returned_worktree_no_directory_locked() {
local branch unpushed_raw unpushed DEFAULT
treehouse_lease_is_cleared "$WT" || {
echo "error: worktree lease is no longer provably cleared after directory-gone detection; retaining metadata" >&2
return 1
}
if ! branch=$(git -C "$PROJ" for-each-ref --format='%(refname:short)' "refs/heads/fm/$ID" 2>/dev/null); then
echo "REFUSED: cannot inspect the recovered task branch for fm/$ID." >&2
return 1
fi
if [ -n "$branch" ]; then
if ! unpushed_raw=$(git -C "$PROJ" log --oneline "$branch" --not --remotes -- 2>/dev/null); then
echo "REFUSED: cannot inspect recovered branch $branch for commits not on a remote." >&2
return 1
fi
unpushed=$(printf '%s\n' "$unpushed_raw" | head -5)
if [ -n "$unpushed" ]; then
if [ "$MODE" = local-only ]; then
DEFAULT=$(default_branch) || {
echo "REFUSED: cannot determine default branch for recovered branch $branch." >&2
return 1
}
git -C "$PROJ" merge-base --is-ancestor "$branch" "$DEFAULT" 2>/dev/null || {
echo "REFUSED: recovered branch $branch has work not on any remote and not merged into $DEFAULT." >&2
printf 'unpushed commits:\n%s\n' "$unpushed" >&2
return 1
}
else
echo "REFUSED: recovered branch $branch has work not on any remote." >&2
printf 'unpushed commits:\n%s\n' "$unpushed" >&2
return 1
fi
fi
treehouse_lease_is_cleared "$WT" || {
echo "error: worktree lease changed during recovered branch safety checks; retaining metadata" >&2
return 1
}
git -C "$PROJ" branch -D "$branch" >/dev/null 2>&1 || {
echo "error: recovered worktree task branch could not be deleted: $branch" >&2
return 1
}
fi
}

validate_worktree_teardown_safety() {
local dirty_raw dirty unpushed_raw unpushed DEFAULT unmerged_raw unmerged branch stash_list
[ -d "$WT" ] || return 0
Expand Down Expand Up @@ -3929,7 +4075,19 @@ if [ "$KIND" = scout ]; then
fi
fi

[ "$KIND" = secondmate ] || validate_teardown_target_identity || exit 1
WORKTREE_ALREADY_RETURNED=0
if [ "$KIND" != secondmate ]; then
if validate_teardown_target_identity; then
:
else
_vtid_rc=$?
if [ "$_vtid_rc" -eq "$TEARDOWN_WORKTREE_ALREADY_RETURNED" ]; then
WORKTREE_ALREADY_RETURNED=1
else
exit 1
fi
fi
fi

PROBE_HOME=
ENDPOINT_HOME=$(fm_backend_endpoint_home "$BACKEND" "$KIND" "$FM_HOME" "$HOME_PATH")
Expand Down Expand Up @@ -4014,14 +4172,26 @@ post_quiescence_safety_refusal() {
echo "The task endpoint has already been shut down; the worktree and task metadata are preserved for a safe retry." >&2
}

validate_teardown_target_identity_or_returned() {
if validate_teardown_target_identity; then
return 0
fi
_vtid_rc=$?
if [ "$_vtid_rc" -eq "$TEARDOWN_WORKTREE_ALREADY_RETURNED" ]; then
WORKTREE_ALREADY_RETURNED=1
return 0
fi
return "$_vtid_rc"
}

if [ "$DIRECT_SPAWN_CLEANUP" = pending ]; then
if [ "$DIRECT_SPAWN_ENDPOINT" != not-created ]; then
quiesce_retained_direct_spawn_endpoint || exit 1
fi
validate_teardown_target_identity || { post_quiescence_safety_refusal; exit 1; }
validate_teardown_target_identity_or_returned || { post_quiescence_safety_refusal; exit 1; }
elif [ "$KIND" != secondmate ]; then
quiesce_task_endpoint || exit 1
validate_teardown_target_identity || { post_quiescence_safety_refusal; exit 1; }
validate_teardown_target_identity_or_returned || { post_quiescence_safety_refusal; exit 1; }
fi

if [ "$BACKEND" = orca ] && [ "$KIND" != secondmate ]; then
Expand Down Expand Up @@ -4068,7 +4238,7 @@ if [ "$KIND" = secondmate ] && [ "$FORCE" = "--force" ]; then
cleanup_firstmate_home_children "$HOME_PATH" || exit 1
fi

[ "$KIND" = secondmate ] || validate_teardown_target_identity || exit 1
[ "$KIND" = secondmate ] || validate_teardown_target_identity_or_returned || exit 1

remove_orca_worktree_locked() {
local branch=HEAD boundary_token
Expand Down Expand Up @@ -4096,19 +4266,25 @@ if [ "$BACKEND" = orca ] && [ "$KIND" != secondmate ]; then
ORCA_PATH_MATCH_VERIFIED=1
fi
fm_checkout_lock_run "$WT" "$CHECKOUT_LOCK_ROOT" remove_orca_worktree_locked || exit 1
elif [ -d "$WT" ] && [ "$KIND" != secondmate ]; then
# Kills remaining processes in the worktree (including the agent), resets, returns
# to pool. treehouse resolves the pool from the working directory, so run it from
# the project. teardown_treehouse_return tolerates transient and stale git locks
# left by a killed crewmate process; see the script header for retry and stale-lock proof.
post_lock_cleanup_check=
if [ "$KIND" != secondmate ]; then
post_lock_cleanup_check=validate_worktree_teardown_safety
elif [ "$KIND" != secondmate ]; then
if [ "$WORKTREE_ALREADY_RETURNED" = 1 ]; then
if [ -d "$WT" ]; then
fm_checkout_lock_run "$WT" "$CHECKOUT_LOCK_ROOT" \
cleanup_already_returned_worktree_locked || exit 1
else
fm_checkout_lock_run "$PROJ" "$CHECKOUT_LOCK_ROOT" \
cleanup_already_returned_worktree_no_directory_locked || exit 1
fi
elif [ -d "$WT" ]; then
post_lock_cleanup_check=
if [ "$KIND" != secondmate ]; then
post_lock_cleanup_check=validate_worktree_teardown_safety
fi
teardown_treehouse_return "$WT" "$PROJ" "worktree" "firstmate-$ID" "$post_lock_cleanup_check" cleanup_returned_worktree || {
echo "error: treehouse return failed for worktree $WT; teardown aborted" >&2
exit 1
}
fi
teardown_treehouse_return "$WT" "$PROJ" "worktree" "firstmate-$ID" "$post_lock_cleanup_check" cleanup_returned_worktree || {
echo "error: treehouse return failed for worktree $WT; teardown aborted" >&2
exit 1
}
fi

if [ "$DIRECT_SPAWN_CLEANUP" = pending ] && [ -n "$DIRECT_SPAWN_BACKUP" ]; then
Expand Down
9 changes: 5 additions & 4 deletions tests/fm-account-routing.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2226,7 +2226,8 @@ test_enforced_secondmate_requires_routing_inheritance_and_capable_home() {
status=$?
[ "$status" -ne 0 ] || fail "enforced secondmate launched from a pre-Agent-Fleet home"
assert_contains "$out" "$id" "capability refusal omitted the offending secondmate"
assert_contains "$out" "dirty working tree" "capability refusal did not stop at the freshness gate"
assert_contains "$out" "home lacks Agent Fleet routing support" \
"capability refusal omitted the unsupported-home reason"
assert_not_grep '^new-window ' "$TMUX_LOG" "capability refusal created an endpoint"
pass "enforced secondmates require inherited routing policy and Agent Fleet-capable homes"
}
Expand Down Expand Up @@ -2269,9 +2270,9 @@ test_secondmate_routing_inheritance_is_authoritative_for_every_mode() {
sm=$(cd "$sm" && pwd -P)
out=$(FM_TEST_PANE_PATH="$sm" run_spawn "$id" "$sm" --secondmate)
status=$?
[ "$status" -ne 0 ] || fail "off secondmate launched from a dirty, capability-drifted home"
assert_contains "$out" "dirty working tree" "off capability drift did not stop at the freshness gate"
assert_not_grep '^new-window ' "$TMUX_LOG" "off capability drift created an endpoint"
[ "$status" -eq 0 ] || fail "off secondmate refused a clean home that does not need routing capability"
assert_contains "$out" "lacks Agent Fleet routing support; launching because account routing is off" \
"off capability warning omitted the explicit routing policy"
pass "secondmate launches require authoritative routing policy in every mode"
}

Expand Down
Loading
Loading