Fix #1645: stop the overview cache from burning the GitHub GraphQL quota - #1652
Conversation
…scope Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JccuXX8YQhT57jC69X82ax
Tower's OverviewCache re-spawned every forge command on every 2.5 s poll once gh started failing (failures were never cached), and even healthy it cost 4 GraphQL calls × N workspaces every 30 s. - Cache failures with a per-workspace, per-budget window: 60 s, doubling per elapsed window (not per failed command) to 15 min. - Detect the rate-limit error and suspend the resolved backend (gh, keyed by executable basename, not provider) until the reset instant from one advisory `gh api rate_limit` probe, or 15 min. Only a success dispatched after the suspension began clears it; gh REST concepts (user-identity) are on their own budget in every direction. - Single-flight per <workspace>:<concept> with an identity check on cleanup. - TTLs 180 s (lists) / 600 s (24 h searches) / 3600 s (identity). invalidate() marks only the open lists stale, debounced 60 s per workspace, and never touches a suspension, a cached failure, or the search/identity caches. - Overview payload carries forgeStatus / forgeResetAt; the dashboard shows it. - executeForgeCommandDetailed surfaces stderr and exit code (and never rejects on a malformed config); github/pr-list no longer hides gh's exit status behind a jq pipe; `# forge-executable:` on the 8 scripts whose first line is a shell builtin. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JccuXX8YQhT57jC69X82ax
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JccuXX8YQhT57jC69X82ax
… status Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JccuXX8YQhT57jC69X82ax
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JccuXX8YQhT57jC69X82ax
…MAP verdicts Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JccuXX8YQhT57jC69X82ax
Architect integration reviewMerging on the human's word. Verified against the branch: the frozen scope from the #1645 prescription and nothing else (448+/125− production lines, 17 files); all eight prescribed pitfall tests present by name plus eight more; the guard-removal table in the body matches the test files; the dashboard banner rendered and read in the Playwright capture; CI 7/7 green; CMAP unanimous in one round. This supersedes #1646 (archived at Field verification next: |
Fixes #1645
Rebuild lane per the architect's prescription on the issue (supersedes #1646). Scope is the six frozen items; the doctor budget check, the single-query collapse (#1647), manual escape (#1648) and workspace-scoped invalidation (#1650) are out.
Problem
Tower's
OverviewCachenever cached a failed forge fetch, so onceghstarted failing every 2.5 s dashboard poll re-spawned all four list commands. Healthy, it still cost 4 GraphQL calls × N workspaces every 30 s. Measured on the production Tower during investigation: ~2ghspawns/s, and the real GraphQL bucket (fromgh api graphql -iheaders) at 1,420 points used five minutes into the window whilegh api rate_limitreportedused: 0.Root cause
overview.ts:if (data !== null) cache.set(...)— failures uncached.forge.ts:executeForgeCommandcollapsed every failure tonull; nothing could tell "rate limited" from "gh missing".invalidate()(porch after every mutating command, VS Code, cleanup) cleared every cache for every workspace.Fix
<workspace>:<budget>window, 60 s doubling per elapsed window to 15 min. Four parallel failures share one window.resolveForgeBackendkeys by the executable's lowercased basename (ghfor/usr/local/bin/gh; a Linear workspace'spr-listkeysgh), provider for generic transports (curl) and custom script paths.ForgeRateLimitersuspends the budget until the reset from one advisorygh api rate_limitprobe (trusted only when it reportsremaining === 0, because it misreports a healthy bucket) or 15 min.user-identity/auth-statusonghare their own budget (gh:rest) in every direction. Only a success dispatched strictly after the suspension began clears it.<workspace>:<concept>, identity-checked cleanup.invalidate()stamps an epoch honoured only by positive open-list entries, debounced 60 s per entry (so per workspace); never touches a suspension, a cached failure, or the search/identity caches.forgeStatus: 'ok' | 'rate-limited' | 'unavailable'+forgeResetAt; error text names the real backend. Dashboard shows a banner (rendered in Chromium via Playwright against an intercepted/api/overview).# forge-executable:on the 8 scripts whose first line is a builtin (gitlab/issue-search, 7 linear);github/pr-listno longer pipes gh into jq (POSIX sh has no pipefail, so a rate-limited gh looked like a successful empty list).Also:
executeForgeCommandDetailedresolves config inside its try, so a malformed.codev/config.jsonno longer reaches tower-server'sunhandledRejection→process.exit(1).Tests
New:
overview-rate-limit.test.ts(10),bugfix-1645-gh-quota.test.ts(realOverviewCache+ real forge scripts + fake permanently-rate-limitedghon PATH: 40 polls → 5 spawns, then 4 more only after the suspension lifts),forge-rate-limit.test.ts(6), 5 additions toforge.test.ts. Seven existing overview tests re-pinned to the new semantics with an injected clock.Pitfall tests verified failing with their guard removed
Process: HEAD committed and pushed; each guard patched in place, the named test run, the file restored from HEAD, tree confirmed clean.
user-identity) does not clear a GraphQL suspensionbudgetKeyForREST splitbudgetKeyForREST splitinvalidate()clears neither a suspension nor the search/identity cachesghharnesspr-listsuspendsgh, notlinearremaining === 0credibility>)# forge-executable:declarationgithub/pr-listpropagates gh's exit statuscache.setNot red-able: the identity check on single-flight cleanup. In this design
invalidate()never touches the in-flight map, so no path can register a newer flight while an older one is still registered; the check is two lines of defence, stated here rather than covered by a test that would have to introduce the race.Checks
pnpm --filter @cluesmith/codev build, then full suite: 288 files, 5,767 passed, 48 skipped, 0 failed.tsc --noEmitclean for packages/codev, packages/types, apps/web.Acceptance (architect's amended criteria)
ghspawns while rate-limited: harness test above.🤖 Generated with Claude Code
https://claude.ai/code/session_01JccuXX8YQhT57jC69X82ax