Skip to content

[Bug]: PR detail/activity is re-fetched for every displayed PR on each focus, visibility change and 5-minute tick (merged PRs included), exhausting the GitHub GraphQL quota #13496

Description

@eowca

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/web pull request views + apps/server PullRequestService / GitHub CLI integration

Steps to reproduce

  1. Use T3 Code with several threads whose PRs appear in the UI (linked PRs, PR tabs or panels opened at some point). Keep a second client connected if possible, e.g. the web UI served over Tailscale.
  2. Leave the app open and switch focus to and from the window every few minutes, as you normally would while working.
  3. Watch the GitHub GraphQL budget: gh api graphql -f query='{rateLimit{remaining resetAt}}'.
  4. Count GitHubCli.execute spans in ~/.t3/userdata/logs/server.trace.ndjson*, grouped by their ws.rpc.* ancestor.

Expected behavior

PR detail and activity reads are cached and shared across clients. A merged or closed PR is not re-fetched on every focus change. Refreshing should back off before the user's GitHub quota runs out, not only after (github requests to github.com are paused until the rate limit resets).

Actual behavior

Each client re-requests pullRequests.detail and pullRequests.activity for every PR it shows. This happens on window focus (after 10 s), on visibilitychange, and every 5 minutes while the user is active (the refresh hook with ya=3e5/1e4 in PanelLayoutControls). Merged PRs are included.

Measured on one machine (trace data plus rateLimit sampling):

  • Bursts of ~120 gh calls every 3–4 minutes. In one 7-minute window, 822 of 881 T3 gh calls came from ws.rpc.pullRequests.detail (264 RPCs) and ws.rpc.pullRequests.activity (192 RPCs). gh pr view --json author,comments,reviews,commits paginates, so this averaged ~3 GraphQL points per call.
  • GraphQL went from 4,974 to 2,206 remaining in 6.5 minutes. The 5,000/h budget ran out three times in one afternoon. Every other consumer combined made 66 GraphQL calls in 40 minutes.
  • Two connected clients (desktop window and a Tailscale-served web tab) each refreshed the same PRs independently.
  • Unlinking 70 merged PRs from active threads did not shrink the bursts. Of the 39 PRs in one burst, 18 were not linked to any thread; several of those match t3.pullRequests.detail:* entries in the client's localStorage.

This is a different path from #3581/#5673, which fixed per-branch gh pr list polling.

Impact

Major degradation or frequent failure

Version or commit

t3code 0.0.42-workspace-guard.1 (desktop AppImage), gh 2.101.0

Environment

Linux 7.0 (Ubuntu), desktop app plus a second web client via tailscale serve

Logs or stack traces

ws.rpc.pullRequests.activity -> PullRequestOperationError: Pull request operation activity failed: github requests to github.com are paused until the rate limit resets.

Workaround in use: a gh wrapper earlier on the server's PATH. For calls whose parent is the T3 server, it caches pr view --json and aliased pullRequest(number:) GraphQL reads for 10 minutes, or 24 hours once a PR is merged or closed. With it, later bursts of ~125 calls cost ~9 points instead of ~350.

Activity

  1. juliusmarminge commented on Sep 24, 2026

    @juliusmarminge
    Member

    Triage

    Verdict: Bug, still present on main (d5d48742c9). Focus, visibility, and the five-minute tick re-read pull request detail from GitHub, and activity reads for every pull request still on screen or viewed in the last five minutes go out on the same refresh signal. Merged and closed pull requests are included. The fifteen-second server cache does not cover that cadence, so two clients each pay. This is not #3581 or #5673.

    What the report gets right

    useLiveRefresh is the hook behind the minified 3e5 / 1e4 constants. The interval is five minutes, the minimum gap is ten seconds, and it stops after six minutes without a click, key, or scroll. It runs on window focus, on visibilitychange, and on that interval while the window is visible (apps/web/src/hooks/useLiveRefresh.ts).

    The open pull request panel calls it and forces pullRequests.detail:

      useLiveRefresh(
        () => {
          detailQuery.refresh();
        },
        { key: `pull-request:${environmentId}:${pullRequestKey}` },
      );

    refresh() ignores the client’s sixty-second stale time. The server then keeps detail and activity for fifteen seconds (DETAIL_CACHE_TTL in PullRequestService.ts). A focus return after that is a cache miss. The pull requests page uses the same hook to re-read the list.

    Activity is gh pr view --json author,comments,reviews,commits (PULL_REQUEST_ACTIVITY_JSON_FIELDS). That is the paginated read in the trace. The focus hook does not call activity itself. The panel refetches activity when that pull request’s updatedAt changes, and both detail and activity atoms subscribe to pullRequests.subscribeRefreshes. A turn checkpoint (CheckpointReactor → refreshAfterTurn), a mutation, or an explicit invalidate bumps the project epoch, drops the cache key, and tells every still-alive detail and activity atom to read again.

    Those atoms stay alive for five minutes after the view unmounts (Atom.setIdleTTL on the query and on the writable wrapper in packages/client-runtime/src/state/pullRequests.ts). Open right-panel tabs subscribe for longer: each pull request tab without a linked snapshot mounts pullRequests.detail for its icon (RightPanelTabs.tsx). Unlinking a thread does not drop those atoms or tabs. t3.pullRequests.detail:* in localStorage is the snapshot written after a successful detail read so the panel can paint before the next RPC. It is not what schedules the fetch. A pull request that was opened, then unlinked, still matches that key and can still be in the burst.

    Merged pull requests are not special on this path. The background link sync already skips a pull request once every linked copy is merged (PullRequestSyncReactor.ts, isDue), and a summary already held as merged is served without another host read. Detail and activity do not do that. Unlinking seventy merged pull requests would not shrink these bursts.

    Two clients of one server do not share the refresh. In-flight identical reads coalesce, and the fifteen-second cache is what the “two people opening the same page” comment is about. Focus timers and five-minute ticks are much further apart than that, so each client’s burst misses and runs gh again.

    The pause error is real. GitHubCli.execute for pr view checks GitHubGraphQlBudget before the process starts, and the message github requests to github.com are paused until the rate limit resets is SourceControlRateLimitPausedError. The preflight that feeds that budget is gh api rate_limit with GraphQL cost hardcoded to 1, and it replaces a cost learned from a real query whenever remaining has fallen. gh pr view never reports its own cost. A burst that starts above the ten-percent floor can spend about three points per call, more when gh paginates, while the budget subtracts one. That matches the quota going from 4,974 to 2,206 in a few minutes and later hitting zero, with the pause landing after the budget is already gone.

    Why #3581 and #5673 are a different path

    #3581 is background gh pr list per branch. #5673 stopped that amplification. Both are closed. Link sync now batches summaries and does not walk merged links every minute. The trace parent spans here are ws.rpc.pullRequests.detail and ws.rpc.pullRequests.activity.

    Still on main

    origin/main is d5d48742c9. The two commits after this checkout are Codex protocol only. Package version on main is 0.0.42. The reported build is 0.0.42-workspace-guard.1. Nothing newer in the nightlies changes this refresh policy. There is no open duplicate that describes this detail/activity focus loop.

    What a fix has to change

    Keep a shared server cache that actually covers the focus and five-minute cadence, and do not bump every pull request on a project into a host read just because one turn checkpointed. Stop re-reading detail and activity once the pull request is merged or closed. Count the real GraphQL cost of gh pr view so the ten-percent reserve trips before the quota hits zero. The localStorage snapshot can stay. It is only paint.

    Workaround

    The gh wrapper already in use matches the missing policy: cache pr view --json and pullRequest(number:) reads for about ten minutes, and for much longer once the pull request is merged or closed. Closing pull request tabs, and leaving the pull requests page, drops the mounted readers. Unlinking threads does not.

    Disposition

    Leave #13496 open. Add bug. Do not close it as a duplicate of #3581 or #5673.

  2. added
    bugSomething is broken or behaving incorrectly.
    acceptedfeature request accepted
    via-triageFiled through npx t3 triage
    on Sep 24, 2026
  3. slavco86 commented on Sep 29, 2026

    @slavco86

    Another data point, from the macOS desktop app (T3 Code Alpha) on a single machine. I took these numbers from ~/.t3/userdata/logs/server.trace.ndjson* for 2026-09-29, 15:47–18:39. Each GitHubCli.execute span is attributed to its ws.rpc.* or reactor ancestor.

    Root gh calls
    ws.rpc.pullRequests.detail 577
    ws.rpc.pullRequests.activity 308
    ThreadPullRequestReactor.synchronize 290
    ThreadSettlementReactor.sweep 145
    ws.rpc.vcs.refreshStatus 18
    everything else 28
    total 1,366

    The calls come in bursts every ~3 minutes: 128 at 17:28, 128 at 17:31, 125 at 17:37, 128 at 17:41, 127 at 17:44 and 254 at 18:36. That is the same pattern as the original report.

    The bursts also have a second effect. While one is running, each gh call takes much longer (p50 6.3 s, p90 22.8 s, max 29.3 s), so a focus-triggered vcs.refreshStatus gets stuck behind it and raises the "Some requests are slow" toast:

    Start vcs.refreshStatus duration gh calls running at the same time
    17:32:26 16.3 s 66
    18:37:15 19.0 s 94
    18:37:50 27.5 s 124

    In the 27.5 s one, 25.0 s was spent in lookupStatusPr → findLatestPrForHeadContext → GitHubCli.execute. So the slow-request toasts people report for vcs.refreshStatus seem to come mostly from these PR detail/activity bursts. #13841 (fewer and cached detail reads) and #13052 (don't block refreshStatus on the remote lookup) would each fix one half.

  4. triias commented on Sep 30, 2026

    @triias

    Another data point from the macOS desktop app (T3 Code Alpha 0.0.44), single machine, one GitHub account.

    I sampled {rateLimit{used}} once a minute from 23:23 to 00:25 and took ps snapshots of every gh process together with its parent.

    • Baseline: about 10 GraphQL points per minute.
    • Bursts: at 23:30 (+438), 23:32 (+433), 23:39–23:43 (+405, +212, +229, +361, +116), 23:57 (+429), and 00:23–00:24 (+259, +193).
    • Total: 3,248 of the 5,000 points were used in the 23:15–00:15 window, almost all of them in these bursts. Earlier that day the quota ran out, and other tools on the same account started to fail with API rate limit already exceeded.

    In every burst, the sampled gh processes were children of the T3 Code server process. Two kinds of calls showed up:

    • gh pr view <n> --json …,statusCheckRollup,body,changedFiles,… (the detail fields)
    • gh api graphql -F number=<n>

    Apart from the T3 Code server, only one other process ran gh during the bursts, and it only made REST calls.

    Which PRs were read in the bursts:

    Both auto-settle options are off in this install. 182 of 196 threads are settled, 2 are archived, and there are 99 PR links, almost all of them to merged PRs. Settling a thread does not seem to take its PR out of the refresh.

    In the bundle I can see refreshAfterTurn clearing the whole PR read cache after every completed turn, in any thread. With several agents running in parallel, that alone makes the bursts frequent. #13841 looks like it addresses exactly this. Holding merged or closed PRs for 10 minutes would have removed most of what I measured.

    A side note for anyone measuring this: gh api rate_limit reported the GraphQL bucket wrong for me (it showed 112 used while a real GraphQL query returned 5000/5000). gh api graphql -f query='{rateLimit{used remaining resetAt}}' is the number to trust.

  5. marcus-hgic commented on Oct 2, 2026

    @marcus-hgic

    Another data point, from T3 Code Alpha 0.0.42 on macOS (single machine, one GitHub account, about ten agent threads active at the same time). It shows a refresh path that the triage above does not cover: every turn that completes in any thread refreshes every open PR view.

    Turn completion invalidates the whole PR read cache

    In the orchestration reactor, each turn.completed or turn.aborted event calls pullRequests.refreshAfterTurn:

    const refreshAfterTurn = suspend(() => {
      turnRefreshEpoch = listingsEpoch = ++epochCounter;
      return readCache.invalidate.pipe(
        andThen(set(pullRequestRefreshes, turnRefreshEpoch)),
      );
    });

    readCache.invalidate clears the cache for all PRs, not only the PR of the thread whose turn completed. subscribeRefreshes then sends the new epoch to every client. In the web client, pull-requests:detail and pull-requests:activity use that stream as their refreshTrigger, so each mounted detail and activity query reads GitHub again. The 15-second DETAIL_CACHE_TTL cannot help, because the cache was just cleared.

    With one interactive thread, this is not a problem. With many agent threads, turns complete every few seconds, so the open PR views read GitHub on that schedule.

    Measured

    From ~/.t3/userdata/logs/server.trace.ndjson*, 22:46–23:18 UTC, GitHubCli.execute spans grouped by ancestor:

    Root gh calls
    ws.rpc.pullRequests.detail 1,338 (546 successful detail RPCs)
    PullRequestSyncReactor.syncGroup 409
    ws.rpc.pullRequests.activity 180
    branch lookups (findLatestPrForHeadContext) ~50

    The GraphQL budget was empty at about 23:12. After that, the detail RPCs continued and failed with paused until the rate limit resets, GitHub API rate limit exceeded, and Pull request not found. Other gh users on the same account (agent sessions) could not create a PR. In a ps sample of every gh process during this time, the agent sessions made only REST calls.

    The activity read costs 15 points

    getChangeRequestActivity runs listReviewThreadComments (REVIEW_THREADS_GRAPHQL_QUERY) next to gh pr view --json author,comments,reviews,commits. A dry run of that document gives:

    rateLimit(dryRun: true) { cost }  →  {"cost": 15}
    

    Most of the cost comes from reactors(first: 10) inside REACTION_GROUPS_FIELDS, under comments(first: 10) under reviewThreads(first: 100). That is 1,000 possible reactor connections. Reading reactors only when the user opens a reaction popover, or reading reactionGroups { content viewerHasReacted reactors { totalCount } } without nodes, would make each activity read much less expensive.

    Suggested changes

    1. On turn completion, invalidate and refresh only the PR(s) linked to that thread's branch, not the whole read cache.
    2. Coalesce turn-triggered refreshes, for example to at most one per PR per minute.
    3. Do not re-read merged or closed PRs on a turn refresh.
    4. Remove reactors(first: 10) { nodes … } from the review thread query, or read it lazily.

    The /rate_limit mismatch in #14673 is also present here. On the same token in the same minute, gh api rate_limit showed GraphQL used 22, and a GraphQL {rateLimit{used remaining}} read showed used 5000, remaining 0.

    Written with Claude Code.

  6. MaximilianMauroner commented on Oct 4, 2026

    @MaximilianMauroner

    Codex Replying on behalf of Max

    Another observed consequence: PR watches repeatedly stop with “stopped watching, could not read it”. For several still-open PRs in a private repository, the agent reports restoring the watch, but the same notification returns. The agent's updates also report GitHub GraphQL RATE_LIMIT errors.

    Repeated PR watch-stop notifications

    The screenshot above is cropped from the actual T3 conversation to show the repeated notifications. Private PR numbers and conversation details are omitted. These PRs are in a private repository; no PR links are included.

    The watch_pull_request tool documents that T3 polls every minute and stops watching after it cannot read a PR for 15 minutes. The observed sequence suggests that rate limiting prevents reads, the watch stops and wakes the agent, the agent restores it, and it stops again while reads remain unavailable. This causes repeated agent runs and gaps in PR monitoring.

    Rate limiting is the suspected cause, not a log-confirmed cause for each watch failure. The exact T3 build was not captured, and no new trace or reproduction was collected for this comment.

    Expected behavior: preserve the watch during a temporary rate-limit pause, wait until the reset time, and resume automatically instead of repeatedly requiring an agent turn to restore it.

    Model: gpt-6.1-sol. Agent tool: Codex harness in T3 Code.

  7. bman654 commented on Oct 5, 2026

    @bman654

    Bitbucket behavior is similar. It seems to be slamming Bitbucket. Including wasting about 17% of the calls on a non-existent API endpoint:

    Bitbucket polling

    • Volume: 2,795 calls in about 100 minutes, so roughly 28 a minute or ~1,700 an hour. That's more than Bitbucket's documented baseline of 1,000 an hour per user, but there were no 429s (rate-limit errors).
    • Per PR: each open PR is polled on its own timer:
    PR Interval
    repo1#282 ~26s
    repo2#254 ~25s
    repo3#128 / #129 ~50s
    repo1#283 ~4 min
    • Each poll makes about 5 calls: the PR itself, statuses, diffstat, conflicts and user/permissions/repositories. It fetches commits and comments less often.
    • Wasted calls: /2.0/user/permissions/repositories returned 404 on all 466 calls. Bitbucket answers it with its generic NotFoundHandler, which suggests the endpoint has been removed. That's a T3 bug worth reporting, and it accounts for about 1 in 6 of the calls.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    acceptedfeature request acceptedbugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions