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, HarnessPreparationFailure, 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(crate) preparation_failure: Option<HarnessPreparationFailure>,
86 pub(crate) observed_updated_at: String,
87 pub cancelled: bool,
90}
91
92pub struct WorkerUpgradeCoordinator {
93 supervisor: Option<tokio::task::JoinHandle<()>>,
94 observer: WorkerUpgradeObserver,
95 results: ObservationReceiver<WorkerUpgradeResult>,
96 gate: Arc<RecoveryGate>,
97}
98
99impl Drop for WorkerUpgradeCoordinator {
100 fn drop(&mut self) {
101 self.gate.close();
104 }
105}
106
107impl WorkerUpgradeCoordinator {
108 pub fn spawn(session_manager: SessionManagerControl, recovery: &RecoveryObserver) -> Self {
111 let (observations_tx, mut observations_rx) =
112 observation_channel::<WorkerUpgradeObservation>();
113 let (results_tx, results_rx) = observation_channel();
114 let gate = recovery.gate.clone();
115 let coordinator_gate = gate.clone();
116 let supervisor = tokio::spawn(async move {
117 let mut policies = BTreeMap::<String, PolicyState>::new();
118 let mut attempts = JoinSet::new();
119 let mut closing = false;
120 loop {
121 if closing && attempts.is_empty() {
122 break;
123 }
124 tokio::select! {
125 _ = coordinator_gate.closed(), if !closing => { closing = true; }
126 observed = observations_rx.recv(), if !closing => {
127 let Some(observation) = observed else { coordinator_gate.close(); closing = true; continue; };
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 let session_manager = session_manager.clone();
139 let task_cancelled = upgrade_cancelled.cancellation();
140 let task_session_id = session_id.clone();
141 attempts.spawn(async move {
142 let observed_updated_at = observation.session.updated_at.clone();
143 let (joined, admission) = upgrade_cancelled.run_blocking(move |cancelled| {
144 let Some(session) = crate::recovery_gate::current_background_session(&observation.session)
145 .map_err(|error| format!("read current upgrade placement: {error:#}"))?
146 else { return Ok((Ok(WorkerUpgradeOutcome::Deferred), None)); };
147 let mut state = State::default();
148 state.sessions.insert(task_session_id.clone(), session);
149 let controller = Controller {
150 config: observation.config,
151 state,
152 };
153 let executor = CancellableProcessExecutor::new(cancelled)
154 .with_deadline(WORKER_UPGRADE_TIMEOUT);
155 let outcome = mj_core::runtime::block_on(controller.upgrade_session_worker(
156 &task_session_id,
157 &executor,
158 &session_manager,
159 observation.worker_build.as_deref(),
160 ))
161 .and_then(|result| result);
162 let preparation_failure = outcome
163 .as_ref()
164 .err()
165 .and_then(|error| error.downcast_ref::<HarnessPreparationFailure>())
166 .cloned();
167 Ok((
168 outcome.map_err(|error| format!("{error:#}")),
169 preparation_failure,
170 ))
171 })
172 .await;
173 let (outcome, preparation_failure) = match joined {
174 Ok(Ok(result)) => result,
175 Ok(Err(error)) => (Err(error), None),
176 Err(error) => (
177 Err(format!("worker upgrade task failed: {error}")),
178 None,
179 ),
180 };
181 let result = WorkerUpgradeResult {
182 session_id,
183 outcome,
184 preparation_failure,
185 observed_updated_at,
186 cancelled: task_cancelled.load(Ordering::Acquire),
187 };
188 (result, admission)
189 });
190 }
191 completed = attempts.join_next(), if !attempts.is_empty() => {
192 let Some(completed) = completed else { continue };
193 let (result, _admission) = match completed {
194 Ok(completed) => completed,
195 Err(error) => { tracing::error!(%error, "worker upgrade attempt supervisor failed"); continue; }
196 };
197 let policy = policies.entry(result.session_id.clone()).or_default();
198 policy.record(&result, Utc::now());
199 if let Err(error) = &result.outcome && !result.cancelled {
200 tracing::warn!(session_id = %result.session_id, %error, "background worker upgrade failed");
201 }
202 results_tx.send(result.session_id.clone(), result, |_, _| {});
203 }
204 }
205 }
206 });
207 Self {
208 supervisor: Some(supervisor),
209 observer: WorkerUpgradeObserver {
210 observations: observations_tx,
211 },
212 results: results_rx,
213 gate,
214 }
215 }
216
217 pub async fn shutdown(&mut self) -> anyhow::Result<()> {
220 self.gate.close();
221 if let Some(supervisor) = self.supervisor.take() {
222 supervisor.await.map_err(|error| {
223 anyhow::anyhow!("background coordinator supervisor failed: {error}")
224 })?;
225 }
226 Ok(())
227 }
228
229 pub fn observer(&self) -> WorkerUpgradeObserver {
230 self.observer.clone()
231 }
232
233 pub fn try_result(&mut self) -> Option<WorkerUpgradeResult> {
234 self.results.try_recv()
235 }
236}
237
238#[derive(Debug, Default, PartialEq, Eq)]
240struct PolicyState {
241 current_build: Option<String>,
244 failed_at: Option<DateTime<Utc>>,
245 deferred_at: Option<DateTime<Utc>>,
246 consecutive_failures: u32,
247}
248
249impl PolicyState {
250 fn due(&self, observation: &WorkerUpgradeObservation, now: DateTime<Utc>) -> bool {
252 if !observation.quiet || observation.session.state != SessionState::Running {
255 return false;
256 }
257 if self.worker_is_known_current(observation.worker_build.as_deref()) {
258 return false;
259 }
260 if self.deferred_at.is_some_and(|deferred| {
261 !elapsed_at_least(deferred, now, WORKER_UPGRADE_DEFERRED_INTERVAL)
262 }) {
263 return false;
264 }
265 self.failed_at.is_none_or(|failed_at| {
266 elapsed_at_least(
267 failed_at,
268 now,
269 backoff_delay(
270 WORKER_UPGRADE_RETRY_INTERVAL,
271 MAX_WORKER_UPGRADE_RETRY_INTERVAL,
272 self.consecutive_failures,
273 ),
274 )
275 })
276 }
277
278 fn worker_is_known_current(&self, worker_build: Option<&str>) -> bool {
282 let (Some(observed), Some(current)) = (worker_build, self.current_build.as_deref()) else {
283 return false;
284 };
285 observed == current
286 }
287
288 fn observe(&mut self, observation: &WorkerUpgradeObservation) {
294 if self.current_build.is_some()
295 && observation.worker_build.as_deref() == self.current_build.as_deref()
296 {
297 self.failed_at = None;
298 self.deferred_at = None;
299 self.consecutive_failures = 0;
300 }
301 }
302
303 fn record(&mut self, result: &WorkerUpgradeResult, now: DateTime<Utc>) {
304 if result.cancelled {
305 return;
308 }
309 self.deferred_at = None;
310 match &result.outcome {
311 Ok(WorkerUpgradeOutcome::Deferred) => {
312 self.deferred_at = Some(now);
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 }
321 Ok(outcome @ WorkerUpgradeOutcome::Upgraded { .. }) => {
322 self.current_build = outcome.build().map(str::to_owned);
326 self.consecutive_failures = self.consecutive_failures.saturating_add(1);
332 self.failed_at = Some(now);
333 }
334 Err(_) => {
335 self.consecutive_failures = self.consecutive_failures.saturating_add(1);
336 self.failed_at = Some(now);
337 }
338 }
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345
346 fn session_record(state: SessionState) -> SessionRecord {
347 SessionRecord {
348 project: None,
349 target_runtime: None,
350 launch_base: None,
351 launch_branch: None,
352 checkout: None,
353 publication: None,
354 build_cache: None,
355 container_workspace: None,
356 subagents: None,
357 create_managed_worktree: None,
358 workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
359 archived: false,
360 container_cpus: None,
361 container_memory: None,
362 id: "session-1".to_owned(),
363 title: "work".into(),
364 harness_kind: mj_core::config::HarnessKind::Codex,
365 last_profile: "codex-1".into(),
366 bundle_id: "hel".into(),
367 project_directory: None,
368 managed_worktree: None,
369 review: None,
370 no_project_memory: false,
371 no_mailbox: false,
372 target_template_id: "podman".into(),
373 resource_allocation: None,
374 additional_mounts: Vec::new(),
375 state,
376 target: None,
377 native_session_id: None,
378 acp_session_title: None,
379 session_title_override: None,
380 created_at: "2026-08-09T12:00:00Z".into(),
381 updated_at: "2026-08-09T12:01:00Z".into(),
382 viewed_through_event_ordinal: 0,
383 draft_input: String::new(),
384 last_error: None,
385 last_checkpoint_error: None,
386 checkpoint: None,
387 }
388 }
389
390 fn observation(worker_build: Option<&str>, quiet: bool) -> WorkerUpgradeObservation {
391 WorkerUpgradeObservation {
392 session: session_record(SessionState::Running),
393 config: Config::default(),
394 worker_build: worker_build.map(str::to_owned),
395 quiet,
396 }
397 }
398
399 fn failure(detail: &str) -> WorkerUpgradeResult {
400 WorkerUpgradeResult {
401 session_id: "session-1".into(),
402 outcome: Err(detail.into()),
403 preparation_failure: None,
404 observed_updated_at: "2026-08-09T12:01:00Z".into(),
405 cancelled: false,
406 }
407 }
408
409 fn current(build: &str) -> WorkerUpgradeOutcome {
410 WorkerUpgradeOutcome::AlreadyCurrent {
411 build: build.to_owned(),
412 }
413 }
414
415 fn upgraded(build: &str) -> WorkerUpgradeOutcome {
416 WorkerUpgradeOutcome::Upgraded {
417 build: build.to_owned(),
418 }
419 }
420
421 fn success(outcome: WorkerUpgradeOutcome) -> WorkerUpgradeResult {
422 WorkerUpgradeResult {
423 session_id: "session-1".into(),
424 outcome: Ok(outcome),
425 preparation_failure: None,
426 observed_updated_at: "2026-08-09T12:01:00Z".into(),
427 cancelled: false,
428 }
429 }
430
431 #[test]
432 fn a_busy_turn_is_never_upgraded_no_matter_how_long_it_runs() {
433 let started = Utc::now();
434 let policy = PolicyState::default();
435 let two_days_later = started + chrono::Duration::days(2);
436
437 assert!(!policy.due(&observation(Some("old-build"), false), two_days_later));
438 assert!(
439 policy.due(&observation(Some("old-build"), true), two_days_later),
440 "the next quiet observation may upgrade without an age-based busy timeout"
441 );
442 assert!(
443 policy.due(&observation(None, true), two_days_later),
444 "a worker too old to report a build is outdated"
445 );
446 }
447
448 #[test]
451 fn only_a_running_session_is_due() {
452 let now = Utc::now();
453 let policy = PolicyState::default();
454 for state in [
455 SessionState::Provisioning,
456 SessionState::Disconnected,
457 SessionState::Checkpointing,
458 SessionState::Closing,
459 SessionState::Destroying,
460 SessionState::Stopped,
461 SessionState::Lost,
462 SessionState::Error,
463 SessionState::DestroyedWithDataLoss,
464 ] {
465 let mut observation = observation(Some("build-a"), true);
466 observation.session.state = state;
467 assert!(!policy.due(&observation, now), "{state:?}");
468 }
469 }
470
471 #[test]
473 fn a_worker_proved_current_stays_trusted_for_coordinator_lifetime() {
474 let now = Utc::now();
475 let mut policy = PolicyState::default();
476 policy.record(&success(current("build-a")), now);
477
478 assert!(!policy.due(&observation(Some("build-a"), true), now));
479 assert!(
480 !policy.due(
481 &observation(Some("build-a"), true),
482 now + chrono::Duration::days(2)
483 ),
484 "the launched build remains trusted for the coordinator lifetime"
485 );
486 assert!(
487 policy.due(&observation(Some("build-b"), true), now),
488 "a different build is outdated however recently the last one was checked"
489 );
490 }
491
492 #[test]
495 fn a_failed_upgrade_backs_off_and_widens() {
496 let now = Utc::now();
497 let mut policy = PolicyState::default();
498 policy.record(&failure("install the current Mjolnir worker binary"), now);
499
500 let interval = chrono::Duration::from_std(WORKER_UPGRADE_RETRY_INTERVAL).unwrap();
501 assert!(!policy.due(&observation(Some("build-a"), true), now));
502 assert!(!policy.due(
503 &observation(Some("build-a"), true),
504 now + interval - chrono::Duration::seconds(1)
505 ));
506 assert!(policy.due(&observation(Some("build-a"), true), now + interval));
507
508 policy.record(&failure("install the current Mjolnir worker binary"), now);
509 assert!(!policy.due(
510 &observation(Some("build-a"), true),
511 now + interval * 2 - chrono::Duration::seconds(1)
512 ));
513 assert!(policy.due(&observation(Some("build-a"), true), now + interval * 2));
514 }
515
516 #[test]
520 fn a_successful_upgrade_stops_the_probing_and_confirming_it_clears_the_backoff() {
521 let now = Utc::now();
522 let mut policy = PolicyState::default();
523 policy.record(&failure("install the current Mjolnir worker binary"), now);
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 #[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.record(&success(upgraded("build-b")), now);
544
545 let unchanged = observation(Some("build-a"), true);
548 policy.observe(&unchanged);
549 let interval = chrono::Duration::from_std(WORKER_UPGRADE_RETRY_INTERVAL).unwrap();
550 assert!(!policy.due(&unchanged, now));
551 assert!(policy.due(&unchanged, now + interval));
552
553 policy.record(&success(upgraded("build-b")), now + interval);
554 policy.observe(&unchanged);
555 assert!(!policy.due(&unchanged, now + interval * 2));
556 assert!(policy.due(&unchanged, now + interval * 3));
557 }
558
559 #[test]
561 fn a_deferred_upgrade_waits_before_repeating_preparation() {
562 let now = Utc::now();
563 let mut policy = PolicyState::default();
564 policy.record(&success(WorkerUpgradeOutcome::Deferred), now);
565
566 assert!(!policy.due(&observation(Some("build-a"), true), now));
567 let delay = chrono::Duration::from_std(WORKER_UPGRADE_DEFERRED_INTERVAL).unwrap();
568 assert!(!policy.due(
569 &observation(Some("build-a"), true),
570 now + delay - chrono::Duration::milliseconds(1)
571 ));
572 assert!(policy.due(&observation(Some("build-a"), true), now + delay));
573 }
574
575 #[test]
578 fn a_preempted_attempt_is_neither_a_success_nor_a_failure() {
579 let now = Utc::now();
580 let mut policy = PolicyState::default();
581 policy.record(
582 &WorkerUpgradeResult {
583 session_id: "session-1".into(),
584 outcome: Err("operation cancelled".into()),
585 preparation_failure: None,
586 observed_updated_at: "2026-08-09T12:01:00Z".into(),
587 cancelled: true,
588 },
589 now,
590 );
591
592 assert_eq!(policy.consecutive_failures, 0);
593 assert!(policy.due(&observation(Some("build-a"), true), now));
594 }
595
596 #[test]
599 fn the_shared_gate_keeps_an_upgrade_and_a_recovery_copy_apart() {
600 let gate = Arc::new(RecoveryGate::default());
601 let recovery_copy = gate.try_start("session-1").expect("the slot starts free");
602
603 assert!(gate.try_start("session-1").is_none());
604
605 drop(recovery_copy);
606 assert!(gate.try_start("session-1").is_some());
607 }
608
609 #[test]
612 fn observing_hands_off_without_waiting() {
613 let (observations, mut queued) = observation_channel();
614 let observer = WorkerUpgradeObserver { observations };
615
616 for _ in 0..64 {
617 observer.observe(observation(Some("build-a"), true));
618 }
619
620 let received = std::iter::from_fn(|| queued.try_recv()).count();
621 assert_eq!(received, 1);
622 }
623}