A backend start adopts, and starts nothing

Restarting the server relaunched a driver for every session in the
config, and ClaudeDriver::launch starts a process when there is none to
adopt -- so a session somebody had deliberately stopped came back at the
next rebuild, and the Idle its new driver announced stamped the session
as active at that moment. On the phone that read as every session idle
and "just now" after every restart, with the list sorted by that time in
an order that meant nothing.

A launch now says why it is happening. Launching::Restart takes charge
of the processes still running and leaves every other session as it
found it; Launching::Asked -- a spawn, a Start, a message -- starts one
where there is none. A session with no process therefore has no driver:
DriverCell is an option rather than a driver whose requests go nowhere,
and LiveSession::ask reports what could not happen instead of sending
into a dead fifo.

Two clocks that moved on their own, both the same lie in the same field
that Transcript::last_activity exists to prevent:

- The status a launch has to correct is written into the transcript at
  the time of the last thing the session actually did. A backend killed
  mid-turn leaves a transcript saying Running, which has to become
  Exited -- but this server noticing is not the session doing something.
- A session that has never done anything reports when it was created. Its
  transcript is empty, since a driver announcing the state it starts in
  is not news, so it is the one session with no line to read a time off
  and the clock was the fallback.

Exercised end to end against a real server: a stand-in CLI adopted
across a restart keeps its status and its time, a stopped session stays
stopped with no process started, a process killed while the backend was
down reports exited stamped at the last thing the session did, and Start
brings it back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-30 14:38:31 -04:00
1 parent 6119926a4d
commit 61af05c732
4 files changed
+461 -81

No files matched your search

+55 -2
View File
@@ -240,8 +240,9 @@ turn. Claude's dialect: a `user` message on stdin mid-stream; pi's: `steer`.
installed CLI doesn't support it, fall back to `shutdown` + respawn with
`--resume <session_id> --model <new>` — cheap, since Claude persists
sessions in `~/.claude/projects` anyway. That resume path is the recovery
story for a process that has genuinely died; a backend restart no longer
uses it, because the process is still there to be adopted (see below).
story for a process that has genuinely died; a backend restart never takes
it — it adopts the process that is still there, and starts nothing for the
session that has none (see below).
**Resuming is only ever safe when nothing else has that session open.**
- Images in: base64 image content blocks in the stream-json user message.
- Working directory, host, and model are spawn-screen fields.
@@ -420,6 +421,58 @@ running (interrupt — the process stays), a red stop when it is not (end the
process), and a green play when it has exited (start it again). One button
rather than three that come and go, so its presence is never the signal.
### A backend start adopts, and starts nothing (decided 2026-08-30)
Starting the server is not something a session should be able to tell
happened. `SessionManager::new` takes charge of the processes that are still
running and **leaves every other session exactly as it found it** — listed,
with its transcript, its event pump and the SSE stream a phone reads, and no
driver at all until somebody asks for one.
What it did before was launch a driver for every session in the config, and
`ClaudeDriver::launch` starts a process when there is none to adopt. So a
session somebody had deliberately stopped came back at the next rebuild,
which is the decision Stop exists to make being undone by an unrelated
event — and since a driver announces `Idle` for a process it started, the
session was also stamped as active at the moment of the restart. On the
phone that read as *every* session idle and "just now" after every restart,
with the list — sorted by that time — in an order that meant nothing.
- **`Launching` is the parameter that says which it is**, and the seed an
import carries rides on the asked-for variant, because a restart re-seeding
a transcript would write the imported conversation into it twice.
- **A session with no process has no driver.** `DriverCell` is an option
rather than a driver whose requests go nowhere, so "nothing is running
this" is a state the code can be asked about instead of one it discovers by
sending into a dead fifo. `LiveSession::ask` is the one place that answers
it, with an `Event::Error` naming what could not happen — a request nobody
can carry out is reported, never swallowed.
- **`--resume` on a crashed session is now a press rather than a restart.**
That is the whole of what is given up, and it is small: a session whose CLI
died reports `Exited` and draws the Start button, and *sending it anything
at all* starts it (above). What is bought is that the two are told apart by
who asked, rather than a restart guessing that everything it found should be
running.
- **What a launch settles the status to is written into the transcript, at
the time of the last thing the session actually did.** Adopting, the
transcript's word stands except for the `Exited` a live process disproves.
Taking charge of nothing, every word but `Exited` is disproved at once — a
backend killed mid-turn leaves a transcript saying `Running`, and that
draws a stop button for a turn that ended hours ago. The correction goes in
the transcript because the list reads the manager's status and the session
screen replays the file; it is stamped with the transcript's own last time
because it is not something the session did — this server noticed, at a
moment of its own choosing, and `now` there is the same lie in the same
field that `Transcript::last_activity` exists to prevent.
- **A session that has never done anything reports when it was created.** Its
transcript is empty — a driver announcing the state it starts in is not
news, so nothing is written — which makes it the one session with no line to
read a time off. The clock was the fallback, so a session nobody had sent
anything to climbed to the top of the list at every restart. Not the
transcript file's mtime, which is the same instant for an empty file and a
worse answer for a shared checkout that can be copied or touched;
`SessionConfig::created` is recorded rather than inferred.
### Importing refuses a session that is already open (decided 2026-08-29)
Claude Code keeps a descriptor per live session at