fix(bin): keep fm_exec_timed's owner capture working on Bash 3.2 - #11
Merged
Merged
Conversation
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.
…pture requirement
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_timedno longer reads$BASHPIDunguarded when deciding whether the captured owner is its own frame. On a shell withoutBASHPID(stock macOS Bash 3.2), where the unguarded read aborted every timed spawn underset -u, it now falls back toexec sh -c 'printf "%s\n" "$PPID"'so a forked child reports the frame's pid instead.PATHon such a shell must leaveshreachable on it.test_runs_without_bashpid_under_set_utotests/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) withBASHPIDunset, and repeating both against a real/bin/bash3.x when the host has one. Two existing tests that read$BASHPIDdirectly now derive the frame pid the same forked-child way, andshwas added to the sandboxedPATHthe 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 variableand 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 theunset BASHPIDsimulation 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.BASHPID: unbound variableand never reaches ClaudeFM_SUPERVISION_HOST_LIVE_E2E=1 BASH_ENV=<unset BASHPID> bash tests/fm-supervision-host-live-e2e.test.shwith bin/fm-timeout-lib.sh temporarily reverted to base commit be03cb5 - live-secondmate-no-ba…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${BASHPID:-$$}mutant (still running at 15s) - real-bash32-owner-death.txt${BASHPID:-$$}fallback makes the new case failbash tests/fm-timeout-lib.test.shagainst a mutated copy of the tree - mutant-naive-fallback.txtunshare -m --propagation private, thentests/fm-timeout-lib.test.sh: "ok - both hold under the real stock bash 3 at /bin/bash, the captain's shel…shon PATH, the fallback still leaves the bound and the command's status intactexec: sh: not foundstderr line) - real-bash32-no-sh-on-path.txt[[ =~ ]]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 variableEvidence: 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 mainEvidence: 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=3Evidence: 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 lostEvidence: 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 shellEvidence: 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=1Evidence: Evidence index with reproduction notes
Source: Evidence index with reproduction notes
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
Evidence: Targeted suite on this host, including the stock-bash skip note
Source: Targeted suite on this host, including the stock-bash skip note
Evidence: Boundary: real 3.2 with no sh on PATH
Source: Boundary: real 3.2 with no sh on PATH
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
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 caseownerwould become$PPID(the calling script's parent) instead of$$, and in the top-level case$PPIDas before — both still exit 7 and printtop-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$parentwas 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$timeoutinstead 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 byunset BASHPIDon 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 -nparse 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$BASHPIDreads in this same file (tests/fm-timeout-lib.test.sh:112 and :240, which would themselves crash under 3.2 with set -u) andshmissing 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 needssh. On the stock bash-3.2 Mac this change targets,exec shfails 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 leavingownerunchanged happens to be the correct answer. The failed exec also leakssh: command not foundinto test_refuses_rather_than_running_unbounded's2>&1capture at line 342. Remedy: addshto the tool loop on line 21, exactly as the new test's own PATH already does at line 242. Non-functional and test-only.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 toshunqualified. 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, butfm-timeout-lib.sh: line 225: exec: sh: not foundleaks 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.BASHPID: unbound variableand never reaches ClaudeFM_SUPERVISION_HOST_LIVE_E2E=1 BASH_ENV=<unset BASHPID> bash tests/fm-supervision-host-live-e2e.test.shwith bin/fm-timeout-lib.sh temporarily reverted to base commit be03cb5 - live-secondmate-no-ba…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${BASHPID:-$$}mutant (still running at 15s) - real-bash32-owner-death.txt${BASHPID:-$$}fallback makes the new case failbash tests/fm-timeout-lib.test.shagainst a mutated copy of the tree - mutant-naive-fallback.txtunshare -m --propagation private, thentests/fm-timeout-lib.test.sh: "ok - both hold under the real stock bash 3 at /bin/bash, the captain's shel…shon PATH, the fallback still leaves the bound and the command's status intactexec: sh: not foundstderr line) - real-bash32-no-sh-on-path.txt[[ =~ ]]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=<unset BASHPID> bash tests/fm-supervision-host-live-e2e.test.shon 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.shon this host (bash 5.2)tests/fm-timeout-lib.test.shwith /bin/bash bind-mounted to a locally built real bash 3.2.0 insideunshare -m --propagation private, exercising the new case's stock-bash branchreal bash 3.2.0 drivingfm_exec_timed 5 1 bash -c 'echo top-level; exit 3'underset -uagainst both the base and the fixed libraryreal 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:-$$}mutantbash tests/fm-timeout-lib.test.shagainst a copy of the tree whose library was mutated to${BASHPID:-$$}real bash 3.2.0 top-levelfm_exec_timedunder a PATH holding perl/bash/sleep but no shPATH=<bash 3.2 shim> FM_SUPERVISION_HOST_LIVE_E2E=1 bash tests/fm-supervision-host-live-e2e.test.sh(whole product under real 3.2)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 needsshin $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.