Let the driver say it started, so both screens agree

The session list reads the manager's status; the session screen replays the
transcript. Correcting a relaunched session's stale `exited` by writing the
manager's view directly left those two saying different things about one
session -- which showed up as a stop button that turned into a play button a
moment after the screen opened, and a status row that disagreed with the row
it was opened from.

So the correction goes through the event sink instead, from the driver that
started the process: `EchoDriver::new` and `LlamaDriver::attached` already
announce the state they start in, and `ClaudeDriver` was the one starting a
process silently. It says idle only when it started one -- adopting says
nothing, since a process that was already running may be mid-turn. Coming
from the driver also orders it against the exit `follow` reports for a
process that dies immediately, which a status written from the manager could
not be.

Verified against a stand-in CLI: stop, restart the backend, and the list and
the transcript's last status both say idle, with the relaunch recorded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-30 12:57:21 -04:00
1 parent a4ec8cfbd8
commit 1d843f20f2
3 files changed
+79 -43

No files matched your search

+32
View File
@@ -233,6 +233,10 @@ impl ClaudeDriver {
// to find. Nothing was ever recorded for one, so this answers "no"
// without needing to know that, which is why the remote case is
// not a branch here.
// Whether this launch *started* a process or picked up one that was
// already there. The two owe the session different things -- see the
// `Status` below.
let started_here;
let record = match process::recorded(session_dir) {
// Still running, and ours. Pick it up where it was left --
// the one path that must not pass `--resume`.
@@ -243,6 +247,7 @@ impl ClaudeDriver {
provider.name,
record.pid
);
started_here = false;
record
}
// Recorded, and the machine will not say whether it is still
@@ -256,12 +261,39 @@ impl ClaudeDriver {
meta.id,
record.pid
);
started_here = false;
record
}
Some((_, process::Liveness::Dead)) | None => {
started_here = true;
Self::start(meta, provider, transport, session_dir)?
}
};
// A process this driver has just started has been asked for nothing,
// which is what idle means. Said here because nothing else will say
// it: the CLI writes not one line until it is given work, so a
// session whose transcript last recorded `Exited` -- one whose
// process died while this server was down, or one somebody stopped
// from the phone -- would keep that word. `Exited` refuses every
// command sent to the session, and it offers a phone the chance to
// start a second CLI against a conversation that already has one.
//
// From the driver rather than from the manager, and before `follow`
// is spawned, so it cannot overtake the exit `follow` reports for a
// process that dies immediately: both come from here, in this order.
// Adopting says nothing, because a process that was already running
// may be mid-turn, and the transcript's last word is the better
// answer until its output says otherwise.
//
// The llama driver has always done this (see `LlamaDriver::attached`,
// which reports `Running` while the model loads and `Idle` when it
// answers); this side was the one silent about it.
if started_here {
let _ = sink.send(Event::Status {
state: SessionStatus::Idle,
});
}
// Where reading of its output had reached. A process just started
// has said nothing, so its record says zero and this is the same
// question with the same answer.
+29 -39
View File
@@ -1114,6 +1114,14 @@ impl SessionManager {
/// nobody could find out whether the process is alive, and starting one
/// on that is precisely the second-CLI-on-one-conversation fault that
/// `session::process` exists to prevent.
///
/// What the session then *reports* is the driver's to say, not this
/// function's: the phone's list reads the manager's status and the
/// session screen replays the transcript, so a status written in one and
/// not the other is two screens disagreeing about one session -- which
/// is what a status set here without an event produced, visible as a
/// stop button that turned into a play button a moment after the screen
/// opened.
pub fn start_session(&self, id: &str) -> Result<()> {
let mut inner = self.inner.write().unwrap();
let meta = inner
@@ -1140,7 +1148,7 @@ impl SessionManager {
// permission mode changed while the session was stopped is what it
// starts with.
let (setup, provider) = resolve(&inner.config, &meta)?;
let session = match existing {
match existing {
Some(session) => {
*session.driver.lock().unwrap() = make_driver(
&meta,
@@ -1151,7 +1159,6 @@ impl SessionManager {
session.transcript_path(),
&session.sink,
)?;
session
}
// Nothing is live for this one -- a session whose launch failed
// when the server started, which has no pump either. That is the
@@ -1166,19 +1173,15 @@ impl SessionManager {
None,
self.notifications.clone(),
)?;
inner.live.insert(id.to_string(), Arc::clone(&session));
session
inner.live.insert(id.to_string(), session);
}
};
// The recorded status is `Exited` and this has just made it untrue.
// Said here because nothing else will say it: a CLI that has been
// given no work writes nothing, so the session would sit at
// `Exited` -- refusing every command, refusing every message, and
// showing a phone an offer to start a second process against the
// conversation this one is already running.
let _ = session.sink.send(Event::Status {
state: SessionStatus::Idle,
});
}
// Nothing is announced from here. A driver that starts a process
// reports the session idle itself, in order with everything else it
// says about that process -- see `ClaudeDriver::launch`. Saying it
// here as well would be a second writer of the same fact, and the
// one that cannot see whether the process it is describing is still
// there.
Ok(())
}
@@ -1501,29 +1504,6 @@ fn launch(
&sink,
)?));
// The transcript's last word is what this session was doing when
// something was last watching it, and building the driver above may have
// just made it untrue: a session recorded as `Exited` with a process
// running again is one this launch has started. Carrying `Exited`
// forward is not merely stale -- it is the word that refuses every
// command sent to the session, and the word that invites somebody to
// start a *second* process against a conversation that already has one,
// which is the fault `session::process` exists to prevent. It reached a
// phone as a play button on a session that was already running.
//
// Idle is what is true: there is a process, and nothing has asked it for
// anything. Written rather than announced, because nobody watched a
// transition -- this is the state the session is being restored in, and
// an event would put a status change in the transcript that never
// happened. A record nobody could check stays as it was and is corrected
// by the driver's first poll, which reports `Unknown` for it.
{
let mut status = shared.status.lock().unwrap();
if *status == SessionStatus::Exited && process::live(&dir).is_some() {
*status = SessionStatus::Idle;
}
}
let commands = Arc::new(Commands {
driver: Arc::clone(&driver),
sink: sink.clone(),
@@ -2492,9 +2472,19 @@ mod tests {
manager.start_session(&info.id).expect("start again");
collect_until(&mut rx, is_idle).await;
// Idle rather than exited, and said by the start rather than left
// for a driver that has been given no work to say for itself.
// Idle rather than exited, and *recorded* -- said by the driver that
// was just built, like every driver says what state it is starting
// in. The manager writing it directly is what made the phone's list
// and its session screen disagree: one reads this status and the
// other replays the transcript, so a status in only one of them is
// two screens describing one session differently.
assert_eq!(manager.sessions()[0].status, SessionStatus::Idle);
assert_eq!(
Transcript::open(&data_dir.join(&info.id).join("transcript.jsonl"))
.expect("reopen transcript")
.last_status(),
Some(SessionStatus::Idle),
);
// The same live session throughout: only the driver was replaced,
// so nothing a phone is reading was interrupted.
assert!(Arc::ptr_eq(