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
+71 -3
View File
@@ -42,6 +42,7 @@ import re
import time
import uuid
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
@@ -381,6 +382,68 @@ class JobView:
def running(self) -> bool:
return self.status == "running"
@property
def tone(self) -> str:
"""What colour this job is, which is not the question `status` answers.
`done` is two outcomes. The row beside the dot already tells them apart
in words -- "Finished" against "Failed, exit 2" -- so a dot keyed on the
status would be green next to a sentence saying the opposite.
The *wording* stays in the template's if-chain rather than moving here
beside the colour. Authored text belongs in the file somebody reads to
change it, and saving one branch is not worth taking five phrases out of
it; this is the half that cannot be said in a class name.
"""
if self.running:
return "running"
if self.status != "done":
return self.status # killed, lost
return "ok" if not self.exit_status else "failed"
@property
def duration(self) -> str:
"""How long it took, once it is over. Empty while it is still running.
Empty on purpose rather than for want of an answer. This panel is
fetched when somebody opens it and is never polled -- the chip beside
the composer is what refreshes on a timer -- so a live "running for
2m 05s" would be stale the instant it painted and stay stale until the
reader pressed something. The chip says something is still going; this
says how long the finished ones took, which is true forever.
Both stamps are normalised before subtracting, for the reason
`compaction.moment` normalises: SQLite stores no offset, so a row read
back from disk is naive while one still in the session's identity map
keeps its tzinfo, and subtracting one from the other raises. `moment`
itself is not reused because it takes a `Message`, not a stamp.
"""
if self.running or self.started_at is None or self.finished_at is None:
return ""
seconds = (_aware(self.finished_at) - _aware(self.started_at)).total_seconds()
return _short_duration(seconds) if seconds >= 0 else ""
def _aware(stamp: datetime) -> datetime:
"""A stamp that can be subtracted from another. See `JobView.duration`."""
return stamp if stamp.tzinfo is not None else stamp.replace(tzinfo=UTC)
def _short_duration(seconds: float) -> str:
"""A wall-clock span, at the precision somebody reading a log cares about.
Deliberately not `steps._short_duration`. That one takes milliseconds, tops
out at minutes and is tuned to a label repainting beside an animating word;
a three-hour build through it reads `184m 12s`. This one is written for a
span that can be hours and is only ever rendered once it is final.
"""
total = int(seconds)
if total < 60:
return f"{total}s"
if total < 3600:
return f"{total // 60}m {total % 60:02d}s"
return f"{total // 3600}h {(total % 3600) // 60:02d}m"
def listing(db, chat_id: str) -> list[JobView]:
"""Every job this chat has, newest first.
@@ -453,8 +516,6 @@ def clear() -> None:
# chat, a transient database hiccup) still runs and is still tracked in-process;
# it just will not survive a restart, which is the row's only purpose.
def _persist_row(job: JobState) -> None:
from datetime import UTC, datetime
from lembas.db.models import Job
from lembas.db.session import session_scope
@@ -614,7 +675,14 @@ async def wake(
chat = db.get(Chat, chat_id)
if chat is None:
return
chat_service.create_message(db, chat, ROLE_USER, content, queued=running)
# `machine` changes the bubble and nothing else: the role stays
# `user` because `_inject` sends a queued turn verbatim and the
# request must carry a user turn, and the framing the *model*
# reads is in the words `_completion_text` wrote. What it stops
# is the transcript claiming the reader typed this.
chat_service.create_message(
db, chat, ROLE_USER, content, queued=running, machine=True
)
if not running:
assistant = chat_service.create_message(
db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id
+2
View File
@@ -530,6 +530,7 @@ def create_message(
complete_: bool = True,
model_id: str = "",
queued: bool = False,
machine: bool = False,
) -> Message:
message = Message(
chat_id=chat.id,
@@ -538,6 +539,7 @@ def create_message(
complete=complete_,
model_id=model_id,
queued=queued,
machine=machine,
)
db.add(message)
db.commit()