diff --git a/AGENTS.md b/AGENTS.md index 02882505282..02ff3f35bc0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -551,7 +551,8 @@ bin/fm-teardown.sh ``` 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`. diff --git a/bin/fm-process-tree-lib.sh b/bin/fm-process-tree-lib.sh index 16e3ded145f..795c25bf34f 100644 --- a/bin/fm-process-tree-lib.sh +++ b/bin/fm-process-tree-lib.sh @@ -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"; diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index de15434eb83..b30c8adcf7d 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -2817,7 +2817,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 diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index ed3af9a2640..f3422b367ad 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -4937,6 +4937,41 @@ test_secondmate_registry_updates_are_locked_and_literal() { pass "secondmate registry updates are serialized and compare ids literally" } +test_fd_leak_under_low_ulimit() { + local case_dir rc dir_count i + case_dir=$(make_case fd-leak-ulimit) + write_meta "$case_dir" no-mistakes ship + wt_commit "$case_dir" "fix the thing" + git -C "$case_dir/wt" push -q origin fm/task-x1 + + dir_count=200 + i=0 + while [ "$i" -lt "$dir_count" ]; do + mkdir -p "$case_dir/wt/deep/dir_$i" + : > "$case_dir/wt/deep/dir_$i/.keep" + i=$((i + 1)) + done + git -C "$case_dir/wt" add -A + git -C "$case_dir/wt" -c user.email=t@t -c user.name=t commit -q -m "add dirs" + git -C "$case_dir/wt" push -q origin fm/task-x1 + + set +e + ( + ulimit -n 256 2>/dev/null || exit 99 + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + ) + rc=$? + set -e + + [ "$rc" -eq 0 ] || fail "fd-leak-ulimit: teardown failed under low ulimit (rc=$rc): $(cat "$case_dir/stderr")" + pass "teardown succeeds under low ulimit -n with many directories (fd leak fix)" +} + +if [ "${FM_TEST_FOCUSED:-}" = fd-leak-ulimit ]; then + test_fd_leak_under_low_ulimit + exit 0 +fi + if [ "${FM_TEST_FOCUSED:-}" = tasktmp-safety ]; then test_teardown_removes_safe_tasktmp_and_accepts_absence test_teardown_refuses_unsafe_tasktmp_metadata @@ -5239,3 +5274,4 @@ test_transient_index_lock_clears_after_first_attempt_and_retry_succeeds test_persistent_index_lock_exhausts_retries_and_refuses_loudly test_empty_retry_wait_uses_default_without_aborting test_fractional_legacy_retry_wait_refuses_without_arithmetic_error +test_fd_leak_under_low_ulimit