Skip to main content

zeph_core/agent/
shutdown.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Graceful shutdown: session-summary generation and orphaned tool-use flush.
5//!
6//! Extracted from `agent/mod.rs` (#4923). Holds the shutdown lifecycle: building a
7//! structured session summary via the LLM (with plain-text fallback), persisting it,
8//! and emitting tombstone `ToolResult` parts for any unpaired `ToolUse` left in history.
9
10use super::Agent;
11use crate::channel::Channel;
12use zeph_llm::provider::{LlmProvider, Message, MessageMetadata, Role};
13
14impl<C: Channel> Agent<C> {
15    /// Call the LLM to generate a structured session summary with a configurable timeout.
16    ///
17    /// Falls back to plain-text chat if structured output fails or times out. Returns `None` on
18    /// any failure, logging a warning — callers must treat `None` as "skip storage".
19    ///
20    /// Each LLM attempt is bounded by `shutdown_summary_timeout_secs`; in the worst case
21    /// (structured call times out and plain-text fallback also times out) this adds up to
22    /// `2 * shutdown_summary_timeout_secs` of shutdown latency.
23    async fn call_llm_for_session_summary(
24        &self,
25        chat_messages: &[Message],
26    ) -> Option<zeph_memory::StructuredSummary> {
27        let provider = self.resolve_background_provider(
28            &self.services.memory.compaction.shutdown_summary_provider,
29        );
30        let timeout_dur = std::time::Duration::from_secs(
31            self.services
32                .memory
33                .compaction
34                .shutdown_summary_timeout_secs,
35        );
36        match tokio::time::timeout(
37            timeout_dur,
38            provider.chat_typed_erased::<zeph_memory::StructuredSummary>(chat_messages),
39        )
40        .await
41        {
42            Ok(Ok(s)) => Some(s),
43            Ok(Err(e)) => {
44                tracing::warn!(
45                    "shutdown summary: structured LLM call failed, falling back to plain: {e:#}"
46                );
47                self.plain_text_summary_fallback(&provider, chat_messages, timeout_dur)
48                    .await
49            }
50            Err(_) => {
51                tracing::warn!(
52                    "shutdown summary: structured LLM call timed out after {}s, falling back to plain",
53                    self.services
54                        .memory
55                        .compaction
56                        .shutdown_summary_timeout_secs
57                );
58                self.plain_text_summary_fallback(&provider, chat_messages, timeout_dur)
59                    .await
60            }
61        }
62    }
63    async fn plain_text_summary_fallback(
64        &self,
65        provider: &zeph_llm::any::AnyProvider,
66        chat_messages: &[Message],
67        timeout_dur: std::time::Duration,
68    ) -> Option<zeph_memory::StructuredSummary> {
69        match tokio::time::timeout(timeout_dur, provider.chat(chat_messages)).await {
70            Ok(Ok(plain)) => Some(zeph_memory::StructuredSummary {
71                summary: plain,
72                key_facts: vec![],
73                entities: vec![],
74            }),
75            Ok(Err(e)) => {
76                tracing::warn!("shutdown summary: plain LLM fallback failed: {e:#}");
77                None
78            }
79            Err(_) => {
80                tracing::warn!("shutdown summary: plain LLM fallback timed out");
81                None
82            }
83        }
84    }
85    /// Persist tombstone `ToolResult` messages for any assistant `ToolUse` parts that were written
86    /// to the DB during this session but never paired with a `ToolResult` (e.g. because stdin
87    /// closed while tool execution was in progress). Without this the next session startup strips
88    /// those assistant messages and emits orphan warnings.
89    pub(super) async fn flush_orphaned_tool_use_on_shutdown(&mut self) {
90        use zeph_llm::provider::{MessagePart, Role};
91
92        // Walk messages in reverse: if the last assistant message (ignoring any trailing
93        // system messages) has ToolUse parts and is NOT immediately followed by a user
94        // message whose ToolResult ids cover those ToolUse ids, persist tombstones.
95        let msgs = &self.msg.messages;
96        // Find last assistant message index.
97        let Some(asst_idx) = msgs.iter().rposition(|m| m.role == Role::Assistant) else {
98            return;
99        };
100        let asst_msg = &msgs[asst_idx];
101        let tool_use_ids: Vec<(&str, &str, &serde_json::Value)> = asst_msg
102            .parts
103            .iter()
104            .filter_map(|p| {
105                if let MessagePart::ToolUse { id, name, input } = p {
106                    Some((id.as_str(), name.as_str(), input))
107                } else {
108                    None
109                }
110            })
111            .collect();
112        if tool_use_ids.is_empty() {
113            return;
114        }
115
116        // Check whether a following user message already pairs all ToolUse ids.
117        let paired_ids: std::collections::HashSet<&str> = msgs
118            .get(asst_idx + 1..)
119            .into_iter()
120            .flatten()
121            .filter(|m| m.role == Role::User)
122            .flat_map(|m| m.parts.iter())
123            .filter_map(|p| {
124                if let MessagePart::ToolResult { tool_use_id, .. } = p {
125                    Some(tool_use_id.as_str())
126                } else {
127                    None
128                }
129            })
130            .collect();
131
132        let unpaired: Vec<zeph_llm::provider::ToolUseRequest> = tool_use_ids
133            .iter()
134            .filter(|(id, _, _)| !paired_ids.contains(*id))
135            .map(|(id, name, input)| zeph_llm::provider::ToolUseRequest {
136                id: (*id).to_owned(),
137                name: (*name).to_owned().into(),
138                input: (*input).clone(),
139            })
140            .collect();
141
142        if unpaired.is_empty() {
143            return;
144        }
145
146        tracing::info!(
147            count = unpaired.len(),
148            "shutdown: persisting tombstone ToolResults for unpaired in-flight tool calls"
149        );
150        // Splice immediately after the orphaned assistant message rather than appending at the
151        // true end: a later turn may already have appended its own message past `asst_idx` by
152        // the time shutdown runs (see #5646), and appending there would still leave the ToolUse
153        // not immediately followed by its ToolResult.
154        self.persist_cancelled_tool_results(&unpaired, Some(asst_idx + 1))
155            .await;
156    }
157    /// Generate and store a lightweight session summary at shutdown when no hard compaction fired.
158    ///
159    /// Guards:
160    /// - `self.runtime.config.bare` must be `false` (#5551 — bare mode never fires shutdown LLM calls)
161    /// - `shutdown_summary` config must be enabled
162    /// - `conversation_id` must be set (memory must be attached)
163    /// - no existing session summary in the store (primary guard — resilient to failed Qdrant writes)
164    /// - at least `shutdown_summary_min_messages` user-turn messages in history
165    ///
166    /// All errors are logged as warnings and swallowed — shutdown must never fail.
167    pub(super) async fn maybe_store_shutdown_summary(&mut self) {
168        if self.runtime.config.bare {
169            return;
170        }
171        if !self.services.memory.compaction.shutdown_summary {
172            return;
173        }
174        let Some(memory) = self.services.memory.persistence.memory.clone() else {
175            return;
176        };
177        let Some(conversation_id) = self.services.memory.persistence.conversation_id else {
178            return;
179        };
180
181        // Primary guard: check if a summary already exists (handles failed Qdrant writes too).
182        match memory.has_session_summary(conversation_id).await {
183            Ok(true) => {
184                tracing::debug!("shutdown summary: session already has a summary, skipping");
185                return;
186            }
187            Ok(false) => {}
188            Err(e) => {
189                tracing::warn!("shutdown summary: failed to check existing summary: {e:#}");
190                return;
191            }
192        }
193
194        // Count user-turn messages only (skip system prompt at index 0).
195        let user_count = self
196            .msg
197            .messages
198            .iter()
199            .skip(1)
200            .filter(|m| m.role == Role::User)
201            .count();
202        if user_count
203            < self
204                .services
205                .memory
206                .compaction
207                .shutdown_summary_min_messages
208        {
209            tracing::debug!(
210                user_count,
211                min = self
212                    .services
213                    .memory
214                    .compaction
215                    .shutdown_summary_min_messages,
216                "shutdown summary: too few user messages, skipping"
217            );
218            return;
219        }
220
221        // TUI status — send errors silently ignored (TUI may already be gone at shutdown).
222        let _ = self.channel.send_status("Saving session summary...").await;
223
224        // Collect last N messages (skip system prompt at index 0).
225        let max = self
226            .services
227            .memory
228            .compaction
229            .shutdown_summary_max_messages;
230        if max == 0 {
231            tracing::debug!("shutdown summary: max_messages=0, skipping");
232            return;
233        }
234        let non_system: Vec<_> = self.msg.messages.iter().skip(1).collect();
235        let slice = if non_system.len() > max {
236            &non_system[non_system.len() - max..]
237        } else {
238            &non_system[..]
239        };
240
241        let msgs_for_prompt: Vec<(zeph_memory::MessageId, String, String)> = slice
242            .iter()
243            .map(|m| {
244                let role = match m.role {
245                    Role::Assistant => "assistant".to_owned(),
246                    Role::System => "system".to_owned(),
247                    Role::User | _ => "user".to_owned(),
248                };
249                (zeph_memory::MessageId(0), role, m.content.clone())
250            })
251            .collect();
252
253        let prompt = zeph_memory::build_summarization_prompt(&msgs_for_prompt);
254        let chat_messages = vec![Message {
255            role: Role::User,
256            content: prompt,
257            parts: vec![],
258            metadata: MessageMetadata::default(),
259        }];
260
261        let Some(structured) = self.call_llm_for_session_summary(&chat_messages).await else {
262            let _ = self.channel.send_status("").await;
263            return;
264        };
265
266        if let Err(e) = memory
267            .store_shutdown_summary(conversation_id, &structured.summary, &structured.key_facts)
268            .await
269        {
270            tracing::warn!("shutdown summary: storage failed: {e:#}");
271        } else {
272            tracing::info!(
273                conversation_id = conversation_id.0,
274                "shutdown summary stored"
275            );
276        }
277
278        // Clear TUI status.
279        let _ = self.channel.send_status("").await;
280    }
281    /// Gracefully shut down the agent and persist state.
282    ///
283    /// Performs the following cleanup:
284    ///
285    /// 1. **Message persistence** — Deferred database writes (hide/summary operations)
286    ///    are flushed to memory or disk
287    /// 2. **Provider state** — LLM router state (e.g., Thompson sampling counters) is saved
288    ///    to the vault
289    /// 3. **Sub-agents** — All active sub-agent tasks are terminated
290    /// 4. **MCP servers** — All connected Model Context Protocol servers are shut down
291    /// 5. **Metrics finalization** — Compaction metrics and session metrics are recorded
292    /// 6. **Memory finalization** — Vector stores and semantic indices are flushed
293    /// 7. **Skill state** — Self-learning engine saves evolved skill definitions
294    ///
295    /// Call this before dropping the agent to ensure no data loss.
296    #[tracing::instrument(name = "core.agent.shutdown", skip_all, level = "debug")]
297    pub async fn shutdown(&mut self) {
298        let _ = self.channel.send_status("Shutting down...").await;
299
300        // CRIT-1: persist Thompson state accumulated during this session.
301        self.provider.save_router_state().await;
302
303        // Persist AdaptOrch Beta-arm table alongside Thompson state.
304        if let Some(ref advisor) = self.services.orchestration.topology_advisor
305            && let Err(e) = advisor.save().await
306        {
307            tracing::warn!(error = %e, "adaptorch: failed to persist state");
308        }
309
310        if let Some(ref mut mgr) = self.services.orchestration.subagent_manager {
311            mgr.shutdown_all();
312        }
313
314        if let Some(ref manager) = self.services.mcp.manager {
315            manager.shutdown_all_shared().await;
316        }
317
318        // Finalize compaction trajectory: push the last open segment into the Vec.
319        // This segment would otherwise only be pushed when the next hard compaction fires,
320        // which never happens at session end.
321        if let Some(turns) = self.context_manager.turns_since_last_hard_compaction() {
322            self.update_metrics(|m| {
323                m.compaction_turns_after_hard.push(turns);
324            });
325            self.context_manager
326                .set_turns_since_last_hard_compaction(None);
327        }
328
329        if let Some(ref tx) = self.runtime.metrics.metrics_tx {
330            let m = tx.borrow();
331            if m.filter_applications > 0 {
332                #[allow(clippy::cast_precision_loss)]
333                let pct = if m.filter_raw_tokens > 0 {
334                    m.filter_saved_tokens as f64 / m.filter_raw_tokens as f64 * 100.0
335                } else {
336                    0.0
337                };
338                tracing::info!(
339                    raw_tokens = m.filter_raw_tokens,
340                    saved_tokens = m.filter_saved_tokens,
341                    applications = m.filter_applications,
342                    "tool output filtering saved ~{} tokens ({pct:.0}%)",
343                    m.filter_saved_tokens,
344                );
345            }
346            if m.compaction_hard_count > 0 {
347                tracing::info!(
348                    hard_compactions = m.compaction_hard_count,
349                    turns_after_hard = ?m.compaction_turns_after_hard,
350                    "hard compaction trajectory"
351                );
352            }
353        }
354
355        // Flush tombstone ToolResults for any assistant ToolUse that was persisted but never
356        // paired with a ToolResult (e.g. stdin EOF mid-execution). Without this the next session
357        // startup strips the orphaned ToolUse and emits warnings.
358        self.flush_orphaned_tool_use_on_shutdown().await;
359
360        // Signal the experiment CancellationToken first so the task can clean up gracefully,
361        // then abort the handle to guarantee it does not outlive the agent regardless.
362        if let Some(ref token) = self.services.experiments.cancel {
363            token.cancel();
364        }
365        if let Some(h) = self.services.experiments.handle.take() {
366            h.abort();
367        }
368
369        // Signal cooperative cancellation to the graph-extraction background task before the
370        // hard abort below. This lets the task exit at a clean checkpoint (e.g. after the
371        // community-refresh select arm fires) rather than being cut mid-write.
372        if let Some(memory) = self.services.memory.persistence.memory.as_ref() {
373            memory.cancel_graph_extraction();
374        }
375
376        // Forcibly abort in-flight Enrichment and Telemetry tasks tracked by the supervisor.
377        self.runtime.lifecycle.supervisor.abort_all();
378
379        // Abort background task handles not tracked by BackgroundSupervisor.
380        // Per the Await Discipline rule, fire-and-forget handles must be aborted on shutdown.
381        if let Some(h) = self.services.compression.pending_task_goal.take() {
382            h.abort();
383        }
384        if let Some(h) = self.services.compression.pending_sidequest_result.take() {
385            h.abort();
386        }
387        if let Some(h) = self.services.compression.pending_subgoal.take() {
388            h.abort();
389        }
390        self.flush_durable_writer().await;
391
392        // Abort learning tasks (JoinSet detached at turn boundaries but not on shutdown).
393        self.services.learning_engine.learning_tasks.abort_all();
394
395        // Await the AutoSkill trace extraction task so it is not silently dropped.
396        // Bounded to avoid hanging shutdown when the LLM call inside the task stalls.
397        if let Some(h) = self.services.learning_engine.trace_extraction_handle.take() {
398            let deadline = std::time::Duration::from_mins(2);
399            match tokio::time::timeout(deadline, h.join()).await {
400                Ok(Ok(())) => {}
401                Ok(Err(e)) => tracing::warn!("trace_extraction: task error at shutdown: {e}"),
402                Err(_) => tracing::warn!(
403                    "trace_extraction: timed out at shutdown ({}s), aborting",
404                    deadline.as_secs()
405                ),
406            }
407        }
408
409        // Abort the heuristic promotion loop (periodic task; abort is safe because
410        // promotion_already_evaluated ensures idempotent retry on next startup).
411        if let Some(h) = self
412            .services
413            .learning_engine
414            .heuristic_promotion_handle
415            .take()
416        {
417            h.abort();
418        }
419
420        // Drain pending shadow sentinel DB writes before final teardown.
421        if let Some(ref sentinel) = self.services.security.shadow_sentinel {
422            sentinel.drain_pending().await;
423        }
424
425        // Allow cancelled tasks to release their HTTP connections before the summary LLM call.
426        // abort_all() posts cancellation signals but does not drain tasks; aborted futures only
427        // observe cancellation at their next .await point. Without yielding here the summary
428        // call races in-flight enrichment HTTP connections for the same API rate-limit budget.
429        for _ in 0..4 {
430            tokio::task::yield_now().await;
431        }
432
433        self.maybe_store_shutdown_summary().await;
434        self.maybe_store_session_digest().await;
435
436        tracing::info!("agent shutdown complete");
437    }
438
439    /// Flush buffered durable journal entries then abort the writer task, for both the P2
440    /// (orchestration) and P1 (agent-turn, #5452) durable adapters.
441    ///
442    /// `flush()` has a built-in ack timeout; the outer 2 s cap ensures shutdown never
443    /// hangs beyond that. Errors are logged as warnings — shutdown must not fail.
444    async fn flush_durable_writer(&mut self) {
445        let flush_deadline = std::time::Duration::from_secs(2);
446        if let Some(ref writer) = self.services.orchestration.durable_writer {
447            match tokio::time::timeout(flush_deadline, writer.flush()).await {
448                Ok(Ok(())) => {}
449                Ok(Err(e)) => {
450                    tracing::warn!(error = %e, "durable writer: flush on shutdown failed");
451                }
452                Err(_) => tracing::warn!("durable writer: flush timed out on shutdown"),
453            }
454        }
455        if let Some(h) = self.services.orchestration.durable_writer_task.take() {
456            h.abort();
457        }
458        if let Some(ref writer) = self.services.session.durable_writer {
459            match tokio::time::timeout(flush_deadline, writer.flush()).await {
460                Ok(Ok(())) => {}
461                Ok(Err(e)) => {
462                    tracing::warn!(error = %e, "durable agent_turns writer: flush on shutdown failed");
463                }
464                Err(_) => tracing::warn!("durable agent_turns writer: flush timed out on shutdown"),
465            }
466        }
467        if let Some(h) = self.services.session.durable_writer_task.take() {
468            h.abort();
469        }
470    }
471}