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 if let Some(mj_core::subagent::SubagentPolicy::SingleModel { model, effort }) =
195 &parent.subagents
196 {
197 ensure!(
198 fixed_selectors_match(model, effort.as_deref(), &request),
199 "child selectors must match the parent's fixed model and effort"
200 );
201 }
202 ensure!(parent.state.is_active(), "parent session is not active");
203 ensure!(parent.target.is_some(), "parent session has no live target");
204 ensure!(
205 crate::database::load_subagent(&parent.id)?.is_none(),
206 "sub-agents cannot spawn other sub-agents"
207 );
208 ensure!(
209 self.config
210 .subagents
211 .profile_is_eligible(&parent.last_profile, &request.profile_id),
212 "profile {:?} is not eligible for sub-agent use",
213 request.profile_id
214 );
215 let profile = self
216 .config
217 .enabled_profile(&request.profile_id)
218 .with_context(|| {
219 format!("sub-agent profile {:?} is unavailable", request.profile_id)
220 })?;
221 profile.ensure_ready(&request.profile_id)?;
222 if profile.kind == HarnessKind::Muse {
223 let multiple_roots = !parent.additional_mounts.is_empty()
224 || (self
225 .state
226 .checkout(&parent.id)?
227 .project_directory()
228 .is_none()
229 && parent
230 .project_bundle(&self.config)
231 .is_some_and(|bundle| bundle.repositories.len() > 1));
232 ensure!(
233 !multiple_roots,
234 "{} ACP supports one workspace root; this parent exposes multiple roots",
235 profile.kind.display_name()
236 );
237 }
238 self.ensure_subagent_slot_available(&parent.id, None)?;
239
240 let child_id = new_session_id()?;
241 let target = borrowed_locator(
242 parent.target.as_ref().expect("live target checked above"),
243 &parent.id,
244 &child_id,
245 )?;
246 let created_at = now();
247 let session = SessionRecord {
248 project: parent.project.clone(),
249 target_runtime: Some(parent.target_runtime_settings(&self.config)?.into_owned()),
250 launch_base: None,
251 launch_branch: None,
252 checkout: None,
253 publication: None,
254 build_cache: parent.build_cache.clone(),
257 subagents: Some(mj_core::subagent::SubagentPolicy::None),
260 create_managed_worktree: Some(false),
261 archived: false,
262 container_cpus: None,
263 container_memory: None,
264 container_workspace: parent.container_workspace.clone(),
267 id: child_id.clone(),
268 workspace_id: parent.workspace_id.clone(),
269 title: request.task_name.clone(),
270 harness_kind: profile.kind,
271 last_profile: request.profile_id.clone(),
272 bundle_id: parent.bundle_id.clone(),
273 project_directory: parent.project_directory.clone(),
274 managed_worktree: None,
275 review: None,
276 target_template_id: parent.target_template_id.clone(),
277 resource_allocation: parent.resource_allocation.clone(),
278 additional_mounts: parent.additional_mounts.clone(),
279 state: SessionState::Provisioning,
280 target: Some(target),
281 native_session_id: None,
282 acp_session_title: None,
283 session_title_override: Some(request.task_name.clone()),
284 created_at: created_at.clone(),
285 updated_at: created_at.clone(),
286 viewed_through_event_ordinal: 0,
287 draft_input: String::new(),
288 last_error: None,
289 last_checkpoint_error: None,
290 checkpoint: None,
291 };
292 let handback_tool = child_gets_handback_tool(profile.kind);
293 let report_dir = request
294 .report_root
295 .as_deref()
296 .map(|root| format!("{}/{child_id}", root.trim_end_matches('/')));
297 let initial_prompt = match (handback_tool, &report_dir) {
299 (true, Some(report_dir)) => format!(
300 "{}\n\n{}",
301 mj_core::subagent::handback_prompt_note(report_dir),
302 request.initial_prompt
303 ),
304 _ => request.initial_prompt,
305 };
306 let relation = SubagentRecord {
307 child_session_id: child_id.clone(),
308 parent_session_id: parent.id,
309 task_name: request.task_name,
310 profile_id: request.profile_id,
311 model: request.model,
312 effort: request.effort,
313 working_directory: request.working_directory,
314 initial_prompt,
315 request_key: request.request_key,
316 created_at,
317 noticed_turn: None,
318 reported_finish: None,
319 handback_tool,
320 };
321 crate::database::save_subagent_session(&session, &relation)?;
322 if let Some(report_dir) = &report_dir {
323 crate::database::record_subagent_report_dir(&child_id, report_dir)?;
324 }
325 self.state.sessions.insert(child_id, session);
326 self.state
327 .subagents
328 .insert(relation.child_session_id.clone(), relation.clone());
329 Ok(relation)
330 }
331
332 pub fn ensure_subagent_slot_available(
342 &self,
343 parent_session_id: &str,
344 starting: Option<&str>,
345 ) -> Result<()> {
346 let live = crate::database::list_subagents(parent_session_id)?
347 .into_iter()
348 .filter(|child| Some(child.child_session_id.as_str()) != starting)
349 .filter_map(|child| {
350 let session = self.state.sessions.get(&child.child_session_id)?;
351 session.state.has_live_worker().then(|| LiveSubagent {
352 child_session_id: child.child_session_id.clone(),
353 title: session.listed_title().to_owned(),
354 state: live_subagent_state(session),
355 })
356 })
357 .collect::<Vec<_>>();
358 let maximum = self.config.subagents.max_concurrent;
359 ensure!(live.len() < maximum, "{}", slot_refusal(&live, maximum));
360 Ok(())
361 }
362}
363
364struct LiveSubagent {
367 child_session_id: String,
368 title: String,
369 state: &'static str,
370}
371
372fn live_subagent_state(session: &SessionRecord) -> &'static str {
376 match session.state {
377 SessionState::Provisioning => "starting",
378 SessionState::Checkpointing => "checkpointing",
379 SessionState::Closing | SessionState::Destroying | SessionState::StartupCleanup => {
380 "stopping"
381 }
382 SessionState::Disconnected => "disconnected",
383 _ => match crate::database::load_materialized_session_summary(&session.id) {
384 Ok(Some(summary))
385 if matches!(
386 summary.execution,
387 mj_core::state::MaterializedExecutionState::Idle
388 ) =>
389 {
390 "idle"
391 }
392 _ => "running",
393 },
394 }
395}
396
397fn slot_refusal(live: &[LiveSubagent], maximum: usize) -> String {
400 let listed = live
401 .iter()
402 .map(|child| {
403 format!(
404 "{} \"{}\" ({})",
405 mj_core::state::short_id(&child.child_session_id),
406 child.title,
407 child.state
408 )
409 })
410 .collect::<Vec<_>>()
411 .join(", ");
412 format!(
413 "this session already has {} live sub-agents and the maximum is {maximum}. \
414 Live sub-agents: {listed}. A sub-agent frees its slot when it hands back its \
415 report (it is then parked and holds no processes until you send it input) or \
416 when you close it.",
417 live.len()
418 )
419}
420
421pub fn subagent_has_handed_back(child_session_id: &str) -> Result<bool> {
426 let Some(relation) = crate::database::load_subagent(child_session_id)? else {
427 return Ok(false);
428 };
429 if crate::database::load_session_state(child_session_id)? == Some(SessionState::Parked) {
432 return Ok(true);
433 }
434 let Some((execution, active_turn, last_turn)) =
435 crate::database::load_materialized_turn_outcome(child_session_id)?
436 else {
437 return Ok(false);
438 };
439 let report = crate::database::load_subagent_report(child_session_id)?;
440 let working = active_turn.is_some()
441 || !matches!(execution, mj_core::state::MaterializedExecutionState::Idle);
442 Ok(mj_core::subagent::has_handed_back(
443 relation.handback_tool,
444 &report,
445 working,
446 last_turn.as_ref(),
447 mj_core::clock::epoch_millis(),
448 ))
449}
450
451pub fn stopped_subagent(
454 state: &mj_core::state::State,
455 child_session_id: &str,
456) -> Result<mj_core::subagent::StoppedSubagent> {
457 let relation = state
458 .subagents
459 .get(child_session_id)
460 .with_context(|| format!("unknown sub-agent session {child_session_id}"))?;
461 let title = state
462 .sessions
463 .get(child_session_id)
464 .map_or(relation.task_name.as_str(), SessionRecord::listed_title)
465 .to_owned();
466 let report_dir = crate::database::load_subagent_report(child_session_id)?.report_dir;
467 Ok(mj_core::subagent::StoppedSubagent {
468 child_session_id: child_session_id.to_owned(),
469 title,
470 task: mj_core::subagent::task_summary(&relation.initial_prompt, report_dir.as_deref()),
471 handed_back: subagent_has_handed_back(child_session_id)?,
472 })
473}
474
475fn sibling_path(path: &Path, parent_id: &str, child_id: &str) -> Result<PathBuf> {
476 ensure!(
477 path.ends_with(parent_id),
478 "parent target path does not end in its session id"
479 );
480 Ok(path
481 .parent()
482 .context("parent target path has no parent")?
483 .join(child_id))
484}
485
486fn borrowed_locator(
487 target: &mj_core::state::TargetLocator,
488 parent_id: &str,
489 child_id: &str,
490) -> Result<mj_core::state::TargetLocator> {
491 use mj_core::state::TargetLocator;
492 Ok(match target {
493 TargetLocator::LocalBare { worker_root } => TargetLocator::LocalBare {
494 worker_root: sibling_path(worker_root, parent_id, child_id)?,
495 },
496 TargetLocator::SshBare {
497 host,
498 workspace,
499 worker_id: _,
500 } => TargetLocator::SshBare {
501 host: host.clone(),
502 workspace: workspace.clone(),
503 worker_id: Some(child_id.to_owned()),
504 },
505 TargetLocator::LocalPodman {
509 container_id,
510 workspace_storage,
511 ..
512 } => TargetLocator::LocalPodman {
513 container_id: container_id.clone(),
514 workspace_storage: workspace_storage.clone(),
515 borrowed_from: Some(parent_id.to_owned()),
516 },
517 TargetLocator::LocalDocker { container_id, .. } => TargetLocator::LocalDocker {
518 container_id: container_id.clone(),
519 borrowed_from: Some(parent_id.to_owned()),
520 },
521 TargetLocator::AppleContainer { container_id, .. } => TargetLocator::AppleContainer {
522 container_id: container_id.clone(),
523 borrowed_from: Some(parent_id.to_owned()),
524 },
525 TargetLocator::SshPodman {
526 host,
527 container_id,
528 workspace_storage,
529 ..
530 } => TargetLocator::SshPodman {
531 host: host.clone(),
532 container_id: container_id.clone(),
533 workspace_storage: workspace_storage.clone(),
534 borrowed_from: Some(parent_id.to_owned()),
535 },
536 TargetLocator::SshDocker {
537 host, container_id, ..
538 } => TargetLocator::SshDocker {
539 host: host.clone(),
540 container_id: container_id.clone(),
541 borrowed_from: Some(parent_id.to_owned()),
542 },
543 other @ TargetLocator::AwsEc2 { .. } => other.clone(),
546 })
547}
548
549fn child_gets_handback_tool(harness: HarnessKind) -> bool {
553 matches!(harness, HarnessKind::Codex | HarnessKind::Claude)
554}
555
556fn ensure_parent_may_delegate(parent: &SessionRecord) -> Result<()> {
561 ensure!(
562 parent
563 .subagents
564 .as_ref()
565 .is_some_and(mj_core::subagent::SubagentPolicy::uses_mjolnir),
566 "this session does not allow Mjolnir sub-agents"
567 );
568 Ok(())
569}
570
571fn fixed_selectors_match(
576 model: &str,
577 effort: Option<&str>,
578 request: &RegisterSubagentRequest,
579) -> bool {
580 let effort_matches = match effort {
581 Some(mj_core::subagent::ADAPTIVE_EFFORT) => {
582 request.effort.as_deref() != Some(mj_core::subagent::ADAPTIVE_EFFORT)
583 }
584 fixed => request.effort.as_deref() == fixed,
585 };
586 request.model.as_deref() == Some(model) && effort_matches
587}
588
589fn subagent_report_root(
592 parent: &SessionRecord,
593 backend: &mj_core::targets::TargetLocator,
594 checkout: &mj_core::state::Checkout<'_>,
595) -> (String, Option<String>) {
596 match checkout.project_directory() {
597 Some(project) => {
598 let project = project.to_string_lossy().trim_end_matches('/').to_owned();
599 (
600 format!("{project}/{}", mj_core::subagent::PROJECT_REPORT_ROOT_DIR),
601 Some(project),
602 )
603 }
604 None => {
605 let workspace =
606 super::network_git::workspace_root(backend, parent.container_workspace.as_deref());
607 (
608 format!(
609 "{}/{}",
610 workspace.trim_end_matches('/'),
611 mj_core::subagent::REPORT_ROOT_DIR
612 ),
613 None,
614 )
615 }
616 }
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622
623 fn request_with(model: Option<&str>, effort: Option<&str>) -> RegisterSubagentRequest {
624 RegisterSubagentRequest {
625 parent_session_id: "parent".into(),
626 task_name: "task".into(),
627 profile_id: "codex".into(),
628 model: model.map(str::to_owned),
629 effort: effort.map(str::to_owned),
630 working_directory: PathBuf::new(),
631 initial_prompt: "do the task".into(),
632 request_key: "key".into(),
633 report_root: None,
634 }
635 }
636
637 #[test]
640 fn a_fixed_adaptive_parent_accepts_a_resolved_child_effort() {
641 let adaptive = Some(mj_core::subagent::ADAPTIVE_EFFORT);
642 let matches = |model, effort| {
643 fixed_selectors_match("gpt-6-luna", adaptive, &request_with(model, effort))
644 };
645 assert!(matches(Some("gpt-6-luna"), Some("medium")));
646 assert!(matches(Some("gpt-6-luna"), Some("high")));
647 assert!(matches(Some("gpt-6-luna"), None));
648 assert!(!matches(Some("gpt-6-luna"), adaptive));
649 assert!(!matches(Some("other-model"), Some("medium")));
650 assert!(!matches(None, Some("medium")));
651 }
652
653 #[test]
654 fn a_fixed_concrete_effort_still_needs_an_exact_match() {
655 let matches = |fixed, effort| {
656 fixed_selectors_match(
657 "gpt-6-luna",
658 fixed,
659 &request_with(Some("gpt-6-luna"), effort),
660 )
661 };
662 assert!(matches(Some("max"), Some("max")));
663 assert!(!matches(Some("max"), Some("high")));
664 assert!(!matches(Some("max"), None));
665 assert!(matches(None, None));
666 assert!(!matches(None, Some("high")));
667 }
668
669 fn run_prepare_script(directory: &Path, exclude_in: Option<&Path>) -> String {
670 let argv = prepare_report_dir_argv(
671 &directory.to_string_lossy(),
672 exclude_in
673 .map(|path| path.to_string_lossy().into_owned())
674 .as_deref(),
675 );
676 let output = std::process::Command::new(&argv[0])
677 .args(&argv[1..])
678 .output()
679 .expect("run the report directory script");
680 assert!(
681 output.status.success(),
682 "{}",
683 String::from_utf8_lossy(&output.stderr)
684 );
685 String::from_utf8(output.stdout)
686 .unwrap()
687 .trim_end()
688 .to_owned()
689 }
690
691 #[test]
695 fn the_report_directory_script_creates_the_directory_and_keeps_git_status_clean() {
696 let temp = tempfile::tempdir().unwrap();
697 let project = temp.path().join("project");
698 std::fs::create_dir_all(&project).unwrap();
699 let git = |args: &[&str]| {
700 let output = std::process::Command::new("git")
701 .arg("-C")
702 .arg(&project)
703 .args(args)
704 .output()
705 .unwrap();
706 assert!(output.status.success(), "{output:?}");
707 String::from_utf8(output.stdout).unwrap()
708 };
709 git(&["init", "-q"]);
710 std::fs::write(project.join(".git/info/exclude"), "*.tmp").unwrap();
713 let root = project.join(mj_core::subagent::PROJECT_REPORT_ROOT_DIR);
714 let printed = run_prepare_script(&root, Some(&project));
715 assert_eq!(
716 Path::new(&printed),
717 root.canonicalize().unwrap(),
718 "the script prints the absolute directory"
719 );
720 run_prepare_script(&root, Some(&project));
721 std::fs::write(root.join("report.md"), "details").unwrap();
722 let exclude = std::fs::read_to_string(project.join(".git/info/exclude")).unwrap();
723 assert_eq!(exclude, "*.tmp\n/.mj/agents/\n");
724 assert_eq!(git(&["status", "--porcelain", "--ignored=no"]), "");
725
726 let plain = temp.path().join("plain");
728 std::fs::create_dir_all(&plain).unwrap();
729 let reports = plain.join(".mj/agents/child");
730 run_prepare_script(&reports, Some(&plain));
731 assert!(reports.is_dir());
732 }
733
734 #[test]
735 fn a_report_root_is_under_the_workspace_or_inside_a_bare_project() {
736 let mut parent = super::super::test_support::checkpoint_test_session("parent-1");
737 let backend = mj_core::targets::TargetLocator::LocalBare {
738 worker_root: "/var/lib/hel/workers/parent-1".into(),
739 };
740 parent.project_directory = None;
741 let checkout = parent.checkout();
742 assert_eq!(
743 subagent_report_root(&parent, &backend, &checkout),
744 ("/var/lib/hel/workers/parent-1/.mj-agents".to_owned(), None)
745 );
746 parent.project_directory = Some("/home/dev/project/".into());
747 let checkout = parent.checkout();
748 assert_eq!(
749 subagent_report_root(&parent, &backend, &checkout),
750 (
751 "/home/dev/project/.mj/agents".to_owned(),
752 Some("/home/dev/project".to_owned())
753 )
754 );
755 }
756
757 #[test]
759 fn a_container_child_borrows_its_parents_container() {
760 use mj_core::state::{PodmanWorkspaceLocator, TargetLocator};
761
762 let parent_id = "0123456789abcdef0123456789abcdef";
763 let child_id = "fedcba9876543210fedcba9876543210";
764 let container = mj_core::targets::resource_name(parent_id).unwrap();
765
766 let local = borrowed_locator(
767 &TargetLocator::LocalPodman {
768 container_id: container.clone(),
769 workspace_storage: PodmanWorkspaceLocator::Volume {
770 name: "parent-volume".to_owned(),
771 },
772 borrowed_from: None,
773 },
774 parent_id,
775 child_id,
776 )
777 .unwrap();
778 assert_eq!(
779 local,
780 TargetLocator::LocalPodman {
781 container_id: container.clone(),
782 workspace_storage: PodmanWorkspaceLocator::Volume {
783 name: "parent-volume".to_owned(),
784 },
785 borrowed_from: Some(parent_id.to_owned()),
786 }
787 );
788
789 let remote = borrowed_locator(
790 &TargetLocator::SshPodman {
791 host: "builder".to_owned(),
792 container_id: container.clone(),
793 workspace_storage: PodmanWorkspaceLocator::ContainerLayer,
794 borrowed_from: None,
795 },
796 parent_id,
797 child_id,
798 )
799 .unwrap();
800 assert_eq!(
801 remote,
802 TargetLocator::SshPodman {
803 host: "builder".to_owned(),
804 container_id: container,
805 workspace_storage: PodmanWorkspaceLocator::ContainerLayer,
806 borrowed_from: Some(parent_id.to_owned()),
807 }
808 );
809 }
810
811 #[test]
812 fn a_parent_using_native_delegation_cannot_spawn_mjolnir_children() {
813 let parent = |choice: Option<bool>| {
814 let mut session = crate::controller::test_support::checkpoint_test_session("parent");
815 session.subagents = choice.map(|enabled| {
816 if enabled {
817 mj_core::subagent::SubagentPolicy::AllModels
818 } else {
819 mj_core::subagent::SubagentPolicy::Native
820 }
821 });
822 session
823 };
824
825 assert_eq!(
826 ensure_parent_may_delegate(&parent(Some(false)))
827 .unwrap_err()
828 .to_string(),
829 "this session does not allow Mjolnir sub-agents"
830 );
831 assert_eq!(
832 ensure_parent_may_delegate(&parent(None))
833 .unwrap_err()
834 .to_string(),
835 "this session does not allow Mjolnir sub-agents"
836 );
837 assert!(ensure_parent_may_delegate(&parent(Some(true))).is_ok());
838 }
839
840 #[test]
847 fn the_cap_counts_every_child_holding_processes_and_names_them() {
848 const MARKER: &str = "MJ_TEST_SUBAGENT_CAP_CHILD";
849 if std::env::var_os(MARKER).is_none() {
850 let directory = tempfile::tempdir().unwrap();
851 super::super::test_support::IsolatedTest::new(super::super::test_support::test_name(
852 module_path!(),
853 "the_cap_counts_every_child_holding_processes_and_names_them",
854 ))
855 .env(MARKER, "1")
856 .isolated_store(directory.path())
857 .run();
858 return;
859 }
860 let _writer = crate::database::install_isolated_test_writer();
861 let parent = super::super::test_support::checkpoint_test_session("parent-1");
862 crate::database::save_session(&parent).unwrap();
863 let children = [
864 ("aaaaaaaa-idle", "Audit the lockfile", SessionState::Running),
865 ("bbbbbbbb-busy", "Run the suite", SessionState::Running),
866 ("cccccccc-park", "Map the parser", SessionState::Parked),
867 ("dddddddd-done", "Old task", SessionState::Stopped),
868 ];
869 for (id, title, state) in children {
870 let mut child = super::super::test_support::checkpoint_test_session(id);
871 child.state = state;
872 child.session_title_override = Some(title.into());
873 crate::database::save_subagent_session(
874 &child,
875 &SubagentRecord {
876 child_session_id: id.into(),
877 parent_session_id: "parent-1".into(),
878 task_name: title.into(),
879 profile_id: "codex".into(),
880 model: None,
881 effort: None,
882 working_directory: PathBuf::new(),
883 initial_prompt: "do it".into(),
884 request_key: format!("request-{id}"),
885 created_at: "2026-09-25T00:00:00Z".into(),
886 noticed_turn: None,
887 reported_finish: None,
888 handback_tool: true,
889 },
890 )
891 .unwrap();
892 }
893 let mut controller = Controller {
894 config: mj_core::config::Config::default(),
895 state: crate::database::load_state().unwrap(),
896 };
897 controller.config.subagents.max_concurrent = 2;
898
899 let refusal = controller
901 .ensure_subagent_slot_available("parent-1", None)
902 .unwrap_err()
903 .to_string();
904 assert!(
905 refusal.contains("already has 2 live sub-agents and the maximum is 2"),
906 "{refusal}"
907 );
908 assert!(
909 refusal.contains("aaaaaaaa \"Audit the lockfile\"")
910 && refusal.contains("bbbbbbbb \"Run the suite\""),
911 "the refusal names every live child: {refusal}"
912 );
913 assert!(
914 !refusal.contains("Map the parser") && !refusal.contains("Old task"),
915 "parked and stopped children hold no processes: {refusal}"
916 );
917 assert!(
918 refusal.contains("hands back")
919 && refusal.contains("parked")
920 && refusal.contains("close it"),
921 "the refusal says how a slot frees up: {refusal}"
922 );
923 assert!(
925 controller
926 .ensure_subagent_slot_available("parent-1", Some("cccccccc-park"))
927 .is_err()
928 );
929 controller
931 .ensure_subagent_slot_available("parent-1", Some("aaaaaaaa-idle"))
932 .unwrap();
933
934 controller
936 .state
937 .sessions
938 .get_mut("aaaaaaaa-idle")
939 .unwrap()
940 .state = SessionState::Parked;
941 controller
942 .ensure_subagent_slot_available("parent-1", None)
943 .unwrap();
944 controller
945 .ensure_subagent_slot_available("parent-1", Some("cccccccc-park"))
946 .unwrap();
947 }
948}