agentmux_srv/backend/subagent_watcher/query.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Query/naming API: `list_active`/`list_dispatches`/`solo_dispatch`/
5//! `get_history`/`get_info`/`set_display_name`/`set_dispatch_name`/
6//! `trigger_eager_naming` — the read-mostly surface RPC dispatch calls into,
7//! plus the eager Haiku-naming trigger fired from `jsonl::process_jsonl_change`.
8
9use super::types::*;
10use super::SubagentWatcher;
11
12impl SubagentWatcher {
13 /// List all subagents across all sessions (sync — safe to call from RPC dispatch).
14 pub fn list_active(&self) -> Vec<SubAgent> {
15 let sessions = self.sessions.lock().unwrap();
16 let mut result = Vec::new();
17 for session in sessions.values() {
18 for state in session.subagents.values() {
19 result.push(state.info.clone());
20 }
21 }
22 result.sort_by(|a, b| b.last_event_at.cmp(&a.last_event_at));
23 result
24 }
25
26 /// List all tracked dispatches — both kinds (sync — safe to call from
27 /// RPC dispatch). Workflow-kind dispatches come from tracked
28 /// `DispatchState`; Solo-kind dispatches have no persistent state of
29 /// their own and are synthesized 1:1 from every loose `SubAgent` (SPEC
30 /// §5/§8) — this is what gives every solo Task-tool call a real,
31 /// backend-issued container it never had before.
32 pub fn list_dispatches(&self) -> Vec<AgentDispatch> {
33 let mut result: Vec<AgentDispatch> = {
34 let mut dispatches = self.dispatches.lock().unwrap();
35 dispatches
36 .values_mut()
37 .map(|state| {
38 Self::refresh_dispatch_status(state);
39 state.info.clone()
40 })
41 .collect()
42 };
43 let sessions = self.sessions.lock().unwrap();
44 for session in sessions.values() {
45 for state in session.subagents.values() {
46 if let Some(solo) = Self::solo_dispatch(&state.info) {
47 result.push(solo);
48 }
49 }
50 }
51 result.sort_by(|a, b| b.last_event_at.cmp(&a.last_event_at));
52 result
53 }
54
55 /// Synthesize the `AgentDispatch` for a loose (non-workflow) `SubAgent`.
56 /// `None` for a workflow member — those are represented by their
57 /// tracked `DispatchState` instead, not synthesized per-call.
58 pub(super) fn solo_dispatch(sub: &SubAgent) -> Option<AgentDispatch> {
59 if !sub.dispatch_id.starts_with("solo:") {
60 return None;
61 }
62 // A solo dispatch's status mirrors its one member's directly — the
63 // member IS the dispatch, no separate aggregation to compute. Kept
64 // as three distinct arms (not `done as usize` collapsing Completed/
65 // Abandoned together) so a dead dispatch reads as Abandoned, not
66 // ambiguously "Completed," in the UI. See
67 // docs/specs/SPEC_SWARM_DISPATCH_ATTRIBUTION_AND_LIFECYCLE_2026_08_19.md §3.2.
68 let (status, done) = match sub.status {
69 SubAgentStatus::Completed => (DispatchStatus::Completed, true),
70 SubAgentStatus::Abandoned => (DispatchStatus::Abandoned, true),
71 SubAgentStatus::Active => (DispatchStatus::Running, false),
72 };
73 Some(AgentDispatch {
74 dispatch_id: sub.dispatch_id.clone(),
75 kind: DispatchKind::Solo,
76 parent_agent: sub.parent_agent.clone(),
77 parent_block_id: sub.parent_block_id.clone(),
78 session_id: sub.session_id.clone(),
79 member_count: 1,
80 members_done: done as usize,
81 status,
82 last_event_at: sub.last_event_at,
83 // A solo dispatch's name IS its one member's display_name — no
84 // separate dispatch_name storage needed for the Solo kind (only
85 // Workflow-kind DispatchState carries its own dispatch_name).
86 dispatch_name: sub.display_name.clone(),
87 })
88 }
89
90 /// Get recent events for a specific subagent (sync — safe to call from RPC dispatch).
91 pub fn get_history(&self, agent_id: &str, limit: usize) -> Vec<SubagentEvent> {
92 let sessions = self.sessions.lock().unwrap();
93 for session in sessions.values() {
94 if let Some(state) = session.subagents.get(agent_id) {
95 let events = &state.events;
96 let start = events.len().saturating_sub(limit);
97 return events[start..].to_vec();
98 }
99 }
100 Vec::new()
101 }
102
103 /// Get info for a single subagent (sync — safe to call from RPC dispatch).
104 /// Targeted counterpart of `list_active()` — a subagent pane reopened
105 /// after its `subagent:spawned` event already fired needs its info once,
106 /// at mount; scanning + cloning every active subagent across every
107 /// session for that is wasteful when the caller already knows the id.
108 pub fn get_info(&self, agent_id: &str) -> Option<SubAgent> {
109 let sessions = self.sessions.lock().unwrap();
110 for session in sessions.values() {
111 if let Some(state) = session.subagents.get(agent_id) {
112 return Some(state.info.clone());
113 }
114 }
115 None
116 }
117
118 /// Set a subagent's Haiku-generated display name and broadcast
119 /// `subagent:named` so every client watching this session picks up the
120 /// result, not just the one that triggered the naming call. Returns
121 /// `false` if the subagent isn't tracked (e.g. it aged out between the
122 /// RPC firing and resolving) — the caller should treat that as a no-op,
123 /// not an error.
124 ///
125 /// A name already set is kept: the parent's description can land in a late
126 /// sidecar while this call runs, and it is the better name. Nothing is
127 /// broadcast then.
128 pub fn set_display_name(&self, agent_id: &str, display_name: &str) -> bool {
129 // Captured alongside the mutation itself (not re-looked-up after
130 // unlocking) — this is the exact moment a NAME-based grouping key is
131 // born, so the fields that decide which group it lands in
132 // (dispatch_id, parent_block_id) need to be logged from the same
133 // locked read that set it, not a racy re-fetch.
134 let mut found_context: Option<(String, String, String)> = None;
135 let found = {
136 let mut sessions = self.sessions.lock().unwrap();
137 let mut found = false;
138 for session in sessions.values_mut() {
139 if let Some(state) = session.subagents.get_mut(agent_id) {
140 found = true;
141 if state.info.display_name.is_some() {
142 break;
143 }
144 state.info.display_name = Some(display_name.to_string());
145 found_context = Some((
146 state.info.parent_block_id.clone(),
147 state.info.session_id.clone(),
148 state.info.dispatch_id.clone(),
149 ));
150 break;
151 }
152 }
153 found
154 };
155 // Mutex released here — broadcast outside the lock
156
157 if let Some((parent_block_id, session_id, dispatch_id)) = &found_context {
158 tracing::info!(
159 agent_id = %agent_id,
160 display_name = %display_name,
161 parent_block_id = %parent_block_id,
162 session_id = %session_id,
163 dispatch_id = %dispatch_id,
164 "subagent display_name resolved"
165 );
166 }
167
168 if found_context.is_some() {
169 self.broadcast_subagent_named(agent_id, display_name);
170 }
171 found
172 }
173
174 /// Set a Workflow-kind dispatch's Haiku-generated name and broadcast
175 /// `dispatch:updated` so every client watching this session picks up the
176 /// result. Mirrors `set_display_name` one level up — see that method's
177 /// doc comment for the "found=false is a safe no-op" convention. Only
178 /// Workflow-kind dispatches store a `dispatch_name` here; a Solo
179 /// dispatch's name IS its one member's `display_name` (`solo_dispatch`
180 /// reads it straight through), so `set_display_name` already covers the
181 /// Solo case — this method is never called for a `solo:` dispatch_id.
182 pub fn set_dispatch_name(&self, dispatch_id: &str, name: &str) -> bool {
183 let info = {
184 let mut dispatches = self.dispatches.lock().unwrap();
185 match dispatches.get_mut(dispatch_id) {
186 Some(state) => {
187 state.info.dispatch_name = Some(name.to_string());
188 Some(state.info.clone())
189 }
190 None => None,
191 }
192 };
193 // Mutex released here — broadcast outside the lock
194
195 let Some(info) = info else { return false };
196 tracing::info!(
197 dispatch_id = %dispatch_id,
198 dispatch_name = %name,
199 parent_block_id = %info.parent_block_id,
200 session_id = %info.session_id,
201 "dispatch display_name resolved"
202 );
203 self.broadcast_dispatch_updated(&info);
204 true
205 }
206
207 /// Test-only accessor for `naming_triggered` — lets tests assert the
208 /// dedup gate claimed (or didn't claim) a given dispatch_id without
209 /// needing a real `Arc<Self>`/spawned task (see `trigger_eager_naming`'s
210 /// doc comment below for why `naming_triggered_contains` alone, not the
211 /// full eager-naming flow, is what unit tests can exercise).
212 #[cfg(test)]
213 pub(crate) fn naming_triggered_contains(&self, dispatch_id: &str) -> bool {
214 self.naming_triggered.lock().unwrap().contains(dispatch_id)
215 }
216
217 /// How long a solo subagent's eager name waits for a late `.meta.json`, whose
218 /// description makes the model call unnecessary.
219 pub(super) const SIDECAR_GRACE: std::time::Duration = std::time::Duration::from_secs(3);
220
221 /// Fire the one eager Haiku-naming call for a dispatch's first-observed
222 /// live spawn (issue: SPEC_SWARM_DISPATCH_NAMING_AND_ROW_MODEL_2026_07_19).
223 /// Caller (`process_jsonl_change`) has already atomically claimed
224 /// `dispatch_id` in `naming_triggered` before calling this — this method
225 /// itself does no further dedup.
226 ///
227 /// Upgrades `self_ref` to a real `Arc<Self>` to move into the spawned
228 /// task; silently no-ops if this watcher was built via bare `new()`
229 /// (most unit tests) rather than `spawn()` — there is no Arc to upgrade
230 /// to in that case, matching this module's "untracked -> safe no-op"
231 /// convention rather than panicking.
232 pub(super) fn trigger_eager_naming(&self, dispatch_id: String, first_member_agent_id: String, is_workflow: bool) {
233 let Some(watcher) = self.self_ref.lock().unwrap().as_ref().and_then(|w| w.upgrade()) else {
234 return;
235 };
236 tokio::spawn(async move {
237 if is_workflow {
238 crate::ambient::tasks::generate_dispatch_name(
239 &watcher.mstore,
240 &watcher,
241 &dispatch_id,
242 &first_member_agent_id,
243 crate::ambient::limits::Class::Interactive,
244 ).await;
245 } else {
246 // The transcript is often seen before its sidecar, whose
247 // description names the subagent for free; give it time to land.
248 tokio::time::sleep(Self::SIDECAR_GRACE).await;
249 crate::ambient::tasks::generate_subagent_name(
250 &watcher.mstore,
251 &watcher,
252 &first_member_agent_id,
253 crate::ambient::limits::Class::Interactive,
254 ).await;
255 }
256 });
257 }
258
259 /// Select up to `limit` currently-unnamed subagents/dispatches for the
260 /// bounded backfill-naming pass (`resolve_unnamed_backlog`), most
261 /// recently-active first, and atomically claim each selected item in
262 /// `naming_triggered` before returning it — reusing the exact dedup
263 /// structure the live eager-naming path already uses
264 /// (`jsonl::process_jsonl_change`), so this can never double-name
265 /// something, race with a live spawn of the same dispatch, or need a
266 /// second `HashSet`. Because the claim is permanent, calling this again
267 /// (a second Swarm-pane-open before an earlier batch finishes, or after
268 /// it drains) never re-selects anything already claimed — this is what
269 /// makes `resolve_unnamed_backlog` safe to fire on every pane open with
270 /// no extra debounce.
271 ///
272 /// Deliberately never holds `sessions`/`dispatches` and `naming_triggered`
273 /// locked at the same time — mirrors `process_jsonl_change`'s own
274 /// convention (see its "Mutex released here" comment).
275 pub(super) fn select_unnamed_backlog(&self, limit: usize) -> Vec<BacklogNamingItem> {
276 let mut candidates: Vec<(u64, BacklogNamingItem)> = {
277 let sessions = self.sessions.lock().unwrap();
278 sessions
279 .values()
280 .flat_map(|s| s.subagents.values())
281 .filter(|state| {
282 state.info.display_name.is_none() && state.info.dispatch_id.starts_with("solo:")
283 })
284 .map(|state| {
285 (
286 state.info.last_event_at,
287 BacklogNamingItem::Solo { agent_id: state.info.agent_id.clone() },
288 )
289 })
290 .collect()
291 };
292
293 let unnamed_workflows: Vec<(String, u64)> = {
294 let dispatches = self.dispatches.lock().unwrap();
295 dispatches
296 .values()
297 .map(|state| &state.info)
298 .filter(|info| info.kind == DispatchKind::Workflow && info.dispatch_name.is_none())
299 .map(|info| (info.dispatch_id.clone(), info.last_event_at))
300 .collect()
301 };
302
303 if !unnamed_workflows.is_empty() {
304 let sessions = self.sessions.lock().unwrap();
305 for (dispatch_id, last_event_at) in unnamed_workflows {
306 let representative = sessions
307 .values()
308 .flat_map(|s| s.subagents.values())
309 .find(|state| state.info.dispatch_id == dispatch_id)
310 .map(|state| state.info.agent_id.clone());
311 if let Some(representative_agent_id) = representative {
312 candidates.push((
313 last_event_at,
314 BacklogNamingItem::Workflow { dispatch_id, representative_agent_id },
315 ));
316 }
317 }
318 }
319
320 candidates.sort_by(|a, b| b.0.cmp(&a.0));
321
322 let mut naming_triggered = self.naming_triggered.lock().unwrap();
323 let mut selected = Vec::with_capacity(limit.min(candidates.len()));
324 for (_, item) in candidates {
325 if selected.len() >= limit {
326 break;
327 }
328 if naming_triggered.insert(item.dispatch_id()) {
329 selected.push(item);
330 }
331 }
332 selected
333 }
334
335 /// Bounded, rate-limited burst that resolves names for whatever's
336 /// currently unnamed in the backfilled/historical backlog
337 /// (`select_unnamed_backlog`) — fired only via the
338 /// `("subagent", "ResolveUnnamedBacklog")` RPC, itself only ever called
339 /// from `SwarmViewModel`'s constructor (i.e. a human actually opening
340 /// the Swarm pane), never from the headless per-agent-pane backfill
341 /// scan — that path stays exactly as `live`-gated as before (see
342 /// `process_jsonl_change`'s doc comment and
343 /// docs/retro/retro-subagent-backfill-storm-oom-2026-07-17.md).
344 ///
345 /// Deliberately separate from `trigger_eager_naming`'s live path: runs as
346 /// background work (`limits::Class::Background`), so it never queues ahead
347 /// of a name or title the user is waiting on.
348 pub(crate) async fn resolve_unnamed_backlog(self: std::sync::Arc<Self>) {
349 let items = self.select_unnamed_backlog(BACKLOG_NAMING_BATCH_LIMIT);
350 for item in items {
351 let watcher = std::sync::Arc::clone(&self);
352 tokio::spawn(async move {
353 match item {
354 BacklogNamingItem::Solo { agent_id } => {
355 crate::ambient::tasks::generate_subagent_name(
356 &watcher.mstore,
357 &watcher,
358 &agent_id,
359 crate::ambient::limits::Class::Background,
360 )
361 .await;
362 }
363 BacklogNamingItem::Workflow { dispatch_id, representative_agent_id } => {
364 crate::ambient::tasks::generate_dispatch_name(
365 &watcher.mstore,
366 &watcher,
367 &dispatch_id,
368 &representative_agent_id,
369 crate::ambient::limits::Class::Background,
370 )
371 .await;
372 }
373 }
374 });
375 }
376 }
377}