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