From 37fe0807d5e12d055ce759649a739cf99d74fda0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 08:59:25 +0000 Subject: [PATCH 1/4] test(server): a finished turn leaves another project's PR listing cached Fails on main: refreshAfterTurn bumps the global listingsEpoch, so every listing misses its cache after a turn in any project (#19363). AI-assisted; verified with the tests and tools listed above. --- .../pullRequest/PullRequestService.test.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index f54c77007c313..bf6cae8b250a4 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -2671,6 +2671,33 @@ it.effect("does not revive old summaries when project epochs are evicted", () => }), ); +it.effect("a finished turn leaves another project's cached listing in place", () => + Effect.gen(function* () { + const reads: string[] = []; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + project({ id: "p2", title: "api", workspaceRoot: "/b", repository: "acme/api" }), + ], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + listChangeRequests: ({ repository }) => { + reads.push(repository); + return Effect.succeed({ items: [], truncated: false, continues: false }); + }, + }), + ], + }); + const webListing = { state: "open" as const, projectId: "p1" as ProjectId }; + + yield* service.list(webListing); + yield* service.refreshAfterTurn("p2" as ProjectId); + yield* service.list(webListing); + + assert.deepStrictEqual(reads, ["acme/web"]); + }), +); + it.effect("a refresh or a finished turn drops the provider's own change request reads", () => Effect.gen(function* () { const forgotten: Array<{ readonly repository: string; readonly number?: number } | undefined> = From 0495c747fd76f45f1a9edf19139338ee6b73c6a3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 09:09:00 +0000 Subject: [PATCH 2/4] fix(server): a finished turn refreshes only its own project's PR listings refreshAfterTurn bumped the global listingsEpoch, so a turn in any project invalidated every cached pull-request listing and the open page re-listed every repository from the forge. Listings now key on the epochs of the projects they cover; a listing across all projects still follows every turn. Fixes pingdotgg/t3code#19363 AI-assisted; verified with the tests and tools listed above. --- .../src/pullRequest/PullRequestService.ts | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 9009e2d09e91f..cb5cc43ae7ab3 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -2910,6 +2910,17 @@ export const make = Effect.gen(function* () { const refEpochs = new Map(); const projectEpochs = new Map(); let projectEpochFloor = 0; + let latestProjectEpoch = 0; + // A turn refreshes its own project's listings; a listing spanning every project sees them all. + const listingProjectsEpoch = (input: Pick) => { + const ids = [ + ...(input.projectId === undefined ? [] : [input.projectId]), + ...(input.projectIds ?? []), + ]; + return ids.length === 0 + ? latestProjectEpoch + : Math.max(...ids.map((id) => projectEpochs.get(id) ?? projectEpochFloor)); + }; const REF_EPOCH_CAPACITY = 2_048; const refScope = (ref: PullRequestRef) => JSON.stringify([ @@ -3082,6 +3093,7 @@ export const make = Effect.gen(function* () { const listCache = yield* Cache.makeWith( (key: string) => { const [ + , , state, involvement, @@ -3093,6 +3105,7 @@ export const make = Effect.gen(function* () { query, cursorEntries, ] = JSON.parse(key) as [ + number, number, string, string | null, @@ -3124,6 +3137,7 @@ export const make = Effect.gen(function* () { const list: PullRequestService["Service"]["list"] = (input) => { const key = JSON.stringify([ listingsEpoch, + listingProjectsEpoch(input), input.state, input.involvement ?? null, input.filters === undefined @@ -3644,7 +3658,8 @@ export const make = Effect.gen(function* () { // change request reached through another repository, and no epoch may move until those // reads are gone, or a reader in between keeps the old one under the new epoch. yield* forgetProviderReads(); - listingsEpoch = ++epochCounter; + const epoch = ++epochCounter; + latestProjectEpoch = epoch; projectEpochs.delete(projectId); if (projectEpochs.size >= REF_EPOCH_CAPACITY) { const oldest = projectEpochs.keys().next().value; @@ -3653,8 +3668,7 @@ export const make = Effect.gen(function* () { projectEpochs.delete(oldest); } } - projectEpochs.set(projectId, listingsEpoch); - const epoch = listingsEpoch; + projectEpochs.set(projectId, epoch); yield* readCache.invalidate(`project:${projectId}`); yield* SubscriptionRef.set(pullRequestRefreshes, epoch); }); From d3b09ac0b051a9f76e2939e1f8c6fc178a4d5eba Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 09:20:38 +0000 Subject: [PATCH 3/4] fix(server): a finished turn leaves every PR listing cached The listing key still followed every turn when the page listed all projects, which is the default page. A turn now leaves listing keys alone: per-PR reads still refresh for the turn's project, and listings pick the change up when their 30-second TTL ends. AI-assisted; verified with the tests and tools listed above. --- .../src/pullRequest/PullRequestService.test.ts | 6 +++++- apps/server/src/pullRequest/PullRequestService.ts | 15 --------------- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index bf6cae8b250a4..83e42506b2418 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -2689,12 +2689,16 @@ it.effect("a finished turn leaves another project's cached listing in place", () ], }); const webListing = { state: "open" as const, projectId: "p1" as ProjectId }; + const everyListing = { state: "open" as const }; yield* service.list(webListing); + yield* service.list(everyListing); + reads.length = 0; yield* service.refreshAfterTurn("p2" as ProjectId); yield* service.list(webListing); + yield* service.list(everyListing); - assert.deepStrictEqual(reads, ["acme/web"]); + assert.deepStrictEqual(reads, []); }), ); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index cb5cc43ae7ab3..645dce7072606 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -2910,17 +2910,6 @@ export const make = Effect.gen(function* () { const refEpochs = new Map(); const projectEpochs = new Map(); let projectEpochFloor = 0; - let latestProjectEpoch = 0; - // A turn refreshes its own project's listings; a listing spanning every project sees them all. - const listingProjectsEpoch = (input: Pick) => { - const ids = [ - ...(input.projectId === undefined ? [] : [input.projectId]), - ...(input.projectIds ?? []), - ]; - return ids.length === 0 - ? latestProjectEpoch - : Math.max(...ids.map((id) => projectEpochs.get(id) ?? projectEpochFloor)); - }; const REF_EPOCH_CAPACITY = 2_048; const refScope = (ref: PullRequestRef) => JSON.stringify([ @@ -3093,7 +3082,6 @@ export const make = Effect.gen(function* () { const listCache = yield* Cache.makeWith( (key: string) => { const [ - , , state, involvement, @@ -3105,7 +3093,6 @@ export const make = Effect.gen(function* () { query, cursorEntries, ] = JSON.parse(key) as [ - number, number, string, string | null, @@ -3137,7 +3124,6 @@ export const make = Effect.gen(function* () { const list: PullRequestService["Service"]["list"] = (input) => { const key = JSON.stringify([ listingsEpoch, - listingProjectsEpoch(input), input.state, input.involvement ?? null, input.filters === undefined @@ -3659,7 +3645,6 @@ export const make = Effect.gen(function* () { // reads are gone, or a reader in between keeps the old one under the new epoch. yield* forgetProviderReads(); const epoch = ++epochCounter; - latestProjectEpoch = epoch; projectEpochs.delete(projectId); if (projectEpochs.size >= REF_EPOCH_CAPACITY) { const oldest = projectEpochs.keys().next().value; From db5cdbfd16039eda98e655578410de74ea8175d4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 09:28:52 +0000 Subject: [PATCH 4/4] fix(server): a push still refreshes PR listings The previous change stopped refreshAfterTurn from moving listingsEpoch, but pushes reach the listings only through refreshAfterTurn, so a PR created by 'Commit, push & create PR' stayed missing from a cached listing. refreshPushedPullRequests now passes pushed: true, which bumps listingsEpoch; a plain turn still leaves listings cached. AI-assisted; verified with the tests and tools listed above. --- apps/server/src/git/linkCreatedPullRequest.test.ts | 12 ++++++++---- apps/server/src/git/refreshPushedPullRequests.ts | 6 +++--- .../src/pullRequest/PullRequestService.test.ts | 5 ++++- apps/server/src/pullRequest/PullRequestService.ts | 9 +++++++-- 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/apps/server/src/git/linkCreatedPullRequest.test.ts b/apps/server/src/git/linkCreatedPullRequest.test.ts index d4d48bd98619f..4ec813d38200a 100644 --- a/apps/server/src/git/linkCreatedPullRequest.test.ts +++ b/apps/server/src/git/linkCreatedPullRequest.test.ts @@ -169,7 +169,7 @@ it.effect( "refreshes PR readers after a push from a thread or project, but not a local commit", () => Effect.gen(function* () { - const refreshed: string[] = []; + const refreshed: Array<[string, boolean | undefined]> = []; const layerDependencies = Layer.mergeAll( Layer.mock(Orchestrator.OrchestratorV2)({ getThreadShell: () => Effect.succeed(v2PullRequestThread(thread)), @@ -178,9 +178,9 @@ it.effect( listShells: () => Effect.succeed([project]), }), Layer.mock(PullRequestService.PullRequestService)({ - refreshAfterTurn: (id) => + refreshAfterTurn: (id, options) => Effect.sync(() => { - refreshed.push(id); + refreshed.push([id, options?.pushed]); }), }), ); @@ -203,6 +203,10 @@ it.effect( { cwd: "/draft-worktree", projectId: PROJECT_ID }, { push: { status: "pushed" } }, ).pipe(Effect.provide(layerDependencies)); - expect(refreshed).toEqual([PROJECT_ID, PROJECT_ID, PROJECT_ID]); + expect(refreshed).toEqual([ + [PROJECT_ID, true], + [PROJECT_ID, true], + [PROJECT_ID, true], + ]); }), ); diff --git a/apps/server/src/git/refreshPushedPullRequests.ts b/apps/server/src/git/refreshPushedPullRequests.ts index b99878dd5e21f..2986b6f77f56f 100644 --- a/apps/server/src/git/refreshPushedPullRequests.ts +++ b/apps/server/src/git/refreshPushedPullRequests.ts @@ -16,18 +16,18 @@ export const refreshPushedPullRequests = Effect.fn("refreshPushedPullRequests")( const engine = yield* OrchestratorV2.OrchestratorV2; const thread = yield* engine.getThreadShell(input.threadId); if (thread !== null) { - yield* pullRequests.refreshAfterTurn(thread.projectId); + yield* pullRequests.refreshAfterTurn(thread.projectId, { pushed: true }); return; } } if (input.projectId !== undefined) { - yield* pullRequests.refreshAfterTurn(input.projectId); + yield* pullRequests.refreshAfterTurn(input.projectId, { pushed: true }); return; } const projects = yield* (yield* ProjectStore.ProjectStoreV2).listShells(); yield* Effect.forEach( projects.filter((project) => project.workspaceRoot === input.cwd), - (project) => pullRequests.refreshAfterTurn(project.id), + (project) => pullRequests.refreshAfterTurn(project.id, { pushed: true }), { discard: true }, ); }, diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 83e42506b2418..2fe591a954e48 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -2697,8 +2697,11 @@ it.effect("a finished turn leaves another project's cached listing in place", () yield* service.refreshAfterTurn("p2" as ProjectId); yield* service.list(webListing); yield* service.list(everyListing); - assert.deepStrictEqual(reads, []); + + yield* service.refreshAfterTurn("p2" as ProjectId, { pushed: true }); + yield* service.list(everyListing); + assert.deepStrictEqual(reads.toSorted(), ["acme/api", "acme/web"]); }), ); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 645dce7072606..91ee91b9ab083 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -218,7 +218,10 @@ export class PullRequestService extends Context.Service< input: PullRequestReportStateInput, ) => Effect.Effect; readonly subscribeRefreshes: Stream.Stream; - readonly refreshAfterTurn: (projectId: ProjectId) => Effect.Effect; + readonly refreshAfterTurn: ( + projectId: ProjectId, + options?: { readonly pushed?: boolean }, + ) => Effect.Effect; /** Project and remote lookups inside `effect` read once per filter, e.g. across one sync sweep. */ readonly withSharedProjectReads: ( effect: Effect.Effect, @@ -3639,12 +3642,14 @@ export const make = Effect.gen(function* () { const refreshAfterTurn: PullRequestService["Service"]["refreshAfterTurn"] = Effect.fn( "PullRequestService.refreshAfterTurn", - )(function* (projectId) { + )(function* (projectId, options) { // Everything the provider holds, not just the project's repository: a turn can touch a // change request reached through another repository, and no epoch may move until those // reads are gone, or a reader in between keeps the old one under the new epoch. yield* forgetProviderReads(); const epoch = ++epochCounter; + // A push can open or move a PR; a plain turn waits for the listing TTL to spare the API quota. + if (options?.pushed === true) listingsEpoch = epoch; projectEpochs.delete(projectId); if (projectEpochs.size >= REF_EPOCH_CAPACITY) { const oldest = projectEpochs.keys().next().value;