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
21 changes: 7 additions & 14 deletions apps/server/src/provider/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
providerModelsFromSettings,
spawnAndCollect,
type ServerProviderDraft,
ProviderProbeTimeoutError,
} from "@t3tools/provider-core/server/snapshotProbe";
import { resolveClaudeSdkExecutablePath } from "./Drivers/ClaudeExecutable.ts";
import { makeClaudeEnvironment } from "./Drivers/ClaudeHome.ts";
Expand Down Expand Up @@ -461,7 +462,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
resolveResetCredits?: (version: string) => Effect.Effect<ServerProviderResetCredits | undefined>,
): Effect.fn.Return<
ServerProviderDraft,
never,
ProviderProbeTimeoutError,
ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
> {
const resolvedEnvironment = environment ?? process.env;
Expand Down Expand Up @@ -517,19 +518,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
}

if (Option.isNone(versionProbe.success)) {
return buildServerProvider({
presentation: CLAUDE_PRESENTATION,
enabled: claudeSettings.enabled,
checkedAt,
models: allModels,
probe: {
installed: true,
version: null,
status: "error",
auth: { status: "unknown" },
message:
"Claude Agent CLI is installed but failed to run. Timed out while running command.",
},
return yield* new ProviderProbeTimeoutError({
provider: "Claude Agent",
probe: "version",
timeoutMs: DEFAULT_TIMEOUT_MS,
installed: true,
});
}

Expand Down
21 changes: 7 additions & 14 deletions apps/server/src/provider/CodexProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
buildServerProvider,
COMPACT_SLASH_COMMAND,
type ServerProviderDraft,
ProviderProbeTimeoutError,
} from "@t3tools/provider-core/server/snapshotProbe";
import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits";
Expand Down Expand Up @@ -584,7 +585,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
managedAuth?: ServerProvider["auth"],
): Effect.fn.Return<
ServerProviderDraft,
ServerSettingsError,
ServerSettingsError | ProviderProbeTimeoutError,
ChildProcessSpawner.ChildProcessSpawner
> {
const resolvedEnvironment = environment ?? process.env;
Expand Down Expand Up @@ -647,19 +648,11 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
}

if (Option.isNone(probeResult.success)) {
return buildServerProvider({
presentation: CODEX_PRESENTATION,
enabled: codexSettings.enabled,
checkedAt,
models: emptyModels,
skills: [],
probe: {
installed: true,
version: null,
status: "error",
auth: { status: "unknown" },
message: "Timed out while checking Codex app-server provider status.",
},
return yield* new ProviderProbeTimeoutError({
provider: "Codex",
probe: "app-server status",
timeoutMs: AUTH_PROBE_TIMEOUT_MS,
installed: true,
});
}

Expand Down
36 changes: 36 additions & 0 deletions apps/server/src/provider/CursorProvider.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { SDKModel } from "@cursor/sdk";
import { describe, expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Deferred from "effect/Deferred";
import * as Fiber from "effect/Fiber";
import { TestClock } from "effect/testing";
import * as Schema from "effect/Schema";
import type { CursorSettings } from "@t3tools/contracts";
import { CursorSettings as CursorSettingsSchema } from "@t3tools/contracts";
Expand Down Expand Up @@ -206,6 +209,39 @@ describe("checkCursorProviderStatus", () => {
}),
);

it.effect("reports a catalog deadline without declaring the provider broken", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>();
const stopped = yield* Deferred.make<void>();
const statusFiber = yield* checkCursorProviderStatus(baseCursorSettings, {
CURSOR_API_KEY: "test-cursor-key",
}).pipe(
Effect.provide(
CursorSdkCatalog.layerTest(() =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Effect.never),
Effect.ensuring(Deferred.succeed(stopped, undefined)),
),
),
),
Effect.flip,
Effect.forkChild,
);

yield* Deferred.await(started);
yield* TestClock.adjust("15 seconds");
const timeout = yield* Fiber.join(statusFiber);
expect(timeout).toMatchObject({
_tag: "ProviderProbeTimeoutError",
provider: "Cursor",
probe: "SDK catalog",
timeoutMs: 15_000,
installed: true,
});
yield* Deferred.await(stopped);
}),
);

it.effect("surfaces SDK authentication failures", () =>
Effect.gen(function* () {
const provider = yield* checkCursorProviderStatus(baseCursorSettings, {
Expand Down
24 changes: 11 additions & 13 deletions apps/server/src/provider/CursorProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
buildServerProvider,
providerModelsFromSettings,
type ServerProviderDraft,
ProviderProbeTimeoutError,
} from "@t3tools/provider-core/server/snapshotProbe";
import * as CursorSdkCatalog from "./CursorSdkCatalog.ts";

Expand Down Expand Up @@ -250,7 +251,11 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(
cursorSettings: CursorSettings,
environment?: NodeJS.ProcessEnv,
authenticationType: "api-key" | "browser" = "api-key",
): Effect.fn.Return<ServerProviderDraft, never, CursorSdkCatalog.CursorSdkCatalog> {
): Effect.fn.Return<
ServerProviderDraft,
ProviderProbeTimeoutError,
CursorSdkCatalog.CursorSdkCatalog
> {
const checkedAt = DateTime.formatIso(yield* DateTime.now);
const fallbackModels = getCursorFallbackModels(cursorSettings);

Expand Down Expand Up @@ -317,18 +322,11 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(
}

if (Option.isNone(catalogResult.success)) {
return buildServerProvider({
presentation: CURSOR_PRESENTATION,
enabled: cursorSettings.enabled,
checkedAt,
models: fallbackModels,
probe: {
installed: true,
version: null,
status: "error",
auth: { status: "unknown" },
message: `Cursor SDK catalog request timed out after ${CURSOR_SDK_CATALOG_TIMEOUT_MS}ms.`,
},
return yield* new ProviderProbeTimeoutError({
provider: "Cursor",
probe: "SDK catalog",
timeoutMs: CURSOR_SDK_CATALOG_TIMEOUT_MS,
installed: true,
});
}

Expand Down
20 changes: 7 additions & 13 deletions apps/server/src/provider/GrokProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
providerModelsFromSettings,
spawnAndCollect,
type ServerProviderDraft,
ProviderProbeTimeoutError,
} from "@t3tools/provider-core/server/snapshotProbe";
import {
enrichProviderSnapshotWithVersionAdvisory,
Expand Down Expand Up @@ -376,7 +377,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
cwd?: string,
): Effect.fn.Return<
ServerProviderDraft,
never,
ProviderProbeTimeoutError,
ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto
> {
const checkedAt = DateTime.formatIso(yield* DateTime.now);
Expand Down Expand Up @@ -426,18 +427,11 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
}

if (Option.isNone(versionResult.success)) {
return buildServerProvider({
presentation: GROK_PRESENTATION,
enabled: grokSettings.enabled,
checkedAt,
models: fallbackModels,
probe: {
installed: true,
version: null,
status: "error",
auth: { status: "unknown" },
message: "Grok CLI is installed but timed out while running `grok --version`.",
},
return yield* new ProviderProbeTimeoutError({
provider: "Grok",
probe: "grok --version",
timeoutMs: VERSION_PROBE_TIMEOUT_MS,
installed: true,
});
}

Expand Down
39 changes: 36 additions & 3 deletions apps/server/src/provider/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as PubSub from "effect/PubSub";
import * as Ref from "effect/Ref";
import * as Result from "effect/Result";
import * as Schema from "effect/Schema";
import * as Scope from "effect/Scope";
import * as Sink from "effect/Sink";
Expand All @@ -22,6 +23,7 @@ import {
EnvironmentId,
ClaudeSettings,
CodexSettings,
GrokSettings,
DEFAULT_SERVER_SETTINGS,
PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS,
ProviderDriverKind,
Expand All @@ -40,6 +42,7 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";

import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts";
import { checkClaudeProviderStatus } from "./ClaudeProvider.ts";
import { checkGrokProviderStatus } from "./GrokProvider.ts";
import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
import * as AntigravityInstallation from "./AntigravityInstallation.ts";
import * as ModelManifest from "./ModelManifest.ts";
Expand Down Expand Up @@ -80,6 +83,7 @@ const disabledDefaultSlots = (
);

const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({});
const defaultGrokSettings: GrokSettings = Schema.decodeSync(GrokSettings)({ enabled: true });
const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({});
const decodeCodexSettings = Schema.decodeSync(CodexSettings);
const disabledCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({
Expand Down Expand Up @@ -597,6 +601,7 @@ it.layer(
const killCalls = yield* Ref.make(0);
const statusFiber = yield* checkCodexProviderStatus(defaultCodexSettings).pipe(
Effect.provide(layerHangingScopedSpawner(killCalls)),
Effect.result,
Effect.forkChild,
);

Expand All @@ -605,16 +610,44 @@ it.layer(
yield* Effect.yieldNow;

const status = yield* Fiber.join(statusFiber);
assert.strictEqual(status.status, "error");
assert.strictEqual(Result.isFailure(status), true);
assert.strictEqual(
status.message,
"Timed out while checking Codex app-server provider status.",
Result.isFailure(status) ? status.failure._tag : null,
"ProviderProbeTimeoutError",
);
assert.strictEqual(yield* Ref.get(killCalls), 1);
}),
);
});

it.effect.each(["Claude Agent", "Grok"] as const)(
"closes the %s version probe scope on timeout",
(provider) =>
Effect.gen(function* () {
const killCalls = yield* Ref.make(0);
const check = Effect.gen(function* () {
if (provider === "Grok") {
return yield* checkGrokProviderStatus(defaultGrokSettings);
}
return yield* checkClaudeProviderStatus(defaultClaudeSettings);
});
const statusFiber = yield* check.pipe(
Effect.provide(layerHangingScopedSpawner(killCalls)),
Effect.flip,
Effect.forkChild,
);

yield* Effect.yieldNow;
yield* TestClock.adjust("4 seconds");
const timeout = yield* Fiber.join(statusFiber);
assert.strictEqual(timeout._tag, "ProviderProbeTimeoutError");
assert.strictEqual(timeout.provider, provider);
assert.strictEqual(timeout.timeoutMs, 4_000);
assert.strictEqual(timeout.installed, true);
assert.strictEqual(yield* Ref.get(killCalls), 1);
}),
);

describe("ProviderRegistry.layer", () => {
it("stores workspace skills and commands without changing machine metadata", () => {
const provider = {
Expand Down
12 changes: 12 additions & 0 deletions docs/internals/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ client connections and provider-instance rebuilds. Releases are immutable, with
selecting the version for new processes. Running processes hold leases on their version. Updates
and removal must respect those leases instead of replacing executables under a running agent.

## Status probe deadlines

A missed status deadline does not establish that a provider is broken. Checks that would
otherwise publish a failure verdict should raise
[`ProviderProbeTimeoutError`](../../packages/provider-core/src/server/snapshotProbe.ts).
The [managed provider](../../packages/provider-core/src/server/managedProvider.ts) carries
forward the last usable status and marks the check stale. Publishing an error instead would
persist that guess in the per-instance cache and hide a working provider after a restart.
Before any usable check, report the timeout plainly; definite failures such as a missing
executable or rejected credentials still replace the previous status. Metadata-only probes
that already degrade without marking the provider broken can keep that behavior.

## Setup must not happen as a health-check side effect

Opening a provider session can start MCP servers, run hooks, or launch a login browser.
Expand Down
Loading
Loading