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