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/// How long a worker that is already the current build stays trusted before
37/// the binary this controller would install is resolved again. The daemon
38/// normally restarts when it is upgraded, but the binary on disk can also be
39/// replaced under a daemon that keeps running.
40const INSTALLED_BUILD_FRESHNESS: Duration = Duration::from_secs(10 * 60);
41
42/// Upper bound on one upgrade. Stopping, installing, starting and waiting for
43/// a recovered journal all happen inside it; past this the attempt is a
44/// reported failure rather than a session whose upgrade never ends.
45const WORKER_UPGRADE_TIMEOUT: Duration = Duration::from_secs(15 * 60);
46
47/// One session view, reduced to what the upgrade decision reads.
48#[derive(Debug, Clone)]
49pub struct WorkerUpgradeObservation {
50    pub session: SessionRecord,
51    pub config: HelConfig,
52    /// Content address the connected worker reported in hello, or `None` when
53    /// it is too old to report one. `None` counts as outdated.
54    pub worker_build: Option<String>,
55    /// Whether replacing the worker now would destroy nothing. See
56    /// [`hel::hel_worker::RelayOperationalState::is_quiet`].
57    ///
58    /// An observer may skip reporting a session that is working - the daemon
59    /// does, to keep the config clone off the streaming path - but the rule
60    /// lives here, so a busy observation that does arrive is still refused.
61    pub quiet: bool,
62}
63
64/// Reports session activity to the upgrade coordinator.
65///
66/// Like the recovery observer, this is a queued hand-off: the caller is an
67/// event loop and must never wait on an upgrade decision.
68#[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    /// The attempt was preempted by a lifecycle operation or by coordinator
91    /// shutdown. It judged nothing, so it is neither a success nor a failure.
92    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        // Stop the coordinator loop, then cancel every attempt already
105        // running. The gate is shared, so this also stops recovery copies -
106        // which is what dropping either coordinator means: the process that
107        // owns both is going away.
108        self.cancelled.store(true, Ordering::Release);
109        self.gate.cancel_all();
110    }
111}
112
113impl WorkerUpgradeCoordinator {
114    /// Start the coordinator, sharing the recovery observer's gate so a
115    /// recovery copy and an upgrade never touch one session at the same time.
116    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/// What the coordinator remembers about one session between observations.
228#[derive(Debug, Default, PartialEq, Eq)]
229struct PolicyState {
230    /// The build an attempt found this session's worker already running, and
231    /// when. While the observed build still matches it, no attempt is needed
232    /// and nothing has to be hashed.
233    current_build: Option<String>,
234    current_build_at: Option<DateTime<Utc>>,
235    /// An attempt is running. The gate enforces this too, but it is shared, so
236    /// the policy keeps its own record of what it started.
237    attempt_in_flight: bool,
238    failed_at: Option<DateTime<Utc>>,
239    consecutive_failures: u32,
240}
241
242impl PolicyState {
243    /// Whether an upgrade attempt should start for this observation.
244    fn due(&self, observation: &WorkerUpgradeObservation, now: DateTime<Utc>) -> bool {
245        // Killing a worker mid-turn destroys the turn, and a session that is
246        // shutting down or already stopped has no worker worth replacing.
247        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    /// Whether a previous attempt proved this exact build current, recently
270    /// enough to still believe it. A worker reporting no build is never
271    /// current: it predates the field, so it predates this controller.
272    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    /// Fold in what one observation proves, before deciding whether to act.
284    ///
285    /// The one thing it can prove is that the last upgrade took: the worker
286    /// now reports the build that upgrade installed. That releases the
287    /// cooldown an upgrade leaves behind.
288    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            // A preempted attempt judged nothing: it must neither be counted
305            // as a failure nor suppress the next observation.
306            return;
307        }
308        match &result.outcome {
309            Ok(WorkerUpgradeOutcome::Deferred) => {
310                // The session started working between the observation and the
311                // attempt. That is ordinary, and the next quiet observation
312                // tries again.
313                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                // The worker this session runs now, so the next observation
324                // reporting it needs no attempt - and, through `observe`,
325                // clears the cooldown below.
326                self.current_build = outcome.build().map(str::to_owned);
327                self.current_build_at = Some(now);
328                // An upgrade that did not take would otherwise restart this
329                // worker every time the freshness window expired, forever. The
330                // same widening cooldown a failure gets bounds that; a worker
331                // that comes back reporting the installed build releases it
332                // immediately, so a healthy upgrade pays nothing.
333                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    /// The two facts that make an upgrade due, each on its own.
417    #[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    /// A session that is closing, checkpointing or already stopped is not a
447    /// session whose worker may be replaced underneath it.
448    #[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    /// One attempt per session at a time: further observations of the same
470    /// quiet session must not pile up restarts on the same worker.
471    #[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    /// A worker an attempt proved current stops being probed, until that proof
481    /// is old enough that the binary on disk may have moved on.
482    #[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    /// A failed upgrade waits, and waits longer each time, so a broken target
498    /// is not restarted on every sync tick.
499    #[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    /// A successful upgrade stops the session being probed again: the worker
524    /// that answers next is the new one, and confirming it clears the failure
525    /// run the upgrade itself was guarded by.
526    #[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    /// An upgrade that does not take - the worker comes back reporting a build
546    /// that is still not the installed one - must not restart that worker on
547    /// every freshness window forever.
548    #[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        // The worker came back as something else, so nothing confirms the
556        // upgrade and the cooldown stands.
557        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    /// A session that started working again judged nothing about the target,
571    /// so the next quiet observation tries straight away.
572    #[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    /// A preempted attempt says nothing either way: it must not count as a
583    /// failure and must not delay the next attempt.
584    #[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    /// Recovery holds the same per-session slot, so an upgrade cannot start
603    /// while a recovery copy is running for that session.
604    #[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    /// Observing must hand off and return: the daemon reports from its event
617    /// loop, and no upgrade decision may hold that loop up.
618    #[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    /// A stopped coordinator leaves observing harmless.
632    #[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}