agentmux_srv/backend/subagent_watcher/mod.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Subagent watcher: monitors Claude Code session directories for subagent
5//! JSONL files and broadcasts activity events to WebSocket clients.
6//!
7//! Claude Code spawns "subagents" via the Task tool. Each subagent writes its
8//! conversation to a JSONL file under:
9//! `<claude-config>/projects/<encoded-workspace>/subagents/agent-<id>.jsonl`
10//!
11//! A Workflow tool run nests its member agents one level deeper, under a
12//! per-run directory, and adds a run-level journal:
13//! `<claude-config>/projects/<encoded-workspace>/subagents/workflows/<run-id>/agent-<id>.jsonl`
14//! `<claude-config>/projects/<encoded-workspace>/subagents/workflows/<run-id>/journal.jsonl`
15//!
16//! This module watches those directories and emits:
17//! - `subagent:spawned` — new subagent JSONL file detected
18//! - `subagent:activity` — new events for a Solo dispatch's one member,
19//! broadcast immediately (existing per-member consumers, e.g. the
20//! agent-pane activity dock, still rely on this)
21//! - `subagent:completed` — subagent finished (result event seen)
22//! - `subagent:named` — a `SubAgent.display_name` resolved (on-demand
23//! click-triggered, or eager at dispatch time — see `trigger_eager_naming`)
24//! - `dispatch:updated` — an `AgentDispatch`'s member count, status, or
25//! `dispatch_name` changed (both Solo and Workflow kinds)
26//! - `dispatch:activity` — coalesced new events across a dispatch's
27//! members, flushed every `DISPATCH_ACTIVITY_FLUSH_INTERVAL`. Workflow
28//! dispatches are ONLY ever represented here (no per-member broadcast).
29//! Solo dispatches are ALSO queued here in addition to the immediate
30//! `subagent:activity` above (dual emission, not a replacement) — see
31//! docs/specs/SPEC_SWARM_DISPATCH_NAMING_AND_ROW_MODEL_2026_07_19.md
32//! Phase A, added so a solo dispatch's row can use the same
33//! concatenated-feed expand mechanism a Workflow row already had.
34//! - `subagent:block_pruned` — a deleted block's subagents/dispatches were
35//! just pruned from this watcher's state (`prune_block`/`unwatch_agent`,
36//! driven by `spawn_block_prune_subscriber` reacting to
37//! `Event::BlockDeleted`/`TabDeleted`/`WorkspaceDeleted`). Without this,
38//! `ListActive`/`ListDispatches` kept returning a closed block's
39//! subagents forever, so the Swarm pane kept rendering a ghost row for
40//! it until the whole app/srv restarted. `prune_block` also tears down
41//! the block's own filesystem watcher (`unwatch_block`) — without that,
42//! a closed block's watcher leaked indefinitely and kept re-creating
43//! fresh (mis-)attributions the next time another agent sharing its
44//! watched directory wrote to its own subagent transcript; see
45//! `session_belongs_to_block` and
46//! docs/retro/retro-subagent-watcher-shared-dir-fanout-and-leak-2026-07-23.md.
47//!
48//! Split into submodules (pure relocation, no behavior change): `types` (the
49//! state/event types), `query` (the read/naming API), `scan` (session/dir
50//! backfill scanning + stale-subagent reconciliation), `jsonl` (the JSONL/
51//! journal file-change state machine and dispatch-activity coalescing), and
52//! `parse` (free-standing path/JSONL parsing utilities). This file keeps the
53//! `SubagentWatcher` struct definition and its lifecycle methods (`new`/
54//! `spawn`/`watch_agent`/`unwatch_agent`/`prune_block*`).
55
56mod completion;
57pub mod events;
58mod jsonl;
59mod parse;
60mod query;
61mod scan;
62mod types;
63
64#[cfg(test)]
65mod tests;
66
67#[allow(unused_imports)]
68pub use parse::{
69 derive_claude_config_dir, resolve_claude_config_dir,
70 spawn_block_prune_subscriber,
71};
72pub(crate) use parse::read_task_prompt;
73#[allow(unused_imports)]
74pub use types::{
75 AgentDispatch, DispatchKind, DispatchStatus, SubAgent, SubAgentStatus, SubagentEvent,
76 SubagentEventType,
77};
78
79use std::collections::HashMap;
80use std::path::{Path, PathBuf};
81use std::sync::{Arc, Mutex, OnceLock};
82use std::time::Duration;
83
84use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
85use tokio::sync::mpsc;
86
87use super::eventbus::EventBus;
88use parse::{derive_session_id, nearest_existing_ancestor};
89use types::{DispatchState, PendingDispatchActivity, SessionWatch, WatchedAgent};
90
91/// Flush cadence for buffered dispatch activity (SPEC §9.5 — 250ms-1s was
92/// floated as a plausible range, not measured; 500ms is the midpoint,
93/// picked as a reasonable default pending real usage data).
94const DISPATCH_ACTIVITY_FLUSH_INTERVAL: Duration = Duration::from_millis(500);
95
96// Host-wide instance, set once at startup (`set_global`, called from
97// bootstrap/background.rs right after `SubagentWatcher::spawn`). Exposed as a global —
98// mirroring `process_tracker::registry`'s own doc comment for the exact
99// same problem — so callers that only occasionally need it (like
100// `blockcontroller/persistent/stdout_reader.rs`'s turn-end reconciliation hook,
101// SPEC_SUBAGENT_LIVE_RECONCILIATION_AND_RETIRE_2026_07_20 Phase A) can reach it
102// without threading an `Arc` through `PersistentSubprocessController::new`
103// and every one of ITS callers up to `resync_controller`. Tests that don't
104// call `set_global` see `None` from `global()` and skip reconciliation —
105// a safe no-op, not a panic.
106static GLOBAL: OnceLock<Arc<SubagentWatcher>> = OnceLock::new();
107
108pub fn set_global(watcher: Arc<SubagentWatcher>) {
109 let _ = GLOBAL.set(watcher);
110}
111
112pub fn global() -> Option<Arc<SubagentWatcher>> {
113 GLOBAL.get().cloned()
114}
115
116pub struct SubagentWatcher {
117 event_bus: Arc<EventBus>,
118 mstore: Arc<crate::backend::storage::store::Store>,
119 /// Held so `recheck_config_dir`/`recheck_all_watched_agents` (called
120 /// from the identity-bind RPC handlers, `server/app_api/identity.rs`
121 /// and `server/agent_handlers/identity.rs`) can re-resolve a fresh
122 /// identity binding without every caller needing to thread these
123 /// stores through. NOT used to self-verify inside `watch_agent`/
124 /// `start_watch` itself — an earlier version of this fix tried that
125 /// and broke callers whose `config_dir` isn't backed by a resolvable
126 /// instance/binding row at all (the legacy `parent_block_id: ""` manual
127 /// RPC entry point, and several tests); see `watch_agent`'s own doc
128 /// comment for why that self-check lives in `handle_reactive_register`
129 /// instead, scoped to the one call site that actually derives
130 /// `config_dir` from identity/binding resolution in the first place.
131 id_store: Arc<crate::backend::storage::store::Store>,
132 identity_store: Arc<crate::backend::storage::store::Store>,
133 sessions: Mutex<HashMap<String, SessionWatch>>,
134 watched_agents: Mutex<Vec<WatchedAgent>>,
135 dispatches: Mutex<HashMap<String, DispatchState>>,
136 pending_activity: Mutex<HashMap<String, PendingDispatchActivity>>,
137 /// Dispatch IDs that have already had their one eager Haiku-naming call
138 /// triggered (issue: SPEC_SWARM_DISPATCH_NAMING_AND_ROW_MODEL_2026_07_19)
139 /// — checked-and-inserted atomically at the same point `is_new` is
140 /// computed in `process_jsonl_change`, so a Workflow dispatch (whose
141 /// `dispatch_id` is shared by every member) only ever triggers once,
142 /// on its first-observed-live member, not once per member. Never
143 /// cleared — naming is "at most once ever per dispatch," not a
144 /// retryable in-flight guard (unlike `activity_watcher.rs`'s
145 /// `in_flight` set, which this deliberately does NOT mirror: there's no
146 /// "try again later" concept for a dispatch's name).
147 naming_triggered: Mutex<std::collections::HashSet<String>>,
148 /// Weak self-reference, populated once by `spawn()` right after the
149 /// `Arc::new` that wraps this watcher — lets any `&self` method upgrade
150 /// to `Arc<Self>` to move into a `tokio::spawn`ed background task (the
151 /// eager-naming trigger) without threading `Arc<Self>` through every
152 /// caller of `process_jsonl_change` (`scan_subagents_dir`/
153 /// `scan_session_subagents`, both `&self` today). `None` for a
154 /// `SubagentWatcher` built via bare `new()` (as most tests do, to skip
155 /// the background flush loop) — eager naming silently no-ops in that
156 /// case rather than panicking, matching this module's existing
157 /// "unknown/untracked -> safe no-op" convention (see `set_display_name`).
158 self_ref: Mutex<Option<std::sync::Weak<SubagentWatcher>>>,
159 /// The scoped, persisted MPS broker -- used only for
160 /// `subagent:backfill_status` (`scan.rs`'s `publish_backfill_status`),
161 /// so a pane can query "is my own backfill still in progress" via
162 /// `EventReadHistoryCommand` rather than relying solely on live-event
163 /// timing (mirrors the identical `agent-resume-retry` design,
164 /// `docs/status/STATUS_STALE_RESUME_LIVE_REPRO_AND_FIX_PLAN_2026_08_23.md`
165 /// section 6.2). Deliberately separate from `event_bus` above (the raw,
166 /// unscoped, unpersisted WS fan-out every OTHER event in this module
167 /// uses) -- this one event specifically needs the scope+persist
168 /// semantics only `mps::Broker` provides. `None` until `set_broker` is
169 /// called (bootstrap only, after both this watcher and the broker
170 /// exist) -- every test call site built via bare `new()` skips this
171 /// event entirely rather than panicking, same posture as `self_ref`.
172 broker: Mutex<Option<Arc<crate::backend::mps::Broker>>>,
173 /// reagentx P2 (PR #2781): `scan_session_subagents` can be called twice
174 /// for the SAME `parent_block_id` in overlapping fashion (the same
175 /// block re-registered under a new `agent_id` -- see
176 /// `server/reactive.rs`'s caller comment -- while an earlier call for
177 /// that same block id is still mid-scan). Without this, an OLDER call's
178 /// "done" can publish after a NEWER call's "started" already fired,
179 /// prematurely clearing the `subagent:backfill_status` gate while the
180 /// newer scan is still running. Keyed by `parent_block_id`, incremented
181 /// at the start of every call; a call only publishes "done" if its own
182 /// captured generation is STILL the latest recorded one for that block
183 /// id when it finishes -- a stale call silently skips "done" entirely,
184 /// leaving the (correctly still-in-progress) gate to whichever call is
185 /// actually current. See `scan.rs`'s `scan_session_subagents`.
186 backfill_generation: Mutex<HashMap<String, u64>>,
187}
188
189impl SubagentWatcher {
190 pub fn new(
191 event_bus: Arc<EventBus>,
192 mstore: Arc<crate::backend::storage::store::Store>,
193 id_store: Arc<crate::backend::storage::store::Store>,
194 identity_store: Arc<crate::backend::storage::store::Store>,
195 ) -> Self {
196 Self {
197 event_bus,
198 mstore,
199 id_store,
200 identity_store,
201 sessions: Mutex::new(HashMap::new()),
202 watched_agents: Mutex::new(Vec::new()),
203 dispatches: Mutex::new(HashMap::new()),
204 pending_activity: Mutex::new(HashMap::new()),
205 naming_triggered: Mutex::new(std::collections::HashSet::new()),
206 self_ref: Mutex::new(None),
207 broker: Mutex::new(None),
208 backfill_generation: Mutex::new(HashMap::new()),
209 }
210 }
211
212 /// Wire in the shared MPS broker post-construction (bootstrap only) --
213 /// see the `broker` field's own doc comment for why this is optional
214 /// and set separately rather than a constructor parameter.
215 pub fn set_broker(&self, broker: Arc<crate::backend::mps::Broker>) {
216 *self.broker.lock().unwrap() = Some(broker);
217 }
218
219 /// Create a new SubagentWatcher and return it wrapped in Arc. Also
220 /// starts the background flush loop for coalesced dispatch activity
221 /// (SPEC §7) — runs for the lifetime of the process, mirroring
222 /// `watch_agent`'s existing `tokio::spawn` pattern.
223 pub fn spawn(
224 event_bus: Arc<EventBus>,
225 mstore: Arc<crate::backend::storage::store::Store>,
226 id_store: Arc<crate::backend::storage::store::Store>,
227 identity_store: Arc<crate::backend::storage::store::Store>,
228 ) -> Arc<Self> {
229 let watcher = Arc::new(Self::new(event_bus, mstore, id_store, identity_store));
230 *watcher.self_ref.lock().unwrap() = Some(Arc::downgrade(&watcher));
231 tracing::info!("subagent watcher initialized");
232 let flusher = Arc::clone(&watcher);
233 tokio::spawn(async move {
234 let mut interval = tokio::time::interval(DISPATCH_ACTIVITY_FLUSH_INTERVAL);
235 loop {
236 interval.tick().await;
237 flusher.flush_pending_dispatch_activity();
238 }
239 });
240 watcher
241 }
242
243 /// Start watching a Claude Code agent's session directory for subagent files.
244 /// Spawns a background tokio task for debounced file event processing.
245 ///
246 /// `parent_block_id` is the pane/block that owns this Claude instance (from
247 /// the reactive register request). It is stamped onto every emitted subagent
248 /// event so the frontend can route the ⚡ panel to the originating pane only,
249 /// instead of every agent pane rendering every subagent globally.
250 ///
251 /// Note: the watcher dedupes by `agent_id`, so if the same agent_id is
252 /// registered from two blocks, events carry the first registrant's block id.
253 /// That edge case (same instance name in two panes) is rare; the common
254 /// leak — a terminal Claude's subagents showing up in unrelated agent panes —
255 /// is fully fixed because the terminal block id never matches an agent pane.
256 pub fn watch_agent(self: &Arc<Self>, agent_id: &str, parent_block_id: &str, config_dir: PathBuf) {
257 // Check if already watching this agent. A second block registering
258 // the same agent_id adds itself as a dependent of the existing
259 // shared watcher (see WatchedAgent::parent_block_ids) instead of
260 // being silently dropped — otherwise `unwatch_block` (below) had no
261 // way to tell that a still-open second block also depended on this
262 // watcher when the first one closed.
263 {
264 let mut watched = self.watched_agents.lock().unwrap();
265 if let Some(existing) = watched.iter_mut().find(|w| w.agent_id == agent_id) {
266 existing.parent_block_ids.insert(parent_block_id.to_string());
267 tracing::debug!(agent = %agent_id, parent_block_id = %parent_block_id, "already watching this agent");
268 return;
269 }
270 }
271 // NOTE: does NOT self-verify its own resolution against a fresh
272 // identity lookup after inserting — an earlier version of this fix
273 // did exactly that (Codex P1 on PR #2980: closing the narrow race
274 // between `handle_reactive_register` resolving `config_dir` and
275 // this call actually installing the watch), but `watch_agent` is a
276 // generic primitive with OTHER callers whose `config_dir` isn't
277 // necessarily backed by a resolvable instance/binding row at all —
278 // the legacy manual `subagent.WatchAgent` RPC entry point
279 // (`server/service/misc.rs`) passes `parent_block_id: ""`
280 // specifically for callers with no pane to scope to, and a blind
281 // re-resolve there (or for any test/manual caller whose block has
282 // no real `AgentInstance`) fell through `resolve_claude_config_dir`'s
283 // last-resort `derive_claude_config_dir` fallback and silently
284 // repointed the watch away from the caller's own explicit choice
285 // (confirmed live: broke `live_fs_event_with_empty_block_id_
286 // bypasses_the_ownership_check` and `live_fs_event_is_not_
287 // misattributed_to_a_block_that_does_not_own_the_session`). The
288 // self-check that DOES close this race lives in
289 // `handle_reactive_register` itself instead, immediately after ITS
290 // `watch_agent` call — the one call site that actually derived
291 // `config_dir` from identity/binding resolution in the first place,
292 // so a fresh re-resolve there can only ever mean "the DB state
293 // changed since I last read it," never "this caller never intended
294 // identity resolution to apply here at all."
295 self.start_watch(agent_id, parent_block_id, config_dir);
296 }
297
298 /// Re-resolve and, if the answer changed, re-point an already-watched
299 /// agent's filesystem watch at a corrected directory. `watch_agent`'s
300 /// own config-dir resolution only ever runs ONCE per agent, at the
301 /// reactive-register handshake — before this agent's identity binding
302 /// is necessarily committed to the DB yet (a genuinely observed race:
303 /// a fresh agent launch's reactive-register can fire before the launch
304 /// flow's own account-bind write lands, landing on a stale/ambient
305 /// `cmd:env` snapshot instead of the real identity-bound directory).
306 /// If that race is lost, the watcher previously had no correction
307 /// mechanism at all — nothing else ever calls `watch_agent` again for
308 /// an already-registered pane, so it silently watched the wrong
309 /// directory, forever, for the rest of that pane's life. Called from
310 /// `watch_agent` itself (a post-insert self-check) and from the
311 /// identity-bind RPC handlers (`server/app_api/identity.rs`,
312 /// `server/agent_handlers/identity.rs`) via `recheck_all_watched_agents`
313 /// below, right after a successful `agent_identity_link` write.
314 ///
315 /// No-op if `agent_id` isn't currently watched (nothing to correct —
316 /// its next real registration resolves fresh, by-then-committed
317 /// bindings correctly on its own) or if the newly-resolved directory
318 /// is unchanged. See
319 /// docs/reports/REPORT_SWARM_SUBAGENT_WATCHER_ONE_SHOT_RESOLUTION_RACE_2026_09_04.md.
320 pub fn recheck_config_dir(self: &Arc<Self>, agent_id: &str, new_config_dir: PathBuf) {
321 let (primary_block_id, all_block_ids, old_dir) = {
322 let watched = self.watched_agents.lock().unwrap();
323 let Some(existing) = watched.iter().find(|w| w.agent_id == agent_id) else {
324 return;
325 };
326 if existing.config_dir == new_config_dir {
327 return;
328 }
329 (existing.primary_block_id.clone(), existing.parent_block_ids.clone(), existing.config_dir.clone())
330 };
331 // Codex P2 on PR #2980: build the replacement BEFORE touching the
332 // existing (stale-but-working) entry. If the new directory's
333 // watcher fails to construct or `.watch()` fails (a transient
334 // resource/permission/filesystem error), keep the old watch in
335 // place rather than losing tracking for this agent entirely —
336 // `build_watch` itself already logs the specific failure reason.
337 let Some((watcher, rx)) = self.build_watch(agent_id, &new_config_dir) else {
338 tracing::warn!(
339 agent = %agent_id,
340 old_dir = %old_dir.display(),
341 new_dir = %new_config_dir.display(),
342 "identity rebind resolved a new subagent config dir, but the replacement watch failed to start — keeping the existing watch"
343 );
344 return;
345 };
346 tracing::info!(
347 agent = %agent_id,
348 old_dir = %old_dir.display(),
349 new_dir = %new_config_dir.display(),
350 "identity rebind changed this agent's Claude config dir — re-pointing subagent watch"
351 );
352 // Atomic swap: remove the old entry (dropping its `_watcher`,
353 // which stops the stale `notify` subscription — the same RAII
354 // teardown `unwatch_agent`/`unwatch_block` rely on) and insert the
355 // replacement in one critical section, preserving the ORIGINAL
356 // `primary_block_id` and the full `parent_block_ids` set — Codex
357 // P3 on PR #2980: never re-derive the primary from the (unordered)
358 // set, which could silently attribute subsequent events to a
359 // different pane than before.
360 {
361 let mut watched = self.watched_agents.lock().unwrap();
362 watched.retain(|w| w.agent_id != agent_id);
363 watched.push(WatchedAgent {
364 agent_id: agent_id.to_string(),
365 primary_block_id: primary_block_id.clone(),
366 parent_block_ids: all_block_ids.clone(),
367 config_dir: new_config_dir.clone(),
368 _watcher: watcher,
369 });
370 }
371 self.spawn_consumer_loop(agent_id, &primary_block_id, rx);
372 // Backfill anything genuinely missed while the watch was pointed at
373 // the wrong directory — same session-scoped mechanism, and the same
374 // reason for scoping it, as `handle_reactive_register`'s own
375 // backfill call: a blind scan-everything would flood Swarm with
376 // every session this identity has ever run, not just this pane's.
377 for block_id in &all_block_ids {
378 let Ok(Some(block)) = self.mstore.get::<crate::backend::obj::Block>(block_id) else {
379 continue;
380 };
381 let session_id = crate::backend::obj::meta_get_string(
382 &block.meta,
383 crate::backend::blockcontroller::core::META_SESSION_ID,
384 "",
385 );
386 if !session_id.is_empty() {
387 self.scan_session_subagents(agent_id, block_id, &new_config_dir, &session_id);
388 }
389 }
390 }
391
392 /// Re-check every currently-watched agent's Claude config dir against a
393 /// fresh identity resolution, re-pointing (`recheck_config_dir`) any
394 /// whose resolution changed. Cheap and safe to call from anywhere an
395 /// identity/account binding just changed — the number of currently-
396 /// watched agents on a single instance is small, and this only runs on
397 /// the rare bind/rebind path, never per-turn.
398 pub fn recheck_all_watched_agents(self: &Arc<Self>) {
399 let candidates: Vec<(String, String)> = {
400 let watched = self.watched_agents.lock().unwrap();
401 watched.iter().map(|w| (w.agent_id.clone(), w.primary_block_id.clone())).collect()
402 };
403 for (agent_id, block_id) in candidates {
404 // ReAgent P1 on PR #2980, round 2: this is called on EVERY
405 // `agent_identity_link` write anywhere in the app, with no
406 // scoping to the agent whose identity actually changed, and it
407 // used to iterate every `WatchedAgent` regardless of whether
408 // that entry's `config_dir` was ever derived from identity
409 // resolution in the first place. An agent registered via the
410 // still-live legacy `subagent.WatchAgent` RPC
411 // (`server/service/misc.rs`) passes `parent_block_id: ""` and
412 // an arbitrary caller-supplied `config_dir` that has nothing to
413 // do with identity binding — exactly the shape `watch_agent`'s
414 // own doc comment already documents as needing to stay outside
415 // identity resolution's reach. Without this guard, a resolve
416 // against a nonexistent block falls through
417 // `resolve_claude_config_dir`'s last-resort
418 // `derive_claude_config_dir` guess and silently repoints that
419 // agent away from its caller's explicit choice — the SAME
420 // failure class the P1 fix's first (reverted) attempt already
421 // hit once inside `watch_agent` itself, reappearing here one
422 // layer up. Skip any candidate with no real, resolvable block:
423 // an empty id can never correspond to one, and a nonexistent
424 // block means there is nothing for identity resolution to
425 // legitimately apply to.
426 if block_id.is_empty() {
427 continue;
428 }
429 let Some(block) = self.mstore.get::<crate::backend::obj::Block>(&block_id).ok().flatten() else {
430 continue;
431 };
432 let bound_dir = crate::identity::resolver::resolve_bound_oauth_config_dir(
433 &self.mstore,
434 &self.id_store,
435 &self.identity_store,
436 &block_id,
437 );
438 let new_dir = resolve_claude_config_dir(&block.meta, &agent_id, bound_dir);
439 if let Some(new_dir) = new_dir {
440 self.recheck_config_dir(&agent_id, new_dir);
441 }
442 }
443 }
444
445 /// Build (but do not install) a `notify` watcher for `config_dir`,
446 /// falling back to the nearest existing ancestor directory if it
447 /// doesn't exist on disk yet. Returns `None` on any failure — every
448 /// branch already logs its own specific reason. Deliberately has NO
449 /// side effect on `watched_agents`: callers (`start_watch` for a
450 /// first-time registration, `recheck_config_dir` for a repoint) decide
451 /// separately whether/how to install the result, which is what lets
452 /// `recheck_config_dir` build the replacement before touching the
453 /// existing entry (Codex P2 on PR #2980).
454 fn build_watch(
455 self: &Arc<Self>,
456 agent_id: &str,
457 config_dir: &Path,
458 ) -> Option<(RecommendedWatcher, mpsc::UnboundedReceiver<PathBuf>)> {
459 // Derive the projects directory where Claude stores session data
460 let projects_dir = config_dir.join("projects");
461 if !projects_dir.exists() {
462 tracing::debug!(
463 agent = %agent_id,
464 dir = %projects_dir.display(),
465 "projects dir does not exist yet, will watch when created"
466 );
467 }
468
469 let (tx, rx) = mpsc::unbounded_channel::<PathBuf>();
470
471 // Set up filesystem watcher. `watch_agent` is called from the
472 // reactive-register handshake, which fires as soon as the CLI's hook
473 // reaches AgentMux — well before the CLI itself has necessarily
474 // created its `CLAUDE_CONFIG_DIR` on disk (a live trace showed a 47s
475 // gap between register and the persistent process actually
476 // spawning). Watching a path that doesn't exist yet fails outright
477 // with no retry, permanently disabling subagent tracking for that
478 // agent's whole session — the observed cause of subagents silently
479 // never appearing in Swarm. Fall back to the nearest EXISTING
480 // ancestor (typically `~/.config`, effectively always present) so
481 // the watch succeeds immediately and the recursive mode picks up
482 // `config_dir`/`projects_dir` once the CLI creates them.
483 let watched_dir = if projects_dir.exists() {
484 projects_dir.clone()
485 } else if config_dir.exists() {
486 config_dir.to_path_buf()
487 } else {
488 match nearest_existing_ancestor(config_dir) {
489 Some(dir) => {
490 tracing::info!(
491 agent = %agent_id,
492 config_dir = %config_dir.display(),
493 watching = %dir.display(),
494 "config dir does not exist yet — watching nearest existing ancestor instead"
495 );
496 dir
497 }
498 None => {
499 tracing::warn!(
500 agent = %agent_id,
501 config_dir = %config_dir.display(),
502 "no existing ancestor found for config dir — cannot watch for subagents"
503 );
504 return None;
505 }
506 }
507 };
508
509 // Filters events to this agent's config dir regardless of which
510 // directory ended up watched — a no-op when watching config_dir or
511 // projects_dir directly (both are already under config_dir), but
512 // essential when watching a shared ancestor above: without it,
513 // every other agent's subagent files under that same ancestor would
514 // be misattributed to this agent_id.
515 let config_dir_filter = config_dir.to_path_buf();
516 let tx_clone = tx.clone();
517
518 let mut watcher = match notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
519 match res {
520 Ok(event) => {
521 let dominated = matches!(
522 event.kind,
523 EventKind::Modify(_) | EventKind::Create(_)
524 );
525 if dominated {
526 for path in event.paths {
527 if !path.starts_with(&config_dir_filter) {
528 continue;
529 }
530 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
531 let is_subagent =
532 name.starts_with("agent-") && name.ends_with(".jsonl");
533 // Workflow run journals live at
534 // subagents/workflows/<wf>/journal.jsonl and drive
535 // workflow-level progress counters.
536 let is_journal = name == "journal.jsonl";
537 if is_subagent || is_journal {
538 let _ = tx_clone.send(path);
539 } else if let Some(transcript) =
540 completion::transcript_for_sidecar(&path)
541 {
542 // The sidecar can land after the transcript's
543 // last line; re-process the transcript so its
544 // `tool_use_id` retry runs (jsonl.rs).
545 let _ = tx_clone.send(transcript);
546 }
547 }
548 }
549 }
550 }
551 Err(e) => {
552 tracing::warn!(error = %e, "subagent filesystem watcher error");
553 }
554 }
555 }) {
556 Ok(w) => w,
557 Err(e) => {
558 tracing::warn!(
559 agent = %agent_id,
560 error = %e,
561 "failed to create subagent file watcher"
562 );
563 return None;
564 }
565 };
566
567 if let Err(e) = watcher.watch(&watched_dir, RecursiveMode::Recursive) {
568 tracing::warn!(
569 agent = %agent_id,
570 dir = %watched_dir.display(),
571 error = %e,
572 "failed to watch directory for subagents"
573 );
574 return None;
575 }
576
577 tracing::info!(
578 agent = %agent_id,
579 dir = %watched_dir.display(),
580 "watching for subagent JSONL files"
581 );
582
583 Some((watcher, rx))
584 }
585
586 /// First-time registration for an agent_id: build the watch and, if it
587 /// succeeds, install it (`WatchedAgent`, with `parent_block_id` as both
588 /// the initial `primary_block_id` and sole member of `parent_block_ids`)
589 /// and spawn its consumer loop. A no-op (nothing installed, nothing
590 /// spawned) if `build_watch` fails — already logs its own reason,
591 /// nothing further to report here.
592 fn start_watch(self: &Arc<Self>, agent_id: &str, parent_block_id: &str, config_dir: PathBuf) {
593 let Some((watcher, rx)) = self.build_watch(agent_id, &config_dir) else {
594 return;
595 };
596 {
597 let mut watched = self.watched_agents.lock().unwrap();
598 watched.push(WatchedAgent {
599 agent_id: agent_id.to_string(),
600 primary_block_id: parent_block_id.to_string(),
601 parent_block_ids: std::iter::once(parent_block_id.to_string()).collect(),
602 config_dir,
603 _watcher: watcher,
604 });
605 }
606
607 // Deliberately NOT scanning history here. `watch_agent` runs at
608 // reactive-register time — before this agent identity's session for
609 // *this pane* is known — and `projects_dir` covers every project
610 // this agent identity has EVER worked in, across every past
611 // session. A blind scan here used to flood the Swarm view with
612 // every subagent this identity had ever spawned, in every project,
613 // the moment any pane for it was reopened (observed: 20 old
614 // sessions, 4-18 subagent files each, all appearing at once). A
615 // brand-new session has nothing to backfill — subagents will be
616 // picked up live, correctly, as the Task tool spawns them. A
617 // RESUMED session's own prior subagents (still legitimately
618 // relevant) are scoped in via `scan_session_subagents`, called from
619 // `handle_reactive_register` when the block's persisted
620 // `agent:sessionid` meta says which exact session this pane is
621 // resuming. See
622 // docs/specs/archive/REPORT_SWARM_SUBAGENT_HISTORY_FLOOD_2026_07_07.md.
623 self.spawn_consumer_loop(agent_id, parent_block_id, rx);
624 }
625
626 /// Spawn the debounced background task that consumes raw filesystem
627 /// events for one agent's watch and dispatches them to
628 /// `process_jsonl_change`/`process_journal_change`. Shared by
629 /// `start_watch` (first registration) and `recheck_config_dir`
630 /// (repointing an existing watch at a corrected directory) — same
631 /// consumer mechanics either way, just fed by a different `rx`.
632 fn spawn_consumer_loop(
633 self: &Arc<Self>,
634 agent_id: &str,
635 parent_block_id: &str,
636 mut rx: mpsc::UnboundedReceiver<PathBuf>,
637 ) {
638 let self_clone = Arc::clone(self);
639 let parent_agent = agent_id.to_string();
640 let parent_block_id = parent_block_id.to_string();
641 tokio::spawn(async move {
642 loop {
643 let path = match rx.recv().await {
644 Some(p) => p,
645 None => {
646 tracing::info!(
647 agent = %parent_agent,
648 "subagent watcher channel closed"
649 );
650 break;
651 }
652 };
653
654 // Debounce: drain additional events within 200ms
655 tokio::time::sleep(Duration::from_millis(200)).await;
656 let mut paths = vec![path];
657 while let Ok(p) = rx.try_recv() {
658 if !paths.contains(&p) {
659 paths.push(p);
660 }
661 }
662
663 for changed_path in paths {
664 // Agents without a per-account auth dir all
665 // resolve to the same shared default Claude config dir
666 // (see resolve_claude_config_dir's doc comment), so this
667 // watcher's notify subscription can legitimately be on a
668 // directory tree several OTHER agents' watchers are also
669 // subscribed to. Without this check, one agent's real
670 // subagent write fans out to every other agent sharing
671 // that path, each misattributing it to their own
672 // parent_block_id. See
673 // docs/retro/retro-subagent-watcher-shared-dir-fanout-and-leak-2026-07-23.md.
674 //
675 // Skipped entirely when parent_block_id is empty — the
676 // legacy/manual `subagent.WatchAgent` RPC entry point
677 // (server/service/misc.rs) deliberately passes "" for
678 // callers with no pane to scope to; there's no block to
679 // own the session against, so the check would otherwise
680 // reject every event from that path unconditionally.
681 let session_id = derive_session_id(&changed_path);
682 if !parent_block_id.is_empty()
683 && !self_clone.session_belongs_to_block(&parent_block_id, &session_id)
684 {
685 tracing::debug!(
686 agent = %parent_agent,
687 parent_block_id = %parent_block_id,
688 session_id = %session_id,
689 path = %changed_path.display(),
690 "dropping subagent fs event: session does not belong to this block"
691 );
692 continue;
693 }
694
695 let is_journal = changed_path.file_name().and_then(|n| n.to_str())
696 == Some("journal.jsonl");
697 if is_journal {
698 self_clone.process_journal_change(
699 &parent_agent,
700 &parent_block_id,
701 &changed_path,
702 );
703 } else {
704 // live=true: this came from the filesystem watcher
705 // observing a real, in-the-moment change — eligible
706 // to trigger eager Haiku naming.
707 self_clone.process_jsonl_change(
708 &parent_agent,
709 &parent_block_id,
710 &changed_path,
711 true,
712 );
713 }
714 }
715 }
716 });
717 }
718
719 /// True if `session_id` is the session currently persisted on
720 /// `block_id`'s own `agent:sessionid` meta. Used by the live
721 /// filesystem-watch dispatch loop (`watch_agent`) to reject an event for
722 /// a session this block doesn't actually own before it can be
723 /// misattributed — necessary because agents without a per-identity
724 /// bundle override all share one `notify` subscription on the same
725 /// default Claude config dir (see `resolve_claude_config_dir`), so any
726 /// one of their watchers can receive any other's raw file-change events.
727 /// `false` for a block that no longer exists (closed pane) or has since
728 /// moved to a different session — both cases mean this event isn't this
729 /// block's to process. See
730 /// docs/retro/retro-subagent-watcher-shared-dir-fanout-and-leak-2026-07-23.md.
731 fn session_belongs_to_block(&self, block_id: &str, session_id: &str) -> bool {
732 let Ok(Some(block)) = self.mstore.get::<crate::backend::obj::Block>(block_id) else {
733 return false;
734 };
735 crate::backend::obj::meta_get_string(
736 &block.meta,
737 crate::backend::blockcontroller::core::META_SESSION_ID,
738 "",
739 ) == session_id
740 }
741
742 /// Stop watching an agent from the graceful `/agentmux/reactive/unregister`
743 /// path: remove `block_id` as a dependent of its watcher (same
744 /// mechanism as `unwatch_block`, keyed by `agent_id` first since that's
745 /// what the caller has), tearing down the underlying filesystem watcher
746 /// — which closes the debounce channel, so the processing task
747 /// self-terminates on its next `rx.recv()` returning `None` — only once
748 /// no block depends on it anymore. Idempotent: a no-op if the agent
749 /// isn't currently watched.
750 ///
751 /// `block_id` is `None` when the reactive registry has no record of
752 /// this agent_id (already unregistered, or never registered) — nothing
753 /// to disassociate in that case, so the watcher-teardown step is
754 /// skipped entirely rather than guessing.
755 ///
756 /// `watch_agent` dedupes by `agent_id`: two blocks registering the same
757 /// agent_id share one `WatchedAgent` entry. Removing that whole entry
758 /// unconditionally here (as this method used to) killed the shared
759 /// watcher — and with it, live tracking — for every OTHER still-open
760 /// block sharing that agent identity the moment any ONE of them
761 /// gracefully closed, since this is the primary, far-more-common
762 /// teardown path (`unwatch_block` only covers the crash/API-delete
763 /// backstop). See `WatchedAgent::parent_block_ids`'s doc comment and
764 /// docs/retro/retro-subagent-watcher-shared-dir-fanout-and-leak-2026-07-23.md.
765 ///
766 /// Without this, `watched_agents` was push-only: every distinct agent that
767 /// ever ran leaked one OS watch handle + channel + idle task for the rest of
768 /// the process lifetime, even after its pane/agent was deleted.
769 ///
770 /// Also prunes `sessions`, `dispatches`, and `pending_activity`: every
771 /// entry whose owner is this agent (`SubAgent`/`AgentDispatch`'s
772 /// `parent_agent`, `PendingDispatchActivity.parent_agent`), and any
773 /// session left with no subagents afterward. The parent agent is gone,
774 /// so nothing can query this data again — it was previously left as
775 /// plain data forever, growing these maps by one entry set per distinct
776 /// agent that ever ran a subagent. (`dispatches`/`pending_activity`
777 /// pruning added alongside `prune_block`'s block-scoped equivalent,
778 /// below — this method previously only pruned `sessions`, silently
779 /// leaking a Workflow-kind `DispatchState` for the agent's lifetime.)
780 pub fn unwatch_agent(&self, agent_id: &str, block_id: Option<&str>) {
781 if let Some(block_id) = block_id {
782 let mut watched = self.watched_agents.lock().unwrap();
783 for w in watched.iter_mut() {
784 if w.agent_id == agent_id {
785 w.parent_block_ids.remove(block_id);
786 }
787 }
788 let before = watched.len();
789 watched.retain(|w| !(w.agent_id == agent_id && w.parent_block_ids.is_empty()));
790 if watched.len() != before {
791 tracing::info!(agent = %agent_id, block_id = %block_id, "stopped watching subagent dir");
792 }
793 }
794
795 let mut sessions = self.sessions.lock().unwrap();
796 let mut pruned_subagents = 0usize;
797 sessions.retain(|_session_id, session| {
798 let before = session.subagents.len();
799 session
800 .subagents
801 .retain(|_agent_id, state| state.info.parent_agent != agent_id);
802 pruned_subagents += before - session.subagents.len();
803 !session.subagents.is_empty()
804 });
805 if pruned_subagents > 0 {
806 tracing::debug!(
807 agent = %agent_id,
808 pruned_subagents,
809 "pruned subagent session state for unwatched agent"
810 );
811 }
812 drop(sessions);
813
814 let mut dispatches = self.dispatches.lock().unwrap();
815 let before = dispatches.len();
816 dispatches.retain(|_, state| state.info.parent_agent != agent_id);
817 let pruned_dispatches = before - dispatches.len();
818 drop(dispatches);
819
820 let mut pending = self.pending_activity.lock().unwrap();
821 let before = pending.len();
822 pending.retain(|_, activity| activity.parent_agent != agent_id);
823 let pruned_pending = before - pending.len();
824 drop(pending);
825
826 if pruned_dispatches > 0 || pruned_pending > 0 {
827 tracing::debug!(
828 agent = %agent_id,
829 pruned_dispatches,
830 pruned_pending,
831 "pruned dispatch/pending-activity state for unwatched agent"
832 );
833 }
834 }
835
836 /// Prune every subagent, dispatch, and buffered activity entry owned by
837 /// `block_id` — the block-scoped counterpart of `unwatch_agent`'s
838 /// agent-scoped pruning, above. This is the robust backstop: it runs
839 /// from a `srv_events_tx` subscriber reacting to `Event::BlockDeleted`/
840 /// `TabDeleted`/`WorkspaceDeleted` (see `spawn_block_prune_subscriber`
841 /// below), independent of whether the frontend's normal
842 /// `/agentmux/reactive/unregister` teardown path (which drives
843 /// `unwatch_agent`) actually fires for this close — that path depends
844 /// on a live renderer's `TermWrap.dispose()` completing an async fetch,
845 /// which an API-driven delete, a tab/workspace cascade delete, or a
846 /// crash can all skip. Without this, closing an agent pane left its
847 /// Swarm-pane row (and any subagents/dispatches under it) visible
848 /// until the whole app/srv restarted — `ListActive`/`ListDispatches`
849 /// kept returning them forever, and the frontend's `buildTree()` has a
850 /// `parentIds` fallback (`swarm-model.ts`) that renders a row for any
851 /// block_id it still sees in the subagent list, with no complementary
852 /// removal path of its own.
853 ///
854 /// Block-scoped (not agent-name-scoped like `unwatch_agent`) so this
855 /// also correctly handles an agent identity reused across multiple
856 /// blocks over time — pruning by `parent_block_id` can never touch a
857 /// different, still-live block that happens to share an agent name.
858 ///
859 /// Also tears down this block's own filesystem watcher (`unwatch_block`,
860 /// below) — without this, `prune_block` only cleared the DERIVED
861 /// subagent/dispatch state at the moment it ran, while the underlying
862 /// `notify` watcher (and its dedicated tokio task, keyed by
863 /// `parent_block_id`) kept running indefinitely, silently re-creating
864 /// fresh entries for this closed block the next time any OTHER agent
865 /// sharing its watched directory wrote to its own subagent transcript
866 /// — see `session_belongs_to_block`'s doc comment and
867 /// docs/retro/retro-subagent-watcher-shared-dir-fanout-and-leak-2026-07-23.md.
868 ///
869 /// Returns whether any DERIVED state was actually pruned, so the caller
870 /// only broadcasts a refresh when there's something for clients to
871 /// refresh — independent of whether a watcher also got torn down.
872 pub fn prune_block(&self, block_id: &str) -> bool {
873 let mut pruned = false;
874
875 let mut sessions = self.sessions.lock().unwrap();
876 sessions.retain(|_session_id, session| {
877 let before = session.subagents.len();
878 session
879 .subagents
880 .retain(|_agent_id, state| state.info.parent_block_id != block_id);
881 if session.subagents.len() != before {
882 pruned = true;
883 }
884 !session.subagents.is_empty()
885 });
886 drop(sessions);
887
888 let mut dispatches = self.dispatches.lock().unwrap();
889 let before = dispatches.len();
890 dispatches.retain(|_, state| state.info.parent_block_id != block_id);
891 if dispatches.len() != before {
892 pruned = true;
893 }
894 drop(dispatches);
895
896 let mut pending = self.pending_activity.lock().unwrap();
897 let before = pending.len();
898 pending.retain(|_, activity| activity.parent_block_id != block_id);
899 if pending.len() != before {
900 pruned = true;
901 }
902 drop(pending);
903
904 // reagentx P2 (PR #2781, round 3): `backfill_generation` is keyed
905 // by `parent_block_id` (see its own doc comment) but was never
906 // pruned here like every other per-block map above — every
907 // distinct block_id that ever called `scan_session_subagents` left
908 // a permanent entry for the life of the srv process, even after
909 // the block was deleted. Not counted toward `pruned` below —
910 // that return value is specifically about DERIVED subagent/
911 // dispatch data clients might need to refresh over, not internal
912 // bookkeeping with no client-visible effect.
913 self.backfill_generation.lock().unwrap().remove(block_id);
914
915 self.unwatch_block(block_id);
916
917 if pruned {
918 tracing::debug!(block_id = %block_id, "pruned subagent/dispatch state for deleted block");
919 }
920 pruned
921 }
922
923 /// Remove `block_id` as a dependent of its watcher, tearing down the
924 /// underlying `notify` subscription (closing the debounce channel so the
925 /// associated processing task self-terminates on its next `rx.recv()`)
926 /// only once NO block depends on it anymore. Block-scoped, not
927 /// agent-name-scoped — mirrors `unwatch_agent`'s teardown mechanism, but
928 /// keyed by `parent_block_id`.
929 ///
930 /// `watch_agent` dedupes by `agent_id`: a second block registering an
931 /// already-watched agent_id adds itself to that entry's
932 /// `parent_block_ids` instead of getting its own watcher. Tearing down
933 /// the whole entry the moment ANY one dependent block closes would kill
934 /// live tracking for every other still-open block sharing that agent
935 /// identity — removing just this block_id from the set (and only
936 /// dropping the entry once the set is empty) keeps the watcher alive for
937 /// as long as any block still needs it. Idempotent — a no-op if
938 /// `block_id` isn't a dependent of any watcher.
939 fn unwatch_block(&self, block_id: &str) {
940 let mut watched = self.watched_agents.lock().unwrap();
941 for w in watched.iter_mut() {
942 w.parent_block_ids.remove(block_id);
943 }
944 let before = watched.len();
945 watched.retain(|w| !w.parent_block_ids.is_empty());
946 if watched.len() != before {
947 tracing::info!(block_id = %block_id, "stopped watching subagent dir: last dependent block closed");
948 }
949 }
950
951 fn broadcast_block_pruned(&self, block_id: &str) {
952 self.event_bus.broadcast_typed::<crate::backend::mps_events::SubagentBlockPruned>(&events::SubagentBlockPrunedEvent {
953 block_id: block_id.to_string(),
954 });
955 }
956
957 /// Prune `block_id` and broadcast `subagent:block_pruned` if anything
958 /// was actually removed — the combined operation `spawn_block_prune_
959 /// subscriber` calls per cascaded block_id.
960 pub fn prune_block_and_notify(&self, block_id: &str) {
961 if self.prune_block(block_id) {
962 self.broadcast_block_pruned(block_id);
963 }
964 }
965}