Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
9 changes: 7 additions & 2 deletions apps/server/src/provider/Layers/AntigravityProvider.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
ANTIGRAVITY_DEFAULT_MODEL,
MIN_PROVIDER_HEALTH_REFRESH_INTERVAL,
ProviderDriverKind,
type AntigravitySettings,
type ProviderSetupError,
Expand All @@ -9,6 +10,7 @@ import {
} from "@t3tools/contracts";
import { createModelCapabilities } from "@t3tools/shared/model";
import * as DateTime from "effect/DateTime";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Predicate from "effect/Predicate";
Expand All @@ -32,7 +34,10 @@ import {

const EMPTY_MODEL_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] });
const MAX_WORKSPACE_SNAPSHOTS = 32;
const HEALTH_CHECK_TIMEOUT = "90 seconds";
// The probe spawns the full ACP server, so the refresh interval floor is
// defined as this timeout: a tick can never start while the previous probe's
// process may still be alive.
const HEALTH_CHECK_TIMEOUT = MIN_PROVIDER_HEALTH_REFRESH_INTERVAL;
const SIGN_IN_MESSAGE = "Sign in with Google to use Antigravity.";
const AUTH_UNCHECKED_MESSAGE =
"Antigravity is installed. Google account access is not checked yet.";
Expand Down Expand Up @@ -189,7 +194,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func
? "Antigravity is not installed or its executable could not be found."
: failure
? "Antigravity could not complete its local health check."
: `Antigravity did not respond to its local health check within ${HEALTH_CHECK_TIMEOUT}.`;
: `Antigravity did not respond to its local health check within ${Duration.toSeconds(HEALTH_CHECK_TIMEOUT)} seconds.`;
const supportsTextGeneration =
initialized !== undefined ? yield* options.supportsTextGeneration : false;
const updatedAt = DateTime.formatIso(yield* DateTime.now);
Expand Down
79 changes: 77 additions & 2 deletions apps/server/src/provider/makeManagedServerProvider.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, assert } from "@effect/vitest";
import {
DEFAULT_SERVER_SETTINGS,
MIN_PROVIDER_HEALTH_REFRESH_INTERVAL,
ProviderDriverKind,
ProviderInstanceId,
type ServerProvider,
Expand Down Expand Up @@ -329,13 +330,15 @@ describe("makeManagedServerProvider", () => {
yield* Deferred.await(initialCheckDone);
const nextServerSettings = {
...initialServerSettings,
providerHealthRefreshInterval: Duration.seconds(1),
providerHealthRefreshInterval: MIN_PROVIDER_HEALTH_REFRESH_INTERVAL,
};
yield* Ref.set(serverSettingsRef, nextServerSettings);
yield* PubSub.publish(serverSettingsChanges, nextServerSettings);
yield* Effect.yieldNow;

yield* TestClock.adjust("999 millis");
yield* TestClock.adjust(
Duration.subtract(MIN_PROVIDER_HEALTH_REFRESH_INTERVAL, Duration.millis(1)),
);
assert.strictEqual(yield* Ref.get(checkCalls), 1);
yield* TestClock.adjust("1 millis");
yield* Deferred.await(periodicCheckDone);
Expand Down Expand Up @@ -655,4 +658,76 @@ describe("makeManagedServerProvider", () => {
}),
).pipe(Effect.provide(AlwaysRunTestLayer)),
);

it.effect("floors a short settings interval and skips ticks while a probe is in flight", () =>
Effect.scoped(
Effect.gen(function* () {
const serverSettings = {
...DEFAULT_SERVER_SETTINGS,
backgroundActivity: {
schemaVersion: 1 as const,
profile: "custom" as const,
baseProfile: "balanced" as const,
overrides: { providerHealthRefreshInterval: Duration.seconds(30) },
},
};
const serverSettingsLayer = Layer.succeed(
ServerSettingsService,
ServerSettingsService.of({
start: Effect.void,
ready: Effect.void,
getSettings: Effect.succeed(serverSettings),
updateSettings: () => Effect.die(new Error("unused in this test")),
streamChanges: Stream.empty,
subscribeChanges: Effect.succeed(Stream.empty),
}),
);
const checkCalls = yield* Ref.make(0);
const firstCheckStarted = yield* Deferred.make<void>();
const releaseFirstCheck = yield* Deferred.make<void>();
const secondCheckDone = yield* Deferred.make<void>();

yield* makeManagedServerProvider<TestSettings>({
resolveMaintenance: () => Effect.succeed(maintenanceCapabilities),
getSettings: Effect.succeed({ enabled: true }),
streamSettings: Stream.empty,
haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
initialSnapshot: () => Effect.succeed(initialSnapshot),
checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe(
Effect.flatMap((count) =>
count === 1
? Deferred.succeed(firstCheckStarted, undefined).pipe(
Effect.andThen(Deferred.await(releaseFirstCheck)),
)
: Deferred.succeed(secondCheckDone, undefined).pipe(Effect.ignore),
),
Effect.as(refreshedSnapshot),
),
}).pipe(Effect.provide(Layer.merge(BackgroundPolicyAlwaysRunLayer, serverSettingsLayer)));

yield* Deferred.await(firstCheckStarted);

// The persisted 30s override is floored, so no tick fires at 30s.
yield* TestClock.adjust("30 seconds");
assert.strictEqual(yield* Ref.get(checkCalls), 1);

// The floored tick fires while the first probe is still running and is
// skipped rather than queued behind it.
yield* TestClock.adjust(
Duration.subtract(MIN_PROVIDER_HEALTH_REFRESH_INTERVAL, Duration.seconds(30)),
);
yield* Effect.yieldNow;
assert.strictEqual(yield* Ref.get(checkCalls), 1);

yield* Deferred.succeed(releaseFirstCheck, undefined);
yield* Effect.yieldNow;
assert.strictEqual(yield* Ref.get(checkCalls), 1);

// The next full interval after the skipped tick probes again.
yield* TestClock.adjust(MIN_PROVIDER_HEALTH_REFRESH_INTERVAL);
yield* Deferred.await(secondCheckDone);
assert.strictEqual(yield* Ref.get(checkCalls), 2);
}),
).pipe(Effect.provide(TestClock.layer())),
);
});
15 changes: 14 additions & 1 deletion apps/server/src/provider/makeManagedServerProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,19 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(
return yield* applySnapshot(nextSettings, { forceRefresh: true });
});

/**
* The periodic tick skips instead of queueing when a probe is already in
* flight. A probe that runs to its timeout would otherwise be followed by
* every tick that piled up behind the semaphore, each spawning the probe
* process again with nothing new to learn.
*/
const refreshSnapshotUnlessProbing = Effect.fn("refreshSnapshotUnlessProbing")(function* () {
const nextSettings = yield* input.getSettings;
yield* refreshSemaphore.withPermitsIfAvailable(1)(
applySnapshotBase(nextSettings, { forceRefresh: true }),
);
});

const hasProviderStatusDemand = Effect.gen(function* () {
const state = yield* Ref.get(snapshotStateRef);
const instanceId = state.snapshot.instanceId;
Expand Down Expand Up @@ -266,7 +279,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(
Duration.toMillis(Duration.fromInputUnsafe(refreshInterval)) > 0
? hasProviderStatusDemand.pipe(
Effect.flatMap((shouldRefresh) =>
shouldRefresh ? refreshSnapshot().pipe(Effect.asVoid) : Effect.void,
shouldRefresh ? refreshSnapshotUnlessProbing() : Effect.void,
),
)
: Effect.void,
Expand Down
10 changes: 7 additions & 3 deletions apps/web/src/components/settings/ProviderSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ import {
backgroundActivityOverrideSettings,
buildProviderInstanceUpdatePatch,
durationToSeconds,
normalizeIntervalSeconds,
normalizeProviderHealthIntervalSeconds,
PROVIDER_HEALTH_INTERVAL_MIN_SECONDS,
PROVIDER_HEALTH_INTERVAL_STEP_SECONDS,
} from "./SettingsPanels.logic";
import {
Expand Down Expand Up @@ -1104,7 +1105,7 @@ export function EnvironmentProviderSettings({
</PolicyTooltip>
</span>
}
description="Refresh provider status, versions, and models in the background. Set to 0 to disable."
description={`Refresh provider status, versions, and models in the background. Set to 0 to disable. Minimum ${PROVIDER_HEALTH_INTERVAL_MIN_SECONDS} seconds.`}
resetAction={
providerHealthRefreshIntervalSeconds !== defaultProviderHealthRefreshIntervalSeconds ? (
<span inert={readOnly} className={readOnly ? "opacity-50" : undefined}>
Expand Down Expand Up @@ -1145,7 +1146,10 @@ export function EnvironmentProviderSettings({
resolvedBackgroundActivity,
{
providerHealthRefreshInterval: Duration.seconds(
normalizeIntervalSeconds(value),
normalizeProviderHealthIntervalSeconds(
value,
providerHealthRefreshIntervalSeconds,
),
),
},
),
Expand Down
25 changes: 25 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ import {
getChangedTypographySettingLabels,
hasChangedBackgroundActivitySettings,
isProjectGroupingEnabled,
normalizeProviderHealthIntervalSeconds,
projectGroupingModeFromToggle,
PROVIDER_HEALTH_INTERVAL_MIN_SECONDS,
resolveBackgroundActivityProfileOption,
} from "./SettingsPanels.logic";

Expand Down Expand Up @@ -281,3 +283,26 @@ describe("getChangedBrowserSettingLabels", () => {
]);
});
});

describe("normalizeProviderHealthIntervalSeconds", () => {
const min = PROVIDER_HEALTH_INTERVAL_MIN_SECONDS;

it("keeps zero and values at or above the minimum", () => {
expect(normalizeProviderHealthIntervalSeconds(0, min)).toBe(0);
expect(normalizeProviderHealthIntervalSeconds(min, 0)).toBe(min);
expect(normalizeProviderHealthIntervalSeconds(600, min)).toBe(600);
});

it("jumps to disabled when stepping down from the minimum", () => {
expect(normalizeProviderHealthIntervalSeconds(min - 30, min)).toBe(0);
});

it("jumps to the minimum when stepping up from disabled", () => {
expect(normalizeProviderHealthIntervalSeconds(30, 0)).toBe(min);
});

it("treats an empty or negative value as disabled", () => {
expect(normalizeProviderHealthIntervalSeconds(null, min)).toBe(0);
expect(normalizeProviderHealthIntervalSeconds(-5, min)).toBe(0);
});
});
25 changes: 24 additions & 1 deletion apps/web/src/components/settings/SettingsPanels.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import type {
SidebarProjectGroupingMode,
UnifiedSettings,
} from "@t3tools/contracts";
import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
import {
DEFAULT_UNIFIED_SETTINGS,
MIN_PROVIDER_HEALTH_REFRESH_INTERVAL,
} from "@t3tools/contracts/settings";
import {
getBackgroundActivityBaseProfile,
normalizeBackgroundActivitySettings,
Expand Down Expand Up @@ -308,6 +311,26 @@ export function normalizeIntervalSeconds(value: number | null, minimum = 0): num
return Math.max(minimum, Math.round(value));
}

export const PROVIDER_HEALTH_INTERVAL_MIN_SECONDS = durationToSeconds(
MIN_PROVIDER_HEALTH_REFRESH_INTERVAL,
);

/**
* Zero disables probes and any other value must reach the minimum, so the
* steppers jump between 0 and the minimum instead of stalling in between. A
* value typed into the gap snaps in the direction the user was moving.
*/
export function normalizeProviderHealthIntervalSeconds(
value: number | null,
previousSeconds: number,
): number {
const seconds = normalizeIntervalSeconds(value);
if (seconds === 0 || seconds >= PROVIDER_HEALTH_INTERVAL_MIN_SECONDS) {
return seconds;
}
return seconds < previousSeconds ? 0 : PROVIDER_HEALTH_INTERVAL_MIN_SECONDS;
}

export function backgroundActivityOverrideSettings(
current: BackgroundActivitySettings,
resolved: ReturnType<typeof resolveServerBackgroundActivitySettings>,
Expand Down
10 changes: 8 additions & 2 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ import {
getChangedBrowserSettingLabels,
getChangedTypographySettingLabels,
normalizeIntervalSeconds,
normalizeProviderHealthIntervalSeconds,
PROVIDER_HEALTH_INTERVAL_MIN_SECONDS,
PROVIDER_HEALTH_INTERVAL_STEP_SECONDS,
hasChangedBackgroundActivitySettings,
isProjectGroupingEnabled,
Expand Down Expand Up @@ -983,7 +985,8 @@ function BackgroundActivityAdvancedDialog({
<div className="min-w-0 space-y-1">
<div className="text-sm font-medium">Provider health interval</div>
<p className="text-xs leading-relaxed text-muted-foreground">
Refresh provider availability, versions, auth state, and model metadata.
Refresh provider availability, versions, auth state, and model metadata. Set to 0
to disable. Minimum {PROVIDER_HEALTH_INTERVAL_MIN_SECONDS} seconds.
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
Expand All @@ -1000,7 +1003,10 @@ function BackgroundActivityAdvancedDialog({
resolvedBackgroundActivity,
{
providerHealthRefreshInterval: Duration.seconds(
normalizeIntervalSeconds(value),
normalizeProviderHealthIntervalSeconds(
value,
providerHealthRefreshIntervalSeconds,
),
),
},
),
Expand Down
7 changes: 7 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,13 @@ 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);
/**
* Shortest allowed gap between provider health probes. Zero still disables
* them. The slowest process-based probe (Antigravity spawns its full ACP
* server) uses this same value as its timeout, so a tick can never start
* while the previous probe's process may still be alive.
*/
export const MIN_PROVIDER_HEALTH_REFRESH_INTERVAL = Duration.seconds(90);

export const BackgroundActivityProfile = Schema.Literals([
"balanced",
Expand Down
Loading
Loading