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::{Component, 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; otherwise this is relative.
20    pub working_directory: PathBuf,
21    pub initial_prompt: String,
22    pub request_key: String,
23}
24
25impl Controller {
26    /// Register a child without provisioning another target or checkout.
27    pub fn register_subagent(
28        &mut self,
29        request: RegisterSubagentRequest,
30    ) -> Result<SubagentRecord> {
31        if let Some(existing) = crate::database::lookup_subagent_request(
32            &request.parent_session_id,
33            &request.request_key,
34        )? {
35            return Ok(existing);
36        }
37        ensure!(
38            !request.request_key.trim().is_empty(),
39            "sub-agent request key cannot be empty"
40        );
41        ensure!(
42            !request.task_name.trim().is_empty(),
43            "sub-agent task name cannot be empty"
44        );
45        ensure!(
46            !request.initial_prompt.trim().is_empty(),
47            "sub-agent instructions cannot be empty"
48        );
49        ensure_safe_relative_directory(&request.working_directory)?;
50
51        let parent = self
52            .state
53            .sessions
54            .get(&request.parent_session_id)
55            .with_context(|| format!("unknown parent session {}", request.parent_session_id))?
56            .clone();
57        ensure!(
58            matches!(
59                parent.harness_kind,
60                HarnessKind::Claude | HarnessKind::Codex
61            ),
62            "only Claude and Codex sessions can spawn sub-agents"
63        );
64        ensure_parent_may_delegate(&parent, self.config.subagents.enabled)?;
65        ensure!(parent.state.is_active(), "parent session is not active");
66        ensure!(parent.target.is_some(), "parent session has no live target");
67        ensure!(
68            crate::database::load_subagent(&parent.id)?.is_none(),
69            "sub-agents cannot spawn other sub-agents"
70        );
71        ensure!(
72            self.config
73                .subagents
74                .profile_is_eligible(&parent.last_profile, &request.profile_id),
75            "profile {:?} is not eligible for sub-agent use",
76            request.profile_id
77        );
78        let profile = self
79            .config
80            .enabled_profile(&request.profile_id)
81            .with_context(|| {
82                format!("sub-agent profile {:?} is unavailable", request.profile_id)
83            })?;
84        if matches!(profile.kind, HarnessKind::Deepseek | HarnessKind::Muse) {
85            let multiple_roots = !parent.additional_mounts.is_empty()
86                || (parent.project_directory.is_none()
87                    && self
88                        .config
89                        .bundles
90                        .get(&parent.bundle_id)
91                        .is_some_and(|bundle| bundle.repositories.len() > 1));
92            ensure!(
93                !multiple_roots,
94                "{} ACP supports one workspace root; this parent exposes multiple roots",
95                profile.kind.display_name()
96            );
97        }
98        let occupied = crate::database::list_subagents(&parent.id)?
99            .into_iter()
100            .filter(|child| {
101                self.subagent_occupies_slot(&child.child_session_id)
102                    .unwrap_or(true)
103            })
104            .count();
105        ensure!(
106            occupied < self.config.subagents.max_concurrent,
107            "parent session already has the maximum {} active sub-agents",
108            self.config.subagents.max_concurrent
109        );
110
111        let child_id = new_session_id()?;
112        let target = borrowed_locator(
113            parent.target.as_ref().expect("live target checked above"),
114            &parent.id,
115            &child_id,
116        )?;
117        let created_at = now();
118        let session = SessionRecord {
119            // A child never receives the Mjolnir sub-agent tools, so it can
120            // never spawn a grandchild.
121            mjolnir_subagents: Some(false),
122            create_managed_worktree: Some(false),
123            archived: false,
124            container_cpus: None,
125            container_memory: None,
126            id: child_id.clone(),
127            workspace_id: parent.workspace_id.clone(),
128            title: request.task_name.clone(),
129            harness_kind: profile.kind,
130            last_profile: request.profile_id.clone(),
131            bundle_id: parent.bundle_id.clone(),
132            project_directory: parent.project_directory.clone(),
133            managed_worktree: None,
134            target_template_id: parent.target_template_id.clone(),
135            resource_allocation: parent.resource_allocation.clone(),
136            additional_mounts: parent.additional_mounts.clone(),
137            state: SessionState::Provisioning,
138            target: Some(target),
139            native_session_id: None,
140            acp_session_title: None,
141            session_title_override: Some(request.task_name.clone()),
142            created_at: created_at.clone(),
143            updated_at: created_at.clone(),
144            viewed_through_event_ordinal: 0,
145            draft_input: String::new(),
146            last_error: None,
147            last_checkpoint_error: None,
148            checkpoint: None,
149        };
150        let relation = SubagentRecord {
151            child_session_id: child_id.clone(),
152            parent_session_id: parent.id,
153            task_name: request.task_name,
154            profile_id: request.profile_id,
155            model: request.model,
156            effort: request.effort,
157            working_directory: request.working_directory,
158            initial_prompt: request.initial_prompt,
159            request_key: request.request_key,
160            created_at,
161            delivered_turn: None,
162        };
163        crate::database::save_subagent_session(&session, &relation)?;
164        self.state.sessions.insert(child_id, session);
165        self.state
166            .subagents
167            .insert(relation.child_session_id.clone(), relation.clone());
168        Ok(relation)
169    }
170
171    pub fn ensure_subagent_slot_available(
172        &self,
173        parent_session_id: &str,
174        child_id: &str,
175    ) -> Result<()> {
176        let occupied = crate::database::list_subagents(parent_session_id)?
177            .into_iter()
178            .filter(|child| child.child_session_id != child_id)
179            .filter(|child| {
180                self.subagent_occupies_slot(&child.child_session_id)
181                    .unwrap_or(true)
182            })
183            .count();
184        ensure!(
185            occupied < self.config.subagents.max_concurrent,
186            "parent session already has the maximum {} active sub-agents",
187            self.config.subagents.max_concurrent
188        );
189        Ok(())
190    }
191
192    fn subagent_occupies_slot(&self, child_id: &str) -> Result<bool> {
193        let Some(session) = self.state.sessions.get(child_id) else {
194            return Ok(false);
195        };
196        if matches!(
197            session.state,
198            SessionState::Provisioning | SessionState::Closing | SessionState::Checkpointing
199        ) {
200            return Ok(true);
201        }
202        if !session.state.is_active() {
203            return Ok(false);
204        }
205        Ok(
206            crate::database::load_materialized_session_summary(child_id)?.is_none_or(|summary| {
207                !matches!(
208                    summary.execution,
209                    mj_core::state::MaterializedExecutionState::Idle
210                )
211            }),
212        )
213    }
214}
215
216fn ensure_safe_relative_directory(path: &Path) -> Result<()> {
217    if path.is_absolute() || path.components().any(|part| part == Component::ParentDir) {
218        bail!("sub-agent working directory must be relative to the parent's workspace");
219    }
220    Ok(())
221}
222
223fn sibling_path(path: &Path, parent_id: &str, child_id: &str) -> Result<PathBuf> {
224    ensure!(
225        path.ends_with(parent_id),
226        "parent target path does not end in its session id"
227    );
228    Ok(path
229        .parent()
230        .context("parent target path has no parent")?
231        .join(child_id))
232}
233
234fn borrowed_locator(
235    target: &mj_core::state::TargetLocator,
236    parent_id: &str,
237    child_id: &str,
238) -> Result<mj_core::state::TargetLocator> {
239    use mj_core::state::TargetLocator;
240    Ok(match target {
241        TargetLocator::LocalBare { worker_root } => TargetLocator::LocalBare {
242            worker_root: sibling_path(worker_root, parent_id, child_id)?,
243        },
244        TargetLocator::SshBare {
245            host,
246            workspace,
247            worker_id: _,
248        } => TargetLocator::SshBare {
249            host: host.clone(),
250            workspace: workspace.clone(),
251            worker_id: Some(child_id.to_owned()),
252        },
253        other => other.clone(),
254    })
255}
256
257/// A parent may delegate to Mjolnir children only if its own stored choice
258/// says so; `None` follows the global `[subagents] enabled` setting. A parent
259/// using its harness's native delegation never received the Mjolnir tools, so
260/// a request from it is stale.
261fn ensure_parent_may_delegate(parent: &SessionRecord, global_enabled: bool) -> Result<()> {
262    match parent.mjolnir_subagents {
263        Some(false) => bail!("this session uses native sub-agents"),
264        None if !global_enabled => bail!("sub-agents are disabled"),
265        _ => Ok(()),
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn a_parent_using_native_delegation_cannot_spawn_mjolnir_children() {
275        let parent = |choice| {
276            let mut session = crate::controller::test_support::checkpoint_test_session("parent");
277            session.mjolnir_subagents = choice;
278            session
279        };
280
281        assert_eq!(
282            ensure_parent_may_delegate(&parent(Some(false)), true)
283                .unwrap_err()
284                .to_string(),
285            "this session uses native sub-agents"
286        );
287        assert_eq!(
288            ensure_parent_may_delegate(&parent(None), false)
289                .unwrap_err()
290                .to_string(),
291            "sub-agents are disabled"
292        );
293        // An explicit opt-in outlives the global setting being turned off.
294        assert!(ensure_parent_may_delegate(&parent(Some(true)), false).is_ok());
295        assert!(ensure_parent_may_delegate(&parent(None), true).is_ok());
296    }
297
298    #[test]
299    fn borrowed_bare_locator_gets_a_private_worker_identity() {
300        let locator = mj_core::state::TargetLocator::LocalBare {
301            worker_root: PathBuf::from("/workers/parent"),
302        };
303        assert_eq!(
304            borrowed_locator(&locator, "parent", "child").unwrap(),
305            mj_core::state::TargetLocator::LocalBare {
306                worker_root: PathBuf::from("/workers/child")
307            }
308        );
309    }
310
311    #[test]
312    fn borrowed_ssh_locator_keeps_parent_workspace_with_private_worker_identity() {
313        let locator = mj_core::state::TargetLocator::SshBare {
314            host: "builder".into(),
315            workspace: PathBuf::from(".local/share/hel/workspaces/parent-session"),
316            worker_id: None,
317        };
318        assert_eq!(
319            borrowed_locator(&locator, "parent-session", "child-session").unwrap(),
320            mj_core::state::TargetLocator::SshBare {
321                host: "builder".into(),
322                workspace: PathBuf::from(".local/share/hel/workspaces/parent-session"),
323                worker_id: Some("child-session".into()),
324            }
325        );
326    }
327}