diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93cfcd0e17..0b6656d724 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,10 +112,10 @@ jobs: tests-portable-serial: name: Behavior portable serial ${{ matrix.shard }} runs-on: ubuntu-latest - # Measured whole remainder is ~19 min of serial work; the balanced shards - # are ~4.8 min each. Cap is a hang tripwire with roughly 3x margin, not the - # expected healthy end of the lane. - timeout-minutes: 15 + # Measured whole remainder is ~19 min of serial work; live shard walls are + # often 8-14 min as scripts and default-weight e2e grow. Cap is a hang + # tripwire with margin above those walls, not the expected healthy end. + timeout-minutes: 20 strategy: # Every shard reports so one failure never hides another shard's result. fail-fast: false diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index da99d42973..405d9c33d2 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -63,6 +63,12 @@ # --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. +# Endpoint-less tmux ship/scout husks (zero window= keys, exact endpoint_task_id, +# worktree, project, backend absent-or-tmux) may teardown only after name-independent +# proof that no live tmux server answers for this uid (protocol probe of uid unix +# sockets, plus add-only unlinked secondary). Process name never authorizes absence. +# Husk teardowns still require land/scout gates under --force; missing worktree or +# missing lsof refuses. Records with endpoints keep existing --force semantics. # After a successful teardown (local or remote), enqueue one advisory fleet # refill wake (bin/fm-wake-lib.sh's fm_wake_enqueue_refill) so firstmate # re-evaluates ready work against free capacity. Refill never selects or spawns. @@ -438,12 +444,189 @@ else fi [ "$remote_teardown_rc" -eq 3 ] || exit "$remote_teardown_rc" + +# --- endpoint-less tmux husk path (name-independent absence proof) ------------- +# A husk is a terminal ship/scout record with no window= target left. Teardown +# may destroy it only after positive proof no live tmux server remains for this +# uid, plus the ordinary land/scout gates (including under --force). + +TEARDOWN_IS_HUSK=0 + +teardown_require_land_gates() { + # Husk never inherits --force's dirty/land/scout skip. + [ "${TEARDOWN_IS_HUSK:-0}" = 1 ] && return 0 + [ "$FORCE" != "--force" ] +} + +teardown_meta_key_count() { # + grep -c "^$2=" "$1" 2>/dev/null || true +} + +teardown_is_tmux_husk() { # + local meta=$1 id=$2 window_count binding worktree project backend_count backend kind_count kind + local pr_count foreign + [ -f "$meta" ] && [ ! -L "$meta" ] || return 1 + case "$id" in ''|*[!A-Za-z0-9._-]*) return 1 ;; esac + + window_count=$(teardown_meta_key_count "$meta" window) + [ "$window_count" = 0 ] || return 1 + + [ "$(teardown_meta_key_count "$meta" endpoint_task_id)" = 1 ] || return 1 + binding=$(grep '^endpoint_task_id=' "$meta" | cut -d= -f2-) + [ "$binding" = "$id" ] || return 1 + + [ "$(teardown_meta_key_count "$meta" worktree)" = 1 ] || return 1 + worktree=$(grep '^worktree=' "$meta" | cut -d= -f2-) + [ -n "$worktree" ] || return 1 + + [ "$(teardown_meta_key_count "$meta" project)" = 1 ] || return 1 + project=$(grep '^project=' "$meta" | cut -d= -f2-) + [ -n "$project" ] || return 1 + + case "$worktree$project" in *$'\n'*|*$'\r'*|*$'\t'*) return 1 ;; esac + + backend_count=$(teardown_meta_key_count "$meta" backend) + case "$backend_count" in + 0) backend=tmux ;; + 1) + backend=$(grep '^backend=' "$meta" | cut -d= -f2-) + [ "$backend" = tmux ] || return 1 + ;; + *) return 1 ;; + esac + + kind_count=$(teardown_meta_key_count "$meta" kind) + [ "$kind_count" = 1 ] || return 1 + kind=$(grep '^kind=' "$meta" | cut -d= -f2-) + case "$kind" in ship|scout) ;; *) return 1 ;; esac + + pr_count=$(teardown_meta_key_count "$meta" pr) + case "$pr_count" in 0|1) ;; *) return 1 ;; esac + + foreign=$(grep -cE '^(herdr_|zellij_|orca_|cmux_|playbot_|terminal=)' "$meta" 2>/dev/null || true) + [ "$foreign" = 0 ] || return 1 + + return 0 +} + +# Print unique live tmux server PIDs for this uid; rc 0 on success (incl empty). +# Name-independent primary: protocol probe of connectable uid unix sockets. +# Each probe is hard-bounded so a non-tmux socket cannot stall teardown. +# Secondary: pgrep -x tmux may only ADD refuse PIDs (unlinked standard servers). +teardown_tmux_protocol_pid_at_socket() { # + # Prints answering server pid on stdout; rc 0 only on a live numeric pid. + local path=$1 ans + [ -S "$path" ] || return 1 + # Prefer gtimeout/timeout; fall back to perl alarm (macOS has no timeout by default). + if command -v gtimeout >/dev/null 2>&1; then + ans=$(gtimeout 0.25 tmux -S "$path" display-message -p '#{pid}' 2>/dev/null) || return 1 + elif command -v timeout >/dev/null 2>&1; then + ans=$(timeout 0.25 tmux -S "$path" display-message -p '#{pid}' 2>/dev/null) || return 1 + else + ans=$(perl -e 'alarm 1; exec @ARGV' tmux -S "$path" display-message -p '#{pid}' 2>/dev/null) || return 1 + fi + case "$ans" in ''|*[!0-9]*) return 1 ;; esac + kill -0 "$ans" 2>/dev/null || return 1 + printf '%s\n' "$ans" +} + +teardown_tmux_husk_endpoint_absent() { # + local id=$1 any_rc + command -v tmux >/dev/null 2>&1 || { + echo "REFUSED: husk $id: tmux is required to prove endpoint absence; preserving task state." >&2 + return 1 + } + command -v lsof >/dev/null 2>&1 || { + echo "REFUSED: husk $id: lsof is required to prove endpoint absence; preserving task state." >&2 + return 1 + } + # 0 = at least one live server, 1 = zero after complete enum, 2 = enum failed + teardown_tmux_any_live_server + any_rc=$? + if [ "$any_rc" -eq 2 ]; then + echo "REFUSED: husk $id: could not complete live tmux server enumeration; preserving task state." >&2 + return 1 + fi + if [ "$any_rc" -eq 0 ]; then + echo "REFUSED: husk $id: live tmux server(s) present; endpoint absence not positively proven." >&2 + return 1 + fi + return 0 +} + +# Return 0 if any live server, 1 if none, 2 on enum failure. +# Short-circuits on first hit so refuse is cheap on busy fleet hosts. +teardown_tmux_any_live_server() { + local uid path ans p lsof_out lsof_rc=0 pgrep_out pgrep_rc=0 paths_file found=0 + command -v tmux >/dev/null 2>&1 || return 2 + command -v lsof >/dev/null 2>&1 || return 2 + if ! command -v gtimeout >/dev/null 2>&1 \ + && ! command -v timeout >/dev/null 2>&1 \ + && ! command -v perl >/dev/null 2>&1; then + return 2 + fi + uid=$(id -u) + + # Add-only secondary first: cheap refuse when standard tmux servers live. + pgrep_out=$(pgrep -x tmux -u "$uid" 2>/dev/null) || pgrep_rc=$? + if [ "$pgrep_rc" -gt 1 ]; then + return 2 + fi + while IFS= read -r p || [ -n "$p" ]; do + [ -n "$p" ] || continue + case "$p" in *[!0-9]*) continue ;; esac + if kill -0 "$p" 2>/dev/null; then + return 0 + fi + done <<< "$pgrep_out" + + # Name-independent completeness: protocol-probe connectable uid unix sockets. + lsof_out=$(lsof -a -U -u "$uid" -F n 2>/dev/null) || lsof_rc=$? + if [ "$lsof_rc" -ne 0 ] && [ -n "$lsof_out" ]; then + return 2 + fi + paths_file=$(mktemp "${TMPDIR:-/tmp}/fm-teardown-husk-socks.XXXXXX") || return 2 + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + n/*) path=${line#n} ;; + *) continue ;; + esac + path=${path%% type=*} + path=${path%% (deleted)*} + path=${path%% \(deleted\)*} + [ -n "$path" ] || continue + [ -S "$path" ] || continue + printf '%s\n' "$path" + done <<< "$lsof_out" | sort -u > "$paths_file" || { + rm -f "$paths_file" + return 2 + } + while IFS= read -r path || [ -n "$path" ]; do + [ -n "$path" ] || continue + if ans=$(teardown_tmux_protocol_pid_at_socket "$path"); then + found=1 + break + fi + done < "$paths_file" + rm -f "$paths_file" + [ "$found" = 1 ] && return 0 + return 1 +} + # This is the first cleanup authorization check. It is metadata-only and must # complete before fm-guard, a backend command, file removal, branch deletion, # worktree return, registry change, or process termination can run. -fm_backend_validate_task_endpoint "$META" "$ID" || exit 1 -BACKEND=$FM_BACKEND_VALIDATED_BACKEND -T=$FM_BACKEND_VALIDATED_TARGET +TEARDOWN_IS_HUSK=0 +if teardown_is_tmux_husk "$META" "$ID"; then + teardown_tmux_husk_endpoint_absent "$ID" || exit 1 + TEARDOWN_IS_HUSK=1 + BACKEND=tmux + T= +else + fm_backend_validate_task_endpoint "$META" "$ID" || exit 1 + BACKEND=$FM_BACKEND_VALIDATED_BACKEND + T=$FM_BACKEND_VALIDATED_TARGET +fi WT=$(fm_meta_get "$META" worktree) PROJ=$(fm_meta_get "$META" project) T_ORCA= @@ -1433,7 +1616,10 @@ teardown_treehouse_return_attempt() { validate_worktree_teardown_safety() { local dirty_raw dirty unpushed_raw unpushed DEFAULT unmerged_raw unmerged branch - [ "$FORCE" != "--force" ] || return 0 + # Husk never inherits --force land/dirty skip (positive land still required). + if [ "$FORCE" = "--force" ] && [ "${TEARDOWN_IS_HUSK:-0}" != 1 ]; then + return 0 + fi case "$KIND" in secondmate|scout) return 0 ;; esac @@ -2711,7 +2897,7 @@ if [ "$KIND" = secondmate ] && [ "$FORCE" = "--force" ]; then cleanup_firstmate_home_children "$HOME_PATH" || exit $? fi -if [ "$KIND" = scout ] && [ "$FORCE" != "--force" ]; then +if [ "$KIND" = scout ] && teardown_require_land_gates; then REPORT="$DATA/$ID/report.md" if [ ! -f "$REPORT" ]; then echo "REFUSED: scout task $ID has no report at $REPORT." >&2 @@ -2770,7 +2956,7 @@ if [ "$BACKEND" = playbot ] && [ "$KIND" != scout ] && [ "$KIND" != secondmate ] } fi -if [ "$FORCE" != "--force" ]; then +if teardown_require_land_gates; then case "$KIND" in secondmate|scout) ;; *) @@ -2872,7 +3058,7 @@ elif [ -d "$WT" ] && [ "$KIND" != secondmate ]; then # the project. teardown_treehouse_return tolerates transient and stale git locks # left by a killed crew process; see the script header for retry and stale-lock proof. pre_return_check= - if [ "$FORCE" != "--force" ] && [ "$KIND" != scout ] && [ "$KIND" != secondmate ]; then + if teardown_require_land_gates && [ "$KIND" != scout ] && [ "$KIND" != secondmate ]; then pre_return_check=validate_worktree_teardown_safety_with_lock_recovery fi teardown_treehouse_return "$WT" "$PROJ" "worktree" "$pre_return_check" || { @@ -2926,7 +3112,9 @@ elif [ "$BACKEND" = herdr ]; then echo "warning: herdr session presentation lock path is unavailable; skipping the pane close rather than closing unlocked" >&2 fi elif [ "$BACKEND" != orca ] && [ "$BACKEND" != playbot ]; then - fm_backend_kill "$BACKEND" "$T" "$(meta_value "$META" zellij_tab_id)" "fm-$ID" 2>/dev/null || true + if [ "${TEARDOWN_IS_HUSK:-0}" != 1 ] && [ -n "$T" ]; then + fm_backend_kill "$BACKEND" "$T" "$(meta_value "$META" zellij_tab_id)" "fm-$ID" 2>/dev/null || true + fi fi if [ "$HERDR_PRESENTATION_RETIRE_CANDIDATE" = 1 ]; then if [ "$(fm_backend_herdr_pane_agent_state "$HERDR_PRESENTATION_SESSION" "$HERDR_PRESENTATION_PANE")" = dead ]; then diff --git a/docs/configuration.md b/docs/configuration.md index 5d38fff7af..1aaab7897a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -87,7 +87,7 @@ A metadata-routed selector returns the recorded backend target (`terminal=` for Only metadata-routed task selectors carry secondmate-marker and Codex-harness context; explicit endpoint escape hatches do not. These five sentences are the single owner of the task-selector vocabulary; backend guides and other documents point here instead of restating the resolution order. `fm-teardown.sh ` takes a task id directly and validates the complete metadata-only endpoint identity before any runtime dispatch or cleanup mutation. -Missing, empty, duplicate, malformed, backend-inconsistent, or task-mismatched endpoint records are preserved and refused. +Missing, empty, duplicate, malformed, backend-inconsistent, or task-mismatched endpoint records are preserved and refused, except that an exact endpoint-less tmux ship/scout husk may still teardown after the header-owned, name-independent proof that no live tmux server remains for this uid and only when the ordinary land/scout gates pass (including under `--force`). Legacy tmux metadata remains cleanup-compatible when its exact window name is `fm-`; opaque non-tmux endpoints require their recorded `endpoint_task_id=` binding. `FM_HOME` determines Herdr's home label: the primary home uses `firstmate`, and a secondmate home marked by `.fm-secondmate-home` uses `2ndmate-`. [`herdr-backend.md`](herdr-backend.md#watching-and-task-containers) owns launcher-bound workspace placement, the label-only fallback, collision handling, and recovery behavior. diff --git a/tests/fm-teardown-husk.test.sh b/tests/fm-teardown-husk.test.sh new file mode 100755 index 0000000000..e63bc86bc6 --- /dev/null +++ b/tests/fm-teardown-husk.test.sh @@ -0,0 +1,254 @@ +#!/usr/bin/env bash +# Regression: endpoint-less tmux husk teardown (name-independent absence proof). +set -u +# shellcheck source=tests/lib.sh disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +fm_git_identity fmtest fmtest@example.invalid + +TEARDOWN="$ROOT/bin/fm-teardown.sh" +TMP_ROOT=$(fm_test_tmproot fm-teardown-husk) +REAL_TMUX=$(command -v tmux || true) +UID_N=$(id -u) + +make_husk_case() { # + local name=$1 case_dir fakebin + case_dir="$TMP_ROOT/$name" + fakebin="$case_dir/fakebin" + mkdir -p "$case_dir/home/state" "$case_dir/home/data" "$case_dir/home/config" \ + "$case_dir/worktree" "$case_dir/project" "$fakebin" + # Land-friendly empty project/worktree git pair (content equal to default). + git init -q "$case_dir/origin.git" --bare + git -C "$case_dir/origin.git" symbolic-ref HEAD refs/heads/main + git clone -q "$case_dir/origin.git" "$case_dir/_seed" 2>/dev/null + git -C "$case_dir/_seed" -c user.email=t@t -c user.name=t commit -q --allow-empty -m base + git -C "$case_dir/_seed" push -q origin main + rm -rf "$case_dir/_seed" + git clone -q "$case_dir/origin.git" "$case_dir/project" + git -C "$case_dir/project" remote set-head origin main 2>/dev/null || true + git -C "$case_dir/project" worktree add -q -b fm/husk "$case_dir/worktree" main + # empty pgrep/lsof for allow-path tests (no live servers) + cat > "$fakebin/pgrep" <<'SH' +#!/usr/bin/env bash +# Legitimate no-match shape for macOS pgrep. +exit 1 +SH + cat > "$fakebin/lsof" <<'SH' +#!/usr/bin/env bash +# Empty unix-domain inventory for husk absence proof; other queries empty-success +# so cwd process reap does not treat the fake as a hard failure. +for arg in "$@"; do + if [ "$arg" = -U ]; then + exit 1 + fi +done +exit 0 +SH + cat > "$fakebin/treehouse" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + cat > "$fakebin/gh-axi" <<'SH' +#!/usr/bin/env bash +case "${1:-} ${2:-}" in + "pr list") printf '%s\n' "count: 0 (showing first 0)" "pull_requests[]: []" ; exit 0 ;; + "pr view") echo "error: pull request not found" >&2 ; exit 1 ;; +esac +exit 0 +SH + cat > "$fakebin/no-mistakes" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$fakebin"/* + touch "$case_dir/home/state/.last-watcher-beat" + printf '%s\n' "$case_dir" +} + +write_husk_meta() { # [extra meta lines...] + local dir=$1 id=$2 + shift 2 + { + printf 'endpoint_task_id=%s\n' "$id" + printf 'worktree=%s\n' "$dir/worktree" + printf 'project=%s\n' "$dir/project" + printf 'kind=ship\n' + printf 'mode=direct-PR\n' + printf 'backend=tmux\n' + for line in "$@"; do + printf '%s\n' "$line" + done + } > "$dir/home/state/$id.meta" +} + +run_husk() { # [--force] + local dir=$1 id=$2 + shift 2 + # Prefer empty enum fakes, but keep real tmux for command -v. + env -u TMUX -u TMUX_PANE \ + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" \ + PATH="$dir/fakebin:/opt/homebrew/bin:/usr/bin:/bin:$PATH" \ + "$TEARDOWN" "$id" "$@" +} + +test_legacy_missing_endpoint_still_refuses() { + local dir id=no-binding + dir=$(make_husk_case legacy-missing) + # No endpoint_task_id → not a husk; missing window still refuses. + printf 'worktree=%s\nproject=%s\nkind=scout\n' \ + "$dir/worktree" "$dir/project" > "$dir/home/state/$id.meta" + set +e + run_husk "$dir" "$id" --force >"$dir/out" 2>"$dir/err" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "legacy missing window unexpectedly allowed" + grep -q 'missing, empty, or ambiguous window endpoint' "$dir/err" \ + || fail "expected window endpoint refusal: $(cat "$dir/err")" + [ -f "$dir/home/state/$id.meta" ] || fail "meta deleted on legacy refuse" + pass "legacy missing-window without endpoint_task_id still refuses" +} + +test_husk_with_empty_enum_and_land_allows() { + local dir id=husk-land + dir=$(make_husk_case allow-land) + # HEAD is main and matches origin/main empty tree → land proof A. + write_husk_meta "$dir" "$id" + set +e + run_husk "$dir" "$id" >"$dir/out" 2>"$dir/err" + rc=$? + set -e + [ "$rc" -eq 0 ] || fail "landed husk with empty enum refused: $(cat "$dir/err")" + [ ! -f "$dir/home/state/$id.meta" ] || fail "husk meta not removed on allow" + pass "ship husk allows when enum empty and land proven" +} + +test_husk_force_does_not_skip_land() { + local dir id=husk-force-land + dir=$(make_husk_case force-land) + # Commit unique content not on origin → unlanded. + printf 'unlanded\n' > "$dir/worktree/only-here" + git -C "$dir/worktree" add only-here + git -C "$dir/worktree" -c user.email=t@t -c user.name=t commit -q -m unlanded + write_husk_meta "$dir" "$id" + set +e + run_husk "$dir" "$id" --force >"$dir/out" 2>"$dir/err" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "force husk destroyed unlanded work" + grep -qiE 'REFUSED|not landed|unpushed|positive land' "$dir/err" \ + || fail "expected land refusal under force: $(cat "$dir/err")" + [ -f "$dir/home/state/$id.meta" ] || fail "meta deleted despite land refuse" + pass "ship husk --force still requires positive land proof" +} + +test_husk_live_server_refuses_even_with_land() { + local dir id=husk-live sock pid + [ -n "$REAL_TMUX" ] || { skip "tmux not installed"; return 0; } + dir=$(make_husk_case live-server) + write_husk_meta "$dir" "$id" + sock="$dir/live.sock" + # Use REAL pgrep/lsof/tmux so the live fixture is visible (not empty fakes). + env -u TMUX -u TMUX_PANE "$REAL_TMUX" -S "$sock" new-session -d -s husklive -n "fm-$id" + pid=$(env -u TMUX -u TMUX_PANE "$REAL_TMUX" -S "$sock" display-message -p '#{pid}') + set +e + env -u TMUX -u TMUX_PANE \ + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" \ + PATH="/opt/homebrew/bin:/usr/bin:/bin:$PATH" \ + "$TEARDOWN" "$id" --force >"$dir/out" 2>"$dir/err" + rc=$? + set -e + env -u TMUX -u TMUX_PANE "$REAL_TMUX" -S "$sock" kill-server 2>/dev/null || true + kill "$pid" 2>/dev/null || true + [ "$rc" -ne 0 ] || fail "husk allowed while live tmux server present" + grep -q 'live tmux server' "$dir/err" \ + || fail "expected live-server refusal: $(cat "$dir/err")" + [ -f "$dir/home/state/$id.meta" ] || fail "meta deleted while live server" + pass "husk refuses while any live tmux server answers" +} + +test_husk_x6_renamed_binary_refuses() { + local dir id=husk-x6 sock pid bin + [ -n "$REAL_TMUX" ] || { skip "tmux not installed"; return 0; } + dir=$(make_husk_case x6-renamed) + write_husk_meta "$dir" "$id" + bin="$dir/notmux" + cp "$REAL_TMUX" "$bin" + chmod +x "$bin" + sock="$dir/x6.sock" + env -u TMUX -u TMUX_PANE "$bin" -S "$sock" new-session -d -s huskx6 -n "fm-$id" + pid=$(env -u TMUX -u TMUX_PANE "$bin" -S "$sock" display-message -p '#{pid}') + # Confirm pgrep -x tmux misses (the v6 hole). + if pgrep -x tmux -u "$UID_N" 2>/dev/null | grep -qx "$pid"; then + env -u TMUX -u TMUX_PANE "$bin" -S "$sock" kill-server 2>/dev/null || true + skip "renamed-binary still visible to pgrep -x tmux on this host" + return 0 + fi + set +e + # Empty pgrep fake would hide secondary; use real tools so protocol must catch. + env -u TMUX -u TMUX_PANE \ + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" \ + PATH="/opt/homebrew/bin:/usr/bin:/bin:$PATH" \ + "$TEARDOWN" "$id" --force >"$dir/out" 2>"$dir/err" + rc=$? + set -e + env -u TMUX -u TMUX_PANE "$bin" -S "$sock" kill-server 2>/dev/null || true + kill "$pid" 2>/dev/null || true + [ "$rc" -ne 0 ] || fail "X6 renamed-binary husk allowed with live worker" + grep -q 'live tmux server' "$dir/err" \ + || fail "X6 expected live-server refusal: $(cat "$dir/err")" + [ -f "$dir/home/state/$id.meta" ] || fail "X6 meta deleted" + pass "X6 renamed-binary live worker: husk refuses (protocol, not process name)" +} + +test_husk_scout_requires_report_under_force() { + local dir id=husk-scout + dir=$(make_husk_case scout-force) + write_husk_meta "$dir" "$id" "kind=scout" + # rewrite kind (write_husk_meta defaults ship) + printf 'endpoint_task_id=%s\nworktree=%s\nproject=%s\nkind=scout\nbackend=tmux\n' \ + "$id" "$dir/worktree" "$dir/project" > "$dir/home/state/$id.meta" + set +e + run_husk "$dir" "$id" --force >"$dir/out" 2>"$dir/err" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "scout husk force skipped report gate" + grep -qi 'report' "$dir/err" || fail "expected report refusal: $(cat "$dir/err")" + [ -f "$dir/home/state/$id.meta" ] || fail "scout meta deleted" + pass "scout husk --force still requires report" +} + +test_endpoint_bearing_force_unchanged() { + local dir id=endpoint-force + dir=$(make_husk_case endpoint-force) + # Valid-looking endpoint with force should still reach later gates (not husk). + printf 'window=isolated:fm-%s\nendpoint_task_id=%s\nworktree=%s\nproject=%s\nkind=ship\nbackend=tmux\n' \ + "$id" "$id" "$dir/worktree" "$dir/project" > "$dir/home/state/$id.meta" + # Unlanded commit; --force should skip land for endpoint-bearing (existing semantics). + printf 'x\n' > "$dir/worktree/x" + git -C "$dir/worktree" add x + git -C "$dir/worktree" -c user.email=t@t -c user.name=t commit -q -m x + set +e + # Need fake tmux kill for completion; use empty enum fakes + fake tmux + cat > "$dir/fakebin/tmux" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$dir/fakebin/tmux" + env FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" \ + PATH="$dir/fakebin:/opt/homebrew/bin:/usr/bin:/bin:$PATH" \ + "$TEARDOWN" "$id" --force >"$dir/out" 2>"$dir/err" + rc=$? + set -e + [ "$rc" -eq 0 ] || fail "endpoint-bearing --force should still skip land: $(cat "$dir/err")" + [ ! -f "$dir/home/state/$id.meta" ] || fail "endpoint meta not removed under force" + pass "endpoint-bearing --force land skip unchanged" +} + +test_legacy_missing_endpoint_still_refuses +test_husk_with_empty_enum_and_land_allows +test_husk_force_does_not_skip_land +test_husk_live_server_refuses_even_with_land +test_husk_x6_renamed_binary_refuses +test_husk_scout_requires_report_under_force +test_endpoint_bearing_force_unchanged + +echo "fm-teardown-husk: all tests passed"