Skip to content

feat(reconcilers): open-PR divergence reconciler — rebase or demote stale PRs after a main merge#175

Merged
thejustinwalsh merged 24 commits into
mainfrom
middle-issue-168
May 29, 2026
Merged

feat(reconcilers): open-PR divergence reconciler — rebase or demote stale PRs after a main merge#175
thejustinwalsh merged 24 commits into
mainfrom
middle-issue-168

Conversation

@thejustinwalsh

@thejustinwalsh thejustinwalsh commented May 28, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #168

Open-PR divergence reconciler: when an Epic PR merges to main, sibling open Epic PRs may become non-mergeable. This adds an autonomous reconciler that detects that drift on every poller tick (and immediately at the moment of a MERGED transition), tries to fix it (rebase first, -X ours merge-commit fallback per CLAUDE.md new-work-as-base), and on double-failure demotes the PR back to draft, reopens its last closed sub-issue, and re-enqueues the Epic through the recommender — so a ready PR never silently rots between a main merge and the human's final-merge gate.

Sister to #139 (parked-workflows reconciler on Epic PR merge/close, merged) and #141 (CI-gate review readiness, merged). Targets the third reconciliation surface: open-and-still-active Epic PRs against a moving main.

What changed

  • packages/dispatcher/src/reconcilers/pr-divergence.tsnew module: classifier (classifyDivergence), worktree helpers (tryRebaseOntoMain, tryMergeMainNewWorkAsBase), success applier (applySuccess), demote applier (applyDemoteToWork), orchestrator (reconcileOpenPRs), production ghReconcilerGateway.
  • packages/dispatcher/src/db/migrations/006_pr_divergence_state.sql — new table pr_divergence_state(repo, pr_number, state, classified_at) with the CHECK-pinned vocabulary and a (repo, pr_number) PK so the same column works for future multi-repo daemons.
  • packages/dispatcher/src/poller.tsreconcileMergedParks gains an onMergedTransition hook with per-pass dedup (Set inside the loop; the natural per-pass boundary, no timer race).
  • packages/dispatcher/src/poller-cron.ts — new ReconcilerHooks arg (perTickSweep + onMergedTransition); both wrapped in try/catch so a thrown sweep never crashes the cron worker.
  • packages/dispatcher/src/main.ts — wires reconcileOpenPRsForRepo (composing ghGitHub + ghReconcilerGateway, threading runRecommenderForRepo + scheduleAutoDispatch as the enqueueEpic seam so re-dispatch routes through the recommender's ranking).
  • packages/dispatcher/test/pr-divergence.test.ts + pr-divergence-integration.test.ts — 41 new tests against the real fixture repo + stub gateways.
  • packages/dispatcher/test/reconcile.test.ts — 2 new tests pinning the onMergedTransition per-pass dedup.

Why these changes

The reconciler completes the three-surface coverage of stale Epic PRs: #139 cleaned up parked workflows after a PR merges; #141 gates review readiness on green CI; this work keeps the open, active PRs mergeable when main moves. The chain (rebase → -X ours → demote) follows CLAUDE.md's "Keeping the branch mergeable into main" rule directly — rebase is the default, the -X ours merge fallback handles "main re-architected the same code", and only when both fail does the reconciler escalate.

The helpers are pure functions over injectable gateways so the unit tests need neither gh nor the daemon; the production wiring lives in one main.ts block. The internal-review loop surfaced six adjacent edges in the helper contracts (skip vs fail shape, null mainSha, manual recovery, NaN closed_at, unguarded JSON parse, onMergedTransition burst) — all addressed before this PR was marked ready.

Verification

Phase 1 (#169) — classify divergence + migration

  • 14 unit tests in pr-divergence.test.ts: every classifier branch + persistence round-trip + CHECK rejects out-of-vocabulary state + (repo, pr_number) PK coexistence.

Phase 2 (#170) — rebase helper

  • 5 fixture-repo integration tests against real git: clean fast-forward, non-FF without conflict, conflict + abort (paths reported, no .git/rebase-merge left), non-managed head ref skip, missing PR skip.

Phase 3 (#171) — -X ours merge fallback

  • 2 integration tests: same-line content collision (rebase conflicts, fallback lands; feature's content wins; merge commit has ≥2 parents) and rename/rename -X ours can't auto-resolve (abort, paths reported, no .git/MERGE_HEAD).

Phase 4 (#172) — applySuccess

  • 4 integration tests: push happens + comment once + state→CLEAN + idempotence across two consecutive calls (proven against the bare remote — git rev-parse middle-issue-32 matches the pushed HEAD); a different mainCommitSha re-announces (marker is sha-keyed); null mainCommitSha skips comment but still pushes and records CLEAN; non-managed head ref no-op.

Phase 5 (#173) — applyDemoteToWork

  • 7 unit tests: full demote happy path (draft + reopen sub-issue + dual-surface escalation comments + enqueue + state→DEMOTED); idempotent re-call via PR.isDraft; partial-retry marker safety; Epic with no closed sub-issues; non-managed head ref; missing PR; manual recovery — an Epic that already carries the demote marker skips the reopenIssue call so a human's manual sub-issue close isn't undone.

Phase 6 (#174) — reconcileOpenPRs orchestrator + wiring

  • 8 end-to-end integration tests: BEHIND→rebase→applySuccess + idempotent re-tick (CLEAN classification → passed); CONFLICTED→merge-fallback→applySuccess; CONFLICTED double-failure→applyDemoteToWork (draft + reopen sub-issue 50 + dual-surface escalations + 1 enqueue + state→DEMOTED, with conflicting paths in the body); rate-limit floor short-circuit (no listing call); CLEAN walk (no side effects); listOpenManagedPrs throws (isolated); two open managed PRs walked in one pass (mix of CLEAN + BEHIND→rebased); per-PR throw increments failed counter and pass continues.
  • 2 wiring tests in reconcile.test.ts: onMergedTransition fires at most once per repo per pass (per-pass dedup inside reconcileMergedParks); a thrown hook is isolated and the merged-parks pass still finalizes every row.

Gatesbun test 870/870 pass; bun run typecheck clean; bun run lint (oxlint --fix --deny-warnings) clean; bun run format (oxfmt) clean.

Mergeabilitygh pr view 175 --json mergeable,mergeStateStatus reads {mergeable: MERGEABLE, mergeStateStatus: CLEAN}. Rebased onto origin/main before marking ready.

Acceptance evidence

Walking the Epic's acceptance criteria (#168):

Stumbling points

  • GitHub's head:<prefix> search syntax doesn't strictly pin a branch-name prefix on gh pr list. The reconciler lists all open PRs and filters client-side on headRefName.startsWith("middle-issue-") — capped at 100 per call (the rate-limit floor is what bounds large backlogs).
  • Rename/rename is the reliable -X ours failure mode — the spec called this "rare; structural rename + content collision", but a same-file dual rename trips it deterministically and was the cleanest fixture-repo test for the demote path.
  • Internal review found six classes of adjacent bugs in one pass — the gateway-shape conflation (skip vs fail), the null-mainSha drop, the manual-recovery reopenIssue re-fire, NaN closed_at, unguarded JSON.parse, and the timer-based dedup race. The "be your own CodeRabbit before pushing" loop ran twice; each round resolved the class (within the helper's blast radius) rather than just the named instance.

Suggested CLAUDE.md updates

None. The existing "Keeping the branch mergeable into main" section already encodes the rebase-first + new-work-as-base merge fallback the reconciler implements; nothing this work hit needed a new doc.

Architectural forks

None — the chain (classify → rebase → merge fallback → demote) and the helper signatures were spec'd by the sub-issue bodies; CLAUDE.md and the existing recommender/poller patterns settled every other decision without needing a worktree fork.

Follow-up issues

None opened. Two MINOR observations from the review loop were resolved in-pass; nothing else surfaced that crosses the "discovered, parallelizable, out-of-scope" bar.

Out of scope

How to run / verify

# Type-check + lint + format + tests
bun run typecheck
bun run lint
bun run format
bun test

# Just the reconciler's tests
bun test packages/dispatcher/test/pr-divergence.test.ts \
         packages/dispatcher/test/pr-divergence-integration.test.ts \
         packages/dispatcher/test/reconcile.test.ts

Reviewer's brief

  • Where to start: packages/dispatcher/src/reconcilers/pr-divergence.ts is the entire reconciler. Read top to bottom: classifier (Phase 1), worktree helpers (Phase 2-3), applySuccess (Phase 4), applyDemoteToWork (Phase 5), reconcileOpenPRs orchestrator + ghReconcilerGateway (Phase 6). The structure mirrors the six sub-issues in dispatch order.
  • Load-bearing pieces:
    1. gitOps.rebase / mergeOurs distinguish conflict (paths reported, abort) from wiring failure (throw) — the orchestrator's failed counter exists for the second case.
    2. applySuccess's push idempotence (local-vs-remote SHA compare) and sha-keyed marker for comment idempotence.
    3. applyDemoteToWork's two-gate idempotency: PR.isDraft short-circuit + Epic-marker gate on the reopenIssue step (so a human's manual recovery isn't undone).
    4. reconcileMergedParks's per-pass dedup of onMergedTransition (inside the loop, no timer — the original timer-based dedup raced across await boundaries).
  • Fragile-looking-but-correct: the gateway's getMergeability / getPullRequest use safeJsonParse to tolerate gh's occasional empty-stdout-with-warning-text interleaving. The integration tests use a symlink to plant the fixture worktree under the helper's expected layout (<root>/<repo>/issue-<N>/); existsSync follows the symlink and git -C follows it through to the real fixture.
  • What's NOT here: no changes to the recommender's ranking logic; enqueueEpic calls runRecommenderForRepo so ranking still applies. No new public API on @middle/dispatcher's index.ts — the reconciler is wired only via main.ts.

Final review and merge are the human's call.

Summary by CodeRabbit

  • New Features

    • Added Codex as a second agent adapter with launch, prompt, transcript, stop/rate-limit handling
    • --adapter flag for mm dispatch to override adapter selection
    • Automatic adapter selection from Epic labels (agent:<name>)
    • Automated PR reconciliation: rebase/merge-fallbacks or demote stale PRs with notifications
  • Improvements

    • Health checks now validate all configured adapters
    • Dispatcher polling cadence tightened to 60s
    • Dispatch rejects disabled adapters with clear error messages

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a3caeeaa-3f99-4286-ab95-6d367e8a1d93

📥 Commits

Reviewing files that changed from the base of the PR and between 2d9930e and 0cdc570.

📒 Files selected for processing (10)
  • packages/cli/src/commands/docs.ts
  • packages/cli/src/index.ts
  • packages/core/src/index.ts
  • packages/dispatcher/src/documentation-run.ts
  • packages/dispatcher/src/main.ts
  • packages/dispatcher/src/poller-cron.ts
  • packages/dispatcher/src/recommender-run.ts
  • packages/dispatcher/test/documentation-run.test.ts
  • packages/dispatcher/test/gates/checkbox-revert-pass.test.ts
  • packages/dispatcher/test/recommender-run.test.ts
🚧 Files skipped from review as they are similar to previous changes (6)
  • packages/cli/src/index.ts
  • packages/dispatcher/src/recommender-run.ts
  • packages/core/src/index.ts
  • packages/dispatcher/src/documentation-run.ts
  • packages/dispatcher/test/documentation-run.test.ts
  • packages/cli/src/commands/docs.ts

📝 Walkthrough

Walkthrough

Adds a Codex AgentAdapter and tests; implements a four-rule adapter selection and centralized adapter registry; updates CLI/dispatcher to support multi-adapter dispatch and validation; introduces a PR divergence reconciler with DB migration, git/worktree resolution, rebase/merge-fallback logic, demotion flow, poller wiring, and extensive unit/integration tests.

Changes

Codex Adapter Implementation

Layer / File(s) Summary
Codex package and entry
packages/adapters/codex/package.json, packages/adapters/codex/src/index.ts
Adds smol-toml to devDependencies and exports codexAdapter, detectNeedsLogin, and auto-mode readiness helpers.
Codex stop classification
packages/adapters/codex/src/classify.ts
Implements classifyStop and detectRateLimit with a Codex rate-limit regex, transcript tail reader, and tolerant .middle sentinel parsing.
Codex hooks
packages/adapters/codex/src/hooks.ts
Writes executable hook.sh and pr-ready-gate.sh and emits .codex/config.toml mapping Codex events to normalized commands.
Codex prompts & transcript
packages/adapters/codex/src/prompt.ts, packages/adapters/codex/src/transcript.ts
Adds buildPromptText mapping for prompt kinds and transcript resolution/parsing into TranscriptState.
Codex tests
packages/adapters/codex/test/adapter.test.ts
Comprehensive Bun tests covering adapter contract, prompts, transcripts, classification, hooks, login detection, and enterAutoMode.

Core Adapter Selection

Layer / File(s) Summary
Adapter selection algorithm
packages/core/src/select-adapter.ts, packages/core/src/index.ts
Adds selectAdapter and types implementing label override, default fallback, portable switching from rate-limited adapters, and skip signaling; re-exported from core.
Selection tests
packages/core/test/select-adapter.test.ts
Tests label precedence, default fallback, portable switching, conflict handling, and skip behavior.

Adapter Registry and CLI/Dispatcher Wiring

Layer / File(s) Summary
Adapter registry
packages/dispatcher/src/adapters.ts, packages/dispatcher/package.json
New registry exporting knownAdapters/isKnownAdapter/getAdapter; dispatcher depends on adapter-codex workspace package.
Dispatch CLI
packages/cli/src/commands/dispatch.ts, packages/cli/src/index.ts, packages/cli/test/dispatch.test.ts
mm dispatch gains --adapter override; runDispatch resolves adapters from config+registry or labels via selectAdapter; tests for override/label/default/disabled/unknown cases.
Docs & Doctor CLI
packages/cli/src/commands/docs.ts, packages/cli/src/commands/doctor.ts
Docs uses shared getAdapter; doctor checks all enabled adapters' binaries via loadConfig.
Dispatcher validation & control-plane
packages/dispatcher/src/main.ts, packages/dispatcher/src/documentation-run.ts, packages/dispatcher/src/hook-server.ts
Dynamic adapter validation via getAdapter; control-plane adapterRejection returns message or null; docs/recommender validate adapter exists and is enabled.
Cross-adapter conformance tests
packages/dispatcher/test/adapter-conformance.test.ts, related tests
Tests ensure registry contains claude and codex, adapter contract conformance, hook installation, sentinel handling, and detectRateLimit presence.
Dashboard & DB updates
packages/dashboard/src/db-deps.ts, packages/dispatcher/test/db.test.ts
Dashboard fallback now lists claude and codex; tests expect DB schema version 6 after migration.

PR Divergence Reconciler (Epic #168)

Layer / File(s) Summary
Schema & types
packages/dispatcher/src/db/migrations/006_pr_divergence_state.sql, packages/dispatcher/src/reconcilers/pr-divergence.ts
Adds pr_divergence_state migration and divergence types/classifyMergeability.
Git & worktree ops
packages/dispatcher/src/reconcilers/pr-divergence.ts
Implements GitOps, worktree resolution, tryRebaseOntoMain, and tryMergeMainNewWorkAsBase with conflict reporting.
Apply success & demote
packages/dispatcher/src/reconcilers/pr-divergence.ts
applySuccess force-push+idempotent comment and record CLEAN; applyDemoteToWork converts to draft, reopens sub-issue, posts guarded comments, re-enqueues, and records DEMOTED.
Orchestrator & gateway
packages/dispatcher/src/reconcilers/pr-divergence.ts
reconcileOpenPRs enforces budget/cap, processes managed PRs through classify→rebase→merge→demote with per-PR isolation; ghReconcilerGateway wraps gh commands and provides helpers.
Poller integration
packages/dispatcher/src/poller-cron.ts, packages/dispatcher/src/poller.ts, packages/dispatcher/src/main.ts
Poller cadence changed to 60s; startPoller accepts ReconcilerHooks and main wires perTickSweep and onMergedTransition to run reconcileOpenPRsForRepo and immediate sweeps.
Tests
packages/dispatcher/test/pr-divergence.test.ts, packages/dispatcher/test/pr-divergence-integration.test.ts, packages/dispatcher/test/reconcile.test.ts
Unit and integration tests with Git fixtures cover rebase/merge scenarios, applySuccess/idempotency, demotion flow, multi-PR iteration, and hook dedup/isolation.

Planning & Decisions

Layer / File(s) Summary
Planning and decision records
planning/issues/60/*, planning/issues/168/plan.md
Documents CodexAdapter decisions, hook-event mapping, sentinel behavior, abstraction-audit, PR-divergence plan, and operator acceptance notes.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

Full AgentAdapter for the Codex CLI mirroring ClaudeAdapter:
- buildLaunchCommand: interactive `codex` (no exec); auto mode + sandbox in
  .codex/config.toml, not the command line
- installHooks: writes .codex/config.toml with approval_policy/sandbox and a
  [hooks] block mapping Codex's event names (startup/turn-start/command/
  turn-end/shutdown) to the normalized taxonomy; reuses the shared hook.sh +
  pr-ready-gate.sh
- buildPromptText: parity with Claude (slash-command + @-reference)
- enterAutoMode: config-driven, so only fails fast on a not-logged-in pane
- resolveTranscriptPath/readTranscriptState: rollout JSONL location + format
- classifyStop/detectRateLimit: shared sentinel logic + Codex's generous
  rate-limit pattern (/rate.?limit|429|too many requests/i)

Codex's observable bits are coded as the spec's start-generous baseline behind
the interface; documented as tightening points in decisions.md.

36 unit tests cover buildLaunchCommand, buildPromptText, installHooks output
shape, classifyStop branches, transcript reads, and rate-limit detection.
…ender

- core/select-adapter.ts: selectAdapter() encodes the spec's 4-rule selection
  (agent:<name> label override → default_adapter → rate-limit switch when
  portable → otherwise skip). A label pin is never switched away from.
- dispatcher/adapters.ts: shared registry (getAdapter/isKnownAdapter/
  knownAdapters) replacing the two hardcoded claude-only getAdapter copies;
  registers claude + codex.
- main.ts/docs.ts/recommender-run.ts/documentation-run.ts: the four claude-only
  gates now validate against the registry, so codex is dispatchable end-to-end.
- mm dispatch: --adapter override, else honors the Epic's agent:<name> label via
  selectAdapter (best-effort gh label fetch, falls back to the default).

Recommender skill prose + state-issue schema already record the per-Epic adapter
in the Ready table. Tests: 12 selectAdapter cases (label/default/switch/skip) +
mm dispatch selection wiring; documentation-run test flipped to accept a known
non-default adapter and reject an unknown one.
…adapters

- adapter-conformance.test.ts: drives both adapters through the same registry
  (getAdapter) and the same interface calls, asserting each conforms and that
  the adapter-agnostic sentinel classification behaves identically — the
  automated same-path cross-adapter proof (#63 criterion 4).
- Leak audit (#63 criterion 2): the dispatch-path leak (hardcoded registries +
  claude-only gates) was fixed in #62. Fixed two tooling leaks in-pass:
  - mm doctor now checks every configured adapter's binary (missing = warn,
    not a blocking fail) instead of hardcoding claude.
  - dashboard slot-pill fallback now lists claude+codex (stale 'codex is a
    later phase' comment removed).
  The state-issue parser/recommender {claude,codex} rate-limit pair is a
  documented deliberate exception (schema v1 fixes the set).
- The AgentAdapter interface did not change for Codex (#63 criterion 3 / Epic
  headline) — documented in decisions.md.

The live dual-dispatch on a test repo (Epic headline + #63 criterion 1) needs a
running Codex CLI + OpenAI creds, absent in the sandbox — it's the operator's
acceptance step (reviewer's brief).
…boundaried 429

Internal review-loop findings (resolve the class, each with a test):
- mm dispatch resolved its adapter set from Object.keys(config.adapters),
  ignoring the enabled flag and the registry — so a disabled or unimplemented
  adapter could be force-dispatched. Now "available" = configured AND enabled
  AND implemented (intersect with knownAdapters()), matching the daemon's
  validation. Test: --adapter codex with codex disabled → exit 1, no dispatch.
- Codex rate-limit regex matched 429 as a bare substring (e.g. "line 4290",
  "commit 4291ab"), which would falsely mark a healthy agent rate-limited and
  skip it. Word-boundaried to \b429\b. Tests: 4290/4291ab/14290/42900 → bare-stop.
Six-phase workstream: classify divergence → rebase helper → -X ours merge
fallback → applySuccess (push+comment) → applyDemoteToWork (draft+reopen+
enqueue) → reconcileOpenPRs + poller-tick + MERGED-transition immediate sweep.
New module packages/dispatcher/src/reconcilers/pr-divergence.ts —
`classifyDivergence(deps, repo, prNumber)` reads mergeable/mergeStateStatus
from an injected gateway and returns one of CLEAN/BEHIND/CONFLICTED/UNKNOWN,
persisting the observation in the new `pr_divergence_state` table
(006_pr_divergence_state.sql, keyed (repo, pr_number), CHECK-pinned vocabulary).
Pure `classifyMergeability` split out so each branch unit-tests without a
gateway. Bumped db.test.ts schema-version assertions to 6.
`tryRebaseOntoMain(deps, repo, prNumber)` resolves the PR's worktree by its
managed `middle-issue-<N>` head ref (creating it via `createWorktree` if absent
under `<worktreeRoot>/<repo>/issue-<N>/`), fetches `origin main`, runs
`git rebase origin/main`, and on conflict captures the unmerged paths from
`git diff --diff-filter=U` BEFORE aborting with `git rebase --abort` so the
worktree returns to a clean state.

Pure `GitOps` + `PrHeadRefGateway` seams keep the helper unit-testable; the
production `gitOps` (real `git` via `Bun.spawn`) is exercised in a new
fixture-repo integration test covering FF / non-FF-no-conflict / conflict +
abort, plus skip-signals for a non-managed head ref and a missing PR.

Also exports `parseEpicFromHeadRef` and `worktreePathFor` (unit tests pin the
managed-ref vocabulary and the layout matches `createWorktree`).
…171)

`tryMergeMainNewWorkAsBase(deps, repo, prNumber)` runs
`git merge --no-edit --no-ff -X ours origin/main` so content collisions resolve
new-work-as-base (the branch's version wins; main's net-new lands on top).
Mirrors the rebase helper's shape: same worktree resolution, same GitResolutionResult
contract, same abort-on-residual-conflict behavior — paths captured BEFORE
`git merge --abort` so the worktree returns to a clean state.

Integration tests cover both paths against a real fixture repo:
- a same-line content collision (rebase would conflict — the fallback lands;
  feature's content wins; a merge commit lands in history with ≥2 parents)
- a rename/rename conflict -X ours can't auto-resolve (abort, paths reported,
  no leftover .git/MERGE_HEAD)

--no-ff is deliberate: even an FF-eligible merge produces a visible merge
commit, so a reviewer can see main was folded in (the reconciliation is loud
on purpose).
…#172)

`applySuccess(deps, repo, prNumber, resolution, mainCommitSha)` lands a clean
rebase or merge by pushing the worktree back to its PR branch, posting one
announcement comment on the PR, and recording CLEAN in pr_divergence_state.

Idempotent across consecutive calls for the same reconciliation:
- Push is conditional: `git fetch origin <branch>` first, then push only if
  local HEAD differs from origin/<branch>; force-with-lease never plain --force.
- Comment is gated by a hidden HTML marker (`<!-- middle-divergence: sha9:res -->`)
  embedded in the body — listed comments are scanned for it, so a second call
  with the same (sha, resolution) skips the post but a future main sha re-announces.
- State row is upserted (idempotent by construction).

Adds three GitOps methods backing this: `revParse(cwd, ref)`,
`pushForceWithLease(cwd, branch)`, plus the `--no-edit --no-ff` already on
`mergeOurs`. Three integration tests exercise the real-`git` paths against the
fixture: push-happens + comment-once + double-call idempotence, sha-keyed
re-announcement, and the non-managed head-ref no-op.
…+ dual-surface escalate + re-enqueue (#173)

`applyDemoteToWork(deps, repo, prNumber, conflictingPaths)` escalates when both
autonomous attempts (rebase + -X ours merge) have failed:

1. Resolves the Epic via the PR's `middle-issue-<N>` head ref.
2. Skips entirely if the PR is already a draft — the demote has already landed.
   This gate survives a classifier overwrite of pr_divergence_state, since the
   live PR state is the source of truth for the action.
3. Converts the PR to draft.
4. Reopens the most-recently-closed sub-issue with an in-line escalation
   comment (or skips when no sub-issue is closed yet).
5. Posts a dual-surface escalation on both the Epic and the PR (CLAUDE.md
   review-feedback convention), embedding a hidden HTML marker keyed on the
   Epic number so a partial-retry doesn't pile on duplicates.
6. Re-enqueues the Epic through the injected `enqueueEpic` callback so the
   recommender's ranking still applies (Phase 6 wires it to a recommender run +
   scheduleAutoDispatch).
7. Records DEMOTED in pr_divergence_state.

Six unit tests cover: full demote happy path (all calls in order), idempotent
re-call via PR.isDraft, partial-retry marker safety, Epic with no closed
sub-issues (graceful), non-managed head ref (no-op), missing PR (no-op).
…#174)

`reconcileOpenPRs(deps, repo)` composes Phases 1-5 into one pass:

  listOpenManagedPrs → for each:
    classifyDivergence → CLEAN/UNKNOWN: pass
                       → BEHIND/CONFLICTED:
                           tryRebaseOntoMain → ok: applySuccess('rebased')
                                             → conflict:
                               tryMergeMainNewWorkAsBase
                                  → ok: applySuccess('merged-new-work-as-base')
                                  → conflict: applyDemoteToWork(union of paths)

- Rate-limit aware: skips the pass when GitHub budget < `rateLimitBuffer`
  (default 100, matches the resume poller). Returns `skippedForBudget: true`.
- Burst-capped at `maxPrsPerPass` (default 25). Per-PR failures are isolated
  and logged; the pass continues.
- Production `ghReconcilerGateway` exports the new gh-backed calls
  (listOpenManagedPrs, getMainCommitSha, getMergeability, getPrHeadRef,
  getPullRequest, convertPrToDraft, listClosedSubIssues, reopenIssue).
  Composed with `ghGitHub` for comment ops at the daemon-wiring site.

Wiring:
- `startPoller` gains a `ReconcilerHooks` arg with `perTickSweep` (per-tick
  sweep over every managed repo) and `onMergedTransition` (immediate sweep
  fired from inside `reconcileMergedParks` when a parked Epic's PR is observed
  transitioning to MERGED — divergence on siblings heals at the moment of
  merge, not up to a tick later).
- `main.ts` builds `reconcileOpenPRsForRepo` (composing `ghGitHub` +
  `ghReconcilerGateway`, threading `runRecommenderForRepo` + `scheduleAutoDispatch`
  as the `enqueueEpic` seam so re-dispatch still routes through the
  recommender's ranking).

Six added integration tests cover the end-to-end paths against the real
fixture repo: BEHIND→rebase→applySuccess (+ idempotent re-tick reading CLEAN),
CONFLICTED→merge-fallback, double-failure→demote, rate-limit floor short-
circuit, CLEAN walk (no side effects), and listing-throws isolation.
…tests

Acceptance-criteria coverage for Epic #168:

- `reconcile.test.ts`: a `reconcileMergedParks` pass with multiple MERGED
  transitions fires `onMergedTransition` once per row (Epic #168 hook the
  daemon wires to an immediate `reconcileOpenPRs` sweep); a thrown hook is
  isolated and the merged-parks pass still finalizes every row.
- `pr-divergence-integration.test.ts`: a two-PR pass exercises the iteration —
  one BEHIND PR rebases to CLEAN (applySuccess posted), one already-CLEAN PR
  is walked but unchanged. Proves the orchestrator's loop on the
  spec's "merging one triggers the reconciler; the other rebases" topology.
…anual recovery, observability

Pre-push internal code-review pass surfaced six adjacent edges in the same
class — the reconciler's result/failure shapes leaking into wrong dispatch
paths. Fix the class within the module's blast radius, each fix tested:

1. **`gitOps.rebase` / `mergeOurs` throw on non-conflict failures** — when the
   underlying command exits non-zero with zero unmerged paths (missing upstream,
   hook refusal, dirty worktree), it's a wiring failure, not a path-less
   conflict. Before: shaped as `{ok:false, conflictingPaths:[]}` indistinguishable
   from the non-managed-PR skip signal. After: throws; the orchestrator's
   per-PR try/catch logs and increments the new `failed` counter.

2. **`applySuccess` accepts `mainCommitSha: string | null`** — a transient
   `getMainCommitSha` failure (Phase 6) used to silently skip the entire
   applySuccess (push + comment + state→CLEAN), but the orchestrator still
   counted it as `reconciled`. After: push + state row are unconditional; only
   the comment step skips when the SHA is null (the marker would be ambiguous).
   A later pass with a readable SHA posts under the correct marker.

3. **`applyDemoteToWork` skips `reopenIssue` when the Epic already carries
   the demote marker** — a human who reviews the escalation, manually fixes
   the conflict, marks the PR ready, and closes the reopened sub-issue
   shouldn't have it re-opened by the next divergence. The PR.isDraft gate
   doesn't catch this (human un-drafted it); the Epic marker does.

4. **`listClosedSubIssues` defends against `NaN` from `Date.parse`** —
   malformed `closed_at` (hand-edited rows) now coerces to null instead of
   becoming a NaN that destabilizes the V8 sort.

5. **`getMergeability` / `getPullRequest` guard `JSON.parse`** via a new
   `safeJsonParse` helper — empty/malformed `gh` stdout (observed in the wild
   when auth warnings interleave) now returns null instead of throwing
   `SyntaxError`. `ghJson` similarly wraps for the throwing path.

6. **`reconcileOpenPRs` reports a `failed` counter** + logs when the
   `maxPrsPerPass` cap drops the tail of a large backlog. Distinguishes a pass
   of all-CLEAN from a pass of all-failures for observability.

7. **`main.ts` dedups `onMergedTransition` per pass** — a single
   `reconcileMergedParks` pass observing N MERGED transitions on the same repo
   used to fire N full-repo sweeps; now coalesced to one per tick.

Five added tests pin the new contracts: null-mainCommitSha skip-comment path,
non-conflict rebase throw, manual-recovery marker gate. Existing tests updated
for the new `failed` field on the result shape.
…dParks

Second internal review pass caught a race in the call-site timer dedup:
`setTimeout(..., 0)` resets the dedup set as soon as the event loop services
macrotasks, which happens between `await` points inside the iteration loop —
so a pass with N MERGED rows on the same repo could still fire a second sweep.

Fix by moving the dedup to the natural per-pass boundary inside
`reconcileMergedParks` (a `Set<string>` constructed at the start of the loop).
The caller's only job is now to forward the hook; no timer involved.

- `poller.ts`: `reconcileMergedParks` tracks fired repos in `mergedRepos`;
  docstring on `onMergedTransition` updated to "at most once per repo per
  pass" with rationale.
- `main.ts`: dropped the call-site IIFE timer; `onMergedTransition` is a thin
  forwarder.
- `reconcile.test.ts`: existing tests updated to assert single-fire dedup
  (two MERGED rows, one repo → triggered.length === 1).
- `pr-divergence-integration.test.ts`: new test exercises the orchestrator's
  `failed` counter (a per-PR throw increments it, the pass continues for
  subsequent PRs) — was previously only pinned at 0 across green paths.
- `pr-divergence.ts`: tightened `applySuccess` docstring on the null-mainSha
  path: a later pass announces against whatever main it then reads, not
  necessarily the SHA this pass would have used.
…etric to rebase)

The rebase non-conflict throw is asserted; `gitOps.mergeOurs` has the same
hardening but no test pinned it. A regression that dropped the throw on the
merge twin would have slipped through. Mirror the rebase assertion against
a missing ref.
@thejustinwalsh thejustinwalsh marked this pull request as ready for review May 28, 2026 06:03
@thejustinwalsh thejustinwalsh added the ready-for-review All phases done and verified — PR ready for final human review and merge label May 28, 2026
@thejustinwalsh

Copy link
Copy Markdown
Owner Author

Reviewer's brief — PR #175 / Epic #168

PR #175 (Open-PR divergence reconciler) is marked ready for review.

How to run

bun run typecheck
bun run lint
bun run format
bun test

# Just the reconciler's tests
bun test packages/dispatcher/test/pr-divergence.test.ts \
         packages/dispatcher/test/pr-divergence-integration.test.ts \
         packages/dispatcher/test/reconcile.test.ts

bun test 870/870 pass; the three test files above contribute 41 new fixture-repo + stub-gateway tests.

Where to start

packages/dispatcher/src/reconcilers/pr-divergence.ts is the entire reconciler. Read top to bottom — the file is laid out in dispatch order matching the six sub-issues:

  1. Classifier (classifyDivergence, classifyMergeability) + migration 006_pr_divergence_state.sql.
  2. Rebase helper (tryRebaseOntoMain).
  3. -X ours merge fallback (tryMergeMainNewWorkAsBase).
  4. applySuccess (force-with-lease push + sha-keyed marker comment + state→CLEAN).
  5. applyDemoteToWork (draft + reopen last sub-issue + dual-surface escalation + Epic re-enqueue).
  6. reconcileOpenPRs orchestrator + ghReconcilerGateway + wiring in poller.ts / poller-cron.ts / main.ts.

What to verify

  • The chain shape — orchestrator's classify → rebase → merge fallback → demote at pr-divergence.ts (reconcileOpenPRs). Worth confirming the chain matches the spec body of Open-PR reconciler: rebase or demote stale PRs after a main merge #168.
  • The two idempotency contracts:
    • applySuccess: push only if local HEAD differs from origin/<branch>; comment only if the resolution+sha marker is absent. Null mainCommitSha skips only the comment.
    • applyDemoteToWork: short-circuits on PR.isDraft; gates reopenIssue on a prior Epic-level demote marker so a human's manual recovery isn't undone.
  • The poller hookreconcileMergedParks fires onMergedTransition at most once per repo per pass (per-pass Set inside the loop; an earlier caller-side timer raced against await boundaries).
  • Mergeabilitygh pr view 175 --json mergeable,mergeStateStatus reads {mergeable: MERGEABLE, mergeStateStatus: CLEAN}. Branch was rebased onto origin/main before marking ready.

Anything fragile that needs extra eyes

  • gitOps.rebase / mergeOurs non-conflict failures throw rather than returning the path-less conflict shape — internal review surfaced that the original empty-paths return was indistinguishable from a non-managed-PR skip and would have routed wrong. The orchestrator's failed counter now tracks these.
  • safeJsonParse swallows malformed gh stdout in getMergeability / getPullRequest. Justified by an observed gh failure mode (auth-warning interleaving) but worth a second pair of eyes — a permanent stdout corruption would now read as "PR doesn't exist" silently.
  • The fixture-repo tests use a symlink (aliasFixtureUnderRoot) to plant the worktree under the helper's expected <root>/<repo>/issue-<N>/ layout. existsSync follows the symlink and git -C follows it through. Works portably on macOS + Linux; haven't validated Windows (the daemon is Linux/macOS-only today).

Acceptance evidence

In the PR description's "Acceptance evidence" block. Each criterion carries a sub-issue reference + the relevant test file. Stakeholder-deferred items: none.

What's NOT here

Final review and merge are the human's call.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (1)
packages/dispatcher/src/db/migrations/006_pr_divergence_state.sql (1)

27-27: ⚡ Quick win

Index classified_at as well to support time-based reconciliation queries.

Right now only state is indexed. For recency/staleness scans, classified_at will force table scans as this grows. Add an index on classified_at (or a composite index matching expected query shape).

💡 Suggested migration tweak
 CREATE INDEX idx_pr_divergence_state ON pr_divergence_state(state);
+CREATE INDEX idx_pr_divergence_state_classified_at
+  ON pr_divergence_state(classified_at);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/dispatcher/src/db/migrations/006_pr_divergence_state.sql` at line
27, The migration currently creates only idx_pr_divergence_state on
pr_divergence_state(state) but misses an index on classified_at needed for
time-based reconciliation queries; update the migration to add an index on
pr_divergence_state(classified_at) or a composite index such as (state,
classified_at) depending on expected query predicates—modify the migration to
create idx_pr_divergence_state_classified_at ON
pr_divergence_state(classified_at) or
idx_pr_divergence_state_state_classified_at ON pr_divergence_state(state,
classified_at) to avoid table scans during recency/staleness scans.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/dispatcher/src/adapters.ts`:
- Around line 12-15: getAdapter currently indexes the plain-object REGISTRY
directly which allows inherited prototype keys (e.g. "toString") to be treated
as defined; change REGISTRY to an exact-key lookup (prefer using a Map<string,
AgentAdapter> or keep the object but use Object.hasOwn / Object.hasOwnProperty /
Object.hasOwn.call) and update getAdapter to first check for an own key and that
the retrieved value is an AgentAdapter, otherwise throw the unknown-adapter
error; reference the REGISTRY constant and the getAdapter function to locate
where to enforce the exact-key check.

In `@packages/dispatcher/src/main.ts`:
- Around line 348-350: The current check uses isKnownAdapter(adapter) which
allows implemented-but-disabled adapters to be dispatched; change the guard to
require the adapter is both implemented and enabled in daemon config (e.g.
replace the single isKnownAdapter(adapter) check with a combined predicate like
isKnownAdapter(adapter) && isAdapterEnabled(adapter) or create a helper
isConfiguredAndEnabledAdapter(adapter) that returns isKnownAdapter(adapter) &&
!!config.adapters?.[adapter]?.enabled), update the error message accordingly,
and apply the same fix at the other usage site referenced (the second check
around the other dispatch/control route).

In `@packages/dispatcher/src/poller-cron.ts`:
- Line 12: The poller cadence constant is set to 120_000 but the flow requires
60_000; update POLLER_INTERVAL_MS to 60_000 and add a new WATCHDOG_INTERVAL_MS
constant set to 30_000 so the watchdog and poller follow the required cadence
(this affects startPoller which is invoked without intervalMs and therefore
relies on POLLER_INTERVAL_MS). Ensure the new constants (POLLER_INTERVAL_MS and
WATCHDOG_INTERVAL_MS) are exported and documented near the top of
poller-cron.ts.

In `@packages/dispatcher/src/reconcilers/pr-divergence.ts`:
- Around line 901-913: getMergeability, getPrHeadRef and getPullRequest
currently treat any non-zero ghSpawn exitCode as "missing data" and return null,
which hides transport/auth/rate-limit failures; change each function
(getMergeability, getPrHeadRef, getPullRequest) to inspect ghSpawn's result and
only return null for explicit not-found/404 semantics, while throwing or
propagating an Error for transport/auth/rate-limit or unexpected failures
(include stderr/exitCode in the thrown Error for observability). Use the ghSpawn
result (r.exitCode, r.stderr, r.stdout) and safeJsonParse the stdout only when
exitCode === 0; for other non-zero codes map known CLI not-found cases to null
and otherwise raise an error so the orchestrator can surface and handle real
failures.
- Around line 594-597: The early return on pr.isDraft in the pr-divergence flow
(after calling deps.github.getPullRequest) prevents retries from completing
follow-up remediation (reopen/comment/enqueue/state-write) if a previous demote
only succeeded in converting to draft; remove the hard return and instead let
the code continue executing remediation steps while using existing idempotency
markers (the marker checks used for reopen/comment/enqueue/state-write) to avoid
duplicate side effects—i.e., replace the `if (pr.isDraft) return;` with a
conditional branch that skips only duplicate actions via the marker checks but
still runs the final state-write/enqueue/comment/reopen logic so retries can
finish remediation.

---

Nitpick comments:
In `@packages/dispatcher/src/db/migrations/006_pr_divergence_state.sql`:
- Line 27: The migration currently creates only idx_pr_divergence_state on
pr_divergence_state(state) but misses an index on classified_at needed for
time-based reconciliation queries; update the migration to add an index on
pr_divergence_state(classified_at) or a composite index such as (state,
classified_at) depending on expected query predicates—modify the migration to
create idx_pr_divergence_state_classified_at ON
pr_divergence_state(classified_at) or
idx_pr_divergence_state_state_classified_at ON pr_divergence_state(state,
classified_at) to avoid table scans during recency/staleness scans.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c26bc9e5-9dda-4f6d-984d-ddc57347fc13

📥 Commits

Reviewing files that changed from the base of the PR and between 10ce01d and 58c5355.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (34)
  • packages/adapters/codex/package.json
  • packages/adapters/codex/src/classify.ts
  • packages/adapters/codex/src/hooks.ts
  • packages/adapters/codex/src/index.ts
  • packages/adapters/codex/src/prompt.ts
  • packages/adapters/codex/src/transcript.ts
  • packages/adapters/codex/test/adapter.test.ts
  • packages/cli/src/commands/dispatch.ts
  • packages/cli/src/commands/docs.ts
  • packages/cli/src/commands/doctor.ts
  • packages/cli/src/index.ts
  • packages/cli/test/dispatch.test.ts
  • packages/core/src/index.ts
  • packages/core/src/select-adapter.ts
  • packages/core/test/select-adapter.test.ts
  • packages/dashboard/src/db-deps.ts
  • packages/dispatcher/package.json
  • packages/dispatcher/src/adapters.ts
  • packages/dispatcher/src/db/migrations/006_pr_divergence_state.sql
  • packages/dispatcher/src/documentation-run.ts
  • packages/dispatcher/src/main.ts
  • packages/dispatcher/src/poller-cron.ts
  • packages/dispatcher/src/poller.ts
  • packages/dispatcher/src/recommender-run.ts
  • packages/dispatcher/src/reconcilers/pr-divergence.ts
  • packages/dispatcher/test/adapter-conformance.test.ts
  • packages/dispatcher/test/db.test.ts
  • packages/dispatcher/test/documentation-run.test.ts
  • packages/dispatcher/test/pr-divergence-integration.test.ts
  • packages/dispatcher/test/pr-divergence.test.ts
  • packages/dispatcher/test/reconcile.test.ts
  • planning/issues/168/plan.md
  • planning/issues/60/decisions.md
  • planning/issues/60/plan.md

Comment thread packages/dispatcher/src/adapters.ts Outdated
Comment thread packages/dispatcher/src/main.ts Outdated
Comment thread packages/dispatcher/src/poller-cron.ts Outdated
Comment thread packages/dispatcher/src/reconcilers/pr-divergence.ts
Comment thread packages/dispatcher/src/reconcilers/pr-divergence.ts
A plain-object REGISTRY lets `getAdapter("toString")` resolve the inherited
`Object.prototype.toString` instead of throwing — `getAdapter`'s
`adapter === undefined` guard only catches truly-missing keys, not inherited
ones. Callers that validate names by calling `getAdapter` and treating thrown
errors as "unknown" (e.g. `resolveRecommenderOptions`) would let a non-
`AgentAdapter` value through.

Switch to `Map<string, AgentAdapter>` so lookups are exact-key by construction;
`knownAdapters`, `isKnownAdapter`, `getAdapter` all use the Map's API. Regression
tests parameterize over the suspect prototype keys (`toString`, `constructor`,
`hasOwnProperty`, `__proto__`).

Addresses CodeRabbit on PR #175.
… entry points

`isKnownAdapter` gates only on "implemented", so a disabled-but-implemented
adapter could slip through the daemon's manual-dispatch route, the recommender
validator, and the docs validator — all daemon-side entry points reachable
below the CLI's enabled-check (a direct `/control/dispatch` POST or a dashboard
call). Each surface needs the same gate.

- `dispatchEpicManual` + the hook-server's `/control/dispatch` route now share
  a single source of truth (`adapterRejectionReason` in main.ts), wired via
  `ControlPlane.adapterRejection: (name) => string | null`. The new contract
  collapses the gate + the diagnostic into one method, so both surfaces report
  "adapter codex is disabled in config" vs "unknown adapter: ghost" identically
  — previously the route always said "unknown" for both, which is misleading.
- `resolveRecommenderOptions` and `resolveDocumentationOptions` get the same
  enabled-check in line with the main.ts gate.

Tests:
- `main.test.ts` — daemon-spawn integration test exercises the disabled and
  unknown cases via real `/control/dispatch` POSTs, asserting both the 400
  status and the JSON body's distinct wording.
- `control-routes.test.ts` — unit test for the route's body validation pinning
  the same disabled-vs-unknown distinction.
- `recommender-run.test.ts` / `documentation-run.test.ts` — resolver-level
  coverage that a disabled adapter is refused with the matching message.

Addresses CodeRabbit on PR #175 (main.ts:348, main.ts:479); also resolves the
same class of bug one step over in the recommender + docs validators (surfaced
by the internal clean-eyes pass).
…contract

The dispatcher's CLAUDE.md pins the cron cadence at `WATCHDOG_INTERVAL_MS = 30s`
and `POLLER_INTERVAL_MS = 60s`, but the code literal had drifted to 120s and
silently doubled production cadence — the cron uses this constant when
`startPoller`'s `intervalMs` is omitted, which is the daemon's call shape.
Resyncing to 60s heals MERGED-transition divergence within one tick per Epic
#168's intent and keeps the doc + code aligned. Add a regression test pinning
the value so a future shift surfaces immediately.

Addresses CodeRabbit on PR #175.
…4 vs failure

Two related hardenings on the `pr-divergence` reconciler, plus a quick-win
migration index.

**applyDemoteToWork — partial-retry safe.**
The pre-existing `if (pr.isDraft) return` short-circuit meant a prior attempt
that crashed AFTER `convertPrToDraft` but BEFORE the reopen/comment/enqueue/
state-write steps could never finish remediation on retry — the next pass
would see `isDraft=true` and bail. Replace with per-step idempotency: skip
only the draft-flip step when already drafted; downstream remediation runs
to completion, gated by its existing markers (Epic comment marker, dual-
surface comment markers, the daemon's existing-workflow guard on enqueue).
Two tests: the renamed "per-step idempotency" test (re-enqueue fires every
pass — docstring contract), and a new "partial-retry: prior attempt left PR
drafted only" test that asserts every downstream step still runs.

**Gateway — distinguish "PR not found" from real failures.**
`getMainCommitSha` / `getMergeability` / `getPrHeadRef` / `getPullRequest`
collapsed every non-zero `gh` exit into `null`, hiding transport / auth /
rate-limit failures as benign UNKNOWN no-ops. Each now consults
`ghStderrIsNotFound` (exported for tests; matches only the two stable `gh`
not-found phrasings — `Could not resolve to a …` and `HTTP 404` — deliberately
narrow so a `secret not found, push declined` can't silently masquerade as a
404). Real failures throw with stderr in the message, propagating to the
orchestrator's per-PR `failed++` counter. `classifyDivergence`'s docstring
calls out the consequence: a transport failure leaves the state row at its
prior observation rather than overwriting with stale UNKNOWN.

**Migration nitpick — index `classified_at`.**
Recency / staleness scans on `pr_divergence_state` are its other query shape;
without an index they'd force a table scan as the table grows.

Addresses CodeRabbit on PR #175 (pr-divergence.ts:597, pr-divergence.ts:913,
006_pr_divergence_state.sql:27 nitpick).
Single-pass new-work-as-base merge of origin/main after rebase kept
re-conflicting on the same hunks across multiple commits (CLAUDE.md
escape hatch).

- packages/dispatcher/src/poller-cron.ts — unified `startPoller(deps,
  opts)` signature; folded `ReconcilerHooks` into `StartPollerOptions`
  as `opts.reconcilers` (alongside `opts.checkboxRevert` and
  `opts.intervalMs`).
- packages/dispatcher/src/main.ts — unified daemon-startup: keeps the
  durable engine + `recoverEngine` + `reconcileOrphanedSignals` from
  #160, the notification-failsafe watchdog comment from #162, and adds
  the `reconcileOpenPRsForRepo` block + `reconcilers` config in the
  `startPoller` call. Dropped the now-unused `Engine` import (main
  routes through `createDurableEngine`).
- packages/core/src/index.ts — kept both export blocks: integration
  rubric from #163, `selectAdapter` from this PR.
- packages/dispatcher/test/recommender-run.test.ts — kept both describe
  blocks (adapter-enabled gate from this PR, schema-resolution from
  #157); added `enabled: true` to the schema test's adapter config so
  it passes the new gate.
- packages/dispatcher/test/gates/checkbox-revert-pass.test.ts — added
  the five new `GitHubGateway` methods to the test stub
  (`listOpenIssues`, `addLabel`, `listMergedPrsClosingRefs`,
  `closeIssue`, `createIssue`) main grew during the marathon.

Gates re-verified locally: `bun run typecheck` clean, `bun test
packages/dispatcher` 620/620 pass, `bun run lint` clean, `bun run
format` clean (no changes).
@thejustinwalsh thejustinwalsh merged commit 3e78d89 into main May 29, 2026
1 check passed
@thejustinwalsh thejustinwalsh deleted the middle-issue-168 branch May 29, 2026 04:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-review All phases done and verified — PR ready for final human review and merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Open-PR reconciler: rebase or demote stale PRs after a main merge

1 participant