Skip to content
Merged
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
25 changes: 25 additions & 0 deletions apps/server/src/provider/Layers/ProviderHealth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ function failingSpawnerLayer(description: string) {
);
}

function dyingSpawnerLayer(error: unknown) {
return Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() => Effect.die(error)),
);
}

/**
* Create a temporary CODEX_HOME scoped to the current Effect test.
* Cleanup is registered in the test scope rather than via Vitest hooks.
Expand Down Expand Up @@ -957,6 +964,24 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => {
}).pipe(Effect.provide(failingSpawnerLayer("spawn cursor-agent ENOENT"))),
);

it.effect("returns unavailable when Cursor Agent spawn dies", () =>
Effect.gen(function* () {
const status = yield* checkCursorProviderStatus;
assert.strictEqual(status.provider, "cursor");
assert.strictEqual(status.status, "error");
assert.strictEqual(status.available, false);
assert.strictEqual(status.authStatus, "unknown");
assert.strictEqual(
status.message,
'Failed to execute Cursor Agent CLI health check: Executable not found in $PATH: "cursor-agent".',
);
}).pipe(
Effect.provide(
dyingSpawnerLayer(new TypeError('Executable not found in $PATH: "cursor-agent"')),
),
),
);

it.effect("returns unavailable when Cursor Agent exits with an error", () =>
Effect.gen(function* () {
const status = yield* checkCursorProviderStatus;
Expand Down
62 changes: 29 additions & 33 deletions apps/server/src/provider/Layers/ProviderHealth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
import {
Array,
Cache,
Cause,
DateTime,
Duration,
Effect,
Expand Down Expand Up @@ -734,6 +735,17 @@ const runCodexCommand = (
),
);

const runCommandHealthProbe = <R>(effect: Effect.Effect<CommandResult, unknown, R>) =>
effect.pipe(
Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
Effect.exit,
Effect.map((exit) =>
Exit.isSuccess(exit)
? Result.succeed(exit.value)
: Result.fail(Cause.squash(exit.cause)),
),
);

const runClaudeCommand = (args: ReadonlyArray<string>, executable = "claude") =>
runProviderCommand(executable, args).pipe(
Effect.flatMap((result) =>
Expand Down Expand Up @@ -843,9 +855,8 @@ export const makeCheckCodexProviderStatus = (
const probeEnv = makeCodexProbeEnv(homePath);

// Probe 1: `codex --version` — is the CLI reachable?
const versionProbe = yield* runCodexCommand(["--version"], executable, probeEnv).pipe(
Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
Effect.result,
const versionProbe = yield* runCommandHealthProbe(
runCodexCommand(["--version"], executable, probeEnv),
);

if (Result.isFailure(versionProbe)) {
Expand Down Expand Up @@ -918,9 +929,8 @@ export const makeCheckCodexProviderStatus = (
} satisfies ServerProviderStatus;
}

const authProbe = yield* runCodexCommand(["login", "status"], executable, probeEnv).pipe(
Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
Effect.result,
const authProbe = yield* runCommandHealthProbe(
runCodexCommand(["login", "status"], executable, probeEnv),
);

if (Result.isFailure(authProbe)) {
Expand Down Expand Up @@ -1077,9 +1087,8 @@ export const makeCheckClaudeProviderStatus = (
const executable = nonEmptyTrimmed(binaryPath) ?? "claude";

// Probe 1: `claude --version` — is the CLI reachable?
const versionProbe = yield* runClaudeCommand(["--version"], executable).pipe(
Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
Effect.result,
const versionProbe = yield* runCommandHealthProbe(
runClaudeCommand(["--version"], executable),
);

if (Result.isFailure(versionProbe)) {
Expand Down Expand Up @@ -1125,9 +1134,8 @@ export const makeCheckClaudeProviderStatus = (
const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`);

// Probe 2: `claude auth status` — is the user authenticated?
const authProbe = yield* runClaudeCommand(["auth", "status"], executable).pipe(
Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
Effect.result,
const authProbe = yield* runCommandHealthProbe(
runClaudeCommand(["auth", "status"], executable),
);

if (Result.isFailure(authProbe)) {
Expand Down Expand Up @@ -1194,9 +1202,8 @@ export const makeCheckGeminiProviderStatus = (
const checkedAt = new Date().toISOString();
const executable = nonEmptyTrimmed(binaryPath) ?? "gemini";

const versionProbe = yield* runGeminiCommand(["--version"], executable).pipe(
Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
Effect.result,
const versionProbe = yield* runCommandHealthProbe(
runGeminiCommand(["--version"], executable),
);

if (Result.isFailure(versionProbe)) {
Expand Down Expand Up @@ -1284,10 +1291,7 @@ export const makeCheckGrokProviderStatus = (
const checkedAt = new Date().toISOString();
const executable = nonEmptyTrimmed(binaryPath) ?? "grok";

const versionProbe = yield* runGrokCommand(["--version"], executable).pipe(
Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
Effect.result,
);
const versionProbe = yield* runCommandHealthProbe(runGrokCommand(["--version"], executable));

if (Result.isFailure(versionProbe)) {
const error = versionProbe.failure;
Expand Down Expand Up @@ -1358,9 +1362,8 @@ export const makeCheckOpenCodeProviderStatus = (
const checkedAt = new Date().toISOString();
const executable = nonEmptyTrimmed(binaryPath) ?? "opencode";

const versionProbe = yield* runOpenCodeCommand(["--version"], executable).pipe(
Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
Effect.result,
const versionProbe = yield* runCommandHealthProbe(
runOpenCodeCommand(["--version"], executable),
);

if (Result.isFailure(versionProbe)) {
Expand Down Expand Up @@ -1427,10 +1430,7 @@ export const makeCheckKiloProviderStatus = (
const checkedAt = new Date().toISOString();
const executable = nonEmptyTrimmed(binaryPath) ?? "kilo";

const versionProbe = yield* runKiloCommand(["--version"], executable).pipe(
Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
Effect.result,
);
const versionProbe = yield* runCommandHealthProbe(runKiloCommand(["--version"], executable));

if (Result.isFailure(versionProbe)) {
const error = versionProbe.failure;
Expand Down Expand Up @@ -1495,10 +1495,7 @@ export const checkPiProviderStatus = (
Effect.gen(function* () {
const checkedAt = new Date().toISOString();
const executable = nonEmptyTrimmed(binaryPath) ?? "pi";
const versionProbe = yield* runPiCommand(["--version"], executable).pipe(
Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
Effect.result,
);
const versionProbe = yield* runCommandHealthProbe(runPiCommand(["--version"], executable));
const version =
Result.isSuccess(versionProbe) && Option.isSome(versionProbe.success)
? versionProbe.success.value
Expand Down Expand Up @@ -1554,9 +1551,8 @@ export const makeCheckCursorProviderStatus = (
const checkedAt = new Date().toISOString();
const executable = resolveCursorAgentBinaryPath(nonEmptyTrimmed(binaryPath));

const versionProbe = yield* runCursorCommand(["--version"], executable).pipe(
Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
Effect.result,
const versionProbe = yield* runCommandHealthProbe(
runCursorCommand(["--version"], executable),
);

if (Result.isFailure(versionProbe)) {
Expand Down