From 2f1ea74f8f0862ae98f5bf7e2f96d32cec06e493 Mon Sep 17 00:00:00 2001 From: kuannnn Date: Wed, 2 Sep 2026 05:54:53 +0800 Subject: [PATCH 1/4] test(gotmp): give the teardown fixture the tmux adapter's session-lock sibling tests/fm-gotmp.test.sh has failed since cf95112 (#1577), which made bin/backends/tmux.sh source fm-session-lock-lib.sh at load time. The test builds a fake FM_HOME by symlinking only the bin/ siblings teardown needs, and that hand-curated list never gained the new sibling. The teardown kill step then dies on the failed source; the message is hidden behind `2>/dev/null || true`, the EXIT trap returns Bash's post-fatal `$?` of 0, and the test's "did not remove the tasktmp dir" assertion is the first one that notices. Production teardown is unaffected: a real bin/ always has the sibling, and tasktmp removal works. This is a fixture fix only. - Symlink fm-session-lock-lib.sh into the fixture next to fm-tmux-lib.sh. - Stub fm-remote-job-reap-orphans.sh like fm-guard.sh and fm-fleet-sync.sh; teardown calls it best-effort, and the real sweep signals processes, so it must never be symlinked into a fixture. - Let make_fake_root omit the tasktmp= line when called with one argument, and drop the second, duplicated copy of the fixture so the sibling list lives in one place. bin/fm-teardown.sh and bin/fm-spawn.sh are untouched. --- tests/fm-gotmp.test.sh | 82 +++++++++++------------------------------- 1 file changed, 21 insertions(+), 61 deletions(-) diff --git a/tests/fm-gotmp.test.sh b/tests/fm-gotmp.test.sh index 3b17c593c23..a1da6ba6e40 100755 --- a/tests/fm-gotmp.test.sh +++ b/tests/fm-gotmp.test.sh @@ -44,8 +44,11 @@ TMP_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/fm-gotmp-tests.XXXXXX") # state and helper scripts inside it. Stub the helper scripts fm-teardown calls so no # live tmux/treehouse/fleet state is touched. A nonexistent worktree path makes both # `if [ -d "$WT" ]` guards skip, so teardown runs straight to the cleanup + state rm. +# make_fake_root []: with one argument the meta carries no +# tasktmp= line at all (a pre-fix task); with two it records the given path. make_fake_root() { - local id=$1 tasktmp=$2 + local id=$1 tasktmp_line= + [ $# -lt 2 ] || tasktmp_line="tasktmp=$2" local fake="$TMP_ROOT/$id" mkdir -p "$fake/bin/backends" "$fake/state" "$fake/data" # Symlink the REAL teardown so the test exercises actual code, not a copy. @@ -57,6 +60,10 @@ make_fake_root() { ln -s "$ROOT/bin/fm-backend.sh" "$fake/bin/fm-backend.sh" ln -s "$ROOT/bin/backends/tmux.sh" "$fake/bin/backends/tmux.sh" ln -s "$ROOT/bin/fm-tmux-lib.sh" "$fake/bin/fm-tmux-lib.sh" + # fm-session-lock-lib.sh: the tmux adapter sources it at load time, so the + # teardown kill step dies with a fatal source error before the tasktmp + # removal unless the sibling is present. + ln -s "$ROOT/bin/fm-session-lock-lib.sh" "$fake/bin/fm-session-lock-lib.sh" ln -s "$ROOT/bin/fm-cursor-lib.sh" "$fake/bin/fm-cursor-lib.sh" ln -s "$ROOT/bin/fm-composer-lib.sh" "$fake/bin/fm-composer-lib.sh" ln -s "$ROOT/bin/fm-nm-run-lib.sh" "$fake/bin/fm-nm-run-lib.sh" @@ -101,6 +108,14 @@ SH exit 0 SH chmod +x "$fake/bin/fm-fleet-sync.sh" + # fm-remote-job-reap-orphans.sh: stub (teardown calls it with `|| true`). The + # real sweep signals orphaned remote job workers on this machine, so it must + # never be symlinked into a fixture. + cat > "$fake/bin/fm-remote-job-reap-orphans.sh" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$fake/bin/fm-remote-job-reap-orphans.sh" # fm-tasks-axi-lib.sh: stub (teardown sources it). Report no backend so the # fused backlog close is skipped and the follow-up echo takes the plain-message # path; there is no tasks-axi and no backlog in this fixture. @@ -119,8 +134,8 @@ harness=claude kind=ship mode=no-mistakes yolo=off -tasktmp=$tasktmp META + [ -z "$tasktmp_line" ] || printf '%s\n' "$tasktmp_line" >> "$fake/state/$id.meta" printf '%s' "$fake" } @@ -147,65 +162,10 @@ test_teardown_skips_gracefully_without_tasktmp() { # Backward compat: a meta from a pre-fix task has no tasktmp= line. Teardown must # not error and must not remove anything. local id=td-absent-z3 - local fake="$TMP_ROOT/$id-root" - mkdir -p "$fake/bin/backends" "$fake/state" "$fake/data" - ln -s "$TEARDOWN" "$fake/bin/fm-teardown.sh" - ln -s "$ROOT/bin/fm-backend.sh" "$fake/bin/fm-backend.sh" - ln -s "$ROOT/bin/backends/tmux.sh" "$fake/bin/backends/tmux.sh" - ln -s "$ROOT/bin/fm-tmux-lib.sh" "$fake/bin/fm-tmux-lib.sh" - ln -s "$ROOT/bin/fm-cursor-lib.sh" "$fake/bin/fm-cursor-lib.sh" - ln -s "$ROOT/bin/fm-composer-lib.sh" "$fake/bin/fm-composer-lib.sh" - ln -s "$ROOT/bin/fm-nm-run-lib.sh" "$fake/bin/fm-nm-run-lib.sh" - ln -s "$ROOT/bin/fm-lock-lib.sh" "$fake/bin/fm-lock-lib.sh" - # fm-lease-lib.sh: teardown sources it for the supervision lease guard. - ln -s "$ROOT/bin/fm-lease-lib.sh" "$fake/bin/fm-lease-lib.sh" - ln -s "$ROOT/bin/fm-control-lib.sh" "$fake/bin/fm-control-lib.sh" - ln -s "$ROOT/bin/fm-classify-lib.sh" "$fake/bin/fm-classify-lib.sh" - # fm-timeout-lib.sh: the shared hard bound fm-classify-lib.sh sources for the - # wedge detector's bounded worktree write probe. - ln -s "$ROOT/bin/fm-timeout-lib.sh" "$fake/bin/fm-timeout-lib.sh" - ln -s "$ROOT/bin/fm-wake-lib.sh" "$fake/bin/fm-wake-lib.sh" - # fm-gate-refuse-lib.sh: teardown sources it before any fleet mutation. - ln -s "$ROOT/bin/fm-gate-refuse-lib.sh" "$fake/bin/fm-gate-refuse-lib.sh" - # fm-pr-lib.sh: teardown uses its canonical task-ID validator for poll cleanup. - ln -s "$ROOT/bin/fm-pr-lib.sh" "$fake/bin/fm-pr-lib.sh" - # fm-public-followup-lib.sh (and the fm-x-lib.sh it sources): teardown sources - # it for the relay-activation gate on the promised-public-reply check. Neither - # does anything in this fixture, which has no .env, but both are real siblings - # teardown now requires. - ln -s "$ROOT/bin/fm-public-followup-lib.sh" "$fake/bin/fm-public-followup-lib.sh" - ln -s "$ROOT/bin/fm-x-lib.sh" "$fake/bin/fm-x-lib.sh" - ln -s "$ROOT/bin/fm-secondmate-registry-lib.sh" "$fake/bin/fm-secondmate-registry-lib.sh" - ln -s "$ROOT/bin/fm-secondmate-parent-lib.sh" "$fake/bin/fm-secondmate-parent-lib.sh" - ln -s "$ROOT/bin/fm-pending-reply-lib.sh" "$fake/bin/fm-pending-reply-lib.sh" - ln -s "$ROOT/bin/fm-marker-lib.sh" "$fake/bin/fm-marker-lib.sh" - ln -s "$ROOT/bin/fm-operational-input.sh" "$fake/bin/fm-operational-input.sh" - cat > "$fake/bin/fm-guard.sh" <<'SH' -#!/usr/bin/env bash -exit 0 -SH - chmod +x "$fake/bin/fm-guard.sh" - cat > "$fake/bin/fm-fleet-sync.sh" <<'SH' -#!/usr/bin/env bash -exit 0 -SH - chmod +x "$fake/bin/fm-fleet-sync.sh" - cat > "$fake/bin/fm-tasks-axi-lib.sh" <<'SH' -fm_tasks_axi_backend_available() { return 1; } -fm_tasks_axi_compatible() { return 1; } -fm_backlog_backend_manual() { return 1; } -SH - ln -s "$ROOT/bin/fm-backlog-transition-lib.sh" "$fake/bin/fm-backlog-transition-lib.sh" - # No tasktmp= line at all. - cat > "$fake/state/$id.meta" < fm_backend_source() { # local name=$1 fm_backend_validate "$name" || return 1 + # A missing or unreadable adapter must come back as an ordinary failed + # return: on Bash 3.2 a `.` of a missing file is a fatal shell error that + # skips the caller's own refusal and, under an EXIT trap, exits 0. + [ -r "$FM_BACKEND_LIB_DIR/backends/$name.sh" ] || return 1 case "$name" in tmux) if [ -z "${_FM_BACKEND_TMUX_SOURCED:-}" ]; then diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index ad9e042ba11..6cb52792c1e 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -254,8 +254,20 @@ teardown_release_locks() { CONTROL_LOCK_HELD=0 fi fm_lease_guard_release || true + # Bash reports $? as 0 inside this trap after a fatal shell error such as a + # failed `source` of a missing sibling, so returning it as-is would turn that + # abort into a silent exit 0 that callers read as success. Every path that + # legitimately exits 0 has already removed the task record, so a record that + # is still present at exit 0 is the one signature of an aborted teardown. + # The message goes to the stderr saved below because the abort may happen + # inside a call whose own stderr is deliberately discarded. + if [ "$status" -eq 0 ] && { [ -e "$STATE/$ID.meta" ] || [ -L "$STATE/$ID.meta" ]; }; then + echo "error: teardown of $ID aborted before its task record was removed; every durable record is retained" >&3 + exit 1 + fi return "$status" } +exec 3>&2 trap teardown_release_locks EXIT fm_lock_try_acquire "$CONTROL_LOCK" || { echo "error: another lifecycle action is already running for task $ID; nothing was changed" >&2 diff --git a/tests/fm-gotmp.test.sh b/tests/fm-gotmp.test.sh index a1da6ba6e40..2248d4c4fa9 100755 --- a/tests/fm-gotmp.test.sh +++ b/tests/fm-gotmp.test.sh @@ -185,6 +185,32 @@ test_teardown_skips_gracefully_when_dir_missing() { pass "fm-teardown skips gracefully when tasktmp= points to a nonexistent dir" } +test_teardown_fails_loudly_when_a_sourced_sibling_is_missing() { + # A fatal `source` of a missing sibling (here the one the tmux adapter loads + # inside teardown's stderr-discarding kill call) aborts the script, and Bash + # then reports $? as 0 inside the EXIT trap. Teardown must still exit + # non-zero, say so on its real stderr, and leave every durable record in + # place rather than let the caller read a silent exit 0 as success. + local id=td-nosib-z5 + local task_tmp="$TMP_ROOT/fm-$id" + mkdir -p "$task_tmp/gotmp" + local fake err + fake=$(make_fake_root "$id" "$task_tmp") + rm "$fake/bin/fm-session-lock-lib.sh" + err="$TMP_ROOT/$id.stderr" + if FM_HOME="$fake" bash "$fake/bin/fm-teardown.sh" "$id" >/dev/null 2>"$err"; then + fail "teardown exited 0 with a sourced sibling missing" + fi + [ -e "$fake/state/$id.meta" ] \ + || fail "teardown removed the task record after a fatal source failure" + [ -e "$task_tmp" ] \ + || fail "teardown removed the tasktmp dir after a fatal source failure" + grep -q "aborted before its task record was removed" "$err" \ + || fail "teardown did not report the aborted teardown on stderr" + pass "fm-teardown exits non-zero and retains every record when a sourced sibling is missing" +} + test_teardown_removes_tasktmp_dir test_teardown_skips_gracefully_without_tasktmp test_teardown_skips_gracefully_when_dir_missing +test_teardown_fails_loudly_when_a_sourced_sibling_is_missing From 05f697bea66467934db0d949b62308dbb0bb2169 Mon Sep 17 00:00:00 2001 From: kuannnn Date: Sat, 5 Sep 2026 12:59:37 +0800 Subject: [PATCH 3/4] fix(teardown): preflight required child adapters before cleanup --- bin/fm-teardown.sh | 9 ++-- tests/fm-gotmp.test.sh | 102 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 107 insertions(+), 4 deletions(-) diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 3aca73b5f36..d353abfb080 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -2507,7 +2507,7 @@ $session $lock_path" return 1 } -preflight_firstmate_home_herdr_children() { # +preflight_firstmate_home_backend_children() { # local home=$1 sub_state child_meta child_id child_backend child_target child_kind child_home child_wt sub_state="$home/state" [ -d "$sub_state" ] || return 0 @@ -2519,6 +2519,9 @@ preflight_firstmate_home_herdr_children() { # child_target=$FM_BACKEND_VALIDATED_TARGET if [ "$child_backend" = herdr ]; then teardown_herdr_preflight_target "$child_target" "$child_id" || return 1 + elif ! fm_backend_source "$child_backend"; then + echo "REFUSED: $child_backend adapter is unavailable for child $child_id; forced teardown changed nothing" >&2 + return 1 fi child_kind=$(meta_value "$child_meta" kind) [ -n "$child_kind" ] || child_kind=ship @@ -2526,7 +2529,7 @@ preflight_firstmate_home_herdr_children() { # child_wt=$(meta_value "$child_meta" worktree) child_home=$(meta_value "$child_meta" home) [ -n "$child_home" ] || child_home=$child_wt - preflight_firstmate_home_herdr_children "$child_home" || return 1 + preflight_firstmate_home_backend_children "$child_home" || return 1 fi done } @@ -2659,7 +2662,7 @@ if [ "$KIND" = secondmate ]; then if [ "$BACKEND" = herdr ]; then teardown_herdr_preflight_target "$T" "$ID" || exit 1 fi - preflight_firstmate_home_herdr_children "$HOME_PATH" || exit 1 + preflight_firstmate_home_backend_children "$HOME_PATH" || exit 1 fi fi diff --git a/tests/fm-gotmp.test.sh b/tests/fm-gotmp.test.sh index 32df9786a33..d12fe634658 100755 --- a/tests/fm-gotmp.test.sh +++ b/tests/fm-gotmp.test.sh @@ -195,18 +195,21 @@ test_teardown_fails_loudly_when_a_sourced_sibling_is_missing() { local fake err fake=$(make_fake_root "$id" "$task_tmp") cat > "$fake/bin/backends/tmux.sh" <<'SH' +: > "$FM_HOME/sibling-source-reached" +set -o posix # shellcheck source=/dev/null . "$FM_BACKEND_LIB_DIR/missing-sibling.sh" fm_backend_tmux_kill() { return 0; } SH err="$TMP_ROOT/$id.stderr" - if FM_HOME="$fake" bash --posix "$fake/bin/fm-teardown.sh" "$id" >/dev/null 2>"$err"; then + if FM_HOME="$fake" bash "$fake/bin/fm-teardown.sh" "$id" >/dev/null 2>"$err"; then fail "teardown exited 0 with a sourced sibling missing" fi [ -e "$fake/state/$id.meta" ] \ || fail "teardown removed the task record after a fatal source failure" [ -e "$task_tmp" ] \ || fail "teardown removed the tasktmp dir after a fatal source failure" + [ -e "$fake/sibling-source-reached" ] || fail "teardown did not reach the missing sibling" pass "fm-teardown exits non-zero and retains every record when a sourced sibling is missing" } @@ -243,9 +246,106 @@ test_missing_adapter_returns_to_caller() { pass "a missing adapter returns failure so the caller can refuse safely" } +test_forced_parent_preflights_child_adapters() { + local mode id fake home task_tmp child_wt child_tmp err log path + for mode in missing-sibling missing-adapter zero-exit kill-failure success; do + id="td-child-$mode" + task_tmp="$TMP_ROOT/fm-$id" + home="$TMP_ROOT/home-$id" + child_wt="$TMP_ROOT/work-$id" + child_tmp="$home/tasktmp" + mkdir -p "$task_tmp/gotmp" "$home/state" "$child_tmp/gotmp" "$child_wt" + fake=$(make_fake_root "$id" "$task_tmp") + printf '%s\n' "$id" > "$home/.fm-secondmate-home" + printf 'parent work\n' > "$home/work-note" + printf 'parent scratch\n' > "$task_tmp/gotmp/artifact" + printf 'child scratch\n' > "$child_tmp/gotmp/artifact" + git -C "$child_wt" init -q || fail "child fixture git init failed" + git -C "$child_wt" -c user.name=Test -c user.email=test@example.invalid \ + -c commit.gpgsign=false commit -q --allow-empty -m fixture \ + || fail "child fixture commit failed" + printf 'child work\n' > "$child_wt/work-note" + cat > "$fake/state/$id.meta" < "$home/state/child-z.meta" < "$fake/bin/backends/zellij.sh" + if [ "$mode" = missing-sibling ]; then + printf 'set -o posix\n' >> "$fake/bin/backends/zellij.sh" + fi + cat >> "$fake/bin/backends/zellij.sh" <<'SH' +. "$FM_BACKEND_LIB_DIR/fm-backend-hometag-lib.sh" +fm_backend_zellij_kill() { + printf '%s\n' "$FM_HOME" "$FM_ROOT" "$@" >> "$TEST_CHILD_KILL_LOG" + return "$TEST_CHILD_KILL_RC" +} +SH + case "$mode" in + missing-sibling) ;; + missing-adapter) rm "$fake/bin/backends/zellij.sh" ;; + zero-exit) printf 'exit 0\n' > "$fake/bin/fm-backend-hometag-lib.sh" ;; + *) : > "$fake/bin/fm-backend-hometag-lib.sh" ;; + esac + printf '#!/usr/bin/env bash\nexit 1\n' > "$fake/bin/treehouse" + chmod +x "$fake/bin/treehouse" + err="$fake/teardown.stderr" + log="$fake/child-kill.log" + local kill_rc=0 rc=0 + [ "$mode" != kill-failure ] || kill_rc=1 + FM_HOME="$fake" PATH="$fake/bin:$PATH" TEST_CHILD_KILL_LOG="$log" TEST_CHILD_KILL_RC="$kill_rc" \ + bash "$fake/bin/fm-teardown.sh" "$id" --force > "$fake/teardown.stdout" 2>"$err" || rc=$? + case "$mode" in + missing-sibling|missing-adapter|zero-exit) + [ "$rc" -ne 0 ] || fail "$mode: forced parent teardown accepted child adapter failure" + cmp -s "$fake/state/$id.meta" "$fake/parent.meta.before" || fail "$mode: parent record changed" + cmp -s "$home/state/child-z.meta" "$fake/child.meta.before" || fail "$mode: child record changed" + [ "$(cat "$home/work-note")" = 'parent work' ] || fail "$mode: parent work changed" + [ "$(cat "$child_wt/work-note")" = 'child work' ] || fail "$mode: child work changed" + [ "$(cat "$task_tmp/gotmp/artifact")" = 'parent scratch' ] || fail "$mode: parent scratch changed" + [ "$(cat "$child_tmp/gotmp/artifact")" = 'child scratch' ] || fail "$mode: child scratch changed" + [ ! -e "$log" ] || fail "$mode: child kill ran before preflight completed" + case "$mode" in + missing-sibling) grep -q 'fm-backend-hometag-lib.sh' "$err" || fail "missing sibling diagnostic absent: $(cat "$err")" ;; + missing-adapter) grep -q 'REFUSED: zellij adapter is unavailable for child child-z' "$err" || fail "missing adapter refusal absent: $(cat "$err")" ;; + zero-exit) grep -q 'aborted before its task record was removed' "$err" || fail "zero-status abort diagnostic absent: $(cat "$err")" ;; + esac + ;; + *) + [ "$rc" -eq 0 ] || { cat "$err" >&2; fail "$mode: forced parent teardown failed"; } + for path in "$fake/state/$id.meta" "$home" "$child_wt" "$task_tmp"; do + [ ! -e "$path" ] || fail "$mode: cleanup retained $path" + done + printf '%s\n' "$home" "$home" fakeses:1 1 fm-child-z > "$fake/expected-kill.log" + cmp -s "$log" "$fake/expected-kill.log" || fail "$mode: child kill lost its owning home or endpoint" + ;; + esac + pass "forced parent child-adapter preflight: $mode" + done +} + test_teardown_removes_tasktmp_dir test_teardown_skips_gracefully_without_tasktmp test_teardown_skips_gracefully_when_dir_missing test_teardown_fails_loudly_when_a_sourced_sibling_is_missing test_teardown_rejects_zero_status_abort test_missing_adapter_returns_to_caller +test_forced_parent_preflights_child_adapters From cf96867399404d54a4734f65cfa5d487f091ebcb Mon Sep 17 00:00:00 2001 From: kuannnn Date: Sat, 5 Sep 2026 14:17:38 +0800 Subject: [PATCH 4/4] no-mistakes(document): Document teardown abort and child adapter preflight contracts --- bin/fm-backend.sh | 4 ++-- bin/fm-teardown.sh | 19 +++++++++++-------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index 5c1f56de801..6e06c590145 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -601,8 +601,8 @@ fm_backend_source() { # local name=$1 fm_backend_validate "$name" || return 1 # A missing or unreadable adapter must come back as an ordinary failed - # return: on Bash 3.2 a `.` of a missing file is a fatal shell error that - # skips the caller's own refusal and, under an EXIT trap, exits 0. + # return: on Bash 3.2 a `.` of a missing file can be a fatal shell error that + # skips the caller's own refusal; teardown's EXIT guard handles that abort. [ -r "$FM_BACKEND_LIB_DIR/backends/$name.sh" ] || return 1 case "$name" in tmux) diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index d353abfb080..96b973d0140 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -19,6 +19,10 @@ # home with a backlog but no compatible tasks-axi refuses before cleanup. # None of this loosens the landed-work gates below: the transition runs only on # the paths that already proceed to remove the record. +# An exit with status zero while the task record remains is an aborted teardown: +# the EXIT guard returns failure and reports it on the original stderr, even if +# the triggering call discarded stderr, without continuing cleanup. +# tests/fm-gotmp.test.sh covers this guard and adapter-load failure retention. # The close - and only the close - is replaced by `tasks-axi reopen` with the # deliverable recorded while the backlog item is still an open captain call # (bin/fm-captain-hold.sh `open` owns that predicate), because the policy holds @@ -75,7 +79,11 @@ # is the approved discard path that prevalidates child removal targets, locks each # descendant home's task set before enumeration, and holds those locks through # child cleanup. Contention refuses the complete forced teardown before child -# mutation. Local and remote retirement serialize their destructive phase with +# mutation. Required child adapters are loaded recursively before child cleanup; +# unavailable adapters or fatal source exits preserve parent and child metadata, +# work, and scratch. After successful loading, endpoint kills keep their existing +# best-effort behavior and Herdr's exact-pane disappearance requirement. +# Local and remote retirement serialize their destructive phase with # that mate's backlog-handoff lock under the registry lock. Pending handoff wake # state is retired with the home, and local removal failure restores that state # before preserving the route for retry. Teardown then discards child work, kills @@ -274,13 +282,8 @@ teardown_release_locks() { CONTROL_LOCK_HELD=0 fi fm_lease_guard_release || true - # Bash reports $? as 0 inside this trap after a fatal shell error such as a - # failed `source` of a missing sibling, so returning it as-is would turn that - # abort into a silent exit 0 that callers read as success. Every path that - # legitimately exits 0 has already removed the task record, so a record that - # is still present at exit 0 is the one signature of an aborted teardown. - # The message goes to the stderr saved below because the abort may happen - # inside a call whose own stderr is deliberately discarded. + # A fatal source error can leave $? at 0 on Bash 3.2, just like an explicit + # exit 0. Descriptor 3 survives best-effort callers redirecting their stderr. if [ "$status" -eq 0 ] && { [ -e "$STATE/$ID.meta" ] || [ -L "$STATE/$ID.meta" ]; }; then echo "error: teardown of $ID aborted before its task record was removed; every durable record is retained" >&3 exit 1