From c37e88ad0c7face8a6a9d149de81eddec05eb6a9 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Fri, 14 Aug 2026 22:44:29 +0100 Subject: [PATCH 1/2] Take the cap reset time as an epoch from the agent, not parsed off its screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The capped badge read "resets 6:40pm" off a session's terminal capture and resolved it to whichever occurrence was nearest, because a bare clock time carries no date and is ambiguous by half a day either way. The same quantity is available exactly: a cap is a property of the account, and Claude Code's stream transport emits a rate_limit_event carrying resetsAt in Unix epoch seconds. Adds an optional `cap` agent verb, an opaque contract like `logs` and the only one that names nothing — no {session}, because one reading answers for every session on the strip. Its negative answer is silence, so an uncapped account, a failed command and an agent defining none all land identically on the old parse. Where a reading exists it decides the badge's time and whether the window has reopened; CapWindow is where the two sources resolve, so the badge and the nudge sweep cannot disagree about which one spoke. The verb spends an API call rather than a screen replay, so it is gated on a badge already being on the strip, asked once per capped episode, and keyed by agent — several capped sessions under one agent are one question. Settled first, from the CLI's own code, the question this rested on: a capped account does still emit the event. extractQuotaStatusFromError builds the info from a 429's anthropic-ratelimit-unified-* headers with status "rejected" and resetsAt intact, calls emitStatusChange, and the print-mode listener enqueues it onto the stream — and a usage cap is non-retryable, so the turn ends at once rather than after a retry loop. Verified: cargo test --workspace (900 pass), clippy and fmt clean. Driven end-to-end in the TUI against a scratch store with a stub agent whose logs verb says only "Weekly limit reached" — no time on screen — and whose cap verb prints an instant: the row badges "capped ↻23:42", flips to "capped · reset passed" when the instant is behind, and `u` releases and nudges it. The real built-in template was run through sh as Voro renders it, and prints nothing while the account has room. --- CHANGELOG.md | 13 ++ crates/voro-core/src/agent.rs | 118 ++++++++++- crates/voro-core/src/cap.rs | 265 ++++++++++++++++++++++++ crates/voro-core/src/lib.rs | 4 +- crates/voro/src/app.rs | 337 +++++++++++++++++++++++++++++-- crates/voro/src/cli.rs | 2 +- crates/voro/src/probe.rs | 229 ++++++++++++++++++++- crates/voro/src/session_probe.rs | 107 +++++++++- crates/voro/src/ui.rs | 22 +- docs/DESIGN.md | 8 +- docs/agent-integration.md | 25 +++ 11 files changed, 1094 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 759f950..6f2b2c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **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 — one reading + answering for every session on the strip, since a cap belongs to the account + rather than to any one conversation. 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. 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", diff --git a/crates/voro-core/src/agent.rs b/crates/voro-core/src/agent.rs index e706ce6..6824e90 100644 --- a/crates/voro-core/src/agent.rs +++ b/crates/voro-core/src/agent.rs @@ -141,6 +141,25 @@ pub const VIEWER_BASE_PLACEHOLDER: &str = "{base}"; /// a not-found line instead, which is why nothing reads its status: text with /// no cap signature in it means "not capped", however it came about. /// +/// The claude `cap` verb answers the same question `logs` does — when does the +/// window reopen — about the account rather than about a session, and as an +/// instant rather than as a clock time on a screen (DESIGN.md §8). The CLI's +/// stream transport emits a `rate_limit_event` carrying `resetsAt`, a Unix +/// epoch, so the spelling is a one-turn print with the event filtered out of +/// the stream: `"status":"rejected"` is what makes it a report of a cap the +/// account is *held at* rather than a note on the window it is spending, and +/// the greps keep the epoch beside it. A live cap always carries the reset, +/// since the same header the message renders its time from is where this comes +/// from. +/// +/// Two properties of the spelling are load-bearing. It prints *nothing* when +/// the account is not refused, which is the contract's whole negative answer; +/// and it names `--model haiku` rather than `{model}`, because unlike every +/// other verb this one spends an API call to ask — a cent or two, mostly cache +/// creation — and the answer is the same whichever model is refused. The +/// `timeout` is the belt to that brace: a cap is not retried (§8), so the turn +/// ends at once, but nothing in Voro should wait on an agent indefinitely. +/// /// The claude `stop` verb retires a session from the agent's own listing once /// Voro closes its row — and, at rest, once it hands back (DESIGN.md §8): the /// release the supervisor holds is what a headless `message` resumes through. @@ -161,6 +180,7 @@ attach = \"claude attach {session}\" resume = \"claude --resume {session}\" message = \"claude -p --resume {session} --permission-mode auto \\\"$(cat {prompt_file})\\\"\" logs = \"claude logs \\\"$(printf %.8s {session})\\\" 2>/dev/null | tail -c 20000\" +cap = '''timeout 120 claude -p --output-format stream-json --verbose --model haiku hi 2>/dev/null | grep -o '\"status\":\"rejected\"[^}]*\"resetsAt\":[0-9]*' | grep -o '[0-9][0-9]*$' | tail -1''' stop = \"claude stop \\\"$(printf %.8s {session})\\\"\" plan = \"claude --name \\\"{session_name}\\\" --permission-mode auto --model {model} \\\"$(cat {prompt_file})\\\"\" model = \"opus\" @@ -293,6 +313,12 @@ const STARTER_HEADER: &str = r#"# Voro configuration (~/.config/voro/voro.toml). # usage cap, which is badged on the running strip and used to # tell a capped death from an ordinary one. Tail it in the # template — Voro reads whatever it prints. +# cap print when the account's usage window reopens, as a Unix +# epoch, while the account is capped — and nothing when it is +# not (no placeholders). Read instead of the clock time on a +# session's screen, which carries no date. Costs whatever +# asking the agent costs, so Voro asks only while a session is +# badged capped. # stop retire a session from the agent's own registry ({session}) # Fired when Voro closes the session's row, so the agent's # listing shows work actually in flight. Fire and forget: Voro @@ -406,6 +432,18 @@ pub struct AgentTemplate { /// produce output for a session simply omits it, and Voro classifies a dead /// session from the launch log as it always has and badges no live one. logs: Option, + /// When the *account* this agent dispatches on has its usage window + /// reopen, as a Unix epoch and nothing else (DESIGN.md §8). Carries no + /// placeholder at all: it names no session, because a cap is a property of + /// the account rather than of any one conversation, and one reading + /// therefore answers for every session on the strip. + /// + /// Its contract is silence-as-negative like [`AgentTemplate::logs`]: print + /// the instant while the account is refused, print nothing otherwise. An + /// agent that cannot say — `codex` names none — leaves Voro reading the + /// reset time off the session's own screen, ambiguous by half a day, as it + /// always did. + cap: Option, /// Retire a session from the agent's own registry, carrying /// [`SESSION_PLACEHOLDER`]: fired when Voro closes the session's row, so the /// agent's listing converges on work actually in flight (DESIGN.md §8). @@ -459,6 +497,10 @@ impl AgentTemplate { self.logs.as_deref() } + pub fn cap(&self) -> Option<&str> { + self.cap.as_deref() + } + pub fn stop(&self) -> Option<&str> { self.stop.as_deref() } @@ -508,12 +550,13 @@ type VerbAccessor = (&'static str, fn(&AgentTemplate) -> Option<&str>); /// listed to the operator. One roster serves both the positive listing and the /// dropped-verb warning under it, so the two lines cannot disagree about the /// same agent. -const OPTIONAL_VERBS: [VerbAccessor; 7] = [ +const OPTIONAL_VERBS: [VerbAccessor; 8] = [ ("sessions", AgentTemplate::sessions), ("attach", AgentTemplate::attach), ("resume", AgentTemplate::resume), ("message", AgentTemplate::message), ("logs", AgentTemplate::logs), + ("cap", AgentTemplate::cap), ("stop", AgentTemplate::stop), ("plan", AgentTemplate::plan), ]; @@ -948,6 +991,26 @@ fn validate_agent(name: &str, agent: &AgentTemplate, path: &Path) -> Result<()> } } } + // `cap` asks about the account, not about a session or a launch, so every + // placeholder there is a category error: there is nothing for Voro to bind + // one to, and it would reach the shell as literal braces. + if let Some(template) = &agent.cap { + for placeholder in [ + SESSION_PLACEHOLDER, + PROMPT_FILE_PLACEHOLDER, + MODEL_PLACEHOLDER, + SESSION_NAME_PLACEHOLDER, + TASK_ID_PLACEHOLDER, + NEW_SESSION_PLACEHOLDER, + ] { + if template.contains(placeholder) { + return Err(invalid(format!( + "agent '{name}' cap carries {placeholder}, but a cap reading is about the \ + account rather than any one session or launch — it takes no placeholders" + ))); + } + } + } // `{new_session}` names the session a *send* opens, so `message` is the one // verb that can bind it; anywhere else it would reach the shell as literal // braces. @@ -1032,6 +1095,7 @@ pub struct ResolvedAgent { pub resume: Option, pub message: Option, pub logs: Option, + pub cap: Option, pub stop: Option, pub plan: Option, pub model: Option, @@ -1281,6 +1345,7 @@ impl AgentsConfig { resume: agent.resume.clone(), message: agent.message.clone(), logs: agent.logs.clone(), + cap: agent.cap.clone(), stop: agent.stop.clone(), plan: agent.plan.clone(), model: agent.model.clone(), @@ -2104,7 +2169,7 @@ mod tests { assert_eq!( agents["claude"].verbs(), vec![ - "sessions", "attach", "resume", "message", "logs", "stop", "plan" + "sessions", "attach", "resume", "message", "logs", "cap", "stop", "plan" ] ); assert_eq!(agents["codex"].verbs(), vec!["resume"]); @@ -2305,6 +2370,55 @@ mod tests { } } + /// The built-in `claude` defines `cap` and `codex` does not, and the + /// spelling carries the two halves Voro depends on: it asks about the + /// account with a model of its own choosing, and it keeps only the *epoch* + /// out of a rejection, so an account merely spending its window prints + /// nothing. + #[test] + fn only_the_claude_builtin_defines_cap() { + let config = AgentsConfig::load(Path::new("/nonexistent/voro.toml")).unwrap(); + let cap = config + .agent("claude") + .expect("the built-in claude") + .cap() + .expect("a cap verb"); + assert!(cap.contains("rejected"), "{cap}"); + assert!(cap.contains("resetsAt"), "{cap}"); + assert!(cap.contains("--model haiku"), "{cap}"); + assert!(config.agent("codex").expect("codex").cap().is_none()); + } + + /// `cap` takes no placeholders at all. It reads the account rather than a + /// session, so `{session}` has nothing to name — and it is not a launch, + /// so the launch placeholders have nothing to bind either. + #[test] + fn cap_is_validated_as_naming_nothing() { + for placeholder in [ + SESSION_PLACEHOLDER, + PROMPT_FILE_PLACEHOLDER, + MODEL_PLACEHOLDER, + SESSION_NAME_PLACEHOLDER, + TASK_ID_PLACEHOLDER, + NEW_SESSION_PLACEHOLDER, + ] { + let toml = format!( + "[agents.a]\ndispatch = \"run {{prompt_file}}\"\nmodel = \"m\"\n\ + cap = \"agent-cap {placeholder}\"\n" + ); + let raw: RawConfig = toml::from_str(&toml).unwrap(); + let err = validate_agent("a", &raw.agents["a"], Path::new("/c.toml")) + .expect_err("a placeholder on cap is refused"); + let message = err.to_string(); + assert!(message.contains("cap"), "{message}"); + assert!(message.contains(placeholder), "{message}"); + } + // And a template that names nothing is exactly what the verb wants. + let toml = "[agents.a]\ndispatch = \"run {prompt_file}\"\ncap = \"agent-cap --epoch\"\n"; + let raw: RawConfig = toml::from_str(toml).unwrap(); + validate_agent("a", &raw.agents["a"], Path::new("/c.toml")).expect("a bare cap is valid"); + } + /// The built-in `stop` renders through the same session binder `logs` does, /// down to the truncation: `claude stop` keys on the eight-character job id, /// so the reference goes in shell-quoted inside the `printf` that trims it diff --git a/crates/voro-core/src/cap.rs b/crates/voro-core/src/cap.rs index 424e8bc..1c1f268 100644 --- a/crates/voro-core/src/cap.rs +++ b/crates/voro-core/src/cap.rs @@ -23,6 +23,15 @@ //! disbelieve the badge. That asymmetry is why [`NOT_CAP_QUALIFIERS`] exists — //! the agent says "approaching" and "not your usage limit" in text that //! otherwise matches, and both mean the session is working fine. +//! +//! *When* the window reopens has a second source, and a better one. The clock +//! time on screen is a bare `6:40pm`: no date, so it is read as whichever +//! occurrence is nearest and is ambiguous by half a day either way. An agent +//! that can say the same thing as an instant — [`AccountCap`], read through its +//! `cap` verb — says it exactly, and [`CapWindow`] is where the two are +//! resolved into the one answer the badge and the sweep both read. That reading +//! is about the *account*, not the session, so one of them serves the whole +//! strip; the parse stays for every agent and every moment that has none. /// Phrases that mean "held at a usage cap", checked case-insensitively. /// @@ -116,6 +125,125 @@ impl CapReading { } } +/// How far behind the present a reported reset may fall and still be believed: +/// a window that reopened while the operator was away is exactly the reading +/// the sweep is waiting for, so a day of slack costs nothing. +const EPOCH_BEHIND: i64 = 24 * 60 * 60; + +/// How far ahead of the present a reported reset may fall and still be +/// believed. The longest window an agent bills in is a week, so a month is +/// generous — the bound is here to refuse a number that is not a timestamp at +/// all, not to second-guess the agent. +const EPOCH_AHEAD: i64 = 30 * 24 * 60 * 60; + +/// What an agent's `cap` verb says about the *account* it dispatches on: the +/// instant its usage window reopens (DESIGN.md §8). +/// +/// This is the same quantity the badge parses off a session's screen, without +/// the parse. It is a fact the agent reports rather than a heuristic standing +/// in for one, and it is account-wide, so a single reading answers for every +/// session on the strip — where the screen reading has to be taken per session, +/// costing a subprocess each. +/// +/// The label is the agent's instant in the operator's own timezone, rendered +/// where the reading is taken because that is off the render path and this +/// crate has no clock. Absent when it could not be rendered, in which case the +/// badge shows the cap without a time exactly as an unparsed one does. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountCap { + /// When the window reopens, in seconds since the Unix epoch. + pub reset_epoch: i64, + /// That instant as a local `21:50`, for the badge. + pub reset_label: Option, +} + +/// Read an agent's `cap` verb output as the instant its window reopens, or +/// `None` when it said nothing (DESIGN.md §8). +/// +/// The verb's contract is one line of shell away from trivial on purpose: print +/// a Unix epoch when the account is *currently* refused, print nothing +/// otherwise. Silence is the whole of the negative answer — an account that is +/// not capped, a verb that failed, an agent that defines none all read the same +/// and all fall back to the screen parse — which is how every other reading in +/// this module degrades. +/// +/// A plausibility band around `now` is the only judgement applied: the output is +/// under the agent template's control, and a stray number in it must not become +/// a badge claiming the window reopens in 1970. The *last* plausible number +/// wins, so a verb that prints a line per window ends with the one it means. +pub fn parse_reset_epoch(out: &str, now_epoch: i64) -> Option { + out.split(|c: char| !c.is_ascii_digit()) + .filter_map(|run| run.parse::().ok()) + .rfind(|epoch| (now_epoch - EPOCH_BEHIND..=now_epoch + EPOCH_AHEAD).contains(epoch)) +} + +/// When a capped session's window reopens and whether it has: the one answer +/// the badge and the nudge sweep both read, resolved from the two sources that +/// can give it (DESIGN.md §8). +/// +/// The account's own reading decides whenever there is one. It is the same +/// quantity the screen states, minus the ambiguity: a bare `6:40pm` carries no +/// date, so "has it passed?" is answered by nearest occurrence and is a +/// half-day guess in both directions, while an instant is simply compared. The +/// screen parse remains the answer for an agent with no `cap` verb, for an +/// account that is not itself refused, and for every reading taken before the +/// verb has run. +/// +/// One caveat rides the precedence and is worth naming: an account is refused by +/// *one* window while a session may be held by another — a weekly model limit +/// behind a five-hour account cap — and the account reading names its own. The +/// sweep is then early for that session, which §8 already prices as the cheap +/// failure: the nudge lands, the turn re-caps at once, and the badge returns. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct CapWindow { + /// The reset as a local `21:50`, absent when neither source named one. + pub label: Option, + /// Whether the window has reopened. + pub passed: bool, + /// Whether anything named a reset at all. A cap with no time is the case + /// the operator's own judgement stands in for ([`CapWindow::due`]). + pub timed: bool, +} + +impl CapWindow { + /// Resolve the two sources for one session, the account's reading first. + pub fn resolve( + reading: &CapReading, + account: Option<&AccountCap>, + now_minutes: Option, + now_epoch: Option, + ) -> CapWindow { + if let Some(account) = account { + return CapWindow { + label: account + .reset_label + .clone() + .or_else(|| reading.reset_label()), + passed: now_epoch.is_some_and(|now| account.reset_epoch <= now), + timed: true, + }; + } + CapWindow { + label: reading.reset_label(), + passed: now_minutes.is_some_and(|now| reading.reset_passed(now)), + timed: reading.reset_minutes.is_some(), + } + } + + /// Whether this session is waiting on a human rather than on the clock — + /// what the sweep nudges. + /// + /// An untimed cap counts, and that is the operator's judgement standing in + /// for the clock's: they pressed the key, and a nudge that turns out to be + /// early is refused by the agent rather than doing harm (§8). It is also + /// the gap the account reading closes — a cap whose time never parsed is + /// timed after all once the account has said when — which is what an + /// automatic sweep, with no keypress behind it, needs. + pub fn due(&self) -> bool { + !self.timed || self.passed + } +} + /// Read a session's output tail as a cap reading, or `None` when nothing in it /// says the session is capped. /// @@ -484,6 +612,143 @@ mod tests { assert_eq!(strip_ansi("plain"), "plain"); } + /// A day in seconds, for writing the epoch tests in units a reader can + /// hold. + const DAY: i64 = 24 * 60 * 60; + + /// The `cap` verb's own output, which is one number: the instant the + /// account's window reopens, exactly as the agent reported it. + #[test] + fn the_cap_verb_reads_as_an_instant() { + let now = 1_786_722_000; + assert_eq!(parse_reset_epoch("1786758000\n", now), Some(1_786_758_000)); + // Whitespace and a trailing newline are the shell's, not the agent's. + assert_eq!( + parse_reset_epoch(" 1786758000 ", now), + Some(1_786_758_000) + ); + // Silence is the negative answer: not capped, verb failed, no verb. + assert_eq!(parse_reset_epoch("", now), None); + assert_eq!(parse_reset_epoch("\n", now), None); + } + + /// A number that cannot be a reset is not one. The verb's output is + /// whatever an agent template prints, so a stray count or id must not badge + /// a session with a window that reopens in 1970 — or in 2031. + #[test] + fn only_a_plausible_instant_is_believed() { + let now = 1_786_722_000; + assert_eq!(parse_reset_epoch("42", now), None); + assert_eq!(parse_reset_epoch("0", now), None); + assert_eq!(parse_reset_epoch(&(now + 400 * DAY).to_string(), now), None); + assert_eq!(parse_reset_epoch(&(now - 3 * DAY).to_string(), now), None); + // The bounds themselves, since a window that reopened while the + // operator slept is precisely the reading the sweep waits for. + assert_eq!( + parse_reset_epoch(&(now - DAY).to_string(), now), + Some(now - DAY) + ); + assert_eq!( + parse_reset_epoch(&(now + 7 * DAY).to_string(), now), + Some(now + 7 * DAY) + ); + } + + /// A verb that prints more than one line ends with the one it means. + #[test] + fn the_last_plausible_instant_wins() { + let now = 1_786_722_000; + let out = format!("{}\n{}\n", now + 60, now + 3600); + assert_eq!(parse_reset_epoch(&out, now), Some(now + 3600)); + // And an implausible number after a good one does not displace it. + assert_eq!( + parse_reset_epoch(&format!("{}\nattempt 2\n", now + 60), now), + Some(now + 60) + ); + } + + /// The precedence proper: the account's instant answers for a session + /// whose screen named a time, and it answers exactly — 21:50 read a minute + /// later has passed, where the parse would have to guess by nearest + /// occurrence. + #[test] + fn the_accounts_instant_decides_over_the_parsed_clock() { + let now = 1_786_722_000; + let reading = read_cap("Session limit reached · resets 9:50pm").expect("a cap"); + let account = AccountCap { + reset_epoch: now + 3600, + reset_label: Some("21:50".into()), + }; + let window = CapWindow::resolve(&reading, Some(&account), Some(20 * 60 + 50), Some(now)); + assert_eq!(window.label.as_deref(), Some("21:50")); + assert!(!window.passed); + assert!(!window.due()); + + let account = AccountCap { + reset_epoch: now - 60, + ..account + }; + let window = CapWindow::resolve(&reading, Some(&account), Some(20 * 60 + 50), Some(now)); + assert!(window.passed); + assert!(window.due()); + } + + /// The gap the account reading closes, and the reason #437 wants it: a cap + /// whose time never parsed is not timed at all, so the sweep can only fire + /// on the operator's say-so. With the account's instant it is timed, and a + /// clock can decide. + #[test] + fn an_untimed_cap_is_timed_by_the_account() { + let now = 1_786_722_000; + let reading = read_cap("Weekly limit reached").expect("a cap"); + let bare = CapWindow::resolve(&reading, None, Some(12 * 60), Some(now)); + assert!(!bare.timed); + assert_eq!(bare.label, None); + assert!(bare.due(), "an untimed cap is the operator's call"); + + let account = AccountCap { + reset_epoch: now + 3600, + reset_label: Some("09:00".into()), + }; + let timed = CapWindow::resolve(&reading, Some(&account), Some(12 * 60), Some(now)); + assert!(timed.timed); + assert_eq!(timed.label.as_deref(), Some("09:00")); + assert!(!timed.due(), "the window is known to be shut"); + } + + /// With no account reading the badge is exactly what it was: the screen + /// parse, judged by nearest occurrence against the local clock. + #[test] + fn without_an_account_reading_the_parse_still_answers() { + let reading = read_cap("Session limit reached · resets 9:50pm").expect("a cap"); + let ahead = CapWindow::resolve(&reading, None, Some(20 * 60 + 50), Some(0)); + assert_eq!(ahead.label.as_deref(), Some("21:50")); + assert!(!ahead.passed); + assert!(ahead.timed); + assert!(CapWindow::resolve(&reading, None, Some(22 * 60 + 50), Some(0)).passed); + } + + /// An account reading Voro could not render a label for still decides + /// whether the window is open — the instant is the load-bearing half, and + /// the session's own time fills the badge behind it. + #[test] + fn an_unrendered_instant_still_decides() { + let now = 1_786_722_000; + let reading = read_cap("Session limit reached · resets 9:50pm").expect("a cap"); + let account = AccountCap { + reset_epoch: now - 60, + reset_label: None, + }; + let window = CapWindow::resolve(&reading, Some(&account), Some(20 * 60 + 50), Some(now)); + assert_eq!(window.label.as_deref(), Some("21:50")); + assert!(window.passed); + + // And with no clock to compare against, nothing claims it has passed. + let window = CapWindow::resolve(&reading, Some(&account), Some(20 * 60 + 50), None); + assert!(!window.passed); + assert!(window.timed); + } + /// A signature landing at the very edge of the text windows the qualifier /// check over multi-byte output without panicking on a char boundary. #[test] diff --git a/crates/voro-core/src/lib.rs b/crates/voro-core/src/lib.rs index 8b4063e..5bf04ae 100644 --- a/crates/voro-core/src/lib.rs +++ b/crates/voro-core/src/lib.rs @@ -24,7 +24,9 @@ pub use agent::{ VIEWER_BASE_PLACEHOLDER, VIEWER_BRANCH_PLACEHOLDER, VIEWER_PATH_PLACEHOLDER, ViewerTemplate, is_builtin_viewer, parse_sessions_json, render_message, render_session, }; -pub use cap::{CAP_SIGNATURES, CapReading, read_cap, strip_ansi}; +pub use cap::{ + AccountCap, CAP_SIGNATURES, CapReading, CapWindow, parse_reset_epoch, read_cap, strip_ansi, +}; pub use error::{Error, Result}; pub use import::{GithubIssue, already_imported, issue_new_task, issue_task_body}; pub use model::{ diff --git a/crates/voro/src/app.rs b/crates/voro/src/app.rs index ebb4092..7335965 100644 --- a/crates/voro/src/app.rs +++ b/crates/voro/src/app.rs @@ -346,6 +346,29 @@ pub struct AttachRequest { pub cwd: String, } +/// One in-flight session the usage-cap readings are taken for (DESIGN.md §8), +/// resolved on refresh where the agents config is already open so the tick that +/// starts a probe does no I/O of its own to decide what to probe. +/// +/// It names two readings, on two different scales. `logs` replays *this +/// session's* screen and is taken per row; `cap` asks *the account* this row's +/// agent dispatches on, so every row under one agent shares one answer — which +/// is why the agent's name is carried beside its verb rather than the verb +/// alone. +#[derive(Debug, Clone)] +struct CapTarget { + task_id: i64, + /// The reference the agent knows this session by. + session_ref: String, + /// The agent's `logs` verb, without which there is no target at all. + logs: String, + /// The agent that ran the session, which is what an account reading is + /// keyed by. + agent_name: String, + /// That agent's `cap` verb, where it defines one. + cap: Option, +} + /// What the quick-message key needs resolved before it can send: the session /// the line lands in, the template that puts it there, and the listing the /// liveness probe reads to be sure the session is between turns. @@ -540,7 +563,7 @@ pub struct App { /// session reference to read, and the agent's `logs` command. Resolved on /// refresh, where the agents config is already loaded, so the tick that /// starts a probe does no I/O of its own to decide what to probe. - cap_targets: Vec<(i64, String, String)>, + cap_targets: Vec, /// Which in-flight tasks are sitting on a usage cap right now, and when /// each window reopens if the agent said (DESIGN.md §8). Purely a reading /// of current session output — no column, no event, no state change — so it @@ -550,11 +573,25 @@ pub struct App { /// The background threads taking those readings, drained by /// `poll_cap_probes`. cap_probe: crate::probe::CapProbe, + /// What each agent's *account* says about when its window reopens + /// (DESIGN.md §8), for the agents with a badged session on the strip. Held + /// per agent rather than per task because a cap belongs to the account: one + /// reading answers for every session running under it, and an agent Voro + /// cannot ask simply has none. + account_caps: std::collections::HashMap, + /// The background threads taking those readings, drained beside the + /// per-session ones. + account_probe: crate::probe::AccountCapProbe, /// The local wall clock as minutes past midnight, for deciding whether a /// badged reset time has gone by. Refreshed on a slow cadence rather than /// per frame: reading it costs a subprocess, and a badge that flips from /// "waiting" to "window open" within half a minute is timely enough. pub now_minutes: Option, + /// The same moment as seconds since the Unix epoch, which is what an + /// agent's own reset instant is compared against. Costs no subprocess, and + /// is refreshed beside `now_minutes` so the two halves of a badge cannot + /// disagree about when now is. + pub now_epoch: Option, /// When `now_minutes` was last read. clock_read_at: Option, @@ -655,7 +692,10 @@ impl App { cap_targets: Vec::new(), caps: std::collections::HashMap::new(), cap_probe: crate::probe::CapProbe::default(), + account_caps: std::collections::HashMap::new(), + account_probe: crate::probe::AccountCapProbe::default(), now_minutes: None, + now_epoch: None, clock_read_at: None, cockpit_rows: Vec::new(), cockpit_sel: 0, @@ -1050,7 +1090,11 @@ impl App { /// it by, and an agent defining a `logs` verb — so an agent without one /// contributes no targets and is probed for nothing, which is how the whole /// feature stays absent for `codex` rather than failing loudly on it. - fn resolve_cap_targets(&self, config: Option<&AgentsConfig>) -> Vec<(i64, String, String)> { + /// + /// It carries the agent it ran under, and that agent's `cap` verb where + /// there is one, because the second reading is taken per *account* and this + /// is where the config is already open. + fn resolve_cap_targets(&self, config: Option<&AgentsConfig>) -> Vec { let Some(config) = config else { return Vec::new(); }; @@ -1063,12 +1107,40 @@ impl App { return None; } let session_ref = session.session_ref.clone()?; - let logs = config.agent(&session.agent)?.logs()?.to_string(); - Some((r.task_id, session_ref, logs)) + let agent = config.agent(&session.agent)?; + Some(CapTarget { + task_id: r.task_id, + session_ref, + logs: agent.logs()?.to_string(), + agent_name: session.agent.clone(), + cap: agent.cap().map(str::to_string), + }) }) .collect() } + /// What the account this task's session runs under says about its cap, when + /// anything has been read for it (DESIGN.md §8). The badge and the sweep + /// both go through here rather than reaching for the map, so the answer a + /// row shows is the one its own agent gave. + pub fn account_cap(&self, task_id: i64) -> Option<&voro_core::AccountCap> { + let target = self.cap_targets.iter().find(|t| t.task_id == task_id)?; + self.account_caps.get(&target.agent_name) + } + + /// The cap window for one badged task: its session's screen and its + /// account's instant resolved into the single answer both the badge and the + /// sweep read (DESIGN.md §8). + pub fn cap_window(&self, task_id: i64) -> Option { + let reading = self.caps.get(&task_id)?; + Some(voro_core::CapWindow::resolve( + reading, + self.account_cap(task_id), + self.now_minutes, + self.now_epoch, + )) + } + /// Advance the usage-cap readings behind the running strip's badge /// (DESIGN.md §8). Both halves are non-blocking: the `logs` verb runs on a /// background thread, because replaying a session's screen takes the better @@ -1078,6 +1150,11 @@ impl App { /// the last one standing, which is the whole of the self-clearing rule: the /// operator continues a capped session, its next output no longer says /// "limit reached", and the badge is gone on the following pass. + /// + /// The account reading rides the same pass and is gated on the badges this + /// one produces: it is asked only while a session of that agent is sitting + /// on a cap, because unlike the screen replay it spends an API call to ask + /// (DESIGN.md §8). pub fn poll_cap_probes(&mut self) { for (task_id, reading) in self.cap_probe.take_results() { match reading { @@ -1089,26 +1166,65 @@ impl App { } } } + for (agent, reading) in self.account_probe.take_results() { + match reading { + Some(reading) => { + self.account_caps.insert(agent, reading); + } + None => { + self.account_caps.remove(&agent); + } + } + } // A task that has left the strip — finished, stalled, redispatched — // keeps neither a badge nor a debounce. let live: std::collections::HashSet = - self.cap_targets.iter().map(|(id, _, _)| *id).collect(); + self.cap_targets.iter().map(|t| t.task_id).collect(); self.caps.retain(|id, _| live.contains(id)); self.cap_probe.retain(&live); let now = std::time::Instant::now(); - let due: Vec<(i64, String, String)> = self + self.refresh_clock(now); + + let due: Vec = self .cap_targets .iter() - .filter(|(id, _, _)| self.cap_probe.due(*id, now)) + .filter(|t| self.cap_probe.due(t.task_id, now)) .cloned() .collect(); - for (task_id, session_ref, logs) in due { - self.cap_probe.start(task_id, session_ref, logs, now); + for target in due { + self.cap_probe + .start(target.task_id, target.session_ref, target.logs, now); } - self.refresh_clock(now); + // An agent is worth asking about only while something under it is + // badged, and stops being worth holding a reading for the moment + // nothing is. + let capped: std::collections::HashSet = self + .cap_targets + .iter() + .filter(|t| self.caps.contains_key(&t.task_id)) + .map(|t| t.agent_name.clone()) + .collect(); + self.account_caps.retain(|agent, _| capped.contains(agent)); + self.account_probe.retain(&capped); + + let Some(now_epoch) = self.now_epoch else { + return; + }; + // Collected by agent rather than by row: several capped sessions under + // one agent are one account, and so one reading. + let asking: std::collections::BTreeMap = self + .cap_targets + .iter() + .filter(|t| capped.contains(&t.agent_name)) + .filter_map(|t| Some((t.agent_name.clone(), t.cap.clone()?))) + .filter(|(agent, _)| self.account_probe.due(agent, now, self.now_epoch)) + .collect(); + for (agent, cap_cmd) in asking { + self.account_probe.start(agent, cap_cmd, now, now_epoch); + } } /// Hand back a reading as though a background probe had produced it, so @@ -1118,10 +1234,17 @@ impl App { self.cap_probe.inject_result(task_id, reading); } + /// Hand back an account reading the same way, so the precedence the badge + /// and the sweep read it by can be tested without spending an API call. + #[cfg(test)] + pub fn inject_account_cap(&mut self, agent: &str, reading: Option) { + self.account_probe.inject_result(agent, reading); + } + /// How many in-flight sessions can be read for a cap this pass. #[cfg(test)] pub fn cap_target_ids(&self) -> Vec { - self.cap_targets.iter().map(|(id, _, _)| *id).collect() + self.cap_targets.iter().map(|t| t.task_id).collect() } /// Keep the wall clock the reset badge is judged against roughly current, @@ -1139,6 +1262,7 @@ impl App { } self.clock_read_at = Some(now); self.now_minutes = crate::session_probe::local_minutes(); + self.now_epoch = crate::session_probe::local_epoch(); } /// Record every revision a background capture has finished (DESIGN.md §8). @@ -2215,17 +2339,17 @@ impl App { /// one that is up, `running`, and *not* mid-turn. Nothing else in the cockpit /// can tell those apart, so nothing else may skip the guards. fn nudge_capped(&mut self) { - let now = self.now_minutes; // A cap whose time never parsed is the operator's call, not the clock's: // they pressed the key, and a nudge sent early is refused by the agent // rather than doing harm. This is the one rule an automatic sweep would // have to invert — with no keypress behind it, an untimed cap has - // nothing saying the window has opened. + // nothing saying the window has opened — and it is the rule the + // account's own instant retires case by case: a cap the screen never + // timed is timed after all once the agent has said when. let (mut ready, mut waiting): (Vec, Vec) = (Vec::new(), Vec::new()); - for (id, reading) in &self.caps { - let due = - reading.reset_minutes.is_none() || now.is_some_and(|now| reading.reset_passed(now)); - if due { &mut ready } else { &mut waiting }.push(*id); + for id in self.caps.keys().copied() { + let due = self.cap_window(id).is_none_or(|window| window.due()); + if due { &mut ready } else { &mut waiting }.push(id); } // A sweep visits the strip in a stable order rather than the map's. ready.sort_unstable(); @@ -5826,10 +5950,21 @@ mod tests { // --- capped-but-alive sessions (task #415) --- + /// [`cap_env`] with an account-level `cap` verb as well (task #441), for the + /// tests that watch what asking the account costs. The verb is a plain + /// template line, so a test can spell one that records every time it ran. + fn cap_env(define_logs: bool, logs_output: &str) -> (App, i64, std::path::PathBuf) { + cap_env_with(define_logs, logs_output, "") + } + /// A project with one live dispatch whose agent's `logs` verb prints /// `logs_output`, which is the whole of what the cap probe reads. /// `define_logs` takes the verb away, for the degradation case. - fn cap_env(define_logs: bool, logs_output: &str) -> (App, i64, std::path::PathBuf) { + fn cap_env_with( + define_logs: bool, + logs_output: &str, + cap_verb: &str, + ) -> (App, i64, std::path::PathBuf) { let (mut store, ctx, project_path) = scratch_env("caps", None); let listing = project_path.parent().unwrap().join("listing.json"); // The session stays listed live, so reconcile-on-read leaves the task @@ -5862,7 +5997,7 @@ mod tests { dispatch = \"cat {{prompt_file}} && sleep 30\"\n\ sessions = \"cat '{}'\"\n\ message = \"cat {{prompt_file}} >> '{}' # {{session}}\"\n\ - stop = \"printf '%s' {{session}} >> '{}'\"\n{logs}", + stop = \"printf '%s' {{session}} >> '{}'\"\n{logs}{cap_verb}", listing.display(), delivered.display(), stopped.display() @@ -5973,6 +6108,170 @@ mod tests { let _ = std::fs::remove_dir_all(project_path.parent().unwrap()); } + // --- the account's own reset instant (task #441) --- + + /// Drive the pass until the account reading lands, which like the session + /// one is a background thread running a subprocess. + fn settle_account(app: &mut App, agent: &str, want: bool) { + for _ in 0..200 { + app.poll_cap_probes(); + if app.account_caps.contains_key(agent) == want { + return; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + panic!("the account reading never settled to {want}"); + } + + /// The headline case (DESIGN.md §8): the account says when its window + /// reopens, as an instant, and that answers for the badge — including for a + /// cap whose own wording named no time at all, which before this had + /// nothing to show and nothing for a clock to decide. + #[test] + fn the_accounts_instant_times_a_cap_the_screen_left_untimed() { + let (mut app, task_id, project_path) = cap_env(true, "Weekly limit reached"); + settle_cap(&mut app, task_id, true); + assert_eq!(app.caps[&task_id].reset_minutes, None); + assert!( + app.cap_window(task_id).expect("a window").due(), + "an untimed cap is the operator's call until something times it" + ); + + let now = crate::session_probe::local_epoch().expect("a clock"); + app.now_epoch = Some(now); + app.inject_account_cap( + "stub", + Some(voro_core::AccountCap { + reset_epoch: now + 3600, + reset_label: Some("09:00".into()), + }), + ); + settle_account(&mut app, "stub", true); + + let window = app.cap_window(task_id).expect("a window"); + assert_eq!(window.label.as_deref(), Some("09:00")); + assert!(window.timed && !window.passed); + assert!(!window.due(), "the window is known to be shut"); + + let _ = std::fs::remove_dir_all(project_path.parent().unwrap()); + } + + /// The account's instant decides over the session's own screen, which is + /// the whole point of asking for it: `9:50pm` on screen is a bare clock + /// time resolved by nearest occurrence, and an instant simply is one. Here + /// the two disagree — the parse says the reset is still an hour off — and + /// the sweep goes by the instant. + #[test] + fn the_sweep_goes_by_the_accounts_instant() { + let (mut app, task_id, project_path) = + cap_env(true, "Session limit reached - Retrying in 5m (9:50pm)"); + settle_cap(&mut app, task_id, true); + // An hour short of the 21:50 the screen named: on the parse alone this + // session is waiting, and the sweep would leave it alone. + app.now_minutes = Some(20 * 60 + 50); + assert!(!app.cap_window(task_id).expect("a window").due()); + + let now = crate::session_probe::local_epoch().expect("a clock"); + app.now_epoch = Some(now); + app.inject_account_cap( + "stub", + Some(voro_core::AccountCap { + reset_epoch: now - 60, + reset_label: Some("21:50".into()), + }), + ); + settle_account(&mut app, "stub", true); + assert!(app.cap_window(task_id).expect("a window").passed); + + key(&mut app, KeyCode::Char('u')); + assert_eq!( + delivered(&project_path).as_deref().map(str::trim), + Some(NUDGE), + "the account's instant says the window is open" + ); + + let _ = std::fs::remove_dir_all(project_path.parent().unwrap()); + } + + /// What asking costs is the reason this reading is gated rather than + /// scheduled: the verb spends an API call, so a fleet with nothing badged + /// never runs it at all. + #[test] + fn a_healthy_fleet_never_asks_the_account() { + let asked = std::env::temp_dir().join(format!("voro-cap-asked-{}", std::process::id())); + let _ = std::fs::remove_file(&asked); + let (mut app, _, project_path) = cap_env_with( + true, + "running the test suite", + &format!("cap = \"printf 'x' >> '{}'\"\n", asked.display()), + ); + for _ in 0..20 { + app.poll_cap_probes(); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(app.caps.is_empty(), "{:?}", app.caps); + assert!(!asked.exists(), "an uncapped fleet asked anyway"); + + let _ = std::fs::remove_dir_all(project_path.parent().unwrap()); + } + + /// And once something *is* badged, the account is asked once — not once a + /// tick, and not once per capped session. The reading it lands is the + /// instant the agent reported. + #[test] + fn a_capped_fleet_asks_the_account_once() { + let now = crate::session_probe::local_epoch().expect("a clock"); + let asked = std::env::temp_dir().join(format!("voro-cap-once-{}", std::process::id())); + let _ = std::fs::remove_file(&asked); + let (mut app, task_id, project_path) = cap_env_with( + true, + "Session limit reached", + &format!( + "cap = \"printf 'x' >> '{}'; printf '%s' {}\"\n", + asked.display(), + now + 1800 + ), + ); + settle_cap(&mut app, task_id, true); + settle_account(&mut app, "stub", true); + for _ in 0..20 { + app.poll_cap_probes(); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + assert_eq!(app.account_caps["stub"].reset_epoch, now + 1800); + assert_eq!( + std::fs::read_to_string(&asked).unwrap_or_default(), + "x", + "the account was asked more than once" + ); + let _ = std::fs::remove_file(&asked); + + let _ = std::fs::remove_dir_all(project_path.parent().unwrap()); + } + + /// An agent that cannot say — `codex` names no `cap`, and neither does the + /// stub here — leaves the badge exactly as it was: the clock time off the + /// session's own screen, judged by nearest occurrence. + #[test] + fn an_agent_without_the_cap_verb_still_badges_from_the_screen() { + let (mut app, task_id, project_path) = + cap_env(true, "Session limit reached - Retrying in 5m (9:50pm)"); + settle_cap(&mut app, task_id, true); + app.now_minutes = Some(20 * 60 + 50); + for _ in 0..10 { + app.poll_cap_probes(); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(app.account_caps.is_empty()); + + let window = app.cap_window(task_id).expect("a window"); + assert_eq!(window.label.as_deref(), Some("21:50")); + assert!(window.timed && !window.passed); + + let _ = std::fs::remove_dir_all(project_path.parent().unwrap()); + } + // --- nudging capped sessions back to work (task #416) --- /// What a nudge was told, if anything. diff --git a/crates/voro/src/cli.rs b/crates/voro/src/cli.rs index a41467e..f49af02 100644 --- a/crates/voro/src/cli.rs +++ b/crates/voro/src/cli.rs @@ -2592,7 +2592,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}" ); diff --git a/crates/voro/src/probe.rs b/crates/voro/src/probe.rs index 1e0ff10..a6983bf 100644 --- a/crates/voro/src/probe.rs +++ b/crates/voro/src/probe.rs @@ -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}; @@ -389,6 +392,138 @@ 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 agent's account reading should be taken this tick (DESIGN.md §8). +/// +/// The rule is "ask once, while it matters": a session of this agent 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 capped on a limit the probe does +/// not hit (a weekly model cap behind a healthy account) 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)>, + now: Instant, + now_epoch: Option, +) -> 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 its usage window reopens. +/// +/// It is [`CapProbe`]'s counterpart and differs from it in both directions of +/// what it costs. It is keyed by *agent* rather than by task, because a cap is a +/// property of the account and one reading answers for every session on the +/// strip — where a screen replay has to be taken per session. 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)>, + rx: Receiver<(String, Option)>, + in_flight: HashSet, + /// Each agent's last reading: when it was started, 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)>, +} + +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, agent: &str, now: Instant, now_epoch: Option) -> bool { + account_probe_due( + self.in_flight.contains(agent), + self.last.get(agent).copied(), + now, + now_epoch, + ) + } + + /// Ask `cap_cmd` what this agent's account says, on a background thread. + pub fn start(&mut self, agent: String, cap_cmd: String, now: Instant, now_epoch: i64) { + self.in_flight.insert(agent.clone()); + // Held from the start, not from the answer, so a verb slower than the + // interval cannot be started twice over. + self.last.insert(agent.clone(), (now, None)); + let tx = self.tx.clone(); + std::thread::spawn(move || { + let reading = crate::session_probe::read_account_cap(&cap_cmd, now_epoch); + let _ = tx.send((agent, reading)); + }); + } + + /// Every reading that has landed since the last drain, each tagged with the + /// agent it was asked of. Never blocks. An empty reading is handed back as + /// meaningfully as a full one: it clears the account's answer, leaving the + /// badge to the session's own screen again. + pub fn take_results(&mut self) -> Vec<(String, Option)> { + let mut landed = Vec::new(); + loop { + match self.rx.try_recv() { + Ok((agent, reading)) => { + self.in_flight.remove(&agent); + if let Some(entry) = self.last.get_mut(&agent) { + entry.1 = reading.as_ref().map(|r| r.reset_epoch); + } + landed.push((agent, reading)); + } + Err(TryRecvError::Empty | TryRecvError::Disconnected) => return landed, + } + } + } + + /// Drop the debounce for agents with nothing capped on the strip, so the + /// next cap reads afresh rather than waiting out the last one's interval. + pub fn retain(&mut self, capped: &HashSet) { + self.last.retain(|agent, _| capped.contains(agent)); + } + + /// 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, agent: &str, reading: Option) { + self.in_flight.insert(agent.to_string()); + self.last + .entry(agent.to_string()) + .or_insert((Instant::now(), None)); + let _ = self.tx.send((agent.to_string(), reading)); + } +} + #[cfg(test)] mod tests { use super::*; @@ -674,6 +809,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] diff --git a/crates/voro/src/session_probe.rs b/crates/voro/src/session_probe.rs index dcedf63..ffd58d1 100644 --- a/crates/voro/src/session_probe.rs +++ b/crates/voro/src/session_probe.rs @@ -21,7 +21,8 @@ use std::path::Path; use std::process::{Command, Stdio}; use voro_core::{ - AgentSessionEntry, CapReading, SessionLiveness, parse_sessions_json, read_cap, render_session, + AccountCap, AgentSessionEntry, CapReading, SessionLiveness, parse_reset_epoch, + parse_sessions_json, read_cap, render_session, }; /// Run an agent's `sessions` command and parse its listing, in a given @@ -109,6 +110,69 @@ pub fn read_session_cap(logs_cmd: &str, session_ref: &str) -> Option read_cap(&String::from_utf8_lossy(&output.stdout)) } +/// Run an agent's `cap` command and read the account's reset instant from it +/// (DESIGN.md §8). `None` means "nothing says this account is capped", which is +/// also what a command that would not run answers, and what an account merely +/// spending its window answers: the verb prints an instant while the account is +/// refused and prints nothing otherwise, so silence is the whole negative +/// answer and every way of arriving at it lands the same. +/// +/// Unlike [`read_session_cap`] this costs the agent an API call rather than a +/// screen replay, which is why the caller asks rarely and only while a session +/// is already badged capped. The template names no session and takes no +/// substitution — the answer is about the account, and one of them serves the +/// whole strip. +/// +/// The label is rendered here, beside the reading, because it is the one part +/// that needs the operator's timezone and the render path may not shell out for +/// it. A `date` that cannot render the instant costs the badge its time, not +/// the reading its meaning: the comparison that says whether the window has +/// reopened is made on the instant itself. +pub fn read_account_cap(cap_cmd: &str, now_epoch: i64) -> Option { + let output = Command::new("sh") + .arg("-c") + .arg(cap_cmd) + .stdin(Stdio::null()) + .output() + .ok()?; + let reset_epoch = parse_reset_epoch(&String::from_utf8_lossy(&output.stdout), now_epoch)?; + Some(AccountCap { + reset_epoch, + reset_label: local_label(reset_epoch), + }) +} + +/// An instant as the local `21:50` the badge shows, via the same `date` the +/// wall clock is read from and for the same reason — the agent's instant means +/// nothing to an operator until it is in their own timezone, and the standard +/// library offers no local time. +fn local_label(epoch: i64) -> Option { + let output = Command::new("date") + .arg(format!("-d@{epoch}")) + .arg("+%H:%M") + .stdin(Stdio::null()) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let stamp = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let (hour, minute) = stamp.split_once(':')?; + let (hour, minute): (u16, u16) = (hour.parse().ok()?, minute.parse().ok()?); + (hour < 24 && minute < 60).then_some(stamp) +} + +/// The wall clock as seconds since the Unix epoch, which is what an agent's own +/// reset instant is compared against ([`CapWindow::resolve`]). No subprocess and +/// no timezone: an instant is an instant, which is the whole reason it beats the +/// clock time on a session's screen. +pub fn local_epoch() -> Option { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .map(|since| since.as_secs() as i64) +} + /// The local wall clock as minutes past midnight, which is what a bare reset /// time in an agent's output is compared against ([`CapReading::reset_passed`]). /// @@ -281,6 +345,47 @@ mod tests { assert!(minutes < 24 * 60, "{minutes}"); } + /// The `cap` verb end to end: an agent printing an instant is read as one, + /// and rendered into the local time the badge shows. + #[test] + fn a_capped_account_reads_as_an_instant_with_its_label() { + let now = local_epoch().expect("a clock"); + let reading = read_account_cap(&format!("printf '%s\\n' {}", now + 3600), now) + .expect("an account reading"); + assert_eq!(reading.reset_epoch, now + 3600); + let label = reading.reset_label.expect("a rendered label"); + assert_eq!(label.len(), 5, "{label}"); + assert!(label.as_bytes()[2] == b':', "{label}"); + } + + /// Every way of learning nothing lands the same, because the verb's + /// negative answer *is* silence: an account with room left prints nothing, + /// and so does a command that would not run. + #[test] + fn an_uncapped_or_unreadable_account_reads_as_nothing() { + let now = local_epoch().expect("a clock"); + for cmd in ["true", "false", "exit 127", "printf ''", "printf 'allowed'"] { + assert_eq!(read_account_cap(cmd, now), None, "{cmd}"); + } + // And a number that cannot be a reset instant is not read as one. + assert_eq!(read_account_cap("printf '2'", now), None); + } + + /// The two clocks agree about now: the instant a reset is compared against + /// and the minutes-past-midnight a parsed time is compared against are + /// readings of the same moment, or the badge contradicts itself. + #[test] + fn both_clocks_read_the_same_moment() { + let epoch = local_epoch().expect("a clock"); + let minutes = local_minutes().expect("a local clock"); + let label = local_label(epoch).expect("a rendered label"); + let (hour, minute) = label.split_once(':').expect("HH:MM"); + let rendered: u16 = hour.parse::().unwrap() * 60 + minute.parse::().unwrap(); + // A minute may tick between the two readings, midnight included. + let apart = (i32::from(rendered) - i32::from(minutes)).rem_euclid(1440); + assert!(apart <= 1, "{label} vs {minutes} minutes past midnight"); + } + /// The rest reading the send path and the reconciler act on: a session the /// agent still holds registered with its turn ended. `blocked` — a /// permission prompt, a supervisor mid-turn — is the one that must not diff --git a/crates/voro/src/ui.rs b/crates/voro/src/ui.rs index bb1e0e2..a54181d 100644 --- a/crates/voro/src/ui.rs +++ b/crates/voro/src/ui.rs @@ -5,7 +5,7 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}; use voro_core::{ - ActionRow, CapReading, CompletionReport, DepKind, DepRef, DigestRow, EffectiveScore, Event, + ActionRow, CapWindow, CompletionReport, DepKind, DepRef, DigestRow, EffectiveScore, Event, QueueRow, ScoreBreakdown, Session, SessionOutcome, StateCounts, TaskState, }; @@ -723,15 +723,21 @@ fn strip_pr_span() -> Span<'static> { /// in progress. /// /// Three shapes, in decreasing order of what Voro managed to learn. With a -/// parsed reset time still ahead, `⚠ capped ↻21:50` — the operator can decide +/// reset time still ahead, `⚠ capped ↻21:50` — the operator can decide /// whether to wait. Past that time the window is open and the session is merely /// waiting to be nudged, which is a different situation and a different thing -/// to do about it, so it says so. With no time parsed at all, the bare badge: +/// to do about it, so it says so. With no time learned at all, the bare badge: /// the cap is the part worth knowing, and suppressing it for want of a /// timestamp would trade the whole signal for a detail. -fn capped_span(reading: &CapReading, now_minutes: Option) -> Span<'static> { - let past = now_minutes.is_some_and(|now| reading.reset_passed(now)); - let text = match (reading.reset_label(), past) { +/// +/// The time itself comes from whichever source could give it — the account's +/// own instant where the agent reports one, the clock time on the session's +/// screen otherwise ([`CapWindow`]) — and the badge is the same either way. It +/// is only the *third* shape that differs, and invisibly: "reset passed" read +/// off an instant is a fact, where read off a bare `6:40pm` it is the nearest +/// occurrence of a time that carries no date. +fn capped_span(window: &CapWindow) -> Span<'static> { + let text = match (&window.label, window.passed) { (_, true) => " ⚠ capped · reset passed".to_string(), (Some(at), false) => format!(" ⚠ capped ↻{at}"), (None, false) => " ⚠ capped".to_string(), @@ -1441,8 +1447,8 @@ fn draw_running(frame: &mut Frame, app: &App, area: Rect, hits: &mut HitMap) { spans.push(strip_pr_span()); } } - if let Some(reading) = app.caps.get(&r.task_id) { - spans.push(capped_span(reading, app.now_minutes)); + if let Some(window) = app.cap_window(r.task_id) { + spans.push(capped_span(&window)); } // A hand-off has nothing left to be live: the work is with someone // else, so a closed session is the expected shape, not an orphan. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 1ee47c7..fed7536 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -337,7 +337,11 @@ A session's entry in the *agent's own* registry follows its row in the same way: That same channel answers a question the reconciler could not previously ask at all. **A usage cap does not kill a supervisor-owned session**: the supervisor stays alive and the session sits waiting for the window to reset, so a capped dispatch is never *dead*, never reconciled, and rides the running strip looking healthy for hours. Reading `logs` for a *live* `running`/`refining` session with a captured ref closes that hole, and the result is rendered as a badge on the strip row — `⚠ capped ↻21:50` with the reset time where the agent named one, `⚠ capped` where it did not, and `⚠ capped · reset passed` once that time has gone by, which is a different situation (the window is open; the session wants a nudge) and so says so. The reset time is parsed best-effort from a bare clock time, resolved to whichever occurrence is nearest — taking the *next* one would claim another day's wait a minute after the window reopened. -Three properties keep that badge honest. It carries **no state change**: `stalled` means "dead dispatch, redispatch me", and a capped session is neither dead nor in need of redispatch, so the task stays `running` and the session stays open. It is **not schema**: the reading is held in memory, recomputed, and never written, which is what makes it self-clearing — the operator continues the session, its next output no longer says "limit reached", and the badge is gone on the following pass rather than needing to be retracted. And it is **off the event loop**: the verb costs the better part of a second per session, which the render path may never wait on (see *What may block the TUI event loop*), so it runs on a background thread and is debounced to one reading per session per minute — the one probe in the TUI debounced against the clock rather than against the selection, since every in-flight session is a target on every tick. There is deliberately no cockpit-header quota gauge: the statusline JSON that carries `rate_limits.five_hour.resets_at` is pushed *to* running Claude sessions and is not readable by Voro, so a gauge would need a data source that does not exist. +That parse is the weak link in the chain, and there is a second source that is not a parse at all. A cap is a property of the **account**, not of a session, and an agent that can report its own can report it as an *instant*: Claude Code's stream transport emits a `rate_limit_event` carrying `resetsAt`, Unix epoch seconds. So the verb set gains an optional **`cap`**, an agent contract like the rest and the only one that names nothing — no `{session}`, because one reading answers for every session on the strip. Its output is one number, and its negative answer is *silence*: print the instant while the account is refused, print nothing otherwise, so an uncapped account, a failed command and an agent that defines none all land identically on the screen parse. `codex` defines none. Where a reading exists it decides both halves of the badge — the time shown and whether the window has reopened — and the parse stays underneath it for everything else. Two consequences are priced rather than discovered. Unlike `logs`, this verb *spends an API call*: it is therefore asked only while a session is already badged capped, once per capped episode (again once if the instant it named has since passed, since the account may have entered a new window), keyed by agent so several capped sessions are one question. And an account is refused by *one* window while a session may be held by another — a weekly model limit behind a five-hour account cap — so the instant can be early for that session; that is the failure this section already prices as cheap, a nudge that re-caps at once. + +The point of the exactness is not the badge, which reads the same either way. It is that a bare clock time is ambiguous by half a day and an instant is not, and that a cap the screen never timed is timed after all once the account has said when — the two properties an automatic sweep needs and a keypress-driven one can do without. + +Three properties keep that badge honest. It carries **no state change**: `stalled` means "dead dispatch, redispatch me", and a capped session is neither dead nor in need of redispatch, so the task stays `running` and the session stays open. It is **not schema**: the reading is held in memory, recomputed, and never written, which is what makes it self-clearing — the operator continues the session, its next output no longer says "limit reached", and the badge is gone on the following pass rather than needing to be retracted. And it is **off the event loop**: the verb costs the better part of a second per session, which the render path may never wait on (see *What may block the TUI event loop*), so it runs on a background thread and is debounced to one reading per session per minute — the one probe in the TUI debounced against the clock rather than against the selection, since every in-flight session is a target on every tick. There is deliberately no cockpit-header quota gauge. The statusline JSON that carries `rate_limits.five_hour.resets_at` is pushed *to* running Claude sessions and is not readable by Voro at all; the `cap` verb above could feed a gauge, but a gauge wants a *continuous* reading and that verb costs an API call every time it is asked, which is exactly the shape this design refuses — it is asked when something is already known to be stuck, never on a cadence. **Recovering a capped session** is then one key, `u`, which nudges *every* badged session whose reset has gone by. A cap does not retry: it ends the session's turn and leaves it sitting there, so work waits for a human however long ago the window reopened — and walking the strip by hand costs an attach, a typed word and a detach per session, which is how the overnight reset hours get lost. The sweep is that walk as a single keystroke, and it reports what it did: how many it nudged, how many are still before their reset, and any the agent refused. The message it sends is one word, *continue*, because the session already holds the whole task — its transcript, its worktree, its half-written work — and anything longer would be Voro restating a brief the agent can already read. @@ -345,7 +349,7 @@ It goes out through the existing `message` verb rather than through any new chan Two costs come with that unconditional stop, priced rather than discovered. The stop is exactly as safe as the cap reading is right — the same bet the sweep already makes when it skips the guards — but the *consequence* of a wrong reading is worse than it was under a forked send: a nudge into a session that turned out to be mid-turn was once a redundant turn and is now a killed one. And the reading is debounced to one probe per session per minute, so it can be that stale: a session an operator restarted by hand a moment ago is still badged, and can be stopped from under them. Neither is a reason to route around the release — a fork would land, but at the price the whole delivery model was changed to avoid — and both are reasons the sweep stays on a keypress rather than on the clock. -Two guards are deliberately stood down for it, and the cap reading is what earns that. A quick message is refused on a `running` task because its session is mid-turn, and refused again when the session is listed live — but a capped session is `running`, listed live, and *not* mid-turn, which is the one combination nothing else in the cockpit can recognise. Nothing else may skip those guards, and standing them down is what obliges the sweep to release its own target rather than trusting the rest rule to have done it (above). The sweep fires only when pressed, and that is a staging decision rather than a principle: automatic resumption once the window reopens is wanted, and this is deliberately the half it can be built on top of. Manual first buys the evidence automation needs — that a nudge reliably lands, and that the badge it would key on does not false-positive — while a wrong reading still costs one keypress instead of an unwatched agent. What automation adds is a trigger, not a channel: the reset-passed test the badge already computes, evaluated on the tick rather than on the key, plus a bound so a session that will not restart is not nudged around the clock. A cap whose reset time never parsed is swept too — the operator pressing the key is the judgement the clock could not supply, and a send that turns out to be early costs a released session and a turn that re-caps at once rather than doing lasting harm, the conversation surviving the release either way — and that is precisely a case automation must decide differently, since no keypress would stand behind it. Because the nudged turn does real work, it depends on the `message` verb's permission mode (above) exactly as a dispatch does: without it the refusals land in the launch log and the send appears delivered while quietly doing nothing, which is the one failure a fire-and-forget channel cannot report. +Two guards are deliberately stood down for it, and the cap reading is what earns that. A quick message is refused on a `running` task because its session is mid-turn, and refused again when the session is listed live — but a capped session is `running`, listed live, and *not* mid-turn, which is the one combination nothing else in the cockpit can recognise. Nothing else may skip those guards, and standing them down is what obliges the sweep to release its own target rather than trusting the rest rule to have done it (above). The sweep fires only when pressed, and that is a staging decision rather than a principle: automatic resumption once the window reopens is wanted, and this is deliberately the half it can be built on top of. Manual first buys the evidence automation needs — that a nudge reliably lands, and that the badge it would key on does not false-positive — while a wrong reading still costs one keypress instead of an unwatched agent. What automation adds is a trigger, not a channel: the reset-passed test the badge already computes, evaluated on the tick rather than on the key, plus a bound so a session that will not restart is not nudged around the clock. A cap whose reset time never parsed is swept too — the operator pressing the key is the judgement the clock could not supply, and a send that turns out to be early costs a released session and a turn that re-caps at once rather than doing lasting harm, the conversation surviving the release either way — and that is precisely a case automation must decide differently, since no keypress would stand behind it. It is also the case the account's own instant retires one session at a time: a cap the screen left untimed is timed once the agent has said when, and the sweep then leaves it alone until the window is genuinely open rather than spending the operator's judgement on it. Because the nudged turn does real work, it depends on the `message` verb's permission mode (above) exactly as a dispatch does: without it the refusals land in the launch log and the send appears delivered while quietly doing nothing, which is the one failure a fire-and-forget channel cannot report. A dispatched process must also be reaped once it exits, or it sits as a zombie for the life of the spawning `voro` process — and `kill -0` on a zombie still reports it alive, which would silently defeat this whole mechanism in a long-lived TUI session. Dispatch therefore hands the child to a detached reaper thread the moment the session is recorded, rather than leaving it to `Drop`. diff --git a/docs/agent-integration.md b/docs/agent-integration.md index 10c9ea9..720c6b7 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -79,6 +79,7 @@ attach = "claude attach {session}" resume = "claude --resume {session}" message = "claude -p --resume {session} --permission-mode auto \"$(cat {prompt_file})\"" logs = "claude logs \"$(printf %.8s {session})\" 2>/dev/null | tail -c 20000" +cap = '''timeout 120 claude -p --output-format stream-json --verbose --model haiku hi 2>/dev/null | grep -o '"status":"rejected"[^}]*"resetsAt":[0-9]*' | grep -o '[0-9][0-9]*$' | tail -1''' stop = "claude stop \"$(printf %.8s {session})\"" plan = "claude --name \"{session_name}\" --permission-mode auto --model {model} \"$(cat {prompt_file})\"" model = "opus" @@ -176,6 +177,30 @@ resume = "codex resume {session}" the built-in does; Voro reads whatever it prints. Note the built-in's truncation: `claude logs` keys on the *job* id, the first eight characters of the session id, so `{session}` is trimmed rather than passed whole. +- `cap` prints **when the account's usage window reopens**, as a Unix epoch in + seconds, and takes no placeholder at all: a cap belongs to the account rather + than to any one session, so a single reading answers for every session on the + strip (DESIGN.md §8). Its negative answer is silence — print the instant while + the account is *refused*, print nothing otherwise — so an account with room + left, a command that fails, and an agent naming no `cap` are all the same + thing to Voro: the reset time falls back to the clock time on the session's + own screen, which is where it came from before this verb existed. Print a bare + number; Voro reads the last plausible one, and refuses anything that is not a + timestamp near the present. + + Voro reads it for the two things a bare clock time cannot give: an unambiguous + answer to whether the window has already reopened (`6:40pm` carries no date), and + a reset time for a cap that never named one. The built-in `claude` spelling + filters `rate_limit_event` out of the CLI's own stream — `"status":"rejected"` + is what distinguishes a cap the account is *held at* from a note on the window + it is merely spending — and asks with a cheap model of its own choosing, since + the answer is the same whichever one is refused. + + This is the one verb that costs an agent real work to answer, so Voro asks + rarely: only while a session is already badged capped, once per capped episode, + and once more only if the instant it named has passed while the badge is still + up. It is spawned like any other verb, so a template that could hang should + bound itself — the built-in wraps its call in `timeout`. - `stop` retires a session from the agent's own registry, taking `{session}` alone. Voro fires it on three triggers (DESIGN.md §8), and all three must be safe before you define it. From aab121fad9daf6bee3b9fcd51b4ded24a83a69f8 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Fri, 14 Aug 2026 23:14:16 +0100 Subject: [PATCH 2/2] Ask the cap verb on the session's own model, not once for the account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An account does not have one usage window. A Claude subscription meters the five-hour pool, the weekly one, and a separate allowance for each strong model — "Opus limit", "Sonnet limit", "Fable 5 limit" in the CLI's own wording, with fast mode drawing on its own pools again. A reading taken with one model therefore answers about that model's window, and answers earlier than the truth whenever a cheaper pool reopens first: exactly the error that would fire a nudge into a session still held. So `cap` may carry {model} — the only placeholder it may carry — and Voro binds the model the session launched under, resolved through the same rule the launch used (model_for_depth, now shared with launch_command so the two cannot drift). The reading is keyed by the rendered question rather than by the agent, which is what makes both shapes fall out without either being special: a template naming {model} is asked once per model in flight, one that names none is asked once for the agent, and sessions asking the same thing share an answer. Asking on the session's own model also makes the case that matters free — a refused request is a rejection rather than a turn and bills nothing — so the only probe that costs is one that finds the account healthy, and that one prints nothing. Verified: cargo test --workspace (902 pass), clippy and fmt clean. Driven in the TUI with a stub whose cap verb answers per model: a deep task's row badged the deep model's window (01:12) rather than the workhorse's earlier one (23:22), which is the reading it would have taken before this change. --- CHANGELOG.md | 18 ++-- crates/voro-core/src/agent.rs | 161 +++++++++++++++++++++++++--------- crates/voro-core/src/lib.rs | 3 +- crates/voro/src/app.rs | 157 ++++++++++++++++++++++----------- crates/voro/src/probe.rs | 85 ++++++++++-------- docs/DESIGN.md | 6 +- docs/agent-integration.md | 38 +++++--- 7 files changed, 314 insertions(+), 154 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f2b2c4..7899293 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,14 +15,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 — one reading - answering for every session on the strip, since a cap belongs to the account - rather than to any one conversation. 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. 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. + 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 diff --git a/crates/voro-core/src/agent.rs b/crates/voro-core/src/agent.rs index 6824e90..2eb0916 100644 --- a/crates/voro-core/src/agent.rs +++ b/crates/voro-core/src/agent.rs @@ -152,13 +152,20 @@ pub const VIEWER_BASE_PLACEHOLDER: &str = "{base}"; /// since the same header the message renders its time from is where this comes /// from. /// -/// Two properties of the spelling are load-bearing. It prints *nothing* when -/// the account is not refused, which is the contract's whole negative answer; -/// and it names `--model haiku` rather than `{model}`, because unlike every -/// other verb this one spends an API call to ask — a cent or two, mostly cache -/// creation — and the answer is the same whichever model is refused. The -/// `timeout` is the belt to that brace: a cap is not retried (§8), so the turn -/// ends at once, but nothing in Voro should wait on an agent indefinitely. +/// It asks with `{model}` — the model whose window is in question — because a +/// Claude subscription meters more than one: the five-hour pool, the weekly +/// one, and a separate allowance for each strong model, which the CLI names +/// "Opus limit", "Sonnet limit" and "Fable 5 limit". A probe on the wrong model +/// would answer for the wrong window, and answer *earlier* than the truth +/// whenever a cheap model's pool reopens first. Asking on the session's own +/// model also makes the case that matters free: a refused request is a 429 and +/// bills nothing, so the only probe that costs a turn is one that finds the +/// account healthy — and prints nothing. +/// +/// The other load-bearing property is that it prints nothing when the account +/// is not refused, which is the contract's whole negative answer. The `timeout` +/// is the belt to that brace: a cap is not retried (§8), so the turn ends at +/// once, but nothing in Voro should wait on an agent indefinitely. /// /// The claude `stop` verb retires a session from the agent's own listing once /// Voro closes its row — and, at rest, once it hands back (DESIGN.md §8): the @@ -180,7 +187,7 @@ attach = \"claude attach {session}\" resume = \"claude --resume {session}\" message = \"claude -p --resume {session} --permission-mode auto \\\"$(cat {prompt_file})\\\"\" logs = \"claude logs \\\"$(printf %.8s {session})\\\" 2>/dev/null | tail -c 20000\" -cap = '''timeout 120 claude -p --output-format stream-json --verbose --model haiku hi 2>/dev/null | grep -o '\"status\":\"rejected\"[^}]*\"resetsAt\":[0-9]*' | grep -o '[0-9][0-9]*$' | tail -1''' +cap = '''timeout 120 claude -p --output-format stream-json --verbose --model {model} hi 2>/dev/null | grep -o '\"status\":\"rejected\"[^}]*\"resetsAt\":[0-9]*' | grep -o '[0-9][0-9]*$' | tail -1''' stop = \"claude stop \\\"$(printf %.8s {session})\\\"\" plan = \"claude --name \\\"{session_name}\\\" --permission-mode auto --model {model} \\\"$(cat {prompt_file})\\\"\" model = \"opus\" @@ -315,8 +322,10 @@ const STARTER_HEADER: &str = r#"# Voro configuration (~/.config/voro/voro.toml). # template — Voro reads whatever it prints. # cap print when the account's usage window reopens, as a Unix # epoch, while the account is capped — and nothing when it is -# not (no placeholders). Read instead of the clock time on a -# session's screen, which carries no date. Costs whatever +# not. Read instead of the clock time on a session's screen, +# which carries no date. It may name {model} and nothing else: +# a subscription meters each strong model separately, so the +# window that refuses depends on which one asks. Costs whatever # asking the agent costs, so Voro asks only while a session is # badged capped. # stop retire a session from the agent's own registry ({session}) @@ -432,11 +441,14 @@ pub struct AgentTemplate { /// produce output for a session simply omits it, and Voro classifies a dead /// session from the launch log as it always has and badges no live one. logs: Option, - /// When the *account* this agent dispatches on has its usage window - /// reopen, as a Unix epoch and nothing else (DESIGN.md §8). Carries no - /// placeholder at all: it names no session, because a cap is a property of - /// the account rather than of any one conversation, and one reading - /// therefore answers for every session on the strip. + /// When the account this agent dispatches on has its usage window reopen, + /// as a Unix epoch and nothing else (DESIGN.md §8). It names no session — a + /// cap is a property of the account, not of any one conversation — and + /// [`MODEL_PLACEHOLDER`] is the only placeholder it may carry, because + /// *which* window refuses depends on which model is asking: a subscription + /// meters the five-hour pool, the weekly one, and each strong model's own + /// allowance separately. A template that binds it is asked once per model in + /// flight; one that does not is asked once for the agent. /// /// Its contract is silence-as-negative like [`AgentTemplate::logs`]: print /// the instant while the account is refused, print nothing otherwise. An @@ -521,6 +533,14 @@ impl AgentTemplate { self.model_plan.as_deref() } + /// The model a launch of this agent at the given depth runs with, by the + /// same rule [`ResolvedAgent::launch_command`] resolves it by — shared so + /// the two cannot drift, since anything asking *about* a session has to + /// name the model that session actually started under. + pub fn model_for(&self, deep: bool) -> Option<&str> { + model_for_depth(self.model(), self.model_deep(), deep) + } + /// The optional verbs this agent defines, in roster order, as `agent list` /// and the Config screen name them (DESIGN.md §8). A `message` that carries /// [`NEW_SESSION_PLACEHOLDER`] reads `message(fork)`, because forking is @@ -773,6 +793,31 @@ pub struct RenderedMessage { /// launch, shell-quoted so a reference carrying shell metacharacters reaches /// the agent as itself. Serves `logs`, whose whole contract is a session in and /// that session's recent output out. +/// Which model a launch at a given depth runs with (DESIGN.md §8): the deeper +/// one for a deep task where the agent names one, the workhorse otherwise. The +/// one place that rule lives, because two callers now depend on agreeing about +/// it — the launch itself, and the `cap` reading that has to ask about the +/// window *that* model is metered against. +pub fn model_for_depth<'a>( + model: Option<&'a str>, + model_deep: Option<&'a str>, + deep: bool, +) -> Option<&'a str> { + if deep { model_deep.or(model) } else { model } +} + +/// A `cap` template rendered into a runnable command line (DESIGN.md §8). The +/// model is bound exactly as a launch binds it — pasted in as the opaque name +/// the operator configured, Voro being model-blind — and a template naming no +/// model renders unchanged, which is what makes the per-model question optional +/// rather than required. +pub fn render_cap(template: &str, model: Option<&str>) -> String { + match model { + Some(model) => render(template, &[(MODEL_PLACEHOLDER, model)]), + None => template.to_string(), + } +} + pub fn render_session(template: &str, session_ref: &str) -> String { let session = shell_quote(Path::new(session_ref)); render(template, &[(SESSION_PLACEHOLDER, session.as_str())]) @@ -991,14 +1036,14 @@ fn validate_agent(name: &str, agent: &AgentTemplate, path: &Path) -> Result<()> } } } - // `cap` asks about the account, not about a session or a launch, so every - // placeholder there is a category error: there is nothing for Voro to bind - // one to, and it would reach the shell as literal braces. + // `cap` asks about the account rather than about a session or a launch, so + // every placeholder but `{model}` is a category error there: nothing binds + // it, and it would reach the shell as literal braces. `{model}` is the + // exception because which window refuses depends on which model asks. if let Some(template) = &agent.cap { for placeholder in [ SESSION_PLACEHOLDER, PROMPT_FILE_PLACEHOLDER, - MODEL_PLACEHOLDER, SESSION_NAME_PLACEHOLDER, TASK_ID_PLACEHOLDER, NEW_SESSION_PLACEHOLDER, @@ -1006,7 +1051,8 @@ fn validate_agent(name: &str, agent: &AgentTemplate, path: &Path) -> Result<()> if template.contains(placeholder) { return Err(invalid(format!( "agent '{name}' cap carries {placeholder}, but a cap reading is about the \ - account rather than any one session or launch — it takes no placeholders" + account rather than any one session or launch — {MODEL_PLACEHOLDER} is the \ + only placeholder it may name" ))); } } @@ -1046,9 +1092,13 @@ fn validate_agent(name: &str, agent: &AgentTemplate, path: &Path) -> Result<()> // that drops `{model}` keeps loading), but the placeholder without them // has nothing to resolve to. if agent.model.is_none() - && [dispatch.as_str(), agent.plan.as_deref().unwrap_or_default()] - .iter() - .any(|t| t.contains(MODEL_PLACEHOLDER)) + && [ + dispatch.as_str(), + agent.plan.as_deref().unwrap_or_default(), + agent.cap.as_deref().unwrap_or_default(), + ] + .iter() + .any(|t| t.contains(MODEL_PLACEHOLDER)) { return Err(invalid(format!( "agent '{name}' uses {MODEL_PLACEHOLDER} but sets no model — add model = \ @@ -1112,11 +1162,7 @@ impl ResolvedAgent { /// renders the same string either way, the graceful degradation of the /// `deep` flag. pub fn launch_command(&self, spec: &LaunchSpec) -> String { - let model = if spec.deep { - self.model_deep.as_deref().or(self.model.as_deref()) - } else { - self.model.as_deref() - }; + let model = model_for_depth(self.model.as_deref(), self.model_deep.as_deref(), spec.deep); render_launch(&self.dispatch, spec, model) } @@ -2371,10 +2417,9 @@ mod tests { } /// The built-in `claude` defines `cap` and `codex` does not, and the - /// spelling carries the two halves Voro depends on: it asks about the - /// account with a model of its own choosing, and it keeps only the *epoch* - /// out of a rejection, so an account merely spending its window prints - /// nothing. + /// spelling carries the three halves Voro depends on: it asks on the + /// session's own model, it keeps only the *epoch* out of a rejection so an + /// account merely spending its window prints nothing, and it bounds itself. #[test] fn only_the_claude_builtin_defines_cap() { let config = AgentsConfig::load(Path::new("/nonexistent/voro.toml")).unwrap(); @@ -2385,19 +2430,20 @@ mod tests { .expect("a cap verb"); assert!(cap.contains("rejected"), "{cap}"); assert!(cap.contains("resetsAt"), "{cap}"); - assert!(cap.contains("--model haiku"), "{cap}"); + assert!(cap.contains(MODEL_PLACEHOLDER), "{cap}"); + assert!(cap.contains("timeout"), "{cap}"); assert!(config.agent("codex").expect("codex").cap().is_none()); } - /// `cap` takes no placeholders at all. It reads the account rather than a - /// session, so `{session}` has nothing to name — and it is not a launch, - /// so the launch placeholders have nothing to bind either. + /// `cap` may name the model whose window it is asking about, and nothing + /// else. It reads the account rather than a session, so `{session}` has + /// nothing to name, and it is not a launch, so the launch placeholders have + /// nothing to bind either. #[test] - fn cap_is_validated_as_naming_nothing() { + fn cap_names_the_model_and_nothing_else() { for placeholder in [ SESSION_PLACEHOLDER, PROMPT_FILE_PLACEHOLDER, - MODEL_PLACEHOLDER, SESSION_NAME_PLACEHOLDER, TASK_ID_PLACEHOLDER, NEW_SESSION_PLACEHOLDER, @@ -2413,10 +2459,43 @@ mod tests { assert!(message.contains("cap"), "{message}"); assert!(message.contains(placeholder), "{message}"); } - // And a template that names nothing is exactly what the verb wants. - let toml = "[agents.a]\ndispatch = \"run {prompt_file}\"\ncap = \"agent-cap --epoch\"\n"; + // The model is the exception, and a template naming nothing is valid + // too: that agent is asked once rather than once per model. + for cap in ["agent-cap --model {model}", "agent-cap --epoch"] { + let toml = format!( + "[agents.a]\ndispatch = \"run {{prompt_file}}\"\nmodel = \"m\"\ncap = \"{cap}\"\n" + ); + let raw: RawConfig = toml::from_str(&toml).unwrap(); + validate_agent("a", &raw.agents["a"], Path::new("/c.toml")).expect("a valid cap"); + } + // `{model}` with nothing to resolve it to is refused, as on a launch. + let toml = "[agents.a]\ndispatch = \"run {prompt_file}\"\ncap = \"agent-cap {model}\"\n"; let raw: RawConfig = toml::from_str(toml).unwrap(); - validate_agent("a", &raw.agents["a"], Path::new("/c.toml")).expect("a bare cap is valid"); + let err = validate_agent("a", &raw.agents["a"], Path::new("/c.toml")) + .expect_err("{model} with no model key is refused"); + assert!(err.to_string().contains("sets no model"), "{err}"); + } + + /// The model a `cap` reading asks about is the one its session launched + /// with, resolved by the same rule the launch used — a deep task's session + /// runs the deeper model, so the window that holds it is that model's. + #[test] + fn a_cap_asks_on_the_model_its_session_ran() { + let text = "[agents.a]\ndispatch = \"run {prompt_file} --model {model}\"\n\ + cap = \"agent-cap --model {model}\"\nmodel = \"workhorse\"\n\ + model_deep = \"strongest\"\n"; + let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap(); + let agent = config.agent("a").expect("agent a"); + assert_eq!(agent.model_for(false), Some("workhorse")); + assert_eq!(agent.model_for(true), Some("strongest")); + assert_eq!( + render_cap(agent.cap().unwrap(), agent.model_for(true)), + "agent-cap --model strongest" + ); + // An agent naming no deeper model runs the workhorse at either depth, + // and one naming no model at all renders the template unchanged. + assert_eq!(model_for_depth(Some("only"), None, true), Some("only")); + assert_eq!(render_cap("agent-cap --epoch", None), "agent-cap --epoch"); } /// The built-in `stop` renders through the same session binder `logs` does, diff --git a/crates/voro-core/src/lib.rs b/crates/voro-core/src/lib.rs index 5bf04ae..4f70b11 100644 --- a/crates/voro-core/src/lib.rs +++ b/crates/voro-core/src/lib.rs @@ -22,7 +22,8 @@ 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, diff --git a/crates/voro/src/app.rs b/crates/voro/src/app.rs index 7335965..b49f22d 100644 --- a/crates/voro/src/app.rs +++ b/crates/voro/src/app.rs @@ -5,7 +5,7 @@ use voro_core::{ Action, ActionRow, AgentsConfig, CompletionReport, DepKind, DepRef, DigestRow, Event, LivenessSource, PrRef, Priority, Project, Queue, QueueRow, RefineOutcome, RunningRow, ScoreBreakdown, StateCounts, Store, Task, TaskState, Triage, WipGate, projects_for_new_task, - scheduler, + render_cap, scheduler, }; /// Lines `PgDn`/`PgUp` move the focus card in one press. A fixed step, since @@ -351,10 +351,15 @@ pub struct AttachRequest { /// starts a probe does no I/O of its own to decide what to probe. /// /// It names two readings, on two different scales. `logs` replays *this -/// session's* screen and is taken per row; `cap` asks *the account* this row's -/// agent dispatches on, so every row under one agent shares one answer — which -/// is why the agent's name is carried beside its verb rather than the verb -/// alone. +/// session's* screen and is taken per row; `cap` asks the account about the +/// window this session's *model* is metered against, so rows asking the same +/// question share one answer. +/// +/// That question is carried already rendered, and is also the key the answer is +/// held under, because it is exactly what distinguishes one reading from +/// another: an agent asking per model splits into one question per model in +/// flight, and an agent whose template names no model collapses to a single +/// question for every row it runs — without either case being special. #[derive(Debug, Clone)] struct CapTarget { task_id: i64, @@ -362,10 +367,8 @@ struct CapTarget { session_ref: String, /// The agent's `logs` verb, without which there is no target at all. logs: String, - /// The agent that ran the session, which is what an account reading is - /// keyed by. - agent_name: String, - /// That agent's `cap` verb, where it defines one. + /// The agent's `cap` verb with this session's model bound, where it defines + /// one. cap: Option, } @@ -1091,9 +1094,12 @@ impl App { /// contributes no targets and is probed for nothing, which is how the whole /// feature stays absent for `codex` rather than failing loudly on it. /// - /// It carries the agent it ran under, and that agent's `cap` verb where - /// there is one, because the second reading is taken per *account* and this - /// is where the config is already open. + /// It carries the agent's `cap` verb rendered for the model this session + /// launched with, because that is what the second reading asks and this is + /// where the config is already open. The model is resolved by the same rule + /// the launch resolved it by — the deeper model for a deep task — since a + /// subscription meters each strong model separately and a reading taken on + /// the wrong one answers about the wrong window. fn resolve_cap_targets(&self, config: Option<&AgentsConfig>) -> Vec { let Some(config) = config else { return Vec::new(); @@ -1108,24 +1114,26 @@ impl App { } let session_ref = session.session_ref.clone()?; let agent = config.agent(&session.agent)?; + let deep = self.store.task(r.task_id).is_ok_and(|t| t.deep); Some(CapTarget { task_id: r.task_id, session_ref, logs: agent.logs()?.to_string(), - agent_name: session.agent.clone(), - cap: agent.cap().map(str::to_string), + cap: agent + .cap() + .map(|template| render_cap(template, agent.model_for(deep))), }) }) .collect() } - /// What the account this task's session runs under says about its cap, when + /// What the account said about the window holding this task's session, when /// anything has been read for it (DESIGN.md §8). The badge and the sweep /// both go through here rather than reaching for the map, so the answer a - /// row shows is the one its own agent gave. + /// row shows is the one to the question that row asks. pub fn account_cap(&self, task_id: i64) -> Option<&voro_core::AccountCap> { let target = self.cap_targets.iter().find(|t| t.task_id == task_id)?; - self.account_caps.get(&target.agent_name) + self.account_caps.get(target.cap.as_ref()?) } /// The cap window for one badged task: its session's screen and its @@ -1166,13 +1174,13 @@ impl App { } } } - for (agent, reading) in self.account_probe.take_results() { + for (question, reading) in self.account_probe.take_results() { match reading { Some(reading) => { - self.account_caps.insert(agent, reading); + self.account_caps.insert(question, reading); } None => { - self.account_caps.remove(&agent); + self.account_caps.remove(&question); } } } @@ -1198,32 +1206,30 @@ impl App { .start(target.task_id, target.session_ref, target.logs, now); } - // An agent is worth asking about only while something under it is - // badged, and stops being worth holding a reading for the moment - // nothing is. - let capped: std::collections::HashSet = self + // A question is worth asking only while a session it answers for is + // badged, and stops being worth holding an answer to the moment none + // is. + let asked_for: std::collections::HashSet = self .cap_targets .iter() .filter(|t| self.caps.contains_key(&t.task_id)) - .map(|t| t.agent_name.clone()) + .filter_map(|t| t.cap.clone()) .collect(); - self.account_caps.retain(|agent, _| capped.contains(agent)); - self.account_probe.retain(&capped); + self.account_caps.retain(|q, _| asked_for.contains(q)); + self.account_probe.retain(&asked_for); let Some(now_epoch) = self.now_epoch else { return; }; - // Collected by agent rather than by row: several capped sessions under - // one agent are one account, and so one reading. - let asking: std::collections::BTreeMap = self - .cap_targets + // Deduplicated by the question itself, so several capped sessions + // asking the same thing — same agent, same model — are one call. + let asking: Vec = asked_for .iter() - .filter(|t| capped.contains(&t.agent_name)) - .filter_map(|t| Some((t.agent_name.clone(), t.cap.clone()?))) - .filter(|(agent, _)| self.account_probe.due(agent, now, self.now_epoch)) + .filter(|question| self.account_probe.due(question, now, self.now_epoch)) + .cloned() .collect(); - for (agent, cap_cmd) in asking { - self.account_probe.start(agent, cap_cmd, now, now_epoch); + for question in asking { + self.account_probe.start(question, now, now_epoch); } } @@ -1247,6 +1253,17 @@ impl App { self.cap_targets.iter().map(|t| t.task_id).collect() } + /// The account question this task's row asks — the `cap` verb with its + /// session's model bound — which is also the key its answer is held under. + #[cfg(test)] + pub fn cap_question(&self, task_id: i64) -> Option { + self.cap_targets + .iter() + .find(|t| t.task_id == task_id)? + .cap + .clone() + } + /// Keep the wall clock the reset badge is judged against roughly current, /// without paying for it on frames where nothing reads it. fn refresh_clock(&mut self, now: std::time::Instant) { @@ -6110,12 +6127,20 @@ mod tests { // --- the account's own reset instant (task #441) --- + /// A stub `cap` verb that asks on the session's model, so the tests below + /// exercise the rendering the real one depends on. `out` is what it prints. + fn cap_verb(out: &str) -> String { + format!( + "cap = \"printf '%s' '{out}' # {{model}}\"\nmodel = \"workhorse\"\nmodel_deep = \"strongest\"\n" + ) + } + /// Drive the pass until the account reading lands, which like the session /// one is a background thread running a subprocess. - fn settle_account(app: &mut App, agent: &str, want: bool) { + fn settle_account(app: &mut App, question: &str, want: bool) { for _ in 0..200 { app.poll_cap_probes(); - if app.account_caps.contains_key(agent) == want { + if app.account_caps.contains_key(question) == want { return; } std::thread::sleep(std::time::Duration::from_millis(10)); @@ -6129,7 +6154,8 @@ mod tests { /// nothing to show and nothing for a clock to decide. #[test] fn the_accounts_instant_times_a_cap_the_screen_left_untimed() { - let (mut app, task_id, project_path) = cap_env(true, "Weekly limit reached"); + let (mut app, task_id, project_path) = + cap_env_with(true, "Weekly limit reached", &cap_verb("")); settle_cap(&mut app, task_id, true); assert_eq!(app.caps[&task_id].reset_minutes, None); assert!( @@ -6139,14 +6165,15 @@ mod tests { let now = crate::session_probe::local_epoch().expect("a clock"); app.now_epoch = Some(now); + let question = app.cap_question(task_id).expect("a question"); app.inject_account_cap( - "stub", + &question, Some(voro_core::AccountCap { reset_epoch: now + 3600, reset_label: Some("09:00".into()), }), ); - settle_account(&mut app, "stub", true); + settle_account(&mut app, &question, true); let window = app.cap_window(task_id).expect("a window"); assert_eq!(window.label.as_deref(), Some("09:00")); @@ -6163,8 +6190,11 @@ mod tests { /// the sweep goes by the instant. #[test] fn the_sweep_goes_by_the_accounts_instant() { - let (mut app, task_id, project_path) = - cap_env(true, "Session limit reached - Retrying in 5m (9:50pm)"); + let (mut app, task_id, project_path) = cap_env_with( + true, + "Session limit reached - Retrying in 5m (9:50pm)", + &cap_verb(""), + ); settle_cap(&mut app, task_id, true); // An hour short of the 21:50 the screen named: on the parse alone this // session is waiting, and the sweep would leave it alone. @@ -6173,14 +6203,15 @@ mod tests { let now = crate::session_probe::local_epoch().expect("a clock"); app.now_epoch = Some(now); + let question = app.cap_question(task_id).expect("a question"); app.inject_account_cap( - "stub", + &question, Some(voro_core::AccountCap { reset_epoch: now - 60, reset_label: Some("21:50".into()), }), ); - settle_account(&mut app, "stub", true); + settle_account(&mut app, &question, true); assert!(app.cap_window(task_id).expect("a window").passed); key(&mut app, KeyCode::Char('u')); @@ -6227,29 +6258,55 @@ mod tests { true, "Session limit reached", &format!( - "cap = \"printf 'x' >> '{}'; printf '%s' {}\"\n", + "cap = \"printf '%s' {{model}} >> '{}'; printf '%s' {}\"\nmodel = \"workhorse\"\n", asked.display(), now + 1800 ), ); settle_cap(&mut app, task_id, true); - settle_account(&mut app, "stub", true); + let question = app.cap_question(task_id).expect("a question"); + settle_account(&mut app, &question, true); for _ in 0..20 { app.poll_cap_probes(); std::thread::sleep(std::time::Duration::from_millis(10)); } - assert_eq!(app.account_caps["stub"].reset_epoch, now + 1800); + assert_eq!(app.account_caps[&question].reset_epoch, now + 1800); assert_eq!( std::fs::read_to_string(&asked).unwrap_or_default(), - "x", - "the account was asked more than once" + "workhorse", + "the account was asked more than once, or on the wrong model" ); let _ = std::fs::remove_file(&asked); let _ = std::fs::remove_dir_all(project_path.parent().unwrap()); } + /// The question is asked on the model the session actually launched with, + /// because a subscription meters each strong model separately: a deep + /// task's session runs the deeper model, so the window that holds it is + /// that model's window and not the workhorse's. A reading taken on the + /// wrong model would answer about the wrong window — and answer *earlier* + /// than the truth whenever the cheaper pool reopens first. + #[test] + fn the_question_names_the_model_its_session_ran() { + let (mut app, task_id, project_path) = + cap_env_with(true, "Session limit reached", &cap_verb("")); + assert!( + app.cap_question(task_id) + .expect("a question") + .contains("workhorse") + ); + + app.store.set_deep(task_id, true).unwrap(); + app.refresh().unwrap(); + let deep = app.cap_question(task_id).expect("a question"); + assert!(deep.contains("strongest"), "{deep}"); + assert!(!deep.contains("workhorse"), "{deep}"); + + let _ = std::fs::remove_dir_all(project_path.parent().unwrap()); + } + /// An agent that cannot say — `codex` names no `cap`, and neither does the /// stub here — leaves the badge exactly as it was: the clock time off the /// session's own screen, judged by nearest occurrence. diff --git a/crates/voro/src/probe.rs b/crates/voro/src/probe.rs index a6983bf..db70c3f 100644 --- a/crates/voro/src/probe.rs +++ b/crates/voro/src/probe.rs @@ -400,16 +400,16 @@ impl CapProbe { /// an hour rather than pounds. pub const ACCOUNT_CAP_INTERVAL: Duration = Duration::from_secs(600); -/// Whether an agent's account reading should be taken this tick (DESIGN.md §8). +/// Whether an account reading should be taken this tick (DESIGN.md §8). /// -/// The rule is "ask once, while it matters": a session of this agent 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 capped on a limit the probe does -/// not hit (a weekly model cap behind a healthy account) from buying a fresh -/// call every interval for as long as it sits there. +/// 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)>, @@ -432,20 +432,25 @@ pub fn account_probe_due( } /// The account-cap probe's off-loop runner (DESIGN.md §8): an agent's `cap` -/// verb on a background thread, reading the instant its usage window reopens. +/// 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 *agent* rather than by task, because a cap is a -/// property of the account and one reading answers for every session on the -/// strip — where a screen replay has to be taken per session. 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 +/// 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)>, rx: Receiver<(String, Option)>, in_flight: HashSet, - /// Each agent's last reading: when it was started, and the instant it came + /// 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)>, @@ -464,63 +469,65 @@ impl Default for AccountCapProbe { } impl AccountCapProbe { - pub fn due(&self, agent: &str, now: Instant, now_epoch: Option) -> bool { + pub fn due(&self, question: &str, now: Instant, now_epoch: Option) -> bool { account_probe_due( - self.in_flight.contains(agent), - self.last.get(agent).copied(), + self.in_flight.contains(question), + self.last.get(question).copied(), now, now_epoch, ) } - /// Ask `cap_cmd` what this agent's account says, on a background thread. - pub fn start(&mut self, agent: String, cap_cmd: String, now: Instant, now_epoch: i64) { - self.in_flight.insert(agent.clone()); + /// 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(agent.clone(), (now, None)); + self.last.insert(question.clone(), (now, None)); let tx = self.tx.clone(); std::thread::spawn(move || { - let reading = crate::session_probe::read_account_cap(&cap_cmd, now_epoch); - let _ = tx.send((agent, reading)); + 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 - /// agent it was asked of. Never blocks. An empty reading is handed back as - /// meaningfully as a full one: it clears the account's answer, leaving 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)> { let mut landed = Vec::new(); loop { match self.rx.try_recv() { - Ok((agent, reading)) => { - self.in_flight.remove(&agent); - if let Some(entry) = self.last.get_mut(&agent) { + 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((agent, reading)); + landed.push((question, reading)); } Err(TryRecvError::Empty | TryRecvError::Disconnected) => return landed, } } } - /// Drop the debounce for agents with nothing capped on the strip, so the - /// next cap reads afresh rather than waiting out the last one's interval. - pub fn retain(&mut self, capped: &HashSet) { - self.last.retain(|agent, _| capped.contains(agent)); + /// 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) { + 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, agent: &str, reading: Option) { - self.in_flight.insert(agent.to_string()); + pub fn inject_result(&mut self, question: &str, reading: Option) { + self.in_flight.insert(question.to_string()); self.last - .entry(agent.to_string()) + .entry(question.to_string()) .or_insert((Instant::now(), None)); - let _ = self.tx.send((agent.to_string(), reading)); + let _ = self.tx.send((question.to_string(), reading)); } } diff --git a/docs/DESIGN.md b/docs/DESIGN.md index fed7536..baf65b6 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -337,7 +337,11 @@ A session's entry in the *agent's own* registry follows its row in the same way: That same channel answers a question the reconciler could not previously ask at all. **A usage cap does not kill a supervisor-owned session**: the supervisor stays alive and the session sits waiting for the window to reset, so a capped dispatch is never *dead*, never reconciled, and rides the running strip looking healthy for hours. Reading `logs` for a *live* `running`/`refining` session with a captured ref closes that hole, and the result is rendered as a badge on the strip row — `⚠ capped ↻21:50` with the reset time where the agent named one, `⚠ capped` where it did not, and `⚠ capped · reset passed` once that time has gone by, which is a different situation (the window is open; the session wants a nudge) and so says so. The reset time is parsed best-effort from a bare clock time, resolved to whichever occurrence is nearest — taking the *next* one would claim another day's wait a minute after the window reopened. -That parse is the weak link in the chain, and there is a second source that is not a parse at all. A cap is a property of the **account**, not of a session, and an agent that can report its own can report it as an *instant*: Claude Code's stream transport emits a `rate_limit_event` carrying `resetsAt`, Unix epoch seconds. So the verb set gains an optional **`cap`**, an agent contract like the rest and the only one that names nothing — no `{session}`, because one reading answers for every session on the strip. Its output is one number, and its negative answer is *silence*: print the instant while the account is refused, print nothing otherwise, so an uncapped account, a failed command and an agent that defines none all land identically on the screen parse. `codex` defines none. Where a reading exists it decides both halves of the badge — the time shown and whether the window has reopened — and the parse stays underneath it for everything else. Two consequences are priced rather than discovered. Unlike `logs`, this verb *spends an API call*: it is therefore asked only while a session is already badged capped, once per capped episode (again once if the instant it named has since passed, since the account may have entered a new window), keyed by agent so several capped sessions are one question. And an account is refused by *one* window while a session may be held by another — a weekly model limit behind a five-hour account cap — so the instant can be early for that session; that is the failure this section already prices as cheap, a nudge that re-caps at once. +That parse is the weak link in the chain, and there is a second source that is not a parse at all. A cap is a property of the **account**, not of a session, and an agent that can report its own can report it as an *instant*: Claude Code's stream transport emits a `rate_limit_event` carrying `resetsAt`, Unix epoch seconds. So the verb set gains an optional **`cap`**, an agent contract like the rest, naming no session because no session is what it is about. Its output is one number, and its negative answer is *silence*: print the instant while the account is refused, print nothing otherwise, so an uncapped account, a failed command and an agent that defines none all land identically on the screen parse. `codex` defines none. Where a reading exists it decides both halves of the badge — the time shown and whether the window has reopened — and the parse stays underneath it for everything else. + +An account, though, does not have *one* window. A Claude subscription meters the five-hour pool, the weekly one, and a separate allowance for each strong model — the CLI names them "session limit", "weekly limit", "Opus limit", "Sonnet limit" and "Fable 5 limit", and fast mode draws on its own pools again. Which window refuses therefore depends on which model asks, and a reading taken on the wrong one answers about the wrong window: worse, it answers *earlier* than the truth whenever a cheaper model's pool reopens first, which is exactly the error that would fire a nudge into a session still held. So `cap` may carry `{model}` — the only placeholder it may carry — and Voro asks it with the model the session in question launched under, resolved by the same rule the launch resolved it by. The reading is then keyed by the question asked rather than by the agent, which is what makes both shapes fall out without either being special: a template naming `{model}` is asked once per model in flight, one that names none is asked once for the agent, and either way every session asking the same thing shares one answer. + +The cost is priced rather than discovered. Unlike `logs`, this verb *spends an API call*: it is asked only while a session is already badged capped, once per capped episode — again once if the instant it named has since passed, since the account may have entered a new window. Asking on the session's own model is also what makes the case that matters free, since a refused request is a rejection rather than a turn and bills nothing; the only probe that costs is one that finds the account healthy, and that one prints nothing. The point of the exactness is not the badge, which reads the same either way. It is that a bare clock time is ambiguous by half a day and an instant is not, and that a cap the screen never timed is timed after all once the account has said when — the two properties an automatic sweep needs and a keypress-driven one can do without. diff --git a/docs/agent-integration.md b/docs/agent-integration.md index 720c6b7..4805db6 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -79,7 +79,7 @@ attach = "claude attach {session}" resume = "claude --resume {session}" message = "claude -p --resume {session} --permission-mode auto \"$(cat {prompt_file})\"" logs = "claude logs \"$(printf %.8s {session})\" 2>/dev/null | tail -c 20000" -cap = '''timeout 120 claude -p --output-format stream-json --verbose --model haiku hi 2>/dev/null | grep -o '"status":"rejected"[^}]*"resetsAt":[0-9]*' | grep -o '[0-9][0-9]*$' | tail -1''' +cap = '''timeout 120 claude -p --output-format stream-json --verbose --model {model} hi 2>/dev/null | grep -o '"status":"rejected"[^}]*"resetsAt":[0-9]*' | grep -o '[0-9][0-9]*$' | tail -1''' stop = "claude stop \"$(printf %.8s {session})\"" plan = "claude --name \"{session_name}\" --permission-mode auto --model {model} \"$(cat {prompt_file})\"" model = "opus" @@ -178,29 +178,39 @@ resume = "codex resume {session}" truncation: `claude logs` keys on the *job* id, the first eight characters of the session id, so `{session}` is trimmed rather than passed whole. - `cap` prints **when the account's usage window reopens**, as a Unix epoch in - seconds, and takes no placeholder at all: a cap belongs to the account rather - than to any one session, so a single reading answers for every session on the - strip (DESIGN.md §8). Its negative answer is silence — print the instant while - the account is *refused*, print nothing otherwise — so an account with room - left, a command that fails, and an agent naming no `cap` are all the same - thing to Voro: the reset time falls back to the clock time on the session's - own screen, which is where it came from before this verb existed. Print a bare - number; Voro reads the last plausible one, and refuses anything that is not a - timestamp near the present. + seconds. It names no session — a cap belongs to the account rather than to any + one conversation (DESIGN.md §8) — and `{model}` is the only placeholder it may + carry. Its negative answer is silence: print the instant while the account is + *refused*, print nothing otherwise, so an account with room left, a command + that fails, and an agent naming no `cap` are all the same thing to Voro — the + reset time falls back to the clock time on the session's own screen, which is + where it came from before this verb existed. Print a bare number; Voro reads + the last plausible one and refuses anything that is not a timestamp near the + present. Voro reads it for the two things a bare clock time cannot give: an unambiguous answer to whether the window has already reopened (`6:40pm` carries no date), and a reset time for a cap that never named one. The built-in `claude` spelling filters `rate_limit_event` out of the CLI's own stream — `"status":"rejected"` is what distinguishes a cap the account is *held at* from a note on the window - it is merely spending — and asks with a cheap model of its own choosing, since - the answer is the same whichever one is refused. + it is merely spending. + + Carry `{model}` if your agent meters models separately, as a Claude + subscription does: the five-hour pool, the weekly one and each strong model's + own allowance are different windows, so which one refuses depends on which + model asks. Voro binds the model the session in question launched with, and + keys the answer by the rendered command — so a template naming `{model}` is + asked once per model in flight, one that names none is asked once for the + agent, and either way sessions asking the same thing share one reading. This is the one verb that costs an agent real work to answer, so Voro asks rarely: only while a session is already badged capped, once per capped episode, and once more only if the instant it named has passed while the badge is still - up. It is spawned like any other verb, so a template that could hang should - bound itself — the built-in wraps its call in `timeout`. + up. Asking on the session's own model keeps the case that matters cheap — a + refused request is a rejection rather than a turn — so the only probe that + costs anything is one that finds the account healthy, and that one prints + nothing. It is spawned like any other verb, so a template that could hang + should bound itself; the built-in wraps its call in `timeout`. - `stop` retires a session from the agent's own registry, taking `{session}` alone. Voro fires it on three triggers (DESIGN.md §8), and all three must be safe before you define it.