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