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
2 changes: 1 addition & 1 deletion apps/server/src/provider/Drivers/ClaudeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
haveSettingsChanged: () => false,
initialSnapshot: (settings) =>
makePendingClaudeProvider(settings).pipe(Effect.map(stampIdentity)),
checkProvider,
checkProvider: () => checkProvider,
enrichSnapshot: ({ snapshot, publishSnapshot }) =>
enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe(
Effect.provideService(HttpClient.HttpClient, httpClient),
Expand Down
15 changes: 11 additions & 4 deletions apps/server/src/provider/Drivers/CodexDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const httpClient = yield* HttpClient.HttpClient;
const eventLoggers = yield* ProviderEventLoggers;
const serverConfig = yield* ServerConfig;
const processEnv = mergeProviderInstanceEnvironment(environment);
const homeLayout = yield* resolveCodexHomeLayout(config);
const continuationIdentity = codexContinuationIdentity(homeLayout);
Expand Down Expand Up @@ -159,10 +160,16 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
// in as instance rebuilds from the registry rather than in-place
// updates. Pre-provide `ChildProcessSpawner` so the check fits
// `makeManagedServerProvider.checkProvider`'s `R = never`.
const checkProvider = checkCodexProviderStatus(effectiveConfig, undefined, processEnv).pipe(
Effect.map(stampIdentity),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
);
const checkProvider = (refreshInput?: { readonly cwd?: string | undefined }) =>
checkCodexProviderStatus(
effectiveConfig,
undefined,
processEnv,
refreshInput?.cwd ?? serverConfig.cwd,
).pipe(
Effect.map(stampIdentity),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
);
const snapshot = yield* makeManagedServerProvider<CodexSettings>({
maintenanceCapabilities,
getSettings: Effect.succeed(effectiveConfig),
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/provider/Drivers/CursorDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
haveSettingsChanged: () => false,
initialSnapshot: (settings) =>
buildInitialCursorProviderSnapshot(settings).pipe(Effect.map(stampIdentity)),
checkProvider,
checkProvider: () => checkProvider,
// Model catalog and capabilities come exclusively from Cursor's
// list_available_models extension method during provider checks.
enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) =>
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/provider/Drivers/OpenCodeDriver.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low

const checkProvider = checkOpenCodeProviderStatus(

The checkProvider function at line 152 ignores the input parameter, so any cwd override passed during refresh is discarded. The interface expects checkProvider: (input?: ProviderSnapshotRefreshInput) => Effect.Effect<...> where input?.cwd can override the working directory, but the current implementation captures serverConfig.cwd at instance creation time and discards runtime cwd values. This breaks the refresh contract when a different working directory is requested.

🤖 Copy this AI Prompt to have your agent fix this:
In file apps/server/src/provider/Drivers/OpenCodeDriver.ts around line 139:

The `checkProvider` function at line 152 ignores the `input` parameter, so any `cwd` override passed during refresh is discarded. The interface expects `checkProvider: (input?: ProviderSnapshotRefreshInput) => Effect.Effect<...>` where `input?.cwd` can override the working directory, but the current implementation captures `serverConfig.cwd` at instance creation time and discards runtime `cwd` values. This breaks the refresh contract when a different working directory is requested.

Evidence trail:
- OpenCodeDriver.ts:139-143 — `checkProvider` created with `serverConfig.cwd` baked in
- OpenCodeDriver.ts:152 — `checkProvider: () => checkProvider` ignores input parameter
- makeManagedServerProvider.ts:31-33 — interface expects `(input?: ProviderSnapshotRefreshInput) => Effect.Effect<...>`
- makeManagedServerProvider.ts:130-131 — `resolveRefreshInput` result passed to `input.checkProvider(refreshInput)`
- ServerProvider.ts:6-8 — `ProviderSnapshotRefreshInput` has `readonly cwd?: string | undefined`
- CodexDriver.ts:163-168 — peer driver properly handles cwd override: `refreshInput?.cwd ?? serverConfig.cwd`
- OpenCodeProvider.ts:301-303 — `checkOpenCodeProviderStatus` accepts a `cwd` parameter, confirming relevance

Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
haveSettingsChanged: () => false,
initialSnapshot: (settings) =>
makePendingOpenCodeProvider(settings).pipe(Effect.map(stampIdentity)),
checkProvider,
checkProvider: () => checkProvider,
enrichSnapshot: ({ snapshot, publishSnapshot }) =>
enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe(
Effect.provideService(HttpClient.HttpClient, httpClient),
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/provider/Layers/CodexProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
ChildProcessSpawner.ChildProcessSpawner | Scope.Scope
> = probeCodexAppServerProvider,
environment: NodeJS.ProcessEnv = process.env,
cwd: string = process.cwd(),
): Effect.fn.Return<
ServerProviderDraft,
ServerSettingsError,
Expand Down Expand Up @@ -446,7 +447,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
const probeResult = yield* probe({
binaryPath: codexSettings.binaryPath,
homePath: codexSettings.homePath,
cwd: process.cwd(),
cwd,
customModels: codexSettings.customModels,
environment,
}).pipe(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ const makeFakeInstance = (
packageName: null,
}),
getSnapshot: Effect.succeed({} as unknown as ServerProvider),
refresh: Effect.succeed({} as unknown as ServerProvider),
refresh: () => Effect.succeed({} as unknown as ServerProvider),
streamChanges: Stream.empty,
},
adapter,
Expand Down
99 changes: 96 additions & 3 deletions apps/server/src/provider/Layers/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { readProviderStatusCache, resolveProviderStatusCachePath } from "../prov
import type { ProviderInstance } from "../ProviderDriver.ts";
import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts";
import { ProviderRegistry } from "../Services/ProviderRegistry.ts";
import type { ProviderSnapshotRefreshInput } from "../Services/ServerProvider.ts";
import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
const decodeServerSettings = Schema.decodeSync(ServerSettings);
const encodeServerSettings = Schema.encodeSync(ServerSettings);
Expand Down Expand Up @@ -368,6 +369,23 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
}),
);

it.effect("passes the supplied cwd to the app-server probe", () =>
Effect.gen(function* () {
let capturedCwd: string | null = null;
yield* checkCodexProviderStatus(
defaultCodexSettings,
(input) => {
capturedCwd = input.cwd;
return Effect.succeed(makeCodexProbeSnapshot());
},
process.env,
"/workspace/project",
);

assert.strictEqual(capturedCwd, "/workspace/project");
}),
);

it.effect(
"returns ready with unknown auth when app-server does not require OpenAI auth",
() =>
Expand Down Expand Up @@ -709,7 +727,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
packageName: null,
}),
getSnapshot: Effect.succeed(initialProvider),
refresh: Effect.succeed(refreshedProvider),
refresh: () => Effect.succeed(refreshedProvider),
streamChanges: Stream.fromPubSub(changes),
},
adapter: {} as ProviderInstance["adapter"],
Expand Down Expand Up @@ -803,7 +821,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
packageName: null,
}),
getSnapshot: Effect.succeed(cachedProvider),
refresh: Effect.die(new Error("simulated refresh failure")),
refresh: () => Effect.die(new Error("simulated refresh failure")),
streamChanges: Stream.empty,
},
adapter: {} as ProviderInstance["adapter"],
Expand Down Expand Up @@ -845,6 +863,81 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
}),
);

it.effect("forwards manual refresh input to the targeted provider instance", () =>
Effect.gen(function* () {
const codexDriver = ProviderDriverKind.make("codex");
const codexInstanceId = ProviderInstanceId.make("codex");
const refreshInputs = yield* Ref.make<Array<string | undefined>>([]);
const provider = {
instanceId: codexInstanceId,
driver: codexDriver,
status: "ready",
enabled: true,
installed: true,
auth: { status: "authenticated" },
checkedAt: "2026-04-29T10:00:00.000Z",
version: "1.0.0",
models: [],
slashCommands: [],
skills: [],
} as const satisfies ServerProvider;
const instance = {
instanceId: codexInstanceId,
driverKind: codexDriver,
continuationIdentity: {
driverKind: codexDriver,
continuationKey: "codex:instance:codex",
},
displayName: undefined,
enabled: true,
snapshot: {
maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({
provider: codexDriver,
packageName: null,
}),
getSnapshot: Effect.succeed(provider),
refresh: (input?: ProviderSnapshotRefreshInput) =>
Ref.update(refreshInputs, (inputs) => [...inputs, input?.cwd]).pipe(
Effect.as(provider),
),
streamChanges: Stream.empty,
},
adapter: {} as ProviderInstance["adapter"],
textGeneration: {} as ProviderInstance["textGeneration"],
} satisfies ProviderInstance;
const instanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, {
getInstance: (instanceId) =>
Effect.succeed(instanceId === codexInstanceId ? instance : undefined),
listInstances: Effect.succeed([instance]),
listUnavailable: Effect.succeed([]),
streamChanges: Stream.empty,
subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), (pubsub) =>
PubSub.subscribe(pubsub),
),
});
const scope = yield* Scope.make();
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
const runtimeServices = yield* Layer.build(
ProviderRegistryLive.pipe(
Layer.provideMerge(instanceRegistryLayer),
Layer.provideMerge(
ServerConfig.layerTest(process.cwd(), {
prefix: "t3-provider-registry-refresh-input-",
}),
),
Layer.provideMerge(NodeServices.layer),
),
).pipe(Scope.provide(scope));

yield* Effect.gen(function* () {
const registry = yield* ProviderRegistry;
yield* registry.refreshInstance(codexInstanceId, { cwd: "/workspace/project" });

assert.strictEqual((yield* Ref.get(refreshInputs)).at(-1), "/workspace/project");
}).pipe(Effect.provide(runtimeServices));
}),
);

it.effect("keeps consuming registry changes after one sync fails", () =>
Effect.gen(function* () {
const codexDriver = ProviderDriverKind.make("codex");
Expand Down Expand Up @@ -892,7 +985,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
packageName: null,
}),
getSnapshot: Effect.succeed(provider),
refresh: Effect.succeed(provider),
refresh: () => Effect.succeed(provider),
streamChanges: Stream.empty,
},
adapter: {} as ProviderInstance["adapter"],
Expand Down
40 changes: 24 additions & 16 deletions apps/server/src/provider/Layers/ProviderRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import * as Semaphore from "effect/Semaphore";
import { ServerConfig } from "../../config.ts";
import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts";
import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts";
import type { ProviderSnapshotRefreshInput } from "../Services/ServerProvider.ts";
import {
hydrateCachedProvider,
isCachedProviderCorrelated,
Expand Down Expand Up @@ -429,27 +430,33 @@ export const ProviderRegistryLive = Layer.effect(

const refreshOneSource = Effect.fn("refreshOneSource")(function* (
providerSource: ProviderSnapshotSource,
input?: ProviderSnapshotRefreshInput,
) {
return yield* providerSource.refresh.pipe(
Effect.flatMap((nextProvider) =>
correlateSnapshotWithSource(providerSource, nextProvider).pipe(
Effect.flatMap(syncProvider),
return yield* providerSource
.refresh(input)
.pipe(
Effect.flatMap((nextProvider) =>
correlateSnapshotWithSource(providerSource, nextProvider).pipe(
Effect.flatMap(syncProvider),
),
),
),
);
);
});

const refreshAll = Effect.fn("refreshAll")(function* () {
const refreshAll = Effect.fn("refreshAll")(function* (input?: ProviderSnapshotRefreshInput) {
const sources = yield* getLiveSources;
return yield* Effect.forEach(sources, (source) => refreshOneSource(source), {
return yield* Effect.forEach(sources, (source) => refreshOneSource(source, input), {
concurrency: "unbounded",
discard: true,
}).pipe(Effect.andThen(Ref.get(providersRef)));
});

const refresh = Effect.fn("refresh")(function* (provider?: ProviderDriverKind) {
const refresh = Effect.fn("refresh")(function* (
provider?: ProviderDriverKind,
input?: ProviderSnapshotRefreshInput,
) {
if (provider === undefined) {
return yield* refreshAll();
return yield* refreshAll(input);
}
// Kind-scoped refreshes target the default instance for that driver.
const defaultInstanceId = defaultInstanceIdForDriver(provider);
Expand All @@ -460,18 +467,19 @@ export const ProviderRegistryLive = Layer.effect(
if (!providerSource) {
return yield* Ref.get(providersRef);
}
return yield* refreshOneSource(providerSource);
return yield* refreshOneSource(providerSource, input);
});

const refreshInstance = Effect.fn("refreshInstance")(function* (
instanceId: ProviderInstanceId,
input?: ProviderSnapshotRefreshInput,
) {
const sources = yield* getLiveSources;
const providerSource = sources.find((candidate) => candidate.instanceId === instanceId);
if (!providerSource) {
return yield* Ref.get(providersRef);
}
return yield* refreshOneSource(providerSource);
return yield* refreshOneSource(providerSource, input);
});

const getProviderMaintenanceCapabilitiesForInstance = Effect.fn(
Expand Down Expand Up @@ -681,10 +689,10 @@ export const ProviderRegistryLive = Layer.effect(

return {
getProviders: Ref.get(providersRef),
refresh: (provider?: ProviderDriverKind) =>
refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)),
refreshInstance: (instanceId: ProviderInstanceId) =>
refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)),
refresh: (provider?: ProviderDriverKind, input?: ProviderSnapshotRefreshInput) =>
refresh(provider, input).pipe(Effect.catchCause(recoverRefreshFailure)),
refreshInstance: (instanceId: ProviderInstanceId, input?: ProviderSnapshotRefreshInput) =>
refreshInstance(instanceId, input).pipe(Effect.catchCause(recoverRefreshFailure)),
getProviderMaintenanceCapabilitiesForInstance,
setProviderMaintenanceActionState,
get streamChanges() {
Expand Down
7 changes: 6 additions & 1 deletion apps/server/src/provider/Services/ProviderRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import * as Context from "effect/Context";
import type * as Effect from "effect/Effect";
import type * as Stream from "effect/Stream";
import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
import type { ProviderSnapshotRefreshInput } from "./ServerProvider.ts";

export type ProviderMaintenanceActionKind = "update";

Expand All @@ -36,7 +37,10 @@ export interface ProviderRegistryShape {
*
* @deprecated prefer `refreshInstance` for new call sites.
*/
readonly refresh: (provider?: ProviderDriverKind) => Effect.Effect<ReadonlyArray<ServerProvider>>;
readonly refresh: (
provider?: ProviderDriverKind,
input?: ProviderSnapshotRefreshInput,
) => Effect.Effect<ReadonlyArray<ServerProvider>>;

/**
* Refresh the specific configured instance. Returns the updated snapshot
Expand All @@ -46,6 +50,7 @@ export interface ProviderRegistryShape {
*/
readonly refreshInstance: (
instanceId: ProviderInstanceId,
input?: ProviderSnapshotRefreshInput,
) => Effect.Effect<ReadonlyArray<ServerProvider>>;

/**
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/provider/Services/ServerProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ import type * as Effect from "effect/Effect";
import type * as Stream from "effect/Stream";
import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts";

export interface ProviderSnapshotRefreshInput {
readonly cwd?: string | undefined;
}

export interface ServerProviderShape {
readonly maintenanceCapabilities: ProviderMaintenanceCapabilities;
readonly getSnapshot: Effect.Effect<ServerProvider>;
readonly refresh: Effect.Effect<ServerProvider>;
readonly refresh: (input?: ProviderSnapshotRefreshInput) => Effect.Effect<ServerProvider>;
readonly streamChanges: Stream.Stream<ServerProvider>;
}
Loading
Loading