Skip to content
Merged
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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
still creates its database with no ceremony, and dev and scratch stores
migrate on open exactly as before.

- **The cap badge takes its reset time from the agent, not from its screen.**
Voro used to read "resets 6:40pm" off a capped session's output and guess
which 6:40pm was meant, because a bare clock time carries no date. Agents can
now report the same thing exactly, through a new optional `cap` verb that
prints when the account's usage window reopens as a Unix epoch. The badge
reads the same, but "reset passed" is now a fact rather than a
nearest-occurrence guess, and a cap whose wording named no time at all gets
one. A subscription meters more than one window — the five-hour pool, the
weekly one, and each strong model's own allowance — so the verb is asked with
the model the session launched under, and sessions asking the same thing share
one reading. The built-in `claude` agent defines it; `codex` does not, and
anything Voro cannot ask keeps the old parse. Because asking costs the agent
an API call rather than a screen replay, Voro asks only while a session is
already badged capped, and once per capped episode.

- **One key gets every capped session working again.** A usage cap ends a
session's turn and leaves it there — nothing retries — so recovering the fleet
used to mean attaching to each capped session in turn and typing "continue",
Expand Down
213 changes: 203 additions & 10 deletions crates/voro-core/src/agent.rs

Large diffs are not rendered by default.

321 changes: 321 additions & 0 deletions crates/voro-core/src/cap.rs

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions crates/voro-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,12 @@ pub use agent::{
NEW_SESSION_PLACEHOLDER, PROMPT_FILE_PLACEHOLDER, Provenance, RenderedMessage, ResolvedAgent,
SESSION_NAME_PLACEHOLDER, SESSION_PLACEHOLDER, SessionLiveness, TASK_ID_PLACEHOLDER,
VIEWER_BASE_PLACEHOLDER, VIEWER_BRANCH_PLACEHOLDER, VIEWER_PATH_PLACEHOLDER, ViewerTemplate,
is_builtin_viewer, parse_sessions_json, render_message, render_session,
is_builtin_viewer, model_for_depth, parse_sessions_json, render_cap, render_message,
render_session,
};
pub use cap::{
AccountCap, CAP_SIGNATURES, CapReading, CapWindow, parse_reset_epoch, read_cap, strip_ansi,
};
pub use cap::{CAP_SIGNATURES, CapReading, read_cap, strip_ansi};
pub use error::{Error, Result};
pub use import::{GithubIssue, already_imported, issue_new_task, issue_task_body};
pub use model::{
Expand Down
436 changes: 414 additions & 22 deletions crates/voro/src/app.rs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion crates/voro/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2630,7 +2630,7 @@ mod tests {
// every optional verb the agent defines, the quick message included —
// named plainly, since the built-in resumes its session in place
assert!(
listed.contains("[sessions attach resume message logs stop plan]"),
listed.contains("[sessions attach resume message logs cap stop plan]"),
"{listed}"
);

Expand Down
236 changes: 234 additions & 2 deletions crates/voro/src/probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,16 @@
//! the browser on it. [`CapProbe`] is the usage-cap reading, behind neither:
//! every in-flight session is a target on every tick, so it is the one runner
//! debounced against the *clock* ([`CAP_INTERVAL`]) rather than against
//! anything the operator does.
//! anything the operator does. [`AccountCapProbe`] is its account-wide
//! counterpart, and the only runner whose debounce guards *money* rather than
//! latency: the verb behind it spends an API call, so it is gated on a badge
//! already being on the strip and asks once per capped episode.

use std::collections::{HashMap, HashSet};
use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};
use std::time::{Duration, Instant};

use voro_core::{CapReading, Mergeability};
use voro_core::{AccountCap, CapReading, Mergeability};

use crate::pr::{PrCreateInput, ReviewedSource};

Expand Down Expand Up @@ -389,6 +392,145 @@ impl CapProbe {
}
}

/// The floor between two readings of one agent's account, and the only thing
/// standing between a stuck badge and a standing charge: unlike every other
/// probe here, this one spends an API call to ask (DESIGN.md §8). It is a
/// backstop rather than the schedule — [`account_probe_due`] normally asks once
/// per capped episode — so it is set where a pathological loop would cost cents
/// an hour rather than pounds.
pub const ACCOUNT_CAP_INTERVAL: Duration = Duration::from_secs(600);

/// Whether an account reading should be taken this tick (DESIGN.md §8).
///
/// The rule is "ask once, while it matters": a session this question answers
/// for is badged capped, nothing is in flight for it, and either nothing has
/// been read yet or what was read has expired — a window that has reopened
/// while a session is still badged is the one case where asking again can learn
/// something, since the account may have entered a new one. A reading that came
/// back *empty* is held too, and that is what stops a session whose badge has
/// gone stale from buying a fresh call every interval for as long as it sits
/// there.
pub fn account_probe_due(
in_flight: bool,
last: Option<(Instant, Option<i64>)>,
now: Instant,
now_epoch: Option<i64>,
) -> bool {
if in_flight {
return false;
}
let Some((started, reset_epoch)) = last else {
return true;
};
if now.saturating_duration_since(started) < ACCOUNT_CAP_INTERVAL {
return false;
}
match (reset_epoch, now_epoch) {
(Some(reset), Some(now)) => reset <= now,
_ => false,
}
}

/// The account-cap probe's off-loop runner (DESIGN.md §8): an agent's `cap`
/// verb on a background thread, reading the instant the window holding a
/// session reopens.
///
/// It is [`CapProbe`]'s counterpart and differs from it in both directions of
/// what it costs. It is keyed by the *question* — the rendered `cap` command —
/// rather than by task, because a cap belongs to the account and one reading
/// answers for every session that would ask the same thing, where a screen
/// replay has to be taken per session. That key is what makes the per-model
/// case fall out without being special: an agent asking on `{model}` renders a
/// different command per model in flight and gets a reading each, one that
/// names no model renders one command and is asked once. And it is debounced
/// far harder, because the verb spends an API call rather than a subprocess:
/// [`account_probe_due`] asks once per capped episode, and
/// [`ACCOUNT_CAP_INTERVAL`] catches anything that would ask in a loop.
pub struct AccountCapProbe {
tx: Sender<(String, Option<AccountCap>)>,
rx: Receiver<(String, Option<AccountCap>)>,
in_flight: HashSet<String>,
/// Each question's last reading: when it was asked, and the instant it came
/// back with — `None` for a reading that found the account uncapped, which
/// is held exactly as a positive one is.
last: HashMap<String, (Instant, Option<i64>)>,
}

impl Default for AccountCapProbe {
fn default() -> Self {
let (tx, rx) = channel();
AccountCapProbe {
tx,
rx,
in_flight: HashSet::new(),
last: HashMap::new(),
}
}
}

impl AccountCapProbe {
pub fn due(&self, question: &str, now: Instant, now_epoch: Option<i64>) -> bool {
account_probe_due(
self.in_flight.contains(question),
self.last.get(question).copied(),
now,
now_epoch,
)
}

/// Ask one rendered `cap` command what the account says, on a background
/// thread.
pub fn start(&mut self, question: String, now: Instant, now_epoch: i64) {
self.in_flight.insert(question.clone());
// Held from the start, not from the answer, so a verb slower than the
// interval cannot be started twice over.
self.last.insert(question.clone(), (now, None));
let tx = self.tx.clone();
std::thread::spawn(move || {
let reading = crate::session_probe::read_account_cap(&question, now_epoch);
let _ = tx.send((question, reading));
});
}

/// Every reading that has landed since the last drain, each tagged with the
/// question it answers. Never blocks. An empty reading is handed back as
/// meaningfully as a full one: it clears that question's answer, leaving the
/// badge to the session's own screen again.
pub fn take_results(&mut self) -> Vec<(String, Option<AccountCap>)> {
let mut landed = Vec::new();
loop {
match self.rx.try_recv() {
Ok((question, reading)) => {
self.in_flight.remove(&question);
if let Some(entry) = self.last.get_mut(&question) {
entry.1 = reading.as_ref().map(|r| r.reset_epoch);
}
landed.push((question, reading));
}
Err(TryRecvError::Empty | TryRecvError::Disconnected) => return landed,
}
}
}

/// Drop the debounce for questions no badged session is asking any more, so
/// the next cap reads afresh rather than waiting out the last one's
/// interval.
pub fn retain(&mut self, asked_for: &HashSet<String>) {
self.last.retain(|question, _| asked_for.contains(question));
}

/// Hand back a reading as though a background probe had produced it, so the
/// drain-and-render half can be tested without spending an API call.
#[cfg(test)]
pub fn inject_result(&mut self, question: &str, reading: Option<AccountCap>) {
self.in_flight.insert(question.to_string());
self.last
.entry(question.to_string())
.or_insert((Instant::now(), None));
let _ = self.tx.send((question.to_string(), reading));
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -675,6 +817,96 @@ mod tests {
assert!(probe.due(7, start));
}

/// An agent whose account has never been asked is asked at once, so the
/// first badged cap gets its instant on the tick it appears rather than
/// after an interval's wait.
#[test]
fn an_unasked_account_is_due_immediately() {
assert!(account_probe_due(false, None, Instant::now(), Some(1)));
}

/// The debounce proper, and the one that guards money rather than
/// subprocesses: a reading stands until the window it named has passed, and
/// never within the interval whatever it named.
#[test]
fn an_account_is_reasked_only_once_its_window_has_passed() {
let start = Instant::now();
let now_epoch = 1_786_722_000;
let ahead = Some((start, Some(now_epoch + 3600)));
let behind = Some((start, Some(now_epoch - 1)));
// Inside the interval nothing is asked, however stale the reading.
assert!(!account_probe_due(false, behind, start, Some(now_epoch)));
// Past it, a window still shut is left alone and a reopened one is
// asked again — the account may have entered a fresh window.
let later = start + ACCOUNT_CAP_INTERVAL;
assert!(!account_probe_due(false, ahead, later, Some(now_epoch)));
assert!(account_probe_due(false, behind, later, Some(now_epoch)));
}

/// A reading that found the account uncapped is held exactly as a positive
/// one is. Dropping it is what would buy a fresh API call every interval
/// for a session capped on a limit the probe never hits.
#[test]
fn an_empty_reading_is_not_reasked() {
let start = Instant::now();
let empty = Some((start, None));
assert!(!account_probe_due(false, empty, start, Some(1)));
assert!(!account_probe_due(
false,
empty,
start + ACCOUNT_CAP_INTERVAL * 10,
Some(1)
));
}

/// A probe already running is never doubled, whatever the clock says.
#[test]
fn an_account_probe_in_flight_is_never_doubled() {
let start = Instant::now();
assert!(!account_probe_due(true, None, start, Some(1)));
assert!(!account_probe_due(
true,
Some((start, Some(0))),
start + ACCOUNT_CAP_INTERVAL,
Some(1)
));
}

/// Readings land against the agent they were asked of, and an empty one
/// clears that agent's answer rather than leaving the last standing.
#[test]
fn draining_hands_back_each_agents_reading() {
let mut probe = AccountCapProbe::default();
let cap = AccountCap {
reset_epoch: 1_786_758_000,
reset_label: Some("02:40".into()),
};
probe.inject_result("claude", Some(cap.clone()));
probe.inject_result("codex", None);
assert_eq!(
probe.take_results(),
vec![
("claude".to_string(), Some(cap)),
("codex".to_string(), None)
]
);
assert!(probe.take_results().is_empty());
}

/// An agent with nothing capped on the strip drops its debounce, so the
/// next cap is read at once rather than inheriting the last one's interval.
#[test]
fn an_uncapped_agent_drops_its_debounce() {
let now = Instant::now();
let mut probe = AccountCapProbe::default();
probe.inject_result("claude", None);
probe.take_results();
assert!(!probe.due("claude", now, Some(1)));

probe.retain(&HashSet::from(["codex".to_string()]));
assert!(probe.due("claude", now, Some(1)));
}

/// Holding the reject key on one task spawns one capture, not one per
/// press; the next press after its answer is drained captures afresh.
#[test]
Expand Down
Loading