Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 104 additions & 9 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,14 @@
# origin, resolves the current remote default branch, and resets to its tip.
# An unreachable origin, unresolved default branch, or non-clean worktree
# refuses the spawn rather than risking a PR based on stale history.
# A repository with no origin remote configured has no upstream to be stale
# against, so that refresh is skipped with a loud notice and the spawn
# continues; only a configured remote can make a base unverifiable. A pooled
# worktree carrying uncommitted work is still refused on that path, because a
# new task must never start on top of another task's unlanded work, and so is
# one whose HEAD has left the local default branch tip, which is how committed
# leftovers show up. That refusal names the worktree, the commit it sits on,
# and the branch tip expected, and never discards those commits itself.
# Batch dispatch: pass one or more `id=repo` pairs instead of a single <id> <project>, e.g.
# fm-spawn.sh fix-a-k3=projects/foo add-b-q7=projects/bar [--scout]
# Each pair re-execs this script in single-task mode, so the single path stays the only
Expand Down Expand Up @@ -1727,8 +1735,92 @@ validate_spawn_worktree() { # <source> <inspect-target>
fi
}

# One reading of a pooled worktree's uncommitted state, shared by both the
# refresh path and the no-origin skip: 0 clean, 1 dirty, 2 unreadable.
spawn_worktree_is_clean() { # <worktree>
local status
status=$(git -C "$1" status --porcelain) || return 2
[ -z "$status" ]
}

# The local default branch of a repository with no origin remote.
# origin/HEAD can never exist there, so the shared resolver's main/master
# fallback is the first attempt, and a custom-named default then resolves only
# when the repository has exactly one local branch, because that lone branch is
# the only thing "default" can mean without an upstream.
# The primary checkout's HEAD is deliberately not consulted: it names whatever
# branch happens to be checked out, so a primary stranded on a feature branch
# would bless that feature tip as the default and let a pooled worktree sitting
# on prior committed feature work slip past the leftover refusal below.
# Several branches with no main or master is genuinely ambiguous, so it returns
# nonzero and the caller refuses loudly instead of guessing.
# Both readings are local; no network call is made.
spawn_local_default_branch() { # <worktree>
local worktree=$1 heads
if default_branch "$worktree"; then
return 0
fi
heads=$(git -C "$worktree" for-each-ref --format='%(refname:short)' refs/heads 2>/dev/null) || return 1
[ -n "$heads" ] || return 1
case $heads in

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Multiple branches block custom defaults

When a no-origin repository uses a custom default such as trunk and retains another local branch, this newline check rejects the branch list instead of resolving the actual default, causing an otherwise valid local-only spawn to exit with “could not determine the local default branch.”

*$'\n'*) return 1 ;;
esac
printf '%s\n' "$heads"
}

# Freshening asks one question: is this worktree's base stale against its
# upstream? A repository with no origin configured has no upstream, so that
# question is meaningless rather than failed, and the spawn proceeds from the
# base it already has. A project that lives only on one machine is a supported
# shape, so refusing it would strand every fresh task worktree on that project.
# The distinction is drawn on whether a remote is CONFIGURED, read from git's own
# local remote list, which makes no network call. A configured origin that cannot
# be reached still falls through to the strict path below and refuses loudly, so a
# network outage on an ordinary project is never reclassified as "no remote".
# This belongs inside the freshening function, not at its call site: the
# freshening contract - including when it does not apply - stays with its one
# owner, so no caller has to know what makes a base refresh meaningful.
# Skipping the refresh never skips the unlanded-work refusal: a pooled worktree
# carrying uncommitted files must not silently become the base of a new task,
# and that risk is identical with or without an upstream.
# Committed leftovers read as clean, so the no-origin path also requires HEAD to
# sit on the local default branch tip. It refuses rather than resetting: with no
# remote, a leftover commit may be the only copy of that work anywhere, so a
# person decides its fate instead of a pool slot being tidied at its expense.
freshen_spawn_worktree_base() { # <worktree>
local worktree=$1 default target expected actual status
local worktree=$1 default target expected actual remotes
remotes=$(git -C "$worktree" remote) || {
echo "error: could not read the configured remotes of pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2
return 1
}
if ! printf '%s\n' "$remotes" | grep -qx origin; then
spawn_worktree_is_clean "$worktree"
case $? in
1)
echo "error: pooled worktree '$worktree' has uncommitted work; refusing to start a new task on top of it" >&2
return 1
;;
2)
echo "error: could not inspect pooled worktree '$worktree' for uncommitted work; refusing to launch" >&2
return 1
;;
esac
default=$(spawn_local_default_branch "$worktree") || {
echo "error: could not determine the local default branch for pooled worktree '$worktree'; refusing to launch from an unverifiable base" >&2
return 1
}
expected=$(git -C "$worktree" rev-parse --verify --quiet "refs/heads/$default^{commit}" 2>/dev/null) || {
echo "error: local default branch '$default' is not a commit for pooled worktree '$worktree'; refusing to launch from an unverifiable base" >&2
return 1
}
actual=$(git -C "$worktree" rev-parse --verify --quiet HEAD 2>/dev/null || true)
if [ "$actual" != "$expected" ]; then
echo "error: pooled worktree '$worktree' is sitting on commit '${actual:-unknown}', not the tip of its local default branch '$default' ('$expected'); refusing to start a new task on top of commits that are not on '$default'. Inspect that worktree and decide what to do with those commits before it is reused" >&2
return 1
fi
echo "notice: pooled worktree '$worktree' has no origin remote configured; skipping the base refresh because there is no upstream it can be stale against" >&2
return 0
fi
if ! git -C "$worktree" fetch --quiet origin; then
echo "error: could not fetch origin for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2
return 1
Expand All @@ -1750,14 +1842,17 @@ freshen_spawn_worktree_base() { # <worktree>
echo "error: '$target' is not a commit for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2
return 1
}
status=$(git -C "$worktree" status --porcelain) || {
echo "error: could not inspect pooled worktree '$worktree' before refreshing its base" >&2
return 1
}
if [ -n "$status" ]; then
echo "error: pooled worktree '$worktree' is not clean; refusing to discard uncommitted work while refreshing its base" >&2
return 1
fi
spawn_worktree_is_clean "$worktree"
case $? in
1)
echo "error: pooled worktree '$worktree' is not clean; refusing to discard uncommitted work while refreshing its base" >&2
return 1
;;
2)
echo "error: could not inspect pooled worktree '$worktree' before refreshing its base" >&2
return 1
;;
esac
if ! git -C "$worktree" reset --hard "$target" >/dev/null; then
echo "error: could not reset pooled worktree '$worktree' to '$target'; refusing to launch from a potentially stale base" >&2
return 1
Expand Down
3 changes: 3 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,9 @@ Codex App support is recorded in `docs/codex-app-backend.md`; it is not selectab
Crewmates never intentionally touch your project clone; [treehouse](https://github.com/kunchenguid/treehouse) pools clean worktrees for tmux, herdr, zellij, and cmux tasks, while Orca creates its own worktrees for `backend=orca`.
For ship and scout work, `fm-spawn.sh` refuses to launch unless the resolved task path is a real git worktree root that is distinct from the project primary checkout.
`fm-spawn.sh` also owns the base-freshness boundary for every fresh ship and scout: 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.
A project with no origin remote configured has no upstream its base can be stale against, so that refresh is skipped with a notice rather than refused.
That skip does not lower the clean-worktree bar: a pooled worktree carrying uncommitted work is still refused, because no task may start on top of another task's unlanded work.
Committed leftovers read as clean, so the skip path also refuses a pooled worktree whose HEAD has left the local default branch tip, refusing rather than resetting because with no remote those commits may be the only copy of that work.
Its header owns the exact refusal mechanics, while `tests/fm-spawn-pool-base-freshen.test.sh` owns the portable regression coverage.

The firstmate repo has one extra exposure because it can dispatch crewmates to work on itself.
Expand Down
Loading