Skip to main content

mj_controller/hel_controller/
resume.rs

1//! Resuming a stopped session onto a profile and target.
2
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5use std::time::Duration;
6
7use agent_client_protocol::schema::v1::ContentBlock;
8use anyhow::{Context, Result, bail, ensure};
9use rayon::prelude::*;
10use serde::{Deserialize, Serialize};
11
12use crate::hel_session_manager::new_command_id;
13use hel::hel_archive::{
14    CanonicalQueuedCommandKind, CanonicalSessionSnapshot, CheckpointRepositoryBundle, SystemGit,
15    checkpoint_bundle_prerequisites, read_checkpoint_repository_bundles, verify_archive_streaming,
16};
17use hel::hel_checkpoint::{CheckpointRestoreSpec, restore_command};
18use hel::hel_config::{HelConfig, ProjectRepository, mount_history_host};
19use hel::hel_projection::materialized_session_from_canonical;
20use hel::hel_state::{MaterializedSession, SessionRecord, SessionResourceAllocation, SessionState};
21use hel::hel_targets::{
22    self, AdditionalMount, CancellableProcessExecutor, CommandExecutor, CommandOutput, CommandSpec,
23    ProcessExecutor, ProvisionStage, ProvisionStageGuard,
24};
25use hel::hel_worker::RelayCommand;
26
27use super::backend::{backend_locator, controller_github_token, validate_resource_allocation};
28use super::checkpoint::upload_checkpoint_spec;
29use super::provisioning::{
30    LocalBootstrap, ProvisioningFailureDisposition, StagedExecutor, execute_concurrent_lanes,
31    install_attached_resources,
32};
33use super::readiness::{connect_started_worker, wait_for_native_session_in_stage};
34use super::worker_binary::{bridge_readiness_stage, start_worker, worker_probe_diagnosis};
35use super::worktree::{
36    PrimaryCheckoutRequirement, ResumeConversion, ResumePlan, apply_raw_to_workspace,
37    apply_workspace_to_raw, cleanup_managed_worktree, create_managed_worktree,
38    managed_worktree_checkout_exists, plan_raw_to_workspace, raw_checkout_divergence_notice,
39    raw_checkout_position, restore_managed_worktree, resume_compatibility, retire_managed_worktree,
40};
41use super::{
42    Controller, SessionResumeOptions, execute_checked, now, selected_host_container_size,
43    target_profile_home,
44};
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct ResumeRepositorySourceMismatch {
48    pub session_id: String,
49    pub bundle_id: String,
50    pub repository_id: String,
51    pub missing_commit: String,
52    pub archived_origin: String,
53    pub configured_origin: String,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct ResumeRepositorySourceReceipt {
59    session_id: String,
60    bundle_id: String,
61    checkpoint_sha256: String,
62    repositories: Vec<ProjectRepository>,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum ResumeRepositorySourcePreflight {
67    Ready(ResumeRepositorySourceReceipt),
68    RepositoryMoved(ResumeRepositorySourceMismatch),
69}
70
71struct ResumeRepositoryBundles {
72    checkpoint_sha256: String,
73    repositories: Vec<CheckpointRepositoryBundle>,
74}
75
76impl Controller {
77    /// Prove that each configured repository source still supplies the commit
78    /// boundary its checkpoint bundle expects, before provisioning anything.
79    pub fn preflight_resume_repository_sources(
80        &self,
81        session_id: &str,
82        target_id: &str,
83        executor: &(impl CommandExecutor + Sync),
84    ) -> Result<ResumeRepositorySourcePreflight> {
85        let session = self
86            .state
87            .sessions
88            .get(session_id)
89            .with_context(|| format!("unknown session {session_id}"))?;
90        let checkpoint = session
91            .checkpoint
92            .as_ref()
93            .context("session has no checkpoint")?;
94        let plan = resume_compatibility(session, &self.config, target_id)
95            .map_err(|reason| anyhow::anyhow!(reason))?;
96        if session.project_directory.is_some() {
97            debug_assert!(matches!(
98                plan,
99                ResumePlan::InPlace | ResumePlan::RawToWorkspace
100            ));
101            // A raw session resumes from its live checkout. Its synthetic
102            // bundle is only a grouping identity and may no longer be in the
103            // config; neither an in-place resume nor a raw-to-workspace
104            // conversion restores repository contents from that bundle.
105            return Ok(ResumeRepositorySourcePreflight::Ready(
106                ResumeRepositorySourceReceipt {
107                    session_id: session_id.to_owned(),
108                    bundle_id: session.bundle_id.clone(),
109                    checkpoint_sha256: checkpoint.sha256.clone(),
110                    repositories: Vec::new(),
111                },
112            ));
113        }
114        let repositories = read_checkpoint_repository_bundles(&checkpoint.archive_path)?;
115        self.preflight_verified_repository_sources(
116            session_id,
117            ResumeRepositoryBundles {
118                checkpoint_sha256: checkpoint.sha256.clone(),
119                repositories,
120            },
121            None,
122            executor,
123        )
124    }
125
126    fn preflight_verified_repository_sources(
127        &self,
128        session_id: &str,
129        verified: ResumeRepositoryBundles,
130        skip_repository_id: Option<&str>,
131        executor: &(impl CommandExecutor + Sync),
132    ) -> Result<ResumeRepositorySourcePreflight> {
133        let session = self
134            .state
135            .sessions
136            .get(session_id)
137            .with_context(|| format!("unknown session {session_id}"))?;
138        if verified.repositories.is_empty() {
139            return Ok(ResumeRepositorySourcePreflight::Ready(
140                ResumeRepositorySourceReceipt {
141                    session_id: session_id.to_owned(),
142                    bundle_id: session.bundle_id.clone(),
143                    checkpoint_sha256: verified.checkpoint_sha256,
144                    repositories: Vec::new(),
145                },
146            ));
147        }
148        let bundle = self
149            .config
150            .bundles
151            .get(&session.bundle_id)
152            .with_context(|| format!("session bundle {:?} is missing", session.bundle_id))?;
153        let configured = verified
154            .repositories
155            .iter()
156            .map(|archived| {
157                bundle
158                    .repositories
159                    .iter()
160                    .find(|repository| repository.id == archived.metadata.id)
161                    .cloned()
162                    .with_context(|| {
163                        format!(
164                            "session bundle {:?} no longer contains repository {:?}",
165                            session.bundle_id, archived.metadata.id
166                        )
167                    })
168            })
169            .collect::<Result<Vec<_>>>()?;
170        let github_token = configured
171            .iter()
172            .any(|repository| repository.github.is_some())
173            .then(controller_github_token)
174            .flatten();
175        let outcomes = verified
176            .repositories
177            .par_iter()
178            .zip(configured.par_iter())
179            .map(|(archived, configured)| {
180                if skip_repository_id == Some(configured.id.as_str()) {
181                    return Ok(None);
182                }
183                checkpoint_source_missing_commit(
184                    configured,
185                    archived,
186                    executor,
187                    github_token.as_deref(),
188                )
189                .map(|missing_commit| {
190                    missing_commit.map(|missing_commit| ResumeRepositorySourceMismatch {
191                        session_id: session_id.to_owned(),
192                        bundle_id: session.bundle_id.clone(),
193                        repository_id: configured.id.clone(),
194                        missing_commit,
195                        archived_origin: archived.metadata.origin.clone(),
196                        configured_origin: configured.source_label(),
197                    })
198                })
199            })
200            .collect::<Vec<Result<Option<ResumeRepositorySourceMismatch>>>>();
201        for outcome in outcomes {
202            if let Some(mismatch) = outcome? {
203                return Ok(ResumeRepositorySourcePreflight::RepositoryMoved(mismatch));
204            }
205        }
206        Ok(ResumeRepositorySourcePreflight::Ready(
207            ResumeRepositorySourceReceipt {
208                session_id: session_id.to_owned(),
209                bundle_id: session.bundle_id.clone(),
210                checkpoint_sha256: verified.checkpoint_sha256,
211                repositories: configured,
212            },
213        ))
214    }
215
216    fn repository_source_receipt_is_current(
217        &self,
218        session_id: &str,
219        receipt: &ResumeRepositorySourceReceipt,
220    ) -> bool {
221        let Some(session) = self.state.sessions.get(session_id) else {
222            return false;
223        };
224        if receipt.session_id != session_id
225            || receipt.bundle_id != session.bundle_id
226            || session
227                .checkpoint
228                .as_ref()
229                .map(|checkpoint| &checkpoint.sha256)
230                != Some(&receipt.checkpoint_sha256)
231        {
232            return false;
233        }
234        if receipt.repositories.is_empty() {
235            return true;
236        }
237        let Some(bundle) = self.config.bundles.get(&session.bundle_id) else {
238            return false;
239        };
240        receipt.repositories.iter().all(|expected| {
241            bundle
242                .repositories
243                .iter()
244                .any(|configured| configured == expected)
245        })
246    }
247
248    /// Validate a replacement first, then atomically save it and check the
249    /// remaining sources so multi-repository bundles can report the next moved
250    /// repository without ever provisioning a partial target.
251    pub fn replace_resume_repository_origin(
252        &mut self,
253        session_id: &str,
254        repository_id: &str,
255        replacement: &str,
256        executor: &(impl CommandExecutor + Sync),
257    ) -> Result<ResumeRepositorySourcePreflight> {
258        let session = self
259            .state
260            .sessions
261            .get(session_id)
262            .with_context(|| format!("unknown session {session_id}"))?;
263        let bundle_id = session.bundle_id.clone();
264        let checkpoint = session
265            .checkpoint
266            .as_ref()
267            .context("session has no checkpoint")?;
268        let replacement = replacement_repository_source(repository_id, replacement)?;
269        let repositories = read_checkpoint_repository_bundles(&checkpoint.archive_path)?;
270        let verified = ResumeRepositoryBundles {
271            checkpoint_sha256: checkpoint.sha256.clone(),
272            repositories,
273        };
274        let archived = verified
275            .repositories
276            .iter()
277            .find(|repository| repository.metadata.id == repository_id)
278            .with_context(|| format!("checkpoint does not contain repository {repository_id:?}"))?;
279        if let Some(missing_commit) = checkpoint_source_missing_commit(
280            &replacement,
281            archived,
282            executor,
283            controller_github_token().as_deref(),
284        )? {
285            return Ok(ResumeRepositorySourcePreflight::RepositoryMoved(
286                ResumeRepositorySourceMismatch {
287                    session_id: session_id.to_owned(),
288                    bundle_id,
289                    repository_id: repository_id.to_owned(),
290                    missing_commit,
291                    archived_origin: archived.metadata.origin.clone(),
292                    configured_origin: replacement.source_label(),
293                },
294            ));
295        }
296        let bundle = self
297            .config
298            .bundles
299            .get_mut(&bundle_id)
300            .with_context(|| format!("session bundle {bundle_id:?} is missing"))?;
301        let repository = bundle
302            .repositories
303            .iter_mut()
304            .find(|repository| repository.id == repository_id)
305            .with_context(|| {
306                format!(
307                    "session bundle {:?} no longer contains repository {repository_id:?}",
308                    bundle_id
309                )
310            })?;
311        repository.github = replacement.github;
312        repository.local = replacement.local;
313        self.config.save()?;
314        self.preflight_verified_repository_sources(
315            session_id,
316            verified,
317            Some(repository_id),
318            executor,
319        )
320    }
321}
322
323fn replacement_repository_source(id: &str, replacement: &str) -> Result<ProjectRepository> {
324    let replacement = replacement.trim();
325    ensure!(!replacement.is_empty(), "enter the repository's new origin");
326    let path = Path::new(replacement);
327    let (github, local) = if path.is_absolute() {
328        ensure!(
329            path.is_dir(),
330            "local repository {replacement:?} is not a directory"
331        );
332        (None, Some(hel::hel_local_git::canonical_repository(path)?))
333    } else {
334        let github = crate::hel_setup::github_repository_from_origin(replacement)
335            .context("origin must be a GitHub repository or an absolute local repository path")?;
336        (
337            Some(format!("{}/{}", github.owner, github.repository)),
338            None,
339        )
340    };
341    Ok(ProjectRepository {
342        id: id.to_owned(),
343        github,
344        local,
345        destination: PathBuf::from(id),
346        git_ref: None,
347    })
348}
349
350fn checkpoint_source_missing_commit(
351    configured: &ProjectRepository,
352    archived: &CheckpointRepositoryBundle,
353    executor: &impl CommandExecutor,
354    github_token: Option<&str>,
355) -> Result<Option<String>> {
356    let staging = tempfile::tempdir().context("create repository source preflight")?;
357    let repository = staging.path().join("repository.git");
358    checked_preflight_git(
359        executor,
360        CommandSpec::new(
361            "git",
362            [
363                "init".to_owned(),
364                "--bare".to_owned(),
365                "--quiet".to_owned(),
366                repository.to_string_lossy().into_owned(),
367            ],
368        )
369        .purpose("initialize repository source preflight"),
370    )?;
371    let missing = checkpoint_bundle_prerequisites(archived)?;
372    if missing.is_empty() {
373        let bundle = staging.path().join("checkpoint.bundle");
374        std::fs::write(&bundle, &archived.committed_bundle)
375            .context("write self-contained checkpoint bundle for source preflight")?;
376        checked_preflight_git(
377            executor,
378            checkpoint_bundle_import_command(&repository, &bundle),
379        )?;
380        return Ok(None);
381    }
382    // The restore clone will obtain the reachable ancestry. This probe only
383    // needs to establish that the source still serves each boundary object, so
384    // stop at that object instead of downloading and walking its whole graph.
385    for commit in missing {
386        let output = fetch_source_commit(executor, &repository, configured, &commit, github_token)?;
387        if output.status != 0 {
388            let stderr = String::from_utf8_lossy(&output.stderr);
389            if source_does_not_have_commit(&stderr) {
390                return Ok(Some(commit));
391            }
392            bail!(
393                "could not check configured source {:?}: {}",
394                configured.source_label(),
395                stderr.trim()
396            );
397        }
398    }
399    // Do not re-index the bundle against this deliberately shallow probe: the
400    // shallow marker would make Git report artificial connectivity failures.
401    // The real restore applies it to the full source clone.
402    Ok(None)
403}
404
405fn checkpoint_bundle_import_command(repository: &Path, bundle: &Path) -> CommandSpec {
406    let mut command = CommandSpec::new(
407        "git",
408        [
409            "-C".to_owned(),
410            repository.to_string_lossy().into_owned(),
411            "fetch".to_owned(),
412            "--no-tags".to_owned(),
413            bundle.to_string_lossy().into_owned(),
414            "HEAD".to_owned(),
415        ],
416    )
417    .purpose("validate self-contained checkpoint bundle");
418    command
419        .env
420        .insert("GIT_NO_LAZY_FETCH".to_owned(), "1".to_owned());
421    command
422        .env
423        .insert("GIT_TERMINAL_PROMPT".to_owned(), "0".to_owned());
424    command
425}
426
427fn fetch_source_commit(
428    executor: &impl CommandExecutor,
429    repository: &Path,
430    configured: &ProjectRepository,
431    commit: &str,
432    github_token: Option<&str>,
433) -> Result<CommandOutput> {
434    let mut arguments = Vec::new();
435    let mut token_auth = false;
436    let mut ssh_transport = false;
437    let source = if let Some(local) = &configured.local {
438        local.to_string_lossy().into_owned()
439    } else {
440        let source = configured
441            .github
442            .as_deref()
443            .context("repository source is missing")?;
444        let github = crate::hel_setup::github_repository_from_origin(source)
445            .context("configured repository is not a GitHub source")?;
446        if github_token.is_some() {
447            token_auth = true;
448            arguments.extend([
449                "-c".to_owned(),
450                "credential.helper=".to_owned(),
451                "-c".to_owned(),
452                "credential.helper=!f() { if [ \"$1\" = get ]; then echo username=x-access-token; echo \"password=$GH_TOKEN\"; fi; }; f".to_owned(),
453            ]);
454            format!(
455                "https://github.com/{}/{}.git",
456                github.owner, github.repository
457            )
458        } else {
459            ssh_transport = true;
460            format!("git@github.com:{}/{}.git", github.owner, github.repository)
461        }
462    };
463    arguments.extend([
464        "-C".to_owned(),
465        repository.to_string_lossy().into_owned(),
466        "fetch".to_owned(),
467        "--no-tags".to_owned(),
468        "--depth=1".to_owned(),
469        "--filter=blob:none".to_owned(),
470        source,
471        commit.to_owned(),
472    ]);
473    let mut command = CommandSpec::new("git", arguments).purpose("check checkpoint base commit");
474    command
475        .env
476        .insert("GIT_NO_LAZY_FETCH".to_owned(), "1".to_owned());
477    command
478        .env
479        .insert("GIT_TERMINAL_PROMPT".to_owned(), "0".to_owned());
480    if token_auth {
481        let token = github_token.expect("token authentication requires a GitHub token");
482        command.env.insert("GH_TOKEN".to_owned(), token.to_owned());
483    }
484    if ssh_transport {
485        command.env.insert(
486            "GIT_SSH_COMMAND".to_owned(),
487            "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15"
488                .to_owned(),
489        );
490    }
491    executor.execute(&command)
492}
493
494fn source_does_not_have_commit(stderr: &str) -> bool {
495    let stderr = stderr.to_ascii_lowercase();
496    [
497        "not our ref",
498        "couldn't find remote ref",
499        "not a valid object name",
500        "no such ref was fetched",
501    ]
502    .iter()
503    .any(|needle| stderr.contains(needle))
504}
505
506fn checked_preflight_git(
507    executor: &impl CommandExecutor,
508    command: CommandSpec,
509) -> Result<CommandOutput> {
510    let output = executor.execute(&command)?;
511    ensure!(
512        output.status == 0,
513        "{}: {}",
514        command.purpose,
515        String::from_utf8_lossy(&output.stderr).trim()
516    );
517    Ok(output)
518}
519
520impl Controller {
521    /// Resume a stopped logical session on any configured profile and
522    /// target. Cross-harness resume restores Git and canonical history, starts
523    /// a fresh native session, and supplies the prior transcript as its first
524    /// context turn.
525    pub async fn resume_session_with_options(
526        &mut self,
527        session_id: &str,
528        profile_id: &str,
529        target_id: &str,
530        additional_mounts: Option<Vec<AdditionalMount>>,
531        resource_allocation: Option<SessionResourceAllocation>,
532    ) -> Result<MaterializedSession> {
533        self.resume_session_with_options_and_queue_disposition(
534            session_id,
535            profile_id,
536            target_id,
537            additional_mounts,
538            resource_allocation,
539            false,
540        )
541        .await
542    }
543
544    pub async fn resume_session_with_options_and_queue_disposition(
545        &mut self,
546        session_id: &str,
547        profile_id: &str,
548        target_id: &str,
549        additional_mounts: Option<Vec<AdditionalMount>>,
550        resource_allocation: Option<SessionResourceAllocation>,
551        discard_queue: bool,
552    ) -> Result<MaterializedSession> {
553        self.resume_session_controlled(
554            session_id,
555            profile_id,
556            target_id,
557            SessionResumeOptions {
558                additional_mounts,
559                resource_allocation,
560                discard_queue,
561            },
562            &ProcessExecutor,
563        )
564        .await
565    }
566
567    pub async fn resume_session_controlled(
568        &mut self,
569        session_id: &str,
570        profile_id: &str,
571        target_id: &str,
572        options: SessionResumeOptions,
573        executor: &(impl CommandExecutor + Sync),
574    ) -> Result<MaterializedSession> {
575        self.resume_session_controlled_with_repository_preflight(
576            session_id, profile_id, target_id, options, None, executor,
577        )
578        .await
579    }
580
581    pub async fn resume_session_controlled_with_repository_preflight(
582        &mut self,
583        session_id: &str,
584        profile_id: &str,
585        target_id: &str,
586        options: SessionResumeOptions,
587        repository_preflight: Option<ResumeRepositorySourceReceipt>,
588        executor: &(impl CommandExecutor + Sync),
589    ) -> Result<MaterializedSession> {
590        let SessionResumeOptions {
591            additional_mounts,
592            resource_allocation,
593            discard_queue,
594        } = options;
595        let previous = self
596            .state
597            .sessions
598            .get(session_id)
599            .with_context(|| format!("unknown session {session_id}"))?
600            .clone();
601        if !matches!(
602            previous.state,
603            SessionState::Stopped | SessionState::Lost | SessionState::Error
604        ) {
605            bail!("session {session_id} is not stopped, lost, or retryable");
606        }
607        let checkpoint = previous
608            .checkpoint
609            .as_ref()
610            .context("session has no checkpoint")?;
611        if !repository_preflight
612            .as_ref()
613            .is_some_and(|receipt| self.repository_source_receipt_is_current(session_id, receipt))
614            && let ResumeRepositorySourcePreflight::RepositoryMoved(mismatch) =
615                self.preflight_resume_repository_sources(session_id, target_id, executor)?
616        {
617            bail!(
618                "checkpoint base commit {} is missing from configured source {:?} for repository {:?}; the repository may have moved (archived origin: {:?})",
619                mismatch.missing_commit,
620                mismatch.configured_origin,
621                mismatch.repository_id,
622                mismatch.archived_origin,
623            );
624        }
625        // Take the snapshot out of the verified metadata and share it behind an
626        // `Arc`: on a long session it is tens of megabytes, and resume reads it
627        // from three places that used to hold private copies.
628        let hel::hel_archive::VerifiedArchiveMetadata {
629            manifest: archive_manifest,
630            canonical_session,
631            archive_sha256,
632        } = verify_archive_streaming(&checkpoint.archive_path)?;
633        if archive_sha256 != checkpoint.sha256 || archive_manifest.session.id != session_id {
634            bail!("persisted checkpoint verification failed");
635        }
636        let canonical_session = Arc::new(canonical_session);
637        let profile = self
638            .config
639            .profiles
640            .get(profile_id)
641            .with_context(|| format!("unknown profile {profile_id:?}"))?
642            .clone();
643        let target_template = self
644            .config
645            .targets
646            .get(target_id)
647            .with_context(|| format!("unknown target template {target_id:?}"))?;
648        // Decide the representation before the record changes, so an
649        // incompatible target fails here instead of during provisioning.
650        let plan = resume_compatibility(&previous, &self.config, target_id)
651            .map_err(|reason| anyhow::anyhow!("{reason}"))?;
652        if plan == ResumePlan::InPlace
653            && previous.managed_worktree.is_none()
654            && let Some(project_directory) = &previous.project_directory
655        {
656            self.validate_project_directory(target_id, project_directory, executor)
657                .context("raw project is unavailable for resume")?;
658        }
659        let conversion = match plan {
660            ResumePlan::InPlace => None,
661            ResumePlan::RawToWorkspace => Some(ResumeConversion::RawToWorkspace(
662                plan_raw_to_workspace(&previous, &self.config, executor)
663                    .context("prepare the raw checkout for its new target")?,
664            )),
665            ResumePlan::WorkspaceToRaw => Some(ResumeConversion::WorkspaceToRaw(
666                self.plan_workspace_to_raw(&previous, target_id, executor)
667                    .context("prepare a checkout for this session")?,
668            )),
669        };
670        let resource_allocation =
671            resource_allocation.or_else(|| previous.resource_allocation.clone());
672        let additional_mounts =
673            additional_mounts.unwrap_or_else(|| previous.additional_mounts.clone());
674        validate_resource_allocation(target_template, resource_allocation.as_ref())?;
675        let selected_container_size =
676            selected_host_container_size(target_template, resource_allocation.as_ref());
677        if !additional_mounts.is_empty() && mount_history_host(target_template).is_none() {
678            bail!("attached resources are unsupported for this target");
679        }
680        hel_targets::validate_additional_mounts(&additional_mounts)?;
681        let history_host = mount_history_host(target_template);
682        let history_mounts = additional_mounts.clone();
683        if previous.state == SessionState::Error
684            && let Some(locator) = &previous.target
685        {
686            let backend = backend_locator(locator, &previous, &self.config)?;
687            hel_targets::close_plan(&backend, session_id)?
688                .execute(executor)
689                .context("clean up target from failed resume")?;
690        }
691        let mut resume_notices = Vec::new();
692        if let Some(conversion) = conversion
693            .as_ref()
694            .and_then(ResumeConversion::raw_to_workspace)
695            && let Some(project_directory) = &previous.project_directory
696        {
697            resume_notices.push(match &conversion.retire {
698                Some(worktree) => format!(
699                    "This session moved out of {} and into the {target_id} target. Its branch {} stays in {}.",
700                    project_directory.display(),
701                    worktree.branch,
702                    worktree.source_repository.display()
703                ),
704                None => format!(
705                    "This session moved out of {} and into the {target_id} target.",
706                    project_directory.display()
707                ),
708            });
709        }
710        if let Some(conversion) = conversion
711            .as_ref()
712            .and_then(ResumeConversion::workspace_to_raw)
713        {
714            resume_notices.push(format!(
715                "This session moved out of its {} target and into {}. Its branch {} is now {}.",
716                previous.target_template_id,
717                conversion.worktree.worktree_root.display(),
718                archive_manifest
719                    .repositories
720                    .first()
721                    .and_then(|repository| repository.metadata.branch.as_deref())
722                    .unwrap_or("a detached head"),
723                conversion.worktree.branch,
724            ));
725        }
726        let managed_checkout_present = previous
727            .managed_worktree
728            .as_ref()
729            .map(|worktree| managed_worktree_checkout_exists(executor, worktree))
730            .transpose()?
731            .unwrap_or(true);
732        // A checkout Mjolnir did not retire remains the truth for a raw session.
733        // A retired checkout is recreated from the branch and archive below.
734        if managed_checkout_present && let Some(project_directory) = &previous.project_directory {
735            match raw_checkout_position(&previous, &self.config, project_directory, executor) {
736                Ok(live) => resume_notices.extend(raw_checkout_divergence_notice(
737                    project_directory,
738                    archive_manifest
739                        .repositories
740                        .first()
741                        .map(|repository| &repository.metadata),
742                    &live,
743                )),
744                // Informational only: a resume must not fail because Mjolnir could
745                // not read where the checkout stands.
746                Err(error) => tracing::warn!(
747                    session_id,
748                    error = format!("{error:#}"),
749                    "could not read the raw checkout position for a resume notice"
750                ),
751            }
752        }
753        // Resolving the worker binary is local and costs microseconds, while
754        // the compaction below costs minutes and paid model requests. A resume
755        // that could never install a worker fails here rather than after all
756        // that work has been thrown away.
757        super::worker_binary::preflight_worker_binary(target_template)?;
758        let same_harness = profile.kind == archive_manifest.session.harness_kind;
759        let context_bytes = profile
760            .context_window_bytes
761            .unwrap_or(crate::hel_compaction::DEFAULT_CONTEXT_BYTES);
762        let utility_handoff = if same_harness {
763            None
764        } else {
765            let _compacting = ProvisionStageGuard::new(executor, ProvisionStage::Compacting);
766            Some(
767                utility_handoff_while_cancellable(
768                    &self.config,
769                    &canonical_session,
770                    context_bytes,
771                    executor,
772                )
773                .await
774                .context("compact the cross-harness handoff transcript")?,
775            )
776        };
777        let discard_queued_prompts = discard_queue || !same_harness;
778        // When this controller archived the session, its durable projection is
779        // already the archive's content. Reading one row decides that; a read
780        // failure or any mismatch rebuilds as before.
781        let stored_frontier = hel::hel_database::materialized_event_frontier(session_id)
782            .unwrap_or_else(|error| {
783                tracing::warn!(
784                    session_id,
785                    error = format!("{error:#}"),
786                    "could not read the stored projection frontier; rebuilding it from the archive"
787                );
788                None
789            });
790        let rebuild_projection = projection_rebuild_required(
791            stored_frontier
792                .as_ref()
793                .map(|(ordinal, digest)| (*ordinal, digest.as_str())),
794            canonical_session.event_frontier,
795            &canonical_session.event_frontier_digest,
796        );
797        // Rebuilding the projection is a pure function of the archive and costs
798        // seconds on a long session. Start it now so it runs while the target is
799        // being provisioned; its result is awaited where it was consumed
800        // before, and the writes it feeds have not moved.
801        //
802        // A resume that fails before the result is needed drops the handle.
803        // `spawn_blocking` work cannot be cancelled, so the computation still
804        // finishes on the blocking pool and its result is discarded; it owns
805        // nothing but its own inputs, so nothing leaks beyond that CPU.
806        let projection_build = rebuild_projection.then(|| {
807            let canonical = Arc::clone(&canonical_session);
808            let session_id = session_id.to_owned();
809            tokio::task::spawn_blocking(move || {
810                materialized_session_from_canonical(session_id, &canonical)
811            })
812        });
813        let github_token = controller_github_token();
814
815        // The configuration gains the bundle before the record points at it, so
816        // no persisted session ever names a bundle that is not there.
817        if let Some(conversion) = conversion
818            .as_ref()
819            .and_then(ResumeConversion::raw_to_workspace)
820            && let Some(bundle) = &conversion.new_bundle
821        {
822            self.config
823                .bundles
824                .insert(conversion.bundle_id.clone(), bundle.clone());
825            self.config
826                .save()
827                .context("save the bundle for a converted raw session")?;
828        }
829
830        let record = self.state.sessions.get_mut(session_id).unwrap();
831        record.harness_kind = profile.kind;
832        record.last_profile = profile_id.to_string();
833        record.target_template_id = target_id.to_string();
834        record.resource_allocation = resource_allocation;
835        record.additional_mounts = additional_mounts;
836        record.target = None;
837        record.native_session_id =
838            same_harness.then(|| archive_manifest.session.native_session_id.clone());
839        record.state = SessionState::Provisioning;
840        record.updated_at = now();
841        record.last_error = None;
842        match &conversion {
843            Some(ResumeConversion::RawToWorkspace(conversion)) => {
844                apply_raw_to_workspace(record, conversion);
845            }
846            Some(ResumeConversion::WorkspaceToRaw(conversion)) => {
847                apply_workspace_to_raw(record, conversion);
848            }
849            None => {}
850        }
851        let resumed_project_directory = record.project_directory.clone();
852        if let Some(host) = history_host {
853            self.state.remember_mount_sources(host, &history_mounts);
854            hel::hel_database::remember_mount_sources(host, &history_mounts)?;
855        }
856        // The session's prompt history is filed under its bundle, so a
857        // conversion moves the history with it before the record is persisted.
858        if let Some(conversion) = conversion
859            .as_ref()
860            .and_then(ResumeConversion::raw_to_workspace)
861        {
862            hel::hel_database::rebind_session_bundle(session_id, &conversion.bundle_id)?;
863        }
864        // Resume rewrites the record it resumes, including the attached
865        // directories and the harness session id, so it writes the whole row.
866        if let Some((host, size)) = selected_container_size.as_ref() {
867            hel::hel_database::save_session_with_container_size(
868                &self.state.sessions[session_id],
869                host,
870                *size,
871            )?;
872        } else {
873            hel::hel_database::save_session(&self.state.sessions[session_id])?;
874        }
875        if let Some((host, size)) = selected_container_size {
876            self.state.remember_container_size(&host, size);
877        }
878
879        let mut recreated_managed_worktree = false;
880        let result = async {
881            if let Some(worktree) = previous.managed_worktree.as_ref() {
882                recreated_managed_worktree = restore_managed_worktree(executor, worktree)?;
883                if recreated_managed_worktree && plan == ResumePlan::RawToWorkspace {
884                    hel::hel_checkpoint::restore_single_repository_onto_branch(
885                        &checkpoint.archive_path,
886                        &worktree.worktree_root,
887                        &worktree.branch,
888                        &SystemGit,
889                    )
890                    .context("restore the retired checkout before moving it into a target")?;
891                }
892            }
893            // The record already names the worktree, so a failure here rolls
894            // back through the same path that cleans up a new session's.
895            if let Some(conversion) = conversion
896                .as_ref()
897                .and_then(ResumeConversion::workspace_to_raw)
898            {
899                create_managed_worktree(
900                    executor,
901                    &conversion.worktree,
902                    None,
903                    PrimaryCheckoutRequirement::Any,
904                )?;
905                hel::hel_checkpoint::restore_single_repository_onto_branch(
906                    &checkpoint.archive_path,
907                    &conversion.worktree.worktree_root,
908                    &conversion.worktree.branch,
909                    &SystemGit,
910                )
911                .context("restore this session's checkout")?;
912            }
913            self.provision_session_with_failure_disposition(
914                session_id,
915                executor,
916                github_token.as_deref(),
917                ProvisioningFailureDisposition::Preserve,
918            )
919            .await?;
920            let (backend, worker_root) = self.worker_placement(session_id)?;
921            let harness_home = target_profile_home(&backend, session_id, &profile);
922            let workspace_root = if let Some(project_directory) = &resumed_project_directory {
923                project_directory
924                    .parent()
925                    .context("bare project directory has no parent")?
926                    .to_string_lossy()
927                    .into_owned()
928            } else {
929                match &backend {
930                    hel_targets::TargetLocator::LocalPodman { .. }
931                    | hel_targets::TargetLocator::LocalDocker { .. }
932                    | hel_targets::TargetLocator::AppleContainer { .. }
933                    | hel_targets::TargetLocator::SshPodman { .. } => "/workspace".to_string(),
934                    hel_targets::TargetLocator::AwsEc2 { workspace, .. }
935                    | hel_targets::TargetLocator::SshBare { workspace, .. } => workspace.clone(),
936                    hel_targets::TargetLocator::LocalBare { worker_root } => worker_root.clone(),
937                }
938            };
939            let target_path = |path: &str| match &backend {
940                hel_targets::TargetLocator::AwsEc2 { .. }
941                | hel_targets::TargetLocator::SshBare { .. }
942                    if !path.starts_with('/') =>
943                {
944                    PathBuf::from(format!("~/{path}"))
945                }
946                _ => PathBuf::from(path),
947            };
948            let remote_archive = format!("{worker_root}/restore.hel.zip");
949            let remote_spec = format!("{worker_root}/restore-spec.json");
950            let restore = CheckpointRestoreSpec {
951                archive_path: target_path(&remote_archive),
952                workspace_root: target_path(&workspace_root),
953                relay_root: target_path(&worker_root),
954                harness_home: target_path(&harness_home),
955                // A converted session's repository arrives as a seed from its
956                // own checkout. An in-place managed checkout recreated from
957                // its retained branch still needs the archive's dirty state.
958                restore_repositories: (resumed_project_directory.is_none() && conversion.is_none())
959                    || (recreated_managed_worktree && plan == ResumePlan::InPlace),
960                restore_native: same_harness,
961                // A conversion puts the checkout somewhere the archive could
962                // not have named, so the restored harness session is pointed at
963                // the real working directory instead of the archived one.
964                primary_repository_root: conversion
965                    .is_some()
966                    .then(|| resumed_project_directory.clone())
967                    .flatten()
968                    .map(|directory| target_path(&directory.to_string_lossy())),
969                discard_queued_prompts,
970            };
971            // A bare target keeps the closed session's worker root on the host.
972            // Stop anything still writing there and clear the leftover relay
973            // state, or the restore's seed loses to a stale snapshot whose
974            // frontier no journal can support. This runs before the worker
975            // binary is installed: a surviving daemon still holds the old one
976            // open, and the install would land on a running executable.
977            {
978                let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
979                if let Some(command) = hel_targets::clear_relay_state_plan(&backend, session_id)? {
980                    execute_checked(syncing, command)?;
981                }
982                // Both lanes below write into the worker root, so it exists first.
983                execute_checked(
984                    syncing,
985                    hel_targets::command_on_locator(
986                        &backend,
987                        session_id,
988                        vec!["mkdir".into(), "-p".into(), worker_root.clone()],
989                        "create the session worker root",
990                    )?,
991                )?;
992            }
993            let staging = tempfile::tempdir().context("create restore staging")?;
994            let local_spec = staging.path().join("restore-spec.json");
995            std::fs::write(&local_spec, serde_json::to_vec_pretty(&restore)?)?;
996            // Two independent lanes into the target. The checkpoint transfer
997            // needs nothing from the worker install, and the worker install
998            // and the local Git connection together are the longer of the two,
999            // so overlapping them hides the smaller one entirely.
1000            //
1001            // The Git connection stays behind the worker install in its own
1002            // lane: the target fetches through `ext::<worker root>/hel worker
1003            // git-proxy`, so the binary has to be there before a fetch runs.
1004            let controller = &*self;
1005            let backend_ref = &backend;
1006            let worker_root_ref = worker_root.as_str();
1007            let local_spec_ref = local_spec.as_path();
1008            execute_concurrent_lanes(
1009                || {
1010                    let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
1011                    controller.prepare_worker_files(
1012                        session_id,
1013                        backend_ref,
1014                        worker_root_ref,
1015                        syncing,
1016                    )?;
1017                    super::provisioning::install_inherited_git_settings(
1018                        syncing,
1019                        backend_ref,
1020                        session_id,
1021                    )?;
1022                    // The restore needs the fetched objects: a committed delta
1023                    // bundle cannot be applied without its prerequisites, and a
1024                    // bundle-free snapshot checks out a head commit only the
1025                    // proxy can supply. The archive carries this session's
1026                    // dirty state, so nothing is seeded.
1027                    controller.connect_local_repositories(
1028                        session_id,
1029                        backend_ref,
1030                        worker_root_ref,
1031                        syncing,
1032                        LocalBootstrap::Skip,
1033                    )
1034                },
1035                || {
1036                    let restoring = &StagedExecutor::new(executor, ProvisionStage::Restoring);
1037                    upload_checkpoint_spec(
1038                        restoring,
1039                        backend_ref,
1040                        session_id,
1041                        &checkpoint.archive_path,
1042                        &remote_archive,
1043                    )?;
1044                    upload_checkpoint_spec(
1045                        restoring,
1046                        backend_ref,
1047                        session_id,
1048                        local_spec_ref,
1049                        &remote_spec,
1050                    )
1051                },
1052            )?;
1053            {
1054                let restoring = &StagedExecutor::new(executor, ProvisionStage::Restoring);
1055                execute_checked(
1056                    restoring,
1057                    restore_command(&backend, session_id, &remote_spec)?,
1058                )?;
1059            }
1060            {
1061                let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
1062                install_attached_resources(
1063                    &self.state,
1064                    session_id,
1065                    &backend,
1066                    &worker_root,
1067                    syncing,
1068                )?;
1069                self.connect_local_repositories(
1070                    session_id,
1071                    &backend,
1072                    &worker_root,
1073                    syncing,
1074                    match conversion
1075                        .as_ref()
1076                        .and_then(ResumeConversion::raw_to_workspace)
1077                    {
1078                        Some(conversion) => LocalBootstrap::SeedFrom(conversion.checkout.clone()),
1079                        None => LocalBootstrap::Seed,
1080                    },
1081                )?;
1082            }
1083            match projection_build {
1084                Some(build) => {
1085                    let mut restored_projection = build
1086                        .await
1087                        .context("rebuild the restored projection")?
1088                        .context("rebuild the restored projection")?;
1089                    if discard_queued_prompts {
1090                        restored_projection.queued_prompts.clear();
1091                    }
1092                    hel::hel_database::save_materialized_session(&restored_projection)?;
1093                }
1094                // The stored projection already is the archived one. Only the
1095                // queue can still need changing.
1096                None if discard_queued_prompts => {
1097                    hel::hel_database::replace_materialized_queued_prompts(session_id, &[])?;
1098                }
1099                None => {}
1100            }
1101            let readiness_stage = bridge_readiness_stage(&profile);
1102            let spec = self.reconnect_command(session_id)?;
1103            let readiness = async {
1104                let mut relay = {
1105                    let _starting = ProvisionStageGuard::new(executor, ProvisionStage::Starting);
1106                    start_worker(executor, &backend, &worker_root)?;
1107                    connect_started_worker(&spec, session_id, executor, &backend, &worker_root)
1108                        .await?
1109                };
1110                let native_session_id =
1111                    wait_for_native_session_in_stage(&mut relay, executor, readiness_stage).await?;
1112                Ok::<_, anyhow::Error>((relay, native_session_id))
1113            }
1114            .await;
1115            let (mut relay, native_session_id) = readiness
1116                .map_err(|error| worker_probe_diagnosis(executor, &backend, &worker_root, error))?;
1117            if same_harness {
1118                if native_session_id != archive_manifest.session.native_session_id {
1119                    bail!(
1120                        "ACP loaded native session {native_session_id}, expected {}",
1121                        archive_manifest.session.native_session_id
1122                    );
1123                }
1124            } else {
1125                relay
1126                    .install_prompt_context(
1127                        utility_handoff
1128                            .clone()
1129                            .context("cross-harness resume has no utility-model handoff")?,
1130                    )
1131                    .await?;
1132                if !discard_queue {
1133                    for prompt in &canonical_session.queued_prompts {
1134                        // A queued configuration change is replayed as itself;
1135                        // rebuilding it as a prompt would send `/model x` to
1136                        // the agent as text.
1137                        let command = match &prompt.kind {
1138                            CanonicalQueuedCommandKind::Prompt => RelayCommand::Prompt {
1139                                prompt: prompt
1140                                    .content
1141                                    .iter()
1142                                    .cloned()
1143                                    .map(serde_json::from_value)
1144                                    .collect::<serde_json::Result<Vec<ContentBlock>>>()?,
1145                            },
1146                            CanonicalQueuedCommandKind::SetConfig { key, value } => {
1147                                RelayCommand::SetConfig {
1148                                    key: key.clone(),
1149                                    value: value.clone(),
1150                                }
1151                            }
1152                        };
1153                        relay.submit(prompt.command_id.clone(), command).await?;
1154                    }
1155                }
1156            }
1157            // Last, and only once the resume has otherwise succeeded: a failure
1158            // before this point rolls the record back to a session whose
1159            // worktree still has to be there.
1160            if let Some(worktree) = conversion
1161                .as_ref()
1162                .and_then(ResumeConversion::raw_to_workspace)
1163                .and_then(|plan| plan.retire.as_ref())
1164                && let Err(error) = retire_managed_worktree(executor, worktree)
1165            {
1166                tracing::warn!(
1167                    session_id,
1168                    worktree = %worktree.worktree_root.display(),
1169                    error = format!("{error:#}"),
1170                    "could not retire the old managed worktree after resume"
1171                );
1172                resume_notices.push(worktree_cleanup_notice(&worktree.worktree_root, &error));
1173            }
1174            for notice in &resume_notices {
1175                let submitted = async {
1176                    let command_id = new_command_id("resume-notice")?;
1177                    relay
1178                        .submit(
1179                            command_id,
1180                            RelayCommand::RecordNotice {
1181                                text: notice.clone(),
1182                            },
1183                        )
1184                        .await
1185                }
1186                .await;
1187                // The conversation line is a courtesy. A relay that refuses it
1188                // has not damaged the resume, so report and carry on.
1189                if let Err(error) = submitted {
1190                    tracing::warn!(
1191                        session_id,
1192                        error = format!("{error:#}"),
1193                        "could not record a resume notice in the conversation"
1194                    );
1195                }
1196            }
1197            self.mark_worker_connected(session_id, Some(native_session_id))?;
1198            Ok::<_, anyhow::Error>(relay.sync().await?.materialized)
1199        }
1200        .await;
1201        match result {
1202            Ok(materialized) => Ok(materialized),
1203            Err(error) => {
1204                // Put back whatever this resume could have written to the
1205                // durable projection. Both branches restore archived content,
1206                // so they are correct whether or not the write had happened
1207                // when the resume failed.
1208                if rebuild_projection {
1209                    match materialized_session_from_canonical(session_id, &canonical_session) {
1210                        Ok(previous_projection) => {
1211                            if let Err(restore_error) =
1212                                hel::hel_database::save_materialized_session(&previous_projection)
1213                            {
1214                                tracing::error!(
1215                                    session_id,
1216                                    error = format!("{restore_error:#}"),
1217                                    "could not restore the durable projection after resume failed"
1218                                );
1219                            }
1220                        }
1221                        Err(restore_error) => {
1222                            tracing::error!(
1223                                session_id,
1224                                error = format!("{restore_error:#}"),
1225                                "could not rebuild the durable projection after resume failed"
1226                            );
1227                        }
1228                    }
1229                } else if discard_queued_prompts
1230                    && let Err(restore_error) =
1231                        hel::hel_database::replace_materialized_queued_prompts(
1232                            session_id,
1233                            &hel::hel_projection::materialized_queued_prompts_from_canonical(
1234                                &canonical_session.queued_prompts,
1235                            ),
1236                        )
1237                {
1238                    tracing::error!(
1239                        session_id,
1240                        error = format!("{restore_error:#}"),
1241                        "could not restore queued prompts after resume failed"
1242                    );
1243                }
1244                Err(self.rollback_failed_resume(
1245                    session_id,
1246                    &previous,
1247                    recreated_managed_worktree,
1248                    error,
1249                    executor,
1250                )?)
1251            }
1252        }
1253    }
1254
1255    fn rollback_failed_resume(
1256        &mut self,
1257        session_id: &str,
1258        previous: &SessionRecord,
1259        recreated_managed_worktree: bool,
1260        error: anyhow::Error,
1261        _executor: &impl CommandExecutor,
1262    ) -> Result<anyhow::Error> {
1263        let current = self
1264            .state
1265            .sessions
1266            .get(session_id)
1267            .with_context(|| format!("unknown session {session_id}"))?
1268            .clone();
1269        let cleanup = match current.target.as_ref() {
1270            Some(locator) => (|| -> Result<()> {
1271                let backend = backend_locator(locator, &current, &self.config)?;
1272                hel_targets::close_plan(&backend, session_id)?
1273                    // Use a fresh executor: cancellation applies to the
1274                    // requested operation, not to its compensating cleanup.
1275                    .execute(&CancellableProcessExecutor::with_timeout(
1276                        Duration::from_secs(15),
1277                    ))
1278                    .map(|_| ())
1279            })(),
1280            None => Ok(()),
1281        };
1282        let worktree_cleanup = match (
1283            current.managed_worktree.as_ref(),
1284            previous.managed_worktree.as_ref(),
1285        ) {
1286            (_, Some(previous)) if recreated_managed_worktree => retire_managed_worktree(
1287                &CancellableProcessExecutor::with_timeout(Duration::from_secs(15)),
1288                previous,
1289            ),
1290            (Some(current), Some(previous)) if current == previous => Ok(()),
1291            (Some(worktree), _) => cleanup_managed_worktree(
1292                &CancellableProcessExecutor::with_timeout(Duration::from_secs(15)),
1293                worktree,
1294            ),
1295            (None, _) => Ok(()),
1296        };
1297        let cleanup_error = [cleanup, worktree_cleanup]
1298            .into_iter()
1299            .filter_map(Result::err)
1300            .map(|cleanup_error| format!("{cleanup_error:#}"))
1301            .collect::<Vec<_>>()
1302            .join("; ");
1303        if !cleanup_error.is_empty() {
1304            tracing::warn!(
1305                session_id,
1306                error = %cleanup_error,
1307                "resume rollback cleanup reported failures"
1308            );
1309        }
1310        let original = format!("{error:#}");
1311        let record = self.state.sessions.get_mut(session_id).unwrap();
1312        let failure = apply_failed_resume_rollback(
1313            record,
1314            previous,
1315            &original,
1316            (!cleanup_error.is_empty()).then_some(cleanup_error),
1317        );
1318        // A conversion filed the session's prompt history under its new bundle.
1319        // The record went back, so the history goes back with it.
1320        if record.bundle_id != current.bundle_id {
1321            let bundle_id = record.bundle_id.clone();
1322            hel::hel_database::rebind_session_bundle(session_id, &bundle_id)?;
1323        }
1324        // The rollback restores the record the resume replaced, attached
1325        // directories included, so it writes the whole row back.
1326        hel::hel_database::save_session(&self.state.sessions[session_id])?;
1327        Ok(failure)
1328    }
1329}
1330
1331fn worktree_cleanup_notice(worktree_root: &Path, error: &anyhow::Error) -> String {
1332    format!(
1333        "Mjolnir could not remove the worktree at {}: {error:#}. Remove it with `git worktree remove --force {}`.",
1334        worktree_root.display(),
1335        worktree_root.display()
1336    )
1337}
1338
1339pub(super) fn apply_failed_resume_rollback(
1340    current: &mut SessionRecord,
1341    previous: &SessionRecord,
1342    original_error: &str,
1343    cleanup_error: Option<String>,
1344) -> anyhow::Error {
1345    match cleanup_error {
1346        None => {
1347            *current = previous.clone();
1348            current.state = SessionState::Stopped;
1349            current.target = None;
1350            current.updated_at = now();
1351            current.last_error = Some(format!("resume failed: {original_error}"));
1352            anyhow::anyhow!(original_error.to_owned())
1353        }
1354        Some(cleanup_error) => {
1355            let failure = format!(
1356                "{original_error}; cleanup of the partial resume target failed: {cleanup_error}"
1357            );
1358            // The target locator stays so the leftover resource can still be
1359            // cleaned up, but the session's representation goes back: a resume
1360            // that converted the record never moved the checkout it names.
1361            current
1362                .project_directory
1363                .clone_from(&previous.project_directory);
1364            current
1365                .managed_worktree
1366                .clone_from(&previous.managed_worktree);
1367            current.bundle_id.clone_from(&previous.bundle_id);
1368            current.state = SessionState::Error;
1369            current.updated_at = now();
1370            current.last_error = Some(format!("resume failed: {failure}"));
1371            anyhow::anyhow!(failure)
1372        }
1373    }
1374}
1375
1376/// Whether a resume has to rebuild the durable projection from its archive.
1377///
1378/// The projection is a deterministic fold of the relay event chain, so a stored
1379/// projection standing at the archive's frontier *and* carrying the archive's
1380/// frontier digest already holds the archived content: same chain, same
1381/// ordinal, same result. Anything else - no stored row, a different ordinal, a
1382/// different digest, or a frontier that could not be read - rebuilds.
1383fn projection_rebuild_required(
1384    stored: Option<(u64, &str)>,
1385    archive_frontier: u64,
1386    archive_frontier_digest: &str,
1387) -> bool {
1388    stored != Some((archive_frontier, archive_frontier_digest))
1389}
1390
1391/// Discover a utility model and compact the cross-harness handoff while still
1392/// watching for cancellation. Discovery and compaction can both make several
1393/// network requests, so a cancelled resume must not wait them out.
1394async fn utility_handoff_while_cancellable(
1395    config: &HelConfig,
1396    snapshot: &CanonicalSessionSnapshot,
1397    context_bytes: usize,
1398    executor: &impl CommandExecutor,
1399) -> Result<String> {
1400    if executor.cancellation_requested() {
1401        bail!("operation cancelled while compacting the cross-harness handoff");
1402    }
1403    let cancel = tokio_util::sync::CancellationToken::new();
1404    let operation = async {
1405        let candidates = crate::hel_utility_llm::UtilityLlmRuntime::shared()
1406            .resolve(config, &cancel)
1407            .await?;
1408        let backend =
1409            crate::hel_utility_llm::UtilityCompactionBackend::new(candidates, cancel.clone());
1410        // Pages are sized by what the summarizer can read; the handoff is
1411        // sized by what the target harness accepts. They are unrelated
1412        // numbers, and using the target's for both is what made one incident
1413        // shard a transcript into 33 pages.
1414        let budget = crate::hel_compaction::CompactionBudget {
1415            page_bytes: backend.page_bytes(),
1416            handoff_bytes: context_bytes,
1417        };
1418        crate::hel_compaction::compact_snapshot(snapshot, budget, &backend).await
1419    };
1420    tokio::pin!(operation);
1421    loop {
1422        tokio::select! {
1423            context = &mut operation => return context,
1424            _ = tokio::time::sleep(super::readiness::CANCELLATION_POLL_INTERVAL) => {
1425                if executor.cancellation_requested() {
1426                    cancel.cancel();
1427                    bail!("operation cancelled while compacting the cross-harness handoff");
1428                }
1429            }
1430        }
1431    }
1432}
1433
1434#[cfg(test)]
1435mod tests {
1436    use std::cell::RefCell;
1437    use std::collections::BTreeMap;
1438    use std::path::{Path, PathBuf};
1439    use std::process::Command;
1440    use std::sync::{Barrier, Mutex};
1441
1442    use anyhow::Result;
1443
1444    use crate::hel_controller::test_support::{
1445        checkpoint_test_session, committed_repository, managed_worktree_session,
1446        resume_compatibility_config, write_checkpoint_gate_archive,
1447    };
1448    use crate::hel_controller::{Controller, SessionResumeOptions};
1449    use hel::hel_archive::{GitCommandRunner, verify_archive_streaming};
1450    use hel::hel_config::{
1451        ContainerTemplate as ConfigContainer, HarnessProfile, HelConfig, ProjectBundle,
1452        ProjectRepository, TargetTemplate,
1453    };
1454    use hel::hel_projection::materialized_session_from_canonical;
1455    use hel::hel_state::{HelState, SessionRecord, SessionState, TargetLocator};
1456    use hel::hel_targets::{CommandExecutor, CommandOutput, CommandSpec, ProcessExecutor};
1457
1458    use super::*;
1459
1460    const RESUME_ROLLBACK_TEST_CHILD: &str = "MJ_RESUME_ROLLBACK_TEST_CHILD";
1461    const RETIRED_WORKTREE_RESUME_TEST_CHILD: &str = "MJ_RETIRED_WORKTREE_RESUME_TEST_CHILD";
1462    const WORKER_PREFLIGHT_TEST_CHILD: &str = "MJ_WORKER_PREFLIGHT_TEST_CHILD";
1463
1464    /// Compaction costs minutes and paid model requests; resolving the worker
1465    /// binary is local and costs microseconds. A cross-harness resume that
1466    /// cannot produce a worker must say so before it compacts anything.
1467    #[test]
1468    fn a_resume_preflights_the_worker_binary_before_compacting() {
1469        // MJ_WORKER_BINARY, MJ_DATA_DIR, and MJ_CONFIG_DIR are process-global,
1470        // so run the half that sets them in an exact child test.
1471        if std::env::var_os(WORKER_PREFLIGHT_TEST_CHILD).is_none() {
1472            let directory = tempfile::tempdir().unwrap();
1473            let test_name = format!(
1474                "{}::a_resume_preflights_the_worker_binary_before_compacting",
1475                module_path!()
1476                    .strip_prefix("mj_controller::")
1477                    .unwrap_or(module_path!())
1478            );
1479            let output = Command::new(std::env::current_exe().unwrap())
1480                .args(["--exact", &test_name, "--nocapture"])
1481                .env(WORKER_PREFLIGHT_TEST_CHILD, "1")
1482                .env("MJ_DATA_DIR", directory.path().join("data"))
1483                .env("MJ_CONFIG_DIR", directory.path().join("config"))
1484                // Names a worker binary that is not there, which is how a
1485                // machine without an installed worker fails the same lookup.
1486                .env("MJ_WORKER_BINARY", directory.path().join("absent-worker"))
1487                .output()
1488                .unwrap();
1489            assert!(
1490                output.status.success(),
1491                "isolated worker preflight test failed\nstdout:\n{}\nstderr:\n{}",
1492                String::from_utf8_lossy(&output.stdout),
1493                String::from_utf8_lossy(&output.stderr)
1494            );
1495            return;
1496        }
1497        // Alone in this child process, so it installs the one writer.
1498        let _writer = hel::hel_database::install_isolated_test_writer();
1499
1500        let data_directory = PathBuf::from(std::env::var_os("MJ_DATA_DIR").unwrap());
1501        let archive_directory = data_directory.join("archives");
1502        std::fs::create_dir_all(&archive_directory).unwrap();
1503        let session_id = "0123456789abcdef0123456789abcdef";
1504        let checkpoint = write_checkpoint_gate_archive(&archive_directory, session_id, 7);
1505        let repository = committed_repository();
1506        let mut session = managed_worktree_session(repository.path(), session_id);
1507        session.checkpoint = Some(checkpoint);
1508
1509        let profile_home = data_directory.join("profile");
1510        std::fs::create_dir_all(&profile_home).unwrap();
1511        let mut config = resume_compatibility_config();
1512        // The archive was written by Codex, so resuming onto Claude is a
1513        // cross-harness resume and would compact the transcript.
1514        config.profiles.insert(
1515            "claude".into(),
1516            HarnessProfile {
1517                kind: hel::hel_config::HarnessKind::Claude,
1518                home: profile_home,
1519                executable: None,
1520                environment: BTreeMap::new(),
1521                context_window_bytes: None,
1522            },
1523        );
1524        let mut controller = Controller {
1525            config,
1526            state: HelState {
1527                sessions: BTreeMap::from([(session_id.into(), session)]),
1528                ..HelState::default()
1529            },
1530        };
1531        hel::hel_database::save_state(&controller.state).unwrap();
1532
1533        let error = tokio::runtime::Builder::new_current_thread()
1534            .enable_all()
1535            .build()
1536            .unwrap()
1537            .block_on(controller.resume_session_controlled(
1538                session_id,
1539                "claude",
1540                "local-bare",
1541                SessionResumeOptions {
1542                    additional_mounts: None,
1543                    resource_allocation: None,
1544                    discard_queue: false,
1545                },
1546                &ProcessExecutor,
1547            ))
1548            .unwrap_err();
1549
1550        let detail = format!("{error:#}");
1551        assert!(
1552            detail.contains("preflight the worker binary before resuming"),
1553            "{detail}"
1554        );
1555        assert!(detail.contains("absent-worker"), "{detail}");
1556        assert!(
1557            !detail.contains("compact the cross-harness handoff transcript"),
1558            "compaction must not run for a resume that cannot install a worker: {detail}"
1559        );
1560        assert_eq!(
1561            controller.state.sessions[session_id].state,
1562            SessionState::Stopped
1563        );
1564    }
1565
1566    #[test]
1567    fn raw_in_place_preflight_does_not_require_its_synthetic_bundle() {
1568        struct UnusedExecutor;
1569
1570        impl CommandExecutor for UnusedExecutor {
1571            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1572                panic!("raw in-place preflight ran {}", command.purpose);
1573            }
1574        }
1575
1576        let directory = tempfile::tempdir().unwrap();
1577        let session_id = "0123456789abcdef0123456789abcdef";
1578        let mut session = checkpoint_test_session(session_id);
1579        session.checkpoint = Some(write_checkpoint_gate_archive(
1580            directory.path(),
1581            session_id,
1582            3,
1583        ));
1584        session.bundle_id = "remote-project-a66373eef659f856".into();
1585        session.target_template_id = "localhost".into();
1586        session.project_directory = Some("/mnt/optane/bifrost-fird".into());
1587        let controller = Controller {
1588            config: HelConfig {
1589                targets: BTreeMap::from([("localhost".into(), TargetTemplate::LocalBare)]),
1590                // The raw checkout is still usable even though its synthetic
1591                // grouping bundle has disappeared from the config.
1592                bundles: BTreeMap::new(),
1593                ..HelConfig::default()
1594            },
1595            state: HelState {
1596                sessions: BTreeMap::from([(session_id.into(), session)]),
1597                ..HelState::default()
1598            },
1599        };
1600
1601        let preflight = controller
1602            .preflight_resume_repository_sources(session_id, "localhost", &UnusedExecutor)
1603            .unwrap();
1604        let ResumeRepositorySourcePreflight::Ready(receipt) = preflight else {
1605            panic!("raw in-place resume unexpectedly needs a repository replacement");
1606        };
1607        assert!(controller.repository_source_receipt_is_current(session_id, &receipt));
1608    }
1609
1610    #[test]
1611    fn repository_preflight_distinguishes_the_original_source_from_a_reused_name() {
1612        fn git(repository: &Path, arguments: &[&str]) {
1613            let output = SystemGit
1614                .run(
1615                    repository,
1616                    &hel::hel_archive::GitCommand {
1617                        arguments: arguments.iter().map(std::ffi::OsString::from).collect(),
1618                        stdin: Vec::new(),
1619                        env: Vec::new(),
1620                    },
1621                )
1622                .unwrap();
1623            assert_eq!(
1624                output.status,
1625                0,
1626                "git {arguments:?}: {}",
1627                String::from_utf8_lossy(&output.stderr)
1628            );
1629        }
1630
1631        let directory = tempfile::tempdir().unwrap();
1632        let origin = directory.path().join("original");
1633        std::fs::create_dir(&origin).unwrap();
1634        git(&origin, &["init", "-q", "-b", "main"]);
1635        git(&origin, &["config", "user.name", "Hel Test"]);
1636        git(&origin, &["config", "user.email", "hel@example.test"]);
1637        git(&origin, &["commit", "--allow-empty", "-qm", "base"]);
1638        let source = directory.path().join("source");
1639        git(
1640            directory.path(),
1641            &["clone", "-q", origin.to_str().unwrap(), "source"],
1642        );
1643        git(&source, &["config", "user.name", "Hel Test"]);
1644        git(&source, &["config", "user.email", "hel@example.test"]);
1645        git(&source, &["commit", "--allow-empty", "-qm", "session"]);
1646        let snapshot = hel::hel_archive::collect_git_snapshot(
1647            &SystemGit,
1648            &source,
1649            &hel::hel_archive::GitCollectionSpec {
1650                id: "project".into(),
1651                relative_destination: "project".into(),
1652                history: hel::hel_archive::GitHistoryMode::SessionDelta,
1653                origin_override: None,
1654            },
1655        )
1656        .unwrap();
1657        let configured = ProjectRepository {
1658            id: "project".into(),
1659            github: None,
1660            local: Some(origin.clone()),
1661            destination: "project".into(),
1662            git_ref: None,
1663        };
1664        assert_eq!(
1665            checkpoint_source_missing_commit(
1666                &configured,
1667                &CheckpointRepositoryBundle {
1668                    metadata: snapshot.metadata.clone(),
1669                    committed_bundle: snapshot.committed_bundle.clone(),
1670                },
1671                &ProcessExecutor,
1672                None,
1673            )
1674            .unwrap(),
1675            None
1676        );
1677
1678        let replacement = directory.path().join("replacement");
1679        std::fs::create_dir(&replacement).unwrap();
1680        git(&replacement, &["init", "-q", "-b", "main"]);
1681        git(&replacement, &["config", "user.name", "Hel Test"]);
1682        git(&replacement, &["config", "user.email", "hel@example.test"]);
1683        git(
1684            &replacement,
1685            &["commit", "--allow-empty", "-qm", "different history"],
1686        );
1687        let configured = ProjectRepository {
1688            local: Some(replacement),
1689            ..configured
1690        };
1691        assert!(
1692            checkpoint_source_missing_commit(
1693                &configured,
1694                &CheckpointRepositoryBundle {
1695                    metadata: snapshot.metadata,
1696                    committed_bundle: snapshot.committed_bundle,
1697                },
1698                &ProcessExecutor,
1699                None,
1700            )
1701            .unwrap()
1702            .is_some()
1703        );
1704    }
1705
1706    #[test]
1707    fn repository_preflight_checks_independent_sources_concurrently_and_receipts_are_scoped() {
1708        struct ConcurrentSourceExecutor {
1709            source_checks: Barrier,
1710        }
1711
1712        impl CommandExecutor for ConcurrentSourceExecutor {
1713            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1714                if command.purpose == "check checkpoint base commit" {
1715                    self.source_checks.wait();
1716                }
1717                Ok(CommandOutput {
1718                    status: 0,
1719                    stdout: Vec::new(),
1720                    stderr: Vec::new(),
1721                })
1722            }
1723        }
1724
1725        let directory = tempfile::tempdir().unwrap();
1726        let session_id = "0123456789abcdef0123456789abcdef";
1727        let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 3);
1728        let repositories = ["one", "two"]
1729            .map(|id| ProjectRepository {
1730                id: id.into(),
1731                github: None,
1732                local: Some(PathBuf::from(format!("/origin/{id}"))),
1733                destination: id.into(),
1734                git_ref: None,
1735            })
1736            .to_vec();
1737        let mut session = checkpoint_test_session(session_id);
1738        session.checkpoint = Some(checkpoint.clone());
1739        let mut controller = Controller {
1740            config: HelConfig {
1741                bundles: BTreeMap::from([(
1742                    session.bundle_id.clone(),
1743                    ProjectBundle {
1744                        primary_repo: "one".into(),
1745                        repositories: repositories.clone(),
1746                    },
1747                )]),
1748                ..HelConfig::default()
1749            },
1750            state: HelState {
1751                sessions: BTreeMap::from([(session_id.into(), session)]),
1752                ..HelState::default()
1753            },
1754        };
1755        let verified = ResumeRepositoryBundles {
1756            checkpoint_sha256: checkpoint.sha256,
1757            repositories: repositories
1758                .iter()
1759                .map(|repository| CheckpointRepositoryBundle {
1760                    metadata: hel::hel_archive::RepositoryMetadata {
1761                        id: repository.id.clone(),
1762                        relative_destination: repository.destination.clone(),
1763                        origin: repository.source_label(),
1764                        base_commit: String::new(),
1765                        head_commit: if repository.id == "one" {
1766                            "a".repeat(40)
1767                        } else {
1768                            "b".repeat(40)
1769                        },
1770                        branch: Some("main".into()),
1771                    },
1772                    committed_bundle: Vec::new(),
1773                })
1774                .collect(),
1775        };
1776        let executor = ConcurrentSourceExecutor {
1777            source_checks: Barrier::new(2),
1778        };
1779        let pool = rayon::ThreadPoolBuilder::new()
1780            .num_threads(2)
1781            .build()
1782            .unwrap();
1783        let preflight = pool
1784            .install(|| {
1785                controller
1786                    .preflight_verified_repository_sources(session_id, verified, None, &executor)
1787            })
1788            .unwrap();
1789        let ResumeRepositorySourcePreflight::Ready(receipt) = preflight else {
1790            panic!("expected repository source receipt");
1791        };
1792        assert!(controller.repository_source_receipt_is_current(session_id, &receipt));
1793
1794        controller
1795            .config
1796            .bundles
1797            .values_mut()
1798            .next()
1799            .unwrap()
1800            .repositories[0]
1801            .local = Some(PathBuf::from("/different-origin"));
1802        assert!(!controller.repository_source_receipt_is_current(session_id, &receipt));
1803    }
1804
1805    #[test]
1806    fn repository_preflight_checks_declared_boundary_without_importing_delta_bundle() {
1807        struct RecordingExecutor {
1808            commands: Mutex<Vec<CommandSpec>>,
1809        }
1810
1811        impl CommandExecutor for RecordingExecutor {
1812            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1813                self.commands.lock().unwrap().push(command.clone());
1814                Ok(CommandOutput {
1815                    status: 0,
1816                    stdout: Vec::new(),
1817                    stderr: Vec::new(),
1818                })
1819            }
1820        }
1821
1822        let prerequisite = "a".repeat(40);
1823        let head = "b".repeat(40);
1824        let archived = CheckpointRepositoryBundle {
1825            metadata: hel::hel_archive::RepositoryMetadata {
1826                id: "project".into(),
1827                relative_destination: "project".into(),
1828                origin: "https://github.com/archived/should-not-be-contacted.git".into(),
1829                base_commit: prerequisite.clone(),
1830                head_commit: head.clone(),
1831                branch: Some("main".into()),
1832            },
1833            committed_bundle: format!(
1834                "# v2 git bundle\n-{prerequisite} base\n{head} HEAD\n\nPACKnot-read"
1835            )
1836            .into_bytes(),
1837        };
1838        let configured = ProjectRepository {
1839            id: "project".into(),
1840            github: Some("configured/project".into()),
1841            local: None,
1842            destination: "project".into(),
1843            git_ref: None,
1844        };
1845        let executor = RecordingExecutor {
1846            commands: Mutex::new(Vec::new()),
1847        };
1848
1849        assert_eq!(
1850            checkpoint_source_missing_commit(
1851                &configured,
1852                &archived,
1853                &executor,
1854                Some("secret-token")
1855            )
1856            .unwrap(),
1857            None
1858        );
1859
1860        let commands = executor.commands.into_inner().unwrap();
1861        assert_eq!(commands.len(), 2, "commands: {commands:?}");
1862        assert_eq!(
1863            commands
1864                .iter()
1865                .map(|command| command.purpose.as_str())
1866                .collect::<Vec<_>>(),
1867            [
1868                "initialize repository source preflight",
1869                "check checkpoint base commit"
1870            ]
1871        );
1872        let source_check = &commands[1];
1873        assert!(
1874            source_check
1875                .args
1876                .iter()
1877                .any(|argument| argument == "credential.helper=")
1878        );
1879        assert_eq!(
1880            source_check
1881                .env
1882                .get("GIT_NO_LAZY_FETCH")
1883                .map(String::as_str),
1884            Some("1")
1885        );
1886        assert_eq!(
1887            source_check
1888                .env
1889                .get("GIT_TERMINAL_PROMPT")
1890                .map(String::as_str),
1891            Some("0")
1892        );
1893        assert_eq!(
1894            source_check.args.last().map(String::as_str),
1895            Some(prerequisite.as_str())
1896        );
1897        assert!(
1898            !source_check
1899                .args
1900                .iter()
1901                .any(|argument| argument.contains("archived"))
1902        );
1903    }
1904
1905    #[test]
1906    fn self_contained_bundle_validation_cannot_lazy_fetch_or_prompt() {
1907        let command = checkpoint_bundle_import_command(
1908            Path::new("/tmp/repository.git"),
1909            Path::new("/tmp/checkpoint.bundle"),
1910        );
1911        assert_eq!(
1912            command.env.get("GIT_NO_LAZY_FETCH").map(String::as_str),
1913            Some("1")
1914        );
1915        assert_eq!(
1916            command.env.get("GIT_TERMINAL_PROMPT").map(String::as_str),
1917            Some("0")
1918        );
1919    }
1920
1921    #[test]
1922    fn lost_bundle_sessions_reach_resume_compatibility_before_the_record_changes() {
1923        struct UnusedExecutor;
1924
1925        impl CommandExecutor for UnusedExecutor {
1926            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1927                panic!("resume ran {} before rejecting the target", command.program);
1928            }
1929        }
1930
1931        let directory = tempfile::tempdir().unwrap();
1932        let session_id = "0123456789abcdef0123456789abcdef";
1933        let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 3);
1934        let mut session = checkpoint_test_session(session_id);
1935        session.state = SessionState::Lost;
1936        session.checkpoint = Some(checkpoint);
1937        let previous = session.clone();
1938        let profile_home = directory.path().join("profile");
1939        std::fs::create_dir_all(&profile_home).unwrap();
1940        let mut config = HelConfig::default();
1941        config.profiles.insert(
1942            "codex".into(),
1943            HarnessProfile {
1944                kind: hel::hel_config::HarnessKind::Codex,
1945                home: profile_home,
1946                executable: None,
1947                environment: BTreeMap::new(),
1948                context_window_bytes: None,
1949            },
1950        );
1951        config
1952            .targets
1953            .insert("localhost".into(), TargetTemplate::LocalBare);
1954        let mut controller = Controller {
1955            config,
1956            state: HelState {
1957                sessions: BTreeMap::from([(session_id.into(), session)]),
1958                ..HelState::default()
1959            },
1960        };
1961
1962        let error = tokio::runtime::Builder::new_current_thread()
1963            .enable_all()
1964            .build()
1965            .unwrap()
1966            .block_on(controller.resume_session_controlled(
1967                session_id,
1968                "codex",
1969                "localhost",
1970                SessionResumeOptions {
1971                    additional_mounts: None,
1972                    resource_allocation: None,
1973                    discard_queue: false,
1974                },
1975                &UnusedExecutor,
1976            ))
1977            .unwrap_err();
1978
1979        let detail = format!("{error:#}");
1980        assert!(detail.contains("created from a project bundle"), "{detail}");
1981        assert!(
1982            detail.contains("resume it on a container, SSH, or EC2 target"),
1983            "{detail}"
1984        );
1985        assert_eq!(controller.state.sessions[session_id], previous);
1986    }
1987    /// Records what it ran and blocks every command on a barrier sized to
1988    /// both lanes, so a run only finishes if the second lane started before
1989    /// the first one's command returned.
1990    struct BarrierExecutor {
1991        seen: Mutex<Vec<String>>,
1992        barrier: Barrier,
1993    }
1994
1995    impl CommandExecutor for BarrierExecutor {
1996        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1997            self.seen.lock().unwrap().push(command.purpose.clone());
1998            self.barrier.wait();
1999            Ok(CommandOutput {
2000                status: 0,
2001                stdout: Vec::new(),
2002                stderr: Vec::new(),
2003            })
2004        }
2005    }
2006
2007    fn lane_command(purpose: &str) -> CommandSpec {
2008        CommandSpec::new("hel", ["worker"]).purpose(purpose)
2009    }
2010
2011    /// Launch progress must not claim "Start" while the target is still
2012    /// receiving the worker binary, the checkpoint archive and the restore.
2013    /// Everything before the daemon launch reports as Sync; the launch itself
2014    /// names its own stage, so a Sync-labelled executor cannot relabel it.
2015    #[test]
2016    fn start_begins_at_the_worker_launch_not_at_the_transfers_before_it() {
2017        struct RecordingExecutor {
2018            commands: RefCell<Vec<CommandSpec>>,
2019        }
2020        impl CommandExecutor for RecordingExecutor {
2021            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2022                self.commands.borrow_mut().push(command.clone());
2023                Ok(CommandOutput {
2024                    status: 0,
2025                    stdout: Vec::new(),
2026                    stderr: Vec::new(),
2027                })
2028            }
2029        }
2030
2031        let session_id = "0123456789abcdef0123456789abcdef";
2032        let worker_root = format!("/var/lib/hel/workers/{session_id}");
2033        let executor = RecordingExecutor {
2034            commands: RefCell::new(Vec::new()),
2035        };
2036        let syncing = StagedExecutor::new(&executor, ProvisionStage::Syncing);
2037        let backend = hel_targets::TargetLocator::LocalPodman {
2038            container_id: "abcdef0123456789".into(),
2039            workspace_storage: Default::default(),
2040        };
2041
2042        upload_checkpoint_spec(
2043            &syncing,
2044            &backend,
2045            session_id,
2046            Path::new("/archives/session.hel.zip"),
2047            &format!("{worker_root}/restore.hel.zip"),
2048        )
2049        .unwrap();
2050        execute_checked(
2051            &syncing,
2052            restore_command(
2053                &backend,
2054                session_id,
2055                &format!("{worker_root}/restore-spec.json"),
2056            )
2057            .unwrap(),
2058        )
2059        .unwrap();
2060        // Deliberately run the launch through the Sync-labelled executor: it
2061        // must still report Start.
2062        start_worker(&syncing, &backend, &worker_root).unwrap();
2063
2064        let stages = executor
2065            .commands
2066            .borrow()
2067            .iter()
2068            .map(|command| (command.purpose.clone(), command.stage))
2069            .collect::<Vec<_>>();
2070        assert_eq!(
2071            stages,
2072            vec![
2073                (
2074                    "upload checkpoint specification".to_owned(),
2075                    Some(ProvisionStage::Syncing)
2076                ),
2077                (
2078                    "restore target checkpoint".to_owned(),
2079                    Some(ProvisionStage::Syncing)
2080                ),
2081                (
2082                    "start detached Mjolnir worker".to_owned(),
2083                    Some(ProvisionStage::Starting)
2084                ),
2085            ]
2086        );
2087    }
2088    #[test]
2089    fn independent_target_lanes_run_at_the_same_time() {
2090        let executor = BarrierExecutor {
2091            seen: Mutex::new(Vec::new()),
2092            barrier: Barrier::new(2),
2093        };
2094
2095        execute_concurrent_lanes(
2096            || execute_checked(&executor, lane_command("install the worker")).map(|_| ()),
2097            || execute_checked(&executor, lane_command("upload the checkpoint")).map(|_| ()),
2098        )
2099        .unwrap();
2100
2101        let mut seen = executor.seen.into_inner().unwrap();
2102        seen.sort();
2103        assert_eq!(seen, ["install the worker", "upload the checkpoint"]);
2104    }
2105    #[test]
2106    fn a_lane_failure_is_reported_in_lane_order_and_never_abandons_the_other_lane() {
2107        let reached = Mutex::new(Vec::new());
2108
2109        // The first lane fails slowly and the second immediately, so a
2110        // completion-order report could only pick the second.
2111        let error = execute_concurrent_lanes(
2112            || -> Result<()> {
2113                std::thread::sleep(Duration::from_millis(50));
2114                bail!("worker install failed")
2115            },
2116            || -> Result<()> {
2117                reached.lock().unwrap().push("second");
2118                bail!("checkpoint upload failed")
2119            },
2120        )
2121        .unwrap_err();
2122
2123        assert_eq!(error.to_string(), "worker install failed");
2124        assert_eq!(
2125            *reached.lock().unwrap(),
2126            ["second"],
2127            "a failing first lane must not cut the second one short"
2128        );
2129
2130        let error = execute_concurrent_lanes(
2131            || Ok(()),
2132            || -> Result<()> { bail!("checkpoint upload failed") },
2133        )
2134        .unwrap_err();
2135        assert_eq!(error.to_string(), "checkpoint upload failed");
2136    }
2137    #[test]
2138    fn a_projection_standing_at_the_archived_frontier_is_reused() {
2139        let digest = "a".repeat(64);
2140        let other = "b".repeat(64);
2141
2142        assert!(!projection_rebuild_required(
2143            Some((82_000, &digest)),
2144            82_000,
2145            &digest
2146        ));
2147
2148        for stored in [
2149            // Same ordinal, different event chain.
2150            Some((82_000, other.as_str())),
2151            // Behind the archive, and ahead of it.
2152            Some((81_999, digest.as_str())),
2153            Some((82_001, digest.as_str())),
2154            // No projection stored, or none that could be read.
2155            None,
2156        ] {
2157            assert!(
2158                projection_rebuild_required(stored, 82_000, &digest),
2159                "{stored:?} must not be mistaken for the archived projection"
2160            );
2161        }
2162    }
2163    #[test]
2164    fn failed_resume_rolls_back_only_after_target_cleanup() {
2165        let previous = SessionRecord {
2166            workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
2167            archived: false,
2168            container_cpus: None,
2169            container_memory: None,
2170            id: "0123456789abcdef0123456789abcdef".into(),
2171            title: "imported session".into(),
2172            harness_kind: hel::hel_config::HarnessKind::Codex,
2173            last_profile: "codex-old".into(),
2174            bundle_id: "project".into(),
2175            project_directory: None,
2176            managed_worktree: None,
2177            target_template_id: "podman-old".into(),
2178            resource_allocation: None,
2179            additional_mounts: Vec::new(),
2180            state: SessionState::Stopped,
2181            target: None,
2182            native_session_id: Some("native-session".into()),
2183            acp_session_title: None,
2184            session_title_override: None,
2185            created_at: "2026-08-12T00:00:00Z".into(),
2186            updated_at: "2026-08-12T00:00:00Z".into(),
2187            viewed_through_event_ordinal: 0,
2188            draft_input: String::new(),
2189            last_error: None,
2190            last_checkpoint_error: None,
2191            checkpoint: None,
2192        };
2193        let partial_target = TargetLocator::LocalPodman {
2194            container_id: "partial-container".into(),
2195            workspace_storage: Default::default(),
2196        };
2197        let mut cleaned = previous.clone();
2198        cleaned.state = SessionState::Error;
2199        cleaned.last_profile = "codex-new".into();
2200        cleaned.target = Some(partial_target.clone());
2201
2202        let failure =
2203            apply_failed_resume_rollback(&mut cleaned, &previous, "worker upload failed", None);
2204
2205        assert_eq!(cleaned.state, SessionState::Stopped);
2206        assert_eq!(cleaned.last_profile, "codex-old");
2207        assert_eq!(cleaned.target, None);
2208        assert_eq!(failure.to_string(), "worker upload failed");
2209        assert_eq!(
2210            cleaned.last_error.as_deref(),
2211            Some("resume failed: worker upload failed")
2212        );
2213
2214        let mut cleanup_failed = previous.clone();
2215        cleanup_failed.state = SessionState::Error;
2216        cleanup_failed.last_profile = "codex-new".into();
2217        cleanup_failed.target = Some(partial_target.clone());
2218
2219        let failure = apply_failed_resume_rollback(
2220            &mut cleanup_failed,
2221            &previous,
2222            "worker upload failed",
2223            Some("podman rm failed".into()),
2224        );
2225
2226        assert_eq!(cleanup_failed.state, SessionState::Error);
2227        assert_eq!(cleanup_failed.last_profile, "codex-new");
2228        assert_eq!(cleanup_failed.target, Some(partial_target));
2229        assert!(failure.to_string().contains("cleanup"));
2230    }
2231    #[test]
2232    fn failed_worktree_cleanup_notice_names_mjolnir_and_the_recovery_command() {
2233        let notice = worktree_cleanup_notice(
2234            Path::new("/workspace/project"),
2235            &anyhow::anyhow!("permission denied"),
2236        );
2237
2238        assert!(
2239            notice.starts_with(
2240                "Mjolnir could not remove the worktree at /workspace/project: permission denied."
2241            ),
2242            "{notice}"
2243        );
2244        assert!(
2245            notice.contains("`git worktree remove --force /workspace/project`"),
2246            "{notice}"
2247        );
2248        assert!(!notice.contains("Hel"), "{notice}");
2249    }
2250    #[test]
2251    fn failed_resume_provisioning_preserves_checkpoint_and_projection_lineage() {
2252        // MJ_DATA_DIR is process-global, so run the database-backed half in an
2253        // exact child test instead of racing unrelated tests in this process.
2254        if std::env::var_os(RESUME_ROLLBACK_TEST_CHILD).is_none() {
2255            let directory = tempfile::tempdir().unwrap();
2256            let test_name = format!(
2257                "{}::failed_resume_provisioning_preserves_checkpoint_and_projection_lineage",
2258                module_path!()
2259                    .strip_prefix("mj_controller::")
2260                    .unwrap_or(module_path!())
2261            );
2262            let output = Command::new(std::env::current_exe().unwrap())
2263                .args(["--exact", &test_name, "--nocapture"])
2264                .env(RESUME_ROLLBACK_TEST_CHILD, "1")
2265                // A remote target needs a portable worker; any existing file
2266                // satisfies the preflight so the test reaches provisioning.
2267                .env("MJ_WORKER_BINARY", std::env::current_exe().unwrap())
2268                .env("MJ_DATA_DIR", directory.path())
2269                .env("GH_TOKEN", "test-token")
2270                .output()
2271                .unwrap();
2272            assert!(
2273                output.status.success(),
2274                "isolated resume rollback test failed\nstdout:\n{}\nstderr:\n{}",
2275                String::from_utf8_lossy(&output.stdout),
2276                String::from_utf8_lossy(&output.stderr)
2277            );
2278            return;
2279        }
2280        // Alone in this child process, so it installs the one writer.
2281        let _writer = hel::hel_database::install_isolated_test_writer();
2282
2283        /// Provisioning runs after the resumed record is persisted, so the
2284        /// durable mounts read here are the ones resume just committed.
2285        #[derive(Default)]
2286        struct FailingPreflightExecutor {
2287            mounts_during_provisioning: Mutex<Option<Vec<AdditionalMount>>>,
2288        }
2289
2290        impl CommandExecutor for FailingPreflightExecutor {
2291            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2292                // Provisioning probes the mount source's filesystem before it
2293                // builds the run arguments; a local disk keeps the overlay.
2294                if command.program == "stat" {
2295                    return Ok(CommandOutput {
2296                        status: 0,
2297                        stdout: b"ext4\n".to_vec(),
2298                        stderr: Vec::new(),
2299                    });
2300                }
2301                assert_eq!(command.program, "podman");
2302                let mut observed = self.mounts_during_provisioning.lock().unwrap();
2303                if observed.is_none() {
2304                    let durable = hel::hel_database::load_state().unwrap();
2305                    *observed = Some(
2306                        durable.sessions["0123456789abcdef0123456789abcdef"]
2307                            .additional_mounts
2308                            .clone(),
2309                    );
2310                }
2311                Ok(CommandOutput {
2312                    status: 1,
2313                    stdout: Vec::new(),
2314                    stderr: b"podman is temporarily unavailable".to_vec(),
2315                })
2316            }
2317        }
2318
2319        let data_directory = PathBuf::from(std::env::var_os("MJ_DATA_DIR").unwrap());
2320        let archive_directory = data_directory.join("archives");
2321        std::fs::create_dir_all(&archive_directory).unwrap();
2322        let session_id = "0123456789abcdef0123456789abcdef";
2323        let checkpoint = write_checkpoint_gate_archive(&archive_directory, session_id, 7);
2324        let archive = verify_archive_streaming(&checkpoint.archive_path).unwrap();
2325        let expected_projection =
2326            materialized_session_from_canonical(session_id, &archive.canonical_session).unwrap();
2327
2328        let mut session = checkpoint_test_session(session_id);
2329        session.state = SessionState::Stopped;
2330        session.checkpoint = Some(checkpoint.clone());
2331        session.additional_mounts = vec![AdditionalMount {
2332            source: PathBuf::from("/host/old"),
2333            destination: PathBuf::from("/mnt/old"),
2334            read_only: false,
2335        }];
2336        let previous = session.clone();
2337        let resumed_mounts = vec![AdditionalMount {
2338            source: PathBuf::from("/host/new"),
2339            destination: PathBuf::from("/mnt/new"),
2340            read_only: false,
2341        }];
2342        let profile_home = data_directory.join("profile");
2343        std::fs::create_dir_all(&profile_home).unwrap();
2344        let mut config = HelConfig::default();
2345        config.profiles.insert(
2346            "codex".into(),
2347            HarnessProfile {
2348                kind: hel::hel_config::HarnessKind::Codex,
2349                home: profile_home,
2350                executable: None,
2351                environment: BTreeMap::new(),
2352                context_window_bytes: None,
2353            },
2354        );
2355        config.bundles.insert(
2356            "project".into(),
2357            ProjectBundle {
2358                primary_repo: "project".into(),
2359                repositories: vec![ProjectRepository {
2360                    id: "project".into(),
2361                    github: Some("example/project".into()),
2362                    local: None,
2363                    destination: "project".into(),
2364                    git_ref: None,
2365                }],
2366            },
2367        );
2368        config.targets.insert(
2369            "podman".into(),
2370            TargetTemplate::LocalPodman {
2371                container: ConfigContainer {
2372                    image: "example.invalid/hel-test:latest".into(),
2373                    pull_policy: Default::default(),
2374                    platform: None,
2375                    cpus: None,
2376                    memory: None,
2377                    environment: BTreeMap::new(),
2378                    workspace_storage: Default::default(),
2379                },
2380            },
2381        );
2382        let mut controller = Controller {
2383            config,
2384            state: HelState {
2385                sessions: BTreeMap::from([(session_id.into(), session)]),
2386                ..HelState::default()
2387            },
2388        };
2389        hel::hel_database::save_state(&controller.state).unwrap();
2390        hel::hel_database::save_materialized_session(&expected_projection).unwrap();
2391
2392        let runtime = tokio::runtime::Builder::new_current_thread()
2393            .enable_all()
2394            .build()
2395            .unwrap();
2396        let executor = FailingPreflightExecutor::default();
2397        let error = runtime
2398            .block_on(controller.resume_session_controlled(
2399                session_id,
2400                "codex",
2401                "podman",
2402                SessionResumeOptions {
2403                    additional_mounts: Some(resumed_mounts.clone()),
2404                    resource_allocation: None,
2405                    discard_queue: false,
2406                },
2407                &executor,
2408            ))
2409            .unwrap_err();
2410        let detail = format!("{error:#}");
2411        assert!(
2412            detail.contains("podman is temporarily unavailable"),
2413            "{detail}"
2414        );
2415        assert!(!detail.contains("returned to stopped"), "{detail}");
2416        assert!(!detail.contains("unknown session"), "{detail}");
2417        assert_eq!(
2418            executor.mounts_during_provisioning.into_inner().unwrap(),
2419            Some(resumed_mounts)
2420        );
2421
2422        let retained = controller.state.sessions.get(session_id).unwrap();
2423        assert_eq!(retained.state, SessionState::Stopped);
2424        assert_eq!(retained.checkpoint, previous.checkpoint);
2425        assert_eq!(retained.managed_worktree, previous.managed_worktree);
2426        assert!(checkpoint.archive_path.is_file());
2427
2428        let durable = hel::hel_database::load_state().unwrap();
2429        let durable_session = durable.sessions.get(session_id).unwrap();
2430        assert_eq!(durable_session.state, SessionState::Stopped);
2431        assert_eq!(durable_session.checkpoint, previous.checkpoint);
2432        assert_eq!(
2433            durable_session.additional_mounts,
2434            previous.additional_mounts
2435        );
2436        assert_eq!(
2437            hel::hel_database::load_materialized_session(session_id).unwrap(),
2438            Some(expected_projection)
2439        );
2440    }
2441    #[test]
2442    fn failed_resume_retires_a_checkout_it_recreated() {
2443        if std::env::var_os(RETIRED_WORKTREE_RESUME_TEST_CHILD).is_none() {
2444            let directory = tempfile::tempdir().unwrap();
2445            let test_name = format!(
2446                "{}::failed_resume_retires_a_checkout_it_recreated",
2447                module_path!()
2448                    .strip_prefix("mj_controller::")
2449                    .unwrap_or(module_path!())
2450            );
2451            let output = Command::new(std::env::current_exe().unwrap())
2452                .args(["--exact", &test_name, "--nocapture"])
2453                .env(RETIRED_WORKTREE_RESUME_TEST_CHILD, "1")
2454                // A remote target needs a portable worker; any existing file
2455                // satisfies the preflight so the test reaches provisioning.
2456                .env("MJ_WORKER_BINARY", std::env::current_exe().unwrap())
2457                .env("MJ_DATA_DIR", directory.path().join("data"))
2458                .env("MJ_CONFIG_DIR", directory.path().join("config"))
2459                .output()
2460                .unwrap();
2461            assert!(
2462                output.status.success(),
2463                "isolated retired-worktree resume test failed\nstdout:\n{}\nstderr:\n{}",
2464                String::from_utf8_lossy(&output.stdout),
2465                String::from_utf8_lossy(&output.stderr)
2466            );
2467            return;
2468        }
2469        // Alone in this child process, so it installs the one writer.
2470        let _writer = hel::hel_database::install_isolated_test_writer();
2471
2472        struct FailAfterWorktreeRestore;
2473
2474        impl CommandExecutor for FailAfterWorktreeRestore {
2475            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2476                if matches!(command.program.as_str(), "git" | "mkdir") {
2477                    return ProcessExecutor.execute(command);
2478                }
2479                Ok(CommandOutput {
2480                    status: 1,
2481                    stdout: Vec::new(),
2482                    stderr: b"stop after recreating the checkout".to_vec(),
2483                })
2484            }
2485        }
2486
2487        let data_directory = PathBuf::from(std::env::var_os("MJ_DATA_DIR").unwrap());
2488        let archive_directory = data_directory.join("archives");
2489        std::fs::create_dir_all(&archive_directory).unwrap();
2490        let session_id = "0123456789abcdef0123456789abcdef";
2491        let checkpoint = write_checkpoint_gate_archive(&archive_directory, session_id, 7);
2492        let repository = committed_repository();
2493        let mut session = managed_worktree_session(repository.path(), session_id);
2494        session.checkpoint = Some(checkpoint);
2495        let worktree = session.managed_worktree.clone().unwrap();
2496        retire_managed_worktree(&ProcessExecutor, &worktree).unwrap();
2497        assert!(!worktree.worktree_root.exists());
2498
2499        let profile_home = data_directory.join("profile");
2500        std::fs::create_dir_all(&profile_home).unwrap();
2501        let mut config = resume_compatibility_config();
2502        config.profiles.insert(
2503            "codex".into(),
2504            HarnessProfile {
2505                kind: hel::hel_config::HarnessKind::Codex,
2506                home: profile_home,
2507                executable: None,
2508                environment: BTreeMap::new(),
2509                context_window_bytes: None,
2510            },
2511        );
2512        let mut controller = Controller {
2513            config,
2514            state: HelState {
2515                sessions: BTreeMap::from([(session_id.into(), session)]),
2516                ..HelState::default()
2517            },
2518        };
2519        hel::hel_database::save_state(&controller.state).unwrap();
2520
2521        let error = tokio::runtime::Builder::new_current_thread()
2522            .enable_all()
2523            .build()
2524            .unwrap()
2525            .block_on(controller.resume_session_controlled(
2526                session_id,
2527                "codex",
2528                "local-bare",
2529                SessionResumeOptions {
2530                    additional_mounts: None,
2531                    resource_allocation: None,
2532                    discard_queue: false,
2533                },
2534                &FailAfterWorktreeRestore,
2535            ))
2536            .unwrap_err();
2537
2538        assert!(
2539            format!("{error:#}").contains("stop after recreating the checkout"),
2540            "{error:#}"
2541        );
2542        assert!(!worktree.worktree_root.exists());
2543        assert_eq!(
2544            controller.state.sessions[session_id].state,
2545            SessionState::Stopped
2546        );
2547        let branch = Command::new("git")
2548            .arg("-C")
2549            .arg(repository.path())
2550            .args([
2551                "show-ref",
2552                "--verify",
2553                &format!("refs/heads/{}", worktree.branch),
2554            ])
2555            .status()
2556            .unwrap();
2557        assert!(branch.success(), "resume rollback must retain the branch");
2558    }
2559    const RAW_CONVERSION_TEST_CHILD: &str = "MJ_RAW_CONVERSION_TEST_CHILD";
2560    #[test]
2561    fn a_failed_raw_conversion_keeps_the_bundle_and_leaves_the_worktree_alone() {
2562        // MJ_DATA_DIR and MJ_CONFIG_DIR are process-global, so run the half
2563        // that writes them in an exact child test.
2564        if std::env::var_os(RAW_CONVERSION_TEST_CHILD).is_none() {
2565            let directory = tempfile::tempdir().unwrap();
2566            let test_name = format!(
2567                "{}::a_failed_raw_conversion_keeps_the_bundle_and_leaves_the_worktree_alone",
2568                module_path!()
2569                    .strip_prefix("mj_controller::")
2570                    .unwrap_or(module_path!())
2571            );
2572            let output = Command::new(std::env::current_exe().unwrap())
2573                .args(["--exact", &test_name, "--nocapture"])
2574                .env(RAW_CONVERSION_TEST_CHILD, "1")
2575                // A remote target needs a portable worker; any existing file
2576                // satisfies the preflight so the test reaches provisioning.
2577                .env("MJ_WORKER_BINARY", std::env::current_exe().unwrap())
2578                .env("MJ_DATA_DIR", directory.path().join("data"))
2579                .env("MJ_CONFIG_DIR", directory.path().join("config"))
2580                .env("GH_TOKEN", "test-token")
2581                .output()
2582                .unwrap();
2583            assert!(
2584                output.status.success(),
2585                "isolated raw conversion test failed\nstdout:\n{}\nstderr:\n{}",
2586                String::from_utf8_lossy(&output.stdout),
2587                String::from_utf8_lossy(&output.stderr)
2588            );
2589            return;
2590        }
2591        // Alone in this child process, so it installs the one writer.
2592        let _writer = hel::hel_database::install_isolated_test_writer();
2593
2594        /// Real Git, no container runtime. Provisioning fails at preflight,
2595        /// after the conversion has already reshaped the record.
2596        struct GitWithoutPodmanExecutor;
2597
2598        impl CommandExecutor for GitWithoutPodmanExecutor {
2599            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2600                if command.program == "git" {
2601                    return ProcessExecutor.execute(command);
2602                }
2603                Ok(CommandOutput {
2604                    status: 1,
2605                    stdout: Vec::new(),
2606                    stderr: b"podman is temporarily unavailable".to_vec(),
2607                })
2608            }
2609        }
2610
2611        let data_directory = PathBuf::from(std::env::var_os("MJ_DATA_DIR").unwrap());
2612        let archive_directory = data_directory.join("archives");
2613        std::fs::create_dir_all(&archive_directory).unwrap();
2614        std::fs::create_dir_all(hel::hel_config::config_dir()).unwrap();
2615        let session_id = "0123456789abcdef0123456789abcdef";
2616        let checkpoint = write_checkpoint_gate_archive(&archive_directory, session_id, 7);
2617
2618        let repository = committed_repository();
2619        let mut session = managed_worktree_session(repository.path(), session_id);
2620        session.checkpoint = Some(checkpoint);
2621        let worktree = session.managed_worktree.clone().unwrap();
2622        let previous = session.clone();
2623
2624        let profile_home = data_directory.join("profile");
2625        std::fs::create_dir_all(&profile_home).unwrap();
2626        let mut config = resume_compatibility_config();
2627        config.profiles.insert(
2628            "codex".into(),
2629            HarnessProfile {
2630                kind: hel::hel_config::HarnessKind::Codex,
2631                home: profile_home,
2632                executable: None,
2633                environment: BTreeMap::new(),
2634                context_window_bytes: None,
2635            },
2636        );
2637        let mut controller = Controller {
2638            config,
2639            state: HelState {
2640                sessions: BTreeMap::from([(session_id.into(), session)]),
2641                ..HelState::default()
2642            },
2643        };
2644        hel::hel_database::save_state(&controller.state).unwrap();
2645
2646        let error = tokio::runtime::Builder::new_current_thread()
2647            .enable_all()
2648            .build()
2649            .unwrap()
2650            .block_on(controller.resume_session_controlled(
2651                session_id,
2652                "codex",
2653                "podman",
2654                SessionResumeOptions {
2655                    additional_mounts: None,
2656                    resource_allocation: None,
2657                    discard_queue: false,
2658                },
2659                &GitWithoutPodmanExecutor,
2660            ))
2661            .unwrap_err();
2662        assert!(
2663            format!("{error:#}").contains("podman is temporarily unavailable"),
2664            "{error:#}"
2665        );
2666        assert!(!format!("{error:#}").contains("returned to stopped"));
2667
2668        // The bundle stays: it was saved before the record referenced it, and a
2669        // retry reuses it instead of adding another.
2670        let (_, bundle) = controller
2671            .config
2672            .bundles
2673            .iter()
2674            .find(|(_, bundle)| bundle.repositories[0].local.as_deref() == Some(repository.path()))
2675            .expect("the conversion added a bundle for the checkout");
2676        assert_eq!(
2677            bundle.repositories[0].destination,
2678            PathBuf::from(session_id)
2679        );
2680        let saved = hel::hel_config::HelConfig::load().unwrap();
2681        assert_eq!(saved.bundles, controller.config.bundles);
2682
2683        let retained = controller.state.sessions.get(session_id).unwrap();
2684        assert_eq!(retained.state, SessionState::Stopped);
2685        assert_eq!(retained.project_directory, previous.project_directory);
2686        assert_eq!(retained.managed_worktree, previous.managed_worktree);
2687        assert_eq!(retained.bundle_id, previous.bundle_id);
2688        assert!(worktree.worktree_root.is_dir(), "the checkout stays put");
2689    }
2690}