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        branch: BranchDisposition,
169    ) -> Result<()> {
170        self.tear_down_stopped_session(session_id, LifecycleKind::DestroyStopped, branch)
171            .await
172    }
173
174    /// Archive every stopped session older than `older_than_days` whose
175    /// conversation SessionWiki holds. Answers with how many were archived.
176    ///
177    /// The whole job belongs in a background task: it runs a full index sync,
178    /// which walks every tool's store, and then one lifecycle per session.
179    pub(crate) async fn archive_aged_sessions(
180        self: &Arc<Self>,
181        older_than_days: u32,
182    ) -> Result<usize> {
183        self.wiki()
184            .sync_now(true)
185            .await
186            .context("sync SessionWiki before archiving stopped sessions")?;
187        let candidates = blocking(move || {
188            let controller = Controller::load()?;
189            Ok(crate::sessionwiki::sessions_ready_to_archive(
190                &controller.state.sessions,
191                &controller.state.subagents,
192                chrono::Utc::now(),
193                older_than_days,
194            ))
195        })
196        .await
197        .context("select the stopped sessions old enough to archive")?;
198        if candidates.is_empty() {
199            return Ok(0);
200        }
201        let indexed = blocking({
202            let candidates = candidates.clone();
203            move || crate::sessionwiki::indexed_with_messages(&candidates)
204        })
205        .await
206        .context("check the SessionWiki index before archiving")?;
207        let mut archived = 0;
208        for session_id in candidates {
209            if !indexed.contains(&session_id) {
210                tracing::warn!(
211                    %session_id,
212                    "SessionWiki holds no conversation for this stopped session; keeping it"
213                );
214                continue;
215            }
216            match self.archive_stopped_session(session_id.clone()).await {
217                Ok(()) => {
218                    archived += 1;
219                    tracing::info!(
220                        %session_id,
221                        older_than_days,
222                        "archived a stopped session: SessionWiki keeps the conversation, and the repository keeps the branch unless another branch already contains it"
223                    );
224                }
225                Err(error) => tracing::warn!(
226                    %session_id,
227                    error = %format!("{error:#}"),
228                    "could not archive a stopped session"
229                ),
230            }
231        }
232        if archived > 0 {
233            // Flip the rows the job just emptied to archived.
234            self.wiki().request_sync(false);
235        }
236        Ok(archived)
237    }
238
239    /// Destroy a stopped session the way the archive job wants: the record,
240    /// the checkpoint, and the attachments go, and the session's git branch
241    /// goes only when another branch already contains all of its commits.
242    /// The conversation itself stays searchable, and restorable, through
243    /// SessionWiki.
244    async fn archive_stopped_session(self: &Arc<Self>, session_id: String) -> Result<()> {
245        self.tear_down_stopped_session(
246            session_id,
247            LifecycleKind::ArchiveStopped,
248            BranchDisposition::DeleteIfMerged,
249        )
250        .await
251    }
252
253    async fn tear_down_stopped_session(
254        self: &Arc<Self>,
255        session_id: String,
256        kind: LifecycleKind,
257        branch: BranchDisposition,
258    ) -> Result<()> {
259        let children = blocking({
260            let session_id = session_id.clone();
261            move || {
262                Ok(crate::database::list_subagents(&session_id)?
263                    .into_iter()
264                    .map(|child| child.child_session_id)
265                    .collect::<Vec<_>>())
266            }
267        })
268        .await?;
269        for child_id in children {
270            // A sub-agent borrows its parent's worker and never owns a managed
271            // worktree, so it has no branch of its own to keep.
272            Box::pin(self.force_destroy_session(child_id.clone(), BranchDisposition::Keep))
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`]. The session's git
358    /// branch survives unless `branch` says to delete it.
359    pub async fn force_destroy_session(
360        self: &Arc<Self>,
361        session_id: String,
362        branch: BranchDisposition,
363    ) -> Result<()> {
364        let children = blocking({
365            let session_id = session_id.clone();
366            move || {
367                Ok(crate::database::list_subagents(&session_id)?
368                    .into_iter()
369                    .map(|child| child.child_session_id)
370                    .collect::<Vec<_>>())
371            }
372        })
373        .await?;
374        for child_id in children {
375            // Sub-agents borrow their parent's worker and own no branch.
376            Box::pin(self.force_destroy_session(child_id.clone(), BranchDisposition::Keep))
377                .await
378                .with_context(|| format!("destroy sub-agent {child_id} before its parent"))?;
379        }
380        self.preempt_active_lifecycle(&session_id).await?;
381        let exists = blocking({
382            let session_id = session_id.clone();
383            move || Ok(Controller::load()?.state.sessions.contains_key(&session_id))
384        })
385        .await?;
386        if !exists {
387            return Ok(());
388        }
389        self.run_lifecycle(
390            session_id,
391            LifecycleKind::ForceDestroy,
392            move |state, session_id, cancelled| async move {
393                let _recovery_reservation = tokio::task::spawn_blocking({
394                    let observer = state.recovery_observer.clone();
395                    let session_id = session_id.clone();
396                    let cancelled = cancelled.clone();
397                    move || reserve_recovery_or_cancel(&observer, &session_id, &cancelled)
398                })
399                .await
400                .context("reserve recovery for daemon force-destroy task")??;
401                blocking({
402                    let session_id = session_id.clone();
403                    move || {
404                        let mut controller = Controller::load()?;
405                        let executor = DaemonStageReportingExecutor::new(
406                            CancellableProcessExecutor::new(cancelled),
407                            state,
408                            session_id.clone(),
409                        );
410                        controller.force_destroy_session(&session_id, &executor, branch)?;
411                        crate::controller::move_session::release_move_queue_hold(&session_id);
412                        Ok(DaemonLifecycleResult::Done)
413                    }
414                })
415                .await
416            },
417        )
418        .await?;
419        Ok(())
420    }
421
422    /// Force-delete a workspace: destroy every active session in it (see
423    /// [`RuntimeState::force_destroy_session`]), drop its detached drafts, and
424    /// remove the workspace row. Stopped histories stay globally resumable.
425    ///
426    /// In-flight resumes into the workspace still refuse the deletion because
427    /// they have not yet claimed a durable session workspace. A session that
428    /// fails to destroy stops the sequence with the remainder named, so the
429    /// operation can be retried without losing progress.
430    pub async fn force_delete_workspace(self: &Arc<Self>, workspace_id: String) -> Result<()> {
431        ensure!(
432            !self.workspace_has_active_resume(&workspace_id),
433            "workspace has a session resume in progress"
434        );
435        let sessions = blocking({
436            let workspace_id = workspace_id.clone();
437            move || {
438                let controller = Controller::load()?;
439                Ok(active_sessions_for_force_destruction(
440                    &controller,
441                    &workspace_id,
442                ))
443            }
444        })
445        .await?;
446        for (index, session_id) in sessions.iter().enumerate() {
447            // Deleting a workspace removes Mjolnir's own copies, not the
448            // user's work: the branches stay in their source repositories.
449            if let Err(error) = self
450                .force_destroy_session(session_id.clone(), BranchDisposition::Keep)
451                .await
452            {
453                let remaining = sessions.len() - index - 1;
454                bail!(
455                    "force-destroying session {session_id} failed: {error:#}; \
456                     {remaining} session(s) in the workspace remain"
457                );
458            }
459        }
460        blocking({
461            let workspace_id = workspace_id.clone();
462            move || crate::database::force_delete_workspace(&workspace_id)
463        })
464        .await?;
465        refresh_runtime_workspaces(self).await?;
466        Ok(())
467    }
468}