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>
This commit is contained in:
Jaroslav Beneš
2026-08-05 21:31:36 +02:00
parent 60e7d0d599
commit 2f09d8363d
65 changed files with 6991 additions and 68 deletions
+339
View File
@@ -0,0 +1,339 @@
"""Working a plain-language request into a schedule, and running unattended.
The compile is the one place a *model* decides when something happens. So the
tests here are mostly about it failing safely: prose, a fence, a cron string, an
endpoint that is down and a cleared fragment all have to end at the manual form
rather than at a schedule that never fires.
"""
from __future__ import annotations
import json
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from lembas.db.models import KIND_TASK, Chat, Schedule, User
from lembas.services import prompts as prompts_service
from lembas.services import settings_store
from lembas.services.llm.openai_client import Endpoint, LLMError
from lembas.services.schedule import compile as compile_service
@pytest.fixture(autouse=True)
def scheduling_allowed(db, registered):
from lembas.db.models import Connection, Model
from lembas.services.crypto import encrypt
settings_store.update(db, {"enabled": True}, key=settings_store.SCHEDULES)
settings_store.update(db, {"default_permissions": {"schedule.use": True}})
connection = Connection(
name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("")
)
db.add(connection)
db.commit()
db.add(Model(connection_id=connection.id, model_id="test-model"))
db.commit()
return None
def _user(db) -> User:
return db.scalars(select(User).order_by(User.created_at)).first()
def _endpoint() -> Endpoint:
return Endpoint(base_url="http://127.0.0.1:1/v1", api_key="", extra_headers={})
def _template(db) -> str:
return prompts_service.resolve(db, "task.schedule_compile")
async def _compile(db, reply: str, monkeypatch, request: str = "every monday at 3"):
async def answer(endpoint, payload):
return reply
monkeypatch.setattr(compile_service, "complete", answer)
return await compile_service.compile_request(
_endpoint(), "test-model", request, template=_template(db), user=_user(db)
)
# --- What it can read -------------------------------------------------------------
@pytest.mark.anyio
async def test_a_plain_json_answer_compiles(client: TestClient, db, registered, monkeypatch):
compiled = await _compile(
db,
json.dumps(
{
"title": "Build check",
"instruction": "Check the build and say what broke.",
"target": "report",
"schedule": {"at": {"weekdays": [0], "times": ["15:00"]}},
}
),
monkeypatch,
)
assert compiled.ok is True
assert compiled.title == "Build check"
assert compiled.target == "report"
assert compiled.rule["at"]["times"] == ["15:00"]
@pytest.mark.anyio
async def test_a_fenced_answer_compiles(client: TestClient, db, registered, monkeypatch):
"""Small models fence their JSON however they were trained to. Refusing it
costs a whole round trip to end up showing the manual form anyway — the
same reasoning `tools.parse_arguments` already follows."""
body = json.dumps(
{"title": "T", "instruction": "I", "schedule": {"at": {"times": ["09:00"]}}}
)
compiled = await _compile(
db, f"Here you go:\n```json\n{body}\n```\nHope that helps!", monkeypatch
)
assert compiled.ok is True
assert compiled.rule["at"]["times"] == ["09:00"]
@pytest.mark.anyio
async def test_a_timer_with_no_start_begins_now(
client: TestClient, db, registered, monkeypatch
):
""""Every six hours" is written `{"every": {"hours": 6}}` and nothing else,
which is the natural reading and cannot fire on its own — a timer measures
from a start, and `rule.py` has no clock to invent one. Filled in here
exactly as the manual form does, or the commonest request of all compiles to
a schedule that never runs."""
compiled = await _compile(
db,
json.dumps({"title": "T", "instruction": "I", "schedule": {"every": {"hours": 6}}}),
monkeypatch,
request="every six hours",
)
assert compiled.ok is True
assert compiled.rule["every"] == {"minutes": 360}
assert compiled.rule["start"]
@pytest.mark.anyio
async def test_thinking_is_stripped_before_parsing(
client: TestClient, db, registered, monkeypatch
):
"""A model that thinks inline puts its reasoning in `content`, which is the
field `complete` hands back verbatim — the trap auto-titling hit."""
body = json.dumps({"title": "T", "instruction": "I", "schedule": {"at": {"times": ["09:00"]}}})
compiled = await _compile(db, f"<think>Let me work this out…</think>{body}", monkeypatch)
assert compiled.ok is True
assert compiled.title == "T"
# --- How it fails ------------------------------------------------------------------
@pytest.mark.anyio
async def test_prose_falls_back_to_the_readers_own_words(
client: TestClient, db, registered, monkeypatch
):
"""Never a schedule nobody asked for. The reader's words survive so the form
is filled in rather than blank."""
compiled = await _compile(db, "Sure! I'd suggest running that weekly.", monkeypatch)
assert compiled.ok is False
assert compiled.instruction == "every monday at 3"
assert compiled.reason
@pytest.mark.anyio
async def test_a_rule_that_normalises_to_nothing_is_not_ok(
client: TestClient, db, registered, monkeypatch
):
"""The compile's output is model output that becomes a *timer*, and this is
the reason `rule.validate` had to be total."""
compiled = await _compile(
db, json.dumps({"title": "T", "instruction": "I", "schedule": "0 3 * * 1"}), monkeypatch
)
assert compiled.ok is False
assert compiled.rule == {}
assert "when" in compiled.reason
@pytest.mark.anyio
async def test_a_time_already_past_is_not_ok(client: TestClient, db, registered, monkeypatch):
compiled = await _compile(
db,
json.dumps(
{"title": "T", "instruction": "I", "schedule": {"start": "2020-01-01T09:00:00Z"}}
),
monkeypatch,
)
assert compiled.ok is False
assert "already passed" in compiled.reason
@pytest.mark.anyio
async def test_an_endpoint_that_is_down_is_not_an_error(
client: TestClient, db, registered, monkeypatch
):
async def refuse(endpoint, payload):
raise LLMError("connection refused")
monkeypatch.setattr(compile_service, "complete", refuse)
compiled = await compile_service.compile_request(
_endpoint(), "test-model", "daily at nine", template=_template(db), user=_user(db)
)
assert compiled.ok is False
assert compiled.instruction == "daily at nine"
@pytest.mark.anyio
async def test_clearing_the_fragment_switches_off_the_compiling_not_the_feature(
client: TestClient, db, registered, monkeypatch
):
"""`task.compact` set the precedent that clearing a fragment kills a
feature. Here it must not: the manual form is what makes "an empty override
means off" safe, and no request is made at all."""
called = False
async def answer(endpoint, payload):
nonlocal called
called = True
return "{}"
monkeypatch.setattr(compile_service, "complete", answer)
compiled = await compile_service.compile_request(
_endpoint(), "test-model", "daily at nine", template="", user=_user(db)
)
assert called is False
assert compiled.ok is False
assert compiled.instruction == "daily at nine"
assert compiled.reason == ""
def test_the_prompt_carries_the_readers_zone(client: TestClient, db, registered):
"""The model works out "Monday at 3" and the ticker fires it. If they
disagree about the zone, nothing errors — it simply runs at the wrong time."""
user = _user(db)
user.settings_json = {**(user.settings_json or {}), "timezone": "Asia/Tokyo"}
db.commit()
prompt = compile_service.render_prompt(
_template(db), request="every monday at 3", user=user
)
assert "Asia/Tokyo" in prompt
assert "every monday at 3" in prompt
# --- The review step ----------------------------------------------------------------
def test_describing_shows_it_back_rather_than_creating_it(
client: TestClient, db, registered, monkeypatch
):
"""A timing a model chose and nobody looked at is exactly the standing
instruction this codebase refuses to create silently elsewhere."""
async def answer(endpoint, payload):
return json.dumps(
{
"title": "Build check",
"instruction": "Check the build.",
"schedule": {"at": {"weekdays": [0], "times": ["15:00"]}},
}
)
monkeypatch.setattr(compile_service, "complete", answer)
response = client.post("/api/schedules/describe", data={"request": "mondays at 3"})
assert response.status_code == 200
assert "Every Monday at 15:00" in response.text
assert "Build check" in response.text
# Shown, not saved.
assert db.scalars(select(Schedule)).all() == []
def test_describing_with_no_model_configured_still_answers(
client: TestClient, db, registered
):
from lembas.db.models import Connection
for connection in db.scalars(select(Connection)):
db.delete(connection)
db.commit()
response = client.post("/api/schedules/describe", data={"request": "mondays at 3"})
assert response.status_code == 200
assert "fill it in yourself" in response.text
# --- Unattended ---------------------------------------------------------------------
def test_ask_user_is_not_offered_in_a_task_chat(client: TestClient, db, registered):
"""Enforced in `resolve_tools`, not merely discouraged in the prompt.
A parked `ask_user` holds the reply for the whole `approval_timeout` with
nobody there to answer — a run that silently does nothing for fifteen
minutes and then gives up. A rule living only in a system message is one a
page the model just read can argue with.
"""
from lembas.db.models import Model
from lembas.services import tools as tools_service
model = db.scalars(select(Model)).one()
model.capabilities_json = {"tools": True, "tool_ask": True}
db.commit()
settings_store.update(db, {"default_permissions": {"tools.ask": True}})
ordinary = Chat(user_id=_user(db).id, model_id="test-model")
task = Chat(user_id=_user(db).id, model_id="test-model", kind=KIND_TASK)
db.add_all([ordinary, task])
db.commit()
offered = {t.name for t in tools_service.resolve_tools(db, ordinary, _user(db)).defs}
assert "ask_user" in offered
withdrawn = {t.name for t in tools_service.resolve_tools(db, task, _user(db)).defs}
assert "ask_user" not in withdrawn
def test_a_task_chat_is_told_what_it_is_for(client: TestClient, db, registered, monkeypatch):
"""A task chat accumulates every run, so by the tenth the instruction is far
out of sight up the transcript."""
from lembas.services import harness
from lembas.services import schedules as schedules_service
schedule = schedules_service.create(
db,
owner=_user(db),
title="Build check",
instruction="Check the build and say what broke.",
rule={"at": {"weekdays": [0], "times": ["15:00"]}},
)
chat = db.get(Chat, schedule.chat_id)
values = harness.context_variables(db, _user(db), [], chat)
assert values["schedule_instruction"] == "Check the build and say what broke."
assert values["schedule_summary"] == "Every Monday at 15:00"
block = harness.compose(db, _user(db), [], chat)
assert "nobody is necessarily reading it" in block
assert "Check the build and say what broke." in block
def test_an_ordinary_chat_is_told_none_of_it(client: TestClient, db, registered):
"""`core.unattended` and `context.schedule` are gated on the same variable,
so the warning cannot appear without the thing it warns about."""
from lembas.services import harness
chat = Chat(user_id=_user(db).id, model_id="test-model")
db.add(chat)
db.commit()
block = harness.compose(db, _user(db), [], chat)
assert "nobody is necessarily reading" not in block
assert "This scheduled task" not in block