fix(bin): spawn workers into projects with no origin remote - #2746
Open
Edvardunsvag wants to merge 2 commits into
Open
fix(bin): spawn workers into projects with no origin remote#2746Edvardunsvag wants to merge 2 commits into
Edvardunsvag wants to merge 2 commits into
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
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:
WHAT MUST NOT BE WEAKENED (this is a safety guard; keep all of it, including on the new branch):
TESTS: tests/fm-spawn-pool-base-freshen.test.sh already exists; extend it to cover:
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:
IMPLEMENTATION DECISIONS MADE WHILE DOING THE WORK, which a reviewer reading only the diff would not know:
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()inbin/fm-spawn.shnow checks for anoriginremote withgit remote get-url originbefore refreshing. Withoriginpresent the fetch,remote set-head, and reset toorigin/<default>are unchanged; without it the fetch and set-head are skipped and the base becomes the localrefs/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.shgained 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 intomake_case_base()withmake_case()andmake_case_no_remote()on top, plus anadvance_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.mdand thebin/fm-spawn.shheader 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 originidiom 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 -vfails at spawn on the base commit with the exact reportedcould not fetch origin ... potentially stale baseerror and succeeds on the target commit, with the task worktree pulled forward onto the project's local default-branch tip. The owner testtests/fm-spawn-pool-base-freshen.test.shpasses 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
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
Evidence: Full test run with observed spawn and refusal output
Source: Full test run with observed spawn and refusal output
Evidence: Reported symptom reproduced on base commit 3f03533
Evidence: Same command on target commit 08d105c
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
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 thanorigin(e.g.upstream) failsgit remote get-url originand now takes the local branch, where before it refused becausegit fetch originerrored. Such a project could be behindupstream/<default>and the worker would silently start on a stale base. Noting it, not asking for a change: the intent explicitly prescribesgit -C "$worktree" remote get-url originas 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 printedbin/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 greenManual before/after CLI transcript: built a registered project with an emptygit remote -v, advanced its local default branch, and ran the realbin/fm-spawn.sh <id> <project> --mode local-only --yolo offagainstbin/fm-spawn.shfrom 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 againstbin/fm-spawn.shfrom 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_defaultall failMutation test 1: replaced thegit remote get-url originprobe withfalse(always take local) and re-rantest_unreachable_origin_refuses_stale_pool_base— caught,not ok - spawn succeeded despite an unreachable originMutation test 2: rewrote the branch to infer 'no remote' from a failinggit fetch originand re-rantest_unreachable_origin_refuses_stale_pool_base— caught,not ok - spawn succeeded despite an unreachable originVerified the origin branch of the new if/else is byte-identical to the pre-fix code modulo indentation by diffing the whitespace-stripped block againstgit show 3f03533:bin/fm-spawn.shgit status --porcelainafter restoring the mutated source and deleting the temporary test drivers — clean, still at 08d105cbin/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.✅ **Push** - passed
✅ No issues found.