Skip to main content

agent_engine/tools/subagent/
resume.rs

1//! SubagentResumeTool — continue a finished subagent with new instructions.
2//!
3//! The worker's own conversation (from its resume archive, see `archive.rs`) is
4//! replayed as the new run's history, with the instructions as the next user
5//! turn, so it continues where it stopped rather than starting over. That works
6//! for any terminal status and after the handle was collected or reaped; an
7//! `archive_path` resumes a worker of an earlier or crashed session. Without an
8//! archive (legacy), the prior run's text is pasted into a fresh task. The caller
9//! gets a new `handle_id` for the continuation run.
10
11use serde_json::{json, Value};
12use std::sync::atomic::Ordering;
13use std::sync::{Arc, RwLock};
14use tokio::sync::{mpsc, oneshot};
15
16use super::super::{Tool, ToolContext, NEXT_SUBAGENT_ID};
17use crate::runtime::subagent::{SubagentHandle, SubagentResult, SubagentState, SubagentStatus};
18use crate::{AgentEvent, Result, RuntimeError};
19
20pub struct SubagentResumeTool;
21
22fn expired_context_error(handle_id: &str) -> RuntimeError {
23    RuntimeError::Tool(format!(
24        "Subagent '{}' has expired resumable context; call subagent_collect with reconciled=true to reconcile its retained tombstone.",
25        handle_id
26    ))
27}
28
29/// Identity and opening messages of a resumed run.
30#[derive(Debug)]
31struct PriorRun {
32    agent_name: String,
33    model: String,
34    system_prompt: String,
35    timeout_secs: u64,
36    messages: Vec<crate::SharedMessage>,
37}
38
39fn prior_run(
40    registry: &std::sync::Mutex<crate::runtime::subagent::SubagentRegistry>,
41    prior_handle_id: &str,
42    explicit_archive: Option<std::path::PathBuf>,
43    instructions: &str,
44) -> Result<PriorRun> {
45    // Where the prior context comes from: the worker's archived conversation
46    // (any terminal handle, including collected, reaped and tombstoned ones),
47    // or else the legacy text snapshot on a live handle. An explicit
48    // archive_path belongs to another session, so the registry is skipped.
49    let (live, archive_path, legacy) = if explicit_archive.is_some() {
50        (None, explicit_archive, None)
51    } else {
52        let reg = registry.lock().unwrap_or_else(|p| p.into_inner());
53        let handle = reg.get(prior_handle_id);
54        if handle.is_some_and(|h| h.status() == SubagentStatus::Running) {
55            return Err(RuntimeError::Tool(format!(
56                "Subagent '{}' is still running. Call subagent_collect first, \
57                     or wait until it finishes.",
58                prior_handle_id
59            )));
60        }
61        if let Some(h) = handle {
62            super::archive::retry_failed_archive(&h.state, prior_handle_id);
63            let s = h.state.read().unwrap_or_else(|p| p.into_inner());
64            if s.archive_error.is_some() || (s.archive_meta.is_some() && s.archive_path.is_none()) {
65                if s.finalization_ready {
66                    if let Some(history) = &s.history {
67                        return Ok(PriorRun {
68                            agent_name: h.agent_name.clone(),
69                            model: h.model.clone(),
70                            system_prompt: h.system_prompt.clone(),
71                            timeout_secs: h.timeout_secs,
72                            messages: super::archive::resume_messages(
73                                history.iter().map(|m| m.as_ref().clone()).collect(),
74                                instructions,
75                            ),
76                        });
77                    }
78                }
79                return Err(RuntimeError::Tool(format!(
80                    "Subagent '{}' has no finalized full history: {}",
81                    prior_handle_id,
82                    s.archive_error.as_deref().unwrap_or("archive pending")
83                )));
84            }
85        }
86        let archive_path = reg.archive_path(prior_handle_id);
87        let live = handle.map(|h| {
88            (
89                h.agent_name.clone(),
90                h.model.clone(),
91                h.system_prompt.clone(),
92                h.timeout_secs,
93            )
94        });
95        let legacy = match handle {
96            Some(_) if archive_path.is_some() => None,
97            Some(h) if h.is_tombstone() => return Err(expired_context_error(prior_handle_id)),
98            Some(h) => {
99                let state = h.conversation_state();
100                Some(if state.is_empty() {
101                    h.partial_output()
102                } else {
103                    serde_json::to_string(&state).unwrap_or_else(|_| h.partial_output())
104                })
105            }
106            None if archive_path.is_some() => None,
107            None => {
108                return Err(RuntimeError::Tool(format!(
109                    "No subagent found with handle_id '{}'",
110                    prior_handle_id
111                )))
112            }
113        };
114        (live, archive_path, legacy)
115    };
116
117    let archive = match &archive_path {
118        Some(path) => Some(super::archive::read_archive(path).map_err(|e| {
119            RuntimeError::Tool(format!(
120                "Could not read the resume archive of '{}' ({}): {e}",
121                prior_handle_id,
122                path.display()
123            ))
124        })?),
125        None => None,
126    };
127    let (agent_name, model, system_prompt, timeout_secs) = match (live, &archive) {
128        (Some(live), _) => live,
129        (None, Some(a)) => (
130            a.meta.agent_name.clone(),
131            a.meta.model.clone(),
132            a.meta.system_prompt.clone(),
133            a.meta.timeout_secs,
134        ),
135        (None, None) => {
136            return Err(RuntimeError::Tool(format!(
137                "No subagent found with handle_id '{}'",
138                prior_handle_id
139            )))
140        }
141    };
142    let messages: Vec<crate::SharedMessage> = match (archive, legacy) {
143        (Some(a), _) => super::archive::resume_messages(a.history, instructions),
144        // Legacy: no archived conversation, only the prior run's text.
145        (None, Some(prior_context)) => vec![Arc::new(json!({
146            "role": "user",
147            "content": format!(
148                "{instructions}\n\n---\n[Prior conversation context from handle {prior_handle_id}]\n{prior_context}"
149            )
150        }))],
151        (None, None) => unreachable!("either an archive or a legacy snapshot was found"),
152    };
153    Ok(PriorRun {
154        agent_name,
155        model,
156        system_prompt,
157        timeout_secs,
158        messages,
159    })
160}
161
162#[async_trait::async_trait]
163impl Tool for SubagentResumeTool {
164    fn origin(&self) -> crate::tools::ToolOrigin {
165        crate::tools::ToolOrigin::Builtin
166    }
167
168    fn name(&self) -> &str {
169        "subagent_resume"
170    }
171
172    fn description(&self) -> &str {
173        "Resume a finished, timed-out, failed or cancelled reactive subagent with new \
174         instructions. The worker continues its own archived conversation (every tool \
175         call and result) with the instructions as the next user turn, so it picks up \
176         where it stopped. Works after the handle was collected or reaped. For a worker \
177         from an earlier or crashed session, pass archive_path. Returns a new handle_id."
178    }
179
180    fn parameters(&self) -> Value {
181        json!({
182            "type": "object",
183            "properties": {
184                "handle_id": {
185                    "type": "string",
186                    "description": "Handle ID of the completed subagent to resume (e.g. \"sa_3\")."
187                },
188                "instructions": {
189                    "type": "string",
190                    "description": "What to do next. Sent as the next user turn after the \
191                                    worker's own prior conversation."
192                },
193                "archive_path": {
194                    "type": "string",
195                    "description": "Optional: a resume archive (.json under \
196                                    ~/.synaps-cli/subagent-history/) of a worker from an \
197                                    earlier or crashed session. handle_id is then only a label."
198                },
199                "timeout": {
200                    "type": "integer",
201                    "description": "Optional wall-clock limit in seconds for the resumed run \
202                                    (default: the prior run's); 0 = no limit."
203                }
204            },
205            "required": ["handle_id", "instructions"]
206        })
207    }
208
209    async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
210        let prior_handle_id = params["handle_id"]
211            .as_str()
212            .ok_or_else(|| RuntimeError::Tool("Missing 'handle_id' parameter".to_string()))?
213            .to_string();
214
215        let instructions = params["instructions"]
216            .as_str()
217            .ok_or_else(|| RuntimeError::Tool("Missing 'instructions' parameter".to_string()))?
218            .to_string();
219
220        let registry = ctx.capabilities.subagent_registry.as_ref().ok_or_else(|| {
221            RuntimeError::Tool("SubagentRegistry not available on this ToolContext".to_string())
222        })?;
223
224        let explicit_archive = match params["archive_path"].as_str() {
225            Some(raw) => {
226                Some(super::archive::checked_archive_path(raw).map_err(RuntimeError::Tool)?)
227            }
228            None => None,
229        };
230
231        let prior = prior_run(registry, &prior_handle_id, explicit_archive, &instructions)?;
232        let (agent_name, model, system_prompt, initial_messages) = (
233            prior.agent_name,
234            prior.model,
235            prior.system_prompt,
236            prior.messages,
237        );
238        let timeout_secs = params["timeout"].as_u64().unwrap_or(prior.timeout_secs);
239
240        // The inherited prior model is still re-authorized as an explicit exact
241        // identity; a stale handle cannot bypass current session policy.
242        let label = agent_name.clone();
243        let task_preview: String = instructions.chars().take(80).collect();
244        let task_full = instructions.clone();
245        let subagent_id = NEXT_SUBAGENT_ID.fetch_add(1, Ordering::Relaxed);
246        let handle_id = format!("sa_{}", subagent_id);
247        let orchestration = ctx
248            .capabilities
249            .orchestration
250            .as_ref()
251            .ok_or_else(|| RuntimeError::Tool("delegation policy unavailable".into()))?;
252        orchestration
253            .reserve_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref())
254            .map_err(|reason| {
255                RuntimeError::Tool(format!("delegation tree budget denied: {reason:?}"))
256            })?;
257        let decision = orchestration
258            .resolve_and_authorize(&handle_id, Some(&model))
259            .map_err(|error| {
260                orchestration
261                    .release_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref());
262                RuntimeError::Tool(error.to_string())
263            })?;
264        let model = decision.model.as_str().to_owned();
265        let codex_parent_plan = ctx.capabilities.codex_parent_plan.clone();
266        let memory_backend = ctx.capabilities.memory_backend.clone();
267
268        tracing::info!(
269            "subagent_resume: dispatching '{}' (id={}, resumed_from={}) model={}",
270            label,
271            handle_id,
272            prior_handle_id,
273            model
274        );
275
276        let state = Arc::new(RwLock::new(SubagentState::new()));
277        state.write().unwrap().archive_meta = Some(crate::runtime::subagent::SubagentArchiveMeta {
278            agent_name: label.clone(),
279            model: model.clone(),
280            system_prompt: system_prompt.clone(),
281            timeout_secs,
282        });
283
284        state.write().unwrap().archive_dir = Some(super::archive::process_dir());
285
286        // Keep the inherited checkpoint even if runtime startup fails before emitting history.
287        state.write().unwrap().history = Some(initial_messages.clone());
288
289        let (steer_tx, steer_rx) = mpsc::unbounded_channel::<String>();
290        let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
291        let (result_tx, result_rx) = oneshot::channel::<SubagentResult>();
292
293        if let Some(ref tx) = ctx.channels.tx_events {
294            let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentStart {
295                subagent_id,
296                agent_name: label.clone(),
297                task_preview: task_preview.clone(),
298            }));
299        }
300
301        let state_t = Arc::clone(&state);
302        let task_full_a = task_full.clone();
303        let label_inner = label.clone();
304        let model_inner = model.clone();
305        let tx_events_inner = ctx.channels.tx_events.clone();
306        let start_time = std::time::Instant::now();
307        let parent_queue = ctx.capabilities.event_queue.clone();
308        let handle_id_inner = handle_id.clone();
309        let prior_handle_for_finalizer = prior_handle_id.clone();
310        let child_parent_id = handle_id.clone();
311
312        // ── Build and register handle BEFORE spawning ─────────────────────────
313        let system_prompt_for_handle = system_prompt.clone();
314        let handle = SubagentHandle::new(
315            handle_id.clone(),
316            subagent_id,
317            label.clone(),
318            task_preview,
319            model.clone(),
320            system_prompt_for_handle,
321            timeout_secs,
322            Arc::clone(&state),
323            Some(steer_tx),
324            Some(shutdown_tx),
325            Some(result_rx),
326        )
327        .with_authorization(&decision);
328        {
329            let mut reg = registry.lock().unwrap();
330            reg.register_with_cancellation(handle, ctx.capabilities.launch_cancel.as_ref());
331        }
332
333        let orchestration_for_worker = Arc::clone(orchestration);
334        let parent_for_worker = ctx.capabilities.delegation_parent.clone();
335        if let Err(error) = orchestration.mark_starting(&handle_id) {
336            orchestration.rollback(&handle_id);
337            orchestration
338                .release_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref());
339            return Err(RuntimeError::Tool(error));
340        }
341        // ── Spawn subagent thread ──────────────────────────────────────────────
342        let thread_handle = std::thread::spawn(move || {
343            // Pre-clone for finalizer — catch_unwind moves state_t and label_inner
344            let state_for_finalizer = Arc::clone(&state_t);
345            let label_for_finalizer = label_inner.clone();
346            let orchestration_for_runtime = Arc::clone(&orchestration_for_worker);
347
348            let mut pending_result = None;
349            let panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
350                let rt = match tokio::runtime::Builder::new_current_thread()
351                    .enable_all()
352                    .build()
353                {
354                    Ok(rt) => rt,
355                    Err(_) => {
356                        state_t.write().unwrap().status =
357                            SubagentStatus::Failed("runtime initialization failed".into());
358                        return;
359                    }
360                };
361
362                let state_a = Arc::clone(&state_t);
363                let label_a = label_inner.clone();
364                let model_a = model_inner.clone();
365                let tx_events_a = tx_events_inner.clone();
366                let task_for_timeout = task_full_a.clone();
367                let task_for_complete = task_full_a;
368
369                let outcome: std::result::Result<SubagentResult, String> =
370                    rt.block_on(async move {
371                        // Host-built worker (shared client/creds/token cache, cached
372                        // registry) or the legacy fresh runtime — see `spawn_runtime`.
373                        let mut runtime = match super::spawn_runtime().await {
374                            Ok(r) => r,
375                            Err(_) => return Err("subagent runtime initialization failed".into()),
376                        };
377
378                        // Apply subagent spawn policy: worker role, 5m cache TTL, worker
379                        // turn budget. Subagents are short-lived one-shots — paying the 1h
380                        // write premium (~2× input price) on them is unrecoverable waste
381                        // (~$0.23 per 10-spawn fan-out). (#110)
382                        super::apply_subagent_runtime_policy(
383                            &mut runtime,
384                            &crate::config::load_config(),
385                            memory_backend.as_ref(),
386                        );
387                        runtime.set_system_prompt(super::compose_system_prompt(
388                            system_prompt,
389                            runtime.memory_backend_is_axel(),
390                        ));
391                        runtime.set_model(model_a.clone());
392                        super::apply_codex_worker_reasoning(
393                            &mut runtime,
394                            codex_parent_plan.as_ref(),
395                        );
396                        runtime
397                            .install_worker_orchestration(Arc::clone(&orchestration_for_runtime));
398                        runtime.set_delegation_parent(Some(child_parent_id.clone()));
399
400                        let cancel = crate::CancellationToken::new();
401                        let cancel_inner = cancel.clone();
402                        tokio::spawn(async move {
403                            let _ = shutdown_rx.await;
404                            cancel_inner.cancel();
405                        });
406
407                        let cancel_on_timeout = cancel.clone();
408                        let stream = runtime.run_stream_with_messages(
409                                initial_messages,
410                                cancel,
411                                Some(steer_rx),
412                                None,
413                                false,
414                            ).await;
415
416                        super::drive::WorkerDrive {
417                            state: Arc::clone(&state_a),
418                            tx_events: tx_events_a.clone(),
419                            subagent_id,
420                            label: label_a.clone(),
421                            model: model_a.clone(),
422                            timeout_secs,
423                            cancel: cancel_on_timeout,
424                            task: task_for_timeout,
425                        }
426                        .run(stream)
427                        .await
428                    });
429
430                match outcome {
431                    Ok(sa_result) => {
432                        {
433                            let mut s = state_t.write().unwrap();
434                            if matches!(s.status, SubagentStatus::Running) && !s.cancel_requested {
435                                s.status = SubagentStatus::Completed;
436                                s.conversation_state = vec![
437                                    serde_json::json!({"role": "user", "content": task_for_complete.clone()}),
438                                    serde_json::json!({"role": "assistant", "content": sa_result.text.clone()}),
439                                ];
440                            }
441                        }
442                        pending_result = Some(sa_result);
443                    }
444                    Err(e) => {
445                        state_t.write().unwrap().status = SubagentStatus::Failed(e);
446                    }
447                }
448            }));
449
450            if let Err(panic_info) = panic_result {
451                let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
452                    s.to_string()
453                } else if let Some(s) = panic_info.downcast_ref::<String>() {
454                    s.clone()
455                } else {
456                    "unknown panic".to_string()
457                };
458                tracing::error!("Resumed subagent thread panicked: {}", msg);
459                state_t.write().unwrap_or_else(|p| p.into_inner()).status =
460                    SubagentStatus::Failed(format!("panic: {}", msg));
461            }
462
463            // ── Terminal finalizer — exactly once, outside catch_unwind ────────
464            // Covers all paths: Ok, Err, timeout, panic, early tokio-build failure.
465            // Sets data.resumed_from so parent can correlate with the prior handle.
466            super::finalize::finalize_subagent(
467                &state_for_finalizer,
468                parent_queue.as_ref(),
469                &handle_id_inner,
470                subagent_id,
471                &label_for_finalizer,
472                start_time,
473                Some(&prior_handle_for_finalizer),
474            );
475            // All terminal publications follow the recovery/finalization barrier.
476            if let Some(ref tx) = tx_events_inner {
477                let s = state_for_finalizer
478                    .read()
479                    .unwrap_or_else(|p| p.into_inner());
480                let preview = match s.status.failure_reason() {
481                    Some(reason) => format!("ERROR: {reason}"),
482                    None => s.partial_text.chars().take(120).collect(),
483                };
484                let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
485                    subagent_id,
486                    agent_name: label_for_finalizer.clone(),
487                    result_preview: preview,
488                    duration_secs: start_time.elapsed().as_secs_f64(),
489                }));
490            }
491            if let Some(result) = pending_result {
492                let _ = result_tx.send(result);
493            }
494            orchestration_for_worker
495                .release_delegation(&handle_id_inner, parent_for_worker.as_deref());
496        });
497
498        // ── Wire thread handle into the already-registered entry ─────────────
499        {
500            let mut reg = registry.lock().unwrap();
501            if let Some(h) = reg.get_mut(&handle_id) {
502                h.set_thread_handle(thread_handle);
503            }
504        }
505        if let Err(error) = orchestration.mark_running(&handle_id) {
506            let mut handle = registry.lock().unwrap().remove(&handle_id);
507            if let Some(handle) = handle.as_mut() {
508                handle.cancel();
509            }
510            if let Some(handle) = handle {
511                let _ = handle.collect().await;
512            }
513            orchestration.rollback(&handle_id);
514            orchestration
515                .release_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref());
516            return Err(RuntimeError::Tool(error));
517        }
518
519        Ok(json!({
520            "handle_id":    handle_id,
521            "resumed_from": prior_handle_id,
522            "agent_name":   label,
523            "status":       "running"
524        })
525        .to_string())
526    }
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532    use crate::runtime::subagent::{reap_finished_with_ttl, SubagentRegistry};
533    use crate::tools::test_helpers::create_tool_context;
534    use std::sync::Mutex;
535    use std::time::Duration;
536
537    fn finished_handle(
538        id: &str,
539        status: SubagentStatus,
540        archive_path: Option<std::path::PathBuf>,
541    ) -> SubagentHandle {
542        let state = Arc::new(RwLock::new(SubagentState::new()));
543        {
544            let mut s = state.write().unwrap();
545            s.status = status;
546            s.partial_text = "prior text".into();
547            s.conversation_state = vec![json!({"role": "assistant", "content": "prior text"})];
548            s.finished_at = Some(std::time::Instant::now());
549            s.archive_path = archive_path;
550        }
551        let (steer_tx, _steer_rx) = mpsc::unbounded_channel();
552        let (shutdown_tx, _shutdown_rx) = oneshot::channel();
553        let (_result_tx, result_rx) = oneshot::channel();
554        SubagentHandle::new(
555            id.into(),
556            1,
557            "live-label".into(),
558            "task".into(),
559            "anthropic/claude-sonnet-4-6".into(),
560            "live system".into(),
561            30,
562            state,
563            Some(steer_tx),
564            Some(shutdown_tx),
565            Some(result_rx),
566        )
567    }
568
569    fn archive_in(dir: &std::path::Path, id: &str) -> std::path::PathBuf {
570        let meta = crate::runtime::subagent::SubagentArchiveMeta {
571            agent_name: "archived-label".into(),
572            model: "anthropic/claude-opus-5-5".into(),
573            system_prompt: "archived system".into(),
574            timeout_secs: 3500,
575        };
576        let history: Vec<crate::SharedMessage> = vec![
577            Arc::new(json!({"role": "user", "content": "build the plate"})),
578            Arc::new(json!({"role": "assistant", "content": [
579                {"type": "tool_use", "id": "t1", "name": "bash", "input": {"command": "render"}}
580            ]})),
581            Arc::new(json!({"role": "user", "content": [
582                {"type": "tool_result", "tool_use_id": "t1", "content": "rendered 300 frames"}
583            ]})),
584        ];
585        super::super::archive::write_archive(dir, id, &meta, "timed_out", "report", &history)
586            .unwrap()
587    }
588
589    fn assert_continues_archive(prior: &PriorRun, instructions: &str) {
590        assert_eq!(
591            prior.messages.len(),
592            3,
593            "archived turns replayed, instructions merged"
594        );
595        assert_eq!(prior.messages[0]["content"], "build the plate");
596        let last = &prior.messages[2]["content"];
597        assert_eq!(last[0]["content"], "rendered 300 frames");
598        assert!(last[1]["text"].as_str().unwrap().ends_with(instructions));
599    }
600
601    #[test]
602    fn reaped_worker_resumes_from_its_archive() {
603        let dir = tempfile::tempdir().unwrap();
604        let path = archive_in(dir.path(), "sa_reaped");
605        let registry = Mutex::new(SubagentRegistry::new());
606        {
607            let mut reg = registry.lock().unwrap();
608            let mut h = finished_handle("sa_reaped", SubagentStatus::TimedOut, Some(path));
609            h.mark_collected();
610            reg.register(h);
611            reg.cleanup_finished_with_ttl(Duration::ZERO);
612            assert!(reg.get("sa_reaped").is_none(), "handle reaped");
613        }
614        let prior = prior_run(&registry, "sa_reaped", None, "render pass 3").unwrap();
615        assert_eq!(prior.agent_name, "archived-label");
616        assert_eq!(prior.model, "anthropic/claude-opus-5-5");
617        assert_eq!(prior.system_prompt, "archived system");
618        assert_eq!(prior.timeout_secs, 3500);
619        assert_continues_archive(&prior, "render pass 3");
620    }
621
622    #[test]
623    fn tombstoned_worker_with_archive_resumes() {
624        let dir = tempfile::tempdir().unwrap();
625        let path = archive_in(dir.path(), "sa_tomb");
626        let registry = Mutex::new(SubagentRegistry::new());
627        {
628            let mut reg = registry.lock().unwrap();
629            reg.register(finished_handle(
630                "sa_tomb",
631                SubagentStatus::Failed("x".into()),
632                Some(path),
633            ));
634            reg.release_finished_resources("sa_tomb");
635            assert!(reg.get("sa_tomb").unwrap().is_tombstone());
636        }
637        let prior = prior_run(&registry, "sa_tomb", None, "continue").unwrap();
638        // A live handle's identity wins over the archive's.
639        assert_eq!(prior.agent_name, "live-label");
640        assert_eq!(prior.timeout_secs, 30);
641        assert_continues_archive(&prior, "continue");
642    }
643
644    #[test]
645    fn review_202_terminal_but_unpublished_worker_must_not_resume_lossily() {
646        let registry = Mutex::new(SubagentRegistry::new());
647        let h = finished_handle("sa_review", SubagentStatus::TimedOut, None);
648        {
649            let mut s = h.state.write().unwrap();
650            // Unlike the intentionally legacy helper, a managed worker has metadata.
651            s.archive_meta = Some(crate::runtime::subagent::SubagentArchiveMeta {
652                agent_name: "worker".into(),
653                model: "model".into(),
654                system_prompt: "system".into(),
655                timeout_secs: 1,
656            });
657            s.finished_at = None;
658        }
659        let state = Arc::clone(&h.state);
660        registry.lock().unwrap().register(h);
661        assert!(prior_run(&registry, "sa_review", None, "continue").is_err());
662        registry
663            .lock()
664            .unwrap()
665            .cleanup_finished_with_ttl(Duration::ZERO);
666        assert!(registry.lock().unwrap().get("sa_review").is_some());
667        let dir = tempfile::tempdir().unwrap();
668        let blocked = dir.path().join("blocked");
669        std::fs::write(&blocked, b"x").unwrap();
670        let history = vec![
671            Arc::new(json!({"role":"user", "content":"task"})),
672            Arc::new(json!({"role":"assistant", "content":[
673                {"type":"tool_use", "id":"t1", "name":"read", "input":{"path":"x"}}
674            ]})),
675            Arc::new(json!({"role":"user", "content":[
676                {"type":"tool_result", "tool_use_id":"t1", "content":"preserved body"}
677            ]})),
678        ];
679        state.write().unwrap().history = Some(history);
680        super::super::finalize::finalize_subagent_in(
681            &state,
682            None,
683            "sa_review",
684            1,
685            "worker",
686            std::time::Instant::now(),
687            None,
688            &blocked,
689        );
690        // Keep this test hermetic: missing metadata prevents the production-dir retry.
691        state.write().unwrap().archive_meta = None;
692        let prior = prior_run(&registry, "sa_review", None, "continue").unwrap();
693        assert_eq!(prior.messages[2]["content"][0]["content"], "preserved body");
694        {
695            let mut reg = registry.lock().unwrap();
696            reg.get_mut("sa_review").unwrap().mark_collected();
697            reg.release_finished_resources("sa_review");
698            reg.cleanup_finished_with_ttl(Duration::ZERO);
699            assert!(reg.get("sa_review").is_some());
700        }
701        assert!(state.read().unwrap().history.is_some());
702    }
703
704    #[test]
705    fn running_worker_cannot_be_resumed() {
706        let registry = Mutex::new(SubagentRegistry::new());
707        registry
708            .lock()
709            .unwrap()
710            .register(finished_handle("sa_run", SubagentStatus::Running, None));
711        let err = prior_run(&registry, "sa_run", None, "x")
712            .unwrap_err()
713            .to_string();
714        assert!(err.contains("still running"), "{err}");
715    }
716
717    #[test]
718    fn explicit_archive_ignores_the_registry() {
719        // An archive from an earlier session can share a handle id with a
720        // worker running now; the registry must not be consulted.
721        let dir = tempfile::tempdir().unwrap();
722        let path = archive_in(dir.path(), "sa_3");
723        let registry = Mutex::new(SubagentRegistry::new());
724        registry
725            .lock()
726            .unwrap()
727            .register(finished_handle("sa_3", SubagentStatus::Running, None));
728        let prior = prior_run(&registry, "sa_3", Some(path), "pick up").unwrap();
729        assert_eq!(prior.agent_name, "archived-label");
730        assert_continues_archive(&prior, "pick up");
731    }
732
733    #[test]
734    fn legacy_handle_without_archive_pastes_prior_text() {
735        let registry = Mutex::new(SubagentRegistry::new());
736        registry.lock().unwrap().register(finished_handle(
737            "sa_old",
738            SubagentStatus::Completed,
739            None,
740        ));
741        let prior = prior_run(&registry, "sa_old", None, "next").unwrap();
742        assert_eq!(prior.messages.len(), 1);
743        let text = prior.messages[0]["content"].as_str().unwrap();
744        assert!(text.starts_with("next") && text.contains("prior text"));
745    }
746
747    #[test]
748    fn unknown_handle_without_archive_is_not_found() {
749        let registry = Mutex::new(SubagentRegistry::new());
750        let err = prior_run(&registry, "sa_none", None, "x")
751            .unwrap_err()
752            .to_string();
753        assert!(err.contains("No subagent found"), "{err}");
754    }
755
756    #[tokio::test]
757    async fn tombstone_resume_reports_expired_context_without_side_effects() {
758        let state = Arc::new(RwLock::new(SubagentState::new()));
759        {
760            let mut state = state.write().unwrap();
761            state.status = SubagentStatus::Completed;
762            state.partial_text = "terminal output".into();
763            state.conversation_state = vec![json!({"role": "assistant", "content": "context"})];
764            state.finished_at = Some(std::time::Instant::now());
765        }
766        let (steer_tx, _steer_rx) = mpsc::unbounded_channel();
767        let (shutdown_tx, _shutdown_rx) = oneshot::channel();
768        let (_result_tx, result_rx) = oneshot::channel();
769        let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
770        registry.lock().unwrap().register(SubagentHandle::new(
771            "sa_expired".into(),
772            1,
773            "test".into(),
774            "task".into(),
775            "anthropic/claude-sonnet-4-6".into(),
776            "system".into(),
777            30,
778            state,
779            Some(steer_tx),
780            Some(shutdown_tx),
781            Some(result_rx),
782        ));
783        let foreground =
784            agent_core::prompt::QualifiedModelId::parse("anthropic/claude-sonnet-4-6").unwrap();
785        let orchestration = Arc::new(
786            crate::orchestration::OrchestrationRuntime::baseline(foreground, 8, 64).unwrap(),
787        );
788        orchestration
789            .authorize("sa_expired", "anthropic/claude-sonnet-4-6")
790            .unwrap();
791        let gate_before = orchestration.completion_gate();
792        reap_finished_with_ttl(&registry, Some(orchestration.as_ref()), Duration::ZERO);
793        assert!(registry
794            .lock()
795            .unwrap()
796            .get("sa_expired")
797            .unwrap()
798            .is_tombstone());
799        let count_before = registry.lock().unwrap().list_active().len();
800
801        let mut ctx = create_tool_context();
802        ctx.capabilities.subagent_registry = Some(registry.clone());
803        ctx.capabilities.orchestration = Some(orchestration.clone());
804        let error = SubagentResumeTool
805            .execute(
806                json!({"handle_id": "sa_expired", "instructions": "continue"}),
807                ctx,
808            )
809            .await
810            .unwrap_err()
811            .to_string();
812        assert!(error.contains("expired resumable context"));
813        assert!(!error.contains("No subagent found"));
814        assert_eq!(registry.lock().unwrap().list_active().len(), count_before);
815        assert_eq!(orchestration.completion_gate(), gate_before);
816    }
817}