Skip to main content

mj_controller/
hel_worker_upgrade.rs

1//! Background worker-binary upgrade policy and coordination.
2//!
3//! A running session keeps the worker it started with, so a session that is
4//! never stopped never gains anything a newer daemon's worker learned. This
5//! coordinator watches the same session views the recovery coordinator does
6//! and, when a session is quiet and its worker is a different build from the
7//! one this controller would install, replaces it in place.
8//!
9//! Quiet is the whole safety argument: stopping a worker tears down the ACP
10//! bridge with it, so an upgrade may only run when nothing would be lost.
11
12use 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
27/// How long a failed upgrade waits before it is tried again, doubling per
28/// consecutive failure. A session that is quiet produces an observation every
29/// sync tick, so without this one broken target would be retried forever.
30const WORKER_UPGRADE_RETRY_INTERVAL: Duration = Duration::from_secs(10 * 60);
31
32/// Ceiling on the widening retry delay, so a target that is broken rather than
33/// blipping is still probed, just rarely.
34const MAX_WORKER_UPGRADE_RETRY_INTERVAL: Duration = Duration::from_secs(2 * 60 * 60);
35
36/// Upper bound on one upgrade. Stopping, installing, starting and waiting for
37/// a recovered journal all happen inside it; past this the attempt is a
38/// reported failure rather than a session whose upgrade never ends.
39const WORKER_UPGRADE_TIMEOUT: Duration = Duration::from_secs(15 * 60);
40
41/// One session view, reduced to what the upgrade decision reads.
42#[derive(Debug, Clone)]
43pub struct WorkerUpgradeObservation {
44    pub session: SessionRecord,
45    pub config: HelConfig,
46    /// Content address the connected worker reported in hello, or `None` when
47    /// it is too old to report one. `None` counts as outdated.
48    pub worker_build: Option<String>,
49    /// Whether replacing the worker now would destroy nothing. See
50    /// [`hel::hel_worker::RelayOperationalState::is_quiet`].
51    ///
52    /// An observer may skip reporting a session that is working - the daemon
53    /// does, to keep the config clone off the streaming path - but the rule
54    /// lives here, so a busy observation that does arrive is still refused.
55    pub quiet: bool,
56}
57
58/// Reports session activity to the upgrade coordinator.
59///
60/// Like the recovery observer, this is a queued hand-off: the caller is an
61/// event loop and must never wait on an upgrade decision.
62#[derive(Clone)]
63pub struct WorkerUpgradeObserver {
64    observations: mpsc::UnboundedSender<WorkerUpgradeObservation>,
65}
66
67impl WorkerUpgradeObserver {
68    pub fn observe(&self, observation: WorkerUpgradeObservation) {
69        let session_id = observation.session.id.clone();
70        if let Err(error) = self.observations.send(observation) {
71            tracing::debug!(
72                %session_id,
73                %error,
74                "worker upgrade observation dropped because the coordinator stopped"
75            );
76        }
77    }
78}
79
80#[derive(Debug, Clone)]
81pub struct WorkerUpgradeResult {
82    pub session_id: String,
83    pub outcome: Result<WorkerUpgradeOutcome, String>,
84    /// The attempt was preempted by a lifecycle operation or by coordinator
85    /// shutdown. It judged nothing, so it is neither a success nor a failure.
86    pub cancelled: bool,
87}
88
89pub struct WorkerUpgradeCoordinator {
90    observer: WorkerUpgradeObserver,
91    results: mpsc::UnboundedReceiver<WorkerUpgradeResult>,
92    cancelled: Arc<AtomicBool>,
93    gate: Arc<RecoveryGate>,
94}
95
96impl Drop for WorkerUpgradeCoordinator {
97    fn drop(&mut self) {
98        // Stop the coordinator loop, then cancel every attempt already
99        // running. The gate is shared, so this also stops recovery copies -
100        // which is what dropping either coordinator means: the process that
101        // owns both is going away.
102        self.cancelled.store(true, Ordering::Release);
103        self.gate.cancel_all();
104    }
105}
106
107impl WorkerUpgradeCoordinator {
108    /// Start the coordinator, sharing the recovery observer's gate so a
109    /// recovery copy and an upgrade never touch one session at the same time.
110    pub fn spawn(session_manager: SessionManagerControl, recovery: &RecoveryObserver) -> Self {
111        let (observations_tx, mut observations_rx) =
112            mpsc::unbounded_channel::<WorkerUpgradeObservation>();
113        let (completed_tx, mut completed_rx) = mpsc::unbounded_channel::<WorkerUpgradeResult>();
114        let (results_tx, results_rx) = mpsc::unbounded_channel();
115        let gate = recovery.gate.clone();
116        let coordinator_gate = gate.clone();
117        let cancelled = Arc::new(AtomicBool::new(false));
118        let coordinator_cancelled = cancelled.clone();
119        tokio::spawn(async move {
120            let mut policies = BTreeMap::<String, PolicyState>::new();
121            loop {
122                tokio::select! {
123                    observed = observations_rx.recv() => {
124                        let Some(observation) = observed else { break };
125                        if coordinator_cancelled.load(Ordering::Acquire) {
126                            break;
127                        }
128                        let session_id = observation.session.id.clone();
129                        let policy = policies.entry(session_id.clone()).or_default();
130                        policy.observe(&observation);
131                        if !policy.due(&observation, Utc::now()) {
132                            continue;
133                        }
134                        let Some(upgrade_cancelled) = coordinator_gate.try_start(&session_id)
135                        else {
136                            continue;
137                        };
138                        policy.attempt_started();
139                        let completed_tx = completed_tx.clone();
140                        let session_manager = session_manager.clone();
141                        let task_cancelled = upgrade_cancelled.clone();
142                        let handle = tokio::runtime::Handle::current();
143                        let task_session_id = session_id.clone();
144                        tokio::spawn(async move {
145                            let joined = tokio::task::spawn_blocking(move || {
146                                let mut state = HelState::default();
147                                state
148                                    .sessions
149                                    .insert(task_session_id.clone(), observation.session);
150                                let controller = Controller {
151                                    config: observation.config,
152                                    state,
153                                };
154                                let executor = CancellableProcessExecutor::new(task_cancelled)
155                                    .with_deadline(WORKER_UPGRADE_TIMEOUT);
156                                handle
157                                    .block_on(controller.upgrade_session_worker(
158                                        &task_session_id,
159                                        &executor,
160                                        &session_manager,
161                                        observation.worker_build.as_deref(),
162                                    ))
163                                    .map_err(|error| format!("{error:#}"))
164                            })
165                            .await;
166                            let outcome = match joined {
167                                Ok(outcome) => outcome,
168                                Err(error) => Err(format!("worker upgrade task failed: {error}")),
169                            };
170                            let result = WorkerUpgradeResult {
171                                session_id,
172                                outcome,
173                                cancelled: upgrade_cancelled.load(Ordering::Acquire),
174                            };
175                            let result_session_id = result.session_id.clone();
176                            if let Err(error) = completed_tx.send(result) {
177                                tracing::debug!(
178                                    session_id = %result_session_id,
179                                    %error,
180                                    "worker upgrade result dropped because the coordinator stopped"
181                                );
182                            }
183                        });
184                    }
185                    completed = completed_rx.recv() => {
186                        let Some(result) = completed else { break };
187                        coordinator_gate.finish(&result.session_id);
188                        let policy = policies.entry(result.session_id.clone()).or_default();
189                        policy.record(&result, Utc::now());
190                        let result_session_id = result.session_id.clone();
191                        if let Err(error) = results_tx.send(result) {
192                            tracing::debug!(
193                                session_id = %result_session_id,
194                                %error,
195                                "worker upgrade result dropped because its consumer stopped"
196                            );
197                        }
198                    }
199                }
200            }
201        });
202        Self {
203            observer: WorkerUpgradeObserver {
204                observations: observations_tx,
205            },
206            results: results_rx,
207            cancelled,
208            gate,
209        }
210    }
211
212    pub fn observer(&self) -> WorkerUpgradeObserver {
213        self.observer.clone()
214    }
215
216    pub fn try_result(&mut self) -> Option<WorkerUpgradeResult> {
217        self.results.try_recv().ok()
218    }
219}
220
221/// What the coordinator remembers about one session between observations.
222#[derive(Debug, Default, PartialEq, Eq)]
223struct PolicyState {
224    /// The build proved current for this coordinator. While the observed
225    /// build still matches it, no attempt is needed and nothing is hashed.
226    current_build: Option<String>,
227    /// An attempt is running. The gate enforces this too, but it is shared, so
228    /// the policy keeps its own record of what it started.
229    attempt_in_flight: bool,
230    failed_at: Option<DateTime<Utc>>,
231    consecutive_failures: u32,
232}
233
234impl PolicyState {
235    /// Whether an upgrade attempt should start for this observation.
236    fn due(&self, observation: &WorkerUpgradeObservation, now: DateTime<Utc>) -> bool {
237        // Killing a worker mid-turn destroys the turn, and a session that is
238        // shutting down or already stopped has no worker worth replacing.
239        if !observation.quiet
240            || observation.session.state != SessionState::Running
241            || self.attempt_in_flight
242        {
243            return false;
244        }
245        if self.worker_is_known_current(observation.worker_build.as_deref()) {
246            return false;
247        }
248        self.failed_at.is_none_or(|failed_at| {
249            elapsed_at_least(
250                failed_at,
251                now,
252                backoff_delay(
253                    WORKER_UPGRADE_RETRY_INTERVAL,
254                    MAX_WORKER_UPGRADE_RETRY_INTERVAL,
255                    self.consecutive_failures,
256                ),
257            )
258        })
259    }
260
261    /// Whether a previous attempt proved this exact build current. A worker
262    /// reporting no build is never current: it predates the field, so it
263    /// predates this controller.
264    fn worker_is_known_current(&self, worker_build: Option<&str>) -> bool {
265        let (Some(observed), Some(current)) = (worker_build, self.current_build.as_deref()) else {
266            return false;
267        };
268        observed == current
269    }
270
271    /// Fold in what one observation proves, before deciding whether to act.
272    ///
273    /// The one thing it can prove is that the last upgrade took: the worker
274    /// now reports the build that upgrade installed. That releases the
275    /// cooldown an upgrade leaves behind.
276    fn observe(&mut self, observation: &WorkerUpgradeObservation) {
277        if self.current_build.is_some()
278            && observation.worker_build.as_deref() == self.current_build.as_deref()
279        {
280            self.failed_at = None;
281            self.consecutive_failures = 0;
282        }
283    }
284
285    fn attempt_started(&mut self) {
286        self.attempt_in_flight = true;
287    }
288
289    fn record(&mut self, result: &WorkerUpgradeResult, now: DateTime<Utc>) {
290        self.attempt_in_flight = false;
291        if result.cancelled {
292            // A preempted attempt judged nothing: it must neither be counted
293            // as a failure nor suppress the next observation.
294            return;
295        }
296        match &result.outcome {
297            Ok(WorkerUpgradeOutcome::Deferred) => {
298                // The session started working between the observation and the
299                // attempt. That is ordinary, and the next quiet observation
300                // tries again.
301                self.failed_at = None;
302                self.consecutive_failures = 0;
303            }
304            Ok(outcome @ WorkerUpgradeOutcome::AlreadyCurrent { .. }) => {
305                self.failed_at = None;
306                self.consecutive_failures = 0;
307                self.current_build = outcome.build().map(str::to_owned);
308            }
309            Ok(outcome @ WorkerUpgradeOutcome::Upgraded { .. }) => {
310                // The worker this session runs now, so the next observation
311                // reporting it needs no attempt - and, through `observe`,
312                // clears the cooldown below.
313                self.current_build = outcome.build().map(str::to_owned);
314                // An upgrade that did not take would otherwise restart this
315                // worker on every sync tick, forever. The same widening
316                // cooldown a failure gets bounds that; a worker that comes
317                // back reporting the installed build releases it immediately,
318                // so a healthy upgrade pays nothing.
319                self.consecutive_failures = self.consecutive_failures.saturating_add(1);
320                self.failed_at = Some(now);
321            }
322            Err(_) => {
323                self.consecutive_failures = self.consecutive_failures.saturating_add(1);
324                self.failed_at = Some(now);
325            }
326        }
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    fn session_record(state: SessionState) -> SessionRecord {
335        SessionRecord {
336            workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
337            archived: false,
338            container_cpus: None,
339            container_memory: None,
340            id: "session-1".to_owned(),
341            title: "work".into(),
342            harness_kind: hel::hel_config::HarnessKind::Codex,
343            last_profile: "codex-1".into(),
344            bundle_id: "hel".into(),
345            project_directory: None,
346            managed_worktree: None,
347            target_template_id: "podman".into(),
348            resource_allocation: None,
349            additional_mounts: Vec::new(),
350            state,
351            target: None,
352            native_session_id: None,
353            acp_session_title: None,
354            session_title_override: None,
355            created_at: "2026-08-09T12:00:00Z".into(),
356            updated_at: "2026-08-09T12:01:00Z".into(),
357            viewed_through_event_ordinal: 0,
358            draft_input: String::new(),
359            last_error: None,
360            last_checkpoint_error: None,
361            checkpoint: None,
362        }
363    }
364
365    fn observation(worker_build: Option<&str>, quiet: bool) -> WorkerUpgradeObservation {
366        WorkerUpgradeObservation {
367            session: session_record(SessionState::Running),
368            config: HelConfig::default(),
369            worker_build: worker_build.map(str::to_owned),
370            quiet,
371        }
372    }
373
374    fn failure(detail: &str) -> WorkerUpgradeResult {
375        WorkerUpgradeResult {
376            session_id: "session-1".into(),
377            outcome: Err(detail.into()),
378            cancelled: false,
379        }
380    }
381
382    fn current(build: &str) -> WorkerUpgradeOutcome {
383        WorkerUpgradeOutcome::AlreadyCurrent {
384            build: build.to_owned(),
385        }
386    }
387
388    fn upgraded(build: &str) -> WorkerUpgradeOutcome {
389        WorkerUpgradeOutcome::Upgraded {
390            build: build.to_owned(),
391        }
392    }
393
394    fn success(outcome: WorkerUpgradeOutcome) -> WorkerUpgradeResult {
395        WorkerUpgradeResult {
396            session_id: "session-1".into(),
397            outcome: Ok(outcome),
398            cancelled: false,
399        }
400    }
401
402    /// The two facts that make an upgrade due, each on its own.
403    #[test]
404    fn only_a_quiet_session_with_an_unknown_build_is_due() {
405        let now = Utc::now();
406        let policy = PolicyState::default();
407
408        assert!(policy.due(&observation(Some("build-a"), true), now));
409        assert!(
410            !policy.due(&observation(Some("build-a"), false), now),
411            "a working session must not have its worker killed"
412        );
413        assert!(
414            policy.due(&observation(None, true), now),
415            "a worker too old to report a build is outdated"
416        );
417    }
418
419    #[test]
420    fn a_busy_turn_is_never_upgraded_no_matter_how_long_it_runs() {
421        let started = Utc::now();
422        let policy = PolicyState::default();
423        let two_days_later = started + chrono::Duration::days(2);
424
425        assert!(!policy.due(&observation(Some("old-build"), false), two_days_later));
426        assert!(
427            policy.due(&observation(Some("old-build"), true), two_days_later),
428            "the next quiet observation may upgrade without an age-based busy timeout"
429        );
430    }
431
432    /// A session that is closing, checkpointing or already stopped is not a
433    /// session whose worker may be replaced underneath it.
434    #[test]
435    fn only_a_running_session_is_due() {
436        let now = Utc::now();
437        let policy = PolicyState::default();
438        for state in [
439            SessionState::Provisioning,
440            SessionState::Disconnected,
441            SessionState::Checkpointing,
442            SessionState::Closing,
443            SessionState::Destroying,
444            SessionState::Stopped,
445            SessionState::Lost,
446            SessionState::Error,
447            SessionState::DestroyedWithDataLoss,
448        ] {
449            let mut observation = observation(Some("build-a"), true);
450            observation.session.state = state;
451            assert!(!policy.due(&observation, now), "{state:?}");
452        }
453    }
454
455    /// One attempt per session at a time: further observations of the same
456    /// quiet session must not pile up restarts on the same worker.
457    #[test]
458    fn an_attempt_in_flight_suppresses_further_observations() {
459        let now = Utc::now();
460        let mut policy = PolicyState::default();
461        policy.attempt_started();
462
463        assert!(!policy.due(&observation(Some("build-a"), true), now));
464    }
465
466    /// A worker an attempt proved current stays trusted for the coordinator's
467    /// lifetime, while a different observed build is checked immediately.
468    #[test]
469    fn a_worker_proved_current_stays_trusted_for_coordinator_lifetime() {
470        let now = Utc::now();
471        let mut policy = PolicyState::default();
472        policy.record(&success(current("build-a")), now);
473
474        assert!(!policy.due(&observation(Some("build-a"), true), now));
475        assert!(
476            !policy.due(
477                &observation(Some("build-a"), true),
478                now + chrono::Duration::days(2)
479            ),
480            "the launched build remains trusted for the coordinator lifetime"
481        );
482        assert!(
483            policy.due(&observation(Some("build-b"), true), now),
484            "a different build is outdated however recently the last one was checked"
485        );
486    }
487
488    /// A failed upgrade waits, and waits longer each time, so a broken target
489    /// is not restarted on every sync tick.
490    #[test]
491    fn a_failed_upgrade_backs_off_and_widens() {
492        let now = Utc::now();
493        let mut policy = PolicyState::default();
494        policy.attempt_started();
495        policy.record(&failure("install the current Mjolnir worker binary"), now);
496
497        let interval = chrono::Duration::from_std(WORKER_UPGRADE_RETRY_INTERVAL).unwrap();
498        assert!(!policy.due(&observation(Some("build-a"), true), now));
499        assert!(!policy.due(
500            &observation(Some("build-a"), true),
501            now + interval - chrono::Duration::seconds(1)
502        ));
503        assert!(policy.due(&observation(Some("build-a"), true), now + interval));
504
505        policy.attempt_started();
506        policy.record(&failure("install the current Mjolnir worker binary"), now);
507        assert!(!policy.due(
508            &observation(Some("build-a"), true),
509            now + interval * 2 - chrono::Duration::seconds(1)
510        ));
511        assert!(policy.due(&observation(Some("build-a"), true), now + interval * 2));
512    }
513
514    /// A successful upgrade stops the session being probed again: the worker
515    /// that answers next is the new one, and confirming it clears the failure
516    /// run the upgrade itself was guarded by.
517    #[test]
518    fn a_successful_upgrade_stops_the_probing_and_confirming_it_clears_the_backoff() {
519        let now = Utc::now();
520        let mut policy = PolicyState::default();
521        policy.attempt_started();
522        policy.record(&failure("install the current Mjolnir worker binary"), now);
523        policy.attempt_started();
524        policy.record(&success(upgraded("build-b")), now);
525
526        let confirmed = observation(Some("build-b"), true);
527        policy.observe(&confirmed);
528        assert_eq!(policy.consecutive_failures, 0);
529        assert_eq!(policy.failed_at, None);
530        assert!(
531            !policy.due(&confirmed, now),
532            "the worker now runs the installed build, so nothing is due"
533        );
534    }
535
536    /// An upgrade that does not take - the worker comes back reporting a build
537    /// that is still not the installed one - must not restart that worker on
538    /// every freshness window forever.
539    #[test]
540    fn an_upgrade_that_does_not_take_backs_off_instead_of_looping() {
541        let now = Utc::now();
542        let mut policy = PolicyState::default();
543        policy.attempt_started();
544        policy.record(&success(upgraded("build-b")), now);
545
546        // The worker came back as something else, so nothing confirms the
547        // upgrade and the cooldown stands.
548        let unchanged = observation(Some("build-a"), true);
549        policy.observe(&unchanged);
550        let interval = chrono::Duration::from_std(WORKER_UPGRADE_RETRY_INTERVAL).unwrap();
551        assert!(!policy.due(&unchanged, now));
552        assert!(policy.due(&unchanged, now + interval));
553
554        policy.attempt_started();
555        policy.record(&success(upgraded("build-b")), now + interval);
556        policy.observe(&unchanged);
557        assert!(!policy.due(&unchanged, now + interval * 2));
558        assert!(policy.due(&unchanged, now + interval * 3));
559    }
560
561    /// A session that started working again judged nothing about the target,
562    /// so the next quiet observation tries straight away.
563    #[test]
564    fn a_deferred_upgrade_is_retried_at_the_next_quiet_observation() {
565        let now = Utc::now();
566        let mut policy = PolicyState::default();
567        policy.attempt_started();
568        policy.record(&success(WorkerUpgradeOutcome::Deferred), now);
569
570        assert!(policy.due(&observation(Some("build-a"), true), now));
571    }
572
573    /// A preempted attempt says nothing either way: it must not count as a
574    /// failure and must not delay the next attempt.
575    #[test]
576    fn a_preempted_attempt_is_neither_a_success_nor_a_failure() {
577        let now = Utc::now();
578        let mut policy = PolicyState::default();
579        policy.attempt_started();
580        policy.record(
581            &WorkerUpgradeResult {
582                session_id: "session-1".into(),
583                outcome: Err("operation cancelled".into()),
584                cancelled: true,
585            },
586            now,
587        );
588
589        assert_eq!(policy.consecutive_failures, 0);
590        assert!(policy.due(&observation(Some("build-a"), true), now));
591    }
592
593    /// Recovery holds the same per-session slot, so an upgrade cannot start
594    /// while a recovery copy is running for that session.
595    #[test]
596    fn the_shared_gate_keeps_an_upgrade_and_a_recovery_copy_apart() {
597        let gate = Arc::new(RecoveryGate::default());
598        let recovery_copy = gate.try_start("session-1").expect("the slot starts free");
599
600        assert!(gate.try_start("session-1").is_none());
601
602        gate.finish("session-1");
603        assert!(gate.try_start("session-1").is_some());
604        drop(recovery_copy);
605    }
606
607    /// Observing must hand off and return: the daemon reports from its event
608    /// loop, and no upgrade decision may hold that loop up.
609    #[test]
610    fn observing_hands_off_without_waiting() {
611        let (observations, mut queued) = mpsc::unbounded_channel();
612        let observer = WorkerUpgradeObserver { observations };
613
614        for _ in 0..64 {
615            observer.observe(observation(Some("build-a"), true));
616        }
617
618        let received = std::iter::from_fn(|| queued.try_recv().ok()).count();
619        assert_eq!(received, 64);
620    }
621
622    /// A stopped coordinator leaves observing harmless.
623    #[test]
624    fn observing_a_stopped_coordinator_is_a_no_op() {
625        let (observations, queued) = mpsc::unbounded_channel();
626        let observer = WorkerUpgradeObserver { observations };
627        drop(queued);
628
629        observer.observe(observation(Some("build-a"), true));
630    }
631}