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>
342 lines
14 KiB
Python
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)
|