Skip to main content

zeph_subagent/manager/
collect.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::path::PathBuf;
5
6use zeph_config::SubAgentConfig;
7
8use super::SubAgentManager;
9use super::SubAgentStatus;
10use crate::def::SubAgentDef;
11use crate::error::SubAgentError;
12use crate::fleet::FleetSessionStatus;
13use crate::hooks::fire_hooks;
14use crate::manager::secrets::make_hook_env;
15use crate::state::SubAgentState;
16use crate::transcript::{TranscriptMeta, TranscriptWriter, sweep_old_transcripts};
17
18impl SubAgentManager {
19    /// Collect the result from a completed sub-agent, removing it from the active set.
20    ///
21    /// Writes a final `TranscriptMeta` sidecar with the terminal state and turn count.
22    ///
23    /// # Errors
24    ///
25    /// Returns [`SubAgentError::NotFound`] if the task ID is unknown,
26    /// [`SubAgentError::Spawn`] if the task panicked.
27    #[tracing::instrument(name = "subagent.manager.collect", skip_all, fields(task_id = task_id))]
28    pub async fn collect(&mut self, task_id: &str) -> Result<String, SubAgentError> {
29        let mut handle = self
30            .agents
31            .remove(task_id)
32            .ok_or_else(|| SubAgentError::NotFound(task_id.to_owned()))?;
33
34        if !self.stop_hooks.is_empty() {
35            let stop_hooks = self.stop_hooks.clone();
36            let stop_env = make_hook_env(task_id, &handle.def.name, "");
37            self.spawn_hook_task(async move {
38                if let Err(e) = fire_hooks(&stop_hooks, &stop_env, None, None).await {
39                    tracing::warn!(error = %e, "SubagentStop hook failed");
40                }
41            });
42        }
43
44        handle.grants_lock().revoke_all();
45
46        // Flatten the outer `BlockingError` (panic/abort of the supervised task itself)
47        // into `result` rather than propagating it with `?`: an early return here would
48        // skip the fleet `mark_terminal` call and final `TranscriptMeta` write below,
49        // leaving both stuck showing the agent as still active even though it has just
50        // been removed from `self.agents` (issue #6408).
51        let result: Result<String, SubAgentError> = if let Some(jh) = handle.join_handle.take() {
52            match jh.join().await {
53                Ok(inner) => inner,
54                Err(e) => Err(SubAgentError::Spawn(e.to_string())),
55            }
56        } else {
57            Ok(String::new())
58        };
59
60        let final_state = {
61            let status = handle.status_rx.borrow();
62            if result.is_err() {
63                SubAgentState::Failed
64            } else if status.state == SubAgentState::Canceled {
65                SubAgentState::Canceled
66            } else {
67                SubAgentState::Completed
68            }
69        };
70
71        if let Some(ref registry) = self.fleet_registry {
72            let registry = std::sync::Arc::clone(registry);
73            let tid = task_id.to_owned();
74            let fleet_status = match final_state {
75                SubAgentState::Failed => FleetSessionStatus::Failed,
76                SubAgentState::Canceled => FleetSessionStatus::Cancelled,
77                _ => FleetSessionStatus::Completed,
78            };
79            self.spawn_hook_task(async move {
80                if let Err(e) = registry.mark_terminal(&tid, fleet_status).await {
81                    tracing::warn!(error = %e, task_id = %tid, "fleet: mark_terminal failed");
82                }
83            });
84        }
85
86        if let Some(ref dir) = handle.transcript_dir.clone() {
87            let turns_used = handle.status_rx.borrow().turns_used;
88            let meta = TranscriptMeta {
89                agent_id: task_id.to_owned(),
90                agent_name: handle.def.name.clone(),
91                def_name: handle.def.name.clone(),
92                status: final_state,
93                started_at: handle.started_at_str.clone(),
94                finished_at: Some(crate::transcript::utc_now()),
95                resumed_from: None,
96                turns_used,
97                mcp_tool_names: handle.mcp_tool_names.clone(),
98            };
99            if let Err(e) = TranscriptWriter::write_meta_async(dir, task_id, &meta).await {
100                tracing::warn!(error = %e, task_id, "failed to write final transcript meta");
101            }
102        }
103
104        result
105    }
106
107    /// Resolve the effective transcript directory from config or default.
108    pub(crate) fn effective_transcript_dir(&self, config: &SubAgentConfig) -> PathBuf {
109        if let Some(ref dir) = self.transcript_dir {
110            dir.clone()
111        } else if let Some(ref dir) = config.transcript_dir {
112            dir.clone()
113        } else {
114            PathBuf::from(".zeph/subagents")
115        }
116    }
117
118    /// Look up the definition name for a resumable transcript without spawning.
119    ///
120    /// Used by callers that need to resolve skills before calling `resume()`.
121    /// Offloads the blocking FS reads to a `spawn_blocking` thread.
122    ///
123    /// # Errors
124    ///
125    /// Returns the same errors as [`crate::transcript::TranscriptReader::find_by_prefix`] and
126    /// [`crate::transcript::TranscriptReader::load_meta`].
127    pub async fn def_name_for_resume(
128        &self,
129        id_prefix: &str,
130        config: &SubAgentConfig,
131    ) -> Result<String, SubAgentError> {
132        let dir = self.effective_transcript_dir(config);
133        let id_prefix = id_prefix.to_owned();
134        tokio::task::spawn_blocking(move || {
135            let original_id =
136                crate::transcript::TranscriptReader::find_by_prefix(&dir, &id_prefix)?;
137            let meta = crate::transcript::TranscriptReader::load_meta(&dir, &original_id)?;
138            Ok(meta.def_name)
139        })
140        .await
141        .map_err(|e| SubAgentError::Spawn(format!("spawn_blocking panicked: {e}")))?
142    }
143
144    /// Return a snapshot of all active sub-agent statuses.
145    #[must_use]
146    pub fn statuses(&self) -> Vec<(String, SubAgentStatus)> {
147        self.agents
148            .values()
149            .map(|h| {
150                let mut status = h.status_rx.borrow().clone();
151                if h.state == SubAgentState::Canceled {
152                    status.state = SubAgentState::Canceled;
153                }
154                (h.task_id.clone(), status)
155            })
156            .collect()
157    }
158
159    /// Returns whether the background task backing `task_id` has finished at the runtime
160    /// level, independent of whether its `status_rx` channel ever published a terminal
161    /// [`SubAgentState`].
162    ///
163    /// A code path that exits `run_agent_loop` without sending a terminal status first —
164    /// most notably a panic — leaves `status_rx` stuck on the last observed state (typically
165    /// `Working`) forever. Callers such as `collect_finished_subagents` in `zeph-core` use
166    /// this as a defense-in-depth reap signal for that case (issue #6408).
167    ///
168    /// Returns `false` for an unknown `task_id` or a handle with no `join_handle` (already
169    /// collected, or a test-constructed handle).
170    #[must_use]
171    pub fn is_task_finished(&self, task_id: &str) -> bool {
172        self.agents
173            .get(task_id)
174            .and_then(|h| h.join_handle.as_ref())
175            .is_some_and(zeph_common::task_supervisor::BlockingHandle::is_finished)
176    }
177
178    /// Return the definition for a specific agent by `task_id`.
179    #[must_use]
180    pub fn agents_def(&self, task_id: &str) -> Option<&SubAgentDef> {
181        self.agents.get(task_id).map(|h| &h.def)
182    }
183
184    /// Return the transcript directory for a specific agent by `task_id`.
185    #[must_use]
186    pub fn agent_transcript_dir(&self, task_id: &str) -> Option<&std::path::Path> {
187        self.agents
188            .get(task_id)
189            .and_then(|h| h.transcript_dir.as_deref())
190    }
191
192    /// Resolve the transcript file path for `agent_id` from `config`, independent of whether the
193    /// agent's handle is still resident in this manager.
194    ///
195    /// Unlike [`Self::agent_transcript_dir`] (which only returns a path for agents still tracked
196    /// in `self.agents`), this is safe to call after [`Self::collect`] has already removed the
197    /// handle — the path is fully determined by `config` and `agent_id`, matching exactly what
198    /// `handle.transcript_dir` held at spawn time (see the `handle_transcript_dir` construction
199    /// in `manager/spawn.rs`).
200    ///
201    /// # Examples
202    ///
203    /// ```
204    /// use zeph_config::SubAgentConfig;
205    /// use zeph_subagent::SubAgentManager;
206    ///
207    /// let mgr = SubAgentManager::new(4);
208    /// let config = SubAgentConfig::default();
209    /// let path = mgr.transcript_path_for(&config, "task-123");
210    /// assert!(path.ends_with("task-123.jsonl"));
211    /// ```
212    #[must_use]
213    pub fn transcript_path_for(&self, config: &SubAgentConfig, agent_id: &str) -> PathBuf {
214        self.effective_transcript_dir(config)
215            .join(format!("{agent_id}.jsonl"))
216    }
217
218    /// Create a transcript writer if transcripts are enabled.
219    ///
220    /// All three blocking FS operations (sweep, file open, meta write) are offloaded via
221    /// [`tokio::task::block_in_place`] on multi-thread runtimes so the Tokio executor
222    /// thread is not stalled. Falls back to a direct call on `current_thread` runtimes
223    /// (e.g. single-threaded unit tests) where `block_in_place` would panic.
224    pub(crate) fn create_transcript_writer(
225        &mut self,
226        config: &SubAgentConfig,
227        task_id: &str,
228        agent_name: &str,
229        resumed_from: Option<&str>,
230    ) -> Option<TranscriptWriter> {
231        if !config.transcript_enabled {
232            return None;
233        }
234        let dir = self.effective_transcript_dir(config);
235        let max_files = self.transcript_max_files;
236        let path = dir.join(format!("{task_id}.jsonl"));
237        let meta = TranscriptMeta {
238            agent_id: task_id.to_owned(),
239            agent_name: agent_name.to_owned(),
240            def_name: agent_name.to_owned(),
241            status: SubAgentState::Submitted,
242            started_at: crate::transcript::utc_now(),
243            finished_at: None,
244            resumed_from: resumed_from.map(str::to_owned),
245            turns_used: 0,
246            mcp_tool_names: Vec::new(),
247        };
248        let task_id = task_id.to_owned();
249        run_blocking(move || {
250            if max_files > 0
251                && let Err(e) = sweep_old_transcripts(&dir, max_files)
252            {
253                tracing::warn!(error = %e, "transcript sweep failed");
254            }
255            match TranscriptWriter::new(&path) {
256                Ok(w) => {
257                    if let Err(e) = TranscriptWriter::write_meta(&dir, &task_id, &meta) {
258                        tracing::warn!(error = %e, "failed to write initial transcript meta");
259                    }
260                    Some(w)
261                }
262                Err(e) => {
263                    tracing::warn!(error = %e, "failed to create transcript writer");
264                    None
265                }
266            }
267        })
268    }
269}
270
271/// Run a blocking closure without stalling the Tokio executor.
272///
273/// On a multi-thread runtime, delegates to [`tokio::task::block_in_place`] so other
274/// tasks can continue running while the blocking work executes. On a `current_thread`
275/// runtime (unit tests, single-threaded entry points) calls the closure directly,
276/// since there is no thread pool to offload to and `block_in_place` would panic.
277fn run_blocking<T>(f: impl FnOnce() -> T) -> T {
278    if tokio::runtime::Handle::try_current()
279        .is_ok_and(|h| h.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread)
280    {
281        tokio::task::block_in_place(f)
282    } else {
283        f()
284    }
285}