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