Skip to main content

mj_controller/daemon/
resume.rs

1use super::*;
2
3impl RuntimeState {
4    /// Resume a session, and return nothing.
5    ///
6    /// This used to answer with the whole `MaterializedSession`. That reply
7    /// travels as one JSON frame against `MAX_FRAME_BYTES`, so a session whose
8    /// projection outgrew 8 MiB could not be resumed at all — it built a
9    /// several-hundred-megabyte buffer and then refused to send it. The
10    /// projection is already durable; a viewer reads it from the store.
11    pub async fn resume_session(self: &Arc<Self>, request: ResumeSessionRequest) -> Result<()> {
12        let session_id = request.session_id.clone();
13        self.wait_for_deferred_cleanup(&session_id).await?;
14        // Whether it is already running is a boolean. Answering it used to
15        // load the entire projection so it could be handed back as the reply.
16        let already_running = blocking({
17            let session_id = session_id.clone();
18            move || {
19                let controller = Controller::load()?;
20                Ok(controller
21                    .state
22                    .sessions
23                    .get(&session_id)
24                    .is_some_and(|session| session.state == SessionState::Running))
25            }
26        })
27        .await?;
28        if already_running {
29            return Ok(());
30        }
31        let profile_id = request.profile_id.clone();
32        let target_template_id = request.target_template_id.clone();
33        let workspace_id = request.workspace_id.clone();
34        let rebind_workspace_id = workspace_id.clone();
35        let operation_session_id = session_id.clone();
36        let result = self.start_or_join_lifecycle_for_workspace(
37            session_id,
38            LifecycleKind::Resume,
39            Some(workspace_id),
40            move |state, session_id, cancelled| async move {
41                let _recovery_reservation = tokio::task::spawn_blocking({
42                    let observer = state.recovery_observer.clone();
43                    let session_id = session_id.clone();
44                    let cancelled = cancelled.clone();
45                    move || reserve_recovery_or_cancel(&observer, &session_id, &cancelled)
46                })
47                .await
48                .context("reserve recovery for daemon resume task")??;
49                blocking({
50                    let session_id = session_id.clone();
51                    move || {
52                        crate::database::reassign_resumable_session_workspace(
53                            &session_id,
54                            &rebind_workspace_id,
55                        )
56                    }
57                })
58                .await?;
59                let restore_request = request.clone();
60                let mut controller = tokio::task::spawn_blocking(move || {
61                    session_move::load_controller_for_resume(&restore_request)
62                })
63                .await
64                .context("load controller for daemon resume task")??;
65                let executor = DaemonStageReportingExecutor::new(
66                    CancellableProcessExecutor::new(cancelled),
67                    state.clone(),
68                    session_id.clone(),
69                );
70                let materialized = controller
71                    .resume_session_controlled_with_repository_preflight(
72                        &session_id,
73                        &request.profile_id,
74                        &request.target_template_id,
75                        SessionResumeOptions {
76                            additional_mounts: request.additional_mounts,
77                            resource_allocation: request.resource_allocation,
78                            discard_queue: request.discard_queue,
79                        },
80                        request.repository_preflight,
81                        &executor,
82                    )
83                    .await?;
84                // The projection stays where it was written. A viewer reads
85                // it from the store; shipping it back through the daemon
86                // reply put a whole transcript in one IPC frame.
87                let _ = materialized;
88                Ok(DaemonLifecycleResult::Done)
89            },
90        )?;
91        self.set_lifecycle_resume_destination(
92            &operation_session_id,
93            profile_id,
94            target_template_id,
95        );
96        let channel = result.clone();
97        let result = Self::wait_lifecycle_result(result).await;
98        self.remove_completed_lifecycle(&channel);
99        match result? {
100            DaemonLifecycleResult::Done => {}
101            DaemonLifecycleResult::Move(_) => unreachable!("resume cannot return a move outcome"),
102            DaemonLifecycleResult::DeferredCleanup => {
103                unreachable!("session resume cannot schedule target cleanup")
104            }
105        }
106        blocking(move || {
107            if let Some(mut operation) =
108                crate::database::load_move_operation(&operation_session_id)?
109                && !operation.queue_admission_started
110            {
111                operation.phase = mj_core::state::MovePhase::Cancelled;
112                operation.queue_admission_finished = true;
113                operation.updated_at = chrono::Utc::now().to_rfc3339();
114                operation.error = Some("Recovered through an explicit Resume operation".into());
115                crate::database::save_move_operation(&operation)?;
116            }
117            Ok(())
118        })
119        .await?;
120        Ok(())
121    }
122
123    pub(super) async fn force_stop_session(self: &Arc<Self>, session_id: String) -> Result<()> {
124        let children = blocking({
125            let session_id = session_id.clone();
126            move || {
127                let controller = Controller::load()?;
128                Ok(active_child_session_ids(&controller.state, &session_id))
129            }
130        })
131        .await?;
132        for child_id in children {
133            Box::pin(self.force_stop_session(child_id.clone()))
134                .await
135                .with_context(|| format!("force-stop sub-agent {child_id} before its parent"))?;
136        }
137        let operation_session_id = session_id.clone();
138        let result = self
139            .run_lifecycle(
140                operation_session_id,
141                LifecycleKind::ForceStop,
142                |state, session_id, cancelled| async move {
143                    blocking(move || {
144                        let mut controller = Controller::load()?;
145                        let executor = DaemonStageReportingExecutor::new(
146                            CancellableProcessExecutor::new(cancelled),
147                            state,
148                            session_id.clone(),
149                        );
150                        let deferred = controller.force_stop(&session_id, &executor)?;
151                        Ok(if deferred {
152                            DaemonLifecycleResult::DeferredCleanup
153                        } else {
154                            DaemonLifecycleResult::Done
155                        })
156                    })
157                    .await
158                },
159            )
160            .await?;
161        let _ = result; // The lifecycle supervisor owns the cleanup handoff.
162        Ok(())
163    }
164
165    pub(super) async fn destroy_stopped_session(
166        self: &Arc<Self>,
167        session_id: String,
168    ) -> Result<()> {
169        self.tear_down_stopped_session(
170            session_id,
171            LifecycleKind::DestroyStopped,
172            BranchDisposition::Delete,
173        )
174        .await
175    }
176
177    /// Archive every stopped session older than `older_than_days` whose
178    /// conversation SessionWiki holds. Answers with how many were archived.
179    ///
180    /// The whole job belongs in a background task: it runs a full index sync,
181    /// which walks every tool's store, and then one lifecycle per session.
182    pub(crate) async fn archive_aged_sessions(
183        self: &Arc<Self>,
184        older_than_days: u32,
185    ) -> Result<usize> {
186        self.wiki()
187            .sync_now(true)
188            .await
189            .context("sync SessionWiki before archiving stopped sessions")?;
190        let candidates = blocking(move || {
191            let controller = Controller::load()?;
192            Ok(crate::sessionwiki::sessions_ready_to_archive(
193                &controller.state.sessions,
194                &controller.state.subagents,
195                chrono::Utc::now(),
196                older_than_days,
197            ))
198        })
199        .await
200        .context("select the stopped sessions old enough to archive")?;
201        if candidates.is_empty() {
202            return Ok(0);
203        }
204        let indexed = blocking({
205            let candidates = candidates.clone();
206            move || crate::sessionwiki::indexed_with_messages(&candidates)
207        })
208        .await
209        .context("check the SessionWiki index before archiving")?;
210        let mut archived = 0;
211        for session_id in candidates {
212            if !indexed.contains(&session_id) {
213                tracing::warn!(
214                    %session_id,
215                    "SessionWiki holds no conversation for this stopped session; keeping it"
216                );
217                continue;
218            }
219            match self.archive_stopped_session(session_id.clone()).await {
220                Ok(()) => {
221                    archived += 1;
222                    tracing::info!(
223                        %session_id,
224                        older_than_days,
225                        "archived a stopped session: SessionWiki keeps the conversation and the repository keeps the branch"
226                    );
227                }
228                Err(error) => tracing::warn!(
229                    %session_id,
230                    error = %format!("{error:#}"),
231                    "could not archive a stopped session"
232                ),
233            }
234        }
235        if archived > 0 {
236            // Flip the rows the job just emptied to archived.
237            self.wiki().request_sync(false);
238        }
239        Ok(archived)
240    }
241
242    /// Destroy a stopped session the way the archive job wants: the record,
243    /// the checkpoint, and the attachments go, and the session's git branch
244    /// stays in the repository. The conversation itself stays searchable, and
245    /// restorable, through SessionWiki.
246    async fn archive_stopped_session(self: &Arc<Self>, session_id: String) -> Result<()> {
247        self.tear_down_stopped_session(
248            session_id,
249            LifecycleKind::ArchiveStopped,
250            BranchDisposition::Keep,
251        )
252        .await
253    }
254
255    async fn tear_down_stopped_session(
256        self: &Arc<Self>,
257        session_id: String,
258        kind: LifecycleKind,
259        branch: BranchDisposition,
260    ) -> Result<()> {
261        let children = blocking({
262            let session_id = session_id.clone();
263            move || {
264                Ok(crate::database::list_subagents(&session_id)?
265                    .into_iter()
266                    .map(|child| child.child_session_id)
267                    .collect::<Vec<_>>())
268            }
269        })
270        .await?;
271        for child_id in children {
272            Box::pin(self.force_destroy_session(child_id.clone()))
273                .await
274                .with_context(|| format!("destroy sub-agent {child_id} before its parent"))?;
275        }
276        self.wait_for_deferred_cleanup(&session_id).await?;
277        let exists = blocking({
278            let session_id = session_id.clone();
279            move || Ok(Controller::load()?.state.sessions.contains_key(&session_id))
280        })
281        .await?;
282        if !exists {
283            return Ok(());
284        }
285        self.run_lifecycle(
286            session_id,
287            kind,
288            move |state, session_id, cancelled| async move {
289                blocking(move || {
290                    let mut controller = Controller::load()?;
291                    let executor = DaemonStageReportingExecutor::new(
292                        CancellableProcessExecutor::new(cancelled),
293                        state,
294                        session_id.clone(),
295                    );
296                    controller.destroy_session_controlled_with(&session_id, &executor, branch)?;
297                    Ok(DaemonLifecycleResult::Done)
298                })
299                .await
300            },
301        )
302        .await?;
303        Ok(())
304    }
305
306    /// Cancel any in-flight lifecycle for `session_id` and wait for it to
307    /// finish.
308    ///
309    /// Force destruction is the escape hatch for a wedged operation, so it
310    /// takes over rather than queueing behind one — but only after the running
311    /// task has stopped, because a cancelled create or close re-persists its
312    /// record as it unwinds and would otherwise resurrect the row this
313    /// operation deletes. A lifecycle that ignores cancellation for longer
314    /// than [`FORCE_DESTROY_PREEMPT_TIMEOUT`] is reported instead of destroyed
315    /// under.
316    pub(super) async fn preempt_active_lifecycle(self: &Arc<Self>, session_id: &str) -> Result<()> {
317        let mut result = {
318            let lifecycle = self
319                .lifecycle
320                .lock()
321                .unwrap_or_else(PoisonError::into_inner);
322            let Some(active) = lifecycle.get(session_id) else {
323                return Ok(());
324            };
325            if !active.result.borrow().is_none() {
326                return Ok(());
327            }
328            active.request_cancel();
329            active.result.clone()
330        };
331        let finished = tokio::time::timeout(FORCE_DESTROY_PREEMPT_TIMEOUT, async {
332            loop {
333                if result.borrow().is_some() {
334                    return Ok(());
335                }
336                if result.changed().await.is_err() {
337                    return Err(());
338                }
339            }
340        })
341        .await;
342        match finished {
343            // The loop only returns once the watch holds a result or its
344            // sender died; distinguish those two, and the timeout separately.
345            Ok(Ok(())) => Ok(()),
346            Ok(Err(())) => bail!(
347                "daemon lifecycle operation stopped without a result for session {session_id}"
348            ),
349            Err(_) => bail!(
350                "session {session_id} still has an operation that did not stop after cancellation; try again"
351            ),
352        }
353    }
354
355    /// Permanently destroy a session from any state, cancelling whatever
356    /// lifecycle operation holds it first. Data loss is the caller's confirmed
357    /// decision; see [`Controller::force_destroy_session`].
358    pub async fn force_destroy_session(self: &Arc<Self>, session_id: String) -> Result<()> {
359        let children = blocking({
360            let session_id = session_id.clone();
361            move || {
362                Ok(crate::database::list_subagents(&session_id)?
363                    .into_iter()
364                    .map(|child| child.child_session_id)
365                    .collect::<Vec<_>>())
366            }
367        })
368        .await?;
369        for child_id in children {
370            Box::pin(self.force_destroy_session(child_id.clone()))
371                .await
372                .with_context(|| format!("destroy sub-agent {child_id} before its parent"))?;
373        }
374        self.preempt_active_lifecycle(&session_id).await?;
375        let exists = blocking({
376            let session_id = session_id.clone();
377            move || Ok(Controller::load()?.state.sessions.contains_key(&session_id))
378        })
379        .await?;
380        if !exists {
381            return Ok(());
382        }
383        self.run_lifecycle(
384            session_id,
385            LifecycleKind::ForceDestroy,
386            |state, session_id, cancelled| async move {
387                let _recovery_reservation = tokio::task::spawn_blocking({
388                    let observer = state.recovery_observer.clone();
389                    let session_id = session_id.clone();
390                    let cancelled = cancelled.clone();
391                    move || reserve_recovery_or_cancel(&observer, &session_id, &cancelled)
392                })
393                .await
394                .context("reserve recovery for daemon force-destroy task")??;
395                blocking({
396                    let session_id = session_id.clone();
397                    move || {
398                        let mut controller = Controller::load()?;
399                        let executor = DaemonStageReportingExecutor::new(
400                            CancellableProcessExecutor::new(cancelled),
401                            state,
402                            session_id.clone(),
403                        );
404                        controller.force_destroy_session(&session_id, &executor)?;
405                        crate::controller::move_session::release_move_queue_hold(&session_id);
406                        Ok(DaemonLifecycleResult::Done)
407                    }
408                })
409                .await
410            },
411        )
412        .await?;
413        Ok(())
414    }
415
416    /// Force-delete a workspace: destroy every active session in it (see
417    /// [`RuntimeState::force_destroy_session`]), drop its detached drafts, and
418    /// remove the workspace row. Stopped histories stay globally resumable.
419    ///
420    /// In-flight resumes into the workspace still refuse the deletion because
421    /// they have not yet claimed a durable session workspace. A session that
422    /// fails to destroy stops the sequence with the remainder named, so the
423    /// operation can be retried without losing progress.
424    pub async fn force_delete_workspace(self: &Arc<Self>, workspace_id: String) -> Result<()> {
425        ensure!(
426            !self.workspace_has_active_resume(&workspace_id),
427            "workspace has a session resume in progress"
428        );
429        let sessions = blocking({
430            let workspace_id = workspace_id.clone();
431            move || {
432                let controller = Controller::load()?;
433                Ok(active_sessions_for_force_destruction(
434                    &controller,
435                    &workspace_id,
436                ))
437            }
438        })
439        .await?;
440        for (index, session_id) in sessions.iter().enumerate() {
441            if let Err(error) = self.force_destroy_session(session_id.clone()).await {
442                let remaining = sessions.len() - index - 1;
443                bail!(
444                    "force-destroying session {session_id} failed: {error:#}; \
445                     {remaining} session(s) in the workspace remain"
446                );
447            }
448        }
449        blocking({
450            let workspace_id = workspace_id.clone();
451            move || crate::database::force_delete_workspace(&workspace_id)
452        })
453        .await?;
454        refresh_runtime_workspaces(self).await?;
455        Ok(())
456    }
457}