An edge that is not drawn, and a panel that stopped eating the site
`hx-get=""` is not "fetch nothing". htmx looks for the attribute, not for a value, so the empty one the canvas rendered before a chat existed was a real request for the empty path -- which the browser resolves against the current document. Opening the canvas on the new-chat screen fetched the new-chat screen and swapped the whole site into the panel. The attribute is omitted now, and a test refuses an empty verb anywhere on the page. Which panels can exist is the server's answer; which are offered is the browser's. Both need an agent chat on a chosen connection, and before a chat exists those are controls in the composer -- so answering with the first profile offered a terminal on an ordinary chat with nothing selected. They follow `lembas:agent-target` now, and an open panel whose target goes away is closed rather than left showing one machine under another's name. `.tabs__body` is only sometimes the scroller: true where the tabs are a bounded flex child, false under the admin layout, where the page scrolls instead. So setting its scrollTop on every tab change had never once run on /admin/prompts, silently, while the reader was dragged to the bottom of a document that had just got shorter. The rule names the position now, and the handler finds the container that actually scrolls. The two top borders come off. They were what made the misalignment at the bottom of the shell visible; `--footer-height` stays, because two ends at different heights are visible without a line to prove it. The top of the shell keeps its line -- there, everything is `--header-height` and aligns by construction. And one version. pyproject carried its own copy and had drifted three minors from the one everything actually reads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user