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

+18 -4
View File
@@ -332,10 +332,24 @@ Three decisions worth not undoing:
That last rule found a real bug in the launch path, which is where the phone
would have hit it: a relaunched session took its status from the transcript,
so one whose process had died before a backend restart reported `Exited`
while the launch it had just gone through was starting a new process. The
status now says `Idle` when a launch leaves a process running — `Exited`
there is not merely stale, it is the word that refuses every command and
invites somebody to start a second process against a live conversation.
while the launch it had just gone through was starting a new process
`Exited` there is not merely stale, it is the word that refuses every command
and invites somebody to start a second process against a live conversation.
**Who says so matters as much as what is said.** The first fix wrote `Idle`
straight into the manager's view, and that produced a second bug on the
phone: the session 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 — visible as a stop button that turned
into a play button a moment after the screen opened. So the rule is that
**a driver announces the state it starts in, through the event sink**, which
is what `EchoDriver::new` and `LlamaDriver::attached` already did;
`ClaudeDriver` was the one that started a process silently. It says `Idle`
only when it *started* one — 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. Coming from the driver also
orders it against the exit `follow` reports, which a status written from the
manager could not be.
On the phone this is one button in the composer, left of Send, whose mark and
colour say what pressing it would do now: an orange pause while a turn is
+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(