agentmux_srv/backend/subagent_watcher/completion.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Did this subagent actually finish, or was it cut off?
5//!
6//! `SubAgentStatus::Abandoned` is — as its own doc comment says — "always an
7//! inference, never an observation". Until this module existed, the inference
8//! was "the subagent transcript has no terminal `Result` line, therefore it was
9//! interrupted" (`scan.rs::reconcile_stale_subagents`). That premise does not
10//! hold: **no AgentMux transcript contains a `"type":"result"` line at all** —
11//! not the subagent sidechain files, not the parent session file. The line type
12//! the old check waited for is absent from the format this deployment writes
13//! (`entrypoint: sdk-cli`), so `SubAgentStatus::Completed` was unreachable and
14//! every Agent-tool dispatch ended up displayed as "interrupted", including the
15//! ones that returned full results the parent went on to use.
16//!
17//! Evidence and the full chain:
18//! `docs/reports/REPORT_SUBAGENT_COMPLETION_NEVER_DETECTED_2026_09_05.md`.
19//!
20//! The replacement uses the strongest evidence available, and it is an
21//! *observation* rather than an inference: the parent's own `tool_result` for
22//! the dispatch's `tool_use_id`. If the parent recorded a result, the subagent
23//! demonstrably returned — whatever its transcript does or doesn't end with.
24//! The correlation key is already on disk: Claude Code writes an
25//! `agent-<id>.meta.json` sidecar next to each subagent transcript carrying the
26//! parent-side `toolUseId`.
27//!
28//! Deliberately kept free of watcher state so the decision is unit-testable on
29//! its own — the same reasoning that split `parse.rs` out.
30
31use std::collections::HashMap;
32use std::io::BufRead;
33use std::path::{Path, PathBuf};
34
35use super::types::SubAgentStatus;
36
37/// One subagent's `.meta.json` sidecar. Only the fields we use are modelled;
38/// the sidecar also carries `agentType`/`spawnDepth`/`model`. `serde` ignores
39/// the rest.
40#[derive(Debug, serde::Deserialize)]
41struct SubagentMeta {
42 #[serde(rename = "toolUseId")]
43 tool_use_id: Option<String>,
44 /// The Agent tool's `description`: the parent model's own 3–5 word summary
45 /// of the task. Absent for workflow members.
46 description: Option<String>,
47}
48
49fn read_meta(subagent_jsonl: &Path) -> Option<SubagentMeta> {
50 let meta_path = sidecar_path(subagent_jsonl)?;
51 let raw = std::fs::read_to_string(meta_path).ok()?;
52 serde_json::from_str::<SubagentMeta>(&raw).ok()
53}
54
55/// The parent-side `tool_use_id` for a subagent transcript, read from its
56/// sidecar. `None` when there is no sidecar, it is unreadable, it is malformed,
57/// or it predates the field — all of which must degrade to the old
58/// conservative behaviour rather than erroring.
59pub(super) fn tool_use_id_for(subagent_jsonl: &Path) -> Option<String> {
60 read_meta(subagent_jsonl)?.tool_use_id
61}
62
63/// The subagent's display name from the parent's description of its task, read
64/// from the same sidecar. `None` when there is none (a workflow member, a
65/// sidecar not written yet); the subagent is then named by Haiku on demand.
66pub(super) fn description_for(subagent_jsonl: &Path) -> Option<String> {
67 display_name_from_description(&read_meta(subagent_jsonl)?.description?)
68}
69
70/// Longest description kept as a name. The tool asks for 3–5 words; this only
71/// stops a runaway one from filling the row.
72const DESCRIPTION_NAME_MAX_CHARS: usize = 80;
73
74/// A description as a one-line name: whitespace collapsed, capped, `None` if
75/// nothing is left.
76pub(super) fn display_name_from_description(description: &str) -> Option<String> {
77 let one_line = description.split_whitespace().collect::<Vec<_>>().join(" ");
78 if one_line.is_empty() {
79 return None;
80 }
81 if one_line.chars().count() <= DESCRIPTION_NAME_MAX_CHARS {
82 return Some(one_line);
83 }
84 let cut: String = one_line.chars().take(DESCRIPTION_NAME_MAX_CHARS - 1).collect();
85 Some(format!("{}…", cut.trim_end()))
86}
87
88/// `…/agent-<id>.jsonl` → `…/agent-<id>.meta.json`.
89fn sidecar_path(subagent_jsonl: &Path) -> Option<PathBuf> {
90 let stem = subagent_jsonl.file_stem()?.to_str()?;
91 Some(subagent_jsonl.with_file_name(format!("{stem}.meta.json")))
92}
93
94/// `…/agent-<id>.meta.json` → `…/agent-<id>.jsonl`; `None` for any other file.
95/// The inverse of `sidecar_path`, so the watcher can route a sidecar write to
96/// the transcript whose `tool_use_id` it carries.
97pub(super) fn transcript_for_sidecar(path: &Path) -> Option<PathBuf> {
98 let name = path.file_name()?.to_str()?;
99 let stem = name.strip_suffix(".meta.json")?;
100 stem.starts_with("agent-")
101 .then(|| path.with_file_name(format!("{stem}.jsonl")))
102}
103
104/// The parent session transcript for a subagent transcript.
105///
106/// Claude Code lays these out as `…/<session-id>.jsonl` alongside a
107/// `…/<session-id>/subagents/` tree, but members are NOT all at the same
108/// depth inside it (`scan_subagents_dir`'s own doc comment):
109///
110/// ```text
111/// <session-id>/subagents/agent-<id>.jsonl ← Task-tool (solo)
112/// <session-id>/subagents/workflows/<run-id>/agent-<id>.jsonl ← Workflow-tool member
113/// ```
114///
115/// So this walks up to the `subagents` directory rather than assuming it is
116/// the immediate parent. Checking only the immediate parent silently excluded
117/// every Workflow-tool member — they could never resolve a completion and
118/// stayed permanently "interrupted", i.e. exactly the bug this module exists
119/// to fix, left unfixed for that dispatch kind (reagent P1 on #3007).
120///
121/// Returns `None` when there is no `subagents` ancestor at all, rather than
122/// guessing at a path and reading some unrelated file as a transcript.
123pub(super) fn parent_transcript_for(subagent_jsonl: &Path) -> Option<PathBuf> {
124 let subagents_dir = subagent_jsonl
125 .ancestors()
126 .find(|a| a.file_name().and_then(|n| n.to_str()) == Some("subagents"))?;
127 let session_dir = subagents_dir.parent()?;
128 let session_id = session_dir.file_name()?.to_str()?;
129 Some(session_dir.with_file_name(format!("{session_id}.jsonl")))
130}
131
132/// Every `tool_use_id` the parent transcript has recorded a `tool_result` for,
133/// mapped to that result's `is_error` flag.
134///
135/// The caller reads once per DISTINCT parent transcript, not once per
136/// subagent: a transcript is routinely tens of MB (26 MB / 15k lines on the
137/// session that surfaced this) and a pass can hold many members, but they do
138/// not all resolve to the same parent (see [`parent_transcript_for`]). The
139/// cheap `contains` pre-filter keeps the JSON parse off the ~99% of lines that
140/// carry no tool result at all.
141pub(super) fn parent_tool_results(reader: impl BufRead) -> HashMap<String, bool> {
142 let mut out = HashMap::new();
143 for line in reader.lines().map_while(Result::ok) {
144 if !line.contains("tool_use_id") {
145 continue;
146 }
147 let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) else {
148 continue;
149 };
150 let Some(content) = value
151 .get("message")
152 .and_then(|m| m.get("content"))
153 .and_then(|c| c.as_array())
154 else {
155 continue;
156 };
157 for block in content {
158 if block.get("type").and_then(|t| t.as_str()) != Some("tool_result") {
159 continue;
160 }
161 let Some(id) = block.get("tool_use_id").and_then(|v| v.as_str()) else {
162 continue;
163 };
164 let is_error = block
165 .get("is_error")
166 .and_then(serde_json::Value::as_bool)
167 .unwrap_or(false);
168 out.insert(id.to_string(), is_error);
169 }
170 }
171 out
172}
173
174/// Read an already-resolved parent transcript. An unreadable/absent file
175/// yields an empty map, which makes [`terminal_status`] fall back to the old
176/// `Abandoned` inference — conservative, and identical to pre-fix behaviour.
177///
178/// Takes the resolved path rather than a subagent path so the caller can
179/// cache one read per distinct parent: members of a reconcile pass do not all
180/// resolve to the same transcript (see [`parent_transcript_for`]), so
181/// resolving once from an arbitrary member and reusing it for the rest is
182/// wrong — that was reagent's P1 on #3007.
183pub(super) fn parent_tool_results_at(parent_jsonl: &Path) -> HashMap<String, bool> {
184 let Ok(file) = std::fs::File::open(parent_jsonl) else {
185 return HashMap::new();
186 };
187 parent_tool_results(std::io::BufReader::new(file))
188}
189
190/// What an `Active` subagent's status should become once its parent turn is
191/// confirmed idle.
192///
193/// `Completed` when the parent recorded a `tool_result` for this dispatch —
194/// proof it returned. `Abandoned` otherwise, which now means what it always
195/// claimed to: no evidence it ever came back.
196///
197/// **An errored result still counts as `Completed`.** The distinction this
198/// draws is *returned* vs *cut off*, and a dispatch that reported an error
199/// returned. Folding failures into `Abandoned` would recreate the same
200/// conflation this fix exists to remove, just with a smaller blast radius.
201///
202/// Surfacing the error state itself needs a status the enum doesn't have yet
203/// (and a matching frontend union) — tracked as follow-up in the report above.
204/// **Nothing reads the `is_error` flag today** — this function only asks
205/// `contains_key`. It is parsed and carried in `parent_results` purely so the
206/// follow-up has it available without re-reading every transcript; do not
207/// mistake that for it being surfaced or logged anywhere (reagent P2 on
208/// #3007, on an earlier version of this comment that claimed otherwise).
209pub(super) fn terminal_status(
210 tool_use_id: Option<&str>,
211 parent_results: &HashMap<String, bool>,
212) -> SubAgentStatus {
213 match tool_use_id {
214 Some(id) if parent_results.contains_key(id) => SubAgentStatus::Completed,
215 _ => SubAgentStatus::Abandoned,
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use std::io::Cursor;
223
224 // Shapes below are taken from the real transcripts that surfaced this bug
225 // (manoz session 468e2051…), not invented.
226
227 #[test]
228 fn parent_transcript_sits_beside_the_session_dir() {
229 let sub = Path::new("/p/C--proj/468e2051-abc/subagents/agent-a5cb.jsonl");
230 assert_eq!(
231 parent_transcript_for(sub),
232 Some(PathBuf::from("/p/C--proj/468e2051-abc.jsonl"))
233 );
234 }
235
236 #[test]
237 fn a_workflow_member_resolves_to_the_same_session_transcript() {
238 // Workflow-tool members sit one level deeper than Task-tool ones
239 // (subagents/workflows/<run-id>/). Requiring `subagents` to be the
240 // IMMEDIATE parent excluded every one of them, so they could never
241 // resolve a completion and stayed permanently "interrupted" —
242 // reagent P1 on #3007. No fixture on the dev machine had a workflow
243 // run, which is why the original tests missed it.
244 let sub = Path::new("/p/C--proj/468e2051-abc/subagents/workflows/wf_9/agent-a5cb.jsonl");
245 assert_eq!(
246 parent_transcript_for(sub),
247 Some(PathBuf::from("/p/C--proj/468e2051-abc.jsonl"))
248 );
249 }
250
251 #[test]
252 fn solo_and_workflow_members_of_one_session_agree_on_the_parent() {
253 // The mixed-batch case: both must resolve to the SAME transcript, or
254 // a pass containing both silently mis-resolves half of them.
255 let solo = Path::new("/p/C--proj/sess-1/subagents/agent-solo.jsonl");
256 let member = Path::new("/p/C--proj/sess-1/subagents/workflows/wf_1/agent-m.jsonl");
257 assert_eq!(parent_transcript_for(solo), parent_transcript_for(member));
258 }
259
260 #[test]
261 fn a_path_that_is_not_in_a_subagents_dir_is_not_guessed_at() {
262 // Better to decline than to invent a parent path and read some
263 // unrelated file as if it were the session transcript.
264 let sub = Path::new("/p/C--proj/468e2051-abc/agent-a5cb.jsonl");
265 assert_eq!(parent_transcript_for(sub), None);
266 }
267
268 #[test]
269 fn sidecar_sits_beside_the_transcript() {
270 assert_eq!(
271 sidecar_path(Path::new("/x/subagents/agent-a5cb.jsonl")),
272 Some(PathBuf::from("/x/subagents/agent-a5cb.meta.json"))
273 );
274 }
275
276 #[test]
277 fn a_sidecar_routes_to_its_transcript_and_nothing_else_does() {
278 assert_eq!(
279 transcript_for_sidecar(Path::new("/x/subagents/agent-a5cb.meta.json")),
280 Some(PathBuf::from("/x/subagents/agent-a5cb.jsonl"))
281 );
282 assert_eq!(
283 transcript_for_sidecar(Path::new("/x/subagents/agent-a5cb.jsonl")),
284 None
285 );
286 assert_eq!(
287 transcript_for_sidecar(Path::new("/x/subagents/other.meta.json")),
288 None
289 );
290 }
291
292 #[test]
293 fn collects_tool_result_ids_with_their_error_flag() {
294 let lines = r#"
295{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_ok","is_error":false,"content":"done"}]}}
296{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_bad","is_error":true,"content":"boom"}]}}
297"#;
298 let got = parent_tool_results(Cursor::new(lines));
299 assert_eq!(got.get("toolu_ok"), Some(&false));
300 assert_eq!(got.get("toolu_bad"), Some(&true));
301 assert_eq!(got.len(), 2);
302 }
303
304 #[test]
305 fn a_missing_is_error_flag_reads_as_success() {
306 // Real results routinely omit it; absent must not read as failure.
307 let line = r#"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_x","content":"ok"}]}}"#;
308 assert_eq!(parent_tool_results(Cursor::new(line)).get("toolu_x"), Some(&false));
309 }
310
311 #[test]
312 fn ignores_tool_use_lines_and_other_noise() {
313 // A `tool_use` line mentions no tool_use_id and must not be mistaken
314 // for evidence that the call came BACK.
315 let lines = r#"
316{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_x","name":"Agent","input":{}}]}}
317{"type":"queue-operation","operation":"enqueue","content":"u there"}
318not json at all
319{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}
320"#;
321 assert!(parent_tool_results(Cursor::new(lines)).is_empty());
322 }
323
324 #[test]
325 fn a_returned_dispatch_is_completed() {
326 let mut results = HashMap::new();
327 results.insert("toolu_ok".to_string(), false);
328 assert_eq!(
329 terminal_status(Some("toolu_ok"), &results),
330 SubAgentStatus::Completed
331 );
332 }
333
334 #[test]
335 fn an_errored_dispatch_still_returned_so_it_is_completed() {
336 // Returned-vs-cut-off is the distinction; an error is a return.
337 let mut results = HashMap::new();
338 results.insert("toolu_bad".to_string(), true);
339 assert_eq!(
340 terminal_status(Some("toolu_bad"), &results),
341 SubAgentStatus::Completed
342 );
343 }
344
345 #[test]
346 fn a_dispatch_with_no_parent_result_is_genuinely_abandoned() {
347 // The case the old code assumed was universal — still handled.
348 assert_eq!(
349 terminal_status(Some("toolu_never_returned"), &HashMap::new()),
350 SubAgentStatus::Abandoned
351 );
352 }
353
354 #[test]
355 fn an_unknown_tool_use_id_falls_back_to_the_old_inference() {
356 // No sidecar / older layout: must degrade to prior behaviour rather
357 // than optimistically completing something we cannot correlate.
358 let mut results = HashMap::new();
359 results.insert("toolu_ok".to_string(), false);
360 assert_eq!(terminal_status(None, &results), SubAgentStatus::Abandoned);
361 }
362}