mj_controller/hel_controller/
worker_restart.rs1use std::time::Duration;
10
11use anyhow::{Context, Result, bail};
12
13use crate::hel_session_manager::{SessionManagerControl, StandaloneSession};
14use hel::hel_targets::{self, CommandExecutor, CommandSpec};
15use hel::hel_worker::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 hel_targets::TargetLocator,
47 pub worker_root: &'a str,
48 pub reconnect: &'a CommandSpec,
49 pub launch: Option<&'a hel::hel_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 = hel_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 = hel::hel_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 if !snapshot.operational.safe_to_replace(harness) {
156 lease.release();
157 return Ok(WorkerUpgradeOutcome::Deferred);
158 }
159 let launch = self.current_worker_launch_config(session_id, &backend)?;
160 if let Err(error) =
161 prepare_managed_harness_for_upgrade(executor, &backend, session_id, &binary, &launch)
162 .context("prepare the current managed harness before replacing the worker")
163 {
164 lease.release();
167 return Err(error);
168 }
169
170 let restarted = self
171 .restart_worker_with_installed_binary(
172 session_id,
173 executor,
174 InstalledWorkerRestart {
175 backend: &backend,
176 worker_root: &worker_root,
177 reconnect: &reconnect,
178 launch: Some(&launch),
179 messages: &RESTART_FOR_UPGRADE,
180 },
181 )
182 .await;
183 match restarted {
184 Ok(connection) => {
185 lease.replace_connection(connection);
186 lease.release();
187 Ok(WorkerUpgradeOutcome::Upgraded { build: installed })
188 }
189 Err(error) => {
190 drop(lease);
193 Err(error)
194 }
195 }
196 }
197
198 pub(super) async fn restart_worker_with_installed_binary(
201 &self,
202 session_id: &str,
203 executor: &(impl CommandExecutor + Sync),
204 restart: InstalledWorkerRestart<'_>,
205 ) -> Result<StandaloneSession> {
206 let InstalledWorkerRestart {
207 backend,
208 worker_root,
209 reconnect,
210 launch,
211 messages,
212 } = restart;
213 stop_worker_after_target_recovery(executor, backend, session_id, worker_root)
214 .context(messages.stop)?;
215 let binary = worker_binary_for(backend, executor)?;
219 replace_installed_worker_binary(executor, backend, session_id, &binary)
220 .context(messages.replace)?;
221 if let Some(launch) = launch {
222 replace_installed_worker_launch_config(executor, backend, session_id, launch)
223 .context("install the current Mjolnir worker launch configuration")?;
224 }
225 start_worker(executor, backend, worker_root).context(messages.start)?;
226 let mut connection = match connect_started_worker_with_timeout(
229 reconnect,
230 session_id,
231 executor,
232 backend,
233 worker_root,
234 WORKER_RESTART_TIMEOUT,
235 )
236 .await
237 {
238 Ok(connection) => connection,
239 Err(error) => {
240 return Err(
241 worker_probe_diagnosis(executor, backend, worker_root, error)
242 .context(messages.connect),
243 );
244 }
245 };
246 let project_memory = match self.project_memory_sync_target(session_id) {
247 Ok(target) => Some(target),
248 Err(error) => {
249 tracing::warn!(
250 session_id,
251 error = format!("{error:#}"),
252 "{}",
253 messages.project_memory
254 );
255 None
256 }
257 };
258 connection.set_project_memory_target(project_memory);
259 wait_for_native_session(&mut connection, executor)
260 .await
261 .context(messages.native_session)?;
262 wait_for_idle_projection(&mut connection, WORKER_RESTART_TIMEOUT)
263 .await
264 .context("wait for ACP to go idle after worker restart")?;
265 Ok(connection)
266 }
267}
268
269async fn wait_for_idle_projection(relay: &mut StandaloneSession, timeout: Duration) -> Result<()> {
274 let deadline = tokio::time::Instant::now() + timeout;
275 let mut last_ordinal = None;
276 let mut stable_polls = 0_u8;
277 loop {
278 let snapshot = relay.sync().await?;
279 let ordinal = snapshot.operational.latest_ordinal;
280 let idle = snapshot.operational.native_session_is_ready()
281 && snapshot.operational.execution == RelayExecutionState::Idle;
282 if idle && last_ordinal == Some(ordinal) {
283 stable_polls = stable_polls.saturating_add(1);
284 if stable_polls >= 3 {
285 return Ok(());
286 }
287 } else {
288 stable_polls = 0;
289 }
290 last_ordinal = Some(ordinal);
291 if snapshot.operational.execution == RelayExecutionState::Closed {
292 bail!("ACP runtime stopped before becoming idle");
293 }
294 if tokio::time::Instant::now() >= deadline {
295 bail!(
296 "ACP runtime did not become idle after worker restart (execution={:?}, ordinal={ordinal})",
297 snapshot.operational.execution
298 );
299 }
300 tokio::time::sleep(Duration::from_millis(200)).await;
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 #[test]
311 fn only_a_matching_reported_build_counts_as_current() {
312 let installed = "a".repeat(64);
313
314 assert!(worker_runs_installed_build(Some(&installed), &installed));
315 assert!(!worker_runs_installed_build(
316 Some(&"b".repeat(64)),
317 &installed
318 ));
319 assert!(
320 !worker_runs_installed_build(None, &installed),
321 "a worker too old to report a build is older than this controller"
322 );
323 }
324}