Order a session's subagents by activity, and delete finished ones by holding

The subcards were oldest first, which buried whatever is working now. They
are ordered on the phone -- still running first, then most recently active --
over the server's stable oldest-first answer, since presentation order is a
display decision and a subagent that is thinking reports nothing meanwhile.

Holding a subcard selects it and several at a time, the import list's gesture
and its confirmation, so selecting is learned once. The selection bar sits
inside the session's card rather than at the bottom of the screen: it belongs
to one card, and one Delete is one request against one parent, so picking a
row in another card moves the selection rather than adding to it. Delete is
disabled, with the reason in words, while anything selected is still running
-- its transcript is still being written to and its process is the session's
to stop, so the server refuses that batch outright.

`POST /sessions/{id}/subagents/delete` takes the batch and checks every id
before removing any, so a set naming a running one is left exactly as it was
rather than half-deleted. It is `Subagents::start`'s path out. What counts as
running is shared with the list route through `has_a_process`, so the two
cannot disagree. On success the phone takes those rows out of that one card
and off the session's count, purges its cached copies, and drops the
expansion when nothing is left -- nothing else is refetched.

Driven on the emulator against the sandbox with ui-trace's new hold-by-name:
selecting two, the dialog, the rows going, a running one holding Delete
disabled, and the expander leaving with the last subagent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-06 13:10:21 -04:00
1 parent cf10b17c5b
commit 13d2d11c2d
5 files changed
+506 -30

No files matched your search

+45 -6
View File
@@ -35,6 +35,8 @@
//! against that subagent's own transcript
//! GET /sessions/{id}/subagents/{sub}/events?after=N exactly the events route above,
//! against that subagent's own stream
//! POST /sessions/{id}/subagents/delete {subagents} -- remove finished ones, transcripts
//! and all; refused while any named one is running
//! POST /sessions/{id}/message {text, attachmentIds?}
//! (starts the process first if it has exited)
//! POST /sessions/{id}/unqueue {messageId} -- take back one not read yet
@@ -138,6 +140,7 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
.route("/sessions/{id}/events", get(events))
.route("/sessions/{id}/transcript", get(transcript))
.route("/sessions/{id}/subagents", get(list_subagents))
.route("/sessions/{id}/subagents/delete", post(delete_subagents))
.route(
"/sessions/{id}/subagents/{sub}/transcript",
get(subagent_transcript),
@@ -1893,15 +1896,51 @@ async fn list_subagents(
UrlPath(id): UrlPath<String>,
) -> Result<axum::Json<Vec<SubagentInfo>>, ApiError> {
let session = lookup(&manager, &id)?;
// Anything but `Exited` or `Unknown` has a process behind it, which is
// what decides whether a subagent still reading `Running` from its own
// transcript can be believed -- see `SUBAGENTS.md`'s wire shape.
let running = !matches!(
Ok(axum::Json(
session.subagents().list(has_a_process(&session)),
))
}
/// Whether this session has a process behind it, which is what decides
/// whether a subagent still reading `Running` from its own transcript can be
/// believed -- see `SUBAGENTS.md`'s wire shape. Shared by every route that
/// asks that question, so listing and deleting cannot disagree about which
/// subagents are running.
fn has_a_process(session: &LiveSession) -> bool {
!matches!(
session.status(),
crate::session::driver::SessionStatus::Exited
| crate::session::driver::SessionStatus::Unknown
);
Ok(axum::Json(session.subagents().list(running)))
)
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct DeleteSubagentsRequest {
subagents: Vec<String>,
}
/// `POST /sessions/{id}/subagents/delete`: removes finished subagents,
/// transcripts and all.
///
/// A batch rather than a `DELETE` per id, for the reason the import list's
/// delete is one: the phone deletes what a reader selected, and one request
/// per row means a batch can half-arrive, leaving rows that were missed
/// looking exactly like rows nobody picked. Nothing is spawned and nothing
/// is reported on a stream -- unlike importing, this is local file removal,
/// so it is done by the time the reply is sent.
async fn delete_subagents(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
axum::Json(body): axum::Json<DeleteSubagentsRequest>,
) -> Result<StatusCode, ApiError> {
let session = lookup(&manager, &id)?;
session
.subagents()
.delete(&body.subagents, has_a_process(&session))
.map_err(bad_request)?;
tracing::info!("deleted {} subagents of {id}", body.subagents.len());
Ok(StatusCode::NO_CONTENT)
}
/// Every session's attention-wanting moments, on one stream.
+97
View File
@@ -313,6 +313,51 @@ impl Subagents {
}
}
/// Removes subagents, transcripts and all -- `POST
/// /sessions/{id}/subagents/delete`, and the path out for [`start`]
/// short of deleting the whole session.
///
/// **All or nothing, and only for one that has finished.** Every id is
/// checked before anything is removed, so a batch naming one that is
/// still running leaves the others exactly as they were rather than
/// deleting up to the offender -- the reader picked a set, and a set
/// half-deleted is indistinguishable, on the list, from rows they never
/// picked. Refusing a running one is not withholding the capability:
/// its transcript is still being written to, and its process is the
/// session's to stop.
///
/// `session_running` decides what "running" means here, exactly as it
/// does in [`Subagents::list`].
///
/// [`start`]: Subagents::start
pub fn delete(&self, ids: &[String], session_running: bool) -> Result<()> {
let dirs: Vec<PathBuf> = ids
.iter()
.map(|id| {
anyhow::ensure!(is_subagent_id(id), "{id} is not a subagent id");
Ok(self.subagents_dir().join(id))
})
.collect::<Result<_>>()?;
for (id, dir) in ids.iter().zip(&dirs) {
let info = info_of(dir, session_running)
.with_context(|| format!("there is no subagent {id} here"))?;
anyhow::ensure!(
info.status != SessionStatus::Running,
"\"{}\" is still running -- it can be deleted once it has finished",
info.title
);
}
let mut live = self.live.lock().unwrap();
for (id, dir) in ids.iter().zip(&dirs) {
fs::remove_dir_all(dir).with_context(|| format!("delete {}", dir.display()))?;
// Out of the registry as well as off the disk, so a later child
// line for this id starts a new subagent rather than appending
// to an unlinked file nothing can read.
live.remove(id);
}
Ok(())
}
/// Every subagent under this session's directory, oldest first --
/// `GET /sessions/{id}/subagents`. Read straight from disk rather than
/// from `live`, so a subagent from before this process started (or one
@@ -463,6 +508,58 @@ mod tests {
subagents.finish("never-started");
}
#[test]
fn deleting_a_finished_subagent_takes_its_directory_with_it() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = Subagents::new(dir.path().to_path_buf());
subagents.start("toolu_done", "helper", None);
subagents.finish("toolu_done");
subagents
.delete(&["toolu_done".to_string()], true)
.expect("delete");
assert!(subagents.list(true).is_empty());
assert!(!dir.path().join("subagents").join("toolu_done").exists());
// Out of the live registry too, so a later line starts a new one rather than appending to
// a file nothing can read.
assert!(subagents.get("toolu_done").is_none());
}
#[test]
fn deleting_a_batch_with_a_running_one_in_it_deletes_none_of_it() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = Subagents::new(dir.path().to_path_buf());
subagents.start("toolu_done", "finished helper", None);
subagents.finish("toolu_done");
subagents.start("toolu_busy", "busy helper", None);
let err = subagents
.delete(&["toolu_done".to_string(), "toolu_busy".to_string()], true)
.expect_err("refused");
assert!(err.to_string().contains("busy helper"), "{err:#}");
assert_eq!(subagents.list(true).len(), 2);
// The same batch once the session behind it has no process: nothing there is running, so
// both go.
subagents
.delete(&["toolu_done".to_string(), "toolu_busy".to_string()], false)
.expect("delete");
assert!(subagents.list(false).is_empty());
}
#[test]
fn deleting_an_id_that_is_not_there_is_refused_rather_than_ignored() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = Subagents::new(dir.path().to_path_buf());
assert!(
subagents
.delete(&["toolu_ghost".to_string()], true)
.is_err()
);
assert!(subagents.delete(&["../../etc".to_string()], true).is_err());
}
#[test]
fn finish_all_closes_only_what_is_still_open() {
let dir = tempfile::tempdir().expect("tempdir");