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}