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