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