Files
LLeMbas/src/lembas/services/schedule/runner.py
T
HomerandClaude Opus 5.5 9970bb43c6 Data groups: a provider's models read only their own group's data
Every connection is in a data group. Its models are handed, and can find,
only that group's memories, notes, skills, knowledge, reports and
personality -- by search and by id. A chat stays in the group it was
started in: switching its model, the endpoint fallback, the crowd, friends,
bases and the @ menu all stay inside it, and a chat whose model has moved
is refused rather than sent. A group may name its own embedder and image
reviewer. data.manage lets a person make personal groups, remap
connections for themselves and move their own records.

Also: a search no longer mixes two embedders of the same width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:07:55 +00:00

342 lines
14 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 (
DEFAULT_GROUP,
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
def _preview(text: str, limit: int = 160) -> str:
"""The opening of a reply, as the body of a notification.
A notification saying "a scheduled run finished" is one somebody has to open
something to understand, which is most of the reason notifications get
ignored. Flattened to one line because a push service and an operating
system will each do their own thing with newlines.
"""
flat = " ".join((text or "").split())
return flat[: limit - 1] + "…" if len(flat) > limit else flat
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.",
group=schedule.data_group_id or DEFAULT_GROUP,
)
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 "",
group=schedule.data_group_id or DEFAULT_GROUP,
)
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
from lembas.services import schedules as schedules_service
# Checked when the schedule was saved, and again here: the person may
# have moved a connection or remapped a group since, and a turn in
# Messages is read by Messages' model on every later reply.
refused = schedules_service.messages_refusal(
db, owner, schedule.data_group_id or ""
)
if refused:
schedule.last_error = refused
db.commit()
return
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()
# The arrival this whole channel exists for: a run that fired while
# nobody was here, landing somewhere they are not looking. The first
# line of the reply is the body, because "a scheduled run finished"
# is a notification you have to open something to understand.
from lembas.services import push as push_service
push_service.announce_later(
owner.id,
title=schedule.title or "Scheduled run",
body=_preview(message.content or ""),
url="/messages",
kind="message",
)
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)