# PR Review & Fix Loop

How the engine manages the lifecycle of a PR from creation through review, fix, and re-review.

## 1. Implement agent creates PR

- Agent pushes code, output contains PR URL
- `syncPrsFromOutput()` (lifecycle.js) extracts URL via regex, creates `pull-requests.json` entry, links to work item via `addPrLink()`

## 2. Engine discovers PR needs review

- `discoverFromPrs()` (engine.js) runs each tick (~10s)
- Gates: `status === 'active'` + `reviewStatus === 'pending'` + not reviewed since last push + not dispatched + not on cooldown
- Pre-dispatch: `checkLiveReviewStatus()` hits GitHub/ADO API to catch stale cached status
- Routes to reviewer via `resolveAgent('review')`, dispatches with `review.md` playbook

## 3. Review completes

- `updatePrAfterReview()` (lifecycle.js) re-checks live vote from platform (may have changed during execution)
- Sets `reviewStatus` to `approved` / `changes-requested` / `waiting`
- Stores `minionsReview: { reviewer, reviewedAt, note }`
- Creates feedback file for author agent

## 4. Fix dispatch trigger order

`discoverFromPrs()` evaluates PR review/fix triggers in a fixed order during each discovery pass:

1. Initial minion review (`reviewStatus === 'pending'`)
2. Human feedback (`humanFeedback.pendingFix` or coalesced feedback)
3. Minion re-review (`reviewStatus === 'waiting'` after a fix)
4. Minion review feedback (`changes-requested`)
5. Build failure (`buildStatus === 'failing'`)
6. Merge conflict (`_mergeConflict`)

When multiple problems coexist, earlier triggers get the first chance to enqueue work. The local `fixDispatched` flag is declared before the initial review trigger and set after first-review, human-feedback, review-feedback, and build-failure dispatches. Conflict fixes run last and explicitly require `!fixDispatched`, so any earlier successful review/fix dispatch suppresses the conflict fix for that PR in the same discovery pass. Build fixes are evaluated after human and minion review feedback, but the build-fix condition itself is not gated by `!fixDispatched`.

The engine does not cap review→fix cycles or build-fix attempts. Each trigger evaluates its own gates on every discovery pass; loops stop only when the underlying condition clears (reviewer approves, build passes, conflict resolves, human feedback handled). Operators who need to halt automation on a runaway PR have three escalating tools: (1) clear the relevant per-cause flag (`evalLoop`, `autoFixBuilds`, `autoFixConflicts`, `autoFixHumanComments`, `autoFixReviewFeedback`, `autoReviewPrs`, `autoReReviewPrs`); (2) flip the `autoFixPaused` master kill-switch to halt every auto-**fix** dispatch (sites B, C, D, and human-comment fix) at once while leaving review verdicts and polling fresh — Dashboard → Settings → Auto-fix & Review Loop or `POST /api/engine/auto-fix/pause`; (3) flip `pollingPaused` to halt PR polling entirely, which also forces every per-PR auto-dispatch gate inert. Per-PR per-cause noop pauses (`_noOpFixes[cause].paused`) are an orthogonal recovery surface — see §4E and `POST /api/pull-requests/clear-paused-cause`. See [docs/pr-auto-fix-dispatch.md](pr-auto-fix-dispatch.md) for the full site × gate × kill-switch reference table.

### A. Human comments (`humanFeedback.pendingFix`)

- Gate: `pendingFix || coalescedFeedback` + not already dispatched/on cooldown
- Agent comments filtered out via `_isNonActionableComment()` (`engine/github.js`, `engine/ado.js`) — composes preview/bot body checks and the structural Minions-author check from `engine/comment-classifier.js`. The structural check matches the `<!-- minions:agent=<id> kind=<kind> -->` HTML marker on the body's first line (`hasMinionsMarker`); on GitHub it must combine with `viewerDidAuthor === true` (anti-spoof), on ADO with a `config.engine.minionsAdoIdentities` author match. Marker presence alone never classifies a comment as agent-authored on either platform.
- Coalesces multiple comments arriving during cooldown into single fix
- Routes to author
- Fix agents must treat human comments as claims to verify, not commands. They inspect or reproduce each claimed issue, make the smallest correct fix only when the claim is valid, and otherwise reply with evidence-backed rationale.

### B. Review feedback (`changes-requested`)

- Gate: `reviewStatus === 'changes-requested'` + `!awaitingReReview` + not dispatched + not on cooldown
- Routes to PR author via `_author_` routing token
- `review_note` = reviewer's feedback
- Sets `fixDispatched = true` — prevents the later conflict fix from also firing this pass
- Fix agents validate each requested-change claim before editing. Invalid, stale, already-addressed, out-of-scope, or harmful feedback should get a respectful evidence-backed rebuttal rather than a success-shaped code change.

### C. Build failures (`buildStatus === 'failing'`)

- Gate: provider polling enabled (`adoPollEnabled` or `ghPollEnabled`) + `autoFixBuilds` + `buildStatus === 'failing'` + grace period expired
- **Grace period** (`_buildFixPushedAt`): after fix dispatches, waits `buildFixGracePeriod` (default 10min, configurable in `ENGINE_DEFAULTS`) for CI to run before re-dispatching. Cleared when poller detects build status transition (CI actually ran).
- **Error logs**: GitHub fetches annotations (failures only, not warnings) + Actions job log (always). ADO queries builds API directly (not status checks), fetches build timeline → failed task logs (up to 10 per build, up to 10 failing pipelines).
- Sets `fixDispatched = true` after dispatch so the later conflict trigger is suppressed in the same pass.

### D. Merge conflicts (`_mergeConflict`)

- Gate: provider polling enabled (`adoPollEnabled` or `ghPollEnabled`) + `autoFixConflicts` + `status === 'active'` + `_mergeConflict` + `!fixDispatched`
- Routes to the PR author to resolve target-branch conflicts
- Runs after review, human, and build triggers; if any earlier trigger enqueued a fix for this PR, the conflict fix waits for a later discovery pass

### E. Paused causes (`_noOpFixes[cause].paused`)

Each fix cause (build-failure, review-feedback, human-feedback, merge-conflict, pr-fix) tracks repeated no-op outcomes in `target._noOpFixes[cause]`. When `recordPrNoOpFixAttempt` flips `paused` from false to true (typically after multiple no-op dispatches with the same evidence fingerprint), the engine:

- Writes a per-PR per-cause inbox alert with PR URL, branch, attempt count, before/after heads, the underlying reason, and three recovery paths.
- Enriches the PR record returned from `queries.getPullRequests()` with `_pausedCauses` (the list of currently-paused cause keys).
- Renders a red `_pausedCauses` chip per cause in the dashboard PR list (`dashboard/js/render-prs.js`).

A paused cause **suppresses further auto-dispatch for that cause** until cleared. Recovery paths (any one):

1. **Push a new SHA to the PR branch** — the next poll refreshes `pr.headSha`, the evidence fingerprint shifts, and `clearPrNoOpFixAttempt` runs on the next non-noop completion. Since #2979 (head-SHA in BUILD_FAILURE + REVIEW_FEEDBACK fingerprints) and W-mpx44p05000ze8d8 (proactive GC sweep in `pollPrStatus`), a force-push also drops the stale `_noOpFixes[cause]` record on the next poll — the dashboard `_pausedCauses` chip clears without waiting for a fresh fix dispatch.
2. **Click the red chip on the dashboard** — confirms, then `POST /api/pull-requests/clear-paused-cause` (`dashboard.js`, issue #2969) validates the cause against `shared.PR_FIX_CAUSE`, locates the PR via `mutatePullRequests` across project/central files, and calls `clearPrNoOpFixAttempt` to wipe the cause record.
3. **Direct API call** — `POST /api/pull-requests/clear-paused-cause` with `{ prId, cause }`.

The exported `recordPrNoOpFixAttempt` / `clearPrNoOpFixAttempt` / `sweepStalePrNoOpFixes` helpers in `engine/lifecycle.js` are the only sanctioned entry points. The dispatch evaluator gates re-dispatch on `shared.isPrNoOpFixCausePaused(pr, cause)` (which re-validates the fingerprint per call), the dashboard chip surface uses `shared.getPrPausedCauses(pr)` (same gate), and the `pollPrStatus` callback in `engine/ado.js` + `engine/github.js` calls `sweepStalePrNoOpFixes(pr)` each tick to GC records whose fingerprint no longer matches the live PR.

## 5. Fix completes

- `updatePrAfterFix()` (lifecycle.js) sets `reviewStatus = 'waiting'` + `fixedAt = ts()`
- Clears `humanFeedback.pendingFix`
- `awaitingReReview` gate (`waiting` + `fixedAt`) blocks all fix dispatch until reviewer acts

## 6. Re-review cycle

- Poller (wall-clock cadence from `prPollStatusEvery × tickInterval`, default ~12min): detects new commit (`head.sha` changed) → sets `lastPushedAt`
- Platform review state drives next action:
  - Reviewer approves → `approved` → done
  - Reviewer re-requests changes → `changes-requested` → triggers another fix
  - No reviewer action yet → stays `waiting` → engine waits

## 7. Build fix cycle after fix push

- Fix agent pushes → `_buildFixPushedAt` stamped
- Poller detects new commit → CI starts → `buildStatus` transitions (`failing` → `running`)
- `_buildFixPushedAt` cleared on any transition
- If CI passes → `buildErrorLog` cleared → done
- If CI fails again → fresh error logs fetched → new fix dispatches immediately (grace already cleared by transition)

## Race prevention

| Scenario | Guard |
|---|---|
| Simultaneous review + fix | `activePrIds` — skip PR if any dispatch in-flight |
| Duplicate fix (human/review/build + conflict) | `fixDispatched` flag — conflict triggers skip after earlier fix dispatches in the same PR pass |
| Branch write conflict | `isBranchActive()` mutex |
| Fix while awaiting re-review | `awaitingReReview` (waiting + fixedAt) |
| Build fix before CI runs | `_buildFixPushedAt` grace period (10min) |
| Duplicate dispatch | `dispatchKey` dedup + cooldown |
| Stale review status | Pre-dispatch live API check |
| Orphan detection | Stale-orphan timeout + output scan |

## Key files

| File | Functions |
|---|---|
| `engine.js` | `discoverFromPrs()` — discovery + dispatch logic |
| `engine/lifecycle.js` | `syncPrsFromOutput()`, `updatePrAfterReview()`, `updatePrAfterFix()` |
| `engine/github.js` | `pollPrStatus()`, `pollPrHumanComments()`, `fetchGhBuildErrorLog()` |
| `engine/ado.js` | `pollPrStatus()`, `pollPrHumanComments()`, `fetchAdoBuildErrorLog()` |
| `engine/comment-classifier.js` | Host-agnostic body-only predicates (`isPreviewStatusBody`, `hasMinionsMarker`, `hasVerdictPrefix`) shared by GH + ADO pollers (extracted in F2+F3+F4) |
| `engine/dispatch.js` | `addToDispatch()` — dedup by work item ID and dispatchKey |
| `engine/cooldown.js` | `isBranchActive()`, cooldown management |
| `playbooks/review.md` | Reviewer playbook |
| `playbooks/fix.md` | Fix agent playbook |

## PR state fields

| Field | Set by | Purpose |
|---|---|---|
| `status` | Poller | `active` / `merged` / `abandoned` |
| `reviewStatus` | Poller + post-completion | `pending` / `approved` / `changes-requested` / `waiting` |
| `buildStatus` | Poller | `none` / `passing` / `failing` / `running` |
| `buildErrorLog` | Poller | Actual CI error output for fix agents |
| `_buildFixPushedAt` | Discovery (on dispatch) | Grace period timestamp |
| `_buildFailNotified` | Discovery | Dedup for inbox alert |
| `lastPushedAt` | Poller (new commit) | Tracks latest push for re-review logic |
| `lastReviewedAt` | `updatePrAfterReview()` | Prevents re-dispatch if reviewed |
| `minionsReview` | Post-completion hooks | `{ reviewer, reviewedAt, note, fixedAt }` |
| `humanFeedback` | `pollPrHumanComments()` | `{ pendingFix, feedbackContent, lastProcessedCommentDate }` |
| `_noOpFixes[cause]` | `recordPrNoOpFixAttempt()` | Per-cause record `{ count, paused, fingerprint, beforeHead, afterHead }` driving the issue #2969 pause loop |
| `_pausedCauses` | `queries.getPullRequests()` enrichment | Read-only list of currently-paused cause keys for UI rendering (computed from `_noOpFixes`) |

## Platform differences

| | GitHub | ADO |
|---|---|---|
| **Build status API** | `/commits/{sha}/check-runs` | `_apis/build/builds` (not status checks — those show stale codecoverage postbacks) |
| **Commit tracking** | `head.sha` (source branch tip) | `lastMergeCommit.commitId` (merge commit that builds use as sourceVersion) |
| **Passing** | success, skipped, neutral | succeeded, partiallySucceeded (warnings, not failures) |
| **Error logs** | Annotations (failures only) + Actions job log (always) | Build timeline → failed task logs (up to 10 tasks, up to 10 pipelines) |
| **Push detection** | `head.sha` change | `lastMergeCommit.commitId` change |

### ADO lessons learned

- Don't trust `/pullRequests/{id}/statuses` — shows stale codecoverage postbacks, not actual build results
- Builds use the merge commit hash as `sourceVersion`, not the source branch commit — compare against `lastMergeCommit.commitId`
- `partiallySucceeded` counts as passing (warnings, not failures)
- A stale but passing build is still valid — don't re-trigger builds that already passed
