1#[cfg(test)]
4mod tests;
5
6use anyhow::{Context, Result, bail, ensure};
7use sha2::{Digest, Sha256};
8
9use super::{Controller, SessionResumeOptions, now};
10
11fn mutation_holds() -> &'static std::sync::Mutex<std::collections::BTreeSet<String>> {
12 static HOLDS: std::sync::OnceLock<std::sync::Mutex<std::collections::BTreeSet<String>>> =
13 std::sync::OnceLock::new();
14 HOLDS.get_or_init(Default::default)
15}
16
17pub fn move_owns_session(session_id: &str) -> bool {
18 move_has_pending_queue(session_id)
19 || mutation_holds()
20 .lock()
21 .unwrap_or_else(std::sync::PoisonError::into_inner)
22 .contains(session_id)
23}
24
25fn queue_holds() -> &'static std::sync::Mutex<std::collections::BTreeSet<String>> {
26 static HOLDS: std::sync::OnceLock<std::sync::Mutex<std::collections::BTreeSet<String>>> =
27 std::sync::OnceLock::new();
28 HOLDS.get_or_init(Default::default)
29}
30
31pub fn move_has_pending_queue(session_id: &str) -> bool {
32 queue_holds()
33 .lock()
34 .unwrap_or_else(std::sync::PoisonError::into_inner)
35 .contains(session_id)
36}
37
38pub fn release_move_queue_hold(session_id: &str) {
39 queue_holds()
40 .lock()
41 .unwrap_or_else(std::sync::PoisonError::into_inner)
42 .remove(session_id);
43}
44
45pub fn restore_move_queue_hold(operation: &MoveOperation) {
46 let mut holds = queue_holds()
47 .lock()
48 .unwrap_or_else(std::sync::PoisonError::into_inner);
49 if operation.queue_admission_started && !operation.queue_admission_finished {
50 holds.insert(operation.selection.session_id.clone());
51 } else {
52 holds.remove(&operation.selection.session_id);
53 }
54}
55
56pub struct MoveMutationGuard(String);
57
58impl MoveMutationGuard {
59 pub fn reserve(session_id: &str) -> Result<Self> {
60 ensure!(
61 mutation_holds()
62 .lock()
63 .unwrap_or_else(std::sync::PoisonError::into_inner)
64 .insert(session_id.to_owned()),
65 "session already has a move owner"
66 );
67 Ok(Self(session_id.to_owned()))
68 }
69}
70
71impl Drop for MoveMutationGuard {
72 fn drop(&mut self) {
73 mutation_holds()
74 .lock()
75 .unwrap_or_else(std::sync::PoisonError::into_inner)
76 .remove(&self.0);
77 }
78}
79
80pub(crate) fn move_refuses_command(session_id: &str, command: &RelayCommand) -> bool {
81 move_owns_session(session_id)
82 && matches!(
83 command,
84 RelayCommand::Prompt { .. }
85 | RelayCommand::SetConfig { .. }
86 | RelayCommand::SetSessionMode { .. }
87 | RelayCommand::RunUserShell { .. }
88 | RelayCommand::CancelUserShell { .. }
89 | RelayCommand::Cancel
90 | RelayCommand::RemoveQueuedPrompt { .. }
91 | RelayCommand::ClearQueuedPrompts
92 )
93}
94use crate::hel_session_manager::{SessionManagerControl, StandaloneSession, new_command_id};
95use hel::hel_archive::{CanonicalQueuedCommandKind, verify_archive_streaming};
96use hel::hel_state::{MoveOperation, MovePhase, ResumeQueueDisposition, SessionState};
97pub use hel::hel_state::{MoveOutcome, MovePreparation, MoveSelection, MoveSessionRequest};
98use hel::hel_targets::{CommandExecutor, ProvisionStage, ProvisionStageGuard};
99use hel::hel_worker::RelayCommand;
100
101fn digest(value: &impl serde::Serialize) -> Result<String> {
102 Ok(format!("{:x}", Sha256::digest(serde_json::to_vec(value)?)))
103}
104
105struct MovePhaseTimer<'a> {
106 session_id: &'a str,
107 phase: &'static str,
108 started: std::time::Instant,
109}
110
111impl<'a> MovePhaseTimer<'a> {
112 fn new(session_id: &'a str, phase: &'static str) -> Self {
113 Self {
114 session_id,
115 phase,
116 started: std::time::Instant::now(),
117 }
118 }
119}
120
121impl Drop for MovePhaseTimer<'_> {
122 fn drop(&mut self) {
123 tracing::info!(
124 session_id = self.session_id,
125 phase = self.phase,
126 elapsed_ms = self.started.elapsed().as_millis() as u64,
127 "move phase finished"
128 );
129 }
130}
131
132impl Controller {
133 fn validate_move_destination_paths(
134 &self,
135 source: &hel::hel_state::SessionRecord,
136 target_id: &str,
137 executor: &(impl CommandExecutor + Sync),
138 ) -> Result<()> {
139 use super::worktree::ResumePlan;
140 match super::worktree::resume_compatibility(source, &self.config, target_id)
141 .map_err(anyhow::Error::msg)?
142 {
143 ResumePlan::RawToWorkspace => {
144 super::worktree::plan_raw_to_workspace(source, &self.config, executor)?;
145 }
146 ResumePlan::WorkspaceToRaw => {
147 self.plan_workspace_to_raw(source, target_id, executor)?;
148 }
149 ResumePlan::InPlace if source.managed_worktree.is_none() => {
150 if let Some(path) = &source.project_directory {
151 self.validate_project_directory(target_id, path, executor)?;
152 }
153 }
154 ResumePlan::InPlace => {}
155 }
156 Ok(())
157 }
158 fn move_confirmation(
159 &self,
160 selection: &MoveSelection,
161 ) -> Result<(bool, Vec<hel::hel_state::MaterializedQueuedPrompt>, String)> {
162 let source = self
163 .state
164 .sessions
165 .get(&selection.session_id)
166 .context("unknown move session")?;
167 let (mut active, mut queued) = hel::hel_database::move_pending_work(&source.id)?;
168 if let Some(operation) = hel::hel_database::load_move_operation(&source.id)?
169 && operation.queue_admission_started
170 && !operation.queue_admission_finished
171 {
172 ensure!(
173 operation.selection == *selection,
174 "queue admission is incomplete on the live destination; retry that move before selecting another destination"
175 );
176 let checkpoint = operation
177 .checkpoint
178 .as_ref()
179 .context("retained queue checkpoint is missing")?;
180 let verified = verify_archive_streaming(&checkpoint.archive_path)?;
181 ensure!(
182 verified.archive_sha256 == checkpoint.sha256
183 && verified.manifest.session.id == source.id,
184 "retained move checkpoint verification failed"
185 );
186 queued = verified
187 .canonical_session
188 .queued_prompts
189 .into_iter()
190 .map(|entry| hel::hel_state::MaterializedQueuedPrompt {
191 command_id: entry.command_id,
192 kind: match entry.kind {
193 CanonicalQueuedCommandKind::Prompt => {
194 hel::hel_state::QueuedCommandKind::Prompt
195 }
196 CanonicalQueuedCommandKind::SetConfig { key, value } => {
197 hel::hel_state::QueuedCommandKind::SetConfig { key, value }
198 }
199 },
200 content: entry.content,
201 queued_at_ms: entry.queued_at_ms,
202 })
203 .collect();
204 active = false;
205 }
206 let fingerprint = digest(&(
207 &source.last_profile,
208 &source.target_template_id,
209 &source.target,
210 &source.native_session_id,
211 &source.resource_allocation,
212 &source.additional_mounts,
213 &source.container_cpus,
214 &source.container_memory,
215 selection,
216 self.move_configuration_fingerprint(selection)?,
217 &queued,
218 ))?;
219 Ok((active, queued, fingerprint))
220 }
221 fn move_configuration_fingerprint(&self, selection: &MoveSelection) -> Result<String> {
222 let profile = self
223 .config
224 .profiles
225 .get(
226 selection
227 .profile_id
228 .as_deref()
229 .context("move profile is unresolved")?,
230 )
231 .context("move profile no longer exists")?;
232 let target = self
233 .config
234 .targets
235 .get(
236 selection
237 .target_template_id
238 .as_deref()
239 .context("move target is unresolved")?,
240 )
241 .context("move target no longer exists")?;
242 let source = self
245 .state
246 .sessions
247 .get(&selection.session_id)
248 .context("unknown move session")?;
249 digest(&(
250 profile,
251 target,
252 &self.config.bundles,
253 self.config.targets.get(&source.target_template_id),
254 self.config.profiles.get(&source.last_profile),
255 ))
256 }
257
258 pub async fn prepare_move_session_controlled(
259 &self,
260 mut selection: MoveSelection,
261 executor: &(impl CommandExecutor + Sync),
262 ) -> Result<MovePreparation> {
263 ensure!(
264 selection.profile_id.is_some() || selection.target_template_id.is_some(),
265 "move requires a target or profile selection"
266 );
267 let source = self
268 .state
269 .sessions
270 .get(&selection.session_id)
271 .context("unknown session")?;
272 let previous = hel::hel_database::load_move_operation(&source.id)?;
273 let retry = previous.as_ref().is_some_and(|op| {
274 !matches!(op.phase, MovePhase::Completed) && source.checkpoint.is_some()
275 });
276 ensure!(
277 matches!(
278 source.state,
279 SessionState::Running | SessionState::Disconnected
280 ) || retry,
281 "only active sessions can move; use Resume for a stopped or lost session"
282 );
283 selection
284 .profile_id
285 .get_or_insert_with(|| source.last_profile.clone());
286 selection
287 .target_template_id
288 .get_or_insert_with(|| source.target_template_id.clone());
289 selection
290 .additional_mounts
291 .get_or_insert_with(|| source.additional_mounts.clone());
292 ensure!(
293 !selection.clear_resource_allocation || selection.resource_allocation.is_none(),
294 "select resource allocation or explicitly clear it, not both"
295 );
296 if !selection.clear_resource_allocation {
297 selection.resource_allocation = selection
298 .resource_allocation
299 .or_else(|| source.resource_allocation.clone());
300 }
301 let profile_id = selection.profile_id.as_deref().unwrap();
302 let target_id = selection.target_template_id.as_deref().unwrap();
303 let profile = self
304 .config
305 .profiles
306 .get(profile_id)
307 .context("unknown destination profile")?;
308 ensure!(
309 profile.enabled,
310 "destination profile {profile_id:?} is disabled"
311 );
312 let target = self
313 .config
314 .targets
315 .get(target_id)
316 .context("unknown destination target")?;
317 self.validate_muse_resume_destination(source, profile.kind, target_id)?;
318 super::worktree::resume_compatibility(source, &self.config, target_id)
319 .map_err(anyhow::Error::msg)?;
320 super::backend::validate_resource_allocation(
321 target,
322 selection.resource_allocation.as_ref(),
323 )?;
324 let mounts = selection.additional_mounts.as_deref().unwrap_or_default();
325 ensure!(
326 profile.kind != hel::hel_config::HarnessKind::Muse || mounts.is_empty(),
327 "Muse Code ACP supports one workspace root; attached directories are unsupported"
328 );
329 ensure!(
330 mounts.is_empty() || hel::hel_config::mount_history_host(target).is_some(),
331 "attached resources are unsupported for this target; select compatible resources explicitly"
332 );
333 hel::hel_targets::validate_additional_mounts(mounts)?;
334 for mount in mounts {
335 self.validate_mount_source(target_id, &mount.source, executor)?;
336 }
337 self.validate_move_destination_paths(source, target_id, executor)?;
338 ensure!(
339 profile.home.is_dir(),
340 "destination profile home is unavailable; configure the profile before moving"
341 );
342 super::worker_binary::preflight_worker_binary(target)?;
343 super::backend::preflight_target(target, executor)?;
344 let source_harness = previous
345 .as_ref()
346 .filter(|operation| {
347 !operation.queue_admission_started && operation.phase != MovePhase::Completed
348 })
349 .and_then(|operation| operation.recovery_session.as_ref())
350 .map_or(source.harness_kind, |record| record.harness_kind);
351 let cross_harness = profile.kind != source_harness;
352 if cross_harness {
353 let cancel = tokio_util::sync::CancellationToken::new();
354 let resolve =
355 crate::hel_utility_llm::UtilityLlmRuntime::shared().resolve(&self.config, &cancel);
356 tokio::pin!(resolve);
357 loop {
358 tokio::select! {
359 result = &mut resolve => { result.context("cross-harness move needs an available utility model")?; break; }
360 _ = tokio::time::sleep(std::time::Duration::from_millis(50)) => {
361 if executor.cancellation_requested() { cancel.cancel(); bail!("move preparation cancelled"); }
362 }
363 }
364 }
365 }
366 let (active, mut queued_commands, fingerprint) = self.move_confirmation(&selection)?;
367 for entry in &mut queued_commands {
370 for content in &mut entry.content {
371 if content.get("type").and_then(serde_json::Value::as_str) == Some("image") {
372 let mime = content
373 .get("mimeType")
374 .and_then(serde_json::Value::as_str)
375 .unwrap_or("image");
376 *content = serde_json::json!({"type":"text", "text":format!("[Image attachment: {mime}]")});
377 }
378 }
379 }
380 let operation_id = previous
381 .as_ref()
382 .filter(|operation| {
383 operation.selection == selection
384 && operation.phase != MovePhase::Completed
385 && operation.checkpoint.is_some()
386 })
387 .map(|operation| operation.operation_id.clone())
388 .unwrap_or(new_command_id("move")?);
389 Ok(MovePreparation {
390 selection,
391 source_profile_id: source.last_profile.clone(),
392 source_target_template_id: source.target_template_id.clone(),
393 cross_harness,
394 active,
395 queued_commands,
396 fingerprint,
397 operation_id,
398 })
399 }
400
401 pub async fn move_session_managed_controlled(
403 &mut self,
404 request: MoveSessionRequest,
405 executor: &(impl CommandExecutor + Sync),
406 manager: &SessionManagerControl,
407 ) -> Result<MoveOutcome> {
408 let prepared = &request.preparation;
409 let id = prepared.selection.session_id.clone();
410 let started = std::time::Instant::now();
411 executor.notify_notice("Checking destination");
412 let checked = {
413 let _checking_destination =
414 ProvisionStageGuard::new(executor, ProvisionStage::Verifying);
415 let mut checked = self
416 .prepare_move_session_controlled(prepared.selection.clone(), executor)
417 .await?;
418 if matches!(
421 self.state.sessions[&id].state,
422 SessionState::Running | SessionState::Disconnected
423 ) {
424 let source_harness = self.state.sessions[&id].harness_kind;
425 let handle = manager
426 .wait_for_session(&id, std::time::Duration::from_secs(5))
427 .await?;
428 handle.sync_now().await?;
429 let (active, queue, fingerprint) = self.move_confirmation(&checked.selection)?;
430 checked.active = active
431 || handle.view().snapshot.as_ref().is_some_and(|snapshot| {
432 let mut operational = snapshot.operational.clone();
433 operational.queued_prompts.clear();
434 operational.checkpoint_barrier = None;
435 !operational.safe_to_replace(source_harness)
436 });
437 checked.queued_commands = queue;
438 checked.fingerprint = fingerprint;
439 }
440 checked
441 };
442 ensure!(
443 checked.fingerprint == prepared.fingerprint,
444 "session, pending work, or destination configuration changed; prepare and confirm Move again"
445 );
446 let queue = request.queue.unwrap_or(ResumeQueueDisposition::Discard);
447 let source = self.state.sessions[&id].clone();
448 let old_operation = hel::hel_database::load_move_operation(&id)?;
449 let retry = old_operation.filter(|op| {
450 op.selection == prepared.selection
451 && op.phase != MovePhase::Completed
452 && op.checkpoint.is_some()
453 });
454 if retry.is_none()
455 && source.state == SessionState::Running
456 && source.last_profile == checked.selection.profile_id.as_deref().unwrap()
457 && source.target_template_id == checked.selection.target_template_id.as_deref().unwrap()
458 && Some(&source.additional_mounts) == checked.selection.additional_mounts.as_ref()
459 && source.resource_allocation == checked.selection.resource_allocation
460 && (!checked.selection.clear_resource_allocation
461 || (source.container_cpus.is_none() && source.container_memory.is_none()))
462 {
463 return Ok(outcome(
464 &prepared.operation_id,
465 &prepared.selection,
466 "unchanged",
467 None,
468 None,
469 ));
470 }
471 ensure!(
472 !checked.active || request.acknowledge_interruption,
473 "active work will be interrupted; confirm Move again with interruption acknowledgement"
474 );
475 ensure!(
476 checked.queued_commands.is_empty() || request.queue.is_some(),
477 "pending work requires an explicit queue choice: discard or start"
478 );
479 let timestamp = now();
480 let mut operation = match retry {
481 Some(mut op) => {
482 ensure!(
483 !op.queue_admission_started || op.queue == queue,
484 "queued work may already have run; retry with the original queue choice on the same destination"
485 );
486 hel::hel_database::clear_move_cancellation_for_retry(&id)?;
487 op.configuration_fingerprint =
488 self.move_configuration_fingerprint(&checked.selection)?;
489 op.queue = queue;
490 op.cancellation_requested = false;
491 op.error = None;
492 op
493 }
494 None => MoveOperation {
495 operation_id: prepared.operation_id.clone(),
496 selection: checked.selection.clone(),
497 source_profile_id: source.last_profile.clone(),
498 source_target_template_id: source.target_template_id.clone(),
499 source_target: source.target.clone(),
500 source_native_session_id: source.native_session_id.clone(),
501 source_additional_mounts: source.additional_mounts.clone(),
502 source_resource_allocation: source.resource_allocation.clone(),
503 destination_target: None,
504 destination_native_session_id: None,
505 destination_store_id: None,
506 configuration_fingerprint: self
507 .move_configuration_fingerprint(&checked.selection)?,
508 checkpoint: None,
509 recovery_session: None,
510 queue,
511 phase: MovePhase::Preparing,
512 queue_admission_started: false,
513 queue_admission_finished: false,
514 cancellation_requested: false,
515 created_at: timestamp.clone(),
516 updated_at: timestamp,
517 error: None,
518 },
519 };
520 hel::hel_database::save_move_operation(&operation)?;
521 tracing::info!(
522 session_id = id,
523 phase = "preflight",
524 elapsed_ms = started.elapsed().as_millis() as u64,
525 "move phase completed"
526 );
527 let result = self
528 .execute_move(&mut operation, Some(&checked), executor, manager)
529 .await;
530 self.finish_move_result(&mut operation, result, executor)
531 }
532
533 fn finish_move_result(
534 &self,
535 operation: &mut MoveOperation,
536 result: Result<()>,
537 executor: &impl CommandExecutor,
538 ) -> Result<MoveOutcome> {
539 let (status, error, recovery) = match result {
540 Ok(()) => {
541 operation.phase = MovePhase::Completed;
542 ("completed", None, None)
543 }
544 Err(error) => {
545 let cancelled =
546 executor.cancellation_requested() || operation.cancellation_requested;
547 operation.phase = if cancelled {
548 MovePhase::Cancelled
549 } else {
550 MovePhase::Failed
551 };
552 operation.cancellation_requested = cancelled;
553 let recovery = if operation.queue_admission_started {
554 "Destination is live; retry queue admission on this same destination. Already accepted work may have effects."
555 } else if self
556 .state
557 .sessions
558 .get(&operation.selection.session_id)
559 .is_some_and(|s| s.state == SessionState::Stopped)
560 {
561 "Session is stopped with a verified checkpoint. Retry move or Resume with previous settings."
562 } else {
563 "Source or partial destination is retained. Retry move after resolving the reported error."
564 };
565 let error = format!("{error:#}");
566 operation.error = Some(error.clone());
567 (
568 if cancelled { "cancelled" } else { "failed" },
569 Some(error),
570 Some(recovery.to_owned()),
571 )
572 }
573 };
574 operation.updated_at = now();
575 hel::hel_database::save_move_operation(operation)?;
576 Ok(outcome(
577 &operation.operation_id,
578 &operation.selection,
579 status,
580 error,
581 recovery,
582 ))
583 }
584
585 pub async fn recover_move_managed_controlled(
586 &mut self,
587 mut operation: MoveOperation,
588 executor: &(impl CommandExecutor + Sync),
589 manager: &SessionManagerControl,
590 ) -> Result<MoveOutcome> {
591 let id = operation.selection.session_id.clone();
592 let result = async {
593 let session = self.state.sessions.get(&id).context("move session is missing")?.clone();
594 if operation.queue_admission_started {
595 ensure!(!operation.cancellation_requested, "Move was cancelled; destination retained without further queue admission");
596 return self.admit_move_queue(&mut operation, executor).await;
597 }
598 if matches!(session.state, SessionState::Closing | SessionState::Destroying) {
599 let cleanup = hel::hel_targets::CancellableProcessExecutor::with_timeout(std::time::Duration::from_secs(15));
600 self.recover_move_source_stop(&mut operation, &cleanup, manager).await?;
601 operation.checkpoint = self.state.sessions[&id].checkpoint.clone();
602 if self.state.sessions[&id].state == SessionState::Stopped && self.state.sessions[&id].target.is_some() {
603 self.cleanup_stopped_target(&id, &cleanup)?;
604 }
605 bail!("Move source stop recovered; no destination work was started. Retry Move or Resume with previous settings");
606 }
607 match operation.phase {
608 MovePhase::Preparing => bail!("Move preparation was interrupted; source retained. Prepare Move again."),
609 MovePhase::ResumingDestination if session.state == SessionState::Running => {
610 ensure!(Some(&session.last_profile) == operation.selection.profile_id.as_ref()
614 && Some(&session.target_template_id) == operation.selection.target_template_id.as_ref(),
615 "ready destination does not match the move intent");
616 operation.destination_target = session.target.clone();
617 operation.destination_native_session_id = session.native_session_id.clone();
618 operation.queue_admission_started = true;
619 operation.phase = MovePhase::StartingQueue;
620 hel::hel_database::save_move_operation(&operation)?;
621 restore_move_queue_hold(&operation);
622 ensure!(!operation.cancellation_requested, "Move was cancelled; ready destination retained");
623 self.admit_move_queue(&mut operation, executor).await
624 }
625 MovePhase::ResumingDestination => {
626 let previous = operation.recovery_session.as_ref().context("move lacks its stopped recovery identity; retain resources for inspection")?;
627 let error = self.rollback_failed_resume(&id, previous, false,
628 anyhow::anyhow!("destination restoration was interrupted; checkpoint retained for an explicit retry"), executor)?;
629 Err(error)
630 }
631 MovePhase::ClosingSource => {
632 if matches!(session.state, SessionState::Closing | SessionState::Destroying) {
633 self.recover_move_source_stop(&mut operation, executor, manager).await?;
634 }
635 operation.checkpoint = self.state.sessions[&id].checkpoint.clone();
636 if self.state.sessions[&id].state == SessionState::Stopped && self.state.sessions[&id].target.is_some() {
637 self.cleanup_stopped_target(&id, executor)?;
638 }
639 bail!("source stop was recovered; verified checkpoint retained. Retry move or Resume with previous settings")
640 }
641 _ => bail!("Move requires an explicit retry after the daemon restarted"),
642 }
643 }.await;
644 self.finish_move_result(&mut operation, result, executor)
645 }
646
647 async fn recover_move_source_stop(
648 &mut self,
649 operation: &mut MoveOperation,
650 executor: &(impl CommandExecutor + Sync),
651 manager: &SessionManagerControl,
652 ) -> Result<()> {
653 let id = operation.selection.session_id.clone();
654 if self.state.sessions[&id].state == SessionState::Closing {
655 let handle = manager
656 .wait_for_session(&id, std::time::Duration::from_secs(5))
657 .await?;
658 let mut lease = handle.lease_connection().await?;
659 let execution = lease.connection_mut().sync().await?.operational.execution;
660 lease.release();
661 if matches!(
662 execution,
663 hel::hel_worker::RelayExecutionState::Idle
664 | hel::hel_worker::RelayExecutionState::Running
665 ) {
666 if operation.cancellation_requested {
667 let record = self.state.sessions.get_mut(&id).unwrap();
668 record.state = SessionState::Running;
669 record.updated_at = now();
670 record.last_error =
671 Some("Move was cancelled before the source was sealed".into());
672 hel::hel_database::save_lifecycle_session(record)?;
673 } else {
674 self.close_session_for_move(&id, executor, manager, operation, None)
675 .await?;
676 }
677 return Ok(());
678 }
679 }
680 self.recover_interrupted_close_managed(&id, executor, manager)
681 .await?;
682 Ok(())
683 }
684
685 async fn execute_move(
686 &mut self,
687 operation: &mut MoveOperation,
688 preparation: Option<&MovePreparation>,
689 executor: &(impl CommandExecutor + Sync),
690 manager: &SessionManagerControl,
691 ) -> Result<()> {
692 let id = operation.selection.session_id.clone();
693 ensure!(
694 !executor.cancellation_requested(),
695 "move cancelled before source interruption"
696 );
697 if !operation.queue_admission_started {
698 if self.state.sessions[&id].state == SessionState::Error
699 && let Some(previous) = operation.recovery_session.as_ref()
700 {
701 let failure = self.rollback_failed_resume(
704 &id,
705 previous,
706 false,
707 anyhow::anyhow!("clean up the partial Move destination before retry"),
708 executor,
709 )?;
710 ensure!(
711 self.state.sessions[&id].state == SessionState::Stopped,
712 "{failure:#}"
713 );
714 }
715 let state = self.state.sessions[&id].state;
716 if matches!(state, SessionState::Closing | SessionState::Destroying) {
717 self.recover_move_source_stop(operation, executor, manager)
718 .await?;
719 operation.checkpoint = self.state.sessions[&id].checkpoint.clone();
720 } else if matches!(state, SessionState::Running | SessionState::Disconnected)
721 && operation.destination_target.is_none()
722 {
723 executor.notify_notice("Stopping source");
724 let _timing = MovePhaseTimer::new(&id, "checkpoint and source stop");
725 operation.phase = MovePhase::ClosingSource;
726 operation.updated_at = now();
727 hel::hel_database::save_move_operation(operation)?;
728 self.close_session_for_move(&id, executor, manager, operation, preparation)
729 .await?;
730 }
731 if self.state.sessions[&id].state == SessionState::Stopped
732 && self.state.sessions[&id].target.is_some()
733 {
734 executor.notify_notice("Cleaning up source");
735 let _timing = MovePhaseTimer::new(&id, "source storage cleanup");
736 self.cleanup_stopped_target(&id, executor)?;
737 }
738 operation.checkpoint = operation
739 .checkpoint
740 .clone()
741 .or_else(|| self.state.sessions[&id].checkpoint.clone());
742 ensure!(
743 operation.checkpoint.is_some(),
744 "move has no verified checkpoint"
745 );
746 ensure!(
747 !executor.cancellation_requested(),
748 "move cancelled after source teardown; session is stopped"
749 );
750 operation.phase = MovePhase::ResumingDestination;
751 operation.recovery_session = Some(self.state.sessions[&id].clone());
752 operation.updated_at = now();
753 hel::hel_database::save_move_operation(operation)?;
754 executor.notify_notice("Preparing destination");
755 if operation.selection.clear_resource_allocation {
756 let session = self.state.sessions.get_mut(&id).unwrap();
757 session.resource_allocation = None;
758 session.container_cpus = None;
759 session.container_memory = None;
760 hel::hel_database::save_session(session)?;
761 }
762 self.resume_session_controlled(
763 &id,
764 operation.selection.profile_id.as_deref().unwrap(),
765 operation.selection.target_template_id.as_deref().unwrap(),
766 SessionResumeOptions {
767 additional_mounts: operation.selection.additional_mounts.clone(),
768 resource_allocation: operation.selection.resource_allocation.clone(),
769 discard_queue: true,
770 },
771 executor,
772 )
773 .await?;
774 let destination = &self.state.sessions[&id];
775 operation.destination_target = destination.target.clone();
776 operation.destination_native_session_id = destination.native_session_id.clone();
777 operation.phase = MovePhase::StartingQueue;
779 operation.queue_admission_started = true;
780 operation.updated_at = now();
781 hel::hel_database::save_move_operation(operation)?;
782 }
783 restore_move_queue_hold(operation);
784 self.admit_move_queue(operation, executor).await
785 }
786
787 async fn admit_move_queue(
788 &self,
789 operation: &mut MoveOperation,
790 executor: &(impl CommandExecutor + Sync),
791 ) -> Result<()> {
792 let timing_id = operation.selection.session_id.clone();
793 let _timing = MovePhaseTimer::new(&timing_id, "queue admission");
794 let id = &operation.selection.session_id;
795 let mut relay = {
796 let _checking_destination =
797 ProvisionStageGuard::new(executor, ProvisionStage::Verifying);
798 let destination = &self.state.sessions[id];
799 ensure!(
800 destination.state == SessionState::Running
801 && destination.target == operation.destination_target
802 && destination.native_session_id == operation.destination_native_session_id,
803 "cannot prove the same ready destination; refusing to replay potentially executed work"
804 );
805 let spec = self.reconnect_command(id)?;
806 let relay = StandaloneSession::connect_command(&spec, id).await?;
807 let store_id = relay.snapshot().operational.store_id.context("destination worker does not expose its durable store identity; upgrade the worker before admitting queued work")?;
808 if let Some(expected) = &operation.destination_store_id {
809 ensure!(
810 *expected == store_id,
811 "destination relay storage was replaced; refusing to replay potentially executed work"
812 );
813 } else {
814 operation.destination_store_id = Some(store_id);
816 hel::hel_database::save_move_operation(operation)?;
817 }
818 ensure!(
819 relay.snapshot().operational.native_session_id
820 == operation.destination_native_session_id,
821 "destination relay native identity changed; refusing queue replay"
822 );
823 relay
824 };
825 if operation.queue == ResumeQueueDisposition::Start && !operation.queue_admission_finished {
826 let _starting_queue = ProvisionStageGuard::new(executor, ProvisionStage::Starting);
827 executor.notify_notice("Starting queued work");
828 let checkpoint = operation
829 .checkpoint
830 .as_ref()
831 .context("move queue archive is missing")?;
832 let verified = verify_archive_streaming(&checkpoint.archive_path)?;
833 ensure!(
834 verified.archive_sha256 == checkpoint.sha256 && verified.manifest.session.id == *id,
835 "move queue checkpoint verification failed"
836 );
837 for queued in verified.canonical_session.queued_prompts {
838 ensure!(
839 !executor.cancellation_requested(),
840 "move cancelled during queue admission; destination retained"
841 );
842 let command = match queued.kind {
843 CanonicalQueuedCommandKind::Prompt => RelayCommand::Prompt {
844 prompt: queued
845 .content
846 .into_iter()
847 .map(serde_json::from_value)
848 .collect::<serde_json::Result<_>>()?,
849 },
850 CanonicalQueuedCommandKind::SetConfig { key, value } => {
851 RelayCommand::SetConfig { key, value }
852 }
853 };
854 relay.submit(queued.command_id, command).await?;
855 }
856 }
857 operation.queue_admission_finished = true;
858 hel::hel_database::save_move_operation(operation)?;
859 restore_move_queue_hold(operation);
860 relay.submit(format!("{}-notice", operation.operation_id), RelayCommand::RecordNotice {
861 text: format!("Moved from {} / {} to {} / {} in a fresh environment. {} The interrupted prompt was not replayed.",
862 operation.source_profile_id, operation.source_target_template_id,
863 operation.selection.profile_id.as_deref().unwrap(), operation.selection.target_template_id.as_deref().unwrap(),
864 if operation.queue == ResumeQueueDisposition::Discard { "Queued work was discarded; ready and idle." } else { "Queued work was accepted." }),
865 }).await?;
866 Ok(())
867 }
868
869 pub(super) fn validate_move_checkpoint(
870 &self,
871 operation: &MoveOperation,
872 preparation: Option<&MovePreparation>,
873 executor: &(impl CommandExecutor + Sync),
874 ) -> Result<()> {
875 let _verifying = ProvisionStageGuard::new(executor, ProvisionStage::Verifying);
876 let current = Controller {
877 config: hel::hel_config::HelConfig::load()?,
878 state: self.state.clone(),
879 };
880 ensure!(
881 current.move_configuration_fingerprint(&operation.selection)?
882 == operation.configuration_fingerprint,
883 "destination configuration changed during move"
884 );
885 let id = &operation.selection.session_id;
886 current.validate_move_destination_paths(
887 &self.state.sessions[id],
888 operation.selection.target_template_id.as_deref().unwrap(),
889 executor,
890 )?;
891 if let super::ResumeRepositorySourcePreflight::RepositoryMoved(mismatch) = self
892 .preflight_resume_repository_sources(
893 id,
894 operation.selection.target_template_id.as_deref().unwrap(),
895 executor,
896 )?
897 {
898 bail!(
899 "destination repository source is missing checkpoint commit {}; source retained",
900 mismatch.missing_commit
901 );
902 }
903 if let Some(prepared) = preparation {
904 let checkpoint = self.state.sessions[id]
905 .checkpoint
906 .as_ref()
907 .context("no move checkpoint")?;
908 let verified = verify_archive_streaming(&checkpoint.archive_path)?;
909 let actual: Vec<_> = verified
910 .canonical_session
911 .queued_prompts
912 .iter()
913 .map(|p| p.command_id.as_str())
914 .collect();
915 let expected: Vec<_> = prepared
916 .queued_commands
917 .iter()
918 .map(|p| p.command_id.as_str())
919 .collect();
920 ensure!(
921 actual == expected,
922 "pending queue changed before checkpoint capture; source retained, confirm Move again"
923 );
924 }
925 Ok(())
926 }
927}
928
929fn outcome(
930 operation_id: &str,
931 selection: &MoveSelection,
932 status: &str,
933 error: Option<String>,
934 recovery: Option<String>,
935) -> MoveOutcome {
936 MoveOutcome {
937 operation_id: operation_id.into(),
938 session_id: selection.session_id.clone(),
939 profile_id: selection.profile_id.clone().unwrap_or_default(),
940 target_template_id: selection.target_template_id.clone().unwrap_or_default(),
941 outcome: status.into(),
942 error,
943 recovery,
944 }
945}