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