1use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, ensure};
6
7use super::{Controller, now};
8use mj_core::config::HarnessKind;
9use mj_core::state::{SessionRecord, SessionState, new_session_id};
10use mj_core::subagent::SubagentRecord;
11
12#[derive(Debug, Clone)]
13pub struct RegisterSubagentRequest {
14 pub parent_session_id: String,
15 pub task_name: String,
16 pub profile_id: String,
17 pub model: Option<String>,
18 pub effort: Option<String>,
19 pub working_directory: PathBuf,
24 pub initial_prompt: String,
25 pub request_key: String,
26 pub report_root: Option<String>,
30}
31
32const PREPARE_REPORT_DIR_SCRIPT: &str = r#"set -eu
41cd
42mkdir -p -- "$1"
43if [ -n "$2" ] && exclude=$(git -C "$2" rev-parse --git-path info/exclude 2>/dev/null); then
44 case "$exclude" in /*) ;; *) exclude="$2/$exclude" ;; esac
45 line="/$3/"
46 if ! grep -qxF -- "$line" "$exclude" 2>/dev/null; then
47 mkdir -p -- "$(dirname -- "$exclude")"
48 if [ -s "$exclude" ] && [ -n "$(tail -c 1 -- "$exclude")" ]; then
49 printf '
50' >> "$exclude"
51 fi
52 printf '%s
53' "$line" >> "$exclude"
54 fi
55fi
56cd -- "$1"
57pwd -P
58"#;
59
60fn prepare_report_dir_argv(directory: &str, exclude_in: Option<&str>) -> Vec<String> {
63 vec![
64 "sh".into(),
65 "-c".into(),
66 PREPARE_REPORT_DIR_SCRIPT.into(),
67 "sh".into(),
68 directory.into(),
69 exclude_in.unwrap_or_default().into(),
70 mj_core::subagent::PROJECT_REPORT_ROOT_DIR.into(),
71 ]
72}
73
74fn prepare_report_dir(
77 executor: &impl mj_core::targets::CommandExecutor,
78 backend: &mj_core::targets::TargetLocator,
79 session_id: &str,
80 directory: &str,
81 exclude_in: Option<&str>,
82) -> Result<String> {
83 let command = mj_core::targets::command_on_locator(
84 backend,
85 session_id,
86 prepare_report_dir_argv(directory, exclude_in),
87 "create the sub-agent report directory",
88 )?;
89 let output = super::execute_checked(executor, command)?;
90 let absolute = String::from_utf8(output.stdout)
91 .context("the sub-agent report directory is not UTF-8")?
92 .trim_end_matches('\n')
93 .to_owned();
94 ensure!(
95 absolute.starts_with('/'),
96 "the target did not report an absolute sub-agent report directory: {absolute:?}"
97 );
98 Ok(absolute)
99}
100
101impl Controller {
102 pub fn prepare_subagent_report_root(
110 &self,
111 parent_session_id: &str,
112 executor: &impl mj_core::targets::CommandExecutor,
113 ) -> Result<String> {
114 let parent = self
115 .state
116 .sessions
117 .get(parent_session_id)
118 .with_context(|| format!("unknown parent session {parent_session_id}"))?;
119 let locator = parent
120 .target
121 .as_ref()
122 .context("parent session has no live target")?;
123 let backend = super::backend::backend_locator(locator, parent, &self.config)?;
124 let checkout = self.state.checkout(parent_session_id)?;
125 let (root, exclude_in) = subagent_report_root(parent, &backend, &checkout);
126 prepare_report_dir(
127 executor,
128 &backend,
129 parent_session_id,
130 &root,
131 exclude_in.as_deref(),
132 )
133 }
134
135 pub(super) fn prepare_subagent_report_dir(
138 &self,
139 session_id: &str,
140 backend: &mj_core::targets::TargetLocator,
141 executor: &impl mj_core::targets::CommandExecutor,
142 ) -> Result<()> {
143 if crate::database::load_subagent(session_id)?.is_none() {
144 return Ok(());
145 }
146 let Some(directory) = crate::database::load_subagent_report(session_id)?.report_dir else {
147 return Ok(());
148 };
149 prepare_report_dir(executor, backend, session_id, &directory, None)?;
150 Ok(())
151 }
152
153 pub fn register_subagent(
155 &mut self,
156 request: RegisterSubagentRequest,
157 ) -> Result<SubagentRecord> {
158 if let Some(existing) = crate::database::lookup_subagent_request(
159 &request.parent_session_id,
160 &request.request_key,
161 )? {
162 return Ok(existing);
163 }
164 ensure!(
165 !request.request_key.trim().is_empty(),
166 "sub-agent request key cannot be empty"
167 );
168 ensure!(
169 !request.task_name.trim().is_empty(),
170 "sub-agent task name cannot be empty"
171 );
172 ensure!(
173 !request.initial_prompt.trim().is_empty(),
174 "sub-agent instructions cannot be empty"
175 );
176 let parent = self
177 .state
178 .sessions
179 .get(&request.parent_session_id)
180 .with_context(|| format!("unknown parent session {}", request.parent_session_id))?
181 .clone();
182 ensure!(
183 matches!(
184 parent.harness_kind,
185 HarnessKind::Claude | HarnessKind::Codex
186 ),
187 "only Claude and Codex sessions can spawn sub-agents"
188 );
189 ensure_parent_may_delegate(&parent)?;
190 ensure!(
191 parent.state != SessionState::StartupCleanup,
192 "parent startup cleanup is pending"
193 );
194 ensure!(parent.state.is_active(), "parent session is not active");
195 ensure!(parent.target.is_some(), "parent session has no live target");
196 ensure!(
197 crate::database::load_subagent(&parent.id)?.is_none(),
198 "sub-agents cannot spawn other sub-agents"
199 );
200 ensure!(
201 self.config
202 .subagents
203 .profile_is_eligible(&parent.last_profile, &request.profile_id),
204 "profile {:?} is not eligible for sub-agent use",
205 request.profile_id
206 );
207 let profile = self
208 .config
209 .enabled_profile(&request.profile_id)
210 .with_context(|| {
211 format!("sub-agent profile {:?} is unavailable", request.profile_id)
212 })?;
213 profile.ensure_ready(&request.profile_id)?;
214 if profile.kind == HarnessKind::Muse {
215 let multiple_roots = !parent.additional_mounts.is_empty()
216 || (self
217 .state
218 .checkout(&parent.id)?
219 .project_directory()
220 .is_none()
221 && parent
222 .project_bundle(&self.config)
223 .is_some_and(|bundle| bundle.repositories.len() > 1));
224 ensure!(
225 !multiple_roots,
226 "{} ACP supports one workspace root; this parent exposes multiple roots",
227 profile.kind.display_name()
228 );
229 }
230 self.ensure_subagent_slot_available(&parent.id, None)?;
231
232 let child_id = new_session_id()?;
233 let target = borrowed_locator(
234 parent.target.as_ref().expect("live target checked above"),
235 &parent.id,
236 &child_id,
237 )?;
238 let created_at = now();
239 let session = SessionRecord {
240 project: parent.project.clone(),
241 target_runtime: Some(parent.target_runtime_settings(&self.config)?.into_owned()),
242 launch_base: None,
243 launch_branch: None,
244 checkout: None,
245 publication: None,
246 build_cache: parent.build_cache.clone(),
249 subagents: Some(mj_core::subagent::SubagentPolicy::None),
252 create_managed_worktree: Some(false),
253 archived: false,
254 container_cpus: None,
255 container_memory: None,
256 container_workspace: parent.container_workspace.clone(),
259 id: child_id.clone(),
260 workspace_id: parent.workspace_id.clone(),
261 title: request.task_name.clone(),
262 harness_kind: profile.kind,
263 last_profile: request.profile_id.clone(),
264 bundle_id: parent.bundle_id.clone(),
265 project_directory: parent.project_directory.clone(),
266 managed_worktree: None,
267 review: None,
268 no_project_memory: parent.no_project_memory,
270 no_mailbox: parent.no_mailbox,
271 target_template_id: parent.target_template_id.clone(),
272 resource_allocation: parent.resource_allocation.clone(),
273 additional_mounts: parent.additional_mounts.clone(),
274 state: SessionState::Provisioning,
275 target: Some(target),
276 native_session_id: None,
277 acp_session_title: None,
278 session_title_override: Some(request.task_name.clone()),
279 created_at: created_at.clone(),
280 updated_at: created_at.clone(),
281 viewed_through_event_ordinal: 0,
282 draft_input: String::new(),
283 last_error: None,
284 last_checkpoint_error: None,
285 checkpoint: None,
286 };
287 let handback_tool = child_gets_handback_tool(profile.kind);
288 let report_dir = request
289 .report_root
290 .as_deref()
291 .map(|root| format!("{}/{child_id}", root.trim_end_matches('/')));
292 let initial_prompt = match (handback_tool, &report_dir) {
294 (true, Some(report_dir)) => format!(
295 "{}\n\n{}",
296 mj_core::subagent::handback_prompt_note(report_dir),
297 request.initial_prompt
298 ),
299 _ => request.initial_prompt,
300 };
301 let relation = SubagentRecord {
302 child_session_id: child_id.clone(),
303 parent_session_id: parent.id,
304 task_name: request.task_name,
305 profile_id: request.profile_id,
306 model: request.model,
307 effort: request.effort,
308 working_directory: request.working_directory,
309 initial_prompt,
310 request_key: request.request_key,
311 created_at,
312 noticed_turn: None,
313 reported_finish: None,
314 handback_tool,
315 };
316 crate::database::save_subagent_session(&session, &relation)?;
317 if let Some(report_dir) = &report_dir {
318 crate::database::record_subagent_report_dir(&child_id, report_dir)?;
319 }
320 self.state.sessions.insert(child_id, session);
321 self.state
322 .subagents
323 .insert(relation.child_session_id.clone(), relation.clone());
324 Ok(relation)
325 }
326
327 pub fn ensure_subagent_slot_available(
337 &self,
338 parent_session_id: &str,
339 starting: Option<&str>,
340 ) -> Result<()> {
341 let live = crate::database::list_subagents(parent_session_id)?
342 .into_iter()
343 .filter(|child| Some(child.child_session_id.as_str()) != starting)
344 .filter_map(|child| {
345 let session = self.state.sessions.get(&child.child_session_id)?;
346 session.state.has_live_worker().then(|| LiveSubagent {
347 child_session_id: child.child_session_id.clone(),
348 title: session.listed_title().to_owned(),
349 state: live_subagent_state(session),
350 })
351 })
352 .collect::<Vec<_>>();
353 let maximum = self.config.subagents.max_concurrent;
354 ensure!(live.len() < maximum, "{}", slot_refusal(&live, maximum));
355 Ok(())
356 }
357}
358
359struct LiveSubagent {
362 child_session_id: String,
363 title: String,
364 state: &'static str,
365}
366
367fn live_subagent_state(session: &SessionRecord) -> &'static str {
371 match session.state {
372 SessionState::Provisioning => "starting",
373 SessionState::Checkpointing => "checkpointing",
374 SessionState::Closing | SessionState::Destroying | SessionState::StartupCleanup => {
375 "stopping"
376 }
377 SessionState::Disconnected => "disconnected",
378 _ => match crate::database::load_materialized_session_summary(&session.id) {
379 Ok(Some(summary))
380 if matches!(
381 summary.execution,
382 mj_core::state::MaterializedExecutionState::Idle
383 ) =>
384 {
385 "idle"
386 }
387 _ => "running",
388 },
389 }
390}
391
392fn slot_refusal(live: &[LiveSubagent], maximum: usize) -> String {
395 let listed = live
396 .iter()
397 .map(|child| {
398 format!(
399 "{} \"{}\" ({})",
400 mj_core::state::short_id(&child.child_session_id),
401 child.title,
402 child.state
403 )
404 })
405 .collect::<Vec<_>>()
406 .join(", ");
407 format!(
408 "this session already has {} live sub-agents and the maximum is {maximum}. \
409 Live sub-agents: {listed}. A sub-agent frees its slot when it hands back its \
410 report (it is then parked and holds no processes until you send it input) or \
411 when you close it.",
412 live.len()
413 )
414}
415
416pub fn subagent_has_handed_back(child_session_id: &str) -> Result<bool> {
421 let Some(relation) = crate::database::load_subagent(child_session_id)? else {
422 return Ok(false);
423 };
424 if crate::database::load_session_state(child_session_id)? == Some(SessionState::Parked) {
427 return Ok(true);
428 }
429 let Some((execution, active_turn, last_turn)) =
430 crate::database::load_materialized_turn_outcome(child_session_id)?
431 else {
432 return Ok(false);
433 };
434 let report = crate::database::load_subagent_report(child_session_id)?;
435 let working = active_turn.is_some()
436 || !matches!(execution, mj_core::state::MaterializedExecutionState::Idle);
437 Ok(mj_core::subagent::has_handed_back(
438 relation.handback_tool,
439 &report,
440 working,
441 last_turn.as_ref(),
442 mj_core::clock::epoch_millis(),
443 ))
444}
445
446pub fn stopped_subagent(
449 state: &mj_core::state::State,
450 child_session_id: &str,
451) -> Result<mj_core::subagent::StoppedSubagent> {
452 let relation = state
453 .subagents
454 .get(child_session_id)
455 .with_context(|| format!("unknown sub-agent session {child_session_id}"))?;
456 let title = state
457 .sessions
458 .get(child_session_id)
459 .map_or(relation.task_name.as_str(), SessionRecord::listed_title)
460 .to_owned();
461 let report_dir = crate::database::load_subagent_report(child_session_id)?.report_dir;
462 Ok(mj_core::subagent::StoppedSubagent {
463 child_session_id: child_session_id.to_owned(),
464 title,
465 task: mj_core::subagent::task_summary(&relation.initial_prompt, report_dir.as_deref()),
466 handed_back: subagent_has_handed_back(child_session_id)?,
467 })
468}
469
470fn sibling_path(path: &Path, parent_id: &str, child_id: &str) -> Result<PathBuf> {
471 ensure!(
472 path.ends_with(parent_id),
473 "parent target path does not end in its session id"
474 );
475 Ok(path
476 .parent()
477 .context("parent target path has no parent")?
478 .join(child_id))
479}
480
481fn borrowed_locator(
482 target: &mj_core::state::TargetLocator,
483 parent_id: &str,
484 child_id: &str,
485) -> Result<mj_core::state::TargetLocator> {
486 use mj_core::state::TargetLocator;
487 Ok(match target {
488 TargetLocator::LocalBare { worker_root } => TargetLocator::LocalBare {
489 worker_root: sibling_path(worker_root, parent_id, child_id)?,
490 },
491 TargetLocator::SshBare {
492 host,
493 workspace,
494 worker_id: _,
495 } => TargetLocator::SshBare {
496 host: host.clone(),
497 workspace: workspace.clone(),
498 worker_id: Some(child_id.to_owned()),
499 },
500 TargetLocator::LocalPodman {
504 container_id,
505 workspace_storage,
506 ..
507 } => TargetLocator::LocalPodman {
508 container_id: container_id.clone(),
509 workspace_storage: workspace_storage.clone(),
510 borrowed_from: Some(parent_id.to_owned()),
511 },
512 TargetLocator::LocalDocker { container_id, .. } => TargetLocator::LocalDocker {
513 container_id: container_id.clone(),
514 borrowed_from: Some(parent_id.to_owned()),
515 },
516 TargetLocator::AppleContainer { container_id, .. } => TargetLocator::AppleContainer {
517 container_id: container_id.clone(),
518 borrowed_from: Some(parent_id.to_owned()),
519 },
520 TargetLocator::SshPodman {
521 host,
522 container_id,
523 workspace_storage,
524 ..
525 } => TargetLocator::SshPodman {
526 host: host.clone(),
527 container_id: container_id.clone(),
528 workspace_storage: workspace_storage.clone(),
529 borrowed_from: Some(parent_id.to_owned()),
530 },
531 TargetLocator::SshDocker {
532 host, container_id, ..
533 } => TargetLocator::SshDocker {
534 host: host.clone(),
535 container_id: container_id.clone(),
536 borrowed_from: Some(parent_id.to_owned()),
537 },
538 other @ TargetLocator::AwsEc2 { .. } => other.clone(),
541 })
542}
543
544fn child_gets_handback_tool(harness: HarnessKind) -> bool {
548 matches!(harness, HarnessKind::Codex | HarnessKind::Claude)
549}
550
551fn ensure_parent_may_delegate(parent: &SessionRecord) -> Result<()> {
556 ensure!(
557 parent
558 .subagents
559 .as_ref()
560 .is_some_and(mj_core::subagent::SubagentPolicy::uses_mjolnir),
561 "this session does not allow Mjolnir sub-agents"
562 );
563 Ok(())
564}
565
566fn subagent_report_root(
569 parent: &SessionRecord,
570 backend: &mj_core::targets::TargetLocator,
571 checkout: &mj_core::state::Checkout<'_>,
572) -> (String, Option<String>) {
573 match checkout.project_directory() {
574 Some(project) => {
575 let project = project.to_string_lossy().trim_end_matches('/').to_owned();
576 (
577 format!("{project}/{}", mj_core::subagent::PROJECT_REPORT_ROOT_DIR),
578 Some(project),
579 )
580 }
581 None => {
582 let workspace =
583 super::network_git::workspace_root(backend, parent.container_workspace.as_deref());
584 (
585 format!(
586 "{}/{}",
587 workspace.trim_end_matches('/'),
588 mj_core::subagent::REPORT_ROOT_DIR
589 ),
590 None,
591 )
592 }
593 }
594}
595
596#[cfg(test)]
597mod tests {
598 use super::*;
599
600 fn run_prepare_script(directory: &Path, exclude_in: Option<&Path>) -> String {
601 let argv = prepare_report_dir_argv(
602 &directory.to_string_lossy(),
603 exclude_in
604 .map(|path| path.to_string_lossy().into_owned())
605 .as_deref(),
606 );
607 let output = std::process::Command::new(&argv[0])
608 .args(&argv[1..])
609 .output()
610 .expect("run the report directory script");
611 assert!(
612 output.status.success(),
613 "{}",
614 String::from_utf8_lossy(&output.stderr)
615 );
616 String::from_utf8(output.stdout)
617 .unwrap()
618 .trim_end()
619 .to_owned()
620 }
621
622 #[test]
626 fn the_report_directory_script_creates_the_directory_and_keeps_git_status_clean() {
627 let temp = tempfile::tempdir().unwrap();
628 let project = temp.path().join("project");
629 std::fs::create_dir_all(&project).unwrap();
630 let git = |args: &[&str]| {
631 let output = std::process::Command::new("git")
632 .arg("-C")
633 .arg(&project)
634 .args(args)
635 .output()
636 .unwrap();
637 assert!(output.status.success(), "{output:?}");
638 String::from_utf8(output.stdout).unwrap()
639 };
640 git(&["init", "-q"]);
641 std::fs::write(project.join(".git/info/exclude"), "*.tmp").unwrap();
644 let root = project.join(mj_core::subagent::PROJECT_REPORT_ROOT_DIR);
645 let printed = run_prepare_script(&root, Some(&project));
646 assert_eq!(
647 Path::new(&printed),
648 root.canonicalize().unwrap(),
649 "the script prints the absolute directory"
650 );
651 run_prepare_script(&root, Some(&project));
652 std::fs::write(root.join("report.md"), "details").unwrap();
653 let exclude = std::fs::read_to_string(project.join(".git/info/exclude")).unwrap();
654 assert_eq!(exclude, "*.tmp\n/.mj/agents/\n");
655 assert_eq!(git(&["status", "--porcelain", "--ignored=no"]), "");
656
657 let plain = temp.path().join("plain");
659 std::fs::create_dir_all(&plain).unwrap();
660 let reports = plain.join(".mj/agents/child");
661 run_prepare_script(&reports, Some(&plain));
662 assert!(reports.is_dir());
663 }
664
665 #[test]
666 fn a_report_root_is_under_the_workspace_or_inside_a_bare_project() {
667 let mut parent = super::super::test_support::checkpoint_test_session("parent-1");
668 let backend = mj_core::targets::TargetLocator::LocalBare {
669 worker_root: "/var/lib/hel/workers/parent-1".into(),
670 };
671 parent.project_directory = None;
672 let checkout = parent.checkout();
673 assert_eq!(
674 subagent_report_root(&parent, &backend, &checkout),
675 ("/var/lib/hel/workers/parent-1/.mj-agents".to_owned(), None)
676 );
677 parent.project_directory = Some("/home/dev/project/".into());
678 let checkout = parent.checkout();
679 assert_eq!(
680 subagent_report_root(&parent, &backend, &checkout),
681 (
682 "/home/dev/project/.mj/agents".to_owned(),
683 Some("/home/dev/project".to_owned())
684 )
685 );
686 }
687
688 #[test]
690 fn a_container_child_borrows_its_parents_container() {
691 use mj_core::state::{PodmanWorkspaceLocator, TargetLocator};
692
693 let parent_id = "0123456789abcdef0123456789abcdef";
694 let child_id = "fedcba9876543210fedcba9876543210";
695 let container = mj_core::targets::resource_name(parent_id).unwrap();
696
697 let local = borrowed_locator(
698 &TargetLocator::LocalPodman {
699 container_id: container.clone(),
700 workspace_storage: PodmanWorkspaceLocator::Volume {
701 name: "parent-volume".to_owned(),
702 },
703 borrowed_from: None,
704 },
705 parent_id,
706 child_id,
707 )
708 .unwrap();
709 assert_eq!(
710 local,
711 TargetLocator::LocalPodman {
712 container_id: container.clone(),
713 workspace_storage: PodmanWorkspaceLocator::Volume {
714 name: "parent-volume".to_owned(),
715 },
716 borrowed_from: Some(parent_id.to_owned()),
717 }
718 );
719
720 let remote = borrowed_locator(
721 &TargetLocator::SshPodman {
722 host: "builder".to_owned(),
723 container_id: container.clone(),
724 workspace_storage: PodmanWorkspaceLocator::ContainerLayer,
725 borrowed_from: None,
726 },
727 parent_id,
728 child_id,
729 )
730 .unwrap();
731 assert_eq!(
732 remote,
733 TargetLocator::SshPodman {
734 host: "builder".to_owned(),
735 container_id: container,
736 workspace_storage: PodmanWorkspaceLocator::ContainerLayer,
737 borrowed_from: Some(parent_id.to_owned()),
738 }
739 );
740 }
741
742 #[test]
743 fn a_parent_using_native_delegation_cannot_spawn_mjolnir_children() {
744 let parent = |choice: Option<bool>| {
745 let mut session = crate::controller::test_support::checkpoint_test_session("parent");
746 session.subagents = choice.map(|enabled| {
747 if enabled {
748 mj_core::subagent::SubagentPolicy::Mjolnir {
749 straightforward: None,
750 subtle: None,
751 }
752 } else {
753 mj_core::subagent::SubagentPolicy::Native
754 }
755 });
756 session
757 };
758
759 assert_eq!(
760 ensure_parent_may_delegate(&parent(Some(false)))
761 .unwrap_err()
762 .to_string(),
763 "this session does not allow Mjolnir sub-agents"
764 );
765 assert_eq!(
766 ensure_parent_may_delegate(&parent(None))
767 .unwrap_err()
768 .to_string(),
769 "this session does not allow Mjolnir sub-agents"
770 );
771 assert!(ensure_parent_may_delegate(&parent(Some(true))).is_ok());
772 }
773
774 #[test]
781 fn the_cap_counts_every_child_holding_processes_and_names_them() {
782 const MARKER: &str = "MJ_TEST_SUBAGENT_CAP_CHILD";
783 if std::env::var_os(MARKER).is_none() {
784 let directory = tempfile::tempdir().unwrap();
785 super::super::test_support::IsolatedTest::new(super::super::test_support::test_name(
786 module_path!(),
787 "the_cap_counts_every_child_holding_processes_and_names_them",
788 ))
789 .env(MARKER, "1")
790 .isolated_store(directory.path())
791 .run();
792 return;
793 }
794 let _writer = crate::database::install_isolated_test_writer();
795 let parent = super::super::test_support::checkpoint_test_session("parent-1");
796 crate::database::save_session(&parent).unwrap();
797 let children = [
798 ("aaaaaaaa-idle", "Audit the lockfile", SessionState::Running),
799 ("bbbbbbbb-busy", "Run the suite", SessionState::Running),
800 ("cccccccc-park", "Map the parser", SessionState::Parked),
801 ("dddddddd-done", "Old task", SessionState::Stopped),
802 ];
803 for (id, title, state) in children {
804 let mut child = super::super::test_support::checkpoint_test_session(id);
805 child.state = state;
806 child.session_title_override = Some(title.into());
807 crate::database::save_subagent_session(
808 &child,
809 &SubagentRecord {
810 child_session_id: id.into(),
811 parent_session_id: "parent-1".into(),
812 task_name: title.into(),
813 profile_id: "codex".into(),
814 model: None,
815 effort: None,
816 working_directory: PathBuf::new(),
817 initial_prompt: "do it".into(),
818 request_key: format!("request-{id}"),
819 created_at: "2026-09-25T00:00:00Z".into(),
820 noticed_turn: None,
821 reported_finish: None,
822 handback_tool: true,
823 },
824 )
825 .unwrap();
826 }
827 let mut controller = Controller {
828 config: mj_core::config::Config::default(),
829 state: crate::database::load_state().unwrap(),
830 };
831 controller.config.subagents.max_concurrent = 2;
832
833 let refusal = controller
835 .ensure_subagent_slot_available("parent-1", None)
836 .unwrap_err()
837 .to_string();
838 assert!(
839 refusal.contains("already has 2 live sub-agents and the maximum is 2"),
840 "{refusal}"
841 );
842 assert!(
843 refusal.contains("aaaaaaaa \"Audit the lockfile\"")
844 && refusal.contains("bbbbbbbb \"Run the suite\""),
845 "the refusal names every live child: {refusal}"
846 );
847 assert!(
848 !refusal.contains("Map the parser") && !refusal.contains("Old task"),
849 "parked and stopped children hold no processes: {refusal}"
850 );
851 assert!(
852 refusal.contains("hands back")
853 && refusal.contains("parked")
854 && refusal.contains("close it"),
855 "the refusal says how a slot frees up: {refusal}"
856 );
857 assert!(
859 controller
860 .ensure_subagent_slot_available("parent-1", Some("cccccccc-park"))
861 .is_err()
862 );
863 controller
865 .ensure_subagent_slot_available("parent-1", Some("aaaaaaaa-idle"))
866 .unwrap();
867
868 controller
870 .state
871 .sessions
872 .get_mut("aaaaaaaa-idle")
873 .unwrap()
874 .state = SessionState::Parked;
875 controller
876 .ensure_subagent_slot_available("parent-1", None)
877 .unwrap();
878 controller
879 .ensure_subagent_slot_available("parent-1", Some("cccccccc-park"))
880 .unwrap();
881 }
882}