Files
LLeMbas/src/lembas/services/schedule/runner.py
T
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

302 lines
12 KiB
Python

"""What happens when a schedule fires.
Every schedule fires the same way — a turn into a chat, answered by the ordinary
generation loop — and the *target* decides only what becomes of the finished
reply. One mechanism, three deliveries:
- `chat` leave it there. The reply is the point, and it is already in the
task chat where somebody will read it.
- `report` copy it into a `Report` and keep the chat out of the way.
- `messages` copy it into the reader's Messages conversation, as an assistant
turn marked `machine`. Copied rather than moved: the task chat is
the working area and keeps the tool calls, the steps and the
metrics; Messages gets the answer.
The alternative — a one-shot `complete()` in the shape of `generate_title` — was
rejected because it has no tools and no rounds, which is useless for the case
this feature exists for. "Give me a daily news report" needs to search the web.
**Nothing in `services/generation.py` changes.** The waiting happens here, in a
task per firing, which is the shape `jobs._watch` already established. Making
generation aware of schedules would mean a branch inside `_persist`, and that is
the single writer with one rule.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
from datetime import UTC, datetime
from lembas.db.models import (
ROLE_ASSISTANT,
TARGET_CHAT,
TARGET_MESSAGES,
TARGET_REPORT,
Chat,
Message,
Schedule,
User,
)
from lembas.db.session import session_scope
from lembas.services import reports as reports_service
from lembas.services import wake as wake_service
from lembas.services.schedule import clock
from lembas.services.schedule import rule as rule_service
log = logging.getLogger(__name__)
# How long to wait for a firing's reply before giving up on delivering it. The
# reply itself is not cancelled -- it goes on and lands in its chat, which is
# where a task chat's output belongs anyway. What times out is only *this*
# task's interest in copying the result somewhere.
DELIVERY_TIMEOUT = 3600.0
# How often the waiter looks. Coarse on purpose: nothing is watching this, and a
# report arriving three seconds late costs nobody anything.
POLL_SECONDS = 3.0
def _preamble(schedule: Schedule, *, zone, due_at: datetime | None) -> str:
"""The turn a firing puts into the chat.
Names itself a scheduled event in *words*, because the role stays `user` --
`_inject` sends a queued turn verbatim and `build_messages` must keep seeing
a user turn. The framing therefore cannot live in the role, exactly as it
cannot for a finished background job.
The scheduled time is stated as well as the actual one, so a run caught up
after an outage can say so rather than reporting stale news as current.
"""
now = datetime.now(tz=UTC).astimezone(zone)
lines = [
"This turn was started by a schedule, not by the person — "
"they are not necessarily at the keyboard.",
"",
f"[schedule: {schedule.title or 'untitled'}] "
f"{rule_service.describe(schedule.rule_json or {}, zone=zone)}",
f"It is now {now.strftime('%A %-d %B %Y, %H:%M')}.",
]
if due_at is not None:
late = (datetime.now(tz=UTC) - clock.as_utc(due_at)).total_seconds()
if late > 600:
local = clock.as_utc(due_at).astimezone(zone)
lines.append(
f"This run was due at {local.strftime('%A %-d %B, %H:%M')} and is late — "
"say so if it makes any of what follows out of date."
)
lines += ["", schedule.instruction or schedule.request or ""]
return "\n".join(lines)
async def _await_reply(chat_id: str, message_id: str) -> None:
"""Wait for one generation to finish.
Polled rather than awaited on the task itself: `generation` owns its
registry and its tasks, and reaching into either from here would couple this
to internals whose whole job is to be replaceable. A poll costs nothing at
this interval and cannot deadlock.
"""
from lembas.services import generation as generation_service
waited = 0.0
while waited < DELIVERY_TIMEOUT:
running = generation_service.running_for(chat_id)
# `running_for` already excludes a finished generation, so `None` is the
# ordinary end of this loop. The id check is what stops us waiting on
# somebody's *next* reply in the same chat, which would otherwise happen
# whenever a queued turn is drained straight after ours.
if running is None or running.message_id != message_id:
return
await asyncio.sleep(POLL_SECONDS)
waited += POLL_SECONDS
log.warning("gave up waiting for the reply to schedule message %s", message_id)
def _finished_reply(db, chat_id: str, message_id: str) -> Message | None:
message = db.get(Message, message_id)
if message is None or message.chat_id != chat_id:
return None
if not message.complete or message.error:
return None
return message
async def deliver(schedule_id: str, message_id: str, *, since: datetime) -> None:
"""Put a finished reply where the schedule said it should go.
`since` is the moment the firing began, and it is what tells a report the
model filed itself apart from one filed on a previous run.
"""
with session_scope() as db:
schedule = db.get(Schedule, schedule_id)
if schedule is None:
return
target = schedule.target
chat_id = schedule.chat_id
if target == TARGET_CHAT:
# Already where it belongs. Stated rather than left to fall through, so
# a reader of this function does not have to infer the common case.
return
await _await_reply(chat_id, message_id)
with session_scope() as db:
schedule = db.get(Schedule, schedule_id)
if schedule is None:
return
owner = db.get(User, schedule.user_id)
if owner is None:
return
message = _finished_reply(db, chat_id, message_id)
if target == TARGET_REPORT:
# If the model filed one itself with `report_write`, that is the
# report and this must not file a second. The tool stamps
# `source_id` with the chat, which is what makes them the same run;
# `since` is what makes it *this* run. Both stamps go through
# `as_utc` because one comes from a row read back from SQLite (which
# loses the offset) and the other is still in memory -- comparing
# the two raises, the trap `compaction.moment` exists for.
already = reports_service.recent(db, owner, limit=5)
if any(
r.source_id == chat_id and clock.as_utc(r.created_at) >= clock.as_utc(since)
for r in already
):
return
if message is None:
reports_service.create(
db,
owner=owner,
title=schedule.title or "Scheduled run",
body="",
source="schedule",
source_id=chat_id,
schedule_id=schedule.id,
error="The run did not produce a reply.",
)
return
reports_service.create(
db,
owner=owner,
title=schedule.title or "Scheduled run",
body=message.content or "",
source="schedule",
source_id=chat_id,
schedule_id=schedule.id,
model_id=message.model_id or "",
)
return
if target == TARGET_MESSAGES:
if message is None:
schedule.last_error = "The run did not produce anything to post."
db.commit()
return
# Copied in as an assistant turn rather than moved, because the task
# chat is the working area and holds the tool calls, the steps and
# the metrics -- the Messages conversation gets the answer. Marked
# `machine` for the same reason a job completion is: the reader did
# not write it, and the bubble should not imply they did.
from lembas.services import chat as chat_service
from lembas.services import messages as messages_service
conversation = messages_service.for_user(db, owner)
chat_service.create_message(
db,
conversation,
ROLE_ASSISTANT,
message.content or "",
model_id=message.model_id or "",
machine=True,
)
conversation.unread = True
conversation.unread_notified = False
db.commit()
async def fire(schedule_id: str, *, due_at: datetime | None = None) -> None:
"""Run one schedule now.
Never raises: the ticker calls this and one bad schedule must not stop the
others. Anything that goes wrong is written to `last_error`, where the
schedule's own page shows it — a run that failed silently is
indistinguishable from one that was never due.
"""
from lembas.services import settings_store
try:
with session_scope() as db:
schedule = db.get(Schedule, schedule_id)
if schedule is None:
return
owner = db.get(User, schedule.user_id)
chat = db.get(Chat, schedule.chat_id) if schedule.chat_id else None
if owner is None:
return
if chat is None or chat.user_id != owner.id:
# The chat was deleted, or never belonged to this owner. Stop
# rather than fire into nothing on every tick from now on.
schedule.enabled = False
schedule.last_error = "Its chat no longer exists, so it has been switched off."
db.commit()
return
limit = int(settings_store.schedules(db).get("max_queued") or 3)
zone = clock.zone_for(owner)
content = _preamble(schedule, zone=zone, due_at=due_at)
chat_id = chat.id
model_id = schedule.model_id or chat.model_id
began = datetime.now(tz=UTC)
schedule.claimed_at = began
schedule.last_error = ""
db.commit()
# Outside the session: a chat already carrying a backlog is one whose
# replies are slower than its schedule, and adding to it makes that
# permanently worse. `_drain` takes one queued turn per reply.
if wake_service.queued_count(chat_id) >= limit:
with session_scope() as db:
schedule = db.get(Schedule, schedule_id)
if schedule is not None:
schedule.last_error = (
"Skipped: the previous run was still going, and turns are "
"already waiting in its chat."
)
schedule.claimed_at = None
db.commit()
return
message_id = await wake_service.wake_chat(chat_id, content, model_id=model_id)
with session_scope() as db:
schedule = db.get(Schedule, schedule_id)
if schedule is not None:
schedule.claimed_at = None
db.commit()
if message_id:
await deliver(schedule_id, message_id, since=began)
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001 - one bad schedule must not stop the rest
log.exception("schedule %s failed to fire", schedule_id)
with contextlib.suppress(Exception), session_scope() as db:
schedule = db.get(Schedule, schedule_id)
if schedule is not None:
schedule.last_error = "Something went wrong running this. See the log."
schedule.claimed_at = None
db.commit()
async def run_now(schedule_id: str) -> None:
"""Fire a schedule because somebody pressed the button.
**Deliberately does not advance `next_fire_at`.** Testing a schedule must
not consume the run it was testing -- somebody who presses this at 14:00 to
check a 15:00 report still expects the 15:00 one. The ticker owns advancing,
and it is the only thing that does.
"""
await fire(schedule_id)