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