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
impl SubagentWatcher
Sourcepub(super) fn process_jsonl_change(
&self,
parent_agent: &str,
parent_block_id: &str,
jsonl_path: &Path,
live: bool,
)
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.
Sourcepub(super) fn flush_pending_dispatch_activity(&self)
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.
Sourcefn broadcast_member_spawned(
&self,
spawn: &PendingSpawn,
parent_agent: &str,
parent_block_id: &str,
session_id: &str,
dispatch_id: &str,
)
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.
Sourcefn broadcast_member_completed(
&self,
done: &PendingCompletion,
parent_agent: &str,
parent_block_id: &str,
session_id: &str,
dispatch_id: &str,
)
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.
Sourcefn update_dispatch_membership(
&self,
workflow_id: &str,
parent_agent: &str,
parent_block_id: &str,
session_id: &str,
member_spawned: bool,
member_completed: bool,
)
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.
Sourcefn queue_dispatch_updated(
&self,
dispatch_id: &str,
parent_agent: &str,
parent_block_id: &str,
session_id: &str,
info: AgentDispatch,
)
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).
Sourcepub(super) fn process_journal_change(
&self,
parent_agent: &str,
parent_block_id: &str,
journal_path: &Path,
)
pub(super) fn process_journal_change( &self, parent_agent: &str, parent_block_id: &str, journal_path: &Path, )
Process a changed workflow journal (subagents/workflows/started/result records and broadcast dispatch:updated.
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
Sourcefn refresh_dispatch_info(state: &mut DispatchState)
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.
Sourcepub(super) fn recompute_dispatch_status(state: &mut DispatchState)
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.
Sourcepub(super) fn refresh_dispatch_status(state: &mut DispatchState)
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.
Sourcepub(super) fn broadcast_subagent_named(
&self,
agent_id: &str,
display_name: &str,
)
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.
Sourcepub(super) fn broadcast_subagent_updated(
&self,
agent_id: &str,
parent_block_id: &str,
)
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.
pub(super) fn broadcast_dispatch_updated(&self, info: &AgentDispatch)
Source§impl SubagentWatcher
impl SubagentWatcher
Sourcepub(super) const SIDECAR_GRACE: Duration
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.
Sourcepub fn list_active(&self) -> Vec<SubAgent>
pub fn list_active(&self) -> Vec<SubAgent>
List all subagents across all sessions (sync — safe to call from RPC dispatch).
Sourcepub fn list_dispatches(&self) -> Vec<AgentDispatch>
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.
Sourcepub(super) fn solo_dispatch(sub: &SubAgent) -> Option<AgentDispatch>
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.
Sourcepub fn get_history(&self, agent_id: &str, limit: usize) -> Vec<SubagentEvent>
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).
Sourcepub fn get_info(&self, agent_id: &str) -> Option<SubAgent>
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.
Sourcepub fn set_display_name(&self, agent_id: &str, display_name: &str) -> bool
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.
Sourcepub fn set_dispatch_name(&self, dispatch_id: &str, name: &str) -> bool
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.
Sourcepub(super) fn trigger_eager_naming(
&self,
dispatch_id: String,
first_member_agent_id: String,
is_workflow: bool,
)
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.
Sourcepub(super) fn select_unnamed_backlog(
&self,
limit: usize,
) -> Vec<BacklogNamingItem>
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).
Sourcepub(crate) async fn resolve_unnamed_backlog(self: Arc<Self>)
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
impl SubagentWatcher
Sourcepub fn scan_session_subagents(
&self,
parent_agent: &str,
parent_block_id: &str,
config_dir: &Path,
session_id: &str,
)
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.
Sourcepub(super) fn is_backfill_generation_current(
&self,
parent_block_id: &str,
generation: u64,
) -> bool
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.
fn scan_session_subagents_inner( &self, parent_agent: &str, parent_block_id: &str, config_dir: &Path, session_id: &str, )
Sourcepub(crate) fn reconcile_stale_subagents(
&self,
parent_block_id: &str,
session_id: &str,
)
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.
pub(super) fn reconcile_stale_subagents_impl( &self, parent_block_id: &str, session_id: &str, retries_remaining: u32, )
Sourcepub(super) fn parent_block_closed(&self, parent_block_id: &str) -> bool
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).
fn retry_reconcile_once( &self, parent_block_id: &str, session_id: &str, retries_remaining: u32, )
Sourcefn broadcast_subagents_abandoned(
&self,
parent_block_id: &str,
agent_ids: &[String],
)
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).
Sourcefn broadcast_subagent_completed(
&self,
parent_block_id: &str,
agent_id: &str,
dispatch_id: &str,
total_events: usize,
)
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.
Sourcefn scan_subagents_dir(
&self,
parent_agent: &str,
parent_block_id: &str,
dir: &Path,
)
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
impl SubagentWatcher
pub fn new( event_bus: Arc<EventBus>, mstore: Arc<Store>, id_store: Arc<Store>, identity_store: Arc<Store>, ) -> Self
Sourcepub fn set_broker(&self, broker: Arc<Broker>)
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.
Sourcepub fn spawn(
event_bus: Arc<EventBus>,
mstore: Arc<Store>,
id_store: Arc<Store>,
identity_store: Arc<Store>,
) -> Arc<Self> ⓘ
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.
Sourcepub fn watch_agent(
self: &Arc<Self>,
agent_id: &str,
parent_block_id: &str,
config_dir: PathBuf,
)
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.
Sourcepub fn recheck_config_dir(
self: &Arc<Self>,
agent_id: &str,
new_config_dir: PathBuf,
)
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.
Sourcepub fn recheck_all_watched_agents(self: &Arc<Self>)
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.
Sourcefn build_watch(
self: &Arc<Self>,
agent_id: &str,
config_dir: &Path,
) -> Option<(RecommendedWatcher, UnboundedReceiver<PathBuf>)>
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).
Sourcefn start_watch(
self: &Arc<Self>,
agent_id: &str,
parent_block_id: &str,
config_dir: PathBuf,
)
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.
Sourcefn spawn_consumer_loop(
self: &Arc<Self>,
agent_id: &str,
parent_block_id: &str,
rx: UnboundedReceiver<PathBuf>,
)
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.
Sourcefn session_belongs_to_block(&self, block_id: &str, session_id: &str) -> bool
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.
Sourcepub fn unwatch_agent(&self, agent_id: &str, block_id: Option<&str>)
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.)
Sourcepub fn prune_block(&self, block_id: &str) -> bool
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.
Sourcefn unwatch_block(&self, block_id: &str)
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.
fn broadcast_block_pruned(&self, block_id: &str)
Sourcepub fn prune_block_and_notify(&self, block_id: &str)
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§
impl !Freeze for SubagentWatcher
impl RefUnwindSafe for SubagentWatcher
impl Send for SubagentWatcher
impl Sync for SubagentWatcher
impl Unpin for SubagentWatcher
impl UnsafeUnpin for SubagentWatcher
impl UnwindSafe for SubagentWatcher
Blanket Implementations§
Source§impl<T> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
§impl<T> Downcast for Twhere
T: Any,
impl<T> Downcast for Twhere
T: Any,
§fn into_any(self: Box<T>) -> Box<dyn Any>
fn into_any(self: Box<T>) -> Box<dyn Any>
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>
fn into_any_rc(self: Rc<T>) -> Rc<dyn Any>
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)
fn as_any(&self) -> &(dyn Any + 'static)
&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)
fn as_any_mut(&mut self) -> &mut (dyn Any + 'static)
&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
impl<T> DowncastSync for T
impl<A, B, T> HttpServerConnExec<A, B> for Twhere
B: Body,
§impl<T> Instrument for T
impl<T> Instrument for T
§fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
Source§impl<T> IntoEither for T
impl<T> IntoEither for T
Source§fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
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 moreSource§fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
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