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