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