Skip to content

[Bug]: After a restart, pull request sync sends 20 to 50 GitHub requests for 110 pull requests instead of about 5 #35153

Closed

Description

@only21mil

Before submitting

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

Area

apps/server

Steps to reproduce

Copy this block into a shell in an empty folder on Linux, with GH_TOKEN set to a token that can read public repositories, and run it. It clones main at the commit below, starts a server from source with an empty data folder, links 110 open pull requests of this repository to one thread, restarts the server with a cold pull request cache, and prints what the startup sweep sent. No provider account is needed.

# Run in an empty folder on Linux with git, curl, python3, jq and vp (Vite+) installed. Uses port 14711.
# Needs GH_TOKEN set to a GitHub token that can read public repositories, for example: export GH_TOKEN="$(gh auth token)"
# The server reads 110 open pull requests of pingdotgg/t3code. One summary request can carry 25 of them,
# so its startup sweep needs about 5 requests. Count it with a cold pull request cache, as after a restart
# once cached reads have expired.
WORK=$PWD
SRC=${T3_SRC:-$WORK/t3code}
if [ ! -d "$SRC" ]; then
  git clone https://github.com/pingdotgg/t3code.git "$SRC"
  git -C "$SRC" checkout b68aedfdb71534bfdfbe55771ef2e686742faa8e
  (cd "$SRC" && vp i)
fi
t3() { (cd "$SRC/apps/server" && vp env exec --node 26.12.1 node src/bin.ts "$@" --base-dir "$WORK/home"); }
serve() { t3 serve --host 127.0.0.1 --port 14711 --no-browser --dev-url http://127.0.0.1:9 >> "$WORK/server.log" 2>&1 & until curl -s -o /dev/null http://127.0.0.1:14711/; do sleep 1; done; }
stop() { kill "$(jq -r .pid "$WORK/home/userdata/server-runtime.json")"; while curl -s -o /dev/null http://127.0.0.1:14711/; do sleep 1; done; }
trace=$WORK/home/userdata/logs/server.trace.ndjson

# 1. A project whose origin is pingdotgg/t3code, a server, and one thread (on the free "Replay (dev)" provider).
git init -b main "$WORK/sample-app"
git -C "$WORK/sample-app" -c user.name=Sample -c [email protected] commit --allow-empty -m "Initial commit"
git -C "$WORK/sample-app" remote add origin https://github.com/pingdotgg/t3code.git
serve
t3 project add "$WORK/sample-app"
thread=$(t3 thread start "$WORK/sample-app" "Track pull requests" --provider devReplay --model showcase)

# 2. Link 110 open pull requests of pingdotgg/t3code to that thread. The t3 CLI has no link command, so this
#    small script signs in the way `t3 thread` does and calls the server's link_pull_requests tool.
urls=$(for page in 1 2; do curl -s -H "Authorization: Bearer $GH_TOKEN" "https://api.github.com/repos/pingdotgg/t3code/pulls?state=open&per_page=100&page=$page" | jq -r '.[].html_url'; done | head -110)
cat > "$SRC/apps/server/link-prs.ts" <<'TS'
// node link-prs.ts <base-dir> <thread-id> <url>...: calls link_pull_requests on the running server, signed in the way `t3 thread` does.
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as NetService from "@t3tools/shared/Net";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as EnvironmentAuth from "./src/auth/EnvironmentAuth.ts";
import { resolveCliAuthConfig } from "./src/cli/config.ts";
import * as ServerConfig from "./src/config.ts";
import { mcpToolSession } from "./src/mcp/AcpMcpStdioBridge.ts";
import { readLiveServerRuntimeState } from "./src/serverRuntimeState.ts";
const [baseDir, threadId, ...urls] = process.argv.slice(2);
const program = Effect.gen(function* () {
  const config = yield* resolveCliAuthConfig({ baseDir: Option.some(baseDir!) }, Option.none());
  return yield* Effect.gen(function* () {
    const origin = Option.getOrThrow(yield* readLiveServerRuntimeState(config.serverRuntimeStatePath)).origin;
    const auth = yield* EnvironmentAuth.EnvironmentAuth;
    const issued = yield* auth.issueMcpClientSession({ label: "link-prs", access: "full-access", peerLink: false, client: { deviceType: "bot" } });
    const session = yield* mcpToolSession({ endpoint: new URL("/mcp", origin).href, authorization: `Bearer ${issued.token}`, clientName: "link-prs" });
    const result = yield* session.call("link_pull_requests", { threadId, urls });
    console.log(JSON.stringify(result.structuredContent));
  }).pipe(Effect.scoped, Effect.provide(EnvironmentAuth.layerRuntime.pipe(Layer.provide(ServerConfig.layer(config)))));
});
NodeRuntime.runMain(program.pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, NetService.layer))));
TS
(cd "$SRC/apps/server" && vp env exec --node 26.12.1 node link-prs.ts "$WORK/home" "$thread" $urls) | jq -c '{linkedCount}'
sleep 30

# 3. Restart with a cold pull request cache: stop the server, remove only its pull request cache folder, start it again.
stop
rm -rf "$WORK/home/caches/pull-requests"
before=$(wc -l < "$trace")
serve
until tail -n +"$((before + 1))" "$trace" | jq -e 'select(.name == "PullRequestSyncReactor.sweep")' > /dev/null; do sleep 2; done

# 4. The startup sweep: pull requests it read, and the GitHub GraphQL summary requests the server sent for them.
#    Every GitHub GraphQL request is one GitHubGraphQlAuth.execute span in the server's own trace file.
tail -n +"$((before + 1))" "$trace" | jq -s -c '
  (map(select(.name == "PullRequestSyncReactor.sweep")) | first) as $sweep
  | ($sweep.startTimeUnixNano | tonumber) as $from | ($from + $sweep.durationMs * 1000000) as $to
  | def inside: (.startTimeUnixNano | tonumber) as $t | $t >= $from and $t <= $to;
  { pullRequestsRead: map(select(.name == "PullRequestSyncReactor.syncGroup" and inside)) | length,
    summaryRequests: map(select(.name == "GitHubGraphQlAuth.execute" and .attributes["github.operation"] == "getPullRequestSummary" and inside)) | length,
    summaryRequestsFailed: map(select(.name == "GitHubGraphQlAuth.execute" and .attributes["github.operation"] == "getPullRequestSummary" and inside and .exit._tag != "Success")) | length }'
stop

Expected behavior

The startup sweep reads the 110 pull requests in about 5 GitHub GraphQL summary requests, since one request carries up to 25 pull requests.

Actual behavior

The startup sweep sends 21 to 48 summary requests for the same 109 or 110 pull requests, in three runs on the same machine. None of them failed, so this is not retries. Each request counts against the token's GraphQL budget. #18295 reported a sweep splitting into many more requests than needed and was closed as fixed on 2026-10-21; on main b68aedf I still see the startup sweep split this way.

Impact

Minor bug or occasional failure

Version or commit

main, commit b68aedf

Environment

Linux, server from source (t3 serve) on Node 26.12.1, a read-only GitHub token. No provider involved.

Logs or stack traces

{"pullRequestsRead":110,"summaryRequests":48,"summaryRequestsFailed":0}
{"pullRequestsRead":109,"summaryRequests":26,"summaryRequestsFailed":0}
{"pullRequestsRead":109,"summaryRequests":21,"summaryRequestsFailed":0}

Screenshots, recordings, or supporting files

Workaround

None that I found.

A fix is in #28422.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions