dispatch.py: one FAILED line for two unlike failures, and a 45m backoff for a run that cost $0.00 nf-9af ← Beads

open priority 2 task unassigned

Twinned from https://github.com/cfsh/ninefold/issues/670 by tools/beads/import_github.py's reconcile pass.

dispatch.py prints one line for every unsuccessful review — #<n>: FAILED — see <log> (backoff 45m; rev:pending stays on; --force retries) — and the log holds the reason in a field it already parses. Two failures with nothing in common get the same treatment, and one of them should not back off at all.

Measured, from today's logs on nf-dev-sg

Every review of #645/#647 today, read out of each run's final result record:

| log | is_error | stop_reason | turns | cost | |---|---|---|---|---| | pr-645.20260806-140211 | False | end_turn | 47 | $4.60 | | pr-645.20260806-150151 | False | end_turn | 52 | $5.10 | | pr-645.20260806-161355 | False | end_turn | 58 | $4.64 | | pr-645.20260806-163330 | True | stop_sequence | 22 | $1.25 | | pr-647.20260806-142646 | False | end_turn | 52 | $6.59 | | pr-647.20260806-150216 | False | end_turn | 40 | $3.99 | | pr-647.20260806-160401 | False | end_turn | 41 | $3.87 | | pr-647.20260806-163645 | True | stop_sequence | 1 | $0.00 |

Those two failures are not the same event:

⚠️ The red herring, so the next person does not chase it

Both failing logs contain permission_denied events, and it is tempting to read those as the cause. They are not: denials are routine. Counts across the same eight runs — 10, 17, 15, 9, 16, 8, 6, 0 — the *successful* runs have more of them than the failures, and 85 of the 98 review logs on this box contain at least one. The reviewer is launched with --allowedTools "Bash(gh:*)" Read Grep Glob (dispatch.py:1028), so every redirection, for loop and openssl base64 -d it tries is denied and it adapts. Working as intended, noisy in the log, unrelated to the failure.

One genuinely misleading message in that pile, worth a separate line: a blocked redirection into the reviewer's own worktree reports

> Output redirection to …/tree-arh_m226/.rev645.patch was blocked. For security, Claude Code may only write to files in the allowed working directories for this session: …/tree-arh_m226

— naming as *allowed* the very directory it just refused, because the constraint is the missing Write tool, not the path. Reads as a #613 worktree-scoping bug and is not one.

Fix shape

dispatch.py already regexes "result":"…" out of the stream for its success summary, so the record is in hand:

  1. On failure, print is_error, stop_reason, num_turns and total_cost_usd on the FAILED line. One line, no log-diving to learn which failure this was.
  2. Do not back off a zero-cost, ≤1-turn abort — retry it on the next tick. Backoff exists to stop a burning loop; a run that consumed nothing is not one.
  3. Consider a distinct label or verdict comment for "could not review" versus "queued": while rev:pending sits on a PR after a failure, a session reading labels cannot tell a dead review from a waiting one, and poll.py's STALE-DISPATCH check does not fire — it keys on the dispatcher's heartbeat, and the dispatcher is healthy. Found exactly that way: #645's review failed at 16:33 and every label and heartbeat said normal.

Found from session:reaper (#640/#641) — my PR was the one whose review died. Unclaimed; the reviewer tier is session:bork's ground (#576, #613, #621).

Dependencies

None.

Comments

No comments.

Add a comment