1use std::collections::BTreeMap;
13use std::sync::Arc;
14use std::sync::atomic::{AtomicBool, Ordering};
15use std::time::Duration;
16
17use chrono::{DateTime, Utc};
18use tokio::sync::mpsc;
19
20use crate::hel_controller::{Controller, WorkerUpgradeOutcome};
21use crate::hel_recovery::{backoff_delay, elapsed_at_least};
22use crate::hel_session_manager::SessionManagerControl;
23use hel::hel_config::HelConfig;
24use hel::hel_state::{HelState, RecoveryGate, RecoveryObserver, SessionRecord, SessionState};
25use hel::hel_targets::CancellableProcessExecutor;
26
27const WORKER_UPGRADE_RETRY_INTERVAL: Duration = Duration::from_secs(10 * 60);
31
32const MAX_WORKER_UPGRADE_RETRY_INTERVAL: Duration = Duration::from_secs(2 * 60 * 60);
35
36const INSTALLED_BUILD_FRESHNESS: Duration = Duration::from_secs(10 * 60);
41
42const WORKER_UPGRADE_TIMEOUT: Duration = Duration::from_secs(15 * 60);
46
47#[derive(Debug, Clone)]
49pub struct WorkerUpgradeObservation {
50 pub session: SessionRecord,
51 pub config: HelConfig,
52 pub worker_build: Option<String>,
55 pub quiet: bool,
62}
63
64#[derive(Clone)]
69pub struct WorkerUpgradeObserver {
70 observations: mpsc::UnboundedSender<WorkerUpgradeObservation>,
71}
72
73impl WorkerUpgradeObserver {
74 pub fn observe(&self, observation: WorkerUpgradeObservation) {
75 let session_id = observation.session.id.clone();
76 if let Err(error) = self.observations.send(observation) {
77 tracing::debug!(
78 %session_id,
79 %error,
80 "worker upgrade observation dropped because the coordinator stopped"
81 );
82 }
83 }
84}
85
86#[derive(Debug, Clone)]
87pub struct WorkerUpgradeResult {
88 pub session_id: String,
89 pub outcome: Result<WorkerUpgradeOutcome, String>,
90 pub cancelled: bool,
93}
94
95pub struct WorkerUpgradeCoordinator {
96 observer: WorkerUpgradeObserver,
97 results: mpsc::UnboundedReceiver<WorkerUpgradeResult>,
98 cancelled: Arc<AtomicBool>,
99 gate: Arc<RecoveryGate>,
100}
101
102impl Drop for WorkerUpgradeCoordinator {
103 fn drop(&mut self) {
104 self.cancelled.store(true, Ordering::Release);
109 self.gate.cancel_all();
110 }
111}
112
113impl WorkerUpgradeCoordinator {
114 pub fn spawn(session_manager: SessionManagerControl, recovery: &RecoveryObserver) -> Self {
117 let (observations_tx, mut observations_rx) =
118 mpsc::unbounded_channel::<WorkerUpgradeObservation>();
119 let (completed_tx, mut completed_rx) = mpsc::unbounded_channel::<WorkerUpgradeResult>();
120 let (results_tx, results_rx) = mpsc::unbounded_channel();
121 let gate = recovery.gate.clone();
122 let coordinator_gate = gate.clone();
123 let cancelled = Arc::new(AtomicBool::new(false));
124 let coordinator_cancelled = cancelled.clone();
125 tokio::spawn(async move {
126 let mut policies = BTreeMap::<String, PolicyState>::new();
127 loop {
128 tokio::select! {
129 observed = observations_rx.recv() => {
130 let Some(observation) = observed else { break };
131 if coordinator_cancelled.load(Ordering::Acquire) {
132 break;
133 }
134 let session_id = observation.session.id.clone();
135 let policy = policies.entry(session_id.clone()).or_default();
136 policy.observe(&observation);
137 if !policy.due(&observation, Utc::now()) {
138 continue;
139 }
140 let Some(upgrade_cancelled) = coordinator_gate.try_start(&session_id)
141 else {
142 continue;
143 };
144 policy.attempt_started();
145 let completed_tx = completed_tx.clone();
146 let session_manager = session_manager.clone();
147 let task_cancelled = upgrade_cancelled.clone();
148 let handle = tokio::runtime::Handle::current();
149 let task_session_id = session_id.clone();
150 tokio::spawn(async move {
151 let joined = tokio::task::spawn_blocking(move || {
152 let mut state = HelState::default();
153 state
154 .sessions
155 .insert(task_session_id.clone(), observation.session);
156 let controller = Controller {
157 config: observation.config,
158 state,
159 };
160 let executor = CancellableProcessExecutor::new(task_cancelled)
161 .with_deadline(WORKER_UPGRADE_TIMEOUT);
162 handle
163 .block_on(controller.upgrade_session_worker(
164 &task_session_id,
165 &executor,
166 &session_manager,
167 observation.worker_build.as_deref(),
168 ))
169 .map_err(|error| format!("{error:#}"))
170 })
171 .await;
172 let outcome = match joined {
173 Ok(outcome) => outcome,
174 Err(error) => Err(format!("worker upgrade task failed: {error}")),
175 };
176 let result = WorkerUpgradeResult {
177 session_id,
178 outcome,
179 cancelled: upgrade_cancelled.load(Ordering::Acquire),
180 };
181 let result_session_id = result.session_id.clone();
182 if let Err(error) = completed_tx.send(result) {
183 tracing::debug!(
184 session_id = %result_session_id,
185 %error,
186 "worker upgrade result dropped because the coordinator stopped"
187 );
188 }
189 });
190 }
191 completed = completed_rx.recv() => {
192 let Some(result) = completed else { break };
193 coordinator_gate.finish(&result.session_id);
194 let policy = policies.entry(result.session_id.clone()).or_default();
195 policy.record(&result, Utc::now());
196 let result_session_id = result.session_id.clone();
197 if let Err(error) = results_tx.send(result) {
198 tracing::debug!(
199 session_id = %result_session_id,
200 %error,
201 "worker upgrade result dropped because its consumer stopped"
202 );
203 }
204 }
205 }
206 }
207 });
208 Self {
209 observer: WorkerUpgradeObserver {
210 observations: observations_tx,
211 },
212 results: results_rx,
213 cancelled,
214 gate,
215 }
216 }
217
218 pub fn observer(&self) -> WorkerUpgradeObserver {
219 self.observer.clone()
220 }
221
222 pub fn try_result(&mut self) -> Option<WorkerUpgradeResult> {
223 self.results.try_recv().ok()
224 }
225}
226
227#[derive(Debug, Default, PartialEq, Eq)]
229struct PolicyState {
230 current_build: Option<String>,
234 current_build_at: Option<DateTime<Utc>>,
235 attempt_in_flight: bool,
238 failed_at: Option<DateTime<Utc>>,
239 consecutive_failures: u32,
240}
241
242impl PolicyState {
243 fn due(&self, observation: &WorkerUpgradeObservation, now: DateTime<Utc>) -> bool {
245 if !observation.quiet
248 || observation.session.state != SessionState::Running
249 || self.attempt_in_flight
250 {
251 return false;
252 }
253 if self.worker_is_known_current(observation.worker_build.as_deref(), now) {
254 return false;
255 }
256 self.failed_at.is_none_or(|failed_at| {
257 elapsed_at_least(
258 failed_at,
259 now,
260 backoff_delay(
261 WORKER_UPGRADE_RETRY_INTERVAL,
262 MAX_WORKER_UPGRADE_RETRY_INTERVAL,
263 self.consecutive_failures,
264 ),
265 )
266 })
267 }
268
269 fn worker_is_known_current(&self, worker_build: Option<&str>, now: DateTime<Utc>) -> bool {
273 let (Some(observed), Some(current), Some(checked_at)) = (
274 worker_build,
275 self.current_build.as_deref(),
276 self.current_build_at,
277 ) else {
278 return false;
279 };
280 observed == current && !elapsed_at_least(checked_at, now, INSTALLED_BUILD_FRESHNESS)
281 }
282
283 fn observe(&mut self, observation: &WorkerUpgradeObservation) {
289 if self.current_build.is_some()
290 && observation.worker_build.as_deref() == self.current_build.as_deref()
291 {
292 self.failed_at = None;
293 self.consecutive_failures = 0;
294 }
295 }
296
297 fn attempt_started(&mut self) {
298 self.attempt_in_flight = true;
299 }
300
301 fn record(&mut self, result: &WorkerUpgradeResult, now: DateTime<Utc>) {
302 self.attempt_in_flight = false;
303 if result.cancelled {
304 return;
307 }
308 match &result.outcome {
309 Ok(WorkerUpgradeOutcome::Deferred) => {
310 self.failed_at = None;
314 self.consecutive_failures = 0;
315 }
316 Ok(outcome @ WorkerUpgradeOutcome::AlreadyCurrent { .. }) => {
317 self.failed_at = None;
318 self.consecutive_failures = 0;
319 self.current_build = outcome.build().map(str::to_owned);
320 self.current_build_at = Some(now);
321 }
322 Ok(outcome @ WorkerUpgradeOutcome::Upgraded { .. }) => {
323 self.current_build = outcome.build().map(str::to_owned);
327 self.current_build_at = Some(now);
328 self.consecutive_failures = self.consecutive_failures.saturating_add(1);
334 self.failed_at = Some(now);
335 }
336 Err(_) => {
337 self.consecutive_failures = self.consecutive_failures.saturating_add(1);
338 self.failed_at = Some(now);
339 }
340 }
341 }
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 fn session_record(state: SessionState) -> SessionRecord {
349 SessionRecord {
350 workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
351 archived: false,
352 container_cpus: None,
353 container_memory: None,
354 id: "session-1".to_owned(),
355 title: "work".into(),
356 harness_kind: hel::hel_config::HarnessKind::Codex,
357 last_profile: "codex-1".into(),
358 bundle_id: "hel".into(),
359 project_directory: None,
360 managed_worktree: None,
361 target_template_id: "podman".into(),
362 resource_allocation: None,
363 additional_mounts: Vec::new(),
364 state,
365 target: None,
366 native_session_id: None,
367 acp_session_title: None,
368 session_title_override: None,
369 created_at: "2026-08-09T12:00:00Z".into(),
370 updated_at: "2026-08-09T12:01:00Z".into(),
371 viewed_through_event_ordinal: 0,
372 draft_input: String::new(),
373 last_error: None,
374 last_checkpoint_error: None,
375 checkpoint: None,
376 }
377 }
378
379 fn observation(worker_build: Option<&str>, quiet: bool) -> WorkerUpgradeObservation {
380 WorkerUpgradeObservation {
381 session: session_record(SessionState::Running),
382 config: HelConfig::default(),
383 worker_build: worker_build.map(str::to_owned),
384 quiet,
385 }
386 }
387
388 fn failure(detail: &str) -> WorkerUpgradeResult {
389 WorkerUpgradeResult {
390 session_id: "session-1".into(),
391 outcome: Err(detail.into()),
392 cancelled: false,
393 }
394 }
395
396 fn current(build: &str) -> WorkerUpgradeOutcome {
397 WorkerUpgradeOutcome::AlreadyCurrent {
398 build: build.to_owned(),
399 }
400 }
401
402 fn upgraded(build: &str) -> WorkerUpgradeOutcome {
403 WorkerUpgradeOutcome::Upgraded {
404 build: build.to_owned(),
405 }
406 }
407
408 fn success(outcome: WorkerUpgradeOutcome) -> WorkerUpgradeResult {
409 WorkerUpgradeResult {
410 session_id: "session-1".into(),
411 outcome: Ok(outcome),
412 cancelled: false,
413 }
414 }
415
416 #[test]
418 fn only_a_quiet_session_with_an_unknown_build_is_due() {
419 let now = Utc::now();
420 let policy = PolicyState::default();
421
422 assert!(policy.due(&observation(Some("build-a"), true), now));
423 assert!(
424 !policy.due(&observation(Some("build-a"), false), now),
425 "a working session must not have its worker killed"
426 );
427 assert!(
428 policy.due(&observation(None, true), now),
429 "a worker too old to report a build is outdated"
430 );
431 }
432
433 #[test]
434 fn a_busy_turn_is_never_upgraded_no_matter_how_long_it_runs() {
435 let started = Utc::now();
436 let policy = PolicyState::default();
437 let two_days_later = started + chrono::Duration::days(2);
438
439 assert!(!policy.due(&observation(Some("old-build"), false), two_days_later));
440 assert!(
441 policy.due(&observation(Some("old-build"), true), two_days_later),
442 "the next quiet observation may upgrade without an age-based busy timeout"
443 );
444 }
445
446 #[test]
449 fn only_a_running_session_is_due() {
450 let now = Utc::now();
451 let policy = PolicyState::default();
452 for state in [
453 SessionState::Provisioning,
454 SessionState::Disconnected,
455 SessionState::Checkpointing,
456 SessionState::Closing,
457 SessionState::Destroying,
458 SessionState::Stopped,
459 SessionState::Lost,
460 SessionState::Error,
461 SessionState::DestroyedWithDataLoss,
462 ] {
463 let mut observation = observation(Some("build-a"), true);
464 observation.session.state = state;
465 assert!(!policy.due(&observation, now), "{state:?}");
466 }
467 }
468
469 #[test]
472 fn an_attempt_in_flight_suppresses_further_observations() {
473 let now = Utc::now();
474 let mut policy = PolicyState::default();
475 policy.attempt_started();
476
477 assert!(!policy.due(&observation(Some("build-a"), true), now));
478 }
479
480 #[test]
483 fn a_worker_proved_current_stops_being_probed_until_the_proof_goes_stale() {
484 let now = Utc::now();
485 let mut policy = PolicyState::default();
486 policy.record(&success(current("build-a")), now);
487
488 assert!(!policy.due(&observation(Some("build-a"), true), now));
489 assert!(
490 policy.due(&observation(Some("build-b"), true), now),
491 "a different build is outdated however recently the last one was checked"
492 );
493 let stale = now + chrono::Duration::from_std(INSTALLED_BUILD_FRESHNESS).unwrap();
494 assert!(policy.due(&observation(Some("build-a"), true), stale));
495 }
496
497 #[test]
500 fn a_failed_upgrade_backs_off_and_widens() {
501 let now = Utc::now();
502 let mut policy = PolicyState::default();
503 policy.attempt_started();
504 policy.record(&failure("install the current Mjolnir worker binary"), now);
505
506 let interval = chrono::Duration::from_std(WORKER_UPGRADE_RETRY_INTERVAL).unwrap();
507 assert!(!policy.due(&observation(Some("build-a"), true), now));
508 assert!(!policy.due(
509 &observation(Some("build-a"), true),
510 now + interval - chrono::Duration::seconds(1)
511 ));
512 assert!(policy.due(&observation(Some("build-a"), true), now + interval));
513
514 policy.attempt_started();
515 policy.record(&failure("install the current Mjolnir worker binary"), now);
516 assert!(!policy.due(
517 &observation(Some("build-a"), true),
518 now + interval * 2 - chrono::Duration::seconds(1)
519 ));
520 assert!(policy.due(&observation(Some("build-a"), true), now + interval * 2));
521 }
522
523 #[test]
527 fn a_successful_upgrade_stops_the_probing_and_confirming_it_clears_the_backoff() {
528 let now = Utc::now();
529 let mut policy = PolicyState::default();
530 policy.attempt_started();
531 policy.record(&failure("install the current Mjolnir worker binary"), now);
532 policy.attempt_started();
533 policy.record(&success(upgraded("build-b")), now);
534
535 let confirmed = observation(Some("build-b"), true);
536 policy.observe(&confirmed);
537 assert_eq!(policy.consecutive_failures, 0);
538 assert_eq!(policy.failed_at, None);
539 assert!(
540 !policy.due(&confirmed, now),
541 "the worker now runs the installed build, so nothing is due"
542 );
543 }
544
545 #[test]
549 fn an_upgrade_that_does_not_take_backs_off_instead_of_looping() {
550 let now = Utc::now();
551 let mut policy = PolicyState::default();
552 policy.attempt_started();
553 policy.record(&success(upgraded("build-b")), now);
554
555 let unchanged = observation(Some("build-a"), true);
558 policy.observe(&unchanged);
559 let interval = chrono::Duration::from_std(WORKER_UPGRADE_RETRY_INTERVAL).unwrap();
560 assert!(!policy.due(&unchanged, now));
561 assert!(policy.due(&unchanged, now + interval));
562
563 policy.attempt_started();
564 policy.record(&success(upgraded("build-b")), now + interval);
565 policy.observe(&unchanged);
566 assert!(!policy.due(&unchanged, now + interval * 2));
567 assert!(policy.due(&unchanged, now + interval * 3));
568 }
569
570 #[test]
573 fn a_deferred_upgrade_is_retried_at_the_next_quiet_observation() {
574 let now = Utc::now();
575 let mut policy = PolicyState::default();
576 policy.attempt_started();
577 policy.record(&success(WorkerUpgradeOutcome::Deferred), now);
578
579 assert!(policy.due(&observation(Some("build-a"), true), now));
580 }
581
582 #[test]
585 fn a_preempted_attempt_is_neither_a_success_nor_a_failure() {
586 let now = Utc::now();
587 let mut policy = PolicyState::default();
588 policy.attempt_started();
589 policy.record(
590 &WorkerUpgradeResult {
591 session_id: "session-1".into(),
592 outcome: Err("operation cancelled".into()),
593 cancelled: true,
594 },
595 now,
596 );
597
598 assert_eq!(policy.consecutive_failures, 0);
599 assert!(policy.due(&observation(Some("build-a"), true), now));
600 }
601
602 #[test]
605 fn the_shared_gate_keeps_an_upgrade_and_a_recovery_copy_apart() {
606 let gate = Arc::new(RecoveryGate::default());
607 let recovery_copy = gate.try_start("session-1").expect("the slot starts free");
608
609 assert!(gate.try_start("session-1").is_none());
610
611 gate.finish("session-1");
612 assert!(gate.try_start("session-1").is_some());
613 drop(recovery_copy);
614 }
615
616 #[test]
619 fn observing_hands_off_without_waiting() {
620 let (observations, mut queued) = mpsc::unbounded_channel();
621 let observer = WorkerUpgradeObserver { observations };
622
623 for _ in 0..64 {
624 observer.observe(observation(Some("build-a"), true));
625 }
626
627 let received = std::iter::from_fn(|| queued.try_recv().ok()).count();
628 assert_eq!(received, 64);
629 }
630
631 #[test]
633 fn observing_a_stopped_coordinator_is_a_no_op() {
634 let (observations, queued) = mpsc::unbounded_channel();
635 let observer = WorkerUpgradeObserver { observations };
636 drop(queued);
637
638 observer.observe(observation(Some("build-a"), true));
639 }
640}