agentmux_srv/backend/subagent_watcher/scan.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Session/directory backfill scanning and stale-subagent reconciliation:
5//! `scan_session_subagents` (pane-reopen backfill entry point),
6//! `reconcile_stale_subagents` (Active -> Abandoned correction once a
7//! parent's turn is confirmed idle), `broadcast_subagents_abandoned`, and
8//! `scan_subagents_dir` (the capped cold-backfill file walk).
9
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12
13use super::completion;
14use super::parse::file_mtime;
15use super::types::*;
16use super::SubagentWatcher;
17
18/// How many times `reconcile_stale_subagents` retries when the parent
19/// block's controller isn't registered yet, before giving up and leaving
20/// the entries as-is. See that function's own doc comment (2026-09-02
21/// update) for why a single 2s attempt proved too short in practice —
22/// `MAX_RECONCILE_RETRIES * RECONCILE_RETRY_INTERVAL_MS` (~15s) is a
23/// realistic bound for a real controller to finish spawning under load,
24/// while still failing open rather than chasing a block forever.
25const MAX_RECONCILE_RETRIES: u32 = 5;
26const RECONCILE_RETRY_INTERVAL_MS: u64 = 3_000;
27
28/// Publish a `mps::EVENT_SUBAGENT_BACKFILL_STATUS` ping, scoped to this
29/// pane's own block id. No-op if `self.broker` was never wired (tests, or a
30/// `SubagentWatcher` built via bare `new()`) -- see the `broker` field's own
31/// doc comment in `mod.rs`. See
32/// docs/retro/retro-activity-dock-flicker-survives-debounce-fix-2026-08-24.md
33/// section 5.
34fn publish_backfill_status(watcher: &SubagentWatcher, parent_block_id: &str, status: &str) {
35 let Some(broker) = watcher.broker.lock().unwrap().clone() else { return };
36 broker.publish_event::<crate::backend::mps_events::SubagentBackfillStatus>(
37 &super::events::SubagentBackfillStatusEvent { status: status.to_string() },
38 vec![format!("block:{}", parent_block_id)],
39 2,
40 );
41}
42
43impl SubagentWatcher {
44 /// Backfill subagents that already existed before this pane (re)opened,
45 /// scoped to exactly the ONE session being resumed — not this agent
46 /// identity's entire history. Called from `handle_reactive_register`
47 /// when the block being registered already has a persisted
48 /// `agent:sessionid` (i.e. it's resuming a prior conversation, not
49 /// starting fresh); a brand-new session has nothing to backfill.
50 ///
51 /// Searches only the top level of `config_dir/projects/*` for a child
52 /// directory literally named `session_id` (the nested-per-session
53 /// layout observed in practice: `projects/<ws>/<session-uuid>/subagents/`)
54 /// — cheap, since an agent identity typically has few distinct project
55 /// dirs. Older/flat-layout installs where `subagents/` sits directly
56 /// under the project dir with no session-level folder have no way to
57 /// isolate one session's subagents from another's at the filesystem
58 /// level; in that case this intentionally finds nothing rather than
59 /// falling back to scanning everything.
60 pub fn scan_session_subagents(
61 &self,
62 parent_agent: &str,
63 parent_block_id: &str,
64 config_dir: &Path,
65 session_id: &str,
66 ) {
67 // "started"/"done" wrap the whole call, regardless of which of
68 // `scan_session_subagents_inner`'s several return paths is actually
69 // taken (nothing found at all, or found-and-processed) -- see
70 // `publish_backfill_status`'s own doc comment. A pane whose block
71 // has no persisted session id never calls this at all (see the
72 // caller in `server/reactive.rs`), so it never sees either status
73 // and its `ready()` gate is unaffected by this signal.
74 //
75 // reagentx P2 (PR #2781): generation-gated "done" -- see
76 // `backfill_generation`'s own doc comment in `mod.rs`. "started" is
77 // always published unconditionally; a stale/superseded call's own
78 // "started" is harmless (the gate just stays closed either way).
79 let my_generation = {
80 let mut gens = self.backfill_generation.lock().unwrap();
81 let next = gens.get(parent_block_id).copied().unwrap_or(0) + 1;
82 gens.insert(parent_block_id.to_string(), next);
83 next
84 };
85 publish_backfill_status(self, parent_block_id, "started");
86 self.scan_session_subagents_inner(parent_agent, parent_block_id, config_dir, session_id);
87 if self.is_backfill_generation_current(parent_block_id, my_generation) {
88 publish_backfill_status(self, parent_block_id, "done");
89 }
90 }
91
92 /// Whether `generation` (captured at the start of one
93 /// `scan_session_subagents` call) is still the most recent generation
94 /// recorded for `parent_block_id` — i.e. no NEWER overlapping call has
95 /// started since. See `backfill_generation`'s own doc comment in
96 /// `mod.rs`.
97 pub(super) fn is_backfill_generation_current(&self, parent_block_id: &str, generation: u64) -> bool {
98 self.backfill_generation.lock().unwrap().get(parent_block_id).copied() == Some(generation)
99 }
100
101 fn scan_session_subagents_inner(
102 &self,
103 parent_agent: &str,
104 parent_block_id: &str,
105 config_dir: &Path,
106 session_id: &str,
107 ) {
108 let projects_dir = config_dir.join("projects");
109 let Ok(walker) = std::fs::read_dir(&projects_dir) else { return };
110
111 for entry in walker.flatten() {
112 let session_dir = entry.path().join(session_id);
113 let subagents_dir = session_dir.join("subagents");
114 if subagents_dir.is_dir() {
115 // Correlates a pane (re)open with the session it's backfilling —
116 // needed to tell "this session was backfilled once" apart from
117 // "this session was backfilled repeatedly under different
118 // parent_block_ids" (a subagent's parent_block_id is fixed at
119 // first discovery, see process_jsonl_change; a mismatch here is
120 // the mechanism a NAME/grouping-dedup bug would leave a trail in).
121 //
122 // reagent (PR #2143 round 1): info!, not debug! — the default
123 // production EnvFilter (crates/srv/src/main.rs, "agentmuxsrv=
124 // info,info") drops debug-level lines unless RUST_LOG=debug is
125 // already set, which would make this diagnostic invisible in a
126 // normally-running srv, defeating the point of adding it.
127 tracing::info!(
128 agent = %parent_agent,
129 parent_block_id = %parent_block_id,
130 session_id = %session_id,
131 dir = %subagents_dir.display(),
132 "backfilling session subagents on pane (re)open"
133 );
134 self.scan_subagents_dir(parent_agent, parent_block_id, &subagents_dir);
135 self.reconcile_stale_subagents(parent_block_id, session_id);
136 return; // session ids are unique — no need to keep scanning
137 }
138 }
139 }
140
141 /// After a full session backfill scan, downgrade any subagent that's
142 /// still `Active` to `Abandoned` if its parent block's turn is not
143 /// currently active. A subagent runs inside its parent's own CLI
144 /// process — a Task-tool call is synchronous within the parent's turn —
145 /// so once the parent's turn has ended, any subagent file lacking a
146 /// terminal `Result` line was interrupted (crashed, killed, or the
147 /// app/srv restarted mid-task), not still running. This call site is
148 /// reached from `scan_session_subagents`, itself only invoked when a
149 /// block re-registers with a pre-existing session id (`reactive.rs`) —
150 /// i.e. a freshly-spawned controller whose turn genuinely hasn't
151 /// started yet, so every subagent found on disk predates this process
152 /// and is unambiguously history, not in-flight.
153 ///
154 /// Only reconciles on a *confirmed-idle* read (`Some(false)`).
155 /// `Some(true)` (confirmed active) skips with no follow-up — correct,
156 /// nothing to reconcile. `None` ("no controller registered yet") used
157 /// to be folded into the same "assume active, don't touch it" bucket as
158 /// confirmed-active, permanently — a previously-flagged, unconfirmed
159 /// race (`SPEC_SUBAGENT_LIVE_RECONCILIATION_AND_RETIRE_2026_07_20.md`
160 /// §5 Open Question 2) where this call site, reached from
161 /// `scan_session_subagents` at pane-reopen backfill time, can run
162 /// before the freshly-spawned controller has registered in
163 /// `CONTROLLER_REGISTRY`. Left unresolved, an entry hitting that race
164 /// stayed `Active`-looking forever, since nothing else ever revisits it
165 /// (see `SPEC_SWARM_DISPATCH_ATTRIBUTION_AND_LIFECYCLE_2026_08_19.md`
166 /// §2, mechanism 4). Fixed by treating `None` as "retry, don't give up
167 /// silently" (`retry_reconcile_once`) instead of a terminal no-op —
168 /// still conservative (a genuine `Some(true)` never retries; only the
169 /// ambiguous case does, and only up to `MAX_RECONCILE_RETRIES` times).
170 ///
171 /// 2026-09-02 (docs/retro/retro-activitydock-appears-on-agent-pane-load-2026-09-02.md):
172 /// the original version of this fix allowed exactly ONE 2s retry before
173 /// giving up permanently. Confirmed live on a real agent whose CLI
174 /// controller never registered at all (an expired provider login — the
175 /// process either never spawns or spawns and exits before reaching
176 /// `register_controller`): the single 2s retry window is frequently too
177 /// short to distinguish "controller is still starting up, wait" from
178 /// "controller is never coming" — for the FORMER case this left the
179 /// pane's Activity Dock showing genuinely-dead subagents as `Active`
180 /// indefinitely (no live turn-end ever fires to retry later either),
181 /// and for the LATTER case a couple of seconds is too optimistic a
182 /// bound for how long a real controller can legitimately take to spawn
183 /// and register under load. Extended to a bounded retry loop
184 /// (`MAX_RECONCILE_RETRIES` attempts, `RECONCILE_RETRY_INTERVAL_MS`
185 /// apart) — still fails open in bounded time (~`MAX_RECONCILE_RETRIES *
186 /// RECONCILE_RETRY_INTERVAL_MS`), just with a realistic window instead
187 /// of a single throw-away check.
188 ///
189 /// Called from two places as of SPEC_SUBAGENT_LIVE_RECONCILIATION_AND_
190 /// RETIRE_2026_07_20 Phase A: `scan_session_subagents` (reopen/backfill,
191 /// unchanged) and `blockcontroller::persistent`'s turn-end hook (live —
192 /// closes docs/specs/SPEC_SUBAGENT_LIFECYCLE_RECONCILIATION_2026_07_12.md
193 /// Open Question 1, which deliberately deferred real-time wiring).
194 /// `pub(crate)` so the live call site (a different module) can reach it.
195 /// Thin entry point — always starts the retry budget at
196 /// `MAX_RECONCILE_RETRIES`.
197 pub(crate) fn reconcile_stale_subagents(&self, parent_block_id: &str, session_id: &str) {
198 self.reconcile_stale_subagents_impl(parent_block_id, session_id, MAX_RECONCILE_RETRIES);
199 }
200
201 // pub(super), not private — `tests.rs` (a sibling module, not a
202 // descendant of `scan`) needs to call this directly with
203 // `retries_remaining: 0` to test the exhausted-retry path without
204 // depending on a real spawned watcher's tokio::spawn actually firing.
205 pub(super) fn reconcile_stale_subagents_impl(&self, parent_block_id: &str, session_id: &str, retries_remaining: u32) {
206 let turn_active = crate::backend::blockcontroller::get_block_controller_status(parent_block_id)
207 .map(|s| s.turn_active);
208 match turn_active {
209 Some(true) => {
210 tracing::info!(
211 parent_block_id = %parent_block_id,
212 session_id = %session_id,
213 "reconcile_stale_subagents: parent turn active — nothing to reconcile"
214 );
215 return;
216 }
217 // The pane was closed: its controller left the registry on
218 // purpose and will never register again. Retrying (~15s of
219 // "not yet registered" lines after every mid-turn close) would
220 // only chase a block that's gone.
221 None if self.parent_block_closed(parent_block_id) => {
222 tracing::debug!(
223 parent_block_id = %parent_block_id,
224 session_id = %session_id,
225 "reconcile_stale_subagents: parent block closed — nothing to reconcile"
226 );
227 return;
228 }
229 None if retries_remaining > 0 => {
230 tracing::info!(
231 parent_block_id = %parent_block_id,
232 session_id = %session_id,
233 retries_remaining,
234 "reconcile_stale_subagents: controller not yet registered — retrying, not giving up silently"
235 );
236 self.retry_reconcile_once(parent_block_id, session_id, retries_remaining - 1);
237 return;
238 }
239 None => {
240 tracing::info!(
241 parent_block_id = %parent_block_id,
242 session_id = %session_id,
243 "reconcile_stale_subagents: controller still not registered after all retries — giving up (bounded)"
244 );
245 return;
246 }
247 Some(false) => {} // confirmed idle — proceed below
248 }
249
250 // Scoped so the `sessions` lock is released before broadcasting (or
251 // touching `self.dispatches`, a separate mutex — never held at the
252 // same time as `sessions` anywhere in this function, to avoid any
253 // lock-ordering deadlock risk against other call sites) below —
254 // `broadcast_subagents_abandoned` doesn't need it, and this call
255 // site can now run live (Phase A), not just at reopen, so holding
256 // the lock any longer than the mutation itself is unnecessary
257 // contention against the live filesystem watcher.
258 //
259 // Alongside `reconciled_agent_ids`, also snapshot every member's
260 // (dispatch_id, status) for this block — not just the ones just
261 // reconciled — so the Workflow-dispatch aggregation pass below has
262 // the full picture per dispatch_id, not just this pass's deltas.
263 // Which of this block's still-`Active` members actually RETURNED.
264 //
265 // Until this existed, every one of them was downgraded to `Abandoned`
266 // on the reasoning that a subagent file without a terminal `Result`
267 // line must have been cut off. That premise is false: AgentMux
268 // transcripts contain no `"type":"result"` line at all, so the
269 // inference fired on healthy dispatches too and the Swarm pane showed
270 // successful Agent-tool calls as "interrupted". `completion.rs` asks a
271 // question the format can actually answer — did the PARENT record a
272 // `tool_result` for this dispatch's `tool_use_id`. See
273 // docs/reports/REPORT_SUBAGENT_COMPLETION_NEVER_DETECTED_2026_09_05.md.
274 //
275 // Resolved BEFORE taking the `sessions` lock, and in two phases: read
276 // the candidate list under a short lock, do the file IO (a parent
277 // transcript is routinely tens of MB) unlocked, then apply. This
278 // function is deliberately careful not to hold that mutex across
279 // anything slow — see the scoping comment below. The apply pass
280 // re-checks `Active`, so a member whose status moved in between is
281 // left alone rather than clobbered by a stale decision.
282 let resolved_statuses: HashMap<String, SubAgentStatus> = {
283 let candidates: Vec<(String, PathBuf)> = {
284 let sessions = self.sessions.lock().unwrap();
285 sessions
286 .get(session_id)
287 .map(|session| {
288 session
289 .subagents
290 .values()
291 .filter(|s| {
292 s.info.parent_block_id == parent_block_id
293 && s.info.status == SubAgentStatus::Active
294 })
295 .map(|s| {
296 (s.info.agent_id.clone(), PathBuf::from(&s.info.jsonl_path))
297 })
298 .collect()
299 })
300 .unwrap_or_default()
301 };
302 // Each candidate resolves its OWN parent transcript, with reads
303 // cached per distinct path. Resolving once from an arbitrary
304 // member and reusing it for the rest was wrong twice over
305 // (reagent P1 on #3007): Workflow-tool members sit one level
306 // deeper (`subagents/workflows/<run-id>/`) so they don't all
307 // share a resolution, and `candidates` comes from a HashMap with
308 // unspecified iteration order — so in a mixed batch an
309 // unresolvable member landing first would zero the map and drag
310 // every Solo member into `Abandoned` too, non-deterministically.
311 let mut results_by_parent: HashMap<PathBuf, HashMap<String, bool>> = HashMap::new();
312 candidates
313 .into_iter()
314 .map(|(agent_id, path)| {
315 let status = match completion::parent_transcript_for(&path) {
316 Some(parent) => {
317 let results = results_by_parent
318 .entry(parent.clone())
319 .or_insert_with(|| completion::parent_tool_results_at(&parent));
320 let tool_use_id = completion::tool_use_id_for(&path);
321 completion::terminal_status(tool_use_id.as_deref(), results)
322 }
323 // No `subagents` ancestor — unrecognised layout.
324 // Fall back to the historical inference.
325 None => SubAgentStatus::Abandoned,
326 };
327 (agent_id, status)
328 })
329 .collect()
330 };
331
332 let (reconciled_agent_ids, completed_members, member_statuses_by_dispatch): (
333 Vec<String>,
334 Vec<(String, String, usize)>,
335 HashMap<String, Vec<SubAgentStatus>>,
336 ) = {
337 let mut sessions = self.sessions.lock().unwrap();
338 let Some(session) = sessions.get_mut(session_id) else { return };
339 // A session's subagent map can hold entries from a DIFFERENT block —
340 // the watcher dedupes purely by agent_id (see watch_agent's doc
341 // comment), so two blocks that both ran the same underlying Claude
342 // session id (e.g. one reattached to a session another block also
343 // touched) can have subagents from both mixed into one
344 // `SessionWatch`. We only have a confirmed-idle read for THIS one
345 // `parent_block_id` — a sibling block's subagent could easily still
346 // be genuinely active, so only reconcile entries this block itself
347 // owns (mirrors unwatch_agent's own parent-scoped filter). muxreview
348 // P1 on PR #2131.
349 let mut reconciled_agent_ids = Vec::new();
350 let mut completed_members: Vec<(String, String, usize)> = Vec::new();
351 let mut member_statuses_by_dispatch: HashMap<String, Vec<SubAgentStatus>> =
352 std::collections::HashMap::new();
353 for state in session.subagents.values_mut() {
354 if state.info.parent_block_id != parent_block_id {
355 continue;
356 }
357 if state.info.status == SubAgentStatus::Active {
358 // Absent a resolution (no sidecar, unreadable parent, an
359 // entry that appeared after the snapshot above) fall back
360 // to the historical `Abandoned` — never optimistically
361 // complete something we could not correlate.
362 let resolved = resolved_statuses
363 .get(&state.info.agent_id)
364 .cloned()
365 .unwrap_or(SubAgentStatus::Abandoned);
366 state.info.status = resolved.clone();
367 if resolved == SubAgentStatus::Completed {
368 tracing::info!(
369 agent_id = %state.info.agent_id,
370 parent_block_id = %parent_block_id,
371 session_id = %session_id,
372 dispatch_id = %state.info.dispatch_id,
373 "subagent reconciled: active -> completed (parent recorded a tool_result)"
374 );
375 completed_members.push((
376 state.info.agent_id.clone(),
377 state.info.dispatch_id.clone(),
378 state.info.event_count,
379 ));
380 member_statuses_by_dispatch
381 .entry(state.info.dispatch_id.clone())
382 .or_default()
383 .push(state.info.status.clone());
384 continue;
385 }
386 reconciled_agent_ids.push(state.info.agent_id.clone());
387 // Every field a NAME-based grouping/dedup bug needs to
388 // reconstruct offline: which subagent, which dispatch,
389 // which display_name it had already resolved (grouping is
390 // keyed on this), and which block/session it's bound to.
391 tracing::info!(
392 agent_id = %state.info.agent_id,
393 parent_block_id = %parent_block_id,
394 session_id = %session_id,
395 dispatch_id = %state.info.dispatch_id,
396 display_name = ?state.info.display_name,
397 slug = %state.info.slug,
398 "subagent reconciled: active -> abandoned (parent turn ended)"
399 );
400 }
401 member_statuses_by_dispatch
402 .entry(state.info.dispatch_id.clone())
403 .or_default()
404 .push(state.info.status.clone());
405 }
406 (
407 reconciled_agent_ids,
408 completed_members,
409 member_statuses_by_dispatch,
410 )
411 };
412 // Push the corrections the same way the abandoned ones are pushed —
413 // an already-open Swarm pane otherwise keeps showing these as running
414 // until some unrelated reload happens to refresh it.
415 for (agent_id, dispatch_id, total_events) in &completed_members {
416 self.broadcast_subagent_completed(
417 parent_block_id,
418 agent_id,
419 dispatch_id,
420 *total_events,
421 );
422 }
423 if !reconciled_agent_ids.is_empty() {
424 tracing::info!(
425 parent_block_id = %parent_block_id,
426 session_id = %session_id,
427 reconciled = reconciled_agent_ids.len(),
428 "reconcile_stale_subagents: pass complete"
429 );
430 self.broadcast_subagents_abandoned(parent_block_id, &reconciled_agent_ids);
431 }
432
433 // Propagate to the owning Workflow-kind `AgentDispatch` aggregate —
434 // a Solo dispatch needs no separate step (its status is synthesized
435 // directly from its one member's status, see `solo_dispatch`).
436 // SPEC_SWARM_DISPATCH_ATTRIBUTION_AND_LIFECYCLE_2026_08_19.md §3.2:
437 // Abandoned iff every member is Completed|Abandoned and at least one
438 // is Abandoned — otherwise leave the existing counts-based Running/
439 // Completed status (set by `refresh_dispatch_status`) alone.
440 let abandoned_dispatch_infos: Vec<AgentDispatch> = {
441 let mut dispatches = self.dispatches.lock().unwrap();
442 let mut updated = Vec::new();
443 for (dispatch_id, statuses) in &member_statuses_by_dispatch {
444 if dispatch_id.starts_with("solo:") {
445 continue;
446 }
447 let Some(state) = dispatches.get_mut(dispatch_id) else { continue };
448 // reagent P2 on PR #2677: `statuses` only reflects members
449 // currently visible in `session.subagents` — a member whose
450 // JSONL file the filesystem watcher hasn't picked up yet
451 // (an async notify/debounce lag racing this exact
452 // reconciliation pass) is invisible here, so `all_done`
453 // could be true against an INCOMPLETE member set. Cross-
454 // check against the dispatch's own authoritative
455 // `member_count` (tracked separately via journal_started/
456 // member_files) — only trust `all_done` when we've actually
457 // seen status for every member the dispatch itself believes
458 // exist. Under-counting here is safe (skip this round, the
459 // next new-evidence event or reconciliation pass reruns
460 // this check with a fuller picture) — over-counting would
461 // risk abandoning a dispatch with a member reconciliation
462 // hasn't even observed yet.
463 if statuses.len() < state.info.member_count {
464 continue;
465 }
466 let all_done = statuses
467 .iter()
468 .all(|s| matches!(s, SubAgentStatus::Completed | SubAgentStatus::Abandoned));
469 let any_abandoned = statuses.iter().any(|s| *s == SubAgentStatus::Abandoned);
470 if !(all_done && any_abandoned) {
471 continue;
472 }
473 if state.info.status != DispatchStatus::Abandoned {
474 state.info.status = DispatchStatus::Abandoned;
475 tracing::info!(
476 dispatch_id = %dispatch_id,
477 parent_block_id = %parent_block_id,
478 session_id = %session_id,
479 member_count = statuses.len(),
480 "dispatch reconciled: -> abandoned (all members done, at least one abandoned)"
481 );
482 updated.push(state.info.clone());
483 }
484 }
485 updated
486 };
487 for info in &abandoned_dispatch_infos {
488 self.broadcast_dispatch_updated(info);
489 }
490 }
491
492 /// Bounded retry for `reconcile_stale_subagents`'s `None`
493 /// (controller-not-yet-registered) case — see that function's doc
494 /// comment for why a single 2s attempt was too short in practice.
495 /// `retries_remaining` counts DOWN — the loop terminates because each
496 /// hop passes a strictly smaller budget, never because of a separate
497 /// flag; a block whose controller genuinely never registers (e.g. it
498 /// was deleted, or its provider login never resolves) still gets a
499 /// bounded, not unbounded, chase.
500 ///
501 /// Mirrors `trigger_eager_naming`'s `self_ref` upgrade-to-`Arc` pattern
502 /// for spawning a task that outlives this sync call; silently no-ops
503 /// for a bare `new()` watcher (most unit tests), same "untracked ->
504 /// safe no-op" convention as that method.
505 /// The parent block is mid-close, or no longer exists — either way no
506 /// controller for it will ever register again. A failed store read is
507 /// "unknown", not "closed", so it keeps the bounded retry.
508 /// SPEC_AGENT_PANE_CLOSE_GRACEFUL_SHUTDOWN_2026_09_18.md (graceful close
509 /// removes the controller before the block record).
510 pub(super) fn parent_block_closed(&self, parent_block_id: &str) -> bool {
511 crate::backend::blockcontroller::is_closing(parent_block_id)
512 || matches!(self.mstore.get::<crate::backend::obj::Block>(parent_block_id), Ok(None))
513 }
514
515 fn retry_reconcile_once(&self, parent_block_id: &str, session_id: &str, retries_remaining: u32) {
516 let Some(watcher) = self.self_ref.lock().unwrap().as_ref().and_then(|w| w.upgrade()) else {
517 return;
518 };
519 let parent_block_id = parent_block_id.to_string();
520 let session_id = session_id.to_string();
521 tokio::spawn(async move {
522 tokio::time::sleep(std::time::Duration::from_millis(RECONCILE_RETRY_INTERVAL_MS)).await;
523 watcher.reconcile_stale_subagents_impl(&parent_block_id, &session_id, retries_remaining);
524 });
525 }
526
527 /// One batched broadcast per reconciliation pass (not one per
528 /// subagent) — a pass can reconcile many subagents at once (e.g. a
529 /// workflow with dozens of members whose parent turn just ended), and
530 /// the frontend only needs "something changed, reload" (`swarm-model.ts`'s
531 /// `scheduleLoadSubagents`), not per-agent granularity. Mirrors the
532 /// batch-not-spam precedent `dispatch:activity` already established in
533 /// this file for the same reason.
534 ///
535 /// The frontend `muxEventSubscribe({ eventType: "subagent:abandoned",
536 /// ... })` listener lands in the immediately-following PR (Phase B,
537 /// #2235 — SPEC_SUBAGENT_LIVE_RECONCILIATION_AND_RETIRE_2026_07_20),
538 /// not this one. Broadcasting with no subscriber yet is inert, not
539 /// harmful: this PR's actual goal (subagents resolving to `Completed`/
540 /// `Abandoned` correctly in the backend, live, not just at reopen) is
541 /// already fully achieved without it — the missing piece until #2235
542 /// merges is only "push the correction to an already-open Swarm pane
543 /// immediately" (an already-open pane still picks up the correction on
544 /// its next unrelated reload in the meantime).
545 fn broadcast_subagents_abandoned(&self, parent_block_id: &str, agent_ids: &[String]) {
546 self.event_bus.broadcast_typed::<crate::backend::mps_events::SubagentAbandoned>(&super::events::SubagentAbandonedEvent {
547 parent_block_id: parent_block_id.to_string(),
548 agent_ids: agent_ids.to_vec(),
549 });
550 }
551
552 /// The `Completed` counterpart to `broadcast_subagents_abandoned` above,
553 /// for members reconciliation resolved as having actually returned (see
554 /// `completion.rs`). Emits the same `subagent:completed` event `jsonl.rs`
555 /// already sends when it observes a completion live, so a client needs no
556 /// new handling for the reconcile path — it just stops being told
557 /// "abandoned" for dispatches that returned.
558 ///
559 /// Per-agent rather than batched, unlike its `Abandoned` sibling: the
560 /// payload carries per-member fields (`totalEvents`, `dispatchId`) that a
561 /// batched shape has nowhere to put, and matching `jsonl.rs`'s existing
562 /// event exactly is worth more here than saving a broadcast — a reconcile
563 /// pass typically resolves a handful of completions, not the dozens the
564 /// batching rationale above was written for.
565 ///
566 /// `parentAgent` is omitted (the live emitter includes it): reconcile is
567 /// keyed on the block, and no consumer reads that field.
568 fn broadcast_subagent_completed(
569 &self,
570 parent_block_id: &str,
571 agent_id: &str,
572 dispatch_id: &str,
573 total_events: usize,
574 ) {
575 self.event_bus.broadcast_typed::<crate::backend::mps_events::SubagentCompleted>(&super::events::SubagentCompletedEvent {
576 agent_id: agent_id.to_string(),
577 parent_agent: None,
578 parent_block_id: parent_block_id.to_string(),
579 total_events: total_events,
580 dispatch_id: dispatch_id.to_string(),
581 });
582 }
583
584 /// Process agent-*.jsonl directly in `dir`, plus workflow runs under
585 /// `dir/workflows/<wf>/` (member agent files + journal).
586 ///
587 /// A pane's `subagents/` directory accumulates forever — every Task-tool
588 /// and Workflow-tool run this session has ever made leaves its
589 /// `agent-*.jsonl` files behind. Because `is_new` (`process_jsonl_change`)
590 /// is keyed off the in-memory `sessions` map, which starts empty on every
591 /// srv process (restart or fresh start), an unbounded scan here replays
592 /// the session's ENTIRE history — full-file read + parse + WS broadcast
593 /// per file — on every pane reopen or srv restart. On a heavily-used pane
594 /// this is a genuine crash trigger, not just wasted work: a live incident
595 /// (see docs/retro/retro-subagent-backfill-storm-oom-2026-07-17.md) hit
596 /// 1,000+ replayed files across three back-to-back srv restarts in under
597 /// 10 seconds, on a machine already near its commit ceiling — the
598 /// resulting allocation/broadcast storm is a plausible contributor to
599 /// each restart re-crashing before the launcher's 3-strikes budget gave
600 /// up and killed the whole app.
601 ///
602 /// Bound the replay to the `BACKFILL_MAX_FILES` most-recently-modified
603 /// agent files (by mtime) — recent activity is what the Swarm pane's
604 /// reopen backfill exists to show; a months-old workflow run replaying on
605 /// every restart serves no one. Skipped files are still on disk and
606 /// still show up via `list_active`'s normal file-count telemetry if
607 /// something later reads them directly — this only bounds the *push*
608 /// (broadcast) side of a cold backfill.
609 fn scan_subagents_dir(&self, parent_agent: &str, parent_block_id: &str, dir: &Path) {
610 let mut candidates: Vec<(std::time::SystemTime, PathBuf)> = Vec::new();
611
612 if let Ok(files) = std::fs::read_dir(dir) {
613 for file in files.flatten() {
614 let path = file.path();
615 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
616 if name.starts_with("agent-") && name.ends_with(".jsonl") {
617 candidates.push((file_mtime(&path), path));
618 }
619 }
620 }
621 }
622
623 let workflows_dir = dir.join("workflows");
624 let mut journals: Vec<PathBuf> = Vec::new();
625 if let Ok(runs) = std::fs::read_dir(&workflows_dir) {
626 for run in runs.flatten() {
627 if let Ok(files) = std::fs::read_dir(run.path()) {
628 for file in files.flatten() {
629 let path = file.path();
630 match path.file_name().and_then(|n| n.to_str()) {
631 Some(name)
632 if name.starts_with("agent-") && name.ends_with(".jsonl") =>
633 {
634 candidates.push((file_mtime(&path), path));
635 }
636 // Journals are one small file per run (not one per
637 // member agent) — always process them so
638 // `workflow:updated`/run status stay accurate
639 // regardless of the member-file cap below.
640 Some("journal.jsonl") => journals.push(path),
641 _ => {}
642 }
643 }
644 }
645 }
646 }
647
648 let total = candidates.len();
649 candidates.sort_by(|a, b| b.0.cmp(&a.0)); // newest mtime first
650 let skipped = total.saturating_sub(BACKFILL_MAX_FILES);
651 if skipped > 0 {
652 tracing::info!(
653 agent = %parent_agent,
654 parent_block_id = %parent_block_id,
655 dir = %dir.display(),
656 total,
657 skipped,
658 cap = BACKFILL_MAX_FILES,
659 "scan_subagents_dir: capping cold-backfill replay to the most recent files"
660 );
661 }
662 for (_, path) in candidates.into_iter().take(BACKFILL_MAX_FILES) {
663 // live=false: this is a cold-backfill replay (pane reopen / srv
664 // restart), not a genuinely-live spawn — must never trigger eager
665 // Haiku naming (see process_jsonl_change's `live` param doc).
666 self.process_jsonl_change(parent_agent, parent_block_id, &path, false);
667 }
668 for path in journals {
669 self.process_journal_change(parent_agent, parent_block_id, &path);
670 }
671 }
672}