Skip to main content

agentmux_srv/backend/subagent_watcher/
jsonl.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The JSONL/journal file-change state machine: `process_jsonl_change` (one
5//! continuous state machine, moved whole rather than decomposed further) and
6//! its dispatch-membership/coalescing plumbing — `flush_pending_dispatch_
7//! activity`, `update_dispatch_membership`, `queue_dispatch_updated`,
8//! `process_journal_change`, `dispatch_entry`, `refresh_dispatch_info`,
9//! `refresh_dispatch_status`, `broadcast_dispatch_updated`.
10
11use std::collections::HashMap;
12use std::path::Path;
13
14use super::parse::*;
15use super::types::*;
16use super::SubagentWatcher;
17
18impl SubagentWatcher {
19    /// Process a changed/new JSONL subagent file. Reads new lines, updates state,
20    /// and broadcasts events via EventBus.
21    ///
22    /// `live`: `true` only when this call originates from the filesystem
23    /// watcher observing a real, in-the-moment change; `false` for a
24    /// cold-backfill replay (`scan_subagents_dir`, capped at
25    /// `BACKFILL_MAX_FILES`, run on pane reopen / srv restart). Eager
26    /// Haiku-naming (see the `is_new` handling below) is gated on `live` —
27    /// without this, every restart of a long-lived session would re-fire a
28    /// naming call for every dispatch replayed from history, repeating the
29    /// broadcast-storm incident class in
30    /// docs/retro/retro-subagent-backfill-storm-oom-2026-07-17.md, just for
31    /// Haiku spend instead of WS events.
32    pub(super) fn process_jsonl_change(&self, parent_agent: &str, parent_block_id: &str, jsonl_path: &Path, live: bool) {
33        // Extract agent ID from filename: agent-<id>.jsonl
34        let agent_id = match jsonl_path
35            .file_stem()
36            .and_then(|s| s.to_str())
37            .and_then(|s| s.strip_prefix("agent-"))
38        {
39            Some(id) => id.to_string(),
40            None => return,
41        };
42
43        // Session id = the directory containing subagents/. Workflow member
44        // files are nested deeper (subagents/workflows/<wf>/agent-*.jsonl),
45        // so walk ancestors instead of assuming a fixed depth.
46        let session_id = derive_session_id(jsonl_path);
47        let workflow_id = parse_workflow_id(jsonl_path);
48        // Every SubAgent has a real dispatch_id now — a solo Task-tool call
49        // gets a synthesized one (SPEC §5), not just workflow members.
50        let dispatch_id = workflow_id.clone().unwrap_or_else(|| solo_dispatch_id(&agent_id));
51
52        // Read the current offset before locking (so file I/O is outside the lock)
53        let current_offset = {
54            let sessions = self.sessions.lock().unwrap();
55            sessions
56                .get(&session_id)
57                .and_then(|s| s.subagents.get(&agent_id))
58                .map(|s| s.file_offset)
59                .unwrap_or(0)
60        };
61
62        // Do file I/O outside the mutex lock
63        let (new_events, new_offset, meta) = match read_jsonl_from_offset(jsonl_path, current_offset) {
64            Ok(result) => result,
65            Err(e) => {
66                tracing::debug!(
67                    agent_id = %agent_id,
68                    error = %e,
69                    "failed to read subagent JSONL"
70                );
71                return;
72            }
73        };
74
75        // Now lock and update state
76        let (is_new, info_snapshot, completed, id_learned, name_learned) = {
77            let mut sessions = self.sessions.lock().unwrap();
78            let session = sessions
79                .entry(session_id.clone())
80                .or_insert_with(|| SessionWatch {
81                    subagents: HashMap::new(),
82                });
83
84            let is_new = !session.subagents.contains_key(&agent_id);
85            // A subagent's parent_block_id is stamped once, at first
86            // discovery (below), and never updated on later re-scans of the
87            // same session — see the entry's doc comment. If this session is
88            // being (re)scanned under a DIFFERENT block_id than the one this
89            // subagent was originally bound to (e.g. the pane was reopened
90            // under a new block), the subagent stays attributed to its
91            // original, now possibly-stale parent_block_id. That's a direct
92            // candidate mechanism for a subagent silently falling outside
93            // the block reconcile_stale_subagents/buildTree() expect it
94            // under — log it so it's visible without needing to reproduce.
95            if let Some(existing) = session.subagents.get(&agent_id) {
96                if existing.info.parent_block_id != parent_block_id {
97                    // reagent (PR #2143 round 1, P1): info!, not debug! — this
98                    // is the single most likely diagnostic trail for the
99                    // duplication bug this PR exists to help find; at debug!
100                    // it would be silently dropped by the default production
101                    // filter and never actually appear when it matters.
102                    tracing::info!(
103                        agent_id = %agent_id,
104                        session_id = %session_id,
105                        existing_parent_block_id = %existing.info.parent_block_id,
106                        rescanned_parent_block_id = %parent_block_id,
107                        "subagent re-observed under a different parent_block_id than its original — parent_block_id NOT updated"
108                    );
109                }
110            }
111            // A replayed spawn (`live == false`) describes a subagent whose
112            // transcript already existed on disk when this pane opened.
113            // Inserting it as `Active` asserts something the replay cannot
114            // know, and `reconcile_stale_subagents` (scan.rs) then retracts it
115            // moments later — up to 200 spurious abandon broadcasts per reopen,
116            // and a window where `subagent.ListActive` reports rows the backend
117            // is about to disown. See
118            // docs/reports/REPORT_AGENT_PANE_LOAD_RENDER_ARCHITECTURE_2026_08_27.md §2.
119            //
120            // Decided here using the SAME predicate reconcile uses, not a
121            // second independently-invented one — a confirmed-idle parent turn
122            // means nothing on disk can still be running. Two deliberate
123            // fallbacks to `Active`:
124            //   - `live` always wins. `turn_active` can lag a genuine spawn,
125            //     and mislabelling a real subagent would break live rows.
126            //   - An unregistered controller is UNKNOWN, not idle.
127            //     `scan_session_subagents` can run before the controller
128            //     registers, so this keeps reconcile's own retry path as the
129            //     authority rather than inferring `Abandoned` from absent
130            //     information.
131            let initial_status = if live {
132                SubAgentStatus::Active
133            } else {
134                match crate::backend::blockcontroller::get_block_controller_status(parent_block_id)
135                    .map(|s| s.turn_active)
136                {
137                    Some(false) => SubAgentStatus::Abandoned,
138                    _ => SubAgentStatus::Active,
139                }
140            };
141            // Real spawn time for a REPLAYED file, not replay time. The
142            // events were just parsed from a transcript that may be months
143            // old; stamping `now_millis()` made every backfilled subagent
144            // claim to have started at pane-open, which both misreported
145            // elapsed time and corrupted the dock's newest-first ordering.
146            // Falls back to `now_millis()` only when the file yielded no
147            // parseable event at all (a genuinely new/empty transcript,
148            // where "now" is the honest answer). See
149            // docs/retro/retro-activitydock-appears-on-agent-pane-load-2026-09-02.md.
150            let initial_spawned_at = new_events
151                .iter()
152                .map(|e| e.timestamp)
153                .min()
154                .unwrap_or_else(now_millis);
155            let state = session.subagents.entry(agent_id.clone()).or_insert_with(|| {
156                SubagentState {
157                    info: SubAgent {
158                        agent_id: agent_id.clone(),
159                        slug: String::new(),
160                        jsonl_path: jsonl_path.to_string_lossy().to_string(),
161                        parent_agent: parent_agent.to_string(),
162                        parent_block_id: parent_block_id.to_string(),
163                        session_id: session_id.clone(),
164                        spawned_at: initial_spawned_at,
165                        last_event_at: initial_spawned_at,
166                        status: initial_status,
167                        event_count: 0,
168                        model: None,
169                        dispatch_id: dispatch_id.clone(),
170                        // The parent's own short description of the task
171                        // (the Agent tool's `description`), from the same
172                        // sidecar as the tool_use_id: a name with no model call,
173                        // shown before anyone opens the row. Haiku names a
174                        // subagent only when there is none (workflow members).
175                        display_name: super::completion::description_for(jsonl_path),
176                        spawned_from_agent_id: None,
177                        tool_use_id: super::completion::tool_use_id_for(jsonl_path),
178                    },
179                    file_offset: 0,
180                    events: Vec::new(),
181                }
182            });
183            // The CLI can write the `.meta.json` sidecar a moment after the
184            // transcript itself; keep trying until it is readable. The watcher
185            // also routes the sidecar's own write here (`build_watch`), so a
186            // sidecar that lands after the transcript's last line is still
187            // picked up. `id_learned`: an already-known subagent just gained
188            // its id, which the spawn broadcast never carried.
189            let id_learned = if state.info.tool_use_id.is_none() {
190                state.info.tool_use_id = super::completion::tool_use_id_for(jsonl_path);
191                !is_new && state.info.tool_use_id.is_some()
192            } else {
193                false
194            };
195            // The description arrives with the same sidecar, so it can be learned
196            // as late as the id. `Some` for an already-known subagent that just
197            // gained its name, which is broadcast as `subagent:named` below.
198            let name_learned = if state.info.display_name.is_none() {
199                state.info.display_name = super::completion::description_for(jsonl_path);
200                state.info.display_name.clone().filter(|_| !is_new)
201            } else {
202                None
203            };
204
205            // A live observation outranks a replay's inference.
206            //
207            // codex P1 on PR #2837: if a backfill scan races ahead of the
208            // filesystem watcher on a newly-created transcript, it can read the
209            // documented-lagging `turn_active == false` and insert the entry as
210            // `Abandoned`. The `live == true` call that follows would otherwise
211            // be stuck with that: `or_insert_with` doesn't run for an existing
212            // entry, ordinary events never revive a status, and
213            // `reconcile_stale_subagents` only ever downgrades `Active` ->
214            // `Abandoned`. A genuinely running subagent would read as abandoned
215            // until a `result` line happened to arrive.
216            //
217            // `Abandoned` is always an inference; watching the file change is a
218            // direct observation, so the observation wins. This can't strand a
219            // wrong answer in the other direction either: if the subagent has
220            // in fact finished, its own `result` event sets `Completed` below;
221            // if the parent turn really is idle, the next reconcile pass
222            // re-abandons it.
223            if live && state.info.status == SubAgentStatus::Abandoned {
224                state.info.status = SubAgentStatus::Active;
225            }
226
227            state.file_offset = new_offset;
228            state.info.dispatch_id = dispatch_id.clone();
229
230            // Update metadata from first line if we got it
231            if let Some(m) = meta {
232                if !m.slug.is_empty() {
233                    state.info.slug = m.slug;
234                }
235                if let Some(model) = m.model {
236                    state.info.model = Some(model);
237                }
238                if m.parent_uuid.is_some() {
239                    state.info.spawned_from_agent_id = m.parent_uuid;
240                }
241            }
242
243            if new_events.is_empty() && !is_new {
244                if id_learned || name_learned.is_some() {
245                    let agent_id = state.info.agent_id.clone();
246                    drop(sessions);
247                    if id_learned {
248                        self.broadcast_subagent_updated(&agent_id, parent_block_id);
249                    }
250                    if let Some(name) = name_learned {
251                        self.broadcast_subagent_named(&agent_id, &name);
252                    }
253                }
254                return;
255            }
256
257            // Process events
258            let mut completed = false;
259            for event in &new_events {
260                state.info.event_count += 1;
261                state.info.last_event_at = event.timestamp;
262                state.events.push(event.clone());
263            }
264            // Trim to the cap, oldest-first — event_count above already
265            // recorded the true cumulative total before this truncation.
266            if state.events.len() > MAX_SUBAGENT_EVENTS {
267                let excess = state.events.len() - MAX_SUBAGENT_EVENTS;
268                state.events.drain(..excess);
269            }
270
271            // Completion by terminal `Result` line.
272            //
273            // ⚠ THIS NEVER FIRES ON ANY TRANSCRIPT AGENTMUX CURRENTLY WRITES.
274            // No `"type":"result"` line exists in the `entrypoint: sdk-cli`
275            // format — 0 of 11 subagent transcripts on the investigated
276            // machine, across CLI 2.1.198 and 2.1.247, and none in the parent
277            // session files either. Every completed subagent transcript ends
278            // on an `assistant` message. Evidence:
279            // docs/reports/REPORT_SUBAGENT_COMPLETION_NEVER_DETECTED_2026_09_05.md.
280            //
281            // Real completion is established by `completion.rs` instead,
282            // correlating the PARENT's `tool_result` for the dispatch's
283            // `tool_use_id` (#3007). If you are here because completions look
284            // wrong, that is the code path to read — not this one.
285            //
286            // Kept rather than deleted: it is not incorrect, only unreachable
287            // in a transcript format we don't control, and it would resume
288            // being the fastest completion signal (immediate, rather than
289            // waiting for the parent turn to end) if such lines ever appear.
290            // Deleting it would also strand `PendingCompletion` and the
291            // workflow-coalescing flush below.
292            //
293            // The history is worth keeping visible, because it is how the bug
294            // survived: #2283 replaced a placeholder-text match its own
295            // comment described as "almost never fired" with this discriminant
296            // check, which fires *never*. Both fail identically from outside —
297            // rows that simply never complete — so the regression read as the
298            // pre-existing flakiness.
299            if let Some(last) = new_events.last() {
300                if matches!(&last.event_type, SubagentEventType::Result { .. }) {
301                    completed = true;
302                    state.info.status = SubAgentStatus::Completed;
303                }
304            }
305
306            let info_snapshot = state.info.clone();
307            (is_new, info_snapshot, completed, id_learned, name_learned)
308        };
309        // Mutex released here — broadcast outside the lock
310
311        if id_learned {
312            self.broadcast_subagent_updated(&agent_id, parent_block_id);
313        }
314        if let Some(name) = name_learned {
315            self.broadcast_subagent_named(&agent_id, &name);
316        }
317
318        if is_new {
319            // Eager per-dispatch Haiku naming (issue:
320            // SPEC_SWARM_DISPATCH_NAMING_AND_ROW_MODEL_2026_07_19). Gated on
321            // `live` (never during backfill replay) and on `naming_triggered`
322            // atomically claiming this dispatch_id — for a Workflow dispatch,
323            // every member's is_new fires with the SAME dispatch_id, so only
324            // the first member to reach this point wins the claim; a Solo
325            // dispatch's dispatch_id is unique to its one member, so it
326            // always wins its own claim exactly once.
327            if live && self.naming_triggered.lock().unwrap().insert(dispatch_id.clone()) {
328                self.trigger_eager_naming(dispatch_id.clone(), agent_id.clone(), workflow_id.is_some());
329            }
330            let spawn = PendingSpawn {
331                agent_id: info_snapshot.agent_id.clone(),
332                slug: info_snapshot.slug.clone(),
333                model: info_snapshot.model.clone(),
334            };
335            if workflow_id.is_some() {
336                let mut pending = self.pending_activity.lock().unwrap();
337                let entry = pending
338                    .entry(dispatch_id.clone())
339                    .or_insert_with(|| PendingDispatchActivity::new(parent_agent, parent_block_id, &session_id));
340                entry.spawned.push(spawn);
341            } else {
342                self.broadcast_member_spawned(
343                    &spawn,
344                    parent_agent,
345                    parent_block_id,
346                    &session_id,
347                    &info_snapshot.dispatch_id,
348                );
349            }
350        }
351
352        if !new_events.is_empty() {
353            if workflow_id.is_some() {
354                // Workflow-kind member: buffer for the next coalesced
355                // dispatch:activity flush instead of broadcasting per-member
356                // (SPEC §7) — a large dispatch's activity ticks are the
357                // exact mechanism behind the crash-storm broadcast volume in
358                // docs/retro/retro-subagent-backfill-storm-oom-2026-07-17.md.
359                let mut pending = self.pending_activity.lock().unwrap();
360                let entry = pending
361                    .entry(dispatch_id.clone())
362                    .or_insert_with(|| PendingDispatchActivity::new(parent_agent, parent_block_id, &session_id));
363                entry.members.push((agent_id.clone(), new_events.clone()));
364            } else {
365                // Solo dispatch: exactly one member, no coalescing benefit for
366                // this event itself — broadcast subagent:activity immediately,
367                // same as before the redesign (existing consumers, e.g. the
368                // agent-pane activity dock, still rely on this per-member
369                // stream). ADDITIONALLY queue into pending_activity so
370                // dispatch:activity also flows for solo dispatch_ids — Phase B
371                // of SPEC_SWARM_DISPATCH_NAMING_AND_ROW_MODEL_2026_07_19's
372                // unified concatenated feed needs this for solo rows, which
373                // dispatch:activity never covered before. Deliberate dual
374                // emission, not a replacement — see that spec's Phase A notes
375                // for why the existing subagent:activity stream is kept as-is.
376                {
377                    let mut pending = self.pending_activity.lock().unwrap();
378                    let entry = pending
379                        .entry(dispatch_id.clone())
380                        .or_insert_with(|| PendingDispatchActivity::new(parent_agent, parent_block_id, &session_id));
381                    entry.members.push((agent_id.clone(), new_events.clone()));
382                }
383                self.event_bus.broadcast_typed::<crate::backend::mps_events::SubagentActivity>(&super::events::SubagentActivityEvent {
384                    agent_id: agent_id.to_string(),
385                    parent_agent: parent_agent.to_string(),
386                    parent_block_id: parent_block_id.to_string(),
387                    new_events: new_events.len(),
388                    total_events: info_snapshot.event_count,
389                    events: new_events.clone(),
390                    dispatch_id: info_snapshot.dispatch_id.to_string(),
391                });
392            }
393        }
394
395        if completed {
396            let done = PendingCompletion {
397                agent_id: agent_id.clone(),
398                total_events: info_snapshot.event_count,
399            };
400            if workflow_id.is_some() {
401                let mut pending = self.pending_activity.lock().unwrap();
402                let entry = pending
403                    .entry(dispatch_id.clone())
404                    .or_insert_with(|| PendingDispatchActivity::new(parent_agent, parent_block_id, &session_id));
405                entry.completed.push(done);
406            } else {
407                self.broadcast_member_completed(
408                    &done,
409                    parent_agent,
410                    parent_block_id,
411                    &session_id,
412                    &info_snapshot.dispatch_id,
413                );
414            }
415        }
416
417        if let Some(wf_id) = workflow_id {
418            self.update_dispatch_membership(
419                &wf_id,
420                parent_agent,
421                parent_block_id,
422                &session_id,
423                is_new,
424                completed,
425            );
426        } else if is_new || completed {
427            // Solo dispatch: no tracked DispatchState to update — just
428            // broadcast the freshly-synthesized AgentDispatch so dispatch:
429            // updated fires for both kinds (SPEC §5), not just Workflow.
430            if let Some(dispatch) = Self::solo_dispatch(&info_snapshot) {
431                self.broadcast_dispatch_updated(&dispatch);
432            }
433        }
434    }
435
436    /// Flush every dispatch with buffered activity as one coalesced
437    /// `dispatch:activity` broadcast (SPEC §7). Called on
438    /// `DISPATCH_ACTIVITY_FLUSH_INTERVAL` by the background task
439    /// `spawn` starts. A quiet dispatch (nothing buffered) costs nothing —
440    /// the map only ever holds entries for dispatches with real pending
441    /// activity, drained on every flush.
442    pub(super) fn flush_pending_dispatch_activity(&self) {
443        let batch: HashMap<String, PendingDispatchActivity> = {
444            let mut pending = self.pending_activity.lock().unwrap();
445            std::mem::take(&mut *pending)
446        };
447        for (dispatch_id, pending) in batch {
448            for spawn in &pending.spawned {
449                self.broadcast_member_spawned(
450                    spawn,
451                    &pending.parent_agent,
452                    &pending.parent_block_id,
453                    &pending.session_id,
454                    &dispatch_id,
455                );
456            }
457
458            if !pending.members.is_empty() {
459                let new_events: usize = pending.members.iter().map(|(_, evs)| evs.len()).sum();
460                self.event_bus.broadcast_typed::<crate::backend::mps_events::DispatchActivity>(&super::events::DispatchActivityEvent {
461                    dispatch_id: dispatch_id.to_string(),
462                    parent_agent: pending.parent_agent.to_string(),
463                    parent_block_id: pending.parent_block_id.to_string(),
464                    session_id: pending.session_id.to_string(),
465                    new_events: new_events,
466                    members: pending.members.iter().map(|(agent_id, events)| super::events::DispatchMemberEvents { agent_id: agent_id.clone(), events: events.clone() }).collect(),
467                });
468            }
469
470            for done in &pending.completed {
471                self.broadcast_member_completed(
472                    done,
473                    &pending.parent_agent,
474                    &pending.parent_block_id,
475                    &pending.session_id,
476                    &dispatch_id,
477                );
478            }
479
480            if let Some(info) = &pending.latest_info {
481                self.broadcast_dispatch_updated(info);
482            }
483        }
484    }
485
486    /// Broadcast `subagent:spawned` for one member, immediately for a solo
487    /// dispatch or from the coalesced flush for a workflow member.
488    fn broadcast_member_spawned(
489        &self,
490        spawn: &PendingSpawn,
491        parent_agent: &str,
492        parent_block_id: &str,
493        session_id: &str,
494        dispatch_id: &str,
495    ) {
496        self.event_bus.broadcast_typed::<crate::backend::mps_events::SubagentSpawned>(&super::events::SubagentSpawnedEvent {
497            agent_id: spawn.agent_id.to_string(),
498            slug: spawn.slug.to_string(),
499            parent_agent: parent_agent.to_string(),
500            parent_block_id: parent_block_id.to_string(),
501            session_id: session_id.to_string(),
502            model: spawn.model.clone(),
503            dispatch_id: dispatch_id.to_string(),
504        });
505        tracing::info!(
506            agent_id = %spawn.agent_id,
507            slug = %spawn.slug,
508            parent = %parent_agent,
509            parent_block_id = %parent_block_id,
510            session_id = %session_id,
511            dispatch_id = %dispatch_id,
512            "subagent spawned"
513        );
514    }
515
516    /// Broadcast `subagent:completed` for one member, immediately for a solo
517    /// dispatch or from the coalesced flush for a workflow member.
518    fn broadcast_member_completed(
519        &self,
520        done: &PendingCompletion,
521        parent_agent: &str,
522        parent_block_id: &str,
523        session_id: &str,
524        dispatch_id: &str,
525    ) {
526        self.event_bus.broadcast_typed::<crate::backend::mps_events::SubagentCompleted>(&super::events::SubagentCompletedEvent {
527            agent_id: done.agent_id.to_string(),
528            parent_agent: Some(parent_agent.to_string()),
529            parent_block_id: parent_block_id.to_string(),
530            total_events: done.total_events,
531            dispatch_id: dispatch_id.to_string(),
532        });
533        tracing::info!(
534            agent_id = %done.agent_id,
535            total_events = done.total_events,
536            parent_block_id = %parent_block_id,
537            session_id = %session_id,
538            dispatch_id = %dispatch_id,
539            "subagent completed"
540        );
541    }
542
543    /// Fold a member subagent's lifecycle into its dispatch aggregate and
544    /// broadcast `dispatch:updated` — but only when membership actually
545    /// changed (a spawn or a completion). `process_jsonl_change` calls this
546    /// unconditionally for every workflow member, including plain
547    /// text/tool_use/tool_result activity ticks that carry neither flag; a
548    /// dispatch with several active members would otherwise broadcast a WS
549    /// event on every one of those ticks even though `memberCount`/
550    /// `membersDone` never moved. Mirrors the `has_new_records` gate in
551    /// `process_journal_change`.
552    fn update_dispatch_membership(
553        &self,
554        workflow_id: &str,
555        parent_agent: &str,
556        parent_block_id: &str,
557        session_id: &str,
558        member_spawned: bool,
559        member_completed: bool,
560    ) {
561        if !member_spawned && !member_completed {
562            return;
563        }
564
565        let info = {
566            let mut dispatches = self.dispatches.lock().unwrap();
567            let state = Self::dispatch_entry(
568                &mut dispatches,
569                workflow_id,
570                parent_agent,
571                parent_block_id,
572                session_id,
573            );
574            if member_spawned {
575                state.member_files += 1;
576            }
577            if member_completed {
578                state.members_completed += 1;
579            }
580            Self::refresh_dispatch_info(state);
581            state.info.clone()
582        };
583        self.queue_dispatch_updated(workflow_id, parent_agent, parent_block_id, session_id, info);
584    }
585
586    /// Queue an `AgentDispatch` snapshot for the next coalesced flush instead
587    /// of broadcasting `dispatch:updated` immediately — every caller here is
588    /// a Workflow-kind dispatch (Solo dispatches broadcast directly via
589    /// `broadcast_dispatch_updated`, see `process_jsonl_change`'s solo path).
590    fn queue_dispatch_updated(
591        &self,
592        dispatch_id: &str,
593        parent_agent: &str,
594        parent_block_id: &str,
595        session_id: &str,
596        info: AgentDispatch,
597    ) {
598        let mut pending = self.pending_activity.lock().unwrap();
599        let entry = pending
600            .entry(dispatch_id.to_string())
601            .or_insert_with(|| PendingDispatchActivity::new(parent_agent, parent_block_id, session_id));
602        entry.latest_info = Some(info);
603    }
604
605    /// Process a changed workflow journal (subagents/workflows/<wf>/journal.jsonl):
606    /// tally new `started`/`result` records and broadcast `dispatch:updated`.
607    pub(super) fn process_journal_change(
608        &self,
609        parent_agent: &str,
610        parent_block_id: &str,
611        journal_path: &Path,
612    ) {
613        let workflow_id = match parse_workflow_id(journal_path) {
614            Some(id) => id,
615            None => return,
616        };
617        let session_id = derive_session_id(journal_path);
618
619        // Read the current offset before locking (file I/O outside the lock).
620        let offset = {
621            let dispatches = self.dispatches.lock().unwrap();
622            dispatches
623                .get(&workflow_id)
624                .map(|w| w.journal_offset)
625                .unwrap_or(0)
626        };
627
628        let (started, results, new_offset) = match read_journal_counts(journal_path, offset) {
629            Ok(r) => r,
630            Err(e) => {
631                tracing::debug!(
632                    workflow_id = %workflow_id,
633                    error = %e,
634                    "failed to read workflow journal"
635                );
636                return;
637            }
638        };
639        // Nothing new landed (no complete lines since `offset`) — nothing to
640        // persist or broadcast.
641        if new_offset == offset {
642            return;
643        }
644
645        let has_new_records = started > 0 || results > 0;
646        let info = {
647            let mut dispatches = self.dispatches.lock().unwrap();
648            let state = Self::dispatch_entry(
649                &mut dispatches,
650                &workflow_id,
651                parent_agent,
652                parent_block_id,
653                &session_id,
654            );
655            // Always persist the advanced offset, even when the newly-read
656            // lines didn't contain a started/result record — otherwise those
657            // bytes get rescanned on every subsequent journal change until a
658            // matching record eventually appears elsewhere in the file.
659            state.journal_offset = new_offset;
660            if has_new_records {
661                state.journal_started += started;
662                state.journal_results += results;
663                Self::refresh_dispatch_info(state);
664            }
665            state.info.clone()
666        };
667        // Only queue when the counters actually moved — an offset-only
668        // advance (non-started/result lines) has no observable effect on
669        // AgentDispatch, so a broadcast would just be noise.
670        if has_new_records {
671            self.queue_dispatch_updated(&workflow_id, parent_agent, parent_block_id, &session_id, info);
672        }
673    }
674
675    fn dispatch_entry<'a>(
676        dispatches: &'a mut HashMap<String, DispatchState>,
677        workflow_id: &str,
678        parent_agent: &str,
679        parent_block_id: &str,
680        session_id: &str,
681    ) -> &'a mut DispatchState {
682        dispatches
683            .entry(workflow_id.to_string())
684            .or_insert_with(|| DispatchState {
685                info: AgentDispatch {
686                    dispatch_id: workflow_id.to_string(),
687                    kind: DispatchKind::Workflow,
688                    parent_agent: parent_agent.to_string(),
689                    parent_block_id: parent_block_id.to_string(),
690                    session_id: session_id.to_string(),
691                    member_count: 0,
692                    members_done: 0,
693                    status: DispatchStatus::Running,
694                    last_event_at: now_millis(),
695                    dispatch_name: None,
696                },
697                journal_offset: 0,
698                journal_started: 0,
699                journal_results: 0,
700                member_files: 0,
701                members_completed: 0,
702            })
703    }
704
705    /// Recompute public counters from raw journal/member counters. Either
706    /// source can lag the other (journal writes vs member file creation), so
707    /// take the max of each pair.
708    fn refresh_dispatch_info(state: &mut DispatchState) {
709        state.info.member_count = state.journal_started.max(state.member_files);
710        state.info.members_done = state.journal_results.max(state.members_completed);
711        state.info.last_event_at = now_millis();
712        // Called only in response to genuinely NEW member evidence (a spawn
713        // or a completion just landed) — always allowed to recompute, even
714        // overriding a prior Abandoned, mirroring the existing SubAgent-level
715        // precedent that a late-arriving Result always wins
716        // (`reconcile_stale_subagents_then_late_result_line_ends_completed_
717        // not_stuck_abandoned`). Codex P2 on PR #2677: an earlier version of
718        // this guard was unconditional (also applied here), permanently
719        // stranding a dispatch at Abandoned even once every member had
720        // actually finished. See `refresh_dispatch_status`'s own doc comment
721        // for why the READ-ONLY path (list_dispatches) still needs the guard.
722        Self::recompute_dispatch_status(state);
723    }
724
725    /// Counts-complete + 60s quiet ⇒ Completed. There is no timer: the flip
726    /// happens lazily at the next event or ListDispatches read. `started ==
727    /// results` alone is not terminal — it also holds between phases of a
728    /// still-running workflow, hence the quiet window.
729    pub(super) fn recompute_dispatch_status(state: &mut DispatchState) {
730        let counts_complete = state.info.member_count > 0
731            && state.info.members_done >= state.info.member_count;
732        let quiet = now_millis().saturating_sub(state.info.last_event_at) > 60_000;
733        state.info.status = if counts_complete && quiet {
734            DispatchStatus::Completed
735        } else {
736            DispatchStatus::Running
737        };
738    }
739
740    /// Read-only variant for `list_dispatches()` — `Abandoned` is terminal
741    /// and never recomputed here, since this call has no new evidence
742    /// (unlike `refresh_dispatch_info`'s call to `recompute_dispatch_status`
743    /// above, triggered by a genuinely new member event). `reconcile_stale_
744    /// subagents` is the ONLY writer of `Abandoned`
745    /// (SPEC_SWARM_DISPATCH_ATTRIBUTION_AND_LIFECYCLE_2026_08_19.md §3.2),
746    /// and `list_dispatches()` calls this on every single read (not just on
747    /// new events) — without this guard, the very next `ListDispatches` RPC
748    /// after a reconciliation pass would silently overwrite Abandoned back
749    /// to Running/Completed based on counts alone, discarding the
750    /// reconciliation before any client ever observed it.
751    pub(super) fn refresh_dispatch_status(state: &mut DispatchState) {
752        if state.info.status == DispatchStatus::Abandoned {
753            return;
754        }
755        Self::recompute_dispatch_status(state);
756    }
757
758    /// `subagent:named`: the subagent's display name is set (from the parent's
759    /// description, or by Haiku). The dock and the Swarm patch their row from it.
760    pub(super) fn broadcast_subagent_named(&self, agent_id: &str, display_name: &str) {
761        self.event_bus.broadcast_typed::<crate::backend::mps_events::SubagentNamed>(&super::events::SubagentNamedEvent {
762            agent_id: agent_id.to_string(),
763            display_name: display_name.to_string(),
764        });
765    }
766
767    /// A known subagent's fields changed without a new spawn — today, only its
768    /// `tool_use_id` arriving late from the sidecar. Swarm reloads the list on
769    /// it so the subagent's background tasks move under its row. Deliberately
770    /// NOT a re-sent `subagent:spawned`: the dock's sources add rows on that.
771    pub(super) fn broadcast_subagent_updated(&self, agent_id: &str, parent_block_id: &str) {
772        self.event_bus.broadcast_typed::<crate::backend::mps_events::SubagentUpdated>(&super::events::SubagentUpdatedEvent {
773            agent_id: agent_id.to_string(),
774            parent_block_id: parent_block_id.to_string(),
775        });
776    }
777
778    pub(super) fn broadcast_dispatch_updated(&self, info: &AgentDispatch) {
779        self.event_bus.broadcast_typed::<crate::backend::mps_events::DispatchUpdated>(&super::events::DispatchUpdatedEvent {
780            dispatch_id: info.dispatch_id.to_string(),
781            kind: info.kind.clone(),
782            parent_agent: info.parent_agent.to_string(),
783            parent_block_id: info.parent_block_id.to_string(),
784            session_id: info.session_id.to_string(),
785            member_count: info.member_count,
786            members_done: info.members_done,
787            status: info.status.clone(),
788            dispatch_name: info.dispatch_name.clone(),
789        });
790    }
791}