A job that finishes reaches the page you are looking at

Three complaints, all downstream of background commands.

A finished job woke the model and not the browser. `jobs.wake` writes the
completion and calls `generation.ensure`, and nothing tells the page: the only
stream here is per-message, opened by the `sse-connect` on an incomplete
assistant bubble -- which is a bubble this page has not got, because the reply
that created it began somewhere else. `_queue_frames` proves the swap works and
can only ride a stream already open. So the reader sat on the chat, watched the
sidebar dot light up for the chat in front of them, and had to click it or
reload to see a reply that had been there for minutes.

`GET /api/chats/{id}/tail?after=` and a five-second poller is the answer, polled
for the reason `/unread` is: a second always-on connection per tab is a lot of
machinery for something that happens a few times a day. A cursor it cannot place
-- absent, from another chat, naming a row a rewind deleted -- is answered with
204 and never with the transcript, which the page still holds every bubble of.
The cut is read from the row so `_inject`'s restamp moves it too, and compared in
SQL, a row read back from SQLite being naive where one still in the session is
aware; the `id >` tie-break is not decoration, since under a bare `>` a row
sharing the cut's microsecond is skipped for ever.

The cursor comes from the DOM, because the DOM is the honest answer to what the
page has -- the composer's POST, the `done` frame and the last poll all move it,
and a variable would have to be updated by each of them, correctly, for ever. On
`htmx:configRequest` rather than `hx-vals="js:…"`: two of the three things that
handler does are cancellations, which `hx-vals` cannot express. Not
`article.msg:last-of-type` either -- that is per-parent, so on a compacted chat
it answers with the last article inside the `<details>` and the poll re-appends
half the conversation. It is silent while a reply streams, since that reply
delivers its own bubbles in the one frame that can get the order right, and a
`htmx:beforeSwap` listener drops any answer holding a bubble already on the page:
the race `hx-sync` cannot reach, and a duplicate there is a second `sse-connect`
for one message rather than a cosmetic one. The route clears `unread` on every
tick including the 204, because `_persist` marks a reply unread whenever
`followers == 0` and that is true of a job-woken reply with somebody watching it.

The completion also claimed the reader had sent it. The role is load-bearing --
`_inject` sends a queued turn verbatim and `build_messages` must keep seeing a
user turn -- so `Message.machine` marks the bubble instead and the request is
untouched. Their initial, their name and a pencil offering to rewrite what a
machine reported: the route refuses the edit too, a hidden button being a
courtesy. `_completion_text` is deliberately unchanged, `tool.background` quoting
its opening sentence to the model, and there is now a test holding the two
together.

And the panel. `.jobs__row` had no horizontal padding while `.picker__menu` has
none either, so every row ran flush into the border under a header inset by
--sp-3. `jobs__row--open` had been emitted since the panel shipped with no rule
anywhere, so the row whose log was on screen looked like the ones that were not.
The dot was keyed on `status`, and `done` is exit 0 and exit 2 alike -- green
beside the row's own "Failed, exit 2" -- so `JobView.tone` answers the colour and
the template goes on answering the wording, which is the half a class name cannot
carry. `duration` is empty for a running job on purpose: this panel is fetched
when somebody opens it and never polled, so a live figure would freeze the
instant it painted. Its stamps are normalised before subtracting, a job started
before a restart and finished after it having one naive and one aware.

Driven under the DOM stub before committing, per the standing rule: two listeners
on document.body for events dispatched at a requesting element are exactly the
shape a regex cannot check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-05 12:19:07 +02:00
parent 4643d1b584
commit e9fab9d858
14 changed files with 1105 additions and 11 deletions
+99 -1
View File
@@ -12,7 +12,7 @@ from types import SimpleNamespace
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
from sqlalchemy import func, select
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session as DBSession
from lembas.api.deps import Db, RequiredUser, require_permission
@@ -723,6 +723,91 @@ async def unread_poll(db: Db, user: RequiredUser) -> Response:
return response
@router.get("/{chat_id}/tail")
async def thread_tail(db: Db, user: RequiredUser, chat_id: str, after: str = "") -> Response:
"""Turns this page has not got yet, appended to the transcript it is showing.
A reply can begin without a request from the browser: `jobs.wake` writes a
completion turn and calls `generation.ensure` when a background job finishes
on an idle chat. There is no channel to tell the page about it. The only
stream here is per-message and it is opened by the `sse-connect` on an
incomplete assistant bubble -- a bubble this page does not have, because the
reply that created it started somewhere else. `_queue_frames` proves the swap
works, but it can only ride a stream that is already open.
So the page asks. Polled for the same reason `/unread` is: a second always-on
connection per tab is a great deal of machinery for something that happens a
few times a day. The cursor comes from the browser -- see `app.js`, which
reads the last bubble in `#thread`, the honest answer to what this page
already holds.
"""
chat = _owned_chat(db, chat_id, user.id)
# Somebody is looking at this chat, which is what `unread` means the absence
# of. `_persist` marks a reply unread whenever `generation.followers == 0`,
# and that is true of a job-woken reply even with the reader watching it --
# so today the toast announces a chat that is already on screen. This is
# `pages.chat_detail` said again for as long as the page stays open rather
# than once when it loads, and it is cleared whether or not anything arrived:
# the claim being made is that somebody is here.
#
# Not airtight, and not pretending to be: the sidebar polls on 10s and this
# on 5s, so this usually wins, but a badly timed tick can still raise one
# toast for the chat in front of you.
if chat.unread or chat.unread_notified:
chat.unread = False
chat.unread_notified = False
db.commit()
# No cursor, a cursor from another chat, or one naming a row a rewind has
# since deleted. Answering with the transcript would append a second copy of
# every bubble the page still holds, and a page whose history was rewritten
# underneath it is one only a reload can reconcile -- which is not this
# route's decision to make, with a half-typed message possibly in the box.
cut = db.get(Message, after) if after else None
if cut is None or cut.chat_id != chat.id:
return Response(status_code=status.HTTP_204_NO_CONTENT)
# The cut is read from the row rather than taken as a timestamp on the wire,
# which is what makes `_inject`'s restamp harmless: if the page's last bubble
# was the assistant placeholder and the placeholder moved, the cut moves with
# it. Compared in SQL and never in Python, for the reason `compaction.moment`
# exists -- a row read back from SQLite is naive and one still in the session
# is aware, and `>` between them raises.
#
# The id clause is not decoration. Under a bare `>` a row sharing the cut's
# microsecond is skipped forever; with it, at most the one sorting lower is.
fresh = list(
db.scalars(
select(Message)
.where(
Message.chat_id == chat.id,
or_(
Message.created_at > cut.created_at,
and_(Message.created_at == cut.created_at, Message.id > cut.id),
),
)
.order_by(Message.created_at, Message.id)
)
)
if not fresh:
# 204 and not an empty 200: htmx does not swap on a 204, where an empty
# body would still fire a swap and a settle on every open page every
# five seconds.
return Response(status_code=status.HTTP_204_NO_CONTENT)
# Queued turns come too, unfiltered. A completion waiting behind a running
# reply is exactly what the reader wants to watch arrive, and its bubble can
# never carry `sse-connect` -- `_message.html` requires the assistant role
# for that. When the running reply ends, `_queue_frames` deletes the stale
# node out of band and re-renders it in place, so arriving early costs
# nothing.
#
# No `just_finished`: that flag is what read-aloud-automatically keys off,
# and a bubble the page merely missed must not start talking.
return HTMLResponse("".join(_render_bubble(db, chat, user, row) for row in fresh))
@router.post("/{chat_id}/messages")
async def post_message(
request: Request,
@@ -1230,6 +1315,11 @@ async def edit_form(
message = db.get(Message, message_id)
if message is None or message.chat_id != chat.id or message.role != ROLE_USER:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That message no longer exists.")
# See `edit_message` for why, and for why this is not the same sentence.
if message.machine:
raise HTTPException(
status.HTTP_404_NOT_FOUND, "A background job's message cannot be edited."
)
return templates.TemplateResponse(
request,
@@ -1287,6 +1377,14 @@ async def edit_message(
message = db.get(Message, message_id)
if message is None or message.chat_id != chat.id or message.role != ROLE_USER:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That message no longer exists.")
# The bubble hides the pencil, but a hidden button is a courtesy and this is
# the rule: editing rewinds and re-sends under the reader's own authority,
# and what a machine reported is not theirs to rewrite. Its own sentence,
# because "no longer exists" would be false and would leave nothing to do.
if message.machine:
raise HTTPException(
status.HTTP_404_NOT_FOUND, "A background job's message cannot be edited."
)
content = content.strip()
if not content and not message.attachments: