1use 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 pub working_directory: PathBuf,
24 pub initial_prompt: String,
25 pub request_key: String,
26}
27
28impl Controller {
29 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 launch_base: None,
121 build_cache: parent.build_cache.clone(),
124 mjolnir_subagents: Some(false),
127 create_managed_worktree: Some(false),
128 archived: false,
129 container_cpus: None,
130 container_memory: None,
131 container_workspace: parent.container_workspace.clone(),
134 id: child_id.clone(),
135 workspace_id: parent.workspace_id.clone(),
136 title: request.task_name.clone(),
137 harness_kind: profile.kind,
138 last_profile: request.profile_id.clone(),
139 bundle_id: parent.bundle_id.clone(),
140 project_directory: parent.project_directory.clone(),
141 managed_worktree: None,
142 target_template_id: parent.target_template_id.clone(),
143 resource_allocation: parent.resource_allocation.clone(),
144 additional_mounts: parent.additional_mounts.clone(),
145 state: SessionState::Provisioning,
146 target: Some(target),
147 native_session_id: None,
148 acp_session_title: None,
149 session_title_override: Some(request.task_name.clone()),
150 created_at: created_at.clone(),
151 updated_at: created_at.clone(),
152 viewed_through_event_ordinal: 0,
153 draft_input: String::new(),
154 last_error: None,
155 last_checkpoint_error: None,
156 checkpoint: None,
157 };
158 let relation = SubagentRecord {
159 child_session_id: child_id.clone(),
160 parent_session_id: parent.id,
161 task_name: request.task_name,
162 profile_id: request.profile_id,
163 model: request.model,
164 effort: request.effort,
165 working_directory: request.working_directory,
166 initial_prompt: request.initial_prompt,
167 request_key: request.request_key,
168 created_at,
169 noticed_turn: None,
170 };
171 crate::database::save_subagent_session(&session, &relation)?;
172 self.state.sessions.insert(child_id, session);
173 self.state
174 .subagents
175 .insert(relation.child_session_id.clone(), relation.clone());
176 Ok(relation)
177 }
178
179 pub fn ensure_subagent_slot_available(
180 &self,
181 parent_session_id: &str,
182 child_id: &str,
183 ) -> Result<()> {
184 let occupied = crate::database::list_subagents(parent_session_id)?
185 .into_iter()
186 .filter(|child| child.child_session_id != child_id)
187 .filter(|child| {
188 self.subagent_occupies_slot(&child.child_session_id)
189 .unwrap_or(true)
190 })
191 .count();
192 ensure!(
193 occupied < self.config.subagents.max_concurrent,
194 "parent session already has the maximum {} active sub-agents",
195 self.config.subagents.max_concurrent
196 );
197 Ok(())
198 }
199
200 fn subagent_occupies_slot(&self, child_id: &str) -> Result<bool> {
201 let Some(session) = self.state.sessions.get(child_id) else {
202 return Ok(false);
203 };
204 if matches!(
205 session.state,
206 SessionState::Provisioning | SessionState::Closing | SessionState::Checkpointing
207 ) {
208 return Ok(true);
209 }
210 if !session.state.is_active() {
211 return Ok(false);
212 }
213 Ok(
214 crate::database::load_materialized_session_summary(child_id)?.is_none_or(|summary| {
215 !matches!(
216 summary.execution,
217 mj_core::state::MaterializedExecutionState::Idle
218 )
219 }),
220 )
221 }
222}
223
224fn sibling_path(path: &Path, parent_id: &str, child_id: &str) -> Result<PathBuf> {
225 ensure!(
226 path.ends_with(parent_id),
227 "parent target path does not end in its session id"
228 );
229 Ok(path
230 .parent()
231 .context("parent target path has no parent")?
232 .join(child_id))
233}
234
235fn borrowed_locator(
236 target: &mj_core::state::TargetLocator,
237 parent_id: &str,
238 child_id: &str,
239) -> Result<mj_core::state::TargetLocator> {
240 use mj_core::state::TargetLocator;
241 Ok(match target {
242 TargetLocator::LocalBare { worker_root } => TargetLocator::LocalBare {
243 worker_root: sibling_path(worker_root, parent_id, child_id)?,
244 },
245 TargetLocator::SshBare {
246 host,
247 workspace,
248 worker_id: _,
249 } => TargetLocator::SshBare {
250 host: host.clone(),
251 workspace: workspace.clone(),
252 worker_id: Some(child_id.to_owned()),
253 },
254 TargetLocator::LocalPodman {
258 container_id,
259 workspace_storage,
260 ..
261 } => TargetLocator::LocalPodman {
262 container_id: container_id.clone(),
263 workspace_storage: workspace_storage.clone(),
264 borrowed_from: Some(parent_id.to_owned()),
265 },
266 TargetLocator::LocalDocker { container_id, .. } => TargetLocator::LocalDocker {
267 container_id: container_id.clone(),
268 borrowed_from: Some(parent_id.to_owned()),
269 },
270 TargetLocator::AppleContainer { container_id, .. } => TargetLocator::AppleContainer {
271 container_id: container_id.clone(),
272 borrowed_from: Some(parent_id.to_owned()),
273 },
274 TargetLocator::SshPodman {
275 host,
276 container_id,
277 workspace_storage,
278 ..
279 } => TargetLocator::SshPodman {
280 host: host.clone(),
281 container_id: container_id.clone(),
282 workspace_storage: workspace_storage.clone(),
283 borrowed_from: Some(parent_id.to_owned()),
284 },
285 TargetLocator::SshDocker {
286 host, container_id, ..
287 } => TargetLocator::SshDocker {
288 host: host.clone(),
289 container_id: container_id.clone(),
290 borrowed_from: Some(parent_id.to_owned()),
291 },
292 other @ TargetLocator::AwsEc2 { .. } => other.clone(),
295 })
296}
297
298fn ensure_parent_may_delegate(parent: &SessionRecord, global_enabled: bool) -> Result<()> {
303 match parent.mjolnir_subagents {
304 Some(false) => bail!("this session uses native sub-agents"),
305 None if !global_enabled => bail!("sub-agents are disabled"),
306 _ => Ok(()),
307 }
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 #[test]
315 fn a_container_child_borrows_its_parents_container() {
316 use mj_core::state::{PodmanWorkspaceLocator, TargetLocator};
317
318 let parent_id = "0123456789abcdef0123456789abcdef";
319 let child_id = "fedcba9876543210fedcba9876543210";
320 let container = mj_core::targets::resource_name(parent_id).unwrap();
321
322 let local = borrowed_locator(
323 &TargetLocator::LocalPodman {
324 container_id: container.clone(),
325 workspace_storage: PodmanWorkspaceLocator::Volume {
326 name: "parent-volume".to_owned(),
327 },
328 borrowed_from: None,
329 },
330 parent_id,
331 child_id,
332 )
333 .unwrap();
334 assert_eq!(
335 local,
336 TargetLocator::LocalPodman {
337 container_id: container.clone(),
338 workspace_storage: PodmanWorkspaceLocator::Volume {
339 name: "parent-volume".to_owned(),
340 },
341 borrowed_from: Some(parent_id.to_owned()),
342 }
343 );
344
345 let remote = borrowed_locator(
346 &TargetLocator::SshPodman {
347 host: "builder".to_owned(),
348 container_id: container.clone(),
349 workspace_storage: PodmanWorkspaceLocator::ContainerLayer,
350 borrowed_from: None,
351 },
352 parent_id,
353 child_id,
354 )
355 .unwrap();
356 assert_eq!(
357 remote,
358 TargetLocator::SshPodman {
359 host: "builder".to_owned(),
360 container_id: container,
361 workspace_storage: PodmanWorkspaceLocator::ContainerLayer,
362 borrowed_from: Some(parent_id.to_owned()),
363 }
364 );
365 }
366
367 #[test]
368 fn a_parent_using_native_delegation_cannot_spawn_mjolnir_children() {
369 let parent = |choice| {
370 let mut session = crate::controller::test_support::checkpoint_test_session("parent");
371 session.mjolnir_subagents = choice;
372 session
373 };
374
375 assert_eq!(
376 ensure_parent_may_delegate(&parent(Some(false)), true)
377 .unwrap_err()
378 .to_string(),
379 "this session uses native sub-agents"
380 );
381 assert_eq!(
382 ensure_parent_may_delegate(&parent(None), false)
383 .unwrap_err()
384 .to_string(),
385 "sub-agents are disabled"
386 );
387 assert!(ensure_parent_may_delegate(&parent(Some(true)), false).is_ok());
389 assert!(ensure_parent_may_delegate(&parent(None), true).is_ok());
390 }
391
392 #[test]
393 fn borrowed_bare_locator_gets_a_private_worker_identity() {
394 let locator = mj_core::state::TargetLocator::LocalBare {
395 worker_root: PathBuf::from("/workers/parent"),
396 };
397 assert_eq!(
398 borrowed_locator(&locator, "parent", "child").unwrap(),
399 mj_core::state::TargetLocator::LocalBare {
400 worker_root: PathBuf::from("/workers/child")
401 }
402 );
403 }
404
405 #[test]
406 fn borrowed_ssh_locator_keeps_parent_workspace_with_private_worker_identity() {
407 let locator = mj_core::state::TargetLocator::SshBare {
408 host: "builder".into(),
409 workspace: PathBuf::from(".local/share/hel/workspaces/parent-session"),
410 worker_id: None,
411 };
412 assert_eq!(
413 borrowed_locator(&locator, "parent-session", "child-session").unwrap(),
414 mj_core::state::TargetLocator::SshBare {
415 host: "builder".into(),
416 workspace: PathBuf::from(".local/share/hel/workspaces/parent-session"),
417 worker_id: Some("child-session".into()),
418 }
419 );
420 }
421}