Skip to content

feat: let /updatefirstmate self-update from a configured fork remote - #5832

Open
Courtneyezra wants to merge 6 commits into
kunchenguid:mainfrom
Courtneyezra:fm/fm-run-from-our-own-fork
Open

Courtneyezra wants to merge 6 commits into
kunchenguid:mainfrom
Courtneyezra:fm/fm-run-from-our-own-fork

Conversation

@Courtneyezra

@Courtneyezra Courtneyezra commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

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:

  • The setting changes ONLY the self-update base. It deliberately does not change where branches are pushed, where pull requests are opened, or what origin means to any other command. That is the point: the decision was to land fixes on the fork AND keep offering the same fixes upstream, so origin must keep meaning the upstream project or the second half stops working by accident. Renaming the remotes was considered and rejected for exactly this reason.
  • A configured remote that a target repo does not define is reported as a skip naming that remote and nothing is fetched or advanced. It deliberately does NOT fall back to origin. Landing a home on the very main the setting exists to move it off is worse than not updating it, so this refuses instead. This is intentional, not a missing fallback.
  • fm_update_remote reads $FM_HOME rather than each target checkout, deliberately, so one home has exactly one answer. A fleet whose homes follow different remotes drifts apart silently, which is worse than a fleet that is merely behind.
  • config/update-remote is added to FM_INHERITABLE_CONFIG on purpose so the primary's answer converges every secondmate home by construction rather than by an operator remembering to edit each one.
  • FM_UPDATE_REMOTE is an environment override that exists for exactly one caller and for tests, not as an operator-facing knob. A remote route's code-root update runs with FM_HOME pointed at the code root, while inherited material lands in that host's secondmate home, so without the hand-off that host would keep following origin while the rest of the fleet followed the fork. This gap was found during the work and is closed here.
  • default_branch now prefers the configured remote's recorded HEAD, then origin's, then a local main/master. With no setting the first two lookups are the same remote, so an unconfigured home resolves byte-identically to before. The fallback chain is what keeps project clones and other callers safe.
  • fetch_once keys its once-per-run cache by remote as well as by object store, because worktrees of one repo share a store but two remotes are two fetches.

Deliberately NOT included, and not oversights:

  • Nothing is re-pointed. No remote is renamed, no config/update-remote is written, no home is moved. A sibling task is mid-run reconciling local main against upstream and re-pointing under it was explicitly out of bounds; the actual switch is an operator step.
  • No fork-divergence register is added. A sibling task is already introducing docs/fork-divergences.md and a second competing file would be worse than one.
  • An unrelated flaky wait in tests/fm-remote-secondmate-lifecycle-e2e.test.sh is deliberately left alone. It failed locally under load, but the base commit fails the identical assertion at the identical point, so it is pre-existing and fixing it here would be scope creep. It is recorded in the PR body as worth filing separately.

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

  • Adds config/update-remote. fm_update_remote in bin/fm-ff-lib.sh reads it from $FM_HOME, and an absent or blank file means origin. /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 to origin. 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. Other bin/fm-ff-lib.sh changes:
    • default_branch now checks the configured remote's recorded HEAD first, then origin's, then a local main/master.
    • fetch_once caches per remote as well as per object store.
  • update-remote is added to FM_INHERITABLE_CONFIG, so secondmate homes pick up the primary's setting.
    • fm-remote-inherit-push.sh accepts an optional single config item, and fm-update.sh uses it to push only config/update-remote to each remote route before asking that route to update.
    • A remote route whose code root predates the setting is skipped with a message, unless the configured remote is origin.
    • fm-remote-secondmate-control.sh reads the setting from the secondmate home and passes it to the code-root update through FM_UPDATE_REMOTE, an override meant only for that caller and for tests.
  • Documents the "Self-update remote" contract in docs/configuration.md, docs/architecture.md and the updatefirstmate, secondmate-provisioning and operational-home-layout skills. Adds three cases to tests/fm-update.test.sh:
    • the primary and a secondmate both land on the fork's tip rather than origin's;
    • an undefined configured remote is skipped by name with nothing moved;
    • a whitespace-only file resolves to origin.

A flaky wait in tests/fm-remote-secondmate-lifecycle-e2e.test.sh is 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 real fm-remote-secondmate-control.sh update. All of those live scenarios passed. The remote-route scenarios ran the primary's real fm-update.sh against 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.sh also 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.

  • Live validation: ✅ go - 7 of 10 scenarios driven live against the product
Scenario Result Live Evidence
Operator writes fork to 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-forward ✅ pass live r3-drive-live.transcript.txt S1: 0e8eee2..ac4ed08 = fork/main while origin/main=5ea3403; parents: 1
Home with no config/update-remote follows origin exactly as before ✅ pass live r3-drive-live.transcript.txt S2: both targets moved to origin/main 5ea3403
A config/update-remote containing only whitespace resolves to origin ✅ pass live r3-drive-live.transcript.txt S3
Adversarial: a configured remote myfork that the repo does not define is skipped by name, nothing is fetched or moved, and there is no fallback to origin ✅ pass live r3-drive-live.transcript.txt S4: 'skipped: no myfork remote', HEADs unchanged, origin tracking ref not fetched
Adversarial: a home already ahead of the chosen fork is refused and never moved back or onto another remote ✅ pass live r3-drive-live.transcript.txt S5: 'skipped: diverged from fork/main', HEADs unchanged
Updating from the fork leaves the origin remote URL and the branch's push/tracking remote unchanged ✅ pass live r3-drive-live.transcript.txt S6: origin URL unchanged, branch.main.remote=origin
On a remote host, fm-remote-secondmate-control.sh update sends the code root and home to fork's tip when the home's inherited copy names fork, and to origin when that copy is absent ✅ pass live r3-drive-remote-host.transcript.txt R1 (synced to fork tip 811cf57) / R2 (origin tip f1c8406)
Fleet switched to fork just before the update: the remote route first receives config/update-remote, then updates its code root and home to fork's tip ⏸️ untested no The prior payload did not establish a live result. The run replaced the SSH transport and remote job worker with a shim because no SSH host or key is authorised for this account (ssh localhost → Permi…
Adversarial: fleet on fork, but the remote host's code root predates the setting. The host is reported not converged with a reason and an operator action, and its update is never run, so it is not str… ⏸️ untested no The prior payload did not establish a live result. The SSH transport was shimmed because no authorised SSH host is available. To test this live, provide an SSH-reachable lab host whose code root is at…
Fleet on origin (default) with a predating remote host: the update proceeds as before and nothing gets stuck ⏸️ untested no The prior payload did not establish a live result. The SSH transport was shimmed because no authorised SSH host is available. To test this live, provide an SSH-reachable lab host with an authorised ke…
Evidence: Live local update drive (S1-S6)

Source: Live local update drive (S1-S6)

base=0e8eee2 fork tip=ac4ed08 origin tip=5ea3403 (diverged: fork and origin at different commits)

===== S1 config/update-remote=fork -> primary + local secondmate land on fork tip, not origin =====
  primary HEAD=0e8eee2  sm1 HEAD=0e8eee2  origin/main=5ea3403  fork/main=ac4ed08
  | firstmate: updated 0e8eee2..ac4ed08
  | no watches registered
  | secondmate sm1: updated 0e8eee2..ac4ed08
  | no watches registered
  | reread-firstmate: no
  | restart-secondmates: none
  | nudge-secondmates: none
  primary HEAD=ac4ed08  sm1 HEAD=ac4ed08  origin/main=5ea3403  fork/main=ac4ed08
  primary HEAD parents: 1 (1 = single-parent ff)

===== S2 no config/update-remote -> unchanged behaviour, follows origin =====
  primary HEAD=0e8eee2  sm1 HEAD=0e8eee2  origin/main=5ea3403  fork/main=ac4ed08
  | firstmate: updated 0e8eee2..5ea3403
  | no watches registered
  | secondmate sm1: updated 0e8eee2..5ea3403
  | no watches registered
  | reread-firstmate: no
  | restart-secondmates: none
  | nudge-secondmates: none
  primary HEAD=5ea3403  sm1 HEAD=5ea3403  origin/main=5ea3403  fork/main=ac4ed08

===== S3 whitespace-only config/update-remote -> origin =====
  primary HEAD=0e8eee2  sm1 HEAD=0e8eee2  origin/main=5ea3403  fork/main=ac4ed08
  | firstmate: updated 0e8eee2..5ea3403
  | no watches registered
  | secondmate sm1: updated 0e8eee2..5ea3403
  | no watches registered
  | reread-firstmate: no
  | restart-secondmates: none
  | nudge-secondmates: none
  primary HEAD=5ea3403  sm1 HEAD=5ea3403  origin/main=5ea3403  fork/main=ac4ed08

===== S4 adversarial: configured remote 'myfork' not defined in repo -> skip by name, no origin fallback =====
  primary HEAD=0e8eee2  sm1 HEAD=0e8eee2  origin/main=5ea3403  fork/main=ac4ed08
  | firstmate: skipped: no myfork remote
  | secondmate sm1: skipped: no myfork remote
  | reread-firstmate: no
  | restart-secondmates: none
  | nudge-secondmates: none
  primary HEAD=0e8eee2  sm1 HEAD=0e8eee2  origin/main=5ea3403  fork/main=ac4ed08
  origin/main tracking ref moved (fetched)? no

===== S5 adversarial: fork configured but primary/secondmate already AHEAD of the fork (fork/main is an ancestor of their HEAD) -> refused, never moved back or elsewhere =====
  fork(behind)/main=0e8eee2
  primary HEAD=5ea3403  sm1 HEAD=5ea3403  origin/main=5ea3403  fork/main=ac4ed08
  | firstmate: skipped: diverged from fork/main
  | secondmate sm1: skipped: diverged from fork/main; reconciliation required (record: <lab>/state/.secondmate-update-reconcile/sm1.pending)
  | reread-firstmate: no
  | restart-secondmates: none
  | nudge-secondmates: none
  primary HEAD=5ea3403  sm1 HEAD=5ea3403  origin/main=5ea3403  fork/main=ac4ed08

===== S6 origin unaffected for other commands: primary's origin URL and push config untouched after fork update =====
  fork	<lab>/fork.git (fetch)
  fork	<lab>/fork.git (push)
  origin	<lab>/origin.git (fetch)
  origin	<lab>/origin.git (push)
  branch.main.remote=origin
Evidence: Remote host code root follows home's inherited remote (R1-R2)

Source: Remote host code root follows home's inherited remote (R1-R2)

base=0e8eee2 fork tip=811cf57 origin tip=f1c8406

===== R1 host home inherited config/update-remote=fork -> host code root AND home land on fork tip =====
  | synced: 811cf5772f711ad96e12a93c31b2516c1ff6d23d instr=
  code root HEAD=811cf57  home HEAD=811cf57

===== R2 host home has no config/update-remote -> host follows origin as before =====
  | synced: f1c84067a719677361499ec9052c66adece907e7 instr=
  code root HEAD=f1c8406  home HEAD=f1c8406
Evidence: Remote route precondition drive (RR1-RR3, SSH shimmed)

Source: Remote route precondition drive (RR1-RR3, SSH shimmed)

base=0e8eee2 fork tip=5f86227 origin tip=1a44566 old(predating) root=050a446

===== RR1 fleet set to fork just now; up-to-date host has NO inherited copy yet -> copy pushed first, host root+home land on fork tip =====
  primary=0e8eee2 hostroot=0e8eee2 hosthome=0e8eee2 | origin/main=1a44566 fork/main=5f86227 | host home config/update-remote=<absent>
  | firstmate: updated 0e8eee2..5f86227
  | no watches registered
  | remote secondmate sm1: updated on labhost (5f86227b7bd1c7174a36ece4e609181c3abae96e)
  | reread-firstmate: no
  | restart-secondmates: none
  | nudge-secondmates: none
  wire order:
    [host labhost] fm-remote-inherit.sh put config/update-remote 5 24ecccc1ee8d359669c641b84c51de4885a4b7d397611d46be9de82bffe1e5c3 1
    [host labhost] fm-remote-secondmate-control.sh update sm1
  primary=5f86227 hostroot=5f86227 hosthome=5f86227 | origin/main=1a44566 fork/main=5f86227 | host home config/update-remote=fork

===== RR2 adversarial: fleet on fork, host code root PREDATES the setting -> NOT converged, host not moved onto origin =====
  primary=0e8eee2 hostroot=050a446 hosthome=050a446 | origin/main=1a44566 fork/main=5f86227 | host home config/update-remote=<absent>
  | firstmate: updated 0e8eee2..5f86227
  | no watches registered
  | remote secondmate sm1: skipped on labhost: not converged: its Firstmate code root predates config/update-remote, so its update would follow origin instead of fork; bring <lab>/hostroot on that host onto fork's default branch by hand, then rerun /updatefirstmate
  | reread-firstmate: no
  | restart-secondmates: none
  | nudge-secondmates: none
  wire order:
    [host labhost] fm-remote-inherit.sh put config/update-remote 5 24ecccc1ee8d359669c641b84c51de4885a4b7d397611d46be9de82bffe1e5c3 2
  primary=5f86227 hostroot=050a446 hosthome=050a446 | origin/main=1a44566 fork/main=5f86227 | host home config/update-remote=<absent>

===== RR3 fleet on origin (no file), host code root predates -> updates exactly as before (no wedge) =====
  primary=0e8eee2 hostroot=050a446 hosthome=050a446 | origin/main=1a44566 fork/main=5f86227 | host home config/update-remote=<absent>
  | firstmate: updated 0e8eee2..1a44566
  | no watches registered
  | remote secondmate sm1: updated on labhost (1a44566576752f057a092999d36c4792bd7f3fbf, instructions changed: bin,.agents/skills)
  | reread-firstmate: no
  | restart-secondmates: none
  | nudge-secondmates: none
  wire order:
    [host labhost] fm-remote-inherit.sh absent config/update-remote 0 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 3
    [host labhost] fm-remote-secondmate-control.sh update sm1
  primary=1a44566 hostroot=1a44566 hosthome=1a44566 | origin/main=1a44566 fork/main=5f86227 | host home config/update-remote=<absent>
Evidence: Remote route drive script

Source: Remote route drive script

#!/usr/bin/env bash
# Drive the primary's real bin/fm-update.sh against a registered REMOTE secondmate
# route whose "host" is a real Firstmate code root + home on this machine. Only the
# SSH transport + remote job worker are replaced: the shim decodes fm-on.sh's argv
# exactly as fm-remote-entrypoint.sh does and executes <host root>/bin/<cmd> with
# FM_HOME=<host home>. Every Firstmate script on both sides is the real one.
set -u
WT=$PWD
OLD=050a44643af4f7c9b7a20b1bf165d4834d064c1b   # base commit: predates config/update-remote
export GIT_AUTHOR_NAME=lab GIT_AUTHOR_EMAIL=lab@example.com GIT_COMMITTER_NAME=lab GIT_COMMITTER_EMAIL=lab@example.com
LAB=$(mktemp -d "${TMPDIR:-/tmp}/fm-lab.XXXXXX"); rmdir "$LAB"
bin/fm-lab-home.sh create "$LAB" >/dev/null; mkdir -p "$LAB/tmux"
W=$(mktemp -d "${TMPDIR:-/tmp}/fm-lab-repos.XXXXXX")
trap 'rm -rf "$LAB" "$W"' EXIT
touch "$LAB/state/.last-watcher-beat"
BASE=$(git -C "$WT" rev-parse HEAD)
git clone -q --bare "$WT" "$W/origin.git"; git -C "$W/origin.git" update-ref refs/heads/main "$BASE"; git -C "$W/origin.git" symbolic-ref HEAD refs/heads/main
git clone -q --bare "$W/origin.git" "$W/fork.git"
bump(){ rm -rf "$W/seed"; git clone -q "$1" "$W/seed"; echo "$2" >> "$W/seed/README.md"; git -C "$W/seed" commit -qam "$2"; git -C "$W/seed" push -q origin main; git -C "$W/seed" rev-parse --short HEAD; }
F=$(bump "$W/fork.git" fork-only); O=$(bump "$W/origin.git" upstream-only)
mkdir -p "$W/bin"
cat > "$W/bin/lab-ssh" <<'SH'
#!/usr/bin/env bash
while [ "$#" -gt 0 ]; do case "$1" in -o) shift 2 ;; --) shift; break ;; *) exit 90 ;; esac; done
host=$1; shift; [ "$1" = fm-remote-entrypoint.sh ] || exit 91
d(){ printf '%s' "$1" | base64 --decode; }
root=$(d "$3"); home=$(d "$4"); args=()
while IFS= read -r -d '' a; do args+=("$a"); done < <(d "$5")
printf '[host %s] %s\n' "$host" "${args[*]}" >> "$LAB_WIRE"
cd "$home" && exec env -u FM_ROOT_OVERRIDE -u FM_STATE_OVERRIDE -u FM_DATA_OVERRIDE -u FM_CONFIG_OVERRIDE -u FM_PROJECTS_OVERRIDE -u FM_UPDATE_REMOTE -u TMUX -u FM_CONFIG_INHERIT_LIVE \
  FM_HOME="$home" "$root/bin/${args[0]}" "${args[@]:1}"
SH
chmod +x "$W/bin/lab-ssh"
setup(){ # <host code-root commit> <primary config/update-remote or empty>
  rm -rf "$W/primary" "$W/hostroot" "$W/hosthome"; : > "$W/wire"
  git clone -q "$W/origin.git" "$W/primary"; git -C "$W/primary" reset -q --hard "$BASE"; git -C "$W/primary" remote add fork "$W/fork.git"
  git clone -q "$W/origin.git" "$W/hostroot"; git -C "$W/hostroot" reset -q --hard "$1"; git -C "$W/hostroot" remote add fork "$W/fork.git"
  mkdir -p "$W/hostroot/state"; touch "$W/hostroot/state/.last-watcher-beat"; printf 'state/\n' >> "$W/hostroot/.git/info/exclude"
  git clone -q "$W/hostroot" "$W/hosthome"; git -C "$W/hosthome" checkout -q --detach "$1"
  printf 'sm1\n' > "$W/hosthome/.fm-secondmate-home"; mkdir -p "$W/hosthome/config" "$W/hosthome/state"; touch "$W/hosthome/state/.last-watcher-beat"
  printf 'state/\nconfig/\n.fm-secondmate-home\n' >> "$W/hosthome/.git/info/exclude"
  rm -f "$LAB/config/update-remote" "$LAB/state/"*.meta; [ -z "$2" ] || printf '%s\n' "$2" > "$LAB/config/update-remote"
  printf -- '- sm1 - remote domain (host: labhost; root: %s; home: %s; scope: things; projects: p; added 2026-09-27)\n' "$W/hostroot" "$W/hosthome" > "$LAB/data/secondmates.md"
}
heads(){ echo "  primary=$(git -C "$W/primary" rev-parse --short HEAD) hostroot=$(git -C "$W/hostroot" rev-parse --short HEAD) hosthome=$(git -C "$W/hosthome" rev-parse --short HEAD) | origin/main=$O fork/main=$F | host home config/update-remote=$(cat "$W/hosthome/config/update-remote" 2>/dev/null || echo '<absent>')"; }
run(){ env -u NO_MISTAKES_GATE -u FM_GATE_REFUSE_BYPASS -u FM_ROOT_OVERRIDE -u FM_STATE_OVERRIDE -u FM_DATA_OVERRIDE -u FM_CONFIG_OVERRIDE -u FM_PROJECTS_OVERRIDE -u FM_UPDATE_REMOTE -u TMUX \
   TMUX_TMPDIR="$LAB/tmux" LAB_WIRE="$W/wire" FM_SSH_BIN="$W/bin/lab-ssh" FM_HOME="$LAB" "$W/primary/bin/fm-update.sh" 2>&1 | sed 's/^/  | /'
   echo "  wire order:"; sed 's/^/    /' "$W/wire"; }
echo "base=$(git -C "$WT" rev-parse --short HEAD) fork tip=$F origin tip=$O old(predating) root=${OLD:0:7}"
echo; echo "===== RR1 fleet set to fork just now; up-to-date host has NO inherited copy yet -> copy pushed first, host root+home land on fork tip ====="
setup "$BASE" fork; heads; run; heads
echo; echo "===== RR2 adversarial: fleet on fork, host code root PREDATES the setting -> NOT converged, host not moved onto origin ====="
setup "$OLD" fork; heads; run; heads
echo; echo "===== RR3 fleet on origin (no file), host code root predates -> updates exactly as before (no wedge) ====="
setup "$OLD" ""; heads; run; heads
Evidence: tests/fm-update.test.sh run

Source: tests/fm-update.test.sh run

ok - T1 main + secondmate fast-forward (single-parent), reread + restart signalled
ok - T3 a non-instruction advance still restarts the live secondmate
ok - T3b a bin/-only advance restarts the secondmate
ok - T3c an unverifiable secondmate receives the fallback nudge
ok - T3d an already-stopped secondmate is left to startup recovery
ok - T3e a legacy remote advance still restarts the live remote mate
ok - T4 dirty secondmate skipped, local edit preserved
ok - T5 diverged secondmate is preserved and durably actionable
ok - T5b squash-merged divergence heals and rejoins live convergence
ok - T6 an already-current live secondmate is still restarted
ok - T6b an already-current mate with an unprovable runtime is steered, not claimed as reloaded
ok - T7 registry backstop resolves, dedups meta+registry, excludes the firstmate repo
ok - T9 firstmate off its default branch is skipped, not forced
ok - T10 firstmate detached HEAD is skipped
ok - T11 unsafe secondmate home is not fast-forwarded
ok - T12 a self-update rebinds a locally armed watch on the primary
ok - T13 /updatefirstmate follows config/update-remote for the primary and its secondmate
ok - T14 a configured remote the repo does not define is refused, never retried against origin
ok - T15 a blank config/update-remote resolves to origin
ok - T16 a remote route receives config/update-remote before its update runs
ok - T17 a failed inherit push refuses the remote update and reports it unconverged
ok - T18 a remote code root follows its home's inherited config/update-remote
ok - T19 an unrelated inherited item cannot block a remote self-update
ok - T20 a host that predates config/update-remote is not updated while the fleet follows a fork
ok - T21 a predating host still updates when the configured remote is origin
ok - T22 a home the chosen remote does not contain is refused, never advanced
# all fm-update tests passed

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ⚠️ bin/fm-remote-secondmate-control.sh:401 - On a remote route, cmd_update reads the update remote from the host's inherited copy ($TARGET_HOME/config/update-remote). fm-update.sh never pushes inherited config before it calls fm-on.sh &lt;id&gt; fm-remote-secondmate-control.sh update &lt;id&gt;. That push only happens in fm-bootstrap.sh:631 and fm-spawn.sh:1037 (via fm-remote-inherit-push.sh). Concrete sequence: the operator writes fork to the primary's config/update-remote and runs /updatefirstmate straight away. The primary and every local secondmate read the primary's $FM_HOME and advance to fork/main. The remote host has no inherited copy yet, so fm_update_remote returns origin and 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 remote update command (for example as an extra argument that cmd_update prefers 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_branch is shared, and it now checks $(fm_update_remote)/HEAD before origin/HEAD in 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 with remote set-head origin --auto, then calls default_branch and builds target=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 wrong origin/&lt;branch&gt;. This is rare, and the intent explicitly adopts this fallback chain. A narrower form is to prefer the configured remote only in ff_target, for example by passing the remote to default_branch from 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 the cmd_update path in fm-remote-secondmate-control.sh, where FM_HOME points at the code root and FM_UPDATE_REMOTE is 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 runs fm-remote-secondmate-control.sh update with a home config naming fork and 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 runs set -e and 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. Its allowed check 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 adds update-remote to FM_INHERITABLE_CONFIG. The primary runs the new code. A remote host still has the pre-change code root. /updatefirstmate pushes absent config/update-remote (or put when 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 push die. 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: when pending was 0, rm -f -- &#34;$marker&#34;.

  • ℹ️ 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, run rm -f -- &#34;$marker&#34; when pending was 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 run rm -f -- &#34;$marker&#34; when pending was 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 run rm -f -- &#34;$marker&#34; when pending was 0, the same way the unchanged-success path does.

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 8 of 9 scenarios driven live against the product
Scenario Result Live Evidence
With config/update-remote=fork, /updatefirstmate advances the primary and its local secondmate to the fork tip (not origin's), single-parent ✅ pass live s1-fork-configured.txt: firstmate: updated d3f8d6c..73cee49, secondmate sm1: updated d3f8d6c..73cee49, parent d3f8d6c
Rerunning the update after landing on the fork reports already current ✅ pass live s2-s3-rerun-and-divergent-remote.txt S2
Adversarial: when the chosen remote's main does not contain the home's current commit, the update refuses and nothing moves ✅ pass live s2-s3-rerun-and-divergent-remote.txt S3: skipped: diverged from origin/main, HEAD stays 73cee49, 0 merges
Adversarial: a configured remote the checkout does not define is skipped by name, with no origin fallback and no fetch ✅ pass live s4-s6-missing-blank-absent.txt S4: skipped: no upstream-fork remote, HEAD unchanged, deleted origin/main tracking ref not re-fetched
A whitespace-only config/update-remote resolves to origin ✅ pass live s4-s6-missing-blank-absent.txt S5: updated to origin tip 1976d4e
A home with no config/update-remote updates from origin exactly as before ✅ pass live s4-s6-missing-blank-absent.txt S6
The primary's config/update-remote is inherited into a live secondmate home, which then resolves the same remote ✅ pass live s7-s8-inheritance-and-origin-untouched.txt: update-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…
The setting does not rename or re-point remotes; origin keeps meaning upstream ✅ pass live s7-s8-inheritance-and-origin-untouched.txt S8 git remote -v
Remote SSH route: config/update-remote is pushed before the remote update, the remote code root follows the home's inherited remote, and a host that predates the setting is not updated off the fork ⏸️ untested no No disposable SSH-reachable remote host was available. The only ~/.ssh alias (fleet-pro) is another real fleet account, which the runbook forbids touching. To drive this live, provide a throwaway SSH…
  • bash 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.meta
  • S1: printf fork &gt; config/update-remote; bin/fm-update.sh -> primary and sm1 updated d3f8d6c..73cee49 (fork tip), single parent
  • S2: rerun bin/fm-update.sh -> already current
  • S3: config switched to origin while homes are on the fork tip -> skipped: diverged from origin/main, nothing moved, 0 merge commits
  • S4: config/update-remote=upstream-fork -> skipped: no upstream-fork remote for both targets, origin/main tracking ref stays deleted (no origin fetch)
  • S5: whitespace-only config -> updated to origin tip 1976d4e
  • S6: 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_remote with FM_HOME=sm1 -> fork
  • S8: git remote -v after all runs: origin and fork unchanged
  • Lab torn down with rm -rf $LAB

✅ No issues found.

  • Live validation: ✅ go - 7 of 8 scenarios driven live against the product
Scenario Result Live Evidence
Unconfigured home runs /updatefirstmate and the primary and secondmate fast-forward to origin's tip; the fork remote, also advanced, is ignored ✅ pass live live-transcript.txt S1: both at a10fc08 = origin/main, fork/main 700764f untouched
Operator writes fork to config/update-remote; the primary and its secondmate land on fork/main (not origin/main) by single-parent fast-forward; remotes and branch upstream unchanged ✅ pass live live-transcript-s2.txt S2
Running the update a second time on the fork reports both homes already current ✅ pass live live-transcript-s2.txt S2b
config/update-remote names a remote the repo lacks: each target skipped with 'no myfork remote', nothing moved, no fallback to the advanced origin ✅ pass live live-transcript.txt S3
Whitespace-only config/update-remote resolves to origin ✅ pass live live-transcript.txt S4
Adversarial: home already on an origin commit the fork's main does not contain, with fork configured; update refuses to move it and records the secondmate for reconciliation ✅ pass live live-transcript.txt S5: 'skipped: diverged from fork/main', primary unmoved
The receiver on a remote home accepts inherited config/update-remote and fm_update_remote resolves it; a code root from before the change refuses the item (the 'host predates the setting' signal) ✅ pass live live-transcript.txt S6
A remote secondmate route over SSH receives config/update-remote before its code root updates, follows the home's remote, and is not updated (reported not converged) when its host predates the setting… ⏸️ untested no Not live: this machine has no reachable SSH remote-secondmate host (ssh localhost gives 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 driver drive.sh (in the evidence dir): cloned this run's commit into bare origin and fork remotes, a primary checkout, a detached-worktree secondmate and a marked lab FM_HOME from bin/fm-lab-home.sh create, then ran the primary's own bin/fm-update.sh
  • S1: unconfigured home, both remotes advanced
  • S3: config/update-remote names myfork, which the repo does not define
  • S4: whitespace-only config/update-remote
  • S5: home already on an origin commit the fork lacks, with fork configured
  • S6: bin/fm-remote-inherit.sh put config/update-remote run against a home with the target code root and with the base-commit code root
  • drive-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.

  • Live validation: ✅ go - 7 of 8 scenarios driven live against the product
Scenario Result Live Evidence
config/update-remote=fork: /updatefirstmate moves the primary and its local secondmate to fork/main's tip (not origin's), with a single-parent fast-forward ✅ pass live drive-live.transcript.txt S1: both 9facc4d..c3acce1 = fork tip while origin tip is 34a95f8; parents=1
No config/update-remote: primary and secondmate follow origin exactly as before ✅ pass live drive-live.transcript.txt S2: both updated to the origin tip 34a95f8
Whitespace-only config/update-remote resolves to origin ✅ pass live drive-live.transcript.txt S3
Adversarial: a configured remote the repo does not define is skipped by name, nothing is moved, and there is no origin fetch or fallback ✅ pass live drive-live.transcript.txt S4: 'skipped: no myfork remote' for both targets, HEADs unchanged, origin/main tracking ref did not move
Adversarial: a home already ahead of the chosen fork is refused and never moved ✅ pass live drive-live.transcript.txt S5: skipped, HEAD stays at 34a95f8 (the wording is the existing 'diverged' message)
The setting changes only the update base: origin's URL and branch.main.remote are untouched after a fork update ✅ pass live drive-live.transcript.txt S6
On a remote host, fm-remote-secondmate-control.sh update moves 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 none ✅ pass live drive-remote-host.transcript.txt R1 (both at 6b46c6e = fork tip) and R2 (both at 3206ea7 = origin tip)
Primary→remote route over SSH: config/update-remote is pushed before the remote update; a failed push, or a host that predates the setting while the fleet follows the fork, is reported as not converge… ⏸️ untested no There is no reachable remote secondmate host or SSH route in this gate environment. To run this live, provision a real remote secondmate route (an SSH host with a Firstmate code root, registered in a…
  • env -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 from bin/fm-lab-home.sh create, real bare origin/fork repos that diverge from this branch's HEAD, the primary clone's own bin/fm-update.sh run with FM_HOME=lab, scenarios S1-S6
  • bash $EV/drive-remote-host.sh: the host code root's own bin/fm-remote-secondmate-control.sh update sm1 run with FM_HOME=secondmate home, with and without an inherited config/update-remote

✅ No issues found.

  • Live validation: ✅ go - 7 of 10 scenarios driven live against the product
Scenario Result Live Evidence
Operator writes fork to 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-forward ✅ pass live r3-drive-live.transcript.txt S1: 0e8eee2..ac4ed08 = fork/main while origin/main=5ea3403; parents: 1
Home with no config/update-remote follows origin exactly as before ✅ pass live r3-drive-live.transcript.txt S2: both targets moved to origin/main 5ea3403
A config/update-remote containing only whitespace resolves to origin ✅ pass live r3-drive-live.transcript.txt S3
Adversarial: a configured remote myfork that the repo does not define is skipped by name, nothing is fetched or moved, and there is no fallback to origin ✅ pass live r3-drive-live.transcript.txt S4: 'skipped: no myfork remote', HEADs unchanged, origin tracking ref not fetched
Adversarial: a home already ahead of the chosen fork is refused and never moved back or onto another remote ✅ pass live r3-drive-live.transcript.txt S5: 'skipped: diverged from fork/main', HEADs unchanged
Updating from the fork leaves the origin remote URL and the branch's push/tracking remote unchanged ✅ pass live r3-drive-live.transcript.txt S6: origin URL unchanged, branch.main.remote=origin
On a remote host, fm-remote-secondmate-control.sh update sends the code root and home to fork's tip when the home's inherited copy names fork, and to origin when that copy is absent ✅ pass live r3-drive-remote-host.transcript.txt R1 (synced to fork tip 811cf57) / R2 (origin tip f1c8406)
Fleet switched to fork just before the update: the remote route first receives config/update-remote, then updates its code root and home to fork's tip ⏸️ untested no The prior payload did not establish a live result. The run replaced the SSH transport and remote job worker with a shim because no SSH host or key is authorised for this account (ssh localhost → Permi…
Adversarial: fleet on fork, but the remote host's code root predates the setting. The host is reported not converged with a reason and an operator action, and its update is never run, so it is not str… ⏸️ untested no The prior payload did not establish a live result. The SSH transport was shimmed because no authorised SSH host is available. To test this live, provide an SSH-reachable lab host whose code root is at…
Fleet on origin (default) with a predating remote host: the update proceeds as before and nothing gets stuck ⏸️ untested no The prior payload did not establish a live result. The SSH transport was shimmed because no authorised SSH host is available. To test this live, provide an SSH-reachable lab host with an authorised ke…
  • bash drive-live.sh (evidence dir): real primary clone's bin/fm-update.sh against a disposable lab home with real bare origin and fork remotes; scenarios S1–S6 → r3-drive-live.transcript.txt
  • bash drive-remote-host.sh (evidence dir): host code root's own bin/fm-remote-secondmate-control.sh update sm1, with the home's inherited config set to fork and then absent → r3-drive-remote-host.transcript.txt
  • bash r3-drive-remote-route.sh (evidence dir): primary fm-update.sh with 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.txt
  • bash 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.

@Courtneyezra Courtneyezra changed the title feat(update): let a fleet self-update from its own fork feat(bin): let a fleet self-update from a configured fork remote Sep 27, 2026
@Courtneyezra

Copy link
Copy Markdown
Contributor Author

Investigation and hand-over notes

The 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 exists

Make it possible to run a Firstmate fleet from its own fork, and establish the facts that decision rests on before anything is re-pointed.

/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. This adds the missing setting, proves the update path still works through it, and leaves the actual re-point to an operator step that is deliberately not taken here.

What changed

  • bin/fm-ff-lib.sh gains fm_update_remote, the single owner of "which remote does this home self-update from". It reads $FM_HOME/config/update-remote, and an absent or blank file means origin, so a home that never creates the file behaves exactly as before.
  • default_branch now prefers the configured remote's recorded HEAD, then origin's, then a local main/master. With no setting the first two lookups are the same remote, so the resolution is byte-identical to today's.
  • fetch_once fetches the configured remote and keys its once-per-run cache by remote as well as by object store.
  • config/update-remote joins FM_INHERITABLE_CONFIG, so the primary's answer converges every secondmate home instead of each home drifting onto its own remote.
  • bin/fm-remote-secondmate-control.sh resolves the home's inherited answer and passes it to the code-root update as FM_UPDATE_REMOTE.
  • docs/configuration.md gains a "Self-update remote" section; AGENTS.md's config list and the updatefirstmate skill get their one-line pointers.

Two deliberate refusals

A configured remote that a target repo does not define is reported as skipped: no <remote> remote and nothing is fetched or advanced. It is not retried against origin: landing a home on the very main the setting exists to move it off is worse than not updating it.

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 found

A remote route's code-root update runs with FM_HOME pointed at the code root, while inherited material lands in that host's secondmate home. Without the hand-off above, that host would have kept following origin while the rest of the fleet followed the fork - silently, which is the exact failure mode the setting exists to prevent.

Evidence

tests/fm-update.test.sh extended with three cases, whole file green at 19/19:

  • T13 - both remotes are advanced to different commits, config/update-remote names the fork, and the primary and its secondmate must both land on the fork's tip and not on origin's. Asserts the fixture is non-vacuous, that HEAD is not origin's tip, and that the advance is still single-parent.
  • T14 - a configured remote the repo does not define is skipped naming it, nothing moves, and it did not fall back to origin (compared against the real bare repo, since a refused update never fetches).
  • T15 - a whitespace-only file resolves to origin.

bin/fm-lint.sh clean. bin/fm-doc-audience-check.sh ok, 109 surfaces / 660 local links.

Also green locally: fm-fleet-sync, fm-secondmate-sync, fm-secondmate-safety, fm-shared-captain-inheritance, fm-trace-context-lib, fm-spawn-dispatch-profile, fm-remote-secondmate-relaunch, fm-secondmate-harness.

A pre-existing flake this ran into, classified rather than assumed

fm-remote-secondmate-lifecycle-e2e failed locally at "remote spawn never reached its blocked inheritance write". That assertion waits a fixed 1500-iteration budget for a write that only happens after every inherited config item has made its own remote round trip, and this PR adds an eleventh item - so it was a fair suspect. Measured on both tips on the same loaded machine:

tip result
this branch ok=16, not ok=1, "remote spawn never reached its blocked inheritance write"
base bb69be62 ok=16, not ok=1, same assertion, same message

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 fm-secondmate-safety fail twice on tmpfs before passing cleanly off it.

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, measured

Measured 26 Sep 2026 after fetching both remotes.

ref head relationship
main (local) 2c80fb8d 19 ahead of origin, 98 behind
origin/main (kunchenguid) bb69be62 -
fork/main (Courtneyezra) 9284978f 63 behind origin, 0 ahead

The fork's 35 commits are not separate work. git merge-base --is-ancestor fork/main origin/main succeeds, and all 35 commits fork/main has that local main lacks are present in origin/main under the same SHAs. fork/main is a stale mirror of upstream, not a divergent line. There is nothing on it to duplicate or revert.

Our 19 commits are genuinely separate, established two independent ways because commit identity alone is not trustworthy here:

  1. git cherry origin/main main - 19 +, 0 -.
  2. Content, not identity: for each of the 19, what fraction of its added lines (ignoring lines under 12 non-whitespace characters) already appear in origin/main's own version of the same file. Result 0-18% for 18 of them - incidental overlap. The one outlier, 09b18c65, is a 10-line test-fixture commit at 6/10 on common boilerplate.

The captain's warning about identity comparison is real and reproducible on this repo: git cherry pr5673 fm/fm-closure-ledger reports all three local commits as -, meaning present on the fork under different SHAs. A SHA comparison would have called that work missing. Patch-id caught that case; the added-line check is the second method because patch-id itself fails when content arrives rebased or squashed.

What this does not change

  • It does not re-point anything. No remote is renamed, no config/update-remote is written, no home is moved. fm-reconcile-main-with-upstream is mid-run and its merge is measured against upstream; re-pointing under it was explicitly out of bounds.
  • It does not touch where branches are pushed or where pull requests are opened. origin still means the upstream project to every other command, which is what keeps the "stay open upstream" half of the decision working by default.
  • It closes nothing upstream. The five open requests are untouched.
  • It does not add a fork register. fm-reconcile-main-with-upstream is already introducing docs/fork-divergences.md; a second file competing with it would be worse than one.
  • The gnhf worktree and its branch were not touched.

The re-point, when the captain wants it

Every step below is proved by ancestry already measured; none of it is run by this PR. The order matters.

1. Land fm-reconcile-main-with-upstream first. Its merge ef9538cc already is the fork's new main: it carries upstream 72b63eee plus all 19 local commits, its 8 conflict hunks are resolved, and its two genuine behavioural incompatibilities are decided. Both of these hold today:

  • fork/main is an ancestor of ef9538cc, so publishing it to the fork is a fast-forward there.
  • 2c80fb8d is an ancestor of ef9538cc, and the primary plus both worktree secondmate homes sit exactly on 2c80fb8d, so every home fast-forwards onto it.

Nothing about that lane's nine hours needs redoing.

2. Publish it to the fork, then 3. set config/update-remote to fork in the primary home. Ordinary secondmate convergence carries the setting to all three registered homes.

4. Four homes must agree, and three of them are confirmed today.

home placement state needs
/home/fleet-max/firstmate primary on 2c80fb8d, has the fork remote the config/update-remote file
case-reviewer linked worktree on 2c80fb8d, shares the primary's .git, so fork is already there; has its own config/ nothing beyond the inherited file
desk-rebuild linked worktree same nothing beyond the inherited file
server-builder remote route on fleet-pro unconfirmed from here its code root /home/fleet-pro/firstmate almost certainly needs git remote add fork <url>

The remote host was deliberately not inspected from this task. Without that remote its update refuses with skipped: no fork remote, which is the guard working rather than a fault - but it must be confirmed rather than assumed, because an unnoticed refusal there is exactly the split fleet the captain ruled out. (Separately, desk-rebuild's charter says server-builder was retired on 22 Sep, yet it is still registered with a live record - worth reconciling, not touched here.)

5. The stranded contributions land on the fork and stay open upstream. All five are cross-repo PRs from Courtneyezra, so their head branches are already on the fork, and none is contained in ef9538cc:

upstream PR fork branch
#5772 fm/fm-autoarm-failure-latch-never-clears
#5676 fm/fm-procevent-no-setsid
#5673 fm/fm-closure-ledger
#5530 fm/fm-snapshot-arglist-too-long
#5829 fm/fm-pool-hands-out-occupied-worktree

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 creates

Following 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

.github/workflows/no-mistakes-required.yml is tracked material, so the fork carries it too, and its exempt-authors list contains only kunchenguid. CONTRIBUTING.md states the consequence plainly: a PR that does not satisfy the attestation contract "will not be reviewed or merged".

Two things follow for landing the stranded fixes on the fork, and they pull in opposite directions:

  • Courtneyezra/firstmate currently reports zero workflow runs. If Actions is off there, PRs onto the fork have no gate - and also no CI at all. Today the only reason our branches get 18-19 green checks is that upstream's CI runs them on our cross-repo PRs. Running from the fork silently gives that up unless Actions is enabled there.
  • If Actions is enabled on the fork, Require no-mistakes then fails every PR we raise onto our own main, because we are not the exempt author.

Whichever way that goes, it is a deliberate choice: enable Actions on the fork and add Courtneyezra to exempt-authors as a recorded fork divergence, or enable Actions and keep raising everything through no-mistakes as we already mostly do. This PR does not decide it, but "the fixes are landable" is not true until it is decided.

This PR itself is subject to the same gate upstream: it was raised direct-PR per its instructions, so PR must be raised via no-mistakes fails on it by construction. That is a delivery-route question for the supervisor, not a defect in the change.


Corrections to the above, established after it was first written

Two things in the section above were measured again and changed:

  1. Actions on the fork. Permissions are enabled (enabled: true, allowed_actions: all), but zero workflows are registered and zero runs exist - the standard fork state where workflow files arrive with the clone but stay dormant until enabled once. So today a PR onto the fork would get no checks at all. The gate itself is not an author-based obstacle: exempt-authors governs who may skip the attestation, not who may pass, and a no-mistakes PR from this account passes it. The precondition is a one-time enable of workflows on the fork.
  2. The fork being 63 behind is not cured by publishing the reconciliation merge. origin/main (bb69be62) is not contained in ef9538cc, so a host that has already advanced on origin cannot fast-forward to the fork even after that merge is published. That is why this change refuses to advance any target whose current commit the chosen remote's default branch does not contain, rather than reasoning about commit distances.

@Courtneyezra
Courtneyezra force-pushed the fm/fm-run-from-our-own-fork branch from 8520535 to 9ca4e01 Compare September 27, 2026 07:45
@Courtneyezra Courtneyezra changed the title feat(bin): let a fleet self-update from a configured fork remote feat(bin): let a fleet self-update from a configured remote such as its own fork Sep 27, 2026
@Courtneyezra
Courtneyezra force-pushed the fm/fm-run-from-our-own-fork branch from 9ca4e01 to a919306 Compare September 27, 2026 10:31
@Courtneyezra Courtneyezra changed the title feat(bin): let a fleet self-update from a configured remote such as its own fork feat(bin): let /updatefirstmate self-update from a configured fork remote Sep 27, 2026
/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.
@Courtneyezra
Courtneyezra force-pushed the fm/fm-run-from-our-own-fork branch from a919306 to 372e5e3 Compare September 27, 2026 15:02
@Courtneyezra Courtneyezra changed the title feat(bin): let /updatefirstmate self-update from a configured fork remote feat: let /updatefirstmate self-update from a configured fork remote Sep 27, 2026
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