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