Skip to content

feat(dispatcher): invoke checkbox-revert reconciler on agent push#156

Merged
thejustinwalsh merged 12 commits into
mainfrom
middle-issue-101
May 28, 2026
Merged

feat(dispatcher): invoke checkbox-revert reconciler on agent push#156
thejustinwalsh merged 12 commits into
mainfrom
middle-issue-101

Conversation

@thejustinwalsh

@thejustinwalsh thejustinwalsh commented May 26, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #101

The checkbox-revert reconciler and the Phase 6 gate machinery were both fully
built and unit-tested, but nothing in production invoked them — the
"after a push, run the declared gates and revert a failing Status checkbox" loop
was wired in code and proven by verify.test.ts, yet no event fired it on a real
push. This PR closes that gap with a new pass on the GitHub poller cron.

What changed

  • packages/dispatcher/src/gates/checkbox-revert-pass.ts (new) — runCheckboxRevertPass: over each running implementation workflow, read the Epic PR; when its head SHA advanced since the last tick (the agent pushed), compose reconcileCheckboxes + makeRunPhaseGates to run sub-issue N's gates for each [ ] → [x] Status transition and revert + comment on failures. This is makeRunPhaseGates's first production consumer.
  • packages/dispatcher/src/workflow-record.ts — typed meta_json accessors (readWorkflowMeta / patchWorkflowMeta, refactoring getWorkflowSource onto them), the per-pass diff-base accessors (get/setCheckboxReconcileState), and listRunningImplementationWorkflows.
  • packages/dispatcher/src/github.tsPullRequest.headSha, mapped from gh's headRefOid in findEpicPr / getPullRequest.
  • packages/dispatcher/src/poller-cron.ts — run the new pass as a third guarded pass each tick (StartPollerOptions); main.ts wires it with the write-capable ghGitHub gateway + the free rate-limit read.

Why these changes

The reconciler is GitHub-state-driven (it reads and writes the PR body), so its
home is the GitHub poller — the same place as reconcileMergedParks, not a Stop
hook (the hook server deliberately runs no business logic, and running gates
inside a hook handler would block it). "Keyed on the Epic PR's head SHA advancing"
is a polling comparison across ticks. The pass lives in its own module so
poller.ts's "never writes to GitHub" invariant stays true. The per-workflow
diff base { headSha, checkboxState } persists in the existing meta_json scratch
column (precedent: source) — no migration. Full reasoning in
planning/issues/101/decisions.md, distilled into inline review comments on this PR.

Verification

  • bun run typecheck — clean.
  • bun test733 pass, 0 fail (81 files), including 16 new tests across test/gates/checkbox-revert-pass.test.ts (revert on fail, passing box stays, head-SHA gate skips/advances, undefined-SHA fall-through, no-config skip, rate-limit ceiling, per-workflow isolation, parked-workflow exclusion) and test/workflow-record.test.ts (meta merge preserves source, state round-trip, updated_at not bumped, running-workflow query filtering).
  • bun run lint / bun run format — clean.
  • Internal clean-eyes review loop (2 rounds): converged after fixing the updated_at/watchdog coupling below; second round returned a clean bill.

Acceptance criteria

  • A production event invokes reconcileCheckboxes after an agent push — done via runCheckboxRevertPass on the poller cron (checkbox-revert-pass.ts, wired in main.ts).
  • Keyed on the Epic PR's head SHA advancing — the head-SHA gate (checkbox-revert-pass.ts#L110), backed by PullRequest.headSha in github.ts.
  • runGates filled with makeRunPhaseGates + loadVerifyConfig(verifyConfigPath(...)) — see the deps composition.
  • Per-pass checkbox state persisted via getPreviousState/setPreviousState in the workflow record — meta_json accessors in workflow-record.ts#L118.

Status

Stumbling points

  • The updated_at/watchdog coupling. The first cut had patchWorkflowMeta bump updated_at (mirroring other writers). Internal review caught that the watchdog folds updated_at into its idle-freshness baseline (watchdog.ts:206), and both run over the same state='running' set — so persisting checkbox scratch would have reset a running agent's idle clock and masked a wedged agent. Fixed: meta writes never touch updated_at (it's scratch, not activity). Would have been avoided by a note on updated_at's consumers in the package CLAUDE.md.
  • Gates run in the agent's live worktree. This pass (like the existing verify-on-stop in build-deps.ts) runs gates with cwd = the agent's worktree, so an auto-fixing gate (oxlint --fix) could touch files the agent is mid-edit on. It fires when the PR head SHA advanced (just-pushed, typically quiescent), and it's the design the issue specifies — but it's a pre-existing class of concern shared with verify-on-stop, worth eyes.

Suggested CLAUDE.md updates

  • In packages/dispatcher/CLAUDE.md, under the watchdog section: note that updated_at is part of the idle-freshness baseline, so non-activity writes (scratch/meta_json) must not bump it.

Architectural forks

None — project conventions decided the trigger mechanism (no genuine ambiguity).

Follow-up issues

None. The scope was fully delivered; nothing genuinely parallelizable surfaced. The burst-cap-before-SHA-gate ordering matches the existing runPoller pattern (you can't know which workflow pushed without the findEpicPr call the cap bounds), and findEpicPr listing all open PRs per call is pre-existing.

Out of scope

The reconciler's checkbox detection/diff/revert mechanics (Phase 4) and the gate machinery (verify.ts / gate-runner / gate-evidence, #37) — consumed as-is. Durable persistence of parked executions across daemon restarts (#116).

Summary by CodeRabbit

  • New Features

    • Automated checkbox-revert pass added to the dispatcher poller to keep PR checkboxes in sync and avoid redundant work
    • PR head tracking to skip unchanged commits and persist per-workflow reconcile state
    • Poller now supports configurable cadence and an optional checkbox-revert pass; respects GitHub rate limits and isolates failing workflows
  • Tests

    • Comprehensive tests covering reconciliation, state persistence, rate-limit behavior, and failure isolation
  • Documentation

    • Added design/plan notes and updated poller cadence documentation

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 26, 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: 4ae67b9e-9371-4976-bd01-6522bda43b42

📥 Commits

Reviewing files that changed from the base of the PR and between 7f4ebd9 and 5e629c9.

📒 Files selected for processing (6)
  • packages/dispatcher/CLAUDE.md
  • packages/dispatcher/src/gates/checkbox-revert-pass.ts
  • packages/dispatcher/src/main.ts
  • packages/dispatcher/src/workflow-record.ts
  • packages/dispatcher/test/workflow-record.test.ts
  • planning/issues/101/decisions.md
✅ Files skipped from review due to trivial changes (1)
  • planning/issues/101/decisions.md

📝 Walkthrough

Walkthrough

This PR adds a checkbox-revert poller pass: typed workflow meta_json accessors for checkbox reconcile state, GitHub PullRequest.headSha support, a runCheckboxRevertPass orchestrator (rate-limit gated, per-workflow error-isolated), cron wiring to start the pass, comprehensive tests, and planning/decision docs.

Changes

Checkbox-revert poller pass integration

Layer / File(s) Summary
Workflow metadata and PullRequest data contracts
packages/dispatcher/src/workflow-record.ts, packages/dispatcher/src/github.ts, packages/dispatcher/test/workflow-record.test.ts
Adds CheckboxReconcileState/WorkflowMeta, safe readWorkflowMeta/patchWorkflowMeta, get/setCheckboxReconcileState, and exposes PullRequest.headSha from GitHub headRefOid. Tests validate meta read/merge/write semantics and sanitize nested shapes.
Running workflow queries and GitHub gateway updates
packages/dispatcher/src/workflow-record.ts, packages/dispatcher/src/github.ts
Adds listRunningImplementationWorkflows to enumerate running implementation workflows with epic/worktree info. findEpicPr and getPullRequest now fetch headRefOid and map it to PullRequest.headSha.
Checkbox-revert pass logic and dependencies
packages/dispatcher/src/gates/checkbox-revert-pass.ts
Adds runCheckboxRevertPass and CheckboxRevertPassDeps: selects running workflows (capped), checks REST remaining-rate-limit once per pass, loads per-worktree config (defaults to null on missing/malformed), fetches Epic PRs, short-circuits when headSha equals persisted state, composes gates via makeRunPhaseGates, calls reconcileCheckboxes with PR-body edit/comment accessors, persists reconcile state, isolates per-workflow failures, and returns total reverted count.
Poller cron configuration and wiring
packages/dispatcher/src/poller-cron.ts, packages/dispatcher/src/main.ts
startPoller now accepts StartPollerOptions with optional checkboxRevert and intervalMs; cron tick conditionally runs runCheckboxRevertPass after reconcile pass with its own try/catch. main.ts wires DB, GitHub gateway, and rate-limit accessor into the poller.
Checkbox-revert pass test coverage
packages/dispatcher/test/gates/checkbox-revert-pass.test.ts
Comprehensive Bun tests covering revert/persist on push, passing-case persistence, unchanged-SHA short-circuit, advanced-SHA reprocessing, undefined gateway SHA fallback behavior, verify.toml-missing skip, rate-limit ceiling enforcement, per-workflow isolation, and parked-workflow filtering.
Planning and architectural decisions
planning/issues/101/plan.md, planning/issues/101/decisions.md, packages/dispatcher/CLAUDE.md
Documentation for Issue #101 covering polling approach, meta_json persistence constraints, head-SHA gating, verify.toml skip behavior, poller cadence clarification, and meta sanitization policy.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • thejustinwalsh/middle#123: Overlaps poller interval/signature changes at startPoller where this PR also extends options for checkboxRevert.
  • thejustinwalsh/middle#99: Introduces makeRunPhaseGates and gate-runner plumbing that runCheckboxRevertPass composes with reconcileCheckboxes.
  • thejustinwalsh/middle#130: Adds ghPollGateway.getRateLimit() and poller rate-limit knobs reused by this pass to enforce REST budget ceilings.

Suggested labels

ready-for-review

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: invoking the checkbox-revert reconciler as a production trigger on agent push via a new poller pass.
Linked Issues check ✅ Passed The implementation fully addresses Issue #101 objectives: detects agent pushes via Epic PR headSha comparison, binds reconcileCheckboxes into poller-cron, persists per-pass state in meta_json, and keeps execution off hooks.
Out of Scope Changes check ✅ Passed All changes directly support Issue #101's checkbox-revert reconciler production trigger; no unrelated or out-of-scope modifications detected.
Docstring Coverage ✅ Passed Docstring coverage is 93.75% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


Comment @coderabbitai help to get the list of available commands and usage tips.

@thejustinwalsh thejustinwalsh left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Design rationale distilled from planning/issues/101/decisions.md — left as inline anchors so the reasoning sits next to the code it explains.

* transition and reverts a box whose gates fail. The per-workflow diff base
* (`{ headSha, checkboxState }`) is persisted in the workflow row's `meta_json`.
*
* Why a poller pass and not a Stop hook: the reconciler is GitHub-state-driven

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Why a poller pass, not a Stop hook (decision). The issue offered either; the codebase decides it. The reconciler is GitHub-state-driven (reads/writes the PR body) — the poller's domain, with reconcileMergedParks as the precedent for "a second reconcile pass over a different workflow set on the same cron." The package CLAUDE.md states the hook server runs no business logic; running gates (tens of seconds of lint/test) inside a hook HTTP handler would block the response and break that invariant. "Keyed on head SHA advancing" is a polling comparison across ticks, not an event. So no architectural fork — conventions pick the poller.

for (const wf of running.slice(0, maxPerPass)) {
try {
const config = loadConfig(wf.worktreePath);
if (!config) continue; // no gates to enforce → nothing to revert

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

No verify.toml → skip the workflow (decision). With no gates declared, a ticked box can never fail one, so there's nothing to revert and tracking state would be dead work. Mirrors verify-on-stop's "missing/malformed verify.toml → skip (ok)" in build-deps.ts.

// Head-SHA gate: skip the parse/diff/gate-run when the PR hasn't advanced
// since the last tick. A gateway that can't supply a SHA (undefined) falls
// through — the reconciler's own checkbox-state diff still gates the work.
if (pr.headSha !== undefined && pr.headSha === previous.headSha) continue;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Head-SHA gate skips the work, not the fetch (decision). The SHA must be fetched to be compared, so the gate saves the expensive work (lint/typecheck/test, parse, body writes), not the one cheap read. When the gateway can't supply a SHA (undefined), we fall through and let reconcileCheckboxes's own checkbox-state diff provide idempotence — only [ ] → [x] transitions run gates regardless.

* checkbox-revert persist reset a running agent's idle-timeout clock — masking a
* genuinely wedged agent (e.g. on first observation after a daemon restart).
*/
export function patchWorkflowMeta(db: Database, id: string, patch: Partial<WorkflowMeta>): void {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Persist in meta_json, and never bump updated_at (decisions). Storing { headSha, checkboxState } in the existing meta_json scratch column (precedent: source) avoids a migration; the merge-on-write preserves source. Critically, this write must not touch updated_at: the watchdog folds updated_at into its idle-freshness baseline (watchdog.ts:206), and both it and this pass run over the same state = 'running' set — bumping it here would reset a running agent's idle-timeout clock and mask a wedged agent (caught in internal review).

@thejustinwalsh thejustinwalsh marked this pull request as ready for review May 26, 2026 17:47
@thejustinwalsh thejustinwalsh added the ready-for-review All phases done and verified — PR ready for final human review and merge label May 26, 2026
@thejustinwalsh

Copy link
Copy Markdown
Owner Author

Reviewer's brief — PR #156 (Closes #101)

What this delivers: the production trigger that fires the checkbox-revert
reconciler on an agent push. Until now reconcileCheckboxes + makeRunPhaseGates
were test-only; this adds a third pass on the GitHub poller cron
(runCheckboxRevertPass) over running implementation workflows, keyed on the
Epic PR head SHA advancing.

How to run it

bun install
bun run typecheck      # clean
bun test               # 733 pass, 0 fail (81 files)
bun run lint           # clean

Targeted suites: bun test packages/dispatcher/test/gates/checkbox-revert-pass.test.ts packages/dispatcher/test/workflow-record.test.ts.

What to verify (and what "correct" looks like)

  • The trigger path (packages/dispatcher/src/gates/checkbox-revert-pass.ts):
    for each running impl workflow, fetch the Epic PR; if pr.headSha equals the
    persisted SHA, skip (no gates run); otherwise run gates per [ ] → [x]
    transition and revert failures. Correct = a failing gate flips the box back to
    [ ] and posts a notice naming the gate; a passing gate leaves it checked.
  • Persistence (workflow-record.ts): { headSha, checkboxState } lives in
    meta_json; patchWorkflowMeta merges (preserves source) and — load-bearing —
    does not touch updated_at, because the watchdog folds updated_at into its
    idle-freshness baseline (watchdog.ts:206) over the same running set. There's
    a regression test for this; if you change the meta accessors, keep that guard.
  • Gateway (github.ts): headRefOid → headSha mapping on findEpicPr and
    getPullRequest; headSha is optional and falls through to the reconciler's
    checkbox-state diff when a stub omits it.
  • Wiring (poller-cron.ts / main.ts): the pass is a third guarded pass each
    tick, isolated so one throw never aborts the resume/reconcile passes.

How to review

Read planning/issues/101/decisions.md first (the why), then the inline review
comments on the diff. Start at checkbox-revert-pass.ts, then the workflow-record.ts
accessors, then the one-call-site wiring in main.ts.

Fragile / needs extra eyes

  • The updated_at/watchdog coupling (see Stumbling points in the PR body) —
    the one non-obvious invariant.
  • Gates run in the agent's live worktree — shared with the existing
    verify-on-stop path; an auto-fixing gate could touch files mid-edit. Fires on a
    just-pushed (quiescent) PR; flagged as a pre-existing class of concern.

The branch is MERGEABLE/CLEAN. The workflow stops here — human reviews and merges.

@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: 3

🤖 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/gates/checkbox-revert-pass.ts`:
- Line 49: Add a TSDoc comment for the exported type CheckboxRevertPassDeps
describing its responsibility and the contract of its properties; locate the
exported type declaration named CheckboxRevertPassDeps in the file and insert a
brief /** ... */ TSDoc block above it that explains what the dependency object
contains and how consumers should use it (e.g., list and describe each
property/interface the type expects).

In `@packages/dispatcher/src/main.ts`:
- Around line 604-627: The poller call to startPoller is currently inheriting
the 120_000ms default; explicitly pass the documented cadence constants so the
daemon runs every 60s and watchdog at 30s: import/define WATCHDOG_INTERVAL_MS =
30_000 and POLLER_INTERVAL_MS = 60_000 and supply POLLER_INTERVAL_MS to
startPoller (e.g., as the explicit interval argument or option) when invoking
startPoller (the call that returns stopPoller and sets up checkboxRevert) so the
checkboxRevert trigger runs at the intended 60s cadence.

In `@packages/dispatcher/src/workflow-record.ts`:
- Around line 134-136: getCheckboxReconcileState currently trusts
readWorkflowMeta().checkboxReconcile but that nested value can be malformed;
update getCheckboxReconcileState to defensively validate the nested shape and
return the safe default when invalid. Specifically, after calling
readWorkflowMeta(db, id) check that checkboxReconcile is an object, that its
headSha is either string or null, and that state is a plain object (not
null/array/primitive); if any check fails return { headSha: null, state: {} }
(preserving CheckboxReconcileState contract). Use the function names
getCheckboxReconcileState and readWorkflowMeta and the type
CheckboxReconcileState to locate where to add these runtime guards.
🪄 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: 0e4e3c4e-8c22-4fc8-a49e-96acbfb164a3

📥 Commits

Reviewing files that changed from the base of the PR and between 720044c and 7f4ebd9.

📒 Files selected for processing (9)
  • packages/dispatcher/src/gates/checkbox-revert-pass.ts
  • packages/dispatcher/src/github.ts
  • packages/dispatcher/src/main.ts
  • packages/dispatcher/src/poller-cron.ts
  • packages/dispatcher/src/workflow-record.ts
  • packages/dispatcher/test/gates/checkbox-revert-pass.test.ts
  • packages/dispatcher/test/workflow-record.test.ts
  • planning/issues/101/decisions.md
  • planning/issues/101/plan.md

Comment thread packages/dispatcher/src/gates/checkbox-revert-pass.ts
Comment thread packages/dispatcher/src/main.ts
Comment thread packages/dispatcher/src/workflow-record.ts
getCheckboxReconcileState trusted readWorkflowMeta().checkboxReconcile, but
readWorkflowMeta only guards the top-level JSON shape — a malformed nested value
(version skew, a hand-edited row) could violate the CheckboxReconcileState return
contract at runtime. Sanitize it back to the contract (non-object/array ->
default, non-string headSha -> null, state rebuilt keeping only boolean entries),
matching the validate-don't-trust posture getWorkflowSource already uses.
The poller default was tuned 60s->120s in 8db12e8 for rate-limit safety, but the
package CLAUDE.md still claimed POLLER_INTERVAL_MS = 60_000 and main.ts's comment
said 'every 60s'. Correct both to the actual 120s default (injectable; the daemon
inherits it), with the rationale and commit reference. No behavior change.
@thejustinwalsh

Copy link
Copy Markdown
Owner Author

@coderabbitai resolve

All three findings from the prior review are addressed:

  • CheckboxRevertPassDeps TSDoc — 5624160
  • getCheckboxReconcileState nested-meta sanitization (+ tests) — aea637b
  • poller-cadence doc drift corrected to the intended 120s default — d2b60e3 (the suggested 60s bump was declined with rationale in-thread; that 60s was a stale guideline, not the intended cadence)

@coderabbitai

coderabbitai Bot commented May 26, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# List all open review threads on PR `#156` to find ones created by coderabbitai
gh api graphql -f query='
{
  repository(owner: "thejustinwalsh", name: "middle") {
    pullRequest(number: 156) {
      reviewThreads(first: 50) {
        nodes {
          id
          isResolved
          comments(first: 1) {
            nodes {
              author { login }
              body
            }
          }
        }
      }
    }
  }
}' | jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false and .comments.nodes[0].author.login == "coderabbitai") | {id, body: .comments.nodes[0].body[:100]}'

Length of output: 831


🏁 Script executed:

#!/bin/bash
# Resolve the open review thread
gh api graphql -f query='
mutation {
  resolveReviewThread(input: { threadId: "PRRT_kwDOSdBe186E4Uqa" }) {
    thread {
      id
      isResolved
    }
  }
}'

Length of output: 340



@thejustinwalsh: All review comments have been resolved. The one remaining open thread (poller-cadence) is now marked resolved — the 120s default with the documented rationale for declining the 60s suggestion is noted.

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.

Invoke the checkbox-revert reconciler on agent push (production trigger)

1 participant