Repository navigation
acpmux: an adopted host's running subagents keep their tags #30614
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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| { | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||||||
| if head.dir == "mux" { | ||||||
| match head.kind { | ||||||
| "host_started" | "stopped" | "exited" => tree.end_background(), | ||||||
| "turn_started" => tree.end_foreground(), | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The seed leaves foreground subagents running after a logged Prompt for AI agents
Suggested change
|
||||||
| _ => {} | ||||||
| } | ||||||
| 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 { | ||||||
|
|
@@ -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; | ||||||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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); | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Prompt for AI agents
Suggested change
|
||||||||||
| } | ||||||||||
|
|
||||||||||
| /// How many background subagents still run. | ||||||||||
| pub fn background_running(&self) -> usize { | ||||||||||
| self.background.len() | ||||||||||
|
|
@@ -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; | ||||||||||
| 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: This test never exercises a nested subagent: Prompt for AI agents |
||
| /// 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"); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2:
seed_subagents_from_logruns 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 theAttached::Readypath immediately before replay delivery.Prompt for AI agents