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