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 178742501d
commit 9ddc0a2103
63 changed files with 6773 additions and 67 deletions
+36
View File
@@ -20,8 +20,11 @@ from lembas.db.models.attachment import (
)
from lembas.db.models.canvas import ScratchDoc
from lembas.db.models.chat import (
ALL_KINDS,
KIND_AGENT,
KIND_CHAT,
KIND_MESSAGES,
KIND_TASK,
KINDS,
ROLE_ASSISTANT,
ROLE_SYSTEM,
@@ -52,6 +55,23 @@ from lembas.db.models.library import (
SkillRevision,
chat_knowledge_bases,
)
from lembas.db.models.report import (
SOURCE_CHAT,
SOURCE_MANUAL,
SOURCE_SCHEDULE,
SOURCES,
Report,
)
from lembas.db.models.schedule import (
ORIGIN_MODEL,
ORIGIN_USER,
ORIGINS,
TARGET_CHAT,
TARGET_MESSAGES,
TARGET_REPORT,
TARGETS,
Schedule,
)
from lembas.db.models.setting import Setting
from lembas.db.models.suggestion import Suggestion
from lembas.db.models.tool import (
@@ -84,12 +104,15 @@ __all__ = [
"AUTH_METHODS",
"AUTH_PASSWORD",
"AUTHOR_USER",
"ALL_KINDS",
"Attachment",
"KINDS",
"KIND_AGENT",
"KIND_CHAT",
"KIND_DOCUMENT",
"KIND_IMAGE",
"KIND_MESSAGES",
"KIND_TASK",
"KIND_TEXT",
"PRINCIPAL_GROUP",
"PRINCIPAL_USER",
@@ -111,8 +134,21 @@ __all__ = [
"SECRET_NONE",
"SECRET_PLACEMENTS",
"SECRET_QUERY",
"ORIGINS",
"ORIGIN_MODEL",
"ORIGIN_USER",
"SOURCES",
"SOURCE_CHAT",
"SOURCE_LINK",
"SOURCE_MANUAL",
"SOURCE_SCHEDULE",
"SOURCE_UPLOAD",
"TARGETS",
"TARGET_CHAT",
"TARGET_MESSAGES",
"TARGET_REPORT",
"Report",
"Schedule",
"Chat",
"Job",
"Connection",
+28 -2
View File
@@ -26,8 +26,28 @@ ROLE_TOOL = "tool"
# chat is pointed at a machine before it starts and stays pointed there.
KIND_CHAT = "chat"
KIND_AGENT = "agent"
# The two sides of the sidebar's Chat/Agent switch, and nothing else.
# `KINDS` must NOT grow: `api/preferences.py:set_sidebar_kind` validates against
# it, so a third entry would make the tree filterable to a side with no button
# to leave it -- the "one side of a fork nobody can move" failure the
# `sidebar_split` guard already exists to prevent.
KINDS = (KIND_CHAT, KIND_AGENT)
# Conversations that belong to a section of their own rather than to the tree.
# A Messages conversation is one per person; a task chat belongs to a schedule
# and is reached through Scheduled. Neither is ever listed among the chats, so
# neither is a side of the switch.
KIND_MESSAGES = "messages"
KIND_TASK = "task"
# What a row's `kind` may actually be. Every listing that means "the sidebar
# tree" filters on KINDS; every check that means "is this a real value" uses
# this. Reading `kind == ""` as "no filter" is what leaks a task chat into the
# ordinary list on an instance with agents switched off, where the sidebar
# passes "" precisely because there is no switch to read.
ALL_KINDS = (*KINDS, KIND_MESSAGES, KIND_TASK)
# Duplicated from services/agent/policy.py rather than imported: a model module
# importing a service would invert the dependency, and this is only the column
# default. policy.MODES is the vocabulary; this is what a row starts as.
@@ -90,14 +110,20 @@ class Folder(UUIDPrimaryKey, Timestamps, Base):
folder branch went through the relationship and filtered nothing.
`kind` narrows to one side of the sidebar's Chat/Agent switch. Empty
means both, which is what every caller outside the sidebar wants.
means *both sides of the switch* -- which is not the same as "no filter",
and the difference only became visible once a third kind existed. An
instance with agents disabled passes "" because there is no switch to
read, so a bare `not kind` would list every task chat and the Messages
conversation among somebody's ordinary chats. Those have sections of
their own and are never in the tree.
Ordered like the unfiled list: pinned first, then most recently touched.
"""
wanted = (kind,) if kind else KINDS
kept = [
chat
for chat in self.chats
if not chat.archived and not chat.temporary and (not kind or chat.kind == kind)
if not chat.archived and not chat.temporary and chat.kind in wanted
]
kept.sort(key=lambda chat: chat.updated_at, reverse=True)
kept.sort(key=lambda chat: not chat.pinned)
+68
View File
@@ -0,0 +1,68 @@
"""Reports: what was found, written down once and never replied to."""
from __future__ import annotations
from sqlalchemy import Boolean, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
# Where a report came from. Not a foreign key to anything -- see `source_id`.
SOURCE_SCHEDULE = "schedule"
SOURCE_CHAT = "chat"
SOURCE_MANUAL = "manual"
SOURCES = (SOURCE_SCHEDULE, SOURCE_CHAT, SOURCE_MANUAL)
class Report(UUIDPrimaryKey, Timestamps, Base):
"""A finished piece of work, filed.
Deliberately not a `Chat` with one `Message` in it. A report is read top to
bottom and never answered, so everything a conversation carries -- a
composer, a sidebar row, a title that regenerates itself, a bubble with an
avatar and a rewind button -- would be machinery to suppress rather than
machinery to use. It is the same line `services/library/` already draws
between a note and a chat: a durable artefact is not a turn.
It must also be writable with no chat behind it at all, being the fallback
destination for a scheduled run whose own chat has gone.
`body` is Markdown written by a model and goes through
`services/markdown.py` like everything else from an endpoint. Hard rule 6
applies here exactly as it does in a transcript.
"""
__tablename__ = "reports"
owner_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
title: Mapped[str] = mapped_column(String(300), nullable=False)
# One line for the list page, so a feed of forty reports can be read without
# opening any of them. Written by the model beside the body; falls back to
# the body's first line when it did not bother.
summary: Mapped[str] = mapped_column(String(500), default="")
body: Mapped[str] = mapped_column(Text, default="")
source: Mapped[str] = mapped_column(String(16), default=SOURCE_MANUAL, nullable=False)
# The chat or the schedule this came out of, kept so a report can say where
# it was made. Deliberately not a ForeignKey: `migrations.py` compiles the
# column type only, so a REFERENCES clause would exist on a fresh database
# and not on an upgraded one -- the same reason `Chat.compacted_through_id`
# and `Folder.ssh_profile_id` are plain ids. Both are validated on read, and
# the row outliving what it points at is normal rather than exceptional: a
# report is worth keeping after the chat that produced it has been deleted.
source_id: Mapped[str] = mapped_column(String(32), default="")
schedule_id: Mapped[str] = mapped_column(String(32), default="")
model_id: Mapped[str] = mapped_column(String(300), default="")
# NOT NULL with a scalar default so `migrations._add_column_sql` can backfill
# it if this column is ever added to a table that already has rows.
unread: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Why a run produced nothing worth reading. A scheduled report that failed
# is still a report -- one that silently did not appear is indistinguishable
# from a schedule that never fired.
error: Mapped[str] = mapped_column(Text, default="")
def __repr__(self) -> str:
return f"<Report {self.title!r}>"
+85
View File
@@ -0,0 +1,85 @@
"""Schedules: what should happen later, and where its result goes."""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
from lembas.db.types import JSONDict
# Where a firing's result is delivered. Chosen per schedule rather than fixed by
# the screen it was made on: Reports has to stay reachable from anywhere, being
# the fallback, and a schedule somebody wants moved from its own chat to Reports
# should not have to be built again.
TARGET_CHAT = "chat"
TARGET_REPORT = "report"
TARGET_MESSAGES = "messages"
TARGETS = (TARGET_CHAT, TARGET_REPORT, TARGET_MESSAGES)
# Who made it. Kept because "why is this running?" is a question with two very
# different answers, and one of them is "a model decided to".
ORIGIN_USER = "user"
ORIGIN_MODEL = "model"
ORIGINS = (ORIGIN_USER, ORIGIN_MODEL)
class Schedule(UUIDPrimaryKey, Timestamps, Base):
"""One standing instruction and when it comes due.
The row carries no recurrence logic at all: `rule_json` is read by
`services/schedule/rule.py`, which is pure and knows nothing about rows.
What lives here is the bookkeeping the ticker needs to claim a firing
without doing it twice.
"""
__tablename__ = "schedules"
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
title: Mapped[str] = mapped_column(String(200), nullable=False, default="")
# What the reader actually typed, kept verbatim and for ever. The compile
# rewrites it into `instruction`, and "what did I actually ask for" has to
# survive that -- both so the edit form can show it and so a recompile has
# something to work from other than its own previous output.
request: Mapped[str] = mapped_column(Text, default="")
# What is sent when it fires. The compiled form: standalone, since it is
# read with no conversation around it.
instruction: Mapped[str] = mapped_column(Text, default="")
rule_json: Mapped[dict] = mapped_column(JSONDict, default=dict)
target: Mapped[str] = mapped_column(String(16), default=TARGET_CHAT, nullable=False)
# The chat this fires into. Deliberately not a ForeignKey -- `migrations.py`
# compiles the column type only, so a REFERENCES clause would exist on a
# fresh database and not on an upgraded one. Validated on read, and a
# dangling value disables the schedule rather than raising every tick.
chat_id: Mapped[str] = mapped_column(String(32), default="")
model_id: Mapped[str] = mapped_column(String(300), default="")
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# The ticker's entire query. Nullable because "nothing more to do" is a real
# state -- a spent count, a closed window, a calendar matching nothing --
# and is different from "due at the epoch".
next_fire_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
last_fire_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Stamped when a firing starts and cleared when it finishes, so a run that
# died halfway says so instead of looking like one that never happened.
claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
fired_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
# Why the last run did not work. Shown on the schedule's own page: a
# schedule that silently stopped producing anything is indistinguishable
# from one that was never due.
last_error: Mapped[str] = mapped_column(Text, default="")
origin: Mapped[str] = mapped_column(String(16), default=ORIGIN_USER, nullable=False)
compiled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
def __repr__(self) -> str:
return f"<Schedule {self.title!r} {'on' if self.enabled else 'off'}>"