1- """Classify unhealthy automations and auto-disable chronic permanent failures."""
1+ """Circuit breaker that pauses automations which keep failing.
2+
3+ Two rules, both evaluated from a single fetch of the automation's most recent
4+ terminal runs:
5+
6+ 1. Consecutive *permanent* failures (bad key, revoked token). Unambiguous, so
7+ it trips fast and needs no time guard.
8+ 2. The last N runs all failed and nothing has succeeded in the configured
9+ window.
10+
11+ Rule 2's window is what keeps a provider outage from pausing healthy
12+ automations: a run count alone cannot tell "broken forever" from "the provider
13+ was down", because a five-minute cron racks up hundreds of failures during any
14+ outage. Asking whether anything succeeded recently answers that directly, and
15+ is indifferent to how often the automation runs.
16+ """
217
318from __future__ import annotations
419
520import logging
621import uuid
7- from collections .abc import Mapping
22+ from collections .abc import Mapping , Sequence
23+ from dataclasses import dataclass
24+ from datetime import datetime , timedelta
825from typing import Any
926
10- from sqlalchemy import CursorResult , select , update
27+ from sqlalchemy import CursorResult , func , select , update
1128from sqlalchemy .ext .asyncio import AsyncSession , async_sessionmaker
1229
1330from openhands .automation .config import get_config
1936 AutomationRunStatus ,
2037)
2138from openhands .automation .utils .run import skip_pending_runs_for_disabled_automation
22- from openhands .automation .utils .time import utcnow
39+ from openhands .automation .utils .time import ensure_utc , utcnow
2340
2441
2542logger = logging .getLogger (__name__ )
3047 AutomationRunStatus .FAILED ,
3148)
3249
50+ SOURCE_PERMANENT = "consecutive_permanent_failures"
51+ SOURCE_CONSECUTIVE = "consecutive_failures"
52+
3353
3454def _string_value (value : Any ) -> str | None :
3555 if isinstance (value , str ) and value :
@@ -66,71 +86,153 @@ def is_permanent_failure_detail(detail: Mapping[str, Any] | None) -> bool:
6686 return detail .get ("user_action" ) == "settings"
6787
6888
69- def _disabled_reason (detail : Mapping [str , Any ], count : int ) -> str :
70- formatted = _string_value (detail .get ("formatted_detail" ))
71- message = formatted or _string_value (detail .get ("detail" )) or "Permanent failure"
72- kind = _string_value (detail .get ("kind" )) or "permanent_failure"
73- return f"{ kind } : { message } (seen in { count } consecutive runs)"
89+ def _humanize (hours : float ) -> str :
90+ if hours < 48 :
91+ return f"{ round (hours )} hours"
92+ return f"{ round (hours / 24 )} days"
93+
94+
95+ @dataclass (frozen = True )
96+ class _Trip :
97+ """A breaker rule that fired, and everything needed to record it."""
7498
99+ reason : str
100+ source : str
101+ detail : dict [str , Any ]
102+ run_id : uuid .UUID | None
75103
76- async def get_consecutive_permanent_failure_count (
104+
105+ async def _history_boundary (
106+ session : AsyncSession , automation_id : uuid .UUID
107+ ) -> datetime | None :
108+ """Timestamp before which run history is ignored.
109+
110+ Re-enabling an automation leaves its old failures in scope, so without
111+ this the very next failure trips the breaker again immediately. A
112+ re-enable always follows a disable event, so the latest event is the
113+ boundary.
114+ """
115+ return await session .scalar (
116+ select (func .max (AutomationDisableEvent .created_at )).where (
117+ AutomationDisableEvent .automation_id == automation_id
118+ )
119+ )
120+
121+
122+ async def _recent_terminal_runs (
77123 session : AsyncSession ,
78124 automation_id : uuid .UUID ,
79- * ,
125+ boundary : datetime | None ,
80126 limit : int ,
81- ) -> tuple [int , dict [str , Any ] | None , uuid .UUID | None ]:
82- """Count latest consecutive terminal runs with permanent failure details."""
127+ ) -> Sequence [AutomationRun ]:
128+ """The automation's most recent terminal runs, newest first."""
129+ stmt = select (AutomationRun ).where (
130+ AutomationRun .automation_id == automation_id ,
131+ AutomationRun .status .in_ (TERMINAL_OUTCOME_STATUSES ),
132+ )
133+ if boundary is not None :
134+ stmt = stmt .where (AutomationRun .created_at > boundary )
83135 result = await session .execute (
84- select (AutomationRun )
136+ stmt .order_by (AutomationRun .created_at .desc ()).limit (limit )
137+ )
138+ return result .scalars ().all ()
139+
140+
141+ async def _succeeded_since (
142+ session : AsyncSession ,
143+ automation_id : uuid .UUID ,
144+ since : datetime ,
145+ ) -> bool :
146+ """Whether any run has completed since `since`. Stops at the first hit."""
147+ found = await session .scalar (
148+ select (AutomationRun .id )
85149 .where (
86150 AutomationRun .automation_id == automation_id ,
87- AutomationRun .status .in_ (TERMINAL_OUTCOME_STATUSES ),
151+ AutomationRun .status == AutomationRunStatus .COMPLETED ,
152+ AutomationRun .created_at > since ,
88153 )
89- .order_by (AutomationRun .created_at .desc ())
90- .limit (limit )
154+ .limit (1 )
91155 )
156+ return found is not None
92157
158+
159+ def _leading_failures (runs : Sequence [AutomationRun ]) -> int :
93160 count = 0
94- latest_detail : dict [str , Any ] | None = None
95- latest_run_id : uuid .UUID | None = None
96- for run in result .scalars ().all ():
97- detail = run .status_detail
98- if not is_permanent_failure_detail (detail ):
161+ for run in runs :
162+ if run .status != AutomationRunStatus .FAILED :
99163 break
100164 count += 1
101- if latest_detail is None :
102- latest_detail = detail
103- latest_run_id = run .id
104- return count , latest_detail , latest_run_id
165+ return count
105166
106167
107- async def maybe_disable_unhealthy_automation (
108- session : AsyncSession ,
109- automation_id : uuid .UUID ,
110- * ,
111- threshold : int | None = None ,
112- ) -> bool :
113- """Disable an automation once permanent failures reach the threshold."""
114- if threshold is None :
115- threshold = get_config ().service .failure_disable_threshold
116- if threshold <= 0 :
117- return False
168+ def _check_permanent (
169+ runs : Sequence [AutomationRun ],
170+ threshold : int ,
171+ ) -> _Trip | None :
172+ """Trip on consecutive terminal runs that all carry permanent-fault detail."""
173+ if threshold <= 0 or len (runs ) < threshold :
174+ return None
118175
119- count , latest_detail , latest_run_id = await get_consecutive_permanent_failure_count (
120- session ,
121- automation_id ,
122- limit = threshold ,
176+ count = 0
177+ for run in runs :
178+ if not is_permanent_failure_detail (run .status_detail ):
179+ break
180+ count += 1
181+ if count < threshold :
182+ return None
183+
184+ detail = runs [0 ].status_detail or {}
185+ formatted = _string_value (detail .get ("formatted_detail" ))
186+ message = formatted or _string_value (detail .get ("detail" )) or "unknown error"
187+ kind = _string_value (detail .get ("kind" )) or "permanent_failure"
188+ return _Trip (
189+ reason = (
190+ f"Paused automatically: { kind } — { message } . "
191+ f"This failed the last { count } runs and needs a configuration fix."
192+ ),
193+ source = SOURCE_PERMANENT ,
194+ detail = {
195+ "rule" : SOURCE_PERMANENT ,
196+ "threshold" : threshold ,
197+ "consecutive_permanent_failures" : count ,
198+ "status_detail" : detail ,
199+ },
200+ run_id = runs [0 ].id ,
123201 )
124- if count < threshold or latest_detail is None :
125- return False
126202
127- disabled_reason = _disabled_reason (latest_detail , count )
203+
204+ def _consecutive_trip (
205+ runs : Sequence [AutomationRun ],
206+ threshold : int ,
207+ window_hours : float ,
208+ ) -> _Trip :
209+ count = _leading_failures (runs )
210+ return _Trip (
211+ reason = (
212+ f"Paused automatically: the last { count } runs all failed and nothing "
213+ f"has succeeded in { _humanize (window_hours )} ."
214+ ),
215+ source = SOURCE_CONSECUTIVE ,
216+ detail = {
217+ "rule" : SOURCE_CONSECUTIVE ,
218+ "threshold" : threshold ,
219+ "consecutive_failures" : count ,
220+ "window_hours" : window_hours ,
221+ },
222+ run_id = runs [0 ].id ,
223+ )
224+
225+
226+ async def _apply_disable (
227+ session : AsyncSession ,
228+ automation_id : uuid .UUID ,
229+ trip : _Trip ,
230+ ) -> bool :
128231 disabled_detail = {
129- "reason" : disabled_reason ,
130- "threshold" : threshold ,
131- "consecutive_permanent_failures" : count ,
132- "run_id" : str (latest_run_id ) if latest_run_id else None ,
133- "status_detail" : latest_detail ,
232+ "reason" : trip .reason ,
233+ "source" : trip .source ,
234+ "run_id" : str (trip .run_id ) if trip .run_id else None ,
235+ ** trip .detail ,
134236 }
135237 disabled_at = utcnow ()
136238 result : CursorResult = await session .execute ( # type: ignore[assignment]
@@ -141,7 +243,7 @@ async def maybe_disable_unhealthy_automation(
141243 )
142244 .values (
143245 enabled = False ,
144- disabled_reason = disabled_reason ,
246+ disabled_reason = trip . reason ,
145247 disabled_detail = disabled_detail ,
146248 disabled_at = disabled_at ,
147249 )
@@ -152,7 +254,7 @@ async def maybe_disable_unhealthy_automation(
152254 await skip_pending_runs_for_disabled_automation (
153255 session ,
154256 automation_id ,
155- reason = disabled_reason ,
257+ reason = trip . reason ,
156258 disabled_detail = disabled_detail ,
157259 completed_at = disabled_at ,
158260 )
@@ -164,21 +266,67 @@ async def maybe_disable_unhealthy_automation(
164266 session .add (
165267 AutomationDisableEvent (
166268 automation_id = automation_id ,
167- run_id = latest_run_id ,
168- reason = disabled_reason ,
269+ run_id = trip . run_id ,
270+ reason = trip . reason ,
169271 detail = disabled_detail ,
170- source = "consecutive_permanent_failures" ,
272+ source = trip . source ,
171273 )
172274 )
173275
174276 logger .warning (
175- "Automation disabled after %s consecutive permanent failures" ,
176- count ,
177- extra = {"automation_id" : str (automation_id ), "run_id" : str (latest_run_id )},
277+ "Automation auto-disabled (%s): %s" ,
278+ trip .source ,
279+ trip .reason ,
280+ extra = {
281+ "automation_id" : str (automation_id ),
282+ "run_id" : str (trip .run_id ) if trip .run_id else None ,
283+ },
178284 )
179285 return True
180286
181287
288+ async def maybe_disable_unhealthy_automation (
289+ session : AsyncSession ,
290+ automation_id : uuid .UUID ,
291+ * ,
292+ threshold : int | None = None ,
293+ ) -> bool :
294+ """Disable an automation if either circuit breaker rule has tripped.
295+
296+ Args:
297+ threshold: Override for the permanent-failure rule only; the
298+ consecutive rule always reads its config values.
299+ """
300+ service = get_config ().service
301+ if threshold is None :
302+ threshold = service .failure_disable_threshold
303+ threshold = max (threshold , 0 )
304+ consecutive = max (service .consecutive_failure_disable_threshold or 0 , 0 )
305+ window_hours = service .consecutive_failure_disable_window_hours
306+
307+ limit = max (threshold , consecutive )
308+ if limit <= 0 :
309+ return False
310+
311+ boundary = await _history_boundary (session , automation_id )
312+ runs = await _recent_terminal_runs (session , automation_id , boundary , limit )
313+
314+ trip = _check_permanent (runs , threshold )
315+
316+ # The all-failed check is in memory, so the success lookup only runs for
317+ # automations that are already failing straight through.
318+ if trip is None and consecutive > 0 and _leading_failures (runs ) >= consecutive :
319+ since = utcnow () - timedelta (hours = window_hours )
320+ if boundary is not None :
321+ since = max (since , ensure_utc (boundary ))
322+ if not await _succeeded_since (session , automation_id , since ):
323+ trip = _consecutive_trip (runs , consecutive , window_hours )
324+
325+ if trip is None :
326+ return False
327+ return await _apply_disable (session , automation_id , trip )
328+
329+
182330async def maybe_disable_unhealthy_automation_after_run (
183331 session_factory : async_sessionmaker [AsyncSession ],
184332 automation_id : uuid .UUID ,
0 commit comments