diff --git a/AGENTS.md b/AGENTS.md index 0b6959e1e7..b5908157fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -541,7 +541,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 16e3ded145..795c25bf34 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 e66d231827..71fa6cd084 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -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 diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index ea03eda10b..fe50545c98 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -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 @@ -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 @@ -1059,7 +1102,19 @@ validate_teardown_target_identity() { echo "error: teardown project metadata is not an exact inspectable repository root: ${PROJ:-}" >&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:-}" >&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:-}" >&2 return 1 } @@ -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() { @@ -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. @@ -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 @@ -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") @@ -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 @@ -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 @@ -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 diff --git a/tests/fm-account-routing.test.sh b/tests/fm-account-routing.test.sh index 8bb02b29a9..4c016b9437 100755 --- a/tests/fm-account-routing.test.sh +++ b/tests/fm-account-routing.test.sh @@ -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" } @@ -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" } diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index 69351fd2f5..fe777b242e 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -81,6 +81,21 @@ SH printf '%s\n' "$fb" } +write_adapter_owned_certificate() { # + local lock_root=$1 session=$2 + mkdir -p "$lock_root" + chmod 700 "$lock_root" + FM_BACKEND_HERDR_SERVER_LOCK_ROOT="$lock_root" bash -c ' + . "$0/bin/backends/herdr.sh" + key=$(fm_backend_herdr_server_lock_key "$2") || exit 1 + certificate=$(fm_backend_herdr_server_legacy_env_certificate_path "$2") || exit 1 + start=$(fm_backend_herdr_process_start "$1") || exit 1 + printf "firstmate-herdr-closed-env-v1\n%s\n%s\n%s\n" \ + "$key" "$1" "$start" > "$certificate" + chmod 600 "$certificate" + ' "$ROOT" "$$" "$session" +} + # make_herdr_statefake: a STATEFUL `herdr` stub that models the parts of herdr's # real container behavior the workspace-leak fix (and the default-tab-prune # safety fix) depend on, so a full spawn->teardown cycle can be replayed @@ -2627,7 +2642,7 @@ test_capture_calls_pane_read() { # fetch bound; the adapter then trims to the caller's requested 250 lines # locally, so all 3 fake lines survive. out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_capture default:w1:p2 250' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_capture default:w1:p2 250' "$ROOT" ) [ "$out" = $'line one\nline two\nline three' ] || fail "capture did not pass through pane read output, got '$out'" assert_contains "$(cat "$log")" "HERDR_SESSION=default"$'\x1f''pane'$'\x1f''read'$'\x1f''w1:p2'$'\x1f''--source'$'\x1f''recent'$'\x1f''--lines'$'\x1f''250' \ "capture did not call pane read with the right pane id and line bound" @@ -2644,7 +2659,7 @@ test_capture_works_around_small_lines_bug() { printf 'a\nb\nc\nd\ne\n' > "$resp/1.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_capture default:w1:p2 2' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_capture default:w1:p2 2' "$ROOT" ) [ "$out" = $'d\ne' ] || fail "a small --lines request should still return the last N lines (trimmed locally), got '$out'" assert_contains "$(cat "$log")" $'\x1f''--lines'$'\x1f''200' \ "capture should request a generous fetch (>=200), never the caller's small N, from herdr's own --lines flag" @@ -2657,7 +2672,7 @@ test_capture_preserves_pane_read_failure() { printf '1\n' > "$resp/1.exit" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_capture default:w1:p2 2' "$ROOT" 2>&1 ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_capture default:w1:p2 2' "$ROOT" 2>&1 ) status=$? [ "$status" -ne 0 ] || fail "capture should fail when pane read fails, got output '$out'" assert_contains "$(cat "$log")" "HERDR_SESSION=default"$'\x1f''status'$'\x1f''--json' \ @@ -2672,7 +2687,7 @@ test_send_key_normalizes_and_targets_pane() { dir="$TMP_ROOT/sendkey"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" fb=$(make_herdr_fakebin "$dir") PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_send_key default:w1:p2 Escape' "$ROOT" + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_send_key default:w1:p2 Escape' "$ROOT" expect_code 0 $? "send_key should succeed" assert_contains "$(cat "$log")" $'\x1f''pane'$'\x1f''send-keys'$'\x1f''w1:p2'$'\x1f''escape' "send_key did not normalize Escape to escape" pass "fm_backend_herdr_send_key: normalizes the key and targets the right pane" @@ -2684,7 +2699,7 @@ test_kill_is_best_effort() { printf '1\n' > "$resp/1.exit" fb=$(make_herdr_fakebin "$dir") PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_kill default:w1:p2' "$ROOT" + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_kill default:w1:p2' "$ROOT" expect_code 0 $? "kill must be best-effort (never fail even when the pane close call itself fails)" assert_contains "$(cat "$log")" $'\x1f''pane'$'\x1f''close'$'\x1f''w1:p2' "kill did not call pane close on the right pane" pass "fm_backend_herdr_kill: calls pane close and stays best-effort on failure" @@ -2886,7 +2901,7 @@ test_current_path_reads_cwd() { printf '{"result":{"pane":{"cwd":"/tmp/pane-creation-dir","foreground_cwd":"/tmp/fake-worktree"}}}\n' > "$resp/1.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_current_path default:w1:p2' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_current_path default:w1:p2' "$ROOT" ) [ "$out" = "/tmp/fake-worktree" ] || fail "current_path should read foreground_cwd (the live process), not the frozen creation-time cwd, got '$out'" assert_contains "$(cat "$log")" $'\x1f''pane'$'\x1f''get'$'\x1f''w1:p2' "current_path did not call pane get" pass "fm_backend_herdr_current_path: reads pane foreground_cwd (the live running process), not the frozen creation-time cwd" @@ -2900,7 +2915,7 @@ test_busy_state_working_maps_to_busy() { printf '{"result":{"agent":{"agent_status":"working"}}}\n' > "$resp/1.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_busy_state default:w1:p2' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_busy_state default:w1:p2' "$ROOT" ) [ "$out" = busy ] || fail "agent_status=working should map to busy, got '$out'" assert_contains "$(cat "$log")" $'\x1f''agent'$'\x1f''get'$'\x1f''w1:p2' "busy_state did not call agent get" pass "fm_backend_herdr_busy_state: working -> busy" @@ -2912,14 +2927,14 @@ test_busy_state_done_and_blocked_map_to_idle() { printf '{"result":{"agent":{"agent_status":"done"}}}\n' > "$resp/1.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_busy_state default:w1:p2' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_busy_state default:w1:p2' "$ROOT" ) [ "$out" = idle ] || fail "agent_status=done should map to idle, got '$out'" dir="$TMP_ROOT/busy-blocked"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" printf '{"result":{"agent":{"agent_status":"blocked"}}}\n' > "$resp/1.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_busy_state default:w1:p2' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_busy_state default:w1:p2' "$ROOT" ) [ "$out" = idle ] || fail "agent_status=blocked should map to idle (stuck waiting on the human, not grinding), got '$out'" pass "fm_backend_herdr_busy_state: done -> idle, blocked -> idle (surfaced like a stale pane, not suppressed as busy)" } @@ -2930,7 +2945,7 @@ test_busy_state_unknown_on_no_agent() { printf '1\n' > "$resp/1.exit" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_busy_state default:w1:p2' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_busy_state default:w1:p2' "$ROOT" ) [ "$out" = unknown ] || fail "a failed agent get should report unknown (the fallback-to-regex cue), got '$out'" pass "fm_backend_herdr_busy_state: unparseable/absent agent state reports unknown, the regex-fallback cue" } @@ -2943,7 +2958,7 @@ test_composer_state_bare_prompt_is_empty() { printf ' ╭────────────────────────╮\n │ ❯ │\n ╰──────── Composer ─────╯\n\n Shift+Tab:mode\n' > "$resp/1.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) [ "$out" = empty ] || fail "a bare prompt glyph should read as empty, got '$out'" pass "fm_backend_herdr_composer_state: a bare '❯' composer row reads empty" } @@ -2954,7 +2969,7 @@ test_composer_state_ghost_placeholder_is_empty() { printf ' ╭────────────────────────╮\n │ ❯ Type a message... │\n ╰──────── Composer ─────╯\n' > "$resp/1.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) [ "$out" = empty ] || fail "the known ghost placeholder 'Type a message...' should read as empty, got '$out'" pass "fm_backend_herdr_composer_state: the ghost placeholder text reads empty, not pending" } @@ -2965,7 +2980,7 @@ test_composer_state_real_text_is_pending() { printf ' ╭────────────────────────╮\n │ ❯ hello captain │\n ╰──────── Composer ─────╯\n\n Enter:send\n' > "$resp/1.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) [ "$out" = pending ] || fail "real unsubmitted text should read as pending, got '$out'" pass "fm_backend_herdr_composer_state: real composer text reads pending" } @@ -2984,7 +2999,7 @@ test_composer_state_popup_placeholder_fill_is_pending() { printf ' ╭──────────────────────────────────────╮\n │ ❯ /compact compaction instructions │\n ╰──────────────── Composer ─────────────╯\n\n Enter:send\n' > "$resp/1.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) [ "$out" = pending ] || fail "a popup-close-with-placeholder-fill must still read as pending (not yet submitted), got '$out'" pass "fm_backend_herdr_composer_state: a slash-command popup's argument-hint placeholder still reads pending (the incident fix)" } @@ -2995,7 +3010,7 @@ test_composer_state_unknown_on_capture_failure() { printf '1\n' > "$resp/1.exit" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) status=$? [ "$status" -eq 0 ] || fail "composer_state should not itself fail the caller" [ "$out" = unknown ] || fail "an unreadable pane should read as unknown, got '$out'" @@ -3012,7 +3027,7 @@ test_composer_state_unknown_when_no_composer_row_found() { fb=$(make_herdr_fakebin "$dir") for glyph in '>' '$' '%' '#'; do out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) [ "$out" = unknown ] || fail "a bare shell prompt '$glyph' should read as unknown, got '$out'" done pass "fm_backend_herdr_composer_state: reports unknown for bare shell prompts with no composer row" @@ -3037,7 +3052,7 @@ test_composer_state_claude_unbordered_prompt_is_empty() { printf ' 20\n 21\n\n\xe2\x9c\xbb Worked for 2s\n\n\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\n\xe2\x9d\xaf\n\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\n Opus 4.8 (1M context) \xe2\x96\x8d 3%%\n \xe2\x86\x90 for agents\n' > "$resp/1.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) [ "$out" = empty ] || fail "a genuinely idle, unbordered real-claude '❯' prompt row (no border glyph anywhere in view) should read empty, got '$out' (regression: this used to read 'unknown' forever, which is exactly what broke escalate_flush's buffer-clear)" pass "fm_backend_herdr_composer_state: a real-claude unbordered '❯' prompt row (no border box in view) reads empty" } @@ -3048,7 +3063,7 @@ test_composer_state_claude_unbordered_prompt_is_pending() { printf ' 20\n 21\n\n\xe2\x9c\xbb Worked for 2s\n\n\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\n\xe2\x9d\xaf hello there this is a test message\n\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\n' > "$resp/1.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) [ "$out" = pending ] || fail "real unsubmitted text in an unbordered real-claude prompt row should read pending, got '$out'" pass "fm_backend_herdr_composer_state: a real-claude unbordered '❯ ' prompt row reads pending" } @@ -3069,7 +3084,7 @@ test_composer_state_bare_prompt_below_stale_bordered_banner_wins() { printf '\xe2\x95\xad\xe2\x94\x80 Claude Code \xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x95\xae\n\xe2\x94\x82 Welcome back Kun! \xe2\x94\x82\n\xe2\x94\x82 \xe2\x94\x82\n\xe2\x95\xb0\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x95\xaf\n\n\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\n\xe2\x9d\xaf still typing captain\n\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\n' > "$resp/1.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) [ "$out" = pending ] || fail "the live unbordered prompt row below a stale bordered banner must win (pending, real text present), got '$out'" pass "fm_backend_herdr_composer_state: a live unbordered prompt row below a stale bordered decorative box still wins (not misread as the box's own row)" } @@ -3092,7 +3107,7 @@ test_composer_state_claude_dim_prompt_suggestion_ghost_is_empty() { printf '\xe2\x9c\xbb Brewed for 2m 40s\n\n\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\n\xe2\x9d\xaf \x1b[0m\x1b[2mwhat did the wheelhouse healing verification find?\x1b[0m\n\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\n Fable 5 80%%\n' > "$resp/1.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_composer_state default:w1:p3' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_composer_state default:w1:p3' "$ROOT" ) [ "$out" = empty ] || fail "the overnight shape - claude's SGR-2 dim prompt-suggestion ghost after a bare '❯' - must read empty, got '$out' (regression: this false-pending wedged away-mode injection all night)" pass "fm_backend_herdr_composer_state: claude's dim prompt-suggestion ghost (the overnight wedge shape) reads empty" } @@ -3106,7 +3121,7 @@ test_composer_state_claude_dim_ghost_row_with_real_text_is_pending() { printf '\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\n\xe2\x9d\xaf land pr 416 now\n\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\n Fable 5 80%%\n' > "$resp/1.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_composer_state default:w1:p3' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_composer_state default:w1:p3' "$ROOT" ) [ "$out" = pending ] || fail "real normal-intensity text after '❯' must still read pending, got '$out'" pass "fm_backend_herdr_composer_state: real typed text on the same claude prompt row still reads pending" } @@ -3123,7 +3138,7 @@ test_composer_state_grok_dark_truecolor_placeholder_is_empty() { printf ' \x1b[38;2;86;82;110m\xe2\x95\xad\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x95\xae\x1b[39m\n \x1b[38;2;86;82;110m\xe2\x94\x82\x1b[38;2;224;222;244m \xe2\x9d\xaf \x1b[38;2;50;47;70mType a message...\x1b[38;2;86;82;110m \xe2\x94\x82\x1b[39m\n \x1b[38;2;86;82;110m\xe2\x95\xb0\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x95\xaf\x1b[39m\n' > "$resp/1.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) [ "$out" = empty ] || fail "a grok bordered composer whose only content is a dark-truecolor placeholder must read empty, got '$out'" pass "fm_backend_herdr_composer_state: grok's dark-truecolor placeholder (the TRUECOLOR gap) reads empty" } @@ -3135,7 +3150,7 @@ test_composer_state_grok_bright_truecolor_real_text_is_pending() { printf ' \x1b[38;2;86;82;110m\xe2\x94\x82\x1b[38;2;224;222;244m \xe2\x9d\xaf fix the login bug \x1b[38;2;86;82;110m\xe2\x94\x82\x1b[39m\n' > "$resp/1.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) [ "$out" = pending ] || fail "real bright typed text in a grok bordered composer must read pending, got '$out'" pass "fm_backend_herdr_composer_state: grok's real bright typed input still reads pending" } @@ -3146,7 +3161,7 @@ test_composer_state_codex_bare_prompt_glyph_is_empty() { printf '\xe2\x80\xa2 You have 2 usage limit resets available.\n\n\xe2\x80\xba\n\n gpt-5.5 xhigh \xc2\xb7 Context 100%% left\n' > "$resp/1.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) [ "$out" = empty ] || fail "a bare '›' (codex) prompt glyph with no trailing text should read empty, got '$out'" pass "fm_backend_herdr_composer_state: a real-codex unbordered '›' prompt row reads empty" } @@ -3157,7 +3172,7 @@ test_composer_state_codex_faint_suggestion_is_empty() { printf '\xe2\x80\xa2 You have 2 usage limit resets available. Run /usage\nto use one.\n\n\x1b[0m\x1b[1m\xe2\x80\xba \x1b[0m\x1b[2mFind and fix a bug in @filename\x1b[0m\n\n gpt-5.5 xhigh \xc2\xb7 Context 100%% left\n' > "$resp/1.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) [ "$out" = empty ] || fail "a faint real-codex ghost suggestion should read empty, not pending, got '$out'" pass "fm_backend_herdr_composer_state: a faint real-codex ghost suggestion reads empty" } @@ -3168,7 +3183,7 @@ test_composer_state_codex_non_faint_same_text_is_pending() { printf '\xe2\x80\xa2 You have 2 usage limit resets available. Run /usage\nto use one.\n\n\x1b[0m\x1b[1m\xe2\x80\xba \x1b[0mFind and fix a bug in @filename\n\n gpt-5.5 xhigh \xc2\xb7 Context 100%% left\n' > "$resp/1.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) [ "$out" = pending ] || fail "the same words without faint styling should still protect real typed input, got '$out'" pass "fm_backend_herdr_composer_state: non-faint codex prompt text still reads pending" } @@ -3239,7 +3254,7 @@ test_send_text_submit_applies_herdr_minimum_confirm_budget() { printf '{"result":{"agent":{"agent_status":"working"}}}\n' > "$resp/9.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_SLEEP_LOG="$sleep_log" FM_BACKEND_HERDR_SUBMIT_POLLS=6 FM_BACKEND_HERDR_SUBMIT_MIN_SLEEP=0.6 \ - bash -c '. "$0/bin/backends/herdr.sh"; sleep() { printf "sleep:%s\n" "$1" >> "$FM_SLEEP_LOG"; }; fm_backend_herdr_send_text_submit default:w1:p2 "hello captain" 1 0.4 0' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; sleep() { printf "sleep:%s\n" "$1" >> "$FM_SLEEP_LOG"; }; fm_backend_herdr_send_text_submit default:w1:p2 "hello captain" 1 0.4 0' "$ROOT" ) [ "$out" = empty ] || fail "send_text_submit should catch a slow-but-valid transition inside the herdr minimum budget, got '$out'" sleeps=$(grep -c '^sleep:0.1200$' "$sleep_log") [ "$sleeps" -eq 5 ] || fail "a 0.4s caller budget should be expanded to five 0.1200s sleeps across the 0.6s herdr floor, got $sleeps; log: $(cat "$sleep_log")" @@ -3304,7 +3319,7 @@ test_send_text_submit_detects_landed_send() { printf '{"result":{"agent":{"agent_status":"working"}}}\n' > "$resp/4.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_BACKEND_HERDR_SUBMIT_POLLS=1 \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_send_text_submit default:w1:p2 "hello captain" 3 0.01 0.01' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_send_text_submit default:w1:p2 "hello captain" 3 0.01 0.01' "$ROOT" ) [ "$out" = empty ] || fail "send_text_submit should report empty (submitted) once agent_status reports working, got '$out'" assert_contains "$(cat "$log")" $'\x1f''agent'$'\x1f''send'$'\x1f''w1:p2'$'\x1f''hello captain' \ "send_text_submit did not address the registered agent with the literal text first" @@ -3324,7 +3339,7 @@ test_send_text_submit_detects_swallowed_enter() { printf '{"result":{"agent":{"agent_status":"idle"}}}\n' > "$resp/6.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_BACKEND_HERDR_SUBMIT_POLLS=1 \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_send_text_submit default:w1:p2 "hello captain" 2 0.01 0.01' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_send_text_submit default:w1:p2 "hello captain" 2 0.01 0.01' "$ROOT" ) [ "$out" = pending ] || fail "send_text_submit should report pending once retries are exhausted with agent_status never going busy, got '$out'" pass "fm_backend_herdr_send_text_submit: reports 'pending' when agent_status never reports working after retried Enters (swallowed)" } @@ -3349,7 +3364,7 @@ test_send_text_submit_popup_autocomplete_requires_second_enter() { printf '{"result":{"agent":{"agent_status":"working"}}}\n' > "$resp/6.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_BACKEND_HERDR_SUBMIT_POLLS=1 \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_send_text_submit default:w1:p2 "/compact" 3 0.01 1.2' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_send_text_submit default:w1:p2 "/compact" 3 0.01 1.2' "$ROOT" ) [ "$out" = empty ] || fail "send_text_submit should eventually report empty once the SECOND Enter actually starts a turn, got '$out'" enter_count=$(grep -c $'\x1f''pane'$'\x1f''send-keys'$'\x1f''w1:p2'$'\x1f''enter' "$log") [ "$enter_count" -eq 2 ] || fail "send_text_submit must send a SECOND Enter after the popup-placeholder fill's agent_status still reads idle, got $enter_count Enter(s)" @@ -3364,7 +3379,7 @@ test_send_text_submit_confirms_blocked_after_enter() { printf '{"result":{"agent":{"agent_status":"blocked"}}}\n' > "$resp/4.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_BACKEND_HERDR_SUBMIT_POLLS=1 \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_send_text_submit default:w1:p2 "needs approval" 3 0.01 0.01' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_send_text_submit default:w1:p2 "needs approval" 3 0.01 0.01' "$ROOT" ) [ "$out" = empty ] || fail "send_text_submit should treat a blocked state after Enter as a confirmed delivered prompt, got '$out'" enter_count=$(grep -c $'\x1f''pane'$'\x1f''send-keys'$'\x1f''w1:p2'$'\x1f''enter' "$log") [ "$enter_count" -eq 1 ] || fail "blocked after Enter must not provoke a retry into the prompt, sent $enter_count Enter(s)" @@ -3380,7 +3395,7 @@ test_send_text_submit_preexisting_working_does_not_false_confirm_swallowed_enter printf ' \xe2\x9d\xaf hello captain\n' > "$resp/6.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_send_text_submit default:w1:p2 "hello captain" 2 0.01 0.01' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_send_text_submit default:w1:p2 "hello captain" 2 0.01 0.01' "$ROOT" ) [ "$out" = pending ] || fail "send_text_submit must not accept preexisting working as proof that this Enter landed, got '$out'" enter_count=$(grep -c $'\x1f''pane'$'\x1f''send-keys'$'\x1f''w1:p2'$'\x1f''enter' "$log") [ "$enter_count" -eq 2 ] || fail "preexisting-working swallowed Enter should retry Enter up to the configured count, sent $enter_count Enter(s)" @@ -3400,7 +3415,7 @@ test_send_text_submit_confirms_despite_codex_idle_tip_composer() { printf '{"result":{"agent":{"agent_status":"working"}}}\n' > "$resp/4.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_BACKEND_HERDR_SUBMIT_POLLS=1 \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_send_text_submit default:w1:p2 "reply with just OK" 3 0.01 0.01' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_send_text_submit default:w1:p2 "reply with just OK" 3 0.01 0.01' "$ROOT" ) [ "$out" = empty ] || fail "send_text_submit should confirm via agent_status alone even for a harness whose idle composer shows dynamic tip text, got '$out'" [ "$(grep -c $'\x1f''pane'$'\x1f''read' "$log")" -eq 0 ] || fail "send_text_submit must never call 'pane read' - a codex-style dynamic idle-tip composer can never mislead a confirmation path that does not read it" pass "fm_backend_herdr_send_text_submit: confirms submission via native agent-state alone, immune to a codex-style dynamic idle-tip composer that would have misread as 'pending' under the old composer-based confirmation" @@ -3417,7 +3432,7 @@ test_composer_state_codex_dynamic_idle_tip_reads_empty_when_faint() { printf '\xe2\x80\xa2 OK\n\n\n\x1b[0m\x1b[1m\xe2\x80\xba \x1b[0m\x1b[2mSummarize recent commits\x1b[0m\n\n gpt-5.5 xhigh \xc2\xb7 Context 97%% left \xc2\xb7 /private/tmp \xc2\xb7 2\xe2\x80\xa6\n' > "$resp/1.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) [ "$out" = empty ] || fail "a faint real-codex dynamic idle-tip row should read empty, got '$out'" pass "fm_backend_herdr_composer_state: a faint real-codex dynamic idle-tip composer row reads empty" } @@ -3434,7 +3449,7 @@ test_composer_state_guard_still_refuses_real_pending_text_after_submit_confirmat printf ' \xe2\x9d\xaf hello there this is a test message\n' > "$resp/1.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - bash -c '. "$0/bin/fm-backend.sh"; fm_backend_composer_state herdr default:w1:p2' "$ROOT" ) + bash -c '. "$0/bin/fm-backend.sh"; . "$0/bin/backends/herdr.sh"; fm_backend_source() { return 0; }; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_composer_state herdr default:w1:p2' "$ROOT" ) [ "$out" = pending ] || fail "the pre-injection empty-box guard must still refuse real unsubmitted composer text after this change, got '$out'" pass "fm_backend_composer_state (herdr): the pre-injection empty-box guard still refuses a genuinely non-empty composer, unaffected by the submit-confirmation change" } @@ -3454,7 +3469,7 @@ test_send_text_submit_slow_transition_within_one_enter_needs_no_extra_enter() { printf '{"result":{"agent":{"agent_status":"working"}}}\n' > "$resp/6.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_BACKEND_HERDR_SUBMIT_POLLS=3 \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_send_text_submit default:w1:p2 "hello captain" 3 0.03 0.01' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_send_text_submit default:w1:p2 "hello captain" 3 0.03 0.01' "$ROOT" ) [ "$out" = empty ] || fail "send_text_submit should confirm once a later sample within the SAME Enter attempt observes working, got '$out'" enter_count=$(grep -c $'\x1f''pane'$'\x1f''send-keys'$'\x1f''w1:p2'$'\x1f''enter' "$log") [ "$enter_count" -eq 1 ] || fail "a slow (but within-budget) transition must not provoke a needless extra Enter, sent $enter_count Enter(s)" @@ -3469,7 +3484,7 @@ test_send_text_submit_send_failed() { printf '1\n' > "$resp/2.exit" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_BACKEND_HERDR_SUBMIT_POLLS=1 \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_send_text_submit default:w1:p2 "x" 2 0.01 0.01' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_send_text_submit default:w1:p2 "x" 2 0.01 0.01' "$ROOT" ) [ "$out" = send-failed ] || fail "send_text_submit should report send-failed when the literal send itself fails, got '$out'" pass "fm_backend_herdr_send_text_submit: reports 'send-failed' when native agent send and the pane-send fallback both error" } @@ -3481,7 +3496,7 @@ test_send_text_submit_unknown_on_capture_failure() { printf '1\n' > "$resp/4.exit" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_BACKEND_HERDR_SUBMIT_POLLS=1 \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_send_text_submit default:w1:p2 "x" 2 0.01 0.01' "$ROOT" ) + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_adapter_owned() { return 0; }; fm_backend_herdr_send_text_submit default:w1:p2 "x" 2 0.01 0.01' "$ROOT" ) [ "$out" = unknown ] || fail "send_text_submit should report unknown when the post-Enter agent-get read fails, got '$out'" enter_count=$(grep -c $'\x1f''pane'$'\x1f''send-keys'$'\x1f''w1:p2'$'\x1f''enter' "$log") [ "$enter_count" -eq 1 ] || fail "send_text_submit must never retry past an unreadable target (that is a hard I/O failure, not a timing race), sent $enter_count Enter(s)" @@ -3531,10 +3546,12 @@ test_dispatch_composer_state_routes_by_backend() { } test_scripts_route_explicit_target_through_meta_backend() { - local dir state log resp fb neutral out + local dir state log resp fb neutral out lock_root dir="$TMP_ROOT/script-explicit-target"; state="$dir/state"; mkdir -p "$state" "$dir/responses" log="$dir/log"; resp="$dir/responses"; : > "$log" neutral="$dir/neutral-root"; mkdir -p "$neutral" + lock_root="$dir/locks" + write_adapter_owned_certificate "$lock_root" default fm_write_meta "$state/herdr-stale.meta" \ "window=default:w1:p2" "backend=herdr" \ "herdr_workspace_id=w1" "herdr_tab_id=w1:t2" "herdr_pane_id=w1:p2" @@ -3554,6 +3571,7 @@ SH out=$( PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$neutral" FM_STATE_OVERRIDE="$state" \ FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_BACKEND_HERDR_SERVER_LOCK_ROOT="$lock_root" \ "$ROOT/bin/fm-peek.sh" default:w1:p2 5 2>/dev/null ) [ "$out" = "captured herdr pane" ] || fail "fm-peek did not capture through herdr for an explicit metadata-matched target, got '$out'" assert_contains "$(cat "$log")" $'\x1f''pane'$'\x1f''read'$'\x1f''w1:p2' \ @@ -3562,6 +3580,7 @@ SH : > "$log" PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$neutral" FM_HOME="$neutral" FM_STATE_OVERRIDE="$state" \ FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_BACKEND_HERDR_SERVER_LOCK_ROOT="$lock_root" \ "$ROOT/bin/fm-send.sh" default:w1:p2 --key Escape >/dev/null 2>&1 expect_code 0 $? "fm-send --key should route an explicit metadata-matched target through herdr" assert_contains "$(cat "$log")" $'\x1f''pane'$'\x1f''send-keys'$'\x1f''w1:p2'$'\x1f''escape' \ diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index ea5751c55d..602d17ada1 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -49,6 +49,11 @@ # (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 already-returned worktree (external treehouse return) and fd leak: +# (z1) already-returned worktree + landed work -> ALLOW (cleanup-only) +# (z2) already-returned worktree + unlanded work -> REFUSE (safety) +# (z3) low ulimit -n + many directories in worktree -> ALLOW (fd leak fix) set -u # shellcheck source=tests/lib.sh disable=SC1091 @@ -90,6 +95,74 @@ with open(state, "w", encoding="utf-8") as stream: PY } +write_treehouse_unleased() { + local worktree=$1 slot pool state + slot=$(cd "$(dirname "$worktree")" && pwd -P) + pool=$(cd "$(dirname "$slot")" && pwd -P) + state="$pool/treehouse-state.json" + python3 - "$state" "$(cd "$worktree" && pwd -P)" <<'PY' +import json +import sys + +state, path = sys.argv[1:] +with open(state, "w", encoding="utf-8") as stream: + json.dump( + { + "worktrees": [ + { + "name": "1", + "path": path, + "leased": False, + "lease_holder": "", + } + ] + }, + stream, + ) +PY +} + +write_treehouse_without_worktree() { + local worktree=$1 slot pool state + slot=$(cd "$(dirname "$worktree")" && pwd -P) + pool=$(cd "$(dirname "$slot")" && pwd -P) + state="$pool/treehouse-state.json" + printf '%s\n' '{"worktrees":[]}' > "$state" +} + +write_treehouse_duplicate_mixed_lease() { + local worktree=$1 slot pool state + slot=$(cd "$(dirname "$worktree")" && pwd -P) + pool=$(cd "$(dirname "$slot")" && pwd -P) + state="$pool/treehouse-state.json" + python3 - "$state" "$(cd "$worktree" && pwd -P)" <<'PY' +import json +import sys + +state, path = sys.argv[1:] +with open(state, "w", encoding="utf-8") as stream: + json.dump( + { + "worktrees": [ + { + "name": "1", + "path": path, + "leased": False, + "lease_holder": "", + }, + { + "name": "duplicate", + "path": path, + "leased": True, + "lease_holder": "firstmate-other-task", + }, + ] + }, + stream, + ) +PY +} + prepare_secondmate_home_fixture() { local case_dir=$1 id=${2:-task-x1} root_default default root_tip exclude home_abs mkdir -p "$case_dir/data" "$case_dir/wt/data" "$case_dir/wt/state" "$case_dir/wt/config" \ @@ -174,6 +247,7 @@ make_case() { echo "secondmate parent was not quiesced before child cleanup: $FM_EXPECT_PARENT_QUIESCED" >&2 exit 98 } +[ -z "${FM_FAKE_COMPLETE_RETURN:-}" ] || "$REAL_GIT_FOR_TEST" -C "${!#}" checkout --quiet --detach [ -z "${FM_TEARDOWN_ORDER_LOG:-}" ] || printf 'treehouse-return %s\n' "$*" >> "$FM_TEARDOWN_ORDER_LOG" exit 0 SH @@ -575,12 +649,11 @@ git_index_lock_path() { } checkout_lock_path() { - local dir=$1 lock_root=$2 common key - common=$(git -C "$dir" rev-parse --git-common-dir) - case "$common" in /*) ;; *) common="$dir/$common" ;; esac - common=$(cd "$common" && pwd -P) - key=$(printf '%s' "$common" | shasum -a 256 | awk '{print substr($1,1,24)}') - printf '%s/%s.lock\n' "$lock_root" "$key" + local dir=$1 lock_root=$2 + bash -c ' + . "$1/bin/fm-checkout-lock-lib.sh" + fm_checkout_lock_path "$2" "$3" + ' _ "$ROOT" "$dir" "$lock_root" } # fakebin/lsof stub: no process ever holds anything open (lsof's not-found exit @@ -667,6 +740,21 @@ SH chmod +x "$case_dir/fakebin/git" } +add_git_recovered_branch_lookup_failure() { + local case_dir=$1 + cat > "$case_dir/fakebin/git" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = -C ] \ + && [ "${2:-}" = "${FM_FAKE_FOR_EACH_REF_PROJECT:?}" ] \ + && [ "${3:-}" = for-each-ref ]; then + echo "fatal: simulated recovered branch lookup failure" >&2 + exit 128 +fi +exec "${REAL_GIT_FOR_TEST:?}" "$@" +SH + chmod +x "$case_dir/fakebin/git" +} + # Run teardown with PATH mocking. Args: case_dir [extra args...] run_teardown() { local case_dir=$1; shift @@ -4320,6 +4408,292 @@ test_secondmate_registry_updates_are_locked_and_literal() { pass "secondmate registry updates are serialized and compare ids literally" } +test_already_returned_worktree_with_landed_work_allows() { + local case_dir order_log rc + case_dir=$(make_case already-returned-landed) + order_log="$case_dir/teardown-order.log" + 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 + + write_treehouse_unleased "$case_dir/wt" + + set +e + FM_FAKE_COMPLETE_RETURN=1 FM_TEARDOWN_ORDER_LOG="$order_log" \ + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + [ "$rc" -eq 0 ] || fail "already-returned-landed: teardown should succeed when work is on a remote: $(cat "$case_dir/stderr")" + assert_grep 'lease already cleared' "$case_dir/stderr" \ + "already-returned-landed: teardown did not log the cleared-lease detection" + assert_grep 'treehouse-return return --force' "$order_log" \ + "already-returned-landed: teardown did not complete the Treehouse return" + pass "already-returned worktree with landed work tears down cleanly" +} + +test_already_returned_worktree_with_unlanded_work_refuses() { + local case_dir rc + case_dir=$(make_case already-returned-unlanded) + write_meta "$case_dir" no-mistakes ship + wt_commit_file "$case_dir" unlanded.txt "content not in main" "add unlanded work" + + write_treehouse_unleased "$case_dir/wt" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + [ "$rc" -ne 0 ] || fail "already-returned-unlanded: teardown should refuse when work is not landed" + assert_grep 'REFUSED' "$case_dir/stderr" \ + "already-returned-unlanded: teardown did not print REFUSED" + pass "already-returned worktree with unlanded work is refused" +} + +test_already_returned_worktree_cleanup_honors_checkout_lock() { + local case_dir rc lock + case_dir=$(make_case already-returned-lock) + 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 + write_treehouse_unleased "$case_dir/wt" + lock=$(checkout_lock_path "$case_dir/wt" "$case_dir/checkout-locks") + mkdir -p "$lock" + printf '%s\n' "$$" > "$lock/pid" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + [ "$rc" -ne 0 ] || fail "already-returned-lock: teardown bypassed the checkout lock" + assert_present "$case_dir/wt" "already-returned-lock: teardown removed the contended worktree" + assert_present "$case_dir/state/task-x1.meta" "already-returned-lock: teardown removed task metadata" + assert_grep "checkout mutation already running for $case_dir/wt (pid $$)" \ + "$case_dir/stderr" "already-returned-lock: teardown did not surface checkout lock contention" + rm -rf "$lock" + pass "already-returned cleanup holds the shared checkout lock" +} + +test_duplicate_treehouse_entries_refuse_cleared_lease() { + local case_dir rc + case_dir=$(make_case already-returned-duplicate) + 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 + write_treehouse_duplicate_mixed_lease "$case_dir/wt" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + [ "$rc" -ne 0 ] || fail "already-returned-duplicate: duplicate lease entries were accepted" + assert_present "$case_dir/wt" "already-returned-duplicate: teardown removed the ambiguous worktree" + assert_present "$case_dir/state/task-x1.meta" "already-returned-duplicate: teardown removed task metadata" + pass "duplicate Treehouse entries fail closed" +} + +test_missing_treehouse_entry_refuses_cleared_lease() { + local case_dir rc + case_dir=$(make_case already-returned-missing) + 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 + write_treehouse_without_worktree "$case_dir/wt" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + [ "$rc" -ne 0 ] || fail "already-returned-missing: missing lease entry was accepted" + assert_present "$case_dir/wt" "already-returned-missing: teardown removed the unproven worktree" + assert_present "$case_dir/state/task-x1.meta" "already-returned-missing: teardown removed task metadata" + pass "missing Treehouse entry fails closed" +} + +test_already_returned_directory_gone_cleans_metadata() { + local case_dir rc + case_dir=$(make_case already-returned-directorygone) + 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 + + write_treehouse_unleased "$case_dir/wt" + + git -C "$case_dir/project" worktree remove --force "$case_dir/wt" 2>/dev/null || true + rm -rf "$case_dir/wt" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + [ "$rc" -eq 0 ] || fail "already-returned-directorygone: teardown should succeed when work is landed and directory is gone: $(cat "$case_dir/stderr")" + assert_grep 'lease already cleared' "$case_dir/stderr" \ + "already-returned-directorygone: teardown did not log the cleared-lease detection" + [ ! -e "$case_dir/state/task-x1.meta" ] || fail "already-returned-directorygone: task metadata was not cleaned" + pass "already-returned worktree with directory gone cleans metadata" +} + +test_already_returned_directory_gone_cleanup_honors_checkout_lock() { + local case_dir rc lock + case_dir=$(make_case already-returned-directorygone-lock) + 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 + write_treehouse_unleased "$case_dir/wt" + + git -C "$case_dir/project" worktree remove --force "$case_dir/wt" 2>/dev/null || true + rm -rf "$case_dir/wt" + lock=$(checkout_lock_path "$case_dir/project" "$case_dir/checkout-locks") + mkdir -p "$lock" + printf '%s\n' "$$" > "$lock/pid" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + [ "$rc" -ne 0 ] || fail "already-returned-directorygone-lock: teardown bypassed the checkout lock" + git -C "$case_dir/project" show-ref --verify --quiet refs/heads/fm/task-x1 \ + || fail "already-returned-directorygone-lock: task branch was deleted" + assert_present "$case_dir/state/task-x1.meta" \ + "already-returned-directorygone-lock: task metadata was removed" + assert_grep "checkout mutation already running for $case_dir/project (pid $$)" \ + "$case_dir/stderr" "already-returned-directorygone-lock: teardown did not surface checkout lock contention" + rm -rf "$lock" + pass "directory-gone recovery holds the shared checkout lock" +} + +test_already_returned_absent_leased_field_refuses() { + local case_dir rc slot pool state + case_dir=$(make_case already-returned-absent-leased) + 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 + + slot=$(cd "$(dirname "$case_dir/wt")" && pwd -P) + pool=$(cd "$(dirname "$slot")" && pwd -P) + state="$pool/treehouse-state.json" + python3 - "$state" "$(cd "$case_dir/wt" && pwd -P)" <<'PY' +import json, sys +state, path = sys.argv[1:] +with open(state, "w", encoding="utf-8") as stream: + json.dump({"worktrees": [{"name": "1", "path": path}]}, stream) +PY + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + [ "$rc" -ne 0 ] || fail "already-returned-absent-leased: missing leased field was accepted" + assert_present "$case_dir/wt" "already-returned-absent-leased: teardown removed the worktree" + assert_present "$case_dir/state/task-x1.meta" "already-returned-absent-leased: teardown removed task metadata" + pass "already-returned worktree with absent leased field fails closed" +} + +test_already_returned_directory_gone_with_unpushed_work_refuses() { + local case_dir rc + case_dir=$(make_case already-returned-directorygone-unpushed) + write_meta "$case_dir" no-mistakes ship + wt_commit_file "$case_dir" unpushed.txt "content not on a remote" "add unpushed work" + write_treehouse_unleased "$case_dir/wt" + + git -C "$case_dir/project" worktree remove --force "$case_dir/wt" 2>/dev/null || true + rm -rf "$case_dir/wt" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + [ "$rc" -ne 0 ] || fail "already-returned-directorygone-unpushed: teardown deleted an unpushed branch" + git -C "$case_dir/project" show-ref --verify --quiet refs/heads/fm/task-x1 \ + || fail "already-returned-directorygone-unpushed: task branch was deleted" + assert_present "$case_dir/state/task-x1.meta" \ + "already-returned-directorygone-unpushed: task metadata was removed" + assert_grep 'REFUSED: recovered branch fm/task-x1 has work not on any remote' "$case_dir/stderr" \ + "already-returned-directorygone-unpushed: teardown did not explain the refusal" + pass "directory-gone recovery preserves an unpushed task branch" +} + +test_already_returned_directory_gone_branch_lookup_failure_refuses() { + local case_dir rc + case_dir=$(make_case already-returned-directorygone-lookup-failure) + 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 + write_treehouse_unleased "$case_dir/wt" + + git -C "$case_dir/project" worktree remove --force "$case_dir/wt" 2>/dev/null || true + rm -rf "$case_dir/wt" + add_git_recovered_branch_lookup_failure "$case_dir" + + set +e + FM_FAKE_FOR_EACH_REF_PROJECT="$case_dir/project" \ + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + [ "$rc" -ne 0 ] || fail "already-returned-directorygone-lookup-failure: teardown ignored branch lookup failure" + git -C "$case_dir/project" show-ref --verify --quiet refs/heads/fm/task-x1 \ + || fail "already-returned-directorygone-lookup-failure: task branch was deleted" + assert_present "$case_dir/state/task-x1.meta" \ + "already-returned-directorygone-lookup-failure: task metadata was removed" + assert_grep 'REFUSED: cannot inspect the recovered task branch for fm/task-x1' "$case_dir/stderr" \ + "already-returned-directorygone-lookup-failure: teardown did not explain the refusal" + pass "directory-gone recovery fails closed on branch lookup errors" +} + +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:-}" = already-returned-and-fd-leak ]; then + test_already_returned_worktree_with_landed_work_allows + test_already_returned_worktree_with_unlanded_work_refuses + test_already_returned_worktree_cleanup_honors_checkout_lock + test_duplicate_treehouse_entries_refuse_cleared_lease + test_missing_treehouse_entry_refuses_cleared_lease + test_already_returned_directory_gone_cleans_metadata + test_already_returned_directory_gone_cleanup_honors_checkout_lock + test_already_returned_absent_leased_field_refuses + test_already_returned_directory_gone_with_unpushed_work_refuses + test_already_returned_directory_gone_branch_lookup_failure_refuses + test_fd_leak_under_low_ulimit + exit 0 +fi + if [ "${FM_TEST_FOCUSED:-}" = tasktmp-safety ]; then test_teardown_refuses_unsafe_tasktmp_metadata exit 0 @@ -4574,3 +4948,14 @@ 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_already_returned_worktree_with_landed_work_allows +test_already_returned_worktree_with_unlanded_work_refuses +test_already_returned_worktree_cleanup_honors_checkout_lock +test_duplicate_treehouse_entries_refuse_cleared_lease +test_missing_treehouse_entry_refuses_cleared_lease +test_already_returned_directory_gone_cleans_metadata +test_already_returned_directory_gone_cleanup_honors_checkout_lock +test_already_returned_absent_leased_field_refuses +test_already_returned_directory_gone_with_unpushed_work_refuses +test_already_returned_directory_gone_branch_lookup_failure_refuses +test_fd_leak_under_low_ulimit