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