From 2cd59aaa3cbbbb06086bb7a92bb5459356f64de1 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Fri, 19 Jun 2026 17:21:47 +0800 Subject: [PATCH] Add resilience primitives: RetryPolicy, CircuitBreaker, AC_circuit_call --- README.md | 8 ++ README/README_zh-CN.md | 8 ++ README/README_zh-TW.md | 8 ++ .../Eng/doc/new_features/v25_features_doc.rst | 53 +++++++++ docs/source/Eng/eng_index.rst | 1 + .../Zh/doc/new_features/v25_features_doc.rst | 50 +++++++++ docs/source/Zh/zh_index.rst | 1 + je_auto_control/__init__.py | 5 + .../gui/script_builder/command_schema.py | 13 +++ .../utils/executor/action_executor.py | 15 +++ .../utils/mcp_server/tools/_factories.py | 22 +++- .../utils/mcp_server/tools/_handlers.py | 6 + je_auto_control/utils/resilience/__init__.py | 6 + .../utils/resilience/resilience.py | 104 ++++++++++++++++++ .../headless/test_resilience_batch.py | 94 ++++++++++++++++ 15 files changed, 393 insertions(+), 1 deletion(-) create mode 100644 docs/source/Eng/doc/new_features/v25_features_doc.rst create mode 100644 docs/source/Zh/doc/new_features/v25_features_doc.rst create mode 100644 je_auto_control/utils/resilience/__init__.py create mode 100644 je_auto_control/utils/resilience/resilience.py create mode 100644 test/unit_test/headless/test_resilience_batch.py diff --git a/README.md b/README.md index d30ae6a8..9d9097f3 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Table of Contents +- [What's new (2026-06-19) — Resilience Primitives](#whats-new-2026-06-19--resilience-primitives) - [What's new (2026-06-19) — Timed Input Macros](#whats-new-2026-06-19--timed-input-macros) - [What's new (2026-06-19) — Semantic Screen State](#whats-new-2026-06-19--semantic-screen-state) - [What's new (2026-06-19) — Set-of-Marks Overlay](#whats-new-2026-06-19--set-of-marks-overlay) @@ -77,6 +78,13 @@ --- +## What's new (2026-06-19) — Resilience Primitives + +Reusable retry + circuit-breaker primitives. Full reference: [`docs/source/Eng/doc/new_features/v25_features_doc.rst`](docs/source/Eng/doc/new_features/v25_features_doc.rst). + +- **RetryPolicy** — `RetryPolicy(...).run(fn)` / `retry_call(fn)`: retry on configured exceptions with exponential backoff (injectable sleep). (The existing `AC_retry` flow command already retries an action body; this is the reusable callable wrapper.) +- **CircuitBreaker** — `CircuitBreaker` / `CircuitOpenError` (`AC_circuit_call`, `ac_circuit_call`): open after N consecutive failures, short-circuit until a reset timeout, then half-open — stops a retry storm hammering a downed dependency. Injectable clock; `AC_circuit_call` runs an action list through a named breaker. + ## What's new (2026-06-19) — Timed Input Macros Replay input with timing fidelity + a press-hold-release DSL, full stack. Full reference: [`docs/source/Eng/doc/new_features/v24_features_doc.rst`](docs/source/Eng/doc/new_features/v24_features_doc.rst). diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 5e71dc59..51224e77 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -12,6 +12,7 @@ ## 目录 +- [本次更新 (2026-06-19) — 韧性原语](#本次更新-2026-06-19--韧性原语) - [本次更新 (2026-06-19) — 计时输入宏](#本次更新-2026-06-19--计时输入宏) - [本次更新 (2026-06-19) — 语义屏幕状态](#本次更新-2026-06-19--语义屏幕状态) - [本次更新 (2026-06-19) — Set-of-Marks 叠图](#本次更新-2026-06-19--set-of-marks-叠图) @@ -76,6 +77,13 @@ --- +## 本次更新 (2026-06-19) — 韧性原语 + +可重用的 retry 与断路器原语。完整参考:[`docs/source/Zh/doc/new_features/v25_features_doc.rst`](../docs/source/Zh/doc/new_features/v25_features_doc.rst)。 + +- **RetryPolicy** — `RetryPolicy(...).run(fn)` / `retry_call(fn)`:在配置的异常上以指数退避重试(可注入 sleep)。(既有 `AC_retry` 流程指令已能对动作 body 重试;这是可重用的可调用包装器。) +- **CircuitBreaker** — `CircuitBreaker` / `CircuitOpenError`(`AC_circuit_call`、`ac_circuit_call`):连续失败 N 次后打开、短路至重置超时、再半开——避免重试风暴打垮已故障依赖。可注入 clock;`AC_circuit_call` 让动作列表通过具名断路器执行。 + ## 本次更新 (2026-06-19) — 计时输入宏 以时间保真度重播输入 + 按住-放开 DSL,走完整五层。完整参考:[`docs/source/Zh/doc/new_features/v24_features_doc.rst`](../docs/source/Zh/doc/new_features/v24_features_doc.rst)。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 0603ea8e..e15858c2 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -12,6 +12,7 @@ ## 目錄 +- [本次更新 (2026-06-19) — 韌性原語](#本次更新-2026-06-19--韌性原語) - [本次更新 (2026-06-19) — 計時輸入巨集](#本次更新-2026-06-19--計時輸入巨集) - [本次更新 (2026-06-19) — 語意螢幕狀態](#本次更新-2026-06-19--語意螢幕狀態) - [本次更新 (2026-06-19) — Set-of-Marks 疊圖](#本次更新-2026-06-19--set-of-marks-疊圖) @@ -76,6 +77,13 @@ --- +## 本次更新 (2026-06-19) — 韌性原語 + +可重用的 retry 與斷路器原語。完整參考:[`docs/source/Zh/doc/new_features/v25_features_doc.rst`](../docs/source/Zh/doc/new_features/v25_features_doc.rst)。 + +- **RetryPolicy** — `RetryPolicy(...).run(fn)` / `retry_call(fn)`:在設定的例外上以指數退避重試(可注入 sleep)。(既有 `AC_retry` 流程指令已能對動作 body 重試;這是可重用的可呼叫包裝器。) +- **CircuitBreaker** — `CircuitBreaker` / `CircuitOpenError`(`AC_circuit_call`、`ac_circuit_call`):連續失敗 N 次後開啟、短路至重置逾時、再半開——避免重試風暴打掛已故障依賴。可注入 clock;`AC_circuit_call` 讓動作清單透過具名斷路器執行。 + ## 本次更新 (2026-06-19) — 計時輸入巨集 以時間保真度重播輸入 + 按住-放開 DSL,走完整五層。完整參考:[`docs/source/Zh/doc/new_features/v24_features_doc.rst`](../docs/source/Zh/doc/new_features/v24_features_doc.rst)。 diff --git a/docs/source/Eng/doc/new_features/v25_features_doc.rst b/docs/source/Eng/doc/new_features/v25_features_doc.rst new file mode 100644 index 00000000..3dc734d5 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v25_features_doc.rst @@ -0,0 +1,53 @@ +================================================== +New Features (2026-06-19) — Resilience Primitives +================================================== + +Reusable resilience primitives — a retry-with-backoff policy and a circuit +breaker — plus an executor command that runs an action list through a named +breaker. Pure standard library; both primitives take injectable +``sleep`` / ``clock`` so they are unit-tested deterministically. + +(The existing ``AC_retry`` flow command already retries an action *body*; +this adds the reusable :class:`RetryPolicy` callable wrapper and the new +:class:`CircuitBreaker`.) + +.. contents:: + :local: + :depth: 2 + + +RetryPolicy +========== + +:: + + from je_auto_control import RetryPolicy, retry_call + + RetryPolicy(max_attempts=5, backoff=0.1, multiplier=2.0).run(flaky_fn) + retry_call(flaky_fn, max_attempts=3) # convenience + +Retries ``func`` on the configured ``exceptions`` with exponential backoff +(``backoff * multiplier**n``, optionally capped by ``max_backoff``), +re-raising the last error when attempts are exhausted. + + +CircuitBreaker +============= + +:: + + from je_auto_control import CircuitBreaker, CircuitOpenError + + breaker = CircuitBreaker(failure_threshold=5, reset_timeout=30.0) + try: + breaker.call(call_remote_service) + except CircuitOpenError: + ... # short-circuited — the dependency is down + +Opens after ``failure_threshold`` consecutive failures and short-circuits +(raising :class:`CircuitOpenError`) until ``reset_timeout`` elapses, then +half-opens for one trial; a success closes it. Stops a retry storm from +hammering a downed dependency. + +``AC_circuit_call`` / ``ac_circuit_call`` run an action list through a +**named** breaker (state shared across calls), returning ``{state, record}``. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index dd677a1b..6c6014d7 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -47,6 +47,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v22_features_doc doc/new_features/v23_features_doc doc/new_features/v24_features_doc + doc/new_features/v25_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/docs/source/Zh/doc/new_features/v25_features_doc.rst b/docs/source/Zh/doc/new_features/v25_features_doc.rst new file mode 100644 index 00000000..8d8ccbea --- /dev/null +++ b/docs/source/Zh/doc/new_features/v25_features_doc.rst @@ -0,0 +1,50 @@ +========================================== +新功能 (2026-06-19) — 韌性原語 +========================================== + +可重用的韌性原語——retry-with-backoff 策略與斷路器(circuit breaker) +——並提供一個執行器指令,讓動作清單透過具名斷路器執行。純標準庫;兩個 +原語都接受可注入的 ``sleep`` / ``clock``,因此能做決定性單元測試。 + +(既有的 ``AC_retry`` 流程指令已能對動作 *body* 重試;本功能新增可重用的 +:class:`RetryPolicy` 可呼叫包裝器與全新的 :class:`CircuitBreaker`。) + +.. contents:: + :local: + :depth: 2 + + +RetryPolicy +=========== + +:: + + from je_auto_control import RetryPolicy, retry_call + + RetryPolicy(max_attempts=5, backoff=0.1, multiplier=2.0).run(flaky_fn) + retry_call(flaky_fn, max_attempts=3) # 便利函式 + +在設定的 ``exceptions`` 上以指數退避重試 ``func``(``backoff * +multiplier**n``,可用 ``max_backoff`` 上限夾限),嘗試耗盡後重新拋出最後 +一個錯誤。 + + +CircuitBreaker +============== + +:: + + from je_auto_control import CircuitBreaker, CircuitOpenError + + breaker = CircuitBreaker(failure_threshold=5, reset_timeout=30.0) + try: + breaker.call(call_remote_service) + except CircuitOpenError: + ... # 已短路——依賴掛了 + +連續失敗達 ``failure_threshold`` 次後開啟並短路(拋出 +:class:`CircuitOpenError`),直到 ``reset_timeout`` 過去後半開試一次;成功 +即關閉。可避免重試風暴持續打掛已故障的依賴。 + +``AC_circuit_call`` / ``ac_circuit_call`` 讓動作清單透過**具名**斷路器 +執行(狀態跨呼叫共享),回傳 ``{state, record}``。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index 2e924bda..7c20da12 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -47,6 +47,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v22_features_doc doc/new_features/v23_features_doc doc/new_features/v24_features_doc + doc/new_features/v25_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index 944a76d9..953adba4 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -176,6 +176,10 @@ ) # Timed input replay + declarative input-sequence DSL from je_auto_control.utils.input_macro import replay_timeline, run_sequence +# Resilience primitives (retry-with-backoff + circuit breaker) +from je_auto_control.utils.resilience import ( + CircuitBreaker, CircuitOpenError, RetryPolicy, retry_call, +) # Background popup/interrupt watchdog (unattended automation) from je_auto_control.utils.watchdog import ( PopupWatchdog, WatchdogRule, default_popup_watchdog, @@ -604,6 +608,7 @@ def start_autocontrol_gui(*args, **kwargs): "describe_screen", "diff_snapshots", "screen_changed", "snapshot", "snapshot_screen", "replay_timeline", "run_sequence", + "CircuitBreaker", "CircuitOpenError", "RetryPolicy", "retry_call", # MCP server "AuditLogger", "HttpMCPServer", "MCPContent", "MCPPrompt", "MCPPromptArgument", "MCPResource", "MCPServer", "MCPTool", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index b039478f..9865972d 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -664,6 +664,19 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: _add_set_of_marks_specs(specs) _add_screen_state_specs(specs) _add_input_macro_specs(specs) + _add_resilience_specs(specs) + + +def _add_resilience_specs(specs: List[CommandSpec]) -> None: + specs.append(CommandSpec( + "AC_circuit_call", "Flow", "Circuit Breaker Call", + fields=( + FieldSpec("name", FieldType.STRING), + FieldSpec("threshold", FieldType.INT, optional=True, default=5), + FieldSpec("reset_s", FieldType.FLOAT, optional=True, default=30.0), + ), + description="Run 'actions' (JSON view) via a named circuit breaker.", + )) def _add_input_macro_specs(specs: List[CommandSpec]) -> None: diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index f7554435..40414a88 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -2794,6 +2794,20 @@ def _input_sequence(steps: List[Dict[str, Any]]) -> Dict[str, Any]: return {"log": run_sequence(steps)} +_CIRCUIT_BREAKERS: Dict[str, Any] = {} + + +def _circuit_call(name: str, actions: List[Any], threshold: int = 5, + reset_s: float = 30.0) -> Dict[str, Any]: + """Adapter: run an action list through a named circuit breaker.""" + from je_auto_control.utils.resilience import CircuitBreaker + breaker = _CIRCUIT_BREAKERS.setdefault( + name, CircuitBreaker(int(threshold), float(reset_s))) + record = breaker.call( + lambda: executor.execute_action(list(actions), raise_on_error=True)) + return {"state": breaker.state, "record": record} + + class Executor: """ Executor @@ -3012,6 +3026,7 @@ def __init__(self): "AC_describe_screen": _describe_screen, "AC_replay_timeline": _replay_timeline, "AC_input_sequence": _input_sequence, + "AC_circuit_call": _circuit_call, "AC_a11y_record_start": _a11y_record_start, "AC_a11y_record_stop": _a11y_record_stop, "AC_a11y_record_events": _a11y_record_events, diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 268a6e8d..3503093e 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -2400,6 +2400,26 @@ def input_macro_tools() -> List[MCPTool]: ] +def resilience_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_circuit_call", + description=("Run an action list through a named circuit breaker: " + "after 'threshold' failures it opens and short-" + "circuits for 'reset_s' seconds. Returns {state, " + "record}."), + input_schema=schema({ + "name": {"type": "string"}, + "actions": {"type": "array"}, + "threshold": {"type": "integer"}, + "reset_s": {"type": "number"}}, + required=["name", "actions"]), + handler=h.circuit_call, + annotations=SIDE_EFFECT_ONLY, + ), + ] + + def unattended_tools() -> List[MCPTool]: return [ MCPTool( @@ -3455,7 +3475,7 @@ def media_assert_tools() -> List[MCPTool]: agent_memory_tools, determinism_tools, observer_tools, sbom_tools, sharding_tools, data_quality_tools, i18n_tools, checkpoint_tools, set_of_marks_tools, screen_state_tools, - input_macro_tools, + input_macro_tools, resilience_tools, screen_record_tools, process_and_shell_tools, remote_desktop_tools, gamepad_tools, usb_passthrough_tools, assertion_tools, data_source_tools, diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index f5848641..8d603e51 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1173,6 +1173,12 @@ def input_sequence(steps): return {"log": _rs(steps)} +def circuit_call(name, actions, threshold=5, reset_s=30.0): + from je_auto_control.utils.executor.action_executor import _circuit_call + return _circuit_call(name, actions, threshold=int(threshold), + reset_s=float(reset_s)) + + def vlm_locate(description: str, screen_region: Optional[List[int]] = None, model: Optional[str] = None) -> Optional[List[int]]: diff --git a/je_auto_control/utils/resilience/__init__.py b/je_auto_control/utils/resilience/__init__.py new file mode 100644 index 00000000..8276231e --- /dev/null +++ b/je_auto_control/utils/resilience/__init__.py @@ -0,0 +1,6 @@ +"""Resilience primitives: retry-with-backoff and a circuit breaker.""" +from je_auto_control.utils.resilience.resilience import ( + CircuitBreaker, CircuitOpenError, RetryPolicy, retry_call, +) + +__all__ = ["CircuitBreaker", "CircuitOpenError", "RetryPolicy", "retry_call"] diff --git a/je_auto_control/utils/resilience/resilience.py b/je_auto_control/utils/resilience/resilience.py new file mode 100644 index 00000000..3af19fa4 --- /dev/null +++ b/je_auto_control/utils/resilience/resilience.py @@ -0,0 +1,104 @@ +"""Resilience primitives — retry-with-backoff and a circuit breaker. + +Two reusable wrappers around any callable (or, via the executor commands, +any action list): + +* :class:`RetryPolicy` retries on configured exceptions with exponential + backoff (and optional cap) — for transient failures. +* :class:`CircuitBreaker` opens after N consecutive failures and + short-circuits calls (raising :class:`CircuitOpenError`) until a reset + timeout elapses, then half-opens for a trial — so a downed dependency + isn't hammered by a retry storm. + +Both take injectable ``sleep`` / ``clock`` callables, so behaviour is +unit-tested deterministically with a fake clock. Pure standard library; +imports no ``PySide6``. +""" +import time +from dataclasses import dataclass +from typing import Any, Callable, Optional, Tuple, Type + + +class CircuitOpenError(RuntimeError): + """Raised by :class:`CircuitBreaker` when the circuit is open.""" + + +@dataclass +class RetryPolicy: + """Retry a callable on failure with exponential backoff.""" + max_attempts: int = 3 + backoff: float = 0.1 + multiplier: float = 2.0 + max_backoff: Optional[float] = None + exceptions: Tuple[Type[BaseException], ...] = (Exception,) + + def run(self, func: Callable[..., Any], *args: Any, + sleep: Optional[Callable[[float], None]] = None, + **kwargs: Any) -> Any: + """Call ``func`` until it succeeds or attempts are exhausted.""" + sleeper = sleep or time.sleep + attempts = max(1, int(self.max_attempts)) + delay = self.backoff + last_error: Optional[BaseException] = None + for attempt in range(1, attempts + 1): + try: + return func(*args, **kwargs) + except self.exceptions as error: + last_error = error + if attempt >= attempts: + break + if delay > 0: + sleeper(delay) + delay *= self.multiplier + if self.max_backoff is not None: + delay = min(delay, self.max_backoff) + raise last_error # type: ignore[misc] + + +def retry_call(func: Callable[..., Any], *args: Any, max_attempts: int = 3, + backoff: float = 0.1, **kwargs: Any) -> Any: + """Convenience: run ``func`` under a default :class:`RetryPolicy`.""" + policy = RetryPolicy(max_attempts=int(max_attempts), backoff=float(backoff)) + return policy.run(func, *args, **kwargs) + + +class CircuitBreaker: + """Open after consecutive failures; short-circuit until a reset timeout.""" + + def __init__(self, failure_threshold: int = 5, reset_timeout: float = 30.0, + clock: Optional[Callable[[], float]] = None) -> None: + self._threshold = max(1, int(failure_threshold)) + self._reset = float(reset_timeout) + self._clock = clock or time.monotonic + self._failures = 0 + self._opened_at: Optional[float] = None + + @property + def state(self) -> str: + """``closed`` / ``open`` / ``half_open``.""" + if self._opened_at is None: + return "closed" + if self._clock() - self._opened_at >= self._reset: + return "half_open" + return "open" + + def call(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any: + """Invoke ``func`` unless the circuit is open.""" + if self.state == "open": + raise CircuitOpenError("circuit is open") + try: + result = func(*args, **kwargs) + except Exception: + self._record_failure() + raise + self._record_success() + return result + + def _record_failure(self) -> None: + self._failures += 1 + if self._failures >= self._threshold: + self._opened_at = self._clock() + + def _record_success(self) -> None: + self._failures = 0 + self._opened_at = None diff --git a/test/unit_test/headless/test_resilience_batch.py b/test/unit_test/headless/test_resilience_batch.py new file mode 100644 index 00000000..bd793dc4 --- /dev/null +++ b/test/unit_test/headless/test_resilience_batch.py @@ -0,0 +1,94 @@ +"""Headless tests for resilience primitives: retry-with-backoff and a +circuit breaker. Injected clock/sleep make timing deterministic. Pure +stdlib; no Qt imports.""" +import pytest + +import je_auto_control as ac +from je_auto_control.utils.resilience import ( + CircuitBreaker, CircuitOpenError, RetryPolicy, retry_call) + + +class _Clock: + def __init__(self): + self.now = 0.0 + + def __call__(self): + return self.now + + +def test_retry_succeeds_after_transient_failures(): + calls = {"n": 0} + slept = [] + + def flaky(): + calls["n"] += 1 + if calls["n"] < 3: + raise ValueError("transient") + return "ok" + + policy = RetryPolicy(max_attempts=5, backoff=0.1, multiplier=2.0) + assert policy.run(flaky, sleep=slept.append) == "ok" + assert calls["n"] == 3 + assert slept == [pytest.approx(0.1), pytest.approx(0.2)] # backoff grows + + +def test_retry_exhausts_and_reraises(): + def always_fail(): + raise KeyError("nope") + + with pytest.raises(KeyError): + RetryPolicy(max_attempts=2, backoff=0).run(always_fail, + sleep=lambda s: None) + + +def test_retry_call_convenience(): + assert retry_call(lambda: 42) == 42 + + +def test_circuit_breaker_opens_and_resets(): + clock = _Clock() + breaker = CircuitBreaker(failure_threshold=2, reset_timeout=10.0, + clock=clock) + + def boom(): + raise RuntimeError("down") + + for _ in range(2): + with pytest.raises(RuntimeError): + breaker.call(boom) + assert breaker.state == "open" + # while open, calls short-circuit without invoking func + with pytest.raises(CircuitOpenError): + breaker.call(boom) + # after the reset timeout it half-opens; a success closes it + clock.now = 10.0 + assert breaker.state == "half_open" + assert breaker.call(lambda: "ok") == "ok" + assert breaker.state == "closed" + + +# --- wiring --------------------------------------------------------------- + +def test_executor_wiring(): + # circuit breaker runs a trivially-succeeding action and stays closed + rec = ac.execute_action([["AC_circuit_call", { + "name": "t", "actions": [["AC_seed_everything", {"seed": 1}]]}]]) + assert any("'state': 'closed'" in str(v) for v in rec.values()) + assert "AC_circuit_call" in ac.executor.known_commands() + + +def test_mcp_and_builder_wiring(): + from je_auto_control.utils.mcp_server.tools import ( + build_default_tool_registry) + names = {t.name for t in build_default_tool_registry()} + assert "ac_circuit_call" in names + from je_auto_control.gui.script_builder.command_schema import _build_specs + cmds = {s.command for s in _build_specs()} + assert "AC_circuit_call" in cmds + + +def test_facade_exports(): + for attr in ("RetryPolicy", "CircuitBreaker", "CircuitOpenError", + "retry_call"): + assert hasattr(ac, attr) + assert attr in ac.__all__