Merge branch 'main' of git.arirex.me:iris/ai-app
This commit is contained in:
commit
d981d63bcc
5 files changed
+549
-69
No files matched your search
@@ -312,6 +312,20 @@ day:
|
|||||||
which keeps the session and its transcript, and `POST .../start` brings
|
which keeps the session and its transcript, and `POST .../start` brings
|
||||||
the process back on the same conversation — or delete the session, which
|
the process back on the same conversation — or delete the session, which
|
||||||
ends the conversation too.
|
ends the conversation too.
|
||||||
|
- **A message or a command sent to a stopped session starts it.** `POST
|
||||||
|
.../message`, `.../command` and `.../compact` go through
|
||||||
|
`SessionManager::send_message` and `::run_command`, which start a process
|
||||||
|
first when the session is known to have exited and then hand the thing to
|
||||||
|
the driver that has one behind it. Only on `exited`: `unknown` has a
|
||||||
|
process that may well be reading its fifo. `/rename` starts one too, and
|
||||||
|
for a sharper reason than the rest: the CLI keeps its own copy of the
|
||||||
|
name, that copy is what its session picker and other agents' session
|
||||||
|
lists show, and a session is only ever *given* a name at birth — every
|
||||||
|
later start is a `--resume` — so a rename that reached no process would
|
||||||
|
leave the two lists disagreeing for good. Its save happens before the
|
||||||
|
telling, so a failure there says the telling failed rather than the
|
||||||
|
rename. So the Start button is for when you want a process and nothing to
|
||||||
|
say to it yet.
|
||||||
- **Each session directory now holds `process.json`, `stdin.fifo`,
|
- **Each session directory now holds `process.json`, `stdin.fifo`,
|
||||||
`stdout.log` and `stderr.log`.** `stdout.log` is the driver's input, read
|
`stdout.log` and `stderr.log`.** `stdout.log` is the driver's input, read
|
||||||
from the byte offset in `process.json`; removing either by hand while the
|
from the byte offset in `process.json`; removing either by hand while the
|
||||||
@@ -323,6 +337,17 @@ day:
|
|||||||
launch has just started a process reports `idle`, because `exited` is the
|
launch has just started a process reports `idle`, because `exited` is the
|
||||||
word that refuses every command and offers a phone the chance to start a
|
word that refuses every command and offers a phone the chance to start a
|
||||||
second CLI on a live conversation.
|
second CLI on a live conversation.
|
||||||
|
- **`exited` is never taken on trust; it is checked against the process
|
||||||
|
record** (`corrected` in `session/mod.rs`). It is the one status that draws
|
||||||
|
the phone's Start button and lets `start_session` build a driver, so a
|
||||||
|
record that is not known to be dead makes it false and the session reports
|
||||||
|
`unknown` instead. Without that, a session adopted at a backend start kept
|
||||||
|
the transcript's `exited` while its CLI was running, Start was accepted
|
||||||
|
every press, and each press left another reader on the same process —
|
||||||
|
which reads on screen as one reply written several times, interleaved
|
||||||
|
(`GotGotGot it — it — it —`), not as anything to do with a button.
|
||||||
|
A driver that `start_session` replaces gets `Driver::detach` for the same
|
||||||
|
reason: swapping the `Arc` does not end the tasks the old one is running.
|
||||||
- Remote sessions are adopted too. The pid recorded for one is the **`ssh`
|
- Remote sessions are adopted too. The pid recorded for one is the **`ssh`
|
||||||
client's**, on this machine — that is the process the backend owns, and it
|
client's**, on this machine — that is the process the backend owns, and it
|
||||||
lives as long as the remote command does. (This said "local only" until
|
lives as long as the remote command does. (This said "local only" until
|
||||||
|
|||||||
@@ -351,6 +351,69 @@ 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
|
orders it against the exit `follow` reports, which a status written from the
|
||||||
manager could not be.
|
manager could not be.
|
||||||
|
|
||||||
|
**`Exited` is a claim about a process, and the record is what settles it.**
|
||||||
|
Adopting saying nothing left one word standing that a live process
|
||||||
|
contradicts. A session whose process was reported gone and then found again
|
||||||
|
at the next backend start kept `Exited` from the transcript — and `Exited` is
|
||||||
|
the word that draws a Start button. Start was then accepted every time it was
|
||||||
|
pressed, and since starting replaces the driver, each press attached *another*
|
||||||
|
reader to the one process: every line the CLI wrote was translated once per
|
||||||
|
reader, so three presses put three interleaved copies of one reply on screen.
|
||||||
|
Two rules come out of it, and neither is optional:
|
||||||
|
|
||||||
|
- **`Exited` is checked against `session::process` before it is believed** —
|
||||||
|
`corrected`, called in `launch` and again in `start_session`. A record that
|
||||||
|
is not known to be dead makes it false, and what replaces it is `Unknown`:
|
||||||
|
there is a process, and nothing here has heard from it, which is the answer
|
||||||
|
`status_of_unlaunched` already gave 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. The
|
||||||
|
correction goes out through the sink for the reason above: written into the
|
||||||
|
manager's view alone it would be the list and the screen disagreeing again.
|
||||||
|
- **A driver that is replaced is detached.** Swapping the `Arc` does not end
|
||||||
|
the tasks the old one is running. `Driver::detach` is what does — it already
|
||||||
|
existed for the backend going away — and it is the whole of what a driver
|
||||||
|
whose process has exited is owed.
|
||||||
|
|
||||||
|
**A message or a command starts the process if there isn't one** (decided
|
||||||
|
2026-08-30). Refusing was work handed back: read the status word, find the
|
||||||
|
other button, press it, type the thing again. Both plainly mean "do this
|
||||||
|
now", and `--resume` puts the new process on the same conversation, so
|
||||||
|
nothing about what was typed changes — only whether there was anything there
|
||||||
|
to read it. A rename is included, and for a sharper reason than the rest:
|
||||||
|
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 — and
|
||||||
|
the cost of a resume buys the one thing renaming is for. It stays
|
||||||
|
`rename_session` rather than becoming a command like the others, because the
|
||||||
|
name is persisted and listed as well as forwarded and that is one operation;
|
||||||
|
the save happens first, so a failure to start reports that the telling
|
||||||
|
failed, not the rename. The manager's `send_message` and `run_command` and
|
||||||
|
the Start button ask one function
|
||||||
|
(`start_if_exited`) and want opposite answers from it: "there is already a
|
||||||
|
process" is a refusal worth showing to somebody who pressed Start, and
|
||||||
|
nothing at all to a message. Deciding it in one place under one write lock is
|
||||||
|
also what stops two requests arriving together from starting two CLIs. Only
|
||||||
|
`Exited` starts anything, for the reason above — `Unknown` has a process that
|
||||||
|
may well be reading its fifo, and what was typed goes to the driver as it
|
||||||
|
always did.
|
||||||
|
|
||||||
|
A command needs one thing a message does not. `Commands::submit` refuses on
|
||||||
|
`Exited`, and a driver that has just started a process 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 had just
|
||||||
|
replaced. `start_if_exited` returning `Exited` is what says a process was
|
||||||
|
started, so `run_command` judges against `Idle` from there rather than
|
||||||
|
re-reading a status the pump may not have caught up with.
|
||||||
|
|
||||||
|
The phone's half is that the process button is disabled while its own request
|
||||||
|
is in flight, so a second press cannot be decided against a status the first
|
||||||
|
one has not changed yet. That is a courtesy rather than the fix: the server
|
||||||
|
refuses the second request either way, because a phone that has lost the
|
||||||
|
stream cannot be relied on to know.
|
||||||
|
|
||||||
On the phone this is one button in the composer, left of Send, whose mark and
|
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
|
colour say what pressing it would do now: an orange pause while a turn is
|
||||||
running (interrupt — the process stays), a red stop when it is not (end the
|
running (interrupt — the process stays), a red stop when it is not (end the
|
||||||
|
|||||||
@@ -568,6 +568,12 @@ fun SessionScreen(
|
|||||||
var compactingFor by remember { mutableStateOf<Long?>(null) }
|
var compactingFor by remember { mutableStateOf<Long?>(null) }
|
||||||
var streamError by remember { mutableStateOf<String?>(null) }
|
var streamError by remember { mutableStateOf<String?>(null) }
|
||||||
var actionError by remember { mutableStateOf<String?>(null) }
|
var actionError by remember { mutableStateOf<String?>(null) }
|
||||||
|
// Whether the composer's process button has a request out. What it does next is decided from
|
||||||
|
// the session's status, and the status only changes once the server has answered and the
|
||||||
|
// stream has carried it back -- so two presses in that gap are two requests, both decided
|
||||||
|
// against the state before either of them. The server refuses the second one, but a control
|
||||||
|
// that can be pressed while its own last press is still in flight is asking to be.
|
||||||
|
var processInFlight by remember { mutableStateOf(false) }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
// Seeded from what was left in the box last time and written back on every keystroke, so
|
// Seeded from what was left in the box last time and written back on every keystroke, so
|
||||||
// leaving the screen -- or the system reclaiming the app -- does not throw away a half-typed
|
// leaving the screen -- or the system reclaiming the app -- does not throw away a half-typed
|
||||||
@@ -1030,7 +1036,7 @@ fun SessionScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun act(onFailure: () -> Unit = {}, action: () -> Unit) {
|
fun act(onFailure: () -> Unit = {}, onDone: () -> Unit = {}, action: () -> Unit) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
withContext(Dispatchers.IO) { action() }
|
withContext(Dispatchers.IO) { action() }
|
||||||
@@ -1038,6 +1044,11 @@ fun SessionScreen(
|
|||||||
} catch (e: ApiException) {
|
} catch (e: ApiException) {
|
||||||
actionError = e.message
|
actionError = e.message
|
||||||
onFailure()
|
onFailure()
|
||||||
|
} finally {
|
||||||
|
// Whatever happened, including the failure above: a caller that re-enables a
|
||||||
|
// control here must get it back on the path where the request was refused too,
|
||||||
|
// or the refusal is what disables the control permanently.
|
||||||
|
onDone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1538,7 +1549,13 @@ fun SessionScreen(
|
|||||||
else -> ProcessAction.Stop
|
else -> ProcessAction.Stop
|
||||||
}
|
}
|
||||||
Button(
|
Button(
|
||||||
onClick = { act { process.perform(settings, summary.id) } },
|
onClick = {
|
||||||
|
processInFlight = true
|
||||||
|
act(onDone = { processInFlight = false }) {
|
||||||
|
process.perform(settings, summary.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !processInFlight,
|
||||||
colors = actionButtonColors(process.colour()),
|
colors = actionButtonColors(process.colour()),
|
||||||
) {
|
) {
|
||||||
Glyph(
|
Glyph(
|
||||||
|
|||||||
+32
-11
@@ -16,6 +16,7 @@
|
|||||||
//! (a backlog past CATCH_UP_LIMIT arrives as a
|
//! (a backlog past CATCH_UP_LIMIT arrives as a
|
||||||
//! `reset` frame plus the newest window)
|
//! `reset` frame plus the newest window)
|
||||||
//! POST /sessions/{id}/message {text, attachmentIds?}
|
//! 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}/answer {questionId, answers} (questions and permissions)
|
||||||
//! POST /sessions/{id}/interrupt stop the running turn; the process stays
|
//! POST /sessions/{id}/interrupt stop the running turn; the process stays
|
||||||
//! POST /sessions/{id}/stop end the process; the session and transcript stay
|
//! POST /sessions/{id}/stop end the process; the session and transcript stay
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
//! POST /sessions/{id}/title {title}
|
//! POST /sessions/{id}/title {title}
|
||||||
//! POST /sessions/{id}/model {model}
|
//! POST /sessions/{id}/model {model}
|
||||||
//! POST /sessions/{id}/command {text} -- /compact, /clear, /rename x, or the dialect's own
|
//! 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}/compact
|
||||||
//! POST /sessions/{id}/attachments multipart image upload -> {id}, referenced by /message
|
//! POST /sessions/{id}/attachments multipart image upload -> {id}, referenced by /message
|
||||||
//! GET /sessions/{id}/files/{name} images the session produced or was sent
|
//! GET /sessions/{id}/files/{name} images the session produced or was sent
|
||||||
@@ -640,11 +642,16 @@ async fn message(
|
|||||||
UrlPath(id): UrlPath<String>,
|
UrlPath(id): UrlPath<String>,
|
||||||
axum::Json(body): axum::Json<MessageRequest>,
|
axum::Json(body): axum::Json<MessageRequest>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> 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() {
|
if body.text.trim().is_empty() && body.attachment_ids.is_empty() {
|
||||||
return Err(ApiError::BadRequest("message is empty".to_string()));
|
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)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -834,15 +841,26 @@ async fn command(
|
|||||||
Some((name, rest)) => (name, rest.trim()),
|
Some((name, rest)) => (name, rest.trim()),
|
||||||
None => (text, ""),
|
None => (text, ""),
|
||||||
};
|
};
|
||||||
match (name, rest) {
|
// All of these start the session's process first if it has exited: a
|
||||||
("/compact", _) => lookup(&manager, &id)?.run_command(SessionCommand::Compact),
|
// command is something somebody asked the session to do, and answering
|
||||||
("/clear", _) => lookup(&manager, &id)?.run_command(SessionCommand::Clear),
|
// that its process is gone hands back the work of starting one.
|
||||||
// Through the manager, not the session: a name is persisted and
|
//
|
||||||
// listed as well as forwarded, and that is one operation.
|
// 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", "") => return Err(bad_request(anyhow::anyhow!("a session needs a name"))),
|
||||||
("/rename", title) => manager.rename_session(&id, title).map_err(bad_request)?,
|
("/rename", title) => {
|
||||||
_ => lookup(&manager, &id)?.run_command(SessionCommand::Raw(text.to_string())),
|
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)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -850,7 +868,10 @@ async fn compact(
|
|||||||
State(manager): State<Arc<SessionManager>>,
|
State(manager): State<Arc<SessionManager>>,
|
||||||
UrlPath(id): UrlPath<String>,
|
UrlPath(id): UrlPath<String>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
lookup(&manager, &id)?.compact();
|
lookup(&manager, &id)?;
|
||||||
|
manager
|
||||||
|
.run_command(&id, SessionCommand::Compact)
|
||||||
|
.map_err(bad_request)?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+410
-56
@@ -381,13 +381,6 @@ impl LiveSession {
|
|||||||
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
|
|
||||||
/// 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) {
|
pub fn interrupt(&self) {
|
||||||
self.driver().interrupt();
|
self.driver().interrupt();
|
||||||
}
|
}
|
||||||
@@ -399,13 +392,6 @@ impl LiveSession {
|
|||||||
self.driver().detach();
|
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> {
|
pub fn subscribe(&self) -> broadcast::Receiver<SeqEvent> {
|
||||||
self.events.subscribe()
|
self.events.subscribe()
|
||||||
}
|
}
|
||||||
@@ -1004,6 +990,15 @@ impl SessionManager {
|
|||||||
/// unlike the model and the permission mode, this is settled here and
|
/// unlike the model and the permission mode, this is settled here and
|
||||||
/// the driver is *told*, rather than asked and believed: see
|
/// the driver is *told*, rather than asked and believed: see
|
||||||
/// [`Driver::set_title`].
|
/// [`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<()> {
|
pub fn rename_session(&self, id: &str, title: &str) -> Result<()> {
|
||||||
let title = title.trim();
|
let title = title.trim();
|
||||||
// An empty name is not a name, and it is what a cleared field
|
// An empty name is not a name, and it is what a cleared field
|
||||||
@@ -1012,25 +1007,37 @@ impl SessionManager {
|
|||||||
if title.is_empty() {
|
if title.is_empty() {
|
||||||
bail!("a session needs a name");
|
bail!("a session needs a name");
|
||||||
}
|
}
|
||||||
let mut inner = self.inner.write().unwrap();
|
{
|
||||||
if !inner.config.sessions.iter().any(|meta| meta.id == id) {
|
let mut inner = self.inner.write().unwrap();
|
||||||
bail!("no session {id}");
|
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();
|
// Dropped the lock first -- `run_command` takes it again to decide
|
||||||
for meta in candidate.sessions.iter_mut().filter(|meta| meta.id == id) {
|
// whether anything needs starting, and this is not a reentrant one.
|
||||||
meta.title = title.to_string();
|
//
|
||||||
}
|
// The context matters more than it looks: the rename above is saved
|
||||||
candidate.save(&self.config_path)?;
|
// by the time this can fail, so a bare error would report a rename
|
||||||
inner.config = candidate;
|
// that did not happen. What failed is only the telling.
|
||||||
if let Some(session) = inner.live.get(id) {
|
self.run_command(id, SessionCommand::SetTitle(title.to_string()))
|
||||||
// The name is this server's and changes now. Telling whatever
|
.with_context(|| {
|
||||||
// runs the session is a command, and commands wait for the
|
format!(
|
||||||
// turn to end -- so the list shows the new name immediately
|
"renamed to \"{title}\" here, but the session's own copy of the name could \
|
||||||
// and the CLI is told at the next boundary.
|
not be changed"
|
||||||
*session.shared.title.lock().unwrap() = title.to_string();
|
)
|
||||||
session.run_command(SessionCommand::SetTitle(title.to_string()));
|
})
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_session_model(&self, id: &str, model: &str) -> Result<()> {
|
pub fn set_session_model(&self, id: &str, model: &str) -> Result<()> {
|
||||||
@@ -1100,9 +1107,14 @@ impl SessionManager {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Starts a process for a session whose process has ended, continuing
|
/// Starts a process for a session whose process has ended, for somebody
|
||||||
/// the same conversation -- for Claude Code, the `--resume` that crash
|
/// who asked for exactly that.
|
||||||
/// recovery already uses.
|
///
|
||||||
|
/// 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
|
/// 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
|
/// 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
|
/// writer of the transcript, which relaunching the whole session would
|
||||||
/// not be.
|
/// not be.
|
||||||
///
|
///
|
||||||
/// Refused unless the session is *known* to have exited. `Unknown` means
|
pub fn start_session(&self, id: &str) -> Result<()> {
|
||||||
/// nobody could find out whether the process is alive, and starting one
|
match self.start_if_exited(id)? {
|
||||||
/// on that is precisely the second-CLI-on-one-conversation fault that
|
SessionStatus::Exited => Ok(()),
|
||||||
/// `session::process` exists to prevent.
|
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
|
/// 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
|
/// 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
|
/// 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
|
/// stop button that turned into a play button a moment after the screen
|
||||||
/// opened.
|
/// 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 mut inner = self.inner.write().unwrap();
|
||||||
let meta = inner
|
let meta = inner
|
||||||
.config
|
.config
|
||||||
@@ -1132,17 +1221,27 @@ impl SessionManager {
|
|||||||
.with_context(|| format!("no session {id}"))?
|
.with_context(|| format!("no session {id}"))?
|
||||||
.clone();
|
.clone();
|
||||||
let existing = inner.live.get(id).cloned();
|
let existing = inner.live.get(id).cloned();
|
||||||
|
let dir = self.data_dir.join(id);
|
||||||
let status = match &existing {
|
let status = match &existing {
|
||||||
Some(session) => *session.shared.status.lock().unwrap(),
|
Some(session) => {
|
||||||
None => status_of_unlaunched(&self.data_dir.join(id)),
|
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 {
|
if status != SessionStatus::Exited {
|
||||||
SessionStatus::Exited => {}
|
return Ok(status);
|
||||||
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
|
// Fresh from the config, like every other launch: a model or a
|
||||||
// permission mode changed while the session was stopped is what it
|
// permission mode changed while the session was stopped is what it
|
||||||
@@ -1150,6 +1249,12 @@ impl SessionManager {
|
|||||||
let (setup, provider) = resolve(&inner.config, &meta)?;
|
let (setup, provider) = resolve(&inner.config, &meta)?;
|
||||||
match existing {
|
match existing {
|
||||||
Some(session) => {
|
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(
|
*session.driver.lock().unwrap() = make_driver(
|
||||||
&meta,
|
&meta,
|
||||||
&setup,
|
&setup,
|
||||||
@@ -1182,7 +1287,7 @@ impl SessionManager {
|
|||||||
// here as well would be a second writer of the same fact, and the
|
// 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
|
// one that cannot see whether the process it is describing is still
|
||||||
// there.
|
// there.
|
||||||
Ok(())
|
Ok(SessionStatus::Exited)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kills the process, releases everything the spawn created, and
|
/// 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
|
/// 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
|
/// does not know what it is doing -- and that is worth a word that means
|
||||||
/// "wait", not one that means "act".
|
/// "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 {
|
fn status_of_unlaunched(session_dir: &Path) -> SessionStatus {
|
||||||
match process::recorded(session_dir) {
|
match process::recorded(session_dir) {
|
||||||
// Nothing was ever recorded: an echo session, or one whose
|
// Nothing was ever recorded: an echo session, or one whose
|
||||||
@@ -1422,6 +1559,7 @@ fn launch(
|
|||||||
wg_app_link::private::create_dir(&dir)?;
|
wg_app_link::private::create_dir(&dir)?;
|
||||||
let transcript_path = dir.join("transcript.jsonl");
|
let transcript_path = dir.join("transcript.jsonl");
|
||||||
let mut transcript = Transcript::open(&transcript_path)?;
|
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
|
// Before the driver starts, so the token is there when it looks and
|
||||||
// the history is already in the transcript a phone will read.
|
// the history is already in the transcript a phone will read.
|
||||||
if let Some(seed) = seed {
|
if let Some(seed) = seed {
|
||||||
@@ -1439,8 +1577,11 @@ fn launch(
|
|||||||
// What it was last known to be doing, not an assumption. A driver
|
// What it was last known to be doing, not an assumption. A driver
|
||||||
// that has something to say corrects this within its first poll;
|
// that has something to say corrects this within its first poll;
|
||||||
// one adopting a process that has been quiet says nothing, and
|
// one adopting a process that has been quiet says nothing, and
|
||||||
// this is then the only true answer available.
|
// this is then the only true answer available. Where it is *not*
|
||||||
status: Mutex::new(transcript.last_status().unwrap_or(SessionStatus::Idle)),
|
// 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()),
|
title: Mutex::new(meta.title.clone()),
|
||||||
// What the transcript last recorded, not the clock: this server has
|
// What the transcript last recorded, not the clock: this server has
|
||||||
// just been told nothing, and `now()` claimed every relaunched
|
// 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(
|
let driver = Arc::new(Mutex::new(make_driver(
|
||||||
&meta,
|
&meta,
|
||||||
setup,
|
setup,
|
||||||
@@ -1877,7 +2034,9 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
session.run_command(SessionCommand::Clear);
|
manager
|
||||||
|
.run_command(&info.id, SessionCommand::Clear)
|
||||||
|
.expect("clear");
|
||||||
let held = collect_until(&mut rx, |event| {
|
let held = collect_until(&mut rx, |event| {
|
||||||
matches!(
|
matches!(
|
||||||
event,
|
event,
|
||||||
@@ -2188,7 +2347,9 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.await;
|
.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| {
|
let seen = collect_until(&mut rx, |event| {
|
||||||
matches!(event, Event::CommandQueued { .. })
|
matches!(event, Event::CommandQueued { .. })
|
||||||
})
|
})
|
||||||
@@ -2232,7 +2393,9 @@ mod tests {
|
|||||||
let session = manager.session(&info.id).expect("live session");
|
let session = manager.session(&info.id).expect("live session");
|
||||||
let mut rx = session.subscribe();
|
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;
|
let seen = collect_until(&mut rx, |event| matches!(event, Event::ToolStart { .. })).await;
|
||||||
// Sent, and never queued: a session between turns has nothing to
|
// Sent, and never queued: a session between turns has nothing to
|
||||||
// wait for, and a phone should not draw a bubble that resolves in
|
// 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
|
// A clear leaves it unmeasured: the conversation is gone, and how
|
||||||
// much is left is a thing nobody has counted.
|
// 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;
|
collect_until(&mut rx, |event| matches!(event, Event::Cleared)).await;
|
||||||
assert_eq!(manager.sessions()[0].context_tokens, None);
|
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]
|
#[tokio::test]
|
||||||
async fn a_restart_relaunches_sessions_and_continues_the_numbering() {
|
async fn a_restart_relaunches_sessions_and_continues_the_numbering() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
|||||||
Reference in new issue
Block a user