mj_controller/controller/
worker_restart.rs1use std::time::Duration;
10
11use anyhow::{Context, Result, bail};
12
13use crate::session_manager::{SessionManagerControl, StandaloneSession};
14use crate::targets::{self, CommandExecutor, CommandSpec};
15use mj_core::relay::RelayExecutionState;
16
17use super::Controller;
18use super::readiness::{connect_started_worker_with_timeout, wait_for_native_session};
19use super::worker_binary::{
20 prepare_managed_harness_for_upgrade, replace_installed_worker_binary,
21 replace_installed_worker_launch_config, start_worker, stop_worker_after_target_recovery,
22 worker_binary_for, worker_probe_diagnosis,
23};
24
25const WORKER_RESTART_TIMEOUT: Duration = Duration::from_secs(300);
29
30const UPGRADE_LEASE_TIMEOUT: Duration = Duration::from_secs(5);
32
33pub(super) struct WorkerRestartMessages {
37 pub stop: &'static str,
38 pub replace: &'static str,
39 pub start: &'static str,
40 pub connect: &'static str,
41 pub project_memory: &'static str,
42 pub native_session: &'static str,
43}
44
45pub(super) struct InstalledWorkerRestart<'a> {
46 pub backend: &'a targets::TargetLocator,
47 pub worker_root: &'a str,
48 pub reconnect: &'a CommandSpec,
49 pub launch: Option<&'a mj_core::worker_launch::WorkerLaunchConfig>,
50 pub messages: &'a WorkerRestartMessages,
51}
52
53pub(super) const RESTART_FOR_CHECKPOINT: WorkerRestartMessages = WorkerRestartMessages {
55 stop: "stop wedged Mjolnir worker before retrying checkpoint",
56 replace: "replace Mjolnir worker binary before retrying checkpoint",
57 start: "start Mjolnir worker after interrupting a wedged ACP turn",
58 connect: "connect to Mjolnir worker after restarting it for checkpoint",
59 project_memory: "project memory will not be synchronized after checkpoint worker restart",
60 native_session: "wait for ACP session after restarting the worker for checkpoint",
61};
62
63const RESTART_FOR_UPGRADE: WorkerRestartMessages = WorkerRestartMessages {
66 stop: "stop the Mjolnir worker before installing the current binary",
67 replace: "install the current Mjolnir worker binary",
68 start: "start Mjolnir worker on the current binary",
69 connect: "connect to Mjolnir worker after upgrading its binary",
70 project_memory: "project memory will not be synchronized after the worker upgrade",
71 native_session: "wait for ACP session after upgrading the worker",
72};
73
74#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum WorkerUpgradeOutcome {
78 Upgraded { build: String },
81 AlreadyCurrent { build: String },
83 Deferred,
86}
87
88impl WorkerUpgradeOutcome {
89 #[must_use]
92 pub fn build(&self) -> Option<&str> {
93 match self {
94 Self::Upgraded { build } | Self::AlreadyCurrent { build } => Some(build),
95 Self::Deferred => None,
96 }
97 }
98}
99
100fn worker_runs_installed_build(reported: Option<&str>, installed: &str) -> bool {
106 reported.is_some_and(|reported| reported == installed)
107}
108
109impl Controller {
110 pub async fn upgrade_session_worker(
119 &self,
120 session_id: &str,
121 executor: &(impl CommandExecutor + Sync),
122 manager: &SessionManagerControl,
123 reported_build: Option<&str>,
124 ) -> Result<WorkerUpgradeOutcome> {
125 let (backend, worker_root) = self.worker_placement(session_id)?;
126 let reconnect = targets::reconnect_plan(&backend, session_id)?
127 .commands
128 .into_iter()
129 .next()
130 .context("reconnect plan is empty")?;
131 let binary = worker_binary_for(&backend, executor)
132 .context("resolve the worker binary this controller would install")?;
133 let installed = mj_core::worker_launch::worker_executable_digest(&binary)?;
134 if worker_runs_installed_build(reported_build, &installed) {
135 return Ok(WorkerUpgradeOutcome::AlreadyCurrent { build: installed });
136 }
137
138 let handle = manager
142 .wait_for_session(session_id, UPGRADE_LEASE_TIMEOUT)
143 .await?;
144 let mut lease = handle.lease_connection().await?;
145 let snapshot = lease
146 .connection_mut()
147 .sync()
148 .await
149 .context("read the session state before upgrading its worker")?;
150 if worker_runs_installed_build(snapshot.worker_build.as_deref(), &installed) {
151 lease.release();
152 return Ok(WorkerUpgradeOutcome::AlreadyCurrent { build: installed });
153 }
154 let harness = self.state.sessions[session_id].harness_kind;
155 let safe_to_replace = snapshot.operational.safe_to_replace(harness);
156 tracing::debug!(
157 session_id,
158 safe_to_replace,
159 goal_synchronized = snapshot.operational.goal.synchronized(),
160 goal_active = snapshot.operational.goal.active(),
161 native_running = snapshot.operational.goal.running(),
162 "evaluated automatic worker replacement"
163 );
164 if !safe_to_replace {
165 lease.release();
166 return Ok(WorkerUpgradeOutcome::Deferred);
167 }
168 let launch = self.current_worker_launch_config(session_id, &backend)?;
169 if let Err(error) =
170 prepare_managed_harness_for_upgrade(executor, &backend, session_id, &binary, &launch)
171 .context("prepare the current managed harness before replacing the worker")
172 {
173 lease.release();
176 return Err(error);
177 }
178
179 let restarted = self
180 .restart_worker_with_installed_binary(
181 session_id,
182 executor,
183 InstalledWorkerRestart {
184 backend: &backend,
185 worker_root: &worker_root,
186 reconnect: &reconnect,
187 launch: Some(&launch),
188 messages: &RESTART_FOR_UPGRADE,
189 },
190 )
191 .await;
192 match restarted {
193 Ok(connection) => {
194 lease.replace_connection(connection);
195 lease.release();
196 Ok(WorkerUpgradeOutcome::Upgraded { build: installed })
197 }
198 Err(error) => {
199 drop(lease);
202 Err(error)
203 }
204 }
205 }
206
207 pub(super) async fn restart_worker_with_installed_binary(
210 &self,
211 session_id: &str,
212 executor: &(impl CommandExecutor + Sync),
213 restart: InstalledWorkerRestart<'_>,
214 ) -> Result<StandaloneSession> {
215 let InstalledWorkerRestart {
216 backend,
217 worker_root,
218 reconnect,
219 launch,
220 messages,
221 } = restart;
222 stop_worker_after_target_recovery(executor, backend, session_id, worker_root)
223 .context(messages.stop)?;
224 let binary = worker_binary_for(backend, executor)?;
228 replace_installed_worker_binary(executor, backend, session_id, &binary)
229 .context(messages.replace)?;
230 if let Some(launch) = launch {
231 replace_installed_worker_launch_config(executor, backend, session_id, launch)
232 .context("install the current Mjolnir worker launch configuration")?;
233 }
234 start_worker(executor, backend, worker_root).context(messages.start)?;
235 let mut connection = match connect_started_worker_with_timeout(
238 reconnect,
239 session_id,
240 executor,
241 backend,
242 worker_root,
243 WORKER_RESTART_TIMEOUT,
244 )
245 .await
246 {
247 Ok(connection) => connection,
248 Err(error) => {
249 return Err(
250 worker_probe_diagnosis(executor, backend, worker_root, error)
251 .context(messages.connect),
252 );
253 }
254 };
255 let project_memory = match self.project_memory_sync_target(session_id) {
256 Ok(target) => Some(target),
257 Err(error) => {
258 tracing::warn!(
259 session_id,
260 error = format!("{error:#}"),
261 "{}",
262 messages.project_memory
263 );
264 None
265 }
266 };
267 connection.set_project_memory_target(project_memory);
268 let checkpoint_only = connection.sync().await?.operational.checkpoint_only;
269 if let Some(launch) = launch {
270 anyhow::ensure!(
271 checkpoint_only
272 == (launch.run_mode == mj_core::worker_launch::WorkerRunMode::CheckpointOnly),
273 "restarted worker did not enter the requested execution mode"
274 );
275 }
276 if checkpoint_only {
277 return Ok(connection);
278 }
279 wait_for_native_session(&mut connection, executor)
280 .await
281 .context(messages.native_session)?;
282 wait_for_idle_projection(&mut connection, WORKER_RESTART_TIMEOUT)
283 .await
284 .context("wait for ACP to go idle after worker restart")?;
285 Ok(connection)
286 }
287}
288
289async fn wait_for_idle_projection(relay: &mut StandaloneSession, timeout: Duration) -> Result<()> {
294 let deadline = tokio::time::Instant::now() + timeout;
295 let mut last_ordinal = None;
296 let mut stable_polls = 0_u8;
297 loop {
298 let snapshot = relay.sync().await?;
299 let ordinal = snapshot.operational.latest_ordinal;
300 let idle = snapshot.operational.native_session_is_ready()
301 && (snapshot.operational.execution == RelayExecutionState::Idle
302 || (snapshot.operational.goal.synchronized()
303 && snapshot.operational.goal.active()));
304 if idle && (snapshot.operational.goal.active() || last_ordinal == Some(ordinal)) {
305 stable_polls = stable_polls.saturating_add(1);
306 if stable_polls >= 3 {
307 return Ok(());
308 }
309 } else {
310 stable_polls = 0;
311 }
312 last_ordinal = Some(ordinal);
313 if snapshot.operational.execution == RelayExecutionState::Closed {
314 bail!("ACP runtime stopped before becoming idle");
315 }
316 if tokio::time::Instant::now() >= deadline {
317 bail!(
318 "ACP runtime did not become idle after worker restart (execution={:?}, ordinal={ordinal})",
319 snapshot.operational.execution
320 );
321 }
322 tokio::time::sleep(Duration::from_millis(200)).await;
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 #[test]
333 fn only_a_matching_reported_build_counts_as_current() {
334 let installed = "a".repeat(64);
335
336 assert!(worker_runs_installed_build(Some(&installed), &installed));
337 assert!(!worker_runs_installed_build(
338 Some(&"b".repeat(64)),
339 &installed
340 ));
341 assert!(
342 !worker_runs_installed_build(None, &installed),
343 "a worker too old to report a build is older than this controller"
344 );
345 }
346}