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
+70
View File
@@ -1028,6 +1028,76 @@ nothing, and the next job started would never appear. The log tail is fetched
only for an expanded row — reading every job's output on every poll would be one
SSH connection per job per five seconds, for output nobody is looking at.
**The dot is coloured by outcome, and the panel is inset because the menu is
not.** `status` is `running|done|killed|lost`, and `done` is two outcomes — so
`jobs__dot--done` would have been green beside the row's own words "Failed, exit
2". `JobView.tone` answers the colour question and the template's if-chain keeps
answering the wording one, which is the half that cannot live in a class name.
`duration` is empty for a *running* job on purpose: this panel is fetched when
somebody opens it and is never polled (the chip is the thing on a timer), so a
live figure would be frozen the instant it painted. Its two stamps are normalised
before subtracting, for the reason `compaction.moment` exists — a job started
before a restart and finished after it has one naive stamp and one aware, and
subtracting them raises. `_short_duration` here is deliberately not `steps`'s:
that one takes milliseconds and tops out at minutes, and a three-hour build
through it reads `184m 12s`. And `.jobs__row` had no horizontal padding while
`.picker__menu` has none either, so every row ran flush into the border under a
header that was inset by `--sp-3`; `jobs__row--open` had been emitted by the
template since the panel shipped with no rule anywhere to render it, which is why
the row whose log was on screen looked like the ones that were not.
**The open chat page polls for turns it has not got.** `jobs.wake` starts a reply
without any request from the browser, and there is no channel to say so: the only
stream is per-message and it is opened by the `sse-connect` on an incomplete
assistant bubble — a bubble the page does not have, because the reply that made it
began elsewhere. `_queue_frames` proves the swap works but can only ride a stream
already open, so a job finishing on an idle chat lit the sidebar dot for the chat
the reader was *looking at* and did nothing else until a reload.
`GET /api/chats/{id}/tail?after=` is the answer, polled for the reason `/unread`
is. Four things about it:
- **A cursor it cannot place is answered with 204, never with the transcript.**
An absent `after`, one from another chat, one a rewind deleted: returning the
thread would append a second copy of every bubble the page still holds. A page
whose history was rewritten underneath it is one only a reload can reconcile,
and that is not this route's call to make with a half-typed message in the box.
- **The cut is read from the row**, so `_inject`'s restamp of the placeholder
moves it too, and the comparison is done **in SQL** — a row read back from
SQLite is naive and one still in the session is aware. The `id >` tie-breaker
is not decoration: under a bare `>` a row sharing the cut's microsecond is
skipped forever.
- **The cursor comes from the DOM** (`app.js`, on `htmx:configRequest`), because
the DOM is the honest answer to what the page holds — 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 forever. Not `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>` rather than the newest
message, and the poll then re-appends half the conversation.
- **It is silent while a reply is streaming**, and a `htmx:beforeSwap` listener
drops any answer containing a bubble the page already has. `hx-sync` cannot
reach that race — the two requests come from different elements — and a
duplicate here is not cosmetic, it is a second `sse-connect` for one message.
The route also clears `unread`/`unread_notified` on every tick **including the
204**: `_persist` marks a reply unread whenever `followers == 0`, which is true of
a job-woken reply with the reader watching it. The element lives outside `#thread`
(a rewind swaps that container's contents and would take the poller with it) and
outside the composer form (which would lend it `hx-target="#thread"`, the jobs
chip's old bug); `tests/test_chat_tail.py` walks the page and refuses both.
**A background job's completion is a user turn on the wire and a machine event on
screen.** 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 nothing about the request changes. Without it the transcript
rendered a machine's report under the reader's name with their initial beside it
and a pencil offering to rewrite it, which is the application putting words in
their mouth; the route refuses the edit too, because a hidden button is a
courtesy. `_completion_text` is deliberately untouched: the `tool.background`
fragment quotes its opening sentence to the model, so rewording it would break
that instruction with nothing anywhere to notice. The body skips the `tokens`
filter — an `@` in a command line is not a mention of anybody's files.
**Reasoning effort goes out twice, and only when it is set.** There is no field
that works everywhere. OpenAI and vLLM read `reasoning_effort`; llama.cpp's own
documentation says other values "have no effect", its maintainer says