Skip to main content

mj_controller/controller/
subagents.rs

1//! Registration and ownership rules for child sessions on a parent's target.
2
3use 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    /// Empty means the parent's working directory. An absolute path is used
20    /// as-is; a relative path is resolved against the parent's working
21    /// directory. The path is interpreted on the parent's target and must
22    /// exist there; no other restriction applies.
23    pub working_directory: PathBuf,
24    pub initial_prompt: String,
25    pub request_key: String,
26    /// The absolute directory on the parent's target that holds its
27    /// children's report directories, from [`Controller::prepare_subagent_report_root`].
28    /// `None` registers a child without one.
29    pub report_root: Option<String>,
30}
31
32/// Creates a report directory on a target and prints its absolute path.
33///
34/// `$1` is the directory, which may be relative to the login home the way an
35/// SSH or EC2 workspace is. `$2`, when not empty, is a repository whose
36/// `info/exclude` must list [`mj_core::subagent::PROJECT_REPORT_ROOT_DIR`], so a
37/// report root inside a bare project never shows in `git status`. A directory
38/// that is not a repository has no status to keep clean. The absolute path is
39/// what the parent and child are told, because neither runs in the login home.
40const 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
60/// The argv that runs [`PREPARE_REPORT_DIR_SCRIPT`] for `directory`, adding
61/// the exclude line to `exclude_in` when given.
62fn 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
74/// Run [`PREPARE_REPORT_DIR_SCRIPT`] on `backend` and return the absolute
75/// directory it printed.
76fn 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    /// Create the directory that holds a parent's children's report
103    /// directories on the parent's target, and return its absolute path.
104    ///
105    /// It sits outside every repository: under the workspace root that a
106    /// bundle session's repositories are checked out below, or, for a bare
107    /// project whose workspace root is the user's own directory, inside the
108    /// project under a path its `info/exclude` lists.
109    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    /// Create a registered child's own report directory on its target. A
135    /// child registered without one has nothing to create.
136    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    /// Register a child without provisioning another target or checkout.
153    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            checkout: None,
236            expected_runtime_identity: None,
237            publication: None,
238            // A child shares its parent's container, so it shares the build
239            // cache that container was created with.
240            build_cache: parent.build_cache.clone(),
241            // A child never receives the Mjolnir sub-agent tools, so it can
242            // never spawn a grandchild.
243            mjolnir_subagents: Some(false),
244            create_managed_worktree: Some(false),
245            archived: false,
246            container_cpus: None,
247            container_memory: None,
248            // A child runs inside its parent's container, so it works in the
249            // parent's workspace, including the legacy shared one.
250            container_workspace: parent.container_workspace.clone(),
251            id: child_id.clone(),
252            workspace_id: parent.workspace_id.clone(),
253            title: request.task_name.clone(),
254            harness_kind: profile.kind,
255            last_profile: request.profile_id.clone(),
256            bundle_id: parent.bundle_id.clone(),
257            project_directory: parent.project_directory.clone(),
258            managed_worktree: None,
259            target_template_id: parent.target_template_id.clone(),
260            resource_allocation: parent.resource_allocation.clone(),
261            additional_mounts: parent.additional_mounts.clone(),
262            state: SessionState::Provisioning,
263            target: Some(target),
264            native_session_id: None,
265            acp_session_title: None,
266            session_title_override: Some(request.task_name.clone()),
267            created_at: created_at.clone(),
268            updated_at: created_at.clone(),
269            viewed_through_event_ordinal: 0,
270            draft_input: String::new(),
271            last_error: None,
272            last_checkpoint_error: None,
273            checkpoint: None,
274        };
275        let handback_tool = child_gets_handback_tool(profile.kind);
276        let report_dir = request
277            .report_root
278            .as_deref()
279            .map(|root| format!("{}/{child_id}", root.trim_end_matches('/')));
280        // The first prompt names the tool only when the child will have it.
281        let initial_prompt = match (handback_tool, &report_dir) {
282            (true, Some(report_dir)) => format!(
283                "{}\n\n{}",
284                mj_core::subagent::handback_prompt_note(report_dir),
285                request.initial_prompt
286            ),
287            _ => request.initial_prompt,
288        };
289        let relation = SubagentRecord {
290            child_session_id: child_id.clone(),
291            parent_session_id: parent.id,
292            task_name: request.task_name,
293            profile_id: request.profile_id,
294            model: request.model,
295            effort: request.effort,
296            working_directory: request.working_directory,
297            initial_prompt,
298            request_key: request.request_key,
299            created_at,
300            noticed_turn: None,
301            handback_tool,
302        };
303        crate::database::save_subagent_session(&session, &relation)?;
304        if let Some(report_dir) = &report_dir {
305            crate::database::record_subagent_report_dir(&child_id, report_dir)?;
306        }
307        self.state.sessions.insert(child_id, session);
308        self.state
309            .subagents
310            .insert(relation.child_session_id.clone(), relation.clone());
311        Ok(relation)
312    }
313
314    /// Refuse to start another child process tree for `parent_session_id`
315    /// when it already has the maximum number of live children.
316    ///
317    /// `starting` is the child a `send_input` is about to start again from
318    /// parked; it is not counted against itself. A spawn passes `None`.
319    ///
320    /// Every child whose worker may be holding processes in the parent's
321    /// container counts, idle or not: those processes are what the cap
322    /// protects (#1161). A parked, stopped, failed or lost child holds none.
323    pub fn ensure_subagent_slot_available(
324        &self,
325        parent_session_id: &str,
326        starting: Option<&str>,
327    ) -> Result<()> {
328        let live = crate::database::list_subagents(parent_session_id)?
329            .into_iter()
330            .filter(|child| Some(child.child_session_id.as_str()) != starting)
331            .filter_map(|child| {
332                let session = self.state.sessions.get(&child.child_session_id)?;
333                session.state.has_live_worker().then(|| LiveSubagent {
334                    child_session_id: child.child_session_id.clone(),
335                    title: session.listed_title().to_owned(),
336                    state: live_subagent_state(session),
337                })
338            })
339            .collect::<Vec<_>>();
340        let maximum = self.config.subagents.max_concurrent;
341        ensure!(live.len() < maximum, "{}", slot_refusal(&live, maximum));
342        Ok(())
343    }
344}
345
346/// One child that holds processes on its parent's target, as a cap refusal
347/// names it.
348struct LiveSubagent {
349    child_session_id: String,
350    title: String,
351    state: &'static str,
352}
353
354/// The word a cap refusal uses for a live child's state. An idle child is
355/// told apart from a working one, because only an idle child can be closed
356/// without losing work.
357fn live_subagent_state(session: &SessionRecord) -> &'static str {
358    match session.state {
359        SessionState::Provisioning => "starting",
360        SessionState::Checkpointing => "checkpointing",
361        SessionState::Closing | SessionState::Destroying => "stopping",
362        SessionState::Disconnected => "disconnected",
363        _ => match crate::database::load_materialized_session_summary(&session.id) {
364            Ok(Some(summary))
365                if matches!(
366                    summary.execution,
367                    mj_core::state::MaterializedExecutionState::Idle
368                ) =>
369            {
370                "idle"
371            }
372            _ => "running",
373        },
374    }
375}
376
377/// The refusal a parent model reads when it asks for one child too many: how
378/// many are live, the maximum, which ones they are, and how a slot frees up.
379fn slot_refusal(live: &[LiveSubagent], maximum: usize) -> String {
380    let listed = live
381        .iter()
382        .map(|child| {
383            format!(
384                "{} \"{}\" ({})",
385                mj_core::state::short_id(&child.child_session_id),
386                child.title,
387                child.state
388            )
389        })
390        .collect::<Vec<_>>()
391        .join(", ");
392    format!(
393        "this session already has {} live sub-agents and the maximum is {maximum}. \
394         Live sub-agents: {listed}. A sub-agent frees its slot when it hands back its \
395         report (it is then parked and holds no processes until you send it input) or \
396         when you close it.",
397        live.len()
398    )
399}
400
401/// Whether a sub-agent child has handed back its report for its parent's
402/// newest task, from what the store holds; see
403/// [`mj_core::subagent::has_handed_back`]. A session that is not a child, or
404/// that has never finished a turn, has not.
405pub fn subagent_has_handed_back(child_session_id: &str) -> Result<bool> {
406    let Some(relation) = crate::database::load_subagent(child_session_id)? else {
407        return Ok(false);
408    };
409    // A child is parked only once its turn ended and its parent was told, so
410    // whatever the parent was going to get from it, it has.
411    if crate::database::load_session_state(child_session_id)? == Some(SessionState::Parked) {
412        return Ok(true);
413    }
414    let Some((execution, active_turn, last_turn)) =
415        crate::database::load_materialized_turn_outcome(child_session_id)?
416    else {
417        return Ok(false);
418    };
419    let report = crate::database::load_subagent_report(child_session_id)?;
420    let working = active_turn.is_some()
421        || !matches!(execution, mj_core::state::MaterializedExecutionState::Idle);
422    Ok(mj_core::subagent::has_handed_back(
423        relation.handback_tool,
424        &report,
425        working,
426        last_turn.as_ref(),
427        mj_core::clock::epoch_millis(),
428    ))
429}
430
431/// What a parent's record keeps about a child its suspend stops: the child's
432/// listed title, one line of its task, and whether it had handed back.
433pub fn stopped_subagent(
434    state: &mj_core::state::State,
435    child_session_id: &str,
436) -> Result<mj_core::subagent::StoppedSubagent> {
437    let relation = state
438        .subagents
439        .get(child_session_id)
440        .with_context(|| format!("unknown sub-agent session {child_session_id}"))?;
441    let title = state
442        .sessions
443        .get(child_session_id)
444        .map_or(relation.task_name.as_str(), SessionRecord::listed_title)
445        .to_owned();
446    let report_dir = crate::database::load_subagent_report(child_session_id)?.report_dir;
447    Ok(mj_core::subagent::StoppedSubagent {
448        child_session_id: child_session_id.to_owned(),
449        title,
450        task: mj_core::subagent::task_summary(&relation.initial_prompt, report_dir.as_deref()),
451        handed_back: subagent_has_handed_back(child_session_id)?,
452    })
453}
454
455fn sibling_path(path: &Path, parent_id: &str, child_id: &str) -> Result<PathBuf> {
456    ensure!(
457        path.ends_with(parent_id),
458        "parent target path does not end in its session id"
459    );
460    Ok(path
461        .parent()
462        .context("parent target path has no parent")?
463        .join(child_id))
464}
465
466fn borrowed_locator(
467    target: &mj_core::state::TargetLocator,
468    parent_id: &str,
469    child_id: &str,
470) -> Result<mj_core::state::TargetLocator> {
471    use mj_core::state::TargetLocator;
472    Ok(match target {
473        TargetLocator::LocalBare { worker_root } => TargetLocator::LocalBare {
474            worker_root: sibling_path(worker_root, parent_id, child_id)?,
475        },
476        TargetLocator::SshBare {
477            host,
478            workspace,
479            worker_id: _,
480        } => TargetLocator::SshBare {
481            host: host.clone(),
482            workspace: workspace.clone(),
483            worker_id: Some(child_id.to_owned()),
484        },
485        // A container child runs its own worker inside the parent's container
486        // and records the parent as the container's owner, so cleanup and
487        // whole-target operations stay with the parent.
488        TargetLocator::LocalPodman {
489            container_id,
490            workspace_storage,
491            ..
492        } => TargetLocator::LocalPodman {
493            container_id: container_id.clone(),
494            workspace_storage: workspace_storage.clone(),
495            borrowed_from: Some(parent_id.to_owned()),
496        },
497        TargetLocator::LocalDocker { container_id, .. } => TargetLocator::LocalDocker {
498            container_id: container_id.clone(),
499            borrowed_from: Some(parent_id.to_owned()),
500        },
501        TargetLocator::AppleContainer { container_id, .. } => TargetLocator::AppleContainer {
502            container_id: container_id.clone(),
503            borrowed_from: Some(parent_id.to_owned()),
504        },
505        TargetLocator::SshPodman {
506            host,
507            container_id,
508            workspace_storage,
509            ..
510        } => TargetLocator::SshPodman {
511            host: host.clone(),
512            container_id: container_id.clone(),
513            workspace_storage: workspace_storage.clone(),
514            borrowed_from: Some(parent_id.to_owned()),
515        },
516        TargetLocator::SshDocker {
517            host, container_id, ..
518        } => TargetLocator::SshDocker {
519            host: host.clone(),
520            container_id: container_id.clone(),
521            borrowed_from: Some(parent_id.to_owned()),
522        },
523        // EC2 children keep the parent's locator unchanged, as they did before
524        // container borrowing was recorded.
525        other @ TargetLocator::AwsEc2 { .. } => other.clone(),
526    })
527}
528
529/// Whether a child can be given the `handback` tool. Codex takes Mjolnir's MCP
530/// servers over ACP; Claude reads them from its staged profile, which every
531/// session has. Other harnesses keep reporting through their last message.
532fn child_gets_handback_tool(harness: HarnessKind) -> bool {
533    matches!(harness, HarnessKind::Codex | HarnessKind::Claude)
534}
535
536/// A parent may delegate to Mjolnir children only if its own stored choice
537/// says so; `None` means native sub-agents, same as `Some(false)`. A parent
538/// using its harness's native delegation never received the Mjolnir tools, so
539/// a request from it is stale.
540fn ensure_parent_may_delegate(parent: &SessionRecord) -> Result<()> {
541    match parent.mjolnir_subagents {
542        Some(true) => Ok(()),
543        _ => bail!("this session uses native sub-agents"),
544    }
545}
546
547/// Where a parent's children keep their report directories, before the target
548/// resolves it, and the repository whose `info/exclude` must list it.
549fn subagent_report_root(
550    parent: &SessionRecord,
551    backend: &mj_core::targets::TargetLocator,
552) -> (String, Option<String>) {
553    match &parent.project_directory {
554        Some(project) => {
555            let project = project.to_string_lossy().trim_end_matches('/').to_owned();
556            (
557                format!("{project}/{}", mj_core::subagent::PROJECT_REPORT_ROOT_DIR),
558                Some(project),
559            )
560        }
561        None => {
562            let workspace =
563                super::network_git::workspace_root(backend, parent.container_workspace.as_deref());
564            (
565                format!(
566                    "{}/{}",
567                    workspace.trim_end_matches('/'),
568                    mj_core::subagent::REPORT_ROOT_DIR
569                ),
570                None,
571            )
572        }
573    }
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579
580    fn run_prepare_script(directory: &Path, exclude_in: Option<&Path>) -> String {
581        let argv = prepare_report_dir_argv(
582            &directory.to_string_lossy(),
583            exclude_in
584                .map(|path| path.to_string_lossy().into_owned())
585                .as_deref(),
586        );
587        let output = std::process::Command::new(&argv[0])
588            .args(&argv[1..])
589            .output()
590            .expect("run the report directory script");
591        assert!(
592            output.status.success(),
593            "{}",
594            String::from_utf8_lossy(&output.stderr)
595        );
596        String::from_utf8(output.stdout)
597            .unwrap()
598            .trim_end()
599            .to_owned()
600    }
601
602    /// A bare project's report root is inside the project, so the script
603    /// lists it in the repository's `info/exclude` exactly once and the
604    /// project's `git status` stays clean.
605    #[test]
606    fn the_report_directory_script_creates_the_directory_and_keeps_git_status_clean() {
607        let temp = tempfile::tempdir().unwrap();
608        let project = temp.path().join("project");
609        std::fs::create_dir_all(&project).unwrap();
610        let git = |args: &[&str]| {
611            let output = std::process::Command::new("git")
612                .arg("-C")
613                .arg(&project)
614                .args(args)
615                .output()
616                .unwrap();
617            assert!(output.status.success(), "{output:?}");
618            String::from_utf8(output.stdout).unwrap()
619        };
620        git(&["init", "-q"]);
621        // An exclude file without a trailing newline must not have its last
622        // line joined to the new one.
623        std::fs::write(project.join(".git/info/exclude"), "*.tmp").unwrap();
624        let root = project.join(mj_core::subagent::PROJECT_REPORT_ROOT_DIR);
625        let printed = run_prepare_script(&root, Some(&project));
626        assert_eq!(
627            Path::new(&printed),
628            root.canonicalize().unwrap(),
629            "the script prints the absolute directory"
630        );
631        run_prepare_script(&root, Some(&project));
632        std::fs::write(root.join("report.md"), "details").unwrap();
633        let exclude = std::fs::read_to_string(project.join(".git/info/exclude")).unwrap();
634        assert_eq!(exclude, "*.tmp\n/.mj/agents/\n");
635        assert_eq!(git(&["status", "--porcelain", "--ignored=no"]), "");
636
637        // Outside a repository there is no exclude to write.
638        let plain = temp.path().join("plain");
639        std::fs::create_dir_all(&plain).unwrap();
640        let reports = plain.join(".mj/agents/child");
641        run_prepare_script(&reports, Some(&plain));
642        assert!(reports.is_dir());
643    }
644
645    #[test]
646    fn a_report_root_is_under_the_workspace_or_inside_a_bare_project() {
647        let mut parent = super::super::test_support::checkpoint_test_session("parent-1");
648        let backend = mj_core::targets::TargetLocator::LocalBare {
649            worker_root: "/var/lib/hel/workers/parent-1".into(),
650        };
651        parent.project_directory = None;
652        assert_eq!(
653            subagent_report_root(&parent, &backend),
654            ("/var/lib/hel/workers/parent-1/.mj-agents".to_owned(), None)
655        );
656        parent.project_directory = Some("/home/dev/project/".into());
657        assert_eq!(
658            subagent_report_root(&parent, &backend),
659            (
660                "/home/dev/project/.mj/agents".to_owned(),
661                Some("/home/dev/project".to_owned())
662            )
663        );
664    }
665
666    #[test]
667    fn a_container_child_borrows_its_parents_container() {
668        use mj_core::state::{PodmanWorkspaceLocator, TargetLocator};
669
670        let parent_id = "0123456789abcdef0123456789abcdef";
671        let child_id = "fedcba9876543210fedcba9876543210";
672        let container = mj_core::targets::resource_name(parent_id).unwrap();
673
674        let local = borrowed_locator(
675            &TargetLocator::LocalPodman {
676                container_id: container.clone(),
677                workspace_storage: PodmanWorkspaceLocator::Volume {
678                    name: "parent-volume".to_owned(),
679                },
680                borrowed_from: None,
681            },
682            parent_id,
683            child_id,
684        )
685        .unwrap();
686        assert_eq!(
687            local,
688            TargetLocator::LocalPodman {
689                container_id: container.clone(),
690                workspace_storage: PodmanWorkspaceLocator::Volume {
691                    name: "parent-volume".to_owned(),
692                },
693                borrowed_from: Some(parent_id.to_owned()),
694            }
695        );
696
697        let remote = borrowed_locator(
698            &TargetLocator::SshPodman {
699                host: "builder".to_owned(),
700                container_id: container.clone(),
701                workspace_storage: PodmanWorkspaceLocator::ContainerLayer,
702                borrowed_from: None,
703            },
704            parent_id,
705            child_id,
706        )
707        .unwrap();
708        assert_eq!(
709            remote,
710            TargetLocator::SshPodman {
711                host: "builder".to_owned(),
712                container_id: container,
713                workspace_storage: PodmanWorkspaceLocator::ContainerLayer,
714                borrowed_from: Some(parent_id.to_owned()),
715            }
716        );
717    }
718
719    #[test]
720    fn a_parent_using_native_delegation_cannot_spawn_mjolnir_children() {
721        let parent = |choice| {
722            let mut session = crate::controller::test_support::checkpoint_test_session("parent");
723            session.mjolnir_subagents = choice;
724            session
725        };
726
727        assert_eq!(
728            ensure_parent_may_delegate(&parent(Some(false)))
729                .unwrap_err()
730                .to_string(),
731            "this session uses native sub-agents"
732        );
733        assert_eq!(
734            ensure_parent_may_delegate(&parent(None))
735                .unwrap_err()
736                .to_string(),
737            "this session uses native sub-agents"
738        );
739        assert!(ensure_parent_may_delegate(&parent(Some(true))).is_ok());
740    }
741
742    /// #1161: idle children held the processes that exhausted their parent's
743    /// container, yet did not count against the cap. Every child that holds
744    /// processes counts now; a parked one does not. The refusal is what the
745    /// parent model reads, so it names the live children and how to free a
746    /// slot.
747    #[test]
748    fn the_cap_counts_every_child_holding_processes_and_names_them() {
749        const MARKER: &str = "MJ_TEST_SUBAGENT_CAP_CHILD";
750        if std::env::var_os(MARKER).is_none() {
751            let directory = tempfile::tempdir().unwrap();
752            super::super::test_support::IsolatedTest::new(super::super::test_support::test_name(
753                module_path!(),
754                "the_cap_counts_every_child_holding_processes_and_names_them",
755            ))
756            .env(MARKER, "1")
757            .isolated_store(directory.path())
758            .run();
759            return;
760        }
761        let _writer = crate::database::install_isolated_test_writer();
762        let parent = super::super::test_support::checkpoint_test_session("parent-1");
763        crate::database::save_session(&parent).unwrap();
764        let children = [
765            ("aaaaaaaa-idle", "Audit the lockfile", SessionState::Running),
766            ("bbbbbbbb-busy", "Run the suite", SessionState::Running),
767            ("cccccccc-park", "Map the parser", SessionState::Parked),
768            ("dddddddd-done", "Old task", SessionState::Stopped),
769        ];
770        for (id, title, state) in children {
771            let mut child = super::super::test_support::checkpoint_test_session(id);
772            child.state = state;
773            child.session_title_override = Some(title.into());
774            crate::database::save_subagent_session(
775                &child,
776                &SubagentRecord {
777                    child_session_id: id.into(),
778                    parent_session_id: "parent-1".into(),
779                    task_name: title.into(),
780                    profile_id: "codex".into(),
781                    model: None,
782                    effort: None,
783                    working_directory: PathBuf::new(),
784                    initial_prompt: "do it".into(),
785                    request_key: format!("request-{id}"),
786                    created_at: "2026-09-25T00:00:00Z".into(),
787                    noticed_turn: None,
788                    handback_tool: true,
789                },
790            )
791            .unwrap();
792        }
793        let mut controller = Controller {
794            config: mj_core::config::Config::default(),
795            state: crate::database::load_state().unwrap(),
796        };
797        controller.config.subagents.max_concurrent = 2;
798
799        // Two live children fill a cap of two, idle or not.
800        let refusal = controller
801            .ensure_subagent_slot_available("parent-1", None)
802            .unwrap_err()
803            .to_string();
804        assert!(
805            refusal.contains("already has 2 live sub-agents and the maximum is 2"),
806            "{refusal}"
807        );
808        assert!(
809            refusal.contains("aaaaaaaa \"Audit the lockfile\"")
810                && refusal.contains("bbbbbbbb \"Run the suite\""),
811            "the refusal names every live child: {refusal}"
812        );
813        assert!(
814            !refusal.contains("Map the parser") && !refusal.contains("Old task"),
815            "parked and stopped children hold no processes: {refusal}"
816        );
817        assert!(
818            refusal.contains("hands back")
819                && refusal.contains("parked")
820                && refusal.contains("close it"),
821            "the refusal says how a slot frees up: {refusal}"
822        );
823        // Starting the parked child again is refused the same way.
824        assert!(
825            controller
826                .ensure_subagent_slot_available("parent-1", Some("cccccccc-park"))
827                .is_err()
828        );
829        // Input to a live child starts nothing, so the cap never refuses it.
830        controller
831            .ensure_subagent_slot_available("parent-1", Some("aaaaaaaa-idle"))
832            .unwrap();
833
834        // Once one live child is parked, a spawn and a restart both fit.
835        controller
836            .state
837            .sessions
838            .get_mut("aaaaaaaa-idle")
839            .unwrap()
840            .state = SessionState::Parked;
841        controller
842            .ensure_subagent_slot_available("parent-1", None)
843            .unwrap();
844        controller
845            .ensure_subagent_slot_available("parent-1", Some("cccccccc-park"))
846            .unwrap();
847    }
848
849    #[test]
850    fn borrowed_bare_locator_gets_a_private_worker_identity() {
851        let locator = mj_core::state::TargetLocator::LocalBare {
852            worker_root: PathBuf::from("/workers/parent"),
853        };
854        assert_eq!(
855            borrowed_locator(&locator, "parent", "child").unwrap(),
856            mj_core::state::TargetLocator::LocalBare {
857                worker_root: PathBuf::from("/workers/child")
858            }
859        );
860    }
861
862    #[test]
863    fn borrowed_ssh_locator_keeps_parent_workspace_with_private_worker_identity() {
864        let locator = mj_core::state::TargetLocator::SshBare {
865            host: "builder".into(),
866            workspace: PathBuf::from(".local/share/hel/workspaces/parent-session"),
867            worker_id: None,
868        };
869        assert_eq!(
870            borrowed_locator(&locator, "parent-session", "child-session").unwrap(),
871            mj_core::state::TargetLocator::SshBare {
872                host: "builder".into(),
873                workspace: PathBuf::from(".local/share/hel/workspaces/parent-session"),
874                worker_id: Some("child-session".into()),
875            }
876        );
877    }
878}