Skip to main content

mj_controller/controller/
provisioning.rs

1//! Session provisioning, rollback, and worker-side Git bootstrap.
2
3use 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    /// A freshly registered session has no durable history to retain.
48    Discard,
49    /// Resume owns rollback to the archived record and checkpoint lineage.
50    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    /// Start a child worker inside an already-provisioned parent target.
101    /// Repository, target, and mount setup belong exclusively to the parent.
102    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                    // Rollback must remain possible after the foreground
160                    // operation's cancellation token has been set.
161                    .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        // Keep planning, preflight, creation, and locator discovery in one
286        // result so the caller's failure disposition applies to every error.
287        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            // The mounts this container runs with, not the ones the session
313            // stores: a forced downgrade belongs to the host the container
314            // lands on, so it is decided here every time and never written
315            // over the user's choice.
316            for notice in enforce_overlay_capable_mounts(&target, &mut runtime_mounts, executor) {
317                executor.notify_notice(&notice);
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                // This arm (no managed worktree to unwind) is the one a
424                // provisioning failure such as a dropped target connection
425                // hits; record the diagnostic and the session-id log here too.
426                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        // Worker/profile installation is independent of repository cloning.
533        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
593/// Record a failed new-session launch consistently across every failure arm:
594/// log the failure with the session id (so `logs/mj-*.log` names the session)
595/// and save the local diagnostic file. Returns the underlying error chain
596/// annotated with the diagnostic path, which the caller stores in `last_error`
597/// so the reason travels to `mj sessions`, `mj wait`, and `mj events`.
598pub(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            // Provisioning has completed, but Running is reserved for a
692            // successful worker handshake.
693            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
775/// Run two independent target setup lanes at the same time and wait for both.
776/// The first lane's failure wins deterministically when both fail, and neither
777/// lane is abandoned while it may still own a transfer or subprocess.
778pub(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/// Run a provisioning plan and discover the locator it produced, tearing the
811/// target down again if anything after its creation fails.
812///
813/// Creation is the boundary that matters. A step that fails before the target
814/// exists has left nothing behind; every failure after it — a later plan step
815/// or locator discovery — owns a target no session record will point at.
816#[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        // Nothing this plan runs can leave a target behind.
826        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
844/// Bring the target into existence and return the commands that populate its
845/// repositories. The caller may overlap that remainder with worker/profile
846/// installation once it has persisted the discovered locator.
847fn 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        // Nothing this plan runs can leave a target behind, so its commands
856        // must still finish before the locator is usable.
857        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
879/// Best-effort teardown of a target whose creation succeeded but whose
880/// provisioning failed before a locator was recorded. Returns a note
881/// describing what happened for inclusion in the session error.
882///
883/// The teardown is the session's own close plan, so a failed launch and an
884/// ordinary close can never disagree about what removing a target means.
885fn 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
938/// The locator a provisioning plan's creating command brought into existence.
939///
940/// Every target but AWS is named before its plan runs; an EC2 instance
941/// reports its own ID in the launch response.
942fn 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        // A bare project directory belongs to the user: provisioning creates
950        // nothing that a failure could leak.
951        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
992/// Attach read-only whatever the selected container overlay cannot hold, and
993/// say so.
994///
995/// The filesystem is probed on the host that runs the container, because that
996/// is where the overlay would be built. A probe that cannot answer leaves the
997/// overlay alone: a failed probe is no evidence of an unsupported filesystem,
998/// and refusing to provision over one would cost the user their session.
999///
1000/// Apple's `container` engine already mounts every extra directory read-only,
1001/// and EC2 copies the directory instead of mounting it, so neither is probed.
1002pub(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
1052/// Reports every command an installer issues as one launch stage, so progress
1053/// stays accurate without threading the stage through each `CommandSpec`.
1054/// A command that already names a stage keeps it.
1055pub(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    /// Answers the filesystem probe, and records every notice provisioning
1234    /// reported while it ran.
1235    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    /// A mount the user already marked read-only has no overlay to protect, so
1460    /// the probe never has to reach a host that may not answer.
1461    #[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    /// Records every command a plan runs, and fails the one whose purpose it
1989    /// was told to fail.
1990    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            // Remote commands reach the target posix-quoted.
2062            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    /// A raw project directory is the user's own: provisioning it creates
2150    /// nothing that a failure could leak.
2151    #[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}