Don't call a turn finished with a message still waiting behind it

A message written into the tail of a turn is read the moment that turn's
`result` lands: the session reports idle and is running again in the same
breath. The phone that sent it got "finished" in between -- seconds before
anything it asked for had been done, which is the notification arriving to
say the opposite of what is happening.

`notification_for` now takes how many messages the session has been given
and not started reading, and a turn ending with any of them waiting is not
an ending. The count is kept in `pump`, from the recorded events, because
that is the one place that sees all of them in transcript order: a
`messageQueued` up, and the `userMessage` that resolves it or a
`messageDropped` down. Asking the driver instead would answer about the
moment the question was asked rather than the moment the status was
written, which is the same class of mistake as reading a session's status
to decide what a queue contains.

It deliberately does not suppress *awaiting input*. A question is worth
interrupting somebody for whatever is queued behind it -- the queue is
precisely what will not move until it is answered.

Tested both halves: the decision on the number, and the number itself,
where an echo turn that reads its queued message before going idle still
announces its finish. That last is the case a suppression written slightly
wrong silences, and it is the common one.
This commit is contained in:
iris committed 2026-08-31 22:43:24 -04:00
1 parent bfaf5e6f38
commit a1eedd7a78
2 files changed
+118 -15

No files matched your search

+105 -15
View File
@@ -2086,10 +2086,26 @@ fn is_news(event: &Event, shared: &Shared) -> bool {
/// adopted at startup, or because a driver announced itself, is not news
/// that anything ended, and sending it would put "finished" on the phone for
/// every session in the config every time the backend restarts.
fn notification_for(was: SessionStatus, now: SessionStatus) -> Option<NotificationKind> {
///
/// `unread` is how many messages the session has been handed and not yet
/// started reading, and it suppresses *Finished* for the same reason: with
/// one waiting, the turn ending is not the work ending. A message written
/// into the tail of a turn is read as soon as that turn's `result` lands, so
/// the session goes idle and immediately runs again -- and the phone that
/// sent it was told its work had finished, seconds before anything of it had
/// been done. It cannot suppress *AwaitingInput*: a question is worth saying
/// whatever else is queued behind it, and the queue is precisely what will
/// not move until it is answered.
fn notification_for(
was: SessionStatus,
now: SessionStatus,
unread: usize,
) -> Option<NotificationKind> {
match (was, now) {
(_, SessionStatus::AwaitingInput) => Some(NotificationKind::AwaitingInput),
(SessionStatus::Running | SessionStatus::Compacting, SessionStatus::Idle) => {
(SessionStatus::Running | SessionStatus::Compacting, SessionStatus::Idle)
if unread == 0 =>
{
Some(NotificationKind::Finished)
}
_ => None,
@@ -2105,6 +2121,13 @@ async fn pump(
commands: Arc<Commands>,
notifications: broadcast::Sender<Notification>,
) {
// Messages the session has been given and not started reading, which is
// what makes a turn ending not the same thing as the work ending; see
// `notification_for`. Counted from the recorded events rather than asked
// of the driver, because this is the one place that sees every event in
// the order the transcript has them -- and because the answer has to
// survive being asked a moment later than the driver would have said it.
let mut unread: usize = 0;
while let Some(event) = source.recv().await {
let ts = now();
// Taking a message is how it enters the conversation, and the
@@ -2154,8 +2177,8 @@ async fn pump(
// Read before it is overwritten: what makes a status
// worth announcing is the transition, not the value.
let was = std::mem::replace(&mut *shared.status.lock().unwrap(), *state);
if let Some(kind) =
notification_for(was, *state).filter(|_| *shared.notify.lock().unwrap())
if let Some(kind) = notification_for(was, *state, unread)
.filter(|_| *shared.notify.lock().unwrap())
{
// No subscribers is the ordinary case -- nobody has
// the app open -- and it is not an error.
@@ -2180,6 +2203,13 @@ async fn pump(
Event::Status {
state: SessionStatus::Exited,
} => commands.abandon("this session's process has exited"),
// The two ends of a message's wait. A `UserMessage` with
// no id never waited -- it is one sent between turns, and
// counting it would take the total below zero.
Event::MessageQueued { .. } => unread += 1,
Event::UserMessage { id: Some(_), .. } | Event::MessageDropped { .. } => {
unread = unread.saturating_sub(1)
}
_ => {}
}
// No subscribers is fine; the transcript already has it.
@@ -2400,28 +2430,43 @@ mod tests {
// Waiting on a person is worth saying however it was reached: it
// will sit unanswered until somebody is told.
assert_eq!(
notification_for(Running, SessionStatus::AwaitingInput),
notification_for(Running, SessionStatus::AwaitingInput, 0),
Some(AwaitingInput)
);
assert_eq!(
notification_for(Idle, SessionStatus::AwaitingInput),
notification_for(Idle, SessionStatus::AwaitingInput, 0),
Some(AwaitingInput)
);
// A turn this server watched run, ending.
assert_eq!(notification_for(Running, Idle), Some(Finished));
assert_eq!(notification_for(Compacting, Idle), Some(Finished));
assert_eq!(notification_for(Running, Idle, 0), Some(Finished));
assert_eq!(notification_for(Compacting, Idle, 0), Some(Finished));
// Idle arrived at from anywhere else is not an ending.
assert_eq!(notification_for(Idle, Idle), None);
assert_eq!(notification_for(Unknown, Idle), None);
assert_eq!(notification_for(Exited, Idle), None);
assert_eq!(notification_for(SessionStatus::AwaitingInput, Idle), None);
assert_eq!(notification_for(Idle, Idle, 0), None);
assert_eq!(notification_for(Unknown, Idle, 0), None);
assert_eq!(notification_for(Exited, Idle, 0), None);
assert_eq!(
notification_for(SessionStatus::AwaitingInput, Idle, 0),
None
);
// Everything else a session does is progress nobody asked to hear.
assert_eq!(notification_for(Idle, Running), None);
assert_eq!(notification_for(Running, Compacting), None);
assert_eq!(notification_for(Running, Exited), None);
assert_eq!(notification_for(Idle, Running, 0), None);
assert_eq!(notification_for(Running, Compacting, 0), None);
assert_eq!(notification_for(Running, Exited, 0), None);
// A turn ending with a message the session has not started reading
// is not the work ending: it goes straight back to running, and
// "finished" would arrive seconds before any of that work was done.
assert_eq!(notification_for(Running, Idle, 1), None);
assert_eq!(notification_for(Compacting, Idle, 2), None);
// A question is still worth saying with a queue behind it -- the
// queue is exactly what will not move until it is answered.
assert_eq!(
notification_for(Running, SessionStatus::AwaitingInput, 1),
Some(AwaitingInput)
);
}
/// The switch reaches the running pump, not just the config file.
@@ -2477,6 +2522,51 @@ mod tests {
);
}
/// Counting the wait, rather than only deciding what to do about it.
///
/// `notification_for` is tested above on the number; this is the number
/// itself, which is kept in `pump` from the recorded events and has no
/// other way to be looked at. Echo takes its queued message *before*
/// going idle -- the same order a real CLI has when the steer lands
/// inside the turn -- so the count is back to zero by the end and the
/// finish is still announced. That is the case a suppression written
/// slightly wrong silences, and it is the common one.
#[tokio::test]
async fn a_turn_that_read_its_queued_message_still_announces_its_finish() {
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");
let mut events = session.subscribe();
let mut notifications = manager.subscribe_notifications();
session.send_message("/slow 1".to_string(), Vec::new());
collect_until(&mut events, |event| {
matches!(
event,
Event::Status {
state: SessionStatus::Running
}
)
})
.await;
session.send_message("and this behind it".to_string(), Vec::new());
collect_until(&mut events, |event| {
matches!(event, Event::MessageQueued { .. })
})
.await;
let announced = tokio::time::timeout(Duration::from_secs(5), notifications.recv())
.await
.expect("a notification within five seconds")
.expect("channel open");
assert_eq!(announced.kind, NotificationKind::Finished);
}
/// A session this app *spawned* is one it is driving, and used to look
/// like somebody else's.
///