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        let min_messages = self
203            .services
204            .memory
205            .compaction
206            .shutdown_summary_min_messages;
207        if user_count < min_messages {
208            tracing::debug!(
209                user_count,
210                min = min_messages,
211                "shutdown summary: too few user messages, skipping"
212            );
213            return;
214        }
215
216        self.channel
217            .send_status_best_effort("Saving session summary...")
218            .await;
219
220        // Collect last N messages (skip system prompt at index 0).
221        let max = self
222            .services
223            .memory
224            .compaction
225            .shutdown_summary_max_messages;
226        if max == 0 {
227            tracing::debug!("shutdown summary: max_messages=0, skipping");
228            return;
229        }
230        let non_system: Vec<_> = self.msg.messages.iter().skip(1).collect();
231        let slice = if non_system.len() > max {
232            &non_system[non_system.len() - max..]
233        } else {
234            &non_system[..]
235        };
236
237        let msgs_for_prompt: Vec<(zeph_memory::MessageId, String, String)> = slice
238            .iter()
239            .map(|m| {
240                let role = match m.role {
241                    Role::Assistant => "assistant".to_owned(),
242                    Role::System => "system".to_owned(),
243                    Role::User | _ => "user".to_owned(),
244                };
245                (zeph_memory::MessageId(0), role, m.content.clone())
246            })
247            .collect();
248
249        let prompt = zeph_memory::build_summarization_prompt(&msgs_for_prompt);
250        let chat_messages = vec![Message {
251            role: Role::User,
252            content: prompt,
253            parts: vec![],
254            metadata: MessageMetadata::default(),
255        }];
256
257        let Some(structured) = self.call_llm_for_session_summary(&chat_messages).await else {
258            self.channel.send_status_best_effort("").await;
259            return;
260        };
261
262        if let Err(e) = memory
263            .store_shutdown_summary(conversation_id, &structured.summary, &structured.key_facts)
264            .await
265        {
266            tracing::warn!("shutdown summary: storage failed: {e:#}");
267        } else {
268            tracing::info!(
269                conversation_id = conversation_id.0,
270                "shutdown summary stored"
271            );
272        }
273
274        self.channel.send_status_best_effort("").await;
275    }
276    /// Gracefully shut down the agent and persist state.
277    ///
278    /// Performs the following cleanup:
279    ///
280    /// 1. **Message persistence** — Deferred database writes (hide/summary operations)
281    ///    are flushed to memory or disk
282    /// 2. **Provider state** — LLM router state (e.g., Thompson sampling counters) is saved
283    ///    to the vault
284    /// 3. **Sub-agents** — All active sub-agent tasks are terminated
285    /// 4. **MCP servers** — All connected Model Context Protocol servers are shut down
286    /// 5. **Metrics finalization** — Compaction metrics and session metrics are recorded
287    /// 6. **Memory finalization** — Vector stores and semantic indices are flushed
288    /// 7. **Skill state** — Self-learning engine saves evolved skill definitions
289    ///
290    /// Call this before dropping the agent to ensure no data loss.
291    #[tracing::instrument(name = "core.agent.shutdown", skip_all, level = "debug")]
292    #[allow(clippy::too_many_lines)]
293    pub async fn shutdown(&mut self) {
294        self.channel
295            .send_status_best_effort("Shutting down...")
296            .await;
297
298        // CRIT-1: persist Thompson state accumulated during this session.
299        self.provider.save_router_state().await;
300
301        // Persist AdaptOrch Beta-arm table alongside Thompson state.
302        if let Some(ref advisor) = self.services.orchestration.topology_advisor
303            && let Err(e) = advisor.save().await
304        {
305            tracing::warn!(error = %e, "adaptorch: failed to persist state");
306        }
307
308        if let Some(ref mut mgr) = self.services.orchestration.subagent_manager {
309            mgr.shutdown_all();
310        }
311
312        if let Some(ref manager) = self.services.mcp.manager {
313            manager.shutdown_all_shared().await;
314        }
315
316        // Anchor the session log (issue #6449): best-effort, logged rather than propagated — a
317        // failed anchor put only degrades this session to #6453-level chain-only protection,
318        // never data loss. Runs on every channel (CLI, TUI, Telegram, ACP, serve), since
319        // `shutdown` is the one call every channel already makes before dropping the agent.
320        if let Some(ref sink) = self.services.session.session_sink
321            && let Err(e) = sink.finalize().await
322        {
323            tracing::warn!(error = %e, "session anchor finalize failed");
324        }
325
326        // Finalize compaction trajectory: push the last open segment into the Vec.
327        // This segment would otherwise only be pushed when the next hard compaction fires,
328        // which never happens at session end.
329        if let Some(turns) = self.context_manager.turns_since_last_hard_compaction() {
330            self.update_metrics(|m| {
331                m.compaction_turns_after_hard.push(turns);
332            });
333            self.context_manager
334                .set_turns_since_last_hard_compaction(None);
335        }
336
337        if let Some(ref tx) = self.runtime.metrics.metrics_tx {
338            let m = tx.borrow();
339            if m.filter_applications > 0 {
340                #[allow(clippy::cast_precision_loss)]
341                let pct = if m.filter_raw_tokens > 0 {
342                    m.filter_saved_tokens as f64 / m.filter_raw_tokens as f64 * 100.0
343                } else {
344                    0.0
345                };
346                tracing::info!(
347                    raw_tokens = m.filter_raw_tokens,
348                    saved_tokens = m.filter_saved_tokens,
349                    applications = m.filter_applications,
350                    "tool output filtering saved ~{} tokens ({pct:.0}%)",
351                    m.filter_saved_tokens,
352                );
353            }
354            if m.compaction_hard_count > 0 {
355                tracing::info!(
356                    hard_compactions = m.compaction_hard_count,
357                    turns_after_hard = ?m.compaction_turns_after_hard,
358                    "hard compaction trajectory"
359                );
360            }
361        }
362
363        // Flush tombstone ToolResults for any assistant ToolUse that was persisted but never
364        // paired with a ToolResult (e.g. stdin EOF mid-execution). Without this the next session
365        // startup strips the orphaned ToolUse and emits warnings.
366        self.flush_orphaned_tool_use_on_shutdown().await;
367
368        // Signal the experiment CancellationToken first so the task can clean up gracefully,
369        // then abort the handle to guarantee it does not outlive the agent regardless.
370        if let Some(ref token) = self.services.experiments.cancel {
371            token.cancel();
372        }
373        if let Some(h) = self.services.experiments.handle.take() {
374            h.abort();
375        }
376
377        // Signal cooperative cancellation to the graph-extraction background task before the
378        // hard abort below. This lets the task exit at a clean checkpoint (e.g. after the
379        // community-refresh select arm fires) rather than being cut mid-write.
380        if let Some(memory) = self.services.memory.persistence.memory.as_ref() {
381            memory.cancel_graph_extraction();
382        }
383
384        // Forcibly abort in-flight Enrichment and Telemetry tasks tracked by the supervisor.
385        self.runtime.lifecycle.supervisor.abort_all();
386
387        // Abort background task handles not tracked by BackgroundSupervisor.
388        // Per the Await Discipline rule, fire-and-forget handles must be aborted on shutdown.
389        if let Some(h) = self.services.compression.pending_task_goal.take() {
390            h.abort();
391        }
392        if let Some(h) = self.services.compression.pending_sidequest_result.take() {
393            h.abort();
394        }
395        if let Some(h) = self.services.compression.pending_subgoal.take() {
396            h.abort();
397        }
398        self.flush_durable_writer().await;
399
400        // Abort learning tasks (JoinSet detached at turn boundaries but not on shutdown).
401        self.services.learning_engine.learning_tasks.abort_all();
402
403        // Await the AutoSkill trace extraction task so it is not silently dropped.
404        // Bounded to avoid hanging shutdown when the LLM call inside the task stalls.
405        if let Some(h) = self.services.learning_engine.trace_extraction_handle.take() {
406            let deadline = std::time::Duration::from_mins(2);
407            match tokio::time::timeout(deadline, h.join()).await {
408                Ok(Ok(())) => {}
409                Ok(Err(e)) => tracing::warn!("trace_extraction: task error at shutdown: {e}"),
410                Err(_) => tracing::warn!(
411                    "trace_extraction: timed out at shutdown ({}s), aborting",
412                    deadline.as_secs()
413                ),
414            }
415        }
416
417        // Abort the heuristic promotion loop (periodic task; abort is safe because
418        // promotion_already_evaluated ensures idempotent retry on next startup).
419        if let Some(h) = self
420            .services
421            .learning_engine
422            .heuristic_promotion_handle
423            .take()
424        {
425            h.abort();
426        }
427
428        // Drain pending shadow sentinel DB writes before final teardown.
429        if let Some(ref sentinel) = self.services.security.shadow_sentinel {
430            sentinel.drain_pending().await;
431        }
432
433        // Allow cancelled tasks to release their HTTP connections before the summary LLM call.
434        // abort_all() posts cancellation signals but does not drain tasks; aborted futures only
435        // observe cancellation at their next .await point. Without yielding here the summary
436        // call races in-flight enrichment HTTP connections for the same API rate-limit budget.
437        for _ in 0..4 {
438            tokio::task::yield_now().await;
439        }
440
441        self.maybe_store_shutdown_summary().await;
442        self.maybe_store_session_digest().await;
443
444        tracing::info!("agent shutdown complete");
445    }
446
447    /// Flush buffered durable journal entries, finalize the P1 agent-turn execution, then abort
448    /// the writer tasks, for both the P2 (orchestration) and P1 (agent-turn, #5452) durable
449    /// adapters.
450    ///
451    /// `flush()` has a built-in ack timeout; the outer 2 s cap ensures shutdown never
452    /// hangs beyond that. Errors are logged as warnings — shutdown must not fail.
453    async fn flush_durable_writer(&mut self) {
454        let flush_deadline = std::time::Duration::from_secs(2);
455        if let Some(ref writer) = self.services.orchestration.durable_writer {
456            match tokio::time::timeout(flush_deadline, writer.flush()).await {
457                Ok(Ok(())) => {}
458                Ok(Err(e)) => {
459                    tracing::warn!(error = %e, "durable writer: flush on shutdown failed");
460                }
461                Err(_) => tracing::warn!("durable writer: flush timed out on shutdown"),
462            }
463        }
464        if let Some(h) = self.services.orchestration.durable_writer_task.take() {
465            h.abort();
466        }
467        if let Some(ref writer) = self.services.session.durable_writer {
468            match tokio::time::timeout(flush_deadline, writer.flush()).await {
469                Ok(Ok(())) => {}
470                Ok(Err(e)) => {
471                    tracing::warn!(error = %e, "durable agent_turns writer: flush on shutdown failed");
472                }
473                Err(_) => tracing::warn!("durable agent_turns writer: flush timed out on shutdown"),
474            }
475        }
476        // Finalize the P1 execution as Completed now that its last turn's steps are flushed. The
477        // execution spans the whole conversation (keyed on ConversationId, #5452), not a single
478        // turn, so this is not "the conversation is over" — it just makes the row eligible for
479        // the TTL prune sweep if the conversation is never resumed. A later resume of the *same*
480        // conversation reopens this row and automatically un-finalizes it back to `running`
481        // (`LocalBackend::open_execution`, #6251), so nothing is lost if the user comes back.
482        // Bounded by the same 2 s deadline as the flush calls above, so this doc comment's "never
483        // hangs beyond that" claim stays accurate.
484        if let Some(ref ctx) = self.services.session.durable_ctx {
485            match tokio::time::timeout(
486                flush_deadline,
487                ctx.finalize(zeph_durable::ExecutionStatus::Completed),
488            )
489            .await
490            {
491                Ok(Ok(())) => {}
492                Ok(Err(e)) => {
493                    tracing::warn!(
494                        error = %e,
495                        "durable agent_turns: failed to finalize execution on shutdown"
496                    );
497                }
498                Err(_) => tracing::warn!("durable agent_turns: finalize timed out on shutdown"),
499            }
500        }
501        if let Some(h) = self.services.session.durable_writer_task.take() {
502            h.abort();
503        }
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    use crate::agent::agent_tests::*;
510
511    fn agent_with_conversation() -> crate::agent::Agent<MockChannel> {
512        let provider = mock_provider(vec!["ok".into()]);
513        let channel = MockChannel::new(vec![]);
514        let registry = create_test_registry();
515        let executor = MockToolExecutor::no_tools();
516        let mut agent = crate::agent::Agent::new(provider, channel, registry, None, 5, executor);
517        agent.services.memory.persistence.conversation_id = Some(zeph_memory::ConversationId(1));
518        agent
519    }
520
521    #[tokio::test]
522    async fn flush_durable_writer_finalizes_the_p1_execution_as_completed() {
523        // #6251: graceful shutdown must finalize the P1 agent-turn execution as `Completed`,
524        // otherwise it stays `running` forever and the retention sweep can never reclaim it.
525        // `:memory:` can't be re-opened from a second connection to verify this, so this test uses
526        // a real file-backed sqlite db (same pattern as
527        // `durable_bootstrap::tests::conversation_switch_finalizes_the_old_execution_as_completed`).
528        let dir = tempfile::tempdir().unwrap();
529        let db_url = dir.path().join("durable.db").to_string_lossy().into_owned();
530
531        let mut agent = agent_with_conversation();
532        agent.services.session.durable_agent_turns_config = Some(zeph_config::DurableConfig {
533            enabled: true,
534            agent_turns: true,
535            ..zeph_config::DurableConfig::default()
536        });
537        agent.services.session.durable_agent_turns_db_url = Some(db_url.clone());
538
539        agent.ensure_session_durable_ctx().await;
540        let exec_id = agent
541            .services
542            .session
543            .durable_ctx
544            .as_ref()
545            .expect("durable_ctx should be populated")
546            .execution_id();
547
548        agent.flush_durable_writer().await;
549
550        let backend = zeph_durable::LocalBackend::open(&db_url, 1_048_576)
551            .await
552            .unwrap();
553        let summaries = backend.list_executions(None, None, 10).await.unwrap();
554        let row = summaries
555            .iter()
556            .find(|s| s.execution_id == exec_id)
557            .expect("the execution's row must still exist");
558        assert_eq!(
559            row.status,
560            zeph_durable::ExecutionStatus::Completed,
561            "the P1 execution must finalize as Completed on graceful shutdown"
562        );
563    }
564
565    #[tokio::test]
566    async fn flush_durable_writer_finalize_is_bounded_by_the_2s_timeout() {
567        // #6251 critic M1: the shutdown finalize call must not hang indefinitely (or for the full
568        // 5s sqlite `busy_timeout`, zeph-db/src/pool.rs) when it can't immediately acquire the
569        // write lock. Holds a write transaction open on a second connection to the same
570        // file-backed db (BEGIN IMMEDIATE takes the write lock upfront, per
571        // `zeph_db::begin_write`'s doc comment) so `ctx.finalize`'s own `begin_write` blocks, then
572        // asserts `flush_durable_writer` still returns well within the 2s bound rather than
573        // waiting out the 5s busy_timeout or hanging forever.
574        let dir = tempfile::tempdir().unwrap();
575        let db_url = dir.path().join("durable.db").to_string_lossy().into_owned();
576
577        let mut agent = agent_with_conversation();
578        agent.services.session.durable_agent_turns_config = Some(zeph_config::DurableConfig {
579            enabled: true,
580            agent_turns: true,
581            ..zeph_config::DurableConfig::default()
582        });
583        agent.services.session.durable_agent_turns_db_url = Some(db_url.clone());
584
585        agent.ensure_session_durable_ctx().await;
586        let exec_id = agent
587            .services
588            .session
589            .durable_ctx
590            .as_ref()
591            .expect("durable_ctx should be populated")
592            .execution_id();
593
594        // A second, independent connection to the same file holds the write lock throughout the
595        // finalize attempt below, without ever committing or rolling back until after the timing
596        // assertion.
597        let lock_holder = zeph_durable::LocalBackend::open(&db_url, 1_048_576)
598            .await
599            .unwrap();
600        let blocking_tx = zeph_db::begin_write(lock_holder.pool()).await.unwrap();
601
602        let start = std::time::Instant::now();
603        agent.flush_durable_writer().await;
604        let elapsed = start.elapsed();
605
606        drop(blocking_tx); // release the write lock
607
608        assert!(
609            elapsed < std::time::Duration::from_secs(4),
610            "flush_durable_writer must return well within its 2s finalize timeout \
611             (plus the writer.flush() call's own bound), not the 5s sqlite busy_timeout; took \
612             {elapsed:?}"
613        );
614
615        let backend = zeph_durable::LocalBackend::open(&db_url, 1_048_576)
616            .await
617            .unwrap();
618        let summaries = backend.list_executions(None, None, 10).await.unwrap();
619        let row = summaries
620            .iter()
621            .find(|s| s.execution_id == exec_id)
622            .expect("the execution's row must still exist");
623        assert_eq!(
624            row.status,
625            zeph_durable::ExecutionStatus::Running,
626            "finalize must not have committed while the write lock was held elsewhere"
627        );
628
629        // Sanity check: with the lock released, a direct finalize succeeds normally — proving the
630        // earlier non-completion was purely lock contention, not a latent bug. (Not calling
631        // `flush_durable_writer` again: its first call already aborted `durable_writer_task`, so a
632        // second `writer.flush()` would just time out waiting for a reply from a dead task.)
633        agent
634            .services
635            .session
636            .durable_ctx
637            .as_ref()
638            .unwrap()
639            .finalize(zeph_durable::ExecutionStatus::Completed)
640            .await
641            .unwrap();
642        let summaries = backend.list_executions(None, None, 10).await.unwrap();
643        let row = summaries
644            .iter()
645            .find(|s| s.execution_id == exec_id)
646            .expect("the execution's row must still exist");
647        assert_eq!(
648            row.status,
649            zeph_durable::ExecutionStatus::Completed,
650            "finalize succeeds once the write lock is free"
651        );
652    }
653}