Streamline recovery of capped sessions after the usage window resets - #155
Merged
Conversation
A usage cap does not retry. It ends the session's turn and leaves it sitting there, so capped work waits for a human however long ago the window reopened — and clearing the strip by hand costs an attach, a typed word and a detach per session, which is how the overnight reset hours got lost. `u` is that walk as a single keystroke: it tells every badged session whose reset has passed to continue, and reports how many it nudged, how many are still before their window, and any the agent refused. It fires only when pressed. There is no daemon, and an agent resuming unwatched on a mis-read badge would cost more than a lost hour. The send goes out through the existing `message` verb rather than any new channel. That verb forks, which is what makes it land: a supervisor-owned session refuses a plain headless `--resume` while its supervisor lives, and a capped session's supervisor is alive by definition. A forked send was confirmed accepted against a live, supervisor-held session mid-turn — a strictly harder case than a capped one, which sits idle with its turn already ended — so no tmux send-keys channel or supervisor IPC is needed. It is recorded exactly as a quick message is, with the forked reference and the pid now carrying the turn, and the badge drops as each send lands so a second press cannot put a second agent on the same worktree. Two guards are 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. Nothing else in the cockpit can recognise that combination, so nothing else may skip them. Two fixes come with it, both found against the first real cap this has ever been able to observe: The badge was losing the reset time on every genuine cap. Real messages end with `/upgrade to increase your usage limit`, which carries a signature of its own and comes last, so it decided — and it is the one signature with no time after it. It is now read as the boilerplate it is and skipped when choosing which signature speaks. Skipping rather than qualifying is deliberate: a qualifier would let an earlier, superseded cap speak past the warning that had replaced it. The `message` verb carried no `--permission-mode`. The flag is per invocation rather than a property of the session, so every resumed turn ran in ask mode against a stdin at `/dev/null`, stopping on approvals nobody could give and landing the refusals in the launch log. A nudge through it would have woken sessions that could not edit or run anything. DESIGN.md §8 covers the sweep, the stood-down guards and the boilerplate rule; agent-integration.md documents the permission-mode expectation.
The nudge sweep shipped arguing for itself: §8 said it "fires nothing on its own" because an agent resuming unwatched "would cost more than a lost hour". That reads as a design position against automatic resumption, when in fact automation is wanted and this is meant to be the half it sits on. A later task would have had to overturn the doc rather than extend it. So the doc now says what is actually true: firing on a keypress is where this starts, not where it ends; automation needs a trigger rather than a channel — the same reset-passed test, read on the tick instead of on the key — and manual came first only because a badge that false-positives costs one wasted keypress today and an unwatched agent once it is automatic. Writing that down surfaced a rule automation has to invert rather than inherit. The sweep nudges a cap whose reset time never parsed, on the grounds that the operator pressing the key is the judgement the clock could not supply. Nothing stands behind that decision on a tick, so an untimed cap has nothing saying its window has opened and should be skipped — which makes the reset-time parse load-bearing in a way it is not today. Noted at the branch in nudge_capped and in §8. No behaviour changes.
…recovery # Conflicts: # CHANGELOG.md
MJohnson459
added a commit
that referenced
this pull request
Aug 14, 2026
`--permission-mode` is per invocation rather than a property of the session a verb joins, so a `message` turn launched without it runs in ask mode against a stdin at /dev/null: the rework session thinks, is refused its edits and its commands, and so cannot run `voro done`. The work is done and never reported, reconcile finds the process gone, and the task lands in `stalled` — a missing flag surfacing as a dead agent, which sends the operator to liveness rather than to the launch that could not act (observed on task 307, session ece71938). The flag itself reached the built-in `claude` message template in #155. What was missing was anything holding it there. A test now asserts it over every built-in template that hands an agent a prompt — `dispatch`, `plan`, `message` — and asserts that `resume` carries neither a prompt nor a mode, so the one launch that omits it does so deliberately: it hands the operator a terminal in which the ask-mode default is answerable. DESIGN.md §8 gains the property the test encodes — a permission mode belongs to a launch, not to a verb — and the capped-recovery paragraph now refers to it rather than restating it. Verified against an isolated scratch store: a task dispatched to the built-in `claude` agent, rejected with feedback through the cockpit's `a` key, forked into `claude -p ... --permission-mode auto` (confirmed in the launch log), and that session edited the file, ran git, and reported with `voro done` — landing the task back in `review` with no permission refusal in its log. `cargo test --workspace` (835 tests) and `cargo clippy --workspace --all-targets -- -D warnings` pass.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A usage cap does not retry. It ends the session's turn and leaves it sitting
there, so capped work waits for a human however long ago the window reopened,
and clearing the strip by hand costs an attach, a typed word and a detach per
session.
uis that walk as one keystroke.This task finally got its live capped session: the cap landed on this very
dispatch mid-task, along with #431 and #425. Everything below was observed, not
inferred.
Investigation
The real cap wording, captured from three live sessions:
and the warning short of it, from a session still working fine:
No cap retries. The "· Retrying in 3s · attempt 3/10" shape seen on a
sibling session belongs to a 529 Overloaded, not a cap. A capped session ends
its turn and waits for a human, which is why attaching and typing was the only
recovery.
#415's guesses held, with one exception.
"session limit"matches the realcap and
"% of your"correctly takes the 98% warning back — both were writtenfrom the agent's binary and both are right. But the reset time was being lost
on every real cap: the notice is followed by
/upgrade to increase your usage limit, which is itself a signature, is last, and so decided — and it is the onesignature with no time after it. Every genuine cap badged as a bare
⚠ capped,which also meant the strip could never say whether the window had reopened. Now
read as boilerplate and skipped. Skipping rather than qualifying is deliberate:
a qualifier would let an earlier, superseded cap speak past the warning that
replaced it.
A headless send does land on a cap-stuck session. Verified directly: a
forked send —
claude -p --resume <id> --fork-session --session-id <fresh>—was fired at a live, supervisor-held, mid-turn
--bgsession (this task's own,status: busy) and returned exit 0 with a real answer. That is strictly harderthan a capped session, which sits
status: idle, state: blocked. The Julyfinding that a bg session refuses headless resume applies to a plain
--resume; the fork sidesteps the supervisor lock, and Voro'smessageverbhas forked since #133. So the channel Voro already owns is the right one — no
tmux send-keys, no supervisor IPC, and none built.
claude agents --jsonstill says nothing distinctive, confirming #415: acapped session is
state: "blocked", the same word a permission prompt earns.One wrinkle worth recording — a session capped while the operator is still
typing at it reads
state: "working", status: "busy", so the listing is notmerely uninformative but actively misleading.
claude logsremains the onlychannel.
What changed
usweeps every badged session whose reset has passed, tells each tocontinue, and reports how many it nudged, how many are still before their
window, and any the agent refused. One word — continue — because the session
already holds its transcript, its worktree and its half-written work.
whose time never parsed is swept too: the keypress is the judgement the clock
could not supply, and an early send is refused by the agent rather than doing
harm.
refused on a
runningtask (mid-turn) and refused again when the session islisted live — but a capped session is
running, listed live, and notmid-turn. Nothing else in the cockpit can recognise that, so nothing else may
skip them.
carrying the turn — so a nudged session stays visible to the reconciler. The
badge drops as each send lands, so a second press cannot put a second agent on
the same worktree; it returns on the next reading if the session is still held.
--permission-mode autoadded to the built-inclaudemessage verb. Theflag is per invocation, not a property of the session, so resumed turns ran in
ask mode against a stdin at
/dev/null: edits and commands stopped forapprovals nobody could give, refusals went to the launch log, and the send
looked delivered while quietly doing nothing. A nudge through it would have
woken sessions that could not act. This also fixes
afor every existing use.runningthroughout.agent-integration.md documents the permission-mode expectation; CHANGELOG has
an Added and two Fixed entries.
Verification
cargo test --workspace: 807 pass (436 voro + 370 voro-core + 1 integration).cargo clippy --workspace --all-targets -- -D warningsandcargo fmtclean.New tests: the two real wordings against the classifier (a cap that keeps its
reset time despite the trailing upgrade prompt, and the 98%/99% warnings that
must not badge); and four on the sweep — a capped session past its reset is
nudged with both guards stood down and the badge cleared, one still before its
reset is left alone, an uncapped fleet sends nothing, and a cap that named no
time is swept anyway. All #415's existing tests still pass unchanged.
One thing I could not test live: whether a nudge fired before the window
reopens is refused cleanly by the agent. The design assumes it is (the fork's
own turn hits the same cap), and the sweep skips those sessions anyway unless
the reset time never parsed.
Known edge: the key map overlay is one row taller, so on an 80×25 terminal
ctrl-nandenow fall below the fold. Any new key would do this; worth ascroll or a two-column Actions list if it bites.
Branch
task/416-capped-recovery, committed locally and not pushed —pushing needs your go-ahead.