返回 CodeWhale
lifecycle_tests.rs
根目录 / crates / tui / src / tools / subagent / lifecycle_tests.rs
1 use super::*;
2 use tempfile::tempdir;
3
4 pub(super) fn status_rows(payload: &Value) -> Vec<Value> {
5 let columns = payload["columns"].as_array().expect("roster columns");
6 let unique = columns
7 .iter()
8 .map(|column| column.as_str().expect("column name"))
9 .collect::<HashSet<_>>();
10 assert_eq!(columns.len(), unique.len(), "duplicate roster column");
11 payload["agents"]
12 .as_array()
13 .expect("roster rows")
14 .iter()
15 .map(|row| {
16 let values = row.as_array().expect("roster row array");
17 assert_eq!(columns.len(), values.len(), "row must match its header");
18 Value::Object(
19 columns
20 .iter()
21 .zip(values)
22 .map(|(column, value)| (column.as_str().unwrap().to_string(), value.clone()))
23 .collect(),
24 )
25 })
26 .collect()
27 }
28
29 fn prior_messages() -> Vec<Message> {
30 vec![Message {
31 role: Role::User,
32 content: vec![ContentBlock::Text {
33 text: "retained work".into(),
34 cache_control: None,
35 }],
36 }]
37 }
38
39 #[tokio::test]
40 async fn lifecycle_bulk_followup_preserves_mappings_and_retries_without_duplicate_workers() {
41 let dir = tempdir().unwrap();
42 let manager = new_shared_subagent_manager(dir.path().to_path_buf(), 12);
43 let mut sources = Vec::new();
44 {
45 let mut guard = manager.write().await;
46 for i in 0..6 {
47 let (id, _) = guard.insert_test_interrupted_continuable_agent(
48 &format!("parked-{i}"),
49 dir.path(),
50 prior_messages(),
51 );
52 let agent = guard.agents.get_mut(&id).unwrap();
53 agent.checkpoint.as_mut().unwrap().parked_at_turn_end = true;
54 agent.agent_type = FleetRole::Scout;
55 agent.model = "deepseek-v4-flash".into();
56 agent.allowed_tools = Some(Vec::new());
57 let spec = &mut guard.worker_records.get_mut(&id).unwrap().spec;
58 spec.model = "deepseek-v4-flash".into();
59 spec.agent_type = FleetRole::Scout;
60 spec.runtime_profile = WorkerRuntimeProfile::for_role(FleetRole::Scout);
61 sources.push(id);
62 }
63 }
64 let (client, _, _) =
65 super::tests::delayed_chat_client(Duration::from_secs(30), "fixture result").await;
66 let mut runtime = super::tests::stub_runtime();
67 runtime.manager = Arc::clone(&manager);
68 runtime.client = client;
69 runtime.context = ToolContext::new(dir.path());
70 let tool = coord::AgentsFollowupTool::new(Arc::clone(&manager)).with_runtime(runtime);
71 let input = json!({"agent_ids": sources, "message": "Continue the assignment."});
72 let first = tool
73 .execute(input.clone(), &ToolContext::new(dir.path()))
74 .await
75 .unwrap();
76 let first: Value = serde_json::from_str(&first.content).unwrap();
77 assert_eq!(first["results"].as_array().unwrap().len(), 6);
78 assert_eq!(first["errors"], json!([]));
79 let second = tool
80 .execute(input, &ToolContext::new(dir.path()))
81 .await
82 .unwrap();
83 let second: Value = serde_json::from_str(&second.content).unwrap();
84 for (a, b) in first["results"]
85 .as_array()
86 .unwrap()
87 .iter()
88 .zip(second["results"].as_array().unwrap())
89 {
90 assert_eq!(a["from"], b["from"]);
91 assert_eq!(a["to"], b["to"]);
92 assert_ne!(a["from"], a["to"]);
93 }
94 let mut guard = manager.write().await;
95 assert_eq!(guard.agents.len(), 12);
96 for id in sources {
97 let target = guard.continuation_target(&id).unwrap();
98 assert_ne!(target, id);
99 assert_eq!(
100 guard.continuation_source(&target).as_deref(),
101 Some(id.as_str())
102 );
103 let _ = guard.cancel_agent(&target);
104 }
105 }
106
107 #[tokio::test]
108 async fn lifecycle_bulk_followup_reports_unknown_and_foreign_targets_without_hiding_success() {
109 let dir = tempdir().unwrap();
110 let manager = new_shared_subagent_manager(dir.path().to_path_buf(), 4);
111 let (owned, foreign) = {
112 let mut guard = manager.write().await;
113 let owned = guard.insert_test_running_agent("owned", dir.path());
114 let foreign = guard.insert_test_running_agent("foreign", dir.path());
115 guard.assign_test_session_owner(&foreign, "another-session");
116 (owned, foreign)
117 };
118 let tool = coord::AgentsFollowupTool::new(Arc::clone(&manager));
119 let result = tool
120 .execute(
121 json!({"agent_ids": [owned, "missing", foreign], "message": "Check progress"}),
122 &ToolContext::new(dir.path()),
123 )
124 .await
125 .unwrap();
126 let payload: Value = serde_json::from_str(&result.content).unwrap();
127 assert_eq!(payload["results"].as_array().unwrap().len(), 1);
128 assert_eq!(payload["errors"].as_array().unwrap().len(), 2);
129 assert!(!manager.read().await.child_was_woken(&foreign));
130 }
131
132 #[tokio::test]
133 async fn lifecycle_followup_rejects_ambiguous_and_invalid_batch_inputs_before_delivery() {
134 let dir = tempdir().unwrap();
135 let manager = new_shared_subagent_manager(dir.path().to_path_buf(), 2);
136 let id = manager
137 .write()
138 .await
139 .insert_test_running_agent("owned", dir.path());
140 let tool = coord::AgentsFollowupTool::new(Arc::clone(&manager));
141 for input in [
142 json!({"agent_id": id, "agent_ids": [id], "message": "x"}),
143 json!({"agent_ids": [], "message": "x"}),
144 json!({"agent_ids": [id, 7], "message": "x"}),
145 json!({"all_parked": "true", "message": "x"}),
146 json!({"agent_id": id, "message": " "}),
147 ] {
148 assert!(
149 tool.execute(input, &ToolContext::new(dir.path()))
150 .await
151 .is_err()
152 );
153 }
154 assert!(!manager.read().await.child_was_woken(&id));
155 }
156
157 #[tokio::test]
158 async fn lifecycle_resume_lineage_survives_persist_and_rejects_cycles_and_foreign_hops() {
159 let dir = tempdir().unwrap();
160 let base = dir.path().canonicalize().unwrap();
161 let state_path = base.join(".codewhale/subagents/state.json");
162 let mut manager = SubAgentManager::new(base.clone(), 6).with_state_path(state_path.clone());
163 let (a, _) = manager.insert_test_interrupted_continuable_agent("old", &base, prior_messages());
164 let (b, _) =
165 manager.insert_test_interrupted_continuable_agent("continued", &base, prior_messages());
166 let (c, _) =
167 manager.insert_test_interrupted_continuable_agent("latest", &base, prior_messages());
168 manager.resume_targets.insert(a.clone(), b.clone());
169 manager.resume_targets.insert(b.clone(), c.clone());
170 let (path, payload) = manager.build_persist_payload().unwrap().unwrap();
171 write_json_atomic(&base, &path, &payload).unwrap();
172 let mut loaded = SubAgentManager::new(base.clone(), 6).with_state_path(state_path);
173 loaded.load_state().unwrap();
174 assert_eq!(loaded.continuation_target(&a).unwrap(), c);
175 loaded.resume_targets.insert(c.clone(), a.clone());
176 assert!(
177 loaded
178 .continuation_target(&a)
179 .unwrap_err()
180 .to_string()
181 .contains("cycle")
182 );
183 loaded.resume_targets.remove(&c);
184 loaded.assign_test_session_owner(&c, "foreign");
185 assert!(
186 loaded
187 .continuation_target(&a)
188 .unwrap_err()
189 .to_string()
190 .contains("outside")
191 );
192 }
193
194 #[tokio::test]
195 async fn lifecycle_compact_roster_bounds_every_state_and_pages_multibyte_names() {
196 let dir = tempdir().unwrap();
197 let manager = new_shared_subagent_manager(dir.path().to_path_buf(), 50);
198 let mut expected = HashSet::new();
199 {
200 let mut guard = manager.write().await;
201 for i in 0..37 {
202 let id = guard.insert_test_running_agent(&format!("bounded-{i}"), dir.path());
203 expected.insert(id.clone());
204 let agent = guard.agents.get_mut(&id).unwrap();
205 agent.session_name = "🐋\"".repeat(4000);
206 agent.prompt = "archive-only".repeat(10_000);
207 agent.checkpoint = Some(build_subagent_checkpoint(
208 &id,
209 "resume",
210 &prior_messages(),
211 1,
212 true,
213 ));
214 if i % 3 == 1 {
215 agent.status = SubAgentStatus::Interrupted("reason".repeat(10_000));
216 }
217 if i % 3 == 2 {
218 agent.status = SubAgentStatus::Failed("failure".repeat(10_000));
219 }
220 let record = guard.worker_records.get_mut(&id).unwrap();
221 record.usage.total_tokens = Some(100);
222 record.verification.summary = "\"🐋".repeat(10_000);
223 if i > 0 {
224 record.parent_run_id = Some("agent_bounded-0".into());
225 }
226 }
227 }
228 let mut offset = 0;
229 let mut seen = HashSet::new();
230 let mut header = None;
231 loop {
232 let result = inspect_agent_from_input(
233 &json!({"action": "status", "verbose": true, "offset": offset}),
234 Arc::clone(&manager),
235 &ToolContext::new(dir.path()),
236 false,
237 None,
238 )
239 .await
240 .unwrap();
241 assert!(
242 result.content.len() <= lifecycle::COMPACT_STATUS_BYTES,
243 "{}",
244 result.content.len()
245 );
246 let value: Value = serde_json::from_str(&result.content).unwrap();
247 assert_eq!(
248 *header.get_or_insert_with(|| value["columns"].clone()),
249 value["columns"]
250 );
251 assert_eq!(value["total_count"], 37);
252 assert_eq!(
253 value["usage"]["total_tokens"], 3700,
254 "scope totals must not be counted per child"
255 );
256 let rows = status_rows(&value);
257 assert!(!rows.is_empty());
258 for row in rows {
259 assert!(seen.insert(row["agent_id"].as_str().unwrap().to_string()));
260 for key in [
261 "snapshot",
262 "worker_record",
263 "checkpoint",
264 "transcript_handle",
265 ] {
266 assert!(row.get(key).is_none(), "{key}");
267 }
268 }
269 let Some(next) = value["next_offset"].as_u64() else {
270 break;
271 };
272 assert!(next > offset);
273 offset = next;
274 }
275 assert_eq!(seen, expected);
276 }
277
278 #[tokio::test]
279 async fn lifecycle_compact_columns_keep_unknown_usage_and_pending_input_explicit() {
280 let dir = tempdir().unwrap();
281 let mut manager = SubAgentManager::new(dir.path().to_path_buf(), 3);
282 let zero = manager.insert_test_running_agent("zero", dir.path());
283 manager
284 .worker_records
285 .get_mut(&zero)
286 .unwrap()
287 .usage
288 .total_tokens = Some(0);
289 let (pending, _) =
290 manager.insert_test_interrupted_continuable_agent("pending", dir.path(), prior_messages());
291 manager.agents.get_mut(&pending).unwrap().needs_input = Some(SubAgentNeedsInput {
292 question: "Approve the next validation step?".into(),
293 });
294 let payload =
295 lifecycle::compact_roster(&manager, &json!({}), "workspace", false, false).unwrap();
296 let rows = status_rows(&payload);
297 let zero = rows.iter().find(|row| row["agent_id"] == zero).unwrap();
298 let pending = rows.iter().find(|row| row["agent_id"] == pending).unwrap();
299 assert_eq!(zero["total_tokens"], 0);
300 assert!(zero["needs_input"].is_null());
301 assert!(pending["total_tokens"].is_null());
302 assert_eq!(pending["needs_input"], "Approve the next validation step?");
303 assert_eq!(pending["needs_continuation"], true);
304 assert_eq!(payload["usage"]["total_tokens"], 0);
305 assert_eq!(payload["usage"]["reported_workers"], 1);
306 let empty = SubAgentManager::new(dir.path().join("empty"), 1);
307 let empty = lifecycle::compact_roster(&empty, &json!({}), "workspace", false, false).unwrap();
308 assert_eq!(empty["columns"], payload["columns"]);
309 assert!(status_rows(&empty).is_empty());
310 }
311
312 #[tokio::test]
313 async fn lifecycle_addressed_status_follows_lineage_and_detail_is_bounded() {
314 let dir = tempdir().unwrap();
315 let manager = new_shared_subagent_manager(dir.path().to_path_buf(), 3);
316 let (old, latest) = {
317 let mut guard = manager.write().await;
318 let (old, _) =
319 guard.insert_test_interrupted_continuable_agent("old", dir.path(), prior_messages());
320 let latest = guard.insert_test_running_agent("latest", dir.path());
321 guard.resume_targets.insert(old.clone(), latest.clone());
322 guard.agents.get_mut(&latest).unwrap().result = Some("🐋".repeat(100_000));
323 (old, latest)
324 };
325 let context = ToolContext::new(dir.path());
326 let roster = inspect_agent_from_input(
327 &json!({"action": "status"}),
328 Arc::clone(&manager),
329 &context,
330 false,
331 None,
332 )
333 .await
334 .unwrap();
335 let roster: Value = serde_json::from_str(&roster.content).unwrap();
336 let rows = status_rows(&roster);
337 assert_eq!(
338 rows.iter().find(|row| row["agent_id"] == old).unwrap()["resumed_as"],
339 latest
340 );
341 assert_eq!(
342 rows.iter().find(|row| row["agent_id"] == latest).unwrap()["resumed_from"],
343 old
344 );
345 let result = inspect_agent_from_input(
346 &json!({"agent_id": old}),
347 Arc::clone(&manager),
348 &context,
349 false,
350 None,
351 )
352 .await
353 .unwrap();
354 let row: Value = serde_json::from_str(&result.content).unwrap();
355 assert_eq!(row["agent_id"], latest);
356 assert_eq!(row["addressed_agent_id"], old);
357 assert_eq!(row["resumed_from"], old);
358 assert!(row.get("snapshot").is_none());
359 let detail = inspect_agent_from_input(
360 &json!({"agent_id": old, "detail": true}),
361 manager,
362 &context,
363 false,
364 None,
365 )
366 .await
367 .unwrap();
368 assert!(detail.content.len() <= 32 * 1024);
369 let row: Value = serde_json::from_str(&detail.content).unwrap();
370 assert_eq!(row["detail_bounded"], true);
371 assert!(row["transcript_handle"].is_object());
372 }
373
374 #[tokio::test]
375 async fn lifecycle_named_cancel_stops_grandchildren_and_preserves_sibling() {
376 let dir = tempdir().unwrap();
377 let mut manager = SubAgentManager::new(dir.path().to_path_buf(), 4);
378 let parent = manager.insert_test_running_agent("parent", dir.path());
379 let child = manager.insert_test_running_agent("child", dir.path());
380 let sibling = manager.insert_test_running_agent("sibling", dir.path());
381 let record = manager.worker_records.get_mut(&child).unwrap();
382 record.parent_run_id = Some(parent.clone());
383 record.spec.parent_run_id = Some(parent.clone());
384 manager
385 .cancel_agent_for_session("workspace", &parent)
386 .unwrap();
387 assert_eq!(
388 manager.get_result(&child).unwrap().status,
389 SubAgentStatus::Cancelled
390 );
391 assert_eq!(
392 manager.get_result(&parent).unwrap().status,
393 SubAgentStatus::Cancelled
394 );
395 assert_eq!(
396 manager.get_result(&sibling).unwrap().status,
397 SubAgentStatus::Running
398 );
399 }
400
401 #[test]
402 fn lifecycle_recovery_never_forks_and_byte_preview_preserves_utf8() {
403 let instruction = subagent_followup_recovery("agent_parked");
404 assert!(instruction.contains("action=\"followup\""));
405 assert!(!instruction.contains("resume_from"));
406 let preview = lifecycle::text_preview(&"🐋".repeat(10_000), 65);
407 assert!(preview.len() <= 65);
408 assert!(preview.ends_with("..."));
409 }
410
411 #[test]
412 fn lifecycle_park_event_sentinel_and_tool_share_recovery_instruction() {
413 let agent_id = "agent_parked";
414 let instruction = subagent_followup_recovery(agent_id);
415 let parking = Arc::new(std::sync::atomic::AtomicBool::new(true));
416 let (status, output, checkpoint, needs_input, _, _) =
417 subagent_cancellation_projection(agent_id, &prior_messages(), 1, None, Some(&parking));
418 assert!(output.as_deref().unwrap().contains(&instruction));
419 assert_eq!(needs_input.as_ref().unwrap().question, instruction);
420
421 let mut result = super::tests::make_snapshot(status);
422 result.agent_id = agent_id.into();
423 result.result = output;
424 result.checkpoint = checkpoint;
425 result.needs_input = needs_input;
426 let sentinel = subagent_done_sentinel(agent_id, &result, false);
427 let metadata: Value = serde_json::from_str(
428 sentinel
429 .strip_prefix("<codewhale:subagent.done>")
430 .unwrap()
431 .strip_suffix("</codewhale:subagent.done>")
432 .unwrap(),
433 )
434 .unwrap();
435 assert_eq!(metadata["needs_input"]["question"], instruction);
436 assert!(AGENT_TOOL_DESCRIPTION.contains(&subagent_followup_recovery("<agent_id>")));
437 }
438
439 #[tokio::test]
440 async fn lifecycle_followup_rechecks_actual_successor_authority() {
441 let dir = tempdir().unwrap();
442 let manager = new_shared_subagent_manager(dir.path().to_path_buf(), 4);
443 let (caller, old, sibling) = {
444 let mut guard = manager.write().await;
445 let caller = guard.insert_test_running_agent("caller", dir.path());
446 let (old, _) = guard.insert_test_interrupted_continuable_agent(
447 "own-child",
448 dir.path(),
449 prior_messages(),
450 );
451 let sibling = guard.insert_test_running_agent("sibling", dir.path());
452 let record = guard.worker_records.get_mut(&old).unwrap();
453 record.parent_run_id = Some(caller.clone());
454 record.spec.parent_run_id = Some(caller.clone());
455 guard.resume_targets.insert(old.clone(), sibling.clone());
456 (caller, old, sibling)
457 };
458 let tool =
459 coord::AgentsFollowupTool::new(Arc::clone(&manager)).with_optional_caller(Some(caller));
460 assert!(
461 tool.execute(
462 json!({"agent_id": old, "message": "Try to wake sibling"}),
463 &ToolContext::new(dir.path())
464 )
465 .await
466 .is_err()
467 );
468 assert!(!manager.read().await.child_was_woken(&sibling));
469 }
470
471 #[tokio::test]
472 async fn lifecycle_deliverable_preview_reports_omissions_and_detail_pages_the_full_list() {
473 let dir = tempdir().unwrap();
474 let mut manager = SubAgentManager::new(dir.path().to_path_buf(), 2);
475 let id = manager.insert_test_running_agent("outputs", dir.path());
476 manager
477 .worker_records
478 .get_mut(&id)
479 .unwrap()
480 .verification
481 .deliverables = (0..9)
482 .map(|index| DeliverableVerdict {
483 path: format!("report-{index}.md"),
484 status: if index == 8 {
485 "missing".into()
486 } else {
487 "present".into()
488 },
489 bytes: (index != 8).then_some(20),
490 })
491 .collect();
492 let compact = lifecycle::compact_row(&manager, &manager.agents[&id]);
493 assert_eq!(compact["verification"]["deliverables_total"], 9);
494 assert_eq!(compact["verification"]["deliverables_omitted"], 5);
495 assert_eq!(compact["verification"]["deliverable_counts"]["missing"], 1);
496 assert_eq!(
497 compact["verification"]["deliverables"][0]["status"],
498 "missing"
499 );
500 let detail = lifecycle::bounded_detail(
501 json!({"verification": manager.worker_records[&id].verification}),
502 compact,
503 4,
504 2,
505 );
506 assert_eq!(
507 detail["verification"]["deliverables"]
508 .as_array()
509 .unwrap()
510 .len(),
511 2
512 );
513 assert_eq!(
514 detail["verification"]["deliverables"][0]["path"],
515 "report-4.md"
516 );
517 assert_eq!(detail["verification"]["deliverables_next_offset"], 6);
518 }
519
520 #[tokio::test]
521 async fn lifecycle_running_row_reports_declared_vs_observed_writes() {
522 // #6194 item 5: the parent sees the declared/observed write diff while
523 // the child is still alive, not only in the post-mortem receipt.
524 let dir = tempdir().unwrap();
525 let mut manager = SubAgentManager::new(dir.path().to_path_buf(), 2);
526 let id = manager.insert_test_running_agent("writer", dir.path());
527 {
528 let record = manager.worker_records.get_mut(&id).unwrap();
529 record.spec.runtime_profile.permissions.write = true;
530 record.spec.launch_manifest = Some(ChildLaunchManifest {
531 owner_session: "root".to_string(),
532 child_id: id.clone(),
533 profile: record.spec.runtime_profile.clone(),
534 prompt: record.spec.objective.clone(),
535 cwd: None,
536 worktree: false,
537 writable_roots: Vec::new(),
538 writable_files: Vec::new(),
539 coordination_contracts: Vec::new(),
540 expected_artifact: None,
541 deliverables: vec!["a.rs".to_string(), "b.rs".to_string()],
542 resume_identity: None,
543 generation: 1,
544 resume_from_agent_id: None,
545 });
546 record
547 .delivery_evidence
548 .observed_writes
549 .insert("b.rs".to_string());
550 record
551 .delivery_evidence
552 .observed_writes
553 .insert("surprise.rs".to_string());
554 }
555 let row = lifecycle::compact_row(&manager, &manager.agents[&id]);
556 assert_eq!(row["write_progress"]["declared_total"], 2);
557 assert_eq!(row["write_progress"]["observed_total"], 2);
558 assert_eq!(row["write_progress"]["declared"][0], "a.rs");
559 assert_eq!(row["write_progress"]["observed"][1], "surprise.rs");
560 }
561
562 #[tokio::test]
563 async fn lifecycle_read_only_row_omits_write_progress() {
564 let dir = tempdir().unwrap();
565 let mut manager = SubAgentManager::new(dir.path().to_path_buf(), 2);
566 let id = manager.insert_test_running_agent("scout", dir.path());
567 let row = lifecycle::compact_row(&manager, &manager.agents[&id]);
568 assert!(row.get("write_progress").is_none());
569 }
570
571 #[tokio::test]
572 async fn lifecycle_continuation_link_is_durable_before_the_child_can_run() {
573 let dir = tempdir().unwrap();
574 let base = dir.path().canonicalize().unwrap();
575 let path = base.join(".codewhale/subagents/state.json");
576 let manager = Arc::new(RwLock::new(
577 SubAgentManager::new(base.clone(), 4).with_state_path(path.clone()),
578 ));
579 let (client, calls, _) =
580 super::tests::delayed_chat_client(Duration::from_secs(30), "fixture").await;
581 let mut runtime = super::tests::stub_runtime();
582 runtime.client = client;
583 runtime.manager = Arc::clone(&manager);
584 runtime.context = ToolContext::new(&base);
585 let mut guard = manager.write().await;
586 let (source, _) =
587 guard.insert_test_interrupted_continuable_agent("durable-source", &base, prior_messages());
588 guard.agents.get_mut(&source).unwrap().model = "deepseek-v4-flash".into();
589 guard
590 .worker_records
591 .get_mut(&source)
592 .unwrap()
593 .spec
594 .runtime_profile = WorkerRuntimeProfile::for_role(FleetRole::Scout);
595 assert!(
596 !guard.worker_records[&source]
597 .spec
598 .runtime_profile
599 .permissions
600 .write
601 );
602 let successor = guard
603 .resume_from_checkpoint(Arc::clone(&manager), runtime, &source, "Continue")
604 .unwrap();
605 // Holding the manager lock keeps run_subagent_task_inner at its first
606 // await. This is the earliest published snapshot, before any child step.
607 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
608 let persisted: PersistedSubAgentState =
609 serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
610 assert_eq!(
611 persisted.resume_targets.get(&source),
612 Some(&successor.agent_id)
613 );
614 assert!(
615 persisted
616 .agents
617 .iter()
618 .any(|agent| agent.id == successor.agent_id)
619 );
620 let _ = guard.cancel_agent(&successor.agent_id);
621 }
622
623 #[tokio::test]
624 async fn lifecycle_continuation_persist_failure_rolls_back_worker_and_link() {
625 let dir = tempdir().unwrap();
626 let base = dir.path().canonicalize().unwrap();
627 let path = base.join(".codewhale/subagents/state.json");
628 let manager = Arc::new(RwLock::new(
629 SubAgentManager::new(base.clone(), 4).with_state_path(path),
630 ));
631 let mut runtime = super::tests::stub_runtime();
632 runtime.manager = Arc::clone(&manager);
633 runtime.context = ToolContext::new(&base);
634 let mut guard = manager.write().await;
635 let (source, _) =
636 guard.insert_test_interrupted_continuable_agent("failed-source", &base, prior_messages());
637 guard.agents.get_mut(&source).unwrap().model = "deepseek-v4-flash".into();
638 guard
639 .worker_records
640 .get_mut(&source)
641 .unwrap()
642 .spec
643 .runtime_profile = WorkerRuntimeProfile::for_role(FleetRole::Scout);
644 assert!(
645 !guard.worker_records[&source]
646 .spec
647 .runtime_profile
648 .permissions
649 .write
650 );
651 std::fs::create_dir_all(base.join(".codewhale")).unwrap();
652 std::fs::write(base.join(".codewhale/subagents"), "not a directory").unwrap();
653 let result = guard.resume_from_checkpoint(Arc::clone(&manager), runtime, &source, "Continue");
654 assert!(result.is_err());
655 assert_eq!(guard.agents.len(), 1);
656 assert_eq!(guard.worker_records.len(), 1);
657 assert!(guard.resume_targets.is_empty());
658 assert!(matches!(
659 guard.get_result(&source).unwrap().status,
660 SubAgentStatus::Interrupted(_)
661 ));
662 }
663
664 #[tokio::test]
665 async fn lifecycle_cancel_original_stops_its_continuation_and_existing_descendants() {
666 let dir = tempdir().unwrap();
667 let mut manager = SubAgentManager::new(dir.path().to_path_buf(), 5);
668 let (source, _) =
669 manager.insert_test_interrupted_continuable_agent("original", dir.path(), prior_messages());
670 let current = manager.insert_test_running_agent("current", dir.path());
671 let child = manager.insert_test_running_agent("existing-child", dir.path());
672 let sibling = manager.insert_test_running_agent("separate-fork", dir.path());
673 manager
674 .resume_targets
675 .insert(source.clone(), current.clone());
676 let record = manager.worker_records.get_mut(&child).unwrap();
677 record.parent_run_id = Some(source.clone());
678 record.spec.parent_run_id = Some(source.clone());
679
680 let cancelled = manager
681 .cancel_agent_for_session("workspace", &source)
682 .unwrap();
683 assert_eq!(cancelled.agent_id, current);
684 assert_eq!(cancelled.status, SubAgentStatus::Cancelled);
685 assert_eq!(
686 manager.get_result(&child).unwrap().status,
687 SubAgentStatus::Cancelled
688 );
689 assert_eq!(
690 manager.get_result(&sibling).unwrap().status,
691 SubAgentStatus::Running
692 );
693 assert!(matches!(
694 manager.get_result(&source).unwrap().status,
695 SubAgentStatus::Interrupted(_)
696 ));
697 }
698
699 #[tokio::test]
700 async fn lifecycle_resumed_parent_controls_existing_descendants_but_never_itself_or_siblings() {
701 let dir = tempdir().unwrap();
702 let mut manager = SubAgentManager::new(dir.path().to_path_buf(), 5);
703 let (source, _) =
704 manager.insert_test_interrupted_continuable_agent("original", dir.path(), prior_messages());
705 let current = manager.insert_test_running_agent("current", dir.path());
706 let child = manager.insert_test_running_agent("existing-child", dir.path());
707 let sibling = manager.insert_test_running_agent("sibling", dir.path());
708 manager
709 .resume_targets
710 .insert(source.clone(), current.clone());
711 let record = manager.worker_records.get_mut(&child).unwrap();
712 record.parent_run_id = Some(source.clone());
713 record.spec.parent_run_id = Some(source.clone());
714 assert!(
715 manager
716 .continuation_target_for_caller("workspace", &child, Some(&current), "test")
717 .is_ok()
718 );
719 for forbidden in [&source, &current, &sibling] {
720 assert!(
721 manager
722 .continuation_target_for_caller("workspace", forbidden, Some(&current), "test")
723 .is_err()
724 );
725 }
726
727 // A corrupt source→sibling link must not turn source authority into
728 // permission to cancel the unrelated actual successor.
729 manager
730 .resume_targets
731 .insert(child.clone(), sibling.clone());
732 let manager = Arc::new(RwLock::new(manager));
733 assert!(
734 cancel_agent_from_input(
735 &json!({"agent_id": child}),
736 Arc::clone(&manager),
737 &ToolContext::new(dir.path()),
738 Some(&current),
739 )
740 .await
741 .is_err()
742 );
743 assert_eq!(
744 manager.read().await.get_result(&sibling).unwrap().status,
745 SubAgentStatus::Running
746 );
747 }
748
749 #[tokio::test]
750 async fn lifecycle_detail_budget_keeps_the_transcript_handle_retrievable() {
751 let dir = tempdir().unwrap();
752 let context = ToolContext::new(dir.path());
753 let mut manager = SubAgentManager::new(dir.path().to_path_buf(), 2);
754 let id = manager.insert_test_running_agent("large-checkpoint", dir.path());
755 let messages = (0..20)
756 .map(|_| Message {
757 role: Role::User,
758 content: vec![ContentBlock::Text {
759 text: "🐋".repeat(1024),
760 cache_control: None,
761 }],
762 })
763 .collect::<Vec<_>>();
764 manager.agents.get_mut(&id).unwrap().checkpoint = Some(build_subagent_checkpoint(
765 &id, "retained", &messages, 1, true,
766 ));
767 let projection = subagent_session_projection(
768 &new_shared_subagent_manager(dir.path().to_path_buf(), 1),
769 manager.get_result(&id).unwrap(),
770 false,
771 &context,
772 manager.get_worker_record_for_session("workspace", &id),
773 )
774 .await;
775 let expected = projection.transcript_handle.clone();
776 let detail = lifecycle::bounded_detail(
777 serde_json::to_value(projection).unwrap(),
778 lifecycle::compact_row(&manager, &manager.agents[&id]),
779 0,
780 20,
781 );
782 assert!(serde_json::to_vec(&detail).unwrap().len() <= 32 * 1024);
783 let handle: VarHandle = serde_json::from_value(detail["transcript_handle"].clone()).unwrap();
784 assert_eq!(handle.session_id, expected.session_id);
785 assert_eq!(handle.name, expected.name);
786 assert_eq!(handle.sha256, expected.sha256);
787 assert!(
788 context
789 .runtime
790 .handle_store
791 .lock()
792 .await
793 .get(&handle)
794 .is_some()
795 );
796 }
797
798 #[tokio::test]
799 async fn lifecycle_cleanup_keeps_source_identity_while_descendants_still_need_it() {
800 let dir = tempdir().unwrap();
801 let mut manager = SubAgentManager::new(dir.path().to_path_buf(), 5);
802 let source = manager.insert_test_running_agent("original", dir.path());
803 let current = manager.insert_test_running_agent("continuation", dir.path());
804 let child = manager.insert_test_running_agent("existing-child", dir.path());
805 manager
806 .resume_targets
807 .insert(source.clone(), current.clone());
808 let record = manager.worker_records.get_mut(&child).unwrap();
809 record.parent_run_id = Some(source.clone());
810 record.spec.parent_run_id = Some(source.clone());
811 let old = Instant::now() - Duration::from_secs(120);
812 manager.agents.get_mut(&source).unwrap().status = SubAgentStatus::Completed;
813 manager.agents.get_mut(&source).unwrap().started_at = old;
814 let record = manager.worker_records.get_mut(&source).unwrap();
815 record.status = AgentWorkerStatus::Completed;
816 record.completed_at_ms = Some(epoch_millis_now().saturating_sub(120_000));
817
818 manager.cleanup_for_session("workspace", Duration::from_secs(60));
819 assert_eq!(manager.continuation_target(&source).unwrap(), current);
820 assert!(
821 manager
822 .continuation_target_for_caller("workspace", &child, Some(&current), "test")
823 .is_ok()
824 );
825 manager
826 .cancel_agent_for_session("workspace", &source)
827 .unwrap();
828 assert_eq!(
829 manager.get_result(&child).unwrap().status,
830 SubAgentStatus::Cancelled
831 );
832
833 // Once no live work uses either projection, normal expiry reclaims the
834 // archived workers and their continuation edge together.
835 for agent in manager.agents.values_mut() {
836 agent.started_at = old;
837 }
838 for record in manager.worker_records.values_mut() {
839 record.completed_at_ms = Some(epoch_millis_now().saturating_sub(120_000));
840 }
841 manager.cleanup_for_session("workspace", Duration::from_secs(60));
842 assert!(manager.agents.is_empty());
843 assert!(manager.worker_records.is_empty());
844 assert!(manager.resume_targets.is_empty());
845 }
846
846 lines RUST