1use std::collections::BTreeSet;
4use std::ffi::OsStr;
5use std::path::{Path, PathBuf};
6use std::time::{Duration, Instant};
7
8use anyhow::{Context, Result, bail, ensure};
9
10use crate::hel_session_manager::{
11 ManagedSessionHandle, ManagedSessionLease, SessionManagerControl, StandaloneSession,
12 new_command_id, worker_connect_needs_restart,
13};
14use crate::hel_worker_client::RelayRejected;
15use hel::hel_archive::{
16 BundleManifest, CanonicalSessionSnapshot, SessionManifest, TargetManifest,
17 verify_archive_streaming,
18};
19use hel::hel_checkpoint::{
20 CHECKPOINT_EXPORT_PROTOCOL_VERSION, CHECKPOINT_STAGING_PROTOCOL_VERSION, CapturedCheckpoint,
21 CheckpointCaptureSpec, CheckpointExportSpec, CheckpointPackSpec, CheckpointRepositoryCapture,
22 CheckpointRepositorySpec, CheckpointTransfer, canonical_session_contains_prompt,
23 capture_stdin_command, checkpoint_sha256, export_command, export_stdin_command,
24 pack_stdin_command,
25};
26use hel::hel_config::sessions_dir;
27use hel::hel_projection::canonical_session_from_materialized;
28use hel::hel_state::{
29 CheckpointMetadata, HelState, ManagedSessionSnapshot, SessionRecord, SessionState,
30};
31use hel::hel_targets::{
32 self, CommandExecutor, CommandOutput, CommandSpec, ProcessExecutor, ProvisionStage,
33 ProvisionStageGuard,
34};
35use hel::hel_worker::{RelayCommand, RelayCursor, RelayExecutionState};
36
37use super::backend::backend_locator;
38use super::readiness::wait_for_native_session_in_stage;
39use super::worker_restart::{InstalledWorkerRestart, RESTART_FOR_CHECKPOINT};
40use super::{
41 Controller, execute_checked, now, persist_session_record_transition_or_restore,
42 scp_command_spec, ssh_command_spec, target_kind, target_profile_home,
43};
44
45const CHECKPOINT_BARRIER_TIMEOUT: Duration = Duration::from_secs(30);
50const CHECKPOINT_CANCEL_TIMEOUT: Duration = Duration::from_secs(30);
54const CHECKPOINT_BARRIER_TIMEOUT_AFTER_RESTART: Duration = Duration::from_secs(300);
57
58pub fn reconcile_managed_checkpoint_archives() -> Result<usize> {
62 let mut state = HelState::load()?;
63 for operation in hel::hel_database::load_move_operations()? {
66 if operation.retains_checkpoint()
67 && let Some(checkpoint) = operation.checkpoint
68 && let Some(mut session) = state.sessions.get(&operation.selection.session_id).cloned()
69 {
70 session.checkpoint = Some(checkpoint);
71 state
72 .sessions
73 .insert(format!("move:{}", operation.operation_id), session);
74 }
75 }
76 reconcile_managed_checkpoint_archives_in(&sessions_dir(), &state)
77}
78
79fn reconcile_managed_checkpoint_archives_in(directory: &Path, state: &HelState) -> Result<usize> {
80 if !directory.exists() {
81 return Ok(0);
82 }
83 let referenced_names = state
84 .sessions
85 .values()
86 .filter_map(|session| session.checkpoint.as_ref())
87 .filter_map(|checkpoint| checkpoint.archive_path.file_name())
88 .map(ToOwned::to_owned)
89 .collect::<BTreeSet<_>>();
90 let mut removed = 0;
91 for entry in std::fs::read_dir(directory)
92 .with_context(|| format!("scan checkpoint directory {}", directory.display()))?
93 {
94 let entry = entry?;
95 let file_type = entry.file_type()?;
96 if !file_type.is_file()
97 || !is_managed_checkpoint_archive_name(&entry.file_name())
98 || referenced_names.contains(&entry.file_name())
99 {
100 continue;
101 }
102 std::fs::remove_file(entry.path()).with_context(|| {
103 format!(
104 "remove unreferenced managed checkpoint {}",
105 entry.path().display()
106 )
107 })?;
108 removed += 1;
109 }
110 Ok(removed)
111}
112
113fn is_managed_checkpoint_archive_name(name: &OsStr) -> bool {
114 let Some(stem) = name.to_str().and_then(|name| name.strip_suffix(".hel.zip")) else {
115 return false;
116 };
117 let Some((frontier_prefix, nonce)) = stem.rsplit_once("-archive-") else {
118 return false;
119 };
120 if nonce.len() != 32
121 || !nonce
122 .bytes()
123 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
124 {
125 return false;
126 }
127 let Some((session_id, frontier)) = frontier_prefix.rsplit_once('-') else {
128 return false;
129 };
130 !session_id.is_empty()
131 && frontier.parse::<u64>().is_ok()
132 && hel::hel_config::validate_id("session", session_id).is_ok()
133}
134
135#[derive(Debug, Clone)]
136pub struct CheckpointArtifact {
137 pub metadata: CheckpointMetadata,
138 pub native_session_id: String,
139 pub event_frontier_digest: String,
141}
142
143pub(super) enum ControllerRelayLease {
151 Managed {
152 handle: ManagedSessionHandle,
153 lease: Option<ManagedSessionLease>,
154 },
155 Standalone(StandaloneSession),
156}
157
158impl ControllerRelayLease {
159 pub(super) fn connection_mut(&mut self) -> &mut StandaloneSession {
162 match self {
163 Self::Managed { lease, .. } => lease
164 .as_mut()
165 .expect("checkpoint latch has already returned its connection")
166 .connection_mut(),
167 Self::Standalone(connection) => connection,
168 }
169 }
170
171 async fn submit(&mut self, command_id: String, command: RelayCommand) -> Result<u64> {
172 match self {
173 Self::Managed {
174 lease: Some(lease), ..
175 } => lease.connection_mut().submit(command_id, command).await,
176 Self::Managed { handle, .. } => handle.submit(command_id, command).await,
177 Self::Standalone(connection) => connection.submit(command_id, command).await,
178 }
179 }
180
181 async fn sync_snapshot(&mut self) -> Result<ManagedSessionSnapshot> {
182 match self {
183 Self::Managed {
184 lease: Some(lease), ..
185 } => lease.connection_mut().sync().await,
186 Self::Managed { handle, .. } => {
187 handle.sync_now().await?;
188 handle
189 .view()
190 .snapshot
191 .context("managed session has no snapshot")
192 }
193 Self::Standalone(connection) => connection.sync().await,
194 }
195 }
196
197 fn replace_connection(&mut self, connection: StandaloneSession) {
199 match self {
200 Self::Managed {
201 lease: Some(lease), ..
202 } => lease.replace_connection(connection),
203 Self::Standalone(existing) => *existing = connection,
204 Self::Managed { lease: None, .. } => {
205 *self = Self::Standalone(connection);
206 }
207 }
208 }
209
210 fn end_latch(&mut self) {
214 if let Self::Managed { lease, .. } = self
215 && let Some(lease) = lease.take()
216 {
217 lease.release();
218 }
219 }
220
221 async fn cancel_abandoned_barrier(&mut self) -> Result<()> {
229 let Self::Managed { handle, lease } = self else {
230 return Ok(());
233 };
234 match lease.take() {
235 Some(lease) => drop(lease),
236 None => drop(handle.lease_connection().await?),
237 }
238 Ok(())
239 }
240
241 pub(super) fn release(self) {
242 if let Self::Managed {
243 lease: Some(lease), ..
244 } = self
245 {
246 lease.release();
247 }
248 }
249}
250
251#[derive(Debug, Clone, Copy, PartialEq, Eq)]
253pub(super) enum LatchExclusivity {
254 ReleaseAfterLatch,
259 HoldThroughClose,
262}
263
264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
266pub(super) enum CheckpointExportPolicy {
267 Always,
269 ReuseUnchangedArchive,
273}
274
275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
277pub(super) enum CheckpointCompletion {
278 HeldBarrier,
282 ReleasedAfterCapture,
285}
286
287pub(super) struct LatchedCheckpoint {
288 pub(super) artifact: CheckpointArtifact,
289 pub(super) relay: ControllerRelayLease,
290 pub(super) barrier_command_id: String,
291 pub(super) cursor: RelayCursor,
292 pub(super) completion: CheckpointCompletion,
293}
294
295impl LatchedCheckpoint {
302 async fn complete(mut self) -> Result<()> {
304 let (prefix, command) = match self.completion {
305 CheckpointCompletion::HeldBarrier => (
306 "checkpoint-complete",
307 RelayCommand::CompleteCheckpoint {
308 barrier_command_id: self.barrier_command_id.clone(),
309 },
310 ),
311 CheckpointCompletion::ReleasedAfterCapture => (
314 "checkpoint-floor",
315 RelayCommand::AdvanceRecoveryFloor {
316 through: self.cursor.clone(),
317 },
318 ),
319 };
320 let command_id = new_command_id(prefix)?;
321 self.relay.submit(command_id, command).await.map(|_| ())
322 }
323
324 async fn abandon(mut self, session_id: &str) {
330 if self.completion == CheckpointCompletion::ReleasedAfterCapture {
331 return;
335 }
336 if let Err(error) = self.relay.cancel_abandoned_barrier().await {
337 tracing::warn!(
338 session_id,
339 "abandoned checkpoint could not cancel its relay barrier: {error:#}"
340 );
341 }
342 }
343}
344
345impl Controller {
346 pub(super) fn persist_checkpoint_transition_or_restore(
347 &mut self,
348 session_id: &str,
349 previous: &SessionRecord,
350 context: &'static str,
351 ) -> Result<()> {
352 persist_session_record_transition_or_restore(
353 &mut self.state,
354 session_id,
355 previous,
356 context,
357 &hel::hel_database::save_checkpointed_session,
358 )
359 }
360
361 pub(super) fn persist_failed_checkpoint_state_or_restore(
362 &mut self,
363 session_id: &str,
364 previous: &SessionRecord,
365 primary: anyhow::Error,
366 ) -> anyhow::Error {
367 match self.persist_session_state(session_id) {
368 Ok(()) => primary,
369 Err(error) => self.restore_prior_session_after_persistence_failure(
370 session_id,
371 previous,
372 primary.context(format!(
373 "failed to persist the checkpoint rollback state: {error:#}"
374 )),
375 ),
376 }
377 }
378
379 pub async fn checkpoint_session(&mut self, session_id: &str) -> Result<CheckpointMetadata> {
383 self.checkpoint_session_controlled(session_id, &ProcessExecutor)
384 .await
385 }
386
387 pub async fn checkpoint_session_controlled(
388 &mut self,
389 session_id: &str,
390 executor: &(impl CommandExecutor + Sync),
391 ) -> Result<CheckpointMetadata> {
392 self.checkpoint_session_controlled_with_manager(session_id, executor, None)
393 .await
394 }
395
396 async fn checkpoint_session_controlled_with_manager(
397 &mut self,
398 session_id: &str,
399 executor: &(impl CommandExecutor + Sync),
400 manager: Option<&SessionManagerControl>,
401 ) -> Result<CheckpointMetadata> {
402 let previous = self
403 .state
404 .sessions
405 .get(session_id)
406 .with_context(|| format!("unknown session {session_id}"))?
407 .clone();
408 ensure!(
409 !matches!(
410 previous.state,
411 SessionState::Closing | SessionState::Destroying
412 ),
413 "session {session_id} is already closing; resume that close instead of starting an ordinary checkpoint"
414 );
415 let record = self.state.sessions.get_mut(session_id).unwrap();
416 record.state = SessionState::Checkpointing;
417 record.updated_at = now();
418 record.last_checkpoint_error = None;
419 self.persist_session_transition_or_restore(
420 session_id,
421 &previous,
422 "persist checkpointing state before creating a checkpoint",
423 )?;
424
425 match self
426 .checkpoint_session_latched(
427 session_id,
428 executor,
429 manager,
430 LatchExclusivity::ReleaseAfterLatch,
431 CheckpointExportPolicy::Always,
432 )
433 .await
434 {
435 Ok(latched) => {
436 let artifact = latched.artifact.clone();
437 if let Err(error) = hel::hel_test_hooks::reach_test_hook(
438 "checkpoint_archive_before_database_publication",
439 ) {
440 latched.abandon(session_id).await;
441 return Err(remove_uninstalled_checkpoint(
442 &artifact.metadata.archive_path,
443 error,
444 ));
445 }
446 {
447 let record = self.state.sessions.get_mut(session_id).unwrap();
448 record.state = SessionState::Running;
449 record.native_session_id = Some(artifact.native_session_id.clone());
450 record.checkpoint = Some(artifact.metadata.clone());
451 record.updated_at = now();
452 record.last_error = None;
453 record.last_checkpoint_error = None;
454 }
455 let persist_started = Instant::now();
456 if let Err(error) = self.persist_checkpoint_transition_or_restore(
457 session_id,
458 &previous,
459 "persist verified checkpoint before releasing relay history",
460 ) {
461 latched.abandon(session_id).await;
462 return Err(error);
463 }
464 tracing::info!(
465 session_id,
466 persist_ms = persist_started.elapsed().as_millis() as u64,
467 "checkpoint metadata persisted"
468 );
469 prune_replaced_checkpoint(previous.checkpoint.as_ref(), &artifact.metadata);
470 release_projection_behind_checkpoint(session_id, &artifact.metadata);
471 if let Err(error) = latched.complete().await {
472 tracing::warn!(
478 session_id,
479 "verified checkpoint was saved, but the relay could not be told to release the history it covers: {error:#}"
480 );
481 }
482 Ok(artifact.metadata)
483 }
484 Err(error) => {
485 let deferred = checkpoint_was_deferred(&error);
490 if let Some(record) = self.state.sessions.get_mut(session_id) {
491 record.state = if previous.state == SessionState::Checkpointing {
492 SessionState::Running
493 } else {
494 previous.state
495 };
496 record.updated_at = now();
497 if !deferred {
498 record.last_checkpoint_error = Some(format!("{error:#}"));
499 }
500 }
501 Err(self.persist_failed_checkpoint_state_or_restore(session_id, &previous, error))
502 }
503 }
504 }
505
506 pub async fn create_recovery_checkpoint_managed_controlled(
509 &self,
510 session_id: &str,
511 manager: &SessionManagerControl,
512 executor: &(impl CommandExecutor + Sync),
513 ) -> Result<CheckpointArtifact> {
514 self.create_recovery_checkpoint_with_manager(session_id, Some(manager), executor)
515 .await
516 }
517
518 async fn create_recovery_checkpoint_with_manager(
519 &self,
520 session_id: &str,
521 manager: Option<&SessionManagerControl>,
522 executor: &(impl CommandExecutor + Sync),
523 ) -> Result<CheckpointArtifact> {
524 let previous_checkpoint = self
525 .state
526 .sessions
527 .get(session_id)
528 .with_context(|| format!("unknown session {session_id}"))?
529 .checkpoint
530 .clone();
531 let latched = self
532 .checkpoint_session_latched_with_recovery_stage(
533 session_id,
534 executor,
535 manager,
536 LatchExclusivity::ReleaseAfterLatch,
537 CheckpointExportPolicy::Always,
538 true,
539 )
540 .await?;
541 let artifact = latched.artifact.clone();
542 let verification = {
543 let _verifying = ProvisionStageGuard::new(executor, ProvisionStage::Verifying);
544 verify_checkpoint_artifact(session_id, &artifact)
545 };
546 if let Err(error) = verification {
547 latched.abandon(session_id).await;
548 return Err(remove_uninstalled_checkpoint(
549 &artifact.metadata.archive_path,
550 error.context("final recovery checkpoint verification"),
551 ));
552 }
553 if let Err(error) =
554 hel::hel_test_hooks::reach_test_hook("checkpoint_archive_before_database_publication")
555 {
556 latched.abandon(session_id).await;
557 return Err(remove_uninstalled_checkpoint(
558 &artifact.metadata.archive_path,
559 error,
560 ));
561 }
562 let persist_started = Instant::now();
563 if let Err(error) = hel::hel_database::record_recovery_success(
564 session_id,
565 &artifact.native_session_id,
566 &artifact.metadata,
567 ) {
568 latched.abandon(session_id).await;
569 return Err(error
570 .context("persist verified recovery checkpoint before releasing relay history"));
571 }
572 tracing::info!(
573 session_id,
574 persist_ms = persist_started.elapsed().as_millis() as u64,
575 "recovery checkpoint metadata persisted"
576 );
577 if let Err(error) = latched.complete().await {
578 tracing::warn!(
583 session_id,
584 "recovery checkpoint was saved, but the relay could not be told to release the history it covers: {error:#}"
585 );
586 }
587 prune_replaced_checkpoint(previous_checkpoint.as_ref(), &artifact.metadata);
588 release_projection_behind_checkpoint(session_id, &artifact.metadata);
589 Ok(artifact)
590 }
591
592 pub(super) async fn checkpoint_session_latched(
593 &self,
594 session_id: &str,
595 executor: &(impl CommandExecutor + Sync),
596 manager: Option<&SessionManagerControl>,
597 exclusivity: LatchExclusivity,
598 export_policy: CheckpointExportPolicy,
599 ) -> Result<LatchedCheckpoint> {
600 self.checkpoint_session_latched_with_recovery_stage(
601 session_id,
602 executor,
603 manager,
604 exclusivity,
605 export_policy,
606 exclusivity == LatchExclusivity::HoldThroughClose,
607 )
608 .await
609 }
610
611 async fn checkpoint_session_latched_with_recovery_stage(
612 &self,
613 session_id: &str,
614 executor: &(impl CommandExecutor + Sync),
615 manager: Option<&SessionManagerControl>,
616 exclusivity: LatchExclusivity,
617 export_policy: CheckpointExportPolicy,
618 recovery_copy: bool,
619 ) -> Result<LatchedCheckpoint> {
620 if let Some(operation) = hel::hel_database::load_move_operation(session_id)?
621 && operation.queue_admission_started
622 && !operation.queue_admission_finished
623 {
624 bail!(
627 "move queue admission is incomplete; retry Move before checkpointing this destination"
628 );
629 }
630 let session = self
631 .state
632 .sessions
633 .get(session_id)
634 .with_context(|| format!("unknown session {session_id}"))?
635 .clone();
636 let locator = session
637 .target
638 .as_ref()
639 .context("session has no live target")?;
640 let backend = backend_locator(locator, &session, &self.config)?;
641 let profile = self
642 .config
643 .profiles
644 .get(&session.last_profile)
645 .context("session profile is missing")?;
646 let bundle = session
647 .project_directory
648 .is_none()
649 .then(|| self.config.bundles.get(&session.bundle_id))
650 .flatten();
651 let reconnect = hel_targets::reconnect_plan(&backend, session_id)?
652 .commands
653 .into_iter()
654 .next()
655 .context("reconnect plan is empty")?;
656 let worker_root = hel_targets::worker_root(&backend, session_id)?;
657 let harness_home = target_profile_home(&backend, session_id, profile);
658 let (workspace_root, primary_repository, repositories) =
659 if let Some(project_directory) = &session.project_directory {
660 let parent = project_directory
661 .parent()
662 .context("bare project directory has no parent")?;
663 let destination = project_directory
664 .file_name()
665 .context("bare project directory cannot be the filesystem root")?;
666 (
667 parent.to_string_lossy().into_owned(),
668 "project".to_owned(),
669 vec![CheckpointRepositorySpec {
670 id: "project".into(),
671 relative_destination: PathBuf::from(destination),
672 capture: if session.managed_worktree.is_some() {
678 CheckpointRepositoryCapture::DeltaFrom {
679 base_commit: super::worktree::raw_checkout_position(
680 &session,
681 &self.config,
682 project_directory,
683 executor,
684 )?
685 .head_commit,
686 }
687 } else {
688 CheckpointRepositoryCapture::MetadataOnly
689 },
690 origin_override: None,
691 }],
692 )
693 } else {
694 let bundle = bundle.context("session bundle is missing")?;
695 let workspace_root = match &backend {
696 hel_targets::TargetLocator::LocalPodman { .. }
697 | hel_targets::TargetLocator::LocalDocker { .. }
698 | hel_targets::TargetLocator::AppleContainer { .. }
699 | hel_targets::TargetLocator::SshPodman { .. }
700 | hel_targets::TargetLocator::SshDocker { .. } => "/workspace".to_string(),
701 hel_targets::TargetLocator::AwsEc2 { workspace, .. }
702 | hel_targets::TargetLocator::SshBare { workspace, .. } => workspace.clone(),
703 hel_targets::TargetLocator::LocalBare { worker_root } => worker_root.clone(),
704 };
705 let repositories = bundle
706 .repositories
707 .iter()
708 .map(|repository| CheckpointRepositorySpec {
709 id: repository.id.clone(),
710 relative_destination: repository.destination.clone(),
711 capture: CheckpointRepositoryCapture::SessionDelta,
712 origin_override: repository
713 .is_local()
714 .then(|| format!("mj-local:{}", repository.id)),
715 })
716 .collect();
717 (workspace_root, bundle.primary_repo.clone(), repositories)
718 };
719 let target_path = |path: &str| match &backend {
720 hel_targets::TargetLocator::AwsEc2 { .. }
721 | hel_targets::TargetLocator::SshBare { .. }
722 if !path.starts_with('/') =>
723 {
724 PathBuf::from(format!("~/{path}"))
725 }
726 _ => PathBuf::from(path),
727 };
728 let remote_spec = format!("{worker_root}/checkpoint-spec.json");
729 let remote_archive = format!("{worker_root}/checkpoint.hel.zip");
730 let remote_stage = format!(
731 "{worker_root}/checkpoint-stage-{}",
732 new_command_id("capture")?
733 );
734 let checkpointed_at = now();
735 let target_manifest = TargetManifest {
736 template_id: session.target_template_id.clone(),
737 target_kind: target_kind(&backend).into(),
738 details: Default::default(),
739 };
740 let bundle_manifest = BundleManifest {
741 id: session.bundle_id.clone(),
742 primary_repository,
743 };
744 let session_manifest = |native_session_id: &str| SessionManifest {
745 id: session.id.clone(),
746 title: session.title.clone(),
747 harness_kind: session.harness_kind,
748 profile_id: session.last_profile.clone(),
749 native_session_id: native_session_id.to_owned(),
750 created_at: session.created_at.clone(),
751 checkpointed_at: checkpointed_at.clone(),
752 hel_version: env!("CARGO_PKG_VERSION").into(),
753 relay_version: env!("CARGO_PKG_VERSION").into(),
754 adapter_version: "acp-v1".into(),
755 };
756 let releases_after_capture = exclusivity == LatchExclusivity::ReleaseAfterLatch;
757 if releases_after_capture
758 && let Some(native_session_id) = session.native_session_id.as_deref()
759 {
760 let prestage = CheckpointCaptureSpec {
761 protocol_version: CHECKPOINT_STAGING_PROTOCOL_VERSION,
762 session: session_manifest(native_session_id),
763 target: target_manifest.clone(),
764 bundle: bundle_manifest.clone(),
765 relay_root: target_path(&worker_root),
766 harness_home: target_path(&harness_home),
767 workspace_root: target_path(&workspace_root),
768 repositories: repositories.clone(),
769 allow_empty_native: false,
770 stage_path: target_path(&remote_stage),
771 refresh_existing: false,
772 };
773 let prestage_started = Instant::now();
774 let prestaged = {
775 let _recovery_copy = recovery_copy
776 .then(|| ProvisionStageGuard::new(executor, ProvisionStage::RecoveryCopy));
777 run_checkpoint_staging_command(
778 executor,
779 &backend,
780 session_id,
781 &prestage,
782 capture_stdin_command,
783 "prestage target checkpoint",
784 )
785 };
786 match prestaged {
787 Ok(output) => match serde_json::from_slice::<CapturedCheckpoint>(&output.stdout) {
788 Ok(captured) => tracing::info!(
789 session_id,
790 prestage_ms = prestage_started.elapsed().as_millis() as u64,
791 native_bytes = captured.native_bytes,
792 repository_bytes = captured.repository_bytes,
793 reused_native = captured.reused_native,
794 "checkpoint target state prestaged while ACP dispatch remained active"
795 ),
796 Err(error) => tracing::warn!(
797 session_id,
798 error = format!("{error:#}"),
799 "checkpoint prestage returned an invalid result; barrier capture will replace it"
800 ),
801 },
802 Err(error) => {
803 if executor.cancellation_requested() {
804 return Err(error.context("checkpoint prestage was cancelled"));
805 }
806 tracing::warn!(
807 session_id,
808 error = format!("{error:#}"),
809 "checkpoint prestage failed; barrier capture will collect a fresh generation"
810 );
811 }
812 }
813 }
814 let (mut relay, mut restarted_worker) = self
815 .open_checkpoint_relay(
816 session_id,
817 executor,
818 manager,
819 &backend,
820 &worker_root,
821 &reconnect,
822 )
823 .await?;
824 let (barrier, barrier_command_id) = loop {
825 wait_for_native_session_in_stage(
829 relay.connection_mut(),
830 executor,
831 hel_targets::ProvisionStage::Starting,
832 )
833 .await?;
834 if exclusivity == LatchExclusivity::ReleaseAfterLatch
835 && relay.connection_mut().sync().await?.operational.execution
836 == RelayExecutionState::Running
837 {
838 relay.release();
841 return Err(CheckpointDeferred::harness_busy().into());
842 }
843 let barrier_command_id = new_command_id("checkpoint")?;
844 let timeout = if restarted_worker {
845 CHECKPOINT_BARRIER_TIMEOUT_AFTER_RESTART
846 } else {
847 CHECKPOINT_BARRIER_TIMEOUT
848 };
849 let result = {
850 let connection = relay.connection_mut();
851 connection
852 .submit(
853 barrier_command_id.clone(),
854 RelayCommand::BeginCheckpoint {
855 reason: Some("controller archive checkpoint".into()),
856 },
857 )
858 .await?;
859 wait_for_checkpoint_barrier(
860 connection,
861 session_id,
862 &barrier_command_id,
863 timeout,
864 BarrierBusyPolicy::of(exclusivity),
865 )
866 .await
867 };
868 match result {
869 Ok(barrier) => break (barrier, barrier_command_id),
870 Err(error)
871 if !restarted_worker && checkpoint_barrier_needs_worker_restart(&error) =>
872 {
873 tracing::warn!(
874 session_id,
875 "checkpoint requires a worker restart; restarting and retrying: {error:#}"
876 );
877 let connection = self
878 .restart_worker_for_checkpoint(
879 session_id,
880 executor,
881 &backend,
882 &worker_root,
883 &reconnect,
884 )
885 .await?;
886 relay.replace_connection(connection);
887 restarted_worker = true;
888 }
889 Err(error) => return Err(error),
890 }
891 };
892 let barrier_ready_at = Instant::now();
893 relay
897 .connection_mut()
898 .sync_project_memory()
899 .await
900 .context("synchronize project memory for checkpoint")?;
901 let cursor = barrier
902 .operational
903 .checkpoint_ready
904 .clone()
905 .context("relay reported a checkpoint barrier without its ready cursor")?;
906 let materialized = barrier.materialized;
907 let expected_ordinal = materialized.applied_event_ordinal;
908 let expected_digest = materialized.applied_event_digest.clone();
909 ensure!(
910 expected_ordinal == barrier.operational.latest_ordinal,
911 "checkpoint projection frontier {expected_ordinal} does not match relay frontier {}",
912 barrier.operational.latest_ordinal
913 );
914 ensure!(
915 expected_digest == barrier.operational.latest_digest,
916 "checkpoint projection digest does not match the relay frontier digest"
917 );
918 ensure_exact_checkpoint_cut(&cursor, expected_ordinal, &expected_digest)?;
919 let canonical_session = canonical_session_from_materialized(&materialized)?;
920 let native_session_id = barrier
921 .operational
922 .native_session_id
923 .or_else(|| session.native_session_id.clone())
924 .context("harness did not report its native session ID")?;
925
926 if exclusivity == LatchExclusivity::ReleaseAfterLatch {
931 relay.end_latch();
932 }
933
934 if export_policy == CheckpointExportPolicy::ReuseUnchangedArchive
940 && session.managed_worktree.is_none()
944 && let Some(artifact) = reusable_installed_checkpoint(
945 session_id,
946 session.checkpoint.as_ref(),
947 &native_session_id,
948 cursor.ordinal,
949 &canonical_session,
950 )
951 {
952 return Ok(LatchedCheckpoint {
953 artifact,
954 relay,
955 barrier_command_id,
956 cursor,
957 completion: CheckpointCompletion::HeldBarrier,
958 });
959 }
960
961 let mut completion = CheckpointCompletion::HeldBarrier;
966
967 let exported: Result<CheckpointArtifact> = async {
968 let spec = CheckpointExportSpec {
969 protocol_version: CHECKPOINT_EXPORT_PROTOCOL_VERSION,
970 session: session_manifest(&native_session_id),
971 target: target_manifest,
972 bundle: bundle_manifest,
973 relay_root: target_path(&worker_root),
974 harness_home: target_path(&harness_home),
975 workspace_root: target_path(&workspace_root),
976 repositories,
977 canonical_session,
978 output_path: target_path(&remote_archive),
979 };
980 let mut export_ms: Option<u64> = None;
983 let exported = if releases_after_capture {
984 let capture_spec = CheckpointCaptureSpec {
985 protocol_version: CHECKPOINT_STAGING_PROTOCOL_VERSION,
986 session: spec.session.clone(),
987 target: spec.target.clone(),
988 bundle: spec.bundle.clone(),
989 relay_root: spec.relay_root.clone(),
990 harness_home: spec.harness_home.clone(),
991 workspace_root: spec.workspace_root.clone(),
992 repositories: spec.repositories.clone(),
993 allow_empty_native: !canonical_session_contains_prompt(&spec.canonical_session),
994 stage_path: target_path(&remote_stage),
995 refresh_existing: true,
996 };
997 let capture_started = Instant::now();
998 let captured = {
999 let _recovery_copy = recovery_copy.then(|| {
1000 ProvisionStageGuard::new(executor, ProvisionStage::RecoveryCopy)
1001 });
1002 run_checkpoint_staging_command(
1003 executor,
1004 &backend,
1005 session_id,
1006 &capture_spec,
1007 capture_stdin_command,
1008 "capture target checkpoint",
1009 )?
1010 };
1011 let captured: CapturedCheckpoint = serde_json::from_slice(&captured.stdout)
1012 .context("decode captured checkpoint result")?;
1013 tracing::info!(
1014 session_id,
1015 capture_ms = capture_started.elapsed().as_millis() as u64,
1016 barrier_held_ms = barrier_ready_at.elapsed().as_millis() as u64,
1017 native_bytes = captured.native_bytes,
1018 repository_bytes = captured.repository_bytes,
1019 reused_native = captured.reused_native,
1020 "checkpoint target state captured; releasing ACP dispatch"
1021 );
1022 completion = release_checkpoint_after_capture(
1023 &mut relay,
1024 session_id,
1025 &barrier_command_id,
1026 &cursor,
1027 )
1028 .await?;
1029 let pack_spec = CheckpointPackSpec {
1030 protocol_version: CHECKPOINT_STAGING_PROTOCOL_VERSION,
1031 relay_root: spec.relay_root.clone(),
1032 stage_path: target_path(&remote_stage),
1033 canonical_session: spec.canonical_session.clone(),
1034 output_path: spec.output_path.clone(),
1035 };
1036 let pack_started = Instant::now();
1037 let output = {
1038 let _recovery_copy = recovery_copy.then(|| {
1039 ProvisionStageGuard::new(executor, ProvisionStage::RecoveryCopy)
1040 });
1041 run_checkpoint_staging_command(
1042 executor,
1043 &backend,
1044 session_id,
1045 &pack_spec,
1046 pack_stdin_command,
1047 "pack target checkpoint",
1048 )?
1049 };
1050 tracing::info!(
1051 session_id,
1052 pack_ms = pack_started.elapsed().as_millis() as u64,
1053 "checkpoint archive packaged after ACP dispatch resumed"
1054 );
1055 output
1056 } else {
1057 let export_started = Instant::now();
1058 let output = {
1059 let _recovery_copy = recovery_copy.then(|| {
1060 ProvisionStageGuard::new(executor, ProvisionStage::RecoveryCopy)
1061 });
1062 export_target_checkpoint(
1063 executor,
1064 &backend,
1065 session_id,
1066 &spec,
1067 &remote_spec,
1068 )?
1069 };
1070 export_ms = Some(export_started.elapsed().as_millis() as u64);
1071 output
1072 };
1073 let target_checkpoint: hel::hel_checkpoint::TargetCheckpoint =
1074 serde_json::from_slice(&exported.stdout)
1075 .context("decode target checkpoint result")?;
1076 if let Some(export_ms) = export_ms {
1077 let timings = target_checkpoint.timings.unwrap_or_default();
1080 tracing::info!(
1081 session_id,
1082 export_ms,
1083 timings_reported = target_checkpoint.timings.is_some(),
1084 native_ms = timings.native_ms,
1085 repositories_ms = timings.repositories_ms,
1086 archive_ms = timings.archive_ms,
1087 worker_total_ms = timings.total_ms,
1088 "checkpoint archive exported on the target"
1089 );
1090 }
1091 if target_checkpoint.event_frontier != expected_ordinal {
1092 bail!(
1093 "target checkpoint event frontier changed: expected {expected_ordinal}, found {}",
1094 target_checkpoint.event_frontier
1095 );
1096 }
1097 if target_checkpoint.event_frontier_digest != expected_digest {
1098 bail!("target checkpoint event frontier digest changed");
1099 }
1100
1101 let archive_id = new_command_id("archive")?;
1106 let destination = sessions_dir().join(format!(
1107 "{session_id}-{}-{archive_id}.hel.zip",
1108 target_checkpoint.event_frontier
1109 ));
1110 let transfer = CheckpointTransfer {
1111 locator: &backend,
1112 session_id,
1113 remote_archive: &remote_archive,
1114 destination: &destination,
1115 expected_sha256: &target_checkpoint.sha256,
1116 expected_event_frontier: target_checkpoint.event_frontier,
1117 expected_event_frontier_digest: &target_checkpoint.event_frontier_digest,
1118 };
1119 let metadata = {
1120 let _verifying = ProvisionStageGuard::new(executor, ProvisionStage::Verifying);
1121 let transfer_started = Instant::now();
1122 let verified = transfer.execute(executor)?;
1123 tracing::info!(
1124 session_id,
1125 transfer_and_checksum_ms = transfer_started.elapsed().as_millis() as u64,
1126 "checkpoint archive transferred and checksum-verified"
1127 );
1128 let installed_archive = verified.archive_path().to_path_buf();
1129 let validate_transferred = || -> Result<()> {
1130 ensure!(
1131 verified.sha256() == target_checkpoint.sha256,
1132 "target and controller checkpoint checksums differ"
1133 );
1134 ensure!(
1135 verified.event_frontier_digest() == expected_digest,
1136 "verified checkpoint event frontier digest changed"
1137 );
1138 Ok(())
1139 };
1140 if let Err(error) = validate_transferred() {
1141 return Err(remove_uninstalled_checkpoint(&installed_archive, error));
1142 }
1143 if completion == CheckpointCompletion::HeldBarrier {
1147 let revalidated = relay.sync_snapshot().await.and_then(|snapshot| {
1148 validate_checkpoint_barrier_snapshot(
1149 &snapshot,
1150 &barrier_command_id,
1151 &cursor,
1152 )
1153 });
1154 if let Err(error) = revalidated {
1155 return Err(remove_uninstalled_checkpoint(
1156 &installed_archive,
1157 error.context(
1158 "checkpoint barrier changed while transferring its archive",
1159 ),
1160 ));
1161 }
1162 }
1163 if let Err(error) = transfer
1164 .cleanup_plan(&verified)
1165 .and_then(|plan| plan.execute(executor).map(|_| ()))
1166 {
1167 return Err(remove_uninstalled_checkpoint(
1168 &installed_archive,
1169 error.context("clean target checkpoint staging"),
1170 ));
1171 }
1172 CheckpointMetadata {
1173 archive_path: verified.archive_path().to_path_buf(),
1174 sha256: verified.sha256().to_string(),
1175 created_at: checkpointed_at.clone(),
1176 event_frontier: verified.event_frontier(),
1177 }
1178 };
1179 Ok(CheckpointArtifact {
1180 metadata,
1181 native_session_id,
1182 event_frontier_digest: expected_digest,
1183 })
1184 }
1185 .await;
1186
1187 let artifact = match exported {
1188 Ok(artifact) => artifact,
1189 Err(error) => {
1190 if completion == CheckpointCompletion::HeldBarrier
1196 && let Err(cancel_error) = relay.cancel_abandoned_barrier().await
1197 {
1198 tracing::warn!(
1199 session_id,
1200 "failed checkpoint could not cancel its relay barrier: {cancel_error:#}"
1201 );
1202 }
1203 return Err(error);
1204 }
1205 };
1206 Ok(LatchedCheckpoint {
1207 artifact,
1208 relay,
1209 barrier_command_id,
1210 cursor,
1211 completion,
1212 })
1213 }
1214
1215 async fn open_checkpoint_relay(
1219 &self,
1220 session_id: &str,
1221 executor: &(impl CommandExecutor + Sync),
1222 manager: Option<&SessionManagerControl>,
1223 backend: &hel_targets::TargetLocator,
1224 worker_root: &str,
1225 reconnect: &hel_targets::CommandSpec,
1226 ) -> Result<(ControllerRelayLease, bool)> {
1227 let project_memory = match self.project_memory_sync_target(session_id) {
1228 Ok(target) => Some(target),
1229 Err(error) => {
1230 tracing::warn!(
1231 session_id,
1232 error = format!("{error:#}"),
1233 "project memory will not be synchronized during checkpoint reconnect"
1234 );
1235 None
1236 }
1237 };
1238 match connect_checkpoint_relay(session_id, manager, reconnect, project_memory.clone()).await
1239 {
1240 Ok(relay) => Ok((relay, false)),
1241 Err(error) if worker_connect_needs_restart(&error) => {
1242 tracing::warn!(
1243 session_id,
1244 "checkpoint could not reach the worker; restarting it: {error:#}"
1245 );
1246 let mut connection = self
1247 .restart_worker_for_checkpoint(
1248 session_id,
1249 executor,
1250 backend,
1251 worker_root,
1252 reconnect,
1253 )
1254 .await?;
1255 connection.set_project_memory_target(project_memory);
1256 let relay =
1257 adopt_restarted_checkpoint_relay(session_id, manager, connection).await?;
1258 Ok((relay, true))
1259 }
1260 Err(error) => Err(error).context("connect to the session worker for checkpoint"),
1261 }
1262 }
1263
1264 async fn restart_worker_for_checkpoint(
1268 &self,
1269 session_id: &str,
1270 executor: &(impl CommandExecutor + Sync),
1271 backend: &hel_targets::TargetLocator,
1272 worker_root: &str,
1273 reconnect: &hel_targets::CommandSpec,
1274 ) -> Result<StandaloneSession> {
1275 self.restart_worker_with_installed_binary(
1276 session_id,
1277 executor,
1278 InstalledWorkerRestart {
1279 backend,
1280 worker_root,
1281 reconnect,
1282 launch: None,
1283 messages: &RESTART_FOR_CHECKPOINT,
1284 },
1285 )
1286 .await
1287 }
1288}
1289
1290async fn connect_checkpoint_relay(
1291 session_id: &str,
1292 manager: Option<&SessionManagerControl>,
1293 reconnect: &hel_targets::CommandSpec,
1294 project_memory: Option<crate::hel_session_manager::ProjectMemorySyncTarget>,
1295) -> Result<ControllerRelayLease> {
1296 if let Some(manager) = manager {
1297 let handle = manager
1298 .wait_for_session(session_id, Duration::from_secs(5))
1299 .await?;
1300 let mut lease = handle.lease_connection().await?;
1301 lease
1302 .connection_mut()
1303 .set_project_memory_target(project_memory);
1304 Ok(ControllerRelayLease::Managed {
1305 handle,
1306 lease: Some(lease),
1307 })
1308 } else {
1309 let target = crate::hel_session_manager::RelaySessionTarget {
1310 session_id: session_id.to_owned(),
1311 spec: reconnect.clone(),
1312 worker_recovery: None,
1313 project_memory,
1314 };
1315 Ok(ControllerRelayLease::Standalone(
1316 StandaloneSession::connect(&target).await?,
1317 ))
1318 }
1319}
1320
1321async fn adopt_restarted_checkpoint_relay(
1322 session_id: &str,
1323 manager: Option<&SessionManagerControl>,
1324 connection: StandaloneSession,
1325) -> Result<ControllerRelayLease> {
1326 let Some(manager) = manager else {
1327 return Ok(ControllerRelayLease::Standalone(connection));
1328 };
1329 let handle = manager
1330 .wait_for_session(session_id, Duration::from_secs(5))
1331 .await?;
1332 match handle.lease_connection().await {
1333 Ok(mut lease) => {
1334 lease.replace_connection(connection);
1335 Ok(ControllerRelayLease::Managed {
1336 handle,
1337 lease: Some(lease),
1338 })
1339 }
1340 Err(error) => {
1341 tracing::warn!(
1342 session_id,
1343 "session actor could not lease after worker restart; using the restarted proxy: {error:#}"
1344 );
1345 Ok(ControllerRelayLease::Standalone(connection))
1346 }
1347 }
1348}
1349
1350#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1352enum BarrierBusyPolicy {
1353 DeferWhileRunning,
1359 InterruptWhileRunning,
1363}
1364
1365impl BarrierBusyPolicy {
1366 fn of(exclusivity: LatchExclusivity) -> Self {
1367 match exclusivity {
1368 LatchExclusivity::ReleaseAfterLatch => Self::DeferWhileRunning,
1369 LatchExclusivity::HoldThroughClose => Self::InterruptWhileRunning,
1370 }
1371 }
1372}
1373
1374async fn wait_for_checkpoint_barrier(
1375 relay: &mut StandaloneSession,
1376 session_id: &str,
1377 command_id: &str,
1378 timeout: Duration,
1379 busy: BarrierBusyPolicy,
1380) -> Result<ManagedSessionSnapshot> {
1381 let deadline = tokio::time::Instant::now() + timeout;
1382 let mut cancel_submitted = false;
1383 let mut cancel_deadline = None;
1384 let mut cancel_started_at: Option<Instant> = None;
1385 loop {
1386 let snapshot = relay.sync().await?;
1387 if checkpoint_barrier_is_ready(&snapshot, command_id) {
1388 if let Some(started_at) = cancel_started_at {
1389 tracing::info!(
1390 session_id,
1391 barrier_command_id = command_id,
1392 cancellation_ms = started_at.elapsed().as_millis() as u64,
1393 "active turn cancellation settled before checkpoint barrier"
1394 );
1395 }
1396 return Ok(snapshot);
1397 }
1398 if busy == BarrierBusyPolicy::InterruptWhileRunning
1399 && snapshot.operational.execution == RelayExecutionState::Running
1400 && !cancel_submitted
1401 {
1402 let cancel_turn = RelayCommand::CancelTurn;
1403 if relay.protocol_version() < cancel_turn.minimum_protocol() {
1404 return Err(CheckpointBarrierUnreachable::cancel_turn_unavailable(
1405 command_id,
1406 relay.protocol_version(),
1407 )
1408 .into());
1409 }
1410 let cancel_command_id = new_command_id("checkpoint-cancel-turn")?;
1411 match relay.submit(cancel_command_id, cancel_turn).await {
1412 Ok(_) => {
1413 cancel_submitted = true;
1414 cancel_started_at = Some(Instant::now());
1415 cancel_deadline = Some(tokio::time::Instant::now() + CHECKPOINT_CANCEL_TIMEOUT);
1416 tracing::info!(
1417 session_id,
1418 barrier_command_id = command_id,
1419 "requested active turn cancellation before checkpoint barrier"
1420 );
1421 }
1422 Err(error) if checkpoint_cancel_turn_needs_worker_restart(&error) => {
1423 return Err(error.context(
1424 CheckpointBarrierUnreachable::cancel_turn_unavailable(
1425 command_id,
1426 relay.protocol_version(),
1427 ),
1428 ));
1429 }
1430 Err(error) if worker_connect_needs_restart(&error) => {
1431 return Err(error.context(
1432 CheckpointBarrierUnreachable::cancel_turn_unreachable(command_id),
1433 ));
1434 }
1435 Err(error) => {
1436 if let Ok(snapshot) = relay.sync().await
1440 && checkpoint_barrier_is_ready(&snapshot, command_id)
1441 {
1442 tracing::info!(
1443 session_id,
1444 barrier_command_id = command_id,
1445 "active turn settled while submitting checkpoint cancellation"
1446 );
1447 return Ok(snapshot);
1448 }
1449 return Err(error.context("cancel active ACP turn before checkpoint barrier"));
1450 }
1451 }
1452 continue;
1453 }
1454 let out_of_time = tokio::time::Instant::now() >= cancel_deadline.unwrap_or(deadline);
1455 if let Some(error) = checkpoint_barrier_wait_ended(
1456 &snapshot,
1457 command_id,
1458 busy,
1459 out_of_time,
1460 cancel_submitted,
1461 ) {
1462 return Err(error);
1463 }
1464 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1465 }
1466}
1467
1468fn checkpoint_barrier_wait_ended(
1475 snapshot: &ManagedSessionSnapshot,
1476 command_id: &str,
1477 busy: BarrierBusyPolicy,
1478 out_of_time: bool,
1479 cancel_submitted: bool,
1480) -> Option<anyhow::Error> {
1481 if snapshot.operational.execution == RelayExecutionState::Closed {
1482 return Some(CheckpointBarrierUnreachable::runtime_stopped().into());
1483 }
1484 if snapshot.operational.execution == RelayExecutionState::Running {
1485 return Some(match busy {
1486 BarrierBusyPolicy::DeferWhileRunning => CheckpointDeferred::harness_busy().into(),
1487 BarrierBusyPolicy::InterruptWhileRunning if out_of_time && cancel_submitted => {
1488 CheckpointBarrierUnreachable::cancel_timed_out(command_id).into()
1489 }
1490 BarrierBusyPolicy::InterruptWhileRunning => return None,
1491 });
1492 }
1493 out_of_time.then(|| CheckpointBarrierUnreachable::not_admitted(command_id).into())
1494}
1495
1496#[derive(Debug)]
1503struct CheckpointBarrierUnreachable(String);
1504
1505impl CheckpointBarrierUnreachable {
1506 fn runtime_stopped() -> Self {
1507 Self("ACP runtime stopped before reaching the checkpoint barrier".to_owned())
1508 }
1509
1510 fn not_admitted(command_id: &str) -> Self {
1511 Self(format!(
1512 "ACP relay did not reach checkpoint barrier {command_id}"
1513 ))
1514 }
1515
1516 fn cancel_timed_out(command_id: &str) -> Self {
1517 Self(format!(
1518 "active ACP turn did not settle after cancellation before checkpoint barrier {command_id}"
1519 ))
1520 }
1521
1522 fn cancel_turn_unavailable(command_id: &str, protocol_version: u32) -> Self {
1523 Self(format!(
1524 "worker protocol {protocol_version} cannot cancel the active ACP turn before checkpoint barrier {command_id} (requires protocol {})",
1525 RelayCommand::CancelTurn.minimum_protocol(),
1526 ))
1527 }
1528
1529 fn cancel_turn_unreachable(command_id: &str) -> Self {
1530 Self(format!(
1531 "worker transport became unavailable while cancelling the active ACP turn before checkpoint barrier {command_id}"
1532 ))
1533 }
1534}
1535
1536impl std::fmt::Display for CheckpointBarrierUnreachable {
1537 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1538 formatter.write_str(&self.0)
1539 }
1540}
1541
1542impl std::error::Error for CheckpointBarrierUnreachable {}
1543
1544fn checkpoint_barrier_needs_worker_restart(error: &anyhow::Error) -> bool {
1545 error
1546 .downcast_ref::<CheckpointBarrierUnreachable>()
1547 .is_some()
1548}
1549
1550fn checkpoint_cancel_turn_needs_worker_restart(error: &anyhow::Error) -> bool {
1555 error.chain().any(|cause| {
1556 let Some(rejected) = cause.downcast_ref::<RelayRejected>() else {
1557 return false;
1558 };
1559 rejected.0.code == hel::hel_worker::RelayErrorCode::IncompatibleProtocol
1560 })
1561}
1562
1563#[derive(Debug)]
1573pub struct CheckpointDeferred(String);
1574
1575impl CheckpointDeferred {
1576 pub(crate) fn harness_busy() -> Self {
1577 Self("the agent is working; try again when it is idle".to_owned())
1578 }
1579
1580 fn frontier_moved() -> Self {
1581 Self(
1582 "the session moved past the checkpoint-ready cursor before the barrier latched, so this checkpoint was deferred"
1583 .to_owned(),
1584 )
1585 }
1586
1587 fn harness_turn_during_capture() -> Self {
1588 Self(
1589 "the agent started a turn of its own while target state was captured, so this checkpoint was deferred"
1590 .to_owned(),
1591 )
1592 }
1593}
1594
1595impl std::fmt::Display for CheckpointDeferred {
1596 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1597 formatter.write_str(&self.0)
1598 }
1599}
1600
1601impl std::error::Error for CheckpointDeferred {}
1602
1603pub fn checkpoint_was_deferred(error: &anyhow::Error) -> bool {
1608 error
1609 .chain()
1610 .any(|cause| cause.downcast_ref::<CheckpointDeferred>().is_some())
1611}
1612
1613fn checkpoint_barrier_is_ready(snapshot: &ManagedSessionSnapshot, command_id: &str) -> bool {
1614 snapshot.operational.checkpoint_barrier.as_deref() == Some(command_id)
1615 && snapshot.operational.checkpoint_ready.is_some()
1616}
1617
1618fn ensure_exact_checkpoint_cut(
1626 cursor: &RelayCursor,
1627 expected_ordinal: u64,
1628 expected_digest: &str,
1629) -> Result<()> {
1630 if cursor.ordinal != expected_ordinal || cursor.digest != expected_digest {
1631 bail!(CheckpointDeferred::frontier_moved());
1632 }
1633 Ok(())
1634}
1635
1636fn validate_checkpoint_barrier_snapshot(
1651 snapshot: &ManagedSessionSnapshot,
1652 command_id: &str,
1653 expected: &RelayCursor,
1654) -> Result<()> {
1655 ensure!(
1656 snapshot.operational.checkpoint_barrier.as_deref() == Some(command_id),
1657 "checkpoint barrier {command_id} is no longer active"
1658 );
1659 ensure!(
1660 snapshot.operational.checkpoint_ready.as_ref() == Some(expected),
1661 "checkpoint barrier {command_id} has a different ready cursor"
1662 );
1663 if snapshot
1664 .operational
1665 .last_harness_turn_started_ordinal
1666 .is_some_and(|ordinal| ordinal > expected.ordinal)
1667 {
1668 bail!(CheckpointDeferred::harness_turn_during_capture());
1669 }
1670 Ok(())
1671}
1672
1673fn remove_uninstalled_checkpoint(path: &Path, error: anyhow::Error) -> anyhow::Error {
1674 match std::fs::remove_file(path) {
1675 Ok(()) => error,
1676 Err(remove_error) if remove_error.kind() == std::io::ErrorKind::NotFound => error,
1677 Err(remove_error) => error.context(format!(
1678 "also failed to remove uninstalled checkpoint {}: {remove_error}",
1679 path.display()
1680 )),
1681 }
1682}
1683
1684pub(super) async fn wait_for_relay_closed(relay: &mut StandaloneSession) -> Result<()> {
1685 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30);
1686 loop {
1687 if relay.sync().await?.operational.execution == RelayExecutionState::Closed {
1688 return Ok(());
1689 }
1690 if tokio::time::Instant::now() >= deadline {
1691 bail!("ACP runtime did not close within 30 seconds");
1692 }
1693 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1694 }
1695}
1696
1697async fn release_checkpoint_after_capture(
1709 relay: &mut ControllerRelayLease,
1710 session_id: &str,
1711 barrier_command_id: &str,
1712 cursor: &RelayCursor,
1713) -> Result<CheckpointCompletion> {
1714 relay
1715 .sync_snapshot()
1716 .await
1717 .and_then(|snapshot| {
1718 validate_checkpoint_barrier_snapshot(&snapshot, barrier_command_id, cursor)
1719 })
1720 .context("checkpoint barrier changed while capturing target state")?;
1721 match relay
1722 .submit(
1723 new_command_id("checkpoint-release")?,
1724 RelayCommand::ReleaseCheckpoint {
1725 barrier_command_id: barrier_command_id.to_owned(),
1726 },
1727 )
1728 .await
1729 {
1730 Ok(_) => Ok(CheckpointCompletion::ReleasedAfterCapture),
1731 Err(error) => {
1732 tracing::debug!(
1733 session_id,
1734 "relay kept the checkpoint barrier through the transfer: {error:#}"
1735 );
1736 Ok(CheckpointCompletion::HeldBarrier)
1737 }
1738 }
1739}
1740
1741fn run_checkpoint_staging_command<T: serde::Serialize>(
1742 executor: &impl CommandExecutor,
1743 locator: &hel_targets::TargetLocator,
1744 session_id: &str,
1745 spec: &T,
1746 command: fn(&hel_targets::TargetLocator, &str) -> Result<CommandSpec>,
1747 operation: &str,
1748) -> Result<CommandOutput> {
1749 let body = serde_json::to_vec(spec).with_context(|| format!("serialize {operation} spec"))?;
1750 let mut replaced_worker = false;
1751 loop {
1752 let command = command(locator, session_id)?;
1753 let output = executor.execute_with_stdin(&command, &mut body.as_slice())?;
1754 if output.status == 0 {
1755 return Ok(output);
1756 }
1757 let failure = String::from_utf8_lossy(&output.stderr).into_owned();
1758 if staging_protocol_unsupported(&failure)
1759 && replace_stale_export_worker(
1760 executor,
1761 locator,
1762 session_id,
1763 None,
1764 &failure,
1765 &mut replaced_worker,
1766 )?
1767 {
1768 continue;
1769 }
1770 bail!(
1771 "{operation} failed with status {}: {failure}",
1772 output.status
1773 );
1774 }
1775}
1776
1777fn export_target_checkpoint(
1782 executor: &impl CommandExecutor,
1783 locator: &hel_targets::TargetLocator,
1784 session_id: &str,
1785 spec: &CheckpointExportSpec,
1786 remote_spec: &str,
1787) -> Result<CommandOutput> {
1788 export_target_checkpoint_with_worker(executor, locator, session_id, spec, remote_spec, None)
1789}
1790
1791fn export_target_checkpoint_with_worker(
1792 executor: &impl CommandExecutor,
1793 locator: &hel_targets::TargetLocator,
1794 session_id: &str,
1795 spec: &CheckpointExportSpec,
1796 remote_spec: &str,
1797 worker_binary: Option<&Path>,
1798) -> Result<CommandOutput> {
1799 let body = serde_json::to_vec(spec).context("serialize checkpoint export spec")?;
1800 let mut replaced_worker = false;
1801 loop {
1802 let streamed = export_stdin_command(locator, session_id)?;
1803 let output = executor.execute_with_stdin(&streamed, &mut body.as_slice())?;
1804 if output.status == 0 {
1805 return Ok(output);
1806 }
1807 let failure = String::from_utf8_lossy(&output.stderr).into_owned();
1808 if export_spec_stdin_unsupported(&failure) {
1809 tracing::debug!(
1810 session_id,
1811 "target worker predates streamed checkpoint specs; uploading the spec file instead"
1812 );
1813 let output = export_uploaded_spec(executor, locator, session_id, spec, remote_spec)?;
1814 if output.status == 0 {
1815 return Ok(output);
1816 }
1817 let failure = String::from_utf8_lossy(&output.stderr).into_owned();
1818 if replace_stale_export_worker(
1819 executor,
1820 locator,
1821 session_id,
1822 worker_binary,
1823 &failure,
1824 &mut replaced_worker,
1825 )? {
1826 continue;
1827 }
1828 bail!(
1829 "export target checkpoint failed with status {}: {failure}",
1830 output.status
1831 );
1832 }
1833 if replace_stale_export_worker(
1834 executor,
1835 locator,
1836 session_id,
1837 worker_binary,
1838 &failure,
1839 &mut replaced_worker,
1840 )? {
1841 continue;
1842 }
1843 bail!(
1844 "{} failed with status {}: {failure}",
1845 streamed.purpose,
1846 output.status
1847 );
1848 }
1849}
1850
1851fn export_uploaded_spec(
1852 executor: &impl CommandExecutor,
1853 locator: &hel_targets::TargetLocator,
1854 session_id: &str,
1855 spec: &CheckpointExportSpec,
1856 remote_spec: &str,
1857) -> Result<CommandOutput> {
1858 let staging = tempfile::tempdir().context("create checkpoint staging")?;
1859 let local_spec = staging.path().join("checkpoint-spec.json");
1860 spec.write(&local_spec)?;
1861 upload_checkpoint_spec(executor, locator, session_id, &local_spec, remote_spec)?;
1862 executor.execute(&export_command(locator, session_id, remote_spec)?)
1863}
1864
1865fn replace_stale_export_worker(
1870 executor: &impl CommandExecutor,
1871 locator: &hel_targets::TargetLocator,
1872 session_id: &str,
1873 worker_binary: Option<&Path>,
1874 failure: &str,
1875 replaced_worker: &mut bool,
1876) -> Result<bool> {
1877 if *replaced_worker || !staging_protocol_unsupported(failure) {
1878 return Ok(false);
1879 }
1880 tracing::debug!(
1881 session_id,
1882 "target worker does not support this checkpoint export protocol; replacing the installed Mjolnir binary and retrying"
1883 );
1884 let owned_binary;
1885 let binary = if let Some(path) = worker_binary {
1886 path
1887 } else {
1888 owned_binary = super::worker_binary::worker_binary_for(locator, executor)?;
1889 owned_binary.as_path()
1890 };
1891 super::worker_binary::replace_installed_worker_binary(executor, locator, session_id, binary)?;
1892 *replaced_worker = true;
1893 Ok(true)
1894}
1895
1896fn export_spec_stdin_unsupported(failure: &str) -> bool {
1904 failure.contains("read checkpoint export spec -")
1905 || failure.contains("unexpected argument")
1906 || failure.contains("invalid value")
1907}
1908
1909fn export_spec_schema_unsupported(failure: &str) -> bool {
1914 failure.contains("parse checkpoint")
1915 && (failure.contains("unknown field") || failure.contains("unknown variant"))
1916}
1917
1918fn export_protocol_unsupported(failure: &str) -> bool {
1919 export_spec_schema_unsupported(failure)
1920 || failure.contains("unsupported checkpoint export protocol version")
1921}
1922
1923fn staging_protocol_unsupported(failure: &str) -> bool {
1924 export_protocol_unsupported(failure)
1925 || failure.contains("unrecognized subcommand")
1926 || failure.contains("unexpected argument")
1927}
1928
1929pub(super) fn upload_checkpoint_spec(
1930 executor: &impl CommandExecutor,
1931 locator: &hel_targets::TargetLocator,
1932 session_id: &str,
1933 local: &Path,
1934 remote: &str,
1935) -> Result<()> {
1936 match locator {
1937 hel_targets::TargetLocator::LocalBare { .. } => {
1938 std::fs::copy(local, remote)
1939 .with_context(|| format!("copy checkpoint specification to {remote}"))?;
1940 Ok(())
1941 }
1942 hel_targets::TargetLocator::LocalPodman { container_id, .. } => execute_checked(
1943 executor,
1944 CommandSpec::new(
1945 "podman",
1946 [
1947 "cp".into(),
1948 local.to_string_lossy().into_owned(),
1949 format!("{container_id}:{remote}"),
1950 ],
1951 )
1952 .purpose("upload checkpoint specification"),
1953 )
1954 .map(|_| ()),
1955 hel_targets::TargetLocator::LocalDocker { container_id } => execute_checked(
1956 executor,
1957 CommandSpec::new(
1958 "docker",
1959 [
1960 "cp".into(),
1961 local.to_string_lossy().into_owned(),
1962 format!("{container_id}:{remote}"),
1963 ],
1964 )
1965 .purpose("upload checkpoint specification"),
1966 )
1967 .map(|_| ()),
1968 hel_targets::TargetLocator::AppleContainer { container_id } => execute_checked(
1969 executor,
1970 CommandSpec::new(
1971 "container",
1972 [
1973 "cp".into(),
1974 local.to_string_lossy().into_owned(),
1975 format!("{container_id}:{remote}"),
1976 ],
1977 )
1978 .purpose("upload checkpoint specification"),
1979 )
1980 .map(|_| ()),
1981 hel_targets::TargetLocator::AwsEc2 { ssh, .. }
1982 | hel_targets::TargetLocator::SshBare { ssh, .. } => execute_checked(
1983 executor,
1984 scp_command_spec(ssh, local, remote, false).purpose("upload checkpoint specification"),
1985 )
1986 .map(|_| ()),
1987 hel_targets::TargetLocator::SshPodman {
1988 ssh, container_id, ..
1989 }
1990 | hel_targets::TargetLocator::SshDocker { ssh, container_id } => {
1991 let engine = match locator {
1992 hel_targets::TargetLocator::SshPodman { .. } => "podman",
1993 hel_targets::TargetLocator::SshDocker { .. } => "docker",
1994 _ => unreachable!("matched remote container target"),
1995 };
1996 let staging = format!(".local/share/hel/uploads/{session_id}-checkpoint.json");
1997 execute_checked(
1998 executor,
1999 ssh_command_spec(ssh, ["mkdir", "-p", ".local/share/hel/uploads"])
2000 .purpose("create remote checkpoint staging"),
2001 )?;
2002 execute_checked(
2003 executor,
2004 scp_command_spec(ssh, local, &staging, false)
2005 .purpose("upload remote container checkpoint specification"),
2006 )?;
2007 execute_checked(
2008 executor,
2009 ssh_command_spec(
2010 ssh,
2011 [engine, "cp", &staging, &format!("{container_id}:{remote}")],
2012 )
2013 .purpose("install remote container checkpoint specification"),
2014 )?;
2015 execute_checked(
2016 executor,
2017 ssh_command_spec(ssh, ["rm", "-f", "--", &staging])
2018 .purpose("remove remote checkpoint staging"),
2019 )?;
2020 Ok(())
2021 }
2022 }?;
2023 Ok(())
2024}
2025
2026fn reusable_installed_checkpoint(
2034 session_id: &str,
2035 installed: Option<&CheckpointMetadata>,
2036 native_session_id: &str,
2037 latched_ordinal: u64,
2038 latched_session: &CanonicalSessionSnapshot,
2039) -> Option<CheckpointArtifact> {
2040 let installed = installed?;
2041 if installed.event_frontier > latched_ordinal {
2042 tracing::warn!(
2043 session_id,
2044 installed_frontier = installed.event_frontier,
2045 latched_ordinal,
2046 "installed checkpoint is ahead of the latched cursor; exporting a fresh archive"
2047 );
2048 return None;
2049 }
2050 let verified = match verify_archive_streaming(&installed.archive_path) {
2051 Ok(verified) => verified,
2052 Err(error) => {
2053 tracing::warn!(
2054 session_id,
2055 path = %installed.archive_path.display(),
2056 "installed checkpoint could not be verified for reuse: {error:#}"
2057 );
2058 return None;
2059 }
2060 };
2061 if verified.archive_sha256 != installed.sha256
2062 || verified.manifest.session.id != session_id
2063 || verified.canonical_session.event_frontier != installed.event_frontier
2064 {
2065 tracing::warn!(
2066 session_id,
2067 path = %installed.archive_path.display(),
2068 "installed checkpoint no longer matches its controller metadata; exporting a fresh archive"
2069 );
2070 return None;
2071 }
2072 if !verified.canonical_session.content_matches(latched_session) {
2073 tracing::info!(
2074 session_id,
2075 archive_frontier = verified.canonical_session.event_frontier,
2076 latched_ordinal,
2077 "session content changed since the installed checkpoint; exporting a fresh archive"
2078 );
2079 return None;
2080 }
2081 tracing::info!(
2082 session_id,
2083 archive_frontier = verified.canonical_session.event_frontier,
2084 latched_ordinal,
2085 "reusing the installed checkpoint archive; only relay bookkeeping moved"
2086 );
2087 Some(CheckpointArtifact {
2088 metadata: installed.clone(),
2089 native_session_id: native_session_id.to_owned(),
2090 event_frontier_digest: verified.canonical_session.event_frontier_digest,
2091 })
2092}
2093
2094pub(super) fn verify_installed_checkpoint_gate(
2095 session_id: &str,
2096 checkpoint: &CheckpointMetadata,
2097) -> Result<()> {
2098 let sha256 = checkpoint_sha256(&checkpoint.archive_path).with_context(|| {
2099 format!(
2100 "hash installed checkpoint {} before target cleanup",
2101 checkpoint.archive_path.display()
2102 )
2103 })?;
2104 ensure!(
2105 sha256 == checkpoint.sha256,
2106 "refusing target cleanup for session {session_id}: installed checkpoint SHA changed"
2107 );
2108 Ok(())
2109}
2110
2111fn verify_checkpoint_artifact(session_id: &str, artifact: &CheckpointArtifact) -> Result<()> {
2112 let sha256 = checkpoint_sha256(&artifact.metadata.archive_path).with_context(|| {
2113 format!(
2114 "hash completed checkpoint {}",
2115 artifact.metadata.archive_path.display()
2116 )
2117 })?;
2118 ensure!(
2119 sha256 == artifact.metadata.sha256,
2120 "completed checkpoint SHA changed before persistence for session {session_id}"
2121 );
2122 Ok(())
2123}
2124
2125pub(super) fn release_projection_behind_checkpoint(session_id: &str, current: &CheckpointMetadata) {
2133 match hel::hel_database::compact_materialized_transcript_through(
2134 session_id,
2135 current.event_frontier,
2136 ) {
2137 Ok(retention) if retention.items == 0 => {}
2138 Ok(retention) => tracing::info!(
2139 session_id,
2140 items = retention.items,
2141 bytes = retention.bytes,
2142 remaining = retention.remaining,
2143 event_frontier = current.event_frontier,
2144 "released projection history the checkpoint covers"
2145 ),
2146 Err(error) => tracing::warn!(
2147 session_id,
2148 "checkpoint was saved, but the projection history it covers could not be released: {error:#}"
2149 ),
2150 }
2151}
2152
2153pub(super) fn prune_replaced_checkpoint(
2154 previous: Option<&CheckpointMetadata>,
2155 current: &CheckpointMetadata,
2156) {
2157 let Some(previous) = previous.filter(|old| old.archive_path != current.archive_path) else {
2158 return;
2159 };
2160 match hel::hel_database::move_checkpoint_is_retained(&previous.archive_path) {
2161 Ok(true) => return,
2162 Ok(false) => {}
2163 Err(error) => {
2164 tracing::warn!(%error, "could not check move retention; keeping superseded checkpoint");
2165 return;
2166 }
2167 }
2168 if let Err(error) = std::fs::remove_file(&previous.archive_path)
2169 && error.kind() != std::io::ErrorKind::NotFound
2170 {
2171 tracing::warn!(
2172 path = %previous.archive_path.display(),
2173 "could not remove superseded recovery copy: {error}"
2174 );
2175 }
2176}
2177
2178#[cfg(test)]
2179mod tests {
2180 use std::cell::{Cell, RefCell};
2181 use std::collections::BTreeMap;
2182 use std::fs::OpenOptions;
2183 use std::path::{Path, PathBuf};
2184 #[cfg(unix)]
2185 use std::process::Command;
2186 #[cfg(unix)]
2187 use std::time::Duration;
2188
2189 #[cfg(unix)]
2190 use agent_client_protocol::schema::v1::{ContentBlock, TextContent};
2191 use anyhow::Result;
2192
2193 #[cfg(unix)]
2194 use crate::hel_controller::now;
2195 use crate::hel_controller::restore_session_after_persistence_failure;
2196 use crate::hel_controller::test_support::{
2197 checkpoint_test_session, write_checkpoint_gate_archive,
2198 };
2199 #[cfg(unix)]
2200 use crate::hel_session_manager::{ManagedSessionHandle, new_command_id};
2201 use crate::hel_worker_client::RelayTransportDead;
2202 use hel::hel_archive::{
2203 BundleManifest, CanonicalTranscriptBody, CanonicalTranscriptItem, TargetManifest,
2204 };
2205 use hel::hel_checkpoint::CheckpointExportSpec;
2206 #[cfg(unix)]
2207 use hel::hel_config::{
2208 HarnessProfile, HelConfig, ProjectBundle, ProjectRepository, TargetTemplate,
2209 };
2210 use hel::hel_projection::canonical_session_from_materialized;
2211 #[cfg(unix)]
2212 use hel::hel_state::TargetLocator;
2213 use hel::hel_state::{
2214 CheckpointMetadata, HelState, ManagedSessionSnapshot, MaterializedSession, SessionState,
2215 };
2216 #[cfg(unix)]
2217 use hel::hel_targets::ProvisionStage;
2218 use hel::hel_targets::{self, CommandExecutor, CommandOutput, CommandSpec};
2219 #[cfg(unix)]
2220 use hel::hel_worker::RelayCommandOutcome;
2221 use hel::hel_worker::{RelayCommand, RelayCursor, RelayExecutionState};
2222
2223 use super::*;
2224
2225 #[test]
2226 fn startup_reconciliation_only_removes_unreferenced_controller_checkpoints() {
2227 let directory = tempfile::tempdir().unwrap();
2228 let session_id = "1123456789abcdef0123456789abcdef";
2229 let referenced_name =
2230 format!("{session_id}-7-archive-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.hel.zip");
2231 let orphan_name =
2232 format!("{session_id}-8-archive-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.hel.zip");
2233 let imported_name = format!("{session_id}.hel.zip");
2234 for name in [
2235 &referenced_name,
2236 &orphan_name,
2237 &imported_name,
2238 "notes.hel.zip",
2239 ] {
2240 std::fs::write(directory.path().join(name), b"test").unwrap();
2241 }
2242 let mut state = HelState::default();
2243 let mut session = checkpoint_test_session(session_id);
2244 session.checkpoint = Some(CheckpointMetadata {
2245 archive_path: directory.path().join(&referenced_name),
2246 sha256: "c".repeat(64),
2247 created_at: "2026-08-12T00:00:00Z".into(),
2248 event_frontier: 7,
2249 });
2250 state.sessions.insert(session_id.into(), session);
2251
2252 assert_eq!(
2253 reconcile_managed_checkpoint_archives_in(directory.path(), &state).unwrap(),
2254 1
2255 );
2256 assert!(directory.path().join(referenced_name).exists());
2257 assert!(!directory.path().join(orphan_name).exists());
2258 assert!(directory.path().join(imported_name).exists());
2259 assert!(directory.path().join("notes.hel.zip").exists());
2260 }
2261 #[test]
2262 fn recovery_artifact_final_verification_checks_the_archive_digest() {
2263 let directory = tempfile::tempdir().unwrap();
2264 let session_id = "1123456789abcdef0123456789abcdef";
2265 let metadata = write_checkpoint_gate_archive(directory.path(), session_id, 7);
2266 let mut artifact = CheckpointArtifact {
2267 metadata,
2268 native_session_id: "native-session".into(),
2269 event_frontier_digest: "a".repeat(64),
2270 };
2271
2272 verify_checkpoint_artifact(session_id, &artifact).unwrap();
2273 artifact.metadata.sha256 = "b".repeat(64);
2274 assert!(
2275 verify_checkpoint_artifact(session_id, &artifact)
2276 .unwrap_err()
2277 .to_string()
2278 .contains("checkpoint SHA changed")
2279 );
2280 }
2281 fn checkpoint_barrier_snapshot(cursor: &RelayCursor) -> ManagedSessionSnapshot {
2284 let mut materialized = MaterializedSession::empty("session-1");
2285 materialized.applied_event_ordinal = cursor.ordinal;
2286 materialized.applied_event_digest = cursor.digest.clone();
2287 ManagedSessionSnapshot {
2288 window: hel::hel_state::ProjectionWindow::of(&materialized),
2289 materialized,
2290 latest_credential_sync_signal: None,
2291 worker_build: None,
2292 operational: hel::hel_worker::RelayOperationalState {
2293 acp_ready: None,
2294 store_id: None,
2295 idle_since_ms: None,
2296 session_id: "session-1".into(),
2297 execution: RelayExecutionState::Idle,
2298 latest_ordinal: cursor.ordinal,
2299 latest_digest: cursor.digest.clone(),
2300 acknowledged_through: cursor.ordinal,
2301 acknowledged_digest: cursor.digest.clone(),
2302 recovery_floor_ordinal: 0,
2303 recovery_floor_digest: hel::hel_worker::RELAY_EVENT_GENESIS_DIGEST.into(),
2304 native_session_id: Some("native-session".into()),
2305 agent_capabilities: None,
2306 agent_info: None,
2307 config_options: Vec::new(),
2308 modes: None,
2309 available_commands: Vec::new(),
2310 config: BTreeMap::new(),
2311 active_prompt: None,
2312 queued_prompts: Vec::new(),
2313 active_user_shells: Vec::new(),
2314 active_agent_terminals: Vec::new(),
2315 checkpoint_barrier: Some("checkpoint-1".into()),
2316 checkpoint_ready: None,
2317 last_acp_activity_at_ms: None,
2318 current_step_started_at_ms: None,
2319 foreground_tool_started_at_ms: None,
2320 harness_turn: None,
2321 last_harness_turn_started_ordinal: None,
2322 background_commands: Vec::new(),
2323 },
2324 }
2325 }
2326 #[test]
2327 fn checkpoint_barrier_is_not_reached_until_its_ready_cursor_is_projected() {
2328 let cursor = RelayCursor {
2329 ordinal: 7,
2330 digest: "a".repeat(64),
2331 };
2332 let mut snapshot = checkpoint_barrier_snapshot(&cursor);
2333
2334 assert!(!checkpoint_barrier_is_ready(&snapshot, "checkpoint-1"));
2335 snapshot.operational.checkpoint_ready = Some(cursor.clone());
2336 assert!(checkpoint_barrier_is_ready(&snapshot, "checkpoint-1"));
2337 validate_checkpoint_barrier_snapshot(&snapshot, "checkpoint-1", &cursor).unwrap();
2338 }
2339 #[test]
2340 fn checkpoint_revalidation_accepts_a_frontier_that_moved_past_the_ready_cursor() {
2341 let cursor = RelayCursor {
2342 ordinal: 7,
2343 digest: "a".repeat(64),
2344 };
2345 let mut snapshot = checkpoint_barrier_snapshot(&cursor);
2346 snapshot.operational.checkpoint_ready = Some(cursor.clone());
2347
2348 snapshot.operational.latest_ordinal = cursor.ordinal + 2;
2352 snapshot.operational.latest_digest = "b".repeat(64);
2353 snapshot.materialized.applied_event_ordinal = cursor.ordinal + 2;
2354 snapshot.materialized.applied_event_digest = "b".repeat(64);
2355 validate_checkpoint_barrier_snapshot(&snapshot, "checkpoint-1", &cursor).unwrap();
2356
2357 snapshot.operational.checkpoint_ready = Some(RelayCursor {
2359 ordinal: cursor.ordinal + 1,
2360 digest: "c".repeat(64),
2361 });
2362 assert!(validate_checkpoint_barrier_snapshot(&snapshot, "checkpoint-1", &cursor).is_err());
2363 snapshot.operational.checkpoint_ready = Some(cursor.clone());
2364 snapshot.operational.checkpoint_barrier = None;
2365 assert!(validate_checkpoint_barrier_snapshot(&snapshot, "checkpoint-1", &cursor).is_err());
2366 }
2367 fn exported_checkpoint_json() -> Vec<u8> {
2369 serde_json::to_vec(&hel::hel_checkpoint::TargetCheckpoint {
2370 path: PathBuf::from("/var/lib/hel/workers/session/checkpoint.hel.zip"),
2371 sha256: "c".repeat(64),
2372 event_frontier: 7,
2373 event_frontier_digest: "d".repeat(64),
2374 timings: None,
2375 })
2376 .unwrap()
2377 }
2378 fn export_spec_fixture() -> CheckpointExportSpec {
2379 CheckpointExportSpec {
2380 protocol_version: CHECKPOINT_EXPORT_PROTOCOL_VERSION,
2381 session: hel::hel_archive::SessionManifest {
2382 id: LATCH_RELAY_SESSION.into(),
2383 title: "streamed spec".into(),
2384 harness_kind: hel::hel_config::HarnessKind::Codex,
2385 profile_id: "codex".into(),
2386 native_session_id: "native-session".into(),
2387 created_at: "2026-08-12T00:00:00Z".into(),
2388 checkpointed_at: "2026-08-16T00:00:00Z".into(),
2389 hel_version: "test".into(),
2390 relay_version: "test".into(),
2391 adapter_version: "acp-v1".into(),
2392 },
2393 target: TargetManifest {
2394 template_id: "podman".into(),
2395 target_kind: "local-podman".into(),
2396 details: BTreeMap::new(),
2397 },
2398 bundle: BundleManifest {
2399 id: "project".into(),
2400 primary_repository: "app".into(),
2401 },
2402 relay_root: PathBuf::from("/var/lib/hel/workers/session"),
2403 harness_home: PathBuf::from("/var/lib/hel/profiles/codex"),
2404 workspace_root: PathBuf::from("/workspace"),
2405 repositories: Vec::new(),
2406 canonical_session: canonical_session_from_materialized(&MaterializedSession::empty(
2407 LATCH_RELAY_SESSION.to_owned(),
2408 ))
2409 .unwrap(),
2410 output_path: PathBuf::from("/var/lib/hel/workers/session/checkpoint.hel.zip"),
2411 }
2412 }
2413 struct ExportExecutor {
2416 streamed_status: i32,
2417 streamed_stderr: String,
2418 retry_stdin_after_failure: bool,
2419 stdin_calls: Cell<usize>,
2420 purposes: RefCell<Vec<String>>,
2421 streamed_spec: RefCell<Vec<u8>>,
2422 }
2423 impl ExportExecutor {
2424 fn new(streamed_status: i32, streamed_stderr: &str) -> Self {
2425 Self {
2426 streamed_status,
2427 streamed_stderr: streamed_stderr.to_owned(),
2428 retry_stdin_after_failure: false,
2429 stdin_calls: Cell::new(0),
2430 purposes: RefCell::new(Vec::new()),
2431 streamed_spec: RefCell::new(Vec::new()),
2432 }
2433 }
2434
2435 fn retry_stdin_after_failure(mut self) -> Self {
2436 self.retry_stdin_after_failure = true;
2437 self
2438 }
2439 }
2440 impl CommandExecutor for ExportExecutor {
2441 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2442 self.purposes.borrow_mut().push(command.purpose.clone());
2443 Ok(CommandOutput {
2444 status: 0,
2445 stdout: exported_checkpoint_json(),
2446 stderr: Vec::new(),
2447 })
2448 }
2449
2450 fn execute_with_stdin(
2451 &self,
2452 command: &CommandSpec,
2453 input: &mut (dyn std::io::Read + Send),
2454 ) -> Result<CommandOutput> {
2455 self.purposes.borrow_mut().push(command.purpose.clone());
2456 let mut spec = Vec::new();
2457 input.read_to_end(&mut spec)?;
2458 *self.streamed_spec.borrow_mut() = spec;
2459 let attempt = self.stdin_calls.get();
2460 self.stdin_calls.set(attempt + 1);
2461 let failed =
2462 self.streamed_status != 0 && (attempt == 0 || !self.retry_stdin_after_failure);
2463 Ok(CommandOutput {
2464 status: if failed { self.streamed_status } else { 0 },
2465 stdout: if failed {
2466 Vec::new()
2467 } else {
2468 exported_checkpoint_json()
2469 },
2470 stderr: if failed {
2471 self.streamed_stderr.clone().into_bytes()
2472 } else {
2473 Vec::new()
2474 },
2475 })
2476 }
2477 }
2478 #[test]
2479 fn docker_checkpoint_fallback_upload_uses_docker_cp() {
2480 struct RecordingExecutor {
2481 commands: RefCell<Vec<CommandSpec>>,
2482 }
2483 impl CommandExecutor for RecordingExecutor {
2484 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2485 self.commands.borrow_mut().push(command.clone());
2486 Ok(CommandOutput {
2487 status: 0,
2488 stdout: Vec::new(),
2489 stderr: Vec::new(),
2490 })
2491 }
2492 }
2493
2494 let executor = RecordingExecutor {
2495 commands: RefCell::new(Vec::new()),
2496 };
2497 let locator = hel_targets::TargetLocator::LocalDocker {
2498 container_id: "hel-session-12345678".to_owned(),
2499 };
2500 upload_checkpoint_spec(
2501 &executor,
2502 &locator,
2503 LATCH_RELAY_SESSION,
2504 Path::new("checkpoint-spec.json"),
2505 "/var/lib/hel/workers/session/checkpoint-spec.json",
2506 )
2507 .unwrap();
2508
2509 let commands = executor.commands.borrow();
2510 assert_eq!(commands.len(), 1);
2511 assert_eq!(commands[0].program, "docker");
2512 assert_eq!(
2513 commands[0].args,
2514 [
2515 "cp",
2516 "checkpoint-spec.json",
2517 "hel-session-12345678:/var/lib/hel/workers/session/checkpoint-spec.json"
2518 ]
2519 );
2520 assert_eq!(commands[0].purpose, "upload checkpoint specification");
2521 }
2522 #[test]
2523 fn checkpoint_export_streams_its_spec_instead_of_uploading_it() {
2524 let locator = hel_targets::TargetLocator::LocalPodman {
2525 container_id: hel_targets::resource_name(LATCH_RELAY_SESSION).unwrap(),
2526 workspace_storage: Default::default(),
2527 };
2528 let spec = export_spec_fixture();
2529 let executor = ExportExecutor::new(0, "");
2530
2531 let output = export_target_checkpoint(
2532 &executor,
2533 &locator,
2534 LATCH_RELAY_SESSION,
2535 &spec,
2536 "/var/lib/hel/workers/session/checkpoint-spec.json",
2537 )
2538 .unwrap();
2539
2540 assert_eq!(output.stdout, exported_checkpoint_json());
2541 assert_eq!(
2542 serde_json::from_slice::<CheckpointExportSpec>(&executor.streamed_spec.borrow())
2543 .unwrap(),
2544 spec
2545 );
2546 assert_eq!(
2547 executor.purposes.into_inner(),
2548 vec!["export target checkpoint".to_owned()]
2549 );
2550 }
2551 #[test]
2554 fn an_export_that_cannot_read_stdin_falls_back_to_uploading_the_spec() {
2555 let locator = hel_targets::TargetLocator::LocalPodman {
2556 container_id: hel_targets::resource_name(LATCH_RELAY_SESSION).unwrap(),
2557 workspace_storage: Default::default(),
2558 };
2559 let executor = ExportExecutor::new(
2560 1,
2561 "Error: read checkpoint export spec -\n\nCaused by:\n \
2562 No such file or directory (os error 2)\n",
2563 );
2564
2565 let output = export_target_checkpoint(
2566 &executor,
2567 &locator,
2568 LATCH_RELAY_SESSION,
2569 &export_spec_fixture(),
2570 "/var/lib/hel/workers/session/checkpoint-spec.json",
2571 )
2572 .unwrap();
2573
2574 assert_eq!(output.stdout, exported_checkpoint_json());
2575 assert_eq!(
2576 executor.purposes.into_inner(),
2577 vec![
2578 "export target checkpoint".to_owned(),
2579 "upload checkpoint specification".to_owned(),
2580 "export target checkpoint".to_owned(),
2581 ]
2582 );
2583 }
2584 #[test]
2585 fn a_failing_export_is_not_retried_as_an_old_worker() {
2586 let locator = hel_targets::TargetLocator::LocalPodman {
2587 container_id: hel_targets::resource_name(LATCH_RELAY_SESSION).unwrap(),
2588 workspace_storage: Default::default(),
2589 };
2590 let executor = ExportExecutor::new(1, "Error: repository 'app' is missing\n");
2591
2592 let error = export_target_checkpoint(
2593 &executor,
2594 &locator,
2595 LATCH_RELAY_SESSION,
2596 &export_spec_fixture(),
2597 "/var/lib/hel/workers/session/checkpoint-spec.json",
2598 )
2599 .unwrap_err();
2600
2601 assert!(
2602 format!("{error:#}").contains("repository 'app' is missing"),
2603 "{error:#}"
2604 );
2605 assert_eq!(
2606 executor.purposes.into_inner(),
2607 vec!["export target checkpoint".to_owned()]
2608 );
2609 }
2610 #[test]
2613 fn a_legacy_export_worker_is_replaced_before_it_runs_obsolete_behavior() {
2614 let locator = hel_targets::TargetLocator::LocalPodman {
2615 container_id: hel_targets::resource_name(LATCH_RELAY_SESSION).unwrap(),
2616 workspace_storage: Default::default(),
2617 };
2618 let spec = export_spec_fixture();
2619 let executor = ExportExecutor::new(
2620 1,
2621 "Error: parse checkpoint export spec from standard input\n\nCaused by:\n \
2622 unknown field `protocol_version`, expected `session` at line 1 column 20\n",
2623 )
2624 .retry_stdin_after_failure();
2625 let worker_binary = Path::new("/hel-test-worker");
2626
2627 let output = export_target_checkpoint_with_worker(
2628 &executor,
2629 &locator,
2630 LATCH_RELAY_SESSION,
2631 &spec,
2632 "/var/lib/hel/workers/session/checkpoint-spec.json",
2633 Some(worker_binary),
2634 )
2635 .unwrap();
2636
2637 assert_eq!(output.stdout, exported_checkpoint_json());
2638 assert_eq!(
2639 serde_json::from_slice::<CheckpointExportSpec>(&executor.streamed_spec.borrow())
2640 .unwrap(),
2641 spec
2642 );
2643 assert_eq!(
2644 executor.purposes.into_inner(),
2645 vec![
2646 "export target checkpoint".to_owned(),
2647 "stage replacement Mjolnir worker".to_owned(),
2648 "replace installed Mjolnir worker".to_owned(),
2649 "make replaced Mjolnir worker executable".to_owned(),
2650 "export target checkpoint".to_owned(),
2651 ]
2652 );
2653 }
2654 #[test]
2655 fn a_schema_mismatch_after_uploading_the_spec_still_replaces_the_worker_binary() {
2656 let locator = hel_targets::TargetLocator::LocalPodman {
2657 container_id: hel_targets::resource_name(LATCH_RELAY_SESSION).unwrap(),
2658 workspace_storage: Default::default(),
2659 };
2660 struct FileThenRefreshExecutor {
2661 purposes: RefCell<Vec<String>>,
2662 file_export_calls: Cell<usize>,
2663 }
2664 impl CommandExecutor for FileThenRefreshExecutor {
2665 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2666 self.purposes.borrow_mut().push(command.purpose.clone());
2667 if command.purpose == "export target checkpoint" {
2668 let attempt = self.file_export_calls.get();
2669 self.file_export_calls.set(attempt + 1);
2670 if attempt == 0 {
2671 return Ok(CommandOutput {
2672 status: 1,
2673 stdout: Vec::new(),
2674 stderr: b"Error: parse checkpoint export spec /spec.json\n\nCaused by:\n unknown variant `terminal_output`, expected one of `user`, `agent`, `thought`, `tool`, `plan`, `system`\n".to_vec(),
2675 });
2676 }
2677 }
2678 Ok(CommandOutput {
2679 status: 0,
2680 stdout: exported_checkpoint_json(),
2681 stderr: Vec::new(),
2682 })
2683 }
2684
2685 fn execute_with_stdin(
2686 &self,
2687 command: &CommandSpec,
2688 input: &mut (dyn std::io::Read + Send),
2689 ) -> Result<CommandOutput> {
2690 self.purposes.borrow_mut().push(command.purpose.clone());
2691 let mut discarded = Vec::new();
2692 input.read_to_end(&mut discarded)?;
2693 let stdin_calls = self
2694 .purposes
2695 .borrow()
2696 .iter()
2697 .filter(|purpose| *purpose == "export target checkpoint")
2698 .count();
2699 if stdin_calls == 1 {
2700 return Ok(CommandOutput {
2701 status: 1,
2702 stdout: Vec::new(),
2703 stderr: b"Error: read checkpoint export spec -\n\nCaused by:\n No such file or directory (os error 2)\n".to_vec(),
2704 });
2705 }
2706 Ok(CommandOutput {
2707 status: 0,
2708 stdout: exported_checkpoint_json(),
2709 stderr: Vec::new(),
2710 })
2711 }
2712 }
2713
2714 let executor = FileThenRefreshExecutor {
2715 purposes: RefCell::new(Vec::new()),
2716 file_export_calls: Cell::new(0),
2717 };
2718 let output = export_target_checkpoint_with_worker(
2719 &executor,
2720 &locator,
2721 LATCH_RELAY_SESSION,
2722 &export_spec_fixture(),
2723 "/var/lib/hel/workers/session/checkpoint-spec.json",
2724 Some(Path::new("/hel-test-worker")),
2725 )
2726 .unwrap();
2727
2728 assert_eq!(output.stdout, exported_checkpoint_json());
2729 assert_eq!(
2730 executor.purposes.into_inner(),
2731 vec![
2732 "export target checkpoint".to_owned(),
2733 "upload checkpoint specification".to_owned(),
2734 "export target checkpoint".to_owned(),
2735 "stage replacement Mjolnir worker".to_owned(),
2736 "replace installed Mjolnir worker".to_owned(),
2737 "make replaced Mjolnir worker executable".to_owned(),
2738 "export target checkpoint".to_owned(),
2739 ]
2740 );
2741 }
2742 #[test]
2746 fn a_working_session_defers_but_close_waits_for_cancellation_before_recovery() {
2747 let cursor = RelayCursor {
2748 ordinal: 7,
2749 digest: "a".repeat(64),
2750 };
2751 let mut snapshot = checkpoint_barrier_snapshot(&cursor);
2752 snapshot.operational.execution = RelayExecutionState::Running;
2753
2754 let deferred = checkpoint_barrier_wait_ended(
2755 &snapshot,
2756 "checkpoint-1",
2757 BarrierBusyPolicy::DeferWhileRunning,
2758 false,
2759 false,
2760 )
2761 .expect("a working session ends the wait at once");
2762 assert!(checkpoint_was_deferred(&deferred), "{deferred:#}");
2763 assert!(
2764 !checkpoint_barrier_needs_worker_restart(&deferred),
2765 "a deferred copy must never restart the worker: {deferred:#}"
2766 );
2767 assert_eq!(
2768 BarrierBusyPolicy::of(LatchExclusivity::HoldThroughClose),
2769 BarrierBusyPolicy::InterruptWhileRunning
2770 );
2771
2772 assert!(
2775 checkpoint_barrier_wait_ended(
2776 &snapshot,
2777 "checkpoint-1",
2778 BarrierBusyPolicy::InterruptWhileRunning,
2779 false,
2780 false,
2781 )
2782 .is_none()
2783 );
2784 let interrupted = checkpoint_barrier_wait_ended(
2785 &snapshot,
2786 "checkpoint-1",
2787 BarrierBusyPolicy::InterruptWhileRunning,
2788 true,
2789 true,
2790 )
2791 .expect("an unresponsive cancellation ends the wait at the deadline");
2792 assert!(
2793 checkpoint_barrier_needs_worker_restart(&interrupted),
2794 "{interrupted:#}"
2795 );
2796 assert!(!checkpoint_was_deferred(&interrupted), "{interrupted:#}");
2797
2798 snapshot.operational.execution = RelayExecutionState::Idle;
2801 let wedged = checkpoint_barrier_wait_ended(
2802 &snapshot,
2803 "checkpoint-1",
2804 BarrierBusyPolicy::DeferWhileRunning,
2805 true,
2806 false,
2807 )
2808 .expect("the deadline ends the wait");
2809 assert!(
2810 checkpoint_barrier_needs_worker_restart(&wedged),
2811 "{wedged:#}"
2812 );
2813 assert!(!checkpoint_was_deferred(&wedged), "{wedged:#}");
2814 }
2815
2816 #[test]
2819 fn a_frontier_that_moved_before_the_latch_defers_the_checkpoint() {
2820 let cursor = RelayCursor {
2821 ordinal: 220,
2822 digest: "a".repeat(64),
2823 };
2824 ensure_exact_checkpoint_cut(&cursor, cursor.ordinal, &cursor.digest)
2825 .expect("a projection latched at the ready cursor is an exact cut");
2826
2827 for (ordinal, digest) in [(223, "a".repeat(64)), (220, "b".repeat(64))] {
2828 let error = ensure_exact_checkpoint_cut(&cursor, ordinal, &digest)
2829 .expect_err("a projection past the ready cursor is not an exact cut");
2830 assert!(checkpoint_was_deferred(&error), "{error:#}");
2831 assert!(
2832 !checkpoint_barrier_needs_worker_restart(&error),
2833 "{error:#}"
2834 );
2835 }
2836 }
2837
2838 #[test]
2842 fn a_harness_turn_started_during_capture_abandons_the_archive() {
2843 let cursor = RelayCursor {
2844 ordinal: 220,
2845 digest: "a".repeat(64),
2846 };
2847 let mut snapshot = checkpoint_barrier_snapshot(&cursor);
2848 snapshot.operational.checkpoint_ready = Some(cursor.clone());
2849
2850 snapshot.operational.last_harness_turn_started_ordinal = Some(cursor.ordinal);
2851 validate_checkpoint_barrier_snapshot(&snapshot, "checkpoint-1", &cursor)
2852 .expect("a turn that started at or before the cursor is covered by the archive");
2853
2854 snapshot.operational.last_harness_turn_started_ordinal = Some(cursor.ordinal + 1);
2855 let error = validate_checkpoint_barrier_snapshot(&snapshot, "checkpoint-1", &cursor)
2856 .expect_err("a turn that started after the cursor invalidates the capture");
2857 assert!(checkpoint_was_deferred(&error), "{error:#}");
2858 }
2859
2860 #[test]
2861 fn a_stuck_checkpoint_barrier_is_retried_by_restarting_the_worker() {
2862 for failure in [
2865 CheckpointBarrierUnreachable::not_admitted(
2866 "checkpoint-976f6746887c5ccd93b9d8bbe120ef06",
2867 ),
2868 CheckpointBarrierUnreachable::runtime_stopped(),
2869 ] {
2870 let error = anyhow::Error::new(failure).context("latch a session checkpoint");
2871 assert!(checkpoint_barrier_needs_worker_restart(&error), "{error:#}");
2872 }
2873 assert!(!checkpoint_barrier_needs_worker_restart(&anyhow::anyhow!(
2874 "export target checkpoint failed with status 1"
2875 )));
2876 assert!(!checkpoint_barrier_needs_worker_restart(&anyhow::anyhow!(
2879 "ACP relay did not reach checkpoint barrier checkpoint-1"
2880 )));
2881 }
2882
2883 #[test]
2884 fn an_incompatible_cancel_turn_requests_worker_recovery() {
2885 let error = anyhow::Error::new(RelayRejected(hel::hel_worker::RelayProtocolError {
2886 code: hel::hel_worker::RelayErrorCode::IncompatibleProtocol,
2887 message: "request uses protocol 6".into(),
2888 retryable: false,
2889 detail: None,
2890 }))
2891 .context("cancel active ACP turn before checkpoint barrier");
2892 assert!(
2893 checkpoint_cancel_turn_needs_worker_restart(&error),
2894 "{error:#}"
2895 );
2896 assert!(checkpoint_barrier_needs_worker_restart(&error.context(
2897 CheckpointBarrierUnreachable::cancel_turn_unavailable("checkpoint-1", 6,)
2898 )));
2899 }
2900 #[test]
2901 fn a_dead_worker_hello_failure_is_retried_by_restarting_the_worker() {
2902 let dead = anyhow::Error::new(RelayTransportDead::new("the proxy is gone"))
2903 .context("connect to the session worker for checkpoint");
2904 assert!(worker_connect_needs_restart(&dead), "{dead:#}");
2905 assert!(!worker_connect_needs_restart(&anyhow::anyhow!(
2906 "unknown session"
2907 )));
2908 }
2909 #[cfg(unix)]
2910 #[tokio::test]
2911 async fn checkpoint_restart_stop_failure_names_mjolnir() {
2912 struct FailingStop;
2913
2914 impl CommandExecutor for FailingStop {
2915 fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
2916 Ok(CommandOutput {
2917 status: 1,
2918 stdout: Vec::new(),
2919 stderr: b"permission denied".to_vec(),
2920 })
2921 }
2922 }
2923
2924 let session_id = "0123456789abcdef0123456789abcdef";
2925 let worker_root = format!("/tmp/mjolnir-checkpoint-test/{session_id}");
2926 let backend = hel_targets::TargetLocator::LocalBare {
2927 worker_root: worker_root.clone(),
2928 };
2929 let controller = Controller {
2930 config: HelConfig::default(),
2931 state: HelState::default(),
2932 };
2933 let reconnect = CommandSpec::new("unused", std::iter::empty::<&str>());
2934
2935 let result = controller
2936 .restart_worker_for_checkpoint(
2937 session_id,
2938 &FailingStop,
2939 &backend,
2940 &worker_root,
2941 &reconnect,
2942 )
2943 .await;
2944 let error = match result {
2945 Ok(_) => panic!("a failed worker stop unexpectedly restarted the checkpoint worker"),
2946 Err(error) => error,
2947 };
2948 let detail = format!("{error:#}");
2949 assert!(
2950 detail.starts_with("stop wedged Mjolnir worker before retrying checkpoint"),
2951 "{detail}"
2952 );
2953 assert!(detail.contains("permission denied"), "{detail}");
2954 }
2955 #[test]
2956 fn export_spec_schema_mismatch_is_detected_from_the_parse_error() {
2957 assert!(export_spec_schema_unsupported(
2958 "Error: parse checkpoint export spec from standard input\n\nCaused by:\n \
2959 unknown field `terminal_refs`, expected `call` at line 1 column 7276552\n"
2960 ));
2961 assert!(export_spec_schema_unsupported(
2962 "Error: parse checkpoint export spec /spec.json\n\nCaused by:\n \
2963 unknown variant `terminal_output`, expected one of `user`, `agent`\n"
2964 ));
2965 assert!(!export_spec_schema_unsupported(
2966 "Error: repository 'app' is missing\n"
2967 ));
2968 assert!(!export_spec_schema_unsupported(
2969 "Error: parse checkpoint export spec from standard input\n\nCaused by:\n \
2970 missing field `relay_root`\n"
2971 ));
2972 assert!(export_protocol_unsupported(
2973 "Error: unsupported checkpoint export protocol version 2; worker supports 1\n"
2974 ));
2975 }
2976 const LATCH_RELAY_ROOT: &str = "MJ_TEST_LATCH_RELAY_ROOT";
2977 const LATCH_RELAY_STARTS: &str = "MJ_TEST_LATCH_RELAY_STARTS";
2978 const LATCH_RELAY_REJECT_RELEASE: &str = "MJ_TEST_LATCH_REJECT_RELEASE";
2979 #[cfg(unix)]
2980 const LATCH_RELAY_RUNNING: &str = "MJ_TEST_LATCH_RELAY_RUNNING";
2981 #[cfg(unix)]
2982 const LATCH_TEST_CHILD: &str = "MJ_TEST_LATCH_CHILD";
2983 #[cfg(unix)]
2984 const ABANDON_TEST_CHILD: &str = "MJ_TEST_ABANDON_LATCH_CHILD";
2985 #[cfg(unix)]
2986 const RELEASE_TEST_CHILD: &str = "MJ_TEST_RELEASE_LATCH_CHILD";
2987 #[cfg(unix)]
2988 const LEGACY_RELEASE_TEST_CHILD: &str = "MJ_TEST_LEGACY_RELEASE_LATCH_CHILD";
2989 #[cfg(unix)]
2990 const REUSE_TEST_CHILD: &str = "MJ_TEST_REUSE_LATCH_CHILD";
2991 const LATCH_RELAY_STARTUP_DELAY_MS: &str = "MJ_TEST_LATCH_STARTUP_DELAY_MS";
2992 const LATCH_RELAY_SESSION: &str = "018f9dd2-a3b4-7c8d-9000-0123456789ab";
2993 #[cfg(unix)]
2995 #[derive(Clone, Copy, PartialEq, Eq)]
2996 enum ReleaseSupport {
2997 Supported,
2998 Rejected,
3001 }
3002 #[test]
3009 fn latch_relay_child_serves_stdio() {
3010 let Some(root) = std::env::var_os(LATCH_RELAY_ROOT) else {
3011 return;
3012 };
3013 println!();
3017 if let Some(starts) = std::env::var_os(LATCH_RELAY_STARTS) {
3020 use std::io::Write;
3021 let mut log = OpenOptions::new()
3022 .create(true)
3023 .append(true)
3024 .open(starts)
3025 .expect("open the relay start log");
3026 writeln!(log, "{}", std::process::id()).expect("record this relay start");
3027 }
3028 let mut relay =
3029 hel::hel_worker::DurableRelay::open(Path::new(&root), LATCH_RELAY_SESSION, "1.0.0")
3030 .expect("open the test relay journal");
3031 if relay.operational_state().native_session_id.is_none() {
3032 relay
3033 .record_observation(hel::hel_worker::RelayObservation::SessionOpened {
3034 native_session_id: "native-session".into(),
3035 resumed: true,
3036 })
3037 .unwrap();
3038 }
3039 let ready_at = Instant::now()
3040 + Duration::from_millis(
3041 std::env::var(LATCH_RELAY_STARTUP_DELAY_MS)
3042 .ok()
3043 .map(|value| value.parse::<u64>().unwrap())
3044 .unwrap_or(0),
3045 );
3046 let reject_release = std::env::var_os(LATCH_RELAY_REJECT_RELEASE).is_some();
3047 #[cfg(unix)]
3048 let running = std::env::var_os(LATCH_RELAY_RUNNING).is_some();
3049 #[cfg(unix)]
3050 if running && relay.operational_state().active_prompt.is_none() {
3051 let response = relay.handle(hel::hel_worker::RelayRequestEnvelope {
3052 request_id: "seed-running-request".into(),
3053 protocol_version: hel::hel_worker::RELAY_PROTOCOL_VERSION,
3054 request: hel::hel_worker::RelayRequest::Submit {
3055 command_id: "seed-running-prompt".into(),
3056 command: RelayCommand::Prompt {
3057 prompt: vec![ContentBlock::Text(TextContent::new("running"))],
3058 },
3059 },
3060 });
3061 assert!(matches!(
3062 response.body,
3063 hel::hel_worker::RelayResponseBody::Ok {
3064 payload: hel::hel_worker::RelayResponsePayload::Accepted { .. }
3065 }
3066 ));
3067 let claimed = relay
3068 .claim_pending_commands(true)
3069 .expect("seed the running prompt");
3070 assert_eq!(claimed.len(), 1);
3071 assert_eq!(claimed[0].command_id, "seed-running-prompt");
3072 }
3073 let mut reader = std::io::stdin().lock();
3074 let mut writer = std::io::stdout().lock();
3075 let mut configured = false;
3076 while let Some(request) =
3077 hel::hel_worker::read_relay_frame(&mut reader).expect("read a relay request")
3078 {
3079 if !configured && Instant::now() >= ready_at {
3080 relay
3081 .record_observation(hel::hel_worker::RelayObservation::SessionConfigured {
3082 config_options: Vec::new(),
3083 })
3084 .unwrap();
3085 configured = true;
3086 }
3087 if matches!(
3088 &request.request,
3089 hel::hel_worker::RelayRequest::Submit {
3090 command: RelayCommand::BeginCheckpoint { .. },
3091 ..
3092 }
3093 ) {
3094 assert!(
3095 relay.operational_state().native_session_is_ready(),
3096 "checkpoint submitted before current ACP startup finished"
3097 );
3098 }
3099 let response = if reject_release && requests_checkpoint_release(&request) {
3100 unparseable_request_response(&request)
3101 } else {
3102 relay.handle(request)
3103 };
3104 hel::hel_worker::write_relay_frame(&mut writer, &response)
3105 .expect("answer a relay request");
3106 for claimed in relay
3107 .claim_pending_commands(true)
3108 .expect("claim relay commands")
3109 {
3110 match claimed.command {
3111 RelayCommand::BeginCheckpoint { .. } => {
3112 relay
3113 .record_checkpoint_ready(&claimed.command_id)
3114 .expect("report the checkpoint barrier ready");
3115 }
3116 #[cfg(unix)]
3117 RelayCommand::CancelTurn => {
3118 let prompt_id = relay
3119 .operational_state()
3120 .active_prompt
3121 .as_ref()
3122 .map(|prompt| prompt.command_id.clone())
3123 .expect("a prompt to cancel");
3124 relay
3125 .record_command_completed(
3126 &claimed.command_id,
3127 RelayCommandOutcome::Cancelled,
3128 )
3129 .expect("complete the cancellation");
3130 relay
3131 .record_command_completed(
3132 &prompt_id,
3133 RelayCommandOutcome::Prompt {
3134 stop_reason: "cancelled".into(),
3135 },
3136 )
3137 .expect("complete the cancelled prompt");
3138 }
3139 _ => {}
3140 }
3141 }
3142 }
3143 }
3144 fn requests_checkpoint_release(request: &hel::hel_worker::RelayRequestEnvelope) -> bool {
3145 matches!(
3146 &request.request,
3147 hel::hel_worker::RelayRequest::Submit {
3148 command: RelayCommand::ReleaseCheckpoint { .. },
3149 ..
3150 }
3151 )
3152 }
3153 fn unparseable_request_response(
3157 request: &hel::hel_worker::RelayRequestEnvelope,
3158 ) -> hel::hel_worker::RelayResponseEnvelope {
3159 hel::hel_worker::RelayResponseEnvelope {
3160 request_id: request.request_id.clone(),
3161 protocol_version: request.protocol_version,
3162 body: hel::hel_worker::RelayResponseBody::Error {
3163 error: hel::hel_worker::RelayProtocolError {
3164 code: hel::hel_worker::RelayErrorCode::InvalidRequest,
3165 message: "unknown variant `release_checkpoint`".into(),
3166 retryable: false,
3167 detail: None,
3168 },
3169 },
3170 }
3171 }
3172 #[cfg(unix)]
3175 fn latch_relay_target(
3176 relay_root: &Path,
3177 starts: Option<&Path>,
3178 release: ReleaseSupport,
3179 running: bool,
3180 ) -> crate::hel_session_manager::RelaySessionTarget {
3181 let script = format!(
3184 "\"$0\" --exact {}::latch_relay_child_serves_stdio --nocapture | \
3185 grep --line-buffered '^{{'",
3186 module_path!()
3187 .strip_prefix("mj_controller::")
3188 .unwrap_or(module_path!())
3189 );
3190 let mut spec = CommandSpec::new(
3191 "sh",
3192 [
3193 "-c".to_owned(),
3194 script,
3195 std::env::current_exe()
3196 .unwrap()
3197 .to_string_lossy()
3198 .into_owned(),
3199 ],
3200 )
3201 .purpose("test latch relay");
3202 spec.env.insert(
3203 LATCH_RELAY_ROOT.to_owned(),
3204 relay_root.to_string_lossy().into_owned(),
3205 );
3206 if let Some(starts) = starts {
3207 spec.env.insert(
3208 LATCH_RELAY_STARTS.to_owned(),
3209 starts.to_string_lossy().into_owned(),
3210 );
3211 }
3212 if release == ReleaseSupport::Rejected {
3213 spec.env
3214 .insert(LATCH_RELAY_REJECT_RELEASE.to_owned(), "1".to_owned());
3215 }
3216 if running {
3217 spec.env
3218 .insert(LATCH_RELAY_RUNNING.to_owned(), "1".to_owned());
3219 }
3220 crate::hel_session_manager::RelaySessionTarget {
3221 session_id: LATCH_RELAY_SESSION.to_owned(),
3222 spec,
3223 worker_recovery: None,
3224 project_memory: None,
3225 }
3226 }
3227 #[cfg(unix)]
3230 async fn latch_a_live_checkpoint(
3231 relay_root: &Path,
3232 starts: Option<&Path>,
3233 release: ReleaseSupport,
3234 running: bool,
3235 ) -> (
3236 crate::hel_session_manager::SessionManagerChannels,
3237 ManagedSessionHandle,
3238 ControllerRelayLease,
3239 String,
3240 RelayCursor,
3241 ) {
3242 hel::hel_database::save_session(&checkpoint_test_session(LATCH_RELAY_SESSION)).unwrap();
3245 let channels = crate::hel_session_manager::spawn_session_manager().unwrap();
3246 channels
3247 .targets
3248 .send(vec![latch_relay_target(
3249 relay_root, starts, release, running,
3250 )])
3251 .unwrap();
3252 let handle = channels
3253 .control
3254 .wait_for_session(LATCH_RELAY_SESSION, Duration::from_secs(10))
3255 .await
3256 .unwrap();
3257
3258 let lease = handle.lease_connection().await.unwrap();
3259 let mut relay = ControllerRelayLease::Managed {
3260 handle: handle.clone(),
3261 lease: Some(lease),
3262 };
3263 let barrier_command_id = new_command_id("checkpoint").unwrap();
3264 let connection = relay.connection_mut();
3265 connection
3266 .submit(
3267 barrier_command_id.clone(),
3268 RelayCommand::BeginCheckpoint { reason: None },
3269 )
3270 .await
3271 .unwrap();
3272 let barrier = wait_for_checkpoint_barrier(
3273 connection,
3274 LATCH_RELAY_SESSION,
3275 &barrier_command_id,
3276 CHECKPOINT_BARRIER_TIMEOUT,
3277 BarrierBusyPolicy::InterruptWhileRunning,
3278 )
3279 .await
3280 .unwrap();
3281 assert_eq!(
3282 barrier.materialized.applied_event_ordinal,
3283 barrier.operational.latest_ordinal
3284 );
3285 let cursor = barrier.operational.checkpoint_ready.clone().unwrap();
3286 (channels, handle, relay, barrier_command_id, cursor)
3287 }
3288
3289 #[cfg(unix)]
3294 #[tokio::test]
3295 async fn a_close_checkpoint_cancels_a_running_turn_without_restarting_the_worker() {
3296 if std::env::var_os(LATCH_TEST_CHILD).is_none() {
3299 let directory = tempfile::tempdir().unwrap();
3300 let test_name = format!(
3301 "{}::a_close_checkpoint_cancels_a_running_turn_without_restarting_the_worker",
3302 module_path!()
3303 .strip_prefix("mj_controller::")
3304 .unwrap_or(module_path!())
3305 );
3306 let output = Command::new(std::env::current_exe().unwrap())
3307 .args(["--exact", &test_name, "--nocapture"])
3308 .env(LATCH_TEST_CHILD, "1")
3309 .env("MJ_DATA_DIR", directory.path())
3310 .output()
3311 .unwrap();
3312 assert!(
3313 output.status.success(),
3314 "isolated cancellation checkpoint test failed\nstdout:\n{}\nstderr:\n{}",
3315 String::from_utf8_lossy(&output.stdout),
3316 String::from_utf8_lossy(&output.stderr)
3317 );
3318 return;
3319 }
3320 let _writer = hel::hel_database::install_isolated_test_writer();
3321 let relay_root = tempfile::tempdir().unwrap();
3322 let start_log_directory = tempfile::tempdir().unwrap();
3323 let start_log = start_log_directory.path().join("relay-starts");
3324 let (_channels, _handle, mut relay, _barrier_command_id, _cursor) =
3325 latch_a_live_checkpoint(
3326 relay_root.path(),
3327 Some(&start_log),
3328 ReleaseSupport::Supported,
3329 true,
3330 )
3331 .await;
3332 let snapshot = relay.sync_snapshot().await.unwrap();
3333 assert_eq!(
3334 snapshot.operational.execution,
3335 RelayExecutionState::Idle,
3336 "the close wait returned before the cancelled turn became idle"
3337 );
3338 assert!(
3339 snapshot.operational.active_prompt.is_none(),
3340 "the close wait returned before the cancelled prompt settled"
3341 );
3342 assert_eq!(
3343 relay_starts(&start_log),
3344 1,
3345 "responsive cancellation restarted worker"
3346 );
3347 }
3348 #[cfg(unix)]
3351 async fn wait_until_the_actor_serves_again(handle: &ManagedSessionHandle) {
3352 for attempt in 0.. {
3353 if handle.sync_now().await.is_ok() {
3354 return;
3355 }
3356 assert!(attempt < 200, "the actor never took its connection back");
3357 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
3358 }
3359 }
3360 #[cfg(unix)]
3364 #[tokio::test]
3365 async fn ending_the_checkpoint_latch_returns_the_connection_to_its_actor() {
3366 if std::env::var_os(LATCH_TEST_CHILD).is_none() {
3369 let directory = tempfile::tempdir().unwrap();
3370 let test_name = format!(
3371 "{}::ending_the_checkpoint_latch_returns_the_connection_to_its_actor",
3372 module_path!()
3373 .strip_prefix("mj_controller::")
3374 .unwrap_or(module_path!())
3375 );
3376 let output = Command::new(std::env::current_exe().unwrap())
3377 .args(["--exact", &test_name, "--nocapture"])
3378 .env(LATCH_TEST_CHILD, "1")
3379 .env("MJ_DATA_DIR", directory.path())
3380 .output()
3381 .unwrap();
3382 assert!(
3383 output.status.success(),
3384 "isolated checkpoint latch test failed\nstdout:\n{}\nstderr:\n{}",
3385 String::from_utf8_lossy(&output.stdout),
3386 String::from_utf8_lossy(&output.stderr)
3387 );
3388 return;
3389 }
3390 let _writer = hel::hel_database::install_isolated_test_writer();
3392
3393 std::thread::spawn(|| {
3396 std::thread::sleep(std::time::Duration::from_secs(120));
3397 eprintln!("the checkpoint latch never returned its connection");
3398 std::process::exit(101);
3399 });
3400
3401 let relay_root = tempfile::tempdir().unwrap();
3402 let (_channels, handle, mut relay, barrier_command_id, cursor) =
3403 latch_a_live_checkpoint(relay_root.path(), None, ReleaseSupport::Supported, false)
3404 .await;
3405
3406 assert!(
3409 handle.sync_now().await.is_err(),
3410 "a latched projection must not be advanced by its own actor"
3411 );
3412
3413 relay.end_latch();
3414 wait_until_the_actor_serves_again(&handle).await;
3415
3416 let latched = relay.sync_snapshot().await.unwrap();
3420 validate_checkpoint_barrier_snapshot(&latched, &barrier_command_id, &cursor).unwrap();
3421
3422 let prompt_ordinal = relay
3425 .submit(
3426 new_command_id("prompt").unwrap(),
3427 RelayCommand::Prompt {
3428 prompt: vec![ContentBlock::Text(TextContent::new("hello"))],
3429 },
3430 )
3431 .await
3432 .unwrap();
3433 assert!(prompt_ordinal > cursor.ordinal);
3434 let snapshot = relay.sync_snapshot().await.unwrap();
3435 assert!(snapshot.operational.latest_ordinal > cursor.ordinal);
3436 validate_checkpoint_barrier_snapshot(&snapshot, &barrier_command_id, &cursor).unwrap();
3437
3438 latched_checkpoint(
3439 relay,
3440 barrier_command_id,
3441 cursor,
3442 CheckpointCompletion::HeldBarrier,
3443 )
3444 .complete()
3445 .await
3446 .unwrap();
3447 handle.sync_now().await.unwrap();
3448 assert_eq!(
3449 handle
3450 .view()
3451 .snapshot
3452 .expect("the actor published the completed barrier")
3453 .operational
3454 .checkpoint_barrier,
3455 None
3456 );
3457 }
3458 #[cfg(unix)]
3462 #[tokio::test]
3463 async fn releasing_a_checkpoint_after_capture_defers_only_the_recovery_floor() {
3464 if std::env::var_os(RELEASE_TEST_CHILD).is_none() {
3467 let directory = tempfile::tempdir().unwrap();
3468 let test_name = format!(
3469 "{}::releasing_a_checkpoint_after_capture_defers_only_the_recovery_floor",
3470 module_path!()
3471 .strip_prefix("mj_controller::")
3472 .unwrap_or(module_path!())
3473 );
3474 let output = Command::new(std::env::current_exe().unwrap())
3475 .args(["--exact", &test_name, "--nocapture"])
3476 .env(RELEASE_TEST_CHILD, "1")
3477 .env("MJ_DATA_DIR", directory.path())
3478 .output()
3479 .unwrap();
3480 assert!(
3481 output.status.success(),
3482 "isolated checkpoint release test failed\nstdout:\n{}\nstderr:\n{}",
3483 String::from_utf8_lossy(&output.stdout),
3484 String::from_utf8_lossy(&output.stderr)
3485 );
3486 return;
3487 }
3488 let _writer = hel::hel_database::install_isolated_test_writer();
3490
3491 std::thread::spawn(|| {
3494 std::thread::sleep(std::time::Duration::from_secs(120));
3495 eprintln!("the captured checkpoint never released its barrier");
3496 std::process::exit(101);
3497 });
3498
3499 let relay_root = tempfile::tempdir().unwrap();
3500 let (_channels, handle, mut relay, barrier_command_id, cursor) =
3501 latch_a_live_checkpoint(relay_root.path(), None, ReleaseSupport::Supported, false)
3502 .await;
3503 relay.end_latch();
3504 wait_until_the_actor_serves_again(&handle).await;
3505
3506 let completion = release_checkpoint_after_capture(
3509 &mut relay,
3510 LATCH_RELAY_SESSION,
3511 &barrier_command_id,
3512 &cursor,
3513 )
3514 .await
3515 .unwrap();
3516 assert_eq!(completion, CheckpointCompletion::ReleasedAfterCapture);
3517 let released = relay.sync_snapshot().await.unwrap();
3518 assert_eq!(released.operational.checkpoint_barrier, None);
3519 assert_eq!(released.operational.checkpoint_ready, None);
3520 assert_eq!(
3521 released.operational.recovery_floor_ordinal, 0,
3522 "an exported archive that is not installed may not release journal history"
3523 );
3524
3525 relay
3528 .submit(
3529 new_command_id("prompt").unwrap(),
3530 RelayCommand::Prompt {
3531 prompt: vec![ContentBlock::Text(TextContent::new("during transfer"))],
3532 },
3533 )
3534 .await
3535 .unwrap();
3536 let mut dispatched = None;
3537 for attempt in 0.. {
3538 let snapshot = relay.sync_snapshot().await.unwrap();
3539 if let Some(active) = snapshot.operational.active_prompt {
3540 dispatched = Some(active);
3541 break;
3542 }
3543 assert!(attempt < 200, "a released barrier still froze ACP dispatch");
3544 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
3545 }
3546 assert!(dispatched.is_some());
3547
3548 latched_checkpoint(
3551 relay,
3552 barrier_command_id,
3553 cursor.clone(),
3554 CheckpointCompletion::ReleasedAfterCapture,
3555 )
3556 .complete()
3557 .await
3558 .unwrap();
3559 handle.sync_now().await.unwrap();
3560 let installed = handle
3561 .view()
3562 .snapshot
3563 .expect("the actor published the advanced recovery floor");
3564 assert_eq!(installed.operational.recovery_floor_ordinal, cursor.ordinal);
3565 assert_eq!(installed.operational.recovery_floor_digest, cursor.digest);
3566 }
3567 #[cfg(unix)]
3570 #[tokio::test]
3571 async fn a_worker_that_rejects_the_release_keeps_its_barrier_through_the_transfer() {
3572 if std::env::var_os(LEGACY_RELEASE_TEST_CHILD).is_none() {
3575 let directory = tempfile::tempdir().unwrap();
3576 let test_name = format!(
3577 "{}::a_worker_that_rejects_the_release_keeps_its_barrier_through_the_transfer",
3578 module_path!()
3579 .strip_prefix("mj_controller::")
3580 .unwrap_or(module_path!())
3581 );
3582 let output = Command::new(std::env::current_exe().unwrap())
3583 .args(["--exact", &test_name, "--nocapture"])
3584 .env(LEGACY_RELEASE_TEST_CHILD, "1")
3585 .env("MJ_DATA_DIR", directory.path())
3586 .output()
3587 .unwrap();
3588 assert!(
3589 output.status.success(),
3590 "isolated legacy checkpoint release test failed\nstdout:\n{}\nstderr:\n{}",
3591 String::from_utf8_lossy(&output.stdout),
3592 String::from_utf8_lossy(&output.stderr)
3593 );
3594 return;
3595 }
3596 let _writer = hel::hel_database::install_isolated_test_writer();
3598
3599 std::thread::spawn(|| {
3602 std::thread::sleep(std::time::Duration::from_secs(120));
3603 eprintln!("the rejected release never finished its checkpoint");
3604 std::process::exit(101);
3605 });
3606
3607 let relay_root = tempfile::tempdir().unwrap();
3608 let start_log = tempfile::tempdir().unwrap();
3609 let start_log = start_log.path().join("relay-starts");
3610 let (_channels, handle, mut relay, barrier_command_id, cursor) = latch_a_live_checkpoint(
3611 relay_root.path(),
3612 Some(&start_log),
3613 ReleaseSupport::Rejected,
3614 false,
3615 )
3616 .await;
3617 relay.end_latch();
3618 wait_until_the_actor_serves_again(&handle).await;
3619
3620 let completion = release_checkpoint_after_capture(
3621 &mut relay,
3622 LATCH_RELAY_SESSION,
3623 &barrier_command_id,
3624 &cursor,
3625 )
3626 .await
3627 .unwrap();
3628 assert_eq!(completion, CheckpointCompletion::HeldBarrier);
3629 assert_eq!(relay_starts(&start_log), 1);
3632
3633 let transferring = relay.sync_snapshot().await.unwrap();
3637 validate_checkpoint_barrier_snapshot(&transferring, &barrier_command_id, &cursor).unwrap();
3638 latched_checkpoint(relay, barrier_command_id, cursor.clone(), completion)
3639 .complete()
3640 .await
3641 .unwrap();
3642 handle.sync_now().await.unwrap();
3643 let completed = handle
3644 .view()
3645 .snapshot
3646 .expect("the actor published the completed barrier");
3647 assert_eq!(completed.operational.checkpoint_barrier, None);
3648 assert_eq!(completed.operational.recovery_floor_ordinal, cursor.ordinal);
3649 }
3650 #[cfg(unix)]
3655 #[tokio::test]
3656 async fn abandoning_a_latched_checkpoint_drops_the_connection_that_opened_its_barrier() {
3657 if std::env::var_os(ABANDON_TEST_CHILD).is_none() {
3660 let directory = tempfile::tempdir().unwrap();
3661 let test_name = format!(
3662 "{}::abandoning_a_latched_checkpoint_drops_the_connection_that_opened_its_barrier",
3663 module_path!()
3664 .strip_prefix("mj_controller::")
3665 .unwrap_or(module_path!())
3666 );
3667 let output = Command::new(std::env::current_exe().unwrap())
3668 .args(["--exact", &test_name, "--nocapture"])
3669 .env(ABANDON_TEST_CHILD, "1")
3670 .env("MJ_DATA_DIR", directory.path())
3671 .output()
3672 .unwrap();
3673 assert!(
3674 output.status.success(),
3675 "isolated abandoned checkpoint test failed\nstdout:\n{}\nstderr:\n{}",
3676 String::from_utf8_lossy(&output.stdout),
3677 String::from_utf8_lossy(&output.stderr)
3678 );
3679 return;
3680 }
3681 let _writer = hel::hel_database::install_isolated_test_writer();
3683
3684 std::thread::spawn(|| {
3687 std::thread::sleep(std::time::Duration::from_secs(120));
3688 eprintln!("an abandoned checkpoint never released its relay connection");
3689 std::process::exit(101);
3690 });
3691
3692 let relay_root = tempfile::tempdir().unwrap();
3693 let start_log = tempfile::tempdir().unwrap();
3694 let start_log = start_log.path().join("relay-starts");
3695 let (_channels, handle, mut relay, barrier_command_id, cursor) = latch_a_live_checkpoint(
3696 relay_root.path(),
3697 Some(&start_log),
3698 ReleaseSupport::Supported,
3699 false,
3700 )
3701 .await;
3702 relay.end_latch();
3703 wait_until_the_actor_serves_again(&handle).await;
3704 assert_eq!(relay_starts(&start_log), 1);
3705
3706 latched_checkpoint(
3707 relay,
3708 barrier_command_id,
3709 cursor,
3710 CheckpointCompletion::HeldBarrier,
3711 )
3712 .abandon(LATCH_RELAY_SESSION)
3713 .await;
3714
3715 wait_until_the_actor_serves_again(&handle).await;
3720 assert_eq!(relay_starts(&start_log), 2);
3721 }
3722 #[cfg(unix)]
3727 #[tokio::test]
3728 async fn a_close_latch_reuses_an_unchanged_archive_and_exports_after_new_content() {
3729 if std::env::var_os(REUSE_TEST_CHILD).is_none() {
3732 let directory = tempfile::tempdir().unwrap();
3733 let test_name = format!(
3734 "{}::a_close_latch_reuses_an_unchanged_archive_and_exports_after_new_content",
3735 module_path!()
3736 .strip_prefix("mj_controller::")
3737 .unwrap_or(module_path!())
3738 );
3739 let output = Command::new(std::env::current_exe().unwrap())
3740 .args(["--exact", &test_name, "--nocapture"])
3741 .env(REUSE_TEST_CHILD, "1")
3742 .env(LATCH_RELAY_STARTUP_DELAY_MS, "31000")
3745 .env("MJ_DATA_DIR", directory.path())
3746 .output()
3747 .unwrap();
3748 assert!(
3749 output.status.success(),
3750 "isolated checkpoint reuse test failed\nstdout:\n{}\nstderr:\n{}",
3751 String::from_utf8_lossy(&output.stdout),
3752 String::from_utf8_lossy(&output.stderr)
3753 );
3754 return;
3755 }
3756 let _writer = hel::hel_database::install_isolated_test_writer();
3758
3759 std::thread::spawn(|| {
3762 std::thread::sleep(std::time::Duration::from_secs(120));
3763 eprintln!("the reuse checkpoint never finished its latch");
3764 std::process::exit(101);
3765 });
3766
3767 #[derive(Default)]
3768 struct RecordingExecutor {
3769 purposes: std::sync::Mutex<Vec<String>>,
3770 active_stages: std::sync::Mutex<Vec<ProvisionStage>>,
3771 stage_events: std::sync::Mutex<Vec<(ProvisionStage, bool)>>,
3772 observed_stages: std::sync::Mutex<Vec<(String, Vec<ProvisionStage>)>>,
3773 }
3774
3775 impl RecordingExecutor {
3776 fn refused(&self, command: &CommandSpec) -> Result<CommandOutput> {
3777 self.purposes.lock().unwrap().push(command.purpose.clone());
3778 self.observed_stages.lock().unwrap().push((
3779 command.purpose.clone(),
3780 self.active_stages.lock().unwrap().clone(),
3781 ));
3782 Ok(CommandOutput {
3783 status: 1,
3784 stdout: Vec::new(),
3785 stderr: b"no target is provisioned for this test".to_vec(),
3786 })
3787 }
3788
3789 fn purposes(&self) -> Vec<String> {
3790 self.purposes.lock().unwrap().clone()
3791 }
3792
3793 fn observed_stages(&self) -> Vec<(String, Vec<ProvisionStage>)> {
3794 self.observed_stages.lock().unwrap().clone()
3795 }
3796
3797 fn stage_events(&self) -> Vec<(ProvisionStage, bool)> {
3798 self.stage_events.lock().unwrap().clone()
3799 }
3800 }
3801
3802 impl CommandExecutor for RecordingExecutor {
3803 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
3804 self.refused(command)
3805 }
3806
3807 fn execute_with_stdin(
3808 &self,
3809 command: &CommandSpec,
3810 _input: &mut (dyn std::io::Read + Send),
3811 ) -> Result<CommandOutput> {
3812 self.refused(command)
3813 }
3814
3815 fn stage_started(&self, stage: ProvisionStage) {
3816 self.active_stages.lock().unwrap().push(stage);
3817 self.stage_events.lock().unwrap().push((stage, true));
3818 }
3819
3820 fn stage_finished(&self, stage: ProvisionStage) {
3821 let mut active = self.active_stages.lock().unwrap();
3822 let position = active
3823 .iter()
3824 .position(|active_stage| *active_stage == stage)
3825 .expect("stage finished without a matching start");
3826 active.remove(position);
3827 self.stage_events.lock().unwrap().push((stage, false));
3828 }
3829 }
3830
3831 let data_directory = PathBuf::from(std::env::var_os("MJ_DATA_DIR").unwrap());
3832 let relay_root = data_directory.join("relay");
3833 let profile_home = data_directory.join("profile");
3834 let archive_directory = data_directory.join("archives");
3835 for directory in [&relay_root, &profile_home, &archive_directory] {
3836 std::fs::create_dir_all(directory).unwrap();
3837 }
3838 let checkpoint = write_checkpoint_gate_archive(&archive_directory, LATCH_RELAY_SESSION, 2);
3841
3842 let mut session = checkpoint_test_session(LATCH_RELAY_SESSION);
3843 session.target_template_id = "local".into();
3844 session.target = Some(TargetLocator::LocalBare {
3845 worker_root: data_directory.join("workers").join(LATCH_RELAY_SESSION),
3846 });
3847 session.checkpoint = Some(checkpoint.clone());
3848 hel::hel_database::save_session(&session).unwrap();
3849
3850 let mut config = HelConfig::default();
3851 config.profiles.insert(
3852 "codex".into(),
3853 HarnessProfile {
3854 kind: hel::hel_config::HarnessKind::Codex,
3855 home: profile_home,
3856 environment: BTreeMap::new(),
3857 context_window_bytes: None,
3858 },
3859 );
3860 config
3861 .targets
3862 .insert("local".into(), TargetTemplate::LocalBare);
3863 config.bundles.insert(
3864 "project".into(),
3865 ProjectBundle {
3866 primary_repo: "project".into(),
3867 repositories: vec![ProjectRepository {
3868 id: "project".into(),
3869 github: Some("example/project".into()),
3870 local: None,
3871 destination: "project".into(),
3872 git_ref: None,
3873 }],
3874 },
3875 );
3876 let controller = Controller {
3877 config,
3878 state: HelState {
3879 sessions: BTreeMap::from([(LATCH_RELAY_SESSION.into(), session)]),
3880 ..HelState::default()
3881 },
3882 };
3883
3884 let channels = crate::hel_session_manager::spawn_session_manager().unwrap();
3885 channels
3886 .targets
3887 .send(vec![latch_relay_target(
3888 &relay_root,
3889 None,
3890 ReleaseSupport::Supported,
3891 false,
3892 )])
3893 .unwrap();
3894 let handle = channels
3895 .control
3896 .wait_for_session(LATCH_RELAY_SESSION, Duration::from_secs(10))
3897 .await
3898 .unwrap();
3899
3900 let executor = RecordingExecutor::default();
3901 let latched = controller
3902 .checkpoint_session_latched(
3903 LATCH_RELAY_SESSION,
3904 &executor,
3905 Some(&channels.control),
3906 LatchExclusivity::HoldThroughClose,
3907 CheckpointExportPolicy::ReuseUnchangedArchive,
3908 )
3909 .await
3910 .unwrap();
3911
3912 assert!(
3913 executor.purposes().is_empty(),
3914 "an unchanged session exported an archive anyway: {:?}",
3915 executor.purposes()
3916 );
3917 assert_eq!(latched.artifact.metadata, checkpoint);
3918 assert!(checkpoint.archive_path.exists());
3919
3920 assert!(latched.cursor.ordinal > checkpoint.event_frontier);
3923 let cursor = latched.cursor.clone();
3924 latched.complete().await.unwrap();
3925 wait_until_the_actor_serves_again(&handle).await;
3926
3927 handle
3931 .submit(
3932 new_command_id("busy-prompt").unwrap(),
3933 RelayCommand::Prompt {
3934 prompt: vec![ContentBlock::Text(TextContent::new("keep working"))],
3935 },
3936 )
3937 .await
3938 .unwrap();
3939 let mut connection = handle.lease_connection().await.unwrap();
3940 let before = connection.connection_mut().sync().await.unwrap();
3941 assert_eq!(before.operational.execution, RelayExecutionState::Running);
3942 connection.release();
3943 let deferred = controller
3944 .checkpoint_session_latched(
3945 LATCH_RELAY_SESSION,
3946 &executor,
3947 Some(&channels.control),
3948 LatchExclusivity::ReleaseAfterLatch,
3949 CheckpointExportPolicy::ReuseUnchangedArchive,
3950 )
3951 .await;
3952 assert!(
3953 matches!(deferred, Err(ref error) if error.downcast_ref::<CheckpointDeferred>().is_some())
3954 );
3955 wait_until_the_actor_serves_again(&handle).await;
3956 let mut connection = handle.lease_connection().await.unwrap();
3957 let after = connection.connection_mut().sync().await.unwrap();
3958 assert_eq!(after.operational.execution, RelayExecutionState::Running);
3959 assert!(after.operational.checkpoint_barrier.is_none());
3960 let journal =
3961 std::fs::read_to_string(relay_root.join("relay-journal/active.jsonl")).unwrap();
3962 for line in journal.lines() {
3963 let event: hel::hel_worker::RelayEvent = serde_json::from_str(line).unwrap();
3964 if event.ordinal > before.operational.latest_ordinal {
3965 assert!(
3966 !matches!(
3967 event.observation,
3968 hel::hel_worker::RelayObservation::CommandQueued {
3969 command: RelayCommand::BeginCheckpoint { .. },
3970 ..
3971 } | hel::hel_worker::RelayObservation::CommandInterrupted {
3972 command: hel::hel_worker::RelayCommandKind::BeginCheckpoint,
3973 ..
3974 }
3975 ),
3976 "busy deferral journaled checkpoint activity: {event:?}"
3977 );
3978 }
3979 }
3980 connection.release();
3981 handle
3982 .submit(
3983 new_command_id("finish-busy-prompt").unwrap(),
3984 RelayCommand::CancelTurn,
3985 )
3986 .await
3987 .unwrap();
3988 handle.sync_now().await.unwrap();
3989
3990 handle
3992 .submit(
3993 new_command_id("resume-notice").unwrap(),
3994 RelayCommand::RecordNotice {
3995 text: "the session changed".into(),
3996 },
3997 )
3998 .await
3999 .unwrap();
4000 for attempt in 0.. {
4001 handle.sync_now().await.unwrap();
4002 let materialized = handle.view().snapshot.map(|snapshot| snapshot.materialized);
4003 if materialized.is_some_and(|materialized| {
4004 materialized.applied_event_ordinal > cursor.ordinal
4005 && !materialized.transcript.is_empty()
4006 }) {
4007 break;
4008 }
4009 assert!(attempt < 200, "the notice never reached the projection");
4010 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
4011 }
4012
4013 let changed = controller
4014 .checkpoint_session_latched(
4015 LATCH_RELAY_SESSION,
4016 &executor,
4017 Some(&channels.control),
4018 LatchExclusivity::HoldThroughClose,
4019 CheckpointExportPolicy::ReuseUnchangedArchive,
4020 )
4021 .await;
4022 let Err(error) = changed else {
4023 panic!("a changed session reused its installed archive");
4024 };
4025
4026 assert!(
4027 executor
4028 .purposes()
4029 .contains(&"export target checkpoint".to_owned()),
4030 "a changed session skipped its export: {:?}",
4031 executor.purposes()
4032 );
4033 assert!(
4034 format!("{error:#}").contains("no target is provisioned for this test"),
4035 "{error:#}"
4036 );
4037 assert!(
4038 executor.observed_stages().iter().any(|(purpose, stages)| {
4039 purpose == "export target checkpoint"
4040 && stages.contains(&ProvisionStage::RecoveryCopy)
4041 }),
4042 "close checkpoint export did not run inside RecoveryCopy: {:?}",
4043 executor.observed_stages()
4044 );
4045 assert_eq!(
4046 executor
4047 .stage_events()
4048 .into_iter()
4049 .filter(|(stage, _)| *stage == ProvisionStage::RecoveryCopy)
4050 .collect::<Vec<_>>(),
4051 vec![
4052 (ProvisionStage::RecoveryCopy, true),
4053 (ProvisionStage::RecoveryCopy, false)
4054 ]
4055 );
4056 assert!(executor.active_stages.lock().unwrap().is_empty());
4057 assert!(checkpoint.archive_path.exists());
4058 }
4059 #[cfg(unix)]
4060 fn relay_starts(path: &Path) -> usize {
4061 std::fs::read_to_string(path)
4062 .unwrap_or_default()
4063 .lines()
4064 .count()
4065 }
4066 #[cfg(unix)]
4069 fn latched_checkpoint(
4070 relay: ControllerRelayLease,
4071 barrier_command_id: String,
4072 cursor: RelayCursor,
4073 completion: CheckpointCompletion,
4074 ) -> LatchedCheckpoint {
4075 LatchedCheckpoint {
4076 artifact: CheckpointArtifact {
4077 metadata: CheckpointMetadata {
4078 archive_path: PathBuf::from("checkpoint.hel.zip"),
4079 sha256: "a".repeat(64),
4080 created_at: now(),
4081 event_frontier: cursor.ordinal,
4082 },
4083 native_session_id: "native-session".into(),
4084 event_frontier_digest: cursor.digest.clone(),
4085 },
4086 relay,
4087 barrier_command_id,
4088 cursor,
4089 completion,
4090 }
4091 }
4092 #[test]
4093 fn checkpoint_persistence_rollback_restores_memory_and_reports_both_failures() {
4094 let session_id = "0123456789abcdef0123456789abcdef";
4095 let previous = checkpoint_test_session(session_id);
4096 let mut changed = previous.clone();
4097 changed.state = SessionState::Closing;
4098 changed.last_checkpoint_error = Some("partially installed checkpoint".into());
4099 let mut state = HelState::default();
4100 state.sessions.insert(session_id.into(), changed);
4101
4102 let error = restore_session_after_persistence_failure(
4103 &mut state,
4104 session_id,
4105 &previous,
4106 anyhow::anyhow!("verified checkpoint persistence failed"),
4107 |record| {
4108 assert_eq!(record, &previous);
4109 Err(anyhow::anyhow!("rollback database write failed"))
4110 },
4111 );
4112
4113 assert_eq!(state.sessions.get(session_id), Some(&previous));
4114 let detail = format!("{error:#}");
4115 assert!(detail.contains("verified checkpoint persistence failed"));
4116 assert!(detail.contains("rollback database write failed"));
4117 }
4118 #[test]
4119 fn installed_checkpoint_gate_reopens_and_checks_sha() {
4120 let directory = tempfile::tempdir().unwrap();
4121 let session_id = "0123456789abcdef0123456789abcdef";
4122 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
4123 verify_installed_checkpoint_gate(session_id, &checkpoint).unwrap();
4124
4125 let mut wrong_sha = checkpoint.clone();
4126 wrong_sha.sha256 = "b".repeat(64);
4127 assert!(
4128 verify_installed_checkpoint_gate(session_id, &wrong_sha)
4129 .unwrap_err()
4130 .to_string()
4131 .contains("SHA changed")
4132 );
4133 std::fs::write(
4134 &checkpoint.archive_path,
4135 b"changed after first verification",
4136 )
4137 .unwrap();
4138 assert!(
4139 format!(
4140 "{:#}",
4141 verify_installed_checkpoint_gate(session_id, &checkpoint).unwrap_err()
4142 )
4143 .contains("installed checkpoint SHA changed")
4144 );
4145 }
4146 #[test]
4147 fn an_installed_archive_is_reused_when_only_relay_bookkeeping_moved() {
4148 let directory = tempfile::tempdir().unwrap();
4149 let session_id = "0123456789abcdef0123456789abcdef";
4150 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
4151 let archived = verify_archive_streaming(&checkpoint.archive_path)
4152 .unwrap()
4153 .canonical_session;
4154
4155 let mut latched = archived.clone();
4158 latched.event_frontier += 6;
4159 latched.event_frontier_digest = "b".repeat(64);
4160 latched.session.last_activity_at_ms = Some(9_999);
4161
4162 let artifact = reusable_installed_checkpoint(
4163 session_id,
4164 Some(&checkpoint),
4165 "native-session",
4166 latched.event_frontier,
4167 &latched,
4168 )
4169 .expect("an unchanged session reuses its installed archive");
4170
4171 assert_eq!(artifact.metadata, checkpoint);
4172 assert_eq!(artifact.native_session_id, "native-session");
4173 assert_eq!(
4174 artifact.event_frontier_digest,
4175 archived.event_frontier_digest
4176 );
4177 verify_checkpoint_artifact(session_id, &artifact).unwrap();
4179 verify_installed_checkpoint_gate(session_id, &artifact.metadata).unwrap();
4180 }
4181 #[test]
4182 fn archive_reuse_falls_back_to_a_full_export_for_anything_but_bookkeeping() {
4183 let directory = tempfile::tempdir().unwrap();
4184 let session_id = "0123456789abcdef0123456789abcdef";
4185 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
4186 let archived = verify_archive_streaming(&checkpoint.archive_path)
4187 .unwrap()
4188 .canonical_session;
4189 let mut latched = archived.clone();
4190 latched.event_frontier += 6;
4191 let reuse = |installed: Option<&CheckpointMetadata>,
4192 ordinal: u64,
4193 session: &CanonicalSessionSnapshot| {
4194 reusable_installed_checkpoint(session_id, installed, "native-session", ordinal, session)
4195 };
4196
4197 assert!(reuse(None, latched.event_frontier, &latched).is_none());
4198
4199 let mut with_new_content = latched.clone();
4200 with_new_content.transcript.push(CanonicalTranscriptItem {
4201 stable_id: "system:notice:notice-1".into(),
4202 position: latched.event_frontier,
4203 latest_content_event_ordinal: None,
4204 created_at_ms: 2_000,
4205 last_changed_at_ms: 2_000,
4206 body: CanonicalTranscriptBody::System {
4207 text: "resumed".into(),
4208 },
4209 });
4210 assert!(reuse(Some(&checkpoint), latched.event_frontier, &with_new_content).is_none());
4211
4212 assert!(reuse(Some(&checkpoint), checkpoint.event_frontier - 1, &latched).is_none());
4214
4215 let mut wrong_sha = checkpoint.clone();
4216 wrong_sha.sha256 = "b".repeat(64);
4217 assert!(reuse(Some(&wrong_sha), latched.event_frontier, &latched).is_none());
4218
4219 let another_session =
4220 write_checkpoint_gate_archive(directory.path(), "1123456789abcdef0123456789abcdef", 7);
4221 assert!(reuse(Some(&another_session), latched.event_frontier, &latched).is_none());
4222
4223 std::fs::write(&checkpoint.archive_path, b"not an archive any more").unwrap();
4224 assert!(reuse(Some(&checkpoint), latched.event_frontier, &latched).is_none());
4225 }
4226}