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