Files
LLeMbas/tests/test_schedule_ticker.py
Jaroslav Beneš 9ddc0a2103 Something can happen because time passed, and land somewhere worth reading
Nothing in LLeMbas ever happened on its own. Every reply was downstream of
somebody pressing Send, and the one exception -- jobs.wake, waking a chat when a
background job finishes -- was downstream of a command they had run. PLAN.md
never listed scheduling as unbuilt because services/chat.py:618 had recorded it
as a decision: "a scheduler is a whole new concern for a single-worker
application". This is that concern, taken on deliberately, plus the two places
its output goes.

Reports first, because it is useful with no scheduling at all. A report is not a
Chat with one Message in it: it has no turns and no reply, it is read top to
bottom, and it must be writable with no chat behind it -- being the fallback for
a run whose own chat has gone. As a Chat it would need a sidebar row per daily
report, a title that regenerates itself, a composer to suppress and a bubble with
a rewind button around something that is not a turn. The section's character is
enforced by absence: nothing under reports/ includes the composer or renders
chat/_message.html, so there is no sse-connect anywhere and nothing on those
pages *can* start a generation. The test reads that off the OpenAPI schema, not
by walking app.routes -- this FastAPI keeps an included router wrapped rather
than flattening it, so the walk finds nothing and the assertion passes for the
wrong reason.

rule.py is pure, total, and was finished before anything called it. No session,
no wall clock, nothing that raises: validate clamps what it recognises, drops
what it does not, and answers {} for prose -- at which point the caller shows the
manual form. It had to be that way because the compile step's output is model
output that becomes a *timer*, which is the sharpest case of hard rule 6 here.
The invariant, pinned: anything validate accepts has a computable next
occurrence. A schedule that can never fire looks exactly like a working one on
every screen it appears on.

Wall-clock and elapsed time are kept apart because they mean different things.
at.times are wall-clock in the owner's zone, so 15:00 stays 15:00 across a
daylight-saving change -- that is what "every Monday at 3PM" means. every is
elapsed real time, so six hours stays six hours across a 23- or 25-hour day --
that is what a timer means. Conflating them gets one of the two wrong twice a
year. A time inside the spring-forward gap fires at the first minute that exists;
left to zoneinfo's own resolution it lands an hour away wearing a wall-clock time
that did not happen, and a daily 02:30 report vanishing once a year on a machine
nobody watches is the failure this file is arranged around.

The ticker claims and commits *before* it fires. The other order is a hot loop: a
firing that raises is retried every tick for ever against whatever it was that
failed, and the only symptom is load. Its blanket except is copied from the
terminal reaper for a sharper reason -- a ticker that dies on one bad row stops
every schedule on the instance and says nothing at all. No request fails, no
reply errors, no dot appears. The reports simply stop.

Three rules that look like bugs from outside: a firing arriving while the chat is
still answering queues rather than starting a second reply, and past max_queued
is skipped with the reason on the row; Run now does not advance next_fire_at, or
testing a schedule silently consumes the run it was testing; resuming recomputes
from now, or a schedule paused for a month fires the instant it comes back, once
per occurrence it missed. Catching up lives in the sweep and not in a startup
hook, because a suspended host and a long stall reproduce "its time passed while
nothing was running" with no restart to hang one on.

services/wake.py is the lock discipline extracted rather than copied. A finished
job and a due schedule are the same problem, and both depend on there being no
await between the running_for check and the writes; two lock dictionaries for one
invariant is how one of them drifts. jobs.wake is now a caller that supplies
wording, and _completion_text stayed exactly where it was because tool.background
quotes its opening sentence.

A scheduled run has no reader, so ask_user is withdrawn from resolve_tools rather
than merely discouraged in core.unattended -- a rule living only in a system
message is one a page the model just read can argue with, and a parked question
holds the reply for the whole approval_timeout with nobody to answer it. For the
same reason a task chat may not be an agent chat in v1: Manual, Edit and Plan all
stop to ask on RISK_EXECUTE, so the only two outcomes would be unattended
execution and a reply that stalls. That deserves its own pass.

Messages is bounded in the request and unbounded on disk. Only the latest chunk
is sent; everything else stays exactly where it was written. Nothing is folded
into text and nothing is deleted -- the visible conversation is identical either
way, so destroying the older rows would buy only disk, against being irreversible
and losing every attachment and tool call in the range, and it would contradict
the rule compaction already holds. should_compact refuses this kind for the
matching reason: two mechanisms narrowing one transcript is how a summary ends up
summarising a summary. The history route is the mirror of thread_tail and keeps
its four properties; the fifth is its own, that prepending moves the scroll
position, so app.js records scrollHeight before the swap and adds the difference
back after.

An empty Chat.kind meant "both sides of the switch" and had been read as "no
filter" since there were only two of them. The sidebar passes "" precisely when
agent chats are switched off -- so the moment a third kind existed, every task
chat and every Messages conversation appeared in somebody's ordinary chat list,
on exactly the instances whose owners would never think to look. KINDS stays the
two-sided fork, because set_sidebar_kind validates against it and a third entry
there makes the tree filterable to a side with no button to leave it; ALL_KINDS
is what a row may be. Both narrowings are pinned, because they are two
implementations of one rule and only one of them is SQL.

Per-user timezone had to exist for any of this: harness.py:179 was telling every
reader the *server's* idea of the date, which is survivable while the answer is
prose and stops being survivable the moment somebody says "every Monday at 3" and
something has to work out when that is.

Three things were caught by a test being wrong rather than by the code being
wrong. The task-chat "no composer" assertions were passing against a page
rendering its no-models-configured branch. A permission test asserted the same
thing twice because the administrator bypasses every permission. And every
Messages test passed with default_model never called, because none of them
configured a model -- so the pair it returns was being assigned straight to
model_id, and SQLite refuses a tuple in a String column. The fixtures now say why
they exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 21:31:36 +02:00

451 lines
15 KiB
Python

"""The ticker and the runner: claiming a firing, and not doing it twice.
`rule.py` is tested on its own in `test_schedule_rule.py`. What is tested here
is everything that goes wrong *around* a correct rule — which is the half that
fails silently. Nothing in this file needs an endpoint: the firing itself is
stubbed, because what is being checked is the bookkeeping, not the reply.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from lembas.db.models import (
ROLE_ASSISTANT,
TARGET_CHAT,
TARGET_REPORT,
Chat,
Message,
Report,
Schedule,
User,
)
from lembas.services import settings_store
from lembas.services.schedule import clock, runner, ticker
from lembas.services.schedule import rule as rule_service
@pytest.fixture(autouse=True)
def scheduling_on(db):
settings_store.update(db, {"enabled": True}, key=settings_store.SCHEDULES)
return None
def _user(db) -> User:
return db.scalars(select(User).order_by(User.created_at)).first()
def _schedule(db, *, chat_id: str = "", **kwargs) -> Schedule:
fields = {
"user_id": _user(db).id,
"title": "A schedule",
"instruction": "Do the thing.",
"rule_json": rule_service.validate(
{"start": "2026-01-01T00:00:00Z", "every": {"hours": 1}}
),
"target": TARGET_CHAT,
"chat_id": chat_id,
"enabled": True,
"next_fire_at": datetime(2026, 1, 1, tzinfo=UTC),
**kwargs,
}
schedule = Schedule(**fields)
db.add(schedule)
db.commit()
return schedule
# --- Claiming -------------------------------------------------------------------
@pytest.mark.anyio
async def test_a_firing_that_raises_still_moves_the_schedule_on(
client: TestClient, db, registered, make_chat, monkeypatch
):
"""The single most important property here.
Claim, commit, *then* fire. The other order is a hot loop: a schedule whose
firing fails is retried every tick for ever, against whatever it was that
failed — and the only symptom is load.
"""
schedule = _schedule(db, chat_id=make_chat())
async def explode(schedule_id, **kwargs):
raise RuntimeError("the endpoint is down")
monkeypatch.setattr(runner, "fire", explode)
fired = await ticker.sweep(now=datetime(2026, 1, 1, 0, 30, tzinfo=UTC))
assert fired == 1
for task in list(ticker._FIRING):
with pytest.raises(RuntimeError):
await task
db.refresh(schedule)
assert schedule.next_fire_at is not None
assert schedule.fired_count == 1
@pytest.mark.anyio
async def test_two_overlapping_sweeps_fire_once(
client: TestClient, db, registered, make_chat, monkeypatch
):
"""A sweep can take minutes — a firing awaits a model. The lock is what
stops the next tick claiming the same row again."""
schedule = _schedule(db, chat_id=make_chat())
calls: list[str] = []
async def record(schedule_id, **kwargs):
calls.append(schedule_id)
monkeypatch.setattr(runner, "fire", record)
now = datetime(2026, 1, 1, 0, 30, tzinfo=UTC)
await ticker.sweep(now=now)
await ticker.sweep(now=now)
for task in list(ticker._FIRING):
await task
assert calls == [schedule.id]
@pytest.mark.anyio
async def test_exhaustion_disables_rather_than_looping(
client: TestClient, db, registered, make_chat, monkeypatch
):
""""Five times" meaning "for ever" is the failure. A rule with nothing left
switches the row off, so it stops being examined at all."""
monkeypatch.setattr(runner, "fire", lambda *a, **k: _noop())
schedule = _schedule(
db,
chat_id=make_chat(),
rule_json=rule_service.validate(
{"start": "2026-01-01T00:00:00Z", "every": {"hours": 1}, "count": 1}
),
)
await ticker.sweep(now=datetime(2026, 1, 1, 0, 30, tzinfo=UTC))
for task in list(ticker._FIRING):
await task
db.refresh(schedule)
assert schedule.fired_count == 1
assert schedule.enabled is False
assert schedule.next_fire_at is None
@pytest.mark.anyio
async def test_a_missed_week_fires_once(
client: TestClient, db, registered, make_chat, monkeypatch
):
"""The host was off. It comes back owing one run, not a hundred and
sixty-eight — and the next one is measured from now."""
calls: list[str] = []
async def record(schedule_id, **kwargs):
calls.append(schedule_id)
monkeypatch.setattr(runner, "fire", record)
schedule = _schedule(db, chat_id=make_chat())
now = datetime(2026, 1, 8, 0, 30, tzinfo=UTC)
await ticker.sweep(now=now)
for task in list(ticker._FIRING):
await task
assert len(calls) == 1
db.refresh(schedule)
assert schedule.fired_count == 1
assert schedule.next_fire_at.replace(tzinfo=UTC) > now
@pytest.mark.anyio
async def test_one_unreadable_row_does_not_stop_the_sweep(
client: TestClient, db, registered, make_chat, monkeypatch
):
"""A ticker that dies on one bad row stops every schedule on the instance,
and nothing anywhere says so."""
calls: list[str] = []
async def record(schedule_id, **kwargs):
calls.append(schedule_id)
monkeypatch.setattr(runner, "fire", record)
broken = _schedule(db, chat_id=make_chat(), title="Broken")
healthy = _schedule(db, chat_id=make_chat(), title="Healthy")
real_advance = rule_service.advance
def selective(rule, **kwargs):
if rule.get("_broken"):
raise ValueError("unreadable")
return real_advance(rule, **kwargs)
broken.rule_json = {**broken.rule_json, "_broken": True}
db.commit()
monkeypatch.setattr(rule_service, "advance", selective)
await ticker.sweep(now=datetime(2026, 1, 1, 0, 30, tzinfo=UTC))
for task in list(ticker._FIRING):
await task
assert calls == [healthy.id]
db.refresh(broken)
assert broken.enabled is False
assert broken.last_error
def test_a_deleted_account_takes_its_schedules_with_it(
client: TestClient, db, registered, make_chat
):
"""`user_id` is a real ForeignKey with CASCADE — unlike `chat_id`, which is
a plain id because `migrations.py` cannot add a REFERENCES clause to a table
that already exists. So an orphaned schedule cannot be reached at all, and
the owner check in the sweep is a guard rather than a path.
Worth pinning as the *reason* that guard looks unreachable: somebody
removing it would be right about today and wrong the moment the column
convention changes.
"""
_schedule(db, chat_id=make_chat())
owner = _user(db)
db.delete(owner)
db.commit()
# The cascade is enforced by SQLite, not by an ORM relationship, so the
# session's identity map still holds the row it was told about. Ask the
# database rather than the cache.
db.expunge_all()
assert db.scalars(select(Schedule)).all() == []
@pytest.mark.anyio
async def test_nothing_fires_while_scheduling_is_switched_off(
client: TestClient, db, registered, make_chat, monkeypatch
):
"""The instance switch is a switch, not a suggestion. Checked in the sweep
rather than at the routes, so a row created while it was on does not go on
firing after it is turned off."""
calls: list[str] = []
monkeypatch.setattr(runner, "fire", lambda sid, **k: calls.append(sid) or _noop())
_schedule(db, chat_id=make_chat())
settings_store.update(db, {"enabled": False}, key=settings_store.SCHEDULES)
assert await ticker.sweep(now=datetime(2026, 1, 1, 0, 30, tzinfo=UTC)) == 0
assert calls == []
# --- The runner -----------------------------------------------------------------
async def _noop() -> None:
return None
@pytest.mark.anyio
async def test_a_firing_writes_a_machine_turn_and_starts_a_reply(
client: TestClient, db, registered, make_chat
):
"""The role stays `user` — `build_messages` needs one there and `_inject`
sends a queued turn verbatim. `machine` is what stops the transcript
claiming the reader typed it."""
chat_id = make_chat()
schedule = _schedule(db, chat_id=chat_id)
await runner.fire(schedule.id)
turns = list(
db.scalars(select(Message).where(Message.chat_id == chat_id).order_by(Message.created_at))
)
assert turns[0].role == "user"
assert turns[0].machine is True
assert "started by a schedule" in turns[0].content
assert "Do the thing." in turns[0].content
# And a reply was opened for it.
assert turns[-1].role == ROLE_ASSISTANT
assert turns[-1].complete is False
@pytest.mark.anyio
async def test_a_deleted_chat_switches_the_schedule_off(
client: TestClient, db, registered, make_chat
):
"""Rather than firing into nothing on every tick from now on — which is a
schedule that looks alive and produces nothing."""
schedule = _schedule(db, chat_id="nosuchchat")
await runner.fire(schedule.id)
db.refresh(schedule)
assert schedule.enabled is False
assert "no longer exists" in schedule.last_error
@pytest.mark.anyio
async def test_a_backlog_skips_rather_than_queues_for_ever(
client: TestClient, db, registered, make_chat
):
"""`_drain` takes one queued turn per reply, so a schedule firing faster
than its chat can answer would build a backlog that outlives the day that
caused it."""
chat_id = make_chat()
chat = db.get(Chat, chat_id)
for index in range(5):
db.add(
Message(chat_id=chat.id, role="user", content=f"waiting {index}", queued=True)
)
db.commit()
schedule = _schedule(db, chat_id=chat_id)
await runner.fire(schedule.id)
db.refresh(schedule)
assert "previous run was still going" in schedule.last_error
assert schedule.claimed_at is None
@pytest.mark.anyio
async def test_run_now_does_not_consume_the_scheduled_run(
client: TestClient, db, registered, make_chat
):
"""Testing a schedule must not skip the run it was testing. Advancing is the
ticker's job and nothing else's."""
schedule = _schedule(db, chat_id=make_chat())
before = clock.as_utc(schedule.next_fire_at)
fired_before = schedule.fired_count
await runner.run_now(schedule.id)
db.refresh(schedule)
# Through `as_utc` on both sides: a row read back from SQLite is naive while
# one still in the session keeps its tzinfo, and comparing the two raises.
assert clock.as_utc(schedule.next_fire_at) == before
assert schedule.fired_count == fired_before
def _finished(db, chat_id: str, text: str) -> Message:
"""A reply that has already been written.
`deliver` is tested against this rather than against the output of
`runner.fire`, because `fire` starts a real generation — which, with no
endpoint configured, races the test to write the same row. The delivery's
job begins at a finished message, so that is what it is handed.
"""
message = Message(
chat_id=chat_id, role=ROLE_ASSISTANT, content=text, complete=True, model_id="m"
)
db.add(message)
db.commit()
return message
@pytest.mark.anyio
async def test_a_report_target_files_the_reply(
client: TestClient, db, registered, make_chat
):
chat_id = make_chat()
schedule = _schedule(db, chat_id=chat_id, target=TARGET_REPORT, title="Daily news")
assistant = _finished(db, chat_id, "Three things happened.")
await runner.deliver(
schedule.id, assistant.id, since=datetime(2020, 1, 1, tzinfo=UTC)
)
filed = db.scalars(select(Report)).all()
assert [r.title for r in filed] == ["Daily news"]
assert filed[0].body == "Three things happened."
assert filed[0].schedule_id == schedule.id
assert filed[0].unread is True
@pytest.mark.anyio
async def test_a_run_that_produced_nothing_still_files_something(
client: TestClient, db, registered, make_chat
):
"""A scheduled report that silently did not appear is indistinguishable
from a schedule that never fired. So the failure is filed as a report."""
chat_id = make_chat()
schedule = _schedule(db, chat_id=chat_id, target=TARGET_REPORT, title="Daily news")
broken = Message(chat_id=chat_id, role=ROLE_ASSISTANT, content="", complete=True,
error="the endpoint refused")
db.add(broken)
db.commit()
await runner.deliver(schedule.id, broken.id, since=datetime(2020, 1, 1, tzinfo=UTC))
filed = db.scalars(select(Report)).all()
assert len(filed) == 1
assert filed[0].error
@pytest.mark.anyio
async def test_a_report_the_model_filed_itself_is_not_duplicated(
client: TestClient, db, registered, make_chat
):
"""`report_write` during the run *is* the report. Filing the reply beside it
would put two of everything in the feed."""
from lembas.services import reports as reports_service
chat_id = make_chat()
schedule = _schedule(db, chat_id=chat_id, target=TARGET_REPORT)
began = datetime.now(tz=UTC) - timedelta(seconds=5)
reports_service.create(
db, owner=_user(db), title="Filed by the model", body="...",
source="chat", source_id=chat_id,
)
assistant = _finished(db, chat_id, "Also this.")
await runner.deliver(schedule.id, assistant.id, since=began)
assert [r.title for r in db.scalars(select(Report))] == ["Filed by the model"]
@pytest.mark.anyio
async def test_a_report_from_an_earlier_run_does_not_suppress_this_one(
client: TestClient, db, registered, make_chat
):
"""The other half of the dedup, and the one that would fail silently: a
daily report would be filed once and then never again, because last week's
is still sitting there with the same `source_id`."""
from lembas.services import reports as reports_service
chat_id = make_chat()
schedule = _schedule(db, chat_id=chat_id, target=TARGET_REPORT, title="Daily news")
reports_service.create(
db, owner=_user(db), title="Yesterday's", body="...",
source="schedule", source_id=chat_id,
)
assistant = _finished(db, chat_id, "Today's news.")
# This run began *after* yesterday's report was filed.
await runner.deliver(schedule.id, assistant.id, since=datetime.now(tz=UTC))
assert sorted(r.title for r in db.scalars(select(Report))) == [
"Daily news",
"Yesterday's",
]
# --- Lifecycle -------------------------------------------------------------------
def test_starting_twice_makes_one_ticker(client: TestClient, registered):
"""Two tickers in one process fires everything twice, which is the failure
two workers would cause and the reason this is idempotent."""
ticker.start()
first = ticker._TICKER
ticker.start()
assert ticker._TICKER is first
def test_release_claims_clears_an_interrupted_run(client: TestClient, db, registered):
"""A restart abandons a firing in flight. Without this the row keeps its
claim stamp for ever and reads as permanently running."""
schedule = _schedule(db, claimed_at=datetime(2026, 1, 1, tzinfo=UTC))
assert ticker.release_claims() == 1
db.refresh(schedule)
assert schedule.claimed_at is None
assert "interrupted by a restart" in schedule.last_error