feat: auto-disable for consecutively failing automations [PLTF-3374] - #397
feat: auto-disable for consecutively failing automations [PLTF-3374]#397dylan-openhands wants to merge 4 commits into
Conversation
|
👋 This PR needs a couple of things fixed before OpenHands can review it:
Push an update once this is addressed and this check re-runs automatically. This is an automated check - no AI was used to generate this comment. |
917880a to
d0a2bfb
Compare
Mutation review of the new testsI hand-wrote a small set of mutants against the diff and ran them with the suite ( Controls — these died, so the core is genuinely pinned
What makes these land: the tests assert the boolean outcome and Survivors (gaps)
1. The watchdog wiring (#3) is unasserted — and it's the motivating case
if terminal:
marked += 1
pass # await maybe_disable_unhealthy_automation_after_run(...) removed — suite still passesThis is exactly the "an automation that only ever times out ran forever" scenario the PR is meant to fix: the run is marked A test that closes it (drives a stale @pytest.mark.asyncio
async def test_watchdog_timeout_disables_a_chronically_failing_automation(
async_session_factory, mock_settings, monkeypatch
):
from openhands.automation.config import clear_config_cache
from openhands.automation.utils.agent_server import VerificationResult
from openhands.automation.watchdog import mark_stale_runs
monkeypatch.setenv("AUTOMATION_CONSECUTIVE_FAILURE_DISABLE_THRESHOLD", "10")
monkeypatch.setenv("AUTOMATION_CONSECUTIVE_FAILURE_DISABLE_WINDOW_HOURS", "24")
clear_config_cache()
now = utcnow()
async with async_session_factory() as session:
automation = Automation(
user_id=TEST_USER_ID, org_id=TEST_ORG_ID, name="Only ever times out",
trigger={"type": "cron", "schedule": "* * * * *", "timezone": "UTC"},
tarball_path="s3://bucket/code.tar.gz", entrypoint="uv run main.py",
enabled=True, timeout=60,
)
session.add(automation)
await session.flush()
for i in range(9): # 9 prior failures in-window
session.add(AutomationRun(
automation_id=automation.id, status=AutomationRunStatus.FAILED,
created_at=now - timedelta(hours=i + 1),
completed_at=now - timedelta(hours=i + 1),
))
session.add(AutomationRun( # 10th run: stale RUNNING, watchdog marks it FAILED
automation_id=automation.id, status=AutomationRunStatus.RUNNING,
sandbox_id="sb-timeout",
started_at=now - timedelta(minutes=5),
timeout_at=now - timedelta(minutes=1),
))
await session.commit()
automation_id = automation.id
mock_backend = _create_mock_backend(
VerificationResult(verified=False, error="Sandbox not available")
)
with patch("openhands.automation.watchdog.get_backend", return_value=mock_backend):
marked = await mark_stale_runs(async_session_factory, mock_settings)
assert marked == 1
async with async_session_factory() as session:
automation = await session.get(Automation, automation_id)
assert automation.enabled is False
assert automation.disabled_detail["rule"] == "consecutive_failures"
clear_config_cache()Verified: this passes on the branch as-is, and fails when the 2. The re-enable boundary clamp on the success window is unassertedIn since = utcnow() - timedelta(hours=window_hours)
if boundary is not None:
since = max(since, ensure_utc(boundary)) # <-- deleting this line survives
async def test_success_before_re_enable_does_not_block_re_disable(
sqlite_session_factory, auto_disable_config
):
auto_disable_config() # threshold 10, window 24h
async with sqlite_session_factory() as session:
automation = await _create_automation(session)
boundary = utcnow() - timedelta(hours=2)
session.add(AutomationDisableEvent(
automation_id=automation.id, run_id=None, reason="previous pause",
detail={}, source="consecutive_failures", created_at=boundary,
))
await session.flush()
# Success from *before* the pause, still inside the 24h window.
await _add_run_at(
session, automation,
status=AutomationRunStatus.COMPLETED, at=utcnow() - timedelta(hours=3),
)
# 10 fresh failures after re-enable (after the boundary).
await _add_failures(session, automation, count=10, every=timedelta(minutes=10))
assert await maybe_disable_unhealthy_automation(session, automation.id) is TrueVerified: passes on the branch, fails when 3.
|
|
🤖 OpenHands is reviewing this PR. Head commit: This comment was posted by an AI agent (OpenHands). |
|
🤖 OpenHands is reviewing this PR. Head commit: This comment was posted by an AI agent (OpenHands). |
|
This comment was posted by an AI agent (OpenHands). |
1 similar comment
|
This comment was posted by an AI agent (OpenHands). |
Human Explanation
HUMAN: Broken automations run forever and recently we found that ~30 automations that have never succeeded account for almost 3,000 sandbox creations per day in SaaS.
This PR adds a default-off rule to disable automations that have failed
Xtimes in a row overYwindow. I intend to configure those values in SaaS for 10 times in a 24 hour windowTested locally: full unit suite passes (1442). New coverage asserts the rule is off unless the env var is set
AI Description
Off by default. One env var turns it on:
AUTOMATION_CONSECUTIVE_FAILURE_DISABLE_THRESHOLDAUTOMATION_CONSECUTIVE_FAILURE_DISABLE_WINDOW_HOURSThe window is the outage guard. A run count alone can't tell "broken forever" from "GitHub was down" — a 5-minute cron racks up ~96 failures in an 8h outage. Asking "has anything succeeded lately?" answers that directly and is indifferent to how often the automation runs. Set it to 48 or 72 to ride out longer outages.
Known gap: a flapping automation that succeeds occasionally never fires, since
LIMIT Nwon't come back all-failed. A failure-rate rule would catch it; it needed an unboundedcount/sumaggregate over each automation's full history and a new composite index to stay fast, which wasn't worth a migration for the secondary case. Deliberately left out.Surfacing
Reuses what's already on
AutomationResponse.disabled_reasonis now a plain sentence — "Paused automatically: the last 10 runs all failed and nothing has succeeded in 24 hours." — anddisabled_detail.ruletells the UI which rule fired. Queued runs already get SKIPPED with a reason, and theautomation_disable_eventsaudit row is unchanged.Files
utils/unhealthy.py— both rules now evaluate from one fetch of recent terminal runs. Counting is scoped to runs after the last disable event, so re-enabling doesn't instantly re-disable on stale history.config.py— the two knobs above.dispatcher.py— drops thestatus_detail is not Noneguard (MVP: Automation service with CRUD API, cron scheduler, and V1 API executor #2).watchdog.py— evaluates the rules after a stale run goes terminal (Migrate automation source code from deploy repo #3).Note that #2 and #3 are not behind the flag. With the threshold unset they only mean the pre-existing permanent rule is evaluated where it always should have been.
Deploying
Ship the tag with the env var absent, confirm it's inert, then we will turn it on as a separate chart change
extra="forbid"), so setting it early is harmless.AUTOMATION_CONSECUTIVE_FAILURE_DISABLE_THRESHOLD=""raisesValidationErrorand the pod won't boot. Guard the key, not the value:0also parses and means off;""never does.Verification
Full suite: 1442 passed, 49 skipped. 16 tests in
test_unhealthy_automations.py, including two that pin the behavior that motivated the design:test_high_frequency_automation_broken_for_days_is_disabled— a 5-min cron with 200 straight failures spans under an hour across its last 10 runs. An earlier span-based version of this rule let it run forever; this catches it.test_a_longer_window_rides_out_a_longer_outage— the same 30h outage disables at24and declines at72.Plus: off-by-default, opt-in via the env var, outage burst declines, a recent success breaks the streak, and re-enable clears prior history.
Local note: testcontainers can't start on Colima (ryuk bind-mounts the docker socket, virtiofs won't create a socket node).
TESTCONTAINERS_RYUK_DISABLED=trueworks around it; containers still clean up via conftest's context manager. CI is unaffected.