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 @@ -48,6 +48,16 @@ All notable changes to this project are documented here. The format follows
`/help` stays visible. One more row, at 80x25, shows them all.
- `packaging` is a declared dependency. It was already installed through other
packages; `update` now imports it directly.
- **The judge goes back to Groq once its minute limit resets.** A 429 whose
`Retry-After` is at most 120 seconds (60 when it names none) now switches to the
local model only until that wait has passed: `Groq limit reached — judging with
local ollama qwen3.5:9b until it resets (~30s)`. The first request after it asks
Groq again, from one thread only; if Groq answers, the judge stays on it and says
so once (`Groq answering again — local ollama qwen3.5:9b stood in for 4 requests`),
on stderr, in the TUI and in the report's footer. Another short 429 starts a new
wait, quietly. Nobody waits: the local model answers while Groq cools down. A daily
limit (a longer `Retry-After`), a 413, a rejected key, a 5xx or no answer still
switches for the rest of the run, with the same notice as before.

## [0.4.10] - 2026-10-01

Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,10 @@ in the environment or a `.env` file, never from config, and is never printed.
**When the judge stops answering.** If the configured judge hits its rate limit or
otherwise stops answering, the run switches at once to `qwen3.5:9b` in your local
Ollama, if it is installed (`ollama pull qwen3.5:9b`; proofpath never pulls it). It says so on stderr, in the TUI
above the prompt, and in the report. The next run tries the configured API again first.
above the prompt, and in the report. A per-minute limit (a 429 that asks for a wait of
two minutes or less) only switches until it resets: the run then tries the configured
API again, goes back to it if it answers, and says that too. The next run tries the
configured API again first.
`proofpath config set judge.fallback off` turns this off. Without a local model to
switch to, the run says so and tells you how to add one, on the same channels.

Expand Down
18 changes: 18 additions & 0 deletions docs/superpowers/OPEN-ITEMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -626,3 +626,21 @@ over "uv tool and pipx only". Design: `docs/superpowers/specs/2026-10-05-update-
to the tool environment with pip, and the browser extra is installed that way on
consent.
- **Behaviour:** running the command updates straight away; `--check` only reports.

## 22. Judge: back to the provider after a minute limit — 2026-10-05

The user asked whether the judge could return to Groq once its per-minute limit resets
instead of staying on the local `qwen3.5:9b` for the rest of the run. They chose
**option A, a cooldown with return**, over waiting on the provider (B) and over A plus
proactive pacing from the `x-ratelimit-*` headers (C). A 429 whose `Retry-After` is at
most 120 s switches only until that time has passed. A daily limit, a 413, a key error
and a server error keep today's sticky switch. Nobody waits, and the provider is asked
again only once the time it named has passed. Design:
`docs/superpowers/specs/2026-10-05-judge-cooldown-design.md`. It amends evidence-search
spec §11.

Follow-up (review, 2026-10-05, left open on purpose): `_come_back` announces the back
notice outside the lock. If a concurrent re-switch announces its own notice inside that
microsecond window, the TUI line can show the back notice while `switched` (the
footer) holds the cooling one. Fix: announce under a separate `_announce_lock`, and only
when `switched` still equals the notice.
5 changes: 4 additions & 1 deletion docs/superpowers/specs/2026-09-28-evidence-search-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,10 @@ judge-written queries and translations).

- **Trigger.** Any `JudgeUnavailable` from the primary provider: a 429, a quota
error, or the provider not answering. The switch lasts for the rest of the run, so
a rate-limited Groq is not hit again on every batch.
a rate-limited Groq is not hit again on every batch. A 429 with a `Retry-After` of
at most 120 s (or none) is the exception: it switches only until that wait has
passed, then the provider is asked again (amended by
`2026-10-05-judge-cooldown-design.md`).
The switch is immediate: with a fallback available, the primary gets no retry and
no `Retry-After` wait. The failed request is sent again to the local model, so no
batch is lost, and the notice appears before the model starts loading.
Expand Down
81 changes: 81 additions & 0 deletions docs/superpowers/specs/2026-10-05-judge-cooldown-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Judge: back to the provider once its minute limit resets — design (2026-10-05)

This amends evidence-search spec §11 ("Local fallback judge"), which made the switch
to the local model last for the rest of the run. OPEN-ITEMS §22 points here.

## 1. Decision (user, 2026-10-05)

The user asked whether the judge could go back to Groq once its per-minute limit
resets, instead of staying on `qwen3.5:9b` for the rest of the run. They chose option
A, **a cooldown with return**, over two others: waiting on the provider (B), and A plus
proactive pacing from the `x-ratelimit-*` headers (C).

§11 had two goals, and both are kept:

- **Nobody waits.** With a fallback behind it, the primary still gets no retry and no
`Retry-After` sleep. While the provider cools down, the local model answers.
- **The provider is not hammered.** It is asked again only once the time it named
itself has passed, and never on every batch.

## 2. Behaviour

1. **Which failures cool down.** Only a **429** whose `Retry-After` is at most
`COOLDOWN_MAX_S = 120` seconds. Groq's per-minute token and request limits fit this.
A 429 without the header counts as `COOLDOWN_DEFAULT_S = 60`. The switch stays
**sticky** for the rest of the run, exactly as before, for:
- a 429 with a longer `Retry-After` (a daily limit: the RPD or TPD tier);
- **413** (the request alone breaks the TPM, so asking again cannot help);
- 401 and 403 (the key);
- 5xx, a timeout, no answer, or an empty answer.
2. **Carrying the wait.** `JudgeUnavailable` gains `retry_after: float | None`.
`JudgeClient` fills it from the same `_retry_after_seconds` parse it already uses,
including when it is `fail_fast`. Never a body, a URL or a key.
3. **Routing.** `FallbackClient` keeps `_cooldown_until`, a clock reading or `None`.
- On a cooling failure it switches as today and sets
`_cooldown_until = now + retry_after`. That covers the notice, the warm-up and the
local answer for the failed request.
- While `now < _cooldown_until`, requests go to the local model.
- The first request at or after `_cooldown_until` goes to the primary again.
- If it answers, routing returns to the primary. A **back notice** is announced
once: `Groq answering again — local ollama <model> stood in for N requests`.
- If it fails again with a cooling 429, a new cooldown starts from the new
`Retry-After`, and that request goes to the local model. No second switch
notice, and no second warm-up.
- If it fails in a sticky way, the switch becomes sticky from then on, as today.
- The clock is injected (`clock: Callable[[], float] = time.monotonic`) so tests
need no sleep. The existing lock guards every read and write of
`_cooldown_until`. Only one thread probes the primary when the cooldown ends;
the others keep using the local model until the probe has answered.
4. **Notices.**
- The switch notice for a cooling 429 says the switch is temporary:
`Groq limit reached — judging with local ollama <model> until it resets (~Ns)`.
- `switched` keeps the latest notice: the switch, or the back notice once the
provider answers again. The report footer therefore tells the truth at the end
of the run.
- Every opinion's `model` field still names the model that gave it, and the
report's cost line still splits `api_calls` from `local_calls`.
- A sticky switch keeps today's wording, unchanged.
5. **`reset()`** (a new run) clears `_cooldown_until`, as it already clears the rest.
6. **Local failures.** These are unchanged (`_dead`, `JUDGE_FALLBACK_FAILED`). If the
local model is dead while the provider cools down, the request fails as today. The
next request after the cooldown still probes the provider.

## 3. Tests (no network, no sleep)

Use `respx`, or the existing fake clients, plus a fake clock. They cover:

- a short 429 switches and then comes back after `Retry-After`, with one switch
notice and one back notice;
- requests inside the cooldown go to the local model and send nothing to the primary;
- a second short 429 at the probe restarts the cooldown with no new notice;
- a long 429, a 413, a 401 and a 5xx each stay sticky with today's notice;
- a 429 without `Retry-After` uses 60 s;
- `reset()` clears the cooldown;
- with concurrent requests at the end of the cooldown, only one probes;
- `JudgeUnavailable.retry_after` is filled on the `fail_fast` path.

## 4. Out of scope

- Proactive pacing from `x-ratelimit-remaining-tokens` (option C).
- Waiting on the provider (option B).
- Any change to the batch caps (`TOKEN_CAP`, `_REVIEW_TOKENS`).
5 changes: 3 additions & 2 deletions src/proofpath/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -577,8 +577,9 @@ def on_event(event: events.Event) -> None:
if isinstance(event, events.StageEnd):
ui.stage_row(human, event.name, event.by, event.summary, event.elapsed)
elif isinstance(event, events.Note) and event.notice:
# The judge switched to its local fallback: said at once, on stderr, and
# never dropped, not even under ``-q`` (spec section 11).
# The judge switched to its local fallback, or back to its provider after
# a cooldown: said at once, on stderr, and never dropped, not even under
# ``-q`` (spec section 11, judge-cooldown spec).
ui.kv(out, "judge", event.text, err=True)
elif isinstance(event, events.Note):
ui.note(human, event.text)
Expand Down
Loading
Loading