Skip to main content

mj_controller/hel_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 hel::hel_archive::{
11    ArchiveInput, BundleManifest, GitCollectionSpec, GitHistoryMode, SessionManifest, SystemGit,
12    TargetManifest, collect_git_snapshot, write_archive_atomic,
13};
14use hel::hel_checkpoint::RepositoryRestoreSpec;
15use hel::hel_config::{ProjectBundle, TargetTemplate, atomic_write, data_dir};
16use hel::hel_git_proxy::{GitBrokerSpec, broker_is_alive, running_broker_pid};
17use hel::hel_local_git::canonical_repository;
18use hel::hel_projection::canonical_session_from_materialized;
19use hel::hel_state::{HelState, SessionRecord, SessionState, TargetLocator};
20use hel::hel_targets::{
21    self, CancellableProcessExecutor, CommandExecutor, CommandOutput, CommandSpec, ProvisionStage,
22    ProvisionStageGuard,
23};
24
25use super::backend::{
26    ContainerOverrides, absolute_target_path, backend_bundle, backend_locator, backend_target,
27    configure_github_token_environment, controller_github_token, locator_after_provision,
28    preflight_target, use_github_https_urls,
29};
30use super::checkpoint::upload_checkpoint_spec;
31use super::git_cache;
32use super::readiness::{connect_started_worker, wait_for_native_session_in_stage};
33use super::worker_binary::{bridge_readiness_stage, start_worker, worker_probe_diagnosis};
34use super::{Controller, execute_checked, now, target_kind};
35
36const INHERITED_GIT_SETTINGS: &[&str] = &[
37    "diff.algorithm",
38    "fetch.prune",
39    "fetch.prunetags",
40    "init.defaultbranch",
41    "merge.conflictstyle",
42    "pull.ff",
43    "pull.rebase",
44    "push.autosetupremote",
45    "push.default",
46    "rebase.autostash",
47    "rerere.autoupdate",
48    "rerere.enabled",
49    "user.email",
50    "user.name",
51];
52
53/// Whether connecting local repositories may also carry the user's current
54/// uncommitted changes into a still-empty target checkout.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub(super) enum LocalBootstrap {
57    /// A fresh target starts from `git init`, so seed its branch and dirty
58    /// state from the local repository.
59    Seed,
60    /// Seed from this checkout instead of the bundle's configured path. A
61    /// resume that moves a raw session into a target carries the session's own
62    /// worktree, not the user's primary checkout.
63    SeedFrom(PathBuf),
64    /// Resume restores the session's own dirty state from the checkpoint
65    /// archive; seeding the local repository's would collide with it.
66    Skip,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub(super) enum ProvisioningFailureDisposition {
71    /// A freshly registered session has no durable history to retain.
72    Discard,
73    /// Resume owns rollback to the archived record and checkpoint lineage.
74    Preserve,
75}
76
77impl Controller {
78    pub async fn provision_session_controlled(
79        &mut self,
80        session_id: &str,
81        executor: &(impl CommandExecutor + Sync),
82    ) -> Result<()> {
83        self.provision_session_controlled_with_commit(session_id, executor, || Ok(()))
84            .await
85    }
86
87    pub async fn provision_session_controlled_with_commit(
88        &mut self,
89        session_id: &str,
90        executor: &(impl CommandExecutor + Sync),
91        grant_commit: impl FnOnce() -> Result<()>,
92    ) -> Result<()> {
93        let github_token = controller_github_token();
94        let repositories = self
95            .provision_session_target_with_failure_disposition(
96                session_id,
97                executor,
98                github_token.as_deref(),
99                ProvisioningFailureDisposition::Discard,
100            )
101            .await?;
102        let setup = execute_concurrent_lanes(
103            || execute_repository_setup(&repositories, executor),
104            || self.install_worker_payload(session_id, executor),
105        );
106        let result = match setup {
107            Ok(((), (backend, worker_root))) => {
108                self.connect_and_start_worker(session_id, executor, &backend, &worker_root)
109                    .await
110            }
111            Err(error) => Err(error),
112        };
113        match result {
114            Ok(native_session_id) => {
115                if let Err(error) = grant_commit() {
116                    return Err(self.rollback_failed_new_session(session_id, error, executor)?);
117                }
118                self.mark_worker_connected(session_id, native_session_id)
119            }
120            Err(error) => Err(self.rollback_failed_new_session(session_id, error, executor)?),
121        }
122    }
123
124    fn rollback_failed_new_session(
125        &mut self,
126        session_id: &str,
127        error: anyhow::Error,
128        executor: &impl CommandExecutor,
129    ) -> Result<anyhow::Error> {
130        let session = self
131            .state
132            .sessions
133            .get(session_id)
134            .with_context(|| format!("unknown session {session_id}"))?
135            .clone();
136        // The broker bridges into the target, so it is stopped before the
137        // target goes away. A launch this rollback discards is over: nothing
138        // will connect to its local origin again.
139        let broker_cleanup = retire_git_broker(session_id);
140        let target_cleanup = match session.target.as_ref() {
141            Some(locator) => (|| -> Result<()> {
142                let backend = backend_locator(locator, &session, &self.config)?;
143                hel_targets::close_plan(&backend, session_id)?
144                    // Rollback must remain possible after the foreground
145                    // operation's cancellation token has been set.
146                    .execute(&CancellableProcessExecutor::with_timeout(
147                        Duration::from_secs(15),
148                    ))
149                    .map(|_| ())
150            })(),
151            None => Ok(()),
152        };
153        let worktree_cleanup =
154            self.cleanup_new_session_worktree_after_failure(session_id, executor);
155        let cleanup_error = [broker_cleanup, target_cleanup, worktree_cleanup]
156            .into_iter()
157            .filter_map(Result::err)
158            .map(|error| format!("{error:#}"))
159            .collect::<Vec<_>>()
160            .join("; ");
161        if !cleanup_error.is_empty() {
162            tracing::warn!(
163                session_id,
164                error = %cleanup_error,
165                "new-session rollback cleanup reported failures"
166            );
167        }
168        let original = format!("{error:#}");
169        let original = match persist_launch_failure(session_id, &original) {
170            Ok(path) => format!("{original}; full diagnostic saved to {}", path.display()),
171            Err(save_error) => {
172                format!("{original}; saving the local diagnostic failed: {save_error:#}")
173            }
174        };
175        let failure = apply_failed_new_session_rollback(
176            &mut self.state,
177            session_id,
178            &original,
179            (!cleanup_error.is_empty()).then_some(cleanup_error),
180        );
181        self.persist_session_state(session_id)?;
182        Ok(failure)
183    }
184
185    pub async fn provision_session_with(
186        &mut self,
187        session_id: &str,
188        executor: &(impl CommandExecutor + Sync),
189    ) -> Result<()> {
190        self.provision_session_with_github_token(session_id, executor, None)
191            .await
192    }
193
194    async fn provision_session_with_github_token(
195        &mut self,
196        session_id: &str,
197        executor: &(impl CommandExecutor + Sync),
198        github_token: Option<&str>,
199    ) -> Result<()> {
200        self.provision_session_with_failure_disposition(
201            session_id,
202            executor,
203            github_token,
204            ProvisioningFailureDisposition::Discard,
205        )
206        .await
207    }
208
209    pub(super) async fn provision_session_with_failure_disposition(
210        &mut self,
211        session_id: &str,
212        executor: &(impl CommandExecutor + Sync),
213        github_token: Option<&str>,
214        failure_disposition: ProvisioningFailureDisposition,
215    ) -> Result<()> {
216        let repositories = self
217            .provision_session_target_with_failure_disposition(
218                session_id,
219                executor,
220                github_token,
221                failure_disposition,
222            )
223            .await?;
224        match execute_repository_setup(&repositories, executor) {
225            Ok(()) => Ok(()),
226            Err(error) if failure_disposition == ProvisioningFailureDisposition::Discard => {
227                Err(self.rollback_failed_new_session(session_id, error, executor)?)
228            }
229            Err(error) => Err(error),
230        }
231    }
232
233    async fn provision_session_target_with_failure_disposition(
234        &mut self,
235        session_id: &str,
236        executor: &(impl CommandExecutor + Sync),
237        github_token: Option<&str>,
238        failure_disposition: ProvisioningFailureDisposition,
239    ) -> Result<hel_targets::CommandPlan> {
240        let session = self
241            .state
242            .sessions
243            .get(session_id)
244            .with_context(|| format!("unknown session {session_id}"))?
245            .clone();
246        if session.state != SessionState::Provisioning {
247            bail!("session {session_id} is not provisioning");
248        }
249        let created_worktree = match self.prepare_managed_raw_worktree(session_id, executor) {
250            Ok(created) => created,
251            Err(error) if failure_disposition == ProvisioningFailureDisposition::Discard => {
252                return Err(self.fail_new_session_with_cleanup(session_id, error, executor)?);
253            }
254            Err(error) => return Err(error),
255        };
256        let session = self
257            .state
258            .sessions
259            .get(session_id)
260            .expect("session retained after managed worktree preparation")
261            .clone();
262        // Keep planning, preflight, creation, and locator discovery in one
263        // result so the caller's failure disposition applies to every error.
264        let result = (|| {
265            let template = self
266                .config
267                .targets
268                .get(&session.target_template_id)
269                .context("target template disappeared during provisioning")?;
270            if matches!(template, TargetTemplate::AwsEc2 { .. }) {
271                for resource in &session.additional_mounts {
272                    ensure!(
273                        resource.source.is_dir(),
274                        "attached resource source is not a directory: {}",
275                        resource.source.display()
276                    );
277                }
278            }
279            let mut target = backend_target(
280                template,
281                session.resource_allocation.as_ref(),
282                ContainerOverrides::for_session(&session),
283            )?;
284            let mut runtime_mounts = if matches!(target, hel_targets::TargetTemplate::AwsEc2(_)) {
285                Vec::new()
286            } else {
287                session.additional_mounts.clone()
288            };
289            // The mounts this container runs with, not the ones the session
290            // stores: a forced downgrade belongs to the host the container
291            // lands on, so it is decided here every time and never written
292            // over the user's choice.
293            for notice in enforce_overlay_capable_mounts(&target, &mut runtime_mounts, executor) {
294                executor.notify_notice(&notice);
295            }
296            let mut bundle = session
297                .project_directory
298                .is_none()
299                .then(|| self.config.bundles.get(&session.bundle_id))
300                .flatten()
301                .map(backend_bundle)
302                .transpose()?;
303            let container_github_token =
304                github_token.filter(|_| configure_github_token_environment(&mut target));
305            if container_github_token.is_some()
306                && let Some(bundle) = bundle.as_mut()
307            {
308                use_github_https_urls(bundle);
309            }
310            preflight_target(template, executor)?;
311            let prepared_cache = bundle.as_mut().and_then(|bundle| {
312                git_cache::prepare(
313                    &target,
314                    session_id,
315                    bundle,
316                    &mut runtime_mounts,
317                    container_github_token,
318                    executor,
319                )
320            });
321            let provision = if let Some(project_directory) = &session.project_directory {
322                hel_targets::provision_bare_project_plan(
323                    &target,
324                    session_id,
325                    &project_directory.to_string_lossy(),
326                )
327            } else {
328                bundle
329                    .as_ref()
330                    .context("project bundle disappeared during provisioning")
331                    .and_then(|bundle| {
332                        hel_targets::provision_plan(&target, session_id, bundle, &runtime_mounts)
333                    })
334            };
335            let mut provision = match provision {
336                Ok(provision) => provision,
337                Err(error) => {
338                    if let Some(cache) = &prepared_cache {
339                        let _ = cache.cleanup(executor);
340                    }
341                    return Err(error);
342                }
343            };
344            if let Some(token) = container_github_token
345                && let Err(error) =
346                    provision.provide_target_environment_secret(&target, "GH_TOKEN", token)
347            {
348                if let Some(cache) = &prepared_cache {
349                    let _ = cache.cleanup(executor);
350                }
351                return Err(error);
352            }
353
354            let started = Instant::now();
355            let result =
356                provision_target_creation(&provision, &target, session_id, executor, |outputs| {
357                    locator_after_provision(
358                        template,
359                        &target,
360                        session_id,
361                        outputs.first(),
362                        executor,
363                    )
364                })
365                .map(|(locator, remainder)| (locator, remainder, bundle));
366            if result.is_err()
367                && let Some(cache) = &prepared_cache
368            {
369                if let Some(locator) = provisioned_locator(&target, session_id, None) {
370                    let _ = hel_targets::close_plan(&locator, session_id)
371                        .and_then(|plan| plan.execute(executor).map(|_| ()));
372                } else {
373                    let _ = cache.cleanup(executor);
374                }
375            }
376            tracing::debug!(
377                session_id,
378                elapsed_ms = started.elapsed().as_millis(),
379                "provisioning plan execution completed"
380            );
381            result
382        })();
383        let result = match result {
384            Err(error)
385                if created_worktree
386                    && failure_disposition == ProvisioningFailureDisposition::Discard =>
387            {
388                return Err(self.fail_new_session_with_cleanup(session_id, error, executor)?);
389            }
390            Err(error) if failure_disposition == ProvisioningFailureDisposition::Preserve => {
391                Err(error)
392            }
393            Err(error) => {
394                let error = match apply_new_session_provisioning_result(
395                    &mut self.state,
396                    session_id,
397                    Err(error),
398                ) {
399                    Ok(()) => unreachable!("an unsuccessful provisioning result returned Ok"),
400                    Err(error) => error,
401                };
402                return match self.persist_session_state(session_id) {
403                    Ok(()) => Err(error),
404                    Err(persistence_error) => Err(error.context(format!(
405                        "persist removal of failed provisioning session {session_id}: {persistence_error:#}"
406                    ))),
407                };
408            }
409            Ok((locator, remainder, bundle)) => {
410                apply_new_session_provisioning_result(&mut self.state, session_id, Ok(locator))?;
411                let session = &self.state.sessions[session_id];
412                let backend = backend_locator(
413                    session
414                        .target
415                        .as_ref()
416                        .context("provisioned target disappeared")?,
417                    session,
418                    &self.config,
419                )?;
420                if matches!(backend, hel_targets::TargetLocator::AwsEc2 { .. }) {
421                    hel_targets::provision_on_locator_plan(
422                        &backend,
423                        session_id,
424                        bundle
425                            .as_ref()
426                            .context("AWS provisioning requires a project bundle")?,
427                    )
428                } else {
429                    Ok(remainder)
430                }
431            }
432        };
433        let result = match result {
434            Err(error) if failure_disposition == ProvisioningFailureDisposition::Discard => {
435                return Err(self.rollback_failed_new_session(session_id, error, executor)?);
436            }
437            result => result,
438        };
439        if result.is_ok()
440            && let Some(session) = self.state.sessions.get(session_id)
441            && let Some(directory) = session
442                .managed_worktree
443                .as_ref()
444                .map(|worktree| worktree.source_project_directory.clone())
445                .or_else(|| session.project_directory.clone())
446            && let Some(template) = self.config.targets.get(&session.target_template_id)
447        {
448            let host = match template {
449                TargetTemplate::LocalBare => Some("local"),
450                TargetTemplate::SshBare { ssh, .. } => Some(ssh.host.as_str()),
451                _ => None,
452            };
453            if let Some(host) = host {
454                self.state.remember_project_directory(host, &directory);
455                hel::hel_database::remember_project_directory(host, &directory)?;
456            }
457        }
458        self.persist_session_state(session_id)?;
459        result
460    }
461
462    pub fn mark_worker_connected(
463        &mut self,
464        session_id: &str,
465        native_session_id: Option<String>,
466    ) -> Result<()> {
467        let session = self
468            .state
469            .sessions
470            .get(session_id)
471            .with_context(|| format!("unknown session {session_id}"))?;
472        if session.target.is_none() {
473            bail!("session {session_id} has no provisioned target");
474        }
475        let updated_at = now();
476        hel::hel_database::mark_session_worker_connected(
477            session_id,
478            native_session_id.as_deref(),
479            &updated_at,
480        )?;
481        let session = self
482            .state
483            .sessions
484            .get_mut(session_id)
485            .expect("session disappeared after its worker connection was saved");
486        session.state = SessionState::Running;
487        if native_session_id.is_some() {
488            session.native_session_id = native_session_id;
489        }
490        session.updated_at = updated_at;
491        session.last_error = None;
492        Ok(())
493    }
494
495    fn install_worker_payload(
496        &self,
497        session_id: &str,
498        executor: &impl CommandExecutor,
499    ) -> Result<(hel_targets::TargetLocator, String)> {
500        // Worker/profile installation is independent of repository cloning.
501        let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
502        let (backend, worker_root) = self.worker_placement(session_id)?;
503        self.prepare_worker_files(session_id, &backend, &worker_root, syncing)?;
504        install_attached_resources(&self.state, session_id, &backend, &worker_root, syncing)?;
505        Ok((backend, worker_root))
506    }
507
508    async fn connect_and_start_worker(
509        &self,
510        session_id: &str,
511        executor: &impl CommandExecutor,
512        backend: &hel_targets::TargetLocator,
513        worker_root: &str,
514    ) -> Result<Option<String>> {
515        // A local-origin fetch needs both the checkout from the clone lane and
516        // the worker binary from the sync lane, so it joins them here.
517        {
518            let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
519            install_inherited_git_settings(executor, backend, session_id)?;
520            self.connect_local_repositories(
521                session_id,
522                backend,
523                worker_root,
524                syncing,
525                LocalBootstrap::Seed,
526            )?;
527        }
528        let session = self
529            .state
530            .sessions
531            .get(session_id)
532            .with_context(|| format!("unknown session {session_id}"))?;
533        let profile = self
534            .config
535            .profiles
536            .get(&session.last_profile)
537            .with_context(|| format!("unknown profile {}", session.last_profile))?;
538        let readiness_stage = bridge_readiness_stage(profile);
539        let reconnect = &hel_targets::reconnect_plan(backend, session_id)?.commands[0];
540        let readiness = async {
541            let mut relay = {
542                let _starting = ProvisionStageGuard::new(executor, ProvisionStage::Starting);
543                start_worker(executor, backend, worker_root)?;
544                connect_started_worker(reconnect, session_id, executor, backend, worker_root)
545                    .await?
546            };
547            let native_session_id =
548                wait_for_native_session_in_stage(&mut relay, executor, readiness_stage).await?;
549            Ok(Some(native_session_id))
550        }
551        .await;
552        match readiness {
553            Ok(native_session_id) => Ok(native_session_id),
554            Err(error) => Err(worker_probe_diagnosis(
555                executor,
556                backend,
557                worker_root,
558                error,
559            )),
560        }
561    }
562
563    /// Point the target's checkouts at the `hel-local` Git proxy and fetch the
564    /// committed history it serves. `bootstrap` decides whether a still-empty
565    /// checkout is also seeded with the local repository's uncommitted changes.
566    pub(super) fn connect_local_repositories(
567        &self,
568        session_id: &str,
569        backend: &hel_targets::TargetLocator,
570        worker_root: &str,
571        executor: &impl CommandExecutor,
572        bootstrap: LocalBootstrap,
573    ) -> Result<()> {
574        let session = self
575            .state
576            .sessions
577            .get(session_id)
578            .with_context(|| format!("unknown session {session_id}"))?;
579        if session.project_directory.is_some() {
580            return Ok(());
581        }
582        let bundle = self
583            .config
584            .bundles
585            .get(&session.bundle_id)
586            .context("session bundle is missing")?;
587        let local = bundle
588            .repositories
589            .iter()
590            .filter_map(|repository| repository.local.as_ref().map(|path| (repository, path)))
591            .collect::<Vec<_>>();
592        if local.is_empty() {
593            return Ok(());
594        }
595
596        let absolute_worker_root =
597            absolute_target_path(executor, backend, session_id, worker_root)?;
598        let repositories = local
599            .iter()
600            .map(|(repository, path)| Ok((repository.id.clone(), canonical_repository(path)?)))
601            .collect::<Result<BTreeMap<_, _>>>()?;
602        ensure_git_broker(session_id, backend, repositories)?;
603
604        let workspace_root = match backend {
605            hel_targets::TargetLocator::LocalPodman { .. }
606            | hel_targets::TargetLocator::LocalDocker { .. }
607            | hel_targets::TargetLocator::AppleContainer { .. }
608            | hel_targets::TargetLocator::SshPodman { .. }
609            | hel_targets::TargetLocator::SshDocker { .. } => "/workspace".to_owned(),
610            hel_targets::TargetLocator::AwsEc2 { workspace, .. }
611            | hel_targets::TargetLocator::SshBare { workspace, .. } => workspace.clone(),
612            hel_targets::TargetLocator::LocalBare { worker_root } => worker_root.clone(),
613        };
614        let mut missing = Vec::new();
615        for &(repository, source) in &local {
616            local_branch(source)?;
617            let destination = format!(
618                "{workspace_root}/{}",
619                repository.destination.to_string_lossy()
620            );
621            let origin = local_origin_url(&absolute_worker_root, &repository.id);
622            for (args, purpose) in [
623                (
624                    vec![
625                        "git".into(),
626                        "-C".into(),
627                        destination.clone(),
628                        "config".into(),
629                        "protocol.ext.allow".into(),
630                        "always".into(),
631                    ],
632                    "enable the confined local Git transport",
633                ),
634                (
635                    vec![
636                        "git".into(),
637                        "-C".into(),
638                        destination.clone(),
639                        "config".into(),
640                        "remote.origin.url".into(),
641                        origin,
642                    ],
643                    "configure local Git origin",
644                ),
645                (
646                    vec![
647                        "git".into(),
648                        "-C".into(),
649                        destination.clone(),
650                        "config".into(),
651                        "remote.origin.fetch".into(),
652                        "+refs/heads/*:refs/remotes/origin/*".into(),
653                    ],
654                    "configure local Git fetch refspec",
655                ),
656            ] {
657                execute_checked(
658                    executor,
659                    hel_targets::command_on_locator(backend, session_id, args, purpose)?,
660                )?;
661            }
662            let has_head = executor.execute(&hel_targets::command_on_locator(
663                backend,
664                session_id,
665                vec![
666                    "git".into(),
667                    "-C".into(),
668                    destination.clone(),
669                    "rev-parse".into(),
670                    "--verify".into(),
671                    "HEAD".into(),
672                ],
673                "inspect local Git bootstrap state",
674            )?)?;
675            if has_head.status != 0 {
676                missing.push((repository, source));
677            }
678        }
679        // Fetch before bootstrapping: the proxy delivers every branch, so the
680        // bootstrap archive only has to carry identity and dirty state, and
681        // the commit it checks out is already present.
682        for (repository, _) in &local {
683            let destination = format!(
684                "{workspace_root}/{}",
685                repository.destination.to_string_lossy()
686            );
687            execute_checked(
688                executor,
689                hel_targets::command_on_locator(
690                    backend,
691                    session_id,
692                    vec![
693                        "git".into(),
694                        "-C".into(),
695                        destination.clone(),
696                        "fetch".into(),
697                        "origin".into(),
698                    ],
699                    "fetch local Git origin",
700                )?,
701            )?;
702        }
703        if let Some(sources) = seed_sources(&missing, &bootstrap)
704            && !sources.is_empty()
705        {
706            bootstrap_local_repositories(
707                executor,
708                backend,
709                session,
710                bundle,
711                &workspace_root,
712                worker_root,
713                &sources,
714            )?;
715        }
716        Ok(())
717    }
718}
719
720const MAX_LAUNCH_DIAGNOSTIC_BYTES: usize = 64 * 1024;
721
722const RETAINED_LAUNCH_DIAGNOSTICS: usize = 20;
723
724fn persist_launch_failure(session_id: &str, detail: &str) -> Result<PathBuf> {
725    persist_launch_failure_to(&data_dir().join("diagnostics"), session_id, detail)
726}
727
728fn persist_launch_failure_to(directory: &Path, session_id: &str, detail: &str) -> Result<PathBuf> {
729    hel::hel_config::validate_id("session", session_id)?;
730    std::fs::create_dir_all(directory).with_context(|| {
731        format!(
732            "create launch diagnostics directory {}",
733            directory.display()
734        )
735    })?;
736    #[cfg(unix)]
737    {
738        use std::os::unix::fs::PermissionsExt;
739        std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700))?;
740    }
741    let path = directory.join(format!("{session_id}-launch-error.txt"));
742    let detail = bounded_launch_diagnostic(detail);
743    let body = format!(
744        "Hel session launch failure\nsession: {session_id}\nat: {}\n\n{detail}\n",
745        now()
746    );
747    atomic_write(&path, body.as_bytes())?;
748    prune_launch_diagnostics(directory)?;
749    Ok(path)
750}
751
752fn bounded_launch_diagnostic(detail: &str) -> String {
753    if detail.len() <= MAX_LAUNCH_DIAGNOSTIC_BYTES {
754        return detail.to_owned();
755    }
756    let mut head_end = MAX_LAUNCH_DIAGNOSTIC_BYTES / 4;
757    while !detail.is_char_boundary(head_end) {
758        head_end -= 1;
759    }
760    let tail_bytes = MAX_LAUNCH_DIAGNOSTIC_BYTES - head_end;
761    let mut tail_start = detail.len() - tail_bytes;
762    while !detail.is_char_boundary(tail_start) {
763        tail_start += 1;
764    }
765    format!(
766        "{}\n\n[... launch diagnostic truncated ...]\n\n{}",
767        &detail[..head_end],
768        &detail[tail_start..]
769    )
770}
771
772fn prune_launch_diagnostics(directory: &Path) -> Result<()> {
773    let mut diagnostics = Vec::new();
774    for entry in std::fs::read_dir(directory)? {
775        let entry = entry?;
776        if !entry
777            .file_name()
778            .to_str()
779            .is_some_and(|name| name.ends_with("-launch-error.txt"))
780        {
781            continue;
782        }
783        diagnostics.push((entry.metadata()?.modified()?, entry.path()));
784    }
785    diagnostics.sort_by_key(|entry| std::cmp::Reverse(entry.0));
786    for (_, path) in diagnostics.into_iter().skip(RETAINED_LAUNCH_DIAGNOSTICS) {
787        std::fs::remove_file(&path)
788            .with_context(|| format!("prune old launch diagnostic {}", path.display()))?;
789    }
790    Ok(())
791}
792
793fn apply_new_session_provisioning_result(
794    state: &mut HelState,
795    session_id: &str,
796    result: Result<TargetLocator>,
797) -> Result<()> {
798    match result {
799        Ok(locator) => {
800            let record = state.sessions.get_mut(session_id).unwrap();
801            record.target = Some(locator);
802            // Provisioning has completed, but Running is reserved for a
803            // successful worker handshake.
804            record.state = SessionState::Disconnected;
805            record.updated_at = now();
806            record.last_error = None;
807            Ok(())
808        }
809        Err(error) => {
810            state.sessions.remove(session_id);
811            Err(error)
812        }
813    }
814}
815
816pub(super) fn apply_failed_new_session_rollback(
817    state: &mut HelState,
818    session_id: &str,
819    original_error: &str,
820    cleanup_error: Option<String>,
821) -> anyhow::Error {
822    match cleanup_error {
823        None => {
824            state.sessions.remove(session_id);
825            anyhow::anyhow!(
826                "{original_error}; partial target removed and provisional session discarded"
827            )
828        }
829        Some(cleanup_error) => {
830            let failure = format!(
831                "{original_error}; cleanup of the failed session target failed: {cleanup_error}"
832            );
833            let record = state.sessions.get_mut(session_id).unwrap();
834            record.state = SessionState::Error;
835            record.updated_at = now();
836            record.last_error = Some(format!("worker bootstrap failed: {failure}"));
837            anyhow::anyhow!(failure)
838        }
839    }
840}
841
842pub(super) fn install_attached_resources(
843    state: &HelState,
844    session_id: &str,
845    backend: &hel_targets::TargetLocator,
846    worker_root: &str,
847    executor: &impl CommandExecutor,
848) -> Result<()> {
849    let hel_targets::TargetLocator::AwsEc2 { .. } = backend else {
850        return Ok(());
851    };
852    let session = state
853        .sessions
854        .get(session_id)
855        .with_context(|| format!("unknown session {session_id}"))?;
856    if session.additional_mounts.is_empty() {
857        return Ok(());
858    }
859    for resource in &session.additional_mounts {
860        let install = hel_targets::command_on_locator(
861            backend,
862            session_id,
863            vec![
864                format!("{worker_root}/hel"),
865                "worker".into(),
866                "install-resource".into(),
867                "--destination".into(),
868                resource.destination.to_string_lossy().into_owned(),
869            ],
870            "stream attached resource",
871        )?;
872        hel::hel_resources::stream_resource(&resource.source, |stream| {
873            execute_checked_with_stdin(executor, &install, stream).map(|_| ())
874        })
875        .with_context(|| format!("stream attached resource {}", resource.source.display()))?;
876    }
877    Ok(())
878}
879
880/// Run two independent target setup lanes at the same time and wait for both.
881/// The first lane's failure wins deterministically when both fail, and neither
882/// lane is abandoned while it may still own a transfer or subprocess.
883pub(super) fn execute_concurrent_lanes<A: Send, B: Send>(
884    first: impl FnOnce() -> Result<A> + Send,
885    second: impl FnOnce() -> Result<B> + Send,
886) -> Result<(A, B)> {
887    std::thread::scope(|scope| {
888        let second = scope.spawn(second);
889        let first = first();
890        let second = second.join().unwrap_or_else(|panic| {
891            Err(anyhow::anyhow!(
892                "concurrent target lane panicked: {}",
893                hel_targets::command_thread_panic_message(panic.as_ref())
894            ))
895        });
896        match (first, second) {
897            (Err(error), _) => Err(error),
898            (Ok(_), Err(error)) => Err(error),
899            (Ok(first), Ok(second)) => Ok((first, second)),
900        }
901    })
902}
903
904fn execute_repository_setup(
905    plan: &hel_targets::CommandPlan,
906    executor: &(impl CommandExecutor + Sync),
907) -> Result<()> {
908    if plan.commands.is_empty() {
909        return Ok(());
910    }
911    let _cloning = ProvisionStageGuard::new(executor, ProvisionStage::Cloning);
912    plan.execute_concurrent(executor).map(|_| ())
913}
914
915/// Run a provisioning plan and discover the locator it produced, tearing the
916/// target down again if anything after its creation fails.
917///
918/// Creation is the boundary that matters. A step that fails before the target
919/// exists has left nothing behind; every failure after it — a later plan step
920/// or locator discovery — owns a target no session record will point at.
921#[cfg(test)]
922fn provision_target(
923    plan: &hel_targets::CommandPlan,
924    target: &hel_targets::TargetTemplate,
925    session_id: &str,
926    executor: &(impl CommandExecutor + Sync),
927    discover: impl FnOnce(&[CommandOutput]) -> Result<TargetLocator>,
928) -> Result<TargetLocator> {
929    let Some((creation, remainder)) = plan.split_at_target_creation() else {
930        // Nothing this plan runs can leave a target behind.
931        return discover(&plan.execute_concurrent(executor)?);
932    };
933    let mut outputs = creation.execute_concurrent(executor)?;
934    let result = match remainder.execute_concurrent(executor) {
935        Ok(rest) => {
936            outputs.extend(rest);
937            discover(&outputs)
938        }
939        Err(error) => Err(error),
940    };
941    result.map_err(|error| {
942        match cleanup_failed_provision(target, session_id, outputs.first(), executor) {
943            Some(note) => error.context(note),
944            None => error,
945        }
946    })
947}
948
949/// Bring the target into existence and return the commands that populate its
950/// repositories. The caller may overlap that remainder with worker/profile
951/// installation once it has persisted the discovered locator.
952fn provision_target_creation(
953    plan: &hel_targets::CommandPlan,
954    target: &hel_targets::TargetTemplate,
955    session_id: &str,
956    executor: &(impl CommandExecutor + Sync),
957    discover: impl FnOnce(&[CommandOutput]) -> Result<TargetLocator>,
958) -> Result<(TargetLocator, hel_targets::CommandPlan)> {
959    let Some((creation, remainder)) = plan.split_at_target_creation() else {
960        // Nothing this plan runs can leave a target behind, so its commands
961        // must still finish before the locator is usable.
962        let outputs = plan.execute_concurrent(executor)?;
963        return discover(&outputs).map(|locator| {
964            (
965                locator,
966                hel_targets::CommandPlan {
967                    description: plan.description.clone(),
968                    commands: Vec::new(),
969                },
970            )
971        });
972    };
973    let outputs = creation.execute_concurrent(executor)?;
974    discover(&outputs)
975        .map(|locator| (locator, remainder))
976        .map_err(|error| {
977            match cleanup_failed_provision(target, session_id, outputs.first(), executor) {
978                Some(note) => error.context(note),
979                None => error,
980            }
981        })
982}
983
984/// Best-effort teardown of a target whose creation succeeded but whose
985/// provisioning failed before a locator was recorded. Returns a note
986/// describing what happened for inclusion in the session error.
987///
988/// The teardown is the session's own close plan, so a failed launch and an
989/// ordinary close can never disagree about what removing a target means.
990fn cleanup_failed_provision(
991    target: &hel_targets::TargetTemplate,
992    session_id: &str,
993    create_output: Option<&CommandOutput>,
994    executor: &impl CommandExecutor,
995) -> Option<String> {
996    let locator = provisioned_locator(target, session_id, create_output)?;
997    let leak = format!(
998        "the resource may still exist; find it via its dev.mj.session={session_id} label/tag"
999    );
1000    let plan = match hel_targets::close_plan(&locator, session_id) {
1001        Ok(plan) => plan,
1002        Err(error) => {
1003            tracing::warn!(
1004                session_id,
1005                error = format!("{error:#}"),
1006                "could not build provisioning cleanup plan"
1007            );
1008            return Some(format!("cleanup FAILED: {error:#}; {leak}"));
1009        }
1010    };
1011    let purpose = plan
1012        .commands
1013        .iter()
1014        .map(|command| command.purpose.clone())
1015        .collect::<Vec<_>>()
1016        .join("; ");
1017    let Err(error) = plan.execute(executor) else {
1018        return Some(format!("cleanup succeeded: {purpose}"));
1019    };
1020    match hel_targets::cleanup_target_is_confirmed_absent(&locator, session_id, executor) {
1021        Ok(true) => Some(format!("cleanup succeeded: {purpose}")),
1022        Ok(false) => {
1023            tracing::warn!(
1024                session_id,
1025                error = format!("{error:#}"),
1026                "provisioning cleanup failed and the target may still exist"
1027            );
1028            Some(format!("cleanup FAILED ({purpose}): {error:#}; {leak}"))
1029        }
1030        Err(confirm_error) => {
1031            tracing::warn!(
1032                session_id,
1033                error = format!("{confirm_error:#}"),
1034                "could not confirm whether the failed provisioning target was removed"
1035            );
1036            Some(format!(
1037                "cleanup FAILED ({purpose}): {error:#}; checking whether it was removed also failed: {confirm_error:#}; {leak}"
1038            ))
1039        }
1040    }
1041}
1042
1043/// The locator a provisioning plan's creating command brought into existence.
1044///
1045/// Every target but AWS is named before its plan runs; an EC2 instance
1046/// reports its own ID in the launch response.
1047fn provisioned_locator(
1048    target: &hel_targets::TargetTemplate,
1049    session_id: &str,
1050    create_output: Option<&CommandOutput>,
1051) -> Option<hel_targets::TargetLocator> {
1052    let container_id = || hel_targets::resource_name(session_id).ok();
1053    Some(match target {
1054        // A bare project directory belongs to the user: provisioning creates
1055        // nothing that a failure could leak.
1056        hel_targets::TargetTemplate::LocalBare => return None,
1057        hel_targets::TargetTemplate::LocalPodman(container) => {
1058            hel_targets::TargetLocator::LocalPodman {
1059                container_id: container_id()?,
1060                workspace_storage: hel_targets::podman_workspace_locator(container, session_id)
1061                    .ok()?,
1062            }
1063        }
1064        hel_targets::TargetTemplate::LocalDocker(_) => hel_targets::TargetLocator::LocalDocker {
1065            container_id: container_id()?,
1066        },
1067        hel_targets::TargetTemplate::AppleContainer(_) => {
1068            hel_targets::TargetLocator::AppleContainer {
1069                container_id: container_id()?,
1070            }
1071        }
1072        hel_targets::TargetTemplate::SshPodman { ssh, container } => {
1073            hel_targets::TargetLocator::SshPodman {
1074                ssh: ssh.clone(),
1075                container_id: container_id()?,
1076                workspace_storage: hel_targets::podman_workspace_locator(container, session_id)
1077                    .ok()?,
1078            }
1079        }
1080        hel_targets::TargetTemplate::SshDocker { ssh, .. } => {
1081            hel_targets::TargetLocator::SshDocker {
1082                ssh: ssh.clone(),
1083                container_id: container_id()?,
1084            }
1085        }
1086        hel_targets::TargetTemplate::SshBare { ssh, .. } => hel_targets::TargetLocator::SshBare {
1087            ssh: ssh.clone(),
1088            workspace: hel_targets::workspace_for(target, session_id).ok()?,
1089        },
1090        hel_targets::TargetTemplate::AwsEc2(aws) => hel_targets::TargetLocator::AwsEc2 {
1091            profile: aws.profile.clone(),
1092            region: aws.region.clone(),
1093            instance_id: serde_json::from_slice::<serde_json::Value>(&create_output?.stdout)
1094                .ok()?
1095                .pointer("/Instances/0/InstanceId")?
1096                .as_str()?
1097                .to_owned(),
1098            ssh: aws.ssh.clone(),
1099            workspace: hel_targets::workspace_for(target, session_id).ok()?,
1100        },
1101    })
1102}
1103
1104fn local_branch(repository: &Path) -> Result<String> {
1105    let output = Command::new("git")
1106        .args(["symbolic-ref", "--quiet", "--short", "HEAD"])
1107        .current_dir(repository)
1108        .output()
1109        .with_context(|| format!("read current branch in {}", repository.display()))?;
1110    if !output.status.success() {
1111        bail!(
1112            "local repository {} must have a branch checked out before Hel can expose it as origin",
1113            repository.display()
1114        );
1115    }
1116    let branch = String::from_utf8(output.stdout).context("decode local Git branch")?;
1117    let branch = branch.trim().to_owned();
1118    if branch.is_empty() {
1119        bail!("local repository has an empty current branch");
1120    }
1121    Ok(branch)
1122}
1123
1124fn local_origin_url(worker_root: &str, repository_id: &str) -> String {
1125    fn ext_argument(value: &str) -> String {
1126        value.replace('%', "%%").replace(' ', "% ")
1127    }
1128    format!(
1129        "ext::{}/hel worker git-proxy --root {} --repository {} %S",
1130        ext_argument(worker_root),
1131        ext_argument(worker_root),
1132        repository_id,
1133    )
1134}
1135
1136fn seed_sources<'a>(
1137    missing: &[(&'a hel::hel_config::ProjectRepository, &'a PathBuf)],
1138    bootstrap: &'a LocalBootstrap,
1139) -> Option<Vec<(&'a hel::hel_config::ProjectRepository, &'a PathBuf)>> {
1140    let checkout = match bootstrap {
1141        LocalBootstrap::Skip => return None,
1142        LocalBootstrap::Seed => None,
1143        LocalBootstrap::SeedFrom(checkout) => Some(checkout),
1144    };
1145    Some(
1146        missing
1147            .iter()
1148            .map(|(repository, source)| (*repository, checkout.unwrap_or(source)))
1149            .collect(),
1150    )
1151}
1152
1153/// Carry a local repository's identity and uncommitted changes into a freshly
1154/// initialized target checkout. Committed history is never bundled here: the
1155/// caller fetches it through the `hel-local` proxy first.
1156fn bootstrap_local_repositories(
1157    executor: &impl CommandExecutor,
1158    locator: &hel_targets::TargetLocator,
1159    session: &SessionRecord,
1160    bundle: &ProjectBundle,
1161    workspace_root: &str,
1162    worker_root: &str,
1163    repositories: &[(&hel::hel_config::ProjectRepository, &PathBuf)],
1164) -> Result<()> {
1165    let snapshots = repositories
1166        .iter()
1167        .map(|(repository, source)| {
1168            collect_git_snapshot(
1169                &SystemGit,
1170                source,
1171                &GitCollectionSpec {
1172                    id: repository.id.clone(),
1173                    relative_destination: repository.destination.clone(),
1174                    history: GitHistoryMode::NoBundle,
1175                    origin_override: Some(format!("mj-local:{}", repository.id)),
1176                },
1177            )
1178            .with_context(|| format!("snapshot local repository {:?}", repository.id))
1179        })
1180        .collect::<Result<Vec<_>>>()?;
1181    let staging = data_dir().join("git-seeds");
1182    std::fs::create_dir_all(&staging)?;
1183    let archive_path = staging.join(format!("{}.hel.zip", session.id));
1184    write_archive_atomic(
1185        &archive_path,
1186        &ArchiveInput {
1187            session: SessionManifest {
1188                id: session.id.clone(),
1189                title: session.title.clone(),
1190                harness_kind: session.harness_kind,
1191                profile_id: session.last_profile.clone(),
1192                native_session_id: session.native_session_id.clone().unwrap_or_default(),
1193                created_at: session.created_at.clone(),
1194                checkpointed_at: now(),
1195                hel_version: env!("CARGO_PKG_VERSION").into(),
1196                relay_version: env!("CARGO_PKG_VERSION").into(),
1197                adapter_version: "acp-v1".into(),
1198            },
1199            target: TargetManifest {
1200                template_id: session.target_template_id.clone(),
1201                target_kind: target_kind(locator).into(),
1202                details: Default::default(),
1203            },
1204            bundle: BundleManifest {
1205                id: session.bundle_id.clone(),
1206                primary_repository: bundle.primary_repo.clone(),
1207            },
1208            canonical_session: canonical_session_from_materialized(
1209                &hel::hel_state::MaterializedSession::empty(session.id.clone()),
1210            )?,
1211            native_artifacts: Vec::new(),
1212            repositories: snapshots,
1213        },
1214    )?;
1215
1216    let remote_archive = format!("{worker_root}/local-seed.hel.zip");
1217    let remote_spec = format!("{worker_root}/local-seed.json");
1218    let target_path = |path: &str| match locator {
1219        hel_targets::TargetLocator::AwsEc2 { .. } | hel_targets::TargetLocator::SshBare { .. } => {
1220            PathBuf::from(format!("~/{path}"))
1221        }
1222        _ => PathBuf::from(path),
1223    };
1224    let spec = RepositoryRestoreSpec {
1225        archive_path: target_path(&remote_archive),
1226        workspace_root: target_path(workspace_root),
1227    };
1228    let local_spec = staging.join(format!("{}.json", session.id));
1229    hel::hel_config::atomic_write(&local_spec, &serde_json::to_vec_pretty(&spec)?)?;
1230    upload_checkpoint_spec(
1231        executor,
1232        locator,
1233        &session.id,
1234        &archive_path,
1235        &remote_archive,
1236    )?;
1237    upload_checkpoint_spec(executor, locator, &session.id, &local_spec, &remote_spec)?;
1238    execute_checked(
1239        executor,
1240        hel_targets::command_on_locator(
1241            locator,
1242            &session.id,
1243            vec![
1244                format!("{worker_root}/hel"),
1245                "worker".into(),
1246                "restore-repositories".into(),
1247                "--spec".into(),
1248                remote_spec,
1249            ],
1250            "restore local repository bootstrap",
1251        )?,
1252    )?;
1253    Ok(())
1254}
1255
1256/// The files one session's local Git broker is identified by.
1257#[derive(Debug, Clone)]
1258struct BrokerFiles {
1259    spec: PathBuf,
1260    ready: PathBuf,
1261    pid: PathBuf,
1262    log: PathBuf,
1263}
1264
1265impl BrokerFiles {
1266    fn in_directory(directory: &Path, session_id: &str) -> Self {
1267        Self {
1268            spec: directory.join(format!("{session_id}.json")),
1269            ready: directory.join(format!("{session_id}.ready")),
1270            pid: directory.join(format!("{session_id}.pid")),
1271            log: directory.join(format!("{session_id}.log")),
1272        }
1273    }
1274}
1275
1276/// How long a starting broker has to publish its ready marker.
1277const BROKER_READY_TIMEOUT: Duration = Duration::from_secs(10);
1278
1279/// Consecutive restarts a supervisor attempts before it reports the session's
1280/// local origin as unserved.
1281const BROKER_RESTART_ATTEMPTS: u32 = 5;
1282
1283/// Delay before the first restart; later attempts wait a multiple of it.
1284const BROKER_RESTART_BACKOFF: Duration = Duration::from_millis(250);
1285
1286/// A run at least this long counts as healthy, so its ending starts a fresh
1287/// restart budget instead of continuing a restart storm.
1288const BROKER_HEALTHY_RUN: Duration = Duration::from_secs(30);
1289
1290/// How long a retired broker has to exit after being asked, and again after
1291/// being killed, before the stop is reported as failed.
1292const BROKER_STOP_GRACE: Duration = Duration::from_secs(2);
1293
1294/// How often a stopping broker's lock is re-tested.
1295const BROKER_STOP_POLL: Duration = Duration::from_millis(25);
1296
1297fn broker_directory() -> PathBuf {
1298    data_dir().join("git-brokers")
1299}
1300
1301fn ensure_git_broker(
1302    session_id: &str,
1303    locator: &hel_targets::TargetLocator,
1304    repositories: BTreeMap<String, PathBuf>,
1305) -> Result<()> {
1306    let directory = broker_directory();
1307    std::fs::create_dir_all(&directory)?;
1308    let files = BrokerFiles::in_directory(&directory, session_id);
1309    let spec = GitBrokerSpec {
1310        session_id: session_id.to_owned(),
1311        bridge: hel_targets::git_bridge_command(locator, session_id)?,
1312        repositories,
1313        ready_path: files.ready.clone(),
1314        pid_path: files.pid.clone(),
1315    };
1316    if broker_is_alive(&files.pid) {
1317        if broker_serves(&files, &spec) {
1318            return Ok(());
1319        }
1320        bail!(
1321            "a different local Git broker is still active for session {session_id}; close its target before reconnecting"
1322        );
1323    }
1324    spec.write(&files.spec)?;
1325    let child = match start_git_broker(&files) {
1326        Ok(child) => child,
1327        // A supervisor may have restarted this session's broker from the same
1328        // spec while this one was starting. That broker serves the session,
1329        // and only one of them can hold the session's broker lock.
1330        Err(error) if broker_serves(&files, &spec) => {
1331            tracing::debug!(
1332                session_id,
1333                error = format!("{error:#}"),
1334                "reused a concurrently started local Git broker"
1335            );
1336            return Ok(());
1337        }
1338        Err(error) => return Err(error),
1339    };
1340    // A broker that dies later takes the session's `origin` remote with it,
1341    // so it is supervised rather than merely reaped.
1342    let session_id = session_id.to_owned();
1343    std::thread::spawn(move || supervise_git_broker(&session_id, &files, child));
1344    Ok(())
1345}
1346
1347/// Whether a broker is already serving exactly this session and spec.
1348fn broker_serves(files: &BrokerFiles, spec: &GitBrokerSpec) -> bool {
1349    broker_is_alive(&files.pid)
1350        && files.ready.exists()
1351        && GitBrokerSpec::read(&files.spec).is_ok_and(|existing| &existing == spec)
1352}
1353
1354/// Start the broker process and wait for it to publish its ready marker.
1355fn start_git_broker(files: &BrokerFiles) -> Result<std::process::Child> {
1356    // A broker killed outright leaves its marker behind, and the new one has
1357    // to publish its own before it counts as ready. A marker a live broker
1358    // owns is never touched.
1359    if !broker_is_alive(&files.pid)
1360        && let Err(error) = std::fs::remove_file(&files.ready)
1361        && error.kind() != std::io::ErrorKind::NotFound
1362    {
1363        tracing::warn!(
1364            path = %files.ready.display(),
1365            %error,
1366            "could not remove stale Git broker ready marker"
1367        );
1368    }
1369    let log = std::fs::OpenOptions::new()
1370        .create(true)
1371        .append(true)
1372        .open(&files.log)
1373        .with_context(|| format!("open Git broker log {}", files.log.display()))?;
1374    let stderr = log.try_clone()?;
1375    let executable = std::env::current_exe().context("locate Hel controller executable")?;
1376    let mut command = Command::new(executable);
1377    command
1378        .args(["broker", "--spec"])
1379        .arg(&files.spec)
1380        .stdin(Stdio::null())
1381        .stdout(log)
1382        .stderr(stderr);
1383    #[cfg(unix)]
1384    {
1385        use std::os::unix::process::CommandExt;
1386        command.process_group(0);
1387    }
1388    let mut child = command.spawn().context("start local Git broker")?;
1389    let deadline = Instant::now() + BROKER_READY_TIMEOUT;
1390    loop {
1391        if files.ready.exists() && broker_is_alive(&files.pid) {
1392            return Ok(child);
1393        }
1394        if let Some(status) = child.try_wait().context("poll local Git broker")? {
1395            bail!(
1396                "local Git broker exited with {status}; see {}",
1397                files.log.display()
1398            );
1399        }
1400        if Instant::now() >= deadline {
1401            // Leave no half-started broker behind holding the session slot.
1402            if let Err(error) = child.kill() {
1403                tracing::warn!(%error, "could not terminate timed-out Git broker");
1404            }
1405            if let Err(error) = child.wait() {
1406                tracing::warn!(%error, "could not reap timed-out Git broker");
1407            }
1408            bail!(
1409                "timed out starting local Git broker; see {}",
1410                files.log.display()
1411            );
1412        }
1413        std::thread::sleep(Duration::from_millis(50));
1414    }
1415}
1416
1417/// Stop this session's local Git broker for good and clear the state that
1418/// would invite any controller to start another one.
1419///
1420/// Closing, deleting, or abandoning a session all end its local origin: the
1421/// target the broker bridges into is about to disappear, so a broker left
1422/// running would be restarted against nothing and finally reported as a
1423/// failure the user never caused.
1424pub(super) fn retire_git_broker(session_id: &str) -> Result<()> {
1425    retire_broker_files(&BrokerFiles::in_directory(&broker_directory(), session_id))
1426}
1427
1428/// Retire one broker: signal the intent, stop the process, then remove the
1429/// files it left behind.
1430///
1431/// The spec is removed *first*, and that ordering is the whole design. Every
1432/// supervisor consults the spec before restarting, so its absence is how a
1433/// deliberate stop is told apart from a broker that died. Removing it after
1434/// the kill would race the supervisor into restarting a broker for a session
1435/// that is being torn down. The process is then stopped before its remaining
1436/// files go, so nothing is ever deleted under a live writer.
1437fn retire_broker_files(files: &BrokerFiles) -> Result<()> {
1438    remove_broker_file(&files.spec)?;
1439    stop_running_broker(&files.pid)?;
1440    remove_broker_file(&files.ready)?;
1441    remove_broker_file(&files.pid)?;
1442    // The log stays: it is where this session's Git failures were reported,
1443    // and reading it after the session ends is the point of keeping it.
1444    Ok(())
1445}
1446
1447/// Whether this session still wants a broker that has stopped to be started
1448/// again.
1449///
1450/// A restart always spawns from the spec on disk, so a rewritten one needs no
1451/// special handling; a retired session's spec is gone, and a broker another
1452/// controller already has running belongs to that controller.
1453fn broker_needs_restart(files: &BrokerFiles) -> bool {
1454    files.spec.exists() && !broker_is_alive(&files.pid)
1455}
1456
1457fn remove_broker_file(path: &Path) -> Result<()> {
1458    match std::fs::remove_file(path) {
1459        Ok(()) => Ok(()),
1460        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1461        Err(error) => {
1462            Err(error).with_context(|| format!("remove Git broker file {}", path.display()))
1463        }
1464    }
1465}
1466
1467/// Terminate whatever broker still holds this session's slot, and wait for it
1468/// to let go of the lock.
1469///
1470/// The PID is re-read on every pass: a restart that was already in flight
1471/// when the session was retired claims the slot a moment later, and it has to
1472/// be stopped too.
1473fn stop_running_broker(pid_path: &Path) -> Result<()> {
1474    for escalate in [false, true] {
1475        let deadline = Instant::now() + BROKER_STOP_GRACE;
1476        loop {
1477            if !broker_is_alive(pid_path) {
1478                return Ok(());
1479            }
1480            if let Some(pid) = running_broker_pid(pid_path) {
1481                stop_broker_process_group(pid, escalate);
1482            }
1483            if Instant::now() >= deadline {
1484                break;
1485            }
1486            std::thread::sleep(BROKER_STOP_POLL);
1487        }
1488    }
1489    bail!(
1490        "the local Git broker holding {} did not stop",
1491        pid_path.display()
1492    )
1493}
1494
1495/// Signal the process group a broker leads. Brokers are started as their own
1496/// group leader, so this stops the target-side bridge with the broker instead
1497/// of leaving it attached to a target that is going away.
1498#[cfg(unix)]
1499fn stop_broker_process_group(pid: i32, escalate: bool) {
1500    hel::hel_subprocess::terminate_process_group(
1501        pid,
1502        if escalate {
1503            libc::SIGKILL
1504        } else {
1505            libc::SIGTERM
1506        },
1507    );
1508}
1509
1510#[cfg(not(unix))]
1511fn stop_broker_process_group(_pid: i32, _escalate: bool) {}
1512
1513/// Keep this session's Git broker running until another controller takes it
1514/// over, until the session retires it, or until restarting it stops helping.
1515fn supervise_git_broker(session_id: &str, files: &BrokerFiles, child: std::process::Child) {
1516    let mut started = Some(child);
1517    let outcome = supervise_broker_restarts(
1518        BROKER_RESTART_ATTEMPTS,
1519        || broker_needs_restart(files),
1520        || {
1521            let running = Instant::now();
1522            let mut child = match started.take() {
1523                Some(child) => child,
1524                None => start_git_broker(files)?,
1525            };
1526            let status = child.wait().context("wait for the local Git broker")?;
1527            Ok((running.elapsed(), format!("{status}")))
1528        },
1529        std::thread::sleep,
1530    );
1531    let Err(error) = outcome else {
1532        return;
1533    };
1534    tracing::error!(
1535        session_id,
1536        error = format!("{error:#}"),
1537        "the session's local Git origin is no longer served"
1538    );
1539    // Every broker error the user sees names this log, so the last word on
1540    // the broker belongs in it too.
1541    use std::io::Write as _;
1542    if let Ok(mut log) = std::fs::OpenOptions::new()
1543        .create(true)
1544        .append(true)
1545        .open(&files.log)
1546    {
1547        let _ = writeln!(log, "[hel {}] local Git origin lost: {error:#}", now());
1548    }
1549}
1550
1551/// Restart the broker whenever it stops, until stopping is no longer this
1552/// supervisor's business or a run of restarts has failed to fix anything.
1553fn supervise_broker_restarts(
1554    attempts: u32,
1555    mut needs_restart: impl FnMut() -> bool,
1556    mut run: impl FnMut() -> Result<(Duration, String)>,
1557    mut back_off: impl FnMut(Duration),
1558) -> Result<()> {
1559    let mut consecutive = 0;
1560    loop {
1561        let failure = match run() {
1562            Ok((ran_for, status)) => {
1563                if ran_for >= BROKER_HEALTHY_RUN {
1564                    consecutive = 0;
1565                }
1566                anyhow::anyhow!("the local Git broker exited with {status}")
1567            }
1568            Err(error) => error,
1569        };
1570        if !needs_restart() {
1571            return Ok(());
1572        }
1573        consecutive += 1;
1574        if consecutive > attempts {
1575            return Err(failure.context(format!(
1576                "the local Git broker stopped {consecutive} times in a row and was not restarted again"
1577            )));
1578        }
1579        back_off(BROKER_RESTART_BACKOFF * consecutive);
1580    }
1581}
1582
1583/// Attach read-only whatever the selected container overlay cannot hold, and
1584/// say so.
1585///
1586/// The filesystem is probed on the host that runs the container, because that
1587/// is where the overlay would be built. A probe that cannot answer leaves the
1588/// overlay alone: a failed probe is no evidence of an unsupported filesystem,
1589/// and refusing to provision over one would cost the user their session.
1590///
1591/// Apple's `container` engine already mounts every extra directory read-only,
1592/// and EC2 copies the directory instead of mounting it, so neither is probed.
1593pub(super) fn enforce_overlay_capable_mounts(
1594    target: &hel_targets::TargetTemplate,
1595    mounts: &mut [hel_targets::AdditionalMount],
1596    executor: &impl CommandExecutor,
1597) -> Vec<String> {
1598    let ssh = match target {
1599        hel_targets::TargetTemplate::LocalPodman(_)
1600        | hel_targets::TargetTemplate::LocalDocker(_) => None,
1601        hel_targets::TargetTemplate::SshPodman { ssh, .. }
1602        | hel_targets::TargetTemplate::SshDocker { ssh, .. } => Some(ssh),
1603        _ => return Vec::new(),
1604    };
1605    let overlaid = mounts
1606        .iter()
1607        .filter(|mount| !mount.read_only)
1608        .map(|mount| mount.source.clone())
1609        .collect::<Vec<_>>();
1610    if overlaid.is_empty() {
1611        return Vec::new();
1612    }
1613    let filesystems = match hel_targets::probe_filesystem_types(ssh, &overlaid, executor) {
1614        Ok(filesystems) => filesystems,
1615        Err(error) => {
1616            tracing::warn!(
1617                error = format!("{error:#}"),
1618                "could not probe attached-directory filesystems; preserving overlay mounts"
1619            );
1620            return vec![format!(
1621                "Could not read the filesystem under the attached directories, so they keep the \
1622                 copy-on-write overlay: {error:#}"
1623            )];
1624        }
1625    };
1626    let mut notices = Vec::new();
1627    for (mount, filesystem) in mounts
1628        .iter_mut()
1629        .filter(|mount| !mount.read_only)
1630        .zip(filesystems)
1631    {
1632        let Some(reason) = hel_targets::overlay_unsupported_filesystem(&filesystem) else {
1633            continue;
1634        };
1635        mount.read_only = true;
1636        notices.push(format!(
1637            "Mounted {} read-only: the overlay is unreliable on {filesystem} ({reason}).",
1638            mount.source.display()
1639        ));
1640    }
1641    notices
1642}
1643
1644/// Reports every command an installer issues as one launch stage, so progress
1645/// stays accurate without threading the stage through each `CommandSpec`.
1646/// A command that already names a stage keeps it.
1647pub(super) struct StagedExecutor<'a, E: CommandExecutor> {
1648    inner: &'a E,
1649    stage: ProvisionStage,
1650    _guard: ProvisionStageGuard<'a, E>,
1651}
1652
1653impl<'a, E: CommandExecutor> StagedExecutor<'a, E> {
1654    pub(crate) fn new(inner: &'a E, stage: ProvisionStage) -> Self {
1655        Self {
1656            inner,
1657            stage,
1658            _guard: ProvisionStageGuard::new(inner, stage),
1659        }
1660    }
1661
1662    fn staged(&self, command: &CommandSpec) -> CommandSpec {
1663        if command.stage.is_some() {
1664            return command.clone();
1665        }
1666        command.clone().stage(self.stage)
1667    }
1668}
1669
1670impl<E: CommandExecutor> CommandExecutor for StagedExecutor<'_, E> {
1671    fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1672        self.inner.execute(&self.staged(command))
1673    }
1674
1675    fn cancellation_requested(&self) -> bool {
1676        self.inner.cancellation_requested()
1677    }
1678
1679    fn stage_started(&self, stage: ProvisionStage) {
1680        self.inner.stage_started(stage);
1681    }
1682
1683    fn stage_finished(&self, stage: ProvisionStage) {
1684        self.inner.stage_finished(stage);
1685    }
1686
1687    fn notify_notice(&self, notice: &str) {
1688        self.inner.notify_notice(notice);
1689    }
1690
1691    fn execute_with_stdin(
1692        &self,
1693        command: &CommandSpec,
1694        input: &mut (dyn std::io::Read + Send),
1695    ) -> Result<CommandOutput> {
1696        self.inner.execute_with_stdin(&self.staged(command), input)
1697    }
1698}
1699
1700fn execute_checked_with_stdin(
1701    executor: &impl CommandExecutor,
1702    command: &CommandSpec,
1703    input: &mut (dyn std::io::Read + Send),
1704) -> Result<CommandOutput> {
1705    let output = executor.execute_with_stdin(command, input)?;
1706    if output.status != 0 {
1707        bail!(
1708            "{} failed with status {}: {}",
1709            command.purpose,
1710            output.status,
1711            String::from_utf8_lossy(&output.stderr)
1712        );
1713    }
1714    Ok(output)
1715}
1716
1717pub(super) fn install_inherited_git_settings(
1718    executor: &impl CommandExecutor,
1719    locator: &hel_targets::TargetLocator,
1720    session_id: &str,
1721) -> Result<()> {
1722    let settings = if inherits_controller_git_settings(locator) {
1723        controller_git_settings()?
1724    } else {
1725        BTreeMap::new()
1726    };
1727    for command in inherited_git_setting_commands(locator, session_id, settings)? {
1728        execute_checked(executor, command)?;
1729    }
1730    Ok(())
1731}
1732
1733fn inherits_controller_git_settings(locator: &hel_targets::TargetLocator) -> bool {
1734    !matches!(
1735        locator,
1736        hel_targets::TargetLocator::LocalBare { .. } | hel_targets::TargetLocator::SshBare { .. }
1737    )
1738}
1739
1740fn inherited_git_setting_commands(
1741    locator: &hel_targets::TargetLocator,
1742    session_id: &str,
1743    settings: BTreeMap<String, String>,
1744) -> Result<Vec<CommandSpec>> {
1745    if matches!(locator, hel_targets::TargetLocator::SshBare { .. }) {
1746        return Ok(Vec::new());
1747    }
1748    settings
1749        .into_iter()
1750        .map(|(key, value)| {
1751            hel_targets::command_on_locator(
1752                locator,
1753                session_id,
1754                vec![
1755                    "git".into(),
1756                    "config".into(),
1757                    "--global".into(),
1758                    "--replace-all".into(),
1759                    "--".into(),
1760                    key.clone(),
1761                    value,
1762                ],
1763                format!("inherit Git setting {key}"),
1764            )
1765        })
1766        .collect()
1767}
1768
1769fn controller_git_settings() -> Result<BTreeMap<String, String>> {
1770    let output = match Command::new("git")
1771        .args(["config", "--global", "--includes", "--null", "--list"])
1772        .stdin(Stdio::null())
1773        .output()
1774    {
1775        Ok(output) => output,
1776        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
1777        Err(error) => return Err(error).context("read controller Git configuration"),
1778    };
1779    if !output.status.success() {
1780        bail!(
1781            "read controller Git configuration failed with status {}: {}",
1782            output.status,
1783            String::from_utf8_lossy(&output.stderr).trim()
1784        );
1785    }
1786    parse_inherited_git_settings(&output.stdout)
1787}
1788
1789fn parse_inherited_git_settings(output: &[u8]) -> Result<BTreeMap<String, String>> {
1790    let mut settings = BTreeMap::new();
1791    for entry in output
1792        .split(|byte| *byte == 0)
1793        .filter(|entry| !entry.is_empty())
1794    {
1795        let entry = std::str::from_utf8(entry).context("decode controller Git configuration")?;
1796        let (key, value) = entry
1797            .split_once('\n')
1798            .with_context(|| format!("controller Git returned malformed entry {entry:?}"))?;
1799        let key = key.to_ascii_lowercase();
1800        if INHERITED_GIT_SETTINGS.contains(&key.as_str()) {
1801            settings.insert(key, value.to_owned());
1802        }
1803    }
1804    Ok(settings)
1805}
1806
1807#[cfg(test)]
1808mod tests {
1809    use std::collections::BTreeMap;
1810
1811    use std::sync::Mutex;
1812
1813    use hel::hel_config::{
1814        ContainerTemplate as ConfigContainer, HarnessKind, HarnessProfile, HelConfig,
1815        ProjectRepository, SshConnection,
1816    };
1817    use hel::hel_state::{HelState, SessionRecord, SessionState, TargetLocator};
1818    use hel::hel_targets::{
1819        self, AdditionalMount, ContainerTemplate, ProjectBundleSpec, SshTarget,
1820    };
1821
1822    use crate::hel_controller::SessionLaunchOptions;
1823
1824    use super::*;
1825
1826    /// Answers the filesystem probe, and records every notice provisioning
1827    /// reported while it ran.
1828    struct ProbeExecutor {
1829        answer: std::result::Result<&'static str, &'static str>,
1830        notices: Mutex<Vec<String>>,
1831    }
1832
1833    impl ProbeExecutor {
1834        fn answering(answer: &'static str) -> Self {
1835            Self {
1836                answer: Ok(answer),
1837                notices: Mutex::new(Vec::new()),
1838            }
1839        }
1840
1841        fn failing(stderr: &'static str) -> Self {
1842            Self {
1843                answer: Err(stderr),
1844                notices: Mutex::new(Vec::new()),
1845            }
1846        }
1847    }
1848
1849    impl CommandExecutor for ProbeExecutor {
1850        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1851            assert_eq!(command.program, "stat", "only the probe may run here");
1852            Ok(match self.answer {
1853                Ok(filesystem) => CommandOutput {
1854                    status: 0,
1855                    stdout: format!("{filesystem}\n").into_bytes(),
1856                    stderr: Vec::new(),
1857                },
1858                Err(stderr) => CommandOutput {
1859                    status: 1,
1860                    stdout: Vec::new(),
1861                    stderr: stderr.as_bytes().to_vec(),
1862                },
1863            })
1864        }
1865
1866        fn notify_notice(&self, notice: &str) {
1867            self.notices.lock().unwrap().push(notice.to_owned());
1868        }
1869    }
1870
1871    fn podman_target() -> hel_targets::TargetTemplate {
1872        hel_targets::TargetTemplate::LocalPodman(ContainerTemplate {
1873            image: "ubuntu:24.04".into(),
1874            pull_policy: Default::default(),
1875            extra_run_args: Vec::new(),
1876            workspace_storage: Default::default(),
1877        })
1878    }
1879
1880    fn probe_bundle() -> ProjectBundleSpec {
1881        ProjectBundleSpec {
1882            primary: "app".into(),
1883            repositories: vec![hel::hel_targets::RepositorySpec {
1884                url: Some("https://github.com/example/app.git".into()),
1885                destination: "app".into(),
1886                git_ref: None,
1887                reference: None,
1888            }],
1889        }
1890    }
1891
1892    fn ssh_docker_registration_config() -> HelConfig {
1893        let mut config = HelConfig::default();
1894        config.profiles.insert(
1895            "codex".into(),
1896            HarnessProfile {
1897                kind: HarnessKind::Codex,
1898                home: PathBuf::from("/home/dev/.codex"),
1899                environment: BTreeMap::new(),
1900                context_window_bytes: None,
1901            },
1902        );
1903        config.bundles.insert(
1904            "project".into(),
1905            ProjectBundle {
1906                primary_repo: "project".into(),
1907                repositories: vec![ProjectRepository {
1908                    id: "project".into(),
1909                    github: Some("owner/project".into()),
1910                    local: None,
1911                    destination: PathBuf::from("project"),
1912                    git_ref: None,
1913                }],
1914            },
1915        );
1916        config.targets.insert(
1917            "docker".into(),
1918            TargetTemplate::SshDocker {
1919                ssh: SshConnection {
1920                    host: "builder".into(),
1921                    user: Some("agent".into()),
1922                    identity_file: None,
1923                    extra_args: Vec::new(),
1924                },
1925                container: ConfigContainer {
1926                    image: "failimage:never".into(),
1927                    pull_policy: Default::default(),
1928                    platform: None,
1929                    cpus: None,
1930                    memory: None,
1931                    environment: BTreeMap::new(),
1932                    workspace_storage: Default::default(),
1933                },
1934            },
1935        );
1936        config
1937    }
1938
1939    #[test]
1940    fn a_source_that_cannot_overlay_is_mounted_read_only_and_reported() {
1941        let executor = ProbeExecutor::answering("nfs");
1942        let mut mounts = vec![AdditionalMount {
1943            source: PathBuf::from("/nfs/share"),
1944            destination: PathBuf::from("/mnt/share"),
1945            read_only: false,
1946        }];
1947
1948        let notices = enforce_overlay_capable_mounts(&podman_target(), &mut mounts, &executor);
1949
1950        assert!(mounts[0].read_only);
1951        assert_eq!(notices.len(), 1);
1952        assert!(
1953            notices[0]
1954                .contains("Mounted /nfs/share read-only: the overlay is unreliable on nfs (network filesystem)"),
1955            "{notices:?}"
1956        );
1957        let plan = hel_targets::provision_plan(
1958            &podman_target(),
1959            "0123456789abcdef0123456789abcdef",
1960            &probe_bundle(),
1961            &mounts,
1962        )
1963        .unwrap();
1964        assert!(
1965            plan.commands[0]
1966                .args
1967                .windows(2)
1968                .any(|args| args == ["--volume", "/nfs/share:/mnt/share:ro"]),
1969            "{:?}",
1970            plan.commands[0].args
1971        );
1972    }
1973
1974    #[test]
1975    fn a_probe_that_cannot_answer_keeps_the_overlay_and_says_so() {
1976        let executor = ProbeExecutor::failing("stat: cannot read file system information");
1977        let mut mounts = vec![AdditionalMount {
1978            source: PathBuf::from("/host/cache"),
1979            destination: PathBuf::from("/mnt/cache"),
1980            read_only: false,
1981        }];
1982
1983        let notices = enforce_overlay_capable_mounts(&podman_target(), &mut mounts, &executor);
1984
1985        assert!(!mounts[0].read_only);
1986        assert_eq!(notices.len(), 1);
1987        assert!(
1988            notices[0].contains("keep the copy-on-write overlay")
1989                && notices[0].contains("cannot read file system information"),
1990            "{notices:?}"
1991        );
1992        let plan = hel_targets::provision_plan(
1993            &podman_target(),
1994            "0123456789abcdef0123456789abcdef",
1995            &probe_bundle(),
1996            &mounts,
1997        )
1998        .unwrap();
1999        assert!(
2000            plan.commands[0]
2001                .args
2002                .windows(2)
2003                .any(|args| args == ["--volume", "/host/cache:/mnt/cache:O"]),
2004            "{:?}",
2005            plan.commands[0].args
2006        );
2007    }
2008
2009    #[test]
2010    fn engines_without_an_overlay_to_lose_are_never_probed() {
2011        struct UnusedExecutor;
2012
2013        impl CommandExecutor for UnusedExecutor {
2014            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2015                panic!("this target must not probe: {}", command.program)
2016            }
2017        }
2018
2019        let mut mounts = vec![AdditionalMount {
2020            source: PathBuf::from("/host/cache"),
2021            destination: PathBuf::from("/mnt/cache"),
2022            read_only: false,
2023        }];
2024        for target in [
2025            hel_targets::TargetTemplate::AppleContainer(ContainerTemplate {
2026                image: "ubuntu:24.04".into(),
2027                pull_policy: Default::default(),
2028                extra_run_args: Vec::new(),
2029                workspace_storage: Default::default(),
2030            }),
2031            hel_targets::TargetTemplate::AwsEc2(hel_targets::AwsTemplate {
2032                profile: "default".into(),
2033                region: "us-east-1".into(),
2034                launch_template: "lt-0123456789abcdef0".into(),
2035                launch_template_version: None,
2036                instance_type: None,
2037                ssh: SshTarget {
2038                    destination: "ubuntu@example.test".into(),
2039                    ssh_args: Vec::new(),
2040                },
2041            }),
2042        ] {
2043            assert!(
2044                enforce_overlay_capable_mounts(&target, &mut mounts, &UnusedExecutor).is_empty()
2045            );
2046            assert!(!mounts[0].read_only);
2047        }
2048    }
2049
2050    /// A mount the user already marked read-only has no overlay to protect, so
2051    /// the probe never has to reach a host that may not answer.
2052    #[test]
2053    fn mounts_already_read_only_are_not_probed() {
2054        struct UnusedExecutor;
2055
2056        impl CommandExecutor for UnusedExecutor {
2057            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2058                panic!("a read-only mount must not probe: {}", command.program)
2059            }
2060        }
2061
2062        let mut mounts = vec![AdditionalMount {
2063            source: PathBuf::from("/host/cache"),
2064            destination: PathBuf::from("/mnt/cache"),
2065            read_only: true,
2066        }];
2067
2068        assert!(
2069            enforce_overlay_capable_mounts(&podman_target(), &mut mounts, &UnusedExecutor)
2070                .is_empty()
2071        );
2072    }
2073
2074    #[test]
2075    fn failed_new_session_provisioning_discards_provisional_record() {
2076        let session_id = "0123456789abcdef0123456789abcdef";
2077        let record = SessionRecord {
2078            workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
2079            archived: false,
2080            container_cpus: None,
2081            container_memory: None,
2082            id: session_id.into(),
2083            title: "new session".into(),
2084            harness_kind: hel::hel_config::HarnessKind::Codex,
2085            last_profile: "codex".into(),
2086            bundle_id: "project".into(),
2087            project_directory: None,
2088            managed_worktree: None,
2089            target_template_id: "podman".into(),
2090            resource_allocation: None,
2091            additional_mounts: Vec::new(),
2092            state: SessionState::Provisioning,
2093            target: None,
2094            native_session_id: None,
2095            acp_session_title: None,
2096            session_title_override: None,
2097            created_at: "2026-08-12T00:00:00Z".into(),
2098            updated_at: "2026-08-12T00:00:00Z".into(),
2099            viewed_through_event_ordinal: 0,
2100            draft_input: String::new(),
2101            last_error: None,
2102            last_checkpoint_error: None,
2103            checkpoint: None,
2104        };
2105        let mut state = HelState::default();
2106        state.sessions.insert(session_id.into(), record);
2107
2108        let result = apply_new_session_provisioning_result(
2109            &mut state,
2110            session_id,
2111            Err(anyhow::anyhow!("container creation failed")),
2112        );
2113
2114        assert!(result.is_err());
2115        assert!(!state.sessions.contains_key(session_id));
2116    }
2117
2118    const SSH_DOCKER_FAILURE_CHILD: &str = "MJ_TEST_SSH_DOCKER_FAILURE_CHILD";
2119
2120    #[test]
2121    fn failed_ssh_docker_preflight_removes_durable_provisioning_record() {
2122        if std::env::var_os(SSH_DOCKER_FAILURE_CHILD).is_none() {
2123            let directory = tempfile::tempdir().unwrap();
2124            let test = "failed_ssh_docker_preflight_removes_durable_provisioning_record";
2125            let mut command = std::process::Command::new(std::env::current_exe().unwrap());
2126            command
2127                .args([
2128                    "--exact",
2129                    &format!("hel_controller::provisioning::tests::{test}"),
2130                    "--nocapture",
2131                ])
2132                .env(SSH_DOCKER_FAILURE_CHILD, "1")
2133                .env("MJ_DATA_DIR", directory.path())
2134                .env("MJ_CONFIG_DIR", directory.path());
2135            let output = hel::hel_subprocess::run_with_input(&mut command, &[]).unwrap();
2136            assert!(
2137                output.status.success(),
2138                "isolated {test} failed\nstdout:\n{}\nstderr:\n{}",
2139                String::from_utf8_lossy(&output.stdout),
2140                String::from_utf8_lossy(&output.stderr)
2141            );
2142            return;
2143        }
2144
2145        let _writer = hel::hel_database::install_isolated_test_writer();
2146        let config = ssh_docker_registration_config();
2147        config.save().unwrap();
2148        let mut controller = Controller {
2149            config,
2150            state: HelState::default(),
2151        };
2152        let session_id = controller
2153            .register_session_with_resources(
2154                "codex",
2155                "project",
2156                "docker",
2157                "failed image",
2158                SessionLaunchOptions {
2159                    workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
2160                    additional_mounts: Vec::new(),
2161                    allow_dirty_local: false,
2162                    resource_allocation: None,
2163                    project_directory: None,
2164                    session_title_override: None,
2165                },
2166            )
2167            .unwrap();
2168        assert!(
2169            hel::hel_database::load_state()
2170                .unwrap()
2171                .sessions
2172                .contains_key(&session_id)
2173        );
2174
2175        let executor = RecordingExecutor::failing("check Docker daemon");
2176        let error =
2177            futures::executor::block_on(controller.provision_session_with_failure_disposition(
2178                &session_id,
2179                &executor,
2180                None,
2181                ProvisioningFailureDisposition::Discard,
2182            ))
2183            .unwrap_err();
2184        let reported = format!("{error:#}");
2185        assert!(
2186            reported.contains("remote Docker preflight failed"),
2187            "{reported}"
2188        );
2189        assert!(
2190            executor.commands().iter().any(|argv| {
2191                let command = argv.join(" ");
2192                command.contains("'docker' 'version'")
2193            }),
2194            "the fake preflight did not run: {:?}",
2195            executor.commands()
2196        );
2197        assert!(!controller.state.sessions.contains_key(&session_id));
2198
2199        let reloaded = Controller::load().unwrap();
2200        assert!(
2201            !reloaded.state.sessions.contains_key(&session_id),
2202            "failed SSH Docker launch left a durable provisioning row"
2203        );
2204    }
2205
2206    #[test]
2207    fn failed_new_worker_start_discards_session_only_after_target_cleanup() {
2208        let session_id = "0123456789abcdef0123456789abcdef";
2209        let mut session = SessionRecord {
2210            workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
2211            archived: false,
2212            container_cpus: None,
2213            container_memory: None,
2214            id: session_id.into(),
2215            title: "new session".into(),
2216            harness_kind: hel::hel_config::HarnessKind::Kimi,
2217            last_profile: "kimi".into(),
2218            bundle_id: "raw-project".into(),
2219            project_directory: Some("/srv/project".into()),
2220            managed_worktree: None,
2221            target_template_id: "remote".into(),
2222            resource_allocation: None,
2223            additional_mounts: Vec::new(),
2224            state: SessionState::Disconnected,
2225            target: Some(TargetLocator::SshBare {
2226                host: "builder".into(),
2227                workspace: format!(".local/share/hel/workspaces/{session_id}").into(),
2228                worker_id: None,
2229            }),
2230            native_session_id: None,
2231            acp_session_title: None,
2232            session_title_override: None,
2233            created_at: "2026-08-12T00:00:00Z".into(),
2234            updated_at: "2026-08-12T00:00:00Z".into(),
2235            viewed_through_event_ordinal: 0,
2236            draft_input: String::new(),
2237            last_error: None,
2238            last_checkpoint_error: None,
2239            checkpoint: None,
2240        };
2241        let mut cleaned = HelState::default();
2242        cleaned.sessions.insert(session_id.into(), session.clone());
2243
2244        let failure =
2245            apply_failed_new_session_rollback(&mut cleaned, session_id, "ACP startup failed", None);
2246
2247        assert!(!cleaned.sessions.contains_key(session_id));
2248        assert!(
2249            failure
2250                .to_string()
2251                .contains("provisional session discarded")
2252        );
2253
2254        session.state = SessionState::Disconnected;
2255        let mut cleanup_failed = HelState::default();
2256        cleanup_failed.sessions.insert(session_id.into(), session);
2257        let failure = apply_failed_new_session_rollback(
2258            &mut cleanup_failed,
2259            session_id,
2260            "ACP startup failed",
2261            Some("ssh unavailable".into()),
2262        );
2263        let retained = cleanup_failed.sessions.get(session_id).unwrap();
2264        assert_eq!(retained.state, SessionState::Error);
2265        assert!(retained.target.is_some());
2266        assert!(failure.to_string().contains("cleanup"));
2267    }
2268    #[test]
2269    fn launch_failure_is_persisted_separately_from_session_state() {
2270        let directory = tempfile::tempdir().unwrap();
2271        let session_id = "0123456789abcdef0123456789abcdef";
2272        let detail = format!(
2273            "specific startup cause\n{}\nstderr tail survives",
2274            "x".repeat(MAX_LAUNCH_DIAGNOSTIC_BYTES)
2275        );
2276
2277        let path = persist_launch_failure_to(directory.path(), session_id, &detail).unwrap();
2278        let saved = std::fs::read_to_string(path).unwrap();
2279
2280        assert!(saved.contains("specific startup cause"));
2281        assert!(saved.contains("launch diagnostic truncated"));
2282        assert!(saved.contains("stderr tail survives"));
2283        #[cfg(unix)]
2284        {
2285            use std::os::unix::fs::PermissionsExt;
2286            assert_eq!(
2287                std::fs::metadata(directory.path())
2288                    .unwrap()
2289                    .permissions()
2290                    .mode()
2291                    & 0o777,
2292                0o700
2293            );
2294        }
2295    }
2296    #[test]
2297    fn inherited_git_settings_allow_only_portable_non_executable_values() {
2298        let settings = parse_inherited_git_settings(
2299                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",
2300            )
2301            .unwrap();
2302
2303        assert_eq!(
2304            settings,
2305            BTreeMap::from([
2306                ("pull.rebase".into(), "true".into()),
2307                ("user.email".into(), "agent@example.test".into()),
2308                ("user.name".into(), "Final User".into()),
2309            ])
2310        );
2311    }
2312    #[test]
2313    fn inherited_git_settings_reject_malformed_or_non_utf8_output() {
2314        assert!(parse_inherited_git_settings(b"user.name\0").is_err());
2315        assert!(parse_inherited_git_settings(b"user.name\n\xff\0").is_err());
2316    }
2317    #[test]
2318    fn inherited_git_settings_target_only_isolated_workers() {
2319        let ssh = SshTarget {
2320            destination: "worker@example.test".into(),
2321            ssh_args: vec!["-p".into(), "2222".into()],
2322        };
2323        let ephemeral = [
2324            hel_targets::TargetLocator::LocalPodman {
2325                container_id: "abcdef012345".into(),
2326                workspace_storage: Default::default(),
2327            },
2328            hel_targets::TargetLocator::AppleContainer {
2329                container_id: "abcdef012346".into(),
2330            },
2331            hel_targets::TargetLocator::AwsEc2 {
2332                profile: "default".into(),
2333                region: "us-east-1".into(),
2334                instance_id: "i-1234567890abcdef0".into(),
2335                ssh: ssh.clone(),
2336                workspace: ".local/share/hel/workspaces/018f9dd2-a3b4-7c8d-9000-123456789abc"
2337                    .into(),
2338            },
2339            hel_targets::TargetLocator::SshPodman {
2340                ssh: ssh.clone(),
2341                container_id: "abcdef012347".into(),
2342                workspace_storage: Default::default(),
2343            },
2344        ];
2345        for locator in &ephemeral {
2346            assert!(inherits_controller_git_settings(locator));
2347            let commands = inherited_git_setting_commands(
2348                locator,
2349                "018f9dd2-a3b4-7c8d-9000-123456789abc",
2350                BTreeMap::from([("user.name".into(), "- Agent O'Brien 日本語".into())]),
2351            )
2352            .unwrap();
2353            assert_eq!(commands.len(), 1);
2354            assert!(
2355                commands[0]
2356                    .args
2357                    .iter()
2358                    .any(|argument| argument.contains("user.name"))
2359            );
2360            assert!(
2361                commands[0]
2362                    .args
2363                    .iter()
2364                    .any(|argument| argument.contains("- Agent O'"))
2365            );
2366        }
2367
2368        let persistent = hel_targets::TargetLocator::SshBare {
2369            ssh,
2370            workspace: "/srv/hel/018f9dd2-a3b4-7c8d-9000-123456789abc".into(),
2371        };
2372        let local = hel_targets::TargetLocator::LocalBare {
2373            worker_root: "/var/lib/hel/workers/018f9dd2-a3b4-7c8d-9000-123456789abc".into(),
2374        };
2375        assert!(!inherits_controller_git_settings(&persistent));
2376        assert!(!inherits_controller_git_settings(&local));
2377        assert!(
2378            inherited_git_setting_commands(
2379                &persistent,
2380                "018f9dd2-a3b4-7c8d-9000-123456789abc",
2381                BTreeMap::from([("user.name".into(), "Agent".into())]),
2382            )
2383            .unwrap()
2384            .is_empty()
2385        );
2386    }
2387
2388    #[test]
2389    fn raw_ssh_targets_select_permissions_and_ssh_podman_is_unconstrained() {
2390        let ssh = hel::hel_config::SshConnection {
2391            host: "builder".into(),
2392            user: None,
2393            identity_file: None,
2394            extra_args: Vec::new(),
2395        };
2396        let guardian = TargetTemplate::SshBare {
2397            ssh: ssh.clone(),
2398            permissions: hel::hel_config::PermissionMode::Guardian,
2399            workspace_prefix: ".local/share/hel/workspaces".into(),
2400        };
2401        let podman = TargetTemplate::SshPodman {
2402            ssh: ssh.clone(),
2403            container: hel::hel_config::ContainerTemplate {
2404                image: "example.invalid/agent:latest".into(),
2405                pull_policy: Default::default(),
2406                platform: None,
2407                cpus: None,
2408                memory: None,
2409                environment: BTreeMap::new(),
2410                workspace_storage: Default::default(),
2411            },
2412        };
2413        let yolo = TargetTemplate::SshBare {
2414            ssh,
2415            permissions: hel::hel_config::PermissionMode::Yolo,
2416            workspace_prefix: ".local/share/hel/workspaces".into(),
2417        };
2418
2419        assert_eq!(
2420            TargetTemplate::LocalBare.execution_policy(),
2421            hel::hel_config::ExecutionPolicy::ConfiguredApprovals
2422        );
2423        assert_eq!(
2424            guardian.execution_policy(),
2425            hel::hel_config::ExecutionPolicy::ConfiguredApprovals
2426        );
2427        assert_eq!(
2428            podman.execution_policy(),
2429            hel::hel_config::ExecutionPolicy::Unconstrained
2430        );
2431        assert_eq!(
2432            yolo.execution_policy(),
2433            hel::hel_config::ExecutionPolicy::Unconstrained
2434        );
2435    }
2436    const PROVISIONED_SESSION: &str = "0123456789abcdef0123456789abcdef";
2437
2438    /// Records every command a plan runs, and fails the one whose purpose it
2439    /// was told to fail.
2440    struct RecordingExecutor {
2441        failing_purpose: String,
2442        commands: Mutex<Vec<Vec<String>>>,
2443    }
2444
2445    impl RecordingExecutor {
2446        fn failing(purpose: impl Into<String>) -> Self {
2447            Self {
2448                failing_purpose: purpose.into(),
2449                commands: Mutex::new(Vec::new()),
2450            }
2451        }
2452
2453        fn succeeding() -> Self {
2454            Self::failing(String::new())
2455        }
2456
2457        fn commands(&self) -> Vec<Vec<String>> {
2458            self.commands.lock().unwrap().clone()
2459        }
2460    }
2461
2462    impl CommandExecutor for RecordingExecutor {
2463        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2464            let mut argv = vec![command.program.clone()];
2465            argv.extend(command.args.clone());
2466            self.commands.lock().unwrap().push(argv);
2467            Ok(CommandOutput {
2468                status: i32::from(command.purpose == self.failing_purpose),
2469                stdout: Vec::new(),
2470                stderr: b"the step failed".to_vec(),
2471            })
2472        }
2473    }
2474
2475    fn container_targets() -> Vec<hel_targets::TargetTemplate> {
2476        let container = ContainerTemplate {
2477            image: "ubuntu:24.04".into(),
2478            pull_policy: Default::default(),
2479            extra_run_args: Vec::new(),
2480            workspace_storage: Default::default(),
2481        };
2482        vec![
2483            hel_targets::TargetTemplate::LocalPodman(container.clone()),
2484            hel_targets::TargetTemplate::AppleContainer(container.clone()),
2485            hel_targets::TargetTemplate::SshPodman {
2486                ssh: SshTarget {
2487                    destination: "dev@example.test".into(),
2488                    ssh_args: vec!["-o".into(), "BatchMode=yes".into()],
2489                },
2490                container,
2491            },
2492        ]
2493    }
2494
2495    #[test]
2496    fn a_failure_after_the_container_exists_removes_it_and_keeps_the_original_error() {
2497        let name = hel_targets::resource_name(PROVISIONED_SESSION).unwrap();
2498        for target in container_targets() {
2499            let plan =
2500                hel_targets::provision_plan(&target, PROVISIONED_SESSION, &probe_bundle(), &[])
2501                    .unwrap();
2502            let executor = RecordingExecutor::failing("clone app");
2503
2504            let error = provision_target(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2505                unreachable!("locator discovery must not run after a failed plan")
2506            })
2507            .unwrap_err();
2508
2509            let reported = format!("{error:#}");
2510            assert!(reported.contains("clone app failed"), "{reported}");
2511            assert!(reported.contains("cleanup succeeded"), "{reported}");
2512            // Remote commands reach the target posix-quoted.
2513            let removal = executor
2514                .commands()
2515                .into_iter()
2516                .map(|arguments| arguments.join(" ").replace('\'', ""))
2517                .find(|command| command.contains("rm --force") && command.contains(&name))
2518                .expect("cleanup removes the exact provisioned container");
2519            assert!(removal.contains("rm --force"), "{removal}");
2520            assert!(removal.contains(&name), "{removal}");
2521        }
2522    }
2523
2524    #[test]
2525    fn target_creation_returns_repository_setup_without_running_it() {
2526        let target = podman_target();
2527        let plan = hel_targets::provision_plan(&target, PROVISIONED_SESSION, &probe_bundle(), &[])
2528            .unwrap();
2529        let executor = RecordingExecutor::succeeding();
2530
2531        let (_, repositories) =
2532            provision_target_creation(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2533                Ok(TargetLocator::LocalPodman {
2534                    container_id: hel_targets::resource_name(PROVISIONED_SESSION)?,
2535                    workspace_storage: Default::default(),
2536                })
2537            })
2538            .unwrap();
2539
2540        assert_eq!(executor.commands().len(), 1, "only podman run may execute");
2541        assert!(
2542            repositories
2543                .commands
2544                .iter()
2545                .any(|command| command.purpose == "clone app")
2546        );
2547    }
2548
2549    #[test]
2550    fn a_target_whose_creation_failed_is_never_torn_down() {
2551        for target in container_targets() {
2552            let plan =
2553                hel_targets::provision_plan(&target, PROVISIONED_SESSION, &probe_bundle(), &[])
2554                    .unwrap();
2555            let creation = plan.split_at_target_creation().unwrap().0;
2556            let executor =
2557                RecordingExecutor::failing(creation.commands.last().unwrap().purpose.clone());
2558
2559            let error = provision_target(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2560                unreachable!("locator discovery must not run after a failed plan")
2561            })
2562            .unwrap_err();
2563
2564            let reported = format!("{error:#}");
2565            assert!(!reported.contains("cleanup"), "{reported}");
2566            assert!(
2567                !executor
2568                    .commands()
2569                    .iter()
2570                    .any(|argv| argv.join(" ").contains("rm --force")),
2571                "{:?}",
2572                executor.commands()
2573            );
2574        }
2575    }
2576
2577    #[test]
2578    fn a_target_whose_locator_cannot_be_discovered_is_removed_again() {
2579        let target = podman_target();
2580        let plan = hel_targets::provision_plan(&target, PROVISIONED_SESSION, &probe_bundle(), &[])
2581            .unwrap();
2582        let executor = RecordingExecutor::succeeding();
2583
2584        let error = provision_target(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2585            bail!("the container never reported an address")
2586        })
2587        .unwrap_err();
2588
2589        let reported = format!("{error:#}");
2590        assert!(reported.contains("never reported an address"), "{reported}");
2591        assert!(reported.contains("cleanup succeeded"), "{reported}");
2592        let removal = executor
2593            .commands()
2594            .into_iter()
2595            .map(|arguments| arguments.join(" "))
2596            .find(|command| command.contains("podman rm --force --ignore"))
2597            .expect("cleanup removes the provisioned Podman container");
2598        assert!(removal.contains("podman rm --force --ignore"), "{removal}");
2599    }
2600
2601    /// A raw project directory is the user's own: provisioning it creates
2602    /// nothing that a failure could leak.
2603    #[test]
2604    fn a_bare_project_failure_removes_nothing() {
2605        let target = hel_targets::TargetTemplate::LocalBare;
2606        let plan =
2607            hel_targets::provision_bare_project_plan(&target, PROVISIONED_SESSION, "/srv/project")
2608                .unwrap();
2609        let executor = RecordingExecutor::succeeding();
2610
2611        let error = provision_target(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2612            bail!("the worker root was unreadable")
2613        })
2614        .unwrap_err();
2615
2616        assert!(!format!("{error:#}").contains("cleanup"));
2617        assert!(executor.commands().is_empty());
2618    }
2619
2620    #[test]
2621    fn a_broker_that_keeps_stopping_is_restarted_a_bounded_number_of_times() {
2622        let mut runs = 0;
2623        let mut waits = Vec::new();
2624
2625        let error = supervise_broker_restarts(
2626            3,
2627            || true,
2628            || {
2629                runs += 1;
2630                Ok((Duration::from_millis(1), "signal: 9".into()))
2631            },
2632            |delay| waits.push(delay),
2633        )
2634        .unwrap_err();
2635
2636        // The first run, then one restart per attempt.
2637        assert_eq!(runs, 4);
2638        assert_eq!(waits.len(), 3);
2639        assert!(waits[0] < waits[2], "{waits:?}");
2640        assert!(
2641            format!("{error:#}").contains("stopped 4 times in a row"),
2642            "{error:#}"
2643        );
2644    }
2645
2646    #[test]
2647    fn a_broker_another_controller_took_over_is_left_alone() {
2648        let mut runs = 0;
2649
2650        supervise_broker_restarts(
2651            3,
2652            || false,
2653            || {
2654                runs += 1;
2655                Ok((Duration::from_millis(1), "exit status: 0".into()))
2656            },
2657            |_| unreachable!("a broker that needs no restart must not be waited on"),
2658        )
2659        .unwrap();
2660
2661        assert_eq!(runs, 1);
2662    }
2663
2664    /// A broker that served the session for a while before dying starts a
2665    /// fresh restart budget, so one bad hour never exhausts a session.
2666    #[test]
2667    fn a_broker_that_ran_healthily_earns_a_fresh_restart_budget() {
2668        let mut runs = 0;
2669
2670        let error = supervise_broker_restarts(
2671            2,
2672            || true,
2673            || {
2674                runs += 1;
2675                Ok(if runs <= 4 {
2676                    (BROKER_HEALTHY_RUN, "signal: 9".into())
2677                } else {
2678                    (Duration::from_millis(1), "signal: 9".into())
2679                })
2680            },
2681            |_| (),
2682        )
2683        .unwrap_err();
2684
2685        assert_eq!(runs, 6);
2686        assert!(format!("{error:#}").contains("stopped 3 times in a row"));
2687    }
2688
2689    /// Turns a re-executed copy of the stop test into a stand-in for a running
2690    /// broker. Holding the slot's lock is exactly what makes a process this
2691    /// session's broker, so a stand-in that holds it is indistinguishable from
2692    /// the real thing to everything that has to stop one.
2693    #[cfg(unix)]
2694    const BROKER_STAND_IN_PID_PATH: &str = "MJ_TEST_BROKER_STAND_IN_PID_PATH";
2695
2696    fn retirable_broker_files(directory: &Path) -> BrokerFiles {
2697        let files = BrokerFiles::in_directory(directory, PROVISIONED_SESSION);
2698        GitBrokerSpec {
2699            session_id: PROVISIONED_SESSION.into(),
2700            bridge: CommandSpec::new("true", Vec::<String>::new()),
2701            repositories: BTreeMap::new(),
2702            ready_path: files.ready.clone(),
2703            pid_path: files.pid.clone(),
2704        }
2705        .write(&files.spec)
2706        .unwrap();
2707        std::fs::write(&files.ready, "ready\n").unwrap();
2708        std::fs::write(&files.log, "broker log\n").unwrap();
2709        files
2710    }
2711
2712    /// A closing session stops its broker on purpose: the process goes, the
2713    /// supervisor that was keeping it alive returns quietly, and the log keeps
2714    /// what it had without a word about a lost origin.
2715    #[cfg(unix)]
2716    #[test]
2717    fn retiring_a_session_stops_its_running_broker_and_reports_nothing() {
2718        if let Some(pid_path) = std::env::var_os(BROKER_STAND_IN_PID_PATH) {
2719            let _slot = hel::hel_git_proxy::claim_broker_pid_file(Path::new(&pid_path)).unwrap();
2720            // Retirement is what ends this process; the sleep only bounds the
2721            // damage when it fails to.
2722            std::thread::sleep(Duration::from_secs(60));
2723            return;
2724        }
2725
2726        let directory = tempfile::tempdir().unwrap();
2727        let files = retirable_broker_files(directory.path());
2728        let test_name = format!(
2729            "{}::retiring_a_session_stops_its_running_broker_and_reports_nothing",
2730            module_path!()
2731                .strip_prefix("mj_controller::")
2732                .unwrap_or(module_path!())
2733        );
2734        let mut command = Command::new(std::env::current_exe().unwrap());
2735        command
2736            .args(["--exact", &test_name, "--nocapture"])
2737            .env(BROKER_STAND_IN_PID_PATH, &files.pid)
2738            .stdin(Stdio::null())
2739            .stdout(Stdio::null())
2740            .stderr(Stdio::inherit());
2741        {
2742            use std::os::unix::process::CommandExt;
2743            // Brokers lead their own process group, and stopping one signals
2744            // that group.
2745            command.process_group(0);
2746        }
2747        let child = command.spawn().unwrap();
2748        let deadline = Instant::now() + Duration::from_secs(30);
2749        while !broker_is_alive(&files.pid) {
2750            assert!(
2751                Instant::now() < deadline,
2752                "the stand-in broker never claimed its slot"
2753            );
2754            std::thread::sleep(Duration::from_millis(10));
2755        }
2756
2757        let (finished, supervised) = std::sync::mpsc::channel();
2758        let supervisor = {
2759            let files = files.clone();
2760            std::thread::spawn(move || {
2761                supervise_git_broker(PROVISIONED_SESSION, &files, child);
2762                let _ = finished.send(());
2763            })
2764        };
2765
2766        retire_broker_files(&files).unwrap();
2767
2768        supervised
2769            .recv_timeout(Duration::from_secs(30))
2770            .expect("the retired broker's supervisor never finished");
2771        supervisor.join().unwrap();
2772        assert!(!broker_is_alive(&files.pid));
2773        assert!(!files.spec.exists());
2774        assert!(!files.pid.exists());
2775        assert!(!files.ready.exists());
2776        assert_eq!(std::fs::read_to_string(&files.log).unwrap(), "broker log\n");
2777    }
2778
2779    /// The same stop that ends a broker also ends its restarts: a broker that
2780    /// died is started again, a broker its session retired is not.
2781    #[test]
2782    fn a_retired_broker_is_never_restarted_where_a_dead_one_is() {
2783        let directory = tempfile::tempdir().unwrap();
2784        let files = retirable_broker_files(directory.path());
2785        // A PID file whose broker is gone: the death was unexpected.
2786        std::fs::write(&files.pid, "424242").unwrap();
2787        assert!(broker_needs_restart(&files));
2788
2789        retire_broker_files(&files).unwrap();
2790
2791        assert!(!broker_needs_restart(&files));
2792        let mut runs = 0;
2793        supervise_broker_restarts(
2794            BROKER_RESTART_ATTEMPTS,
2795            || broker_needs_restart(&files),
2796            || {
2797                runs += 1;
2798                Ok((Duration::from_millis(1), "signal: 15".into()))
2799            },
2800            |_| unreachable!("a retired broker must never be waited on for a restart"),
2801        )
2802        .unwrap();
2803
2804        // The broker that was already running, and not one restart after it.
2805        assert_eq!(runs, 1);
2806        assert!(!files.spec.exists());
2807        assert!(!files.pid.exists());
2808        assert!(!files.ready.exists());
2809        assert_eq!(std::fs::read_to_string(&files.log).unwrap(), "broker log\n");
2810    }
2811
2812    /// A session with no local repositories never had a broker, so retiring it
2813    /// touches nothing at all.
2814    #[test]
2815    fn retiring_a_session_that_never_had_a_broker_creates_nothing() {
2816        let directory = tempfile::tempdir().unwrap();
2817        let brokers = directory.path().join("git-brokers");
2818
2819        retire_broker_files(&BrokerFiles::in_directory(&brokers, PROVISIONED_SESSION)).unwrap();
2820
2821        assert!(!brokers.exists());
2822        assert!(
2823            std::fs::read_dir(directory.path())
2824                .unwrap()
2825                .next()
2826                .is_none()
2827        );
2828    }
2829
2830    #[test]
2831    fn a_converting_resume_seeds_from_its_own_checkout() {
2832        let repository = ProjectRepository {
2833            id: "project".into(),
2834            github: None,
2835            local: Some(PathBuf::from("/home/dev/project")),
2836            destination: PathBuf::from("project"),
2837            git_ref: None,
2838        };
2839        let configured = PathBuf::from("/home/dev/project");
2840        let missing = vec![(&repository, &configured)];
2841        let checkout = PathBuf::from("/home/dev/project/.mj/worktrees/session");
2842
2843        assert_eq!(seed_sources(&missing, &LocalBootstrap::Skip), None);
2844        assert_eq!(
2845            seed_sources(&missing, &LocalBootstrap::Seed)
2846                .unwrap()
2847                .into_iter()
2848                .map(|(_, source)| source.clone())
2849                .collect::<Vec<_>>(),
2850            vec![configured.clone()]
2851        );
2852        assert_eq!(
2853            seed_sources(&missing, &LocalBootstrap::SeedFrom(checkout.clone()))
2854                .unwrap()
2855                .into_iter()
2856                .map(|(_, source)| source.clone())
2857                .collect::<Vec<_>>(),
2858            vec![checkout]
2859        );
2860    }
2861}