fix: pipelines died at spawn on shared-branch contention (and ~ in project paths) - #686
Conversation
A project path can reach the DB as the shell's own shorthand ("~/Projects/foo")
from the settings form, project detect, or a hand-edited row. Nothing downstream
runs through a shell — the executor and the pipeline hand these paths straight to
exec.Command("git", "-C", dir, ...) — so a literal "~" is just a directory name
that does not exist:
pipeline: could not pre-seed shared branch "pipeline/5120-..." on origin;
git push: fatal: cannot change to '~/Projects/rails/offerlab'
config.GetProjectDir expanded it; the callers that read Project.Path directly
(pipeline.projectDirFor, executor.lookupKindInstructions, completion.Complete)
did not, so those projects silently lost their pre-seeded branch, their
project-local .taskyou/workflows dir, and any GetProjectByPath match.
Normalizing on both write and read removes the chance to forget.
Also adds GetTaskByWorktreePath: given a worktree, which task owns it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Steps that run at the same time cannot share one branch: git attaches a branch
to at most ONE worktree, so the second sibling's `git worktree add` dies with
fatal: 'pipeline/5120-...' is already checked out at ...
before its agent ever starts. Every workflow with a fan-out was affected —
PlanReviewA/B and the three CodeReviews of design-build-verify never ran.
The design was already there in the prompts: composeInstruction has always told
a parallel step to push to `{{branch}}-<slug>` and told its dependent to read
that branch back with `git show origin/{{branch}}-<slug>:<file>`. What was
missing is that nothing ever gave the step a worktree on that branch — Create
pinned only SourceBranch, so the executor tried to attach it to the shared
branch that the instructions explicitly say NOT to push to.
Create now pins BranchName = StepBranch(shared, step) for any step with a
parallel peer, cut from the shared branch. StepBranch is the one source of truth
for that name, used by both Create and composeInstruction, so the branch a step
is given and the branch its instructions name cannot drift apart again.
Sequential steps are unchanged: they attach to the shared branch itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… instead of failing Two ways a pipeline died at spawn, both from the same fact: git allows one worktree per branch. 1. A finished step keeps holding the shared branch. Its worktree is deliberately kept for inspection, so it holds the branch forever and the NEXT step can never attach. This hit the first downstream step of every pipeline, not just fan-outs. A finished holder now hands the branch over: `git checkout --detach` moves no files, keeps uncommitted work, and leaves the branch ref where it is, so the worktree stays readable and nothing can be lost. A holder that is still RUNNING keeps the branch. 2. A step whose branch is busy was failed outright. setupWorktree's error parked it 'blocked' with started_at and completed_at one second apart — a step that reads as "ran and finished" on every surface, having never launched an agent, which is how a pipeline silently loses a phase. A busy branch is now ErrBranchBusy: the task stays 'queued' and the next tick retries it. Fan-out steps arrive with their own branch pinned (see the previous commit) and get a worktree on it, cut from the shared branch. Cutting a branch FROM a branch is unrestricted, so no number of siblings can contend. Holder lookup compares symlink-resolved path forms: git reports a worktree as /private/var/... while the DB stores /var/..., and reading that mismatch as "no task owns this" would refuse to reclaim a branch from a long-finished step. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ack the shared branch
Both bugs were unreachable until fan-out steps could actually spawn together,
and both showed up on the first real run.
`git worktree add` is not concurrency-safe within a repository: it writes
.git/config to record the new worktree's upstream, and a loser of git's own
config lock fails outright instead of retrying —
error: could not lock config file .git/config: File exists
error: unable to write upstream branch configuration
which killed one of two sibling steps spawned in the same instant. Worktree
creation now takes a per-repo flock (executorlock.AcquireRepo), covering the
ordinary-task path too: two normal tasks starting at once raced the same way.
`worktree add -b X <path> origin/<shared>` also sets X's upstream to the SHARED
branch, so a later bare `git push` from a fan-out step would land its commits on
the branch its instructions explicitly tell it not to touch. Cut with --no-track.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Verified live on the stuck pipeline (#5120, offerlab)Deployed this build to the daemon and retried the two review steps that had been dead since 15:24. Two further bugs surfaced on the first real run — both were unreachable until fan-out steps could actually spawn together, and both are fixed in
The concurrency test fails without the lock and passes with it: Live resultBoth review steps now hold their own branches at once — which had never happened before: Unrelated observation for later: on its first run, PlanReviewB ran for 4 minutes on |
What happened
Pipeline #5120 (
design-build-verify, offerlab) stalled with every step after the root parked inblocked, one second after starting:Three separate bugs, all rooted in one fact: git allows exactly one worktree per branch.
1. A finished step holds the shared branch forever
A step's worktree is deliberately kept after it completes so its work can be inspected — and it goes on holding the branch. The first downstream step of every pipeline therefore couldn't attach. Not fan-out-specific; it hit sequential chains too (same error on pipeline 4963, Jul 22 and 23).
Now: a finished holder hands the branch over via
git checkout --detach— moves no files, keeps uncommitted work, leaves the branch ref where it is. The worktree stays on disk and readable. A holder that is still running keeps the branch.2. Fan-out steps were never given the branch their own instructions name
composeInstructionhas always told a parallel step to push to{{branch}}-<slug>, and told its dependent to read it back withgit show origin/{{branch}}-<slug>:<file>. ButCreatepinned onlySourceBranch, so the executor tried to attach the step to the shared branch — the one its instructions explicitly say not to push to. Siblings then collided with each other and with the root.Now
CreatepinsBranchName = StepBranch(shared, step)for any step with a parallel peer, and the executor cuts a worktree on that branch from the shared branch.StepBranchis the single source of truth used by both, so the branch a step gets and the branch its prompt names can't drift again. Cutting a branch from a branch is unrestricted, so no number of siblings can contend.3. A busy branch failed the step instead of waiting
The raw git error parked the step
blockedwithstarted_atandcompleted_atone second apart — a step that reads as "ran and finished" on every surface, having never launched an agent. That's how a pipeline silently loses a phase. Now it'sErrBranchBusy: the task staysqueuedand the next tick retries it.Bonus:
~in project pathsThe same run logged
git push: fatal: cannot change to '~/Projects/rails/offerlab'. Two project rows stored the shell's shorthand.config.GetProjectDirexpands it; the callers readingProject.Pathdirectly (pipeline.projectDirFor,executor.lookupKindInstructions,completion.Complete) did not — so those projects silently lost their pre-seeded branch, their project-local.taskyou/workflowsdir, and anyGetProjectByPathmatch. Now normalized on both read and write, so no consumer has to remember.Evidence
Each test reproduces the production failure before the fix:
The executor tests drive real git repos and real worktrees — the constraint under test is git's own, so a fake would prove nothing.
go test ./...— all greengo vet ./...,gofmt -l— cleangolangci-lint runwith the CI-pinned v2.8.0 — 0 issuesNo user-visible surface changes (daemon/git internals), so no screenshots.
🤖 Generated with Claude Code