1use std::collections::BTreeMap;
13use std::sync::Arc;
14use std::sync::atomic::Ordering;
15use std::time::Duration;
16
17use chrono::{DateTime, Utc};
18use tokio::task::JoinSet;
19
20use crate::controller::{Controller, WorkerUpgradeOutcome};
21use crate::recovery::{backoff_delay, elapsed_at_least};
22use crate::recovery_gate::{
23 ObservationReceiver, ObservationSender, RecoveryGate, RecoveryObserver, observation_channel,
24};
25use crate::session_manager::SessionManagerControl;
26use crate::targets::CancellableProcessExecutor;
27use mj_core::config::Config;
28use mj_core::state::{SessionRecord, SessionState, State};
29
30const WORKER_UPGRADE_RETRY_INTERVAL: Duration = Duration::from_secs(10 * 60);
34const WORKER_UPGRADE_DEFERRED_INTERVAL: Duration = Duration::from_secs(30);
35
36const MAX_WORKER_UPGRADE_RETRY_INTERVAL: Duration = Duration::from_secs(2 * 60 * 60);
39
40const WORKER_UPGRADE_TIMEOUT: Duration = Duration::from_secs(15 * 60);
44
45#[derive(Debug, Clone)]
47pub struct WorkerUpgradeObservation {
48 pub session: SessionRecord,
49 pub config: Config,
50 pub worker_build: Option<String>,
53 pub quiet: bool,
60}
61
62#[derive(Clone)]
67pub struct WorkerUpgradeObserver {
68 observations: ObservationSender<WorkerUpgradeObservation>,
69}
70
71impl WorkerUpgradeObserver {
72 pub fn observe(&self, observation: WorkerUpgradeObservation) {
73 self.observations
74 .send(observation.session.id.clone(), observation, |_, _| {});
75 }
76}
77
78#[derive(Debug, Clone)]
79pub struct WorkerUpgradeResult {
80 pub session_id: String,
81 pub outcome: Result<WorkerUpgradeOutcome, String>,
82 pub cancelled: bool,
85}
86
87pub struct WorkerUpgradeCoordinator {
88 supervisor: Option<tokio::task::JoinHandle<()>>,
89 observer: WorkerUpgradeObserver,
90 results: ObservationReceiver<WorkerUpgradeResult>,
91 gate: Arc<RecoveryGate>,
92}
93
94impl Drop for WorkerUpgradeCoordinator {
95 fn drop(&mut self) {
96 self.gate.close();
99 }
100}
101
102impl WorkerUpgradeCoordinator {
103 pub fn spawn(session_manager: SessionManagerControl, recovery: &RecoveryObserver) -> Self {
106 let (observations_tx, mut observations_rx) =
107 observation_channel::<WorkerUpgradeObservation>();
108 let (results_tx, results_rx) = observation_channel();
109 let gate = recovery.gate.clone();
110 let coordinator_gate = gate.clone();
111 let supervisor = tokio::spawn(async move {
112 let mut policies = BTreeMap::<String, PolicyState>::new();
113 let mut attempts = JoinSet::new();
114 let mut closing = false;
115 loop {
116 if closing && attempts.is_empty() {
117 break;
118 }
119 tokio::select! {
120 _ = coordinator_gate.closed(), if !closing => { closing = true; }
121 observed = observations_rx.recv(), if !closing => {
122 let Some(observation) = observed else { coordinator_gate.close(); closing = true; continue; };
123 let session_id = observation.session.id.clone();
124 let policy = policies.entry(session_id.clone()).or_default();
125 policy.observe(&observation);
126 if !policy.due(&observation, Utc::now()) {
127 continue;
128 }
129 let Some(upgrade_cancelled) = coordinator_gate.try_start(&session_id)
130 else {
131 continue;
132 };
133 let session_manager = session_manager.clone();
134 let task_cancelled = upgrade_cancelled.cancellation();
135 let task_session_id = session_id.clone();
136 attempts.spawn(async move {
137 let (joined, admission) = upgrade_cancelled.run_blocking(move |cancelled| {
138 let Some(session) = crate::recovery_gate::current_background_session(&observation.session)
139 .map_err(|error| format!("read current upgrade placement: {error:#}"))?
140 else { return Ok(WorkerUpgradeOutcome::Deferred); };
141 let mut state = State::default();
142 state.sessions.insert(task_session_id.clone(), session);
143 let controller = Controller {
144 config: observation.config,
145 state,
146 };
147 let executor = CancellableProcessExecutor::new(cancelled)
148 .with_deadline(WORKER_UPGRADE_TIMEOUT);
149 mj_core::runtime::block_on(controller.upgrade_session_worker(
150 &task_session_id,
151 &executor,
152 &session_manager,
153 observation.worker_build.as_deref(),
154 ))
155 .and_then(|result| result)
156 .map_err(|error| format!("{error:#}"))
157 })
158 .await;
159 let outcome = match joined {
160 Ok(outcome) => outcome,
161 Err(error) => Err(format!("worker upgrade task failed: {error}")),
162 };
163 let result = WorkerUpgradeResult {
164 session_id,
165 outcome,
166 cancelled: task_cancelled.load(Ordering::Acquire),
167 };
168 (result, admission)
169 });
170 }
171 completed = attempts.join_next(), if !attempts.is_empty() => {
172 let Some(completed) = completed else { continue };
173 let (result, _admission) = match completed {
174 Ok(completed) => completed,
175 Err(error) => { tracing::error!(%error, "worker upgrade attempt supervisor failed"); continue; }
176 };
177 let policy = policies.entry(result.session_id.clone()).or_default();
178 policy.record(&result, Utc::now());
179 if let Err(error) = &result.outcome && !result.cancelled {
180 tracing::warn!(session_id = %result.session_id, %error, "background worker upgrade failed");
181 }
182 results_tx.send(result.session_id.clone(), result, |_, _| {});
183 }
184 }
185 }
186 });
187 Self {
188 supervisor: Some(supervisor),
189 observer: WorkerUpgradeObserver {
190 observations: observations_tx,
191 },
192 results: results_rx,
193 gate,
194 }
195 }
196
197 pub async fn shutdown(&mut self) -> anyhow::Result<()> {
200 self.gate.close();
201 if let Some(supervisor) = self.supervisor.take() {
202 supervisor.await.map_err(|error| {
203 anyhow::anyhow!("background coordinator supervisor failed: {error}")
204 })?;
205 }
206 Ok(())
207 }
208
209 pub fn observer(&self) -> WorkerUpgradeObserver {
210 self.observer.clone()
211 }
212
213 pub fn try_result(&mut self) -> Option<WorkerUpgradeResult> {
214 self.results.try_recv()
215 }
216}
217
218#[derive(Debug, Default, PartialEq, Eq)]
220struct PolicyState {
221 current_build: Option<String>,
224 failed_at: Option<DateTime<Utc>>,
225 deferred_at: Option<DateTime<Utc>>,
226 consecutive_failures: u32,
227}
228
229impl PolicyState {
230 fn due(&self, observation: &WorkerUpgradeObservation, now: DateTime<Utc>) -> bool {
232 if !observation.quiet || observation.session.state != SessionState::Running {
235 return false;
236 }
237 if self.worker_is_known_current(observation.worker_build.as_deref()) {
238 return false;
239 }
240 if self.deferred_at.is_some_and(|deferred| {
241 !elapsed_at_least(deferred, now, WORKER_UPGRADE_DEFERRED_INTERVAL)
242 }) {
243 return false;
244 }
245 self.failed_at.is_none_or(|failed_at| {
246 elapsed_at_least(
247 failed_at,
248 now,
249 backoff_delay(
250 WORKER_UPGRADE_RETRY_INTERVAL,
251 MAX_WORKER_UPGRADE_RETRY_INTERVAL,
252 self.consecutive_failures,
253 ),
254 )
255 })
256 }
257
258 fn worker_is_known_current(&self, worker_build: Option<&str>) -> bool {
262 let (Some(observed), Some(current)) = (worker_build, self.current_build.as_deref()) else {
263 return false;
264 };
265 observed == current
266 }
267
268 fn observe(&mut self, observation: &WorkerUpgradeObservation) {
274 if self.current_build.is_some()
275 && observation.worker_build.as_deref() == self.current_build.as_deref()
276 {
277 self.failed_at = None;
278 self.deferred_at = None;
279 self.consecutive_failures = 0;
280 }
281 }
282
283 fn record(&mut self, result: &WorkerUpgradeResult, now: DateTime<Utc>) {
284 if result.cancelled {
285 return;
288 }
289 self.deferred_at = None;
290 match &result.outcome {
291 Ok(WorkerUpgradeOutcome::Deferred) => {
292 self.deferred_at = Some(now);
295 }
296 Ok(outcome @ WorkerUpgradeOutcome::AlreadyCurrent { .. }) => {
297 self.failed_at = None;
298 self.consecutive_failures = 0;
299 self.current_build = outcome.build().map(str::to_owned);
300 }
301 Ok(outcome @ WorkerUpgradeOutcome::Upgraded { .. }) => {
302 self.current_build = outcome.build().map(str::to_owned);
306 self.consecutive_failures = self.consecutive_failures.saturating_add(1);
312 self.failed_at = Some(now);
313 }
314 Err(_) => {
315 self.consecutive_failures = self.consecutive_failures.saturating_add(1);
316 self.failed_at = Some(now);
317 }
318 }
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 fn session_record(state: SessionState) -> SessionRecord {
327 SessionRecord {
328 project: None,
329 target_runtime: None,
330 launch_base: None,
331 launch_branch: None,
332 checkout: None,
333 publication: None,
334 build_cache: None,
335 container_workspace: None,
336 subagents: None,
337 create_managed_worktree: None,
338 workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
339 archived: false,
340 container_cpus: None,
341 container_memory: None,
342 id: "session-1".to_owned(),
343 title: "work".into(),
344 harness_kind: mj_core::config::HarnessKind::Codex,
345 last_profile: "codex-1".into(),
346 bundle_id: "hel".into(),
347 project_directory: None,
348 managed_worktree: None,
349 review: None,
350 target_template_id: "podman".into(),
351 resource_allocation: None,
352 additional_mounts: Vec::new(),
353 state,
354 target: None,
355 native_session_id: None,
356 acp_session_title: None,
357 session_title_override: None,
358 created_at: "2026-08-09T12:00:00Z".into(),
359 updated_at: "2026-08-09T12:01:00Z".into(),
360 viewed_through_event_ordinal: 0,
361 draft_input: String::new(),
362 last_error: None,
363 last_checkpoint_error: None,
364 checkpoint: None,
365 }
366 }
367
368 fn observation(worker_build: Option<&str>, quiet: bool) -> WorkerUpgradeObservation {
369 WorkerUpgradeObservation {
370 session: session_record(SessionState::Running),
371 config: Config::default(),
372 worker_build: worker_build.map(str::to_owned),
373 quiet,
374 }
375 }
376
377 fn failure(detail: &str) -> WorkerUpgradeResult {
378 WorkerUpgradeResult {
379 session_id: "session-1".into(),
380 outcome: Err(detail.into()),
381 cancelled: false,
382 }
383 }
384
385 fn current(build: &str) -> WorkerUpgradeOutcome {
386 WorkerUpgradeOutcome::AlreadyCurrent {
387 build: build.to_owned(),
388 }
389 }
390
391 fn upgraded(build: &str) -> WorkerUpgradeOutcome {
392 WorkerUpgradeOutcome::Upgraded {
393 build: build.to_owned(),
394 }
395 }
396
397 fn success(outcome: WorkerUpgradeOutcome) -> WorkerUpgradeResult {
398 WorkerUpgradeResult {
399 session_id: "session-1".into(),
400 outcome: Ok(outcome),
401 cancelled: false,
402 }
403 }
404
405 #[test]
407 fn only_a_quiet_session_with_an_unknown_build_is_due() {
408 let now = Utc::now();
409 let policy = PolicyState::default();
410
411 assert!(policy.due(&observation(Some("build-a"), true), now));
412 assert!(
413 !policy.due(&observation(Some("build-a"), false), now),
414 "a working session must not have its worker killed"
415 );
416 assert!(
417 policy.due(&observation(None, true), now),
418 "a worker too old to report a build is outdated"
419 );
420 }
421
422 #[test]
423 fn a_busy_turn_is_never_upgraded_no_matter_how_long_it_runs() {
424 let started = Utc::now();
425 let policy = PolicyState::default();
426 let two_days_later = started + chrono::Duration::days(2);
427
428 assert!(!policy.due(&observation(Some("old-build"), false), two_days_later));
429 assert!(
430 policy.due(&observation(Some("old-build"), true), two_days_later),
431 "the next quiet observation may upgrade without an age-based busy timeout"
432 );
433 }
434
435 #[test]
438 fn only_a_running_session_is_due() {
439 let now = Utc::now();
440 let policy = PolicyState::default();
441 for state in [
442 SessionState::Provisioning,
443 SessionState::Disconnected,
444 SessionState::Checkpointing,
445 SessionState::Closing,
446 SessionState::Destroying,
447 SessionState::Stopped,
448 SessionState::Lost,
449 SessionState::Error,
450 SessionState::DestroyedWithDataLoss,
451 ] {
452 let mut observation = observation(Some("build-a"), true);
453 observation.session.state = state;
454 assert!(!policy.due(&observation, now), "{state:?}");
455 }
456 }
457
458 #[test]
460 fn a_worker_proved_current_stays_trusted_for_coordinator_lifetime() {
461 let now = Utc::now();
462 let mut policy = PolicyState::default();
463 policy.record(&success(current("build-a")), now);
464
465 assert!(!policy.due(&observation(Some("build-a"), true), now));
466 assert!(
467 !policy.due(
468 &observation(Some("build-a"), true),
469 now + chrono::Duration::days(2)
470 ),
471 "the launched build remains trusted for the coordinator lifetime"
472 );
473 assert!(
474 policy.due(&observation(Some("build-b"), true), now),
475 "a different build is outdated however recently the last one was checked"
476 );
477 }
478
479 #[test]
482 fn a_failed_upgrade_backs_off_and_widens() {
483 let now = Utc::now();
484 let mut policy = PolicyState::default();
485 policy.record(&failure("install the current Mjolnir worker binary"), now);
486
487 let interval = chrono::Duration::from_std(WORKER_UPGRADE_RETRY_INTERVAL).unwrap();
488 assert!(!policy.due(&observation(Some("build-a"), true), now));
489 assert!(!policy.due(
490 &observation(Some("build-a"), true),
491 now + interval - chrono::Duration::seconds(1)
492 ));
493 assert!(policy.due(&observation(Some("build-a"), true), now + interval));
494
495 policy.record(&failure("install the current Mjolnir worker binary"), now);
496 assert!(!policy.due(
497 &observation(Some("build-a"), true),
498 now + interval * 2 - chrono::Duration::seconds(1)
499 ));
500 assert!(policy.due(&observation(Some("build-a"), true), now + interval * 2));
501 }
502
503 #[test]
507 fn a_successful_upgrade_stops_the_probing_and_confirming_it_clears_the_backoff() {
508 let now = Utc::now();
509 let mut policy = PolicyState::default();
510 policy.record(&failure("install the current Mjolnir worker binary"), now);
511 policy.record(&success(upgraded("build-b")), now);
512
513 let confirmed = observation(Some("build-b"), true);
514 policy.observe(&confirmed);
515 assert_eq!(policy.consecutive_failures, 0);
516 assert_eq!(policy.failed_at, None);
517 assert!(
518 !policy.due(&confirmed, now),
519 "the worker now runs the installed build, so nothing is due"
520 );
521 }
522
523 #[test]
527 fn an_upgrade_that_does_not_take_backs_off_instead_of_looping() {
528 let now = Utc::now();
529 let mut policy = PolicyState::default();
530 policy.record(&success(upgraded("build-b")), now);
531
532 let unchanged = observation(Some("build-a"), true);
535 policy.observe(&unchanged);
536 let interval = chrono::Duration::from_std(WORKER_UPGRADE_RETRY_INTERVAL).unwrap();
537 assert!(!policy.due(&unchanged, now));
538 assert!(policy.due(&unchanged, now + interval));
539
540 policy.record(&success(upgraded("build-b")), now + interval);
541 policy.observe(&unchanged);
542 assert!(!policy.due(&unchanged, now + interval * 2));
543 assert!(policy.due(&unchanged, now + interval * 3));
544 }
545
546 #[test]
548 fn a_deferred_upgrade_waits_before_repeating_preparation() {
549 let now = Utc::now();
550 let mut policy = PolicyState::default();
551 policy.record(&success(WorkerUpgradeOutcome::Deferred), now);
552
553 assert!(!policy.due(&observation(Some("build-a"), true), now));
554 let delay = chrono::Duration::from_std(WORKER_UPGRADE_DEFERRED_INTERVAL).unwrap();
555 assert!(!policy.due(
556 &observation(Some("build-a"), true),
557 now + delay - chrono::Duration::milliseconds(1)
558 ));
559 assert!(policy.due(&observation(Some("build-a"), true), now + delay));
560 }
561
562 #[test]
565 fn a_preempted_attempt_is_neither_a_success_nor_a_failure() {
566 let now = Utc::now();
567 let mut policy = PolicyState::default();
568 policy.record(
569 &WorkerUpgradeResult {
570 session_id: "session-1".into(),
571 outcome: Err("operation cancelled".into()),
572 cancelled: true,
573 },
574 now,
575 );
576
577 assert_eq!(policy.consecutive_failures, 0);
578 assert!(policy.due(&observation(Some("build-a"), true), now));
579 }
580
581 #[test]
584 fn the_shared_gate_keeps_an_upgrade_and_a_recovery_copy_apart() {
585 let gate = Arc::new(RecoveryGate::default());
586 let recovery_copy = gate.try_start("session-1").expect("the slot starts free");
587
588 assert!(gate.try_start("session-1").is_none());
589
590 drop(recovery_copy);
591 assert!(gate.try_start("session-1").is_some());
592 }
593
594 #[test]
597 fn observing_hands_off_without_waiting() {
598 let (observations, mut queued) = observation_channel();
599 let observer = WorkerUpgradeObserver { observations };
600
601 for _ in 0..64 {
602 observer.observe(observation(Some("build-a"), true));
603 }
604
605 let received = std::iter::from_fn(|| queued.try_recv()).count();
606 assert_eq!(received, 1);
607 }
608
609 #[test]
611 fn observing_a_stopped_coordinator_is_a_no_op() {
612 let (observations, queued) = observation_channel();
613 let observer = WorkerUpgradeObserver { observations };
614 drop(queued);
615
616 observer.observe(observation(Some("build-a"), true));
617 }
618}