1use std::time::Duration;
4
5use anyhow::{Context, Result, bail, ensure};
6
7use crate::session_manager::{SessionManagerControl, new_command_id};
8use mj_core::state::{CheckpointMetadata, SessionRecord, SessionState};
9
10use crate::targets::{self, CommandExecutor, ProcessExecutor, ProvisionStage, ProvisionStageGuard};
11use mj_core::relay::{RelayCommand, RelayExecutionState};
12
13use super::backend::backend_locator;
14use super::checkpoint::{
15 CheckpointExportPolicy, LatchExclusivity, prune_replaced_checkpoint,
16 release_projection_behind_checkpoint, verify_installed_checkpoint_gate, wait_for_relay_closed,
17};
18use super::worktree::{cleanup_managed_worktree, retire_managed_worktree};
19use super::{Controller, now, persist_session_record_transition_or_restore};
20
21impl Controller {
22 pub async fn close_session(&mut self, session_id: &str) -> Result<()> {
25 self.close_session_controlled(session_id, &ProcessExecutor)
26 .await
27 }
28
29 pub async fn close_session_controlled(
30 &mut self,
31 session_id: &str,
32 executor: &(impl CommandExecutor + Sync),
33 ) -> Result<()> {
34 if self
35 .close_session_controlled_with_manager(session_id, executor, None, None)
36 .await?
37 {
38 self.cleanup_stopped_target(session_id, executor)?;
39 }
40 Ok(())
41 }
42
43 pub async fn close_session_managed_controlled(
44 &mut self,
45 session_id: &str,
46 executor: &(impl CommandExecutor + Sync),
47 manager: &SessionManagerControl,
48 ) -> Result<bool> {
49 self.close_session_controlled_with_manager(session_id, executor, Some(manager), None)
50 .await
51 }
52
53 pub(super) async fn close_session_for_move(
54 &mut self,
55 session_id: &str,
56 executor: &(impl CommandExecutor + Sync),
57 manager: &SessionManagerControl,
58 operation: &mut mj_core::state::MoveOperation,
59 preparation: Option<&mj_core::state::MovePreparation>,
60 ) -> Result<bool> {
61 self.prepare_move_source_checkpoint(session_id, executor, manager, operation)
62 .await?;
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 mj_core::state::MoveOperation,
79 Option<&mj_core::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 crate::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 crate::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 target_mutex = crate::recovery_gate::worker_target_mutex(session_id);
305 let _target_guard = target_mutex.lock().map_err(|_| {
306 anyhow::anyhow!("worker target ownership lock poisoned for {session_id}")
307 })?;
308 let session = self
309 .state
310 .sessions
311 .get(session_id)
312 .with_context(|| format!("unknown session {session_id}"))?
313 .clone();
314 ensure!(
315 matches!(
316 session.state,
317 SessionState::Closing | SessionState::Destroying
318 ),
319 "refusing to destroy session {session_id}: it is not closing or destroying"
320 );
321 ensure!(
322 session.checkpoint.as_ref() == Some(verified),
323 "refusing to destroy session {session_id}: verified checkpoint gate is stale"
324 );
325 if session.state == SessionState::Closing {
326 let record = self.state.sessions.get_mut(session_id).unwrap();
327 record.state = SessionState::Destroying;
328 record.updated_at = now();
329 record.last_error = None;
330 persist_session_record_transition_or_restore(
331 &mut self.state,
332 session_id,
333 &session,
334 "persist destroying state before target cleanup",
335 &persist,
336 )?;
337 }
338
339 let destroying = self
340 .state
341 .sessions
342 .get(session_id)
343 .expect("destroying session disappeared")
344 .clone();
345 {
346 let _verifying = ProvisionStageGuard::new(executor, ProvisionStage::Verifying);
347 verify_installed_checkpoint_gate(session_id, verified)?;
348 }
349 if let Err(error) = crate::database::lose_reviewer_continuity(session_id) {
354 tracing::warn!(
355 session_id,
356 error = format!("{error:#}"),
357 "could not record that the second-opinion conversation ends with this target"
358 );
359 }
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 self.state.subagents.contains_key(session_id) {
366 targets::borrowed_worker_cleanup_plan(&backend, session_id)?.execute(executor)?;
367 false
368 } else if let Some(plan) = targets::quiesce_plan(&backend, session_id)? {
369 plan.execute(executor)?;
370 true
371 } else {
372 execute_target_cleanup(&backend, session_id, executor)?;
373 false
374 };
375 if let Some(worktree) = &destroying.managed_worktree {
376 retire_managed_worktree(executor, worktree)
377 .context("retire managed raw-session worktree after verified close")?;
378 }
379 let record = self.state.sessions.get_mut(session_id).unwrap();
380 record.state = SessionState::Stopped;
381 if !deferred {
382 record.target = None;
383 }
384 record.updated_at = now();
385 record.last_error = None;
386 persist_session_record_transition_or_restore(
387 &mut self.state,
388 session_id,
389 &destroying,
390 "persist stopped state after target cleanup",
391 &persist,
392 )?;
393 Ok(deferred)
394 }
395
396 pub fn cleanup_stopped_target(
400 &mut self,
401 session_id: &str,
402 executor: &impl CommandExecutor,
403 ) -> Result<()> {
404 self.cleanup_stopped_target_with(
405 session_id,
406 executor,
407 crate::database::save_lifecycle_session,
408 )
409 }
410
411 fn cleanup_stopped_target_with(
412 &mut self,
413 session_id: &str,
414 executor: &impl CommandExecutor,
415 persist: impl Fn(&SessionRecord) -> Result<()>,
416 ) -> Result<()> {
417 let target_mutex = crate::recovery_gate::worker_target_mutex(session_id);
418 let _target_guard = target_mutex.lock().map_err(|_| {
419 anyhow::anyhow!("worker target ownership lock poisoned for {session_id}")
420 })?;
421 let previous = self
422 .state
423 .sessions
424 .get(session_id)
425 .with_context(|| format!("unknown session {session_id}"))?
426 .clone();
427 ensure!(
428 previous.state == SessionState::Stopped,
429 "refusing deferred cleanup for active session {session_id}"
430 );
431 let Some(locator) = previous.target.as_ref() else {
432 return Ok(());
433 };
434 let backend = backend_locator(locator, &previous, &self.config)?;
435 ensure!(
436 targets::quiesce_plan(&backend, session_id)?.is_some(),
437 "session {session_id} retained a non-Podman target after stopping"
438 );
439 if let Err(error) = execute_target_cleanup(&backend, session_id, executor) {
440 let record = self.state.sessions.get_mut(session_id).unwrap();
441 record.updated_at = now();
442 record.last_error = Some(format!("deferred target cleanup failed: {error:#}"));
443 let persisted = persist_session_record_transition_or_restore(
444 &mut self.state,
445 session_id,
446 &previous,
447 "persist deferred target cleanup failure",
448 &persist,
449 );
450 return match persisted {
451 Ok(()) => Err(error),
452 Err(persist_error) => Err(error.context(format!(
453 "also failed to persist deferred target cleanup failure: {persist_error:#}"
454 ))),
455 };
456 }
457 let record = self.state.sessions.get_mut(session_id).unwrap();
458 record.target = None;
459 record.updated_at = now();
460 record.last_error = None;
461 persist_session_record_transition_or_restore(
462 &mut self.state,
463 session_id,
464 &previous,
465 "persist completion of deferred Podman target cleanup",
466 &persist,
467 )
468 }
469
470 pub fn force_stop(
473 &mut self,
474 session_id: &str,
475 executor: &impl CommandExecutor,
476 ) -> Result<bool> {
477 self.force_stop_with(
478 session_id,
479 executor,
480 crate::database::save_lifecycle_session,
481 )
482 }
483
484 fn force_stop_with(
485 &mut self,
486 session_id: &str,
487 executor: &impl CommandExecutor,
488 persist: impl Fn(&SessionRecord) -> Result<()>,
489 ) -> Result<bool> {
490 let session = self
491 .state
492 .sessions
493 .get(session_id)
494 .with_context(|| format!("unknown session {session_id}"))?
495 .clone();
496 ensure!(
497 session.state.is_active(),
498 "session {session_id} is already inactive"
499 );
500 let checkpoint = session
501 .checkpoint
502 .as_ref()
503 .context("force stop requires an existing recovery archive")?;
504 {
507 let _verifying = ProvisionStageGuard::new(executor, ProvisionStage::Verifying);
508 verify_installed_checkpoint_gate(session_id, checkpoint)
509 .context("verify the recovery archive before force stopping")?;
510 }
511 let mut deferred = false;
512 if let Some(locator) = &session.target {
513 let backend = backend_locator(locator, &session, &self.config)?;
514 if let Some(plan) = targets::quiesce_plan(&backend, session_id)? {
515 plan.execute(executor)?;
516 deferred = true;
517 } else {
518 execute_target_cleanup(&backend, session_id, executor)?;
519 }
520 }
521 if let Some(worktree) = &session.managed_worktree {
522 retire_managed_worktree(executor, worktree)
523 .context("retire managed raw-session worktree after force stop")?;
524 }
525 let record = self.state.sessions.get_mut(session_id).unwrap();
526 record.state = SessionState::Stopped;
527 if !deferred {
528 record.target = None;
529 }
530 record.updated_at = now();
531 record.last_error = None;
532 record.last_checkpoint_error = None;
533 persist_session_record_transition_or_restore(
534 &mut self.state,
535 session_id,
536 &session,
537 "persist stopped state after force stopping the current target",
538 &persist,
539 )?;
540 Ok(deferred)
541 }
542
543 pub fn destroy_session_controlled(
547 &mut self,
548 session_id: &str,
549 executor: &impl CommandExecutor,
550 ) -> Result<()> {
551 let session = self
552 .state
553 .sessions
554 .get(session_id)
555 .with_context(|| format!("unknown session {session_id}"))?
556 .clone();
557 if session.state.is_active() {
558 bail!("refusing to destroy active session {session_id}");
559 }
560 if let Some(worktree) = &session.managed_worktree {
561 cleanup_managed_worktree(executor, worktree)
562 .context("remove managed raw-session worktree")?;
563 }
564 if let Some(checkpoint) = &session.checkpoint
565 && let Err(error) = std::fs::remove_file(&checkpoint.archive_path)
566 && error.kind() != std::io::ErrorKind::NotFound
567 {
568 return Err(error).with_context(|| {
569 format!(
570 "remove session recovery archive {}",
571 checkpoint.archive_path.display()
572 )
573 });
574 }
575 mj_core::attachment::AttachmentStore::controller(session_id)?
576 .remove_session_data()
577 .context("remove session image attachments")?;
578 crate::database::delete_session(session_id)
579 .context("destroy stopped session in database")?;
580 self.state.subagents.remove(session_id);
581 self.state.destroy_stopped_session(session_id)?;
582 Ok(())
583 }
584
585 pub fn force_destroy_session(
595 &mut self,
596 session_id: &str,
597 executor: &impl CommandExecutor,
598 ) -> Result<()> {
599 self.force_destroy_session_with(session_id, executor, crate::database::delete_session)
600 }
601
602 fn force_destroy_session_with(
603 &mut self,
604 session_id: &str,
605 executor: &impl CommandExecutor,
606 delete: impl Fn(&str) -> Result<()>,
607 ) -> Result<()> {
608 let session = self
609 .state
610 .sessions
611 .get(session_id)
612 .with_context(|| format!("unknown session {session_id}"))?
613 .clone();
614 if let Some(locator) = &session.target {
618 let backend = backend_locator(locator, &session, &self.config)?;
619 if self.state.subagents.contains_key(session_id) {
620 targets::borrowed_worker_cleanup_plan(&backend, session_id)?.execute(executor)?;
621 } else {
622 execute_target_cleanup(&backend, session_id, executor)?;
623 }
624 }
625 if let Some(worktree) = &session.managed_worktree {
626 cleanup_managed_worktree(executor, worktree)
627 .context("remove managed raw-session worktree")?;
628 }
629 if let Some(checkpoint) = &session.checkpoint
630 && let Err(error) = std::fs::remove_file(&checkpoint.archive_path)
631 && error.kind() != std::io::ErrorKind::NotFound
632 {
633 return Err(error).with_context(|| {
634 format!(
635 "remove session recovery archive {}",
636 checkpoint.archive_path.display()
637 )
638 });
639 }
640 mj_core::attachment::AttachmentStore::controller(session_id)?
641 .remove_session_data()
642 .context("remove session image attachments")?;
643 delete(session_id).context("force destroy session in database")?;
644 self.state.subagents.remove(session_id);
645 self.state.destroy_session_force(session_id)?;
646 Ok(())
647 }
648}
649
650fn execute_target_cleanup(
651 backend: &targets::TargetLocator,
652 session_id: &str,
653 executor: &impl CommandExecutor,
654) -> Result<()> {
655 if let Err(cleanup_error) = targets::close_plan(backend, session_id)?.execute(executor) {
656 match targets::cleanup_target_is_confirmed_absent(backend, session_id, executor) {
657 Ok(true) => {
658 tracing::warn!(
659 session_id,
660 error = format!("{cleanup_error:#}"),
661 "target cleanup command failed, but the target was confirmed absent"
662 );
663 }
664 Ok(false) => {
665 tracing::error!(
666 session_id,
667 error = format!("{cleanup_error:#}"),
668 "target cleanup failed and the target is still present"
669 );
670 return Err(cleanup_error);
671 }
672 Err(probe_error) => {
673 tracing::error!(
674 session_id,
675 cleanup_error = format!("{cleanup_error:#}"),
676 probe_error = format!("{probe_error:#}"),
677 "target cleanup failed and exact absence could not be confirmed"
678 );
679 return Err(cleanup_error.context(format!(
680 "target cleanup failed and exact absence could not be confirmed: {probe_error:#}"
681 )));
682 }
683 }
684 }
685 Ok(())
686}
687
688fn apply_close_checkpoint_started(record: &mut SessionRecord, updated_at: String) {
689 record.state = SessionState::Closing;
690 record.updated_at = updated_at;
691 record.last_checkpoint_error = None;
692}
693
694fn apply_interrupted_close_error(
695 record: &mut SessionRecord,
696 error: &anyhow::Error,
697 updated_at: &str,
698) {
699 let destroying = record.state == SessionState::Destroying;
700 if !destroying {
701 record.state = SessionState::Closing;
702 }
703 record.updated_at = updated_at.to_owned();
704 record.last_error = Some(if destroying {
705 format!("target cleanup is safely retryable from its verified checkpoint: {error:#}")
706 } else {
707 format!("close is safely resumable from its verified checkpoint: {error:#}")
708 });
709}
710
711#[cfg(test)]
712mod tests {
713 use std::cell::RefCell;
714 use std::collections::BTreeMap;
715
716 use anyhow::Result;
717
718 use crate::controller::Controller;
719 use crate::controller::test_support::{
720 checkpoint_test_session, committed_repository, managed_worktree_session, test_git,
721 write_checkpoint_gate_archive,
722 };
723 use mj_core::config::{Config, ContainerTemplate as ConfigContainer, TargetTemplate};
724 use mj_core::state::{SessionState, State, TargetLocator};
725
726 use crate::targets::{self, CommandExecutor, CommandOutput, CommandSpec, ProcessExecutor};
727
728 use super::*;
729
730 #[test]
731 fn starting_close_persists_its_intent_before_checkpointing() {
732 let mut session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
733 session.state = SessionState::Running;
734 session.last_checkpoint_error = Some("old failure".into());
735
736 apply_close_checkpoint_started(&mut session, "2026-08-14T12:00:00Z".into());
737
738 assert_eq!(session.state, SessionState::Closing);
739 assert_eq!(session.updated_at, "2026-08-14T12:00:00Z");
740 assert!(session.last_checkpoint_error.is_none());
741 }
742
743 struct DeferredCleanupExecutor {
744 statuses: RefCell<Vec<i32>>,
745 }
746
747 impl CommandExecutor for DeferredCleanupExecutor {
748 fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
749 let status = self
750 .statuses
751 .borrow_mut()
752 .pop()
753 .expect("test cleanup command status");
754 Ok(CommandOutput {
755 status,
756 stdout: Vec::new(),
757 stderr: if status != 0 {
758 b"cleanup failed".to_vec()
759 } else {
760 Vec::new()
761 },
762 })
763 }
764 }
765
766 fn stopped_podman_cleanup_controller(session_id: &str) -> Controller {
767 let container_id = targets::resource_name(session_id).unwrap();
768 let volume = format!("{container_id}-workspace");
769 let mut session = checkpoint_test_session(session_id);
770 session.target_template_id = "podman".into();
771 session.state = SessionState::Stopped;
772 session.target = Some(TargetLocator::LocalPodman {
773 container_id,
774 workspace_storage: mj_core::state::PodmanWorkspaceLocator::Volume { name: volume },
775 });
776 let mut config = Config::default();
777 config.targets.insert(
778 "podman".into(),
779 TargetTemplate::LocalPodman {
780 container: ConfigContainer {
781 image: "test:latest".into(),
782 pull_policy: Default::default(),
783 platform: None,
784 cpus: None,
785 memory: None,
786 environment: BTreeMap::new(),
787 workspace_storage: mj_core::config::PodmanWorkspaceStorage::PodmanVolume,
788 },
789 },
790 );
791 Controller {
792 config,
793 state: State {
794 sessions: BTreeMap::from([(session_id.into(), session)]),
795 ..State::default()
796 },
797 }
798 }
799
800 #[test]
801 fn deferred_cleanup_failure_is_visible_and_successful_retry_clears_it() {
802 let session_id = "0123456789abcdef0123456789abcdef";
803 let mut controller = stopped_podman_cleanup_controller(session_id);
804 let persisted = RefCell::new(Vec::new());
805 let failure = controller
806 .cleanup_stopped_target_with(
807 session_id,
808 &DeferredCleanupExecutor {
809 statuses: RefCell::new(vec![1, 1]),
812 },
813 |record| {
814 persisted
815 .borrow_mut()
816 .push((record.target.is_some(), record.last_error.clone()));
817 Ok(())
818 },
819 )
820 .unwrap_err();
821 assert!(format!("{failure:#}").contains("cleanup failed"));
822 assert_eq!(persisted.borrow().len(), 1);
823 assert!(persisted.borrow()[0].0);
824 assert!(
825 persisted.borrow()[0]
826 .1
827 .as_deref()
828 .is_some_and(|error| error.contains("deferred target cleanup failed"))
829 );
830 assert!(controller.state.sessions[session_id].target.is_some());
831 assert!(controller.state.sessions[session_id].last_error.is_some());
832
833 let retry_persisted = RefCell::new(Vec::new());
834 controller
835 .cleanup_stopped_target_with(
836 session_id,
837 &DeferredCleanupExecutor {
838 statuses: RefCell::new(vec![0, 0, 0]),
839 },
840 |record| {
841 retry_persisted
842 .borrow_mut()
843 .push((record.target.is_some(), record.last_error.clone()));
844 Ok(())
845 },
846 )
847 .unwrap();
848 assert_eq!(retry_persisted.borrow().as_slice(), &[(false, None)]);
849 assert!(controller.state.sessions[session_id].target.is_none());
850 assert!(controller.state.sessions[session_id].last_error.is_none());
851 }
852
853 #[test]
854 fn deferred_cleanup_persistence_failure_restores_the_stopped_record() {
855 let session_id = "0123456789abcdef0123456789abcdef";
856 let mut controller = stopped_podman_cleanup_controller(session_id);
857 let previous = controller.state.sessions[session_id].clone();
858 let failure = controller
859 .cleanup_stopped_target_with(
860 session_id,
861 &DeferredCleanupExecutor {
862 statuses: RefCell::new(vec![1, 1]),
863 },
864 |_| Err(anyhow::anyhow!("database unavailable")),
865 )
866 .unwrap_err();
867 let detail = format!("{failure:#}");
868 assert!(detail.contains("cleanup failed"), "{detail}");
869 assert!(detail.contains("database unavailable"), "{detail}");
870 assert_eq!(controller.state.sessions[session_id], previous);
871 }
872
873 #[test]
874 fn target_cleanup_persists_destroying_and_rechecks_the_installed_archive() {
875 struct RecordingExecutor {
876 commands: RefCell<Vec<CommandSpec>>,
877 }
878
879 impl CommandExecutor for RecordingExecutor {
880 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
881 self.commands.borrow_mut().push(command.clone());
882 Ok(CommandOutput {
883 status: 0,
884 stdout: Vec::new(),
885 stderr: Vec::new(),
886 })
887 }
888 }
889
890 let directory = tempfile::tempdir().unwrap();
891 let session_id = "0123456789abcdef0123456789abcdef";
892 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
893 let mut session = checkpoint_test_session(session_id);
894 session.target_template_id = "local".into();
895 session.state = SessionState::Closing;
896 session.target = Some(TargetLocator::LocalBare {
897 worker_root: directory.path().join(session_id),
898 });
899 session.checkpoint = Some(checkpoint.clone());
900 let mut config = Config::default();
901 config
902 .targets
903 .insert("local".into(), TargetTemplate::LocalBare);
904 let mut controller = Controller {
905 config,
906 state: State {
907 sessions: BTreeMap::from([(session_id.into(), session)]),
908 ..State::default()
909 },
910 };
911 let executor = RecordingExecutor {
912 commands: RefCell::new(Vec::new()),
913 };
914 let persisted = RefCell::new(Vec::new());
915
916 controller
917 .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
918 persisted.borrow_mut().push(record.state);
919 Ok(())
920 })
921 .unwrap();
922
923 assert_eq!(
924 persisted.into_inner(),
925 vec![SessionState::Destroying, SessionState::Stopped]
926 );
927 assert_eq!(executor.commands.borrow().len(), 1);
928 let stopped = &controller.state.sessions[session_id];
929 assert_eq!(stopped.state, SessionState::Stopped);
930 assert!(stopped.target.is_none());
931 }
932
933 #[test]
934 fn destruction_waits_for_recovery_and_failed_cleanup_never_restarts_the_target() {
935 use crate::session_manager::{
936 WorkerRecoveryOutcome, WorkerRecoveryPlan, recover_worker_controlled,
937 };
938 use std::sync::{Mutex, mpsc};
939 const CHILD: &str = "MJ_DESTRUCTION_RECOVERY_TEST_CHILD";
940 const TEST: &str = "controller::lifecycle::tests::destruction_waits_for_recovery_and_failed_cleanup_never_restarts_the_target";
941 if std::env::var_os(CHILD).is_none() {
942 let directory = tempfile::tempdir().unwrap();
943 let output = std::process::Command::new(std::env::current_exe().unwrap())
944 .args(["--exact", TEST, "--nocapture"])
945 .env(CHILD, "1")
946 .env("MJ_DATA_DIR", directory.path())
947 .env("MJ_CONFIG_DIR", directory.path().join("config"))
948 .output()
949 .unwrap();
950 assert!(
951 output.status.success(),
952 "{}\n{}",
953 String::from_utf8_lossy(&output.stdout),
954 String::from_utf8_lossy(&output.stderr)
955 );
956 return;
957 }
958 let _writer = crate::database::install_isolated_test_writer();
959 let directory = tempfile::tempdir().unwrap();
960 let session_id = "0123456789abcdef0123456789abcdef";
961 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
962 let mut controller = stopped_podman_cleanup_controller(session_id);
963 let record = controller.state.sessions.get_mut(session_id).unwrap();
964 record.state = SessionState::Closing;
965 record.checkpoint = Some(checkpoint.clone());
966 crate::database::save_session(record).unwrap();
967 let plan = WorkerRecoveryPlan {
968 source_target: record.target.clone().unwrap(),
969 target: None,
970 workspace: None,
971 liveness_probe: CommandSpec::new("probe", std::iter::empty::<&str>()),
972 binary_refresh: None,
973 launch_refresh: None,
974 restart: targets::CommandPlan {
975 description: "restart worker".into(),
976 commands: vec![CommandSpec::new("restart", std::iter::empty::<&str>())],
977 },
978 };
979 struct PausedRecovery {
980 entered: mpsc::Sender<()>,
981 release: Mutex<mpsc::Receiver<()>>,
982 restarted: Mutex<bool>,
983 }
984 impl CommandExecutor for PausedRecovery {
985 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
986 if command.program == "probe" {
987 self.entered.send(()).unwrap();
988 self.release
989 .lock()
990 .unwrap()
991 .recv_timeout(Duration::from_secs(5))
992 .unwrap();
993 } else {
994 assert_eq!(command.program, "restart");
995 assert_eq!(crate::database::load_state().unwrap().sessions["0123456789abcdef0123456789abcdef"].state,
996 SessionState::Closing, "recovery must finish before destruction is persisted");
997 *self.restarted.lock().unwrap() = true;
998 }
999 Ok(CommandOutput {
1000 status: 0,
1001 stdout: b"dead\n".to_vec(),
1002 stderr: Vec::new(),
1003 })
1004 }
1005 }
1006 let (entered_tx, entered_rx) = mpsc::channel();
1007 let (release_tx, release_rx) = mpsc::channel();
1008 let recovery = PausedRecovery {
1009 entered: entered_tx,
1010 release: Mutex::new(release_rx),
1011 restarted: Mutex::new(false),
1012 };
1013 let (persisted_tx, persisted_rx) = mpsc::channel();
1014 std::thread::scope(|scope| {
1015 let recovering = scope.spawn(|| {
1016 recover_worker_controlled(plan.clone(), false, Some(session_id), &recovery)
1017 });
1018 entered_rx.recv_timeout(Duration::from_secs(5)).unwrap();
1019 let closing = scope.spawn(|| {
1020 controller.destroy_after_verified_checkpoint_with(
1021 session_id,
1022 &checkpoint,
1023 &DeferredCleanupExecutor {
1024 statuses: RefCell::new(vec![125]),
1025 },
1026 |record| {
1027 assert!(*recovery.restarted.lock().unwrap());
1028 crate::database::save_lifecycle_session(record)?;
1029 persisted_tx.send(record.state).unwrap();
1030 Ok(())
1031 },
1032 )
1033 });
1034 assert!(
1035 persisted_rx
1036 .recv_timeout(Duration::from_millis(50))
1037 .is_err()
1038 );
1039 release_tx.send(()).unwrap();
1040 assert_eq!(
1041 recovering.join().unwrap().unwrap(),
1042 WorkerRecoveryOutcome::RestartedDead
1043 );
1044 assert!(closing.join().unwrap().is_err());
1045 });
1046 assert_eq!(
1047 persisted_rx.recv_timeout(Duration::from_secs(5)).unwrap(),
1048 SessionState::Destroying
1049 );
1050 assert_eq!(
1051 crate::database::load_state().unwrap().sessions[session_id].state,
1052 SessionState::Destroying
1053 );
1054 assert_eq!(
1055 controller.state.sessions[session_id].checkpoint.as_ref(),
1056 Some(&checkpoint)
1057 );
1058 for _ in 0..3 {
1060 assert!(crate::pollers::dashboard_worker_targets(&controller).is_empty());
1061 assert_eq!(
1062 recover_worker_controlled(
1063 plan.clone(),
1064 true,
1065 Some(session_id),
1066 &DeferredCleanupExecutor {
1067 statuses: RefCell::new(Vec::new())
1068 }
1069 )
1070 .unwrap(),
1071 WorkerRecoveryOutcome::Suppressed
1072 );
1073 }
1074 assert!(
1075 controller
1076 .destroy_after_verified_checkpoint_with(
1077 session_id,
1078 &checkpoint,
1079 &DeferredCleanupExecutor {
1080 statuses: RefCell::new(vec![0])
1081 },
1082 crate::database::save_lifecycle_session
1083 )
1084 .unwrap()
1085 );
1086 assert_eq!(
1087 crate::database::load_state().unwrap().sessions[session_id].state,
1088 SessionState::Stopped
1089 );
1090 assert_eq!(
1091 mj_checkpoint::checkpoint::checkpoint_sha256(&checkpoint.archive_path).unwrap(),
1092 checkpoint.sha256
1093 );
1094 }
1095
1096 #[test]
1097 fn podman_close_persists_stopped_before_deferred_storage_cleanup() {
1098 struct RecordingExecutor {
1099 commands: RefCell<Vec<CommandSpec>>,
1100 }
1101
1102 impl CommandExecutor for RecordingExecutor {
1103 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1104 self.commands.borrow_mut().push(command.clone());
1105 Ok(CommandOutput {
1106 status: 0,
1107 stdout: Vec::new(),
1108 stderr: Vec::new(),
1109 })
1110 }
1111 }
1112
1113 let directory = tempfile::tempdir().unwrap();
1114 let session_id = "0123456789abcdef0123456789abcdef";
1115 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1116 let container_id = targets::resource_name(session_id).unwrap();
1117 let volume = format!("{container_id}-workspace");
1118 let mut session = checkpoint_test_session(session_id);
1119 session.target_template_id = "podman".into();
1120 session.state = SessionState::Closing;
1121 session.target = Some(TargetLocator::LocalPodman {
1122 container_id,
1123 workspace_storage: mj_core::state::PodmanWorkspaceLocator::Volume {
1124 name: volume.clone(),
1125 },
1126 });
1127 session.checkpoint = Some(checkpoint.clone());
1128 let mut config = Config::default();
1129 config.targets.insert(
1130 "podman".into(),
1131 TargetTemplate::LocalPodman {
1132 container: ConfigContainer {
1133 image: "test:latest".into(),
1134 pull_policy: Default::default(),
1135 platform: None,
1136 cpus: None,
1137 memory: None,
1138 environment: BTreeMap::new(),
1139 workspace_storage: mj_core::config::PodmanWorkspaceStorage::PodmanVolume,
1140 },
1141 },
1142 );
1143 let mut controller = Controller {
1144 config,
1145 state: State {
1146 sessions: BTreeMap::from([(session_id.into(), session)]),
1147 ..State::default()
1148 },
1149 };
1150 let executor = RecordingExecutor {
1151 commands: RefCell::new(Vec::new()),
1152 };
1153 let persisted = RefCell::new(Vec::new());
1154
1155 let deferred = controller
1156 .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
1157 persisted
1158 .borrow_mut()
1159 .push((record.state, record.target.is_some()));
1160 Ok(())
1161 })
1162 .unwrap();
1163
1164 assert!(deferred);
1165 assert_eq!(
1166 persisted.borrow().as_slice(),
1167 &[
1168 (SessionState::Destroying, true),
1169 (SessionState::Stopped, true)
1170 ]
1171 );
1172 let commands = executor.commands.borrow();
1173 assert_eq!(commands.len(), 1);
1174 assert!(commands[0].args[1].contains("podman stop --time 0"));
1175 assert!(!commands[0].args[1].contains("podman rm"));
1176 drop(commands);
1177
1178 controller
1179 .cleanup_stopped_target_with(session_id, &executor, |record| {
1180 assert_eq!(record.state, SessionState::Stopped);
1181 assert!(record.target.is_none());
1182 Ok(())
1183 })
1184 .unwrap();
1185
1186 let commands = executor.commands.borrow();
1187 assert_eq!(commands.len(), 4);
1188 assert_eq!(
1189 commands[1].stage,
1190 Some(targets::ProvisionStage::RemovingContainer)
1191 );
1192 assert_eq!(
1193 commands[2].stage,
1194 Some(targets::ProvisionStage::RemovingStorage)
1195 );
1196 assert_eq!(
1197 commands[3].stage,
1198 Some(targets::ProvisionStage::CleaningCache)
1199 );
1200 assert!(commands[2].args.contains(&volume));
1201 assert!(controller.state.sessions[session_id].target.is_none());
1202 }
1203 #[test]
1204 fn verified_close_retires_managed_checkout_but_keeps_archive_and_branch() {
1205 let archive_directory = tempfile::tempdir().unwrap();
1206 let repository = committed_repository();
1207 let session_id = "0123456789abcdef0123456789abcdef";
1208 let checkpoint = write_checkpoint_gate_archive(archive_directory.path(), session_id, 7);
1209 let mut session = managed_worktree_session(repository.path(), session_id);
1210 let worktree = session.managed_worktree.clone().unwrap();
1211 std::fs::write(worktree.worktree_root.join("dirty.txt"), "worktree state\n").unwrap();
1212 session.state = SessionState::Closing;
1213 session.target = Some(TargetLocator::LocalBare {
1214 worker_root: archive_directory.path().join(session_id),
1215 });
1216 session.checkpoint = Some(checkpoint.clone());
1217 let mut config = Config::default();
1218 config
1219 .targets
1220 .insert("local-bare".into(), TargetTemplate::LocalBare);
1221 let mut controller = Controller {
1222 config,
1223 state: State {
1224 sessions: BTreeMap::from([(session_id.into(), session)]),
1225 ..State::default()
1226 },
1227 };
1228
1229 controller
1230 .destroy_after_verified_checkpoint_with(
1231 session_id,
1232 &checkpoint,
1233 &ProcessExecutor,
1234 |_| Ok(()),
1235 )
1236 .unwrap();
1237
1238 assert!(!worktree.worktree_root.exists());
1239 assert!(checkpoint.archive_path.is_file());
1240 assert_eq!(
1241 test_git(
1242 repository.path(),
1243 &[
1244 "show-ref",
1245 "--hash",
1246 &format!("refs/heads/{}", worktree.branch),
1247 ],
1248 )
1249 .len(),
1250 40
1251 );
1252 assert_eq!(
1253 controller.state.sessions[session_id].state,
1254 SessionState::Stopped
1255 );
1256 }
1257 #[test]
1258 fn force_stop_reuses_verified_archive_and_leaves_session_resumable() {
1259 let archive_directory = tempfile::tempdir().unwrap();
1260 let repository = committed_repository();
1261 let session_id = "0123456789abcdef0123456789abcdef";
1262 let checkpoint = write_checkpoint_gate_archive(archive_directory.path(), session_id, 7);
1263 let mut session = managed_worktree_session(repository.path(), session_id);
1264 let worktree = session.managed_worktree.clone().unwrap();
1265 session.state = SessionState::Running;
1266 session.target = Some(TargetLocator::LocalBare {
1267 worker_root: archive_directory.path().join(session_id),
1268 });
1269 session.checkpoint = Some(checkpoint.clone());
1270 let mut config = Config::default();
1271 config
1272 .targets
1273 .insert("local-bare".into(), TargetTemplate::LocalBare);
1274 let mut controller = Controller {
1275 config,
1276 state: State {
1277 sessions: BTreeMap::from([(session_id.into(), session)]),
1278 ..State::default()
1279 },
1280 };
1281
1282 controller
1283 .force_stop_with(session_id, &ProcessExecutor, |_| Ok(()))
1284 .unwrap();
1285
1286 let stopped = &controller.state.sessions[session_id];
1287 assert_eq!(stopped.state, SessionState::Stopped);
1288 assert!(stopped.target.is_none());
1289 assert_eq!(stopped.checkpoint.as_ref(), Some(&checkpoint));
1290 assert!(checkpoint.archive_path.is_file());
1291 assert!(!worktree.worktree_root.exists());
1292 assert!(
1293 !test_git(
1294 repository.path(),
1295 &[
1296 "show-ref",
1297 "--hash",
1298 &format!("refs/heads/{}", worktree.branch),
1299 ],
1300 )
1301 .is_empty()
1302 );
1303 }
1304 #[test]
1305 fn force_stop_without_a_recovery_archive_does_not_touch_the_target() {
1306 struct RecordingExecutor {
1307 calls: RefCell<usize>,
1308 }
1309
1310 impl CommandExecutor for RecordingExecutor {
1311 fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
1312 *self.calls.borrow_mut() += 1;
1313 Ok(CommandOutput {
1314 status: 0,
1315 stdout: Vec::new(),
1316 stderr: Vec::new(),
1317 })
1318 }
1319 }
1320
1321 let directory = tempfile::tempdir().unwrap();
1322 let session_id = "0123456789abcdef0123456789abcdef";
1323 let mut session = checkpoint_test_session(session_id);
1324 session.state = SessionState::Running;
1325 session.checkpoint = None;
1326 session.target_template_id = "local".into();
1327 session.target = Some(TargetLocator::LocalBare {
1328 worker_root: directory.path().join(session_id),
1329 });
1330 let mut config = Config::default();
1331 config
1332 .targets
1333 .insert("local".into(), TargetTemplate::LocalBare);
1334 let mut controller = Controller {
1335 config,
1336 state: State {
1337 sessions: BTreeMap::from([(session_id.into(), session)]),
1338 ..State::default()
1339 },
1340 };
1341 let executor = RecordingExecutor {
1342 calls: RefCell::new(0),
1343 };
1344
1345 let error = controller
1346 .force_stop_with(session_id, &executor, |_| Ok(()))
1347 .unwrap_err();
1348
1349 assert!(error.to_string().contains("existing recovery archive"));
1350 assert_eq!(*executor.calls.borrow(), 0);
1351 assert_eq!(
1352 controller.state.sessions[session_id].state,
1353 SessionState::Running
1354 );
1355 assert!(controller.state.sessions[session_id].target.is_some());
1356 }
1357 #[test]
1358 fn destroying_retry_blocks_cleanup_when_the_archive_gate_changed() {
1359 struct RecordingExecutor {
1360 calls: RefCell<usize>,
1361 }
1362
1363 impl CommandExecutor for RecordingExecutor {
1364 fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
1365 *self.calls.borrow_mut() += 1;
1366 Ok(CommandOutput {
1367 status: 0,
1368 stdout: Vec::new(),
1369 stderr: Vec::new(),
1370 })
1371 }
1372 }
1373
1374 let directory = tempfile::tempdir().unwrap();
1375 let repository = committed_repository();
1376 let session_id = "0123456789abcdef0123456789abcdef";
1377 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1378 let mut session = managed_worktree_session(repository.path(), session_id);
1379 let worktree = session.managed_worktree.clone().unwrap();
1380 session.target_template_id = "local".into();
1381 session.state = SessionState::Destroying;
1382 session.target = Some(TargetLocator::LocalBare {
1383 worker_root: directory.path().join(session_id),
1384 });
1385 session.checkpoint = Some(checkpoint.clone());
1386 let mut config = Config::default();
1387 config
1388 .targets
1389 .insert("local".into(), TargetTemplate::LocalBare);
1390 let mut controller = Controller {
1391 config,
1392 state: State {
1393 sessions: BTreeMap::from([(session_id.into(), session)]),
1394 ..State::default()
1395 },
1396 };
1397 let executor = RecordingExecutor {
1398 calls: RefCell::new(0),
1399 };
1400 let persisted = RefCell::new(Vec::new());
1401 std::fs::write(&checkpoint.archive_path, b"changed after checkpoint").unwrap();
1402
1403 let error = controller
1404 .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
1405 persisted.borrow_mut().push(record.state);
1406 Ok(())
1407 })
1408 .unwrap_err();
1409
1410 assert!(error.to_string().contains("checkpoint SHA changed"));
1411 assert_eq!(*executor.calls.borrow(), 0);
1412 assert!(persisted.into_inner().is_empty());
1413 assert!(worktree.worktree_root.is_dir());
1414 assert_eq!(
1415 controller.state.sessions[session_id].state,
1416 SessionState::Destroying
1417 );
1418 }
1419 #[test]
1420 fn destroying_retry_finalizes_when_apple_container_is_confirmed_absent() {
1421 struct AlreadyRemovedExecutor {
1422 commands: RefCell<Vec<CommandSpec>>,
1423 }
1424
1425 impl CommandExecutor for AlreadyRemovedExecutor {
1426 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1427 self.commands.borrow_mut().push(command.clone());
1428 if command.program == "sh"
1429 && command
1430 .args
1431 .get(1)
1432 .is_some_and(|script| script.contains("container rm --force"))
1433 {
1434 Ok(CommandOutput {
1435 status: 1,
1436 stdout: Vec::new(),
1437 stderr: b"container not found".to_vec(),
1438 })
1439 } else {
1440 Ok(CommandOutput {
1441 status: 0,
1442 stdout: Vec::new(),
1443 stderr: Vec::new(),
1444 })
1445 }
1446 }
1447 }
1448
1449 let directory = tempfile::tempdir().unwrap();
1450 let session_id = "0123456789abcdef0123456789abcdef";
1451 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1452 let mut session = checkpoint_test_session(session_id);
1453 session.target_template_id = "apple".into();
1454 session.state = SessionState::Destroying;
1455 session.target = Some(TargetLocator::AppleContainer {
1456 container_id: targets::resource_name(session_id).unwrap(),
1457 });
1458 session.checkpoint = Some(checkpoint.clone());
1459 let mut config = Config::default();
1460 config.targets.insert(
1461 "apple".into(),
1462 TargetTemplate::AppleContainer {
1463 container: ConfigContainer {
1464 image: "test:latest".into(),
1465 pull_policy: Default::default(),
1466 platform: None,
1467 cpus: None,
1468 memory: None,
1469 environment: BTreeMap::new(),
1470 workspace_storage: Default::default(),
1471 },
1472 },
1473 );
1474 let mut controller = Controller {
1475 config,
1476 state: State {
1477 sessions: BTreeMap::from([(session_id.into(), session)]),
1478 ..State::default()
1479 },
1480 };
1481 let executor = AlreadyRemovedExecutor {
1482 commands: RefCell::new(Vec::new()),
1483 };
1484 let persisted = RefCell::new(Vec::new());
1485
1486 controller
1487 .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
1488 persisted.borrow_mut().push(record.state);
1489 Ok(())
1490 })
1491 .unwrap();
1492
1493 let commands = executor.commands.borrow();
1494 assert_eq!(commands.len(), 2);
1495 assert_eq!(commands[0].program, "sh");
1496 assert!(commands[0].args[1].contains("container rm --force"));
1497 assert!(commands[0].args[1].contains(".cache/mjolnir/git/sessions"));
1498 assert_eq!(commands[1].args, ["list", "--all", "--quiet"]);
1499 assert_eq!(persisted.into_inner(), vec![SessionState::Stopped]);
1500 assert_eq!(
1501 controller.state.sessions[session_id].state,
1502 SessionState::Stopped
1503 );
1504 }
1505
1506 #[test]
1507 fn interrupted_close_error_preserves_destroying_phase() {
1508 let session_id = "0123456789abcdef0123456789abcdef";
1509 let mut session = checkpoint_test_session(session_id);
1510 session.state = SessionState::Destroying;
1511
1512 apply_interrupted_close_error(
1513 &mut session,
1514 &anyhow::anyhow!("podman unavailable"),
1515 "2026-08-14T12:00:00Z",
1516 );
1517
1518 assert_eq!(session.state, SessionState::Destroying);
1519 assert_eq!(session.updated_at, "2026-08-14T12:00:00Z");
1520 assert!(
1521 session
1522 .last_error
1523 .as_deref()
1524 .is_some_and(|error| error.contains("cleanup is safely retryable"))
1525 );
1526 }
1527
1528 struct FailingExecutor;
1529
1530 impl CommandExecutor for FailingExecutor {
1531 fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
1532 Ok(CommandOutput {
1533 status: 1,
1534 stdout: Vec::new(),
1535 stderr: b"teardown unavailable".to_vec(),
1536 })
1537 }
1538 }
1539
1540 fn branch_exists(repository: &std::path::Path, branch: &str) -> bool {
1541 std::process::Command::new("git")
1542 .arg("-C")
1543 .arg(repository)
1544 .args(["show-ref", "--verify", "--quiet"])
1545 .arg(format!("refs/heads/{branch}"))
1546 .output()
1547 .unwrap()
1548 .status
1549 .success()
1550 }
1551
1552 #[test]
1553 fn force_destroy_from_running_removes_target_worktree_branch_and_archive() {
1554 let directory = tempfile::tempdir().unwrap();
1555 let repository = committed_repository();
1556 let session_id = "0123456789abcdef0123456789abcdef";
1557 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1558 let worker_root = directory.path().join(session_id);
1559 std::fs::create_dir_all(&worker_root).unwrap();
1560 let mut session = managed_worktree_session(repository.path(), session_id);
1561 session.state = SessionState::Running;
1562 session.target_template_id = "local".into();
1563 session.target = Some(TargetLocator::LocalBare {
1564 worker_root: worker_root.clone(),
1565 });
1566 session.checkpoint = Some(checkpoint.clone());
1567 let mut config = Config::default();
1568 config
1569 .targets
1570 .insert("local".into(), TargetTemplate::LocalBare);
1571 let mut controller = Controller {
1572 config,
1573 state: State {
1574 sessions: BTreeMap::from([(session_id.into(), session)]),
1575 ..State::default()
1576 },
1577 };
1578 let deleted = RefCell::new(Vec::new());
1579
1580 controller
1581 .force_destroy_session_with(session_id, &ProcessExecutor, |id| {
1582 deleted.borrow_mut().push(id.to_owned());
1583 Ok(())
1584 })
1585 .unwrap();
1586
1587 assert!(!worker_root.exists(), "local target must be removed");
1588 let worktree_root = repository.path().join(".mj/worktrees").join(session_id);
1589 assert!(!worktree_root.exists(), "managed worktree must be removed");
1590 assert!(
1591 !branch_exists(repository.path(), &format!("mj/{session_id}")),
1592 "generated branch must be removed"
1593 );
1594 assert!(!checkpoint.archive_path.exists(), "archive must be removed");
1595 assert!(!controller.state.sessions.contains_key(session_id));
1596 assert_eq!(deleted.into_inner(), vec![session_id.to_owned()]);
1597 }
1598
1599 #[test]
1600 fn force_destroy_without_a_target_or_archive_still_removes_the_record() {
1601 let session_id = "0123456789abcdef0123456789abcdef";
1602 let mut session = checkpoint_test_session(session_id);
1603 session.state = SessionState::Provisioning;
1604 session.target = None;
1605 session.checkpoint = None;
1606 let mut controller = Controller {
1607 config: Config::default(),
1608 state: State {
1609 sessions: BTreeMap::from([(session_id.into(), session)]),
1610 ..State::default()
1611 },
1612 };
1613 let deleted = RefCell::new(Vec::new());
1614
1615 controller
1616 .force_destroy_session_with(session_id, &ProcessExecutor, |id| {
1617 deleted.borrow_mut().push(id.to_owned());
1618 Ok(())
1619 })
1620 .unwrap();
1621
1622 assert!(!controller.state.sessions.contains_key(session_id));
1623 assert_eq!(deleted.into_inner(), vec![session_id.to_owned()]);
1624 }
1625
1626 #[test]
1627 fn force_destroy_aborts_and_keeps_the_record_when_the_target_survives() {
1628 let directory = tempfile::tempdir().unwrap();
1629 let session_id = "0123456789abcdef0123456789abcdef";
1630 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1631 let mut session = checkpoint_test_session(session_id);
1632 session.target_template_id = "local".into();
1633 session.state = SessionState::Running;
1634 session.target = Some(TargetLocator::LocalBare {
1635 worker_root: directory.path().join(session_id),
1636 });
1637 session.checkpoint = Some(checkpoint.clone());
1638 let mut config = Config::default();
1639 config
1640 .targets
1641 .insert("local".into(), TargetTemplate::LocalBare);
1642 let mut controller = Controller {
1643 config,
1644 state: State {
1645 sessions: BTreeMap::from([(session_id.into(), session)]),
1646 ..State::default()
1647 },
1648 };
1649 let deleted = RefCell::new(Vec::new());
1650
1651 let error = controller
1652 .force_destroy_session_with(session_id, &FailingExecutor, |id| {
1653 deleted.borrow_mut().push(id.to_owned());
1654 Ok(())
1655 })
1656 .unwrap_err();
1657
1658 assert!(
1659 error.to_string().contains("teardown unavailable"),
1660 "{error:#}"
1661 );
1662 assert!(
1663 controller.state.sessions.contains_key(session_id),
1664 "a surviving target must keep the record for a retry"
1665 );
1666 assert!(
1667 checkpoint.archive_path.exists(),
1668 "a surviving target must keep the recovery archive"
1669 );
1670 assert!(deleted.into_inner().is_empty());
1671 }
1672
1673 #[test]
1674 fn force_destroy_tolerates_a_missing_archive() {
1675 let directory = tempfile::tempdir().unwrap();
1676 let session_id = "0123456789abcdef0123456789abcdef";
1677 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1678 std::fs::remove_file(&checkpoint.archive_path).unwrap();
1679 let mut session = checkpoint_test_session(session_id);
1680 session.state = SessionState::Error;
1681 session.checkpoint = Some(checkpoint);
1682 let mut controller = Controller {
1683 config: Config::default(),
1684 state: State {
1685 sessions: BTreeMap::from([(session_id.into(), session)]),
1686 ..State::default()
1687 },
1688 };
1689
1690 controller
1691 .force_destroy_session_with(session_id, &ProcessExecutor, |_| Ok(()))
1692 .unwrap();
1693
1694 assert!(!controller.state.sessions.contains_key(session_id));
1695 }
1696}