Skip to content

fix(dispatcher): make dispatcher the sole writer of In-flight/Rate-limits/Slot-usage#186

Merged
thejustinwalsh merged 7 commits into
mainfrom
middle-issue-180
Jun 3, 2026
Merged

fix(dispatcher): make dispatcher the sole writer of In-flight/Rate-limits/Slot-usage#186
thejustinwalsh merged 7 commits into
mainfrom
middle-issue-180

Conversation

@thejustinwalsh

@thejustinwalsh thejustinwalsh commented May 29, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #180

The recurring malformed "In-flight" item parse failure that silently stalled auto-dispatch is fixed at its root. The dispatcher's in-place section seam (applyDispatcherSections) existed but was never called in production — so the three dispatcher-owned sections (In-flight / Rate limits / Slot usage) were authored only by the recommender agent, which reconstructed the In-flight line from prompt JSON that carries no heartbeat. The renderer needs a 5-field line (… · last heartbeat <rel> · …); the agent could only produce 4 fields → a malformed body every time, after which every auto-dispatch tick died with a parse error in stderr until a human noticed.

This wires the dispatcher as the sole writer of those three sections and makes parse failures self-announcing.

What changed

  • packages/dispatcher/src/workflow-record.tslistActiveImplementationWorkflows now returns lastHeartbeat (the canonical In-flight line needs it; the agent never had it).
  • packages/dispatcher/src/workflows/recommender.ts — new reapply-dispatcher-sections step overwrites the three owned sections with canonical content (dispatcherSectionsFromContext + heartbeatRel) right after the agent runs; the prompt now tells the agent to emit the In-flight empty placeholder and treat the context blocks as ranking input only.
  • packages/dispatcher/src/auto-dispatch.tscreateParseFailureSurfacer: comments a parse failure on the state issue, deduped per repo, re-armed after a healthy read.
  • packages/dispatcher/src/main.tsrunAutoDispatch surfaces parse failures through it instead of dying in stderr.

Why these changes

The schema names the dispatcher the owner of In-flight / Rate limits / Slot usage, but nothing enforced it — the agent was authoring them and had no heartbeat to render. Making the dispatcher reapply those sections from its own state (heartbeat included) after every recommender run closes the bug at the source; the agent emitting the empty placeholder removes the temptation to reconstruct a malformed line. Reapply is best-effort — if a disobedient agent leaves an unparseable body, verify-state-issue-parses stays the single surfacing point (no double comment). Relaxing the parser (issue option 2) was rejected as hiding the symptom.

Verification

All four mechanical gates pass on the rebased branch: bun run format, bun run lint, bun run typecheck (0 errors), bun test (1082 pass, 0 fail).

  • Phase 1 (heartbeat data layer): packages/dispatcher/test/workflow-record.test.tslistActiveImplementationWorkflows (#180) returns the touched epoch / null.
  • Phase 2 (dispatcher SoT): recommender-workflow.test.tsdispatcherSectionsFromContext mapping (null-issue dropped, null-session→pending), heartbeatRel formatting table, the 8-step order, and the self-heal test asserting the canonical 5-field line plus a renderStateIssue(parseStateIssue(body)) === body round-trip assertion.
  • Phase 3 (prompt): #44 build-prompt test asserts the agent is told DISPATCHER-OWNED / emit - _no agents in flight_ / decision INPUT.
  • Phase 4 (surfacing): auto-dispatch.test.tscreateParseFailureSurfacer (#180) dedup / reset / per-repo / retry-after-failed-comment / ignore-non-parse-errors.
  • Edge paths: reapply no-op skip and throwing-write→compensate (no dispatch, worktree rolled back).

Acceptance criteria

Stumbling points

  • The dispatcher's updateDispatcherSections / applyDispatcherSections seam looked wired but a grep showed it had zero production callers — the real gap. The heartbeat simply never flowed into a rendered body because listActiveImplementationWorkflows didn't carry it.
  • The branch base was 92 commits behind origin/main; rebased onto current main (rerere on) — two isolated conflicts (a test import list, and main.ts imports restructured by the Persist parked executions across daemon restart (durable bunqueue store) #116 durable-engine work), resolved new-work-as-base. Re-verified all gates after.

Suggested CLAUDE.md updates

None. The dispatcher CLAUDE.md's "ownership" framing already matches; this PR just makes the code honor it.

Follow-up issues

None. One nit surfaced (the In-flight progress field can carry non-running workflow states like launching/waiting-human, which deviate from the schema's documented closed set) — it parses and validates fine and showing the real state is arguably more useful, so it's left as-is rather than filed.

Out of scope

  • Full eager dispatcher In-flight updates on every workflow transition (the schema's "between recommender runs" mechanism). This fix makes the dispatcher authoritative at the recommender-run boundary, which closes the bug.

Summary by CodeRabbit

  • New Features

    • Parse failures during auto-dispatch are now surfaced to the repo state issue with per-repo deduplication and controlled re-arming.
    • Dispatcher-owned sections are re-applied after agent runs to ensure canonical content; heartbeat data is tracked and shown in status.
  • Tests

    • Expanded tests covering parse-failure surfacing, dedupe/reset behavior, reapply-overwrite flow, heartbeat handling, and related step-order behavior.
  • Documentation

    • Added planning/decision notes describing the reapply and surfacing approach.

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 29, 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: e2cb4b9f-9a50-4637-bcfc-4e474485f856

📥 Commits

Reviewing files that changed from the base of the PR and between 5582fbb and 534a494.

📒 Files selected for processing (3)
  • packages/dispatcher/src/auto-dispatch.ts
  • packages/dispatcher/src/main.ts
  • packages/dispatcher/test/auto-dispatch.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/dispatcher/src/auto-dispatch.ts
  • packages/dispatcher/test/auto-dispatch.test.ts

📝 Walkthrough

Walkthrough

This PR surfaces auto-dispatch parse failures (deduped) to state issues, threads workflow heartbeats into recommender context, and adds a post-agent reapply step that overwrites dispatcher-owned sections (In-flight, Rate limits, Slot usage) with canonical, heartbeat-inclusive content. Tests and planning docs accompany the changes.

Changes

Dispatcher Issue #180: Parse Failure Surfacing & Section Canonicalization

Layer / File(s) Summary
Parse Failure Surfacer: Abstraction & Auto-dispatch Wiring
packages/dispatcher/src/auto-dispatch.ts, packages/dispatcher/src/main.ts, packages/dispatcher/test/auto-dispatch.test.ts
Adds didReadState, ParseFailureSurfacer, and createParseFailureSurfacer(surfaceProblem); auto-dispatch wraps calls to surface deduped "does not parse" errors to a state-issue sink and conditionally resets dedupe on successful reads. Tests cover surfacer semantics and didReadState gating.
Heartbeat Propagation: DB → Recommender Context
packages/dispatcher/src/workflow-record.ts, packages/dispatcher/src/workflows/recommender.ts, packages/dispatcher/test/workflow-record.test.ts
Select last_heartbeat in listActiveImplementationWorkflows, extend ActiveImplementationWorkflow and InFlightSummary with lastHeartbeat, and thread heartbeats into recommender context; tests verify touchHeartbeat effects.
Dispatcher Section Canonicalization: Reapply Logic & Helpers
packages/dispatcher/src/workflows/recommender.ts, packages/dispatcher/src/recommender-run.ts
Add heartbeatRel and dispatcherSectionsFromContext; change RecommenderDeps.stateIssue to StateIssueGateway; insert reapplyDispatcherSections step that parses agent output, regenerates canonical dispatcher-owned sections from fresh context (including heartbeat formatting), and overwrites the state issue when parsed and changed.
Recommender Workflow Tests & Harness
packages/dispatcher/test/recommender-workflow.test.ts
Extend test harness to model three-phase readBody/writeBody interactions, capture dispatcher writes, update step-order and trace assertions, and add comprehensive tests validating canonicalization, heartbeat formatting, self-heal, no-op, and compensation behaviors.
Recommender Run Overrides
packages/dispatcher/src/recommender-run.ts, packages/dispatcher/test/recommender-run.test.ts
Change test override type from StateIssueReader to StateIssueGateway and add a writeBody no-op to the mocked stateIssue for tests.
Workflow Record Heartbeat Tests
packages/dispatcher/test/workflow-record.test.ts
New tests verify listActiveImplementationWorkflows returns per-epic lastHeartbeat (null if untouched, touched epoch when set).
Planning & Decision Documentation
planning/issues/180/plan.md, planning/issues/180/decisions.md
Add plan and decision notes describing canonical dispatcher reapply, heartbeat propagation, parse-failure surfacing with dedupe, and test coverage.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • thejustinwalsh/middle#105: Foundational PR touching the recommender workflow and parse/verify gating; this change extends that architecture with surfacing and post-agent reapply logic.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 68.42% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the primary change: making the dispatcher the sole writer of three dispatcher-owned sections (In-flight, Rate-limits, Slot-usage). It directly corresponds to the main objective of the pull request.
Linked Issues check ✅ Passed The PR addresses all coding requirements from #180: (1) dispatcher now writes In-flight/Rate-limits/Slot-usage via reapply-dispatcher-sections step using dispatcherSectionsFromContext with heartbeat data; (2) recommender prompt updated to emit empty placeholder; (3) parse failures surfaced on state issue via createParseFailureSurfacer with dedup logic; (4) round-trip render/parse verified by tests.
Out of Scope Changes check ✅ Passed All changes directly support the stated objectives: heartbeat tracking in workflow records, reapply-dispatcher-sections orchestration, parse-failure surfacing, prompt updates, and comprehensive test coverage. No unrelated modifications detected.

✏️ 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.

… agent stops authoring them

The recommender agent reconstructed the In-flight line from prompt JSON that
carries no heartbeat, producing the malformed 4-field line that silently broke
auto-dispatch (#180). Wire applyDispatcherSections as the sole writer: a new
reapply-dispatcher-sections step overwrites In-flight/Rate-limits/Slot-usage with
canonical content (heartbeat included) right after the agent runs, and the prompt
tells the agent to emit the In-flight empty placeholder instead of authoring it.
Reapply is best-effort; verify-state-issue-parses stays the single surfacing gate
for an unparseable agent body.
…h on the issue

The read-only auto-dispatch loop threw `… does not parse …` only to stderr, so a
malformed body silently stalled the auto-loop until a human noticed (#180). Add a
deduped ParseFailureSurfacer that comments the failure on the state issue once per
distinct message and re-arms after a healthy read; wire it into runAutoDispatch.
…omment; drop dead StateIssueReader

A failed gh comment must be retried next tick, not suppressed by a prematurely
recorded dedup entry. Also removes the now-unused StateIssueReader type (the
recommender dep is the full StateIssueGateway).
* comment). Re-gathers context here (not from build-prompt) so the In-flight
* snapshot reflects state *after* the agent's run.
*/
async function reapplyDispatcherSections(ctx: StepContext<RecommenderInput>): Promise<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.

Decision — the dispatcher's in-place section seam was never wired. grep showed applyDispatcherSections/updateDispatcherSections were defined and unit-tested but had zero production callers. So In-flight was authored only by the recommender agent, reconstructing the line from the in_flight prompt JSON — which carried no heartbeat (InFlightSummary and listActiveImplementationWorkflows both lacked one). The renderer requires last heartbeat <rel>, so the agent literally could not produce the canonical 5-field line. This step makes the dispatcher reapply its three owned sections from its own state right after the agent runs. Relaxing the parser (issue option 2) was rejected as hiding the symptom.

async function reapplyDispatcherSections(ctx: StepContext<RecommenderInput>): Promise<void> {
const before = await deps.stateIssue.readBody(ctx.input.repo, ctx.input.stateIssue);
const parsed = parseStateIssue(before);
if (isParseError(parsed)) {

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.

Decision — reapply is best-effort; verify-state-issue-parses is the single parse gate. A surgical section overwrite needs a parseable body. On the happy path the agent emits the canonical empty In-flight placeholder (per the new prompt), so this parses and overwrites. If a disobedient agent leaves an unparseable body, this step skips (logs) rather than surfacing, so the downstream verify step is the only place a parse failure is announced — no double comment on the state issue.

const problem = `⚠️ auto-dispatch halted: state issue #${stateIssue} does not parse, so the ranked dispatch plan can't be read and no Epics will dispatch until this is fixed.\n\n\`${error.message}\``;
if (lastSurfaced.get(repo) === problem) return false;
// Record only AFTER a successful comment — a failed `gh` comment (throws)
// must be retried next tick, not silently suppressed by a recorded dedup.

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.

Decision — record the dedup key only after a successful comment. A failed gh comment throws; if we recorded the key first, a transient failure would suppress the surface permanently (until a healthy read resets it). Recording after the await means the next tick retries. Self-review caught this; covered by the retry-after-failed-comment test in auto-dispatch.test.ts.

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

Copy link
Copy Markdown
Owner Author

Reviewer's brief — PR #186 (fixes #180)

What this fixes: the recurring malformed "In-flight" item parse failure that silently stalled auto-dispatch. Root cause: the dispatcher's applyDispatcherSections seam existed but had zero production callers, so the In-flight section was authored only by the recommender agent — which had no heartbeat to render the canonical 5-field line.

How to run it

bun install
bun run format && bun run lint && bun run typecheck   # all clean
bun test                                              # 1082 pass, 0 fail
# focused:
bun test packages/dispatcher/test/recommender-workflow.test.ts \
         packages/dispatcher/test/auto-dispatch.test.ts \
         packages/dispatcher/test/workflow-record.test.ts

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

  1. Dispatcher is the sole writer of the 3 owned sections. packages/dispatcher/src/workflows/recommender.ts — the new reapply-dispatcher-sections step (after spawn, before verify) overwrites In-flight / Rate limits / Slot usage via applyDispatcherSections, using dispatcherSectionsFromContext (heartbeat from listActiveImplementationWorkflows). Correct = the agent's content for those sections is always replaced by canonical, heartbeat-bearing output.
  2. The agent no longer authors them. assembleRecommenderPrompt tells the agent to emit - _no agents in flight_ and treat rate_limits/in_flight/slots as ranking input only. Verified by the #44 build-prompt test.
  3. Self-heal vs surface. self-heal test: agent emits the empty placeholder → dispatcher writes the canonical 5-field line (with a round-trip byte-identity assertion). exact bug shape test: a 4-field agent line is unparseable → reapply skips, verify-state-issue-parses surfaces it once.
  4. Auto-dispatch no longer dies silently. packages/dispatcher/src/auto-dispatch.ts createParseFailureSurfacer comments a parse failure on the state issue, deduped per repo, re-armed after a healthy read; wired in main.ts runAutoDispatch.

How to review

Start at the reapply-dispatcher-sections step and dispatcherSectionsFromContext (the InFlightSummary→InFlightItem mapping: null-issue dropped, null-session→pending, null-heartbeat→unknown). Then the prompt diff, then the surfacer + its main.ts wiring.

Fragile bits needing extra eyes

  • Reapply ordering / failure path: a throwing gatherContext/writeBody propagates and the prepare-shallow-worktree compensation rolls the worktree back, ending the run compensated (no dispatch). Covered by the throwing-write test.
  • Surfacer dedup: records the key only after a successful comment (a failed gh comment must retry, not be suppressed) and resets on a healthy read. Daemon-lifetime in-memory — a restart re-arms (one re-comment after restart if still broken).
  • Schema nit (left as-is, not filed): the In-flight progress field can carry non-running workflow states (launching, waiting-human) that deviate from the schema doc's closed set. It parses and validates; showing the real state is arguably more useful.

Branch is rebased onto current main (was 92 commits behind), MERGEABLE/CLEAN. Decisions are also posted as inline review comments.

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

🤖 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/main.ts`:
- Around line 341-342: The parse-failure dedupe reset is being called
unconditionally; move the parseFailureSurfacer.reset(repo) call so it only runs
after a successful state read instead of whenever autoDispatch returns
"disabled". Concretely, update the logic in the auto-dispatch branch that uses
autoDispatch(...) and the result object: only invoke
parseFailureSurfacer.reset(repo) after you have performed and confirmed a
healthy read (the code path that produces the successful `result`), not
before/when autoDispatch returns "disabled" — e.g., relocate the call into the
block that handles successful reads (the same place where you check
`result.enqueued.length` or otherwise confirm the read was healthy).
🪄 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: ff1795f6-7b0c-4812-9d13-d0b02106d84a

📥 Commits

Reviewing files that changed from the base of the PR and between 2124d02 and 5582fbb.

📒 Files selected for processing (11)
  • packages/dispatcher/src/auto-dispatch.ts
  • packages/dispatcher/src/main.ts
  • packages/dispatcher/src/recommender-run.ts
  • packages/dispatcher/src/workflow-record.ts
  • packages/dispatcher/src/workflows/recommender.ts
  • packages/dispatcher/test/auto-dispatch.test.ts
  • packages/dispatcher/test/recommender-run.test.ts
  • packages/dispatcher/test/recommender-workflow.test.ts
  • packages/dispatcher/test/workflow-record.test.ts
  • planning/issues/180/decisions.md
  • planning/issues/180/plan.md

Comment thread packages/dispatcher/src/main.ts Outdated
…state read

A "disabled" auto-dispatch pass returns before readState(), so it never
performs a healthy read; resetting the parse-failure dedup there would let an
unfixed parse failure re-surface a duplicate comment without an intervening
read. Gate the reset on a new didReadState() predicate that centralizes the
no-read contract (#180).
@thejustinwalsh thejustinwalsh merged commit a4d6688 into main Jun 3, 2026
1 check passed
@thejustinwalsh thejustinwalsh deleted the middle-issue-180 branch June 3, 2026 06:05
thejustinwalsh added a commit that referenced this pull request Jun 3, 2026
- migration filenames: 008→009 (workflows.epic_ref) to clear collision
  with #181's 007_retention.sql
- db.test.ts + db-scripts.test.ts: bump schema-version assertions to 9
  (final post-008+009 state)
- re-apply rename codemod to references introduced by #185/#186 after
  the original rename codemod landed
thejustinwalsh added a commit that referenced this pull request Jun 3, 2026
- migration filenames: 008→009 (workflows.epic_ref) to clear collision
  with #181's 007_retention.sql
- db.test.ts + db-scripts.test.ts: bump schema-version assertions to 9
  (final post-008+009 state)
- re-apply rename codemod to references introduced by #185/#186 after
  the original rename codemod landed
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.

fix(dispatcher): recurring "In-flight" parse failure blocks auto-dispatch (heartbeat field missing)

1 participant