Repository navigation
fix(server): local threads follow the branch their turn ran on - #129
Conversation
A local-checkout thread records its branch once, at creation, and nothing rewrites it when the agent runs `git checkout -b` in the shared checkout. The sidebar card kept saying `custom` while the composer showed the real branch, and the PR the agent opened never linked to the thread because the PR reactor looks up PRs by the recorded branch. Upstream's drift-follow (pingdotgg#5159) refuses every shared cwd. Keep that for worktree threads and lift it for local threads, but only for the thread whose turn just completed: it adopts the checked-out branch through the existing compare-and-swap dispatch, and the other local threads keep their record and their mismatch banner. Fenced as server-local-checkout-branch-follow with a manifest entry, a reactor test for the local case, and a fork guard. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
No major structural issues found.
The policy change stays in followWorktreeBranchDrift, the function that already owns checkout adoption. Inverting the shared-cwd refusal so local threads (worktreePath === null) fall through to the existing expectedBranch compare-and-swap is the small model: no second reactor, no ownership registry, no extra snapshot read on the local path. Worktree threads keep the exclusive-cwd check unchanged.
CheckpointReactor.ts stays under 1k. The harness null vs omitted threadWorktreePath fix matches the option's existing type and the three tests that already passed null meaning unset. The fork guard and reactor case cover the invariant without adding a wrapper or a parallel helper.
Sent by Cursor Automation: Thermo nuke 4.6
Thread transfer impact✅ Thread transfer remains within every enforced ceiling.
Baseline: Scenario and decoded snapshot size10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.
Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed. |
NoahHendrickson
left a comment
There was a problem hiding this comment.
Code review — fix(server): local threads follow the branch their turn ran on
Verdict: the policy change is sound and lands in the right function; one behavioural gap and one vacuous assertion to address. All checks green.
What I verified
- Inverting the refusal is minimal and correct: worktree threads keep both the exclusive-cwd check and the shared-worktree snapshot read, and the pre-existing
it.eachcase ("does not adopt a drifted checkout … when the worktree is shared by another thread") still exercises that path unchanged. - The harness hoist is not cosmetic — it's required.
options?.threadWorktreePath ?? cwdwould have collapsed the explicitnullback tocwdand silently turned the new test into a worktree case.=== undefined ? cwd : …is the right shape and matches the option's?: string | nulltype. - Adoption still goes through upstream's
expectedBranchcompare-and-swap, andrefreshPullRequestAfterTurnre-reads the thread after adoption, so the PR refresh follows the new branch rather than the stale one.
1. A local thread now adopts the default branch when the checkout returns to it
apps/server/src/orchestration/Layers/CheckpointReactor.ts:576
followWorktreeBranchDrift has no isDefaultRef guard. Upstream can afford that: a dedicated worktree rarely sits on main. A shared local checkout sits on the default branch constantly — it is the normal resting state after a PR merges.
Concrete failure: a local thread records fix/foo and has PR #10. The PR merges, the user (or an agent in another thread) runs git checkout custom, then sends a follow-up message in the original thread — "thanks, can you summarise what shipped". The turn completes on custom, and the thread's recorded branch becomes custom. The card and the PR badge lose the association with #10 permanently unless someone notices and checks the branch back out from the composer picker.
Note that the very next function draws exactly this line: refreshPullRequestAfterTurn returns early on input.local.isDefaultRef, and there's a test pinning it ("does not re-ask for the pull request at turn end on the default branch"). Upstream already treats a default-ref checkout as "not this thread's work". I think the local path wants the same treatment:
if (thread.worktreePath === null && input.local.isDefaultRef) {
return;
}A local thread has nothing to gain from adopting the default branch — there's no PR there to follow — and it has a real record to lose. If this was a deliberate call, it belongs in the manifest's "accepted consequence" paragraph, which currently only covers adopting another feature branch.
2. expect(other?.branch).toBeNull() proves nothing
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts:1106
The harness creates the second thread with a hardcoded branch: null (CheckpointReactor.test.ts:462), and followWorktreeBranchDrift returns early on thread.branch === null. Thread 2 could never have adopted the drift under any implementation, including one that followed the checkout for every local thread. The assertion passes for the wrong reason.
The isolation claim is the load-bearing half of the intent ("the other local threads keep their record and the composer's 'Branch changed' banner"), so it deserves a real test: give thread 2 a distinct non-null branch — e.g. thread the option through the harness as secondThreadBranch — and assert it is still that branch after the drain.
3. Nit — fence size in an upstream file
The customization is a ~15-line inline conditional inside followWorktreeBranchDrift, so every upstream edit to that function conflicts across it. #124 was refactored for exactly this reason (the branch resolver moved into its own module to shrink the ws.ts fence). Extracting a small predicate — shouldFollowCheckoutDrift({ thread, cwd, sharedThreads }) — would leave a one-line fence here. Optional; the current shape reads fine and the logic is genuinely intertwined with the snapshot read, so I'd only do it if sync friction on this function has bitten before.
…the default A shared checkout sits on the default branch after every merge, so a follow-up turn there would have traded the thread's recorded branch and PR for `main`. Skip adoption on the default ref for local threads, the line refreshPullRequestAfterTurn already draws. Give the second local thread in the isolation test a real branch so the assertion can fail. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Addressed in 1624536:
|


Problem
A local-checkout thread records its branch once, at creation, and nothing rewrites it when the agent runs
git checkout -bin the project's shared checkout. The sidebar card kept sayingcustomwhile the composer's chip showed the real branch, and the PR the agent opened never linked to the thread, because the PR reactor looks PRs up by the recorded branch.Upstream's drift-follow (pingdotgg#5159) adopts the checked-out branch only inside a worktree owned by exactly one thread and refuses every shared cwd, since following it for all local threads would hand one PR to every card in the project.
Fix
Keep that refusal for worktree threads and lift it for local threads, but only for the thread whose turn just completed. It adopts the checked-out branch through the existing
expectedBranchcompare-and-swap dispatch; the other local threads keep their record and the composer's mismatch banner. Detached HEAD, temporary placeholder checkouts, and threads with no recorded branch are still skipped.Accepted trade-off, recorded in the manifest: a thread that runs a casual turn on a branch another agent left checked out adopts that branch and its PR, and with auto-settle-on-merge it settles when the PR merges. That is factual and reversible, and was chosen over a first-claim ownership rule that would leave the second thread in exactly the stale state this fixes. The record still updates only at turn completion.
Fenced as
server-local-checkout-branch-followwith a manifest entry, a reactor test for the local case (including a fenced two-line harness fix so anullworktree option means a local thread instead of defaulting to a worktree), and a fork guard.Verification
CheckpointReactor.test.ts: 33 passed, including the new local-checkout case.serverLocalCheckoutBranchFollow.test.tspasses.tsc --noEmitclean; fork lint reports no blocking warnings.Claude Fable 5.1 in Claude Code.
🤖 Generated with Claude Code