Skip to content

fix(bin): spawn workers into projects with no origin remote - #2746

Open
Edvardunsvag wants to merge 2 commits into
kunchenguid:mainfrom
Edvardunsvag:fm/fm-spawn-no-remote
Open

fix(bin): spawn workers into projects with no origin remote#2746
Edvardunsvag wants to merge 2 commits into
kunchenguid:mainfrom
Edvardunsvag:fm/fm-spawn-no-remote

Conversation

@Edvardunsvag

Copy link
Copy Markdown

Intent

bin/fm-spawn.sh cannot spawn a worker into a registered project that has no 'origin' remote. Fix that.

REPRODUCED SYMPTOM: a registered project whose 'git remote -v' is empty fails at spawn and the whole launch stops:
fatal: Could not read from remote repository.
error: could not fetch origin for pooled worktree ''; refusing to launch from a potentially stale base

CAUSE: freshen_spawn_worktree_base() in bin/fm-spawn.sh was unconditional and assumed 'origin' in four consecutive steps: 'git fetch origin', 'git remote set-head origin --auto', default_branch, and 'git fetch origin +refs/heads/$default:refs/remotes/origin/$default', then 'reset --hard origin/$default'. The first step fails when there is no remote at all, and the caller's 'freshen_spawn_worktree_base "$WT" || exit 1' aborts the spawn.

WHY THIS IS A BUG AND NOT INTENDED BEHAVIOR: AGENTS.md section 6 and .agents/skills/project-management/SKILL.md both state that a 'local-only' project may have no remote, and data/projects.md can register such a project. That posture was therefore useless in practice: firstmate could register the project but never put a worker on it.

WHAT THE FIX SHOULD DO: when the project has no 'origin' remote, the fresh base is the project's LOCAL default branch. The pooled task worktree is a linked worktree of the same git dir as the primary checkout, so refs/heads/ is already visible with no fetch at all. Reset to that instead. Specifically:

  • Detect the absence of 'origin' EXPLICITLY (for example 'git -C "$worktree" remote get-url origin'), not by letting fetch fail and guessing why. An 'origin' that exists but is unreachable must STILL be an error: that is exactly a potentially stale base.
  • Without 'origin': skip the fetch and the remote set-head, resolve the default branch locally, and 'reset --hard '.
  • With 'origin': no behavior change whatsoever.

WHAT MUST NOT BE WEAKENED (this is a safety guard; keep all of it, including on the new branch):

  • Refusal when the worktree is not clean ('git status --porcelain' non-empty). It must never discard uncommitted work.
  • The verification afterwards: that HEAD actually landed on the expected commit, and refusal if not.
  • Refusal when the default branch cannot be resolved or is not a commit.
  • The isolation assertion in validate_spawn_worktree - do not touch it.

TESTS: tests/fm-spawn-pool-base-freshen.test.sh already exists; extend it to cover:

  • A project with no remote: spawn succeeds and the worktree stands on the project's local default-branch commit.
  • A project with no remote where the local default branch has moved: the worktree is pulled forward to the new commit, not left on the old one.
  • A project with no remote with uncommitted changes in the worktree: refused, as today.
  • A project WITH an 'origin' that does not answer: still refused. This is the regression guard proving the fix did not simply switch the check off.
  • Existing tests for the remote path must still pass unchanged.
    Use bin/fm-test-run.sh the way the repo's other tests are run.

DOCUMENTATION: if a tracked doc describes this guard, update it to mention the remote-less path. Do not duplicate the contract in several places - point to the owner.

ACCEPTANCE:

  • A worker can be spawned into a registered project with no remote.
  • No behavior change for projects with a working 'origin'.
  • An unreachable 'origin' is still refused.
  • Uncommitted work is still refused.
  • bin/fm-test-run.sh tests/fm-spawn-pool-base-freshen.test.sh is green, and the whole suite is green.

IMPLEMENTATION DECISIONS MADE WHILE DOING THE WORK, which a reviewer reading only the diff would not know:

  • The origin branch of the new if/else is byte-identical to the previous code apart from indentation; this was deliberate so the remote path provably does not change.
  • The clean-worktree check, the '^{commit}' resolution, the reset and the post-reset HEAD verification were deliberately left AFTER the if/else as shared code, so neither path can bypass them structurally.
  • default_branch() comes from bin/fm-ff-lib.sh and already falls back to local refs/heads/main then refs/heads/master when there is no origin/HEAD, so the remote-less path reuses it rather than adding a second default-branch resolver.
  • A remote-less project whose default branch is neither main nor master is deliberately still a refusal ('could not determine the local default branch'), because an unresolvable base must fail rather than guess. A test covers this.
  • The pre-existing unreachable-origin test was strengthened, not just kept: it now advances the LOCAL default branch first, so a wrong fallback to local history would both succeed AND move HEAD to an observable commit instead of coincidentally landing on the same SHA. This was verified by mutation testing in an isolated copy of the repo: the guard catches both plausible wrong fixes (always-take-local, and inferring 'no remote' from a failing fetch), and all four new tests were confirmed to fail against the unfixed code.
  • The test fixture was refactored into a shared make_case_base() with make_case() (adds origin) and make_case_no_remote() (asserts no remote at all) on top, to avoid duplicating the setup a third time.
  • docs/architecture.md gained ONE sentence about the remote-less path; the contract itself stays owned by the fm-spawn.sh header comment, which was also updated. This follows the repo's one-owner rule rather than restating the contract in both places.
  • AGENTS.md was deliberately NOT changed: it does not describe this guard, and adding conditional detail there would violate the repo's AGENTS.md size discipline.

KNOWN PRE-EXISTING ENVIRONMENT STATE, not caused by this change: the full suite on this machine reports 20 failing scripts, and all 20 were verified to fail identically on the base commit 3f03533. They are environmental (no tmux, no actionlint, missing herdr adapter, timeouts). tests/fm-spawn-pool-base-freshen.test.sh itself passes all 10 cases inside the full run.

What Changed

  • freshen_spawn_worktree_base() in bin/fm-spawn.sh now checks for an origin remote with git remote get-url origin before refreshing. With origin present the fetch, remote set-head, and reset to origin/<default> are unchanged; without it the fetch and set-head are skipped and the base becomes the local refs/heads/<default> already visible in the linked worktree. The clean-worktree check, default-branch resolution, <target>^{commit} lookup, reset, and post-reset HEAD verification stay shared after the branch, so neither path can skip them.
  • tests/fm-spawn-pool-base-freshen.test.sh gained four cases for the remote-less path: spawn lands on the local default-branch commit, follows that branch when it moves, refuses a dirty worktree without discarding the uncommitted file, and refuses an unresolvable default branch. The fixture was split into make_case_base() with make_case() and make_case_no_remote() on top, plus an advance_local_default() helper. The existing unreachable-origin test now advances the local default branch first and asserts HEAD did not move to it, so a fallback to local history would be caught rather than coincidentally matching.
  • docs/architecture.md and the bin/fm-spawn.sh header note the remote-less path; the refusal mechanics stay owned by the script header.

Risk Assessment

✅ Low: The change is tightly bounded to one function: the origin path is provably byte-identical apart from indentation, all four refusals (dirty worktree, unresolvable default, non-commit target, post-reset HEAD verification) remain shared code after the branch so neither path can bypass them, the unreachable-origin regression guard was strengthened rather than relaxed, and the new remote-less path reuses the same remote get-url origin idiom three sibling scripts already use.

Testing

I drove the real spawn path rather than only asserting on unit results: a registered project with an empty git remote -v fails at spawn on the base commit with the exact reported could not fetch origin ... potentially stale base error and succeeds on the target commit, with the task worktree pulled forward onto the project's local default-branch tip. The owner test tests/fm-spawn-pool-base-freshen.test.sh passes all ten cases, and each of the four new cases was confirmed to fail against the base commit, so they are genuine regression coverage. To check the guard was not merely switched off, I applied the two plausible wrong fixes — always take local, and infer "no remote" from a failing fetch — and the unreachable-origin case caught both; the source was restored and the tree left clean. The related targeted set (spawn worktree settle, spawn batch, documentation audiences) is also green. This is a CLI change with no rendered surface, so the reviewer-visible evidence is a before/after command transcript rather than a screenshot. I deliberately did not run the full suite: that is remote CI's regression boundary, and the author already notes 20 environmentally failing scripts that also fail on the base commit.

Evidence: Before/after CLI transcript: spawning into a project with no remote

Source: Before/after CLI transcript: spawning into a project with no remote

# Spawning a worker into a registered project with no remote

Real `bin/fm-spawn.sh` runs against a registered project whose `git remote -v`
is empty. Same fixture, same command, only `bin/fm-spawn.sh` differs.

## Before — base commit 3f03533

`` `console
$ git -C <project> remote -v
  (no output: this registered project has no remote)

$ git -C <project> log --oneline -1 main
18ad198 advance-main

$ bin/fm-spawn.sh local-only-worker <project> --mode local-only --yolo off
warning: .../data/local-only-worker/brief.md records no delivery contract line ...
notice: local-only-worker ships mode=local-only while the standing posture for project is no-mistakes ...
fatal: 'origin' does not appear to be a git repository
fatal: Could not read from remote repository.

Please make sure you have the correct access rights
and the repository exists.
error: could not fetch origin for pooled worktree '.../evidence-before/pool'; refusing to launch from a potentially stale base
[exit status: 1]

$ git -C <task-worktree> log --oneline -1 HEAD
0140a21 initial
  project local main is at 18ad198
`` `

The launch stops. No worker starts. This is the reported symptom, verbatim.

## After — target commit 08d105c

`` `console
$ git -C <project> remote -v
  (no output: this registered project has no remote)

$ git -C <project> log --oneline -1 main
6e813e9 advance-main

$ bin/fm-spawn.sh local-only-worker <project> --mode local-only --yolo off
warning: .../data/local-only-worker/brief.md records no delivery contract line ...
notice: local-only-worker ships mode=local-only while the standing posture for project is no-mistakes ...
spawned local-only-worker harness=codex kind=ship mode=local-only yolo=off window=firstmate:fm-local-only-worker worktree=.../evidence-after/pool
[exit status: 0]

$ git -C <task-worktree> log --oneline -1 HEAD
6e813e9 advance-main
  project local main is at 6e813e9
`` `

The worker spawns. The task worktree was pulled forward from `initial` onto the
project's local default-branch tip `6e813e9`, with no fetch attempted.
Evidence: The safety guard was not weakened: refusals, mutation tests, and pre-fix failures

Source: The safety guard was not weakened: refusals, mutation tests, and pre-fix failures

# The safety guard was not switched off

`FM_TEST_EVIDENCE=1 bin/fm-test-run.sh tests/fm-spawn-pool-base-freshen.test.sh`
prints the refusal each guard case actually produced.

## An origin that exists but does not answer — still refused

`` `
error: could not fetch origin for pooled worktree '.../unreachable-origin/pool'; refusing to launch from a potentially stale base
`` `

The fixture advances the project's LOCAL default branch first, so a wrong
fallback to local history would succeed and move HEAD to an observable commit.
It does neither.

## Uncommitted work — still refused, still preserved

With an origin:

`` `
error: pooled worktree '.../dirty-refusal/pool' is not clean; refusing to discard uncommitted work while refreshing its base
preserved=keep this local work
`` `

Without an origin (new case) the same refusal fires and `uncommitted.txt`
survives.

## Unresolvable default branch — still refused

With an origin:

`` `
error: could not resolve origin's current default branch for pooled worktree '.../unresolved-default/pool'; refusing to launch from a potentially stale base
`` `

Without an origin, a project whose default branch is neither `main` nor
`master` is refused rather than guessed.

## Mutation check — the unreachable-origin case genuinely catches wrong fixes

Two plausible wrong fixes were applied to `bin/fm-spawn.sh` in turn and the
unreachable-origin case re-run:

| Wrong fix | Result |
|---|---|
| always take the local default branch, ignore origin | `not ok - spawn succeeded despite an unreachable origin` |
| infer "no remote" from a failing `git fetch` | `not ok - spawn succeeded despite an unreachable origin` |

Both are caught. The source was restored afterwards; the working tree is clean.

## The four new cases fail against the unfixed code

Run against `bin/fm-spawn.sh` from base commit 3f03533:

`` `
test_project_without_origin_spawns_from_local_default   -> exit=1  not ok - spawn should launch into a project that has no origin remote: expected exit 0, got 1
test_project_without_origin_refreshes_moved_local_default -> exit=1  not ok - spawn should refresh a stale remote-less pooled worktree: expected exit 0, got 1
test_project_without_origin_refuses_dirty_pool          -> exit=1  not ok - spawn did not clearly refuse a dirty remote-less pooled worktree (missing: 'is not clean')
test_project_without_origin_refuses_unresolved_default  -> exit=1  not ok - spawn did not clearly refuse an unresolvable remote-less default branch (missing: 'default branch')
`` `

The two refusal cases fail on the old code for the right reason: it never
reaches the clean-worktree or default-branch check, because the fetch already
aborted the spawn.
Evidence: Full test run with observed spawn and refusal output

Source: Full test run with observed spawn and refusal output

FM_TEST_BEGIN 2026-08-21T13:56:03Z tests/fm-spawn-pool-base-freshen.test.sh family=unclassified expected_gate_skip=none
# observed spawn: spawned pool-current-base-r1 harness=codex kind=ship mode=no-mistakes yolo=off window=firstmate:fm-pool-current-base-r1 worktree=/var/folders/t3/3phqjpdd0ljb54t7ggtwtr980000gp/T//fm-spawn-pool-base-freshen.7slp3u/current-base/pool
# observed base: HEAD=971eff60e88b96e8a089b4504976a3ef15eea4e0 origin/main=971eff60e88b96e8a089b4504976a3ef15eea4e0 advanced-main=must survive a newly spawned branch
ok - a stale pooled worktree refreshes to current origin/main before a crew branch is created
ok - a stale pooled worktree resolves and refreshes a non-main default branch
# observed direct-pr spawn: spawned pool-direct-pr-r3 harness=codex kind=ship mode=direct-PR yolo=off window=firstmate:fm-pool-direct-pr-r3 worktree=/var/folders/t3/3phqjpdd0ljb54t7ggtwtr980000gp/T//fm-spawn-pool-base-freshen.7slp3u/direct-pr/pool
# observed scout spawn: spawned pool-scout-r3 harness=codex kind=scout window=firstmate:fm-pool-scout-r3 worktree=/var/folders/t3/3phqjpdd0ljb54t7ggtwtr980000gp/T//fm-spawn-pool-base-freshen.7slp3u/scout/pool
ok - direct-PR ships and scouts both refresh stale pooled worktrees before launch
# observed dirty refusal: error: pooled worktree '/var/folders/t3/3phqjpdd0ljb54t7ggtwtr980000gp/T//fm-spawn-pool-base-freshen.7slp3u/dirty-refusal/pool' is not clean; refusing to discard uncommitted work while refreshing its base; preserved=keep this local work
ok - a dirty pooled worktree is refused without discarding its local work
# observed unresolved-default refusal: error: could not resolve origin's current default branch for pooled worktree '/var/folders/t3/3phqjpdd0ljb54t7ggtwtr980000gp/T//fm-spawn-pool-base-freshen.7slp3u/unresolved-default/pool'; refusing to launch from a potentially stale base
ok - an unresolved remote default branch refuses the pooled worktree
# observed unreachable-origin refusal: error: could not fetch origin for pooled worktree '/var/folders/t3/3phqjpdd0ljb54t7ggtwtr980000gp/T//fm-spawn-pool-base-freshen.7slp3u/unreachable-origin/pool'; refusing to launch from a potentially stale base
ok - an unreachable origin refuses a potentially stale pooled worktree
# observed no-remote spawn: spawned pool-no-remote-r6 harness=codex kind=ship mode=local-only yolo=off window=firstmate:fm-pool-no-remote-r6 worktree=/var/folders/t3/3phqjpdd0ljb54t7ggtwtr980000gp/T//fm-spawn-pool-base-freshen.7slp3u/no-remote/pool
# observed no-remote base: HEAD=06c9ab3b2a8682a7d435463bbab5e48f77b8577d local main=06c9ab3b2a8682a7d435463bbab5e48f77b8577d
ok - a project with no origin remote spawns from its local default branch
ok - a moved local default branch pulls a remote-less pooled worktree forward
ok - a remote-less project still refuses a dirty pooled worktree without discarding work
ok - a remote-less project with no resolvable default branch refuses the pooled worktree
# all fm-spawn-pool-base-freshen tests passed
FM_TEST_END 2026-08-21T13:56:31Z tests/fm-spawn-pool-base-freshen.test.sh exit=0 duration_ms=27823 gate_skip=false
FM_TEST_SUMMARY total=1 failed=0 skipped_gate=0 duration_ms=27889
FM_TEST_SUMMARY_FAMILY family=unclassified count=1 duration_ms=27823 failed=0
FM_TEST_SLOWEST rank=1 script=tests/fm-spawn-pool-base-freshen.test.sh duration_ms=27823
Evidence: Reported symptom reproduced on base commit 3f03533
$ bin/fm-spawn.sh local-only-worker <project> --mode local-only --yolo off
fatal: 'origin' does not appear to be a git repository
fatal: Could not read from remote repository.

Please make sure you have the correct access rights
and the repository exists.
error: could not fetch origin for pooled worktree '.../evidence-before/pool'; refusing to launch from a potentially stale base
[exit status: 1]
Evidence: Same command on target commit 08d105c
$ bin/fm-spawn.sh local-only-worker <project> --mode local-only --yolo off
spawned local-only-worker harness=codex kind=ship mode=local-only yolo=off window=firstmate:fm-local-only-worker worktree=.../evidence-after/pool
[exit status: 0]

$ git -C <task-worktree> log --oneline -1 HEAD
6e813e9 advance-main
project local main is at 6e813e9

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ℹ️ bin/fm-spawn.sh:1746 - The origin probe widens what counts as "local-only" slightly beyond a truly remote-less project. A repo whose only remote is named something other than origin (e.g. upstream) fails git remote get-url origin and now takes the local branch, where before it refused because git fetch origin errored. Such a project could be behind upstream/&lt;default&gt; and the worker would silently start on a stale base. Noting it, not asking for a change: the intent explicitly prescribes git -C &#34;$worktree&#34; remote get-url origin as the detector, and every other origin-optional path in the repo (fm-teardown.sh:869, fm-review-diff.sh:136, fm-ff-lib.sh:295) already treats "no origin" as local-only, so this is consistent with the codebase rather than a divergence introduced here.
✅ **Test** - passed

✅ No issues found.

  • FM_TEST_EVIDENCE=1 bin/fm-test-run.sh tests/fm-spawn-pool-base-freshen.test.sh — all 10 cases pass, with the observed spawn lines and refusal messages printed
  • bin/fm-test-run.sh tests/fm-spawn-pool-base-freshen.test.sh tests/fm-spawn-worktree-settle.test.sh tests/fm-spawn-batch.test.sh tests/fm-documentation-audiences.test.sh — targeted related set, 4/4 scripts green
  • Manual before/after CLI transcript: built a registered project with an empty git remote -v, advanced its local default branch, and ran the real bin/fm-spawn.sh &lt;id&gt; &lt;project&gt; --mode local-only --yolo off against bin/fm-spawn.sh from base 3f03533 (exit 1, could not fetch origin) and from target 08d105c (exit 0, spawned local-only-worker, HEAD on the local default-branch tip)
  • Regression proof: ran each new case individually against bin/fm-spawn.sh from base commit 3f03533 — test_project_without_origin_spawns_from_local_default, test_project_without_origin_refreshes_moved_local_default, test_project_without_origin_refuses_dirty_pool, test_project_without_origin_refuses_unresolved_default all fail
  • Mutation test 1: replaced the git remote get-url origin probe with false (always take local) and re-ran test_unreachable_origin_refuses_stale_pool_base — caught, not ok - spawn succeeded despite an unreachable origin
  • Mutation test 2: rewrote the branch to infer 'no remote' from a failing git fetch origin and re-ran test_unreachable_origin_refuses_stale_pool_base — caught, not ok - spawn succeeded despite an unreachable origin
  • Verified the origin branch of the new if/else is byte-identical to the pre-fix code modulo indentation by diffing the whitespace-stripped block against git show 3f03533:bin/fm-spawn.sh
  • git status --porcelain after restoring the mutated source and deleting the temporary test drivers — clean, still at 08d105c
⚠️ **Document** - 1 info
  • ℹ️ bin/fm-ff-lib.sh:37 - The remote-less spawn path resolves the base through default_branch(), which without origin/HEAD only accepts local refs/heads/main or refs/heads/master. A registered local-only project whose default branch is named anything else (e.g. trunk) is therefore refused with 'could not determine the local default branch'. That is deliberate and test-covered, and fm-spawn.sh's header does say an unresolved default branch stops the spawn, but no doc states which local branch names resolve. default_branch() is a shared helper with several pre-existing consumers and no doc comment, so writing that supported limit at its owner is a follow-up rather than part of this change's staleness.
⚠️ **Lint** - 1 warning
  • ⚠️ linter found issues (exit code 127)
✅ **Push** - passed

✅ No issues found.

Edvardunsvag and others added 2 commits August 21, 2026 15:49
…efault branch

freshen_spawn_worktree_base() assumed an origin remote unconditionally: it ran
`git fetch origin`, `git remote set-head origin --auto`, then fetched and reset
to `origin/<default>`. A project with no remote failed on the first step, and
the caller turned that into a refusal, so a registered project without an origin
could never have a worker spawned into it at all. AGENTS.md section 6 and the
project-management skill both allow a local-only project to have no remote, so
that posture was unusable in practice.

A project with no origin has no remote tip to be stale against. Its current base
is its own local default branch, and the pooled task worktree is a linked
worktree of the same git dir as the primary checkout, so refs/heads/<default> is
already visible with no fetch. Detect the absence of origin explicitly with
`git remote get-url origin` rather than inferring it from a failing fetch, then
skip the fetch and set-head and reset to the local branch instead.

An origin that exists but is unreachable is still a refusal - that is exactly
the potentially stale base this guard exists to catch. The origin path is
otherwise unchanged. Both paths keep every other refusal: a non-clean worktree
is never discarded, an unresolvable or non-commit default branch stops the
spawn, and HEAD is verified to have landed on the expected commit.

Tests cover the remote-less path end to end through the real spawn: a clean
launch onto the local default-branch commit, a moved local default branch
pulling a stale worktree forward, and the unchanged dirty-worktree and
unresolvable-default refusals. The unreachable-origin regression test now
advances the local default branch first, so a wrong fallback to local history
would both succeed and move HEAD to an observable commit instead of landing on
the same SHA by coincidence.
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