fix(execution): SUB-003 post-switch retry gets the turn's remaining budget, not the 300s reader-race ceiling (#2789) - #2817
Conversation
…ng budget, not the reader-race 300s ceiling (#2789) A 3600s agent that took a 429 thirty seconds into a Workspace turn was switched to a healthy subscription and re-run with `timeout_seconds=300`: the agent server killed its own process group at exactly 300s with `stop_reason=tool_use` — actively working — and the turn was discarded after being billed. Re-sending the same message succeeded. Both inline retries read `_AUTO_RETRY_MAX_TIMEOUT_S`. That constant was written for #678's reader-race retry — a re-dispatch of a turn that never started, where "reader races fire fast; 5 min is plenty" is right. The SUB-003 retry (#792/#2638) is a full re-run of the user's turn and earns the budget the turn was given. `remaining_s` already bounds first attempt + retry at the operator's `execution_timeout_seconds`, so the second ceiling constrained nothing that was not already constrained and cost every turn honestly longer than five minutes. - SUB-003 retry: `min(remaining_s, 300)` -> `remaining_s`. The #678 path keeps its ceiling; the constant's docstring now names which path owns it and why the portal marker derivation depends on that. - `_AGENT_HTTP_SLACK_S` (10): the first dispatch gives the backend's HTTP read 10s more than the agent's own budget so the agent's structured 504 beats a bare ReadTimeout. The retry collapsed both onto one instant; it now keeps the same slack. - `_AttemptState.applied_timeout_seconds`: `state.start_time` is reset before an inline retry, so `_handle_timeout` measures the retry — it now judges that against the retry's own budget instead of the untouched configured one. A retry that ran its full allowance was being labelled #2106 NETWORK ("aborted after 300s of 3600 seconds allowed"), sending operators to raise a limit that was never reached. None on the first attempt, so #2106's own tests are unaffected. Set inside the #678 CB gate's `else`, not above it: a retry the breaker refuses never runs. - `_warn_if_retry_budget_clamped`: the applied budget appeared in no log line; a clamp is now stated at WARNING where it is decided, silent when nothing was taken. `test_792_subscription_retry.py::test_retry_timeout_bounded` asserted `<= _AUTO_RETRY_MAX_TIMEOUT_S` while running at `timeout_seconds=300`, where the ceiling and the remaining budget are the same number — it passed for every value of this bug. Re-anchored to the bound that is load-bearing. New tests in `test_2789_subswitch_retry_budget.py` are mutation-checked: reverting the budget fails 2, mutating the attribution wiring fails the end-to-end one. Fixes #2789 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
|
…nd is not a clamp (#2789) `_warn_if_retry_budget_clamped` warned on every seat switch: the re-run is always shorter than the turn by what the first attempt spent, so a 429 at 30s logged "clamped to 3570s of 3600s — a timeout at that point is this ceiling". There is no ceiling on that path any more; the 30s is arithmetic. The helper is now `_log_retry_budget` and takes the elapsed time: the #678 reader-race ceiling stays a WARNING ("clamped"), the SUB-003 re-run logs the breakdown at INFO, and escalates to WARNING only when less than the reader-race ceiling is left — a re-run that short is likely hopeless and is still billed, which is the one an operator wants to see. /review I1 on #2817. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
merge-train: not on this train — one criticalThe core one-retry fix is right, genuinely executed by tests (I confirmed by mutation: reverting it turns ❌ Removing the second ceiling breaks the wall-clock bound the PR claims to preserve
Measured against a controlled clock (attempt 1 = 3000s, reader-race retry = 5s,
One
The repo already has a named test for this path: Fix shape: carry a turn-level clock that is never reset ( Why CI is green: Warnings
🤖 Generated with Claude Code |
…ry-budget # Conflicts: # docs/memory/learnings.md
… not the last retry's (#2789) Merge-train review C1 on #2817: `state.start_time` is re-stamped before each inline retry so `_handle_timeout` classifies the attempt it measures — which made it the wrong clock for a BUDGET. On the #678→#792 interplay (502 → reader-race retry → 429 → switch) `elapsed_s` counted only the reader-race retry, and the SUB-003 retry re-granted nearly the whole turn a third time: 6610s of slot time on a 3600s cap, past the slot lease (timeout+300), the watchdog (age > timeout) and the portal marker. - `_AttemptState.turn_started_at`: the turn's clock, never reset; `_turn_elapsed_seconds` reads it. The SUB-003 budget is derived from it, and AFTER the 3s settle delay (W3), so every second the turn has spent counts against it. - `_log_retry_budget` keys "clamped" on a `ceiling=` the caller names, never on `elapsed_s <= 0` (W4). - `client_portal.portal_attempt_ceiling_seconds` imports `_AGENT_HTTP_SLACK_S` instead of a magic 10 (W2). - Tests: the tautological portal test is replaced by two that EXECUTE the interplay on a controlled clock — the SUB-003 grant at t=3005 on a 3600s turn is ≤605s (was 3605s), and the executed worst case (attempt 1 to cap, reader-race to ceiling, SUB-003 the remainder) fits the marker. The literal classifier test is replaced by one that drives `_handle_timeout` with a state carrying an applied budget (W1). Mutations: retry-clock budget → 2 red; classifier ignoring the applied budget → 2 red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
… not just the first (#2789) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
|
Addressed in C1 — the bound now holds on the interplay, measuredYou were right, and the diff's own 200-lines-later sentence is what should have stopped me. Your table, re-run on this head with a controlled clock (
Reverting the one line to the retry clock turns that test and the marker test red (3605s granted). The slot lease (3900s), the watchdog (3600s) and The tautologyGone. Warnings
Related suites: |
|
… a silent fallback (#2789) /review I1 on #2817: `turn_started_at` defaulted to None with an `or state.start_time` fallback in the reader — a future construction that names only `start_time` would have anchored the turn budget on the reset clock without anyone noticing. `__post_init__` derives it from `start_time` (the same instant at construction) and the reader no longer falls back to anything. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
…ords its mutation (#2829) Three of five ejections on the 2026-09-15 merge train — the third train running — were tests that prove the code was written rather than that it runs: source-text regexes over the module under test (#2811), a bound check at the one value where both bounds coincide (#2817), a docstring claim about CI never negative-controlled (#2805). All green. - docs/testing/STRATEGY.md: a new "Evidence bar for a test" section beside the harness bar — the three spellings, the two greps (the live-consumer grep is the one that decides), guard-vs-source-only with the train's own pair (#2819 kept, #2811 ejected, same shape), mutation as the fix standard, bound tests away from the coincidence — each with what enforces it. - .github/pull_request_template.md: a Testing checkbox for "every new test executes the changed path" and a `Mutation:` line naming the test(s) that go red with the fix reverted ("n/a — not a fix" otherwise). The trailing space after the colon matches the existing `Journey Impact:` line — a fill-in prompt. - docs/memory/learnings.md: the class, with the prior occurrences. The skill half — /review Step 2.5 and /validate-pr §5.4 answered first and in writing, /implement's two done-criteria — is trinity-dev#29. Fixes #2829 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
… not a literal 10 (#2817) — mechanical, per the merge-train note on the PR Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
|
merge-train: pushed to this branch — one commit, mechanical (the pre-validation |
… learnings.md keep-both, mechanical
|
merge-train: merged |
…ords its mutation (#2829) (#2833) Three of five ejections on the 2026-09-15 merge train — the third train running — were tests that prove the code was written rather than that it runs: source-text regexes over the module under test (#2811), a bound check at the one value where both bounds coincide (#2817), a docstring claim about CI never negative-controlled (#2805). All green. - docs/testing/STRATEGY.md: a new "Evidence bar for a test" section beside the harness bar — the three spellings, the two greps (the live-consumer grep is the one that decides), guard-vs-source-only with the train's own pair (#2819 kept, #2811 ejected, same shape), mutation as the fix standard, bound tests away from the coincidence — each with what enforces it. - .github/pull_request_template.md: a Testing checkbox for "every new test executes the changed path" and a `Mutation:` line naming the test(s) that go red with the fix reverted ("n/a — not a fix" otherwise). The trailing space after the colon matches the existing `Journey Impact:` line — a fill-in prompt. - docs/memory/learnings.md: the class, with the prior occurrences. The skill half — /review Step 2.5 and /validate-pr §5.4 answered first and in writing, /implement's two done-criteria — is trinity-dev#29. Fixes #2829 Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Eugene Vyborov <1073874+vybe@users.noreply.github.com>
Summary
A 3600s agent took a 429 ~30s into a Workspace turn, SUB-003 moved it to a healthy subscription, and the retry — a full re-run of the user's turn — was dispatched with
timeout_seconds=300. The agent server killed its own process group at exactly 300s withstop_reason=tool_use(actively working); the turn was discarded after being billed. Re-sending the same message succeeded.Both inline retries read
_AUTO_RETRY_MAX_TIMEOUT_S. That constant was written for #678's reader-race retry — a re-dispatch of a turn that never started, where "reader races fire fast; 5 min is plenty" is right. The SUB-003 retry (#792/#2638) re-runs the user's whole turn and earns the budget the turn was given.remaining_salready bounds first attempt + retry at the operator's ownexecution_timeout_seconds, so the second ceiling constrained nothing that wasn't already constrained and cost every turn honestly longer than five minutes. (The issue's alternativemin(remaining, max(300, timeout))is provably a no-op across the PUT-validated 60–7200 range — so the simple form is equivalent and one number fewer.)Changes
services/task_execution_service.pymin(remaining_s, 300)→remaining_s. The Async chat_with_agent: long execution silently fails with null response (reader-thread) #678 path keeps its ceiling; the constant's docstring now names which path owns it and whyclient_portal.portal_attempt_ceiling_seconds(which imports it) depends on that split._AGENT_HTTP_SLACK_S = 10: the first dispatch gives the backend HTTP read 10s more than the agent's own budget so the agent's structured 504 beats a bareReadTimeout. The retry collapsed both onto one instant — masked by the 300s clamp; with the clamp gone they'd land together. Preserved on the retry._AttemptState.applied_timeout_seconds→_handle_timeout:state.start_timeis reset before an inline retry, so_handle_timeoutmeasures the retry — it now judges it against the retry's own budget. A retry that ran its full allowance was labelled bug: abnormal task terminations are labelledTask execution timed out after N secondswhen the run never approached N #2106NETWORK("aborted after 300s of 3600 seconds allowed"), sending operators to raise a limit that was never reached. The issue's "Compounding" paragraph.Noneon the first attempt, sotest_2106is untouched. Set inside the Async chat_with_agent: long execution silently fails with null response (reader-thread) #678 CB gate'selse— a retry the breaker refuses never runs, and a budget claimed for it would misattribute the original response's terminal._warn_if_retry_budget_clamped: the applied budget appeared in no log line (the report had to reverse-engineer it from "300s of 3600"). Stated at WARNING where decided; silent when nothing was taken.client_portal/service.py— docstring only:portal_attempt_ceiling_secondsalready claimed "unlike the SUB-003 retry, which is capped to the remaining budget". Now true by construction; the note says so and what breaks if the two retries ever share a ceiling again. No marker-TTL change.Tests
test_792_subscription_retry.py::test_retry_timeout_bounded— had to change: it asserted≤ _AUTO_RETRY_MAX_TIMEOUT_Swhile running attimeout_seconds=300, where the ceiling and the remaining budget are the same number. It passed at every commit and could not have failed for any value of this bug. Re-anchored to the bound that is load-bearing.test_2789_subswitch_retry_budget.py(12 new) — 3600s agent gets ~3600s not 300; bounded by the configured cap across 60–7200; HTTP slack preserved on the retry; Async chat_with_agent: long execution silently fails with null response (reader-thread) #678 keeps its ceiling (negative control); portal ceiling still covers one worst attempt; attribution end-to-end (reader-race 502 → retry ReadTimeout on a 3600s turn names "300 seconds allowed", not 3600); clamp WARNING fires/stays quiet correctly.Docs —
architecture/backend.mdbullet,feature-flows/task-execution-service.mdSUB-003 section,learnings.md(a constant reused by a second caller carries its first caller's reasoning; a bound-check parameterised at the coincidence certifies whichever bound is wrong).Accepted residual
A retry whose first attempt already burned most of the budget still gets whatever is left (
max(1, …)), and a near-zero retry is billed. Skip-if-hopeless is a separate decision that belongs with #2638.Test Plan
cd tests && pytest unit/test_2789_subswitch_retry_budget.py unit/test_792_subscription_retry.py unit/test_2106_timeout_attribution.py unit/test_2133_bounded_reply_poll.py -v— 46 passedtests/unit/locally: 16162 passed / 25 failed — 15 are the::ffff:IPv4-mapped family (local Python 3.12 vs the pinned 3.13), 10 are all oftest_792_*failing under full-suite ordering via a leaked pre-dispatch-switch stub (#2638 pre-dispatch switch … before the first attemptin the captured log) — they pass in isolation and when run after this PR's own file. A/B againstorigin/devrunning; CI's base/head diff is the authority.execution_timeout_seconds=3600on an exhausted subscription + one healthy alternate, send a >5-min tool-heavy turn — expectSUB-003 switched … auto-retry 1/1and the retry to run past 300s.Fixes #2789
🤖 Generated with Claude Code
https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf