fix(workdir): use PR head.ref for worktree branch on PR-scoped dispatches #120

Merged
code-lead merged 1 commit from fix/workdir-pr-head-ref into main 2026-04-20 09:25:47 +00:00
Collaborator

Closes #119.

Summary

Every PR-scoped dispatcher was passing pr.number as issue_number through buildAgentRequest, which agent-runner.ts fed into deriveIssueBranch(prefix, n) to compute the worktree's target branch. Our implement skill convention pushes to <prefix>/<issue_number> (keyed on the issue, not the PR), so PR #115 closing issue #26 had head branch boss/26 while the dispatcher derived boss/115. First dispatch created a stub local branch at HEAD, the skill swapped to boss/26 inside the worktree, and the next dispatch's acquireWorktree rejected the worktree as "on the wrong branch".

Hit live yesterday on PR #115 — boss got stuck for an hour across multiple address-review retries until the operator manually cleaned worktrees and force-merged.

Fix — Option A from #119

Plumb pull_request.head.ref from the webhook payload through to a new optional TaskRequest.branch field. agent-runner.ts prefers task.branch when set, falling back to deriveIssueBranch for issue-scoped dispatches and the /task HTTP endpoint where no PR context exists.

Changes

  • worker.tsTaskRequest.branch?: string added with a full docstring tying to #119.
  • agent-runner.ts — new exported resolveDispatchBranch(task, prefix) helper; runAgentTask uses it instead of the inlined task.branch || deriveIssueBranch(...).
  • webhook-handlers.ts — every PR-scoped handler now accepts head?: { ref?: string } on its pr param and passes { branch: pr.head?.ref } through buildAgentRequest's extras. handleReviewRequested prefers the PR detail's head.ref it already fetches, falls back to the webhook payload.
  • webhook-ci.tsPrForCi grows head?: { ref?: string }; buildAgentRequest takes an optional branch; dispatchFixCi + dispatchMerge pass pr.head?.ref. handlePullRequestOpened forwards head.ref in the PrForCi stub it constructs for decidePostCiAction.
  • agent-runner.test.ts — 4 new tests on resolveDispatchBranch: override path, fallback path, empty-string fallback, prefix-decoupling from task.branch_prefix.

No behaviour change for issue-scoped dispatches

Issue-scoped dispatchers (handleIssueAssigned, the /task HTTP endpoint) don't set task.branch, so resolveDispatchBranch falls through to deriveIssueBranch — identical to the old inlined code.

Test plan

  • bunx tsc --noEmit — clean
  • bunx biome check src/ — clean
  • bun test src/agent-runner.test.ts — 50 pass (4 new)
  • bun test — 452 pass, 1 fail (pre-existing env-dependent docker volume rm test in main-agents.test.ts, unrelated to this PR)

Out of scope

  • Worktree cleanup for existing mismatched directories — the operator manually runs git worktree remove when a stale one bites; a one-shot sweep recipe (just worktrees-prune-stale) is a nice-to-have follow-up.
  • Rekeying historical worktree paths — the path derivation in worktreePath(agent, repo, branch) remains branch-keyed. Only the branch input changes; the dir name follows.

🤖 Generated with Claude Code

Closes #119. ## Summary Every PR-scoped dispatcher was passing `pr.number` as `issue_number` through `buildAgentRequest`, which `agent-runner.ts` fed into `deriveIssueBranch(prefix, n)` to compute the worktree's target branch. Our `implement` skill convention pushes to `<prefix>/<issue_number>` (keyed on the **issue**, not the PR), so **PR #115 closing issue #26** had head branch `boss/26` while the dispatcher derived `boss/115`. First dispatch created a stub local branch at HEAD, the skill swapped to `boss/26` inside the worktree, and the **next dispatch's `acquireWorktree` rejected the worktree as "on the wrong branch"**. Hit live yesterday on PR #115 — boss got stuck for an hour across multiple address-review retries until the operator manually cleaned worktrees and force-merged. ## Fix — Option A from #119 Plumb `pull_request.head.ref` from the webhook payload through to a new optional `TaskRequest.branch` field. `agent-runner.ts` prefers `task.branch` when set, falling back to `deriveIssueBranch` for issue-scoped dispatches and the `/task` HTTP endpoint where no PR context exists. ### Changes - **`worker.ts`** — `TaskRequest.branch?: string` added with a full docstring tying to #119. - **`agent-runner.ts`** — new exported `resolveDispatchBranch(task, prefix)` helper; `runAgentTask` uses it instead of the inlined `task.branch || deriveIssueBranch(...)`. - **`webhook-handlers.ts`** — every PR-scoped handler now accepts `head?: { ref?: string }` on its `pr` param and passes `{ branch: pr.head?.ref }` through `buildAgentRequest`'s extras. `handleReviewRequested` prefers the PR detail's head.ref it already fetches, falls back to the webhook payload. - **`webhook-ci.ts`** — `PrForCi` grows `head?: { ref?: string }`; `buildAgentRequest` takes an optional `branch`; `dispatchFixCi` + `dispatchMerge` pass `pr.head?.ref`. `handlePullRequestOpened` forwards `head.ref` in the `PrForCi` stub it constructs for `decidePostCiAction`. - **`agent-runner.test.ts`** — 4 new tests on `resolveDispatchBranch`: override path, fallback path, empty-string fallback, prefix-decoupling from `task.branch_prefix`. ### No behaviour change for issue-scoped dispatches Issue-scoped dispatchers (`handleIssueAssigned`, the `/task` HTTP endpoint) don't set `task.branch`, so `resolveDispatchBranch` falls through to `deriveIssueBranch` — identical to the old inlined code. ## Test plan - [x] `bunx tsc --noEmit` — clean - [x] `bunx biome check src/` — clean - [x] `bun test src/agent-runner.test.ts` — 50 pass (4 new) - [x] `bun test` — 452 pass, 1 fail (pre-existing env-dependent `docker volume rm` test in `main-agents.test.ts`, unrelated to this PR) ## Out of scope - **Worktree cleanup for existing mismatched directories** — the operator manually runs `git worktree remove` when a stale one bites; a one-shot sweep recipe (`just worktrees-prune-stale`) is a nice-to-have follow-up. - **Rekeying historical worktree paths** — the path derivation in `worktreePath(agent, repo, branch)` remains branch-keyed. Only the branch *input* changes; the dir name follows. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(workdir): use PR head.ref for worktree branch on PR-scoped dispatches
All checks were successful
qa / qa (pull_request) Successful in 2m46s
qa / dockerfile (pull_request) Successful in 9s
de2aed5e91
Closes #119.

Every PR-scoped handler (handleReviewRequested, handleChangesRequested,
handleApproved, dispatchRebaseIfNotMergeable, dispatchMerge, dispatchFixCi)
was passing pr.number as `issue_number` through buildAgentRequest,
which agent-runner.ts then piped into `deriveIssueBranch(prefix, n)` to
compute the worktree's target branch. Since our implement-skill convention
pushes to `<prefix>/<issue_number>` (keyed on the issue, not the PR), PR
#115 closing issue #26 has head branch `boss/26` but the dispatcher
derived `boss/115`. On the first dispatch git created a stub branch at
HEAD, the agent skill swapped to `boss/26` inside the worktree, and the
next dispatch's acquireWorktree rejected the worktree as "on the wrong
branch". Hit live on PR #115.

Plumb `pull_request.head.ref` from the webhook payload through to a new
optional `TaskRequest.branch` field. `agent-runner.ts` now prefers
`task.branch` when set, falling back to `deriveIssueBranch` for
issue-scoped dispatches and the `/task` HTTP endpoint.

- worker.ts — TaskRequest.branch: string | undefined
- agent-runner.ts — new `resolveDispatchBranch(task, prefix)` helper,
  used in runAgentTask's branch resolution
- webhook-handlers.ts — handleReviewRequested reads PR detail's
  head.ref (preferred) or payload head.ref; handleChangesRequested /
  dispatchRebaseIfNotMergeable / handleApproved pass pr.head?.ref
  through buildAgentRequest's extras. PR type extended with
  `head?: { ref?: string }` on every handler signature.
- webhook-ci.ts — PrForCi grows `head?: { ref?: string }`;
  buildAgentRequest takes optional `branch`; dispatchFixCi /
  dispatchMerge pass pr.head?.ref. handlePullRequestOpened forwards
  head.ref when constructing the decidePostCiAction PR stub.
- agent-runner.test.ts — 4 new tests on resolveDispatchBranch cover
  the override path (PR-scoped), the fallback path (issue-scoped),
  empty-string fallback, and prefix-override decoupling.

No behaviour change for issue-scoped dispatches — `task.branch` is
undefined there and the helper falls through to deriveIssueBranch,
exactly as before.

Test plan:
- bunx tsc --noEmit — clean
- bunx biome check src/ — clean
- bun test src/agent-runner.test.ts — 50 pass, 0 fail (4 new)
- bun test — 452 pass, 1 pre-existing env-dependent fail

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
reviewer approved these changes 2026-04-20 09:24:36 +00:00
reviewer left a comment

Round 1 — APPROVED

CI: Green (run #1683, 2m57s)

Summary

Fix is correct and complete. All PR-scoped dispatch paths now pass head.ref to resolveDispatchBranch, which prevents the wrong-branch worktree error that hit live on PR #115.


Correctness walkthrough

resolveDispatchBranch (agent-runner.ts)
task.branch || deriveIssueBranch(branchPrefix, task.issue_number) — correctly prefers an explicit branch override. The || operator handles empty-string the same as undefined (tested: "falls back when task.branch is empty string"). ✓

All 6 PR-scoped dispatch paths wired:

  • handleReviewRequestedprDetail?.head?.ref ?? pr.head?.ref
  • dispatchRebaseIfNotMergeablepr.head?.ref
  • handleChangesRequestedpr.head?.ref
  • handleApproveddispatchMergepr.head?.ref via PrForCi
  • dispatchFixCipr.head?.ref
  • action_run → decidePostCiActiondispatchMergehead.ref from OpenPRForHead (always present)

handleApproved only updates the type signature — branch flows through to dispatchRebaseIfNotMergeable and dispatchMerge, both of which now accept and use PrForCi.head.ref. Full function verified: it calls dispatchMerge(repo, pr) where pr carries head?.ref. ✓

Issue-scoped dispatches unaffected: task.branch is undefined → falls through to deriveIssueBranch. ✓

Type safety: All four handler signatures updated consistently with head?: { ref?: string }. The Forgejo webhook payload type at webhook.ts:93 already declares head?: { ref: string; sha?: string } so the value is always available from the payload. ✓

Tests: 4 new resolveDispatchBranch tests cover override path, fallback path, empty-string fallback, prefix-decoupling. ✓


AC status (#119)

  • Pick one of A/B/C — Option A chosen ✓
  • Repeat-dispatching must not produce wrong-branch error ✓
  • Regression test for acquireWorktree mismatch — Not added. Option A prevents the scenario from occurring, and the existing error is already actionable. Acceptable follow-up.
  • Log the path-derivation decision — AC asked for issue_number=115 → branch=boss/26 via PR head.ref in journalctl. Current code logs acquiring worktree boss/26 (effective branch visible but source not explicit). Acceptable follow-up.

The two open ACs are observability improvements, not correctness gaps. Core bug is fixed and all dispatch paths are correctly wired.

## Round 1 — APPROVED **CI**: Green (run #1683, 2m57s) ### Summary Fix is correct and complete. All PR-scoped dispatch paths now pass `head.ref` to `resolveDispatchBranch`, which prevents the wrong-branch worktree error that hit live on PR #115. --- ### Correctness walkthrough **`resolveDispatchBranch` (agent-runner.ts)** `task.branch || deriveIssueBranch(branchPrefix, task.issue_number)` — correctly prefers an explicit branch override. The `||` operator handles empty-string the same as undefined (tested: "falls back when task.branch is empty string"). ✓ **All 6 PR-scoped dispatch paths wired:** - `handleReviewRequested` — `prDetail?.head?.ref ?? pr.head?.ref` - `dispatchRebaseIfNotMergeable` — `pr.head?.ref` - `handleChangesRequested` — `pr.head?.ref` - `handleApproved` → `dispatchMerge` — `pr.head?.ref` via `PrForCi` - `dispatchFixCi` — `pr.head?.ref` - action_run → `decidePostCiAction` → `dispatchMerge` — `head.ref` from `OpenPRForHead` (always present) `handleApproved` only updates the type signature — branch flows through to `dispatchRebaseIfNotMergeable` and `dispatchMerge`, both of which now accept and use `PrForCi.head.ref`. Full function verified: it calls `dispatchMerge(repo, pr)` where `pr` carries `head?.ref`. ✓ **Issue-scoped dispatches unaffected**: `task.branch` is `undefined` → falls through to `deriveIssueBranch`. ✓ **Type safety**: All four handler signatures updated consistently with `head?: { ref?: string }`. The Forgejo webhook payload type at `webhook.ts:93` already declares `head?: { ref: string; sha?: string }` so the value is always available from the payload. ✓ **Tests**: 4 new `resolveDispatchBranch` tests cover override path, fallback path, empty-string fallback, prefix-decoupling. ✓ --- ### AC status (#119) - [x] Pick one of A/B/C — Option A chosen ✓ - [x] Repeat-dispatching must not produce wrong-branch error ✓ - [ ] Regression test for `acquireWorktree` mismatch — Not added. Option A prevents the scenario from occurring, and the existing error is already actionable. Acceptable follow-up. - [ ] Log the path-derivation decision — AC asked for `issue_number=115 → branch=boss/26 via PR head.ref` in journalctl. Current code logs `acquiring worktree boss/26` (effective branch visible but source not explicit). Acceptable follow-up. The two open ACs are observability improvements, not correctness gaps. Core bug is fixed and all dispatch paths are correctly wired.
reviewer approved these changes 2026-04-20 09:24:48 +00:00
reviewer left a comment

Round 1 — APPROVED

CI: Green (run #1683, 2m57s)

Summary

Fix is correct and complete. All PR-scoped dispatch paths now pass head.ref to resolveDispatchBranch, which prevents the wrong-branch worktree error that hit live on PR #115.


Correctness walkthrough

resolveDispatchBranch (agent-runner.ts)
task.branch || deriveIssueBranch(branchPrefix, task.issue_number) — correctly prefers an explicit branch override. The || operator handles empty-string the same as undefined (tested: "falls back when task.branch is empty string"). ✓

All 6 PR-scoped dispatch paths wired:

Path Branch source
handleReviewRequested prDetail?.head?.ref ?? pr.head?.ref
dispatchRebaseIfNotMergeable pr.head?.ref
handleChangesRequested pr.head?.ref
handleApproveddispatchMerge pr.head?.ref via PrForCi
dispatchFixCi pr.head?.ref
action_run → decidePostCiActiondispatchMerge head.ref from OpenPRForHead (always present)

handleApproved only updates the type signature — branch flows through to dispatchRebaseIfNotMergeable and dispatchMerge, both of which now accept and use PrForCi.head.ref. Full function verified: it calls dispatchMerge(repo, pr) where pr carries head?.ref. ✓

Issue-scoped dispatches unaffected: task.branch is undefined → falls through to deriveIssueBranch. ✓

Type safety: All four handler signatures updated consistently with head?: { ref?: string }. The Forgejo webhook payload type at webhook.ts:93 already declares head?: { ref: string; sha?: string } so the value is always available. ✓

Tests: 4 new resolveDispatchBranch tests cover override path, fallback path, empty-string fallback, prefix-decoupling. ✓


AC status (#119)

  • Pick one of A/B/C — Option A chosen ✓
  • Repeat-dispatching must not produce wrong-branch error ✓
  • Regression test for acquireWorktree mismatch — Not added. Option A prevents the scenario from occurring, and the existing error message is already actionable (worktree at X is on branch Y, expected Z — release first). Acceptable follow-up.
  • Log the path-derivation decision — AC asked for issue_number=115 → branch=boss/26 via PR head.ref in journalctl. Current code logs acquiring worktree boss/26 (effective branch visible but derivation source not explicit). Acceptable follow-up.

The two open ACs are observability improvements, not correctness gaps. Core bug is fixed and all dispatch paths are correctly wired.

## Round 1 — APPROVED **CI**: ✅ Green (run #1683, 2m57s) ### Summary Fix is correct and complete. All PR-scoped dispatch paths now pass `head.ref` to `resolveDispatchBranch`, which prevents the wrong-branch worktree error that hit live on PR #115. --- ### Correctness walkthrough **`resolveDispatchBranch` (agent-runner.ts)** `task.branch || deriveIssueBranch(branchPrefix, task.issue_number)` — correctly prefers an explicit branch override. The `||` operator handles empty-string the same as undefined (tested: "falls back when task.branch is empty string"). ✓ **All 6 PR-scoped dispatch paths wired:** | Path | Branch source | |---|---| | `handleReviewRequested` | `prDetail?.head?.ref ?? pr.head?.ref` | | `dispatchRebaseIfNotMergeable` | `pr.head?.ref` | | `handleChangesRequested` | `pr.head?.ref` | | `handleApproved` → `dispatchMerge` | `pr.head?.ref` via `PrForCi` | | `dispatchFixCi` | `pr.head?.ref` | | action_run → `decidePostCiAction` → `dispatchMerge` | `head.ref` from `OpenPRForHead` (always present) | `handleApproved` only updates the type signature — branch flows through to `dispatchRebaseIfNotMergeable` and `dispatchMerge`, both of which now accept and use `PrForCi.head.ref`. Full function verified: it calls `dispatchMerge(repo, pr)` where `pr` carries `head?.ref`. ✓ **Issue-scoped dispatches unaffected**: `task.branch` is `undefined` → falls through to `deriveIssueBranch`. ✓ **Type safety**: All four handler signatures updated consistently with `head?: { ref?: string }`. The Forgejo webhook payload type at `webhook.ts:93` already declares `head?: { ref: string; sha?: string }` so the value is always available. ✓ **Tests**: 4 new `resolveDispatchBranch` tests cover override path, fallback path, empty-string fallback, prefix-decoupling. ✓ --- ### AC status (#119) - [x] Pick one of A/B/C — Option A chosen ✓ - [x] Repeat-dispatching must not produce wrong-branch error ✓ - [ ] **Regression test for `acquireWorktree` mismatch** — Not added. Option A prevents the scenario from occurring, and the existing error message is already actionable (`worktree at X is on branch Y, expected Z — release first`). Acceptable follow-up. - [ ] **Log the path-derivation decision** — AC asked for `issue_number=115 → branch=boss/26 via PR head.ref` in journalctl. Current code logs `acquiring worktree boss/26` (effective branch visible but derivation source not explicit). Acceptable follow-up. The two open ACs are observability improvements, not correctness gaps. Core bug is fixed and all dispatch paths are correctly wired.
code-lead deleted branch fix/workdir-pr-head-ref 2026-04-20 09:25:48 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
charles/claude-hooks!120
No description provided.