The alert queue grew for three days and nothing reached the author.
A reviewer’s defer on the flush summary re-queued it forever. The alert flush kept re-deferring its own summary.
The alert flush fix
The fix landed in lib/alert.py (48 lines changed). A defer on the flush summary now sends it. Nested summaries are discarded.
Ghost rows from refused dispatches
The same commit fixed a second problem. estimate_gate runs on the same PreToolUse as our dispatch recorder, and it refuses some dispatches. A refused dispatch never gets a PostToolUse. The recorder had written a row for it, and that row said “running” forever. Three of those ghost rows were on one hub’s board that morning.
The recorder now asks whether the gate will refuse the dispatch before it writes anything:
Those are the gate’s own two predicates. The check mirrors the gate’s decision without its side effects: the gate records an estimate or emits a blocked event, and this hook must do neither. tests/test_agent_record_refused_dispatch.py (92 lines) pins the helper to estimate_gate.evaluate so the two cannot drift apart. The helper never raises. If the question can’t be answered, the dispatch is recorded as before. The gate already emits its own terminal pipeline event for the refused flight.
Two rows from one dispatch
One dispatch became two rows, the second with no label. The content hash alone can differ between the PreToolUse and PostToolUse firings. The PostToolUse side now joins the result to the row its own PreToolUse wrote, by the harness’s tool_use_id, when that row carries one:
agent_id = _id_for_tool_use(sid, tool_use_id) or agent_id
Where the unit gets created
Each dispatch is also recorded as a unit in its session, so a hub’s lanes show up in the CLI’s unit list with an outcome, not only on the board. That unit is created at PostToolUse, not PreToolUse. PostToolUse only fires for a dispatch that actually ran, so a call that any PreToolUse hook refused never leaves an orphan wip unit that would hold up pre-close. The new units/dispatch_capture.py is 179 lines, with 114 lines of tests in tests/test_units_dispatch_capture.py.
The whole commit touched 13 files, with 636 insertions and 6 deletions.
What the three days came down to
The flush re-deferred its own summary, and the queue grew for three days. defer on that summary now sends it, and nested summaries are discarded.
AI Skills
Use this lesson with the AI assistant you already use
An alert queue grew for three days and nothing reached the author, because the flush kept re-deferring its own summary. The same commit also fixed ghost rows that showed refused dispatches as running forever.
Paste the prompt, share only the context needed to answer it, and treat the result as a draft for your review. Do not include confidential information or let an AI assistant make changes without your approval.
Optional: for a visual report and saved memory, run /dxdev first.
Don’t have it? Get it at dxdev.com/skills/dxdev. The prompt works without it.
dxdev LESSON · paste into your AI coding agent
LESSON: A Summary Of A Queue Must Never Re-enter The Queue It Summarizes
SOURCE: dxdev.com/blog/alert-summary-deferred-itself-for-three-days
WHAT HAPPENED: A reviewer's defer on the flush summary put the summary back in the queue, so the flush re-deferred its own output on every pass and nothing was ever sent. The fix in lib/alert.py makes a defer on the flush summary send it, and nested summaries are discarded. The same commit found a related problem in the dispatch recorder. It wrote a row at PreToolUse, but estimate_gate sometimes refused the dispatch, so no PostToolUse ever arrived and the row said running forever, with three such rows on one hub's board. The recorder now checks the gate's own two predicates before writing, without the gate's side effects. A test pins that check to estimate_gate.evaluate so the two cannot drift apart. The unit for each dispatch is now created at PostToolUse, so a refused call never leaves an orphan wip unit.
THE RULE: Output that reports on a queue or a gate must not pass through the same deferral or refusal path it reports on. Record an action as in progress only after you know it was not refused, or create the record when the outcome event fires.
CHECK MY CODE, then report PASS or FAIL with file:line for each:
1. A defer decision on a flush summary results in the summary being sent, and a test covers that case.
2. A summary generated during a flush is discarded if it would itself be summarized, so a summary can never contain or re-queue another summary.
3. No record is written in a running state before the refusal check has run, and any helper that mirrors a gate's decision has a test tying it to the gate's own evaluate function.
THEN PRINT: a table (check, PASS/FAIL, evidence, fix) + a verdict (applies / partially / OUT_OF_SCOPE / no) + the single most important next action.