Skip to main content

mj_controller/daemon/
state.rs

1use super::*;
2
3impl RuntimeState {
4    pub(super) fn new(
5        session_manager: SessionManagerControl,
6        controller: Controller,
7        recovery_observer: RecoveryObserver,
8        worker_upgrade_observer: WorkerUpgradeObserver,
9        workspaces: Vec<WorkspaceRecord>,
10    ) -> Self {
11        Self::new_with_controller_loader(
12            session_manager,
13            controller,
14            recovery_observer,
15            worker_upgrade_observer,
16            workspaces,
17            Controller::load,
18        )
19    }
20
21    pub(super) fn new_with_controller_loader(
22        session_manager: SessionManagerControl,
23        controller: Controller,
24        recovery_observer: RecoveryObserver,
25        worker_upgrade_observer: WorkerUpgradeObserver,
26        workspaces: Vec<WorkspaceRecord>,
27        controller_loader: fn() -> Result<Controller>,
28    ) -> Self {
29        // Revisions are opaque cursors, so give every daemon incarnation a
30        // fresh high-water mark. Clients that survive a daemon restart must
31        // never wait on, or render, a cursor from the previous process as if
32        // it belonged to the new feed.
33        let initial_revision = u64::try_from(chrono::Utc::now().timestamp_micros()).unwrap_or(1);
34        let revisions = RuntimeRevisions::new(initial_revision);
35        let (workspaces_tx, _) = tokio::sync::watch::channel(workspaces);
36        // The host reads `[review]` at each trigger decision. The target
37        // refresher already reloads config.toml every 500 ms and installs the
38        // result here, so arming needs no reload machinery of its own.
39        let review_config = Arc::new(Mutex::new(controller.config.review.clone()));
40        let review_host = TurnReviewHost::spawn_notifying(
41            session_manager.clone(),
42            {
43                let installed = review_config.clone();
44                Arc::new(move || {
45                    installed
46                        .lock()
47                        .unwrap_or_else(PoisonError::into_inner)
48                        .clone()
49                })
50            },
51            revisions.notifier(),
52        );
53        Self {
54            attachments: Mutex::new(BTreeMap::new()),
55            phone_status: Mutex::new(WebViewerStatus::Starting),
56            web_viewer: crate::web_viewer::ViewerControl::new(),
57            ever_attached: AtomicBool::new(false),
58            sessions: Mutex::new(BTreeMap::new()),
59            revisions,
60            workspaces_tx,
61            session_manager,
62            lifecycle: Mutex::new(BTreeMap::new()),
63            close_requested: Mutex::new(BTreeSet::new()),
64            controller: Mutex::new(controller),
65            controller_loader,
66            config_mutation: tokio::sync::Mutex::new(()),
67            recovery_observer,
68            worker_upgrade_observer,
69            notices: Mutex::new(VecDeque::new()),
70            next_notice_id: AtomicU64::new(1),
71            review_config,
72            review_host,
73            wiki: crate::sessionwiki::WikiIndexer::spawn(),
74        }
75    }
76
77    /// The review host, for the surfaces that project and resolve reviews.
78    pub fn review_host(&self) -> &TurnReviewHost {
79        &self.review_host
80    }
81
82    /// The SessionWiki indexer, for the surfaces and jobs that trigger a sync.
83    pub fn wiki(&self) -> &crate::sessionwiki::WikiIndexer {
84        &self.wiki
85    }
86
87    /// Search the user's SessionWiki index, with this daemon's own live
88    /// sessions marked so a surface can resume them instead of restoring them.
89    pub async fn wiki_search(&self, query: String, limit: usize) -> Result<WikiSearchPage> {
90        if crate::sessionwiki::sync_is_stale(self.wiki.last_success()) {
91            // Fresh enough matters less than answering now: the sync runs in
92            // the background and the next keystroke sees its result.
93            self.wiki.request_sync(false);
94        }
95        let live = self.live_session_ids();
96        let rows = blocking(move || crate::sessionwiki::query_rows(&query, limit, &live)).await?;
97        // The status is read after the rows, so a sync that finished while the
98        // query ran is reported as finished.
99        Ok(WikiSearchPage {
100            rows,
101            status: self.wiki.status(),
102        })
103    }
104
105    /// The markdown briefing for one indexed session, or `None` when the index
106    /// holds no session with that id.
107    pub async fn wiki_brief(&self, wiki_id: String, max_chars: usize) -> Result<Option<String>> {
108        blocking(move || crate::sessionwiki::brief(&wiki_id, max_chars)).await
109    }
110
111    /// Start a new session carrying a hand-off compacted from an archived one.
112    ///
113    /// The session starts like any other; the hand-off is installed in the
114    /// background once the harness is ready, because building it can take
115    /// several summarizer requests and the caller should not hold a socket
116    /// open for them.
117    /// `None` means the index holds no session with that id.
118    pub async fn restore_wiki_session(
119        self: &Arc<Self>,
120        request: WikiRestoreRequest,
121    ) -> Result<Option<RegisteredSession>> {
122        let wiki_id = request.wiki_id.clone();
123        let Some(archived) =
124            blocking(move || crate::sessionwiki::archived_session(&wiki_id)).await?
125        else {
126            return Ok(None);
127        };
128        let project_directory = request
129            .project_directory
130            .clone()
131            .or_else(|| archived.project_directory.clone())
132            .context(
133                "name a project directory: the archived session's own project is no longer on this machine",
134            )?;
135        let source = project_directory.display().to_string();
136        let bundle_id = blocking(move || {
137            crate::controller::create_bundle_from_sources(&[source])
138                .map(|created| created.bundle_id)
139                .map_err(anyhow::Error::new)
140        })
141        .await
142        .context("find or create a bundle for the restored session's project")?;
143        let registered = self
144            .start_create_session(CreateSessionRequest {
145                create_managed_worktree: None,
146                mjolnir_subagents: None,
147                initial_prompt: None,
148                workspace_id: request.workspace_id,
149                profile_id: request.profile_id,
150                bundle_id,
151                project_directory: Some(project_directory),
152                target_template_id: request.target_template_id,
153                additional_mounts: request.additional_mounts,
154                resource_allocation: request.resource_allocation,
155                title: archived.title.clone(),
156                // The harness names a session after its first message, and the
157                // first message here carries the hidden hand-off. Pinning the
158                // archived session's own title keeps that text out of every
159                // list the session appears in.
160                session_title_override: Some(archived.title.clone()),
161            })
162            .await?;
163        let session_id = registered.session.id.clone();
164        let runtime = Arc::clone(self);
165        let handoff_session = session_id.clone();
166        tokio::spawn(async move {
167            if let Err(error) = runtime
168                .install_archive_handoff(&handoff_session, archived.snapshot)
169                .await
170            {
171                tracing::warn!(
172                    session_id = %handoff_session,
173                    %error,
174                    "could not install the restored archive's hand-off"
175                );
176                runtime.push_notice(
177                    &handoff_session,
178                    format!(
179                        "The restored session started without its archived hand-off: {error:#}"
180                    ),
181                );
182            }
183        });
184        Ok(Some(registered))
185    }
186
187    fn live_session_ids(&self) -> BTreeSet<String> {
188        self.controller
189            .lock()
190            .unwrap_or_else(PoisonError::into_inner)
191            .state
192            .sessions
193            .keys()
194            .cloned()
195            .collect()
196    }
197
198    /// Compact an archived transcript and hand it to the new session's harness
199    /// as hidden context for its first prompt, which is what the cross-harness
200    /// resume does with a checkpoint.
201    async fn install_archive_handoff(
202        &self,
203        session_id: &str,
204        snapshot: mj_core::archive::CanonicalSessionSnapshot,
205    ) -> Result<()> {
206        let handle = self.wait_for_ready_session(session_id).await?;
207        let (config, profile_id) = {
208            let controller = self
209                .controller
210                .lock()
211                .unwrap_or_else(PoisonError::into_inner);
212            let profile_id = controller
213                .state
214                .sessions
215                .get(session_id)
216                .map(|record| record.last_profile.clone());
217            (controller.config.clone(), profile_id)
218        };
219        let context_bytes = crate::handoff::profile_handoff_bytes(
220            profile_id.and_then(|id| config.profiles.get(&id)),
221        );
222        let cancel = CancellationToken::new();
223        let handoff = crate::handoff::build_handoff_context(
224            session_id,
225            &config,
226            &snapshot,
227            context_bytes,
228            &cancel,
229        )
230        .await
231        .context("compact the archived transcript")?;
232        handle
233            .install_prompt_context(format!(
234                "{} {handoff}",
235                crate::compaction::ARCHIVE_HANDOFF_PREAMBLE
236            ))
237            .await
238            .context("install the archived hand-off")?;
239        tracing::info!(
240            session_id,
241            bytes = handoff.len(),
242            "installed the restored archive's hand-off"
243        );
244        Ok(())
245    }
246
247    /// Wait until a just-created session has a harness that can be handed to.
248    async fn wait_for_ready_session(
249        &self,
250        session_id: &str,
251    ) -> Result<crate::session_manager::ManagedSessionHandle> {
252        const POLL: Duration = Duration::from_millis(250);
253        let deadline = tokio::time::Instant::now() + Duration::from_secs(30 * 60);
254        loop {
255            // A session that is coming up moves through Disconnected and
256            // Checkpointing on its way; only a state it cannot leave ends the
257            // wait. This is the same set the API's first-prompt wait accepts.
258            match self.session_state(session_id) {
259                Some(
260                    SessionState::Provisioning
261                    | SessionState::Running
262                    | SessionState::Disconnected
263                    | SessionState::Checkpointing,
264                ) => {}
265                Some(state) => bail!("session {session_id} is {state:?} before its hand-off"),
266                None => bail!("session {session_id} disappeared before its hand-off"),
267            }
268            if let Ok(handle) = self.session_manager.session(session_id).await {
269                let view = handle.view();
270                if view.connected
271                    && view
272                        .snapshot
273                        .is_some_and(|snapshot| snapshot.operational.native_session_is_ready())
274                {
275                    return Ok(handle);
276                }
277            }
278            ensure!(
279                tokio::time::Instant::now() < deadline,
280                "session {session_id} was not ready for its hand-off within 30 minutes"
281            );
282            tokio::time::sleep(POLL).await;
283        }
284    }
285
286    /// React to the durable outcome of one lifecycle operation.
287    ///
288    /// A session that has just reached `Stopped` is checkpointed and torn
289    /// down, so its transcript is complete and ready to index. This is the one
290    /// place the daemon sees every operation's reloaded durable state.
291    pub(super) fn note_lifecycle_outcome(&self, session_id: &str) {
292        let stopped = {
293            let controller = self
294                .controller
295                .lock()
296                .unwrap_or_else(PoisonError::into_inner);
297            durable_session_state(&controller, session_id) == Some(SessionState::Stopped)
298        };
299        if stopped {
300            self.wiki.request_sync(false);
301        }
302    }
303
304    pub fn allocate_revision(&self) -> u64 {
305        self.revisions.allocate()
306    }
307
308    pub(super) fn publish_revision(&self) -> u64 {
309        self.revisions.publish()
310    }
311
312    pub(super) fn attachments(&self) -> std::sync::MutexGuard<'_, BTreeMap<String, Attachment>> {
313        self.attachments
314            .lock()
315            .unwrap_or_else(PoisonError::into_inner)
316    }
317
318    pub(super) fn prune_dead_clients(&self) {
319        self.attachments()
320            .retain(|_, attachment| process_is_alive(attachment.pid));
321    }
322
323    pub(super) fn workspace_has_active_resume(&self, workspace_id: &str) -> bool {
324        self.lifecycle
325            .lock()
326            .unwrap_or_else(PoisonError::into_inner)
327            .values()
328            .any(|active| {
329                active.result.borrow().is_none()
330                    && active.resume_workspace_id.as_deref() == Some(workspace_id)
331            })
332    }
333
334    pub fn publish_web_access(&self, access: crate::server::WebViewerAccess) {
335        use crate::server::WebViewerAccess;
336        let status = match &access {
337            WebViewerAccess::Starting => WebViewerStatus::Starting,
338            WebViewerAccess::Ready {
339                viewer_url,
340                viewer_code,
341                qr_login_url,
342                fallback_reason,
343            } => WebViewerStatus::Ready {
344                viewer_url: viewer_url.clone(),
345                viewer_code: viewer_code.clone(),
346                qr_login_url: qr_login_url.clone(),
347                fallback_reason: fallback_reason.clone(),
348            },
349            WebViewerAccess::Failed {
350                address, message, ..
351            } => WebViewerStatus::Error {
352                message: format!("{message} Address: {address}"),
353            },
354            WebViewerAccess::Unavailable(message) => WebViewerStatus::Error {
355                message: message.clone(),
356            },
357        };
358        self.web_viewer.publish(access);
359        self.set_phone_status(status);
360    }
361
362    pub(super) fn set_phone_status(&self, status: WebViewerStatus) {
363        *self
364            .phone_status
365            .lock()
366            .unwrap_or_else(PoisonError::into_inner) = status;
367    }
368
369    pub(super) fn phone_status(&self) -> WebViewerStatus {
370        self.phone_status
371            .lock()
372            .unwrap_or_else(PoisonError::into_inner)
373            .clone()
374    }
375
376    pub(super) fn workspaces(&self) -> tokio::sync::watch::Receiver<Vec<WorkspaceRecord>> {
377        self.workspaces_tx.subscribe()
378    }
379
380    pub(super) fn worker_poll_exclusion_session_ids(
381        &self,
382        controller: &Controller,
383    ) -> BTreeSet<String> {
384        self.lifecycle
385            .lock()
386            .unwrap_or_else(PoisonError::into_inner)
387            .iter()
388            .filter(|(session_id, active)| {
389                active.result.borrow().is_none()
390                    && (active.move_source_closed
391                        || lifecycle_owns_worker_target(
392                            active.kind,
393                            controller
394                                .state
395                                .sessions
396                                .get(*session_id)
397                                .map(|session| session.state),
398                        ))
399            })
400            .map(|(session_id, _)| session_id.clone())
401            .collect()
402    }
403
404    pub fn revisions(&self) -> tokio::sync::watch::Receiver<u64> {
405        self.revisions.subscribe()
406    }
407
408    /// Read the config the daemon serves right now. A task on a schedule reads
409    /// it again on every tick, so a reload reaches it without a restart.
410    pub fn with_config<T>(&self, read: impl FnOnce(&Config) -> T) -> T {
411        read(
412            &self
413                .controller
414                .lock()
415                .unwrap_or_else(PoisonError::into_inner)
416                .config,
417        )
418    }
419
420    /// Create a bundle under the daemon's config-mutation coordinator. The
421    /// controller helper also takes the cross-process config lock, so a TUI
422    /// transaction cannot race this one while the daemon's other config
423    /// writers are excluded by this mutex.
424    pub async fn create_quick_bundle(
425        &self,
426        source: String,
427    ) -> std::result::Result<
428        crate::controller::QuickBundleCreation,
429        crate::controller::QuickBundleFailure,
430    > {
431        let _mutation = self.config_mutation.lock().await;
432        tokio::task::spawn_blocking(move || crate::controller::create_quick_bundle(&source))
433            .await
434            .map_err(|error| {
435                crate::controller::QuickBundleFailure::Persistence(anyhow!(
436                    "bundle creation task panicked: {error}"
437                ))
438            })?
439    }
440
441    pub(super) fn publish_workspaces(&self, workspaces: Vec<WorkspaceRecord>) {
442        self.workspaces_tx.send_replace(workspaces);
443        self.publish_revision();
444    }
445}