Skip to content

fix(bin): keep fm_exec_timed's owner capture working on Bash 3.2 - #11

Merged
d-ploutarchos merged 3 commits into
mainfrom
fm/fm-bashpid-fix
Oct 1, 2026
Merged

d-ploutarchos merged 3 commits into
mainfrom
fm/fm-bashpid-fix

Conversation

@d-ploutarchos

Copy link
Copy Markdown

Intent

The update merged today (#10, upstream sync) broke the remote second mate on the captain's Mac: bin/fm-timeout-lib.sh fm_exec_timed reads $BASHPID, which macOS's bash 3.2 does not have, so under set -u every timed spawn on the Mac fails and all Mac reviews are blocked.

What Changed

  • fm_exec_timed no longer reads $BASHPID unguarded when deciding whether the captured owner is its own frame. On a shell without BASHPID (stock macOS Bash 3.2), where the unguarded read aborted every timed spawn under set -u, it now falls back to exec sh -c 'printf "%s\n" "$PPID"' so a forked child reports the frame's pid instead.
  • Documented the new requirement in the library header: because the fallback needs a forked child, a caller that sandboxes PATH on such a shell must leave sh reachable on it.
  • Added test_runs_without_bashpid_under_set_u to tests/fm-timeout-lib.test.sh, covering both status pass-through (subshell and top-level calls) and owner capture (a watchdog whose owner dies during startup ends its command rather than running to its bound) with BASHPID unset, and repeating both against a real /bin/bash 3.x when the host has one. Two existing tests that read $BASHPID directly now derive the frame pid the same forked-child way, and sh was added to the sandboxed PATH the tests build.

Risk Assessment

✅ Low: The source change is a single well-bounded line that reuses the repo's own established BASHPID-less idiom (bin/fm-wake-lib.sh:42), is inert on bash 4+, satisfies the stated intent completely (it was the only unguarded BASHPID read in bin/), and is covered by a regression test I confirmed actually discriminates the naive ${BASHPID:-$$} fallback; the one finding is test-coverage hygiene with no behavioral impact.

Testing

I drove the reported surface itself: the repo's opt-in live supervision-host guard, which runs a real headless Claude engine turn for an away wake in an isolated lab home. With the base-commit library and no BASHPID in any shell, that run fails exactly as reported - the host log records a failed turn with BASHPID: unbound variable and the wake stays unacked; with this change the same run handles and then resumes two real Claude turns without waking main. To avoid relying on the unset BASHPID simulation alone I built a real GNU bash 3.2.0 and used it to drive the library directly (timed spawn works and passes status through, pre-fix dies), to prove the owner-identity invariant the review asked for (fixed library ends the bounded command ~1s after owner death; the naive ${BASHPID:-$$} fallback still ran at 15s), and - via a private mount-namespace bind of /bin/bash - to make the new test's real-stock-bash branch actually fire and pass. Mutating the library to the naive fallback makes the new case fail, so the invariant is pinned. Running the entire product under real 3.2 is blocked before the engine by pre-existing 3.2-incompatible regexes in fm-pr-lib.sh and fm-watch.sh (identical at the base commit), which I report as informational. This change has no UI surface, so the artifacts are CLI transcripts and host-log state rather than screenshots. Worktree left clean and the temporary bash build removed.

  • Live validation: ✅ go - 7 of 8 scenarios driven live against the product
Scenario Result Live Evidence
Reported failure reproduces: with the pre-fix library, a second-mate engine turn on a BASHPID-less shell dies with BASHPID: unbound variable and never reaches Claude ✅ pass live FM_SUPERVISION_HOST_LIVE_E2E=1 BASH_ENV=<unset BASHPID> bash tests/fm-supervision-host-live-e2e.test.sh with bin/fm-timeout-lib.sh temporarily reverted to base commit be03cb5 - live-secondmate-no-ba…
Remote second mate unblocked: on a shell with no BASHPID, the real supervision host drives two real Claude engine turns that handle and resume the away wakes, record the outcome, and never wake main ✅ pass live FM_SUPERVISION_HOST_LIVE_E2E=1 BASH_ENV=<unset BASHPID> bash tests/fm-supervision-host-live-e2e.test.sh - live-secondmate-no-bashpid-FIXED.txt
Stock Bash 3.2 caller under set -u: a timed spawn runs and passes the bounded command's own status and output through ✅ pass live locally built real GNU bash 3.2.0 sourcing bin/fm-timeout-lib.sh and calling fm_exec_timed, base library vs this change - real-bash32-timed-spawn.txt
Stock Bash 3.2, supervision-engine shape: an owner that dies during watchdog startup still ends the 60s-bounded command at once ✅ pass live real bash 3.2.0 driving the fm-supervision-engine-lib.sh:379 shape against this change (ends in ~1s) and against a ${BASHPID:-$$} mutant (still running at 15s) - real-bash32-owner-death.txt
The regression test pins the owner identity: mutating the library to the naive ${BASHPID:-$$} fallback makes the new case fail ✅ pass live bash tests/fm-timeout-lib.test.sh against a mutated copy of the tree - mutant-naive-fallback.txt
On a host whose /bin/bash is bash 3.x, the new test detects it and runs both invariants under that real stock shell ✅ pass live real bash 3.2.0 bind-mounted over /bin/bash inside unshare -m --propagation private, then tests/fm-timeout-lib.test.sh: "ok - both hold under the real stock bash 3 at /bin/bash, the captain's shel…
Boundary: on real 3.2 with no sh on PATH, the fallback still leaves the bound and the command's status intact ✅ pass live real bash 3.2.0, PATH with perl/bash/sleep only: bounded command ran, rc=4 passed through (with an exec: sh: not found stderr line) - real-bash32-no-sh-on-path.txt
End-to-end second-mate turn with every product script running under a real stock bash 3.2 interpreter ⏸️ untested no The run stops before any timed spawn: bin/fm-pr-lib.sh:265 and bin/fm-watch.sh:764 contain [[ =~ ]] alternation that bash 3.2 rejects, so the watcher cycle exits 2. Both lines are identical at the b…
Evidence: Pre-fix: the real second mate's engine turn dies on the Mac-shaped shell

Source: Pre-fix: the real second mate's engine turn dies on the Mac-shaped shell

not ok - the real engine did not handle the away wake (2.1.286 (Claude Code)) --- host log 1790843166 start gen=host-675709-1790843166 primary=claude 1790843170 failed turn=host-675709-1790843166.1 posture=away rc=1 reports=0 unacked=1 2 no-result .../fm/bin/fm-timeout-lib.sh: line 224: BASHPID: unbound variable

not ok - the real engine did not handle the away wake (2.1.286 (Claude Code))
--- host.out
watcher: started pid=676201 (beacon fresh)
--- host log
1790843166	start	gen=host-675709-1790843166	primary=claude
1790843170	failed	turn=host-675709-1790843166.1	posture=away	rc=1	reports=0	unacked=1 2	no-result	/tmp/fm-supervision-host-live.G1Ck7x/fm/bin/fm-timeout-lib.sh: line 224: BASHPID: unbound variable 	signal: /tmp/fm-supervision-host-live.G1Ck7x/fm/state/demo.status
--- queue
1790843168	1	signal	demo.status	signal: /tmp/fm-supervision-host-live.G1Ck7x/fm/state/demo.status
1790843168	2	signal	demo.status	signal: /tmp/fm-supervision-host-live.G1Ck7x/fm/state/demo.status
rc=1
Evidence: This change: real Claude engine turns handle and resume away wakes with no BASHPID

Source: This change: real Claude engine turns handle and resume away wakes with no BASHPID

# first turn: handled turn=host-631968-1790843094.1 posture=away rc=0 # second turn: handled turn=host-631968-1790843094.2 posture=away rc=0 ok - supervision host live (2.1.286 (Claude Code)): a real engine handles and resumes away wakes under the branch contract without waking main

# first turn: handled	turn=host-631968-1790843094.1	posture=away	rc=0
# outcome: {"seq":1,"epoch":1790843107,"task":"demo","wake":"signal: /tmp/fm-supervision-host-live.b01Mv1/fm/state/demo.status","verdict":"captain","summary":"The demo worker reports its cleanup finished and nothing else is needed. No PR is recorded and its workspace is already gone, so I took no action (no merge or dispatch, per your away instructions to merge nothing and dispatch nothing).","silent":false,"statusEndpoint":72,"statusIdent":"strong:64770:1751531:2026-10-01 08:24:55.419597550 +0000"}
# second turn: handled	turn=host-631968-1790843094.2	posture=away	rc=0
ok - supervision host live (2.1.286 (Claude Code)): a real engine handles and resumes away wakes under the branch contract without waking main
rc=0
Evidence: Real bash 3.2.0: pre-fix vs fixed timed spawn under set -u

Source: Real bash 3.2.0: pre-fix vs fixed timed spawn under set -u

# real bash 3.2 (3.2.0(1)-release) ## PRE-FIX library (base commit be03cb5) /tmp/basebug/bin/fm-timeout-lib.sh: line 224: BASHPID: unbound variable rc=92 ## FIXED library (this change) top-level rc=3

# real bash 3.2 (3.2.0(1)-release) - a timed spawn from a stock-bash caller under set -u
## PRE-FIX library (base commit be03cb5)
--- lib=/tmp/basebug
/tmp/basebug/bin/fm-timeout-lib.sh: line 224: BASHPID: unbound variable
rc=92
## FIXED library (this change)
--- lib=~/.no-mistakes/worktrees/7f0ec18181b6/01M3V7F3VZE9ZN95DNKZGTYXT8
top-level
rc=3
(expected: fixed prints 'top-level' and exits 3, the bounded command's own status)
Evidence: Real bash 3.2.0: owner-death detection survives, naive fallback loses it

Source: Real bash 3.2.0: owner-death detection survives, naive fallback loses it

FIXED library (this change): owner died during startup, the watchdog ended the 60s-bounded command after 1s -> owner captured correctly NAIVE ${BASHPID:-$$} fallback: owner died during startup, yet the watchdog was STILL RUNNING after 15s (bound is 60s) -> owner-death detection lost

# real bash 3.2 - supervision-engine shape: a backgrounded subshell calls fm_exec_timed with no named owner and its calling script dies during startup
FIXED library (this change): owner died during startup, the watchdog ended the 60s-bounded command after 1s -> owner captured correctly
NAIVE ${BASHPID:-$$} fallback (the mutant this change must rule out): owner died during startup, yet the watchdog was STILL RUNNING after 15s (bound is 60s) -> owner-death detection lost
Evidence: The new test's real-/bin/bash branch firing on a bash 3.x host

Source: The new test's real-/bin/bash branch firing on a bash 3.x host

/bin/bash is bash 3.2.0(1)-release, BASHPID=[<unset>] ok - without BASHPID fm_exec_timed passes the command's status through and still captures the calling script as owner ok - both hold under the real stock bash 3 at /bin/bash, the captain's shell

# inside a private mount namespace, /bin/bash is now the real stock shell:
  /bin/bash is bash 3.2.0(1)-release, BASHPID=[<unset>]
ok - fm_exec_timed passes a command's status and output through unchanged
_: line 3: 887110 Terminated              "$@"
ok - fm_run_timed reports 124 when the bound TERMs a read whose wrapper recorded 143
ok - fm_run_timed passes a natural exit through when the bound fired after completion
ok - fm_exec_timed sends TERM at the bound and a cooperative command ends there
ok - fm_exec_timed kills a TERM-ignoring command once the grace has passed
ok - fm_exec_timed replaces the calling shell instead of wrapping it
ok - without BASHPID fm_exec_timed passes the command's status through and still captures the calling script as owner
ok - both hold under the real stock bash 3 at /bin/bash, the captain's shell
ok - fm_exec_timed reaps a descendant that would otherwise hold the output past the bound
ok - fm_exec_timed forwards a TERM it receives to the bounded command
ok - fm_exec_timed ends the command when its named owner is already gone
ok - fm_exec_timed ends the command when its owner dies during watchdog startup
ok - fm_exec_timed prefers its perl watchdog over timeout
ok - fm_exec_timed refuses instead of running unbounded when no mechanism exists
ok - fm_exec_timed rejects a zero, padded, non-numeric, or missing bound and a missing command
tests/fm-timeout-lib.test.sh: line 28: 891620 Killed                  ( . "$ROOT/bin/fm-timeout-lib.sh"; PATH=$path fm_exec_timed "$@" )
ok - fm_exec_timed's GNU timeout fallback kills a TERM-ignoring command once the grace has passed
ok - fm_timed_out accepts 124 and 137 and nothing else
Evidence: Mutating the library to ${BASHPID:-$$} fails the new case

Source: Mutating the library to ${BASHPID:-$$} fails the new case

not ok - without BASHPID under /usr/bin/bash, a watchdog whose owner died during startup ran on toward its bound rc=1

ok - fm_exec_timed passes a command's status and output through unchanged
Terminated
ok - fm_run_timed reports 124 when the bound TERMs a read whose wrapper recorded 143
ok - fm_run_timed passes a natural exit through when the bound fired after completion
ok - fm_exec_timed sends TERM at the bound and a cooperative command ends there
ok - fm_exec_timed kills a TERM-ignoring command once the grace has passed
ok - fm_exec_timed replaces the calling shell instead of wrapping it
not ok - without BASHPID under /usr/bin/bash, a watchdog whose owner died during startup ran on toward its bound
rc=1
Evidence: Evidence index with reproduction notes

Source: Evidence index with reproduction notes

# Live validation: fm_exec_timed on stock macOS Bash 3.2 (branch fm/fm-bashpid-fix)

Reported failure: after #10, `bin/fm-timeout-lib.sh` fm_exec_timed read `$BASHPID`,
which macOS's /bin/bash 3.2 does not define, so under `set -u` every timed spawn
on the captain's Mac died and the remote second mate could not run a review turn.

A real GNU bash 3.2.0 (no BASHPID) was built in /tmp from the GNU tarball and used
as the stock interpreter; the 3.2 condition was also reproduced on this host's bash
5.2 via `BASH_ENV` that runs `unset BASHPID` in every non-interactive shell.

| file | what it shows |
| --- | --- |
| live-secondmate-no-bashpid-BASE-pre-fix.txt | the reported breakage, at the product surface: the real supervision host's engine turn logs `failed ... rc=1 ... fm-timeout-lib.sh: line 224: BASHPID: unbound variable`, no Claude turn ever runs, the wake stays unacked |
| live-secondmate-no-bashpid-FIXED.txt | same live run on this change: two real Claude engine turns handle and resume the away wakes, outcome recorded, main never woken |
| real-bash32-timed-spawn.txt | real bash 3.2 caller under `set -u`: pre-fix library dies on `BASHPID: unbound variable`, this change runs the bounded command and passes its status (3) and output through |
| real-bash32-owner-death.txt | real bash 3.2, supervision-engine shape: this change still ends the 60s-bounded command ~1s after its owner dies; the naive `${BASHPID:-$$}` fallback was still running at 15s |
| repo-test-under-real-stock-bin-bash.txt | tests/fm-timeout-lib.test.sh with /bin/bash bind-mounted to the real 3.2 in a private mount namespace: the new case's stock-bash branch fires - "both hold under the real stock bash 3 at /bin/bash, the captain's shell" |
| mutant-naive-fallback.txt | the new test fails (`not ok ... a watchdog whose owner died during startup ran on toward its bound`) when the library is mutated to `${BASHPID:-$$}` |
| timeout-lib-suite.txt | the suite on this host (bash 5.2): new case passes, stock-bash leg prints its skip note |
| real-bash32-no-sh-on-path.txt | boundary: real 3.2 with no `sh` on PATH - the bound and status still hold, but the fallback leaks `exec: sh: not found` and the owner degrades |
| live-secondmate-real-bash32-FIXED.txt | attempt to run the whole product under real 3.2: blocked before the engine by pre-existing 3.2-incompatible regexes in fm-pr-lib.sh:265 and fm-watch.sh:764 (identical at the base commit) |
Evidence: Whole product under real bash 3.2: blocked by pre-existing incompatible regexes

Source: Whole product under real bash 3.2: blocked by pre-existing incompatible regexes

not ok - the host never started a watcher cycle (2.1.286 (Claude Code))
--- host.out
/tmp/fm-supervision-host-live.2Ihcj3/fm/bin/fm-pr-lib.sh: line 265: syntax error in conditional expression: unexpected token `|'
/tmp/fm-supervision-host-live.2Ihcj3/fm/bin/fm-pr-lib.sh: line 265: syntax error near `|^'
/tmp/fm-supervision-host-live.2Ihcj3/fm/bin/fm-pr-lib.sh: line 265: `  [[ "$head" =~ ^[0-9a-f]{40}$|^[0-9a-f]{64}$ ]]'
/tmp/fm-supervision-host-live.2Ihcj3/fm/bin/fm-pr-lib.sh: line 265: syntax error in conditional expression: unexpected token `|'
/tmp/fm-supervision-host-live.2Ihcj3/fm/bin/fm-pr-lib.sh: line 265: syntax error near `|^'
/tmp/fm-supervision-host-live.2Ihcj3/fm/bin/fm-pr-lib.sh: line 265: `  [[ "$head" =~ ^[0-9a-f]{40}$|^[0-9a-f]{64}$ ]]'
/tmp/fm-supervision-host-live.2Ihcj3/fm/bin/fm-pr-lib.sh: line 265: syntax error in conditional expression: unexpected token `|'
/tmp/fm-supervision-host-live.2Ihcj3/fm/bin/fm-pr-lib.sh: line 265: syntax error near `|^'
/tmp/fm-supervision-host-live.2Ihcj3/fm/bin/fm-pr-lib.sh: line 265: `  [[ "$head" =~ ^[0-9a-f]{40}$|^[0-9a-f]{64}$ ]]'
/tmp/fm-supervision-host-live.2Ihcj3/fm/bin/fm-watch.sh: line 764: syntax error in conditional expression: unexpected token `('
/tmp/fm-supervision-host-live.2Ihcj3/fm/bin/fm-watch.sh: line 764: syntax error near `^(0'
/tmp/fm-supervision-host-live.2Ihcj3/fm/bin/fm-watch.sh: line 764: `    [[ $since =~ ^(0|[1-9][0-9]*)$ ]] || return 1'
watcher: FAILED - watcher cycle exited 2 without an actionable reason
--- host log
1790843556	start	gen=host-853428-1790843556	primary=claude
1790843557	pass-through	a close without a wake
--- queue
rc=1
Evidence: Targeted suite on this host, including the stock-bash skip note

Source: Targeted suite on this host, including the stock-bash skip note

ok - fm_exec_timed passes a command's status and output through unchanged
Terminated
ok - fm_run_timed reports 124 when the bound TERMs a read whose wrapper recorded 143
ok - fm_run_timed passes a natural exit through when the bound fired after completion
ok - fm_exec_timed sends TERM at the bound and a cooperative command ends there
ok - fm_exec_timed kills a TERM-ignoring command once the grace has passed
ok - fm_exec_timed replaces the calling shell instead of wrapping it
ok - without BASHPID fm_exec_timed passes the command's status through and still captures the calling script as owner
skip: /bin/bash is not a bash 3.x, so only the BASHPID-unset simulation ran
ok - fm_exec_timed reaps a descendant that would otherwise hold the output past the bound
ok - fm_exec_timed forwards a TERM it receives to the bounded command
ok - fm_exec_timed ends the command when its named owner is already gone
ok - fm_exec_timed ends the command when its owner dies during watchdog startup
ok - fm_exec_timed prefers its perl watchdog over timeout
ok - fm_exec_timed refuses instead of running unbounded when no mechanism exists
ok - fm_exec_timed rejects a zero, padded, non-numeric, or missing bound and a missing command
tests/fm-timeout-lib.test.sh: line 28: 605816 Killed                  ( . "$ROOT/bin/fm-timeout-lib.sh"; PATH=$path fm_exec_timed "$@" )
ok - fm_exec_timed's GNU timeout fallback kills a TERM-ignoring command once the grace has passed
ok - fm_timed_out accepts 124 and 137 and nothing else
Evidence: Boundary: real 3.2 with no sh on PATH

Source: Boundary: real 3.2 with no sh on PATH

# real bash 3.2, fixed library, top-level call whose PATH has perl+bash+sleep but no sh (the fallback's own helper)
~/.no-mistakes/worktrees/7f0ec18181b6/01M3V7F3VZE9ZN95DNKZGTYXT8/bin/fm-timeout-lib.sh: line 225: exec: sh: not found
bounded command ran under a PATH with no sh
rc=4
(the bound itself must still be enforced and the status passed through)
- Outcome: ⚠️ 2 infos across 1 run (14m48s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ⚠️ tests/fm-timeout-lib.test.sh:196 - The new regression test only asserts status/stdout passthrough, so it cannot distinguish the correct fallback from the naive ${BASHPID:-$$} one, and the owner-identity invariant the fix exists to preserve stays unguarded on BASHPID-less shells. Mutate line 225 of bin/fm-timeout-lib.sh to ${BASHPID:-$$} and this test still passes: in the subshell case owner would become $PPID (the calling script's parent) instead of $$, and in the top-level case $PPID as before — both still exit 7 and print top-level. That wrong owner is load-bearing in production: bin/fm-supervision-engine-lib.sh:379 calls fm_exec_timed from a backgrounded subshell with no named owner, so on a bash-3.2 host the perl watchdog would poll a grandparent that is still alive while $parent was already captured as the reparented pid — the exact startup-death case test_an_owner_that_dies_during_startup_ends_the_command guards — and an agent turn whose owner died would run to its full $timeout instead of ending. Remedy (test-only): in the BASHPID-less case, assert the captured owner, e.g. reuse the test_an_owner_that_dies_during_startup_ends_the_command shape with BASHPID unset, so a one-generation-off owner fails here.
  • ℹ️ tests/fm-timeout-lib.test.sh:175 - The 3.2 shell is simulated by unset BASHPID on the ambient bash (5.x in CI), so nothing executes fm_exec_timed under a real stock /bin/bash. That is the gap the feat(bin): sync the fork with upstream firstmate main and reconcile the zcode harness #10 sync slipped through: the macos-stock-bash lane (.github/workflows/ci.yml:412) only runs a /bin/bash -n parse sweep plus the snapshot, bearings, public-followup, watch-triage, fork-free and backend cases, and I confirmed none of those reach fm_exec_timed (fm-fleet-snapshot-view/fm-bearings-snapshot never call it; fm_tasks_axi only takes the bounded path when FM_TASKS_AXI_TIMEOUT is set). The repo's stated convention for this failure class is real-interpreter execution — tests/fm-brief.test.sh:15 ("the real cross-version enforcement lives in the macos-stock-bash CI job"), tests/fm-fork-free-helpers.test.sh's test_interpreters sweep over /bin/bash, and test_first_register_succeeds_with_empty_lock_list_under_bash32 — so the simulation leaves any other 3.2-only runtime behavior of this path unguarded. Flagged as ask-user because the remedy extends past this change: it needs a macos-stock-bash lane step plus handling the pre-existing bare $BASHPID reads in this same file (tests/fm-timeout-lib.test.sh:112 and :240, which would themselves crash under 3.2 with set -u) and sh missing from $PERL_ONLY. A narrower in-scope version is to run just the new case under /bin/bash when it exists.

🔧 Fix applied.
1 info still open:

  • ℹ️ tests/fm-timeout-lib.test.sh:21 - $PERL_ONLY links only perl, bash and sleep, but fm_exec_timed's new BASHPID-less fallback at bin/fm-timeout-lib.sh:225 now needs sh. On the stock bash-3.2 Mac this change targets, exec sh fails under that PATH, the substitution yields the empty string, and the owner comparison always takes the not-equal branch — so the new code is never actually exercised there. All five PERL_ONLY cases still pass, but only by coincidence: test_the_bound_replaces_the_calling_shell, test_a_named_owner_that_is_gone_ends_the_command, test_an_owner_that_dies_during_startup_ends_the_command, test_perl_is_preferred_over_timeout and test_refuses_rather_than_running_unbounded are all subshell or named-owner shapes where leaving owner unchanged happens to be the correct answer. The failed exec also leaks sh: command not found into test_refuses_rather_than_running_unbounded's 2&gt;&amp;1 capture at line 342. Remedy: add sh to the tool loop on line 21, exactly as the new test's own PATH already does at line 242. Non-functional and test-only.
⚠️ **Test** - 2 infos
  • ℹ️ bin/fm-pr-lib.sh:265 - Running the whole product under a real stock bash 3.2 cannot get as far as the engine turn: bin/fm-pr-lib.sh:265 and bin/fm-watch.sh:764 use [[ =~ ]] alternation that bash 3.2 rejects, so the watcher cycle exits 2 before any timed spawn happens. Both lines are byte-identical at the base commit, so this is pre-existing and not caused by this change - but it means a captain's Mac running stock /bin/bash for the watcher path would still be blocked after this fix. Captured in live-secondmate-real-bash32-FIXED.txt.
  • ℹ️ bin/fm-timeout-lib.sh:225 - The new fallback shells out to sh unqualified. On a real bash 3.2 with a PATH that lacks sh (the shape tests/fm-timeout-lib.test.sh's PERL_ONLY has), the bound and the command's status still hold, but fm-timeout-lib.sh: line 225: exec: sh: not found leaks to the caller's stderr and a top-level call's owner silently degrades to the pid the watchdog itself takes over, losing owner-death detection. No production caller restricts PATH this way, so this is a boundary note rather than a reachable defect. Captured in real-bash32-no-sh-on-path.txt.
  • Live validation: ✅ go - 7 of 8 scenarios driven live against the product
Scenario Result Live Evidence
Reported failure reproduces: with the pre-fix library, a second-mate engine turn on a BASHPID-less shell dies with BASHPID: unbound variable and never reaches Claude ✅ pass live FM_SUPERVISION_HOST_LIVE_E2E=1 BASH_ENV=&lt;unset BASHPID&gt; bash tests/fm-supervision-host-live-e2e.test.sh with bin/fm-timeout-lib.sh temporarily reverted to base commit be03cb5 - live-secondmate-no-ba…
Remote second mate unblocked: on a shell with no BASHPID, the real supervision host drives two real Claude engine turns that handle and resume the away wakes, record the outcome, and never wake main ✅ pass live FM_SUPERVISION_HOST_LIVE_E2E=1 BASH_ENV=&lt;unset BASHPID&gt; bash tests/fm-supervision-host-live-e2e.test.sh - live-secondmate-no-bashpid-FIXED.txt
Stock Bash 3.2 caller under set -u: a timed spawn runs and passes the bounded command's own status and output through ✅ pass live locally built real GNU bash 3.2.0 sourcing bin/fm-timeout-lib.sh and calling fm_exec_timed, base library vs this change - real-bash32-timed-spawn.txt
Stock Bash 3.2, supervision-engine shape: an owner that dies during watchdog startup still ends the 60s-bounded command at once ✅ pass live real bash 3.2.0 driving the fm-supervision-engine-lib.sh:379 shape against this change (ends in ~1s) and against a ${BASHPID:-$$} mutant (still running at 15s) - real-bash32-owner-death.txt
The regression test pins the owner identity: mutating the library to the naive ${BASHPID:-$$} fallback makes the new case fail ✅ pass live bash tests/fm-timeout-lib.test.sh against a mutated copy of the tree - mutant-naive-fallback.txt
On a host whose /bin/bash is bash 3.x, the new test detects it and runs both invariants under that real stock shell ✅ pass live real bash 3.2.0 bind-mounted over /bin/bash inside unshare -m --propagation private, then tests/fm-timeout-lib.test.sh: "ok - both hold under the real stock bash 3 at /bin/bash, the captain's shel…
Boundary: on real 3.2 with no sh on PATH, the fallback still leaves the bound and the command's status intact ✅ pass live real bash 3.2.0, PATH with perl/bash/sleep only: bounded command ran, rc=4 passed through (with an exec: sh: not found stderr line) - real-bash32-no-sh-on-path.txt
End-to-end second-mate turn with every product script running under a real stock bash 3.2 interpreter ⏸️ untested no The run stops before any timed spawn: bin/fm-pr-lib.sh:265 and bin/fm-watch.sh:764 contain [[ =~ ]] alternation that bash 3.2 rejects, so the watcher cycle exits 2. Both lines are identical at the b…
  • FM_SUPERVISION_HOST_LIVE_E2E=1 BASH_ENV=&lt;unset BASHPID&gt; bash tests/fm-supervision-host-live-e2e.test.sh on this change (two real Claude engine turns)
  • same live guard with bin/fm-timeout-lib.sh reverted to the base commit, to reproduce the reported failure (worktree restored immediately after)
  • bash tests/fm-timeout-lib.test.sh on this host (bash 5.2)
  • tests/fm-timeout-lib.test.sh with /bin/bash bind-mounted to a locally built real bash 3.2.0 inside unshare -m --propagation private, exercising the new case's stock-bash branch
  • real bash 3.2.0 driving fm_exec_timed 5 1 bash -c &#39;echo top-level; exit 3&#39; under set -u against both the base and the fixed library
  • real bash 3.2.0 driving the fm-supervision-engine-lib.sh:379 shape (backgrounded subshell, no named owner, calling script dies during startup) against the fixed library and against a ${BASHPID:-$$} mutant
  • bash tests/fm-timeout-lib.test.sh against a copy of the tree whose library was mutated to ${BASHPID:-$$}
  • real bash 3.2.0 top-level fm_exec_timed under a PATH holding perl/bash/sleep but no sh
  • PATH=&lt;bash 3.2 shim&gt; FM_SUPERVISION_HOST_LIVE_E2E=1 bash tests/fm-supervision-host-live-e2e.test.sh (whole product under real 3.2)
⚠️ **Document** - 1 info
  • ℹ️ CONTRIBUTING.md:130 - The new BASHPID-less case runs under a real stock /bin/bash only when the host happens to have bash 3.x, so on Linux CI it silently prints a skip and only the BASHPID-unset simulation executes. The macos-stock-bash lane (.github/workflows/ci.yml:412) still does not reach fm_exec_timed, and the user explicitly scoped CI lane changes out of this change, so no documentation surface can truthfully claim fm_exec_timed's 3.2 guarantee is CI-enforced. I deliberately did not create a docs/verification/ record for it: the placement policy forbids opening a new documentation surface to close a perceived gap, and a verification record must state a currently-enforced empirical fact. Follow-up worth doing separately: add the fm-timeout-lib BASHPID case to the macos-stock-bash lane (which also needs sh in $PERL_ONLY and the remaining bare $BASHPID reads at tests/fm-timeout-lib.test.sh:112 and :240 made 3.2-safe), then record the dated stock-bash evidence in its classified maintainer-verification owner.
✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

Stock macOS Bash 3.2 has no BASHPID, so under set -u the owner capture
in fm_exec_timed aborted every timed command. Fall back to the frame pid
a child shell reports, which keeps the owner identical to Bash >= 4 both
in a subshell and at top level.
@d-ploutarchos
d-ploutarchos merged commit 9bc7f86 into main Oct 1, 2026
40 of 41 checks passed
d-ploutarchos added a commit that referenced this pull request Oct 1, 2026
Records OK-LG/firstmate main (9bc7f86) as a parent so this branch merges
cleanly, while keeping this branch's tree unchanged: upstream main 8f756bb
plus the cherry-picked Bash 3.2 owner-capture fix (#11). The fork-only
zcode adapter (#1-#10) is intentionally dropped; #10's upstream content is
already contained in upstream main.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant