Skip to main content

mj_controller/
pollers.rs

1//! Background feeds for the control surfaces.
2//!
3//! Everything here runs off the event loop and reports back over a channel:
4//! harness quota refreshes, worker session polling, per-session resource and
5//! deployment capacity probes, credential-sync scheduling, and the one-shot
6//! tasks that recover interrupted closes. The loop that consumes them never
7//! blocks; see [`Feed`] for the wait-then-drain shape they all share.
8
9use std::future::Future;
10use std::path::PathBuf;
11use std::sync::Arc;
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::time::{Duration, Instant};
14
15use anyhow::{Context, Result, bail};
16use mj_core::clock::epoch_seconds;
17use mj_core::config::Config;
18use mj_core::credentials::{
19    CredentialSyncCause, CredentialSyncHandle, CredentialSyncReason, CredentialSyncSignal,
20    CredentialSyncTarget,
21};
22use mj_core::state::{
23    ManagedSessionSnapshot, MaterializedSession, SessionRecord, SessionResourceAllocation,
24    SessionState, State,
25};
26
27use crate::controller::Controller;
28use crate::quota::{QuotaManager, QuotaRefreshOutcome, QuotaRefreshRequest};
29use crate::recovery::{RecoveryCoordinator, RecoveryResult};
30use crate::session_manager::{
31    ManagedSessionView, RelaySessionTarget, RemoteSessionRequest, SessionManagerControl,
32    SessionManagerShutdown, SessionManagerUpdate, SessionManagerUpdates, ViewError,
33    spawn_remote_session_manager,
34};
35use crate::targets::{
36    CancellableProcessExecutor, CommandExecutor, CommandOutput, CommandSpec,
37    DeploymentCapacityKind, DeploymentCapacityTarget, DeploymentCapacityUsage, ImageRefresh,
38    SessionResourceProbe, SessionResourceUsage,
39};
40use crate::worker_client::CredentialSyncCoordinator;
41
42use crate::daemon;
43use mj_core::state::short_id;
44
45#[cfg(test)]
46mod runtime_feed_tests;
47
48pub const QUOTA_REFRESH_INTERVAL: Duration = Duration::from_secs(10 * 60);
49/// When a quota reading stops counting as current. A reading only goes stale
50/// once a scheduled refresh should already have replaced it, so this is
51/// derived from the refresh interval rather than chosen next to it: a shorter
52/// threshold would label every healthy quota "stale" for part of every cycle.
53/// The extra interval is slack for a refresh that is itself still running.
54pub const QUOTA_STALE_AFTER: Duration = Duration::from_secs(2 * QUOTA_REFRESH_INTERVAL.as_secs());
55/// How often the daemon looks for a newer copy of every container image its
56/// targets use. Launches no longer pull, so this is what makes a remote
57/// `:latest` tag current, and it has to be rare enough to stay off the
58/// registry's back.
59pub const IMAGE_REFRESH_INTERVAL: Duration = Duration::from_secs(60 * 60);
60/// The daemon has startup work of its own, and a pull competes with it for the
61/// network. The first refresh waits this long, then the interval takes over.
62const IMAGE_REFRESH_DELAY: Duration = Duration::from_secs(30);
63pub const RESOURCE_POLL_INTERVAL: Duration = Duration::from_secs(60);
64const RESOURCE_POLL_TIMEOUT: Duration = Duration::from_secs(15);
65pub const CAPACITY_POLL_INTERVAL: Duration = Duration::from_secs(30);
66
67/// Something a control loop waits on and then drains: one awaited receive for
68/// the `select!` arm, and a non-blocking receive for the batch that follows.
69pub trait FeedSource {
70    type Item;
71
72    /// Cancel-safe: a wait that loses the race must not drop a message.
73    fn wait(&mut self) -> impl Future<Output = Option<Self::Item>>;
74
75    fn poll_now(&mut self) -> Option<Self::Item>;
76}
77
78impl<T> FeedSource for tokio::sync::mpsc::Receiver<T> {
79    type Item = T;
80
81    fn wait(&mut self) -> impl Future<Output = Option<T>> {
82        self.recv()
83    }
84
85    fn poll_now(&mut self) -> Option<T> {
86        self.try_recv().ok()
87    }
88}
89
90impl<T> FeedSource for tokio::sync::mpsc::UnboundedReceiver<T> {
91    type Item = T;
92
93    fn wait(&mut self) -> impl Future<Output = Option<T>> {
94        self.recv()
95    }
96
97    fn poll_now(&mut self) -> Option<T> {
98        self.try_recv().ok()
99    }
100}
101
102impl<T: Clone> FeedSource for tokio::sync::watch::Receiver<T> {
103    type Item = T;
104
105    async fn wait(&mut self) -> Option<T> {
106        self.changed().await.ok()?;
107        Some(self.borrow_and_update().clone())
108    }
109
110    fn poll_now(&mut self) -> Option<T> {
111        self.has_changed()
112            .ok()
113            .filter(|changed| *changed)
114            .map(|_| self.borrow_and_update().clone())
115    }
116}
117
118impl FeedSource for SessionManagerUpdates {
119    type Item = SessionManagerUpdate;
120
121    fn wait(&mut self) -> impl Future<Output = Option<SessionManagerUpdate>> {
122        self.recv()
123    }
124
125    fn poll_now(&mut self) -> Option<SessionManagerUpdate> {
126        self.try_recv().ok()
127    }
128}
129
130impl FeedSource for RecoveryCoordinator {
131    type Item = RecoveryResult;
132
133    fn wait(&mut self) -> impl Future<Output = Option<RecoveryResult>> {
134        self.result()
135    }
136
137    fn poll_now(&mut self) -> Option<RecoveryResult> {
138        self.try_result()
139    }
140}
141
142impl FeedSource for CredentialSyncCoordinator {
143    type Item = mj_core::credentials::CredentialSyncResult;
144
145    fn wait(&mut self) -> impl Future<Output = Option<Self::Item>> {
146        self.result()
147    }
148
149    fn poll_now(&mut self) -> Option<Self::Item> {
150        self.try_result()
151    }
152}
153
154/// One background feed as a control loop uses it.
155///
156/// The `select!` arm hands the message that woke the loop to [`Feed::accept`],
157/// and the drain that follows walks [`Feed::next_ready`] until the feed is
158/// empty, so a burst of updates costs one draw. A closed channel reports `None`
159/// for ever, which would leave its arm permanently ready; `accept` retires the
160/// feed instead, and [`Feed::is_open`] gates the arm.
161pub struct Feed<S: FeedSource> {
162    source: S,
163    pending: Option<S::Item>,
164    open: bool,
165}
166
167impl<S: FeedSource> Feed<S> {
168    pub fn new(source: S) -> Self {
169        Self {
170            source,
171            pending: None,
172            open: true,
173        }
174    }
175
176    pub fn is_open(&self) -> bool {
177        self.open
178    }
179
180    pub fn wait(&mut self) -> impl Future<Output = Option<S::Item>> {
181        self.source.wait()
182    }
183
184    /// Latches the message that won the select and reports whether one arrived.
185    /// Applying it determines whether the visible state needs a redraw.
186    pub fn accept(&mut self, message: Option<S::Item>) -> bool {
187        match message {
188            Some(message) => {
189                self.pending = Some(message);
190                true
191            }
192            None => {
193                self.open = false;
194                false
195            }
196        }
197    }
198
199    /// The next message for the batch drain: the one that won the select
200    /// first, then whatever queued behind it.
201    pub fn next_ready(&mut self) -> Option<S::Item> {
202        self.pending.take().or_else(|| self.source.poll_now())
203    }
204}
205
206#[derive(Debug, Clone, Default)]
207pub struct QuotaRefreshBatch {
208    pub generation: u64,
209    pub profiles: Vec<QuotaRefreshRequest>,
210}
211
212#[derive(Debug)]
213pub enum QuotaUpdate {
214    Refreshing { profile_ids: Vec<String> },
215    Report(QuotaRefreshOutcome),
216    Finished { generation: u64 },
217}
218
219pub type WorkerPollTarget = RelaySessionTarget;
220pub type WorkerPollUpdate = SessionManagerUpdate;
221
222#[derive(Debug)]
223struct WorkerDiagnosisEpisode {
224    id: u64,
225    error: String,
226    diagnosed: bool,
227}
228
229#[derive(Debug, Default)]
230pub struct WorkerDiagnosisTracker {
231    next_episode: u64,
232    current: std::collections::BTreeMap<String, WorkerDiagnosisEpisode>,
233    pending: std::collections::BTreeMap<String, u64>,
234}
235
236#[derive(Debug, Default, PartialEq, Eq)]
237pub struct WorkerDiagnosisCompletion {
238    pub display_error: Option<String>,
239    pub restart_episode: Option<u64>,
240}
241
242impl WorkerDiagnosisTracker {
243    pub fn observe(
244        &mut self,
245        session_id: &str,
246        connected: bool,
247        error: Option<String>,
248    ) -> Option<u64> {
249        if connected || error.is_none() {
250            self.current.remove(session_id);
251        }
252        let error = error?;
253        let episode = self
254            .current
255            .entry(session_id.to_owned())
256            .or_insert_with(|| {
257                self.next_episode = self.next_episode.wrapping_add(1).max(1);
258                WorkerDiagnosisEpisode {
259                    id: self.next_episode,
260                    error: error.clone(),
261                    diagnosed: false,
262                }
263            });
264        episode.error = error;
265        if episode.diagnosed || self.pending.contains_key(session_id) {
266            return None;
267        }
268        self.pending.insert(session_id.to_owned(), episode.id);
269        Some(episode.id)
270    }
271
272    pub fn finish(&mut self, session_id: &str, episode_id: u64) -> WorkerDiagnosisCompletion {
273        if self.pending.get(session_id) != Some(&episode_id) {
274            return WorkerDiagnosisCompletion::default();
275        }
276        self.pending.remove(session_id);
277        let Some(current) = self.current.get_mut(session_id) else {
278            return WorkerDiagnosisCompletion::default();
279        };
280        if current.id == episode_id {
281            current.diagnosed = true;
282            return WorkerDiagnosisCompletion {
283                display_error: Some(current.error.clone()),
284                restart_episode: None,
285            };
286        }
287        if !current.diagnosed {
288            self.pending.insert(session_id.to_owned(), current.id);
289            return WorkerDiagnosisCompletion {
290                display_error: None,
291                restart_episode: Some(current.id),
292            };
293        }
294        WorkerDiagnosisCompletion::default()
295    }
296}
297
298#[derive(Debug, Clone)]
299pub struct ResourcePollTarget {
300    session_id: String,
301    probe: SessionResourceProbe,
302}
303
304#[derive(Debug)]
305pub struct ResourcePollUpdate {
306    pub session_id: String,
307    pub usage: SessionResourceUsage,
308}
309
310#[derive(Debug)]
311pub struct CapacityPollUpdate {
312    pub target_id: String,
313    pub result: std::result::Result<Option<DeploymentCapacityUsage>, String>,
314    pub sampled_at_epoch_seconds: u64,
315}
316
317pub fn projected_queued_prompts(
318    controller: &Controller,
319) -> Result<std::collections::BTreeMap<String, Vec<mj_core::relay::QueuedPrompt>>> {
320    let queues = crate::database::load_materialized_queued_prompts()?;
321    Ok(controller
322        .state
323        .sessions
324        .keys()
325        .filter_map(|session_id| {
326            queues
327                .get(session_id)
328                .map(|queue| (session_id.clone(), queued_prompt_entries(queue)))
329        })
330        .collect())
331}
332
333pub fn quota_refresh_profiles(controller: &Controller) -> Vec<QuotaRefreshRequest> {
334    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
335    controller
336        .config
337        .enabled_profiles()
338        .map(|(id, profile)| {
339            let mut environment = profile.environment.clone();
340            profile
341                .kind
342                .configure_home_environment(&profile.home, &mut environment);
343            QuotaRefreshRequest {
344                profile_id: id.to_owned(),
345                harness: profile.kind,
346                source_home: profile.home.clone(),
347                environment,
348                cwd: cwd.clone(),
349            }
350        })
351        .collect()
352}
353
354pub fn spawn_quota_refresher() -> (
355    tokio::sync::watch::Sender<QuotaRefreshBatch>,
356    tokio::sync::mpsc::Receiver<QuotaUpdate>,
357) {
358    let (profiles_tx, mut profiles_rx) = tokio::sync::watch::channel(QuotaRefreshBatch::default());
359    let (updates_tx, updates_rx) = tokio::sync::mpsc::channel(32);
360    tokio::spawn(async move {
361        let mut quotas = QuotaManager::default();
362        let mut batch = QuotaRefreshBatch::default();
363        let mut interval = tokio::time::interval(QUOTA_REFRESH_INTERVAL);
364        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
365        interval.tick().await;
366        loop {
367            tokio::select! {
368                _ = interval.tick(), if !batch.profiles.is_empty() => {
369                    if !refresh_profile_quotas(
370                        &mut quotas,
371                        batch.generation,
372                        &batch.profiles,
373                        &updates_tx,
374                    ).await {
375                        break;
376                    }
377                }
378                changed = profiles_rx.changed() => {
379                    if changed.is_err() {
380                        tracing::debug!("quota profile target feed closed; stopping quota refresher");
381                        break;
382                    }
383                    batch = profiles_rx.borrow_and_update().clone();
384                    if !refresh_profile_quotas(
385                        &mut quotas,
386                        batch.generation,
387                        &batch.profiles,
388                        &updates_tx,
389                    ).await {
390                        break;
391                    }
392                }
393            }
394        }
395        quotas.shutdown().await;
396    });
397    (profiles_tx, updates_rx)
398}
399
400async fn refresh_profile_quotas(
401    quotas: &mut QuotaManager,
402    generation: u64,
403    profiles: &[QuotaRefreshRequest],
404    updates: &tokio::sync::mpsc::Sender<QuotaUpdate>,
405) -> bool {
406    let ids = profiles
407        .iter()
408        .map(|profile| profile.profile_id.clone())
409        .collect::<Vec<_>>();
410    if updates
411        .send(QuotaUpdate::Refreshing { profile_ids: ids })
412        .await
413        .is_err()
414    {
415        tracing::debug!("quota update consumer closed before refresh started");
416        return false;
417    }
418    // Keep draining even if the UI is gone so codex clients return to the
419    // manager for a clean shutdown; just stop sending.
420    let delivered = AtomicBool::new(true);
421    quotas
422        .refresh_profiles(profiles.to_vec(), |quota| {
423            let delivered = &delivered;
424            async move {
425                if delivered.load(Ordering::Acquire)
426                    && updates.send(QuotaUpdate::Report(quota)).await.is_err()
427                {
428                    tracing::debug!("quota update consumer closed while reporting a profile");
429                    delivered.store(false, Ordering::Release);
430                }
431            }
432        })
433        .await;
434    if !delivered.into_inner() {
435        return false;
436    }
437    if updates
438        .send(QuotaUpdate::Finished { generation })
439        .await
440        .is_err()
441    {
442        tracing::debug!(
443            generation,
444            "quota update consumer closed before refresh completed"
445        );
446        false
447    } else {
448        true
449    }
450}
451
452/// Keep every configured container image current, away from any session
453/// launch.
454///
455/// `plan` is called on every tick rather than once, so a config reload changes
456/// what gets refreshed without a daemon restart. Hosts refresh concurrently;
457/// each host runs its own commands in order.
458pub fn spawn_image_refresher(
459    plan: impl Fn() -> Vec<ImageRefresh> + Send + 'static,
460    cancellation: tokio_util::sync::CancellationToken,
461) -> tokio::task::JoinHandle<()> {
462    tokio::spawn(async move {
463        let mut interval = tokio::time::interval_at(
464            tokio::time::Instant::now() + IMAGE_REFRESH_DELAY,
465            IMAGE_REFRESH_INTERVAL,
466        );
467        // A refresh slower than the interval collapses the ticks it missed
468        // instead of stacking a second pull behind the first.
469        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
470        loop {
471            tokio::select! {
472                // Quitting wins over a tick that came due during a long
473                // refresh, so shutdown never starts one more pull.
474                biased;
475                _ = cancellation.cancelled() => return,
476                _ = interval.tick() => refresh_images(plan(), &cancellation).await,
477            }
478        }
479    })
480}
481
482async fn refresh_images(
483    plan: Vec<ImageRefresh>,
484    cancellation: &tokio_util::sync::CancellationToken,
485) {
486    if plan.is_empty() {
487        return;
488    }
489    // One flag for every host, so quitting kills the pulls in flight instead of
490    // waiting out a multi-gigabyte download.
491    let cancelled = Arc::new(AtomicBool::new(false));
492    let mut hosts = tokio::task::JoinSet::new();
493    for refresh in plan {
494        // ProcessExecutor is synchronous, and a pull is long: it belongs on a
495        // blocking thread, never on the runtime.
496        let executor = CancellableProcessExecutor::new(cancelled.clone());
497        hosts.spawn_blocking(move || {
498            let Err(error) = refresh_host_image(&refresh, &executor) else {
499                return;
500            };
501            if executor.is_cancelled() {
502                // The daemon is leaving. That is not a fault of the host.
503                tracing::debug!(
504                    host = refresh.host.label(),
505                    image = refresh.image,
506                    "container image refresh cancelled"
507                );
508                return;
509            }
510            tracing::warn!(
511                host = refresh.host.label(),
512                image = refresh.image,
513                error = format!("{error:#}"),
514                "could not refresh a container image"
515            );
516        });
517    }
518    let mut cancelling = false;
519    loop {
520        tokio::select! {
521            biased;
522            _ = cancellation.cancelled(), if !cancelling => {
523                cancelling = true;
524                cancelled.store(true, Ordering::Release);
525            }
526            joined = hosts.join_next() => match joined {
527                None => return,
528                Some(Ok(())) => {}
529                Some(Err(error)) => {
530                    tracing::warn!(%error, "container image refresh task failed");
531                }
532            },
533        }
534    }
535}
536
537/// Pull one image on one host, then drop whatever that unlinked.
538///
539/// The image id before and after says whether the pull actually changed
540/// anything, which is the only part worth an `info` line.
541fn refresh_host_image(refresh: &ImageRefresh, executor: &impl CommandExecutor) -> Result<()> {
542    let cached = image_id(&refresh.image_id, executor);
543    run_refresh_command(&refresh.pull, executor)?;
544    let pulled = image_id(&refresh.image_id, executor);
545    if pulled.is_some() && pulled != cached {
546        tracing::info!(
547            host = refresh.host.label(),
548            image = refresh.image,
549            id = pulled.unwrap_or_default(),
550            "pulled a newer container image"
551        );
552    } else {
553        tracing::debug!(
554            host = refresh.host.label(),
555            image = refresh.image,
556            "container image is already current"
557        );
558    }
559    run_refresh_command(&refresh.prune, executor)?;
560    Ok(())
561}
562
563/// The host's id for an image, or `None` when it has no copy of it yet. A
564/// missing image is the ordinary first-pull case, not a fault.
565fn image_id(command: &CommandSpec, executor: &impl CommandExecutor) -> Option<String> {
566    let output = executor.execute(command).ok()?;
567    if output.status != 0 {
568        return None;
569    }
570    let id = String::from_utf8_lossy(&output.stdout).trim().to_owned();
571    (!id.is_empty()).then_some(id)
572}
573
574fn run_refresh_command(command: &CommandSpec, executor: &impl CommandExecutor) -> Result<()> {
575    let output = executor.execute(command)?;
576    if output.status != 0 {
577        bail!(
578            "{} failed with status {}: {}",
579            command.purpose,
580            output.status,
581            String::from_utf8_lossy(&output.stderr).trim()
582        );
583    }
584    Ok(())
585}
586
587pub fn complete_manual_quota_refresh(
588    pending_generation: &mut Option<u64>,
589    completed_generation: u64,
590) -> bool {
591    if *pending_generation != Some(completed_generation) {
592        return false;
593    }
594    *pending_generation = None;
595    true
596}
597
598pub fn dashboard_worker_targets(controller: &Controller) -> Vec<WorkerPollTarget> {
599    controller
600        .state
601        .sessions
602        .values()
603        .filter(|session| session_target_is_pollable(session))
604        .filter_map(|session| {
605            let spec = match controller.reconnect_command(&session.id) {
606                Ok(spec) => spec,
607                Err(error) => {
608                    tracing::warn!(session_id = %session.id, "could not build worker poll target: {error:#}");
609                    return None;
610                }
611            };
612            Some(WorkerPollTarget {
613                session_id: session.id.clone(),
614                spec,
615                worker_recovery: match controller.worker_recovery_plan(&session.id) {
616                    Ok(plan) => Some(plan),
617                    Err(error) => {
618                        tracing::debug!(session_id = %session.id, "worker recovery target unavailable: {error:#}");
619                        None
620                    }
621                },
622                project_memory: match controller.project_memory_sync_target(&session.id) {
623                    Ok(target) => Some(target),
624                    Err(error) => {
625                        tracing::debug!(session_id = %session.id, "project memory target unavailable: {error:#}");
626                        None
627                    }
628                },
629            })
630        })
631        .collect()
632}
633
634pub fn dashboard_worker_targets_excluding(
635    controller: &Controller,
636    excluded_sessions: &std::collections::BTreeSet<String>,
637) -> Vec<WorkerPollTarget> {
638    let mut targets = dashboard_worker_targets(controller);
639    targets.retain(|target| !excluded_sessions.contains(&target.session_id));
640    targets
641}
642
643/// Sessions whose worker can answer credential requests right now. Sessions
644/// still provisioning or already disconnected would only produce connection
645/// errors, so they stay out.
646pub fn credential_sync_targets(controller: &Controller) -> Vec<CredentialSyncTarget> {
647    controller
648        .state
649        .sessions
650        .values()
651        .filter(|session| {
652            matches!(
653                session.state,
654                SessionState::Running | SessionState::Checkpointing
655            ) && session.target.is_some()
656        })
657        .filter_map(|session| {
658            let profile = controller.config.profiles.get(&session.last_profile)?;
659            let spec = match controller.reconnect_command(&session.id) {
660                Ok(spec) => spec,
661                Err(error) => {
662                    tracing::warn!(session_id = %session.id, "could not build credential sync target: {error:#}");
663                    return None;
664                }
665            };
666            let sync_github_token = target_syncs_github_token(session.target.as_ref());
667            Some(CredentialSyncTarget {
668                session_id: session.id.clone(),
669                profile_id: session.last_profile.clone(),
670                harness: profile.kind,
671                profile_home: profile.home.clone(),
672                sync_github_token,
673                spec,
674            })
675        })
676        .collect()
677}
678
679fn target_syncs_github_token(target: Option<&mj_core::state::TargetLocator>) -> bool {
680    target.is_some()
681        && !matches!(
682            target,
683            Some(mj_core::state::TargetLocator::LocalBare { .. })
684        )
685}
686
687/// One immediate sync and notice per session per cooldown, so a harness that
688/// repeats the same failed turn does not flood the UI.
689pub const IMMEDIATE_CREDENTIAL_SYNC_COOLDOWN: Duration = Duration::from_secs(5 * 60);
690
691#[derive(Debug, Clone, PartialEq, Eq)]
692struct PendingCredentialSync {
693    signal: CredentialSyncSignal,
694    profile_id: String,
695}
696
697/// Deduplicates the actor's sticky failure marker while retaining a newer
698/// failure until its session cooldown expires.
699#[derive(Debug, Default)]
700pub struct CredentialSyncSignalTracker {
701    handled_ordinals: std::collections::BTreeMap<String, u64>,
702    last_attempts: std::collections::BTreeMap<String, Instant>,
703    pending: std::collections::BTreeMap<String, PendingCredentialSync>,
704}
705
706impl CredentialSyncSignalTracker {
707    pub fn observe(&mut self, session_id: &str, profile_id: &str, signal: CredentialSyncSignal) {
708        if self
709            .handled_ordinals
710            .get(session_id)
711            .is_some_and(|handled| *handled >= signal.ordinal)
712        {
713            return;
714        }
715        let pending = PendingCredentialSync {
716            signal,
717            profile_id: profile_id.to_owned(),
718        };
719        match self.pending.entry(session_id.to_owned()) {
720            std::collections::btree_map::Entry::Vacant(entry) => {
721                entry.insert(pending);
722            }
723            std::collections::btree_map::Entry::Occupied(mut entry)
724                if entry.get().signal.ordinal <= pending.signal.ordinal =>
725            {
726                entry.insert(pending);
727            }
728            std::collections::btree_map::Entry::Occupied(_) => {}
729        }
730    }
731
732    fn drain_due(&mut self, now: Instant) -> Vec<(String, String, CredentialSyncReason)> {
733        let due = self
734            .pending
735            .keys()
736            .filter(|session_id| {
737                self.last_attempts.get(*session_id).is_none_or(|previous| {
738                    now.saturating_duration_since(*previous) >= IMMEDIATE_CREDENTIAL_SYNC_COOLDOWN
739                })
740            })
741            .cloned()
742            .collect::<Vec<_>>();
743        due.into_iter()
744            .map(|session_id| {
745                let pending = self
746                    .pending
747                    .remove(&session_id)
748                    .expect("due credential sync signal disappeared");
749                self.handled_ordinals
750                    .insert(session_id.clone(), pending.signal.ordinal);
751                self.last_attempts.insert(session_id.clone(), now);
752                (session_id, pending.profile_id, pending.signal.reason)
753            })
754            .collect()
755    }
756}
757
758pub fn schedule_due_credential_syncs(
759    tracker: &mut CredentialSyncSignalTracker,
760    credential_sync: &CredentialSyncHandle,
761    now: Instant,
762) {
763    for (session_id, profile_id, reason) in tracker.drain_due(now) {
764        credential_sync.sync_profile_now(
765            &profile_id,
766            Some(CredentialSyncCause { session_id, reason }),
767        );
768    }
769}
770
771/// Turns finished credential syncs into UI notices.
772///
773/// The periodic cycle revisits every profile, so a session that keeps failing
774/// the same way would post the same notice forever. The last failure message
775/// per key is remembered and only a changed one speaks up again. Keys are the
776/// profile for a whole-sync failure and the profile plus session for a
777/// per-session failure.
778#[derive(Debug, Default)]
779pub struct CredentialSyncNotices {
780    last_failures: std::collections::BTreeMap<(String, Option<String>), String>,
781}
782
783pub fn log_credential_sync_actions(result: &mj_core::credentials::CredentialSyncResult) {
784    let sessions = result.credential_sessions();
785    if sessions > 0 {
786        tracing::info!(
787            profile_id = %result.profile_id,
788            sessions,
789            "refreshed harness credentials"
790        );
791    }
792}
793
794/// The extra option a Claude profile has after an auth failure.
795///
796/// Claude Code cannot refresh its rotating login early, so a container copy
797/// can lose the single-use refresh race with the host. A setup token does not
798/// rotate, which takes the race away rather than retrying it.
799fn setup_token_advice(profile_id: &str, harness: Option<mj_core::config::HarnessKind>) -> String {
800    if harness == Some(mj_core::config::HarnessKind::Claude) {
801        format!(
802            ", or store a long-lived token with `mj login --profile {profile_id} --setup-token`"
803        )
804    } else {
805        String::new()
806    }
807}
808
809impl CredentialSyncNotices {
810    /// Healthy no-op cycles stay out of the UI; only actions, new failures, and
811    /// answers to an event-triggered reconciliation are worth a notice.
812    pub fn notice(
813        &mut self,
814        result: &mj_core::credentials::CredentialSyncResult,
815        harness: Option<mj_core::config::HarnessKind>,
816    ) -> Option<String> {
817        let advice = setup_token_advice(&result.profile_id, harness);
818        // Event-triggered syncs always speak: the upstream per-session
819        // cooldown, not this dedup, is what keeps them rare.
820        if let Some(trigger) = &result.trigger {
821            let session_id = &trigger.session_id;
822            let sync_failure = result.failure.as_deref().or_else(|| {
823                result.failures().find_map(|(failed_session, detail)| {
824                    (failed_session == session_id).then_some(detail)
825                })
826            });
827            if let Some(detail) = sync_failure {
828                return Some(match trigger.reason {
829                    CredentialSyncReason::AuthenticationFailure => format!(
830                        "Auth failure on profile {} (session {}); credential reconciliation failed: {detail}. Run `mj login --profile {}`{advice}.",
831                        result.profile_id,
832                        short_id(session_id),
833                        result.profile_id
834                    ),
835                    CredentialSyncReason::EmptyPromptResponse => format!(
836                        "Session {} returned no response; credential reconciliation for profile {} failed: {detail}. The failure is recorded in the transcript.",
837                        short_id(session_id),
838                        result.profile_id
839                    ),
840                });
841            }
842            // The first ~80 columns are all most people read before a notice
843            // scrolls off, so the profile leads and the advice trails.
844            return Some(match (trigger.reason, result.pushed_to(session_id)) {
845                (CredentialSyncReason::AuthenticationFailure, true) => format!(
846                    "Auth failure on profile {} (session {}); refreshed credentials were pushed. Retry the prompt, and if it repeats run `mj login --profile {}`{advice}.",
847                    result.profile_id,
848                    short_id(session_id),
849                    result.profile_id
850                ),
851                (CredentialSyncReason::AuthenticationFailure, false) => format!(
852                    "Auth failure on profile {} (session {}); nothing fresher to push. Run `mj login --profile {}`{advice}.",
853                    result.profile_id,
854                    short_id(session_id),
855                    result.profile_id
856                ),
857                (CredentialSyncReason::EmptyPromptResponse, true) => format!(
858                    "Session {} returned no response; fresher credentials from profile {} were pushed. Retry the prompt.",
859                    short_id(session_id),
860                    result.profile_id
861                ),
862                (CredentialSyncReason::EmptyPromptResponse, false) => format!(
863                    "Session {} returned no response; profile {} had no newer credentials to push. The failure is recorded in the transcript.",
864                    short_id(session_id),
865                    result.profile_id
866                ),
867            });
868        }
869
870        let mut failures = std::collections::BTreeMap::new();
871        if let Some(detail) = &result.failure {
872            failures.insert(
873                (result.profile_id.clone(), None),
874                format!(
875                    "Credential sync for profile {} failed: {detail}",
876                    result.profile_id
877                ),
878            );
879        }
880        for (session_id, detail) in result.failures() {
881            failures.insert(
882                (result.profile_id.clone(), Some(session_id.to_owned())),
883                format!(
884                    "Credential sync for profile {} (session {}) failed: {detail}",
885                    result.profile_id,
886                    short_id(session_id)
887                ),
888            );
889        }
890        // A key that stopped failing is forgotten silently, so the same failure
891        // after a clean cycle is reported again.
892        self.last_failures
893            .retain(|key, _| key.0 != result.profile_id || failures.contains_key(key));
894        let mut notice = None;
895        for (key, message) in failures {
896            if self.last_failures.get(&key) != Some(&message) {
897                notice.get_or_insert_with(|| message.clone());
898            }
899            self.last_failures.insert(key, message);
900        }
901        if notice.is_some() {
902            return notice;
903        }
904
905        let mut parts = Vec::new();
906        let skills = result.skills_sessions();
907        if skills > 0 {
908            parts.push(format!(
909                "Synced skills for profile {} to {skills} session(s).",
910                result.profile_id
911            ));
912        }
913        let github_pushed = result.github_token_pushed_sessions();
914        if github_pushed > 0 {
915            parts.push(format!(
916                "Synced the GitHub CLI token to {github_pushed} session(s)."
917            ));
918        }
919        let github_removed = result.github_token_removed_sessions();
920        if github_removed > 0 {
921            parts.push(format!(
922                "Removed the GitHub CLI token from {github_removed} session(s)."
923            ));
924        }
925        (!parts.is_empty()).then(|| parts.join(" "))
926    }
927}
928
929fn dashboard_resource_targets(controller: &Controller) -> Vec<ResourcePollTarget> {
930    controller
931        .state
932        .sessions
933        .values()
934        .filter(|session| session_target_is_pollable(session))
935        .filter_map(|session| {
936            match controller.resource_probe(&session.id) {
937                Ok(probe) => Some(ResourcePollTarget {
938                    session_id: session.id.clone(),
939                    probe,
940                }),
941                Err(error) => {
942                    tracing::warn!(session_id = %session.id, "could not build resource poll target: {error:#}");
943                    None
944                }
945            }
946        })
947        .collect()
948}
949
950/// `is_active` means visible on the active dashboard, not necessarily backed
951/// by a live target. A recoverable error stays visible so the user can resume
952/// its checkpoint, but its failed target must not keep reconnecting or being
953/// sampled. `Destroying` also stays visible, but its verified close has
954/// permanently handed the target to cleanup, even after a cleanup task fails.
955///
956/// `Provisioning` is excluded for the same reason `credential_sync_targets`
957/// excludes it, and for a sharper one: a session gets its `target` as soon as
958/// the target itself exists, which is *before* its worker binary has been
959/// copied into place. Polling that window means running `execve` on a file
960/// `cp` still holds open for writing, which fails with `ETXTBSY` and leaves
961/// the session recorded as unreachable. Provisioning connects to its own
962/// worker when it is ready and then marks the session `Running`, which is when
963/// there is something here to poll.
964pub fn session_target_is_pollable(session: &mj_core::state::SessionRecord) -> bool {
965    session.state.is_active()
966        && !matches!(
967            session.state,
968            SessionState::Error | SessionState::Provisioning | SessionState::Destroying
969        )
970        && session.target.is_some()
971}
972
973pub fn refresh_dashboard_poll_targets(
974    controller: &Controller,
975    worker_targets_tx: &tokio::sync::watch::Sender<Vec<WorkerPollTarget>>,
976    resource_targets_tx: &tokio::sync::watch::Sender<Vec<ResourcePollTarget>>,
977    credential_sync: &CredentialSyncHandle,
978    excluded_sessions: &std::collections::BTreeSet<String>,
979) {
980    let worker_targets = dashboard_worker_targets_excluding(controller, excluded_sessions);
981    worker_targets_tx.send_replace(worker_targets);
982    let mut resource_targets = dashboard_resource_targets(controller);
983    resource_targets.retain(|target| !excluded_sessions.contains(&target.session_id));
984    resource_targets_tx.send_replace(resource_targets);
985    let mut credential_targets = credential_sync_targets(controller);
986    credential_targets.retain(|target| !excluded_sessions.contains(&target.session_id));
987    credential_sync.set_targets(credential_targets);
988}
989
990pub fn spawn_aws_resource_options_resolution(
991    config: Config,
992    target_id: String,
993    updates: tokio::sync::mpsc::UnboundedSender<(
994        String,
995        std::result::Result<Vec<SessionResourceAllocation>, String>,
996    )>,
997    tracker: mj_client::operations::CriticalOperationTracker,
998) {
999    let cancelled = Arc::new(AtomicBool::new(false));
1000    let guard = tracker.begin_cancellable(
1001        format!("resolving resources for {target_id}"),
1002        cancelled.clone(),
1003    );
1004    let _task = tokio::task::spawn_blocking(move || {
1005        let controller = Controller {
1006            config,
1007            state: State::default(),
1008        };
1009        let result = controller
1010            .resolve_aws_resource_options(&target_id, &CancellableProcessExecutor::new(cancelled))
1011            .map_err(|error| format!("{error:#}"));
1012        if let Err(error) = updates.send((target_id.clone(), result)) {
1013            tracing::debug!(target_id, %error, "AWS resource options result dropped after dashboard shutdown");
1014        }
1015        drop(guard);
1016    });
1017}
1018
1019pub fn spawn_dashboard_resource_poller() -> (
1020    tokio::sync::watch::Sender<Vec<ResourcePollTarget>>,
1021    tokio::sync::mpsc::Sender<String>,
1022    tokio::sync::mpsc::Receiver<ResourcePollUpdate>,
1023) {
1024    let (targets_tx, mut targets_rx) =
1025        tokio::sync::watch::channel(Vec::<ResourcePollTarget>::new());
1026    let (triggers_tx, mut triggers_rx) = tokio::sync::mpsc::channel(64);
1027    let (updates_tx, updates_rx) = tokio::sync::mpsc::channel(64);
1028    tokio::spawn(async move {
1029        let mut targets = std::collections::BTreeMap::new();
1030        let mut last_started = std::collections::BTreeMap::new();
1031        let mut interval = tokio::time::interval(RESOURCE_POLL_INTERVAL);
1032        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1033        loop {
1034            tokio::select! {
1035                _ = interval.tick() => {
1036                    let due = targets.values().cloned().collect::<Vec<_>>();
1037                    for target in due {
1038                        schedule_resource_sample(target, &mut last_started, &updates_tx);
1039                    }
1040                }
1041                changed = targets_rx.changed() => {
1042                    if changed.is_err() {
1043                        tracing::debug!("resource poll target feed closed; stopping resource poller");
1044                        break;
1045                    }
1046                    targets = targets_rx
1047                        .borrow_and_update()
1048                        .iter()
1049                        .cloned()
1050                        .map(|target| (target.session_id.clone(), target))
1051                        .collect();
1052                    last_started.retain(|session_id, _| targets.contains_key(session_id));
1053                    let due = targets.values().cloned().collect::<Vec<_>>();
1054                    for target in due {
1055                        schedule_resource_sample(target, &mut last_started, &updates_tx);
1056                    }
1057                }
1058                session_id = triggers_rx.recv() => {
1059                    let Some(session_id) = session_id else {
1060                        break;
1061                    };
1062                    if let Some(target) = targets.get(&session_id).cloned() {
1063                        schedule_resource_sample(target, &mut last_started, &updates_tx);
1064                    }
1065                }
1066            }
1067        }
1068    });
1069    (targets_tx, triggers_tx, updates_rx)
1070}
1071
1072fn resource_sample_is_due(
1073    last_started: Option<&tokio::time::Instant>,
1074    now: tokio::time::Instant,
1075) -> bool {
1076    last_started.is_none_or(|started| now.duration_since(*started) >= RESOURCE_POLL_INTERVAL)
1077}
1078
1079fn schedule_resource_sample(
1080    target: ResourcePollTarget,
1081    last_started: &mut std::collections::BTreeMap<String, tokio::time::Instant>,
1082    updates: &tokio::sync::mpsc::Sender<ResourcePollUpdate>,
1083) {
1084    let now = tokio::time::Instant::now();
1085    if !resource_sample_is_due(last_started.get(&target.session_id), now) {
1086        return;
1087    }
1088    last_started.insert(target.session_id.clone(), now);
1089    let updates = updates.clone();
1090    tokio::spawn(async move {
1091        let usage = match tokio::time::timeout(
1092            RESOURCE_POLL_TIMEOUT,
1093            collect_session_resource_usage(&target.probe),
1094        )
1095        .await
1096        {
1097            Ok(Ok(usage)) => Some(usage),
1098            Ok(Err(error)) => {
1099                tracing::warn!(session_id = %target.session_id, "resource probe failed: {error:#}");
1100                None
1101            }
1102            Err(_) => {
1103                tracing::warn!(session_id = %target.session_id, "resource probe timed out");
1104                None
1105            }
1106        };
1107        let Some(usage) = usage else {
1108            return;
1109        };
1110        if let Err(error) = updates
1111            .send(ResourcePollUpdate {
1112                session_id: target.session_id.clone(),
1113                usage,
1114            })
1115            .await
1116        {
1117            tracing::debug!(session_id = %target.session_id, %error, "resource probe result dropped after dashboard shutdown");
1118        }
1119    });
1120}
1121
1122async fn collect_session_resource_usage(
1123    probe: &SessionResourceProbe,
1124) -> Result<SessionResourceUsage> {
1125    let memory = execute_resource_command(&probe.memory).await?;
1126    let disk = match &probe.disk {
1127        Some(command) => match execute_resource_command(command).await {
1128            Ok(output) => Some(output),
1129            Err(error) => {
1130                tracing::debug!(purpose = %command.purpose, "optional disk resource probe failed: {error:#}");
1131                None
1132            }
1133        },
1134        None => None,
1135    };
1136    crate::targets::parse_resource_usage(
1137        &memory.stdout,
1138        disk.as_ref().map(|output| output.stdout.as_slice()),
1139    )
1140}
1141
1142pub fn spawn_dashboard_capacity_poller() -> (
1143    tokio::sync::watch::Sender<Vec<DeploymentCapacityTarget>>,
1144    tokio::sync::mpsc::Sender<()>,
1145    tokio::sync::mpsc::Receiver<CapacityPollUpdate>,
1146) {
1147    spawn_capacity_poller_with(|target| async move {
1148        if let Some(error) = &target.probe_error {
1149            bail!("capacity probe is unavailable: {error}");
1150        }
1151        if target.local {
1152            return collect_local_capacity_with(collect_local_capacity)
1153                .await
1154                .map(Some);
1155        }
1156        tokio::time::timeout(RESOURCE_POLL_TIMEOUT, collect_capacity(&target))
1157            .await
1158            .context("capacity probe timed out")?
1159    })
1160}
1161
1162fn spawn_capacity_poller_with<F, Fut>(
1163    collect: F,
1164) -> (
1165    tokio::sync::watch::Sender<Vec<DeploymentCapacityTarget>>,
1166    tokio::sync::mpsc::Sender<()>,
1167    tokio::sync::mpsc::Receiver<CapacityPollUpdate>,
1168)
1169where
1170    F: Fn(DeploymentCapacityTarget) -> Fut + Send + Sync + 'static,
1171    Fut: Future<Output = Result<Option<DeploymentCapacityUsage>>> + Send + 'static,
1172{
1173    let (targets_tx, mut targets_rx) =
1174        tokio::sync::watch::channel(Vec::<DeploymentCapacityTarget>::new());
1175    let (updates_tx, updates_rx) = tokio::sync::mpsc::channel(64);
1176    let (triggers_tx, mut triggers_rx) = tokio::sync::mpsc::channel(1);
1177    tokio::spawn(async move {
1178        let mut targets = Vec::new();
1179        let collect = Arc::new(collect);
1180        let mut samples = CapacitySamples::default();
1181        let mut interval = tokio::time::interval_at(
1182            tokio::time::Instant::now() + CAPACITY_POLL_INTERVAL,
1183            CAPACITY_POLL_INTERVAL,
1184        );
1185        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1186        loop {
1187            tokio::select! {
1188                _ = updates_tx.closed() => break,
1189                _ = interval.tick() => {
1190                    samples.schedule(targets.iter().cloned(), &collect);
1191                }
1192                changed = targets_rx.changed() => {
1193                    if changed.is_err() {
1194                        tracing::debug!("capacity poll target feed closed; stopping capacity poller");
1195                        break;
1196                    }
1197                    let updated = targets_rx.borrow_and_update().clone();
1198                    samples.schedule(
1199                        updated.iter().filter(|target| !targets.contains(target)).cloned(),
1200                        &collect,
1201                    );
1202                    targets = updated;
1203                }
1204                trigger = triggers_rx.recv() => {
1205                    if trigger.is_none() {
1206                        break;
1207                    }
1208                    samples.schedule(targets.iter().cloned(), &collect);
1209                }
1210                completed = samples.tasks.join_next_with_id(), if !samples.tasks.is_empty() => {
1211                    let (id, result) = match completed.expect("capacity task exists") {
1212                        Ok((id, result)) => (id, result.map_err(|error| format!("{error:#}"))),
1213                        Err(error) => (error.id(), Err(format!("capacity probe task failed: {error}"))),
1214                    };
1215                    let sampled = samples.targets.remove(&id).expect("capacity task retains its target");
1216                    if let Err(error) = &result {
1217                        tracing::warn!(target_id = %sampled.id, %error, "capacity probe failed");
1218                    }
1219                    let Ok(permit) = updates_tx.reserve().await else {
1220                        break;
1221                    };
1222                    // A watch update and completion can become ready together.
1223                    // Revalidate after backpressure, with no await between
1224                    // reading the latest target and publishing the result.
1225                    let current = targets_rx.borrow().iter().find(|target| target.id == sampled.id).cloned();
1226                    let Some(current) = current else {
1227                        continue;
1228                    };
1229                    if current != sampled {
1230                        // A changed target gets one follow-up; its old result
1231                        // must not overwrite a reading for the new configuration.
1232                        // If changed() is still pending, that arm will start it.
1233                        if targets.contains(&current) {
1234                            samples.schedule(std::iter::once(current), &collect);
1235                        }
1236                        continue;
1237                    }
1238                    permit.send(CapacityPollUpdate {
1239                        target_id: sampled.id,
1240                        result,
1241                        sampled_at_epoch_seconds: epoch_seconds(),
1242                    });
1243                }
1244            }
1245        }
1246        samples.tasks.abort_all();
1247        while let Some(completed) = samples.tasks.join_next().await {
1248            match completed {
1249                Ok(Err(error)) => tracing::warn!(%error, "capacity probe failed during shutdown"),
1250                Err(error) if !error.is_cancelled() => {
1251                    tracing::error!(%error, "capacity probe task failed during shutdown");
1252                }
1253                _ => {}
1254            }
1255        }
1256    });
1257    (targets_tx, triggers_tx, updates_rx)
1258}
1259
1260#[derive(Default)]
1261struct CapacitySamples {
1262    tasks: tokio::task::JoinSet<Result<Option<DeploymentCapacityUsage>>>,
1263    targets: std::collections::HashMap<tokio::task::Id, DeploymentCapacityTarget>,
1264}
1265
1266impl CapacitySamples {
1267    fn schedule<F, Fut>(
1268        &mut self,
1269        targets: impl IntoIterator<Item = DeploymentCapacityTarget>,
1270        collect: &Arc<F>,
1271    ) where
1272        F: Fn(DeploymentCapacityTarget) -> Fut + Send + Sync + 'static,
1273        Fut: Future<Output = Result<Option<DeploymentCapacityUsage>>> + Send + 'static,
1274    {
1275        for target in targets {
1276            if self.targets.values().any(|running| running.id == target.id) {
1277                continue;
1278            }
1279            let collect = collect.clone();
1280            let sampled = target.clone();
1281            let task = self.tasks.spawn(async move {
1282                let started = Instant::now();
1283                let target_id = sampled.id.clone();
1284                let result = collect(sampled).await;
1285                tracing::debug!(
1286                    %target_id,
1287                    elapsed_ms = started.elapsed().as_millis() as u64,
1288                    success = result.is_ok(),
1289                    "capacity probe completed",
1290                );
1291                result
1292            });
1293            self.targets.insert(task.id(), target);
1294        }
1295    }
1296}
1297
1298async fn collect_capacity(
1299    target: &DeploymentCapacityTarget,
1300) -> Result<Option<DeploymentCapacityUsage>> {
1301    if let Some(error) = &target.probe_error {
1302        anyhow::bail!("capacity probe is unavailable: {error}");
1303    }
1304    match target.kind {
1305        DeploymentCapacityKind::Host => {
1306            let mut last_error = None;
1307            for command in &target.probes {
1308                match execute_resource_command(command).await {
1309                    Ok(output) => {
1310                        return crate::targets::parse_host_capacity(&output.stdout).map(Some);
1311                    }
1312                    Err(error) => last_error = Some(error),
1313                }
1314            }
1315            Err(last_error.unwrap_or_else(|| anyhow::anyhow!("no host probe is configured")))
1316        }
1317        DeploymentCapacityKind::AwsFleet => {
1318            if target.probes.is_empty() {
1319                return Ok(None);
1320            }
1321            let mut tasks = tokio::task::JoinSet::new();
1322            for command in target.probes.clone() {
1323                tasks.spawn(async move {
1324                    let output = execute_resource_command(&command).await?;
1325                    crate::targets::parse_aws_allocated_capacity(&output.stdout)
1326                });
1327            }
1328            let mut usages = Vec::new();
1329            while let Some(result) = tasks.join_next().await {
1330                usages.push(result.context("join EC2 capacity probe")??);
1331            }
1332            aggregate_aws_capacity(&usages).map(Some)
1333        }
1334    }
1335}
1336
1337pub fn aggregate_aws_capacity(
1338    usages: &[DeploymentCapacityUsage],
1339) -> Result<DeploymentCapacityUsage> {
1340    let mut total = DeploymentCapacityUsage {
1341        cpu_percent: None,
1342        memory_used_bytes: 0,
1343        memory_total_bytes: 0,
1344        logical_cores: 0,
1345        disk_total_bytes: Some(0),
1346    };
1347    for usage in usages {
1348        total.memory_total_bytes = total
1349            .memory_total_bytes
1350            .checked_add(usage.memory_total_bytes)
1351            .context("aggregate EC2 RAM overflow")?;
1352        total.logical_cores = total
1353            .logical_cores
1354            .checked_add(usage.logical_cores)
1355            .context("aggregate EC2 core count overflow")?;
1356        total.disk_total_bytes = Some(
1357            total
1358                .disk_total_bytes
1359                .unwrap_or(0)
1360                .checked_add(usage.disk_total_bytes.unwrap_or(0))
1361                .context("aggregate EC2 disk overflow")?,
1362        );
1363    }
1364    Ok(total)
1365}
1366
1367fn collect_local_capacity() -> Result<DeploymentCapacityUsage> {
1368    let mut system = sysinfo::System::new();
1369    system.refresh_memory();
1370    // Frequency is unused and scans every core in parallel on each refresh.
1371    system.refresh_cpu_usage();
1372    std::thread::sleep(sysinfo::MINIMUM_CPU_UPDATE_INTERVAL);
1373    system.refresh_cpu_usage();
1374    Ok(DeploymentCapacityUsage {
1375        cpu_percent: Some(system.global_cpu_usage().round().clamp(0.0, 100.0) as u8),
1376        memory_used_bytes: system
1377            .total_memory()
1378            .saturating_sub(system.available_memory()),
1379        memory_total_bytes: system.total_memory(),
1380        logical_cores: system
1381            .cpus()
1382            .len()
1383            .try_into()
1384            .context("logical CPU count overflow")?,
1385        disk_total_bytes: None,
1386    })
1387}
1388
1389async fn collect_local_capacity_with(
1390    collect: impl FnOnce() -> Result<DeploymentCapacityUsage> + Send + 'static,
1391) -> Result<DeploymentCapacityUsage> {
1392    // A blocking sample cannot be cancelled. Keep its slot occupied
1393    // until it exits, even when the deadline has elapsed.
1394    let mut sample = tokio::task::spawn_blocking(move || {
1395        let result = collect();
1396        // Shutdown can drop the awaiting future before this thread exits.
1397        if let Err(error) = &result {
1398            tracing::warn!(%error, "local capacity sample failed");
1399        }
1400        result
1401    });
1402    match tokio::time::timeout(RESOURCE_POLL_TIMEOUT, &mut sample).await {
1403        Ok(result) => result.context("join local capacity probe")?,
1404        Err(_) => {
1405            match sample.await {
1406                Ok(Ok(_)) => {}
1407                Ok(Err(error)) => tracing::warn!(%error, "timed-out capacity probe failed"),
1408                Err(error) => tracing::error!(%error, "timed-out capacity probe task failed"),
1409            }
1410            bail!("capacity probe timed out")
1411        }
1412    }
1413}
1414
1415async fn execute_resource_command(command: &CommandSpec) -> Result<CommandOutput> {
1416    let mut process = tokio::process::Command::new(&command.program);
1417    process
1418        .args(&command.args)
1419        .envs(&command.env)
1420        .stdin(std::process::Stdio::null())
1421        .stdout(std::process::Stdio::piped())
1422        .stderr(std::process::Stdio::piped())
1423        .kill_on_drop(true);
1424    let child = process
1425        .spawn()
1426        .with_context(|| format!("start {} for {}", command.program, command.purpose))?;
1427    // stdin is null; nothing writes while output drains, so this cannot hit
1428    // the write-then-wait deadlock the disallowed_methods lint guards against.
1429    #[allow(clippy::disallowed_methods)]
1430    let output = child
1431        .wait_with_output()
1432        .await
1433        .with_context(|| format!("wait for {}", command.purpose))?;
1434    let command_output = CommandOutput {
1435        status: output.status.code().unwrap_or(-1),
1436        stdout: output.stdout,
1437        stderr: output.stderr,
1438    };
1439    if command_output.status != 0 {
1440        bail!(
1441            "{} failed with status {}: {}",
1442            command.purpose,
1443            command_output.status,
1444            String::from_utf8_lossy(&command_output.stderr).trim()
1445        );
1446    }
1447    Ok(command_output)
1448}
1449
1450pub struct RemoteDashboardWorkerPoller {
1451    pub targets: tokio::sync::watch::Sender<Vec<WorkerPollTarget>>,
1452    pub updates: SessionManagerUpdates,
1453    pub control: SessionManagerControl,
1454    pub shutdown: SessionManagerShutdown,
1455    pub state: tokio::sync::watch::Receiver<RuntimeStateUpdate>,
1456    /// Reviews the daemon is running for this workspace's sessions.
1457    pub reviews: tokio::sync::watch::Receiver<Vec<crate::review_host::RuntimeReviewView>>,
1458    /// Background events the daemon wants reported once, oldest first.
1459    pub notices: tokio::sync::watch::Receiver<Vec<daemon::RuntimeNotice>>,
1460    pub config: tokio::sync::watch::Receiver<mj_core::config::Config>,
1461}
1462
1463/// Records and lifecycle ownership must reach the surface in the same frame.
1464#[derive(Debug, Clone, Default)]
1465pub struct RuntimeStateUpdate {
1466    pub workspace_names: std::collections::BTreeMap<String, String>,
1467    pub revision: u64,
1468    pub records: Vec<SessionRecord>,
1469    pub lifecycles: Vec<daemon::RuntimeLifecycleView>,
1470    pub moves: Vec<mj_core::state::MoveOperation>,
1471}
1472
1473/// What a session looked like the last time a view was published for it.
1474///
1475/// The poller compares this before reading anything, so a session that has not
1476/// moved costs one comparison rather than a full transcript load. Nothing here
1477/// grows with the transcript: the projection is identified by its ordinal and
1478/// digest, and the operational state is bounded by the relay's own command and
1479/// configuration surface.
1480#[derive(Debug, Clone, PartialEq)]
1481struct PublishedView {
1482    projection_ordinal: u64,
1483    projection_digest: String,
1484    operational: Option<mj_core::relay::RelayOperationalState>,
1485    connected: bool,
1486    error: Option<String>,
1487}
1488
1489impl PublishedView {
1490    fn of(runtime: &crate::daemon::RuntimeSessionView) -> Self {
1491        Self {
1492            projection_ordinal: runtime.projection_ordinal,
1493            projection_digest: runtime.projection_digest.clone(),
1494            operational: runtime.operational.clone(),
1495            connected: runtime.connected,
1496            error: runtime.error.as_ref().map(|error| format!("{error:?}")),
1497        }
1498    }
1499
1500    fn matches(&self, runtime: &crate::daemon::RuntimeSessionView) -> bool {
1501        *self == Self::of(runtime)
1502    }
1503}
1504
1505const PROJECTION_CONVERGENCE_RETRIES: u8 = 20;
1506const PROJECTION_CONVERGENCE_RETRY_DELAY: Duration = Duration::from_millis(50);
1507
1508#[derive(Debug, Clone, PartialEq, Eq)]
1509struct ProjectionMismatch {
1510    published_ordinal: u64,
1511    published_digest: String,
1512    durable_ordinal: u64,
1513    durable_digest: String,
1514}
1515
1516#[derive(Default)]
1517struct ProjectionConvergence {
1518    attempts: std::collections::BTreeMap<String, (ProjectionMismatch, u8)>,
1519}
1520
1521impl ProjectionConvergence {
1522    fn converged(&mut self, session_id: &str) {
1523        self.attempts.remove(session_id);
1524    }
1525
1526    /// Give a lifecycle rollback and the daemon's cached relay view a bounded
1527    /// window to converge. Repeating the same mismatch eventually reports the
1528    /// integrity failure instead of hiding it indefinitely.
1529    fn should_retry(&mut self, session_id: &str, mismatch: ProjectionMismatch) -> bool {
1530        let entry = self
1531            .attempts
1532            .entry(session_id.to_owned())
1533            .or_insert_with(|| (mismatch.clone(), 0));
1534        if entry.0 != mismatch {
1535            *entry = (mismatch, 0);
1536        }
1537        entry.1 = entry.1.saturating_add(1);
1538        entry.1 <= PROJECTION_CONVERGENCE_RETRIES
1539    }
1540}
1541
1542/// Read-only updates shared by the dashboard and workspace preview. A snapshot
1543/// precedes its session views, so consumers can establish membership first.
1544pub enum RuntimeFeedUpdate {
1545    Snapshot(Box<daemon::RuntimeSnapshot>),
1546    Session {
1547        session_id: String,
1548        view: Box<ManagedSessionView>,
1549    },
1550    Error(String),
1551}
1552
1553/// Dropping a subscription cancels even a pending daemon long poll. The task
1554/// owns no writer or relay connection; blocking projection reads are bounded.
1555pub struct RuntimeFeed {
1556    pub updates: tokio::sync::mpsc::Receiver<RuntimeFeedUpdate>,
1557    task: tokio::task::JoinHandle<()>,
1558}
1559
1560impl Drop for RuntimeFeed {
1561    fn drop(&mut self) {
1562        self.task.abort();
1563    }
1564}
1565
1566type StoredProjection = Option<(MaterializedSession, mj_core::state::ProjectionWindow)>;
1567
1568async fn load_runtime_projection(session_id: String) -> Result<StoredProjection> {
1569    static READERS: std::sync::LazyLock<Arc<tokio::sync::Semaphore>> =
1570        std::sync::LazyLock::new(|| Arc::new(tokio::sync::Semaphore::new(4)));
1571    let permit = Arc::clone(&READERS)
1572        .acquire_owned()
1573        .await
1574        .context("projection readers stopped")?;
1575    tokio::task::spawn_blocking(move || {
1576        let _permit = permit;
1577        let result = crate::database::load_materialized_projection_tail(
1578            &session_id,
1579            crate::database::PROJECTION_TAIL_ITEMS,
1580        );
1581        // A blocking SQLite read can outlive cancellation of its subscriber.
1582        if let Err(error) = &result {
1583            tracing::warn!(%session_id, %error, "could not load runtime projection");
1584        }
1585        result
1586    })
1587    .await
1588    .context("projection load task failed")?
1589}
1590
1591fn spawn_runtime_feed_with<P, PF, L, LF>(workspace_id: String, poll: P, load: L) -> RuntimeFeed
1592where
1593    P: Fn(String, u64) -> PF + Send + 'static,
1594    PF: Future<Output = Result<daemon::RuntimeSnapshot>> + Send,
1595    L: Fn(String) -> LF + Clone + Send + 'static,
1596    LF: Future<Output = Result<StoredProjection>> + Send + 'static,
1597{
1598    let (tx, updates) = tokio::sync::mpsc::channel(32);
1599    let task = tokio::spawn(async move {
1600        let result = run_runtime_feed(workspace_id, poll, load, &tx).await;
1601        if let Err(error) = result {
1602            let message = format!("Runtime feed stopped: {error:#}");
1603            tracing::error!(%message);
1604            let _ = tx.send(RuntimeFeedUpdate::Error(message)).await;
1605        }
1606    });
1607    RuntimeFeed { updates, task }
1608}
1609
1610async fn run_runtime_feed<P, PF, L, LF>(
1611    workspace_id: String,
1612    poll: P,
1613    load: L,
1614    tx: &tokio::sync::mpsc::Sender<RuntimeFeedUpdate>,
1615) -> Result<()>
1616where
1617    P: Fn(String, u64) -> PF,
1618    PF: Future<Output = Result<daemon::RuntimeSnapshot>>,
1619    L: Fn(String) -> LF + Clone + Send + 'static,
1620    LF: Future<Output = Result<StoredProjection>> + Send + 'static,
1621{
1622    let mut revision = 0;
1623    let mut convergence = ProjectionConvergence::default();
1624    let mut published = std::collections::BTreeMap::<String, PublishedView>::new();
1625    loop {
1626        let mut snapshot = match poll(workspace_id.clone(), revision).await {
1627            Ok(snapshot) => snapshot,
1628            Err(error) => {
1629                if tx
1630                    .send(RuntimeFeedUpdate::Error(format!(
1631                        "Could not refresh sessions: {error:#}"
1632                    )))
1633                    .await
1634                    .is_err()
1635                {
1636                    return Ok(());
1637                }
1638                tokio::time::sleep(Duration::from_millis(250)).await;
1639                continue;
1640            }
1641        };
1642        let snapshot_revision = snapshot.revision;
1643        let sessions = std::mem::take(&mut snapshot.sessions);
1644        published.retain(|id, _| sessions.iter().any(|session| &session.session_id == id));
1645        convergence
1646            .attempts
1647            .retain(|id, _| sessions.iter().any(|session| &session.session_id == id));
1648        if tx
1649            .send(RuntimeFeedUpdate::Snapshot(Box::new(snapshot)))
1650            .await
1651            .is_err()
1652        {
1653            return Ok(());
1654        }
1655        let mut pending = sessions
1656            .into_iter()
1657            .filter(|runtime| {
1658                !published
1659                    .get(&runtime.session_id)
1660                    .is_some_and(|last| last.matches(runtime))
1661            })
1662            .collect::<std::collections::VecDeque<_>>();
1663        let mut tasks = tokio::task::JoinSet::new();
1664        let mut retry = false;
1665        while !pending.is_empty() || !tasks.is_empty() {
1666            // Independent session reads overlap, without flooding SQLite or
1667            // leaving an unbounded number of blocking reads after cancellation.
1668            while tasks.len() < 4 {
1669                let Some(runtime) = pending.pop_front() else {
1670                    break;
1671                };
1672                let load = load.clone();
1673                tasks.spawn(async move {
1674                    let stored = if runtime.operational.is_some() {
1675                        load(runtime.session_id.clone()).await
1676                    } else {
1677                        Ok(None)
1678                    };
1679                    (runtime, stored)
1680                });
1681            }
1682            let Some(result) = tasks.join_next().await else {
1683                break;
1684            };
1685            let (runtime, stored) = result.context("join runtime projection reader")?;
1686            let session_id = runtime.session_id.clone();
1687            let fingerprint = PublishedView::of(&runtime);
1688            let Some(view) = runtime_projection_view(runtime, stored, &mut convergence) else {
1689                retry = true;
1690                continue;
1691            };
1692            if view.snapshot.is_some() {
1693                published.insert(session_id.clone(), fingerprint);
1694            } else {
1695                published.remove(&session_id);
1696            }
1697            if tx
1698                .send(RuntimeFeedUpdate::Session {
1699                    session_id,
1700                    view: Box::new(view),
1701                })
1702                .await
1703                .is_err()
1704            {
1705                return Ok(());
1706            }
1707        }
1708        if retry {
1709            tokio::time::sleep(PROJECTION_CONVERGENCE_RETRY_DELAY).await;
1710        } else {
1711            revision = revision.max(snapshot_revision);
1712        }
1713    }
1714}
1715
1716fn runtime_projection_view(
1717    runtime: daemon::RuntimeSessionView,
1718    stored: Result<StoredProjection>,
1719    convergence: &mut ProjectionConvergence,
1720) -> Option<ManagedSessionView> {
1721    let Some(operational) = runtime.operational else {
1722        return Some(ManagedSessionView {
1723            snapshot: None,
1724            connected: runtime.connected,
1725            error: runtime.error,
1726        });
1727    };
1728    let detail = match stored {
1729        Ok(Some((materialized, window)))
1730            if materialized.applied_event_ordinal > runtime.projection_ordinal
1731                || (materialized.applied_event_ordinal == runtime.projection_ordinal
1732                    && materialized.applied_event_digest == runtime.projection_digest) =>
1733        {
1734            convergence.converged(&runtime.session_id);
1735            return Some(ManagedSessionView {
1736                snapshot: Some(ManagedSessionSnapshot {
1737                    materialized,
1738                    window,
1739                    operational,
1740                    latest_credential_sync_signal: runtime.latest_credential_sync_signal,
1741                    worker_build: None,
1742                    subagent_requests: Vec::new(),
1743                    subagent_results: Vec::new(),
1744                }),
1745                connected: runtime.connected,
1746                error: runtime.error,
1747            });
1748        }
1749        Ok(Some((materialized, _))) => {
1750            let mismatch = ProjectionMismatch {
1751                published_ordinal: runtime.projection_ordinal,
1752                published_digest: runtime.projection_digest,
1753                durable_ordinal: materialized.applied_event_ordinal,
1754                durable_digest: materialized.applied_event_digest.clone(),
1755            };
1756            if convergence.should_retry(&runtime.session_id, mismatch) {
1757                return None;
1758            }
1759            if materialized.applied_event_ordinal < runtime.projection_ordinal {
1760                format!(
1761                    "daemon published projection {} but SQLite contains only {} after a bounded convergence retry",
1762                    runtime.projection_ordinal, materialized.applied_event_ordinal
1763                )
1764            } else {
1765                format!(
1766                    "daemon and SQLite projection digests differ at ordinal {} after a bounded convergence retry",
1767                    runtime.projection_ordinal
1768                )
1769            }
1770        }
1771        Ok(None) => "daemon published a session with no durable projection".into(),
1772        Err(error) => format!("load daemon-owned projection: {error:#}"),
1773    };
1774    Some(ManagedSessionView {
1775        snapshot: None,
1776        connected: false,
1777        error: Some(ViewError::ProjectionIntegrity(detail)),
1778    })
1779}
1780
1781pub fn spawn_remote_dashboard_worker_poller(
1782    workspace_id: String,
1783) -> Result<RemoteDashboardWorkerPoller> {
1784    let channels = spawn_remote_session_manager()?;
1785    let crate::session_manager::RemoteSessionManagerChannels {
1786        targets,
1787        control,
1788        updates,
1789        shutdown,
1790        publisher,
1791        mut requests,
1792    } = channels;
1793    let (state_tx, state_rx) = tokio::sync::watch::channel(RuntimeStateUpdate::default());
1794    let (reviews_tx, reviews_rx) = tokio::sync::watch::channel(Vec::new());
1795    let (notices_tx, notices_rx) = tokio::sync::watch::channel(Vec::new());
1796    let (config_tx, config_rx) = tokio::sync::watch::channel(mj_core::config::Config::default());
1797    tokio::spawn(async move {
1798        let mut feed = spawn_runtime_feed_with(
1799            workspace_id,
1800            |workspace, revision| poll_daemon_runtime(workspace, revision, true),
1801            load_runtime_projection,
1802        );
1803        let mut request_order = crate::session_manager::SessionRequestOrder::new();
1804        loop {
1805            tokio::select! {
1806                request = requests.recv() => {
1807                    let Some(request) = request else { return; };
1808                    request_order.dispatch(request, forward_remote_session_request);
1809                }
1810                update = feed.updates.recv() => {
1811                    match update {
1812                        Some(RuntimeFeedUpdate::Snapshot(snapshot)) => {
1813                            config_tx.send_if_modified(|config| {
1814                                if *config == snapshot.config { false }
1815                                else { *config = snapshot.config.clone(); true }
1816                            });
1817                            state_tx.send_replace(RuntimeStateUpdate {
1818                                workspace_names: snapshot.workspace_names,
1819                                revision: snapshot.revision,
1820                                records: snapshot.records,
1821                                lifecycles: snapshot.lifecycles,
1822                                moves: snapshot.moves,
1823                            });
1824                            reviews_tx.send_replace(snapshot.reviews);
1825                            notices_tx.send_replace(snapshot.notices);
1826                        }
1827                        Some(RuntimeFeedUpdate::Session { session_id, view }) => {
1828                            if publisher.publish(session_id, *view).await.is_err() { return; }
1829                        }
1830                        Some(RuntimeFeedUpdate::Error(error)) => {
1831                            tracing::warn!(%error, "could not refresh sessions from controller daemon");
1832                        }
1833                        None => return,
1834                    }
1835                }
1836            }
1837        }
1838    });
1839    Ok(RemoteDashboardWorkerPoller {
1840        targets,
1841        updates,
1842        control,
1843        shutdown,
1844        state: state_rx,
1845        reviews: reviews_rx,
1846        notices: notices_rx,
1847        config: config_rx,
1848    })
1849}
1850
1851async fn poll_daemon_runtime(
1852    workspace_id: String,
1853    after_revision: u64,
1854    all_workspaces: bool,
1855) -> Result<daemon::RuntimeSnapshot> {
1856    let mut daemon = mj_client::daemon::connect_existing().await?;
1857    daemon
1858        .runtime_snapshot(workspace_id, after_revision, all_workspaces)
1859        .await
1860}
1861
1862async fn forward_remote_session_request(request: RemoteSessionRequest) {
1863    match request {
1864        RemoteSessionRequest::Submit {
1865            session_id,
1866            command_id,
1867            command,
1868            admission,
1869            reply,
1870        } => {
1871            if admission.is_some() {
1872                let _ = reply.send(Err(
1873                    "review delivery admissions cannot cross the daemon request bridge".into(),
1874                ));
1875                return;
1876            }
1877            let result = async {
1878                mj_client::daemon::connect_existing()
1879                    .await?
1880                    .submit_session_command(session_id, command_id, command, None)
1881                    .await
1882            }
1883            .await
1884            .map_err(|error| format!("{error:#}"));
1885            let _ = reply.send(result);
1886        }
1887        RemoteSessionRequest::Sync { session_id, reply } => {
1888            let result = async {
1889                mj_client::daemon::connect_existing()
1890                    .await?
1891                    .sync_session(session_id)
1892                    .await
1893            }
1894            .await
1895            .map_err(|error| format!("{error:#}"));
1896            let _ = reply.send(result);
1897        }
1898        RemoteSessionRequest::RespondElicitation {
1899            session_id,
1900            elicitation_id,
1901            response,
1902            reply,
1903        } => {
1904            let result = async {
1905                mj_client::daemon::connect_existing()
1906                    .await?
1907                    .respond_elicitation(session_id, elicitation_id, response)
1908                    .await
1909            }
1910            .await
1911            .map_err(|error| format!("{error:#}"));
1912            let _ = reply.send(result);
1913        }
1914        RemoteSessionRequest::StopBackgroundTask {
1915            session_id,
1916            background_task_id,
1917            reply,
1918        } => {
1919            let result = async {
1920                mj_client::daemon::connect_existing()
1921                    .await?
1922                    .stop_background_task(session_id, background_task_id)
1923                    .await
1924            }
1925            .await
1926            .map_err(|error| format!("{error:#}"));
1927            let _ = reply.send(result);
1928        }
1929        RemoteSessionRequest::Reviewer {
1930            session_id,
1931            role,
1932            action,
1933            mut reply,
1934        } => {
1935            let result = tokio::select! {
1936                _ = reply.closed() => return,
1937                result = async {
1938                    mj_client::daemon::connect_existing()
1939                        .await?
1940                        .reviewer_action(session_id, role, action)
1941                        .await
1942                } => result,
1943            }
1944            .map_err(|error| format!("{error:#}"));
1945            let _ = reply.send(result);
1946        }
1947    }
1948}
1949
1950pub fn queued_prompt_projection(
1951    session: &MaterializedSession,
1952) -> Vec<mj_core::relay::QueuedPrompt> {
1953    queued_prompt_entries(&session.queued_prompts)
1954}
1955
1956fn queued_prompt_entries(
1957    prompts: &[mj_core::state::MaterializedQueuedPrompt],
1958) -> Vec<mj_core::relay::QueuedPrompt> {
1959    prompts
1960        .iter()
1961        .map(|prompt| mj_core::relay::QueuedPrompt {
1962            id: prompt.command_id.clone(),
1963            text: mj_core::transcript::materialized_content_text(&prompt.content),
1964            attachments: Vec::new(),
1965            created_at_ms: prompt.queued_at_ms,
1966        })
1967        .collect()
1968}
1969
1970pub enum LifecycleSuccess {
1971    Created,
1972    Resumed {
1973        profile_id: String,
1974        target_id: String,
1975    },
1976    Moved(mj_core::state::MoveOutcome),
1977    Closed,
1978    ForceStopped,
1979    DestroyedStopped,
1980    ForceDestroyed,
1981}
1982
1983pub struct LifecycleUpdate {
1984    pub session_id: String,
1985    pub result: std::result::Result<LifecycleSuccess, String>,
1986    pub deferred_cleanup: bool,
1987}
1988
1989/// Whether a close stopped partway and left the record mid-close with its
1990/// target still present. Such a record cannot be closed again from the start:
1991/// its worker is gone, so only recovery can finish it.
1992pub fn is_interrupted_close(session: &SessionRecord) -> bool {
1993    matches!(
1994        session.state,
1995        SessionState::Closing | SessionState::Destroying
1996    ) && session.target.is_some()
1997}
1998
1999pub fn interrupted_close_session_ids(controller: &Controller) -> Vec<String> {
2000    controller
2001        .state
2002        .sessions
2003        .values()
2004        .filter(|session| is_interrupted_close(session))
2005        .map(|session| session.id.clone())
2006        .collect()
2007}
2008
2009pub fn spawn_interrupted_close_recovery(
2010    session_id: String,
2011    session_manager: SessionManagerControl,
2012    recovery_observer: crate::recovery_gate::RecoveryObserver,
2013    cancelled: Arc<AtomicBool>,
2014    updates: tokio::sync::mpsc::UnboundedSender<LifecycleUpdate>,
2015    tracker: Option<mj_client::operations::CriticalOperationTracker>,
2016) -> tokio::task::JoinHandle<()> {
2017    let guard = tracker.map(|tracker| {
2018        tracker.begin_cancellable(
2019            format!(
2020                "recovering session {}",
2021                mj_core::state::short_id(&session_id)
2022            ),
2023            cancelled.clone(),
2024        )
2025    });
2026    let runtime = tokio::runtime::Handle::current();
2027    tokio::spawn(async move {
2028        let operation_session_id = session_id.clone();
2029        let joined = tokio::task::spawn_blocking(move || {
2030            (|| -> Result<bool> {
2031                let _recovery_reservation = reserve_recovery_or_cancel(
2032                    &recovery_observer,
2033                    &operation_session_id,
2034                    &cancelled,
2035                )?;
2036                let mut controller = Controller::load()?;
2037                let executor = CancellableProcessExecutor::new(cancelled);
2038                runtime.block_on(controller.recover_interrupted_close_managed(
2039                    &operation_session_id,
2040                    &executor,
2041                    &session_manager,
2042                ))
2043            })()
2044            .map_err(|error| format!("{error:#}"))
2045        })
2046        .await;
2047        let (result, deferred_cleanup) = match joined {
2048            Ok(Ok(deferred_cleanup)) => (Ok(LifecycleSuccess::Closed), deferred_cleanup),
2049            Ok(Err(error)) => (Err(error), false),
2050            Err(error) => (
2051                Err(format!("interrupted close recovery task failed: {error}")),
2052                false,
2053            ),
2054        };
2055        if let Err(error) = updates.send(LifecycleUpdate {
2056            session_id: session_id.clone(),
2057            result,
2058            deferred_cleanup,
2059        }) {
2060            tracing::debug!(%session_id, %error, "interrupted close result dropped after dashboard shutdown");
2061        }
2062        drop(guard);
2063    })
2064}
2065
2066pub fn reserve_recovery_or_cancel(
2067    observer: &crate::recovery_gate::RecoveryObserver,
2068    session_id: &str,
2069    cancelled: &AtomicBool,
2070) -> Result<crate::recovery_gate::RecoveryReservation> {
2071    let reservation = observer.reserve(session_id);
2072    // The reservation stops the next copy; cancelling preempts the one already
2073    // running so a lifecycle operation never queues behind a long or wedged
2074    // copy.
2075    observer.cancel_busy(session_id);
2076    while observer.is_busy(session_id) {
2077        if cancelled.load(Ordering::Acquire) {
2078            bail!("operation cancelled while waiting for recovery copy");
2079        }
2080        std::thread::sleep(Duration::from_millis(25));
2081    }
2082    Ok(reservation)
2083}
2084
2085pub fn project_worker_title(
2086    controller: &mut Controller,
2087    update: &WorkerPollUpdate,
2088) -> Option<Option<String>> {
2089    let snapshot = update.view.snapshot.as_ref()?;
2090    let session = controller.state.sessions.get_mut(&update.session_id)?;
2091    let title = snapshot.resolved_title();
2092    if session.acp_session_title == title {
2093        return None;
2094    }
2095    session.acp_session_title = title.clone();
2096    Some(title)
2097}
2098
2099pub fn apply_worker_record_update(controller: &mut Controller, update: &WorkerPollUpdate) {
2100    let Some(title) = project_worker_title(controller, update) else {
2101        return;
2102    };
2103    let session_id = update.session_id.clone();
2104    tokio::spawn(async move {
2105        let result = tokio::task::spawn_blocking(move || {
2106            crate::database::set_session_acp_title(&session_id, title.as_deref())
2107        })
2108        .await;
2109        match result {
2110            Ok(Ok(())) => {}
2111            Ok(Err(error)) => tracing::warn!(%error, "could not persist relay title"),
2112            Err(error) => tracing::warn!(%error, "relay title persistence task failed"),
2113        }
2114    });
2115}
2116
2117#[cfg(test)]
2118mod tests {
2119
2120    /// The poller used to re-read and re-deserialise every live session's whole
2121    /// transcript on every runtime snapshot, then compare ordinals to discover
2122    /// that nothing had moved. On a real session that is 28,066 rows and
2123    /// 635 MiB, per poll. The comparison has to happen before the read.
2124    #[test]
2125    fn an_unchanged_session_is_recognised_without_reading_its_transcript() {
2126        let runtime = runtime_view("session-1", 42, "digest-42");
2127        let published = PublishedView::of(&runtime);
2128
2129        assert!(
2130            published.matches(&runtime),
2131            "an identical snapshot was treated as a change, so it would be re-read"
2132        );
2133
2134        // Anything a viewer would notice has to defeat the skip.
2135        let advanced = runtime_view("session-1", 43, "digest-43");
2136        assert!(
2137            !published.matches(&advanced),
2138            "a moved projection was mistaken for an unchanged one"
2139        );
2140
2141        // A digest change at the same ordinal is a rewritten projection, not a
2142        // quiet one: the convergence path exists precisely for this.
2143        let rewritten = runtime_view("session-1", 42, "digest-other");
2144        assert!(
2145            !published.matches(&rewritten),
2146            "a rewritten projection at the same ordinal was skipped"
2147        );
2148
2149        // The transcript can stand still while the agent starts a turn, and a
2150        // viewer has to see that.
2151        let mut busy = runtime_view("session-1", 42, "digest-42");
2152        busy.connected = false;
2153        assert!(
2154            !published.matches(&busy),
2155            "a disconnect was skipped as unchanged"
2156        );
2157    }
2158
2159    fn runtime_view(
2160        session_id: &str,
2161        projection_ordinal: u64,
2162        projection_digest: &str,
2163    ) -> crate::daemon::RuntimeSessionView {
2164        crate::daemon::RuntimeSessionView {
2165            session_id: session_id.to_owned(),
2166            projection_ordinal,
2167            projection_digest: projection_digest.to_owned(),
2168            operational: None,
2169            latest_credential_sync_signal: None,
2170            connected: true,
2171            error: None,
2172        }
2173    }
2174    use super::*;
2175
2176    fn podman_controller(state: SessionState) -> Controller {
2177        let session_id = "0123456789abcdef0123456789abcdef";
2178        let mut config = Config::default();
2179        config.profiles.insert(
2180            "codex".into(),
2181            mj_core::config::HarnessProfile {
2182                enabled: true,
2183                kind: mj_core::config::HarnessKind::Codex,
2184                home: PathBuf::from("/home/dev/.codex"),
2185                environment: Default::default(),
2186                context_window_bytes: None,
2187            },
2188        );
2189        config.targets.insert(
2190            "podman".into(),
2191            mj_core::config::TargetTemplate::LocalPodman {
2192                container: mj_core::config::ContainerTemplate {
2193                    image: "ubuntu:24.04".into(),
2194                    pull_policy: Default::default(),
2195                    platform: None,
2196                    cpus: None,
2197                    memory: None,
2198                    environment: std::collections::BTreeMap::new(),
2199                    workspace_storage: Default::default(),
2200                },
2201            },
2202        );
2203        config.bundles.insert(
2204            "project".into(),
2205            mj_core::config::ProjectBundle {
2206                primary_repo: "project".into(),
2207                repositories: vec![mj_core::config::ProjectRepository {
2208                    id: "project".into(),
2209                    github: Some("owner/project".into()),
2210                    local: None,
2211                    destination: "project".into(),
2212                    git_ref: None,
2213                }],
2214            },
2215        );
2216        let mut app_state = State::default();
2217        app_state.sessions.insert(
2218            session_id.into(),
2219            mj_core::state::SessionRecord {
2220                mjolnir_subagents: None,
2221                create_managed_worktree: None,
2222                workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
2223                archived: false,
2224                container_cpus: None,
2225                container_memory: None,
2226                id: session_id.into(),
2227                title: "poll target".into(),
2228                harness_kind: mj_core::config::HarnessKind::Codex,
2229                last_profile: "codex".into(),
2230                bundle_id: "project".into(),
2231                project_directory: None,
2232                managed_worktree: None,
2233                target_template_id: "podman".into(),
2234                resource_allocation: None,
2235                additional_mounts: Vec::new(),
2236                state,
2237                target: Some(mj_core::state::TargetLocator::LocalPodman {
2238                    container_id: "a".repeat(64),
2239                    workspace_storage: Default::default(),
2240                }),
2241                native_session_id: None,
2242                acp_session_title: None,
2243                session_title_override: None,
2244                created_at: "2026-08-27T00:00:00Z".into(),
2245                updated_at: "2026-08-27T00:00:00Z".into(),
2246                viewed_through_event_ordinal: 0,
2247                draft_input: String::new(),
2248                last_error: None,
2249                last_checkpoint_error: None,
2250                checkpoint: None,
2251            },
2252        );
2253        Controller {
2254            config,
2255            state: app_state,
2256        }
2257    }
2258
2259    #[test]
2260    fn recoverable_error_session_stays_out_of_live_target_pollers() {
2261        let running = podman_controller(SessionState::Running);
2262        assert_eq!(dashboard_worker_targets(&running).len(), 1);
2263        assert_eq!(dashboard_resource_targets(&running).len(), 1);
2264        assert_eq!(credential_sync_targets(&running).len(), 1);
2265
2266        let recoverable_error = podman_controller(SessionState::Error);
2267        assert!(
2268            recoverable_error
2269                .state
2270                .sessions
2271                .values()
2272                .all(|session| session.target.is_some()),
2273            "the test session keeps its target so the exclusion is about its state"
2274        );
2275        assert!(
2276            !recoverable_error
2277                .state
2278                .sessions
2279                .values()
2280                .any(session_target_is_pollable),
2281            "an errored session is not dialed even while its target exists"
2282        );
2283        assert!(dashboard_worker_targets(&recoverable_error).is_empty());
2284        assert!(dashboard_resource_targets(&recoverable_error).is_empty());
2285        assert!(credential_sync_targets(&recoverable_error).is_empty());
2286    }
2287
2288    /// A session gets its `target` as soon as the target exists, which is
2289    /// before its worker binary has finished being copied into place. Polling
2290    /// that window runs `execve` on a file `cp` still holds open for writing:
2291    /// `ETXTBSY`, and a session recorded as unreachable while it was merely
2292    /// still being built.
2293    #[test]
2294    fn a_provisioning_session_is_not_polled_before_its_worker_exists() {
2295        let provisioning = podman_controller(SessionState::Provisioning);
2296        assert!(
2297            provisioning
2298                .state
2299                .sessions
2300                .values()
2301                .all(|session| session.target.is_some())
2302        );
2303
2304        assert!(dashboard_worker_targets(&provisioning).is_empty());
2305        assert!(dashboard_resource_targets(&provisioning).is_empty());
2306
2307        // Provisioning connects to its own worker and then marks the session
2308        // running, which is when there is something to poll.
2309        let running = podman_controller(SessionState::Running);
2310        assert_eq!(dashboard_worker_targets(&running).len(), 1);
2311        assert_eq!(dashboard_resource_targets(&running).len(), 1);
2312    }
2313
2314    #[test]
2315    fn failed_destruction_stays_out_of_pollers_without_an_active_lifecycle() {
2316        let mut controller = podman_controller(SessionState::Destroying);
2317        for session in controller.state.sessions.values_mut() {
2318            session.last_error =
2319                Some("verified checkpoint retained; cleanup is safely retryable".into());
2320        }
2321        // No in-flight lifecycle exclusion survives a failed close or restart.
2322        let excluded = std::collections::BTreeSet::new();
2323        for _ in 0..3 {
2324            assert!(dashboard_worker_targets_excluding(&controller, &excluded).is_empty());
2325            assert!(dashboard_resource_targets(&controller).is_empty());
2326            assert!(credential_sync_targets(&controller).is_empty());
2327        }
2328        let closing = podman_controller(SessionState::Closing);
2329        assert_eq!(dashboard_worker_targets(&closing).len(), 1);
2330    }
2331
2332    #[test]
2333    fn lifecycle_owned_session_stays_out_of_worker_targets() {
2334        let controller = podman_controller(SessionState::Running);
2335        assert_eq!(dashboard_worker_targets(&controller).len(), 1);
2336
2337        let excluded = controller
2338            .state
2339            .sessions
2340            .keys()
2341            .cloned()
2342            .collect::<std::collections::BTreeSet<_>>();
2343
2344        assert!(dashboard_worker_targets_excluding(&controller, &excluded).is_empty());
2345    }
2346
2347    #[test]
2348    fn projection_rollback_race_retries_before_reporting_integrity_failure() {
2349        let mismatch = ProjectionMismatch {
2350            published_ordinal: 39,
2351            published_digest: "published".into(),
2352            durable_ordinal: 36,
2353            durable_digest: "durable".into(),
2354        };
2355        let mut convergence = ProjectionConvergence::default();
2356
2357        for _ in 0..PROJECTION_CONVERGENCE_RETRIES {
2358            assert!(convergence.should_retry("session-1", mismatch.clone()));
2359        }
2360        assert!(
2361            !convergence.should_retry("session-1", mismatch),
2362            "a persistent mismatch must still become an integrity error"
2363        );
2364
2365        convergence.converged("session-1");
2366        assert!(convergence.attempts.is_empty());
2367    }
2368
2369    #[test]
2370    fn a_changed_projection_mismatch_gets_its_own_convergence_window() {
2371        let mut convergence = ProjectionConvergence::default();
2372        let stale_lineage = ProjectionMismatch {
2373            published_ordinal: 39,
2374            published_digest: "old-lineage".into(),
2375            durable_ordinal: 36,
2376            durable_digest: "checkpoint".into(),
2377        };
2378        for _ in 0..=PROJECTION_CONVERGENCE_RETRIES {
2379            convergence.should_retry("session-1", stale_lineage.clone());
2380        }
2381        let equal_frontier_different_lineage = ProjectionMismatch {
2382            published_ordinal: 39,
2383            published_digest: "old-lineage".into(),
2384            durable_ordinal: 39,
2385            durable_digest: "new-lineage".into(),
2386        };
2387
2388        assert!(convergence.should_retry("session-1", equal_frontier_different_lineage));
2389    }
2390
2391    #[test]
2392    fn worker_diagnosis_is_coalesced_for_one_unreachable_episode() {
2393        let mut tracker = WorkerDiagnosisTracker::default();
2394        let episode = tracker
2395            .observe("session-1", false, Some("connection refused".into()))
2396            .unwrap();
2397
2398        assert_eq!(
2399            tracker.observe("session-1", false, Some("still unreachable".into())),
2400            None
2401        );
2402        assert_eq!(
2403            tracker.finish("session-1", episode),
2404            WorkerDiagnosisCompletion {
2405                display_error: Some("still unreachable".into()),
2406                restart_episode: None,
2407            }
2408        );
2409        assert_eq!(
2410            tracker.observe("session-1", false, Some("third poll".into())),
2411            None
2412        );
2413    }
2414
2415    #[test]
2416    fn stale_worker_diagnosis_is_not_published_after_reconnect() {
2417        let mut tracker = WorkerDiagnosisTracker::default();
2418        let first = tracker
2419            .observe("session-1", false, Some("first outage".into()))
2420            .unwrap();
2421        assert_eq!(tracker.observe("session-1", true, None), None);
2422        assert_eq!(
2423            tracker.observe("session-1", false, Some("new outage".into())),
2424            None
2425        );
2426
2427        let completion = tracker.finish("session-1", first);
2428        assert_eq!(completion.display_error, None);
2429        let second = completion.restart_episode.unwrap();
2430        assert_eq!(
2431            tracker.finish("session-1", second).display_error.as_deref(),
2432            Some("new outage")
2433        );
2434    }
2435
2436    #[test]
2437    fn stale_worker_diagnosis_is_not_published_after_a_terminal_poll_error() {
2438        let mut tracker = WorkerDiagnosisTracker::default();
2439        let episode = tracker
2440            .observe("session-1", false, Some("relay failed".into()))
2441            .unwrap();
2442
2443        assert_eq!(tracker.observe("session-1", false, None), None);
2444        assert_eq!(
2445            tracker.finish("session-1", episode),
2446            WorkerDiagnosisCompletion::default()
2447        );
2448    }
2449
2450    #[tokio::test]
2451    async fn quota_refresh_completion_keeps_its_generation() {
2452        let mut quotas = QuotaManager::default();
2453        let (updates, mut received) = tokio::sync::mpsc::channel(4);
2454        assert!(refresh_profile_quotas(&mut quotas, 42, &[], &updates).await);
2455        assert!(matches!(
2456            received.recv().await,
2457            Some(QuotaUpdate::Refreshing {
2458                profile_ids,
2459            }) if profile_ids.is_empty()
2460        ));
2461        assert!(matches!(
2462            received.recv().await,
2463            Some(QuotaUpdate::Finished { generation: 42 })
2464        ));
2465
2466        let mut pending = Some(43);
2467        assert!(!complete_manual_quota_refresh(&mut pending, 42));
2468        assert_eq!(pending, Some(43));
2469        assert!(complete_manual_quota_refresh(&mut pending, 43));
2470        assert_eq!(pending, None);
2471        quotas.shutdown().await;
2472    }
2473
2474    #[test]
2475    fn quota_refresh_requests_exclude_disabled_profiles() {
2476        let mut controller = podman_controller(SessionState::Stopped);
2477        let mut disabled = controller.config.profiles["codex"].clone();
2478        disabled.enabled = false;
2479        controller
2480            .config
2481            .profiles
2482            .insert("reserve".into(), disabled);
2483
2484        let requests = quota_refresh_profiles(&controller);
2485
2486        assert_eq!(
2487            requests
2488                .iter()
2489                .map(|request| request.profile_id.as_str())
2490                .collect::<Vec<_>>(),
2491            ["codex"]
2492        );
2493    }
2494
2495    #[test]
2496    fn resource_samples_are_throttled_to_one_per_minute() {
2497        let started = tokio::time::Instant::now();
2498        assert!(!resource_sample_is_due(
2499            Some(&started),
2500            started + Duration::from_secs(59),
2501        ));
2502        assert!(resource_sample_is_due(
2503            Some(&started),
2504            started + RESOURCE_POLL_INTERVAL,
2505        ));
2506    }
2507
2508    struct PendingCapacityProbe {
2509        target: DeploymentCapacityTarget,
2510        finish: tokio::sync::oneshot::Sender<Result<Option<DeploymentCapacityUsage>>>,
2511    }
2512
2513    struct CapacityPollerFixture {
2514        targets: tokio::sync::watch::Sender<Vec<DeploymentCapacityTarget>>,
2515        triggers: tokio::sync::mpsc::Sender<()>,
2516        updates: tokio::sync::mpsc::Receiver<CapacityPollUpdate>,
2517        started: tokio::sync::mpsc::UnboundedReceiver<PendingCapacityProbe>,
2518    }
2519
2520    impl CapacityPollerFixture {
2521        fn new() -> Self {
2522            let (started_tx, started) = tokio::sync::mpsc::unbounded_channel();
2523            let (targets, triggers, updates) = spawn_capacity_poller_with(move |target| {
2524                let started_tx = started_tx.clone();
2525                async move {
2526                    let (finish, result) = tokio::sync::oneshot::channel();
2527                    started_tx
2528                        .send(PendingCapacityProbe { target, finish })
2529                        .unwrap();
2530                    result.await.context("test probe completion dropped")?
2531                }
2532            });
2533            Self {
2534                targets,
2535                triggers,
2536                updates,
2537                started,
2538            }
2539        }
2540
2541        async fn assert_no_start(&mut self) {
2542            assert!(
2543                tokio::time::timeout(Duration::from_millis(1), self.started.recv())
2544                    .await
2545                    .is_err()
2546            );
2547        }
2548    }
2549
2550    fn capacity_target(id: &str) -> DeploymentCapacityTarget {
2551        DeploymentCapacityTarget {
2552            id: id.into(),
2553            host: id.into(),
2554            target_ids: vec![id.into()],
2555            kind: DeploymentCapacityKind::Host,
2556            local: true,
2557            probes: Vec::new(),
2558            probe_error: None,
2559        }
2560    }
2561
2562    #[tokio::test(start_paused = true)]
2563    async fn capacity_samples_follow_timer_and_manual_refresh_not_unchanged_publications() {
2564        let mut fixture = CapacityPollerFixture::new();
2565        let targets = vec![capacity_target("local")];
2566        fixture.targets.send_replace(targets.clone());
2567        fixture
2568            .started
2569            .recv()
2570            .await
2571            .unwrap()
2572            .finish
2573            .send(Ok(None))
2574            .unwrap();
2575        assert!(fixture.updates.recv().await.unwrap().result.is_ok());
2576
2577        fixture.targets.send_replace(targets);
2578        fixture.assert_no_start().await;
2579        tokio::time::advance(Duration::from_secs(29)).await;
2580        fixture.assert_no_start().await;
2581        tokio::time::advance(Duration::from_secs(1)).await;
2582        fixture
2583            .started
2584            .recv()
2585            .await
2586            .unwrap()
2587            .finish
2588            .send(Ok(None))
2589            .unwrap();
2590        assert!(fixture.updates.recv().await.unwrap().result.is_ok());
2591
2592        fixture.triggers.send(()).await.unwrap();
2593        fixture
2594            .started
2595            .recv()
2596            .await
2597            .unwrap()
2598            .finish
2599            .send(Ok(None))
2600            .unwrap();
2601        assert!(fixture.updates.recv().await.unwrap().result.is_ok());
2602        fixture.assert_no_start().await;
2603    }
2604
2605    #[tokio::test(start_paused = true)]
2606    async fn capacity_busy_targets_coalesce_requests_without_blocking_other_targets() {
2607        let mut fixture = CapacityPollerFixture::new();
2608        let first_target = capacity_target("first");
2609        fixture.targets.send_replace(vec![first_target.clone()]);
2610        let first = fixture.started.recv().await.unwrap();
2611        fixture
2612            .targets
2613            .send_replace(vec![first_target, capacity_target("second")]);
2614        let second = fixture.started.recv().await.unwrap();
2615        assert_eq!(second.target.id, "second");
2616
2617        fixture.triggers.send(()).await.unwrap();
2618        fixture.assert_no_start().await;
2619        tokio::time::advance(CAPACITY_POLL_INTERVAL).await;
2620        fixture.assert_no_start().await;
2621        first.finish.send(Ok(None)).unwrap();
2622        second.finish.send(Ok(None)).unwrap();
2623        assert!(fixture.updates.recv().await.unwrap().result.is_ok());
2624        assert!(fixture.updates.recv().await.unwrap().result.is_ok());
2625        fixture.assert_no_start().await;
2626    }
2627
2628    #[tokio::test(start_paused = true)]
2629    async fn capacity_changed_targets_get_one_follow_up_and_removed_results_are_discarded() {
2630        let mut fixture = CapacityPollerFixture::new();
2631        let mut target = capacity_target("local");
2632        fixture.targets.send_replace(vec![target.clone()]);
2633        let first = fixture.started.recv().await.unwrap();
2634        target.host = "new-host".into();
2635        fixture.targets.send_replace(vec![target.clone()]);
2636        fixture.assert_no_start().await;
2637        first.finish.send(Ok(None)).unwrap();
2638        let changed = fixture.started.recv().await.unwrap();
2639        assert_eq!(changed.target, target);
2640        assert!(
2641            fixture.updates.try_recv().is_err(),
2642            "old configuration result escaped"
2643        );
2644        changed
2645            .finish
2646            .send(Err(anyhow::anyhow!("new host unavailable")))
2647            .unwrap();
2648        assert!(
2649            fixture
2650                .updates
2651                .recv()
2652                .await
2653                .unwrap()
2654                .result
2655                .unwrap_err()
2656                .contains("new host unavailable")
2657        );
2658
2659        fixture.triggers.send(()).await.unwrap();
2660        let removed = fixture.started.recv().await.unwrap();
2661        fixture.targets.send_replace(Vec::new());
2662        fixture.assert_no_start().await;
2663        removed.finish.send(Ok(None)).unwrap();
2664        assert!(
2665            tokio::time::timeout(Duration::from_millis(1), fixture.updates.recv())
2666                .await
2667                .is_err()
2668        );
2669        fixture.targets.send_replace(vec![target]);
2670        let mut last = fixture.started.recv().await.unwrap();
2671        drop(fixture.updates);
2672        last.finish.closed().await;
2673    }
2674
2675    #[tokio::test]
2676    async fn capacity_probe_panics_are_reported_and_do_not_prevent_retry() {
2677        let first = AtomicBool::new(true);
2678        let (targets, triggers, mut updates) = spawn_capacity_poller_with(move |_| {
2679            if first.swap(false, Ordering::SeqCst) {
2680                panic!("test capacity probe panic");
2681            }
2682            async { Ok(None) }
2683        });
2684        targets.send_replace(vec![capacity_target("local")]);
2685        let failure = updates.recv().await.unwrap();
2686        assert_eq!(failure.target_id, "local");
2687        assert!(
2688            failure
2689                .result
2690                .unwrap_err()
2691                .contains("test capacity probe panic")
2692        );
2693        triggers.send(()).await.unwrap();
2694        assert!(updates.recv().await.unwrap().result.is_ok());
2695    }
2696
2697    #[tokio::test(start_paused = true)]
2698    async fn capacity_results_are_revalidated_after_output_backpressure() {
2699        let mut fixture = CapacityPollerFixture::new();
2700        let mut targets: Vec<_> = (0..65).map(|id| capacity_target(&id.to_string())).collect();
2701        fixture.targets.send_replace(targets.clone());
2702        let mut pending = Vec::new();
2703        for _ in 0..65 {
2704            pending.push(fixture.started.recv().await.unwrap());
2705        }
2706        let last = pending.pop().unwrap();
2707        let last_id = last.target.id;
2708        for probe in pending {
2709            probe.finish.send(Ok(None)).unwrap();
2710        }
2711        fixture.assert_no_start().await;
2712        assert_eq!(fixture.updates.len(), 64);
2713        last.finish.send(Ok(None)).unwrap();
2714        fixture.assert_no_start().await;
2715
2716        targets
2717            .iter_mut()
2718            .find(|target| target.id == last_id)
2719            .unwrap()
2720            .host = "changed".into();
2721        fixture.targets.send_replace(targets);
2722        for _ in 0..64 {
2723            let update = fixture.updates.recv().await.unwrap();
2724            assert_ne!(update.target_id, last_id);
2725        }
2726        let changed = fixture.started.recv().await.unwrap();
2727        assert_eq!(changed.target.id, last_id);
2728        assert_eq!(changed.target.host, "changed");
2729        assert!(
2730            fixture.updates.try_recv().is_err(),
2731            "stale blocked result escaped"
2732        );
2733        changed.finish.send(Ok(None)).unwrap();
2734        assert_eq!(fixture.updates.recv().await.unwrap().target_id, last_id);
2735        fixture.assert_no_start().await;
2736    }
2737
2738    #[tokio::test(start_paused = true)]
2739    async fn capacity_timeout_retains_blocking_sample_until_it_exits() {
2740        let (started_tx, started_rx) = tokio::sync::oneshot::channel();
2741        let (finish_tx, finish_rx) = std::sync::mpsc::channel();
2742        let sample = tokio::spawn(collect_local_capacity_with(move || {
2743            started_tx.send(()).unwrap();
2744            // Dropping finish_tx on a test failure also releases this thread.
2745            finish_rx.recv().context("test sample was cancelled")?;
2746            Ok(DeploymentCapacityUsage {
2747                cpu_percent: Some(10),
2748                memory_used_bytes: 1,
2749                memory_total_bytes: 2,
2750                logical_cores: 4,
2751                disk_total_bytes: None,
2752            })
2753        }));
2754        started_rx.await.unwrap();
2755        tokio::time::advance(RESOURCE_POLL_TIMEOUT + Duration::from_secs(1)).await;
2756        tokio::task::yield_now().await;
2757        assert!(
2758            !sample.is_finished(),
2759            "timeout released a still-running blocking sample"
2760        );
2761        finish_tx.send(()).unwrap();
2762        assert!(
2763            sample
2764                .await
2765                .unwrap()
2766                .unwrap_err()
2767                .to_string()
2768                .contains("timed out")
2769        );
2770    }
2771
2772    #[test]
2773    fn a_new_credential_signal_waits_out_the_cooldown_without_being_lost() {
2774        let signal = |ordinal, reason| CredentialSyncSignal { ordinal, reason };
2775        let mut tracker = CredentialSyncSignalTracker::default();
2776        let started = Instant::now();
2777        tracker.observe(
2778            "session",
2779            "work",
2780            signal(41, CredentialSyncReason::AuthenticationFailure),
2781        );
2782        assert_eq!(
2783            tracker.drain_due(started),
2784            vec![(
2785                "session".into(),
2786                "work".into(),
2787                CredentialSyncReason::AuthenticationFailure
2788            )]
2789        );
2790
2791        tracker.observe(
2792            "session",
2793            "work",
2794            signal(42, CredentialSyncReason::AuthenticationFailure),
2795        );
2796        assert!(
2797            tracker
2798                .drain_due(started + Duration::from_secs(60))
2799                .is_empty()
2800        );
2801        tracker.observe(
2802            "session",
2803            "new-profile",
2804            signal(43, CredentialSyncReason::EmptyPromptResponse),
2805        );
2806        assert_eq!(tracker.pending["session"].signal.ordinal, 43);
2807
2808        // No repeated observation is needed: the loop timer drains the sticky
2809        // failure once its cooldown expires.
2810        assert_eq!(
2811            tracker.drain_due(started + IMMEDIATE_CREDENTIAL_SYNC_COOLDOWN),
2812            vec![(
2813                "session".into(),
2814                "new-profile".into(),
2815                CredentialSyncReason::EmptyPromptResponse
2816            )]
2817        );
2818        tracker.observe(
2819            "session",
2820            "new-profile",
2821            signal(43, CredentialSyncReason::EmptyPromptResponse),
2822        );
2823        assert!(
2824            tracker
2825                .drain_due(started + (IMMEDIATE_CREDENTIAL_SYNC_COOLDOWN * 2))
2826                .is_empty()
2827        );
2828
2829        tracker.observe(
2830            "other",
2831            "personal",
2832            signal(1, CredentialSyncReason::AuthenticationFailure),
2833        );
2834        assert_eq!(
2835            tracker.drain_due(started + Duration::from_secs(60)),
2836            vec![(
2837                "other".into(),
2838                "personal".into(),
2839                CredentialSyncReason::AuthenticationFailure
2840            )]
2841        );
2842    }
2843
2844    #[test]
2845    fn a_healthy_credential_cycle_stays_out_of_the_ui() {
2846        let result = mj_core::credentials::CredentialSyncResult {
2847            profile_id: "work".into(),
2848            trigger: None,
2849            failure: None,
2850            outcomes: Vec::new(),
2851        };
2852        assert_eq!(CredentialSyncNotices::default().notice(&result, None), None);
2853    }
2854
2855    #[test]
2856    fn github_tokens_sync_to_every_remote_target_but_raw_localhost() {
2857        use mj_core::state::TargetLocator;
2858
2859        let remotes = [
2860            TargetLocator::LocalPodman {
2861                container_id: "podman".into(),
2862                workspace_storage: Default::default(),
2863            },
2864            TargetLocator::AppleContainer {
2865                container_id: "apple".into(),
2866            },
2867            TargetLocator::AwsEc2 {
2868                instance_id: "i-123".into(),
2869                address: Some("example.invalid".into()),
2870            },
2871            TargetLocator::SshBare {
2872                host: "ssh.example".into(),
2873                workspace: "/workspace".into(),
2874                worker_id: None,
2875            },
2876            TargetLocator::SshPodman {
2877                host: "ssh.example".into(),
2878                container_id: "remote-podman".into(),
2879                workspace_storage: Default::default(),
2880            },
2881            TargetLocator::SshDocker {
2882                host: "ssh.example".into(),
2883                container_id: "remote-docker".into(),
2884            },
2885        ];
2886        for target in &remotes {
2887            assert!(target_syncs_github_token(Some(target)), "{target:?}");
2888        }
2889        assert!(!target_syncs_github_token(Some(
2890            &TargetLocator::LocalBare {
2891                worker_root: "/tmp/worker".into(),
2892            }
2893        )));
2894        assert!(!target_syncs_github_token(None));
2895    }
2896
2897    #[test]
2898    fn an_authentication_failure_notice_says_whether_anything_was_pushed() {
2899        use mj_core::credentials::{
2900            CredentialSyncAction, CredentialSyncOutcome, CredentialSyncResult,
2901        };
2902
2903        let mut notices = CredentialSyncNotices::default();
2904        let pushed = CredentialSyncResult {
2905            profile_id: "work".into(),
2906            trigger: Some(CredentialSyncCause {
2907                session_id: "018f9dd2-a3b4".into(),
2908                reason: CredentialSyncReason::AuthenticationFailure,
2909            }),
2910            failure: None,
2911            outcomes: vec![CredentialSyncOutcome {
2912                session_id: "018f9dd2-a3b4".into(),
2913                outcome: Ok(vec![CredentialSyncAction::Pushed]),
2914            }],
2915        };
2916        let notice = notices.notice(&pushed, None).unwrap();
2917        assert!(notice.contains("were pushed"), "{notice}");
2918        assert!(notice.contains("mj login --profile work"), "{notice}");
2919
2920        let nothing_to_push = CredentialSyncResult {
2921            trigger: Some(CredentialSyncCause {
2922                session_id: "018f9dd2-a3b4".into(),
2923                reason: CredentialSyncReason::AuthenticationFailure,
2924            }),
2925            outcomes: Vec::new(),
2926            ..pushed
2927        };
2928        let notice = notices.notice(&nothing_to_push, None).unwrap();
2929        assert!(notice.contains("nothing fresher"), "{notice}");
2930        assert!(notice.contains("mj login --profile work"), "{notice}");
2931        // The per-session cooldown upstream limits these; the dedup must not.
2932        assert_eq!(notices.notice(&nothing_to_push, None), Some(notice));
2933    }
2934
2935    #[test]
2936    fn a_claude_authentication_failure_offers_the_long_lived_token() {
2937        use mj_core::config::HarnessKind;
2938        use mj_core::credentials::{CredentialSyncOutcome, CredentialSyncResult};
2939
2940        let result = CredentialSyncResult {
2941            profile_id: "claude-max".into(),
2942            trigger: Some(CredentialSyncCause {
2943                session_id: "018f9dd2-a3b4".into(),
2944                reason: CredentialSyncReason::AuthenticationFailure,
2945            }),
2946            failure: None,
2947            outcomes: Vec::new(),
2948        };
2949
2950        let claude = CredentialSyncNotices::default()
2951            .notice(&result, Some(HarnessKind::Claude))
2952            .unwrap();
2953        assert!(
2954            claude.ends_with(
2955                "Run `mj login --profile claude-max`, or store a long-lived token with `mj login --profile claude-max --setup-token`."
2956            ),
2957            "{claude}"
2958        );
2959
2960        // Only Claude can rotate ahead of expiry this way.
2961        let codex = CredentialSyncNotices::default()
2962            .notice(&result, Some(HarnessKind::Codex))
2963            .unwrap();
2964        assert!(
2965            codex.ends_with("Run `mj login --profile claude-max`."),
2966            "{codex}"
2967        );
2968
2969        // The advice also reaches a failed reconciliation, not only a clean one.
2970        let failed = CredentialSyncResult {
2971            outcomes: vec![CredentialSyncOutcome {
2972                session_id: "018f9dd2-a3b4".into(),
2973                outcome: Err("worker proxy disconnected".into()),
2974            }],
2975            ..result
2976        };
2977        let claude_failure = CredentialSyncNotices::default()
2978            .notice(&failed, Some(HarnessKind::Claude))
2979            .unwrap();
2980        assert!(
2981            claude_failure.contains("--setup-token`."),
2982            "{claude_failure}"
2983        );
2984    }
2985
2986    #[test]
2987    fn an_empty_prompt_notice_does_not_claim_authentication_failed() {
2988        use mj_core::credentials::{
2989            CredentialSyncAction, CredentialSyncOutcome, CredentialSyncResult,
2990        };
2991
2992        let result = CredentialSyncResult {
2993            profile_id: "work".into(),
2994            trigger: Some(CredentialSyncCause {
2995                session_id: "018f9dd2-a3b4".into(),
2996                reason: CredentialSyncReason::EmptyPromptResponse,
2997            }),
2998            failure: None,
2999            outcomes: vec![CredentialSyncOutcome {
3000                session_id: "018f9dd2-a3b4".into(),
3001                outcome: Ok(vec![CredentialSyncAction::Pushed]),
3002            }],
3003        };
3004        let notice = CredentialSyncNotices::default()
3005            .notice(&result, None)
3006            .unwrap();
3007        assert!(notice.contains("returned no response"), "{notice}");
3008        assert!(notice.contains("were pushed"), "{notice}");
3009        assert!(!notice.contains("Auth failure"), "{notice}");
3010    }
3011
3012    #[test]
3013    fn an_immediate_sync_failure_is_not_reported_as_no_new_credentials() {
3014        use mj_core::credentials::CredentialSyncResult;
3015
3016        let result = CredentialSyncResult {
3017            profile_id: "work".into(),
3018            trigger: Some(CredentialSyncCause {
3019                session_id: "018f9dd2-a3b4".into(),
3020                reason: CredentialSyncReason::AuthenticationFailure,
3021            }),
3022            failure: Some("controller credential file is unreadable".into()),
3023            outcomes: Vec::new(),
3024        };
3025        let notice = CredentialSyncNotices::default()
3026            .notice(&result, None)
3027            .unwrap();
3028        assert!(notice.contains("reconciliation failed"), "{notice}");
3029        assert!(notice.contains("credential file is unreadable"), "{notice}");
3030        assert!(!notice.contains("nothing fresher"), "{notice}");
3031    }
3032
3033    #[test]
3034    fn a_failed_credential_sync_is_reported() {
3035        use mj_core::credentials::{CredentialSyncOutcome, CredentialSyncResult};
3036
3037        let result = CredentialSyncResult {
3038            profile_id: "work".into(),
3039            trigger: None,
3040            failure: None,
3041            outcomes: vec![CredentialSyncOutcome {
3042                session_id: "018f9dd2-a3b4".into(),
3043                outcome: Err("worker proxy disconnected".into()),
3044            }],
3045        };
3046        let notice = CredentialSyncNotices::default()
3047            .notice(&result, None)
3048            .unwrap();
3049        assert!(notice.contains("worker proxy disconnected"), "{notice}");
3050    }
3051
3052    #[test]
3053    fn a_repeated_credential_failure_is_reported_once_until_it_changes() {
3054        use mj_core::credentials::{
3055            CredentialSyncAction, CredentialSyncOutcome, CredentialSyncResult,
3056        };
3057
3058        let failed = |detail: &str| CredentialSyncResult {
3059            profile_id: "work".into(),
3060            trigger: None,
3061            failure: None,
3062            outcomes: vec![CredentialSyncOutcome {
3063                session_id: "018f9dd2-a3b4".into(),
3064                outcome: Err(detail.to_owned()),
3065            }],
3066        };
3067        let mut notices = CredentialSyncNotices::default();
3068
3069        assert!(
3070            notices
3071                .notice(&failed("worker proxy disconnected"), None)
3072                .is_some()
3073        );
3074        assert_eq!(
3075            notices.notice(&failed("worker proxy disconnected"), None),
3076            None
3077        );
3078
3079        let changed = notices.notice(&failed("container is gone"), None).unwrap();
3080        assert!(changed.contains("container is gone"), "{changed}");
3081        assert_eq!(notices.notice(&failed("container is gone"), None), None);
3082
3083        // A clean cycle forgets the failure, so a recurrence is reported again.
3084        let healthy = CredentialSyncResult {
3085            profile_id: "work".into(),
3086            trigger: None,
3087            failure: None,
3088            outcomes: vec![CredentialSyncOutcome {
3089                session_id: "018f9dd2-a3b4".into(),
3090                outcome: Ok(vec![CredentialSyncAction::Pushed]),
3091            }],
3092        };
3093        assert_eq!(notices.notice(&healthy, None), None);
3094        assert!(notices.notice(&failed("container is gone"), None).is_some());
3095    }
3096
3097    #[test]
3098    fn a_repeated_whole_sync_failure_is_reported_once_per_profile() {
3099        use mj_core::credentials::CredentialSyncResult;
3100
3101        let failed = |profile_id: &str| CredentialSyncResult {
3102            profile_id: profile_id.to_owned(),
3103            trigger: None,
3104            failure: Some("controller home is unreadable".into()),
3105            outcomes: Vec::new(),
3106        };
3107        let mut notices = CredentialSyncNotices::default();
3108
3109        let notice = notices.notice(&failed("work"), None).unwrap();
3110        assert!(notice.contains("profile work"), "{notice}");
3111        assert_eq!(notices.notice(&failed("work"), None), None);
3112        // Another profile failing the same way is its own key.
3113        assert!(notices.notice(&failed("personal"), None).is_some());
3114        assert_eq!(notices.notice(&failed("work"), None), None);
3115    }
3116
3117    #[test]
3118    fn skills_and_github_syncs_speak_while_harness_credentials_stay_out_of_the_notice() {
3119        use mj_core::credentials::{
3120            CredentialSyncAction, CredentialSyncOutcome, CredentialSyncResult,
3121        };
3122
3123        let result = CredentialSyncResult {
3124            profile_id: "work".into(),
3125            trigger: None,
3126            failure: None,
3127            outcomes: vec![
3128                CredentialSyncOutcome {
3129                    session_id: "018f9dd2-a3b4".into(),
3130                    outcome: Ok(vec![
3131                        CredentialSyncAction::Pushed,
3132                        CredentialSyncAction::SkillsPushed,
3133                        CredentialSyncAction::GithubTokenPushed,
3134                    ]),
3135                },
3136                CredentialSyncOutcome {
3137                    session_id: "018f9dd2-bbbb".into(),
3138                    outcome: Ok(vec![
3139                        CredentialSyncAction::SkillsPushed,
3140                        CredentialSyncAction::GithubTokenRemoved,
3141                    ]),
3142                },
3143            ],
3144        };
3145        let notice = CredentialSyncNotices::default()
3146            .notice(&result, None)
3147            .unwrap();
3148        assert!(!notice.contains("harness credentials"), "{notice}");
3149        assert!(
3150            notice.contains("Synced skills for profile work to 2 session(s)."),
3151            "{notice}"
3152        );
3153        assert!(
3154            notice.contains("Synced the GitHub CLI token to 1 session(s)."),
3155            "{notice}"
3156        );
3157        assert!(
3158            notice.contains("Removed the GitHub CLI token from 1 session(s)."),
3159            "{notice}"
3160        );
3161    }
3162
3163    #[test]
3164    fn aws_capacity_sums_live_instance_allocations() {
3165        let total = aggregate_aws_capacity(&[
3166            DeploymentCapacityUsage {
3167                cpu_percent: None,
3168                memory_used_bytes: 0,
3169                memory_total_bytes: 8,
3170                logical_cores: 2,
3171                disk_total_bytes: Some(100),
3172            },
3173            DeploymentCapacityUsage {
3174                cpu_percent: None,
3175                memory_used_bytes: 0,
3176                memory_total_bytes: 16,
3177                logical_cores: 4,
3178                disk_total_bytes: Some(200),
3179            },
3180        ])
3181        .unwrap();
3182
3183        assert_eq!(total.memory_total_bytes, 24);
3184        assert_eq!(total.logical_cores, 6);
3185        assert_eq!(total.disk_total_bytes, Some(300));
3186    }
3187
3188    /// A background refresh is a chore, not a launch. One host that cannot
3189    /// reach its registry must not cost the other hosts their pull, and the
3190    /// failure has to say which host, which image, and what the engine
3191    /// reported. `refresh_images` gives every host its own task for the same
3192    /// reason.
3193    #[test]
3194    fn a_failed_pull_is_reported_and_leaves_the_other_host_alone() {
3195        struct FailingPullExecutor {
3196            failing_image: String,
3197            commands: std::sync::Mutex<Vec<(String, Vec<String>)>>,
3198        }
3199
3200        impl CommandExecutor for FailingPullExecutor {
3201            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
3202                self.commands
3203                    .lock()
3204                    .unwrap()
3205                    .push((command.program.clone(), command.args.clone()));
3206                if command.args.contains(&"pull".to_owned())
3207                    && command.args.contains(&self.failing_image)
3208                {
3209                    return Ok(CommandOutput {
3210                        status: 125,
3211                        stdout: Vec::new(),
3212                        stderr: b"short-name resolution failed".to_vec(),
3213                    });
3214                }
3215                Ok(CommandOutput {
3216                    status: 0,
3217                    stdout: b"sha256:1111\n".to_vec(),
3218                    stderr: Vec::new(),
3219                })
3220            }
3221        }
3222
3223        let failing_image = "ghcr.io/example/broken:latest";
3224        let broken = crate::targets::image_refresh(
3225            crate::targets::ImageHost::LocalPodman,
3226            failing_image,
3227            None,
3228            mj_core::config::ImagePullPolicy::Auto,
3229        )
3230        .expect("a remote latest image is refreshed");
3231        let healthy = crate::targets::image_refresh(
3232            crate::targets::ImageHost::LocalDocker,
3233            "ghcr.io/example/dev:latest",
3234            None,
3235            mj_core::config::ImagePullPolicy::Auto,
3236        )
3237        .expect("a remote latest image is refreshed");
3238        let executor = FailingPullExecutor {
3239            failing_image: failing_image.to_owned(),
3240            commands: std::sync::Mutex::new(Vec::new()),
3241        };
3242
3243        let reported = refresh_host_image(&broken, &executor)
3244            .expect_err("a failed pull has to reach the caller");
3245        let reported = format!("{reported:#}");
3246        assert!(
3247            reported.contains("short-name resolution failed"),
3248            "{reported}"
3249        );
3250        assert!(reported.contains(failing_image), "{reported}");
3251
3252        refresh_host_image(&healthy, &executor).expect("the second host still refreshes");
3253
3254        let commands = executor.commands.lock().unwrap();
3255        let ran = |program: &str, args: &[&str]| {
3256            commands
3257                .iter()
3258                .any(|(command, arguments)| command == program && arguments == args)
3259        };
3260        assert!(ran("podman", &["pull", failing_image]), "{commands:?}");
3261        assert!(
3262            !ran("podman", &["image", "prune", "-f"]),
3263            "a host that could not pull has nothing to prune: {commands:?}"
3264        );
3265        assert!(
3266            ran("docker", &["pull", "ghcr.io/example/dev:latest"]),
3267            "{commands:?}"
3268        );
3269        assert!(ran("docker", &["image", "prune", "-f"]), "{commands:?}");
3270    }
3271}