Skip to content

Landing a PR from the agent surface: one shared merge service, server-credentialed forge merges

labctl could open and inspect a pull/change request but never merge one — the pr surface was create | view | list, write-once then read-only, and the Tracker seam had no merge method at all. Landing a green PR is a routine end-of-task step, yet an agent had only two off-surface workarounds, both wrong: the forge merge API (needs a forge token the session does not have — LAB_TOKEN is a lab run token, ADR-0014), or a direct push to a protected trunk (bypasses the forge merge flow). Net: "land the PR" needed a human every time, even for a trivially mergeable PR. This ADR closes that: the Tracker seam grows MergePull(number) → PullRef, reachable as labctl pr merge <n> over the run-token agent surface, repo-scoped by the run row exactly like every other agent action (ADR-0014).

The merge machinery already existed — but only for the built-in binding, and only behind the operator route: the change-request merge (ADR-0011) does an ancestry-checked fast-forward else a --no-ff commit in a throwaway worktree, under a per-CR mutex, cancellation-immune once the git window opens, best-effort closing every Closes #N, publishing cr.changed/issue.changed. Rather than reimplement any of that agent-side, we lifted that orchestration into a shared crmerge.Service both surfaces call: the operator route (POST /crs/{n}/merge) and the built-in tracker's MergePull. So the ADR-0011 invariants hold byte-for-byte on both paths — the service owns the per-CR mutex (so merge and close still serialize), the detached-context git window, the author identity (the repo's real identity else settings, never a bot — D15 measure 5), the per-op credential, the issue closure, and the events. The built-in MergePull is the thin agent-side adapter over it; Close moved into the service too, because it must share the same mutex.

The pins, decided:

  • The forge bindings gain a real merge action. Forgejo/GitHub only ever read merged state before; now each performs the merge as a server-side authenticated call under lab's own forge token — the token never reaches the session, so the run token's repo scope stays the only agent-side boundary (ADR-0014). The token still never appears in an error or log.
  • Merge method is fixed — no flag. Built-in: fast-forward when possible else a merge commit (ADR-0011, unchanged). Forge: a single fixed merge method; the forge applies its own configured strategy and enforces its own branch protection. No --method/squash/rebase surface.
  • Mergeability is the backend's call, never reasoned about agent-side. No pre-flight of required checks or branch protection. Attempt the merge; if the forge (or a git push under a pre-receive hook) refuses — required check red, protected base, conflict — surface that refusal's own words verbatim as tracker.ErrMergeRejected → a 409 with a non-zero labctl exit. This is ADR-0011's "push refusals surface git's stderr verbatim", widened to the agent surface.
  • Convergent / idempotent. Re-merging an already-merged PR/CR is a no-op success naming the existing merged state, not an error — matching gitx.CRMerge's own convergence and the create path's duplicate handling. (The operator route keeps its ADR-0011 409 on a re-merge; only the agent land step, which is retried, converges — the built-in MergePull re-reads to distinguish an already-merged CR from a closed-unmerged one.)
  • The head branch is never deleted on merge. Branch lifecycle stays owned by guarded teardown / the sweep, which GCs a merged head once the PR/CR leaves the open state (ADR-0011); merge does not touch it.

Status

Accepted. Extends ADR-0011 (which scoped merge to the operator/built-in surface; it gains a status pointer here) and ADR-0014's agent surface (MergePull joins CreatePull/Pull/Pulls). The reject → re-queue loop and pr close/pr comment stay out of scope, tracked separately.

Considered options

  • Reimplement the merge agent-side instead of sharing the service. Rejected: the ADR-0011 invariants (per-CR serialization, cancellation-immunity, convergent re-merge, Closes #N, events) are subtle and safety-critical; two implementations would drift. Lifting them into one service both routes call is the only way they stay identical.
  • Hand the forge token to the session so labctl merges directly. Rejected: the run token's repo scope is the whole agent-surface security boundary (ADR-0014); a forge token in the session breaks it. The merge runs under the server's credential.
  • Pre-check mergeability agent-side (required checks / branch protection). Rejected: the forge is the authority and its state races anything lab could cache; attempt-and-surface-verbatim is both simpler and correct.
  • Selectable merge method (--method squash/rebase). Rejected: no consumer needs it; a fixed method keeps one wire shape, and the forge's configured strategy still applies.
  • Delete the head branch on merge. Rejected: an open PR/CR's head is owned by teardown/the sweep (ADR-0011); merge deleting it would race the GC and strand the retry path.
  • Pair this with pr close. Deferred: close is part of the reject → re-queue-to-AFK loop, a separate enhancement; this ships merge-only.