Skip to main content

mj_controller/controller/
lifecycle.rs

1//! Session close, force-stop, and permanent-destruction transitions.
2
3use 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    /// Checkpoint, ask the harness to close, and only then tear down the exact
24    /// provisioned target. Checkpoint failure is deliberately non-destructive,
25    /// except when the checkpoint's worker restart left no live worker: that
26    /// records `Error` and keeps the target for a later resume or forced close.
27    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        // Persist the close intent before beginning its checkpoint. A process
93        // exit anywhere below must leave enough state for the next controller
94        // to retry the close, even when no checkpoint has been installed yet.
95        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        // Close seals the relay at the exact latched cursor, so this checkpoint
103        // keeps its exclusive connection until the relay reports Closed.
104        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                // The target is kept even when the restart left no worker: a
118                // forced destroy and a resume's pre-clean both use it to tear
119                // down the dead container.
120                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            // The source is still behind an unsealed barrier. A destination
142            // preflight error must release it and leave its processes alive.
143            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        // A stopping session will not checkpoint again, so this is its last
160        // chance to release what its checkpoint now covers.
161        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    /// Resume the durable closing state after a controller restart. If the
218    /// relay had accepted Close, wait for it and destroy through the exact
219    /// installed checkpoint gate. If it had not, take a fresh checkpoint;
220    /// the previously installed archive may have become stale after EOF
221    /// released its barrier.
222    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    /// Execute cleanup only after the close state machine has installed a
286    /// verified checkpoint on the record.
287    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        // The reviewer's native session lives on the target that is about to
354        // go. Recording that now, before the target is torn down, is what
355        // stops a resumed session from trying to reload a conversation that no
356        // longer exists; its transcript is kept for reference either way.
357        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    /// Finish storage cleanup for a stopped Podman target retained by the
401    /// quiescence transition. The locator stays durable until every command
402    /// succeeds, making daemon restart and explicit retry idempotent.
403    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    /// Tear down the current target without taking a fresh checkpoint, then
475    /// leave the logical session resumable from its latest verified archive.
476    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        // Force stop skips a new checkpoint, never the checksum gate on the
509        // archive that makes the logical session resumable afterwards.
510        {
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    /// Permanently destroy an inactive session and every artifact Hel owns for it.
548    /// External cleanup happens before the durable record is dropped so failures
549    /// remain visible and retryable.
550    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    /// Permanently destroy a session from any state, without checkpointing
590    /// and without requiring a recovery archive.
591    ///
592    /// Unlike [`Controller::destroy_session_controlled`], this accepts active
593    /// states: it tears the live target down with the same close plan a
594    /// verified close uses, so the owning process group dies before any files
595    /// go. External cleanup happens before the durable record is dropped so
596    /// failures stay visible and retryable; the recovery archive is removed,
597    /// which is what makes the destruction irreversible.
598    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        // A session destroyed for good keeps nothing, including a broker an
619        // earlier failure left running; retiring it first also stops a live
620        // writer from recreating files under the teardown below.
621        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
698/// Record a close whose checkpoint failed.
699///
700/// An ordinary failure is non-destructive: the session returns to the state it
701/// had. A restart that left no live worker cannot return to Running, because
702/// nothing is listening there any more; it records `Error` so the session stops
703/// being polled, and keeps its target for a later resume or forced close.
704fn 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                    // The cleanup command fails, then the exact-absence probe
891                    // confirms that the owned target is still present.
892                    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        // An empty executor panics if a stale retry attempts any target command.
1140        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}