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
22/// What destroying a session does with its managed worktree's git branch.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum BranchDisposition {
25    /// Delete the branch with the rest of the session. Only for a branch
26    /// nobody has worked on, or when the user asks for it by name.
27    Delete,
28    /// Leave the branch in the repository. The default for every destroy,
29    /// because the branch may hold work the user still wants.
30    Keep,
31    /// Delete the branch only when every commit on it is reachable from some
32    /// other branch, local or remote-tracking, that is not a session branch.
33    /// Anything else keeps the branch, exactly as [`BranchDisposition::Keep`]
34    /// would. The archive job uses this so a branch whose work has landed
35    /// elsewhere does not pile up forever.
36    DeleteIfMerged,
37}
38
39/// What a verified close does with the target the session was running in.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub(super) enum SourceTargetDisposition {
42    /// Verified checkpoint, sealed relay, then destroy the exact target.
43    Destroy,
44    /// Verified checkpoint, sealed relay; keep the target and its worker
45    /// daemon alive for an in-place harness replacement.
46    RetainForInPlaceSwap,
47}
48
49impl Controller {
50    /// Checkpoint, ask the harness to close, and only then tear down the exact
51    /// provisioned target. Checkpoint failure is deliberately non-destructive,
52    /// except when the checkpoint's worker restart left no live worker: that
53    /// records `Error` and keeps the target for a later resume or forced close.
54    pub async fn close_session(&mut self, session_id: &str) -> Result<()> {
55        self.close_session_controlled(session_id, &ProcessExecutor)
56            .await
57    }
58
59    pub async fn close_session_controlled(
60        &mut self,
61        session_id: &str,
62        executor: &(impl CommandExecutor + Sync),
63    ) -> Result<()> {
64        if self
65            .close_session_controlled_with_manager(
66                session_id,
67                executor,
68                None,
69                None,
70                SourceTargetDisposition::Destroy,
71            )
72            .await?
73        {
74            self.cleanup_stopped_target(session_id, executor)?;
75        }
76        Ok(())
77    }
78
79    pub async fn close_session_managed_controlled(
80        &mut self,
81        session_id: &str,
82        executor: &(impl CommandExecutor + Sync),
83        manager: &SessionManagerControl,
84    ) -> Result<bool> {
85        self.close_session_controlled_with_manager(
86            session_id,
87            executor,
88            Some(manager),
89            None,
90            SourceTargetDisposition::Destroy,
91        )
92        .await
93    }
94
95    pub(super) async fn close_session_for_move(
96        &mut self,
97        session_id: &str,
98        executor: &(impl CommandExecutor + Sync),
99        manager: &SessionManagerControl,
100        operation: &mut mj_core::state::MoveOperation,
101        preparation: Option<&mj_core::state::MovePreparation>,
102        disposition: SourceTargetDisposition,
103    ) -> Result<bool> {
104        self.prepare_move_source_checkpoint(session_id, executor, manager, operation)
105            .await?;
106        self.close_session_controlled_with_manager(
107            session_id,
108            executor,
109            Some(manager),
110            Some((operation, preparation)),
111            disposition,
112        )
113        .await
114    }
115
116    async fn close_session_controlled_with_manager(
117        &mut self,
118        session_id: &str,
119        executor: &(impl CommandExecutor + Sync),
120        manager: Option<&SessionManagerControl>,
121        move_intent: Option<(
122            &mut mj_core::state::MoveOperation,
123            Option<&mj_core::state::MovePreparation>,
124        )>,
125        disposition: SourceTargetDisposition,
126    ) -> Result<bool> {
127        let previous = self
128            .state
129            .sessions
130            .get(session_id)
131            .with_context(|| format!("unknown session {session_id}"))?
132            .clone();
133        let record = self.state.sessions.get_mut(session_id).unwrap();
134        // Persist the close intent before beginning its checkpoint. A process
135        // exit anywhere below must leave enough state for the next controller
136        // to retry the close, even when no checkpoint has been installed yet.
137        apply_close_checkpoint_started(record, now());
138        self.persist_session_transition_or_restore(
139            session_id,
140            &previous,
141            "persist closing state before checkpointing the session",
142        )?;
143
144        // Close seals the relay at the exact latched cursor, so this checkpoint
145        // keeps its exclusive connection until the relay reports Closed.
146        let mut latched = match self
147            .checkpoint_session_latched(
148                session_id,
149                executor,
150                manager,
151                LatchExclusivity::HoldThroughClose,
152                CheckpointExportPolicy::ReuseUnchangedArchive,
153            )
154            .await
155        {
156            Ok(latched) => latched,
157            Err(error) => {
158                let record = self.state.sessions.get_mut(session_id).unwrap();
159                // The target is kept even when the restart left no worker: a
160                // forced destroy and a resume's pre-clean both use it to tear
161                // down the dead container.
162                apply_close_checkpoint_failure(record, &previous, &error, now());
163                return Err(
164                    self.persist_failed_checkpoint_state_or_restore(session_id, &previous, error)
165                );
166            }
167        };
168
169        let artifact = latched.artifact.clone();
170        let record = self.state.sessions.get_mut(session_id).unwrap();
171        record.state = SessionState::Closing;
172        record.native_session_id = Some(artifact.native_session_id.clone());
173        record.checkpoint = Some(artifact.metadata.clone());
174        record.updated_at = now();
175        record.last_error = None;
176        record.last_checkpoint_error = None;
177        self.persist_checkpoint_transition_or_restore(
178            session_id,
179            &previous,
180            "persist verified checkpoint and closing state before sealing the relay",
181        )?;
182        if let Some((operation, preparation)) = move_intent {
183            // The source is still behind an unsealed barrier. A destination
184            // preflight error must release it and leave its processes alive.
185            if let Err(error) = self.validate_move_checkpoint(operation, preparation, executor) {
186                let record = self.state.sessions.get_mut(session_id).unwrap();
187                record.state = previous.state;
188                record.last_error = Some(format!("{error:#}"));
189                self.persist_session_transition_or_restore(
190                    session_id,
191                    &previous,
192                    "restore source after move preflight failure",
193                )?;
194                return Err(error);
195            }
196            operation.checkpoint = Some(artifact.metadata.clone());
197            operation.updated_at = now();
198            crate::database::save_move_operation(operation)?;
199        }
200        prune_replaced_checkpoint(previous.checkpoint.as_ref(), &artifact.metadata);
201        // A stopping session will not checkpoint again, so this is its last
202        // chance to release what its checkpoint now covers.
203        release_projection_behind_checkpoint(session_id, &artifact.metadata);
204
205        let close_command_id = new_command_id("close")?;
206        let barrier_command_id = latched.barrier_command_id.clone();
207        let close_result = {
208            let _closing = ProvisionStageGuard::new(executor, ProvisionStage::Closing);
209            latched
210                .relay
211                .connection_mut()
212                .submit(
213                    close_command_id,
214                    RelayCommand::Close {
215                        barrier_command_id: barrier_command_id.clone(),
216                        expected: latched.cursor.clone(),
217                    },
218                )
219                .await
220        };
221        if let Err(error) = close_result {
222            self.record_interrupted_close(session_id, &error)?;
223            return Err(error.context("seal verified checkpoint for close"));
224        }
225        let close_result = {
226            let _closing = ProvisionStageGuard::new(executor, ProvisionStage::Closing);
227            latched
228                .relay
229                .connection_mut()
230                .submit(
231                    new_command_id("checkpoint-complete")?,
232                    RelayCommand::CompleteCheckpoint { barrier_command_id },
233                )
234                .await
235        };
236        if let Err(error) = close_result {
237            self.record_interrupted_close(session_id, &error)?;
238            return Err(error.context("release verified close checkpoint"));
239        }
240        let close_result = {
241            let _closing = ProvisionStageGuard::new(executor, ProvisionStage::Closing);
242            wait_for_relay_closed(latched.relay.connection_mut()).await
243        };
244        if let Err(error) = close_result {
245            self.record_interrupted_close(session_id, &error)?;
246            return Err(error);
247        }
248        latched.relay.release();
249
250        if disposition == SourceTargetDisposition::RetainForInPlaceSwap {
251            // The record stays `Closing` with its verified checkpoint and its
252            // target. The worker daemon is deliberately left running: a crash
253            // between here and the in-place restore recovers through
254            // `recover_interrupted_close_managed`, which needs the daemon to
255            // answer `Closed`.
256            return Ok(false);
257        }
258        match self.destroy_after_verified_checkpoint(session_id, &artifact.metadata, executor) {
259            Ok(deferred) => Ok(deferred),
260            Err(error) => {
261                self.record_interrupted_close(session_id, &error)?;
262                Err(error)
263            }
264        }
265    }
266
267    /// Resume the durable closing state after a controller restart. If the
268    /// relay had accepted Close, wait for it and destroy through the exact
269    /// installed checkpoint gate. If it had not, take a fresh checkpoint;
270    /// the previously installed archive may have become stale after EOF
271    /// released its barrier.
272    pub async fn recover_interrupted_close_managed(
273        &mut self,
274        session_id: &str,
275        executor: &(impl CommandExecutor + Sync),
276        manager: &SessionManagerControl,
277    ) -> Result<bool> {
278        let (state, verified) = {
279            let session = self
280                .state
281                .sessions
282                .get(session_id)
283                .with_context(|| format!("unknown session {session_id}"))?;
284            ensure!(
285                matches!(
286                    session.state,
287                    SessionState::Closing | SessionState::Destroying
288                ),
289                "session {session_id} has no interrupted close to recover"
290            );
291            (session.state, session.checkpoint.clone())
292        };
293        if state == SessionState::Destroying {
294            let verified = verified.context("destroying session has no verified checkpoint")?;
295            return self.destroy_after_verified_checkpoint(session_id, &verified, executor);
296        }
297        ensure!(
298            state == SessionState::Closing,
299            "session {session_id} has no relay close to recover"
300        );
301        let handle = manager
302            .wait_for_session(session_id, Duration::from_secs(5))
303            .await?;
304        let mut lease = handle.lease_connection().await?;
305        let execution = lease.connection_mut().sync().await?.operational.execution;
306        match execution {
307            RelayExecutionState::Closed => {}
308            RelayExecutionState::Closing => {
309                let _closing = ProvisionStageGuard::new(executor, ProvisionStage::Closing);
310                wait_for_relay_closed(lease.connection_mut()).await?;
311            }
312            RelayExecutionState::Idle | RelayExecutionState::Running => {
313                lease.release();
314                return self
315                    .close_session_controlled_with_manager(
316                        session_id,
317                        executor,
318                        Some(manager),
319                        None,
320                        SourceTargetDisposition::Destroy,
321                    )
322                    .await;
323            }
324        }
325        lease.release();
326        let verified = verified.context("closed relay has no verified checkpoint")?;
327        self.destroy_after_verified_checkpoint(session_id, &verified, executor)
328    }
329
330    /// Record that an in-flight lifecycle state has no operation left to
331    /// finish it, so the session stops waiting for one.
332    ///
333    /// The state is re-checked against the freshly loaded record, because the
334    /// caller decided what to reconcile from a startup snapshot. Returns
335    /// whether anything changed.
336    pub fn fail_interrupted_lifecycle(&mut self, session_id: &str, cause: &str) -> Result<bool> {
337        self.fail_interrupted_lifecycle_with(
338            session_id,
339            cause,
340            crate::database::save_lifecycle_session,
341        )
342    }
343
344    fn fail_interrupted_lifecycle_with(
345        &mut self,
346        session_id: &str,
347        cause: &str,
348        persist: impl Fn(&SessionRecord) -> Result<()>,
349    ) -> Result<bool> {
350        let Some(record) = self.state.sessions.get_mut(session_id) else {
351            return Ok(false);
352        };
353        if crate::pollers::interrupted_lifecycle_cause(record).is_none() {
354            return Ok(false);
355        }
356        let previous = record.clone();
357        record.state = SessionState::Error;
358        record.updated_at = now();
359        record.last_error = Some(cause.to_owned());
360        persist_session_record_transition_or_restore(
361            &mut self.state,
362            session_id,
363            &previous,
364            "persist the failure of an interrupted lifecycle state",
365            &persist,
366        )?;
367        Ok(true)
368    }
369
370    /// Record why a close failed on a session the close left in its earlier
371    /// state, so the person who asked for it learns that it did not finish.
372    ///
373    /// A close that ended in a state of its own — interrupted and resumable,
374    /// or left without a live worker — has already recorded the reason that
375    /// fits that state, and it says more than this one does, so it is kept.
376    /// Reports whether anything changed.
377    pub fn record_failed_close(&mut self, session_id: &str, cause: &str) -> Result<bool> {
378        let Some(record) = self.state.sessions.get(session_id) else {
379            return Ok(false);
380        };
381        // A reason from an earlier close of this session is replaced, so a
382        // repeated close reports its own log entry rather than an older one.
383        if record.last_error.is_some() && record.public_error().is_none() {
384            return Ok(false);
385        }
386        let previous = record.clone();
387        let record = self.state.sessions.get_mut(session_id).unwrap();
388        record.last_error = Some(cause.to_owned());
389        record.updated_at = now();
390        persist_session_record_transition_or_restore(
391            &mut self.state,
392            session_id,
393            &previous,
394            "persist the reason a close did not finish",
395            &crate::database::save_lifecycle_session,
396        )?;
397        Ok(true)
398    }
399
400    /// Forget a recorded close failure, because something for this session has
401    /// since succeeded. Only the sentence a failed close wrote is cleared; a
402    /// raw error from any other operation is left alone. Reports whether
403    /// anything changed.
404    pub fn clear_recorded_close_failure(&mut self, session_id: &str) -> Result<bool> {
405        let Some(record) = self.state.sessions.get(session_id) else {
406            return Ok(false);
407        };
408        if record.public_error().is_none() {
409            return Ok(false);
410        }
411        let previous = record.clone();
412        let record = self.state.sessions.get_mut(session_id).unwrap();
413        record.last_error = None;
414        record.updated_at = now();
415        persist_session_record_transition_or_restore(
416            &mut self.state,
417            session_id,
418            &previous,
419            "clear the reason a close did not finish",
420            &crate::database::save_lifecycle_session,
421        )?;
422        Ok(true)
423    }
424
425    /// Close a session that has nothing to checkpoint.
426    ///
427    /// A record still provisioning never reached a running worker, and a
428    /// record with no target locator has no target to read a workspace from,
429    /// so in both cases there is no relay to latch and no harness state to
430    /// archive. Waiting for a relay that does not exist is what left a stuck
431    /// provisioning session unclosable. Any target the session did leave
432    /// behind is still torn down, and the checkpoint it already had is kept,
433    /// so this is a close, not a forced destroy.
434    ///
435    /// Returns whether target storage cleanup was deferred, like the graceful
436    /// close does.
437    pub fn close_session_without_checkpoint(
438        &mut self,
439        session_id: &str,
440        executor: &impl CommandExecutor,
441    ) -> Result<bool> {
442        self.close_session_without_checkpoint_with(
443            session_id,
444            executor,
445            crate::database::save_lifecycle_session,
446        )
447    }
448
449    fn close_session_without_checkpoint_with(
450        &mut self,
451        session_id: &str,
452        executor: &impl CommandExecutor,
453        persist: impl Fn(&SessionRecord) -> Result<()>,
454    ) -> Result<bool> {
455        let session = self
456            .state
457            .sessions
458            .get(session_id)
459            .with_context(|| format!("unknown session {session_id}"))?
460            .clone();
461        ensure!(
462            has_nothing_to_checkpoint(&session),
463            "session {session_id} has a workspace to checkpoint; close it gracefully instead"
464        );
465        self.stop_target_and_settle(session_id, &session, executor, &persist)
466    }
467
468    fn record_interrupted_close(&mut self, session_id: &str, error: &anyhow::Error) -> Result<()> {
469        let record = self.state.sessions.get_mut(session_id).unwrap();
470        apply_interrupted_close_error(record, error, &now());
471        self.persist_session_state(session_id)
472    }
473
474    /// Execute cleanup only after the close state machine has installed a
475    /// verified checkpoint on the record.
476    fn destroy_after_verified_checkpoint(
477        &mut self,
478        session_id: &str,
479        verified: &CheckpointMetadata,
480        executor: &impl CommandExecutor,
481    ) -> Result<bool> {
482        self.destroy_after_verified_checkpoint_with(
483            session_id,
484            verified,
485            executor,
486            crate::database::save_lifecycle_session,
487        )
488    }
489
490    fn destroy_after_verified_checkpoint_with(
491        &mut self,
492        session_id: &str,
493        verified: &CheckpointMetadata,
494        executor: &impl CommandExecutor,
495        persist: impl Fn(&SessionRecord) -> Result<()>,
496    ) -> Result<bool> {
497        let target_mutex = crate::recovery_gate::worker_target_mutex(session_id);
498        let _target_guard = target_mutex.lock().map_err(|_| {
499            anyhow::anyhow!("worker target ownership lock poisoned for {session_id}")
500        })?;
501        let session = self
502            .state
503            .sessions
504            .get(session_id)
505            .with_context(|| format!("unknown session {session_id}"))?
506            .clone();
507        ensure!(
508            matches!(
509                session.state,
510                SessionState::Closing | SessionState::Destroying
511            ),
512            "refusing to destroy session {session_id}: it is not closing or destroying"
513        );
514        ensure!(
515            session.checkpoint.as_ref() == Some(verified),
516            "refusing to destroy session {session_id}: verified checkpoint gate is stale"
517        );
518        if session.state == SessionState::Closing {
519            let record = self.state.sessions.get_mut(session_id).unwrap();
520            record.state = SessionState::Destroying;
521            record.updated_at = now();
522            record.last_error = None;
523            persist_session_record_transition_or_restore(
524                &mut self.state,
525                session_id,
526                &session,
527                "persist destroying state before target cleanup",
528                &persist,
529            )?;
530        }
531
532        let destroying = self
533            .state
534            .sessions
535            .get(session_id)
536            .expect("destroying session disappeared")
537            .clone();
538        {
539            let _verifying = ProvisionStageGuard::new(executor, ProvisionStage::Verifying);
540            verify_installed_checkpoint_gate(session_id, verified)?;
541        }
542        // The reviewer's native session lives on the target that is about to
543        // go. Recording that now, before the target is torn down, is what
544        // stops a resumed session from trying to reload a conversation that no
545        // longer exists; its transcript is kept for reference either way.
546        if let Err(error) = crate::database::lose_reviewer_continuity(session_id) {
547            tracing::warn!(
548                session_id,
549                error = format!("{error:#}"),
550                "could not record that the second-opinion conversation ends with this target"
551            );
552        }
553        let locator = destroying
554            .target
555            .as_ref()
556            .context("session has no target")?;
557        let backend = backend_locator(locator, &destroying, &self.config)?;
558        let deferred = if self.state.subagents.contains_key(session_id) {
559            targets::borrowed_worker_cleanup_plan(&backend, session_id)?.execute(executor)?;
560            false
561        } else if let Some(plan) = targets::quiesce_plan(&backend, session_id)? {
562            plan.execute(executor)?;
563            true
564        } else {
565            execute_target_cleanup(&backend, session_id, executor)?;
566            false
567        };
568        if let Some(worktree) = &destroying.managed_worktree {
569            retire_managed_worktree(executor, worktree)
570                .context("retire managed raw-session worktree after verified close")?;
571        }
572        let record = self.state.sessions.get_mut(session_id).unwrap();
573        record.state = SessionState::Stopped;
574        if !deferred {
575            record.target = None;
576        }
577        record.updated_at = now();
578        record.last_error = None;
579        persist_session_record_transition_or_restore(
580            &mut self.state,
581            session_id,
582            &destroying,
583            "persist stopped state after target cleanup",
584            &persist,
585        )?;
586        Ok(deferred)
587    }
588
589    /// Finish storage cleanup for a stopped Podman target retained by the
590    /// quiescence transition. The locator stays durable until every command
591    /// succeeds, making daemon restart and explicit retry idempotent.
592    pub fn cleanup_stopped_target(
593        &mut self,
594        session_id: &str,
595        executor: &impl CommandExecutor,
596    ) -> Result<()> {
597        self.cleanup_stopped_target_with(
598            session_id,
599            executor,
600            crate::database::save_lifecycle_session,
601        )
602    }
603
604    fn cleanup_stopped_target_with(
605        &mut self,
606        session_id: &str,
607        executor: &impl CommandExecutor,
608        persist: impl Fn(&SessionRecord) -> Result<()>,
609    ) -> Result<()> {
610        let target_mutex = crate::recovery_gate::worker_target_mutex(session_id);
611        let _target_guard = target_mutex.lock().map_err(|_| {
612            anyhow::anyhow!("worker target ownership lock poisoned for {session_id}")
613        })?;
614        let previous = self
615            .state
616            .sessions
617            .get(session_id)
618            .with_context(|| format!("unknown session {session_id}"))?
619            .clone();
620        ensure!(
621            previous.state == SessionState::Stopped,
622            "refusing deferred cleanup for active session {session_id}"
623        );
624        let Some(locator) = previous.target.as_ref() else {
625            return Ok(());
626        };
627        let backend = backend_locator(locator, &previous, &self.config)?;
628        ensure!(
629            targets::quiesce_plan(&backend, session_id)?.is_some(),
630            "session {session_id} retained a non-Podman target after stopping"
631        );
632        if let Err(error) = execute_target_cleanup(&backend, session_id, executor) {
633            let record = self.state.sessions.get_mut(session_id).unwrap();
634            record.updated_at = now();
635            record.last_error = Some(format!("deferred target cleanup failed: {error:#}"));
636            let persisted = persist_session_record_transition_or_restore(
637                &mut self.state,
638                session_id,
639                &previous,
640                "persist deferred target cleanup failure",
641                &persist,
642            );
643            return match persisted {
644                Ok(()) => Err(error),
645                Err(persist_error) => Err(error.context(format!(
646                    "also failed to persist deferred target cleanup failure: {persist_error:#}"
647                ))),
648            };
649        }
650        let record = self.state.sessions.get_mut(session_id).unwrap();
651        record.target = None;
652        record.updated_at = now();
653        record.last_error = None;
654        persist_session_record_transition_or_restore(
655            &mut self.state,
656            session_id,
657            &previous,
658            "persist completion of deferred Podman target cleanup",
659            &persist,
660        )
661    }
662
663    /// Tear down the current target without taking a fresh checkpoint, then
664    /// leave the logical session resumable from its latest verified archive.
665    pub fn force_stop(
666        &mut self,
667        session_id: &str,
668        executor: &impl CommandExecutor,
669    ) -> Result<bool> {
670        self.force_stop_with(
671            session_id,
672            executor,
673            crate::database::save_lifecycle_session,
674        )
675    }
676
677    fn force_stop_with(
678        &mut self,
679        session_id: &str,
680        executor: &impl CommandExecutor,
681        persist: impl Fn(&SessionRecord) -> Result<()>,
682    ) -> Result<bool> {
683        let session = self
684            .state
685            .sessions
686            .get(session_id)
687            .with_context(|| format!("unknown session {session_id}"))?
688            .clone();
689        ensure!(
690            session.state.is_active(),
691            "session {session_id} is already inactive"
692        );
693        let checkpoint = session
694            .checkpoint
695            .as_ref()
696            .context("force stop requires an existing recovery archive")?;
697        // Force stop skips a new checkpoint, never the checksum gate on the
698        // archive that makes the logical session resumable afterwards.
699        {
700            let _verifying = ProvisionStageGuard::new(executor, ProvisionStage::Verifying);
701            verify_installed_checkpoint_gate(session_id, checkpoint)
702                .context("verify the recovery archive before force stopping")?;
703        }
704        self.stop_target_and_settle(session_id, &session, executor, &persist)
705    }
706
707    /// Tear down whatever target the session holds and settle its record in
708    /// `Stopped`. Shared by force stop and by a close that has nothing to
709    /// checkpoint; neither takes a fresh archive, so neither may decide on its
710    /// own whether the session still has one.
711    fn stop_target_and_settle(
712        &mut self,
713        session_id: &str,
714        session: &SessionRecord,
715        executor: &impl CommandExecutor,
716        persist: &impl Fn(&SessionRecord) -> Result<()>,
717    ) -> Result<bool> {
718        let mut deferred = false;
719        if let Some(locator) = &session.target {
720            let backend = backend_locator(locator, session, &self.config)?;
721            if let Some(plan) = targets::quiesce_plan(&backend, session_id)? {
722                plan.execute(executor)?;
723                deferred = true;
724            } else {
725                execute_target_cleanup(&backend, session_id, executor)?;
726            }
727        }
728        if let Some(worktree) = &session.managed_worktree {
729            retire_managed_worktree(executor, worktree)
730                .context("retire managed raw-session worktree after stopping the target")?;
731        }
732        let record = self.state.sessions.get_mut(session_id).unwrap();
733        record.state = SessionState::Stopped;
734        if !deferred {
735            record.target = None;
736        }
737        record.updated_at = now();
738        record.last_error = None;
739        record.last_checkpoint_error = None;
740        persist_session_record_transition_or_restore(
741            &mut self.state,
742            session_id,
743            session,
744            "persist stopped state after tearing down the current target",
745            persist,
746        )?;
747        Ok(deferred)
748    }
749
750    /// Permanently destroy an inactive session and every artifact Hel owns for it.
751    /// External cleanup happens before the durable record is dropped so failures
752    /// remain visible and retryable.
753    pub fn destroy_session_controlled(
754        &mut self,
755        session_id: &str,
756        executor: &impl CommandExecutor,
757    ) -> Result<()> {
758        self.destroy_session_controlled_with(session_id, executor, BranchDisposition::Keep)
759    }
760
761    /// The same, with a say in what happens to the managed worktree's branch.
762    ///
763    /// A session that is already `Stopped` has had its checkout removed by
764    /// [`retire_managed_worktree`], so usually only the branch is left for
765    /// [`cleanup_managed_worktree`] to take. With [`BranchDisposition::Keep`]
766    /// the record, the checkpoint, and the attachments go and the branch
767    /// stays, which is what a destroy does unless the user asks otherwise.
768    pub fn destroy_session_controlled_with(
769        &mut self,
770        session_id: &str,
771        executor: &impl CommandExecutor,
772        branch: BranchDisposition,
773    ) -> Result<()> {
774        let session = self
775            .state
776            .sessions
777            .get(session_id)
778            .with_context(|| format!("unknown session {session_id}"))?
779            .clone();
780        if session.state.is_active() {
781            bail!("refusing to destroy active session {session_id}");
782        }
783        if let Some(worktree) = &session.managed_worktree {
784            cleanup_managed_worktree(executor, worktree, branch)
785                .context("remove managed raw-session worktree")?;
786        }
787        if let Some(checkpoint) = &session.checkpoint
788            && let Err(error) = std::fs::remove_file(&checkpoint.archive_path)
789            && error.kind() != std::io::ErrorKind::NotFound
790        {
791            return Err(error).with_context(|| {
792                format!(
793                    "remove session recovery archive {}",
794                    checkpoint.archive_path.display()
795                )
796            });
797        }
798        mj_core::attachment::AttachmentStore::controller(session_id)?
799            .remove_session_data()
800            .context("remove session image attachments")?;
801        crate::database::delete_session(session_id)
802            .context("destroy stopped session in database")?;
803        self.state.subagents.remove(session_id);
804        self.state.destroy_stopped_session(session_id)?;
805        Ok(())
806    }
807
808    /// Permanently destroy a session from any state, without checkpointing
809    /// and without requiring a recovery archive.
810    ///
811    /// Unlike [`Controller::destroy_session_controlled`], this accepts active
812    /// states: it tears the live target down with the same close plan a
813    /// verified close uses, so the owning process group dies before any files
814    /// go. External cleanup happens before the durable record is dropped so
815    /// failures stay visible and retryable; the recovery archive is removed,
816    /// which is what makes the destruction irreversible. The managed
817    /// worktree's checkout always goes; its branch goes only when `branch`
818    /// says so.
819    pub fn force_destroy_session(
820        &mut self,
821        session_id: &str,
822        executor: &impl CommandExecutor,
823        branch: BranchDisposition,
824    ) -> Result<()> {
825        self.force_destroy_session_with(
826            session_id,
827            executor,
828            branch,
829            crate::database::delete_session,
830        )
831    }
832
833    fn force_destroy_session_with(
834        &mut self,
835        session_id: &str,
836        executor: &impl CommandExecutor,
837        branch: BranchDisposition,
838        delete: impl Fn(&str) -> Result<()>,
839    ) -> Result<()> {
840        let session = self
841            .state
842            .sessions
843            .get(session_id)
844            .with_context(|| format!("unknown session {session_id}"))?
845            .clone();
846        // A session destroyed for good keeps nothing, including a broker an
847        // earlier failure left running; retiring it first also stops a live
848        // writer from recreating files under the teardown below.
849        if let Some(locator) = &session.target {
850            let backend = backend_locator(locator, &session, &self.config)?;
851            if self.state.subagents.contains_key(session_id) {
852                targets::borrowed_worker_cleanup_plan(&backend, session_id)?.execute(executor)?;
853            } else {
854                execute_target_cleanup(&backend, session_id, executor)?;
855            }
856        }
857        if let Some(worktree) = &session.managed_worktree {
858            cleanup_managed_worktree(executor, worktree, branch)
859                .context("remove managed raw-session worktree")?;
860        }
861        if let Some(checkpoint) = &session.checkpoint
862            && let Err(error) = std::fs::remove_file(&checkpoint.archive_path)
863            && error.kind() != std::io::ErrorKind::NotFound
864        {
865            return Err(error).with_context(|| {
866                format!(
867                    "remove session recovery archive {}",
868                    checkpoint.archive_path.display()
869                )
870            });
871        }
872        mj_core::attachment::AttachmentStore::controller(session_id)?
873            .remove_session_data()
874            .context("remove session image attachments")?;
875        delete(session_id).context("force destroy session in database")?;
876        self.state.subagents.remove(session_id);
877        self.state.destroy_session_force(session_id)?;
878        Ok(())
879    }
880}
881
882/// Whether a close of this session has no workspace to archive.
883///
884/// Only a session that cannot be holding live work qualifies. A record still
885/// `Provisioning` has never had a worker connected, so there is no relay to
886/// latch and no harness state to capture. A record mid-close or already
887/// failed, with no target locator left, has no target to read a workspace
888/// from at all. Every other state may hold work and must take the graceful
889/// close's checkpoint.
890pub fn has_nothing_to_checkpoint(session: &SessionRecord) -> bool {
891    match session.state {
892        SessionState::Provisioning => true,
893        SessionState::Closing
894        | SessionState::Destroying
895        | SessionState::Error
896        | SessionState::Lost => session.target.is_none(),
897        SessionState::Running
898        | SessionState::Disconnected
899        | SessionState::Checkpointing
900        | SessionState::Stopped
901        | SessionState::DestroyedWithDataLoss => false,
902    }
903}
904
905fn execute_target_cleanup(
906    backend: &targets::TargetLocator,
907    session_id: &str,
908    executor: &impl CommandExecutor,
909) -> Result<()> {
910    if let Err(cleanup_error) = targets::close_plan(backend, session_id)?.execute(executor) {
911        match targets::cleanup_target_is_confirmed_absent(backend, session_id, executor) {
912            Ok(true) => {
913                tracing::warn!(
914                    session_id,
915                    error = format!("{cleanup_error:#}"),
916                    "target cleanup command failed, but the target was confirmed absent"
917                );
918            }
919            Ok(false) => {
920                tracing::error!(
921                    session_id,
922                    error = format!("{cleanup_error:#}"),
923                    "target cleanup failed and the target is still present"
924                );
925                return Err(cleanup_error);
926            }
927            Err(probe_error) => {
928                tracing::error!(
929                    session_id,
930                    cleanup_error = format!("{cleanup_error:#}"),
931                    probe_error = format!("{probe_error:#}"),
932                    "target cleanup failed and exact absence could not be confirmed"
933                );
934                return Err(cleanup_error.context(format!(
935                    "target cleanup failed and exact absence could not be confirmed: {probe_error:#}"
936                )));
937            }
938        }
939    }
940    Ok(())
941}
942
943fn apply_close_checkpoint_started(record: &mut SessionRecord, updated_at: String) {
944    record.state = SessionState::Closing;
945    record.updated_at = updated_at;
946    record.last_checkpoint_error = None;
947}
948
949/// Record a close whose checkpoint failed.
950///
951/// An ordinary failure is non-destructive: the session returns to the state it
952/// had. A restart that left no live worker cannot return to Running, because
953/// nothing is listening there any more; it records `Error` so the session stops
954/// being polled, and keeps its target for a later resume or forced close.
955fn apply_close_checkpoint_failure(
956    record: &mut SessionRecord,
957    previous: &SessionRecord,
958    error: &anyhow::Error,
959    updated_at: String,
960) {
961    if WorkerRestartLeftNoWorker::marks(error) {
962        record.state = SessionState::Error;
963        record.last_error = Some(format!(
964            "close failed and left the session without a live worker; retry the close, \
965             resume from its checkpoint, or close it with --force: {error:#}"
966        ));
967    } else {
968        record.state = previous.state;
969    }
970    record.last_checkpoint_error = Some(format!("{error:#}"));
971    record.updated_at = updated_at;
972}
973
974fn apply_interrupted_close_error(
975    record: &mut SessionRecord,
976    error: &anyhow::Error,
977    updated_at: &str,
978) {
979    let destroying = record.state == SessionState::Destroying;
980    if !destroying {
981        record.state = SessionState::Closing;
982    }
983    record.updated_at = updated_at.to_owned();
984    record.last_error = Some(if destroying {
985        format!("target cleanup is safely retryable from its verified checkpoint: {error:#}")
986    } else {
987        format!("close is safely resumable from its verified checkpoint: {error:#}")
988    });
989}
990
991#[cfg(test)]
992mod tests;