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