Skip to content

fix(spawn): let a local-only task launch in a project with no origin - #11

Merged
doitdigital0495 merged 2 commits into
mainfrom
fm/firstmate-local-only-no-origin
Aug 25, 2026
Merged

fix(spawn): let a local-only task launch in a project with no origin#11
doitdigital0495 merged 2 commits into
mainfrom
fm/firstmate-local-only-no-origin

Conversation

@doitdigital0495

Copy link
Copy Markdown
Owner

Intent

Fix Firstmate spawning for a registered local-only project that intentionally has no origin remote.

Reported end-user problem, already observed through the real path: after a repository moved into a secondmate home and was registered local-only with no remote, every product worker spawn reached freshen_spawn_worktree_base in bin/fm-spawn.sh, ran 'git fetch --quiet origin' unconditionally, and refused with "could not fetch origin ...; refusing to launch from a potentially stale base". Planning-repository workers still spawned because that repository has an origin.

Required behavior the captain specified:

  • A project with no configured origin may use its current local default branch as the fresh worktree base when the task delivery mode is local-only.
  • An origin that exists but is unreachable, fails authentication, or cannot resolve its remote default branch must continue to stop safely exactly as today.
  • PR-based modes must not silently accept a repository with no origin.
  • Do not weaken worktree isolation, clean-tree checks, default-branch resolution, branch ownership, metadata, backend behavior, or any unrelated spawn safety.
  • Prefer one guard at the shared causal boundary over caller-specific exceptions.
  • Turn the faithful reproduction into a regression test using the repo's existing colocated test style and public executable behavior, including the success path and both refusal paths (existing-but-unreachable origin, and PR mode without origin).
  • Inspect every supported runtime backend and harness integration surface affected by this shared spawn path, marking genuinely irrelevant axes only after inspection.
  • Update authoritative operator/maintainer documentation only if current behavior or a stable invariant changes; keep mechanics in the script header/help owner.
  • No agent co-author on commits.

Diagnosis performed before coding, per the repo's diagnostic-reasoning procedure. Reproduced through the real bin/fm-spawn.sh with a fixture project that has no remote at all. Initiating trigger: freshen_spawn_worktree_base fetches origin unconditionally on every fresh ship or scout spawn. Masking condition: a configured origin - every previously registered project had one, so the unconditional fetch always succeeded, which is why the origin-backed planning repository still worked. Visible symptom: 'fatal: origin does not appear to be a git repository' followed by the stale-base refusal and exit 1. Earliest divergence from the proven origin-backed path is the existence of the remote, not its reachability. History: the function arrived whole in commit fc5f164, whose purpose was protecting a PR from a stale base - with no origin there is no PR and nothing to be stale against. Smallest counterfactual: the same fixture under --mode local-only now spawns and lands on the local default-branch tip. Disconfirming check deliberately retained as a test: an existing-but-unreachable origin under --mode local-only must still refuse, proving the change is not 'tolerate offline'.

Deliberate implementation decisions a reviewer reading only the diff would not know:

  • The guard is placed inside freshen_spawn_worktree_base, before the first git call that assumes origin exists, because that function is the single shared causal boundary every backend and harness routes through (one call site, after all backend-specific worktree resolution: tmux, herdr, zellij and cmux via the treehouse-get poll, orca via its own earlier worktree resolution). Caller-specific exceptions were deliberately rejected.
  • 'No origin configured' is treated as a categorically different fact from 'origin unreachable'. Nothing can go stale against a remote the repository does not have, so no fetch, credential, or network state can change that verdict. Every existing refusal - unreachable origin, unresolvable remote default branch, dirty worktree, non-commit target, post-reset HEAD verification - is untouched.
  • Only mode=local-only is allowed to launch without an origin. Every push mode and also a scout still refuse, naming the posture in the message. Allowing scouts was considered and deliberately NOT done: the captain's stated requirement names local-only only, and refusing a scout is the existing safe behavior rather than a new silent broadening. This is a known, intentional remaining limitation.
  • When there is no origin, the base target is the repository's own current local default branch resolved through the existing default_branch helper (refs/heads/); a repository with no origin and no local main or master refuses with an explicit unknown-base message rather than guessing.
  • The clean-tree check, reset, and post-reset HEAD verification stay shared by both paths, so no safety step is duplicated or bypassed.
  • Backend and harness axes were inspected rather than assumed irrelevant: the guard is pure git on the resolved worktree path and reads only the already-validated delivery mode, so it is backend- and harness-independent; the single shared call site is what makes one guard sufficient.
  • Documentation: only bin/fm-spawn.sh's own header (the mechanics owner) documents this invariant, so it was updated there; no other maintained prose surface documents it, and bin/fm-doc-audience-check.sh passes.
  • Tests extend the existing colocated tests/fm-spawn-pool-base-freshen.test.sh rather than adding a new runner, and drive the real spawn executable: no-origin local-only success starting from an advanced local default branch, no-origin refusal for no-mistakes, direct-PR and scout with HEAD proven unmoved, and local-only still refused by an existing-but-unreachable origin.

Validation already run locally before this pipeline: bin/fm-lint.sh green, bin/fm-doc-audience-check.sh green, and the spawn/brief group (fm-spawn-pool-base-freshen, fm-spawn-batch, fm-spawn-dispatch-profile, fm-spawn-worktree-settle, fm-brief, fm-task-delivery, fm-trace-context-spawn) green with 0 failures.

What Changed

  • freshen_spawn_worktree_base in bin/fm-spawn.sh now checks for a configured origin before its first git call: with an origin present the existing fetch, remote set-head, default-branch resolution and refusals are unchanged; with no origin at all a --mode local-only ship task bases on the repository's own local default branch (refs/heads/<default>) instead.
  • Without an origin, any push mode and any scout refuse with a message naming the spawn's posture, and a repository with no origin and no local main/master refuses with an explicit unknown-base error. The shared clean-tree check, reset and post-reset HEAD verification run for both paths.
  • tests/fm-spawn-pool-base-freshen.test.sh gains cases driving the real spawn executable: no-origin local-only success from an advanced local default branch, no-origin refusal for no-mistakes, direct-PR and scout with HEAD proven unmoved, and local-only still refused by an existing-but-unreachable origin. docs/architecture.md records the no-origin local-only exception to the base-freshness invariant.

Risk Assessment

✅ Low: The change is one guard at the single shared spawn boundary, leaves every existing refusal path intact, is covered by behavioral tests on the real executable, and the pipeline's doc fix correctly patches the one authoritative invariant sentence in place.

Testing

Ran the colocated base-freshen regression suite (9 tests green), proved the 3 new cases are a real regression by re-running them against the base-commit script (the no-origin local-only case fails there with exit 1), and captured a before/after CLI transcript from the real bin/fm-spawn.sh showing the reported fatal: 'origin' does not appear to be a git repository + stale-base refusal turning into a successful spawn that starts from the verified local default-branch tip, while push modes, scouts, and an unreachable origin still refuse with the pooled worktree HEAD unmoved; adjacent spawn/delivery tests stayed green and the worktree was left clean. This is a shell CLI change with no rendered surface, so the product-level evidence is CLI transcripts rather than screenshots.

Evidence: Before/after CLI transcript: no-origin local-only spawn

Source: Before/after CLI transcript: no-origin local-only spawn

=== BEFORE (base commit fc6a4e1) === $ fm-spawn.sh demo-local-only-task <project> --mode local-only --yolo off fatal: 'origin' does not appear to be a git repository fatal: Could not read from remote repository. error: could not fetch origin for pooled worktree '<work>/pool'; refusing to launch from a potentially stale base exit=1 $ git -C pool rev-parse HEAD 2f34d82c... (project main = 46a19dd7... -> stale) $ ls pool/newer.txt No such file or directory === AFTER (target commit 7a827f4) === $ fm-spawn.sh demo-local-only-task <project> --mode local-only --yolo off spawned demo-local-only-task harness=codex kind=ship mode=local-only yolo=off window=firstmate:fm-demo-local-only-task worktree=<work>/pool exit=0 $ git -C pool log --oneline -1 68e2be8 advance-main $ git -C project rev-parse main 68e2be8156e1396c2fe18b74955e22802b387b59 $ git -C pool rev-parse HEAD 68e2be8156e1396c2fe18b74955e22802b387b59 $ ls pool/newer.txt <work>/pool/newer.txt

Reported end-user problem: a project registered local-only with no origin remote.
Real bin/fm-spawn.sh driven with a fake terminal; project has no remote at all.

=== BEFORE (base commit fc6a4e1 bin/fm-spawn.sh) ===
$ git -C project remote -v      # registered local-only, no remote at all
(no output: no origin configured)

$ fm-spawn.sh demo-local-only-task <project> --mode local-only --yolo off
warning: <work>/home/data/demo-local-only-task/brief.md records no delivery contract line (scaffolded before ship briefs recorded one); launching on the explicit --mode local-only - confirm its definition of done matches
notice: demo-local-only-task ships mode=local-only while the standing posture for project is no-mistakes - less rigor than the captain's standing posture; proceed only on a current explicit captain instruction or an intake judgment you can state
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 '<work>/pool'; refusing to launch from a potentially stale base
exit=1

$ git -C pool log --oneline -1  # base the worker actually starts from
2f34d82 initial
$ git -C project rev-parse main
46a19dd75e2e0d0d489f9e13976daf346b191708
$ git -C pool rev-parse HEAD   
2f34d82c55acf0c6bc51ace04ee429c214c057d1
$ ls pool/newer.txt
ls: cannot access '<work>/pool/newer.txt': No such file or directory

=== AFTER (target commit 7a827f4) ===
$ git -C project remote -v      # registered local-only, no remote at all
(no output: no origin configured)

$ fm-spawn.sh demo-local-only-task <project> --mode local-only --yolo off
warning: <work>/home/data/demo-local-only-task/brief.md records no delivery contract line (scaffolded before ship briefs recorded one); launching on the explicit --mode local-only - confirm its definition of done matches
notice: demo-local-only-task ships mode=local-only while the standing posture for project is no-mistakes - less rigor than the captain's standing posture; proceed only on a current explicit captain instruction or an intake judgment you can state
spawned demo-local-only-task harness=codex kind=ship mode=local-only yolo=off window=firstmate:fm-demo-local-only-task worktree=<work>/pool
exit=0

$ git -C pool log --oneline -1  # base the worker actually starts from
68e2be8 advance-main
$ git -C project rev-parse main
68e2be8156e1396c2fe18b74955e22802b387b59
$ git -C pool rev-parse HEAD   
68e2be8156e1396c2fe18b74955e22802b387b59
$ ls pool/newer.txt
<work>/pool/newer.txt
Evidence: Observed spawn lines and exact refusal messages from the real executable

Source: Observed spawn lines and exact refusal messages from the real executable

# observed no-origin local-only spawn: spawned pool-no-origin-local-only-r6 harness=codex kind=ship mode=local-only yolo=off ... # observed no-mistakes no-origin refusal: error: pooled worktree '<tmp>/no-origin-refusal-1/pool' has no 'origin' remote; only a local-only ship task may launch from a repository with no origin, and this spawn is mode=no-mistakes; give the project an origin or ship the task --mode local-only # observed direct-PR no-origin refusal: ... this spawn is mode=direct-PR; ... # observed scout no-origin refusal: ... this spawn is a scout; ... # observed local-only unreachable-origin refusal: error: could not fetch origin for pooled worktree '<tmp>/local-only-unreachable-origin/pool'; refusing to launch from a potentially stale base # all fm-spawn-pool-base-freshen tests passed

# observed spawn: spawned pool-current-base-r1 harness=codex kind=ship mode=no-mistakes yolo=off window=firstmate:fm-pool-current-base-r1 worktree=<tmp>/current-base/pool
# observed base: HEAD=c8a61ca212296d708e6cda70f78aafee59bcb279 origin/main=c8a61ca212296d708e6cda70f78aafee59bcb279 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=<tmp>/direct-pr/pool
# observed scout spawn: spawned pool-scout-r3 harness=codex kind=scout window=firstmate:fm-pool-scout-r3 worktree=<tmp>/scout/pool
ok - direct-PR ships and scouts both refresh stale pooled worktrees before launch
# observed dirty refusal: error: pooled worktree '<tmp>/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 '<tmp>/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 '<tmp>/unreachable-origin/pool'; refusing to launch from a potentially stale base
ok - an unreachable origin refuses a potentially stale pooled worktree
# observed no-origin local-only spawn: spawned pool-no-origin-local-only-r6 harness=codex kind=ship mode=local-only yolo=off window=firstmate:fm-pool-no-origin-local-only-r6 worktree=<tmp>/no-origin-local-only/pool
# observed base: HEAD=2813e34e44fb3e47e62be592600ac8d61c9c7e68 local-main=2813e34e44fb3e47e62be592600ac8d61c9c7e68
ok - a local-only ship task refreshes from the local default branch when the project has no origin
# observed no-mistakes no-origin refusal: error: pooled worktree '<tmp>/no-origin-refusal-1/pool' has no 'origin' remote; only a local-only ship task may launch from a repository with no origin, and this spawn is mode=no-mistakes; give the project an origin or ship the task --mode local-only
# observed direct-PR no-origin refusal: error: pooled worktree '<tmp>/no-origin-refusal-2/pool' has no 'origin' remote; only a local-only ship task may launch from a repository with no origin, and this spawn is mode=direct-PR; give the project an origin or ship the task --mode local-only
# observed scout no-origin refusal: error: pooled worktree '<tmp>/no-origin-refusal-3/pool' has no 'origin' remote; only a local-only ship task may launch from a repository with no origin, and this spawn is a scout; give the project an origin or ship the task --mode local-only
ok - push modes and scouts still refuse a project that has no origin
# observed local-only unreachable-origin refusal: error: could not fetch origin for pooled worktree '<tmp>/local-only-unreachable-origin/pool'; refusing to launch from a potentially stale base
ok - a configured but unreachable origin still refuses a local-only spawn
# all fm-spawn-pool-base-freshen tests passed
Evidence: Reproduction script used for the manual end-to-end run

Source: Reproduction script used for the manual end-to-end run

#!/usr/bin/env bash
# Manual end-user reproduction: a registered local-only project with no origin.
set -u
ROOT=$1
WORK=$(mktemp -d /tmp/fm-noorigin-demo.XXXXXX)
HOME_DIR="$WORK/home"; PROJECT="$WORK/project"; POOL="$WORK/pool"; FAKE="$WORK/fake"
ID=demo-local-only-task
mkdir -p "$HOME_DIR/data/$ID" "$HOME_DIR/projects" "$HOME_DIR/state" "$HOME_DIR/config" "$FAKE"
printf 'codex\n' > "$HOME_DIR/config/crew-harness"
printf 'demo brief\n' > "$HOME_DIR/data/$ID/brief.md"
touch "$HOME_DIR/state/.last-watcher-beat"
cat > "$FAKE/tmux" <<'SH'
#!/usr/bin/env bash
set -u
case "$*" in *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH}"; exit 0 ;; esac
case "${1:-}" in display-message) printf 'firstmate\n'; exit 0 ;; esac
exit 0
SH
printf '#!/usr/bin/env bash\nexit 0\n' > "$FAKE/treehouse"
chmod +x "$FAKE/tmux" "$FAKE/treehouse"
git init --quiet -b main "$PROJECT"
printf 'base\n' > "$PROJECT/README.md"
git -C "$PROJECT" add README.md
git -C "$PROJECT" -c user.name=Demo -c user.email=d@e.invalid commit -qm initial
BASE=$(git -C "$PROJECT" rev-parse HEAD)
git -C "$PROJECT" worktree add --quiet --detach "$POOL" "$BASE"
printf 'work landed after the pool worktree was allocated\n' > "$PROJECT/newer.txt"
git -C "$PROJECT" add newer.txt
git -C "$PROJECT" -c user.name=Demo -c user.email=d@e.invalid commit -qm advance-main
echo "\$ git -C project remote -v      # registered local-only, no remote at all"
git -C "$PROJECT" remote -v
echo "(no output: no origin configured)"
echo
echo "\$ fm-spawn.sh $ID <project> --mode local-only --yolo off"
FM_ROOT_OVERRIDE='' FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$HOME_DIR/state" \
  FM_DATA_OVERRIDE="$HOME_DIR/data" FM_PROJECTS_OVERRIDE="$HOME_DIR/projects" \
  FM_CONFIG_OVERRIDE="$HOME_DIR/config" FM_SPAWN_NO_GUARD=1 FM_GATE_REFUSE_BYPASS=1 TMUX="fake,1,0" \
  FM_FAKE_PANE_PATH="$POOL" PATH="$FAKE:$PATH" \
  "$ROOT/bin/fm-spawn.sh" "$ID" "$PROJECT" --mode local-only --yolo off 2>&1 | sed "s#$WORK#<work>#g"
echo "exit=${PIPESTATUS[0]}"
echo
echo "\$ git -C pool log --oneline -1  # base the worker actually starts from"
git -C "$POOL" log --oneline -1
echo "\$ git -C project rev-parse main"; git -C "$PROJECT" rev-parse main
echo "\$ git -C pool rev-parse HEAD   "; git -C "$POOL" rev-parse HEAD
echo "\$ ls pool/newer.txt"; ls "$POOL/newer.txt" 2>&1 | sed "s#$WORK#<work>#g"
git -C "$PROJECT" worktree remove --force "$POOL" >/dev/null 2>&1
rm -rf "$WORK"

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ⚠️ docs/architecture.md:166 - Intent requires: "Update authoritative operator/maintainer documentation only if current behavior or a stable invariant changes." A stable invariant did change, and docs/architecture.md:166 still states the old one: "no worker starts until its clean task worktree matches the fetched tip of origin's resolved default branch, and any unsafe or unverifiable base stops the spawn." After bin/fm-spawn.sh:1913-1924, a --mode local-only spawn in a repository with no origin starts from refs/heads/<local default> and matches no origin tip. The author's stated assumption that "no other maintained prose surface documents it" is contradicted by this line, which the same doc pairs with "Its header owns the exact refusal mechanics" - i.e. architecture.md deliberately owns the invariant, the header owns the mechanics. Needs a one-clause exception added at architecture.md:166.
  • ℹ️ bin/fm-spawn.sh:1913 - A scout in a no-origin project still refuses (MODE empty for a fresh scout spawn, so the guard rejects with "this spawn is a scout"). This is intentional per the stated intent and is the pre-existing safe behavior, and the message is accurate: secondmate spawns skip freshen entirely (line 2439) and relaunches skip it too, so an empty MODE at this point can only be a scout. Noting it as a known remaining limitation, not a defect.

🔧 Fix: docs: state local-only no-origin base exception in architecture invariant
1 info still open:

  • ℹ️ bin/fm-spawn.sh:1919 - On the no-origin path the base comes from default_branch(), which without origin/HEAD can only guess 'main' then 'master' (bin/fm-ff-lib.sh:37-51). A no-origin repo whose real default is e.g. 'develop' but that still carries a leftover local 'main' would silently reset the pool to 'main' rather than refusing. This is the pre-existing repo-wide heuristic (fm-merge-local.sh and fm-review-diff.sh state the same 'expected origin/HEAD, main, or master' contract and would merge into the same branch), so the change stays internally consistent rather than introducing a new divergence, and the author explicitly documented the no-main/no-master refusal as the chosen ceiling. Noting the ceiling, not requesting a change.
✅ **Test** - passed

✅ No issues found.

  • bash tests/fm-spawn-pool-base-freshen.test.sh (9 tests, all pass) - the 3 new cases cover no-origin local-only success, no-origin refusal for no-mistakes/direct-PR/scout, and local-only vs unreachable origin
  • Regression proof: temporarily restored git show fc6a4e1:bin/fm-spawn.sh and re-ran the same test file - not ok - a local-only ship task should launch in a project with no origin: expected exit 0, got 1; restored the fixed script and the worktree back to clean
  • Manual end-user reproduction script driving the real spawn executable against a fixture project with no remote, run against both the base and target script (/tmp/no-mistakes-evidence/01M0WB4NPF71THS4XVD2FMSGPC/reproduce-no-origin-spawn.sh)
  • Post-spawn base verification in the manual run: git -C pool rev-parse HEAD equals git -C project rev-parse main, and pool/newer.txt (the commit made after worktree allocation) is present
  • Adjacent shared-spawn-path tests: bash tests/fm-spawn-batch.test.sh, bash tests/fm-spawn-worktree-settle.test.sh, bash tests/fm-spawn-dispatch-profile.test.sh, bash tests/fm-task-delivery.test.sh - all exit 0
  • FM_TEST_EVIDENCE=1 bash tests/fm-spawn-pool-base-freshen.test.sh to capture the observed spawn lines and exact refusal messages
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

freshen_spawn_worktree_base fetched origin unconditionally, so every fresh
ship or scout spawn in a project that intentionally has no remote refused
with "could not fetch origin ...; refusing to launch from a potentially
stale base". A configured origin was the masking condition: every project
registered before this one had one, so the unconditional fetch always
succeeded.

A repository with no origin at all is a different fact from one whose
origin is unreachable - nothing can go stale against a remote that does
not exist. Decide that once, at the shared boundary, before the first git
call that assumes origin: a local-only ship task resets to the
repository's own current default branch, while every push mode and every
scout still refuses, so a repository with no origin can never back a
pushed branch or PR. An existing but unreachable origin, an unresolved
remote default branch, a dirty worktree, and every other refusal are
untouched, and the clean-tree check, reset, and verification stay shared
by both paths.
@doitdigital0495
doitdigital0495 merged commit 4bf365e into main Aug 25, 2026
13 checks passed
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.

2 participants