Skip to content

PR Management Flow

How The Engineer turns a reviewed change into a merged pull request — and how it reacts to everything that happens on that PR after it opens.

PR management is two cooperating pieces:

  • The pipeline's delivery phase — the sub-phases that describe, push, open, and (on approval) merge the pull request.
  • The daemon's PR-event poller — a background loop that watches each open PR and feeds review feedback, CI results, approvals, and merges back into the task.

Everything upstream of delivery (requirements → research → planning → execution → review) is identical no matter how the work ships. Only delivery changes shape, and it changes by configuration, not by branching code.

The two deliverables

The whole pipeline exists to produce one of two deliverables, chosen by workspace.pr.skip_pr_creation (global default, per-repo override):

ModeSettingDeliverableDone when
PR mode (default)skip_pr_creation: falseA reviewed, merged pull requestThe PR is merged
Push-only modeskip_pr_creation: trueA pushed branch — no PR, no review, no mergeThe branch is pushed

In push-only mode, every PR-specific delivery sub-phase skips and the task completes as soon as the branch is pushed. It is a deliberate escape hatch for operators who own the downstream PR process themselves.

Delivery sub-phases

In PR mode, delivery runs four sub-phases in order, then waits:

  1. pr-description — an agent pass that writes the PR narrative to a deliverable file (thoughts/delivery/pr-description.md) that create-pr later reads.
  2. push — commits anything the agent left uncommitted (a safety net; execution commits as it goes), then pushes the branch through the workspace manager's authenticated push. Runs in both modes — it is the entire deliverable in push-only mode. A push that cannot run throws, and the task blocks loud and recoverable; nothing to push is a clean advance.
  3. create-pr — opens the pull request: it composes the title and body (plugin decorations + a trigger reference + the agent's narrative + a footer), opens the PR through the git hosting plugin, records the PR number on the task, and notifies. On a rework re-entry (the PR already exists), it instead dismisses the now-stale approval, refreshes the PR's title and body so they describe the whole PR as it now stands, and marks the addressed feedback applied — see The rework loop.
  4. await-review — parks the task in the awaiting_pr_review block and exits the pipeline. This is an expected wait, not a failure and not a separate state: the task is blocked, and the poller resumes it when an external event arrives.

A fifth sub-phase, auto-merge, is entry-only — the normal advance path stops at await-review and never reaches it. It runs only when an external pr_ready_to_merge or pr_merged event re-enters the task there.

In push-only mode, pr-description, create-pr, await-review, and auto-merge all skip; only push runs, and the task completes.

Key files

ConcernFile
Delivery sub-phasessrc/core/orchestrator/pipeline/delivery/{pr-description,push,create-pr,await-review,auto-merge}.ts
PR-vs-push-only skip gatesrc/core/orchestrator/pipeline/delivery/deliverable.ts
Background PR-event pollingsrc/core/daemon/pr-event-poller.ts
Core PR-event policy (routing, arbitration, dedup, /approve)src/core/orchestrator/pipeline/pr-events.ts
Re-entry wiring (consume the pending event, start at its entry point)src/core/orchestrator/index.ts

Waiting and re-entry

Once the PR is open, the task sits in blocked(awaiting_pr_review). The daemon's PR-event poller drives everything from here.

Each tick, for every task waiting on review, the poller asks the git hosting plugin what events currently hold on the PR (detectPrEvents), filters them through Core policy, picks a single winner, and re-enters the task by writing the winning event's type onto it (pending_pr_event) and re-queuing it. On the next dispatch, the orchestrator reads that type, starts the pipeline at the event's entry point, and clears it.

The whole loop is database-shaped — no in-memory wait state anywhere:

The event vocabulary is small and typed. Only pr_comments carries data (the comments, so Core can dedup and find an authorized /approve); the rest are bare signals whose detail is re-derived live when needed:

EventMeansRe-enters at
pr_commentsActionable reviewer feedbackrequirements
pr_ci_failureChecks are redexecution (implement)
pr_merge_conflictThe base moved; the branch no longer mergesexecution (implement)
pr_ready_to_mergeApproved and CI green and mergeable, all at oncedelivery (auto-merge)
pr_mergedMerged, by us or externallydelivery (auto-merge)

Three Core policies shape this, all living in pr-events.ts so a hosting plugin never re-implements them:

  • Arbitration. Several events can hold in one poll (a comment and an approval). arbitrate picks one winner by precedence — pr_merged > pr_comments > pr_merge_conflict > pr_ci_failure > pr_ready_to_merge — so pending feedback is always addressed before a merge proceeds.
  • Dedup. pr_comments is filtered against the task's accommodated_comment_ids, so the same feedback never reworks the task twice.
  • Stateless readiness. The plugin reports pr_ready_to_merge only when approval, green CI, and mergeability all hold at the same time, recomputed every poll. "Approved but CI still running" reports nothing — the task simply keeps waiting.

Re-entry flows entirely through the database (write the event, re-queue, re-dispatch). There is no back-channel call from the daemon into the orchestrator, so the boundary stays one-directional and crash-safe: the pending event lives on the task row and survives a restart.

The merge

auto-merge performs the merge. Because it is reached only by a pr_ready_to_merge / pr_merged re-entry, the PR was just observed ready — but state can shift in the moment between the signal and the merge, so auto-merge re-derives readiness from the live PR before acting, then routes on what it finds:

What auto-merge findsWhat it does
Already mergedRecords the merge (no milestone — the external-merge backfill) and completes the task
Auto-merge disabled for the repoNotifies the owner and completes — the human merges manually
CI failingReworks: jumps back to execution to fix it
Definitively conflictingReworks: jumps back to execution to resolve it
Mergeability not yet computed, or checks not yet greenReturns to the review wait; the poller retries when the PR is ready
Green and mergeableRemoves branch thoughts (if configured) and attempts the merge — then routes on the result (below)

The merge is an optimistic attempt. The Engineer does not try to predict whether its token is allowed to merge — it prepares the branch as far as it can (including the thoughts cleanup) and attempts the merge with whatever token it holds, then routes on what the host returns:

The merge attemptWhat it does
SucceedsRecords the merge (below)
Host blocks it (reason not_mergeable — a required review the Engineer cannot satisfy, or no merge permission)Hands off to the owner — notifies once and completes the task, exactly like auto-merge-disabled. The task leaves the review-poll set, so a standing /approve can never re-promote it into the same doomed merge: it does not loop.
Conflicts (reason conflict)Reworks: jumps back to execution to resolve it
Fails transientlyReturns to the review wait; the poller retries when the PR is ready

The hand-off is what keeps a protected branch from looping: when the host requires something the Engineer cannot provide (a human's formal review, say), it stops and asks the owner to merge rather than re-attempting forever. The route the attempt took is recorded as a merge_outcome decision, so the owner can always see why a PR did not merge itself. If the pre-merge thoughts cleanup pushed a commit that dismissed a formal approval (under the host's dismiss_stale_reviews), the hand-off says so and asks for a fresh approval — a /approve comment is never dismissed, so it gets the plain hand-off.

A successful merge records the merge: it stamps review.merged_at on the task, publishes git.pr_merged (from the orchestrator), and notifies the Merged PR milestone. auto-merge does not delete the branch — the daemon's workspace reaper is the sole branch deleter and removes the merged branch once workspace.pr.branch_retention_days has elapsed, publishing git.branch_deleted (from the workspace-reaper). The scheduler's completion path invokes the reaper eagerly, so retention 0 deletes the branch the moment the task completes; the reaper's interval sweep honors longer retention and is the backstop for eager-deletion failures and PRs merged while the daemon was down. An externally-merged PR takes the same record path with no milestone — the external-merge backfill — so the reaper can reap its branch too. The local worktree is reaped separately by the scheduler's normal completion path.

Auto-merge is off by default. safety.merge.auto_merge_after_approval defaults to false, so by default an approval completes the task and the owner merges the PR themselves. The merge strategy (squash / merge / rebase) comes from workspace.pr.default_merge_strategy; safety.merge.exclude_thoughts_on_merge removes branch-introduced thoughts/ files before the merge.

The rework loop

When a review event needs work rather than a merge, the task re-enters the pipeline and runs forward again:

  • New feedback (pr_comments) re-enters at requirements — feedback can change scope, so it runs the full pipeline forward (trivial-complexity skip-gates carry it past research/planning as needed). The feedback rides into the re-entered phase as context.
  • CI failure / merge conflict re-enters at execution (implement) to fix the root cause and re-push.

When the rework reaches delivery again, create-pr sees the PR already exists: it dismisses the stale approval (the code changed, so the prior sign-off no longer applies), refreshes the PR's title and body so they describe the whole PR as it now stands (regenerated from the full diff against base, and pushed only when that diff actually changed — an unchanged re-push is a clean no-op), and marks the addressed feedback applied, then await-review parks the task again. The refresh is best-effort: a failed host update never blocks the rework, since the code is already pushed. A human re-approves to re-trigger the merge — that human gate is what bounds the feedback loop, alongside a global total_reworks ceiling on a single dispatch.

Automated blockers are bounded too. A merge conflict or CI failure re-enters on its own, with no human in the loop — and because the per-dispatch total_reworks ceiling resets on every PR-event re-entry, it cannot see a blocker that keeps re-firing across dispatches. So Core counts consecutive automated-blocker re-entries on the open PR (review.consecutive_blocker_reentries); once they exceed daemon.review_polling.max_blocker_reentries (default 3), the task stops reworking and is escalated to the owner — re-blocked under the pr_rework_cap_hit reason, with an alert — instead of looping forever on a blocker the automated rework cannot clear. A reviewer comment (human engagement) or the blocker clearing resets the count. The owner resolves the PR and runs engineer retry to resume.

The /approve path

A sole contributor cannot formally approve their own PR on the host, so The Engineer recognizes a /approve (or /approved) comment as an approval. This path is gated by safety.merge.enable_comment_approval (off by default — with it off, /approve comments are ignored).

When enabled, the poller promotes an authorized /approve to a merge only after a live re-check confirms the PR is open, green, and mergeable — the same preconditions a formal approval must meet. A not-yet-green /approve leaves the task waiting rather than attempting a doomed merge. Authorization is Core policy: when an owner or reviewer is configured, the commenter's GitHub handle must match one of them; when no one is configured, any /approve counts (a permissive floor for the single-contributor case). The plugin only reports the comment as a fact — it never learns the /approve convention or sees the people directory.

Notifications

Every transition the owner cares about leaves a trail. The owner's channels receive milestones; the source ticket receives comments:

WhenChannelMessage
Work picked upmilestone + ticket"Starting work on: {title}" / "Starting work on this ticket."
PR openedmilestone + ticket"PR created: {url}"
Reviewer feedback re-queues the taskticket"Reviewer feedback received — reworking to address it."
CI failure re-queues the taskticket"CI is failing on the pull request — reworking to fix it."
Merge conflict re-queues the taskticket"The pull request has merge conflicts — reworking to resolve them."
Rework pushedticket"Pushed rework to the PR."
Approval + green re-queues for mergeticket"Pull request approved with CI green — merging."
External merge detectedticket"Pull request merged — finalizing."
Auto-merge disabled, PR readyticket"PR #{n} is approved and ready. Auto-merge is disabled for this repo — merge it when you're ready."
Host blocks the Engineer's mergeticket"PR #{n} is approved and ready, but the host won't let me complete the merge (its rules need a human). Merge it when you're ready."
Host-blocked, and a thoughts cleanup dismissed a formal approvalticket"PR #{n} is ready, but my thoughts-cleanup commit dismissed your earlier approval. Re-approve it and merge when you're ready."
Merge completedmilestone"Merged PR #{n}"

Resilience

Readiness is computed statelessly from the live PR on every poll, so there is no in-memory wait-state to lose. An earlier design tracked "approved, waiting on CI" in a daemon-memory map; a restart mid-wait could strand a task forever with nothing to alert. That class of bug cannot occur here — after a restart, the poller simply re-derives readiness from the PR.

Configuration

PR management reads its configuration from two files (documented there, not duplicated here):

  • safety.yamlmerge.*auto_merge_after_approval, enable_comment_approval, exclude_thoughts_on_merge.
  • workspace.yamlpr.*default_merge_strategy, branch_retention_days, skip_pr_creation.

The review-polling circuit breaker (review_polling.*) lives in daemon.yaml.