1use 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 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
81struct 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 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 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 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 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 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 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 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
433pub 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 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 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 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 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 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 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 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 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 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 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 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 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 let utility_config = (!native_continuity).then(|| self.config.clone());
931 let discard_queued_prompts = discard_queue || !same_harness;
932 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 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 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 let record = self.state.sessions.get_mut(session_id).unwrap();
995 record.harness_kind = profile.kind;
996 record.last_profile = profile_id.to_string();
997 record.target_template_id = target_id.to_string();
998 record.resource_allocation = resource_allocation;
999 record.additional_mounts = additional_mounts;
1000 record.target = None;
1001 record.native_session_id =
1002 native_continuity.then(|| archive_manifest.session.native_session_id.clone());
1003 record.state = SessionState::Provisioning;
1004 record.updated_at = now();
1005 record.last_error = None;
1006 match &conversion {
1007 Some(ResumeConversion::RawToWorkspace(conversion)) => {
1008 apply_raw_to_workspace(record, conversion);
1009 }
1010 Some(ResumeConversion::WorkspaceToRaw(conversion)) => {
1011 apply_workspace_to_raw(record, conversion);
1012 }
1013 None => {}
1014 }
1015 let resumed_project_directory = record.project_directory.clone();
1016 if let Some(host) = history_host {
1017 self.state.remember_mount_sources(host, &history_mounts);
1018 crate::database::remember_mount_sources(host, &history_mounts)?;
1019 }
1020 if let Some(conversion) = conversion
1023 .as_ref()
1024 .and_then(ResumeConversion::raw_to_workspace)
1025 {
1026 crate::database::rebind_session_bundle(session_id, &conversion.bundle_id)?;
1027 }
1028 if let Some((host, size)) = selected_container_size.as_ref() {
1031 crate::database::save_session_with_container_size(
1032 &self.state.sessions[session_id],
1033 host,
1034 *size,
1035 )?;
1036 } else {
1037 crate::database::save_session(&self.state.sessions[session_id])?;
1038 }
1039 if let Some((host, size)) = selected_container_size.as_ref() {
1040 self.state.remember_container_size(host, *size);
1041 }
1042
1043 let mut recreated_managed_worktree = false;
1044 let mut conversion_checkpoint_written: Option<mj_core::state::CheckpointMetadata> = None;
1047 let result = async {
1048 if let Some(worktree) = previous.managed_worktree.as_ref() {
1049 recreated_managed_worktree = restore_managed_worktree(executor, worktree)?;
1050 if recreated_managed_worktree && plan == ResumePlan::RawToWorkspace {
1051 mj_checkpoint::checkpoint::restore_single_repository_onto_branch(
1052 &archive_path,
1053 &worktree.worktree_root,
1054 &worktree.branch,
1055 &SystemGit,
1056 )
1057 .context("restore the retired checkout before moving it into a target")?;
1058 }
1059 }
1060 if let Some(conversion) = conversion
1063 .as_ref()
1064 .and_then(ResumeConversion::workspace_to_raw)
1065 {
1066 if conversion.reuse_existing_branch {
1067 let recovery_ref =
1068 preserve_retained_managed_worktree_branch(executor, &conversion.worktree)?;
1069 restore_managed_worktree(executor, &conversion.worktree)?;
1070 resume_notices.push(format!(
1071 "Before restoring this session's retained branch, Mjolnir preserved its tip at {recovery_ref}."
1072 ));
1073 } else {
1074 create_managed_worktree(
1075 executor,
1076 &conversion.worktree,
1077 None,
1078 PrimaryCheckoutRequirement::Any,
1079 )?;
1080 }
1081 mj_checkpoint::checkpoint::restore_single_repository_onto_branch(
1082 &archive_path,
1083 &conversion.worktree.worktree_root,
1084 &conversion.worktree.branch,
1085 &SystemGit,
1086 )
1087 .context("restore this session's checkout")?;
1088 }
1089 if let Some(conversion) = conversion
1094 .as_ref()
1095 .and_then(ResumeConversion::raw_to_workspace)
1096 {
1097 let destination = PathBuf::from(
1098 previous
1099 .project_directory
1100 .as_deref()
1101 .context("a raw session has no project directory")?
1102 .file_name()
1103 .context("a raw project directory cannot be the filesystem root")?,
1104 );
1105 let snapshot = raw_checkout_snapshot(
1106 &conversion.checkout,
1107 &conversion.source,
1108 &destination,
1109 &SystemGit,
1110 )
1111 .context("snapshot the host checkout for its new target")?;
1112 resume_notices.push(conversion_notice(
1113 target_id,
1114 previous
1115 .project_directory
1116 .as_deref()
1117 .unwrap_or(&conversion.checkout),
1118 snapshot.metadata.branch.as_deref(),
1119 conversion.retire.as_ref(),
1120 ));
1121 let archives = mj_core::config::sessions_dir();
1122 std::fs::create_dir_all(&archives).with_context(|| {
1123 format!("create the checkpoint directory {}", archives.display())
1124 })?;
1125 let output = archives.join(format!(
1129 "{session_id}-converted-{}-{}.hel.zip",
1130 previous
1131 .checkpoint
1132 .as_ref()
1133 .map_or(0, |checkpoint| checkpoint.event_frontier),
1134 new_command_id("archive")?
1135 ));
1136 let written = conversion_checkpoint(&archive_path, snapshot, &output)?;
1137 conversion_checkpoint_written = Some(written.clone());
1138 let record = self.state.sessions.get_mut(session_id).unwrap();
1139 record.checkpoint = Some(written);
1140 record.updated_at = now();
1141 if let Some((host, size)) = selected_container_size.as_ref() {
1144 crate::database::save_session_with_container_size(
1145 &self.state.sessions[session_id],
1146 host,
1147 *size,
1148 )?;
1149 } else {
1150 crate::database::save_session(&self.state.sessions[session_id])?;
1151 }
1152 }
1153 let utility_handoff = {
1154 let _provisioning = ResumePhaseTimer::new(session_id, "provision destination");
1155 if let Some(config) = utility_config.as_ref() {
1156 Some(
1157 provision_with_cross_harness_handoff(
1158 self,
1159 session_id,
1160 executor,
1161 github_token.as_deref(),
1162 config,
1163 &canonical_session,
1164 context_bytes,
1165 )
1166 .context("prepare the cross-harness destination")?,
1167 )
1168 } else {
1169 self.provision_session_with_failure_disposition(
1170 session_id,
1171 executor,
1172 github_token.as_deref(),
1173 ProvisioningFailureDisposition::Preserve,
1174 )
1175 .await?;
1176 None
1177 }
1178 };
1179 let (backend, worker_root) = self.worker_placement(session_id)?;
1180 let harness_home = target_profile_home(&backend, session_id, &profile);
1181 let workspace_root = if let Some(project_directory) = &resumed_project_directory {
1182 project_directory
1183 .parent()
1184 .context("bare project directory has no parent")?
1185 .to_string_lossy()
1186 .into_owned()
1187 } else {
1188 match &backend {
1189 targets::TargetLocator::LocalPodman { .. }
1190 | targets::TargetLocator::LocalDocker { .. }
1191 | targets::TargetLocator::AppleContainer { .. }
1192 | targets::TargetLocator::SshPodman { .. }
1193 | targets::TargetLocator::SshDocker { .. } => "/workspace".to_string(),
1194 targets::TargetLocator::AwsEc2 { workspace, .. }
1195 | targets::TargetLocator::SshBare { workspace, .. } => workspace.clone(),
1196 targets::TargetLocator::LocalBare { worker_root } => worker_root.clone(),
1197 }
1198 };
1199 let target_path = |path: &str| match &backend {
1200 targets::TargetLocator::AwsEc2 { .. }
1201 | targets::TargetLocator::SshBare { .. }
1202 if !path.starts_with('/') =>
1203 {
1204 PathBuf::from(format!("~/{path}"))
1205 }
1206 _ => PathBuf::from(path),
1207 };
1208 let remote_archive = format!("{worker_root}/restore.hel.zip");
1209 let remote_spec = format!("{worker_root}/restore-spec.json");
1210 let restored_archive = conversion_checkpoint_written
1213 .as_ref()
1214 .map_or(archive_path.as_path(), |checkpoint| {
1215 checkpoint.archive_path.as_path()
1216 });
1217 let restore = CheckpointRestoreSpec {
1218 archive_path: restore_archive_path(
1219 &backend,
1220 restored_archive,
1221 &target_path(&remote_archive),
1222 ),
1223 workspace_root: target_path(&workspace_root),
1224 relay_root: target_path(&worker_root),
1225 harness_home: target_path(&harness_home),
1226 restore_repositories: (resumed_project_directory.is_none()
1232 && conversion.is_none())
1233 || plan == ResumePlan::RawToWorkspace
1234 || (recreated_managed_worktree && plan == ResumePlan::InPlace),
1235 restore_native: native_continuity,
1236 primary_repository_root: conversion
1243 .is_some()
1244 .then(|| resumed_project_directory.clone())
1245 .flatten()
1246 .map(|directory| target_path(&directory.to_string_lossy())),
1247 discard_queued_prompts,
1248 };
1249 {
1256 let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
1257 if let Some(command) = targets::clear_relay_state_plan(&backend, session_id)? {
1258 execute_checked(syncing, command)?;
1259 }
1260 execute_checked(
1262 syncing,
1263 targets::command_on_locator(
1264 &backend,
1265 session_id,
1266 vec!["mkdir".into(), "-p".into(), worker_root.clone()],
1267 "create the session worker root",
1268 )?,
1269 )?;
1270 }
1271 let staging = tempfile::tempdir().context("create restore staging")?;
1272 let local_spec = staging.path().join("restore-spec.json");
1273 std::fs::write(&local_spec, serde_json::to_vec_pretty(&restore)?)?;
1274 let controller = &*self;
1278 let backend_ref = &backend;
1279 let worker_root_ref = worker_root.as_str();
1280 let local_spec_ref = local_spec.as_path();
1281 execute_concurrent_lanes(
1282 || {
1283 let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
1284 controller.prepare_worker_files(
1285 session_id,
1286 backend_ref,
1287 worker_root_ref,
1288 syncing,
1289 )?;
1290 super::provisioning::install_inherited_git_settings(
1291 syncing,
1292 backend_ref,
1293 session_id,
1294 )?;
1295 Ok(())
1296 },
1297 || {
1298 let restoring = &StagedExecutor::new(executor, ProvisionStage::Restoring);
1299 if should_upload_restore_archive(&backend) {
1300 upload_checkpoint_spec(
1301 restoring,
1302 backend_ref,
1303 session_id,
1304 restored_archive,
1305 &remote_archive,
1306 )?;
1307 }
1308 upload_checkpoint_spec(
1309 restoring,
1310 backend_ref,
1311 session_id,
1312 local_spec_ref,
1313 &remote_spec,
1314 )
1315 },
1316 )?;
1317 {
1318 let restoring = &StagedExecutor::new(executor, ProvisionStage::Restoring);
1319 execute_checked(
1320 restoring,
1321 restore_command(&backend, session_id, &remote_spec)?,
1322 )?;
1323 }
1324 {
1325 let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
1326 install_attached_resources(
1327 &self.state,
1328 session_id,
1329 &backend,
1330 &worker_root,
1331 syncing,
1332 )?;
1333
1334 }
1335 match projection_build {
1336 Some(build) => {
1337 let mut restored_projection = build
1338 .await
1339 .context("rebuild the restored projection")?
1340 .context("rebuild the restored projection")?;
1341 if discard_queued_prompts {
1342 restored_projection.queued_prompts.clear();
1343 }
1344 crate::database::save_materialized_session(&restored_projection)?;
1345 }
1346 None if discard_queued_prompts => {
1349 crate::database::replace_materialized_queued_prompts(session_id, &[])?;
1350 }
1351 None => {}
1352 }
1353 let readiness_stage = bridge_readiness_stage(&profile);
1354 let spec = self.reconnect_command(session_id)?;
1355 let readiness = async {
1356 let mut relay = {
1357 let _starting = ProvisionStageGuard::new(executor, ProvisionStage::Starting);
1358 start_worker(executor, &backend, &worker_root)?;
1359 connect_started_worker(&spec, session_id, executor, &backend, &worker_root)
1360 .await?
1361 };
1362 let native_session_id =
1363 wait_for_native_session_in_stage(&mut relay, executor, readiness_stage).await?;
1364 Ok::<_, anyhow::Error>((relay, native_session_id))
1365 }
1366 .await;
1367 let (mut relay, native_session_id) = readiness
1368 .map_err(|error| worker_probe_diagnosis(executor, &backend, &worker_root, error))?;
1369 if native_continuity {
1370 if native_session_id != archive_manifest.session.native_session_id {
1371 bail!(
1372 "ACP loaded native session {native_session_id}, expected {}",
1373 archive_manifest.session.native_session_id
1374 );
1375 }
1376 } else {
1377 relay
1378 .install_prompt_context(
1379 utility_handoff
1380 .clone()
1381 .context("a resume into a fresh native session has no handoff")?,
1382 )
1383 .await?;
1384 if !discard_queue {
1385 for prompt in &canonical_session.queued_prompts {
1386 let command = match &prompt.kind {
1390 CanonicalQueuedCommandKind::Prompt => RelayCommand::Prompt {
1391 prompt: prompt
1392 .content
1393 .iter()
1394 .cloned()
1395 .map(serde_json::from_value)
1396 .collect::<serde_json::Result<Vec<ContentBlock>>>()?,
1397 },
1398 CanonicalQueuedCommandKind::SetConfig { key, value } => {
1399 RelayCommand::SetConfig {
1400 key: key.clone(),
1401 value: value.clone(),
1402 }
1403 }
1404 };
1405 relay.submit(prompt.command_id.clone(), command).await?;
1406 }
1407 }
1408 }
1409 if let Some(worktree) = conversion
1413 .as_ref()
1414 .and_then(ResumeConversion::raw_to_workspace)
1415 .and_then(|plan| plan.retire.as_ref())
1416 && let Err(error) = retire_managed_worktree(executor, worktree)
1417 {
1418 tracing::warn!(
1419 session_id,
1420 worktree = %worktree.worktree_root.display(),
1421 error = format!("{error:#}"),
1422 "could not retire the old managed worktree after resume"
1423 );
1424 resume_notices.push(worktree_cleanup_notice(&worktree.worktree_root, &error));
1425 }
1426 for notice in &resume_notices {
1427 let submitted = async {
1428 let command_id = new_command_id("resume-notice")?;
1429 relay
1430 .submit(
1431 command_id,
1432 RelayCommand::RecordNotice {
1433 text: notice.clone(),
1434 },
1435 )
1436 .await
1437 }
1438 .await;
1439 if let Err(error) = submitted {
1442 tracing::warn!(
1443 session_id,
1444 error = format!("{error:#}"),
1445 "could not record a resume notice in the conversation"
1446 );
1447 }
1448 }
1449 self.mark_worker_connected(session_id, Some(native_session_id))?;
1450 Ok::<_, anyhow::Error>(relay.sync().await?.materialized)
1451 }
1452 .await;
1453 match result {
1454 Ok(materialized) => {
1455 if let Some(written) = &conversion_checkpoint_written {
1458 super::checkpoint::prune_replaced_checkpoint(
1459 previous.checkpoint.as_ref(),
1460 written,
1461 );
1462 }
1463 Ok(materialized)
1464 }
1465 Err(error) => {
1466 if let Some(written) = &conversion_checkpoint_written
1469 && let Err(remove_error) = std::fs::remove_file(&written.archive_path)
1470 && remove_error.kind() != std::io::ErrorKind::NotFound
1471 {
1472 tracing::warn!(
1473 session_id,
1474 path = %written.archive_path.display(),
1475 "could not remove the conversion checkpoint after resume failed: {remove_error}"
1476 );
1477 }
1478 if rebuild_projection {
1483 match materialized_session_from_canonical(session_id, &canonical_session) {
1484 Ok(previous_projection) => {
1485 if let Err(restore_error) =
1486 crate::database::save_materialized_session(&previous_projection)
1487 {
1488 tracing::error!(
1489 session_id,
1490 error = format!("{restore_error:#}"),
1491 "could not restore the durable projection after resume failed"
1492 );
1493 }
1494 }
1495 Err(restore_error) => {
1496 tracing::error!(
1497 session_id,
1498 error = format!("{restore_error:#}"),
1499 "could not rebuild the durable projection after resume failed"
1500 );
1501 }
1502 }
1503 } else if discard_queued_prompts
1504 && let Err(restore_error) = crate::database::replace_materialized_queued_prompts(
1505 session_id,
1506 &mj_transcript::projection::materialized_queued_prompts_from_canonical(
1507 &canonical_session.queued_prompts,
1508 ),
1509 )
1510 {
1511 tracing::error!(
1512 session_id,
1513 error = format!("{restore_error:#}"),
1514 "could not restore queued prompts after resume failed"
1515 );
1516 }
1517 Err(self.rollback_failed_resume(
1518 session_id,
1519 &previous,
1520 recreated_managed_worktree,
1521 error,
1522 executor,
1523 )?)
1524 }
1525 }
1526 }
1527
1528 pub(super) fn rollback_failed_resume(
1529 &mut self,
1530 session_id: &str,
1531 previous: &SessionRecord,
1532 recreated_managed_worktree: bool,
1533 error: anyhow::Error,
1534 _executor: &impl CommandExecutor,
1535 ) -> Result<anyhow::Error> {
1536 let current = self
1537 .state
1538 .sessions
1539 .get(session_id)
1540 .with_context(|| format!("unknown session {session_id}"))?
1541 .clone();
1542 let cleanup = match current.target.as_ref() {
1543 Some(locator) => (|| -> Result<()> {
1544 let backend = backend_locator(locator, ¤t, &self.config)?;
1545 targets::close_plan(&backend, session_id)?
1546 .execute(&CancellableProcessExecutor::with_timeout(
1549 Duration::from_secs(15),
1550 ))
1551 .map(|_| ())
1552 })(),
1553 None => Ok(()),
1554 };
1555 let worktree_cleanup = if cleanup.is_err() {
1558 Ok(())
1559 } else {
1560 match (
1561 current.managed_worktree.as_ref(),
1562 previous.managed_worktree.as_ref(),
1563 ) {
1564 (_, Some(previous)) if recreated_managed_worktree => retire_managed_worktree(
1565 &CancellableProcessExecutor::with_timeout(Duration::from_secs(15)),
1566 previous,
1567 ),
1568 (Some(current), Some(previous)) if current == previous => Ok(()),
1569 (Some(worktree), _) => cleanup_managed_worktree(
1570 &CancellableProcessExecutor::with_timeout(Duration::from_secs(15)),
1571 worktree,
1572 ),
1573 (None, _) => Ok(()),
1574 }
1575 };
1576 let cleanup_error = [cleanup, worktree_cleanup]
1577 .into_iter()
1578 .filter_map(Result::err)
1579 .map(|cleanup_error| format!("{cleanup_error:#}"))
1580 .collect::<Vec<_>>()
1581 .join("; ");
1582 if !cleanup_error.is_empty() {
1583 tracing::warn!(
1584 session_id,
1585 error = %cleanup_error,
1586 "resume rollback cleanup reported failures"
1587 );
1588 }
1589 let original = format!("{error:#}");
1590 let record = self.state.sessions.get_mut(session_id).unwrap();
1591 let failure = apply_failed_resume_rollback(
1592 record,
1593 previous,
1594 &original,
1595 (!cleanup_error.is_empty()).then_some(cleanup_error),
1596 );
1597 if record.bundle_id != current.bundle_id {
1600 let bundle_id = record.bundle_id.clone();
1601 crate::database::rebind_session_bundle(session_id, &bundle_id)?;
1602 }
1603 crate::database::save_session(&self.state.sessions[session_id])?;
1606 Ok(failure)
1607 }
1608}
1609
1610fn worktree_cleanup_notice(worktree_root: &Path, error: &anyhow::Error) -> String {
1611 format!(
1612 "Mjolnir could not remove the worktree at {}: {error:#}. Remove it with `git worktree remove --force {}`.",
1613 worktree_root.display(),
1614 worktree_root.display()
1615 )
1616}
1617
1618pub(super) fn apply_failed_resume_rollback(
1619 current: &mut SessionRecord,
1620 previous: &SessionRecord,
1621 original_error: &str,
1622 cleanup_error: Option<String>,
1623) -> anyhow::Error {
1624 match cleanup_error {
1625 None => {
1626 *current = previous.clone();
1627 current.state = SessionState::Stopped;
1628 current.target = None;
1629 current.updated_at = now();
1630 current.last_error = Some(format!("resume failed: {original_error}"));
1631 anyhow::anyhow!(original_error.to_owned())
1632 }
1633 Some(cleanup_error) => {
1634 let failure = format!(
1635 "{original_error}; cleanup of the partial resume target failed: {cleanup_error}"
1636 );
1637 if current.managed_worktree.is_none() {
1642 current
1643 .project_directory
1644 .clone_from(&previous.project_directory);
1645 current
1646 .managed_worktree
1647 .clone_from(&previous.managed_worktree);
1648 current.bundle_id.clone_from(&previous.bundle_id);
1649 }
1650 current.state = SessionState::Error;
1651 current.updated_at = now();
1652 current.last_error = Some(format!("resume failed: {failure}"));
1653 anyhow::anyhow!(failure)
1654 }
1655 }
1656}
1657
1658fn conversion_notice(
1660 target_id: &str,
1661 checkout: &Path,
1662 branch: Option<&str>,
1663 retire: Option<&mj_core::state::ManagedWorktree>,
1664) -> String {
1665 let branch = branch.unwrap_or("a detached head");
1666 match retire {
1667 Some(worktree) => format!(
1668 "This session moved out of {} and into the {target_id} target, where its checkout is on {branch}. Its branch {} stays in {}.",
1669 checkout.display(),
1670 worktree.branch,
1671 worktree.source_repository.display()
1672 ),
1673 None => format!(
1674 "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.",
1675 checkout.display()
1676 ),
1677 }
1678}
1679
1680fn conversion_checkpoint(
1688 previous_archive: &Path,
1689 snapshot: mj_checkpoint::archive::RepositorySnapshot,
1690 output: &Path,
1691) -> Result<mj_core::state::CheckpointMetadata> {
1692 let previous = mj_checkpoint::archive::read_archive_verified(previous_archive)
1693 .with_context(|| format!("read checkpoint archive {}", previous_archive.display()))?;
1694 let native_artifacts = previous
1697 .manifest
1698 .payloads
1699 .iter()
1700 .filter_map(|descriptor| match &descriptor.role {
1701 mj_checkpoint::archive::PayloadRole::NativeArtifact { relative_path } => {
1702 Some((relative_path, descriptor))
1703 }
1704 _ => None,
1705 })
1706 .map(|(relative_path, descriptor)| {
1707 Ok(mj_checkpoint::archive::NativeArtifact {
1708 relative_path: relative_path.clone(),
1709 data: previous.payload(descriptor)?.to_vec(),
1710 mode: descriptor.mode,
1711 })
1712 })
1713 .collect::<Result<Vec<_>>>()?;
1714 let canonical_session = previous.canonical_session()?;
1715 let event_frontier = canonical_session.event_frontier;
1716 let written = mj_checkpoint::archive::write_archive_atomic(
1717 output,
1718 &mj_checkpoint::archive::ArchiveInput {
1719 session: previous.manifest.session.clone(),
1720 target: previous.manifest.target.clone(),
1723 bundle: mj_checkpoint::archive::BundleManifest {
1724 id: previous.manifest.bundle.id.clone(),
1725 primary_repository: snapshot.metadata.id.clone(),
1729 },
1730 canonical_session,
1731 native_artifacts,
1732 repositories: vec![snapshot],
1733 },
1734 )
1735 .with_context(|| format!("write the conversion archive {}", output.display()))?;
1736 Ok(mj_core::state::CheckpointMetadata {
1737 archive_path: output.to_path_buf(),
1738 sha256: written.archive_sha256,
1739 created_at: now(),
1740 event_frontier,
1741 })
1742}
1743
1744fn projection_rebuild_required(
1752 stored: Option<(u64, &str)>,
1753 archive_frontier: u64,
1754 archive_frontier_digest: &str,
1755) -> bool {
1756 stored != Some((archive_frontier, archive_frontier_digest))
1757}
1758
1759fn restore_archive_path(
1760 backend: &targets::TargetLocator,
1761 verified_archive: &Path,
1762 remote_archive: &Path,
1763) -> PathBuf {
1764 if matches!(backend, targets::TargetLocator::LocalBare { .. }) {
1765 verified_archive.to_path_buf()
1766 } else {
1767 remote_archive.to_path_buf()
1768 }
1769}
1770
1771fn should_upload_restore_archive(backend: &targets::TargetLocator) -> bool {
1772 !matches!(backend, targets::TargetLocator::LocalBare { .. })
1773}
1774
1775struct CrossHarnessProvisionExecutor<'a, E: CommandExecutor + ?Sized> {
1780 inner: &'a E,
1781 cancellation: CancellationToken,
1782}
1783
1784impl<E: CommandExecutor + ?Sized> CommandExecutor for CrossHarnessProvisionExecutor<'_, E> {
1785 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1786 if self.cancellation.is_cancelled() {
1787 bail!("operation cancelled while provisioning destination");
1788 }
1789 self.inner.execute(command)
1790 }
1791
1792 fn cancellation_requested(&self) -> bool {
1793 self.cancellation.is_cancelled() || self.inner.cancellation_requested()
1794 }
1795
1796 fn stage_started(&self, stage: ProvisionStage) {
1797 self.inner.stage_started(stage);
1798 }
1799
1800 fn stage_finished(&self, stage: ProvisionStage) {
1801 self.inner.stage_finished(stage);
1802 }
1803
1804 fn notify_notice(&self, notice: &str) {
1805 self.inner.notify_notice(notice);
1806 }
1807
1808 fn execute_with_stdin(
1809 &self,
1810 command: &CommandSpec,
1811 input: &mut (dyn std::io::Read + Send),
1812 ) -> Result<CommandOutput> {
1813 if self.cancellation.is_cancelled() {
1814 bail!("operation cancelled while provisioning destination");
1815 }
1816 self.inner.execute_with_stdin(command, input)
1817 }
1818}
1819
1820fn provision_with_cross_harness_handoff(
1821 controller: &mut Controller,
1822 session_id: &str,
1823 executor: &(impl CommandExecutor + Sync),
1824 github_token: Option<&str>,
1825 config: &Config,
1826 snapshot: &CanonicalSessionSnapshot,
1827 context_bytes: usize,
1828) -> Result<String> {
1829 let (_provision, handoff) = execute_joined_cross_harness_work(
1830 "cross-harness provisioning",
1831 move |cancellation| {
1832 let provision_executor = CrossHarnessProvisionExecutor {
1833 inner: executor,
1834 cancellation,
1835 };
1836 futures::executor::block_on(controller.provision_session_with_failure_disposition(
1837 session_id,
1838 &provision_executor,
1839 github_token,
1840 ProvisioningFailureDisposition::Preserve,
1841 ))
1842 },
1843 "cross-harness handoff",
1844 move |cancellation| {
1845 let runtime = tokio::runtime::Builder::new_current_thread()
1846 .enable_all()
1847 .build()
1848 .context("create cross-harness handoff runtime")?;
1849 runtime.block_on(utility_handoff_while_cancellable(
1850 session_id,
1851 config,
1852 snapshot,
1853 context_bytes,
1854 executor,
1855 cancellation,
1856 ))
1857 },
1858 )?;
1859 ensure!(
1860 !executor.cancellation_requested(),
1861 "operation cancelled while provisioning destination"
1862 );
1863 Ok(handoff)
1864}
1865
1866fn execute_joined_cross_harness_work<A: Send, B: Send>(
1870 first_name: &'static str,
1871 first: impl FnOnce(CancellationToken) -> Result<A> + Send,
1872 second_name: &'static str,
1873 second: impl FnOnce(CancellationToken) -> Result<B> + Send,
1874) -> Result<(A, B)> {
1875 let cancellation = CancellationToken::new();
1876 std::thread::scope(|scope| {
1877 let first_cancel = cancellation.clone();
1878 let mut first_handle = Some(scope.spawn(move || first(first_cancel)));
1879 let second_cancel = cancellation.clone();
1880 let mut second_handle = Some(scope.spawn(move || second(second_cancel)));
1881 let mut first_result = None;
1882 let mut second_result = None;
1883
1884 while first_result.is_none() || second_result.is_none() {
1885 if first_result.is_none()
1886 && first_handle
1887 .as_ref()
1888 .is_some_and(|handle| handle.is_finished())
1889 {
1890 let handle = first_handle.take().expect("first lane handle present");
1891 first_result = Some(match handle.join() {
1892 Ok(result) => result,
1893 Err(panic) => {
1894 cancellation.cancel();
1895 Err(anyhow::anyhow!(
1896 "{first_name} thread panicked: {}",
1897 targets::command_thread_panic_message(panic.as_ref())
1898 ))
1899 }
1900 });
1901 if first_result.as_ref().is_some_and(Result::is_err) {
1902 cancellation.cancel();
1903 }
1904 }
1905 if second_result.is_none()
1906 && second_handle
1907 .as_ref()
1908 .is_some_and(|handle| handle.is_finished())
1909 {
1910 let handle = second_handle.take().expect("second lane handle present");
1911 second_result = Some(match handle.join() {
1912 Ok(result) => result,
1913 Err(panic) => {
1914 cancellation.cancel();
1915 Err(anyhow::anyhow!(
1916 "{second_name} thread panicked: {}",
1917 targets::command_thread_panic_message(panic.as_ref())
1918 ))
1919 }
1920 });
1921 if second_result.as_ref().is_some_and(Result::is_err) {
1922 cancellation.cancel();
1923 }
1924 }
1925 if first_result.is_none() || second_result.is_none() {
1926 std::thread::sleep(Duration::from_millis(10));
1927 }
1928 }
1929
1930 match (
1931 first_result.expect("first lane result received after joined handle"),
1932 second_result.expect("second lane result received after joined handle"),
1933 ) {
1934 (Err(first), Err(second)) => {
1935 Err(first.context(format!("{second_name} lane also failed: {second:#}")))
1936 }
1937 (Err(error), Ok(_)) => Err(error),
1938 (Ok(_), Err(error)) => Err(error),
1939 (Ok(first), Ok(second)) => Ok((first, second)),
1940 }
1941 })
1942}
1943
1944fn native_continuity_preserved(profile_kind: HarnessKind, archived_kind: HarnessKind) -> bool {
1948 profile_kind == archived_kind
1949}
1950
1951async fn utility_handoff_while_cancellable(
1955 session_id: &str,
1956 config: &Config,
1957 snapshot: &CanonicalSessionSnapshot,
1958 context_bytes: usize,
1959 executor: &impl CommandExecutor,
1960 cancellation: CancellationToken,
1961) -> Result<String> {
1962 let _phase = ResumePhaseTimer::new(session_id, "cross-harness handoff");
1963 if executor.cancellation_requested() {
1964 bail!("operation cancelled while compacting the cross-harness handoff");
1965 }
1966 let _compacting = ProvisionStageGuard::new(executor, ProvisionStage::Compacting);
1967 let cancel = cancellation.child_token();
1968 let operation =
1969 crate::handoff::build_handoff_context(session_id, config, snapshot, context_bytes, &cancel);
1970 tokio::pin!(operation);
1971 loop {
1972 tokio::select! {
1973 context = &mut operation => return context,
1974 _ = cancellation.cancelled() => {
1975 cancel.cancel();
1976 bail!("operation cancelled while compacting the cross-harness handoff");
1977 }
1978 _ = tokio::time::sleep(super::readiness::CANCELLATION_POLL_INTERVAL) => {
1979 if executor.cancellation_requested() {
1980 cancel.cancel();
1981 bail!("operation cancelled while compacting the cross-harness handoff");
1982 }
1983 }
1984 }
1985 }
1986}
1987
1988#[cfg(test)]
1989mod tests {
1990 use std::cell::RefCell;
1991 use std::collections::BTreeMap;
1992 use std::path::{Path, PathBuf};
1993 use std::process::Command;
1994 use std::sync::{Barrier, Mutex};
1995
1996 use anyhow::Result;
1997
1998 use crate::controller::test_support::{
1999 FIXTURE_FETCH_URL, FixtureRemoteExecutor, checkout_with_network_remote,
2000 checkpoint_test_session, committed_repository, managed_worktree_session,
2001 network_remote_for, raw_session_on, resume_compatibility_config,
2002 write_checkpoint_archive_with_native_state, write_checkpoint_gate_archive,
2003 };
2004 use crate::controller::{Controller, SessionResumeOptions};
2005 use mj_checkpoint::archive::{GitCommandRunner, verify_archive_streaming};
2006 use mj_core::config::{
2007 Config, ContainerTemplate as ConfigContainer, HarnessProfile, ProjectBundle,
2008 ProjectRepository, TargetTemplate,
2009 };
2010 use mj_core::state::{SessionRecord, SessionState, State, TargetLocator};
2011 use mj_transcript::projection::materialized_session_from_canonical;
2012
2013 use crate::targets::{CommandExecutor, CommandOutput, CommandSpec, ProcessExecutor};
2014
2015 use super::*;
2016
2017 #[test]
2021 fn a_local_checkout_resuming_into_a_container_preflights_its_conversion() {
2022 let (checkout, _remote_parent, remote) = checkout_with_network_remote();
2023 std::fs::write(checkout.path().join("untracked.txt"), "u".repeat(2048)).unwrap();
2024 let mut session = raw_session_on("local-bare", &checkout.path().to_string_lossy());
2025 session.checkpoint = Some(mj_core::state::CheckpointMetadata {
2026 archive_path: checkout.path().join("unused.hel.zip"),
2027 sha256: "a".repeat(64),
2028 created_at: "2026-09-14T00:00:00Z".into(),
2029 event_frontier: 3,
2030 });
2031 let session_id = session.id.clone();
2032 let controller = Controller {
2033 config: resume_compatibility_config(),
2034 state: State {
2035 sessions: BTreeMap::from([(session_id.clone(), session)]),
2036 ..State::default()
2037 },
2038 };
2039 let executor = FixtureRemoteExecutor { remote };
2040
2041 let converting = controller
2042 .preflight_resume_repository_sources(&session_id, "podman", &executor)
2043 .unwrap();
2044 let ResumeRepositorySourcePreflight::ConvertingRawCheckout { receipt, preview } =
2045 converting
2046 else {
2047 panic!("a container destination converts the checkout, got {converting:?}");
2048 };
2049 assert_eq!(receipt.session_id, session_id);
2050 assert_eq!(preview.fetch_url, FIXTURE_FETCH_URL);
2051 assert_eq!(preview.untracked_files, 1);
2052 assert!(preview.host_checkout_retained);
2053
2054 assert!(
2055 matches!(
2056 controller
2057 .preflight_resume_repository_sources(&session_id, "local-bare", &executor)
2058 .unwrap(),
2059 ResumeRepositorySourcePreflight::Ready(_)
2060 ),
2061 "resuming in place asks nothing"
2062 );
2063 }
2064
2065 const RESUME_ROLLBACK_TEST_CHILD: &str = "MJ_RESUME_ROLLBACK_TEST_CHILD";
2066 const RETIRED_WORKTREE_RESUME_TEST_CHILD: &str = "MJ_RETIRED_WORKTREE_RESUME_TEST_CHILD";
2067 const WORKER_PREFLIGHT_TEST_CHILD: &str = "MJ_WORKER_PREFLIGHT_TEST_CHILD";
2068
2069 #[test]
2070 fn muse_resume_allows_workspace_relocation_before_provisioning() {
2071 let mut config = resume_compatibility_config();
2072 config
2073 .targets
2074 .insert("other-container".into(), config.targets["podman"].clone());
2075 let controller = Controller {
2076 config,
2077 state: State::default(),
2078 };
2079 let mut session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
2080 session.harness_kind = HarnessKind::Muse;
2081 assert!(
2082 controller
2083 .validate_muse_resume_destination(&session, HarnessKind::Muse, "podman")
2084 .is_ok()
2085 );
2086 assert!(
2087 controller
2088 .validate_muse_resume_destination(&session, HarnessKind::Muse, "other-container")
2089 .is_ok()
2090 );
2091 controller
2092 .validate_muse_resume_destination(&session, HarnessKind::Muse, "ssh-bare")
2093 .unwrap();
2094 assert!(
2095 controller
2096 .validate_muse_resume_destination(&session, HarnessKind::Codex, "ssh-bare")
2097 .is_ok()
2098 );
2099 assert_eq!(session.state, SessionState::Running);
2100 }
2101
2102 #[test]
2106 fn a_resume_preflights_the_worker_binary_before_compacting() {
2107 if std::env::var_os(WORKER_PREFLIGHT_TEST_CHILD).is_none() {
2110 let directory = tempfile::tempdir().unwrap();
2111 let test_name = format!(
2112 "{}::a_resume_preflights_the_worker_binary_before_compacting",
2113 module_path!()
2114 .strip_prefix("mj_controller::")
2115 .unwrap_or(module_path!())
2116 );
2117 let output = Command::new(std::env::current_exe().unwrap())
2118 .args(["--exact", &test_name, "--nocapture"])
2119 .env(WORKER_PREFLIGHT_TEST_CHILD, "1")
2120 .env("MJ_DATA_DIR", directory.path().join("data"))
2121 .env("MJ_CONFIG_DIR", directory.path().join("config"))
2122 .env("MJ_WORKER_BINARY", directory.path().join("absent-worker"))
2125 .output()
2126 .unwrap();
2127 assert!(
2128 output.status.success(),
2129 "isolated worker preflight test failed\nstdout:\n{}\nstderr:\n{}",
2130 String::from_utf8_lossy(&output.stdout),
2131 String::from_utf8_lossy(&output.stderr)
2132 );
2133 return;
2134 }
2135 let _writer = crate::database::install_isolated_test_writer();
2137
2138 let data_directory = PathBuf::from(std::env::var_os("MJ_DATA_DIR").unwrap());
2139 let archive_directory = data_directory.join("archives");
2140 std::fs::create_dir_all(&archive_directory).unwrap();
2141 let session_id = "0123456789abcdef0123456789abcdef";
2142 let checkpoint = write_checkpoint_gate_archive(&archive_directory, session_id, 7);
2143 let repository = committed_repository();
2144 let mut session = managed_worktree_session(repository.path(), session_id);
2145 session.checkpoint = Some(checkpoint);
2146
2147 let profile_home = data_directory.join("profile");
2148 std::fs::create_dir_all(&profile_home).unwrap();
2149 let mut config = resume_compatibility_config();
2150 config.profiles.insert(
2153 "claude".into(),
2154 HarnessProfile {
2155 enabled: true,
2156 kind: mj_core::config::HarnessKind::Claude,
2157 home: profile_home,
2158 environment: BTreeMap::new(),
2159 context_window_bytes: None,
2160 guardian_review_model: None,
2161 },
2162 );
2163 let mut controller = Controller {
2164 config,
2165 state: State {
2166 sessions: BTreeMap::from([(session_id.into(), session)]),
2167 ..State::default()
2168 },
2169 };
2170 crate::database::save_state(&controller.state).unwrap();
2171
2172 let error = tokio::runtime::Builder::new_current_thread()
2173 .enable_all()
2174 .build()
2175 .unwrap()
2176 .block_on(controller.resume_session_controlled(
2177 session_id,
2178 "claude",
2179 "local-bare",
2180 SessionResumeOptions {
2181 additional_mounts: None,
2182 resource_allocation: None,
2183 discard_queue: false,
2184 },
2185 &ProcessExecutor,
2186 ))
2187 .unwrap_err();
2188
2189 let detail = format!("{error:#}");
2190 assert!(
2191 detail.contains("preflight the worker binary before resuming"),
2192 "{detail}"
2193 );
2194 assert!(detail.contains("absent-worker"), "{detail}");
2195 assert!(
2196 !detail.contains("compact the cross-harness handoff transcript"),
2197 "compaction must not run for a resume that cannot install a worker: {detail}"
2198 );
2199 assert_eq!(
2200 controller.state.sessions[session_id].state,
2201 SessionState::Stopped
2202 );
2203 }
2204
2205 #[test]
2206 fn network_resume_ignores_host_history_but_an_explicit_raw_move_checks_it() {
2207 let directory = tempfile::tempdir().unwrap();
2208 let repository = committed_repository();
2209 let session_id = "0123456789abcdef0123456789abcdef";
2210 let mut session = checkpoint_test_session(session_id);
2211 session.state = SessionState::Stopped;
2212 session.checkpoint = Some(
2213 super::super::test_support::write_network_checkpoint_archive(
2214 directory.path(),
2215 session_id,
2216 0,
2217 ),
2218 );
2219 let mut config = resume_compatibility_config();
2220 config.bundles.insert(
2221 "project".into(),
2222 super::super::test_support::local_bundle(repository.path()),
2223 );
2224 let controller = Controller {
2225 config,
2226 state: State {
2227 sessions: BTreeMap::from([(session_id.into(), session)]),
2228 ..State::default()
2229 },
2230 };
2231 assert!(matches!(
2232 controller
2233 .preflight_resume_repository_sources(session_id, "podman", &ProcessExecutor,)
2234 .unwrap(),
2235 ResumeRepositorySourcePreflight::Ready(_)
2236 ));
2237 let result = controller
2238 .preflight_resume_repository_sources(session_id, "local-bare", &ProcessExecutor)
2239 .unwrap();
2240 let ResumeRepositorySourcePreflight::RepositoryMoved(mismatch) = result else {
2241 panic!("moving into a host checkout must detect its missing archive base");
2242 };
2243 assert_eq!(mismatch.missing_commit, "a".repeat(40));
2244 assert!(!repository.path().join(".mj/worktrees").exists());
2245 }
2246
2247 #[test]
2248 fn raw_in_place_preflight_does_not_require_its_synthetic_bundle() {
2249 struct UnusedExecutor;
2250
2251 impl CommandExecutor for UnusedExecutor {
2252 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2253 panic!("raw in-place preflight ran {}", command.purpose);
2254 }
2255 }
2256
2257 let directory = tempfile::tempdir().unwrap();
2258 let session_id = "0123456789abcdef0123456789abcdef";
2259 let mut session = checkpoint_test_session(session_id);
2260 session.checkpoint = Some(write_checkpoint_gate_archive(
2261 directory.path(),
2262 session_id,
2263 3,
2264 ));
2265 session.bundle_id = "remote-project-a66373eef659f856".into();
2266 session.target_template_id = "localhost".into();
2267 session.project_directory = Some("/mnt/optane/bifrost-fird".into());
2268 let controller = Controller {
2269 config: Config {
2270 targets: BTreeMap::from([("localhost".into(), TargetTemplate::LocalBare)]),
2271 bundles: BTreeMap::new(),
2274 ..Config::default()
2275 },
2276 state: State {
2277 sessions: BTreeMap::from([(session_id.into(), session)]),
2278 ..State::default()
2279 },
2280 };
2281
2282 let preflight = controller
2283 .preflight_resume_repository_sources(session_id, "localhost", &UnusedExecutor)
2284 .unwrap();
2285 let ResumeRepositorySourcePreflight::Ready(receipt) = preflight else {
2286 panic!("raw in-place resume unexpectedly needs a repository replacement");
2287 };
2288 assert!(controller.repository_source_receipt_is_current(session_id, &receipt));
2289 }
2290
2291 #[test]
2292 fn repository_preflight_distinguishes_the_original_source_from_a_reused_name() {
2293 fn git(repository: &Path, arguments: &[&str]) {
2294 let output = SystemGit
2295 .run(
2296 repository,
2297 &mj_checkpoint::archive::GitCommand {
2298 arguments: arguments.iter().map(std::ffi::OsString::from).collect(),
2299 stdin: Vec::new(),
2300 env: Vec::new(),
2301 },
2302 )
2303 .unwrap();
2304 assert_eq!(
2305 output.status,
2306 0,
2307 "git {arguments:?}: {}",
2308 String::from_utf8_lossy(&output.stderr)
2309 );
2310 }
2311
2312 let directory = tempfile::tempdir().unwrap();
2313 let origin = directory.path().join("original");
2314 std::fs::create_dir(&origin).unwrap();
2315 git(&origin, &["init", "-q", "-b", "main"]);
2316 git(&origin, &["config", "user.name", "Hel Test"]);
2317 git(&origin, &["config", "user.email", "hel@example.test"]);
2318 git(&origin, &["commit", "--allow-empty", "-qm", "base"]);
2319 let source = directory.path().join("source");
2320 git(
2321 directory.path(),
2322 &["clone", "-q", origin.to_str().unwrap(), "source"],
2323 );
2324 git(&source, &["config", "user.name", "Hel Test"]);
2325 git(&source, &["config", "user.email", "hel@example.test"]);
2326 git(&source, &["commit", "--allow-empty", "-qm", "session"]);
2327 let snapshot = mj_checkpoint::archive::collect_git_snapshot(
2328 &SystemGit,
2329 &source,
2330 &mj_checkpoint::archive::GitCollectionSpec {
2331 id: "project".into(),
2332 relative_destination: "project".into(),
2333 history: mj_checkpoint::archive::GitHistoryMode::SessionDelta,
2334 origin_override: None,
2335 },
2336 )
2337 .unwrap();
2338 let configured = ProjectRepository {
2339 id: "project".into(),
2340 github: None,
2341 local: Some(origin.clone()),
2342 destination: "project".into(),
2343 git_ref: None,
2344 };
2345 assert_eq!(
2346 checkpoint_source_missing_commit(
2347 &configured,
2348 &CheckpointRepositoryBundle {
2349 metadata: snapshot.metadata.clone(),
2350 committed_bundle: snapshot.committed_bundle.clone(),
2351 },
2352 &ProcessExecutor,
2353 None,
2354 )
2355 .unwrap(),
2356 None
2357 );
2358
2359 let replacement = directory.path().join("replacement");
2360 std::fs::create_dir(&replacement).unwrap();
2361 git(&replacement, &["init", "-q", "-b", "main"]);
2362 git(&replacement, &["config", "user.name", "Hel Test"]);
2363 git(&replacement, &["config", "user.email", "hel@example.test"]);
2364 git(
2365 &replacement,
2366 &["commit", "--allow-empty", "-qm", "different history"],
2367 );
2368 let configured = ProjectRepository {
2369 local: Some(replacement),
2370 ..configured
2371 };
2372 assert!(
2373 checkpoint_source_missing_commit(
2374 &configured,
2375 &CheckpointRepositoryBundle {
2376 metadata: snapshot.metadata,
2377 committed_bundle: snapshot.committed_bundle,
2378 },
2379 &ProcessExecutor,
2380 None,
2381 )
2382 .unwrap()
2383 .is_some()
2384 );
2385 }
2386
2387 #[test]
2388 fn repository_preflight_checks_independent_sources_concurrently_and_receipts_are_scoped() {
2389 struct ConcurrentSourceExecutor {
2390 source_checks: Barrier,
2391 }
2392
2393 impl CommandExecutor for ConcurrentSourceExecutor {
2394 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2395 if command.purpose == "check checkpoint base commit" {
2396 self.source_checks.wait();
2397 }
2398 Ok(CommandOutput {
2399 status: 0,
2400 stdout: Vec::new(),
2401 stderr: Vec::new(),
2402 })
2403 }
2404 }
2405
2406 let directory = tempfile::tempdir().unwrap();
2407 let session_id = "0123456789abcdef0123456789abcdef";
2408 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 3);
2409 let repositories = ["one", "two"]
2410 .map(|id| ProjectRepository {
2411 id: id.into(),
2412 github: None,
2413 local: Some(PathBuf::from(format!("/origin/{id}"))),
2414 destination: id.into(),
2415 git_ref: None,
2416 })
2417 .to_vec();
2418 let mut session = checkpoint_test_session(session_id);
2419 session.checkpoint = Some(checkpoint.clone());
2420 let mut controller = Controller {
2421 config: Config {
2422 bundles: BTreeMap::from([(
2423 session.bundle_id.clone(),
2424 ProjectBundle {
2425 primary_repo: "one".into(),
2426 repositories: repositories.clone(),
2427 },
2428 )]),
2429 ..Config::default()
2430 },
2431 state: State {
2432 sessions: BTreeMap::from([(session_id.into(), session)]),
2433 ..State::default()
2434 },
2435 };
2436 let verified = ResumeRepositoryBundles {
2437 checkpoint_sha256: checkpoint.sha256,
2438 repositories: repositories
2439 .iter()
2440 .map(|repository| CheckpointRepositoryBundle {
2441 metadata: mj_checkpoint::archive::RepositoryMetadata {
2442 push_urls: Vec::new(),
2443 remote_workspace: false,
2444 id: repository.id.clone(),
2445 relative_destination: repository.destination.clone(),
2446 origin: repository.source_label(),
2447 base_commit: String::new(),
2448 head_commit: if repository.id == "one" {
2449 "a".repeat(40)
2450 } else {
2451 "b".repeat(40)
2452 },
2453 branch: Some("main".into()),
2454 },
2455 committed_bundle: Vec::new(),
2456 })
2457 .collect(),
2458 };
2459 let executor = ConcurrentSourceExecutor {
2460 source_checks: Barrier::new(2),
2461 };
2462 let pool = rayon::ThreadPoolBuilder::new()
2463 .num_threads(2)
2464 .build()
2465 .unwrap();
2466 let preflight = pool
2467 .install(|| {
2468 controller.preflight_verified_repository_sources(
2469 session_id, verified, None, false, &executor,
2470 )
2471 })
2472 .unwrap();
2473 let ResumeRepositorySourcePreflight::Ready(receipt) = preflight else {
2474 panic!("expected repository source receipt");
2475 };
2476 assert!(controller.repository_source_receipt_is_current(session_id, &receipt));
2477
2478 controller
2479 .config
2480 .bundles
2481 .values_mut()
2482 .next()
2483 .unwrap()
2484 .repositories[0]
2485 .local = Some(PathBuf::from("/different-origin"));
2486 assert!(!controller.repository_source_receipt_is_current(session_id, &receipt));
2487 }
2488
2489 #[test]
2490 fn repository_preflight_checks_declared_boundary_without_importing_delta_bundle() {
2491 struct RecordingExecutor {
2492 commands: Mutex<Vec<CommandSpec>>,
2493 }
2494
2495 impl CommandExecutor for RecordingExecutor {
2496 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2497 self.commands.lock().unwrap().push(command.clone());
2498 Ok(CommandOutput {
2499 status: 0,
2500 stdout: Vec::new(),
2501 stderr: Vec::new(),
2502 })
2503 }
2504 }
2505
2506 let prerequisite = "a".repeat(40);
2507 let head = "b".repeat(40);
2508 let archived = CheckpointRepositoryBundle {
2509 metadata: mj_checkpoint::archive::RepositoryMetadata {
2510 push_urls: Vec::new(),
2511 remote_workspace: false,
2512 id: "project".into(),
2513 relative_destination: "project".into(),
2514 origin: "https://github.com/archived/should-not-be-contacted.git".into(),
2515 base_commit: prerequisite.clone(),
2516 head_commit: head.clone(),
2517 branch: Some("main".into()),
2518 },
2519 committed_bundle: format!(
2520 "# v2 git bundle\n-{prerequisite} base\n{head} HEAD\n\nPACKnot-read"
2521 )
2522 .into_bytes(),
2523 };
2524 let configured = ProjectRepository {
2525 id: "project".into(),
2526 github: Some("configured/project".into()),
2527 local: None,
2528 destination: "project".into(),
2529 git_ref: None,
2530 };
2531 let executor = RecordingExecutor {
2532 commands: Mutex::new(Vec::new()),
2533 };
2534
2535 assert_eq!(
2536 checkpoint_source_missing_commit(
2537 &configured,
2538 &archived,
2539 &executor,
2540 Some("secret-token")
2541 )
2542 .unwrap(),
2543 None
2544 );
2545
2546 let commands = executor.commands.into_inner().unwrap();
2547 assert_eq!(commands.len(), 2, "commands: {commands:?}");
2548 assert_eq!(
2549 commands
2550 .iter()
2551 .map(|command| command.purpose.as_str())
2552 .collect::<Vec<_>>(),
2553 [
2554 "initialize repository source preflight",
2555 "check checkpoint base commit"
2556 ]
2557 );
2558 let source_check = &commands[1];
2559 assert!(
2560 source_check
2561 .args
2562 .iter()
2563 .any(|argument| argument == "credential.helper=")
2564 );
2565 assert_eq!(
2566 source_check
2567 .env
2568 .get("GIT_NO_LAZY_FETCH")
2569 .map(String::as_str),
2570 Some("1")
2571 );
2572 assert_eq!(
2573 source_check
2574 .env
2575 .get("GIT_TERMINAL_PROMPT")
2576 .map(String::as_str),
2577 Some("0")
2578 );
2579 assert_eq!(
2580 source_check.args.last().map(String::as_str),
2581 Some(prerequisite.as_str())
2582 );
2583 assert!(
2584 !source_check
2585 .args
2586 .iter()
2587 .any(|argument| argument.contains("archived"))
2588 );
2589 }
2590
2591 #[test]
2592 fn self_contained_bundle_validation_cannot_lazy_fetch_or_prompt() {
2593 let command = checkpoint_bundle_import_command(
2594 Path::new("/tmp/repository.git"),
2595 Path::new("/tmp/checkpoint.bundle"),
2596 );
2597 assert_eq!(
2598 command.env.get("GIT_NO_LAZY_FETCH").map(String::as_str),
2599 Some("1")
2600 );
2601 assert_eq!(
2602 command.env.get("GIT_TERMINAL_PROMPT").map(String::as_str),
2603 Some("0")
2604 );
2605 }
2606
2607 #[test]
2608 fn lost_bundle_sessions_reach_resume_compatibility_before_the_record_changes() {
2609 struct UnusedExecutor;
2610
2611 impl CommandExecutor for UnusedExecutor {
2612 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2613 panic!("resume ran {} before rejecting the target", command.program);
2614 }
2615 }
2616
2617 let directory = tempfile::tempdir().unwrap();
2618 let session_id = "0123456789abcdef0123456789abcdef";
2619 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 3);
2620 let mut session = checkpoint_test_session(session_id);
2621 session.state = SessionState::Lost;
2622 session.checkpoint = Some(checkpoint);
2623 let previous = session.clone();
2624 let profile_home = directory.path().join("profile");
2625 std::fs::create_dir_all(&profile_home).unwrap();
2626 let mut config = Config::default();
2627 config.profiles.insert(
2628 "codex".into(),
2629 HarnessProfile {
2630 enabled: true,
2631 kind: mj_core::config::HarnessKind::Codex,
2632 home: profile_home,
2633 environment: BTreeMap::new(),
2634 context_window_bytes: None,
2635 guardian_review_model: None,
2636 },
2637 );
2638 config
2639 .targets
2640 .insert("localhost".into(), TargetTemplate::LocalBare);
2641 let mut controller = Controller {
2642 config,
2643 state: State {
2644 sessions: BTreeMap::from([(session_id.into(), session)]),
2645 ..State::default()
2646 },
2647 };
2648
2649 let error = tokio::runtime::Builder::new_current_thread()
2650 .enable_all()
2651 .build()
2652 .unwrap()
2653 .block_on(controller.resume_session_controlled(
2654 session_id,
2655 "codex",
2656 "localhost",
2657 SessionResumeOptions {
2658 additional_mounts: None,
2659 resource_allocation: None,
2660 discard_queue: false,
2661 },
2662 &UnusedExecutor,
2663 ))
2664 .unwrap_err();
2665
2666 let detail = format!("{error:#}");
2667 assert!(detail.contains("created from a project bundle"), "{detail}");
2668 assert!(
2669 detail.contains("resume it on a container, SSH, or EC2 target"),
2670 "{detail}"
2671 );
2672 assert_eq!(controller.state.sessions[session_id], previous);
2673 }
2674 struct BarrierExecutor {
2678 seen: Mutex<Vec<String>>,
2679 barrier: Barrier,
2680 }
2681
2682 impl CommandExecutor for BarrierExecutor {
2683 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2684 self.seen.lock().unwrap().push(command.purpose.clone());
2685 self.barrier.wait();
2686 Ok(CommandOutput {
2687 status: 0,
2688 stdout: Vec::new(),
2689 stderr: Vec::new(),
2690 })
2691 }
2692 }
2693
2694 fn lane_command(purpose: &str) -> CommandSpec {
2695 CommandSpec::new("hel", ["worker"]).purpose(purpose)
2696 }
2697
2698 #[test]
2703 fn start_begins_at_the_worker_launch_not_at_the_transfers_before_it() {
2704 struct RecordingExecutor {
2705 commands: RefCell<Vec<CommandSpec>>,
2706 }
2707 impl CommandExecutor for RecordingExecutor {
2708 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2709 self.commands.borrow_mut().push(command.clone());
2710 Ok(CommandOutput {
2711 status: 0,
2712 stdout: Vec::new(),
2713 stderr: Vec::new(),
2714 })
2715 }
2716 }
2717
2718 let session_id = "0123456789abcdef0123456789abcdef";
2719 let worker_root = format!("/var/lib/hel/workers/{session_id}");
2720 let executor = RecordingExecutor {
2721 commands: RefCell::new(Vec::new()),
2722 };
2723 let syncing = StagedExecutor::new(&executor, ProvisionStage::Syncing);
2724 let backend = targets::TargetLocator::LocalPodman {
2725 container_id: "abcdef0123456789".into(),
2726 workspace_storage: Default::default(),
2727 };
2728
2729 upload_checkpoint_spec(
2730 &syncing,
2731 &backend,
2732 session_id,
2733 Path::new("/archives/session.hel.zip"),
2734 &format!("{worker_root}/restore.hel.zip"),
2735 )
2736 .unwrap();
2737 execute_checked(
2738 &syncing,
2739 restore_command(
2740 &backend,
2741 session_id,
2742 &format!("{worker_root}/restore-spec.json"),
2743 )
2744 .unwrap(),
2745 )
2746 .unwrap();
2747 start_worker(&syncing, &backend, &worker_root).unwrap();
2750
2751 let stages = executor
2752 .commands
2753 .borrow()
2754 .iter()
2755 .map(|command| (command.purpose.clone(), command.stage))
2756 .collect::<Vec<_>>();
2757 assert_eq!(
2758 stages,
2759 vec![
2760 (
2761 "upload checkpoint specification".to_owned(),
2762 Some(ProvisionStage::Syncing)
2763 ),
2764 (
2765 "restore target checkpoint".to_owned(),
2766 Some(ProvisionStage::Syncing)
2767 ),
2768 (
2769 "start detached Mjolnir worker".to_owned(),
2770 Some(ProvisionStage::Starting)
2771 ),
2772 ]
2773 );
2774 }
2775 #[test]
2776 fn independent_target_lanes_run_at_the_same_time() {
2777 let executor = BarrierExecutor {
2778 seen: Mutex::new(Vec::new()),
2779 barrier: Barrier::new(2),
2780 };
2781
2782 execute_concurrent_lanes(
2783 || execute_checked(&executor, lane_command("install the worker")).map(|_| ()),
2784 || execute_checked(&executor, lane_command("upload the checkpoint")).map(|_| ()),
2785 )
2786 .unwrap();
2787
2788 let mut seen = executor.seen.into_inner().unwrap();
2789 seen.sort();
2790 assert_eq!(seen, ["install the worker", "upload the checkpoint"]);
2791 }
2792 #[test]
2793 fn a_lane_failure_is_reported_in_lane_order_and_never_abandons_the_other_lane() {
2794 let reached = Mutex::new(Vec::new());
2795
2796 let error = execute_concurrent_lanes(
2799 || -> Result<()> {
2800 std::thread::sleep(Duration::from_millis(50));
2801 bail!("worker install failed")
2802 },
2803 || -> Result<()> {
2804 reached.lock().unwrap().push("second");
2805 bail!("checkpoint upload failed")
2806 },
2807 )
2808 .unwrap_err();
2809
2810 assert_eq!(error.to_string(), "worker install failed");
2811 assert_eq!(
2812 *reached.lock().unwrap(),
2813 ["second"],
2814 "a failing first lane must not cut the second one short"
2815 );
2816
2817 let error = execute_concurrent_lanes(
2818 || Ok(()),
2819 || -> Result<()> { bail!("checkpoint upload failed") },
2820 )
2821 .unwrap_err();
2822 assert_eq!(error.to_string(), "checkpoint upload failed");
2823 }
2824
2825 #[test]
2826 fn cross_harness_lanes_prove_overlap_with_handshake_channels() {
2827 let (provision_started_tx, provision_started_rx) = std::sync::mpsc::sync_channel::<()>(1);
2828 let (handoff_started_tx, handoff_started_rx) = std::sync::mpsc::sync_channel::<()>(1);
2829 let (provision_seen_handoff_tx, provision_seen_handoff_rx) =
2830 std::sync::mpsc::sync_channel::<()>(1);
2831 let (handoff_seen_provision_tx, handoff_seen_provision_rx) =
2832 std::sync::mpsc::sync_channel::<()>(1);
2833
2834 execute_joined_cross_harness_work(
2835 "provision",
2836 move |_cancellation| -> Result<()> {
2837 provision_started_tx
2838 .send(())
2839 .map_err(|error| anyhow::anyhow!("signal provisioning start: {error}"))?;
2840 handoff_started_rx
2841 .recv_timeout(Duration::from_secs(2))
2842 .map_err(|error| anyhow::anyhow!("wait for handoff start: {error}"))?;
2843 provision_seen_handoff_tx
2844 .send(())
2845 .map_err(|error| anyhow::anyhow!("signal provisioning overlap: {error}"))?;
2846 Ok(())
2847 },
2848 "handoff",
2849 move |_cancellation| -> Result<()> {
2850 handoff_started_tx
2851 .send(())
2852 .map_err(|error| anyhow::anyhow!("signal handoff start: {error}"))?;
2853 provision_started_rx
2854 .recv_timeout(Duration::from_secs(2))
2855 .map_err(|error| anyhow::anyhow!("wait for provisioning start: {error}"))?;
2856 handoff_seen_provision_tx
2857 .send(())
2858 .map_err(|error| anyhow::anyhow!("signal handoff overlap: {error}"))?;
2859 Ok(())
2860 },
2861 )
2862 .unwrap();
2863
2864 assert!(provision_seen_handoff_rx.recv().is_ok());
2865 assert!(handoff_seen_provision_rx.recv().is_ok());
2866 }
2867
2868 #[test]
2869 fn cross_harness_lane_failure_cancels_and_joins_the_peer() {
2870 let (handoff_started_tx, handoff_started_rx) = std::sync::mpsc::sync_channel::<()>(1);
2871 let (handoff_joined_tx, handoff_joined_rx) = std::sync::mpsc::sync_channel::<()>(1);
2872
2873 let error = execute_joined_cross_harness_work(
2874 "provision",
2875 move |_cancellation| -> Result<()> {
2876 handoff_started_rx
2877 .recv_timeout(Duration::from_secs(2))
2878 .map_err(|error| anyhow::anyhow!("wait for handoff start: {error}"))?;
2879 bail!("provisioning failed after handoff started");
2880 },
2881 "handoff",
2882 move |cancellation| -> Result<()> {
2883 handoff_started_tx
2884 .send(())
2885 .map_err(|error| anyhow::anyhow!("signal handoff start: {error}"))?;
2886 let runtime = tokio::runtime::Builder::new_current_thread()
2887 .enable_all()
2888 .build()
2889 .map_err(|error| {
2890 anyhow::anyhow!("create cancellation test runtime: {error}")
2891 })?;
2892 runtime
2893 .block_on(async {
2894 tokio::time::timeout(Duration::from_secs(2), cancellation.cancelled()).await
2895 })
2896 .map_err(|error| anyhow::anyhow!("peer was not cancelled: {error}"))?;
2897 handoff_joined_tx
2898 .send(())
2899 .map_err(|error| anyhow::anyhow!("signal handoff join: {error}"))?;
2900 Ok(())
2901 },
2902 )
2903 .unwrap_err();
2904
2905 assert_eq!(
2906 error.to_string(),
2907 "provisioning failed after handoff started"
2908 );
2909 assert!(handoff_joined_rx.recv().is_ok());
2910 }
2911
2912 #[test]
2913 fn a_projection_standing_at_the_archived_frontier_is_reused() {
2914 let digest = "a".repeat(64);
2915 let other = "b".repeat(64);
2916
2917 assert!(!projection_rebuild_required(
2918 Some((82_000, &digest)),
2919 82_000,
2920 &digest
2921 ));
2922
2923 for stored in [
2924 Some((82_000, other.as_str())),
2926 Some((81_999, digest.as_str())),
2928 Some((82_001, digest.as_str())),
2929 None,
2931 ] {
2932 assert!(
2933 projection_rebuild_required(stored, 82_000, &digest),
2934 "{stored:?} must not be mistaken for the archived projection"
2935 );
2936 }
2937 }
2938
2939 #[test]
2940 fn local_bare_restore_reuses_verified_absolute_archive_without_upload() {
2941 let archive = Path::new("/var/lib/hel/archives/session.hel.zip");
2942 let remote = Path::new("/var/lib/hel/workers/session/restore.hel.zip");
2943 let local = targets::TargetLocator::LocalBare {
2944 worker_root: "/var/lib/hel/workers/session".into(),
2945 };
2946 let container = targets::TargetLocator::LocalPodman {
2947 container_id: "container".into(),
2948 workspace_storage: Default::default(),
2949 };
2950
2951 assert_eq!(restore_archive_path(&local, archive, remote), archive);
2952 assert!(!should_upload_restore_archive(&local));
2953 assert_eq!(restore_archive_path(&container, archive, remote), remote);
2954 assert!(should_upload_restore_archive(&container));
2955 }
2956
2957 #[test]
2958 fn cross_harness_provision_cancellation_stops_the_next_command() {
2959 struct RecordingExecutor {
2960 commands: Mutex<Vec<String>>,
2961 }
2962
2963 impl CommandExecutor for RecordingExecutor {
2964 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2965 self.commands.lock().unwrap().push(command.purpose.clone());
2966 Ok(CommandOutput {
2967 status: 0,
2968 stdout: Vec::new(),
2969 stderr: Vec::new(),
2970 })
2971 }
2972 }
2973
2974 let inner = RecordingExecutor {
2975 commands: Mutex::new(Vec::new()),
2976 };
2977 let cancellation = CancellationToken::new();
2978 let provision = CrossHarnessProvisionExecutor {
2979 inner: &inner,
2980 cancellation: cancellation.clone(),
2981 };
2982 let command = CommandSpec::new("hel", ["worker"]).purpose("provision target");
2983 provision.execute(&command).unwrap();
2984 cancellation.cancel();
2985
2986 let error = provision.execute(&command).unwrap_err();
2987 assert!(error.to_string().contains("cancelled while provisioning"));
2988 assert_eq!(
2989 inner.commands.lock().unwrap().as_slice(),
2990 ["provision target"]
2991 );
2992 }
2993
2994 #[test]
2995 fn failed_resume_rolls_back_only_after_target_cleanup() {
2996 let previous = SessionRecord {
2997 mjolnir_subagents: None,
2998 create_managed_worktree: None,
2999 workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
3000 archived: false,
3001 container_cpus: None,
3002 container_memory: None,
3003 id: "0123456789abcdef0123456789abcdef".into(),
3004 title: "imported session".into(),
3005 harness_kind: mj_core::config::HarnessKind::Codex,
3006 last_profile: "codex-old".into(),
3007 bundle_id: "project".into(),
3008 project_directory: None,
3009 managed_worktree: None,
3010 target_template_id: "podman-old".into(),
3011 resource_allocation: None,
3012 additional_mounts: Vec::new(),
3013 state: SessionState::Stopped,
3014 target: None,
3015 native_session_id: Some("native-session".into()),
3016 acp_session_title: None,
3017 session_title_override: None,
3018 created_at: "2026-08-12T00:00:00Z".into(),
3019 updated_at: "2026-08-12T00:00:00Z".into(),
3020 viewed_through_event_ordinal: 0,
3021 draft_input: String::new(),
3022 last_error: None,
3023 last_checkpoint_error: None,
3024 checkpoint: None,
3025 };
3026 let partial_target = TargetLocator::LocalPodman {
3027 container_id: "partial-container".into(),
3028 workspace_storage: Default::default(),
3029 };
3030 let mut cleaned = previous.clone();
3031 cleaned.state = SessionState::Error;
3032 cleaned.last_profile = "codex-new".into();
3033 cleaned.target = Some(partial_target.clone());
3034
3035 let failure =
3036 apply_failed_resume_rollback(&mut cleaned, &previous, "worker upload failed", None);
3037
3038 assert_eq!(cleaned.state, SessionState::Stopped);
3039 assert_eq!(cleaned.last_profile, "codex-old");
3040 assert_eq!(cleaned.target, None);
3041 assert_eq!(failure.to_string(), "worker upload failed");
3042 assert_eq!(
3043 cleaned.last_error.as_deref(),
3044 Some("resume failed: worker upload failed")
3045 );
3046
3047 let mut cleanup_failed = previous.clone();
3048 cleanup_failed.state = SessionState::Error;
3049 cleanup_failed.last_profile = "codex-new".into();
3050 cleanup_failed.target = Some(partial_target.clone());
3051 let partial_checkout = crate::controller::test_support::managed_raw_session(
3052 mj_core::state::ManagedWorktreeTarget::Local,
3053 );
3054 cleanup_failed.project_directory = partial_checkout.project_directory.clone();
3055 cleanup_failed.managed_worktree = partial_checkout.managed_worktree.clone();
3056
3057 let failure = apply_failed_resume_rollback(
3058 &mut cleanup_failed,
3059 &previous,
3060 "worker upload failed",
3061 Some("podman rm failed".into()),
3062 );
3063
3064 assert_eq!(cleanup_failed.state, SessionState::Error);
3065 assert_eq!(cleanup_failed.last_profile, "codex-new");
3066 assert_eq!(cleanup_failed.target, Some(partial_target));
3067 assert_eq!(
3068 cleanup_failed.project_directory,
3069 partial_checkout.project_directory
3070 );
3071 assert_eq!(
3072 cleanup_failed.managed_worktree,
3073 partial_checkout.managed_worktree
3074 );
3075 assert!(failure.to_string().contains("cleanup"));
3076 }
3077 #[test]
3078 fn failed_worktree_cleanup_notice_names_mjolnir_and_the_recovery_command() {
3079 let notice = worktree_cleanup_notice(
3080 Path::new("/workspace/project"),
3081 &anyhow::anyhow!("permission denied"),
3082 );
3083
3084 assert!(
3085 notice.starts_with(
3086 "Mjolnir could not remove the worktree at /workspace/project: permission denied."
3087 ),
3088 "{notice}"
3089 );
3090 assert!(
3091 notice.contains("`git worktree remove --force /workspace/project`"),
3092 "{notice}"
3093 );
3094 assert!(!notice.contains("Hel"), "{notice}");
3095 }
3096 #[test]
3097 fn failed_resume_provisioning_preserves_checkpoint_and_projection_lineage() {
3098 if std::env::var_os(RESUME_ROLLBACK_TEST_CHILD).is_none() {
3101 let directory = tempfile::tempdir().unwrap();
3102 let test_name = format!(
3103 "{}::failed_resume_provisioning_preserves_checkpoint_and_projection_lineage",
3104 module_path!()
3105 .strip_prefix("mj_controller::")
3106 .unwrap_or(module_path!())
3107 );
3108 let output = Command::new(std::env::current_exe().unwrap())
3109 .args(["--exact", &test_name, "--nocapture"])
3110 .env(RESUME_ROLLBACK_TEST_CHILD, "1")
3111 .env("MJ_WORKER_BINARY", std::env::current_exe().unwrap())
3114 .env("MJ_DATA_DIR", directory.path())
3115 .env("GH_TOKEN", "test-token")
3116 .output()
3117 .unwrap();
3118 assert!(
3119 output.status.success(),
3120 "isolated resume rollback test failed\nstdout:\n{}\nstderr:\n{}",
3121 String::from_utf8_lossy(&output.stdout),
3122 String::from_utf8_lossy(&output.stderr)
3123 );
3124 return;
3125 }
3126 let _writer = crate::database::install_isolated_test_writer();
3128
3129 #[derive(Default)]
3132 struct FailingPreflightExecutor {
3133 mounts_during_provisioning: Mutex<Option<Vec<AdditionalMount>>>,
3134 }
3135
3136 impl CommandExecutor for FailingPreflightExecutor {
3137 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
3138 if command.program == "stat" {
3141 return Ok(CommandOutput {
3142 status: 0,
3143 stdout: b"ext4\n".to_vec(),
3144 stderr: Vec::new(),
3145 });
3146 }
3147 assert_eq!(command.program, "podman");
3148 let mut observed = self.mounts_during_provisioning.lock().unwrap();
3149 if observed.is_none() {
3150 let durable = crate::database::load_state().unwrap();
3151 *observed = Some(
3152 durable.sessions["0123456789abcdef0123456789abcdef"]
3153 .additional_mounts
3154 .clone(),
3155 );
3156 }
3157 Ok(CommandOutput {
3158 status: 1,
3159 stdout: Vec::new(),
3160 stderr: b"podman is temporarily unavailable".to_vec(),
3161 })
3162 }
3163 }
3164
3165 let data_directory = PathBuf::from(std::env::var_os("MJ_DATA_DIR").unwrap());
3166 let archive_directory = data_directory.join("archives");
3167 std::fs::create_dir_all(&archive_directory).unwrap();
3168 let session_id = "0123456789abcdef0123456789abcdef";
3169 let checkpoint = super::super::test_support::write_network_checkpoint_archive(
3170 &archive_directory,
3171 session_id,
3172 7,
3173 );
3174 let archive = verify_archive_streaming(&checkpoint.archive_path).unwrap();
3175 let expected_projection =
3176 materialized_session_from_canonical(session_id, &archive.canonical_session).unwrap();
3177
3178 let mut session = checkpoint_test_session(session_id);
3179 session.state = SessionState::Stopped;
3180 session.checkpoint = Some(checkpoint.clone());
3181 session.additional_mounts = vec![AdditionalMount {
3182 source: PathBuf::from("/host/old"),
3183 destination: PathBuf::from("/mnt/old"),
3184 read_only: false,
3185 }];
3186 let previous = session.clone();
3187 let resumed_mounts = vec![AdditionalMount {
3188 source: PathBuf::from("/host/new"),
3189 destination: PathBuf::from("/mnt/new"),
3190 read_only: false,
3191 }];
3192 let profile_home = data_directory.join("profile");
3193 std::fs::create_dir_all(&profile_home).unwrap();
3194 let mut config = Config::default();
3195 config.profiles.insert(
3196 "codex".into(),
3197 HarnessProfile {
3198 enabled: true,
3199 kind: mj_core::config::HarnessKind::Codex,
3200 home: profile_home,
3201 environment: BTreeMap::new(),
3202 context_window_bytes: None,
3203 guardian_review_model: None,
3204 },
3205 );
3206 config.bundles.insert(
3207 "project".into(),
3208 ProjectBundle {
3209 primary_repo: "project".into(),
3210 repositories: vec![ProjectRepository {
3211 id: "project".into(),
3212 github: None,
3213 local: Some(data_directory.join("host-clone-that-no-longer-exists")),
3214 destination: "project".into(),
3215 git_ref: None,
3216 }],
3217 },
3218 );
3219 config.targets.insert(
3220 "podman".into(),
3221 TargetTemplate::LocalPodman {
3222 container: ConfigContainer {
3223 image: "example.invalid/hel-test:latest".into(),
3224 pull_policy: Default::default(),
3225 platform: None,
3226 cpus: None,
3227 memory: None,
3228 environment: BTreeMap::new(),
3229 workspace_storage: Default::default(),
3230 },
3231 },
3232 );
3233 let mut controller = Controller {
3234 config,
3235 state: State {
3236 sessions: BTreeMap::from([(session_id.into(), session)]),
3237 ..State::default()
3238 },
3239 };
3240 crate::database::save_state(&controller.state).unwrap();
3241 crate::database::save_materialized_session(&expected_projection).unwrap();
3242
3243 let runtime = tokio::runtime::Builder::new_current_thread()
3244 .enable_all()
3245 .build()
3246 .unwrap();
3247 let executor = FailingPreflightExecutor::default();
3248 let error = runtime
3249 .block_on(controller.resume_session_controlled(
3250 session_id,
3251 "codex",
3252 "podman",
3253 SessionResumeOptions {
3254 additional_mounts: Some(resumed_mounts.clone()),
3255 resource_allocation: None,
3256 discard_queue: false,
3257 },
3258 &executor,
3259 ))
3260 .unwrap_err();
3261 let detail = format!("{error:#}");
3262 assert!(
3263 detail.contains("podman is temporarily unavailable"),
3264 "{detail}"
3265 );
3266 assert!(!detail.contains("returned to stopped"), "{detail}");
3267 assert!(!detail.contains("unknown session"), "{detail}");
3268 assert_eq!(
3269 executor.mounts_during_provisioning.into_inner().unwrap(),
3270 Some(resumed_mounts)
3271 );
3272
3273 let retained = controller.state.sessions.get(session_id).unwrap();
3274 assert_eq!(retained.state, SessionState::Stopped);
3275 assert_eq!(retained.checkpoint, previous.checkpoint);
3276 assert_eq!(retained.managed_worktree, previous.managed_worktree);
3277 assert!(checkpoint.archive_path.is_file());
3278
3279 let durable = crate::database::load_state().unwrap();
3280 let durable_session = durable.sessions.get(session_id).unwrap();
3281 assert_eq!(durable_session.state, SessionState::Stopped);
3282 assert_eq!(durable_session.checkpoint, previous.checkpoint);
3283 assert_eq!(
3284 durable_session.additional_mounts,
3285 previous.additional_mounts
3286 );
3287 assert_eq!(
3288 crate::database::load_materialized_session(session_id).unwrap(),
3289 Some(expected_projection)
3290 );
3291 }
3292 #[test]
3293 fn failed_resume_retires_a_checkout_it_recreated() {
3294 if std::env::var_os(RETIRED_WORKTREE_RESUME_TEST_CHILD).is_none() {
3295 let directory = tempfile::tempdir().unwrap();
3296 let test_name = format!(
3297 "{}::failed_resume_retires_a_checkout_it_recreated",
3298 module_path!()
3299 .strip_prefix("mj_controller::")
3300 .unwrap_or(module_path!())
3301 );
3302 let output = Command::new(std::env::current_exe().unwrap())
3303 .args(["--exact", &test_name, "--nocapture"])
3304 .env(RETIRED_WORKTREE_RESUME_TEST_CHILD, "1")
3305 .env("MJ_WORKER_BINARY", std::env::current_exe().unwrap())
3308 .env("MJ_DATA_DIR", directory.path().join("data"))
3309 .env("MJ_CONFIG_DIR", directory.path().join("config"))
3310 .output()
3311 .unwrap();
3312 assert!(
3313 output.status.success(),
3314 "isolated retired-worktree resume test failed\nstdout:\n{}\nstderr:\n{}",
3315 String::from_utf8_lossy(&output.stdout),
3316 String::from_utf8_lossy(&output.stderr)
3317 );
3318 return;
3319 }
3320 let _writer = crate::database::install_isolated_test_writer();
3322
3323 struct FailAfterWorktreeRestore;
3324
3325 impl CommandExecutor for FailAfterWorktreeRestore {
3326 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
3327 if matches!(command.program.as_str(), "git" | "mkdir") {
3328 return ProcessExecutor.execute(command);
3329 }
3330 Ok(CommandOutput {
3331 status: 1,
3332 stdout: Vec::new(),
3333 stderr: b"stop after recreating the checkout".to_vec(),
3334 })
3335 }
3336 }
3337
3338 let data_directory = PathBuf::from(std::env::var_os("MJ_DATA_DIR").unwrap());
3339 let archive_directory = data_directory.join("archives");
3340 std::fs::create_dir_all(&archive_directory).unwrap();
3341 let session_id = "0123456789abcdef0123456789abcdef";
3342 let checkpoint = write_checkpoint_gate_archive(&archive_directory, session_id, 7);
3343 let repository = committed_repository();
3344 let mut session = managed_worktree_session(repository.path(), session_id);
3345 session.checkpoint = Some(checkpoint);
3346 let worktree = session.managed_worktree.clone().unwrap();
3347 retire_managed_worktree(&ProcessExecutor, &worktree).unwrap();
3348 assert!(!worktree.worktree_root.exists());
3349
3350 let profile_home = data_directory.join("profile");
3351 std::fs::create_dir_all(&profile_home).unwrap();
3352 let mut config = resume_compatibility_config();
3353 config.profiles.insert(
3354 "codex".into(),
3355 HarnessProfile {
3356 enabled: true,
3357 kind: mj_core::config::HarnessKind::Codex,
3358 home: profile_home,
3359 environment: BTreeMap::new(),
3360 context_window_bytes: None,
3361 guardian_review_model: None,
3362 },
3363 );
3364 let mut controller = Controller {
3365 config,
3366 state: State {
3367 sessions: BTreeMap::from([(session_id.into(), session)]),
3368 ..State::default()
3369 },
3370 };
3371 crate::database::save_state(&controller.state).unwrap();
3372
3373 let error = tokio::runtime::Builder::new_current_thread()
3374 .enable_all()
3375 .build()
3376 .unwrap()
3377 .block_on(controller.resume_session_controlled(
3378 session_id,
3379 "codex",
3380 "local-bare",
3381 SessionResumeOptions {
3382 additional_mounts: None,
3383 resource_allocation: None,
3384 discard_queue: false,
3385 },
3386 &FailAfterWorktreeRestore,
3387 ))
3388 .unwrap_err();
3389
3390 assert!(
3391 format!("{error:#}").contains("stop after recreating the checkout"),
3392 "{error:#}"
3393 );
3394 assert!(!worktree.worktree_root.exists());
3395 assert_eq!(
3396 controller.state.sessions[session_id].state,
3397 SessionState::Stopped
3398 );
3399 let branch = Command::new("git")
3400 .arg("-C")
3401 .arg(repository.path())
3402 .args([
3403 "show-ref",
3404 "--verify",
3405 &format!("refs/heads/{}", worktree.branch),
3406 ])
3407 .status()
3408 .unwrap();
3409 assert!(branch.success(), "resume rollback must retain the branch");
3410 }
3411 #[test]
3412 fn a_conversion_archive_carries_the_checkouts_remote_and_the_conversation() {
3413 let directory = tempfile::tempdir().unwrap();
3414 let session_id = "0123456789abcdef0123456789abcdef";
3415 let previous = write_checkpoint_archive_with_native_state(directory.path(), session_id, 7);
3416 let (checkout, _remote_parent, _remote) = checkout_with_network_remote();
3417 let source =
3418 mj_core::remote_git::resolve_local_repository(checkout.path(), &ProcessExecutor)
3419 .unwrap();
3420 let dirname = PathBuf::from(checkout.path().file_name().unwrap());
3421 let snapshot =
3422 raw_checkout_snapshot(checkout.path(), &source, &dirname, &SystemGit).unwrap();
3423
3424 let output = directory.path().join("converted.hel.zip");
3425 let converted = conversion_checkpoint(&previous.archive_path, snapshot, &output).unwrap();
3426
3427 let verified = mj_checkpoint::archive::read_archive_verified(&output).unwrap();
3430 assert_eq!(converted.archive_path, output);
3431 assert_eq!(converted.sha256, verified.archive_sha256);
3432 assert_eq!(converted.event_frontier, previous.event_frontier);
3433 let bundle =
3434 crate::controller::network_git::bundle_from_manifest(&verified.manifest).unwrap();
3435 assert_eq!(bundle.primary, dirname.to_string_lossy());
3436 assert_eq!(bundle.repositories.len(), 1);
3437 assert_eq!(
3438 bundle.repositories[0].url.as_deref(),
3439 Some(FIXTURE_FETCH_URL)
3440 );
3441 assert_eq!(bundle.repositories[0].push_urls, [FIXTURE_FETCH_URL]);
3442 assert_eq!(
3443 bundle.repositories[0].destination,
3444 dirname.to_string_lossy()
3445 );
3446
3447 let original =
3449 mj_checkpoint::archive::read_archive_verified(&previous.archive_path).unwrap();
3450 assert_eq!(
3451 verified.canonical_session().unwrap(),
3452 original.canonical_session().unwrap()
3453 );
3454 assert_eq!(verified.manifest.session, original.manifest.session);
3455 assert_eq!(native_state(&original), native_state(&verified));
3456 assert!(
3457 !native_state(&verified).is_empty(),
3458 "the fixture has native state"
3459 );
3460 }
3461
3462 fn native_state(
3464 archive: &mj_checkpoint::archive::VerifiedArchive,
3465 ) -> Vec<(PathBuf, u32, Vec<u8>)> {
3466 archive
3467 .manifest
3468 .payloads
3469 .iter()
3470 .filter_map(|descriptor| match &descriptor.role {
3471 mj_checkpoint::archive::PayloadRole::NativeArtifact { relative_path } => Some((
3472 relative_path.clone(),
3473 descriptor.mode,
3474 archive.payload(descriptor).unwrap().to_vec(),
3475 )),
3476 _ => None,
3477 })
3478 .collect()
3479 }
3480 const RAW_CONVERSION_TEST_CHILD: &str = "MJ_RAW_CONVERSION_TEST_CHILD";
3481 #[test]
3482 fn a_failed_raw_conversion_keeps_the_checkout_and_its_previous_checkpoint() {
3483 if std::env::var_os(RAW_CONVERSION_TEST_CHILD).is_none() {
3486 let directory = tempfile::tempdir().unwrap();
3487 let test_name = format!(
3488 "{}::a_failed_raw_conversion_keeps_the_checkout_and_its_previous_checkpoint",
3489 module_path!()
3490 .strip_prefix("mj_controller::")
3491 .unwrap_or(module_path!())
3492 );
3493 let output = Command::new(std::env::current_exe().unwrap())
3494 .args(["--exact", &test_name, "--nocapture"])
3495 .env(RAW_CONVERSION_TEST_CHILD, "1")
3496 .env("MJ_WORKER_BINARY", std::env::current_exe().unwrap())
3499 .env("MJ_DATA_DIR", directory.path().join("data"))
3500 .env("MJ_CONFIG_DIR", directory.path().join("config"))
3501 .env("GH_TOKEN", "test-token")
3502 .output()
3503 .unwrap();
3504 assert!(
3505 output.status.success(),
3506 "isolated raw conversion test failed\nstdout:\n{}\nstderr:\n{}",
3507 String::from_utf8_lossy(&output.stdout),
3508 String::from_utf8_lossy(&output.stderr)
3509 );
3510 return;
3511 }
3512 let _writer = crate::database::install_isolated_test_writer();
3514
3515 struct GitWithoutPodmanExecutor;
3518
3519 impl CommandExecutor for GitWithoutPodmanExecutor {
3520 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
3521 if command.program == "git" {
3522 return ProcessExecutor.execute(command);
3523 }
3524 Ok(CommandOutput {
3525 status: 1,
3526 stdout: Vec::new(),
3527 stderr: b"podman is temporarily unavailable".to_vec(),
3528 })
3529 }
3530 }
3531
3532 let data_directory = PathBuf::from(std::env::var_os("MJ_DATA_DIR").unwrap());
3533 let archive_directory = data_directory.join("archives");
3534 std::fs::create_dir_all(&archive_directory).unwrap();
3535 std::fs::create_dir_all(mj_core::config::config_dir()).unwrap();
3536 let session_id = "0123456789abcdef0123456789abcdef";
3537 let checkpoint = write_checkpoint_gate_archive(&archive_directory, session_id, 7);
3538
3539 let repository = committed_repository();
3540 let (_remote_parent, _remote) = network_remote_for(repository.path());
3543 let mut session = managed_worktree_session(repository.path(), session_id);
3544 session.checkpoint = Some(checkpoint.clone());
3545 let worktree = session.managed_worktree.clone().unwrap();
3546 let previous = session.clone();
3547
3548 let profile_home = data_directory.join("profile");
3549 std::fs::create_dir_all(&profile_home).unwrap();
3550 let mut config = resume_compatibility_config();
3551 config.profiles.insert(
3552 "codex".into(),
3553 HarnessProfile {
3554 enabled: true,
3555 kind: mj_core::config::HarnessKind::Codex,
3556 home: profile_home,
3557 environment: BTreeMap::new(),
3558 context_window_bytes: None,
3559 guardian_review_model: None,
3560 },
3561 );
3562 config.save().unwrap();
3565 let original_config = config.clone();
3566 let mut controller = Controller {
3567 config,
3568 state: State {
3569 sessions: BTreeMap::from([(session_id.into(), session)]),
3570 ..State::default()
3571 },
3572 };
3573 crate::database::save_state(&controller.state).unwrap();
3574
3575 let error = tokio::runtime::Builder::new_current_thread()
3576 .enable_all()
3577 .build()
3578 .unwrap()
3579 .block_on(controller.resume_session_controlled(
3580 session_id,
3581 "codex",
3582 "podman",
3583 SessionResumeOptions {
3584 additional_mounts: None,
3585 resource_allocation: None,
3586 discard_queue: false,
3587 },
3588 &GitWithoutPodmanExecutor,
3589 ))
3590 .unwrap_err();
3591 assert!(
3594 format!("{error:#}").contains("podman is temporarily unavailable"),
3595 "{error:#}"
3596 );
3597 assert!(!format!("{error:#}").contains("returned to stopped"));
3598
3599 let mut expected_config = original_config.clone();
3602 let (bundle_id, bundle) = mj_core::config::Config::load()
3603 .unwrap()
3604 .bundles
3605 .into_iter()
3606 .next()
3607 .expect("the conversion installed a bundle for the checkout");
3608 expected_config.bundles.insert(bundle_id, bundle);
3609 assert_eq!(
3610 controller.config,
3611 expected_config.clone().with_local_targets()
3612 );
3613 assert_eq!(
3614 mj_core::config::Config::load_from(&mj_core::config::config_path()).unwrap(),
3615 expected_config
3616 );
3617
3618 let retained = controller.state.sessions.get(session_id).unwrap();
3619 assert_eq!(retained.state, SessionState::Stopped);
3620 assert_eq!(retained.checkpoint, Some(checkpoint.clone()));
3621 assert_eq!(retained.project_directory, previous.project_directory);
3622 assert_eq!(retained.managed_worktree, previous.managed_worktree);
3623 assert_eq!(retained.bundle_id, previous.bundle_id);
3624 assert!(worktree.worktree_root.is_dir(), "the checkout stays put");
3625 assert!(
3626 checkpoint.archive_path.is_file(),
3627 "the previous archive is what the rolled-back record names"
3628 );
3629 let durable = crate::database::load_state().unwrap();
3630 assert_eq!(durable.sessions[session_id].checkpoint, Some(checkpoint));
3631 let sessions = mj_core::config::sessions_dir();
3634 assert!(sessions.is_dir(), "the conversion wrote an archive");
3635 let leftover: Vec<_> = std::fs::read_dir(&sessions)
3636 .map(|entries| {
3637 entries
3638 .map(|entry| entry.unwrap().file_name())
3639 .filter(|name| name.to_string_lossy().ends_with(".hel.zip"))
3640 .collect()
3641 })
3642 .unwrap_or_default();
3643 assert!(
3644 leftover.is_empty(),
3645 "{leftover:?} in {}",
3646 sessions.display()
3647 );
3648 }
3649
3650 #[test]
3653 fn only_the_same_harness_keeps_native_continuity_on_resume() {
3654 use mj_core::config::HarnessKind;
3655
3656 assert!(super::native_continuity_preserved(
3657 HarnessKind::Codex,
3658 HarnessKind::Codex
3659 ));
3660 assert!(super::native_continuity_preserved(
3661 HarnessKind::Claude,
3662 HarnessKind::Claude
3663 ));
3664 assert!(!super::native_continuity_preserved(
3665 HarnessKind::Claude,
3666 HarnessKind::Codex
3667 ));
3668 }
3669}