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 (root, exclude_in) = subagent_report_root(parent, &backend);
125 prepare_report_dir(
126 executor,
127 &backend,
128 parent_session_id,
129 &root,
130 exclude_in.as_deref(),
131 )
132 }
133
134 pub(super) fn prepare_subagent_report_dir(
137 &self,
138 session_id: &str,
139 backend: &mj_core::targets::TargetLocator,
140 executor: &impl mj_core::targets::CommandExecutor,
141 ) -> Result<()> {
142 if crate::database::load_subagent(session_id)?.is_none() {
143 return Ok(());
144 }
145 let Some(directory) = crate::database::load_subagent_report(session_id)?.report_dir else {
146 return Ok(());
147 };
148 prepare_report_dir(executor, backend, session_id, &directory, None)?;
149 Ok(())
150 }
151
152 pub fn register_subagent(
154 &mut self,
155 request: RegisterSubagentRequest,
156 ) -> Result<SubagentRecord> {
157 if let Some(existing) = crate::database::lookup_subagent_request(
158 &request.parent_session_id,
159 &request.request_key,
160 )? {
161 return Ok(existing);
162 }
163 ensure!(
164 !request.request_key.trim().is_empty(),
165 "sub-agent request key cannot be empty"
166 );
167 ensure!(
168 !request.task_name.trim().is_empty(),
169 "sub-agent task name cannot be empty"
170 );
171 ensure!(
172 !request.initial_prompt.trim().is_empty(),
173 "sub-agent instructions cannot be empty"
174 );
175 let parent = self
176 .state
177 .sessions
178 .get(&request.parent_session_id)
179 .with_context(|| format!("unknown parent session {}", request.parent_session_id))?
180 .clone();
181 ensure!(
182 matches!(
183 parent.harness_kind,
184 HarnessKind::Claude | HarnessKind::Codex
185 ),
186 "only Claude and Codex sessions can spawn sub-agents"
187 );
188 ensure_parent_may_delegate(&parent)?;
189 ensure!(
190 parent.state != SessionState::StartupCleanup,
191 "parent startup cleanup is pending"
192 );
193 if let Some(mj_core::subagent::SubagentPolicy::SingleModel { model, effort }) =
194 &parent.subagents
195 {
196 ensure!(
197 request.model.as_ref() == Some(model) && &request.effort == effort,
198 "child selectors must match the parent's fixed model and effort"
199 );
200 }
201 ensure!(parent.state.is_active(), "parent session is not active");
202 ensure!(parent.target.is_some(), "parent session has no live target");
203 ensure!(
204 crate::database::load_subagent(&parent.id)?.is_none(),
205 "sub-agents cannot spawn other sub-agents"
206 );
207 ensure!(
208 self.config
209 .subagents
210 .profile_is_eligible(&parent.last_profile, &request.profile_id),
211 "profile {:?} is not eligible for sub-agent use",
212 request.profile_id
213 );
214 let profile = self
215 .config
216 .enabled_profile(&request.profile_id)
217 .with_context(|| {
218 format!("sub-agent profile {:?} is unavailable", request.profile_id)
219 })?;
220 profile.ensure_ready(&request.profile_id)?;
221 if profile.kind == HarnessKind::Muse {
222 let multiple_roots = !parent.additional_mounts.is_empty()
223 || (parent.project_directory.is_none()
224 && parent
225 .project_bundle(&self.config)
226 .is_some_and(|bundle| bundle.repositories.len() > 1));
227 ensure!(
228 !multiple_roots,
229 "{} ACP supports one workspace root; this parent exposes multiple roots",
230 profile.kind.display_name()
231 );
232 }
233 self.ensure_subagent_slot_available(&parent.id, None)?;
234
235 let child_id = new_session_id()?;
236 let target = borrowed_locator(
237 parent.target.as_ref().expect("live target checked above"),
238 &parent.id,
239 &child_id,
240 )?;
241 let created_at = now();
242 let session = SessionRecord {
243 project: parent.project.clone(),
244 target_runtime: Some(parent.target_runtime_settings(&self.config)?.into_owned()),
245 launch_base: None,
246 launch_branch: None,
247 checkout: None,
248 publication: None,
249 build_cache: parent.build_cache.clone(),
252 subagents: Some(mj_core::subagent::SubagentPolicy::None),
255 create_managed_worktree: Some(false),
256 archived: false,
257 container_cpus: None,
258 container_memory: None,
259 container_workspace: parent.container_workspace.clone(),
262 id: child_id.clone(),
263 workspace_id: parent.workspace_id.clone(),
264 title: request.task_name.clone(),
265 harness_kind: profile.kind,
266 last_profile: request.profile_id.clone(),
267 bundle_id: parent.bundle_id.clone(),
268 project_directory: parent.project_directory.clone(),
269 managed_worktree: None,
270 review: None,
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 handback_tool,
314 };
315 crate::database::save_subagent_session(&session, &relation)?;
316 if let Some(report_dir) = &report_dir {
317 crate::database::record_subagent_report_dir(&child_id, report_dir)?;
318 }
319 self.state.sessions.insert(child_id, session);
320 self.state
321 .subagents
322 .insert(relation.child_session_id.clone(), relation.clone());
323 Ok(relation)
324 }
325
326 pub fn ensure_subagent_slot_available(
336 &self,
337 parent_session_id: &str,
338 starting: Option<&str>,
339 ) -> Result<()> {
340 let live = crate::database::list_subagents(parent_session_id)?
341 .into_iter()
342 .filter(|child| Some(child.child_session_id.as_str()) != starting)
343 .filter_map(|child| {
344 let session = self.state.sessions.get(&child.child_session_id)?;
345 session.state.has_live_worker().then(|| LiveSubagent {
346 child_session_id: child.child_session_id.clone(),
347 title: session.listed_title().to_owned(),
348 state: live_subagent_state(session),
349 })
350 })
351 .collect::<Vec<_>>();
352 let maximum = self.config.subagents.max_concurrent;
353 ensure!(live.len() < maximum, "{}", slot_refusal(&live, maximum));
354 Ok(())
355 }
356}
357
358struct LiveSubagent {
361 child_session_id: String,
362 title: String,
363 state: &'static str,
364}
365
366fn live_subagent_state(session: &SessionRecord) -> &'static str {
370 match session.state {
371 SessionState::Provisioning => "starting",
372 SessionState::Checkpointing => "checkpointing",
373 SessionState::Closing | SessionState::Destroying | SessionState::StartupCleanup => {
374 "stopping"
375 }
376 SessionState::Disconnected => "disconnected",
377 _ => match crate::database::load_materialized_session_summary(&session.id) {
378 Ok(Some(summary))
379 if matches!(
380 summary.execution,
381 mj_core::state::MaterializedExecutionState::Idle
382 ) =>
383 {
384 "idle"
385 }
386 _ => "running",
387 },
388 }
389}
390
391fn slot_refusal(live: &[LiveSubagent], maximum: usize) -> String {
394 let listed = live
395 .iter()
396 .map(|child| {
397 format!(
398 "{} \"{}\" ({})",
399 mj_core::state::short_id(&child.child_session_id),
400 child.title,
401 child.state
402 )
403 })
404 .collect::<Vec<_>>()
405 .join(", ");
406 format!(
407 "this session already has {} live sub-agents and the maximum is {maximum}. \
408 Live sub-agents: {listed}. A sub-agent frees its slot when it hands back its \
409 report (it is then parked and holds no processes until you send it input) or \
410 when you close it.",
411 live.len()
412 )
413}
414
415pub fn subagent_has_handed_back(child_session_id: &str) -> Result<bool> {
420 let Some(relation) = crate::database::load_subagent(child_session_id)? else {
421 return Ok(false);
422 };
423 if crate::database::load_session_state(child_session_id)? == Some(SessionState::Parked) {
426 return Ok(true);
427 }
428 let Some((execution, active_turn, last_turn)) =
429 crate::database::load_materialized_turn_outcome(child_session_id)?
430 else {
431 return Ok(false);
432 };
433 let report = crate::database::load_subagent_report(child_session_id)?;
434 let working = active_turn.is_some()
435 || !matches!(execution, mj_core::state::MaterializedExecutionState::Idle);
436 Ok(mj_core::subagent::has_handed_back(
437 relation.handback_tool,
438 &report,
439 working,
440 last_turn.as_ref(),
441 mj_core::clock::epoch_millis(),
442 ))
443}
444
445pub fn stopped_subagent(
448 state: &mj_core::state::State,
449 child_session_id: &str,
450) -> Result<mj_core::subagent::StoppedSubagent> {
451 let relation = state
452 .subagents
453 .get(child_session_id)
454 .with_context(|| format!("unknown sub-agent session {child_session_id}"))?;
455 let title = state
456 .sessions
457 .get(child_session_id)
458 .map_or(relation.task_name.as_str(), SessionRecord::listed_title)
459 .to_owned();
460 let report_dir = crate::database::load_subagent_report(child_session_id)?.report_dir;
461 Ok(mj_core::subagent::StoppedSubagent {
462 child_session_id: child_session_id.to_owned(),
463 title,
464 task: mj_core::subagent::task_summary(&relation.initial_prompt, report_dir.as_deref()),
465 handed_back: subagent_has_handed_back(child_session_id)?,
466 })
467}
468
469fn sibling_path(path: &Path, parent_id: &str, child_id: &str) -> Result<PathBuf> {
470 ensure!(
471 path.ends_with(parent_id),
472 "parent target path does not end in its session id"
473 );
474 Ok(path
475 .parent()
476 .context("parent target path has no parent")?
477 .join(child_id))
478}
479
480fn borrowed_locator(
481 target: &mj_core::state::TargetLocator,
482 parent_id: &str,
483 child_id: &str,
484) -> Result<mj_core::state::TargetLocator> {
485 use mj_core::state::TargetLocator;
486 Ok(match target {
487 TargetLocator::LocalBare { worker_root } => TargetLocator::LocalBare {
488 worker_root: sibling_path(worker_root, parent_id, child_id)?,
489 },
490 TargetLocator::SshBare {
491 host,
492 workspace,
493 worker_id: _,
494 } => TargetLocator::SshBare {
495 host: host.clone(),
496 workspace: workspace.clone(),
497 worker_id: Some(child_id.to_owned()),
498 },
499 TargetLocator::LocalPodman {
503 container_id,
504 workspace_storage,
505 ..
506 } => TargetLocator::LocalPodman {
507 container_id: container_id.clone(),
508 workspace_storage: workspace_storage.clone(),
509 borrowed_from: Some(parent_id.to_owned()),
510 },
511 TargetLocator::LocalDocker { container_id, .. } => TargetLocator::LocalDocker {
512 container_id: container_id.clone(),
513 borrowed_from: Some(parent_id.to_owned()),
514 },
515 TargetLocator::AppleContainer { container_id, .. } => TargetLocator::AppleContainer {
516 container_id: container_id.clone(),
517 borrowed_from: Some(parent_id.to_owned()),
518 },
519 TargetLocator::SshPodman {
520 host,
521 container_id,
522 workspace_storage,
523 ..
524 } => TargetLocator::SshPodman {
525 host: host.clone(),
526 container_id: container_id.clone(),
527 workspace_storage: workspace_storage.clone(),
528 borrowed_from: Some(parent_id.to_owned()),
529 },
530 TargetLocator::SshDocker {
531 host, container_id, ..
532 } => TargetLocator::SshDocker {
533 host: host.clone(),
534 container_id: container_id.clone(),
535 borrowed_from: Some(parent_id.to_owned()),
536 },
537 other @ TargetLocator::AwsEc2 { .. } => other.clone(),
540 })
541}
542
543fn child_gets_handback_tool(harness: HarnessKind) -> bool {
547 matches!(harness, HarnessKind::Codex | HarnessKind::Claude)
548}
549
550fn ensure_parent_may_delegate(parent: &SessionRecord) -> Result<()> {
555 ensure!(
556 parent
557 .subagents
558 .as_ref()
559 .is_some_and(mj_core::subagent::SubagentPolicy::uses_mjolnir),
560 "this session does not allow Mjolnir sub-agents"
561 );
562 Ok(())
563}
564
565fn subagent_report_root(
568 parent: &SessionRecord,
569 backend: &mj_core::targets::TargetLocator,
570) -> (String, Option<String>) {
571 match &parent.project_directory {
572 Some(project) => {
573 let project = project.to_string_lossy().trim_end_matches('/').to_owned();
574 (
575 format!("{project}/{}", mj_core::subagent::PROJECT_REPORT_ROOT_DIR),
576 Some(project),
577 )
578 }
579 None => {
580 let workspace =
581 super::network_git::workspace_root(backend, parent.container_workspace.as_deref());
582 (
583 format!(
584 "{}/{}",
585 workspace.trim_end_matches('/'),
586 mj_core::subagent::REPORT_ROOT_DIR
587 ),
588 None,
589 )
590 }
591 }
592}
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597
598 fn run_prepare_script(directory: &Path, exclude_in: Option<&Path>) -> String {
599 let argv = prepare_report_dir_argv(
600 &directory.to_string_lossy(),
601 exclude_in
602 .map(|path| path.to_string_lossy().into_owned())
603 .as_deref(),
604 );
605 let output = std::process::Command::new(&argv[0])
606 .args(&argv[1..])
607 .output()
608 .expect("run the report directory script");
609 assert!(
610 output.status.success(),
611 "{}",
612 String::from_utf8_lossy(&output.stderr)
613 );
614 String::from_utf8(output.stdout)
615 .unwrap()
616 .trim_end()
617 .to_owned()
618 }
619
620 #[test]
624 fn the_report_directory_script_creates_the_directory_and_keeps_git_status_clean() {
625 let temp = tempfile::tempdir().unwrap();
626 let project = temp.path().join("project");
627 std::fs::create_dir_all(&project).unwrap();
628 let git = |args: &[&str]| {
629 let output = std::process::Command::new("git")
630 .arg("-C")
631 .arg(&project)
632 .args(args)
633 .output()
634 .unwrap();
635 assert!(output.status.success(), "{output:?}");
636 String::from_utf8(output.stdout).unwrap()
637 };
638 git(&["init", "-q"]);
639 std::fs::write(project.join(".git/info/exclude"), "*.tmp").unwrap();
642 let root = project.join(mj_core::subagent::PROJECT_REPORT_ROOT_DIR);
643 let printed = run_prepare_script(&root, Some(&project));
644 assert_eq!(
645 Path::new(&printed),
646 root.canonicalize().unwrap(),
647 "the script prints the absolute directory"
648 );
649 run_prepare_script(&root, Some(&project));
650 std::fs::write(root.join("report.md"), "details").unwrap();
651 let exclude = std::fs::read_to_string(project.join(".git/info/exclude")).unwrap();
652 assert_eq!(exclude, "*.tmp\n/.mj/agents/\n");
653 assert_eq!(git(&["status", "--porcelain", "--ignored=no"]), "");
654
655 let plain = temp.path().join("plain");
657 std::fs::create_dir_all(&plain).unwrap();
658 let reports = plain.join(".mj/agents/child");
659 run_prepare_script(&reports, Some(&plain));
660 assert!(reports.is_dir());
661 }
662
663 #[test]
664 fn a_report_root_is_under_the_workspace_or_inside_a_bare_project() {
665 let mut parent = super::super::test_support::checkpoint_test_session("parent-1");
666 let backend = mj_core::targets::TargetLocator::LocalBare {
667 worker_root: "/var/lib/hel/workers/parent-1".into(),
668 };
669 parent.project_directory = None;
670 assert_eq!(
671 subagent_report_root(&parent, &backend),
672 ("/var/lib/hel/workers/parent-1/.mj-agents".to_owned(), None)
673 );
674 parent.project_directory = Some("/home/dev/project/".into());
675 assert_eq!(
676 subagent_report_root(&parent, &backend),
677 (
678 "/home/dev/project/.mj/agents".to_owned(),
679 Some("/home/dev/project".to_owned())
680 )
681 );
682 }
683
684 #[test]
686 fn a_container_child_borrows_its_parents_container() {
687 use mj_core::state::{PodmanWorkspaceLocator, TargetLocator};
688
689 let parent_id = "0123456789abcdef0123456789abcdef";
690 let child_id = "fedcba9876543210fedcba9876543210";
691 let container = mj_core::targets::resource_name(parent_id).unwrap();
692
693 let local = borrowed_locator(
694 &TargetLocator::LocalPodman {
695 container_id: container.clone(),
696 workspace_storage: PodmanWorkspaceLocator::Volume {
697 name: "parent-volume".to_owned(),
698 },
699 borrowed_from: None,
700 },
701 parent_id,
702 child_id,
703 )
704 .unwrap();
705 assert_eq!(
706 local,
707 TargetLocator::LocalPodman {
708 container_id: container.clone(),
709 workspace_storage: PodmanWorkspaceLocator::Volume {
710 name: "parent-volume".to_owned(),
711 },
712 borrowed_from: Some(parent_id.to_owned()),
713 }
714 );
715
716 let remote = borrowed_locator(
717 &TargetLocator::SshPodman {
718 host: "builder".to_owned(),
719 container_id: container.clone(),
720 workspace_storage: PodmanWorkspaceLocator::ContainerLayer,
721 borrowed_from: None,
722 },
723 parent_id,
724 child_id,
725 )
726 .unwrap();
727 assert_eq!(
728 remote,
729 TargetLocator::SshPodman {
730 host: "builder".to_owned(),
731 container_id: container,
732 workspace_storage: PodmanWorkspaceLocator::ContainerLayer,
733 borrowed_from: Some(parent_id.to_owned()),
734 }
735 );
736 }
737
738 #[test]
739 fn a_parent_using_native_delegation_cannot_spawn_mjolnir_children() {
740 let parent = |choice: Option<bool>| {
741 let mut session = crate::controller::test_support::checkpoint_test_session("parent");
742 session.subagents = choice.map(|enabled| {
743 if enabled {
744 mj_core::subagent::SubagentPolicy::AllModels
745 } else {
746 mj_core::subagent::SubagentPolicy::Native
747 }
748 });
749 session
750 };
751
752 assert_eq!(
753 ensure_parent_may_delegate(&parent(Some(false)))
754 .unwrap_err()
755 .to_string(),
756 "this session does not allow Mjolnir sub-agents"
757 );
758 assert_eq!(
759 ensure_parent_may_delegate(&parent(None))
760 .unwrap_err()
761 .to_string(),
762 "this session does not allow Mjolnir sub-agents"
763 );
764 assert!(ensure_parent_may_delegate(&parent(Some(true))).is_ok());
765 }
766
767 #[test]
774 fn the_cap_counts_every_child_holding_processes_and_names_them() {
775 const MARKER: &str = "MJ_TEST_SUBAGENT_CAP_CHILD";
776 if std::env::var_os(MARKER).is_none() {
777 let directory = tempfile::tempdir().unwrap();
778 super::super::test_support::IsolatedTest::new(super::super::test_support::test_name(
779 module_path!(),
780 "the_cap_counts_every_child_holding_processes_and_names_them",
781 ))
782 .env(MARKER, "1")
783 .isolated_store(directory.path())
784 .run();
785 return;
786 }
787 let _writer = crate::database::install_isolated_test_writer();
788 let parent = super::super::test_support::checkpoint_test_session("parent-1");
789 crate::database::save_session(&parent).unwrap();
790 let children = [
791 ("aaaaaaaa-idle", "Audit the lockfile", SessionState::Running),
792 ("bbbbbbbb-busy", "Run the suite", SessionState::Running),
793 ("cccccccc-park", "Map the parser", SessionState::Parked),
794 ("dddddddd-done", "Old task", SessionState::Stopped),
795 ];
796 for (id, title, state) in children {
797 let mut child = super::super::test_support::checkpoint_test_session(id);
798 child.state = state;
799 child.session_title_override = Some(title.into());
800 crate::database::save_subagent_session(
801 &child,
802 &SubagentRecord {
803 child_session_id: id.into(),
804 parent_session_id: "parent-1".into(),
805 task_name: title.into(),
806 profile_id: "codex".into(),
807 model: None,
808 effort: None,
809 working_directory: PathBuf::new(),
810 initial_prompt: "do it".into(),
811 request_key: format!("request-{id}"),
812 created_at: "2026-09-25T00:00:00Z".into(),
813 noticed_turn: None,
814 handback_tool: true,
815 },
816 )
817 .unwrap();
818 }
819 let mut controller = Controller {
820 config: mj_core::config::Config::default(),
821 state: crate::database::load_state().unwrap(),
822 };
823 controller.config.subagents.max_concurrent = 2;
824
825 let refusal = controller
827 .ensure_subagent_slot_available("parent-1", None)
828 .unwrap_err()
829 .to_string();
830 assert!(
831 refusal.contains("already has 2 live sub-agents and the maximum is 2"),
832 "{refusal}"
833 );
834 assert!(
835 refusal.contains("aaaaaaaa \"Audit the lockfile\"")
836 && refusal.contains("bbbbbbbb \"Run the suite\""),
837 "the refusal names every live child: {refusal}"
838 );
839 assert!(
840 !refusal.contains("Map the parser") && !refusal.contains("Old task"),
841 "parked and stopped children hold no processes: {refusal}"
842 );
843 assert!(
844 refusal.contains("hands back")
845 && refusal.contains("parked")
846 && refusal.contains("close it"),
847 "the refusal says how a slot frees up: {refusal}"
848 );
849 assert!(
851 controller
852 .ensure_subagent_slot_available("parent-1", Some("cccccccc-park"))
853 .is_err()
854 );
855 controller
857 .ensure_subagent_slot_available("parent-1", Some("aaaaaaaa-idle"))
858 .unwrap();
859
860 controller
862 .state
863 .sessions
864 .get_mut("aaaaaaaa-idle")
865 .unwrap()
866 .state = SessionState::Parked;
867 controller
868 .ensure_subagent_slot_available("parent-1", None)
869 .unwrap();
870 controller
871 .ensure_subagent_slot_available("parent-1", Some("cccccccc-park"))
872 .unwrap();
873 }
874}