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    // A startup-failure archive holds no conversation: resuming it would
128    // start the worker blank under a preamble claiming earlier work.
129    if let Some(a) = archive.as_ref().filter(|a| a.history.is_empty()) {
130        return Err(RuntimeError::Tool(format!(
131            "Subagent '{prior_handle_id}' has no conversation to resume: it exited before its \
132             first history checkpoint ({}). Start a new subagent instead.",
133            a.error.as_deref().unwrap_or(&a.status)
134        )));
135    }
136    let (agent_name, model, system_prompt, timeout_secs) = match (live, &archive) {
137        (Some(live), _) => live,
138        (None, Some(a)) => (
139            a.meta.agent_name.clone(),
140            a.meta.model.clone(),
141            a.meta.system_prompt.clone(),
142            a.meta.timeout_secs,
143        ),
144        (None, None) => {
145            return Err(RuntimeError::Tool(format!(
146                "No subagent found with handle_id '{}'",
147                prior_handle_id
148            )))
149        }
150    };
151    let messages: Vec<crate::SharedMessage> = match (archive, legacy) {
152        (Some(a), _) => super::archive::resume_messages(a.history, instructions),
153        // Legacy: no archived conversation, only the prior run's text.
154        (None, Some(prior_context)) => vec![Arc::new(json!({
155            "role": "user",
156            "content": format!(
157                "{instructions}\n\n---\n[Prior conversation context from handle {prior_handle_id}]\n{prior_context}"
158            )
159        }))],
160        (None, None) => unreachable!("either an archive or a legacy snapshot was found"),
161    };
162    Ok(PriorRun {
163        agent_name,
164        model,
165        system_prompt,
166        timeout_secs,
167        messages,
168    })
169}
170
171#[async_trait::async_trait]
172impl Tool for SubagentResumeTool {
173    fn origin(&self) -> crate::tools::ToolOrigin {
174        crate::tools::ToolOrigin::Builtin
175    }
176
177    fn name(&self) -> &str {
178        "subagent_resume"
179    }
180
181    fn description(&self) -> &str {
182        "Resume a finished, timed-out, failed or cancelled reactive subagent with new \
183         instructions. The worker continues its own archived conversation (every tool \
184         call and result) with the instructions as the next user turn, so it picks up \
185         where it stopped. Works after the handle was collected or reaped. For a worker \
186         from an earlier or crashed session, pass archive_path. Returns a new handle_id."
187    }
188
189    fn parameters(&self) -> Value {
190        json!({
191            "type": "object",
192            "properties": {
193                "handle_id": {
194                    "type": "string",
195                    "description": "Handle ID of the completed subagent to resume (e.g. \"sa_3\")."
196                },
197                "instructions": {
198                    "type": "string",
199                    "description": "What to do next. Sent as the next user turn after the \
200                                    worker's own prior conversation."
201                },
202                "archive_path": {
203                    "type": "string",
204                    "description": "Optional: a resume archive (.json under \
205                                    ~/.synaps-cli/subagent-history/) of a worker from an \
206                                    earlier or crashed session. handle_id is then only a label."
207                },
208                "timeout": {
209                    "type": "integer",
210                    "description": "Optional wall-clock limit in seconds for the resumed run \
211                                    (default: the prior run's); 0 = no limit. Positive values below 300s emit a warning; prefer at least 600s for API retries."
212                }
213            },
214            "required": ["handle_id", "instructions"]
215        })
216    }
217
218    async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
219        let prior_handle_id = params["handle_id"]
220            .as_str()
221            .ok_or_else(|| RuntimeError::Tool("Missing 'handle_id' parameter".to_string()))?
222            .to_string();
223
224        let instructions = params["instructions"]
225            .as_str()
226            .ok_or_else(|| RuntimeError::Tool("Missing 'instructions' parameter".to_string()))?
227            .to_string();
228
229        let registry = ctx.capabilities.subagent_registry.as_ref().ok_or_else(|| {
230            RuntimeError::Tool("SubagentRegistry not available on this ToolContext".to_string())
231        })?;
232
233        let explicit_archive = match params["archive_path"].as_str() {
234            Some(raw) => {
235                Some(super::archive::checked_archive_path(raw).map_err(RuntimeError::Tool)?)
236            }
237            None => None,
238        };
239
240        let prior = prior_run(registry, &prior_handle_id, explicit_archive, &instructions)?;
241        let (agent_name, model, system_prompt, initial_messages) = (
242            prior.agent_name,
243            prior.model,
244            prior.system_prompt,
245            prior.messages,
246        );
247        let timeout_secs = params["timeout"].as_u64().unwrap_or(prior.timeout_secs);
248        let timeout_warning = short_timeout_warning(timeout_secs);
249        if let Some(warning) = timeout_warning {
250            tracing::warn!(timeout_secs, warning, "short subagent resume timeout");
251        }
252
253        // The inherited prior model is still re-authorized as an explicit exact
254        // identity; a stale handle cannot bypass current session policy.
255        let label = agent_name.clone();
256        let task_preview: String = instructions.chars().take(80).collect();
257        let task_full = instructions.clone();
258        let subagent_id = NEXT_SUBAGENT_ID.fetch_add(1, Ordering::Relaxed);
259        let handle_id = format!("sa_{}", subagent_id);
260        let orchestration = ctx
261            .capabilities
262            .orchestration
263            .as_ref()
264            .ok_or_else(|| RuntimeError::Tool("delegation policy unavailable".into()))?;
265        orchestration
266            .reserve_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref())
267            .map_err(|reason| {
268                RuntimeError::Tool(format!("delegation tree budget denied: {reason:?}"))
269            })?;
270        let decision = orchestration
271            .resolve_and_authorize(&handle_id, Some(&model))
272            .map_err(|error| {
273                orchestration
274                    .release_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref());
275                RuntimeError::Tool(error.to_string())
276            })?;
277        let model = decision.model.as_str().to_owned();
278        let codex_parent_plan = ctx.capabilities.codex_parent_plan.clone();
279        let memory_backend = ctx.capabilities.memory_backend.clone();
280        let session_allow_all = ctx.capabilities.session_allow_all.clone();
281        let telemetry = ctx.capabilities.telemetry.clone();
282
283        tracing::info!(
284            "subagent_resume: dispatching '{}' (id={}, resumed_from={}) model={}",
285            label,
286            handle_id,
287            prior_handle_id,
288            model
289        );
290
291        let state = Arc::new(RwLock::new(SubagentState::new()));
292        state.write().unwrap().archive_meta = Some(crate::runtime::subagent::SubagentArchiveMeta {
293            agent_name: label.clone(),
294            model: model.clone(),
295            system_prompt: system_prompt.clone(),
296            timeout_secs,
297        });
298
299        state.write().unwrap().archive_dir = Some(super::archive::process_dir());
300
301        // Keep the inherited checkpoint even if runtime startup fails before emitting history.
302        state.write().unwrap().history = Some(initial_messages.clone());
303
304        let (steer_tx, steer_rx) = mpsc::unbounded_channel::<String>();
305        let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
306        let (result_tx, result_rx) = oneshot::channel::<SubagentResult>();
307
308        if let Some(ref tx) = ctx.channels.tx_events {
309            let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentStart {
310                subagent_id,
311                agent_name: label.clone(),
312                task_preview: task_preview.clone(),
313            }));
314        }
315
316        let state_t = Arc::clone(&state);
317        let task_full_a = task_full.clone();
318        let label_inner = label.clone();
319        let model_inner = model.clone();
320        let tx_events_inner = ctx.channels.tx_events.clone();
321        let start_time = std::time::Instant::now();
322        let parent_queue = ctx.capabilities.event_queue.clone();
323        let handle_id_inner = handle_id.clone();
324        let prior_handle_for_finalizer = prior_handle_id.clone();
325        let child_parent_id = handle_id.clone();
326
327        // ── Build and register handle BEFORE spawning ─────────────────────────
328        let system_prompt_for_handle = system_prompt.clone();
329        let handle = SubagentHandle::new(
330            handle_id.clone(),
331            subagent_id,
332            label.clone(),
333            task_preview,
334            model.clone(),
335            system_prompt_for_handle,
336            timeout_secs,
337            Arc::clone(&state),
338            Some(steer_tx),
339            Some(shutdown_tx),
340            Some(result_rx),
341        )
342        .with_authorization(&decision);
343        {
344            let mut reg = registry.lock().unwrap();
345            reg.register_with_cancellation(handle, ctx.capabilities.launch_cancel.as_ref());
346        }
347
348        let orchestration_for_worker = Arc::clone(orchestration);
349        let parent_for_worker = ctx.capabilities.delegation_parent.clone();
350        if let Err(error) = orchestration.mark_starting(&handle_id) {
351            orchestration.rollback(&handle_id);
352            orchestration
353                .release_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref());
354            return Err(RuntimeError::Tool(error));
355        }
356        // ── Spawn subagent thread ──────────────────────────────────────────────
357        let thread_handle = std::thread::spawn(move || {
358            // Pre-clone for finalizer — catch_unwind moves state_t and label_inner
359            let state_for_finalizer = Arc::clone(&state_t);
360            let label_for_finalizer = label_inner.clone();
361            let orchestration_for_runtime = Arc::clone(&orchestration_for_worker);
362
363            let mut pending_result = None;
364            let panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
365                let rt = match tokio::runtime::Builder::new_current_thread()
366                    .enable_all()
367                    .build()
368                {
369                    Ok(rt) => rt,
370                    Err(_) => {
371                        state_t.write().unwrap().status =
372                            SubagentStatus::Failed("runtime initialization failed".into());
373                        return;
374                    }
375                };
376
377                let state_a = Arc::clone(&state_t);
378                let label_a = label_inner.clone();
379                let model_a = model_inner.clone();
380                let tx_events_a = tx_events_inner.clone();
381                let task_for_timeout = task_full_a.clone();
382                let task_for_complete = task_full_a;
383
384                let outcome: std::result::Result<SubagentResult, String> =
385                    rt.block_on(async move {
386                        // Host-built worker (shared client/creds/token cache, cached
387                        // registry) or the legacy fresh runtime — see `spawn_runtime`.
388                        let mut runtime = match super::spawn_runtime().await {
389                            Ok(r) => r,
390                            Err(_) => return Err("subagent runtime initialization failed".into()),
391                        };
392
393                        // Apply subagent spawn policy: worker role, 5m cache TTL, worker
394                        // turn budget. Subagents are short-lived one-shots — paying the 1h
395                        // write premium (~2× input price) on them is unrecoverable waste
396                        // (~$0.23 per 10-spawn fan-out). (#110)
397                        super::apply_subagent_runtime_policy(
398                            &mut runtime,
399                            &crate::config::load_config(),
400                            memory_backend.as_ref(),
401                            session_allow_all.as_ref(),
402                            telemetry.as_ref(),
403                        );
404                        runtime.set_system_prompt(super::compose_system_prompt(
405                            system_prompt,
406                            runtime.memory_backend_is_axel(),
407                        ));
408                        runtime.set_model(model_a.clone());
409                        super::apply_codex_worker_reasoning(
410                            &mut runtime,
411                            codex_parent_plan.as_ref(),
412                        );
413                        runtime
414                            .install_worker_orchestration(Arc::clone(&orchestration_for_runtime));
415                        runtime.set_delegation_parent(Some(child_parent_id.clone()));
416
417                        let cancel = crate::CancellationToken::new();
418                        let cancel_inner = cancel.clone();
419                        tokio::spawn(async move {
420                            let _ = shutdown_rx.await;
421                            cancel_inner.cancel();
422                        });
423
424                        let cancel_on_timeout = cancel.clone();
425                        let stream = runtime.run_stream_with_messages(
426                                initial_messages,
427                                cancel,
428                                Some(steer_rx),
429                                None,
430                                false,
431                            ).await;
432
433                        super::drive::WorkerDrive {
434                            state: Arc::clone(&state_a),
435                            tx_events: tx_events_a.clone(),
436                            subagent_id,
437                            label: label_a.clone(),
438                            model: model_a.clone(),
439                            timeout_secs,
440                            cancel: cancel_on_timeout,
441                            task: task_for_timeout,
442                        }
443                        .run(stream)
444                        .await
445                    });
446
447                match outcome {
448                    Ok(sa_result) => {
449                        {
450                            let mut s = state_t.write().unwrap();
451                            if matches!(s.status, SubagentStatus::Running) && !s.cancel_requested {
452                                s.status = SubagentStatus::Completed;
453                                s.conversation_state = vec![
454                                    serde_json::json!({"role": "user", "content": task_for_complete.clone()}),
455                                    serde_json::json!({"role": "assistant", "content": sa_result.text.clone()}),
456                                ];
457                            }
458                        }
459                        pending_result = Some(sa_result);
460                    }
461                    Err(e) => {
462                        state_t.write().unwrap().status = SubagentStatus::Failed(e);
463                    }
464                }
465            }));
466
467            if let Err(panic_info) = panic_result {
468                let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
469                    s.to_string()
470                } else if let Some(s) = panic_info.downcast_ref::<String>() {
471                    s.clone()
472                } else {
473                    "unknown panic".to_string()
474                };
475                tracing::error!("Resumed subagent thread panicked: {}", msg);
476                state_t.write().unwrap_or_else(|p| p.into_inner()).status =
477                    SubagentStatus::Failed(format!("panic: {}", msg));
478            }
479
480            // ── Terminal finalizer — exactly once, outside catch_unwind ────────
481            // Covers all paths: Ok, Err, timeout, panic, early tokio-build failure.
482            // Sets data.resumed_from so parent can correlate with the prior handle.
483            super::finalize::finalize_subagent(
484                &state_for_finalizer,
485                parent_queue.as_ref(),
486                &handle_id_inner,
487                subagent_id,
488                &label_for_finalizer,
489                start_time,
490                Some(&prior_handle_for_finalizer),
491            );
492            // A cancelled worker publishes no wake, so nothing would ever
493            // prompt a collect: retire it before any terminal publication.
494            super::finalize::retire_if_cancelled(
495                &state_for_finalizer,
496                &orchestration_for_worker,
497                &handle_id_inner,
498            );
499            // All terminal publications follow the recovery/finalization barrier.
500            if let Some(ref tx) = tx_events_inner {
501                let s = state_for_finalizer
502                    .read()
503                    .unwrap_or_else(|p| p.into_inner());
504                let preview = match s.status.failure_reason() {
505                    Some(reason) => format!("ERROR: {reason}"),
506                    None => s.partial_text.chars().take(120).collect(),
507                };
508                let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
509                    subagent_id,
510                    agent_name: label_for_finalizer.clone(),
511                    result_preview: preview,
512                    duration_secs: start_time.elapsed().as_secs_f64(),
513                }));
514            }
515            if let Some(result) = pending_result {
516                let _ = result_tx.send(result);
517            }
518            orchestration_for_worker
519                .release_delegation(&handle_id_inner, parent_for_worker.as_deref());
520        });
521
522        // ── Wire thread handle into the already-registered entry ─────────────
523        {
524            let mut reg = registry.lock().unwrap();
525            if let Some(h) = reg.get_mut(&handle_id) {
526                h.set_thread_handle(thread_handle);
527            }
528        }
529        if let Err(error) = orchestration.mark_running(&handle_id) {
530            let mut handle = registry.lock().unwrap().remove(&handle_id);
531            if let Some(handle) = handle.as_mut() {
532                handle.cancel();
533            }
534            if let Some(handle) = handle {
535                let _ = handle.collect().await;
536            }
537            orchestration.rollback(&handle_id);
538            orchestration
539                .release_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref());
540            return Err(RuntimeError::Tool(error));
541        }
542        // The thread may have been cancelled and finalized before it was
543        // marked running; its own retire attempt was then too early.
544        super::finalize::retire_if_cancelled(&state, orchestration, &handle_id);
545
546        Ok(json!({
547            "handle_id":    handle_id,
548            "resumed_from": prior_handle_id,
549            "warning": timeout_warning,
550            "agent_name":   label,
551            "status":       "running"
552        })
553        .to_string())
554    }
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560    use crate::runtime::subagent::{reap_finished_with_ttl, SubagentRegistry};
561    use crate::tools::test_helpers::create_tool_context;
562    use std::sync::Mutex;
563    use std::time::Duration;
564
565    fn finished_handle(
566        id: &str,
567        status: SubagentStatus,
568        archive_path: Option<std::path::PathBuf>,
569    ) -> SubagentHandle {
570        let state = Arc::new(RwLock::new(SubagentState::new()));
571        {
572            let mut s = state.write().unwrap();
573            s.status = status;
574            s.partial_text = "prior text".into();
575            s.conversation_state = vec![json!({"role": "assistant", "content": "prior text"})];
576            s.finished_at = Some(std::time::Instant::now());
577            s.archive_path = archive_path;
578        }
579        let (steer_tx, _steer_rx) = mpsc::unbounded_channel();
580        let (shutdown_tx, _shutdown_rx) = oneshot::channel();
581        let (_result_tx, result_rx) = oneshot::channel();
582        SubagentHandle::new(
583            id.into(),
584            1,
585            "live-label".into(),
586            "task".into(),
587            "anthropic/claude-sonnet-4-6".into(),
588            "live system".into(),
589            30,
590            state,
591            Some(steer_tx),
592            Some(shutdown_tx),
593            Some(result_rx),
594        )
595    }
596
597    fn archive_in(dir: &std::path::Path, id: &str) -> std::path::PathBuf {
598        let meta = crate::runtime::subagent::SubagentArchiveMeta {
599            agent_name: "archived-label".into(),
600            model: "anthropic/claude-opus-5-5".into(),
601            system_prompt: "archived system".into(),
602            timeout_secs: 3500,
603        };
604        let history: Vec<crate::SharedMessage> = vec![
605            Arc::new(json!({"role": "user", "content": "build the plate"})),
606            Arc::new(json!({"role": "assistant", "content": [
607                {"type": "tool_use", "id": "t1", "name": "bash", "input": {"command": "render"}}
608            ]})),
609            Arc::new(json!({"role": "user", "content": [
610                {"type": "tool_result", "tool_use_id": "t1", "content": "rendered 300 frames"}
611            ]})),
612        ];
613        super::super::archive::write_archive(
614            dir,
615            id,
616            &meta,
617            "timed_out",
618            Some("worker timed out"),
619            "report",
620            &history,
621        )
622        .unwrap()
623    }
624
625    fn assert_continues_archive(prior: &PriorRun, instructions: &str) {
626        assert_eq!(
627            prior.messages.len(),
628            3,
629            "archived turns replayed, instructions merged"
630        );
631        assert_eq!(prior.messages[0]["content"], "build the plate");
632        let last = &prior.messages[2]["content"];
633        assert_eq!(last[0]["content"], "rendered 300 frames");
634        assert!(last[1]["text"].as_str().unwrap().ends_with(instructions));
635    }
636
637    #[test]
638    fn reaped_worker_resumes_from_its_archive() {
639        let dir = tempfile::tempdir().unwrap();
640        let path = archive_in(dir.path(), "sa_reaped");
641        let registry = Mutex::new(SubagentRegistry::new());
642        {
643            let mut reg = registry.lock().unwrap();
644            let mut h = finished_handle("sa_reaped", SubagentStatus::TimedOut, Some(path));
645            h.mark_collected();
646            reg.register(h);
647            reg.cleanup_finished_with_ttl(Duration::ZERO);
648            assert!(reg.get("sa_reaped").is_none(), "handle reaped");
649        }
650        let prior = prior_run(&registry, "sa_reaped", None, "render pass 3").unwrap();
651        assert_eq!(prior.agent_name, "archived-label");
652        assert_eq!(prior.model, "anthropic/claude-opus-5-5");
653        assert_eq!(prior.system_prompt, "archived system");
654        assert_eq!(prior.timeout_secs, 3500);
655        assert_continues_archive(&prior, "render pass 3");
656    }
657
658    #[test]
659    fn tombstoned_worker_with_archive_resumes() {
660        let dir = tempfile::tempdir().unwrap();
661        let path = archive_in(dir.path(), "sa_tomb");
662        let registry = Mutex::new(SubagentRegistry::new());
663        {
664            let mut reg = registry.lock().unwrap();
665            reg.register(finished_handle(
666                "sa_tomb",
667                SubagentStatus::Failed("x".into()),
668                Some(path),
669            ));
670            reg.release_finished_resources("sa_tomb");
671            assert!(reg.get("sa_tomb").unwrap().is_tombstone());
672        }
673        let prior = prior_run(&registry, "sa_tomb", None, "continue").unwrap();
674        // A live handle's identity wins over the archive's.
675        assert_eq!(prior.agent_name, "live-label");
676        assert_eq!(prior.timeout_secs, 30);
677        assert_continues_archive(&prior, "continue");
678    }
679
680    #[test]
681    fn review_202_terminal_but_unpublished_worker_must_not_resume_lossily() {
682        let registry = Mutex::new(SubagentRegistry::new());
683        let h = finished_handle("sa_review", SubagentStatus::TimedOut, None);
684        {
685            let mut s = h.state.write().unwrap();
686            // Unlike the intentionally legacy helper, a managed worker has metadata.
687            s.archive_meta = Some(crate::runtime::subagent::SubagentArchiveMeta {
688                agent_name: "worker".into(),
689                model: "model".into(),
690                system_prompt: "system".into(),
691                timeout_secs: 1,
692            });
693            s.finished_at = None;
694        }
695        let state = Arc::clone(&h.state);
696        registry.lock().unwrap().register(h);
697        assert!(prior_run(&registry, "sa_review", None, "continue").is_err());
698        registry
699            .lock()
700            .unwrap()
701            .cleanup_finished_with_ttl(Duration::ZERO);
702        assert!(registry.lock().unwrap().get("sa_review").is_some());
703        let dir = tempfile::tempdir().unwrap();
704        let blocked = dir.path().join("blocked");
705        std::fs::write(&blocked, b"x").unwrap();
706        let history = vec![
707            Arc::new(json!({"role":"user", "content":"task"})),
708            Arc::new(json!({"role":"assistant", "content":[
709                {"type":"tool_use", "id":"t1", "name":"read", "input":{"path":"x"}}
710            ]})),
711            Arc::new(json!({"role":"user", "content":[
712                {"type":"tool_result", "tool_use_id":"t1", "content":"preserved body"}
713            ]})),
714        ];
715        state.write().unwrap().history = Some(history);
716        super::super::finalize::finalize_subagent_in(
717            &state,
718            None,
719            "sa_review",
720            1,
721            "worker",
722            std::time::Instant::now(),
723            None,
724            &blocked,
725        );
726        // Keep this test hermetic: missing metadata prevents the production-dir retry.
727        state.write().unwrap().archive_meta = None;
728        let prior = prior_run(&registry, "sa_review", None, "continue").unwrap();
729        assert_eq!(prior.messages[2]["content"][0]["content"], "preserved body");
730        {
731            let mut reg = registry.lock().unwrap();
732            reg.get_mut("sa_review").unwrap().mark_collected();
733            reg.release_finished_resources("sa_review");
734            reg.cleanup_finished_with_ttl(Duration::ZERO);
735            assert!(reg.get("sa_review").is_some());
736        }
737        assert!(state.read().unwrap().history.is_some());
738    }
739
740    #[test]
741    fn running_worker_cannot_be_resumed() {
742        let registry = Mutex::new(SubagentRegistry::new());
743        registry
744            .lock()
745            .unwrap()
746            .register(finished_handle("sa_run", SubagentStatus::Running, None));
747        let err = prior_run(&registry, "sa_run", None, "x")
748            .unwrap_err()
749            .to_string();
750        assert!(err.contains("still running"), "{err}");
751    }
752
753    #[test]
754    fn explicit_archive_ignores_the_registry() {
755        // An archive from an earlier session can share a handle id with a
756        // worker running now; the registry must not be consulted.
757        let dir = tempfile::tempdir().unwrap();
758        let path = archive_in(dir.path(), "sa_3");
759        let registry = Mutex::new(SubagentRegistry::new());
760        registry
761            .lock()
762            .unwrap()
763            .register(finished_handle("sa_3", SubagentStatus::Running, None));
764        let prior = prior_run(&registry, "sa_3", Some(path), "pick up").unwrap();
765        assert_eq!(prior.agent_name, "archived-label");
766        assert_continues_archive(&prior, "pick up");
767    }
768
769    #[test]
770    fn empty_history_archive_is_refused_not_resumed_blank() {
771        // A startup-failure archive (worker exited before its first
772        // checkpoint) is written with no history; resuming it would start
773        // the worker with nothing but a preamble claiming earlier work.
774        let dir = tempfile::tempdir().unwrap();
775        let meta = crate::runtime::subagent::SubagentArchiveMeta {
776            agent_name: "archived-label".into(),
777            model: "anthropic/claude-opus-5-5".into(),
778            system_prompt: "archived system".into(),
779            timeout_secs: 30,
780        };
781        let path = super::super::archive::write_archive(
782            dir.path(),
783            "sa_empty",
784            &meta,
785            "failed",
786            Some("provider refused the model"),
787            "",
788            &[],
789        )
790        .unwrap();
791        let registry = Mutex::new(SubagentRegistry::new());
792        let err = match prior_run(&registry, "sa_empty", Some(path.clone()), "go on") {
793            Err(e) => e.to_string(),
794            Ok(_) => panic!("empty archive resumed"),
795        };
796        assert!(err.contains("no conversation to resume"), "{err}");
797        assert!(err.contains("provider refused the model"), "{err}");
798        // Same through the registry (reaped handle, archive on record).
799        {
800            let mut reg = registry.lock().unwrap();
801            let mut h = finished_handle("sa_empty", SubagentStatus::Failed("x".into()), Some(path));
802            h.mark_collected();
803            reg.register(h);
804            reg.cleanup_finished_with_ttl(Duration::ZERO);
805        }
806        assert!(prior_run(&registry, "sa_empty", None, "go on").is_err());
807    }
808
809    #[test]
810    fn legacy_handle_without_archive_pastes_prior_text() {
811        let registry = Mutex::new(SubagentRegistry::new());
812        registry.lock().unwrap().register(finished_handle(
813            "sa_old",
814            SubagentStatus::Completed,
815            None,
816        ));
817        let prior = prior_run(&registry, "sa_old", None, "next").unwrap();
818        assert_eq!(prior.messages.len(), 1);
819        let text = prior.messages[0]["content"].as_str().unwrap();
820        assert!(text.starts_with("next") && text.contains("prior text"));
821    }
822
823    #[test]
824    fn unknown_handle_without_archive_is_not_found() {
825        let registry = Mutex::new(SubagentRegistry::new());
826        let err = prior_run(&registry, "sa_none", None, "x")
827            .unwrap_err()
828            .to_string();
829        assert!(err.contains("No subagent found"), "{err}");
830    }
831
832    #[tokio::test]
833    async fn tombstone_resume_reports_expired_context_without_side_effects() {
834        let state = Arc::new(RwLock::new(SubagentState::new()));
835        {
836            let mut state = state.write().unwrap();
837            state.status = SubagentStatus::Completed;
838            state.partial_text = "terminal output".into();
839            state.conversation_state = vec![json!({"role": "assistant", "content": "context"})];
840            state.finished_at = Some(std::time::Instant::now());
841        }
842        let (steer_tx, _steer_rx) = mpsc::unbounded_channel();
843        let (shutdown_tx, _shutdown_rx) = oneshot::channel();
844        let (_result_tx, result_rx) = oneshot::channel();
845        let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
846        registry.lock().unwrap().register(SubagentHandle::new(
847            "sa_expired".into(),
848            1,
849            "test".into(),
850            "task".into(),
851            "anthropic/claude-sonnet-4-6".into(),
852            "system".into(),
853            30,
854            state,
855            Some(steer_tx),
856            Some(shutdown_tx),
857            Some(result_rx),
858        ));
859        let foreground =
860            agent_core::prompt::QualifiedModelId::parse("anthropic/claude-sonnet-4-6").unwrap();
861        let orchestration = Arc::new(
862            crate::orchestration::OrchestrationRuntime::baseline(foreground, 8, 64).unwrap(),
863        );
864        orchestration
865            .authorize("sa_expired", "anthropic/claude-sonnet-4-6")
866            .unwrap();
867        let gate_before = orchestration.completion_gate();
868        reap_finished_with_ttl(&registry, Some(orchestration.as_ref()), Duration::ZERO);
869        assert!(registry
870            .lock()
871            .unwrap()
872            .get("sa_expired")
873            .unwrap()
874            .is_tombstone());
875        let count_before = registry.lock().unwrap().list_active().len();
876
877        let mut ctx = create_tool_context();
878        ctx.capabilities.subagent_registry = Some(registry.clone());
879        ctx.capabilities.orchestration = Some(orchestration.clone());
880        let error = SubagentResumeTool
881            .execute(
882                json!({"handle_id": "sa_expired", "instructions": "continue"}),
883                ctx,
884            )
885            .await
886            .unwrap_err()
887            .to_string();
888        assert!(error.contains("expired resumable context"));
889        assert!(!error.contains("No subagent found"));
890        assert_eq!(registry.lock().unwrap().list_active().len(), count_before);
891        assert_eq!(orchestration.completion_gate(), gate_before);
892    }
893}
894
895/// Warn, never silently extend an explicitly requested wall-clock limit.
896fn short_timeout_warning(seconds: u64) -> Option<&'static str> {
897    (seconds > 0 && seconds < 300).then_some(
898        "Resume timeout is below 300s and may expire during upload or API retries; prefer at least 600s (0 disables the limit)."
899    )
900}
901
902#[cfg(test)]
903mod short_timeout_tests {
904    #[test]
905    fn only_short_positive_effective_limits_warn() {
906        for (seconds, warns) in [
907            (0, false),
908            (60, true),
909            (299, true),
910            (300, false),
911            (600, false),
912        ] {
913            assert_eq!(super::short_timeout_warning(seconds).is_some(), warns);
914        }
915    }
916}