Skip to content

Commit dbf0ebb

Browse files
authored
Merge pull request #436 from Integration-Automation/feat/retry-budget-batch
Add retry_budget: deadline + jitter over resilience retries
2 parents 2dd1b96 + c22ac51 commit dbf0ebb

11 files changed

Lines changed: 530 additions & 0 deletions

File tree

WHATS_NEW.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
## What's new (2026-06-26)
44

5+
### Retry Budget — Deadline + Jitter
6+
7+
Retry a flaky step bounded by a total time budget, with jittered backoff. Full reference: [`docs/source/Eng/doc/new_features/v209_features_doc.rst`](docs/source/Eng/doc/new_features/v209_features_doc.rst).
8+
9+
- **`RetryBudget` / `run_with_budget` / `backoff_delay` / `jittered_delay`** (`AC_retry_delay`, `AC_plan_retry_delays`): `resilience.RetryPolicy` retries a fixed attempt count with plain exponential backoff — it can't express a *wall-clock deadline* ("give up after 30 s total, however many attempts that is") or *jitter* (randomized backoff so retrying workers don't resynchronize into a thundering herd). `RetryBudget` adds both: bounded by `max_attempts` *and/or* `deadline_s`, `run_with_budget` honours whichever is hit first and never sleeps past the deadline; delays use capped exponential backoff with a selectable `full`/`equal`/`none` jitter strategy. The randomness (`uniform`), clock and sleeper are all injectable, so every delay and giveup decision is deterministic in tests. First feature of the ROUND-15 input-fidelity lane. No `PySide6`.
10+
511
### Live IME State for Safe CJK Entry
612

713
Wait for the input method to commit before reading a Japanese/Chinese/Korean field. Full reference: [`docs/source/Eng/doc/new_features/v208_features_doc.rst`](docs/source/Eng/doc/new_features/v208_features_doc.rst).
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
Retry Budget — Deadline + Jitter
2+
================================
3+
4+
:class:`resilience.RetryPolicy` retries a fixed number of attempts with plain
5+
exponential backoff. Two things it can't express are exactly what flaky,
6+
contended UI automation needs:
7+
8+
* a **wall-clock deadline** — "keep retrying, but give up after 30 s total",
9+
independent of how many attempts that takes; and
10+
* **jitter** — randomized backoff so many retrying workers don't resynchronize
11+
into a thundering herd.
12+
13+
``retry_budget`` adds both. :class:`RetryBudget` is bounded by ``max_attempts``
14+
*and / or* ``deadline_s``; :func:`run_with_budget` honours whichever is hit
15+
first and never sleeps past the deadline. Delays use capped exponential backoff
16+
with a selectable jitter strategy (``full`` / ``equal`` / ``none``). The
17+
randomness source (``uniform``), the clock and the sleeper are all injectable,
18+
so every delay and decision is deterministic in tests. Imports no ``PySide6``.
19+
20+
Headless API
21+
------------
22+
23+
.. code-block:: python
24+
25+
from je_auto_control import RetryBudget, run_with_budget
26+
27+
budget = RetryBudget(max_attempts=8, deadline_s=30.0,
28+
base_delay_s=0.2, max_delay_s=5.0)
29+
30+
# Retry the click until it lands, capped at 8 tries OR 30 seconds total
31+
run_with_budget(lambda: click_and_verify("Save"), budget)
32+
33+
``RetryBudget`` is bounded by attempts and / or a deadline — set either to
34+
``None`` to bound only by the other. :func:`backoff_delay` (pure, no jitter) and
35+
:meth:`RetryBudget.plan` give the delay schedule for inspection:
36+
37+
.. code-block:: python
38+
39+
RetryBudget(jitter="none").plan(4) # [0.1, 0.2, 0.4, 0.8]
40+
41+
For deterministic tests inject ``uniform`` / ``clock`` / ``sleep``:
42+
43+
.. code-block:: python
44+
45+
run_with_budget(flaky, budget, clock=fake_clock, sleep=fake_sleep,
46+
uniform=lambda lo, hi: lo) # always the low bound
47+
48+
Executor commands
49+
-----------------
50+
51+
``AC_retry_delay`` (``attempt`` / ``base`` / ``max_delay`` / ``multiplier`` /
52+
``jitter`` → ``{delay}``) and ``AC_plan_retry_delays`` (``attempts`` … →
53+
``{delays}``) expose the pure backoff schedule (``jitter`` defaults to ``none``
54+
for a deterministic result). They are the matching read-only ``ac_*`` MCP tools
55+
and Script Builder commands under **Flow**. :func:`run_with_budget` (which wraps
56+
a callable) is the Python-API surface.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
重試預算——截止時間 + 抖動
2+
==========================
3+
4+
:class:`resilience.RetryPolicy` 以固定次數搭配單純指數退避重試。有兩件它無法表達的事,正是不穩定、
5+
高競爭的 UI 自動化所需:
6+
7+
* **掛鐘截止時間**——「持續重試,但總共超過 30 秒就放棄」,與嘗試了幾次無關;以及
8+
* **抖動(jitter)**——隨機化退避,讓眾多重試中的工作者不會重新同步成驚群效應。
9+
10+
``retry_budget`` 兩者皆補上。:class:`RetryBudget` 由 ``max_attempts`` *與 / 或* ``deadline_s``
11+
界定;:func:`run_with_budget` 以先達到者為準,且絕不會睡過截止時間。延遲採用有上限的指數退避,
12+
搭配可選的抖動策略(``full`` / ``equal`` / ``none``)。隨機來源(``uniform``)、時鐘與睡眠器
13+
皆可注入,故每個延遲與決策在測試中都是確定的。不匯入 ``PySide6``。
14+
15+
無頭 API
16+
--------
17+
18+
.. code-block:: python
19+
20+
from je_auto_control import RetryBudget, run_with_budget
21+
22+
budget = RetryBudget(max_attempts=8, deadline_s=30.0,
23+
base_delay_s=0.2, max_delay_s=5.0)
24+
25+
# 重試點擊直到成功,上限為 8 次嘗試 或 總共 30 秒
26+
run_with_budget(lambda: click_and_verify("Save"), budget)
27+
28+
``RetryBudget`` 由嘗試次數與 / 或截止時間界定——把其一設為 ``None`` 即只以另一者界定。
29+
:func:`backoff_delay`(純函式,無抖動)與 :meth:`RetryBudget.plan` 提供延遲排程以供檢視:
30+
31+
.. code-block:: python
32+
33+
RetryBudget(jitter="none").plan(4) # [0.1, 0.2, 0.4, 0.8]
34+
35+
確定性測試可注入 ``uniform`` / ``clock`` / ``sleep``:
36+
37+
.. code-block:: python
38+
39+
run_with_budget(flaky, budget, clock=fake_clock, sleep=fake_sleep,
40+
uniform=lambda lo, hi: lo) # 永遠取下界
41+
42+
執行器指令
43+
----------
44+
45+
``AC_retry_delay``(``attempt`` / ``base`` / ``max_delay`` / ``multiplier`` /
46+
``jitter`` → ``{delay}``)與 ``AC_plan_retry_delays``(``attempts`` … →
47+
``{delays}``)暴露純退避排程(``jitter`` 預設為 ``none`` 以得確定結果)。皆以對應的唯讀
48+
``ac_*`` MCP 工具及 Script Builder 指令(位於 **Flow** 分類下)形式提供。
49+
:func:`run_with_budget`(包裹一個 callable)則是 Python API 介面。

je_auto_control/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,10 @@
115115
decode_conversion_mode, ime_state, is_composing,
116116
wait_for_composition_commit,
117117
)
118+
# Retry budget — deadline + jitter retries over a callable
119+
from je_auto_control.utils.retry_budget import (
120+
RetryBudget, backoff_delay, jittered_delay, run_with_budget,
121+
)
118122
# Rich clipboard formats — RTF + CSV/TSV codecs and Windows get / set
119123
from je_auto_control.utils.clipboard_rich_formats import (
120124
build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv,
@@ -1734,6 +1738,7 @@ def start_autocontrol_gui(*args, **kwargs):
17341738
"wait_for_lock", "classify_lock_transitions",
17351739
"ime_state", "is_composing", "wait_for_composition_commit",
17361740
"decode_conversion_mode",
1741+
"RetryBudget", "run_with_budget", "backoff_delay", "jittered_delay",
17371742
"build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows",
17381743
"set_clipboard_rtf", "get_clipboard_rtf",
17391744
"set_clipboard_csv", "get_clipboard_csv",

je_auto_control/gui/script_builder/command_schema.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4401,6 +4401,36 @@ def _add_work_queue_specs(specs: List[CommandSpec]) -> None:
44014401
),
44024402
description="Decode an IMM32 conversion bitmask into named flags.",
44034403
))
4404+
specs.append(CommandSpec(
4405+
"AC_retry_delay", "Flow", "Retry Backoff Delay",
4406+
fields=(
4407+
FieldSpec("attempt", FieldType.INT, default=1,
4408+
placeholder="1-based retry attempt"),
4409+
FieldSpec("base", FieldType.FLOAT, optional=True, default=0.1),
4410+
FieldSpec("max_delay", FieldType.FLOAT, optional=True,
4411+
default=5.0),
4412+
FieldSpec("multiplier", FieldType.FLOAT, optional=True,
4413+
default=2.0),
4414+
FieldSpec("jitter", FieldType.STRING, optional=True,
4415+
default="none", placeholder="none / full / equal"),
4416+
),
4417+
description="Capped exponential backoff delay before a retry attempt.",
4418+
))
4419+
specs.append(CommandSpec(
4420+
"AC_plan_retry_delays", "Flow", "Plan Retry Delays",
4421+
fields=(
4422+
FieldSpec("attempts", FieldType.INT, default=5,
4423+
placeholder="number of retries"),
4424+
FieldSpec("base", FieldType.FLOAT, optional=True, default=0.1),
4425+
FieldSpec("max_delay", FieldType.FLOAT, optional=True,
4426+
default=5.0),
4427+
FieldSpec("multiplier", FieldType.FLOAT, optional=True,
4428+
default=2.0),
4429+
FieldSpec("jitter", FieldType.STRING, optional=True,
4430+
default="none", placeholder="none / full / equal"),
4431+
),
4432+
description="The backoff delay schedule for the first N retries.",
4433+
))
44044434
specs.append(CommandSpec(
44054435
"AC_normalize_ext", "Shell", "Normalize Extension",
44064436
fields=(

je_auto_control/utils/executor/action_executor.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2738,6 +2738,29 @@ def _decode_conversion_mode(flags: Any) -> Dict[str, Any]:
27382738
return decode_conversion_mode(int(flags))
27392739

27402740

2741+
def _make_retry_budget(base: Any, max_delay: Any, multiplier: Any,
2742+
jitter: Any) -> Any:
2743+
"""Build a RetryBudget from executor scalars (helper for the adapters)."""
2744+
from je_auto_control.utils.retry_budget import RetryBudget
2745+
return RetryBudget(base_delay_s=float(base), max_delay_s=float(max_delay),
2746+
multiplier=float(multiplier), jitter=str(jitter))
2747+
2748+
2749+
def _retry_delay(attempt: Any, base: Any = 0.1, max_delay: Any = 5.0,
2750+
multiplier: Any = 2.0, jitter: Any = "none") -> Dict[str, Any]:
2751+
"""Adapter: the (jittered) backoff delay before a retry attempt (pure)."""
2752+
budget = _make_retry_budget(base, max_delay, multiplier, jitter)
2753+
return {"delay": float(budget.next_delay(int(attempt)))}
2754+
2755+
2756+
def _plan_retry_delays(attempts: Any, base: Any = 0.1, max_delay: Any = 5.0,
2757+
multiplier: Any = 2.0, jitter: Any = "none"
2758+
) -> Dict[str, Any]:
2759+
"""Adapter: the backoff delay schedule for the first N retries (pure)."""
2760+
budget = _make_retry_budget(base, max_delay, multiplier, jitter)
2761+
return {"delays": [float(d) for d in budget.plan(int(attempts))]}
2762+
2763+
27412764
def _normalize_ext(target: str) -> Dict[str, Any]:
27422765
"""Adapter: the lowercased extension of a path / bare ext (pure)."""
27432766
from je_auto_control.utils.file_assoc import normalize_ext
@@ -6760,6 +6783,8 @@ def __init__(self):
67606783
"AC_is_composing": _is_composing,
67616784
"AC_wait_for_composition_commit": _wait_for_composition_commit,
67626785
"AC_decode_conversion_mode": _decode_conversion_mode,
6786+
"AC_retry_delay": _retry_delay,
6787+
"AC_plan_retry_delays": _plan_retry_delays,
67636788
"AC_normalize_ext": _normalize_ext,
67646789
"AC_file_association": _file_association,
67656790
"AC_get_control_text": _get_control_text,

je_auto_control/utils/mcp_server/tools/_factories.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1769,6 +1769,34 @@ def smart_wait_tools() -> List[MCPTool]:
17691769
handler=h.wait_for_process,
17701770
annotations=READ_ONLY,
17711771
),
1772+
MCPTool(
1773+
name="ac_retry_delay",
1774+
description=("Capped exponential backoff delay (seconds) before a "
1775+
"given 1-based retry 'attempt'. 'jitter' is none / "
1776+
"full / equal (default none). Returns {delay}."),
1777+
input_schema=schema({"attempt": {"type": "integer"},
1778+
"base": {"type": "number"},
1779+
"max_delay": {"type": "number"},
1780+
"multiplier": {"type": "number"},
1781+
"jitter": {"type": "string"}},
1782+
required=["attempt"]),
1783+
handler=h.retry_delay,
1784+
annotations=READ_ONLY,
1785+
),
1786+
MCPTool(
1787+
name="ac_plan_retry_delays",
1788+
description=("The backoff delay schedule (seconds) for the first "
1789+
"'attempts' retries. 'jitter' none / full / equal "
1790+
"(default none). Returns {delays}."),
1791+
input_schema=schema({"attempts": {"type": "integer"},
1792+
"base": {"type": "number"},
1793+
"max_delay": {"type": "number"},
1794+
"multiplier": {"type": "number"},
1795+
"jitter": {"type": "string"}},
1796+
required=["attempts"]),
1797+
handler=h.plan_retry_delays,
1798+
annotations=READ_ONLY,
1799+
),
17721800
]
17731801

17741802

je_auto_control/utils/mcp_server/tools/_handlers.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -668,6 +668,20 @@ def decode_conversion_mode(flags):
668668
return _decode_conversion_mode(flags)
669669

670670

671+
def retry_delay(attempt, base=0.1, max_delay=5.0, multiplier=2.0,
672+
jitter="none"):
673+
from je_auto_control.utils.executor.action_executor import _retry_delay
674+
return _retry_delay(attempt, base, max_delay, multiplier, jitter)
675+
676+
677+
def plan_retry_delays(attempts, base=0.1, max_delay=5.0, multiplier=2.0,
678+
jitter="none"):
679+
from je_auto_control.utils.executor.action_executor import (
680+
_plan_retry_delays,
681+
)
682+
return _plan_retry_delays(attempts, base, max_delay, multiplier, jitter)
683+
684+
671685
def normalize_ext(target):
672686
from je_auto_control.utils.executor.action_executor import _normalize_ext
673687
return _normalize_ext(target)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""Retry budget: bound retries by a wall-clock deadline and full jitter."""
2+
from je_auto_control.utils.retry_budget.retry_budget import (
3+
JITTER_EQUAL, JITTER_FULL, JITTER_NONE, RetryBudget, backoff_delay,
4+
jittered_delay, run_with_budget,
5+
)
6+
7+
__all__ = [
8+
"RetryBudget", "run_with_budget", "backoff_delay", "jittered_delay",
9+
"JITTER_FULL", "JITTER_EQUAL", "JITTER_NONE",
10+
]

0 commit comments

Comments
 (0)