Skip to main content

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}