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