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_database::delete_session(session_id)
573 .context("destroy stopped session in database")?;
574 self.state.destroy_stopped_session(session_id)?;
575 Ok(())
576 }
577
578 pub fn force_destroy_session(
588 &mut self,
589 session_id: &str,
590 executor: &impl CommandExecutor,
591 ) -> Result<()> {
592 self.force_destroy_session_with(session_id, executor, hel::hel_database::delete_session)
593 }
594
595 fn force_destroy_session_with(
596 &mut self,
597 session_id: &str,
598 executor: &impl CommandExecutor,
599 delete: impl Fn(&str) -> Result<()>,
600 ) -> Result<()> {
601 let session = self
602 .state
603 .sessions
604 .get(session_id)
605 .with_context(|| format!("unknown session {session_id}"))?
606 .clone();
607 retire_git_broker(session_id).context("stop the session's local Git broker")?;
611 if let Some(locator) = &session.target {
612 let backend = backend_locator(locator, &session, &self.config)?;
613 execute_target_cleanup(&backend, session_id, executor)?;
614 }
615 if let Some(worktree) = &session.managed_worktree {
616 cleanup_managed_worktree(executor, worktree)
617 .context("remove managed raw-session worktree")?;
618 }
619 if let Some(checkpoint) = &session.checkpoint
620 && let Err(error) = std::fs::remove_file(&checkpoint.archive_path)
621 && error.kind() != std::io::ErrorKind::NotFound
622 {
623 return Err(error).with_context(|| {
624 format!(
625 "remove session recovery archive {}",
626 checkpoint.archive_path.display()
627 )
628 });
629 }
630 delete(session_id).context("force destroy session in database")?;
631 self.state.destroy_session_force(session_id)?;
632 Ok(())
633 }
634}
635
636fn execute_target_cleanup(
637 backend: &hel_targets::TargetLocator,
638 session_id: &str,
639 executor: &impl CommandExecutor,
640) -> Result<()> {
641 if let Err(cleanup_error) = hel_targets::close_plan(backend, session_id)?.execute(executor) {
642 match hel_targets::cleanup_target_is_confirmed_absent(backend, session_id, executor) {
643 Ok(true) => {
644 tracing::warn!(
645 session_id,
646 error = format!("{cleanup_error:#}"),
647 "target cleanup command failed, but the target was confirmed absent"
648 );
649 }
650 Ok(false) => {
651 tracing::error!(
652 session_id,
653 error = format!("{cleanup_error:#}"),
654 "target cleanup failed and the target is still present"
655 );
656 return Err(cleanup_error);
657 }
658 Err(probe_error) => {
659 tracing::error!(
660 session_id,
661 cleanup_error = format!("{cleanup_error:#}"),
662 probe_error = format!("{probe_error:#}"),
663 "target cleanup failed and exact absence could not be confirmed"
664 );
665 return Err(cleanup_error.context(format!(
666 "target cleanup failed and exact absence could not be confirmed: {probe_error:#}"
667 )));
668 }
669 }
670 }
671 Ok(())
672}
673
674fn apply_close_checkpoint_started(record: &mut SessionRecord, updated_at: String) {
675 record.state = SessionState::Closing;
676 record.updated_at = updated_at;
677 record.last_checkpoint_error = None;
678}
679
680fn apply_interrupted_close_error(
681 record: &mut SessionRecord,
682 error: &anyhow::Error,
683 updated_at: &str,
684) {
685 let destroying = record.state == SessionState::Destroying;
686 if !destroying {
687 record.state = SessionState::Closing;
688 }
689 record.updated_at = updated_at.to_owned();
690 record.last_error = Some(if destroying {
691 format!("target cleanup is safely retryable from its verified checkpoint: {error:#}")
692 } else {
693 format!("close is safely resumable from its verified checkpoint: {error:#}")
694 });
695}
696
697#[cfg(test)]
698mod tests {
699 use std::cell::RefCell;
700 use std::collections::BTreeMap;
701
702 use anyhow::Result;
703
704 use crate::hel_controller::Controller;
705 use crate::hel_controller::test_support::{
706 checkpoint_test_session, committed_repository, managed_worktree_session, test_git,
707 write_checkpoint_gate_archive,
708 };
709 use hel::hel_config::{ContainerTemplate as ConfigContainer, HelConfig, TargetTemplate};
710 use hel::hel_state::{HelState, SessionState, TargetLocator};
711 use hel::hel_targets::{self, CommandExecutor, CommandOutput, CommandSpec, ProcessExecutor};
712
713 use super::*;
714
715 #[test]
716 fn starting_close_persists_its_intent_before_checkpointing() {
717 let mut session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
718 session.state = SessionState::Running;
719 session.last_checkpoint_error = Some("old failure".into());
720
721 apply_close_checkpoint_started(&mut session, "2026-08-14T12:00:00Z".into());
722
723 assert_eq!(session.state, SessionState::Closing);
724 assert_eq!(session.updated_at, "2026-08-14T12:00:00Z");
725 assert!(session.last_checkpoint_error.is_none());
726 }
727
728 struct DeferredCleanupExecutor {
729 statuses: RefCell<Vec<i32>>,
730 }
731
732 impl CommandExecutor for DeferredCleanupExecutor {
733 fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
734 let status = self
735 .statuses
736 .borrow_mut()
737 .pop()
738 .expect("test cleanup command status");
739 Ok(CommandOutput {
740 status,
741 stdout: Vec::new(),
742 stderr: if status != 0 {
743 b"cleanup failed".to_vec()
744 } else {
745 Vec::new()
746 },
747 })
748 }
749 }
750
751 fn stopped_podman_cleanup_controller(session_id: &str) -> Controller {
752 let container_id = hel_targets::resource_name(session_id).unwrap();
753 let volume = format!("{container_id}-workspace");
754 let mut session = checkpoint_test_session(session_id);
755 session.target_template_id = "podman".into();
756 session.state = SessionState::Stopped;
757 session.target = Some(TargetLocator::LocalPodman {
758 container_id,
759 workspace_storage: hel::hel_state::PodmanWorkspaceLocator::Volume { name: volume },
760 });
761 let mut config = HelConfig::default();
762 config.targets.insert(
763 "podman".into(),
764 TargetTemplate::LocalPodman {
765 container: ConfigContainer {
766 image: "test:latest".into(),
767 pull_policy: Default::default(),
768 platform: None,
769 cpus: None,
770 memory: None,
771 environment: BTreeMap::new(),
772 workspace_storage: hel::hel_config::PodmanWorkspaceStorage::PodmanVolume,
773 },
774 },
775 );
776 Controller {
777 config,
778 state: HelState {
779 sessions: BTreeMap::from([(session_id.into(), session)]),
780 ..HelState::default()
781 },
782 }
783 }
784
785 #[test]
786 fn deferred_cleanup_failure_is_visible_and_successful_retry_clears_it() {
787 let session_id = "0123456789abcdef0123456789abcdef";
788 let mut controller = stopped_podman_cleanup_controller(session_id);
789 let persisted = RefCell::new(Vec::new());
790 let failure = controller
791 .cleanup_stopped_target_with(
792 session_id,
793 &DeferredCleanupExecutor {
794 statuses: RefCell::new(vec![1, 1]),
797 },
798 |record| {
799 persisted
800 .borrow_mut()
801 .push((record.target.is_some(), record.last_error.clone()));
802 Ok(())
803 },
804 )
805 .unwrap_err();
806 assert!(format!("{failure:#}").contains("cleanup failed"));
807 assert_eq!(persisted.borrow().len(), 1);
808 assert!(persisted.borrow()[0].0);
809 assert!(
810 persisted.borrow()[0]
811 .1
812 .as_deref()
813 .is_some_and(|error| error.contains("deferred target cleanup failed"))
814 );
815 assert!(controller.state.sessions[session_id].target.is_some());
816 assert!(controller.state.sessions[session_id].last_error.is_some());
817
818 let retry_persisted = RefCell::new(Vec::new());
819 controller
820 .cleanup_stopped_target_with(
821 session_id,
822 &DeferredCleanupExecutor {
823 statuses: RefCell::new(vec![0, 0, 0]),
824 },
825 |record| {
826 retry_persisted
827 .borrow_mut()
828 .push((record.target.is_some(), record.last_error.clone()));
829 Ok(())
830 },
831 )
832 .unwrap();
833 assert_eq!(retry_persisted.borrow().as_slice(), &[(false, None)]);
834 assert!(controller.state.sessions[session_id].target.is_none());
835 assert!(controller.state.sessions[session_id].last_error.is_none());
836 }
837
838 #[test]
839 fn deferred_cleanup_persistence_failure_restores_the_stopped_record() {
840 let session_id = "0123456789abcdef0123456789abcdef";
841 let mut controller = stopped_podman_cleanup_controller(session_id);
842 let previous = controller.state.sessions[session_id].clone();
843 let failure = controller
844 .cleanup_stopped_target_with(
845 session_id,
846 &DeferredCleanupExecutor {
847 statuses: RefCell::new(vec![1, 1]),
848 },
849 |_| Err(anyhow::anyhow!("database unavailable")),
850 )
851 .unwrap_err();
852 let detail = format!("{failure:#}");
853 assert!(detail.contains("cleanup failed"), "{detail}");
854 assert!(detail.contains("database unavailable"), "{detail}");
855 assert_eq!(controller.state.sessions[session_id], previous);
856 }
857
858 #[test]
859 fn target_cleanup_persists_destroying_and_rechecks_the_installed_archive() {
860 struct RecordingExecutor {
861 commands: RefCell<Vec<CommandSpec>>,
862 }
863
864 impl CommandExecutor for RecordingExecutor {
865 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
866 self.commands.borrow_mut().push(command.clone());
867 Ok(CommandOutput {
868 status: 0,
869 stdout: Vec::new(),
870 stderr: Vec::new(),
871 })
872 }
873 }
874
875 let directory = tempfile::tempdir().unwrap();
876 let session_id = "0123456789abcdef0123456789abcdef";
877 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
878 let mut session = checkpoint_test_session(session_id);
879 session.target_template_id = "local".into();
880 session.state = SessionState::Closing;
881 session.target = Some(TargetLocator::LocalBare {
882 worker_root: directory.path().join(session_id),
883 });
884 session.checkpoint = Some(checkpoint.clone());
885 let mut config = HelConfig::default();
886 config
887 .targets
888 .insert("local".into(), TargetTemplate::LocalBare);
889 let mut controller = Controller {
890 config,
891 state: HelState {
892 sessions: BTreeMap::from([(session_id.into(), session)]),
893 ..HelState::default()
894 },
895 };
896 let executor = RecordingExecutor {
897 commands: RefCell::new(Vec::new()),
898 };
899 let persisted = RefCell::new(Vec::new());
900
901 controller
902 .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
903 persisted.borrow_mut().push(record.state);
904 Ok(())
905 })
906 .unwrap();
907
908 assert_eq!(
909 persisted.into_inner(),
910 vec![SessionState::Destroying, SessionState::Stopped]
911 );
912 assert_eq!(executor.commands.borrow().len(), 1);
913 let stopped = &controller.state.sessions[session_id];
914 assert_eq!(stopped.state, SessionState::Stopped);
915 assert!(stopped.target.is_none());
916 }
917
918 #[test]
919 fn podman_close_persists_stopped_before_deferred_storage_cleanup() {
920 struct RecordingExecutor {
921 commands: RefCell<Vec<CommandSpec>>,
922 }
923
924 impl CommandExecutor for RecordingExecutor {
925 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
926 self.commands.borrow_mut().push(command.clone());
927 Ok(CommandOutput {
928 status: 0,
929 stdout: Vec::new(),
930 stderr: Vec::new(),
931 })
932 }
933 }
934
935 let directory = tempfile::tempdir().unwrap();
936 let session_id = "0123456789abcdef0123456789abcdef";
937 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
938 let container_id = hel_targets::resource_name(session_id).unwrap();
939 let volume = format!("{container_id}-workspace");
940 let mut session = checkpoint_test_session(session_id);
941 session.target_template_id = "podman".into();
942 session.state = SessionState::Closing;
943 session.target = Some(TargetLocator::LocalPodman {
944 container_id,
945 workspace_storage: hel::hel_state::PodmanWorkspaceLocator::Volume {
946 name: volume.clone(),
947 },
948 });
949 session.checkpoint = Some(checkpoint.clone());
950 let mut config = HelConfig::default();
951 config.targets.insert(
952 "podman".into(),
953 TargetTemplate::LocalPodman {
954 container: ConfigContainer {
955 image: "test:latest".into(),
956 pull_policy: Default::default(),
957 platform: None,
958 cpus: None,
959 memory: None,
960 environment: BTreeMap::new(),
961 workspace_storage: hel::hel_config::PodmanWorkspaceStorage::PodmanVolume,
962 },
963 },
964 );
965 let mut controller = Controller {
966 config,
967 state: HelState {
968 sessions: BTreeMap::from([(session_id.into(), session)]),
969 ..HelState::default()
970 },
971 };
972 let executor = RecordingExecutor {
973 commands: RefCell::new(Vec::new()),
974 };
975 let persisted = RefCell::new(Vec::new());
976
977 let deferred = controller
978 .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
979 persisted
980 .borrow_mut()
981 .push((record.state, record.target.is_some()));
982 Ok(())
983 })
984 .unwrap();
985
986 assert!(deferred);
987 assert_eq!(
988 persisted.borrow().as_slice(),
989 &[
990 (SessionState::Destroying, true),
991 (SessionState::Stopped, true)
992 ]
993 );
994 let commands = executor.commands.borrow();
995 assert_eq!(commands.len(), 1);
996 assert!(commands[0].args[1].contains("podman stop --time 0"));
997 assert!(!commands[0].args[1].contains("podman rm"));
998 drop(commands);
999
1000 controller
1001 .cleanup_stopped_target_with(session_id, &executor, |record| {
1002 assert_eq!(record.state, SessionState::Stopped);
1003 assert!(record.target.is_none());
1004 Ok(())
1005 })
1006 .unwrap();
1007
1008 let commands = executor.commands.borrow();
1009 assert_eq!(commands.len(), 4);
1010 assert_eq!(
1011 commands[1].stage,
1012 Some(hel_targets::ProvisionStage::RemovingContainer)
1013 );
1014 assert_eq!(
1015 commands[2].stage,
1016 Some(hel_targets::ProvisionStage::RemovingStorage)
1017 );
1018 assert_eq!(
1019 commands[3].stage,
1020 Some(hel_targets::ProvisionStage::CleaningCache)
1021 );
1022 assert!(commands[2].args.contains(&volume));
1023 assert!(controller.state.sessions[session_id].target.is_none());
1024 }
1025 #[test]
1026 fn verified_close_retires_managed_checkout_but_keeps_archive_and_branch() {
1027 let archive_directory = tempfile::tempdir().unwrap();
1028 let repository = committed_repository();
1029 let session_id = "0123456789abcdef0123456789abcdef";
1030 let checkpoint = write_checkpoint_gate_archive(archive_directory.path(), session_id, 7);
1031 let mut session = managed_worktree_session(repository.path(), session_id);
1032 let worktree = session.managed_worktree.clone().unwrap();
1033 std::fs::write(worktree.worktree_root.join("dirty.txt"), "worktree state\n").unwrap();
1034 session.state = SessionState::Closing;
1035 session.target = Some(TargetLocator::LocalBare {
1036 worker_root: archive_directory.path().join(session_id),
1037 });
1038 session.checkpoint = Some(checkpoint.clone());
1039 let mut config = HelConfig::default();
1040 config
1041 .targets
1042 .insert("local-bare".into(), TargetTemplate::LocalBare);
1043 let mut controller = Controller {
1044 config,
1045 state: HelState {
1046 sessions: BTreeMap::from([(session_id.into(), session)]),
1047 ..HelState::default()
1048 },
1049 };
1050
1051 controller
1052 .destroy_after_verified_checkpoint_with(
1053 session_id,
1054 &checkpoint,
1055 &ProcessExecutor,
1056 |_| Ok(()),
1057 )
1058 .unwrap();
1059
1060 assert!(!worktree.worktree_root.exists());
1061 assert!(checkpoint.archive_path.is_file());
1062 assert_eq!(
1063 test_git(
1064 repository.path(),
1065 &[
1066 "show-ref",
1067 "--hash",
1068 &format!("refs/heads/{}", worktree.branch),
1069 ],
1070 )
1071 .len(),
1072 40
1073 );
1074 assert_eq!(
1075 controller.state.sessions[session_id].state,
1076 SessionState::Stopped
1077 );
1078 }
1079 #[test]
1080 fn force_stop_reuses_verified_archive_and_leaves_session_resumable() {
1081 let archive_directory = tempfile::tempdir().unwrap();
1082 let repository = committed_repository();
1083 let session_id = "0123456789abcdef0123456789abcdef";
1084 let checkpoint = write_checkpoint_gate_archive(archive_directory.path(), session_id, 7);
1085 let mut session = managed_worktree_session(repository.path(), session_id);
1086 let worktree = session.managed_worktree.clone().unwrap();
1087 session.state = SessionState::Running;
1088 session.target = Some(TargetLocator::LocalBare {
1089 worker_root: archive_directory.path().join(session_id),
1090 });
1091 session.checkpoint = Some(checkpoint.clone());
1092 let mut config = HelConfig::default();
1093 config
1094 .targets
1095 .insert("local-bare".into(), TargetTemplate::LocalBare);
1096 let mut controller = Controller {
1097 config,
1098 state: HelState {
1099 sessions: BTreeMap::from([(session_id.into(), session)]),
1100 ..HelState::default()
1101 },
1102 };
1103
1104 controller
1105 .force_stop_with(session_id, &ProcessExecutor, |_| Ok(()))
1106 .unwrap();
1107
1108 let stopped = &controller.state.sessions[session_id];
1109 assert_eq!(stopped.state, SessionState::Stopped);
1110 assert!(stopped.target.is_none());
1111 assert_eq!(stopped.checkpoint.as_ref(), Some(&checkpoint));
1112 assert!(checkpoint.archive_path.is_file());
1113 assert!(!worktree.worktree_root.exists());
1114 assert!(
1115 !test_git(
1116 repository.path(),
1117 &[
1118 "show-ref",
1119 "--hash",
1120 &format!("refs/heads/{}", worktree.branch),
1121 ],
1122 )
1123 .is_empty()
1124 );
1125 }
1126 #[test]
1127 fn force_stop_without_a_recovery_archive_does_not_touch_the_target() {
1128 struct RecordingExecutor {
1129 calls: RefCell<usize>,
1130 }
1131
1132 impl CommandExecutor for RecordingExecutor {
1133 fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
1134 *self.calls.borrow_mut() += 1;
1135 Ok(CommandOutput {
1136 status: 0,
1137 stdout: Vec::new(),
1138 stderr: Vec::new(),
1139 })
1140 }
1141 }
1142
1143 let directory = tempfile::tempdir().unwrap();
1144 let session_id = "0123456789abcdef0123456789abcdef";
1145 let mut session = checkpoint_test_session(session_id);
1146 session.state = SessionState::Running;
1147 session.checkpoint = None;
1148 session.target_template_id = "local".into();
1149 session.target = Some(TargetLocator::LocalBare {
1150 worker_root: directory.path().join(session_id),
1151 });
1152 let mut config = HelConfig::default();
1153 config
1154 .targets
1155 .insert("local".into(), TargetTemplate::LocalBare);
1156 let mut controller = Controller {
1157 config,
1158 state: HelState {
1159 sessions: BTreeMap::from([(session_id.into(), session)]),
1160 ..HelState::default()
1161 },
1162 };
1163 let executor = RecordingExecutor {
1164 calls: RefCell::new(0),
1165 };
1166
1167 let error = controller
1168 .force_stop_with(session_id, &executor, |_| Ok(()))
1169 .unwrap_err();
1170
1171 assert!(error.to_string().contains("existing recovery archive"));
1172 assert_eq!(*executor.calls.borrow(), 0);
1173 assert_eq!(
1174 controller.state.sessions[session_id].state,
1175 SessionState::Running
1176 );
1177 assert!(controller.state.sessions[session_id].target.is_some());
1178 }
1179 #[test]
1180 fn destroying_retry_blocks_cleanup_when_the_archive_gate_changed() {
1181 struct RecordingExecutor {
1182 calls: RefCell<usize>,
1183 }
1184
1185 impl CommandExecutor for RecordingExecutor {
1186 fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
1187 *self.calls.borrow_mut() += 1;
1188 Ok(CommandOutput {
1189 status: 0,
1190 stdout: Vec::new(),
1191 stderr: Vec::new(),
1192 })
1193 }
1194 }
1195
1196 let directory = tempfile::tempdir().unwrap();
1197 let repository = committed_repository();
1198 let session_id = "0123456789abcdef0123456789abcdef";
1199 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1200 let mut session = managed_worktree_session(repository.path(), session_id);
1201 let worktree = session.managed_worktree.clone().unwrap();
1202 session.target_template_id = "local".into();
1203 session.state = SessionState::Destroying;
1204 session.target = Some(TargetLocator::LocalBare {
1205 worker_root: directory.path().join(session_id),
1206 });
1207 session.checkpoint = Some(checkpoint.clone());
1208 let mut config = HelConfig::default();
1209 config
1210 .targets
1211 .insert("local".into(), TargetTemplate::LocalBare);
1212 let mut controller = Controller {
1213 config,
1214 state: HelState {
1215 sessions: BTreeMap::from([(session_id.into(), session)]),
1216 ..HelState::default()
1217 },
1218 };
1219 let executor = RecordingExecutor {
1220 calls: RefCell::new(0),
1221 };
1222 let persisted = RefCell::new(Vec::new());
1223 std::fs::write(&checkpoint.archive_path, b"changed after checkpoint").unwrap();
1224
1225 let error = controller
1226 .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
1227 persisted.borrow_mut().push(record.state);
1228 Ok(())
1229 })
1230 .unwrap_err();
1231
1232 assert!(error.to_string().contains("checkpoint SHA changed"));
1233 assert_eq!(*executor.calls.borrow(), 0);
1234 assert!(persisted.into_inner().is_empty());
1235 assert!(worktree.worktree_root.is_dir());
1236 assert_eq!(
1237 controller.state.sessions[session_id].state,
1238 SessionState::Destroying
1239 );
1240 }
1241 #[test]
1242 fn destroying_retry_finalizes_when_apple_container_is_confirmed_absent() {
1243 struct AlreadyRemovedExecutor {
1244 commands: RefCell<Vec<CommandSpec>>,
1245 }
1246
1247 impl CommandExecutor for AlreadyRemovedExecutor {
1248 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1249 self.commands.borrow_mut().push(command.clone());
1250 if command.program == "sh"
1251 && command
1252 .args
1253 .get(1)
1254 .is_some_and(|script| script.contains("container rm --force"))
1255 {
1256 Ok(CommandOutput {
1257 status: 1,
1258 stdout: Vec::new(),
1259 stderr: b"container not found".to_vec(),
1260 })
1261 } else {
1262 Ok(CommandOutput {
1263 status: 0,
1264 stdout: Vec::new(),
1265 stderr: Vec::new(),
1266 })
1267 }
1268 }
1269 }
1270
1271 let directory = tempfile::tempdir().unwrap();
1272 let session_id = "0123456789abcdef0123456789abcdef";
1273 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1274 let mut session = checkpoint_test_session(session_id);
1275 session.target_template_id = "apple".into();
1276 session.state = SessionState::Destroying;
1277 session.target = Some(TargetLocator::AppleContainer {
1278 container_id: hel_targets::resource_name(session_id).unwrap(),
1279 });
1280 session.checkpoint = Some(checkpoint.clone());
1281 let mut config = HelConfig::default();
1282 config.targets.insert(
1283 "apple".into(),
1284 TargetTemplate::AppleContainer {
1285 container: ConfigContainer {
1286 image: "test:latest".into(),
1287 pull_policy: Default::default(),
1288 platform: None,
1289 cpus: None,
1290 memory: None,
1291 environment: BTreeMap::new(),
1292 workspace_storage: Default::default(),
1293 },
1294 },
1295 );
1296 let mut controller = Controller {
1297 config,
1298 state: HelState {
1299 sessions: BTreeMap::from([(session_id.into(), session)]),
1300 ..HelState::default()
1301 },
1302 };
1303 let executor = AlreadyRemovedExecutor {
1304 commands: RefCell::new(Vec::new()),
1305 };
1306 let persisted = RefCell::new(Vec::new());
1307
1308 controller
1309 .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
1310 persisted.borrow_mut().push(record.state);
1311 Ok(())
1312 })
1313 .unwrap();
1314
1315 let commands = executor.commands.borrow();
1316 assert_eq!(commands.len(), 2);
1317 assert_eq!(commands[0].program, "sh");
1318 assert!(commands[0].args[1].contains("container rm --force"));
1319 assert!(commands[0].args[1].contains(".cache/mjolnir/git/sessions"));
1320 assert_eq!(commands[1].args, ["list", "--all", "--quiet"]);
1321 assert_eq!(persisted.into_inner(), vec![SessionState::Stopped]);
1322 assert_eq!(
1323 controller.state.sessions[session_id].state,
1324 SessionState::Stopped
1325 );
1326 }
1327 #[cfg(unix)]
1332 #[test]
1333 fn every_session_ending_retires_its_local_git_broker() {
1334 const RETIREMENT_TEST_CHILD: &str = "MJ_TEST_BROKER_RETIREMENT_CHILD";
1335
1336 struct SucceedingExecutor;
1337
1338 impl CommandExecutor for SucceedingExecutor {
1339 fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
1340 Ok(CommandOutput {
1341 status: 0,
1342 stdout: Vec::new(),
1343 stderr: Vec::new(),
1344 })
1345 }
1346 }
1347
1348 if std::env::var_os(RETIREMENT_TEST_CHILD).is_none() {
1351 let directory = tempfile::tempdir().unwrap();
1352 let test_name = format!(
1353 "{}::every_session_ending_retires_its_local_git_broker",
1354 module_path!()
1355 .strip_prefix("mj_controller::")
1356 .unwrap_or(module_path!())
1357 );
1358 let output = std::process::Command::new(std::env::current_exe().unwrap())
1359 .args(["--exact", &test_name, "--nocapture"])
1360 .env(RETIREMENT_TEST_CHILD, "1")
1361 .env("MJ_DATA_DIR", directory.path())
1362 .output()
1363 .unwrap();
1364 let reported = String::from_utf8_lossy(&output.stdout).into_owned();
1365 assert!(
1366 output.status.success(),
1367 "isolated broker retirement test failed\nstdout:\n{reported}\nstderr:\n{}",
1368 String::from_utf8_lossy(&output.stderr)
1369 );
1370 assert!(
1373 reported.contains("1 passed"),
1374 "the isolated broker retirement test never ran\nstdout:\n{reported}"
1375 );
1376 return;
1377 }
1378 let _writer = hel::hel_database::install_isolated_test_writer();
1380
1381 let brokers = hel::hel_config::data_dir().join("git-brokers");
1382 std::fs::create_dir_all(&brokers).unwrap();
1383 let seed_broker = |session_id: &str| {
1384 for (extension, contents) in [
1385 ("json", "{}"),
1386 ("pid", "424242"),
1390 ("ready", "ready\n"),
1391 ("log", "broker log\n"),
1392 ] {
1393 std::fs::write(brokers.join(format!("{session_id}.{extension}")), contents)
1394 .unwrap();
1395 }
1396 };
1397 let assert_retired = |session_id: &str| {
1398 for extension in ["json", "pid", "ready"] {
1399 let path = brokers.join(format!("{session_id}.{extension}"));
1400 assert!(!path.exists(), "{} outlived its session", path.display());
1401 }
1402 assert_eq!(
1403 std::fs::read_to_string(brokers.join(format!("{session_id}.log"))).unwrap(),
1404 "broker log\n",
1405 "the broker log must survive its session"
1406 );
1407 };
1408
1409 let directory = tempfile::tempdir().unwrap();
1410 let closing = "0123456789abcdef0123456789abcdef";
1411 let force_stopped = "0123456789abcdef0123456789abcdee";
1412 let destroyed = "0123456789abcdef0123456789abcded";
1413 let checkpoint = write_checkpoint_gate_archive(directory.path(), closing, 7);
1414 let mut closing_session = checkpoint_test_session(closing);
1415 closing_session.target_template_id = "local".into();
1416 closing_session.state = SessionState::Closing;
1417 closing_session.target = Some(TargetLocator::LocalBare {
1418 worker_root: directory.path().join(closing),
1419 });
1420 closing_session.checkpoint = Some(checkpoint.clone());
1421 let force_stop_checkpoint =
1422 write_checkpoint_gate_archive(directory.path(), force_stopped, 7);
1423 let mut force_stopped_session = checkpoint_test_session(force_stopped);
1424 force_stopped_session.target_template_id = "local".into();
1425 force_stopped_session.state = SessionState::Running;
1426 force_stopped_session.target = Some(TargetLocator::LocalBare {
1427 worker_root: directory.path().join(force_stopped),
1428 });
1429 force_stopped_session.checkpoint = Some(force_stop_checkpoint);
1430 let mut destroyed_session = checkpoint_test_session(destroyed);
1431 destroyed_session.target_template_id = "local".into();
1432 destroyed_session.state = SessionState::Stopped;
1433 let mut config = HelConfig::default();
1434 config
1435 .targets
1436 .insert("local".into(), TargetTemplate::LocalBare);
1437 let mut controller = Controller {
1438 config,
1439 state: HelState {
1440 sessions: BTreeMap::from([
1441 (closing.into(), closing_session),
1442 (force_stopped.into(), force_stopped_session),
1443 (destroyed.into(), destroyed_session),
1444 ]),
1445 ..HelState::default()
1446 },
1447 };
1448 for session_id in [closing, force_stopped, destroyed] {
1449 seed_broker(session_id);
1450 }
1451
1452 controller
1453 .destroy_after_verified_checkpoint_with(
1454 closing,
1455 &checkpoint,
1456 &SucceedingExecutor,
1457 |_| Ok(()),
1458 )
1459 .unwrap();
1460 assert_retired(closing);
1461
1462 controller
1463 .force_stop_with(force_stopped, &SucceedingExecutor, |_| Ok(()))
1464 .unwrap();
1465 assert_retired(force_stopped);
1466
1467 controller
1468 .destroy_session_controlled(destroyed, &SucceedingExecutor)
1469 .unwrap();
1470 assert_retired(destroyed);
1471 }
1472 #[test]
1473 fn interrupted_close_error_preserves_destroying_phase() {
1474 let session_id = "0123456789abcdef0123456789abcdef";
1475 let mut session = checkpoint_test_session(session_id);
1476 session.state = SessionState::Destroying;
1477
1478 apply_interrupted_close_error(
1479 &mut session,
1480 &anyhow::anyhow!("podman unavailable"),
1481 "2026-08-14T12:00:00Z",
1482 );
1483
1484 assert_eq!(session.state, SessionState::Destroying);
1485 assert_eq!(session.updated_at, "2026-08-14T12:00:00Z");
1486 assert!(
1487 session
1488 .last_error
1489 .as_deref()
1490 .is_some_and(|error| error.contains("cleanup is safely retryable"))
1491 );
1492 }
1493
1494 struct FailingExecutor;
1495
1496 impl CommandExecutor for FailingExecutor {
1497 fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
1498 Ok(CommandOutput {
1499 status: 1,
1500 stdout: Vec::new(),
1501 stderr: b"teardown unavailable".to_vec(),
1502 })
1503 }
1504 }
1505
1506 fn branch_exists(repository: &std::path::Path, branch: &str) -> bool {
1507 std::process::Command::new("git")
1508 .arg("-C")
1509 .arg(repository)
1510 .args(["show-ref", "--verify", "--quiet"])
1511 .arg(format!("refs/heads/{branch}"))
1512 .output()
1513 .unwrap()
1514 .status
1515 .success()
1516 }
1517
1518 #[test]
1519 fn force_destroy_from_running_removes_target_worktree_branch_and_archive() {
1520 let directory = tempfile::tempdir().unwrap();
1521 let repository = committed_repository();
1522 let session_id = "0123456789abcdef0123456789abcdef";
1523 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1524 let worker_root = directory.path().join(session_id);
1525 std::fs::create_dir_all(&worker_root).unwrap();
1526 let mut session = managed_worktree_session(repository.path(), session_id);
1527 session.state = SessionState::Running;
1528 session.target_template_id = "local".into();
1529 session.target = Some(TargetLocator::LocalBare {
1530 worker_root: worker_root.clone(),
1531 });
1532 session.checkpoint = Some(checkpoint.clone());
1533 let mut config = HelConfig::default();
1534 config
1535 .targets
1536 .insert("local".into(), TargetTemplate::LocalBare);
1537 let mut controller = Controller {
1538 config,
1539 state: HelState {
1540 sessions: BTreeMap::from([(session_id.into(), session)]),
1541 ..HelState::default()
1542 },
1543 };
1544 let deleted = RefCell::new(Vec::new());
1545
1546 controller
1547 .force_destroy_session_with(session_id, &ProcessExecutor, |id| {
1548 deleted.borrow_mut().push(id.to_owned());
1549 Ok(())
1550 })
1551 .unwrap();
1552
1553 assert!(!worker_root.exists(), "local target must be removed");
1554 let worktree_root = repository.path().join(".mj/worktrees").join(session_id);
1555 assert!(!worktree_root.exists(), "managed worktree must be removed");
1556 assert!(
1557 !branch_exists(repository.path(), &format!("mj/{session_id}")),
1558 "generated branch must be removed"
1559 );
1560 assert!(!checkpoint.archive_path.exists(), "archive must be removed");
1561 assert!(!controller.state.sessions.contains_key(session_id));
1562 assert_eq!(deleted.into_inner(), vec![session_id.to_owned()]);
1563 }
1564
1565 #[test]
1566 fn force_destroy_without_a_target_or_archive_still_removes_the_record() {
1567 let session_id = "0123456789abcdef0123456789abcdef";
1568 let mut session = checkpoint_test_session(session_id);
1569 session.state = SessionState::Provisioning;
1570 session.target = None;
1571 session.checkpoint = None;
1572 let mut controller = Controller {
1573 config: HelConfig::default(),
1574 state: HelState {
1575 sessions: BTreeMap::from([(session_id.into(), session)]),
1576 ..HelState::default()
1577 },
1578 };
1579 let deleted = RefCell::new(Vec::new());
1580
1581 controller
1582 .force_destroy_session_with(session_id, &ProcessExecutor, |id| {
1583 deleted.borrow_mut().push(id.to_owned());
1584 Ok(())
1585 })
1586 .unwrap();
1587
1588 assert!(!controller.state.sessions.contains_key(session_id));
1589 assert_eq!(deleted.into_inner(), vec![session_id.to_owned()]);
1590 }
1591
1592 #[test]
1593 fn force_destroy_aborts_and_keeps_the_record_when_the_target_survives() {
1594 let directory = tempfile::tempdir().unwrap();
1595 let session_id = "0123456789abcdef0123456789abcdef";
1596 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1597 let mut session = checkpoint_test_session(session_id);
1598 session.target_template_id = "local".into();
1599 session.state = SessionState::Running;
1600 session.target = Some(TargetLocator::LocalBare {
1601 worker_root: directory.path().join(session_id),
1602 });
1603 session.checkpoint = Some(checkpoint.clone());
1604 let mut config = HelConfig::default();
1605 config
1606 .targets
1607 .insert("local".into(), TargetTemplate::LocalBare);
1608 let mut controller = Controller {
1609 config,
1610 state: HelState {
1611 sessions: BTreeMap::from([(session_id.into(), session)]),
1612 ..HelState::default()
1613 },
1614 };
1615 let deleted = RefCell::new(Vec::new());
1616
1617 let error = controller
1618 .force_destroy_session_with(session_id, &FailingExecutor, |id| {
1619 deleted.borrow_mut().push(id.to_owned());
1620 Ok(())
1621 })
1622 .unwrap_err();
1623
1624 assert!(
1625 error.to_string().contains("teardown unavailable"),
1626 "{error:#}"
1627 );
1628 assert!(
1629 controller.state.sessions.contains_key(session_id),
1630 "a surviving target must keep the record for a retry"
1631 );
1632 assert!(
1633 checkpoint.archive_path.exists(),
1634 "a surviving target must keep the recovery archive"
1635 );
1636 assert!(deleted.into_inner().is_empty());
1637 }
1638
1639 #[test]
1640 fn force_destroy_tolerates_a_missing_archive() {
1641 let directory = tempfile::tempdir().unwrap();
1642 let session_id = "0123456789abcdef0123456789abcdef";
1643 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1644 std::fs::remove_file(&checkpoint.archive_path).unwrap();
1645 let mut session = checkpoint_test_session(session_id);
1646 session.state = SessionState::Error;
1647 session.checkpoint = Some(checkpoint);
1648 let mut controller = Controller {
1649 config: HelConfig::default(),
1650 state: HelState {
1651 sessions: BTreeMap::from([(session_id.into(), session)]),
1652 ..HelState::default()
1653 },
1654 };
1655
1656 controller
1657 .force_destroy_session_with(session_id, &ProcessExecutor, |_| Ok(()))
1658 .unwrap();
1659
1660 assert!(!controller.state.sessions.contains_key(session_id));
1661 }
1662}