Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 28 additions & 14 deletions internal/supervisor/supervisor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
94 changes: 94 additions & 0 deletions internal/supervisor/supervisor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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<time>[^\r\n.]+)`},
AutoResponses: []adapter.AutoResponseSpec{{
Pattern: `(?i)rate.?limit.?options|stop and wait for (?:(?:the|your) )?limit to reset`,
Keys: "1\r",
Once: true,
}},
})
if err != nil {
t.Fatal(err)
}
sup := New(Options{
Adapter: ad, Tmux: pane, Prompt: prompt.NewStatic("continue"),
PollInterval: time.Second, ResetBuffer: time.Second, MaxWait: 24 * time.Hour,
Now: clk.now,
})

must(t, sup.tick()) // RUNNING -> LIMITED (no menu drawn yet)
must(t, sup.tick()) // LIMITED -> WAITING
if sup.State() != state.Waiting {
t.Fatalf("state = %s, want WAITING", sup.State())
}
if len(pane.injected) != 0 {
t.Fatalf("nothing should be injected before the menu appears, got %q", pane.injected)
}

// The menu draws while the supervisor is already waiting.
pane.screen = banner + menu
must(t, sup.tick())
if len(pane.injected) != 1 || pane.injected[0] != "1\r" {
t.Fatalf("auto-response injected = %q, want one %q", pane.injected, "1\\r")
}
if sup.State() != state.Waiting {
t.Fatalf("state = %s, want WAITING (still)", sup.State())
}
}

// TestResumingIgnoresRolledForwardStaleBanner reproduces the second half of the
// daybreak-1 failure: right after the reset, a lingering bare-clock banner that
// renders differently from the triggering line re-parses as tomorrow's time. It
// must not count as a re-hit (which pushed the wait past max_wait and detached).
func TestResumingIgnoresRolledForwardStaleBanner(t *testing.T) {
clk := &driveClock{t: time.Date(2026, 6, 26, 10, 0, 0, 0, time.Local)}
pane := &fakePane{screen: "5-hour limit reached ∙ resets 11am\n"}
// After the resume prompt is injected, the pane still shows a limit line with
// the same clock time, but worded differently from the line that latched.
pane.onInject = func(string) string {
return "weekly limit reached ∙ resets 11am\nresumed, working again\n"
}

sup := New(Options{
Adapter: testAdapter(t),
Tmux: pane,
Prompt: prompt.NewStatic("continue"),
PollInterval: time.Second,
ResetBuffer: 60 * time.Second,
MaxWait: 24 * time.Hour,
Now: clk.now,
})

must(t, sup.tick()) // RUNNING -> LIMITED
must(t, sup.tick()) // LIMITED -> WAITING (until 11:01)
if sup.State() != state.Waiting {
t.Fatalf("state = %s, want WAITING", sup.State())
}

clk.add(62 * time.Minute) // past 11:01
must(t, sup.tick()) // WAITING -> RESUMING
clk.add(3 * time.Second) // let the idle heuristic pass
must(t, sup.tick()) // inject resume prompt; stale banner renders
if len(pane.injected) != 1 || pane.injected[0] != "continue" {
t.Fatalf("injected = %v, want [continue]", pane.injected)
}

// The differently-worded banner resolves to 11am tomorrow — a pure 24h
// roll-forward of the reset we just waited out. Resume must confirm.
must(t, sup.tick())
if sup.State() != state.Running {
t.Fatalf("state = %s, want RUNNING (stale rolled-forward banner treated as re-hit?)", sup.State())
}
if sup.limitCycles != 0 {
t.Fatalf("limitCycles = %d, want 0", sup.limitCycles)
}
}

func TestSafeStopAndWaitGateMatchesCapturedMenu(t *testing.T) {
menu := readParserTestdata(t, "claude", "rate-limit-menu.txt")
if !safeStopAndWait.MatchString(menu) {
Expand Down
Loading