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 replace_installed_worker_binary, start_worker, stop_worker_after_target_recovery,
21 worker_binary_for, worker_probe_diagnosis,
22};
23
24const WORKER_RESTART_TIMEOUT: Duration = Duration::from_secs(300);
28
29const UPGRADE_LEASE_TIMEOUT: Duration = Duration::from_secs(5);
31
32pub(super) struct WorkerRestartMessages {
36 pub stop: &'static str,
37 pub replace: &'static str,
38 pub start: &'static str,
39 pub connect: &'static str,
40 pub project_memory: &'static str,
41 pub native_session: &'static str,
42}
43
44pub(super) const RESTART_FOR_CHECKPOINT: WorkerRestartMessages = WorkerRestartMessages {
46 stop: "stop wedged Mjolnir worker before retrying checkpoint",
47 replace: "replace Mjolnir worker binary before retrying checkpoint",
48 start: "start Mjolnir worker after interrupting a wedged ACP turn",
49 connect: "connect to Mjolnir worker after restarting it for checkpoint",
50 project_memory: "project memory will not be synchronized after checkpoint worker restart",
51 native_session: "wait for ACP session after restarting the worker for checkpoint",
52};
53
54const RESTART_FOR_UPGRADE: WorkerRestartMessages = WorkerRestartMessages {
57 stop: "stop the Mjolnir worker before installing the current binary",
58 replace: "install the current Mjolnir worker binary",
59 start: "start Mjolnir worker on the current binary",
60 connect: "connect to Mjolnir worker after upgrading its binary",
61 project_memory: "project memory will not be synchronized after the worker upgrade",
62 native_session: "wait for ACP session after upgrading the worker",
63};
64
65#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum WorkerUpgradeOutcome {
69 Upgraded { build: String },
72 AlreadyCurrent { build: String },
74 Deferred,
77}
78
79impl WorkerUpgradeOutcome {
80 #[must_use]
83 pub fn build(&self) -> Option<&str> {
84 match self {
85 Self::Upgraded { build } | Self::AlreadyCurrent { build } => Some(build),
86 Self::Deferred => None,
87 }
88 }
89}
90
91fn worker_runs_installed_build(reported: Option<&str>, installed: &str) -> bool {
97 reported.is_some_and(|reported| reported == installed)
98}
99
100impl Controller {
101 pub async fn upgrade_session_worker(
110 &self,
111 session_id: &str,
112 executor: &(impl CommandExecutor + Sync),
113 manager: &SessionManagerControl,
114 reported_build: Option<&str>,
115 ) -> Result<WorkerUpgradeOutcome> {
116 let (backend, worker_root) = self.worker_placement(session_id)?;
117 let reconnect = hel_targets::reconnect_plan(&backend, session_id)?
118 .commands
119 .into_iter()
120 .next()
121 .context("reconnect plan is empty")?;
122 let binary = worker_binary_for(&backend, executor)
123 .context("resolve the worker binary this controller would install")?;
124 let installed = hel::hel_worker_launch::worker_executable_digest(&binary)?;
125 if worker_runs_installed_build(reported_build, &installed) {
126 return Ok(WorkerUpgradeOutcome::AlreadyCurrent { build: installed });
127 }
128
129 let handle = manager
133 .wait_for_session(session_id, UPGRADE_LEASE_TIMEOUT)
134 .await?;
135 let mut lease = handle.lease_connection().await?;
136 let snapshot = lease
137 .connection_mut()
138 .sync()
139 .await
140 .context("read the session state before upgrading its worker")?;
141 if worker_runs_installed_build(snapshot.worker_build.as_deref(), &installed) {
142 lease.release();
143 return Ok(WorkerUpgradeOutcome::AlreadyCurrent { build: installed });
144 }
145 if !snapshot.operational.is_quiet() {
146 lease.release();
147 return Ok(WorkerUpgradeOutcome::Deferred);
148 }
149
150 let restarted = self
151 .restart_worker_with_installed_binary(
152 session_id,
153 executor,
154 &backend,
155 &worker_root,
156 &reconnect,
157 &RESTART_FOR_UPGRADE,
158 )
159 .await;
160 match restarted {
161 Ok(connection) => {
162 lease.replace_connection(connection);
163 lease.release();
164 Ok(WorkerUpgradeOutcome::Upgraded { build: installed })
165 }
166 Err(error) => {
167 drop(lease);
170 Err(error)
171 }
172 }
173 }
174
175 pub(super) async fn restart_worker_with_installed_binary(
178 &self,
179 session_id: &str,
180 executor: &(impl CommandExecutor + Sync),
181 backend: &hel_targets::TargetLocator,
182 worker_root: &str,
183 reconnect: &CommandSpec,
184 messages: &WorkerRestartMessages,
185 ) -> Result<StandaloneSession> {
186 stop_worker_after_target_recovery(executor, backend, session_id, worker_root)
187 .context(messages.stop)?;
188 let binary = worker_binary_for(backend, executor)?;
192 replace_installed_worker_binary(executor, backend, session_id, &binary)
193 .context(messages.replace)?;
194 start_worker(executor, backend, worker_root).context(messages.start)?;
195 let mut connection = match connect_started_worker_with_timeout(
198 reconnect,
199 session_id,
200 executor,
201 backend,
202 worker_root,
203 WORKER_RESTART_TIMEOUT,
204 )
205 .await
206 {
207 Ok(connection) => connection,
208 Err(error) => {
209 return Err(
210 worker_probe_diagnosis(executor, backend, worker_root, error)
211 .context(messages.connect),
212 );
213 }
214 };
215 let project_memory = match self.project_memory_sync_target(session_id) {
216 Ok(target) => Some(target),
217 Err(error) => {
218 tracing::warn!(
219 session_id,
220 error = format!("{error:#}"),
221 "{}",
222 messages.project_memory
223 );
224 None
225 }
226 };
227 connection.set_project_memory_target(project_memory);
228 wait_for_native_session(&mut connection, executor)
229 .await
230 .context(messages.native_session)?;
231 wait_for_idle_projection(&mut connection, WORKER_RESTART_TIMEOUT)
232 .await
233 .context("wait for ACP to go idle after worker restart")?;
234 Ok(connection)
235 }
236}
237
238async fn wait_for_idle_projection(relay: &mut StandaloneSession, timeout: Duration) -> Result<()> {
243 let deadline = tokio::time::Instant::now() + timeout;
244 let mut last_ordinal = None;
245 let mut stable_polls = 0_u8;
246 loop {
247 let snapshot = relay.sync().await?;
248 let ordinal = snapshot.operational.latest_ordinal;
249 let idle = snapshot.operational.execution == RelayExecutionState::Idle;
250 if idle && last_ordinal == Some(ordinal) {
251 stable_polls = stable_polls.saturating_add(1);
252 if stable_polls >= 3 {
253 return Ok(());
254 }
255 } else {
256 stable_polls = 0;
257 }
258 last_ordinal = Some(ordinal);
259 if snapshot.operational.execution == RelayExecutionState::Closed {
260 bail!("ACP runtime stopped before becoming idle");
261 }
262 if tokio::time::Instant::now() >= deadline {
263 bail!(
264 "ACP runtime did not become idle after worker restart (execution={:?}, ordinal={ordinal})",
265 snapshot.operational.execution
266 );
267 }
268 tokio::time::sleep(Duration::from_millis(200)).await;
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 #[test]
279 fn only_a_matching_reported_build_counts_as_current() {
280 let installed = "a".repeat(64);
281
282 assert!(worker_runs_installed_build(Some(&installed), &installed));
283 assert!(!worker_runs_installed_build(
284 Some(&"b".repeat(64)),
285 &installed
286 ));
287 assert!(
288 !worker_runs_installed_build(None, &installed),
289 "a worker too old to report a build is older than this controller"
290 );
291 }
292}