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