5. Render one book at a time, in its own unit, and let the page watch#
Status#
Accepted. Two things below are out of date and both are about the screen rather
than the mechanism. This panel does open books now — on the shelf, added
2026-08-07 — and it is no longer one panel: it was cut in two on 2026-08-08,
Books at night and Workshop in daylight, with the catalog search, the queue
and the ended rows all on the daytime half. The paragraph headed The page
watches, and adds, and never opens is annotated where both matter.
Nothing about the queue itself, the worker unit, the lease or the heartbeat is
affected by either. What was added in the first was a list of books somnia has
already rendered and one write of position_at; the second moved elements
between two overlays and moved the poll with them. The argument for the first
reversal is in ADR 3’s amendment.
Context#
Adding a book has no queue. Library.add_book spawned somnia add with
start_new_session=True and all three stdio at /dev/null, discarded the
handle, and returned a sentence promising chapter one in a few minutes. Two
requests a minute apart gave two Kokoro processes on two vCPUs, and the render
then no longer outruns 1× listening — which is the assumption the whole of
streaming ingest rests on. design.md says so: at about 1.15×
realtime the renderer outruns a listener with a thin margin. Two renders halve
a thin margin, and the sentence about chapter one stops being true.
Worse, nothing could be seen. An agent-started render wrote one line to the
server’s log saying it had begun and then nothing at all; a database is locked after the five-second busy timeout raised into a process whose stderr
was /dev/null, and the render simply vanished, leaving status = 'rendering'
for ever. Nothing recorded how many chapters a book has, so no honest
fraction existed. Nothing distinguished a render that had died from one that
was merely slow — the only evidence either way was whether the chapter count
was still moving, and the only thing that knew when it last moved was the mtime
of an m4a. There was no cancel: you killed it by hand and the row stayed as it
was. And there was no resume, so restarting a dead render started at chapter
one and added a second copy of every passage to the index
(#11), which is why
keep-renders-running.md said, in bold,
do not add Restart=.
The deploy makes it sharper than it looks. somnia-serve.service carries
Restart=always, and deploying is pull main and restart the unit.
start_new_session=True escapes the session and the process group but not the
cgroup, and the unit uses the default KillMode=control-group — so every
render the agent has ever started was already shot dead on every deploy,
silently, leaving a row claiming work that nobody was doing. Every book on the
VPS stuck on rendering is one of those.
The alternatives we rejected#
A render thread inside somnia serve. The obvious one, and it fails on the
sentence the feature exists to defend. Kokoro and the embedder resident in the
process that answers the page means a restart costs twenty seconds rather than
one, an OOM takes the page down with the book, and the renderer competes for
both cores with the thing serving a 206 — which contradicts the fast lane’s own
premise, written in server.py, that a seek is never stuck behind a model
turn. It can be nice’d, but a renderer nice’d below 1× realtime fails the
streaming premise outright, and “one at a time, slowed down” is a worse remedy
than “one at a time”. A separate unit gets the page the same protection with
Nice= and CPUWeight= without gambling on the margin.
A lock file instead of a heartbeat. flock answers “is anything rendering”
in one syscall and needs no schema at all. It cannot answer the question that
matters: a wedged process still holds its lock, so the queue reads healthy for
ever, and a page showing “rendering” for six hours of nothing is worse than one
that says the renderer has gone quiet. beat_at is the only thing that tells a
crashed renderer from a slow one, and because staleness is computed when
somebody looks rather than written down by anybody, the answer stays honest
even when the worker unit has been stopped and there is nobody left to write.
A new books.status value for “stopped”. Rejected because pending is the
schema’s own default and grep says nothing has ever written it, so it already
means exactly “not growing and not finished” — no migration, and four published
documents stay true verbatim. The published enum stays pending, rendering, done.
Cancelling with a signal. Instant, rather than the twenty seconds a
heartbeat costs, and it can land anywhere: including the two lines between a
chapter’s passages being indexed and its chapters row being written, which
leaves the index holding words the player’s manifest cannot see — so a place
the listener chose would send them past the end of their own timeline. Between
sentences is the only safe place to stop, and asking is the only way to stop
there.
Automatic retry of anything that failed. A queue that spends the night failing the same book is worse than one that stops and says why. Interruptions are picked up again, because a deploy and a reboot are somnia’s business and not the listener’s; a child that ran and then died is not.
Decision#
Renders leave somnia serve altogether and run under a second systemd user
unit, somnia-worker: a thin supervisor with no torch in it that spawns one
child per book and waits for it. Restarting somnia serve — which is what a
deploy is — now costs a render nothing. Kokoro and the embedder never enter the
process that answers the page, so a restart stays a second rather than twenty,
an OOM kills one book instead of the night, and the fast lane keeps its promise
against CPU as well as against the connection. The child exits between books
and takes every megabyte of the model with it.
One at a time is a guarantee, not a convention. The claim is one guarded
UPDATE ... RETURNING over a new queue table whose WHERE refuses while any
row has a heartbeat newer than ten minutes. sqlite has one writer, so two
claimants cannot both see an empty slot: the second takes the write lock only
after the first has committed, and re-evaluates its own condition. That is the
same idiom tools._write_position already uses, where an empty result set is
the answer. somnia add by hand takes the same claim, so there is exactly one
function in the codebase that renders a book and it always holds a lease.
The heartbeat does three jobs in one statement — UPDATE queue SET beat_at = datetime('now') WHERE id = ? AND lease = ? RETURNING cancel. It proves the
lease is still ours, renews it, and carries the cancel flag back. So
cancellation needs no signal, no pipe and no IPC: the child asks for it,
between sentences, on a database three writers already share. A stop between
sentences writes nothing at all, because _render_chapter encodes the whole
chapter on its last line — no partial m4a, no chunks, no chapters row. The
lease is a uuid4 and never a pid, because pids are reused after a reboot and a
lease must not be resurrectable.
A crashed renderer is told from a slow one by beat_at alone, computed at
read time and stored nowhere, so the queue says “not responding” honestly even
when the worker unit is stopped. books.chapters_total is written with the
existing upsert the moment fetch_book returns, which is the denominator that
makes “chapter 4 of 39” possible at all. A resume starts at the first missing
chapter and continues the global timeline, which is safe only because
add_chunks now deletes before inserting: that closes #11 and is what earns
Restart= on a unit for the first time.
The page watches, and adds, and never opens. One library control in the
top-left corner of the header — as far from the thumb as the geometry allows,
and the only thing in that corner on the screen the book is on — and one overlay
behind it, a sibling of #candidates and last in the document, which polls only
while it is open and the page is visible. It searches the catalog, submits,
shows what is rendering and what is waiting, and stops one. It never switches
what is playing, so ADR 3’s “the page opens
the book they were last listening to; changing books is done by asking” stays
literally true: a catalog search for adding is not a library browser.
(Amended 2026-08-07: the panel does open books now, from a shelf of the ones
somnia already has, and ADR 3’s clause was withdrawn to allow it. What that
amendment kept is the distinction this paragraph turns on — the catalog search
is still adding, and still a different act from picking up a book you have.)
(Amended 2026-08-08: that distinction is two screens now. The panel had
grown to eight blocks and could not be read in the dark, and what fixed it was
cutting on when each block is used: Books is the night half — the book
playing and the shelf — at the player’s own type size, and Workshop is the
daytime half — the catalog search, the queue, the ended rows and the server
note — smaller and denser than anything else in the app, behind a quiet
workshop › pill in Books’ own top-right corner. That pill was a row at the
foot of Books’ scroller until 2026-08-08: quiet was right and still is, but a
row under the shelf put the one control on that screen which is not about books
behind every book that is, and a header is at the same reach whatever the shelf
is doing. The two settings that were filed one to each screen are on
a third, Settings, reached from the player’s own top-right corner: both are
set in the dark with the book playing, which is a fact about when they are used
and not about what they are. The poll went with the queue, so
the five-second wake is now on a screen two presses from the player that nobody
opens at 3am. The header pill says books and the panel says Books back.)
The way out is a ‹ controls pill in the top left — the same corner and word as
the way out of #candidates, and the same shape as the books pill that opened
this; Workshop’s says ‹ books, because that is where it goes — and it is
inert in the full sense
ADR 4 gave that word; the destructive
control is stop reading this, two presses, quiet and dashed and nowhere near
the transport.
A child that exits non-zero is a failure, not an interruption. It records
its own ending for everything it survives, so a row still saying rendering
after the process has gone means it was killed outright — and the supervisor,
which is the one process that can prove nothing holds that row because it
waited for the pid itself, closes it as failed with the exit code in the
sentence. Interruptions go back in the line instead, bounded at three attempts.
Consequences#
A second unit to install and keep running. Deploying is now two restarts, and if somebody forgets the worker, submissions pile up in a queue nobody empties. The queue tells the truth about that — waiting rows sit there with a place in line and nothing rendering — but nothing shouts.
Cancel takes up to about twenty seconds: one heartbeat interval plus the sentence in flight. It could be instant with a signal, at the price of a kill that lands between the chunk insert and the chapters row. Twenty seconds is what that costs.
A restart of the worker throws away the chapter in flight. The signal handler finishes the sentence and puts the book back in the line rather than trying to finish the chapter, because a chapter can outlast systemd’s stop timeout and be SIGKILLed, which is the death that opens the orphan window. Minutes of Kokoro per worker restart, and a render resumes at the chapter boundary it reached.
The old guard that add_book refuses a book somnia already has in any state
is gone. It was the only thing standing between an agent and #11, and it is
replaced by the fix rather than reinstated — a done book is still refused, a
live queue row is still refused, and a dead render is now retryable, which it
never was.
somnia add is no longer the renderer, and no longer starts at chapter
one. It submits and then takes the same claim as everything else, so it
refuses while the worker is busy instead of becoming a second renderer, and
what it renders is the head of the line — which is the book you named unless
you had already asked for others. The template unit
somnia-render@.service is retired with it.
A second overlay on a one-screen page. ADR 4 argued the first one into
existence and this reuses its shape exactly, but two is the point at which
somebody will propose a third, and the argument for this one is weaker than for
the first: a list of places answers a question the listener just asked, whereas
this is a thing somebody goes looking for. Its defences are that it is opened
only on purpose from the far corner, that it holds no payload so nothing has to
close it, and that it never switches what is playing. (Amended 2026-08-07:
the third of those is gone — it switches what is playing when a book on its
shelf is pressed. The first two still hold, and the press replaced by it was a
sentence typed to an agent at 2am. And 2026-08-08: the third this paragraph
predicted arrived, and a fourth with it — the panel was cut into Books and
Workshop and Settings was added, so the page carries four. The Decision’s
note of that date says why each one was argued for.)
No percentage, no time remaining, no notification. People will want all three. Chapters differ in length by an order of magnitude, so a bar drawn from 4/39 moves in lurches that read as a stall; the only honest time denominator does not exist until the last chapter is encoded; and a lit screen at 3am costs more than the news is worth. There is one honest fraction available — characters rendered over characters parsed, since Kokoro’s cost is close to linear in characters — and it is written down here as the thing to add when somebody wants a bar that means something.
The no-auth model now buys more. There is still no login: reachability is
the authentication, and reference/http.md’s “anything that can reach the port
can list your books, read the agent, spend your API credit and move your
position” gains “and start or stop hours of rendering”. A password is not the
honest response, because design.md already ruled it out — “adding a password
would mean typing one at 2am, in the dark, to ask where the horse dies”. The
damage stays bounded by shape rather than by a token: the only submittable
thing is a Gutenberg id, into a serialised queue, on a box that is one tailnet
device.
Two claims here are properties of a machine and not of a test, so, in the form
ADR 3 set for its locked-screen check,
they are to be checked on the VPS and written into this file with the date:
that one render at a time really does outrun 1× listening at the measured rate,
and that systemctl --user restart somnia-serve really does now leave a render
running.
Checked 2026-08-07. With scripts/somnia-bench.py, which renders the same
passage one sentence at a time through the real Kokoro: nuc2 3.87× realtime, the
VPS 1.06×. One render at a time outruns 1× listening on either box, and
ADR 7 records what the difference
between those two numbers costs. The restart is still unchecked, and the box to
check it on is nuc2, which is where somnia runs now.