1use super::*;
2
3const REPORT_ROOT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
5
6impl RuntimeState {
7 pub(super) async fn start_create_session(
8 self: &Arc<Self>,
9 request: CreateSessionRequest,
10 ) -> Result<RegisteredSession> {
11 self.start_create_session_inner(request, CreateSessionControl::default(), None)
12 .await
13 }
14
15 pub async fn start_subagent_session(
17 self: &Arc<Self>,
18 request: crate::controller::RegisterSubagentRequest,
19 ) -> Result<mj_core::subagent::SubagentRecord> {
20 let _upgrade_work = crate::upgrade::activity("subagent admission")?;
21 let (relation, needs_provisioning) = blocking(move || {
22 let mut controller = Controller::load()?;
23 if let Some(existing) = crate::database::lookup_subagent_request(
24 &request.parent_session_id,
25 &request.request_key,
26 )? {
27 let needs_provisioning = controller
28 .state
29 .sessions
30 .get(&existing.child_session_id)
31 .is_some_and(|record| {
32 record.state == mj_core::state::SessionState::Provisioning
33 });
34 return Ok((existing, needs_provisioning));
35 }
36 let mut request = request;
37 let executor = CancellableProcessExecutor::with_timeout(REPORT_ROOT_TIMEOUT);
41 request.report_root = Some(
42 controller
43 .prepare_subagent_report_root(&request.parent_session_id, &executor)
44 .context("create the sub-agent report directory")?,
45 );
46 controller
47 .register_subagent(request)
48 .map(|relation| (relation, true))
49 })
50 .await?;
51 if !needs_provisioning {
52 return Ok(relation);
53 }
54 let session_id = relation.child_session_id.clone();
55 let parent_session_id = relation.parent_session_id.clone();
56 let started = self.start_or_join_lifecycle_controlled(
57 session_id.clone(),
58 LifecycleKind::Create,
59 None,
60 Some(relation.request_key.clone()),
61 None,
62 move |state, session_id, cancelled| async move {
63 let mut controller = tokio::task::spawn_blocking(Controller::load)
64 .await
65 .context("load controller for sub-agent startup")??;
66 if controller
70 .state
71 .sessions
72 .get(&session_id)
73 .is_none_or(|record| record.state != mj_core::state::SessionState::Provisioning)
74 {
75 return Ok(DaemonLifecycleResult::Done);
76 }
77 let executor = DaemonStageReportingExecutor::new(
78 CancellableProcessExecutor::new(cancelled),
79 state.clone(),
80 session_id.clone(),
81 );
82 if let Err(error) = controller
83 .provision_subagent_session_controlled(&session_id, &executor)
84 .await
85 {
86 drop(controller);
87 state.reload_controller().await?;
88 if let Err(prompt_error) = state
89 .ensure_parent_wait_prompt(&parent_session_id)
90 .await
91 {
92 tracing::warn!(
93 child_session_id = %session_id,
94 parent_session_id,
95 error = format!("{prompt_error:#}"),
96 "could not reconcile the parent's sub-agent wait prompt after provisioning failure"
97 );
98 }
99 return Err(error);
100 }
101 Ok(DaemonLifecycleResult::Done)
102 },
103 );
104 if let Err(error) = started {
105 self.reload_controller().await?;
106 if let Err(prompt_error) = self
107 .ensure_parent_wait_prompt(&relation.parent_session_id)
108 .await
109 {
110 tracing::warn!(
111 child_session_id = %relation.child_session_id,
112 parent_session_id = %relation.parent_session_id,
113 error = format!("{prompt_error:#}"),
114 "could not reconcile the parent's sub-agent wait prompt after provisioning failure"
115 );
116 }
117 return Err(error);
118 }
119 self.reload_controller().await?;
120 Ok(relation)
121 }
122
123 pub async fn start_create_session_controlled(
124 self: &Arc<Self>,
125 request: CreateSessionRequest,
126 control: CreateSessionControl,
127 publication: tokio::sync::oneshot::Receiver<std::result::Result<(), String>>,
128 ) -> Result<RegisteredSession> {
129 self.start_create_session_inner(request, control, Some(publication))
130 .await
131 }
132
133 pub(super) async fn start_create_session_inner(
134 self: &Arc<Self>,
135 request: CreateSessionRequest,
136 control: CreateSessionControl,
137 publication: Option<tokio::sync::oneshot::Receiver<std::result::Result<(), String>>>,
138 ) -> Result<RegisteredSession> {
139 let mut request = request;
140 let parent = request.profile_id.clone();
141 let supplied = request.subagents.clone();
142 let (config, policy) = blocking(move || {
143 let controller = Controller::load()?;
144 let _profile = controller
145 .config
146 .enabled_profile(&parent)
147 .context("parent profile unavailable")?;
148 let policy = supplied.unwrap_or_default();
149 Ok((controller.config, policy))
150 })
151 .await?;
152 crate::controller::profile_config::validate_session_subagent_policy(
153 &config,
154 &request.profile_id,
155 &policy,
156 )
157 .await?;
158 if !request.bundle_id.is_empty() {
159 crate::controller::config_only_controller(config.clone())
160 .validate_github_bundle_installations(&request.bundle_id)
161 .await
162 .map_err(crate::controller::GithubBundleSelectionError::into_anyhow)?;
163 }
164 let _upgrade_work = crate::upgrade::activity("session admission")?;
166 request.subagents = Some(policy);
167 let path_cancelled = control.cancelled.clone();
168 let registered = blocking(move || {
169 let mut controller = Controller::load()?;
170 let path_executor = crate::targets::CancellableProcessExecutor::new(path_cancelled)
171 .with_deadline(Duration::from_secs(30));
172 let project_directory = request
173 .project_directory
174 .as_deref()
175 .map(|path| {
176 controller.resolve_project_directory(
177 &request.target_template_id,
178 path,
179 &path_executor,
180 )
181 })
182 .transpose()?;
183 let session_id = controller.register_session_with_resources(
184 &request.profile_id,
185 &request.bundle_id,
186 &request.target_template_id,
187 request.title,
188 SessionLaunchOptions {
189 create_managed_worktree: request.create_managed_worktree,
190 at: request.at,
191 branch: request.branch,
192 base: request.base,
193 subagents: request.subagents,
194 review: request.review,
195 no_project_memory: request.no_project_memory,
196 no_mailbox: request.no_mailbox,
197 initial_prompt: request.initial_prompt,
198 workspace_id: request.workspace_id,
199 additional_mounts: request.additional_mounts,
200 resource_allocation: request.resource_allocation,
201 project_directory,
202 session_title_override: request.session_title_override,
203 },
204 )?;
205 if let Err(error) = mj_core::go::GoPreferences::remember_first_pair(
210 &mj_core::go::GoPreferences::path(),
211 &request.profile_id,
212 &request.target_template_id,
213 ) {
214 tracing::warn!(%error, "could not save the default profile and target");
215 }
216 let session = controller
217 .state
218 .sessions
219 .get(&session_id)
220 .expect("newly registered session exists")
221 .clone();
222 let remembered_container_size = controller
223 .config
224 .targets
225 .get(&request.target_template_id)
226 .and_then(mj_core::config::container_size_host)
227 .and_then(|host| {
228 controller
229 .state
230 .container_sizes
231 .get(host)
232 .copied()
233 .map(|size| (host.to_owned(), size))
234 });
235 Ok(RegisteredSession {
236 session,
237 remembered_container_size,
238 })
239 })
240 .await?;
241 let session_id = registered.session.id.clone();
242 self.start_or_join_lifecycle_controlled(
243 session_id,
244 LifecycleKind::Create,
245 None,
246 None,
247 Some(control.clone()),
248 move |state, session_id, cancelled| async move {
249 let mut controller = tokio::task::spawn_blocking(Controller::load)
250 .await
251 .context("load controller for daemon create task")??;
252 let publication_error = if let Some(publication) = publication {
253 let published = tokio::select! {
254 result = publication => result.context("session publication owner stopped")
255 .and_then(|result| result.map_err(anyhow::Error::msg)),
256 () = async {
257 while !cancelled.load(Ordering::Acquire) {
258 tokio::time::sleep(Duration::from_millis(25)).await;
259 }
260 } => Err(anyhow!("session creation cancelled before publication")),
261 };
262 published.err()
263 } else {
264 None
265 };
266 if publication_error.is_some() {
267 control.request_cancel();
268 }
269 let executor = DaemonStageReportingExecutor::new(
270 CancellableProcessExecutor::new(cancelled),
271 state,
272 session_id.clone(),
273 );
274 let provision = controller
275 .provision_session_controlled_with_commit(&session_id, &executor, || {
276 ensure!(
277 control.grant_commit(),
278 "session creation cancelled before commit"
279 );
280 Ok(())
281 })
282 .await;
283 if let Some(error) = publication_error {
284 return match provision {
285 Ok(()) => Err(error),
286 Err(rollback) => {
287 Err(error.context(format!("discard unpublished session: {rollback:#}")))
288 }
289 };
290 }
291 provision?;
292 Ok(DaemonLifecycleResult::Done)
293 },
294 )?;
295 self.reload_controller().await?;
296 Ok(registered)
297 }
298
299 pub async fn wait_create_session(&self, session_id: &str) -> Result<()> {
300 let result = {
301 let lifecycle_owner = self.owner();
302 let lifecycle = &lifecycle_owner.lifecycle;
303 let active = lifecycle
304 .get(session_id)
305 .with_context(|| format!("no create operation exists for session {session_id}"))?;
306 ensure!(
307 active.kind == LifecycleKind::Create,
308 "session {session_id} is no longer being created"
309 );
310 active.result.clone()
311 };
312 let channel = result.clone();
313 let outcome = Self::wait_lifecycle_result(result).await;
314 self.remove_completed_lifecycle(&channel);
315 match outcome? {
316 DaemonLifecycleResult::Done => Ok(()),
317 DaemonLifecycleResult::Move(_) | DaemonLifecycleResult::Park(_) => {
318 unreachable!("cleanup cannot return a move outcome")
319 }
320 DaemonLifecycleResult::DeferredCleanup => {
321 unreachable!("session creation cannot schedule target cleanup")
322 }
323 }
324 }
325}
326
327#[cfg(test)]
328mod delegation_replay_tests {
329 use crate::controller::test_support::{IsolatedTest, test_name};
330
331 #[tokio::test]
332 async fn unconfigured_mjolnir_creation_is_refused_before_registration_or_provisioning() {
333 const CHILD: &str = "MJ_TEST_MULTI_MODEL_CREATION_REFUSAL";
334 if std::env::var_os(CHILD).is_none() {
335 let root = tempfile::tempdir().unwrap();
336 IsolatedTest::new(test_name(
337 module_path!(),
338 "unconfigured_mjolnir_creation_is_refused_before_registration_or_provisioning",
339 ))
340 .env(CHILD, "1")
341 .env("MJ_INSTANCE", "multi-model-creation-refusal")
342 .isolated_store(root.path())
343 .run();
344 return;
345 }
346 let _writer = crate::database::install_isolated_test_writer();
347 let mut config = mj_core::config::Config::default();
348 config.profiles.insert(
349 "codex".into(),
350 mj_core::config::HarnessProfile {
351 enabled: true,
352 kind: mj_core::config::HarnessKind::Codex,
353 home: mj_core::config::data_dir().join("missing-profile-home"),
354 environment: Default::default(),
355 context_window_bytes: None,
356 guardian_review_model: None,
357 legacy_subagents: None,
358 },
359 );
360 config.save().unwrap();
361 let workspace = crate::database::create_workspace("legacy delegation").unwrap();
362 let mut legacy = crate::daemon::tests::runtime_test_session(
363 "legacy-parent",
364 &workspace.id,
365 mj_core::state::SessionState::Running,
366 );
367 legacy.subagents = Some(mj_core::subagent::SubagentPolicy::Mjolnir);
368 crate::database::save_session(&legacy).unwrap();
369 let runtime = crate::daemon::tests::test_runtime_state();
370 let request = mj_client::daemon::CreateSessionRequest {
371 create_managed_worktree: None,
372 at: None,
373 branch: None,
374 base: None,
375 subagents: Some(mj_core::subagent::SubagentPolicy::Mjolnir),
376 review: None,
377 no_project_memory: false,
378 no_mailbox: false,
379 initial_prompt: None,
380 workspace_id: workspace.id,
381 profile_id: "codex".into(),
382 bundle_id: "missing-bundle".into(),
383 project_directory: None,
384 target_template_id: "missing-target".into(),
385 additional_mounts: Vec::new(),
386 resource_allocation: None,
387 title: "must not register".into(),
388 session_title_override: None,
389 };
390 let error = runtime
391 .start_create_session(request)
392 .await
393 .expect_err("a refusal");
394 let refusal = mj_core::refusal::Refusal::of(&error).expect("a request refusal");
395 assert!(
396 refusal.message().contains("[subagents.straightforward]")
397 && refusal.message().contains("[subagents.subtle]")
398 );
399 assert_eq!(
400 refusal.code(),
401 Some(mj_core::subagent::CHOICE_UNAVAILABLE_CODE)
402 );
403 assert!(runtime.active_lifecycles().is_empty());
404 let state = crate::database::load_state().unwrap();
405 assert_eq!(state.sessions.len(), 1);
406 assert_eq!(state.sessions[&legacy.id], legacy);
407 }
408
409 #[tokio::test]
410 async fn replayed_spawn_does_not_reprovision_an_existing_child() {
411 const CHILD: &str = "MJ_TEST_SPAWN_REPLAY";
412 if std::env::var_os(CHILD).is_none() {
413 let root = tempfile::tempdir().unwrap();
414 IsolatedTest::new(test_name(
415 module_path!(),
416 "replayed_spawn_does_not_reprovision_an_existing_child",
417 ))
418 .env(CHILD, "1")
419 .env("MJ_INSTANCE", "concurrency-sweep-spawn")
420 .isolated_store(root.path())
421 .run();
422 return;
423 }
424 let _writer = crate::database::install_isolated_test_writer();
425 let workspace = crate::database::create_workspace("spawn replay").unwrap();
426 let parent = crate::daemon::tests::runtime_test_session(
427 "parent",
428 &workspace.id,
429 mj_core::state::SessionState::Running,
430 );
431 crate::database::save_session(&parent).unwrap();
432 for (index, status) in [
433 mj_core::state::SessionState::Running,
434 mj_core::state::SessionState::Parked,
435 mj_core::state::SessionState::Stopped,
436 ]
437 .into_iter()
438 .enumerate()
439 {
440 let child_id = format!("child-{index}");
441 let child =
442 crate::daemon::tests::runtime_test_session(&child_id, &workspace.id, status);
443 let relation = crate::daemon::tests::runtime_test_subagent(&child_id, "parent");
444 crate::database::save_subagent_session(&child, &relation).unwrap();
445 let runtime = crate::daemon::tests::test_runtime_state();
446 let replayed = runtime
447 .start_subagent_session(crate::controller::RegisterSubagentRequest {
448 parent_session_id: "parent".into(),
449 task_name: "must reuse original".into(),
450 profile_id: "missing-profile".into(),
451 model: None,
452 effort: None,
453 working_directory: Default::default(),
454 initial_prompt: "must not resend".into(),
455 request_key: relation.request_key.clone(),
456 report_root: None,
457 })
458 .await
459 .unwrap();
460 assert_eq!(replayed, relation);
461 assert!(runtime.active_lifecycles().is_empty());
462 assert_eq!(
463 crate::database::load_session_record(&child_id)
464 .unwrap()
465 .unwrap()
466 .state,
467 status
468 );
469 }
470 }
471}