What changed, and why
froot is live now, so this batch is four correctness fixes — each one a place where the running loop was quietly doing the wrong thing — bundled because they share a theme and a discipline: every fix lands with a test that fails on the old code.
The four, each its own section below:
1. Logging that emitted nothing. The worker builds a structured loop_outcome JSON line per loop at INFO, but nothing configured logging — so the root logger's WARNING default dropped every line. kubectl logs and the ClickStack filelog were empty. 2. GitHub rate-limits killed the loop. A 403 was classified as a permanent auth fault and made non-retryable. But GitHub returns 403 for its secondary rate limit too, so a transient throttle permanently killed the call-heaviest path. 3. Pagination silently truncated. A single per_page=100 read of checks and comments stopped at item 100 — a failing 101st check read as green, and a marker past page 1 was missed, so froot posted a duplicate comment. 4. The two determinism tables could drift. froot's blocking CI gate and its advisory reviewer each hand-copy four BANNED_* tables. Nothing pinned them equal — froot could disagree with itself about what "deterministic" means.
A one-line Makefile target rounds it out. Read the four below; each is small, local, and guarded.
Fix 1 — the worker now actually emits its outcome lines
The activities emit one JSON loop_outcome line per closed loop at INFO on the froot.* loggers. That is the cheap, human-readable half of "derive, never store" — the stream the deploy points operators and the ClickStack filelog at. But a logger with no configured handler falls back to the root logger, which defaults to WARNING with no handler. So every INFO line was silently dropped.
configure_logging fixes it: set the root to INFO, attach one stdout handler that emits the record verbatim (the message is already JSON, so it stays machine-parseable), and pin the chatty temporalio SDK at WARNING so the outcome lines are not buried in poll/heartbeat noise.
The new entrypoint config: root to INFO, one verbatim stdout handler, SDK quieted.
src/froot/worker.py · 141 lines
⋯ 41 lines hidden (lines 1–41)
⋯ 75 lines hidden (lines 67–141)
It is idempotent by construction. The handler is added only if no stdout StreamHandler is already attached, so a second call neither duplicates the handler nor re-lowers levels. And main() — the console entrypoint — now calls it before asyncio.run, so the configuration is in place before the first loop closes.
main() wires it in, before the event loop starts.
src/froot/worker.py · 141 lines
⋯ 133 lines hidden (lines 1–133)
The guard: no config → INFO line dropped.
tests/test_worker.py · 85 lines
⋯ 65 lines hidden (lines 1–65)
⋯ 12 lines hidden (lines 74–85)
Fix 2 — GitHub rate limits are retryable, not fatal
The old _raise_for_status had one rule for 401 and 403: permanent auth fault, non_retryable=True. That is right for 401 and for a real permission 403. It is wrong for the other thing GitHub returns 403 for — its secondary rate limit. Marking that non-retryable means a transient throttle permanently kills the activity, on the path that calls GitHub the most (the durable CI poll).
The fix splits the classification. 401 stays a hard fault. A 403 or 429 that carries a rate-limit marker becomes a retryable ApplicationError that honors the server's advised wait, so Temporal backs off instead of giving up. A 403 without those markers is still a real permission fault and stays non-retryable.
The new three-way split: hard 401, retryable rate-limit, hard 403.
src/froot/adapters/github.py · 461 lines
⋯ 184 lines hidden (lines 1–184)
⋯ 263 lines hidden (lines 199–461)
_is_rate_limited is the discriminator: a 429 is always a limit; a 403 is one only if it carries a Retry-After header, an exhausted X-RateLimit-Remaining, or a "rate limit" note in the body (the secondary-limit form often only says so there). _retry_after_seconds then turns the server's hint into a concrete delay — the Retry-After delta-seconds, else the X-RateLimit-Reset epoch minus now — and returns None when there is no usable hint, deferring to Temporal's own backoff.
_is_rate_limited: the markers that tell a throttle from a permission fault.
src/froot/adapters/github.py · 461 lines
⋯ 154 lines hidden (lines 1–154)
⋯ 290 lines hidden (lines 172–461)
Fix 3 — pagination, so the CI oracle and comment upsert see everything
GitHub caps a list page at 100 items. The old code read exactly one page (per_page=100) of check-runs, open PRs, and PR comments. On a busy repo that silently truncates, and here truncation is not cosmetic — it corrupts two decisions froot makes.
The new _iter_pages follows the Link: rel="next" header to read the whole set. The first request carries the caller's filters plus per_page=100; later requests use the absolute Link URL verbatim (it already encodes them), so the local params drop off. The data, not a fixed cap, bounds the loop.
_iter_pages: follow Link rel=next; first page filtered, rest verbatim.
src/froot/adapters/github.py · 461 lines
⋯ 206 lines hidden (lines 1–206)
⋯ 232 lines hidden (lines 230–461)
ci_status now extends its CheckRow list across every check-runs page before mapping. The combined commit status stays a single GET — it is one rolled-up state over all statuses, not a paginated list, so it needs no paging.
ci_status: paginate check-runs; the combined status stays a single read.
src/froot/adapters/github.py · 461 lines
⋯ 395 lines hidden (lines 1–395)
⋯ 37 lines hidden (lines 425–461)
The comment upsert delegates its lookup to _marked_comment_id, which iterates every comment page and stops at the first hit. Finding the marker on any page means the upsert PATCHes that comment; only a genuine absence falls through to a POST.
_marked_comment_id: search all pages, stop at the first hit.
src/froot/adapters/github.py · 461 lines
⋯ 231 lines hidden (lines 1–231)
⋯ 212 lines hidden (lines 250–461)
Fix 4 — the determinism tables can no longer drift apart
froot enforces determinism two ways. scripts/check_determinism.py is a vendored, package-free script that runs as the blocking CI gate. froot.policy.determinism is the brain's advisory reviewer that posts findings on PRs. Each independently defines the same four BANNED_* tables — the symbols that make a workflow non-deterministic — by hand-copy.
If those tables drift, froot's gate and its reviewer disagree about what "deterministic" means. That is the exact class of slow decay froot exists to catch, uncaught in froot itself. The new test pins them equal.
The dogfood guard: all four BANNED_* tables, kernel == brain.
tests/test_kernel_script.py · 227 lines
⋯ 208 lines hidden (lines 1–208)
And the Makefile
One additive target, mirroring the existing start-scan: start-review runs the durable determinism-review loop once per FROOT_REPOS repo. It is added to .PHONY and is a single uv run line — no behavior change to anything else.
start-review: the review loop's one-shot starter, parallel to start-scan.