Skip to main content

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}