Skip to content

fix: never start a task outside its worktree, and say so when one is missing - #689

Merged
bborn merged 1 commit into
mainfrom
fix/no-unisolated-fallback-run
Aug 15, 2026
Merged

fix: never start a task outside its worktree, and say so when one is missing#689
bborn merged 1 commit into
mainfrom
fix/no-unisolated-fallback-run

Conversation

@bborn

@bborn bborn commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Follow-up to #688. That PR stopped the loud failure (a step re-queueing at 2Hz). This one fixes the quiet one underneath it.

What happened

The Collab Product Network pipeline's [Verify] step ran 42 minutes inside the primary offerlab clone instead of a worktree, then disappeared from the workflow: it committed, pushed and opened a PR, but never parked in blocked for merge review. The board showed the pipeline complete while the PR sat unmerged and conflicting.

The Claude session transcript is unambiguous:

20:48:01.537Z  /Users/bruno/Projects/rails/offerlab                    ← launched in the main clone
20:48:21.812Z  .../.task-worktrees/5124-build-collab-product-network…  ← agent cd'd itself
21:30:37.347Z  (last event — 42m36s, matching the session's own "Worked for 42m 36s")

It only reached the right branch because the agent worked out where the shared branch was checked out and walked into another step's worktree by hand.

The chain

  1. The daemon hit ErrBranchBusy on its first attempt (stale tmux window pinning the shared branch — fixed in fix(executor): stale tmux window no longer pins a shared branch forever #688), so setupWorktree returned before persisting worktree_path. The task had no worktree at all.
  2. The detail view waits 60s for the daemon's executor window, then gives up and starts the session itself. The daemon was looping and never going to create one.
  3. EnsureTaskWindow resolved its workdir via taskWorkdir, which falls back to the project directory and then to $HOME:
    if task.WorktreePath != "" { return task.WorktreePath }
    if task.Project != "" {
        if dir := e.GetProjectDir(task.Project); dir != "" { return dir }  // ← the primary clone
    }
    home, _ := os.UserHomeDir(); return home                               // ← or your home directory
  4. reconcileFinishedWorkflowSteps skips any step with no worktree_pathsilently. With nothing recorded, the terminal step could never be parked for merge review, however often the sweep ran.

The daemon's own path already refuses step 3 — "never fall back to project directory to prevent Claude from accidentally writing to the main repo". Every other launch path (TUI, GUI, HTTP API) ignored that rule.

The fixes — one per link

launchWorkdir (new) is the workdir resolver for anything that starts an agent. It returns ErrNoWorktree instead of falling back. A project that opts out of worktrees still gets its project dir — that's its normal working directory, not a fallback. taskWorkdir stays for read-only callers (session-file probes, display).

shouldFallBackToStart takes hasWorktree and refuses to start an unprovisioned task. The fallback exists for a daemon that died after provisioning; a task with no worktree has nowhere isolated to run, so waiting is the only correct answer.

reconcileFinishedWorkflowSteps logs once, on the task, when a started pipeline step has no worktree. It still can't assess such a step — WorkflowStepFinished needs a worktree and base commit — but a workflow that can't advance should say why rather than stall in silence.

Tests

Both guards verified to fail without the fix:

  • TestLaunchWorkdirRefusesProjectDirForWorktreeProject — without it: got ("/…/TestLaunchWorkdir…/001", <nil>), i.e. it hands back the primary clone. The test also asserts taskWorkdir still returns the project dir, so the guard is demonstrably load-bearing rather than cosmetic.
  • TestShouldFallBackToStart — two new cases fail without it: worktree=false waited=2m0s = true, want false and the same at 1h.
  • Plus ...AllowsProjectDirWhenWorktreesDisabled and ...UsesWorktreeWhenPresent so the guard doesn't over-correct.

go vet ./..., go test -race ./... (full repo, incl. the parity harness), and make lint (0 issues) all pass.

Note

This does not retroactively fix task 5128 — its work is already on pipeline/5120-… and PR offerlab#3435 is still open and conflicting. It prevents the next pipeline from ending up the same way.

🤖 Generated with Claude Code

…missing

A pipeline verify step ran 42 minutes inside the PRIMARY offerlab clone
instead of a worktree, then vanished from the workflow: it committed,
pushed and opened a PR, but never parked in 'blocked' for merge review, so
the board reported the pipeline complete while the PR sat unmerged.

Chain:

1. The daemon hit ErrBranchBusy on its first attempt (a stale tmux window
   pinned the shared branch — fixed in #688), so setupWorktree returned
   before persisting worktree_path. The task had no worktree at all.
2. The detail view waits 60s for the daemon's executor window, then gives
   up and starts the session itself. The daemon was looping and never
   going to create one, so the TUI started it.
3. EnsureTaskWindow resolved its working directory via taskWorkdir, which
   falls back to the project directory and then to $HOME. With no
   worktree, that is the primary clone. The transcript confirms it: the
   session opened in /Users/bruno/Projects/rails/offerlab and only reached
   the right branch because the agent cd'd itself into another step's
   worktree 20 seconds in.
4. reconcileFinishedWorkflowSteps skips any step with no worktree_path —
   silently. With nothing recorded, the terminal step could never be
   parked for merge review, no matter how often the sweep ran.

The daemon path already refuses this ("never fall back to project
directory to prevent Claude from accidentally writing to the main repo");
every other launch path ignored that rule.

Three changes, one per link:

- launchWorkdir (new) is the workdir resolver for anything that STARTS an
  agent, and returns ErrNoWorktree rather than falling back to the project
  dir or $HOME. A project that opts out of worktrees still gets its
  project dir — that is its normal working directory, not a fallback.
  EnsureTaskWindow now uses it. taskWorkdir stays for read-only callers
  (session-file probes, display).
- shouldFallBackToStart takes hasWorktree and refuses to start an
  unprovisioned task. The fallback exists for a daemon that died after
  provisioning; a task with no worktree has nowhere isolated to run, and
  waiting is the only correct answer.
- reconcileFinishedWorkflowSteps logs once, on the task, when a started
  pipeline step has no worktree. It still cannot assess such a step —
  WorkflowStepFinished needs a worktree and base commit — but a workflow
  that cannot advance should say why instead of stalling in silence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bborn
bborn merged commit cd7d685 into main Aug 15, 2026
4 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.

1 participant