Skip to content

Streamline recovery of capped sessions after the usage window resets - #155

Merged
MJohnson459 merged 3 commits into
mainfrom
task/416-capped-recovery
Aug 14, 2026
Merged

Streamline recovery of capped sessions after the usage window resets#155
MJohnson459 merged 3 commits into
mainfrom
task/416-capped-recovery

Conversation

@MJohnson459

Copy link
Copy Markdown
Contributor

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. u is 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:

You've hit your session limit · resets 6:40pm (Europe/London)
/upgrade to increase your usage limit.

and the warning short of it, from a session still working fine:

You've used 98% of your session limit · resets 6:40pm (Europe/London)
/upgrade to keep using Claude Code

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 real
cap and "% of your" correctly takes the 98% warning back — both were written
from 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 one
signature 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 --bg session (this task's own,
status: busy) and returned exit 0 with a real answer. That is strictly harder
than a capped session, which sits status: idle, state: blocked. The July
finding that a bg session refuses headless resume applies to a plain
--resume; the fork sidesteps the supervisor lock, and Voro's message verb
has 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 --json still says nothing distinctive, confirming #415: a
capped 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 not
merely uninformative but actively misleading. claude logs remains the only
channel.

What changed

  • u sweeps every badged session whose reset has passed, tells each to
    continue, 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.
  • Manual only, per your call. No daemon, nothing fires unattended. A cap
    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.
  • Two guards stood down, earned by the cap reading: a quick message is
    refused on a running task (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, so nothing else may
    skip them.
  • The send is recorded as a quick message is — forked reference, the pid now
    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 auto added to the built-in claude message verb. The
    flag 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 for
    approvals 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 a for every existing use.
  • No state change, no migration — the task stays running throughout.
  • DESIGN.md §8 covers the sweep, the stood-down guards and the boilerplate rule;
    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 warnings and cargo fmt clean.

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-n and e now fall below the fold. Any new key would do this; worth a
scroll or a two-column Actions list if it bites.

Branch task/416-capped-recovery, committed locally and not pushed
pushing needs your go-ahead.

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.
@MJohnson459
MJohnson459 merged commit 88d6e30 into main Aug 14, 2026
7 checks passed
@MJohnson459
MJohnson459 deleted the task/416-capped-recovery branch August 14, 2026 13:30
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant