diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 4357ce49ff..5dc9fffa4e 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -50,9 +50,16 @@ jobs: # touches is distributed, so the AGPL/dual-license reasoning in the # header does not apply to it. # - # The @img/sharp-* tuples that used to sit here arrived through - # arenabench/web's lockfile; that folder left with the ejection to - # its own repository (#2380), so the exemption left with it. + # The @img/sharp-* tuples first arrived through arenabench/web's + # lockfile and left with its ejection (#2380). They are back through + # `website/`, the docs site: `next` lists sharp as an optional + # dependency for every platform, and fourteen of its tuples ship + # LGPL-3.0 libvips binaries. The site is `"private": true`, imports + # no `next/image` (so sharp is never loaded, even at build), and is + # deployed to Vercel — nothing here reaches the AGPL workspace or the + # commercial binary. The action has no purl globs, so each tuple is + # named; a new libvips tuple reds this gate again and is added the + # same way. #2532 tracks the recurrence. # # Must stay ABOVE `allow-licenses`: check-license-allowlist-parity.sh # reads that key's folded scalar by consuming every following indented @@ -66,7 +73,21 @@ jobs: # nothing — so a NEW libvips tuple reds this gate again and has to be # added by hand. #2532 tracks removing the recurrence instead. allow-dependencies-licenses: >- - pkg:githubactions/Swatinem/rust-cache + pkg:githubactions/Swatinem/rust-cache, + pkg:npm/%40img/sharp-libvips-darwin-arm64, + pkg:npm/%40img/sharp-libvips-darwin-x64, + pkg:npm/%40img/sharp-libvips-linux-arm, + pkg:npm/%40img/sharp-libvips-linux-arm64, + pkg:npm/%40img/sharp-libvips-linux-ppc64, + pkg:npm/%40img/sharp-libvips-linux-riscv64, + pkg:npm/%40img/sharp-libvips-linux-s390x, + pkg:npm/%40img/sharp-libvips-linux-x64, + pkg:npm/%40img/sharp-libvips-linuxmusl-arm64, + pkg:npm/%40img/sharp-libvips-linuxmusl-x64, + pkg:npm/%40img/sharp-wasm32, + pkg:npm/%40img/sharp-win32-arm64, + pkg:npm/%40img/sharp-win32-ia32, + pkg:npm/%40img/sharp-win32-x64 allow-licenses: >- AGPL-3.0-only, Apache-2.0, Apache-2.0 WITH LLVM-exception, MIT, BSD-2-Clause, BSD-3-Clause, ISC, Zlib, BSL-1.0, MPL-2.0, Unlicense, diff --git a/AGENTS.md b/AGENTS.md index 18e2b566fa..b519cb066a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1341,8 +1341,9 @@ empty and is meant to stay empty. ## Workspace layout — where a change goes -Twenty-nine crates, every one under the `crates/` directory (`crates/stella-core`, -`crates/stella-cli`, …; the two bench members stay under `bench/`). The +Every crate lives under the `crates/` directory (`crates/stella-core`, +`crates/stella-cli`, …; the two bench members stay under `bench/`); the +`members` list in the root `Cargo.toml` is the roll. The one-sentence rule of thumb below routes you to the right one; **each crate's own `README.md`** (linked from the table) then covers its boundary, layout, invariants, gotchas, and extension recipe in depth. Read that before changing @@ -1365,6 +1366,7 @@ the files you must plan around (see below). | Change the wrapper socket's **trait** — `TurnWrapper`, `admissible`, `judge`/`again`, the in-process/subprocess transports | [`stella-runtime`](crates/stella-runtime/README.md) | `src/wrapper/` (#3380, landed #3479, `doc:wrapper-socket`). Lives one layer above `stella-core` because `before_turn`/`after_turn` do I/O, which invariant 2 bans in the engine; consumes `stella-plugin`'s wire types rather than redefining them. It is reached four ways now: a `--pipeline` run, a goal round, a fleet attempt, and an auto-resolved plugin — see the crate's own README. | | Change how a plugin is **installed, listed, removed, or trusted** — `.stella/plugins/` and `~/.stella/plugins/` resolution, install consent, the project-tier trust gate | [`stella-cli`](crates/stella-cli/README.md) | `src/plugin_cmd.rs` + `src/plugin_cmd/{roster,process}.rs` — renders `stella_plugin::consent_text` before anything executes, gates a cloned repository's plugins on `project_code_execution_trusted()` (#3509), and is the one place `LoopGrant::permits_hook`/`permits_point` get consulted against an installed manifest. | | Decide whether a human is present to see/answer a mid-run prompt | [`stella-tty`](crates/stella-tty/README.md) | **A leaf with NO dependencies at all** (#3036) — one pure `human_can_answer(interactive_output, stdin_is_terminal, prompt_is_visible)`, which is what lets `stella-cli`'s approval prompts and `stella-model`'s credential prompt share one derivation without `stella-model` depending on `stella-cli` (invariant 1). | +| Read the real clock, wait on the real timer, or stand in for either under test, behind `stella-core`'s `Sleeper` and `Clock` ports | [`stella-time`](crates/stella-time/README.md) | **Near-leaf: `stella-core` is its only workspace dependency.** `TokioSleeper` (the engine's real sleeper and `now`), `WallClock` (Unix epoch, for a stamp another process reads) and `MonotonicClock` (one origin per process, for a span compared as a number), plus `test_util`'s `PausedSleeper` and `NoopSleeper` behind the `test-util` feature. One home, because `stella-serve` may not link `stella-cli` or `stella-runtime` and `stella-core` may not carry a timer (ADR 0042); `stella-core` tests against it through a dev-dependency cycle, the tokio / tokio-test shape. | | Emit a diagnostic — a record explaining *why* the program did something | [`stella-diag`](crates/stella-diag/README.md) | **A leaf: `serde` only, so anything may depend on it.** Field values cannot hold a `String`, a `Path`, or model output — that is a compile error, not a review question. Design: [`docs/spec/diagnostics.md`](docs/spec/diagnostics.md). | | Compute a line-oriented unified diff (`@@` hunks, git's exact shape) | [`stella-diff`](crates/stella-diff/README.md) | **A leaf with NO dependencies at all** (#1511) — pure functions over borrowed strings, which is what lets [`stella-observatory`](crates/stella-observatory/README.md) and [`stella-cli`](crates/stella-cli/README.md) share one differ without costing the observatory its isolation. | | Strip ANSI escape sequences from tool output | [`stella-ansi`](crates/stella-ansi/README.md) | **A leaf with NO dependencies at all** — one pure function over a borrowed `&str`, extracted from `stella-tui` so [`stella-observatory`](crates/stella-observatory/README.md) could strip a colourised tool's output before it reaches the journal route's `
` without linking `ratatui`. `stella-tui`'s `ansi` module re-exports it and keeps only the `ratatui`-shaped emission half. |
@@ -1492,7 +1494,7 @@ a plan needs and the part that rarely changes:
| `stella-store` | `src/tests.rs`, `src/lib.rs`, `src/usage.rs` |
| `stella-tui` | `src/deck_ui.rs` |
-The other twenty-four crates carry no god files — keep it that way. Each crate's
+Every crate not named there carries no god files — keep it that way. Each crate's
README repeats its own list under "God files — do not add lines", so the
constraint is in view wherever planning starts.
diff --git a/Cargo.lock b/Cargo.lock
index 87c96cf4cf..5739003343 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3240,6 +3240,7 @@ dependencies = [
"stella-records",
"stella-runtime",
"stella-store",
+ "stella-time",
"stella-tools",
"stella-transcript",
"stella-tty",
@@ -3283,6 +3284,7 @@ dependencies = [
"serde_json_canonicalizer",
"sha2 0.11.0",
"stella-protocol",
+ "stella-time",
"thiserror 2.0.20",
"tokio",
]
@@ -3328,6 +3330,7 @@ dependencies = [
"serde_json",
"stella-core",
"stella-protocol",
+ "stella-time",
"tokio",
]
@@ -3345,6 +3348,7 @@ dependencies = [
"stella-core",
"stella-protocol",
"stella-store",
+ "stella-time",
"stella-tools",
"tempfile",
"thiserror 2.0.20",
@@ -3529,6 +3533,7 @@ dependencies = [
"stella-plugin",
"stella-protocol",
"stella-store",
+ "stella-time",
"stella-tools",
"tempfile",
"thiserror 2.0.20",
@@ -3548,6 +3553,7 @@ dependencies = [
"stella-core",
"stella-engine",
"stella-protocol",
+ "stella-time",
"tempfile",
"thiserror 2.0.20",
"tokio",
@@ -3571,6 +3577,16 @@ dependencies = [
"thiserror 2.0.20",
]
+[[package]]
+name = "stella-time"
+version = "0.9.416"
+dependencies = [
+ "async-trait",
+ "rand 0.10.2",
+ "stella-core",
+ "tokio",
+]
+
[[package]]
name = "stella-tool-facts"
version = "0.9.416"
diff --git a/Cargo.toml b/Cargo.toml
index 6d2c47b062..5638e5f763 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -23,6 +23,7 @@ members = [
"crates/stella-mcp",
"crates/stella-engine",
"crates/stella-runtime",
+ "crates/stella-time",
"crates/stella-fleet",
"crates/stella-observatory",
"crates/stella-serve",
diff --git a/crates/stella-cli/Cargo.toml b/crates/stella-cli/Cargo.toml
index e40d57942f..88e22578e7 100644
--- a/crates/stella-cli/Cargo.toml
+++ b/crates/stella-cli/Cargo.toml
@@ -44,6 +44,8 @@ contextgraph-host.workspace = true
# see the exact-version note in the root manifest.
contextgraph-trace.workspace = true
stella-core = { path = "../stella-core" }
+# The real sleeper and clocks behind stella-core's time ports.
+stella-time = { path = "../stella-time" }
# The context-record plane — the typed taxonomy, the ingestion boundary, and
# the registry the prompt's rule block renders from. It left `stella-core`
# because the engine reached it through one hash call and nothing else;
diff --git a/crates/stella-cli/src/agent.rs b/crates/stella-cli/src/agent.rs
index e06ea73edb..8bb82fa4b8 100644
--- a/crates/stella-cli/src/agent.rs
+++ b/crates/stella-cli/src/agent.rs
@@ -37,7 +37,7 @@ use crate::memory::{
turn_warrants_reflection,
};
use crate::plain::{self, accent};
-use crate::runtime::{SystemClock, TokioSleeper};
+use crate::runtime::{MonotonicClock, TokioSleeper};
use crate::{OutputFormat, config::Config};
use stella_context::EpisodeOutcome;
diff --git a/crates/stella-cli/src/agent/engine.rs b/crates/stella-cli/src/agent/engine.rs
index a3b31bc023..7376bde9ea 100644
--- a/crates/stella-cli/src/agent/engine.rs
+++ b/crates/stella-cli/src/agent/engine.rs
@@ -754,7 +754,7 @@ pub(crate) fn session_router(cfg: &Config, worker_ref: &ModelRef) -> Router {
Router::new(
wiring.pins,
wiring.profiles,
- CircuitBreaker::new(Box::new(SystemClock::new())),
+ CircuitBreaker::new(Box::new(MonotonicClock)),
)
}
diff --git a/crates/stella-cli/src/agent/persistence.rs b/crates/stella-cli/src/agent/persistence.rs
index 3bd518888c..8946e79255 100644
--- a/crates/stella-cli/src/agent/persistence.rs
+++ b/crates/stella-cli/src/agent/persistence.rs
@@ -223,7 +223,7 @@ pub(crate) fn spawn_renderer(
crate::diag_boot::dx(),
Some(crate::diag_boot::workspace_root()),
);
- // The stream-json sink's clock. Wall-anchored, not `SystemClock`: a
+ // The stream-json sink's clock. Wall-anchored, not `MonotonicClock`: a
// journal stamp has to stay comparable across processes and runs, and a
// per-construction origin is exactly the wrong shape for that (#2111).
let clock = WallClock;
diff --git a/crates/stella-cli/src/agent/tests/engine_wiring.rs b/crates/stella-cli/src/agent/tests/engine_wiring.rs
index 72e46fd282..027fd9d299 100644
--- a/crates/stella-cli/src/agent/tests/engine_wiring.rs
+++ b/crates/stella-cli/src/agent/tests/engine_wiring.rs
@@ -209,7 +209,7 @@ fn the_settings_model_is_inert_without_the_fix_but_routes_with_it() {
// The full round trip through the actual router — `SessionFallback` is the
// one live `Router::resolve` caller, and `Role::Worker` is the one role it
// asks for, which is why that pin is the one #3908 kept.
- let breaker = CircuitBreaker::new(Box::new(SystemClock::new()));
+ let breaker = CircuitBreaker::new(Box::new(MonotonicClock));
let router = Router::new(wiring.pins.clone(), wiring.profiles.clone(), breaker);
assert_eq!(
router.resolve(Role::Worker).unwrap().model_ref,
@@ -255,7 +255,7 @@ fn an_explicit_model_flag_outranks_the_settings_model() {
// The round trip through the real router is the claim that matters: the
// pin table being empty is only useful if resolution lands on the flag.
- let breaker = CircuitBreaker::new(Box::new(SystemClock::new()));
+ let breaker = CircuitBreaker::new(Box::new(MonotonicClock));
let router = Router::new(wiring.pins.clone(), wiring.profiles.clone(), breaker);
assert_eq!(
router.resolve(Role::Worker).unwrap().model_ref,
diff --git a/crates/stella-cli/src/fleet_cmd.rs b/crates/stella-cli/src/fleet_cmd.rs
index e12461304b..b1171e63f0 100644
--- a/crates/stella-cli/src/fleet_cmd.rs
+++ b/crates/stella-cli/src/fleet_cmd.rs
@@ -76,7 +76,7 @@ use tokio::sync::{mpsc, oneshot, watch};
use crate::config::Config;
use crate::lane_capabilities;
-use crate::runtime::{SystemClock, TokioSleeper, WallClock};
+use crate::runtime::{MonotonicClock, TokioSleeper, WallClock};
// The trait is in scope for `AttemptPointStream::publish` below — a fleet
// attempt publishes its own channel across the dispatch's points (#4730).
use crate::wrapper_plugin::PointStream;
@@ -235,10 +235,10 @@ pub async fn run_fleet(
WorktreeManager::new(SystemGitCli, root.clone()).with_run_scope(&run_id),
ledger,
agent::build_budget_guard(budget_limit),
- // Wall-anchored, NOT `SystemClock`: every stamp this clock feeds is
+ // Wall-anchored, NOT `MonotonicClock`: every stamp this clock feeds is
// a durable ledger row that must stay comparable across runs — the
// warmth projection (#1222) reads a PRIOR run's `finished_at_ms`.
- // `SystemClock`'s per-process origin made every run start near zero.
+ // A per-process origin would make every run start near zero.
WallClock,
{
let mut config =
@@ -365,8 +365,7 @@ pub async fn run_fleet(
);
} else {
let config = WatchConfig::default();
- let monitor =
- Monitor::new(SystemGhCli, Box::new(SystemClock::new())).with_config(config);
+ let monitor = Monitor::new(SystemGhCli, Box::new(MonotonicClock)).with_config(config);
println!(
" watching CI for {} fleet branch(es) — polling every {}s, wall cap {}m\n",
targets.len(),
diff --git a/crates/stella-cli/src/fleet_cmd/tests.rs b/crates/stella-cli/src/fleet_cmd/tests.rs
index 8522af93f6..0df580c00a 100644
--- a/crates/stella-cli/src/fleet_cmd/tests.rs
+++ b/crates/stella-cli/src/fleet_cmd/tests.rs
@@ -427,7 +427,7 @@ async fn watch_branch_reports_green_ci_and_open_pr() {
Some(r#"{"state":"OPEN","isDraft":false}"#),
);
let calls = gh.calls.clone();
- let monitor = Monitor::new(gh, Box::new(SystemClock::new()));
+ let monitor = Monitor::new(gh, Box::new(MonotonicClock));
let watched = branch_watch::watch_branch(&monitor, "t1", "fleet/t1-abc").await;
assert!(watched.is_green());
@@ -455,7 +455,7 @@ async fn watch_branch_red_ci_and_a_missing_pr_are_states_not_errors() {
r#"[{"status":"completed","conclusion":"failure","name":"ci"}]"#,
None,
);
- let monitor = Monitor::new(gh, Box::new(SystemClock::new()));
+ let monitor = Monitor::new(gh, Box::new(MonotonicClock));
let watched = branch_watch::watch_branch(&monitor, "t1", "fleet/t1-abc").await;
assert!(!watched.is_green());
@@ -475,7 +475,7 @@ async fn watch_branch_treats_a_ci_timeout_as_red() {
// first decision (elapsed >= grace with a 0ms grace) — the watch ends
// as NoRunsStarted without sleeping, and the branch is red.
let gh = RoutedGh::new("[]", None);
- let monitor = Monitor::new(gh, Box::new(SystemClock::new())).with_config(WatchConfig {
+ let monitor = Monitor::new(gh, Box::new(MonotonicClock)).with_config(WatchConfig {
poll_interval_ms: 1,
max_total_ms: 60_000,
stall_timeout_ms: 60_000,
diff --git a/crates/stella-cli/src/runtime.rs b/crates/stella-cli/src/runtime.rs
index ac0f85bb81..9b0d2136a3 100644
--- a/crates/stella-cli/src/runtime.rs
+++ b/crates/stella-cli/src/runtime.rs
@@ -1,82 +1,9 @@
-//! Production implementations of `stella-core`'s time ports. The engine
-//! exports only the [`Clock`] and [`Sleeper`] traits (ports, not
-//! specific implementations), so the binary owns the concrete
-//! wall-clock/tokio impls and wires them at construction. This is what
-//! keeps `stella-core` free of production `tokio::time` calls.
+//! The binary's side of `stella-core`'s time ports. The engine exports only
+//! the `Sleeper` and `Clock` traits; the real sources behind them are
+//! `stella-time`'s, re-exported here under the names the rest of this crate
+//! wires at construction. One-shot runs also arm their task deadline here.
-use async_trait::async_trait;
-use rand::RngExt;
-use stella_core::ports::Clock;
-use stella_core::retry::Sleeper;
-
-/// The production clock: monotonic milliseconds since construction.
-pub struct SystemClock {
- origin: std::time::Instant,
-}
-
-impl SystemClock {
- pub fn new() -> Self {
- Self {
- origin: std::time::Instant::now(),
- }
- }
-}
-
-impl Default for SystemClock {
- fn default() -> Self {
- Self::new()
- }
-}
-
-impl Clock for SystemClock {
- fn now_ms(&self) -> u64 {
- self.origin.elapsed().as_millis() as u64
- }
-}
-
-/// A [`Clock`] anchored to the **wall** (Unix-epoch milliseconds), for
-/// stamps that must stay comparable across processes and runs — the fleet
-/// ledger's rows, where issue #1222's cache-warmth projection compares a
-/// prior run's `finished_at_ms` against "now". [`SystemClock`]'s
-/// per-construction origin is the right shape for in-process elapsed
-/// arithmetic (retry pacing, watch caps) and exactly the wrong one for a
-/// durable timestamp: two runs' stamps share no origin, so their difference
-/// means nothing. Not strictly monotonic (NTP can step it), which is why it
-/// does not replace [`SystemClock`] everywhere; a pre-epoch system clock
-/// reads as `0` rather than panicking.
-#[derive(Debug, Default, Clone, Copy)]
-pub struct WallClock;
-
-impl Clock for WallClock {
- fn now_ms(&self) -> u64 {
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .map(|d| d.as_millis() as u64)
- .unwrap_or(0)
- }
-}
-
-/// The production [`Sleeper`]: `tokio::time::sleep` for the wait, and the
-/// OS entropy pool for the jitter that spreads concurrent retriers across
-/// the backoff window. Both live here so `stella-core` links neither a
-/// timer nor an entropy source.
-#[derive(Debug, Default, Clone, Copy)]
-pub struct TokioSleeper;
-
-#[async_trait]
-impl Sleeper for TokioSleeper {
- async fn sleep(&self, duration_ms: u64) {
- tokio::time::sleep(std::time::Duration::from_millis(duration_ms)).await;
- }
-
- fn now(&self) -> std::time::Instant {
- std::time::Instant::now()
- }
-
- fn jitter(&self, upper: u64) -> u64 {
- rand::rng().random_range(0..=upper)
- }
-}
+pub use stella_time::{MonotonicClock, TokioSleeper, WallClock};
/// The budget guard for a one-shot invocation, with its wall-clock task
/// deadline armed (#1503).
@@ -123,28 +50,6 @@ pub(crate) fn one_shot_budget_guard(
mod tests {
use super::*;
- #[test]
- fn system_clock_starts_near_zero_and_advances_monotonically() {
- let clock = SystemClock::new();
- let first = clock.now_ms();
- std::thread::sleep(std::time::Duration::from_millis(5));
- let second = clock.now_ms();
- assert!(second >= first, "clock must never go backwards");
- assert!(
- second - first >= 4,
- "clock must actually advance with wall time"
- );
- }
-
- #[test]
- fn default_constructs_a_fresh_clock() {
- let clock = SystemClock::default();
- assert!(
- clock.now_ms() < 1000,
- "a freshly constructed clock starts near zero"
- );
- }
-
/// The #1503 witness: #1481's deadline mechanism was proven by a driver
/// test but nothing in the shipping binary armed it — this is the arming,
/// so a one-shot task stops itself at a safe boundary instead of running
@@ -206,16 +111,4 @@ mod tests {
harness's kill with its work discarded (#3868)"
);
}
-
- #[test]
- fn wall_clock_reads_epoch_milliseconds_not_a_process_origin() {
- let now = WallClock.now_ms();
- // Any plausible present is decades past the epoch — a process-origin
- // clock would read near zero here, which is exactly the bug this
- // clock exists to avoid for durable stamps.
- assert!(
- now > 1_600_000_000_000,
- "wall clock must be epoch-anchored, got {now}"
- );
- }
}
diff --git a/crates/stella-core/Cargo.toml b/crates/stella-core/Cargo.toml
index a11be42137..f47d338ff9 100644
--- a/crates/stella-core/Cargo.toml
+++ b/crates/stella-core/Cargo.toml
@@ -20,6 +20,10 @@ futures-util = { workspace = true }
sha2 = { workspace = true }
[dev-dependencies]
+# The engine's tests take the shared sleeper doubles from the crate that
+# holds the real sleeper. A dev-dependency cycle is allowed, and this is the
+# tokio / tokio-test shape.
+stella-time = { path = "../stella-time", features = ["test-util"] }
tokio = { workspace = true, features = ["test-util"] }
# Seeds the retry tests' jitter double. Shipping code draws nothing: the
# entropy behind backoff jitter is a port (`retry::Sleeper::jitter`), and
diff --git a/crates/stella-core/README.md b/crates/stella-core/README.md
index be4c5ee855..778ef310c5 100644
--- a/crates/stella-core/README.md
+++ b/crates/stella-core/README.md
@@ -161,7 +161,7 @@ lib.rs), never as a planning assumption.
| [`src/estimator.rs`](src/estimator.rs) | Conservative token estimate plus `Calibration`/`CalibrationMap`, the per-model drift correction fed by reported usage. |
| [`src/loop_detect.rs`](src/loop_detect.rs) + [`src/loop_detect/`](src/loop_detect) | `detect_loop()` — five rungs over `CallRecord`s: exact repeats, short cycles, stagnation, interleaved repeats, and a wrapped monotonic sweep. |
| [`src/shell_text.rs`](src/shell_text.rs) | Reading a shell command as *text*: the quote-aware `shell_words` splitter, `is_operator_word`, and `bare_sleep_seconds` — the stall classifier the `bash` advisory and the engine's stall rung ([`src/driver/loop_escalation.rs`](src/driver/loop_escalation.rs)) share, so one operator list serves both (#2022). |
-| [`src/retry.rs`](src/retry.rs) | `RetryPolicy`, backoff computation, `retry_with_backoff*`, and the `Sleeper` port. |
+| [`src/retry.rs`](src/retry.rs) | `RetryPolicy`, backoff computation, `retry_with_backoff*`, `bounded`, and the `Sleeper` port; its real impl and its two test doubles are `stella-time`'s. |
| [`src/starvation.rs`](src/starvation.rs) | Reasoning-starvation arithmetic: output-contract headroom, the empty-`length` signature, and the retry cap. Written to serve two chokepoints from one copy, because one copy is what makes a fix reach both (#2128, #2174): `stella-cli`'s standalone-call chokepoint, and the staged pipeline's management chokepoint until that crate was deleted (#3865). |
| [`src/speculation.rs`](src/speculation.rs) | Early execution of read-only tool calls announced mid-stream (`pub(crate)`). |
| [`src/receipts.rs`](src/receipts.rs) | `ReceiptLedger` — `BlockRegistered` + `StepManifest` context receipts, content-free (digests, never payloads). |
@@ -293,9 +293,12 @@ system message and the latest user message are never touched.
with: a nested turn that dropped `gate`, `steering` and `hooks` at once.
Do not hand-roll a child engine. Call
[`src/subagent.rs`](src/subagent.rs)'s `run_sub_agent`, which constructs the
- child in-crate and carries every seam. The crate still exports the `Sleeper`
- port with no production implementation — wiring a real one is the binary's
- job, and tests wire a no-op to run retries at zero wall-clock cost.
+ child in-crate and carries every seam. The crate exports the `Sleeper` and
+ `Clock` ports with no production implementation — `stella-time` holds
+ those, and every host passes them to `Engine::assemble`. Tests take
+ `PausedSleeper` and `NoopSleeper` from `stella_time::test_util` — the unit
+ tests from `crate::tests`, the one copy the compiler forces — so a
+ retry costs no wall clock and a timeout still means what it says.
- **A sub-agent's steering is filtered, not inherited.** `drain_steering` is
destructive by contract, so a child that inherited the parent's `TurnSteering`
would swallow a message the user addressed to the parent. `ChildSteering`
@@ -330,7 +333,8 @@ turn-driver audit witnesses; also `budget_boundaries.rs`,
[`src/loop_detect.rs`](src/loop_detect.rs); a failing case writes its seed to
`proptest-regressions/`, and that seed is committed. No feature flag, no env var, no
fixture server and no network — driver tests wire scripted `Provider`s, counting
-`ToolExecutor`s and no-op `Sleeper`s, so the suite runs in seconds. Keep it that
+`ToolExecutor`s and `stella-time`'s shared sleeper doubles on a paused runtime,
+so the suite runs in seconds. Keep it that
way: a test here that needs a file or a socket means the logic under test is in
the wrong crate.
diff --git a/crates/stella-core/src/accounted_call.rs b/crates/stella-core/src/accounted_call.rs
index 0bd3355ead..6f142fb137 100644
--- a/crates/stella-core/src/accounted_call.rs
+++ b/crates/stella-core/src/accounted_call.rs
@@ -144,12 +144,12 @@ pub enum AccountedCallError {
/// measured since the last streamed fragment, so it re-arms every time the
/// dispatch is observed to still be producing — the same distinction
/// `crate::step::bounded_generation` draws for the engine's own step loop.
-/// A flat wall-clock deadline here used to abandon a call the instant total
-/// elapsed time crossed the ceiling even while the provider was actively
-/// answering, which lost OpenRouter's trailing usage/cost frame (it arrives
-/// in a final SSE frame *after* the content, once the gateway has settled
-/// the routed call's price) to a ceiling sized to catch silence, not a
-/// slow-but-live generation (#1467).
+/// A flat wall-clock deadline here abandoned a call the instant total
+/// elapsed time crossed the ceiling, even while the provider was still
+/// answering. That lost OpenRouter's trailing usage/cost frame, which
+/// arrives in a final SSE frame *after* the content, once the gateway has
+/// settled the routed call's price. The ceiling was sized to catch silence,
+/// not a slow-but-live generation (#1467).
pub async fn run_accounted_call(
call: AccountedCall<'_>,
budget: &mut BudgetGuard,
@@ -460,24 +460,9 @@ mod tests {
use stella_protocol::{BudgetMode, CompletionMessage, CompletionRequestRef, CompletionUsage};
use super::*;
+ use crate::tests::{NoopSleeper, PausedSleeper};
use std::time::Instant;
- struct NoopSleeper;
-
- #[async_trait]
- impl Sleeper for NoopSleeper {
- async fn sleep(&self, _duration_ms: u64) {}
-
- // The floor: a test that asserts on retry timing wants no spread in it.
- fn now(&self) -> std::time::Instant {
- std::time::Instant::now()
- }
-
- fn jitter(&self, _upper: u64) -> u64 {
- 0
- }
- }
-
struct RetryThenSuccess {
attempts: Mutex,
}
@@ -982,27 +967,6 @@ mod tests {
);
}
- /// A [`Sleeper`] backed by real (here, paused-virtual) tokio time so a
- /// caller-supplied per-call timeout can expire *during* a backoff sleep.
- struct TokioSleeper;
-
- #[async_trait]
- impl Sleeper for TokioSleeper {
- async fn sleep(&self, duration_ms: u64) {
- tokio::time::sleep(Duration::from_millis(duration_ms)).await;
- }
-
- // The floor: the timeout under test is placed against the exact
- // backoff, so the draw must not move it.
- fn now(&self) -> std::time::Instant {
- std::time::Instant::now()
- }
-
- fn jitter(&self, _upper: u64) -> u64 {
- 0
- }
- }
-
struct AlwaysRetryable;
#[async_trait]
@@ -1048,7 +1012,7 @@ mod tests {
},
&mut budget,
&EventSender::new(tx),
- &TokioSleeper,
+ &PausedSleeper,
)
.await;
@@ -1170,7 +1134,7 @@ mod tests {
},
&mut budget,
&EventSender::new(tx),
- &TokioSleeper,
+ &PausedSleeper,
)
.await
.expect("the trailing gap must not abandon a call that was actively answering");
diff --git a/crates/stella-core/src/driver/capabilities.rs b/crates/stella-core/src/driver/capabilities.rs
index 3d9b0ecc04..3c88ea2ebd 100644
--- a/crates/stella-core/src/driver/capabilities.rs
+++ b/crates/stella-core/src/driver/capabilities.rs
@@ -431,7 +431,7 @@ mod tests {
let provider = crate::subagent::tests::ScriptedProvider::new(vec![]);
let tools = crate::subagent::tests::MixedTools::default();
- let sleeper = crate::subagent::tests::TokioSleeper;
+ let sleeper = crate::tests::PausedSleeper;
let owned = built_elsewhere();
let seams = owned.as_borrowed();
@@ -473,7 +473,7 @@ mod tests {
fn assemble_carries_the_bare_capability_set_onto_the_engine() {
let provider = crate::subagent::tests::ScriptedProvider::new(vec![]);
let tools = crate::subagent::tests::MixedTools::default();
- let sleeper = crate::subagent::tests::TokioSleeper;
+ let sleeper = crate::tests::PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
diff --git a/crates/stella-core/src/driver/dispatch.rs b/crates/stella-core/src/driver/dispatch.rs
index 919298ac64..736a095bf0 100644
--- a/crates/stella-core/src/driver/dispatch.rs
+++ b/crates/stella-core/src/driver/dispatch.rs
@@ -348,8 +348,8 @@ mod tests {
use super::super::TurnHalt;
use crate::event_sender::EventSender;
- use crate::retry::Sleeper;
use crate::step::{BudgetSnapshot, CHECKPOINT_VERSION, Checkpoint, TurnState};
+ use crate::tests::NoopSleeper;
use crate::{Engine, EngineConfig, TurnCapabilities, TurnOutcome};
use stella_protocol::{
AgentEvent, BudgetMode, CompletionMessage, CompletionRequestRef, CompletionResult,
@@ -449,22 +449,6 @@ mod tests {
}
}
- #[derive(Debug)]
- struct NoopSleeper;
- #[async_trait]
- impl Sleeper for NoopSleeper {
- async fn sleep(&self, _duration_ms: u64) {}
-
- // The floor: a test that asserts on retry timing wants no spread in it.
- fn now(&self) -> std::time::Instant {
- std::time::Instant::now()
- }
-
- fn jitter(&self, _upper: u64) -> u64 {
- 0
- }
- }
-
/// Fires once the executor's flag is up — the shape of the pipeline's
/// `FlipHalt`, which latches synchronously inside the `ToolResult` tap.
#[derive(Debug)]
diff --git a/crates/stella-core/src/driver/drive.rs b/crates/stella-core/src/driver/drive.rs
index e86a1a1759..7cb1ba7c34 100644
--- a/crates/stella-core/src/driver/drive.rs
+++ b/crates/stella-core/src/driver/drive.rs
@@ -207,8 +207,8 @@ mod tests {
use super::super::TurnHalt;
use crate::event_sender::EventSender;
- use crate::retry::Sleeper;
use crate::step::{BudgetSnapshot, CHECKPOINT_VERSION, Checkpoint, TurnState};
+ use crate::tests::NoopSleeper;
use crate::{Engine, EngineConfig, TurnCapabilities, TurnOutcome};
use stella_protocol::{
BudgetMode, CompletionMessage, CompletionRequestRef, CompletionResult, CompletionUsage,
@@ -278,22 +278,6 @@ mod tests {
}
}
- #[derive(Debug)]
- struct NoopSleeper;
- #[async_trait]
- impl Sleeper for NoopSleeper {
- async fn sleep(&self, _duration_ms: u64) {}
-
- // The floor: a test that asserts on retry timing wants no spread in it.
- fn now(&self) -> std::time::Instant {
- std::time::Instant::now()
- }
-
- fn jitter(&self, _upper: u64) -> u64 {
- 0
- }
- }
-
/// Armed from the start, so the first committed boundary ends the turn.
#[derive(Debug)]
struct AlwaysHalt;
diff --git a/crates/stella-core/src/driver/restore.rs b/crates/stella-core/src/driver/restore.rs
index e04190db5f..09d24d8ab8 100644
--- a/crates/stella-core/src/driver/restore.rs
+++ b/crates/stella-core/src/driver/restore.rs
@@ -575,25 +575,10 @@ mod tests {
use crate::driver::{Engine, EngineConfig};
use crate::event_sender::EventSender;
use crate::ports::ToolExecutor;
- use crate::retry::Sleeper;
use crate::step::SummarizerHealth;
+ use crate::tests::NoopSleeper;
use stella_protocol::BudgetMode;
- struct NoSleep;
- #[async_trait]
- impl Sleeper for NoSleep {
- async fn sleep(&self, _duration_ms: u64) {}
-
- // The floor: a test that asserts on retry timing wants no spread in it.
- fn now(&self) -> std::time::Instant {
- std::time::Instant::now()
- }
-
- fn jitter(&self, _upper: u64) -> u64 {
- 0
- }
- }
-
/// Always answers "SUMMARY" — the summarizer path under test is the
/// restoration that follows the splice, not the summary itself.
struct SummaryProvider;
@@ -752,7 +737,7 @@ mod tests {
active: vec![],
};
let seams = TurnCapabilities::none();
- let engine = Engine::assemble(&provider, &tools, config(), &NoSleep, seams);
+ let engine = Engine::assemble(&provider, &tools, config(), &NoopSleeper, seams);
let mut messages = vec![
CompletionMessage::system("sys"),
CompletionMessage::user("the task"),
@@ -793,7 +778,7 @@ mod tests {
active: vec![],
};
let seams = TurnCapabilities::none();
- let engine = Engine::assemble(&provider, &tools, config(), &NoSleep, seams);
+ let engine = Engine::assemble(&provider, &tools, config(), &NoopSleeper, seams);
let mut messages = vec![
CompletionMessage::system("sys"),
CompletionMessage::user("the task"),
@@ -837,7 +822,7 @@ mod tests {
active: vec![],
};
let seams = TurnCapabilities::none();
- let engine = Engine::assemble(&provider, &tools, config(), &NoSleep, seams);
+ let engine = Engine::assemble(&provider, &tools, config(), &NoopSleeper, seams);
let mut messages = vec![
CompletionMessage::system("sys"),
CompletionMessage::user("the task"),
@@ -883,7 +868,7 @@ mod tests {
active: vec!["deploy".into()],
};
let seams = TurnCapabilities::none();
- let engine = Engine::assemble(&provider, &tools, config(), &NoSleep, seams);
+ let engine = Engine::assemble(&provider, &tools, config(), &NoopSleeper, seams);
let mut messages = vec![
CompletionMessage::system("sys"),
CompletionMessage::user("the task"),
@@ -924,7 +909,7 @@ mod tests {
active: vec![],
};
let seams = TurnCapabilities::none();
- let engine = Engine::assemble(&provider, &tools, config(), &NoSleep, seams);
+ let engine = Engine::assemble(&provider, &tools, config(), &NoopSleeper, seams);
let mut messages = vec![
CompletionMessage::system("sys"),
CompletionMessage::user("the task"),
@@ -957,7 +942,7 @@ mod tests {
active: vec![],
};
let seams = TurnCapabilities::none();
- let engine = Engine::assemble(&provider, &tools, config(), &NoSleep, seams);
+ let engine = Engine::assemble(&provider, &tools, config(), &NoopSleeper, seams);
let mut messages = vec![
CompletionMessage::system("sys"),
CompletionMessage::user("the task"),
@@ -1083,7 +1068,7 @@ mod tests {
active: vec![],
};
let seams = TurnCapabilities::none();
- let engine = Engine::assemble(&provider, &tools, config(), &NoSleep, seams);
+ let engine = Engine::assemble(&provider, &tools, config(), &NoopSleeper, seams);
let mut messages = vec![
CompletionMessage::system("sys"),
CompletionMessage::user("the task"),
@@ -1138,7 +1123,7 @@ mod tests {
active: vec![],
};
let seams = TurnCapabilities::none();
- let engine = Engine::assemble(&provider, &tools, config(), &NoSleep, seams);
+ let engine = Engine::assemble(&provider, &tools, config(), &NoopSleeper, seams);
let mut messages = vec![
CompletionMessage::system("sys"),
CompletionMessage::user("the task"),
diff --git a/crates/stella-core/src/driver/tests.rs b/crates/stella-core/src/driver/tests.rs
index 086053f76e..4b96489b04 100644
--- a/crates/stella-core/src/driver/tests.rs
+++ b/crates/stella-core/src/driver/tests.rs
@@ -11,30 +11,7 @@ use tokio::sync::mpsc;
use super::{CONTINUATION_MARKER_PREFIX as NUDGE, *};
use crate::TurnCapabilities;
use crate::hooks::{HookAction, HookExecError, HookExecResult, HookMatcher};
-use crate::retry::Sleeper;
-
-/// A `Sleeper` on tokio's clock, which every test here runs paused: a sleep
-/// costs nothing while the runtime is idle and still lets a pending call
-/// finish first, and `now` reads the same virtual timeline. A sleeper that
-/// returned at once would make every engine timeout fire the moment a
-/// provider future waited on another task, which is not what a timeout is.
-#[derive(Default)]
-struct TokioSleeper;
-#[async_trait]
-impl Sleeper for TokioSleeper {
- async fn sleep(&self, duration_ms: u64) {
- tokio::time::sleep(std::time::Duration::from_millis(duration_ms)).await;
- }
-
- fn now(&self) -> std::time::Instant {
- tokio::time::Instant::now().into_std()
- }
-
- // The floor: a test that asserts on retry timing wants no spread in it.
- fn jitter(&self, _upper: u64) -> u64 {
- 0
- }
-}
+use crate::tests::PausedSleeper;
/// A `ToolExecutor` that always succeeds and counts real invocations — the
/// counter is what `retry_never_re_executes_a_tool_call` asserts against.
@@ -252,7 +229,7 @@ async fn run_speculation_turn(
provider: &SpeculatingProvider,
tools: &dyn ToolExecutor,
) -> (TurnOutcome, Vec) {
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(provider, tools, EngineConfig::default(), &sleeper, seams);
let (tx, mut rx) = mpsc::unbounded_channel();
@@ -444,7 +421,7 @@ async fn budget_abort_after_speculation_discards_the_pool() {
executed,
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let (tx, mut rx) = mpsc::unbounded_channel();
@@ -557,7 +534,7 @@ async fn a_failed_attempts_speculative_pool_emits_discarded_events() {
calls: calls.clone(),
executed,
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![CompletionMessage::user("read a.rs")];
@@ -617,7 +594,7 @@ async fn text_deltas_precede_the_authoritative_text_and_concatenate_to_it() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let (tx, mut rx) = mpsc::unbounded_channel();
@@ -737,7 +714,7 @@ async fn a_wedged_generation_trips_the_deadline_once_instead_of_burning_the_retr
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let config = EngineConfig {
model_timeout: Some(Duration::from_millis(50)),
..EngineConfig::default()
@@ -778,7 +755,7 @@ async fn simple_turn_with_no_tool_calls_completes() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -848,7 +825,7 @@ async fn a_halt_ends_the_turn_at_the_next_step_boundary_as_completed() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let config = EngineConfig {
turn_halt: Some(Arc::new(AlwaysHalt)),
..EngineConfig::default()
@@ -896,7 +873,7 @@ async fn a_halt_that_never_fires_leaves_the_turn_exactly_as_it_was() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let config = EngineConfig {
turn_halt: Some(Arc::new(NeverHalt)),
..EngineConfig::default()
@@ -995,7 +972,7 @@ async fn steered_messages_inject_before_the_next_model_call() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let steering = TestSteering {
queue: std::sync::Mutex::new(vec!["also check the tests".into()]),
stop_after_drains: None,
@@ -1053,7 +1030,7 @@ async fn soft_stop_ends_the_turn_keeping_completed_steps() {
let tools = CountingTools {
calls: tool_calls.clone(),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
// Stop latches after the first boundary: step 0 runs fully (model
// call + tool), step 1's boundary honors the stop.
let steering = TestSteering {
@@ -1100,7 +1077,7 @@ async fn overflow_of_protected_content_is_summarized_and_metered() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, overflow_config(), &sleeper, seams);
let mut messages = vec![
@@ -1165,7 +1142,7 @@ async fn summarization_disabled_leaves_history_untouched() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let config = EngineConfig {
summarize_overflow: false,
..overflow_config()
@@ -1205,7 +1182,7 @@ async fn summarizer_failure_is_non_fatal_and_leaves_history() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, overflow_config(), &sleeper, seams);
let mut messages = vec![
@@ -1244,7 +1221,7 @@ async fn summarization_never_orphans_tool_results_at_the_span_edge() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, overflow_config(), &sleeper, seams);
// The naive span end (len - keep_recent) lands ON the tool-result
@@ -1330,7 +1307,7 @@ async fn empty_completion_aborts_with_a_visible_message_not_a_silent_success() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -1381,7 +1358,7 @@ async fn a_step_out_of_time_completes_with_a_truthful_partial_instead_of_abortin
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(
&provider,
@@ -1475,7 +1452,7 @@ async fn a_length_truncated_tool_less_step_continues_the_turn_instead_of_complet
let tools = CountingTools {
calls: tool_calls.clone(),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -1535,7 +1512,7 @@ async fn length_continuations_are_bounded_per_turn() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -1592,7 +1569,7 @@ async fn tool_calls_execute_and_feed_back_into_history() {
let tools = CountingTools {
calls: tool_calls.clone(),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -1645,7 +1622,7 @@ async fn retry_never_re_executes_a_tool_call() {
let tools = CountingTools {
calls: tool_calls.clone(),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -1693,7 +1670,7 @@ async fn malformed_tool_call_input_is_repaired_not_executed_blindly() {
let tools = CountingTools {
calls: tool_calls.clone(),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -1737,7 +1714,7 @@ async fn stuck_loop_aborts_the_turn_cleanly_before_the_step_cap() {
let tools = CountingTools {
calls: tool_calls.clone(),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -1776,7 +1753,7 @@ async fn stuck_loop_steers_once_then_aborts_on_re_detection() {
let tools = CountingTools {
calls: tool_calls.clone(),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -1898,7 +1875,7 @@ async fn identical_polls_with_changing_output_complete_without_abort() {
let tools = PollingTools {
calls: tool_calls.clone(),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -1960,7 +1937,7 @@ async fn period_three_cycle_with_no_progress_steers_then_aborts() {
let tools = CountingTools {
calls: tool_calls.clone(),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let config = EngineConfig {
loop_detection: LoopDetectionConfig {
exact_repeat_threshold: 3,
@@ -2014,7 +1991,7 @@ async fn enforced_budget_aborts_the_turn_cleanly_between_steps() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -2141,10 +2118,10 @@ async fn run_synthetic_survival_turn(dialect: &str, id_style: fn(u32) -> String)
}
}
let tools = GrowingTools;
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let config = EngineConfig {
// Keep the retry backoff floor at 0 so 200 steps with injected
- // 429s/drops still runs near-instantly under TokioSleeper.
+ // 429s/drops still runs near-instantly under PausedSleeper.
retry_policy: RetryPolicy::new(3, 0, 0),
// A tight-ish compaction budget so the growing tool output
// actually forces multiple compaction passes over 200 steps.
@@ -2263,7 +2240,7 @@ async fn read_only_calls_in_one_step_execute_concurrently() {
let tools = BarrierTools {
barrier: tokio::sync::Barrier::new(2),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -2361,7 +2338,7 @@ async fn mutating_calls_are_barriers_and_history_keeps_call_order() {
read1_started: tokio::sync::Notify::new(),
read2_done: tokio::sync::Notify::new(),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -2464,7 +2441,7 @@ async fn every_committed_step_emits_exactly_one_step_usage_record() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -2555,7 +2532,7 @@ async fn a_wedged_tool_trips_the_dispatch_ceiling_instead_of_hanging() {
]),
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let config = EngineConfig {
tool_timeout: Some(Duration::from_secs(900)),
..EngineConfig::default()
@@ -2611,7 +2588,7 @@ async fn a_none_ceiling_leaves_tool_dispatch_unbounded() {
]),
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let config = EngineConfig {
tool_timeout: None,
..EngineConfig::default()
@@ -2704,7 +2681,7 @@ async fn a_turn_checkpoints_at_every_step_boundary_and_clears_when_it_ends() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let sink = Arc::new(RecordingSink::default());
let config = EngineConfig {
checkpoint_sink: Some(sink.clone() as Arc),
@@ -2766,7 +2743,7 @@ async fn a_turn_without_a_sink_is_unchanged() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
assert!(
EngineConfig::default().checkpoint_sink.is_none(),
"durability is opt-in: a default engine writes no checkpoints"
diff --git a/crates/stella-core/src/driver/tests/audit_fixes.rs b/crates/stella-core/src/driver/tests/audit_fixes.rs
index 4877b53fcc..88d470e7d5 100644
--- a/crates/stella-core/src/driver/tests/audit_fixes.rs
+++ b/crates/stella-core/src/driver/tests/audit_fixes.rs
@@ -19,7 +19,7 @@ async fn summarize_keep_recent_zero_does_not_panic() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let config = EngineConfig {
summarize_keep_recent: 0,
..overflow_config()
@@ -61,7 +61,7 @@ async fn observed_budget_breach_emits_a_warning_event() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -116,7 +116,7 @@ async fn budget_abort_synthetic_results_are_visible_in_the_event_stream() {
let tools = CountingTools {
calls: tool_calls.clone(),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -160,7 +160,7 @@ async fn whitespace_only_completion_aborts_without_a_text_event() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -335,7 +335,7 @@ async fn hard_cancel_mid_stream_emits_a_cancelled_usage_envelope() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![CompletionMessage::user("secret prompt text")];
@@ -497,7 +497,7 @@ async fn overflow_summarizer_retries_a_transient_error() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, overflow_config(), &sleeper, seams);
let mut messages = overflow_messages();
@@ -551,7 +551,7 @@ async fn budget_aborted_summary_is_applied_not_discarded() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, overflow_config(), &sleeper, seams);
let mut messages = overflow_messages();
@@ -615,7 +615,7 @@ async fn overflow_summary_names_the_folded_tool_result_blocks() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, overflow_config(), &sleeper, seams);
@@ -716,7 +716,7 @@ async fn repeated_summarizer_failures_emit_events_and_latch() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, overflow_config(), &sleeper, seams);
let mut budget = BudgetGuard::new(BudgetMode::Off, None, None);
@@ -814,7 +814,7 @@ async fn observed_budget_breach_warns_once_per_axis_per_turn() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -871,7 +871,7 @@ async fn enforced_session_breach_abort_reason_names_the_axis() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -906,7 +906,7 @@ async fn enforced_session_breach_at_step_boundary_names_the_axis() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -1267,7 +1267,7 @@ async fn a_recycled_speculation_call_id_reports_the_execution_it_displaces() {
let tools = CountingReadTools {
executions: executions.clone(),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let (tx, mut rx) = mpsc::unbounded_channel();
@@ -1385,7 +1385,7 @@ async fn the_system_prefix_stays_byte_stable_across_a_compacting_turn() {
prefixes: std::sync::Mutex::new(Vec::new()),
step: AtomicU32::new(0),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let config = EngineConfig {
// Small enough that every step after the first compacts, and small
// enough that the pure passes cannot get under it alone — so the
diff --git a/crates/stella-core/src/driver/tests/budget_boundaries.rs b/crates/stella-core/src/driver/tests/budget_boundaries.rs
index d5c8d354da..5b7bfafe62 100644
--- a/crates/stella-core/src/driver/tests/budget_boundaries.rs
+++ b/crates/stella-core/src/driver/tests/budget_boundaries.rs
@@ -71,7 +71,7 @@ async fn an_over_cap_budget_abort_hands_back_a_well_paired_transcript() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = transcript_with_an_unanswered_tool_call();
@@ -107,7 +107,7 @@ async fn a_past_deadline_abort_hands_back_a_well_paired_transcript() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = transcript_with_an_unanswered_tool_call();
@@ -211,7 +211,7 @@ async fn summary_induced_budget_breach_aborts_with_cost_before_next_provider_cal
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, overflow_config(), &sleeper, seams);
let mut messages = vec![
@@ -263,7 +263,7 @@ async fn an_existing_budget_breach_stops_before_paid_compaction() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, overflow_config(), &sleeper, seams);
let mut messages = vec![
@@ -310,7 +310,7 @@ async fn a_past_task_deadline_stops_the_turn_before_the_next_call_with_partial_w
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -363,7 +363,7 @@ async fn cancellation_after_billed_completion_before_speculation_finishes_keeps_
provider_completed: provider_completed.clone(),
};
let tools = ForeverRead;
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![CompletionMessage::user("read")];
@@ -406,7 +406,7 @@ async fn a_normal_completion_charges_the_budget_exactly_once() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![CompletionMessage::user("answer")];
@@ -479,7 +479,7 @@ async fn a_slow_tool_stops_the_turn_before_the_deadline() {
};
let provider_calls = provider.calls.clone();
let tools = SlowTool { took: tool_time };
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
diff --git a/crates/stella-core/src/driver/tests/calibration.rs b/crates/stella-core/src/driver/tests/calibration.rs
index 034d4c71d4..43e3d99074 100644
--- a/crates/stella-core/src/driver/tests/calibration.rs
+++ b/crates/stella-core/src/driver/tests/calibration.rs
@@ -62,7 +62,7 @@ async fn calibrated_estimate_changes_the_compaction_decision() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let mut messages = compactable_history();
// A budget the RAW estimate just fits under: uncalibrated, no
// compaction can fire.
@@ -141,7 +141,7 @@ async fn each_committed_step_feeds_the_calibration_and_reports_its_estimate() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let calibration = CalibrationMap::new();
let seams = TurnCapabilities {
calibration: Some(&calibration),
@@ -228,7 +228,7 @@ async fn cache_write_tokens_count_toward_the_calibration_actual() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let calibration = CalibrationMap::new();
let seams = TurnCapabilities {
calibration: Some(&calibration),
@@ -273,7 +273,7 @@ async fn attachment_weight_is_excluded_from_the_drift_sample_estimate() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let mut messages = vec![
CompletionMessage::system("sys"),
CompletionMessage::user_with_attachments(
@@ -359,7 +359,7 @@ async fn a_fresh_sessions_calibration_factor_leaves_identity_within_the_turn() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
// Unseeded, exactly like a bench container's first run: warm-up happens
// (or fails to matter) entirely inside this one turn.
let calibration = CalibrationMap::new();
diff --git a/crates/stella-core/src/driver/tests/compute_passes.rs b/crates/stella-core/src/driver/tests/compute_passes.rs
index 007400c0b6..26c77d9a63 100644
--- a/crates/stella-core/src/driver/tests/compute_passes.rs
+++ b/crates/stella-core/src/driver/tests/compute_passes.rs
@@ -39,7 +39,7 @@ async fn a_step_walks_the_transcript_to_estimate_it_at_most_twice() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -94,7 +94,7 @@ async fn the_receipt_and_the_usage_record_report_one_estimate_per_step() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -197,7 +197,7 @@ async fn per_step_hashing_grows_with_the_turn_not_with_its_square() {
}
}
let tools = EchoingTools;
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -384,7 +384,7 @@ async fn compaction_mid_turn_invalidates_the_receipt_ledgers_digest_memo() {
calls: Arc::new(AtomicU32::new(0)),
};
let tools = BigOutputTools { filler: 4_000 };
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let config = EngineConfig {
// Small enough that a couple of 4 KB results blow it, and no summarizer so
// the rewrite comes purely from eviction/aging.
@@ -481,7 +481,7 @@ async fn a_compaction_pass_journals_the_replacement_bytes_it_wrote() {
calls: Arc::new(AtomicU32::new(0)),
};
let tools = BigOutputTools { filler: 4_000 };
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let config = EngineConfig {
compaction_budget_tokens: 800,
summarize_overflow: false,
diff --git a/crates/stella-core/src/driver/tests/context_efficiency.rs b/crates/stella-core/src/driver/tests/context_efficiency.rs
index 4b8b2c8983..63417eec4f 100644
--- a/crates/stella-core/src/driver/tests/context_efficiency.rs
+++ b/crates/stella-core/src/driver/tests/context_efficiency.rs
@@ -59,7 +59,7 @@ async fn a_long_turn_ages_old_tool_results_far_below_the_compaction_budget() {
let tools = BigOutputTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let config = EngineConfig {
compaction_budget_tokens: 20_000,
..EngineConfig::default()
@@ -147,7 +147,7 @@ async fn a_spent_allowance_retains_an_elided_partial_not_the_scratchpad() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -213,7 +213,7 @@ async fn a_truncated_step_with_tool_calls_retains_elided_narration() {
let tools = CountingTools {
calls: tool_calls.clone(),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
diff --git a/crates/stella-core/src/driver/tests/context_overflow.rs b/crates/stella-core/src/driver/tests/context_overflow.rs
index c7805aaeef..4970592f73 100644
--- a/crates/stella-core/src/driver/tests/context_overflow.rs
+++ b/crates/stella-core/src/driver/tests/context_overflow.rs
@@ -28,6 +28,7 @@ use stella_protocol::{CompletionResult, ToolSchema, UsageIncompleteReason};
use super::super::*;
use crate::TurnCapabilities;
+use crate::tests::NoopSleeper;
/// Rejects the first `overflows` calls as context overflow, then completes.
struct OverflowThenComplete {
@@ -101,22 +102,6 @@ impl ToolExecutor for NoTools {
}
}
-struct NoSleep;
-
-#[async_trait::async_trait]
-impl crate::retry::Sleeper for NoSleep {
- async fn sleep(&self, _duration_ms: u64) {}
-
- // The floor: a test that asserts on retry timing wants no spread in it.
- fn now(&self) -> std::time::Instant {
- std::time::Instant::now()
- }
-
- fn jitter(&self, _upper: u64) -> u64 {
- 0
- }
-}
-
/// Completes every call — the wider-window replacement the spent ladder
/// hands the transcript to.
struct HealthyFallback {
@@ -186,7 +171,7 @@ async fn run_turn_with_fallback(
resolver: Option<&dyn crate::ports::FallbackResolver>,
) -> (TurnOutcome, Vec) {
let tools = NoTools;
- let sleeper = NoSleep;
+ let sleeper = NoopSleeper;
let seams = TurnCapabilities {
fallback: resolver,
..TurnCapabilities::none()
@@ -444,7 +429,7 @@ async fn a_summarizer_request_that_overflows_drops_its_head_and_still_folds_the_
let tools = super::CountingTools {
calls: std::sync::Arc::new(AtomicU32::new(0)),
};
- let sleeper = NoSleep;
+ let sleeper = NoopSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, super::overflow_config(), &sleeper, seams);
let mut messages = vec![
@@ -496,7 +481,7 @@ async fn head_dropping_is_bounded_when_no_span_size_is_accepted() {
let tools = super::CountingTools {
calls: std::sync::Arc::new(AtomicU32::new(0)),
};
- let sleeper = NoSleep;
+ let sleeper = NoopSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, super::overflow_config(), &sleeper, seams);
let mut messages = vec![
diff --git a/crates/stella-core/src/driver/tests/deadline_notice.rs b/crates/stella-core/src/driver/tests/deadline_notice.rs
index ef2af88b07..9535f9c7f9 100644
--- a/crates/stella-core/src/driver/tests/deadline_notice.rs
+++ b/crates/stella-core/src/driver/tests/deadline_notice.rs
@@ -17,6 +17,7 @@ use stella_protocol::{CompletionResult, ToolSchema};
use super::super::*;
use crate::TurnCapabilities;
+use crate::tests::NoopSleeper;
/// Answers immediately, recording the transcript it was handed.
#[derive(Default)]
@@ -59,22 +60,6 @@ impl Provider for RecordingProvider {
}
}
-struct NoSleep;
-
-#[async_trait::async_trait]
-impl crate::retry::Sleeper for NoSleep {
- async fn sleep(&self, _duration_ms: u64) {}
-
- // The floor: a test that asserts on retry timing wants no spread in it.
- fn now(&self) -> std::time::Instant {
- std::time::Instant::now()
- }
-
- fn jitter(&self, _upper: u64) -> u64 {
- 0
- }
-}
-
struct NoTools;
#[async_trait::async_trait]
@@ -96,7 +81,7 @@ async fn run_turn_with_deadline(
deadline: Option,
) {
let tools = NoTools;
- let sleeper = NoSleep;
+ let sleeper = NoopSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(provider, &tools, EngineConfig::default(), &sleeper, seams);
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
diff --git a/crates/stella-core/src/driver/tests/lifecycle_bus.rs b/crates/stella-core/src/driver/tests/lifecycle_bus.rs
index b5c3c5533c..794b614ec6 100644
--- a/crates/stella-core/src/driver/tests/lifecycle_bus.rs
+++ b/crates/stella-core/src/driver/tests/lifecycle_bus.rs
@@ -17,6 +17,7 @@ use std::sync::{Arc, Mutex};
use super::super::*;
use crate::TurnCapabilities;
use crate::bus::{HookBus, HookEvent, names};
+use crate::tests::NoopSleeper;
use serde_json::Value;
use stella_protocol::{CompletionResult, ToolSchema};
@@ -129,25 +130,9 @@ impl ToolExecutor for NoTools {
}
}
-struct NoSleep;
-
-#[async_trait::async_trait]
-impl crate::retry::Sleeper for NoSleep {
- async fn sleep(&self, _duration_ms: u64) {}
-
- // The floor: a test that asserts on retry timing wants no spread in it.
- fn now(&self) -> std::time::Instant {
- std::time::Instant::now()
- }
-
- fn jitter(&self, _upper: u64) -> u64 {
- 0
- }
-}
-
async fn run_one_turn(provider: &dyn Provider, bus: &HookBus) -> TurnOutcome {
let tools = NoTools;
- let sleeper = NoSleep;
+ let sleeper = NoopSleeper;
let seams = TurnCapabilities {
bus: Some(bus),
..TurnCapabilities::none()
@@ -166,7 +151,7 @@ async fn run_one_turn_in_lane(
lane: stella_protocol::TurnLane,
) -> TurnOutcome {
let tools = NoTools;
- let sleeper = NoSleep;
+ let sleeper = NoopSleeper;
let seams = crate::driver::capabilities::TurnCapabilities {
bus: Some(bus),
lane: Some(lane),
@@ -337,7 +322,7 @@ async fn a_step_reports_how_it_ended() {
#[tokio::test]
async fn an_engine_without_a_bus_runs_identically() {
let tools = NoTools;
- let sleeper = NoSleep;
+ let sleeper = NoopSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(
&OneShotProvider,
diff --git a/crates/stella-core/src/driver/tests/live_services.rs b/crates/stella-core/src/driver/tests/live_services.rs
index 45cb2e98e1..a740719644 100644
--- a/crates/stella-core/src/driver/tests/live_services.rs
+++ b/crates/stella-core/src/driver/tests/live_services.rs
@@ -98,7 +98,7 @@ async fn run(
script: TokioMutex::new(script.into_iter().map(Ok).collect()),
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
diff --git a/crates/stella-core/src/driver/tests/loop_abort.rs b/crates/stella-core/src/driver/tests/loop_abort.rs
index 48a3e39c48..13de49bcaa 100644
--- a/crates/stella-core/src/driver/tests/loop_abort.rs
+++ b/crates/stella-core/src/driver/tests/loop_abort.rs
@@ -124,7 +124,7 @@ async fn a_loop_that_resumes_after_a_successful_course_correction_still_aborts()
let tools = ConstantTools {
calls: tool_calls.clone(),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
// A low cap, so a broken ladder shows up as "ground to the cap" in a
// second.
let config = EngineConfig {
@@ -231,7 +231,7 @@ async fn a_second_distinct_loop_earns_its_own_steer_before_the_turn_dies() {
let tools = ConstantTools {
calls: tool_calls.clone(),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, exact_repeat_only(30), &sleeper, seams);
let mut messages = vec![
@@ -341,7 +341,7 @@ async fn a_third_loop_is_not_steered_and_is_not_blamed_on_the_warned_one() {
let tools = ConstantTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, exact_repeat_only(30), &sleeper, seams);
let mut messages = vec![
@@ -442,7 +442,7 @@ async fn a_tool_answering_identically_to_every_new_argument_is_steered_then_kill
let tools = ConstantTools {
calls: tool_calls.clone(),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let config = EngineConfig {
max_steps: Some(60),
..EngineConfig::default()
@@ -512,7 +512,7 @@ async fn a_confident_zero_never_reports_as_completed() {
let tools = ConstantTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -565,7 +565,7 @@ async fn a_direct_zero_tool_answer_still_completes() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -602,7 +602,7 @@ async fn a_turn_that_did_mutating_work_still_completes_despite_a_bare_closing_li
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -641,7 +641,7 @@ async fn a_terminated_short_answer_after_investigation_still_completes() {
let tools = ConstantTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -692,7 +692,7 @@ async fn a_stuck_loop_steer_names_the_loop_rung_as_its_cause() {
let tools = ConstantTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, exact_repeat_only(30), &sleeper, seams);
let mut messages = vec![
diff --git a/crates/stella-core/src/driver/tests/model_fallback.rs b/crates/stella-core/src/driver/tests/model_fallback.rs
index 08be2a88f1..a72f5af5d3 100644
--- a/crates/stella-core/src/driver/tests/model_fallback.rs
+++ b/crates/stella-core/src/driver/tests/model_fallback.rs
@@ -24,6 +24,7 @@ use super::super::*;
use crate::TurnCapabilities;
use crate::ports::{FallbackResolver, ResolvedFallback};
use crate::retry::RetryPolicy;
+use crate::tests::NoopSleeper;
/// Fails every call with the error `build` produces, counting the calls.
struct AlwaysFailing {
@@ -165,22 +166,6 @@ impl ToolExecutor for NoTools {
}
}
-struct NoSleep;
-
-#[async_trait::async_trait]
-impl crate::retry::Sleeper for NoSleep {
- async fn sleep(&self, _duration_ms: u64) {}
-
- // The floor: a test that asserts on retry timing wants no spread in it.
- fn now(&self) -> std::time::Instant {
- std::time::Instant::now()
- }
-
- fn jitter(&self, _upper: u64) -> u64 {
- 0
- }
-}
-
/// One real turn against `provider` with `resolver` attached (when given),
/// returning the outcome and every event.
async fn run_turn_collecting(
@@ -190,7 +175,7 @@ async fn run_turn_collecting(
config: EngineConfig,
) -> (TurnOutcome, Vec) {
let tools = NoTools;
- let sleeper = NoSleep;
+ let sleeper = NoopSleeper;
let seams = TurnCapabilities {
fallback: resolver,
..TurnCapabilities::none()
diff --git a/crates/stella-core/src/driver/tests/output_budget.rs b/crates/stella-core/src/driver/tests/output_budget.rs
index bd8da7f5bd..36232569a3 100644
--- a/crates/stella-core/src/driver/tests/output_budget.rs
+++ b/crates/stella-core/src/driver/tests/output_budget.rs
@@ -26,6 +26,7 @@ use stella_protocol::{CompletionResult, ToolSchema};
use super::super::*;
use crate::TurnCapabilities;
use crate::driver::output_budget_recovery::SessionOutputCeilings;
+use crate::tests::NoopSleeper;
/// Rejects the first `refusals` calls as an unaffordable ceiling, naming
/// `affordable` each time, then completes. Records the ceiling every attempt
@@ -153,22 +154,6 @@ impl ToolExecutor for NoTools {
}
}
-struct NoSleep;
-
-#[async_trait::async_trait]
-impl crate::retry::Sleeper for NoSleep {
- async fn sleep(&self, _duration_ms: u64) {}
-
- // The floor: a test that asserts on retry timing wants no spread in it.
- fn now(&self) -> std::time::Instant {
- std::time::Instant::now()
- }
-
- fn jitter(&self, _upper: u64) -> u64 {
- 0
- }
-}
-
/// One real turn against `provider` with a configured output ceiling, and
/// optionally the session-scoped carry a host attaches (#3307). Passing the
/// same handle to two calls is what makes them two turns of ONE session
@@ -179,7 +164,7 @@ async fn run_turn_with_ceiling_and_carry(
carry: Option<&std::sync::Arc>,
) -> TurnOutcome {
let tools = NoTools;
- let sleeper = NoSleep;
+ let sleeper = NoopSleeper;
let config = EngineConfig {
max_output_tokens: Some(ceiling),
session_output_ceilings: carry.cloned(),
diff --git a/crates/stella-core/src/driver/tests/parked_wait.rs b/crates/stella-core/src/driver/tests/parked_wait.rs
index b28e9d2055..fc66bc48fd 100644
--- a/crates/stella-core/src/driver/tests/parked_wait.rs
+++ b/crates/stella-core/src/driver/tests/parked_wait.rs
@@ -124,7 +124,7 @@ async fn a_change_on_the_nth_probe_re_invokes_the_model_exactly_once() {
let tools = ParkingTools::depositing(ci_wait_request(600), 3);
let probe_calls = tools.probe_calls.clone();
let wake_calls = tools.wake_calls.clone();
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -239,7 +239,7 @@ async fn an_unchanged_condition_wakes_once_at_the_deadline() {
let tools = ParkingTools::depositing(ci_wait_request(20), u32::MAX);
let probe_calls = tools.probe_calls.clone();
let wake_calls = tools.wake_calls.clone();
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -297,7 +297,7 @@ async fn a_non_read_only_probe_is_refused_not_replayed() {
request.probe.name = "bash".into(); // not in the read-only schema set
let tools = ParkingTools::depositing(request, 1);
let probe_calls = tools.probe_calls.clone();
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -361,7 +361,7 @@ async fn sustained_rate_limiting_parks_within_budget_and_recovers() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -425,7 +425,7 @@ async fn a_long_retry_after_hint_is_honored_by_parking_when_budget_allows() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -475,7 +475,7 @@ async fn a_hint_past_the_remaining_deadline_still_fails_fast_without_parking() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -534,7 +534,7 @@ async fn a_sustained_529_brownout_parks_within_budget_and_recovers() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -597,7 +597,7 @@ async fn a_sustained_transport_fault_still_exhausts_the_ladder_and_aborts() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -662,7 +662,7 @@ async fn a_soft_stop_during_a_park_ends_the_turn_as_a_deliberate_stop() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
// Ask 1 is the step boundary's, which must answer "no" or the turn ends
// before a park exists; ask 2 is the park's first per-chunk tick.
let steering = StopOnAsk {
diff --git a/crates/stella-core/src/driver/tests/provider_outcomes.rs b/crates/stella-core/src/driver/tests/provider_outcomes.rs
index c97570d1a1..0ada4f429b 100644
--- a/crates/stella-core/src/driver/tests/provider_outcomes.rs
+++ b/crates/stella-core/src/driver/tests/provider_outcomes.rs
@@ -15,6 +15,7 @@ use super::super::*;
use crate::TurnCapabilities;
use crate::ports::Clock;
use crate::router::{CircuitBreaker, ProviderProfile, RoleTable, Router};
+use crate::tests::NoopSleeper;
use serde_json::Value;
use stella_protocol::{CompletionResult, ModelRef, Role, ToolSchema};
@@ -76,22 +77,6 @@ impl ToolExecutor for NoTools {
}
}
-struct NoSleep;
-
-#[async_trait::async_trait]
-impl crate::retry::Sleeper for NoSleep {
- async fn sleep(&self, _duration_ms: u64) {}
-
- // The floor: a test that asserts on retry timing wants no spread in it.
- fn now(&self) -> std::time::Instant {
- std::time::Instant::now()
- }
-
- fn jitter(&self, _upper: u64) -> u64 {
- 0
- }
-}
-
/// Cooldown timing is irrelevant here (nothing advances), so a frozen clock
/// keeps the breaker's open state deterministic for the whole test.
struct FrozenClock;
@@ -121,7 +106,7 @@ fn router() -> Router {
/// production wiring shape (`&Router`, shared, at the call site).
async fn run_turn_feeding(provider: &dyn Provider, router: &Router) -> TurnOutcome {
let tools = NoTools;
- let sleeper = NoSleep;
+ let sleeper = NoopSleeper;
let seams = TurnCapabilities {
outcomes: Some(router),
..TurnCapabilities::none()
diff --git a/crates/stella-core/src/driver/tests/requery.rs b/crates/stella-core/src/driver/tests/requery.rs
index a648d3dc97..3008189d81 100644
--- a/crates/stella-core/src/driver/tests/requery.rs
+++ b/crates/stella-core/src/driver/tests/requery.rs
@@ -22,22 +22,7 @@ use crate::TurnCapabilities;
use crate::budget::BudgetGuard;
use crate::driver::*;
use crate::ports::SteeringRequery;
-use crate::retry::Sleeper;
-
-struct NoopSleeper;
-#[async_trait]
-impl Sleeper for NoopSleeper {
- async fn sleep(&self, _duration_ms: u64) {}
-
- // The floor: a test that asserts on retry timing wants no spread in it.
- fn now(&self) -> std::time::Instant {
- std::time::Instant::now()
- }
-
- fn jitter(&self, _upper: u64) -> u64 {
- 0
- }
-}
+use crate::tests::NoopSleeper;
struct OkTools;
#[async_trait]
diff --git a/crates/stella-core/src/driver/tests/steer_midturn.rs b/crates/stella-core/src/driver/tests/steer_midturn.rs
index 0f42d8ea51..12453ec487 100644
--- a/crates/stella-core/src/driver/tests/steer_midturn.rs
+++ b/crates/stella-core/src/driver/tests/steer_midturn.rs
@@ -62,7 +62,7 @@ async fn a_soft_stop_closes_caller_supplied_open_tool_calls() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let steering = StopNow;
let seams = TurnCapabilities {
steering: Some(&steering),
@@ -126,7 +126,7 @@ async fn a_steer_after_a_tool_round_keeps_every_call_paired_with_its_result() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let steering = SteerAtDrain {
queue: std::sync::Mutex::new(vec!["also check the tests".into()]),
fire_on_drain: 2,
@@ -199,7 +199,7 @@ async fn a_mid_tool_round_steer_leaves_a_tool_message_immediately_before_it() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let steering = SteerAtDrain {
queue: std::sync::Mutex::new(vec!["actually, stop after this".into()]),
fire_on_drain: 2,
diff --git a/crates/stella-core/src/driver/tests/streaming_deadline.rs b/crates/stella-core/src/driver/tests/streaming_deadline.rs
index 95e21e0518..d8d0adda4e 100644
--- a/crates/stella-core/src/driver/tests/streaming_deadline.rs
+++ b/crates/stella-core/src/driver/tests/streaming_deadline.rs
@@ -61,7 +61,7 @@ async fn a_streaming_generation_outlives_the_deadline_because_it_is_not_stalled(
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let config = EngineConfig {
model_timeout: Some(Duration::from_millis(50)),
..EngineConfig::default()
@@ -153,7 +153,7 @@ async fn a_call_only_stream_outlives_the_deadline_because_it_is_not_stalled() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let config = EngineConfig {
model_timeout: Some(Duration::from_millis(50)),
..EngineConfig::default()
diff --git a/crates/stella-core/src/driver/tests/unbounded_by_default.rs b/crates/stella-core/src/driver/tests/unbounded_by_default.rs
index 2fa0b165d8..86a27137ac 100644
--- a/crates/stella-core/src/driver/tests/unbounded_by_default.rs
+++ b/crates/stella-core/src/driver/tests/unbounded_by_default.rs
@@ -65,7 +65,7 @@ async fn run_productive_turn(steps: u32, config: EngineConfig) -> (TurnOutcome,
script: TokioMutex::new(productive_script(steps)),
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &DistinctTools, config, &sleeper, seams);
let mut messages = vec![
diff --git a/crates/stella-core/src/driver/tests/usage_anchor.rs b/crates/stella-core/src/driver/tests/usage_anchor.rs
index 5bed702a63..f6b0313d30 100644
--- a/crates/stella-core/src/driver/tests/usage_anchor.rs
+++ b/crates/stella-core/src/driver/tests/usage_anchor.rs
@@ -61,7 +61,7 @@ async fn compaction_fired_with_report(usage: CompletionUsage) -> bool {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let mut messages = compactable_history();
let raw = crate::estimator::estimate_conversation_tokens(&messages);
let config = EngineConfig {
diff --git a/crates/stella-core/src/driver/tests/usage_completeness.rs b/crates/stella-core/src/driver/tests/usage_completeness.rs
index 3bbf8f7d5b..e7152a08e2 100644
--- a/crates/stella-core/src/driver/tests/usage_completeness.rs
+++ b/crates/stella-core/src/driver/tests/usage_completeness.rs
@@ -14,7 +14,7 @@ async fn exhausted_worker_call_emits_one_content_free_incompleteness_event() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -97,7 +97,7 @@ async fn a_failed_call_names_the_model_that_made_it() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -164,7 +164,7 @@ async fn a_failed_attempts_recovered_usage_reaches_the_event_stream() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -221,7 +221,7 @@ async fn exhausted_retries_emit_typed_reasons_before_the_error() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -281,7 +281,7 @@ async fn auth_failure_on_first_attempt_reports_not_retryable() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -338,7 +338,7 @@ async fn successful_retry_keeps_the_failed_attempt_usage_incomplete() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let config = EngineConfig {
retry_policy: RetryPolicy::new(1, 0, 0),
..EngineConfig::default()
@@ -394,7 +394,7 @@ async fn step_usage_carries_the_requests_effort_and_output_ceiling() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let config = EngineConfig {
effort: Some(stella_protocol::completion::ReasoningEffort::High),
max_output_tokens: Some(32_000),
@@ -450,7 +450,7 @@ async fn step_usage_carries_the_requests_generation_params() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let asked = stella_protocol::completion::GenerationParams {
top_p: Some(0.9),
seed: Some(4_621),
@@ -520,7 +520,7 @@ async fn overflow_summarizer_emits_its_own_usage_envelope() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, overflow_config(), &sleeper, seams);
let mut messages = overflow_messages();
@@ -555,7 +555,7 @@ async fn failed_overflow_summarizer_emits_content_free_incompleteness() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, overflow_config(), &sleeper, seams);
let mut messages = overflow_messages();
@@ -613,7 +613,7 @@ async fn two_calls_at_one_step_are_separable_by_their_usage_rows() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams)
.with_turn_instance(TURN);
diff --git a/crates/stella-core/src/driver/tests/user_hooks.rs b/crates/stella-core/src/driver/tests/user_hooks.rs
index 8271002bce..ceb30bbab3 100644
--- a/crates/stella-core/src/driver/tests/user_hooks.rs
+++ b/crates/stella-core/src/driver/tests/user_hooks.rs
@@ -54,7 +54,7 @@ async fn pre_tool_use_hook_nonzero_exit_blocks_the_tool_and_model_sees_it() {
let tools = CountingTools {
calls: tool_calls.clone(),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let payloads = Arc::new(TokioMutex::new(Vec::new()));
let runner = RecordingHookRunner {
exit_code: 1,
@@ -128,7 +128,7 @@ async fn post_tool_use_hook_runs_after_the_tool_and_never_blocks() {
let tools = CountingTools {
calls: tool_calls.clone(),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let payloads = Arc::new(TokioMutex::new(Vec::new()));
// Exit 3 (non-zero) proves a *failing* PostToolUse hook is still a
// pure observation — it can neither block nor abort the turn.
@@ -196,7 +196,7 @@ async fn non_blocking_hook_failure_surfaces_as_one_retryable_error_event() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let payloads = Arc::new(TokioMutex::new(Vec::new()));
let runner = RecordingHookRunner {
exit_code: 3,
@@ -270,7 +270,7 @@ async fn no_hooks_configured_leaves_the_turn_path_unchanged() {
let tools = CountingTools {
calls: tool_calls.clone(),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
// Built WITHOUT `with_hooks` — `hooks` stays `None`.
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
@@ -319,7 +319,7 @@ async fn run_turn_never_fires_session_start_hooks() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let payloads = Arc::new(TokioMutex::new(Vec::new()));
let runner = RecordingHookRunner {
exit_code: 0,
@@ -383,7 +383,7 @@ async fn a_hooked_read_fires_its_hook_once_never_for_a_dropped_speculative_attem
calls: calls.clone(),
executed,
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let payloads = Arc::new(TokioMutex::new(Vec::new()));
// exit 0: non-blocking, so the tool runs and the hook is a pure
// observation — what matters is how many times it is invoked.
@@ -493,7 +493,7 @@ async fn pre_tool_use_modify_decision_rewrites_the_input_the_tool_receives() {
schemas: vec![bash_schema(false)],
inputs: inputs.clone(),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let payloads = Arc::new(TokioMutex::new(Vec::new()));
let runner = RecordingHookRunner {
exit_code: 0,
@@ -624,7 +624,7 @@ async fn a_hook_require_approval_parks_on_the_route_and_the_answer_decides() {
runner,
hooks,
} = require_approval_fixture(ask);
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let route = ScriptedRoute {
resolution: crate::hooks::decision::ApprovalRouteResolution::Approved,
calls: Arc::new(AtomicU32::new(0)),
@@ -722,7 +722,7 @@ async fn require_approval_without_a_route_refuses_with_the_grant_path() {
runner,
hooks,
} = require_approval_fixture(ask);
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities {
hooks: Some((&hooks, &runner)),
..TurnCapabilities::none()
@@ -773,7 +773,7 @@ async fn pre_tool_use_payload_carries_the_schemas_read_only_bit() {
schemas: vec![bash_schema(true)],
inputs: Arc::new(TokioMutex::new(Vec::new())),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let payloads = Arc::new(TokioMutex::new(Vec::new()));
let runner = RecordingHookRunner {
exit_code: 0,
@@ -869,7 +869,7 @@ async fn an_always_denying_stop_hook_is_consulted_to_the_bound_then_the_turn_sta
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let payloads = Arc::new(TokioMutex::new(Vec::new()));
let runner = RecordingHookRunner {
exit_code: 0,
@@ -977,7 +977,7 @@ async fn a_deny_then_allow_is_reconsulted_and_the_allowed_completion_stands() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let payloads = Arc::new(TokioMutex::new(Vec::new()));
let runner = ScriptedHookRunner {
stdouts: TokioMutex::new(vec![
@@ -1051,7 +1051,7 @@ async fn a_failing_stop_hook_never_holds_the_turn_open() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let runner = RecordingHookRunner {
exit_code: 3,
stdout: String::new(),
@@ -1125,7 +1125,7 @@ async fn a_stop_hooks_require_approval_resolves_both_ways() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let runner = RecordingHookRunner {
exit_code: 0,
stdout: r#"{"action":"require_approval","reason":"verification budget exhausted, continue?"}"#
@@ -1220,7 +1220,7 @@ async fn a_stop_hooks_require_approval_with_no_route_lets_the_turn_complete() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let runner = RecordingHookRunner {
exit_code: 0,
stdout: r#"{"action":"require_approval","reason":"ask a human"}"#.into(),
@@ -1275,7 +1275,7 @@ async fn pre_compact_hook_veto_skips_the_summarization_round() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let hook_payloads = Arc::new(TokioMutex::new(Vec::new()));
let runner = RecordingHookRunner {
exit_code: 0,
@@ -1346,7 +1346,7 @@ async fn pre_compact_modify_instructions_reach_the_summarizer_request() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let runner = RecordingHookRunner {
exit_code: 0,
stdout: r#"{"action":"modify","payload":{"instructions":"keep every file path verbatim"}}"#
diff --git a/crates/stella-core/src/driver/tests/user_hooks/verdicts.rs b/crates/stella-core/src/driver/tests/user_hooks/verdicts.rs
index 723c7927d1..11dbfcc46f 100644
--- a/crates/stella-core/src/driver/tests/user_hooks/verdicts.rs
+++ b/crates/stella-core/src/driver/tests/user_hooks/verdicts.rs
@@ -29,7 +29,7 @@ async fn a_structured_stop_denial_reaches_the_model_and_the_journal_intact() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let runner = ScriptedHookRunner {
stdouts: TokioMutex::new(vec![
r#"{"action":"deny","reason":"the witness is still red",
@@ -123,7 +123,7 @@ async fn a_prose_only_stop_denial_grows_no_evidence_section() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let runner = ScriptedHookRunner {
stdouts: TokioMutex::new(vec![
r#"{"action":"deny","reason":"the checklist is not done"}"#.into(),
@@ -209,7 +209,7 @@ async fn run_always_denying_stop_gate(allowance: Option, answers: usize) ->
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let runner = RecordingHookRunner {
exit_code: 0,
stdout: r#"{"action":"deny","reason":"not yet"}"#.into(),
diff --git a/crates/stella-core/src/driver/tests/zero_copy_request.rs b/crates/stella-core/src/driver/tests/zero_copy_request.rs
index 9433d58298..a1917f3352 100644
--- a/crates/stella-core/src/driver/tests/zero_copy_request.rs
+++ b/crates/stella-core/src/driver/tests/zero_copy_request.rs
@@ -65,7 +65,7 @@ async fn a_retried_attempt_re_sends_the_same_slices_it_did_the_first_time() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let (tx, _rx) = mpsc::unbounded_channel();
@@ -122,7 +122,7 @@ async fn the_adapter_serializes_off_the_callers_own_transcript() {
let tools = CountingTools {
calls: Arc::new(AtomicU32::new(0)),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let (tx, _rx) = mpsc::unbounded_channel();
diff --git a/crates/stella-core/src/goal.rs b/crates/stella-core/src/goal.rs
index 46815c5aa1..2ef31ff755 100644
--- a/crates/stella-core/src/goal.rs
+++ b/crates/stella-core/src/goal.rs
@@ -554,23 +554,7 @@ mod tests {
use crate::TurnCapabilities;
use crate::driver::EngineConfig;
use crate::ports::ToolExecutor;
- use crate::retry::Sleeper;
-
- /// Sleeper that never really sleeps — goal tests run instantly.
- struct NoSleep;
- #[async_trait]
- impl Sleeper for NoSleep {
- async fn sleep(&self, _duration_ms: u64) {}
-
- // The floor: a test that asserts on retry timing wants no spread in it.
- fn now(&self) -> std::time::Instant {
- std::time::Instant::now()
- }
-
- fn jitter(&self, _upper: u64) -> u64 {
- 0
- }
- }
+ use crate::tests::NoopSleeper;
/// A provider that returns a fixed sequence of results, then errors.
struct ScriptedProvider {
@@ -670,7 +654,13 @@ mod tests {
]);
let tools = NoTools;
let seams = TurnCapabilities::none();
- let engine = Engine::assemble(&worker, &tools, EngineConfig::default(), &NoSleep, seams);
+ let engine = Engine::assemble(
+ &worker,
+ &tools,
+ EngineConfig::default(),
+ &NoopSleeper,
+ seams,
+ );
let mut messages = vec![CompletionMessage::system("sys")];
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
let (tx, rx) = mpsc::unbounded_channel();
@@ -736,7 +726,13 @@ mod tests {
]);
let tools = NoTools;
let seams = TurnCapabilities::none();
- let engine = Engine::assemble(&worker, &tools, EngineConfig::default(), &NoSleep, seams);
+ let engine = Engine::assemble(
+ &worker,
+ &tools,
+ EngineConfig::default(),
+ &NoopSleeper,
+ seams,
+ );
let mut messages = vec![CompletionMessage::system("sys")];
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
let (tx, rx) = mpsc::unbounded_channel();
@@ -823,7 +819,13 @@ mod tests {
]);
let tools = NoTools;
let seams = TurnCapabilities::none();
- let engine = Engine::assemble(&worker, &tools, EngineConfig::default(), &NoSleep, seams);
+ let engine = Engine::assemble(
+ &worker,
+ &tools,
+ EngineConfig::default(),
+ &NoopSleeper,
+ seams,
+ );
let mut messages = vec![CompletionMessage::system("sys")];
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
let (tx, _rx) = mpsc::unbounded_channel();
@@ -883,7 +885,13 @@ mod tests {
]);
let tools = NoTools;
let seams = TurnCapabilities::none();
- let engine = Engine::assemble(&worker, &tools, EngineConfig::default(), &NoSleep, seams);
+ let engine = Engine::assemble(
+ &worker,
+ &tools,
+ EngineConfig::default(),
+ &NoopSleeper,
+ seams,
+ );
let mut messages = vec![CompletionMessage::system("sys")];
// Mirrors `build_budget_guard(Some(0.05))`: the cap is on the session
// axis. (A per-turn axis here would reset each round and let the loop
@@ -963,7 +971,13 @@ mod tests {
]);
let tools = NoTools;
let seams = TurnCapabilities::none();
- let engine = Engine::assemble(&worker, &tools, EngineConfig::default(), &NoSleep, seams);
+ let engine = Engine::assemble(
+ &worker,
+ &tools,
+ EngineConfig::default(),
+ &NoopSleeper,
+ seams,
+ );
let mut messages = vec![CompletionMessage::system("sys")];
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
let (tx, _rx) = mpsc::unbounded_channel();
@@ -1014,7 +1028,13 @@ mod tests {
gate: Some(&gate),
..TurnCapabilities::none()
};
- let engine = Engine::assemble(&worker, &tools, EngineConfig::default(), &NoSleep, seams);
+ let engine = Engine::assemble(
+ &worker,
+ &tools,
+ EngineConfig::default(),
+ &NoopSleeper,
+ seams,
+ );
let mut messages = vec![CompletionMessage::system("sys")];
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
let (tx, _rx) = mpsc::unbounded_channel();
@@ -1065,7 +1085,13 @@ mod tests {
]);
let tools = NoTools;
let seams = TurnCapabilities::none();
- let engine = Engine::assemble(&worker, &tools, EngineConfig::default(), &NoSleep, seams);
+ let engine = Engine::assemble(
+ &worker,
+ &tools,
+ EngineConfig::default(),
+ &NoopSleeper,
+ seams,
+ );
let mut messages = vec![CompletionMessage::system("sys")];
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
let (tx, _rx) = mpsc::unbounded_channel();
@@ -1105,7 +1131,13 @@ mod tests {
let verifier = ScriptedProvider::new(vec![Err(ProviderError::Auth("bad key".into()))]);
let tools = NoTools;
let seams = TurnCapabilities::none();
- let engine = Engine::assemble(&worker, &tools, EngineConfig::default(), &NoSleep, seams);
+ let engine = Engine::assemble(
+ &worker,
+ &tools,
+ EngineConfig::default(),
+ &NoopSleeper,
+ seams,
+ );
let mut messages = vec![CompletionMessage::system("sys")];
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
let (tx, _rx) = mpsc::unbounded_channel();
@@ -1137,7 +1169,13 @@ mod tests {
let verifier = ScriptedProvider::new(vec![]);
let tools = NoTools;
let seams = TurnCapabilities::none();
- let engine = Engine::assemble(&worker, &tools, EngineConfig::default(), &NoSleep, seams);
+ let engine = Engine::assemble(
+ &worker,
+ &tools,
+ EngineConfig::default(),
+ &NoopSleeper,
+ seams,
+ );
let mut messages = vec![CompletionMessage::system("sys")];
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
let (tx, _rx) = mpsc::unbounded_channel();
@@ -1195,7 +1233,13 @@ mod tests {
let verifier = ScriptedProvider::new(vec![]);
let tools = NoTools;
let seams = TurnCapabilities::none();
- let engine = Engine::assemble(&worker, &tools, EngineConfig::default(), &NoSleep, seams);
+ let engine = Engine::assemble(
+ &worker,
+ &tools,
+ EngineConfig::default(),
+ &NoopSleeper,
+ seams,
+ );
let mut messages = vec![CompletionMessage::system("sys")];
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
let (tx, _rx) = mpsc::unbounded_channel();
@@ -1230,7 +1274,13 @@ mod tests {
))]);
let tools = NoTools;
let seams = TurnCapabilities::none();
- let engine = Engine::assemble(&worker, &tools, EngineConfig::default(), &NoSleep, seams);
+ let engine = Engine::assemble(
+ &worker,
+ &tools,
+ EngineConfig::default(),
+ &NoopSleeper,
+ seams,
+ );
let mut messages = vec![CompletionMessage::system("sys")];
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
budget.reseed_session_spend(0.75);
diff --git a/crates/stella-core/src/lib.rs b/crates/stella-core/src/lib.rs
index b61507c014..dcb3421567 100644
--- a/crates/stella-core/src/lib.rs
+++ b/crates/stella-core/src/lib.rs
@@ -27,6 +27,9 @@ pub mod ports;
pub mod receipts;
pub mod restore;
pub mod retry;
+
+// The two sleeper doubles the unit tests share. The module doc says why
+// this copy of `stella-time`'s exists.
pub mod router;
pub mod running_task;
pub mod shell_text;
@@ -37,6 +40,8 @@ pub mod steering;
pub mod step;
pub mod subagent;
mod summarize;
+#[cfg(test)]
+pub(crate) mod tests;
pub mod waiting;
pub use budget::{BudgetGuard, BudgetOutcome};
diff --git a/crates/stella-core/src/subagent/tests.rs b/crates/stella-core/src/subagent/tests.rs
index c980411cd9..535234a587 100644
--- a/crates/stella-core/src/subagent/tests.rs
+++ b/crates/stella-core/src/subagent/tests.rs
@@ -23,30 +23,10 @@ use super::*;
use crate::TurnCapabilities;
use crate::budget::BudgetOutcome;
use crate::ports::TurnGate;
-use crate::retry::Sleeper;
+pub(crate) use crate::tests::PausedSleeper;
// ---- fakes -----------------------------------------------------------
-/// A `Sleeper` on tokio's clock, run paused by every test here: a sleep is
-/// free while the runtime is idle and still lets a pending child finish
-/// first, and `now` reads the same virtual timeline.
-pub(crate) struct TokioSleeper;
-#[async_trait]
-impl Sleeper for TokioSleeper {
- async fn sleep(&self, duration_ms: u64) {
- tokio::time::sleep(std::time::Duration::from_millis(duration_ms)).await;
- }
-
- fn now(&self) -> std::time::Instant {
- tokio::time::Instant::now().into_std()
- }
-
- // The floor: a test that asserts on retry timing wants no spread in it.
- fn jitter(&self, _upper: u64) -> u64 {
- 0
- }
-}
-
/// A provider that returns a fixed sequence of results, then errors.
pub(crate) struct ScriptedProvider {
script: Mutex>>,
diff --git a/crates/stella-core/src/subagent/tests/failure_and_events.rs b/crates/stella-core/src/subagent/tests/failure_and_events.rs
index 124ed91230..fa60e577bf 100644
--- a/crates/stella-core/src/subagent/tests/failure_and_events.rs
+++ b/crates/stella-core/src/subagent/tests/failure_and_events.rs
@@ -33,7 +33,7 @@ async fn an_aborted_child_salvages_the_last_answer_it_paid_for() {
&parent_provider,
&tools,
EngineConfig::default(),
- &TokioSleeper,
+ &PausedSleeper,
seams,
);
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
@@ -72,7 +72,7 @@ async fn a_failed_child_never_becomes_an_error_the_parent_has_to_handle() {
&parent_provider,
&tools,
EngineConfig::default(),
- &TokioSleeper,
+ &PausedSleeper,
seams,
);
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
@@ -106,7 +106,7 @@ async fn nesting_deeper_than_the_cap_is_refused_before_spending() {
&parent_provider,
&tools,
EngineConfig::default(),
- &TokioSleeper,
+ &PausedSleeper,
seams,
);
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
@@ -149,7 +149,7 @@ async fn the_childs_stage_and_narration_never_reach_the_parents_stream() {
&parent_provider,
&tools,
EngineConfig::default(),
- &TokioSleeper,
+ &PausedSleeper,
seams,
);
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
@@ -210,7 +210,7 @@ async fn a_childs_metering_records_name_the_child_that_spent_them() {
&parent_provider,
&tools,
EngineConfig::default(),
- &TokioSleeper,
+ &PausedSleeper,
seams,
);
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
@@ -258,7 +258,7 @@ async fn the_leads_own_calls_name_no_sub_agent() {
&provider,
&tools,
EngineConfig::default(),
- &TokioSleeper,
+ &PausedSleeper,
seams,
);
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
@@ -293,7 +293,7 @@ async fn step_usage_and_tool_activity_reach_the_parent_so_cost_rolls_up() {
&parent_provider,
&tools,
EngineConfig::default(),
- &TokioSleeper,
+ &PausedSleeper,
seams,
);
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
@@ -358,7 +358,7 @@ async fn a_childs_tool_calls_name_the_child_that_ran_them() {
&parent_provider,
&tools,
EngineConfig::default(),
- &TokioSleeper,
+ &PausedSleeper,
seams,
);
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
@@ -406,7 +406,7 @@ async fn the_leads_own_tool_calls_name_no_sub_agent() {
&provider,
&tools,
EngineConfig::default(),
- &TokioSleeper,
+ &PausedSleeper,
seams,
);
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
diff --git a/crates/stella-core/src/subagent/tests/fork_scope.rs b/crates/stella-core/src/subagent/tests/fork_scope.rs
index ed35a2d248..2f12cfdcd5 100644
--- a/crates/stella-core/src/subagent/tests/fork_scope.rs
+++ b/crates/stella-core/src/subagent/tests/fork_scope.rs
@@ -15,7 +15,7 @@ use async_trait::async_trait;
use stella_protocol::{BudgetMode, CompletionRequestRef, CompletionResult, ProviderError};
use tokio::sync::mpsc;
-use super::{MixedTools, ScriptedProvider, TokioSleeper, text_result, tool_call_result};
+use super::{MixedTools, PausedSleeper, ScriptedProvider, text_result, tool_call_result};
use crate::subagent::*;
// ---- forked-skill scoping: allowed_tools + effort (#2682) ---------------
@@ -81,7 +81,7 @@ async fn a_grant_scoped_child_cannot_see_or_call_outside_its_grant() {
&parent_provider,
&tools,
EngineConfig::default(),
- &TokioSleeper,
+ &PausedSleeper,
seams,
);
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
@@ -134,7 +134,7 @@ async fn a_forked_skills_effort_overrides_the_parents_and_absent_inherits() {
..EngineConfig::default()
};
let seams = TurnCapabilities::none();
- let parent = Engine::assemble(&parent_provider, &tools, config, &TokioSleeper, seams);
+ let parent = Engine::assemble(&parent_provider, &tools, config, &PausedSleeper, seams);
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
let (tx, _rx) = mpsc::unbounded_channel();
diff --git a/crates/stella-core/src/subagent/tests/seams.rs b/crates/stella-core/src/subagent/tests/seams.rs
index 65f9e6612d..3214a37c95 100644
--- a/crates/stella-core/src/subagent/tests/seams.rs
+++ b/crates/stella-core/src/subagent/tests/seams.rs
@@ -36,7 +36,7 @@ async fn a_child_honors_the_soft_stop_but_never_eats_the_parents_steering() {
&parent_provider,
&tools,
EngineConfig::default(),
- &TokioSleeper,
+ &PausedSleeper,
seams,
);
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
@@ -111,7 +111,7 @@ async fn a_child_polls_the_parents_pause_gate() {
&parent_provider,
&tools,
EngineConfig::default(),
- &TokioSleeper,
+ &PausedSleeper,
seams,
);
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
@@ -161,7 +161,7 @@ async fn owned_turn_controls_stop_a_child_without_clobbering_an_attached_gate()
&parent_provider,
&tools,
EngineConfig::default(),
- &TokioSleeper,
+ &PausedSleeper,
seams,
)
.with_turn_controls(&controls);
@@ -220,7 +220,7 @@ fn turn_controls_carrying_both_seams_give_a_child_both() {
&provider,
&tools,
EngineConfig::default(),
- &TokioSleeper,
+ &PausedSleeper,
seams,
)
.with_turn_controls(&both);
@@ -247,7 +247,7 @@ fn empty_turn_controls_leave_an_engine_exactly_as_it_was() {
&provider,
&tools,
EngineConfig::default(),
- &TokioSleeper,
+ &PausedSleeper,
seams,
)
.with_turn_controls(¬hing);
@@ -313,7 +313,7 @@ async fn the_child_claims_its_own_receipt_turn_slot() {
..EngineConfig::default()
};
let seams = TurnCapabilities::none();
- let parent = Engine::assemble(&parent_provider, &tools, config, &TokioSleeper, seams);
+ let parent = Engine::assemble(&parent_provider, &tools, config, &PausedSleeper, seams);
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
let (tx, mut rx) = mpsc::unbounded_channel();
@@ -398,7 +398,7 @@ async fn subagent_start_and_stop_hooks_fire_around_a_child_turn() {
&parent_provider,
&tools,
EngineConfig::default(),
- &TokioSleeper,
+ &PausedSleeper,
seams,
);
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
@@ -484,7 +484,7 @@ async fn a_forked_child_stamps_the_subagent_fork_lane() {
&parent_provider,
&tools,
EngineConfig::default(),
- &TokioSleeper,
+ &PausedSleeper,
seams,
);
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
diff --git a/crates/stella-core/src/subagent/tests/spend_and_cancellation.rs b/crates/stella-core/src/subagent/tests/spend_and_cancellation.rs
index 34be303bf3..882ab6756d 100644
--- a/crates/stella-core/src/subagent/tests/spend_and_cancellation.rs
+++ b/crates/stella-core/src/subagent/tests/spend_and_cancellation.rs
@@ -62,7 +62,7 @@ async fn tool_dispatched_child_spend_aborts_the_parent_at_the_next_step_boundary
&provider,
&tools,
EngineConfig::default(),
- &TokioSleeper,
+ &PausedSleeper,
seams,
);
let mut messages = vec![CompletionMessage::user("go")];
@@ -135,7 +135,7 @@ async fn the_drain_is_destructive_so_child_spend_is_never_charged_twice() {
&provider,
&tools,
EngineConfig::default(),
- &TokioSleeper,
+ &PausedSleeper,
seams,
);
let mut messages = vec![CompletionMessage::user("go")];
@@ -244,7 +244,7 @@ async fn a_cancelled_child_closes_its_bracket_with_committed_steps_and_cost() {
&parent_provider,
&tools,
EngineConfig::default(),
- &TokioSleeper,
+ &PausedSleeper,
seams,
);
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
@@ -379,7 +379,7 @@ async fn a_child_is_bounded_by_the_ceiling_its_whole_run_sits_under() {
model_timeout: None,
..EngineConfig::default()
},
- &TokioSleeper,
+ &PausedSleeper,
seams,
);
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
@@ -491,7 +491,7 @@ async fn a_ceiling_too_short_refuses_the_whole_spawn_loudly() {
tool_timeout: Some(Duration::from_secs(30)),
..EngineConfig::default()
},
- &TokioSleeper,
+ &PausedSleeper,
seams,
);
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
diff --git a/crates/stella-core/src/subagent/tests/tools_and_budget.rs b/crates/stella-core/src/subagent/tests/tools_and_budget.rs
index daae2369a1..39e5f26e5a 100644
--- a/crates/stella-core/src/subagent/tests/tools_and_budget.rs
+++ b/crates/stella-core/src/subagent/tests/tools_and_budget.rs
@@ -15,7 +15,7 @@ async fn a_read_only_child_cannot_execute_a_mutating_tool_even_when_it_tries() {
&parent_provider,
&tools,
EngineConfig::default(),
- &TokioSleeper,
+ &PausedSleeper,
seams,
);
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
@@ -50,7 +50,7 @@ async fn write_access_is_opt_in_per_spawn() {
&parent_provider,
&tools,
EngineConfig::default(),
- &TokioSleeper,
+ &PausedSleeper,
seams,
);
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
@@ -82,7 +82,7 @@ async fn child_spend_settles_into_the_parent_exactly_once() {
&parent_provider,
&tools,
EngineConfig::default(),
- &TokioSleeper,
+ &PausedSleeper,
seams,
);
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
@@ -124,7 +124,7 @@ async fn an_enforced_carve_stops_the_child_without_touching_the_parents_turn() {
&parent_provider,
&tools,
EngineConfig::default(),
- &TokioSleeper,
+ &PausedSleeper,
seams,
);
let mut budget = BudgetGuard::new(BudgetMode::Enforced, None, Some(100.0));
@@ -167,7 +167,7 @@ async fn a_child_can_never_be_carved_past_the_parents_remaining_headroom() {
&parent_provider,
&tools,
EngineConfig::default(),
- &TokioSleeper,
+ &PausedSleeper,
seams,
);
let mut budget = BudgetGuard::new(BudgetMode::Enforced, None, Some(1.0));
@@ -214,7 +214,7 @@ async fn an_enforced_parent_with_no_headroom_refuses_before_spending_anything()
&parent_provider,
&tools,
EngineConfig::default(),
- &TokioSleeper,
+ &PausedSleeper,
seams,
);
let mut budget = BudgetGuard::new(BudgetMode::Enforced, None, Some(1.0));
diff --git a/crates/stella-core/src/subagent/tests/witness.rs b/crates/stella-core/src/subagent/tests/witness.rs
index 7c0703a69f..a36615fdaa 100644
--- a/crates/stella-core/src/subagent/tests/witness.rs
+++ b/crates/stella-core/src/subagent/tests/witness.rs
@@ -30,7 +30,7 @@ async fn the_parent_transcript_does_not_grow_by_the_childs_intermediate_work() {
&parent_provider,
&tools,
EngineConfig::default(),
- &TokioSleeper,
+ &PausedSleeper,
seams,
);
@@ -139,7 +139,7 @@ async fn a_child_turn_never_writes_or_clears_the_parents_resume_point() {
..EngineConfig::default()
};
let seams = TurnCapabilities::none();
- let parent = Engine::assemble(&parent_provider, &tools, config, &TokioSleeper, seams);
+ let parent = Engine::assemble(&parent_provider, &tools, config, &PausedSleeper, seams);
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
let (tx, mut rx) = mpsc::unbounded_channel();
@@ -188,7 +188,7 @@ async fn the_report_is_clamped_to_the_spec_cap_and_says_so() {
&parent_provider,
&tools,
EngineConfig::default(),
- &TokioSleeper,
+ &PausedSleeper,
seams,
);
let mut budget = BudgetGuard::new(BudgetMode::Observed, None, None);
diff --git a/crates/stella-core/src/tests.rs b/crates/stella-core/src/tests.rs
new file mode 100644
index 0000000000..a80e9f9d29
--- /dev/null
+++ b/crates/stella-core/src/tests.rs
@@ -0,0 +1,66 @@
+//! The two [`Sleeper`] doubles this crate's own unit tests share.
+//!
+//! They are the same two `stella-time` ships behind its `test-util`
+//! feature. Every other crate takes them from there. This crate's unit
+//! tests cannot. A lib's unit tests are a second build of the lib. A
+//! dev-dependency that links the lib links the first build, so its
+//! `impl Sleeper` is for a trait these tests do not see. The integration
+//! tests under `tests/` are separate crates and use `stella-time`'s.
+//!
+//! So this is the one copy the compiler forces, and `stella-time`'s
+//! `one_home` test names it as such.
+
+use std::time::Instant;
+
+use async_trait::async_trait;
+
+use crate::retry::Sleeper;
+
+/// A [`Sleeper`] on tokio's clock, for a test that runs its runtime paused.
+///
+/// A sleep costs nothing while the runtime is idle. It still lets a pending
+/// call finish first, and `now` reads the same virtual timeline. That is
+/// what a timeout is. So this is the double for any test that arms a tool
+/// or model timeout. A sleeper that returned at once would fire every
+/// engine timeout as soon as a provider future waited on another task.
+///
+/// Jitter is zero: a test that asserts on retry timing wants no spread.
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
+pub(crate) struct PausedSleeper;
+
+#[async_trait]
+impl Sleeper for PausedSleeper {
+ async fn sleep(&self, duration_ms: u64) {
+ tokio::time::sleep(std::time::Duration::from_millis(duration_ms)).await;
+ }
+
+ fn now(&self) -> Instant {
+ tokio::time::Instant::now().into_std()
+ }
+
+ fn jitter(&self, _upper: u64) -> u64 {
+ 0
+ }
+}
+
+/// A [`Sleeper`] that never waits.
+///
+/// For a test that arms no timeout and wants a retry ladder to run at once.
+/// `now` reads the real monotonic clock, so a deadline set from it still
+/// means what it says. Do not drive a timed turn with it: see
+/// [`PausedSleeper`] for why.
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
+pub(crate) struct NoopSleeper;
+
+#[async_trait]
+impl Sleeper for NoopSleeper {
+ async fn sleep(&self, _duration_ms: u64) {}
+
+ fn now(&self) -> Instant {
+ Instant::now()
+ }
+
+ fn jitter(&self, _upper: u64) -> u64 {
+ 0
+ }
+}
diff --git a/crates/stella-core/tests/engine_emits_no_stage.rs b/crates/stella-core/tests/engine_emits_no_stage.rs
index 519c75f926..35233c148f 100644
--- a/crates/stella-core/tests/engine_emits_no_stage.rs
+++ b/crates/stella-core/tests/engine_emits_no_stage.rs
@@ -23,27 +23,12 @@ use serde_json::Value;
use stella_core::budget::BudgetGuard;
use stella_core::event_sender::EventSender;
use stella_core::ports::ToolExecutor;
-use stella_core::retry::Sleeper;
use stella_core::{Engine, EngineConfig, TurnCapabilities};
use stella_protocol::{
AgentEvent, BudgetMode, CompletionMessage, CompletionRequestRef, CompletionResult,
CompletionUsage, Provider, ProviderError, ToolOutput, ToolSchema,
};
-
-struct NoopSleeper;
-#[async_trait]
-impl Sleeper for NoopSleeper {
- async fn sleep(&self, _duration_ms: u64) {}
-
- // The floor: a test that asserts on retry timing wants no spread in it.
- fn now(&self) -> std::time::Instant {
- std::time::Instant::now()
- }
-
- fn jitter(&self, _upper: u64) -> u64 {
- 0
- }
-}
+use stella_time::test_util::NoopSleeper;
/// Answers once, with text and no tool calls, so the turn completes in one
/// step — the shortest turn that still passes through the whole framing.
diff --git a/crates/stella-core/tests/hard_drop_write_back.rs b/crates/stella-core/tests/hard_drop_write_back.rs
index a545d371e3..ddf28db08a 100644
--- a/crates/stella-core/tests/hard_drop_write_back.rs
+++ b/crates/stella-core/tests/hard_drop_write_back.rs
@@ -22,34 +22,12 @@ use serde_json::Value;
use stella_core::budget::BudgetGuard;
use stella_core::event_sender::EventSender;
use stella_core::ports::ToolExecutor;
-use stella_core::retry::Sleeper;
use stella_core::{Engine, EngineConfig, TurnCapabilities};
use stella_protocol::{
BudgetMode, CompletionMessage, CompletionRequestRef, CompletionResult, CompletionUsage,
MessageRole, Provider, ProviderError, ToolCall, ToolOutput, ToolSchema,
};
-
-/// A `Sleeper` on tokio's clock. The tool below hangs for an hour, and the
-/// engine's tool timeout is that same clock racing it: a sleeper that
-/// returned at once would time the tool out on its first poll and the turn
-/// this test drops mid-tool would already be over.
-struct TokioSleeper;
-#[async_trait]
-impl Sleeper for TokioSleeper {
- async fn sleep(&self, duration_ms: u64) {
- tokio::time::sleep(Duration::from_millis(duration_ms)).await;
- }
-
- fn now(&self) -> std::time::Instant {
- std::time::Instant::now()
- }
-
- // The floor: a test that asserts on retry timing wants no spread in it.
-
- fn jitter(&self, _upper: u64) -> u64 {
- 0
- }
-}
+use stella_time::test_util::PausedSleeper;
/// Always answers with the same single tool call, so the turn reaches tool
/// dispatch and parks there.
@@ -108,7 +86,7 @@ impl ToolExecutor for WedgedTool {
async fn dropping_a_turn_mid_tool_still_leaves_the_partial_history_with_the_caller() {
let provider = AlwaysCallsATool;
let tools = WedgedTool;
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
diff --git a/crates/stella-core/tests/parallel_dispatch.rs b/crates/stella-core/tests/parallel_dispatch.rs
index 73b2052064..bdc081f6c5 100644
--- a/crates/stella-core/tests/parallel_dispatch.rs
+++ b/crates/stella-core/tests/parallel_dispatch.rs
@@ -25,35 +25,14 @@ use async_trait::async_trait;
use serde_json::Value;
use stella_core::budget::BudgetGuard;
use stella_core::ports::ToolExecutor;
-use stella_core::retry::Sleeper;
use stella_core::{Engine, EngineConfig, TurnCapabilities, TurnOutcome};
use stella_protocol::{
BudgetMode, CompletionMessage, CompletionRequestRef, CompletionResult, CompletionUsage,
Provider, ProviderError, ToolCall, ToolOutput, ToolSchema,
};
+use stella_time::test_util::PausedSleeper;
use tokio::sync::mpsc;
-/// A `Sleeper` on tokio's clock. The engine's tool timeout is this sleep racing
-/// the dispatch, and the barrier test below needs a parked tool to stay
-/// parked: a sleeper that returned at once would time it out on the first
-/// poll and hand the test a completion it must not see.
-struct TokioSleeper;
-#[async_trait]
-impl Sleeper for TokioSleeper {
- async fn sleep(&self, duration_ms: u64) {
- tokio::time::sleep(std::time::Duration::from_millis(duration_ms)).await;
- }
-
- // The floor: a test that asserts on retry timing wants no spread in it.
- fn now(&self) -> std::time::Instant {
- std::time::Instant::now()
- }
-
- fn jitter(&self, _upper: u64) -> u64 {
- 0
- }
-}
-
/// First call: one step carrying two sibling `delegate` calls. Second call: done.
struct TwoSpawnsThenDone {
calls: std::sync::atomic::AtomicU32,
@@ -140,7 +119,7 @@ async fn sibling_delegate_calls_in_one_step_execute_concurrently() {
let tools = BarrierSpawns {
barrier: tokio::sync::Barrier::new(2),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
@@ -251,7 +230,7 @@ async fn a_mutating_call_between_spawns_keeps_its_barrier() {
let tools = BarrierSpawnsAndEdit {
barrier: tokio::sync::Barrier::new(2),
};
- let sleeper = TokioSleeper;
+ let sleeper = PausedSleeper;
let seams = TurnCapabilities::none();
let engine = Engine::assemble(&provider, &tools, EngineConfig::default(), &sleeper, seams);
let mut messages = vec![
diff --git a/crates/stella-core/tests/spend_gate.rs b/crates/stella-core/tests/spend_gate.rs
index afe3dd7178..c743be30ae 100644
--- a/crates/stella-core/tests/spend_gate.rs
+++ b/crates/stella-core/tests/spend_gate.rs
@@ -33,12 +33,13 @@ use stella_core::hooks::{
HookAction, HookExecError, HookExecResult, HookMatcher, HookRunner, Hooks,
};
use stella_core::ports::{FallbackResolver, ResolvedFallback, ToolExecutor};
-use stella_core::retry::{ParkPlan, Sleeper, plan_park};
+use stella_core::retry::{ParkPlan, plan_park};
use stella_core::{Engine, EngineConfig, TurnCapabilities, TurnOutcome};
use stella_protocol::{
BudgetMode, CompletionMessage, CompletionRequestRef, CompletionResult, CompletionUsage,
Provider, ProviderError, ToolCall, ToolOutput, ToolSchema,
};
+use stella_time::test_util::NoopSleeper;
use tokio::sync::Mutex as TokioMutex;
use tokio::sync::mpsc;
@@ -58,22 +59,6 @@ struct Spend {
cost_usd: f64,
}
-/// A `Sleeper` that never waits, so a retry ladder costs no test time.
-struct NoopSleeper;
-#[async_trait]
-impl Sleeper for NoopSleeper {
- async fn sleep(&self, _duration_ms: u64) {}
-
- // The floor: a test that asserts on retry timing wants no spread in it.
- fn now(&self) -> std::time::Instant {
- std::time::Instant::now()
- }
-
- fn jitter(&self, _upper: u64) -> u64 {
- 0
- }
-}
-
/// A scripted `Provider`: one entry per call, repeating the last entry once
/// the script runs out. Counts every attempt it is handed.
///
diff --git a/crates/stella-core/tests/tool_wall_clock.rs b/crates/stella-core/tests/tool_wall_clock.rs
index fa5a79996d..642e737762 100644
--- a/crates/stella-core/tests/tool_wall_clock.rs
+++ b/crates/stella-core/tests/tool_wall_clock.rs
@@ -26,29 +26,14 @@ use async_trait::async_trait;
use serde_json::Value;
use stella_core::budget::BudgetGuard;
use stella_core::ports::ToolExecutor;
-use stella_core::retry::Sleeper;
use stella_core::{Engine, EngineConfig, TurnCapabilities, TurnOutcome};
use stella_protocol::{
AgentEvent, BudgetMode, CompletionMessage, CompletionRequestRef, CompletionResult,
CompletionUsage, Provider, ProviderError, ToolCall, ToolOutput, ToolSchema,
};
+use stella_time::test_util::NoopSleeper;
use tokio::sync::mpsc;
-struct NoopSleeper;
-#[async_trait]
-impl Sleeper for NoopSleeper {
- async fn sleep(&self, _duration_ms: u64) {}
-
- // The floor: a test that asserts on retry timing wants no spread in it.
- fn now(&self) -> std::time::Instant {
- std::time::Instant::now()
- }
-
- fn jitter(&self, _upper: u64) -> u64 {
- 0
- }
-}
-
/// First call: one long shell command. Second call: an answer. The turn only
/// gets there because a refusal is a result it can go on from.
struct OneShellCallThenDone {
diff --git a/crates/stella-engine/Cargo.toml b/crates/stella-engine/Cargo.toml
index 9fd69f757b..d096a32210 100644
--- a/crates/stella-engine/Cargo.toml
+++ b/crates/stella-engine/Cargo.toml
@@ -18,5 +18,7 @@ serde_json = { workspace = true }
tokio = { workspace = true, features = ["sync"] }
[dev-dependencies]
+# The shared sleeper doubles (`NoopSleeper`, `PausedSleeper`).
+stella-time = { path = "../stella-time", features = ["test-util"] }
tokio = { workspace = true, features = ["macros", "test-util"] }
async-trait = { workspace = true }
diff --git a/crates/stella-engine/src/tests.rs b/crates/stella-engine/src/tests.rs
index 633fa65212..a45c533e33 100644
--- a/crates/stella-engine/src/tests.rs
+++ b/crates/stella-engine/src/tests.rs
@@ -10,6 +10,7 @@ use std::sync::atomic::{AtomicU32, Ordering};
use async_trait::async_trait;
use serde_json::Value;
+use stella_time::test_util::NoopSleeper;
use tokio::sync::mpsc;
// Everything through `crate::`, including the three completion types that used
@@ -19,26 +20,10 @@ use tokio::sync::mpsc;
use crate::{
AgentEvent, BudgetGuard, BudgetMode, CANCELLED_REASON, CancelToken, CompletionMessage,
CompletionRequestRef, CompletionResult, CompletionUsage, Engine, EngineConfig, EventSender,
- MessageRole, Provider, ProviderError, Sleeper, StepOutcome, ToolCall, ToolExecutor, ToolOutput,
+ MessageRole, Provider, ProviderError, StepOutcome, ToolCall, ToolExecutor, ToolOutput,
ToolSchema, TurnCapabilities, TurnOutcome,
};
-/// A `Sleeper` that records but never actually waits.
-struct NoopSleeper;
-#[async_trait]
-impl Sleeper for NoopSleeper {
- async fn sleep(&self, _duration_ms: u64) {}
-
- // The floor: a test that asserts on retry timing wants no spread in it.
- fn now(&self) -> std::time::Instant {
- std::time::Instant::now()
- }
-
- fn jitter(&self, _upper: u64) -> u64 {
- 0
- }
-}
-
/// A scripted provider: one response per call, in order, looping the last
/// entry once exhausted so a runaway loop fails loudly rather than panicking.
struct ScriptedProvider {
@@ -171,9 +156,9 @@ fn event_type(event: &AgentEvent) -> String {
.unwrap_or_default()
}
-/// Context receipts are the one thing a resumed turn re-emits, because the
-/// block registry is a per-turn memo a `Checkpoint` does not
-/// carry (see `stella_core::step`). Compared separately, never mixed in.
+/// Context receipts are the one thing a resumed turn re-emits. The block
+/// registry is a per-turn memo, and a `Checkpoint` does not carry it (see
+/// `stella_core::step`). They are compared on their own, never mixed in.
fn is_receipt(event: &AgentEvent) -> bool {
matches!(
event_type(event).as_str(),
@@ -432,9 +417,9 @@ async fn a_turn_resumed_from_a_checkpoint_emits_the_same_downstream_events() {
"the downstream event stream must be indistinguishable from the un-interrupted run"
);
- // The one documented difference, asserted rather than assumed: the block
- // registry is a per-turn memo the checkpoint drops, so a resumed turn
- // re-registers the blocks the reference run had already seen.
+ // The one documented difference, asserted here. The block registry is a
+ // per-turn memo the checkpoint drops, so a resumed turn re-registers the
+ // blocks the reference run had already seen.
let reference_receipts = reference[1].iter().filter(|e| is_receipt(e)).count();
let resumed_receipts = resumed_events.iter().filter(|e| is_receipt(e)).count();
assert!(
diff --git a/crates/stella-engine/tests/embedding.rs b/crates/stella-engine/tests/embedding.rs
index be577efde7..9315bcbe03 100644
--- a/crates/stella-engine/tests/embedding.rs
+++ b/crates/stella-engine/tests/embedding.rs
@@ -41,14 +41,15 @@ use std::sync::Arc;
use std::sync::Mutex;
use async_trait::async_trait;
+use stella_time::test_util::NoopSleeper;
use tokio::sync::mpsc;
use stella_engine::{
AbortKind, AgentEvent, BudgetGuard, BudgetMode, CheckpointSink, CompletionMessage,
CompletionRequestRef, CompletionResult, CompletionUsage, DispatchAdmission, DispatchGate,
- Engine, EngineConfig, LiveService, Provider, ProviderError, RECALL_MARKER, Sleeper,
- SteeringRequery, ToolCall, ToolContract, ToolExecutor, ToolOutput, ToolSchema,
- TurnCapabilities, TurnOutcome, TurnSignal, WaitCall, WaitRequest, admit_dispatch,
+ Engine, EngineConfig, LiveService, Provider, ProviderError, RECALL_MARKER, SteeringRequery,
+ ToolCall, ToolContract, ToolExecutor, ToolOutput, ToolSchema, TurnCapabilities, TurnOutcome,
+ TurnSignal, WaitCall, WaitRequest, admit_dispatch,
};
/// The one tool the host advertises. Its only job is to make the model's first
@@ -304,22 +305,6 @@ async fn a_host_can_wrap_its_tool_surface_without_dropping_what_the_port_forward
);
}
-struct NoopSleeper;
-
-#[async_trait]
-impl Sleeper for NoopSleeper {
- async fn sleep(&self, _duration_ms: u64) {}
-
- // The floor: a test that asserts on retry timing wants no spread in it.
- fn now(&self) -> std::time::Instant {
- std::time::Instant::now()
- }
-
- fn jitter(&self, _upper: u64) -> u64 {
- 0
- }
-}
-
/// What one consult of the re-query port was shown, owned so the port can keep
/// it past the borrowed [`TurnSignal`]'s lifetime.
#[derive(Debug, Clone)]
diff --git a/crates/stella-fleet/Cargo.toml b/crates/stella-fleet/Cargo.toml
index 3270a48f47..2d440d84d1 100644
--- a/crates/stella-fleet/Cargo.toml
+++ b/crates/stella-fleet/Cargo.toml
@@ -15,6 +15,8 @@ publish.workspace = true
stella-autonomy = { path = "../stella-autonomy" }
stella-protocol = { path = "../stella-protocol" }
stella-core = { path = "../stella-core" }
+# The real sleeper and clocks behind stella-core's time ports.
+stella-time = { path = "../stella-time" }
stella-store = { path = "../stella-store" }
stella-tools = { path = "../stella-tools" }
serde.workspace = true
diff --git a/crates/stella-fleet/src/monitor.rs b/crates/stella-fleet/src/monitor.rs
index f0907be713..1ac67d663d 100644
--- a/crates/stella-fleet/src/monitor.rs
+++ b/crates/stella-fleet/src/monitor.rs
@@ -142,24 +142,13 @@ async fn run_with_timeout(
// Sleeper (deferred-wait pacing; injectable so caps are testable)
-/// The pacing seam for the poll loop — real impl sleeps, the test impl
-/// advances the injected [`Clock`] instead so a 2h cap is proven in
+/// The pacing seam for the poll loop: the engine's own [`Sleeper`] port, so
+/// the fleet keeps no second one. The real impl sleeps; the test impl
+/// advances the injected [`Clock`] instead, so a 2h cap is proven in
/// microseconds.
-#[async_trait]
-pub trait Sleeper: Send + Sync {
- async fn sleep(&self, ms: u64);
-}
-
-/// Production [`Sleeper`] — a real `tokio` sleep.
-#[derive(Debug, Clone, Copy, Default)]
-pub struct TokioSleeper;
-
-#[async_trait]
-impl Sleeper for TokioSleeper {
- async fn sleep(&self, ms: u64) {
- tokio::time::sleep(Duration::from_millis(ms)).await;
- }
-}
+pub use stella_core::retry::Sleeper;
+/// Production [`Sleeper`] — a real `tokio` sleep, from `stella-time`.
+pub use stella_time::TokioSleeper;
// Errors
@@ -837,6 +826,16 @@ mod tests {
async fn sleep(&self, ms: u64) {
self.0.fetch_add(ms, Ordering::SeqCst);
}
+
+ // The monitor reads the injected `Clock`, never this: the port's
+ // `now` and `jitter` exist for the engine's retry ladder.
+ fn now(&self) -> std::time::Instant {
+ std::time::Instant::now()
+ }
+
+ fn jitter(&self, _upper: u64) -> u64 {
+ 0
+ }
}
/// A scripted `gh`: records calls, pops one response per call, and
diff --git a/crates/stella-runtime/Cargo.toml b/crates/stella-runtime/Cargo.toml
index fa74fd3432..f5f1258fd6 100644
--- a/crates/stella-runtime/Cargo.toml
+++ b/crates/stella-runtime/Cargo.toml
@@ -11,6 +11,8 @@ publish.workspace = true
[dependencies]
stella-protocol = { path = "../stella-protocol" }
stella-core = { path = "../stella-core" }
+# The real sleeper and clocks behind stella-core's time ports.
+stella-time = { path = "../stella-time" }
stella-model = { path = "../stella-model" }
stella-tools = { path = "../stella-tools" }
stella-store = { path = "../stella-store" }
diff --git a/crates/stella-runtime/src/wrapper/dispatch.rs b/crates/stella-runtime/src/wrapper/dispatch.rs
index 5a101e803b..a0ca54d733 100644
--- a/crates/stella-runtime/src/wrapper/dispatch.rs
+++ b/crates/stella-runtime/src/wrapper/dispatch.rs
@@ -83,7 +83,7 @@ use stella_plugin::{
use stella_protocol::completion::CompletionMessage;
use stella_protocol::{GateBoard, LadderRung, LadderSnapshot, VerdictEvidence};
-use super::stamp::{HostClock, StampTiming};
+use super::stamp::StampTiming;
use super::{
ArbiterClaim, Arbitration, TurnHoldBudget, TurnWrapper, WrapperError, admissible, again,
fold_stamps, judge, stamp,
@@ -585,7 +585,7 @@ impl WrapperDispatch {
hold_grant: composition.hold_grant,
host_max_holds: DEFAULT_HOST_MAX_HOLDS,
context: None,
- clock: Arc::new(HostClock),
+ clock: Arc::new(stella_time::WallClock),
})
}
diff --git a/crates/stella-runtime/src/wrapper/stamp.rs b/crates/stella-runtime/src/wrapper/stamp.rs
index 34a0281fa1..b2c67b9a43 100644
--- a/crates/stella-runtime/src/wrapper/stamp.rs
+++ b/crates/stella-runtime/src/wrapper/stamp.rs
@@ -13,9 +13,8 @@
//!
//! [`stamped`] reads no clock and touches no file. The caller passes the two
//! times in, which is what lets a test pin a whole stamp to the byte.
-//! [`HostClock`] is the source a real run passes them from.
+//! `stella-time`'s `WallClock` is the source a real run passes them from.
-use stella_core::ports::Clock;
use stella_plugin::{
EvidenceProvenance, EvidenceSet, FlipObservation, TamperFinding, UndecidedReason, Verdict,
VerdictRule,
@@ -28,28 +27,6 @@ use super::arbitration::ArbiterClaim;
/// The name a stamp carries when the host reached the answer itself.
pub const HOST_AUTHOR: &str = "engine";
-/// The host's own clock, counting from the Unix epoch.
-///
-/// Two stamps are compared across runs and across machines, so they have to
-/// count from a shared start. A clock that counts from the moment a process
-/// began would make the gap between two stamps mean nothing. `stella-cli`'s
-/// `WallClock` answers the same port the same way and for the same reason.
-///
-/// A system clock set before the epoch reads as `0` rather than failing: a
-/// wrong time is a bad stamp, and a run that stops for one is worse.
-#[derive(Debug, Default, Clone, Copy)]
-pub struct HostClock;
-
-impl Clock for HostClock {
- fn now_ms(&self) -> u64 {
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .map_or(0, |since| {
- u64::try_from(since.as_millis()).unwrap_or(u64::MAX)
- })
- }
-}
-
/// When an observer decided, and how long it took.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct StampTiming {
@@ -182,8 +159,8 @@ pub fn stamped(
/// verdict could not say whether a check answered, stood aside, or was never
/// heard from. This is what carries them.
///
-/// They go **ahead** of the arbiter's stamp, because they arrived first and
-/// the list is arrival order — the same order the fold's rows are in, so a
+/// They go **ahead** of the arbiter's stamp. They arrived first, and the
+/// list is in arrival order. That is the order of the fold's rows too, so a
/// reader of the record and a reader of the fold see one sequence.
///
/// Every stamp on one record shares a hash. The preimage drops the stamp
diff --git a/crates/stella-serve/Cargo.toml b/crates/stella-serve/Cargo.toml
index 945ffd700f..72509abb31 100644
--- a/crates/stella-serve/Cargo.toml
+++ b/crates/stella-serve/Cargo.toml
@@ -27,6 +27,8 @@ schema = ["dep:schemars", "stella-protocol/schema"]
schemars = { workspace = true, optional = true }
stella-protocol = { path = "../stella-protocol" }
stella-core = { path = "../stella-core" }
+# The real sleeper and clocks behind stella-core's time ports.
+stella-time = { path = "../stella-time" }
# The step-scoped facade (#971 phase 1). `stella-serve` drives `run_step`
# rather than `Engine::run_turn` so a turn can be cancelled at a step
# boundary and checkpointed between steps (#1129).
diff --git a/crates/stella-serve/src/extensions.rs b/crates/stella-serve/src/extensions.rs
index d1262bb28e..fddf5f48e0 100644
--- a/crates/stella-serve/src/extensions.rs
+++ b/crates/stella-serve/src/extensions.rs
@@ -144,7 +144,7 @@ pub(crate) fn install_for_turn(
if extensions.is_empty() {
return None;
}
- let bus = HookBus::new(turn_id, crate::remote::WallClock);
+ let bus = HookBus::new(turn_id, stella_time::WallClock);
for extension in extensions {
extension.install(&bus);
}
diff --git a/crates/stella-serve/src/remote.rs b/crates/stella-serve/src/remote.rs
index 085915eb08..ba3514a91b 100644
--- a/crates/stella-serve/src/remote.rs
+++ b/crates/stella-serve/src/remote.rs
@@ -16,14 +16,10 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use async_trait::async_trait;
-use rand::RngExt;
use serde_json::Value;
use stella_core::bus::{self, HookBus, HookEventDraft, names as hook_names};
use stella_core::hooks::decision::{GateVerdict, OperatorPosture, resolve_precedence};
-use stella_core::ports::{
- AuthzGate, Clock, DispatchAdmission, DispatchGate, Principal, ToolExecutor,
-};
-use stella_core::retry::Sleeper;
+use stella_core::ports::{AuthzGate, DispatchAdmission, DispatchGate, Principal, ToolExecutor};
use stella_protocol::{
CompletionRequestRef, CompletionResult, Provider, ProviderError, ToolCallObserver, ToolOutput,
ToolSchema,
@@ -129,47 +125,6 @@ fn forward_delta(observer: Option<&dyn ToolCallObserver>, delta: &ProviderDelta)
}
}
-/// A Tokio-backed [`Sleeper`] for the session runtime's retry backoff. The
-/// session runtime is built with the time driver enabled, so `sleep` resolves
-/// there. The jitter draws from the OS entropy pool, which is the host's to
-/// hold: `stella-core` takes the draw through the port and links no entropy
-/// source of its own.
-pub(crate) struct TokioSleeper;
-
-#[async_trait]
-impl Sleeper for TokioSleeper {
- async fn sleep(&self, duration_ms: u64) {
- tokio::time::sleep(std::time::Duration::from_millis(duration_ms)).await;
- }
-
- fn now(&self) -> std::time::Instant {
- std::time::Instant::now()
- }
-
- fn jitter(&self, upper: u64) -> u64 {
- rand::rng().random_range(0..=upper)
- }
-}
-
-/// The host's own clock, counting from the Unix epoch, for the stamp on
-/// every hook event an installed extension sees. Those stamps are read on
-/// the other side of the wire and compared with the host's own, so they have
-/// to share the host's origin; `stella-cli`'s `WallClock` answers the same
-/// port the same way. A system clock set before the epoch reads as `0`
-/// rather than failing the turn.
-#[derive(Debug, Default, Clone, Copy)]
-pub(crate) struct WallClock;
-
-impl Clock for WallClock {
- fn now_ms(&self) -> u64 {
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .map_or(0, |since| {
- u64::try_from(since.as_millis()).unwrap_or(u64::MAX)
- })
- }
-}
-
/// The `Provider` port as a reverse-RPC to the host. `complete_ref` emits a
/// [`ServerFrame::ProviderRequest`] and blocks the step on the host's answer.
pub(crate) struct RemoteProvider {
@@ -235,12 +190,12 @@ impl RemoteProvider {
impl RemoteProvider {
/// The one remoted completion path, shared by both trait methods.
///
- /// With an observer this is what closes #1165: fragments the host POSTs to
- /// `/v1/turns/{id}/provider-delta` land on the registered feed and are
+ /// With an observer this is what closes #1165. Fragments the host POSTs to
+ /// `/v1/turns/{id}/provider-delta` land on the registered feed. They are
/// forwarded inline, so the engine's gate emits `TextDelta` / `Reasoning`
- /// events and the frames flow into `FrameHistory` — ordering, `seq`, and
- /// replay for free, exactly as with a local streaming adapter. Without an
- /// observer the fragments are drained and dropped; the aggregated result
+ /// events and the frames flow into `FrameHistory`. Ordering, `seq` and
+ /// replay come for free, as with a local streaming adapter. Without an
+ /// observer the fragments are drained and dropped. The aggregated result
/// stays authoritative either way.
async fn complete_remoted(
&self,
diff --git a/crates/stella-serve/src/session.rs b/crates/stella-serve/src/session.rs
index df0e46264f..e485f0a5f6 100644
--- a/crates/stella-serve/src/session.rs
+++ b/crates/stella-serve/src/session.rs
@@ -42,9 +42,8 @@ use crate::observe::SharedObserver;
use crate::observe::event::{ServeEvent, SettledOutcome, TurnRef, TurnTally, millis};
use crate::observe::tally::TallyFold;
use crate::pending::Pending;
-use crate::remote::{
- DEFAULT_REVERSE_REQUEST_TIMEOUT, RemoteProvider, RemoteToolExecutor, TokioSleeper,
-};
+use crate::remote::{DEFAULT_REVERSE_REQUEST_TIMEOUT, RemoteProvider, RemoteToolExecutor};
+use stella_time::TokioSleeper;
/// Everything needed to run one turn. The host assembles this — it owns prompt
/// construction, recall, model selection, and the tool set (advertised as
diff --git a/crates/stella-serve/src/subagents.rs b/crates/stella-serve/src/subagents.rs
index b94b6c0ee8..20e4807aec 100644
--- a/crates/stella-serve/src/subagents.rs
+++ b/crates/stella-serve/src/subagents.rs
@@ -270,7 +270,7 @@ impl SubAgentDispatcher for ServedSubAgents {
&*provider,
&read_only,
config,
- &crate::remote::TokioSleeper,
+ &stella_time::TokioSleeper,
seams,
);
// Carve from the shared pool, run, settle. The lock is
diff --git a/crates/stella-time/Cargo.toml b/crates/stella-time/Cargo.toml
new file mode 100644
index 0000000000..89e5984e7f
--- /dev/null
+++ b/crates/stella-time/Cargo.toml
@@ -0,0 +1,30 @@
+[package]
+name = "stella-time"
+description = "The real time sources behind stella-core's Sleeper and Clock ports — one Tokio sleeper, one wall clock, one monotonic clock — shared by every host that assembles an engine, none of which may depend on another; plus, behind `test-util`, the two sleeper doubles every test against the engine shares."
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+repository.workspace = true
+publish.workspace = true
+
+# The ports come from stella-core; the effects behind them come from the
+# runtime and the entropy pool. stella-core may link neither (its manifest
+# says so, and `make core-no-io` holds it there), and stella-serve may not
+# link stella-cli or stella-runtime, so the copies these replaced had no
+# home below all of their callers until this crate.
+[dependencies]
+stella-core = { path = "../stella-core" }
+tokio = { workspace = true, features = ["time"] }
+async-trait = { workspace = true }
+rand = { workspace = true }
+
+[features]
+# The two sleeper doubles every test against the engine shares: `NoopSleeper`
+# and `PausedSleeper` (`test_util`). Off in a release build. This is the
+# tokio-test shape: the doubles live beside the real sources, and the crate
+# under test takes them as a dev-dependency.
+test-util = []
+
+[dev-dependencies]
+tokio = { workspace = true, features = ["rt", "macros", "test-util"] }
diff --git a/crates/stella-time/README.md b/crates/stella-time/README.md
new file mode 100644
index 0000000000..55e5c50e91
--- /dev/null
+++ b/crates/stella-time/README.md
@@ -0,0 +1,80 @@
+# stella-time
+
+The real time sources behind `stella-core`'s two time ports, and the two
+test doubles every test against the engine shares.
+
+```rust
+stella_time::TokioSleeper // Sleeper: tokio::time::sleep, Instant::now, jitter from the OS
+stella_time::WallClock // Clock: milliseconds since the Unix epoch
+stella_time::MonotonicClock // Clock: milliseconds since this process's first read, never backwards
+
+stella_time::test_util::PausedSleeper // Sleeper on tokio's paused clock (feature `test-util`)
+stella_time::test_util::NoopSleeper // Sleeper that never waits (feature `test-util`)
+```
+
+`stella_core::retry::Sleeper` and `stella_core::ports::Clock` are the ports.
+The engine reads time, waits and times out only through them (ADR 0042). A
+host picks a source here and hands it to `Engine::assemble`.
+
+## Which source
+
+- **The engine, in a real run:** `TokioSleeper`. Its `now` is the monotonic
+ clock, so a deadline armed from it is checked on the same timeline.
+- **A stamp read by another process or a later run:** `WallClock`. A hook
+ event's stamp, a fleet ledger row, a verdict stamp. Two such stamps must
+ count from one start. The epoch is the only start every process agrees
+ on.
+- **A span measured inside one process and compared as a number:**
+ `MonotonicClock`. A circuit breaker's cooling window, a CI monitor's wall
+ cap. Every instance shares one origin, so two holders built at different
+ moments read one timeline.
+- **A test that arms a timeout:** `PausedSleeper`, on a paused tokio
+ runtime. A sleep is free while the runtime is idle and still lets a
+ pending call finish first. A sleeper that returned at once would fire
+ every engine timeout the moment a provider future waited on another task.
+- **A test that arms none:** `NoopSleeper`.
+
+## Boundary — does this change belong here?
+
+This crate implements the ports. It decides nothing. A change belongs here
+only if it is a new way to read the real clock, wait on the real timer, or
+stand in for either under test. What to do with a reading belongs in
+`stella-core`, where it is a pure function over the reading. A double with
+a special shape, such as a seeded jitter or a sleep that records its calls,
+belongs beside the one test that needs it.
+
+## Why it is a crate
+
+AGENTS.md § "When a new crate is justified" names two of its three cases.
+These items are the effects the ports keep out of `stella-core`. Its
+manifest may not take the Tokio timer or an entropy source, and
+`make core-no-io` holds it there. And they sit below every host.
+`stella-cli`, `stella-runtime` and `stella-serve` each build an engine, and
+`stella-serve` may not link the other two. Before this crate each host kept
+its own copy, and about thirty test files each kept a double.
+
+`test_util` lives here and not in `stella-core` for the same reason: a
+faithful sleeper double needs the Tokio timer, and `stella-core` may not
+link it. `stella-core`'s integration tests take this crate as a
+dev-dependency, which cargo allows in a cycle. That is the tokio /
+tokio-test shape. `stella-core`'s own unit tests keep one copy,
+`tests`, because a lib's unit tests are a second build of the lib
+and a dev-dependency that links the lib implements the trait for the
+first. `tests/one_home.rs` names that copy and fails on any other.
+
+## God files — do not add lines
+
+This crate has no god files. No file exceeds the gate's 1500-line ratchet
+(`scripts/check-file-size.sh`), and none may appear. A new file crossing
+1500 lines fails the gate outright, and `scripts/file-size-baseline.txt`
+accepts no new entries. When a file here nears the limit, split it before
+it crosses.
+
+## Consumers
+
+- `stella-cli` re-exports the three real sources from its `runtime` module.
+- `stella-serve` builds its engines on `TokioSleeper` and stamps its
+ extension bus from `WallClock`.
+- `stella-runtime` stamps wrapper verdicts from `WallClock`.
+- `stella-fleet` paces its CI monitor with `TokioSleeper`.
+- `stella-core` and `stella-engine` test against `test_util`.
diff --git a/crates/stella-time/src/lib.rs b/crates/stella-time/src/lib.rs
new file mode 100644
index 0000000000..8e222925cc
--- /dev/null
+++ b/crates/stella-time/src/lib.rs
@@ -0,0 +1,164 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright (c) 2026 Oxagen, Inc. Commercial licensing: licensing@oxagen.sh
+
+//! The real time sources behind `stella-core`'s two time ports.
+//!
+//! [`stella_core::retry::Sleeper`] and [`stella_core::ports::Clock`] are
+//! traits. The engine reads time, waits and times out only through them. A
+//! real run gets its answers here: a sleeper on the Tokio timer, a clock
+//! counting from the Unix epoch, and one counting from the first read. The
+//! README says why the three share one crate below every host, and
+//! `test_util` holds the two doubles every test against the engine shares.
+
+use std::sync::OnceLock;
+use std::time::{Instant, SystemTime, UNIX_EPOCH};
+
+use async_trait::async_trait;
+use rand::RngExt;
+use stella_core::ports::Clock;
+use stella_core::retry::Sleeper;
+
+#[cfg(feature = "test-util")]
+pub mod test_util;
+
+/// The real [`Sleeper`]. It waits on `tokio::time::sleep`, reads `now` off
+/// the monotonic clock, and draws its jitter from the OS entropy pool, which
+/// spreads retriers across the backoff window.
+///
+/// It needs a Tokio runtime with its time driver on. Every host here builds
+/// one.
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
+pub struct TokioSleeper;
+
+#[async_trait]
+impl Sleeper for TokioSleeper {
+ async fn sleep(&self, duration_ms: u64) {
+ tokio::time::sleep(std::time::Duration::from_millis(duration_ms)).await;
+ }
+
+ fn now(&self) -> Instant {
+ Instant::now()
+ }
+
+ fn jitter(&self, upper: u64) -> u64 {
+ rand::rng().random_range(0..=upper)
+ }
+}
+
+/// A [`Clock`] counting milliseconds from the Unix epoch.
+///
+/// For a stamp read by another process or a later run: a hook event on the
+/// far side of a socket, a fleet ledger row, a verdict stamp. Two such
+/// stamps must count from one start. The epoch is the only start every
+/// process agrees on.
+///
+/// Not for a deadline. The system clock can be stepped, by NTP or by hand.
+/// A deadline set against it moves with it. The engine's deadlines come off
+/// [`Sleeper::now`] instead.
+///
+/// A system clock set before the epoch reads as `0`. One past what `u64`
+/// holds reads as `u64::MAX`. A wrong time makes a bad stamp. A run that
+/// stops for one is worse.
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
+pub struct WallClock;
+
+impl Clock for WallClock {
+ fn now_ms(&self) -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map_or(0, |since| {
+ u64::try_from(since.as_millis()).unwrap_or(u64::MAX)
+ })
+ }
+}
+
+/// The origin every [`MonotonicClock`] counts from: the first read in this
+/// process. One cell, so two instances built at different moments agree.
+static ORIGIN: OnceLock = OnceLock::new();
+
+/// A [`Clock`] counting milliseconds from the process's first read of it.
+/// It never goes backwards.
+///
+/// For a span measured inside one process and compared as a number: a
+/// circuit breaker's cooling window, a CI monitor's wall cap. Every
+/// instance shares the origin, so two holders built at different moments
+/// read one timeline. A clock whose origin was the moment it was built
+/// would put their readings apart, by the time between the two builds, and
+/// say nothing.
+///
+/// The first read in the process reads `0`.
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
+pub struct MonotonicClock;
+
+impl Clock for MonotonicClock {
+ fn now_ms(&self) -> u64 {
+ let origin = ORIGIN.get_or_init(Instant::now);
+ u64::try_from(origin.elapsed().as_millis()).unwrap_or(u64::MAX)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn the_monotonic_clock_never_goes_backwards() {
+ let first = MonotonicClock.now_ms();
+ std::thread::sleep(std::time::Duration::from_millis(5));
+ let second = MonotonicClock.now_ms();
+ assert!(second >= first, "clock must never go backwards");
+ assert!(
+ second - first >= 4,
+ "5ms of sleep should register: {first} -> {second}"
+ );
+ }
+
+ /// Two instances share one origin, so a reading taken on one is a
+ /// reading on the other.
+ #[test]
+ fn every_monotonic_clock_shares_one_origin() {
+ let first = MonotonicClock;
+ let mark = first.now_ms() + 10_000;
+ std::thread::sleep(std::time::Duration::from_millis(5));
+ let second = MonotonicClock;
+ let now = second.now_ms();
+ assert!(
+ now < mark,
+ "a fresh instance reads the same clock: {now} vs {mark}"
+ );
+ assert!(
+ mark - now <= 10_000,
+ "and it has moved on from the first read"
+ );
+ }
+
+ #[test]
+ fn the_wall_clock_counts_from_the_unix_epoch() {
+ // 2026-01-01T00:00:00Z. The test runs after that date. The point is
+ // the origin, not the exact reading.
+ let epoch_2026_ms: u64 = 1_767_225_600_000;
+ assert!(WallClock.now_ms() > epoch_2026_ms);
+ }
+
+ #[test]
+ fn jitter_stays_inside_the_window() {
+ for _ in 0..1_000 {
+ assert!(TokioSleeper.jitter(7) <= 7);
+ }
+ assert_eq!(TokioSleeper.jitter(0), 0);
+ }
+
+ #[tokio::test(start_paused = true)]
+ async fn the_sleeper_waits_on_the_tokio_timer() {
+ let before = tokio::time::Instant::now();
+ TokioSleeper.sleep(250).await;
+ assert!(before.elapsed() >= std::time::Duration::from_millis(250));
+ }
+
+ #[test]
+ fn the_sleepers_now_is_the_monotonic_clock() {
+ let a = TokioSleeper.now();
+ let b = TokioSleeper.now();
+ assert!(b >= a);
+ }
+}
diff --git a/crates/stella-time/src/test_util.rs b/crates/stella-time/src/test_util.rs
new file mode 100644
index 0000000000..67b9f8349f
--- /dev/null
+++ b/crates/stella-time/src/test_util.rs
@@ -0,0 +1,64 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright (c) 2026 Oxagen, Inc. Commercial licensing: licensing@oxagen.sh
+
+//! The two [`Sleeper`] doubles every test against the engine shares.
+//!
+//! Behind the `test-util` feature, so a release build carries neither. A
+//! crate that tests the engine takes this crate as a dev-dependency with the
+//! feature on, the way tokio's tests take `tokio-test`.
+
+use std::time::Instant;
+
+use async_trait::async_trait;
+use stella_core::retry::Sleeper;
+
+/// A [`Sleeper`] on tokio's clock, for a test that runs its runtime paused.
+///
+/// A sleep costs nothing while the runtime is idle. It still lets a pending
+/// call finish first, and `now` reads the same virtual timeline. That is
+/// what a timeout is. So this is the double for any test that arms a tool
+/// or model timeout. A sleeper that returned at once would fire every
+/// engine timeout as soon as a provider future waited on another task.
+///
+/// Jitter is zero: a test that asserts on retry timing wants no spread.
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
+pub struct PausedSleeper;
+
+#[async_trait]
+impl Sleeper for PausedSleeper {
+ async fn sleep(&self, duration_ms: u64) {
+ tokio::time::sleep(std::time::Duration::from_millis(duration_ms)).await;
+ }
+
+ fn now(&self) -> Instant {
+ tokio::time::Instant::now().into_std()
+ }
+
+ fn jitter(&self, _upper: u64) -> u64 {
+ 0
+ }
+}
+
+/// A [`Sleeper`] that never waits.
+///
+/// For a test that arms no timeout and wants a retry ladder to run at once.
+/// `now` reads the real monotonic clock, so a deadline set from it still
+/// means what it says. Do not drive a timed turn with it: see
+/// [`PausedSleeper`] for why.
+///
+/// Jitter is zero, for the same reason as [`PausedSleeper`]'s.
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
+pub struct NoopSleeper;
+
+#[async_trait]
+impl Sleeper for NoopSleeper {
+ async fn sleep(&self, _duration_ms: u64) {}
+
+ fn now(&self) -> Instant {
+ Instant::now()
+ }
+
+ fn jitter(&self, _upper: u64) -> u64 {
+ 0
+ }
+}
diff --git a/crates/stella-time/tests/one_home.rs b/crates/stella-time/tests/one_home.rs
new file mode 100644
index 0000000000..32700f0da0
--- /dev/null
+++ b/crates/stella-time/tests/one_home.rs
@@ -0,0 +1,158 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright (c) 2026 Oxagen, Inc. Commercial licensing: licensing@oxagen.sh
+
+//! The witness for this crate: the real time sources and the shared sleeper
+//! doubles live here and nowhere else.
+//!
+//! Three crates each kept a wall clock. Two kept a Tokio sleeper. One kept a
+//! sleeper trait of its own. About thirty test files each wrote the same
+//! no-op double. This test reads the tree and fails on the first copy that
+//! comes back. It reads source, which a test of "where does this live" has
+//! to do. `stella-core` would refuse that read. This crate does not.
+
+use std::fs;
+use std::path::{Path, PathBuf};
+
+/// A `Sleeper` impl outside this crate that stays on purpose, with the
+/// reason. Each one has a shape a shared double cannot have.
+const SLEEPER_IMPLS_KEPT: &[(&str, &str)] = &[
+ (
+ "crates/stella-core/src/tests.rs",
+ "the copy the compiler forces: a lib's unit tests are a second build of the lib, and a \
+ dev-dependency that links the lib implements Sleeper for the first",
+ ),
+ (
+ "crates/stella-core/src/retry.rs",
+ "the retry tests' recording sleeper: it logs every requested delay and draws a seeded jitter",
+ ),
+ (
+ "crates/stella-fleet/src/monitor.rs",
+ "the monitor tests' advancing sleeper: a sleep moves the injected clock instead of waiting",
+ ),
+ (
+ "crates/stella-core/src/driver/tests/audit_fixes.rs",
+ "the hanging sleeper: it announces its first sleep and then parks forever, for a test that drops a turn mid-backoff",
+ ),
+ (
+ "crates/stella-core/src/step/tests.rs",
+ "a sleeper on unpaused tokio time: the bound tests race a trickling call against a sleep that has to take time",
+ ),
+];
+
+/// A clock struct named like one of ours that is not ours.
+const CLOCK_STRUCTS_KEPT: &[(&str, &str)] = &[(
+ "crates/stella-context/src/clock.rs",
+ "stella-context's own Clock trait, which reads RFC 3339 text rather than milliseconds",
+)];
+
+fn workspace_root() -> PathBuf {
+ Path::new(env!("CARGO_MANIFEST_DIR"))
+ .ancestors()
+ .nth(2)
+ .expect("crates/stella-time sits two levels below the workspace root")
+ .to_path_buf()
+}
+
+/// Every `.rs` file under `crates/*/src` and `crates/*/tests`, as paths
+/// relative to the workspace root, skipping this crate.
+fn rust_sources(root: &Path) -> Vec<(String, String)> {
+ let mut out = Vec::new();
+ let crates = root.join("crates");
+ for entry in fs::read_dir(&crates).expect("crates/ is readable") {
+ let dir = entry.expect("a crate directory").path();
+ if dir.file_name().is_some_and(|name| name == "stella-time") {
+ continue;
+ }
+ for sub in ["src", "tests"] {
+ collect(&dir.join(sub), root, &mut out);
+ }
+ }
+ out.sort();
+ out
+}
+
+fn collect(dir: &Path, root: &Path, out: &mut Vec<(String, String)>) {
+ let Ok(entries) = fs::read_dir(dir) else {
+ return;
+ };
+ for entry in entries {
+ let path = entry.expect("a directory entry").path();
+ if path.is_dir() {
+ collect(&path, root, out);
+ } else if path.extension().is_some_and(|ext| ext == "rs") {
+ let rel = path
+ .strip_prefix(root)
+ .expect("under the root")
+ .to_string_lossy()
+ .replace('\\', "/");
+ let text = fs::read_to_string(&path).expect("a source file is readable");
+ out.push((rel, text));
+ }
+ }
+}
+
+fn is_kept(path: &str, kept: &[(&str, &str)]) -> bool {
+ kept.iter().any(|(kept_path, _)| *kept_path == path)
+}
+
+#[test]
+fn every_sleeper_impl_outside_this_crate_is_one_a_shared_double_cannot_be() {
+ let root = workspace_root();
+ let mut strays = Vec::new();
+ for (path, text) in rust_sources(&root) {
+ let impls = text
+ .lines()
+ .filter(|line| {
+ let line = line.trim_start();
+ line.starts_with("impl ")
+ && line.contains("Sleeper for ")
+ && !line.contains("ParkSupervisor")
+ })
+ .count();
+ if impls > 0 && !is_kept(&path, SLEEPER_IMPLS_KEPT) {
+ strays.push(format!("{path}: {impls} impl(s)"));
+ }
+ }
+ assert!(
+ strays.is_empty(),
+ "a Sleeper impl outside stella-time is a copy of one it already has: take \
+ `stella_time::TokioSleeper`, or `stella_time::test_util::{{PausedSleeper, NoopSleeper}}` \
+ behind the `test-util` feature. Found:\n {}",
+ strays.join("\n ")
+ );
+}
+
+#[test]
+fn every_kept_sleeper_impl_is_still_there() {
+ let root = workspace_root();
+ for (path, reason) in SLEEPER_IMPLS_KEPT {
+ let text = fs::read_to_string(root.join(path)).expect("a kept path exists");
+ assert!(
+ text.contains("Sleeper for "),
+ "{path} has no Sleeper impl; drop it from SLEEPER_IMPLS_KEPT ({reason})"
+ );
+ }
+}
+
+#[test]
+fn no_crate_keeps_its_own_wall_or_monotonic_clock() {
+ let root = workspace_root();
+ let names = ["WallClock", "HostClock", "SystemClock", "MonotonicClock"];
+ let mut strays = Vec::new();
+ for (path, text) in rust_sources(&root) {
+ if is_kept(&path, CLOCK_STRUCTS_KEPT) {
+ continue;
+ }
+ for name in names {
+ let needle = format!("struct {name}");
+ if text.contains(&needle) {
+ strays.push(format!("{path}: {needle}"));
+ }
+ }
+ }
+ assert!(
+ strays.is_empty(),
+ "a clock struct outside stella-time is a copy of one it already has. Found:\n {}",
+ strays.join("\n ")
+ );
+}
diff --git a/docs/adr/0042-the-engine-reads-time-through-the-sleeper-port.md b/docs/adr/0042-the-engine-reads-time-through-the-sleeper-port.md
new file mode 100644
index 0000000000..dcbfd14c3a
--- /dev/null
+++ b/docs/adr/0042-the-engine-reads-time-through-the-sleeper-port.md
@@ -0,0 +1,102 @@
+---
+id: adr/0042-the-engine-reads-time-through-the-sleeper-port
+title: "ADR 0042: The engine reads time through the Sleeper port"
+status: implemented
+---
+
+# ADR 0042: The engine reads time through the Sleeper port
+
+- Status: accepted
+- Date: 2026-09-10
+- Decides: `#6486`, `#6484`
+- Not part of the Phase 0 series.
+
+## Context
+
+`stella-core` has had a `Clock` port since the router's circuit breaker
+needed one. It reads `now_ms() -> u64`. One module used it. The deadline
+code read `std::time::Instant::now()` itself, in nineteen places, and nine
+more read `.elapsed()`, which is the same call in disguise. Four timeouts
+went to `tokio::time::timeout` directly. `#6482` counted the plain reads
+into a down-only baseline instead of removing them.
+
+A seam nobody routes through is a claim. A test that wanted to see a
+deadline fire had to arm a real one and wait. A turn could not be replayed
+from its record. And the prose about the crate's time source drifted from
+the code twice.
+
+Two sessions fixed this at once. One made every instant a `u64` reading of
+`Clock`, with a hand-moved clock as the double. The other put `now` on
+`Sleeper` and routed the timeouts through it. The second merged as
+`#6486`. This record states that decision and why it holds.
+
+The three hosts that build an engine each kept their own copy of the real
+sources. A wall clock lived in `stella-cli`, `stella-runtime` and
+`stella-serve`. A Tokio sleeper lived in two of them, and `stella-fleet`
+had a sleeper trait of its own. About thirty test files each wrote the same
+no-op sleeper. `stella-serve` may not link the other two, and `stella-core`
+may not carry a timer, so the copies had nowhere to go.
+
+## Decision
+
+**`retry::Sleeper` is the engine's whole time port.** `now() -> Instant`
+sits beside `sleep` and `jitter`. Every deadline the engine holds is an
+`Instant` from that reading. Every elapsed time is the difference of two.
+Every timeout is `retry::bounded`, a sleep racing a call through the same
+port. Nothing in the crate calls `Instant::now()`, `.elapsed()` or
+`tokio::time`; `make core-no-io` refuses all three, and its baseline is
+empty.
+
+**One port, not two, because a double cannot answer them apart.** A sleeper
+that suspends virtually has moved its own `now`. A timeout is a sleep
+racing a call. Tokio's paused runtime is the same shape: one virtual clock
+behind `sleep` and `Instant::now`. A `u64` reading on a second port would
+have made the test move two clocks and keep them in step by hand.
+
+**The reading is an `Instant`, not a `u64`.** The deadline arithmetic
+already spoke `Instant`, `tokio::time::Instant` converts to it, and a
+paused runtime produces one. The cost is that a record cannot hold an
+`Instant`. Replay against a host that answers `now` from a record is later
+work either way.
+
+**`ports::Clock` stays, for stamps.** The hook bus and the router read
+milliseconds from an epoch the holder picks, and a hook script on the far
+side of a socket compares those stamps with its own. That is a different
+question from a deadline, and it keeps its own port.
+
+**The real sources live in one crate, `stella-time`.** `TokioSleeper`
+waits on the Tokio timer, reads `now` off the monotonic clock, and draws
+its jitter from the OS entropy pool. `WallClock` counts from the Unix
+epoch, for a stamp another process reads. `MonotonicClock` counts from the
+first read, for a span compared as a number. AGENTS.md § "When a new crate
+is justified" allows the crate on two counts. It holds the effects the
+ports keep out of `stella-core`. And it sits below `stella-serve`, which
+may not link `stella-cli` or `stella-runtime`. `stella-fleet`'s own
+`Sleeper` trait is gone; its monitor takes the engine's.
+
+**The test doubles ship from `stella-time` behind a `test-util` feature.**
+`PausedSleeper` sleeps on tokio's clock and reads its `Instant`, for a test
+that arms a timeout on a paused runtime. `NoopSleeper` never waits, for a
+test that arms none. They live beside the real sources and not in
+`stella-core` because a faithful sleeper needs the Tokio timer, which
+`stella-core` may not link. `stella-core`'s integration tests and every
+other crate take it as a dev-dependency, which cargo allows in a cycle.
+That is the tokio / tokio-test shape. `stella-core`'s own unit tests keep
+one copy, `tests`, because a lib's unit tests are a second build of
+the lib and a dev-dependency that links the lib implements the trait for
+the first. The compiler forces that copy, and the witness names it.
+
+## Consequences
+
+`stella-core`'s test suite runs on a paused clock. Backoff sleeps that used
+to cost real seconds cost nothing, and a timeout still lets a pending call
+finish first.
+
+Each host passes `stella_time::TokioSleeper` to `Engine::assemble`. Each
+test passes `PausedSleeper` or `NoopSleeper`. A new double with a special
+shape, such as a seeded jitter or a sleep that records its calls, stays
+beside the one test that needs it.
+
+`stella-time`'s `one_home` test reads the tree and fails on any other
+`impl Sleeper for` in shipping code, so the copies cannot come back
+unnoticed.
diff --git a/docs/adr/README.md b/docs/adr/README.md
index 67569284c4..e6d259822f 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -108,6 +108,7 @@ open; nothing before Phase 3 forces it.
| [0039](0039-a-live-smoke-provider-is-armed-or-declared-unarmed.md) | A Live Smoke Provider Is Armed or Declared Unarmed | Accepted |
| [0040](0040-host-does-not-pick-the-tests.md) | The Host Does Not Pick the Tests | Accepted |
| [0041](0041-the-turn-clock-reaches-dispatch-by-value.md) | The Turn Clock Reaches Dispatch by Value | Accepted |
+| [0042](0042-the-engine-reads-time-through-the-sleeper-port.md) | The Engine Reads Time Through the Sleeper Port | Accepted |
ADR 0013 draws the line between what Stella owes a caller that moves a session
between machines (an artifact, a fingerprint, a version contract, a visible
@@ -256,6 +257,12 @@ a defaulted port method, because the key that carries it belongs to one tool.
A call that cannot finish and still leave room to report back is refused before
it starts, which is the only moment AGENTS.md #6 leaves.
+ADR 0042 records how the engine reads time at all: every instant, wait and
+timeout goes through `retry::Sleeper`, whose `now` joined `sleep` and `jitter`
+because one double has to answer all three from one timeline. The real sources
+live in one crate, `stella-time`, below every host, and the two test doubles
+ship from it behind a `test-util` feature.
+
## The number is a shared cell
Take the number one past the highest in the table above. Two branches doing
diff --git a/docs/manifest.json b/docs/manifest.json
index 30bf33d1b9..45739ff094 100644
--- a/docs/manifest.json
+++ b/docs/manifest.json
@@ -210,6 +210,11 @@
"status": "implemented",
"title": "ADR 0041: The turn clock reaches dispatch by value"
},
+ "adr/0042-the-engine-reads-time-through-the-sleeper-port": {
+ "path": "docs/adr/0042-the-engine-reads-time-through-the-sleeper-port.md",
+ "status": "implemented",
+ "title": "ADR 0042: The engine reads time through the Sleeper port"
+ },
"agent-monitor-protocol": {
"path": "docs/spec/agent-monitor-protocol.md",
"status": "living",
diff --git a/website/pnpm-lock.yaml b/website/pnpm-lock.yaml
index 64aa0956d0..c748daeb1f 100644
--- a/website/pnpm-lock.yaml
+++ b/website/pnpm-lock.yaml
@@ -6,7 +6,8 @@ settings:
overrides:
postcss: '>=8.5.23'
- sharp: '>=0.35.0'
+ sharp: '>=0.35.4'
+ js-yaml: ^4.3.2
nanoid: ^3.3.18
importers:
@@ -319,8 +320,8 @@ packages:
resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==}
engines: {node: '>=18.0.0'}
- '@emnapi/runtime@1.11.2':
- resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==}
+ '@emnapi/runtime@1.11.3':
+ resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
'@esbuild/aix-ppc64@0.25.4':
resolution: {integrity: sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==}
@@ -658,160 +659,160 @@ packages:
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
- '@img/sharp-darwin-arm64@0.35.3':
- resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==}
+ '@img/sharp-darwin-arm64@0.35.4':
+ resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [darwin]
- '@img/sharp-darwin-x64@0.35.3':
- resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==}
+ '@img/sharp-darwin-x64@0.35.4':
+ resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [darwin]
- '@img/sharp-freebsd-wasm32@0.35.3':
- resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==}
+ '@img/sharp-freebsd-wasm32@0.35.4':
+ resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==}
engines: {node: '>=20.9.0'}
os: [freebsd]
- '@img/sharp-libvips-darwin-arm64@1.3.2':
- resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==}
+ '@img/sharp-libvips-darwin-arm64@1.3.3':
+ resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==}
cpu: [arm64]
os: [darwin]
- '@img/sharp-libvips-darwin-x64@1.3.2':
- resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==}
+ '@img/sharp-libvips-darwin-x64@1.3.3':
+ resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==}
cpu: [x64]
os: [darwin]
- '@img/sharp-libvips-linux-arm64@1.3.2':
- resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==}
+ '@img/sharp-libvips-linux-arm64@1.3.3':
+ resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-arm@1.3.2':
- resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==}
+ '@img/sharp-libvips-linux-arm@1.3.3':
+ resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==}
cpu: [arm]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-ppc64@1.3.2':
- resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==}
+ '@img/sharp-libvips-linux-ppc64@1.3.3':
+ resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-riscv64@1.3.2':
- resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==}
+ '@img/sharp-libvips-linux-riscv64@1.3.3':
+ resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-s390x@1.3.2':
- resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==}
+ '@img/sharp-libvips-linux-s390x@1.3.3':
+ resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==}
cpu: [s390x]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-x64@1.3.2':
- resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==}
+ '@img/sharp-libvips-linux-x64@1.3.3':
+ resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linuxmusl-arm64@1.3.2':
- resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==}
+ '@img/sharp-libvips-linuxmusl-arm64@1.3.3':
+ resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@img/sharp-libvips-linuxmusl-x64@1.3.2':
- resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==}
+ '@img/sharp-libvips-linuxmusl-x64@1.3.3':
+ resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==}
cpu: [x64]
os: [linux]
libc: [musl]
- '@img/sharp-linux-arm64@0.35.3':
- resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==}
+ '@img/sharp-linux-arm64@0.35.4':
+ resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@img/sharp-linux-arm@0.35.3':
- resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==}
+ '@img/sharp-linux-arm@0.35.4':
+ resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==}
engines: {node: '>=20.9.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
- '@img/sharp-linux-ppc64@0.35.3':
- resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==}
+ '@img/sharp-linux-ppc64@0.35.4':
+ resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==}
engines: {node: '>=20.9.0'}
cpu: [ppc64]
os: [linux]
libc: [glibc]
- '@img/sharp-linux-riscv64@0.35.3':
- resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==}
+ '@img/sharp-linux-riscv64@0.35.4':
+ resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==}
engines: {node: '>=20.9.0'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
- '@img/sharp-linux-s390x@0.35.3':
- resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==}
+ '@img/sharp-linux-s390x@0.35.4':
+ resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==}
engines: {node: '>=20.9.0'}
cpu: [s390x]
os: [linux]
libc: [glibc]
- '@img/sharp-linux-x64@0.35.3':
- resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==}
+ '@img/sharp-linux-x64@0.35.4':
+ resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@img/sharp-linuxmusl-arm64@0.35.3':
- resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==}
+ '@img/sharp-linuxmusl-arm64@0.35.4':
+ resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@img/sharp-linuxmusl-x64@0.35.3':
- resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==}
+ '@img/sharp-linuxmusl-x64@0.35.4':
+ resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [musl]
- '@img/sharp-wasm32@0.35.3':
- resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==}
+ '@img/sharp-wasm32@0.35.4':
+ resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==}
engines: {node: '>=20.9.0'}
- '@img/sharp-webcontainers-wasm32@0.35.3':
- resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==}
+ '@img/sharp-webcontainers-wasm32@0.35.4':
+ resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==}
engines: {node: '>=20.9.0'}
cpu: [wasm32]
- '@img/sharp-win32-arm64@0.35.3':
- resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==}
+ '@img/sharp-win32-arm64@0.35.4':
+ resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [win32]
- '@img/sharp-win32-ia32@0.35.3':
- resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==}
+ '@img/sharp-win32-ia32@0.35.4':
+ resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==}
engines: {node: ^20.9.0}
cpu: [ia32]
os: [win32]
- '@img/sharp-win32-x64@0.35.3':
- resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==}
+ '@img/sharp-win32-x64@0.35.4':
+ resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [win32]
@@ -2176,8 +2177,8 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
- js-yaml@4.3.1:
- resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
+ js-yaml@4.3.2:
+ resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==}
hasBin: true
lightningcss-android-arm64@1.32.0:
@@ -2721,8 +2722,8 @@ packages:
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
- sharp@0.35.3:
- resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==}
+ sharp@0.35.4:
+ resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==}
engines: {node: '>=20.9.0'}
peerDependencies:
'@types/node': '*'
@@ -3501,7 +3502,7 @@ snapshots:
'@aws/lambda-invoke-store@0.3.0': {}
- '@emnapi/runtime@1.11.2':
+ '@emnapi/runtime@1.11.3':
dependencies:
tslib: 2.8.1
optional: true
@@ -3684,108 +3685,108 @@ snapshots:
'@img/colour@1.1.0':
optional: true
- '@img/sharp-darwin-arm64@0.35.3':
+ '@img/sharp-darwin-arm64@0.35.4':
optionalDependencies:
- '@img/sharp-libvips-darwin-arm64': 1.3.2
+ '@img/sharp-libvips-darwin-arm64': 1.3.3
optional: true
- '@img/sharp-darwin-x64@0.35.3':
+ '@img/sharp-darwin-x64@0.35.4':
optionalDependencies:
- '@img/sharp-libvips-darwin-x64': 1.3.2
+ '@img/sharp-libvips-darwin-x64': 1.3.3
optional: true
- '@img/sharp-freebsd-wasm32@0.35.3':
+ '@img/sharp-freebsd-wasm32@0.35.4':
dependencies:
- '@img/sharp-wasm32': 0.35.3
+ '@img/sharp-wasm32': 0.35.4
optional: true
- '@img/sharp-libvips-darwin-arm64@1.3.2':
+ '@img/sharp-libvips-darwin-arm64@1.3.3':
optional: true
- '@img/sharp-libvips-darwin-x64@1.3.2':
+ '@img/sharp-libvips-darwin-x64@1.3.3':
optional: true
- '@img/sharp-libvips-linux-arm64@1.3.2':
+ '@img/sharp-libvips-linux-arm64@1.3.3':
optional: true
- '@img/sharp-libvips-linux-arm@1.3.2':
+ '@img/sharp-libvips-linux-arm@1.3.3':
optional: true
- '@img/sharp-libvips-linux-ppc64@1.3.2':
+ '@img/sharp-libvips-linux-ppc64@1.3.3':
optional: true
- '@img/sharp-libvips-linux-riscv64@1.3.2':
+ '@img/sharp-libvips-linux-riscv64@1.3.3':
optional: true
- '@img/sharp-libvips-linux-s390x@1.3.2':
+ '@img/sharp-libvips-linux-s390x@1.3.3':
optional: true
- '@img/sharp-libvips-linux-x64@1.3.2':
+ '@img/sharp-libvips-linux-x64@1.3.3':
optional: true
- '@img/sharp-libvips-linuxmusl-arm64@1.3.2':
+ '@img/sharp-libvips-linuxmusl-arm64@1.3.3':
optional: true
- '@img/sharp-libvips-linuxmusl-x64@1.3.2':
+ '@img/sharp-libvips-linuxmusl-x64@1.3.3':
optional: true
- '@img/sharp-linux-arm64@0.35.3':
+ '@img/sharp-linux-arm64@0.35.4':
optionalDependencies:
- '@img/sharp-libvips-linux-arm64': 1.3.2
+ '@img/sharp-libvips-linux-arm64': 1.3.3
optional: true
- '@img/sharp-linux-arm@0.35.3':
+ '@img/sharp-linux-arm@0.35.4':
optionalDependencies:
- '@img/sharp-libvips-linux-arm': 1.3.2
+ '@img/sharp-libvips-linux-arm': 1.3.3
optional: true
- '@img/sharp-linux-ppc64@0.35.3':
+ '@img/sharp-linux-ppc64@0.35.4':
optionalDependencies:
- '@img/sharp-libvips-linux-ppc64': 1.3.2
+ '@img/sharp-libvips-linux-ppc64': 1.3.3
optional: true
- '@img/sharp-linux-riscv64@0.35.3':
+ '@img/sharp-linux-riscv64@0.35.4':
optionalDependencies:
- '@img/sharp-libvips-linux-riscv64': 1.3.2
+ '@img/sharp-libvips-linux-riscv64': 1.3.3
optional: true
- '@img/sharp-linux-s390x@0.35.3':
+ '@img/sharp-linux-s390x@0.35.4':
optionalDependencies:
- '@img/sharp-libvips-linux-s390x': 1.3.2
+ '@img/sharp-libvips-linux-s390x': 1.3.3
optional: true
- '@img/sharp-linux-x64@0.35.3':
+ '@img/sharp-linux-x64@0.35.4':
optionalDependencies:
- '@img/sharp-libvips-linux-x64': 1.3.2
+ '@img/sharp-libvips-linux-x64': 1.3.3
optional: true
- '@img/sharp-linuxmusl-arm64@0.35.3':
+ '@img/sharp-linuxmusl-arm64@0.35.4':
optionalDependencies:
- '@img/sharp-libvips-linuxmusl-arm64': 1.3.2
+ '@img/sharp-libvips-linuxmusl-arm64': 1.3.3
optional: true
- '@img/sharp-linuxmusl-x64@0.35.3':
+ '@img/sharp-linuxmusl-x64@0.35.4':
optionalDependencies:
- '@img/sharp-libvips-linuxmusl-x64': 1.3.2
+ '@img/sharp-libvips-linuxmusl-x64': 1.3.3
optional: true
- '@img/sharp-wasm32@0.35.3':
+ '@img/sharp-wasm32@0.35.4':
dependencies:
- '@emnapi/runtime': 1.11.2
+ '@emnapi/runtime': 1.11.3
optional: true
- '@img/sharp-webcontainers-wasm32@0.35.3':
+ '@img/sharp-webcontainers-wasm32@0.35.4':
dependencies:
- '@img/sharp-wasm32': 0.35.3
+ '@img/sharp-wasm32': 0.35.4
optional: true
- '@img/sharp-win32-arm64@0.35.3':
+ '@img/sharp-win32-arm64@0.35.4':
optional: true
- '@img/sharp-win32-ia32@0.35.3':
+ '@img/sharp-win32-ia32@0.35.4':
optional: true
- '@img/sharp-win32-x64@0.35.3':
+ '@img/sharp-win32-x64@0.35.4':
optional: true
'@jridgewell/gen-mapping@0.3.13':
@@ -4963,7 +4964,7 @@ snapshots:
github-slugger: 2.0.0
hast-util-to-estree: 3.1.3
hast-util-to-jsx-runtime: 2.3.6
- js-yaml: 4.3.1
+ js-yaml: 4.3.2
mdast-util-mdx: 3.0.0
mdast-util-to-markdown: 2.1.2
remark: 15.0.1
@@ -4997,7 +4998,7 @@ snapshots:
esbuild: 0.28.1
estree-util-value-to-estree: 3.5.0
fumadocs-core: 16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@0.556.0(react@19.2.6))(next@16.3.3(@types/node@25.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3)
- js-yaml: 4.3.1
+ js-yaml: 4.3.2
mdast-util-mdx: 3.0.0
picocolors: 1.1.1
picomatch: 4.0.5
@@ -5243,7 +5244,7 @@ snapshots:
jiti@2.7.0: {}
- js-yaml@4.3.1:
+ js-yaml@4.3.2:
dependencies:
argparse: 2.0.1
@@ -5815,7 +5816,7 @@ snapshots:
'@next/swc-linux-x64-musl': 16.3.3
'@next/swc-win32-arm64-msvc': 16.3.3
'@next/swc-win32-x64-msvc': 16.3.3
- sharp: 0.35.3(@types/node@25.9.1)
+ sharp: 0.35.4(@types/node@25.9.1)
transitivePeerDependencies:
- '@babel/core'
- '@types/node'
@@ -6084,37 +6085,37 @@ snapshots:
setprototypeof@1.2.0: {}
- sharp@0.35.3(@types/node@25.9.1):
+ sharp@0.35.4(@types/node@25.9.1):
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.8.5
optionalDependencies:
- '@img/sharp-darwin-arm64': 0.35.3
- '@img/sharp-darwin-x64': 0.35.3
- '@img/sharp-freebsd-wasm32': 0.35.3
- '@img/sharp-libvips-darwin-arm64': 1.3.2
- '@img/sharp-libvips-darwin-x64': 1.3.2
- '@img/sharp-libvips-linux-arm': 1.3.2
- '@img/sharp-libvips-linux-arm64': 1.3.2
- '@img/sharp-libvips-linux-ppc64': 1.3.2
- '@img/sharp-libvips-linux-riscv64': 1.3.2
- '@img/sharp-libvips-linux-s390x': 1.3.2
- '@img/sharp-libvips-linux-x64': 1.3.2
- '@img/sharp-libvips-linuxmusl-arm64': 1.3.2
- '@img/sharp-libvips-linuxmusl-x64': 1.3.2
- '@img/sharp-linux-arm': 0.35.3
- '@img/sharp-linux-arm64': 0.35.3
- '@img/sharp-linux-ppc64': 0.35.3
- '@img/sharp-linux-riscv64': 0.35.3
- '@img/sharp-linux-s390x': 0.35.3
- '@img/sharp-linux-x64': 0.35.3
- '@img/sharp-linuxmusl-arm64': 0.35.3
- '@img/sharp-linuxmusl-x64': 0.35.3
- '@img/sharp-webcontainers-wasm32': 0.35.3
- '@img/sharp-win32-arm64': 0.35.3
- '@img/sharp-win32-ia32': 0.35.3
- '@img/sharp-win32-x64': 0.35.3
+ '@img/sharp-darwin-arm64': 0.35.4
+ '@img/sharp-darwin-x64': 0.35.4
+ '@img/sharp-freebsd-wasm32': 0.35.4
+ '@img/sharp-libvips-darwin-arm64': 1.3.3
+ '@img/sharp-libvips-darwin-x64': 1.3.3
+ '@img/sharp-libvips-linux-arm': 1.3.3
+ '@img/sharp-libvips-linux-arm64': 1.3.3
+ '@img/sharp-libvips-linux-ppc64': 1.3.3
+ '@img/sharp-libvips-linux-riscv64': 1.3.3
+ '@img/sharp-libvips-linux-s390x': 1.3.3
+ '@img/sharp-libvips-linux-x64': 1.3.3
+ '@img/sharp-libvips-linuxmusl-arm64': 1.3.3
+ '@img/sharp-libvips-linuxmusl-x64': 1.3.3
+ '@img/sharp-linux-arm': 0.35.4
+ '@img/sharp-linux-arm64': 0.35.4
+ '@img/sharp-linux-ppc64': 0.35.4
+ '@img/sharp-linux-riscv64': 0.35.4
+ '@img/sharp-linux-s390x': 0.35.4
+ '@img/sharp-linux-x64': 0.35.4
+ '@img/sharp-linuxmusl-arm64': 0.35.4
+ '@img/sharp-linuxmusl-x64': 0.35.4
+ '@img/sharp-webcontainers-wasm32': 0.35.4
+ '@img/sharp-win32-arm64': 0.35.4
+ '@img/sharp-win32-ia32': 0.35.4
+ '@img/sharp-win32-x64': 0.35.4
'@types/node': 25.9.1
optional: true
diff --git a/website/pnpm-workspace.yaml b/website/pnpm-workspace.yaml
index 315a893e44..d23eb01d39 100644
--- a/website/pnpm-workspace.yaml
+++ b/website/pnpm-workspace.yaml
@@ -44,7 +44,14 @@ minimumReleaseAgeExclude:
# direct `postcss` devDependency in `package.json` moving together.
overrides:
postcss: ">=8.5.23"
- sharp: ">=0.35.0"
+ # sharp <0.35.4 and js-yaml <4.3.2 each carry a high-severity advisory
+ # (GHSA-rgj7-g3m4-5g8c, GHSA-2883-xcg3-v3hh). Both arrive transitively —
+ # sharp through `next`, js-yaml through `fumadocs-core` and `fumadocs-mdx` —
+ # so, as with postcss above, no direct bump reaches them; the floor does.
+ # js-yaml takes a caret, like nanoid below: a bare floor resolves to 5.x,
+ # which fumadocs does not call.
+ sharp: ">=0.35.4"
+ js-yaml: "^4.3.2"
# nanoid <3.3.18: a custom alphabet with a non-integer or zero `size` makes
# the generator loop indefinitely (GHSA high, patched at 3.3.18). It arrives
# transitively through postcss@8.5.23, which both `next` and