1use std::time::Duration;
4
5use anyhow::{Context, Result, bail, ensure};
6
7use crate::hel_session_manager::{SessionManagerControl, new_command_id};
8use hel::hel_state::{CheckpointMetadata, SessionRecord, SessionState};
9use hel::hel_targets::{
10 self, CommandExecutor, ProcessExecutor, ProvisionStage, ProvisionStageGuard,
11};
12use hel::hel_worker::{RelayCommand, RelayExecutionState};
13
14use super::backend::backend_locator;
15use super::checkpoint::{
16 CheckpointExportPolicy, LatchExclusivity, prune_replaced_checkpoint,
17 release_projection_behind_checkpoint, verify_installed_checkpoint_gate, wait_for_relay_closed,
18};
19use super::worktree::{cleanup_managed_worktree, retire_managed_worktree};
20use super::{Controller, now, persist_session_record_transition_or_restore};
21
22impl Controller {
23 pub async fn close_session(&mut self, session_id: &str) -> Result<()> {
26 self.close_session_controlled(session_id, &ProcessExecutor)
27 .await
28 }
29
30 pub async fn close_session_controlled(
31 &mut self,
32 session_id: &str,
33 executor: &(impl CommandExecutor + Sync),
34 ) -> Result<()> {
35 if self
36 .close_session_controlled_with_manager(session_id, executor, None, None)
37 .await?
38 {
39 self.cleanup_stopped_target(session_id, executor)?;
40 }
41 Ok(())
42 }
43
44 pub async fn close_session_managed_controlled(
45 &mut self,
46 session_id: &str,
47 executor: &(impl CommandExecutor + Sync),
48 manager: &SessionManagerControl,
49 ) -> Result<bool> {
50 self.close_session_controlled_with_manager(session_id, executor, Some(manager), None)
51 .await
52 }
53
54 pub(super) async fn close_session_for_move(
55 &mut self,
56 session_id: &str,
57 executor: &(impl CommandExecutor + Sync),
58 manager: &SessionManagerControl,
59 operation: &mut hel::hel_state::MoveOperation,
60 preparation: Option<&hel::hel_state::MovePreparation>,
61 ) -> Result<bool> {
62 self.close_session_controlled_with_manager(
63 session_id,
64 executor,
65 Some(manager),
66 Some((operation, preparation)),
67 )
68 .await
69 }
70
71 async fn close_session_controlled_with_manager(
72 &mut self,
73 session_id: &str,
74 executor: &(impl CommandExecutor + Sync),
75 manager: Option<&SessionManagerControl>,
76 move_intent: Option<(
77 &mut hel::hel_state::MoveOperation,
78 Option<&hel::hel_state::MovePreparation>,
79 )>,
80 ) -> Result<bool> {
81 let previous = self
82 .state
83 .sessions
84 .get(session_id)
85 .with_context(|| format!("unknown session {session_id}"))?
86 .clone();
87 let record = self.state.sessions.get_mut(session_id).unwrap();
88 apply_close_checkpoint_started(record, now());
92 self.persist_session_transition_or_restore(
93 session_id,
94 &previous,
95 "persist closing state before checkpointing the session",
96 )?;
97
98 let mut latched = match self
101 .checkpoint_session_latched(
102 session_id,
103 executor,
104 manager,
105 LatchExclusivity::HoldThroughClose,
106 CheckpointExportPolicy::ReuseUnchangedArchive,
107 )
108 .await
109 {
110 Ok(latched) => latched,
111 Err(error) => {
112 let record = self.state.sessions.get_mut(session_id).unwrap();
113 record.state = previous.state;
114 record.updated_at = now();
115 record.last_checkpoint_error = Some(format!("{error:#}"));
116 return Err(
117 self.persist_failed_checkpoint_state_or_restore(session_id, &previous, error)
118 );
119 }
120 };
121
122 let artifact = latched.artifact.clone();
123 let record = self.state.sessions.get_mut(session_id).unwrap();
124 record.state = SessionState::Closing;
125 record.native_session_id = Some(artifact.native_session_id.clone());
126 record.checkpoint = Some(artifact.metadata.clone());
127 record.updated_at = now();
128 record.last_error = None;
129 record.last_checkpoint_error = None;
130 self.persist_checkpoint_transition_or_restore(
131 session_id,
132 &previous,
133 "persist verified checkpoint and closing state before sealing the relay",
134 )?;
135 if let Some((operation, preparation)) = move_intent {
136 if let Err(error) = self.validate_move_checkpoint(operation, preparation, executor) {
139 let record = self.state.sessions.get_mut(session_id).unwrap();
140 record.state = previous.state;
141 record.last_error = Some(format!("{error:#}"));
142 self.persist_session_transition_or_restore(
143 session_id,
144 &previous,
145 "restore source after move preflight failure",
146 )?;
147 return Err(error);
148 }
149 operation.checkpoint = Some(artifact.metadata.clone());
150 operation.updated_at = now();
151 hel::hel_database::save_move_operation(operation)?;
152 }
153 prune_replaced_checkpoint(previous.checkpoint.as_ref(), &artifact.metadata);
154 release_projection_behind_checkpoint(session_id, &artifact.metadata);
157
158 let close_command_id = new_command_id("close")?;
159 let barrier_command_id = latched.barrier_command_id.clone();
160 let close_result = {
161 let _closing = ProvisionStageGuard::new(executor, ProvisionStage::Closing);
162 latched
163 .relay
164 .connection_mut()
165 .submit(
166 close_command_id,
167 RelayCommand::Close {
168 barrier_command_id: barrier_command_id.clone(),
169 expected: latched.cursor.clone(),
170 },
171 )
172 .await
173 };
174 if let Err(error) = close_result {
175 self.record_interrupted_close(session_id, &error)?;
176 return Err(error.context("seal verified checkpoint for close"));
177 }
178 let close_result = {
179 let _closing = ProvisionStageGuard::new(executor, ProvisionStage::Closing);
180 latched
181 .relay
182 .connection_mut()
183 .submit(
184 new_command_id("checkpoint-complete")?,
185 RelayCommand::CompleteCheckpoint { barrier_command_id },
186 )
187 .await
188 };
189 if let Err(error) = close_result {
190 self.record_interrupted_close(session_id, &error)?;
191 return Err(error.context("release verified close checkpoint"));
192 }
193 let close_result = {
194 let _closing = ProvisionStageGuard::new(executor, ProvisionStage::Closing);
195 wait_for_relay_closed(latched.relay.connection_mut()).await
196 };
197 if let Err(error) = close_result {
198 self.record_interrupted_close(session_id, &error)?;
199 return Err(error);
200 }
201 latched.relay.release();
202
203 match self.destroy_after_verified_checkpoint(session_id, &artifact.metadata, executor) {
204 Ok(deferred) => Ok(deferred),
205 Err(error) => {
206 self.record_interrupted_close(session_id, &error)?;
207 Err(error)
208 }
209 }
210 }
211
212 pub async fn recover_interrupted_close_managed(
218 &mut self,
219 session_id: &str,
220 executor: &(impl CommandExecutor + Sync),
221 manager: &SessionManagerControl,
222 ) -> Result<bool> {
223 let (state, verified) = {
224 let session = self
225 .state
226 .sessions
227 .get(session_id)
228 .with_context(|| format!("unknown session {session_id}"))?;
229 ensure!(
230 matches!(
231 session.state,
232 SessionState::Closing | SessionState::Destroying
233 ),
234 "session {session_id} has no interrupted close to recover"
235 );
236 (session.state, session.checkpoint.clone())
237 };
238 if state == SessionState::Destroying {
239 let verified = verified.context("destroying session has no verified checkpoint")?;
240 return self.destroy_after_verified_checkpoint(session_id, &verified, executor);
241 }
242 ensure!(
243 state == SessionState::Closing,
244 "session {session_id} has no relay close to recover"
245 );
246 let handle = manager
247 .wait_for_session(session_id, Duration::from_secs(5))
248 .await?;
249 let mut lease = handle.lease_connection().await?;
250 let execution = lease.connection_mut().sync().await?.operational.execution;
251 match execution {
252 RelayExecutionState::Closed => {}
253 RelayExecutionState::Closing => {
254 let _closing = ProvisionStageGuard::new(executor, ProvisionStage::Closing);
255 wait_for_relay_closed(lease.connection_mut()).await?;
256 }
257 RelayExecutionState::Idle | RelayExecutionState::Running => {
258 lease.release();
259 return self
260 .close_session_controlled_with_manager(
261 session_id,
262 executor,
263 Some(manager),
264 None,
265 )
266 .await;
267 }
268 }
269 lease.release();
270 let verified = verified.context("closed relay has no verified checkpoint")?;
271 self.destroy_after_verified_checkpoint(session_id, &verified, executor)
272 }
273
274 fn record_interrupted_close(&mut self, session_id: &str, error: &anyhow::Error) -> Result<()> {
275 let record = self.state.sessions.get_mut(session_id).unwrap();
276 apply_interrupted_close_error(record, error, &now());
277 self.persist_session_state(session_id)
278 }
279
280 fn destroy_after_verified_checkpoint(
283 &mut self,
284 session_id: &str,
285 verified: &CheckpointMetadata,
286 executor: &impl CommandExecutor,
287 ) -> Result<bool> {
288 self.destroy_after_verified_checkpoint_with(
289 session_id,
290 verified,
291 executor,
292 hel::hel_database::save_lifecycle_session,
293 )
294 }
295
296 fn destroy_after_verified_checkpoint_with(
297 &mut self,
298 session_id: &str,
299 verified: &CheckpointMetadata,
300 executor: &impl CommandExecutor,
301 persist: impl Fn(&SessionRecord) -> Result<()>,
302 ) -> Result<bool> {
303 let session = self
304 .state
305 .sessions
306 .get(session_id)
307 .with_context(|| format!("unknown session {session_id}"))?
308 .clone();
309 ensure!(
310 matches!(
311 session.state,
312 SessionState::Closing | SessionState::Destroying
313 ),
314 "refusing to destroy session {session_id}: it is not closing or destroying"
315 );
316 ensure!(
317 session.checkpoint.as_ref() == Some(verified),
318 "refusing to destroy session {session_id}: verified checkpoint gate is stale"
319 );
320 if session.state == SessionState::Closing {
321 let record = self.state.sessions.get_mut(session_id).unwrap();
322 record.state = SessionState::Destroying;
323 record.updated_at = now();
324 record.last_error = None;
325 persist_session_record_transition_or_restore(
326 &mut self.state,
327 session_id,
328 &session,
329 "persist destroying state before target cleanup",
330 &persist,
331 )?;
332 }
333
334 let destroying = self
335 .state
336 .sessions
337 .get(session_id)
338 .expect("destroying session disappeared")
339 .clone();
340 {
341 let _verifying = ProvisionStageGuard::new(executor, ProvisionStage::Verifying);
342 verify_installed_checkpoint_gate(session_id, verified)?;
343 }
344 if let Err(error) = hel::hel_database::lose_reviewer_continuity(session_id) {
349 tracing::warn!(
350 session_id,
351 error = format!("{error:#}"),
352 "could not record that the second-opinion conversation ends with this target"
353 );
354 }
355 let locator = destroying
356 .target
357 .as_ref()
358 .context("session has no target")?;
359 let backend = backend_locator(locator, &destroying, &self.config)?;
360 let deferred = if let Some(plan) = hel_targets::quiesce_plan(&backend, session_id)? {
361 plan.execute(executor)?;
362 true
363 } else {
364 execute_target_cleanup(&backend, session_id, executor)?;
365 false
366 };
367 if let Some(worktree) = &destroying.managed_worktree {
368 retire_managed_worktree(executor, worktree)
369 .context("retire managed raw-session worktree after verified close")?;
370 }
371 let record = self.state.sessions.get_mut(session_id).unwrap();
372 record.state = SessionState::Stopped;
373 if !deferred {
374 record.target = None;
375 }
376 record.updated_at = now();
377 record.last_error = None;
378 persist_session_record_transition_or_restore(
379 &mut self.state,
380 session_id,
381 &destroying,
382 "persist stopped state after target cleanup",
383 &persist,
384 )?;
385 Ok(deferred)
386 }
387
388 pub fn cleanup_stopped_target(
392 &mut self,
393 session_id: &str,
394 executor: &impl CommandExecutor,
395 ) -> Result<()> {
396 self.cleanup_stopped_target_with(
397 session_id,
398 executor,
399 hel::hel_database::save_lifecycle_session,
400 )
401 }
402
403 fn cleanup_stopped_target_with(
404 &mut self,
405 session_id: &str,
406 executor: &impl CommandExecutor,
407 persist: impl Fn(&SessionRecord) -> Result<()>,
408 ) -> Result<()> {
409 let previous = self
410 .state
411 .sessions
412 .get(session_id)
413 .with_context(|| format!("unknown session {session_id}"))?
414 .clone();
415 ensure!(
416 previous.state == SessionState::Stopped,
417 "refusing deferred cleanup for active session {session_id}"
418 );
419 let Some(locator) = previous.target.as_ref() else {
420 return Ok(());
421 };
422 let backend = backend_locator(locator, &previous, &self.config)?;
423 ensure!(
424 hel_targets::quiesce_plan(&backend, session_id)?.is_some(),
425 "session {session_id} retained a non-Podman target after stopping"
426 );
427 if let Err(error) = execute_target_cleanup(&backend, session_id, executor) {
428 let record = self.state.sessions.get_mut(session_id).unwrap();
429 record.updated_at = now();
430 record.last_error = Some(format!("deferred target cleanup failed: {error:#}"));
431 let persisted = persist_session_record_transition_or_restore(
432 &mut self.state,
433 session_id,
434 &previous,
435 "persist deferred target cleanup failure",
436 &persist,
437 );
438 return match persisted {
439 Ok(()) => Err(error),
440 Err(persist_error) => Err(error.context(format!(
441 "also failed to persist deferred target cleanup failure: {persist_error:#}"
442 ))),
443 };
444 }
445 let record = self.state.sessions.get_mut(session_id).unwrap();
446 record.target = None;
447 record.updated_at = now();
448 record.last_error = None;
449 persist_session_record_transition_or_restore(
450 &mut self.state,
451 session_id,
452 &previous,
453 "persist completion of deferred Podman target cleanup",
454 &persist,
455 )
456 }
457
458 pub fn force_stop(
461 &mut self,
462 session_id: &str,
463 executor: &impl CommandExecutor,
464 ) -> Result<bool> {
465 self.force_stop_with(
466 session_id,
467 executor,
468 hel::hel_database::save_lifecycle_session,
469 )
470 }
471
472 fn force_stop_with(
473 &mut self,
474 session_id: &str,
475 executor: &impl CommandExecutor,
476 persist: impl Fn(&SessionRecord) -> Result<()>,
477 ) -> Result<bool> {
478 let session = self
479 .state
480 .sessions
481 .get(session_id)
482 .with_context(|| format!("unknown session {session_id}"))?
483 .clone();
484 ensure!(
485 session.state.is_active(),
486 "session {session_id} is already inactive"
487 );
488 let checkpoint = session
489 .checkpoint
490 .as_ref()
491 .context("force stop requires an existing recovery archive")?;
492 {
495 let _verifying = ProvisionStageGuard::new(executor, ProvisionStage::Verifying);
496 verify_installed_checkpoint_gate(session_id, checkpoint)
497 .context("verify the recovery archive before force stopping")?;
498 }
499 let mut deferred = false;
500 if let Some(locator) = &session.target {
501 let backend = backend_locator(locator, &session, &self.config)?;
502 if let Some(plan) = hel_targets::quiesce_plan(&backend, session_id)? {
503 plan.execute(executor)?;
504 deferred = true;
505 } else {
506 execute_target_cleanup(&backend, session_id, executor)?;
507 }
508 }
509 if let Some(worktree) = &session.managed_worktree {
510 retire_managed_worktree(executor, worktree)
511 .context("retire managed raw-session worktree after force stop")?;
512 }
513 let record = self.state.sessions.get_mut(session_id).unwrap();
514 record.state = SessionState::Stopped;
515 if !deferred {
516 record.target = None;
517 }
518 record.updated_at = now();
519 record.last_error = None;
520 record.last_checkpoint_error = None;
521 persist_session_record_transition_or_restore(
522 &mut self.state,
523 session_id,
524 &session,
525 "persist stopped state after force stopping the current target",
526 &persist,
527 )?;
528 Ok(deferred)
529 }
530
531 pub fn destroy_session_controlled(
535 &mut self,
536 session_id: &str,
537 executor: &impl CommandExecutor,
538 ) -> Result<()> {
539 let session = self
540 .state
541 .sessions
542 .get(session_id)
543 .with_context(|| format!("unknown session {session_id}"))?
544 .clone();
545 if session.state.is_active() {
546 bail!("refusing to destroy active session {session_id}");
547 }
548 if let Some(worktree) = &session.managed_worktree {
549 cleanup_managed_worktree(executor, worktree)
550 .context("remove managed raw-session worktree")?;
551 }
552 if let Some(checkpoint) = &session.checkpoint
553 && let Err(error) = std::fs::remove_file(&checkpoint.archive_path)
554 && error.kind() != std::io::ErrorKind::NotFound
555 {
556 return Err(error).with_context(|| {
557 format!(
558 "remove session recovery archive {}",
559 checkpoint.archive_path.display()
560 )
561 });
562 }
563 hel::hel_attachment::AttachmentStore::controller(session_id)?
564 .remove_session_data()
565 .context("remove session image attachments")?;
566 hel::hel_database::delete_session(session_id)
567 .context("destroy stopped session in database")?;
568 self.state.destroy_stopped_session(session_id)?;
569 Ok(())
570 }
571
572 pub fn force_destroy_session(
582 &mut self,
583 session_id: &str,
584 executor: &impl CommandExecutor,
585 ) -> Result<()> {
586 self.force_destroy_session_with(session_id, executor, hel::hel_database::delete_session)
587 }
588
589 fn force_destroy_session_with(
590 &mut self,
591 session_id: &str,
592 executor: &impl CommandExecutor,
593 delete: impl Fn(&str) -> Result<()>,
594 ) -> Result<()> {
595 let session = self
596 .state
597 .sessions
598 .get(session_id)
599 .with_context(|| format!("unknown session {session_id}"))?
600 .clone();
601 if let Some(locator) = &session.target {
605 let backend = backend_locator(locator, &session, &self.config)?;
606 execute_target_cleanup(&backend, session_id, executor)?;
607 }
608 if let Some(worktree) = &session.managed_worktree {
609 cleanup_managed_worktree(executor, worktree)
610 .context("remove managed raw-session worktree")?;
611 }
612 if let Some(checkpoint) = &session.checkpoint
613 && let Err(error) = std::fs::remove_file(&checkpoint.archive_path)
614 && error.kind() != std::io::ErrorKind::NotFound
615 {
616 return Err(error).with_context(|| {
617 format!(
618 "remove session recovery archive {}",
619 checkpoint.archive_path.display()
620 )
621 });
622 }
623 hel::hel_attachment::AttachmentStore::controller(session_id)?
624 .remove_session_data()
625 .context("remove session image attachments")?;
626 delete(session_id).context("force destroy session in database")?;
627 self.state.destroy_session_force(session_id)?;
628 Ok(())
629 }
630}
631
632fn execute_target_cleanup(
633 backend: &hel_targets::TargetLocator,
634 session_id: &str,
635 executor: &impl CommandExecutor,
636) -> Result<()> {
637 if let Err(cleanup_error) = hel_targets::close_plan(backend, session_id)?.execute(executor) {
638 match hel_targets::cleanup_target_is_confirmed_absent(backend, session_id, executor) {
639 Ok(true) => {
640 tracing::warn!(
641 session_id,
642 error = format!("{cleanup_error:#}"),
643 "target cleanup command failed, but the target was confirmed absent"
644 );
645 }
646 Ok(false) => {
647 tracing::error!(
648 session_id,
649 error = format!("{cleanup_error:#}"),
650 "target cleanup failed and the target is still present"
651 );
652 return Err(cleanup_error);
653 }
654 Err(probe_error) => {
655 tracing::error!(
656 session_id,
657 cleanup_error = format!("{cleanup_error:#}"),
658 probe_error = format!("{probe_error:#}"),
659 "target cleanup failed and exact absence could not be confirmed"
660 );
661 return Err(cleanup_error.context(format!(
662 "target cleanup failed and exact absence could not be confirmed: {probe_error:#}"
663 )));
664 }
665 }
666 }
667 Ok(())
668}
669
670fn apply_close_checkpoint_started(record: &mut SessionRecord, updated_at: String) {
671 record.state = SessionState::Closing;
672 record.updated_at = updated_at;
673 record.last_checkpoint_error = None;
674}
675
676fn apply_interrupted_close_error(
677 record: &mut SessionRecord,
678 error: &anyhow::Error,
679 updated_at: &str,
680) {
681 let destroying = record.state == SessionState::Destroying;
682 if !destroying {
683 record.state = SessionState::Closing;
684 }
685 record.updated_at = updated_at.to_owned();
686 record.last_error = Some(if destroying {
687 format!("target cleanup is safely retryable from its verified checkpoint: {error:#}")
688 } else {
689 format!("close is safely resumable from its verified checkpoint: {error:#}")
690 });
691}
692
693#[cfg(test)]
694mod tests {
695 use std::cell::RefCell;
696 use std::collections::BTreeMap;
697
698 use anyhow::Result;
699
700 use crate::hel_controller::Controller;
701 use crate::hel_controller::test_support::{
702 checkpoint_test_session, committed_repository, managed_worktree_session, test_git,
703 write_checkpoint_gate_archive,
704 };
705 use hel::hel_config::{ContainerTemplate as ConfigContainer, HelConfig, TargetTemplate};
706 use hel::hel_state::{HelState, SessionState, TargetLocator};
707 use hel::hel_targets::{self, CommandExecutor, CommandOutput, CommandSpec, ProcessExecutor};
708
709 use super::*;
710
711 #[test]
712 fn starting_close_persists_its_intent_before_checkpointing() {
713 let mut session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
714 session.state = SessionState::Running;
715 session.last_checkpoint_error = Some("old failure".into());
716
717 apply_close_checkpoint_started(&mut session, "2026-08-14T12:00:00Z".into());
718
719 assert_eq!(session.state, SessionState::Closing);
720 assert_eq!(session.updated_at, "2026-08-14T12:00:00Z");
721 assert!(session.last_checkpoint_error.is_none());
722 }
723
724 struct DeferredCleanupExecutor {
725 statuses: RefCell<Vec<i32>>,
726 }
727
728 impl CommandExecutor for DeferredCleanupExecutor {
729 fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
730 let status = self
731 .statuses
732 .borrow_mut()
733 .pop()
734 .expect("test cleanup command status");
735 Ok(CommandOutput {
736 status,
737 stdout: Vec::new(),
738 stderr: if status != 0 {
739 b"cleanup failed".to_vec()
740 } else {
741 Vec::new()
742 },
743 })
744 }
745 }
746
747 fn stopped_podman_cleanup_controller(session_id: &str) -> Controller {
748 let container_id = hel_targets::resource_name(session_id).unwrap();
749 let volume = format!("{container_id}-workspace");
750 let mut session = checkpoint_test_session(session_id);
751 session.target_template_id = "podman".into();
752 session.state = SessionState::Stopped;
753 session.target = Some(TargetLocator::LocalPodman {
754 container_id,
755 workspace_storage: hel::hel_state::PodmanWorkspaceLocator::Volume { name: volume },
756 });
757 let mut config = HelConfig::default();
758 config.targets.insert(
759 "podman".into(),
760 TargetTemplate::LocalPodman {
761 container: ConfigContainer {
762 image: "test:latest".into(),
763 pull_policy: Default::default(),
764 platform: None,
765 cpus: None,
766 memory: None,
767 environment: BTreeMap::new(),
768 workspace_storage: hel::hel_config::PodmanWorkspaceStorage::PodmanVolume,
769 },
770 },
771 );
772 Controller {
773 config,
774 state: HelState {
775 sessions: BTreeMap::from([(session_id.into(), session)]),
776 ..HelState::default()
777 },
778 }
779 }
780
781 #[test]
782 fn deferred_cleanup_failure_is_visible_and_successful_retry_clears_it() {
783 let session_id = "0123456789abcdef0123456789abcdef";
784 let mut controller = stopped_podman_cleanup_controller(session_id);
785 let persisted = RefCell::new(Vec::new());
786 let failure = controller
787 .cleanup_stopped_target_with(
788 session_id,
789 &DeferredCleanupExecutor {
790 statuses: RefCell::new(vec![1, 1]),
793 },
794 |record| {
795 persisted
796 .borrow_mut()
797 .push((record.target.is_some(), record.last_error.clone()));
798 Ok(())
799 },
800 )
801 .unwrap_err();
802 assert!(format!("{failure:#}").contains("cleanup failed"));
803 assert_eq!(persisted.borrow().len(), 1);
804 assert!(persisted.borrow()[0].0);
805 assert!(
806 persisted.borrow()[0]
807 .1
808 .as_deref()
809 .is_some_and(|error| error.contains("deferred target cleanup failed"))
810 );
811 assert!(controller.state.sessions[session_id].target.is_some());
812 assert!(controller.state.sessions[session_id].last_error.is_some());
813
814 let retry_persisted = RefCell::new(Vec::new());
815 controller
816 .cleanup_stopped_target_with(
817 session_id,
818 &DeferredCleanupExecutor {
819 statuses: RefCell::new(vec![0, 0, 0]),
820 },
821 |record| {
822 retry_persisted
823 .borrow_mut()
824 .push((record.target.is_some(), record.last_error.clone()));
825 Ok(())
826 },
827 )
828 .unwrap();
829 assert_eq!(retry_persisted.borrow().as_slice(), &[(false, None)]);
830 assert!(controller.state.sessions[session_id].target.is_none());
831 assert!(controller.state.sessions[session_id].last_error.is_none());
832 }
833
834 #[test]
835 fn deferred_cleanup_persistence_failure_restores_the_stopped_record() {
836 let session_id = "0123456789abcdef0123456789abcdef";
837 let mut controller = stopped_podman_cleanup_controller(session_id);
838 let previous = controller.state.sessions[session_id].clone();
839 let failure = controller
840 .cleanup_stopped_target_with(
841 session_id,
842 &DeferredCleanupExecutor {
843 statuses: RefCell::new(vec![1, 1]),
844 },
845 |_| Err(anyhow::anyhow!("database unavailable")),
846 )
847 .unwrap_err();
848 let detail = format!("{failure:#}");
849 assert!(detail.contains("cleanup failed"), "{detail}");
850 assert!(detail.contains("database unavailable"), "{detail}");
851 assert_eq!(controller.state.sessions[session_id], previous);
852 }
853
854 #[test]
855 fn target_cleanup_persists_destroying_and_rechecks_the_installed_archive() {
856 struct RecordingExecutor {
857 commands: RefCell<Vec<CommandSpec>>,
858 }
859
860 impl CommandExecutor for RecordingExecutor {
861 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
862 self.commands.borrow_mut().push(command.clone());
863 Ok(CommandOutput {
864 status: 0,
865 stdout: Vec::new(),
866 stderr: Vec::new(),
867 })
868 }
869 }
870
871 let directory = tempfile::tempdir().unwrap();
872 let session_id = "0123456789abcdef0123456789abcdef";
873 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
874 let mut session = checkpoint_test_session(session_id);
875 session.target_template_id = "local".into();
876 session.state = SessionState::Closing;
877 session.target = Some(TargetLocator::LocalBare {
878 worker_root: directory.path().join(session_id),
879 });
880 session.checkpoint = Some(checkpoint.clone());
881 let mut config = HelConfig::default();
882 config
883 .targets
884 .insert("local".into(), TargetTemplate::LocalBare);
885 let mut controller = Controller {
886 config,
887 state: HelState {
888 sessions: BTreeMap::from([(session_id.into(), session)]),
889 ..HelState::default()
890 },
891 };
892 let executor = RecordingExecutor {
893 commands: RefCell::new(Vec::new()),
894 };
895 let persisted = RefCell::new(Vec::new());
896
897 controller
898 .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
899 persisted.borrow_mut().push(record.state);
900 Ok(())
901 })
902 .unwrap();
903
904 assert_eq!(
905 persisted.into_inner(),
906 vec![SessionState::Destroying, SessionState::Stopped]
907 );
908 assert_eq!(executor.commands.borrow().len(), 1);
909 let stopped = &controller.state.sessions[session_id];
910 assert_eq!(stopped.state, SessionState::Stopped);
911 assert!(stopped.target.is_none());
912 }
913
914 #[test]
915 fn podman_close_persists_stopped_before_deferred_storage_cleanup() {
916 struct RecordingExecutor {
917 commands: RefCell<Vec<CommandSpec>>,
918 }
919
920 impl CommandExecutor for RecordingExecutor {
921 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
922 self.commands.borrow_mut().push(command.clone());
923 Ok(CommandOutput {
924 status: 0,
925 stdout: Vec::new(),
926 stderr: Vec::new(),
927 })
928 }
929 }
930
931 let directory = tempfile::tempdir().unwrap();
932 let session_id = "0123456789abcdef0123456789abcdef";
933 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
934 let container_id = hel_targets::resource_name(session_id).unwrap();
935 let volume = format!("{container_id}-workspace");
936 let mut session = checkpoint_test_session(session_id);
937 session.target_template_id = "podman".into();
938 session.state = SessionState::Closing;
939 session.target = Some(TargetLocator::LocalPodman {
940 container_id,
941 workspace_storage: hel::hel_state::PodmanWorkspaceLocator::Volume {
942 name: volume.clone(),
943 },
944 });
945 session.checkpoint = Some(checkpoint.clone());
946 let mut config = HelConfig::default();
947 config.targets.insert(
948 "podman".into(),
949 TargetTemplate::LocalPodman {
950 container: ConfigContainer {
951 image: "test:latest".into(),
952 pull_policy: Default::default(),
953 platform: None,
954 cpus: None,
955 memory: None,
956 environment: BTreeMap::new(),
957 workspace_storage: hel::hel_config::PodmanWorkspaceStorage::PodmanVolume,
958 },
959 },
960 );
961 let mut controller = Controller {
962 config,
963 state: HelState {
964 sessions: BTreeMap::from([(session_id.into(), session)]),
965 ..HelState::default()
966 },
967 };
968 let executor = RecordingExecutor {
969 commands: RefCell::new(Vec::new()),
970 };
971 let persisted = RefCell::new(Vec::new());
972
973 let deferred = controller
974 .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
975 persisted
976 .borrow_mut()
977 .push((record.state, record.target.is_some()));
978 Ok(())
979 })
980 .unwrap();
981
982 assert!(deferred);
983 assert_eq!(
984 persisted.borrow().as_slice(),
985 &[
986 (SessionState::Destroying, true),
987 (SessionState::Stopped, true)
988 ]
989 );
990 let commands = executor.commands.borrow();
991 assert_eq!(commands.len(), 1);
992 assert!(commands[0].args[1].contains("podman stop --time 0"));
993 assert!(!commands[0].args[1].contains("podman rm"));
994 drop(commands);
995
996 controller
997 .cleanup_stopped_target_with(session_id, &executor, |record| {
998 assert_eq!(record.state, SessionState::Stopped);
999 assert!(record.target.is_none());
1000 Ok(())
1001 })
1002 .unwrap();
1003
1004 let commands = executor.commands.borrow();
1005 assert_eq!(commands.len(), 4);
1006 assert_eq!(
1007 commands[1].stage,
1008 Some(hel_targets::ProvisionStage::RemovingContainer)
1009 );
1010 assert_eq!(
1011 commands[2].stage,
1012 Some(hel_targets::ProvisionStage::RemovingStorage)
1013 );
1014 assert_eq!(
1015 commands[3].stage,
1016 Some(hel_targets::ProvisionStage::CleaningCache)
1017 );
1018 assert!(commands[2].args.contains(&volume));
1019 assert!(controller.state.sessions[session_id].target.is_none());
1020 }
1021 #[test]
1022 fn verified_close_retires_managed_checkout_but_keeps_archive_and_branch() {
1023 let archive_directory = tempfile::tempdir().unwrap();
1024 let repository = committed_repository();
1025 let session_id = "0123456789abcdef0123456789abcdef";
1026 let checkpoint = write_checkpoint_gate_archive(archive_directory.path(), session_id, 7);
1027 let mut session = managed_worktree_session(repository.path(), session_id);
1028 let worktree = session.managed_worktree.clone().unwrap();
1029 std::fs::write(worktree.worktree_root.join("dirty.txt"), "worktree state\n").unwrap();
1030 session.state = SessionState::Closing;
1031 session.target = Some(TargetLocator::LocalBare {
1032 worker_root: archive_directory.path().join(session_id),
1033 });
1034 session.checkpoint = Some(checkpoint.clone());
1035 let mut config = HelConfig::default();
1036 config
1037 .targets
1038 .insert("local-bare".into(), TargetTemplate::LocalBare);
1039 let mut controller = Controller {
1040 config,
1041 state: HelState {
1042 sessions: BTreeMap::from([(session_id.into(), session)]),
1043 ..HelState::default()
1044 },
1045 };
1046
1047 controller
1048 .destroy_after_verified_checkpoint_with(
1049 session_id,
1050 &checkpoint,
1051 &ProcessExecutor,
1052 |_| Ok(()),
1053 )
1054 .unwrap();
1055
1056 assert!(!worktree.worktree_root.exists());
1057 assert!(checkpoint.archive_path.is_file());
1058 assert_eq!(
1059 test_git(
1060 repository.path(),
1061 &[
1062 "show-ref",
1063 "--hash",
1064 &format!("refs/heads/{}", worktree.branch),
1065 ],
1066 )
1067 .len(),
1068 40
1069 );
1070 assert_eq!(
1071 controller.state.sessions[session_id].state,
1072 SessionState::Stopped
1073 );
1074 }
1075 #[test]
1076 fn force_stop_reuses_verified_archive_and_leaves_session_resumable() {
1077 let archive_directory = tempfile::tempdir().unwrap();
1078 let repository = committed_repository();
1079 let session_id = "0123456789abcdef0123456789abcdef";
1080 let checkpoint = write_checkpoint_gate_archive(archive_directory.path(), session_id, 7);
1081 let mut session = managed_worktree_session(repository.path(), session_id);
1082 let worktree = session.managed_worktree.clone().unwrap();
1083 session.state = SessionState::Running;
1084 session.target = Some(TargetLocator::LocalBare {
1085 worker_root: archive_directory.path().join(session_id),
1086 });
1087 session.checkpoint = Some(checkpoint.clone());
1088 let mut config = HelConfig::default();
1089 config
1090 .targets
1091 .insert("local-bare".into(), TargetTemplate::LocalBare);
1092 let mut controller = Controller {
1093 config,
1094 state: HelState {
1095 sessions: BTreeMap::from([(session_id.into(), session)]),
1096 ..HelState::default()
1097 },
1098 };
1099
1100 controller
1101 .force_stop_with(session_id, &ProcessExecutor, |_| Ok(()))
1102 .unwrap();
1103
1104 let stopped = &controller.state.sessions[session_id];
1105 assert_eq!(stopped.state, SessionState::Stopped);
1106 assert!(stopped.target.is_none());
1107 assert_eq!(stopped.checkpoint.as_ref(), Some(&checkpoint));
1108 assert!(checkpoint.archive_path.is_file());
1109 assert!(!worktree.worktree_root.exists());
1110 assert!(
1111 !test_git(
1112 repository.path(),
1113 &[
1114 "show-ref",
1115 "--hash",
1116 &format!("refs/heads/{}", worktree.branch),
1117 ],
1118 )
1119 .is_empty()
1120 );
1121 }
1122 #[test]
1123 fn force_stop_without_a_recovery_archive_does_not_touch_the_target() {
1124 struct RecordingExecutor {
1125 calls: RefCell<usize>,
1126 }
1127
1128 impl CommandExecutor for RecordingExecutor {
1129 fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
1130 *self.calls.borrow_mut() += 1;
1131 Ok(CommandOutput {
1132 status: 0,
1133 stdout: Vec::new(),
1134 stderr: Vec::new(),
1135 })
1136 }
1137 }
1138
1139 let directory = tempfile::tempdir().unwrap();
1140 let session_id = "0123456789abcdef0123456789abcdef";
1141 let mut session = checkpoint_test_session(session_id);
1142 session.state = SessionState::Running;
1143 session.checkpoint = None;
1144 session.target_template_id = "local".into();
1145 session.target = Some(TargetLocator::LocalBare {
1146 worker_root: directory.path().join(session_id),
1147 });
1148 let mut config = HelConfig::default();
1149 config
1150 .targets
1151 .insert("local".into(), TargetTemplate::LocalBare);
1152 let mut controller = Controller {
1153 config,
1154 state: HelState {
1155 sessions: BTreeMap::from([(session_id.into(), session)]),
1156 ..HelState::default()
1157 },
1158 };
1159 let executor = RecordingExecutor {
1160 calls: RefCell::new(0),
1161 };
1162
1163 let error = controller
1164 .force_stop_with(session_id, &executor, |_| Ok(()))
1165 .unwrap_err();
1166
1167 assert!(error.to_string().contains("existing recovery archive"));
1168 assert_eq!(*executor.calls.borrow(), 0);
1169 assert_eq!(
1170 controller.state.sessions[session_id].state,
1171 SessionState::Running
1172 );
1173 assert!(controller.state.sessions[session_id].target.is_some());
1174 }
1175 #[test]
1176 fn destroying_retry_blocks_cleanup_when_the_archive_gate_changed() {
1177 struct RecordingExecutor {
1178 calls: RefCell<usize>,
1179 }
1180
1181 impl CommandExecutor for RecordingExecutor {
1182 fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
1183 *self.calls.borrow_mut() += 1;
1184 Ok(CommandOutput {
1185 status: 0,
1186 stdout: Vec::new(),
1187 stderr: Vec::new(),
1188 })
1189 }
1190 }
1191
1192 let directory = tempfile::tempdir().unwrap();
1193 let repository = committed_repository();
1194 let session_id = "0123456789abcdef0123456789abcdef";
1195 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1196 let mut session = managed_worktree_session(repository.path(), session_id);
1197 let worktree = session.managed_worktree.clone().unwrap();
1198 session.target_template_id = "local".into();
1199 session.state = SessionState::Destroying;
1200 session.target = Some(TargetLocator::LocalBare {
1201 worker_root: directory.path().join(session_id),
1202 });
1203 session.checkpoint = Some(checkpoint.clone());
1204 let mut config = HelConfig::default();
1205 config
1206 .targets
1207 .insert("local".into(), TargetTemplate::LocalBare);
1208 let mut controller = Controller {
1209 config,
1210 state: HelState {
1211 sessions: BTreeMap::from([(session_id.into(), session)]),
1212 ..HelState::default()
1213 },
1214 };
1215 let executor = RecordingExecutor {
1216 calls: RefCell::new(0),
1217 };
1218 let persisted = RefCell::new(Vec::new());
1219 std::fs::write(&checkpoint.archive_path, b"changed after checkpoint").unwrap();
1220
1221 let error = controller
1222 .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
1223 persisted.borrow_mut().push(record.state);
1224 Ok(())
1225 })
1226 .unwrap_err();
1227
1228 assert!(error.to_string().contains("checkpoint SHA changed"));
1229 assert_eq!(*executor.calls.borrow(), 0);
1230 assert!(persisted.into_inner().is_empty());
1231 assert!(worktree.worktree_root.is_dir());
1232 assert_eq!(
1233 controller.state.sessions[session_id].state,
1234 SessionState::Destroying
1235 );
1236 }
1237 #[test]
1238 fn destroying_retry_finalizes_when_apple_container_is_confirmed_absent() {
1239 struct AlreadyRemovedExecutor {
1240 commands: RefCell<Vec<CommandSpec>>,
1241 }
1242
1243 impl CommandExecutor for AlreadyRemovedExecutor {
1244 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1245 self.commands.borrow_mut().push(command.clone());
1246 if command.program == "sh"
1247 && command
1248 .args
1249 .get(1)
1250 .is_some_and(|script| script.contains("container rm --force"))
1251 {
1252 Ok(CommandOutput {
1253 status: 1,
1254 stdout: Vec::new(),
1255 stderr: b"container not found".to_vec(),
1256 })
1257 } else {
1258 Ok(CommandOutput {
1259 status: 0,
1260 stdout: Vec::new(),
1261 stderr: Vec::new(),
1262 })
1263 }
1264 }
1265 }
1266
1267 let directory = tempfile::tempdir().unwrap();
1268 let session_id = "0123456789abcdef0123456789abcdef";
1269 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1270 let mut session = checkpoint_test_session(session_id);
1271 session.target_template_id = "apple".into();
1272 session.state = SessionState::Destroying;
1273 session.target = Some(TargetLocator::AppleContainer {
1274 container_id: hel_targets::resource_name(session_id).unwrap(),
1275 });
1276 session.checkpoint = Some(checkpoint.clone());
1277 let mut config = HelConfig::default();
1278 config.targets.insert(
1279 "apple".into(),
1280 TargetTemplate::AppleContainer {
1281 container: ConfigContainer {
1282 image: "test:latest".into(),
1283 pull_policy: Default::default(),
1284 platform: None,
1285 cpus: None,
1286 memory: None,
1287 environment: BTreeMap::new(),
1288 workspace_storage: Default::default(),
1289 },
1290 },
1291 );
1292 let mut controller = Controller {
1293 config,
1294 state: HelState {
1295 sessions: BTreeMap::from([(session_id.into(), session)]),
1296 ..HelState::default()
1297 },
1298 };
1299 let executor = AlreadyRemovedExecutor {
1300 commands: RefCell::new(Vec::new()),
1301 };
1302 let persisted = RefCell::new(Vec::new());
1303
1304 controller
1305 .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
1306 persisted.borrow_mut().push(record.state);
1307 Ok(())
1308 })
1309 .unwrap();
1310
1311 let commands = executor.commands.borrow();
1312 assert_eq!(commands.len(), 2);
1313 assert_eq!(commands[0].program, "sh");
1314 assert!(commands[0].args[1].contains("container rm --force"));
1315 assert!(commands[0].args[1].contains(".cache/mjolnir/git/sessions"));
1316 assert_eq!(commands[1].args, ["list", "--all", "--quiet"]);
1317 assert_eq!(persisted.into_inner(), vec![SessionState::Stopped]);
1318 assert_eq!(
1319 controller.state.sessions[session_id].state,
1320 SessionState::Stopped
1321 );
1322 }
1323
1324 #[test]
1325 fn interrupted_close_error_preserves_destroying_phase() {
1326 let session_id = "0123456789abcdef0123456789abcdef";
1327 let mut session = checkpoint_test_session(session_id);
1328 session.state = SessionState::Destroying;
1329
1330 apply_interrupted_close_error(
1331 &mut session,
1332 &anyhow::anyhow!("podman unavailable"),
1333 "2026-08-14T12:00:00Z",
1334 );
1335
1336 assert_eq!(session.state, SessionState::Destroying);
1337 assert_eq!(session.updated_at, "2026-08-14T12:00:00Z");
1338 assert!(
1339 session
1340 .last_error
1341 .as_deref()
1342 .is_some_and(|error| error.contains("cleanup is safely retryable"))
1343 );
1344 }
1345
1346 struct FailingExecutor;
1347
1348 impl CommandExecutor for FailingExecutor {
1349 fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
1350 Ok(CommandOutput {
1351 status: 1,
1352 stdout: Vec::new(),
1353 stderr: b"teardown unavailable".to_vec(),
1354 })
1355 }
1356 }
1357
1358 fn branch_exists(repository: &std::path::Path, branch: &str) -> bool {
1359 std::process::Command::new("git")
1360 .arg("-C")
1361 .arg(repository)
1362 .args(["show-ref", "--verify", "--quiet"])
1363 .arg(format!("refs/heads/{branch}"))
1364 .output()
1365 .unwrap()
1366 .status
1367 .success()
1368 }
1369
1370 #[test]
1371 fn force_destroy_from_running_removes_target_worktree_branch_and_archive() {
1372 let directory = tempfile::tempdir().unwrap();
1373 let repository = committed_repository();
1374 let session_id = "0123456789abcdef0123456789abcdef";
1375 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1376 let worker_root = directory.path().join(session_id);
1377 std::fs::create_dir_all(&worker_root).unwrap();
1378 let mut session = managed_worktree_session(repository.path(), session_id);
1379 session.state = SessionState::Running;
1380 session.target_template_id = "local".into();
1381 session.target = Some(TargetLocator::LocalBare {
1382 worker_root: worker_root.clone(),
1383 });
1384 session.checkpoint = Some(checkpoint.clone());
1385 let mut config = HelConfig::default();
1386 config
1387 .targets
1388 .insert("local".into(), TargetTemplate::LocalBare);
1389 let mut controller = Controller {
1390 config,
1391 state: HelState {
1392 sessions: BTreeMap::from([(session_id.into(), session)]),
1393 ..HelState::default()
1394 },
1395 };
1396 let deleted = RefCell::new(Vec::new());
1397
1398 controller
1399 .force_destroy_session_with(session_id, &ProcessExecutor, |id| {
1400 deleted.borrow_mut().push(id.to_owned());
1401 Ok(())
1402 })
1403 .unwrap();
1404
1405 assert!(!worker_root.exists(), "local target must be removed");
1406 let worktree_root = repository.path().join(".mj/worktrees").join(session_id);
1407 assert!(!worktree_root.exists(), "managed worktree must be removed");
1408 assert!(
1409 !branch_exists(repository.path(), &format!("mj/{session_id}")),
1410 "generated branch must be removed"
1411 );
1412 assert!(!checkpoint.archive_path.exists(), "archive must be removed");
1413 assert!(!controller.state.sessions.contains_key(session_id));
1414 assert_eq!(deleted.into_inner(), vec![session_id.to_owned()]);
1415 }
1416
1417 #[test]
1418 fn force_destroy_without_a_target_or_archive_still_removes_the_record() {
1419 let session_id = "0123456789abcdef0123456789abcdef";
1420 let mut session = checkpoint_test_session(session_id);
1421 session.state = SessionState::Provisioning;
1422 session.target = None;
1423 session.checkpoint = None;
1424 let mut controller = Controller {
1425 config: HelConfig::default(),
1426 state: HelState {
1427 sessions: BTreeMap::from([(session_id.into(), session)]),
1428 ..HelState::default()
1429 },
1430 };
1431 let deleted = RefCell::new(Vec::new());
1432
1433 controller
1434 .force_destroy_session_with(session_id, &ProcessExecutor, |id| {
1435 deleted.borrow_mut().push(id.to_owned());
1436 Ok(())
1437 })
1438 .unwrap();
1439
1440 assert!(!controller.state.sessions.contains_key(session_id));
1441 assert_eq!(deleted.into_inner(), vec![session_id.to_owned()]);
1442 }
1443
1444 #[test]
1445 fn force_destroy_aborts_and_keeps_the_record_when_the_target_survives() {
1446 let directory = tempfile::tempdir().unwrap();
1447 let session_id = "0123456789abcdef0123456789abcdef";
1448 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1449 let mut session = checkpoint_test_session(session_id);
1450 session.target_template_id = "local".into();
1451 session.state = SessionState::Running;
1452 session.target = Some(TargetLocator::LocalBare {
1453 worker_root: directory.path().join(session_id),
1454 });
1455 session.checkpoint = Some(checkpoint.clone());
1456 let mut config = HelConfig::default();
1457 config
1458 .targets
1459 .insert("local".into(), TargetTemplate::LocalBare);
1460 let mut controller = Controller {
1461 config,
1462 state: HelState {
1463 sessions: BTreeMap::from([(session_id.into(), session)]),
1464 ..HelState::default()
1465 },
1466 };
1467 let deleted = RefCell::new(Vec::new());
1468
1469 let error = controller
1470 .force_destroy_session_with(session_id, &FailingExecutor, |id| {
1471 deleted.borrow_mut().push(id.to_owned());
1472 Ok(())
1473 })
1474 .unwrap_err();
1475
1476 assert!(
1477 error.to_string().contains("teardown unavailable"),
1478 "{error:#}"
1479 );
1480 assert!(
1481 controller.state.sessions.contains_key(session_id),
1482 "a surviving target must keep the record for a retry"
1483 );
1484 assert!(
1485 checkpoint.archive_path.exists(),
1486 "a surviving target must keep the recovery archive"
1487 );
1488 assert!(deleted.into_inner().is_empty());
1489 }
1490
1491 #[test]
1492 fn force_destroy_tolerates_a_missing_archive() {
1493 let directory = tempfile::tempdir().unwrap();
1494 let session_id = "0123456789abcdef0123456789abcdef";
1495 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1496 std::fs::remove_file(&checkpoint.archive_path).unwrap();
1497 let mut session = checkpoint_test_session(session_id);
1498 session.state = SessionState::Error;
1499 session.checkpoint = Some(checkpoint);
1500 let mut controller = Controller {
1501 config: HelConfig::default(),
1502 state: HelState {
1503 sessions: BTreeMap::from([(session_id.into(), session)]),
1504 ..HelState::default()
1505 },
1506 };
1507
1508 controller
1509 .force_destroy_session_with(session_id, &ProcessExecutor, |_| Ok(()))
1510 .unwrap();
1511
1512 assert!(!controller.state.sessions.contains_key(session_id));
1513 }
1514}