Skip to content

Commit d19bae7

Browse files
drknowhowclaude
andauthored
fix: circuit breaker for c3_delegate CLI backends (v2.39.1) (#22)
* fix: circuit breaker for c3_delegate CLI backends A broken-but-installed delegate backend (gemini/codex/claude) re-spawned a 90-120s subprocess on every call: the handlers returned the error on runtime failure (if not ok:) but never demoted the backend. Add a reusable thread-safe CircuitBreaker (services/circuit_breaker.py) and wire it into all three CLI handlers: gate before spawn, record_failure() opens after N consecutive fails (default 3 / 60s cooldown, configurable via delegate_config breaker_failure_threshold/breaker_cooldown_seconds), record_success() resets, half-open probe after cooldown. Trips emit a NotificationStore warning; the auto router skips tripped backends and falls back to ollama. Breaker state is process-global on purpose: backend health (auth, CLI version) is a host fact, not per-project. Tests: tests/test_circuit_breaker.py (5 pass). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(release): 2.39.1 - c3_delegate circuit breaker Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1ebb3d2 commit d19bae7

5 files changed

Lines changed: 287 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
66

77
## [Unreleased]
88

9+
## [2.39.1] - 2026-06-24
10+
11+
A small reliability fix for `c3_delegate`.
12+
13+
### Fixed
14+
15+
- **A broken delegate backend re-spawned a subprocess on every call.** When a CLI
16+
backend (`gemini`/`codex`/`claude`) was installed but failing at runtime (expired
17+
auth, model pulled away, repeated timeouts), `_handle_*_delegate` returned the error
18+
without demoting the backend — so every subsequent `c3_delegate` call paid the full
19+
90–120s subprocess spawn + timeout again. Each backend now has a thread-safe circuit
20+
breaker (`services/circuit_breaker.py`): after N consecutive failures (default 3) it
21+
short-circuits for a cooldown (default 60s) with a single half-open probe on recovery,
22+
and surfaces a notification when it trips. The `auto` router skips tripped backends and
23+
falls back to Ollama. Thresholds are configurable via `delegate_config`
24+
(`breaker_failure_threshold`, `breaker_cooldown_seconds`).
25+
926
## [2.39.0] - 2026-06-22
1027

1128
A correctness & security hardening release. A multi-agent audit of C3 surfaced a

cli/tools/delegate.py

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@
1111
import shutil
1212
import subprocess
1313
import sys
14+
import threading
1415
import time
1516
from pathlib import Path
1617

1718
from core import count_tokens
19+
from services.circuit_breaker import CircuitBreaker
1820

1921
log = logging.getLogger(__name__)
2022

@@ -252,11 +254,20 @@ def _handle_claude_delegate(task: str, task_type: str, context: str,
252254
file_path: str, svc, dcfg: dict, finalize) -> str:
253255
"""Handle delegation via Claude Code CLI."""
254256
timeout = int(dcfg.get("claude_timeout", 90))
257+
breaker = _backend_breaker("claude", dcfg)
258+
if not breaker.allow():
259+
return finalize("c3_delegate", {"task_type": task_type, "backend": "claude"},
260+
"[delegate:degraded] Claude skipped after repeated failures; retrying in "
261+
f"~{breaker.cooldown_remaining()}s. Run 'claude --version' to diagnose.",
262+
"degraded")
255263
_log_progress(svc, f"[delegate] Routing {task_type} → Claude CLI...")
256264
output, ok = _run_claude(task, context, cwd=str(svc.project_path), timeout=timeout)
257265
if not ok:
266+
if breaker.record_failure():
267+
_notify_backend_degraded(svc, "claude", breaker)
258268
return finalize("c3_delegate", {"task_type": task_type, "backend": "claude"},
259269
output, "error")
270+
breaker.record_success()
260271
return finalize("c3_delegate", {"task_type": task_type, "backend": "claude"},
261272
output, "ok")
262273

@@ -681,6 +692,51 @@ def _run_codex_resume(follow_up: str, timeout: int = 120,
681692
_delegate_cache: dict[str, tuple[str, int]] = {}
682693
_delegate_metrics = {"total_calls": 0, "tokens_saved": 0}
683694

695+
# Per-backend runtime circuit breakers. Distinct from the install-status flags
696+
# (_gemini_available etc., which only answer "is the CLI on PATH"): these track
697+
# *runtime* health so a broken-but-installed backend (expired auth, repeated
698+
# timeouts) stops re-spawning a 90-120s subprocess on every call. Keyed by
699+
# backend name and intentionally process-global — backend health (auth, CLI
700+
# version) is a property of the host, not of any single project.
701+
_backend_breakers: dict[str, CircuitBreaker] = {}
702+
_backend_breakers_lock = threading.Lock()
703+
704+
705+
def _backend_breaker(name: str, dcfg: dict | None = None) -> CircuitBreaker:
706+
"""Return (creating on first use) the runtime circuit breaker for a backend."""
707+
with _backend_breakers_lock:
708+
breaker = _backend_breakers.get(name)
709+
if breaker is None:
710+
cfg = dcfg or {}
711+
breaker = CircuitBreaker(
712+
name,
713+
failure_threshold=int(cfg.get("breaker_failure_threshold", 3) or 3),
714+
cooldown_seconds=float(cfg.get("breaker_cooldown_seconds", 60) or 60),
715+
)
716+
_backend_breakers[name] = breaker
717+
return breaker
718+
719+
720+
def _notify_backend_degraded(svc, name: str, breaker: CircuitBreaker) -> None:
721+
"""Surface a backend trip via the NotificationStore (best-effort, never raises)."""
722+
notifications = getattr(svc, "notifications", None)
723+
if notifications is None:
724+
return
725+
try:
726+
notifications.add(
727+
agent="c3",
728+
severity="warning",
729+
title=f"Delegate backend degraded: {name}",
730+
message=(
731+
f"{name} failed {breaker.failure_threshold}x consecutively; c3_delegate "
732+
f"will skip it for ~{int(breaker.cooldown_seconds)}s instead of re-spawning "
733+
f"the CLI. Run '{name} --version' to diagnose."
734+
),
735+
replace_if_unacked=True,
736+
)
737+
except Exception:
738+
pass
739+
684740

685741
def get_delegate_metrics() -> dict:
686742
return dict(_delegate_metrics)
@@ -765,6 +821,13 @@ def _handle_codex_delegate(task: str, task_type: str, context: str,
765821
"[delegate:error] Codex CLI not available. Run 'codex --version' to diagnose.",
766822
"unavailable")
767823

824+
breaker = _backend_breaker("codex", dcfg)
825+
if not breaker.allow():
826+
return finalize("c3_delegate", {"task_type": task_type, "backend": "codex"},
827+
"[delegate:degraded] Codex skipped after repeated failures; retrying in "
828+
f"~{breaker.cooldown_remaining()}s. Run 'codex --version' to diagnose.",
829+
"degraded")
830+
768831
# Resolve model/sandbox/reasoning from config or defaults
769832
cdef = CODEX_MODELS.get(task_type, CODEX_MODELS.get("ask", {}))
770833
model = dcfg.get("codex_default_model") or cdef.get("model", "gpt-5.3-codex-spark")
@@ -807,10 +870,13 @@ def _handle_codex_delegate(task: str, task_type: str, context: str,
807870
elapsed = round(time.monotonic() - t0, 1)
808871

809872
if not ok:
873+
if breaker.record_failure():
874+
_notify_backend_degraded(svc, "codex", breaker)
810875
return finalize("c3_delegate",
811876
{"task_type": task_type, "backend": "codex", "model": model, "elapsed": f"{elapsed}s"},
812877
output, "error")
813878

879+
breaker.record_success()
814880
_delegate_metrics["total_calls"] += 1
815881
_delegate_cache[ckey] = (output, count_tokens(output))
816882

@@ -880,6 +946,13 @@ def _handle_gemini_delegate(task: str, task_type: str, context: str,
880946
"[delegate:error] Gemini CLI not available. Run 'gemini --version' to diagnose.",
881947
"unavailable")
882948

949+
breaker = _backend_breaker("gemini", dcfg)
950+
if not breaker.allow():
951+
return finalize("c3_delegate", {"task_type": task_type, "backend": "gemini"},
952+
"[delegate:degraded] Gemini skipped after repeated failures; retrying in "
953+
f"~{breaker.cooldown_remaining()}s. Run 'gemini --version' to diagnose.",
954+
"degraded")
955+
883956
# Resolve model from config or defaults
884957
gdef = GEMINI_MODELS.get(task_type, GEMINI_MODELS.get("ask", {}))
885958
model = dcfg.get("gemini_default_model") or gdef.get("model", "gemini-2.5-flash")
@@ -919,10 +992,13 @@ def _handle_gemini_delegate(task: str, task_type: str, context: str,
919992
elapsed = round(time.monotonic() - t0, 1)
920993

921994
if not ok:
995+
if breaker.record_failure():
996+
_notify_backend_degraded(svc, "gemini", breaker)
922997
return finalize("c3_delegate",
923998
{"task_type": task_type, "backend": "gemini", "model": model, "elapsed": f"{elapsed}s"},
924999
output, "error")
9251000

1001+
breaker.record_success()
9261002
_delegate_metrics["total_calls"] += 1
9271003
_delegate_cache[ckey] = (output, count_tokens(output))
9281004

@@ -1068,9 +1144,11 @@ def _check_claude():
10681144
_gemini_avail = (_gemini_available is True) or (
10691145
_gemini_available is None and task_type not in _light_tasks and _is_gemini_on_path()
10701146
)
1071-
if task_type in heavy_codex and _codex_avail and _codex_available is not False:
1147+
if (task_type in heavy_codex and _codex_avail and _codex_available is not False
1148+
and _backend_breaker("codex", dcfg).allow()):
10721149
backend = "codex"
1073-
elif task_type in heavy_gemini and _gemini_avail and _gemini_available is not False:
1150+
elif (task_type in heavy_gemini and _gemini_avail and _gemini_available is not False
1151+
and _backend_breaker("gemini", dcfg).allow()):
10741152
backend = "gemini"
10751153
else:
10761154
backend = "ollama"

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "code-context-control"
7-
version = "2.39.0"
7+
version = "2.39.1"
88
description = "Local code-intelligence layer for AI coding tools (Claude Code, Codex, Gemini, Copilot). Retrieve less, read less, edit safer."
99
readme = "README.md"
1010
requires-python = ">=3.10"

services/circuit_breaker.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""Lightweight thread-safe circuit breaker for flapping subsystems.
2+
3+
Borrowed in spirit from Headroom's TransformPipeline breaker: after N
4+
consecutive failures a subsystem is treated as unhealthy and calls are
5+
short-circuited for a cooldown window instead of re-running (and re-failing)
6+
the expensive operation every time. A single success closes the breaker.
7+
8+
First consumer: c3_delegate, to stop re-spawning a broken-but-installed CLI
9+
backend (a 90-120s subprocess timeout) on every call. Deliberately
10+
dependency-free so any call-time subsystem (e.g. the Ollama embed/generate
11+
path) can reuse it later.
12+
"""
13+
from __future__ import annotations
14+
15+
import threading
16+
import time
17+
18+
19+
class CircuitBreaker:
20+
"""Consecutive-failure breaker: closed -> open (after N fails) -> half-open (after cooldown)."""
21+
22+
def __init__(
23+
self,
24+
name: str = "",
25+
*,
26+
failure_threshold: int = 3,
27+
cooldown_seconds: float = 60.0,
28+
) -> None:
29+
self.name = name
30+
self.failure_threshold = max(1, int(failure_threshold))
31+
self.cooldown_seconds = max(0.0, float(cooldown_seconds))
32+
self._lock = threading.Lock()
33+
self._failures = 0
34+
self._open = False
35+
self._opened_at = 0.0
36+
37+
def allow(self) -> bool:
38+
"""Return True if a call may proceed.
39+
40+
An open breaker permits a single probe once the cooldown has elapsed
41+
(half-open); the next ``record_success``/``record_failure`` resolves it.
42+
"""
43+
with self._lock:
44+
if not self._open:
45+
return True
46+
return (time.monotonic() - self._opened_at) >= self.cooldown_seconds
47+
48+
def record_success(self) -> None:
49+
"""Reset the breaker after a healthy call."""
50+
with self._lock:
51+
self._failures = 0
52+
self._open = False
53+
self._opened_at = 0.0
54+
55+
def record_failure(self) -> bool:
56+
"""Count a failed call.
57+
58+
Returns True iff this failure *just* tripped the breaker open (callers
59+
can use that edge to emit a one-shot notification). A failed half-open
60+
probe restarts the cooldown but does not re-trip.
61+
"""
62+
with self._lock:
63+
self._failures += 1
64+
if not self._open and self._failures >= self.failure_threshold:
65+
self._open = True
66+
self._opened_at = time.monotonic()
67+
return True
68+
if self._open:
69+
self._opened_at = time.monotonic()
70+
return False
71+
72+
def cooldown_remaining(self) -> int:
73+
"""Whole seconds left before the next probe is allowed (0 if closed/elapsed)."""
74+
with self._lock:
75+
if not self._open:
76+
return 0
77+
remaining = self.cooldown_seconds - (time.monotonic() - self._opened_at)
78+
return max(0, int(round(remaining)))
79+
80+
@property
81+
def is_open(self) -> bool:
82+
"""True while calls are actively being short-circuited (open and within cooldown)."""
83+
with self._lock:
84+
if not self._open:
85+
return False
86+
return (time.monotonic() - self._opened_at) < self.cooldown_seconds

tests/test_circuit_breaker.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Tests for the CircuitBreaker primitive and the c3_delegate demote-on-failure wiring."""
2+
import time
3+
4+
from services.circuit_breaker import CircuitBreaker
5+
6+
7+
def test_closed_until_threshold_then_opens():
8+
br = CircuitBreaker("x", failure_threshold=3, cooldown_seconds=60)
9+
assert br.allow()
10+
assert br.record_failure() is False # 1
11+
assert br.record_failure() is False # 2
12+
assert br.allow() # still closed below threshold
13+
assert br.record_failure() is True # 3 -> trips open (edge)
14+
assert not br.allow() # open, within cooldown
15+
assert br.is_open
16+
17+
18+
def test_success_closes_breaker():
19+
br = CircuitBreaker("x", failure_threshold=2, cooldown_seconds=60)
20+
br.record_failure()
21+
assert br.record_failure() is True
22+
assert not br.allow()
23+
br.record_success()
24+
assert br.allow()
25+
assert br.cooldown_remaining() == 0
26+
assert not br.is_open
27+
28+
29+
def test_half_open_probe_after_cooldown():
30+
br = CircuitBreaker("x", failure_threshold=1, cooldown_seconds=0.05)
31+
assert br.record_failure() is True
32+
assert not br.allow() # blocked within cooldown
33+
time.sleep(0.08)
34+
assert br.allow() # half-open: one probe allowed
35+
assert br.record_failure() is False # failed probe re-arms, not a new trip
36+
assert not br.allow()
37+
38+
39+
def test_cooldown_remaining_within_bounds():
40+
br = CircuitBreaker("x", failure_threshold=1, cooldown_seconds=30)
41+
br.record_failure()
42+
assert 0 < br.cooldown_remaining() <= 30
43+
44+
45+
class _FakeNotifications:
46+
def __init__(self):
47+
self.entries = []
48+
49+
def add(self, **kwargs):
50+
self.entries.append(kwargs)
51+
return kwargs
52+
53+
54+
class _FakeSvc:
55+
def __init__(self):
56+
self.project_path = "."
57+
self.delegate_config = {
58+
"gemini_enabled": True,
59+
"auto_compress": False,
60+
"breaker_failure_threshold": 3,
61+
"breaker_cooldown_seconds": 60,
62+
}
63+
self.notifications = _FakeNotifications()
64+
self.compressor = None
65+
self._agent_progress_cb = None
66+
67+
68+
def _finalize(_tool, _meta, _output, status):
69+
return status
70+
71+
72+
def test_gemini_demotes_after_repeated_failures(monkeypatch):
73+
"""The core bug fix: a broken backend must stop re-spawning the CLI every call."""
74+
from cli.tools import delegate
75+
76+
# Isolate module-global breaker + cache + availability state.
77+
delegate._backend_breakers.clear()
78+
delegate._delegate_cache.clear()
79+
monkeypatch.setattr(delegate, "_gemini_available", True, raising=False)
80+
81+
calls = {"run": 0}
82+
83+
def fake_run_gemini(*_a, **_k):
84+
calls["run"] += 1
85+
return ("boom", False, {})
86+
87+
monkeypatch.setattr(delegate, "_run_gemini", fake_run_gemini)
88+
89+
svc = _FakeSvc()
90+
dcfg = svc.delegate_config
91+
92+
# Three real failures trip the breaker (threshold=3).
93+
for _ in range(3):
94+
assert delegate._handle_gemini_delegate("t", "ask", "", "", svc, dcfg, _finalize) == "error"
95+
assert calls["run"] == 3
96+
97+
# Fourth call: breaker open -> short-circuit, NO subprocess re-spawn.
98+
assert delegate._handle_gemini_delegate("t", "ask", "", "", svc, dcfg, _finalize) == "degraded"
99+
assert calls["run"] == 3
100+
101+
# The trip emitted exactly one degradation notification.
102+
degraded = [e for e in svc.notifications.entries if "degraded" in e.get("title", "").lower()]
103+
assert len(degraded) == 1

0 commit comments

Comments
 (0)