Environment
- bmad-loop 0.11.1 (uv tool install), macOS 25.6.0 (Darwin), tmux 3.7c
- adapter:
claude, scm.isolation = "worktree", branch_per = "story"
limits.session_timeout_min = 90, max_dev_attempts = 2
The bug
env_fault classification is a conjunction of two signals — an env_fault_patterns hit in
the last ENV_FAULT_TAIL_BYTES (64 KiB) of the pane log, and a terminal status in
ENV_FAULT_STATUSES (adapters/env_fault.py:55,65,169). Neither signal says anything about
whether the session did work.
escalation.py:135 then states the assumption the PAUSE rests on:
if result.env_fault:
# A transport/API failure (the CLI never reached the API, #194): the
# attempt did no real work, so pause for a human instead of charging
# it — re-arm resets the budget ...
That holds when the outage kills the session early — #194's shape, where dev-2 idled out
with tokens=0. It does not hold when a genuine, transient provider error lands in the
final 64 KiB of a session that has been productive for its whole clock. The session is then
a real timeout wearing an env-fault costume: the story is not retried (the budget is
re-armed but nothing re-drives), the run PAUSEs, and it stays paused until a human returns.
Unattended, that inverts the feature's purpose: #194 traded a wrongly-charged attempt for a
pause; here the pause is spent on a session that should simply have been charged and retried,
and the cost is however long the operator is away.
Evidence
Run 20260903-202834-5a20, story 3-4a, adapter claude.
The session was productive for its entire clock.
| signal |
value |
session-start → session-end |
20:28:35 → 21:58:36 = exactly 90 min |
status |
timeout |
expired_clock |
both — wall and monotonic expired; the host never suspended |
tokens |
9,931,122 raw |
tokens_weighted |
1,280,747 |
| work produced |
22 files, 1296 insertions, 277 deletions, spec written with status: 'in-progress' |
expired_clock: "both" is worth singling out. The adapter already distinguishes "wall"
alone as the suspend signature (adapters/base.py:133); "both" is its opposite — the
monotonic clock also ran out, so the process was alive and scheduled for the full 90 minutes.
Combined with 1.28M weighted tokens, "the CLI never reached the API" is directly falsified by
data the journal already carries.
The pattern hit by 1,750 bytes.
The shipped claude.toml:59 pattern API Error: (5[2]9 Overloaded|5[0]0 Internal server error)
matched a real, transient 529.
| measurement |
bytes |
| pane log size |
3,175,355 |
first Overloaded occurrence |
offset 3,111,569 |
| distance from EOF |
63,786 |
ENV_FAULT_TAIL_BYTES |
65,536 |
| margin inside the window |
1,750 |
The first 97.99% of the log is clean, productive work. Had the session emitted 1,751 more
bytes after the error, the pattern would have fallen outside the tail window, the result
would have been an ordinary timeout, and decide_dev would have RETRIED it — which, on
this repo's history, is the path that lands the story (two stories in a prior run,
3-2 and 3-7, both timed out on dev-1 and completed on dev-2).
So the classification here turns on how much output happened to follow the error, not on
anything about the failure.
Cost of the false PAUSE. run-paused at 21:58:36; the operator returned at 06:07 the
next morning. 8 h 09 min idle, zero tokens spent, one story's worth of work parked. The
run had completed 0 stories at that point.
Expected vs actual
- Expected: a session that consumed measurable tokens and exhausted its monotonic clock
is a story timeout, whatever appears in its last 64 KiB. It should be charged an attempt
and retried while budget remains.
- Actual: a single late transient 5xx re-labels 90 productive minutes as "the attempt did
no real work" and pauses the run for a human.
Suggested direction
The missing signal is a veto, not a new pattern: measured-nonzero token usage should
disqualify the env_fault verdict (or at minimum the PAUSE branch in decide_dev), falling
back to the ordinary charge-and-retry path.
This needs exactly the machinery #611 is already scoped to build — the tri-state token
signal ("measured zero" / "measured nonzero" / "not measurable"), blocked today by
tokens.py's tally() collapsing "known zero" and "could not read the transcript" on the
claude-jsonl parser. #611 aims that signal one way (classify a zero-token timeout as
an env fault); this issue is the same signal aimed the other way (refuse the env-fault verdict
when tokens are measured nonzero). Both directions fall out of the same tri-state, and
"not measurable" preserves today's behaviour, so the veto adds no new false-negative surface.
expired_clock is a second, already-recorded corroborator: "both" means the session was
alive for its full budget, which is incompatible with "the CLI never reached the API".
A cheap interim guard, if the tri-state is too large: gate the PAUSE on
tokens_weighted in (None, 0). That is the mirror of the interim mitigation proposed in #194
itself ("a session that ends timeout with zero token usage is almost certainly an
environment fault"), and it would have classified this run correctly.
Related
Environment
claude,scm.isolation = "worktree",branch_per = "story"limits.session_timeout_min = 90,max_dev_attempts = 2The bug
env_faultclassification is a conjunction of two signals — anenv_fault_patternshit inthe last
ENV_FAULT_TAIL_BYTES(64 KiB) of the pane log, and a terminal status inENV_FAULT_STATUSES(adapters/env_fault.py:55,65,169). Neither signal says anything aboutwhether the session did work.
escalation.py:135then states the assumption the PAUSE rests on:That holds when the outage kills the session early — #194's shape, where
dev-2idled outwith
tokens=0. It does not hold when a genuine, transient provider error lands in thefinal 64 KiB of a session that has been productive for its whole clock. The session is then
a real timeout wearing an env-fault costume: the story is not retried (the budget is
re-armed but nothing re-drives), the run PAUSEs, and it stays paused until a human returns.
Unattended, that inverts the feature's purpose: #194 traded a wrongly-charged attempt for a
pause; here the pause is spent on a session that should simply have been charged and retried,
and the cost is however long the operator is away.
Evidence
Run
20260903-202834-5a20, story3-4a, adapterclaude.The session was productive for its entire clock.
session-start→session-endstatustimeoutexpired_clockboth— wall and monotonic expired; the host never suspendedtokenstokens_weightedstatus: 'in-progress'expired_clock: "both"is worth singling out. The adapter already distinguishes"wall"alone as the suspend signature (
adapters/base.py:133);"both"is its opposite — themonotonic clock also ran out, so the process was alive and scheduled for the full 90 minutes.
Combined with 1.28M weighted tokens, "the CLI never reached the API" is directly falsified by
data the journal already carries.
The pattern hit by 1,750 bytes.
The shipped
claude.toml:59patternAPI Error: (5[2]9 Overloaded|5[0]0 Internal server error)matched a real, transient 529.
OverloadedoccurrenceENV_FAULT_TAIL_BYTESThe first 97.99% of the log is clean, productive work. Had the session emitted 1,751 more
bytes after the error, the pattern would have fallen outside the tail window, the result
would have been an ordinary
timeout, anddecide_devwould have RETRIED it — which, onthis repo's history, is the path that lands the story (two stories in a prior run,
3-2and3-7, both timed out ondev-1and completed ondev-2).So the classification here turns on how much output happened to follow the error, not on
anything about the failure.
Cost of the false PAUSE.
run-pausedat 21:58:36; the operator returned at 06:07 thenext morning. 8 h 09 min idle, zero tokens spent, one story's worth of work parked. The
run had completed 0 stories at that point.
Expected vs actual
is a story timeout, whatever appears in its last 64 KiB. It should be charged an attempt
and retried while budget remains.
no real work" and pauses the run for a human.
Suggested direction
The missing signal is a veto, not a new pattern: measured-nonzero token usage should
disqualify the
env_faultverdict (or at minimum the PAUSE branch indecide_dev), fallingback to the ordinary charge-and-retry path.
This needs exactly the machinery #611 is already scoped to build — the tri-state token
signal ("measured zero" / "measured nonzero" / "not measurable"), blocked today by
tokens.py'stally()collapsing "known zero" and "could not read the transcript" on theclaude-jsonlparser. #611 aims that signal one way (classify a zero-token timeout asan env fault); this issue is the same signal aimed the other way (refuse the env-fault verdict
when tokens are measured nonzero). Both directions fall out of the same tri-state, and
"not measurable" preserves today's behaviour, so the veto adds no new false-negative surface.
expired_clockis a second, already-recorded corroborator:"both"means the session wasalive for its full budget, which is incompatible with "the CLI never reached the API".
A cheap interim guard, if the tri-state is too large: gate the PAUSE on
tokens_weighted in (None, 0). That is the mirror of the interim mitigation proposed in #194itself ("a session that ends
timeoutwith zero token usage is almost certainly anenvironment fault"), and it would have classified this run correctly.
Related
already floats token usage as the discriminator.
ordinary prose; here the error is real and the misclassification is about when it arrived.