diff --git a/CHANGELOG.md b/CHANGELOG.md index 5161db3..e67b23b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 still creates its database with no ceremony, and dev and scratch stores migrate on open exactly as before. +- **The cap badge takes its reset time from the agent, not from its screen.** + Voro used to read "resets 6:40pm" off a capped session's output and guess + which 6:40pm was meant, because a bare clock time carries no date. Agents can + now report the same thing exactly, through a new optional `cap` verb that + prints when the account's usage window reopens as a Unix epoch. The badge + reads the same, but "reset passed" is now a fact rather than a + nearest-occurrence guess, and a cap whose wording named no time at all gets + one. A subscription meters more than one window — the five-hour pool, the + weekly one, and each strong model's own allowance — so the verb is asked with + the model the session launched under, and sessions asking the same thing share + one reading. The built-in `claude` agent defines it; `codex` does not, and + anything Voro cannot ask keeps the old parse. Because asking costs the agent + an API call rather than a screen replay, Voro asks only while a session is + already badged capped, and once per capped episode. + - **One key gets every capped session working again.** A usage cap ends a session's turn and leaves it there — nothing retries — so recovering the fleet used to mean attaching to each capped session in turn and typing "continue", diff --git a/crates/voro-core/src/agent.rs b/crates/voro-core/src/agent.rs index 1b0c245..5c6dfd8 100644 --- a/crates/voro-core/src/agent.rs +++ b/crates/voro-core/src/agent.rs @@ -137,6 +137,32 @@ 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. +/// +/// 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 /// release the supervisor holds is what a headless `message` resumes through. @@ -157,6 +183,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 {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\" @@ -289,6 +316,14 @@ 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. 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}) # Fired when Voro closes the session's row, so the agent's # listing shows work actually in flight. Fire and forget: Voro @@ -402,6 +437,21 @@ 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). 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 + /// 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). @@ -455,6 +505,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() } @@ -475,6 +529,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 @@ -504,12 +566,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), ]; @@ -726,6 +789,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())]) @@ -944,6 +1032,27 @@ fn validate_agent(name: &str, agent: &AgentTemplate, path: &Path) -> Result<()> } } } + // `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, + 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 — {MODEL_PLACEHOLDER} is the \ + only placeholder it may name" + ))); + } + } + } // `{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. @@ -979,9 +1088,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 = \ @@ -1028,6 +1141,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, @@ -1044,11 +1158,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) } @@ -1277,6 +1387,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(), @@ -2100,7 +2211,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"]); @@ -2301,6 +2412,88 @@ mod tests { } } + /// The built-in `claude` defines `cap` and `codex` does not, and the + /// 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(); + 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_PLACEHOLDER), "{cap}"); + assert!(cap.contains("timeout"), "{cap}"); + assert!(config.agent("codex").expect("codex").cap().is_none()); + } + + /// `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_names_the_model_and_nothing_else() { + for placeholder in [ + SESSION_PLACEHOLDER, + PROMPT_FILE_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}"); + } + // 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(); + 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, /// 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 14b223c..3e85d51 100644 --- a/crates/voro-core/src/cap.rs +++ b/crates/voro-core/src/cap.rs @@ -32,6 +32,16 @@ //! is held and the text that says it is mid-turn differ by the retry phrase //! alone, and everything else about the two is identical: `blocked` in the //! listing, supervisor alive, cap phrase on screen. +//! +//! *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, and the +//! retry above, are resolved into the one answer the badge and the sweep both +//! read. That reading is about the *account* rather than the session, so it +//! serves every session asking the same of it; the parse stays for every agent +//! and every moment that has none. /// Phrases that mean "held at a usage cap", checked case-insensitively. /// @@ -157,6 +167,152 @@ 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, + /// Whether the session is retrying the rejected request rather than sitting + /// on it ([`CapReading::retrying`]). Carried through rather than resolved + /// away, because it is the one badged shape that wants nothing done about + /// it and the badge has to say so. + pub retrying: bool, +} + +impl CapWindow { + /// Resolve the sources for one session, the account's reading first. + /// + /// A session that is *retrying* takes neither: the account's instant says + /// when the window reopens, and a retrying session is not waiting on the + /// window — it is mid-turn on a request of its own, and the time it names + /// is when that request goes out (§8). Reading an account instant onto it + /// would mark it due the moment the window opened, which is precisely the + /// session a nudge must not touch. + pub fn resolve( + reading: &CapReading, + account: Option<&AccountCap>, + now_minutes: Option, + now_epoch: Option, + ) -> CapWindow { + if reading.retrying { + return CapWindow { + label: reading.reset_label(), + passed: false, + timed: reading.reset_minutes.is_some(), + retrying: true, + }; + } + 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, + retrying: false, + }; + } + CapWindow { + label: reading.reset_label(), + passed: now_minutes.is_some_and(|now| reading.reset_passed(now)), + timed: reading.reset_minutes.is_some(), + retrying: false, + } + } + + /// 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. + /// + /// A retrying session is never due, whatever it named: it is working, and + /// the sweep stops its target before resuming it, so a nudge there ends a + /// turn rather than adding one. That answer lives here rather than only in + /// the sweep, so a caller reading `due` alone cannot miss it. + pub fn due(&self) -> bool { + !self.retrying && (!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. /// @@ -575,6 +731,171 @@ 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"); + } + + /// A retrying session takes no account instant, whatever the account says. + /// It is not waiting on the window — it is mid-turn on its own request — so + /// an instant that has passed must not mark it due: that is the one badged + /// shape a nudge would interrupt rather than help (§8). + #[test] + fn a_retrying_session_takes_no_instant_and_is_never_due() { + let now = 1_786_722_000; + let reading = read_cap( + "429 Number of requests has exceeded your rate limit · Retrying in 30s · attempt 3/10", + ) + .expect("a cap"); + assert!(reading.retrying); + let account = AccountCap { + reset_epoch: now - 3600, + reset_label: Some("21:50".into()), + }; + let window = CapWindow::resolve(&reading, Some(&account), Some(12 * 60), Some(now)); + assert!(window.retrying); + assert!(!window.passed, "the window's instant does not speak for it"); + assert!(!window.due()); + // And the badge still shows what the session itself named, if anything. + let timed = read_cap("Session limit reached · Retrying in 5m (9:50pm) · attempt 2/10") + .expect("a cap"); + let window = CapWindow::resolve(&timed, Some(&account), Some(12 * 60), Some(now)); + assert_eq!(window.label.as_deref(), Some("21:50")); + assert!(!window.due()); + } + + /// 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..4f70b11 100644 --- a/crates/voro-core/src/lib.rs +++ b/crates/voro-core/src/lib.rs @@ -22,9 +22,12 @@ pub use agent::{ NEW_SESSION_PLACEHOLDER, PROMPT_FILE_PLACEHOLDER, Provenance, RenderedMessage, ResolvedAgent, SESSION_NAME_PLACEHOLDER, SESSION_PLACEHOLDER, SessionLiveness, TASK_ID_PLACEHOLDER, VIEWER_BASE_PLACEHOLDER, VIEWER_BRANCH_PLACEHOLDER, VIEWER_PATH_PLACEHOLDER, ViewerTemplate, - is_builtin_viewer, parse_sessions_json, render_message, render_session, + is_builtin_viewer, model_for_depth, parse_sessions_json, render_cap, render_message, + render_session, +}; +pub use cap::{ + AccountCap, CAP_SIGNATURES, CapReading, CapWindow, parse_reset_epoch, read_cap, strip_ansi, }; -pub use cap::{CAP_SIGNATURES, CapReading, read_cap, strip_ansi}; pub use error::{Error, Result}; pub use import::{GithubIssue, already_imported, issue_new_task, issue_task_body}; pub use model::{ diff --git a/crates/voro/src/app.rs b/crates/voro/src/app.rs index 48289df..c9fdc3e 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 @@ -346,6 +346,32 @@ 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 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, + /// 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's `cap` verb with this session's model bound, 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 +566,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 +576,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, @@ -663,7 +703,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, @@ -1066,7 +1109,14 @@ 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'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(); }; @@ -1079,12 +1129,42 @@ 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)?; + 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(), + cap: agent + .cap() + .map(|template| render_cap(template, agent.model_for(deep))), + }) }) .collect() } + /// 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 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.cap.as_ref()?) + } + + /// 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 @@ -1094,6 +1174,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 { @@ -1105,26 +1190,66 @@ impl App { } } } + for (question, reading) in self.account_probe.take_results() { + match reading { + Some(reading) => { + self.account_caps.insert(question, reading); + } + None => { + self.account_caps.remove(&question); + } + } + } // 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); + } + + // A question is worth asking only while a session it answers for is + // *held*, and stops being worth holding an answer to the moment none + // is. A session retrying its rejected request is badged but not held — + // it is mid-turn and will carry on by itself — so it buys no reading, + // which matters here more than elsewhere because the reading is bought + // with an API call. + let asked_for: std::collections::HashSet = self + .cap_targets + .iter() + .filter(|t| self.caps.get(&t.task_id).is_some_and(|r| !r.retrying)) + .filter_map(|t| t.cap.clone()) + .collect(); + self.account_caps.retain(|q, _| asked_for.contains(q)); + self.account_probe.retain(&asked_for); - self.refresh_clock(now); + let Some(now_epoch) = self.now_epoch else { + return; + }; + // 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(|question| self.account_probe.due(question, now, self.now_epoch)) + .cloned() + .collect(); + for question in asking { + self.account_probe.start(question, now, now_epoch); + } } /// Hand back a reading as though a background probe had produced it, so @@ -1134,10 +1259,28 @@ 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() + } + + /// 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, @@ -1155,6 +1298,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). @@ -2230,15 +2374,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()); let mut retrying = 0usize; - for (id, reading) in &self.caps { + for id in self.caps.keys().copied() { + let window = self.cap_window(id); // A session retrying the rejected request is the one badged shape // that must be walked past. It is mid-turn, so it will carry on by // itself — and since the nudge stops its target before resuming it @@ -2248,13 +2394,12 @@ impl App { // their judgement because nothing else knows whether the window is // open, whereas here the session has said outright that it is // working. - if reading.retrying { + if window.as_ref().is_some_and(|window| window.retrying) { retrying += 1; continue; } - 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); + let due = window.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(); @@ -5876,10 +6021,21 @@ mod tests { // --- capped-but-alive sessions --- + /// [`cap_env`] with an account-level `cap` verb as well, 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 @@ -5914,7 +6070,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() @@ -6025,6 +6181,242 @@ mod tests { let _ = std::fs::remove_dir_all(project_path.parent().unwrap()); } + // --- the account's own reset instant --- + + /// 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, question: &str, want: bool) { + for _ in 0..200 { + app.poll_cap_probes(); + if app.account_caps.contains_key(question) == 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 nothing else can + /// time and no clock can judge. + #[test] + fn the_accounts_instant_times_a_cap_the_screen_left_untimed() { + 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!( + 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); + let question = app.cap_question(task_id).expect("a question"); + app.inject_account_cap( + &question, + Some(voro_core::AccountCap { + reset_epoch: now + 3600, + reset_label: Some("09:00".into()), + }), + ); + 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")); + 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_with(true, "Session limit reached - resets 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. + 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); + let question = app.cap_question(task_id).expect("a question"); + app.inject_account_cap( + &question, + Some(voro_core::AccountCap { + reset_epoch: now - 60, + reset_label: Some("21:50".into()), + }), + ); + settle_account(&mut app, &question, 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()); + } + + /// A retrying session buys no account reading. It is badged, but it is + /// mid-turn rather than held, so there is nothing for an instant to answer + /// — and this is the one probe where the difference is money rather than a + /// subprocess. + #[test] + fn a_retrying_session_never_asks_the_account() { + let asked = std::env::temp_dir().join(format!("voro-cap-retry-{}", std::process::id())); + let _ = std::fs::remove_file(&asked); + let (mut app, task_id, project_path) = cap_env_with( + true, + "Session limit reached - Retrying in 5m (9:50pm) - attempt 2/10", + &format!( + "cap = \"printf 'x' >> '{}'\"\nmodel = \"workhorse\"\n", + asked.display() + ), + ); + settle_cap(&mut app, task_id, true); + assert!(app.caps[&task_id].retrying); + for _ in 0..20 { + app.poll_cap_probes(); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!( + !asked.exists(), + "a retrying session asked the account anyway" + ); + assert!(app.account_caps.is_empty()); + assert!( + !app.cap_window(task_id).expect("a window").due(), + "and it is never swept" + ); + + 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 '%s' {{model}} >> '{}'; printf '%s' {}\"\nmodel = \"workhorse\"\n", + asked.display(), + now + 1800 + ), + ); + settle_cap(&mut app, task_id, 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[&question].reset_epoch, now + 1800); + assert_eq!( + std::fs::read_to_string(&asked).unwrap_or_default(), + "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. + #[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 - resets 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 --- /// What a nudge was told, if anything. diff --git a/crates/voro/src/cli.rs b/crates/voro/src/cli.rs index 2d6e323..502eae5 100644 --- a/crates/voro/src/cli.rs +++ b/crates/voro/src/cli.rs @@ -2630,7 +2630,7 @@ mod tests { // every optional verb the agent defines, the quick message included — // named plainly, since the built-in resumes its session in place assert!( - listed.contains("[sessions attach resume message logs stop plan]"), + listed.contains("[sessions attach resume message logs cap stop plan]"), "{listed}" ); diff --git a/crates/voro/src/probe.rs b/crates/voro/src/probe.rs index f98e874..7f38593 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,145 @@ impl CapProbe { } } +/// The floor between two readings of one agent's account, and the only thing +/// standing between a stuck badge and a standing charge: unlike every other +/// probe here, this one spends an API call to ask (DESIGN.md §8). It is a +/// backstop rather than the schedule — [`account_probe_due`] normally asks once +/// per capped episode — so it is set where a pathological loop would cost cents +/// an hour rather than pounds. +pub const ACCOUNT_CAP_INTERVAL: Duration = Duration::from_secs(600); + +/// Whether an account reading should be taken this tick (DESIGN.md §8). +/// +/// The rule is "ask once, while it matters": a session this question answers +/// for is badged capped, nothing is in flight for it, and either nothing has +/// been read yet or what was read has expired — a window that has reopened +/// while a session is still badged is the one case where asking again can learn +/// something, since the account may have entered a new one. A reading that came +/// back *empty* is held too, and that is what stops a session whose badge has +/// gone stale from buying a fresh call every interval for as long as it sits +/// there. +pub fn account_probe_due( + in_flight: bool, + last: Option<(Instant, Option)>, + 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 the window holding a +/// session reopens. +/// +/// It is [`CapProbe`]'s counterpart and differs from it in both directions of +/// what it costs. It is keyed by the *question* — the rendered `cap` command — +/// rather than by task, because a cap belongs to the account and one reading +/// answers for every session that would ask the same thing, where a screen +/// replay has to be taken per session. That key is what makes the per-model +/// case fall out without being special: an agent asking on `{model}` renders a +/// different command per model in flight and gets a reading each, one that +/// names no model renders one command and is asked once. And it is debounced +/// far harder, because the verb spends an API call rather than a subprocess: +/// [`account_probe_due`] asks once per capped episode, and +/// [`ACCOUNT_CAP_INTERVAL`] catches anything that would ask in a loop. +pub struct AccountCapProbe { + tx: Sender<(String, Option)>, + rx: Receiver<(String, Option)>, + in_flight: HashSet, + /// 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)>, +} + +impl Default for AccountCapProbe { + fn default() -> Self { + let (tx, rx) = channel(); + AccountCapProbe { + tx, + rx, + in_flight: HashSet::new(), + last: HashMap::new(), + } + } +} + +impl AccountCapProbe { + pub fn due(&self, question: &str, now: Instant, now_epoch: Option) -> bool { + account_probe_due( + self.in_flight.contains(question), + self.last.get(question).copied(), + now, + now_epoch, + ) + } + + /// Ask one rendered `cap` command what the account says, on a background + /// thread. + pub fn start(&mut self, question: String, now: Instant, now_epoch: i64) { + self.in_flight.insert(question.clone()); + // Held from the start, not from the answer, so a verb slower than the + // interval cannot be started twice over. + self.last.insert(question.clone(), (now, None)); + let tx = self.tx.clone(); + std::thread::spawn(move || { + let reading = crate::session_probe::read_account_cap(&question, now_epoch); + let _ = tx.send((question, reading)); + }); + } + + /// Every reading that has landed since the last drain, each tagged with the + /// question it answers. Never blocks. An empty reading is handed back as + /// meaningfully as a full one: it clears that question's answer, leaving the + /// badge to the session's own screen again. + pub fn take_results(&mut self) -> Vec<(String, Option)> { + let mut landed = Vec::new(); + loop { + match self.rx.try_recv() { + Ok((question, reading)) => { + self.in_flight.remove(&question); + if let Some(entry) = self.last.get_mut(&question) { + entry.1 = reading.as_ref().map(|r| r.reset_epoch); + } + landed.push((question, reading)); + } + Err(TryRecvError::Empty | TryRecvError::Disconnected) => return landed, + } + } + } + + /// Drop the debounce for questions no badged session is asking any more, so + /// the next cap reads afresh rather than waiting out the last one's + /// interval. + pub fn retain(&mut self, asked_for: &HashSet) { + self.last.retain(|question, _| asked_for.contains(question)); + } + + /// Hand back a reading as though a background probe had produced it, so the + /// drain-and-render half can be tested without spending an API call. + #[cfg(test)] + pub fn inject_result(&mut self, question: &str, reading: Option) { + self.in_flight.insert(question.to_string()); + self.last + .entry(question.to_string()) + .or_insert((Instant::now(), None)); + let _ = self.tx.send((question.to_string(), reading)); + } +} + #[cfg(test)] mod tests { use super::*; @@ -675,6 +817,96 @@ mod tests { assert!(probe.due(7, start)); } + /// An agent whose account has never been asked is asked at once, so the + /// first badged cap gets its instant on the tick it appears rather than + /// after an interval's wait. + #[test] + fn an_unasked_account_is_due_immediately() { + assert!(account_probe_due(false, None, Instant::now(), Some(1))); + } + + /// The debounce proper, and the one that guards money rather than + /// subprocesses: a reading stands until the window it named has passed, and + /// never within the interval whatever it named. + #[test] + fn an_account_is_reasked_only_once_its_window_has_passed() { + let start = Instant::now(); + let now_epoch = 1_786_722_000; + let ahead = Some((start, Some(now_epoch + 3600))); + let behind = Some((start, Some(now_epoch - 1))); + // Inside the interval nothing is asked, however stale the reading. + assert!(!account_probe_due(false, behind, start, Some(now_epoch))); + // Past it, a window still shut is left alone and a reopened one is + // asked again — the account may have entered a fresh window. + let later = start + ACCOUNT_CAP_INTERVAL; + assert!(!account_probe_due(false, ahead, later, Some(now_epoch))); + assert!(account_probe_due(false, behind, later, Some(now_epoch))); + } + + /// A reading that found the account uncapped is held exactly as a positive + /// one is. Dropping it is what would buy a fresh API call every interval + /// for a session capped on a limit the probe never hits. + #[test] + fn an_empty_reading_is_not_reasked() { + let start = Instant::now(); + let empty = Some((start, None)); + assert!(!account_probe_due(false, empty, start, Some(1))); + assert!(!account_probe_due( + false, + empty, + start + ACCOUNT_CAP_INTERVAL * 10, + Some(1) + )); + } + + /// A probe already running is never doubled, whatever the clock says. + #[test] + fn an_account_probe_in_flight_is_never_doubled() { + let start = Instant::now(); + assert!(!account_probe_due(true, None, start, Some(1))); + assert!(!account_probe_due( + true, + Some((start, Some(0))), + start + ACCOUNT_CAP_INTERVAL, + Some(1) + )); + } + + /// Readings land against the agent they were asked of, and an empty one + /// clears that agent's answer rather than leaving the last standing. + #[test] + fn draining_hands_back_each_agents_reading() { + let mut probe = AccountCapProbe::default(); + let cap = AccountCap { + reset_epoch: 1_786_758_000, + reset_label: Some("02:40".into()), + }; + probe.inject_result("claude", Some(cap.clone())); + probe.inject_result("codex", None); + assert_eq!( + probe.take_results(), + vec![ + ("claude".to_string(), Some(cap)), + ("codex".to_string(), None) + ] + ); + assert!(probe.take_results().is_empty()); + } + + /// An agent with nothing capped on the strip drops its debounce, so the + /// next cap is read at once rather than inheriting the last one's interval. + #[test] + fn an_uncapped_agent_drops_its_debounce() { + let now = Instant::now(); + let mut probe = AccountCapProbe::default(); + probe.inject_result("claude", None); + probe.take_results(); + assert!(!probe.due("claude", now, Some(1))); + + probe.retain(&HashSet::from(["codex".to_string()])); + assert!(probe.due("claude", now, Some(1))); + } + /// Holding the reject key on one task spawns one capture, not one per /// press; the next press after its answer is drained captures afresh. #[test] diff --git a/crates/voro/src/session_probe.rs b/crates/voro/src/session_probe.rs index 6c3893e..3a3efcf 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`]). /// @@ -282,6 +346,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 4bd0c3b..239f61f 100644 --- a/crates/voro/src/ui.rs +++ b/crates/voro/src/ui.rs @@ -7,7 +7,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, Store, TaskState, }; @@ -723,22 +723,28 @@ fn strip_pr_span() -> Span<'static> { /// Without the badge a capped row is indistinguishable from work 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. /// +/// 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. +/// /// A fourth says the session is retrying it (§8), which is the one shape that /// wants *nothing* done about it: the turn is still running and will carry on /// by itself. It still badges rather than reading as healthy, because a /// session sitting on a retry is as idle-looking on the strip as a capped one /// and the operator deserves the reason — but it says which, so `u` passing it /// over reads as the right answer instead of a missed row. -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, reading.retrying) { +fn capped_span(window: &CapWindow) -> Span<'static> { + let text = match (&window.label, window.passed, window.retrying) { (Some(at), _, true) => format!(" ⚠ capped · retrying ↻{at}"), (None, _, true) => " ⚠ capped · retrying".to_string(), (_, true, false) => " ⚠ capped · reset passed".to_string(), @@ -1450,8 +1456,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 792d6c1..8a79feb 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1531,6 +1531,49 @@ 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, 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. + 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 @@ -1543,10 +1586,12 @@ 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 +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. +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 — Claude Code @@ -1578,7 +1623,12 @@ silence. Skipping is not a courtesy: because the send stops its target before resuming it (below), nudging a retrying session would not add a redundant turn to it but end the turn already running. The same reading also holds that session's badge back from `reset passed`, since the time such a line names is -when its own request goes out and not when a human should step in. +when its own request goes out and not when a human should step in — and it +holds the account's instant back too, for the same reason: a retrying session is +not waiting on the window, so the instant the window reopens says nothing about +it, and reading one onto it would mark it due exactly when it is least safe to +touch. It is not asked for either, which keeps the cost where the value is +(above). It goes out through the existing `message` verb rather than through any new channel, and it is the one send that releases its target *unconditionally* diff --git a/docs/agent-integration.md b/docs/agent-integration.md index 8e57526..6338d28 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -82,6 +82,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 {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" @@ -175,6 +176,39 @@ 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. 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. 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. + + 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. 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.