Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 93 additions & 3 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<void>;
}

function fakeGhOutput(stdout: string): VcsProcess.VcsProcessOutput {
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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 })),
);
}

Expand Down Expand Up @@ -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<void>();
const releaseSlow = yield* Deferred.make<void>();
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-");
Expand Down
31 changes: 24 additions & 7 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
type SourceControlWritingStyleSettings,
type ThreadId,
} from "@t3tools/contracts";
import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
import {
hasProjectSettingsOverrides,
resolveProjectSettings,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 [
Expand All @@ -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.
Expand All @@ -1094,18 +1111,18 @@ 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 };
});
},
{
capacity: PR_LOOKUP_CACHE_CAPACITY,
timeToLive: (exit, key) => {
if (Exit.isSuccess(exit)) {
prLookupFailureStreakByKey.delete(key);
return PR_LOOKUP_CACHE_TTL;
return exit.value.ttl;
}
return nextPrLookupFailureTtl(key);
},
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
42 changes: 42 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -879,6 +879,9 @@ function BackgroundActivityAdvancedDialog({
const providerHealthRefreshIntervalSeconds = durationToSeconds(
resolvedBackgroundActivity.providerHealthRefreshInterval,
);
const pullRequestLookupIntervalMinutes = Math.round(
durationToSeconds(resolvedBackgroundActivity.pullRequestLookupInterval) / 60,
);
const hostPowerMonitorActiveIntervalSeconds = durationToSeconds(
resolvedBackgroundActivity.hostPowerMonitorActiveInterval,
);
Expand Down Expand Up @@ -979,6 +982,45 @@ function BackgroundActivityAdvancedDialog({
</div>
</div>

<div className="flex flex-col gap-3 border-b px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="text-sm font-medium">Pull request lookup interval</div>
<p className="text-xs leading-relaxed text-muted-foreground">
Ask the Git host again for a branch's pull request. Finished turns and refreshes
ask right away.
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<NumberField
value={pullRequestLookupIntervalMinutes}
min={1}
step={1}
size="sm"
className="w-32"
onValueChange={(value) =>
updateSettings(
backgroundActivityOverrideSettings(
settings.backgroundActivity,
resolvedBackgroundActivity,
{
pullRequestLookupInterval: Duration.minutes(
normalizeIntervalSeconds(value, 1),
),
},
),
)
}
>
<NumberFieldGroup>
<NumberFieldDecrement aria-label="Decrease pull request lookup interval" />
<NumberFieldInput aria-label="Pull request lookup interval in minutes" />
<NumberFieldIncrement aria-label="Increase pull request lookup interval" />
</NumberFieldGroup>
</NumberField>
<span className="text-xs text-muted-foreground">minutes</span>
</div>
</div>

<div className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="text-sm font-medium">Provider health interval</div>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,7 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/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",
],
},
{
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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),
Expand Down
13 changes: 13 additions & 0 deletions packages/shared/src/backgroundActivitySettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -26,6 +29,7 @@ const PRESET_SETTINGS: Record<BackgroundActivityProfile, ResolvedBackgroundActiv
profile: "performance",
automaticGitFetchInterval: Duration.seconds(15),
providerHealthRefreshInterval: Duration.minutes(1),
pullRequestLookupInterval: Duration.minutes(1),
hostPowerMonitorActiveInterval: Duration.seconds(30),
hostPowerMonitorIdleInterval: Duration.minutes(2),
idleClientTtl: Duration.seconds(45),
Expand All @@ -38,6 +42,7 @@ const PRESET_SETTINGS: Record<BackgroundActivityProfile, ResolvedBackgroundActiv
profile: "balanced",
automaticGitFetchInterval: DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL,
providerHealthRefreshInterval: DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL,
pullRequestLookupInterval: DEFAULT_PULL_REQUEST_LOOKUP_INTERVAL,
hostPowerMonitorActiveInterval: Duration.seconds(30),
hostPowerMonitorIdleInterval: Duration.minutes(5),
idleClientTtl: Duration.seconds(45),
Expand All @@ -50,6 +55,7 @@ const PRESET_SETTINGS: Record<BackgroundActivityProfile, ResolvedBackgroundActiv
profile: "battery-saver",
automaticGitFetchInterval: Duration.seconds(0),
providerHealthRefreshInterval: Duration.minutes(15),
pullRequestLookupInterval: Duration.minutes(15),
hostPowerMonitorActiveInterval: Duration.minutes(1),
hostPowerMonitorIdleInterval: Duration.minutes(10),
idleClientTtl: Duration.seconds(45),
Expand Down Expand Up @@ -87,6 +93,8 @@ export function resolveBackgroundActivitySettings(
overrides.automaticGitFetchInterval ?? preset.automaticGitFetchInterval,
providerHealthRefreshInterval:
overrides.providerHealthRefreshInterval ?? preset.providerHealthRefreshInterval,
pullRequestLookupInterval:
overrides.pullRequestLookupInterval ?? preset.pullRequestLookupInterval,
hostPowerMonitorActiveInterval:
overrides.hostPowerMonitorActiveInterval ?? preset.hostPowerMonitorActiveInterval,
hostPowerMonitorIdleInterval:
Expand All @@ -110,6 +118,7 @@ function resolvedSettingsEqual(
return (
durationsEqual(a.automaticGitFetchInterval, b.automaticGitFetchInterval) &&
durationsEqual(a.providerHealthRefreshInterval, b.providerHealthRefreshInterval) &&
durationsEqual(a.pullRequestLookupInterval, b.pullRequestLookupInterval) &&
durationsEqual(a.hostPowerMonitorActiveInterval, b.hostPowerMonitorActiveInterval) &&
durationsEqual(a.hostPowerMonitorIdleInterval, b.hostPowerMonitorIdleInterval) &&
durationsEqual(a.idleClientTtl, b.idleClientTtl) &&
Expand Down Expand Up @@ -160,6 +169,9 @@ export function normalizeBackgroundActivitySettings(
)
? { providerHealthRefreshInterval: resolved.providerHealthRefreshInterval }
: {}),
...(!durationsEqual(resolved.pullRequestLookupInterval, preset.pullRequestLookupInterval)
? { pullRequestLookupInterval: resolved.pullRequestLookupInterval }
: {}),
...(!durationsEqual(
resolved.hostPowerMonitorActiveInterval,
preset.hostPowerMonitorActiveInterval,
Expand Down Expand Up @@ -258,6 +270,7 @@ export function normalizeServerBackgroundActivitySettings(
overrides: {
automaticGitFetchInterval: resolved.automaticGitFetchInterval,
providerHealthRefreshInterval: resolved.providerHealthRefreshInterval,
pullRequestLookupInterval: resolved.pullRequestLookupInterval,
hostPowerMonitorActiveInterval: resolved.hostPowerMonitorActiveInterval,
hostPowerMonitorIdleInterval: resolved.hostPowerMonitorIdleInterval,
idleClientTtl: resolved.idleClientTtl,
Expand Down
Loading