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 super::archive::{CompactedResume, ResumePolicy};
18use crate::runtime::subagent::{SubagentHandle, SubagentResult, SubagentState, SubagentStatus};
19use crate::{AgentEvent, Result, RuntimeError};
20
21pub struct SubagentResumeTool;
22
23fn expired_context_error(handle_id: &str) -> RuntimeError {
24    RuntimeError::Tool(format!(
25        "Subagent '{}' has expired resumable context; call subagent_collect with reconciled=true to reconcile its retained tombstone.",
26        handle_id
27    ))
28}
29
30/// Identity and opening messages of a resumed run.
31#[derive(Debug)]
32struct PriorRun {
33    agent_name: String,
34    model: String,
35    system_prompt: String,
36    timeout_secs: u64,
37    /// Directory the prior run worked in, when recorded.
38    cwd: Option<std::path::PathBuf>,
39    /// Worker effort recorded for the prior run; None in older archives.
40    thinking: Option<String>,
41    messages: Vec<crate::SharedMessage>,
42    /// Set when a cold, large history was replaced by a compact context.
43    compacted: Option<CompactedResume>,
44}
45
46/// [`prior_run_with`] under today's full-replay policy.
47#[cfg(test)]
48fn prior_run(
49    registry: &std::sync::Mutex<crate::runtime::subagent::SubagentRegistry>,
50    prior_handle_id: &str,
51    explicit_archive: Option<std::path::PathBuf>,
52    instructions: &str,
53) -> Result<PriorRun> {
54    prior_run_with(
55        registry,
56        prior_handle_id,
57        explicit_archive,
58        instructions,
59        ResumePolicy::FULL,
60    )
61}
62
63fn prior_run_with(
64    registry: &std::sync::Mutex<crate::runtime::subagent::SubagentRegistry>,
65    prior_handle_id: &str,
66    explicit_archive: Option<std::path::PathBuf>,
67    instructions: &str,
68    policy: ResumePolicy,
69) -> Result<PriorRun> {
70    // Where the prior context comes from: the worker's archived conversation
71    // (any terminal handle, including collected, reaped and tombstoned ones),
72    // or else the legacy text snapshot on a live handle. An explicit
73    // archive_path belongs to another session, so the registry is skipped.
74    let (live, archive_path, legacy) = if explicit_archive.is_some() {
75        (None, explicit_archive, None)
76    } else {
77        let reg = registry.lock().unwrap_or_else(|p| p.into_inner());
78        let handle = reg.get(prior_handle_id);
79        if handle.is_some_and(|h| h.status() == SubagentStatus::Running) {
80            return Err(RuntimeError::Tool(format!(
81                "Subagent '{}' is still running. Call subagent_collect first, \
82                     or wait until it finishes.",
83                prior_handle_id
84            )));
85        }
86        if let Some(h) = handle {
87            super::archive::retry_failed_archive(&h.state, prior_handle_id);
88            let s = h.state.read().unwrap_or_else(|p| p.into_inner());
89            if s.archive_error.is_some() || (s.archive_meta.is_some() && s.archive_path.is_none()) {
90                if s.finalization_ready {
91                    if let Some(history) = &s.history {
92                        // No archive file to date the run by: the recorded
93                        // anchor, else time since the worker finished.
94                        let cache_age = match s.cache_anchor_unix_ms {
95                            Some(ms) => Some(super::archive::anchor_age(
96                                ms,
97                                std::time::SystemTime::now(),
98                            )),
99                            None => s.finished_at.map(|t| t.elapsed()),
100                        };
101                        let (messages, compacted) = super::archive::plan_resume_messages(
102                            history.iter().map(|m| m.as_ref().clone()).collect(),
103                            instructions,
104                            cache_age,
105                            policy,
106                        );
107                        return Ok(PriorRun {
108                            agent_name: h.agent_name.clone(),
109                            model: h.model.clone(),
110                            system_prompt: h.system_prompt.clone(),
111                            timeout_secs: h.timeout_secs,
112                            cwd: s.archive_meta.as_ref().and_then(|m| m.cwd.clone()),
113                            thinking: s.archive_meta.as_ref().and_then(|m| m.thinking.clone()),
114                            messages,
115                            compacted,
116                        });
117                    }
118                }
119                return Err(RuntimeError::Tool(format!(
120                    "Subagent '{}' has no finalized full history: {}",
121                    prior_handle_id,
122                    s.archive_error.as_deref().unwrap_or("archive pending")
123                )));
124            }
125        }
126        let archive_path = reg.archive_path(prior_handle_id);
127        let live = handle.map(|h| {
128            let (cwd, thinking) = {
129                let s = h.state.read().unwrap_or_else(|p| p.into_inner());
130                let meta = s.archive_meta.as_ref();
131                (
132                    meta.and_then(|m| m.cwd.clone()),
133                    meta.and_then(|m| m.thinking.clone()),
134                )
135            };
136            (
137                h.agent_name.clone(),
138                h.model.clone(),
139                h.system_prompt.clone(),
140                h.timeout_secs,
141                cwd,
142                thinking,
143            )
144        });
145        let legacy = match handle {
146            Some(_) if archive_path.is_some() => None,
147            Some(h) if h.is_tombstone() => return Err(expired_context_error(prior_handle_id)),
148            Some(h) => {
149                let state = h.conversation_state();
150                Some(if state.is_empty() {
151                    h.partial_output()
152                } else {
153                    serde_json::to_string(&state).unwrap_or_else(|_| h.partial_output())
154                })
155            }
156            None if archive_path.is_some() => None,
157            None => {
158                return Err(RuntimeError::Tool(format!(
159                    "No subagent found with handle_id '{}'",
160                    prior_handle_id
161                )))
162            }
163        };
164        (live, archive_path, legacy)
165    };
166
167    let archive = match &archive_path {
168        Some(path) => Some(super::archive::read_archive(path).map_err(|e| {
169            RuntimeError::Tool(format!(
170                "Could not read the resume archive of '{}' ({}): {e}",
171                prior_handle_id,
172                path.display()
173            ))
174        })?),
175        None => None,
176    };
177    // A startup-failure archive holds no conversation: resuming it would
178    // start the worker blank under a preamble claiming earlier work.
179    if let Some(a) = archive.as_ref().filter(|a| a.history.is_empty()) {
180        return Err(RuntimeError::Tool(format!(
181            "Subagent '{prior_handle_id}' has no conversation to resume: it exited before its \
182             first history checkpoint ({}). Start a new subagent instead.",
183            a.error.as_deref().unwrap_or(&a.status)
184        )));
185    }
186    let (agent_name, model, system_prompt, timeout_secs, live_cwd, thinking) =
187        match (live, &archive) {
188        (Some((agent, model, prompt, timeout, cwd, thinking)), archive) => (
189            agent,
190            model,
191            prompt,
192            timeout,
193            cwd,
194            thinking.or_else(|| archive.as_ref().and_then(|a| a.meta.thinking.clone())),
195        ),
196        (None, Some(a)) => (
197            a.meta.agent_name.clone(),
198            a.meta.model.clone(),
199            a.meta.system_prompt.clone(),
200            a.meta.timeout_secs,
201            None,
202            a.meta.thinking.clone(),
203        ),
204        (None, None) => {
205            return Err(RuntimeError::Tool(format!(
206                "No subagent found with handle_id '{}'",
207                prior_handle_id
208            )))
209        }
210    };
211    let cwd = live_cwd.or_else(|| archive.as_ref().and_then(|a| a.meta.cwd.clone()));
212    let mut compacted = None;
213    let messages: Vec<crate::SharedMessage> = match (archive, legacy) {
214        (Some(a), _) => {
215            let cache_age = super::archive::archive_cache_age(
216                a.cache_anchor_unix_ms,
217                archive_path.as_deref(),
218                std::time::SystemTime::now(),
219            );
220            let (messages, plan) =
221                super::archive::plan_resume_messages(a.history, instructions, cache_age, policy);
222            compacted = plan;
223            messages
224        }
225        // Legacy: no archived conversation, only the prior run's text.
226        (None, Some(prior_context)) => vec![Arc::new(json!({
227            "role": "user",
228            "content": format!(
229                "{instructions}\n\n---\n[Prior conversation context from handle {prior_handle_id}]\n{prior_context}"
230            )
231        }))],
232        (None, None) => unreachable!("either an archive or a legacy snapshot was found"),
233    };
234    Ok(PriorRun {
235        agent_name,
236        model,
237        system_prompt,
238        timeout_secs,
239        cwd,
240        thinking,
241        messages,
242        compacted,
243    })
244}
245
246#[async_trait::async_trait]
247impl Tool for SubagentResumeTool {
248    fn origin(&self) -> crate::tools::ToolOrigin {
249        crate::tools::ToolOrigin::Builtin
250    }
251
252    fn name(&self) -> &str {
253        "subagent_resume"
254    }
255
256    fn description(&self) -> &str {
257        "Resume a finished, timed-out, failed or cancelled reactive subagent with new \
258         instructions. The worker continues its own archived conversation (every tool \
259         call and result) with the instructions as the next user turn, so it picks up \
260         where it stopped. Works after the handle was collected or reaped. For a worker \
261         from an earlier or crashed session, pass archive_path. A worker whose prompt \
262         cache has expired and whose history is large resumes from a compact context \
263         (task, last steps, final report) unless full_history is true. Returns a new \
264         handle_id."
265    }
266
267    fn parameters(&self) -> Value {
268        json!({
269            "type": "object",
270            "properties": {
271                "handle_id": {
272                    "type": "string",
273                    "description": "Handle ID of the completed subagent to resume (e.g. \"sa_3\")."
274                },
275                "instructions": {
276                    "type": "string",
277                    "description": "What to do next. Sent as the next user turn after the \
278                                    worker's own prior conversation."
279                },
280                "archive_path": {
281                    "type": "string",
282                    "description": "Optional: a resume archive (.json under \
283                                    ~/.synaps-cli/subagent-history/) of a worker from an \
284                                    earlier or crashed session. handle_id is then only a label."
285                },
286                "full_history": {
287                    "type": "boolean",
288                    "description": "Optional (default false): replay the worker's full \
289                                    conversation even when its prompt cache has expired. \
290                                    Otherwise a cold history above \
291                                    subagent_cold_resume_compact_tokens is resumed from a \
292                                    compact context (original task, last steps, final \
293                                    report), avoiding a full cache rewrite."
294                },
295                "thinking": {
296                    "type": "string",
297                    "enum": ["low", "medium", "high", "xhigh", "max"],
298                    "description": "Override worker effort for this resumed run. Omit to keep its recorded effort; legacy archives use subagent_thinking (default xhigh). Unsupported model levels are refused."
299                },
300                "timeout": {
301                    "type": "integer",
302                    "description": "Optional wall-clock limit in seconds for the resumed run \
303                                    (default: the prior run's); 0 = no limit. Positive values below 300s emit a warning; prefer at least 600s for API retries."
304                }
305            },
306            "required": ["handle_id", "instructions"]
307        })
308    }
309
310    async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
311        let prior_handle_id = params["handle_id"]
312            .as_str()
313            .ok_or_else(|| RuntimeError::Tool("Missing 'handle_id' parameter".to_string()))?
314            .to_string();
315
316        let instructions = params["instructions"]
317            .as_str()
318            .ok_or_else(|| RuntimeError::Tool("Missing 'instructions' parameter".to_string()))?
319            .to_string();
320
321        let registry = ctx.capabilities.subagent_registry.as_ref().ok_or_else(|| {
322            RuntimeError::Tool("SubagentRegistry not available on this ToolContext".to_string())
323        })?;
324
325        let explicit_archive = match params["archive_path"].as_str() {
326            Some(raw) => {
327                Some(super::archive::checked_archive_path(raw).map_err(RuntimeError::Tool)?)
328            }
329            None => None,
330        };
331
332        // One config snapshot for the whole spawn (`spawn_config`).
333        let config = super::spawn_config();
334        let policy = ResumePolicy {
335            full_history: params["full_history"].as_bool().unwrap_or(false),
336            compact_tokens: config.subagent_cold_resume_compact_tokens,
337        };
338        let prior = prior_run_with(
339            registry,
340            &prior_handle_id,
341            explicit_archive,
342            &instructions,
343            policy,
344        )?;
345        let thinking = super::resolve_subagent_thinking(
346            &params,
347            prior.thinking.as_deref(),
348            &config,
349        )?;
350        let compacted = prior.compacted;
351        if let Some(c) = compacted {
352            tracing::info!(
353                prior_handle_id = %prior_handle_id,
354                messages = c.messages,
355                est_tokens = c.est_tokens,
356                kept_est_tokens = c.kept_est_tokens,
357                cache_age_secs = ?c.cache_age_secs,
358                "subagent_resume: cold cache, resuming from a compact context"
359            );
360        }
361        // The resumed worker keeps the directory it was started in (its
362        // history refers to paths there); else the resuming session's.
363        let mut workspace = super::WorkerWorkspace::from_capabilities(&ctx.capabilities);
364        if let Some(prior_cwd) = prior.cwd.filter(|dir| dir.is_dir()) {
365            workspace.cwd = Some(prior_cwd);
366        }
367        let (agent_name, model, system_prompt, initial_messages) = (
368            prior.agent_name,
369            prior.model,
370            prior.system_prompt,
371            prior.messages,
372        );
373        let timeout_secs = params["timeout"].as_u64().unwrap_or(prior.timeout_secs);
374        let timeout_warning = short_timeout_warning(timeout_secs);
375        if let Some(warning) = timeout_warning {
376            tracing::warn!(timeout_secs, warning, "short subagent resume timeout");
377        }
378
379        // The inherited prior model is still re-authorized as an explicit exact
380        // identity; a stale handle cannot bypass current session policy.
381        let label = agent_name.clone();
382        let task_preview: String = instructions.chars().take(80).collect();
383        let task_full = instructions.clone();
384        let subagent_id = NEXT_SUBAGENT_ID.fetch_add(1, Ordering::Relaxed);
385        let handle_id = format!("sa_{}", subagent_id);
386        let orchestration = ctx
387            .capabilities
388            .orchestration
389            .as_ref()
390            .ok_or_else(|| RuntimeError::Tool("delegation policy unavailable".into()))?;
391        orchestration
392            .reserve_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref())
393            .map_err(|reason| {
394                RuntimeError::Tool(format!("delegation tree budget denied: {reason:?}"))
395            })?;
396        let decision = orchestration
397            .resolve_and_authorize(&handle_id, Some(&model))
398            .map_err(|error| {
399                orchestration
400                    .release_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref());
401                RuntimeError::Tool(error.to_string())
402            })?;
403        let model = decision.model.as_str().to_owned();
404        let codex_parent_plan = ctx.capabilities.codex_parent_plan.clone();
405        let memory_backend = ctx.capabilities.memory_backend.clone();
406        let session_allow_all = ctx.capabilities.session_allow_all.clone();
407        let telemetry = ctx.capabilities.telemetry.clone();
408        let telemetry = telemetry.map(|t| t.for_worker(&label, &handle_id));
409
410        tracing::info!(
411            "subagent_resume: dispatching '{}' (id={}, resumed_from={}) model={}",
412            label,
413            handle_id,
414            prior_handle_id,
415            model
416        );
417
418        let state = Arc::new(RwLock::new(SubagentState::new()));
419        state.write().unwrap().archive_meta = Some(crate::runtime::subagent::SubagentArchiveMeta {
420            agent_name: label.clone(),
421            model: model.clone(),
422            system_prompt: system_prompt.clone(),
423            timeout_secs,
424            cwd: workspace
425                .cwd
426                .clone()
427                .or_else(|| std::env::current_dir().ok()),
428            thinking: Some(thinking.as_str().into()),
429        });
430
431        state.write().unwrap().archive_dir = Some(super::archive::process_dir());
432
433        // Keep the inherited checkpoint even if runtime startup fails before emitting history.
434        state.write().unwrap().history = Some(initial_messages.clone());
435
436        let (steer_tx, steer_rx) = mpsc::unbounded_channel::<String>();
437        let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
438        let (result_tx, result_rx) = oneshot::channel::<SubagentResult>();
439
440        if let Some(ref tx) = ctx.channels.tx_events {
441            let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentStart {
442                subagent_id,
443                agent_name: label.clone(),
444                task_preview: task_preview.clone(),
445            }));
446        }
447
448        let state_t = Arc::clone(&state);
449        let task_full_a = task_full.clone();
450        let label_inner = label.clone();
451        let model_inner = model.clone();
452        let tx_events_inner = ctx.channels.tx_events.clone();
453        let start_time = std::time::Instant::now();
454        let parent_queue = ctx.capabilities.event_queue.clone();
455        let handle_id_inner = handle_id.clone();
456        let prior_handle_for_finalizer = prior_handle_id.clone();
457        let child_parent_id = handle_id.clone();
458
459        // ── Build and register handle BEFORE spawning ─────────────────────────
460        let system_prompt_for_handle = system_prompt.clone();
461        let handle = SubagentHandle::new(
462            handle_id.clone(),
463            subagent_id,
464            label.clone(),
465            task_preview,
466            model.clone(),
467            system_prompt_for_handle,
468            timeout_secs,
469            Arc::clone(&state),
470            Some(steer_tx),
471            Some(shutdown_tx),
472            Some(result_rx),
473        )
474        .with_authorization(&decision);
475        {
476            let mut reg = registry.lock().unwrap();
477            reg.register_with_cancellation(handle, ctx.capabilities.launch_cancel.as_ref());
478        }
479
480        let orchestration_for_worker = Arc::clone(orchestration);
481        let parent_for_worker = ctx.capabilities.delegation_parent.clone();
482        if let Err(error) = orchestration.mark_starting(&handle_id) {
483            orchestration.rollback(&handle_id);
484            orchestration
485                .release_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref());
486            return Err(RuntimeError::Tool(error));
487        }
488        // ── Spawn subagent thread ──────────────────────────────────────────────
489        let thread_handle = std::thread::spawn(move || {
490            // Pre-clone for finalizer — catch_unwind moves state_t and label_inner
491            let state_for_finalizer = Arc::clone(&state_t);
492            let label_for_finalizer = label_inner.clone();
493            let orchestration_for_runtime = Arc::clone(&orchestration_for_worker);
494
495            let mut pending_result = None;
496            let panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
497                let rt = match tokio::runtime::Builder::new_current_thread()
498                    .enable_all()
499                    .build()
500                {
501                    Ok(rt) => rt,
502                    Err(_) => {
503                        state_t.write().unwrap().status =
504                            SubagentStatus::Failed("runtime initialization failed".into());
505                        return;
506                    }
507                };
508
509                let state_a = Arc::clone(&state_t);
510                let label_a = label_inner.clone();
511                let model_a = model_inner.clone();
512                let tx_events_a = tx_events_inner.clone();
513                let task_for_timeout = task_full_a.clone();
514                let task_for_complete = task_full_a;
515
516                let outcome: std::result::Result<SubagentResult, String> =
517                    rt.block_on(async move {
518                        // Host-built worker (shared client/creds/token cache, cached
519                        // registry) or the legacy fresh runtime — see `spawn_runtime`.
520                        let mut runtime = match super::spawn_runtime().await {
521                            Ok(r) => r,
522                            Err(_) => return Err("subagent runtime initialization failed".into()),
523                        };
524
525                        // Apply subagent spawn policy: worker role, 5m cache TTL, worker
526                        // turn budget. Subagents are short-lived one-shots — paying the 1h
527                        // write premium (~2× input price) on them is unrecoverable waste
528                        // (~$0.23 per 10-spawn fan-out). (#110)
529                        super::apply_subagent_runtime_policy(
530                            &mut runtime,
531                            &config,
532                            memory_backend.as_ref(),
533                            session_allow_all.as_ref(),
534                            telemetry.as_ref(),
535                            &workspace,
536                        );
537                        runtime.set_system_prompt(super::compose_system_prompt(
538                            system_prompt,
539                            runtime.memory_backend_is_axel(),
540                        ));
541                        runtime.set_model(model_a.clone());
542                        super::fit_subagent_context_window(&mut runtime);
543                        super::apply_codex_worker_reasoning(
544                            &mut runtime,
545                            codex_parent_plan.as_ref(),
546                        );
547                        super::apply_subagent_thinking(&mut runtime, thinking)?;
548                        runtime
549                            .install_worker_orchestration(Arc::clone(&orchestration_for_runtime));
550                        runtime.set_delegation_parent(Some(child_parent_id.clone()));
551                        // Rolled-over context goes beside this worker's
552                        // resume archive (see `worker_context`).
553                        let archive_dir = state_a
554                            .read()
555                            .unwrap_or_else(|p| p.into_inner())
556                            .archive_dir
557                            .clone()
558                            .unwrap_or_else(super::archive::process_dir);
559                        super::attach_worker_transcripts(
560                            &mut runtime,
561                            archive_dir,
562                            &child_parent_id,
563                            Some(Arc::clone(&state_a)),
564                            &initial_messages,
565                            tx_events_a.clone(),
566                            subagent_id,
567                            &label_a,
568                        );
569
570                        let cancel = crate::CancellationToken::new();
571                        let cancel_inner = cancel.clone();
572                        tokio::spawn(async move {
573                            let _ = shutdown_rx.await;
574                            cancel_inner.cancel();
575                        });
576
577                        let cancel_on_timeout = cancel.clone();
578                        let stream = runtime.run_stream_with_messages(
579                                initial_messages,
580                                cancel,
581                                Some(steer_rx),
582                                None,
583                                false,
584                            ).await;
585
586                        let drive_outcome = super::drive::WorkerDrive {
587                            state: Arc::clone(&state_a),
588                            tx_events: tx_events_a.clone(),
589                            subagent_id,
590                            label: label_a.clone(),
591                            model: model_a.clone(),
592                            timeout_secs,
593                            cancel: cancel_on_timeout,
594                            task: task_for_timeout,
595                        }
596                        .run(stream)
597                        .await;
598                        // Date this run's cache for the next resume (cold check).
599                        super::archive::record_cache_anchor(&state_a, runtime.cache_anchor());
600                        drive_outcome
601                    });
602
603                match outcome {
604                    Ok(sa_result) => {
605                        {
606                            let mut s = state_t.write().unwrap();
607                            if matches!(s.status, SubagentStatus::Running) && !s.cancel_requested {
608                                s.status = SubagentStatus::Completed;
609                                s.conversation_state = vec![
610                                    serde_json::json!({"role": "user", "content": task_for_complete.clone()}),
611                                    serde_json::json!({"role": "assistant", "content": sa_result.text.clone()}),
612                                ];
613                            }
614                        }
615                        pending_result = Some(sa_result);
616                    }
617                    Err(e) => {
618                        state_t.write().unwrap().status = SubagentStatus::Failed(e);
619                    }
620                }
621            }));
622
623            if let Err(panic_info) = panic_result {
624                let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
625                    s.to_string()
626                } else if let Some(s) = panic_info.downcast_ref::<String>() {
627                    s.clone()
628                } else {
629                    "unknown panic".to_string()
630                };
631                tracing::error!("Resumed subagent thread panicked: {}", msg);
632                state_t.write().unwrap_or_else(|p| p.into_inner()).status =
633                    SubagentStatus::Failed(format!("panic: {}", msg));
634            }
635
636            // ── Terminal finalizer — exactly once, outside catch_unwind ────────
637            // Covers all paths: Ok, Err, timeout, panic, early tokio-build failure.
638            // Sets data.resumed_from so parent can correlate with the prior handle.
639            super::finalize::finalize_subagent(
640                &state_for_finalizer,
641                parent_queue.as_ref(),
642                &handle_id_inner,
643                subagent_id,
644                &label_for_finalizer,
645                start_time,
646                Some(&prior_handle_for_finalizer),
647            );
648            // A cancelled worker publishes no wake, so nothing would ever
649            // prompt a collect: retire it before any terminal publication.
650            super::finalize::retire_if_cancelled(
651                &state_for_finalizer,
652                &orchestration_for_worker,
653                &handle_id_inner,
654            );
655            // All terminal publications follow the recovery/finalization barrier.
656            if let Some(ref tx) = tx_events_inner {
657                let s = state_for_finalizer
658                    .read()
659                    .unwrap_or_else(|p| p.into_inner());
660                let preview = match s.status.failure_reason() {
661                    Some(reason) => format!("ERROR: {reason}"),
662                    None => s.partial_text.chars().take(120).collect(),
663                };
664                let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
665                    subagent_id,
666                    agent_name: label_for_finalizer.clone(),
667                    result_preview: preview,
668                    duration_secs: start_time.elapsed().as_secs_f64(),
669                }));
670            }
671            if let Some(result) = pending_result {
672                let _ = result_tx.send(result);
673            }
674            orchestration_for_worker
675                .release_delegation(&handle_id_inner, parent_for_worker.as_deref());
676        });
677
678        // ── Wire thread handle into the already-registered entry ─────────────
679        {
680            let mut reg = registry.lock().unwrap();
681            if let Some(h) = reg.get_mut(&handle_id) {
682                h.set_thread_handle(thread_handle);
683            }
684        }
685        if let Err(error) = orchestration.mark_running(&handle_id) {
686            let mut handle = registry.lock().unwrap().remove(&handle_id);
687            if let Some(handle) = handle.as_mut() {
688                handle.cancel();
689            }
690            if let Some(handle) = handle {
691                let _ = handle.collect().await;
692            }
693            orchestration.rollback(&handle_id);
694            orchestration
695                .release_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref());
696            return Err(RuntimeError::Tool(error));
697        }
698        // The thread may have been cancelled and finalized before it was
699        // marked running; its own retire attempt was then too early.
700        super::finalize::retire_if_cancelled(&state, orchestration, &handle_id);
701
702        let context = if compacted.is_some() {
703            "compacted"
704        } else {
705            "full"
706        };
707        let mut reply = json!({
708            "handle_id":    handle_id,
709            "resumed_from": prior_handle_id,
710            "warning": timeout_warning,
711            "agent_name":   label,
712            "status":       "running",
713            "context":      context
714        });
715        if let Some(c) = compacted {
716            reply["compacted"] = json!(c);
717        }
718        Ok(reply.to_string())
719    }
720}
721
722#[cfg(test)]
723mod tests {
724    use super::*;
725    use crate::runtime::subagent::{reap_finished_with_ttl, SubagentRegistry};
726    use crate::tools::test_helpers::create_tool_context;
727    use std::sync::Mutex;
728    use std::time::Duration;
729
730    fn finished_handle(
731        id: &str,
732        status: SubagentStatus,
733        archive_path: Option<std::path::PathBuf>,
734    ) -> SubagentHandle {
735        let state = Arc::new(RwLock::new(SubagentState::new()));
736        {
737            let mut s = state.write().unwrap();
738            s.status = status;
739            s.partial_text = "prior text".into();
740            s.conversation_state = vec![json!({"role": "assistant", "content": "prior text"})];
741            s.finished_at = Some(std::time::Instant::now());
742            s.archive_path = archive_path;
743        }
744        let (steer_tx, _steer_rx) = mpsc::unbounded_channel();
745        let (shutdown_tx, _shutdown_rx) = oneshot::channel();
746        let (_result_tx, result_rx) = oneshot::channel();
747        SubagentHandle::new(
748            id.into(),
749            1,
750            "live-label".into(),
751            "task".into(),
752            "anthropic/claude-sonnet-4-6".into(),
753            "live system".into(),
754            30,
755            state,
756            Some(steer_tx),
757            Some(shutdown_tx),
758            Some(result_rx),
759        )
760    }
761
762    fn archive_in(dir: &std::path::Path, id: &str) -> std::path::PathBuf {
763        let meta = crate::runtime::subagent::SubagentArchiveMeta {
764            agent_name: "archived-label".into(),
765            model: "anthropic/claude-opus-5-5".into(),
766            system_prompt: "archived system".into(),
767            timeout_secs: 3500,
768            cwd: None,
769            thinking: Some("low".into()),
770        };
771        let history: Vec<crate::SharedMessage> = vec![
772            Arc::new(json!({"role": "user", "content": "build the plate"})),
773            Arc::new(json!({"role": "assistant", "content": [
774                {"type": "tool_use", "id": "t1", "name": "bash", "input": {"command": "render"}}
775            ]})),
776            Arc::new(json!({"role": "user", "content": [
777                {"type": "tool_result", "tool_use_id": "t1", "content": "rendered 300 frames"}
778            ]})),
779        ];
780        super::super::archive::write_archive(
781            dir,
782            id,
783            &meta,
784            "timed_out",
785            Some("worker timed out"),
786            "report",
787            &history,
788        )
789        .unwrap()
790    }
791
792    fn assert_continues_archive(prior: &PriorRun, instructions: &str) {
793        assert_eq!(
794            prior.messages.len(),
795            3,
796            "archived turns replayed, instructions merged"
797        );
798        assert_eq!(prior.messages[0]["content"], "build the plate");
799        let last = &prior.messages[2]["content"];
800        assert_eq!(last[0]["content"], "rendered 300 frames");
801        assert!(last[1]["text"].as_str().unwrap().ends_with(instructions));
802    }
803
804    #[test]
805    fn subagent_thinking_resume_archive_survives_config_change_and_override() {
806        let dir = tempfile::tempdir().unwrap();
807        let path = archive_in(dir.path(), "sa_thinking");
808        let registry = Mutex::new(SubagentRegistry::new());
809        let prior = prior_run(&registry, "sa_thinking", Some(path), "continue").unwrap();
810        assert_eq!(prior.thinking.as_deref(), Some("low"));
811        let cfg = crate::config::load_config_from_str("subagent_thinking = max\n");
812        let retained = super::super::resolve_subagent_thinking(&json!({}), prior.thinking.as_deref(), &cfg).unwrap();
813        assert_eq!(retained.as_str(), "low");
814        let overridden = super::super::resolve_subagent_thinking(&json!({"thinking":"xhigh"}), prior.thinking.as_deref(), &cfg).unwrap();
815        let mut runtime = crate::Runtime::new_headless();
816        runtime.set_model("openai-codex/gpt-6-astra".into());
817        super::super::apply_subagent_thinking(&mut runtime, retained).unwrap();
818        assert_eq!(runtime.reasoning_level().as_str(), "low");
819        super::super::apply_subagent_thinking(&mut runtime, overridden).unwrap();
820        assert_eq!(runtime.reasoning_level().as_str(), "xhigh");
821    }
822
823    #[test]
824    fn subagent_thinking_legacy_archive_defaults_without_migration() {
825        let meta: crate::runtime::subagent::SubagentArchiveMeta = serde_json::from_value(json!({
826            "agent_name":"old", "model":"openai-codex/gpt-6-astra",
827            "system_prompt":"test", "timeout_secs":300
828        })).unwrap();
829        assert!(meta.thinking.is_none());
830        assert_eq!(super::super::resolve_subagent_thinking(&json!({}), meta.thinking.as_deref(), &Default::default()).unwrap().as_str(), "xhigh");
831    }
832
833    #[test]
834    fn reaped_worker_resumes_from_its_archive() {
835        let dir = tempfile::tempdir().unwrap();
836        let path = archive_in(dir.path(), "sa_reaped");
837        let registry = Mutex::new(SubagentRegistry::new());
838        {
839            let mut reg = registry.lock().unwrap();
840            let mut h = finished_handle("sa_reaped", SubagentStatus::TimedOut, Some(path));
841            h.mark_collected();
842            reg.register(h);
843            reg.cleanup_finished_with_ttl(Duration::ZERO);
844            assert!(reg.get("sa_reaped").is_none(), "handle reaped");
845        }
846        let prior = prior_run(&registry, "sa_reaped", None, "render pass 3").unwrap();
847        assert_eq!(prior.agent_name, "archived-label");
848        assert_eq!(prior.model, "anthropic/claude-opus-5-5");
849        assert_eq!(prior.system_prompt, "archived system");
850        assert_eq!(prior.timeout_secs, 3500);
851        assert_continues_archive(&prior, "render pass 3");
852    }
853
854    #[test]
855    fn tombstoned_worker_with_archive_resumes() {
856        let dir = tempfile::tempdir().unwrap();
857        let path = archive_in(dir.path(), "sa_tomb");
858        let registry = Mutex::new(SubagentRegistry::new());
859        {
860            let mut reg = registry.lock().unwrap();
861            reg.register(finished_handle(
862                "sa_tomb",
863                SubagentStatus::Failed("x".into()),
864                Some(path),
865            ));
866            reg.release_finished_resources("sa_tomb");
867            assert!(reg.get("sa_tomb").unwrap().is_tombstone());
868        }
869        let prior = prior_run(&registry, "sa_tomb", None, "continue").unwrap();
870        // A live handle's identity wins over the archive's.
871        assert_eq!(prior.agent_name, "live-label");
872        assert_eq!(prior.timeout_secs, 30);
873        assert_continues_archive(&prior, "continue");
874    }
875
876    #[test]
877    fn review_202_terminal_but_unpublished_worker_must_not_resume_lossily() {
878        let registry = Mutex::new(SubagentRegistry::new());
879        let h = finished_handle("sa_review", SubagentStatus::TimedOut, None);
880        {
881            let mut s = h.state.write().unwrap();
882            // Unlike the intentionally legacy helper, a managed worker has metadata.
883            s.archive_meta = Some(crate::runtime::subagent::SubagentArchiveMeta {
884                agent_name: "worker".into(),
885                model: "model".into(),
886                system_prompt: "system".into(),
887                timeout_secs: 1,
888                cwd: None,
889                thinking: Some("medium".into()),
890            });
891            s.finished_at = None;
892        }
893        let state = Arc::clone(&h.state);
894        registry.lock().unwrap().register(h);
895        assert!(prior_run(&registry, "sa_review", None, "continue").is_err());
896        registry
897            .lock()
898            .unwrap()
899            .cleanup_finished_with_ttl(Duration::ZERO);
900        assert!(registry.lock().unwrap().get("sa_review").is_some());
901        let dir = tempfile::tempdir().unwrap();
902        let blocked = dir.path().join("blocked");
903        std::fs::write(&blocked, b"x").unwrap();
904        let history = vec![
905            Arc::new(json!({"role":"user", "content":"task"})),
906            Arc::new(json!({"role":"assistant", "content":[
907                {"type":"tool_use", "id":"t1", "name":"read", "input":{"path":"x"}}
908            ]})),
909            Arc::new(json!({"role":"user", "content":[
910                {"type":"tool_result", "tool_use_id":"t1", "content":"preserved body"}
911            ]})),
912        ];
913        state.write().unwrap().history = Some(history);
914        super::super::finalize::finalize_subagent_in(
915            &state,
916            None,
917            "sa_review",
918            1,
919            "worker",
920            std::time::Instant::now(),
921            None,
922            &blocked,
923        );
924        // Keep this test hermetic: missing metadata prevents the production-dir retry.
925        state.write().unwrap().archive_meta = None;
926        let prior = prior_run(&registry, "sa_review", None, "continue").unwrap();
927        assert_eq!(prior.messages[2]["content"][0]["content"], "preserved body");
928        {
929            let mut reg = registry.lock().unwrap();
930            reg.get_mut("sa_review").unwrap().mark_collected();
931            reg.release_finished_resources("sa_review");
932            reg.cleanup_finished_with_ttl(Duration::ZERO);
933            assert!(reg.get("sa_review").is_some());
934        }
935        assert!(state.read().unwrap().history.is_some());
936    }
937
938    /// A history well above a 1k-token threshold: bulky tool results.
939    fn bulky_history() -> Vec<crate::SharedMessage> {
940        let mut h = vec![Arc::new(json!({"role": "user", "content": "render the reel"}))];
941        for i in 0..6 {
942            h.push(Arc::new(json!({"role": "assistant", "content": [
943                {"type": "tool_use", "id": format!("t{i}"), "name": "bash",
944                 "input": {"command": format!("render {i}")}}
945            ]})));
946            h.push(Arc::new(json!({"role": "user", "content": [
947                {"type": "tool_result", "tool_use_id": format!("t{i}"), "content": "z".repeat(4000)}
948            ]})));
949        }
950        h.push(Arc::new(json!({"role": "assistant", "content": "reel rendered"})));
951        h
952    }
953
954    const COMPACT: ResumePolicy = ResumePolicy {
955        full_history: false,
956        compact_tokens: 1_000,
957    };
958
959    #[test]
960    fn cold_large_archive_resumes_compacted_and_full_history_overrides() {
961        let dir = tempfile::tempdir().unwrap();
962        let meta = crate::runtime::subagent::SubagentArchiveMeta {
963            agent_name: "archived-label".into(),
964            model: "anthropic/claude-opus-5-5".into(),
965            system_prompt: "archived system".into(),
966            timeout_secs: 3500,
967            cwd: None,
968            thinking: Some("max".into()),
969        };
970        let old_anchor = std::time::SystemTime::now() - Duration::from_secs(3600);
971        let old_ms = old_anchor
972            .duration_since(std::time::UNIX_EPOCH)
973            .unwrap()
974            .as_millis() as u64;
975        let history = bulky_history();
976        let path = super::super::archive::write_archive_anchored(
977            dir.path(),
978            "sa_cold",
979            &meta,
980            "completed",
981            None,
982            "reel rendered",
983            &history,
984            Some(old_ms),
985        )
986        .unwrap();
987        let before = std::fs::read(&path).unwrap();
988        let registry = Mutex::new(SubagentRegistry::new());
989
990        let prior =
991            prior_run_with(&registry, "sa_cold", Some(path.clone()), "add titles", COMPACT)
992                .unwrap();
993        let c = prior.compacted.expect("cold + big compacts");
994        // Compacting the history must not drop the worker's recorded effort.
995        assert_eq!(prior.thinking.as_deref(), Some("max"));
996        assert_eq!(c.messages, history.len());
997        assert!(c.cache_age_secs.unwrap() >= 3600);
998        assert_eq!(prior.messages.len(), 1);
999        let text = prior.messages[0]["content"].to_string();
1000        assert!(text.contains("render the reel") && text.contains("add titles"));
1001
1002        // The prior archive is untouched, so a full replay is still possible.
1003        assert_eq!(std::fs::read(&path).unwrap(), before);
1004        let full = ResumePolicy {
1005            full_history: true,
1006            ..COMPACT
1007        };
1008        let prior =
1009            prior_run_with(&registry, "sa_cold", Some(path.clone()), "add titles", full).unwrap();
1010        assert!(prior.compacted.is_none());
1011        assert_eq!(prior.thinking.as_deref(), Some("max"));
1012        assert_eq!(prior.messages.len(), history.len() + 1);
1013
1014        // A warm archive (fresh anchor) replays in full.
1015        let fresh_ms = std::time::SystemTime::now()
1016            .duration_since(std::time::UNIX_EPOCH)
1017            .unwrap()
1018            .as_millis() as u64;
1019        let warm = super::super::archive::write_archive_anchored(
1020            dir.path(),
1021            "sa_warm",
1022            &meta,
1023            "completed",
1024            None,
1025            "",
1026            &history,
1027            Some(fresh_ms),
1028        )
1029        .unwrap();
1030        let prior = prior_run_with(&registry, "sa_warm", Some(warm), "x", COMPACT).unwrap();
1031        assert!(prior.compacted.is_none());
1032    }
1033
1034    #[test]
1035    fn live_unarchived_history_uses_state_anchor_for_the_cold_check() {
1036        let registry = Mutex::new(SubagentRegistry::new());
1037        let h = finished_handle("sa_live", SubagentStatus::Completed, None);
1038        {
1039            let mut s = h.state.write().unwrap();
1040            s.archive_meta = Some(crate::runtime::subagent::SubagentArchiveMeta {
1041                agent_name: "worker".into(),
1042                model: "model".into(),
1043                system_prompt: "system".into(),
1044                timeout_secs: 1,
1045                cwd: None,
1046                thinking: Some("medium".into()),
1047            });
1048            s.archive_error = Some("archive write failed".into());
1049            s.finalization_ready = true;
1050            s.history = Some(bulky_history());
1051            // Finished just now, but its last request was an hour ago.
1052            s.cache_anchor_unix_ms = Some(
1053                (std::time::SystemTime::now() - Duration::from_secs(3600))
1054                    .duration_since(std::time::UNIX_EPOCH)
1055                    .unwrap()
1056                    .as_millis() as u64,
1057            );
1058        }
1059        let state = Arc::clone(&h.state);
1060        registry.lock().unwrap().register(h);
1061        let prior = prior_run_with(&registry, "sa_live", None, "go", COMPACT).unwrap();
1062        assert!(prior.compacted.is_some());
1063        assert_eq!(prior.thinking.as_deref(), Some("medium"));
1064        // Without an anchor, `finished_at` (just now) says warm.
1065        state.write().unwrap().cache_anchor_unix_ms = None;
1066        let prior = prior_run_with(&registry, "sa_live", None, "go", COMPACT).unwrap();
1067        assert!(prior.compacted.is_none());
1068        assert_eq!(prior.thinking.as_deref(), Some("medium"));
1069    }
1070
1071    #[test]
1072    fn running_worker_cannot_be_resumed() {
1073        let registry = Mutex::new(SubagentRegistry::new());
1074        registry
1075            .lock()
1076            .unwrap()
1077            .register(finished_handle("sa_run", SubagentStatus::Running, None));
1078        let err = prior_run(&registry, "sa_run", None, "x")
1079            .unwrap_err()
1080            .to_string();
1081        assert!(err.contains("still running"), "{err}");
1082    }
1083
1084    #[test]
1085    fn explicit_archive_ignores_the_registry() {
1086        // An archive from an earlier session can share a handle id with a
1087        // worker running now; the registry must not be consulted.
1088        let dir = tempfile::tempdir().unwrap();
1089        let path = archive_in(dir.path(), "sa_3");
1090        let registry = Mutex::new(SubagentRegistry::new());
1091        registry
1092            .lock()
1093            .unwrap()
1094            .register(finished_handle("sa_3", SubagentStatus::Running, None));
1095        let prior = prior_run(&registry, "sa_3", Some(path), "pick up").unwrap();
1096        assert_eq!(prior.agent_name, "archived-label");
1097        assert_continues_archive(&prior, "pick up");
1098    }
1099
1100    #[test]
1101    fn empty_history_archive_is_refused_not_resumed_blank() {
1102        // A startup-failure archive (worker exited before its first
1103        // checkpoint) is written with no history; resuming it would start
1104        // the worker with nothing but a preamble claiming earlier work.
1105        let dir = tempfile::tempdir().unwrap();
1106        let meta = crate::runtime::subagent::SubagentArchiveMeta {
1107            agent_name: "archived-label".into(),
1108            model: "anthropic/claude-opus-5-5".into(),
1109            system_prompt: "archived system".into(),
1110            timeout_secs: 30,
1111            cwd: None,
1112            thinking: None,
1113        };
1114        let path = super::super::archive::write_archive(
1115            dir.path(),
1116            "sa_empty",
1117            &meta,
1118            "failed",
1119            Some("provider refused the model"),
1120            "",
1121            &[],
1122        )
1123        .unwrap();
1124        let registry = Mutex::new(SubagentRegistry::new());
1125        let err = match prior_run(&registry, "sa_empty", Some(path.clone()), "go on") {
1126            Err(e) => e.to_string(),
1127            Ok(_) => panic!("empty archive resumed"),
1128        };
1129        assert!(err.contains("no conversation to resume"), "{err}");
1130        assert!(err.contains("provider refused the model"), "{err}");
1131        // Same through the registry (reaped handle, archive on record).
1132        {
1133            let mut reg = registry.lock().unwrap();
1134            let mut h = finished_handle("sa_empty", SubagentStatus::Failed("x".into()), Some(path));
1135            h.mark_collected();
1136            reg.register(h);
1137            reg.cleanup_finished_with_ttl(Duration::ZERO);
1138        }
1139        assert!(prior_run(&registry, "sa_empty", None, "go on").is_err());
1140    }
1141
1142    #[test]
1143    fn legacy_handle_without_archive_pastes_prior_text() {
1144        let registry = Mutex::new(SubagentRegistry::new());
1145        registry.lock().unwrap().register(finished_handle(
1146            "sa_old",
1147            SubagentStatus::Completed,
1148            None,
1149        ));
1150        let prior = prior_run(&registry, "sa_old", None, "next").unwrap();
1151        assert_eq!(prior.messages.len(), 1);
1152        let text = prior.messages[0]["content"].as_str().unwrap();
1153        assert!(text.starts_with("next") && text.contains("prior text"));
1154    }
1155
1156    #[test]
1157    fn unknown_handle_without_archive_is_not_found() {
1158        let registry = Mutex::new(SubagentRegistry::new());
1159        let err = prior_run(&registry, "sa_none", None, "x")
1160            .unwrap_err()
1161            .to_string();
1162        assert!(err.contains("No subagent found"), "{err}");
1163    }
1164
1165    #[tokio::test]
1166    async fn tombstone_resume_reports_expired_context_without_side_effects() {
1167        let state = Arc::new(RwLock::new(SubagentState::new()));
1168        {
1169            let mut state = state.write().unwrap();
1170            state.status = SubagentStatus::Completed;
1171            state.partial_text = "terminal output".into();
1172            state.conversation_state = vec![json!({"role": "assistant", "content": "context"})];
1173            state.finished_at = Some(std::time::Instant::now());
1174        }
1175        let (steer_tx, _steer_rx) = mpsc::unbounded_channel();
1176        let (shutdown_tx, _shutdown_rx) = oneshot::channel();
1177        let (_result_tx, result_rx) = oneshot::channel();
1178        let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
1179        registry.lock().unwrap().register(SubagentHandle::new(
1180            "sa_expired".into(),
1181            1,
1182            "test".into(),
1183            "task".into(),
1184            "anthropic/claude-sonnet-4-6".into(),
1185            "system".into(),
1186            30,
1187            state,
1188            Some(steer_tx),
1189            Some(shutdown_tx),
1190            Some(result_rx),
1191        ));
1192        let foreground =
1193            agent_core::prompt::QualifiedModelId::parse("anthropic/claude-sonnet-4-6").unwrap();
1194        let orchestration = Arc::new(
1195            crate::orchestration::OrchestrationRuntime::baseline(foreground, 8, 64).unwrap(),
1196        );
1197        orchestration
1198            .authorize("sa_expired", "anthropic/claude-sonnet-4-6")
1199            .unwrap();
1200        let gate_before = orchestration.completion_gate();
1201        reap_finished_with_ttl(&registry, Some(orchestration.as_ref()), Duration::ZERO);
1202        assert!(registry
1203            .lock()
1204            .unwrap()
1205            .get("sa_expired")
1206            .unwrap()
1207            .is_tombstone());
1208        let count_before = registry.lock().unwrap().list_active().len();
1209
1210        let mut ctx = create_tool_context();
1211        ctx.capabilities.subagent_registry = Some(registry.clone());
1212        ctx.capabilities.orchestration = Some(orchestration.clone());
1213        let error = SubagentResumeTool
1214            .execute(
1215                json!({"handle_id": "sa_expired", "instructions": "continue"}),
1216                ctx,
1217            )
1218            .await
1219            .unwrap_err()
1220            .to_string();
1221        assert!(error.contains("expired resumable context"));
1222        assert!(!error.contains("No subagent found"));
1223        assert_eq!(registry.lock().unwrap().list_active().len(), count_before);
1224        assert_eq!(orchestration.completion_gate(), gate_before);
1225    }
1226}
1227
1228/// Warn, never silently extend an explicitly requested wall-clock limit.
1229fn short_timeout_warning(seconds: u64) -> Option<&'static str> {
1230    (seconds > 0 && seconds < 300).then_some(
1231        "Resume timeout is below 300s and may expire during upload or API retries; prefer at least 600s (0 disables the limit)."
1232    )
1233}
1234
1235#[cfg(test)]
1236mod short_timeout_tests {
1237    #[test]
1238    fn only_short_positive_effective_limits_warn() {
1239        for (seconds, warns) in [
1240            (0, false),
1241            (60, true),
1242            (299, true),
1243            (300, false),
1244            (600, false),
1245        ] {
1246            assert_eq!(super::short_timeout_warning(seconds).is_some(), warns);
1247        }
1248    }
1249}