diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 6837d849d779..db4971930d01 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -5,8 +5,11 @@ import * as NodeChildProcess from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; +import * as TestClock from "effect/testing/TestClock"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; @@ -74,6 +77,8 @@ interface FakeGhScenario { failWith?: GitHubCli.GitHubCliError; /** Let this many gh calls succeed before failWith kicks in (default 0 = fail immediately). */ failAfterCalls?: number; + /** Runs before a `pr list` answers, so a test can hold one lookup open. */ + beforePrList?: (headSelector: string) => Effect.Effect; } function fakeGhOutput(stdout: string): VcsProcess.VcsProcessOutput { @@ -396,7 +401,9 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { ? scenario.prListByHeadSelector?.[headSelector] : undefined; const stdout = (mappedQueue ?? mappedStdout ?? prListQueue.shift() ?? "[]") + "\n"; - return Effect.succeed(fakeGhOutput(stdout)); + return (scenario.beforePrList?.(headSelector ?? "") ?? Effect.void).pipe( + Effect.as(fakeGhOutput(stdout)), + ); } if (args[0] === "pr" && args[1] === "create") { @@ -703,9 +710,12 @@ function makeManager(input?: { serverSettingsLayer, ).pipe(Layer.provideMerge(sourceControlRegistryLayer), Layer.provideMerge(NodeServices.layer)); - return GitManager.make.pipe( + return Effect.all({ + manager: GitManager.make, + serverSettings: ServerSettings.ServerSettingsService, + }).pipe( Effect.provide(managerLayer), - Effect.map((manager) => ({ manager, ghCalls })), + Effect.map(({ manager, serverSettings }) => ({ manager, serverSettings, ghCalls })), ); } @@ -1066,6 +1076,86 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect.each([ + ["balanced", 5], + ["performance", 1], + ] as const)( + "asks the host for an unchanged branch once per %s lookup interval", + ([profile, minutes]) => + 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/lookup-interval"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/lookup-interval"]); + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { prListSequence: ["[]"] }, + serverSettings: { backgroundActivity: { profile } }, + }); + const lookups = () => ghCalls.filter((call) => call.startsWith("pr list ")).length; + const ask = manager.branchPullRequest({ cwd: repoDir, branch: "feature/lookup-interval" }); + + yield* ask; + yield* TestClock.adjust(Duration.seconds(minutes * 60 - 1)); + yield* ask; + expect(lookups()).toBe(1); + + yield* TestClock.adjust("2 seconds"); + yield* ask; + expect(lookups()).toBe(2); + + // Turn ends and explicit refreshes do not wait for the interval. + yield* manager.branchPullRequest( + { cwd: repoDir, branch: "feature/lookup-interval" }, + { refresh: true }, + ); + expect(lookups()).toBe(3); + }), + ); + + it.effect("keeps a lookup's own interval when another branch's lookup overlaps 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]); + for (const branch of ["feature/slow", "feature/fast"]) { + yield* runGit(repoDir, ["checkout", "-b", branch]); + yield* runGit(repoDir, ["push", "-u", "origin", branch]); + } + const slowStarted = yield* Deferred.make(); + const releaseSlow = yield* Deferred.make(); + const { manager, serverSettings, ghCalls } = yield* makeManager({ + ghScenario: { + beforePrList: (headSelector) => + headSelector.includes("slow") + ? Deferred.succeed(slowStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseSlow)), + ) + : Effect.void, + }, + serverSettings: { backgroundActivity: { profile: "balanced" } }, + }); + const askSlow = manager.branchPullRequest({ cwd: repoDir, branch: "feature/slow" }); + const slowLookups = () => ghCalls.filter((call) => call.includes("slow")).length; + + const slow = yield* Effect.forkChild(askSlow); + yield* Deferred.await(slowStarted); + // The interval changes and another branch is looked up while the first is still open. + yield* serverSettings.updateSettings({ backgroundActivity: { profile: "performance" } }); + yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature/fast" }); + yield* Deferred.succeed(releaseSlow, undefined); + yield* Fiber.join(slow); + const lookupsSoFar = slowLookups(); + + // Started under the five-minute interval, so two minutes later it is still fresh. + yield* TestClock.adjust("2 minutes"); + yield* askSlow; + expect(slowLookups()).toBe(lookupsSoFar); + }), + ); + it.effect("turn-end refresh preserves failed PR lookup backoff", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 033cc192ccfe..96f4a5f02dce 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -35,6 +35,7 @@ import { type SourceControlWritingStyleSettings, type ThreadId, } from "@t3tools/contracts"; +import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; import { hasProjectSettingsOverrides, resolveProjectSettings, @@ -137,9 +138,10 @@ const SHORT_SHA_LENGTH = 7; const TOAST_DESCRIPTION_MAX = 72; const STATUS_RESULT_CACHE_TTL = Duration.seconds(1); const STATUS_RESULT_CACHE_CAPACITY = 2_048; -// Matches the automatic settlement sweep cadence so every background sweep -// reads fresh branch state: an external merge settles within about a minute -// instead of waiting out a longer cache. Unpublished branches never reach the +// The shortest lifetime of a pull request lookup, and the cadence of the +// sweeps that read it. The Background Activity `pullRequestLookupInterval` +// can only lengthen it: at this floor an external merge settles within about a +// minute, at a longer interval within that interval. Unpublished branches never reach the // host (a local probe answers first), and failed lookups still back off // exponentially via prLookupFailureTtl, so throttling pressure still drops // under 429s instead of amplifying it. @@ -1065,6 +1067,20 @@ export const make = Effect.gen(function* () { prLookupFailureStreakByKey.set(key, streak); return prLookupFailureTtl(streak); }; + // Every background reader of a branch's pull request (thread discovery, + // settlement, status) goes through this cache, so its lifetime is the one + // place that bounds how often the hosting CLI runs for an unchanged branch. + // Each entry carries the interval it was looked up under; lookups of + // different branches overlap, so a shared value could come from another one. + const readPrLookupTtl = serverSettingsService.getSettings.pipe( + Effect.map((settings) => + Duration.max( + resolveServerBackgroundActivitySettings(settings).pullRequestLookupInterval, + PR_LOOKUP_CACHE_TTL, + ), + ), + Effect.orElseSucceed(() => PR_LOOKUP_CACHE_TTL), + ); const prLookupCache = yield* Cache.makeWith( (key: string) => { const [ @@ -1083,9 +1099,10 @@ export const make = Effect.gen(function* () { ...(remoteName.length > 0 ? { remoteName } : {}), }; return Effect.gen(function* () { + const ttl = yield* readPrLookupTtl; const { headContext, lookup } = yield* resolveLookupHeadContext(cwd, details); if (!lookup) { - return { latest: null, headContext }; + return { latest: null, headContext, ttl }; } // Only skip when the branch is untracked as well: anything carrying an // upstream keeps the old behaviour. @@ -1094,10 +1111,10 @@ export const make = Effect.gen(function* () { details.upstreamRef === null && (yield* isUnpublishedBranch(cwd, headContext)) ) { - return { latest: null, headContext }; + return { latest: null, headContext, ttl }; } const latest = yield* findLatestPrForHeadContext(cwd, headContext); - return { latest, headContext }; + return { latest, headContext, ttl }; }); }, { @@ -1105,7 +1122,7 @@ export const make = Effect.gen(function* () { timeToLive: (exit, key) => { if (Exit.isSuccess(exit)) { prLookupFailureStreakByKey.delete(key); - return PR_LOOKUP_CACHE_TTL; + return exit.value.ttl; } return nextPrLookupFailureTtl(key); }, diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index 5cbcb190a97b..3d8cf49ae9b4 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -177,6 +177,7 @@ export function resolveBackgroundActivityProfileOption( overrides: { automaticGitFetchInterval: resolved.automaticGitFetchInterval, providerHealthRefreshInterval: resolved.providerHealthRefreshInterval, + pullRequestLookupInterval: resolved.pullRequestLookupInterval, hostPowerMonitorActiveInterval: resolved.hostPowerMonitorActiveInterval, hostPowerMonitorIdleInterval: resolved.hostPowerMonitorIdleInterval, idleClientTtl: resolved.idleClientTtl, @@ -316,6 +317,7 @@ export function backgroundActivityOverrideSettings( const nextOverrides: BackgroundActivityOverridePatch = { automaticGitFetchInterval: resolved.automaticGitFetchInterval, providerHealthRefreshInterval: resolved.providerHealthRefreshInterval, + pullRequestLookupInterval: resolved.pullRequestLookupInterval, hostPowerMonitorActiveInterval: resolved.hostPowerMonitorActiveInterval, hostPowerMonitorIdleInterval: resolved.hostPowerMonitorIdleInterval, idleClientTtl: resolved.idleClientTtl, diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 85e1de588f94..eab5ac0e8bbc 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -879,6 +879,9 @@ function BackgroundActivityAdvancedDialog({ const providerHealthRefreshIntervalSeconds = durationToSeconds( resolvedBackgroundActivity.providerHealthRefreshInterval, ); + const pullRequestLookupIntervalMinutes = Math.round( + durationToSeconds(resolvedBackgroundActivity.pullRequestLookupInterval) / 60, + ); const hostPowerMonitorActiveIntervalSeconds = durationToSeconds( resolvedBackgroundActivity.hostPowerMonitorActiveInterval, ); @@ -979,6 +982,45 @@ function BackgroundActivityAdvancedDialog({ +
+
+
Pull request lookup interval
+

+ Ask the Git host again for a branch's pull request. Finished turns and refreshes + ask right away. +

+
+
+ + updateSettings( + backgroundActivityOverrideSettings( + settings.backgroundActivity, + resolvedBackgroundActivity, + { + pullRequestLookupInterval: Duration.minutes( + normalizeIntervalSeconds(value, 1), + ), + }, + ), + ) + } + > + + + + + + + minutes +
+
+
Provider health interval
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 4532bd5f72fb..20ac4073b4d3 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -391,7 +391,7 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "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/settings/general", scope: "environment-defaults", searchTerms: [ - "balanced performance battery saver advanced git fetch provider health refresh host power monitor idle policy", + "balanced performance battery saver advanced git fetch pull request lookup provider health refresh host power monitor idle policy", ], }, { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 42e91a76ecaf..9b1b35acc295 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -913,6 +913,7 @@ export type SourceControlWritingStyleSettings = typeof SourceControlWritingStyle export const DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL = Duration.seconds(30); export const DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL = Duration.minutes(5); +export const DEFAULT_PULL_REQUEST_LOOKUP_INTERVAL = Duration.minutes(5); export const BackgroundActivityProfile = Schema.Literals([ "balanced", @@ -933,6 +934,7 @@ export type BackgroundActivityProfileSelection = typeof BackgroundActivityProfil export const BackgroundActivityOverrides = Schema.Struct({ automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis), providerHealthRefreshInterval: Schema.optionalKey(Schema.DurationFromMillis), + pullRequestLookupInterval: Schema.optionalKey(Schema.DurationFromMillis), hostPowerMonitorActiveInterval: Schema.optionalKey(Schema.DurationFromMillis), hostPowerMonitorIdleInterval: Schema.optionalKey(Schema.DurationFromMillis), idleClientTtl: Schema.optionalKey(Schema.DurationFromMillis), diff --git a/packages/shared/src/backgroundActivitySettings.ts b/packages/shared/src/backgroundActivitySettings.ts index fbba3359c460..d709b24d2166 100644 --- a/packages/shared/src/backgroundActivitySettings.ts +++ b/packages/shared/src/backgroundActivitySettings.ts @@ -4,6 +4,7 @@ import { DEFAULT_BACKGROUND_ACTIVITY_PROFILE, DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL, + DEFAULT_PULL_REQUEST_LOOKUP_INTERVAL, type ServerSettings, } from "@t3tools/contracts"; import * as Duration from "effect/Duration"; @@ -12,6 +13,8 @@ export interface ResolvedBackgroundActivitySettings { readonly profile: BackgroundActivityProfile; readonly automaticGitFetchInterval: Duration.Duration; readonly providerHealthRefreshInterval: Duration.Duration; + /** How long a branch keeps its last pull request lookup before the hosting CLI is asked again. */ + readonly pullRequestLookupInterval: Duration.Duration; readonly hostPowerMonitorActiveInterval: Duration.Duration; readonly hostPowerMonitorIdleInterval: Duration.Duration; readonly idleClientTtl: Duration.Duration; @@ -26,6 +29,7 @@ const PRESET_SETTINGS: Record