Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions .github/workflows/dependency-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 `<pre>` without linking `ratatui`. `stella-tui`'s `ansi` module re-exports it and keeps only the `ratatui`-shaped emission half. |
Expand Down Expand Up @@ -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.

Expand Down
16 changes: 16 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions crates/stella-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion crates/stella-cli/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
2 changes: 1 addition & 1 deletion crates/stella-cli/src/agent/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
)
}

Expand Down
2 changes: 1 addition & 1 deletion crates/stella-cli/src/agent/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions crates/stella-cli/src/agent/tests/engine_wiring.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 4 additions & 5 deletions crates/stella-cli/src/fleet_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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(),
Expand Down
6 changes: 3 additions & 3 deletions crates/stella-cli/src/fleet_cmd/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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());
Expand All @@ -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,
Expand Down
117 changes: 5 additions & 112 deletions crates/stella-cli/src/runtime.rs
Original file line number Diff line number Diff line change
@@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}"
);
}
}
Loading
Loading