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 },
1311 );
1312 config.bundles.insert(
1313 "project".into(),
1314 ProjectBundle {
1315 primary_repo: "project".into(),
1316 repositories: vec![ProjectRepository {
1317 id: "project".into(),
1318 github: Some("owner/project".into()),
1319 local: None,
1320 destination: PathBuf::from("project"),
1321 git_ref: None,
1322 }],
1323 },
1324 );
1325 config.targets.insert(
1326 "docker".into(),
1327 TargetTemplate::SshDocker {
1328 ssh: SshConnection {
1329 host: "builder".into(),
1330 user: Some("agent".into()),
1331 identity_file: None,
1332 extra_args: Vec::new(),
1333 },
1334 container: ConfigContainer {
1335 image: "failimage:never".into(),
1336 pull_policy: Default::default(),
1337 platform: None,
1338 cpus: None,
1339 memory: None,
1340 environment: BTreeMap::new(),
1341 workspace_storage: Default::default(),
1342 },
1343 },
1344 );
1345 config
1346 }
1347
1348 #[test]
1349 fn a_source_that_cannot_overlay_is_mounted_read_only_and_reported() {
1350 let executor = ProbeExecutor::answering("nfs");
1351 let mut mounts = vec![AdditionalMount {
1352 source: PathBuf::from("/nfs/share"),
1353 destination: PathBuf::from("/mnt/share"),
1354 read_only: false,
1355 }];
1356
1357 let notices = enforce_overlay_capable_mounts(&podman_target(), &mut mounts, &executor);
1358
1359 assert!(mounts[0].read_only);
1360 assert_eq!(notices.len(), 1);
1361 assert!(
1362 notices[0]
1363 .contains("Mounted /nfs/share read-only: the overlay is unreliable on nfs (network filesystem)"),
1364 "{notices:?}"
1365 );
1366 let plan = targets::provision_plan(
1367 &podman_target(),
1368 "0123456789abcdef0123456789abcdef",
1369 &probe_bundle(),
1370 &mounts,
1371 )
1372 .unwrap();
1373 assert!(
1374 plan.commands[0]
1375 .args
1376 .windows(2)
1377 .any(|args| args == ["--volume", "/nfs/share:/mnt/share:ro"]),
1378 "{:?}",
1379 plan.commands[0].args
1380 );
1381 }
1382
1383 #[test]
1384 fn a_probe_that_cannot_answer_keeps_the_overlay_and_says_so() {
1385 let executor = ProbeExecutor::failing("stat: cannot read file system information");
1386 let mut mounts = vec![AdditionalMount {
1387 source: PathBuf::from("/host/cache"),
1388 destination: PathBuf::from("/mnt/cache"),
1389 read_only: false,
1390 }];
1391
1392 let notices = enforce_overlay_capable_mounts(&podman_target(), &mut mounts, &executor);
1393
1394 assert!(!mounts[0].read_only);
1395 assert_eq!(notices.len(), 1);
1396 assert!(
1397 notices[0].contains("keep the copy-on-write overlay")
1398 && notices[0].contains("cannot read file system information"),
1399 "{notices:?}"
1400 );
1401 let plan = targets::provision_plan(
1402 &podman_target(),
1403 "0123456789abcdef0123456789abcdef",
1404 &probe_bundle(),
1405 &mounts,
1406 )
1407 .unwrap();
1408 assert!(
1409 plan.commands[0]
1410 .args
1411 .windows(2)
1412 .any(|args| args == ["--volume", "/host/cache:/mnt/cache:O"]),
1413 "{:?}",
1414 plan.commands[0].args
1415 );
1416 }
1417
1418 #[test]
1419 fn engines_without_an_overlay_to_lose_are_never_probed() {
1420 struct UnusedExecutor;
1421
1422 impl CommandExecutor for UnusedExecutor {
1423 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1424 panic!("this target must not probe: {}", command.program)
1425 }
1426 }
1427
1428 let mut mounts = vec![AdditionalMount {
1429 source: PathBuf::from("/host/cache"),
1430 destination: PathBuf::from("/mnt/cache"),
1431 read_only: false,
1432 }];
1433 for target in [
1434 targets::TargetTemplate::AppleContainer(ContainerTemplate {
1435 image: "ubuntu:24.04".into(),
1436 pull_policy: Default::default(),
1437 extra_run_args: Vec::new(),
1438 workspace_storage: Default::default(),
1439 }),
1440 targets::TargetTemplate::AwsEc2(targets::AwsTemplate {
1441 profile: "default".into(),
1442 region: "us-east-1".into(),
1443 launch_template: "lt-0123456789abcdef0".into(),
1444 launch_template_version: None,
1445 instance_type: None,
1446 ssh: SshTarget {
1447 destination: "ubuntu@example.test".into(),
1448 ssh_args: Vec::new(),
1449 },
1450 }),
1451 ] {
1452 assert!(
1453 enforce_overlay_capable_mounts(&target, &mut mounts, &UnusedExecutor).is_empty()
1454 );
1455 assert!(!mounts[0].read_only);
1456 }
1457 }
1458
1459 #[test]
1462 fn mounts_already_read_only_are_not_probed() {
1463 struct UnusedExecutor;
1464
1465 impl CommandExecutor for UnusedExecutor {
1466 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1467 panic!("a read-only mount must not probe: {}", command.program)
1468 }
1469 }
1470
1471 let mut mounts = vec![AdditionalMount {
1472 source: PathBuf::from("/host/cache"),
1473 destination: PathBuf::from("/mnt/cache"),
1474 read_only: true,
1475 }];
1476
1477 assert!(
1478 enforce_overlay_capable_mounts(&podman_target(), &mut mounts, &UnusedExecutor)
1479 .is_empty()
1480 );
1481 }
1482
1483 #[test]
1484 fn failed_new_session_provisioning_retains_error_record() {
1485 let session_id = "0123456789abcdef0123456789abcdef";
1486 let record = SessionRecord {
1487 mjolnir_subagents: None,
1488 create_managed_worktree: None,
1489 workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1490 archived: false,
1491 container_cpus: None,
1492 container_memory: None,
1493 id: session_id.into(),
1494 title: "new session".into(),
1495 harness_kind: mj_core::config::HarnessKind::Codex,
1496 last_profile: "codex".into(),
1497 bundle_id: "project".into(),
1498 project_directory: None,
1499 managed_worktree: None,
1500 target_template_id: "podman".into(),
1501 resource_allocation: None,
1502 additional_mounts: Vec::new(),
1503 state: SessionState::Provisioning,
1504 target: None,
1505 native_session_id: None,
1506 acp_session_title: None,
1507 session_title_override: None,
1508 created_at: "2026-08-12T00:00:00Z".into(),
1509 updated_at: "2026-08-12T00:00:00Z".into(),
1510 viewed_through_event_ordinal: 0,
1511 draft_input: String::new(),
1512 last_error: None,
1513 last_checkpoint_error: None,
1514 checkpoint: None,
1515 };
1516 let mut state = State::default();
1517 state.sessions.insert(session_id.into(), record);
1518
1519 let result = apply_new_session_provisioning_result(
1520 &mut state,
1521 session_id,
1522 Err(anyhow::anyhow!("container creation failed")),
1523 );
1524
1525 assert!(result.is_err());
1526 let retained = &state.sessions[session_id];
1527 assert_eq!(retained.state, SessionState::Error);
1528 assert!(retained.target.is_none());
1529 assert!(
1530 retained
1531 .last_error
1532 .as_deref()
1533 .unwrap()
1534 .contains("container creation failed")
1535 );
1536 }
1537
1538 const SSH_DOCKER_FAILURE_CHILD: &str = "MJ_TEST_SSH_DOCKER_FAILURE_CHILD";
1539
1540 #[test]
1541 fn failed_ssh_docker_preflight_retains_durable_error_record() {
1542 if std::env::var_os(SSH_DOCKER_FAILURE_CHILD).is_none() {
1543 let directory = tempfile::tempdir().unwrap();
1544 let test = "failed_ssh_docker_preflight_retains_durable_error_record";
1545 let mut command = std::process::Command::new(std::env::current_exe().unwrap());
1546 command
1547 .args([
1548 "--exact",
1549 &format!("controller::provisioning::tests::{test}"),
1550 "--nocapture",
1551 ])
1552 .env(SSH_DOCKER_FAILURE_CHILD, "1")
1553 .env("MJ_DATA_DIR", directory.path())
1554 .env("MJ_CONFIG_DIR", directory.path());
1555 let output = mj_core::subprocess::run_with_input(&mut command, &[]).unwrap();
1556 assert!(
1557 output.status.success(),
1558 "isolated {test} failed\nstdout:\n{}\nstderr:\n{}",
1559 String::from_utf8_lossy(&output.stdout),
1560 String::from_utf8_lossy(&output.stderr)
1561 );
1562 return;
1563 }
1564
1565 let _writer = crate::database::install_isolated_test_writer();
1566 let config = ssh_docker_registration_config();
1567 config.save().unwrap();
1568 let mut controller = Controller {
1569 config,
1570 state: State::default(),
1571 };
1572 let session_id = controller
1573 .register_session_with_resources(
1574 "codex",
1575 "project",
1576 "docker",
1577 "failed image",
1578 SessionLaunchOptions {
1579 mjolnir_subagents: None,
1580 create_managed_worktree: None,
1581 initial_prompt: None,
1582 workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1583 additional_mounts: Vec::new(),
1584 allow_dirty_local: false,
1585 resource_allocation: None,
1586 project_directory: None,
1587 session_title_override: None,
1588 },
1589 )
1590 .unwrap();
1591 assert!(
1592 crate::database::load_state()
1593 .unwrap()
1594 .sessions
1595 .contains_key(&session_id)
1596 );
1597
1598 let executor = RecordingExecutor::failing("check Docker daemon");
1599 let error =
1600 futures::executor::block_on(controller.provision_session_with_failure_disposition(
1601 &session_id,
1602 &executor,
1603 None,
1604 ProvisioningFailureDisposition::Discard,
1605 ))
1606 .unwrap_err();
1607 let reported = format!("{error:#}");
1608 assert!(
1609 reported.contains("remote Docker preflight failed"),
1610 "{reported}"
1611 );
1612 assert!(
1613 executor.commands().iter().any(|argv| {
1614 let command = argv.join(" ");
1615 command.contains("'docker' 'version'")
1616 }),
1617 "the fake preflight did not run: {:?}",
1618 executor.commands()
1619 );
1620 let retained = &controller.state.sessions[&session_id];
1621 assert_eq!(retained.state, SessionState::Error);
1622 assert!(retained.target.is_none());
1623
1624 let reloaded = Controller::load().unwrap();
1625 let retained = &reloaded.state.sessions[&session_id];
1626 assert_eq!(retained.state, SessionState::Error);
1627 assert!(retained.target.is_none());
1628 assert!(retained.last_error.is_some());
1629 }
1630
1631 #[test]
1632 fn failed_node_preflight_retains_error_before_provisioning() {
1633 if std::env::var_os(SSH_DOCKER_FAILURE_CHILD).is_none() {
1634 let directory = tempfile::tempdir().unwrap();
1635 let test = "failed_node_preflight_retains_error_before_provisioning";
1636 let mut command = std::process::Command::new(std::env::current_exe().unwrap());
1637 command
1638 .args([
1639 "--exact",
1640 &format!("controller::provisioning::tests::{test}"),
1641 "--nocapture",
1642 ])
1643 .env(SSH_DOCKER_FAILURE_CHILD, "1")
1644 .env("MJ_DATA_DIR", directory.path())
1645 .env("MJ_CONFIG_DIR", directory.path());
1646 let output = mj_core::subprocess::run_with_input(&mut command, &[]).unwrap();
1647 assert!(
1648 output.status.success(),
1649 "isolated {test} failed\nstdout:\n{}\nstderr:\n{}",
1650 String::from_utf8_lossy(&output.stdout),
1651 String::from_utf8_lossy(&output.stderr)
1652 );
1653 return;
1654 }
1655
1656 let _writer = crate::database::install_isolated_test_writer();
1657 let mut config = ssh_docker_registration_config();
1658 config.targets.insert(
1659 "docker".into(),
1660 TargetTemplate::SshBare {
1661 ssh: SshConnection {
1662 host: "builder".into(),
1663 user: Some("agent".into()),
1664 identity_file: None,
1665 extra_args: Vec::new(),
1666 },
1667 permissions: mj_core::config::PermissionMode::Guardian,
1668 workspace_prefix: PathBuf::from(".local/share/hel/workspaces"),
1669 },
1670 );
1671 config.save().unwrap();
1672 let mut controller = Controller {
1673 config,
1674 state: State::default(),
1675 };
1676 let session_id = controller
1677 .register_session_with_resources(
1678 "codex",
1679 "project",
1680 "docker",
1681 "missing Node",
1682 SessionLaunchOptions {
1683 mjolnir_subagents: None,
1684 create_managed_worktree: None,
1685 initial_prompt: None,
1686 workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1687 additional_mounts: Vec::new(),
1688 allow_dirty_local: false,
1689 resource_allocation: None,
1690 project_directory: Some("/srv/project".into()),
1691 session_title_override: None,
1692 },
1693 )
1694 .unwrap();
1695 assert!(
1696 crate::database::load_state()
1697 .unwrap()
1698 .sessions
1699 .contains_key(&session_id)
1700 );
1701
1702 let executor = RecordingExecutor::failing("preflight managed harness Node.js and npm");
1703 let error =
1704 futures::executor::block_on(controller.provision_session_with_failure_disposition(
1705 &session_id,
1706 &executor,
1707 None,
1708 ProvisioningFailureDisposition::Discard,
1709 ))
1710 .unwrap_err();
1711 let reported = format!("{error:#}");
1712 assert!(reported.contains("Node.js 22+ and npm"), "{reported}");
1713 assert_eq!(
1714 executor.commands().len(),
1715 1,
1716 "preflight must fail before provisioning"
1717 );
1718 let retained = &controller.state.sessions[&session_id];
1719 assert_eq!(retained.state, SessionState::Error);
1720 assert!(retained.target.is_none());
1721
1722 let reloaded = Controller::load().unwrap();
1723 let retained = &reloaded.state.sessions[&session_id];
1724 assert_eq!(retained.state, SessionState::Error);
1725 assert!(retained.target.is_none());
1726 assert!(retained.last_error.is_some());
1727 }
1728
1729 #[test]
1730 fn failed_new_worker_start_retains_session_only_after_target_cleanup() {
1731 let session_id = "0123456789abcdef0123456789abcdef";
1732 let mut session = SessionRecord {
1733 mjolnir_subagents: None,
1734 create_managed_worktree: None,
1735 workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1736 archived: false,
1737 container_cpus: None,
1738 container_memory: None,
1739 id: session_id.into(),
1740 title: "new session".into(),
1741 harness_kind: mj_core::config::HarnessKind::Kimi,
1742 last_profile: "kimi".into(),
1743 bundle_id: "raw-project".into(),
1744 project_directory: Some("/srv/project".into()),
1745 managed_worktree: None,
1746 target_template_id: "remote".into(),
1747 resource_allocation: None,
1748 additional_mounts: Vec::new(),
1749 state: SessionState::Disconnected,
1750 target: Some(TargetLocator::SshBare {
1751 host: "builder".into(),
1752 workspace: format!(".local/share/hel/workspaces/{session_id}").into(),
1753 worker_id: None,
1754 }),
1755 native_session_id: None,
1756 acp_session_title: None,
1757 session_title_override: None,
1758 created_at: "2026-08-12T00:00:00Z".into(),
1759 updated_at: "2026-08-12T00:00:00Z".into(),
1760 viewed_through_event_ordinal: 0,
1761 draft_input: String::new(),
1762 last_error: None,
1763 last_checkpoint_error: None,
1764 checkpoint: None,
1765 };
1766 let mut cleaned = State::default();
1767 cleaned.sessions.insert(session_id.into(), session.clone());
1768
1769 let failure =
1770 apply_failed_new_session_rollback(&mut cleaned, session_id, "ACP startup failed", None);
1771
1772 let retained = &cleaned.sessions[session_id];
1773 assert_eq!(retained.state, SessionState::Error);
1774 assert!(retained.target.is_none());
1775 assert!(failure.to_string().contains("failed session retained"));
1776
1777 session.state = SessionState::Disconnected;
1778 let mut cleanup_failed = State::default();
1779 cleanup_failed.sessions.insert(session_id.into(), session);
1780 let failure = apply_failed_new_session_rollback(
1781 &mut cleanup_failed,
1782 session_id,
1783 "ACP startup failed",
1784 Some("ssh unavailable".into()),
1785 );
1786 let retained = cleanup_failed.sessions.get(session_id).unwrap();
1787 assert_eq!(retained.state, SessionState::Error);
1788 assert!(retained.target.is_some());
1789 assert!(failure.to_string().contains("cleanup"));
1790 }
1791 #[test]
1792 fn launch_failure_is_persisted_separately_from_session_state() {
1793 let directory = tempfile::tempdir().unwrap();
1794 let session_id = "0123456789abcdef0123456789abcdef";
1795 let detail = format!(
1796 "specific startup cause\n{}\nstderr tail survives",
1797 "x".repeat(MAX_LAUNCH_DIAGNOSTIC_BYTES)
1798 );
1799
1800 let path = persist_launch_failure_to(directory.path(), session_id, &detail).unwrap();
1801 let saved = std::fs::read_to_string(path).unwrap();
1802
1803 assert!(saved.contains("specific startup cause"));
1804 assert!(saved.contains("launch diagnostic truncated"));
1805 assert!(saved.contains("stderr tail survives"));
1806 #[cfg(unix)]
1807 {
1808 use std::os::unix::fs::PermissionsExt;
1809 assert_eq!(
1810 std::fs::metadata(directory.path())
1811 .unwrap()
1812 .permissions()
1813 .mode()
1814 & 0o777,
1815 0o700
1816 );
1817 }
1818 }
1819
1820 #[test]
1821 fn noting_a_launch_failure_writes_the_diagnostic_and_returns_the_reason() {
1822 let directory = tempfile::tempdir().unwrap();
1823 let session_id = "0123456789abcdef0123456789abcdef";
1824 let error =
1825 anyhow::anyhow!("connect worker").context("Connection closed by 10.0.0.1 port 22");
1826
1827 let detail = note_new_session_launch_failure_in(directory.path(), session_id, &error);
1828
1829 assert!(
1830 detail.contains("Connection closed by 10.0.0.1 port 22"),
1831 "the returned reason keeps the underlying error text"
1832 );
1833 assert!(
1834 detail.contains("full diagnostic saved to"),
1835 "the reason points at the saved diagnostic"
1836 );
1837 let saved = std::fs::read_to_string(
1838 directory
1839 .path()
1840 .join(format!("{session_id}-launch-error.txt")),
1841 )
1842 .unwrap();
1843 assert!(saved.contains("Connection closed by 10.0.0.1 port 22"));
1844 }
1845 #[test]
1846 fn inherited_git_settings_allow_only_portable_non_executable_values() {
1847 let settings = parse_inherited_git_settings(
1848 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",
1849 )
1850 .unwrap();
1851
1852 assert_eq!(
1853 settings,
1854 BTreeMap::from([
1855 ("pull.rebase".into(), "true".into()),
1856 ("user.email".into(), "agent@example.test".into()),
1857 ("user.name".into(), "Final User".into()),
1858 ])
1859 );
1860 }
1861 #[test]
1862 fn inherited_git_settings_reject_malformed_or_non_utf8_output() {
1863 assert!(parse_inherited_git_settings(b"user.name\0").is_err());
1864 assert!(parse_inherited_git_settings(b"user.name\n\xff\0").is_err());
1865 }
1866 #[test]
1867 fn inherited_git_settings_target_only_isolated_workers() {
1868 let ssh = SshTarget {
1869 destination: "worker@example.test".into(),
1870 ssh_args: vec!["-p".into(), "2222".into()],
1871 };
1872 let ephemeral = [
1873 targets::TargetLocator::LocalPodman {
1874 container_id: "abcdef012345".into(),
1875 workspace_storage: Default::default(),
1876 },
1877 targets::TargetLocator::AppleContainer {
1878 container_id: "abcdef012346".into(),
1879 },
1880 targets::TargetLocator::AwsEc2 {
1881 profile: "default".into(),
1882 region: "us-east-1".into(),
1883 instance_id: "i-1234567890abcdef0".into(),
1884 ssh: ssh.clone(),
1885 workspace: ".local/share/hel/workspaces/018f9dd2-a3b4-7c8d-9000-123456789abc"
1886 .into(),
1887 },
1888 targets::TargetLocator::SshPodman {
1889 ssh: ssh.clone(),
1890 container_id: "abcdef012347".into(),
1891 workspace_storage: Default::default(),
1892 },
1893 ];
1894 for locator in &ephemeral {
1895 assert!(inherits_controller_git_settings(locator));
1896 let commands = inherited_git_setting_commands(
1897 locator,
1898 "018f9dd2-a3b4-7c8d-9000-123456789abc",
1899 BTreeMap::from([("user.name".into(), "- Agent O'Brien 日本語".into())]),
1900 )
1901 .unwrap();
1902 assert_eq!(commands.len(), 1);
1903 assert!(
1904 commands[0]
1905 .args
1906 .iter()
1907 .any(|argument| argument.contains("user.name"))
1908 );
1909 assert!(
1910 commands[0]
1911 .args
1912 .iter()
1913 .any(|argument| argument.contains("- Agent O'"))
1914 );
1915 }
1916
1917 let persistent = targets::TargetLocator::SshBare {
1918 worker_id: None,
1919 ssh,
1920 workspace: "/srv/hel/018f9dd2-a3b4-7c8d-9000-123456789abc".into(),
1921 };
1922 let local = targets::TargetLocator::LocalBare {
1923 worker_root: "/var/lib/hel/workers/018f9dd2-a3b4-7c8d-9000-123456789abc".into(),
1924 };
1925 assert!(!inherits_controller_git_settings(&persistent));
1926 assert!(!inherits_controller_git_settings(&local));
1927 assert!(
1928 inherited_git_setting_commands(
1929 &persistent,
1930 "018f9dd2-a3b4-7c8d-9000-123456789abc",
1931 BTreeMap::from([("user.name".into(), "Agent".into())]),
1932 )
1933 .unwrap()
1934 .is_empty()
1935 );
1936 }
1937
1938 #[test]
1939 fn raw_ssh_targets_select_permissions_and_ssh_podman_is_unconstrained() {
1940 let ssh = mj_core::config::SshConnection {
1941 host: "builder".into(),
1942 user: None,
1943 identity_file: None,
1944 extra_args: Vec::new(),
1945 };
1946 let guardian = TargetTemplate::SshBare {
1947 ssh: ssh.clone(),
1948 permissions: mj_core::config::PermissionMode::Guardian,
1949 workspace_prefix: ".local/share/hel/workspaces".into(),
1950 };
1951 let podman = TargetTemplate::SshPodman {
1952 ssh: ssh.clone(),
1953 container: mj_core::config::ContainerTemplate {
1954 image: "example.invalid/agent:latest".into(),
1955 pull_policy: Default::default(),
1956 platform: None,
1957 cpus: None,
1958 memory: None,
1959 environment: BTreeMap::new(),
1960 workspace_storage: Default::default(),
1961 },
1962 };
1963 let yolo = TargetTemplate::SshBare {
1964 ssh,
1965 permissions: mj_core::config::PermissionMode::Yolo,
1966 workspace_prefix: ".local/share/hel/workspaces".into(),
1967 };
1968
1969 assert_eq!(
1970 TargetTemplate::LocalBare.execution_policy(),
1971 mj_core::config::ExecutionPolicy::ConfiguredApprovals
1972 );
1973 assert_eq!(
1974 guardian.execution_policy(),
1975 mj_core::config::ExecutionPolicy::ConfiguredApprovals
1976 );
1977 assert_eq!(
1978 podman.execution_policy(),
1979 mj_core::config::ExecutionPolicy::Unconstrained
1980 );
1981 assert_eq!(
1982 yolo.execution_policy(),
1983 mj_core::config::ExecutionPolicy::Unconstrained
1984 );
1985 }
1986 const PROVISIONED_SESSION: &str = "0123456789abcdef0123456789abcdef";
1987
1988 struct RecordingExecutor {
1991 failing_purpose: String,
1992 commands: Mutex<Vec<Vec<String>>>,
1993 }
1994
1995 impl RecordingExecutor {
1996 fn failing(purpose: impl Into<String>) -> Self {
1997 Self {
1998 failing_purpose: purpose.into(),
1999 commands: Mutex::new(Vec::new()),
2000 }
2001 }
2002
2003 fn succeeding() -> Self {
2004 Self::failing(String::new())
2005 }
2006
2007 fn commands(&self) -> Vec<Vec<String>> {
2008 self.commands.lock().unwrap().clone()
2009 }
2010 }
2011
2012 impl CommandExecutor for RecordingExecutor {
2013 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2014 let mut argv = vec![command.program.clone()];
2015 argv.extend(command.args.clone());
2016 self.commands.lock().unwrap().push(argv);
2017 Ok(CommandOutput {
2018 status: i32::from(command.purpose == self.failing_purpose),
2019 stdout: Vec::new(),
2020 stderr: b"the step failed".to_vec(),
2021 })
2022 }
2023 }
2024
2025 fn container_targets() -> Vec<targets::TargetTemplate> {
2026 let container = ContainerTemplate {
2027 image: "ubuntu:24.04".into(),
2028 pull_policy: Default::default(),
2029 extra_run_args: Vec::new(),
2030 workspace_storage: Default::default(),
2031 };
2032 vec![
2033 targets::TargetTemplate::LocalPodman(container.clone()),
2034 targets::TargetTemplate::AppleContainer(container.clone()),
2035 targets::TargetTemplate::SshPodman {
2036 ssh: SshTarget {
2037 destination: "dev@example.test".into(),
2038 ssh_args: vec!["-o".into(), "BatchMode=yes".into()],
2039 },
2040 container,
2041 },
2042 ]
2043 }
2044
2045 #[test]
2046 fn a_failure_after_the_container_exists_removes_it_and_keeps_the_original_error() {
2047 let name = targets::resource_name(PROVISIONED_SESSION).unwrap();
2048 for target in container_targets() {
2049 let plan = targets::provision_plan(&target, PROVISIONED_SESSION, &probe_bundle(), &[])
2050 .unwrap();
2051 let executor = RecordingExecutor::failing("clone app");
2052
2053 let error = provision_target(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2054 unreachable!("locator discovery must not run after a failed plan")
2055 })
2056 .unwrap_err();
2057
2058 let reported = format!("{error:#}");
2059 assert!(reported.contains("clone app failed"), "{reported}");
2060 assert!(reported.contains("cleanup succeeded"), "{reported}");
2061 let removal = executor
2063 .commands()
2064 .into_iter()
2065 .map(|arguments| arguments.join(" ").replace('\'', ""))
2066 .find(|command| command.contains("rm --force") && command.contains(&name))
2067 .expect("cleanup removes the exact provisioned container");
2068 assert!(removal.contains("rm --force"), "{removal}");
2069 assert!(removal.contains(&name), "{removal}");
2070 }
2071 }
2072
2073 #[test]
2074 fn target_creation_returns_repository_setup_without_running_it() {
2075 let target = podman_target();
2076 let plan =
2077 targets::provision_plan(&target, PROVISIONED_SESSION, &probe_bundle(), &[]).unwrap();
2078 let executor = RecordingExecutor::succeeding();
2079
2080 let (_, repositories) =
2081 provision_target_creation(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2082 Ok(TargetLocator::LocalPodman {
2083 container_id: targets::resource_name(PROVISIONED_SESSION)?,
2084 workspace_storage: Default::default(),
2085 })
2086 })
2087 .unwrap();
2088
2089 assert_eq!(executor.commands().len(), 1, "only podman run may execute");
2090 assert!(
2091 repositories
2092 .commands
2093 .iter()
2094 .any(|command| command.purpose == "clone app")
2095 );
2096 }
2097
2098 #[test]
2099 fn a_target_whose_creation_failed_is_never_torn_down() {
2100 for target in container_targets() {
2101 let plan = targets::provision_plan(&target, PROVISIONED_SESSION, &probe_bundle(), &[])
2102 .unwrap();
2103 let creation = plan.split_at_target_creation().unwrap().0;
2104 let executor =
2105 RecordingExecutor::failing(creation.commands.last().unwrap().purpose.clone());
2106
2107 let error = provision_target(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2108 unreachable!("locator discovery must not run after a failed plan")
2109 })
2110 .unwrap_err();
2111
2112 let reported = format!("{error:#}");
2113 assert!(!reported.contains("cleanup"), "{reported}");
2114 assert!(
2115 !executor
2116 .commands()
2117 .iter()
2118 .any(|argv| argv.join(" ").contains("rm --force")),
2119 "{:?}",
2120 executor.commands()
2121 );
2122 }
2123 }
2124
2125 #[test]
2126 fn a_target_whose_locator_cannot_be_discovered_is_removed_again() {
2127 let target = podman_target();
2128 let plan =
2129 targets::provision_plan(&target, PROVISIONED_SESSION, &probe_bundle(), &[]).unwrap();
2130 let executor = RecordingExecutor::succeeding();
2131
2132 let error = provision_target(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2133 bail!("the container never reported an address")
2134 })
2135 .unwrap_err();
2136
2137 let reported = format!("{error:#}");
2138 assert!(reported.contains("never reported an address"), "{reported}");
2139 assert!(reported.contains("cleanup succeeded"), "{reported}");
2140 let removal = executor
2141 .commands()
2142 .into_iter()
2143 .map(|arguments| arguments.join(" "))
2144 .find(|command| command.contains("podman rm --force --ignore"))
2145 .expect("cleanup removes the provisioned Podman container");
2146 assert!(removal.contains("podman rm --force --ignore"), "{removal}");
2147 }
2148
2149 #[test]
2152 fn a_bare_project_failure_removes_nothing() {
2153 let target = targets::TargetTemplate::LocalBare;
2154 let plan =
2155 targets::provision_bare_project_plan(&target, PROVISIONED_SESSION, "/srv/project")
2156 .unwrap();
2157 let executor = RecordingExecutor::succeeding();
2158
2159 let error = provision_target(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2160 bail!("the worker root was unreadable")
2161 })
2162 .unwrap_err();
2163
2164 assert!(!format!("{error:#}").contains("cleanup"));
2165 assert!(executor.commands().is_empty());
2166 }
2167}