From 6d8ca90371e16627752f796803b4ecb3bfa14863 Mon Sep 17 00:00:00 2001 From: Aman Jaiman Date: Sat, 11 Jul 2026 10:52:54 -0400 Subject: [PATCH] Answer the rate-limit menu while WAITING; ignore rolled-forward stale banners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs from the daybreak-1 run, where the watchdog slept through Claude's "What do you want to do?" rate-limit menu and then detached at reset time: - Only RUNNING and LIMITED scanned for auto-responses, so a menu that drew a tick after the limit banner sat unanswered for the entire wait. onWaiting now runs the same scanAutoResponses/scanAutoAnswerPrompt pass. - onResuming counted any new-looking limit line as a re-hit. A bare-clock banner from the event just waited out, worded differently from the line that latched, re-parsed after the reset as tomorrow's time — a 24h wait that exceeded max_wait and detached. The re-hit check now applies the same IsNextDayRollForward guard onRunning already used. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 10 +++ internal/supervisor/supervisor.go | 42 ++++++++---- internal/supervisor/supervisor_test.go | 94 ++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c4039c..85784c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed +- The rate-limit menu is now auto-answered even when it draws after the + supervisor has already moved to WAITING. Previously only RUNNING and LIMITED + scanned for auto-responses, so a menu that appeared a tick after the limit + banner sat unanswered for the whole wait and swallowed the resume prompt. +- After a reset, a lingering bare-clock limit banner whose wording differs from + the line that latched (so it dodged the same-match dedupe) no longer counts + as a fresh re-hit. It used to re-parse as tomorrow's time, exceed `max_wait`, + and detach the watchdog right at the moment it should have resumed. + ## [0.5.0] - 2026-07-10 ### Added diff --git a/internal/supervisor/supervisor.go b/internal/supervisor/supervisor.go index 6ec87a4..b523cc7 100644 --- a/internal/supervisor/supervisor.go +++ b/internal/supervisor/supervisor.go @@ -291,7 +291,7 @@ func (s *Supervisor) tick() error { case state.Limited: s.onLimited(capture, now) case state.Waiting: - s.onWaiting(now) + s.onWaiting(capture, now) case state.Resuming: if err := s.onResuming(capture, now); err != nil { return err @@ -515,7 +515,13 @@ func firstPromptKeys(promptText string) string { return "\r" } -func (s *Supervisor) onWaiting(now time.Time) { +func (s *Supervisor) onWaiting(capture string, now time.Time) { + // The agent's rate-limit menu can draw a tick or more after the banner that + // moved us to WAITING. Keep answering safe prompts here, or the menu sits + // unhandled for the whole wait and swallows the resume prompt at reset. + if !s.scanAutoResponses(capture) { + s.scanAutoAnswerPrompt(capture, now) + } if !now.Before(s.waitUntil) { s.opt.Logf("reset reached; resuming") s.injected = false @@ -564,20 +570,28 @@ func (s *Supervisor) onResuming(capture string, now time.Time) error { // limit line counts — the line that triggered this event still lingers in // scrollback and must not be mistaken for a fresh re-hit. if match, groups, limited := parser.Detect(s.opt.Adapter.LimitPatterns, capture); limited && match != s.currentMatch { - s.limitCycles++ - if s.limitCycles >= maxLimitCycles { - s.opt.Logf("limit re-triggered %d times after resume; detaching. Take over with: %s", - s.limitCycles, s.opt.Tmux.AttachHint()) - s.st = state.Detached + // A bare-clock banner from the event we just waited out re-parses after + // the reset as tomorrow's time; that stale roll-forward is not a fresh + // re-hit and must not restart a (day-long) wait. + reset := parser.Resolve(groups, now, fallbackWindow) + if reset.Source == parser.SourceClock && parser.IsNextDayRollForward(reset.Time, s.reset.Time, resetRollForwardTolerance) { + s.opt.Logf("ignoring stale limit banner that rolled forward to the next day") + } else { + s.limitCycles++ + if s.limitCycles >= maxLimitCycles { + s.opt.Logf("limit re-triggered %d times after resume; detaching. Take over with: %s", + s.limitCycles, s.opt.Tmux.AttachHint()) + s.st = state.Detached + return nil + } + s.opt.Logf("resume re-hit the limit (cycle %d); re-waiting", s.limitCycles) + s.currentMatch = match + s.groups = groups + s.injected = false + s.injectAttempts = 0 + s.st = state.Limited return nil } - s.opt.Logf("resume re-hit the limit (cycle %d); re-waiting", s.limitCycles) - s.currentMatch = match - s.groups = groups - s.injected = false - s.injectAttempts = 0 - s.st = state.Limited - return nil } // If the prompt is still sitting in the input box, it was typed but not diff --git a/internal/supervisor/supervisor_test.go b/internal/supervisor/supervisor_test.go index fff4415..0e0c9c9 100644 --- a/internal/supervisor/supervisor_test.go +++ b/internal/supervisor/supervisor_test.go @@ -454,6 +454,100 @@ func TestAutoResponseInjectsSafeStopAndWaitOnce(t *testing.T) { } } +// TestAutoResponseAnswersMenuThatAppearsWhileWaiting reproduces the daybreak-1 +// failure: the limit banner is detected and the supervisor moves to WAITING +// before the rate-limit menu finishes drawing. The menu must still be +// auto-answered mid-wait, not sit unhandled until the reset. +func TestAutoResponseAnswersMenuThatAppearsWhileWaiting(t *testing.T) { + menu := readParserTestdata(t, "claude", "rate-limit-menu.txt") + clk := &driveClock{t: time.Date(2026, 6, 26, 10, 0, 0, 0, time.Local)} + banner := "Claude usage limit reached. Your limit will reset at 11am.\n" + pane := &fakePane{screen: banner} + ad, err := adapter.Compile("claude", adapter.Spec{ + LimitPatterns: []string{`(?i)usage limit reached.*reset at (?P