Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion openhands/automation/capabilities_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,12 @@ async def _custom_sources(org_id: uuid.UUID, session: AsyncSession) -> list[str]

def _cron_interval_floor() -> int:
"""Shortest interval between fires the scheduler can actually honour."""
return max(POLL_INTERVAL_SECONDS, get_config().service.scheduler_interval_seconds)
settings = get_config().service
return max(
POLL_INTERVAL_SECONDS,
settings.scheduler_interval_seconds,
settings.min_cron_interval_seconds,
)


def _cron_errors(trigger: CronTrigger) -> list[DraftValidationError]:
Expand Down
5 changes: 4 additions & 1 deletion openhands/automation/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
from typing import Literal
from urllib.parse import urlparse

from pydantic import model_validator
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings


Expand Down Expand Up @@ -327,6 +327,8 @@ class ServiceSettings(BaseSettings):
AUTOMATION_WORKSPACE_BASE: Base workspace directory (local mode default)

# Background workers
AUTOMATION_MIN_CRON_INTERVAL_SECONDS: Minimum allowed gap between cron
fire times (default: 0, disabled)
AUTOMATION_SCHEDULER_INTERVAL_SECONDS: Scheduler poll interval (default: 60)
AUTOMATION_SCHEDULER_BATCH_SIZE: Scheduler batch size (default: 50)
AUTOMATION_DISPATCHER_INTERVAL_SECONDS: Dispatcher poll interval (default: 10)
Expand Down Expand Up @@ -412,6 +414,7 @@ class ServiceSettings(BaseSettings):
# Background workers
scheduler_interval_seconds: int = 60
scheduler_batch_size: int = 50
min_cron_interval_seconds: int = Field(default=0, ge=0)
dispatcher_interval_seconds: int = 10
dispatcher_batch_size: int = 10
watchdog_interval_seconds: int = 60
Expand Down
13 changes: 12 additions & 1 deletion openhands/automation/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from openhands.automation.constants import MODEL_PROFILE_PATTERN
from openhands.automation.utils.cron import (
min_interval_seconds,
validate_cron_schedule as validate_cron_schedule_value,
validate_timezone_name,
)
Expand Down Expand Up @@ -42,7 +43,17 @@ class CronTrigger(BaseModel):
@field_validator("schedule")
@classmethod
def validate_cron_schedule(cls, v: str) -> str:
return validate_cron_schedule_value(v)
schedule = validate_cron_schedule_value(v)

# Import lazily to keep the schema/config dependency one-way at import time.
from openhands.automation.config import get_config

floor = get_config().service.min_cron_interval_seconds
if floor > 0 and min_interval_seconds(schedule) < floor:
raise ValueError(
f"Cron expression must have at least {floor} seconds between fires"
)
return schedule

@field_validator("timezone")
@classmethod
Expand Down
11 changes: 11 additions & 0 deletions tests/test_capabilities_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,17 @@ async def test_advertised_cron_floor_follows_the_scheduler_interval(

assert response.json()["triggers"]["cron"]["minIntervalSeconds"] == 300

async def test_advertises_a_higher_configured_cron_floor(
self, async_client, ready_deployment, monkeypatch
):
"""Clients see the same deployment floor enforced by trigger validation."""
monkeypatch.setenv("AUTOMATION_MIN_CRON_INTERVAL_SECONDS", "900")
clear_config_cache()

response = await async_client.get(CAPABILITIES_URL)

assert response.json()["triggers"]["cron"]["minIntervalSeconds"] == 900


class TestValidateDraft:
"""Tests for POST /v1/validate endpoint."""
Expand Down
19 changes: 19 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@

import warnings

import pytest

from openhands.automation.config import (
HttpSettings,
LogSettings,
SandboxSettings,
ServiceSettings,
Settings,
clear_config_cache,
get_config,
Expand Down Expand Up @@ -57,6 +60,22 @@ def test_resolve_caps_stored_timeout_to_configured_max(self):
assert resolve_automation_timeout_seconds(max_duration + 600) == max_duration


class TestCronIntervalSettings:
def test_default_disables_minimum_cron_interval(self, monkeypatch):
monkeypatch.delenv("AUTOMATION_MIN_CRON_INTERVAL_SECONDS", raising=False)

assert ServiceSettings().min_cron_interval_seconds == 0

def test_loads_minimum_cron_interval_from_environment(self, monkeypatch):
monkeypatch.setenv("AUTOMATION_MIN_CRON_INTERVAL_SECONDS", "300")

assert ServiceSettings().min_cron_interval_seconds == 300

def test_rejects_negative_minimum_cron_interval(self):
with pytest.raises(ValueError, match="greater than or equal to 0"):
ServiceSettings(min_cron_interval_seconds=-1)


class TestBasePath:
"""Verify base_path is derived from base_url path + /api/automation."""

Expand Down
24 changes: 24 additions & 0 deletions tests/test_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import pytest
from pydantic import ValidationError

from openhands.automation.config import clear_config_cache
from openhands.automation.schemas import (
AutomationResponse,
AutomationRunResponse,
Expand Down Expand Up @@ -49,6 +50,9 @@ def test_non_utc_aware_datetime_is_unchanged(self):


class TestCronTriggerValidation:
def teardown_method(self):
clear_config_cache()

def test_accepts_valid_cron_and_timezone(self):
trigger = CronTrigger(schedule="0 9 * * *", timezone="America/New_York")

Expand All @@ -65,6 +69,26 @@ def test_rejects_invalid_timezone(self):
with pytest.raises(ValidationError, match="Invalid timezone"):
CronTrigger(schedule="0 9 * * *", timezone="Not/A_Timezone")

def test_default_allows_any_valid_interval(self):
trigger = CronTrigger(schedule="* * * * *")

assert trigger.schedule == "* * * * *"

def test_rejects_schedule_below_configured_interval(self, monkeypatch):
monkeypatch.setenv("AUTOMATION_MIN_CRON_INTERVAL_SECONDS", "300")
clear_config_cache()

with pytest.raises(ValidationError, match="at least 300 seconds"):
CronTrigger(schedule="* * * * *")

def test_accepts_schedule_at_configured_interval(self, monkeypatch):
monkeypatch.setenv("AUTOMATION_MIN_CRON_INTERVAL_SECONDS", "300")
clear_config_cache()

trigger = CronTrigger(schedule="*/5 * * * *")

assert trigger.schedule == "*/5 * * * *"


class TestAutomationRunResponseUtcSerialisation:
"""AutomationRunResponse must include a UTC offset in all datetime fields."""
Expand Down
Loading