Stop and start a session's process from the composer

The composer's second button now says what pressing it would do to the
process behind the session, in one place that is always there: an orange
pause while a turn is running (interrupt, the process stays), a red stop
when it is not (end the process), a green play when it has exited (start it
again on the same conversation). Send is disabled while there is nothing to
send, rather than pressable and silent.

Behind it, two routes. `stop` signals the recorded process and says nothing
else -- the driver's own reader already reports a death correctly, and
announcing it here would be a guess ahead of the measurement. `start`
replaces the driver and nothing else, so the transcript, the pump and every
open phone's stream stay where they were and there is still one writer of
the transcript; it is refused unless the session is known to have exited,
since starting on `Unknown` is the two-CLIs-on-one-conversation fault.

That last rule found a bug in the launch path: a relaunched session took its
status from the transcript, so one whose process had died before a backend
restart reported `exited` while the launch had just started a new process --
which refuses every command and offers a phone the chance to start a second
CLI on a live conversation. A launch that leaves a process running now says
idle.

The icon font moves to the Mono face, where every glyph is one em square, so
two icon buttons are the same width without either being told one; the
proportional advances ran 0.46 to 0.92 em and Send came out visibly wider
than Stop. GLYPH_SIZE comes down to match, since a glyph that fills its em
draws bigger at the same point size.

Verified against a stand-in CLI on the emulator: idle -> stop -> exited ->
start -> idle, a turn interrupted from the pause button, and both buttons
measured at 171x105 device pixels.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-30 12:41:36 -04:00
1 parent 317ad29d85
commit 6154cb1949
13 files changed
+562 -79

No files matched your search

+303 -33
View File
@@ -169,11 +169,21 @@ pub struct SessionInfo {
pub created: f64,
}
/// What is running a session at this moment.
///
/// Behind a lock because a session outlives its process: stopping one and
/// starting it again replaces the driver while the transcript, the event
/// pump and the stream every open phone is reading stay exactly where they
/// were. Shared with [`Commands`] rather than copied into it, because two
/// holders of "the driver" are two answers to that question the moment one
/// of them is replaced.
type DriverCell = Arc<Mutex<Arc<dyn Driver>>>;
/// A running session: its driver plus the shared state the event pump
/// keeps current. Cheap to clone-by-`Arc` into request handlers.
pub struct LiveSession {
meta: SessionConfig,
driver: Arc<dyn Driver>,
driver: DriverCell,
/// Commands asked for and not yet run, oldest first, with the pump
/// that will run them. Shared with that pump, which is what notices
/// the boundary.
@@ -195,12 +205,17 @@ pub struct LiveSession {
/// for the turn to end. Drivers therefore never have to think about it,
/// and a new provider cannot get it wrong by omission.
struct Commands {
driver: Arc<dyn Driver>,
driver: DriverCell,
sink: EventSink,
waiting: Mutex<VecDeque<(String, SessionCommand)>>,
}
impl Commands {
/// Whatever is driving the session now -- see [`DriverCell`].
fn driver(&self) -> Arc<dyn Driver> {
self.driver.lock().unwrap().clone()
}
/// Runs `command` now if the session is between turns, holds it until
/// it is, and refuses it outright if there will never be one. Whichever
/// happened, the phone is told.
@@ -236,9 +251,10 @@ impl Commands {
});
return;
}
if self.driver.between_turns() {
let driver = self.driver();
if driver.between_turns() {
let _ = self.sink.send(Event::CommandSent { id, text });
command.apply(self.driver.as_ref());
command.apply(driver.as_ref());
return;
}
let _ = self.sink.send(Event::CommandQueued {
@@ -258,7 +274,8 @@ impl Commands {
/// began by itself, which it does: a background task finishing makes it
/// pick the conversation back up with nothing written to it.
fn take_one(&self) {
if !self.driver.between_turns() {
let driver = self.driver();
if !driver.between_turns() {
return;
}
let Some((id, command)) = self.waiting.lock().unwrap().pop_front() else {
@@ -268,7 +285,7 @@ impl Commands {
id,
text: command.label(),
});
command.apply(self.driver.as_ref());
command.apply(driver.as_ref());
}
/// Gives up on everything held, because the session cannot run them.
@@ -336,6 +353,11 @@ struct Shared {
}
impl LiveSession {
/// Whatever is driving this session now -- see [`DriverCell`].
fn driver(&self) -> Arc<dyn Driver> {
self.driver.lock().unwrap().clone()
}
/// Hands the user's message to the driver, which records it in the
/// transcript by reporting that it has taken it -- see `MessageTaken`.
///
@@ -348,7 +370,7 @@ impl LiveSession {
// drew a person's screenshot as a row floating above the bubble
// that sent it, and left the phone inferring from adjacency which
// message an image went with -- a thing the sender already knew.
self.driver.send_user_message(text, images);
self.driver().send_user_message(text, images);
}
pub fn answer_question(&self, question_id: &str, answers: &[String]) {
@@ -356,7 +378,7 @@ impl LiveSession {
id: question_id.to_string(),
answers: answers.to_vec(),
});
self.driver.answer_question(question_id, answers);
self.driver().answer_question(question_id, answers);
}
/// Asks the session to run a command on itself, now or at the next
@@ -367,14 +389,14 @@ impl LiveSession {
}
pub fn interrupt(&self) {
self.driver.interrupt();
self.driver().interrupt();
}
/// Leaves this session's process running and stops attending to it,
/// for a server that is going away and means to come back. See
/// [`Driver::detach`].
pub fn detach(&self) {
self.driver.detach();
self.driver().detach();
}
/// Compacts at the next boundary. Through the command queue like
@@ -942,7 +964,7 @@ impl SessionManager {
// change, and as an error if it cannot. The config above is a
// different question -- what to launch this session with next
// time -- and it is answered by the request.
session.driver.set_permission_mode(mode);
session.driver().set_permission_mode(mode);
}
Ok(())
}
@@ -1025,11 +1047,141 @@ impl SessionManager {
if let Some(session) = inner.live.get(id) {
// See `set_session_permission_mode`: the driver reports what
// it is set to, this only asks.
session.driver.set_model(model);
session.driver().set_model(model);
}
Ok(())
}
/// Ends this session's process, leaving the session -- its transcript,
/// its place in the list, everything a phone is watching -- exactly
/// where it is. [`SessionManager::start_session`] is the way back.
///
/// The signal is all this does. Whether the process actually went, what
/// it said on the way out, and the `Exited` that follows are reported by
/// the path a session that died on its own already takes: the driver's
/// own reader notices within a poll, drains what was still unread, and
/// records it. Announcing it from here would be this side's guess
/// arriving ahead of the measurement, and it would be wrong for the five
/// seconds a process that ignores SIGTERM keeps running.
///
/// Deliberately not routed through the driver. The record is the
/// session's rather than any dialect's -- `session::process` writes it
/// for every provider that has a process at all -- so asking it here
/// stops a session whose driver is in no state to be asked, and adds no
/// method a new driver could implement wrongly.
pub fn stop_session(&self, id: &str) -> Result<()> {
if !self
.inner
.read()
.unwrap()
.config
.sessions
.iter()
.any(|meta| meta.id == id)
{
bail!("no session {id}");
}
// Three answers, and they are three different things to tell
// somebody: it is running (stop it), it is not (nothing to do), and
// nobody could find out (nothing was signalled, and saying "nothing
// is running" would be inventing the answer).
let record = match process::recorded(&self.data_dir.join(id)) {
Some((record, process::Liveness::Alive)) => record,
Some((_, process::Liveness::Unknown)) => bail!(
"this machine won't say whether this session's process is still running, so it \
wasn't signalled"
),
Some((_, process::Liveness::Dead)) | None => {
bail!("this session has no process running")
}
};
tracing::info!("stopping session {id} (pid {})", record.pid);
process::stop(&record, process::STOP_GRACE);
Ok(())
}
/// Starts a process for a session whose process has ended, continuing
/// the same conversation -- for Claude Code, the `--resume` that crash
/// recovery already uses.
///
/// Only the driver is new. The transcript, the event pump and the stream
/// every open phone is reading stay as they were, so this is not a
/// reconnect for anybody watching -- and there is still exactly one
/// writer of the transcript, which relaunching the whole session would
/// not be.
///
/// Refused unless the session is *known* to have exited. `Unknown` means
/// 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.
pub fn start_session(&self, id: &str) -> Result<()> {
let mut inner = self.inner.write().unwrap();
let meta = inner
.config
.sessions
.iter()
.find(|meta| meta.id == id)
.with_context(|| format!("no session {id}"))?
.clone();
let existing = inner.live.get(id).cloned();
let status = match &existing {
Some(session) => *session.shared.status.lock().unwrap(),
None => status_of_unlaunched(&self.data_dir.join(id)),
};
match status {
SessionStatus::Exited => {}
SessionStatus::Unknown => bail!(
"this machine won't say whether this session's process is still running, so \
nothing was started"
),
_ => bail!("this session is already running"),
}
// Fresh from the config, like every other launch: a model or a
// permission mode changed while the session was stopped is what it
// starts with.
let (setup, provider) = resolve(&inner.config, &meta)?;
let session = match existing {
Some(session) => {
*session.driver.lock().unwrap() = make_driver(
&meta,
&setup,
&provider,
&self.models_dir,
session.dir(),
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
// whole of `launch`, and the same call the server start makes.
None => {
let session = launch(
meta,
&setup,
&provider,
&self.data_dir,
&self.models_dir,
None,
self.notifications.clone(),
)?;
inner.live.insert(id.to_string(), Arc::clone(&session));
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,
});
Ok(())
}
/// Kills the process, releases everything the spawn created, and
/// deletes the transcript and files -- the complete path out.
pub fn delete_session(&self, id: &str) -> Result<()> {
@@ -1045,7 +1197,7 @@ impl SessionManager {
// Stopped, not detached: this is the one exit where the
// process must not survive, because the conversation it
// belongs to is being removed. See `Driver::stop`.
session.driver.stop();
session.driver().stop();
}
let dir = self.data_dir.join(id);
if dir.exists() {
@@ -1339,25 +1491,38 @@ fn launch(
);
}
let driver: Arc<dyn Driver> = match provider.kind {
DriverKind::Echo => Arc::new(EchoDriver::new(sink.clone(), dir.clone())),
DriverKind::LlamaCpp => Arc::new(LlamaDriver::launch(
&meta,
provider,
&Transport::for_setup(setup),
models_dir,
&transcript_path,
&dir,
sink.clone(),
)?),
DriverKind::ClaudeCli => Arc::new(ClaudeDriver::launch(
&meta,
provider,
&Transport::for_setup(setup),
&dir,
sink.clone(),
)?),
};
let driver = Arc::new(Mutex::new(make_driver(
&meta,
setup,
provider,
models_dir,
&dir,
&transcript_path,
&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),
@@ -1386,6 +1551,44 @@ fn launch(
}))
}
/// Whatever runs this session's provider, pointed at the session's own
/// directory and reporting into `sink`.
///
/// Split out of [`launch`] because a session outlives its process: it is
/// also what [`SessionManager::start_session`] builds when somebody starts a
/// stopped session again. That path replaces the driver and nothing else, so
/// it has to construct one the same way rather than becoming a second answer
/// to "what runs this".
fn make_driver(
meta: &SessionConfig,
setup: &SetupConfig,
provider: &ProviderConfig,
models_dir: &Path,
dir: &Path,
transcript_path: &Path,
sink: &EventSink,
) -> Result<Arc<dyn Driver>> {
Ok(match provider.kind {
DriverKind::Echo => Arc::new(EchoDriver::new(sink.clone(), dir.to_path_buf())),
DriverKind::LlamaCpp => Arc::new(LlamaDriver::launch(
meta,
provider,
&Transport::for_setup(setup),
models_dir,
transcript_path,
dir,
sink.clone(),
)?),
DriverKind::ClaudeCli => Arc::new(ClaudeDriver::launch(
meta,
provider,
&Transport::for_setup(setup),
dir,
sink.clone(),
)?),
})
}
/// The one writer of a session's transcript: assigns sequence numbers,
/// appends, updates the shared status/activity view, fans out. Ends when
/// every sender is dropped -- i.e. when the session is deleted and its
@@ -1639,7 +1842,10 @@ mod tests {
let dir = tempfile::tempdir().expect("tempdir");
let (sink, mut events) = mpsc::unbounded_channel();
let commands = Commands {
driver: Arc::new(EchoDriver::new(sink.clone(), dir.path().to_path_buf())),
driver: Arc::new(Mutex::new(Arc::new(EchoDriver::new(
sink.clone(),
dir.path().to_path_buf(),
)))),
sink,
waiting: Mutex::new(VecDeque::new()),
};
@@ -2233,6 +2439,70 @@ mod tests {
std::fs::write(path, rewritten).expect("write transcript");
}
/// Stopping and starting a session is about its *process*, and the two
/// refusals are the whole of what keeps starting one from becoming a
/// second one on the same conversation.
///
/// Echo has no process, which makes it the right session to ask the
/// first question of: "there is nothing to stop" is an answer, and
/// reporting success would leave a phone showing a session it believes
/// it stopped. The second question is asked of a session that has been
/// told it exited, since the guard is on the *status* rather than on
/// which driver it is.
#[tokio::test]
async fn a_session_is_started_again_only_once_it_is_known_to_have_exited() {
let dir = tempfile::tempdir().expect("tempdir");
let config_path = dir.path().join("config.ron");
let data_dir = dir.path().join("sessions");
seed_echo_only(&config_path);
let manager = SessionManager::new(config_path, data_dir.clone(), data_dir.join("models"))
.expect("manager");
let info = manager.spawn_session(echo_spec()).expect("spawn");
let session = manager.session(&info.id).expect("live session");
let mut rx = session.subscribe();
let refused = manager.stop_session(&info.id).expect_err("nothing to stop");
assert!(
refused.to_string().contains("no process running"),
"said: {refused:#}"
);
let refused = manager
.start_session(&info.id)
.expect_err("already running");
assert!(
refused.to_string().contains("already running"),
"said: {refused:#}"
);
// What a driver reports when its process goes, without a process
// to go: the guard reads the recorded status, so this is the same
// state a stopped claude session reaches.
let _ = session.sink.send(Event::Status {
state: SessionStatus::Exited,
});
collect_until(&mut rx, |event| {
matches!(
event,
Event::Status {
state: SessionStatus::Exited
}
)
})
.await;
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.
assert_eq!(manager.sessions()[0].status, SessionStatus::Idle);
// The same live session throughout: only the driver was replaced,
// so nothing a phone is reading was interrupted.
assert!(Arc::ptr_eq(
&session,
&manager.session(&info.id).expect("still live")
));
}
#[tokio::test]
async fn a_restart_relaunches_sessions_and_continues_the_numbering() {
let dir = tempfile::tempdir().expect("tempdir");