1use 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#[derive(Debug)]
32struct PriorRun {
33 agent_name: String,
34 model: String,
35 system_prompt: String,
36 timeout_secs: u64,
37 cwd: Option<std::path::PathBuf>,
39 thinking: Option<String>,
41 messages: Vec<crate::SharedMessage>,
42 compacted: Option<CompactedResume>,
44}
45
46#[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 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 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 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 (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 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 ¶ms,
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 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 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 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 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 let thread_handle = std::thread::spawn(move || {
490 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 let mut runtime = match super::spawn_runtime().await {
521 Ok(r) => r,
522 Err(_) => return Err("subagent runtime initialization failed".into()),
523 };
524
525 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 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 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 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 super::finalize::retire_if_cancelled(
648 &state_for_finalizer,
649 &orchestration_for_worker,
650 &handle_id_inner,
651 );
652 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 {
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 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(®istry, "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(®istry, "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(®istry, "sa_tomb", None, "continue").unwrap();
867 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 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(®istry, "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 state.write().unwrap().archive_meta = None;
923 let prior = prior_run(®istry, "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 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(®istry, "sa_cold", Some(path.clone()), "add titles", COMPACT)
989 .unwrap();
990 let c = prior.compacted.expect("cold + big compacts");
991 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 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(®istry, "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 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(®istry, "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 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(®istry, "sa_live", None, "go", COMPACT).unwrap();
1059 assert!(prior.compacted.is_some());
1060 assert_eq!(prior.thinking.as_deref(), Some("medium"));
1061 state.write().unwrap().cache_anchor_unix_ms = None;
1063 let prior = prior_run_with(®istry, "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(®istry, "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 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(®istry, "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 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(®istry, "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 {
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(®istry, "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(®istry, "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(®istry, "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(®istry, 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
1225fn 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}