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)?;
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 build_cache: parent.build_cache.clone(),
127 mjolnir_subagents: Some(false),
130 create_managed_worktree: Some(false),
131 archived: false,
132 container_cpus: None,
133 container_memory: None,
134 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 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 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 other @ TargetLocator::AwsEc2 { .. } => other.clone(),
318 })
319}
320
321fn 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
339fn 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}