feca8861a1
Asked to remind somebody every Monday, a model looked down its tool list, found notes_create described as "something worth having in a later conversation" and memory_add beginning with the word Remember, wrote a note, and reported that it had scheduled something. Every screen agreed with it. There was no scheduling tool at all -- the near-misses were the only thing there was to reach for, and nothing anywhere said the thing it was being asked for existed. The seam had been left open on purpose: Schedule.origin has defined ORIGIN_MODEL, with no writer, since scheduling shipped, and services/schedules.py says in its first line that it holds what the routes *and the tools* both need. This is the tool that was meant to go through it. Four of them, and a thin layer: rule.validate is still the one total normaliser the form and the compile share, schedules.create still writes the row and the task chat together, and rule.describe still says what came out. A second dialect for models would mean two definitions of "every other Tuesday" and one of them going quietly wrong. The result is that description, never "done". A schedule is invisible until it fires, which may be days away, so the sentence in the reply is the only moment anybody can check that Monday was read as Monday -- and the tool says so, in the text the model reads back. The list badges the ones nobody typed. Gated on schedule.use rather than a permission of its own: somebody who may set one up by hand may say so to a model instead, and a second checkbox beside the first would only ever be answered "the same as that one". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
187 lines
12 KiB
Markdown
187 lines
12 KiB
Markdown
# Schedules, reports and the sidebar's sections
|
|
|
|
Split out of `CLAUDE.md` -- same document, same rules, kept here because that
|
|
file is loaded in full on every session and this part is only wanted when you
|
|
are working on work that happens because time passed. Read it before you do.
|
|
|
|
Covers `services/schedule/`, `services/schedules.py`, `services/wake.py`,
|
|
`services/reports.py`, and how a third `Chat.kind` narrows the sidebar.
|
|
|
|
**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
|
|
*inside* the fragment it swaps, or the two buttons would go on showing the side
|
|
you had just left — and "New chat", which sits *above* the scroll area rather
|
|
than in the tree, comes along out of band
|
|
(`partials/_sidebar_actions.html`, rendered with `oob` only by the fragment
|
|
route). That one shipped broken: the button went on saying "New chat" over a
|
|
list of agent chats. Whether it *worked* was never the question — it said one
|
|
thing and did another, which is the shape of failure the switch itself was
|
|
arranged to avoid. `Folder.shown_in` hides a folder the filter emptied and keeps
|
|
one that was empty to begin with — the second is a container somebody just made,
|
|
and hiding it means it can never be found again, let alone filed into. And with
|
|
agent chats switched off there is no switch and no filtering at all, rather than
|
|
one side of a fork nobody can move: an administrator turning the feature off
|
|
would otherwise strand whoever last left it on Agents in an empty sidebar.
|
|
|
|
## A model can schedule, and could not before
|
|
|
|
**There was no scheduling tool, and that was the whole failure.** Asked to
|
|
"remind me every Monday at noon", a model looked down its list, found
|
|
`notes_create` described as *"something worth having in a later conversation"*
|
|
and `memory_add` beginning with the word *Remember*, wrote a note, and said it
|
|
had scheduled something. Every screen agreed with it. No amount of prompting
|
|
fixes that: the near-misses were the only thing there was to reach for, and
|
|
nothing anywhere said scheduling existed.
|
|
|
|
The seam had been left open. `Schedule.origin` has defined `ORIGIN_MODEL` since
|
|
the feature shipped with **no writer**, and `services/schedules.py` says in its
|
|
first line that it holds "what the routes *and the tools* both need".
|
|
`services/schedule/tool.py` is what was meant to go through it.
|
|
|
|
**One vocabulary, not a second one.** The four tools are a thin layer over what
|
|
the form already uses: `rule.validate` is the single total normaliser — the
|
|
manual form, the compile step and the tool all hand it the same raw shape —
|
|
`schedules.create` writes the row and the task chat together, and
|
|
`rule.describe` says what came out in words. A separate dialect for models would
|
|
mean two definitions of "every other Tuesday" and one of them going quietly
|
|
wrong. The `tool.schedule` fragment is deliberately worded from
|
|
`task.schedule_compile`, which has been turning people's words into this same
|
|
JSON since the feature shipped.
|
|
|
|
**The tool answers with `rule.describe`, never "done".** A schedule is invisible
|
|
until it fires, which may be days away, so the sentence in the reply is the only
|
|
moment anybody can check that Monday was understood as Monday. The tool hands
|
|
the description over and says, in the result text, to quote it. `ORIGIN_MODEL`
|
|
goes on the row for the matching reason: the Scheduled list badges the ones
|
|
nobody typed, because otherwise a model's decision and the reader's own are the
|
|
same row.
|
|
|
|
**Gated on `schedule.use`, not on a `tools.schedule` of its own.** A reader who
|
|
may set a schedule up by hand may say so to a model instead, and a second
|
|
permission beside the first would only ever be answered "the same as that one".
|
|
The instance switch is passed into `_family_allowed` the way `images` is, so an
|
|
instance with scheduling off offers nothing — a model handed a tool that cannot
|
|
work spends a round finding out, which in a one-round reply is the whole reply.
|
|
|
|
**`tool.notes` and `tool.memory` both say what they are not for.** They are what
|
|
the model actually reached for, so each ends with the line that redirects:
|
|
anything that should *happen* at a time is a schedule, and remembering that
|
|
something should happen does not make it happen.
|