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