Skip to content

fix: pipelines died at spawn on shared-branch contention (and ~ in project paths) - #686

Merged
bborn merged 4 commits into
mainfrom
fix/pipeline-shared-branch-contention
Aug 14, 2026
Merged

fix: pipelines died at spawn on shared-branch contention (and ~ in project paths)#686
bborn merged 4 commits into
mainfrom
fix/pipeline-shared-branch-contention

Conversation

@bborn

@bborn bborn commented Aug 14, 2026

Copy link
Copy Markdown
Owner

What happened

Pipeline #5120 (design-build-verify, offerlab) stalled with every step after the root parked in blocked, one second after starting:

Failed to setup worktree: create worktree on branch pipeline/5120-collab-product-network-https-linear-app: exit status 128
fatal: 'pipeline/5120-...' is already checked out at
  /Users/bruno/Projects/rails/offerlab/.task-worktrees/5120-plan-collab-product-networkhttpslinearap

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

composeInstruction has always told a parallel step to push to {{branch}}-<slug>, and told its dependent to read it back with git show origin/{{branch}}-<slug>:<file>. But Create pinned only SourceBranch, 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 Create pins BranchName = StepBranch(shared, step) for any step with a parallel peer, and the executor cuts a worktree on that branch from the shared branch. StepBranch is 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 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. That's how a pipeline silently loses a phase. Now it's ErrBranchBusy: the task stays queued and the next tick retries it.

Bonus: ~ in project paths

The same run logged git push: fatal: cannot change to '~/Projects/rails/offerlab'. Two project rows stored the shell's shorthand. config.GetProjectDir expands it; the callers reading 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. Now normalized on both read and write, so no consumer has to remember.

Evidence

Each test reproduces the production failure before the fix:

--- FAIL: TestSourceBranchWorktreeReclaimsBranchFromFinishedStep
    next step could not take the shared branch: exit status 128
    fatal: 'pipeline/1-demo' is already checked out at '/private/var/.../holder'

--- FAIL: TestParallelStepsGetTheirOwnBranch
    [Review A] BranchName = "", want its own branch (siblings cannot share one branch)
    both reviews got branch ""; they would contend

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 green
  • go vet ./..., gofmt -l — clean
  • golangci-lint run with the CI-pinned v2.8.0 — 0 issues

No user-visible surface changes (daemon/git internals), so no screenshots.

🤖 Generated with Claude Code

bborn and others added 4 commits August 14, 2026 12:42
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>
@bborn

bborn commented Aug 14, 2026

Copy link
Copy Markdown
Owner Author

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 1e093a60:

  1. git worktree add is not concurrency-safe within a repo. Siblings spawning in the same instant raced on .git/config, and the loser fails outright rather than retrying:

    error: could not lock config file .git/config: File exists
    error: unable to write upstream branch configuration
    

    Now serialized with a per-repo flock (executorlock.AcquireRepo). This covers the ordinary-task path too — two normal tasks starting at once raced the same way, which likely explains intermittent worktree failures unrelated to pipelines.

  2. -b X <path> origin/<shared> silently set X's upstream to the shared branch. A later bare git push from a fan-out step would have landed its commits on the branch its instructions explicitly forbid. Cut with --no-track now.

The concurrency test fails without the lock and passes with it:

with lock:     ok    github.com/bborn/workflow/internal/executor
without lock:  FAIL  github.com/bborn/workflow/internal/executor

Live result

Both review steps now hold their own branches at once — which had never happened before:

.task-worktrees/5121-planreviewa-...  [pipeline/5120-...-planreviewa]   processing
.task-worktrees/5122-planreviewb-...  [pipeline/5120-...-planreviewb]   processing

Unrelated observation for later: on its first run, PlanReviewB ran for 4 minutes on kimi-k2.7-code:cloud and committed nothing, so the step parked instead of handing off. That's a step/model question, not a plumbing one — the worktree and branch were correct.

@bborn
bborn merged commit ba1be6b into main Aug 14, 2026
4 checks passed
@bborn
bborn deleted the fix/pipeline-shared-branch-contention branch August 14, 2026 18:16
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