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