feat(scheduler,conversations,api): cron and schedule in the gateway tz, job runs persisted

This commit is contained in:
hh
2026-09-01 23:13:17 +02:00
parent 39f2bc05d9
commit bac40ff268
10 changed files with 326 additions and 43 deletions
+36 -1
View File
@@ -21,6 +21,7 @@ from test_conversations import ScriptedClient, World
from beaver_gateway.core.conversation_store import rewrite_messages
from beaver_gateway.core.auth import TokenStore
from beaver_gateway.core.registry import McpRegistry
from beaver_gateway.core.scheduler import Job, JobRun, Scheduler
from beaver_gateway.core.transcript import build_entries
from beaver_gateway.frontends.admin import AdminFrontend
from beaver_gateway.frontends.admin.frontend import build_app as build_admin
@@ -96,7 +97,12 @@ class ToolClient(ScriptedClient):
class Api:
def __init__(self, world: World, memory_root: Path | None = None) -> None:
def __init__(
self,
world: World,
memory_root: Path | None = None,
scheduler: Scheduler | None = None,
) -> None:
self.world = world
self.store = TokenStore(world.db, bootstrap={"t": TOKEN})
self.runtime = GatewayRuntime(
@@ -112,6 +118,7 @@ class Api:
conversations=world.conversations,
bus=world.bus,
pool=world.pool,
scheduler=scheduler,
)
self.app = build_api(self.runtime, memory_root=memory_root)
self.http = AsyncClient(
@@ -146,6 +153,34 @@ async def seed_usage(world: World, rows: list[dict[str, Any]]) -> None:
await session.commit()
async def test_job_runs_come_from_the_db(world: World) -> None:
async def ping(_run: JobRun) -> None:
pass
scheduler = Scheduler(
conversations=world.conversations, jobs=[Job("ping", ping)], tz="Europe/Warsaw"
)
api = Api(world, scheduler=scheduler)
await scheduler.start()
res = await api.http.post("/jobs/ping/run", headers=HEADERS)
assert res.status_code == 202, res.text
for _ in range(100):
runs = (await api.get("/jobs/ping/runs", {"limit": 10}))["runs"]
if runs:
break
await asyncio.sleep(0.02)
assert [(r["trigger"], r["status"], r["payload"]) for r in runs] == [
("manual", "done", {})
]
jobs = await api.get("/jobs")
assert jobs["tz"] == "Europe/Warsaw"
assert jobs["jobs"][0]["run"]["id"] == runs[0]["id"]
assert jobs["jobs"][0]["run"]["finished_at"].endswith("+02:00")
missing = await api.http.get("/jobs/nope/runs", headers=HEADERS)
assert missing.status_code == 404
await scheduler.stop()
async def test_usage_groups_by_agent_day_and_model(world: World) -> None:
api = Api(world)
now = datetime.now(UTC).replace(tzinfo=None)
+4
View File
@@ -4,6 +4,7 @@ import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
import pytest
from claude_agent_sdk import (
@@ -629,6 +630,9 @@ async def test_schedule_without_scheduler_and_parse_at(world: World) -> None:
assert parse_at("2026-09-01T10:00:00+02:00") == datetime(
2026, 9, 1, 8, 0, tzinfo=UTC
)
assert parse_at("2026-09-01T10:00", ZoneInfo("Europe/Warsaw")) == datetime(
2026, 9, 1, 8, 0, tzinfo=UTC
)
delta = (parse_at("+15m") - datetime.now(UTC)).total_seconds()
assert 14 * 60 < delta <= 15 * 60
with pytest.raises(ValueError, match="Invalid isoformat"):
+100 -7
View File
@@ -1,9 +1,11 @@
import asyncio
import inspect
import os
import tempfile
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
import psycopg
import pytest
@@ -11,14 +13,12 @@ from httpx import ASGITransport, AsyncClient
from pgqueuer import PsycopgDriver, Queries
from test_conversations import World
from beaver_gateway.core.scheduler import Budget, Job, JobRun, Scheduler
from beaver_gateway.core.conversations import parse_at
from beaver_gateway.core.scheduler import Budget, Job, JobRun, Scheduler, next_run
from beaver_gateway.storage.models import RateLimit
DATABASE_URL = os.environ.get("TEST_DATABASE_URL")
pytestmark = pytest.mark.skipif(
not DATABASE_URL, reason="TEST_DATABASE_URL (postgres) is not set"
)
WARSAW = ZoneInfo("Europe/Warsaw")
class Pg:
@@ -37,6 +37,8 @@ class Pg:
@pytest.fixture
async def pg() -> Pg:
if not DATABASE_URL:
pytest.skip("TEST_DATABASE_URL (postgres) is not set")
handle = Pg()
queries = Queries(await handle.driver())
if await queries.has_table("pgqueuer"):
@@ -61,6 +63,8 @@ async def until(pred, timeout: float = 5.0) -> Any:
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
value = pred()
if inspect.isawaitable(value):
value = await value
if value:
return value
await asyncio.sleep(0.05)
@@ -68,16 +72,91 @@ async def until(pred, timeout: float = 5.0) -> Any:
raise AssertionError(msg)
def test_cron_is_read_in_the_gateway_tz() -> None:
summer = datetime(2026, 7, 1, 12, tzinfo=UTC)
assert next_run("0 4 * * *", WARSAW, summer) == datetime(2026, 7, 2, 2, tzinfo=UTC)
assert next_run("0 4 * * *", UTC, summer) == datetime(2026, 7, 2, 4, tzinfo=UTC)
before_fallback = datetime(2026, 10, 24, 12, tzinfo=UTC)
assert next_run("0 4 * * *", WARSAW, before_fallback) == datetime(
2026, 10, 25, 3, tzinfo=UTC
)
during_fallback = datetime(2026, 10, 25, 1, 30, tzinfo=UTC)
assert next_run("0 4 * * *", WARSAW, during_fallback) == datetime(
2026, 10, 25, 3, tzinfo=UTC
)
assert next_run("*/15 * * * *", WARSAW, during_fallback) == datetime(
2026, 10, 25, 1, 45, tzinfo=UTC
)
soon = next_run("* * * * *", WARSAW)
assert soon.tzinfo is UTC
assert timedelta(0) < soon - datetime.now(UTC) <= timedelta(minutes=1)
def test_parse_at_reads_naive_iso_in_tz() -> None:
assert parse_at("2026-09-01T10:00", WARSAW) == datetime(
2026, 9, 1, 8, 0, tzinfo=UTC
)
assert parse_at("2026-12-01T10:00", WARSAW) == datetime(
2026, 12, 1, 9, 0, tzinfo=UTC
)
assert parse_at("2026-09-01T10:00:00+02:00", WARSAW) == datetime(
2026, 9, 1, 8, 0, tzinfo=UTC
)
assert parse_at("2026-09-01T10:00") == datetime(2026, 9, 1, 10, 0, tzinfo=UTC)
async def test_runs_are_recorded_without_postgres(world: World) -> None:
async def ok(run: JobRun) -> None:
run.payload["seen"] = True
async def boom(_run: JobRun) -> None:
msg = "no network"
raise ConnectionError(msg)
scheduler = Scheduler(
conversations=world.conversations,
jobs=[Job("ok", ok, webhook=True), Job("boom", boom)],
tz="Europe/Warsaw",
)
await scheduler.start()
await scheduler.hook("ok", {"stack": "x"})
await scheduler.trigger(scheduler.job("boom"))
await until(lambda: len(scheduler._runs) == 2) # noqa: SLF001
runs = await scheduler.runs("boom")
assert [r["status"] for r in runs] == ["failed"]
assert runs[0]["error"].startswith("ConnectionError: no network\n")
assert runs[0]["trigger"] == "manual"
assert runs[0]["started_at"] <= runs[0]["finished_at"]
assert runs[0]["started_at"].endswith("+02:00")
ok_runs = await scheduler.runs("ok")
assert ok_runs[0]["payload"] == {"stack": "x", "seen": True}
assert ok_runs[0]["trigger"] == "webhook"
await scheduler.hook("ok", {"stack": "y"})
await until(lambda: scheduler._runs["ok"]["payload"].get("stack") == "y") # noqa: SLF001
assert len(await scheduler.runs("ok")) == 2
assert len(await scheduler.runs("ok", limit=1)) == 1
snapshot = await scheduler.snapshot()
assert snapshot["tz"] == "Europe/Warsaw"
runs_by_name = {j["name"]: j["run"] for j in snapshot["jobs"]}
assert runs_by_name["ok"]["payload"]["stack"] == "y"
assert runs_by_name["boom"]["status"] == "failed"
await scheduler.stop()
async def test_schedule_survives_a_restart(world: World, pg: Pg) -> None:
conv = await world.conversations.create(kind="master", agent="a", origin="test")
first = Scheduler(conversations=world.conversations, driver=await pg.driver())
first = Scheduler(
conversations=world.conversations, driver=await pg.driver(), tz="Europe/Warsaw"
)
world.conversations.scheduler = first
await first.start()
job_id, when = await world.conversations.schedule(conv, "+1s", "push X")
assert job_id is not None
assert 0 < (when - datetime.now(UTC)).total_seconds() <= 1
assert when.utcoffset() == datetime.now(WARSAW).utcoffset()
queued = await world.conversations.schedules(conv)
assert [q["payload"]["text"] for q in queued] == ["push X"]
assert queued[0]["payload"]["at"] == when.isoformat(timespec="minutes")
await first.stop()
second = Scheduler(conversations=world.conversations, driver=await pg.driver())
@@ -138,9 +217,14 @@ async def test_webhook_runs_the_job_with_its_payload(world: World, pg: Pg) -> No
assert missing.status_code == 404
await until(lambda: seen)
assert seen == [{"trigger": "webhook", "stack": "x"}]
await until(lambda: scheduler._runs.get("ping")) # noqa: SLF001
snapshot = await scheduler.snapshot()
assert [j["name"] for j in snapshot["jobs"]] == ["ping", "quiet"]
assert snapshot["enabled"] and snapshot["jobs"][0]["webhook"]
assert snapshot["jobs"][0]["run"]["status"] == "done"
assert snapshot["jobs"][0]["run"]["trigger"] == "webhook"
runs = await scheduler.runs("ping")
assert [r["payload"] for r in runs] == [{"stack": "x"}]
await scheduler.stop()
@@ -170,6 +254,7 @@ async def test_non_critical_jobs_wait_while_the_window_is_hot(
],
driver=await pg.driver(),
budget=Budget(threshold=0.7),
tz="Europe/Warsaw",
)
await scheduler.start()
events: list[dict[str, Any]] = []
@@ -193,5 +278,13 @@ async def test_non_critical_jobs_wait_while_the_window_is_hot(
assert snapshot["throttled"] and snapshot["utilization"] == 0.9
crons = {j["name"]: j["cron"] for j in snapshot["jobs"]}
assert crons == {"vibegram": "*/10 * * * *", "rotation": "0 * * * *"}
await until(lambda: all(j["next_run"] for j in snapshot["jobs"]) or True)
async def crons_ready() -> dict[str, Any] | None:
snap = await scheduler.snapshot()
return snap if all(j["next_run"] for j in snap["jobs"]) else None
snapshot = await until(crons_ready)
expected = datetime.now(WARSAW).utcoffset()
for job_info in snapshot["jobs"]:
assert datetime.fromisoformat(job_info["next_run"]).utcoffset() == expected
await scheduler.stop()