1use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6use std::time::{Duration, Instant};
7
8use anyhow::{Context, Result, bail, ensure};
9
10use mj_core::config::{TargetTemplate, atomic_write, data_dir};
11use mj_core::state::{SessionState, State, TargetLocator};
12
13use crate::targets::{
14 self, CancellableProcessExecutor, CommandExecutor, CommandOutput, CommandSpec, ProvisionStage,
15 ProvisionStageGuard,
16};
17
18use super::backend::{
19 ContainerOverrides, backend_bundle, backend_locator, backend_target,
20 configure_github_token_environment, controller_github_token, locator_after_provision,
21 preflight_target, use_github_https_urls,
22};
23use super::git_cache;
24use super::readiness::{connect_started_worker, wait_for_native_session_in_stage};
25use super::worker_binary::{bridge_readiness_stage, start_worker, worker_probe_diagnosis};
26use super::{Controller, execute_checked, now};
27
28const INHERITED_GIT_SETTINGS: &[&str] = &[
29 "diff.algorithm",
30 "fetch.prune",
31 "fetch.prunetags",
32 "init.defaultbranch",
33 "merge.conflictstyle",
34 "pull.ff",
35 "pull.rebase",
36 "push.autosetupremote",
37 "push.default",
38 "rebase.autostash",
39 "rerere.autoupdate",
40 "rerere.enabled",
41 "user.email",
42 "user.name",
43];
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub(super) enum ProvisioningFailureDisposition {
47 Discard,
49 Preserve,
51}
52
53impl Controller {
54 pub async fn provision_session_controlled(
55 &mut self,
56 session_id: &str,
57 executor: &(impl CommandExecutor + Sync),
58 ) -> Result<()> {
59 self.provision_session_controlled_with_commit(session_id, executor, || Ok(()))
60 .await
61 }
62
63 pub async fn provision_session_controlled_with_commit(
64 &mut self,
65 session_id: &str,
66 executor: &(impl CommandExecutor + Sync),
67 grant_commit: impl FnOnce() -> Result<()>,
68 ) -> Result<()> {
69 let github_token = controller_github_token();
70 let repositories = self
71 .provision_session_target_with_failure_disposition(
72 session_id,
73 executor,
74 github_token.as_deref(),
75 ProvisioningFailureDisposition::Discard,
76 )
77 .await?;
78 let setup = execute_concurrent_lanes(
79 || execute_repository_setup(&repositories, executor),
80 || self.install_worker_payload(session_id, executor),
81 );
82 let result = match setup {
83 Ok(((), (backend, worker_root))) => {
84 self.connect_and_start_worker(session_id, executor, &backend, &worker_root, true)
85 .await
86 }
87 Err(error) => Err(error),
88 };
89 match result {
90 Ok(native_session_id) => {
91 if let Err(error) = grant_commit() {
92 return Err(self.rollback_failed_new_session(session_id, error, executor)?);
93 }
94 self.mark_worker_connected(session_id, native_session_id)
95 }
96 Err(error) => Err(self.rollback_failed_new_session(session_id, error, executor)?),
97 }
98 }
99
100 pub async fn provision_subagent_session_controlled(
103 &mut self,
104 session_id: &str,
105 executor: &(impl CommandExecutor + Sync),
106 ) -> Result<()> {
107 let (backend, worker_root) = self.worker_placement(session_id)?;
108 let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
109 let result = self.prepare_worker_files(session_id, &backend, &worker_root, syncing);
110 let result = match result {
111 Ok(()) => {
112 self.connect_and_start_worker(session_id, executor, &backend, &worker_root, false)
113 .await
114 }
115 Err(error) => Err(error),
116 };
117 match result {
118 Ok(native_session_id) => self.mark_worker_connected(session_id, native_session_id),
119 Err(error) => {
120 if let Err(stop_error) =
121 super::worker_binary::stop_worker(executor, &backend, &worker_root)
122 {
123 tracing::warn!(
124 session_id,
125 error = format!("{stop_error:#}"),
126 "failed sub-agent worker could not be stopped cleanly"
127 );
128 }
129 let record = self
130 .state
131 .sessions
132 .get_mut(session_id)
133 .context("failed sub-agent session disappeared")?;
134 record.state = SessionState::Error;
135 record.updated_at = super::now();
136 record.last_error = Some(format!("sub-agent startup failed: {error:#}"));
137 crate::database::save_lifecycle_session(record)?;
138 Err(error)
139 }
140 }
141 }
142
143 fn rollback_failed_new_session(
144 &mut self,
145 session_id: &str,
146 error: anyhow::Error,
147 executor: &impl CommandExecutor,
148 ) -> Result<anyhow::Error> {
149 let session = self
150 .state
151 .sessions
152 .get(session_id)
153 .with_context(|| format!("unknown session {session_id}"))?
154 .clone();
155 let target_cleanup = match session.target.as_ref() {
156 Some(locator) => (|| -> Result<()> {
157 let backend = backend_locator(locator, &session, &self.config)?;
158 targets::close_plan(&backend, session_id)?
159 .execute(&CancellableProcessExecutor::with_timeout(
162 Duration::from_secs(15),
163 ))
164 .map(|_| ())
165 })(),
166 None => Ok(()),
167 };
168 let worktree_cleanup =
169 self.cleanup_new_session_worktree_after_failure(session_id, executor);
170 let cleanup_error = [target_cleanup, worktree_cleanup]
171 .into_iter()
172 .filter_map(Result::err)
173 .map(|error| format!("{error:#}"))
174 .collect::<Vec<_>>()
175 .join("; ");
176 if !cleanup_error.is_empty() {
177 tracing::warn!(
178 session_id,
179 error = %cleanup_error,
180 "new-session rollback cleanup reported failures"
181 );
182 }
183 let original = note_new_session_launch_failure(session_id, &error);
184 let failure = apply_failed_new_session_rollback(
185 &mut self.state,
186 session_id,
187 &original,
188 (!cleanup_error.is_empty()).then_some(cleanup_error),
189 );
190 self.persist_session_state(session_id)?;
191 Ok(failure)
192 }
193
194 pub async fn provision_session_with(
195 &mut self,
196 session_id: &str,
197 executor: &(impl CommandExecutor + Sync),
198 ) -> Result<()> {
199 self.provision_session_with_github_token(session_id, executor, None)
200 .await
201 }
202
203 async fn provision_session_with_github_token(
204 &mut self,
205 session_id: &str,
206 executor: &(impl CommandExecutor + Sync),
207 github_token: Option<&str>,
208 ) -> Result<()> {
209 self.provision_session_with_failure_disposition(
210 session_id,
211 executor,
212 github_token,
213 ProvisioningFailureDisposition::Discard,
214 )
215 .await
216 }
217
218 pub(super) async fn provision_session_with_failure_disposition(
219 &mut self,
220 session_id: &str,
221 executor: &(impl CommandExecutor + Sync),
222 github_token: Option<&str>,
223 failure_disposition: ProvisioningFailureDisposition,
224 ) -> Result<()> {
225 let repositories = self
226 .provision_session_target_with_failure_disposition(
227 session_id,
228 executor,
229 github_token,
230 failure_disposition,
231 )
232 .await?;
233 match execute_repository_setup(&repositories, executor) {
234 Ok(()) => Ok(()),
235 Err(error) if failure_disposition == ProvisioningFailureDisposition::Discard => {
236 Err(self.rollback_failed_new_session(session_id, error, executor)?)
237 }
238 Err(error) => Err(error),
239 }
240 }
241
242 async fn provision_session_target_with_failure_disposition(
243 &mut self,
244 session_id: &str,
245 executor: &(impl CommandExecutor + Sync),
246 github_token: Option<&str>,
247 failure_disposition: ProvisioningFailureDisposition,
248 ) -> Result<targets::CommandPlan> {
249 let session = self
250 .state
251 .sessions
252 .get(session_id)
253 .with_context(|| format!("unknown session {session_id}"))?
254 .clone();
255 if session.state != SessionState::Provisioning {
256 bail!("session {session_id} is not provisioning");
257 }
258 let preparation = (|| {
259 let template = self
260 .config
261 .targets
262 .get(&session.target_template_id)
263 .context("target template disappeared during provisioning")?;
264 let profile = self
265 .config
266 .profiles
267 .get(&session.last_profile)
268 .context("harness profile disappeared during provisioning")?;
269 super::worker_binary::preflight_harness(template, profile, executor)?;
270 self.prepare_managed_raw_worktree(session_id, executor)
271 })();
272 let created_worktree = match preparation {
273 Ok(created) => created,
274 Err(error) if failure_disposition == ProvisioningFailureDisposition::Discard => {
275 return Err(self.fail_new_session_with_cleanup(session_id, error, executor)?);
276 }
277 Err(error) => return Err(error),
278 };
279 let session = self
280 .state
281 .sessions
282 .get(session_id)
283 .expect("session retained after managed worktree preparation")
284 .clone();
285 let result = (|| {
288 let template = self
289 .config
290 .targets
291 .get(&session.target_template_id)
292 .context("target template disappeared during provisioning")?;
293 if matches!(template, TargetTemplate::AwsEc2 { .. }) {
294 for resource in &session.additional_mounts {
295 ensure!(
296 resource.source.is_dir(),
297 "attached resource source is not a directory: {}",
298 resource.source.display()
299 );
300 }
301 }
302 let mut target = backend_target(
303 template,
304 session.resource_allocation.as_ref(),
305 ContainerOverrides::for_session(&session),
306 )?;
307 let mut runtime_mounts = if matches!(target, targets::TargetTemplate::AwsEc2(_)) {
308 Vec::new()
309 } else {
310 session.additional_mounts.clone()
311 };
312 for notice in enforce_overlay_capable_mounts(&target, &mut runtime_mounts, executor) {
317 executor.notify_notice(¬ice);
318 }
319 let mut bundle = if session.project_directory.is_some() {
320 None
321 } else if failure_disposition == ProvisioningFailureDisposition::Preserve {
322 Some(super::network_git::checkpoint_bundle(&session)?)
323 } else {
324 Some(backend_bundle(
325 self.config
326 .bundles
327 .get(&session.bundle_id)
328 .context("session bundle is missing")?,
329 executor,
330 )?)
331 };
332 let container_github_token =
333 github_token.filter(|_| configure_github_token_environment(&mut target));
334 if container_github_token.is_some()
335 && let Some(bundle) = bundle.as_mut()
336 {
337 use_github_https_urls(bundle);
338 }
339 preflight_target(template, executor)?;
340 let prepared_cache = bundle.as_mut().and_then(|bundle| {
341 git_cache::prepare(
342 &target,
343 session_id,
344 bundle,
345 &mut runtime_mounts,
346 container_github_token,
347 executor,
348 )
349 });
350 let provision = if let Some(project_directory) = &session.project_directory {
351 targets::provision_bare_project_plan(
352 &target,
353 session_id,
354 &project_directory.to_string_lossy(),
355 )
356 } else {
357 bundle
358 .as_ref()
359 .context("project bundle disappeared during provisioning")
360 .and_then(|bundle| {
361 targets::provision_plan(&target, session_id, bundle, &runtime_mounts)
362 })
363 };
364 let mut provision = match provision {
365 Ok(provision) => provision,
366 Err(error) => {
367 if let Some(cache) = &prepared_cache {
368 let _ = cache.cleanup(executor);
369 }
370 return Err(error);
371 }
372 };
373 if let Some(token) = container_github_token
374 && let Err(error) =
375 provision.provide_target_environment_secret(&target, "GH_TOKEN", token)
376 {
377 if let Some(cache) = &prepared_cache {
378 let _ = cache.cleanup(executor);
379 }
380 return Err(error);
381 }
382
383 let started = Instant::now();
384 let result =
385 provision_target_creation(&provision, &target, session_id, executor, |outputs| {
386 locator_after_provision(
387 template,
388 &target,
389 session_id,
390 outputs.first(),
391 executor,
392 )
393 })
394 .map(|(locator, remainder)| (locator, remainder, bundle));
395 if result.is_err()
396 && let Some(cache) = &prepared_cache
397 {
398 if let Some(locator) = provisioned_locator(&target, session_id, None) {
399 let _ = targets::close_plan(&locator, session_id)
400 .and_then(|plan| plan.execute(executor).map(|_| ()));
401 } else {
402 let _ = cache.cleanup(executor);
403 }
404 }
405 tracing::debug!(
406 session_id,
407 elapsed_ms = started.elapsed().as_millis(),
408 "provisioning plan execution completed"
409 );
410 result
411 })();
412 let result = match result {
413 Err(error)
414 if created_worktree
415 && failure_disposition == ProvisioningFailureDisposition::Discard =>
416 {
417 return Err(self.fail_new_session_with_cleanup(session_id, error, executor)?);
418 }
419 Err(error) if failure_disposition == ProvisioningFailureDisposition::Preserve => {
420 Err(error)
421 }
422 Err(error) => {
423 let detail = note_new_session_launch_failure(session_id, &error);
427 {
428 let record = self.state.sessions.get_mut(session_id).unwrap();
429 record.state = SessionState::Error;
430 record.target = None;
431 record.updated_at = super::now();
432 record.last_error = Some(format!("session provisioning failed: {detail}"));
433 }
434 return match self.persist_session_state(session_id) {
435 Ok(()) => Err(error),
436 Err(persistence_error) => Err(error.context(format!(
437 "persist removal of failed provisioning session {session_id}: {persistence_error:#}"
438 ))),
439 };
440 }
441 Ok((locator, remainder, bundle)) => {
442 apply_new_session_provisioning_result(&mut self.state, session_id, Ok(locator))?;
443 let session = &self.state.sessions[session_id];
444 let backend = backend_locator(
445 session
446 .target
447 .as_ref()
448 .context("provisioned target disappeared")?,
449 session,
450 &self.config,
451 )?;
452 if matches!(backend, targets::TargetLocator::AwsEc2 { .. }) {
453 targets::provision_on_locator_plan(
454 &backend,
455 session_id,
456 bundle
457 .as_ref()
458 .context("AWS provisioning requires a project bundle")?,
459 )
460 } else {
461 Ok(remainder)
462 }
463 }
464 };
465 let result = match result {
466 Err(error) if failure_disposition == ProvisioningFailureDisposition::Discard => {
467 return Err(self.rollback_failed_new_session(session_id, error, executor)?);
468 }
469 result => result,
470 };
471 if result.is_ok()
472 && let Some(session) = self.state.sessions.get(session_id)
473 && let Some(directory) = session
474 .managed_worktree
475 .as_ref()
476 .map(|worktree| worktree.source_project_directory.clone())
477 .or_else(|| session.project_directory.clone())
478 && let Some(template) = self.config.targets.get(&session.target_template_id)
479 {
480 let host = match template {
481 TargetTemplate::LocalBare => Some("local"),
482 TargetTemplate::SshBare { ssh, .. } => Some(ssh.host.as_str()),
483 _ => None,
484 };
485 if let Some(host) = host {
486 self.state.remember_project_directory(host, &directory);
487 crate::database::remember_project_directory(host, &directory)?;
488 }
489 }
490 self.persist_session_state(session_id)?;
491 result
492 }
493
494 pub fn mark_worker_connected(
495 &mut self,
496 session_id: &str,
497 native_session_id: Option<String>,
498 ) -> Result<()> {
499 let session = self
500 .state
501 .sessions
502 .get(session_id)
503 .with_context(|| format!("unknown session {session_id}"))?;
504 if session.target.is_none() {
505 bail!("session {session_id} has no provisioned target");
506 }
507 let updated_at = now();
508 crate::database::mark_session_worker_connected(
509 session_id,
510 native_session_id.as_deref(),
511 &updated_at,
512 )?;
513 let session = self
514 .state
515 .sessions
516 .get_mut(session_id)
517 .expect("session disappeared after its worker connection was saved");
518 session.state = SessionState::Running;
519 if native_session_id.is_some() {
520 session.native_session_id = native_session_id;
521 }
522 session.updated_at = updated_at;
523 session.last_error = None;
524 Ok(())
525 }
526
527 fn install_worker_payload(
528 &self,
529 session_id: &str,
530 executor: &impl CommandExecutor,
531 ) -> Result<(targets::TargetLocator, String)> {
532 let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
534 let (backend, worker_root) = self.worker_placement(session_id)?;
535 self.prepare_worker_files(session_id, &backend, &worker_root, syncing)?;
536 install_attached_resources(&self.state, session_id, &backend, &worker_root, syncing)?;
537 Ok((backend, worker_root))
538 }
539
540 async fn connect_and_start_worker(
541 &self,
542 session_id: &str,
543 executor: &impl CommandExecutor,
544 backend: &targets::TargetLocator,
545 worker_root: &str,
546 initialize_workspace: bool,
547 ) -> Result<Option<String>> {
548 let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
549 if initialize_workspace {
550 install_inherited_git_settings(executor, backend, session_id)?;
551 self.initialize_network_workspaces(session_id, backend, syncing)?;
552 }
553 let session = self
554 .state
555 .sessions
556 .get(session_id)
557 .with_context(|| format!("unknown session {session_id}"))?;
558 let profile = self
559 .config
560 .profiles
561 .get(&session.last_profile)
562 .with_context(|| format!("unknown profile {}", session.last_profile))?;
563 let readiness_stage = bridge_readiness_stage(profile);
564 let reconnect = &targets::reconnect_plan(backend, session_id)?.commands[0];
565 let readiness = async {
566 let mut relay = {
567 let _starting = ProvisionStageGuard::new(executor, ProvisionStage::Starting);
568 start_worker(executor, backend, worker_root)?;
569 connect_started_worker(reconnect, session_id, executor, backend, worker_root)
570 .await?
571 };
572 let native_session_id =
573 wait_for_native_session_in_stage(&mut relay, executor, readiness_stage).await?;
574 Ok(Some(native_session_id))
575 }
576 .await;
577 match readiness {
578 Ok(native_session_id) => Ok(native_session_id),
579 Err(error) => Err(worker_probe_diagnosis(
580 executor,
581 backend,
582 worker_root,
583 error,
584 )),
585 }
586 }
587}
588
589const MAX_LAUNCH_DIAGNOSTIC_BYTES: usize = 64 * 1024;
590
591const RETAINED_LAUNCH_DIAGNOSTICS: usize = 20;
592
593pub(super) fn note_new_session_launch_failure(session_id: &str, error: &anyhow::Error) -> String {
599 note_new_session_launch_failure_in(&data_dir().join("diagnostics"), session_id, error)
600}
601
602fn note_new_session_launch_failure_in(
603 directory: &Path,
604 session_id: &str,
605 error: &anyhow::Error,
606) -> String {
607 let original = format!("{error:#}");
608 tracing::warn!(session_id, error = %original, "session launch failed");
609 match persist_launch_failure_to(directory, session_id, &original) {
610 Ok(path) => format!("{original}; full diagnostic saved to {}", path.display()),
611 Err(save_error) => {
612 format!("{original}; saving the local diagnostic failed: {save_error:#}")
613 }
614 }
615}
616
617fn persist_launch_failure_to(directory: &Path, session_id: &str, detail: &str) -> Result<PathBuf> {
618 mj_core::config::validate_id("session", session_id)?;
619 std::fs::create_dir_all(directory).with_context(|| {
620 format!(
621 "create launch diagnostics directory {}",
622 directory.display()
623 )
624 })?;
625 #[cfg(unix)]
626 {
627 use std::os::unix::fs::PermissionsExt;
628 std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700))?;
629 }
630 let path = directory.join(format!("{session_id}-launch-error.txt"));
631 let detail = bounded_launch_diagnostic(detail);
632 let body = format!(
633 "Hel session launch failure\nsession: {session_id}\nat: {}\n\n{detail}\n",
634 now()
635 );
636 atomic_write(&path, body.as_bytes())?;
637 prune_launch_diagnostics(directory)?;
638 Ok(path)
639}
640
641fn bounded_launch_diagnostic(detail: &str) -> String {
642 if detail.len() <= MAX_LAUNCH_DIAGNOSTIC_BYTES {
643 return detail.to_owned();
644 }
645 let mut head_end = MAX_LAUNCH_DIAGNOSTIC_BYTES / 4;
646 while !detail.is_char_boundary(head_end) {
647 head_end -= 1;
648 }
649 let tail_bytes = MAX_LAUNCH_DIAGNOSTIC_BYTES - head_end;
650 let mut tail_start = detail.len() - tail_bytes;
651 while !detail.is_char_boundary(tail_start) {
652 tail_start += 1;
653 }
654 format!(
655 "{}\n\n[... launch diagnostic truncated ...]\n\n{}",
656 &detail[..head_end],
657 &detail[tail_start..]
658 )
659}
660
661fn prune_launch_diagnostics(directory: &Path) -> Result<()> {
662 let mut diagnostics = Vec::new();
663 for entry in std::fs::read_dir(directory)? {
664 let entry = entry?;
665 if !entry
666 .file_name()
667 .to_str()
668 .is_some_and(|name| name.ends_with("-launch-error.txt"))
669 {
670 continue;
671 }
672 diagnostics.push((entry.metadata()?.modified()?, entry.path()));
673 }
674 diagnostics.sort_by_key(|entry| std::cmp::Reverse(entry.0));
675 for (_, path) in diagnostics.into_iter().skip(RETAINED_LAUNCH_DIAGNOSTICS) {
676 std::fs::remove_file(&path)
677 .with_context(|| format!("prune old launch diagnostic {}", path.display()))?;
678 }
679 Ok(())
680}
681
682fn apply_new_session_provisioning_result(
683 state: &mut State,
684 session_id: &str,
685 result: Result<TargetLocator>,
686) -> Result<()> {
687 match result {
688 Ok(locator) => {
689 let record = state.sessions.get_mut(session_id).unwrap();
690 record.target = Some(locator);
691 record.state = SessionState::Disconnected;
694 record.updated_at = now();
695 record.last_error = None;
696 Ok(())
697 }
698 Err(error) => {
699 let record = state.sessions.get_mut(session_id).unwrap();
700 record.state = SessionState::Error;
701 record.target = None;
702 record.updated_at = now();
703 record.last_error = Some(format!("session provisioning failed: {error:#}"));
704 Err(error)
705 }
706 }
707}
708
709pub(super) fn apply_failed_new_session_rollback(
710 state: &mut State,
711 session_id: &str,
712 original_error: &str,
713 cleanup_error: Option<String>,
714) -> anyhow::Error {
715 match cleanup_error {
716 None => {
717 let record = state.sessions.get_mut(session_id).unwrap();
718 record.state = SessionState::Error;
719 record.target = None;
720 record.updated_at = now();
721 record.last_error = Some(format!("worker bootstrap failed: {original_error}"));
722 anyhow::anyhow!("{original_error}; partial target removed and failed session retained")
723 }
724 Some(cleanup_error) => {
725 let failure = format!(
726 "{original_error}; cleanup of the failed session target failed: {cleanup_error}"
727 );
728 let record = state.sessions.get_mut(session_id).unwrap();
729 record.state = SessionState::Error;
730 record.updated_at = now();
731 record.last_error = Some(format!("worker bootstrap failed: {failure}"));
732 anyhow::anyhow!(failure)
733 }
734 }
735}
736
737pub(super) fn install_attached_resources(
738 state: &State,
739 session_id: &str,
740 backend: &targets::TargetLocator,
741 worker_root: &str,
742 executor: &impl CommandExecutor,
743) -> Result<()> {
744 let targets::TargetLocator::AwsEc2 { .. } = backend else {
745 return Ok(());
746 };
747 let session = state
748 .sessions
749 .get(session_id)
750 .with_context(|| format!("unknown session {session_id}"))?;
751 if session.additional_mounts.is_empty() {
752 return Ok(());
753 }
754 for resource in &session.additional_mounts {
755 let install = targets::command_on_locator(
756 backend,
757 session_id,
758 vec![
759 format!("{worker_root}/hel"),
760 "worker".into(),
761 "install-resource".into(),
762 "--destination".into(),
763 resource.destination.to_string_lossy().into_owned(),
764 ],
765 "stream attached resource",
766 )?;
767 mj_checkpoint::resources::stream_resource(&resource.source, |stream| {
768 execute_checked_with_stdin(executor, &install, stream).map(|_| ())
769 })
770 .with_context(|| format!("stream attached resource {}", resource.source.display()))?;
771 }
772 Ok(())
773}
774
775pub(super) fn execute_concurrent_lanes<A: Send, B: Send>(
779 first: impl FnOnce() -> Result<A> + Send,
780 second: impl FnOnce() -> Result<B> + Send,
781) -> Result<(A, B)> {
782 std::thread::scope(|scope| {
783 let second = scope.spawn(second);
784 let first = first();
785 let second = second.join().unwrap_or_else(|panic| {
786 Err(anyhow::anyhow!(
787 "concurrent target lane panicked: {}",
788 targets::command_thread_panic_message(panic.as_ref())
789 ))
790 });
791 match (first, second) {
792 (Err(error), _) => Err(error),
793 (Ok(_), Err(error)) => Err(error),
794 (Ok(first), Ok(second)) => Ok((first, second)),
795 }
796 })
797}
798
799fn execute_repository_setup(
800 plan: &targets::CommandPlan,
801 executor: &(impl CommandExecutor + Sync),
802) -> Result<()> {
803 if plan.commands.is_empty() {
804 return Ok(());
805 }
806 let _cloning = ProvisionStageGuard::new(executor, ProvisionStage::Cloning);
807 plan.execute_concurrent(executor).map(|_| ())
808}
809
810#[cfg(test)]
817fn provision_target(
818 plan: &targets::CommandPlan,
819 target: &targets::TargetTemplate,
820 session_id: &str,
821 executor: &(impl CommandExecutor + Sync),
822 discover: impl FnOnce(&[CommandOutput]) -> Result<TargetLocator>,
823) -> Result<TargetLocator> {
824 let Some((creation, remainder)) = plan.split_at_target_creation() else {
825 return discover(&plan.execute_concurrent(executor)?);
827 };
828 let mut outputs = creation.execute_concurrent(executor)?;
829 let result = match remainder.execute_concurrent(executor) {
830 Ok(rest) => {
831 outputs.extend(rest);
832 discover(&outputs)
833 }
834 Err(error) => Err(error),
835 };
836 result.map_err(|error| {
837 match cleanup_failed_provision(target, session_id, outputs.first(), executor) {
838 Some(note) => error.context(note),
839 None => error,
840 }
841 })
842}
843
844fn provision_target_creation(
848 plan: &targets::CommandPlan,
849 target: &targets::TargetTemplate,
850 session_id: &str,
851 executor: &(impl CommandExecutor + Sync),
852 discover: impl FnOnce(&[CommandOutput]) -> Result<TargetLocator>,
853) -> Result<(TargetLocator, targets::CommandPlan)> {
854 let Some((creation, remainder)) = plan.split_at_target_creation() else {
855 let outputs = plan.execute_concurrent(executor)?;
858 return discover(&outputs).map(|locator| {
859 (
860 locator,
861 targets::CommandPlan {
862 description: plan.description.clone(),
863 commands: Vec::new(),
864 },
865 )
866 });
867 };
868 let outputs = creation.execute_concurrent(executor)?;
869 discover(&outputs)
870 .map(|locator| (locator, remainder))
871 .map_err(|error| {
872 match cleanup_failed_provision(target, session_id, outputs.first(), executor) {
873 Some(note) => error.context(note),
874 None => error,
875 }
876 })
877}
878
879fn cleanup_failed_provision(
886 target: &targets::TargetTemplate,
887 session_id: &str,
888 create_output: Option<&CommandOutput>,
889 executor: &impl CommandExecutor,
890) -> Option<String> {
891 let locator = provisioned_locator(target, session_id, create_output)?;
892 let leak = format!(
893 "the resource may still exist; find it via its dev.mj.session={session_id} label/tag"
894 );
895 let plan = match targets::close_plan(&locator, session_id) {
896 Ok(plan) => plan,
897 Err(error) => {
898 tracing::warn!(
899 session_id,
900 error = format!("{error:#}"),
901 "could not build provisioning cleanup plan"
902 );
903 return Some(format!("cleanup FAILED: {error:#}; {leak}"));
904 }
905 };
906 let purpose = plan
907 .commands
908 .iter()
909 .map(|command| command.purpose.clone())
910 .collect::<Vec<_>>()
911 .join("; ");
912 let Err(error) = plan.execute(executor) else {
913 return Some(format!("cleanup succeeded: {purpose}"));
914 };
915 match targets::cleanup_target_is_confirmed_absent(&locator, session_id, executor) {
916 Ok(true) => Some(format!("cleanup succeeded: {purpose}")),
917 Ok(false) => {
918 tracing::warn!(
919 session_id,
920 error = format!("{error:#}"),
921 "provisioning cleanup failed and the target may still exist"
922 );
923 Some(format!("cleanup FAILED ({purpose}): {error:#}; {leak}"))
924 }
925 Err(confirm_error) => {
926 tracing::warn!(
927 session_id,
928 error = format!("{confirm_error:#}"),
929 "could not confirm whether the failed provisioning target was removed"
930 );
931 Some(format!(
932 "cleanup FAILED ({purpose}): {error:#}; checking whether it was removed also failed: {confirm_error:#}; {leak}"
933 ))
934 }
935 }
936}
937
938fn provisioned_locator(
943 target: &targets::TargetTemplate,
944 session_id: &str,
945 create_output: Option<&CommandOutput>,
946) -> Option<targets::TargetLocator> {
947 let container_id = || targets::resource_name(session_id).ok();
948 Some(match target {
949 targets::TargetTemplate::LocalBare => return None,
952 targets::TargetTemplate::LocalPodman(container) => targets::TargetLocator::LocalPodman {
953 container_id: container_id()?,
954 workspace_storage: targets::podman_workspace_locator(container, session_id).ok()?,
955 },
956 targets::TargetTemplate::LocalDocker(_) => targets::TargetLocator::LocalDocker {
957 container_id: container_id()?,
958 },
959 targets::TargetTemplate::AppleContainer(_) => targets::TargetLocator::AppleContainer {
960 container_id: container_id()?,
961 },
962 targets::TargetTemplate::SshPodman { ssh, container } => {
963 targets::TargetLocator::SshPodman {
964 ssh: ssh.clone(),
965 container_id: container_id()?,
966 workspace_storage: targets::podman_workspace_locator(container, session_id).ok()?,
967 }
968 }
969 targets::TargetTemplate::SshDocker { ssh, .. } => targets::TargetLocator::SshDocker {
970 ssh: ssh.clone(),
971 container_id: container_id()?,
972 },
973 targets::TargetTemplate::SshBare { ssh, .. } => targets::TargetLocator::SshBare {
974 ssh: ssh.clone(),
975 workspace: targets::workspace_for(target, session_id).ok()?,
976 worker_id: None,
977 },
978 targets::TargetTemplate::AwsEc2(aws) => targets::TargetLocator::AwsEc2 {
979 profile: aws.profile.clone(),
980 region: aws.region.clone(),
981 instance_id: serde_json::from_slice::<serde_json::Value>(&create_output?.stdout)
982 .ok()?
983 .pointer("/Instances/0/InstanceId")?
984 .as_str()?
985 .to_owned(),
986 ssh: aws.ssh.clone(),
987 workspace: targets::workspace_for(target, session_id).ok()?,
988 },
989 })
990}
991
992pub(super) fn enforce_overlay_capable_mounts(
1003 target: &targets::TargetTemplate,
1004 mounts: &mut [targets::AdditionalMount],
1005 executor: &impl CommandExecutor,
1006) -> Vec<String> {
1007 let ssh = match target {
1008 targets::TargetTemplate::LocalPodman(_) | targets::TargetTemplate::LocalDocker(_) => None,
1009 targets::TargetTemplate::SshPodman { ssh, .. }
1010 | targets::TargetTemplate::SshDocker { ssh, .. } => Some(ssh),
1011 _ => return Vec::new(),
1012 };
1013 let overlaid = mounts
1014 .iter()
1015 .filter(|mount| !mount.read_only)
1016 .map(|mount| mount.source.clone())
1017 .collect::<Vec<_>>();
1018 if overlaid.is_empty() {
1019 return Vec::new();
1020 }
1021 let filesystems = match targets::probe_filesystem_types(ssh, &overlaid, executor) {
1022 Ok(filesystems) => filesystems,
1023 Err(error) => {
1024 tracing::warn!(
1025 error = format!("{error:#}"),
1026 "could not probe attached-directory filesystems; preserving overlay mounts"
1027 );
1028 return vec![format!(
1029 "Could not read the filesystem under the attached directories, so they keep the \
1030 copy-on-write overlay: {error:#}"
1031 )];
1032 }
1033 };
1034 let mut notices = Vec::new();
1035 for (mount, filesystem) in mounts
1036 .iter_mut()
1037 .filter(|mount| !mount.read_only)
1038 .zip(filesystems)
1039 {
1040 let Some(reason) = targets::overlay_unsupported_filesystem(&filesystem) else {
1041 continue;
1042 };
1043 mount.read_only = true;
1044 notices.push(format!(
1045 "Mounted {} read-only: the overlay is unreliable on {filesystem} ({reason}).",
1046 mount.source.display()
1047 ));
1048 }
1049 notices
1050}
1051
1052pub(super) struct StagedExecutor<'a, E: CommandExecutor> {
1056 inner: &'a E,
1057 stage: ProvisionStage,
1058 _guard: ProvisionStageGuard<'a, E>,
1059}
1060
1061impl<'a, E: CommandExecutor> StagedExecutor<'a, E> {
1062 pub(crate) fn new(inner: &'a E, stage: ProvisionStage) -> Self {
1063 Self {
1064 inner,
1065 stage,
1066 _guard: ProvisionStageGuard::new(inner, stage),
1067 }
1068 }
1069
1070 fn staged(&self, command: &CommandSpec) -> CommandSpec {
1071 if command.stage.is_some() {
1072 return command.clone();
1073 }
1074 command.clone().stage(self.stage)
1075 }
1076}
1077
1078impl<E: CommandExecutor> CommandExecutor for StagedExecutor<'_, E> {
1079 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1080 self.inner.execute(&self.staged(command))
1081 }
1082
1083 fn cancellation_requested(&self) -> bool {
1084 self.inner.cancellation_requested()
1085 }
1086
1087 fn stage_started(&self, stage: ProvisionStage) {
1088 self.inner.stage_started(stage);
1089 }
1090
1091 fn stage_finished(&self, stage: ProvisionStage) {
1092 self.inner.stage_finished(stage);
1093 }
1094
1095 fn notify_notice(&self, notice: &str) {
1096 self.inner.notify_notice(notice);
1097 }
1098
1099 fn execute_with_stdin(
1100 &self,
1101 command: &CommandSpec,
1102 input: &mut (dyn std::io::Read + Send),
1103 ) -> Result<CommandOutput> {
1104 self.inner.execute_with_stdin(&self.staged(command), input)
1105 }
1106}
1107
1108fn execute_checked_with_stdin(
1109 executor: &impl CommandExecutor,
1110 command: &CommandSpec,
1111 input: &mut (dyn std::io::Read + Send),
1112) -> Result<CommandOutput> {
1113 let output = executor.execute_with_stdin(command, input)?;
1114 if output.status != 0 {
1115 bail!(
1116 "{} failed with status {}: {}",
1117 command.purpose,
1118 output.status,
1119 String::from_utf8_lossy(&output.stderr)
1120 );
1121 }
1122 Ok(output)
1123}
1124
1125pub(super) fn install_inherited_git_settings(
1126 executor: &impl CommandExecutor,
1127 locator: &targets::TargetLocator,
1128 session_id: &str,
1129) -> Result<()> {
1130 let settings = if inherits_controller_git_settings(locator) {
1131 controller_git_settings()?
1132 } else {
1133 BTreeMap::new()
1134 };
1135 for command in inherited_git_setting_commands(locator, session_id, settings)? {
1136 execute_checked(executor, command)?;
1137 }
1138 Ok(())
1139}
1140
1141fn inherits_controller_git_settings(locator: &targets::TargetLocator) -> bool {
1142 !matches!(
1143 locator,
1144 targets::TargetLocator::LocalBare { .. } | targets::TargetLocator::SshBare { .. }
1145 )
1146}
1147
1148fn inherited_git_setting_commands(
1149 locator: &targets::TargetLocator,
1150 session_id: &str,
1151 settings: BTreeMap<String, String>,
1152) -> Result<Vec<CommandSpec>> {
1153 if matches!(locator, targets::TargetLocator::SshBare { .. }) {
1154 return Ok(Vec::new());
1155 }
1156 settings
1157 .into_iter()
1158 .map(|(key, value)| {
1159 targets::command_on_locator(
1160 locator,
1161 session_id,
1162 vec![
1163 "git".into(),
1164 "config".into(),
1165 "--global".into(),
1166 "--replace-all".into(),
1167 "--".into(),
1168 key.clone(),
1169 value,
1170 ],
1171 format!("inherit Git setting {key}"),
1172 )
1173 })
1174 .collect()
1175}
1176
1177fn controller_git_settings() -> Result<BTreeMap<String, String>> {
1178 let output = match Command::new("git")
1179 .args(["config", "--global", "--includes", "--null", "--list"])
1180 .stdin(Stdio::null())
1181 .output()
1182 {
1183 Ok(output) => output,
1184 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
1185 Err(error) => return Err(error).context("read controller Git configuration"),
1186 };
1187 if !output.status.success() {
1188 bail!(
1189 "read controller Git configuration failed with status {}: {}",
1190 output.status,
1191 String::from_utf8_lossy(&output.stderr).trim()
1192 );
1193 }
1194 parse_inherited_git_settings(&output.stdout)
1195}
1196
1197fn parse_inherited_git_settings(output: &[u8]) -> Result<BTreeMap<String, String>> {
1198 let mut settings = BTreeMap::new();
1199 for entry in output
1200 .split(|byte| *byte == 0)
1201 .filter(|entry| !entry.is_empty())
1202 {
1203 let entry = std::str::from_utf8(entry).context("decode controller Git configuration")?;
1204 let (key, value) = entry
1205 .split_once('\n')
1206 .with_context(|| format!("controller Git returned malformed entry {entry:?}"))?;
1207 let key = key.to_ascii_lowercase();
1208 if INHERITED_GIT_SETTINGS.contains(&key.as_str()) {
1209 settings.insert(key, value.to_owned());
1210 }
1211 }
1212 Ok(settings)
1213}
1214
1215#[cfg(test)]
1216mod tests {
1217 use std::collections::BTreeMap;
1218
1219 use std::sync::Mutex;
1220
1221 use mj_core::config::{
1222 Config, ContainerTemplate as ConfigContainer, HarnessKind, HarnessProfile, ProjectBundle,
1223 ProjectRepository, SshConnection,
1224 };
1225 use mj_core::state::{SessionRecord, SessionState, State, TargetLocator};
1226
1227 use crate::targets::{self, AdditionalMount, ContainerTemplate, ProjectBundleSpec, SshTarget};
1228
1229 use crate::controller::SessionLaunchOptions;
1230
1231 use super::*;
1232
1233 struct ProbeExecutor {
1236 answer: std::result::Result<&'static str, &'static str>,
1237 notices: Mutex<Vec<String>>,
1238 }
1239
1240 impl ProbeExecutor {
1241 fn answering(answer: &'static str) -> Self {
1242 Self {
1243 answer: Ok(answer),
1244 notices: Mutex::new(Vec::new()),
1245 }
1246 }
1247
1248 fn failing(stderr: &'static str) -> Self {
1249 Self {
1250 answer: Err(stderr),
1251 notices: Mutex::new(Vec::new()),
1252 }
1253 }
1254 }
1255
1256 impl CommandExecutor for ProbeExecutor {
1257 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1258 assert_eq!(command.program, "stat", "only the probe may run here");
1259 Ok(match self.answer {
1260 Ok(filesystem) => CommandOutput {
1261 status: 0,
1262 stdout: format!("{filesystem}\n").into_bytes(),
1263 stderr: Vec::new(),
1264 },
1265 Err(stderr) => CommandOutput {
1266 status: 1,
1267 stdout: Vec::new(),
1268 stderr: stderr.as_bytes().to_vec(),
1269 },
1270 })
1271 }
1272
1273 fn notify_notice(&self, notice: &str) {
1274 self.notices.lock().unwrap().push(notice.to_owned());
1275 }
1276 }
1277
1278 fn podman_target() -> targets::TargetTemplate {
1279 targets::TargetTemplate::LocalPodman(ContainerTemplate {
1280 image: "ubuntu:24.04".into(),
1281 pull_policy: Default::default(),
1282 extra_run_args: Vec::new(),
1283 workspace_storage: Default::default(),
1284 })
1285 }
1286
1287 fn probe_bundle() -> ProjectBundleSpec {
1288 ProjectBundleSpec {
1289 primary: "app".into(),
1290 repositories: vec![crate::targets::RepositorySpec {
1291 url: Some("https://github.com/example/app.git".into()),
1292 push_urls: Vec::new(),
1293 destination: "app".into(),
1294 git_ref: None,
1295 reference: None,
1296 }],
1297 }
1298 }
1299
1300 fn ssh_docker_registration_config() -> Config {
1301 let mut config = Config::default();
1302 config.profiles.insert(
1303 "codex".into(),
1304 HarnessProfile {
1305 enabled: true,
1306 kind: HarnessKind::Codex,
1307 home: PathBuf::from("/home/dev/.codex"),
1308 environment: BTreeMap::new(),
1309 context_window_bytes: None,
1310 guardian_review_model: None,
1311 },
1312 );
1313 config.bundles.insert(
1314 "project".into(),
1315 ProjectBundle {
1316 primary_repo: "project".into(),
1317 repositories: vec![ProjectRepository {
1318 id: "project".into(),
1319 github: Some("owner/project".into()),
1320 local: None,
1321 destination: PathBuf::from("project"),
1322 git_ref: None,
1323 }],
1324 },
1325 );
1326 config.targets.insert(
1327 "docker".into(),
1328 TargetTemplate::SshDocker {
1329 ssh: SshConnection {
1330 host: "builder".into(),
1331 user: Some("agent".into()),
1332 identity_file: None,
1333 extra_args: Vec::new(),
1334 },
1335 container: ConfigContainer {
1336 image: "failimage:never".into(),
1337 pull_policy: Default::default(),
1338 platform: None,
1339 cpus: None,
1340 memory: None,
1341 environment: BTreeMap::new(),
1342 workspace_storage: Default::default(),
1343 },
1344 },
1345 );
1346 config
1347 }
1348
1349 #[test]
1350 fn a_source_that_cannot_overlay_is_mounted_read_only_and_reported() {
1351 let executor = ProbeExecutor::answering("nfs");
1352 let mut mounts = vec![AdditionalMount {
1353 source: PathBuf::from("/nfs/share"),
1354 destination: PathBuf::from("/mnt/share"),
1355 read_only: false,
1356 }];
1357
1358 let notices = enforce_overlay_capable_mounts(&podman_target(), &mut mounts, &executor);
1359
1360 assert!(mounts[0].read_only);
1361 assert_eq!(notices.len(), 1);
1362 assert!(
1363 notices[0]
1364 .contains("Mounted /nfs/share read-only: the overlay is unreliable on nfs (network filesystem)"),
1365 "{notices:?}"
1366 );
1367 let plan = targets::provision_plan(
1368 &podman_target(),
1369 "0123456789abcdef0123456789abcdef",
1370 &probe_bundle(),
1371 &mounts,
1372 )
1373 .unwrap();
1374 assert!(
1375 plan.commands[0]
1376 .args
1377 .windows(2)
1378 .any(|args| args == ["--volume", "/nfs/share:/mnt/share:ro"]),
1379 "{:?}",
1380 plan.commands[0].args
1381 );
1382 }
1383
1384 #[test]
1385 fn a_probe_that_cannot_answer_keeps_the_overlay_and_says_so() {
1386 let executor = ProbeExecutor::failing("stat: cannot read file system information");
1387 let mut mounts = vec![AdditionalMount {
1388 source: PathBuf::from("/host/cache"),
1389 destination: PathBuf::from("/mnt/cache"),
1390 read_only: false,
1391 }];
1392
1393 let notices = enforce_overlay_capable_mounts(&podman_target(), &mut mounts, &executor);
1394
1395 assert!(!mounts[0].read_only);
1396 assert_eq!(notices.len(), 1);
1397 assert!(
1398 notices[0].contains("keep the copy-on-write overlay")
1399 && notices[0].contains("cannot read file system information"),
1400 "{notices:?}"
1401 );
1402 let plan = targets::provision_plan(
1403 &podman_target(),
1404 "0123456789abcdef0123456789abcdef",
1405 &probe_bundle(),
1406 &mounts,
1407 )
1408 .unwrap();
1409 assert!(
1410 plan.commands[0]
1411 .args
1412 .windows(2)
1413 .any(|args| args == ["--volume", "/host/cache:/mnt/cache:O"]),
1414 "{:?}",
1415 plan.commands[0].args
1416 );
1417 }
1418
1419 #[test]
1420 fn engines_without_an_overlay_to_lose_are_never_probed() {
1421 struct UnusedExecutor;
1422
1423 impl CommandExecutor for UnusedExecutor {
1424 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1425 panic!("this target must not probe: {}", command.program)
1426 }
1427 }
1428
1429 let mut mounts = vec![AdditionalMount {
1430 source: PathBuf::from("/host/cache"),
1431 destination: PathBuf::from("/mnt/cache"),
1432 read_only: false,
1433 }];
1434 for target in [
1435 targets::TargetTemplate::AppleContainer(ContainerTemplate {
1436 image: "ubuntu:24.04".into(),
1437 pull_policy: Default::default(),
1438 extra_run_args: Vec::new(),
1439 workspace_storage: Default::default(),
1440 }),
1441 targets::TargetTemplate::AwsEc2(targets::AwsTemplate {
1442 profile: "default".into(),
1443 region: "us-east-1".into(),
1444 launch_template: "lt-0123456789abcdef0".into(),
1445 launch_template_version: None,
1446 instance_type: None,
1447 ssh: SshTarget {
1448 destination: "ubuntu@example.test".into(),
1449 ssh_args: Vec::new(),
1450 },
1451 }),
1452 ] {
1453 assert!(
1454 enforce_overlay_capable_mounts(&target, &mut mounts, &UnusedExecutor).is_empty()
1455 );
1456 assert!(!mounts[0].read_only);
1457 }
1458 }
1459
1460 #[test]
1463 fn mounts_already_read_only_are_not_probed() {
1464 struct UnusedExecutor;
1465
1466 impl CommandExecutor for UnusedExecutor {
1467 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1468 panic!("a read-only mount must not probe: {}", command.program)
1469 }
1470 }
1471
1472 let mut mounts = vec![AdditionalMount {
1473 source: PathBuf::from("/host/cache"),
1474 destination: PathBuf::from("/mnt/cache"),
1475 read_only: true,
1476 }];
1477
1478 assert!(
1479 enforce_overlay_capable_mounts(&podman_target(), &mut mounts, &UnusedExecutor)
1480 .is_empty()
1481 );
1482 }
1483
1484 #[test]
1485 fn failed_new_session_provisioning_retains_error_record() {
1486 let session_id = "0123456789abcdef0123456789abcdef";
1487 let record = SessionRecord {
1488 mjolnir_subagents: None,
1489 create_managed_worktree: None,
1490 workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1491 archived: false,
1492 container_cpus: None,
1493 container_memory: None,
1494 id: session_id.into(),
1495 title: "new session".into(),
1496 harness_kind: mj_core::config::HarnessKind::Codex,
1497 last_profile: "codex".into(),
1498 bundle_id: "project".into(),
1499 project_directory: None,
1500 managed_worktree: None,
1501 target_template_id: "podman".into(),
1502 resource_allocation: None,
1503 additional_mounts: Vec::new(),
1504 state: SessionState::Provisioning,
1505 target: None,
1506 native_session_id: None,
1507 acp_session_title: None,
1508 session_title_override: None,
1509 created_at: "2026-08-12T00:00:00Z".into(),
1510 updated_at: "2026-08-12T00:00:00Z".into(),
1511 viewed_through_event_ordinal: 0,
1512 draft_input: String::new(),
1513 last_error: None,
1514 last_checkpoint_error: None,
1515 checkpoint: None,
1516 };
1517 let mut state = State::default();
1518 state.sessions.insert(session_id.into(), record);
1519
1520 let result = apply_new_session_provisioning_result(
1521 &mut state,
1522 session_id,
1523 Err(anyhow::anyhow!("container creation failed")),
1524 );
1525
1526 assert!(result.is_err());
1527 let retained = &state.sessions[session_id];
1528 assert_eq!(retained.state, SessionState::Error);
1529 assert!(retained.target.is_none());
1530 assert!(
1531 retained
1532 .last_error
1533 .as_deref()
1534 .unwrap()
1535 .contains("container creation failed")
1536 );
1537 }
1538
1539 const SSH_DOCKER_FAILURE_CHILD: &str = "MJ_TEST_SSH_DOCKER_FAILURE_CHILD";
1540
1541 #[test]
1542 fn failed_ssh_docker_preflight_retains_durable_error_record() {
1543 if std::env::var_os(SSH_DOCKER_FAILURE_CHILD).is_none() {
1544 let directory = tempfile::tempdir().unwrap();
1545 let test = "failed_ssh_docker_preflight_retains_durable_error_record";
1546 let mut command = std::process::Command::new(std::env::current_exe().unwrap());
1547 command
1548 .args([
1549 "--exact",
1550 &format!("controller::provisioning::tests::{test}"),
1551 "--nocapture",
1552 ])
1553 .env(SSH_DOCKER_FAILURE_CHILD, "1")
1554 .env("MJ_DATA_DIR", directory.path())
1555 .env("MJ_CONFIG_DIR", directory.path());
1556 let output = mj_core::subprocess::run_with_input(&mut command, &[]).unwrap();
1557 assert!(
1558 output.status.success(),
1559 "isolated {test} failed\nstdout:\n{}\nstderr:\n{}",
1560 String::from_utf8_lossy(&output.stdout),
1561 String::from_utf8_lossy(&output.stderr)
1562 );
1563 return;
1564 }
1565
1566 let _writer = crate::database::install_isolated_test_writer();
1567 let config = ssh_docker_registration_config();
1568 config.save().unwrap();
1569 let mut controller = Controller {
1570 config,
1571 state: State::default(),
1572 };
1573 let session_id = controller
1574 .register_session_with_resources(
1575 "codex",
1576 "project",
1577 "docker",
1578 "failed image",
1579 SessionLaunchOptions {
1580 mjolnir_subagents: None,
1581 create_managed_worktree: None,
1582 initial_prompt: None,
1583 workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1584 additional_mounts: Vec::new(),
1585 allow_dirty_local: false,
1586 resource_allocation: None,
1587 project_directory: None,
1588 session_title_override: None,
1589 },
1590 )
1591 .unwrap();
1592 assert!(
1593 crate::database::load_state()
1594 .unwrap()
1595 .sessions
1596 .contains_key(&session_id)
1597 );
1598
1599 let executor = RecordingExecutor::failing("check Docker daemon");
1600 let error =
1601 futures::executor::block_on(controller.provision_session_with_failure_disposition(
1602 &session_id,
1603 &executor,
1604 None,
1605 ProvisioningFailureDisposition::Discard,
1606 ))
1607 .unwrap_err();
1608 let reported = format!("{error:#}");
1609 assert!(
1610 reported.contains("remote Docker preflight failed"),
1611 "{reported}"
1612 );
1613 assert!(
1614 executor.commands().iter().any(|argv| {
1615 let command = argv.join(" ");
1616 command.contains("'docker' 'version'")
1617 }),
1618 "the fake preflight did not run: {:?}",
1619 executor.commands()
1620 );
1621 let retained = &controller.state.sessions[&session_id];
1622 assert_eq!(retained.state, SessionState::Error);
1623 assert!(retained.target.is_none());
1624
1625 let reloaded = Controller::load().unwrap();
1626 let retained = &reloaded.state.sessions[&session_id];
1627 assert_eq!(retained.state, SessionState::Error);
1628 assert!(retained.target.is_none());
1629 assert!(retained.last_error.is_some());
1630 }
1631
1632 #[test]
1633 fn failed_node_preflight_retains_error_before_provisioning() {
1634 if std::env::var_os(SSH_DOCKER_FAILURE_CHILD).is_none() {
1635 let directory = tempfile::tempdir().unwrap();
1636 let test = "failed_node_preflight_retains_error_before_provisioning";
1637 let mut command = std::process::Command::new(std::env::current_exe().unwrap());
1638 command
1639 .args([
1640 "--exact",
1641 &format!("controller::provisioning::tests::{test}"),
1642 "--nocapture",
1643 ])
1644 .env(SSH_DOCKER_FAILURE_CHILD, "1")
1645 .env("MJ_DATA_DIR", directory.path())
1646 .env("MJ_CONFIG_DIR", directory.path());
1647 let output = mj_core::subprocess::run_with_input(&mut command, &[]).unwrap();
1648 assert!(
1649 output.status.success(),
1650 "isolated {test} failed\nstdout:\n{}\nstderr:\n{}",
1651 String::from_utf8_lossy(&output.stdout),
1652 String::from_utf8_lossy(&output.stderr)
1653 );
1654 return;
1655 }
1656
1657 let _writer = crate::database::install_isolated_test_writer();
1658 let mut config = ssh_docker_registration_config();
1659 config.targets.insert(
1660 "docker".into(),
1661 TargetTemplate::SshBare {
1662 ssh: SshConnection {
1663 host: "builder".into(),
1664 user: Some("agent".into()),
1665 identity_file: None,
1666 extra_args: Vec::new(),
1667 },
1668 permissions: mj_core::config::PermissionMode::Guardian,
1669 workspace_prefix: PathBuf::from(".local/share/hel/workspaces"),
1670 },
1671 );
1672 config.save().unwrap();
1673 let mut controller = Controller {
1674 config,
1675 state: State::default(),
1676 };
1677 let session_id = controller
1678 .register_session_with_resources(
1679 "codex",
1680 "project",
1681 "docker",
1682 "missing Node",
1683 SessionLaunchOptions {
1684 mjolnir_subagents: None,
1685 create_managed_worktree: None,
1686 initial_prompt: None,
1687 workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1688 additional_mounts: Vec::new(),
1689 allow_dirty_local: false,
1690 resource_allocation: None,
1691 project_directory: Some("/srv/project".into()),
1692 session_title_override: None,
1693 },
1694 )
1695 .unwrap();
1696 assert!(
1697 crate::database::load_state()
1698 .unwrap()
1699 .sessions
1700 .contains_key(&session_id)
1701 );
1702
1703 let executor = RecordingExecutor::failing("preflight managed harness Node.js and npm");
1704 let error =
1705 futures::executor::block_on(controller.provision_session_with_failure_disposition(
1706 &session_id,
1707 &executor,
1708 None,
1709 ProvisioningFailureDisposition::Discard,
1710 ))
1711 .unwrap_err();
1712 let reported = format!("{error:#}");
1713 assert!(reported.contains("Node.js 22+ and npm"), "{reported}");
1714 assert_eq!(
1715 executor.commands().len(),
1716 1,
1717 "preflight must fail before provisioning"
1718 );
1719 let retained = &controller.state.sessions[&session_id];
1720 assert_eq!(retained.state, SessionState::Error);
1721 assert!(retained.target.is_none());
1722
1723 let reloaded = Controller::load().unwrap();
1724 let retained = &reloaded.state.sessions[&session_id];
1725 assert_eq!(retained.state, SessionState::Error);
1726 assert!(retained.target.is_none());
1727 assert!(retained.last_error.is_some());
1728 }
1729
1730 #[test]
1731 fn failed_new_worker_start_retains_session_only_after_target_cleanup() {
1732 let session_id = "0123456789abcdef0123456789abcdef";
1733 let mut session = SessionRecord {
1734 mjolnir_subagents: None,
1735 create_managed_worktree: None,
1736 workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1737 archived: false,
1738 container_cpus: None,
1739 container_memory: None,
1740 id: session_id.into(),
1741 title: "new session".into(),
1742 harness_kind: mj_core::config::HarnessKind::Kimi,
1743 last_profile: "kimi".into(),
1744 bundle_id: "raw-project".into(),
1745 project_directory: Some("/srv/project".into()),
1746 managed_worktree: None,
1747 target_template_id: "remote".into(),
1748 resource_allocation: None,
1749 additional_mounts: Vec::new(),
1750 state: SessionState::Disconnected,
1751 target: Some(TargetLocator::SshBare {
1752 host: "builder".into(),
1753 workspace: format!(".local/share/hel/workspaces/{session_id}").into(),
1754 worker_id: None,
1755 }),
1756 native_session_id: None,
1757 acp_session_title: None,
1758 session_title_override: None,
1759 created_at: "2026-08-12T00:00:00Z".into(),
1760 updated_at: "2026-08-12T00:00:00Z".into(),
1761 viewed_through_event_ordinal: 0,
1762 draft_input: String::new(),
1763 last_error: None,
1764 last_checkpoint_error: None,
1765 checkpoint: None,
1766 };
1767 let mut cleaned = State::default();
1768 cleaned.sessions.insert(session_id.into(), session.clone());
1769
1770 let failure =
1771 apply_failed_new_session_rollback(&mut cleaned, session_id, "ACP startup failed", None);
1772
1773 let retained = &cleaned.sessions[session_id];
1774 assert_eq!(retained.state, SessionState::Error);
1775 assert!(retained.target.is_none());
1776 assert!(failure.to_string().contains("failed session retained"));
1777
1778 session.state = SessionState::Disconnected;
1779 let mut cleanup_failed = State::default();
1780 cleanup_failed.sessions.insert(session_id.into(), session);
1781 let failure = apply_failed_new_session_rollback(
1782 &mut cleanup_failed,
1783 session_id,
1784 "ACP startup failed",
1785 Some("ssh unavailable".into()),
1786 );
1787 let retained = cleanup_failed.sessions.get(session_id).unwrap();
1788 assert_eq!(retained.state, SessionState::Error);
1789 assert!(retained.target.is_some());
1790 assert!(failure.to_string().contains("cleanup"));
1791 }
1792 #[test]
1793 fn launch_failure_is_persisted_separately_from_session_state() {
1794 let directory = tempfile::tempdir().unwrap();
1795 let session_id = "0123456789abcdef0123456789abcdef";
1796 let detail = format!(
1797 "specific startup cause\n{}\nstderr tail survives",
1798 "x".repeat(MAX_LAUNCH_DIAGNOSTIC_BYTES)
1799 );
1800
1801 let path = persist_launch_failure_to(directory.path(), session_id, &detail).unwrap();
1802 let saved = std::fs::read_to_string(path).unwrap();
1803
1804 assert!(saved.contains("specific startup cause"));
1805 assert!(saved.contains("launch diagnostic truncated"));
1806 assert!(saved.contains("stderr tail survives"));
1807 #[cfg(unix)]
1808 {
1809 use std::os::unix::fs::PermissionsExt;
1810 assert_eq!(
1811 std::fs::metadata(directory.path())
1812 .unwrap()
1813 .permissions()
1814 .mode()
1815 & 0o777,
1816 0o700
1817 );
1818 }
1819 }
1820
1821 #[test]
1822 fn noting_a_launch_failure_writes_the_diagnostic_and_returns_the_reason() {
1823 let directory = tempfile::tempdir().unwrap();
1824 let session_id = "0123456789abcdef0123456789abcdef";
1825 let error =
1826 anyhow::anyhow!("connect worker").context("Connection closed by 10.0.0.1 port 22");
1827
1828 let detail = note_new_session_launch_failure_in(directory.path(), session_id, &error);
1829
1830 assert!(
1831 detail.contains("Connection closed by 10.0.0.1 port 22"),
1832 "the returned reason keeps the underlying error text"
1833 );
1834 assert!(
1835 detail.contains("full diagnostic saved to"),
1836 "the reason points at the saved diagnostic"
1837 );
1838 let saved = std::fs::read_to_string(
1839 directory
1840 .path()
1841 .join(format!("{session_id}-launch-error.txt")),
1842 )
1843 .unwrap();
1844 assert!(saved.contains("Connection closed by 10.0.0.1 port 22"));
1845 }
1846 #[test]
1847 fn inherited_git_settings_allow_only_portable_non_executable_values() {
1848 let settings = parse_inherited_git_settings(
1849 b"user.name\nAgent User\0USER.EMAIL\nagent@example.test\0pull.rebase\ntrue\0alias.deploy\n!ship\0credential.helper\nstore\0core.editor\nvim\0include.path\n/host/config\0user.name\nFinal User\0",
1850 )
1851 .unwrap();
1852
1853 assert_eq!(
1854 settings,
1855 BTreeMap::from([
1856 ("pull.rebase".into(), "true".into()),
1857 ("user.email".into(), "agent@example.test".into()),
1858 ("user.name".into(), "Final User".into()),
1859 ])
1860 );
1861 }
1862 #[test]
1863 fn inherited_git_settings_reject_malformed_or_non_utf8_output() {
1864 assert!(parse_inherited_git_settings(b"user.name\0").is_err());
1865 assert!(parse_inherited_git_settings(b"user.name\n\xff\0").is_err());
1866 }
1867 #[test]
1868 fn inherited_git_settings_target_only_isolated_workers() {
1869 let ssh = SshTarget {
1870 destination: "worker@example.test".into(),
1871 ssh_args: vec!["-p".into(), "2222".into()],
1872 };
1873 let ephemeral = [
1874 targets::TargetLocator::LocalPodman {
1875 container_id: "abcdef012345".into(),
1876 workspace_storage: Default::default(),
1877 },
1878 targets::TargetLocator::AppleContainer {
1879 container_id: "abcdef012346".into(),
1880 },
1881 targets::TargetLocator::AwsEc2 {
1882 profile: "default".into(),
1883 region: "us-east-1".into(),
1884 instance_id: "i-1234567890abcdef0".into(),
1885 ssh: ssh.clone(),
1886 workspace: ".local/share/hel/workspaces/018f9dd2-a3b4-7c8d-9000-123456789abc"
1887 .into(),
1888 },
1889 targets::TargetLocator::SshPodman {
1890 ssh: ssh.clone(),
1891 container_id: "abcdef012347".into(),
1892 workspace_storage: Default::default(),
1893 },
1894 ];
1895 for locator in &ephemeral {
1896 assert!(inherits_controller_git_settings(locator));
1897 let commands = inherited_git_setting_commands(
1898 locator,
1899 "018f9dd2-a3b4-7c8d-9000-123456789abc",
1900 BTreeMap::from([("user.name".into(), "- Agent O'Brien 日本語".into())]),
1901 )
1902 .unwrap();
1903 assert_eq!(commands.len(), 1);
1904 assert!(
1905 commands[0]
1906 .args
1907 .iter()
1908 .any(|argument| argument.contains("user.name"))
1909 );
1910 assert!(
1911 commands[0]
1912 .args
1913 .iter()
1914 .any(|argument| argument.contains("- Agent O'"))
1915 );
1916 }
1917
1918 let persistent = targets::TargetLocator::SshBare {
1919 worker_id: None,
1920 ssh,
1921 workspace: "/srv/hel/018f9dd2-a3b4-7c8d-9000-123456789abc".into(),
1922 };
1923 let local = targets::TargetLocator::LocalBare {
1924 worker_root: "/var/lib/hel/workers/018f9dd2-a3b4-7c8d-9000-123456789abc".into(),
1925 };
1926 assert!(!inherits_controller_git_settings(&persistent));
1927 assert!(!inherits_controller_git_settings(&local));
1928 assert!(
1929 inherited_git_setting_commands(
1930 &persistent,
1931 "018f9dd2-a3b4-7c8d-9000-123456789abc",
1932 BTreeMap::from([("user.name".into(), "Agent".into())]),
1933 )
1934 .unwrap()
1935 .is_empty()
1936 );
1937 }
1938
1939 #[test]
1940 fn raw_ssh_targets_select_permissions_and_ssh_podman_is_unconstrained() {
1941 let ssh = mj_core::config::SshConnection {
1942 host: "builder".into(),
1943 user: None,
1944 identity_file: None,
1945 extra_args: Vec::new(),
1946 };
1947 let guardian = TargetTemplate::SshBare {
1948 ssh: ssh.clone(),
1949 permissions: mj_core::config::PermissionMode::Guardian,
1950 workspace_prefix: ".local/share/hel/workspaces".into(),
1951 };
1952 let podman = TargetTemplate::SshPodman {
1953 ssh: ssh.clone(),
1954 container: mj_core::config::ContainerTemplate {
1955 image: "example.invalid/agent:latest".into(),
1956 pull_policy: Default::default(),
1957 platform: None,
1958 cpus: None,
1959 memory: None,
1960 environment: BTreeMap::new(),
1961 workspace_storage: Default::default(),
1962 },
1963 };
1964 let yolo = TargetTemplate::SshBare {
1965 ssh,
1966 permissions: mj_core::config::PermissionMode::Yolo,
1967 workspace_prefix: ".local/share/hel/workspaces".into(),
1968 };
1969
1970 assert_eq!(
1971 TargetTemplate::LocalBare.execution_policy(),
1972 mj_core::config::ExecutionPolicy::ConfiguredApprovals
1973 );
1974 assert_eq!(
1975 guardian.execution_policy(),
1976 mj_core::config::ExecutionPolicy::ConfiguredApprovals
1977 );
1978 assert_eq!(
1979 podman.execution_policy(),
1980 mj_core::config::ExecutionPolicy::Unconstrained
1981 );
1982 assert_eq!(
1983 yolo.execution_policy(),
1984 mj_core::config::ExecutionPolicy::Unconstrained
1985 );
1986 }
1987 const PROVISIONED_SESSION: &str = "0123456789abcdef0123456789abcdef";
1988
1989 struct RecordingExecutor {
1992 failing_purpose: String,
1993 commands: Mutex<Vec<Vec<String>>>,
1994 }
1995
1996 impl RecordingExecutor {
1997 fn failing(purpose: impl Into<String>) -> Self {
1998 Self {
1999 failing_purpose: purpose.into(),
2000 commands: Mutex::new(Vec::new()),
2001 }
2002 }
2003
2004 fn succeeding() -> Self {
2005 Self::failing(String::new())
2006 }
2007
2008 fn commands(&self) -> Vec<Vec<String>> {
2009 self.commands.lock().unwrap().clone()
2010 }
2011 }
2012
2013 impl CommandExecutor for RecordingExecutor {
2014 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2015 let mut argv = vec![command.program.clone()];
2016 argv.extend(command.args.clone());
2017 self.commands.lock().unwrap().push(argv);
2018 Ok(CommandOutput {
2019 status: i32::from(command.purpose == self.failing_purpose),
2020 stdout: Vec::new(),
2021 stderr: b"the step failed".to_vec(),
2022 })
2023 }
2024 }
2025
2026 fn container_targets() -> Vec<targets::TargetTemplate> {
2027 let container = ContainerTemplate {
2028 image: "ubuntu:24.04".into(),
2029 pull_policy: Default::default(),
2030 extra_run_args: Vec::new(),
2031 workspace_storage: Default::default(),
2032 };
2033 vec![
2034 targets::TargetTemplate::LocalPodman(container.clone()),
2035 targets::TargetTemplate::AppleContainer(container.clone()),
2036 targets::TargetTemplate::SshPodman {
2037 ssh: SshTarget {
2038 destination: "dev@example.test".into(),
2039 ssh_args: vec!["-o".into(), "BatchMode=yes".into()],
2040 },
2041 container,
2042 },
2043 ]
2044 }
2045
2046 #[test]
2047 fn a_failure_after_the_container_exists_removes_it_and_keeps_the_original_error() {
2048 let name = targets::resource_name(PROVISIONED_SESSION).unwrap();
2049 for target in container_targets() {
2050 let plan = targets::provision_plan(&target, PROVISIONED_SESSION, &probe_bundle(), &[])
2051 .unwrap();
2052 let executor = RecordingExecutor::failing("clone app");
2053
2054 let error = provision_target(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2055 unreachable!("locator discovery must not run after a failed plan")
2056 })
2057 .unwrap_err();
2058
2059 let reported = format!("{error:#}");
2060 assert!(reported.contains("clone app failed"), "{reported}");
2061 assert!(reported.contains("cleanup succeeded"), "{reported}");
2062 let removal = executor
2064 .commands()
2065 .into_iter()
2066 .map(|arguments| arguments.join(" ").replace('\'', ""))
2067 .find(|command| command.contains("rm --force") && command.contains(&name))
2068 .expect("cleanup removes the exact provisioned container");
2069 assert!(removal.contains("rm --force"), "{removal}");
2070 assert!(removal.contains(&name), "{removal}");
2071 }
2072 }
2073
2074 #[test]
2075 fn target_creation_returns_repository_setup_without_running_it() {
2076 let target = podman_target();
2077 let plan =
2078 targets::provision_plan(&target, PROVISIONED_SESSION, &probe_bundle(), &[]).unwrap();
2079 let executor = RecordingExecutor::succeeding();
2080
2081 let (_, repositories) =
2082 provision_target_creation(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2083 Ok(TargetLocator::LocalPodman {
2084 container_id: targets::resource_name(PROVISIONED_SESSION)?,
2085 workspace_storage: Default::default(),
2086 })
2087 })
2088 .unwrap();
2089
2090 assert_eq!(executor.commands().len(), 1, "only podman run may execute");
2091 assert!(
2092 repositories
2093 .commands
2094 .iter()
2095 .any(|command| command.purpose == "clone app")
2096 );
2097 }
2098
2099 #[test]
2100 fn a_target_whose_creation_failed_is_never_torn_down() {
2101 for target in container_targets() {
2102 let plan = targets::provision_plan(&target, PROVISIONED_SESSION, &probe_bundle(), &[])
2103 .unwrap();
2104 let creation = plan.split_at_target_creation().unwrap().0;
2105 let executor =
2106 RecordingExecutor::failing(creation.commands.last().unwrap().purpose.clone());
2107
2108 let error = provision_target(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2109 unreachable!("locator discovery must not run after a failed plan")
2110 })
2111 .unwrap_err();
2112
2113 let reported = format!("{error:#}");
2114 assert!(!reported.contains("cleanup"), "{reported}");
2115 assert!(
2116 !executor
2117 .commands()
2118 .iter()
2119 .any(|argv| argv.join(" ").contains("rm --force")),
2120 "{:?}",
2121 executor.commands()
2122 );
2123 }
2124 }
2125
2126 #[test]
2127 fn a_target_whose_locator_cannot_be_discovered_is_removed_again() {
2128 let target = podman_target();
2129 let plan =
2130 targets::provision_plan(&target, PROVISIONED_SESSION, &probe_bundle(), &[]).unwrap();
2131 let executor = RecordingExecutor::succeeding();
2132
2133 let error = provision_target(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2134 bail!("the container never reported an address")
2135 })
2136 .unwrap_err();
2137
2138 let reported = format!("{error:#}");
2139 assert!(reported.contains("never reported an address"), "{reported}");
2140 assert!(reported.contains("cleanup succeeded"), "{reported}");
2141 let removal = executor
2142 .commands()
2143 .into_iter()
2144 .map(|arguments| arguments.join(" "))
2145 .find(|command| command.contains("podman rm --force --ignore"))
2146 .expect("cleanup removes the provisioned Podman container");
2147 assert!(removal.contains("podman rm --force --ignore"), "{removal}");
2148 }
2149
2150 #[test]
2153 fn a_bare_project_failure_removes_nothing() {
2154 let target = targets::TargetTemplate::LocalBare;
2155 let plan =
2156 targets::provision_bare_project_plan(&target, PROVISIONED_SESSION, "/srv/project")
2157 .unwrap();
2158 let executor = RecordingExecutor::succeeding();
2159
2160 let error = provision_target(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2161 bail!("the worker root was unreadable")
2162 })
2163 .unwrap_err();
2164
2165 assert!(!format!("{error:#}").contains("cleanup"));
2166 assert!(executor.commands().is_empty());
2167 }
2168}