Skip to main content

SubagentWatcher

Struct SubagentWatcher 

Source
pub struct SubagentWatcher {
    event_bus: Arc<EventBus>,
    mstore: Arc<Store>,
    id_store: Arc<Store>,
    identity_store: Arc<Store>,
    sessions: Mutex<HashMap<String, SessionWatch>>,
    watched_agents: Mutex<Vec<WatchedAgent>>,
    dispatches: Mutex<HashMap<String, DispatchState>>,
    pending_activity: Mutex<HashMap<String, PendingDispatchActivity>>,
    naming_triggered: Mutex<HashSet<String>>,
    self_ref: Mutex<Option<Weak<SubagentWatcher>>>,
    broker: Mutex<Option<Arc<Broker>>>,
    backfill_generation: Mutex<HashMap<String, u64>>,
}

Fields§

§event_bus: Arc<EventBus>§mstore: Arc<Store>§id_store: Arc<Store>

Held so recheck_config_dir/recheck_all_watched_agents (called from the identity-bind RPC handlers, server/app_api/identity.rs and server/agent_handlers/identity.rs) can re-resolve a fresh identity binding without every caller needing to thread these stores through. NOT used to self-verify inside watch_agent/ start_watch itself — an earlier version of this fix tried that and broke callers whose config_dir isn’t backed by a resolvable instance/binding row at all (the legacy parent_block_id: "" manual RPC entry point, and several tests); see watch_agent’s own doc comment for why that self-check lives in handle_reactive_register instead, scoped to the one call site that actually derives config_dir from identity/binding resolution in the first place.

§identity_store: Arc<Store>§sessions: Mutex<HashMap<String, SessionWatch>>§watched_agents: Mutex<Vec<WatchedAgent>>§dispatches: Mutex<HashMap<String, DispatchState>>§pending_activity: Mutex<HashMap<String, PendingDispatchActivity>>§naming_triggered: Mutex<HashSet<String>>

Dispatch IDs that have already had their one eager Haiku-naming call triggered (issue: SPEC_SWARM_DISPATCH_NAMING_AND_ROW_MODEL_2026_07_19) — checked-and-inserted atomically at the same point is_new is computed in process_jsonl_change, so a Workflow dispatch (whose dispatch_id is shared by every member) only ever triggers once, on its first-observed-live member, not once per member. Never cleared — naming is “at most once ever per dispatch,” not a retryable in-flight guard (unlike activity_watcher.rs’s in_flight set, which this deliberately does NOT mirror: there’s no “try again later” concept for a dispatch’s name).

§self_ref: Mutex<Option<Weak<SubagentWatcher>>>

Weak self-reference, populated once by spawn() right after the Arc::new that wraps this watcher — lets any &self method upgrade to Arc<Self> to move into a tokio::spawned background task (the eager-naming trigger) without threading Arc<Self> through every caller of process_jsonl_change (scan_subagents_dir/ scan_session_subagents, both &self today). None for a SubagentWatcher built via bare new() (as most tests do, to skip the background flush loop) — eager naming silently no-ops in that case rather than panicking, matching this module’s existing “unknown/untracked -> safe no-op” convention (see set_display_name).

§broker: Mutex<Option<Arc<Broker>>>

The scoped, persisted MPS broker – used only for subagent:backfill_status (scan.rs’s publish_backfill_status), so a pane can query “is my own backfill still in progress” via EventReadHistoryCommand rather than relying solely on live-event timing (mirrors the identical agent-resume-retry design, docs/status/STATUS_STALE_RESUME_LIVE_REPRO_AND_FIX_PLAN_2026_08_23.md section 6.2). Deliberately separate from event_bus above (the raw, unscoped, unpersisted WS fan-out every OTHER event in this module uses) – this one event specifically needs the scope+persist semantics only mps::Broker provides. None until set_broker is called (bootstrap only, after both this watcher and the broker exist) – every test call site built via bare new() skips this event entirely rather than panicking, same posture as self_ref.

§backfill_generation: Mutex<HashMap<String, u64>>

reagentx P2 (PR #2781): scan_session_subagents can be called twice for the SAME parent_block_id in overlapping fashion (the same block re-registered under a new agent_id – see server/reactive.rs’s caller comment – while an earlier call for that same block id is still mid-scan). Without this, an OLDER call’s “done” can publish after a NEWER call’s “started” already fired, prematurely clearing the subagent:backfill_status gate while the newer scan is still running. Keyed by parent_block_id, incremented at the start of every call; a call only publishes “done” if its own captured generation is STILL the latest recorded one for that block id when it finishes – a stale call silently skips “done” entirely, leaving the (correctly still-in-progress) gate to whichever call is actually current. See scan.rs’s scan_session_subagents.

Implementations§

Source§

impl SubagentWatcher

Source

pub(super) fn process_jsonl_change( &self, parent_agent: &str, parent_block_id: &str, jsonl_path: &Path, live: bool, )

Process a changed/new JSONL subagent file. Reads new lines, updates state, and broadcasts events via EventBus.

live: true only when this call originates from the filesystem watcher observing a real, in-the-moment change; false for a cold-backfill replay (scan_subagents_dir, capped at BACKFILL_MAX_FILES, run on pane reopen / srv restart). Eager Haiku-naming (see the is_new handling below) is gated on live — without this, every restart of a long-lived session would re-fire a naming call for every dispatch replayed from history, repeating the broadcast-storm incident class in docs/retro/retro-subagent-backfill-storm-oom-2026-07-17.md, just for Haiku spend instead of WS events.

Source

pub(super) fn flush_pending_dispatch_activity(&self)

Flush every dispatch with buffered activity as one coalesced dispatch:activity broadcast (SPEC §7). Called on DISPATCH_ACTIVITY_FLUSH_INTERVAL by the background task spawn starts. A quiet dispatch (nothing buffered) costs nothing — the map only ever holds entries for dispatches with real pending activity, drained on every flush.

Source

fn broadcast_member_spawned( &self, spawn: &PendingSpawn, parent_agent: &str, parent_block_id: &str, session_id: &str, dispatch_id: &str, )

Broadcast subagent:spawned for one member, immediately for a solo dispatch or from the coalesced flush for a workflow member.

Source

fn broadcast_member_completed( &self, done: &PendingCompletion, parent_agent: &str, parent_block_id: &str, session_id: &str, dispatch_id: &str, )

Broadcast subagent:completed for one member, immediately for a solo dispatch or from the coalesced flush for a workflow member.

Source

fn update_dispatch_membership( &self, workflow_id: &str, parent_agent: &str, parent_block_id: &str, session_id: &str, member_spawned: bool, member_completed: bool, )

Fold a member subagent’s lifecycle into its dispatch aggregate and broadcast dispatch:updated — but only when membership actually changed (a spawn or a completion). process_jsonl_change calls this unconditionally for every workflow member, including plain text/tool_use/tool_result activity ticks that carry neither flag; a dispatch with several active members would otherwise broadcast a WS event on every one of those ticks even though memberCount/ membersDone never moved. Mirrors the has_new_records gate in process_journal_change.

Source

fn queue_dispatch_updated( &self, dispatch_id: &str, parent_agent: &str, parent_block_id: &str, session_id: &str, info: AgentDispatch, )

Queue an AgentDispatch snapshot for the next coalesced flush instead of broadcasting dispatch:updated immediately — every caller here is a Workflow-kind dispatch (Solo dispatches broadcast directly via broadcast_dispatch_updated, see process_jsonl_change’s solo path).

Source

pub(super) fn process_journal_change( &self, parent_agent: &str, parent_block_id: &str, journal_path: &Path, )

Process a changed workflow journal (subagents/workflows//journal.jsonl): tally new started/result records and broadcast dispatch:updated.

Source

fn dispatch_entry<'a>( dispatches: &'a mut HashMap<String, DispatchState>, workflow_id: &str, parent_agent: &str, parent_block_id: &str, session_id: &str, ) -> &'a mut DispatchState

Source

fn refresh_dispatch_info(state: &mut DispatchState)

Recompute public counters from raw journal/member counters. Either source can lag the other (journal writes vs member file creation), so take the max of each pair.

Source

pub(super) fn recompute_dispatch_status(state: &mut DispatchState)

Counts-complete + 60s quiet ⇒ Completed. There is no timer: the flip happens lazily at the next event or ListDispatches read. started == results alone is not terminal — it also holds between phases of a still-running workflow, hence the quiet window.

Source

pub(super) fn refresh_dispatch_status(state: &mut DispatchState)

Read-only variant for list_dispatches() — Abandoned is terminal and never recomputed here, since this call has no new evidence (unlike refresh_dispatch_info’s call to recompute_dispatch_status above, triggered by a genuinely new member event). reconcile_stale_ subagents is the ONLY writer of Abandoned (SPEC_SWARM_DISPATCH_ATTRIBUTION_AND_LIFECYCLE_2026_08_19.md §3.2), and list_dispatches() calls this on every single read (not just on new events) — without this guard, the very next ListDispatches RPC after a reconciliation pass would silently overwrite Abandoned back to Running/Completed based on counts alone, discarding the reconciliation before any client ever observed it.

Source

pub(super) fn broadcast_subagent_named( &self, agent_id: &str, display_name: &str, )

subagent:named: the subagent’s display name is set (from the parent’s description, or by Haiku). The dock and the Swarm patch their row from it.

Source

pub(super) fn broadcast_subagent_updated( &self, agent_id: &str, parent_block_id: &str, )

A known subagent’s fields changed without a new spawn — today, only its tool_use_id arriving late from the sidecar. Swarm reloads the list on it so the subagent’s background tasks move under its row. Deliberately NOT a re-sent subagent:spawned: the dock’s sources add rows on that.

Source

pub(super) fn broadcast_dispatch_updated(&self, info: &AgentDispatch)

Source§

impl SubagentWatcher

Source

pub(super) const SIDECAR_GRACE: Duration

How long a solo subagent’s eager name waits for a late .meta.json, whose description makes the model call unnecessary.

Source

pub fn list_active(&self) -> Vec<SubAgent>

List all subagents across all sessions (sync — safe to call from RPC dispatch).

Source

pub fn list_dispatches(&self) -> Vec<AgentDispatch>

List all tracked dispatches — both kinds (sync — safe to call from RPC dispatch). Workflow-kind dispatches come from tracked DispatchState; Solo-kind dispatches have no persistent state of their own and are synthesized 1:1 from every loose SubAgent (SPEC §5/§8) — this is what gives every solo Task-tool call a real, backend-issued container it never had before.

Source

pub(super) fn solo_dispatch(sub: &SubAgent) -> Option<AgentDispatch>

Synthesize the AgentDispatch for a loose (non-workflow) SubAgent. None for a workflow member — those are represented by their tracked DispatchState instead, not synthesized per-call.

Source

pub fn get_history(&self, agent_id: &str, limit: usize) -> Vec<SubagentEvent>

Get recent events for a specific subagent (sync — safe to call from RPC dispatch).

Source

pub fn get_info(&self, agent_id: &str) -> Option<SubAgent>

Get info for a single subagent (sync — safe to call from RPC dispatch). Targeted counterpart of list_active() — a subagent pane reopened after its subagent:spawned event already fired needs its info once, at mount; scanning + cloning every active subagent across every session for that is wasteful when the caller already knows the id.

Source

pub fn set_display_name(&self, agent_id: &str, display_name: &str) -> bool

Set a subagent’s Haiku-generated display name and broadcast subagent:named so every client watching this session picks up the result, not just the one that triggered the naming call. Returns false if the subagent isn’t tracked (e.g. it aged out between the RPC firing and resolving) — the caller should treat that as a no-op, not an error.

A name already set is kept: the parent’s description can land in a late sidecar while this call runs, and it is the better name. Nothing is broadcast then.

Source

pub fn set_dispatch_name(&self, dispatch_id: &str, name: &str) -> bool

Set a Workflow-kind dispatch’s Haiku-generated name and broadcast dispatch:updated so every client watching this session picks up the result. Mirrors set_display_name one level up — see that method’s doc comment for the “found=false is a safe no-op” convention. Only Workflow-kind dispatches store a dispatch_name here; a Solo dispatch’s name IS its one member’s display_name (solo_dispatch reads it straight through), so set_display_name already covers the Solo case — this method is never called for a solo: dispatch_id.

Source

pub(super) fn trigger_eager_naming( &self, dispatch_id: String, first_member_agent_id: String, is_workflow: bool, )

Fire the one eager Haiku-naming call for a dispatch’s first-observed live spawn (issue: SPEC_SWARM_DISPATCH_NAMING_AND_ROW_MODEL_2026_07_19). Caller (process_jsonl_change) has already atomically claimed dispatch_id in naming_triggered before calling this — this method itself does no further dedup.

Upgrades self_ref to a real Arc<Self> to move into the spawned task; silently no-ops if this watcher was built via bare new() (most unit tests) rather than spawn() — there is no Arc to upgrade to in that case, matching this module’s “untracked -> safe no-op” convention rather than panicking.

Source

pub(super) fn select_unnamed_backlog( &self, limit: usize, ) -> Vec<BacklogNamingItem>

Select up to limit currently-unnamed subagents/dispatches for the bounded backfill-naming pass (resolve_unnamed_backlog), most recently-active first, and atomically claim each selected item in naming_triggered before returning it — reusing the exact dedup structure the live eager-naming path already uses (jsonl::process_jsonl_change), so this can never double-name something, race with a live spawn of the same dispatch, or need a second HashSet. Because the claim is permanent, calling this again (a second Swarm-pane-open before an earlier batch finishes, or after it drains) never re-selects anything already claimed — this is what makes resolve_unnamed_backlog safe to fire on every pane open with no extra debounce.

Deliberately never holds sessions/dispatches and naming_triggered locked at the same time — mirrors process_jsonl_change’s own convention (see its “Mutex released here” comment).

Source

pub(crate) async fn resolve_unnamed_backlog(self: Arc<Self>)

Bounded, rate-limited burst that resolves names for whatever’s currently unnamed in the backfilled/historical backlog (select_unnamed_backlog) — fired only via the ("subagent", "ResolveUnnamedBacklog") RPC, itself only ever called from SwarmViewModel’s constructor (i.e. a human actually opening the Swarm pane), never from the headless per-agent-pane backfill scan — that path stays exactly as live-gated as before (see process_jsonl_change’s doc comment and docs/retro/retro-subagent-backfill-storm-oom-2026-07-17.md).

Deliberately separate from trigger_eager_naming’s live path: runs as background work (limits::Class::Background), so it never queues ahead of a name or title the user is waiting on.

Source§

impl SubagentWatcher

Source

pub fn scan_session_subagents( &self, parent_agent: &str, parent_block_id: &str, config_dir: &Path, session_id: &str, )

Backfill subagents that already existed before this pane (re)opened, scoped to exactly the ONE session being resumed — not this agent identity’s entire history. Called from handle_reactive_register when the block being registered already has a persisted agent:sessionid (i.e. it’s resuming a prior conversation, not starting fresh); a brand-new session has nothing to backfill.

Searches only the top level of config_dir/projects/* for a child directory literally named session_id (the nested-per-session layout observed in practice: projects/<ws>/<session-uuid>/subagents/) — cheap, since an agent identity typically has few distinct project dirs. Older/flat-layout installs where subagents/ sits directly under the project dir with no session-level folder have no way to isolate one session’s subagents from another’s at the filesystem level; in that case this intentionally finds nothing rather than falling back to scanning everything.

Source

pub(super) fn is_backfill_generation_current( &self, parent_block_id: &str, generation: u64, ) -> bool

Whether generation (captured at the start of one scan_session_subagents call) is still the most recent generation recorded for parent_block_id — i.e. no NEWER overlapping call has started since. See backfill_generation’s own doc comment in mod.rs.

Source

fn scan_session_subagents_inner( &self, parent_agent: &str, parent_block_id: &str, config_dir: &Path, session_id: &str, )

Source

pub(crate) fn reconcile_stale_subagents( &self, parent_block_id: &str, session_id: &str, )

After a full session backfill scan, downgrade any subagent that’s still Active to Abandoned if its parent block’s turn is not currently active. A subagent runs inside its parent’s own CLI process — a Task-tool call is synchronous within the parent’s turn — so once the parent’s turn has ended, any subagent file lacking a terminal Result line was interrupted (crashed, killed, or the app/srv restarted mid-task), not still running. This call site is reached from scan_session_subagents, itself only invoked when a block re-registers with a pre-existing session id (reactive.rs) — i.e. a freshly-spawned controller whose turn genuinely hasn’t started yet, so every subagent found on disk predates this process and is unambiguously history, not in-flight.

Only reconciles on a confirmed-idle read (Some(false)). Some(true) (confirmed active) skips with no follow-up — correct, nothing to reconcile. None (“no controller registered yet”) used to be folded into the same “assume active, don’t touch it” bucket as confirmed-active, permanently — a previously-flagged, unconfirmed race (SPEC_SUBAGENT_LIVE_RECONCILIATION_AND_RETIRE_2026_07_20.md §5 Open Question 2) where this call site, reached from scan_session_subagents at pane-reopen backfill time, can run before the freshly-spawned controller has registered in CONTROLLER_REGISTRY. Left unresolved, an entry hitting that race stayed Active-looking forever, since nothing else ever revisits it (see SPEC_SWARM_DISPATCH_ATTRIBUTION_AND_LIFECYCLE_2026_08_19.md §2, mechanism 4). Fixed by treating None as “retry, don’t give up silently” (retry_reconcile_once) instead of a terminal no-op — still conservative (a genuine Some(true) never retries; only the ambiguous case does, and only up to MAX_RECONCILE_RETRIES times).

2026-09-02 (docs/retro/retro-activitydock-appears-on-agent-pane-load-2026-09-02.md): the original version of this fix allowed exactly ONE 2s retry before giving up permanently. Confirmed live on a real agent whose CLI controller never registered at all (an expired provider login — the process either never spawns or spawns and exits before reaching register_controller): the single 2s retry window is frequently too short to distinguish “controller is still starting up, wait” from “controller is never coming” — for the FORMER case this left the pane’s Activity Dock showing genuinely-dead subagents as Active indefinitely (no live turn-end ever fires to retry later either), and for the LATTER case a couple of seconds is too optimistic a bound for how long a real controller can legitimately take to spawn and register under load. Extended to a bounded retry loop (MAX_RECONCILE_RETRIES attempts, RECONCILE_RETRY_INTERVAL_MS apart) — still fails open in bounded time (~MAX_RECONCILE_RETRIES * RECONCILE_RETRY_INTERVAL_MS), just with a realistic window instead of a single throw-away check.

Called from two places as of SPEC_SUBAGENT_LIVE_RECONCILIATION_AND_ RETIRE_2026_07_20 Phase A: scan_session_subagents (reopen/backfill, unchanged) and blockcontroller::persistent’s turn-end hook (live — closes docs/specs/SPEC_SUBAGENT_LIFECYCLE_RECONCILIATION_2026_07_12.md Open Question 1, which deliberately deferred real-time wiring). pub(crate) so the live call site (a different module) can reach it. Thin entry point — always starts the retry budget at MAX_RECONCILE_RETRIES.

Source

pub(super) fn reconcile_stale_subagents_impl( &self, parent_block_id: &str, session_id: &str, retries_remaining: u32, )

Source

pub(super) fn parent_block_closed(&self, parent_block_id: &str) -> bool

Bounded retry for reconcile_stale_subagents’s None (controller-not-yet-registered) case — see that function’s doc comment for why a single 2s attempt was too short in practice. retries_remaining counts DOWN — the loop terminates because each hop passes a strictly smaller budget, never because of a separate flag; a block whose controller genuinely never registers (e.g. it was deleted, or its provider login never resolves) still gets a bounded, not unbounded, chase.

Mirrors trigger_eager_naming’s self_ref upgrade-to-Arc pattern for spawning a task that outlives this sync call; silently no-ops for a bare new() watcher (most unit tests), same “untracked -> safe no-op” convention as that method. The parent block is mid-close, or no longer exists — either way no controller for it will ever register again. A failed store read is “unknown”, not “closed”, so it keeps the bounded retry. SPEC_AGENT_PANE_CLOSE_GRACEFUL_SHUTDOWN_2026_09_18.md (graceful close removes the controller before the block record).

Source

fn retry_reconcile_once( &self, parent_block_id: &str, session_id: &str, retries_remaining: u32, )

Source

fn broadcast_subagents_abandoned( &self, parent_block_id: &str, agent_ids: &[String], )

One batched broadcast per reconciliation pass (not one per subagent) — a pass can reconcile many subagents at once (e.g. a workflow with dozens of members whose parent turn just ended), and the frontend only needs “something changed, reload” (swarm-model.ts’s scheduleLoadSubagents), not per-agent granularity. Mirrors the batch-not-spam precedent dispatch:activity already established in this file for the same reason.

The frontend muxEventSubscribe({ eventType: "subagent:abandoned", ... }) listener lands in the immediately-following PR (Phase B, #2235 — SPEC_SUBAGENT_LIVE_RECONCILIATION_AND_RETIRE_2026_07_20), not this one. Broadcasting with no subscriber yet is inert, not harmful: this PR’s actual goal (subagents resolving to Completed/ Abandoned correctly in the backend, live, not just at reopen) is already fully achieved without it — the missing piece until #2235 merges is only “push the correction to an already-open Swarm pane immediately” (an already-open pane still picks up the correction on its next unrelated reload in the meantime).

Source

fn broadcast_subagent_completed( &self, parent_block_id: &str, agent_id: &str, dispatch_id: &str, total_events: usize, )

The Completed counterpart to broadcast_subagents_abandoned above, for members reconciliation resolved as having actually returned (see completion.rs). Emits the same subagent:completed event jsonl.rs already sends when it observes a completion live, so a client needs no new handling for the reconcile path — it just stops being told “abandoned” for dispatches that returned.

Per-agent rather than batched, unlike its Abandoned sibling: the payload carries per-member fields (totalEvents, dispatchId) that a batched shape has nowhere to put, and matching jsonl.rs’s existing event exactly is worth more here than saving a broadcast — a reconcile pass typically resolves a handful of completions, not the dozens the batching rationale above was written for.

parentAgent is omitted (the live emitter includes it): reconcile is keyed on the block, and no consumer reads that field.

Source

fn scan_subagents_dir( &self, parent_agent: &str, parent_block_id: &str, dir: &Path, )

Process agent-*.jsonl directly in dir, plus workflow runs under dir/workflows/<wf>/ (member agent files + journal).

A pane’s subagents/ directory accumulates forever — every Task-tool and Workflow-tool run this session has ever made leaves its agent-*.jsonl files behind. Because is_new (process_jsonl_change) is keyed off the in-memory sessions map, which starts empty on every srv process (restart or fresh start), an unbounded scan here replays the session’s ENTIRE history — full-file read + parse + WS broadcast per file — on every pane reopen or srv restart. On a heavily-used pane this is a genuine crash trigger, not just wasted work: a live incident (see docs/retro/retro-subagent-backfill-storm-oom-2026-07-17.md) hit 1,000+ replayed files across three back-to-back srv restarts in under 10 seconds, on a machine already near its commit ceiling — the resulting allocation/broadcast storm is a plausible contributor to each restart re-crashing before the launcher’s 3-strikes budget gave up and killed the whole app.

Bound the replay to the BACKFILL_MAX_FILES most-recently-modified agent files (by mtime) — recent activity is what the Swarm pane’s reopen backfill exists to show; a months-old workflow run replaying on every restart serves no one. Skipped files are still on disk and still show up via list_active’s normal file-count telemetry if something later reads them directly — this only bounds the push (broadcast) side of a cold backfill.

Source§

impl SubagentWatcher

Source

pub fn new( event_bus: Arc<EventBus>, mstore: Arc<Store>, id_store: Arc<Store>, identity_store: Arc<Store>, ) -> Self

Source

pub fn set_broker(&self, broker: Arc<Broker>)

Wire in the shared MPS broker post-construction (bootstrap only) – see the broker field’s own doc comment for why this is optional and set separately rather than a constructor parameter.

Source

pub fn spawn( event_bus: Arc<EventBus>, mstore: Arc<Store>, id_store: Arc<Store>, identity_store: Arc<Store>, ) -> Arc<Self> ⓘ

Create a new SubagentWatcher and return it wrapped in Arc. Also starts the background flush loop for coalesced dispatch activity (SPEC §7) — runs for the lifetime of the process, mirroring watch_agent’s existing tokio::spawn pattern.

Source

pub fn watch_agent( self: &Arc<Self>, agent_id: &str, parent_block_id: &str, config_dir: PathBuf, )

Start watching a Claude Code agent’s session directory for subagent files. Spawns a background tokio task for debounced file event processing.

parent_block_id is the pane/block that owns this Claude instance (from the reactive register request). It is stamped onto every emitted subagent event so the frontend can route the ⚡ panel to the originating pane only, instead of every agent pane rendering every subagent globally.

Note: the watcher dedupes by agent_id, so if the same agent_id is registered from two blocks, events carry the first registrant’s block id. That edge case (same instance name in two panes) is rare; the common leak — a terminal Claude’s subagents showing up in unrelated agent panes — is fully fixed because the terminal block id never matches an agent pane.

Source

pub fn recheck_config_dir( self: &Arc<Self>, agent_id: &str, new_config_dir: PathBuf, )

Re-resolve and, if the answer changed, re-point an already-watched agent’s filesystem watch at a corrected directory. watch_agent’s own config-dir resolution only ever runs ONCE per agent, at the reactive-register handshake — before this agent’s identity binding is necessarily committed to the DB yet (a genuinely observed race: a fresh agent launch’s reactive-register can fire before the launch flow’s own account-bind write lands, landing on a stale/ambient cmd:env snapshot instead of the real identity-bound directory). If that race is lost, the watcher previously had no correction mechanism at all — nothing else ever calls watch_agent again for an already-registered pane, so it silently watched the wrong directory, forever, for the rest of that pane’s life. Called from watch_agent itself (a post-insert self-check) and from the identity-bind RPC handlers (server/app_api/identity.rs, server/agent_handlers/identity.rs) via recheck_all_watched_agents below, right after a successful agent_identity_link write.

No-op if agent_id isn’t currently watched (nothing to correct — its next real registration resolves fresh, by-then-committed bindings correctly on its own) or if the newly-resolved directory is unchanged. See docs/reports/REPORT_SWARM_SUBAGENT_WATCHER_ONE_SHOT_RESOLUTION_RACE_2026_09_04.md.

Source

pub fn recheck_all_watched_agents(self: &Arc<Self>)

Re-check every currently-watched agent’s Claude config dir against a fresh identity resolution, re-pointing (recheck_config_dir) any whose resolution changed. Cheap and safe to call from anywhere an identity/account binding just changed — the number of currently- watched agents on a single instance is small, and this only runs on the rare bind/rebind path, never per-turn.

Source

fn build_watch( self: &Arc<Self>, agent_id: &str, config_dir: &Path, ) -> Option<(RecommendedWatcher, UnboundedReceiver<PathBuf>)>

Build (but do not install) a notify watcher for config_dir, falling back to the nearest existing ancestor directory if it doesn’t exist on disk yet. Returns None on any failure — every branch already logs its own specific reason. Deliberately has NO side effect on watched_agents: callers (start_watch for a first-time registration, recheck_config_dir for a repoint) decide separately whether/how to install the result, which is what lets recheck_config_dir build the replacement before touching the existing entry (Codex P2 on PR #2980).

Source

fn start_watch( self: &Arc<Self>, agent_id: &str, parent_block_id: &str, config_dir: PathBuf, )

First-time registration for an agent_id: build the watch and, if it succeeds, install it (WatchedAgent, with parent_block_id as both the initial primary_block_id and sole member of parent_block_ids) and spawn its consumer loop. A no-op (nothing installed, nothing spawned) if build_watch fails — already logs its own reason, nothing further to report here.

Source

fn spawn_consumer_loop( self: &Arc<Self>, agent_id: &str, parent_block_id: &str, rx: UnboundedReceiver<PathBuf>, )

Spawn the debounced background task that consumes raw filesystem events for one agent’s watch and dispatches them to process_jsonl_change/process_journal_change. Shared by start_watch (first registration) and recheck_config_dir (repointing an existing watch at a corrected directory) — same consumer mechanics either way, just fed by a different rx.

Source

fn session_belongs_to_block(&self, block_id: &str, session_id: &str) -> bool

True if session_id is the session currently persisted on block_id’s own agent:sessionid meta. Used by the live filesystem-watch dispatch loop (watch_agent) to reject an event for a session this block doesn’t actually own before it can be misattributed — necessary because agents without a per-identity bundle override all share one notify subscription on the same default Claude config dir (see resolve_claude_config_dir), so any one of their watchers can receive any other’s raw file-change events. false for a block that no longer exists (closed pane) or has since moved to a different session — both cases mean this event isn’t this block’s to process. See docs/retro/retro-subagent-watcher-shared-dir-fanout-and-leak-2026-07-23.md.

Source

pub fn unwatch_agent(&self, agent_id: &str, block_id: Option<&str>)

Stop watching an agent from the graceful /agentmux/reactive/unregister path: remove block_id as a dependent of its watcher (same mechanism as unwatch_block, keyed by agent_id first since that’s what the caller has), tearing down the underlying filesystem watcher — which closes the debounce channel, so the processing task self-terminates on its next rx.recv() returning None — only once no block depends on it anymore. Idempotent: a no-op if the agent isn’t currently watched.

block_id is None when the reactive registry has no record of this agent_id (already unregistered, or never registered) — nothing to disassociate in that case, so the watcher-teardown step is skipped entirely rather than guessing.

watch_agent dedupes by agent_id: two blocks registering the same agent_id share one WatchedAgent entry. Removing that whole entry unconditionally here (as this method used to) killed the shared watcher — and with it, live tracking — for every OTHER still-open block sharing that agent identity the moment any ONE of them gracefully closed, since this is the primary, far-more-common teardown path (unwatch_block only covers the crash/API-delete backstop). See WatchedAgent::parent_block_ids’s doc comment and docs/retro/retro-subagent-watcher-shared-dir-fanout-and-leak-2026-07-23.md.

Without this, watched_agents was push-only: every distinct agent that ever ran leaked one OS watch handle + channel + idle task for the rest of the process lifetime, even after its pane/agent was deleted.

Also prunes sessions, dispatches, and pending_activity: every entry whose owner is this agent (SubAgent/AgentDispatch’s parent_agent, PendingDispatchActivity.parent_agent), and any session left with no subagents afterward. The parent agent is gone, so nothing can query this data again — it was previously left as plain data forever, growing these maps by one entry set per distinct agent that ever ran a subagent. (dispatches/pending_activity pruning added alongside prune_block’s block-scoped equivalent, below — this method previously only pruned sessions, silently leaking a Workflow-kind DispatchState for the agent’s lifetime.)

Source

pub fn prune_block(&self, block_id: &str) -> bool

Prune every subagent, dispatch, and buffered activity entry owned by block_id — the block-scoped counterpart of unwatch_agent’s agent-scoped pruning, above. This is the robust backstop: it runs from a srv_events_tx subscriber reacting to Event::BlockDeleted/ TabDeleted/WorkspaceDeleted (see spawn_block_prune_subscriber below), independent of whether the frontend’s normal /agentmux/reactive/unregister teardown path (which drives unwatch_agent) actually fires for this close — that path depends on a live renderer’s TermWrap.dispose() completing an async fetch, which an API-driven delete, a tab/workspace cascade delete, or a crash can all skip. Without this, closing an agent pane left its Swarm-pane row (and any subagents/dispatches under it) visible until the whole app/srv restarted — ListActive/ListDispatches kept returning them forever, and the frontend’s buildTree() has a parentIds fallback (swarm-model.ts) that renders a row for any block_id it still sees in the subagent list, with no complementary removal path of its own.

Block-scoped (not agent-name-scoped like unwatch_agent) so this also correctly handles an agent identity reused across multiple blocks over time — pruning by parent_block_id can never touch a different, still-live block that happens to share an agent name.

Also tears down this block’s own filesystem watcher (unwatch_block, below) — without this, prune_block only cleared the DERIVED subagent/dispatch state at the moment it ran, while the underlying notify watcher (and its dedicated tokio task, keyed by parent_block_id) kept running indefinitely, silently re-creating fresh entries for this closed block the next time any OTHER agent sharing its watched directory wrote to its own subagent transcript — see session_belongs_to_block’s doc comment and docs/retro/retro-subagent-watcher-shared-dir-fanout-and-leak-2026-07-23.md.

Returns whether any DERIVED state was actually pruned, so the caller only broadcasts a refresh when there’s something for clients to refresh — independent of whether a watcher also got torn down.

Source

fn unwatch_block(&self, block_id: &str)

Remove block_id as a dependent of its watcher, tearing down the underlying notify subscription (closing the debounce channel so the associated processing task self-terminates on its next rx.recv()) only once NO block depends on it anymore. Block-scoped, not agent-name-scoped — mirrors unwatch_agent’s teardown mechanism, but keyed by parent_block_id.

watch_agent dedupes by agent_id: a second block registering an already-watched agent_id adds itself to that entry’s parent_block_ids instead of getting its own watcher. Tearing down the whole entry the moment ANY one dependent block closes would kill live tracking for every other still-open block sharing that agent identity — removing just this block_id from the set (and only dropping the entry once the set is empty) keeps the watcher alive for as long as any block still needs it. Idempotent — a no-op if block_id isn’t a dependent of any watcher.

Source

fn broadcast_block_pruned(&self, block_id: &str)

Source

pub fn prune_block_and_notify(&self, block_id: &str)

Prune block_id and broadcast subagent:block_pruned if anything was actually removed — the combined operation spawn_block_prune_ subscriber calls per cascaded block_id.

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
§

impl<T> Downcast for T
where T: Any,

§

fn into_any(self: Box<T>) -> Box<dyn Any>

Convert Box<dyn Trait> (where Trait: Downcast) to Box<dyn Any>. Box<dyn Any> can then be further downcast into Box<ConcreteType> where ConcreteType implements Trait.
§

fn into_any_rc(self: Rc<T>) -> Rc<dyn Any>

Convert Rc<Trait> (where Trait: Downcast) to Rc<Any>. Rc<Any> can then be further downcast into Rc<ConcreteType> where ConcreteType implements Trait.
§

fn as_any(&self) -> &(dyn Any + 'static)

Convert &Trait (where Trait: Downcast) to &Any. This is needed since Rust cannot generate &Any’s vtable from &Trait’s.
§

fn as_any_mut(&mut self) -> &mut (dyn Any + 'static)

Convert &mut Trait (where Trait: Downcast) to &Any. This is needed since Rust cannot generate &mut Any’s vtable from &mut Trait’s.
§

impl<T> DowncastSync for T
where T: Any + Send + Sync,

§

fn into_any_arc(self: Arc<T>) -> Arc<dyn Any + Send + Sync> ⓘ

Convert Arc<Trait> (where Trait: Downcast) to Arc<Any>. Arc<Any> can then be further downcast into Arc<ConcreteType> where ConcreteType implements Trait.
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

§

impl<A, B, T> HttpServerConnExec<A, B> for T
where B: Body,

§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self> ⓘ

Instruments this type with the provided [Span], returning an Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self> ⓘ

Instruments this type with the current Span, returning an Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> IntoEither for T

Source§

fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ

Converts self into a Left variant of Either<Self, Self> if into_left is true. Converts self into a Right variant of Either<Self, Self> otherwise. Read more
Source§

fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
where F: FnOnce(&Self) -> bool,

Converts self into a Left variant of Either<Self, Self> if into_left(&self) returns true. Converts self into a Right variant of Either<Self, Self> otherwise. Read more
§

impl<T> PolicyExt for T
where T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where T: Sized + Policy<B, E>, P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where T: Sized + Policy<B, E>, P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self> ⓘ
where S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a [WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self> ⓘ

Attaches the current default Subscriber to this type, returning a [WithDispatch] wrapper. Read more