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}
27
28impl Controller {
29    /// Register a child without provisioning another target or checkout.
30    pub fn register_subagent(
31        &mut self,
32        request: RegisterSubagentRequest,
33    ) -> Result<SubagentRecord> {
34        if let Some(existing) = crate::database::lookup_subagent_request(
35            &request.parent_session_id,
36            &request.request_key,
37        )? {
38            return Ok(existing);
39        }
40        ensure!(
41            !request.request_key.trim().is_empty(),
42            "sub-agent request key cannot be empty"
43        );
44        ensure!(
45            !request.task_name.trim().is_empty(),
46            "sub-agent task name cannot be empty"
47        );
48        ensure!(
49            !request.initial_prompt.trim().is_empty(),
50            "sub-agent instructions cannot be empty"
51        );
52        let parent = self
53            .state
54            .sessions
55            .get(&request.parent_session_id)
56            .with_context(|| format!("unknown parent session {}", request.parent_session_id))?
57            .clone();
58        ensure!(
59            matches!(
60                parent.harness_kind,
61                HarnessKind::Claude | HarnessKind::Codex
62            ),
63            "only Claude and Codex sessions can spawn sub-agents"
64        );
65        ensure_parent_may_delegate(&parent)?;
66        ensure!(parent.state.is_active(), "parent session is not active");
67        ensure!(parent.target.is_some(), "parent session has no live target");
68        ensure!(
69            crate::database::load_subagent(&parent.id)?.is_none(),
70            "sub-agents cannot spawn other sub-agents"
71        );
72        ensure!(
73            self.config
74                .subagents
75                .profile_is_eligible(&parent.last_profile, &request.profile_id),
76            "profile {:?} is not eligible for sub-agent use",
77            request.profile_id
78        );
79        let profile = self
80            .config
81            .enabled_profile(&request.profile_id)
82            .with_context(|| {
83                format!("sub-agent profile {:?} is unavailable", request.profile_id)
84            })?;
85        if profile.kind == HarnessKind::Muse {
86            let multiple_roots = !parent.additional_mounts.is_empty()
87                || (parent.project_directory.is_none()
88                    && self
89                        .config
90                        .bundles
91                        .get(&parent.bundle_id)
92                        .is_some_and(|bundle| bundle.repositories.len() > 1));
93            ensure!(
94                !multiple_roots,
95                "{} ACP supports one workspace root; this parent exposes multiple roots",
96                profile.kind.display_name()
97            );
98        }
99        let occupied = crate::database::list_subagents(&parent.id)?
100            .into_iter()
101            .filter(|child| {
102                self.subagent_occupies_slot(&child.child_session_id)
103                    .unwrap_or(true)
104            })
105            .count();
106        ensure!(
107            occupied < self.config.subagents.max_concurrent,
108            "parent session already has the maximum {} active sub-agents",
109            self.config.subagents.max_concurrent
110        );
111
112        let child_id = new_session_id()?;
113        let target = borrowed_locator(
114            parent.target.as_ref().expect("live target checked above"),
115            &parent.id,
116            &child_id,
117        )?;
118        let created_at = now();
119        let session = SessionRecord {
120            target_runtime: Some(parent.target_runtime_settings(&self.config)?.into_owned()),
121            launch_base: None,
122            launch_branch: None,
123            publication: None,
124            // A child shares its parent's container, so it shares the build
125            // cache that container was created with.
126            build_cache: parent.build_cache.clone(),
127            // A child never receives the Mjolnir sub-agent tools, so it can
128            // never spawn a grandchild.
129            mjolnir_subagents: Some(false),
130            create_managed_worktree: Some(false),
131            archived: false,
132            container_cpus: None,
133            container_memory: None,
134            // A child runs inside its parent's container, so it works in the
135            // parent's workspace, including the legacy shared one.
136            container_workspace: parent.container_workspace.clone(),
137            id: child_id.clone(),
138            workspace_id: parent.workspace_id.clone(),
139            title: request.task_name.clone(),
140            harness_kind: profile.kind,
141            last_profile: request.profile_id.clone(),
142            bundle_id: parent.bundle_id.clone(),
143            project_directory: parent.project_directory.clone(),
144            managed_worktree: None,
145            target_template_id: parent.target_template_id.clone(),
146            resource_allocation: parent.resource_allocation.clone(),
147            additional_mounts: parent.additional_mounts.clone(),
148            state: SessionState::Provisioning,
149            target: Some(target),
150            native_session_id: None,
151            acp_session_title: None,
152            session_title_override: Some(request.task_name.clone()),
153            created_at: created_at.clone(),
154            updated_at: created_at.clone(),
155            viewed_through_event_ordinal: 0,
156            draft_input: String::new(),
157            last_error: None,
158            last_checkpoint_error: None,
159            checkpoint: None,
160        };
161        let handback_tool = child_gets_handback_tool(
162            profile,
163            &child_id,
164            &super::backend::backend_locator(
165                session.target.as_ref().expect("child target set above"),
166                &session,
167                &self.config,
168            )?,
169        );
170        // The first prompt names the tool only when the child will have it.
171        let initial_prompt = if handback_tool {
172            format!(
173                "{}\n\n{}",
174                mj_core::subagent::HANDBACK_PROMPT_NOTE,
175                request.initial_prompt
176            )
177        } else {
178            request.initial_prompt
179        };
180        let relation = SubagentRecord {
181            child_session_id: child_id.clone(),
182            parent_session_id: parent.id,
183            task_name: request.task_name,
184            profile_id: request.profile_id,
185            model: request.model,
186            effort: request.effort,
187            working_directory: request.working_directory,
188            initial_prompt,
189            request_key: request.request_key,
190            created_at,
191            noticed_turn: None,
192            handback_tool,
193        };
194        crate::database::save_subagent_session(&session, &relation)?;
195        self.state.sessions.insert(child_id, session);
196        self.state
197            .subagents
198            .insert(relation.child_session_id.clone(), relation.clone());
199        Ok(relation)
200    }
201
202    pub fn ensure_subagent_slot_available(
203        &self,
204        parent_session_id: &str,
205        child_id: &str,
206    ) -> Result<()> {
207        let occupied = crate::database::list_subagents(parent_session_id)?
208            .into_iter()
209            .filter(|child| child.child_session_id != child_id)
210            .filter(|child| {
211                self.subagent_occupies_slot(&child.child_session_id)
212                    .unwrap_or(true)
213            })
214            .count();
215        ensure!(
216            occupied < self.config.subagents.max_concurrent,
217            "parent session already has the maximum {} active sub-agents",
218            self.config.subagents.max_concurrent
219        );
220        Ok(())
221    }
222
223    fn subagent_occupies_slot(&self, child_id: &str) -> Result<bool> {
224        let Some(session) = self.state.sessions.get(child_id) else {
225            return Ok(false);
226        };
227        if matches!(
228            session.state,
229            SessionState::Provisioning | SessionState::Closing | SessionState::Checkpointing
230        ) {
231            return Ok(true);
232        }
233        if !session.state.is_active() {
234            return Ok(false);
235        }
236        Ok(
237            crate::database::load_materialized_session_summary(child_id)?.is_none_or(|summary| {
238                !matches!(
239                    summary.execution,
240                    mj_core::state::MaterializedExecutionState::Idle
241                )
242            }),
243        )
244    }
245}
246
247fn sibling_path(path: &Path, parent_id: &str, child_id: &str) -> Result<PathBuf> {
248    ensure!(
249        path.ends_with(parent_id),
250        "parent target path does not end in its session id"
251    );
252    Ok(path
253        .parent()
254        .context("parent target path has no parent")?
255        .join(child_id))
256}
257
258fn borrowed_locator(
259    target: &mj_core::state::TargetLocator,
260    parent_id: &str,
261    child_id: &str,
262) -> Result<mj_core::state::TargetLocator> {
263    use mj_core::state::TargetLocator;
264    Ok(match target {
265        TargetLocator::LocalBare { worker_root } => TargetLocator::LocalBare {
266            worker_root: sibling_path(worker_root, parent_id, child_id)?,
267        },
268        TargetLocator::SshBare {
269            host,
270            workspace,
271            worker_id: _,
272        } => TargetLocator::SshBare {
273            host: host.clone(),
274            workspace: workspace.clone(),
275            worker_id: Some(child_id.to_owned()),
276        },
277        // A container child runs its own worker inside the parent's container
278        // and records the parent as the container's owner, so cleanup and
279        // whole-target operations stay with the parent.
280        TargetLocator::LocalPodman {
281            container_id,
282            workspace_storage,
283            ..
284        } => TargetLocator::LocalPodman {
285            container_id: container_id.clone(),
286            workspace_storage: workspace_storage.clone(),
287            borrowed_from: Some(parent_id.to_owned()),
288        },
289        TargetLocator::LocalDocker { container_id, .. } => TargetLocator::LocalDocker {
290            container_id: container_id.clone(),
291            borrowed_from: Some(parent_id.to_owned()),
292        },
293        TargetLocator::AppleContainer { container_id, .. } => TargetLocator::AppleContainer {
294            container_id: container_id.clone(),
295            borrowed_from: Some(parent_id.to_owned()),
296        },
297        TargetLocator::SshPodman {
298            host,
299            container_id,
300            workspace_storage,
301            ..
302        } => TargetLocator::SshPodman {
303            host: host.clone(),
304            container_id: container_id.clone(),
305            workspace_storage: workspace_storage.clone(),
306            borrowed_from: Some(parent_id.to_owned()),
307        },
308        TargetLocator::SshDocker {
309            host, container_id, ..
310        } => TargetLocator::SshDocker {
311            host: host.clone(),
312            container_id: container_id.clone(),
313            borrowed_from: Some(parent_id.to_owned()),
314        },
315        // EC2 children keep the parent's locator unchanged, as they did before
316        // container borrowing was recorded.
317        other @ TargetLocator::AwsEc2 { .. } => other.clone(),
318    })
319}
320
321/// Whether a child can be given the `handback` tool. Codex takes Mjolnir's MCP
322/// servers over ACP; Claude reads them from a staged profile, so a Claude child
323/// needs a harness home of its own. Other harnesses keep reporting through
324/// their last message.
325fn child_gets_handback_tool(
326    profile: &mj_core::config::HarnessProfile,
327    child_id: &str,
328    locator: &crate::targets::TargetLocator,
329) -> bool {
330    match profile.kind {
331        HarnessKind::Codex => true,
332        HarnessKind::Claude => {
333            crate::controller::session_owns_profile_home(locator, child_id, profile)
334        }
335        _ => false,
336    }
337}
338
339/// A parent may delegate to Mjolnir children only if its own stored choice
340/// says so; `None` means native sub-agents, same as `Some(false)`. A parent
341/// using its harness's native delegation never received the Mjolnir tools, so
342/// a request from it is stale.
343fn ensure_parent_may_delegate(parent: &SessionRecord) -> Result<()> {
344    match parent.mjolnir_subagents {
345        Some(true) => Ok(()),
346        _ => bail!("this session uses native sub-agents"),
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn a_container_child_borrows_its_parents_container() {
356        use mj_core::state::{PodmanWorkspaceLocator, TargetLocator};
357
358        let parent_id = "0123456789abcdef0123456789abcdef";
359        let child_id = "fedcba9876543210fedcba9876543210";
360        let container = mj_core::targets::resource_name(parent_id).unwrap();
361
362        let local = borrowed_locator(
363            &TargetLocator::LocalPodman {
364                container_id: container.clone(),
365                workspace_storage: PodmanWorkspaceLocator::Volume {
366                    name: "parent-volume".to_owned(),
367                },
368                borrowed_from: None,
369            },
370            parent_id,
371            child_id,
372        )
373        .unwrap();
374        assert_eq!(
375            local,
376            TargetLocator::LocalPodman {
377                container_id: container.clone(),
378                workspace_storage: PodmanWorkspaceLocator::Volume {
379                    name: "parent-volume".to_owned(),
380                },
381                borrowed_from: Some(parent_id.to_owned()),
382            }
383        );
384
385        let remote = borrowed_locator(
386            &TargetLocator::SshPodman {
387                host: "builder".to_owned(),
388                container_id: container.clone(),
389                workspace_storage: PodmanWorkspaceLocator::ContainerLayer,
390                borrowed_from: None,
391            },
392            parent_id,
393            child_id,
394        )
395        .unwrap();
396        assert_eq!(
397            remote,
398            TargetLocator::SshPodman {
399                host: "builder".to_owned(),
400                container_id: container,
401                workspace_storage: PodmanWorkspaceLocator::ContainerLayer,
402                borrowed_from: Some(parent_id.to_owned()),
403            }
404        );
405    }
406
407    #[test]
408    fn a_parent_using_native_delegation_cannot_spawn_mjolnir_children() {
409        let parent = |choice| {
410            let mut session = crate::controller::test_support::checkpoint_test_session("parent");
411            session.mjolnir_subagents = choice;
412            session
413        };
414
415        assert_eq!(
416            ensure_parent_may_delegate(&parent(Some(false)))
417                .unwrap_err()
418                .to_string(),
419            "this session uses native sub-agents"
420        );
421        assert_eq!(
422            ensure_parent_may_delegate(&parent(None))
423                .unwrap_err()
424                .to_string(),
425            "this session uses native sub-agents"
426        );
427        assert!(ensure_parent_may_delegate(&parent(Some(true))).is_ok());
428    }
429
430    #[test]
431    fn borrowed_bare_locator_gets_a_private_worker_identity() {
432        let locator = mj_core::state::TargetLocator::LocalBare {
433            worker_root: PathBuf::from("/workers/parent"),
434        };
435        assert_eq!(
436            borrowed_locator(&locator, "parent", "child").unwrap(),
437            mj_core::state::TargetLocator::LocalBare {
438                worker_root: PathBuf::from("/workers/child")
439            }
440        );
441    }
442
443    #[test]
444    fn borrowed_ssh_locator_keeps_parent_workspace_with_private_worker_identity() {
445        let locator = mj_core::state::TargetLocator::SshBare {
446            host: "builder".into(),
447            workspace: PathBuf::from(".local/share/hel/workspaces/parent-session"),
448            worker_id: None,
449        };
450        assert_eq!(
451            borrowed_locator(&locator, "parent-session", "child-session").unwrap(),
452            mj_core::state::TargetLocator::SshBare {
453                host: "builder".into(),
454                workspace: PathBuf::from(".local/share/hel/workspaces/parent-session"),
455                worker_id: Some("child-session".into()),
456            }
457        );
458    }
459}