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, 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 checkout = self.state.checkout(parent_session_id)?;
125        let (root, exclude_in) = subagent_report_root(parent, &backend, &checkout);
126        prepare_report_dir(
127            executor,
128            &backend,
129            parent_session_id,
130            &root,
131            exclude_in.as_deref(),
132        )
133    }
134
135    /// Create a registered child's own report directory on its target. A
136    /// child registered without one has nothing to create.
137    pub(super) fn prepare_subagent_report_dir(
138        &self,
139        session_id: &str,
140        backend: &mj_core::targets::TargetLocator,
141        executor: &impl mj_core::targets::CommandExecutor,
142    ) -> Result<()> {
143        if crate::database::load_subagent(session_id)?.is_none() {
144            return Ok(());
145        }
146        let Some(directory) = crate::database::load_subagent_report(session_id)?.report_dir else {
147            return Ok(());
148        };
149        prepare_report_dir(executor, backend, session_id, &directory, None)?;
150        Ok(())
151    }
152
153    /// Register a child without provisioning another target or checkout.
154    pub fn register_subagent(
155        &mut self,
156        request: RegisterSubagentRequest,
157    ) -> Result<SubagentRecord> {
158        if let Some(existing) = crate::database::lookup_subagent_request(
159            &request.parent_session_id,
160            &request.request_key,
161        )? {
162            return Ok(existing);
163        }
164        ensure!(
165            !request.request_key.trim().is_empty(),
166            "sub-agent request key cannot be empty"
167        );
168        ensure!(
169            !request.task_name.trim().is_empty(),
170            "sub-agent task name cannot be empty"
171        );
172        ensure!(
173            !request.initial_prompt.trim().is_empty(),
174            "sub-agent instructions cannot be empty"
175        );
176        let parent = self
177            .state
178            .sessions
179            .get(&request.parent_session_id)
180            .with_context(|| format!("unknown parent session {}", request.parent_session_id))?
181            .clone();
182        ensure!(
183            matches!(
184                parent.harness_kind,
185                HarnessKind::Claude | HarnessKind::Codex
186            ),
187            "only Claude and Codex sessions can spawn sub-agents"
188        );
189        ensure_parent_may_delegate(&parent)?;
190        ensure!(
191            parent.state != SessionState::StartupCleanup,
192            "parent startup cleanup is pending"
193        );
194        if let Some(mj_core::subagent::SubagentPolicy::SingleModel { model, effort }) =
195            &parent.subagents
196        {
197            ensure!(
198                fixed_selectors_match(model, effort.as_deref(), &request),
199                "child selectors must match the parent's fixed model and effort"
200            );
201        }
202        ensure!(parent.state.is_active(), "parent session is not active");
203        ensure!(parent.target.is_some(), "parent session has no live target");
204        ensure!(
205            crate::database::load_subagent(&parent.id)?.is_none(),
206            "sub-agents cannot spawn other sub-agents"
207        );
208        ensure!(
209            self.config
210                .subagents
211                .profile_is_eligible(&parent.last_profile, &request.profile_id),
212            "profile {:?} is not eligible for sub-agent use",
213            request.profile_id
214        );
215        let profile = self
216            .config
217            .enabled_profile(&request.profile_id)
218            .with_context(|| {
219                format!("sub-agent profile {:?} is unavailable", request.profile_id)
220            })?;
221        profile.ensure_ready(&request.profile_id)?;
222        if profile.kind == HarnessKind::Muse {
223            let multiple_roots = !parent.additional_mounts.is_empty()
224                || (self
225                    .state
226                    .checkout(&parent.id)?
227                    .project_directory()
228                    .is_none()
229                    && parent
230                        .project_bundle(&self.config)
231                        .is_some_and(|bundle| bundle.repositories.len() > 1));
232            ensure!(
233                !multiple_roots,
234                "{} ACP supports one workspace root; this parent exposes multiple roots",
235                profile.kind.display_name()
236            );
237        }
238        self.ensure_subagent_slot_available(&parent.id, None)?;
239
240        let child_id = new_session_id()?;
241        let target = borrowed_locator(
242            parent.target.as_ref().expect("live target checked above"),
243            &parent.id,
244            &child_id,
245        )?;
246        let created_at = now();
247        let session = SessionRecord {
248            project: parent.project.clone(),
249            target_runtime: Some(parent.target_runtime_settings(&self.config)?.into_owned()),
250            launch_base: None,
251            launch_branch: None,
252            checkout: None,
253            publication: None,
254            // A child shares its parent's container, so it shares the build
255            // cache that container was created with.
256            build_cache: parent.build_cache.clone(),
257            // A child never receives the Mjolnir sub-agent tools, so it can
258            // never spawn a grandchild.
259            subagents: Some(mj_core::subagent::SubagentPolicy::None),
260            create_managed_worktree: Some(false),
261            archived: false,
262            container_cpus: None,
263            container_memory: None,
264            // A child runs inside its parent's container, so it works in the
265            // parent's workspace, including the legacy shared one.
266            container_workspace: parent.container_workspace.clone(),
267            id: child_id.clone(),
268            workspace_id: parent.workspace_id.clone(),
269            title: request.task_name.clone(),
270            harness_kind: profile.kind,
271            last_profile: request.profile_id.clone(),
272            bundle_id: parent.bundle_id.clone(),
273            project_directory: parent.project_directory.clone(),
274            managed_worktree: None,
275            review: None,
276            target_template_id: parent.target_template_id.clone(),
277            resource_allocation: parent.resource_allocation.clone(),
278            additional_mounts: parent.additional_mounts.clone(),
279            state: SessionState::Provisioning,
280            target: Some(target),
281            native_session_id: None,
282            acp_session_title: None,
283            session_title_override: Some(request.task_name.clone()),
284            created_at: created_at.clone(),
285            updated_at: created_at.clone(),
286            viewed_through_event_ordinal: 0,
287            draft_input: String::new(),
288            last_error: None,
289            last_checkpoint_error: None,
290            checkpoint: None,
291        };
292        let handback_tool = child_gets_handback_tool(profile.kind);
293        let report_dir = request
294            .report_root
295            .as_deref()
296            .map(|root| format!("{}/{child_id}", root.trim_end_matches('/')));
297        // The first prompt names the tool only when the child will have it.
298        let initial_prompt = match (handback_tool, &report_dir) {
299            (true, Some(report_dir)) => format!(
300                "{}\n\n{}",
301                mj_core::subagent::handback_prompt_note(report_dir),
302                request.initial_prompt
303            ),
304            _ => request.initial_prompt,
305        };
306        let relation = SubagentRecord {
307            child_session_id: child_id.clone(),
308            parent_session_id: parent.id,
309            task_name: request.task_name,
310            profile_id: request.profile_id,
311            model: request.model,
312            effort: request.effort,
313            working_directory: request.working_directory,
314            initial_prompt,
315            request_key: request.request_key,
316            created_at,
317            noticed_turn: None,
318            reported_finish: None,
319            handback_tool,
320        };
321        crate::database::save_subagent_session(&session, &relation)?;
322        if let Some(report_dir) = &report_dir {
323            crate::database::record_subagent_report_dir(&child_id, report_dir)?;
324        }
325        self.state.sessions.insert(child_id, session);
326        self.state
327            .subagents
328            .insert(relation.child_session_id.clone(), relation.clone());
329        Ok(relation)
330    }
331
332    /// Refuse to start another child process tree for `parent_session_id`
333    /// when it already has the maximum number of live children.
334    ///
335    /// `starting` is the child a `send_message` is about to start again from
336    /// parked; it is not counted against itself. A spawn passes `None`.
337    ///
338    /// Every child whose worker may be holding processes in the parent's
339    /// container counts, idle or not: those processes are what the cap
340    /// protects (#1161). A parked, stopped, failed or lost child holds none.
341    pub fn ensure_subagent_slot_available(
342        &self,
343        parent_session_id: &str,
344        starting: Option<&str>,
345    ) -> Result<()> {
346        let live = crate::database::list_subagents(parent_session_id)?
347            .into_iter()
348            .filter(|child| Some(child.child_session_id.as_str()) != starting)
349            .filter_map(|child| {
350                let session = self.state.sessions.get(&child.child_session_id)?;
351                session.state.has_live_worker().then(|| LiveSubagent {
352                    child_session_id: child.child_session_id.clone(),
353                    title: session.listed_title().to_owned(),
354                    state: live_subagent_state(session),
355                })
356            })
357            .collect::<Vec<_>>();
358        let maximum = self.config.subagents.max_concurrent;
359        ensure!(live.len() < maximum, "{}", slot_refusal(&live, maximum));
360        Ok(())
361    }
362}
363
364/// One child that holds processes on its parent's target, as a cap refusal
365/// names it.
366struct LiveSubagent {
367    child_session_id: String,
368    title: String,
369    state: &'static str,
370}
371
372/// The word a cap refusal uses for a live child's state. An idle child is
373/// told apart from a working one, because only an idle child can be closed
374/// without losing work.
375fn live_subagent_state(session: &SessionRecord) -> &'static str {
376    match session.state {
377        SessionState::Provisioning => "starting",
378        SessionState::Checkpointing => "checkpointing",
379        SessionState::Closing | SessionState::Destroying | SessionState::StartupCleanup => {
380            "stopping"
381        }
382        SessionState::Disconnected => "disconnected",
383        _ => match crate::database::load_materialized_session_summary(&session.id) {
384            Ok(Some(summary))
385                if matches!(
386                    summary.execution,
387                    mj_core::state::MaterializedExecutionState::Idle
388                ) =>
389            {
390                "idle"
391            }
392            _ => "running",
393        },
394    }
395}
396
397/// The refusal a parent model reads when it asks for one child too many: how
398/// many are live, the maximum, which ones they are, and how a slot frees up.
399fn slot_refusal(live: &[LiveSubagent], maximum: usize) -> String {
400    let listed = live
401        .iter()
402        .map(|child| {
403            format!(
404                "{} \"{}\" ({})",
405                mj_core::state::short_id(&child.child_session_id),
406                child.title,
407                child.state
408            )
409        })
410        .collect::<Vec<_>>()
411        .join(", ");
412    format!(
413        "this session already has {} live sub-agents and the maximum is {maximum}. \
414         Live sub-agents: {listed}. A sub-agent frees its slot when it hands back its \
415         report (it is then parked and holds no processes until you send it input) or \
416         when you close it.",
417        live.len()
418    )
419}
420
421/// Whether a sub-agent child has handed back its report for its parent's
422/// newest task, from what the store holds; see
423/// [`mj_core::subagent::has_handed_back`]. A session that is not a child, or
424/// that has never finished a turn, has not.
425pub fn subagent_has_handed_back(child_session_id: &str) -> Result<bool> {
426    let Some(relation) = crate::database::load_subagent(child_session_id)? else {
427        return Ok(false);
428    };
429    // A child is parked only once its turn ended and its parent was told, so
430    // whatever the parent was going to get from it, it has.
431    if crate::database::load_session_state(child_session_id)? == Some(SessionState::Parked) {
432        return Ok(true);
433    }
434    let Some((execution, active_turn, last_turn)) =
435        crate::database::load_materialized_turn_outcome(child_session_id)?
436    else {
437        return Ok(false);
438    };
439    let report = crate::database::load_subagent_report(child_session_id)?;
440    let working = active_turn.is_some()
441        || !matches!(execution, mj_core::state::MaterializedExecutionState::Idle);
442    Ok(mj_core::subagent::has_handed_back(
443        relation.handback_tool,
444        &report,
445        working,
446        last_turn.as_ref(),
447        mj_core::clock::epoch_millis(),
448    ))
449}
450
451/// What a parent's record keeps about a child its suspend stops: the child's
452/// listed title, one line of its task, and whether it had handed back.
453pub fn stopped_subagent(
454    state: &mj_core::state::State,
455    child_session_id: &str,
456) -> Result<mj_core::subagent::StoppedSubagent> {
457    let relation = state
458        .subagents
459        .get(child_session_id)
460        .with_context(|| format!("unknown sub-agent session {child_session_id}"))?;
461    let title = state
462        .sessions
463        .get(child_session_id)
464        .map_or(relation.task_name.as_str(), SessionRecord::listed_title)
465        .to_owned();
466    let report_dir = crate::database::load_subagent_report(child_session_id)?.report_dir;
467    Ok(mj_core::subagent::StoppedSubagent {
468        child_session_id: child_session_id.to_owned(),
469        title,
470        task: mj_core::subagent::task_summary(&relation.initial_prompt, report_dir.as_deref()),
471        handed_back: subagent_has_handed_back(child_session_id)?,
472    })
473}
474
475fn sibling_path(path: &Path, parent_id: &str, child_id: &str) -> Result<PathBuf> {
476    ensure!(
477        path.ends_with(parent_id),
478        "parent target path does not end in its session id"
479    );
480    Ok(path
481        .parent()
482        .context("parent target path has no parent")?
483        .join(child_id))
484}
485
486fn borrowed_locator(
487    target: &mj_core::state::TargetLocator,
488    parent_id: &str,
489    child_id: &str,
490) -> Result<mj_core::state::TargetLocator> {
491    use mj_core::state::TargetLocator;
492    Ok(match target {
493        TargetLocator::LocalBare { worker_root } => TargetLocator::LocalBare {
494            worker_root: sibling_path(worker_root, parent_id, child_id)?,
495        },
496        TargetLocator::SshBare {
497            host,
498            workspace,
499            worker_id: _,
500        } => TargetLocator::SshBare {
501            host: host.clone(),
502            workspace: workspace.clone(),
503            worker_id: Some(child_id.to_owned()),
504        },
505        // A container child runs its own worker inside the parent's container
506        // and records the parent as the container's owner, so cleanup and
507        // whole-target operations stay with the parent.
508        TargetLocator::LocalPodman {
509            container_id,
510            workspace_storage,
511            ..
512        } => TargetLocator::LocalPodman {
513            container_id: container_id.clone(),
514            workspace_storage: workspace_storage.clone(),
515            borrowed_from: Some(parent_id.to_owned()),
516        },
517        TargetLocator::LocalDocker { container_id, .. } => TargetLocator::LocalDocker {
518            container_id: container_id.clone(),
519            borrowed_from: Some(parent_id.to_owned()),
520        },
521        TargetLocator::AppleContainer { container_id, .. } => TargetLocator::AppleContainer {
522            container_id: container_id.clone(),
523            borrowed_from: Some(parent_id.to_owned()),
524        },
525        TargetLocator::SshPodman {
526            host,
527            container_id,
528            workspace_storage,
529            ..
530        } => TargetLocator::SshPodman {
531            host: host.clone(),
532            container_id: container_id.clone(),
533            workspace_storage: workspace_storage.clone(),
534            borrowed_from: Some(parent_id.to_owned()),
535        },
536        TargetLocator::SshDocker {
537            host, container_id, ..
538        } => TargetLocator::SshDocker {
539            host: host.clone(),
540            container_id: container_id.clone(),
541            borrowed_from: Some(parent_id.to_owned()),
542        },
543        // EC2 children keep the parent's locator unchanged, as they did before
544        // container borrowing was recorded.
545        other @ TargetLocator::AwsEc2 { .. } => other.clone(),
546    })
547}
548
549/// Whether a child can be given the `handback` tool. Codex takes Mjolnir's MCP
550/// servers over ACP; Claude reads them from its staged profile, which every
551/// session has. Other harnesses keep reporting through their last message.
552fn child_gets_handback_tool(harness: HarnessKind) -> bool {
553    matches!(harness, HarnessKind::Codex | HarnessKind::Claude)
554}
555
556/// A parent may delegate to Mjolnir children only if its own stored choice
557/// says so; historical records without a policy use native delegation. A parent
558/// using its harness's native delegation never received the Mjolnir tools, so
559/// a request from it is stale.
560fn ensure_parent_may_delegate(parent: &SessionRecord) -> Result<()> {
561    ensure!(
562        parent
563            .subagents
564            .as_ref()
565            .is_some_and(mj_core::subagent::SubagentPolicy::uses_mjolnir),
566        "this session does not allow Mjolnir sub-agents"
567    );
568    Ok(())
569}
570
571/// Whether a child's selectors satisfy a single-model parent's fixed model and
572/// effort. The spawn path resolves a fixed `adaptive` effort to the child's own
573/// effort before registration, so under `adaptive` any resolved effort, or
574/// none, matches; only the unresolved `adaptive` itself does not.
575fn fixed_selectors_match(
576    model: &str,
577    effort: Option<&str>,
578    request: &RegisterSubagentRequest,
579) -> bool {
580    let effort_matches = match effort {
581        Some(mj_core::subagent::ADAPTIVE_EFFORT) => {
582            request.effort.as_deref() != Some(mj_core::subagent::ADAPTIVE_EFFORT)
583        }
584        fixed => request.effort.as_deref() == fixed,
585    };
586    request.model.as_deref() == Some(model) && effort_matches
587}
588
589/// Where a parent's children keep their report directories, before the target
590/// resolves it, and the repository whose `info/exclude` must list it.
591fn subagent_report_root(
592    parent: &SessionRecord,
593    backend: &mj_core::targets::TargetLocator,
594    checkout: &mj_core::state::Checkout<'_>,
595) -> (String, Option<String>) {
596    match checkout.project_directory() {
597        Some(project) => {
598            let project = project.to_string_lossy().trim_end_matches('/').to_owned();
599            (
600                format!("{project}/{}", mj_core::subagent::PROJECT_REPORT_ROOT_DIR),
601                Some(project),
602            )
603        }
604        None => {
605            let workspace =
606                super::network_git::workspace_root(backend, parent.container_workspace.as_deref());
607            (
608                format!(
609                    "{}/{}",
610                    workspace.trim_end_matches('/'),
611                    mj_core::subagent::REPORT_ROOT_DIR
612                ),
613                None,
614            )
615        }
616    }
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622
623    fn request_with(model: Option<&str>, effort: Option<&str>) -> RegisterSubagentRequest {
624        RegisterSubagentRequest {
625            parent_session_id: "parent".into(),
626            task_name: "task".into(),
627            profile_id: "codex".into(),
628            model: model.map(str::to_owned),
629            effort: effort.map(str::to_owned),
630            working_directory: PathBuf::new(),
631            initial_prompt: "do the task".into(),
632            request_key: "key".into(),
633            report_root: None,
634        }
635    }
636
637    /// Issue #1271: the spawn path resolves `adaptive` before registration,
638    /// so a fixed `adaptive` parent must accept the resolved child effort.
639    #[test]
640    fn a_fixed_adaptive_parent_accepts_a_resolved_child_effort() {
641        let adaptive = Some(mj_core::subagent::ADAPTIVE_EFFORT);
642        let matches = |model, effort| {
643            fixed_selectors_match("gpt-6-luna", adaptive, &request_with(model, effort))
644        };
645        assert!(matches(Some("gpt-6-luna"), Some("medium")));
646        assert!(matches(Some("gpt-6-luna"), Some("high")));
647        assert!(matches(Some("gpt-6-luna"), None));
648        assert!(!matches(Some("gpt-6-luna"), adaptive));
649        assert!(!matches(Some("other-model"), Some("medium")));
650        assert!(!matches(None, Some("medium")));
651    }
652
653    #[test]
654    fn a_fixed_concrete_effort_still_needs_an_exact_match() {
655        let matches = |fixed, effort| {
656            fixed_selectors_match(
657                "gpt-6-luna",
658                fixed,
659                &request_with(Some("gpt-6-luna"), effort),
660            )
661        };
662        assert!(matches(Some("max"), Some("max")));
663        assert!(!matches(Some("max"), Some("high")));
664        assert!(!matches(Some("max"), None));
665        assert!(matches(None, None));
666        assert!(!matches(None, Some("high")));
667    }
668
669    fn run_prepare_script(directory: &Path, exclude_in: Option<&Path>) -> String {
670        let argv = prepare_report_dir_argv(
671            &directory.to_string_lossy(),
672            exclude_in
673                .map(|path| path.to_string_lossy().into_owned())
674                .as_deref(),
675        );
676        let output = std::process::Command::new(&argv[0])
677            .args(&argv[1..])
678            .output()
679            .expect("run the report directory script");
680        assert!(
681            output.status.success(),
682            "{}",
683            String::from_utf8_lossy(&output.stderr)
684        );
685        String::from_utf8(output.stdout)
686            .unwrap()
687            .trim_end()
688            .to_owned()
689    }
690
691    /// A bare project's report root is inside the project, so the script
692    /// lists it in the repository's `info/exclude` exactly once and the
693    /// project's `git status` stays clean.
694    #[test]
695    fn the_report_directory_script_creates_the_directory_and_keeps_git_status_clean() {
696        let temp = tempfile::tempdir().unwrap();
697        let project = temp.path().join("project");
698        std::fs::create_dir_all(&project).unwrap();
699        let git = |args: &[&str]| {
700            let output = std::process::Command::new("git")
701                .arg("-C")
702                .arg(&project)
703                .args(args)
704                .output()
705                .unwrap();
706            assert!(output.status.success(), "{output:?}");
707            String::from_utf8(output.stdout).unwrap()
708        };
709        git(&["init", "-q"]);
710        // An exclude file without a trailing newline must not have its last
711        // line joined to the new one.
712        std::fs::write(project.join(".git/info/exclude"), "*.tmp").unwrap();
713        let root = project.join(mj_core::subagent::PROJECT_REPORT_ROOT_DIR);
714        let printed = run_prepare_script(&root, Some(&project));
715        assert_eq!(
716            Path::new(&printed),
717            root.canonicalize().unwrap(),
718            "the script prints the absolute directory"
719        );
720        run_prepare_script(&root, Some(&project));
721        std::fs::write(root.join("report.md"), "details").unwrap();
722        let exclude = std::fs::read_to_string(project.join(".git/info/exclude")).unwrap();
723        assert_eq!(exclude, "*.tmp\n/.mj/agents/\n");
724        assert_eq!(git(&["status", "--porcelain", "--ignored=no"]), "");
725
726        // Outside a repository there is no exclude to write.
727        let plain = temp.path().join("plain");
728        std::fs::create_dir_all(&plain).unwrap();
729        let reports = plain.join(".mj/agents/child");
730        run_prepare_script(&reports, Some(&plain));
731        assert!(reports.is_dir());
732    }
733
734    #[test]
735    fn a_report_root_is_under_the_workspace_or_inside_a_bare_project() {
736        let mut parent = super::super::test_support::checkpoint_test_session("parent-1");
737        let backend = mj_core::targets::TargetLocator::LocalBare {
738            worker_root: "/var/lib/hel/workers/parent-1".into(),
739        };
740        parent.project_directory = None;
741        let checkout = parent.checkout();
742        assert_eq!(
743            subagent_report_root(&parent, &backend, &checkout),
744            ("/var/lib/hel/workers/parent-1/.mj-agents".to_owned(), None)
745        );
746        parent.project_directory = Some("/home/dev/project/".into());
747        let checkout = parent.checkout();
748        assert_eq!(
749            subagent_report_root(&parent, &backend, &checkout),
750            (
751                "/home/dev/project/.mj/agents".to_owned(),
752                Some("/home/dev/project".to_owned())
753            )
754        );
755    }
756
757    // Hard-won: 5cea6d11569a: a container child inherited its parent's locator and was refused before starting.
758    #[test]
759    fn a_container_child_borrows_its_parents_container() {
760        use mj_core::state::{PodmanWorkspaceLocator, TargetLocator};
761
762        let parent_id = "0123456789abcdef0123456789abcdef";
763        let child_id = "fedcba9876543210fedcba9876543210";
764        let container = mj_core::targets::resource_name(parent_id).unwrap();
765
766        let local = borrowed_locator(
767            &TargetLocator::LocalPodman {
768                container_id: container.clone(),
769                workspace_storage: PodmanWorkspaceLocator::Volume {
770                    name: "parent-volume".to_owned(),
771                },
772                borrowed_from: None,
773            },
774            parent_id,
775            child_id,
776        )
777        .unwrap();
778        assert_eq!(
779            local,
780            TargetLocator::LocalPodman {
781                container_id: container.clone(),
782                workspace_storage: PodmanWorkspaceLocator::Volume {
783                    name: "parent-volume".to_owned(),
784                },
785                borrowed_from: Some(parent_id.to_owned()),
786            }
787        );
788
789        let remote = borrowed_locator(
790            &TargetLocator::SshPodman {
791                host: "builder".to_owned(),
792                container_id: container.clone(),
793                workspace_storage: PodmanWorkspaceLocator::ContainerLayer,
794                borrowed_from: None,
795            },
796            parent_id,
797            child_id,
798        )
799        .unwrap();
800        assert_eq!(
801            remote,
802            TargetLocator::SshPodman {
803                host: "builder".to_owned(),
804                container_id: container,
805                workspace_storage: PodmanWorkspaceLocator::ContainerLayer,
806                borrowed_from: Some(parent_id.to_owned()),
807            }
808        );
809    }
810
811    #[test]
812    fn a_parent_using_native_delegation_cannot_spawn_mjolnir_children() {
813        let parent = |choice: Option<bool>| {
814            let mut session = crate::controller::test_support::checkpoint_test_session("parent");
815            session.subagents = choice.map(|enabled| {
816                if enabled {
817                    mj_core::subagent::SubagentPolicy::AllModels
818                } else {
819                    mj_core::subagent::SubagentPolicy::Native
820                }
821            });
822            session
823        };
824
825        assert_eq!(
826            ensure_parent_may_delegate(&parent(Some(false)))
827                .unwrap_err()
828                .to_string(),
829            "this session does not allow Mjolnir sub-agents"
830        );
831        assert_eq!(
832            ensure_parent_may_delegate(&parent(None))
833                .unwrap_err()
834                .to_string(),
835            "this session does not allow Mjolnir sub-agents"
836        );
837        assert!(ensure_parent_may_delegate(&parent(Some(true))).is_ok());
838    }
839
840    /// #1161: idle children held the processes that exhausted their parent's
841    /// container, yet did not count against the cap. Every child that holds
842    /// processes counts now; a parked one does not. The refusal is what the
843    /// parent model reads, so it names the live children and how to free a
844    /// slot.
845    // Hard-won: 6927da2ba976: the live-child cap ignored idle workers until their shared container ran out of process slots.
846    #[test]
847    fn the_cap_counts_every_child_holding_processes_and_names_them() {
848        const MARKER: &str = "MJ_TEST_SUBAGENT_CAP_CHILD";
849        if std::env::var_os(MARKER).is_none() {
850            let directory = tempfile::tempdir().unwrap();
851            super::super::test_support::IsolatedTest::new(super::super::test_support::test_name(
852                module_path!(),
853                "the_cap_counts_every_child_holding_processes_and_names_them",
854            ))
855            .env(MARKER, "1")
856            .isolated_store(directory.path())
857            .run();
858            return;
859        }
860        let _writer = crate::database::install_isolated_test_writer();
861        let parent = super::super::test_support::checkpoint_test_session("parent-1");
862        crate::database::save_session(&parent).unwrap();
863        let children = [
864            ("aaaaaaaa-idle", "Audit the lockfile", SessionState::Running),
865            ("bbbbbbbb-busy", "Run the suite", SessionState::Running),
866            ("cccccccc-park", "Map the parser", SessionState::Parked),
867            ("dddddddd-done", "Old task", SessionState::Stopped),
868        ];
869        for (id, title, state) in children {
870            let mut child = super::super::test_support::checkpoint_test_session(id);
871            child.state = state;
872            child.session_title_override = Some(title.into());
873            crate::database::save_subagent_session(
874                &child,
875                &SubagentRecord {
876                    child_session_id: id.into(),
877                    parent_session_id: "parent-1".into(),
878                    task_name: title.into(),
879                    profile_id: "codex".into(),
880                    model: None,
881                    effort: None,
882                    working_directory: PathBuf::new(),
883                    initial_prompt: "do it".into(),
884                    request_key: format!("request-{id}"),
885                    created_at: "2026-09-25T00:00:00Z".into(),
886                    noticed_turn: None,
887                    reported_finish: None,
888                    handback_tool: true,
889                },
890            )
891            .unwrap();
892        }
893        let mut controller = Controller {
894            config: mj_core::config::Config::default(),
895            state: crate::database::load_state().unwrap(),
896        };
897        controller.config.subagents.max_concurrent = 2;
898
899        // Two live children fill a cap of two, idle or not.
900        let refusal = controller
901            .ensure_subagent_slot_available("parent-1", None)
902            .unwrap_err()
903            .to_string();
904        assert!(
905            refusal.contains("already has 2 live sub-agents and the maximum is 2"),
906            "{refusal}"
907        );
908        assert!(
909            refusal.contains("aaaaaaaa \"Audit the lockfile\"")
910                && refusal.contains("bbbbbbbb \"Run the suite\""),
911            "the refusal names every live child: {refusal}"
912        );
913        assert!(
914            !refusal.contains("Map the parser") && !refusal.contains("Old task"),
915            "parked and stopped children hold no processes: {refusal}"
916        );
917        assert!(
918            refusal.contains("hands back")
919                && refusal.contains("parked")
920                && refusal.contains("close it"),
921            "the refusal says how a slot frees up: {refusal}"
922        );
923        // Starting the parked child again is refused the same way.
924        assert!(
925            controller
926                .ensure_subagent_slot_available("parent-1", Some("cccccccc-park"))
927                .is_err()
928        );
929        // Input to a live child starts nothing, so the cap never refuses it.
930        controller
931            .ensure_subagent_slot_available("parent-1", Some("aaaaaaaa-idle"))
932            .unwrap();
933
934        // Once one live child is parked, a spawn and a restart both fit.
935        controller
936            .state
937            .sessions
938            .get_mut("aaaaaaaa-idle")
939            .unwrap()
940            .state = SessionState::Parked;
941        controller
942            .ensure_subagent_slot_available("parent-1", None)
943            .unwrap();
944        controller
945            .ensure_subagent_slot_available("parent-1", Some("cccccccc-park"))
946            .unwrap();
947    }
948}