feat: per-agent dispatch circuit breaker to prevent backlog poisoning (#526) - #986
Merged
Conversation
#526) Producer-side breaker at the dispatch layer: when an agent is auth-dead (reachable but answers HTTP 503 -> execution error_code == AUTH), fast-fail new executions instead of poisoning the persistent backlog with doomed tasks, and self-heal via a half-open probe. - services/dispatch_breaker.py: consecutive-failure state machine (closed -> open -> half-open(probe) -> closed) in Redis via atomic Lua, AUTH-only counting, exp backoff, one-probe-at-a-time lock. Fail-open, never raises. Separate namespace/Lua from the transport breaker (#631) so the two never contaminate each other's counter. - redis_breaker_util.py: shared fail-open client + Lua ScriptCache + decode helpers, extracted from agent_client so both breakers reuse one plumbing layer (top-level leaf module, no services/__init__ import). - task_execution_service: single outcome-recording path -> record_outcome on every terminal; on ->open, background fail_queued_for_agent + clear in-memory queue + audit via _spawn_bg (strong ref against GC). CircuitOpen from acquire -> FAILED row, no enqueue (no-enqueue invariant). - capacity_manager: dispatch gate at the top of acquire() (gated on per-agent + global flags); half-open probe admitted only into a free slot; 60s breaker-aware backstop re-fails queued backlog for still-open breakers. - db: fail_queued_for_agent (QUEUED -> FAILED), circuit_breaker_enabled opt-in column (migration + schema), bulk accessors. - routers/agents: GET/PUT /circuit-breaker (read=authorized, config=owner) and reset (admin) clears both breakers; unified view embedded in GET /{name}. Tests: 32 unit (routing, fail-open, acquire-gate, drain wiring) + 19 integration against real Redis Lua (sibling stack). Green across CI seeds 12345/67890/99999. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surface an open dispatch breaker in the UI so operators see auth-dead agents
at a glance:
- AgentHeader: danger-styled "circuit open" badge from the circuit_breaker
block embedded in GET /api/agents/{name} (no extra round-trip).
- AgentNode: same badge on the network graph, driven by the per-agent
circuit_breakers map from the bulk slots endpoint; clears on recovery.
- stores/agents + network: derive badge state from the embedded block /
bulk map (only OPEN breakers are sent).
- e2e/circuit-breaker-badge.spec.js: Playwright coverage for the badge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ow (#526) - architecture.md: dispatch_breaker/redis_breaker_util/circuit_breaker_view modules, the 3 circuit-breaker endpoints, circuit_breaker_enabled column, task_execution_service outcome-recording + drain, capacity_manager backstop. - feature-flows/dispatch-circuit-breaker.md: new end-to-end flow doc. - feature-flows index + capacity-management + task-execution-service updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Resolve by running |
Merged
3 tasks
# Conflicts: # docs/memory/feature-flows.md # docs/memory/requirements.md # src/backend/db/migrations.py
vybe
approved these changes
Jun 2, 2026
vybe
left a comment
Contributor
There was a problem hiding this comment.
✅ Validated via /validate-pr (RELIABILITY-007, P1).
- Auth boundaries correct (read=authorized / config=owner+audit / reset=admin), SQL parameterized, schema↔migration parity intact.
- No-enqueue invariant preserved end-to-end including across the half-open probe window; AUTH-only outcome recording respects the caller contract; fail-open throughout.
- CI green across base+head × seeds 12345/67890/99999, sys.modules lint, schema-parity, regression diff.
- Ships default-OFF dual-gated opt-in canary.
Merge conflicts with dev (feature-flows.md, requirements.md §10.10 collision → renumbered dispatch breaker to §10.11, migrations.py MIGRATIONS list → all four migrations preserved) resolved in the merge commit; CI re-running.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements the per-agent dispatch circuit breaker (RELIABILITY-007). When an agent is auth-dead — reachable but answering HTTP 503 (
execution error_code == AUTH) — this producer-side breaker fast-fails new executions at the dispatch layer instead of poisoning the persistent backlog with doomed tasks, then self-heals through a half-open probe. Distinct from, and namespace-isolated from, the existing transport-reachability breaker (#631).Backend
services/dispatch_breaker.py— consecutive-failure state machine (closed → open → half-open(probe) → closed) in Redis via atomic Lua. AUTH-only counting (threshold 3), exponential backoff (30s→300s cap), one-probe-at-a-timeSET NX EXlock. Fail-open, never raises.redis_breaker_util.py— shared fail-open Redis client + LuaScriptCache+ decode helpers, extracted fromagent_clientso both breakers reuse one plumbing layer (top-level leaf module → no circular import).services/circuit_breaker_view.py— single source for the unified{dispatch, transport, open, config}block.task_execution_service— single outcome-recording path records every terminal; on→openit backgroundsfail_queued_for_agent+ clear in-memory queue + audit via_spawn_bg(strong ref against GC).CircuitOpen→ FAILED row, never enqueues (no-enqueue invariant).capacity_manager— dispatch gate at the top ofacquire()(gated on per-agentcircuit_breaker_enabledand globalDISPATCH_BREAKER_ENABLED); half-open probe admitted only into a free slot; 60s breaker-aware backstop re-fails queued backlog for still-open breakers if an inline drain is lost.fail_queued_for_agent(QUEUED→FAILED, not CANCELLED),circuit_breaker_enabledopt-in column (migration + schema, default OFF), bulk accessors.routers/agents—GET/PUT /api/agents/{name}/circuit-breaker(read = authorized, config = owner-only + audit),reset(admin) now clears both breakers; unified block embedded inGET /api/agents/{name}.Frontend
AgentHeaderandAgentNode, derived from the embedded block / bulk slots map; clears on recovery. Playwright spec added.Default OFF — both the per-agent opt-in and the global master switch must be on to engage (opt-in canary).
Test Coverage
Run on an isolated sibling Docker stack (never the running instance): Redis-only
docker-compose.sibling.ymlon:6390for the Lua state machine, plus a throwaway sibling backend on:8100for the api-client auth fixture.test_dispatch_breaker.py,test_dispatch_breaker_wiring.py): transition value-object, AUTH-only routing, fail-open (Redis down / no-Lua), acquire-gate + no-enqueue, half-open-probe-into-free-slot, drain wiring, maintenance backstop. fakeredis.test_dispatch_breaker.py,test_fail_queued_for_agent.py) against real Redis Lua: consecutive-AUTH threshold, success reset, ignored outcomes, retry_after, half-open probe close, probe reopen with grown backoff, missed-heartbeat seam, record_success no-op, operator hooks; andfail_queued_for_agentsets FAILED not CANCELLED.1843 passed.lint_sys_modulespasses; schema/migration parity intact.Tests: +4 files (2 unit, 2 integration), +1 dep (fakeredis).Pre-Landing Review
TestMaintenanceBackstop,TestDrainWiring) were victims of a pre-existing test-isolation leak —test_904_agent_call_limiterinstalls a method-lessdatabase.dbstub it never restores, so under randomized ordersetattr(database.db, …)raisedAttributeError. Fix: the tests now swap the wholedbobject in the namespace the code resolves. Verified green across all 3 CI seeds.Pre-existing failures (NOT this branch)
test_orphaned_execution_recovery::{test_in_registry_left_alone, test_multiple_agents_mixed}fail under randomized order ondevas well (confirmed by running the suite without this branch's new files:1811 passed, 2 failed). Caused by an unrelated agent-client mock-clobber polluter;cleanup_serviceis untouched by this PR. CI's new-failing-test diff against base will not flag them.Test plan
tests/unitgreen across CI seeds 12345 / 67890 / 99999 (1843 passed)lint_sys_modulespasses; migration applies on a fresh DB (sibling backend booted clean)ui-gatedfrontend-e2eCI job (not run locally; needs the frontend stack)Closes #526
🤖 Generated with Claude Code