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(system_prompt));
538                        runtime.set_model(model_a.clone());
539                        super::fit_subagent_context_window(&mut runtime);
540                        super::apply_codex_worker_reasoning(
541                            &mut runtime,
542                            codex_parent_plan.as_ref(),
543                        );
544                        super::apply_subagent_thinking(&mut runtime, thinking)?;
545                        runtime
546                            .install_worker_orchestration(Arc::clone(&orchestration_for_runtime));
547                        runtime.set_delegation_parent(Some(child_parent_id.clone()));
548                        // Rolled-over context goes beside this worker's
549                        // resume archive (see `worker_context`).
550                        let archive_dir = state_a
551                            .read()
552                            .unwrap_or_else(|p| p.into_inner())
553                            .archive_dir
554                            .clone()
555                            .unwrap_or_else(super::archive::process_dir);
556                        super::attach_worker_transcripts(
557                            &mut runtime,
558                            archive_dir,
559                            &child_parent_id,
560                            Some(Arc::clone(&state_a)),
561                            &initial_messages,
562                            tx_events_a.clone(),
563                            subagent_id,
564                            &label_a,
565                        );
566
567                        let cancel = crate::CancellationToken::new();
568                        let cancel_inner = cancel.clone();
569                        tokio::spawn(async move {
570                            let _ = shutdown_rx.await;
571                            cancel_inner.cancel();
572                        });
573
574                        let cancel_on_timeout = cancel.clone();
575                        let stream = runtime.run_stream_with_messages(
576                                initial_messages,
577                                cancel,
578                                Some(steer_rx),
579                                None,
580                                false,
581                            ).await;
582
583                        let drive_outcome = super::drive::WorkerDrive {
584                            state: Arc::clone(&state_a),
585                            tx_events: tx_events_a.clone(),
586                            subagent_id,
587                            label: label_a.clone(),
588                            model: model_a.clone(),
589                            timeout_secs,
590                            cancel: cancel_on_timeout,
591                            task: task_for_timeout,
592                        }
593                        .run(stream)
594                        .await;
595                        // Date this run's cache for the next resume (cold check).
596                        super::archive::record_cache_anchor(&state_a, runtime.cache_anchor());
597                        drive_outcome
598                    });
599
600                match outcome {
601                    Ok(sa_result) => {
602                        {
603                            let mut s = state_t.write().unwrap();
604                            if matches!(s.status, SubagentStatus::Running) && !s.cancel_requested {
605                                s.status = SubagentStatus::Completed;
606                                s.conversation_state = vec![
607                                    serde_json::json!({"role": "user", "content": task_for_complete.clone()}),
608                                    serde_json::json!({"role": "assistant", "content": sa_result.text.clone()}),
609                                ];
610                            }
611                        }
612                        pending_result = Some(sa_result);
613                    }
614                    Err(e) => {
615                        state_t.write().unwrap().status = SubagentStatus::Failed(e);
616                    }
617                }
618            }));
619
620            if let Err(panic_info) = panic_result {
621                let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
622                    s.to_string()
623                } else if let Some(s) = panic_info.downcast_ref::<String>() {
624                    s.clone()
625                } else {
626                    "unknown panic".to_string()
627                };
628                tracing::error!("Resumed subagent thread panicked: {}", msg);
629                state_t.write().unwrap_or_else(|p| p.into_inner()).status =
630                    SubagentStatus::Failed(format!("panic: {}", msg));
631            }
632
633            // ── Terminal finalizer — exactly once, outside catch_unwind ────────
634            // Covers all paths: Ok, Err, timeout, panic, early tokio-build failure.
635            // Sets data.resumed_from so parent can correlate with the prior handle.
636            super::finalize::finalize_subagent(
637                &state_for_finalizer,
638                parent_queue.as_ref(),
639                &handle_id_inner,
640                subagent_id,
641                &label_for_finalizer,
642                start_time,
643                Some(&prior_handle_for_finalizer),
644            );
645            // A cancelled worker publishes no wake, so nothing would ever
646            // prompt a collect: retire it before any terminal publication.
647            super::finalize::retire_if_cancelled(
648                &state_for_finalizer,
649                &orchestration_for_worker,
650                &handle_id_inner,
651            );
652            // All terminal publications follow the recovery/finalization barrier.
653            if let Some(ref tx) = tx_events_inner {
654                let s = state_for_finalizer
655                    .read()
656                    .unwrap_or_else(|p| p.into_inner());
657                let preview = match s.status.failure_reason() {
658                    Some(reason) => format!("ERROR: {reason}"),
659                    None => s.partial_text.chars().take(120).collect(),
660                };
661                let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
662                    subagent_id,
663                    agent_name: label_for_finalizer.clone(),
664                    result_preview: preview,
665                    duration_secs: start_time.elapsed().as_secs_f64(),
666                }));
667            }
668            if let Some(result) = pending_result {
669                let _ = result_tx.send(result);
670            }
671            orchestration_for_worker
672                .release_delegation(&handle_id_inner, parent_for_worker.as_deref());
673        });
674
675        // ── Wire thread handle into the already-registered entry ─────────────
676        {
677            let mut reg = registry.lock().unwrap();
678            if let Some(h) = reg.get_mut(&handle_id) {
679                h.set_thread_handle(thread_handle);
680            }
681        }
682        if let Err(error) = orchestration.mark_running(&handle_id) {
683            let mut handle = registry.lock().unwrap().remove(&handle_id);
684            if let Some(handle) = handle.as_mut() {
685                handle.cancel();
686            }
687            if let Some(handle) = handle {
688                let _ = handle.collect().await;
689            }
690            orchestration.rollback(&handle_id);
691            orchestration
692                .release_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref());
693            return Err(RuntimeError::Tool(error));
694        }
695        // The thread may have been cancelled and finalized before it was
696        // marked running; its own retire attempt was then too early.
697        super::finalize::retire_if_cancelled(&state, orchestration, &handle_id);
698
699        let context = if compacted.is_some() {
700            "compacted"
701        } else {
702            "full"
703        };
704        let mut reply = json!({
705            "handle_id":    handle_id,
706            "resumed_from": prior_handle_id,
707            "warning": timeout_warning,
708            "agent_name":   label,
709            "status":       "running",
710            "context":      context
711        });
712        if let Some(c) = compacted {
713            reply["compacted"] = json!(c);
714        }
715        Ok(reply.to_string())
716    }
717}
718
719#[cfg(test)]
720mod tests {
721    use super::*;
722    use crate::runtime::subagent::{reap_finished_with_ttl, SubagentRegistry};
723    use crate::tools::test_helpers::create_tool_context;
724    use std::sync::Mutex;
725    use std::time::Duration;
726
727    fn finished_handle(
728        id: &str,
729        status: SubagentStatus,
730        archive_path: Option<std::path::PathBuf>,
731    ) -> SubagentHandle {
732        let state = Arc::new(RwLock::new(SubagentState::new()));
733        {
734            let mut s = state.write().unwrap();
735            s.status = status;
736            s.partial_text = "prior text".into();
737            s.conversation_state = vec![json!({"role": "assistant", "content": "prior text"})];
738            s.finished_at = Some(std::time::Instant::now());
739            s.archive_path = archive_path;
740        }
741        let (steer_tx, _steer_rx) = mpsc::unbounded_channel();
742        let (shutdown_tx, _shutdown_rx) = oneshot::channel();
743        let (_result_tx, result_rx) = oneshot::channel();
744        SubagentHandle::new(
745            id.into(),
746            1,
747            "live-label".into(),
748            "task".into(),
749            "anthropic/claude-sonnet-4-6".into(),
750            "live system".into(),
751            30,
752            state,
753            Some(steer_tx),
754            Some(shutdown_tx),
755            Some(result_rx),
756        )
757    }
758
759    fn archive_in(dir: &std::path::Path, id: &str) -> std::path::PathBuf {
760        let meta = crate::runtime::subagent::SubagentArchiveMeta {
761            agent_name: "archived-label".into(),
762            model: "anthropic/claude-opus-5-5".into(),
763            system_prompt: "archived system".into(),
764            timeout_secs: 3500,
765            cwd: None,
766            thinking: Some("low".into()),
767        };
768        let history: Vec<crate::SharedMessage> = vec![
769            Arc::new(json!({"role": "user", "content": "build the plate"})),
770            Arc::new(json!({"role": "assistant", "content": [
771                {"type": "tool_use", "id": "t1", "name": "bash", "input": {"command": "render"}}
772            ]})),
773            Arc::new(json!({"role": "user", "content": [
774                {"type": "tool_result", "tool_use_id": "t1", "content": "rendered 300 frames"}
775            ]})),
776        ];
777        super::super::archive::write_archive(
778            dir,
779            id,
780            &meta,
781            "timed_out",
782            Some("worker timed out"),
783            "report",
784            &history,
785        )
786        .unwrap()
787    }
788
789    fn assert_continues_archive(prior: &PriorRun, instructions: &str) {
790        assert_eq!(
791            prior.messages.len(),
792            3,
793            "archived turns replayed, instructions merged"
794        );
795        assert_eq!(prior.messages[0]["content"], "build the plate");
796        let last = &prior.messages[2]["content"];
797        assert_eq!(last[0]["content"], "rendered 300 frames");
798        assert!(last[1]["text"].as_str().unwrap().ends_with(instructions));
799    }
800
801    #[test]
802    fn subagent_thinking_resume_archive_survives_config_change_and_override() {
803        let dir = tempfile::tempdir().unwrap();
804        let path = archive_in(dir.path(), "sa_thinking");
805        let registry = Mutex::new(SubagentRegistry::new());
806        let prior = prior_run(&registry, "sa_thinking", Some(path), "continue").unwrap();
807        assert_eq!(prior.thinking.as_deref(), Some("low"));
808        let cfg = crate::config::load_config_from_str("subagent_thinking = max\n");
809        let retained = super::super::resolve_subagent_thinking(&json!({}), prior.thinking.as_deref(), &cfg).unwrap();
810        assert_eq!(retained.as_str(), "low");
811        let overridden = super::super::resolve_subagent_thinking(&json!({"thinking":"xhigh"}), prior.thinking.as_deref(), &cfg).unwrap();
812        let mut runtime = crate::Runtime::new_headless();
813        runtime.set_model("openai-codex/gpt-6-astra".into());
814        super::super::apply_subagent_thinking(&mut runtime, retained).unwrap();
815        assert_eq!(runtime.reasoning_level().as_str(), "low");
816        super::super::apply_subagent_thinking(&mut runtime, overridden).unwrap();
817        assert_eq!(runtime.reasoning_level().as_str(), "xhigh");
818    }
819
820    #[test]
821    fn subagent_thinking_legacy_archive_defaults_without_migration() {
822        let meta: crate::runtime::subagent::SubagentArchiveMeta = serde_json::from_value(json!({
823            "agent_name":"old", "model":"openai-codex/gpt-6-astra",
824            "system_prompt":"test", "timeout_secs":300
825        })).unwrap();
826        assert!(meta.thinking.is_none());
827        assert_eq!(super::super::resolve_subagent_thinking(&json!({}), meta.thinking.as_deref(), &Default::default()).unwrap().as_str(), "xhigh");
828    }
829
830    #[test]
831    fn reaped_worker_resumes_from_its_archive() {
832        let dir = tempfile::tempdir().unwrap();
833        let path = archive_in(dir.path(), "sa_reaped");
834        let registry = Mutex::new(SubagentRegistry::new());
835        {
836            let mut reg = registry.lock().unwrap();
837            let mut h = finished_handle("sa_reaped", SubagentStatus::TimedOut, Some(path));
838            h.mark_collected();
839            reg.register(h);
840            reg.cleanup_finished_with_ttl(Duration::ZERO);
841            assert!(reg.get("sa_reaped").is_none(), "handle reaped");
842        }
843        let prior = prior_run(&registry, "sa_reaped", None, "render pass 3").unwrap();
844        assert_eq!(prior.agent_name, "archived-label");
845        assert_eq!(prior.model, "anthropic/claude-opus-5-5");
846        assert_eq!(prior.system_prompt, "archived system");
847        assert_eq!(prior.timeout_secs, 3500);
848        assert_continues_archive(&prior, "render pass 3");
849    }
850
851    #[test]
852    fn tombstoned_worker_with_archive_resumes() {
853        let dir = tempfile::tempdir().unwrap();
854        let path = archive_in(dir.path(), "sa_tomb");
855        let registry = Mutex::new(SubagentRegistry::new());
856        {
857            let mut reg = registry.lock().unwrap();
858            reg.register(finished_handle(
859                "sa_tomb",
860                SubagentStatus::Failed("x".into()),
861                Some(path),
862            ));
863            reg.release_finished_resources("sa_tomb");
864            assert!(reg.get("sa_tomb").unwrap().is_tombstone());
865        }
866        let prior = prior_run(&registry, "sa_tomb", None, "continue").unwrap();
867        // A live handle's identity wins over the archive's.
868        assert_eq!(prior.agent_name, "live-label");
869        assert_eq!(prior.timeout_secs, 30);
870        assert_continues_archive(&prior, "continue");
871    }
872
873    #[test]
874    fn review_202_terminal_but_unpublished_worker_must_not_resume_lossily() {
875        let registry = Mutex::new(SubagentRegistry::new());
876        let h = finished_handle("sa_review", SubagentStatus::TimedOut, None);
877        {
878            let mut s = h.state.write().unwrap();
879            // Unlike the intentionally legacy helper, a managed worker has metadata.
880            s.archive_meta = Some(crate::runtime::subagent::SubagentArchiveMeta {
881                agent_name: "worker".into(),
882                model: "model".into(),
883                system_prompt: "system".into(),
884                timeout_secs: 1,
885                cwd: None,
886                thinking: Some("medium".into()),
887            });
888            s.finished_at = None;
889        }
890        let state = Arc::clone(&h.state);
891        registry.lock().unwrap().register(h);
892        assert!(prior_run(&registry, "sa_review", None, "continue").is_err());
893        registry
894            .lock()
895            .unwrap()
896            .cleanup_finished_with_ttl(Duration::ZERO);
897        assert!(registry.lock().unwrap().get("sa_review").is_some());
898        let dir = tempfile::tempdir().unwrap();
899        let blocked = dir.path().join("blocked");
900        std::fs::write(&blocked, b"x").unwrap();
901        let history = vec![
902            Arc::new(json!({"role":"user", "content":"task"})),
903            Arc::new(json!({"role":"assistant", "content":[
904                {"type":"tool_use", "id":"t1", "name":"read", "input":{"path":"x"}}
905            ]})),
906            Arc::new(json!({"role":"user", "content":[
907                {"type":"tool_result", "tool_use_id":"t1", "content":"preserved body"}
908            ]})),
909        ];
910        state.write().unwrap().history = Some(history);
911        super::super::finalize::finalize_subagent_in(
912            &state,
913            None,
914            "sa_review",
915            1,
916            "worker",
917            std::time::Instant::now(),
918            None,
919            &blocked,
920        );
921        // Keep this test hermetic: missing metadata prevents the production-dir retry.
922        state.write().unwrap().archive_meta = None;
923        let prior = prior_run(&registry, "sa_review", None, "continue").unwrap();
924        assert_eq!(prior.messages[2]["content"][0]["content"], "preserved body");
925        {
926            let mut reg = registry.lock().unwrap();
927            reg.get_mut("sa_review").unwrap().mark_collected();
928            reg.release_finished_resources("sa_review");
929            reg.cleanup_finished_with_ttl(Duration::ZERO);
930            assert!(reg.get("sa_review").is_some());
931        }
932        assert!(state.read().unwrap().history.is_some());
933    }
934
935    /// A history well above a 1k-token threshold: bulky tool results.
936    fn bulky_history() -> Vec<crate::SharedMessage> {
937        let mut h = vec![Arc::new(json!({"role": "user", "content": "render the reel"}))];
938        for i in 0..6 {
939            h.push(Arc::new(json!({"role": "assistant", "content": [
940                {"type": "tool_use", "id": format!("t{i}"), "name": "bash",
941                 "input": {"command": format!("render {i}")}}
942            ]})));
943            h.push(Arc::new(json!({"role": "user", "content": [
944                {"type": "tool_result", "tool_use_id": format!("t{i}"), "content": "z".repeat(4000)}
945            ]})));
946        }
947        h.push(Arc::new(json!({"role": "assistant", "content": "reel rendered"})));
948        h
949    }
950
951    const COMPACT: ResumePolicy = ResumePolicy {
952        full_history: false,
953        compact_tokens: 1_000,
954    };
955
956    #[test]
957    fn cold_large_archive_resumes_compacted_and_full_history_overrides() {
958        let dir = tempfile::tempdir().unwrap();
959        let meta = crate::runtime::subagent::SubagentArchiveMeta {
960            agent_name: "archived-label".into(),
961            model: "anthropic/claude-opus-5-5".into(),
962            system_prompt: "archived system".into(),
963            timeout_secs: 3500,
964            cwd: None,
965            thinking: Some("max".into()),
966        };
967        let old_anchor = std::time::SystemTime::now() - Duration::from_secs(3600);
968        let old_ms = old_anchor
969            .duration_since(std::time::UNIX_EPOCH)
970            .unwrap()
971            .as_millis() as u64;
972        let history = bulky_history();
973        let path = super::super::archive::write_archive_anchored(
974            dir.path(),
975            "sa_cold",
976            &meta,
977            "completed",
978            None,
979            "reel rendered",
980            &history,
981            Some(old_ms),
982        )
983        .unwrap();
984        let before = std::fs::read(&path).unwrap();
985        let registry = Mutex::new(SubagentRegistry::new());
986
987        let prior =
988            prior_run_with(&registry, "sa_cold", Some(path.clone()), "add titles", COMPACT)
989                .unwrap();
990        let c = prior.compacted.expect("cold + big compacts");
991        // Compacting the history must not drop the worker's recorded effort.
992        assert_eq!(prior.thinking.as_deref(), Some("max"));
993        assert_eq!(c.messages, history.len());
994        assert!(c.cache_age_secs.unwrap() >= 3600);
995        assert_eq!(prior.messages.len(), 1);
996        let text = prior.messages[0]["content"].to_string();
997        assert!(text.contains("render the reel") && text.contains("add titles"));
998
999        // The prior archive is untouched, so a full replay is still possible.
1000        assert_eq!(std::fs::read(&path).unwrap(), before);
1001        let full = ResumePolicy {
1002            full_history: true,
1003            ..COMPACT
1004        };
1005        let prior =
1006            prior_run_with(&registry, "sa_cold", Some(path.clone()), "add titles", full).unwrap();
1007        assert!(prior.compacted.is_none());
1008        assert_eq!(prior.thinking.as_deref(), Some("max"));
1009        assert_eq!(prior.messages.len(), history.len() + 1);
1010
1011        // A warm archive (fresh anchor) replays in full.
1012        let fresh_ms = std::time::SystemTime::now()
1013            .duration_since(std::time::UNIX_EPOCH)
1014            .unwrap()
1015            .as_millis() as u64;
1016        let warm = super::super::archive::write_archive_anchored(
1017            dir.path(),
1018            "sa_warm",
1019            &meta,
1020            "completed",
1021            None,
1022            "",
1023            &history,
1024            Some(fresh_ms),
1025        )
1026        .unwrap();
1027        let prior = prior_run_with(&registry, "sa_warm", Some(warm), "x", COMPACT).unwrap();
1028        assert!(prior.compacted.is_none());
1029    }
1030
1031    #[test]
1032    fn live_unarchived_history_uses_state_anchor_for_the_cold_check() {
1033        let registry = Mutex::new(SubagentRegistry::new());
1034        let h = finished_handle("sa_live", SubagentStatus::Completed, None);
1035        {
1036            let mut s = h.state.write().unwrap();
1037            s.archive_meta = Some(crate::runtime::subagent::SubagentArchiveMeta {
1038                agent_name: "worker".into(),
1039                model: "model".into(),
1040                system_prompt: "system".into(),
1041                timeout_secs: 1,
1042                cwd: None,
1043                thinking: Some("medium".into()),
1044            });
1045            s.archive_error = Some("archive write failed".into());
1046            s.finalization_ready = true;
1047            s.history = Some(bulky_history());
1048            // Finished just now, but its last request was an hour ago.
1049            s.cache_anchor_unix_ms = Some(
1050                (std::time::SystemTime::now() - Duration::from_secs(3600))
1051                    .duration_since(std::time::UNIX_EPOCH)
1052                    .unwrap()
1053                    .as_millis() as u64,
1054            );
1055        }
1056        let state = Arc::clone(&h.state);
1057        registry.lock().unwrap().register(h);
1058        let prior = prior_run_with(&registry, "sa_live", None, "go", COMPACT).unwrap();
1059        assert!(prior.compacted.is_some());
1060        assert_eq!(prior.thinking.as_deref(), Some("medium"));
1061        // Without an anchor, `finished_at` (just now) says warm.
1062        state.write().unwrap().cache_anchor_unix_ms = None;
1063        let prior = prior_run_with(&registry, "sa_live", None, "go", COMPACT).unwrap();
1064        assert!(prior.compacted.is_none());
1065        assert_eq!(prior.thinking.as_deref(), Some("medium"));
1066    }
1067
1068    #[test]
1069    fn running_worker_cannot_be_resumed() {
1070        let registry = Mutex::new(SubagentRegistry::new());
1071        registry
1072            .lock()
1073            .unwrap()
1074            .register(finished_handle("sa_run", SubagentStatus::Running, None));
1075        let err = prior_run(&registry, "sa_run", None, "x")
1076            .unwrap_err()
1077            .to_string();
1078        assert!(err.contains("still running"), "{err}");
1079    }
1080
1081    #[test]
1082    fn explicit_archive_ignores_the_registry() {
1083        // An archive from an earlier session can share a handle id with a
1084        // worker running now; the registry must not be consulted.
1085        let dir = tempfile::tempdir().unwrap();
1086        let path = archive_in(dir.path(), "sa_3");
1087        let registry = Mutex::new(SubagentRegistry::new());
1088        registry
1089            .lock()
1090            .unwrap()
1091            .register(finished_handle("sa_3", SubagentStatus::Running, None));
1092        let prior = prior_run(&registry, "sa_3", Some(path), "pick up").unwrap();
1093        assert_eq!(prior.agent_name, "archived-label");
1094        assert_continues_archive(&prior, "pick up");
1095    }
1096
1097    #[test]
1098    fn empty_history_archive_is_refused_not_resumed_blank() {
1099        // A startup-failure archive (worker exited before its first
1100        // checkpoint) is written with no history; resuming it would start
1101        // the worker with nothing but a preamble claiming earlier work.
1102        let dir = tempfile::tempdir().unwrap();
1103        let meta = crate::runtime::subagent::SubagentArchiveMeta {
1104            agent_name: "archived-label".into(),
1105            model: "anthropic/claude-opus-5-5".into(),
1106            system_prompt: "archived system".into(),
1107            timeout_secs: 30,
1108            cwd: None,
1109            thinking: None,
1110        };
1111        let path = super::super::archive::write_archive(
1112            dir.path(),
1113            "sa_empty",
1114            &meta,
1115            "failed",
1116            Some("provider refused the model"),
1117            "",
1118            &[],
1119        )
1120        .unwrap();
1121        let registry = Mutex::new(SubagentRegistry::new());
1122        let err = match prior_run(&registry, "sa_empty", Some(path.clone()), "go on") {
1123            Err(e) => e.to_string(),
1124            Ok(_) => panic!("empty archive resumed"),
1125        };
1126        assert!(err.contains("no conversation to resume"), "{err}");
1127        assert!(err.contains("provider refused the model"), "{err}");
1128        // Same through the registry (reaped handle, archive on record).
1129        {
1130            let mut reg = registry.lock().unwrap();
1131            let mut h = finished_handle("sa_empty", SubagentStatus::Failed("x".into()), Some(path));
1132            h.mark_collected();
1133            reg.register(h);
1134            reg.cleanup_finished_with_ttl(Duration::ZERO);
1135        }
1136        assert!(prior_run(&registry, "sa_empty", None, "go on").is_err());
1137    }
1138
1139    #[test]
1140    fn legacy_handle_without_archive_pastes_prior_text() {
1141        let registry = Mutex::new(SubagentRegistry::new());
1142        registry.lock().unwrap().register(finished_handle(
1143            "sa_old",
1144            SubagentStatus::Completed,
1145            None,
1146        ));
1147        let prior = prior_run(&registry, "sa_old", None, "next").unwrap();
1148        assert_eq!(prior.messages.len(), 1);
1149        let text = prior.messages[0]["content"].as_str().unwrap();
1150        assert!(text.starts_with("next") && text.contains("prior text"));
1151    }
1152
1153    #[test]
1154    fn unknown_handle_without_archive_is_not_found() {
1155        let registry = Mutex::new(SubagentRegistry::new());
1156        let err = prior_run(&registry, "sa_none", None, "x")
1157            .unwrap_err()
1158            .to_string();
1159        assert!(err.contains("No subagent found"), "{err}");
1160    }
1161
1162    #[tokio::test]
1163    async fn tombstone_resume_reports_expired_context_without_side_effects() {
1164        let state = Arc::new(RwLock::new(SubagentState::new()));
1165        {
1166            let mut state = state.write().unwrap();
1167            state.status = SubagentStatus::Completed;
1168            state.partial_text = "terminal output".into();
1169            state.conversation_state = vec![json!({"role": "assistant", "content": "context"})];
1170            state.finished_at = Some(std::time::Instant::now());
1171        }
1172        let (steer_tx, _steer_rx) = mpsc::unbounded_channel();
1173        let (shutdown_tx, _shutdown_rx) = oneshot::channel();
1174        let (_result_tx, result_rx) = oneshot::channel();
1175        let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
1176        registry.lock().unwrap().register(SubagentHandle::new(
1177            "sa_expired".into(),
1178            1,
1179            "test".into(),
1180            "task".into(),
1181            "anthropic/claude-sonnet-4-6".into(),
1182            "system".into(),
1183            30,
1184            state,
1185            Some(steer_tx),
1186            Some(shutdown_tx),
1187            Some(result_rx),
1188        ));
1189        let foreground =
1190            agent_core::prompt::QualifiedModelId::parse("anthropic/claude-sonnet-4-6").unwrap();
1191        let orchestration = Arc::new(
1192            crate::orchestration::OrchestrationRuntime::baseline(foreground, 8, 64).unwrap(),
1193        );
1194        orchestration
1195            .authorize("sa_expired", "anthropic/claude-sonnet-4-6")
1196            .unwrap();
1197        let gate_before = orchestration.completion_gate();
1198        reap_finished_with_ttl(&registry, Some(orchestration.as_ref()), Duration::ZERO);
1199        assert!(registry
1200            .lock()
1201            .unwrap()
1202            .get("sa_expired")
1203            .unwrap()
1204            .is_tombstone());
1205        let count_before = registry.lock().unwrap().list_active().len();
1206
1207        let mut ctx = create_tool_context();
1208        ctx.capabilities.subagent_registry = Some(registry.clone());
1209        ctx.capabilities.orchestration = Some(orchestration.clone());
1210        let error = SubagentResumeTool
1211            .execute(
1212                json!({"handle_id": "sa_expired", "instructions": "continue"}),
1213                ctx,
1214            )
1215            .await
1216            .unwrap_err()
1217            .to_string();
1218        assert!(error.contains("expired resumable context"));
1219        assert!(!error.contains("No subagent found"));
1220        assert_eq!(registry.lock().unwrap().list_active().len(), count_before);
1221        assert_eq!(orchestration.completion_gate(), gate_before);
1222    }
1223}
1224
1225/// Warn, never silently extend an explicitly requested wall-clock limit.
1226fn short_timeout_warning(seconds: u64) -> Option<&'static str> {
1227    (seconds > 0 && seconds < 300).then_some(
1228        "Resume timeout is below 300s and may expire during upload or API retries; prefer at least 600s (0 disables the limit)."
1229    )
1230}
1231
1232#[cfg(test)]
1233mod short_timeout_tests {
1234    #[test]
1235    fn only_short_positive_effective_limits_warn() {
1236        for (seconds, warns) in [
1237            (0, false),
1238            (60, true),
1239            (299, true),
1240            (300, false),
1241            (600, false),
1242        ] {
1243            assert_eq!(super::short_timeout_warning(seconds).is_some(), warns);
1244        }
1245    }
1246}