diff --git a/crates/voro-core/src/cap.rs b/crates/voro-core/src/cap.rs index 424e8bc..14b223c 100644 --- a/crates/voro-core/src/cap.rs +++ b/crates/voro-core/src/cap.rs @@ -23,6 +23,15 @@ //! disbelieve the badge. That asymmetry is why [`NOT_CAP_QUALIFIERS`] exists — //! the agent says "approaching" and "not your usage limit" in text that //! otherwise matches, and both mean the session is working fine. +//! +//! A matched cap is then read for one thing beyond its reset time: whether the +//! agent is *retrying* the rejected request ([`CapReading::retrying`]). A +//! usage cap never is — Claude Code declines to retry one at all (§8) — but a +//! plain rate limit is retried, and the agent labels that retry with the API's +//! own wording, which contains "rate limit". So the text that says a session +//! 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. /// Phrases that mean "held at a usage cap", checked case-insensitively. /// @@ -65,8 +74,16 @@ const NOT_CAP_QUALIFIERS: [&str; 4] = ["approaching", "% of your", "not your", " /// speak past a warning that had since replaced it. const MENTION_PREFIXES: [&str; 2] = ["/upgrade", "increase your"]; +/// Phrases that mean the agent is *retrying* the request the cap rejected +/// rather than having ended its turn on it. Read in the same window as the +/// reset time, since the agent renders both on one line with the retry half +/// after the label: `Session limit reached · Retrying in 5m (9:50pm) · +/// attempt 2/10`. +const RETRYING: [&str; 1] = ["retrying in"]; + /// How much text after a matched signature is read for the reset time that -/// goes on the badge. +/// goes on the badge, and for the retry wording that says the turn is still +/// running. const WINDOW: usize = 200; /// How much text before a matched signature is read for the qualifiers that @@ -86,6 +103,24 @@ 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, + /// Whether the agent said it is retrying the rejected request rather than + /// having ended its turn on it. + /// + /// This is the one capped shape a nudge must not touch. The session is + /// mid-turn: it holds the request itself and will resume when the retry + /// lands, so stopping it to send *continue* kills a turn that needed no + /// help — and the sweep stops its target unconditionally (§8), so there is + /// nothing else between a misread and a killed turn. Both shapes otherwise + /// read identically: `blocked` in the agent's listing, supervisor alive, + /// the same cap phrase in the output. + /// + /// The risk this guards is not that a *cap* retries — Claude Code refuses + /// to retry a usage-limit rejection at all (§8) — but that a retry reads + /// as a cap. A rate limit that carries no usage-limit headers is retried, + /// and the agent labels that retry with the API's own message, which says + /// "rate limit" in it: a signature this module matches, riding a session + /// that is working perfectly well. + pub retrying: bool, } impl CapReading { @@ -96,7 +131,10 @@ impl CapReading { } /// Whether the named reset has already gone by, given the local wall clock - /// as minutes past midnight. + /// as minutes past midnight — and so whether this session is waiting on a + /// human rather than on the clock. A retrying session is never either: it + /// is waiting on its own request, and its reset time is when that request + /// goes out, not when someone should intervene. /// /// 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 @@ -106,6 +144,9 @@ impl CapReading { /// 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 { + if self.retrying { + return false; + } let Some(reset) = self.reset_minutes else { return false; }; @@ -131,6 +172,7 @@ pub fn read_cap(tail: &str) -> Option { let after = &text[at..ceil_boundary(&text, (at + signature.len() + WINDOW).min(text.len()))]; Some(CapReading { reset_minutes: parse_clock(after), + retrying: RETRYING.iter().any(|p| after.contains(p)), }) } @@ -274,14 +316,24 @@ fn ceil_boundary(text: &str, mut at: usize) -> usize { mod tests { use super::*; - /// The wording every `--bg` dispatch of the built-in `claude` hits first, - /// and the one the original three signatures all missed: a five-hour cap + /// The wording the original three signatures all missed: a five-hour cap /// says "Session limit reached" and nothing about usage, rates or quotas. + /// + /// The retry half is *binary-derived*, assembled from the agent's own + /// template rather than seen on a session — and taking it for an + /// observation is what sent a reviewer looking for a cap that retries. + /// What the template shows is real enough: the agent renders exactly this + /// line for a retried rate limit, reset time in parentheses and attempt + /// counter behind it. What it does not show is a cap reaching it, because + /// a usage limit is not among the errors the agent retries (§8). So the + /// string stands as the shape [`CapReading::retrying`] must recognise, and + /// stands for nothing about whether a *cap* wears it. #[test] fn a_five_hour_cap_reads_as_capped() { let reading = read_cap("Session limit reached · Retrying in 5m (9:50pm) · attempt 2/10") .expect("a cap"); assert_eq!(reading.reset_label().as_deref(), Some("21:50")); + assert!(reading.retrying); } /// The wording an actual five-hour cap turned out to use, captured from @@ -300,6 +352,44 @@ mod tests { ) .expect("a cap"); assert_eq!(reading.reset_label().as_deref(), Some("18:40")); + // The half that matters for the sweep: the real thing names no retry, + // so the turn is over and the session is waiting on a human. + assert!(!reading.retrying); + } + + /// A retry the operator's sessions really do hit, captured from a `--bg` + /// session on 2026-08-13 while a sibling sat at the cap above. It is the + /// live proof that a backgrounded session reaches the retry banner — and + /// that the banner alone is not a cap: an overload names no limit, so + /// nothing here matches and no badge is owed. + #[test] + fn an_overload_retry_is_not_a_cap() { + assert_eq!( + read_cap( + "529 Overloaded · Retrying in 3s · attempt 3/10\n\ + If it persists, check https://status.claude.com." + ), + None + ); + } + + /// The case [`CapReading::retrying`] is built for: a rate limit the agent + /// *does* retry, labelled with the API's own wording. It matches on "rate + /// limit" like any cap, it badges like any cap — and the session behind it + /// is mid-turn, so the sweep must let it alone. + #[test] + fn a_retried_rate_limit_reads_as_retrying() { + 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); + // And it is never due a nudge, whatever the clock says, because the + // time it names is when its own request goes out. + let timed = read_cap("Session limit reached · Retrying in 5m (9:50pm) · attempt 2/10") + .expect("a cap"); + assert_eq!(timed.reset_minutes, Some(21 * 60 + 50)); + assert!(!timed.reset_passed(22 * 60 + 50)); } /// The real warning short of that cap, captured from a session that went on @@ -442,6 +532,7 @@ mod tests { fn a_reset_is_passed_by_nearest_occurrence() { let at = |m| CapReading { reset_minutes: Some(m), + retrying: false, }; // 21:50 reset, read at 20:50 — an hour to go. assert!(!at(1310).reset_passed(1250)); diff --git a/crates/voro/src/app.rs b/crates/voro/src/app.rs index ebb4092..698b01a 100644 --- a/crates/voro/src/app.rs +++ b/crates/voro/src/app.rs @@ -2222,19 +2222,45 @@ impl App { // 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()); + let mut retrying = 0usize; for (id, reading) in &self.caps { + // 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 + // (`nudge_one`), sending into one does not add a redundant turn but + // ends the turn already running. That the operator pressed the key + // is no argument for it either: the untimed cap below is swept on + // 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 { + 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); } // A sweep visits the strip in a stable order rather than the map's. ready.sort_unstable(); + // Every badged session the sweep declined to touch is accounted for in + // what it reports, retrying ones included: a row left alone in silence + // reads as one the sweep missed. + let retrying_note = match retrying { + 0 => String::new(), + n => format!(" — {n} retrying"), + }; if ready.is_empty() { - self.status = Some(if waiting.is_empty() { + self.status = Some(if waiting.is_empty() && retrying == 0 { "no session is capped".into() + } else if waiting.is_empty() { + format!( + "{retrying} capped session{} — retrying, none waiting on you", + if retrying == 1 { "" } else { "s" } + ) } else { format!( - "{} capped session{} — none has reached its reset yet", + "{} capped session{} — none has reached its reset yet{retrying_note}", waiting.len(), if waiting.len() == 1 { "" } else { "s" } ) @@ -2265,6 +2291,7 @@ impl App { if !waiting.is_empty() { note.push_str(&format!(" — {} still before its reset", waiting.len())); } + note.push_str(&retrying_note); if !refused.is_empty() { note.push_str(&format!(" — refused {}", refused.join("; "))); } @@ -5841,8 +5868,10 @@ mod tests { std::process::id() ), ); + // Double-quoted in the shell so a fixture can carry the apostrophe the + // agent's real cap message has in it. let logs = if define_logs { - format!("logs = \"printf '%s' '{logs_output}' # {{session}}\"\n") + format!("logs = \"printf '%s' \\\"{logs_output}\\\" # {{session}}\"\n") } else { String::new() }; @@ -5913,7 +5942,7 @@ mod tests { #[test] fn a_live_capped_dispatch_is_read_with_its_reset_time() { let (mut app, task_id, project_path) = - cap_env(true, "Session limit reached - Retrying in 5m (9:50pm)"); + cap_env(true, "You've hit your session limit · resets 9:50pm"); assert_eq!(app.cap_target_ids(), vec![task_id]); settle_cap(&mut app, task_id, true); @@ -5993,7 +6022,7 @@ mod tests { #[test] fn u_nudges_a_capped_session_whose_window_has_reopened() { let (mut app, task_id, project_path) = - cap_env(true, "Session limit reached - Retrying in 5m (9:50pm)"); + cap_env(true, "You've hit your session limit · resets 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); @@ -6036,7 +6065,7 @@ mod tests { #[test] fn u_releases_the_capped_session_before_resuming_it() { let (mut app, task_id, project_path) = - cap_env(true, "Session limit reached - Retrying in 5m (9:50pm)"); + cap_env(true, "You've hit your session limit · resets 9:50pm"); settle_cap(&mut app, task_id, true); app.now_minutes = Some(22 * 60 + 50); @@ -6066,7 +6095,7 @@ mod tests { #[test] fn a_nudge_whose_release_fails_sends_nothing() { let (mut app, task_id, project_path) = - cap_env(true, "Session limit reached - Retrying in 5m (9:50pm)"); + cap_env(true, "You've hit your session limit · resets 9:50pm"); settle_cap(&mut app, task_id, true); app.now_minutes = Some(22 * 60 + 50); set_verb( @@ -6096,7 +6125,7 @@ mod tests { #[test] fn u_leaves_a_capped_session_that_is_still_waiting() { let (mut app, task_id, project_path) = - cap_env(true, "Session limit reached - Retrying in 5m (9:50pm)"); + cap_env(true, "You've hit your session limit · resets 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); @@ -6158,6 +6187,47 @@ mod tests { let _ = std::fs::remove_dir_all(project_path.parent().unwrap()); } + /// The one badged session the sweep walks past (DESIGN.md §8): one that is + /// retrying the request the limit rejected. It is mid-turn and will carry + /// on by itself, and a nudge would not add a turn to it but end the one + /// running, since the send stops its target first. So it is skipped however + /// far past the named time the clock has gone — and said aloud, because a + /// row passed over in silence reads as one the sweep failed to reach. + #[test] + fn u_leaves_a_retrying_session_alone() { + let (mut app, task_id, project_path) = cap_env( + true, + "429 exceeded your rate limit · Retrying in 30s · attempt 3/10", + ); + settle_cap(&mut app, task_id, true); + assert!(app.caps[&task_id].retrying); + app.now_minutes = Some(22 * 60 + 50); + + key(&mut app, KeyCode::Char('u')); + + assert_eq!( + delivered(&project_path), + None, + "a mid-turn session is not sent into: {:?}", + app.status + ); + assert_eq!( + nudge_stopped(&project_path), + None, + "nor stopped, which is what a send would have done to it first" + ); + assert!( + app.caps.contains_key(&task_id), + "and it keeps its badge, since nothing was done about it" + ); + assert_eq!( + app.status.as_deref(), + Some("1 capped session — retrying, none waiting on you") + ); + + let _ = std::fs::remove_dir_all(project_path.parent().unwrap()); + } + /// `A` on a running task whose session is still listed queues the agent's /// `attach` command — ref substituted, project path as cwd — for main() to /// run with the TUI suspended. diff --git a/crates/voro/src/probe.rs b/crates/voro/src/probe.rs index 1e0ff10..f98e874 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), + retrying: false, }; 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..6c3893e 100644 --- a/crates/voro/src/session_probe.rs +++ b/crates/voro/src/session_probe.rs @@ -242,11 +242,12 @@ mod tests { #[test] fn a_capped_session_reads_as_capped_through_the_logs_verb() { let reading = read_session_cap( - "printf 'Session limit reached · Retrying in 5m (9:50pm)' # {session}", + "printf \"You've hit your session limit · resets 9:50pm (Europe/London)\" # {session}", "uuid-1", ) .expect("a cap"); assert_eq!(reading.reset_label().as_deref(), Some("21:50")); + assert!(!reading.retrying); // The reference reaches the command as itself, so a session can be // singled out by it rather than the template being merely decorative. diff --git a/crates/voro/src/ui.rs b/crates/voro/src/ui.rs index bb1e0e2..a7dda67 100644 --- a/crates/voro/src/ui.rs +++ b/crates/voro/src/ui.rs @@ -729,12 +729,21 @@ 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. +/// +/// 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) { - (_, true) => " ⚠ capped · reset passed".to_string(), - (Some(at), false) => format!(" ⚠ capped ↻{at}"), - (None, false) => " ⚠ capped".to_string(), + let text = match (reading.reset_label(), past, reading.retrying) { + (Some(at), _, true) => format!(" ⚠ capped · retrying ↻{at}"), + (None, _, true) => " ⚠ capped · retrying".to_string(), + (_, true, false) => " ⚠ capped · reset passed".to_string(), + (Some(at), false, false) => format!(" ⚠ capped ↻{at}"), + (None, false, false) => " ⚠ capped".to_string(), }; Span::styled( text, @@ -3325,6 +3334,7 @@ mod tests { task, CapReading { reset_minutes: Some(21 * 60 + 50), + retrying: false, }, ); app.now_minutes = Some(20 * 60 + 50); @@ -3353,6 +3363,22 @@ mod tests { assert!(!line.contains('↻'), "{line}"); assert!(!line.contains("reset passed"), "{line}"); + // A session retrying the rejected request says so, and never says its + // reset has passed however long ago the named time went by: it is + // mid-turn, so the time is when its own request goes out rather than + // when the operator should step in. + app.caps.insert( + task, + CapReading { + reset_minutes: Some(21 * 60 + 50), + retrying: true, + }, + ); + app.now_minutes = Some(22 * 60); + let line = row(&app); + assert!(line.contains("⚠ capped · retrying ↻21:50"), "{line}"); + assert!(!line.contains("reset passed"), "{line}"); + // And the badge clears itself once the reading goes away, which is what // continuing the session does on the next pass. app.caps.remove(&task); diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 1ee47c7..9bf1854 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -335,11 +335,13 @@ 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, `⚠ 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, and `⚠ capped · retrying ↻21:50` where the agent said it is retrying the rejected request, which is the one shape that wants nothing done about it at all (below). The reset time is parsed best-effort from a bare clock time, resolved to whichever occurrence is nearest — taking the *next* one would claim another day's wait a minute after the window reopened. Three properties keep that badge honest. It carries **no state change**: `stalled` means "dead dispatch, redispatch me", and a capped session is neither dead nor in need of redispatch, so the task stays `running` and the session stays open. It is **not schema**: the reading is held in memory, recomputed, and never written, which is what makes it self-clearing — the operator continues the session, its next output no longer says "limit reached", and the badge is gone on the following pass rather than needing to be retracted. And it is **off the event loop**: the verb costs the better part of a second per session, which the render path may never wait on (see *What may block the TUI event loop*), so it runs on a background thread and is debounced to one reading per session per minute — the one probe in the TUI debounced against the clock rather than against the selection, since every in-flight session is a target on every tick. There is deliberately no cockpit-header quota gauge: the statusline JSON that carries `rate_limits.five_hour.resets_at` is pushed *to* running Claude sessions and is not readable by Voro, so a gauge would need a data source that does not exist. -**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 reset has gone by. A cap does not retry — Claude Code declines to retry a usage-limit rejection at all, a rule read out of the agent itself rather than assumed: a 429 carrying the usage-limit headers is classified non-retryable under a subscription login, and the one path that would wait a cap out and resume by itself is gated on an environment flag the agent's remote runner sets and a local `--bg` launch does not. So the cap ends the session's turn and leaves it sitting there, and 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. + +The one badged session the sweep walks past is one whose output says it is *retrying*. That a cap never retries is a fact about the agent, not about the badge, and the risk runs the other way: a rate limit carrying none of the usage-limit headers *is* retried, and the agent labels that retry with the API's own wording, which has "rate limit" in it — so the matcher sees a cap on a session that is mid-turn and will resume itself. Nothing else tells the two apart, both reading `blocked` with a live supervisor and both wearing a cap phrase, so the reading carries whether the output named a retry and the sweep skips those, counting them in what it reports rather than passing over them in 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. 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.