fix(pr-bot): fail fast when the review model's budget is spent

Claude Code answers an exhausted model budget INSIDE the turn ("You've
reached your Fable limit. Run /usage-credits to continue or switch models
with /model.") and then sits there with nothing to write. The reviewer never
produces a report, so `runTurn` waited out its full 40-minute deadline and
reported a bare "timed out after 40 min without a report", which reads as a
hung reviewer rather than an account that needs attention.

Measured on 2026-09-08: #388, #393, #394 and #377 each lost 40 minutes this
way, and because every attempt counted, all four reached MAX_AUTO_RETRIES and
would NOT have been picked up again once the budget returned. One spent
afternoon quietly took the whole queue out of service.

`findModelLimitNotice()` reads the notice off the pane and `runTurn` returns
a new `limit` outcome instead of waiting. It is consulted in exactly two
places, both of which mean "the turn produced nothing": on a stop where
`isDone()` is still false, and on each timed-out wait slice. A review that
merely discusses usage limits in its own findings therefore cannot be
mistaken for one that hit the wall, and the pattern matches neither the model
name nor a straight apostrophe, since the pane renders a typographic one and
every model prints the same sentence.

A spent budget is an account condition, not a bad PR, so it no longer spends
the per-head retry budget: the queue resumes by itself when the budget does.
Telegram now names the cause and the file to change.

Tests use the pane captured verbatim off the run that lost the 40 minutes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-08 19:27:43 +02:00
parent a164c07f92
commit 5130ca6633
5 changed files with 104 additions and 10 deletions
+33 -1
View File
@@ -20,7 +20,7 @@ import {
} from '../scripts/pr-bot/report.js';
import { classifyCi, latestRunPerWorkflow, type PrSummary, type WorkflowRun } from '../scripts/pr-bot/github.js';
import { parseCallback, parseCommand, prNumberFromMessageText } from '../scripts/pr-bot/telegram.js';
import { trustDialogKey } from '../scripts/pr-bot/codeman-client.js';
import { findModelLimitNotice, trustDialogKey } from '../scripts/pr-bot/codeman-client.js';
import { buildConfig, parseEnvFile } from '../scripts/pr-bot/config.js';
import { buildReviewBrief } from '../scripts/pr-bot/review-task.js';
@@ -302,6 +302,38 @@ describe('trustDialogKey', () => {
});
});
describe('findModelLimitNotice', () => {
// Captured off prbot-394's pane on 2026-09-08, the run that lost 40 minutes: Claude
// Code answers a spent budget inside the turn and then simply sits there.
const SPENT_PANE = [
'\x1b[38;5;153m\u276f\x1b[39m Read /home/arkon/.codeman/pr-bot/jobs/pr-394/brief.md and do the review.',
" \u23bf You've reached your Fable limit. Run /usage-credits to continue or switch models with /model.",
'\u273b Saut\u00e9ed for 1s \u00b7 done 7:16 PM',
].join('\n');
it('finds the notice on a real pane, ANSI and gutter glyph stripped', () => {
expect(findModelLimitNotice(SPENT_PANE)).toBe(
"You've reached your Fable limit. Run /usage-credits to continue or switch models with /model."
);
});
it('is not tied to one model name or to a straight apostrophe', () => {
// The pane renders a typographic apostrophe, and every model prints this sentence.
expect(
findModelLimitNotice(' \u23bf You\u2019ve reached your Opus limit. Run /usage-credits to continue.')
).toContain('reached your Opus limit');
expect(findModelLimitNotice('You have reached your Sonnet 5 limit.')).toContain('Sonnet 5');
});
it('says nothing about an ordinary working pane', () => {
expect(findModelLimitNotice('\u273b Actualizing\u2026 (13m 23s \u00b7 esc to interrupt)')).toBeUndefined();
expect(findModelLimitNotice('')).toBeUndefined();
// The bare word is not the notice: a review whose own findings discuss usage limits
// must not be reported as an exhausted account.
expect(findModelLimitNotice('the usage limit parser handles the 5-hour reset')).toBeUndefined();
});
});
describe('config', () => {
it('parses env files with quotes, comments and export prefixes', () => {
const env = parseEnvFile('# c\nexport A="x y"\nB=\'z\'\nC=plain\nbad line\n=nokey\n');