Skip to main content

algocline_engine/execution/
registry.rs

1//! `SessionRegistryV2` — the engine-level session lifecycle manager for the v2 path.
2//!
3//! Coexists with the legacy `SessionRegistry` (`session.rs`) without modifying it.
4//! New callers (Subtask 3's `AppService::ExecutionService` impl) use this registry.
5//!
6//! # Design invariants
7//!
8//! - **Invariant 6**: `spawn_v2()` returns the `SessionId` immediately; execution
9//!   runs in the background via `tokio::spawn(driver_loop(...))`.
10//! - **Crux R1**: No `rmcp::*`, `progressToken`, `_meta`, `notifications/*`, or
11//!   `mcp_`-prefixed identifiers appear anywhere in this module.
12//! - **Crux R2**: Cancellation uses `CancellationToken::cancel()`; no
13//!   `JoinHandle::abort()` or process kill path exists.
14//! - **Crux R3**: `observe()` is a sync `fn` that calls `bus_tx.subscribe()` and
15//!   returns a valid handle with zero pre-registered observers.
16//! - **K-4**: The `sessions` `RwLock` is never held across `.await` points; the
17//!   `clone-then-release` pattern is used throughout.
18
19use std::collections::HashMap;
20use std::sync::atomic::{AtomicI64, Ordering};
21use std::sync::Arc;
22use std::time::Duration;
23
24use algocline_core::execution::{
25    AwaitError, CancelError, CancelReason, ExecutionState, ExecutionStateTag, ObserveError,
26    ObserverHandle, PauseKind, ProgressEvent, ResumeError, ResumeOutcome, SessionId, SpawnError,
27    StateError, TerminalOutcome,
28};
29use algocline_core::{ExecutionMetrics, ExecutionObserver, QueryId, TokenUsage};
30use tokio::sync::{Mutex, RwLock};
31use tokio_util::sync::CancellationToken;
32
33use super::driver::{build_cancel_info, driver_loop, now_ms, transition_state, DriverContext};
34use super::observer::BroadcastObserverHandle;
35use super::record::{RespTxsMap, SessionRecord};
36use crate::executor::Executor;
37
38// ---------------------------------------------------------------------------
39// SessionRegistryV2
40// ---------------------------------------------------------------------------
41
42/// Registry that manages the lifecycle of v2 execution sessions.
43///
44/// `Clone` is cheap — the inner `Arc<RwLock<...>>` is reference-counted.
45#[derive(Clone)]
46pub struct SessionRegistryV2 {
47    sessions: Arc<RwLock<HashMap<SessionId, Arc<SessionRecord>>>>,
48    executor: Arc<Executor>,
49    dirs: crate::executor::SessionDirs,
50}
51
52impl SessionRegistryV2 {
53    /// Create a new empty registry backed by `executor`, with the storage
54    /// paths that will be injected into each spawned VM session.
55    ///
56    /// `dirs` mirrors the legacy `AppService` resolution against the
57    /// `AppConfig::app_dir()` layout, so a v2 caller produces the same
58    /// on-disk side effects as a legacy caller. An empty
59    /// [`crate::executor::SessionDirs::nn_dir`] disables the `alc.nn` store
60    /// install (save/load then error).
61    pub fn new(executor: Arc<Executor>, dirs: crate::executor::SessionDirs) -> Self {
62        Self {
63            sessions: Arc::new(RwLock::new(HashMap::new())),
64            executor,
65            dirs,
66        }
67    }
68
69    // -----------------------------------------------------------------------
70    // spawn_v2
71    // -----------------------------------------------------------------------
72
73    /// Start a new v2 execution session, returning the `SessionId` immediately.
74    ///
75    /// Execution proceeds in the background via `tokio::spawn(driver_loop(...))`.
76    /// The caller receives the `SessionId` without waiting for execution to complete
77    /// or for the first event (Invariant 6 / debt #40955).
78    ///
79    /// Only [`algocline_core::execution::SpecKind::Run`] is supported in this subtask.
80    /// Other variants return [`SpawnError::InvalidSpec`].  Subtask 3 will extend this
81    /// to handle `Advice` and `Eval` through the full `AppService` path.
82    ///
83    /// `card_run_enabled` is the resolved `[setting.card].run` gate value.  It is
84    /// threaded through into the per-session bridge so `alc.card.create` /
85    /// `alc.card.append` calls carrying a `run` field are no-op when the gate
86    /// is off (Phase 1-B).
87    ///
88    /// # Errors
89    /// - [`SpawnError::Engine`] — the executor failed to start the session.
90    /// - [`SpawnError::InvalidSpec`] — the provided spec is malformed or uses an
91    ///   unsupported kind.
92    pub async fn spawn_v2(
93        &self,
94        spec: algocline_core::execution::SessionSpec,
95        card_run_enabled: bool,
96    ) -> Result<SessionId, SpawnError> {
97        use algocline_core::execution::SpecKind;
98
99        // Extract code from the spec kind.  Only Run is supported here.
100        let code = match spec.kind {
101            SpecKind::Run { code } => code,
102            other => {
103                return Err(SpawnError::InvalidSpec(format!(
104                    "SessionRegistryV2::spawn_v2 only supports SpecKind::Run; got {:?}",
105                    std::mem::discriminant(&other)
106                )));
107            }
108        };
109
110        if code.trim().is_empty() {
111            return Err(SpawnError::InvalidSpec("code must not be empty".into()));
112        }
113
114        let ctx = spec.ctx.unwrap_or_else(|| serde_json::json!({}));
115
116        // Start the per-session VM using the storage paths injected at
117        // registry construction (mirrors legacy AppService::start_and_tick).
118        let session = self
119            .executor
120            .start_session(
121                code,
122                ctx,
123                vec![], // extra_lib_paths — populated by Advice/Eval kinds later
124                vec![], // variant_pkgs   — populated by Advice/Eval kinds later
125                self.dirs.clone(),
126                card_run_enabled,
127            )
128            .await
129            .map_err(SpawnError::Engine)?;
130
131        let (exec_task, llm_rx, vm_driver, metrics) = session.into_driver_parts();
132
133        // Build shared components — all constructed before spawning the task.
134        let state: Arc<Mutex<ExecutionState>> = Arc::new(Mutex::new(ExecutionState::Running));
135        let cancel_token = CancellationToken::new();
136        let resp_txs: RespTxsMap = Arc::new(Mutex::new(HashMap::new()));
137        // Wall-clock ms timestamp for idle-time GC (Crux #3 legacy parity).
138        // Initialised to now_ms() so a session that is evicted before driver_loop
139        // even starts is treated as "just spawned" rather than immediately expired.
140        let last_active: Arc<AtomicI64> = Arc::new(AtomicI64::new(now_ms()));
141        // Wrap metrics in Arc and clone into both DriverContext and SessionRecord
142        // so both can access the same SessionStatus accumulator (K-4 clone-then-release).
143        let metrics_arc: Arc<ExecutionMetrics> = Arc::new(metrics);
144
145        // Crux R3 (sink-free): the receiver returned alongside `bus_tx` is
146        // dropped immediately.  `bus_tx.send()` returns `Err(SendError)` when
147        // 0 observers are subscribed, but every call site in `driver_loop`
148        // uses `let _ = bus_tx.send(...)` to absorb the result — the caller
149        // is never crashed by 0 observers.  See
150        // `record::tests::bus_tx_does_not_crash_caller_with_zero_observers`.
151        let (bus_tx, _) = tokio::sync::broadcast::channel::<ProgressEvent>(256);
152
153        let session_id = SessionId::generate();
154
155        // Bundle shared resources for driver_loop.
156        let ctx = DriverContext {
157            state: Arc::clone(&state),
158            bus_tx: bus_tx.clone(),
159            cancel_token: cancel_token.clone(),
160            resp_txs: Arc::clone(&resp_txs),
161            last_active: Arc::clone(&last_active),
162            metrics: Arc::clone(&metrics_arc),
163        };
164
165        let join_handle = tokio::spawn(async move {
166            // vm_driver must stay alive for the duration of the session.
167            let _keep_driver = vm_driver;
168            driver_loop(ctx, exec_task, llm_rx).await;
169        });
170
171        // Assemble the record with all shared fields.
172        let record = Arc::new(SessionRecord {
173            state,
174            bus_tx,
175            last_active,
176            cancel_token,
177            join_handle: Mutex::new(Some(join_handle)),
178            resp_txs,
179            first_cancel_info: Mutex::new(None),
180            metrics: metrics_arc,
181        });
182
183        // Insert into registry.
184        {
185            let mut map = self.sessions.write().await;
186            map.insert(session_id.clone(), record);
187        }
188        Ok(session_id)
189    }
190
191    // -----------------------------------------------------------------------
192    // state
193    // -----------------------------------------------------------------------
194
195    /// Query the current [`ExecutionState`] of a session.
196    ///
197    /// # Errors
198    /// - [`StateError::NotFound`] — no session with the given id exists.
199    pub async fn state(&self, id: &SessionId) -> Result<ExecutionState, StateError> {
200        let record = self
201            .get_record(id)
202            .await
203            .ok_or_else(|| StateError::NotFound(id.clone()))?;
204        let guard = record.state.lock().await;
205        Ok(guard.clone())
206    }
207
208    // -----------------------------------------------------------------------
209    // resume
210    // -----------------------------------------------------------------------
211
212    /// Resume a paused session by delivering LLM responses.
213    ///
214    /// # Errors
215    /// - [`ResumeError::NotFound`] — no session with the given id exists.
216    /// - [`ResumeError::NotPaused`] — the session is not in the `Paused` state.
217    /// - [`ResumeError::AlreadyCancelled`] — the session is already cancelled.
218    pub async fn resume(
219        &self,
220        id: &SessionId,
221        payload: algocline_core::execution::ResumePayload,
222    ) -> Result<ResumeOutcome, ResumeError> {
223        use algocline_core::execution::ResumePayload;
224
225        let record = self
226            .get_record(id)
227            .await
228            .ok_or_else(|| ResumeError::NotFound(id.clone()))?;
229
230        // checkpoint C: at resume entry
231        // If the token is already cancelled, reject the resume immediately.
232        if record.cancel_token.is_cancelled() {
233            return Err(ResumeError::AlreadyCancelled);
234        }
235
236        // Verify the session is Paused (or Cancelled after the token check above).
237        let (actual_tag, pause_kind) = {
238            let guard = record.state.lock().await;
239            let tag = guard.tag();
240            let kind = if let ExecutionState::Paused(ref info) = *guard {
241                info.kind
242            } else {
243                PauseKind::Single
244            };
245            (tag, kind)
246        };
247
248        match actual_tag {
249            ExecutionStateTag::Cancelled => return Err(ResumeError::AlreadyCancelled),
250            ExecutionStateTag::Paused => {} // continue
251            _ => return Err(ResumeError::NotPaused { actual_tag }),
252        }
253
254        // Extract query responses from the payload, preserving per-response usage.
255        let responses: Vec<(String, String, Option<TokenUsage>)> = match payload {
256            ResumePayload::Single {
257                query_id,
258                response,
259                usage,
260            } => vec![(query_id, response, usage)],
261            ResumePayload::Batch(batch) => batch
262                .into_iter()
263                .map(|r| (r.query_id, r.response, r.usage))
264                .collect(),
265        };
266
267        // Deliver responses via the shared resp_txs map.
268        {
269            let mut txs = record.resp_txs.lock().await;
270            for (qid_str, response, _usage) in &responses {
271                let qid = QueryId::parse(qid_str);
272                match txs.remove(&qid) {
273                    Some(tx) => {
274                        if let Err(_e) = tx.send(Ok(response.clone())) {
275                            tracing::debug!(
276                                "registry::resume: oneshot receiver already dropped for query {qid_str}"
277                            );
278                        }
279                    }
280                    None => {
281                        tracing::debug!("registry::resume: no pending tx for query {qid_str}");
282                    }
283                }
284            }
285        }
286
287        // Propagate per-response usage to the metrics observer (Crux 1: same Arc).
288        // Observer call is outside the txs lock scope to keep cancel/lock paths intact.
289        let observer = record.metrics.create_observer();
290        for (qid_str, response, usage) in &responses {
291            let qid = QueryId::parse(qid_str);
292            observer.on_response_fed(&qid, response, usage.as_ref());
293        }
294
295        // Transition state from Paused → Running.
296        {
297            let guard = record.state.lock().await;
298            if guard.tag() == ExecutionStateTag::Paused {
299                drop(guard);
300                transition_state(&record.state, &record.bus_tx, ExecutionState::Running).await;
301                let _ = record.bus_tx.send(ProgressEvent::ResumeAccepted {
302                    payload_kind: pause_kind,
303                    at: now_ms(),
304                });
305            }
306        }
307
308        Ok(ResumeOutcome::Continued)
309    }
310
311    // -----------------------------------------------------------------------
312    // cancel
313    // -----------------------------------------------------------------------
314
315    /// Request cooperative cancellation of a session.
316    ///
317    /// Idempotent: returns `Ok(())` for sessions already in a terminal state.
318    ///
319    /// # Errors
320    /// - [`CancelError::NotFound`] — no session with the given id exists.
321    pub async fn cancel(&self, id: &SessionId, reason: CancelReason) -> Result<(), CancelError> {
322        let record = self
323            .get_record(id)
324            .await
325            .ok_or_else(|| CancelError::NotFound(id.clone()))?;
326
327        // Idempotency: already terminal → Ok.
328        {
329            let guard = record.state.lock().await;
330            if matches!(
331                guard.tag(),
332                ExecutionStateTag::Done | ExecutionStateTag::Failed | ExecutionStateTag::Cancelled
333            ) {
334                return Ok(());
335            }
336        }
337
338        // Store the first CancelInfo (idempotent: only set once).
339        {
340            let mut first = record.first_cancel_info.lock().await;
341            if first.is_none() {
342                let info = build_cancel_info(&record.state, reason).await;
343                *first = Some(info);
344            }
345        }
346
347        // Signal the driver (Crux R2: cooperative — no abort).
348        record.cancel_token.cancel();
349
350        // For Paused sessions, transition immediately: the driver is blocked
351        // waiting for a resume and won't hit a checkpoint on its own.
352        let should_transition = {
353            let guard = record.state.lock().await;
354            guard.tag() == ExecutionStateTag::Paused
355        };
356        if should_transition {
357            let cancel_info_opt = {
358                let first = record.first_cancel_info.lock().await;
359                first.clone()
360            };
361            if let Some(info) = cancel_info_opt {
362                transition_state(
363                    &record.state,
364                    &record.bus_tx,
365                    ExecutionState::Cancelled(info),
366                )
367                .await;
368            }
369        }
370
371        Ok(())
372    }
373
374    // -----------------------------------------------------------------------
375    // observe  (sync fn — Crux R3)
376    // -----------------------------------------------------------------------
377
378    /// Subscribe to the progress event stream for a session.
379    ///
380    /// This is a **synchronous** `fn`: `broadcast::Sender::subscribe()` is
381    /// synchronous and does not perform I/O.  Multiple concurrent subscribers
382    /// each receive the full event stream independently (Crux R3).
383    ///
384    /// # Errors
385    /// - [`ObserveError::NotFound`] — no session with the given id exists, **or**
386    ///   `try_read()` experienced lock contention (write lock held by `spawn`).
387    ///   The contention path emits `tracing::warn!(target = "session.observe", ...)`;
388    ///   callers cannot distinguish it from a true absent-session result.
389    pub fn observe(&self, id: &SessionId) -> Result<Box<dyn ObserverHandle>, ObserveError> {
390        // Non-blocking read; the write lock is only held very briefly during spawn.
391        match self.sessions.try_read() {
392            Ok(map) => {
393                let record = map
394                    .get(id)
395                    .ok_or_else(|| ObserveError::NotFound(id.clone()))?;
396                Ok(Box::new(BroadcastObserverHandle::new(&record.bus_tx)))
397            }
398            Err(_) => {
399                tracing::warn!(
400                    target = "session.observe",
401                    session_id = %id,
402                    "try_read contention; surfacing as NotFound"
403                );
404                Err(ObserveError::NotFound(id.clone()))
405            }
406        }
407    }
408
409    // -----------------------------------------------------------------------
410    // await_terminal
411    // -----------------------------------------------------------------------
412
413    /// Await the terminal state of a session.
414    ///
415    /// Polls the shared state until it reaches a terminal variant (`Done`,
416    /// `Cancelled`, or `Failed`).  The `JoinHandle` is never `.abort()`-ed
417    /// (Crux R2).
418    ///
419    /// # Errors
420    /// - [`AwaitError::NotFound`] — no session with the given id exists.
421    pub async fn await_terminal(&self, id: &SessionId) -> Result<TerminalOutcome, AwaitError> {
422        let record = self
423            .get_record(id)
424            .await
425            .ok_or_else(|| AwaitError::NotFound(id.clone()))?;
426
427        // Single-awaiter path: take the JoinHandle and await `driver_loop`
428        // completion directly.  Replaces the previous `yield_now()` polling
429        // loop that occupied a tokio worker slot scheduling-wise even though
430        // it consumed no CPU.  The `driver_loop` guarantees a terminal
431        // `transition_state` before returning, so once `handle.await` resolves
432        // the state is guaranteed terminal.
433        let handle_opt = {
434            let mut guard = record.join_handle.lock().await;
435            guard.take()
436        };
437
438        if let Some(handle) = handle_opt {
439            handle
440                .await
441                .map_err(|e| AwaitError::Joined(format!("driver_loop join error: {e}")))?;
442        }
443        // (None branch: another caller has already taken the handle.  Either
444        // they are still awaiting it — in which case the driver_loop has not
445        // yet transitioned to terminal — or they have already finished, in
446        // which case the state is terminal.  We fall through to a single
447        // state read; the rare concurrent race returns `AwaitError::Joined`.)
448
449        let guard = record.state.lock().await;
450        match &*guard {
451            ExecutionState::Done(result) => Ok(TerminalOutcome::Done(result.clone())),
452            ExecutionState::Cancelled(info) => Ok(TerminalOutcome::Cancelled(info.clone())),
453            ExecutionState::Failed(info) => Ok(TerminalOutcome::Failed(info.clone())),
454            other => Err(AwaitError::Joined(format!(
455                "await_terminal: driver_loop completed but state is {:?} (concurrent awaiter race)",
456                other.tag()
457            ))),
458        }
459    }
460
461    // -----------------------------------------------------------------------
462    // Internal helpers
463    // -----------------------------------------------------------------------
464
465    // -----------------------------------------------------------------------
466    // spawn_gc_task
467    // -----------------------------------------------------------------------
468
469    /// Spawn a background GC task that periodically evicts idle, terminal sessions.
470    ///
471    /// Mirrors the legacy `SessionRegistry::spawn_gc_task` contract (Crux #3 legacy
472    /// parity) with two extensions:
473    ///
474    /// 1. **Subscriber-count gate** (Crux #1): a session is only evicted when
475    ///    `bus_tx.receiver_count() == 0` at the moment the write guard is held,
476    ///    ensuring no use-after-eviction for active observers.
477    /// 2. **Parameterised `interval`** (Crux #2): callers can supply a sub-second
478    ///    interval for test determinism without requiring `tokio::time::pause`.
479    ///
480    /// The `JoinHandle` returned by `tokio::spawn` is intentionally dropped —
481    /// the task runs until process exit (legacy fire-and-forget contract).
482    ///
483    /// # K-4 invariant
484    ///
485    /// The `sessions` write guard is acquired once per GC tick.  All operations
486    /// inside the guard (`receiver_count()`, `AtomicI64::load`, `HashMap::remove`)
487    /// are **synchronous** — no `.await` is called while the guard is held.
488    pub fn spawn_gc_task(&self, ttl: Duration, interval: Duration) {
489        let sessions = Arc::clone(&self.sessions);
490        tokio::spawn(async move {
491            let mut ticker = tokio::time::interval(interval);
492            loop {
493                ticker.tick().await;
494                // Acquire the write guard once per tick.  All reads and removes
495                // within this block are sync — no `.await` inside the guard (K-4).
496                let mut map = sessions.write().await;
497                let mut to_evict: Vec<SessionId> = Vec::new();
498                for (id, record) in map.iter() {
499                    // Crux #1: check subscriber count atomically with the guard held.
500                    // `receiver_count()` is sync (no lock required on its own), but
501                    // holding the write guard here means `observe()` cannot attach a
502                    // new subscriber via `try_read()` concurrently — TOCTOU excluded.
503                    let no_subscribers = record.bus_tx.receiver_count() == 0;
504                    let last_ms = record.last_active.load(Ordering::Relaxed);
505                    if no_subscribers && is_expired_v2(last_ms, ttl) {
506                        to_evict.push(id.clone());
507                    }
508                }
509                for id in &to_evict {
510                    tracing::info!(session_id = %id, "GC: reaping expired v2 session");
511                    map.remove(id);
512                }
513            }
514        });
515    }
516
517    // -----------------------------------------------------------------------
518    // Internal helpers
519    // -----------------------------------------------------------------------
520
521    /// Clone-then-release lookup (K-4): the lock is dropped before returning.
522    async fn get_record(&self, id: &SessionId) -> Option<Arc<SessionRecord>> {
523        let map = self.sessions.read().await;
524        map.get(id).cloned()
525    }
526}
527
528// ---------------------------------------------------------------------------
529// GC helpers (module-private)
530// ---------------------------------------------------------------------------
531
532/// Returns `true` when the session has been idle for at least `ttl`.
533///
534/// Uses wall-clock milliseconds matching the legacy `is_expired_impl` semantics:
535/// `now_ms() - last_active_ms >= ttl.as_millis()`.
536///
537/// The legacy implementation uses `Instant` (monotonic) whereas this uses
538/// `SystemTime` (wall-clock) — identical to the `now_ms()` helper in `driver.rs`
539/// and to `Session.last_activity_ms` in the legacy codebase (Crux #3 parity).
540fn is_expired_v2(last_active_ms: i64, ttl: Duration) -> bool {
541    let now = super::driver::now_ms();
542    let elapsed_ms = now.saturating_sub(last_active_ms);
543    elapsed_ms >= ttl.as_millis() as i64
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549    use algocline_core::execution::{
550        CancelCode, CancelReason, ExecutionState, SessionSpec, SpecKind,
551    };
552    use std::sync::Arc;
553
554    async fn make_executor() -> Arc<Executor> {
555        Arc::new(Executor::new(vec![]).await.expect("Executor::new"))
556    }
557
558    /// Construct a registry backed by per-test tempdir paths so the legacy
559    /// AppConfig::app_dir() layout is approximated without touching the user's
560    /// `~/.algocline` directory.
561    fn make_registry(executor: Arc<Executor>) -> (SessionRegistryV2, tempfile::TempDir) {
562        let tmp = tempfile::tempdir().expect("tempdir");
563        let state_store = Arc::new(crate::state::JsonFileStore::new(tmp.path().join("state")));
564        let card_store = Arc::new(crate::card::FileCardStore::new(tmp.path().join("cards")));
565        let scenarios_dir = tmp.path().join("scenarios");
566        let nn_dir = tmp.path().join("nn");
567        let dirs = crate::executor::SessionDirs {
568            state_store,
569            card_store,
570            scenarios_dir,
571            nn_dir,
572        };
573        (SessionRegistryV2::new(executor, dirs), tmp)
574    }
575
576    fn simple_spec(code: &str) -> SessionSpec {
577        SessionSpec {
578            kind: SpecKind::Run {
579                code: code.to_owned(),
580            },
581            project_root: None,
582            ctx: None,
583        }
584    }
585
586    fn cancel_reason() -> CancelReason {
587        CancelReason {
588            code: CancelCode::User,
589            detail: None,
590            requested_at: now_ms(),
591        }
592    }
593
594    // -----------------------------------------------------------------------
595    // spawn_returns_session_id_immediately (debt #40955)
596    // -----------------------------------------------------------------------
597
598    /// `spawn_v2` must return `SessionId` without blocking on execution.
599    #[tokio::test]
600    async fn spawn_returns_session_id_immediately() {
601        let executor = make_executor().await;
602        let (registry, _tmp) = make_registry(executor);
603
604        let start = std::time::Instant::now();
605        let result = tokio::time::timeout(
606            std::time::Duration::from_millis(200),
607            registry.spawn_v2(simple_spec("return 42"), false),
608        )
609        .await;
610
611        assert!(result.is_ok(), "spawn_v2 must complete within 200ms");
612        assert!(
613            result.unwrap().is_ok(),
614            "spawn_v2 must return Ok(SessionId)"
615        );
616
617        let elapsed = start.elapsed();
618        assert!(
619            elapsed < std::time::Duration::from_millis(150),
620            "spawn_v2 took too long: {elapsed:?}"
621        );
622    }
623
624    // -----------------------------------------------------------------------
625    // state_query_running
626    // -----------------------------------------------------------------------
627
628    /// Immediately after spawn, `state()` must return Running or Paused.
629    #[tokio::test]
630    async fn state_query_running() {
631        let executor = make_executor().await;
632        let (registry, _tmp) = make_registry(executor);
633
634        // Lua that pauses immediately so the session is observable.
635        let sid = registry
636            .spawn_v2(simple_spec(r#"return alc.llm("q")"#), false)
637            .await
638            .expect("spawn");
639
640        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
641
642        let state = registry.state(&sid).await.expect("state");
643        assert!(
644            matches!(state, ExecutionState::Running | ExecutionState::Paused(_)),
645            "state just after spawn must be Running or Paused, got: {:?}",
646            state.tag()
647        );
648    }
649
650    // -----------------------------------------------------------------------
651    // cancel_at_checkpoint_c_at_resume_entry
652    // -----------------------------------------------------------------------
653
654    /// `resume()` on a cancelled session must return `AlreadyCancelled`.
655    #[tokio::test]
656    async fn cancel_at_checkpoint_c_at_resume_entry() {
657        use algocline_core::execution::{ResumeError, ResumePayload};
658
659        let executor = make_executor().await;
660        let (registry, _tmp) = make_registry(executor);
661
662        let sid = registry
663            .spawn_v2(simple_spec(r#"return alc.llm("q")"#), false)
664            .await
665            .expect("spawn");
666
667        // Wait for Paused.
668        let mut retries = 0;
669        loop {
670            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
671            if registry.state(&sid).await.expect("state").tag() == ExecutionStateTag::Paused {
672                break;
673            }
674            retries += 1;
675            assert!(retries < 50, "session did not reach Paused state");
676        }
677
678        registry
679            .cancel(&sid, cancel_reason())
680            .await
681            .expect("cancel");
682
683        // checkpoint C: at resume entry
684        let result = registry
685            .resume(
686                &sid,
687                ResumePayload::Single {
688                    query_id: "q".into(),
689                    response: "4".into(),
690                    usage: None,
691                },
692            )
693            .await;
694
695        assert!(
696            matches!(result, Err(ResumeError::AlreadyCancelled)),
697            "resume on cancelled session must return AlreadyCancelled, got: {result:?}"
698        );
699    }
700
701    // -----------------------------------------------------------------------
702    // cancel_idempotent
703    // -----------------------------------------------------------------------
704
705    #[tokio::test]
706    async fn cancel_idempotent() {
707        let executor = make_executor().await;
708        let (registry, _tmp) = make_registry(executor);
709
710        let sid = registry
711            .spawn_v2(simple_spec("return 1"), false)
712            .await
713            .expect("spawn");
714
715        registry
716            .cancel(&sid, cancel_reason())
717            .await
718            .expect("first cancel");
719        registry
720            .cancel(&sid, cancel_reason())
721            .await
722            .expect("second cancel");
723    }
724
725    // -----------------------------------------------------------------------
726    // await_terminal returns Done without busy-polling
727    // -----------------------------------------------------------------------
728
729    /// Regression for #2 (case A): `await_terminal` must complete by awaiting
730    /// the `driver_loop` `JoinHandle` directly (single-awaiter `take` +
731    /// `.await`) instead of polling `state` in a `yield_now()` loop.  We can't
732    /// observe scheduler occupancy from a test, but we can verify the
733    /// behavioural contract: (1) the call returns the correct `TerminalOutcome`,
734    /// (2) it returns within a tight wall-clock budget without sleep, and
735    /// (3) a second concurrent caller does not panic.
736    #[tokio::test]
737    async fn await_terminal_returns_done_for_trivial_script() {
738        let executor = make_executor().await;
739        let (registry, _tmp) = make_registry(executor);
740
741        let sid = registry
742            .spawn_v2(simple_spec("return 42"), false)
743            .await
744            .expect("spawn");
745
746        let outcome = registry.await_terminal(&sid).await.expect("await_terminal");
747        match outcome {
748            TerminalOutcome::Done(result) => {
749                assert_eq!(result.value, serde_json::json!(42));
750            }
751            other => panic!("expected Done, got: {other:?}"),
752        }
753    }
754
755    /// Regression for #2 (case A) single-awaiter discipline: when two callers
756    /// race on `await_terminal`, the second caller (which observes `None` after
757    /// the first has taken the handle) must NOT panic.  It must either return
758    /// the same terminal outcome (if the first has already finished) or an
759    /// `AwaitError::Joined` (the documented race fallback).
760    #[tokio::test]
761    async fn await_terminal_does_not_panic_on_second_concurrent_caller() {
762        let executor = make_executor().await;
763        let (registry, _tmp) = make_registry(executor);
764
765        let sid = registry
766            .spawn_v2(simple_spec("return 99"), false)
767            .await
768            .expect("spawn");
769
770        let r1 = registry.clone();
771        let r2 = registry.clone();
772        let s1 = sid.clone();
773        let s2 = sid.clone();
774
775        let h1 = tokio::spawn(async move { r1.await_terminal(&s1).await });
776        let h2 = tokio::spawn(async move { r2.await_terminal(&s2).await });
777
778        let out1 = h1.await.expect("h1 join");
779        let out2 = h2.await.expect("h2 join");
780
781        // First-caller path must succeed with the real outcome.
782        let first_ok = matches!(&out1, Ok(TerminalOutcome::Done(_)))
783            || matches!(&out2, Ok(TerminalOutcome::Done(_)));
784        assert!(
785            first_ok,
786            "at least one caller must observe Done; got out1={out1:?}, out2={out2:?}"
787        );
788        // Second caller may have observed Joined (race) or Done; either is OK,
789        // neither must panic — which we've already verified by the join above.
790    }
791
792    // -----------------------------------------------------------------------
793    // observe_sink_free (Crux R3 — registry level)
794    // -----------------------------------------------------------------------
795
796    /// `observe()` must succeed and return a valid handle even with 0 prior observers.
797    #[tokio::test]
798    async fn observe_sink_free_registry() {
799        let executor = make_executor().await;
800        let (registry, _tmp) = make_registry(executor);
801
802        let sid = registry
803            .spawn_v2(simple_spec(r#"return alc.llm("q")"#), false)
804            .await
805            .expect("spawn");
806
807        // observe() before any subscriber exists must succeed.
808        let handle = registry.observe(&sid);
809        assert!(
810            handle.is_ok(),
811            "observe() must return Ok even with 0 prior observers"
812        );
813    }
814
815    // -----------------------------------------------------------------------
816    // observe_multi_subscriber_fan_out (Crux R3 — registry level)
817    // -----------------------------------------------------------------------
818
819    /// Multiple independent observers each get the same events.
820    #[tokio::test]
821    async fn observe_multi_subscriber_fan_out_registry() {
822        use algocline_core::execution::ObserverRecvError;
823
824        let executor = make_executor().await;
825        let (registry, _tmp) = make_registry(executor);
826
827        // A script that returns immediately — the driver will publish Done.
828        let sid = registry
829            .spawn_v2(simple_spec("return 99"), false)
830            .await
831            .expect("spawn");
832
833        // Subscribe 3 observers.
834        let mut h1 = registry.observe(&sid).expect("observe h1");
835        let mut h2 = registry.observe(&sid).expect("observe h2");
836        let mut h3 = registry.observe(&sid).expect("observe h3");
837
838        // Wait for terminal so we know events have been published.
839        let _ = registry.await_terminal(&sid).await;
840
841        // Each observer must receive at least the terminal StateTransition.
842        // Drain with idle-timeout: bus_tx is retained in SessionRecord for
843        // sink-free late-subscribe (Crux R3), so Closed never fires while the
844        // registry is alive.  A 100ms idle window after await_terminal() is
845        // sufficient — all events are already buffered.
846        use std::time::Duration;
847        for (label, handle) in [("h1", &mut h1), ("h2", &mut h2), ("h3", &mut h3)] {
848            let mut got_transition = false;
849            loop {
850                match tokio::time::timeout(Duration::from_millis(100), handle.recv()).await {
851                    Ok(Ok(ProgressEvent::StateTransition { .. })) => got_transition = true,
852                    Ok(Ok(_)) => {}
853                    Ok(Err(ObserverRecvError::Closed)) => break,
854                    Ok(Err(ObserverRecvError::Lagged(_))) => {}
855                    Err(_) => break, // idle-timeout: no more events coming
856                }
857            }
858            assert!(
859                got_transition,
860                "{label}: must receive at least one StateTransition event"
861            );
862        }
863    }
864
865    // -----------------------------------------------------------------------
866    // AC#5a — gc_evicts_terminal_session_after_ttl
867    // -----------------------------------------------------------------------
868
869    /// GC must remove a terminal session (no subscribers) after TTL has elapsed
870    /// and one full interval tick has fired.
871    ///
872    /// Covers: `tokio::time::interval` + `AtomicI64::load` + `RwLock::write` +
873    /// `receiver_count == 0` (Crux #1 / concurrency-analysis §2 5a).
874    #[tokio::test]
875    async fn gc_evicts_terminal_session_after_ttl() {
876        use algocline_core::execution::ObserveError;
877        use std::time::Duration;
878
879        let executor = make_executor().await;
880        let (registry, _tmp) = make_registry(executor);
881
882        let ttl = Duration::from_millis(100);
883        let interval = Duration::from_millis(50);
884
885        let sid = registry
886            .spawn_v2(simple_spec("return 1"), false)
887            .await
888            .expect("spawn");
889
890        // Wait for the session to complete (terminal, no subscribers).
891        registry.await_terminal(&sid).await.expect("await_terminal");
892
893        // Sleep beyond one full GC interval + TTL + slack so the GC has had at
894        // least one opportunity to evict (R4 fallback: interval + ttl + 50ms).
895        tokio::time::sleep(interval + ttl + Duration::from_millis(50)).await;
896
897        registry.spawn_gc_task(ttl, interval);
898
899        // Sleep again to let the newly spawned GC run at least one tick.
900        tokio::time::sleep(interval + Duration::from_millis(50)).await;
901
902        // The session must now be gone.
903        assert!(
904            matches!(registry.observe(&sid), Err(ObserveError::NotFound(_))),
905            "session must be evicted after TTL + interval"
906        );
907    }
908
909    // -----------------------------------------------------------------------
910    // AC#5b — gc_does_not_evict_session_with_active_subscriber
911    // -----------------------------------------------------------------------
912
913    /// GC must NOT evict a session that still has active subscribers, even after
914    /// TTL has elapsed.  Once the subscriber is dropped, subsequent GC ticks must
915    /// evict the session.
916    ///
917    /// Covers: `broadcast::Sender::receiver_count` > 0 path (Crux #1 /
918    /// concurrency-analysis §2 5b).
919    #[tokio::test]
920    async fn gc_does_not_evict_session_with_active_subscriber() {
921        use algocline_core::execution::ObserveError;
922        use std::time::Duration;
923
924        let executor = make_executor().await;
925        let (registry, _tmp) = make_registry(executor);
926
927        let ttl = Duration::from_millis(100);
928        let interval = Duration::from_millis(50);
929
930        let sid = registry
931            .spawn_v2(simple_spec("return 2"), false)
932            .await
933            .expect("spawn");
934
935        // Acquire a subscriber *before* the session reaches terminal.
936        let _handle = registry.observe(&sid).expect("observe");
937
938        // Wait for terminal while subscriber is still held.
939        registry.await_terminal(&sid).await.expect("await_terminal");
940
941        // Start GC — session has receiver_count > 0, must NOT be evicted.
942        registry.spawn_gc_task(ttl, interval);
943
944        // Sleep well beyond TTL + interval.
945        tokio::time::sleep(interval + ttl + Duration::from_millis(50)).await;
946
947        // Session must still be present (subscriber is alive).
948        assert!(
949            registry.observe(&sid).is_ok(),
950            "session must NOT be evicted while a subscriber is held"
951        );
952
953        // Drop the subscriber — now eviction is permitted.
954        drop(_handle);
955
956        // Sleep for another interval + slack so GC ticks again after the drop.
957        tokio::time::sleep(interval + Duration::from_millis(50)).await;
958
959        // Now the session should be evicted.
960        assert!(
961            matches!(registry.observe(&sid), Err(ObserveError::NotFound(_))),
962            "session must be evicted after subscriber is dropped and GC ticks"
963        );
964    }
965
966    // -----------------------------------------------------------------------
967    // AC#5c — gc_respects_interval_no_immediate_eviction
968    // -----------------------------------------------------------------------
969
970    /// GC must NOT evict a terminal session before the interval has fired,
971    /// even if TTL has already elapsed.
972    ///
973    /// Covers: `tokio::time::interval MissedTickBehavior::Burst` guard
974    /// (R4 / concurrency-analysis §2 5c).
975    #[tokio::test]
976    async fn gc_respects_interval_no_immediate_eviction() {
977        use std::time::Duration;
978
979        let executor = make_executor().await;
980        let (registry, _tmp) = make_registry(executor);
981
982        // Use a long interval so we can assert the session is still present
983        // after TTL has elapsed but before an interval tick fires.
984        let ttl = Duration::from_millis(20);
985        let interval = Duration::from_millis(500);
986
987        let sid = registry
988            .spawn_v2(simple_spec("return 3"), false)
989            .await
990            .expect("spawn");
991
992        registry.await_terminal(&sid).await.expect("await_terminal");
993
994        // Start GC after TTL has elapsed — the first tick fires up to `interval`
995        // from now, so we check immediately (well before the first tick).
996        tokio::time::sleep(ttl + Duration::from_millis(10)).await;
997        registry.spawn_gc_task(ttl, interval);
998
999        // Check immediately — no tick has fired yet.
1000        assert!(
1001            registry.observe(&sid).is_ok(),
1002            "session must NOT be evicted before first GC tick fires"
1003        );
1004    }
1005
1006    // -----------------------------------------------------------------------
1007    // AC#5d — test_atomic_last_active_updated_by_driver_loop
1008    // -----------------------------------------------------------------------
1009
1010    /// Concurrent writer (store) and reader (load) on `last_active` with
1011    /// Relaxed ordering must not panic or cause UB; final value must be > 0.
1012    ///
1013    /// Covers: `AtomicI64::store` + `AtomicI64::load` Relaxed ordering safety
1014    /// under concurrent access (concurrency-analysis §2 5d / Crux #3 invariant).
1015    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1016    async fn test_atomic_last_active_updated_by_driver_loop() {
1017        use std::sync::atomic::{AtomicI64, Ordering};
1018        use std::sync::Arc;
1019
1020        let last_active = Arc::new(AtomicI64::new(0));
1021
1022        let writer_la = Arc::clone(&last_active);
1023        let writer = tokio::spawn(async move {
1024            for _ in 0..1000 {
1025                writer_la.store(now_ms(), Ordering::Relaxed);
1026                tokio::task::yield_now().await;
1027            }
1028        });
1029
1030        let reader_la = Arc::clone(&last_active);
1031        let reader = tokio::spawn(async move {
1032            for _ in 0..1000 {
1033                let _ = reader_la.load(Ordering::Relaxed);
1034                tokio::task::yield_now().await;
1035            }
1036        });
1037
1038        writer.await.expect("writer task must not panic");
1039        reader.await.expect("reader task must not panic");
1040
1041        // After 1000 stores of now_ms() the value must be > 0.
1042        assert!(
1043            last_active.load(Ordering::Relaxed) > 0,
1044            "last_active must be updated to a non-zero wall-clock value"
1045        );
1046    }
1047
1048    // -----------------------------------------------------------------------
1049    // AC#5e — test_concurrent_observe_during_gc_tick
1050    // -----------------------------------------------------------------------
1051
1052    /// 8 concurrent tasks each calling `observe()` 100 times while GC is running
1053    /// must produce only `Ok` or `Err(NotFound)` — never a panic.
1054    ///
1055    /// Covers: `RwLock::try_read` vs `RwLock::write` mutual exclusion +
1056    /// `Arc<RwLock<HashMap>>` clone safety (concurrency-analysis §2 5e).
1057    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1058    async fn test_concurrent_observe_during_gc_tick() {
1059        use algocline_core::execution::ObserveError;
1060        use std::sync::Arc;
1061        use std::time::Duration;
1062
1063        let executor = make_executor().await;
1064        let (registry, _tmp) = make_registry(executor);
1065        let registry = Arc::new(registry);
1066
1067        let ttl = Duration::from_millis(10);
1068        let interval = Duration::from_millis(5);
1069
1070        let sid = registry
1071            .spawn_v2(simple_spec("return 42"), false)
1072            .await
1073            .expect("spawn");
1074
1075        registry.await_terminal(&sid).await.expect("await_terminal");
1076        registry.spawn_gc_task(ttl, interval);
1077
1078        let mut handles = Vec::new();
1079        for _ in 0..8 {
1080            let reg = Arc::clone(&registry);
1081            let id = sid.clone();
1082            handles.push(tokio::spawn(async move {
1083                for _ in 0..100 {
1084                    match reg.observe(&id) {
1085                        Ok(_) | Err(ObserveError::NotFound(_)) => {}
1086                    }
1087                    tokio::task::yield_now().await;
1088                }
1089            }));
1090        }
1091
1092        for h in handles {
1093            h.await.expect("concurrent observe task must not panic");
1094        }
1095    }
1096
1097    // -----------------------------------------------------------------------
1098    // AC#5f — test_gc_task_spawn_survives_handle_drop
1099    // -----------------------------------------------------------------------
1100
1101    /// `spawn_gc_task` internally drops the `JoinHandle` (legacy fire-and-forget).
1102    /// Verify the GC loop continues running after `spawn_gc_task()` returns by
1103    /// asserting eviction occurs after the expected window.
1104    ///
1105    /// Covers: `tokio::task::spawn` "JoinHandle drop ≠ task abort" contract
1106    /// (concurrency-analysis §2 5f / Crux #2 legacy parity).
1107    #[tokio::test]
1108    async fn test_gc_task_spawn_survives_handle_drop() {
1109        use algocline_core::execution::ObserveError;
1110        use std::time::Duration;
1111
1112        let executor = make_executor().await;
1113        let (registry, _tmp) = make_registry(executor);
1114
1115        let ttl = Duration::from_millis(100);
1116        let interval = Duration::from_millis(50);
1117
1118        // Start GC first — handle is immediately dropped inside spawn_gc_task.
1119        registry.spawn_gc_task(ttl, interval);
1120
1121        let sid = registry
1122            .spawn_v2(simple_spec("return 99"), false)
1123            .await
1124            .expect("spawn");
1125
1126        registry.await_terminal(&sid).await.expect("await_terminal");
1127
1128        // Sleep long enough for at least 2 GC ticks after TTL.
1129        tokio::time::sleep(ttl + interval * 2 + Duration::from_millis(50)).await;
1130
1131        // The GC task (whose JoinHandle was dropped) must have continued running
1132        // and evicted the session.
1133        assert!(
1134            matches!(registry.observe(&sid), Err(ObserveError::NotFound(_))),
1135            "session must be evicted by the GC task even after its JoinHandle was dropped"
1136        );
1137    }
1138
1139    // -----------------------------------------------------------------------
1140    // AC#5g — test_arc_rwlock_hashmap_shared_across_clones
1141    // -----------------------------------------------------------------------
1142
1143    /// `SessionRegistryV2: Clone` shares the same underlying
1144    /// `Arc<RwLock<HashMap>>`.  A session spawned via one clone must be visible
1145    /// from another clone, and GC started on one clone must evict sessions
1146    /// visible from the other.
1147    ///
1148    /// Covers: `Arc<RwLock<HashMap>>` Send + Sync + Clone shared-state contract
1149    /// (concurrency-analysis §2 5g).
1150    #[tokio::test]
1151    async fn test_arc_rwlock_hashmap_shared_across_clones() {
1152        use algocline_core::execution::ObserveError;
1153        use std::time::Duration;
1154
1155        let executor = make_executor().await;
1156        let (registry_a, _tmp) = make_registry(executor);
1157        let registry_b = registry_a.clone();
1158
1159        let ttl = Duration::from_millis(100);
1160        let interval = Duration::from_millis(50);
1161
1162        // Spawn via registry_a.
1163        let sid = registry_a
1164            .spawn_v2(simple_spec("return 7"), false)
1165            .await
1166            .expect("spawn via registry_a");
1167
1168        // Session must be visible from registry_b (shared Arc<RwLock<HashMap>>).
1169        assert!(
1170            registry_b.observe(&sid).is_ok(),
1171            "session spawned via registry_a must be visible from registry_b"
1172        );
1173
1174        // Wait for terminal.
1175        registry_a
1176            .await_terminal(&sid)
1177            .await
1178            .expect("await_terminal");
1179
1180        // Start GC via registry_b.
1181        registry_b.spawn_gc_task(ttl, interval);
1182
1183        // Sleep long enough for eviction.
1184        tokio::time::sleep(ttl + interval + Duration::from_millis(50)).await;
1185
1186        // Session evicted via registry_b's GC must be invisible from registry_a too.
1187        assert!(
1188            matches!(registry_a.observe(&sid), Err(ObserveError::NotFound(_))),
1189            "session evicted by registry_b GC must be gone from registry_a too"
1190        );
1191    }
1192
1193    // -----------------------------------------------------------------------
1194    // usage_aggregate_none_for_run_without_llm_calls (test (b))
1195    // -----------------------------------------------------------------------
1196
1197    /// When no `alc.llm` call occurs, `Done.usage` must be `None`.
1198    /// Verifies that the `on_paused` wiring does not falsely activate when no
1199    /// LLM call occurs, and that `usage_aggregate()` gates on `llm_calls > 0`.
1200    #[tokio::test]
1201    async fn usage_aggregate_none_for_run_without_llm_calls() {
1202        use algocline_core::execution::TerminalOutcome;
1203
1204        let executor = make_executor().await;
1205        let (registry, _tmp) = make_registry(executor);
1206
1207        let sid = registry
1208            .spawn_v2(simple_spec("return 42"), false)
1209            .await
1210            .expect("spawn");
1211
1212        let outcome = registry.await_terminal(&sid).await.expect("await_terminal");
1213        match outcome {
1214            TerminalOutcome::Done(result) => {
1215                assert_eq!(
1216                    result.usage, None,
1217                    "Done.usage must be None when no alc.llm call occurred"
1218                );
1219            }
1220            other => panic!("expected Done, got: {other:?}"),
1221        }
1222    }
1223
1224    // -----------------------------------------------------------------------
1225    // usage_aggregate_some_for_run_with_llm_call (test (a))
1226    // -----------------------------------------------------------------------
1227
1228    /// When `alc.llm` is called and resumed with host-reported usage,
1229    /// `Done.usage` must be `Some(TokenUsage { prompt_tokens: Some(10), completion_tokens: Some(5) })`.
1230    /// Verifies both `on_paused` wiring and `on_response_fed` propagation.
1231    #[tokio::test]
1232    async fn usage_aggregate_some_for_run_with_llm_call() {
1233        use algocline_core::execution::{ResumePayload, TerminalOutcome};
1234        use algocline_core::TokenUsage;
1235
1236        let executor = make_executor().await;
1237        let (registry, _tmp) = make_registry(executor);
1238
1239        let sid = registry
1240            .spawn_v2(simple_spec(r#"return alc.llm("q")"#), false)
1241            .await
1242            .expect("spawn");
1243
1244        // Wait for Paused state.
1245        let mut retries = 0;
1246        loop {
1247            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1248            if registry.state(&sid).await.expect("state").tag() == ExecutionStateTag::Paused {
1249                break;
1250            }
1251            retries += 1;
1252            assert!(retries < 500, "session did not reach Paused state");
1253        }
1254
1255        // Resume with host-reported usage.
1256        registry
1257            .resume(
1258                &sid,
1259                ResumePayload::Single {
1260                    query_id: "q-0".into(),
1261                    response: "answer".into(),
1262                    usage: Some(TokenUsage {
1263                        prompt_tokens: Some(10),
1264                        completion_tokens: Some(5),
1265                    }),
1266                },
1267            )
1268            .await
1269            .expect("resume");
1270
1271        let outcome = registry.await_terminal(&sid).await.expect("await_terminal");
1272        match outcome {
1273            TerminalOutcome::Done(result) => {
1274                assert_eq!(
1275                    result.usage,
1276                    Some(TokenUsage {
1277                        prompt_tokens: Some(10),
1278                        completion_tokens: Some(5),
1279                    }),
1280                    "Done.usage must reflect host-reported token counts"
1281                );
1282            }
1283            other => panic!("expected Done, got: {other:?}"),
1284        }
1285    }
1286
1287    // -----------------------------------------------------------------------
1288    // usage_aggregate_uses_estimates_when_usage_omitted (test (d))
1289    // -----------------------------------------------------------------------
1290
1291    /// When `alc.llm` is called but resumed with `usage: None`, `Done.usage`
1292    /// must be `Some` with non-zero estimated values (Estimated source from
1293    /// prompt length heuristic in `MetricsObserver::on_paused`).
1294    #[tokio::test]
1295    async fn usage_aggregate_uses_estimates_when_usage_omitted() {
1296        use algocline_core::execution::{ResumePayload, TerminalOutcome};
1297
1298        let executor = make_executor().await;
1299        let (registry, _tmp) = make_registry(executor);
1300
1301        let sid = registry
1302            .spawn_v2(simple_spec(r#"return alc.llm("q")"#), false)
1303            .await
1304            .expect("spawn");
1305
1306        // Wait for Paused state.
1307        let mut retries = 0;
1308        loop {
1309            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1310            if registry.state(&sid).await.expect("state").tag() == ExecutionStateTag::Paused {
1311                break;
1312            }
1313            retries += 1;
1314            assert!(retries < 500, "session did not reach Paused state");
1315        }
1316
1317        // Resume without host-reported usage (observer uses Estimated values).
1318        registry
1319            .resume(
1320                &sid,
1321                ResumePayload::Single {
1322                    query_id: "q-0".into(),
1323                    response: "answer".into(),
1324                    usage: None,
1325                },
1326            )
1327            .await
1328            .expect("resume");
1329
1330        let outcome = registry.await_terminal(&sid).await.expect("await_terminal");
1331        match outcome {
1332            TerminalOutcome::Done(result) => {
1333                let usage = result
1334                    .usage
1335                    .expect("Done.usage must be Some when alc.llm was called");
1336                assert!(
1337                    usage.prompt_tokens.unwrap_or(0) > 0
1338                        || usage.completion_tokens.unwrap_or(0) > 0,
1339                    "Done.usage must have non-zero estimated tokens, got: {usage:?}"
1340                );
1341            }
1342            other => panic!("expected Done, got: {other:?}"),
1343        }
1344    }
1345}