fix(spawn): give every launched worker its own FM_HOME - #81
Merged
Conversation
fm-spawn told only a --secondmate launch which firstmate home it belonged to. An ordinary crewmate or scout started with FM_HOME unset, so an operator shell profile that derives per-vessel values from it resolved them from its own default. On a machine carrying two firstmate homes under one OS account that default is another vessel entirely, and workers of this home published Bridge mail out of the other home's clone: the envelopes' own from field said this vessel while the commits carrying them were authored by the other. Two places now carry the home, and the second is not a copy of the first. The launch command gets an FM_HOME= prefix for every kind, not just secondmate. That reaches the agent process on every backend, which is what makes fm-send, the findings surface, and every other FM_HOME-resolving script answer for this home. It is not enough on its own, and this was measured rather than assumed. A profile runs once, when the task's shell starts, and nothing re-derives it afterwards; firstmate sends the launch command after that shell already exists. So the tmux adapter now also seeds FM_HOME with `new-window -e`, which tmux applies before the shell starts, and the existing derivation comes out right with firstmate naming no variable but its own. herdr, zellij, Orca and cmux create a task through CLIs with no environment seam, so there a worker learns its home from the launch command only; docs/tmux-backend.md records that limit, the measurement, and the negative control that shows why the prefix alone cannot do it. The overrides stay cleared for a secondmate only, unchanged.
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
Make a firstmate-launched worker run as its own vessel, so its Bridge mail is not composed and published from another seat's clone.
Measured defect: two firstmate homes share one OS account on this machine (/home/captain/sc1-firstmate, vessel sc1; /home/captain/firstmate-upstream, vessel tugboat). The operator's ~/.bashrc derives the Bridge seat from FM_HOME and deliberately defaults to sc1 when FM_HOME is unset. That file is the operator's own and is NOT to be changed - explicitly out of scope. The bug is on firstmate's side: bin/fm-spawn.sh exported FM_HOME into the launched pane only for KIND=secondmate, so an ordinary crewmate or scout started with FM_HOME unset, the profile resolved the seat to sc1, and every bridge-*.sh command that worker ran read and wrote the staff captain's clone. Evidence: an envelope whose own from field said tugboat was carried by a commit authored 'sc1 sc1@fleet.local' out of sc1's clone; our own clone's git identity was already correct (tugboat), which is why a git-config change would fix nothing. This matters because the fleet's approval chain authenticates an envelope by its commit author.
Decisions made while doing the work, which a reviewer reading only the diff would not know:
The originally-specified fix (widen the secondmate-only FM_HOME= launch-command prefix to every kind) was implemented AND then measured to be necessary but NOT sufficient. ~/.bashrc returns early for non-interactive shells, and the seat variables are exported once by the pane's interactive shell - which fm-spawn starts BEFORE it sends the launch command. Every later non-interactive shell inherits the stale value rather than recomputing it. Measured: 'FM_HOME= bash -c echo $BRIDGE_VESSEL' still printed sc1, and with BRIDGE_VESSEL scrubbed it printed nothing at all, proving the derivation never re-runs. An earlier proof of mine that used 'bash -ic' was invalid because it forced interactivity real tool shells never have.
Therefore the fix is deliberately in two places, and the second is not a redundant copy of the first: (a) the launch-command FM_HOME= prefix for every kind, which reaches the agent process on every backend and is what makes fm-send, the findings surface and other FM_HOME-resolving scripts answer for this home; (b) tmux new-window -e FM_HOME=, which tmux applies before the window's shell starts, so the existing profile derivation comes out right. Verified on real tmux 3.6: a window created with -e printed seat=tugboat.
Layering was chosen deliberately. Firstmate names ONLY its own variable FM_HOME and never BRIDGE_VESSEL/BRIDGE_ROOT or any other profile-derived name; re-deriving another tool's variables was considered and rejected as a wrong-layer fix.
Known and deliberately-accepted limit, recorded rather than hidden: only tmux has a pre-shell environment seam. herdr, zellij, Orca and cmux create a task through CLIs taking a label and cwd and no environment, so on those a worker learns its home from the launch command only and a profile-derived value is still fixed before firstmate can influence it. docs/tmux-backend.md records this, the measurement, the negative control, and the tmux 3.0 floor for -e. This also only affects newly spawned workers; nothing re-derives for one already running.
AXI_LAUNCH_HOME was renamed to LAUNCH_HOME and hoisted to before task-surface creation, because it now serves both the AXI bin path and the pane environment and must exist earlier than it used to. Its per-kind value is unchanged (this process's FM_HOME for crew/scout, PROJ_ABS for --secondmate). The secondmate branch's clearing of FM_ROOT/STATE/DATA/PROJECTS/CONFIG_OVERRIDE is deliberately left exactly as it was; ordinary crew must NOT clear those.
The five exact-equality launch-line assertions in tests/fm-spawn-dispatch-profile.test.sh were updated to carry the new FM_HOME=' ' prefix; that prefix appearing on the raw launch-command escape hatch is intended, not an accident. Two new tests pin the change: an ordinary crew launch names its own home both in the created window's environment and on the launch command, and a secondmate window is created in the SECONDMATE's home rather than the primary's.
Checked and confirmed safe rather than assumed: no path assumes FM_HOME is unset inside a task worktree. bin/fm-test-run.sh already unsets FM_HOME and the FM_*_OVERRIDE vars per test script; every FM_HOME-sensitive tracked hook is gated by fm_primary_scope_matches, which refuses a linked task worktree before FM_HOME is consulted, and fm-spawn asserts the worktree is linked; bin/fm-send.sh fails closed WITHOUT FM_HOME, so inheriting it only removes a failure mode.
Repo constraints: this is firstmate's own shared tracked material, so firstmate-coding-guidelines applies - one sentence per line in tracked Markdown, plain dash never an em dash, shellcheck-clean bin scripts, tests colocated in tests/ extending the existing suite rather than a new runner, and backend-verification docs recording exact commands, versions and output. No agent co-author on commits.
Pre-existing and unrelated: tests/fm-backend-tmux-smoke.test.sh fails on this machine with 'the tmux task shell did not become ready'. It was verified to fail identically against a pristine extract of the base commit cb55c08, so it is not caused by this change and was deliberately not chased.
What Changed
bin/fm-spawn.shnow prefixesFM_HOME=<home>onto the launch command for every spawn kind, not just--secondmate, so an ordinary crewmate or scout resolves this home's state, findings surface andfm-sendtargets instead of falling back to another home's clone.AXI_LAUNCH_HOMEwas renamed toLAUNCH_HOMEand hoisted above task-surface creation so both the AXI bin path and the pane environment can use it; its per-kind value (this process'sFM_HOME, orPROJ_ABSfor a secondmate) and the secondmate-only clearing ofFM_ROOT/STATE/DATA/PROJECTS/CONFIG_OVERRIDEare unchanged.fm_backend_tmux_create_tasktakes an optional<fm-home>and passes it asnew-window -e FM_HOME=..., which tmux applies before the window's shell starts - the launch-command prefix alone arrives after a shell profile has already derived its per-vessel values. The flag is omitted entirely when no home is given.tests/fm-spawn-dispatch-profile.test.shnow carry theFM_HOME=prefix, plus two new cases pinning that an ordinary crew launch names its own home in both the window environment and the launch command, and that a secondmate window is created in the secondmate's home.docs/tmux-backend.mdrecords the measurement, the negative control, the tmux 3.0 floor for-e, and the accepted limit that herdr, zellij, Orca and cmux have no pre-shell environment seam;docs/configuration.mdnotes the per-worker inheritance.Risk Assessment
✅ Low: The change is tightly bounded to two well-understood seams (an env prefix on the launch command and an optional tmux
new-window -e), preserves the secondmate path byte-for-byte, is covered by two new tests plus five updated exact-equality assertions, and the surrounding invariants (hook scope gate, test-runner env scrubbing, fm-send fail-closed, other tmux argv assertions) were each checked and hold.Testing
Ran the targeted spawn/backend suites (fm-spawn-dispatch-profile with its two new tests, fm-backend, fm-spawn-batch, fm-spawn-worktree-settle, fm-secondmate-harness, fm-backend-orca, fm-backend-herdr) - all pass - and then proved the intent end-to-end on a real tmux 3.6 server: with a sandbox profile mirroring the operator's FM_HOME-derived Bridge seat, a crewmate belonging to the tugboat home publishes as vessel=sc1 from sc1's clone on base cb55c08, still publishes as sc1 when only the launch-command FM_HOME prefix is added, and publishes as vessel=tugboat from tugboat's clone on the target commit; a second transcript shows the real fm-spawn issuing bothnew-window -e FM_HOME=<home>and theFM_HOME='<home>'launch prefix, with the secondmate case naming the secondmate's home in both places. This is a shell/tmux change with no rendered UI surface, so the reviewer-visible artifacts are CLI transcripts rather than screenshots. tests/fm-backend-tmux-smoke.test.sh fails, but reproduces identically against a pristine extract of base cb55c08, so it is pre-existing and unrelated; the working tree is clean and transient sandbox directories were removed.Evidence: Real tmux: worker seat before vs after (base / prefix-only / target)
# tmux 3.6 on Linux 6.18.33.2-microsoft-standard-WSL2 # one OS account, two firstmate homes: .../sc1-firstmate -> vessel sc1 (profile default when FM_HOME is unset), .../firstmate-upstream -> vessel tugboat # in every case below, firstmate is spawning a crewmate that belongs to the TUGBOAT home. === A-base-cb55c08 === agent process FM_HOME : <unset> bridge-.sh publishes: vessel=sc1 clone=.../sc1-firstmate/projects/coditan-bridge === B-base-plus-launch-prefix-only === agent process FM_HOME : .../firstmate-upstream bridge-.sh publishes: vessel=sc1 clone=.../sc1-firstmate/projects/coditan-bridge === C-target-25e06b5 === agent process FM_HOME : .../firstmate-upstream bridge-*.sh publishes: vessel=tugboat clone=.../firstmate-upstream/projects/coditan-bridgeEvidence: Real fm-spawn.sh: the tmux commands issued for a crewmate and a secondmate
=== ordinary-crewmate === this firstmate home : /tmp/.../ordinary-crewmate/home window created with : tmux new-window -dP -F #{window_id} -t firstmate: -n fm-crew-t1 -c /tmp/.../project -e FM_HOME=/tmp/.../ordinary-crewmate/home launch command sent : FM_HOME='/tmp/.../ordinary-crewmate/home' CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions --settings '...' === secondmate === this firstmate home : /tmp/.../secondmate/home secondmate home : /tmp/.../secondmate/secondmate-home window created with : tmux new-window -dP -F #{window_id} -t firstmate: -n fm-second-t2 -c /tmp/.../secondmate-home -e FM_HOME=/tmp/.../secondmate/secondmate-home launch command sent : FM_ROOT_OVERRIDE= FM_STATE_OVERRIDE= FM_DATA_OVERRIDE= FM_PROJECTS_OVERRIDE= FM_CONFIG_OVERRIDE= FM_HOME='/tmp/.../secondmate/secondmate-home' ... claude --dangerously-skip-permissions ...Evidence: Reproducer: real-tmux seat E2E (base vs prefix-only vs target)
Evidence: Reproducer: fm-spawn tmux command transcript
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
bin/fm-spawn.sh:927-HERDR_LABEL_HOME(lines 927-930) now computes exactly whatLAUNCH_HOMEalready holds from line 863: this process'sFM_HOME, orPROJ_ABSwhenKIND=secondmate. Two independent copies of the same per-kind rule can drift apart on the next change to it. Collapse toHERDR_LABEL_HOME=$LAUNCH_HOME, or useLAUNCH_HOMEdirectly at lines 940/949/977/988 and drop the variable.bin/backends/tmux.sh:126- Every tmux spawn now passes-e, which requires tmux >= 3.0 (recorded in docs/tmux-backend.md:209 but not gated in code). On an older tmuxnew-windowaborts with its own usage error,fm_backend_tmux_create_taskreturns 1 and fm-spawn exits 1 with no firstmate-authored message pointing at the cause - unlike the herdr adapter, which refuses loudly throughfm_backend_herdr_version_check. Failing closed is defensible (silently dropping-ewould restore the wrong-seat bug), so this is noted rather than recommended for change; a one-line message naming the tmux 3.0 requirement on the failure path would make the diagnosis obvious.docs/tmux-backend.md:210- The accepted limitation for herdr, zellij, Orca and cmux - a worker there learns its home only from the launch command, so a profile-derived seat in its shell is still fixed before firstmate can influence it - is recorded only in the tmux backend document. docs/herdr-backend.md ("Known gaps and follow-up notes") and docs/zellij-backend.md ("Known gaps left for a follow-up") already exist as the per-backend homes for exactly this kind of fact, and an operator reading those will not find it. A one-line pointer back to this section in each would make the recorded limit discoverable where it applies.tests/fm-backend-tmux-smoke.test.sh- tests/fm-backend-tmux-smoke.test.sh fails on this machine, but at a different point than the intent records: it fails at "could not make agent-fallback the session's current window" (tmux reports "can't find window: agent-fallback"), not "the tmux task shell did not become ready". I confirmed it is pre-existing by running the same script against a pristinegit archive cb55c08extract, where it fails identically at the same assertion; the change's own create_task assertions in that file still pass. No action needed for this change../bin/fm-test-run.sh tests/fm-spawn-dispatch-profile.test.sh- full file including the two new tests (test_ordinary_crew_launch_names_its_own_home,test_secondmate_window_is_created_in_its_own_home) and the five updated exact-equality launch-line assertions./bin/fm-test-run.sh tests/fm-backend.test.sh tests/fm-spawn-batch.test.sh tests/fm-spawn-worktree-settle.test.sh- backend dispatch and spawn paths around the edited code./bin/fm-test-run.sh tests/fm-secondmate-harness.test.sh tests/fm-backend-orca.test.sh tests/fm-backend-herdr.test.sh- the other suites that assert launch commands or non-tmux backendsManual real-tmux E2E (/tmp/no-mistakes-evidence/01KZHMCYP25C7JMDJEAAX8D5ST/seat-e2e.sh): sourced the realfm_backend_tmux_create_taskfrom basecb55c08and from the target commit against private tmux servers (tmux 3.6), each spawning a window for the tugboat home under a sandbox HOME whose profile derives the seat from FM_HOME and defaults to sc1, then ran a bridge-style command in a non-interactive shell inside the paneManual negative control in the same run: basecreate_taskplus theFM_HOME='<home>'launch-command prefix alone, showing the profile-derived seat stays sc1Manual spawn transcript (/tmp/no-mistakes-evidence/01KZHMCYP25C7JMDJEAAX8D5ST/spawn-transcript.sh): drove the realbin/fm-spawn.shbehind a recording fake tmux for an ordinary crewmate and for a--secondmatespawn, printing the literalnew-windowand launch commands issuedgit archive cb55c08 | tar -x -C /tmp/fm-base-check && ./bin/fm-test-run.sh tests/fm-backend-tmux-smoke.test.sh- confirmed the tmux smoke failure is pre-existing and identical at basebin/fm-spawn.sh:927- bin/fm-spawn.sh now derives the same per-kind home twice: LAUNCH_HOME (line 863) and the herdr arm's HERDR_LABEL_HOME (line 927), with identical rules. docs/herdr-backend.md:105 documents HERDR_LABEL_HOME as its own computation. Collapsing the herdr arm onto LAUNCH_HOME and reducing that doc line to name the shared variable would remove the duplicate, but that is a code change outside this documentation phase.✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.