From a1e22ab9908cd0fe15867c57d36cf8a4cd9c00e8 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Fri, 14 Aug 2026 22:54:29 +0100 Subject: [PATCH] Judge cap readiness per agent account, and read a dated reset as a date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nudge sweep asked each badged session's own reading whether its reset had gone by. That rebuilds a fact that is not per-session: a usage cap belongs to the account a session was launched under, so every session on it reports the same window, and the only way two of them can differ is that one is stale — still displaying the window it was capped in hours ago. Judged alone that fossil reads ready, quite correctly, and since #428 nudging it stops a live session to spend a turn the account re-caps at once. The verdict now moves to voro-core as plan_sweep, taken once per agent entry, which is Voro's proxy for the account. One live reading holds every badged session of that agent, untimed ones included; a group with no live reading is swept entire, so a lone untimed cap is still the operator's call. The proxy errs in the conservative direction on purpose: two agent entries sharing an account are not pooled (no worse than judging each session alone), while one entry across two accounts costs a delay rather than a stopped session. The other half is that a weekly cap reads ready days early. Its reset carries a date — resets Aug 17, 9pm — and only the clock half was read, resolved to the nearest occurrence, so it flipped to "reset passed" the moment tonight's 9pm went by. Both halves are read now, dates compared like clocks on a synthetic ordinal of twelve 31-day months, since only the sign of the difference is ever read and no calendar dependency is wanted. The badge still shows the clock and never the date; the sweep's report names both. The status line stops counting sessions before their reset and names the hold: "claude capped until Aug 17 21:00 — 3 sessions". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KkiuQnYkEhGcsKvWfbe4kV --- CHANGELOG.md | 22 ++ crates/voro-core/src/cap.rs | 579 +++++++++++++++++++++++++++++-- crates/voro-core/src/lib.rs | 5 +- crates/voro/src/app.rs | 207 ++++++++--- crates/voro/src/probe.rs | 1 + crates/voro/src/session_probe.rs | 33 +- crates/voro/src/ui.rs | 40 ++- docs/DESIGN.md | 8 +- 8 files changed, 802 insertions(+), 93 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 759f950..18d4d53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/ClachDev/Voro/compare/v0.1.0...HEAD) - ReleaseDate +### Fixed + +- **A weekly cap is no longer read as reopening tonight.** An agent writes a + reset more than a day out with a date — `resets Aug 17, 9pm` — and only the + clock half was read, so the badge claimed the window had reopened the moment + tonight's 9pm went by, and `u` would release the session for a turn that + re-capped at once. The date is now read beside the clock and is what decides + whether the window has opened. The badge still shows the time and not the + date; a hold in the sweep's report names both where the agent gave both. + +- **The sweep asks whether the *account* is capped, not each session.** A usage + cap belongs to the account a session was launched under, so every session on + it reports the same window — and the only way two of them can differ is that + one is stale, still displaying the window it was capped in hours ago. `u` used + to read that fossil as ready and nudge a session the account would refuse + again on its next breath. Readiness is now judged once per agent entry, which + is Voro's proxy for the account: one live reading holds every badged session + of that agent, including those whose message named no time, and a group with + no live reading is swept entire as before. The report names the holds rather + than counting them — `claude capped until Aug 17 21:00 — 3 sessions` — so it + says what is being waited on instead of how many sessions said so. + ### Added - **One key gets every capped session working again.** A usage cap ends a diff --git a/crates/voro-core/src/cap.rs b/crates/voro-core/src/cap.rs index 424e8bc..9e998b1 100644 --- a/crates/voro-core/src/cap.rs +++ b/crates/voro-core/src/cap.rs @@ -69,6 +69,32 @@ const MENTION_PREFIXES: [&str; 2] = ["/upgrade", "increase your"]; /// goes on the badge. const WINDOW: usize = 200; +/// How much text before a clock time is read for the date that clock belongs +/// to. Enough for `september 17, ` and no more: anything further back is not +/// this clock's date. +const DATE_WINDOW: usize = 16; + +/// Month names, in order, matched by any prefix of at least three letters — +/// `aug`, `sept` and `august` all name the same month, and no shorter word can +/// name one by accident. +const MONTHS: [&str; 12] = [ + "january", + "february", + "march", + "april", + "may", + "june", + "july", + "august", + "september", + "october", + "november", + "december", +]; + +/// The span of the synthetic year [`CapDate::ordinal`] lays dates out in. +const SYNTHETIC_YEAR: i32 = 12 * 31; + /// How much text before a matched signature is read for the qualifiers that /// take it back. Deliberately short: every qualifier attaches directly to the /// phrase it modifies, so a wider look-back would let an *earlier* warning @@ -76,6 +102,98 @@ const WINDOW: usize = 200; /// cap rather than merely missing an unworded one. const QUALIFIER_WINDOW: usize = 32; +/// A month and day as an agent writes them, for the reset times that carry one. +/// +/// Only the *side* one of these falls on relative to another is ever read, +/// which is what lets the comparison do without a calendar dependency (see +/// [`CapDate::days_from`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CapDate { + pub month: u8, + pub day: u8, +} + +impl CapDate { + /// A day number in a synthetic year of twelve 31-day months. + /// + /// Non-contiguous across short months and blind to leap years, both of + /// which are irrelevant here: nothing reads the magnitude of a difference + /// between two of these, only its sign, and no pair of dates a cap window + /// spans comes anywhere near the half-year where that could flip. + fn ordinal(self) -> i32 { + (i32::from(self.month) - 1) * 31 + i32::from(self.day) + } + + /// Days from `other` to `self`, negative once `self` is behind it. + /// + /// Wrapped into (-186, 186] for the same reason a bare clock is read as its + /// nearest occurrence: the year is no more written down than the day is, so + /// a reset dated 2 January read on 30 December is three days out rather + /// than most of a year behind. + fn days_from(self, other: Self) -> i32 { + let delta = (self.ordinal() - other.ordinal()).rem_euclid(SYNTHETIC_YEAR); + if delta > SYNTHETIC_YEAR / 2 { + delta - SYNTHETIC_YEAR + } else { + delta + } + } + + /// `Aug 17`, as the sweep names the day a hold runs to. + fn label(self) -> String { + let month = MONTHS[usize::from(self.month) - 1]; + format!("{}{} {}", month[..1].to_uppercase(), &month[1..3], self.day) + } +} + +/// The local wall clock, as much of it as could be read (DESIGN.md §8). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LocalNow { + /// Minutes past local midnight. + pub minutes: u16, + /// Today's date, where the reader supplied one. A clock on its own judges a + /// dated reset by its clock half alone — what Voro did before any reset + /// carried a date. + pub date: Option, +} + +impl From for LocalNow { + fn from(minutes: u16) -> Self { + Self { + minutes, + date: None, + } + } +} + +impl LocalNow { + /// Read `22:50 08-14`: the clock and the date, asked of `date` in one call + /// so both halves come from one reading (`session_probe::local_now`). + /// + /// A date half that will not parse costs the date and not the clock, which + /// leaves every judgement exactly where it was before dates were read. + pub fn parse(stamp: &str) -> Option { + let stamp = stamp.trim(); + let (clock, date) = stamp.split_once(' ').unwrap_or((stamp, "")); + let (hour, minute) = clock.split_once(':')?; + let (hour, minute): (u16, u16) = (hour.parse().ok()?, minute.parse().ok()?); + if hour > 23 || minute > 59 { + return None; + } + Some(Self { + minutes: hour * 60 + minute, + date: parse_stamp_date(date), + }) + } +} + +/// `08-14` as a date, for the clock reading above. +fn parse_stamp_date(stamp: &str) -> Option { + let (month, day) = stamp.split_once('-')?; + let (month, day): (u8, u8) = (month.parse().ok()?, day.parse().ok()?); + ((1..=12).contains(&month) && (1..=31).contains(&day)).then_some(CapDate { month, day }) +} + /// What a session's output says about a usage cap. Held in memory only: it is a /// reading of the current output tail, retaken on the next pass, so it clears /// itself once the operator continues the session and new output displaces the @@ -86,6 +204,11 @@ pub struct CapReading { /// named a time. Best-effort by design: an unparsed time badges without /// one rather than suppressing the badge. pub reset_minutes: Option, + /// The day that reset falls on, where the message named one — a weekly cap + /// says `resets Aug 17, 9pm`. The badge shows the clock and never the date + /// (§8); this half is what stops the clock alone reading a reset three days + /// out as passed the moment tonight's 9pm goes by. + pub reset_date: Option, } impl CapReading { @@ -95,24 +218,58 @@ impl CapReading { .map(|m| format!("{:02}:{:02}", m / 60, m % 60)) } - /// Whether the named reset has already gone by, given the local wall clock - /// as minutes past midnight. + /// The reset as the sweep names the window a hold runs to: the same clock, + /// behind the date where the message carried one, so a five-hour hold reads + /// `until 21:50` and a weekly one `until Aug 17 21:00`. + pub fn reset_stamp(&self) -> Option { + let clock = self.reset_label()?; + Some(match self.reset_date { + Some(date) => format!("{} {clock}", date.label()), + None => clock, + }) + } + + /// How long until the named reset, negative once the window has opened and + /// `None` when the message named no time at all. + /// + /// Where the agent names a bare clock time — `9:50pm`, no date — the + /// occurrence meant is the one *nearest* now, in either direction. Taking + /// the next one instead would be self-defeating: a minute after the window + /// reopened, "the next 9:50pm" is tomorrow's, and the badge would claim + /// another 24 hours of waiting exactly when it should be saying the + /// opposite. Half a day is the widest a bare clock time can be read + /// unambiguously, and a five-hour window is never further off than that. + /// + /// A weekly window is, and it says so with a date. Where the reading and + /// the clock both carry one, the date decides and the clock only refines + /// it — which is what stops a reset three days out reading as passed the + /// moment tonight's 9pm goes by. A date is compared exactly as a clock is, + /// nearest occurrence in either direction ([`CapDate::days_from`]), and a + /// reset dated *today* falls back to the clock rule, there being nothing + /// for the date to decide. /// - /// The agent names a bare clock time — `9:50pm`, no date — so the occurrence - /// meant is the one *nearest* now, in either direction. Taking the next one - /// instead would be self-defeating: a minute after the window reopened, - /// "the next 9:50pm" is tomorrow's, and the badge would claim another 24 - /// hours of waiting exactly when it should be saying the opposite. Half a - /// day is the widest a bare clock time can be read unambiguously, and a cap - /// window the operator is watching is never further off than that. - pub fn reset_passed(&self, now_minutes: u16) -> bool { - let Some(reset) = self.reset_minutes else { - return false; + /// A signed distance rather than a bool because the sweep has to rank two + /// live readings against each other to find the binding one + /// ([`plan_sweep`]). + pub fn minutes_until(&self, now: impl Into) -> Option { + let now = now.into(); + let reset = i64::from(self.reset_minutes?); + let clock = reset - i64::from(now.minutes); + let days = match (self.reset_date, now.date) { + (Some(reset), Some(today)) => i64::from(reset.days_from(today)), + _ => 0, }; + if days != 0 { + return Some(days * 1440 + clock); + } // Signed distance wrapped into (-720, 720]: negative is behind us. - let delta = (i32::from(reset) - i32::from(now_minutes)).rem_euclid(1440); - let delta = if delta > 720 { delta - 1440 } else { delta }; - delta <= 0 + let delta = clock.rem_euclid(1440); + Some(if delta > 720 { delta - 1440 } else { delta }) + } + + /// Whether the named reset has already gone by, given the local wall clock. + pub fn reset_passed(&self, now: impl Into) -> bool { + self.minutes_until(now).is_some_and(|left| left <= 0) } } @@ -129,8 +286,10 @@ pub fn read_cap(tail: &str) -> Option { return None; } let after = &text[at..ceil_boundary(&text, (at + signature.len() + WINDOW).min(text.len()))]; + let (reset_minutes, reset_date) = parse_reset(after); Some(CapReading { - reset_minutes: parse_clock(after), + reset_minutes, + reset_date, }) } @@ -201,15 +360,49 @@ pub fn strip_ansi(raw: &str) -> String { out } +/// When the window reopens, read out of the text just after a cap signature, +/// which must already be lowercased: the clock, and the date that clock belongs +/// to where the message named one. +fn parse_reset(text: &str) -> (Option, Option) { + let Some((minutes, at)) = find_clock(text) else { + return (None, None); + }; + (Some(minutes), parse_date_before(text, at)) +} + +/// The `aug 17` immediately before the clock time at `at`, where there is one. +/// +/// Best-effort like every reading here: anything else in front of the clock — +/// `resets `, `retrying in 5m (`, nothing at all — is no date, which leaves the +/// reading where it was before dates were read. +fn parse_date_before(text: &str, at: usize) -> Option { + let before = + text[floor_boundary(text, at.saturating_sub(DATE_WINDOW))..at].trim_end_matches([' ', ',']); + let day_at = before.trim_end_matches(|c: char| c.is_ascii_digit()).len(); + let day: u8 = before[day_at..].parse().ok()?; + let word = before[..day_at].trim_end(); + let month_at = word + .trim_end_matches(|c: char| c.is_ascii_alphabetic()) + .len(); + let word = &word[month_at..]; + let month = MONTHS + .iter() + .position(|m| word.len() >= 3 && m.starts_with(word))?; + (1..=31).contains(&day).then_some(CapDate { + month: month as u8 + 1, + day, + }) +} + /// Minutes past midnight for the first `9pm` / `9:50pm` clock time in `text`, -/// which must already be lowercased. +/// which must already be lowercased, and where in `text` that clock begins. /// /// This is the shape Claude Code renders a reset time in when it is less than a -/// day out, which a cap window the operator is looking at always is. A longer -/// horizon is spelled with a date (`Aug 14, 9pm`) and the clock half still -/// reads, which is the right answer for a badge that shows a time and not a -/// date. -fn parse_clock(text: &str) -> Option { +/// day out, which a five-hour window always is. A longer horizon is spelled +/// with a date (`Aug 17, 9pm`); the clock half still reads here, which is all +/// the badge shows, and the position handed back is what lets the date half be +/// read beside it. +fn find_clock(text: &str) -> Option<(u16, usize)> { let bytes = text.as_bytes(); for i in 0..bytes.len().saturating_sub(1) { let meridiem = match (bytes[i], bytes[i + 1]) { @@ -232,7 +425,7 @@ fn parse_clock(text: &str) -> Option { let Some(minutes) = clock_minutes(&text[j..end], meridiem) else { continue; }; - return Some(minutes); + return Some((minutes, j)); } None } @@ -270,6 +463,96 @@ fn ceil_boundary(text: &str, mut at: usize) -> usize { at } +/// One agent's badged sessions, held back from a sweep, and what holds them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CapHold { + /// The agent entry those sessions were dispatched with — Voro's proxy for + /// the account whose window they are all waiting on. + pub agent: String, + /// How many of that agent's badged sessions are held. + pub sessions: usize, + /// The window they are held until, as [`CapReading::reset_stamp`] names it. + /// `None` where no reading in the group named a time. + pub until: Option, +} + +/// What a sweep of the badged sessions should do (DESIGN.md §8). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SweepPlan { + /// The tasks to nudge, in ascending id order — a sweep visits the strip in + /// a stable order rather than the caller's. + pub ready: Vec, + /// The agents nothing is nudged on, in agent-name order. + pub held: Vec, +} + +/// Decide which capped sessions the sweep may nudge, given every badged +/// reading as `(task, agent, reading)` and the local clock (DESIGN.md §8). +/// +/// A usage cap belongs to the *account* a session was launched under and not to +/// the session, so the verdict is taken once per account rather than once per +/// session: every session of a capped account reports the same window, and +/// reading N of those texts to rebuild one boolean only invites them to +/// disagree. The way they disagree is staleness — a session capped at 4pm still +/// displaying `resets 9pm` at midnight beside one reporting a live cap — and a +/// per-session verdict reads that fossil as ready, quite correctly, and nudges +/// a session the account will re-cap on its next breath. +/// +/// Voro's proxy for the account is the agent entry the session was dispatched +/// with, because that is what it knows. The proxy can be wrong in both +/// directions, and the direction it errs in is chosen: two agent entries +/// sharing one account are not pooled, which is no worse than judging each +/// session alone; one entry run under two accounts holds a session that was in +/// fact free, which costs a delay until the next press rather than a stopped +/// session and a turn that re-caps at once. Since #428 a nudge stops its target +/// first, so the conservative direction is the affordable one. +/// +/// Within a group, one live reading holds every session — including the ones +/// whose message named no time, whose account has just told Voro, through a +/// sibling, that its window is shut. A group with no live reading is ready +/// entire, untimed readings included: nothing that agent said contradicts the +/// operator's keypress. The hold is labelled with the *furthest-out* live +/// reset, which is the binding one — how a weekly cap speaks over a five-hour +/// message on a sibling session. +pub fn plan_sweep(readings: &[(i64, &str, CapReading)], now: Option) -> SweepPlan { + let mut groups: std::collections::BTreeMap<&str, Vec<(i64, CapReading)>> = + std::collections::BTreeMap::new(); + for (task_id, agent, reading) in readings { + groups.entry(agent).or_default().push((*task_id, *reading)); + } + + let mut plan = SweepPlan::default(); + for (agent, mut group) in groups { + group.sort_unstable_by_key(|(task_id, _)| *task_id); + // With no clock nothing can be judged past its reset, so every timed + // reading is live and holds its group. + let live = |reading: &CapReading| match now { + Some(now) => reading.minutes_until(now).is_some_and(|left| left > 0), + None => reading.reset_minutes.is_some(), + }; + if !group.iter().any(|(_, reading)| live(reading)) { + plan.ready.extend(group.iter().map(|(task_id, _)| *task_id)); + continue; + } + // Unjudgeable, the hold takes the lowest task id's window rather than + // whichever the map happened to yield. + let binding = match now { + Some(now) => group + .iter() + .filter(|(_, reading)| live(reading)) + .max_by_key(|(_, reading)| reading.minutes_until(now).unwrap_or(0)), + None => group.first(), + }; + plan.held.push(CapHold { + agent: agent.to_string(), + sessions: group.len(), + until: binding.and_then(|(_, reading)| reading.reset_stamp()), + }); + } + plan.ready.sort_unstable(); + plan +} + #[cfg(test)] mod tests { use super::*; @@ -442,6 +725,7 @@ mod tests { fn a_reset_is_passed_by_nearest_occurrence() { let at = |m| CapReading { reset_minutes: Some(m), + reset_date: None, }; // 21:50 reset, read at 20:50 — an hour to go. assert!(!at(1310).reset_passed(1250)); @@ -484,6 +768,255 @@ mod tests { assert_eq!(strip_ansi("plain"), "plain"); } + /// A reset far enough out to be spelled with a date is read in both halves: + /// the clock the badge shows, and the day that tells the sweep the window + /// is still days away. + #[test] + fn a_dated_reset_reads_both_halves() { + let reading = read_cap("Opus limit reached · resets Aug 14, 9pm").expect("a cap"); + assert_eq!(reading.reset_minutes, Some(21 * 60)); + assert_eq!(reading.reset_date, Some(CapDate { month: 8, day: 14 })); + assert_eq!(reading.reset_label().as_deref(), Some("21:00")); + assert_eq!(reading.reset_stamp().as_deref(), Some("Aug 14 21:00")); + + // The other spellings of the same day, and a five-hour reset with no + // date in front of it. + for text in ["resets august 14, 9pm", "resets aug 14 9pm"] { + let reading = read_cap(&format!("Weekly limit reached · {text}")).expect("a cap"); + assert_eq!( + reading.reset_date, + Some(CapDate { month: 8, day: 14 }), + "{text}" + ); + } + assert_eq!( + read_cap("Session limit reached · Retrying in 5m (9:50pm)") + .expect("a cap") + .reset_date, + None + ); + } + + /// The bug a date exists to fix: a weekly reset three days out was read as + /// passed the moment tonight's 9pm went by, and the sweep would have + /// released the session to spend a turn that re-caps at once. + #[test] + fn a_weekly_reset_is_not_passed_days_early() { + let reading = read_cap("Weekly limit reached · resets Aug 17, 9pm").expect("a cap"); + for hour in 0..24u16 { + let now = LocalNow { + minutes: hour * 60, + date: Some(CapDate { month: 8, day: 14 }), + }; + assert!(!reading.reset_passed(now), "{hour}:00"); + } + } + + /// And the other direction: a date behind today has gone by whatever the + /// clock says, where the bare clock would have called half of those hours + /// a wait. + #[test] + fn a_reset_dated_behind_today_has_passed() { + let reading = read_cap("Weekly limit reached · resets Aug 12, 9pm").expect("a cap"); + for hour in 0..24u16 { + let now = LocalNow { + minutes: hour * 60, + date: Some(CapDate { month: 8, day: 14 }), + }; + assert!(reading.reset_passed(now), "{hour}:00"); + } + } + + /// Dates are compared by nearest occurrence exactly as clocks are, the year + /// being no more written down than the day: a reset in early January read + /// on 30 December is days out, not most of a year behind. + #[test] + fn dates_wrap_the_year_boundary() { + let ahead = read_cap("Weekly limit reached · resets Jan 2, 9pm").expect("a cap"); + let behind = read_cap("Weekly limit reached · resets Dec 30, 9pm").expect("a cap"); + let dec30 = LocalNow { + minutes: 12 * 60, + date: Some(CapDate { month: 12, day: 30 }), + }; + let jan2 = LocalNow { + minutes: 12 * 60, + date: Some(CapDate { month: 1, day: 2 }), + }; + assert!(!ahead.reset_passed(dec30)); + assert!(behind.reset_passed(jan2)); + } + + /// A date invents no clock, and a date in text that never said a cap + /// invents no reading at all. + #[test] + fn a_date_alone_is_not_a_reset_time() { + let reading = read_cap("Weekly limit reached · resets Aug 17").expect("a cap"); + assert_eq!(reading.reset_minutes, None); + assert_eq!(reading.reset_date, None); + assert_eq!(read_cap("your slot resets Aug 17, 9pm"), None); + } + + /// A reading with no clock to read `now` against is judged as it always + /// was, by the clock half alone — which is the whole of what an unreadable + /// date leaves behind. + #[test] + fn an_undated_clock_judges_a_dated_reset_by_its_clock() { + let reading = read_cap("Weekly limit reached · resets Aug 17, 9pm").expect("a cap"); + assert!(!reading.reset_passed(20 * 60)); + assert!(reading.reset_passed(22 * 60)); + } + + /// A cap reading with a reset `minutes` minutes past midnight and no date. + fn timed(minutes: u16) -> CapReading { + CapReading { + reset_minutes: Some(minutes), + reset_date: None, + } + } + + /// Midday on 14 August, the clock the plan tests judge against. + fn midday() -> Option { + Some(LocalNow { + minutes: 12 * 60, + date: Some(CapDate { month: 8, day: 14 }), + }) + } + + /// The account fact, which is the point of the whole rollup: one agent's + /// two sessions, one reporting a live window and one still showing the + /// window it was capped in hours ago. Judged apart, the fossil reads ready + /// and is nudged into an account that is still shut; judged together, the + /// live sibling holds them both and the report names the real window. + #[test] + fn a_live_sibling_holds_a_fossil_of_the_same_agent() { + let plan = plan_sweep( + &[(1, "claude", timed(9 * 60)), (2, "claude", timed(13 * 60))], + midday(), + ); + assert!(plan.ready.is_empty(), "{plan:?}"); + assert_eq!( + plan.held, + vec![CapHold { + agent: "claude".into(), + sessions: 2, + until: Some("13:00".into()), + }] + ); + } + + /// The same two sessions under different agent names are different + /// accounts, and nothing pools them: the one whose window has reopened is + /// swept, the other held. + #[test] + fn different_agents_are_judged_apart() { + let plan = plan_sweep( + &[(1, "claude", timed(9 * 60)), (2, "codex", timed(13 * 60))], + midday(), + ); + assert_eq!(plan.ready, vec![1]); + assert_eq!( + plan.held, + vec![CapHold { + agent: "codex".into(), + sessions: 1, + until: Some("13:00".into()), + }] + ); + } + + /// A cap whose message named no time is the operator's call, not the + /// clock's — unless the account itself contradicts them, which is what a + /// live sibling of the same agent does and a live session of another agent + /// does not. + #[test] + fn an_untimed_reading_is_swept_unless_its_own_agent_is_held() { + let alone = plan_sweep(&[(1, "claude", CapReading::default())], midday()); + assert_eq!(alone.ready, vec![1]); + assert!(alone.held.is_empty()); + + let sibling = plan_sweep( + &[ + (1, "claude", CapReading::default()), + (2, "claude", timed(13 * 60)), + ], + midday(), + ); + assert!(sibling.ready.is_empty(), "{sibling:?}"); + assert_eq!(sibling.held[0].sessions, 2); + + let stranger = plan_sweep( + &[ + (1, "claude", CapReading::default()), + (2, "codex", timed(13 * 60)), + ], + midday(), + ); + assert_eq!(stranger.ready, vec![1]); + assert_eq!(stranger.held[0].agent, "codex"); + } + + /// The binding window is the furthest-out live one — how a weekly cap on + /// one session speaks over a five-hour message on its sibling. + #[test] + fn a_hold_is_labelled_with_the_furthest_out_reset() { + let weekly = CapReading { + reset_minutes: Some(21 * 60), + reset_date: Some(CapDate { month: 8, day: 17 }), + }; + let plan = plan_sweep( + &[(1, "claude", timed(13 * 60)), (2, "claude", weekly)], + midday(), + ); + assert_eq!(plan.held[0].until.as_deref(), Some("Aug 17 21:00")); + } + + /// With no clock to read, nothing can be judged past its reset: every timed + /// reading holds, and a group that named no times at all is still the + /// operator's call. + #[test] + fn an_unknown_clock_judges_nothing_passed() { + let plan = plan_sweep( + &[ + (2, "claude", timed(13 * 60)), + (1, "claude", timed(9 * 60)), + (3, "codex", CapReading::default()), + ], + None, + ); + assert_eq!(plan.ready, vec![3]); + assert_eq!( + plan.held, + vec![CapHold { + agent: "claude".into(), + sessions: 2, + // The lowest task id's window, rather than whichever the map + // happened to yield. + until: Some("09:00".into()), + }] + ); + } + + /// A sweep visits the strip in a stable order rather than the caller's. + #[test] + fn ready_tasks_come_back_in_ascending_order() { + let plan = plan_sweep( + &[ + (9, "codex", timed(9 * 60)), + (2, "claude", timed(9 * 60)), + (5, "claude", CapReading::default()), + ], + midday(), + ); + assert_eq!(plan.ready, vec![2, 5, 9]); + assert!(plan.held.is_empty()); + } + + /// Nothing badged is no plan at all. + #[test] + fn nothing_badged_plans_nothing() { + assert_eq!(plan_sweep(&[], midday()), SweepPlan::default()); + } + /// 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..73dc691 100644 --- a/crates/voro-core/src/lib.rs +++ b/crates/voro-core/src/lib.rs @@ -24,7 +24,10 @@ 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::{ + CAP_SIGNATURES, CapDate, CapHold, CapReading, LocalNow, SweepPlan, plan_sweep, 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..2469556 100644 --- a/crates/voro/src/app.rs +++ b/crates/voro/src/app.rs @@ -397,6 +397,20 @@ fn state_jump_verb(state: TaskState) -> Option { /// that was only ever interrupted. const NUDGE: &str = "continue"; +/// How the sweep names one agent's hold: `claude capped until Aug 17 21:00`, +/// or `claude capped` where no reading in the group named a window at all. +fn hold_clause(hold: &voro_core::CapHold) -> String { + match &hold.until { + Some(until) => format!("{} capped until {until}", hold.agent), + None => format!("{} capped", hold.agent), + } +} + +/// How many sessions that hold covers, as the status line says it. +fn held_sessions(count: usize) -> String { + format!("{count} session{}", if count == 1 { "" } else { "s" }) +} + fn state_accepts_message(state: TaskState) -> bool { matches!( state, @@ -550,12 +564,14 @@ pub struct App { /// The background threads taking those readings, drained by /// `poll_cap_probes`. cap_probe: crate::probe::CapProbe, - /// 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, - /// When `now_minutes` was last read. + /// The local wall clock and date, for deciding whether a badged reset has + /// gone by — the date because a weekly cap names one and a clock alone + /// reads such a reset as passed days early. 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: Option, + /// When `now` was last read. clock_read_at: Option, pub cockpit_rows: Vec, @@ -655,7 +671,7 @@ impl App { cap_targets: Vec::new(), caps: std::collections::HashMap::new(), cap_probe: crate::probe::CapProbe::default(), - now_minutes: None, + now: None, clock_read_at: None, cockpit_rows: Vec::new(), cockpit_sel: 0, @@ -1138,7 +1154,7 @@ impl App { return; } self.clock_read_at = Some(now); - self.now_minutes = crate::session_probe::local_minutes(); + self.now = crate::session_probe::local_now(); } /// Record every revision a background capture has finished (DESIGN.md §8). @@ -2192,7 +2208,8 @@ impl App { }; } - /// Nudge every cap-stuck session whose window has reopened (DESIGN.md §8). + /// Nudge every cap-stuck session whose account's window has reopened + /// (DESIGN.md §8). /// /// A usage cap ends a session's turn and leaves it sitting there: nothing /// retries, so the work waits for a human however long ago the window @@ -2203,10 +2220,10 @@ impl App { /// A keypress starts it, which is where this begins rather than where it is /// meant to end: firing automatically once the window reopens is wanted, and /// nothing here is shaped to prevent it. Automation needs a trigger, not a - /// channel — the same `reset_passed` test, read on the tick instead of on - /// the key — so it layers on top of this rather than replacing it. Manual - /// first only because a badge that false-positives costs one wasted keypress - /// today and an unwatched agent once it is automatic. + /// channel — the same [`voro_core::plan_sweep`], read on the tick instead + /// of on the key — so it layers on top of this rather than replacing it. + /// Manual first only because a badge that false-positives costs one wasted + /// keypress today and an unwatched agent once it is automatic. /// /// Both guards the quick-message key answers to are stood down here, and the /// cap reading is what earns that: [`state_accepts_message`] refuses a @@ -2215,36 +2232,39 @@ 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. - 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); - } - // A sweep visits the strip in a stable order rather than the map's. - ready.sort_unstable(); - if ready.is_empty() { - self.status = Some(if waiting.is_empty() { + // Who is ready is a question about the *account*, not the session, and + // the agent entry a session was dispatched with is Voro's proxy for it + // (DESIGN.md §8) — so the readings go to `plan_sweep` paired with their + // agent and the verdict comes back per group. A session that has since + // left `last_sessions` names no agent and is skipped rather than + // guessed at. + let plan = { + let readings: Vec<(i64, &str, voro_core::CapReading)> = self + .caps + .iter() + .filter_map(|(task_id, reading)| { + let agent = self.last_sessions.get(task_id)?.agent.as_str(); + Some((*task_id, agent, *reading)) + }) + .collect(); + voro_core::plan_sweep(&readings, self.now) + }; + if plan.ready.is_empty() { + self.status = Some(if plan.held.is_empty() { "no session is capped".into() } else { - format!( - "{} capped session{} — none has reached its reset yet", - waiting.len(), - if waiting.len() == 1 { "" } else { "s" } - ) + plan.held + .iter() + .map(|hold| format!("{} — {}", hold_clause(hold), held_sessions(hold.sessions))) + .collect::>() + .join("; ") }); return; } let mut sent = 0usize; let mut refused: Vec = Vec::new(); - for task_id in ready { + for task_id in plan.ready { match self.nudge_one(task_id) { Ok(()) => { sent += 1; @@ -2262,8 +2282,15 @@ impl App { "nudged {sent} capped session{}", if sent == 1 { "" } else { "s" } ); - if !waiting.is_empty() { - note.push_str(&format!(" — {} still before its reset", waiting.len())); + if !plan.held.is_empty() { + note.push_str(&format!( + " — {}", + plan.held + .iter() + .map(|hold| format!("{} ({})", hold_clause(hold), held_sessions(hold.sessions))) + .collect::>() + .join("; ") + )); } if !refused.is_empty() { note.push_str(&format!(" — refused {}", refused.join("; "))); @@ -5893,6 +5920,14 @@ mod tests { (app, task.id, project_path) } + /// The local clock the sweep judges a reset against, on 14 August. + fn local_now(minutes: u16) -> voro_core::LocalNow { + voro_core::LocalNow { + minutes, + date: Some(voro_core::CapDate { month: 8, day: 14 }), + } + } + /// Drive the probe until its reading lands, which is a background thread /// running a subprocess and so not instant. fn settle_cap(app: &mut App, task_id: i64, want: bool) { @@ -5996,7 +6031,7 @@ mod tests { cap_env(true, "Session limit reached - Retrying in 5m (9:50pm)"); settle_cap(&mut app, task_id, true); // An hour past the 21:50 the agent named. - app.now_minutes = Some(22 * 60 + 50); + app.now = Some(local_now(22 * 60 + 50)); key(&mut app, KeyCode::Char('u')); @@ -6038,7 +6073,7 @@ mod tests { 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(22 * 60 + 50); + app.now = Some(local_now(22 * 60 + 50)); key(&mut app, KeyCode::Char('u')); @@ -6068,7 +6103,7 @@ mod tests { 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(22 * 60 + 50); + app.now = Some(local_now(22 * 60 + 50)); set_verb( &app.dispatch_ctx.agents_path, "stop", @@ -6099,7 +6134,7 @@ mod tests { 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 agent named. - app.now_minutes = Some(20 * 60 + 50); + app.now = Some(local_now(20 * 60 + 50)); key(&mut app, KeyCode::Char('u')); @@ -6108,12 +6143,10 @@ mod tests { app.caps.contains_key(&task_id), "the badge stands until the window opens" ); - assert!( - app.status - .as_deref() - .is_some_and(|s| s.contains("none has reached its reset")), - "{:?}", - app.status + assert_eq!( + app.status.as_deref(), + Some("stub capped until 21:50 — 1 session"), + "the hold names the agent whose window everything waits on" ); let _ = std::fs::remove_dir_all(project_path.parent().unwrap()); @@ -6137,6 +6170,86 @@ mod tests { let _ = std::fs::remove_dir_all(project_path.parent().unwrap()); } + /// A weekly cap names a date, and the date is what the sweep judges: three + /// days out is three days out, however many times tonight's 9pm goes by. + /// Read as a bare clock this was swept the same evening, which stopped the + /// session to spend a turn the account re-capped at once. + #[test] + fn u_leaves_a_weekly_cap_dated_days_out() { + let (mut app, task_id, project_path) = + cap_env(true, "Weekly limit reached - resets Aug 17, 9pm"); + settle_cap(&mut app, task_id, true); + // Late on the 14th: tonight's 21:00 has gone by, the 17th has not. + app.now = Some(local_now(22 * 60 + 50)); + + key(&mut app, KeyCode::Char('u')); + + assert_eq!(delivered(&project_path), None, "nothing was sent"); + assert!(app.caps.contains_key(&task_id), "the badge stands"); + assert_eq!( + app.status.as_deref(), + Some("stub capped until Aug 17 21:00 — 1 session"), + "a hold names the date it runs to where the agent gave one" + ); + + let _ = std::fs::remove_dir_all(project_path.parent().unwrap()); + } + + /// The account fact the sweep now reads (DESIGN.md §8): two sessions of one + /// agent, one reporting a live window and one still displaying the window + /// it was capped in hours ago. The fossil's own reading says ready, quite + /// correctly — its window did reopen — but the account's has not, so + /// nudging it would stop a session for a turn that re-caps at once. One + /// hold, named for the live window, and nothing sent. + #[test] + fn u_holds_a_stale_reading_beside_a_live_sibling() { + 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); + + let sibling = app + .store + .create_task(NewTask { + project_id: app.store.task(task_id).unwrap().project_id, + repo_id: None, + title: "the stale one".into(), + body: String::new(), + priority: Priority::P2, + state: TaskState::Ready, + agent: None, + human: false, + deep: false, + }) + .unwrap() + .id; + app.store + .record_dispatch(sibling, "stub", None, LivenessSource::Listing, None) + .unwrap(); + app.refresh().unwrap(); + // Capped at four, still saying so at nine — the one way two sessions of + // one account can disagree. + app.caps.insert( + sibling, + voro_core::CapReading { + reset_minutes: Some(16 * 60), + reset_date: None, + }, + ); + app.now = Some(local_now(20 * 60 + 50)); + + key(&mut app, KeyCode::Char('u')); + + assert_eq!(delivered(&project_path), None, "nothing was sent"); + assert!(app.caps.contains_key(&sibling), "both badges stand"); + assert_eq!( + app.status.as_deref(), + Some("stub capped until 21:50 — 2 sessions"), + "the live window speaks for the account, and both sessions are held" + ); + + let _ = std::fs::remove_dir_all(project_path.parent().unwrap()); + } + /// A cap whose reset time never parsed is still nudgeable: the operator /// pressing the key is the judgement the clock could not supply, and a send /// that turns out to be early is refused by the agent rather than doing harm. diff --git a/crates/voro/src/probe.rs b/crates/voro/src/probe.rs index 1e0ff10..9053b56 100644 --- a/crates/voro/src/probe.rs +++ b/crates/voro/src/probe.rs @@ -642,6 +642,7 @@ mod tests { let mut probe = CapProbe::default(); let capped = CapReading { reset_minutes: Some(1310), + reset_date: None, }; probe.inject_result(7, Some(capped)); probe.inject_result(8, None); diff --git a/crates/voro/src/session_probe.rs b/crates/voro/src/session_probe.rs index dcedf63..bf47a8c 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, + AgentSessionEntry, CapReading, LocalNow, SessionLiveness, parse_sessions_json, read_cap, + render_session, }; /// Run an agent's `sessions` command and parse its listing, in a given @@ -109,24 +110,23 @@ pub fn read_session_cap(logs_cmd: &str, session_ref: &str) -> Option read_cap(&String::from_utf8_lossy(&output.stdout)) } -/// 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`]). +/// The local wall clock and today's date, which is what a reset time in an +/// agent's output is compared against ([`CapReading::reset_passed`]). /// /// Read from `date` because the agent renders that time in the operator's own /// timezone and the standard library offers no local time — a dependency for /// one clock reading would cost more than the subprocess, which runs only when -/// a badge with a time is actually on screen. An unreadable clock answers -/// `None`, and the badge simply shows the time without judging it. -pub fn local_minutes() -> Option { +/// a badge with a time is actually on screen. Both halves come from the one +/// call, so a reading taken a second before midnight cannot pair yesterday's +/// date with today's clock. An unreadable clock answers `None`, and the badge +/// simply shows the time without judging it. +pub fn local_now() -> Option { let output = Command::new("date") - .arg("+%H:%M") + .arg("+%H:%M %m-%d") .stdin(Stdio::null()) .output() .ok()?; - let stamp = String::from_utf8_lossy(&output.stdout); - let (hour, minute) = stamp.trim().split_once(':')?; - let (hour, minute): (u16, u16) = (hour.parse().ok()?, minute.parse().ok()?); - (hour < 24 && minute < 60).then_some(hour * 60 + minute) + LocalNow::parse(&String::from_utf8_lossy(&output.stdout)) } /// What one listing says about one session, for a caller that needs more than @@ -274,11 +274,16 @@ mod tests { } /// The wall clock the reset badge is judged against is a real reading, in - /// range — the badge is wrong in both directions if this is not. + /// range — the badge is wrong in both directions if this is not. Today's + /// date rides along with it, which is what a dated reset is compared + /// against. #[test] fn the_local_clock_reads_within_the_day() { - let minutes = local_minutes().expect("a local clock"); - assert!(minutes < 24 * 60, "{minutes}"); + let now = local_now().expect("a local clock"); + assert!(now.minutes < 24 * 60, "{now:?}"); + let date = now.date.expect("today's date"); + assert!((1..=12).contains(&date.month), "{date:?}"); + assert!((1..=31).contains(&date.day), "{date:?}"); } /// The rest reading the send path and the reconciler act on: a session the diff --git a/crates/voro/src/ui.rs b/crates/voro/src/ui.rs index bb1e0e2..2f5348e 100644 --- a/crates/voro/src/ui.rs +++ b/crates/voro/src/ui.rs @@ -729,8 +729,13 @@ fn strip_pr_span() -> Span<'static> { /// to do about it, so it says so. With no time parsed 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)); +/// +/// A reset that named a date shows its clock and not the date, which is a row +/// on a strip rather than a diary. The date is still what decides the middle +/// shape from the first: without it a weekly reset claimed the window had +/// reopened as soon as tonight's 9pm went by. +fn capped_span(reading: &CapReading, now: Option) -> Span<'static> { + let past = now.is_some_and(|now| reading.reset_passed(now)); let text = match (reading.reset_label(), past) { (_, true) => " ⚠ capped · reset passed".to_string(), (Some(at), false) => format!(" ⚠ capped ↻{at}"), @@ -1442,7 +1447,7 @@ fn draw_running(frame: &mut Frame, app: &App, area: Rect, hits: &mut HitMap) { } } if let Some(reading) = app.caps.get(&r.task_id) { - spans.push(capped_span(reading, app.now_minutes)); + spans.push(capped_span(reading, app.now)); } // 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. @@ -3254,6 +3259,14 @@ mod tests { ); } + /// The local clock the badge judges a reset against, on 14 August. + fn local_now(minutes: u16, day: u8) -> voro_core::LocalNow { + voro_core::LocalNow { + minutes, + date: Some(voro_core::CapDate { month: 8, day }), + } + } + /// The badge a capped-but-alive dispatch earns (DESIGN.md §8), end to end /// through the real cockpit draw. The session is `running` throughout and /// stays that way: a cap is a display fact about a session that will resume @@ -3325,9 +3338,10 @@ mod tests { task, CapReading { reset_minutes: Some(21 * 60 + 50), + reset_date: None, }, ); - app.now_minutes = Some(20 * 60 + 50); + app.now = Some(local_now(20 * 60 + 50, 14)); let line = row(&app); assert!(line.contains("⚠ capped"), "{line}"); assert!(line.contains("↻21:50"), "{line}"); @@ -3340,10 +3354,26 @@ mod tests { // Past that time the window is open and the session is only waiting to // be nudged — a different situation, said differently. - app.now_minutes = Some(22 * 60); + app.now = Some(local_now(22 * 60, 14)); let line = row(&app); assert!(line.contains("⚠ capped · reset passed"), "{line}"); + // A weekly cap dated days out is not that: the badge keeps showing the + // clock and nothing else, but the day it falls on is what decides + // whether the window has reopened, so tonight's 21:00 going by no + // longer reads as the window opening. + app.caps.insert( + task, + CapReading { + reset_minutes: Some(21 * 60), + reset_date: Some(voro_core::CapDate { month: 8, day: 17 }), + }, + ); + let line = row(&app); + assert!(line.contains("⚠ capped ↻21:00"), "{line}"); + assert!(!line.contains("reset passed"), "{line}"); + assert!(!line.contains("Aug"), "{line}"); + // A cap the agent named no time for still badges: the time is the // optional half, and withholding the badge for want of it would trade // the signal for a detail. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 1ee47c7..7466425 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -335,17 +335,19 @@ A session's entry in the *agent's own* registry follows its row in the same way: *Which text* is scanned is the substantive question, and Voro's own launch log is the wrong answer for the launches that matter. Under a supervisor-owned launch (`claude --bg`) the launcher exits at birth having written nothing but the backgrounding banner, so scanning that log could essentially never report `capped` however the session died. The agent's verb set therefore gains an optional **`logs`**: a session in (`{session}`), that session's recent output out. It is an opaque per-agent contract like the rest and degrades like the rest — an agent that defines none is classified from the launch-log tail exactly as before, and the built-in `codex` defines none. The text it returns may be a terminal capture rather than a log, so escape sequences are stripped before matching, with cursor movement becoming a space (it stands for the gap between two words) and colour vanishing (it does not, and would otherwise split the phrase it styles). -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 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. A window further off than a day is written with a date (`resets Aug 17, 9pm`), and both halves are read: the badge still shows the clock and never the date, being a row on a strip rather than a diary, but the date is what the reset is *compared* by, since a bare clock read as its nearest occurrence calls a weekly reset passed the moment tonight's 9pm goes by — and an automatic sweep would act on that. Dates are compared as clocks are, nearest occurrence in either direction, on a synthetic ordinal of twelve 31-day months: only the sign of the difference is ever read, so short months and leap years do not enter into it and no calendar dependency is needed. 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. -**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. +**Recovering a capped session** is then one key, `u`, which nudges *every* badged session whose window has reopened. 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, which agents are held and until when, and any the agent refused. + +*Whose* window has reopened is a question about the **account** a session was launched under and not about the session. Every session of a capped account reports the same window; reading N of their screens to rebuild one boolean only invites them to disagree, and the one way they can is staleness — a session capped at four o'clock still displaying `resets 9pm` at midnight beside one reporting a live cap. Judged alone, that fossil reads ready and is quite right, its window did reopen; judged as part of the account, it is held with its sibling, because nudging it stops a session for a turn the account re-caps at once. The verdict is therefore taken once per **agent entry**, which is Voro's proxy for the account, and it is per agent rather than global because the operator's accounts are: different agents on different projects have separate windows, and one global reading would be wrong the moment two are in play. The proxy is wrong in two ways and the direction is chosen. Two agent entries sharing an account (a second `claude` differing only in model) are not pooled, which degrades to judging each session alone — no worse than before. One entry run under two accounts holds a session that was in fact free, costing a delay until the next press rather than a stopped session and a wasted turn; now that every nudge stops its target first, the conservative direction is the affordable one. All of this is read off Claude Code's behaviour — the five-hour and weekly windows, the `resets 9:50pm` and `resets Aug 17, 9pm` wordings — and other agents may cap per project, per key or per organisation. Grouping by agent stays true for an agent whose caps are *narrower* than an account, being merely conservative there, and an agent defining no `logs` verb contributes no readings and is swept by nothing, exactly as `codex` is. The reading itself stays on the session, which is where the text comes from and what the badge renders; only the verdict is account-scoped. Within a group the hold is labelled with the *furthest-out* live reset, the binding one — how a weekly cap on one session speaks over a five-hour message on its sibling. 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. 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* first. A supervisor-owned session refuses a plain headless `--resume` for as long as its supervisor lives, and a capped session's supervisor is alive by definition, so something has to remove that hold. The rest-stop above never will: it fires on a listing entry that reads `done`, and a capped session reads `blocked` — the same word a permission prompt earns — for as long as it sits there. Nor can the sweep wait for the rule, because the rule's `done` test is only *sufficient* by virtue of the liveness gate refusing everything it does not cover, and the sweep has already walked past that gate. Having stood down the guard that makes the test enough, it cannot then lean on the test; the bypass has to be complete or the nudge does not land. So the sweep stops the session itself, waits for the answer, and resumes it in place, abandoning the nudge if the release fails rather than spawning a send that could only be refused. No `tmux send-keys` channel or supervisor IPC is needed, and none is built. The send is otherwise recorded exactly as a quick message is, with the pid now carrying the turn, so a nudged session stays as visible to the reconciler as a messaged one; the badge is dropped the moment the send lands, so a second press cannot put a second agent on the same worktree, and it returns on the next reading if the session is still held. 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 same account-scoped plan, 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, unless its own agent has a live reading — the operator pressing the key is the judgement the clock could not supply, and nothing that agent said contradicts them, whereas a capped sibling of the same account does say so and holds it. 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. 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`.