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