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 a63723713f
commit 08fec2cb64
12 changed files with 1023 additions and 9 deletions
+132
View File
@@ -436,6 +436,138 @@ async def test_two_jobs_finishing_at_once_start_one_reply(db, user_id, registere
assert len(started) == 1, "the lock made the second wake see the first's reply"
async def test_a_completion_is_marked_as_a_machine_event(db, user_id, registered, monkeypatch):
"""The role stays `user` -- `_inject` sends a queued turn verbatim and
`build_messages` has to keep seeing a user turn -- and `machine` is what
stops the transcript claiming the reader typed it."""
from lembas.services import generation as generation_service
chat_id = _chat(db, user_id)
monkeypatch.setattr(generation_service, "running_for", lambda _c: None)
monkeypatch.setattr(generation_service, "ensure", lambda c, m: None)
await jobs.wake(chat_id, "abc123abc123", "sleep 1", "done", 0, "hi")
from lembas.db.models import Message
db.expire_all()
users = [
m for m in db.query(Message).filter(Message.chat_id == chat_id).all() if m.role == "user"
]
assert users[0].role == "user", "the wire role is load-bearing and must not move"
assert users[0].machine is True
async def test_a_queued_completion_is_marked_too(db, user_id, registered, monkeypatch):
"""The busy path writes the same row with `queued` set; it must not lose the
marking on the way, or a completion delivered by `_drain` arrives wearing the
reader's name."""
from lembas.services import generation as generation_service
chat_id = _chat(db, user_id)
monkeypatch.setattr(generation_service, "running_for", lambda _c: object())
monkeypatch.setattr(generation_service, "ensure", lambda c, m: None)
await jobs.wake(chat_id, "abc123abc123", "sleep 1", "done", 0, "hi")
from lembas.db.models import Message
db.expire_all()
users = [
m for m in db.query(Message).filter(Message.chat_id == chat_id).all() if m.role == "user"
]
assert users[0].queued is True
assert users[0].machine is True
def test_the_prompt_quotes_the_words_the_completion_actually_carries():
"""`tool.background` tells the model a completion "begins" with a particular
sentence, so that it reads one as a machine event rather than as the person
speaking. Rewording `_completion_text` breaks that instruction, in a way
nothing else here would notice -- the turn still arrives, the model just
stops being told what it is."""
from lembas.services import prompts as prompts_service
text = jobs._completion_text("abc123abc123", "pytest -q", "done", 0, "")
opening = "A background job you started has finished"
assert text.startswith(opening)
fragment = next(f for f in prompts_service.BUILTIN if f.key == "tool.background")
assert opening in fragment.default
# --- What a job looks like in the panel ----------------------------------------
@pytest.mark.parametrize(
("status", "exit_status", "expected"),
[
("running", None, "running"),
("done", 0, "ok"),
("done", 2, "failed"),
("killed", 143, "killed"),
("lost", None, "lost"),
],
)
def test_the_dot_tells_a_failure_from_a_success(status, exit_status, expected):
"""`status` is `done` for exit 0 and exit 2 alike, and the row beside the dot
already says "Finished" or "Failed, exit 2". A dot keyed on the status would
be green next to the sentence contradicting it."""
view = jobs.JobView(id="a", command="x", status=status, exit_status=exit_status)
assert view.tone == expected
def test_a_finished_job_says_how_long_it_took():
from datetime import UTC, datetime, timedelta
started = datetime(2026, 8, 5, 14, 0, tzinfo=UTC)
view = jobs.JobView(
id="a",
command="x",
status="done",
exit_status=0,
started_at=started,
finished_at=started + timedelta(seconds=252),
)
assert view.duration == "4m 12s"
def test_a_running_job_reports_no_duration():
"""Not for want of an answer. The panel is fetched when somebody opens it and
never polled, so a live figure would be frozen the instant it painted."""
from datetime import UTC, datetime
view = jobs.JobView(
id="a",
command="x",
status="running",
started_at=datetime(2026, 8, 5, 14, 0, tzinfo=UTC),
)
assert view.duration == ""
def test_a_job_with_no_row_reports_no_duration():
"""`_persist_row` is best-effort by design, so a job with no stamps is a real
case rather than a defensive one."""
assert jobs.JobView(id="a", command="x", status="done", exit_status=0).duration == ""
def test_a_duration_survives_a_stamp_read_back_from_disk():
"""SQLite stores no offset, so a row loaded from disk comes back naive while
one still in the session's identity map keeps its tzinfo -- and a job started
before a restart and finished after it has one of each. Subtracting them
without normalising raises, and it raises in the panel, not in a test."""
from datetime import UTC, datetime
view = jobs.JobView(
id="a",
command="x",
status="done",
exit_status=0,
started_at=datetime(2026, 8, 5, 14, 0), # naive, as SQLite hands it back
finished_at=datetime(2026, 8, 5, 15, 6, tzinfo=UTC),
)
assert view.duration == "1h 06m"
# --- The watcher, end to end ---------------------------------------------------
async def test_the_watcher_end_to_end(db, user_id, registered, monkeypatch, tmp_path):
from lembas.services.agent import ssh as ssh_service