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, self.config.subagents.enabled)?;
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            // A child never receives the Mjolnir sub-agent tools, so it can
121            // never spawn a grandchild.
122            mjolnir_subagents: Some(false),
123            create_managed_worktree: Some(false),
124            archived: false,
125            container_cpus: None,
126            container_memory: None,
127            id: child_id.clone(),
128            workspace_id: parent.workspace_id.clone(),
129            title: request.task_name.clone(),
130            harness_kind: profile.kind,
131            last_profile: request.profile_id.clone(),
132            bundle_id: parent.bundle_id.clone(),
133            project_directory: parent.project_directory.clone(),
134            managed_worktree: None,
135            target_template_id: parent.target_template_id.clone(),
136            resource_allocation: parent.resource_allocation.clone(),
137            additional_mounts: parent.additional_mounts.clone(),
138            state: SessionState::Provisioning,
139            target: Some(target),
140            native_session_id: None,
141            acp_session_title: None,
142            session_title_override: Some(request.task_name.clone()),
143            created_at: created_at.clone(),
144            updated_at: created_at.clone(),
145            viewed_through_event_ordinal: 0,
146            draft_input: String::new(),
147            last_error: None,
148            last_checkpoint_error: None,
149            checkpoint: None,
150        };
151        let relation = SubagentRecord {
152            child_session_id: child_id.clone(),
153            parent_session_id: parent.id,
154            task_name: request.task_name,
155            profile_id: request.profile_id,
156            model: request.model,
157            effort: request.effort,
158            working_directory: request.working_directory,
159            initial_prompt: request.initial_prompt,
160            request_key: request.request_key,
161            created_at,
162            noticed_turn: None,
163        };
164        crate::database::save_subagent_session(&session, &relation)?;
165        self.state.sessions.insert(child_id, session);
166        self.state
167            .subagents
168            .insert(relation.child_session_id.clone(), relation.clone());
169        Ok(relation)
170    }
171
172    pub fn ensure_subagent_slot_available(
173        &self,
174        parent_session_id: &str,
175        child_id: &str,
176    ) -> Result<()> {
177        let occupied = crate::database::list_subagents(parent_session_id)?
178            .into_iter()
179            .filter(|child| child.child_session_id != child_id)
180            .filter(|child| {
181                self.subagent_occupies_slot(&child.child_session_id)
182                    .unwrap_or(true)
183            })
184            .count();
185        ensure!(
186            occupied < self.config.subagents.max_concurrent,
187            "parent session already has the maximum {} active sub-agents",
188            self.config.subagents.max_concurrent
189        );
190        Ok(())
191    }
192
193    fn subagent_occupies_slot(&self, child_id: &str) -> Result<bool> {
194        let Some(session) = self.state.sessions.get(child_id) else {
195            return Ok(false);
196        };
197        if matches!(
198            session.state,
199            SessionState::Provisioning | SessionState::Closing | SessionState::Checkpointing
200        ) {
201            return Ok(true);
202        }
203        if !session.state.is_active() {
204            return Ok(false);
205        }
206        Ok(
207            crate::database::load_materialized_session_summary(child_id)?.is_none_or(|summary| {
208                !matches!(
209                    summary.execution,
210                    mj_core::state::MaterializedExecutionState::Idle
211                )
212            }),
213        )
214    }
215}
216
217fn sibling_path(path: &Path, parent_id: &str, child_id: &str) -> Result<PathBuf> {
218    ensure!(
219        path.ends_with(parent_id),
220        "parent target path does not end in its session id"
221    );
222    Ok(path
223        .parent()
224        .context("parent target path has no parent")?
225        .join(child_id))
226}
227
228fn borrowed_locator(
229    target: &mj_core::state::TargetLocator,
230    parent_id: &str,
231    child_id: &str,
232) -> Result<mj_core::state::TargetLocator> {
233    use mj_core::state::TargetLocator;
234    Ok(match target {
235        TargetLocator::LocalBare { worker_root } => TargetLocator::LocalBare {
236            worker_root: sibling_path(worker_root, parent_id, child_id)?,
237        },
238        TargetLocator::SshBare {
239            host,
240            workspace,
241            worker_id: _,
242        } => TargetLocator::SshBare {
243            host: host.clone(),
244            workspace: workspace.clone(),
245            worker_id: Some(child_id.to_owned()),
246        },
247        other => other.clone(),
248    })
249}
250
251/// A parent may delegate to Mjolnir children only if its own stored choice
252/// says so; `None` follows the global `[subagents] enabled` setting. A parent
253/// using its harness's native delegation never received the Mjolnir tools, so
254/// a request from it is stale.
255fn ensure_parent_may_delegate(parent: &SessionRecord, global_enabled: bool) -> Result<()> {
256    match parent.mjolnir_subagents {
257        Some(false) => bail!("this session uses native sub-agents"),
258        None if !global_enabled => bail!("sub-agents are disabled"),
259        _ => Ok(()),
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn a_parent_using_native_delegation_cannot_spawn_mjolnir_children() {
269        let parent = |choice| {
270            let mut session = crate::controller::test_support::checkpoint_test_session("parent");
271            session.mjolnir_subagents = choice;
272            session
273        };
274
275        assert_eq!(
276            ensure_parent_may_delegate(&parent(Some(false)), true)
277                .unwrap_err()
278                .to_string(),
279            "this session uses native sub-agents"
280        );
281        assert_eq!(
282            ensure_parent_may_delegate(&parent(None), false)
283                .unwrap_err()
284                .to_string(),
285            "sub-agents are disabled"
286        );
287        // An explicit opt-in outlives the global setting being turned off.
288        assert!(ensure_parent_may_delegate(&parent(Some(true)), false).is_ok());
289        assert!(ensure_parent_may_delegate(&parent(None), true).is_ok());
290    }
291
292    #[test]
293    fn borrowed_bare_locator_gets_a_private_worker_identity() {
294        let locator = mj_core::state::TargetLocator::LocalBare {
295            worker_root: PathBuf::from("/workers/parent"),
296        };
297        assert_eq!(
298            borrowed_locator(&locator, "parent", "child").unwrap(),
299            mj_core::state::TargetLocator::LocalBare {
300                worker_root: PathBuf::from("/workers/child")
301            }
302        );
303    }
304
305    #[test]
306    fn borrowed_ssh_locator_keeps_parent_workspace_with_private_worker_identity() {
307        let locator = mj_core::state::TargetLocator::SshBare {
308            host: "builder".into(),
309            workspace: PathBuf::from(".local/share/hel/workspaces/parent-session"),
310            worker_id: None,
311        };
312        assert_eq!(
313            borrowed_locator(&locator, "parent-session", "child-session").unwrap(),
314            mj_core::state::TargetLocator::SshBare {
315                host: "builder".into(),
316                workspace: PathBuf::from(".local/share/hel/workspaces/parent-session"),
317                worker_id: Some("child-session".into()),
318            }
319        );
320    }
321}