Skip to content

fix(tests): classify a model-turn 503 instead of skipping on it (#2889) - #2894

Merged
vybe merged 2 commits into
devfrom
fix/2889-503-skip-laundering
Sep 20, 2026
Merged

vybe merged 2 commits into
devfrom
fix/2889-503-skip-laundering

Conversation

@dolho

@dolho dolho commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Summary

  • A 503 from POST /chat / /task was pytest.skipped as "Agent server not ready" at ~50 live-test sites, so an exhausted credit balance (the turn ran and failed on the credential) read as a benign readiness skip while the run stayed green.
  • Backend: an additive X-Trinity-Error-Code header on every sync /chat|/task failure the backend classifies (auth / billing / network / agent_error / capacity / timeout). /task threads the TaskExecutionResult.error_code it already computed and dropped at _map_task_failure; /chat derives it from agent_status_code with the same producer-side rule task_execution_service applies. Bodies are byte-identical — nothing parsing them breaks. No new substring classifier in the test tree (the bug: agent container OOM cascades into backend worker saturation and freezes admin UI #904 drift class).
  • Tests: one classifier in tests/testkit/readiness.py — require_agent_answer reads the header first, falls back to transport vocabulary pinned to the backend source, and fails on anything the agent answered; only network skips, naming its evidence (still unallowlisted, so run-full.sh's audit keeps flagging it). A requires_model marker opts a test into the session model_provider_preflight fixture (claude_auth_configured flag, then one real /task probe on TEST_AGENT_NAME) so a stack whose credential cannot execute fails once with one cause; a credential-class verdict seen mid-session fails later model turns at setup instead of spending a 120 s call each (an unknown/agent_error 503 fails only its own test — no cascade from one OOM). 53 sites converted across the six files in the issue plus test_fan_out.py and test_dynamic_thinking_status.py.

Changes

  • src/backend/services/chat_execution_service.py — ERROR_CODE_HEADER, _error_code_headers, _classify_agent_http_failure; header threaded through _apply_sub003_autoswitch + _map_task_failure
  • tests/testing_utils/readiness.py (via the testkit symlink) — classifier + require_agent_answer + session verdict
  • tests/conftest.py — requires_model → model_provider_preflight injection, pytest_runtest_setup fast-fail; pyproject.toml marker
  • 8 live-test files — 53 skip sites → require_agent_answer(...) + @pytest.mark.requires_model
  • tests/unit/test_2889_readiness_classifier.py — classification table, fail/skip behaviour, backend-source contract for the transport vocabulary, AST guard (no bare 503 skip after a model-turn POST; every helper site is marked)
  • tests/unit/test_2889_error_code_header.py — helper + both backend paths
  • Docs: architecture/execution.md, tests/README.md, docs/testing/STRATEGY.md, a2a flow error table, learnings.md, CSO diff report

Acceptance criteria

  • Credit/billing/subscription-exhaustion 503 FAILS (require_agent_answer → pytest.fail, code from the header; unknown ⇒ fail too)
  • Genuine readiness race still skips, reason states the verdict and evidence (network / transport vocabulary → "agent server still starting — … evidence: …")
  • Skip reasons distinguish the two causes (readiness skip vs "RAN and failed (code=auth)")
  • Session-level preflight fails fast with one message (model_provider_preflight, cached session-fixture error + pytest_runtest_setup)
  • Policy lives in one place (tests/testkit/readiness.py), guarded by AST

Test Plan

  • cd tests && pytest unit/test_2889_readiness_classifier.py unit/test_2889_error_code_header.py -v — 63 passed; AST guard mutation-checked (reverting one site turns it red)
  • Targeted unit subset (173 files): 4278 passed; 5 failures in test_736_a2a_outbound_edges C1b are pre-existing on a clean tree (a2a untouched)
  • lint_sys_modules.py / lint_root_test_placement.py — no new violations
  • --setup-plan shows model_provider_preflight injected into requires_model items
  • Live: test_agent_chat.py::test_send_message_returns_response passes against a local stack (preflight ran)
  • Header end-to-end on a rebuilt backend (unit-covered on both paths; needs /verify-local or a deploy)

Out of scope: ~170 "Agent server not ready" skips on non-model proxies (files/git/dashboard) — a 503 there is the agent-server hop, never the provider.

Fixes #2889

🤖 Generated with Claude Code

A 503 from POST /chat or /task was skipped as "Agent server not ready" at
~50 live-test sites, so an exhausted credit balance — the agent ran the turn
and failed on the credential — read as a benign readiness skip while the run
stayed green. The two causes shared one reason string because the sync 503
body is prose reconstructed from the agent's error text; the backend already
computed TaskExecutionErrorCode and dropped it at _map_task_failure.

Backend: an ADDITIVE X-Trinity-Error-Code header on every sync /chat|/task
failure the backend classifies (auth / billing / network / agent_error /
capacity / timeout). /task threads TaskExecutionResult.error_code; /chat
derives it from agent_status_code with the same producer-side rule
task_execution_service applies. Bodies are byte-identical to before.

Tests: tests/testkit/readiness.py holds the one classifier —
require_agent_answer reads the header first, falls back to transport
vocabulary pinned to the backend source, and FAILS on anything the agent
answered; only `network` skips, naming its evidence. A `requires_model`
marker opts a test into the session model_provider_preflight fixture
(claude_auth_configured flag, then one real /task probe on TEST_AGENT_NAME),
so a stack whose credential cannot execute fails once with one cause; a
credential-class verdict seen mid-session fails later model turns at setup
instead of spending a 120 s call each. 53 sites converted across the six
files in the issue plus test_fan_out.py and test_dynamic_thinking_status.py.
An AST guard fails CI on a bare `if status == 503: pytest.skip` after a
model-turn POST.

Fixes #2889

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

⚠️ Nightly unit-suite found regressions when this PR is merged into dev (1 of 3 seeds).

Regression details (head_sha: `340a818bf8ec64abbbf9b0f9d00d435a565f1566`)

Seed 12345

Backend unit-suite regression diff

Per-XML totals

Side Path Total Pass Fail Error Skip
base junit-base-pr2894-12345.xml 16663 16629 2 1 31
head junit-head-pr2894-12345.xml 16726 16692 2 1 31

❌ New failures introduced by HEAD (2)

Tests failing under HEAD that did not fail under BASE in any seed:

  • [E] test_subprocess_pgroup.TestTerminateProcessGroup::test_kills_parent_and_grandchild
  • [F] test_subprocess_pgroup.TestTerminateProcessGroup::test_kills_parent_and_grandchild

🟢 Failures fixed by HEAD (2)

  • [E] test_subprocess_pgroup.TestDrainReaderThreads::test_buffered_data_preserved_after_grandchild_kill
  • [F] test_subprocess_pgroup.TestDrainReaderThreads::test_buffered_data_preserved_after_grandchild_kill

Legend: [F] = assertion failure, [E] = collection or fixture error.
Identity = (classname, name, kind); union taken across all input XMLs.

Reproduce locally: git merge dev && ( cd tests && python -m pytest unit/ -m "not slow" -p randomly --randomly-seed=12345 )

…dering

# Conflicts:
#	docs/memory/learnings.md
@vybe

vybe commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

merge-train: dev merged into this branch to resolve the conflict (345717c34). No other change was pushed.

docs/memory/learnings.md was the only conflicting path — the routine append collision. Resolved by keeping both sides: this branch's 2026-09-18 entry, then dev's 2026-09-19 (ent#653 execution search) and 2026-09-20 (#2917 per-tab measurement) entries, in that order at the tail. Verified no markers survive anywhere in the tree.

Validation came back READY with no critical findings. Worth recording what was checked, since this PR's whole subject is whether a test really runs:

  • I mutated rather than read. Replacing all 8 headers=code_headers with headers=None in chat_execution_service.py → 8 failed. Reverting one call site to if status_code == 503: pytest.skip(...) → the AST guard went red, naming tests/test_executions.py:95.
  • test_2889_error_code_header.py has zero source-text assertions — it calls the real _map_task_failure and _apply_sub003_autoswitch and asserts on the raised ChatDispatchError.headers. The 6 read_text hits in test_2889_readiness_classifier.py are all confined to its labelled drift-pin and AST-guard sections; section 1 drives classify_unavailable / require_agent_answer over 17 real 503-shaped responses.
  • The skip path is genuinely gone, not re-worded: all 53 sites are physically deleted across 8 files, and git diff | grep '^-[^-]' over tests/ shows no deletions that aren't the conversions, so no existing guard was weakened.
  • I separately confirmed the one mechanism the tests don't cover — that appending to item.fixturenames in pytest_collection_modifyitems still reaches a session fixture on pytest 9.0.2 — with a minimal repro. It does.

I've filed #2919 for the gap this doesn't reach: 18 bare if status_code == 429: pytest.skip("Agent queue full") sites remain, and per #2638 an exhausted Claude subscription surfaces as 429, not 503. This PR now emits X-Trinity-Error-Code: billing on exactly those responses, but require_agent_answer no-ops on non-503, so that case is still laundered — and the new AST guard only inspects == 503, so a future 429 site won't be caught either. That issue also notes capacity/timeout currently have no live consumer, which is the other half of the same gap, and raises whether a per-agent assigned-subscription failure should cascade fleet-wide via _SESSION_PROVIDER_FAILURE.

Merging once the required checks come back green on this push.

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

merge-train: validated 2026-09-20. Coverage mutation-proven on both halves (headers=None → 8 red; reverting one skip site → AST guard red). dev merged for the learnings.md append collision, keeping both sides. One regression diff red was a timing flake in test_subprocess_pgroup (unrelated file, 1 of 3 seeds); a full re-run with regenerated artifacts is green. Follow-up for the 429 twin filed as #2919.

@vybe
vybe merged commit 0c470c7 into dev Sep 20, 2026
44 of 46 checks passed
vybe pushed a commit that referenced this pull request Sep 23, 2026
…skips; the admission 429s say "capacity" (#2919) (#2977)

* test(readiness): red — a billing 429 must fail and a capacity 429 must skip (#2919)

Replaces test_non_503_is_a_no_op (which pinned 429 as a no-op) with the
three consumer cases the 429 arm has to satisfy. Red for the behaviour:
DID NOT RAISE Failed / Skipped, 4 failed, 39 passed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(readiness): classify a model-turn 429 — billing fails, capacity skips, unknown fails (#2919)

The 429 arm in require_agent_answer, mirroring #2894 one status code
over: header first (billing/auth → fail + session cascade; capacity →
skip with evidence, never recorded), X-Circuit-Open second, then on a 429
only the source-pinned capacity wording (transport vocabulary is not
consulted), else unknown ⇒ FAIL. Verdict gains a trailing-default
status; the reason builders name it; capacity_skip_reason is new and
stays off the skip-audit allowlist.

Gate: 63 → 89 passed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(chat): the three admission-refusal 429s carry X-Trinity-Error-Code: capacity (#2919)

TaskExecutionErrorCode.CAPACITY had zero assignment sites, so a queue-full
429 was recognisable only by the absence of a header plus its prose, while
execution.md and the A2A feature flow already listed `capacity` as emitted.
Three additive headers= kwargs, bodies byte-identical:

- routers/chat.py: the /chat CapacityFull admission 429 (through
  chat_execution_service._error_code_headers, the one emitter)
- _dispatch_async: the CapacityFull (capacity + backlog full) 429
- _map_task_failure: the at-capacity branch decides the status by the
  result text, so the code it sends now agrees (capacity overrides any
  result code; none exists on that branch today)

task_execution_service.py untouched.

Red (backend reverted from scratch copy): 3 failed, 24 passed —
  assert None == {'X-Trinity-Error-Code': 'capacity'} ×3
Green: test_2889_readiness_classifier + test_2889_error_code_header
  92 passed; characterization suites (chat_admission, 946, 2661, 1483)
  48 passed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(readiness): the AST guard covers == 429; delete the 18 dead 429 skips (#2919)

_is_503_check → _is_laundered_status_check, keyed on
readiness.LAUNDERED_STATUSES (one home for the vocabulary): `==`, `in`
list/tuple/set, and an `or` of such checks. Known escapes are written
down in its docstring (same limits #2894 accepted). Adds the matcher
table and a walk meta-test pinning the four site files inside the guard.

Guard widened BEFORE deleting — red listed exactly 18 offenders:
  test_dynamic_thinking_status.py:203,230,268,334,425,507
  test_parallel_task.py:569,630,664,719,794,839
  test_agent_chat.py:42,67,432,469,505
  agent_server/test_agent_chat_direct.py:35
Each followed require_agent_answer(...), which now skips a capacity 429
and fails a billing/unknown one, so every branch was dead. Deleted; the
downstream assert_status(…, 200) is unchanged. Mutation check: restoring
test_agent_chat_direct.py's site → guard red with exactly 1 offender.

Gate: 104 passed. The 18 integration sites were not run live (Docker
down); the four files collect (98 tests).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs: a model turn's 429 is classified too; capacity is now emitted (#2919)

- execution.md (header contract's owning area file): the three
  admission-refusal emitters of `capacity`, and the 429 arm of
  require_agent_answer; the value list there is now true.
- agent-to-agent-collaboration.md: a queue-full 429 carries `capacity`.
- STRATEGY.md / tests/README.md: "503 or 429" — capacity skips
  (unallowlisted), billing fails and arms the session fast-fail, the guard
  covers `== 429`.

Requirements delta: none (no area covers the test harness or the header).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs(feature-flows): the queue-full 429 carries X-Trinity-Error-Code: capacity (#2919)

/sync-feature-flows over origin/dev..HEAD: routers/chat.py and
chat_execution_service.py map to execution-queue.md (the 429 body it
documents) and agent-to-agent-collaboration.md (already updated). One
sentence; no new flow, no index change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(readiness): a fail-expecting case must go red on a skip, not report skipped (#2919)

`Skipped` is not a `Failed`, so `pytest.raises(pytest.fail.Exception)` lets a
wrongly-skipping helper propagate its skip: mutating require_agent_answer to
"skip every 429" (the defect this issue removes) left the unit file at
71 passed / 6 skipped / 0 failed. Route every fail-expecting case through
`_must_fail()`, which turns a skip into an AssertionError. Same mutation now:
6 failed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(readiness): keep the laundering guard operator-agnostic, as #2894's was (#2919)

`_is_503_check` matched any comparison against 503; the widened matcher
accepted only `==`/`in`, silently unguarding `>= 503`, `!= 503` and
`not in [503]` on the 503 half. Match the constant regardless of operator
(a strict superset of both), pin `>= 429` / `!= 503` / `not in [503]` in the
shape table, and correct the "known escapes" list. Mutation: re-adding a
`status_code >= 429: pytest.skip` site turns the guard red naming it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(chat): `capacity` fills an absent task code, never overrides the producer's (#2919)

`_map_task_failure` picks its 429 by substring ("at capacity") on the result
text and was stamping `capacity` over any code the result carried. That code
is the producer's structured verdict on a turn that RAN (#2638 `billing`,
whose `error` is the agent's own prose) — overriding it would let the new
429 arm SKIP an exhausted subscription whose prose happened to say "at
capacity". Prose must not beat the structured code: `capacity` now only
fills an absent code (the capacity rejection sets none, so every live
at-capacity 429 still carries `capacity`). Red first:
`{'X-Trinity-Error-Code': 'capacity'} != {'X-Trinity-Error-Code': 'billing'}`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* merge-train: capacity site is _acquire_task_capacity; backlog path gets capacity on at-capacity (#2919) — mechanical, doc/comment accuracy

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants