authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-22 20:50:49-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-22 20:50:49-07:00
log5975ec78b7eaff654e6183b99f8de7a05591e99d
treece0437ed5ffe8ae0ce1a14d89c16ba0ccb3c0728
parent0c91f687de606f62e157209f34e6aa74744b555a
signature Signed by PGP key B5690EEEBB952194

fix(server): background PR checks spend less GitHub quota (#13189)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

4 files changed, 144 insertions(+), 8 deletions(-)

apps/server/src/git/GitManager.test.ts+56
......@@ -15,6 +15,7 @@ import * as PlatformError from "effect/PlatformError";
1515import * as References from "effect/References";
1616import * as Schema from "effect/Schema";
1717import * as Scope from "effect/Scope";
18import { TestClock } from "effect/testing";
1819import { ChildProcessSpawner } from "effect/unstable/process";
1920import { expect } from "vite-plus/test";
2021import type {
......@@ -1490,6 +1491,61 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
14901491 }),
14911492 );
14921493
1494 it.effect("branch PR lookup rechecks open PRs every minute and settled answers less often", () =>
1495 Effect.gen(function* () {
1496 const repoDir = yield* makeTempDir("t3code-git-manager-");
1497 yield* initRepo(repoDir);
1498 const remoteDir = yield* createBareRemote();
1499 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1500 for (const branch of ["feature/open-pr", "feature/merged-pr", "feature/no-pr"]) {
1501 yield* runGit(repoDir, ["checkout", "-b", branch, "main"]);
1502 yield* runGit(repoDir, ["push", "-u", "origin", branch]);
1503 }
1504 const pullRequest = (number: number, headRefName: string, state: string) => ({
1505 number,
1506 title: headRefName,
1507 url: `https://github.com/pingdotgg/codething-mvp/pull/${number}`,
1508 baseRefName: "main",
1509 headRefName,
1510 state,
1511 updatedAt: "2026-04-07T15:00:00Z",
1512 });
1513 const { manager, ghCalls } = yield* makeManager({
1514 ghScenario: {
1515 prListByHeadSelector: {
1516 "feature/open-pr": encodeCliJson([pullRequest(301, "feature/open-pr", "OPEN")]),
1517 "feature/merged-pr": encodeCliJson([pullRequest(302, "feature/merged-pr", "MERGED")]),
1518 },
1519 },
1520 });
1521 const lookupAll = Effect.forEach(
1522 ["feature/open-pr", "feature/merged-pr", "feature/no-pr"],
1523 (branch) => manager.branchPullRequest({ cwd: repoDir, branch }),
1524 );
1525 const prListCalls = () => ghCalls.filter((call) => call.startsWith("pr list "));
1526
1527 yield* lookupAll;
1528 expect(prListCalls()).toHaveLength(3);
1529
1530 yield* TestClock.adjust("61 seconds");
1531 yield* lookupAll;
1532 expect(prListCalls()).toHaveLength(4);
1533 expect(prListCalls().at(-1)).toContain("--head feature/open-pr");
1534
1535 // Just inside the 5-minute window only the open PR is asked again.
1536 yield* TestClock.adjust("238 seconds");
1537 yield* lookupAll;
1538 expect(prListCalls()).toHaveLength(5);
1539 expect(prListCalls().at(-1)).toContain("--head feature/open-pr");
1540
1541 // Just past it the settled answers expire too.
1542 yield* TestClock.adjust("2 seconds");
1543 yield* lookupAll;
1544 expect(prListCalls()).toHaveLength(7);
1545 expect(prListCalls().slice(-2).join("\n")).not.toContain("--head feature/open-pr");
1546 }),
1547 );
1548
14931549 it.effect("branch PR lookup propagates provider failures", () =>
14941550 Effect.gen(function* () {
14951551 const repoDir = yield* makeTempDir("t3code-git-manager-");
apps/server/src/git/GitManager.ts+9-1
......@@ -144,6 +144,12 @@ const STATUS_RESULT_CACHE_CAPACITY = 2_048;
144144// exponentially via prLookupFailureTtl, so throttling pressure still drops
145145// under 429s instead of amplifying it.
146146const PR_LOOKUP_CACHE_TTL = Duration.seconds(60);
147// Answers without an open PR ("no PR yet", merged, closed) only change when
148// someone opens a PR, and the paths that do that in-app (turn end, push,
149// create PR, user refresh) bypass this cache. Re-asking every minute for each
150// idle branch was the bulk of background GitHub quota use, so these wait out
151// a longer TTL and a PR opened outside the app shows up within minutes.
152const PR_LOOKUP_NO_OPEN_PR_CACHE_TTL = Duration.minutes(5);
147153const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20);
148154const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15);
149155const PR_LOOKUP_CACHE_CAPACITY = 2_048;
......@@ -1105,7 +1111,9 @@ export const make = Effect.gen(function* () {
11051111 timeToLive: (exit, key) => {
11061112 if (Exit.isSuccess(exit)) {
11071113 prLookupFailureStreakByKey.delete(key);
1108 return PR_LOOKUP_CACHE_TTL;
1114 return exit.value.latest?.state === "open"
1115 ? PR_LOOKUP_CACHE_TTL
1116 : PR_LOOKUP_NO_OPEN_PR_CACHE_TTL;
11091117 }
11101118 return nextPrLookupFailureTtl(key);
11111119 },
apps/server/src/orchestration/ThreadSettlementReactor.test.ts+43
......@@ -475,6 +475,49 @@ describe("ThreadSettlementReactor", () => {
475475 }),
476476 ),
477477 );
478 it.effect("skips the branch recheck when a terminal link would settle nothing", () =>
479 Effect.scoped(
480 Effect.gen(function* () {
481 yield* TestClock.setTime(Date.parse(NOW));
482 const previous = {
483 projectId: PROJECT_ID,
484 repository: "owner/repository",
485 number: 1,
486 url: "https://example.test/owner/repository/pull/1",
487 };
488 const fixture = yield* makeHarness({
489 snapshot: makeSnapshot(
490 [
491 makeThread("resumed-manual", {
492 branch: "main",
493 linkedPullRequest: previous,
494 latestUserMessageAt: "2026-08-28T00:00:00.000Z",
495 }),
496 ],
497 [makeProject()],
498 ),
499 settings: {
500 ...DEFAULT_SERVER_SETTINGS,
501 sidebarAutoSettleAfterDays: null,
502 sidebarAutoSettleOnMerge: true,
503 },
504 branchPullRequest: () => Effect.succeed(makeBranchPullRequest("open")),
505 pullRequestSummary: (input) =>
506 Effect.succeed({
507 ...makePullRequestSummary({ ...input, state: "merged" }),
508 mergedAt: "2026-08-27T00:00:00.000Z",
509 }),
510 });
511 yield* Effect.gen(function* () {
512 const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
513 yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
514 assert.deepStrictEqual(yield* Ref.get(fixture.commands), []);
515 assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1);
516 assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []);
517 }).pipe(Effect.provide(fixture.layer));
518 }),
519 ),
520 );
478521 it.effect("uses saved PRs without settling resumed threads or branches with newer PRs", () =>
479522 Effect.scoped(
480523 Effect.gen(function* () {
apps/server/src/orchestration/ThreadSettlementReactor.ts+36-7
......@@ -209,9 +209,30 @@ export const make = Effect.gen(function* () {
209209 };
210210 const groups = Map.groupBy(lookupCandidates, lookupKey);
211211
212 const wouldSettle = Effect.fn("ThreadSettlementReactor.wouldSettle")(function* (
213 group: ReadonlyArray<(typeof candidates)[number]>,
214 pullRequest: SettlementPullRequest,
215 ) {
216 const currentSettings = yield* settingsService.getSettings;
217 const decisionNow = DateTime.formatIso(yield* DateTime.now);
218 return group.some((thread) => {
219 const { settings } = resolveProjectSettings(currentSettings, thread.projectId);
220 return (
221 resolveAutoSettlementAt({
222 thread,
223 pullRequest,
224 now: decisionNow,
225 autoSettleAfterDays: settings.sidebarAutoSettleAfterDays,
226 autoSettleOnMerge: settings.sidebarAutoSettleOnMerge,
227 }) !== null
228 );
229 });
230 });
231
212232 const pullRequestFor = Effect.fn("ThreadSettlementReactor.pullRequestFor")(function* (
213 thread: (typeof candidates)[number],
233 group: ReadonlyArray<(typeof candidates)[number]>,
214234 ) {
235 const thread = group[0]!;
215236 const reference = thread.linkedPullRequest ?? thread.branchPullRequest;
216237 if (reference != null) {
217238 const matchesMerge =
......@@ -236,10 +257,21 @@ export const make = Effect.gen(function* () {
236257 },
237258 { recoverTransientFailure: false },
238259 );
260 const terminal = {
261 state: summary.state,
262 closedAt: summary.closedAt ?? null,
263 mergedAt: summary.mergedAt ?? null,
264 } satisfies SettlementPullRequest;
239265 const cwd = lookupCwdByThreadId.get(thread.id);
240266 if (summary.state !== "open" && thread.branch !== null && cwd !== undefined) {
241267 // A reused branch can already have a new open PR while discovery
242268 // is replacing its old link. Do not let settlement win that race.
269 // Only pay for the uncached lookup when this sweep would otherwise
270 // settle: a terminal link that settles nothing (resumed thread,
271 // settle-on-merge off) would re-query the host every minute. A
272 // group that becomes eligible after this check waits for the next
273 // sweep rather than settling on the unverified link.
274 if (!(yield* wouldSettle(group, terminal))) return undefined;
243275 const current = yield* git.branchPullRequest(
244276 { cwd, branch: thread.branch },
245277 { refresh: true },
......@@ -253,11 +285,7 @@ export const make = Effect.gen(function* () {
253285 return current;
254286 }
255287 }
256 return {
257 state: summary.state,
258 closedAt: summary.closedAt ?? null,
259 mergedAt: summary.mergedAt ?? null,
260 } satisfies SettlementPullRequest;
288 return terminal;
261289 }
262290 if (thread.branch === null) return null;
263291 const cwd = lookupCwdByThreadId.get(thread.id);
......@@ -271,7 +299,8 @@ export const make = Effect.gen(function* () {
271299 groups.values(),
272300 (group) =>
273301 Effect.gen(function* () {
274 const pullRequest = yield* pullRequestFor(group[0]!);
302 const pullRequest = yield* pullRequestFor(group);
303 if (pullRequest === undefined) return;
275304 yield* Effect.forEach(group, (thread) => settleThread(thread, pullRequest), {
276305 discard: true,
277306 });