1use std::time::Duration;
10
11use anyhow::{Context, Result};
12
13use crate::session_manager::{SessionManagerControl, StandaloneSession};
14use crate::targets::{self, CommandExecutor, CommandSpec};
15use mj_core::config::HarnessKind;
16
17use super::Controller;
18use super::readiness::{connect_started_worker_with_timeout, wait_for_native_session};
19use super::worker_binary::{
20 install_staged_worker_binary, prepare_managed_harness_for_upgrade,
21 replace_installed_worker_binary, replace_installed_worker_launch_config,
22 stage_worker_binary_for_upgrade, start_worker, start_worker_durably,
23 stop_worker_after_target_recovery, worker_binary_for, worker_probe_diagnosis,
24};
25
26const WORKER_RESTART_TIMEOUT: Duration = Duration::from_secs(300);
30
31const UPGRADE_LEASE_TIMEOUT: Duration = Duration::from_secs(5);
33
34#[derive(Debug)]
38pub struct WorkerRestartLeftNoWorker;
39
40impl WorkerRestartLeftNoWorker {
41 #[must_use]
47 pub fn marks(error: &anyhow::Error) -> bool {
48 error.downcast_ref::<Self>().is_some()
49 }
50}
51
52impl std::fmt::Display for WorkerRestartLeftNoWorker {
53 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 formatter.write_str("the worker restart left the session without a live worker")
55 }
56}
57
58impl std::error::Error for WorkerRestartLeftNoWorker {}
59
60fn mark_if_transport_died(error: anyhow::Error) -> anyhow::Error {
63 if crate::worker_client::RelayTransportDead::marks(&error) {
64 error.context(WorkerRestartLeftNoWorker)
65 } else {
66 error
67 }
68}
69
70pub(super) struct WorkerRestartMessages {
74 pub stop: &'static str,
75 pub replace: &'static str,
76 pub start: &'static str,
77 pub connect: &'static str,
78 pub project_memory: &'static str,
79 pub native_session: &'static str,
80}
81
82pub(super) struct InstalledWorkerRestart<'a> {
83 pub backend: &'a targets::TargetLocator,
84 pub worker_root: &'a str,
85 pub reconnect: &'a CommandSpec,
86 pub launch: Option<&'a mj_core::worker_launch::WorkerLaunchConfig>,
87 pub prepared: bool,
88 pub messages: &'a WorkerRestartMessages,
89}
90
91pub(super) const RESTART_FOR_CHECKPOINT: WorkerRestartMessages = WorkerRestartMessages {
93 stop: "stop wedged Mjolnir worker before retrying checkpoint",
94 replace: "replace Mjolnir worker binary before retrying checkpoint",
95 start: "start Mjolnir worker after interrupting a wedged ACP turn",
96 connect: "connect to Mjolnir worker after restarting it for checkpoint",
97 project_memory: "project memory will not be synchronized after checkpoint worker restart",
98 native_session: "wait for ACP session after restarting the worker for checkpoint",
99};
100
101const RESTART_FOR_UPGRADE: WorkerRestartMessages = WorkerRestartMessages {
104 stop: "stop the Mjolnir worker before installing the current binary",
105 replace: "install the current Mjolnir worker binary",
106 start: "start Mjolnir worker on the current binary",
107 connect: "connect to Mjolnir worker after upgrading its binary",
108 project_memory: "project memory will not be synchronized after the worker upgrade",
109 native_session: "wait for ACP session after upgrading the worker",
110};
111
112#[derive(Debug, Clone, PartialEq, Eq)]
115pub enum WorkerUpgradeOutcome {
116 Upgraded { build: String },
119 AlreadyCurrent { build: String },
121 Deferred,
124}
125
126impl WorkerUpgradeOutcome {
127 #[must_use]
130 pub fn build(&self) -> Option<&str> {
131 match self {
132 Self::Upgraded { build } | Self::AlreadyCurrent { build } => Some(build),
133 Self::Deferred => None,
134 }
135 }
136}
137
138fn worker_runs_installed_build(reported: Option<&str>, installed: &str) -> bool {
143 reported.is_some_and(|reported| reported == installed)
144}
145
146impl Controller {
147 pub async fn upgrade_session_worker(
157 &self,
158 session_id: &str,
159 executor: &(impl CommandExecutor + Sync),
160 manager: &SessionManagerControl,
161 reported_build: Option<&str>,
162 ) -> Result<WorkerUpgradeOutcome> {
163 let (backend, worker_root) = self.worker_placement(session_id)?;
164 let reconnect = targets::reconnect_plan(&backend, session_id)?
165 .commands
166 .into_iter()
167 .next()
168 .context("reconnect plan is empty")?;
169 let binary = worker_binary_for(&backend, executor)
170 .context("resolve the worker binary this controller would install")?;
171 let installed = mj_core::worker_build::WORKER_BUILD_ID.to_owned();
172 if worker_runs_installed_build(reported_build, &installed) {
173 return Ok(WorkerUpgradeOutcome::AlreadyCurrent { build: installed });
174 }
175
176 let launch = self.current_worker_launch_config(session_id, &backend)?;
179 if let Some(session) = self.state.sessions.get(session_id)
180 && session.build_cache.is_some()
181 {
182 self.prepare_build_cache_links(session, &backend, &launch, executor)
183 .context("prepare shared machine cache configuration before worker upgrade")?;
184 }
185 prepare_managed_harness_for_upgrade(executor, &backend, session_id, &binary, &launch)
186 .context("prepare the current managed harness before replacing the worker")?;
187 let staging = stage_worker_binary_for_upgrade(executor, &backend, session_id, &binary)
188 .context("stage the current worker while the old worker remains available")?;
189 let Some(owner) =
190 crate::worker_lifecycle::WorkerPermit::try_acquire(session_id, "worker upgrade")?
191 else {
192 return Ok(WorkerUpgradeOutcome::Deferred);
193 };
194 owner
195 .scope(async {
196 let target = self.state.sessions[session_id]
197 .target
198 .as_ref()
199 .context("worker upgrade has no target")?;
200 owner.verify_target(target)?;
201 let current = Controller::load()?;
202 let current_launch = current.current_worker_launch_config(session_id, &backend)?;
203 if serde_json::to_value(¤t_launch)? != serde_json::to_value(&launch)? {
204 return Ok(WorkerUpgradeOutcome::Deferred);
208 }
209 if crate::database::load_worker_restart(session_id)?.is_some() {
210 return Ok(WorkerUpgradeOutcome::Deferred);
211 }
212 let handle = manager
213 .wait_for_session(session_id, UPGRADE_LEASE_TIMEOUT)
214 .await?;
215 let harness = self.state.sessions[session_id].harness_kind;
216 let Ok(swap) = crate::upgrade::activity_unless_draining("worker swap") else {
219 return Ok(WorkerUpgradeOutcome::Deferred);
220 };
221 let Some(mut lease) =
222 super::IdleWorkspaceLease::acquire_for_upgrade(&handle, harness).await?
223 else {
224 return Ok(WorkerUpgradeOutcome::Deferred);
225 };
226 if !lease.verify_for_upgrade().await? {
227 return Ok(WorkerUpgradeOutcome::Deferred);
228 }
229 let operation_id = owner.operation_id().to_owned();
232 let intent = crate::database::WorkerRestartIntent {
233 operation_id: operation_id.clone(),
234 target: self.state.sessions[session_id]
235 .target
236 .clone()
237 .context("worker restart has no durable target")?,
238 desired_build: installed.clone(),
239 };
240 {
241 crate::database::begin_worker_restart(session_id, &intent)?;
242 install_staged_worker_binary(&owner, &staging, executor, &backend, session_id)
243 .context("install the prepared worker under its idle reservation")?;
244 replace_installed_worker_launch_config(executor, &backend, session_id, &launch)
245 .context(
246 "install the worker launch configuration under its idle reservation",
247 )?;
248 crate::database::advance_worker_restart(
249 session_id,
250 &operation_id,
251 crate::database::WorkerRestartPhase::Swapping,
252 )?;
253 stop_worker_after_target_recovery(executor, &backend, session_id, &worker_root)
254 .context(RESTART_FOR_UPGRADE.stop)?;
255 start_worker_durably(&owner, target, executor, &backend, &worker_root)
256 .context(RESTART_FOR_UPGRADE.start)
257 .map_err(|error| error.context(WorkerRestartLeftNoWorker))?;
258 }
259 drop(swap);
262 let mut connection = connect_started_worker_with_timeout(
263 &reconnect,
264 session_id,
265 executor,
266 &backend,
267 &worker_root,
268 WORKER_RESTART_TIMEOUT,
269 )
270 .await
271 .context(RESTART_FOR_UPGRADE.connect)?;
272 anyhow::ensure!(
273 connection.snapshot().worker_build.as_deref() == Some(&installed),
274 "replacement worker reported an unexpected build"
275 );
276 anyhow::ensure!(
277 connection.snapshot().operational.checkpoint_only
278 == (launch.run_mode
279 == mj_core::worker_launch::WorkerRunMode::CheckpointOnly),
280 "replacement worker reported an unexpected execution mode"
281 );
282 let project_memory = match self.project_memory_sync_target(session_id) {
283 Ok(target) => target,
284 Err(error) => {
285 tracing::warn!(
286 session_id,
287 error = format!("{error:#}"),
288 "project memory will not be synchronized after worker upgrade"
289 );
290 None
291 }
292 };
293 connection.set_project_memory_target(project_memory);
294 wait_for_native_session(&mut connection, executor, harness).await?;
295 crate::database::finish_worker_restart(session_id, &operation_id)?;
296 lease.finish_replacement(connection);
297 Ok(WorkerUpgradeOutcome::Upgraded { build: installed })
298 })
299 .await
300 }
301
302 pub(super) async fn restart_worker_with_installed_binary(
305 &self,
306 session_id: &str,
307 executor: &(impl CommandExecutor + Sync),
308 restart: InstalledWorkerRestart<'_>,
309 ) -> Result<StandaloneSession> {
310 crate::worker_lifecycle::run(
311 session_id,
312 "restart worker with installed binary",
313 executor,
314 async {
315 let owner = crate::worker_lifecycle::require(session_id)?;
316 let target = self
317 .state
318 .sessions
319 .get(session_id)
320 .and_then(|session| session.target.as_ref());
321 if let Some(target) = target {
322 owner.verify_target(target)?;
323 if crate::database::load_worker_restart(session_id)?.is_some() {
324 let output =
325 executor.execute(&super::worker_binary::worker_liveness_command(
326 restart.backend,
327 restart.worker_root,
328 ))?;
329 anyhow::ensure!(
330 output.status == 0
331 && String::from_utf8_lossy(&output.stdout).trim() == "dead",
332 "worker replacement is still booting; reconnect before restarting it"
333 );
334 owner.settle_dead_restart(target)?;
335 }
336 owner.begin_restart(target, String::new())?;
337 crate::database::advance_worker_restart(
338 session_id,
339 owner.operation_id(),
340 crate::database::WorkerRestartPhase::Swapping,
341 )?;
342 }
343 stop_worker_after_target_recovery(
347 executor,
348 restart.backend,
349 session_id,
350 restart.worker_root,
351 )
352 .context(restart.messages.stop)?;
353 self.start_installed_worker(session_id, executor, restart)
354 .await
355 },
356 )
357 .await
358 }
359
360 pub(super) async fn start_installed_worker(
366 &self,
367 session_id: &str,
368 executor: &(impl CommandExecutor + Sync),
369 restart: InstalledWorkerRestart<'_>,
370 ) -> Result<StandaloneSession> {
371 crate::worker_lifecycle::run(session_id, "start installed worker", executor, async {
372 let InstalledWorkerRestart {
373 backend,
374 worker_root,
375 reconnect,
376 launch,
377 prepared,
378 messages,
379 } = restart;
380 let owner = crate::worker_lifecycle::require(session_id)?;
381 let target = self
382 .state
383 .sessions
384 .get(session_id)
385 .and_then(|session| session.target.as_ref());
386 if let Some(target) = target {
387 owner.verify_target(target)?;
388 if let Some(intent) = crate::database::load_worker_restart(session_id)? {
389 if intent.operation_id != owner.operation_id()
392 || crate::database::worker_restart_phase(session_id)?
393 == Some(crate::database::WorkerRestartPhase::AwaitingReadiness)
394 {
395 let output = executor.execute(
396 &super::worker_binary::worker_liveness_command(backend, worker_root),
397 )?;
398 anyhow::ensure!(
399 output.status == 0
400 && String::from_utf8_lossy(&output.stdout).trim() == "dead",
401 "worker replacement is still booting"
402 );
403 owner.settle_dead_restart(target)?;
404 }
405 }
406 if crate::database::load_worker_restart(session_id)?.is_none() {
407 owner.begin_restart(target, String::new())?;
408 crate::database::advance_worker_restart(
409 session_id,
410 owner.operation_id(),
411 crate::database::WorkerRestartPhase::Swapping,
412 )?;
413 }
414 }
415 async {
418 if !prepared {
422 let binary = worker_binary_for(backend, executor)?;
423 replace_installed_worker_binary(executor, backend, session_id, &binary)
424 .context(messages.replace)?;
425 if let Some(launch) = launch {
426 replace_installed_worker_launch_config(
427 executor, backend, session_id, launch,
428 )
429 .context("install the current Mjolnir worker launch configuration")?;
430 }
431 }
432 let started = match target {
433 Some(target) => {
434 start_worker_durably(&owner, target, executor, backend, worker_root)
435 }
436 None => start_worker(&owner, executor, backend, worker_root),
437 };
438 started.context(messages.start)?;
439 Ok::<_, anyhow::Error>(())
440 }
441 .await
442 .map_err(|error| error.context(WorkerRestartLeftNoWorker))?;
443 self.observe_started_worker(
444 session_id,
445 executor,
446 InstalledWorkerRestart {
447 backend,
448 worker_root,
449 reconnect,
450 launch,
451 prepared,
452 messages,
453 },
454 )
455 .await
456 })
457 .await
458 }
459
460 pub(super) async fn observe_started_worker(
463 &self,
464 session_id: &str,
465 executor: &(impl CommandExecutor + Sync),
466 restart: InstalledWorkerRestart<'_>,
467 ) -> Result<StandaloneSession> {
468 let InstalledWorkerRestart {
469 backend,
470 worker_root,
471 reconnect,
472 launch,
473 messages,
474 ..
475 } = restart;
476 let owner = crate::worker_lifecycle::require(session_id)?;
477 let session = self.state.sessions.get(session_id);
478 let harness = session
479 .map(|session| session.harness_kind)
480 .or_else(|| launch.map(|launch| launch.harness))
481 .unwrap_or(HarnessKind::Codex);
482 let observed_updated_at = session.map(|session| session.updated_at.clone());
483 let mut connection = connect_started_worker_with_timeout(
485 reconnect,
486 session_id,
487 executor,
488 backend,
489 worker_root,
490 WORKER_RESTART_TIMEOUT,
491 )
492 .await
493 .map_err(|error| {
494 worker_probe_diagnosis(executor, backend, worker_root, error)
495 .context(messages.connect)
496 .context(WorkerRestartLeftNoWorker)
497 })?;
498 let project_memory = match self.project_memory_sync_target(session_id) {
499 Ok(target) => target,
500 Err(error) => {
501 tracing::warn!(
502 session_id,
503 error = format!("{error:#}"),
504 "{}",
505 messages.project_memory
506 );
507 None
508 }
509 };
510 connection.set_project_memory_target(project_memory);
511 let readiness = async {
512 let checkpoint_only = connection.sync().await?.operational.checkpoint_only;
513 if let Some(launch) = launch {
514 anyhow::ensure!(
515 checkpoint_only
516 == (launch.run_mode
517 == mj_core::worker_launch::WorkerRunMode::CheckpointOnly),
518 "restarted worker did not enter the requested execution mode"
519 );
520 }
521 if !checkpoint_only {
522 wait_for_native_session(&mut connection, executor, harness)
523 .await
524 .context(messages.native_session)?;
525 }
526 Ok(())
527 }
528 .await
529 .map_err(mark_if_transport_died);
530 if let Err(error) = readiness {
531 if let (Some(failure), Some(observed_updated_at)) = (
532 error.downcast_ref::<super::readiness::HarnessPreparationFailure>(),
533 observed_updated_at.as_deref(),
534 ) {
535 self.persist_harness_preparation_failure(
536 session_id,
537 &failure.to_string(),
538 observed_updated_at,
539 )
540 .await?;
541 }
542 return Err(error);
543 }
544 if session
545 .and_then(|session| session.target.as_ref())
546 .is_some()
547 {
548 crate::database::finish_worker_restart(session_id, owner.operation_id())?;
549 }
550 Ok(connection)
551 }
552}
553
554#[cfg(test)]
555mod tests {
556 #[test]
558 fn a_dead_transport_after_reconnect_marks_the_restart_as_leaving_no_worker() {
559 let died = anyhow::Error::new(crate::worker_client::RelayTransportDead::new(
560 "relay proxy disconnected during attach",
561 ))
562 .context("wait for ACP session after restarting the worker for checkpoint");
563 assert!(super::WorkerRestartLeftNoWorker::marks(
564 &super::mark_if_transport_died(died)
565 ));
566
567 let slow = anyhow::anyhow!("timed out waiting for the ACP session")
568 .context("wait for ACP session after restarting the worker for checkpoint");
569 let slow = super::mark_if_transport_died(slow);
570 assert!(!super::WorkerRestartLeftNoWorker::marks(&slow), "{slow:#}");
571 }
572
573 #[cfg(unix)]
574 use super::*;
575
576 #[cfg(unix)]
577 use std::sync::Mutex;
578
579 #[cfg(unix)]
580 use crate::targets::CommandOutput;
581
582 #[cfg(unix)]
585 struct StopSucceedsThenFails {
586 executed: Mutex<Vec<String>>,
587 }
588
589 #[cfg(unix)]
590 impl CommandExecutor for StopSucceedsThenFails {
591 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
592 let mut executed = self.executed.lock().expect("executed commands");
593 executed.push(command.program.clone());
594 if executed.len() == 1 {
595 return Ok(CommandOutput {
596 status: 0,
597 stdout: Vec::new(),
598 stderr: Vec::new(),
599 });
600 }
601 Ok(CommandOutput {
602 status: 1,
603 stdout: Vec::new(),
604 stderr: b"no such target".to_vec(),
605 })
606 }
607 }
608
609 #[cfg(unix)]
610 struct FailingStop;
611
612 #[cfg(unix)]
613 impl CommandExecutor for FailingStop {
614 fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
615 Ok(CommandOutput {
616 status: 1,
617 stdout: Vec::new(),
618 stderr: b"permission denied".to_vec(),
619 })
620 }
621 }
622
623 #[cfg(unix)]
624 fn bare_restart_controller() -> Controller {
625 Controller {
626 config: mj_core::config::Config::default(),
627 state: mj_core::state::State::default(),
628 }
629 }
630
631 #[cfg(unix)]
632 async fn restart_error(
633 session_id: &str,
634 executor: &(impl CommandExecutor + Sync),
635 ) -> anyhow::Error {
636 let worker_root = format!("/tmp/mjolnir-restart-test/{session_id}");
637 let backend = targets::TargetLocator::LocalBare {
638 worker_root: worker_root.clone(),
639 };
640 let reconnect = CommandSpec::new("unused", std::iter::empty::<&str>());
641 let result = bare_restart_controller()
642 .restart_worker_with_installed_binary(
643 session_id,
644 executor,
645 InstalledWorkerRestart {
646 backend: &backend,
647 worker_root: &worker_root,
648 reconnect: &reconnect,
649 launch: None,
650 prepared: false,
651 messages: &RESTART_FOR_CHECKPOINT,
652 },
653 )
654 .await;
655 match result {
656 Ok(_) => panic!("a failing executor unexpectedly restarted the worker"),
657 Err(error) => error,
658 }
659 }
660
661 #[cfg(unix)]
662 #[tokio::test]
663 async fn a_restart_that_could_not_stop_the_worker_leaves_it_running() {
664 let error = restart_error("0123456789abcdef0123456789abcdef", &FailingStop).await;
665
666 assert!(
667 !WorkerRestartLeftNoWorker::marks(&error),
668 "a failed stop may leave the old worker alive: {error:#}"
669 );
670 }
671
672 #[cfg(unix)]
673 #[tokio::test]
675 async fn a_restart_that_stopped_the_worker_and_then_failed_is_marked() {
676 let executor = StopSucceedsThenFails {
677 executed: Mutex::new(Vec::new()),
678 };
679
680 let error = restart_error("0123456789abcdef0123456789abcdef", &executor).await;
681
682 assert!(
683 WorkerRestartLeftNoWorker::marks(&error),
684 "the worker was stopped and nothing replaced it: {error:#}"
685 );
686 assert!(
687 executor.executed.lock().expect("executed commands").len() > 1,
688 "the restart should have failed after its stop, not during it"
689 );
690 }
691
692 #[cfg(unix)]
693 #[tokio::test]
694 async fn checkpoint_restart_waits_for_upgrade_while_recovery_defers_and_another_session_runs() {
695 use std::sync::atomic::{AtomicUsize, Ordering};
696 struct FailingCommands(AtomicUsize);
697 impl CommandExecutor for FailingCommands {
698 fn execute(&self, _: &CommandSpec) -> Result<CommandOutput> {
699 self.0.fetch_add(1, Ordering::SeqCst);
700 Ok(CommandOutput {
701 status: 1,
702 stdout: Vec::new(),
703 stderr: b"stop refused".to_vec(),
704 })
705 }
706 }
707 let id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
708 let upgrading = crate::worker_lifecycle::WorkerPermit::try_acquire(id, "worker upgrade")
709 .unwrap()
710 .unwrap();
711 let executor = FailingCommands(AtomicUsize::new(0));
712 let checkpoint = restart_error(id, &executor);
713 tokio::pin!(checkpoint);
714 assert!(
715 tokio::time::timeout(Duration::from_millis(50), &mut checkpoint)
716 .await
717 .is_err()
718 );
719 let recovery = crate::session_manager::WorkerRecoveryPlan {
720 source_target: mj_core::state::TargetLocator::LocalBare {
721 worker_root: "/unused".into(),
722 },
723 target: None,
724 workspace: None,
725 exit_record: None,
726 liveness_probe: CommandSpec::new("probe", std::iter::empty::<&str>()),
727 binary_refresh: None,
728 launch_refresh: None,
729 restart: targets::CommandPlan {
730 description: "restart".into(),
731 commands: vec![],
732 },
733 };
734 assert_eq!(
735 crate::session_manager::recover_worker_controlled(recovery, true, Some(id), &executor)
736 .unwrap(),
737 crate::session_manager::WorkerRecoveryOutcome::Suppressed
738 );
739 assert_eq!(executor.0.load(Ordering::SeqCst), 0);
740 let other = FailingCommands(AtomicUsize::new(0));
741 tokio::time::timeout(
742 Duration::from_secs(2),
743 restart_error("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", &other),
744 )
745 .await
746 .unwrap();
747 assert_eq!(other.0.load(Ordering::SeqCst), 1);
748 drop(upgrading);
749 let error = tokio::time::timeout(Duration::from_secs(2), checkpoint)
750 .await
751 .unwrap();
752 assert!(!WorkerRestartLeftNoWorker::marks(&error));
753 assert_eq!(executor.0.load(Ordering::SeqCst), 1);
754 }
755}