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(®istry);
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(®istry);
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 ®istry,
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 ®istry,
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 ®istry,
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}