From ae7bed6e402897b8020f10d13747dd6a3779b5ae Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:15:21 -0400 Subject: [PATCH] perf(server): cached branch PR lookups no longer start git processes (#17063) --- apps/server/src/git/GitManager.test.ts | 219 ++++++++++++++--- apps/server/src/git/GitManager.ts | 232 ++++++++++++++---- .../ThreadPullRequestService.ts | 5 +- 3 files changed, 371 insertions(+), 85 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 7356d67ac273975d6fc930004dede8034c25b7ca..df6a742a54a47f8264d159e5eb1cacd90c17cc0c 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -11,6 +11,7 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; @@ -728,6 +729,14 @@ function makeManager(input?: { serverSettings?: Parameters[0]; setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; gitConfigReads?: string[]; + /** Records the arguments of every git process the manager starts. */ + gitCommands?: string[]; + /** Holds a matching git process's output until `release`, once it has exited. */ + gitSpawnGate?: { + readonly matches: (args: ReadonlyArray) => boolean; + readonly reached: Deferred.Deferred; + readonly release: Deferred.Deferred; + }; /** Seeds the V2 stores the per-project settings lookup reads. */ seed?: Effect.Effect< void, @@ -745,7 +754,40 @@ function makeManager(input?: { const layerServerSettings = ServerSettings.ServerSettingsService.layerTest(input?.serverSettings); - const layerVcsDriver = input?.gitConfigReads + const gitCommands = input?.gitCommands; + const gitSpawnGate = input?.gitSpawnGate; + const watchesGitSpawns = gitCommands !== undefined || gitSpawnGate !== undefined; + const layerGitDriverSpawner = watchesGitSpawns + ? Layer.effect( + ChildProcessSpawner.ChildProcessSpawner, + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return ChildProcessSpawner.make((command) => { + // On Windows the driver starts git by its full path to git.exe. + const args = + command._tag === "StandardCommand" && + NodePath.parse(command.command).name.toLowerCase() === "git" + ? command.args + : null; + if (args !== null) gitCommands?.push(args.join(" ")); + if (args === null || gitSpawnGate === undefined || !gitSpawnGate.matches(args)) { + return spawner.spawn(command); + } + return spawner + .spawn(command) + .pipe( + Effect.tap((handle) => + handle.exitCode.pipe( + Effect.andThen(Deferred.succeed(gitSpawnGate.reached, undefined)), + Effect.andThen(Deferred.await(gitSpawnGate.release)), + ), + ), + ); + }); + }), + ).pipe(Layer.provide(NodeServices.layer)) + : Layer.empty; + const gitVcsDriver = input?.gitConfigReads ? Layer.effect( GitVcsDriver.GitVcsDriver, GitVcsDriver.make.pipe( @@ -759,16 +801,15 @@ function makeManager(input?: { }), ), ), - ).pipe( - Layer.provideMerge(VcsProcess.layer), - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(layerServerConfig), ) - : GitVcsDriver.layer.pipe( - Layer.provideMerge(VcsProcess.layer), - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(layerServerConfig), - ); + : GitVcsDriver.layer; + // Fresh so the counting spawner is not swapped for the suite's shared driver. + const layerVcsDriver = (watchesGitSpawns ? Layer.fresh(gitVcsDriver) : gitVcsDriver).pipe( + Layer.provide(layerGitDriverSpawner), + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(layerServerConfig), + ); const layerSourceControlRegistry = Layer.effect( SourceControlProviderRegistry.SourceControlProviderRegistry, Effect.gen(function* () { @@ -1250,45 +1291,159 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("a branch tracking origin reads origin's URL once per PR lookup", () => + it.effect("a warm branch PR lookup starts no git process", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["checkout", "-b", "feature/origin-once"]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/origin-once"]); - - const gitConfigReads: string[] = []; - const { manager } = yield* makeManager({ - gitConfigReads, + for (const branch of ["feature/warm-pr", "feature/warm-no-pr"]) { + yield* runGit(repoDir, ["checkout", "-b", branch, "main"]); + yield* runGit(repoDir, ["push", "-u", "origin", branch]); + } + const gitCommands: string[] = []; + const { manager, ghCalls } = yield* makeManager({ + gitCommands, ghScenario: { - prListSequence: [ - // Fake gh returns raw JSON stdout, matching the CLI boundary under test. - JSON.stringify([ + prListByHeadSelector: { + "feature/warm-pr": encodeCliJson([ { - number: 217, - title: "Origin once PR", - url: "https://github.com/pingdotgg/t3code/pull/217", + number: 230, + title: "Warm PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/230", baseRefName: "main", - headRefName: "feature/origin-once", + headRefName: "feature/warm-pr", state: "OPEN", - updatedAt: "2026-04-03T15:00:00Z", + updatedAt: "2026-04-07T15:00:00Z", }, ]), - ], + }, }, }); + const lookupAll = Effect.forEach(["feature/warm-pr", "feature/warm-no-pr"], (branch) => + manager.branchPullRequest({ cwd: repoDir, branch }), + ); - yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature/origin-once" }); - gitConfigReads.length = 0; - const pullRequest = yield* manager.branchPullRequest({ + const cold = yield* lookupAll; + expect(cold.map((pullRequest) => pullRequest?.number ?? null)).toEqual([230, null]); + expect(gitCommands.length).toBeGreaterThan(0); + + gitCommands.length = 0; + const warm = yield* lookupAll; + expect(warm).toEqual(cold); + expect(gitCommands).toEqual([]); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); + }), + ); + + it.effect( + "a branch PR lookup sees an upstream change on request, after a T3 git action, or after the TTL", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/moving-upstream"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/moving-upstream"]); + yield* runGit(repoDir, ["push", "origin", "HEAD:refs/heads/feature/other-head"]); + const pullRequest = (number: number, headRefName: string) => + encodeCliJson([ + { + number, + title: headRefName, + url: `https://github.com/pingdotgg/codething-mvp/pull/${number}`, + baseRefName: "main", + headRefName, + state: "OPEN", + updatedAt: "2026-04-07T15:00:00Z", + }, + ]); + const { manager } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + "feature/moving-upstream": pullRequest(235, "feature/moving-upstream"), + "feature/other-head": pullRequest(236, "feature/other-head"), + }, + }, + }); + const lookup = manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/moving-upstream", + }); + expect((yield* lookup)?.number).toBe(235); + + // Discovery rereads git before it persists an answer. + yield* runGit(repoDir, ["branch", "--set-upstream-to=origin/feature/other-head"]); + const reread = yield* manager.branchPullRequest( + { cwd: repoDir, branch: "feature/moving-upstream" }, + { freshGitState: true }, + ); + expect(reread?.number).toBe(236); + + // T3's own git actions end in invalidateStatus. + yield* runGit(repoDir, ["branch", "--set-upstream-to=origin/feature/moving-upstream"]); + yield* manager.invalidateStatus(repoDir); + expect((yield* lookup)?.number).toBe(235); + + // A change made outside T3 shows once the remembered state expires. + yield* runGit(repoDir, ["branch", "--set-upstream-to=origin/feature/other-head"]); + yield* TestClock.adjust("5 minutes"); + expect((yield* lookup)?.number).toBe(236); + }), + ); + + it.effect("a git read in flight during a T3 git action does not outlive it", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/racing-upstream"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/racing-upstream"]); + yield* runGit(repoDir, ["push", "origin", "HEAD:refs/heads/feature/raced-head"]); + const pullRequest = (number: number, headRefName: string) => + encodeCliJson([ + { + number, + title: headRefName, + url: `https://github.com/pingdotgg/codething-mvp/pull/${number}`, + baseRefName: "main", + headRefName, + state: "OPEN", + updatedAt: "2026-04-07T15:00:00Z", + }, + ]); + const gate = { + matches: (args: ReadonlyArray) => + args[0] === "for-each-ref" && args.at(-1) === "refs/heads/feature/racing-upstream", + reached: yield* Deferred.make(), + release: yield* Deferred.make(), + }; + const { manager } = yield* makeManager({ + gitSpawnGate: gate, + ghScenario: { + prListByHeadSelector: { + "feature/racing-upstream": pullRequest(237, "feature/racing-upstream"), + "feature/raced-head": pullRequest(238, "feature/raced-head"), + }, + }, + }); + const lookup = manager.branchPullRequest({ cwd: repoDir, - branch: "feature/origin-once", + branch: "feature/racing-upstream", }); - expect(pullRequest?.number).toBe(217); - expect(gitConfigReads.filter((key) => key === "remote.origin.url")).toHaveLength(1); + // The branch read has exited with the old upstream; a T3 git action + // changes it before the lookup gets that output. + const inFlight = yield* Effect.forkChild(lookup); + yield* Deferred.await(gate.reached); + yield* runGit(repoDir, ["branch", "--set-upstream-to=origin/feature/raced-head"]); + yield* manager.invalidateStatus(repoDir); + yield* Deferred.succeed(gate.release, undefined); + yield* Fiber.join(inFlight); + + expect((yield* lookup)?.number).toBe(238); }), ); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 4a917e6783758eeee7d75dc8fafc6d2bd5f73fe0..08a41257ce4e9c32f0d68845e8946cd6041ad658 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1,6 +1,7 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Arr from "effect/Array"; import * as Cache from "effect/Cache"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as ByteSize from "effect/ByteSize"; @@ -131,10 +132,14 @@ export class GitManager extends Context.Service< input: VcsStatusInput, options?: GitRemoteStatusOptions, ) => Effect.Effect; - /** Resolve the PR for a saved branch without changing the current checkout. */ + /** + * Resolve the PR for a saved branch without changing the current checkout. + * `refresh` asks the host again. `freshGitState` rereads the branch's git + * state without forcing a host refresh; use it before persisting a result. + */ readonly branchPullRequest: ( input: { readonly cwd: string; readonly branch: string }, - options?: { readonly refresh?: boolean }, + options?: { readonly refresh?: boolean; readonly freshGitState?: boolean }, ) => Effect.Effect; readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; @@ -183,6 +188,10 @@ const PR_LOOKUP_NO_OPEN_PR_CACHE_TTL = Duration.minutes(5); const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20); const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15); const PR_LOOKUP_CACHE_CAPACITY = 2_048; +// How long branchPullRequest reuses the git state around a cached PR answer. +// Matches PR_LOOKUP_NO_OPEN_PR_CACHE_TTL, the delay already accepted for a +// change made outside the app. +const PR_LOOKUP_GIT_STATE_TTL = Duration.minutes(5); const isSourceControlProviderError = Schema.is(SourceControlProviderError); /** @@ -1491,20 +1500,6 @@ export const make = Effect.gen(function* () { { concurrency: "unbounded" }, ); - const resolvePrLookupRepositoryIdentity = Effect.fn("resolvePrLookupRepositoryIdentity")( - function* (cwd: string, branch: string, remoteNameOverride?: string) { - const remoteName = - remoteNameOverride ?? (yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)); - const [headRemote, targetRemote] = yield* resolveHeadAndOriginContexts(cwd, remoteName); - return { - remoteName, - headRemoteUrlKey: - headRemote.remoteUrlKey ?? (remoteName === null ? targetRemote.remoteUrlKey : null), - targetRemoteUrlKey: targetRemote.remoteUrlKey, - }; - }, - ); - const resolveBranchHeadContext = Effect.fn("resolveBranchHeadContext")(function* ( cwd: string, details: { branch: string; upstreamRef: string | null; remoteName?: string }, @@ -2357,32 +2352,134 @@ export const make = Effect.gen(function* () { }); return mergeGitStatusParts(local, remote); }); - const branchPullRequest: GitManager["Service"]["branchPullRequest"] = Effect.fn( - "branchPullRequest", - )(function* ({ cwd, branch }, options) { - const cacheCwd = yield* normalizeStatusCacheKey(cwd); - const remotes = yield* gitCore.execute({ - operation: "GitManager.branchPullRequest.remotes", - cwd: cacheCwd, - args: ["remote"], + // Discovery and the settlement sweep call branchPullRequest for every thread + // each minute, and most calls find the PR answer already cached. The git + // state around that answer (remotes, the branch's upstream, the remote HEAD, + // remote URLs) is remembered per checkout for PR_LOOKUP_GIT_STATE_TTL, so + // those calls start no git process. T3's own git actions (invalidateStatus) + // and `refresh` read it again, and a change made outside T3 shows within the + // TTL. A merged or closed answer can settle a thread or remove its worktree, + // so branchPullRequest confirms one against fresh reads, and discovery asks + // for fresh reads (`freshGitState`) before it persists an answer. + const prLookupGitStateByKey = new Map< + string, + { readonly readAtMs: number; readonly value: string | null } + >(); + const rememberPrLookupGitState = ( + cwd: string, + key: string, + fresh: boolean, + read: Effect.Effect, + ) => + Effect.gen(function* () { + const entryKey = `${cwd}\u0000${key}`; + const readAtMs = yield* Clock.currentTimeMillis; + const remembered = prLookupGitStateByKey.get(entryKey); + if ( + !fresh && + remembered !== undefined && + readAtMs - remembered.readAtMs < Duration.toMillis(PR_LOOKUP_GIT_STATE_TTL) + ) { + return remembered.value; + } + // invalidateStatus bumps the epoch. A read that started before a T3 git + // action, or before a newer read finished, must not replace their state. + const epoch = prLookupEpoch(cwd); + const value = yield* read; + const latest = prLookupGitStateByKey.get(entryKey); + if (prLookupEpoch(cwd) !== epoch || (latest !== undefined && latest.readAtMs > readAtMs)) { + return value; + } + prLookupGitStateByKey.delete(entryKey); + if (prLookupGitStateByKey.size >= PR_LOOKUP_CACHE_CAPACITY) { + const oldestKey = prLookupGitStateByKey.keys().next().value; + if (oldestKey !== undefined) prLookupGitStateByKey.delete(oldestKey); + } + prLookupGitStateByKey.set(entryKey, { readAtMs, value }); + return value; }); - const remoteNames = remotes.stdout + const forgetPrLookupGitState = (cwd: string) => + normalizeStatusCacheKey(cwd).pipe( + Effect.map((cacheCwd) => { + const prefix = `${cacheCwd}\u0000`; + for (const key of prLookupGitStateByKey.keys()) { + if (key.startsWith(prefix)) prLookupGitStateByKey.delete(key); + } + }), + ); + const resolvePrLookupRepositoryIdentity = Effect.fn("resolvePrLookupRepositoryIdentity")( + function* ( + cwd: string, + branch: string, + remoteNameOverride: string | undefined, + fresh: boolean, + ) { + const readConfig = (key: string) => + rememberPrLookupGitState( + cwd, + `config\u0000${key}`, + fresh, + gitCore.readConfigValue(cwd, key), + ).pipe(Effect.orElseSucceed(() => null)); + const readRemoteUrlKey = (remoteName: string | null) => + remoteName === null + ? Effect.succeed(null) + : readConfig(`remote.${remoteName}.url`).pipe( + Effect.map((remoteUrl) => (remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null)), + ); + const remoteName = remoteNameOverride ?? (yield* readConfig(`branch.${branch}.remote`)); + const [headRemoteUrlKey, targetRemoteUrlKey] = + remoteName === "origin" + ? yield* readRemoteUrlKey("origin").pipe(Effect.map((key) => [key, key] as const)) + : yield* Effect.all([readRemoteUrlKey(remoteName), readRemoteUrlKey("origin")], { + concurrency: "unbounded", + }); + return { + remoteName, + headRemoteUrlKey: headRemoteUrlKey ?? (remoteName === null ? targetRemoteUrlKey : null), + targetRemoteUrlKey, + }; + }, + ); + const lookupBranchPullRequest = Effect.fn("lookupBranchPullRequest")(function* ( + cacheCwd: string, + branch: string, + options: { readonly refresh: boolean; readonly freshGitState: boolean }, + ) { + const remember = (key: string, read: Effect.Effect) => + rememberPrLookupGitState(cacheCwd, key, options.freshGitState, read); + const remotes = yield* remember( + "remotes", + gitCore + .execute({ + operation: "GitManager.branchPullRequest.remotes", + cwd: cacheCwd, + args: ["remote"], + }) + .pipe(Effect.map((result) => result.stdout)), + ); + const remoteNames = (remotes ?? "") .split("\n") .map((remoteName) => remoteName.trim()) .filter((remoteName) => remoteName.length > 0); const [firstRemoteName] = remoteNames; if (firstRemoteName === undefined) return null; - const branchRef = yield* gitCore.execute({ - operation: "GitManager.branchPullRequest.branchRef", - cwd: cacheCwd, - args: [ - "for-each-ref", - "--format=%(refname)%00%(upstream:short)%00%(upstream:remotename)%00%(upstream:remoteref)", - `refs/heads/${branch}`, - ], - }); + const branchRef = yield* remember( + `branchRef\u0000${branch}`, + gitCore + .execute({ + operation: "GitManager.branchPullRequest.branchRef", + cwd: cacheCwd, + args: [ + "for-each-ref", + "--format=%(refname)%00%(upstream:short)%00%(upstream:remotename)%00%(upstream:remoteref)", + `refs/heads/${branch}`, + ], + }) + .pipe(Effect.map((result) => result.stdout)), + ); const expectedRefName = `refs/heads/${branch}`; - const exactBranch = branchRef.stdout + const exactBranch = (branchRef ?? "") .split("\n") .find((line) => line.split("\u0000", 1)[0] === expectedRefName); const [refName = "", savedUpstream = "", savedRemoteName = "", savedRemoteRef = ""] = @@ -2402,20 +2499,31 @@ export const make = Effect.gen(function* () { const upstreamBranch = savedRemoteRef.replace(/^refs\/heads\//, ""); upstreamRef = `${remoteName}/${upstreamBranch}`; } else if (!localBranchExists) { - const trackingRefs = yield* gitCore.execute({ - operation: "GitManager.branchPullRequest.remoteTrackingRefs", - cwd: cacheCwd, - args: ["for-each-ref", "--format=%(refname)", "refs/remotes"], - }); - const refNames = new Set( - trackingRefs.stdout - .split("\n") - .map((remoteRef) => remoteRef.trim()) - .filter((remoteRef) => remoteRef.length > 0), - ); - const matchingRemoteNames = remoteNames.filter((candidate) => - refNames.has(`refs/remotes/${candidate}/${branch}`), + const trackingRemotes = yield* remember( + `trackingRemotes\u0000${branch}`, + gitCore + .execute({ + operation: "GitManager.branchPullRequest.remoteTrackingRefs", + cwd: cacheCwd, + args: ["for-each-ref", "--format=%(refname)", "refs/remotes"], + }) + .pipe( + Effect.map((trackingRefs) => { + const refNames = new Set( + trackingRefs.stdout + .split("\n") + .map((remoteRef) => remoteRef.trim()) + .filter((remoteRef) => remoteRef.length > 0), + ); + return remoteNames + .filter((candidate) => refNames.has(`refs/remotes/${candidate}/${branch}`)) + .join("\n"); + }), + ), ); + const matchingRemoteNames = (trackingRemotes ?? "") + .split("\n") + .filter((candidate) => candidate.length > 0); if (matchingRemoteNames.length > 1) { return yield* new GitManagerError({ operation: "branchPullRequest", @@ -2429,9 +2537,10 @@ export const make = Effect.gen(function* () { } } const defaultRemoteName = remoteNames.includes("origin") ? "origin" : firstRemoteName; - const defaultBranch = yield* gitCore - .resolveDefaultBranchName(cacheCwd, defaultRemoteName) - .pipe(Effect.orElseSucceed(() => null)); + const defaultBranch = yield* remember( + `defaultBranch\u0000${defaultRemoteName}`, + gitCore.resolveDefaultBranchName(cacheCwd, defaultRemoteName), + ).pipe(Effect.orElseSucceed(() => null)); const cacheKey = prLookupCacheKey(cacheCwd, { branch, upstreamRef, @@ -2439,7 +2548,7 @@ export const make = Effect.gen(function* () { localBranchExists, ...(localBranchExists ? {} : { remoteName }), }); - if (options?.refresh) { + if (options.refresh) { // A completed turn can create a PR or reuse a merged PR's branch. // Refresh successful answers, but keep failed lookups' retry backoff. const cached = yield* Cache.getOption(prLookupCache, cacheKey).pipe( @@ -2457,6 +2566,7 @@ export const make = Effect.gen(function* () { cacheCwd, branch, identityRemoteName(cached.headContext), + options.freshGitState, ); const canVerifyIdentity = (headContext: BranchHeadContext, identity: typeof currentIdentity) => !( @@ -2480,6 +2590,7 @@ export const make = Effect.gen(function* () { cacheCwd, branch, identityRemoteName(cached.headContext), + true, ); if ( !canVerifyIdentity(cached.headContext, refreshedIdentity) || @@ -2511,6 +2622,22 @@ export const make = Effect.gen(function* () { repositoryKey: pullRequestRepositoryKey(latest.url), }; }); + const branchPullRequest: GitManager["Service"]["branchPullRequest"] = Effect.fn( + "branchPullRequest", + )(function* ({ cwd, branch }, options) { + const cacheCwd = yield* normalizeStatusCacheKey(cwd); + const refresh = options?.refresh === true; + const freshGitState = refresh || options?.freshGitState === true; + const pullRequest = yield* lookupBranchPullRequest(cacheCwd, branch, { + refresh, + freshGitState, + }); + if (pullRequest === null || pullRequest.state === "open" || freshGitState) return pullRequest; + return yield* lookupBranchPullRequest(cacheCwd, branch, { + refresh: false, + freshGitState: true, + }); + }); const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn( "invalidateLocalStatus", )(function* (cwd) { @@ -2529,6 +2656,7 @@ export const make = Effect.gen(function* () { // refresh); it also bypasses the slow PR-lookup cache. The periodic // status poll only invalidates local/remote and keeps the PR cache warm. yield* bumpPrLookupEpoch(cwd); + yield* forgetPrLookupGitState(cwd); }, ); diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index cd88d939777688e4c8f18b9ec4e15fcad8ff2616..7385f3ff5d25cf97ad6d591579751cfc531efc79 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -275,7 +275,10 @@ export const make = Effect.gen(function* () { if (updates.length === 0) return; if (detected !== null && first.branch !== null) { - const current = yield* git.branchPullRequest({ cwd, branch: first.branch }); + const current = yield* git.branchPullRequest( + { cwd, branch: first.branch }, + { freshGitState: true }, + ); const currentIdentity = yield* repositoryIdentities.resolve(project.workspaceRoot, { refresh: true, }); -- 2.54.0