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