Skip to main content

yapi_core/agent_session/
recovery.rs

1//! What happens after a run: automatic retries, overflow recovery and
2//! compaction, and manual compaction.
3
4use std::sync::atomic::Ordering;
5
6use tokio_util::sync::CancellationToken;
7use yapi_ai::errors::{is_context_overflow, is_recoverable_length, is_retryable_assistant_error};
8use yapi_types::event::AgentEvent;
9use yapi_types::event::{CompactionReason, CompactionResult, SummarySource};
10use yapi_types::extension_event::ExtensionEvent;
11use yapi_types::message::{AssistantMessage, Message, StopReason, ThinkingLevel};
12use yapi_types::model::Model;
13use yapi_types::session::FileEntry;
14use yapi_types::sync::lock;
15
16use crate::compaction::{
17    CompactionSettings, Preparation, RetryPolicy, Summarizer, SummaryRetry,
18    calculate_context_tokens, estimate_context_tokens, estimate_projected_context_tokens,
19    estimate_tokens, prepare_compaction, should_compact,
20};
21use crate::session::build_projection;
22use crate::time::parse_iso;
23
24use super::{AgentSession, Compacting};
25
26/// What the compaction check decided.
27enum CompactionCheck {
28    None,
29    Overflow { will_retry: bool },
30    OverflowFailed(String),
31    Threshold,
32}
33
34impl AgentSession {
35    /// Cancels the wait before an automatic retry. The failed response stands.
36    pub fn abort_retry(&self) {
37        let cancel = lock(&self.inner.retry_cancel).clone();
38        if let Some(cancel) = cancel {
39            // The retry's end is reported before this returns, as pi reports
40            // it before answering `abort_retry`. The waiting retry then finds
41            // nothing left to report.
42            self.finish_cancelled_retry();
43            cancel.cancel();
44        }
45    }
46
47    /// The model whose limits apply to a response: the current one when it made it.
48    fn model_for_message(&self, message: &AssistantMessage) -> Option<Model> {
49        self.model()
50            .filter(|model| model.is(&message.provider, &message.model))
51    }
52
53    fn is_retryable(&self, message: &AssistantMessage) -> bool {
54        let window = self
55            .model_for_message(message)
56            .or_else(|| self.model())
57            .map_or(0, |model| model.context_window);
58        !is_context_overflow(message, window) && is_retryable_assistant_error(message)
59    }
60
61    fn retry_policy(&self) -> RetryPolicy {
62        RetryPolicy::resolve(lock(&self.inner.settings).settings())
63    }
64
65    /// Whether the finished run will be retried, for `agent_end`.
66    pub(super) fn will_retry_after(&self, messages: &[Message]) -> bool {
67        let cancelled = lock(&self.inner.cancel)
68            .as_ref()
69            .is_some_and(CancellationToken::is_cancelled);
70        let policy = self.retry_policy();
71        if cancelled
72            || !policy.enabled
73            || lock(&self.inner.recovery).retry_attempt >= policy.max_retries
74        {
75            return false;
76        }
77        messages
78            .iter()
79            .rev()
80            .find_map(|message| match message {
81                Message::Assistant(assistant) => Some(self.is_retryable(assistant)),
82                _ => None,
83            })
84            .unwrap_or(false)
85    }
86
87    pub(super) fn finish_cancelled_retry(&self) {
88        let attempt = std::mem::take(&mut lock(&self.inner.recovery).retry_attempt);
89        if attempt > 0 {
90            self.emit(&AgentEvent::AutoRetryEnd {
91                success: false,
92                attempt,
93                final_error: Some("Retry cancelled".into()),
94            });
95        }
96    }
97
98    /// Drops a failed or truncated attempt from the model's context while keeping
99    /// it in the session history.
100    fn omit_recovery_attempt(&self, entry_ids: &[String]) {
101        for id in entry_ids {
102            let entry = self.with_session(|session| {
103                let edit = session.append_context_edit(id, None).ok()?;
104                session.entry(&edit).cloned()
105            });
106            if let Some(entry) = entry {
107                self.emit(&AgentEvent::EntryAppended { entry });
108            }
109        }
110    }
111
112    async fn prepare_retry(
113        &self,
114        message: &AssistantMessage,
115        entry_id: Option<String>,
116        cancel: &CancellationToken,
117    ) -> bool {
118        let policy = self.retry_policy();
119        if !policy.enabled {
120            return false;
121        }
122        let attempt = {
123            let mut recovery = lock(&self.inner.recovery);
124            if recovery.retry_attempt >= policy.max_retries {
125                return false;
126            }
127            recovery.retry_attempt += 1;
128            recovery.retry_attempt
129        };
130        let delay_ms = policy.delay_ms(attempt);
131        self.emit(&AgentEvent::AutoRetryStart {
132            attempt,
133            max_attempts: policy.max_retries,
134            delay_ms,
135            error_message: message
136                .error_message
137                .clone()
138                .filter(|m| !m.is_empty())
139                .unwrap_or_else(|| "Unknown error".into()),
140        });
141        self.omit_recovery_attempt(&entry_id.into_iter().collect::<Vec<_>>());
142        let retry = cancel.child_token();
143        *lock(&self.inner.retry_cancel) = Some(retry.clone());
144        let waited = tokio::select! {
145            () = retry.cancelled() => {
146                self.finish_cancelled_retry();
147                false
148            }
149            () = tokio::time::sleep(std::time::Duration::from_millis(delay_ms)) => true,
150        };
151        *lock(&self.inner.retry_cancel) = None;
152        waited
153    }
154
155    /// pi's post-run step: whether to run the agent again, or the error of
156    /// an invalid compaction setting, which ends the prompt.
157    pub(super) async fn handle_post_run(&self, cancel: &CancellationToken) -> Result<bool, String> {
158        let (last, tool_results) = {
159            let mut recovery = lock(&self.inner.recovery);
160            (
161                recovery.last_assistant.take(),
162                std::mem::take(&mut recovery.last_tool_results),
163            )
164        };
165        if cancel.is_cancelled() {
166            self.finish_cancelled_retry();
167            return Ok(false);
168        }
169        let Some((message, entry_id)) = last else {
170            return Ok(self.has_queued());
171        };
172        if self.is_retryable(&message)
173            && self.prepare_retry(&message, entry_id.clone(), cancel).await
174        {
175            return Ok(!cancel.is_cancelled());
176        }
177        if cancel.is_cancelled() {
178            self.finish_cancelled_retry();
179            return Ok(false);
180        }
181        if message.stop_reason == StopReason::Error {
182            let attempt = std::mem::take(&mut lock(&self.inner.recovery).retry_attempt);
183            if attempt > 0 {
184                self.emit(&AgentEvent::AutoRetryEnd {
185                    success: false,
186                    attempt,
187                    final_error: message.error_message.clone(),
188                });
189            }
190        }
191        if self
192            .check_compaction(&message, entry_id, tool_results, cancel, true)
193            .await?
194        {
195            return Ok(!cancel.is_cancelled());
196        }
197        Ok(!cancel.is_cancelled() && self.has_queued())
198    }
199
200    /// pi's check before a prompt is sent: [`Self::check_compaction`] for
201    /// the context's last response, aborted ones included. It catches
202    /// aborted responses and sessions resumed above the threshold. An abort
203    /// stops the compaction, not the prompt. An invalid compaction setting
204    /// fails the prompt with its error, as pi's check throws.
205    pub(super) async fn compact_before_prompt(&self) -> Result<(), String> {
206        let last = self.with_session(|session| {
207            let branch = session.branch_path(None);
208            build_projection(&branch)
209                .entries
210                .iter()
211                .rev()
212                .find_map(|entry| {
213                    let message =
214                        entry
215                            .messages
216                            .iter()
217                            .rev()
218                            .find_map(|message| match message {
219                                Message::Assistant(assistant) => Some((**assistant).clone()),
220                                _ => None,
221                            })?;
222                    Some((message, entry.source.meta().map(|meta| meta.id.clone())))
223                })
224        });
225        let Some((message, entry_id)) = last else {
226            return Ok(());
227        };
228        let cancel = CancellationToken::new();
229        *lock(&self.inner.cancel) = Some(cancel.clone());
230        let checked = self
231            .check_compaction(&message, entry_id, Vec::new(), &cancel, false)
232            .await;
233        *lock(&self.inner.cancel) = None;
234        checked.map(drop)
235    }
236
237    /// pi's `_compactBeforeNextAssistantResponse`, before each turn of a run
238    /// after the first: threshold compaction when the context the next
239    /// request would send exceeds the threshold. An invalid compaction
240    /// setting is the error that ends the run, as pi's hook throws.
241    pub(super) async fn compact_before_next_response(
242        &self,
243        cancel: &CancellationToken,
244    ) -> Result<(), String> {
245        let Some(model) = self.model().filter(|model| model.context_window > 0) else {
246            return Ok(());
247        };
248        if let Some(error) = lock(&self.inner.settings).compaction_error(Some(&model)) {
249            return Err(error);
250        }
251        let settings =
252            CompactionSettings::resolve(lock(&self.inner.settings).settings(), Some(&model));
253        let tokens = self.with_session(|session| {
254            let branch = session.branch_path(None);
255            estimate_projected_context_tokens(&build_projection(&branch), &branch).tokens
256        });
257        if should_compact(tokens, model.context_window, &settings) {
258            self.run_auto_compaction(CompactionReason::Threshold, false, cancel)
259                .await;
260        }
261        Ok(())
262    }
263
264    /// pi's `_checkCompaction`: overflow recovery, then threshold compaction,
265    /// or the error of an invalid compaction setting. An aborted `message` is
266    /// skipped when `skip_aborted`.
267    async fn check_compaction(
268        &self,
269        message: &AssistantMessage,
270        entry_id: Option<String>,
271        tool_results: Vec<String>,
272        cancel: &CancellationToken,
273        skip_aborted: bool,
274    ) -> Result<bool, String> {
275        let model = self.model();
276        if let Some(error) = lock(&self.inner.settings).compaction_error(model.as_ref()) {
277            return Err(error);
278        }
279        let Some(model) = model else {
280            return Ok(false);
281        };
282        let settings =
283            CompactionSettings::resolve(lock(&self.inner.settings).settings(), Some(&model));
284        if !settings.enabled || (skip_aborted && message.stop_reason == StopReason::Aborted) {
285            return Ok(false);
286        }
287        let message_model = self.model_for_message(message);
288        let context_window = message_model.as_ref().unwrap_or(&model).context_window;
289        let overflow_attempted = lock(&self.inner.recovery).overflow_recovery_attempted;
290        let check = self.with_session(|session| {
291            let branch = session.branch_path(None);
292            let latest_compaction = branch.iter().rev().find_map(|entry| match entry {
293                FileEntry::Compaction(compaction) => Some(compaction),
294                _ => None,
295            });
296            let compaction_time =
297                latest_compaction.and_then(|compaction| parse_iso(&compaction.meta.timestamp));
298            if compaction_time.is_some_and(|time| message.timestamp <= time) {
299                return CompactionCheck::None;
300            }
301            let projection = build_projection(&branch);
302            let entry_id = entry_id.as_deref();
303            let projected = entry_id.is_none_or(|id| {
304                projection.entries.iter().any(|entry| {
305                    entry.source.meta().is_some_and(|meta| meta.id == id)
306                        && entry
307                            .messages
308                            .iter()
309                            .any(|message| matches!(message, Message::Assistant(_)))
310                })
311            });
312            let after: Vec<&FileEntry> = entry_id
313                .and_then(|id| {
314                    branch
315                        .iter()
316                        .position(|entry| entry.meta().is_some_and(|meta| meta.id == id))
317                })
318                .map(|index| branch[index + 1..].to_vec())
319                .unwrap_or_default();
320            let post_edit = after
321                .iter()
322                .any(|entry| matches!(entry, FileEntry::ContextEdit(_)));
323            let latest_edit = after.iter().rev().find_map(|entry| match entry {
324                FileEntry::ContextEdit(edit) if Some(edit.target_id.as_str()) == entry_id => {
325                    Some(edit)
326                }
327                _ => None,
328            });
329            let retained = entry_id.is_none()
330                || (!after
331                    .iter()
332                    .any(|entry| matches!(entry, FileEntry::Compaction(_)))
333                    && latest_edit.is_none_or(|edit| edit.replacement.is_some()));
334            let usage_matches = projected && !post_edit;
335            let explicit = message.stop_reason == StopReason::Error && is_context_overflow(message, 0);
336            let same_model = message_model.is_some();
337            let overflow = same_model
338                && ((explicit && retained)
339                    || (usage_matches && is_context_overflow(message, context_window)));
340            let length = message_model.as_ref().is_some_and(|model| {
341                projected && is_recoverable_length(message, model.max_tokens)
342            });
343            if overflow || length {
344                let will_retry = message.stop_reason != StopReason::Stop;
345                if !will_retry {
346                    return CompactionCheck::Overflow { will_retry: false };
347                }
348                if overflow_attempted {
349                    return CompactionCheck::OverflowFailed(if overflow {
350                        "Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model.".into()
351                    } else {
352                        "Truncated response recovery failed after one compact-and-retry attempt.".into()
353                    });
354                }
355                return CompactionCheck::Overflow { will_retry: true };
356            }
357            let has_edits = projection
358                .entries
359                .iter()
360                .any(|entry| matches!(entry.source, FileEntry::ContextEdit(_)));
361            let direct = calculate_context_tokens(&message.usage);
362            let tokens = if has_edits {
363                estimate_projected_context_tokens(&projection, &branch).tokens
364            } else if message.stop_reason == StopReason::Error || direct == 0 {
365                let estimate = estimate_context_tokens(&projection.messages);
366                if let Some(index) = estimate.last_usage_index
367                    && let Message::Assistant(usage_message) = &projection.messages[index]
368                    && compaction_time.is_some_and(|time| usage_message.timestamp <= time)
369                {
370                    return CompactionCheck::None;
371                }
372                estimate.tokens
373            } else {
374                direct
375            };
376            if should_compact(tokens, context_window, &settings) {
377                CompactionCheck::Threshold
378            } else {
379                CompactionCheck::None
380            }
381        });
382        Ok(match check {
383            CompactionCheck::None => false,
384            CompactionCheck::Threshold => {
385                self.run_auto_compaction(CompactionReason::Threshold, false, cancel)
386                    .await
387            }
388            CompactionCheck::OverflowFailed(error_message) => {
389                self.compaction_failed(CompactionReason::Overflow, Some(error_message), false)
390                    .await;
391                false
392            }
393            CompactionCheck::Overflow { will_retry } => {
394                if will_retry {
395                    lock(&self.inner.recovery).overflow_recovery_attempted = true;
396                    let ids: Vec<String> = entry_id.into_iter().chain(tool_results).collect();
397                    self.omit_recovery_attempt(&ids);
398                }
399                self.run_auto_compaction(CompactionReason::Overflow, will_retry, cancel)
400                    .await
401            }
402        })
403    }
404
405    /// A summarizer for `model` with the session's credentials and retry
406    /// policy, reporting retries as `source` summaries.
407    pub(super) async fn summarizer<'a>(
408        &'a self,
409        model: &'a Model,
410        thinking_level: ThinkingLevel,
411        source: SummarySource,
412        reason: Option<CompactionReason>,
413        cancel: CancellationToken,
414    ) -> Summarizer<'a> {
415        Summarizer {
416            model,
417            apis: self.apis(),
418            auth: self.registry().auth(model).await,
419            thinking_level,
420            retry: self.retry_policy(),
421            request: self.request_options(),
422            hooks: yapi_ai::stream::RequestHooks {
423                headers: self.headers_hook(&cancel),
424                ..Default::default()
425            },
426            cancel,
427            on_retry: Box::new(move |retry| self.emit_summary_retry(retry, source, reason)),
428        }
429    }
430
431    /// pi's `summarizeForBugReport`: a bug report the current model writes
432    /// from the end of the context, with the user's `hint`, or why it could
433    /// not. `cancel` stops the request.
434    pub async fn bug_report_summary(
435        &self,
436        hint: Option<&str>,
437        cancel: CancellationToken,
438    ) -> Result<String, String> {
439        let model = self.model().ok_or_else(|| "No model selected".to_owned())?;
440        let mut summarizer = self
441            .summarizer(
442                &model,
443                self.thinking_level(),
444                SummarySource::Compaction,
445                None,
446                cancel,
447            )
448            .await;
449        // pi retries a bug report summary without reporting it.
450        summarizer.on_retry = Box::new(|_| {});
451        crate::bug_report::summary(&summarizer, &self.messages(), hint).await
452    }
453
454    /// What manual and automatic compaction share: pi's
455    /// `session_before_compact` event, which may cancel the compaction or
456    /// supply its summary, else a summary of `preparation`. The
457    /// compaction is recorded with the estimate after it and reported as
458    /// `session_compact`, unless `cancel` fired meanwhile.
459    async fn run_compaction(
460        &self,
461        model: &Model,
462        preparation: &Preparation,
463        custom_instructions: Option<&str>,
464        (reason, will_retry): (CompactionReason, bool),
465        cancel: &CancellationToken,
466    ) -> Compacted {
467        let mut from_extension = false;
468        let mut supplied = None;
469        if self.has_handlers("session_before_compact") {
470            let preparation = serde_json::to_value(preparation).unwrap_or_default();
471            let event = self.with_session(|session| {
472                ExtensionEvent::SessionBeforeCompact {
473                    preparation,
474                    branch_entries: &session.branch_path(None),
475                    custom_instructions,
476                    reason,
477                    will_retry,
478                }
479                .to_value()
480            });
481            let result = self.emit_extension_event(&event, cancel.clone()).await;
482            if result
483                .as_ref()
484                .is_some_and(|result| result["cancel"] == true)
485            {
486                return Compacted {
487                    outcome: Err("Compaction cancelled".into()),
488                    from_extension,
489                    refused: true,
490                };
491            }
492            supplied = result.and_then(|result| {
493                serde_json::from_value::<CompactionResult>(result["compaction"].clone()).ok()
494            });
495            from_extension = supplied.is_some();
496        }
497        let outcome = async {
498            let mut result = match supplied {
499                Some(result) => result,
500                None => {
501                    let summarizer = self
502                        .summarizer(
503                            model,
504                            self.thinking_level(),
505                            SummarySource::Compaction,
506                            Some(reason),
507                            cancel.clone(),
508                        )
509                        .await;
510                    summarizer.compact(preparation, custom_instructions).await?
511                }
512            };
513            // As pi, a summary cut short by an abort is not recorded.
514            if cancel.is_cancelled() {
515                return Err("Compaction cancelled".to_owned());
516            }
517            let (entry, estimate) = self.with_session(|session| {
518                let id = session
519                    .append_compaction(
520                        result.summary.clone(),
521                        Some(result.first_kept_entry_id.clone()),
522                        result.tokens_before,
523                        result.details.clone(),
524                        Some(from_extension),
525                        result.usage.clone(),
526                    )
527                    .ok();
528                let estimate = session
529                    .build_context()
530                    .messages
531                    .iter()
532                    .map(estimate_tokens)
533                    .sum();
534                (id.and_then(|id| session.entry(&id).cloned()), estimate)
535            });
536            result.estimated_tokens_after = Some(estimate);
537            if let Some(entry) = entry {
538                let event = ExtensionEvent::SessionCompact {
539                    compaction_entry: &entry,
540                    from_extension,
541                    reason,
542                    will_retry,
543                };
544                self.emit_extension_event(&event.to_value(), CancellationToken::new())
545                    .await;
546            }
547            Ok(result)
548        }
549        .await;
550        Compacted {
551            outcome,
552            from_extension,
553            refused: false,
554        }
555    }
556
557    /// pi's `compaction_end` for a compaction that did not happen, then its
558    /// `session_compact_failed` event for extensions.
559    async fn compaction_failed(
560        &self,
561        reason: CompactionReason,
562        error_message: Option<String>,
563        from_extension: bool,
564    ) {
565        let aborted = error_message.is_none();
566        self.emit_compaction_end(reason, Err(error_message.clone()), false);
567        if !self.has_handlers("session_compact_failed") {
568            return;
569        }
570        let event = ExtensionEvent::SessionCompactFailed {
571            reason,
572            error_message: error_message.as_deref(),
573            aborted,
574            will_retry: false,
575            from_extension,
576        };
577        self.emit_extension_event(&event.to_value(), CancellationToken::new())
578            .await;
579    }
580
581    /// pi's `compaction_end` event: with the result, or aborted (`Err(None)`),
582    /// or failed with an error message.
583    fn emit_compaction_end(
584        &self,
585        reason: CompactionReason,
586        outcome: Result<CompactionResult, Option<String>>,
587        will_retry: bool,
588    ) {
589        let (result, aborted, error_message) = match outcome {
590            Ok(result) => (Some(result), false, None),
591            Err(None) => (None, true, None),
592            Err(Some(error)) => (None, false, Some(error)),
593        };
594        self.emit(&AgentEvent::CompactionEnd {
595            reason,
596            result,
597            aborted,
598            will_retry,
599            error_message,
600        });
601    }
602
603    /// pi's `summarization_retry_*` events for a summary request's retries.
604    fn emit_summary_retry(
605        &self,
606        retry: SummaryRetry,
607        source: SummarySource,
608        reason: Option<CompactionReason>,
609    ) {
610        self.emit(&match retry {
611            SummaryRetry::Scheduled {
612                attempt,
613                max_attempts,
614                delay_ms,
615                error_message,
616            } => AgentEvent::SummarizationRetryScheduled {
617                attempt,
618                max_attempts,
619                delay_ms,
620                error_message,
621            },
622            SummaryRetry::AttemptStart => {
623                AgentEvent::SummarizationRetryAttemptStart { source, reason }
624            }
625            SummaryRetry::Finished => AgentEvent::SummarizationRetryFinished,
626        });
627    }
628
629    async fn run_auto_compaction(
630        &self,
631        reason: CompactionReason,
632        will_retry: bool,
633        cancel: &CancellationToken,
634    ) -> bool {
635        let Some(model) = self.model() else {
636            return false;
637        };
638        let settings =
639            CompactionSettings::resolve(lock(&self.inner.settings).settings(), Some(&model));
640        let Some(preparation) =
641            self.with_session(|session| prepare_compaction(&session.branch_path(None), settings))
642        else {
643            return false;
644        };
645        self.emit(&AgentEvent::CompactionStart { reason });
646        let compacting = Compacting::start(&self.inner.compacting);
647        let compacted = self
648            .run_compaction(&model, &preparation, None, (reason, will_retry), cancel)
649            .await;
650        drop(compacting);
651        self.inner.idle.notify_waiters();
652        match compacted.outcome {
653            Ok(result) => {
654                self.emit_compaction_end(reason, Ok(result), will_retry);
655                will_retry || self.has_queued()
656            }
657            Err(message) => {
658                let aborted = cancel.is_cancelled() || compacted.refused;
659                let error_message = (!aborted).then(|| match reason {
660                    CompactionReason::Overflow => {
661                        format!("Context overflow recovery failed: {message}")
662                    }
663                    _ => format!("Auto-compaction failed: {message}"),
664                });
665                self.compaction_failed(reason, error_message, compacted.from_extension)
666                    .await;
667                false
668            }
669        }
670    }
671
672    /// Compacts the session now, optionally focused by `custom_instructions`.
673    /// Aborts a running response first.
674    pub async fn compact(
675        &self,
676        custom_instructions: Option<&str>,
677    ) -> Result<CompactionResult, String> {
678        self.abort();
679        self.wait_for_idle().await;
680        let cancel = CancellationToken::new();
681        *lock(&self.inner.cancel) = Some(cancel.clone());
682        let compacting = Compacting::start(&self.inner.compacting);
683        self.inner.manual_compaction.store(true, Ordering::SeqCst);
684        self.emit(&AgentEvent::CompactionStart {
685            reason: CompactionReason::Manual,
686        });
687        let outcome = async {
688            let model = self
689                .model()
690                .ok_or_else(|| crate::auth_guidance::no_model_selected(&self.inner.docs))?;
691            let settings =
692                CompactionSettings::resolve(lock(&self.inner.settings).settings(), Some(&model));
693            let preparation = self.with_session(|session| {
694                let branch = session.branch_path(None);
695                match prepare_compaction(&branch, settings) {
696                    Some(preparation) => Ok(preparation),
697                    None if matches!(branch.last(), Some(FileEntry::Compaction(_))) => {
698                        Err("Already compacted".to_owned())
699                    }
700                    None => Err("Nothing to compact (session too small)".to_owned()),
701                }
702            })?;
703            Ok(self
704                .run_compaction(
705                    &model,
706                    &preparation,
707                    custom_instructions,
708                    (CompactionReason::Manual, false),
709                    &cancel,
710                )
711                .await)
712        }
713        .await;
714        let compacted = outcome.unwrap_or_else(|error| Compacted {
715            outcome: Err(error),
716            from_extension: false,
717            refused: false,
718        });
719        *lock(&self.inner.cancel) = None;
720        self.inner.manual_compaction.store(false, Ordering::SeqCst);
721        drop(compacting);
722        // Waiters for idle run once this returns, after its own outcome.
723        self.inner.idle.notify_waiters();
724        let reason = CompactionReason::Manual;
725        match compacted.outcome {
726            Ok(result) => {
727                self.emit_compaction_end(reason, Ok(result.clone()), false);
728                Ok(result)
729            }
730            Err(message) => {
731                let aborted = cancel.is_cancelled() || compacted.refused;
732                let error_message = (!aborted).then(|| format!("Compaction failed: {message}"));
733                self.compaction_failed(reason, error_message, compacted.from_extension)
734                    .await;
735                // pi reports the summarizer's error, such as an aborted request.
736                Err(message)
737            }
738        }
739    }
740}
741
742/// A compaction's outcome, whether an extension supplied its summary, and
743/// whether an extension cancelled it.
744struct Compacted {
745    outcome: Result<CompactionResult, String>,
746    from_extension: bool,
747    refused: bool,
748}