1use std::time::Duration;
4
5use anyhow::{Context, Result, bail, ensure};
6
7use crate::session_manager::{SessionManagerControl, new_command_id};
8use mj_core::state::{CheckpointMetadata, SessionRecord, SessionState};
9
10use crate::targets::{self, CommandExecutor, ProcessExecutor, ProvisionStage, ProvisionStageGuard};
11use mj_core::relay::{RelayCommand, RelayExecutionState};
12
13use super::backend::backend_locator;
14use super::checkpoint::{
15 CheckpointExportPolicy, LatchExclusivity, prune_replaced_checkpoint,
16 release_projection_behind_checkpoint, verify_installed_checkpoint_gate, wait_for_relay_closed,
17};
18use super::worker_restart::WorkerRestartLeftNoWorker;
19use super::worktree::{cleanup_managed_worktree, retire_managed_worktree};
20use super::{Controller, now, persist_session_record_transition_or_restore};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum BranchDisposition {
25 Delete,
28 Keep,
31 DeleteIfMerged,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub(super) enum SourceTargetDisposition {
42 Destroy,
44 RetainForInPlaceSwap,
47}
48
49impl Controller {
50 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 {
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 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 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 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 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 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
882pub 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
949fn 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;