Merge branch 'main' of git.arirex.me:iris/ai-app

This commit is contained in:
iris committed 2026-08-30 14:27:31 -04:00
commit d981d63bcc
5 files changed
+549 -69

No files matched your search

+32 -11
View File
@@ -16,6 +16,7 @@
//! (a backlog past CATCH_UP_LIMIT arrives as a
//! `reset` frame plus the newest window)
//! POST /sessions/{id}/message {text, attachmentIds?}
//! (starts the process first if it has exited)
//! POST /sessions/{id}/answer {questionId, answers} (questions and permissions)
//! POST /sessions/{id}/interrupt stop the running turn; the process stays
//! POST /sessions/{id}/stop end the process; the session and transcript stay
@@ -23,6 +24,7 @@
//! POST /sessions/{id}/title {title}
//! POST /sessions/{id}/model {model}
//! POST /sessions/{id}/command {text} -- /compact, /clear, /rename x, or the dialect's own
//! (starts the process first if it has exited)
//! POST /sessions/{id}/compact
//! POST /sessions/{id}/attachments multipart image upload -> {id}, referenced by /message
//! GET /sessions/{id}/files/{name} images the session produced or was sent
@@ -640,11 +642,16 @@ async fn message(
UrlPath(id): UrlPath<String>,
axum::Json(body): axum::Json<MessageRequest>,
) -> Result<StatusCode, ApiError> {
let session = lookup(&manager, &id)?;
// For the 404 a session that is not here has always answered with; the
// send itself goes through the manager, which may have to start a
// process before there is anything to send to.
lookup(&manager, &id)?;
if body.text.trim().is_empty() && body.attachment_ids.is_empty() {
return Err(ApiError::BadRequest("message is empty".to_string()));
}
session.send_message(body.text, body.attachment_ids);
manager
.send_message(&id, body.text, body.attachment_ids)
.map_err(bad_request)?;
Ok(StatusCode::NO_CONTENT)
}
@@ -834,15 +841,26 @@ async fn command(
Some((name, rest)) => (name, rest.trim()),
None => (text, ""),
};
match (name, rest) {
("/compact", _) => lookup(&manager, &id)?.run_command(SessionCommand::Compact),
("/clear", _) => lookup(&manager, &id)?.run_command(SessionCommand::Clear),
// Through the manager, not the session: a name is persisted and
// listed as well as forwarded, and that is one operation.
// All of these start the session's process first if it has exited: a
// command is something somebody asked the session to do, and answering
// that its process is gone hands back the work of starting one.
//
// A rename still goes through `rename_session` rather than being a
// command like the rest, because the name is persisted and listed as
// well as forwarded, and that is one operation. It starts a process
// too, and for a sharper reason than the others -- see there.
let command = match (name, rest) {
("/compact", _) => SessionCommand::Compact,
("/clear", _) => SessionCommand::Clear,
("/rename", "") => return Err(bad_request(anyhow::anyhow!("a session needs a name"))),
("/rename", title) => manager.rename_session(&id, title).map_err(bad_request)?,
_ => lookup(&manager, &id)?.run_command(SessionCommand::Raw(text.to_string())),
}
("/rename", title) => {
manager.rename_session(&id, title).map_err(bad_request)?;
return Ok(StatusCode::NO_CONTENT);
}
_ => SessionCommand::Raw(text.to_string()),
};
lookup(&manager, &id)?;
manager.run_command(&id, command).map_err(bad_request)?;
Ok(StatusCode::NO_CONTENT)
}
@@ -850,7 +868,10 @@ async fn compact(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
) -> Result<StatusCode, ApiError> {
lookup(&manager, &id)?.compact();
lookup(&manager, &id)?;
manager
.run_command(&id, SessionCommand::Compact)
.map_err(bad_request)?;
Ok(StatusCode::NO_CONTENT)
}
+410 -56
View File
@@ -381,13 +381,6 @@ impl LiveSession {
self.driver().answer_question(question_id, answers);
}
/// Asks the session to run a command on itself, now or at the next
/// boundary. See [`Commands`] for why it may not be now.
pub fn run_command(&self, command: SessionCommand) {
self.commands
.submit(command, *self.shared.status.lock().unwrap());
}
pub fn interrupt(&self) {
self.driver().interrupt();
}
@@ -399,13 +392,6 @@ impl LiveSession {
self.driver().detach();
}
/// Compacts at the next boundary. Through the command queue like
/// every other instruction to the session, so pressing it during a
/// turn holds rather than writing into that turn.
pub fn compact(&self) {
self.run_command(SessionCommand::Compact);
}
pub fn subscribe(&self) -> broadcast::Receiver<SeqEvent> {
self.events.subscribe()
}
@@ -1004,6 +990,15 @@ impl SessionManager {
/// unlike the model and the permission mode, this is settled here and
/// the driver is *told*, rather than asked and believed: see
/// [`Driver::set_title`].
///
/// Telling it is not decoration, which is why this starts a stopped
/// session like any other command. Claude Code keeps its own copy of
/// the name, and that copy is what its session picker shows and what
/// other agents see when they list sessions -- and a session is only
/// ever *given* a name at birth, since every later start is a
/// `--resume`. So a rename that reached no process would leave the two
/// lists disagreeing permanently, with the app's the only one that had
/// moved.
pub fn rename_session(&self, id: &str, title: &str) -> Result<()> {
let title = title.trim();
// An empty name is not a name, and it is what a cleared field
@@ -1012,25 +1007,37 @@ impl SessionManager {
if title.is_empty() {
bail!("a session needs a name");
}
let mut inner = self.inner.write().unwrap();
if !inner.config.sessions.iter().any(|meta| meta.id == id) {
bail!("no session {id}");
{
let mut inner = self.inner.write().unwrap();
if !inner.config.sessions.iter().any(|meta| meta.id == id) {
bail!("no session {id}");
}
let mut candidate = inner.config.clone();
for meta in candidate.sessions.iter_mut().filter(|meta| meta.id == id) {
meta.title = title.to_string();
}
candidate.save(&self.config_path)?;
inner.config = candidate;
// The name is this server's and changes now, whatever happens
// next: the list shows it immediately, and the process is told
// at the next boundary.
if let Some(session) = inner.live.get(id) {
*session.shared.title.lock().unwrap() = title.to_string();
}
}
let mut candidate = inner.config.clone();
for meta in candidate.sessions.iter_mut().filter(|meta| meta.id == id) {
meta.title = title.to_string();
}
candidate.save(&self.config_path)?;
inner.config = candidate;
if let Some(session) = inner.live.get(id) {
// The name is this server's and changes now. Telling whatever
// runs the session is a command, and commands wait for the
// turn to end -- so the list shows the new name immediately
// and the CLI is told at the next boundary.
*session.shared.title.lock().unwrap() = title.to_string();
session.run_command(SessionCommand::SetTitle(title.to_string()));
}
Ok(())
// Dropped the lock first -- `run_command` takes it again to decide
// whether anything needs starting, and this is not a reentrant one.
//
// The context matters more than it looks: the rename above is saved
// by the time this can fail, so a bare error would report a rename
// that did not happen. What failed is only the telling.
self.run_command(id, SessionCommand::SetTitle(title.to_string()))
.with_context(|| {
format!(
"renamed to \"{title}\" here, but the session's own copy of the name could \
not be changed"
)
})
}
pub fn set_session_model(&self, id: &str, model: &str) -> Result<()> {
@@ -1100,9 +1107,14 @@ impl SessionManager {
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.
/// Starts a process for a session whose process has ended, for somebody
/// who asked for exactly that.
///
/// Anything other than a session known to have exited is a refusal to
/// report, because the person pressing this expects a process to appear
/// and is owed the reason one did not. [`SessionManager::send_message`]
/// asks the same question of [`SessionManager::start_if_exited`] and
/// wants the opposite answer.
///
/// 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
@@ -1110,10 +1122,87 @@ impl SessionManager {
/// 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<()> {
match self.start_if_exited(id)? {
SessionStatus::Exited => Ok(()),
SessionStatus::Unknown => {
bail!("there is still a process recorded for this session, so nothing was started")
}
_ => bail!("this session is already running"),
}
}
/// Hands a message to a session, starting its process first if that
/// session has none.
///
/// Sending is the one instruction that plainly means "do this now", so a
/// session whose CLI has ended starts it rather than answering that it
/// cannot -- which left the person holding the phone to read a status
/// word, find a second button, press it, and type the message again.
/// `--resume` puts the new process on the same conversation, so nothing
/// about the message changes; only whether there was anything there to
/// read it.
///
/// Started before the message rather than after, because starting
/// replaces the driver and the driver that takes the message has to be
/// the one with a process behind it.
pub fn send_message(&self, id: &str, text: String, images: Vec<ImageRef>) -> Result<()> {
// Only `Exited` starts anything -- see `start_if_exited`. A session
// this cannot say has exited keeps the behaviour it always had: the
// message goes to the driver, which answers for it.
self.start_if_exited(id)?;
self.session(id)
.with_context(|| format!("no session {id}"))?
.send_message(text, images);
Ok(())
}
/// Runs one of the session's own commands, starting its process first
/// if that session has none.
///
/// The same reasoning as [`SessionManager::send_message`], and for the
/// same reason it is not left to each caller: a command is something
/// somebody asked the session to do, and answering "its process has
/// exited" hands back the work of starting one. `/compact` on a
/// stopped session is the case that shows it -- the thing being asked
/// for is exactly what a stopped session needs before it is useful
/// again.
///
pub fn run_command(&self, id: &str, command: SessionCommand) -> Result<()> {
// Judged against the status *after* the start, not the one that
// caused it. A driver that has just started a process announces
// `idle` through the sink and the pump may not have recorded it
// yet, so reading the session's own status here would refuse the
// command the start was for -- `Commands::submit` refuses on
// `Exited`, which is exactly the word that has just stopped being
// true. `start_if_exited` returning `Exited` is what says a process
// was started; anything else is a status nothing has invalidated.
let status = match self.start_if_exited(id)? {
SessionStatus::Exited => SessionStatus::Idle,
found => found,
};
self.session(id)
.with_context(|| format!("no session {id}"))?
.commands
.submit(command, status);
Ok(())
}
/// Starts a process for the session if it is known to have exited, and
/// reports what the session was found to be doing either way. `Exited`
/// is therefore the one returned value that means something was started.
///
/// One decision with two callers who want opposite things from it: a
/// Start button treats "there is already a process" as a refusal worth
/// showing, and a message being sent treats it as nothing at all.
/// Deciding it here, under the one write lock, is also what stops two
/// requests that arrive together from starting two CLIs on one
/// conversation.
///
/// Nothing is started on `Unknown`. That means nobody could find out
/// whether the process is alive, and starting one on that is precisely
/// the second-CLI-on-one-conversation fault `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
@@ -1122,7 +1211,7 @@ impl SessionManager {
/// 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<()> {
fn start_if_exited(&self, id: &str) -> Result<SessionStatus> {
let mut inner = self.inner.write().unwrap();
let meta = inner
.config
@@ -1132,17 +1221,27 @@ impl SessionManager {
.with_context(|| format!("no session {id}"))?
.clone();
let existing = inner.live.get(id).cloned();
let dir = self.data_dir.join(id);
let status = match &existing {
Some(session) => *session.shared.status.lock().unwrap(),
None => status_of_unlaunched(&self.data_dir.join(id)),
Some(session) => {
let last = *session.shared.status.lock().unwrap();
let now = corrected(last, &dir);
if now != last {
// Published, not merely acted on. The phone is drawing a
// Start button on the strength of the word this has just
// disproved, and it learns what a session is doing from
// the stream like everything else -- so a correction
// nobody sends leaves that button there to be pressed
// again, and again. Through the sink, which keeps the
// pump the only writer of the status.
let _ = session.sink.send(Event::Status { state: now });
}
now
}
None => status_of_unlaunched(&dir),
};
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"),
if status != SessionStatus::Exited {
return Ok(status);
}
// Fresh from the config, like every other launch: a model or a
// permission mode changed while the session was stopped is what it
@@ -1150,6 +1249,12 @@ impl SessionManager {
let (setup, provider) = resolve(&inner.config, &meta)?;
match existing {
Some(session) => {
// The driver being replaced is still reading this session's
// output, and replacing the value it lives in does not end
// the tasks that do it. Its process has exited -- that is
// how this line was reached -- so there is nothing left to
// preserve and `detach` is the whole of what it is owed.
session.driver().detach();
*session.driver.lock().unwrap() = make_driver(
&meta,
&setup,
@@ -1182,7 +1287,7 @@ impl SessionManager {
// 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(())
Ok(SessionStatus::Exited)
}
/// Kills the process, releases everything the spawn created, and
@@ -1226,6 +1331,38 @@ impl SessionManager {
/// reports `Unknown` too: this server is not driving it, so it genuinely
/// does not know what it is doing -- and that is worth a word that means
/// "wait", not one that means "act".
/// The last word about a session, with the one status that cannot be taken
/// on trust checked against the only authority on it.
///
/// `Exited` is not just a description: it is the word that offers a phone a
/// Start button and lets [`SessionManager::start_session`] build a second
/// CLI against a conversation. So before it is believed it is checked
/// against the process record, and a record that is not known to be dead
/// makes it false. What replaces it is `Unknown` -- there is a process, and
/// nothing here has heard from it -- which is the same answer
/// [`status_of_unlaunched`] gives to the same question.
///
/// Every other status is left exactly as it was. Those are the pump's,
/// written from what the process itself said, and none of them authorises
/// starting anything.
///
/// This was reachable and did happen: a session adopted at server start
/// keeps the transcript's last word, so one whose process was reported gone
/// and then found again read as `exited` while it was running. Start was
/// accepted every time it was pressed, each press attaching another reader
/// to the one process, and every line it wrote was then translated once per
/// reader -- three presses put three interleaved copies of one reply on
/// screen.
fn corrected(status: SessionStatus, session_dir: &Path) -> SessionStatus {
if status != SessionStatus::Exited {
return status;
}
match process::recorded(session_dir) {
Some((_, process::Liveness::Alive | process::Liveness::Unknown)) => SessionStatus::Unknown,
Some((_, process::Liveness::Dead)) | None => SessionStatus::Exited,
}
}
fn status_of_unlaunched(session_dir: &Path) -> SessionStatus {
match process::recorded(session_dir) {
// Nothing was ever recorded: an echo session, or one whose
@@ -1422,6 +1559,7 @@ fn launch(
wg_app_link::private::create_dir(&dir)?;
let transcript_path = dir.join("transcript.jsonl");
let mut transcript = Transcript::open(&transcript_path)?;
let last_status = transcript.last_status().unwrap_or(SessionStatus::Idle);
// Before the driver starts, so the token is there when it looks and
// the history is already in the transcript a phone will read.
if let Some(seed) = seed {
@@ -1439,8 +1577,11 @@ fn launch(
// What it was last known to be doing, not an assumption. A driver
// that has something to say corrects this within its first poll;
// one adopting a process that has been quiet says nothing, and
// this is then the only true answer available.
status: Mutex::new(transcript.last_status().unwrap_or(SessionStatus::Idle)),
// this is then the only true answer available. Where it is *not*
// the true answer, the correction goes through the sink below
// rather than being written here, so that it reaches the
// transcript too -- see there.
status: Mutex::new(last_status),
title: Mutex::new(meta.title.clone()),
// What the transcript last recorded, not the clock: this server has
// just been told nothing, and `now()` claimed every relaunched
@@ -1494,6 +1635,22 @@ fn launch(
);
}
// Before the driver, because the driver may start a process and say so,
// and that has to be the later word of the two.
//
// Sent rather than written into `shared`: the session screen replays the
// transcript and the session list reads `shared`, so a correction made
// in only one of them is the two of them describing one session
// differently -- which is how the phone came to show a Start button on a
// running session in the first place. One event, and the pump puts it in
// both.
let corrected_status = corrected(last_status, &dir);
if corrected_status != last_status {
let _ = sink.send(Event::Status {
state: corrected_status,
});
}
let driver = Arc::new(Mutex::new(make_driver(
&meta,
setup,
@@ -1877,7 +2034,9 @@ mod tests {
})
.await;
session.run_command(SessionCommand::Clear);
manager
.run_command(&info.id, SessionCommand::Clear)
.expect("clear");
let held = collect_until(&mut rx, |event| {
matches!(
event,
@@ -2188,7 +2347,9 @@ mod tests {
})
.await;
session.run_command(SessionCommand::Raw("/tool held".to_string()));
manager
.run_command(&info.id, SessionCommand::Raw("/tool held".to_string()))
.expect("command");
let seen = collect_until(&mut rx, |event| {
matches!(event, Event::CommandQueued { .. })
})
@@ -2232,7 +2393,9 @@ mod tests {
let session = manager.session(&info.id).expect("live session");
let mut rx = session.subscribe();
session.run_command(SessionCommand::Raw("/tool now".to_string()));
manager
.run_command(&info.id, SessionCommand::Raw("/tool now".to_string()))
.expect("command");
let seen = collect_until(&mut rx, |event| matches!(event, Event::ToolStart { .. })).await;
// Sent, and never queued: a session between turns has nothing to
// wait for, and a phone should not draw a bubble that resolves in
@@ -2389,7 +2552,9 @@ mod tests {
// A clear leaves it unmeasured: the conversation is gone, and how
// much is left is a thing nobody has counted.
session.run_command(SessionCommand::Clear);
manager
.run_command(&info.id, SessionCommand::Clear)
.expect("clear");
collect_until(&mut rx, |event| matches!(event, Event::Cleared)).await;
assert_eq!(manager.sessions()[0].context_tokens, None);
@@ -2493,6 +2658,195 @@ mod tests {
));
}
/// A message and a command both mean "now", so neither answers that the
/// session's process has gone -- they start one and go to it.
///
/// Refusing was the old behaviour and it was work handed back: read the
/// status word, find the other button, press it, type the thing again.
/// `--resume` puts the new process on the same conversation, so what it
/// reads is what was typed.
///
/// Both halves in one test because they are one rule. A command is the
/// half that can fail on its own: `Commands::submit` refuses on
/// `Exited`, and the start it has just been given announces `Idle`
/// through the sink rather than writing it -- so a command judged
/// against the session's own status would be refused by the word the
/// start replaced, in a window a test is the only thing likely to hit.
#[tokio::test]
async fn an_instruction_starts_the_process_a_stopped_session_has_not_got() {
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 _ = session.sink.send(Event::Status {
state: SessionStatus::Exited,
});
collect_until(&mut rx, |event| {
matches!(
event,
Event::Status {
state: SessionStatus::Exited
}
)
})
.await;
manager
.send_message(&info.id, "carry on".to_string(), Vec::new())
.expect("send to a stopped session");
collect_until(
&mut rx,
|event| matches!(event, Event::UserMessage { text, .. } if text == "carry on"),
)
.await;
// And the session is running again, not merely written to: a message
// delivered to a session still reporting `exited` is one the phone
// draws under a Start button.
assert_ne!(manager.sessions()[0].status, SessionStatus::Exited);
let _ = session.sink.send(Event::Status {
state: SessionStatus::Exited,
});
collect_until(&mut rx, |event| {
matches!(
event,
Event::Status {
state: SessionStatus::Exited
}
)
})
.await;
manager
.run_command(&info.id, SessionCommand::Clear)
.expect("clear a stopped session");
collect_until(&mut rx, |event| matches!(event, Event::Cleared)).await;
assert_ne!(manager.sessions()[0].status, SessionStatus::Exited);
}
/// A rename is not decoration, so it starts a stopped session too.
///
/// Claude Code keeps its own copy of the name; that copy is what its
/// session picker shows and what other agents read when they list
/// sessions, and a session is only ever *given* a name at birth, since
/// every later start is a `--resume`. So a rename that reached no
/// process would leave the two lists disagreeing permanently, with this
/// app's the only one that had moved.
#[tokio::test]
async fn a_rename_reaches_the_process_even_when_one_has_to_be_started() {
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 _ = session.sink.send(Event::Status {
state: SessionStatus::Exited,
});
collect_until(&mut rx, |event| {
matches!(
event,
Event::Status {
state: SessionStatus::Exited
}
)
})
.await;
manager
.rename_session(&info.id, "the new name")
.expect("rename a stopped session");
collect_until(
&mut rx,
|event| matches!(event, Event::CommandSent { text, .. } if text == "/rename the new name"),
)
.await;
// Both halves: the name this server lists changed, and it was told
// to a process rather than only written down.
assert_eq!(manager.sessions()[0].title, "the new name");
assert_ne!(manager.sessions()[0].status, SessionStatus::Exited);
}
/// The status is a claim about a process, and the process record is
/// what settles it.
///
/// Without this the phone offered Start on a session whose CLI was
/// running, and taking it up attached a second reader to that one
/// process rather than failing -- so the session went on saying
/// `exited`, the button stayed, and each further press added another
/// reader. On screen that was one reply written as many times as the
/// button had been pressed, interleaved word by word.
#[tokio::test]
async fn a_stale_exited_does_not_start_anything_while_a_process_is_recorded() {
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();
// A live process for this session: this test's own, which is the
// one process certain to still be there when the guard looks.
let record = process::Record::of(
std::process::id(),
process::Detail::Stdio { stdout_read: 0 },
)
.expect("record this process");
process::write(&data_dir.join(&info.id), &record);
let _ = session.sink.send(Event::Status {
state: SessionStatus::Exited,
});
collect_until(&mut rx, |event| {
matches!(
event,
Event::Status {
state: SessionStatus::Exited
}
)
})
.await;
let refused = manager
.start_session(&info.id)
.expect_err("a process is recorded");
assert!(
refused.to_string().contains("still a process recorded"),
"said: {refused:#}"
);
// And the word that was wrong is taken back, on the stream and in
// the transcript -- otherwise the button that asked for this is
// still there, still saying Start.
collect_until(&mut rx, |event| {
matches!(
event,
Event::Status {
state: SessionStatus::Unknown
}
)
})
.await;
assert_eq!(manager.sessions()[0].status, SessionStatus::Unknown);
assert_eq!(
Transcript::open(&data_dir.join(&info.id).join("transcript.jsonl"))
.expect("reopen transcript")
.last_status(),
Some(SessionStatus::Unknown),
);
}
#[tokio::test]
async fn a_restart_relaunches_sessions_and_continues_the_numbering() {
let dir = tempfile::tempdir().expect("tempdir");