Skip to content

A late transient 5xx classifies a fully productive dev session as an environment fault, pausing the run instead of retrying #764

Description

@IvanLeontev-stack

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-startsession-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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions