feat: let /updatefirstmate self-update from a configured fork remote - #5832
Courtneyezra wants to merge 6 commits into
Conversation
Investigation and hand-over notesThe pipeline owns the PR description, so the analysis this change rests on is recorded here instead. Nothing below is a request to change the diff. Why this existsMake it possible to run a Firstmate fleet from its own fork, and establish the facts that decision rests on before anything is re-pointed.
What changed
Two deliberate refusalsA configured remote that a target repo does not define is reported as Fast-forward-only is unchanged everywhere. Pointing a home at a remote whose default branch is not already a descendant of that home's commit is a divergence, and the update skips and records it rather than forcing, merging, or stashing. The remote-route gap this foundA remote route's code-root update runs with Evidence
Also green locally: A pre-existing flake this ran into, classified rather than assumed
Identical, so the failure is not caused by this change. The machine was at load average ~19 from three sibling test runs; the same contention made Worth filing separately, not fixed here: that wait is an iteration count while the work ahead of it grows with the inheritance list, so it silently tightens every time an item is added. A wall-clock bound would stop it degrading. Left out deliberately to keep this PR the change it says it is. The three mains, measuredMeasured 26 Sep 2026 after fetching both remotes.
The fork's 35 commits are not separate work. Our 19 commits are genuinely separate, established two independent ways because commit identity alone is not trustworthy here:
The captain's warning about identity comparison is real and reproducible on this repo: What this does not change
The re-point, when the captain wants itEvery step below is proved by ancestry already measured; none of it is run by this PR. The order matters. 1. Land
Nothing about that lane's nine hours needs redoing. 2. Publish it to the fork, then 3. set 4. Four homes must agree, and three of them are confirmed today.
The remote host was deliberately not inspected from this task. Without that remote its update refuses with 5. The stranded contributions land on the fork and stay open upstream. All five are cross-repo PRs from
Each lands by its own pull request onto the fork's main, once the fork's main is the merged base. The upstream requests are separate objects and stay open and offered. There were four when this task was written; 5829 opened while it ran, which is the cost the decision was made to stop paying. The ongoing cost this createsFollowing the fork means upstream's work reaches the fleet only when the fork is synced forward from upstream. Upstream moved 91 commits in four days by the reconcile lane's own measurement, so that sync is a recurring step, not a one-off. This PR deliberately does not automate it: doing so would be guessing at a policy the captain has not set. One thing landing on the fork will hit, found while opening this PR
Two things follow for landing the stranded fixes on the fork, and they pull in opposite directions:
Whichever way that goes, it is a deliberate choice: enable Actions on the fork and add This PR itself is subject to the same gate upstream: it was raised direct-PR per its instructions, so Corrections to the above, established after it was first writtenTwo things in the section above were measured again and changed:
|
8520535 to
9ca4e01
Compare
9ca4e01 to
a919306
Compare
/updatefirstmate has always fast-forwarded from a remote literally named origin, so a fleet that runs from its own fork had no way to say so. The remote name is now resolved from this home's config/update-remote, which is absent by default and means origin, so nothing changes for a home that never creates the file. bin/fm-ff-lib.sh's fm_update_remote is the single owner of that resolution. It reads $FM_HOME rather than each target checkout, because the point of the setting is that the primary and every secondmate home it advances follow the SAME remote; config/update-remote joins the inherited configuration list so the primary's answer converges the fleet instead of each home drifting onto its own remote. A configured remote a target repo does not define is reported as a skip naming that remote. It is deliberately not retried against origin: landing a home on the very main the setting exists to move it off is worse than not updating it at all. A remote route needed one extra hop. Its code-root update runs with FM_HOME pointed at the code root, while the inherited copy of the setting lands in that host's secondmate home, so fm-remote-secondmate-control.sh now resolves the home's answer and passes it to the code-root update as FM_UPDATE_REMOTE. Fast-forward-only is unchanged on every path: a remote whose default branch does not already contain the home's commit is a divergence, and the update skips and records it rather than forcing or merging.
…pe default-branch remote preference
…d origin exception
a919306 to
372e5e3
Compare
Intent
Make it possible to run a Firstmate fleet from its own fork, as part of the 26 Sep 2026 decision to stop depending on upstream merges after four fixes sat open upstream unmergeable by this fleet and one of them was re-diagnosed and re-filed as a new fault because a submitted fix is invisible to the fleet.
/updatefirstmate has always fast-forwarded from a remote literally named origin, so a fleet running from its own fork had no way to say so. Add config/update-remote, absent or blank meaning origin, so nothing changes for any home that never creates the file.
Deliberate decisions a reviewer reading only the diff would not know:
Deliberately NOT included, and not oversights:
Tests: tests/fm-update.test.sh gains three cases - a fork and an origin advanced to different commits where the primary and its secondmate must both land on the fork's tip and not origin's, with the fixture asserted non-vacuous and the advance asserted single-parent; a configured remote the repo does not define being skipped by name with nothing moved and no origin fallback, compared against the real bare repo because a refused update never fetches; and a whitespace-only file resolving to origin. The whole file is green at 19 of 19, so the sixteen pre-existing cases prove the unconfigured path is untouched.
What Changed
config/update-remote.fm_update_remoteinbin/fm-ff-lib.shreads it from$FM_HOME, and an absent or blank file meansorigin./updatefirstmate(bin/fm-update.sh) now fetches and fast-forwards the primary and every secondmate home from that remote. If a target repo doesn't define the configured remote, the update skips it by name and doesn't fall back toorigin. The setting changes only the self-update base. It doesn't change where branches are pushed or PRs are opened, and no remote is renamed or re-pointed. Otherbin/fm-ff-lib.shchanges:default_branchnow checks the configured remote's recorded HEAD first, thenorigin's, then a localmain/master.fetch_oncecaches per remote as well as per object store.update-remoteis added toFM_INHERITABLE_CONFIG, so secondmate homes pick up the primary's setting.fm-remote-inherit-push.shaccepts an optional single config item, andfm-update.shuses it to push onlyconfig/update-remoteto each remote route before asking that route to update.origin.fm-remote-secondmate-control.shreads the setting from the secondmate home and passes it to the code-root update throughFM_UPDATE_REMOTE, an override meant only for that caller and for tests.docs/configuration.md,docs/architecture.mdand the updatefirstmate, secondmate-provisioning and operational-home-layout skills. Adds three cases totests/fm-update.test.sh:origin.A flaky wait in
tests/fm-remote-secondmate-lifecycle-e2e.test.shis left unchanged: the base commit fails the same assertion. It's worth filing separately.🤖 Generated with Claude Code
Risk Assessment
✅ Low: The change matches the stated intent. It resolves the update remote from one place, skips a missing remote instead of falling back to origin, advances by fast-forward only with an ancestor check, and gates the remote update on delivering config/update-remote. The only issue left is a harmless stale reread-marker leak.
Testing
I re-drove the scenarios at the current target commit. Local-route scenarios ran as a real
bin/fm-update.sh(from a clone of this branch) against a disposable lab home, with real bare origin and fork repos pushed to different commits. The remote-host side ran the host code root's realfm-remote-secondmate-control.sh update. All of those live scenarios passed. The remote-route scenarios ran the primary's realfm-update.shagainst real host checkouts, one at the current code and one at the base commit that predates the setting. For those, the SSH transport and the remote job worker were replaced by a local argv-decoding shim, because localhost SSH rejects this account's key. So they are recorded as untested live.tests/fm-update.test.shalso passes in full. The labs were removed by trap and the worktree is clean. There is no UI surface, so the evidence is CLI transcripts.forkto config/update-remote and runs the update: the primary and its local secondmate both land on fork's tip (not origin's) in a single-parent fast-forwardmyforkthat the repo does not define is skipped by name, nothing is fetched or moved, and there is no fallback to originoriginremote URL and the branch's push/tracking remote unchangedfm-remote-secondmate-control.sh updatesends the code root and home to fork's tip when the home's inherited copy names fork, and to origin when that copy is absentEvidence: Live local update drive (S1-S6)
Source: Live local update drive (S1-S6)
Evidence: Remote host code root follows home's inherited remote (R1-R2)
Source: Remote host code root follows home's inherited remote (R1-R2)
Evidence: Remote route precondition drive (RR1-RR3, SSH shimmed)
Source: Remote route precondition drive (RR1-RR3, SSH shimmed)
Evidence: Remote route drive script
Source: Remote route drive script
Evidence: tests/fm-update.test.sh run
Source: tests/fm-update.test.sh run
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
bin/fm-remote-secondmate-control.sh:401- On a remote route,cmd_updatereads the update remote from the host's inherited copy ($TARGET_HOME/config/update-remote).fm-update.shnever pushes inherited config before it callsfm-on.sh <id> fm-remote-secondmate-control.sh update <id>. That push only happens in fm-bootstrap.sh:631 and fm-spawn.sh:1037 (via fm-remote-inherit-push.sh). Concrete sequence: the operator writesforkto the primary's config/update-remote and runs /updatefirstmate straight away. The primary and every local secondmate read the primary's$FM_HOMEand advance to fork/main. The remote host has no inherited copy yet, sofm_update_remotereturnsoriginand that host's code root and home advance to origin/main. The run reports success and the fleet is split across two remotes with no warning. The intent calls this silent drift worse than being behind, and it is the gap this hand-off was meant to close. Smallest remedy: have fm-update.sh send its own resolved remote with the remoteupdatecommand (for example as an extra argument thatcmd_updateprefers over the home copy), or run the remote inherit push before the remote update. Either one changes the cross-host update protocol or the update ordering, so the author needs to confirm the approach.bin/fm-ff-lib.sh:84-default_branchis shared, and it now checks$(fm_update_remote)/HEADbeforeorigin/HEADin every directory it is given, not only firstmate homes. The pooled project worktree path at fm-spawn.sh:3270-3281 first refreshes origin's HEAD withremote set-head origin --auto, then callsdefault_branchand buildstarget=origin/$default. If a project clone happens to have a remote with the same name as the fleet's update remote (e.g.fork) and a stale recorded HEAD there (e.g. master after upstream renamed to main), the branch name comes from that remote's HEAD and not from the origin HEAD that was just refreshed. The worktree is then reset to, or refused on, the wrongorigin/<branch>. This is rare, and the intent explicitly adopts this fallback chain. A narrower form is to prefer the configured remote only inff_target, for example by passing the remote todefault_branchfrom there. Because the intent chose this design on purpose, the author should decide.tests/fm-update.test.sh:659- The three new tests cover only the local primary and a worktree secondmate. Nothing runs thecmd_updatepath in fm-remote-secondmate-control.sh, whereFM_HOMEpoints at the code root andFM_UPDATE_REMOTEis taken from the home. If that hand-off regressed, the code root would quietly go back to following origin and every existing test would still pass. A test that runsfm-remote-secondmate-control.sh updatewith a home config namingforkand a code root that has both remotes would check the fix the intent says was made.🔧 Fix applied.
2 issues (1 error, 1 warning) still open:
bin/fm-update.sh:222- The fix round now requires the full inherited-set push (fm-remote-inherit-push.sh, which runsset -eand covers every FM_INHERITABLE_CONFIG item plus data/captain-shared.md) to succeed before the remote code-root update runs. The receiver, fm-remote-inherit.sh, runs from the remote host's current (old) code root. Itsallowedcheck dies with "path is not inherited material" for any item that its revision does not declare. fm-config-inherit-lib.sh:40-44 says such a skew "must be reconciled by the ordinary remote sync/update path before the transfer succeeds", and this gate blocks exactly that path. Concrete sequence: this change addsupdate-remoteto FM_INHERITABLE_CONFIG. The primary runs the new code. A remote host still has the pre-change code root. /updatefirstmate pushesabsent config/update-remote(orputwhen the file is set), the remote rejects it, and the push exits 1. The route is reported "not converged" and is never updated, so it can never receive the code that would accept the item. The same permanent wedge happens for every future allowlist addition. Any unrelated item failure also blocks self-update: for example, a primary data/captain-shared.md without a valid header makes the pushdie. The user prescribed "push first, refuse on failure", but that remedy as implemented deadlocks the fleet's own upgrade path. Options that keep the user's intent: push only config/update-remote and treat a receiver 'not inherited material' refusal as 'host predates the setting' (an old host cannot read config/update-remote anyway, so it follows origin); or update first, then push, then re-run the update when the push changed config/update-remote. The fix itself needs the author's decision.bin/fm-update.sh:190- push_remote_inherited_config says it is the 'same transaction as bin/fm-config-push.sh', but it leaves out that transaction's reread handling. It never writes the fm_secondmate_nudge_write retry marker and never sends a reread when items come back pushed/removed. Concrete sequence: the primary changes config/crew-dispatch.json, and /updatefirstmate pushes it to a live remote home (reported as 'pushed:'). The following remote update then fails, for example because the host's code root is dirty, so claim_settled_secondmate never restarts the mate. The live agent keeps its stale config. A later fm-config-push.sh sees 'unchanged' with no pending marker, so it never nudges either. Remedy with existing machinery: write the nudge marker before the push, as fm-config-push.sh:141-147 does, so a later config-push delivers the reread.🔧 Fix applied.
1 info still open:
ℹ️
bin/fm-update.sh:216- push_remote_update_remote writes the remote reread retry marker before the push, but only removes it on the success path. When the receiver refuses config/update-remote because the host predates the setting (rc=2), nothing was delivered, yet the marker stays. In origin mode the update then goes ahead as normal. Concrete sequence: an existing fleet with no config/update-remote, and a remote host still on the pre-change code root. Every /updatefirstmate writes state/.secondmate-nudge-pending/<id>.pending and leaves it there, and the next bootstrap retry or fm-config-push sends a reread nudge that nothing needed. The effect is harmless but noisy. The fix is to treat rc=2 the way the unchanged-success path is treated: whenpendingwas 0,rm -f -- "$marker".ℹ️
bin/fm-update.sh:217- This was reported in round 3 and has not been fixed. Before the push, push_remote_update_remote writes the remote reread retry marker. It removes the marker only when the push succeeds with nothing pushed or removed. When the receiver refuses config/update-remote because the host predates the setting (rc=2), nothing reached the host, but the marker stays. In origin mode that refusal counts as success and the update goes ahead. Concrete sequence: an existing fleet has no config/update-remote, and a remote host still runs the pre-change code root. Every /updatefirstmate then leaves state/.secondmate-nudge-pending/<id>.pending behind, and the next fm-config-push or bootstrap retry sends a reread nudge the host never needed. This is harmless but repeats on every run until the host is upgraded. Fix: in the rc=2 branch, runrm -f -- "$marker"whenpendingwas 0, the same way the unchanged-success path does.ℹ️
bin/fm-update.sh:217- Still unfixed from rounds 3 and 4. Before the push, push_remote_update_remote writes the remote reread retry marker. It only removes it on the success path when nothing came back pushed/removed. When the receiver refuses config/update-remote (rc=2, 'path is not inherited material'), nothing was delivered, but the marker stays. Concrete sequence: an existing fleet has no config/update-remote (origin mode), and a remote host still runs the pre-change code root. Every /updatefirstmate leaves state/.secondmate-nudge-pending/<id>.pending behind, and the next fm-config-push or bootstrap retry sends that host a reread nudge it did not need. This repeats until the host is upgraded. It is harmless but noisy. Fix: in the rc=2 branch, also runrm -f -- "$marker"whenpendingwas 0.ℹ️
bin/fm-update.sh:218- This has been reported since round 3 and is still not fixed. Before the push, push_remote_update_remote writes the remote reread retry marker. It removes the marker only when the push succeeds and nothing comes back as pushed or removed. When the receiver refuses config/update-remote because the host's code root predates the setting (rc=2), nothing reached the host, but the marker stays. In origin mode that refusal then counts as success and the update goes ahead. Concrete sequence: an existing fleet with no config/update-remote has a remote host still running the pre-change code root. Every /updatefirstmate run leaves state/.secondmate-nudge-pending/<id>.pending behind, and the next fm-config-push or bootstrap retry sends that host a reread nudge it did not need. This repeats until the host is upgraded. Fix: in the rc=2 branch, also runrm -f -- "$marker"whenpendingwas 0, the same way the unchanged-success path does.✅ **Test** - passed
✅ No issues found.
firstmate: updated d3f8d6c..73cee49,secondmate sm1: updated d3f8d6c..73cee49, parent d3f8d6cskipped: diverged from origin/main, HEAD stays 73cee49, 0 mergesskipped: no upstream-fork remote, HEAD unchanged, deleted origin/main tracking ref not re-fetchedupdate-remote: pushed, sm1 fm_update_remote -> fork (the separate reread send was refused by the gate's lifecycle guard; that step is pre-existing and out…git remote -vbash tests/fm-update.test.sh(all 26 cases pass, including T13-T22 for the fork, missing-remote, blank-file, remote-route push, predating-host and ahead-of-remote guards)Live lab:bin/fm-lab-home.sh create $LAB/home, clone of target commit d3f8d6c as primary with origin.git and fork.git remotes diverged by one commit each, plus a detached secondmate worktree sm1 registered in state/sm1.metaS1:printf fork > config/update-remote; bin/fm-update.sh-> primary and sm1 updated d3f8d6c..73cee49 (fork tip), single parentS2: rerunbin/fm-update.sh-> already currentS3: config switched to origin while homes are on the fork tip ->skipped: diverged from origin/main, nothing moved, 0 merge commitsS4:config/update-remote=upstream-fork->skipped: no upstream-fork remotefor both targets, origin/main tracking ref stays deleted (no origin fetch)S5: whitespace-only config -> updated to origin tip 1976d4eS6: no config file -> updated to origin tip 1976d4e (unchanged behaviour)S7:bin/fm-config-push.sh->update-remote: pushed, sm1/config/update-remote=fork;fm_update_remotewith FM_HOME=sm1 -> forkS8:git remote -vafter all runs: origin and fork unchangedLab torn down withrm -rf $LAB✅ No issues found.
forkto config/update-remote; the primary and its secondmate land on fork/main (not origin/main) by single-parent fast-forward; remotes and branch upstream unchangedssh localhostgives Permission denied (publickey)). To run it live, provide a remote host with firstmate provisioned and non-inter…bash tests/fm-update.test.sh: T1–T22 all passed, including the new remote-route cases T16–T22 (fake SSH)Live driverdrive.sh(in the evidence dir): cloned this run's commit into bareoriginandforkremotes, a primary checkout, a detached-worktree secondmate and a marked lab FM_HOME frombin/fm-lab-home.sh create, then ran the primary's ownbin/fm-update.shS1: unconfigured home, both remotes advancedS3:config/update-remotenamesmyfork, which the repo does not defineS4: whitespace-onlyconfig/update-remoteS5: home already on an origin commit the fork lacks, withforkconfiguredS6:bin/fm-remote-inherit.sh put config/update-remoterun against a home with the target code root and with the base-commit code rootdrive-s2.sh: fork set as the update remote, origin and fork both descend from the home's commit but have different tips, then a second run to check it is idempotent✅ No issues found.
fm-remote-secondmate-control.sh updatemoves the code root and home to the fork tip when the home has inherited update-remote=fork, and to origin's tip when it has noneenv -u NO_MISTAKES_GATE bash tests/fm-update.test.sh(26/26 ok, including T13-T22 for the new behaviour)bash $EV/drive-live.sh: lab home frombin/fm-lab-home.sh create, real bare origin/fork repos that diverge from this branch's HEAD, the primary clone's ownbin/fm-update.shrun with FM_HOME=lab, scenarios S1-S6bash $EV/drive-remote-host.sh: the host code root's ownbin/fm-remote-secondmate-control.sh update sm1run with FM_HOME=secondmate home, with and without an inherited config/update-remote✅ No issues found.
forkto config/update-remote and runs the update: the primary and its local secondmate both land on fork's tip (not origin's) in a single-parent fast-forwardmyforkthat the repo does not define is skipped by name, nothing is fetched or moved, and there is no fallback to originoriginremote URL and the branch's push/tracking remote unchangedfm-remote-secondmate-control.sh updatesends the code root and home to fork's tip when the home's inherited copy names fork, and to origin when that copy is absentbash drive-live.sh(evidence dir): real primary clone'sbin/fm-update.shagainst a disposable lab home with real bare origin and fork remotes; scenarios S1–S6 → r3-drive-live.transcript.txtbash drive-remote-host.sh(evidence dir): host code root's ownbin/fm-remote-secondmate-control.sh update sm1, with the home's inherited config set to fork and then absent → r3-drive-remote-host.transcript.txtbash r3-drive-remote-route.sh(evidence dir): primaryfm-update.shwith a registered remote route whose host runs real Firstmate scripts on a real code root and home, first at the current HEAD and then at the predating base 050a446; only ssh and the remote job worker are shimmed (not live) → r3-drive-remote-route.transcript.txtbash tests/fm-update.test.sh(targeted file, all cases incl. T13–T22 pass) → r3-fm-update-test.log✅ **Document** - passed
✅ No issues found.
✅ No issues found.
✅ No issues found.
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ No issues found.
✅ No issues found.
✅ No issues found.
✅ **Push** - passed
✅ No issues found.
✅ No issues found.
✅ No issues found.
✅ No issues found.