End subagents on the CLI's own task lifecycle, and detect a limit two ways

Subagents were showing "running" long after they had finished. Measured
against 2.1.237 by running a session that launched one Task agent and
reading its stdout: a subagent's lines carry no `stream_event` at all --
they are whole `user`/`assistant` lines with a null `stop_reason` -- and no
`result` line is sent for one. So `ends_a_turn`, which watches for a raw
`message_delta` saying `end_turn`, could never fire for a subagent, and
nothing finished one until its session's process exited.

What the CLI does send is a task lifecycle, as top-level `system` lines:
`task_started` (with the tool_use id), `task_progress`, `task_updated`
(status, naming the task only) and `task_notification` (tool id, status, and
the agent's own summary). `translate_task` keeps the task -> tool mapping,
records the summary as the subagent's closing text -- the run showed its
child lines stop at its last tool_result, so without this a finished
subagent reads as stopping mid-tool -- and ends it. A `completed` update is
deliberately not the end, since its notification carries the summary; any
other terminal status is, because the failure to avoid is a subagent nothing
ever finishes. `ends_a_turn` stays as a second detector and must never be
the only one again. Verified by replaying the captured stream through the
server as a fake CLI: running, prompt, Bash call, output, report, exited.

`finish_all` now reads the directory rather than the live map, which is what
clears the ones already stuck: a subagent left running by an earlier run of
the server is exactly the one this process never touched, so it read
"running" again every time its session was started.

Auto-resume gets the same treatment on its own single point of failure. The
only thing that scheduled a resume was the CLI's error sentence at the end
of a failed turn; the CLI also sends `rate_limit_event` lines saying where
the account stands, and this server ignored them entirely. Both are read
now. Anything that is not an `allowed...` status counts as refused and is
logged if unfamiliar -- being wrong that way costs one question to the usage
meter, which is still what decides whether anything is sent, and being wrong
the other way is the feature silently not existing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-06 13:29:23 -04:00
1 parent 13d2d11c2d
commit 74c07d687a
5 files changed
+371 -22

No files matched your search

+38 -4
View File
@@ -301,11 +301,26 @@ impl Subagents {
}
/// The parent session's process is gone, so nothing still open here has
/// a process behind it either -- see `SUBAGENTS.md`'s lifecycle #4.
/// a process behind it either -- see `SUBAGENTS.md`'s lifecycle #5.
///
/// Read from the directory rather than from `live`, because a subagent
/// left `Running` by a *previous* run of this server is exactly the one
/// that needs closing and is the one `live` does not have: nothing in
/// this process ever touched it, so it would keep reading `running`
/// every time its session was started again, with nothing able to
/// correct it.
pub fn finish_all(&self) {
let subagents: Vec<Arc<Subagent>> = self.live.lock().unwrap().values().cloned().collect();
for subagent in subagents {
if subagent.is_open() {
// `list(true)` reports each one's own last status rather than
// rewriting a running one as unknown -- what is wanted here is which
// are open on disk, and this call is the very thing that decides the
// session is not running.
for info in self.list(true) {
if info.status != SessionStatus::Running {
continue;
}
if let Some(subagent) = self.get(&info.id)
&& subagent.is_open()
{
subagent.append(Event::Status {
state: SessionStatus::Exited,
});
@@ -508,6 +523,25 @@ mod tests {
subagents.finish("never-started");
}
#[test]
fn finish_all_closes_one_left_running_by_an_earlier_run() {
// The state a backend restart leaves behind: the subagent is on disk
// reading `Running` and nothing in this process has touched it, so a
// registry that only knew its own `live` map left it running for
// ever -- and the phone showed it as running every time the session
// was started again.
let dir = tempfile::tempdir().expect("tempdir");
{
let subagents = Subagents::new(dir.path().to_path_buf());
subagents.start("toolu_stale", "helper", None);
}
let subagents = Subagents::new(dir.path().to_path_buf());
subagents.finish_all();
let rows = subagents.list(true);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].status, SessionStatus::Exited);
}
#[test]
fn deleting_a_finished_subagent_takes_its_directory_with_it() {
let dir = tempfile::tempdir().expect("tempdir");