Skip to main content

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}