fix: never start a task outside its worktree, and say so when one is missing - #689
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 inblockedfor merge review. The board showed the pipeline complete while the PR sat unmerged and conflicting.The Claude session transcript is unambiguous:
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
ErrBranchBusyon 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), sosetupWorktreereturned before persistingworktree_path. The task had no worktree at all.EnsureTaskWindowresolved its workdir viataskWorkdir, which falls back to the project directory and then to$HOME:reconcileFinishedWorkflowStepsskips any step with noworktree_path— silently. 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 returnsErrNoWorktreeinstead of falling back. A project that opts out of worktrees still gets its project dir — that's its normal working directory, not a fallback.taskWorkdirstays for read-only callers (session-file probes, display).shouldFallBackToStarttakeshasWorktreeand 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.reconcileFinishedWorkflowStepslogs once, on the task, when a started pipeline step has no worktree. It still can't assess such a step —WorkflowStepFinishedneeds 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 assertstaskWorkdirstill 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 falseand the same at 1h....AllowsProjectDirWhenWorktreesDisabledand...UsesWorktreeWhenPresentso the guard doesn't over-correct.go vet ./...,go test -race ./...(full repo, incl. the parity harness), andmake 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