Skip to main content

mj_controller/daemon/
views.rs

1use super::*;
2
3impl RuntimeState {
4    fn request_lifecycle_cancel(&self, session_id: &str) -> Result<Option<bool>> {
5        let mut controller_owner = self.owner();
6        let durable = durable_session_state(controller_owner.controller(), session_id);
7        let Some(active) = controller_owner.lifecycle.get_mut(session_id) else {
8            return Ok(None);
9        };
10        ensure!(
11            lifecycle_cancellable(active.kind, durable),
12            "stop of {session_id} has passed its verified checkpoint and is removing the target; \
13             it cannot be cancelled"
14        );
15        ensure!(
16            active.request_cancel(),
17            "lifecycle operation is no longer cancellable"
18        );
19        let restart = active.kind == LifecycleKind::Restart;
20        drop(controller_owner);
21        self.publish_revision();
22        Ok(Some(restart))
23    }
24
25    pub async fn cancel_lifecycle_with_intent(&self, session_id: &str) -> Result<()> {
26        let restart = self
27            .request_lifecycle_cancel(session_id)?
28            .with_context(|| {
29                format!("no lifecycle operation is running for session {session_id}")
30            })?;
31        if restart {
32            blocking({
33                let session_id = session_id.to_owned();
34                move || crate::database::cancel_session_restart(&session_id)
35            })
36            .await?;
37        }
38        Ok(())
39    }
40
41    pub async fn cancel_lifecycle_if_active(&self, session_id: &str) -> Result<bool> {
42        let Some(restart) = self.request_lifecycle_cancel(session_id)? else {
43            return Ok(false);
44        };
45        if restart {
46            blocking({
47                let session_id = session_id.to_owned();
48                move || crate::database::cancel_session_restart(&session_id)
49            })
50            .await?;
51        }
52        Ok(true)
53    }
54
55    /// Let storage cleanup drain briefly, then cancel and join every lifecycle
56    /// owner before the daemon closes its session manager and database writer.
57    /// One shared deadline bounds all cleanup tasks rather than granting eight
58    /// seconds to each session serially.
59    pub(super) async fn cancel_and_wait_lifecycles(&self) -> Result<()> {
60        let mut pending = {
61            let mut lifecycle_owner = self.owner();
62            let lifecycle = &mut lifecycle_owner.lifecycle;
63            lifecycle
64                .iter_mut()
65                .filter(|(_, active)| active.is_running())
66                .map(|(session_id, active)| {
67                    if active.kind != LifecycleKind::Cleanup {
68                        active.request_cancel();
69                    }
70                    let stage = active
71                        .active_stages
72                        .keys()
73                        .next_back()
74                        .map(|stage| stage.label())
75                        .unwrap_or_else(|| "container cleanup".to_owned());
76                    (
77                        session_id.clone(),
78                        active.operation_id.clone(),
79                        active.kind,
80                        stage,
81                        active.result.clone(),
82                    )
83                })
84                .collect::<Vec<_>>()
85        };
86        let cleanup_deadline = tokio::time::Instant::now() + Duration::from_secs(8);
87        for (session_id, operation_id, kind, stage, result) in &mut pending {
88            if *kind != LifecycleKind::Cleanup || result.borrow().is_some() {
89                continue;
90            }
91            tracing::info!(%session_id, %stage, "daemon shutdown is waiting for deferred cleanup");
92            self.set_lifecycle_notice(
93                session_id,
94                operation_id,
95                &format!("Daemon shutdown is waiting for {stage}"),
96            );
97            let finished = tokio::time::timeout_at(cleanup_deadline, async {
98                while result.borrow_and_update().is_none() {
99                    result.changed().await.with_context(|| {
100                        format!("cleanup owner stopped without a result for session {session_id}")
101                    })?;
102                }
103                Ok::<_, anyhow::Error>(())
104            })
105            .await;
106            match finished {
107                Ok(result) => result?,
108                Err(_) => {
109                    tracing::warn!(%session_id, %stage, "deferred cleanup exceeded the daemon shutdown drain deadline");
110                    self.cancel_operation(session_id, operation_id);
111                }
112            }
113        }
114        let join_started = tokio::time::Instant::now();
115        let join_deadline = join_started + Duration::from_secs(1);
116        // Launch cancellation is followed by independent, bounded teardown.
117        // Join that owner before closing the store it must settle.
118        let startup_cleanup_deadline = join_started
119            + crate::controller::FAILED_STARTUP_CLEANUP_TIMEOUT
120            + Duration::from_secs(1);
121        for (session_id, operation_id, kind, stage, mut result) in pending {
122            let join_deadline = if matches!(
123                kind,
124                LifecycleKind::Create
125                    | LifecycleKind::Resume
126                    | LifecycleKind::Restart
127                    | LifecycleKind::StartupCleanup
128            ) {
129                startup_cleanup_deadline
130            } else {
131                join_deadline
132            };
133            self.cancel_operation(&session_id, &operation_id);
134            let joined = tokio::time::timeout_at(join_deadline, async {
135                while result.borrow_and_update().is_none() {
136                    result.changed().await.with_context(|| {
137                        format!("lifecycle owner stopped without a result for session {session_id}")
138                    })?;
139                }
140                Ok::<_, anyhow::Error>(())
141            })
142            .await;
143            if joined.is_err() {
144                bail!(
145                    "timed out cancelling lifecycle owner for session {session_id} while {stage}"
146                );
147            }
148            joined.expect("checked timeout")?;
149        }
150        Ok(())
151    }
152
153    /// Every lifecycle operation running now.
154    ///
155    /// The dashboard receives these through a watch channel built by its own
156    /// poller, which the phone server does not have; rather than plumb that
157    /// channel through the session-manager handle, the phone loop reads the
158    /// same state directly. The read is a mutex acquisition over a small map,
159    /// and it happens once per published snapshot, so it never blocks the
160    /// loop the way an await on the async snapshot path would.
161    pub fn active_lifecycles(&self) -> Vec<RuntimeLifecycleView> {
162        let controller_owner = self.owner();
163        Self::active_lifecycles_with(&controller_owner)
164    }
165
166    /// Project records and ownership from the same transition boundary.
167    pub(super) fn active_lifecycles_with(owner: &RuntimeStateOwner) -> Vec<RuntimeLifecycleView> {
168        let controller = owner.controller();
169        owner
170            .lifecycle
171            .iter()
172            .filter(|(_, active)| active.is_visible())
173            .map(|(session_id, active)| RuntimeLifecycleView {
174                operation_id: active.operation_id.clone(),
175                cancellable: active.is_cancellable()
176                    && lifecycle_cancellable(
177                        active.kind,
178                        durable_session_state(controller, session_id),
179                    ),
180                session_id: session_id.clone(),
181                kind: active.kind.into(),
182                started_at_epoch_seconds: active.started_at_epoch_seconds,
183                active_stages: active
184                    .active_stages
185                    .iter()
186                    .map(|(stage, (_, started_at))| (stage.clone(), *started_at))
187                    .collect(),
188                resume_destination: active.resume_destination.clone(),
189                notice: active.notice.clone(),
190            })
191            .collect()
192    }
193
194    /// The lifecycle state of one in-memory record, or `None` when the daemon
195    /// holds no record for it. Reading one field costs one lock rather than a
196    /// clone of every record, which is what a poll wants.
197    pub fn session_state(&self, session_id: &str) -> Option<mj_core::state::SessionState> {
198        let owner = self.owner();
199        if owner.close_requested.contains(session_id) {
200            return Some(SessionState::Closing);
201        }
202        owner
203            .controller()
204            .state
205            .sessions
206            .get(session_id)
207            .map(|record| record.state)
208    }
209
210    /// One in-memory session record, or `None` when the daemon holds none.
211    pub fn session_record(&self, session_id: &str) -> Option<SessionRecord> {
212        self.owner()
213            .controller()
214            .state
215            .sessions
216            .get(session_id)
217            .cloned()
218    }
219
220    pub async fn workspace_session_handle(
221        &self,
222        session_id: &str,
223    ) -> Result<crate::session_manager::ManagedSessionHandle> {
224        let record = self.session_record(session_id).context("unknown session")?;
225        ensure!(
226            record.target.is_some()
227                && record.state == SessionState::Running
228                && !self.close_is_requested(session_id),
229            "session must have a live running target for file injection"
230        );
231        self.session_manager.session(session_id.to_owned()).await
232    }
233
234    /// Checkpoint a session now and publish the result, the way the daemon's
235    /// own checkpoint action does.
236    ///
237    /// The API's bundle export needs a fresh archive for a running session. Only
238    /// that session's own lifecycle operation can conflict with its checkpoint,
239    /// so this refuses when the session itself is mid-operation and returns a
240    /// [`SessionLifecycleBusy`] the export path can fall back on. It must not
241    /// take the process-wide lifecycle guard: that rejected every export while
242    /// any unrelated session anywhere was mid-lifecycle (#1010).
243    pub async fn checkpoint_session_now(
244        &self,
245        session_id: &str,
246    ) -> Result<mj_core::state::CheckpointMetadata> {
247        let _upgrade_work = crate::upgrade::activity("requested checkpoint")?;
248        if let Some(busy) = self.session_lifecycle_busy(session_id) {
249            return Err(anyhow::Error::new(busy));
250        }
251        let session_id = session_id.to_owned();
252        // Checkpoint capture and cleanup run synchronous filesystem, database
253        // and SSH operations between relay awaits. Keep the whole future,
254        // including its destructors, off the daemon's event loop.
255        let checkpoint = blocking(move || {
256            let mut controller = Controller::load()?;
257            mj_core::runtime::block_on(controller.checkpoint_session(&session_id))?
258        })
259        .await?;
260        refresh_runtime_controller(self).await;
261        Ok(checkpoint)
262    }
263
264    /// The lifecycle operation this specific session is running, if any, named
265    /// along with its age. A checkpoint conflicts only with its own session's
266    /// operations, never with another session's (#1010).
267    pub(super) fn session_lifecycle_busy(&self, session_id: &str) -> Option<SessionLifecycleBusy> {
268        let lifecycle_owner = self.owner();
269        let lifecycle = &lifecycle_owner.lifecycle;
270        let active = lifecycle.get(session_id)?;
271        active
272            .is_running()
273            .then(|| describe_lifecycle_busy(session_id, active))
274    }
275
276    /// Any session's running lifecycle operation, named the same way. The
277    /// config-rename guard reports this, so its refusal says which operation
278    /// stands in the way rather than only that one does (#1010).
279    pub(super) fn any_lifecycle_busy(&self) -> Option<SessionLifecycleBusy> {
280        self.owner()
281            .lifecycle
282            .iter()
283            .find(|(_, active)| active.is_running())
284            .map(|(session_id, active)| describe_lifecycle_busy(session_id, active))
285    }
286
287    /// In-memory records and ownership sampled with the same lock order as
288    /// completion. A web publish must not pair old records with a new absence
289    /// of ownership, even while its background database reload is in flight.
290    pub fn session_projection(
291        &self,
292    ) -> (
293        mj_core::snapshot_map::SnapshotMap<String, SessionRecord>,
294        Vec<RuntimeLifecycleView>,
295    ) {
296        let controller_owner = self.owner();
297        let operations = Self::active_lifecycles_with(&controller_owner);
298        (controller_owner.projected_records(), operations)
299    }
300
301    /// What the resume dialog lists: every inactive session that is not a
302    /// sub-agent, with the import de-duplication data from every record.
303    /// Only shared map handles cross the owner lock; the copies are made after.
304    pub(super) fn resume_candidates(&self) -> mj_client::daemon::ResumeCandidates {
305        let (records, subagents, moves, config, local_checkout_roots) = {
306            let owner = self.owner();
307            let controller = owner.controller();
308            let records = owner.projected_records();
309            let local_checkout_roots = records
310                .iter()
311                .filter_map(|(id, _)| match controller.state.checkout(id).ok()? {
312                    mj_core::state::Checkout::ManagedWorktree { worktree, .. }
313                        if worktree.target == mj_core::state::ManagedWorktreeTarget::Local =>
314                    {
315                        Some(worktree.worktree_root.clone())
316                    }
317                    _ => None,
318                })
319                .collect::<Vec<_>>();
320            (
321                records,
322                controller.state.subagents.clone(),
323                owner
324                    .committed()
325                    .map(|committed| committed.moves.clone())
326                    .unwrap_or_default(),
327                controller.config.clone(),
328                local_checkout_roots,
329            )
330        };
331        let mut candidates = mj_client::daemon::ResumeCandidates {
332            local_checkout_roots,
333            ..mj_client::daemon::ResumeCandidates::default()
334        };
335        for (id, record) in &records {
336            if let Some(native_session_id) = &record.native_session_id {
337                candidates
338                    .adopted_native_sessions
339                    .push((record.harness_kind, native_session_id.clone()));
340            }
341            if record.state.is_active()
342                || subagents.contains_key(id)
343                || mj_core::native_agent::is_view_id(id)
344            {
345                continue;
346            }
347            if let Some(operation) = moves.get(id) {
348                candidates.moves.push(operation.clone());
349            }
350            candidates
351                .candidates
352                .push(mj_client::daemon::ResumeCandidate::of(record, &config));
353        }
354        candidates
355    }
356
357    /// The session `mj go` opens in `workspace_id`: `last_session_id` while it
358    /// is still eligible, otherwise the most recently updated eligible one.
359    pub(super) fn go_startup_session(
360        &self,
361        workspace_id: &str,
362        last_session_id: Option<&str>,
363    ) -> Option<SessionRecord> {
364        let (records, subagents) = {
365            let owner = self.owner();
366            (
367                owner.projected_records(),
368                owner.controller().state.subagents.clone(),
369            )
370        };
371        let eligible = |session: &&SessionRecord| {
372            session.workspace_id == workspace_id
373                && !session.archived
374                && !subagents.contains_key(&session.id)
375                && !mj_core::native_agent::is_view_id(&session.id)
376                && session.state != SessionState::DestroyedWithDataLoss
377        };
378        last_session_id
379            .and_then(|id| records.get(id))
380            .filter(eligible)
381            .or_else(|| {
382                records
383                    .values()
384                    .filter(eligible)
385                    .max_by_key(|session| &session.updated_at)
386            })
387            .cloned()
388    }
389
390    pub(crate) fn worker_controller_projection(&self) -> Controller {
391        self.owner().pollable_worker_inputs().controller()
392    }
393
394    pub(crate) fn controller_projection(&self) -> Controller {
395        let owner = self.owner();
396        let mut state = owner.controller().state.clone();
397        state.sessions = owner.projected_records();
398        Controller {
399            config: owner.controller().config.clone(),
400            state,
401        }
402    }
403
404    pub(crate) fn active_controller_projection(&self) -> Controller {
405        let owner = self.owner();
406        Controller {
407            config: owner.controller().config.clone(),
408            state: mj_core::state::State {
409                sessions: owner
410                    .indexes
411                    .active
412                    .keys()
413                    .filter_map(|id| {
414                        owner
415                            .controller()
416                            .state
417                            .sessions
418                            .get(id)
419                            .map(|record| (id.clone(), record.clone()))
420                    })
421                    .collect(),
422                ..Default::default()
423            },
424        }
425    }
426
427    pub(super) fn set_lifecycle_resume_destination(
428        &self,
429        session_id: &str,
430        profile_id: String,
431        target_id: String,
432    ) {
433        if let Some(active) = self.owner().lifecycle.get_mut(session_id) {
434            active.resume_destination = Some((profile_id, target_id));
435            self.publish_revision();
436        }
437    }
438
439    pub(super) fn change_lifecycle_stage(
440        &self,
441        session_id: &str,
442        operation_id: &str,
443        stage: ProvisionStage,
444        active: bool,
445    ) {
446        let changed = {
447            let mut lifecycle_owner = self.owner();
448            let lifecycle = &mut lifecycle_owner.lifecycle;
449            let Some(operation) = lifecycle.get_mut(session_id) else {
450                return;
451            };
452            if operation.operation_id != operation_id || !operation.is_running() {
453                return;
454            }
455            if active {
456                let started_at = stage
457                    .started_at_epoch_seconds()
458                    .unwrap_or_else(epoch_seconds);
459                let entry = operation
460                    .active_stages
461                    .entry(stage)
462                    .or_insert_with(|| (0, started_at));
463                entry.0 += 1;
464                entry.0 == 1
465            } else {
466                let Some((count, _)) = operation.active_stages.get_mut(&stage) else {
467                    return;
468                };
469                *count -= 1;
470                if *count == 0 {
471                    operation.active_stages.remove(&stage);
472                    true
473                } else {
474                    false
475                }
476            }
477        };
478        if changed {
479            self.publish_revision();
480        }
481    }
482
483    /// Record something the daemon did on its own, for every attached surface
484    /// to report once.
485    pub(crate) fn push_notice(&self, session_id: &str, text: impl Into<String>) {
486        const RETAINED_NOTICES: usize = 32;
487
488        let notice = RuntimeNotice {
489            id: self.next_notice_id.fetch_add(1, Ordering::AcqRel),
490            session_id: session_id.to_owned(),
491            text: text.into(),
492        };
493        {
494            let mut notices = self.notices.lock().unwrap_or_else(PoisonError::into_inner);
495            notices.push_back(notice);
496            while notices.len() > RETAINED_NOTICES {
497                notices.pop_front();
498            }
499        }
500        self.publish_revision();
501    }
502
503    /// Connect the quota poller's wake-up channel. The poller is the daemon's
504    /// only prober; a surface that wants a fresh reading asks the daemon, and
505    /// this is how the ask reaches the poller.
506    pub(crate) fn attach_quota_refresh(&self, refresh: tokio::sync::mpsc::Sender<()>) {
507        self.quota
508            .lock()
509            .unwrap_or_else(PoisonError::into_inner)
510            .refresh = Some(refresh);
511    }
512
513    /// Ask the quota poller to probe now. Requests that arrive while one is
514    /// already waiting are one request.
515    pub(crate) fn request_quota_refresh(&self) -> Result<()> {
516        let quota = self.quota.lock().unwrap_or_else(PoisonError::into_inner);
517        let refresh = quota
518            .refresh
519            .as_ref()
520            .context("the daemon's quota service is not running")?;
521        // A full channel means a refresh is already queued.
522        let _ = refresh.try_send(());
523        Ok(())
524    }
525
526    /// Replace what the daemon says about quota, and wake every attached
527    /// surface when it changed.
528    pub(crate) fn publish_quotas(&self, snapshot: mj_client::quota::QuotaSnapshot) {
529        {
530            let mut quota = self.quota.lock().unwrap_or_else(PoisonError::into_inner);
531            if quota.snapshot == snapshot {
532                return;
533            }
534            quota.snapshot = snapshot;
535        }
536        self.publish_revision();
537    }
538
539    pub(super) fn reserve_move_destination(&self, session_id: &str, operation_id: &str) {
540        let mut owner = self.owner();
541        if let Some(active) = owner.lifecycle.get_mut(session_id)
542            && active.operation_id == operation_id
543            && active.kind == LifecycleKind::Move
544        {
545            active.phase = match active.phase {
546                LifecyclePhase::Executing => LifecyclePhase::MovingDestination,
547                LifecyclePhase::Cancelling => LifecyclePhase::CancellingMoveDestination,
548                _ => return,
549            };
550            self.publish_revision();
551        }
552    }
553
554    pub(super) fn set_lifecycle_notice(&self, session_id: &str, operation_id: &str, notice: &str) {
555        if let Some(active) = self.owner().lifecycle.get_mut(session_id)
556            && active.operation_id == operation_id
557            && active.is_running()
558        {
559            active.notice = Some(notice.to_owned());
560            self.publish_revision();
561        }
562    }
563
564    fn cancel_operation(&self, session_id: &str, operation_id: &str) {
565        if let Some(active) = self.owner().lifecycle.get_mut(session_id)
566            && active.operation_id == operation_id
567            && active.request_cancel()
568        {
569            self.publish_revision();
570        }
571    }
572}