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 async fn validate_move_destination_configuration(
288 &self,
289 selection: &MoveSelection,
290 source_harness: mj_core::config::HarnessKind,
291 operational: &mj_core::relay::RelayOperationalState,
292 ) -> Result<()> {
293 let profile_id = selection
294 .profile_id
295 .as_deref()
296 .context("move profile is unresolved")?;
297 let profile = self
298 .config
299 .profiles
300 .get(profile_id)
301 .context("move profile no longer exists")?;
302 let source = self
303 .state
304 .sessions
305 .get(&selection.session_id)
306 .context("unknown move session")?;
307 if profile.kind != source_harness || profile_id == source.last_profile {
308 return Ok(());
309 }
310 let accepted = mj_core::acp::AcceptedSessionConfig::from_configuration(
311 &operational.config,
312 &operational.config_options,
313 );
314 if accepted.model.is_none() && accepted.effort.is_none() {
315 return Ok(());
316 }
317 let choices =
320 super::profile_config::discover(profile_id.to_owned(), accepted.model.clone(), true)
321 .await
322 .with_context(|| {
323 format!("discover destination profile {profile_id:?} configuration")
324 })?;
325 validate_preserved_configuration(profile_id, &accepted, &choices)
326 }
327
328 pub async fn prepare_move_session_controlled(
329 &self,
330 mut selection: MoveSelection,
331 executor: &(impl CommandExecutor + Sync),
332 ) -> Result<MovePreparation> {
333 ensure!(
334 selection.profile_id.is_some() || selection.target_template_id.is_some(),
335 "move requires a target or profile selection"
336 );
337 let source = self
338 .state
339 .sessions
340 .get(&selection.session_id)
341 .context("unknown session")?;
342 ensure!(
343 !self.state.subagents.contains_key(&source.id),
344 "sub-agent sessions cannot move independently of their parent"
345 );
346 ensure!(
347 !self.state.subagents.values().any(|child| {
348 child.parent_session_id == source.id
349 && self
350 .state
351 .sessions
352 .get(&child.child_session_id)
353 .is_some_and(|session| session.state.is_active())
354 }),
355 "stop active sub-agents before moving their parent session"
356 );
357 let previous = crate::database::load_move_operation(&source.id)?;
358 let retry = previous.as_ref().is_some_and(|op| {
359 !matches!(op.phase, MovePhase::Completed) && source.checkpoint.is_some()
360 });
361 ensure!(
362 matches!(
363 source.state,
364 SessionState::Running | SessionState::Disconnected
365 ) || retry,
366 "only active sessions can move; use Resume for a stopped or lost session"
367 );
368 selection
369 .profile_id
370 .get_or_insert_with(|| source.last_profile.clone());
371 selection
372 .target_template_id
373 .get_or_insert_with(|| source.target_template_id.clone());
374 selection
375 .additional_mounts
376 .get_or_insert_with(|| source.additional_mounts.clone());
377 ensure!(
378 !selection.clear_resource_allocation || selection.resource_allocation.is_none(),
379 "select resource allocation or explicitly clear it, not both"
380 );
381 if !selection.clear_resource_allocation {
382 selection.resource_allocation = selection
383 .resource_allocation
384 .or_else(|| source.resource_allocation.clone());
385 }
386 let profile_id = selection.profile_id.as_deref().unwrap();
387 let target_id = selection.target_template_id.as_deref().unwrap();
388 let profile = self
389 .config
390 .profiles
391 .get(profile_id)
392 .context("unknown destination profile")?;
393 ensure!(
394 profile.enabled,
395 "destination profile {profile_id:?} is disabled"
396 );
397 let target = self
398 .config
399 .targets
400 .get(target_id)
401 .context("unknown destination target")?;
402 self.validate_muse_resume_destination(source, profile.kind, target_id)?;
403 super::worktree::resume_compatibility(source, &self.config, target_id)
404 .map_err(anyhow::Error::msg)?;
405 super::backend::validate_resource_allocation(
406 target,
407 selection.resource_allocation.as_ref(),
408 )?;
409 let mounts = selection.additional_mounts.as_deref().unwrap_or_default();
410 ensure!(
411 profile.kind != mj_core::config::HarnessKind::Muse || mounts.is_empty(),
412 "Muse Code ACP supports one workspace root; attached directories are unsupported"
413 );
414 ensure!(
415 mounts.is_empty() || mj_core::config::mount_history_host(target).is_some(),
416 "attached resources are unsupported for this target; select compatible resources explicitly"
417 );
418 crate::targets::validate_additional_mounts(mounts)?;
419 for mount in mounts {
420 self.validate_mount_source(target_id, &mount.source, executor)?;
421 }
422 self.validate_move_destination_paths(source, target_id, executor)?;
423 ensure!(
424 profile.home.is_dir(),
425 "destination profile home is unavailable; configure the profile before moving"
426 );
427 super::worker_binary::preflight_worker_binary(target)?;
428 super::backend::preflight_target(target, executor)?;
429 let source_harness = previous
430 .as_ref()
431 .filter(|operation| {
432 !operation.queue_admission_started && operation.phase != MovePhase::Completed
433 })
434 .and_then(|operation| operation.recovery_session.as_ref())
435 .map_or(source.harness_kind, |record| record.harness_kind);
436 let cross_harness = profile.kind != source_harness;
437 if cross_harness {
438 let cancel = tokio_util::sync::CancellationToken::new();
439 let resolve =
440 crate::utility_llm::UtilityLlmRuntime::shared().resolve(&self.config, &cancel);
441 tokio::pin!(resolve);
442 loop {
443 tokio::select! {
444 result = &mut resolve => { result.context("cross-harness move needs an available utility model")?; break; }
445 _ = tokio::time::sleep(std::time::Duration::from_millis(50)) => {
446 if executor.cancellation_requested() { cancel.cancel(); bail!("move preparation cancelled"); }
447 }
448 }
449 }
450 }
451 let (active, mut queued_commands, fingerprint) = self.move_confirmation(&selection)?;
452 for entry in &mut queued_commands {
455 for content in &mut entry.content {
456 if content.get("type").and_then(serde_json::Value::as_str) == Some("image") {
457 let mime = content
458 .get("mimeType")
459 .and_then(serde_json::Value::as_str)
460 .unwrap_or("image");
461 *content = serde_json::json!({"type":"text", "text":format!("[Image attachment: {mime}]")});
462 }
463 }
464 }
465 let operation_id = previous
466 .as_ref()
467 .filter(|operation| {
468 operation.selection == selection
469 && operation.phase != MovePhase::Completed
470 && operation.checkpoint.is_some()
471 })
472 .map(|operation| operation.operation_id.clone())
473 .unwrap_or(new_command_id("move")?);
474 Ok(MovePreparation {
475 source_unavailable: false,
476 selection,
477 source_profile_id: source.last_profile.clone(),
478 source_target_template_id: source.target_template_id.clone(),
479 cross_harness,
480 active,
481 queued_commands,
482 fingerprint,
483 operation_id,
484 })
485 }
486
487 pub async fn move_session_managed_controlled(
489 &mut self,
490 request: MoveSessionRequest,
491 executor: &(impl CommandExecutor + Sync),
492 manager: &SessionManagerControl,
493 ) -> Result<MoveOutcome> {
494 let prepared = &request.preparation;
495 let id = prepared.selection.session_id.clone();
496 let started = std::time::Instant::now();
497 executor.notify_notice("Checking destination");
498 let checked = {
499 let _checking_destination =
500 ProvisionStageGuard::new(executor, ProvisionStage::Verifying);
501 let mut checked = self
502 .prepare_move_session_controlled(prepared.selection.clone(), executor)
503 .await?;
504 if matches!(
507 self.state.sessions[&id].state,
508 SessionState::Running | SessionState::Disconnected
509 ) {
510 let source_harness = self.state.sessions[&id].harness_kind;
511 let snapshot = refresh_move_source(manager, &id).await?;
512 let (active, queue, fingerprint) = self.move_confirmation(&checked.selection)?;
513 checked.source_unavailable = snapshot
514 .as_ref()
515 .is_none_or(|snapshot| !snapshot.operational.native_session_is_ready());
516 checked.active = active
517 || checked.source_unavailable
518 || snapshot.as_ref().is_some_and(|snapshot| {
519 let mut operational = snapshot.operational.clone();
520 operational.queued_prompts.clear();
521 operational.checkpoint_barrier = None;
522 !operational.safe_to_replace(source_harness)
523 });
524 if let Some(snapshot) = &snapshot {
525 self.validate_move_destination_configuration(
526 &checked.selection,
527 source_harness,
528 &snapshot.operational,
529 )
530 .await?;
531 }
532 checked.queued_commands = queue;
533 checked.fingerprint = fingerprint;
534 }
535 checked
536 };
537 ensure!(
538 checked.fingerprint == prepared.fingerprint,
539 "session, pending work, or destination configuration changed; prepare and confirm Move again"
540 );
541 let queue = request.queue.unwrap_or(ResumeQueueDisposition::Discard);
542 let source = self.state.sessions[&id].clone();
543 let old_operation = crate::database::load_move_operation(&id)?;
544 let retry = old_operation.filter(|op| {
545 op.selection == prepared.selection
546 && op.phase != MovePhase::Completed
547 && op.checkpoint.is_some()
548 });
549 if retry.is_none()
550 && source.state == SessionState::Running
551 && source.last_profile == checked.selection.profile_id.as_deref().unwrap()
552 && source.target_template_id == checked.selection.target_template_id.as_deref().unwrap()
553 && Some(&source.additional_mounts) == checked.selection.additional_mounts.as_ref()
554 && source.resource_allocation == checked.selection.resource_allocation
555 && (!checked.selection.clear_resource_allocation
556 || (source.container_cpus.is_none() && source.container_memory.is_none()))
557 {
558 return Ok(outcome(
559 &prepared.operation_id,
560 &prepared.selection,
561 "unchanged",
562 None,
563 None,
564 ));
565 }
566 ensure!(
567 !checked.active || request.acknowledge_interruption,
568 "active work will be interrupted; confirm Move again with interruption acknowledgement"
569 );
570 ensure!(
571 checked.queued_commands.is_empty() || request.queue.is_some(),
572 "pending work requires an explicit queue choice: discard or start"
573 );
574 let timestamp = now();
575 let mut operation = match retry {
576 Some(mut op) => {
577 ensure!(
578 !op.queue_admission_started || op.queue == queue,
579 "queued work may already have run; retry with the original queue choice on the same destination"
580 );
581 crate::database::clear_move_cancellation_for_retry(&id)?;
582 op.configuration_fingerprint =
583 self.move_configuration_fingerprint(&checked.selection)?;
584 op.queue = queue;
585 op.cancellation_requested = false;
586 op.error = None;
587 op
588 }
589 None => MoveOperation {
590 source_checkpoint_only: false,
591 operation_id: prepared.operation_id.clone(),
592 selection: checked.selection.clone(),
593 source_profile_id: source.last_profile.clone(),
594 source_target_template_id: source.target_template_id.clone(),
595 source_target: source.target.clone(),
596 source_native_session_id: source.native_session_id.clone(),
597 source_additional_mounts: source.additional_mounts.clone(),
598 source_resource_allocation: source.resource_allocation.clone(),
599 destination_target: None,
600 destination_native_session_id: None,
601 destination_store_id: None,
602 configuration_fingerprint: self
603 .move_configuration_fingerprint(&checked.selection)?,
604 checkpoint: None,
605 recovery_session: None,
606 queue,
607 phase: MovePhase::Preparing,
608 queue_admission_started: false,
609 queue_admission_finished: false,
610 cancellation_requested: false,
611 created_at: timestamp.clone(),
612 updated_at: timestamp,
613 error: None,
614 },
615 };
616 crate::database::save_move_operation(&operation)?;
617 tracing::info!(
618 session_id = id,
619 phase = "preflight",
620 elapsed_ms = started.elapsed().as_millis() as u64,
621 "move phase completed"
622 );
623 let result = self
624 .execute_move(&mut operation, Some(&checked), executor, manager)
625 .await;
626 self.finish_move_result(&mut operation, result, executor)
627 }
628
629 fn finish_move_result(
630 &self,
631 operation: &mut MoveOperation,
632 result: Result<()>,
633 executor: &impl CommandExecutor,
634 ) -> Result<MoveOutcome> {
635 let (status, error, recovery) = match result {
636 Ok(()) => {
637 operation.phase = MovePhase::Completed;
638 ("completed", None, None)
639 }
640 Err(error) => {
641 let cancelled =
642 executor.cancellation_requested() || operation.cancellation_requested;
643 operation.phase = if cancelled {
644 MovePhase::Cancelled
645 } else {
646 MovePhase::Failed
647 };
648 operation.cancellation_requested = cancelled;
649 let recovery = if operation.queue_admission_started {
650 "Destination is live; retry queue admission on this same destination. Already accepted work may have effects."
651 } else if self
652 .state
653 .sessions
654 .get(&operation.selection.session_id)
655 .is_some_and(|s| s.state == SessionState::Stopped)
656 {
657 "Session is stopped with a verified checkpoint. Retry move or Resume with previous settings."
658 } else {
659 "Source or partial destination is retained. Retry move after resolving the reported error."
660 };
661 let error = format!("{error:#}");
662 operation.error = Some(error.clone());
663 (
664 if cancelled { "cancelled" } else { "failed" },
665 Some(error),
666 Some(recovery.to_owned()),
667 )
668 }
669 };
670 operation.updated_at = now();
671 crate::database::save_move_operation(operation)?;
672 Ok(outcome(
673 &operation.operation_id,
674 &operation.selection,
675 status,
676 error,
677 recovery,
678 ))
679 }
680
681 pub async fn recover_move_managed_controlled(
682 &mut self,
683 mut operation: MoveOperation,
684 executor: &(impl CommandExecutor + Sync),
685 manager: &SessionManagerControl,
686 ) -> Result<MoveOutcome> {
687 let id = operation.selection.session_id.clone();
688 let result = async {
689 let session = self.state.sessions.get(&id).context("move session is missing")?.clone();
690 if operation.queue_admission_started {
691 ensure!(!operation.cancellation_requested, "Move was cancelled; destination retained without further queue admission");
692 return self.admit_move_queue(&mut operation, executor).await;
693 }
694 if matches!(session.state, SessionState::Closing | SessionState::Destroying) {
695 let cleanup = crate::targets::CancellableProcessExecutor::with_timeout(std::time::Duration::from_secs(15));
696 self.recover_move_source_stop(&mut operation, &cleanup, manager).await?;
697 operation.checkpoint = self.state.sessions[&id].checkpoint.clone();
698 if self.state.sessions[&id].state == SessionState::Stopped && self.state.sessions[&id].target.is_some() {
699 self.cleanup_stopped_target(&id, &cleanup)?;
700 }
701 bail!("Move source stop recovered; no destination work was started. Retry Move or Resume with previous settings");
702 }
703 match operation.phase {
704 MovePhase::Preparing => bail!("Move preparation was interrupted; source retained. Prepare Move again."),
705 MovePhase::ResumingDestination if session.state == SessionState::Running => {
706 ensure!(Some(&session.last_profile) == operation.selection.profile_id.as_ref()
710 && Some(&session.target_template_id) == operation.selection.target_template_id.as_ref(),
711 "ready destination does not match the move intent");
712 operation.destination_target = session.target.clone();
713 operation.destination_native_session_id = session.native_session_id.clone();
714 operation.queue_admission_started = true;
715 operation.phase = MovePhase::StartingQueue;
716 crate::database::save_move_operation(&operation)?;
717 restore_move_queue_hold(&operation);
718 ensure!(!operation.cancellation_requested, "Move was cancelled; ready destination retained");
719 self.admit_move_queue(&mut operation, executor).await
720 }
721 MovePhase::ResumingDestination => {
722 let previous = operation.recovery_session.as_ref().context("move lacks its stopped recovery identity; retain resources for inspection")?;
723 let error = self.rollback_failed_resume(&id, previous, false,
724 anyhow::anyhow!("destination restoration was interrupted; checkpoint retained for an explicit retry"), executor)?;
725 Err(error)
726 }
727 MovePhase::ClosingSource => {
728 if matches!(session.state, SessionState::Closing | SessionState::Destroying) {
729 self.recover_move_source_stop(&mut operation, executor, manager).await?;
730 }
731 operation.checkpoint = self.state.sessions[&id].checkpoint.clone();
732 if self.state.sessions[&id].state == SessionState::Stopped && self.state.sessions[&id].target.is_some() {
733 self.cleanup_stopped_target(&id, executor)?;
734 }
735 bail!("source stop was recovered; verified checkpoint retained. Retry move or Resume with previous settings")
736 }
737 _ => bail!("Move requires an explicit retry after the daemon restarted"),
738 }
739 }.await;
740 self.finish_move_result(&mut operation, result, executor)
741 }
742
743 async fn recover_move_source_stop(
744 &mut self,
745 operation: &mut MoveOperation,
746 executor: &(impl CommandExecutor + Sync),
747 manager: &SessionManagerControl,
748 ) -> Result<()> {
749 let id = operation.selection.session_id.clone();
750 if self.state.sessions[&id].state == SessionState::Closing {
751 self.prepare_move_source_checkpoint(&id, executor, manager, operation)
752 .await?;
753 let handle = manager
754 .wait_for_session(&id, std::time::Duration::from_secs(5))
755 .await?;
756 let mut lease = handle.lease_connection().await?;
757 let execution = lease.connection_mut().sync().await?.operational.execution;
758 lease.release();
759 if matches!(
760 execution,
761 mj_core::relay::RelayExecutionState::Idle
762 | mj_core::relay::RelayExecutionState::Running
763 ) {
764 if operation.cancellation_requested {
765 let record = self.state.sessions.get_mut(&id).unwrap();
766 record.state = SessionState::Running;
767 record.updated_at = now();
768 record.last_error =
769 Some("Move was cancelled before the source was sealed".into());
770 crate::database::save_lifecycle_session(record)?;
771 } else {
772 self.close_session_for_move(&id, executor, manager, operation, None)
773 .await?;
774 }
775 return Ok(());
776 }
777 }
778 self.recover_interrupted_close_managed(&id, executor, manager)
779 .await?;
780 Ok(())
781 }
782
783 async fn execute_move(
784 &mut self,
785 operation: &mut MoveOperation,
786 preparation: Option<&MovePreparation>,
787 executor: &(impl CommandExecutor + Sync),
788 manager: &SessionManagerControl,
789 ) -> Result<()> {
790 let id = operation.selection.session_id.clone();
791 ensure!(
792 !executor.cancellation_requested(),
793 "move cancelled before source interruption"
794 );
795 if !operation.queue_admission_started {
796 if self.state.sessions[&id].state == SessionState::Error
797 && let Some(previous) = operation.recovery_session.as_ref()
798 {
799 let failure = self.rollback_failed_resume(
802 &id,
803 previous,
804 false,
805 anyhow::anyhow!("clean up the partial Move destination before retry"),
806 executor,
807 )?;
808 ensure!(
809 self.state.sessions[&id].state == SessionState::Stopped,
810 "{failure:#}"
811 );
812 }
813 let state = self.state.sessions[&id].state;
814 if matches!(state, SessionState::Closing | SessionState::Destroying) {
815 self.recover_move_source_stop(operation, executor, manager)
816 .await?;
817 operation.checkpoint = self.state.sessions[&id].checkpoint.clone();
818 } else if matches!(state, SessionState::Running | SessionState::Disconnected)
819 && operation.destination_target.is_none()
820 {
821 executor.notify_notice("Stopping source");
822 let _timing = MovePhaseTimer::new(&id, "checkpoint and source stop");
823 operation.phase = MovePhase::ClosingSource;
824 operation.updated_at = now();
825 crate::database::save_move_operation(operation)?;
826 self.close_session_for_move(&id, executor, manager, operation, preparation)
827 .await?;
828 }
829 if self.state.sessions[&id].state == SessionState::Stopped
830 && self.state.sessions[&id].target.is_some()
831 {
832 executor.notify_notice("Cleaning up source");
833 let _timing = MovePhaseTimer::new(&id, "source storage cleanup");
834 self.cleanup_stopped_target(&id, executor)?;
835 }
836 operation.checkpoint = operation
837 .checkpoint
838 .clone()
839 .or_else(|| self.state.sessions[&id].checkpoint.clone());
840 ensure!(
841 operation.checkpoint.is_some(),
842 "move has no verified checkpoint"
843 );
844 ensure!(
845 !executor.cancellation_requested(),
846 "move cancelled after source teardown; session is stopped"
847 );
848 operation.phase = MovePhase::ResumingDestination;
849 operation.recovery_session = Some(self.state.sessions[&id].clone());
850 operation.updated_at = now();
851 crate::database::save_move_operation(operation)?;
852 executor.notify_notice("Preparing destination");
853 if operation.selection.clear_resource_allocation {
854 let session = self.state.sessions.get_mut(&id).unwrap();
855 session.resource_allocation = None;
856 session.container_cpus = None;
857 session.container_memory = None;
858 crate::database::save_session(session)?;
859 }
860 self.resume_session_controlled(
861 &id,
862 operation.selection.profile_id.as_deref().unwrap(),
863 operation.selection.target_template_id.as_deref().unwrap(),
864 SessionResumeOptions {
865 additional_mounts: operation.selection.additional_mounts.clone(),
866 resource_allocation: operation.selection.resource_allocation.clone(),
867 discard_queue: true,
868 },
869 executor,
870 )
871 .await?;
872 let destination = &self.state.sessions[&id];
873 operation.destination_target = destination.target.clone();
874 operation.destination_native_session_id = destination.native_session_id.clone();
875 operation.phase = MovePhase::StartingQueue;
877 operation.queue_admission_started = true;
878 operation.updated_at = now();
879 crate::database::save_move_operation(operation)?;
880 }
881 restore_move_queue_hold(operation);
882 self.admit_move_queue(operation, executor).await
883 }
884
885 async fn admit_move_queue(
886 &self,
887 operation: &mut MoveOperation,
888 executor: &(impl CommandExecutor + Sync),
889 ) -> Result<()> {
890 let timing_id = operation.selection.session_id.clone();
891 let _timing = MovePhaseTimer::new(&timing_id, "queue admission");
892 let id = &operation.selection.session_id;
893 let mut relay = {
894 let _checking_destination =
895 ProvisionStageGuard::new(executor, ProvisionStage::Verifying);
896 let destination = &self.state.sessions[id];
897 ensure!(
898 destination.state == SessionState::Running
899 && destination.target == operation.destination_target
900 && destination.native_session_id == operation.destination_native_session_id,
901 "cannot prove the same ready destination; refusing to replay potentially executed work"
902 );
903 let spec = self.reconnect_command(id)?;
904 let relay = StandaloneSession::connect_command(&spec, id).await?;
905 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")?;
906 if let Some(expected) = &operation.destination_store_id {
907 ensure!(
908 *expected == store_id,
909 "destination relay storage was replaced; refusing to replay potentially executed work"
910 );
911 } else {
912 operation.destination_store_id = Some(store_id);
914 crate::database::save_move_operation(operation)?;
915 }
916 ensure!(
917 relay.snapshot().operational.native_session_id
918 == operation.destination_native_session_id,
919 "destination relay native identity changed; refusing queue replay"
920 );
921 relay
922 };
923 if operation.queue == ResumeQueueDisposition::Start && !operation.queue_admission_finished {
924 let _starting_queue = ProvisionStageGuard::new(executor, ProvisionStage::Starting);
925 executor.notify_notice("Starting queued work");
926 let checkpoint = operation
927 .checkpoint
928 .as_ref()
929 .context("move queue archive is missing")?;
930 let verified = verify_archive_streaming(&checkpoint.archive_path)?;
931 ensure!(
932 verified.archive_sha256 == checkpoint.sha256 && verified.manifest.session.id == *id,
933 "move queue checkpoint verification failed"
934 );
935 for queued in verified.canonical_session.queued_prompts {
936 ensure!(
937 !executor.cancellation_requested(),
938 "move cancelled during queue admission; destination retained"
939 );
940 let command = match queued.kind {
941 CanonicalQueuedCommandKind::Prompt => RelayCommand::Prompt {
942 prompt: queued
943 .content
944 .into_iter()
945 .map(serde_json::from_value)
946 .collect::<serde_json::Result<_>>()?,
947 },
948 CanonicalQueuedCommandKind::SetConfig { key, value } => {
949 RelayCommand::SetConfig { key, value }
950 }
951 };
952 relay.submit(queued.command_id, command).await?;
953 }
954 }
955 operation.queue_admission_finished = true;
956 crate::database::save_move_operation(operation)?;
957 restore_move_queue_hold(operation);
958 relay.submit(format!("{}-notice", operation.operation_id), RelayCommand::RecordNotice {
959 text: format!("Moved from {} / {} to {} / {} in a fresh environment. {} The interrupted prompt was not replayed.",
960 operation.source_profile_id, operation.source_target_template_id,
961 operation.selection.profile_id.as_deref().unwrap(), operation.selection.target_template_id.as_deref().unwrap(),
962 if operation.queue == ResumeQueueDisposition::Discard { "Queued work was discarded; ready and idle." } else { "Queued work was accepted." }),
963 }).await?;
964 Ok(())
965 }
966
967 pub(super) fn validate_move_checkpoint(
968 &self,
969 operation: &MoveOperation,
970 preparation: Option<&MovePreparation>,
971 executor: &(impl CommandExecutor + Sync),
972 ) -> Result<()> {
973 let _verifying = ProvisionStageGuard::new(executor, ProvisionStage::Verifying);
974 let current = Controller {
975 config: mj_core::config::Config::load()?,
976 state: self.state.clone(),
977 };
978 ensure!(
979 current.move_configuration_fingerprint(&operation.selection)?
980 == operation.configuration_fingerprint,
981 "destination configuration changed during move"
982 );
983 let id = &operation.selection.session_id;
984 current.validate_move_destination_paths(
985 &self.state.sessions[id],
986 operation.selection.target_template_id.as_deref().unwrap(),
987 executor,
988 )?;
989 if let super::ResumeRepositorySourcePreflight::RepositoryMoved(mismatch) = self
990 .preflight_resume_repository_sources(
991 id,
992 operation.selection.target_template_id.as_deref().unwrap(),
993 executor,
994 )?
995 {
996 bail!(
997 "destination repository source is missing checkpoint commit {}; source retained",
998 mismatch.missing_commit
999 );
1000 }
1001 if let Some(prepared) = preparation {
1002 let checkpoint = self.state.sessions[id]
1003 .checkpoint
1004 .as_ref()
1005 .context("no move checkpoint")?;
1006 let verified = verify_archive_streaming(&checkpoint.archive_path)?;
1007 let actual: Vec<_> = verified
1008 .canonical_session
1009 .queued_prompts
1010 .iter()
1011 .map(|p| p.command_id.as_str())
1012 .collect();
1013 let expected: Vec<_> = prepared
1014 .queued_commands
1015 .iter()
1016 .map(|p| p.command_id.as_str())
1017 .collect();
1018 ensure!(
1019 actual == expected,
1020 "pending queue changed before checkpoint capture; source retained, confirm Move again"
1021 );
1022 }
1023 Ok(())
1024 }
1025}
1026
1027fn validate_preserved_configuration(
1028 profile_id: &str,
1029 accepted: &mj_core::acp::AcceptedSessionConfig,
1030 choices: &mj_core::worker_launch::ProfileConfig,
1031) -> Result<()> {
1032 for (key, value, offered) in [
1033 ("model", accepted.model.as_deref(), &choices.models),
1034 ("effort", accepted.effort.as_deref(), &choices.efforts),
1035 ] {
1036 let Some(value) = value else { continue };
1037 ensure!(
1038 offered.iter().any(|choice| choice.value == value),
1039 "destination profile {profile_id:?} does not offer the session's accepted {key} {value:?}; choices: {}",
1040 offered
1041 .iter()
1042 .map(|choice| choice.value.as_str())
1043 .collect::<Vec<_>>()
1044 .join(", ")
1045 );
1046 }
1047 Ok(())
1048}
1049
1050fn outcome(
1051 operation_id: &str,
1052 selection: &MoveSelection,
1053 status: &str,
1054 error: Option<String>,
1055 recovery: Option<String>,
1056) -> MoveOutcome {
1057 MoveOutcome {
1058 operation_id: operation_id.into(),
1059 session_id: selection.session_id.clone(),
1060 profile_id: selection.profile_id.clone().unwrap_or_default(),
1061 target_template_id: selection.target_template_id.clone().unwrap_or_default(),
1062 outcome: status.into(),
1063 error,
1064 recovery,
1065 }
1066}