grok-build-upstream-mirror/crates/codegen/xai-grok-workspace/src/activity.rs
grokkybara[bot] a422116582 Synced from monorepo
Synced from monorepo

Changes:
- Release a shell session's resources in one drop
- Make the tools blocking-wait cap client-configurable and self-describing
- Recognize API "exceeds budget" errors as context overflow
- Retry /btw on model overload
- Carry running background tasks and subagents across compaction
- Require round-trip time for SDK liveness checks
- Background-subagent completion reminders with a selectable delivery surface
- Make a PTY shell reap itself until it reaches the registry
- Recover the OS error code from a TLS-phase connection reset
- Consume the attached-client signal and report why idle is withheld
- Treat `.grok/sandbox.toml` edits as protected so auto mode prompts before writing
- Surface history/search in the Ctrl+. cheatsheet and keep it working in history view
- Delete sessions from the dashboard and welcome list
- Release a session's activity record when the session ends
- Stop charging auth-retry budget for fail-closed 401s; reset it across suspends
- Scope skills watches on project vendor roots
- Make [stop] cancel in-flight compaction
- Make the leader soak measure the leader, not its harness

Source-Revision: 8d69c91f02bcacf01e98d5aebbf2f92547c45738
2026-07-31 18:08:03 +00:00

2241 lines
88 KiB
Rust

//! Per-session and connection-level activity tracking for tool server
//! lifecycle status reporting.
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, OnceLock};
use std::time::Instant;
use dashmap::DashMap;
use xai_file_utils::events::{Event, EventWriter, ToolCompletedSource, ToolOutcome};
use xai_file_utils::queue::UploadQueueStats;
use xai_tool_protocol::{IdleWithholdReason, ToolServerLifecycleStatus, ToolServerStatusPayload};
const LIFECYCLE_NONE: u8 = 0;
const LIFECYCLE_DRAINING: u8 = 1;
const LIFECYCLE_SHUTTING_DOWN: u8 = 2;
const DEFAULT_SESSION: &str = "__default__";
const SESSION_IDLE_PRUNE_MS: u64 = 5 * 60 * 1000;
/// Default cap (ms) on how long pending durability work (artifact producers /
/// queued uploads) may withhold `idle_since_ms`. Overridable via
/// `GROK_WORKSPACE_DURABILITY_IDLE_HOLD_MAX_MS`.
const DEFAULT_DURABILITY_IDLE_HOLD_MAX_MS: u64 = 600_000;
/// How long recent preview-proxy traffic withholds `idle_since_ms` — a decaying
/// window (not a reset), so a polled preview stays alive but a single stale poll
/// can't pin it. Larger than the 5s status poll, smaller than the idle grace.
pub(crate) const PREVIEW_ACTIVITY_WINDOW_MS: u64 = 60_000;
struct SessionActivity {
active_tool_calls: AtomicU32,
active_tools: DashMap<String, String>,
last_call_started_ms: AtomicU64,
last_call_completed_ms: AtomicU64,
idle_since_ms: AtomicU64,
/// Current turn number (set by `turn_started`).
current_turn: AtomicU64,
/// Whether a turn is currently active.
turn_active: AtomicBool,
}
impl SessionActivity {
fn new() -> Self {
Self {
active_tool_calls: AtomicU32::new(0),
active_tools: DashMap::new(),
last_call_started_ms: AtomicU64::new(0),
last_call_completed_ms: AtomicU64::new(0),
idle_since_ms: AtomicU64::new(now_ms()),
current_turn: AtomicU64::new(0),
turn_active: AtomicBool::new(false),
}
}
}
/// Tracks in-flight tool calls and background tasks for
/// [`ToolServerStatusPayload`] reporting.
///
/// All methods are `&self` — share via `Arc` across the tool handler, the
/// activity feed, and the status publisher.
pub struct ActivityTracker {
active_tool_calls: AtomicU32,
active_tools: DashMap<String, String>,
background_tasks: AtomicU32,
background_ids: DashMap<String, ()>,
last_call_started_ms: AtomicU64,
last_call_completed_ms: AtomicU64,
idle_since_ms: AtomicU64,
started_at: Instant,
lifecycle: AtomicU8,
/// `Arc` so the upload queue (via [`notify_handle`](Self::notify_handle))
/// can wake the same waiter the status publisher blocks on.
notify: Arc<tokio::sync::Notify>,
/// Coupled upload-queue stats; unset for bare trackers (tests, queue-less mode).
upload_queue_stats: OnceLock<Arc<UploadQueueStats>>,
/// Epoch ms a graceful drain began; `0` means "not draining".
drain_started_ms: AtomicU64,
/// Coupled artifact-producer task tracker; unset for bare trackers.
producer_tasks: OnceLock<tokio_util::task::TaskTracker>,
/// Epoch ms the durability-busy condition started; `0` while clear. Stamped
/// lazily at snapshot time.
durability_busy_since_ms: AtomicU64,
/// Cap (ms) on how long durability work may withhold `idle_since_ms`.
durability_idle_hold_max_ms: u64,
/// When set, the idle verdict ignores background tasks so `idle_since_ms`
/// tracks foreground tool-call activity only. Drain/`status` stay bg-aware.
idle_ignores_background: bool,
/// Window (ms) recent preview-proxy traffic withholds idle for; defaults to
/// [`PREVIEW_ACTIVITY_WINDOW_MS`], overridable via the builder.
preview_activity_window_ms: u64,
/// Epoch ms the pane's own status poll was last observed (`0` = none). Fed
/// by the preview-activity scraper; withholds idle within
/// [`preview_activity_window_ms`](Self::preview_activity_window_ms).
///
/// The *observation* time, not the proxy's stamp: the two processes are not
/// clock-coupled, and only the local clock is comparable with the rest.
last_preview_status_ms: AtomicU64,
/// Epoch ms real app traffic was last observed (`0` = none).
last_preview_routed_ms: AtomicU64,
/// Open preview WebSocket (HMR) tunnels as of the last scrape. Nonzero ⇒ a
/// client is attached, which no activity stamp would reveal.
preview_ws_tunnels_open: AtomicU64,
/// In-flight `Routed` preview requests as of the last scrape.
preview_routed_in_flight: AtomicU64,
/// Epoch ms this process started — the floor of the withhold anchor, so a
/// young or freshly-restored workspace is never treated as long-idle.
/// Distinct from [`Self::started_at`], a monotonic `Instant` that cannot be
/// compared against the epoch stamps around it.
started_at_ms: u64,
sessions: DashMap<String, SessionActivity>,
/// call_id → session_id so `tool_call_completed` can decrement
/// the right session without the caller repeating it.
call_to_session: DashMap<String, String>,
/// Idle window (ms) after which an inactive session is pruned by
/// [`known_sessions`]. Set once at construction; no locking.
prune_window_ms: u64,
/// Per-session `events.jsonl` writers, shared (`Arc`) with
/// [`WorkspaceShared`](crate::session::WorkspaceShared). `None` until
/// [`set_event_writers`](Self::set_event_writers) is called during
/// `WorkspaceHandle` construction; bare trackers (the existing unit tests)
/// leave it unset so no `Tool*` events are emitted — behaviour-preserving.
event_writers: OnceLock<Arc<DashMap<String, EventWriter>>>,
/// Per-call start timestamps (epoch ms), keyed by `call_id`. Populated only
/// when the call's session has an OPEN `events.jsonl` writer — i.e. the same
/// condition under which `ToolStarted` is emitted — so the flag-off /
/// no-writer path inserts nothing (the per-session writer map is empty when
/// `GROK_WORKSPACE_EVENTS_ENABLED` is off). Consumed by
/// [`tool_call_completed`](Self::tool_call_completed) to report a truthful
/// `ToolCompleted.duration_ms`.
///
/// The stored value pairs the start timestamp with a clone of the session's
/// `EventWriter` captured at `ToolStarted` time. Holding the writer handle
/// here guarantees a symmetric `ToolStarted`/`ToolCompleted` pair: presence
/// of this entry is the single source of truth that a `ToolStarted` was
/// emitted, and the captured `Arc`-backed writer keeps the session's
/// `events.jsonl` fd alive so the paired `ToolCompleted` is still written
/// even if the per-session writer was evicted (e.g. `on_session_ended`)
/// mid-call.
call_started_ms: DashMap<String, (u64, EventWriter)>,
}
impl Default for ActivityTracker {
fn default() -> Self {
Self::new()
}
}
impl ActivityTracker {
/// Construct a tracker with the default 5-minute session-prune window.
pub fn new() -> Self {
Self::with_prune_window(std::time::Duration::from_millis(SESSION_IDLE_PRUNE_MS))
}
/// Construct a tracker with a custom session-prune window. The durability
/// idle-hold cap comes from `GROK_WORKSPACE_DURABILITY_IDLE_HOLD_MAX_MS`
/// (default [`DEFAULT_DURABILITY_IDLE_HOLD_MAX_MS`]).
pub fn with_prune_window(prune_window: std::time::Duration) -> Self {
Self::with_prune_window_and_idle_hold(prune_window, durability_idle_hold_max_from_env())
}
/// [`with_prune_window`](Self::with_prune_window) with an explicit
/// durability idle-hold cap, so tests never race process env.
pub fn with_prune_window_and_idle_hold(
prune_window: std::time::Duration,
durability_idle_hold_max_ms: u64,
) -> Self {
Self {
active_tool_calls: AtomicU32::new(0),
active_tools: DashMap::new(),
background_tasks: AtomicU32::new(0),
background_ids: DashMap::new(),
last_call_started_ms: AtomicU64::new(0),
last_call_completed_ms: AtomicU64::new(0),
idle_since_ms: AtomicU64::new(now_ms()),
started_at: Instant::now(),
lifecycle: AtomicU8::new(LIFECYCLE_NONE),
notify: Arc::new(tokio::sync::Notify::new()),
upload_queue_stats: OnceLock::new(),
drain_started_ms: AtomicU64::new(0),
producer_tasks: OnceLock::new(),
durability_busy_since_ms: AtomicU64::new(0),
durability_idle_hold_max_ms,
idle_ignores_background: false,
preview_activity_window_ms: PREVIEW_ACTIVITY_WINDOW_MS,
last_preview_status_ms: AtomicU64::new(0),
last_preview_routed_ms: AtomicU64::new(0),
preview_ws_tunnels_open: AtomicU64::new(0),
preview_routed_in_flight: AtomicU64::new(0),
started_at_ms: now_ms(),
sessions: DashMap::new(),
call_to_session: DashMap::new(),
prune_window_ms: prune_window.as_millis() as u64,
event_writers: OnceLock::new(),
call_started_ms: DashMap::new(),
}
}
/// Opt into foreground-only idle: background tasks stop withholding
/// `idle_since_ms`.
pub fn with_idle_ignores_background(mut self, enabled: bool) -> Self {
self.idle_ignores_background = enabled;
self
}
/// Override the preview-activity withhold window; the WorkspaceServer sources
/// it from `StatusConfig`.
pub fn with_preview_activity_window_ms(mut self, window_ms: u64) -> Self {
self.preview_activity_window_ms = window_ms;
self
}
/// Wire the shared per-session `events.jsonl` writer map into the tracker so
/// [`tool_call_started`](Self::tool_call_started) /
/// [`tool_call_completed`](Self::tool_call_completed) can emit `Tool*`
/// events. Set once during `WorkspaceHandle` construction; calling it a
/// second time is a no-op (the first map wins).
pub fn set_event_writers(&self, writers: Arc<DashMap<String, EventWriter>>) {
let _ = self.event_writers.set(writers);
}
/// Couple upload-queue stats (status reports queue depth; `is_drained` waits
/// for the queue). Set once; a second call is a no-op.
pub fn set_upload_queue_stats(&self, stats: Arc<UploadQueueStats>) {
let _ = self.upload_queue_stats.set(stats);
}
/// Couple the artifact-producer tracker (status counts producers, withholds
/// idle while they run). Set once; a second call is a no-op.
pub fn set_producer_tasks(&self, tasks: tokio_util::task::TaskTracker) {
let _ = self.producer_tasks.set(tasks);
}
/// Clone of the internal `Notify` for the upload queue to drive republishes.
pub fn notify_handle(&self) -> Arc<tokio::sync::Notify> {
self.notify.clone()
}
/// Record fresh `Routed` preview traffic. Withholds `idle_since_ms` for
/// [`preview_activity_window_ms`](Self::preview_activity_window_ms) and wakes
/// the status publisher so the renewed "active" status reaches the server promptly.
pub fn note_preview_routed_activity(&self) {
self.last_preview_routed_ms
.store(now_ms(), Ordering::Relaxed);
self.notify.notify_waiters();
}
/// Record a fresh preview status poll. Withholds idle exactly as routed
/// traffic does today, but is tracked separately: the poll continues at the
/// same cadence whether or not anyone is watching.
pub fn note_preview_status_activity(&self) {
self.last_preview_status_ms
.store(now_ms(), Ordering::Relaxed);
self.notify.notify_waiters();
}
/// Mirror the proxy's attached-client counters. Absolute values, not edges,
/// so a missed scrape self-corrects on the next one.
pub fn set_preview_attached(&self, ws_tunnels_open: u64, routed_in_flight: u64) {
let was_attached = self.has_preview_client_attached();
self.preview_ws_tunnels_open
.store(ws_tunnels_open, Ordering::Relaxed);
self.preview_routed_in_flight
.store(routed_in_flight, Ordering::Relaxed);
// The scraper calls this every tick; the common case is 0 → 0.
if was_attached != self.has_preview_client_attached() {
self.notify.notify_waiters();
}
}
/// An open WebSocket tunnel or a routed request in flight. Never a poll.
fn has_preview_client_attached(&self) -> bool {
self.preview_ws_tunnels_open.load(Ordering::Relaxed) > 0
|| self.preview_routed_in_flight.load(Ordering::Relaxed) > 0
}
pub fn preview_ws_tunnels_open(&self) -> u64 {
self.preview_ws_tunnels_open.load(Ordering::Relaxed)
}
/// Window recent preview activity withholds idle for — the horizon the
/// stamps decay over, and the one the scraper ages an absent proxy against.
pub fn preview_activity_window_ms(&self) -> u64 {
self.preview_activity_window_ms
}
/// Pending upload-queue items (0 when no queue is coupled).
fn upload_queue_pending(&self) -> u64 {
self.upload_queue_stats
.get()
.map(|s| s.pending.load(Ordering::Relaxed))
.unwrap_or(0)
}
/// Queue/drain status fields shared by both snapshot paths; all zero when no
/// queue is coupled. Best-effort: independent `Relaxed` loads can transiently
/// show `inflight > pending` (cosmetic; the drain reads `pending` alone).
fn drain_status_fields(&self) -> (u32, u64, u32, bool, Option<u64>) {
let (pending, pending_bytes, inflight, breaker) = match self.upload_queue_stats.get() {
Some(s) => (
s.pending.load(Ordering::Relaxed) as u32,
s.pending_bytes.load(Ordering::Relaxed),
s.inflight.load(Ordering::Relaxed) as u32,
s.circuit_breaker_active.load(Ordering::Relaxed),
),
None => (0, 0, 0, false),
};
let drain_started = match self.drain_started_ms.load(Ordering::Relaxed) {
0 => None,
ms => Some(ms),
};
(pending, pending_bytes, inflight, breaker, drain_started)
}
/// Whether a drain has been started (`set_draining` ran) in this process.
pub fn drain_started(&self) -> bool {
self.drain_started_ms.load(Ordering::Relaxed) != 0
}
/// Durability tail shared by [`Self::snapshot`] and [`Self::snapshot_session`]
/// (one construction site so the two payloads can't drift).
fn durability_payload_fields(&self, idle_since: u64) -> DurabilityPayloadFields {
let (queue_pending, queue_pending_bytes, queue_inflight, breaker, drain_started) =
self.drain_status_fields();
let (producers, durability_withhold) = self.durability_gate(queue_pending, breaker);
let now = now_ms();
let (preview_withhold, preview_reason, preview_anchor) = self.preview_withholds_idle(now);
// Withhold idle on durability work OR preview activity, decided here
// once so both snapshot paths agree (preview has no hold cap; 12h VM TTL backstops).
let withhold_idle = durability_withhold || preview_withhold;
// Invariants: no reason while genuinely busy (`idle_since == 0` — the
// work is the cause, not a concurrent poll); durability outranks
// preview; every reason carries a stamp.
let (withhold_reason, withhold_since_ms) = if idle_since == 0 {
(None, None)
} else if durability_withhold {
(
Some(IdleWithholdReason::Durability),
Some(self.durability_busy_since_ms.load(Ordering::Relaxed)),
)
} else {
(preview_reason, preview_reason.map(|_| preview_anchor))
};
DurabilityPayloadFields {
idle_since_ms: if idle_since == 0 || withhold_idle {
None
} else {
Some(idle_since)
},
withhold_reason,
withhold_since_ms,
preview_ws_tunnels_open: self.preview_ws_tunnels_open().min(u32::MAX as u64) as u32,
upload_queue_pending: queue_pending,
upload_queue_pending_bytes: queue_pending_bytes,
upload_queue_inflight: queue_inflight,
upload_queue_circuit_breaker_tripped: breaker,
artifact_producers_inflight: producers,
drain_started_ms: drain_started,
}
}
/// Durability gate: returns (producers in flight, whether `idle_since_ms`
/// must be withheld). Idle is withheld while producers or queued uploads are
/// outstanding, except past the bounded hold cap, or — queued items only —
/// when the circuit breaker is tripped (queued items survive via disk spill +
/// restart recovery; an in-flight producer has nothing on disk yet, so it
/// withholds regardless of queue health). The hold resets when it clears.
fn durability_gate(&self, queue_pending: u32, breaker_tripped: bool) -> (u32, bool) {
let producers = self
.producer_tasks
.get()
.map(|t| t.len() as u32)
.unwrap_or(0);
let withholding = producers > 0 || (queue_pending > 0 && !breaker_tripped);
if !withholding {
self.durability_busy_since_ms.store(0, Ordering::Relaxed);
return (producers, false);
}
let now = now_ms();
// First observation of the busy condition wins the stamp. Relaxed:
// the stamp guards no other data, it's just a monotonic-enough clock.
let since = match self.durability_busy_since_ms.compare_exchange(
0,
now,
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(_) => now,
Err(prev) => prev,
};
let hold_expired = now.saturating_sub(since) >= self.durability_idle_hold_max_ms;
(producers, !hold_expired)
}
/// Whether preview activity should withhold idle, and on what grounds.
///
/// Returns `(withhold, reason, anchor)`, where the anchor is the epoch-ms
/// the current hold is measured from. Tiers are checked strongest first;
/// all three withhold identically today, only the accounting differs.
fn preview_withholds_idle(&self, now: u64) -> (bool, Option<IdleWithholdReason>, u64) {
// Including process start means a young or freshly-restored workspace
// can never look long-idle — the process restarts on restore, so this
// covers revived sessions without a separate minimum-age rule.
let anchor = self
.last_preview_routed_ms
.load(Ordering::Relaxed)
.max(self.last_call_completed_ms.load(Ordering::Relaxed))
.max(self.started_at_ms);
if self.has_preview_client_attached() {
return (true, Some(IdleWithholdReason::PreviewAttached), anchor);
}
if preview_activity_withholds_idle(
now,
self.last_preview_routed_ms.load(Ordering::Relaxed),
self.preview_activity_window_ms,
) {
return (true, Some(IdleWithholdReason::PreviewRouted), anchor);
}
if preview_activity_withholds_idle(
now,
self.last_preview_status_ms.load(Ordering::Relaxed),
self.preview_activity_window_ms,
) {
// Holds, but never advances the anchor: a poll must not reset a
// clock meant to measure real use.
return (true, Some(IdleWithholdReason::PreviewStatusOnly), anchor);
}
(false, None, anchor)
}
/// Whether any tracked session currently has an active turn (the aggregate
/// `turn_active`).
fn any_turn_active(&self) -> bool {
self.sessions
.iter()
.any(|s| s.value().turn_active.load(Ordering::Acquire))
}
/// Resolve the `events.jsonl` writer for `session_id` — `Some` only when an
/// event sink is configured AND the session already has an open writer
/// (opened at turn start). Returns `None` (so no event is emitted) for the
/// `__default__` / unknown-session cases.
fn session_writer(&self, session_id: Option<&str>) -> Option<EventWriter> {
let session_id = session_id?;
let writers = self.event_writers.get()?;
writers.get(session_id).map(|w| w.value().clone())
}
pub fn tool_call_started(&self, call_id: &str, tool_name: &str, session_id: Option<&str>) {
if self.active_tools.contains_key(call_id) {
return;
}
let now = now_ms();
self.active_tool_calls.fetch_add(1, Ordering::Relaxed);
self.active_tools
.insert(call_id.to_owned(), tool_name.to_owned());
self.last_call_started_ms.store(now, Ordering::Relaxed);
self.idle_since_ms.store(0, Ordering::Relaxed);
let sid = session_id.unwrap_or(DEFAULT_SESSION);
self.call_to_session
.insert(call_id.to_owned(), sid.to_owned());
let session = self
.sessions
.entry(sid.to_owned())
.or_insert_with(SessionActivity::new);
session.active_tool_calls.fetch_add(1, Ordering::Relaxed);
session
.active_tools
.insert(call_id.to_owned(), tool_name.to_owned());
session.last_call_started_ms.store(now, Ordering::Relaxed);
session.idle_since_ms.store(0, Ordering::Relaxed);
self.notify.notify_waiters();
// events.jsonl: only when the session's writer is already open (turn
// started, under the events flag) do we record the start time (for a
// truthful completion duration) and emit `ToolStarted`. When the writer
// is absent — flag-off (empty per-session map) or no turn yet — this
// whole block is skipped, so the flag-off path allocates nothing.
if let Some(writer) = self.session_writer(session_id) {
// Capture the writer handle alongside the start time so the paired
// `ToolCompleted` is emitted to the same `events.jsonl` even if the
// session writer is evicted mid-call.
self.call_started_ms
.insert(call_id.to_owned(), (now, writer.clone()));
writer.emit(Event::ToolStarted {
tool_name: tool_name.to_owned(),
});
}
}
/// Mark an in-flight tool call as completed.
///
/// `session_id` identifies the owning session for the caller's bookkeeping;
/// the internal session counters key off the recorded `call_id → session`
/// mapping, and the [`ToolCompleted`](Event::ToolCompleted) event is written
/// via the `EventWriter` handle captured at `ToolStarted` time (so it lands
/// in the right `events.jsonl` even if the session writer was evicted
/// mid-call). `outcome` is the truthful terminal status from the caller
/// (`Success`/`Error` at the tool handler, `Cancelled` from the cancel
/// paths).
pub fn tool_call_completed(
&self,
call_id: &str,
_session_id: Option<&str>,
outcome: ToolOutcome,
) {
let Some((_, tool_name)) = self.active_tools.remove(call_id) else {
return;
};
let now = now_ms();
self.active_tool_calls.fetch_sub(1, Ordering::AcqRel);
self.last_call_completed_ms.store(now, Ordering::Relaxed);
// Re-read after decrement: a concurrent `tool_call_started` may
// have bumped the counter back up between our `fetch_sub` and
// this load. Only transition to idle if we're truly at zero.
if self.active_tool_calls.load(Ordering::Acquire) == 0
&& (self.idle_ignores_background || self.background_tasks.load(Ordering::Acquire) == 0)
{
self.idle_since_ms.store(now, Ordering::Relaxed);
}
if let Some((_, sid)) = self.call_to_session.remove(call_id)
&& let Some(session) = self.sessions.get(&sid)
{
session.active_tool_calls.fetch_sub(1, Ordering::AcqRel);
session.active_tools.remove(call_id);
session.last_call_completed_ms.store(now, Ordering::Relaxed);
if session.active_tool_calls.load(Ordering::Acquire) == 0 {
session.idle_since_ms.store(now, Ordering::Relaxed);
}
}
self.notify.notify_waiters();
// events.jsonl: emit `ToolCompleted` only when a paired `ToolStarted`
// was recorded for this call (its `call_started_ms` entry is present).
// Gating on that entry — rather than re-checking writer state at
// completion — keeps the start/completion pair symmetric: no orphan
// zero-duration `ToolCompleted` when a writer opens mid-call, and no
// dropped completion when the session writer is evicted mid-call (the
// captured writer handle keeps the file open). `duration_ms` is always
// truthful because the start time is paired with the writer.
if let Some((_, (started_ms, writer))) = self.call_started_ms.remove(call_id) {
// Workspace = hub/proxy hop; join package durations on shell rows (no source).
writer.emit(Event::ToolCompleted {
tool_name,
duration_ms: now.saturating_sub(started_ms),
outcome,
tool_call_id: call_id.to_owned(),
source: ToolCompletedSource::Workspace,
});
}
}
pub fn background_task_started(&self, task_id: &str) {
if self.background_ids.contains_key(task_id) {
return;
}
self.background_tasks.fetch_add(1, Ordering::Relaxed);
self.background_ids.insert(task_id.to_owned(), ());
if self.idle_ignores_background {
// An active fg call's store(0) must keep idle withheld.
if self.active_tool_calls.load(Ordering::Acquire) == 0 {
self.idle_since_ms.store(now_ms(), Ordering::Relaxed);
}
} else {
self.idle_since_ms.store(0, Ordering::Relaxed);
}
self.notify.notify_waiters();
}
pub fn background_task_completed(&self, task_id: &str) {
if self.background_ids.remove(task_id).is_none() {
return;
}
let prev = self.background_tasks.fetch_sub(1, Ordering::AcqRel);
if !self.idle_ignores_background
&& prev == 1
&& self.active_tool_calls.load(Ordering::Acquire) == 0
{
self.idle_since_ms.store(now_ms(), Ordering::Relaxed);
}
self.notify.notify_waiters();
}
pub fn turn_started(&self, session_id: &str, turn_number: u64) {
let session = self
.sessions
.entry(session_id.to_owned())
.or_insert_with(SessionActivity::new);
session.current_turn.store(turn_number, Ordering::Release);
session.turn_active.store(true, Ordering::Release);
self.notify.notify_waiters();
}
pub fn turn_completed(&self, session_id: &str, turn_number: u64, _duration_ms: u64) {
if let Some(session) = self.sessions.get(session_id)
&& session.current_turn.load(Ordering::Acquire) == turn_number
{
session.turn_active.store(false, Ordering::Release);
self.notify.notify_waiters();
}
}
/// Complete all in-flight tool calls for the given session.
///
/// Returns the number of calls that were marked as completed.
/// Called when a session-wide Cancel hook arrives without a specific
/// `call_id` (broadcast cancel from Ctrl+C).
pub fn cancel_all_session_calls(&self, session_id: &str) -> usize {
let call_ids: Vec<String> = self
.call_to_session
.iter()
.filter(|entry| entry.value() == session_id)
.map(|entry| entry.key().clone())
.collect();
let count = call_ids.len();
for call_id in call_ids {
self.tool_call_completed(&call_id, Some(session_id), ToolOutcome::Cancelled);
}
count
}
/// Releases the session's entry. One with calls in flight keeps it: the
/// swap policy reads that count, and the idle prune collects it later.
pub fn session_ended(&self, session_id: &str) {
let released = self
.sessions
.remove_if(session_id, |_, s| {
s.active_tool_calls.load(Ordering::Acquire) == 0
})
.is_some();
if !released && let Some(session) = self.sessions.get(session_id) {
session.turn_active.store(false, Ordering::Release);
}
self.notify.notify_waiters();
}
/// Whether a turn is currently active for the given session.
pub fn is_turn_active(&self, session_id: &str) -> bool {
self.sessions
.get(session_id)
.is_some_and(|s| s.turn_active.load(Ordering::Acquire))
}
/// In-flight tool calls for the given session (`0` when unknown). Only
/// the model-facing tool handler ticks the underlying counter, so
/// `workspace_rpc` traffic never contributes.
pub fn session_active_tool_calls(&self, session_id: &str) -> u32 {
self.sessions
.get(session_id)
.map_or(0, |s| s.active_tool_calls.load(Ordering::Acquire))
}
pub fn set_active(&self) {
self.lifecycle.store(LIFECYCLE_NONE, Ordering::Release);
// Clear the drain stamp symmetrically with `set_draining`: leaving it set
// after a resume would make `drain_started_ms` mean "a drain ever began"
// rather than "currently draining", and the server's idle gate keys its
// unconditional drain escape off that stamp (a stale stamp would let it
// report idle while durable work is still outstanding).
self.drain_started_ms.store(0, Ordering::Release);
self.notify.notify_waiters();
}
pub fn set_draining(&self) {
self.lifecycle.store(LIFECYCLE_DRAINING, Ordering::Release);
// First transition wins, so `drain_started_ms` is stable across calls.
let _ = self.drain_started_ms.compare_exchange(
0,
now_ms(),
Ordering::AcqRel,
Ordering::Relaxed,
);
self.notify.notify_waiters();
}
pub fn set_shutting_down(&self) {
self.lifecycle
.store(LIFECYCLE_SHUTTING_DOWN, Ordering::Release);
self.notify.notify_waiters();
}
pub fn is_draining(&self) -> bool {
self.lifecycle.load(Ordering::Acquire) >= LIFECYCLE_DRAINING
}
/// Fully drained: draining, no active tool calls/background tasks, and the
/// upload queue emptied (must not exit with artifacts still pending).
///
/// In-flight artifact producers are intentionally *not* part of this gate:
/// they are withheld from idle by [`Self::durability_gate`] and are awaited
/// by the graceful drain's producer phase (`phase 1.5` of
/// `WorkspaceHandle::two_phase_drain`)
/// *before* the queue flush, so by the time the queue empties their work has
/// already been enqueued and is reflected in `upload_queue_pending`.
pub fn is_drained(&self) -> bool {
self.is_draining() && self.total_active() == 0 && self.upload_queue_pending() == 0
}
/// Phase-1 drain condition: all in-flight tool calls and background tasks
/// finished, independent of the upload queue.
pub fn tools_idle(&self) -> bool {
self.total_active() == 0
}
pub fn total_active(&self) -> u32 {
self.active_tool_calls.load(Ordering::Relaxed)
+ self.background_tasks.load(Ordering::Relaxed)
}
pub async fn wait_for_change(&self, timeout: std::time::Duration) {
let _ = tokio::time::timeout(timeout, self.notify.notified()).await;
}
/// Wake the status publisher so it sends a heartbeat immediately.
pub fn poke(&self) {
self.notify.notify_waiters();
}
/// Wait until the tracker is both draining and all active work
/// (tool calls + background tasks + the upload queue) has completed.
pub async fn wait_until_drained(&self) {
loop {
if self.is_drained() {
return;
}
// `notify_waiters` stores no permit, so a wake between the check and
// this await is missed — safe because every caller is timeout-bounded.
self.notify.notified().await;
}
}
/// Wait until all in-flight tool calls and background tasks have finished,
/// ignoring the upload queue (phase 1 of the two-phase drain).
pub async fn wait_until_tools_idle(&self) {
loop {
if self.tools_idle() {
return;
}
// Same timeout-bounded missed-wakeup tolerance as `wait_until_drained`.
self.notify.notified().await;
}
}
/// Resident session records. Does not prune, unlike [`Self::known_sessions`].
pub fn session_count(&self) -> usize {
self.sessions.len()
}
/// Returns live session IDs. As a side-effect, prunes sessions
/// that have been idle longer than the configured prune window.
pub fn known_sessions(&self) -> Vec<String> {
let now = now_ms();
let mut live = Vec::new();
let mut stale = Vec::new();
for entry in self.sessions.iter() {
let key = entry.key();
if key == DEFAULT_SESSION {
continue;
}
if entry.value().active_tool_calls.load(Ordering::Relaxed) > 0 {
live.push(key.clone());
continue;
}
let idle_since = entry.value().idle_since_ms.load(Ordering::Relaxed);
if idle_since > 0 && now.saturating_sub(idle_since) > self.prune_window_ms {
stale.push(key.clone());
} else {
live.push(key.clone());
}
}
for key in &stale {
self.sessions.remove(key);
self.call_to_session.retain(|_, sid| sid != key);
}
live
}
/// Per-session snapshot. `background_task_ids` is the connection aggregate,
/// re-published to the session's client.
pub fn snapshot_session(&self, session_id: &str) -> ToolServerStatusPayload {
let lifecycle = self.lifecycle.load(Ordering::Acquire);
let bg = self.background_tasks.load(Ordering::Relaxed);
let (active, active_tool_names, last_started, last_completed, idle_since, turn_active) =
if let Some(session) = self.sessions.get(session_id) {
let a = session.active_tool_calls.load(Ordering::Relaxed);
let names: Vec<String> = session
.active_tools
.iter()
.map(|r| r.value().clone())
.collect();
let started = session.last_call_started_ms.load(Ordering::Relaxed);
let completed = session.last_call_completed_ms.load(Ordering::Relaxed);
let idle = session.idle_since_ms.load(Ordering::Relaxed);
let turn = session.turn_active.load(Ordering::Acquire);
(a, names, started, completed, idle, turn)
} else {
(0, vec![], 0, 0, now_ms(), false)
};
let status = match lifecycle {
LIFECYCLE_SHUTTING_DOWN => ToolServerLifecycleStatus::ShuttingDown,
LIFECYCLE_DRAINING => ToolServerLifecycleStatus::Draining,
_ if active > 0 => ToolServerLifecycleStatus::Busy,
_ => ToolServerLifecycleStatus::Ready,
};
let background_task_ids: Vec<String> = self
.background_ids
.iter()
.map(|r| r.key().clone())
.collect();
let d = self.durability_payload_fields(idle_since);
ToolServerStatusPayload {
status,
session_id: xai_tool_protocol::SessionId::new(session_id).ok(),
connection_id: None,
active_tool_calls: active,
active_tool_names,
background_tasks: bg,
background_task_ids,
pending_tool_calls: 0,
last_tool_call_started_ms: last_started,
last_tool_call_completed_ms: last_completed,
uptime_ms: self.started_at.elapsed().as_millis() as u64,
idle_since_ms: d.idle_since_ms,
upload_queue_pending: d.upload_queue_pending,
upload_queue_pending_bytes: d.upload_queue_pending_bytes,
upload_queue_inflight: d.upload_queue_inflight,
upload_queue_circuit_breaker_tripped: d.upload_queue_circuit_breaker_tripped,
artifact_producers_inflight: d.artifact_producers_inflight,
drain_started_ms: d.drain_started_ms,
turn_active,
idle_ignores_background: self.idle_ignores_background,
withhold_reason: d.withhold_reason,
withhold_since_ms: d.withhold_since_ms,
// No ceilings configured yet, so a hold can never be capped.
withhold_capped: false,
preview_ws_tunnels_open: d.preview_ws_tunnels_open,
}
}
/// Aggregate snapshot across all sessions.
pub fn snapshot(&self) -> ToolServerStatusPayload {
let lifecycle = self.lifecycle.load(Ordering::Acquire);
let active = self.active_tool_calls.load(Ordering::Relaxed);
let bg = self.background_tasks.load(Ordering::Relaxed);
let status = match lifecycle {
LIFECYCLE_SHUTTING_DOWN => ToolServerLifecycleStatus::ShuttingDown,
LIFECYCLE_DRAINING => ToolServerLifecycleStatus::Draining,
_ if active + bg > 0 => ToolServerLifecycleStatus::Busy,
_ => ToolServerLifecycleStatus::Ready,
};
let active_tool_names: Vec<String> = self
.active_tools
.iter()
.map(|r| r.value().clone())
.collect();
let background_task_ids: Vec<String> = self
.background_ids
.iter()
.map(|r| r.key().clone())
.collect();
let idle_since = self.idle_since_ms.load(Ordering::Relaxed);
let d = self.durability_payload_fields(idle_since);
ToolServerStatusPayload {
status,
session_id: None,
connection_id: None,
active_tool_calls: active,
active_tool_names,
background_tasks: bg,
background_task_ids,
pending_tool_calls: 0,
last_tool_call_started_ms: self.last_call_started_ms.load(Ordering::Relaxed),
last_tool_call_completed_ms: self.last_call_completed_ms.load(Ordering::Relaxed),
uptime_ms: self.started_at.elapsed().as_millis() as u64,
idle_since_ms: d.idle_since_ms,
upload_queue_pending: d.upload_queue_pending,
upload_queue_pending_bytes: d.upload_queue_pending_bytes,
upload_queue_inflight: d.upload_queue_inflight,
upload_queue_circuit_breaker_tripped: d.upload_queue_circuit_breaker_tripped,
artifact_producers_inflight: d.artifact_producers_inflight,
drain_started_ms: d.drain_started_ms,
turn_active: self.any_turn_active(),
idle_ignores_background: self.idle_ignores_background,
withhold_reason: d.withhold_reason,
withhold_since_ms: d.withhold_since_ms,
// No ceilings configured yet, so a hold can never be capped.
withhold_capped: false,
preview_ws_tunnels_open: d.preview_ws_tunnels_open,
}
}
}
/// Durability tail of a [`ToolServerStatusPayload`], built only by
/// [`ActivityTracker::durability_payload_fields`].
struct DurabilityPayloadFields {
idle_since_ms: Option<u64>,
withhold_reason: Option<IdleWithholdReason>,
withhold_since_ms: Option<u64>,
preview_ws_tunnels_open: u32,
upload_queue_pending: u32,
upload_queue_pending_bytes: u64,
upload_queue_inflight: u32,
upload_queue_circuit_breaker_tripped: bool,
artifact_producers_inflight: u32,
drain_started_ms: Option<u64>,
}
/// The durability idle-hold cap from `GROK_WORKSPACE_DURABILITY_IDLE_HOLD_MAX_MS`.
fn durability_idle_hold_max_from_env() -> u64 {
durability_idle_hold_from_raw(std::env::var("GROK_WORKSPACE_DURABILITY_IDLE_HOLD_MAX_MS").ok())
}
/// Pure parse of the idle-hold env value: a non-negative integer ms wins (0
/// disables the hold entirely); absent or malformed falls back to the default.
fn durability_idle_hold_from_raw(raw: Option<String>) -> u64 {
raw.and_then(|s| s.trim().parse::<u64>().ok())
.unwrap_or(DEFAULT_DURABILITY_IDLE_HOLD_MAX_MS)
}
/// Whether a preview-activity stamp still withholds idle at `now`: true while it
/// is within `window` ms. A zero stamp (no activity recorded) never withholds,
/// and the window is exclusive at the boundary so it decays rather than pins.
fn preview_activity_withholds_idle(now: u64, last_activity_ms: u64, window_ms: u64) -> bool {
last_activity_ms != 0 && now.saturating_sub(last_activity_ms) < window_ms
}
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn starts_ready() {
let t = ActivityTracker::new();
let s = t.snapshot();
assert_eq!(s.status, ToolServerLifecycleStatus::Ready);
assert_eq!(s.active_tool_calls, 0);
assert!(s.idle_since_ms.is_some());
assert!(s.session_id.is_none());
}
#[test]
fn tool_call_transitions_to_busy() {
let t = ActivityTracker::new();
t.tool_call_started("c1", "read_file", Some("sess-a"));
let s = t.snapshot();
assert_eq!(s.status, ToolServerLifecycleStatus::Busy);
assert_eq!(s.active_tool_calls, 1);
assert_eq!(s.active_tool_names, vec!["read_file"]);
assert!(s.idle_since_ms.is_none());
}
#[test]
fn tool_call_completion_returns_to_ready() {
let t = ActivityTracker::new();
t.tool_call_started("c1", "read_file", Some("sess-a"));
t.tool_call_completed("c1", None, ToolOutcome::Success);
let s = t.snapshot();
assert_eq!(s.status, ToolServerLifecycleStatus::Ready);
assert_eq!(s.active_tool_calls, 0);
assert!(s.idle_since_ms.is_some());
}
#[test]
fn background_task_makes_busy() {
let t = ActivityTracker::new();
t.background_task_started("t1");
let s = t.snapshot();
assert_eq!(s.status, ToolServerLifecycleStatus::Busy);
assert_eq!(s.background_tasks, 1);
}
#[test]
fn background_task_started_dedups_by_id() {
let t = ActivityTracker::new();
t.background_task_started("dup");
t.background_task_started("dup");
assert_eq!(t.snapshot().background_tasks, 1);
}
#[test]
fn background_task_completed_unknown_id_does_not_underflow() {
let t = ActivityTracker::new();
t.background_task_completed("never-started");
assert_eq!(t.snapshot().background_tasks, 0);
t.background_task_started("real");
assert_eq!(t.snapshot().background_tasks, 1);
}
#[test]
fn background_task_decrement_restores_idle_only_at_zero() {
let t = ActivityTracker::new();
t.background_task_started("a");
t.background_task_started("b");
assert!(t.snapshot().idle_since_ms.is_none());
t.background_task_completed("a");
assert!(
t.snapshot().idle_since_ms.is_none(),
"one bg task left → still not idle"
);
t.background_task_completed("b");
assert!(
t.snapshot().idle_since_ms.is_some(),
"idle restored only when the last bg task completes"
);
}
#[test]
fn background_after_calls_complete_pins_idle_only_when_flag_off() {
let on = ActivityTracker::new().with_idle_ignores_background(true);
on.tool_call_started("c1", "read_file", None);
on.tool_call_completed("c1", None, ToolOutcome::Success);
on.background_task_started("bg1");
assert!(on.snapshot().idle_since_ms.is_some());
let off = ActivityTracker::new();
off.tool_call_started("c1", "read_file", None);
off.tool_call_completed("c1", None, ToolOutcome::Success);
off.background_task_started("bg1");
assert!(off.snapshot().idle_since_ms.is_none());
}
#[test]
fn flag_on_active_call_withholds_idle_until_it_completes_despite_background() {
let t = ActivityTracker::new().with_idle_ignores_background(true);
t.tool_call_started("c1", "read_file", None);
t.background_task_started("bg1");
assert!(t.snapshot().idle_since_ms.is_none());
t.tool_call_completed("c1", None, ToolOutcome::Success);
assert!(t.snapshot().idle_since_ms.is_some());
}
#[test]
fn flag_on_keeps_busy_status_while_reporting_idle() {
let t = ActivityTracker::new().with_idle_ignores_background(true);
t.background_task_started("bg1");
let s = t.snapshot();
assert_eq!(s.status, ToolServerLifecycleStatus::Busy);
assert!(s.idle_since_ms.is_some());
}
#[test]
fn flag_on_background_completion_does_not_advance_idle() {
let t = ActivityTracker::new().with_idle_ignores_background(true);
t.background_task_started("bg1");
let after_start = t.snapshot().idle_since_ms;
std::thread::sleep(std::time::Duration::from_millis(5));
t.background_task_completed("bg1");
assert_eq!(t.snapshot().idle_since_ms, after_start);
}
#[test]
fn flag_on_background_start_advances_idle_when_no_active_call() {
let t = ActivityTracker::new().with_idle_ignores_background(true);
t.tool_call_started("c1", "read_file", None);
t.tool_call_completed("c1", None, ToolOutcome::Success);
let before = t.snapshot().idle_since_ms.unwrap();
std::thread::sleep(std::time::Duration::from_millis(5));
t.background_task_started("bg1");
let after = t.snapshot().idle_since_ms.unwrap();
assert!(after > before);
}
#[test]
fn drain_counts_background_tasks_regardless_of_flag() {
for flag in [false, true] {
let t = ActivityTracker::new().with_idle_ignores_background(flag);
t.background_task_started("bg1");
assert_eq!(t.total_active(), 1);
t.set_draining();
assert!(!t.is_drained());
assert!(!t.tools_idle());
t.background_task_completed("bg1");
assert!(t.is_drained());
assert!(t.tools_idle());
}
}
#[test]
fn snapshot_payloads_report_idle_ignores_background_flag() {
let on = ActivityTracker::new().with_idle_ignores_background(true);
assert!(on.snapshot().idle_ignores_background);
assert!(on.snapshot_session("sess-a").idle_ignores_background);
let off = ActivityTracker::new();
assert!(!off.snapshot().idle_ignores_background);
assert!(!off.snapshot_session("sess-a").idle_ignores_background);
}
#[test]
fn draining_overrides_busy() {
let t = ActivityTracker::new();
t.tool_call_started("c1", "grep", None);
t.set_draining();
assert_eq!(t.snapshot().status, ToolServerLifecycleStatus::Draining);
}
#[test]
fn set_active_clears_draining() {
let t = ActivityTracker::new();
t.set_draining();
assert_eq!(t.snapshot().status, ToolServerLifecycleStatus::Draining);
t.set_active();
assert_eq!(t.snapshot().status, ToolServerLifecycleStatus::Ready);
assert!(!t.is_draining());
t.tool_call_started("c1", "read_file", Some("sess-a"));
assert_eq!(t.snapshot().status, ToolServerLifecycleStatus::Busy);
}
#[test]
fn shutting_down_overrides_draining() {
let t = ActivityTracker::new();
t.set_draining();
t.set_shutting_down();
assert_eq!(t.snapshot().status, ToolServerLifecycleStatus::ShuttingDown);
}
#[test]
fn is_drained_requires_zero_active() {
let t = ActivityTracker::new();
t.tool_call_started("c1", "x", None);
t.set_draining();
assert!(!t.is_drained());
t.tool_call_completed("c1", None, ToolOutcome::Success);
assert!(t.is_drained());
}
// ── upload-queue coupling + drain status fields ───────────────
/// Coupled queue depth surfaces in both aggregate and per-session payloads.
#[test]
fn snapshot_reports_coupled_upload_queue_stats() {
let t = ActivityTracker::new();
let stats = Arc::new(UploadQueueStats::new());
stats.pending.store(3, Ordering::Relaxed);
stats.pending_bytes.store(9000, Ordering::Relaxed);
stats.inflight.store(1, Ordering::Relaxed);
stats.circuit_breaker_active.store(true, Ordering::Relaxed);
t.set_upload_queue_stats(stats);
let agg = t.snapshot();
assert_eq!(agg.upload_queue_pending, 3);
assert_eq!(agg.upload_queue_pending_bytes, 9000);
assert_eq!(agg.upload_queue_inflight, 1);
assert!(agg.upload_queue_circuit_breaker_tripped);
t.tool_call_started("c1", "x", Some("sess-a"));
let sess = t.snapshot_session("sess-a");
assert_eq!(sess.upload_queue_pending, 3);
assert_eq!(sess.upload_queue_inflight, 1);
assert!(sess.upload_queue_circuit_breaker_tripped);
}
/// The queue-less path reports zeroed queue fields (legacy behaviour).
#[test]
fn snapshot_queue_fields_zero_without_coupled_queue() {
let t = ActivityTracker::new();
let s = t.snapshot();
assert_eq!(s.upload_queue_pending, 0);
assert_eq!(s.upload_queue_pending_bytes, 0);
assert_eq!(s.upload_queue_inflight, 0);
assert!(!s.upload_queue_circuit_breaker_tripped);
assert_eq!(s.drain_started_ms, None);
}
/// Draining with no tool calls but a non-empty queue is NOT drained.
#[test]
fn is_drained_requires_empty_upload_queue() {
let t = ActivityTracker::new();
let stats = Arc::new(UploadQueueStats::new());
stats.pending.store(2, Ordering::Relaxed);
t.set_upload_queue_stats(stats.clone());
t.set_draining();
assert!(
!t.is_drained(),
"queue still has 2 pending → not drained even with no tool calls"
);
stats.pending.store(0, Ordering::Relaxed);
assert!(t.is_drained(), "queue emptied → now fully drained");
}
/// The phase-1 drain condition must not wait on the queue.
#[test]
fn tools_idle_ignores_upload_queue() {
let t = ActivityTracker::new();
let stats = Arc::new(UploadQueueStats::new());
stats.pending.store(5, Ordering::Relaxed);
t.set_upload_queue_stats(stats);
assert!(
t.tools_idle(),
"no tool calls → tools idle regardless of queue"
);
assert!(
!t.is_drained(),
"but not fully drained while queue is non-empty"
);
}
// ── durability-aware idle gating ───────────────────────────────
#[tokio::test]
async fn idle_withheld_while_producer_in_flight() {
let t = ActivityTracker::new();
let tasks = tokio_util::task::TaskTracker::new();
t.set_producer_tasks(tasks.clone());
let s = t.snapshot();
assert_eq!(s.artifact_producers_inflight, 0);
assert!(s.idle_since_ms.is_some(), "no producers → idle reported");
let gate = Arc::new(tokio::sync::Notify::new());
let gate2 = gate.clone();
let join = tasks.spawn(async move { gate2.notified().await });
let s = t.snapshot();
assert_eq!(s.artifact_producers_inflight, 1);
assert!(
s.idle_since_ms.is_none(),
"an in-flight producer must withhold idle"
);
assert!(t.snapshot_session("any").idle_since_ms.is_none());
gate.notify_one();
join.await.expect("producer task must not panic");
let s = t.snapshot();
assert_eq!(s.artifact_producers_inflight, 0);
assert!(
s.idle_since_ms.is_some(),
"idle must be restored once the producer completes"
);
}
#[test]
fn idle_withheld_while_upload_queue_pending() {
let t = ActivityTracker::new();
let stats = Arc::new(UploadQueueStats::new());
stats.pending.store(1, Ordering::Relaxed);
t.set_upload_queue_stats(stats.clone());
assert!(
t.snapshot().idle_since_ms.is_none(),
"pending uploads must withhold idle"
);
stats.pending.store(0, Ordering::Relaxed);
assert!(
t.snapshot().idle_since_ms.is_some(),
"idle must be restored once the queue empties"
);
}
#[test]
fn durability_hold_cap_expiry_allows_idle() {
let t = ActivityTracker::with_prune_window_and_idle_hold(
std::time::Duration::from_millis(SESSION_IDLE_PRUNE_MS),
1_000,
);
let stats = Arc::new(UploadQueueStats::new());
stats.pending.store(1, Ordering::Relaxed);
t.set_upload_queue_stats(stats);
assert!(t.snapshot().idle_since_ms.is_none(), "within the hold cap");
// Backdate the busy stamp past the cap.
t.durability_busy_since_ms
.store(now_ms() - 2_000, Ordering::Relaxed);
let s = t.snapshot();
assert!(
s.idle_since_ms.is_some(),
"expired hold cap must allow idle despite pending work"
);
assert_eq!(
s.upload_queue_pending, 1,
"the pending depth stays truthfully reported"
);
}
#[test]
fn durability_busy_stamp_resets_when_condition_clears() {
let t = ActivityTracker::with_prune_window_and_idle_hold(
std::time::Duration::from_millis(SESSION_IDLE_PRUNE_MS),
1_000,
);
let stats = Arc::new(UploadQueueStats::new());
stats.pending.store(1, Ordering::Relaxed);
t.set_upload_queue_stats(stats.clone());
let _ = t.snapshot(); // stamps the busy start
t.durability_busy_since_ms
.store(now_ms() - 600, Ordering::Relaxed);
stats.pending.store(0, Ordering::Relaxed);
let _ = t.snapshot();
assert_eq!(
t.durability_busy_since_ms.load(Ordering::Relaxed),
0,
"a clear condition must reset the stamp"
);
// A new busy condition measures its hold from a fresh stamp.
stats.pending.store(1, Ordering::Relaxed);
assert!(t.snapshot().idle_since_ms.is_none());
}
#[test]
fn breaker_tripped_allows_idle_despite_pending_work() {
let t = ActivityTracker::new();
let stats = Arc::new(UploadQueueStats::new());
stats.pending.store(3, Ordering::Relaxed);
stats.circuit_breaker_active.store(true, Ordering::Relaxed);
t.set_upload_queue_stats(stats);
let s = t.snapshot();
assert!(
s.idle_since_ms.is_some(),
"a tripped breaker must allow hibernation despite pending work"
);
assert!(s.upload_queue_circuit_breaker_tripped);
}
/// The breaker escape covers queued items only (they survive via disk
/// spill + restart recovery); an in-flight producer has nothing on disk
/// yet and must keep withholding idle even with the breaker tripped.
#[tokio::test]
async fn breaker_tripped_does_not_bypass_producer_hold() {
let t = ActivityTracker::new();
let stats = Arc::new(UploadQueueStats::new());
stats.circuit_breaker_active.store(true, Ordering::Relaxed);
t.set_upload_queue_stats(stats);
let tasks = tokio_util::task::TaskTracker::new();
t.set_producer_tasks(tasks.clone());
let gate = Arc::new(tokio::sync::Notify::new());
let gate2 = gate.clone();
let join = tasks.spawn(async move { gate2.notified().await });
assert!(
t.snapshot().idle_since_ms.is_none(),
"a producer must withhold idle even with the breaker tripped"
);
gate.notify_one();
join.await.expect("producer task must not panic");
assert!(
t.snapshot().idle_since_ms.is_some(),
"idle restored once the producer completes (breaker alone is no hold)"
);
}
#[test]
fn preview_activity_withholds_idle_window_boundaries() {
let now = 10_000_000;
assert!(
!preview_activity_withholds_idle(now, 0, PREVIEW_ACTIVITY_WINDOW_MS),
"a zero stamp (no activity ever) must never withhold"
);
assert!(
preview_activity_withholds_idle(now, now, PREVIEW_ACTIVITY_WINDOW_MS),
"activity right now withholds"
);
assert!(
preview_activity_withholds_idle(
now,
now - (PREVIEW_ACTIVITY_WINDOW_MS - 1),
PREVIEW_ACTIVITY_WINDOW_MS
),
"just inside the window withholds"
);
assert!(
!preview_activity_withholds_idle(
now,
now - PREVIEW_ACTIVITY_WINDOW_MS,
PREVIEW_ACTIVITY_WINDOW_MS
),
"exactly at the window edge no longer withholds (decaying, exclusive)"
);
assert!(
!preview_activity_withholds_idle(
now,
now - (PREVIEW_ACTIVITY_WINDOW_MS + 5_000),
PREVIEW_ACTIVITY_WINDOW_MS
),
"past the window no longer withholds"
);
}
#[test]
fn note_preview_activity_withholds_then_resumes_idle() {
for (label, note) in [
(
"routed",
&ActivityTracker::note_preview_routed_activity as &dyn Fn(&ActivityTracker),
),
("status", &ActivityTracker::note_preview_status_activity),
] {
let t = ActivityTracker::new();
assert!(
t.snapshot().idle_since_ms.is_some(),
"{label}: an idle tracker reports idle before any preview activity"
);
note(&t);
assert!(
t.snapshot().idle_since_ms.is_none(),
"{label}: recent preview activity must withhold idle"
);
assert!(
t.snapshot_session("any").idle_since_ms.is_none(),
"{label}: the per-session payload must withhold idle too"
);
let stale = now_ms().saturating_sub(PREVIEW_ACTIVITY_WINDOW_MS + 1_000);
t.last_preview_routed_ms.store(stale, Ordering::Relaxed);
t.last_preview_status_ms.store(stale, Ordering::Relaxed);
assert!(
t.snapshot().idle_since_ms.is_some(),
"{label}: idle must resume once the preview window decays"
);
}
}
#[test]
fn withhold_reason_reports_the_tier_that_is_holding() {
let t = ActivityTracker::new();
assert_eq!(
t.snapshot().withhold_reason,
None,
"nothing holding ⇒ no reason"
);
t.note_preview_status_activity();
let s = t.snapshot();
assert_eq!(
s.withhold_reason,
Some(IdleWithholdReason::PreviewStatusOnly),
"a bare status poll is the weakest tier"
);
assert!(
s.withhold_since_ms.is_some(),
"every reason carries a since-stamp so a reader can age the hold"
);
t.note_preview_routed_activity();
assert_eq!(
t.snapshot().withhold_reason,
Some(IdleWithholdReason::PreviewRouted),
"real app traffic outranks the status poll"
);
t.set_preview_attached(1, 0);
let s = t.snapshot();
assert_eq!(
s.withhold_reason,
Some(IdleWithholdReason::PreviewAttached),
"an open tunnel outranks everything below it"
);
assert_eq!(s.preview_ws_tunnels_open, 1);
assert!(!s.withhold_capped, "no ceilings configured yet");
}
/// An HMR socket writes no activity stamps, so the counter must hold alone
/// — which is exactly why the scraper must not clear it on one missed poll.
#[test]
fn attached_client_withholds_without_any_activity_stamp() {
let t = ActivityTracker::new();
assert!(t.snapshot().idle_since_ms.is_some());
t.set_preview_attached(1, 0);
assert!(
t.snapshot().idle_since_ms.is_none(),
"an open WebSocket tunnel withholds idle by itself"
);
t.set_preview_attached(0, 1);
assert_eq!(
t.snapshot().withhold_reason,
Some(IdleWithholdReason::PreviewAttached),
"a routed request in flight is equally an attached client"
);
t.set_preview_attached(0, 0);
assert!(
t.snapshot().idle_since_ms.is_some(),
"once detached with no stamp in window, idle resumes"
);
}
/// A ceiling will be measured from the anchor, so letting the pane's own
/// poll reset it would make that ceiling unreachable.
#[test]
fn status_poll_does_not_advance_the_withhold_anchor() {
let t = ActivityTracker::new();
t.note_preview_routed_activity();
let anchor = t.snapshot().withhold_since_ms.expect("routed holds");
std::thread::sleep(std::time::Duration::from_millis(5));
t.note_preview_status_activity();
assert_eq!(
t.snapshot().withhold_since_ms,
Some(anchor),
"a status poll leaves the anchor where the last real use put it"
);
}
/// A session busy with real work must not be attributed to the preview just
/// because the pane happens to be polling it. Both look like a missing
/// `idle_since_ms` on the wire, and conflating them would inflate the
/// preview share of exactly the population this field exists to measure.
#[test]
fn a_genuinely_busy_session_reports_no_withhold_reason() {
let t = ActivityTracker::new();
t.note_preview_status_activity();
assert_eq!(
t.snapshot().withhold_reason,
Some(IdleWithholdReason::PreviewStatusOnly),
"idle but polled ⇒ the poll is the reason"
);
t.tool_call_started("c1", "read_file", Some("sess-a"));
let s = t.snapshot();
assert!(
s.idle_since_ms.is_none(),
"a tool call in flight withholds idle on its own"
);
assert_eq!(
s.withhold_reason, None,
"the tool call is the cause, not the concurrent poll"
);
assert_eq!(s.withhold_since_ms, None);
t.tool_call_completed("c1", Some("sess-a"), ToolOutcome::Success);
assert_eq!(
t.snapshot().withhold_reason,
Some(IdleWithholdReason::PreviewStatusOnly),
"once the real work finishes, the poll is the reason again"
);
}
/// Durability is already bounded by its own cap, so it is the reason worth
/// reporting when both hold.
#[tokio::test]
async fn durability_outranks_preview_in_the_reported_reason() {
let t = ActivityTracker::new();
let tasks = tokio_util::task::TaskTracker::new();
t.set_producer_tasks(tasks.clone());
t.note_preview_status_activity();
assert_eq!(
t.snapshot().withhold_reason,
Some(IdleWithholdReason::PreviewStatusOnly),
"preview alone reports the preview reason"
);
// An in-flight producer engages the durability gate. Nothing else does
// — a background task is not durable work.
let gate = Arc::new(tokio::sync::Notify::new());
let gate2 = gate.clone();
let join = tasks.spawn(async move { gate2.notified().await });
let s = t.snapshot();
assert_eq!(s.artifact_producers_inflight, 1, "the gate is engaged");
assert_eq!(
s.withhold_reason,
Some(IdleWithholdReason::Durability),
"durability outranks a concurrent preview hold"
);
assert!(
s.withhold_since_ms.is_some(),
"a durability hold reports its own busy-since stamp"
);
gate.notify_one();
join.await.expect("producer task must not panic");
assert_eq!(
t.snapshot().withhold_reason,
Some(IdleWithholdReason::PreviewStatusOnly),
"once durability clears, the preview hold is reported again"
);
}
#[test]
fn configured_preview_window_overrides_default() {
let configured = ActivityTracker::new().with_preview_activity_window_ms(500);
configured
.last_preview_status_ms
.store(now_ms().saturating_sub(1_000), Ordering::Relaxed);
assert!(configured.snapshot().idle_since_ms.is_some());
let default = ActivityTracker::new();
default
.last_preview_status_ms
.store(now_ms().saturating_sub(1_000), Ordering::Relaxed);
assert!(default.snapshot().idle_since_ms.is_none());
}
#[test]
fn preview_activity_does_not_override_active_tool_call() {
let t = ActivityTracker::new();
t.tool_call_started("c1", "read_file", Some("sess-a"));
t.note_preview_routed_activity();
let s = t.snapshot();
assert!(s.idle_since_ms.is_none());
assert_eq!(
s.status,
ToolServerLifecycleStatus::Busy,
"an in-flight tool call stays Busy; preview activity only gates idle reporting"
);
}
#[test]
fn durability_idle_hold_from_raw_parses_and_falls_back() {
assert_eq!(
durability_idle_hold_from_raw(None),
DEFAULT_DURABILITY_IDLE_HOLD_MAX_MS
);
assert_eq!(durability_idle_hold_from_raw(Some("1234".into())), 1234);
assert_eq!(durability_idle_hold_from_raw(Some(" 99 ".into())), 99);
assert_eq!(durability_idle_hold_from_raw(Some("0".into())), 0);
assert_eq!(
durability_idle_hold_from_raw(Some("nonsense".into())),
DEFAULT_DURABILITY_IDLE_HOLD_MAX_MS
);
assert_eq!(
durability_idle_hold_from_raw(Some("-5".into())),
DEFAULT_DURABILITY_IDLE_HOLD_MAX_MS
);
}
#[test]
fn set_draining_stamps_drain_started_ms_once() {
let t = ActivityTracker::new();
assert_eq!(t.snapshot().drain_started_ms, None);
t.set_draining();
let first = t.snapshot().drain_started_ms.expect("drain_started_ms set");
assert!(first > 0);
// A second set_draining must not move the stamp.
t.set_draining();
assert_eq!(t.snapshot().drain_started_ms, Some(first));
}
#[test]
fn set_active_clears_drain_started_ms() {
let t = ActivityTracker::new();
t.set_draining();
assert!(t.snapshot().drain_started_ms.is_some());
// Resuming clears the stamp so it tracks "currently draining", not
// "ever drained" — the server idle gate's drain escape keys off it.
t.set_active();
assert_eq!(t.snapshot().drain_started_ms, None);
// A fresh drain after the resume re-stamps with a new value.
t.set_draining();
assert!(t.snapshot().drain_started_ms.is_some());
}
#[test]
fn turn_active_surfaces_in_snapshots() {
let t = ActivityTracker::new();
assert!(!t.snapshot().turn_active);
t.turn_started("sess-a", 1);
assert!(t.snapshot().turn_active, "aggregate: any session active");
assert!(t.snapshot_session("sess-a").turn_active);
assert!(
!t.snapshot_session("sess-b").turn_active,
"a different session is not turn-active"
);
t.turn_completed("sess-a", 1, 10);
assert!(!t.snapshot().turn_active);
}
/// The notify handle wakes the same waiter the status publisher blocks on.
#[tokio::test]
async fn notify_handle_shares_the_publisher_waiter() {
let t = Arc::new(ActivityTracker::new());
let notify = t.notify_handle();
let t2 = t.clone();
let waiter =
tokio::spawn(
async move { t2.wait_for_change(std::time::Duration::from_secs(5)).await },
);
tokio::task::yield_now().await;
notify.notify_waiters();
tokio::time::timeout(std::time::Duration::from_secs(2), waiter)
.await
.expect("publisher waiter must wake via the shared notify handle")
.expect("waiter task should not panic");
}
#[test]
fn multiple_concurrent_calls() {
let t = ActivityTracker::new();
t.tool_call_started("c1", "read_file", None);
t.tool_call_started("c2", "grep", None);
assert_eq!(t.snapshot().active_tool_calls, 2);
t.tool_call_completed("c1", None, ToolOutcome::Success);
assert_eq!(t.snapshot().active_tool_calls, 1);
t.tool_call_completed("c2", None, ToolOutcome::Success);
assert_eq!(t.snapshot().status, ToolServerLifecycleStatus::Ready);
}
#[test]
fn background_task_keeps_busy_after_calls_complete() {
let t = ActivityTracker::new();
t.tool_call_started("c1", "run_terminal_cmd", None);
t.background_task_started("bg1");
t.tool_call_completed("c1", None, ToolOutcome::Success);
assert_eq!(t.snapshot().status, ToolServerLifecycleStatus::Busy);
t.background_task_completed("bg1");
assert_eq!(t.snapshot().status, ToolServerLifecycleStatus::Ready);
}
#[test]
fn per_session_independent_tracking() {
let t = ActivityTracker::new();
t.tool_call_started("c1", "read_file", Some("sess-a"));
t.tool_call_started("c2", "grep", Some("sess-b"));
let sa = t.snapshot_session("sess-a");
assert_eq!(sa.active_tool_calls, 1);
assert_eq!(sa.session_id.as_ref().map(|s| s.as_str()), Some("sess-a"));
let sb = t.snapshot_session("sess-b");
assert_eq!(sb.active_tool_calls, 1);
assert_eq!(t.snapshot().active_tool_calls, 2);
}
#[test]
fn per_session_completion_independent() {
let t = ActivityTracker::new();
t.tool_call_started("c1", "read_file", Some("sess-a"));
t.tool_call_started("c2", "grep", Some("sess-b"));
t.tool_call_completed("c1", None, ToolOutcome::Success);
assert_eq!(
t.snapshot_session("sess-a").status,
ToolServerLifecycleStatus::Ready
);
assert_eq!(
t.snapshot_session("sess-b").status,
ToolServerLifecycleStatus::Busy
);
}
#[test]
fn unknown_session_returns_ready() {
let t = ActivityTracker::new();
assert_eq!(
t.snapshot_session("nonexistent").status,
ToolServerLifecycleStatus::Ready
);
}
#[test]
fn known_sessions_excludes_default() {
let t = ActivityTracker::new();
t.tool_call_started("c1", "x", None);
t.tool_call_started("c2", "y", Some("sess-a"));
assert_eq!(t.known_sessions(), vec!["sess-a"]);
}
#[test]
fn draining_propagates_to_session_snapshot() {
let t = ActivityTracker::new();
t.tool_call_started("c1", "x", Some("sess-a"));
t.set_draining();
assert_eq!(
t.snapshot_session("sess-a").status,
ToolServerLifecycleStatus::Draining
);
}
#[test]
fn prune_keeps_active_sessions() {
let t = ActivityTracker::new();
t.tool_call_started("c1", "x", Some("active"));
assert_eq!(t.known_sessions(), vec!["active"]);
}
#[test]
fn prune_keeps_recently_idle() {
let t = ActivityTracker::new();
t.tool_call_started("c1", "x", Some("recent"));
t.tool_call_completed("c1", None, ToolOutcome::Success);
assert_eq!(t.known_sessions(), vec!["recent"]);
}
#[test]
fn prune_removes_stale() {
let t = ActivityTracker::new();
t.tool_call_started("c1", "x", Some("stale"));
t.tool_call_completed("c1", None, ToolOutcome::Success);
if let Some(session) = t.sessions.get("stale") {
let old = now_ms() - SESSION_IDLE_PRUNE_MS - 1000;
session.idle_since_ms.store(old, Ordering::Relaxed);
}
assert!(t.known_sessions().is_empty());
assert!(!t.sessions.contains_key("stale"));
}
#[test]
fn small_prune_window_evicts_session_default_window_retains() {
// A session idle for ~50ms: pruned under a 10ms window, retained
// under the default 300s window. Proves the window is actually used.
let idle_ago = 50;
let small = ActivityTracker::with_prune_window(std::time::Duration::from_millis(10));
small.tool_call_started("c1", "x", Some("sess"));
small.tool_call_completed("c1", None, ToolOutcome::Success);
if let Some(session) = small.sessions.get("sess") {
session
.idle_since_ms
.store(now_ms() - idle_ago, Ordering::Relaxed);
}
assert!(
small.known_sessions().is_empty(),
"session idle past the small window must be pruned"
);
assert!(!small.sessions.contains_key("sess"));
let default = ActivityTracker::new();
default.tool_call_started("c1", "x", Some("sess"));
default.tool_call_completed("c1", None, ToolOutcome::Success);
if let Some(session) = default.sessions.get("sess") {
session
.idle_since_ms
.store(now_ms() - idle_ago, Ordering::Relaxed);
}
assert_eq!(
default.known_sessions(),
vec!["sess"],
"the same idle time must be retained under the default window"
);
}
#[test]
fn duplicate_start_is_noop() {
let t = ActivityTracker::new();
t.tool_call_started("c1", "read_file", Some("sess-a"));
t.tool_call_started("c1", "grep", Some("sess-b"));
// Second start with the same call_id must be ignored.
assert_eq!(t.snapshot().active_tool_calls, 1);
assert_eq!(t.snapshot_session("sess-a").active_tool_calls, 1);
// sess-b should not have been created by the duplicate.
assert_eq!(t.snapshot_session("sess-b").active_tool_calls, 0);
// Completion still works normally.
t.tool_call_completed("c1", None, ToolOutcome::Success);
assert_eq!(t.snapshot().active_tool_calls, 0);
}
#[test]
fn duplicate_completion_is_noop() {
let t = ActivityTracker::new();
t.tool_call_started("c1", "read_file", None);
assert_eq!(t.snapshot().active_tool_calls, 1);
t.tool_call_completed("c1", None, ToolOutcome::Success);
assert_eq!(t.snapshot().active_tool_calls, 0);
// Second completion of the same call_id must not underflow.
t.tool_call_completed("c1", None, ToolOutcome::Success);
assert_eq!(t.snapshot().active_tool_calls, 0);
}
#[test]
fn unknown_call_id_completion_is_noop() {
let t = ActivityTracker::new();
// Completing a call_id that was never started must not underflow.
t.tool_call_completed("never-started", None, ToolOutcome::Success);
assert_eq!(t.snapshot().active_tool_calls, 0);
assert_eq!(t.snapshot().status, ToolServerLifecycleStatus::Ready);
}
#[test]
fn duplicate_background_start_is_noop() {
let t = ActivityTracker::new();
t.background_task_started("bg1");
t.background_task_started("bg1");
assert_eq!(t.snapshot().background_tasks, 1);
t.background_task_completed("bg1");
assert_eq!(t.snapshot().background_tasks, 0);
}
#[test]
fn duplicate_background_completion_is_noop() {
let t = ActivityTracker::new();
t.background_task_started("bg1");
assert_eq!(t.snapshot().background_tasks, 1);
t.background_task_completed("bg1");
assert_eq!(t.snapshot().background_tasks, 0);
// Second completion must not underflow.
t.background_task_completed("bg1");
assert_eq!(t.snapshot().background_tasks, 0);
}
#[test]
fn unknown_background_task_completion_is_noop() {
let t = ActivityTracker::new();
t.background_task_completed("never-started");
assert_eq!(t.snapshot().background_tasks, 0);
}
#[test]
fn prune_cleans_call_to_session_mapping() {
let t = ActivityTracker::new();
t.tool_call_started("c1", "x", Some("stale"));
t.tool_call_completed("c1", None, ToolOutcome::Success);
// Simulate stale idle time.
if let Some(session) = t.sessions.get("stale") {
let old = now_ms() - SESSION_IDLE_PRUNE_MS - 1000;
session.idle_since_ms.store(old, Ordering::Relaxed);
}
// Prune should remove the session and its call_to_session entries.
t.known_sessions();
assert!(!t.sessions.contains_key("stale"));
// Verify call_to_session was cleaned (no dangling entries).
assert!(!t.call_to_session.contains_key("c1"));
}
#[test]
fn per_session_completion_after_prune_is_safe() {
let t = ActivityTracker::new();
t.tool_call_started("c1", "x", Some("sess"));
// Force-prune the session while the call is still "active"
// (simulates the theoretical race).
t.sessions.remove("sess");
// Completing should not panic or underflow — the session
// lookup returns None, so only the global counter decrements.
t.tool_call_completed("c1", None, ToolOutcome::Success);
assert_eq!(t.snapshot().active_tool_calls, 0);
}
#[test]
fn turn_started_sets_current_turn_and_active() {
let t = ActivityTracker::new();
t.turn_started("sess-a", 1);
let session = t.sessions.get("sess-a").expect("session should exist");
assert_eq!(session.current_turn.load(Ordering::Acquire), 1);
assert!(session.turn_active.load(Ordering::Acquire));
}
#[test]
fn turn_completed_clears_active_when_turn_matches() {
let t = ActivityTracker::new();
t.turn_started("sess-a", 1);
t.turn_completed("sess-a", 1, 500);
let session = t.sessions.get("sess-a").expect("session should exist");
assert!(!session.turn_active.load(Ordering::Acquire));
}
#[test]
fn turn_completed_stale_turn_does_not_clear_active() {
let t = ActivityTracker::new();
t.turn_started("sess-a", 1);
t.turn_started("sess-a", 2);
// Completing the stale turn 1 must not clear turn_active for turn 2.
t.turn_completed("sess-a", 1, 500);
let session = t.sessions.get("sess-a").expect("session should exist");
assert!(
session.turn_active.load(Ordering::Acquire),
"turn_active should still be true for current turn 2"
);
assert_eq!(session.current_turn.load(Ordering::Acquire), 2);
}
#[test]
fn turn_completed_unknown_session_is_noop() {
let t = ActivityTracker::new();
// Must not panic or create a session entry.
t.turn_completed("nonexistent", 1, 100);
assert!(!t.sessions.contains_key("nonexistent"));
}
#[test]
fn session_ended_releases_an_idle_session() {
let t = ActivityTracker::new();
t.turn_started("sess-a", 3);
assert!(t.is_turn_active("sess-a"));
t.session_ended("sess-a");
assert!(
!t.sessions.contains_key("sess-a"),
"an ended session must not stay resident"
);
}
#[test]
fn session_ended_unknown_session_is_noop() {
let t = ActivityTracker::new();
// Must not panic or create a session entry.
t.session_ended("nonexistent");
assert!(!t.sessions.contains_key("nonexistent"));
}
#[test]
fn session_ended_without_active_turn_is_safe() {
let t = ActivityTracker::new();
// Create a session via a tool call cycle (turn never started).
t.tool_call_started("c1", "read_file", Some("sess-a"));
t.tool_call_completed("c1", None, ToolOutcome::Success);
t.session_ended("sess-a");
assert!(!t.sessions.contains_key("sess-a"));
}
#[tokio::test]
async fn session_ended_notifies_waiters() {
let t = std::sync::Arc::new(ActivityTracker::new());
t.turn_started("sess-a", 1);
let t2 = t.clone();
let waiter = tokio::spawn(async move {
t2.wait_for_change(std::time::Duration::from_secs(5)).await;
});
// Give the waiter a moment to register.
tokio::task::yield_now().await;
t.session_ended("sess-a");
// The waiter should complete promptly (not timeout).
tokio::time::timeout(std::time::Duration::from_secs(2), waiter)
.await
.expect("waiter should complete within timeout")
.expect("waiter task should not panic");
}
#[test]
fn session_ended_with_inflight_tool_calls() {
let t = ActivityTracker::new();
t.turn_started("sess-a", 1);
t.tool_call_started("c1", "read_file", Some("sess-a"));
assert!(t.is_turn_active("sess-a"));
assert_eq!(t.snapshot_session("sess-a").active_tool_calls, 1);
t.session_ended("sess-a");
assert!(!t.is_turn_active("sess-a"));
assert!(
t.sessions.contains_key("sess-a"),
"a session with a call in flight keeps its counters"
);
assert_eq!(
t.snapshot_session("sess-a").active_tool_calls,
1,
"session_ended must not clear active tool calls"
);
assert_eq!(
t.snapshot_session("sess-a").status,
ToolServerLifecycleStatus::Busy,
"session should still be busy due to in-flight tool call"
);
}
#[test]
fn cancel_all_session_calls_completes_all_for_session() {
let t = ActivityTracker::new();
t.tool_call_started("c1", "read_file", Some("sess-a"));
t.tool_call_started("c2", "grep", Some("sess-a"));
t.tool_call_started("c3", "write", Some("sess-b"));
assert_eq!(t.snapshot().active_tool_calls, 3);
let cancelled = t.cancel_all_session_calls("sess-a");
assert_eq!(cancelled, 2, "should cancel 2 calls for sess-a");
assert_eq!(
t.snapshot().active_tool_calls,
1,
"only sess-b call remains"
);
assert_eq!(t.snapshot_session("sess-a").active_tool_calls, 0);
assert_eq!(t.snapshot_session("sess-b").active_tool_calls, 1);
}
#[test]
fn cancel_all_session_calls_noop_for_empty_session() {
let t = ActivityTracker::new();
let cancelled = t.cancel_all_session_calls("nonexistent");
assert_eq!(cancelled, 0);
assert_eq!(t.snapshot().active_tool_calls, 0);
}
#[test]
fn cancel_all_session_calls_idempotent() {
let t = ActivityTracker::new();
t.tool_call_started("c1", "read_file", Some("sess-a"));
assert_eq!(t.cancel_all_session_calls("sess-a"), 1);
assert_eq!(
t.cancel_all_session_calls("sess-a"),
0,
"second cancel_all should find no calls"
);
assert_eq!(t.snapshot().active_tool_calls, 0);
}
#[test]
fn double_cancel_via_tool_call_completed_is_idempotent() {
let t = ActivityTracker::new();
t.tool_call_started("c1", "read_file", Some("sess-a"));
assert_eq!(t.snapshot().active_tool_calls, 1);
t.tool_call_completed("c1", None, ToolOutcome::Success);
assert_eq!(t.snapshot().active_tool_calls, 0);
// Second completion of the same call_id must not underflow.
t.tool_call_completed("c1", None, ToolOutcome::Success);
assert_eq!(
t.snapshot().active_tool_calls,
0,
"double cancel must not underflow active_tool_calls"
);
assert_eq!(
t.snapshot_session("sess-a").active_tool_calls,
0,
"double cancel must not underflow session active_tool_calls"
);
}
// ── events.jsonl emission ─────────────────────────────────
/// Build a tracker whose event sink points at a fresh tempdir, with the
/// `events.jsonl` writer for `session` pre-opened (as turn-start would do).
fn tracker_with_writer(session: &str) -> (ActivityTracker, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let writers: Arc<DashMap<String, EventWriter>> = Arc::new(DashMap::new());
writers.insert(session.to_owned(), EventWriter::open(dir.path()));
let t = ActivityTracker::new();
t.set_event_writers(writers);
(t, dir)
}
fn read_events(dir: &tempfile::TempDir) -> Vec<serde_json::Value> {
let text = std::fs::read_to_string(dir.path().join("events.jsonl")).unwrap();
text.trim()
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect()
}
#[test]
fn emits_tool_started_and_completed_with_real_content() {
let (t, dir) = tracker_with_writer("sess-a");
t.tool_call_started("c1", "read_file", Some("sess-a"));
t.tool_call_completed("c1", Some("sess-a"), ToolOutcome::Success);
let events = read_events(&dir);
assert_eq!(events.len(), 2, "expected ToolStarted + ToolCompleted");
assert_eq!(events[0]["type"], "tool_started");
assert_eq!(events[0]["tool_name"], "read_file");
assert_eq!(events[1]["type"], "tool_completed");
assert_eq!(events[1]["tool_name"], "read_file");
assert_eq!(events[1]["outcome"], "success");
assert_eq!(events[1]["source"], "workspace");
assert!(
events[1]["duration_ms"].as_u64().is_some(),
"ToolCompleted must carry a duration_ms"
);
}
#[test]
fn tool_completed_outcome_is_truthful_error() {
let (t, dir) = tracker_with_writer("sess-a");
t.tool_call_started("c1", "run_terminal_command", Some("sess-a"));
t.tool_call_completed("c1", Some("sess-a"), ToolOutcome::Error);
let events = read_events(&dir);
assert_eq!(events[1]["type"], "tool_completed");
assert_eq!(events[1]["outcome"], "error");
}
#[test]
fn cancel_all_marks_tool_completed_cancelled() {
let (t, dir) = tracker_with_writer("sess-a");
t.tool_call_started("c1", "run_terminal_command", Some("sess-a"));
assert_eq!(t.cancel_all_session_calls("sess-a"), 1);
let events = read_events(&dir);
let last = events.last().unwrap();
assert_eq!(last["type"], "tool_completed");
assert_eq!(last["outcome"], "cancelled");
}
#[test]
fn no_tool_events_without_event_sink() {
// Behaviour preservation: the default tracker (no event sink — the state
// for all the legacy tests above) must never touch the filesystem.
let t = ActivityTracker::new();
t.tool_call_started("c1", "read_file", Some("sess-a"));
t.tool_call_completed("c1", Some("sess-a"), ToolOutcome::Success);
// Counters still behave, and nothing was recorded.
assert_eq!(t.snapshot().active_tool_calls, 0);
assert!(t.call_started_ms.is_empty());
}
#[test]
fn no_event_when_session_writer_not_open() {
// Sink configured but the session's writer was never opened (no
// turn-start): no event is emitted and no start time leaks.
let dir = tempfile::tempdir().unwrap();
let writers: Arc<DashMap<String, EventWriter>> = Arc::new(DashMap::new());
writers.insert("other".to_owned(), EventWriter::open(dir.path()));
let t = ActivityTracker::new();
t.set_event_writers(writers);
t.tool_call_started("c1", "read_file", Some("unopened-sess"));
t.tool_call_completed("c1", Some("unopened-sess"), ToolOutcome::Success);
// The "other" session's events.jsonl must stay empty.
let text = std::fs::read_to_string(dir.path().join("events.jsonl")).unwrap();
assert!(text.trim().is_empty(), "no event should be written");
// No start time was ever recorded: the insert is gated on this session's
// writer being open, and "unopened-sess" has none — so the map is empty
// (this is exactly the production flag-off shape: sink wired, map empty).
assert!(t.call_started_ms.is_empty());
}
}