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:
@@ -94,6 +94,9 @@ src/lembas/
|
||||
terminal.py the terminal panel's WebSocket, and its two locks
|
||||
audio.py transcribe, speak, voice discovery
|
||||
library.py knowledge, notes, skills pages; memory CRUD
|
||||
messages.py the one long conversation, and paging back through it
|
||||
reports.py the Reports feed, and one report on its own page
|
||||
schedules.py the Scheduled list, the rule form, and a task's controls
|
||||
files.py upload, serve, remove attachments
|
||||
preferences.py per-user theme, default model, password, audio
|
||||
db/
|
||||
@@ -118,6 +121,16 @@ src/lembas/
|
||||
patch.py (applying a unified diff, and rendering one)
|
||||
audio.py OpenAI-shaped /v1/audio/* client
|
||||
fetch.py URL retrieval, HTML to text, the SSRF guard
|
||||
messages.py the one conversation per person: bounded in the request,
|
||||
unbounded on disk
|
||||
reports.py filing a finished piece of work, and finding it again
|
||||
schedules.py making, changing and stopping a schedule
|
||||
schedule/ work that happens because time passed: clock.py (whose
|
||||
"now"), rule.py (the recurrence, pure and total),
|
||||
ticker.py (the loop and the claim), runner.py (firing),
|
||||
compile.py (plain words into a rule)
|
||||
wake.py starting a reply from outside a request -- one lock
|
||||
discipline, shared by finished jobs and by schedules
|
||||
sharing.py one visibility rule for every library store
|
||||
prompts.py every injected prompt fragment, and {{variables}}
|
||||
metrics.py tokens, context percentage and tokens/second
|
||||
@@ -1206,6 +1219,150 @@ The composer keeps its own because it does more: it follows the selected
|
||||
profile's default directory until somebody picks their own, which only means
|
||||
something while a chat is being created.
|
||||
|
||||
**Messages is bounded in the request and unbounded on disk.** One conversation
|
||||
per person, meant to run for years, so it cannot all be sent -- `build_messages`
|
||||
takes the last `LIVE_CHUNK` turns and nothing before them. **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, that hiding turns is not
|
||||
deleting them. `compaction.should_compact` refuses this kind for the matching
|
||||
reason: two mechanisms narrowing one transcript is how a summary ends up
|
||||
summarising a summary.
|
||||
|
||||
The consequence is worth stating rather than discovering: past the live chunk
|
||||
the model genuinely does not see what was said.
|
||||
|
||||
`GET /api/messages/history?before=` is the mirror of `thread_tail` and keeps its
|
||||
four properties -- 204 on a cursor it cannot place, the comparison in SQL with an
|
||||
`id` tie-breaker (without which a row sharing the cursor's microsecond can never
|
||||
be reached, and a message that cannot be scrolled back to is gone), an explicit
|
||||
`hx-target`, and a sentinel outside the composer form. The fifth is its own:
|
||||
**prepending moves the scroll position**, so `app.js` records `scrollHeight`
|
||||
before the swap and adds the difference back after. Without it the reader is
|
||||
dragged up the page the instant the sentinel fires, which reads as a browser bug.
|
||||
|
||||
**`TemplateResponse` injects nothing.** The history route renders
|
||||
`chat/_message.html` outside `render()`, so `user` *and* `chat` are passed by
|
||||
hand -- the same reason the SSE path does. Missing either is a 500 on scroll from
|
||||
a page that rendered perfectly.
|
||||
|
||||
**A schedule is claimed before it is fired, and that order is the design.**
|
||||
`ticker.sweep` moves the row on -- `fired_count`, `last_fire_at`, the next
|
||||
`next_fire_at` -- and **commits** before a single firing is awaited. 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. A sweep lock stops two
|
||||
overlapping passes claiming the same row, because a firing awaits a model and can
|
||||
take minutes. Exhaustion *disables*: a rule with nothing left returns `None` and
|
||||
the row is switched off rather than examined for ever.
|
||||
|
||||
The blanket `except` around the loop is copied from `terminal._reaper_loop` for a
|
||||
sharper reason than the reaper has. **A ticker that dies on one bad row stops
|
||||
every schedule on the instance and says nothing** -- no request fails, no reply
|
||||
errors, no dot appears. The reports simply stop.
|
||||
|
||||
**`rule.py` is pure, total and tested before anything calls it.** No session, no
|
||||
wall clock, nothing that raises. `validate` is this feature's `nh3.clean`: the
|
||||
compile step's output is *model output that becomes a timer*, so it clamps what
|
||||
it recognises, drops what it does not, and answers `{}` for prose -- at which
|
||||
point the route shows the manual form rather than writing a schedule that can
|
||||
never fire. The invariant, pinned in the tests, is that **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 deliberately different. `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 rather than being
|
||||
skipped, because a daily report vanishing once a year on a machine nobody watches
|
||||
is exactly the failure this file is arranged around; `zoneinfo`'s own resolution
|
||||
yields an instant an hour away wearing a wall-clock time that did not happen.
|
||||
|
||||
**`services/wake.py` is one lock discipline with two callers.** A finished
|
||||
background job and a due schedule are the same problem -- put a turn into a chat
|
||||
from outside any request and get it answered -- 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, so `jobs.wake` is now a caller that
|
||||
supplies wording. `_completion_text` stayed where it was, because
|
||||
`tool.background` quotes its opening sentence to the model.
|
||||
|
||||
**Three rules around firing each look like a bug from outside.** A firing
|
||||
arriving while the chat still answers the previous one *queues* rather than
|
||||
starting a second reply -- but `_drain` takes one per reply, so the queue is
|
||||
bounded and past `max_queued` the firing is skipped with the reason on the row.
|
||||
**Run now does not advance `next_fire_at`**, or testing a schedule would silently
|
||||
consume the run it was testing. **Resuming recomputes from now**, or a schedule
|
||||
paused for a month fires the instant it comes back, once for every occurrence it
|
||||
missed.
|
||||
|
||||
**A task chat is created with its schedule, and that is the one place "chats are
|
||||
created lazily" is bent.** The lazy rule exists so an opened-and-abandoned chat
|
||||
never appears in the sidebar; a task chat is not opened and abandoned, because
|
||||
creating it *is* the act -- and it has to exist before a first firing that may be
|
||||
days away with nobody present to make one. Removing a schedule keeps the chat by
|
||||
default and turns it back into an ordinary one: deleting a transcript as a side
|
||||
effect of removing a timer is the destructive default this codebase avoids, and a
|
||||
`KIND_TASK` chat with no schedule behind it would appear in no list at all.
|
||||
|
||||
**A task chat may not be an agent chat, in v1.** Scheduling one means running
|
||||
commands on a timer with nobody watching -- and since Manual, Edit and Plan all
|
||||
stop to ask on `RISK_EXECUTE`, the only two outcomes are unattended execution and
|
||||
a reply that stalls until `approval_timeout`. Neither is a feature. That deserves
|
||||
its own pass with a mode built for it.
|
||||
|
||||
**A task chat has no composer, and the suppression is by absence.**
|
||||
`chat/index.html` includes `schedules/_strip.html` instead. `chat/_composer.html`
|
||||
is the only thing that posts a message, so its absence *is* the guarantee -- a
|
||||
hidden one would still be a form anybody could post to, the same reason Reports
|
||||
has no route that would accept one.
|
||||
|
||||
**An empty `kind` means both sides of the switch, and never "no filter".** For
|
||||
as long as there were exactly two kinds those were the same sentence, and the
|
||||
sidebar leant on it: `Folder.visible_chats` read `not kind or chat.kind == kind`
|
||||
and `sidebar_context` added its `where` only when `kind` was truthy. `kind` is
|
||||
`""` precisely when the Chat/Agent switch is *absent* — an instance with agent
|
||||
chats turned off — so the moment a third kind existed, every conversation
|
||||
belonging to a section rather than to the tree appeared in somebody's ordinary
|
||||
chat list, on exactly the instances whose owners would never think to look.
|
||||
|
||||
So `KINDS` stays the two-sided switch and `ALL_KINDS` is what a row may be.
|
||||
**`KINDS` must not grow**: `api/preferences.py:set_sidebar_kind` validates
|
||||
against it, and a third entry there makes 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. Both narrowings filter against
|
||||
`KINDS`, and both are pinned in `tests/test_sidebar_sections.py`, because they
|
||||
are two implementations of one rule and only one of them is SQL: fixing the
|
||||
query alone leaves a task chat filed in a folder showing up anyway.
|
||||
|
||||
`/api/chats/unread` narrows the same way and for a sharper reason — a section
|
||||
gets **one dot for the section**, not one per conversation inside it, so forty
|
||||
task chats must not mean forty out-of-band spans aimed at elements that are not
|
||||
on the page. htmx says nothing at all when an OOB target is missing, so that
|
||||
would be silent waste rather than a visible bug.
|
||||
|
||||
**A report is not a chat with one message in it.** It has a title, a body, a
|
||||
time and a source; it is read top to bottom and never answered; and it must be
|
||||
writable with no chat behind it at all, being the fallback destination for
|
||||
scheduled work whose own chat has gone. As a `Chat` it would need a sidebar row
|
||||
per daily report, a `title_generated` flag, an `unread` flag, a composer to
|
||||
suppress and a bubble with an avatar and a rewind button around something that
|
||||
is not a turn. It is the line `services/library/` already draws from the other
|
||||
side, and `services/reports.py` is deliberately thinner than the library stores:
|
||||
no sharing (a report records what somebody's own model did for them) and no
|
||||
revisions (it describes a moment, not a document being worked on).
|
||||
|
||||
The section's character is enforced by absence rather than by suppression:
|
||||
`reports/*.html` never includes the composer and never renders
|
||||
`chat/_message.html`, so there is no `sse-connect` anywhere on those pages and
|
||||
nothing on them *can* start a generation. `tests/test_reports.py` asserts both
|
||||
the markup and, from the OpenAPI schema, that no route under `/reports` or
|
||||
`/api/reports` accepts anything but the delete. Read the schema and not
|
||||
`app.routes` — this FastAPI keeps an included router wrapped rather than
|
||||
flattening it, so walking the routes finds nothing and the assertion passes for
|
||||
the wrong reason.
|
||||
|
||||
**The sidebar shows one kind at a time.** `Chat.kind` distinguishes an agent
|
||||
chat everywhere except the one place a person looked. The switch is stored on
|
||||
the account, and three things about it are not the obvious version. It lives
|
||||
|
||||
Reference in New Issue
Block a user