mj_controller/controller/
subagents.rs1use 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 pub working_directory: PathBuf,
21 pub initial_prompt: String,
22 pub request_key: String,
23}
24
25impl Controller {
26 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 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
257fn 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 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}