Skip to main content

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}