Skip to main content

mj_controller/controller/
provisioning.rs

1//! Session provisioning, rollback, and worker-side Git bootstrap.
2
3use std::collections::{BTreeMap, HashMap};
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6use std::sync::{Arc, Mutex, OnceLock};
7use std::time::Instant;
8
9use anyhow::{Context, Result, bail, ensure};
10
11use mj_core::config::{TargetTemplate, atomic_write, data_dir};
12use mj_core::state::{SessionState, State, TargetLocator};
13
14use crate::targets::{
15    self, CommandExecutor, CommandOutput, CommandSpec, ProvisionStage, ProvisionStageGuard,
16};
17
18use super::backend::{
19    ContainerOverrides, TargetCheck, backend_locator, backend_session_bundle, backend_target,
20    configure_github_token_environment, preflight_target, use_github_https_urls,
21};
22use super::git_cache;
23use super::readiness::{connect_started_worker, wait_for_native_session_in_stage};
24use super::worker_binary::{bridge_readiness_stage, start_worker_durably, worker_probe_diagnosis};
25use super::{Controller, execute_checked, now};
26
27const INHERITED_GIT_SETTINGS: &[&str] = &[
28    "diff.algorithm",
29    "fetch.prune",
30    "fetch.prunetags",
31    "init.defaultbranch",
32    "merge.conflictstyle",
33    "pull.ff",
34    "pull.rebase",
35    "push.autosetupremote",
36    "push.default",
37    "rebase.autostash",
38    "rerere.autoupdate",
39    "rerere.enabled",
40    "user.email",
41    "user.name",
42];
43
44/// How many sub-agent children may be brought up inside one container at the
45/// same time.
46///
47/// Starting a child means starting a harness, and a harness start inside a
48/// container is expensive, especially while its reviewer is active. Measured
49/// on a local Podman
50/// target, ten children started one after another each reached their harness
51/// in about seven seconds, while four started at once left two or three of
52/// them past the 300-second harness-startup wait. Admitting two at a time
53/// keeps a burst slower but finished, instead of fast and failed.
54const CONTAINER_START_ADMISSION: usize = 2;
55
56/// The admission gate for one container, created on first use.
57///
58/// Keyed by the container the children share. A bare target has no gate: a
59/// child there is an ordinary process on a whole machine, and twenty
60/// sequential and four concurrent starts measured 2.8 seconds each.
61pub(super) fn container_start_gate(
62    locator: &targets::TargetLocator,
63) -> Option<Arc<tokio::sync::Semaphore>> {
64    static GATES: OnceLock<Mutex<HashMap<String, Arc<tokio::sync::Semaphore>>>> = OnceLock::new();
65    let container = match locator {
66        targets::TargetLocator::LocalPodman { container_id, .. }
67        | targets::TargetLocator::LocalDocker { container_id, .. }
68        | targets::TargetLocator::AppleContainer { container_id, .. }
69        | targets::TargetLocator::SshPodman { container_id, .. }
70        | targets::TargetLocator::SshDocker { container_id, .. } => container_id.clone(),
71        targets::TargetLocator::LocalBare { .. }
72        | targets::TargetLocator::SshBare { .. }
73        | targets::TargetLocator::AwsEc2 { .. } => return None,
74    };
75    let gates = GATES.get_or_init(|| Mutex::new(HashMap::new()));
76    let mut gates = gates
77        .lock()
78        .unwrap_or_else(std::sync::PoisonError::into_inner);
79    Some(Arc::clone(gates.entry(container).or_insert_with(|| {
80        Arc::new(tokio::sync::Semaphore::new(CONTAINER_START_ADMISSION))
81    })))
82}
83
84/// Whether starting a child worker can be tried again.
85///
86/// Only a worker that provably never published its control socket qualifies:
87/// it owns no relay, no journal and no harness, so a second start cannot
88/// duplicate or corrupt work. A refusal is never retried, because it names a
89/// precondition that a second attempt would meet in exactly the same way, and
90/// a cancelled operation is not retried either.
91fn subagent_start_is_retryable(error: &anyhow::Error) -> bool {
92    if mj_core::refusal::Refusal::of(error).is_some() {
93        return false;
94    }
95    if format!("{error:#}").contains("operation cancelled") {
96        return false;
97    }
98    error
99        .downcast_ref::<super::readiness::WorkerStartupFailure>()
100        .is_some_and(|failure| !failure.reached_socket)
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub(super) enum ProvisioningFailureDisposition {
105    /// A freshly registered session has no durable history to retain.
106    Discard,
107    /// Resume owns rollback to the archived record and checkpoint lineage.
108    Preserve,
109}
110
111impl Controller {
112    pub async fn provision_session_controlled_with_commit(
113        &mut self,
114        session_id: &str,
115        executor: &(impl CommandExecutor + Sync),
116        grant_commit: impl FnOnce() -> Result<()>,
117    ) -> Result<()> {
118        crate::worker_lifecycle::run(
119            session_id,
120            "provision session controlled with commit",
121            executor,
122            async {
123                crate::worker_lifecycle::require(session_id)?.verify_cached_target(&self.state)?;
124                let github_token = self
125                    .github_token_for_session(session_id)
126                    .await
127                    .context("resolve GitHub credentials for session")?;
128                let repositories = self
129                    .provision_session_target_with_failure_disposition(
130                        session_id,
131                        executor,
132                        github_token.as_deref(),
133                        ProvisioningFailureDisposition::Discard,
134                    )
135                    .await?;
136                let setup = execute_concurrent_lanes(
137                    || execute_repository_setup(&repositories, executor),
138                    || self.install_worker_payload(session_id, executor),
139                );
140                let result = match setup {
141                    Ok(((), (backend, worker_root))) => {
142                        self.connect_and_start_worker(
143                            session_id,
144                            executor,
145                            &backend,
146                            &worker_root,
147                            true,
148                        )
149                        .await
150                    }
151                    Err(error) => Err(error),
152                };
153                match result {
154                    Ok(native_session_id) => {
155                        if let Err(error) = grant_commit() {
156                            return Err(self.rollback_failed_new_session(session_id, error)?);
157                        }
158                        self.mark_worker_connected(session_id, native_session_id)
159                    }
160                    Err(error) => Err(self.rollback_failed_new_session(session_id, error)?),
161                }
162            },
163        )
164        .await
165    }
166
167    /// Start a child worker inside an already-provisioned parent target.
168    /// Repository, target, and mount setup belong exclusively to the parent.
169    pub async fn provision_subagent_session_controlled(
170        &mut self,
171        session_id: &str,
172        executor: &(impl CommandExecutor + Sync),
173    ) -> Result<()> {
174        crate::worker_lifecycle::run(
175            session_id,
176            "provision subagent session controlled",
177            executor,
178            async {
179                crate::worker_lifecycle::require(session_id)?.verify_cached_target(&self.state)?;
180                // One retry, and only for a worker that provably never published a
181                // control socket. Such a worker has no relay, no durable journal and
182                // no harness, so starting another over the same root cannot duplicate
183                // or corrupt anything. A spawn is issued by a model that cannot see
184                // the target, so a transient start failure it could have retried by
185                // hand is better retried here.
186                let mut attempts: Vec<String> = Vec::new();
187                loop {
188                    let (result, placement) =
189                        self.attempt_subagent_start(session_id, executor).await;
190                    let error = match result {
191                        Ok(native_session_id) => {
192                            return self.mark_worker_connected(session_id, native_session_id);
193                        }
194                        Err(error) => error,
195                    };
196                    let retry = attempts.is_empty() && subagent_start_is_retryable(&error);
197                    let error = match &placement {
198                        Some((backend, _)) => super::subagent_park::explain_process_exhaustion(
199                            error, backend, session_id,
200                        ),
201                        None => error,
202                    };
203                    attempts.push(format!("{error:#}"));
204                    let error = if attempts.len() > 1 {
205                        anyhow::anyhow!(
206                            "{}",
207                            attempts
208                                .iter()
209                                .enumerate()
210                                .map(|(index, error)| format!("attempt {}: {error}", index + 1))
211                                .collect::<Vec<_>>()
212                                .join("; ")
213                        )
214                    } else {
215                        error
216                    };
217                    let diagnostic = note_new_session_launch_failure(session_id, &error);
218                    let previous = self
219                        .state
220                        .sessions
221                        .get(session_id)
222                        .context("failed child disappeared")?
223                        .clone();
224                    let record = self.state.sessions.get_mut(session_id).unwrap();
225                    record.state = SessionState::StartupCleanup;
226                    record.updated_at = now();
227                    record.last_error = Some(format!("sub-agent startup failed: {diagnostic}"));
228                    self.persist_session_transition_or_restore(
229                        session_id,
230                        &previous,
231                        "record failed startup before teardown",
232                    )?;
233                    let cleanup = super::failed_launch_cleanup_executor();
234                    if let Err(cleanup_error) =
235                        self.finish_failed_startup_controlled(session_id, &cleanup, retry)
236                    {
237                        return Err(error.context(format!(
238                            "startup cleanup remains pending: {cleanup_error:#}"
239                        )));
240                    }
241                    if !retry {
242                        return Err(error);
243                    }
244                    tracing::warn!(
245                        session_id,
246                        error = format!("{error:#}"),
247                        "sub-agent worker never started and was stopped; retrying once"
248                    );
249                }
250            },
251        )
252        .await
253    }
254
255    /// Finish a failed startup without reopening its relay or replaying work.
256    pub(crate) fn cleanup_failed_startup_controlled(
257        &mut self,
258        session_id: &str,
259        executor: &impl CommandExecutor,
260    ) -> Result<()> {
261        self.finish_failed_startup_controlled(session_id, executor, false)
262    }
263
264    fn finish_failed_startup_controlled(
265        &mut self,
266        session_id: &str,
267        executor: &impl CommandExecutor,
268        retry_after_stop: bool,
269    ) -> Result<()> {
270        crate::worker_lifecycle::run_blocking(
271            session_id,
272            "finish failed startup controlled",
273            executor,
274            || {
275                let previous = self
276                    .state
277                    .sessions
278                    .get(session_id)
279                    .context("cleanup session disappeared")?
280                    .clone();
281                ensure!(
282                    previous.state == SessionState::StartupCleanup,
283                    "session is not awaiting startup cleanup"
284                );
285                let child = self.state.subagents.contains_key(session_id);
286                ensure!(
287                    !retry_after_stop || child,
288                    "only unaccepted child startup may retry"
289                );
290                let outcome = (|| -> Result<()> {
291                    if let Some(locator) = &previous.target {
292                        let backend = backend_locator(locator, &previous, &self.config)?;
293                        if child {
294                            let root = targets::worker_root(&backend, session_id)?;
295                            super::worker_binary::stop_worker(
296                                &crate::worker_lifecycle::require(session_id)?,
297                                executor,
298                                &backend,
299                                &root,
300                            )?;
301                        } else {
302                            targets::close_plan(&backend, session_id)?.execute(executor)?;
303                        }
304                    }
305                    if !child {
306                        self.cleanup_new_session_worktree_after_failure(session_id, executor)?;
307                    }
308                    Ok(())
309                })();
310                let record = self.state.sessions.get_mut(session_id).unwrap();
311                record.updated_at = now();
312                let cause = previous
313                    .last_error
314                    .as_deref()
315                    .unwrap_or("startup failed")
316                    .split("; startup cleanup failed:")
317                    .next()
318                    .unwrap()
319                    .to_owned();
320                match &outcome {
321                    Ok(()) => {
322                        record.state = if retry_after_stop {
323                            SessionState::Provisioning
324                        } else {
325                            SessionState::Error
326                        };
327                        record.last_error = (!retry_after_stop).then_some(cause);
328                        if !child {
329                            record.target = None;
330                        }
331                    }
332                    Err(error) => {
333                        record.last_error =
334                            Some(format!("{cause}; startup cleanup failed: {error:#}"));
335                    }
336                }
337                let record = self.state.sessions.get_mut(session_id).unwrap();
338                if outcome.is_ok() && child && !retry_after_stop {
339                    record.archived = true;
340                }
341                if let Err(error) = crate::database::save_startup_cleanup_outcome(
342                    record,
343                    outcome.is_ok() && child && !retry_after_stop,
344                ) {
345                    self.state.sessions.insert(session_id.to_owned(), previous);
346                    return Err(error).context("record startup cleanup outcome");
347                }
348                outcome
349            },
350        )
351    }
352
353    /// One start of a child worker: place it, install its files, and wait for
354    /// its relay. The placement is returned even on failure, because the
355    /// caller needs it to stop a worker that may be half up.
356    async fn attempt_subagent_start(
357        &mut self,
358        session_id: &str,
359        executor: &(impl CommandExecutor + Sync),
360    ) -> (
361        Result<Option<String>>,
362        Option<(targets::TargetLocator, String)>,
363    ) {
364        // Placement failures must reach the same failure arm as startup
365        // failures; otherwise the child record stays `Provisioning` forever.
366        match self.worker_placement(session_id) {
367            Ok((backend, worker_root)) => {
368                let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
369                let prepared = self
370                    .prepare_worker_files(session_id, &backend, &worker_root, syncing)
371                    .and_then(|()| self.prepare_subagent_report_dir(session_id, &backend, syncing));
372                let result = match prepared {
373                    Ok(()) => {
374                        // Held across the harness startup wait, which is the
375                        // part that does not survive a crowd.
376                        let gate = container_start_gate(&backend);
377                        let _admitted = match &gate {
378                            Some(gate) => gate.acquire().await.ok(),
379                            None => None,
380                        };
381                        self.connect_and_start_worker(
382                            session_id,
383                            executor,
384                            &backend,
385                            &worker_root,
386                            false,
387                        )
388                        .await
389                    }
390                    Err(error) => Err(error),
391                };
392                (result, Some((backend, worker_root)))
393            }
394            Err(error) => (Err(error), None),
395        }
396    }
397
398    fn rollback_failed_new_session(
399        &mut self,
400        session_id: &str,
401        error: anyhow::Error,
402    ) -> Result<anyhow::Error> {
403        self.rollback_failed_new_session_with(
404            session_id,
405            error,
406            // Rollback must remain possible after the foreground operation's
407            // cancellation token has been set.
408            &super::failed_launch_cleanup_executor(),
409        )
410    }
411
412    fn rollback_failed_new_session_with(
413        &mut self,
414        session_id: &str,
415        error: anyhow::Error,
416        target_cleanup_executor: &impl CommandExecutor,
417    ) -> Result<anyhow::Error> {
418        let session = self
419            .state
420            .sessions
421            .get(session_id)
422            .with_context(|| format!("unknown session {session_id}"))?
423            .clone();
424        // The failure goes on record before anything the record points at is
425        // removed. Removing the worker and its checkout can outlast a daemon
426        // that is stopping, and a daemon that exits partway would otherwise
427        // leave a record naming a worker that no longer exists, which every
428        // later daemon keeps reconnecting to. A failed record that still
429        // names its target is one Destroy knows how to clean up.
430        let original = note_new_session_launch_failure(session_id, &error);
431        apply_failed_new_session_launch(&mut self.state, session_id, &original);
432        self.persist_session_transition_or_restore(
433            session_id,
434            &session,
435            "record the failed launch before removing its target",
436        )
437        .map_err(|persist_error| {
438            persist_error.context(format!(
439                "{original}; its target was left in place because the failure could not be recorded"
440            ))
441        })?;
442        let target_cleanup = match session.target.as_ref() {
443            Some(locator) => (|| -> Result<()> {
444                let backend = backend_locator(locator, &session, &self.config)?;
445                targets::close_plan(&backend, session_id)?
446                    .execute(target_cleanup_executor)
447                    .map(|_| ())
448            })(),
449            None => Ok(()),
450        };
451        // A failed stop leaves the harness able to write its checkout. Keep
452        // that checkout until a later cleanup confirms process termination.
453        let cleanup_error = target_cleanup
454            .and_then(|()| {
455                self.cleanup_new_session_worktree_after_failure(session_id, target_cleanup_executor)
456            })
457            .err()
458            .map(|error| format!("{error:#}"));
459        if let Some(cleanup_error) = &cleanup_error {
460            tracing::warn!(
461                session_id,
462                error = %cleanup_error,
463                "new-session rollback cleanup reported failures"
464            );
465        }
466        let failure = apply_failed_new_session_rollback(
467            &mut self.state,
468            session_id,
469            &original,
470            cleanup_error,
471        );
472        self.persist_session_state(session_id)?;
473        Ok(failure)
474    }
475
476    pub(super) async fn provision_session_with_failure_disposition(
477        &mut self,
478        session_id: &str,
479        executor: &(impl CommandExecutor + Sync),
480        github_token: Option<&str>,
481        failure_disposition: ProvisioningFailureDisposition,
482    ) -> Result<()> {
483        let repositories = self
484            .provision_session_target_with_failure_disposition(
485                session_id,
486                executor,
487                github_token,
488                failure_disposition,
489            )
490            .await?;
491        match execute_repository_setup(&repositories, executor) {
492            Ok(()) => Ok(()),
493            Err(error) if failure_disposition == ProvisioningFailureDisposition::Discard => {
494                Err(self.rollback_failed_new_session(session_id, error)?)
495            }
496            Err(error) => Err(error),
497        }
498    }
499
500    async fn provision_session_target_with_failure_disposition(
501        &mut self,
502        session_id: &str,
503        executor: &(impl CommandExecutor + Sync),
504        github_token: Option<&str>,
505        failure_disposition: ProvisioningFailureDisposition,
506    ) -> Result<targets::CommandPlan> {
507        crate::worker_lifecycle::run(session_id, "provision session target with failure disposition", executor, async {
508        let session = self
509            .state
510            .sessions
511            .get(session_id)
512            .with_context(|| format!("unknown session {session_id}"))?
513            .clone();
514        if session.state != SessionState::Provisioning {
515            bail!("session {session_id} is not provisioning");
516        }
517        if let Some(plan) = self.adopt_prepared_ec2_destination(session_id, github_token)? {
518            return Ok(plan);
519        }
520        let preparation = (|| {
521            let selected = self
522                .config
523                .targets
524                .get(&session.target_template_id)
525                .context("target template disappeared before provisioning")?;
526            // Before the runtime is recorded, so a container never starts
527            // without an entry whose secret is missing.
528            selected.ensure_ready(&session.target_template_id)?;
529            self.config
530                .profiles
531                .get(&session.last_profile)
532                .context("harness profile disappeared before provisioning")?
533                .ensure_ready(&session.last_profile)?;
534            let mut runtime = mj_core::state::TargetRuntimeSettings::from(selected);
535            if let Some(recorded) = &session.target_runtime {
536                runtime.tool_cache = recorded.tool_cache.clone();
537                ensure!(
538                    recorded == &runtime,
539                    "target access settings changed before provisioning; retry with the selected target"
540                );
541            } else {
542                self.state
543                    .sessions
544                    .get_mut(session_id)
545                    .unwrap()
546                    .target_runtime = Some(runtime);
547                self.persist_session_state(session_id)?;
548            }
549            let template = self
550                .config
551                .targets
552                .get(&session.target_template_id)
553                .context("target template disappeared during provisioning")?;
554            let profile = self
555                .config
556                .profiles
557                .get(&session.last_profile)
558                .context("harness profile disappeared during provisioning")?;
559            super::worker_binary::preflight_worker_binary(template, executor)?;
560            super::worker_binary::preflight_harness(template, profile, executor)?;
561            self.prepare_managed_raw_worktree(session_id, executor)
562        })();
563        let created_worktree = match preparation {
564            Ok(created) => created,
565            Err(error) if failure_disposition == ProvisioningFailureDisposition::Discard => {
566                return Err(self.fail_new_session_with_cleanup(session_id, error, executor)?);
567            }
568            Err(error) => return Err(error),
569        };
570        let session = self
571            .state
572            .sessions
573            .get(session_id)
574            .expect("session retained after managed worktree preparation")
575            .clone();
576        let project_directory = self.state.checkout(session_id)?.project_directory().map(Path::to_path_buf);
577        // Keep planning, preflight, creation, and locator discovery in one
578        // result so the caller's failure disposition applies to every error.
579        let result = (|| {
580            let template = self
581                .config
582                .targets
583                .get(&session.target_template_id)
584                .context("target template disappeared during provisioning")?;
585            if matches!(template, TargetTemplate::AwsEc2 { .. }) {
586                for resource in &session.additional_mounts {
587                    ensure!(
588                        resource.source.is_dir(),
589                        "attached resource source is not a directory: {}",
590                        resource.source.display()
591                    );
592                }
593            }
594            let mut target = backend_target(
595                template,
596                session.resource_allocation.as_ref(),
597                ContainerOverrides::for_session(&session),
598            )?;
599            let mut runtime_mounts = if matches!(target, targets::TargetTemplate::AwsEc2(_)) {
600                Vec::new()
601            } else {
602                session.additional_mounts.clone()
603            };
604            // The mounts this container runs with, not the ones the session
605            // stores: a forced downgrade belongs to the host the container
606            // lands on, so it is decided here every time and never written
607            // over the user's choice.
608            for notice in enforce_overlay_capable_mounts(&target, &mut runtime_mounts, executor) {
609                executor.notify_notice(&notice);
610            }
611            let mut bundle = if project_directory.is_some() {
612                None
613            } else if let Some(bundle) = self.move_destination_bundle(session_id)? {
614                Some(bundle)
615            } else if failure_disposition == ProvisioningFailureDisposition::Preserve {
616                Some(super::network_git::checkpoint_bundle(&session)?)
617            } else {
618                Some(backend_session_bundle(&session, &self.config, executor)?)
619            };
620            let container_github_token =
621                github_token.filter(|_| configure_github_token_environment(&mut target));
622            if container_github_token.is_some()
623                && let Some(bundle) = bundle.as_mut()
624            {
625                use_github_https_urls(bundle);
626            }
627            preflight_target(template, executor, TargetCheck::Launch)?;
628            let resource_name = crate::database::load_move_operation(session_id)?
629                .filter(|op| {
630                    op.workspace_transfer.is_some()
631                        && op.phase == mj_core::state::MovePhase::ResumingDestination
632                })
633                .map(|op| targets::move_resource_name(session_id, &op.operation_id))
634                .unwrap_or_else(|| targets::resource_name(session_id))?;
635            let moving_workspace = resource_name != targets::resource_name(session_id)?;
636            let tool_cache = super::tool_cache::prepare(
637                &self.config,
638                &session.target_template_id,
639                &mut runtime_mounts,
640                executor,
641            );
642            // Record placement before creating fixed container mounts. A crash
643            // after creation must not leave a mount whose location was lost.
644            self.state.sessions.get_mut(session_id)
645                .expect("session retained during provisioning")
646                .target_runtime.as_mut().context("selected runtime disappeared")?
647                .tool_cache = tool_cache;
648            self.persist_session_state(session_id)?;
649            let prepared_cache = bundle
650                .as_mut()
651                .filter(|_| !moving_workspace)
652                .and_then(|bundle| {
653                    git_cache::prepare(
654                        &target,
655                        session_id,
656                        bundle,
657                        &mut runtime_mounts,
658                        container_github_token,
659                        executor,
660                    )
661                });
662            // Mounts are fixed when the container is created, so the build
663            // cache is decided here, before the provisioning plan is built.
664            let build_cache = super::mbx::prepare(
665                &target,
666                &session,
667                &mut runtime_mounts,
668                executor,
669            );
670            let provision = if let Some(project_directory) = project_directory {
671                targets::provision_bare_project_plan(
672                    &target,
673                    session_id,
674                    &project_directory.to_string_lossy(),
675                )
676            } else {
677                bundle
678                    .as_ref()
679                    .context("project bundle disappeared during provisioning")
680                    .and_then(|bundle| {
681                        targets::provision_plan_named(
682                            &target,
683                            session_id,
684                            bundle,
685                            &runtime_mounts,
686                            session.container_workspace.as_deref(),
687                            &resource_name,
688                        )
689                    })
690            };
691            let mut provision = match provision {
692                Ok(provision) => provision,
693                Err(error) => {
694                    if let Some(cache) = &prepared_cache {
695                        let _ = cache.cleanup(executor);
696                    }
697                    return Err(error);
698                }
699            };
700            if let Some(token) = container_github_token
701                && let Err(error) =
702                    provision.provide_target_environment_secret(&target, "GH_TOKEN", token)
703            {
704                if let Some(cache) = &prepared_cache {
705                    let _ = cache.cleanup(executor);
706                }
707                return Err(error);
708            }
709
710            let started = Instant::now();
711            let result = provision_target_creation_named(
712                &provision,
713                &target,
714                session_id,
715                executor,
716                &resource_name,
717                |outputs| {
718                    super::backend::locator_after_provision_named(
719                        template,
720                        &target,
721                        session_id,
722                        outputs.first(),
723                        executor,
724                        &resource_name,
725                    )
726                },
727            )
728            .map(|(locator, remainder)| (locator, remainder, bundle, build_cache));
729            if result.is_err()
730                && let Some(cache) = &prepared_cache
731            {
732                if let Some(locator) =
733                    provisioned_locator_named(&target, session_id, None, &resource_name)
734                {
735                    let _ = targets::close_plan(&locator, session_id)
736                        .and_then(|plan| plan.execute(executor).map(|_| ()));
737                } else {
738                    let _ = cache.cleanup(executor);
739                }
740            }
741            tracing::debug!(
742                session_id,
743                elapsed_ms = started.elapsed().as_millis(),
744                "provisioning plan execution completed"
745            );
746            result
747        })();
748        let result = match result {
749            Err(error)
750                if created_worktree
751                    && failure_disposition == ProvisioningFailureDisposition::Discard =>
752            {
753                return Err(self.fail_new_session_with_cleanup(session_id, error, executor)?);
754            }
755            Err(error) if failure_disposition == ProvisioningFailureDisposition::Preserve => {
756                Err(error)
757            }
758            Err(error) => {
759                // This arm (no managed worktree to unwind) is the one a
760                // provisioning failure such as a dropped target connection
761                // hits; record the diagnostic and the session-id log here too.
762                let detail = note_new_session_launch_failure(session_id, &error);
763                {
764                    let record = self.state.sessions.get_mut(session_id).unwrap();
765                    record.state = SessionState::Error;
766                    record.target = None;
767                    record.updated_at = super::now();
768                    record.last_error = Some(format!("session provisioning failed: {detail}"));
769                }
770                return match self.persist_session_state(session_id) {
771                    Ok(()) => Err(error),
772                    Err(persistence_error) => Err(error.context(format!(
773                        "persist removal of failed provisioning session {session_id}: {persistence_error:#}"
774                    ))),
775                };
776            }
777            Ok((locator, remainder, bundle, build_cache)) => {
778                apply_new_session_provisioning_result(&mut self.state, session_id, Ok(locator))?;
779                self.state
780                    .sessions
781                    .get_mut(session_id)
782                    .expect("session retained after provisioning")
783                    .build_cache = build_cache;
784                let session = &self.state.sessions[session_id];
785                let backend = backend_locator(
786                    session
787                        .target
788                        .as_ref()
789                        .context("provisioned target disappeared")?,
790                    session,
791                    &self.config,
792                )?;
793                if matches!(backend, targets::TargetLocator::AwsEc2 { .. }) {
794                    targets::provision_on_locator_plan(
795                        &backend,
796                        session_id,
797                        bundle
798                            .as_ref()
799                            .context("AWS provisioning requires a project bundle")?,
800                        github_token,
801                    )
802                } else {
803                    Ok(remainder)
804                }
805            }
806        };
807        let result = match result {
808            Err(error) if failure_disposition == ProvisioningFailureDisposition::Discard => {
809                return Err(self.rollback_failed_new_session(session_id, error)?);
810            }
811            result => result,
812        };
813        if result.is_ok() {
814            let target_template_id = self
815                .state
816                .sessions
817                .get(session_id)
818                .map(|session| session.target_template_id.clone());
819            let directory = match self.state.checkout(session_id)?.effective() {
820                mj_core::state::Checkout::ManagedWorktree { worktree, .. } => {
821                    Some(worktree.source_project_directory.clone())
822                }
823                mj_core::state::Checkout::Attached { path } => Some(path.to_path_buf()),
824                mj_core::state::Checkout::ManagedWorkspace => None,
825                mj_core::state::Checkout::Borrowed { .. } => {
826                    unreachable!("effective checkout resolves borrowing")
827                }
828            };
829            let host = target_template_id
830                .as_deref()
831                .and_then(|id| self.config.targets.get(id))
832                .and_then(|template| match template {
833                    TargetTemplate::LocalBare => Some("local".to_owned()),
834                    TargetTemplate::SshBare { ssh, .. } => Some(ssh.host.clone()),
835                    _ => None,
836                });
837            if let (Some(directory), Some(host)) = (directory, host) {
838                self.state.remember_project_directory(&host, &directory);
839                crate::database::remember_project_directory(&host, &directory)?;
840            }
841        }
842        self.persist_session_state(session_id)?;
843        result
844
845        }).await
846    }
847
848    pub fn mark_worker_connected(
849        &mut self,
850        session_id: &str,
851        native_session_id: Option<String>,
852    ) -> Result<()> {
853        let session = self
854            .state
855            .sessions
856            .get(session_id)
857            .with_context(|| format!("unknown session {session_id}"))?;
858        if session.target.is_none() {
859            bail!("session {session_id} has no provisioned target");
860        }
861        let updated_at = now();
862        crate::database::mark_session_worker_connected(
863            session_id,
864            native_session_id.as_deref(),
865            &updated_at,
866        )?;
867        let session = self
868            .state
869            .sessions
870            .get_mut(session_id)
871            .expect("session disappeared after its worker connection was saved");
872        session.state = SessionState::Running;
873        if native_session_id.is_some() {
874            session.native_session_id = native_session_id;
875        }
876        session.updated_at = updated_at;
877        session.last_error = None;
878        Ok(())
879    }
880
881    fn install_worker_payload(
882        &self,
883        session_id: &str,
884        executor: &impl CommandExecutor,
885    ) -> Result<(targets::TargetLocator, String)> {
886        // Worker/profile installation is independent of repository cloning.
887        let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
888        let (backend, worker_root) = self.worker_placement(session_id)?;
889        self.prepare_worker_files(session_id, &backend, &worker_root, syncing)?;
890        install_attached_resources(&self.state, session_id, &backend, &worker_root, syncing)?;
891        Ok((backend, worker_root))
892    }
893
894    async fn connect_and_start_worker(
895        &self,
896        session_id: &str,
897        executor: &impl CommandExecutor,
898        backend: &targets::TargetLocator,
899        worker_root: &str,
900        initialize_workspace: bool,
901    ) -> Result<Option<String>> {
902        crate::worker_lifecycle::run(session_id, "connect and start worker", executor, async {
903            let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
904            if initialize_workspace {
905                install_inherited_git_settings(executor, backend, session_id)?;
906                self.initialize_network_workspaces(session_id, backend, syncing)?;
907            }
908            let session = self
909                .state
910                .sessions
911                .get(session_id)
912                .with_context(|| format!("unknown session {session_id}"))?;
913            let profile = self
914                .config
915                .profiles
916                .get(&session.last_profile)
917                .with_context(|| format!("unknown profile {}", session.last_profile))?;
918            let readiness_stage = bridge_readiness_stage(profile);
919            let reconnect = &targets::reconnect_plan(backend, session_id)?.commands[0];
920            let readiness = async {
921                let mut relay = {
922                    let _starting = ProvisionStageGuard::new(executor, ProvisionStage::Starting);
923                    start_worker_durably(
924                        &crate::worker_lifecycle::require(session_id)?,
925                        self.state.sessions[session_id]
926                            .target
927                            .as_ref()
928                            .context("worker start has no durable target")?,
929                        executor,
930                        backend,
931                        worker_root,
932                    )?;
933                    connect_started_worker(reconnect, session_id, executor, backend, worker_root)
934                        .await?
935                };
936                let native_session_id = wait_for_native_session_in_stage(
937                    &mut relay,
938                    executor,
939                    readiness_stage,
940                    profile.kind,
941                )
942                .await?;
943                let owner = crate::worker_lifecycle::require(session_id)?;
944                crate::database::finish_worker_restart(session_id, owner.operation_id())?;
945                Ok(Some(native_session_id))
946            }
947            .await;
948            match readiness {
949                Ok(native_session_id) => Ok(native_session_id),
950                Err(error) => {
951                    // The diagnosis reads the worker's state, so it runs before the
952                    // worker is stopped.
953                    let error = worker_probe_diagnosis(executor, backend, worker_root, error);
954                    Err(error)
955                }
956            }
957        })
958        .await
959    }
960}
961
962const MAX_LAUNCH_DIAGNOSTIC_BYTES: usize = 64 * 1024;
963
964const RETAINED_LAUNCH_DIAGNOSTICS: usize = 20;
965
966/// Record a failed new-session launch consistently across every failure arm:
967/// log the failure with the session id (so `logs/mj-*.log` names the session)
968/// and save the local diagnostic file. Returns the underlying error chain
969/// annotated with the diagnostic path, which the caller stores in `last_error`
970/// so the reason travels to `mj sessions`, `mj wait`, and `mj events`.
971pub(super) fn note_new_session_launch_failure(session_id: &str, error: &anyhow::Error) -> String {
972    note_new_session_launch_failure_in(&data_dir().join("diagnostics"), session_id, error)
973}
974
975fn note_new_session_launch_failure_in(
976    directory: &Path,
977    session_id: &str,
978    error: &anyhow::Error,
979) -> String {
980    let original = format!("{error:#}");
981    tracing::warn!(session_id, error = %original, "session launch failed");
982    match persist_launch_failure_to(directory, session_id, &original) {
983        Ok(path) => format!("{original}; full diagnostic saved to {}", path.display()),
984        Err(save_error) => {
985            format!("{original}; saving the local diagnostic failed: {save_error:#}")
986        }
987    }
988}
989
990fn persist_launch_failure_to(directory: &Path, session_id: &str, detail: &str) -> Result<PathBuf> {
991    mj_core::config::validate_id("session", session_id)?;
992    std::fs::create_dir_all(directory).with_context(|| {
993        format!(
994            "create launch diagnostics directory {}",
995            directory.display()
996        )
997    })?;
998    #[cfg(unix)]
999    {
1000        use std::os::unix::fs::PermissionsExt;
1001        std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700))?;
1002    }
1003    let path = directory.join(format!("{session_id}-launch-error.txt"));
1004    let detail = bounded_launch_diagnostic(detail);
1005    let body = format!(
1006        "Mjolnir session launch failure\nsession: {session_id}\nat: {}\n\n{detail}\n",
1007        now()
1008    );
1009    atomic_write(&path, body.as_bytes())?;
1010    prune_launch_diagnostics(directory)?;
1011    Ok(path)
1012}
1013
1014fn bounded_launch_diagnostic(detail: &str) -> String {
1015    if detail.len() <= MAX_LAUNCH_DIAGNOSTIC_BYTES {
1016        return detail.to_owned();
1017    }
1018    let mut head_end = MAX_LAUNCH_DIAGNOSTIC_BYTES / 4;
1019    while !detail.is_char_boundary(head_end) {
1020        head_end -= 1;
1021    }
1022    let tail_bytes = MAX_LAUNCH_DIAGNOSTIC_BYTES - head_end;
1023    let mut tail_start = detail.len() - tail_bytes;
1024    while !detail.is_char_boundary(tail_start) {
1025        tail_start += 1;
1026    }
1027    format!(
1028        "{}\n\n[... launch diagnostic truncated ...]\n\n{}",
1029        &detail[..head_end],
1030        &detail[tail_start..]
1031    )
1032}
1033
1034fn prune_launch_diagnostics(directory: &Path) -> Result<()> {
1035    let mut diagnostics = Vec::new();
1036    for entry in std::fs::read_dir(directory)? {
1037        let entry = entry?;
1038        if !entry
1039            .file_name()
1040            .to_str()
1041            .is_some_and(|name| name.ends_with("-launch-error.txt"))
1042        {
1043            continue;
1044        }
1045        diagnostics.push((entry.metadata()?.modified()?, entry.path()));
1046    }
1047    diagnostics.sort_by_key(|entry| std::cmp::Reverse(entry.0));
1048    for (_, path) in diagnostics.into_iter().skip(RETAINED_LAUNCH_DIAGNOSTICS) {
1049        std::fs::remove_file(&path)
1050            .with_context(|| format!("prune old launch diagnostic {}", path.display()))?;
1051    }
1052    Ok(())
1053}
1054
1055fn apply_new_session_provisioning_result(
1056    state: &mut State,
1057    session_id: &str,
1058    result: Result<TargetLocator>,
1059) -> Result<()> {
1060    match result {
1061        Ok(locator) => {
1062            let record = state.sessions.get_mut(session_id).unwrap();
1063            record.target = Some(locator);
1064            // Provisioning has completed, but Running is reserved for a
1065            // successful worker handshake.
1066            record.state = SessionState::Disconnected;
1067            record.updated_at = now();
1068            record.last_error = None;
1069            Ok(())
1070        }
1071        Err(error) => {
1072            let record = state.sessions.get_mut(session_id).unwrap();
1073            record.state = SessionState::Error;
1074            record.target = None;
1075            record.updated_at = now();
1076            record.last_error = Some(format!("session provisioning failed: {error:#}"));
1077            Err(error)
1078        }
1079    }
1080}
1081
1082/// Mark a new session's launch failed while it still names its target, so the
1083/// record is true while the rollback removes that target.
1084fn apply_failed_new_session_launch(state: &mut State, session_id: &str, original_error: &str) {
1085    let record = state.sessions.get_mut(session_id).unwrap();
1086    record.state = SessionState::StartupCleanup;
1087    record.updated_at = now();
1088    record.last_error = Some(format!("worker bootstrap failed: {original_error}"));
1089}
1090
1091pub(super) fn apply_failed_new_session_rollback(
1092    state: &mut State,
1093    session_id: &str,
1094    original_error: &str,
1095    cleanup_error: Option<String>,
1096) -> anyhow::Error {
1097    match cleanup_error {
1098        None => {
1099            let record = state.sessions.get_mut(session_id).unwrap();
1100            record.state = SessionState::Error;
1101            record.target = None;
1102            record.updated_at = now();
1103            record.last_error = Some(format!("worker bootstrap failed: {original_error}"));
1104            anyhow::anyhow!("{original_error}; partial target removed and failed session retained")
1105        }
1106        Some(cleanup_error) => {
1107            let failure = format!(
1108                "{original_error}; cleanup of the failed session target failed: {cleanup_error}"
1109            );
1110            let record = state.sessions.get_mut(session_id).unwrap();
1111            record.state = SessionState::StartupCleanup;
1112            record.updated_at = now();
1113            record.last_error = Some(format!("worker bootstrap failed: {failure}"));
1114            anyhow::anyhow!(failure)
1115        }
1116    }
1117}
1118
1119pub(super) fn install_attached_resources(
1120    state: &State,
1121    session_id: &str,
1122    backend: &targets::TargetLocator,
1123    worker_root: &str,
1124    executor: &impl CommandExecutor,
1125) -> Result<()> {
1126    let targets::TargetLocator::AwsEc2 { .. } = backend else {
1127        return Ok(());
1128    };
1129    let session = state
1130        .sessions
1131        .get(session_id)
1132        .with_context(|| format!("unknown session {session_id}"))?;
1133    if session.additional_mounts.is_empty() {
1134        return Ok(());
1135    }
1136    for resource in &session.additional_mounts {
1137        let install = targets::command_on_locator(
1138            backend,
1139            session_id,
1140            vec![
1141                format!("{worker_root}/hel"),
1142                "worker".into(),
1143                "install-resource".into(),
1144                "--destination".into(),
1145                resource.destination.to_string_lossy().into_owned(),
1146            ],
1147            "stream attached resource",
1148        )?;
1149        mj_checkpoint::resources::stream_resource(&resource.source, |stream| {
1150            execute_checked_with_stdin(executor, &install, stream).map(|_| ())
1151        })
1152        .with_context(|| format!("stream attached resource {}", resource.source.display()))?;
1153    }
1154    Ok(())
1155}
1156
1157/// Run two independent target setup lanes at the same time and wait for both.
1158/// The first lane's failure wins deterministically when both fail, and neither
1159/// lane is abandoned while it may still own a transfer or subprocess.
1160pub(super) fn execute_concurrent_lanes<A: Send, B: Send>(
1161    first: impl FnOnce() -> Result<A> + Send,
1162    second: impl FnOnce() -> Result<B> + Send,
1163) -> Result<(A, B)> {
1164    std::thread::scope(|scope| {
1165        let owner = crate::worker_lifecycle::capture();
1166        let second = scope.spawn(move || match owner {
1167            Some(owner) => owner.scope_blocking(second),
1168            None => second(),
1169        });
1170        let first = first();
1171        let second = second.join().unwrap_or_else(|panic| {
1172            Err(anyhow::anyhow!(
1173                "concurrent target lane panicked: {}",
1174                targets::command_thread_panic_message(panic.as_ref())
1175            ))
1176        });
1177        match (first, second) {
1178            (Err(error), _) => Err(error),
1179            (Ok(_), Err(error)) => Err(error),
1180            (Ok(first), Ok(second)) => Ok((first, second)),
1181        }
1182    })
1183}
1184
1185fn execute_repository_setup(
1186    plan: &targets::CommandPlan,
1187    executor: &(impl CommandExecutor + Sync),
1188) -> Result<()> {
1189    if plan.commands.is_empty() {
1190        return Ok(());
1191    }
1192    let _cloning = ProvisionStageGuard::new(executor, ProvisionStage::Cloning);
1193    plan.execute_concurrent(executor).map(|_| ())
1194}
1195
1196/// Run a provisioning plan and discover the locator it produced, tearing the
1197/// target down again if anything after its creation fails.
1198///
1199/// Creation is the boundary that matters. A step that fails before the target
1200/// exists has left nothing behind; every failure after it — a later plan step
1201/// or locator discovery — owns a target no session record will point at.
1202#[cfg(test)]
1203fn provision_target(
1204    plan: &targets::CommandPlan,
1205    target: &targets::TargetTemplate,
1206    session_id: &str,
1207    executor: &(impl CommandExecutor + Sync),
1208    discover: impl FnOnce(&[CommandOutput]) -> Result<TargetLocator>,
1209) -> Result<TargetLocator> {
1210    let Some((creation, remainder)) = plan.split_at_target_creation() else {
1211        // Nothing this plan runs can leave a target behind.
1212        return discover(&plan.execute_concurrent(executor)?);
1213    };
1214    let mut outputs = creation.execute_concurrent(executor)?;
1215    let result = match remainder.execute_concurrent(executor) {
1216        Ok(rest) => {
1217            outputs.extend(rest);
1218            discover(&outputs)
1219        }
1220        Err(error) => Err(error),
1221    };
1222    result.map_err(|error| {
1223        match cleanup_failed_provision(target, session_id, outputs.first(), executor) {
1224            Some(note) => error.context(note),
1225            None => error,
1226        }
1227    })
1228}
1229
1230/// Bring the target into existence and return the commands that populate its
1231/// repositories. The caller may overlap that remainder with worker/profile
1232/// installation once it has persisted the discovered locator.
1233#[cfg(test)]
1234fn provision_target_creation(
1235    plan: &targets::CommandPlan,
1236    target: &targets::TargetTemplate,
1237    session_id: &str,
1238    executor: &(impl CommandExecutor + Sync),
1239    discover: impl FnOnce(&[CommandOutput]) -> Result<TargetLocator>,
1240) -> Result<(TargetLocator, targets::CommandPlan)> {
1241    provision_target_creation_named(
1242        plan,
1243        target,
1244        session_id,
1245        executor,
1246        &targets::resource_name(session_id)?,
1247        discover,
1248    )
1249}
1250
1251fn provision_target_creation_named(
1252    plan: &targets::CommandPlan,
1253    target: &targets::TargetTemplate,
1254    session_id: &str,
1255    executor: &(impl CommandExecutor + Sync),
1256    name: &str,
1257    discover: impl FnOnce(&[CommandOutput]) -> Result<TargetLocator>,
1258) -> Result<(TargetLocator, targets::CommandPlan)> {
1259    let Some((creation, remainder)) = plan.split_at_target_creation() else {
1260        // Nothing this plan runs can leave a target behind, so its commands
1261        // must still finish before the locator is usable.
1262        let outputs = crate::image_pull_gate::with_image_ready(target, executor, || {
1263            plan.execute_concurrent(executor)
1264        })?;
1265        return discover(&outputs).map(|locator| {
1266            (
1267                locator,
1268                targets::CommandPlan {
1269                    description: plan.description.clone(),
1270                    commands: Vec::new(),
1271                },
1272            )
1273        });
1274    };
1275    // Creating the container is what downloads the image on Docker, and the
1276    // probe just before it is what downloads it on Podman. Either way, a
1277    // background download of the same image must finish first.
1278    let outputs = crate::image_pull_gate::with_image_ready(target, executor, || {
1279        creation.execute_concurrent(executor)
1280    })?;
1281    discover(&outputs)
1282        .map(|locator| (locator, remainder))
1283        .map_err(|error| {
1284            match cleanup_failed_provision_named(
1285                target,
1286                session_id,
1287                outputs.first(),
1288                executor,
1289                name,
1290            ) {
1291                Some(note) => error.context(note),
1292                None => error,
1293            }
1294        })
1295}
1296
1297/// Best-effort teardown of a target whose creation succeeded but whose
1298/// provisioning failed before a locator was recorded. Returns a note
1299/// describing what happened for inclusion in the session error.
1300///
1301/// The teardown is the session's own close plan, so a failed launch and an
1302/// ordinary close can never disagree about what removing a target means.
1303#[cfg(test)]
1304fn cleanup_failed_provision(
1305    target: &targets::TargetTemplate,
1306    session_id: &str,
1307    create_output: Option<&CommandOutput>,
1308    executor: &impl CommandExecutor,
1309) -> Option<String> {
1310    cleanup_failed_provision_named(
1311        target,
1312        session_id,
1313        create_output,
1314        executor,
1315        &targets::resource_name(session_id).ok()?,
1316    )
1317}
1318
1319fn cleanup_failed_provision_named(
1320    target: &targets::TargetTemplate,
1321    session_id: &str,
1322    create_output: Option<&CommandOutput>,
1323    executor: &impl CommandExecutor,
1324    name: &str,
1325) -> Option<String> {
1326    let locator = provisioned_locator_named(target, session_id, create_output, name)?;
1327
1328    let leak = format!(
1329        "the resource may still exist; find it via its dev.mj.session={session_id} label/tag"
1330    );
1331    let cleanup = if name == targets::resource_name(session_id).ok()? {
1332        targets::close_plan(&locator, session_id)
1333    } else {
1334        targets::retire_move_target_plan(&locator, session_id)
1335    };
1336    let plan = match cleanup {
1337        Ok(plan) => plan,
1338        Err(error) => {
1339            tracing::warn!(
1340                session_id,
1341                error = format!("{error:#}"),
1342                "could not build provisioning cleanup plan"
1343            );
1344            return Some(format!("cleanup FAILED: {error:#}; {leak}"));
1345        }
1346    };
1347    let purpose = plan
1348        .commands
1349        .iter()
1350        .map(|command| command.purpose.clone())
1351        .collect::<Vec<_>>()
1352        .join("; ");
1353    let Err(error) = plan.execute(executor) else {
1354        return Some(format!("cleanup succeeded: {purpose}"));
1355    };
1356    match targets::cleanup_target_is_confirmed_absent(&locator, session_id, executor) {
1357        Ok(true) => Some(format!("cleanup succeeded: {purpose}")),
1358        Ok(false) => {
1359            tracing::warn!(
1360                session_id,
1361                error = format!("{error:#}"),
1362                "provisioning cleanup failed and the target may still exist"
1363            );
1364            Some(format!("cleanup FAILED ({purpose}): {error:#}; {leak}"))
1365        }
1366        Err(confirm_error) => {
1367            tracing::warn!(
1368                session_id,
1369                error = format!("{confirm_error:#}"),
1370                "could not confirm whether the failed provisioning target was removed"
1371            );
1372            Some(format!(
1373                "cleanup FAILED ({purpose}): {error:#}; checking whether it was removed also failed: {confirm_error:#}; {leak}"
1374            ))
1375        }
1376    }
1377}
1378
1379/// The locator a provisioning plan's creating command brought into existence.
1380///
1381/// Every target but AWS is named before its plan runs; an EC2 instance
1382/// reports its own ID in the launch response.
1383fn provisioned_locator_named(
1384    target: &targets::TargetTemplate,
1385    session_id: &str,
1386    create_output: Option<&CommandOutput>,
1387    name: &str,
1388) -> Option<targets::TargetLocator> {
1389    let container_id = || Some(name.to_owned());
1390
1391    Some(match target {
1392        // A bare project directory belongs to the user: provisioning creates
1393        // nothing that a failure could leak.
1394        targets::TargetTemplate::LocalBare => return None,
1395        targets::TargetTemplate::LocalPodman(container) => targets::TargetLocator::LocalPodman {
1396            borrowed_from: None,
1397            container_id: container_id()?,
1398            workspace_storage: targets::podman_workspace_locator_named(container, name).ok()?,
1399        },
1400        targets::TargetTemplate::LocalDocker(_) => targets::TargetLocator::LocalDocker {
1401            borrowed_from: None,
1402            container_id: container_id()?,
1403        },
1404        targets::TargetTemplate::AppleContainer(_) => targets::TargetLocator::AppleContainer {
1405            borrowed_from: None,
1406            container_id: container_id()?,
1407        },
1408        targets::TargetTemplate::SshPodman { ssh, container } => {
1409            targets::TargetLocator::SshPodman {
1410                borrowed_from: None,
1411                ssh: ssh.clone(),
1412                container_id: container_id()?,
1413                workspace_storage: targets::podman_workspace_locator_named(container, name).ok()?,
1414            }
1415        }
1416        targets::TargetTemplate::SshDocker { ssh, .. } => targets::TargetLocator::SshDocker {
1417            borrowed_from: None,
1418            ssh: ssh.clone(),
1419            container_id: container_id()?,
1420        },
1421        targets::TargetTemplate::SshBare { ssh, .. } => targets::TargetLocator::SshBare {
1422            ssh: ssh.clone(),
1423            workspace: targets::workspace_for(target, session_id).ok()?,
1424            worker_id: None,
1425        },
1426        targets::TargetTemplate::AwsEc2(aws) => targets::TargetLocator::AwsEc2 {
1427            profile: aws.profile.clone(),
1428            region: aws.region.clone(),
1429            instance_id: serde_json::from_slice::<serde_json::Value>(&create_output?.stdout)
1430                .ok()?
1431                .pointer("/Instances/0/InstanceId")?
1432                .as_str()?
1433                .to_owned(),
1434            ssh: aws.ssh.clone(),
1435            workspace: targets::workspace_for(target, session_id).ok()?,
1436        },
1437    })
1438}
1439
1440/// Attach read-only whatever the selected container overlay cannot hold, and
1441/// say so.
1442///
1443/// The filesystem is probed on the host that runs the container, because that
1444/// is where the overlay would be built. A probe that cannot answer leaves the
1445/// overlay alone: a failed probe is no evidence of an unsupported filesystem,
1446/// and refusing to provision over one would cost the user their session.
1447///
1448/// Apple's `container` engine already mounts every extra directory read-only,
1449/// and EC2 copies the directory instead of mounting it, so neither is probed.
1450pub(super) fn enforce_overlay_capable_mounts(
1451    target: &targets::TargetTemplate,
1452    mounts: &mut [targets::AdditionalMount],
1453    executor: &impl CommandExecutor,
1454) -> Vec<String> {
1455    let ssh = match target {
1456        targets::TargetTemplate::LocalPodman(_) | targets::TargetTemplate::LocalDocker(_) => None,
1457        targets::TargetTemplate::SshPodman { ssh, .. }
1458        | targets::TargetTemplate::SshDocker { ssh, .. } => Some(ssh),
1459        _ => return Vec::new(),
1460    };
1461    let overlaid = mounts
1462        .iter()
1463        .filter(|mount| mount.access == targets::MountAccess::Cow)
1464        .map(|mount| mount.source.clone())
1465        .collect::<Vec<_>>();
1466    if overlaid.is_empty() {
1467        return Vec::new();
1468    }
1469    // `stat` on this host cannot see what a VM-hosted Docker daemon mounts.
1470    if matches!(target, targets::TargetTemplate::LocalDocker(_)) {
1471        match targets::local_docker_vm_share(executor) {
1472            Ok(Some(reason)) => {
1473                return mounts
1474                    .iter_mut()
1475                    .filter(|mount| mount.access == targets::MountAccess::Cow)
1476                    .map(|mount| {
1477                        mount.access = mount.access.without_overlay();
1478                        format!(
1479                            "Mounted {} read-only: {reason}, which cannot back the \
1480                             copy-on-write overlay.",
1481                            mount.source.display()
1482                        )
1483                    })
1484                    .collect();
1485            }
1486            Ok(None) => {}
1487            Err(error) => tracing::warn!(
1488                error = format!("{error:#}"),
1489                "could not identify the Docker daemon platform; probing this host's filesystems"
1490            ),
1491        }
1492    }
1493    let filesystems = match targets::probe_filesystem_types(ssh, &overlaid, executor) {
1494        Ok(filesystems) => filesystems,
1495        Err(error) => {
1496            tracing::warn!(
1497                error = format!("{error:#}"),
1498                "could not probe attached-directory filesystems; preserving overlay mounts"
1499            );
1500            return vec![format!(
1501                "Could not read the filesystem under the attached directories, so they keep the \
1502                 copy-on-write overlay: {error:#}"
1503            )];
1504        }
1505    };
1506    let mut notices = Vec::new();
1507    for (mount, filesystem) in mounts
1508        .iter_mut()
1509        .filter(|mount| mount.access == targets::MountAccess::Cow)
1510        .zip(filesystems)
1511    {
1512        let Some(reason) = targets::overlay_unsupported_filesystem(&filesystem) else {
1513            continue;
1514        };
1515        mount.access = mount.access.without_overlay();
1516        notices.push(format!(
1517            "Mounted {} read-only: the overlay is unreliable on {filesystem} ({reason}).",
1518            mount.source.display()
1519        ));
1520    }
1521    notices
1522}
1523
1524/// Reports every command an installer issues as one launch stage, so progress
1525/// stays accurate without threading the stage through each `CommandSpec`.
1526/// A command that already names a stage keeps it.
1527pub(super) struct StagedExecutor<'a, E: CommandExecutor> {
1528    inner: &'a E,
1529    stage: ProvisionStage,
1530    _guard: ProvisionStageGuard<'a, E>,
1531}
1532
1533impl<'a, E: CommandExecutor> StagedExecutor<'a, E> {
1534    pub(crate) fn new(inner: &'a E, stage: ProvisionStage) -> Self {
1535        Self {
1536            inner,
1537            stage: stage.clone(),
1538            _guard: ProvisionStageGuard::new(inner, stage.clone()),
1539        }
1540    }
1541
1542    fn staged(&self, command: &CommandSpec) -> CommandSpec {
1543        if command.stage.is_some() {
1544            return command.clone();
1545        }
1546        command.clone().stage(self.stage.clone())
1547    }
1548}
1549
1550impl<E: CommandExecutor> CommandExecutor for StagedExecutor<'_, E> {
1551    fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1552        self.inner.execute(&self.staged(command))
1553    }
1554
1555    fn cancellation_requested(&self) -> bool {
1556        self.inner.cancellation_requested()
1557    }
1558
1559    fn stage_started(&self, stage: ProvisionStage) {
1560        self.inner.stage_started(stage);
1561    }
1562
1563    fn stage_finished(&self, stage: ProvisionStage) {
1564        self.inner.stage_finished(stage);
1565    }
1566
1567    fn notify_notice(&self, notice: &str) {
1568        self.inner.notify_notice(notice);
1569    }
1570
1571    fn execute_with_stdin(
1572        &self,
1573        command: &CommandSpec,
1574        input: &mut (dyn std::io::Read + Send),
1575    ) -> Result<CommandOutput> {
1576        self.inner.execute_with_stdin(&self.staged(command), input)
1577    }
1578}
1579
1580fn execute_checked_with_stdin(
1581    executor: &impl CommandExecutor,
1582    command: &CommandSpec,
1583    input: &mut (dyn std::io::Read + Send),
1584) -> Result<CommandOutput> {
1585    let output = executor.execute_with_stdin(command, input)?;
1586    if output.status != 0 {
1587        bail!(
1588            "{} failed with status {}: {}",
1589            command.purpose,
1590            output.status,
1591            String::from_utf8_lossy(&output.stderr)
1592        );
1593    }
1594    Ok(output)
1595}
1596
1597pub(super) fn install_inherited_git_settings(
1598    executor: &impl CommandExecutor,
1599    locator: &targets::TargetLocator,
1600    session_id: &str,
1601) -> Result<()> {
1602    let settings = if inherits_controller_git_settings(locator) {
1603        controller_git_settings()?
1604    } else {
1605        BTreeMap::new()
1606    };
1607    for command in inherited_git_setting_commands(locator, session_id, settings)? {
1608        execute_checked(executor, command)?;
1609    }
1610    Ok(())
1611}
1612
1613fn inherits_controller_git_settings(locator: &targets::TargetLocator) -> bool {
1614    !matches!(
1615        locator,
1616        targets::TargetLocator::LocalBare { .. } | targets::TargetLocator::SshBare { .. }
1617    )
1618}
1619
1620fn inherited_git_setting_commands(
1621    locator: &targets::TargetLocator,
1622    session_id: &str,
1623    settings: BTreeMap<String, String>,
1624) -> Result<Vec<CommandSpec>> {
1625    if matches!(locator, targets::TargetLocator::SshBare { .. }) {
1626        return Ok(Vec::new());
1627    }
1628    settings
1629        .into_iter()
1630        .map(|(key, value)| {
1631            let args = vec![
1632                "git".into(),
1633                "config".into(),
1634                "--global".into(),
1635                "--replace-all".into(),
1636                "--".into(),
1637                key.clone(),
1638                value,
1639            ];
1640            targets::command_on_locator(
1641                locator,
1642                session_id,
1643                args,
1644                format!("inherit Git setting {key}"),
1645            )
1646        })
1647        .collect()
1648}
1649
1650fn controller_git_settings() -> Result<BTreeMap<String, String>> {
1651    let output = match Command::new("git")
1652        .args(["config", "--global", "--includes", "--null", "--list"])
1653        .stdin(Stdio::null())
1654        .output()
1655    {
1656        Ok(output) => output,
1657        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
1658        Err(error) => return Err(error).context("read controller Git configuration"),
1659    };
1660    if !output.status.success() {
1661        bail!(
1662            "read controller Git configuration failed with status {}: {}",
1663            output.status,
1664            String::from_utf8_lossy(&output.stderr).trim()
1665        );
1666    }
1667    parse_inherited_git_settings(&output.stdout)
1668}
1669
1670fn parse_inherited_git_settings(output: &[u8]) -> Result<BTreeMap<String, String>> {
1671    let mut settings = BTreeMap::new();
1672    for entry in output
1673        .split(|byte| *byte == 0)
1674        .filter(|entry| !entry.is_empty())
1675    {
1676        let entry = std::str::from_utf8(entry).context("decode controller Git configuration")?;
1677        let (key, value) = entry
1678            .split_once('\n')
1679            .with_context(|| format!("controller Git returned malformed entry {entry:?}"))?;
1680        let key = key.to_ascii_lowercase();
1681        if INHERITED_GIT_SETTINGS.contains(&key.as_str()) {
1682            settings.insert(key, value.to_owned());
1683        }
1684    }
1685    Ok(settings)
1686}
1687
1688#[cfg(test)]
1689mod tests;