Skip to content

fix(worktree): resolve the task integration target instead of trusting the recorded base_branch - #688

Open
EnmaJim wants to merge 1 commit into
andresharpe:mainfrom
EnmaJim:bugfix/worktree-merge-target-reconciliation
Open

fix(worktree): resolve the task integration target instead of trusting the recorded base_branch#688
EnmaJim wants to merge 1 commit into
andresharpe:mainfrom
EnmaJim:bugfix/worktree-merge-target-reconciliation

Conversation

@EnmaJim

@EnmaJim EnmaJim commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Linked issue

Closes #679
Closes #680

Related: #676 (a task stuck after a merge failure). This change removes the most common cause of that
merge failure but does not touch the escalation/skip mechanism itself, so it is not claimed as closed.

Summary of changes

Complete-TaskWorktree took its integration target from the base_branch that
worktree-map.json recorded when the task worktree was created, and never reconciled it with the
branch the main checkout was actually on. Two consequences, both reproducible without an AI provider:

  • Clean tree — it checked out the recorded branch and squash-merged the task into it, reporting
    plain success: True / "Squash-merged to master and cleaned up", while the run was on
    workflow/plan-feature-XXXX. The trunk was written without operator intent, silently, and the
    working copy was left on the trunk.
  • Dirty tracked file under .bot/workspace/decisions/ — the pre-merge stash excluded that tree, so
    the checkout failed and the run halted with Failed to checkout master … (currently on: workflow/…)
    and no stated cause. decisions/ is tracked and is committed by this same function, but unlike
    .bot/workspace/tasks/ it was never scrubbed or backed up, so a dirty file there defeated the very
    checkout the stash exists to enable. Recovery was impossible without hand-editing the map:
    New-TaskWorktree only writes an entry for a task id it has not seen, so base_branch was never
    refreshed and "investigate and retry" replayed the identical failure.

The target is now resolved by an explicit precedence, in a new private Resolve-TaskMergeTarget:

  1. an explicit -BaseBranch — a workflow run passes its integration branch, so the target is stated
    rather than inferred;
  2. a configured git.base_branch — an operator declaration, delegated to Resolve-DotbotBaseBranch so
    a configured-but-missing branch still fails fast (Per-run integration branch + configurable base branch (provider-agnostic core) #466);
  3. the recorded value, when it already matches the checkout;
  4. the checked-out branch — adopted, reconciled back into the map, and named in the result message;
  5. the recorded value when HEAD is detached or sits on a task/* branch, neither of which is a valid
    integration target;
  6. Resolve-MainBranch, when nothing was recorded.

Adoption lives in Complete-TaskWorktree and deliberately not in Resolve-MainBranch /
Resolve-DotbotBaseBranch — those stay HEAD-blind so they remain safe to call while the main repo is on
a task branch (#317).

Three defects in the same paths are fixed alongside it, because the first fix is unsafe without them:

  • The pre-merge stash no longer excludes .bot/workspace/decisions/. Only
    .bot/workspace/tasks/ is excluded now, and only because it is scrubbed immediately above and
    restored from the task-state backup.
  • Stash detection no longer trusts git stash push's exit code. git stash push -u can stash
    successfully and still exit 1 over an advisory — The following paths are ignored by one of your .gitignore files: .bot/workspace/tasks, emitted because the worktree execution environment adds that
    path to .git/info/exclude. $wasStashed was computed from that exit code, so the pop was skipped
    and the operator's uncommitted work was silently parked in a stash. Detection now compares
    refs/stash before and after. Without this, removing the decisions/ exclusion would have traded a
    blocked run for silently parked work — strictly worse.
  • Assert-OnBaseBranch reports why a checkout failed, and can honour git.base_branch. It piped
    git's stderr to Out-Null, so every blocker — a dirty tracked file, a branch held by another linked
    worktree, a file lock — produced the same unactionable message. It also had no -BotRoot parameter,
    so its no--BranchName fallback could only ever resolve main/master; the three cleanup call sites
    in Invoke-WorkflowProcess.ps1 (:1774, :2390, :2413) used that fallback and yanked the working
    copy off a configured base branch on any failed or skipped task. One of those sites carries the
    comment # Re-assert base branch after failed-task cleanup (Fix: wrong-branch merge).

Update-TaskWorktreeBaseBranch is exported so the runner can reconcile a stale record when it reuses a
worktree created by an earlier run, rather than duplicating the map-locking logic.

Note for the reviewer

Three places still describe the pre-fe69737 behaviour — Dotbot.Worktree.psm1's module synopsis,
AGENTS.md:71, and README.md:21 all say the task branch is "squash-merged to main". That was already
imprecise once per-run integration branches landed, and this change widens the gap. Left untouched here
to keep the diff to one concern; happy to correct them in this PR or a follow-up, whichever you prefer.

Testing notes

28 new assertions in tests/Test-Components.ps1, placed with the existing Complete-TaskWorktree
coverage. They cover adoption (target, blast radius, trunk untouched, checkout preserved, map
reconciled), explicit -BaseBranch precedence, the #466 configured-base precedence, the task/*
guard, the stash round-trip — including that no stash is left behind and the operator's dirty content is
restored — and Assert-OnBaseBranch, which had zero coverage before this change despite being the
source of the reported failure.

$env:DOTBOT_HOME = '<this checkout>'
pwsh tests/Run-Tests.ps1                                  # layers 1-3
pwsh ./src/hooks/verify/03-check-md-refs.ps1 -RepoRoot .   # hard CI step, not in Run-Tests

Results on Windows 11 / pwsh 7.6.4 / git 2.54.0:

Gate Result
03-check-md-refs.ps1 exit 0
Layer 1 (18 files) ALL PASSED
Layer 2 (12 files) 887 passed, 0 failed, 1 skipped
Layer 3 (2 files) ALL PASSED
Test-Compilation export alignment 26 declared ≡ 26 exported
Checkout clean after the suite yes

Pre-existing guards re-confirmed green rather than adjusted: #317 (Resolve-MainBranch never reads
HEAD; Get-BaseBranch stays deleted; the E2E test that a task worktree forks from main's tip while
HEAD is on a feature branch), #466, #517, #570, #655, the detached-HEAD completion test, the
unborn-main completion tests, and the merge --squash / patch-pathspec string-literal greps.

Verified end to end against a real project

A 1129-file polyglot repo (.NET 10 + React 19 + Astro + Node gateways), driving the original
reproductions:

Scenario Before After
Mismatch, clean tree Squash-merged to master; master advanced; HEAD yanked to master merged on the operator's branch; master byte-identical; HEAD preserved; reconciliation named in the message
Mismatch × dirty decisions/ failure_kind: exception, Failed to checkout master … (currently on: workflow/…), task parked success: True; the dirty content restored verbatim; no stash left behind
A blocker the stash cannot fix (branch held by a second linked worktree) bare message, no cause … (currently on: probe-branch): fatal: 'master' is already used by worktree at '…'
git.base_branch set, failure-path cleanup HEAD yanked to master stays on the configured base
git.base_branch set, happy path merged to the configured base unchanged

A real fast-prompt run on that project still merges into its integration branch with the same 2-file
blast radius, master untouched, HEAD restored, no stashes or worktrees left behind — and the project's
own suite still passes 370/370, matching the pre-change baseline exactly.

Two things worth knowing, both caught by that verification and fixed before this PR:

  • Logging the reconciliation with Write-BotLog on the happy path broke 17 merge tests.
    Dotbot.Worktree does not depend on Dotbot.Logging, so Write-BotLog exists only when the runtime
    process happens to have loaded it — every pre-existing call in the module sits on an error path, which
    is why it had never mattered. The call is now guarded with Get-Command, matching how
    Resolve-DotbotBaseBranch guards Get-MergedSettings. Unguarded it would also have broken the MCP
    task-submit-review and UI TaskAPI retry paths, which call Complete-TaskWorktree from hosts that
    do not load the logger.
  • Applying adoption unconditionally overrode a configured git.base_branch whenever the checkout
    happened to sit elsewhere, putting the work on master and breaking the documented workaround for
    this very bug. Hence precedence step 2, now pinned by its own test.

Checklist

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Inbox

1 participant