Skip to main content

zeph_core/
serve.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! `SessionActor` and `LiveSessionRegistry` for `zeph serve` (spec-068 §9, #5343).
5//!
6//! Each live conversation-session under `zeph serve` is a [`SessionActor`] task: it owns an
7//! [`Agent<LoopbackChannel>`](crate::agent::Agent) exclusively, bridges [`SessionCommand`]s into
8//! the agent's channel input, and forwards the channel's output as [`SessionOutput`] over a
9//! broadcast channel any number of HTTP/SSE or TUI attachments can subscribe to.
10//!
11//! [`LiveSessionRegistry`] is pure bookkeeping (a `HashMap` behind a `parking_lot::Mutex`, never
12//! held across `.await`) — it does not itself supervise tasks. [`SessionActor::spawn`] registers
13//! a *coordinator* task under `TaskSupervisor` via `spawn_oneshot(name: Arc<str>, factory)`
14//! (architect ruling D-7): the dynamic `serve.session.<id>` name and non-restarting `RunOnce`
15//! policy are exactly right for a session actor (re-driving a torn turn/replay after a crash is
16//! unsafe; recovery is a fresh spawn that replays the durable log from the last committed `seq`).
17//!
18//! `Agent<C>`'s futures are `!Send` (documented precedent: `crates/zeph-acp/src/transport/
19//! stdio.rs`, "Agent futures are `!Send` and deeply nested") and `Agent<LoopbackChannel>` itself
20//! cannot cross *any* thread boundary — not just `spawn_oneshot`'s `Send` bound but a
21//! `std::thread::spawn` one too. [`SessionActor::spawn`] resolves this exactly as `zeph-acp`'s
22//! `serve_stdio`/`transport/http.rs` do (architect ruling D-8): the `Agent` is constructed and
23//! driven entirely inside a dedicated OS thread with its own `current_thread` runtime and
24//! `LocalSet` — only `Send`-safe state (a `FnOnce(LoopbackChannel) -> Agent<LoopbackChannel>`
25//! factory, mirroring `zeph-acp`'s `SendAgentSpawner`) crosses into that thread. The
26//! `spawn_oneshot` task is a *thin coordinator*, never the agent driver itself: it awaits the
27//! thread's completion signal and, on process-wide supervisor shutdown, forwards cancellation
28//! onto the session's own [`CancellationToken`] (distinct from the supervisor's) so idle
29//! eviction (spec §9.3) can cancel exactly one session without tearing down every live actor,
30//! while `drive` only ever needs to select on a single cancellation source regardless of trigger.
31
32use std::collections::HashMap;
33use std::future::Future;
34use std::sync::Arc;
35use std::time::{Duration, Instant};
36
37use parking_lot::Mutex;
38use tokio::sync::{broadcast, mpsc};
39use tokio_util::sync::CancellationToken;
40use zeph_common::SessionId;
41use zeph_common::task_supervisor::{BlockingHandle, TaskSupervisor};
42
43use crate::agent::Agent;
44use crate::channel::{ChannelMessage, LoopbackChannel, LoopbackEvent, LoopbackHandle};
45
46/// Stack size for the dedicated per-session thread — mirrors `zeph-acp`'s
47/// `ACP_AGENT_STACK_SIZE` (Agent futures are deeply nested, ~512 KiB measured on overflow with
48/// the default 2 MiB worker-thread stack).
49const SESSION_ACTOR_STACK_SIZE: usize = 8 * 1024 * 1024;
50
51/// Buffer capacity for the `LoopbackChannel` constructed inside [`SessionActor::spawn`]'s
52/// dedicated thread — matches the buffer used by every other headless `LoopbackChannel` consumer
53/// (e.g. `zeph-acp`'s A2A bridge).
54const LOOPBACK_CHANNEL_CAPACITY: usize = 8;
55
56/// A command sent to a live [`SessionActor`] (spec §9.2).
57#[derive(Debug)]
58pub enum SessionCommand {
59    /// Submit a new user prompt for this turn.
60    Prompt {
61        /// The prompt text.
62        text: String,
63    },
64    /// Interrupt the agent's current operation (mirrors the existing `LoopbackHandle` cancel
65    /// signal used by every other headless channel consumer).
66    Cancel,
67    /// Gracefully end the actor: closes the agent's channel input, letting `Agent::run` observe
68    /// channel closure and exit its loop on its own rather than being aborted mid-turn.
69    Shutdown,
70}
71
72/// An event streamed out of a live [`SessionActor`] (spec §9.2).
73///
74/// `Serialize`s as an adjacently tagged JSON object (`{"type": "token", "data": "..."}`) for
75/// `GET /sessions/:id/events`'s SSE stream (spec §9.4) — adjacent rather than internal tagging
76/// because [`Self::Token`] and [`Self::Error`] wrap a bare `String`, which cannot be flattened
77/// into an internally tagged object.
78#[derive(Debug, Clone, serde::Serialize)]
79#[serde(tag = "type", content = "data", rename_all = "snake_case")]
80pub enum SessionOutput {
81    /// A streamed or full-message text chunk from the agent's response.
82    Token(String),
83    /// A tool call started.
84    ToolCall {
85        /// Name of the tool being invoked.
86        tool_name: String,
87        /// Opaque tool call ID assigned by the LLM.
88        tool_call_id: String,
89    },
90    /// A tool call produced output.
91    ToolResult {
92        /// Name of the tool that produced this output.
93        tool_name: String,
94        /// Human-readable output text.
95        display: String,
96    },
97    /// The current turn finished.
98    TurnComplete,
99    /// The agent loop ended with an error.
100    Error(String),
101}
102
103/// Default broadcast channel capacity for [`SessionOutput`] — generous enough to absorb a burst
104/// of streamed tokens between a slow subscriber's polls without lagging.
105const OUTPUT_CHANNEL_CAPACITY: usize = 256;
106
107/// Owns a live [`Agent<LoopbackChannel>`](crate::agent::Agent) for one conversation-session
108/// (spec §9.2).
109///
110/// This is a driver, not a stored value — [`SessionActor::spawn`] returns a
111/// [`SessionActorHandle`] (cheap to clone, holds only channel senders) for
112/// [`LiveSessionRegistry`] to track; the actor's own state lives entirely inside the spawned
113/// task.
114pub struct SessionActor;
115
116impl SessionActor {
117    /// Spawn a new `SessionActor` under `supervisor` (architect ruling D-8).
118    ///
119    /// `build_agent` is called *inside* a dedicated thread (see the module doc) with a freshly
120    /// constructed `LoopbackChannel`, and must return the `Agent<LoopbackChannel>` built from it
121    /// — never pass an already-built `Agent` in, since `Agent<LoopbackChannel>` is `!Send` and
122    /// cannot cross the thread boundary. Mirrors `zeph-acp`'s `SendAgentSpawner`
123    /// (`Arc<dyn Fn(...) -> Agent + Send + Sync>`): typical callers wrap an existing
124    /// `AgentBuilder` pipeline (the same one used for CLI/TUI/Telegram/ACP sessions) in a closure
125    /// capturing only `Send`-safe dependencies (provider, skill registry, tool executor, the
126    /// session's `Arc<SessionEventLog>`, session id — all `Send`/`Sync`). `Agent::new` is sync,
127    /// so `build_agent` is a plain sync closure — no async-construction-in-thread complexity.
128    ///
129    /// Registers a *coordinator* task under the dynamic name `serve.session.<id>` via
130    /// [`TaskSupervisor::spawn_oneshot`] — visible through `supervisor.snapshot()`. The
131    /// coordinator never touches the `!Send` `Agent`; it only awaits the dedicated thread's
132    /// completion signal and, if the supervisor's own `CancellationToken` fires first (process
133    /// shutdown), forwards cancellation onto the session's own token so `Self::drive` observes
134    /// exactly one cancellation source regardless of trigger. Session actors intentionally do not
135    /// auto-restart on panic or unexpected exit (`spawn_oneshot`'s `RestartPolicy::RunOnce`):
136    /// re-driving a torn turn or replay in place is unsafe. Recovery is a fresh spawn (re-attach)
137    /// that replays the durable log from the last committed `seq`, not an in-place restart.
138    ///
139    /// Returns the [`SessionActorHandle`] for [`LiveSessionRegistry`] plus the raw
140    /// [`BlockingHandle`] — callers that need a forced-abort fallback (e.g. if a `serve.evict`
141    /// idle-eviction task's cancellation overruns its TTL grace) should hold onto the latter. The
142    /// graceful paths are sending [`SessionCommand::Shutdown`] over [`SessionActorHandle::tx`],
143    /// or cancelling [`SessionActorHandle::cancel`] directly (what idle eviction uses, spec §9.3,
144    /// to target exactly this session without affecting any other live actor).
145    #[must_use]
146    pub fn spawn<F>(
147        supervisor: &TaskSupervisor,
148        registry: &Arc<LiveSessionRegistry>,
149        session_id: &SessionId,
150        build_agent: F,
151        mailbox_capacity: usize,
152        resume_banner: Option<String>,
153    ) -> (SessionActorHandle, BlockingHandle<()>)
154    where
155        F: FnOnce(LoopbackChannel) -> Agent<LoopbackChannel> + Send + 'static,
156    {
157        let (cmd_tx, cmd_rx) = mpsc::channel(mailbox_capacity.max(1));
158        let (tx_out, _first_subscriber) = broadcast::channel(OUTPUT_CHANNEL_CAPACITY);
159        let tx_out_for_actor = tx_out.clone();
160        let id_str = session_id.as_str().to_owned();
161
162        // Per-session cancellation, distinct from the supervisor's process-wide token, so idle
163        // eviction can target exactly this session (spec §9.3) without tearing down every live
164        // actor. The coordinator forwards a supervisor-wide shutdown onto this same token.
165        let session_cancel = CancellationToken::new();
166        let session_cancel_for_thread = session_cancel.clone();
167        let session_cancel_for_coordinator = session_cancel.clone();
168
169        let (done_tx, done_rx) = tokio::sync::oneshot::channel::<()>();
170        let thread_name = format!("serve-session-{id_str}");
171        let thread_session_id = id_str.clone();
172        let spawn_result = std::thread::Builder::new()
173            .name(thread_name)
174            .stack_size(SESSION_ACTOR_STACK_SIZE)
175            .spawn(move || {
176                let rt = match tokio::runtime::Builder::new_current_thread()
177                    .enable_all()
178                    .build()
179                {
180                    Ok(rt) => rt,
181                    Err(e) => {
182                        tracing::error!(
183                            session_id = %thread_session_id,
184                            error = %e,
185                            "failed to build session actor tokio runtime"
186                        );
187                        let _ = done_tx.send(());
188                        return;
189                    }
190                };
191                let (channel, handle) = LoopbackChannel::pair(LOOPBACK_CHANNEL_CAPACITY);
192                let agent = build_agent(channel);
193                let local = tokio::task::LocalSet::new();
194                rt.block_on(local.run_until(Self::drive(
195                    agent,
196                    handle,
197                    cmd_rx,
198                    tx_out_for_actor,
199                    session_cancel_for_thread,
200                )));
201                let _ = done_tx.send(());
202            });
203
204        if let Err(e) = spawn_result {
205            tracing::error!(error = %e, "failed to spawn dedicated session actor thread");
206        }
207
208        let name: Arc<str> = Arc::from(format!("serve.session.{id_str}"));
209        let supervisor_cancel = supervisor.cancellation_token();
210        let registry_for_coordinator = Arc::clone(registry);
211        let session_id_for_coordinator = session_id.clone();
212        let tx_for_coordinator = cmd_tx.clone();
213        let blocking_handle = supervisor.spawn_oneshot(name, move || {
214            Self::coordinate(
215                done_rx,
216                supervisor_cancel,
217                session_cancel_for_coordinator,
218                registry_for_coordinator,
219                session_id_for_coordinator,
220                tx_for_coordinator,
221            )
222        });
223
224        (
225            SessionActorHandle {
226                tx: cmd_tx,
227                tx_out,
228                last_active: Instant::now(),
229                cancel: session_cancel,
230                resume_banner_sent: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
231                pending_resume_banner: resume_banner.map(Arc::from),
232            },
233            blocking_handle,
234        )
235    }
236
237    /// Thin `Send`-safe coordinator handed to [`TaskSupervisor::spawn_oneshot`]: never touches
238    /// the `!Send` `Agent` — only a thread-completion signal and cancellation tokens. Forwards a
239    /// process-wide supervisor shutdown onto the session's own [`CancellationToken`] so
240    /// [`Self::drive`] (running on the dedicated thread) only ever needs to select on one
241    /// cancellation source.
242    ///
243    /// M1 (impl-critic finding): reaps `registry`'s entry for `session_id` unconditionally once
244    /// the dedicated thread signals completion — regardless of *why* it ended (agent panic,
245    /// normal exit, or supervisor shutdown). Before this, [`LiveSessionRegistry::idle_candidates`]
246    /// was the *only* reap path, and it requires `receiver_count() == 0`; a lingering `GET
247    /// /sessions/:id/events` SSE subscriber (or a crashed actor whose stream simply stops
248    /// emitting, never closing) could pin a dead session in the registry forever, so
249    /// `POST /sessions/:id/prompt` would return `410 Gone` for it permanently with no path back
250    /// to a live actor even under D-12's reactivation. Uses
251    /// [`LiveSessionRegistry::remove_if_current`] (not a plain key-based `remove`) so a
252    /// concurrent reactivation that has already `insert`ed a fresh handle under the same
253    /// `session_id` is never evicted by this now-dead coordinator.
254    async fn coordinate(
255        done_rx: tokio::sync::oneshot::Receiver<()>,
256        supervisor_cancel: CancellationToken,
257        session_cancel: CancellationToken,
258        registry: Arc<LiveSessionRegistry>,
259        session_id: SessionId,
260        tx: mpsc::Sender<SessionCommand>,
261    ) {
262        let mut done_rx = done_rx;
263        tokio::select! {
264            _ = &mut done_rx => {}
265            () = supervisor_cancel.cancelled() => {
266                session_cancel.cancel();
267                let _ = done_rx.await;
268            }
269        }
270        registry.remove_if_current(&session_id, &tx);
271    }
272
273    /// Bridge `cmd_rx` into the agent's `LoopbackChannel` input and forward the channel's output
274    /// as [`SessionOutput`] over `tx_out`, while concurrently driving `agent.run()` to
275    /// completion. A single `tokio::select!` loop — no raw `tokio::spawn` — per the non-blocking
276    /// contract (CLAUDE.md Async & Background Tasks).
277    ///
278    /// Must run inside a `tokio::task::LocalSet` (or be `.await`ed directly, never spawned via
279    /// a `Send`-bound spawner) because `Agent<C>`'s futures are `!Send`.
280    ///
281    /// `cancel` cancelling (via `TaskSupervisor::shutdown_all`) is treated the same as
282    /// [`SessionCommand::Shutdown`] — a graceful flush (drop the channel's input sender, let
283    /// `Agent::run` observe closure and exit, drain buffered output) rather than the abrupt
284    /// task-abort `shutdown_all` would otherwise apply. Abrupt abort is still safe (INV-SP-2
285    /// torn-tail truncation covers a torn trailing write) but an explicit flush avoids leaving a
286    /// truncated trailing event on every shutdown.
287    async fn drive(
288        mut agent: Agent<LoopbackChannel>,
289        handle: LoopbackHandle,
290        mut cmd_rx: mpsc::Receiver<SessionCommand>,
291        tx_out: broadcast::Sender<SessionOutput>,
292        cancel: CancellationToken,
293    ) {
294        let LoopbackHandle {
295            input_tx,
296            mut output_rx,
297            cancel_signal,
298        } = handle;
299        let mut input_tx = Some(input_tx);
300
301        let mut agent_run = std::pin::pin!(agent.run());
302        let mut agent_done = false;
303
304        while !agent_done {
305            tokio::select! {
306                biased;
307                result = &mut agent_run => {
308                    agent_done = true;
309                    if let Err(e) = result {
310                        tracing::warn!(error = %e, "session actor: agent run ended with error");
311                        let _ = tx_out.send(SessionOutput::Error(e.to_string()));
312                    }
313                }
314                Some(event) = output_rx.recv() => {
315                    if let Some(output) = translate_loopback_event(event) {
316                        let _ = tx_out.send(output);
317                    }
318                }
319                () = cancel.cancelled(), if input_tx.is_some() => {
320                    tracing::info!("session actor: supervisor shutdown, flushing and exiting");
321                    input_tx = None;
322                }
323                cmd = cmd_rx.recv() => {
324                    match cmd {
325                        Some(SessionCommand::Prompt { text }) => {
326                            if let Some(tx) = &input_tx {
327                                let msg = ChannelMessage {
328                                    text,
329                                    attachments: Vec::new(),
330                                    is_guest_context: false,
331                                    is_from_bot: false,
332                                    owner_key: None,
333                                };
334                                tracing::debug!("session actor: forwarding prompt to agent channel");
335                                let _ = tx.send(msg).await;
336                                tracing::debug!("session actor: prompt forwarded");
337                            }
338                        }
339                        Some(SessionCommand::Cancel) => cancel_signal.notify_one(),
340                        Some(SessionCommand::Shutdown) | None => {
341                            // Drop the sender: `Agent::run`'s `next_event()` observes the closed
342                            // channel and returns `Ok(None)`, ending the loop gracefully (see
343                            // `crates/zeph-core/src/agent/mod.rs::next_event` doc comment).
344                            input_tx = None;
345                        }
346                    }
347                }
348            }
349        }
350
351        // Drain any output events buffered just before the agent loop ended.
352        while let Ok(event) = output_rx.try_recv() {
353            if let Some(output) = translate_loopback_event(event) {
354                let _ = tx_out.send(output);
355            }
356        }
357    }
358}
359
360/// Maps a [`LoopbackEvent`] to a [`SessionOutput`], or `None` for event kinds not yet part of
361/// the spec §9.2 `SessionOutput` schema (`Status`/`ThinkingChunk`/`Usage`/`SessionTitle`/`Plan`/
362/// `Stop`) — dropped rather than guessed at an ad hoc extension.
363fn translate_loopback_event(event: LoopbackEvent) -> Option<SessionOutput> {
364    match event {
365        LoopbackEvent::Chunk(text) | LoopbackEvent::FullMessage(text) => {
366            Some(SessionOutput::Token(text))
367        }
368        LoopbackEvent::Flush => Some(SessionOutput::TurnComplete),
369        LoopbackEvent::ToolStart(ev) => Some(SessionOutput::ToolCall {
370            tool_name: ev.tool_name.to_string(),
371            tool_call_id: ev.tool_call_id,
372        }),
373        LoopbackEvent::ToolOutput(ev) => Some(SessionOutput::ToolResult {
374            tool_name: ev.tool_name.to_string(),
375            display: ev.display,
376        }),
377        _ => None,
378    }
379}
380
381/// Bookkeeping handle for one live session, held by [`LiveSessionRegistry`] (spec §9.3).
382///
383/// Cheap to clone — `tx`/`tx_out` are `Arc`-backed channel senders.
384#[derive(Clone)]
385pub struct SessionActorHandle {
386    /// Mailbox for [`SessionCommand`]s; same-session prompts are serialized by this mpsc's FIFO
387    /// ordering (spec §9.2 concurrency policy — no separate turn-lock needed).
388    pub tx: mpsc::Sender<SessionCommand>,
389    /// Broadcast source new subscribers (SSE connections, TUI attach) subscribe to.
390    pub tx_out: broadcast::Sender<SessionOutput>,
391    /// Updated by [`LiveSessionRegistry::get`] on every lookup; read by
392    /// [`LiveSessionRegistry::idle_candidates`] for TTL eviction.
393    pub last_active: Instant,
394    /// Cancelling this token ends exactly this session's actor gracefully — the same effect as
395    /// sending [`SessionCommand::Shutdown`], but usable without an owned mpsc permit. What
396    /// `serve.evict` idle eviction (spec §9.3) calls to target one session without affecting any
397    /// other live actor. Distinct from the process-wide `TaskSupervisor` cancellation token.
398    pub cancel: CancellationToken,
399    /// Resume-banner single-emission guard (spec-068 §13.5, AC-24): when more than one
400    /// display-owning channel attaches to the same live session, exactly one attach must
401    /// render the banner. `Arc`-shared across every `Clone` of this handle so all attach
402    /// paths observe the same flag. Use [`Self::claim_resume_banner`] rather than reading
403    /// this directly.
404    pub resume_banner_sent: std::sync::Arc<std::sync::atomic::AtomicBool>,
405    /// Resume-visibility banner text, computed once at session build time (spec-068 §13.5,
406    /// AC-24) from the session's replayed history. `None` when `[session.resume] show_banner
407    /// = false` or the session had no prior history to resume. Rendered by exactly one
408    /// attach path, gated by [`Self::claim_resume_banner`] — see `GET /sessions/:id/events`
409    /// (`events_session_handler`, `src/serve/handlers.rs`) for the sole production consumer.
410    pub pending_resume_banner: Option<std::sync::Arc<str>>,
411}
412
413impl SessionActorHandle {
414    /// Atomically claim the right to render the resume banner for this session.
415    ///
416    /// Returns `true` for exactly one caller across all attach paths sharing this handle
417    /// (via `Clone`) — that caller renders the banner; every other caller (this attach or
418    /// any subsequent one) gets `false` and must render nothing (spec-068 §13.5, AC-24).
419    #[must_use]
420    pub fn claim_resume_banner(&self) -> bool {
421        self.resume_banner_sent
422            .compare_exchange(
423                false,
424                true,
425                std::sync::atomic::Ordering::AcqRel,
426                std::sync::atomic::Ordering::Acquire,
427            )
428            .is_ok()
429    }
430}
431
432/// Registry of live [`SessionActor`]s for `zeph serve` (spec §9.3).
433///
434/// Distinct from the TUI's own `SessionRegistry`/`SlotId` tab-switching abstraction
435/// (`zeph-tui/src/session.rs`) — this is `zeph serve`-specific bookkeeping only. The internal
436/// `sessions` mutex is never held across `.await`; [`Self::get_or_reactivate`]'s
437/// `reactivation_lock` is a separate `tokio::sync::Mutex` deliberately held across `.await` for
438/// its entire critical section — see that method's doc comment.
439#[derive(Default)]
440pub struct LiveSessionRegistry {
441    sessions: Mutex<HashMap<SessionId, SessionActorHandle>>,
442    /// Serializes [`Self::get_or_reactivate`]'s check-build-insert critical section (N1,
443    /// impl-critic re-verify finding): without it, two concurrent requests for the same
444    /// evicted-but-durable session both miss the fast-path `get`, both independently replay and
445    /// spawn a `SessionActor` over the *same* `SessionEventLog` file, and the second `insert`
446    /// silently orphans the first — two live writers on one INV-D2 single-writer log, corrupting
447    /// it (duplicate `seq`s, a torn line that isn't the trailing one). A single process-wide lock
448    /// (not a per-session map) is deliberate: reactivation is a rare recovery path, not the hot
449    /// `get()` path every prompt/events call takes first — serializing distinct sessions'
450    /// reactivations against each other is an acceptable trade for not needing to manage a
451    /// second, dynamically-growing lock table's own cleanup/eviction lifecycle.
452    reactivation_lock: tokio::sync::Mutex<()>,
453}
454
455impl LiveSessionRegistry {
456    /// Construct an empty registry.
457    #[must_use]
458    pub fn new() -> Self {
459        Self::default()
460    }
461
462    /// Look up a live session by id, refreshing its `last_active` timestamp on hit.
463    #[must_use]
464    pub fn get(&self, id: &SessionId) -> Option<SessionActorHandle> {
465        let mut sessions = self.sessions.lock();
466        let handle = sessions.get_mut(id)?;
467        handle.last_active = Instant::now();
468        Some(handle.clone())
469    }
470
471    /// Register a freshly spawned actor's handle, replacing (and dropping, without aborting) any
472    /// prior entry under the same id.
473    pub fn insert(&self, id: SessionId, handle: SessionActorHandle) {
474        self.sessions.lock().insert(id, handle);
475    }
476
477    /// Look up a live session, reactivating it via `reactivate` if it isn't currently live —
478    /// atomically, so two concurrent callers for the same absent `id` can never both win and
479    /// double-spawn a `SessionActor` over the same durable log (N1, impl-critic re-verify
480    /// finding: `SessionEventLog` is single-writer per INV-D2, and a plain `get()`-miss-then-
481    /// spawn-then-`insert()` sequence has no such guarantee under concurrency).
482    ///
483    /// Fast path (the common case — session already live): a plain `get()`, no lock contention
484    /// with any in-flight reactivation elsewhere. Slow path (session absent): acquires
485    /// `reactivation_lock`, then re-checks `get()` *under the lock* — if a concurrent caller won
486    /// the race and already reactivated `id` while this caller was waiting for the lock, that
487    /// caller's `insert` is visible here and `reactivate` is never invoked a second time. Only
488    /// the loser of the race skips calling `reactivate`; the winner runs it exactly once.
489    ///
490    /// `reactivate` is expected to build+spawn the actor and `insert` it into `self` before
491    /// resolving — it runs to completion holding `reactivation_lock`, so its own `insert` is
492    /// safely serialized against any other concurrent `get_or_reactivate` call for the same
493    /// (or a different) id.
494    #[tracing::instrument(
495        name = "core.serve.registry.get_or_reactivate",
496        skip_all,
497        level = "debug",
498        fields(session_id = id.as_str())
499    )]
500    pub async fn get_or_reactivate<F, Fut>(
501        &self,
502        id: &SessionId,
503        reactivate: F,
504    ) -> Option<SessionActorHandle>
505    where
506        F: FnOnce() -> Fut,
507        Fut: Future<Output = Option<SessionActorHandle>>,
508    {
509        if let Some(handle) = self.get(id) {
510            return Some(handle);
511        }
512        let _guard = self.reactivation_lock.lock().await;
513        if let Some(handle) = self.get(id) {
514            return Some(handle);
515        }
516        reactivate().await
517    }
518
519    /// Remove `id`'s entry only if it is still the exact handle identified by `tx` (compared via
520    /// [`mpsc::Sender::same_channel`], which is `true` iff both senders share the same underlying
521    /// channel).
522    ///
523    /// Used by [`SessionActor`]'s coordinator (M1) to reap its own registry entry on completion
524    /// without racing a concurrent reactivation (D-12) that has already `insert`ed a *fresh*
525    /// handle under the same id — a plain `remove(id)` there would delete the new entry too, an
526    /// unconditional key-based removal cannot tell "my own now-dead entry" from "a different,
527    /// live entry that happens to share this id".
528    pub fn remove_if_current(
529        &self,
530        id: &SessionId,
531        tx: &mpsc::Sender<SessionCommand>,
532    ) -> Option<SessionActorHandle> {
533        let mut sessions = self.sessions.lock();
534        if sessions.get(id).is_some_and(|h| h.tx.same_channel(tx)) {
535            sessions.remove(id)
536        } else {
537            None
538        }
539    }
540
541    /// Remove a session's handle (used by idle eviction and explicit shutdown), returning it if
542    /// present.
543    pub fn remove(&self, id: &SessionId) -> Option<SessionActorHandle> {
544        self.sessions.lock().remove(id)
545    }
546
547    /// Session ids with no attached broadcast receivers (`receiver_count() == 0`) whose
548    /// `last_active` is at least `ttl` old — eviction candidates for a `serve.evict` task
549    /// (spec §9.3).
550    #[must_use]
551    pub fn idle_candidates(&self, ttl: Duration) -> Vec<SessionId> {
552        let sessions = self.sessions.lock();
553        let now = Instant::now();
554        sessions
555            .iter()
556            .filter(|(_, handle)| {
557                handle.tx_out.receiver_count() == 0 && now.duration_since(handle.last_active) >= ttl
558            })
559            .map(|(id, _)| id.clone())
560            .collect()
561    }
562
563    /// Ids of all live sessions currently tracked, in arbitrary order.
564    #[must_use]
565    pub fn ids(&self) -> Vec<SessionId> {
566        self.sessions.lock().keys().cloned().collect()
567    }
568
569    /// Number of live sessions currently tracked.
570    #[must_use]
571    pub fn len(&self) -> usize {
572        self.sessions.lock().len()
573    }
574
575    /// `true` when no sessions are tracked.
576    #[must_use]
577    pub fn is_empty(&self) -> bool {
578        self.len() == 0
579    }
580}
581
582#[cfg(test)]
583mod tests {
584    use std::time::Duration;
585
586    use super::*;
587
588    fn make_handle() -> SessionActorHandle {
589        let (tx, _rx) = mpsc::channel(4);
590        let (tx_out, _sub) = broadcast::channel(4);
591        SessionActorHandle {
592            tx,
593            tx_out,
594            last_active: Instant::now(),
595            cancel: CancellationToken::new(),
596            resume_banner_sent: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
597            pending_resume_banner: None,
598        }
599    }
600
601    /// Regression test for AC-24 (spec-068 §13.5): when multiple display-owning channels
602    /// attach to the same live session, exactly one attach must win the resume banner claim.
603    #[test]
604    fn claim_resume_banner_wins_exactly_once_across_clones() {
605        let handle = make_handle();
606        let attach_a = handle.clone();
607        let attach_b = handle.clone();
608
609        assert!(
610            attach_a.claim_resume_banner(),
611            "first attach must win the claim"
612        );
613        assert!(
614            !attach_b.claim_resume_banner(),
615            "second attach (sharing the same underlying flag via Clone) must not win"
616        );
617        assert!(
618            !handle.claim_resume_banner(),
619            "a third read via the original handle must also see the claim as already taken"
620        );
621    }
622
623    #[test]
624    fn registry_insert_and_get_round_trips() {
625        let registry = LiveSessionRegistry::new();
626        let id = SessionId::new("s1");
627        registry.insert(id.clone(), make_handle());
628        assert!(registry.get(&id).is_some());
629        assert_eq!(registry.len(), 1);
630    }
631
632    #[test]
633    fn registry_get_missing_returns_none() {
634        let registry = LiveSessionRegistry::new();
635        assert!(registry.get(&SessionId::new("nope")).is_none());
636    }
637
638    #[test]
639    fn registry_remove_drops_entry() {
640        let registry = LiveSessionRegistry::new();
641        let id = SessionId::new("s1");
642        registry.insert(id.clone(), make_handle());
643        assert!(registry.remove(&id).is_some());
644        assert!(registry.get(&id).is_none());
645        assert!(registry.is_empty());
646    }
647
648    #[test]
649    fn registry_remove_if_current_drops_matching_entry() {
650        let registry = LiveSessionRegistry::new();
651        let id = SessionId::new("s1");
652        let handle = make_handle();
653        let tx = handle.tx.clone();
654        registry.insert(id.clone(), handle);
655
656        assert!(registry.remove_if_current(&id, &tx).is_some());
657        assert!(registry.get(&id).is_none());
658    }
659
660    /// M1 regression: a stale coordinator's `remove_if_current` (its own now-dead handle's `tx`)
661    /// must NOT evict a fresh entry a concurrent reactivation (D-12) already `insert`ed under the
662    /// same id — the exact race a plain key-based `remove` would lose.
663    #[test]
664    fn registry_remove_if_current_ignores_stale_tx_after_reactivation() {
665        let registry = LiveSessionRegistry::new();
666        let id = SessionId::new("s1");
667        let stale_handle = make_handle();
668        let stale_tx = stale_handle.tx.clone();
669        registry.insert(id.clone(), stale_handle);
670
671        // A concurrent reactivation replaces the entry with a fresh handle under the same id.
672        registry.insert(id.clone(), make_handle());
673
674        // The stale coordinator's reap call must be a no-op — the fresh entry survives.
675        assert!(registry.remove_if_current(&id, &stale_tx).is_none());
676        assert!(
677            registry.get(&id).is_some(),
678            "a stale coordinator must never evict a concurrently-reactivated entry"
679        );
680    }
681
682    /// N1 regression (impl-critic re-verify finding): a genuine concurrency test, not a
683    /// sequential table-driven one — real `tokio::spawn` tasks race `get_or_reactivate` for the
684    /// same absent session id, each `.await`ing a `yield_now()` inside its `reactivate` closure
685    /// so they actually interleave (without the yield, the first task could run its whole
686    /// closure to completion before the second is even polled, defeating the point of the test).
687    /// Before the `reactivation_lock`, every task's fast-path `get()` would miss and every task
688    /// would run its `reactivate` closure — spawning N independent `SessionActorHandle`s that
689    /// each `insert` under the same id, the exact double-spawn-over-one-log scenario N1
690    /// describes. With the fix, exactly one task's closure must run to completion.
691    #[tokio::test]
692    async fn get_or_reactivate_serializes_concurrent_reactivation_for_the_same_id() {
693        use std::sync::atomic::{AtomicUsize, Ordering};
694
695        let registry = Arc::new(LiveSessionRegistry::new());
696        let id = SessionId::new("race-test");
697        let reactivate_calls = Arc::new(AtomicUsize::new(0));
698
699        let mut tasks = Vec::new();
700        for _ in 0..8 {
701            let registry = Arc::clone(&registry);
702            let id = id.clone();
703            let reactivate_calls = Arc::clone(&reactivate_calls);
704            tasks.push(tokio::spawn(async move {
705                registry
706                    .get_or_reactivate(&id, || {
707                        let registry = Arc::clone(&registry);
708                        let id = id.clone();
709                        let reactivate_calls = Arc::clone(&reactivate_calls);
710                        async move {
711                            // Force a real interleaving window — without this, tasks could
712                            // resolve strictly in spawn order without ever actually contending
713                            // for `reactivation_lock`.
714                            tokio::task::yield_now().await;
715                            reactivate_calls.fetch_add(1, Ordering::SeqCst);
716                            let handle = make_handle();
717                            registry.insert(id, handle.clone());
718                            Some(handle)
719                        }
720                    })
721                    .await
722            }));
723        }
724
725        for task in tasks {
726            assert!(
727                task.await.unwrap().is_some(),
728                "every concurrent caller must resolve to a live handle, win or lose the race"
729            );
730        }
731
732        assert_eq!(
733            reactivate_calls.load(Ordering::SeqCst),
734            1,
735            "exactly one concurrent caller may run the reactivation closure — a second run means \
736             two SessionActors would have been spawned over the same durable log (N1)"
737        );
738    }
739
740    #[test]
741    fn registry_ids_lists_all_tracked_sessions() {
742        let registry = LiveSessionRegistry::new();
743        assert!(registry.ids().is_empty());
744        registry.insert(SessionId::new("s1"), make_handle());
745        registry.insert(SessionId::new("s2"), make_handle());
746        let mut ids: Vec<String> = registry
747            .ids()
748            .into_iter()
749            .map(|id| id.as_str().to_owned())
750            .collect();
751        ids.sort();
752        assert_eq!(ids, vec!["s1".to_owned(), "s2".to_owned()]);
753    }
754
755    #[test]
756    fn registry_idle_candidates_requires_no_subscribers_and_expired_ttl() {
757        let registry = LiveSessionRegistry::new();
758        let id = SessionId::new("s1");
759        let mut handle = make_handle();
760        // No subscriber to tx_out, but last_active is "now" — not yet past a long TTL.
761        handle.last_active = Instant::now();
762        registry.insert(id.clone(), handle);
763        assert!(registry.idle_candidates(Duration::from_hours(1)).is_empty());
764    }
765
766    #[test]
767    fn registry_idle_candidates_skips_sessions_with_active_subscribers() {
768        let registry = LiveSessionRegistry::new();
769        let id = SessionId::new("s1");
770        let mut handle = make_handle();
771        handle.last_active = Instant::now()
772            .checked_sub(Duration::from_secs(9999))
773            .unwrap();
774        let _subscriber = handle.tx_out.subscribe();
775        registry.insert(id, handle);
776        assert!(registry.idle_candidates(Duration::from_secs(1)).is_empty());
777    }
778
779    #[test]
780    fn registry_idle_candidates_returns_expired_unattached_sessions() {
781        let registry = LiveSessionRegistry::new();
782        let id = SessionId::new("s1");
783        let mut handle = make_handle();
784        handle.last_active = Instant::now()
785            .checked_sub(Duration::from_secs(9999))
786            .unwrap();
787        registry.insert(id.clone(), handle);
788        let candidates = registry.idle_candidates(Duration::from_secs(1));
789        assert_eq!(candidates, vec![id]);
790    }
791
792    #[tokio::test]
793    async fn session_actor_drive_shuts_down_cleanly_on_command() {
794        use crate::agent::Agent;
795        use crate::agent::agent_tests::{MockToolExecutor, create_test_registry, mock_provider};
796
797        // `LoopbackChannel::pair` gives exactly the (channel, handle) split `SessionActor::drive`
798        // bridges between — the same split every other headless channel consumer (A2A) uses.
799        let (channel, handle) = LoopbackChannel::pair(8);
800        let provider = mock_provider(vec!["ok".to_owned()]);
801        let registry = create_test_registry();
802        let executor = MockToolExecutor::no_tools();
803        let agent: Agent<LoopbackChannel> =
804            Agent::new(provider, channel, registry, None, 5, executor);
805
806        let (cmd_tx, cmd_rx) = mpsc::channel::<SessionCommand>(4);
807        let (tx_out, _sub) = broadcast::channel::<SessionOutput>(16);
808
809        // `Agent<C>`'s futures are `!Send` (see the module doc), so `drive`'s future cannot be
810        // handed to `tokio::spawn`. Pre-queue both commands into the bounded mpsc buffer, then
811        // `.await` `drive` directly in this test task — no cross-thread Send requirement applies
812        // to a future that is only ever polled in place, never spawned.
813        cmd_tx
814            .send(SessionCommand::Prompt {
815                text: "hello".to_owned(),
816            })
817            .await
818            .unwrap();
819        cmd_tx.send(SessionCommand::Shutdown).await.unwrap();
820        drop(cmd_tx);
821
822        // `drive`'s future embeds the whole `Agent` state (large) — box it per
823        // `clippy::large_futures` rather than growing this test task's stack.
824        tokio::time::timeout(
825            Duration::from_secs(10),
826            Box::pin(SessionActor::drive(
827                agent,
828                handle,
829                cmd_rx,
830                tx_out,
831                CancellationToken::new(),
832            )),
833        )
834        .await
835        .expect("drive must finish within the timeout");
836    }
837
838    #[tokio::test]
839    async fn session_actor_spawn_runs_on_dedicated_thread_and_shuts_down() {
840        use crate::agent::Agent;
841        use crate::agent::agent_tests::{MockToolExecutor, create_test_registry, mock_provider};
842
843        let supervisor = TaskSupervisor::new(CancellationToken::new());
844        let registry = Arc::new(LiveSessionRegistry::new());
845        let session_id = SessionId::new("spawn-test");
846
847        // `build_agent` is `Send` (captures only `Send`-safe test doubles); the `Agent` it
848        // constructs is built *inside* the dedicated thread `spawn` creates from the
849        // caller-supplied `LoopbackChannel`, never crossing a thread boundary itself.
850        let (handle, blocking_handle) = SessionActor::spawn(
851            &supervisor,
852            &registry,
853            &session_id,
854            move |channel| {
855                let provider = mock_provider(vec!["ok".to_owned()]);
856                let registry = create_test_registry();
857                let executor = MockToolExecutor::no_tools();
858                let agent: Agent<LoopbackChannel> =
859                    Agent::new(provider, channel, registry, None, 5, executor);
860                agent
861            },
862            4,
863            None,
864        );
865
866        handle
867            .tx
868            .send(SessionCommand::Prompt {
869                text: "hello".to_owned(),
870            })
871            .await
872            .unwrap();
873        handle.tx.send(SessionCommand::Shutdown).await.unwrap();
874
875        tokio::time::timeout(Duration::from_secs(10), blocking_handle.join())
876            .await
877            .expect("session actor must finish within the timeout")
878            .expect("session actor task must not panic or be aborted");
879
880        // M1 (impl-critic finding): the coordinator must reap its own registry entry once the
881        // dedicated thread signals completion, not only via `idle_candidates`'s
882        // `receiver_count() == 0` TTL path — otherwise a session whose actor died with no
883        // eviction ever running stays permanently registered as "live" with a dead mailbox.
884        assert!(
885            registry.get(&session_id).is_none(),
886            "registry entry must be reaped once the session actor's coordinator completes"
887        );
888    }
889
890    #[tokio::test]
891    async fn session_actor_handle_cancel_shuts_down_without_supervisor_shutdown() {
892        use crate::agent::Agent;
893        use crate::agent::agent_tests::{MockToolExecutor, create_test_registry, mock_provider};
894
895        // Regression test for the D-8 per-session cancellation path: `serve.evict` idle eviction
896        // cancels one session's own `SessionActorHandle::cancel` — it must terminate that actor
897        // without the supervisor's own token ever being cancelled (i.e. without a process-wide
898        // shutdown), proving the two cancellation sources are genuinely independent.
899        let supervisor = TaskSupervisor::new(CancellationToken::new());
900        let registry = Arc::new(LiveSessionRegistry::new());
901        let session_id = SessionId::new("cancel-test");
902
903        let (handle, blocking_handle) = SessionActor::spawn(
904            &supervisor,
905            &registry,
906            &session_id,
907            move |channel| {
908                let provider = mock_provider(vec!["ok".to_owned()]);
909                let registry = create_test_registry();
910                let executor = MockToolExecutor::no_tools();
911                Agent::new(provider, channel, registry, None, 5, executor)
912            },
913            4,
914            None,
915        );
916
917        // No `SessionCommand::Shutdown` sent — cancel the per-session token directly, as idle
918        // eviction would.
919        handle.cancel.cancel();
920
921        tokio::time::timeout(Duration::from_secs(10), blocking_handle.join())
922            .await
923            .expect("session actor must finish within the timeout after its own token cancels")
924            .expect("session actor task must not panic or be aborted");
925    }
926
927    /// Samples this process's own RSS via `sysinfo` — the same technique
928    /// `system_metrics::spawn_system_metrics_task` uses in production — so results are directly
929    /// comparable to other in-process RSS measurements.
930    fn sample_rss(sys: &mut sysinfo::System, pid: sysinfo::Pid) -> u64 {
931        sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
932        sys.process(pid).map_or(0, sysinfo::Process::memory)
933    }
934
935    // TODO(critic): decompose idle floor (stack-resident vs Agent heap vs skill registry) and
936    // add a production-realistic (non-mock) upper-bound variant.
937    //
938    /// NFR-P7 follow-up (#5840): the #5445 standalone harness measured `SessionActor::spawn`'s
939    /// structural housing cost in isolation (thread stack, runtime, channels — ~65-93 KiB/actor,
940    /// see `specs/068-session-persistence/nfr.md`) but could not construct a real
941    /// `Agent<LoopbackChannel>`, which is private to this crate. This test closes that gap: it
942    /// spawns real `SessionActor`s wrapping real (mock-provider-backed, since no live LLM/Qdrant
943    /// is needed to measure idle housing) `Agent<LoopbackChannel>` instances in-process, so the
944    /// measured RSS also includes `Agent`'s own owned state — not just the actor's housing.
945    ///
946    /// **This is a zero-conversation-turn floor** (no prompts are ever sent), so the ~875-905 KiB
947    /// measured here is *not* `Agent`'s message-history buffer, which is empty throughout. The
948    /// dominant contributor is not yet decomposed (see the `TODO` above) but is more likely extra
949    /// resident pages of the actor's 8 MiB thread stack ([`SESSION_ACTOR_STACK_SIZE`]) touched by
950    /// the deeper `Agent::new`/`drive` call chain, and/or the shared `SkillRegistry` load —
951    /// neither of which #5445's Agent-less harness ever touched.
952    ///
953    /// **Lower bound, not an upper bound**: `mock_provider` has no HTTP client/connection pool,
954    /// [`MockToolExecutor::no_tools`] carries no tool definitions, and the shared registry below
955    /// loads exactly one trivial skill with no embedding vectors. A production session's real
956    /// `SkillRegistry` (many skills + embeddings), real provider (reqwest client + pool), and any
957    /// `SemanticMemory` state will all measure higher than what this test reports — a pass here is
958    /// not proof that a production idle session stays under NFR-P7's budget.
959    ///
960    /// **Composite vs. NFR-P7's own scope**: `specs/068-session-persistence/nfr.md`'s NFR-P7 is
961    /// defined as housing-only (thread stack + runtime + channels); the composite this test
962    /// measures (housing + `Agent`'s owned state) is a distinct, larger quantity the spec's #5445
963    /// rationale explicitly separates out. The `assert!` below reuses NFR-P7's 1 MiB number as a
964    /// convenience threshold for this composite measurement, not as a formal claim that NFR-P7
965    /// (as specified) is satisfied — see the "NFR-P7 follow-up (#5840)" rationale note in
966    /// `nfr.md` for the recorded distinction. This test is also `#[ignore]`d and matched by no CI
967    /// workflow filter, so it never runs automatically — it is a manual tripwire for whoever
968    /// invokes it by hand, not an automated regression gate.
969    ///
970    /// Mirrors the #5445 harness's approach of measuring marginal RSS across growing cumulative
971    /// batches (10, 25, 50, 100 actors) so one-time fixed costs (allocator warmup, first-touch
972    /// page faults) amortize out. The reported floor is the delta between the *last two*
973    /// checkpoints (50→100), not an earlier window — the 50→100 and 25→50 deltas agree to within
974    /// noise (confirming convergence), whereas the 10→25 window still carries first-batch fixed
975    /// costs and reads noticeably higher; using the converged tail avoids attributing one-time
976    /// warmup cost to the per-session marginal figure.
977    #[tokio::test(flavor = "multi_thread")]
978    #[ignore = "spawns up to 100 real OS threads; run explicitly to verify NFR-P7, e.g. \
979                `cargo nextest run -p zeph-core -E 'test(nfr_p7)' --run-ignored ignored-only \
980                --no-capture`"]
981    async fn nfr_p7_real_agent_idle_session_memory_floor() {
982        use crate::agent::Agent;
983        use crate::agent::agent_tests::{MockToolExecutor, create_test_registry, mock_provider};
984
985        let supervisor = TaskSupervisor::new(CancellationToken::new());
986        let registry = Arc::new(LiveSessionRegistry::new());
987        let pid = sysinfo::get_current_pid().expect("current pid must be resolvable");
988        let mut sys = sysinfo::System::new();
989
990        // Shared across every spawned Agent, mirroring production (`src/serve/agent_factory.rs`'s
991        // `build_agent_factory`, which passes one `Clone`-shared `deps.registry` to
992        // `Agent::new_with_registry_arc` for every session) rather than each actor building its
993        // own private registry via the simpler `Agent::new`. A 101st real session costs one `Arc`
994        // clone, not a whole new registry — this keeps the measured marginal cost representative
995        // of production instead of overstating it with a per-actor registry that doesn't scale.
996        let skill_registry = Arc::new(parking_lot::RwLock::new(create_test_registry()));
997
998        let mut actors = Vec::new();
999        let mut checkpoints: Vec<(usize, u64)> = Vec::new();
1000
1001        for target in [10usize, 25, 50, 100] {
1002            while actors.len() < target {
1003                let session_id = SessionId::new(format!("nfr-p7-{}", actors.len()));
1004                let shared_registry = Arc::clone(&skill_registry);
1005                let (handle, blocking) = SessionActor::spawn(
1006                    &supervisor,
1007                    &registry,
1008                    &session_id,
1009                    move |channel| {
1010                        let provider = mock_provider(vec!["ok".to_owned()]);
1011                        let embedding_provider = provider.clone();
1012                        let executor = MockToolExecutor::no_tools();
1013                        Agent::new_with_registry_arc(
1014                            provider,
1015                            embedding_provider,
1016                            channel,
1017                            shared_registry,
1018                            None,
1019                            5,
1020                            executor,
1021                        )
1022                    },
1023                    4,
1024                    None,
1025                );
1026                actors.push((handle, blocking));
1027            }
1028            // Let newly spawned dedicated threads finish constructing their Agent and settle into
1029            // `drive`'s idle `select!` loop before sampling.
1030            tokio::time::sleep(Duration::from_millis(200)).await;
1031            checkpoints.push((target, sample_rss(&mut sys, pid)));
1032        }
1033
1034        for (handle, _) in &actors {
1035            let _ = handle.tx.send(SessionCommand::Shutdown).await;
1036        }
1037        for (_, blocking) in actors {
1038            let _ = tokio::time::timeout(Duration::from_secs(10), blocking.join()).await;
1039        }
1040
1041        let (n_prev, rss_prev) = checkpoints[checkpoints.len() - 2];
1042        let (n_last, rss_last) = checkpoints[checkpoints.len() - 1];
1043        let marginal_per_session = rss_last.saturating_sub(rss_prev) / (n_last - n_prev) as u64;
1044
1045        eprintln!("NFR-P7 real-Agent memory floor checkpoints: {checkpoints:?}");
1046        eprintln!(
1047            "NFR-P7 real-Agent per-session marginal floor (housing + Agent owned state, \
1048             synthetic mock-backed lower bound): {marginal_per_session} bytes ({} KiB)",
1049            marginal_per_session / 1024
1050        );
1051
1052        assert!(
1053            marginal_per_session < 1_048_576,
1054            "NFR-P7 composite budget (informational threshold, see nfr.md's #5840 rationale) \
1055             exceeded: measured {marginal_per_session} bytes/session >= 1 MiB \
1056             (checkpoints: {checkpoints:?})"
1057        );
1058    }
1059}