Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 5 additions & 12 deletions cmux-tui/crates/acpmux/src/hub/hosts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,9 @@ impl Hub {
return Ok(());
}
let resume_after = self.last_host_seq(session, &record.incarnation);
// The subagents the host still runs, before its first replayed line reaches the tap:
// a child's line is tagged with its session from the first one (`work_below.rs`).
self.seed_subagents_from_log(session);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: seed_subagents_from_log runs before adoption proves that the host is compatible. An incompatible or failed attach therefore leaves subagents from the old log permanently reported as running even though this daemon will not receive their updates; move seeding into the Attached::Ready path immediately before replay delivery.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At cmux-tui/crates/acpmux/src/hub/hosts.rs, line 487:

<comment>`seed_subagents_from_log` runs before adoption proves that the host is compatible. An incompatible or failed attach therefore leaves subagents from the old log permanently reported as running even though this daemon will not receive their updates; move seeding into the `Attached::Ready` path immediately before replay delivery.</comment>

<file context>
@@ -482,6 +482,9 @@ impl Hub {
         let resume_after = self.last_host_seq(session, &record.incarnation);
+        // The subagents the host still runs, before its first replayed line reaches the tap:
+        // a child's line is tagged with its session from the first one (`work_below.rs`).
+        self.seed_subagents_from_log(session);
         let tap = self.session_tap(session);
         let incarnation = record.incarnation.clone();
</file context>

let tap = self.session_tap(session);
let incarnation = record.incarnation.clone();
let caller_token = record.caller_token.clone();
Expand Down Expand Up @@ -1968,18 +1971,8 @@ fn open_work_reads(head: &RecordHead<'_>, current: bool, turn_open: bool) -> boo
"in" => {
// An open turn's records: the model's signs of work, its tools, steers' answers.
turn_open
// Every subagent the log starts, which a later turn's update may belong to
// (`SubagentTree::annotate`, replayed history included; Codex's legacy ones are
// a `spawnAgent` call or a `_meta.codex.subagent` object).
|| matches!(
head.kind.strip_suffix(".replay").unwrap_or(head.kind),
"subagent_spawned" | "subagent_state_update" | "subagent_progress"
)
|| (matches!(
head.kind.strip_suffix(".replay").unwrap_or(head.kind),
"tool_call" | "tool_call_update"
) && (head.may_contain(b"\"spawnAgent\"")
|| head.may_contain(b"\"subagent\":{")))
// Every subagent the log starts, which a later turn's update may belong to.
|| super::work_below::names_subagents(head)
// An agent request (an `id` and a `method`), answered or not.
|| (current && head.may_contain(b"\"id\":") && head.may_contain(b"\"method\":"))
}
Expand Down
61 changes: 61 additions & 0 deletions cmux-tui/crates/acpmux/src/hub/work_below.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,57 @@ impl Hub {
self.refresh_work_below(session);
}

/// Before a daemon adopts `session`'s running host (`hosts.rs` `adopt_held`), its subagents
/// are rebuilt from the log, as the tap followed them live: which subagents the chat started,
/// so a child's next line is still tagged with its session id, and which still run, so the
/// summary still counts them. A new daemon started with none, and until each subagent's next
/// progress event its lines reached clients untagged, as the chat's own (a helper's grep read
/// as the chat still running grep after its turn ended). A harness start ends the background
/// subagents of the one before, and a turn start the foreground ones, as live.
pub(super) fn seed_subagents_from_log(&self, session: &Session) {
let meta = session.meta();
let mut tree = crate::subagents::SubagentTree::default();
tree.set_own(meta.agent_session_id.as_deref());
tree.set_roots(
meta.agent_session_id.iter().chain(&meta.retired_agent_session_ids).map(String::as_str),
);
let _ = self.store.scan_heads(&session.id, &mut |head| {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This discards log-scan failures and lets adoption continue with an incomplete subagent tree, so the first post-restart child update can be tagged as the chat and the summary can undercount running subagents. Propagate the scan error or otherwise refuse/delay adoption until reconstruction succeeds.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At cmux-tui/crates/acpmux/src/hub/work_below.rs, line 126:

<comment>This discards log-scan failures and lets adoption continue with an incomplete subagent tree, so the first post-restart child update can be tagged as the chat and the summary can undercount running subagents. Propagate the scan error or otherwise refuse/delay adoption until reconstruction succeeds.</comment>

<file context>
@@ -109,6 +109,57 @@ impl Hub {
+        tree.set_roots(
+            meta.agent_session_id.iter().chain(&meta.retired_agent_session_ids).map(String::as_str),
+        );
+        let _ = self.store.scan_heads(&session.id, &mut |head| {
+            if head.dir == "mux" {
+                match head.kind {
</file context>

if head.dir == "mux" {
match head.kind {
"host_started" | "stopped" | "exited" => tree.end_background(),
"turn_started" => tree.end_foreground(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The seed leaves foreground subagents running after a logged turn_result until another turn starts. Handle turn_result here as a foreground boundary, so canceled or otherwise unclosed foreground agents do not remain in subagentsRunning after restart.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At cmux-tui/crates/acpmux/src/hub/work_below.rs, line 130:

<comment>The seed leaves foreground subagents running after a logged `turn_result` until another turn starts. Handle `turn_result` here as a foreground boundary, so canceled or otherwise unclosed foreground agents do not remain in `subagentsRunning` after restart.</comment>

<file context>
@@ -109,6 +109,57 @@ impl Hub {
+            if head.dir == "mux" {
+                match head.kind {
+                    "host_started" | "stopped" | "exited" => tree.end_background(),
+                    "turn_started" => tree.end_foreground(),
+                    _ => {}
+                }
</file context>
Suggested change
"turn_started" => tree.end_foreground(),
"turn_started" | "turn_result" => tree.end_foreground(),

_ => {}
}
return true;
}
if head.dir != "in" || !names_subagents(head) {
return true;
}
let replay = head.kind.ends_with(".replay");
if let Some(mut e) = head.record()
&& let Some(params) = e.msg.get_mut("params")
{
tree.annotate_live(params, !replay);
}
true
});
let (running_before, running_after) = {
let mut live = session.subagents.lock().unwrap_or_else(|e| e.into_inner());
let before = live.running_count();
live.adopt(tree);
(before, live.running_count())
};
if running_after != running_before {
self.append(
session,
"mux",
super::views::SUBAGENTS_RUNNING,
json!({"count": running_after}),
);
}
self.refresh_work_below(session);
}

/// Whether a background subagent of `session` runs, or one of its open sub-sessions works.
fn works_below(&self, session: &Session) -> bool {
if session.subagents.lock().unwrap_or_else(|e| e.into_inner()).background_running() > 0 {
Expand Down Expand Up @@ -136,6 +187,16 @@ impl Hub {
}
}

/// Whether an agent's logged line may start, change or end a subagent (`SubagentTree::annotate`,
/// replayed history included): a native subagent record, or Codex's legacy `spawnAgent` call or
/// `_meta.codex.subagent` activity. Judged from the head, so a scan parses no other line.
pub(super) fn names_subagents(head: &crate::store::RecordHead<'_>) -> bool {
let kind = head.kind.strip_suffix(".replay").unwrap_or(head.kind);
matches!(kind, "subagent_spawned" | "subagent_state_update" | "subagent_progress")
|| (matches!(kind, "tool_call" | "tool_call_update")
&& (head.may_contain(b"\"spawnAgent\"") || head.may_contain(b"\"subagent\":{")))
}

#[cfg(test)]
#[path = "work_below_tests.rs"]
mod work_below_tests;
148 changes: 25 additions & 123 deletions cmux-tui/crates/acpmux/src/subagents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,29 @@ impl SubagentTree {
owner
}

/// Takes in what `log` (a tree rebuilt from the chat's log) knows: every subagent it started,
/// and which of them still run. A subagent this tree already knows keeps its own state, since
/// a live update may have ended it after the log was read.
pub fn adopt(&mut self, log: SubagentTree) {
let known: HashSet<String> = self.parents.keys().cloned().collect();
for (id, parent) in log.parents {
self.parents.entry(id).or_insert(parent);
}
for (sender, children) in log.pending {
self.pending.entry(sender).or_default().extend(children);
}
for (id, preview) in log.previews {
self.previews.entry(id).or_insert(preview);
}
self.background.extend(log.background.into_iter().filter(|id| !known.contains(id)));
for (id, label) in log.running {
if !known.contains(&id) && !self.running.iter().any(|(r, _)| *r == id) {
self.running.push((id, label));
}
}
self.root_threads.extend(log.root_threads);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Adopting a root known by the durable log does not repair a live tree's pre-root phantom state. Apply each adopted root through remember_root so its stale parents, background, and running entries are removed before subsequent updates are classified.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At cmux-tui/crates/acpmux/src/subagents.rs, line 226:

<comment>Adopting a root known by the durable log does not repair a live tree's pre-root phantom state. Apply each adopted root through `remember_root` so its stale `parents`, `background`, and `running` entries are removed before subsequent updates are classified.</comment>

<file context>
@@ -203,6 +203,29 @@ impl SubagentTree {
+                self.running.push((id, label));
+            }
+        }
+        self.root_threads.extend(log.root_threads);
+    }
+
</file context>
Suggested change
self.root_threads.extend(log.root_threads);
for root in log.root_threads {
self.remember_root(&root);
}

}

/// How many background subagents still run.
pub fn background_running(&self) -> usize {
self.background.len()
Expand Down Expand Up @@ -1307,126 +1330,5 @@ mod codex_tests {
}

#[cfg(test)]
mod background_tests {
use super::*;

fn annotate(tree: &mut SubagentTree, update: Value, live: bool) {
let mut params = json!({"sessionId": "s1", "update": update});
tree.annotate_live(&mut params, live);
}

/// manaflow-ai/cmux#25230: the background subagents a chat runs past its turn, from their
/// start to their end; a foreground one ends with its turn, and a replay changes nothing.
#[test]
fn background_subagents_run_from_their_start_to_their_end() {
let mut tree = SubagentTree::default();
tree.set_own(Some("s1"));
let spawn = |id: &str, background: bool| json!({"sessionUpdate": "subagent_spawned", "subagentSessionId": id, "background": background});
let end = |id: &str| json!({"sessionUpdate": "subagent_state_update", "subagentSessionId": id, "state": "completed"});
annotate(&mut tree, spawn("fg", false), true);
assert_eq!(tree.background_running(), 0);
annotate(&mut tree, spawn("replayed", true), false);
assert_eq!(tree.background_running(), 0, "a replay starts nothing");
annotate(&mut tree, spawn("bg", true), true);
// Moved to the background later (Claude's task_started is_backgrounded).
annotate(
&mut tree,
json!({"sessionUpdate": "subagent_progress", "subagentSessionId": "fg", "background": true}),
true,
);
assert_eq!(tree.background_running(), 2);
// Progress without the mark keeps it.
annotate(
&mut tree,
json!({"sessionUpdate": "subagent_progress", "subagentSessionId": "bg"}),
true,
);
assert_eq!(tree.background_running(), 2);
annotate(&mut tree, end("bg"), true);
annotate(&mut tree, end("fg"), true);
assert_eq!(tree.background_running(), 0);
annotate(&mut tree, spawn("bg2", true), true);
tree.end_background();
assert_eq!(tree.background_running(), 0, "its harness exited");
}

/// The hover card's live count (cmux NEXT sidebar, Leo 2026-11-01: "this hover preview being
/// able to see the # of associated subagents"): every running subagent, foreground or
/// background, nested ones too, with its name; a replay counts none, and without a running
/// turn only the background ones still run.
#[test]
fn running_subagents_are_counted_with_their_names_from_start_to_end() {
let mut tree = SubagentTree::default();
tree.set_own(Some("s1"));
let spawn = |id: &str, name: &str, background: bool| json!({"sessionUpdate": "subagent_spawned", "subagentSessionId": id, "name": name, "background": background});
let end = |id: &str| json!({"sessionUpdate": "subagent_state_update", "subagentSessionId": id, "state": "completed"});
annotate(&mut tree, spawn("replayed", "Old", false), false);
assert_eq!(tree.running_count(), 0, "a replay starts nothing");
annotate(&mut tree, spawn("a", "Review the sidebar", false), true);
annotate(&mut tree, spawn("b", "Fix the\nbuild", true), true);
annotate(&mut tree, spawn("c", &"x".repeat(80), false), true);
assert_eq!(tree.running_count(), 3);
let names: Vec<_> =
tree.running_now(true).into_iter().map(|(_, l)| l.unwrap().to_owned()).collect();
assert_eq!(names[0], "Review the sidebar");
assert_eq!(names[1], "Fix the build", "one line, single spaces");
assert_eq!(names[2].chars().count(), LABEL_CHARS, "a long name is cut");
assert_eq!(tree.running_now(false).len(), 1, "between turns only the background one runs");
annotate(&mut tree, end("a"), true);
assert_eq!(tree.running_count(), 2);
assert_eq!(tree.running_now(true)[0].0, "b", "oldest first");
tree.end_background();
assert_eq!(tree.running_count(), 0, "its harness exited");
}

/// A subagent named by its task (its prompt) never carries a credential out to the summary.
#[test]
fn a_running_subagents_task_label_has_its_secrets_masked() {
let mut tree = SubagentTree::default();
tree.set_own(Some("s1"));
let token: String = format!("gh{}_{}", "p", "aZ3kQ9mX7pL2vB8nR4tW6yC1dF5gH0jSaZ3k");
let task = format!("Push with GITHUB_TOKEN={token} and open the PR");
annotate(
&mut tree,
json!({"sessionUpdate": "subagent_spawned", "subagentSessionId": "a", "task": task}),
true,
);
let label = tree.running_now(true)[0].1.unwrap().to_owned();
assert!(!label.contains(&token), "the token survives in {label:?}");
assert!(label.starts_with("Push with"), "{label:?}");
}

/// Two turns in a row: the first turn's foreground subagents, whose ends the harness never
/// sent, are gone once it ends, and only its background one runs on into the next.
#[test]
fn a_turns_foreground_subagents_do_not_show_again_in_the_next_turn() {
let mut tree = SubagentTree::default();
tree.set_own(Some("s1"));
let spawn = |id: &str, background: bool| json!({"sessionUpdate": "subagent_spawned", "subagentSessionId": id, "name": id, "background": background});
annotate(&mut tree, spawn("fg1", false), true);
annotate(&mut tree, spawn("bg1", true), true);
assert_eq!(tree.running_now(true).len(), 2, "turn 1");
tree.end_foreground();
annotate(&mut tree, spawn("fg2", false), true);
let ids: Vec<_> = tree.running_now(true).into_iter().map(|(id, _)| id).collect();
assert_eq!(ids, vec!["bg1", "fg2"], "turn 2 shows its own and the background one");
}

/// A legacy activity echo of the native root that arrives before the root is known counts as
/// running background work; recognizing the root afterwards must stop counting it.
#[test]
fn a_late_root_seed_stops_counting_its_echo_as_background_work() {
let mut tree = SubagentTree::default();
tree.set_own(Some("s1"));
annotate(
&mut tree,
json!({"sessionUpdate": "tool_call_update", "toolCallId": "echo-root",
"_meta": {"codex": {"subagent": {"threadId": "root-native",
"path": "/root", "activity": "working"}}}}),
true,
);
assert_eq!(tree.background_running(), 1, "the echo was counted before the root was known");
tree.set_root("root-native");
assert_eq!(tree.background_running(), 0, "the native root is not background work");
}
}
#[path = "subagents_background_tests.rs"]
mod background_tests;
123 changes: 123 additions & 0 deletions cmux-tui/crates/acpmux/src/subagents_background_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
//! `SubagentTree` background and running subagents (`subagents.rs`).

use super::*;

fn annotate(tree: &mut SubagentTree, update: Value, live: bool) {
let mut params = json!({"sessionId": "s1", "update": update});
tree.annotate_live(&mut params, live);
}

/// manaflow-ai/cmux#25230: the background subagents a chat runs past its turn, from their
/// start to their end; a foreground one ends with its turn, and a replay changes nothing.
#[test]
fn background_subagents_run_from_their_start_to_their_end() {
let mut tree = SubagentTree::default();
tree.set_own(Some("s1"));
let spawn = |id: &str, background: bool| json!({"sessionUpdate": "subagent_spawned", "subagentSessionId": id, "background": background});
let end = |id: &str| json!({"sessionUpdate": "subagent_state_update", "subagentSessionId": id, "state": "completed"});
annotate(&mut tree, spawn("fg", false), true);
assert_eq!(tree.background_running(), 0);
annotate(&mut tree, spawn("replayed", true), false);
assert_eq!(tree.background_running(), 0, "a replay starts nothing");
annotate(&mut tree, spawn("bg", true), true);
// Moved to the background later (Claude's task_started is_backgrounded).
annotate(
&mut tree,
json!({"sessionUpdate": "subagent_progress", "subagentSessionId": "fg", "background": true}),
true,
);
assert_eq!(tree.background_running(), 2);
// Progress without the mark keeps it.
annotate(
&mut tree,
json!({"sessionUpdate": "subagent_progress", "subagentSessionId": "bg"}),
true,
);
assert_eq!(tree.background_running(), 2);
annotate(&mut tree, end("bg"), true);
annotate(&mut tree, end("fg"), true);
assert_eq!(tree.background_running(), 0);
annotate(&mut tree, spawn("bg2", true), true);
tree.end_background();
assert_eq!(tree.background_running(), 0, "its harness exited");
}

/// The hover card's live count (cmux NEXT sidebar, Leo 2026-11-01: "this hover preview being
/// able to see the # of associated subagents"): every running subagent, foreground or
/// background, nested ones too, with its name; a replay counts none, and without a running

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This test never exercises a nested subagent: annotate always uses sessionId: "s1", so every spawn is a root child. Add a child update under a and assert running_count and running_now include it; otherwise nested-count regressions can pass.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At cmux-tui/crates/acpmux/src/subagents_background_tests.rs, line 47:

<comment>This test never exercises a nested subagent: `annotate` always uses `sessionId: "s1"`, so every spawn is a root child. Add a child update under `a` and assert `running_count` and `running_now` include it; otherwise nested-count regressions can pass.</comment>

<file context>
@@ -0,0 +1,123 @@
+
+/// The hover card's live count (cmux NEXT sidebar, Leo 2026-11-01: "this hover preview being
+/// able to see the # of associated subagents"): every running subagent, foreground or
+/// background, nested ones too, with its name; a replay counts none, and without a running
+/// turn only the background ones still run.
+#[test]
</file context>

/// turn only the background ones still run.
#[test]
fn running_subagents_are_counted_with_their_names_from_start_to_end() {
let mut tree = SubagentTree::default();
tree.set_own(Some("s1"));
let spawn = |id: &str, name: &str, background: bool| json!({"sessionUpdate": "subagent_spawned", "subagentSessionId": id, "name": name, "background": background});
let end = |id: &str| json!({"sessionUpdate": "subagent_state_update", "subagentSessionId": id, "state": "completed"});
annotate(&mut tree, spawn("replayed", "Old", false), false);
assert_eq!(tree.running_count(), 0, "a replay starts nothing");
annotate(&mut tree, spawn("a", "Review the sidebar", false), true);
annotate(&mut tree, spawn("b", "Fix the\nbuild", true), true);
annotate(&mut tree, spawn("c", &"x".repeat(80), false), true);
assert_eq!(tree.running_count(), 3);
let names: Vec<_> =
tree.running_now(true).into_iter().map(|(_, l)| l.unwrap().to_owned()).collect();
assert_eq!(names[0], "Review the sidebar");
assert_eq!(names[1], "Fix the build", "one line, single spaces");
assert_eq!(names[2].chars().count(), LABEL_CHARS, "a long name is cut");
assert_eq!(tree.running_now(false).len(), 1, "between turns only the background one runs");
annotate(&mut tree, end("a"), true);
assert_eq!(tree.running_count(), 2);
assert_eq!(tree.running_now(true)[0].0, "b", "oldest first");
tree.end_background();
assert_eq!(tree.running_count(), 0, "its harness exited");
}

/// A subagent named by its task (its prompt) never carries a credential out to the summary.
#[test]
fn a_running_subagents_task_label_has_its_secrets_masked() {
let mut tree = SubagentTree::default();
tree.set_own(Some("s1"));
let token: String = format!("gh{}_{}", "p", "aZ3kQ9mX7pL2vB8nR4tW6yC1dF5gH0jSaZ3k");
let task = format!("Push with GITHUB_TOKEN={token} and open the PR");
annotate(
&mut tree,
json!({"sessionUpdate": "subagent_spawned", "subagentSessionId": "a", "task": task}),
true,
);
let label = tree.running_now(true)[0].1.unwrap().to_owned();
assert!(!label.contains(&token), "the token survives in {label:?}");
assert!(label.starts_with("Push with"), "{label:?}");
}

/// Two turns in a row: the first turn's foreground subagents, whose ends the harness never
/// sent, are gone once it ends, and only its background one runs on into the next.
#[test]
fn a_turns_foreground_subagents_do_not_show_again_in_the_next_turn() {
let mut tree = SubagentTree::default();
tree.set_own(Some("s1"));
let spawn = |id: &str, background: bool| json!({"sessionUpdate": "subagent_spawned", "subagentSessionId": id, "name": id, "background": background});
annotate(&mut tree, spawn("fg1", false), true);
annotate(&mut tree, spawn("bg1", true), true);
assert_eq!(tree.running_now(true).len(), 2, "turn 1");
tree.end_foreground();
annotate(&mut tree, spawn("fg2", false), true);
let ids: Vec<_> = tree.running_now(true).into_iter().map(|(id, _)| id).collect();
assert_eq!(ids, vec!["bg1", "fg2"], "turn 2 shows its own and the background one");
}

/// A legacy activity echo of the native root that arrives before the root is known counts as
/// running background work; recognizing the root afterwards must stop counting it.
#[test]
fn a_late_root_seed_stops_counting_its_echo_as_background_work() {
let mut tree = SubagentTree::default();
tree.set_own(Some("s1"));
annotate(
&mut tree,
json!({"sessionUpdate": "tool_call_update", "toolCallId": "echo-root",
"_meta": {"codex": {"subagent": {"threadId": "root-native",
"path": "/root", "activity": "working"}}}}),
true,
);
assert_eq!(tree.background_running(), 1, "the echo was counted before the root was known");
tree.set_root("root-native");
assert_eq!(tree.background_running(), 0, "the native root is not background work");
}
Loading
Loading