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