<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## Summary
- Introduces a closed, machine-readable empty-dispatch diagnosis (src/empty-dispatch-diagnosis.ts) that classifies *why* a dispatch tick fired nothing: no-ready-supply, lock-contended, open-pr-cap, daily-fire-cap, all-paused-or-vetoed, all-candidates-excluded, preflight-or-infra-failure, mixed, or indeterminate.
- The diagnosis is derived *exclusively* from evidence already on the terminal dispatch receipt (fired / fireFailures / selected / skipped / lockContended / refusedMainRed / errors) — never a new gate, never console-string parsing, never a re-poll.
- Stamped once and shared: dispatcher.ts puts it on DispatchReceipt.emptyDispatchDiagnosis; heartbeat.ts's deriveHeartbeatFromDispatchReceipt derives the *same* value onto the tick-outcome/heartbeat path (DispatchTickOutcome.emptyDispatchDiagnosis), so the receipt and the durable scheduler record can never disagree.
- Rendered as a stable one-line summary (EMPTY-DISPATCH: <kind> [reasons]) in the existing drones dispatch console output and the drones heartbeat log — no new artifact to open.
## Why It's Needed
Today an operator (or an unattended reader of runs/dispatch-tick-outcome-*.json) has to manually cross-reference skipped[], accounting.skipReasonHistogram, lockContended, and refusedMainRed to answer "did the queue genuinely starve, or did a safety control correctly block a fire?" Those are opposite operator responses (starvation needs more supply; a cap/veto is working as intended). This closes that gap with one durable, closed-vocabulary field instead of ad-hoc log reading.
## Changes
- src/empty-dispatch-diagnosis.ts (new) — EmptyDispatchDiagnosisKind (closed union + exhaustiveness check), EmptyDispatchDiagnosis (kind + capped/deduped contributingReasons: DispatchSkipReason[]), deriveEmptyDispatchDiagnosis (pure classifier with documented precedence: fired > healthy dry-run/plan > lock-contended > refusedMainRed > fireFailures > no-supply-vs-preflight-error > skip-reason family histogram), and formatEmptyDispatchDiagnosisLine. Only a type-only dependency on dispatcher.ts (DispatchSkipReason), so it does not join the existing dispatcher.ts ↔ dispatch-tick-outcome.ts runtime import cycle.
- src/dispatcher.ts — new optional DispatchReceipt.emptyDispatchDiagnosis field; computed once at both receipt-build call sites (the main fire path and the dedicated lock-contended path) from the same fields already going onto the receipt; an EMPTY-DISPATCH: … line is pushed onto the existing lines output alongside the existing [dispatch] receipt: … line.
- src/heartbeat.ts — deriveHeartbeatFromDispatchReceipt now also picks up receipt.selected and derives the diagnosis once, spreading it onto every return branch (never computed twice); BuildTickHeartbeatInput / buildTickHeartbeat carry the field through verbatim into DispatchTickOutcome; emitHeartbeatForExistingOutcome overlays a caller-supplied diagnosis onto an existing on-disk record or preserves the existing one when the caller omits it (same pattern as phases / killedInPhase).
- src/dispatch-tick-outcome.ts — new optional, validated DispatchTickOutcome.emptyDispatchDiagnosis field (schemaVersion stays 1 — additive); readDispatchTickOutcome rejects a malformed value but tolerates absence (older records read exactly as before).
- src/cli/heartbeat.ts — forwards derived.emptyDispatchDiagnosis into both the derive-and-overlay and derive-and-emit branches, and logs the rendered line.
- ARCHITECTURE.md — documents the new module (required by the repo's arch-drift test).
- docs/decisions/ — new entry recording the shared-derivation-module design call.
- Tests: src/empty-dispatch-diagnosis.test.ts (new, 35 pure unit cases covering every kind + precedence + hygiene), plus focused additions to src/dispatcher.test.ts (8 runDispatch-level integration cases), src/heartbeat.test.ts (13 cases), and src/dispatch-tick-outcome.test.ts (7 cases).
### Contract surface affected
- DispatchReceipt, DispatchTickOutcome: each gains one new optional field (emptyDispatchDiagnosis). No existing field's type or semantics changed; skipped[] and accounting.skipReasonHistogram are untouched. Every consumer that destructures/Picks these types without naming the new field is unaffected — verified by the full existing suite passing unchanged.
- deriveHeartbeatFromDispatchReceipt's Pick<DispatchReceipt, …> parameter type gained "selected". The one caller (cli/heartbeat.ts) already passes a full DispatchReceipt/DispatchReceiptRead, so no call site needed an update; a defensive Array.isArray guard keeps a partial/legacy test fixture from throwing.
## Breaking Changes
None. Both new fields are additive and optional; DispatchTickOutcome.schemaVersion stays 1. Selection, --max-fires/--max-open-prs/--max-fires-per-day/kill-switch/paused-label behavior, retry behavior, and Linear write-back are all unchanged (verified — no existing test needed a behavioral update, only the new field's presence/absence).
## Test Plan
- [x] npx vitest run src/empty-dispatch-diagnosis.test.ts → 35 passed
- [x] npx vitest run src/dispatcher.test.ts -t "AI-516" → 8 passed (no supply, WIP cap, daily cap, paused/vetoed, dep-blocked candidate exclusion, lock-contended vs. idle, a real fire has no diagnosis, a healthy dry-run plan has no diagnosis)
- [x] npx vitest run src/dispatcher.test.ts (full file) → 196 passed
- [x] npx vitest run src/heartbeat.test.ts (full file, incl. new AI-516 blocks: derive-from-receipt for every kind + precedence, buildTickHeartbeat passthrough, emitHeartbeatForExistingOutcome overlay/preserve) → 104 passed
- [x] npx vitest run src/dispatch-tick-outcome.test.ts (full file, incl. new round-trip / backward-compat / malformed-payload cases) → 99 passed
- [x] pnpm typecheck → clean, no errors
- [x] pnpm test (full vitest + Python suite) → 139 files / 4567 tests passed, 0 failed
## Verification Artifact
Three representative receipts from the new integration tests in src/dispatcher.test.ts, each produced by a real runDispatch() call (not a hand-built fixture):
1. Healthy empty queue ("no candidates at all → no-ready-supply" — zero specs, zero Linear tickets):
"emptyDispatchDiagnosis": { "kind": "no-ready-supply", "contributingReasons": [] }Console line: [dispatch] EMPTY-DISPATCH: no-ready-supply
2. Lock-contended tick ("lock-contended tick carries lock-contended, distinct from an idle no-ready-supply tick" — invocation lock held by another process):
"lockContended": true,"emptyDispatchDiagnosis": { "kind": "lock-contended", "contributingReasons": ["lock-contended"] }
Console line: [dispatch] EMPTY-DISPATCH: lock-contended [lock-contended]
(The same test also asserts the sibling idle tick — no lock held, no candidates — comes back no-ready-supply, proving the two never collapse into one bucket.)
3. Guard-blocked tick ("kill-switch halt → all-paused-or-vetoed" — kill-switch sentinel present, one otherwise-fireable candidate):
"runawayGuards": { "killSwitchActive": true, ... },"skipped": [{ "reason": "kill-switch", ... }],
"emptyDispatchDiagnosis": { "kind": "all-paused-or-vetoed", "contributingReasons": ["kill-switch"] }
Verbose test run (all three above, vitest --reporter=verbose -t "AI-516"):
✓ src/dispatcher.test.ts > AI-516 — empty-dispatch diagnosis on the dispatch receipt > no candidates at all → no-ready-supply✓ src/dispatcher.test.ts > AI-516 — empty-dispatch diagnosis on the dispatch receipt > kill-switch halt → all-paused-or-vetoed
✓ src/dispatcher.test.ts > AI-516 — empty-dispatch diagnosis on the dispatch receipt > max-open-prs cap halt → open-pr-cap (WIP cap)
✓ src/dispatcher.test.ts > AI-516 — empty-dispatch diagnosis on the dispatch receipt > max-fires-per-day cap halt → daily-fire-cap
✓ src/dispatcher.test.ts > AI-516 — empty-dispatch diagnosis on the dispatch receipt > every candidate blocked by an unsatisfied dependency → all-candidates-excluded
✓ src/dispatcher.test.ts > AI-516 — empty-dispatch diagnosis on the dispatch receipt > lock-contended tick carries lock-contended, distinct from an idle no-ready-supply tick
✓ src/dispatcher.test.ts > AI-516 — empty-dispatch diagnosis on the dispatch receipt > a successful fire carries NO empty-dispatch diagnosis
✓ src/dispatcher.test.ts > AI-516 — empty-dispatch diagnosis on the dispatch receipt > dry-run with a healthy fireable plan carries NO empty-dispatch diagnosis
Test Files 1 passed (1)
Tests 8 passed | 188 skipped (196)
## Impact Estimate
Business value: Operators can distinguish supply starvation from the safety controls correctly preventing a fire, making unattended capacity and queue health measurable without scraping orchestrator logs.
Pre-AI estimate: 2 points — trace terminal dispatch data through receipt and heartbeat shapes, preserve their precedence semantics, and build cross-module regression fixtures.
Closes AI-516
<!-- CURSOR_AGENT_PR_BODY_END -->
<div><a href="https://cursor.com/agents/bc-57dba8fe-e3c2-4467-b498-8366d757726f?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a> <a href="https://cursor.com/background-agent?bcId=bc-57dba8fe-e3c2-4467-b498-8366d757726f&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a> </div>