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>
12 KiB
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.