Skip to main content

mj_controller/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 tokio_util::sync::CancellationToken;
11
12use crate::checkpoint_transfer::restore_command;
13use crate::session_manager::new_command_id;
14use mj_checkpoint::archive::{
15    CanonicalQueuedCommandKind, CanonicalSessionSnapshot, CheckpointRepositoryBundle, SystemGit,
16    checkpoint_bundle_prerequisites, read_checkpoint_repository_bundles, verify_archive_streaming,
17};
18use mj_checkpoint::checkpoint::CheckpointRestoreSpec;
19use mj_core::config::{Config, HarnessKind, ProjectRepository, mount_history_host};
20use mj_core::state::{MaterializedSession, SessionRecord, SessionResourceAllocation, SessionState};
21use mj_transcript::projection::materialized_session_from_canonical;
22
23use crate::targets::{
24    self, AdditionalMount, CancellableProcessExecutor, CommandExecutor, CommandOutput, CommandSpec,
25    ProcessExecutor, ProvisionStage, ProvisionStageGuard,
26};
27use mj_core::relay::RelayCommand;
28
29use super::backend::{backend_locator, controller_github_token, validate_resource_allocation};
30use super::checkpoint::upload_checkpoint_spec;
31use super::provisioning::{
32    ProvisioningFailureDisposition, StagedExecutor, execute_concurrent_lanes,
33    install_attached_resources,
34};
35use super::readiness::{connect_started_worker, wait_for_native_session_in_stage};
36use super::worker_binary::{bridge_readiness_stage, start_worker, worker_probe_diagnosis};
37use super::worktree::{
38    PrimaryCheckoutRequirement, ResumeConversion, ResumePlan, apply_raw_to_workspace,
39    apply_workspace_to_raw, cleanup_managed_worktree, create_managed_worktree,
40    managed_worktree_checkout_exists, plan_raw_to_workspace,
41    preserve_retained_managed_worktree_branch, raw_checkout_divergence_notice,
42    raw_checkout_position, raw_checkout_snapshot, raw_conversion_preview, restore_managed_worktree,
43    resume_compatibility, retire_managed_worktree,
44};
45use super::{
46    Controller, SessionResumeOptions, execute_checked, now, selected_host_container_size,
47    target_profile_home,
48};
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct ResumeRepositorySourceMismatch {
52    pub session_id: String,
53    pub bundle_id: String,
54    pub repository_id: String,
55    pub missing_commit: String,
56    pub archived_origin: String,
57    pub configured_origin: String,
58}
59
60pub use mj_core::state::ResumeRepositorySourceReceipt;
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum ResumeRepositorySourcePreflight {
64    Ready(ResumeRepositorySourceReceipt),
65    RepositoryMoved(ResumeRepositorySourceMismatch),
66    /// The resume converts a local checkout into an isolated workspace. The
67    /// receipt is already valid; the preview is what a person has to confirm
68    /// before the checkout is snapshotted and the session moves off this
69    /// machine.
70    ConvertingRawCheckout {
71        receipt: ResumeRepositorySourceReceipt,
72        preview: Box<mj_core::state::RawConversionPreview>,
73    },
74}
75
76struct ResumeRepositoryBundles {
77    checkpoint_sha256: String,
78    repositories: Vec<CheckpointRepositoryBundle>,
79}
80
81/// A small timing scope for the expensive resume phases. Target commands
82/// already trace their own durations; this covers controller-side work and
83/// lets an operator see where a slow resume spent its wall-clock budget.
84struct ResumePhaseTimer<'a> {
85    session_id: &'a str,
86    phase: &'static str,
87    started: Instant,
88}
89
90impl<'a> ResumePhaseTimer<'a> {
91    fn new(session_id: &'a str, phase: &'static str) -> Self {
92        Self {
93            session_id,
94            phase,
95            started: Instant::now(),
96        }
97    }
98}
99
100impl Drop for ResumePhaseTimer<'_> {
101    fn drop(&mut self) {
102        tracing::debug!(
103            session_id = self.session_id,
104            phase = self.phase,
105            elapsed_ms = self.started.elapsed().as_millis(),
106            "resume phase completed"
107        );
108    }
109}
110
111impl Controller {
112    /// Muse has one workspace root; native restore relocates its metadata.
113    pub(super) fn validate_muse_resume_destination(
114        &self,
115        source: &SessionRecord,
116        destination_harness: HarnessKind,
117        _target_id: &str,
118    ) -> Result<()> {
119        if destination_harness != HarnessKind::Muse {
120            return Ok(());
121        }
122        ensure!(
123            source.project_directory.is_some()
124                || self
125                    .config
126                    .bundles
127                    .get(&source.bundle_id)
128                    .is_none_or(|bundle| bundle.repositories.len() == 1),
129            "Muse Code ACP supports one workspace root; use a single-repository bundle"
130        );
131        Ok(())
132    }
133    /// Prove that each configured repository source still supplies the commit
134    /// boundary its checkpoint bundle expects, before provisioning anything,
135    /// and describe a local checkout's conversion so a person can confirm it.
136    pub fn preflight_resume_repository_sources(
137        &self,
138        session_id: &str,
139        target_id: &str,
140        executor: &(impl CommandExecutor + Sync),
141    ) -> Result<ResumeRepositorySourcePreflight> {
142        self.preflight_repository_sources(session_id, target_id, true, executor)
143    }
144
145    /// `describe_conversion` buys the conversion preview with a read of the
146    /// checkout and a question to its remote. The resume itself only needs to
147    /// know whether a configured source moved, and the person has already
148    /// confirmed by then, so it asks for the cheap answer.
149    fn preflight_repository_sources(
150        &self,
151        session_id: &str,
152        target_id: &str,
153        describe_conversion: bool,
154        executor: &(impl CommandExecutor + Sync),
155    ) -> Result<ResumeRepositorySourcePreflight> {
156        let session = self
157            .state
158            .sessions
159            .get(session_id)
160            .with_context(|| format!("unknown session {session_id}"))?;
161        let checkpoint = session
162            .checkpoint
163            .as_ref()
164            .context("session has no checkpoint")?;
165        let plan = resume_compatibility(session, &self.config, target_id)
166            .map_err(|reason| anyhow::anyhow!(reason))?;
167        if session.project_directory.is_some() {
168            debug_assert!(matches!(
169                plan,
170                ResumePlan::InPlace | ResumePlan::RawToWorkspace
171            ));
172            // A raw session resumes from its live checkout. Its synthetic
173            // bundle is only a grouping identity and may no longer be in the
174            // config; neither an in-place resume nor a raw-to-workspace
175            // conversion restores repository contents from that bundle.
176            let receipt = ResumeRepositorySourceReceipt {
177                session_id: session_id.to_owned(),
178                bundle_id: session.bundle_id.clone(),
179                checkpoint_sha256: checkpoint.sha256.clone(),
180                repositories: Vec::new(),
181            };
182            // A conversion reads the checkout and its remote so the person
183            // sees what will travel. Planning failures (no network remote, an
184            // unreachable remote, a dirty submodule) are this preflight's
185            // error, which every surface already reports.
186            if describe_conversion && plan == ResumePlan::RawToWorkspace {
187                let preview = raw_conversion_preview_for(session, &self.config, executor)?;
188                return Ok(ResumeRepositorySourcePreflight::ConvertingRawCheckout {
189                    receipt,
190                    preview: Box::new(preview),
191                });
192            }
193            return Ok(ResumeRepositorySourcePreflight::Ready(receipt));
194        }
195        let repositories = read_checkpoint_repository_bundles(&checkpoint.archive_path)?;
196        self.preflight_verified_repository_sources(
197            session_id,
198            ResumeRepositoryBundles {
199                checkpoint_sha256: checkpoint.sha256.clone(),
200                repositories,
201            },
202            None,
203            plan != ResumePlan::WorkspaceToRaw,
204            executor,
205        )
206    }
207
208    fn preflight_verified_repository_sources(
209        &self,
210        session_id: &str,
211        verified: ResumeRepositoryBundles,
212        skip_repository_id: Option<&str>,
213        use_archived_network_sources: bool,
214        executor: &(impl CommandExecutor + Sync),
215    ) -> Result<ResumeRepositorySourcePreflight> {
216        let session = self
217            .state
218            .sessions
219            .get(session_id)
220            .with_context(|| format!("unknown session {session_id}"))?;
221        ensure!(
222            verified.repositories.iter().all(|repository| {
223                !repository.metadata.origin.starts_with("mj-local:")
224                    && !repository.metadata.origin.starts_with("ext::")
225            }),
226            "resuming legacy host-bridge sessions is not supported; start a new network-backed session"
227        );
228        // The immutable archive supplies network provenance. Local source
229        // paths and later host configuration changes are irrelevant.
230        if use_archived_network_sources
231            && verified
232                .repositories
233                .iter()
234                .all(|repository| repository.metadata.remote_workspace)
235        {
236            return Ok(ResumeRepositorySourcePreflight::Ready(
237                ResumeRepositorySourceReceipt {
238                    session_id: session_id.to_owned(),
239                    bundle_id: session.bundle_id.clone(),
240                    checkpoint_sha256: verified.checkpoint_sha256,
241                    repositories: Vec::new(),
242                },
243            ));
244        }
245        if verified.repositories.is_empty() {
246            return Ok(ResumeRepositorySourcePreflight::Ready(
247                ResumeRepositorySourceReceipt {
248                    session_id: session_id.to_owned(),
249                    bundle_id: session.bundle_id.clone(),
250                    checkpoint_sha256: verified.checkpoint_sha256,
251                    repositories: Vec::new(),
252                },
253            ));
254        }
255        let bundle = self
256            .config
257            .bundles
258            .get(&session.bundle_id)
259            .with_context(|| format!("session bundle {:?} is missing", session.bundle_id))?;
260        let configured = verified
261            .repositories
262            .iter()
263            .map(|archived| {
264                bundle
265                    .repositories
266                    .iter()
267                    .find(|repository| repository.id == archived.metadata.id)
268                    .cloned()
269                    .with_context(|| {
270                        format!(
271                            "session bundle {:?} no longer contains repository {:?}",
272                            session.bundle_id, archived.metadata.id
273                        )
274                    })
275            })
276            .collect::<Result<Vec<_>>>()?;
277        let github_token = configured
278            .iter()
279            .any(|repository| repository.github.is_some())
280            .then(controller_github_token)
281            .flatten();
282        let outcomes = verified
283            .repositories
284            .par_iter()
285            .zip(configured.par_iter())
286            .map(|(archived, configured)| {
287                if skip_repository_id == Some(configured.id.as_str()) {
288                    return Ok(None);
289                }
290                checkpoint_source_missing_commit(
291                    configured,
292                    archived,
293                    executor,
294                    github_token.as_deref(),
295                )
296                .map(|missing_commit| {
297                    missing_commit.map(|missing_commit| ResumeRepositorySourceMismatch {
298                        session_id: session_id.to_owned(),
299                        bundle_id: session.bundle_id.clone(),
300                        repository_id: configured.id.clone(),
301                        missing_commit,
302                        archived_origin: archived.metadata.origin.clone(),
303                        configured_origin: configured.source_label(),
304                    })
305                })
306            })
307            .collect::<Vec<Result<Option<ResumeRepositorySourceMismatch>>>>();
308        for outcome in outcomes {
309            if let Some(mismatch) = outcome? {
310                return Ok(ResumeRepositorySourcePreflight::RepositoryMoved(mismatch));
311            }
312        }
313        Ok(ResumeRepositorySourcePreflight::Ready(
314            ResumeRepositorySourceReceipt {
315                session_id: session_id.to_owned(),
316                bundle_id: session.bundle_id.clone(),
317                checkpoint_sha256: verified.checkpoint_sha256,
318                repositories: configured,
319            },
320        ))
321    }
322
323    fn repository_source_receipt_is_current(
324        &self,
325        session_id: &str,
326        receipt: &ResumeRepositorySourceReceipt,
327    ) -> bool {
328        let Some(session) = self.state.sessions.get(session_id) else {
329            return false;
330        };
331        if receipt.session_id != session_id
332            || receipt.bundle_id != session.bundle_id
333            || session
334                .checkpoint
335                .as_ref()
336                .map(|checkpoint| &checkpoint.sha256)
337                != Some(&receipt.checkpoint_sha256)
338        {
339            return false;
340        }
341        if receipt.repositories.is_empty() {
342            return true;
343        }
344        let Some(bundle) = self.config.bundles.get(&session.bundle_id) else {
345            return false;
346        };
347        receipt.repositories.iter().all(|expected| {
348            bundle
349                .repositories
350                .iter()
351                .any(|configured| configured == expected)
352        })
353    }
354
355    /// Validate a replacement first, then atomically save it and check the
356    /// remaining sources so multi-repository bundles can report the next moved
357    /// repository without ever provisioning a partial target.
358    pub fn replace_resume_repository_origin(
359        &mut self,
360        session_id: &str,
361        repository_id: &str,
362        replacement: &str,
363        executor: &(impl CommandExecutor + Sync),
364    ) -> Result<ResumeRepositorySourcePreflight> {
365        let session = self
366            .state
367            .sessions
368            .get(session_id)
369            .with_context(|| format!("unknown session {session_id}"))?;
370        let bundle_id = session.bundle_id.clone();
371        let checkpoint = session
372            .checkpoint
373            .as_ref()
374            .context("session has no checkpoint")?;
375        let replacement = replacement_repository_source(repository_id, replacement)?;
376        let repositories = read_checkpoint_repository_bundles(&checkpoint.archive_path)?;
377        let verified = ResumeRepositoryBundles {
378            checkpoint_sha256: checkpoint.sha256.clone(),
379            repositories,
380        };
381        let archived = verified
382            .repositories
383            .iter()
384            .find(|repository| repository.metadata.id == repository_id)
385            .with_context(|| format!("checkpoint does not contain repository {repository_id:?}"))?;
386        if let Some(missing_commit) = checkpoint_source_missing_commit(
387            &replacement,
388            archived,
389            executor,
390            controller_github_token().as_deref(),
391        )? {
392            return Ok(ResumeRepositorySourcePreflight::RepositoryMoved(
393                ResumeRepositorySourceMismatch {
394                    session_id: session_id.to_owned(),
395                    bundle_id,
396                    repository_id: repository_id.to_owned(),
397                    missing_commit,
398                    archived_origin: archived.metadata.origin.clone(),
399                    configured_origin: replacement.source_label(),
400                },
401            ));
402        }
403        let (config, ()) = Config::update(|config| {
404            let bundle = config
405                .bundles
406                .get_mut(&bundle_id)
407                .with_context(|| format!("session bundle {bundle_id:?} is missing"))?;
408            let repository = bundle
409                .repositories
410                .iter_mut()
411                .find(|repository| repository.id == repository_id)
412                .with_context(|| {
413                    format!(
414                        "session bundle {:?} no longer contains repository {repository_id:?}",
415                        bundle_id
416                    )
417                })?;
418            repository.github = replacement.github.clone();
419            repository.local = replacement.local.clone();
420            Ok(())
421        })?;
422        self.config = config;
423        self.preflight_verified_repository_sources(
424            session_id,
425            verified,
426            Some(repository_id),
427            false,
428            executor,
429        )
430    }
431}
432
433/// Plan a local checkout's conversion into an isolated workspace and describe
434/// it, without changing anything.
435///
436/// The resume preflight and the browser's resume card both need this answer
437/// before a person confirms, and neither owns a [`Controller`] at that point,
438/// so it takes the record and the configuration directly.
439pub fn raw_conversion_preview_for(
440    session: &SessionRecord,
441    config: &Config,
442    executor: &(impl CommandExecutor + Sync),
443) -> Result<mj_core::state::RawConversionPreview> {
444    let conversion = plan_raw_to_workspace(session, config, executor)?;
445    raw_conversion_preview(session, &conversion, executor)
446}
447
448fn replacement_repository_source(id: &str, replacement: &str) -> Result<ProjectRepository> {
449    let replacement = replacement.trim();
450    ensure!(!replacement.is_empty(), "enter the repository's new origin");
451    let expanded = mj_core::path_input::expand_local(Path::new(replacement))?;
452    let path = expanded.as_path();
453    let (github, local) = if path.is_absolute() {
454        ensure!(
455            path.is_dir(),
456            "local repository {replacement:?} is not a directory"
457        );
458        (None, Some(mj_core::local_git::canonical_repository(path)?))
459    } else {
460        let github = crate::setup::github_repository_from_origin(replacement)
461            .context("origin must be a GitHub repository or an absolute local repository path")?;
462        (
463            Some(format!("{}/{}", github.owner, github.repository)),
464            None,
465        )
466    };
467    Ok(ProjectRepository {
468        id: id.to_owned(),
469        github,
470        local,
471        destination: PathBuf::from(id),
472        git_ref: None,
473    })
474}
475
476fn checkpoint_source_missing_commit(
477    configured: &ProjectRepository,
478    archived: &CheckpointRepositoryBundle,
479    executor: &impl CommandExecutor,
480    github_token: Option<&str>,
481) -> Result<Option<String>> {
482    let staging = tempfile::tempdir().context("create repository source preflight")?;
483    let repository = staging.path().join("repository.git");
484    checked_preflight_git(
485        executor,
486        CommandSpec::new(
487            "git",
488            [
489                "init".to_owned(),
490                "--bare".to_owned(),
491                "--quiet".to_owned(),
492                repository.to_string_lossy().into_owned(),
493            ],
494        )
495        .purpose("initialize repository source preflight"),
496    )?;
497    let missing = checkpoint_bundle_prerequisites(archived)?;
498    if missing.is_empty() {
499        let bundle = staging.path().join("checkpoint.bundle");
500        std::fs::write(&bundle, &archived.committed_bundle)
501            .context("write self-contained checkpoint bundle for source preflight")?;
502        checked_preflight_git(
503            executor,
504            checkpoint_bundle_import_command(&repository, &bundle),
505        )?;
506        return Ok(None);
507    }
508    // The restore clone will obtain the reachable ancestry. This probe only
509    // needs to establish that the source still serves each boundary object, so
510    // stop at that object instead of downloading and walking its whole graph.
511    for commit in missing {
512        let output = fetch_source_commit(executor, &repository, configured, &commit, github_token)?;
513        if output.status != 0 {
514            let stderr = String::from_utf8_lossy(&output.stderr);
515            if source_does_not_have_commit(&stderr) {
516                return Ok(Some(commit));
517            }
518            bail!(
519                "could not check configured source {:?}: {}",
520                configured.source_label(),
521                stderr.trim()
522            );
523        }
524    }
525    // Do not re-index the bundle against this deliberately shallow probe: the
526    // shallow marker would make Git report artificial connectivity failures.
527    // The real restore applies it to the full source clone.
528    Ok(None)
529}
530
531fn checkpoint_bundle_import_command(repository: &Path, bundle: &Path) -> CommandSpec {
532    let mut command = CommandSpec::new(
533        "git",
534        [
535            "-C".to_owned(),
536            repository.to_string_lossy().into_owned(),
537            "fetch".to_owned(),
538            "--no-tags".to_owned(),
539            bundle.to_string_lossy().into_owned(),
540            "HEAD".to_owned(),
541        ],
542    )
543    .purpose("validate self-contained checkpoint bundle");
544    command
545        .env
546        .insert("GIT_NO_LAZY_FETCH".to_owned(), "1".to_owned());
547    command
548        .env
549        .insert("GIT_TERMINAL_PROMPT".to_owned(), "0".to_owned());
550    command
551}
552
553fn fetch_source_commit(
554    executor: &impl CommandExecutor,
555    repository: &Path,
556    configured: &ProjectRepository,
557    commit: &str,
558    github_token: Option<&str>,
559) -> Result<CommandOutput> {
560    let mut arguments = Vec::new();
561    let mut token_auth = false;
562    let mut ssh_transport = false;
563    let source = if let Some(local) = &configured.local {
564        local.to_string_lossy().into_owned()
565    } else {
566        let source = configured
567            .github
568            .as_deref()
569            .context("repository source is missing")?;
570        let github = crate::setup::github_repository_from_origin(source)
571            .context("configured repository is not a GitHub source")?;
572        if github_token.is_some() {
573            token_auth = true;
574            arguments.extend([
575                "-c".to_owned(),
576                "credential.helper=".to_owned(),
577                "-c".to_owned(),
578                "credential.helper=!f() { if [ \"$1\" = get ]; then echo username=x-access-token; echo \"password=$GH_TOKEN\"; fi; }; f".to_owned(),
579            ]);
580            format!(
581                "https://github.com/{}/{}.git",
582                github.owner, github.repository
583            )
584        } else {
585            ssh_transport = true;
586            format!("git@github.com:{}/{}.git", github.owner, github.repository)
587        }
588    };
589    arguments.extend([
590        "-C".to_owned(),
591        repository.to_string_lossy().into_owned(),
592        "fetch".to_owned(),
593        "--no-tags".to_owned(),
594        "--depth=1".to_owned(),
595        "--filter=blob:none".to_owned(),
596        source,
597        commit.to_owned(),
598    ]);
599    let mut command = CommandSpec::new("git", arguments).purpose("check checkpoint base commit");
600    command
601        .env
602        .insert("GIT_NO_LAZY_FETCH".to_owned(), "1".to_owned());
603    command
604        .env
605        .insert("GIT_TERMINAL_PROMPT".to_owned(), "0".to_owned());
606    if token_auth {
607        let token = github_token.expect("token authentication requires a GitHub token");
608        command.env.insert("GH_TOKEN".to_owned(), token.to_owned());
609    }
610    if ssh_transport {
611        command.env.insert(
612            "GIT_SSH_COMMAND".to_owned(),
613            "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15"
614                .to_owned(),
615        );
616    }
617    executor.execute(&command)
618}
619
620fn source_does_not_have_commit(stderr: &str) -> bool {
621    let stderr = stderr.to_ascii_lowercase();
622    [
623        "not our ref",
624        "couldn't find remote ref",
625        "not a valid object name",
626        "no such ref was fetched",
627    ]
628    .iter()
629    .any(|needle| stderr.contains(needle))
630}
631
632fn checked_preflight_git(
633    executor: &impl CommandExecutor,
634    command: CommandSpec,
635) -> Result<CommandOutput> {
636    let output = executor.execute(&command)?;
637    ensure!(
638        output.status == 0,
639        "{}: {}",
640        command.purpose,
641        String::from_utf8_lossy(&output.stderr).trim()
642    );
643    Ok(output)
644}
645
646impl Controller {
647    /// Resume a stopped logical session on any configured profile and
648    /// target. Cross-harness resume restores Git and canonical history, starts
649    /// a fresh native session, and supplies the prior transcript as its first
650    /// context turn.
651    pub async fn resume_session_with_options(
652        &mut self,
653        session_id: &str,
654        profile_id: &str,
655        target_id: &str,
656        additional_mounts: Option<Vec<AdditionalMount>>,
657        resource_allocation: Option<SessionResourceAllocation>,
658    ) -> Result<MaterializedSession> {
659        self.resume_session_with_options_and_queue_disposition(
660            session_id,
661            profile_id,
662            target_id,
663            additional_mounts,
664            resource_allocation,
665            false,
666        )
667        .await
668    }
669
670    pub async fn resume_session_with_options_and_queue_disposition(
671        &mut self,
672        session_id: &str,
673        profile_id: &str,
674        target_id: &str,
675        additional_mounts: Option<Vec<AdditionalMount>>,
676        resource_allocation: Option<SessionResourceAllocation>,
677        discard_queue: bool,
678    ) -> Result<MaterializedSession> {
679        self.resume_session_controlled(
680            session_id,
681            profile_id,
682            target_id,
683            SessionResumeOptions {
684                additional_mounts,
685                resource_allocation,
686                discard_queue,
687            },
688            &ProcessExecutor,
689        )
690        .await
691    }
692
693    pub async fn resume_session_controlled(
694        &mut self,
695        session_id: &str,
696        profile_id: &str,
697        target_id: &str,
698        options: SessionResumeOptions,
699        executor: &(impl CommandExecutor + Sync),
700    ) -> Result<MaterializedSession> {
701        self.resume_session_controlled_with_repository_preflight(
702            session_id, profile_id, target_id, options, None, executor,
703        )
704        .await
705    }
706
707    pub async fn resume_session_controlled_with_repository_preflight(
708        &mut self,
709        session_id: &str,
710        profile_id: &str,
711        target_id: &str,
712        options: SessionResumeOptions,
713        repository_preflight: Option<ResumeRepositorySourceReceipt>,
714        executor: &(impl CommandExecutor + Sync),
715    ) -> Result<MaterializedSession> {
716        let SessionResumeOptions {
717            additional_mounts,
718            resource_allocation,
719            discard_queue,
720        } = options;
721        let previous = self
722            .state
723            .sessions
724            .get(session_id)
725            .with_context(|| format!("unknown session {session_id}"))?
726            .clone();
727        if !matches!(
728            previous.state,
729            SessionState::Stopped | SessionState::Lost | SessionState::Error
730        ) {
731            bail!("session {session_id} is not stopped, lost, or retryable");
732        }
733        let checkpoint = previous
734            .checkpoint
735            .as_ref()
736            .context("session has no checkpoint")?;
737        // A receipt for an isolated destination says nothing about a host
738        // checkout. Explicit moves to raw execution must check that source.
739        let moving_to_raw = previous.project_directory.is_none()
740            && self
741                .config
742                .targets
743                .get(target_id)
744                .is_some_and(mj_core::config::is_bare_project_target);
745        if moving_to_raw
746            || !repository_preflight.as_ref().is_some_and(|receipt| {
747                self.repository_source_receipt_is_current(session_id, receipt)
748            })
749        {
750            let _phase = ResumePhaseTimer::new(session_id, "preflight repository sources");
751            if let ResumeRepositorySourcePreflight::RepositoryMoved(mismatch) =
752                self.preflight_repository_sources(session_id, target_id, false, executor)?
753            {
754                bail!(
755                    "checkpoint base commit {} is missing from configured source {:?} for repository {:?}; the repository may have moved (archived origin: {:?})",
756                    mismatch.missing_commit,
757                    mismatch.configured_origin,
758                    mismatch.repository_id,
759                    mismatch.archived_origin,
760                );
761            }
762        }
763        // Canonicalize before verification and keep this exact absolute path
764        // for the restore. A LocalBare worker shares the controller's
765        // filesystem, so it can consume the verified archive directly instead
766        // of copying a second large file into its worker root.
767        let archive_path = {
768            let _phase = ResumePhaseTimer::new(session_id, "verify checkpoint archive");
769            checkpoint.archive_path.canonicalize().with_context(|| {
770                format!(
771                    "resolve checkpoint archive {}",
772                    checkpoint.archive_path.display()
773                )
774            })?
775        };
776        ensure!(
777            archive_path.is_absolute() && archive_path.is_file(),
778            "checkpoint archive path is not an absolute regular file: {}",
779            archive_path.display()
780        );
781        // Take the snapshot out of the verified metadata and share it behind an
782        // `Arc`: on a long session it is tens of megabytes, and resume reads it
783        // from three places that used to hold private copies.
784        let mj_checkpoint::archive::VerifiedArchiveMetadata {
785            manifest: archive_manifest,
786            canonical_session,
787            archive_sha256,
788        } = {
789            let _phase = ResumePhaseTimer::new(session_id, "verify checkpoint archive contents");
790            verify_archive_streaming(&archive_path)?
791        };
792        if archive_sha256 != checkpoint.sha256 || archive_manifest.session.id != session_id {
793            bail!("persisted checkpoint verification failed");
794        }
795        ensure!(
796            archive_manifest.repositories.iter().all(|repository| {
797                !repository.metadata.origin.starts_with("mj-local:")
798                    && !repository.metadata.origin.starts_with("ext::")
799            }),
800            "resuming legacy host-bridge sessions is not supported; start a new network-backed session"
801        );
802        let canonical_session = Arc::new(canonical_session);
803        let profile = self
804            .config
805            .profiles
806            .get(profile_id)
807            .with_context(|| format!("unknown profile {profile_id:?}"))?
808            .clone();
809        ensure!(profile.enabled, "profile {profile_id:?} is disabled");
810        let target_template = self
811            .config
812            .targets
813            .get(target_id)
814            .with_context(|| format!("unknown target template {target_id:?}"))?
815            .clone();
816        // Decide the representation before the record changes, so an
817        // incompatible target fails here instead of during provisioning.
818        self.validate_muse_resume_destination(&previous, profile.kind, target_id)?;
819        ensure!(
820            profile.kind != HarnessKind::Muse || previous.additional_mounts.is_empty(),
821            "Muse Code ACP supports one workspace root; attached directories are unsupported"
822        );
823        let plan = resume_compatibility(&previous, &self.config, target_id)
824            .map_err(|reason| anyhow::anyhow!("{reason}"))?;
825        // A converting resume writes its own archive below, from the host
826        // checkout's own network remote, and provisioning reads that one. Every
827        // other isolated resume clones what its stored archive already names.
828        if !mj_core::config::is_bare_project_target(&target_template)
829            && plan != ResumePlan::RawToWorkspace
830        {
831            super::network_git::bundle_from_manifest(&archive_manifest)?;
832        }
833        if plan == ResumePlan::InPlace
834            && previous.managed_worktree.is_none()
835            && let Some(project_directory) = &previous.project_directory
836        {
837            self.validate_project_directory(target_id, project_directory, executor)
838                .context("raw project is unavailable for resume")?;
839        }
840        let conversion = match plan {
841            ResumePlan::InPlace => None,
842            ResumePlan::RawToWorkspace => Some(ResumeConversion::RawToWorkspace(
843                plan_raw_to_workspace(&previous, &self.config, executor)
844                    .context("prepare the raw checkout for its new target")?,
845            )),
846            ResumePlan::WorkspaceToRaw => Some(ResumeConversion::WorkspaceToRaw(
847                self.plan_workspace_to_raw(&previous, target_id, executor)
848                    .context("prepare a checkout for this session")?,
849            )),
850        };
851        let resource_allocation =
852            resource_allocation.or_else(|| previous.resource_allocation.clone());
853        let additional_mounts =
854            additional_mounts.unwrap_or_else(|| previous.additional_mounts.clone());
855        validate_resource_allocation(&target_template, resource_allocation.as_ref())?;
856        let selected_container_size =
857            selected_host_container_size(&target_template, resource_allocation.as_ref());
858        if !additional_mounts.is_empty() && mount_history_host(&target_template).is_none() {
859            bail!("attached resources are unsupported for this target");
860        }
861        targets::validate_additional_mounts(&additional_mounts)?;
862        let history_host = mount_history_host(&target_template);
863        let history_mounts = additional_mounts.clone();
864        if previous.state == SessionState::Error
865            && let Some(locator) = &previous.target
866        {
867            let backend = backend_locator(locator, &previous, &self.config)?;
868            targets::close_plan(&backend, session_id)?
869                .execute(executor)
870                .context("clean up target from failed resume")?;
871        }
872        let mut resume_notices = Vec::new();
873        // The raw-to-workspace notice is written where the conversion snapshot
874        // is taken, because it reports the branch the container arrives on.
875        if let Some(conversion) = conversion
876            .as_ref()
877            .and_then(ResumeConversion::workspace_to_raw)
878        {
879            resume_notices.push(format!(
880                "This session moved out of its {} target and into {}. Its branch {} is now {}.",
881                previous.target_template_id,
882                conversion.worktree.worktree_root.display(),
883                archive_manifest
884                    .repositories
885                    .first()
886                    .and_then(|repository| repository.metadata.branch.as_deref())
887                    .unwrap_or("a detached head"),
888                conversion.worktree.branch,
889            ));
890        }
891        let managed_checkout_present = previous
892            .managed_worktree
893            .as_ref()
894            .map(|worktree| managed_worktree_checkout_exists(executor, worktree))
895            .transpose()?
896            .unwrap_or(true);
897        // A checkout Mjolnir did not retire remains the truth for a raw session.
898        // A retired checkout is recreated from the branch and archive below.
899        if managed_checkout_present && let Some(project_directory) = &previous.project_directory {
900            match raw_checkout_position(&previous, &self.config, project_directory, executor) {
901                Ok(live) => resume_notices.extend(raw_checkout_divergence_notice(
902                    project_directory,
903                    archive_manifest
904                        .repositories
905                        .first()
906                        .map(|repository| &repository.metadata),
907                    &live,
908                )),
909                // Informational only: a resume must not fail because Mjolnir could
910                // not read where the checkout stands.
911                Err(error) => tracing::warn!(
912                    session_id,
913                    error = format!("{error:#}"),
914                    "could not read the raw checkout position for a resume notice"
915                ),
916            }
917        }
918        // Resolving the worker binary is local and costs microseconds, while
919        // the compaction below costs minutes and paid model requests. A resume
920        // that could never install a worker fails here rather than after all
921        // that work has been thrown away.
922        super::worker_binary::preflight_worker_binary(&target_template)?;
923        let same_harness = profile.kind == archive_manifest.session.harness_kind;
924        let native_continuity =
925            native_continuity_preserved(profile.kind, archive_manifest.session.harness_kind);
926        let context_bytes = crate::handoff::profile_handoff_bytes(Some(&profile));
927        // Cross-harness compaction is started alongside destination
928        // provisioning below. Clone only the configuration it reads so the
929        // controller can continue owning and mutating its session record.
930        let utility_config = (!native_continuity).then(|| self.config.clone());
931        let discard_queued_prompts = discard_queue || !same_harness;
932        // When this controller archived the session, its durable projection is
933        // already the archive's content. Reading one row decides that; a read
934        // failure or any mismatch rebuilds as before.
935        let stored_frontier = crate::database::materialized_event_frontier(session_id)
936            .unwrap_or_else(|error| {
937                tracing::warn!(
938                    session_id,
939                    error = format!("{error:#}"),
940                    "could not read the stored projection frontier; rebuilding it from the archive"
941                );
942                None
943            });
944        let rebuild_projection = projection_rebuild_required(
945            stored_frontier
946                .as_ref()
947                .map(|(ordinal, digest)| (*ordinal, digest.as_str())),
948            canonical_session.event_frontier,
949            &canonical_session.event_frontier_digest,
950        );
951        // Rebuilding the projection is a pure function of the archive and costs
952        // seconds on a long session. Start it now so it runs while the target is
953        // being provisioned; its result is awaited where it was consumed
954        // before, and the writes it feeds have not moved.
955        //
956        // A resume that fails before the result is needed drops the handle.
957        // `spawn_blocking` work cannot be cancelled, so the computation still
958        // finishes on the blocking pool and its result is discarded; it owns
959        // nothing but its own inputs, so nothing leaks beyond that CPU.
960        let projection_build = rebuild_projection.then(|| {
961            let canonical = Arc::clone(&canonical_session);
962            let session_id = session_id.to_owned();
963            tokio::task::spawn_blocking(move || {
964                materialized_session_from_canonical(session_id, &canonical)
965            })
966        });
967        let github_token = controller_github_token();
968
969        // The configuration gains the bundle before the record points at it, so
970        // no persisted session ever names a bundle that is not there.
971        if let Some(conversion) = conversion
972            .as_ref()
973            .and_then(ResumeConversion::raw_to_workspace)
974            && let Some(bundle) = &conversion.new_bundle
975        {
976            let (config, ()) = Config::update(|config| {
977                if let Some(existing) = config.bundles.get(&conversion.bundle_id) {
978                    ensure!(
979                        existing == bundle,
980                        "bundle {:?} was configured concurrently with a different definition; retry the resume",
981                        conversion.bundle_id
982                    );
983                } else {
984                    config
985                        .bundles
986                        .insert(conversion.bundle_id.clone(), bundle.clone());
987                }
988                Ok(())
989            })
990            .context("save the bundle for a converted raw session")?;
991            self.config = config;
992        }
993
994        // A session that leaves a non-container target for a container one is
995        // built a container it never had, so it starts using the per-session
996        // workspace path here if it predates them. A session that already ran
997        // in a container keeps its path: the harness's recorded working
998        // directory has to survive the resume.
999        let moving_into_first_container = self
1000            .config
1001            .targets
1002            .get(target_id)
1003            .is_some_and(mj_core::config::is_container_target)
1004            && !self
1005                .config
1006                .targets
1007                .get(&previous.target_template_id)
1008                .is_some_and(mj_core::config::is_container_target);
1009        let record = self.state.sessions.get_mut(session_id).unwrap();
1010        if record.container_workspace.is_none() && moving_into_first_container {
1011            record.container_workspace = Some(targets::new_container_workspace(session_id)?);
1012        }
1013        record.harness_kind = profile.kind;
1014        record.last_profile = profile_id.to_string();
1015        record.target_template_id = target_id.to_string();
1016        record.resource_allocation = resource_allocation;
1017        record.additional_mounts = additional_mounts;
1018        record.target = None;
1019        record.native_session_id =
1020            native_continuity.then(|| archive_manifest.session.native_session_id.clone());
1021        record.state = SessionState::Provisioning;
1022        record.updated_at = now();
1023        record.last_error = None;
1024        match &conversion {
1025            Some(ResumeConversion::RawToWorkspace(conversion)) => {
1026                apply_raw_to_workspace(record, conversion);
1027            }
1028            Some(ResumeConversion::WorkspaceToRaw(conversion)) => {
1029                apply_workspace_to_raw(record, conversion);
1030            }
1031            None => {}
1032        }
1033        let resumed_project_directory = record.project_directory.clone();
1034        let resumed_container_workspace = record.container_workspace.clone();
1035        if let Some(host) = history_host {
1036            self.state.remember_mount_sources(host, &history_mounts);
1037            crate::database::remember_mount_sources(host, &history_mounts)?;
1038        }
1039        // The session's prompt history is filed under its bundle, so a
1040        // conversion moves the history with it before the record is persisted.
1041        if let Some(conversion) = conversion
1042            .as_ref()
1043            .and_then(ResumeConversion::raw_to_workspace)
1044        {
1045            crate::database::rebind_session_bundle(session_id, &conversion.bundle_id)?;
1046        }
1047        // Resume rewrites the record it resumes, including the attached
1048        // directories and the harness session id, so it writes the whole row.
1049        if let Some((host, size)) = selected_container_size.as_ref() {
1050            crate::database::save_session_with_container_size(
1051                &self.state.sessions[session_id],
1052                host,
1053                *size,
1054            )?;
1055        } else {
1056            crate::database::save_session(&self.state.sessions[session_id])?;
1057        }
1058        if let Some((host, size)) = selected_container_size.as_ref() {
1059            self.state.remember_container_size(host, *size);
1060        }
1061
1062        let mut recreated_managed_worktree = false;
1063        // Set once a conversion has written its archive, so the success path can
1064        // retire the archive it replaced and the failure path can remove it.
1065        let mut conversion_checkpoint_written: Option<mj_core::state::CheckpointMetadata> = None;
1066        let result = async {
1067            if let Some(worktree) = previous.managed_worktree.as_ref() {
1068                recreated_managed_worktree = restore_managed_worktree(executor, worktree)?;
1069                if recreated_managed_worktree && plan == ResumePlan::RawToWorkspace {
1070                    mj_checkpoint::checkpoint::restore_single_repository_onto_branch(
1071                        &archive_path,
1072                        &worktree.worktree_root,
1073                        &worktree.branch,
1074                        &SystemGit,
1075                    )
1076                    .context("restore the retired checkout before moving it into a target")?;
1077                }
1078            }
1079            // The record already names the worktree, so a failure here rolls
1080            // back through the same path that cleans up a new session's.
1081            if let Some(conversion) = conversion
1082                .as_ref()
1083                .and_then(ResumeConversion::workspace_to_raw)
1084            {
1085                if conversion.reuse_existing_branch {
1086                    let recovery_ref =
1087                        preserve_retained_managed_worktree_branch(executor, &conversion.worktree)?;
1088                    restore_managed_worktree(executor, &conversion.worktree)?;
1089                    resume_notices.push(format!(
1090                        "Before restoring this session's retained branch, Mjolnir preserved its tip at {recovery_ref}."
1091                    ));
1092                } else {
1093                    create_managed_worktree(
1094                        executor,
1095                        &conversion.worktree,
1096                        None,
1097                        PrimaryCheckoutRequirement::Any,
1098                    )?;
1099                }
1100                mj_checkpoint::checkpoint::restore_single_repository_onto_branch(
1101                    &archive_path,
1102                    &conversion.worktree.worktree_root,
1103                    &conversion.worktree.branch,
1104                    &SystemGit,
1105                )
1106                .context("restore this session's checkout")?;
1107            }
1108            // A local checkout becomes an isolated workspace by being
1109            // re-snapshotted into a new archive whose provenance is the
1110            // checkout's own network remote. Provisioning clones that remote,
1111            // and the restore below lays this snapshot over the fresh clone.
1112            if let Some(conversion) = conversion
1113                .as_ref()
1114                .and_then(ResumeConversion::raw_to_workspace)
1115            {
1116                let destination = PathBuf::from(
1117                    previous
1118                        .project_directory
1119                        .as_deref()
1120                        .context("a raw session has no project directory")?
1121                        .file_name()
1122                        .context("a raw project directory cannot be the filesystem root")?,
1123                );
1124                let snapshot = raw_checkout_snapshot(
1125                    &conversion.checkout,
1126                    &conversion.source,
1127                    &destination,
1128                    &SystemGit,
1129                )
1130                .context("snapshot the host checkout for its new target")?;
1131                resume_notices.push(conversion_notice(
1132                    target_id,
1133                    previous
1134                        .project_directory
1135                        .as_deref()
1136                        .unwrap_or(&conversion.checkout),
1137                    snapshot.metadata.branch.as_deref(),
1138                    conversion.retire.as_ref(),
1139                ));
1140                let archives = mj_core::config::sessions_dir();
1141                std::fs::create_dir_all(&archives).with_context(|| {
1142                    format!("create the checkpoint directory {}", archives.display())
1143                })?;
1144                // Named like every other managed archive, so an interrupted
1145                // conversion's file is reconciled away, with `converted`
1146                // marking where it came from.
1147                let output = archives.join(format!(
1148                    "{session_id}-converted-{}-{}.hel.zip",
1149                    previous
1150                        .checkpoint
1151                        .as_ref()
1152                        .map_or(0, |checkpoint| checkpoint.event_frontier),
1153                    new_command_id("archive")?
1154                ));
1155                let written = conversion_checkpoint(&archive_path, snapshot, &output)?;
1156                conversion_checkpoint_written = Some(written.clone());
1157                let record = self.state.sessions.get_mut(session_id).unwrap();
1158                record.checkpoint = Some(written);
1159                record.updated_at = now();
1160                // The whole row again: provisioning must read the converted
1161                // archive even if this process dies right here.
1162                if let Some((host, size)) = selected_container_size.as_ref() {
1163                    crate::database::save_session_with_container_size(
1164                        &self.state.sessions[session_id],
1165                        host,
1166                        *size,
1167                    )?;
1168                } else {
1169                    crate::database::save_session(&self.state.sessions[session_id])?;
1170                }
1171            }
1172            let utility_handoff = {
1173                let _provisioning = ResumePhaseTimer::new(session_id, "provision destination");
1174                if let Some(config) = utility_config.as_ref() {
1175                    Some(
1176                        provision_with_cross_harness_handoff(
1177                            self,
1178                            session_id,
1179                            executor,
1180                            github_token.as_deref(),
1181                            config,
1182                            &canonical_session,
1183                            context_bytes,
1184                        )
1185                        .context("prepare the cross-harness destination")?,
1186                    )
1187                } else {
1188                    self.provision_session_with_failure_disposition(
1189                        session_id,
1190                        executor,
1191                        github_token.as_deref(),
1192                        ProvisioningFailureDisposition::Preserve,
1193                    )
1194                    .await?;
1195                    None
1196                }
1197            };
1198            let (backend, worker_root) = self.worker_placement(session_id)?;
1199            let harness_home = target_profile_home(&backend, session_id, &profile);
1200            let workspace_root = if let Some(project_directory) = &resumed_project_directory {
1201                project_directory
1202                    .parent()
1203                    .context("bare project directory has no parent")?
1204                    .to_string_lossy()
1205                    .into_owned()
1206            } else {
1207                super::network_git::workspace_root(&backend, resumed_container_workspace.as_deref())
1208            };
1209            let target_path = |path: &str| match &backend {
1210                targets::TargetLocator::AwsEc2 { .. }
1211                | targets::TargetLocator::SshBare { .. }
1212                    if !path.starts_with('/') =>
1213                {
1214                    PathBuf::from(format!("~/{path}"))
1215                }
1216                _ => PathBuf::from(path),
1217            };
1218            let remote_archive = format!("{worker_root}/restore.hel.zip");
1219            let remote_spec = format!("{worker_root}/restore-spec.json");
1220            // A conversion restores the archive it just wrote, not the raw one
1221            // the session was stopped with.
1222            let restored_archive = conversion_checkpoint_written
1223                .as_ref()
1224                .map_or(archive_path.as_path(), |checkpoint| {
1225                    checkpoint.archive_path.as_path()
1226                });
1227            let restore = CheckpointRestoreSpec {
1228                archive_path: restore_archive_path(
1229                    &backend,
1230                    restored_archive,
1231                    &target_path(&remote_archive),
1232                ),
1233                workspace_root: target_path(&workspace_root),
1234                relay_root: target_path(&worker_root),
1235                harness_home: target_path(&harness_home),
1236                // A local checkout converting into a workspace arrives as a
1237                // fresh clone of its own remote, and the conversion archive
1238                // carries the commits, dirty files, and branch that go over it.
1239                // An in-place managed checkout recreated from its retained
1240                // branch still needs the archive's dirty state.
1241                restore_repositories: (resumed_project_directory.is_none()
1242                    && conversion.is_none())
1243                    || plan == ResumePlan::RawToWorkspace
1244                    || (recreated_managed_worktree && plan == ResumePlan::InPlace),
1245                restore_native: native_continuity,
1246                // A move onto a checkout puts it somewhere the archive could
1247                // not have named, so the restored harness session is pointed at
1248                // the real working directory instead of the archived one. A
1249                // move into a target has no host directory left, and the
1250                // conversion archive already names the destination under
1251                // `/workspace`, so this stays empty there.
1252                primary_repository_root: conversion
1253                    .is_some()
1254                    .then(|| resumed_project_directory.clone())
1255                    .flatten()
1256                    .map(|directory| target_path(&directory.to_string_lossy())),
1257                discard_queued_prompts,
1258            };
1259            // A bare target keeps the closed session's worker root on the host.
1260            // Stop anything still writing there and clear the leftover relay
1261            // state, or the restore's seed loses to a stale snapshot whose
1262            // frontier no journal can support. This runs before the worker
1263            // binary is installed: a surviving daemon still holds the old one
1264            // open, and the install would land on a running executable.
1265            {
1266                let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
1267                if let Some(command) = targets::clear_relay_state_plan(&backend, session_id)? {
1268                    execute_checked(syncing, command)?;
1269                }
1270                // Both lanes below write into the worker root, so it exists first.
1271                execute_checked(
1272                    syncing,
1273                    targets::command_on_locator(
1274                        &backend,
1275                        session_id,
1276                        vec!["mkdir".into(), "-p".into(), worker_root.clone()],
1277                        "create the session worker root",
1278                    )?,
1279                )?;
1280            }
1281            let staging = tempfile::tempdir().context("create restore staging")?;
1282            let local_spec = staging.path().join("restore-spec.json");
1283            std::fs::write(&local_spec, serde_json::to_vec_pretty(&restore)?)?;
1284            // Two independent lanes into the target. The checkpoint transfer
1285            // needs nothing from the worker install, and the worker install
1286            // is independent of archive upload, so both run concurrently.
1287            let controller = &*self;
1288            let backend_ref = &backend;
1289            let worker_root_ref = worker_root.as_str();
1290            let local_spec_ref = local_spec.as_path();
1291            execute_concurrent_lanes(
1292                || {
1293                    let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
1294                    controller.prepare_worker_files(
1295                        session_id,
1296                        backend_ref,
1297                        worker_root_ref,
1298                        syncing,
1299                    )?;
1300                    super::provisioning::install_inherited_git_settings(
1301                        syncing,
1302                        backend_ref,
1303                        session_id,
1304                    )?;
1305                    Ok(())
1306                },
1307                || {
1308                    let restoring = &StagedExecutor::new(executor, ProvisionStage::Restoring);
1309                    if should_upload_restore_archive(&backend) {
1310                        upload_checkpoint_spec(
1311                            restoring,
1312                            backend_ref,
1313                            session_id,
1314                            restored_archive,
1315                            &remote_archive,
1316                        )?;
1317                    }
1318                    upload_checkpoint_spec(
1319                        restoring,
1320                        backend_ref,
1321                        session_id,
1322                        local_spec_ref,
1323                        &remote_spec,
1324                    )
1325                },
1326            )?;
1327            {
1328                let restoring = &StagedExecutor::new(executor, ProvisionStage::Restoring);
1329                execute_checked(
1330                    restoring,
1331                    restore_command(&backend, session_id, &remote_spec)?,
1332                )?;
1333            }
1334            {
1335                let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
1336                install_attached_resources(
1337                    &self.state,
1338                    session_id,
1339                    &backend,
1340                    &worker_root,
1341                    syncing,
1342                )?;
1343
1344            }
1345            match projection_build {
1346                Some(build) => {
1347                    let mut restored_projection = build
1348                        .await
1349                        .context("rebuild the restored projection")?
1350                        .context("rebuild the restored projection")?;
1351                    if discard_queued_prompts {
1352                        restored_projection.queued_prompts.clear();
1353                    }
1354                    crate::database::save_materialized_session(&restored_projection)?;
1355                }
1356                // The stored projection already is the archived one. Only the
1357                // queue can still need changing.
1358                None if discard_queued_prompts => {
1359                    crate::database::replace_materialized_queued_prompts(session_id, &[])?;
1360                }
1361                None => {}
1362            }
1363            let readiness_stage = bridge_readiness_stage(&profile);
1364            let spec = self.reconnect_command(session_id)?;
1365            let readiness = async {
1366                let mut relay = {
1367                    let _starting = ProvisionStageGuard::new(executor, ProvisionStage::Starting);
1368                    start_worker(executor, &backend, &worker_root)?;
1369                    connect_started_worker(&spec, session_id, executor, &backend, &worker_root)
1370                        .await?
1371                };
1372                let native_session_id =
1373                    wait_for_native_session_in_stage(&mut relay, executor, readiness_stage).await?;
1374                Ok::<_, anyhow::Error>((relay, native_session_id))
1375            }
1376            .await;
1377            let (mut relay, native_session_id) = readiness
1378                .map_err(|error| worker_probe_diagnosis(executor, &backend, &worker_root, error))?;
1379            if native_continuity {
1380                if native_session_id != archive_manifest.session.native_session_id {
1381                    bail!(
1382                        "ACP loaded native session {native_session_id}, expected {}",
1383                        archive_manifest.session.native_session_id
1384                    );
1385                }
1386            } else {
1387                relay
1388                    .install_prompt_context(
1389                        utility_handoff
1390                            .clone()
1391                            .context("a resume into a fresh native session has no handoff")?,
1392                    )
1393                    .await?;
1394                if !discard_queue {
1395                    for prompt in &canonical_session.queued_prompts {
1396                        // A queued configuration change is replayed as itself;
1397                        // rebuilding it as a prompt would send `/model x` to
1398                        // the agent as text.
1399                        let command = match &prompt.kind {
1400                            CanonicalQueuedCommandKind::Prompt => RelayCommand::Prompt {
1401                                prompt: prompt
1402                                    .content
1403                                    .iter()
1404                                    .cloned()
1405                                    .map(serde_json::from_value)
1406                                    .collect::<serde_json::Result<Vec<ContentBlock>>>()?,
1407                            },
1408                            CanonicalQueuedCommandKind::SetConfig { key, value } => {
1409                                RelayCommand::SetConfig {
1410                                    key: key.clone(),
1411                                    value: value.clone(),
1412                                }
1413                            }
1414                        };
1415                        relay.submit(prompt.command_id.clone(), command).await?;
1416                    }
1417                }
1418            }
1419            // Last, and only once the resume has otherwise succeeded: a failure
1420            // before this point rolls the record back to a session whose
1421            // worktree still has to be there.
1422            if let Some(worktree) = conversion
1423                .as_ref()
1424                .and_then(ResumeConversion::raw_to_workspace)
1425                .and_then(|plan| plan.retire.as_ref())
1426                && let Err(error) = retire_managed_worktree(executor, worktree)
1427            {
1428                tracing::warn!(
1429                    session_id,
1430                    worktree = %worktree.worktree_root.display(),
1431                    error = format!("{error:#}"),
1432                    "could not retire the old managed worktree after resume"
1433                );
1434                resume_notices.push(worktree_cleanup_notice(&worktree.worktree_root, &error));
1435            }
1436            for notice in &resume_notices {
1437                let submitted = async {
1438                    let command_id = new_command_id("resume-notice")?;
1439                    relay
1440                        .submit(
1441                            command_id,
1442                            RelayCommand::RecordNotice {
1443                                text: notice.clone(),
1444                            },
1445                        )
1446                        .await
1447                }
1448                .await;
1449                // The conversation line is a courtesy. A relay that refuses it
1450                // has not damaged the resume, so report and carry on.
1451                if let Err(error) = submitted {
1452                    tracing::warn!(
1453                        session_id,
1454                        error = format!("{error:#}"),
1455                        "could not record a resume notice in the conversation"
1456                    );
1457                }
1458            }
1459            self.mark_worker_connected(session_id, Some(native_session_id))?;
1460            Ok::<_, anyhow::Error>(relay.sync().await?.materialized)
1461        }
1462        .await;
1463        match result {
1464            Ok(materialized) => {
1465                // The session now runs from the conversion archive, so the raw
1466                // one it replaced can go.
1467                if let Some(written) = &conversion_checkpoint_written {
1468                    super::checkpoint::prune_replaced_checkpoint(
1469                        previous.checkpoint.as_ref(),
1470                        written,
1471                    );
1472                }
1473                Ok(materialized)
1474            }
1475            Err(error) => {
1476                // The rollback puts back a record that names the previous
1477                // archive, so the conversion's archive is nothing but litter.
1478                if let Some(written) = &conversion_checkpoint_written
1479                    && let Err(remove_error) = std::fs::remove_file(&written.archive_path)
1480                    && remove_error.kind() != std::io::ErrorKind::NotFound
1481                {
1482                    tracing::warn!(
1483                        session_id,
1484                        path = %written.archive_path.display(),
1485                        "could not remove the conversion checkpoint after resume failed: {remove_error}"
1486                    );
1487                }
1488                // Put back whatever this resume could have written to the
1489                // durable projection. Both branches restore archived content,
1490                // so they are correct whether or not the write had happened
1491                // when the resume failed.
1492                if rebuild_projection {
1493                    match materialized_session_from_canonical(session_id, &canonical_session) {
1494                        Ok(previous_projection) => {
1495                            if let Err(restore_error) =
1496                                crate::database::save_materialized_session(&previous_projection)
1497                            {
1498                                tracing::error!(
1499                                    session_id,
1500                                    error = format!("{restore_error:#}"),
1501                                    "could not restore the durable projection after resume failed"
1502                                );
1503                            }
1504                        }
1505                        Err(restore_error) => {
1506                            tracing::error!(
1507                                session_id,
1508                                error = format!("{restore_error:#}"),
1509                                "could not rebuild the durable projection after resume failed"
1510                            );
1511                        }
1512                    }
1513                } else if discard_queued_prompts
1514                    && let Err(restore_error) = crate::database::replace_materialized_queued_prompts(
1515                        session_id,
1516                        &mj_transcript::projection::materialized_queued_prompts_from_canonical(
1517                            &canonical_session.queued_prompts,
1518                        ),
1519                    )
1520                {
1521                    tracing::error!(
1522                        session_id,
1523                        error = format!("{restore_error:#}"),
1524                        "could not restore queued prompts after resume failed"
1525                    );
1526                }
1527                Err(self.rollback_failed_resume(
1528                    session_id,
1529                    &previous,
1530                    recreated_managed_worktree,
1531                    error,
1532                    executor,
1533                )?)
1534            }
1535        }
1536    }
1537
1538    pub(super) fn rollback_failed_resume(
1539        &mut self,
1540        session_id: &str,
1541        previous: &SessionRecord,
1542        recreated_managed_worktree: bool,
1543        error: anyhow::Error,
1544        _executor: &impl CommandExecutor,
1545    ) -> Result<anyhow::Error> {
1546        let current = self
1547            .state
1548            .sessions
1549            .get(session_id)
1550            .with_context(|| format!("unknown session {session_id}"))?
1551            .clone();
1552        let cleanup = match current.target.as_ref() {
1553            Some(locator) => (|| -> Result<()> {
1554                let backend = backend_locator(locator, &current, &self.config)?;
1555                targets::close_plan(&backend, session_id)?
1556                    // Use a fresh executor: cancellation applies to the
1557                    // requested operation, not to its compensating cleanup.
1558                    .execute(&CancellableProcessExecutor::with_timeout(
1559                        Duration::from_secs(15),
1560                    ))
1561                    .map(|_| ())
1562            })(),
1563            None => Ok(()),
1564        };
1565        // A failed target teardown may leave its harness writing. Keep its
1566        // checkout intact until a later retry proves the process is stopped.
1567        let worktree_cleanup = if cleanup.is_err() {
1568            Ok(())
1569        } else {
1570            match (
1571                current.managed_worktree.as_ref(),
1572                previous.managed_worktree.as_ref(),
1573            ) {
1574                (_, Some(previous)) if recreated_managed_worktree => retire_managed_worktree(
1575                    &CancellableProcessExecutor::with_timeout(Duration::from_secs(15)),
1576                    previous,
1577                ),
1578                (Some(current), Some(previous)) if current == previous => Ok(()),
1579                (Some(worktree), _) => cleanup_managed_worktree(
1580                    &CancellableProcessExecutor::with_timeout(Duration::from_secs(15)),
1581                    worktree,
1582                ),
1583                (None, _) => Ok(()),
1584            }
1585        };
1586        let cleanup_error = [cleanup, worktree_cleanup]
1587            .into_iter()
1588            .filter_map(Result::err)
1589            .map(|cleanup_error| format!("{cleanup_error:#}"))
1590            .collect::<Vec<_>>()
1591            .join("; ");
1592        if !cleanup_error.is_empty() {
1593            tracing::warn!(
1594                session_id,
1595                error = %cleanup_error,
1596                "resume rollback cleanup reported failures"
1597            );
1598        }
1599        let original = format!("{error:#}");
1600        let record = self.state.sessions.get_mut(session_id).unwrap();
1601        let failure = apply_failed_resume_rollback(
1602            record,
1603            previous,
1604            &original,
1605            (!cleanup_error.is_empty()).then_some(cleanup_error),
1606        );
1607        // A conversion filed the session's prompt history under its new bundle.
1608        // The record went back, so the history goes back with it.
1609        if record.bundle_id != current.bundle_id {
1610            let bundle_id = record.bundle_id.clone();
1611            crate::database::rebind_session_bundle(session_id, &bundle_id)?;
1612        }
1613        // The rollback restores the record the resume replaced, attached
1614        // directories included, so it writes the whole row back.
1615        crate::database::save_session(&self.state.sessions[session_id])?;
1616        Ok(failure)
1617    }
1618}
1619
1620fn worktree_cleanup_notice(worktree_root: &Path, error: &anyhow::Error) -> String {
1621    format!(
1622        "Mjolnir could not remove the worktree at {}: {error:#}. Remove it with `git worktree remove --force {}`.",
1623        worktree_root.display(),
1624        worktree_root.display()
1625    )
1626}
1627
1628pub(super) fn apply_failed_resume_rollback(
1629    current: &mut SessionRecord,
1630    previous: &SessionRecord,
1631    original_error: &str,
1632    cleanup_error: Option<String>,
1633) -> anyhow::Error {
1634    match cleanup_error {
1635        None => {
1636            *current = previous.clone();
1637            current.state = SessionState::Stopped;
1638            current.target = None;
1639            current.updated_at = now();
1640            current.last_error = Some(format!("resume failed: {original_error}"));
1641            anyhow::anyhow!(original_error.to_owned())
1642        }
1643        Some(cleanup_error) => {
1644            let failure = format!(
1645                "{original_error}; cleanup of the partial resume target failed: {cleanup_error}"
1646            );
1647            // Keep the exact partial target and checkout ownership until
1648            // cleanup succeeds; the harness may still be writing there.
1649            // A container conversion has no new managed host checkout, so
1650            // retain the original host checkout that it has not retired yet.
1651            if current.managed_worktree.is_none() {
1652                current
1653                    .project_directory
1654                    .clone_from(&previous.project_directory);
1655                current
1656                    .managed_worktree
1657                    .clone_from(&previous.managed_worktree);
1658                current.bundle_id.clone_from(&previous.bundle_id);
1659            }
1660            current.state = SessionState::Error;
1661            current.updated_at = now();
1662            current.last_error = Some(format!("resume failed: {failure}"));
1663            anyhow::anyhow!(failure)
1664        }
1665    }
1666}
1667
1668/// What the conversation is told when a local session moves into a target.
1669fn conversion_notice(
1670    target_id: &str,
1671    checkout: &Path,
1672    branch: Option<&str>,
1673    retire: Option<&mj_core::state::ManagedWorktree>,
1674) -> String {
1675    let branch = branch.unwrap_or("a detached head");
1676    match retire {
1677        Some(worktree) => format!(
1678            "This session moved out of {} and into the {target_id} target, where its checkout is on {branch}. Its branch {} stays in {}.",
1679            checkout.display(),
1680            worktree.branch,
1681            worktree.source_repository.display()
1682        ),
1683        None => format!(
1684            "This session moved out of {} and into the {target_id} target, where its checkout is on {branch}. The checkout on this machine stays where it is.",
1685            checkout.display()
1686        ),
1687    }
1688}
1689
1690/// The archive a converting resume provisions and restores from: the previous
1691/// archive's session, conversation, and native state, with the host checkout's
1692/// snapshot as its only repository.
1693///
1694/// The snapshot carries network provenance, so this archive is what lets the
1695/// destination clone a real remote and later checkpoint like any other
1696/// isolated session.
1697fn conversion_checkpoint(
1698    previous_archive: &Path,
1699    snapshot: mj_checkpoint::archive::RepositorySnapshot,
1700    output: &Path,
1701) -> Result<mj_core::state::CheckpointMetadata> {
1702    let previous = mj_checkpoint::archive::read_archive_verified(previous_archive)
1703        .with_context(|| format!("read checkpoint archive {}", previous_archive.display()))?;
1704    // Native harness state and relay attachments travel byte for byte: a
1705    // conversion replaces repository content and nothing else.
1706    let native_artifacts = previous
1707        .manifest
1708        .payloads
1709        .iter()
1710        .filter_map(|descriptor| match &descriptor.role {
1711            mj_checkpoint::archive::PayloadRole::NativeArtifact { relative_path } => {
1712                Some((relative_path, descriptor))
1713            }
1714            _ => None,
1715        })
1716        .map(|(relative_path, descriptor)| {
1717            Ok(mj_checkpoint::archive::NativeArtifact {
1718                relative_path: relative_path.clone(),
1719                data: previous.payload(descriptor)?.to_vec(),
1720                mode: descriptor.mode,
1721            })
1722        })
1723        .collect::<Result<Vec<_>>>()?;
1724    let canonical_session = previous.canonical_session()?;
1725    let event_frontier = canonical_session.event_frontier;
1726    let written = mj_checkpoint::archive::write_archive_atomic(
1727        output,
1728        &mj_checkpoint::archive::ArchiveInput {
1729            session: previous.manifest.session.clone(),
1730            // Provenance for a person reading the archive; a restore reads
1731            // nothing from it, so the target it was captured on stands.
1732            target: previous.manifest.target.clone(),
1733            bundle: mj_checkpoint::archive::BundleManifest {
1734                id: previous.manifest.bundle.id.clone(),
1735                // A restore finds the primary repository by this id, and with
1736                // it the working directory the native transcript is rewritten
1737                // to, so it has to name the snapshot.
1738                primary_repository: snapshot.metadata.id.clone(),
1739            },
1740            canonical_session,
1741            native_artifacts,
1742            repositories: vec![snapshot],
1743        },
1744    )
1745    .with_context(|| format!("write the conversion archive {}", output.display()))?;
1746    Ok(mj_core::state::CheckpointMetadata {
1747        archive_path: output.to_path_buf(),
1748        sha256: written.archive_sha256,
1749        created_at: now(),
1750        event_frontier,
1751    })
1752}
1753
1754/// Whether a resume has to rebuild the durable projection from its archive.
1755///
1756/// The projection is a deterministic fold of the relay event chain, so a stored
1757/// projection standing at the archive's frontier *and* carrying the archive's
1758/// frontier digest already holds the archived content: same chain, same
1759/// ordinal, same result. Anything else - no stored row, a different ordinal, a
1760/// different digest, or a frontier that could not be read - rebuilds.
1761fn projection_rebuild_required(
1762    stored: Option<(u64, &str)>,
1763    archive_frontier: u64,
1764    archive_frontier_digest: &str,
1765) -> bool {
1766    stored != Some((archive_frontier, archive_frontier_digest))
1767}
1768
1769fn restore_archive_path(
1770    backend: &targets::TargetLocator,
1771    verified_archive: &Path,
1772    remote_archive: &Path,
1773) -> PathBuf {
1774    if matches!(backend, targets::TargetLocator::LocalBare { .. }) {
1775        verified_archive.to_path_buf()
1776    } else {
1777        remote_archive.to_path_buf()
1778    }
1779}
1780
1781fn should_upload_restore_archive(backend: &targets::TargetLocator) -> bool {
1782    !matches!(backend, targets::TargetLocator::LocalBare { .. })
1783}
1784
1785/// Provisioning currently performs its target plan synchronously inside an
1786/// async function. Run the network-bound cross-harness handoff on a joined
1787/// side runtime so it can make progress during that plan without borrowing
1788/// the mutable controller or leaving work behind on failure.
1789struct CrossHarnessProvisionExecutor<'a, E: CommandExecutor + ?Sized> {
1790    inner: &'a E,
1791    cancellation: CancellationToken,
1792}
1793
1794impl<E: CommandExecutor + ?Sized> CommandExecutor for CrossHarnessProvisionExecutor<'_, E> {
1795    fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1796        if self.cancellation.is_cancelled() {
1797            bail!("operation cancelled while provisioning destination");
1798        }
1799        self.inner.execute(command)
1800    }
1801
1802    fn cancellation_requested(&self) -> bool {
1803        self.cancellation.is_cancelled() || self.inner.cancellation_requested()
1804    }
1805
1806    fn stage_started(&self, stage: ProvisionStage) {
1807        self.inner.stage_started(stage);
1808    }
1809
1810    fn stage_finished(&self, stage: ProvisionStage) {
1811        self.inner.stage_finished(stage);
1812    }
1813
1814    fn notify_notice(&self, notice: &str) {
1815        self.inner.notify_notice(notice);
1816    }
1817
1818    fn execute_with_stdin(
1819        &self,
1820        command: &CommandSpec,
1821        input: &mut (dyn std::io::Read + Send),
1822    ) -> Result<CommandOutput> {
1823        if self.cancellation.is_cancelled() {
1824            bail!("operation cancelled while provisioning destination");
1825        }
1826        self.inner.execute_with_stdin(command, input)
1827    }
1828}
1829
1830fn provision_with_cross_harness_handoff(
1831    controller: &mut Controller,
1832    session_id: &str,
1833    executor: &(impl CommandExecutor + Sync),
1834    github_token: Option<&str>,
1835    config: &Config,
1836    snapshot: &CanonicalSessionSnapshot,
1837    context_bytes: usize,
1838) -> Result<String> {
1839    let (_provision, handoff) = execute_joined_cross_harness_work(
1840        "cross-harness provisioning",
1841        move |cancellation| {
1842            let provision_executor = CrossHarnessProvisionExecutor {
1843                inner: executor,
1844                cancellation,
1845            };
1846            futures::executor::block_on(controller.provision_session_with_failure_disposition(
1847                session_id,
1848                &provision_executor,
1849                github_token,
1850                ProvisioningFailureDisposition::Preserve,
1851            ))
1852        },
1853        "cross-harness handoff",
1854        move |cancellation| {
1855            let runtime = tokio::runtime::Builder::new_current_thread()
1856                .enable_all()
1857                .build()
1858                .context("create cross-harness handoff runtime")?;
1859            runtime.block_on(utility_handoff_while_cancellable(
1860                session_id,
1861                config,
1862                snapshot,
1863                context_bytes,
1864                executor,
1865                cancellation,
1866            ))
1867        },
1868    )?;
1869    ensure!(
1870        !executor.cancellation_requested(),
1871        "operation cancelled while provisioning destination"
1872    );
1873    Ok(handoff)
1874}
1875
1876/// Run the two independent cross-harness lanes together, cancelling and
1877/// joining the peer as soon as either lane fails. The lane order is stable so
1878/// diagnostics do not depend on which worker happened to finish first.
1879fn execute_joined_cross_harness_work<A: Send, B: Send>(
1880    first_name: &'static str,
1881    first: impl FnOnce(CancellationToken) -> Result<A> + Send,
1882    second_name: &'static str,
1883    second: impl FnOnce(CancellationToken) -> Result<B> + Send,
1884) -> Result<(A, B)> {
1885    let cancellation = CancellationToken::new();
1886    std::thread::scope(|scope| {
1887        let first_cancel = cancellation.clone();
1888        let mut first_handle = Some(scope.spawn(move || first(first_cancel)));
1889        let second_cancel = cancellation.clone();
1890        let mut second_handle = Some(scope.spawn(move || second(second_cancel)));
1891        let mut first_result = None;
1892        let mut second_result = None;
1893
1894        while first_result.is_none() || second_result.is_none() {
1895            if first_result.is_none()
1896                && first_handle
1897                    .as_ref()
1898                    .is_some_and(|handle| handle.is_finished())
1899            {
1900                let handle = first_handle.take().expect("first lane handle present");
1901                first_result = Some(match handle.join() {
1902                    Ok(result) => result,
1903                    Err(panic) => {
1904                        cancellation.cancel();
1905                        Err(anyhow::anyhow!(
1906                            "{first_name} thread panicked: {}",
1907                            targets::command_thread_panic_message(panic.as_ref())
1908                        ))
1909                    }
1910                });
1911                if first_result.as_ref().is_some_and(Result::is_err) {
1912                    cancellation.cancel();
1913                }
1914            }
1915            if second_result.is_none()
1916                && second_handle
1917                    .as_ref()
1918                    .is_some_and(|handle| handle.is_finished())
1919            {
1920                let handle = second_handle.take().expect("second lane handle present");
1921                second_result = Some(match handle.join() {
1922                    Ok(result) => result,
1923                    Err(panic) => {
1924                        cancellation.cancel();
1925                        Err(anyhow::anyhow!(
1926                            "{second_name} thread panicked: {}",
1927                            targets::command_thread_panic_message(panic.as_ref())
1928                        ))
1929                    }
1930                });
1931                if second_result.as_ref().is_some_and(Result::is_err) {
1932                    cancellation.cancel();
1933                }
1934            }
1935            if first_result.is_none() || second_result.is_none() {
1936                std::thread::sleep(Duration::from_millis(10));
1937            }
1938        }
1939
1940        match (
1941            first_result.expect("first lane result received after joined handle"),
1942            second_result.expect("second lane result received after joined handle"),
1943        ) {
1944            (Err(first), Err(second)) => {
1945                Err(first.context(format!("{second_name} lane also failed: {second:#}")))
1946            }
1947            (Err(error), Ok(_)) => Err(error),
1948            (Ok(_), Err(error)) => Err(error),
1949            (Ok(first), Ok(second)) => Ok((first, second)),
1950        }
1951    })
1952}
1953
1954/// Whether the restored native session can carry the conversation into the
1955/// resumed session.
1956///
1957fn native_continuity_preserved(profile_kind: HarnessKind, archived_kind: HarnessKind) -> bool {
1958    profile_kind == archived_kind
1959}
1960
1961/// Discover a utility model and compact the cross-harness handoff while still
1962/// watching for cancellation. Discovery and compaction can both make several
1963/// network requests, so a cancelled resume must not wait them out.
1964async fn utility_handoff_while_cancellable(
1965    session_id: &str,
1966    config: &Config,
1967    snapshot: &CanonicalSessionSnapshot,
1968    context_bytes: usize,
1969    executor: &impl CommandExecutor,
1970    cancellation: CancellationToken,
1971) -> Result<String> {
1972    let _phase = ResumePhaseTimer::new(session_id, "cross-harness handoff");
1973    if executor.cancellation_requested() {
1974        bail!("operation cancelled while compacting the cross-harness handoff");
1975    }
1976    let _compacting = ProvisionStageGuard::new(executor, ProvisionStage::Compacting);
1977    let cancel = cancellation.child_token();
1978    let operation =
1979        crate::handoff::build_handoff_context(session_id, config, snapshot, context_bytes, &cancel);
1980    tokio::pin!(operation);
1981    loop {
1982        tokio::select! {
1983            context = &mut operation => return context,
1984            _ = cancellation.cancelled() => {
1985                cancel.cancel();
1986                bail!("operation cancelled while compacting the cross-harness handoff");
1987            }
1988            _ = tokio::time::sleep(super::readiness::CANCELLATION_POLL_INTERVAL) => {
1989                if executor.cancellation_requested() {
1990                    cancel.cancel();
1991                    bail!("operation cancelled while compacting the cross-harness handoff");
1992                }
1993            }
1994        }
1995    }
1996}
1997
1998#[cfg(test)]
1999mod tests;