fix(dashboard): stop the PR panel re-reading unchanged pull requests - #8356
Conversation
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of Reading complete — I've reviewed the full patch (backend TTL/revalidation layer, frontend scoped invalidation and stale-while-revalidate, tests, docs) against the base module. Design-Verdict: PASS Root-cause fix on both sides of the wire, with fail-safe degradation (a failed probe just runs the old fanout) and every freshness assumption bounded and tested. Suggestions
[DESIGN-REVIEWED] 7a3deae |
UX Review (Fable 5, fork) — ✅ PASSUX-level review of UX-Verdict: PASS A failed refresh now degrades to a compact, actionable notice over retained content instead of replacing a visible PR with an error card — the copy asserts, explains, and offers Retry, in all 12 locales. Suggestions
[UX-REVIEWED] 7a3deae |
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of I've now read the full patch, the intent file, and the relevant base files. My verification: the base has exactly 2 unscoped First-Principles-Verdict: CONCERNS Every item is declared and cause-level; the one soft spot is a second spelling of the gateway's 30s window that Sage already encodes on the same query key. What this change shipsIntent: stop the PR panel and chip loop spending provider subprocesses and rate limit on pull requests that have not changed — a FIX.
WatchThe "gateway cache window ≈ 30s" fact now has two client spellings on one query key (counted: Subtractions
[FIRST-PRINCIPLES-REVIEWED] 7a3deae |
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsFINDING -- website/src/components/PullRequestPanel.tsx:888 -- FINDING -- src/kiro_crew/dashboard/handlers/source_providers.py:1795 -- FINDING -- src/kiro_crew/dashboard/handlers/source_providers.py:4688 -- [GPT-REVIEWED] 7a3deae |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsBased on my analysis, I need to verify the single candidate against the actual The candidate claims Falsifying:
The one grounded effect is a robustness nicety whose stated failure mode does not occur, and the candidate self-rated it low. It does not clear 80+, and it is not a BLOCKING class. Dropped. Step 2: while tracing the revalidation path ( No findings. [OPUS-REVIEWED] 7a3deae |
eaeb9e9 to
7c38ffd
Compare
7c38ffd to
b805d62
Compare
b805d62 to
044194e
Compare
044194e to
b459d4c
Compare
|
Addressed in b459d4c (rebased on main, one commit):
Kept as is, deliberately:
|
Every open of the Changes panel re-ran the full provider fanout (the
core `gh pr view`, the files, review-comment and rollup reads, and the
merge-state re-reads) although nothing about the pull request had
moved. Two independent causes, one on each side:
* The client invalidated the WHOLE `['pull-request-source']` query
family — no URL key — on every turn boundary of ANY session (active
slot: refetch now; background slot: mark stale). While any chat was
running, every session's detail payload was therefore stale on open.
The invalidation is now scoped to the finished slot: the active slot
refetches the MOUNTED detail query with `refetchQueries(type:
'active')` (the PR on screen; unlike `invalidateQueries` it marks
nothing else stale) and marks its own serialized chip URLs stale for
their next mount; a background slot only marks its own URLs, and the
panel's mutation handlers invalidate only the PR they changed. The
detail queries also retain an unmounted payload for one hour instead
of React Query's five-minute default and revalidate on mount once the
retained data is older than the gateway's cache window, so a reopened
panel renders at once and refreshes in the background
(stale-while-revalidate) instead of showing a spinner or presenting an
hour-old discussion as current.
* The gateway aged every full payload by one 30s TTL and re-read every
chip every 60s regardless of lifecycle, so a merged or closed pull
request cost one `gh` subprocess per minute for as long as its chip
stayed in a sidebar. The chip cache -- and the full payloads with no
cheaper read (GitLab, plugins) -- now age a merged entry by six hours
and a closed one by one hour (it can be reopened), decided from the
payload itself through the same `_project_state` the chip projection
uses so the caches agree; the explicit refresh and mutation
invalidation still bypass it, and the turn-boundary force still
re-reads a closed chip but never a merged one.
An expired github.com payload is no longer re-read in full straight
away either, whatever its lifecycle. It is first revalidated with small
conditional REST GETs (`gh api -i -H If-None-Match`): `issues/{n}`,
whose ETag follows the pull request's `updated_at` (title, body,
labels, lifecycle including a reopen, pushes, reviews, comments), and
for an open pull request also `commits/{head_sha}/check-runs` and
`commits/{head_sha}/status`, because CI hangs off the commit and never
moves `updated_at` (check runs and legacy statuses are separate
resources and the rollup renders both). All-304 re-stamps the cached
entry; anything else runs the fanout -- so post-merge comments reach the
panel within one TTL for one rate-limit-free request. It is strictly 304-only — the first probe of a URL
has no validator, answers 200 and only learns the ETags; a failed probe
is unknown; bodies are never compared. `pulls/{n}` is deliberately not
the probe (its ETag churns on the embedded repository counters), GraphQL
— what `gh pr view` speaks — has no conditional requests, an
authenticated 304 is free on the primary rate limit, and `gh` exits 1 on
a 304, so the parser reads the status line rather than the exit code.
Refresh, mutation invalidation, terminal payloads, GitLab and registered
plugins never probe. `_run_json` is now a thin wrapper over
`_run_provider(parse=...)` so the conditional reader shares the same
isolation, bounds and SEL audit.
b459d4c to
7a3deae
Compare
|
7a3deae: took the Design suggestion — re-stamping now has an absolute ceiling. Each validator set carries |
chenmingwei23
left a comment
There was a problem hiding this comment.
Approving: PR Readiness green (the repo's only required check), no failing lanes, MERGEABLE.
Problem / Motivation
Opening the Changes side panel (GitHub PR sidebar) re-ran the full provider fanout every time — the core
gh pr view, the files, review-comment and check-rollup reads, and the merge-state re-reads (~5ghsubprocesses) — even when nothing about the pull request had changed. Merged and closed pull requests kept being re-read on the open-PR cadence for as long as their chip stayed in a sidebar.Why it matters
Every panel open shows a reload and spends five subprocesses plus five GitHub API calls against the user's 5,000/h primary rate limit; with a dozen finished PRs across the session list the chip loop alone was one
ghsubprocess per finished PR per minute, forever. The same shape (a panel poll loop that never slows on finished items) exhausted a provider-side throttle for every user of our internal edition, so this closes the class on the public side too.What changed (motivation → approach → change)
Two independent causes, one on each side of the wire, plus a conditional-request layer so an expired open PR is revalidated instead of re-read.
Client (root cause 1).
useWebSocket.tsinvalidated the WHOLE['pull-request-source']query family — no URL key — on every turn boundary of ANY session (active slot: refetch now; background: mark stale). While any chat was running, every session's detail payload was stale on open. Now the invalidation is scoped to the finished slot: the active slot refetches the MOUNTED detail query withrefetchQueries({queryKey, type: 'active'})(the PR on screen — the slots payload names only the first few chips, so the PR being viewed can lie outside that set;refetchQueriesmarks nothing else stale, whereasinvalidateQuerieswithrefetchType: 'active'still marks every cached PR stale) and marks its ownsource_linkschange URLs stale for their next mount; a background slot only marks its own URLs; the panel's mutation success handlers invalidate only the PR they changed.slotChangeUrls(utils/pullRequestLinks.ts) is the pure helper. The PR and issue detail queries also retain an unmounted payload for one hour (SOURCE_DETAIL_GC_MS) instead of React Query's five-minute default, and the PR detail query revalidates on mount whenever the retained data is older than the gateway's cache window (SOURCE_REMOUNT_REVALIDATE_MS, 30s), so a reopened panel renders the retained payload at once and refetches in the background — stale-while-revalidate — rather than presenting an hour-old discussion as current (events from this gateway cannot see a teammate's comments). Younger data is not refetched: the gateway would return the same bytes, and Code Review Sage mounts its pane and this panel on one key, which stays one provider read per open. Past the window the refetch is cheap because the gateway revalidates with conditional GETs. The Issues panel gets the same predicate (it has no turn-boundary or status-delta invalidation at all). A background revalidation that fails over a loaded payload renders a compact one-line notice — "Couldn't refresh — showing the last loaded version", with the login command and a retry — instead of stacking the full-height "could not load" card over content that is visibly on screen; the string is added to all twelve catalogs and the pseudolocale regenerated.Gateway (root cause 2).
source_providers.pyaged every full payload by one 30s TTL and re-read every chip every 60s regardless of lifecycle. The chip cache — and the full payloads with no cheaper read (GitLab, registered plugins) — now age a merged entry by_TERMINAL_TTL_SECS(six hours) and a closed one by_CLOSED_TTL_SECS(one hour: it can be reopened and keeps accruing discussion). The payload TTL is decided from the payload itself through the same_project_statethe chip projection uses (_full_payload_ttl), so the two caches cannot disagree about whether a URL is finished. The explicit refresh button and mutation invalidation still bypass it, and the turn-boundary force (request_check_refresh_now) still re-reads a closed chip (an agent can reopen one) but never a merged one (_chip_refresh_due).Conditional revalidation. An expired github.com payload — whatever its lifecycle — is first revalidated with small conditional REST GETs (
gh api -i -H If-None-Match) before the fanout:issues/{n}— its ETag follows the pull request'supdated_at(title/body/labels/lifecycle including a reopen, a push, reviews, comments), so post-merge comments and a reopen reach the panel within one TTL for one rate-limit-free request.pulls/{n}is deliberately NOT the probe: it embeds the base/head repository objects whose live counters (open issues, stars,pushed_at) change its ETag on a busy repo without the PR changing.commits/{head_sha}/check-runsandcommits/{head_sha}/status— CI hangs off the commit and never movesupdated_at; check runs and legacy commit statuses are separate resources and the rollup renders both. Skipped for merged/closed, whose CI the panel and chip no longer track.All-304 re-stamps the cached entry; anything else runs the fanout. Strictly 304-only: the first probe of a URL has no validator, answers 200 and only LEARNS the ETags (
_REVALIDATORS, bounded, the two commit-level validators scoped to the head sha so a push cannot reuse the old commit's); a failed probe is "unknown"; bodies are never compared. Validators from an all-304 are committed at once; validators from a 200 are committed only after the full read that follows has succeeded, so a fanout that fails (rate limit, 503) cannot pair the old payload with new ETags and have every later probe re-stamp it as current._ConditionalReadcarries status and ETag only — bodies are never decoded, because nothing is ever judged by comparing them. Re-stamping is capped: a validator set remembers when its payload was last read in full, and past 6 h (_REVALIDATED_MAX_AGE_SECS) one full read runs without probing and the set is dropped, so an ETag-coverage gap on GitHub's side degrades to bounded staleness rather than unbounded. GitHub's GraphQL API — whatgh pr viewspeaks — has no conditional requests, an authenticated 304 costs nothing on the primary rate limit, andghexits 1 on a 304 (gh: HTTP 304), so_parse_conditional_getreads the status line rather than the exit code. The merge pair moves neither validator and stays with the existing chip↔full protocol. Refresh, mutation invalidation, GitLab and registered plugins never probe._run_jsonis now a thin wrapper over_run_provider(parse=...)so the conditional reader shares the same isolation, bounds and SEL audit (every probe is an auditedghinvocation).Design references (how mature tools do it): GitLens and GitHub Desktop serve the cache on panel open and revalidate in the background with 30-min list TTLs and a 60s checks floor; the VS Code GitHub PR extension backs off 5→30 min on an unchanged open PR; GitHub Desktop and the JetBrains GitLab plugin send real
If-None-Match; GitHub's REST guidance: "a 304 does not count against your primary rate limit".Tests
Backend (
test/test_source_providers.py, +34):_full_payload_ttlby projected lifecycle (open/draft/opened → short; merged → six hours; closed incl. closed-while-draft → one hour; locked/unknown → short) and the ordering property the retention tests rest on.fetch_pull_request(GitLab, no conditional read): a merged payload past the open TTL is a hit with no provider read andrefresh=Truestill bypasses; closed ages on the one-hour clock; a terminal payload past its own TTL re-reads; the on-write sweep ages each entry by its own TTL. GitHub: an expired merged payload is revalidated with the issue probe alone (304 → served and re-stamped; 200 → full read)._chip_refresh_dueparametrized over open/draft/closed/merged/unknown × age (open / closed / merged clocks) × force (merged not force-read; closed force-read);schedule_check_refresh+request_check_refresh_nowskip finished PRs._parse_conditional_get: 200 with CRLF headers, 304 arriving as exit 1, LF-only headers, missing ETag, rejection of 404/401/no status line/bad JSON, login hint on auth failure._gh_conditional_getargv (header only when a validator is known);_github_payload_unchanged: first probe learns and is unknown, all-304 required (issue + check-runs + commit status for open; issue only for merged/closed), CI-moved, status-moved and review-landed cases, commit-level validators not reused across a push, probe failure/missing head are unknown;_REVALIDATORSbounded;_revalidation_applies(github.com only).Frontend:
PullRequestPanel.test.tsx: a retained payload older than the window paints immediately and triggers one background refetch; one inside the window paints without any fetch; a failing background revalidation shows the compactstatusnotice and never thealertcard.useWebSocketSourceStatus.test.ts: active-slot turn callsrefetchQueries(type: 'active')and marks its own URL stale, neverinvalidateQuerieson the bare family; a background slot's turn marks ONLY its own PR (onepull-request-sourcecall, no refetch); a slot with no PR links touches no detail query.pullRequestLinks.test.ts:slotChangeUrlsskips issue links and duplicates, yields nothing for an unknown slot or missing links.Local gates:
flake8,isort,mypy --platform linux,pyteston the touched suites (885 passed; the twotest_provider_executable_*failures reproduce on untouchedorigin/main— host uid ownership),tsc -b,eslint,vitest(163 passed),docs-lint.sh, black/brand/harness/subprocess-encoding/sync-io gates.Manual verification
Parser checked against real
gh(2.x) output onrepos/kirodotdev/KiroCrew/issues/8208:-iprints the status line with a bare\nand headers with\r\n; 200 →W/"…"ETag + JSON body parsed; replaying withIf-None-Match→ exit 1,gh: HTTP 304on stderr,HTTP/2.0 304 Not Modifiedon stdout, strong-form ETag echoed, parsed as_ConditionalRead(304, '"…"', None).Screenshots / video
Why no screenshot: the frontend change alters only WHEN the panel refetches (scoped query invalidation and a longer cache retention); no component renders differently, and the spinner it removes was the default loading state, not a new surface.
Related Issues
N/A — reported internally (same shape as the CRUX panel throttle incident on the internal edition).
Pattern harvest
Rule candidate: review-prompt
Pattern: an event handler that invalidates a whole query-key family (
invalidateQueries({queryKey: [family]})) instead of the entity the event names, and a cache whose TTL ignores that the entity has reached a terminal state.Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)