atlas-iac/testing/tests/test_suite_deadlines.py

123 lines
5.9 KiB
Python
Raw Normal View History

"""Explicit long budgets share one deadline across routing, passes and progress."""
import json
import threading
from pathlib import Path
import pytest
import suite_backends
import suite_jobs
import suite_multipass
import suite_recovery
from suite_contract import MAX_JOB_SECONDS, Problem, TIMEOUT, preflight, validate_request
from test_suite_multipass import install_backend, natural, request
@pytest.mark.parametrize("seconds", [1801, 2700, 3600, 7200])
def test_explicit_long_budget_matches_published_schema(seconds):
"""Admission accepts opt-in durations; omitted limits retain their old default."""
source = request(7)
assert source["execution"]["max_seconds"] == TIMEOUT == 1800
source["execution"]["max_seconds"] = seconds
source = validate_request(source, ["claude"])
assert preflight(source)["provider"] == "claude"
schema = json.loads((Path(__file__).resolve().parents[2] /
"docs/contracts/suite_planning_request.schema.json").read_text())
limit = schema["properties"]["execution"]["properties"]["max_seconds"]
assert limit["maximum"] == MAX_JOB_SECONDS == 7200 and limit["default"] == TIMEOUT
@pytest.mark.parametrize("seconds", [7201, 7200.1, True, "7200", 9])
def test_invalid_budget_fails_before_inference(seconds):
source = request(7)
source["execution"]["max_seconds"] = seconds
with pytest.raises(Problem, match="invalid_timeout"):
validate_request(source, ["claude"])
def test_job_passes_1800_seconds_with_one_absolute_deadline(tmp_path, monkeypatch):
"""Advance synthetic time through a complete five-pass job beyond the old cap."""
source = request(7)
source["execution"]["max_seconds"] = 3600
family = natural(source, [[c["alias"] for c in source["cases"]]])
install_backend(monkeypatch, source, family)
original = suite_backends.claude_generate
clock, allocations, deadlines = [100.0], [], []
monkeypatch.setattr(suite_jobs.time, "monotonic", lambda: clock[0])
def routing(_):
clock[0] += 10
def backend(value, cancel, **kwargs):
allocations.append(value["execution"]["max_seconds"])
deadlines.append(kwargs["job_deadline"])
result = original(value, cancel, **kwargs)
clock[0] += 500
return result
monkeypatch.setattr(suite_backends, "switchyard_decision", routing)
monkeypatch.setattr(suite_backends, "claude_generate", backend)
jobs = suite_jobs.Jobs(tmp_path / "jobs.sqlite")
selected = preflight(source)
job, _ = jobs.submit("owner", "long-budget", source, selected, "192.168.22.8", launch=False)
jobs.run(job["job_id"], "owner", source, selected, "192.168.22.8")
result = jobs.get(job["job_id"], "owner")
assert result["status"] == "completed" and result["wall_seconds"] == 2510
assert allocations == [1795, 1743.75, 1692.5, 1641.25, 1590]
assert deadlines == [3700] * 5
assert result["execution_progress"]["job_remaining_seconds"] == 1090
assert result["execution_progress"]["job_elapsed_seconds"] == 2510
assert result["execution"]["max_seconds"] == 3600
@pytest.mark.parametrize("provider_error", ["backend_timeout_or_unavailable", "timeout"])
def test_distinct_backend_timeout_is_not_reclassified(monkeypatch, provider_error):
"""Only the shared-deadline watchdog identifies job time exhaustion."""
source = request(7)
def fail(*_, **__):
raise Problem(provider_error, 504, failure_stage="subprocess")
monkeypatch.setattr(suite_backends, "claude_generate", fail)
monkeypatch.setattr(suite_multipass, "FALLBACK_ERRORS", set())
monkeypatch.setattr(suite_recovery, "MAX_ATTEMPTS", 1)
with pytest.raises(Problem, match="^(pass_provider_transient_failure|pass_timeout)$"):
suite_multipass.generate(source, preflight(source), threading.Event(), "192.168.22.8")
def test_routing_consumes_job_budget_and_stops_before_provider(tmp_path, monkeypatch):
source = request(7)
source["execution"]["max_seconds"] = 10
clock = [100.0]
monkeypatch.setattr(suite_jobs.time, "monotonic", lambda: clock[0])
monkeypatch.setattr(suite_backends, "switchyard_decision", lambda _: clock.__setitem__(0, 111))
monkeypatch.setattr(suite_backends, "claude_generate", lambda *a, **k: pytest.fail("provider launched"))
jobs = suite_jobs.Jobs(tmp_path / "jobs.sqlite")
selected = preflight(source)
job, _ = jobs.submit("owner", "routing-budget", source, selected, "192.168.22.8", launch=False)
jobs.run(job["job_id"], "owner", source, selected, "192.168.22.8")
result = jobs.get(job["job_id"], "owner")
assert result["error"]["code"] == "job_time_budget_exhausted"
assert result["error"]["details"]["failure_stage"] == "routing"
def test_watchdog_job_expiry_reaches_status_with_zero_remaining(tmp_path, monkeypatch):
"""The backend's shared-deadline error survives orchestration and persistence."""
source = request(7)
source["execution"]["max_seconds"] = 3600
clock = [100.0]
monkeypatch.setattr(suite_jobs.time, "monotonic", lambda: clock[0])
monkeypatch.setattr(suite_backends, "switchyard_decision", lambda _: None)
def expired(value, cancel, *, invocation, progress, job_deadline):
assert value["execution"]["max_seconds"] == 1800
clock[0] = job_deadline
raise Problem("job_time_budget_exhausted", 504, failure_stage="subprocess")
monkeypatch.setattr(suite_backends, "claude_generate", expired)
jobs = suite_jobs.Jobs(tmp_path / "jobs.sqlite")
selected = preflight(source)
job, _ = jobs.submit("owner", "watchdog-budget", source, selected, "192.168.22.8", launch=False)
jobs.run(job["job_id"], "owner", source, selected, "192.168.22.8")
result = jobs.get(job["job_id"], "owner")
assert result["status"] == "failed" and not jobs.results
assert result["error"]["code"] == "job_time_budget_exhausted"
assert result["error"]["details"]["failure_stage"] == "subprocess"
assert result["execution_progress"]["job_remaining_seconds"] == 0