From 3f9daa83bbc12b1d5c422d89c75efe4c21881205 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 10 Oct 2026 17:39:01 -0400 Subject: [PATCH 01/39] feat(settings): providers can be turned on or off per project Signed-off-by: Yordis Prieto --- .../settings/ScheduledTaskPickerScreens.tsx | 6 +- .../SettingsScheduledTasksRouteScreen.tsx | 5 +- .../features/settings/scheduledTaskDraft.ts | 7 +- .../src/features/threads/ThreadComposer.tsx | 15 ++- .../threads/new-task-flow-provider.tsx | 3 + apps/mobile/src/lib/modelOptions.test.ts | 67 ++++++++++++ apps/mobile/src/lib/modelOptions.ts | 11 +- .../server/src/mcp/McpToolAccess.race.test.ts | 5 +- apps/server/src/mcp/OrchestratorMcpService.ts | 2 + ...OrchestratorMcpToolkit.integration.test.ts | 11 +- ...skCancelNativeSubagent.integration.test.ts | 6 +- .../src/mcp/toolkits/project/handlers.test.ts | 13 ++- apps/server/src/mcp/toolkits/project/tools.ts | 2 + ...laudeAutomaticDelivery.integration.test.ts | 2 + .../ThreadLaunchService.test.ts | 8 +- .../ThreadManagementService.test.ts | 80 ++++++++++++++ .../ThreadManagementService.ts | 48 ++++++++- .../ThreadMessageIntake.test.ts | 3 +- .../orchestration-v2/ThreadMessageIntake.ts | 16 +++ .../src/orchestration-v2/ThreadStop.test.ts | 2 + .../ThreadTitleRegenerationService.test.ts | 8 +- .../ProviderInstanceRegistryHydration.test.ts | 100 ++++++++++++++++++ .../ProviderInstanceRegistryHydration.ts | 19 ++++ apps/server/src/ws.ts | 8 ++ apps/web/src/components/ChatView.tsx | 9 +- apps/web/src/components/chat/ChatComposer.tsx | 11 +- .../settings/ProjectDefaultsSettings.tsx | 7 +- .../settings/ProviderInstanceCard.tsx | 39 ++++++- .../ProviderSettingsPanel.logic.test.ts | 77 +++++++++++++- .../settings/ProviderSettingsPanel.logic.ts | 29 +++++ .../settings/ProviderSettingsPanel.tsx | 53 ++++++++++ .../settings/ScheduledTasksSettings.tsx | 20 ++-- .../components/settings/SettingsPanels.tsx | 8 +- .../settings/SourceControlWritingSettings.tsx | 8 +- .../settings/useScopedModelAvailability.ts | 1 + apps/web/src/providerInstances.test.ts | 14 ++- apps/web/src/providerInstances.ts | 24 +++-- apps/web/src/routes/settings.providers.tsx | 3 +- docs/user/project-settings.md | 6 ++ packages/contracts/src/settings.test.ts | 73 +++++++++++++ packages/contracts/src/settings.ts | 45 +++++++- packages/contracts/src/t3ProjectFile.ts | 5 +- packages/shared/src/projectSettings.ts | 11 +- 43 files changed, 832 insertions(+), 58 deletions(-) create mode 100644 apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts diff --git a/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx b/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx index ce27c1debbcc..9dbbcfa2012e 100644 --- a/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx +++ b/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx @@ -25,7 +25,11 @@ export function ScheduledTaskModelPickerRouteScreen() { const { editor, setEditor } = useScheduledTaskEditor(); const config = useEnvironmentServerConfig(editor?.environmentId ?? null); const selectedModel = editor?.draft.modelSelection ?? null; - const models = useMemo(() => buildModelOptions(config, selectedModel), [config, selectedModel]); + const projectId = editor?.draft.projectId ?? null; + const models = useMemo( + () => buildModelOptions(config, selectedModel, undefined, projectId), + [config, selectedModel, projectId], + ); const providerGroups = useMemo(() => groupByProvider(models), [models]); const selectedOption = models.find( (option) => diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index e9cc8563f2a9..e75a36fc26a8 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -582,7 +582,10 @@ function TaskForm({ ); const projects = useProjects().filter((project) => project.environmentId === environmentId); const config = useEnvironmentServerConfig(environmentId); - const modelOptions = useMemo(() => buildModelOptions(config, null), [config]); + const modelOptions = useMemo( + () => buildModelOptions(config, null, undefined, draft.projectId), + [config, draft.projectId], + ); const canOperate = useAtomValue( serverEnvironment.upsertScheduledTask.permissionAtom(environmentId), ); diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.ts index 0ba9a437cdb6..1afbbbe5a4e6 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.ts @@ -34,7 +34,12 @@ export function scheduledTaskDefaultModel( draftSelection: null, projectDefaultSelection, stickySelection: null, - modelOptions: buildModelOptions(config, projectDefaultSelection), + modelOptions: buildModelOptions( + config, + projectDefaultSelection, + undefined, + project?.id ?? null, + ), }); } diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 4000571c1e80..7bb52cda6d66 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -651,8 +651,19 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ? undefined : currentModelSelection.instanceId; const modelOptions = useMemo( - () => buildModelOptions(props.serverConfig, currentModelSelection, lockedProviderInstanceId), - [props.serverConfig, currentModelSelection, lockedProviderInstanceId], + () => + buildModelOptions( + props.serverConfig, + currentModelSelection, + lockedProviderInstanceId, + props.selectedThread.projectId, + ), + [ + props.serverConfig, + currentModelSelection, + lockedProviderInstanceId, + props.selectedThread.projectId, + ], ); const threadProviderGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); const currentModelOption = diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 9827bdd15254..58fadfe5c43f 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -596,12 +596,15 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { buildModelOptions( selectedEnvironmentServerConfig, draftModelSelection ?? projectDefaultModelSelection ?? stickyModelSelection, + undefined, + selectedProject?.id ?? null, ), [ selectedEnvironmentServerConfig, draftModelSelection, projectDefaultModelSelection, stickyModelSelection, + selectedProject?.id, ], ); diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index ccf1e7bfd820..d1b5bbcbf0ea 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -2,9 +2,12 @@ import { describe, expect, it } from "vite-plus/test"; import { DEFAULT_SERVER_SETTINGS, + ProjectId, + ProviderDriverKind, ProviderInstanceId, type ModelSelection, type ServerConfig, + type ServerSettings, } from "@t3tools/contracts"; import { @@ -508,6 +511,70 @@ describe("mobile model options", () => { }), ).toBeNull(); }); + + describe("project-aware enablement", () => { + const projectId = ProjectId.make("project-a"); + const instanceId = ProviderInstanceId.make("codex_work"); + + function configWith(settings: ServerSettings): ServerConfig { + return { + providers: [ + { + instanceId, + driver: "codex", + displayName: "Codex Work", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + models: [{ slug: "model-a", name: "Model A", isCustom: false, capabilities: null }], + }, + ], + settings, + } as unknown as ServerConfig; + } + + it("hides an instance the project disables even though the machine enables it", () => { + const settings: ServerSettings = { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, + }, + projectSettingsOverrides: { + [projectId]: { providerInstanceEnablement: { [instanceId]: false } }, + }, + }; + + expect(buildModelOptions(configWith(settings), null, undefined, projectId)).toEqual([]); + }); + + it("shows an instance the project enables even though the machine disables it", () => { + const settings: ServerSettings = { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false }, + }, + projectSettingsOverrides: { + [projectId]: { providerInstanceEnablement: { [instanceId]: true } }, + }, + }; + + expect(buildModelOptions(configWith(settings), null, undefined, projectId)).toHaveLength(1); + }); + + it("falls back to the machine value with no project in scope", () => { + const settings: ServerSettings = { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, + }, + projectSettingsOverrides: { + [projectId]: { providerInstanceEnablement: { [instanceId]: false } }, + }, + }; + + expect(buildModelOptions(configWith(settings), null, undefined, null)).toHaveLength(1); + }); + }); }); describe("synchronized mobile model visibility", () => { diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index eeae36a336b0..a7262a898fa7 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -2,9 +2,11 @@ import type { MenuAction } from "@react-native-menu/menu"; import type { ModelCapabilities, ModelSelection, + ProjectId, RuntimeMode, ServerConfig as T3ServerConfig, } from "@t3tools/contracts"; +import { resolveProjectProviderInstanceEnabled } from "@t3tools/contracts"; import { buildExplicitProviderOptionSelectionsFromDescriptors, getProviderOptionDescriptors, @@ -169,14 +171,21 @@ export function buildModelOptions( config: T3ServerConfig | null | undefined, fallbackModelSelection: ModelSelection | null, providerInstanceId?: ModelSelection["instanceId"], + projectId: ProjectId | null = null, includeHidden = false, ): ReadonlyArray { const options = new Map(); for (const provider of config?.providers ?? []) { + // `provider.enabled` reflects the machine plus any project's keep-alive + // override, so it alone can't tell this project apart from one that + // merely shares the instance. Settings carry the real per-project view. + const isEnabledForProject = config?.settings + ? resolveProjectProviderInstanceEnabled(config.settings, projectId, provider.instanceId) + : provider.enabled; if ( (providerInstanceId !== undefined && provider.instanceId !== providerInstanceId) || - !provider.enabled || + !isEnabledForProject || !provider.installed || provider.auth.status === "unauthenticated" || (provider.driver === "antigravity" && provider.availability === "unavailable") diff --git a/apps/server/src/mcp/McpToolAccess.race.test.ts b/apps/server/src/mcp/McpToolAccess.race.test.ts index 46522810f707..1f9196494cdf 100644 --- a/apps/server/src/mcp/McpToolAccess.race.test.ts +++ b/apps/server/src/mcp/McpToolAccess.race.test.ts @@ -21,6 +21,7 @@ import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; @@ -184,7 +185,7 @@ it.layer(layerOrchestrator)("writesThreads against a mode raise", (it) => { const shell = yield* projections.getThreadShell(threadId); assert.equal(shell?.runtimeMode, "full-access"); assert.equal(shell?.title, "Before"); - }).pipe(Effect.provide(ThreadManagement.layer)), + }).pipe(Effect.provide(ThreadManagement.layer.pipe(Layer.provide(ServerSettings.layerTest())))), ); it.effect("lets the write through when the thread stays within the caller's modes", () => @@ -195,6 +196,6 @@ it.layer(layerOrchestrator)("writesThreads against a mode raise", (it) => { const outcome = yield* renameRacingTheUser(threadId, false); assert.deepEqual(outcome, { renamed: true }); assert.equal((yield* projections.getThreadShell(threadId))?.title, "Renamed by the agent"); - }).pipe(Effect.provide(ThreadManagement.layer)), + }).pipe(Effect.provide(ThreadManagement.layer.pipe(Layer.provide(ServerSettings.layerTest())))), ); }); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 64c8cc020086..8a6495f0ee22 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -215,6 +215,8 @@ function threadManagementFailure(error: unknown): OrchestratorMcpFailure { case "ThreadManagementProjectThreadsListError": case "ThreadManagementDurableRunProjectionError": return failure("orchestration_error", error.message); + case "ThreadManagementProviderInstanceDisabledError": + return failure("invalid_request", error.message); } } diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index d0b1753eddc0..d15b43146562 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -57,6 +57,7 @@ import { threadShellFromProjection } from "../orchestration-v2/ProjectionStore.t import * as EventSink from "../orchestration-v2/EventSink.ts"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as ThreadSearch from "../orchestration-v2/ThreadSearch.ts"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; @@ -612,7 +613,10 @@ describe("orchestrator MCP toolkit", () => { ).pipe(Layer.provide(layerContinuationProbe)); const layerOrchestration = Layer.merge( layerOrchestrator, - ThreadManagementService.layer.pipe(Layer.provide(layerOrchestrator)), + ThreadManagementService.layer.pipe( + Layer.provide(layerOrchestrator), + Layer.provide(ServerSettings.layerTest()), + ), ); const layerProviderRegistry = ProviderRegistryMock.layer([ makeProviderSnapshot({ @@ -3809,7 +3813,10 @@ describe("orchestrator MCP toolkit", () => { ); const layerOrchestration = Layer.merge( layerOrchestrator, - ThreadManagementService.layer.pipe(Layer.provide(layerOrchestrator)), + ThreadManagementService.layer.pipe( + Layer.provide(layerOrchestrator), + Layer.provide(ServerSettings.layerTest()), + ), ); const layerProviderRegistry = ProviderRegistryMock.layer([ makeProviderSnapshot({ diff --git a/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts b/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts index 16830049662c..8b533ffbced7 100644 --- a/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts +++ b/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts @@ -27,6 +27,7 @@ import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; import * as ProjectService from "../project/ProjectService.ts"; @@ -133,7 +134,10 @@ it.effect("task_cancel ends a delegated child's background native subagent", () ); const layerOrchestration = Layer.merge( layerOrchestrator, - ThreadManagementService.layer.pipe(Layer.provide(layerOrchestrator)), + ThreadManagementService.layer.pipe( + Layer.provide(layerOrchestrator), + Layer.provide(ServerSettings.layerTest()), + ), ); const layerTest = OrchestratorMcpService.layer.pipe( Layer.provideMerge(layerOrchestration), diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index b74df9dae1f9..7b045ae6b189 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -19,6 +19,7 @@ import * as Stream from "effect/Stream"; import * as ThreadLaunch from "../../../orchestration-v2/ThreadLaunchService.ts"; import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts"; import * as ServerConfig from "../../../config.ts"; +import * as ServerSettings from "../../../serverSettings.ts"; import * as Project from "../../../project/ProjectService.ts"; import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; @@ -47,6 +48,7 @@ it.effect("attributes a launched thread's first message to the calling thread", } as OrchestrationV2ThreadShell; let launchedSender: ThreadId | undefined; const layerDependencies = Layer.mergeAll( + ServerSettings.layerTest(), NodeCrypto.layer, Layer.succeed(McpInvocationContext.McpInvocationContext, { environmentId: EnvironmentId.make("environment"), @@ -118,6 +120,7 @@ it.effect("launches a scratch thread into the Scratch project", () => } as OrchestrationV2ThreadShell; const launched: Array = []; const layerDependencies = Layer.mergeAll( + ServerSettings.layerTest(), NodeCrypto.layer, Layer.succeed(McpInvocationContext.McpInvocationContext, { environmentId: EnvironmentId.make("environment"), @@ -215,6 +218,7 @@ it.effect("starts a project from just a title when workspaceRoot is omitted", () deletedAt: null, }; const layerDependencies = Layer.mergeAll( + ServerSettings.layerTest(), NodeCrypto.layer, Layer.succeed(McpInvocationContext.McpInvocationContext, { environmentId: EnvironmentId.make("environment"), @@ -304,8 +308,15 @@ const clientLaunchHarness = (input: { readonly checkWorktreeBase?: ThreadLaunch.ThreadLaunchService["Service"]["checkWorktreeBase"]; }) => { const projectId = ProjectId.make("project:client-target"); - const modelSelection = { instanceId: ProviderInstanceId.make("claude"), model: "claude-opus" }; + // "claudeAgent" is the real default instance id for the Claude driver + // (see `defaultInstanceIdForDriver`), so it resolves as enabled by default + // under the project-scoped provider instance gate the launch path now runs. + const modelSelection = { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-opus", + }; const layerDependencies = Layer.mergeAll( + ServerSettings.layerTest(), NodeCrypto.layer, Layer.succeed(McpInvocationContext.McpInvocationContext, { environmentId: EnvironmentId.make("environment"), diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index ee489d4e104c..e455c041c4c3 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -19,6 +19,7 @@ import { } from "@t3tools/contracts"; import * as FileSystem from "effect/FileSystem"; import * as ServerConfig from "../../../config.ts"; +import * as ServerSettings from "../../../serverSettings.ts"; import * as ThreadLaunchService from "../../../orchestration-v2/ThreadLaunchService.ts"; import * as Crypto from "effect/Crypto"; import * as Schema from "effect/Schema"; @@ -144,6 +145,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { GitVcsDriver.GitVcsDriver, FileSystem.FileSystem, ServerConfig.ServerConfig, + ServerSettings.ServerSettingsService, ], }) .annotate(Tool.Destructive, true) diff --git a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts index f7e2e70ab3c5..a5d92eff8a72 100644 --- a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts +++ b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts @@ -38,6 +38,7 @@ import * as Orchestrator from "./Orchestrator.ts"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ThreadLaunchService from "./ThreadLaunchService.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; @@ -313,6 +314,7 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con Effect.provide( ScheduledTaskService.layer.pipe( Layer.provide(ThreadManagementService.layer), + Layer.provide(ServerSettings.layerTest()), Layer.provide( Layer.mock(LegacyV1ThreadImporter.LegacyV1ThreadImporter)({ ensureTranscript: () => diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index f7eede36b8e4..1fd532033f42 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -118,7 +118,11 @@ function makeHarness(options: HarnessOptions = {}) { layerRegistry, { databaseLayer: layerDatabase, runEffectWorker: false }, ); - const layerThreadManagement = ThreadManagement.layer.pipe(Layer.provide(layerOrchestrator)); + const layerServerSettings = ServerSettings.layerTest(options.serverSettings); + const layerThreadManagement = ThreadManagement.layer.pipe( + Layer.provide(layerOrchestrator), + Layer.provide(layerServerSettings), + ); const layerReceipts = CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase)); const layerOutbox = EffectOutbox.layer.pipe(Layer.provide(layerDatabase)); const createWorktree = vi.fn( @@ -185,7 +189,7 @@ function makeHarness(options: HarnessOptions = {}) { generateThreadTitle, generateBranchName, }), - ServerSettings.layerTest(options.serverSettings), + layerServerSettings, ProviderRegistryMock.layer(options.providers), options.managedFolders ?? Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index b4694a19863c..a579393173b8 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -31,6 +31,7 @@ import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; @@ -273,6 +274,79 @@ it("derives thread management messages from structural error attributes", () => ); }); +it.effect("refuses a new turn when the project effectively disables the instance", () => { + const projectId = ProjectId.make("project:thread-management:gate-disabled"); + const instanceId = ProviderInstanceId.make("codex_work"); + const layerTest = ServerSettings.layerTest({ + providerInstances: { [instanceId]: { driver: "codex", enabled: true } }, + projectSettingsOverrides: { + [projectId]: { providerInstanceEnablement: { [instanceId]: false } }, + }, + }); + + return Effect.gen(function* () { + const settings = yield* ServerSettings.ServerSettingsService; + const error = yield* Effect.flip( + ThreadManagementService.assertProviderInstanceEnabledForProject( + settings, + projectId, + instanceId, + ), + ); + + expect(error).toBeInstanceOf( + ThreadManagementService.ThreadManagementProviderInstanceDisabledError, + ); + expect(error).toMatchObject({ projectId, instanceId }); + expect(error.message).toBe( + `Provider instance "${instanceId}" is disabled for project ${projectId}.`, + ); + }).pipe(Effect.provide(layerTest)); +}); + +it.effect("allows a new turn when the project enables an instance the machine disabled", () => { + const projectId = ProjectId.make("project:thread-management:gate-enabled"); + const instanceId = ProviderInstanceId.make("codex_work"); + const layerTest = ServerSettings.layerTest({ + providerInstances: { [instanceId]: { driver: "codex", enabled: false } }, + projectSettingsOverrides: { + [projectId]: { providerInstanceEnablement: { [instanceId]: true } }, + }, + }); + + return Effect.gen(function* () { + const settings = yield* ServerSettings.ServerSettingsService; + yield* ThreadManagementService.assertProviderInstanceEnabledForProject( + settings, + projectId, + instanceId, + ); + }).pipe(Effect.provide(layerTest)); +}); + +it.effect("inherits the machine value when the project has no override", () => { + const projectId = ProjectId.make("project:thread-management:gate-inherit"); + const instanceId = ProviderInstanceId.make("codex_work"); + const layerTest = ServerSettings.layerTest({ + providerInstances: { [instanceId]: { driver: "codex", enabled: false } }, + }); + + return Effect.gen(function* () { + const settings = yield* ServerSettings.ServerSettingsService; + const error = yield* Effect.flip( + ThreadManagementService.assertProviderInstanceEnabledForProject( + settings, + projectId, + instanceId, + ), + ); + + expect(error).toBeInstanceOf( + ThreadManagementService.ThreadManagementProviderInstanceDisabledError, + ); + }).pipe(Effect.provide(layerTest)); +}); + it.effect("classifies projection infrastructure failures separately from a missing thread", () => { const projectId = ProjectId.make("project:thread-management:projection-failure"); const threadId = ThreadId.make("thread:thread-management:projection-failure"); @@ -288,6 +362,7 @@ it.effect("classifies projection infrastructure failures separately from a missi getThreadProjection: () => Effect.fail(projectionError), }), ), + Layer.provide(ServerSettings.layerTest()), ); return Effect.gen(function* () { @@ -322,6 +397,7 @@ it.effect("uses thread-not-found only after a projection loads outside the proje getThreadProjection: () => Effect.succeed(projection), }), ), + Layer.provide(ServerSettings.layerTest()), ); return Effect.gen(function* () { @@ -354,6 +430,7 @@ it.effect("preserves failed legacy materialization when reading checkpoint conte }), ), ), + Layer.provide(ServerSettings.layerTest()), ); return Effect.gen(function* () { const service = yield* ThreadManagementService.ThreadManagementService; @@ -402,6 +479,7 @@ it.effect.each([ }), }), ), + Layer.provide(ServerSettings.layerTest()), ); const service = yield* ThreadManagementService.ThreadManagementService.pipe( Effect.provide(layerTest), @@ -473,6 +551,7 @@ it.effect("waitForThread reads the run again only when the run updates", () => }), }), ), + Layer.provide(ServerSettings.layerTest()), ); const service = yield* ThreadManagementService.ThreadManagementService.pipe( Effect.provide(layerTest), @@ -735,6 +814,7 @@ it.effect.each([ }), }), ), + Layer.provide(ServerSettings.layerTest()), ); const service = yield* ThreadManagementService.ThreadManagementService.pipe( Effect.provide(layerTest), diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index c1a542bf7761..93d44f7cb305 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -26,6 +26,8 @@ import { type OrchestrationV2ThreadShell, type OrchestrationV2TurnItem, ProjectId, + ProviderInstanceId, + resolveProjectProviderInstanceEnabled, RunId, type ScheduledTaskId, ThreadId, @@ -47,6 +49,7 @@ import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import { DispatchModeLimit, exceededDispatchModeLimit } from "./DispatchModeLimit.ts"; import { projectTurnItemForDetail } from "./WireProjection.ts"; import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; +import * as ServerSettings from "../serverSettings.ts"; export type ThreadManagementSendMode = "auto" | "queue" | "steer" | "restart"; @@ -266,6 +269,18 @@ export class ThreadManagementDurableRunProjectionError extends Schema.TaggedErro } } +export class ThreadManagementProviderInstanceDisabledError extends Schema.TaggedError()( + "ThreadManagementProviderInstanceDisabledError", + { + projectId: ProjectId, + instanceId: ProviderInstanceId, + }, +) { + override get message(): string { + return `Provider instance "${this.instanceId}" is disabled for project ${this.projectId}.`; + } +} + export const ThreadManagementError = Schema.Union([ ThreadManagementThreadNotFoundError, ThreadManagementRunNotFoundError, @@ -275,11 +290,33 @@ export const ThreadManagementError = Schema.Union([ ThreadManagementProjectionLoadError, ThreadManagementProjectThreadsListError, ThreadManagementDurableRunProjectionError, + ThreadManagementProviderInstanceDisabledError, ]); export type ThreadManagementError = typeof ThreadManagementError.Type; type ThreadManagementFailure = ThreadManagementError | Orchestrator.OrchestratorV2Error; +/** + * Refuses to start a new turn on an instance a project has effectively + * disabled, in either direction: the machine disabled it with no project + * override, or the project disabled it over a machine-enabled instance. + * Shared by `sendToThread` and by the intake layer's new-thread and + * existing-thread dispatch paths, which all read settings themselves. + */ +export const assertProviderInstanceEnabledForProject = ( + settings: ServerSettings.ServerSettingsService["Service"], + projectId: ProjectId, + instanceId: ProviderInstanceId, +): Effect.Effect => + settings.getSettings.pipe( + Effect.orDie, + Effect.flatMap((current) => + resolveProjectProviderInstanceEnabled(current, projectId, instanceId) + ? Effect.void + : new ThreadManagementProviderInstanceDisabledError({ projectId, instanceId }), + ), + ); + export interface ThreadManagementServiceShape { readonly searchThreadStream: ( input: OrchestrationV2SearchThreadInput, @@ -458,6 +495,7 @@ const make = Effect.gen(function* () { const sessions = yield* ProviderSessionManager.ProviderSessionManagerV2; const layerScope = yield* Effect.scope; const legacyImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; + const serverSettings = yield* ServerSettings.ServerSettingsService; const ensureLegacyTranscript = Effect.fn( "orchestrationV2.threadManagement.ensureLegacyTranscript", @@ -608,6 +646,11 @@ const make = Effect.gen(function* () { threadId: input.threadId, }); } + yield* assertProviderInstanceEnabledForProject( + serverSettings, + input.projectId, + (input.modelSelection ?? target.thread.modelSelection).instanceId, + ); const steerableRun = latestSteerableRun(target); let dispatchMode: Extract< @@ -1041,7 +1084,9 @@ const layerLegacyV1ThreadImporterNoop = Layer.succeed( export const layer: Layer.Layer< ThreadManagementService, never, - Orchestrator.OrchestratorV2 | ProviderSessionManager.ProviderSessionManagerV2 + | Orchestrator.OrchestratorV2 + | ProviderSessionManager.ProviderSessionManagerV2 + | ServerSettings.ServerSettingsService > = Layer.effect(ThreadManagementService, make).pipe( Layer.provide(layerLegacyV1ThreadImporterNoop), Layer.provide(ThreadCommandExecutor.layer), @@ -1053,4 +1098,5 @@ export const layerWithLegacyImporter: Layer.Layer< | LegacyV1ThreadImporter.LegacyV1ThreadImporter | Orchestrator.OrchestratorV2 | ProviderSessionManager.ProviderSessionManagerV2 + | ServerSettings.ServerSettingsService > = Layer.effect(ThreadManagementService, make).pipe(Layer.provide(ThreadCommandExecutor.layer)); diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts index 81dd25847799..1295222770da 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts @@ -20,6 +20,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { createPendingAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; import { OrchestratorCommandPreviouslyRejectedError, OrchestratorDispatchError, @@ -29,7 +30,7 @@ import { dispatchCommand } from "./ThreadMessageIntake.ts"; const layerIntakeTest = ServerConfig.layerTest(process.cwd(), { prefix: "t3-question-intake-", -}).pipe(Layer.provideMerge(NodeServices.layer)); +}).pipe(Layer.provideMerge(NodeServices.layer), Layer.provideMerge(ServerSettings.layerTest())); const layerFailingDispatch = (captured: OrchestrationV2ServerCommand[]) => Layer.mock(ThreadManagementService.ThreadManagementService)({ diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts index c8f65fd0340a..b78966e55852 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { resolveAttachmentPath } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as AttachmentClaims from "./AttachmentClaims.ts"; @@ -54,6 +55,15 @@ export const dispatchCommand = Effect.fn("ThreadMessageIntake.dispatchCommand")( command: OrchestrationV2Command, ) { const threads = yield* ThreadManagement.ThreadManagementService; + if (command.type === "message.dispatch") { + const serverSettings = yield* ServerSettings.ServerSettingsService; + const projection = yield* threads.getThreadRecords(command.threadId, []); + yield* ThreadManagement.assertProviderInstanceEnabledForProject( + serverSettings, + projection.thread.projectId, + (command.modelSelection ?? projection.thread.modelSelection).instanceId, + ); + } if (command.type === "runtime-request.respond" && command.attachmentsByQuestionId) { const config = yield* ServerConfig.ServerConfig; const incomingByQuestionId = command.attachmentsByQuestionId; @@ -183,6 +193,12 @@ export const launchThread = Effect.fn("ThreadMessageIntake.launchThread")(functi input: ThreadLaunch.ThreadLaunchInput, ) { const launches = yield* ThreadLaunch.ThreadLaunchService; + const serverSettings = yield* ServerSettings.ServerSettingsService; + yield* ThreadManagement.assertProviderInstanceEnabledForProject( + serverSettings, + input.projectId, + input.modelSelection.instanceId, + ); yield* AttachmentClaims.validateAttachmentLimits(input.initialMessage?.attachments ?? []); if (!input.initialMessage?.attachments.some(AttachmentClaims.attachmentIsPendingUpload)) { return yield* launches.launch(input); diff --git a/apps/server/src/orchestration-v2/ThreadStop.test.ts b/apps/server/src/orchestration-v2/ThreadStop.test.ts index 6b058f25a7e8..8bbebdbe22ac 100644 --- a/apps/server/src/orchestration-v2/ThreadStop.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStop.test.ts @@ -21,6 +21,7 @@ import * as SqlClient from "effect/sql/SqlClient"; import * as TestClock from "effect/testing/TestClock"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as ServerSettings from "../serverSettings.ts"; import { CLAUDE_PROVIDER } from "./Adapters/ClaudeAdapterV2.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import { OrchestrationEffectRequestV2 } from "./EffectOutbox.ts"; @@ -54,6 +55,7 @@ const layerTest = ThreadManagementService.layer.pipe( ), ), ), + Layer.provideMerge(ServerSettings.layerTest()), ); const encodeEffectRequest = Schema.encodeSync(Schema.fromJsonString(OrchestrationEffectRequestV2)); diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts index 06f7aa2488c8..b294409edd50 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts @@ -54,7 +54,11 @@ function makeHarness( layerRegistry, { databaseLayer: layerDatabase, runEffectWorker: false }, ); - const layerThreadManagement = ThreadManagement.layer.pipe(Layer.provide(layerOrchestrator)); + const layerServerSettings = ServerSettings.layerTest({}); + const layerThreadManagement = ThreadManagement.layer.pipe( + Layer.provide(layerOrchestrator), + Layer.provide(layerServerSettings), + ); const layerOutbox = EffectOutbox.layer.pipe(Layer.provide(layerDatabase)); const generateThreadTitle = vi.fn( options.generateTitle ?? (() => Effect.succeed({ title: "Generated title" })), @@ -86,7 +90,7 @@ function makeHarness( layerThreadManagement, layerProjectedProjects, Layer.mock(TextGeneration.TextGeneration)({ generateThreadTitle }), - ServerSettings.layerTest({}), + layerServerSettings, ), ), ); diff --git a/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts b/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts new file mode 100644 index 000000000000..de6d0b34f0bc --- /dev/null +++ b/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts @@ -0,0 +1,100 @@ +import { + DEFAULT_SERVER_SETTINGS, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + type ServerSettings, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { deriveProviderInstanceConfigMap } from "./ProviderInstanceRegistryHydration.ts"; + +const projectId = ProjectId.make("project-a"); +const instanceId = ProviderInstanceId.make("codex_work"); + +function settingsWith(overrides: Partial): ServerSettings { + return { ...DEFAULT_SERVER_SETTINGS, ...overrides }; +} + +describe("deriveProviderInstanceConfigMap", () => { + it("turns a machine-disabled instance on when a project enables it", () => { + const settings = settingsWith({ + providerInstances: { + [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false }, + }, + projectSettingsOverrides: { + [projectId]: { providerInstanceEnablement: { [instanceId]: true } }, + }, + }); + + const merged = deriveProviderInstanceConfigMap(settings); + + expect(merged[instanceId]).toEqual({ driver: ProviderDriverKind.make("codex"), enabled: true }); + }); + + it("leaves an already machine-enabled instance untouched", () => { + const settings = settingsWith({ + providerInstances: { + [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, + }, + projectSettingsOverrides: { + [projectId]: { providerInstanceEnablement: { [instanceId]: true } }, + }, + }); + + const merged = deriveProviderInstanceConfigMap(settings); + + expect(merged[instanceId]).toBe(settings.providerInstances[instanceId]); + }); + + it("does not revive an instance a project override references but settings no longer configure", () => { + const settings = settingsWith({ + providerInstances: {}, + projectSettingsOverrides: { + [projectId]: { providerInstanceEnablement: { [instanceId]: true } }, + }, + }); + + const merged = deriveProviderInstanceConfigMap(settings); + + expect(merged[instanceId]).toBeUndefined(); + }); + + it("ignores a project override that disables an instance, leaving the machine's config in charge", () => { + const settings = settingsWith({ + providerInstances: { + [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, + }, + projectSettingsOverrides: { + [projectId]: { providerInstanceEnablement: { [instanceId]: false } }, + }, + }); + + const merged = deriveProviderInstanceConfigMap(settings); + + expect(merged[instanceId]).toBe(settings.providerInstances[instanceId]); + }); + + it("folds overrides from multiple projects", () => { + const otherInstanceId = ProviderInstanceId.make("claudeAgent_personal"); + const otherProjectId = ProjectId.make("project-b"); + const settings = settingsWith({ + providerInstances: { + [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false }, + [otherInstanceId]: { driver: ProviderDriverKind.make("claudeAgent"), enabled: false }, + }, + projectSettingsOverrides: { + [projectId]: { providerInstanceEnablement: { [instanceId]: true } }, + [otherProjectId]: { providerInstanceEnablement: { [otherInstanceId]: true } }, + }, + }); + + const merged = deriveProviderInstanceConfigMap(settings); + + expect(merged[instanceId]).toEqual({ driver: ProviderDriverKind.make("codex"), enabled: true }); + expect(merged[otherInstanceId]).toEqual({ + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + }); + }); +}); diff --git a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts index 822b69bcb817..629910d1bff9 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts @@ -25,8 +25,10 @@ */ import { defaultInstanceIdForDriver, + resolveProviderInstanceEnabled, type ProviderInstanceConfig, type ProviderInstanceConfigMap, + type ProviderInstanceId, ServerSettings, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -73,6 +75,23 @@ export const deriveProviderInstanceConfigMap = ( merged[instanceId] = { driver: driver.driverKind }; } + // A project that turns an instance on needs it alive and probed even when + // the machine itself has it off, or a thread in that project would route + // to an instance nobody ever checked in on. Fold every project's + // `providerInstanceEnablement` overrides in: an instance already enabled + // is untouched, and one absent from settings entirely (deleted) stays out. + for (const overrides of Object.values(settings.projectSettingsOverrides)) { + for (const [rawInstanceId, projectEnabled] of Object.entries( + overrides?.providerInstanceEnablement ?? {}, + )) { + if (!projectEnabled) continue; + const instanceId = rawInstanceId as ProviderInstanceId; + const existing = merged[instanceId]; + if (existing === undefined || resolveProviderInstanceEnabled(existing)) continue; + merged[instanceId] = { ...existing, enabled: true }; + } + } + return merged as ProviderInstanceConfigMap; }; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 80921ec4b5a2..375c2ac78aa1 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1199,6 +1199,7 @@ const layerWsRpc = ( | ThreadLaunchService.ThreadLaunchService | FileSystem.FileSystem | ServerConfig.ServerConfig + | ServerSettings.ServerSettingsService >(); const applicationEvents = yield* OrchestrationEventStore.OrchestrationEventStore; const projectStore = yield* ProjectStore.ProjectStoreV2; @@ -2029,6 +2030,13 @@ const layerWsRpc = ( message: "Failed to launch thread", cause, }), + ThreadManagementProviderInstanceDisabledError: (cause) => + new OrchestrationV2ThreadLaunchError({ + commandId: input.commandId, + projectId: input.projectId, + message: cause.message, + cause, + }), }), ), ), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 84f5b476a133..3d3a52062a4f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3241,9 +3241,13 @@ export default function ChatView(props: ChatViewProps) { const providerInstanceEntries = useMemo( () => sortProviderInstanceEntries( - applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings), + applyProviderInstanceSettings( + deriveProviderInstanceEntries(providerStatuses), + settings, + activeProject?.id ?? null, + ), ), - [providerStatuses, settings], + [providerStatuses, settings, activeProject?.id], ); const { selectedProviderEntry, requestedDriverKind } = useMemo( () => @@ -11759,6 +11763,7 @@ export default function ChatView(props: ChatViewProps) { activeProjectDefaultModelSelection } activeThreadModelSelection={activeThread?.modelSelection} + activeProjectId={activeProject?.id ?? null} activeContextWindow={activeContextWindow} activeTasksProgress={activeComposerTasksProgress} activeTaskSteps={activeComposerTaskSteps} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 6d7646e521d7..6d672acec875 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1607,6 +1607,8 @@ export interface ChatComposerProps { activeProjectDefaultModelSelection: ModelSelection | null | undefined; activeThreadModelSelection: ModelSelection | null | undefined; reportedModelSelection?: ModelSelection | null; + /** The active project, so the picker can fold in its provider instance overrides. */ + activeProjectId: ProjectId | null; // Context window activeContextWindow: ContextWindowSnapshot | null; @@ -1756,6 +1758,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeProjectDefaultModelSelection, activeThreadModelSelection, reportedModelSelection, + activeProjectId, activeContextWindow, compactThreadUnavailable, compactDisabled, @@ -2132,9 +2135,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const providerInstanceEntries = useMemo>( () => sortProviderInstanceEntries( - applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings), + applyProviderInstanceSettings( + deriveProviderInstanceEntries(providerStatuses), + settings, + activeProjectId, + ), ), - [providerStatuses, settings], + [providerStatuses, settings, activeProjectId], ); const selectedProviderByThreadId = composerDraft.activeProvider ?? null; const { diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index e27ed061ad0d..58f4d10b3f77 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -64,7 +64,11 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting const providers = representative?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS; const selection = resolveDefaultProviderModelSelection(providers, settings.defaultModelSelection); const entries = sortProviderInstanceEntries( - applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings), + applyProviderInstanceSettings( + deriveProviderInstanceEntries(providers), + settings, + target?.projectId ?? null, + ), ); const modelOptions = getCustomModelOptionsByInstance( settings, @@ -105,6 +109,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting const entry = applyProviderInstanceSettings( deriveProviderInstanceEntries(config.providers), candidate.settings, + candidate.projectId, ).find((option) => option.instanceId === instanceId); const options = getCustomModelOptionsByInstance( mergeEnvironmentSettings(candidate.settings, settings), diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 5bbbc8c222ad..4c12a2d12c70 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -55,7 +55,7 @@ import { ProviderModelsSection } from "./ProviderModelsSection"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; import { RedactedSensitiveText } from "./RedactedSensitiveText"; -import { SettingsRow, SettingsSection } from "./settingsLayout"; +import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout"; import { AcpSessionManagementSection } from "./AcpSessionManagementSection"; import { FoldedSettingsSection } from "./FoldedSettingsSection"; import { readCodexSetupMode } from "./CodexSetupSection.logic"; @@ -515,6 +515,21 @@ interface ProviderInstanceCardProps { readonly isUpdating?: boolean | undefined; readonly onAcceptUrlAuth?: ((action: AcpRegistryUrlAuthAction) => void) | undefined; readonly environmentId?: EnvironmentId | undefined; + /** + * When a project is in scope, the enable control reads and writes that + * project's override instead of the environment's `instance.enabled`. + * `value` is the project's explicit override (`undefined` means it + * inherits the environment); `effectiveEnabled` is what the Switch shows. + * Omit this prop entirely for environment-wide editing. + */ + readonly projectEnablement?: + | { + readonly value: boolean | undefined; + readonly effectiveEnabled: boolean; + readonly onChange: (next: boolean) => void; + readonly onReset: () => void; + } + | undefined; readonly acpProjects?: | ReadonlyArray<{ readonly id: ProjectId; @@ -544,6 +559,9 @@ const EMPTY_ACP_PROJECTS: NonNullable * driver-specific `config.enabled` into the envelope on load and both * sides resolve through `resolveProviderInstanceEnabled` (an explicit * false wins, then envelope, then config, then the driver default). + * With `projectEnablement` set, the Switch instead reads and writes + * that project's override, leaving the environment's `instance.enabled` + * untouched. */ export function ProviderInstanceCard({ instanceId, @@ -573,8 +591,11 @@ export function ProviderInstanceCard({ onAcceptUrlAuth, environmentId, acpProjects = EMPTY_ACP_PROJECTS, + projectEnablement, }: ProviderInstanceCardProps) { - const enabled = resolveProviderInstanceEnabled(instance); + const enabled = projectEnablement + ? projectEnablement.effectiveEnabled + : resolveProviderInstanceEnabled(instance); const compatibility = enabled ? liveProvider?.compatibilityAdvisory : undefined; // A locally disabled provider reads "Disabled" with a muted dot even if its // last server status is stale. Enabled providers use the server status. @@ -670,6 +691,10 @@ export function ProviderInstanceCard({ }; const updateEnabled = (value: boolean) => { + if (projectEnablement) { + projectEnablement.onChange(value); + return; + } onUpdate({ ...instance, enabled: value }); }; @@ -968,7 +993,15 @@ export function ProviderInstanceCard({ - + + {projectEnablement?.value !== undefined ? ( + + ) : null} { }); }); +describe("buildProviderInstanceEnablementOverridePatch", () => { + const projectId = ProjectId.make("project-a"); + const instanceId = ProviderInstanceId.make("codex_work"); + const otherInstanceId = ProviderInstanceId.make("claudeAgent_personal"); + + it("sets an override when the project has none yet", () => { + const settings: Pick = { + projectSettingsOverrides: {}, + }; + + expect( + buildProviderInstanceEnablementOverridePatch(settings, projectId, instanceId, true), + ).toEqual({ [projectId]: { providerInstanceEnablement: { [instanceId]: true } } }); + }); + + it("adds an instance override alongside the project's other overrides", () => { + const settings: Pick = { + projectSettingsOverrides: { + [projectId]: { + newWorktreesStartFromOrigin: true, + providerInstanceEnablement: { [otherInstanceId]: false }, + }, + }, + }; + + expect( + buildProviderInstanceEnablementOverridePatch(settings, projectId, instanceId, true), + ).toEqual({ + [projectId]: { + newWorktreesStartFromOrigin: true, + providerInstanceEnablement: { [otherInstanceId]: false, [instanceId]: true }, + }, + }); + }); + + it("resets the only override by clearing the whole project entry", () => { + const settings: Pick = { + projectSettingsOverrides: { + [projectId]: { providerInstanceEnablement: { [instanceId]: true } }, + }, + }; + + expect( + buildProviderInstanceEnablementOverridePatch(settings, projectId, instanceId, undefined), + ).toEqual({ [projectId]: null }); + }); + + it("resets one instance while another instance's override and other keys survive", () => { + const settings: Pick = { + projectSettingsOverrides: { + [projectId]: { + newWorktreesStartFromOrigin: true, + providerInstanceEnablement: { [instanceId]: true, [otherInstanceId]: false }, + }, + }, + }; + + expect( + buildProviderInstanceEnablementOverridePatch(settings, projectId, instanceId, undefined), + ).toEqual({ + [projectId]: { + newWorktreesStartFromOrigin: true, + providerInstanceEnablement: { [otherInstanceId]: false }, + }, + }); + }); +}); + describe("remote operate access", () => { it("does not treat the old orchestration grant as provider management", () => { expect( diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts index 6e05957c76b5..4f53763c213e 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts @@ -3,7 +3,11 @@ import { AuthProvidersManageScope, type AuthSessionState, type EnvironmentId, + type ProjectId, + type ProjectSettingsOverrides, + type ProviderInstanceId, sessionGrantsScope, + type ServerSettings, type SessionGrantInput, } from "@t3tools/contracts"; @@ -105,6 +109,31 @@ export function resolveRemoteOperateAccess(input: { return resolveSessionOperateAccess(input); } +/** + * The patch for one instance's project override, following the envelope + * replacement rule for `projectSettingsOverrides`: the returned entry + * replaces the project's whole override set, and `null` removes it. + * `value: undefined` resets that single instance back to inheriting the + * environment's enablement, leaving any other overridden keys untouched. + */ +export function buildProviderInstanceEnablementOverridePatch( + settings: Pick, + projectId: ProjectId, + instanceId: ProviderInstanceId, + value: boolean | undefined, +): Record { + const current = settings.projectSettingsOverrides[projectId]; + const enablement = { ...current?.providerInstanceEnablement }; + if (value === undefined) delete enablement[instanceId]; + else enablement[instanceId] = value; + + const { providerInstanceEnablement: _omit, ...rest } = current ?? {}; + const nextEntry: ProjectSettingsOverrides = + Object.keys(enablement).length > 0 ? { ...rest, providerInstanceEnablement: enablement } : rest; + + return { [projectId]: Object.keys(nextEntry).length === 0 ? null : nextEntry }; +} + export function classifyProviderEnvironmentAccess(input: { readonly connectionPhase: EnvironmentConnectionPhase; readonly hasServerConfig: boolean; diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 76938b559990..7424fce0226b 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -19,9 +19,11 @@ import { type AcpRegistryUrlAuthAction, PROVIDER_DISPLAY_NAMES, ProviderDriverKind, + type ProjectId, type ProviderInstanceConfig, type ProviderInstanceId, resolveEnvironmentMachineKind, + resolveProjectProviderInstanceEnabled, resolveProviderInstanceEnabled, } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; @@ -116,6 +118,7 @@ import { } from "./settingsLayout"; import { buildProviderEnvironmentOptions, + buildProviderInstanceEnablementOverridePatch, classifyProviderEnvironmentAccess, isProviderSettingsEnvironmentAvailable, type ProviderEnvironmentAccess, @@ -279,6 +282,8 @@ interface ProviderSettingsTarget { readonly instanceId?: ProviderInstanceId; readonly scoped?: boolean; readonly environmentIds?: readonly EnvironmentId[]; + /** The project in scope, if any. Narrows the enable toggle to that project's override. */ + readonly projectId?: ProjectId | null; } export function ProviderSettingsPanel(target: ProviderSettingsTarget) { @@ -450,6 +455,7 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) { ? target.instanceId : undefined } + projectId={target.projectId ?? null} /> ) : null} @@ -460,16 +466,19 @@ function SelectedEnvironmentProviderSettings({ environment, deviceTabs, targetInstanceId, + projectId, }: { readonly environment: EnvironmentPresentation; readonly deviceTabs?: ReactNode; readonly targetInstanceId?: ProviderInstanceId | undefined; + readonly projectId: ProjectId | null; }) { return ( ); } @@ -478,10 +487,12 @@ function RemoteSessionGatedProviderSettings({ environment, deviceTabs, targetInstanceId, + projectId, }: { readonly environment: EnvironmentPresentation; readonly deviceTabs?: ReactNode; readonly targetInstanceId?: ProviderInstanceId | undefined; + readonly projectId: ProjectId | null; }) { const sessionState = useEnvironmentSessionState(environment.environmentId); const operateAccess = resolveRemoteOperateAccess({ @@ -495,6 +506,7 @@ function RemoteSessionGatedProviderSettings({ operateAccess={operateAccess} deviceTabs={deviceTabs} targetInstanceId={targetInstanceId} + projectId={projectId} /> ); } @@ -504,11 +516,13 @@ function AccessGatedProviderSettings({ operateAccess, deviceTabs, targetInstanceId, + projectId, }: { readonly environment: EnvironmentPresentation; readonly operateAccess: ProviderOperateAccess; readonly deviceTabs?: ReactNode; readonly targetInstanceId?: ProviderInstanceId | undefined; + readonly projectId: ProjectId | null; }) { const access = classifyProviderEnvironmentAccess({ connectionPhase: environment.connection.phase, @@ -531,6 +545,7 @@ function AccessGatedProviderSettings({ readOnly={access.kind === "read-only"} deviceTabs={deviceTabs} targetInstanceId={targetInstanceId} + projectId={projectId} /> ); } @@ -541,11 +556,14 @@ export function EnvironmentProviderSettings({ readOnly = false, deviceTabs, targetInstanceId, + projectId = null, }: { readonly environmentId: EnvironmentId; readonly environmentLabel: string; readonly deviceTabs?: ReactNode; readonly targetInstanceId?: ProviderInstanceId | undefined; + /** The project in scope, if any. Narrows the enable toggle to that project's override. */ + readonly projectId?: ProjectId | null; /** * Grey out and freeze every write control when this session's credential * lacks `providers:manage` on the environment. Selecting providers @@ -901,12 +919,47 @@ export function EnvironmentProviderSettings({ favorite.provider === row.instanceId ? Result.succeed(favorite.model) : Result.failVoid, ); const resetLabel = driverOption?.label ?? String(row.driver); + const projectEnablement = + projectId === null + ? undefined + : { + value: + settings.projectSettingsOverrides[projectId]?.providerInstanceEnablement?.[ + row.instanceId + ], + effectiveEnabled: resolveProjectProviderInstanceEnabled( + settings, + projectId, + row.instanceId, + ), + onChange: (next: boolean) => { + updateSettings({ + projectSettingsOverrides: buildProviderInstanceEnablementOverridePatch( + settings, + projectId, + row.instanceId, + next, + ), + }); + }, + onReset: () => { + updateSettings({ + projectSettingsOverrides: buildProviderInstanceEnablementOverridePatch( + settings, + projectId, + row.instanceId, + undefined, + ), + }); + }, + }; return ( acceptUrlAuthentication(row.instanceId, action) } diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 7b941f97a79e..ba28e3f8be41 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -804,13 +804,6 @@ function ScheduledTaskEditorDialog({ const upsertTask = useAtomCommand(serverEnvironment.upsertScheduledTask, { label: "scheduled task upsert", }); - const instanceEntries = useMemo( - () => - sortProviderInstanceEntries( - applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings), - ), - [providers, settings], - ); const [draft, setDraft] = useState(() => task ? taskToDraft(task) : { ...EMPTY_DRAFT, projectId: projects[0]?.id ?? "" }, ); @@ -827,6 +820,19 @@ function ScheduledTaskEditorDialog({ : task; const selectedProjectId = draft.projectId || projects[0]?.id || ""; const selectedProject = projects.find((project) => project.id === selectedProjectId); + // The task runs in selectedProject, so its model picker should reflect that + // project's effective enablement, not just the machine's. + const instanceEntries = useMemo( + () => + sortProviderInstanceEntries( + applyProviderInstanceSettings( + deriveProviderInstanceEntries(providers), + settings, + selectedProject?.id ?? null, + ), + ), + [providers, settings, selectedProject?.id], + ); // The real model picker is keyed by a `${instanceId}:${model}` string, which // is exactly how the draft stores its selection. diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index db1c7749ee7b..131e8539282c 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2183,7 +2183,7 @@ export function GeneralSettingsPanel() { const settings = useScopedSettings(); const updateSettings = useUpdateScopedSettings(); const navigate = useNavigate(); - const { scope, environment, connectedEnvironments } = useSettingsScope(); + const { scope, environment, connectedEnvironments, target } = useSettingsScope(); // The representative environment supplies the provider list for pickers; // a fanned-out model choice is validated against every target before it // is written. Per-machine tuning (background activity overrides) still @@ -2219,7 +2219,11 @@ export function GeneralSettingsPanel() { const textGenModel = textGenerationModelSelection.model; const textGenModelOptions = textGenerationModelSelection.options; const textGenerationModelInstanceEntries = sortProviderInstanceEntries( - applyProviderInstanceSettings(deriveProviderInstanceEntries(textGenerationProviders), settings), + applyProviderInstanceSettings( + deriveProviderInstanceEntries(textGenerationProviders), + settings, + target?.projectId ?? null, + ), ); const hasTextGenerationProvider = textGenerationModelInstanceEntries.some( (entry) => entry.enabled && entry.isAvailable, diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx index 37dcc5981172..4cf661f3c9c6 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx @@ -66,7 +66,7 @@ export function SourceControlWritingSettingsSection() { const updateSettings = useUpdateScopedSettings(); const mixedReviewGuidesEnabled = useScopedSettingsMixed(["reviewGuidesEnabled"]); const navigate = useNavigate(); - const { environment, connectedEnvironments, targets } = useSettingsScope(); + const { environment, connectedEnvironments, targets, target } = useSettingsScope(); // The representative supplies the provider list; a model choice is checked // against every target before it fans out. const environmentId = environment?.environmentId ?? null; @@ -119,7 +119,11 @@ export function SourceControlWritingSettingsSection() { textGenerationProviders, ); const instanceEntries = sortProviderInstanceEntries( - applyProviderInstanceSettings(deriveProviderInstanceEntries(textGenerationProviders), settings), + applyProviderInstanceSettings( + deriveProviderInstanceEntries(textGenerationProviders), + settings, + target?.projectId ?? null, + ), ); const canEnableDedicatedModel = instanceEntries.some( (entry) => diff --git a/apps/web/src/components/settings/useScopedModelAvailability.ts b/apps/web/src/components/settings/useScopedModelAvailability.ts index d188d29f2285..134f12af4326 100644 --- a/apps/web/src/components/settings/useScopedModelAvailability.ts +++ b/apps/web/src/components/settings/useScopedModelAvailability.ts @@ -35,6 +35,7 @@ export function useScopedModelDisabledReason( const entry = applyProviderInstanceSettings( deriveProviderInstanceEntries(config.providers), candidate.settings, + candidate.projectId, ).find((option) => option.instanceId === instanceId); const options = getCustomModelOptionsByInstance( mergeEnvironmentSettings(candidate.settings, settings), diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index 236b4363a026..35cab1c14e56 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -143,6 +143,7 @@ describe("applyProviderInstanceSettings", () => { enabled: false, }, }, + projectSettingsOverrides: {}, }); expect(entry?.enabled).toBe(false); @@ -157,6 +158,7 @@ describe("applyProviderInstanceSettings", () => { ]); const [entry] = applyProviderInstanceSettings(entries, { providerInstances: {}, + projectSettingsOverrides: {}, }); expect(entry?.enabled).toBe(false); @@ -173,6 +175,7 @@ describe("applyProviderInstanceSettings", () => { ]); const [entry] = applyProviderInstanceSettings(entries, { providerInstances: {}, + projectSettingsOverrides: {}, }); expect(entry?.enabled).toBe(false); @@ -194,6 +197,7 @@ describe("applyProviderInstanceSettings", () => { enabled: false, }, }, + projectSettingsOverrides: {}, }); expect(entry?.enabled).toBe(false); @@ -209,6 +213,7 @@ describe("applyProviderInstanceSettings", () => { ]); const [entry] = applyProviderInstanceSettings(entries, { providerInstances: {}, + projectSettingsOverrides: {}, }); expect(entry?.isDefault).toBe(true); @@ -220,7 +225,10 @@ describe("applyProviderInstanceSettings", () => { provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex", enabled: false }), provider({ provider: ProviderDriverKind.make("grok"), instanceId: "grok", enabled: true }), ]); - const [codex, grok] = applyProviderInstanceSettings(entries, { providerInstances: {} }); + const [codex, grok] = applyProviderInstanceSettings(entries, { + providerInstances: {}, + projectSettingsOverrides: {}, + }); // Settings decide over a stale probe: Codex starts on, Grok starts off. expect(codex?.enabled).toBe(true); @@ -260,6 +268,7 @@ describe("deriveProviderEntriesByEnvironment", () => { config: { agentId, registryIconUrl: `https://example.com/${agentId}.svg` }, }, }, + projectSettingsOverrides: {}, }, ] as const, ), @@ -620,6 +629,7 @@ describe("provider icon metadata", () => { const registryConfig = { agentId: "swe-agent", registryIconUrl: iconUrl }; const brandedEntries = applyProviderInstanceSettings(snapshots, { providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } }, + projectSettingsOverrides: {}, }); expect(brandedEntries[0]?.acpRegistryAgentId).toBe("swe-agent"); expect(brandedEntries[0]?.acpRegistryIconUrl).toBe(iconUrl); @@ -632,6 +642,7 @@ describe("provider icon metadata", () => { config: { ...registryConfig, source: "local", commandPath: "dsh" }, }, }, + projectSettingsOverrides: {}, }); expect(localEntry?.acpRegistryAgentId).toBeUndefined(); expect(localEntry?.acpRegistryIconUrl).toBeUndefined(); @@ -640,6 +651,7 @@ describe("provider icon metadata", () => { const [restoredEntry] = applyProviderInstanceSettings(localEntry ? [localEntry] : [], { providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } }, + projectSettingsOverrides: {}, }); expect(restoredEntry?.acpRegistryAgentId).toBe("swe-agent"); expect(restoredEntry?.acpRegistryIconUrl).toBe(iconUrl); diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index d6c6c5e2a945..171e59f1e17b 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -15,9 +15,9 @@ import { DEFAULT_MODEL_BY_PROVIDER, defaultInstanceIdForDriver, - isUnconfiguredDefaultInstanceEnabled, - resolveProviderInstanceEnabled, + resolveProjectProviderInstanceEnabled, type ModelSelection, + type ProjectId, type ProviderDriverKind, ProviderInstanceId, type ServerProvider, @@ -154,7 +154,11 @@ export function deriveProviderInstanceEntries( */ export function deriveProviderEntriesByEnvironment( providersByEnvironment: Iterable< - readonly [string, ReadonlyArray, Pick?] + readonly [ + string, + ReadonlyArray, + Pick?, + ] >, ): ReadonlyMap> { const byEnvironment = new Map>(); @@ -179,20 +183,22 @@ export function deriveProviderEntriesByEnvironment( * streamed state, since the server runs it with default config. Any other * instance missing from `providerInstances` is stale (for example * immediately after deletion) and is treated as disabled. + * + * `projectId` folds in that project's `providerInstanceEnablement` override + * (enable or disable in either direction over the machine value); `null` + * (no project in scope, or a caller that isn't project-aware) resolves the + * machine value only, matching every call site before this parameter existed. */ export function applyProviderInstanceSettings( entries: ReadonlyArray, - settings: Pick, + settings: Pick, + projectId: ProjectId | null = null, ): ReadonlyArray { return entries.map((entry) => { const explicitInstance = Object.hasOwn(settings.providerInstances, entry.instanceId) ? settings.providerInstances[entry.instanceId] : undefined; - const enabled = explicitInstance - ? resolveProviderInstanceEnabled(explicitInstance) - : entry.isDefault - ? isUnconfiguredDefaultInstanceEnabled(entry.instanceId) - : false; + const enabled = resolveProjectProviderInstanceEnabled(settings, projectId, entry.instanceId); if (entry.driverKind !== "acpRegistry" || explicitInstance === undefined) { return enabled === entry.enabled ? entry : { ...entry, enabled }; } diff --git a/apps/web/src/routes/settings.providers.tsx b/apps/web/src/routes/settings.providers.tsx index 33e02505b992..889020e4616a 100644 --- a/apps/web/src/routes/settings.providers.tsx +++ b/apps/web/src/routes/settings.providers.tsx @@ -11,7 +11,7 @@ import { useSettingsScope } from "../components/settings/SettingsScopeContext"; */ function SettingsProvidersRoute() { const target = Route.useSearch(); - const { environment, scope } = useSettingsScope(); + const { environment, scope, target: scopedTarget } = useSettingsScope(); if (!environment) { return (

@@ -25,6 +25,7 @@ function SettingsProvidersRoute() { ); diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index c92226a9ee4f..9c01efdd433a 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -27,6 +27,12 @@ again. Providers and diagnostics are per machine: they show one environment at a time, the primary one until you pick another. Every other setting fans out to the selection. +With a project selected, a provider's switch in **Settings → Providers** turns it on or off for +that project only, in either direction: a provider off on the machine can be used in one project, +and one on everywhere can be kept out of another. That project's model pickers follow the switch, +and new messages there cannot use a provider it turned off. Reset returns the project to the +machine's setting. Every other provider setting stays per machine. + On mobile, open **Settings** and use the filter in its header to choose connected environments and a project. The filter stays available in server-setting pages. With **All projects** selected, the **Server settings** categories and auto-settle controls in **Thread behavior** edit the diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 60145dba1a05..3a7b78831453 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -9,6 +9,7 @@ import { ClaudeSettings, DEFAULT_SERVER_SETTINGS, requiredScopesForProjectMutation, + resolveProjectProviderInstanceEnabled, resolveProviderInstanceEnabled, ServerSettings, ServerSettingsPatch, @@ -1063,6 +1064,78 @@ describe("ServerSettings.removeAgentCreditsOnMerge", () => { }); }); +describe("resolveProjectProviderInstanceEnabled", () => { + const projectId = "project-a" as ProjectId; + const instanceId = ProviderInstanceId.make("codex_work"); + + it("inherits the machine value when the project has no override", () => { + const settings: ServerSettings = { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false }, + }, + }; + expect(resolveProjectProviderInstanceEnabled(settings, projectId, instanceId)).toBe(false); + }); + + it("lets a project enable an instance the machine disabled", () => { + const settings: ServerSettings = { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false }, + }, + projectSettingsOverrides: { + [projectId]: { providerInstanceEnablement: { [instanceId]: true } }, + }, + }; + expect(resolveProjectProviderInstanceEnabled(settings, projectId, instanceId)).toBe(true); + }); + + it("lets a project disable an instance the machine enabled", () => { + const settings: ServerSettings = { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, + }, + projectSettingsOverrides: { + [projectId]: { providerInstanceEnablement: { [instanceId]: false } }, + }, + }; + expect(resolveProjectProviderInstanceEnabled(settings, projectId, instanceId)).toBe(false); + }); + + it("always resolves to the machine value when no project is in scope", () => { + const settings: ServerSettings = { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, + }, + projectSettingsOverrides: { + [projectId]: { providerInstanceEnablement: { [instanceId]: false } }, + }, + }; + expect(resolveProjectProviderInstanceEnabled(settings, null, instanceId)).toBe(true); + }); + + it("falls back to the unconfigured default when the instance has no settings entry", () => { + const settings: ServerSettings = { ...DEFAULT_SERVER_SETTINGS, providerInstances: {} }; + // `codex_work` has no entry in `providerInstances`, so with no project + // override either, this is the same unconfigured-default fallback a + // machine-wide (no project) lookup would use. + expect(resolveProjectProviderInstanceEnabled(settings, projectId, instanceId)).toBe( + resolveProjectProviderInstanceEnabled(settings, null, instanceId), + ); + + const overridden = { + ...settings, + projectSettingsOverrides: { + [projectId]: { providerInstanceEnablement: { [instanceId]: true } }, + }, + }; + expect(resolveProjectProviderInstanceEnabled(overridden, projectId, instanceId)).toBe(true); + }); +}); + describe("requiredScopesForProjectMutation", () => { it("adds the settings scope only when a mutation carries scripts", () => { const projectId = "project-1" as ProjectId; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2384e17c07f2..9c6fe6fcb874 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -976,6 +976,15 @@ export type ProjectScopedServerSettingKey = (typeof PROJECT_SCOPED_SERVER_SETTIN * One project's overrides. An absent key inherits the environment value; * `null` is a real value where the environment type is nullable (no default * model, no dedicated writer model, never auto-settle). + * + * `providerInstanceEnablement` sits outside `PROJECT_SCOPED_SERVER_SETTING_KEYS`: + * every other key mirrors a `ServerSettings` field, but there is no single + * machine-wide `enabled` field to mirror, only each instance's own + * `providerInstances[id].enabled`. It overrides per `ProviderInstanceId` + * instead: an instance id absent from the record inherits the machine value, + * and a present entry wins in either direction, so a project can turn on an + * instance the machine disabled, or turn off one the machine enabled. See + * `resolveProjectProviderInstanceEnabled` for the combined resolution. */ export const ProjectSettingsOverrides = Schema.Struct({ worktreeCleanup: Schema.optionalKey(WorktreeCleanup), @@ -1002,7 +1011,8 @@ export const ProjectSettingsOverrides = Schema.Struct({ sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), responseStreamingMode: Schema.optionalKey(ResponseStreamingMode), -} satisfies Record); + providerInstanceEnablement: Schema.optionalKey(Schema.Record(ProviderInstanceId, Schema.Boolean)), +} satisfies Record); export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type; /** @@ -1126,10 +1136,11 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE)), ), /** - * Per-project overrides of the keys in `PROJECT_SCOPED_SERVER_SETTING_KEYS`. - * The source of truth for project settings; `projectAgentBrowserAccessOverrides`, - * `projectAutoPullOverrides` and `projectScriptOverrides` are derived views - * kept for one release so older clients keep reading them. + * Per-project overrides of the keys in `PROJECT_SCOPED_SERVER_SETTING_KEYS`, + * plus `providerInstanceEnablement`. The source of truth for project + * settings; `projectAgentBrowserAccessOverrides`, `projectAutoPullOverrides` + * and `projectScriptOverrides` are derived views kept for one release so + * older clients keep reading them. */ projectSettingsOverrides: Schema.Record(ProjectId, ProjectSettingsOverrides).pipe( Schema.withDecodingDefault(Effect.succeed({})), @@ -1371,6 +1382,30 @@ export const resolveProviderInstanceEnabled = ( return instance.enabled ?? configEnabled ?? defaultEnabledForDriver(instance.driver); }; +/** + * Effective enablement for a provider instance within a project: the + * project's `providerInstanceEnablement` override when set, otherwise the + * machine-wide `resolveProviderInstanceEnabled` value. `projectId` of `null` + * (no project in scope) always resolves to the machine-wide value. + */ +export const resolveProjectProviderInstanceEnabled = ( + settings: Pick, + projectId: ProjectId | null, + instanceId: ProviderInstanceId, +): boolean => { + const override = + projectId === null + ? undefined + : settings.projectSettingsOverrides[projectId]?.providerInstanceEnablement?.[instanceId]; + if (override !== undefined) return override; + const instanceConfig = Object.hasOwn(settings.providerInstances, instanceId) + ? settings.providerInstances[instanceId] + : undefined; + return instanceConfig !== undefined + ? resolveProviderInstanceEnabled(instanceConfig) + : isUnconfiguredDefaultInstanceEnabled(instanceId); +}; + export const ServerSettingsOperation = Schema.Literals([ "normalize", "check-exists", diff --git a/packages/contracts/src/t3ProjectFile.ts b/packages/contracts/src/t3ProjectFile.ts index 5b3274be6348..d3a72dabf3d4 100644 --- a/packages/contracts/src/t3ProjectFile.ts +++ b/packages/contracts/src/t3ProjectFile.ts @@ -126,7 +126,10 @@ export const PROJECT_FILE_BACKED_SETTINGS = { defaultThreadEnvMode: { field: "defaultThreadEnvMode", builtIn: "local" }, worktreeSubmodules: { field: "worktreeSubmodules", builtIn: "recursive" }, } as const satisfies { - readonly [K in ProjectScopedServerSettingKey]?: { + // `providerInstanceEnablement` is scoped per project but has no matching + // environment-level `ServerSettings` field (see its doc comment in + // settings.ts), so it cannot be file-backed and is excluded here. + readonly [K in ProjectScopedServerSettingKey & keyof ServerSettings]?: { readonly field: { readonly [F in keyof T3ProjectFile]: T3ProjectFile[F] extends | Exclude diff --git a/packages/shared/src/projectSettings.ts b/packages/shared/src/projectSettings.ts index 2165fc8310ab..49d9a2095dce 100644 --- a/packages/shared/src/projectSettings.ts +++ b/packages/shared/src/projectSettings.ts @@ -6,6 +6,7 @@ import { type ProjectId, type ProjectScopedServerSettingKey, type ProjectSettingsOverrides, + resolveProjectProviderInstanceEnabled, type ResolvedServerSettings, type ServerSettings, type T3ProjectFile, @@ -13,7 +14,6 @@ import { type WorktreeCleanupRules, } from "@t3tools/contracts"; import { normalizeProjectPathForComparison } from "./path.ts"; -import { isModelSelectionProviderEnabled } from "./serverSettings.ts"; /** * Where a project-scoped value came from. The order is the priority order: @@ -174,12 +174,17 @@ function resolveProjectOverrides( // undefined; that is not an override. if (value === undefined) continue; // A model on a disabled provider falls back to the environment, like the - // environment-level guards do for these keys. + // environment-level guards do for these keys. An instance this project + // enabled via `providerInstanceEnablement` still counts as enabled here. if ( (key === "textGenerationModelSelection" || key === "defaultModelSelection") && value !== undefined && value !== null && - !isModelSelectionProviderEnabled(settings, value as ModelSelection) + !resolveProjectProviderInstanceEnabled( + settings, + projectId, + (value as ModelSelection).instanceId, + ) ) { continue; } From 24d780217f74c3fae3c1291294fb440dd4f3524b Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 10 Oct 2026 20:09:44 -0400 Subject: [PATCH 02/39] fix(settings): project provider switches hold on every launch and picker path Signed-off-by: Yordis Prieto --- .../settings/scheduledTaskDraft.test.ts | 7 +++ .../features/settings/scheduledTaskDraft.ts | 4 +- .../src/features/threads/ThreadComposer.tsx | 6 ++- .../threads/new-task-flow-provider.tsx | 4 ++ apps/mobile/src/lib/modelOptions.test.ts | 38 +++++++++++++++ apps/mobile/src/lib/modelOptions.ts | 40 +++++++++++----- .../src/mcp/toolkits/project/handlers.ts | 20 ++++++-- .../ThreadLaunchService.test.ts | 47 +++++++++++++++++-- .../orchestration-v2/ThreadLaunchService.ts | 10 +++- .../orchestration-v2/ThreadMessageIntake.ts | 10 ++-- .../ProviderInstanceRegistryHydration.test.ts | 22 +++++++++ .../ProviderInstanceRegistryHydration.ts | 9 +++- .../settings/ProviderInstanceCard.tsx | 37 ++++++++------- .../settings/SourceControlWritingSettings.tsx | 28 +++++++---- 14 files changed, 224 insertions(+), 58 deletions(-) diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts index 9f83e0c8fa32..d69c5124d9b7 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { DEFAULT_SERVER_SETTINGS, type ServerConfig, + ProviderDriverKind, ProviderInstanceId, ProjectId, ScheduledTaskId, @@ -339,6 +340,12 @@ describe("scheduled task model defaults", () => { resolve( { ...config, + settings: { + ...config.settings, + providerInstances: { + [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false }, + }, + }, providers: config.providers.map((provider) => ({ ...provider, enabled: false })), }, null, diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.ts index 1afbbbe5a4e6..707bd987e822 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.ts @@ -28,8 +28,8 @@ export function scheduledTaskDefaultModel( const configured = resolveProjectSettings(settings, project?.id ?? null, project).settings .defaultModelSelection; const projectDefaultSelection = - resolveDefaultableModelSelection(config, configured) ?? - resolveDefaultableModelSelection(config, settings.defaultModelSelection); + resolveDefaultableModelSelection(config, configured, project?.id ?? null) ?? + resolveDefaultableModelSelection(config, settings.defaultModelSelection, project?.id ?? null); return resolveNewTaskModelSelection({ draftSelection: null, projectDefaultSelection, diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 7bb52cda6d66..178f2852c6d9 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -435,7 +435,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const currentRuntimeMode = props.selectedThread.runtimeMode; const modelUnavailable = props.connectionState === "connected" && - isModelSelectionUnavailable(props.serverConfig, currentModelSelection); + isModelSelectionUnavailable( + props.serverConfig, + currentModelSelection, + props.selectedThread.projectId, + ); const selectedProviderStatus = useMemo(() => { if (!props.serverConfig) return null; return ( diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 58fadfe5c43f..2c37898dd8f9 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -581,15 +581,18 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const draftModelSelection = resolveSelectableModelSelection( selectedEnvironmentServerConfig, selectedProjectDraft.modelSelection ?? null, + selectedProject?.id ?? null, ); const projectDefaultModelSelection = resolveDefaultableModelSelection( selectedEnvironmentServerConfig, projectSettings.settings.defaultModelSelection, + selectedProject?.id ?? null, ); const storedStickyModelSelection = useStickyComposerModelSelection(); const stickyModelSelection = resolveDefaultableModelSelection( selectedEnvironmentServerConfig, storedStickyModelSelection, + selectedProject?.id ?? null, ); const modelOptions = useMemo( () => @@ -1120,6 +1123,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { resolveSelectableModelSelection( selectedEnvironmentServerConfig, draft.modelSelection ?? null, + selectedProject.id, ) ?? selectedModel; // A shared image or file is a task on its own; text is optional. if ((text.length === 0 && draft.attachments.length === 0) || !draftModelSelection) { diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index d1b5bbcbf0ea..25caea870cd0 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -574,6 +574,44 @@ describe("mobile model options", () => { expect(buildModelOptions(configWith(settings), null, undefined, null)).toHaveLength(1); }); + + it("marks a project-disabled instance's fallback selection unavailable instead of reviving it", () => { + const settings: ServerSettings = { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, + }, + projectSettingsOverrides: { + [projectId]: { providerInstanceEnablement: { [instanceId]: false } }, + }, + }; + const fallback = { instanceId, model: "model-a" }; + + const options = buildModelOptions(configWith(settings), fallback, undefined, projectId); + + expect(options).toHaveLength(1); + expect(options[0]).toMatchObject({ selection: fallback, isUnavailable: true }); + }); + + it("rejects a project-disabled instance's selection via resolveSelectableModelSelection", () => { + const settings: ServerSettings = { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, + }, + projectSettingsOverrides: { + [projectId]: { providerInstanceEnablement: { [instanceId]: false } }, + }, + }; + const selection = { instanceId, model: "model-a" }; + + expect( + resolveSelectableModelSelection(configWith(settings), selection, projectId), + ).toBeNull(); + expect(resolveSelectableModelSelection(configWith(settings), selection, null)).toBe( + selection, + ); + }); }); }); diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index a7262a898fa7..5ed0b3106d96 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -56,6 +56,26 @@ function providerDisplayLabel(provider: { return provider.instanceId; } +/** + * `provider.enabled` reflects the machine plus any project's keep-alive + * override, so it alone can't tell this project apart from one that merely + * shares the instance. Settings carry the real per-project view. + */ +function isProviderEnabledForProject( + config: T3ServerConfig | null | undefined, + provider: + | { readonly instanceId: ModelSelection["instanceId"]; readonly enabled: boolean } + | undefined, + projectId: ProjectId | null, +): boolean { + if (!provider) { + return false; + } + return config?.settings + ? resolveProjectProviderInstanceEnabled(config.settings, projectId, provider.instanceId) + : provider.enabled; +} + function normalizeSelectionOptions( selection: ModelSelection, capabilities: ModelCapabilities | null, @@ -85,6 +105,7 @@ function normalizeSelectionOptions( export function isModelSelectionUnavailable( config: T3ServerConfig | null | undefined, selection: ModelSelection | null | undefined, + projectId: ProjectId | null = null, ): boolean { if (!config || !selection) { return false; @@ -97,7 +118,7 @@ export function isModelSelectionUnavailable( return ( driver === "antigravity" && (!provider || - !provider.enabled || + !isProviderEnabledForProject(config, provider, projectId) || !provider.installed || provider.auth.status === "unauthenticated" || provider.availability === "unavailable" || @@ -113,6 +134,7 @@ export function isModelSelectionUnavailable( export function resolveSelectableModelSelection( config: T3ServerConfig | null | undefined, selection: ModelSelection | null, + projectId: ProjectId | null = null, ): ModelSelection | null { if (!selection || !config) { return selection; @@ -126,7 +148,7 @@ export function resolveSelectableModelSelection( return selection; } return provider && - provider.enabled && + isProviderEnabledForProject(config, provider, projectId) && provider.installed && provider.auth.status !== "unauthenticated" ? selection @@ -141,8 +163,9 @@ export function resolveSelectableModelSelection( export function resolveDefaultableModelSelection( config: T3ServerConfig | null | undefined, selection: ModelSelection | null, + projectId: ProjectId | null = null, ): ModelSelection | null { - const usable = resolveSelectableModelSelection(config, selection); + const usable = resolveSelectableModelSelection(config, selection, projectId); if (!usable || !config) { return usable; } @@ -177,15 +200,9 @@ export function buildModelOptions( const options = new Map(); for (const provider of config?.providers ?? []) { - // `provider.enabled` reflects the machine plus any project's keep-alive - // override, so it alone can't tell this project apart from one that - // merely shares the instance. Settings carry the real per-project view. - const isEnabledForProject = config?.settings - ? resolveProjectProviderInstanceEnabled(config.settings, projectId, provider.instanceId) - : provider.enabled; if ( (providerInstanceId !== undefined && provider.instanceId !== providerInstanceId) || - !isEnabledForProject || + !isProviderEnabledForProject(config, provider, projectId) || !provider.installed || provider.auth.status === "unauthenticated" || (provider.driver === "antigravity" && provider.availability === "unavailable") @@ -280,7 +297,8 @@ export function buildModelOptions( providerDriver, isDefault: false, isLegacy: model?.isLegacy === true, - ...(isModelSelectionUnavailable(config, fallbackModelSelection) || + ...(isModelSelectionUnavailable(config, fallbackModelSelection, projectId) || + (provider !== undefined && !isProviderEnabledForProject(config, provider, projectId)) || provider?.updateRequiredModels?.some((gated) => gated.slug === fallbackModelSelection.model) ? { isUnavailable: true } : {}), diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index edb732e400bc..c346ad62001f 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -157,11 +157,21 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { createdBy: "agent", creationSource: "mcp", }).pipe( - Effect.mapError((error) => - error._tag === "AttachmentClaimError" - ? new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message }) - : unavailable(), - ), + Effect.mapError((error) => { + if (error._tag === "AttachmentClaimError") { + return new OrchestratorMcpFailure({ + code: "orchestration_error", + message: error.message, + }); + } + if (error._tag === "ThreadManagementProviderInstanceDisabledError") { + return new OrchestratorMcpFailure({ + code: "invalid_request", + message: error.message, + }); + } + return unavailable(); + }), ); const thread = result.projection.thread; const run = result.projection.runs.find((run) => run.userMessageId === messageId); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 1fd532033f42..67193469c470 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -1769,6 +1769,33 @@ it.effect("replays a server-allocated launch", () => }), ); +it.effect("refuses a launch when the project disables the model's provider instance", () => { + const harness = makeHarness({ + serverSettings: { + projectSettingsOverrides: { + [projectId]: { providerInstanceEnablement: { [modelSelection.instanceId]: false } }, + }, + }, + }); + return Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const failed = yield* launches + .launch( + launchInput({ + command: "command:launch:project-disabled-instance", + thread: "thread:launch:project-disabled-instance", + message: "Hello", + }), + ) + .pipe(Effect.flip); + if (failed._tag !== "ThreadManagementProviderInstanceDisabledError") { + assert.fail(`expected ThreadManagementProviderInstanceDisabledError, got ${failed._tag}`); + } + assert.equal(failed.projectId, projectId); + assert.equal(failed.instanceId, modelSelection.instanceId); + }).pipe(Effect.provide(harness.layer)); +}); + it.effect("rejects a server-allocated launch replay with a mismatching thread id", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -1786,7 +1813,9 @@ it.effect("rejects a server-allocated launch replay with a mismatching thread id }) .pipe(Effect.flip); assert.notEqual(first.threadId, ThreadId.make("thread:launch:allocated-mismatch")); - assert.equal(failed._tag, "ThreadLaunchError"); + if (failed._tag !== "ThreadLaunchError") { + assert.fail(`expected ThreadLaunchError, got ${failed._tag}`); + } assert.equal(failed.operation, "create-thread"); assert.include(String(failed.cause), "cannot be replayed"); }).pipe(Effect.provide(harness.layer)); @@ -1808,7 +1837,9 @@ it.effect("rejects a server-allocated launch receipt from another project", () = projectId: otherProjectId, }) .pipe(Effect.flip); - assert.equal(failed._tag, "ThreadLaunchError"); + if (failed._tag !== "ThreadLaunchError") { + assert.fail(`expected ThreadLaunchError, got ${failed._tag}`); + } assert.equal(failed.operation, "resolve-project"); assert.equal(failed.threadId, first.threadId); assert.equal(failed.cause, "Project identity changed."); @@ -1832,7 +1863,9 @@ it.effect("rejects a server-allocated launch retry after the thread is deleted", threadId: first.threadId, }); const failed = yield* launches.launch(rest).pipe(Effect.flip); - assert.equal(failed._tag, "ThreadLaunchError"); + if (failed._tag !== "ThreadLaunchError") { + assert.fail(`expected ThreadLaunchError, got ${failed._tag}`); + } assert.equal(failed.operation, "create-thread"); assert.equal(failed.threadId, first.threadId); assert.equal(failed.cause, "Thread not found."); @@ -1873,7 +1906,9 @@ it.effect("does not treat an unrelated accepted command receipt as a launch", () message: "Should not become a launch", }); const failed = yield* launches.launch(rest).pipe(Effect.flip); - assert.equal(failed._tag, "ThreadLaunchError"); + if (failed._tag !== "ThreadLaunchError") { + assert.fail(`expected ThreadLaunchError, got ${failed._tag}`); + } assert.equal(failed.operation, "create-thread"); assert.include(String(failed.cause), "cannot be replayed"); const projection = yield* threads.getThreadProjection(threadId); @@ -1918,7 +1953,9 @@ it.effect("bounds concurrent first launches to one thread per command", () => continue; } const error = Cause.findErrorOption(result.cause).pipe(Option.getOrThrow); - assert.equal(error._tag, "ThreadLaunchError"); + if (error._tag !== "ThreadLaunchError") { + assert.fail(`expected ThreadLaunchError, got ${error._tag}`); + } assert.equal(error.operation, "create-thread"); assert.include(String(error.cause), "cannot be replayed"); const retried = yield* launches.launch(rest); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index d1a432bc9b52..2670a1217a4a 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -167,7 +167,10 @@ export class ThreadLaunchService extends Context.Service< { readonly launch: ( input: ThreadLaunchInput, - ) => Effect.Effect; + ) => Effect.Effect< + ThreadLaunchResult, + ThreadLaunchError | ThreadManagement.ThreadManagementProviderInstanceDisabledError + >; /** * Fails when a new worktree's base ref cannot give provisioning a commit. * `launch` accepts such a launch and reports the failure inside the @@ -743,6 +746,11 @@ const make = Effect.gen(function* () { const launch: ThreadLaunchService["Service"]["launch"] = Effect.fn("ThreadLaunchService.launch")( function* (input) { + yield* ThreadManagement.assertProviderInstanceEnabledForProject( + serverSettings, + input.projectId, + input.modelSelection.instanceId, + ); yield* ProjectCloneTracker.rejectCommandsDuringClone(cloneTracker, { type: "thread.create", projectId: input.projectId, diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts index b78966e55852..9fafdae2d344 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts @@ -193,12 +193,6 @@ export const launchThread = Effect.fn("ThreadMessageIntake.launchThread")(functi input: ThreadLaunch.ThreadLaunchInput, ) { const launches = yield* ThreadLaunch.ThreadLaunchService; - const serverSettings = yield* ServerSettings.ServerSettingsService; - yield* ThreadManagement.assertProviderInstanceEnabledForProject( - serverSettings, - input.projectId, - input.modelSelection.instanceId, - ); yield* AttachmentClaims.validateAttachmentLimits(input.initialMessage?.attachments ?? []); if (!input.initialMessage?.attachments.some(AttachmentClaims.attachmentIsPendingUpload)) { return yield* launches.launch(input); @@ -237,6 +231,10 @@ export const launchThread = Effect.fn("ThreadMessageIntake.launchThread")(functi ), ), Effect.tapError((error) => { + // A disabled-instance refusal happens before any dispatch is attempted. + if (error._tag === "ThreadManagementProviderInstanceDisabledError") { + return AttachmentClaims.releaseClaimedAttachments(claimed.claimedPaths); + } // Project/receipt reads precede message dispatch. The create-thread error // also wraps post-message projection reads, so its tag alone is not proof. const notAccepted = diff --git a/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts b/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts index de6d0b34f0bc..84c7a1923768 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts @@ -32,6 +32,28 @@ describe("deriveProviderInstanceConfigMap", () => { expect(merged[instanceId]).toEqual({ driver: ProviderDriverKind.make("codex"), enabled: true }); }); + it("clears a nested config.enabled:false flag when a project enables the instance", () => { + const settings = settingsWith({ + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("codex"), + config: { enabled: false }, + }, + }, + projectSettingsOverrides: { + [projectId]: { providerInstanceEnablement: { [instanceId]: true } }, + }, + }); + + const merged = deriveProviderInstanceConfigMap(settings); + + expect(merged[instanceId]).toEqual({ + driver: ProviderDriverKind.make("codex"), + enabled: true, + config: { enabled: true }, + }); + }); + it("leaves an already machine-enabled instance untouched", () => { const settings = settingsWith({ providerInstances: { diff --git a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts index 629910d1bff9..bbe9f6ddd3f6 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts @@ -25,6 +25,7 @@ */ import { defaultInstanceIdForDriver, + providerInstanceConfigEnabledFlag, resolveProviderInstanceEnabled, type ProviderInstanceConfig, type ProviderInstanceConfigMap, @@ -88,7 +89,13 @@ export const deriveProviderInstanceConfigMap = ( const instanceId = rawInstanceId as ProviderInstanceId; const existing = merged[instanceId]; if (existing === undefined || resolveProviderInstanceEnabled(existing)) continue; - merged[instanceId] = { ...existing, enabled: true }; + merged[instanceId] = { + ...existing, + enabled: true, + ...(providerInstanceConfigEnabledFlag(existing.config) === false + ? { config: { ...(existing.config as Record), enabled: true } } + : {}), + }; } } diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 4c12a2d12c70..ab09c19cd09b 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -472,6 +472,19 @@ export function ProviderEnvironmentSection(props: { ); } +/** + * When a project is in scope, the enable control reads and writes that + * project's override instead of the environment's `instance.enabled`. + * `value` is the project's explicit override (`undefined` means it + * inherits the environment); `effectiveEnabled` is what the Switch shows. + */ +interface ProjectEnablementControl { + readonly value: boolean | undefined; + readonly effectiveEnabled: boolean; + readonly onChange: (next: boolean) => void; + readonly onReset: () => void; +} + interface ProviderInstanceCardProps { readonly instanceId: ProviderInstanceId; readonly instance: ProviderInstanceConfig; @@ -515,21 +528,8 @@ interface ProviderInstanceCardProps { readonly isUpdating?: boolean | undefined; readonly onAcceptUrlAuth?: ((action: AcpRegistryUrlAuthAction) => void) | undefined; readonly environmentId?: EnvironmentId | undefined; - /** - * When a project is in scope, the enable control reads and writes that - * project's override instead of the environment's `instance.enabled`. - * `value` is the project's explicit override (`undefined` means it - * inherits the environment); `effectiveEnabled` is what the Switch shows. - * Omit this prop entirely for environment-wide editing. - */ - readonly projectEnablement?: - | { - readonly value: boolean | undefined; - readonly effectiveEnabled: boolean; - readonly onChange: (next: boolean) => void; - readonly onReset: () => void; - } - | undefined; + /** Omit this prop entirely for environment-wide editing. */ + readonly projectEnablement?: ProjectEnablementControl | undefined; readonly acpProjects?: | ReadonlyArray<{ readonly id: ProjectId; @@ -603,8 +603,9 @@ export function ProviderInstanceCard({ ? ((liveProvider?.status as ProviderStatusKey | undefined) ?? "warning") : "disabled"; const statusStyle = PROVIDER_STATUS_STYLES[statusKey]; + // `liveProvider.enabled` is machine-wide; a project override can turn it on. const summary = enabled - ? getProviderSummary(liveProvider) + ? getProviderSummary(liveProvider && { ...liveProvider, enabled: true }) : { headline: "Disabled", detail: null }; const authEmail = liveProvider?.auth.email?.trim(); const isAuthenticated = enabled && liveProvider?.auth.status === "authenticated"; @@ -998,13 +999,13 @@ export function ProviderInstanceCard({ ) : null} updateEnabled(Boolean(checked))} aria-label={`Enable ${displayName}`} /> diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx index 4cf661f3c9c6..2d19fde999f1 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx @@ -106,7 +106,26 @@ export function SourceControlWritingSettingsSection() { const textGenerationProviders = serverProviders.filter( (provider) => provider.supportsTextGeneration !== false, ); - const defaultModelSelection = resolveAppModelSelectionState(settings, textGenerationProviders); + const instanceEntries = sortProviderInstanceEntries( + applyProviderInstanceSettings( + deriveProviderInstanceEntries(textGenerationProviders), + settings, + target?.projectId ?? null, + ), + ); + // The environment default can name an instance this project turned off. + const projectEnabledInstanceIds = new Set( + instanceEntries + .filter((entry) => entry.enabled && entry.isAvailable) + .map((entry) => entry.instanceId), + ); + const projectEffectiveTextGenerationProviders = textGenerationProviders.filter((provider) => + projectEnabledInstanceIds.has(provider.instanceId), + ); + const defaultModelSelection = resolveAppModelSelectionState( + settings, + projectEffectiveTextGenerationProviders, + ); const usesDedicatedModel = settings.sourceControlWriterModelSelection !== null; const activeSelection = resolveAppModelSelectionState( { @@ -118,13 +137,6 @@ export function SourceControlWritingSettingsSection() { }, textGenerationProviders, ); - const instanceEntries = sortProviderInstanceEntries( - applyProviderInstanceSettings( - deriveProviderInstanceEntries(textGenerationProviders), - settings, - target?.projectId ?? null, - ), - ); const canEnableDedicatedModel = instanceEntries.some( (entry) => entry.instanceId === defaultModelSelection.instanceId && entry.enabled && entry.isAvailable, From 43f180246abe038c102843493362111a837564d2 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 10 Oct 2026 20:48:22 -0400 Subject: [PATCH 03/39] fix(settings): project provider disablement holds through antigravity, send, and text generation A project-disabled provider instance could still surface as the active antigravity selection, survive a refused message send with its upload left claimed, remain the active choice in the source control writer picker, and keep generating commit, PR, and branch text, because each of those paths only checked machine-wide enablement. Signed-off-by: Yordis Prieto --- apps/mobile/src/lib/modelOptions.test.ts | 34 ++++++++++++ apps/mobile/src/lib/modelOptions.ts | 21 ++++++- apps/server/src/git/GitManager.ts | 13 +++-- .../orchestration-v2/ThreadLaunchService.ts | 6 +- .../ThreadMessageIntake.test.ts | 55 ++++++++++++++++++- .../orchestration-v2/ThreadMessageIntake.ts | 3 +- apps/server/src/serverSettings.ts | 1 + .../settings/SourceControlWritingSettings.tsx | 6 +- packages/shared/src/serverSettings.test.ts | 44 +++++++++++++++ packages/shared/src/serverSettings.ts | 18 ++++++ 10 files changed, 188 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 25caea870cd0..04c64da2387e 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -612,6 +612,40 @@ describe("mobile model options", () => { selection, ); }); + + it("rejects a project-disabled antigravity instance's selection instead of keeping it", () => { + const antigravityInstanceId = ProviderInstanceId.make("antigravity_work"); + const config = { + providers: [ + { + instanceId: antigravityInstanceId, + driver: "antigravity", + displayName: "Antigravity Work", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + availability: "available", + models: [{ slug: "model-a", name: "Model A", isCustom: false, capabilities: null }], + }, + ], + settings: { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [antigravityInstanceId]: { + driver: ProviderDriverKind.make("antigravity"), + enabled: true, + }, + }, + projectSettingsOverrides: { + [projectId]: { providerInstanceEnablement: { [antigravityInstanceId]: false } }, + }, + }, + } as unknown as ServerConfig; + const selection = { instanceId: antigravityInstanceId, model: "model-a" }; + + expect(resolveSelectableModelSelection(config, selection, projectId)).toBeNull(); + expect(resolveSelectableModelSelection(config, selection, null)).toBe(selection); + }); }); }); diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 5ed0b3106d96..9f6364315bb1 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -76,6 +76,25 @@ function isProviderEnabledForProject( : provider.enabled; } +/** + * Whether the project has explicitly turned this instance off, as opposed + * to it merely being disabled or unreachable at the machine level. + */ +function isProjectDisabledOverride( + config: T3ServerConfig | null | undefined, + provider: { readonly instanceId: ModelSelection["instanceId"] } | undefined, + projectId: ProjectId | null, +): boolean { + if (!provider || !projectId || !config?.settings) { + return false; + } + return ( + config.settings.projectSettingsOverrides[projectId]?.providerInstanceEnablement?.[ + provider.instanceId + ] === false + ); +} + function normalizeSelectionOptions( selection: ModelSelection, capabilities: ModelCapabilities | null, @@ -145,7 +164,7 @@ export function resolveSelectableModelSelection( const driver = provider?.driver ?? config.settings?.providerInstances[selection.instanceId]?.driver; if (driver === "antigravity") { - return selection; + return isProjectDisabledOverride(config, provider, projectId) ? null : selection; } return provider && isProviderEnabledForProject(config, provider, projectId) && diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 6079ee714f69..78ed1b632385 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -731,7 +731,8 @@ export const make = Effect.gen(function* () { readonly threadId?: ThreadId | undefined; }) { const settings = yield* serverSettingsService.getSettings; - if (!hasProjectSettingsOverrides(settings)) return settings; + if (!hasProjectSettingsOverrides(settings)) + return { settings, projectId: null as ProjectId | null }; const projectId: ProjectId | null = yield* input.threadId !== undefined ? threads.getThreadShell(input.threadId).pipe( Effect.map((thread) => thread?.projectId ?? null), @@ -741,7 +742,7 @@ export const make = Effect.gen(function* () { Effect.map((project) => Option.getOrNull(project)?.projectId ?? null), Effect.orElseSucceed(() => null), ); - return resolveProjectSettings(settings, projectId).settings; + return { settings: resolveProjectSettings(settings, projectId).settings, projectId }; }); // Best effort: a settings read failure falls back to the default location. const readWorktreesDirectory = serverSettingsService.getSettings.pipe( @@ -755,7 +756,7 @@ export const make = Effect.gen(function* () { options?.submodules !== undefined ? options.submodules : yield* projectSettingsFor(input).pipe( - Effect.map((settings) => settings.worktreeSubmodules), + Effect.map(({ settings }) => settings.worktreeSubmodules), Effect.orElseSucceed(() => null), ); const worktreesDirectory = yield* readWorktreesDirectory; @@ -2798,7 +2799,7 @@ export const make = Effect.gen(function* () { worktreesDirectory: yield* readWorktreesDirectory, // Best effort: a settings read failure falls back to the checkout's t3.json. submodules: yield* projectSettingsFor(input).pipe( - Effect.map((settings) => settings.worktreeSubmodules), + Effect.map(({ settings }) => settings.worktreeSubmodules), Effect.orElseSucceed(() => null), ), }, @@ -2910,7 +2911,7 @@ export const make = Effect.gen(function* () { let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined; const textGenerationSettings = yield* projectSettingsFor(input).pipe( - Effect.flatMap((settings) => + Effect.flatMap(({ settings, projectId }) => settings.sourceControlWriterModelSelection === null ? Effect.succeed({ modelSelection: settings.textGenerationModelSelection, @@ -2920,7 +2921,7 @@ export const make = Effect.gen(function* () { Effect.map((providers) => ({ modelSelection: ServerSettings.resolveSourceControlWriterModelSelection( settings, - providers, + ServerSettings.filterProvidersForProject(settings, providers, projectId), ), style: settings.sourceControlWritingStyle, })), diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 2670a1217a4a..655e5d3b4f8b 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -343,7 +343,11 @@ const make = Effect.gen(function* () { ? settings.textGenerationModelSelection : ServerSettings.resolveSourceControlWriterModelSelection( settings, - yield* providerRegistry.getProviders, + ServerSettings.filterProvidersForProject( + settings, + yield* providerRegistry.getProviders, + input.projectId, + ), ); return yield* textGeneration .generateBranchName({ diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts index 1295222770da..c073b6c545df 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts @@ -7,6 +7,9 @@ import { ChatAttachmentId, CommandId, EventId, + MessageId, + ProjectId, + ProviderInstanceId, RuntimeRequestId, ThreadId, TurnItemId, @@ -26,7 +29,7 @@ import { OrchestratorDispatchError, } from "./Orchestrator.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; -import { dispatchCommand } from "./ThreadMessageIntake.ts"; +import { dispatchCommand, sendToThread } from "./ThreadMessageIntake.ts"; const layerIntakeTest = ServerConfig.layerTest(process.cwd(), { prefix: "t3-question-intake-", @@ -765,3 +768,53 @@ it.effect("applies the image budget across all questions before dispatch", () => expect(captured).toEqual([]); }).pipe(Effect.provide(layerIntakeTest)), ); + +it.effect("releases claimed copies when a project disables the message's provider instance", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const pendingId = ChatAttachmentId.make(createPendingAttachmentId()!); + NodeFS.writeFileSync( + NodePath.join(config.attachmentsDir, `${pendingId}.png`), + new Uint8Array([1, 2, 3]), + ); + const result = yield* sendToThread({ + projectId: ProjectId.make("project-disabled-send"), + commandId: CommandId.make("command-disabled-send"), + threadId: ThreadId.make("thread-disabled-send"), + messageId: MessageId.make("message-disabled-send"), + text: "Hello", + attachments: [ + { + type: "image", + id: pendingId, + name: "screen.png", + mimeType: "image/png", + sizeBytes: 3, + }, + ], + mode: "auto", + createdBy: "user", + creationSource: "web", + }).pipe( + Effect.provide( + Layer.mock(ThreadManagementService.ThreadManagementService)({ + sendToThread: (input) => + Effect.fail( + new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({ + projectId: input.projectId, + instanceId: ProviderInstanceId.make("codex"), + }), + ), + }), + ), + Effect.result, + ); + expect(result._tag).toBe("Failure"); + expect( + NodeFS.readdirSync(config.attachmentsDir).filter((entry) => + entry.startsWith("thread-disabled-send-"), + ), + ).toEqual([]); + expect(NodeFS.existsSync(NodePath.join(config.attachmentsDir, `${pendingId}.png`))).toBe(true); + }).pipe(Effect.provide(layerIntakeTest)), +); diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts index 9fafdae2d344..aac4ca774dae 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts @@ -14,6 +14,7 @@ import * as ThreadManagement from "./ThreadManagementService.ts"; // These dispatcher failures occur in receipt validation or planning, before // commitCommand. Generic dispatch errors can follow a commit and remain uncertain. +// A disabled-instance refusal is asserted before any dispatch is attempted. function dispatchWasNotAccepted( error: Orchestrator.OrchestratorV2Error | ThreadManagement.ThreadManagementError, ) { @@ -25,6 +26,7 @@ function dispatchWasNotAccepted( case "OrchestratorCommandIdConflictError": case "OrchestratorSubagentThreadReadOnlyError": case "OrchestratorThreadAboveModeLimitError": + case "ThreadManagementProviderInstanceDisabledError": return true; default: return false; @@ -231,7 +233,6 @@ export const launchThread = Effect.fn("ThreadMessageIntake.launchThread")(functi ), ), Effect.tapError((error) => { - // A disabled-instance refusal happens before any dispatch is attempted. if (error._tag === "ThreadManagementProviderInstanceDisabledError") { return AttachmentClaims.releaseClaimedAttachments(claimed.claimedPaths); } diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 0d39ee4ee79e..2c822035c4b3 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -73,6 +73,7 @@ import { } from "./sourceControl/sourceControlHostSecrets.ts"; export { + filterProvidersForProject, resolveReviewGuideModelSelection, resolveSourceControlWriterModelSelection, } from "@t3tools/shared/serverSettings"; diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx index 2d19fde999f1..775534a9024e 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx @@ -132,10 +132,10 @@ export function SourceControlWritingSettingsSection() { ...settings, textGenerationModelSelection: resolveSourceControlWriterModelSelection( settings, - textGenerationProviders, + projectEffectiveTextGenerationProviders, ), }, - textGenerationProviders, + projectEffectiveTextGenerationProviders, ); const canEnableDedicatedModel = instanceEntries.some( (entry) => @@ -143,7 +143,7 @@ export function SourceControlWritingSettingsSection() { ); const modelOptionsByInstance = getCustomModelOptionsByInstance( settings, - textGenerationProviders, + projectEffectiveTextGenerationProviders, activeSelection.instanceId, activeSelection.model, ); diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 8a0d21470d6b..f0a7ee7d1600 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -17,6 +17,7 @@ import { resolveModelPreferences, isModelSelectionProviderEnabled, parsePersistedServerObservabilitySettings, + filterProvidersForProject, resolveSourceControlWriterModelSelection, resolveProjectAgentBrowserAccess, resolveProjectAutoPull, @@ -518,6 +519,49 @@ describe("serverSettings helpers", () => { expect(settings.sourceControlWriterModelSelection).toBe(sourceControlWriterModelSelection); }); + it("falls back from a writer provider a project turned off, keeping it for other projects", () => { + const instanceId = ProviderInstanceId.make("codex_writer"); + const projectId = ProjectId.make("project-a"); + const sourceControlWriterModelSelection = createModelSelection(instanceId, "gpt-5.4-mini"); + const settings = { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true, config: {} }, + }, + projectSettingsOverrides: { + [projectId]: { providerInstanceEnablement: { [instanceId]: false } }, + }, + sourceControlWriterModelSelection, + }; + const provider = { + instanceId, + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-07-27T00:00:00.000Z", + availability: "available", + models: [], + slashCommands: [], + skills: [], + } satisfies ServerProvider; + + expect( + resolveSourceControlWriterModelSelection( + settings, + filterProvidersForProject(settings, [provider], projectId), + ), + ).toBe(settings.textGenerationModelSelection); + expect( + resolveSourceControlWriterModelSelection( + settings, + filterProvidersForProject(settings, [provider], null), + ), + ).toBe(sourceControlWriterModelSelection); + }); + it("replaces providerInstances maps so omitted instance fields are cleared", () => { const codexId = ProviderInstanceId.make("codex"); const current = { diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 65ddbcd9b5a7..990d02f7bcdb 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -2,6 +2,7 @@ import type { ModelPreferences } from "@t3tools/contracts/settings"; import { isProviderAvailable, isUnconfiguredDefaultInstanceEnabled, + resolveProjectProviderInstanceEnabled, resolveProviderInstanceEnabled, isProviderTextGenerationCapable, type ModelSelection, @@ -71,6 +72,23 @@ export function isModelSelectionProviderEnabled( return isUnconfiguredDefaultInstanceEnabled(selection.instanceId); } +/** + * Providers a project can actually use: machine-enabled instances minus + * whatever that project's `providerInstanceEnablement` override turned off. + * Feed this into provider-list-aware resolvers such as + * `resolveSourceControlWriterModelSelection` so a project-disabled instance + * falls back the same way a machine-disabled one does. + */ +export function filterProvidersForProject( + settings: Pick, + providers: ReadonlyArray, + projectId: ProjectId | null, +): ServerProvider[] { + return providers.filter((provider) => + resolveProjectProviderInstanceEnabled(settings, projectId, provider.instanceId), + ); +} + export function resolveSourceControlWriterModelSelection( settings: ServerSettings, providers?: ReadonlyArray, From 143e3fc784fdfa7d4b91a61ae14e9bf9873d097d Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 10 Oct 2026 20:58:36 -0400 Subject: [PATCH 04/39] fix(mobile): a project's antigravity opt-out holds before provider status loads Signed-off-by: Yordis Prieto --- apps/mobile/src/lib/modelOptions.test.ts | 3 +++ apps/mobile/src/lib/modelOptions.ts | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 04c64da2387e..95bdbb5b3402 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -645,6 +645,9 @@ describe("mobile model options", () => { expect(resolveSelectableModelSelection(config, selection, projectId)).toBeNull(); expect(resolveSelectableModelSelection(config, selection, null)).toBe(selection); + expect( + resolveSelectableModelSelection({ ...config, providers: [] }, selection, projectId), + ).toBeNull(); }); }); }); diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 9f6364315bb1..533d25da0c52 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -82,15 +82,15 @@ function isProviderEnabledForProject( */ function isProjectDisabledOverride( config: T3ServerConfig | null | undefined, - provider: { readonly instanceId: ModelSelection["instanceId"] } | undefined, + instanceId: ModelSelection["instanceId"], projectId: ProjectId | null, ): boolean { - if (!provider || !projectId || !config?.settings) { + if (!projectId || !config?.settings) { return false; } return ( config.settings.projectSettingsOverrides[projectId]?.providerInstanceEnablement?.[ - provider.instanceId + instanceId ] === false ); } @@ -164,7 +164,7 @@ export function resolveSelectableModelSelection( const driver = provider?.driver ?? config.settings?.providerInstances[selection.instanceId]?.driver; if (driver === "antigravity") { - return isProjectDisabledOverride(config, provider, projectId) ? null : selection; + return isProjectDisabledOverride(config, selection.instanceId, projectId) ? null : selection; } return provider && isProviderEnabledForProject(config, provider, projectId) && From 06721e148afcc273922625afdc8d44685d33decf Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 10 Oct 2026 21:07:46 -0400 Subject: [PATCH 05/39] fix(settings): project opt-ins keep their writer model and task drafts drop models a project turned off Signed-off-by: Yordis Prieto --- .../SettingsScheduledTasksRouteScreen.tsx | 31 +++++++--- apps/server/src/git/GitManager.ts | 3 +- .../orchestration-v2/ThreadLaunchService.ts | 7 +-- apps/server/src/serverSettings.ts | 1 - .../settings/ScheduledTasksSettings.tsx | 9 ++- .../settings/SourceControlWritingSettings.tsx | 1 + packages/shared/src/serverSettings.test.ts | 56 ++++++++++++++----- packages/shared/src/serverSettings.ts | 23 ++------ 8 files changed, 81 insertions(+), 50 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index e75a36fc26a8..e906b33020a7 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -1,5 +1,8 @@ import { useAtomValue } from "@effect/atom-react"; -import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; +import { + AuthOrchestrationOperateScope, + resolveProjectProviderInstanceEnabled, +} from "@t3tools/contracts"; import { readEnvironmentScope } from "../../state/session"; import type { EnvironmentId, @@ -760,14 +763,24 @@ function TaskForm({ }))} onSelect={(id) => { const project = projects.find((item) => item.id === id); - if (project) - setDraft({ - ...draft, - projectId: project.id, - modelSelection: draft.modelSelectionIsExplicit - ? draft.modelSelection - : scheduledTaskDefaultModel(config, project), - }); + if (!project) return; + const keepsExplicitModel = + draft.modelSelectionIsExplicit && + (draft.modelSelection === null || + !config?.settings || + resolveProjectProviderInstanceEnabled( + config.settings, + project.id, + draft.modelSelection.instanceId, + )); + setDraft({ + ...draft, + projectId: project.id, + modelSelection: keepsExplicitModel + ? draft.modelSelection + : scheduledTaskDefaultModel(config, project), + modelSelectionIsExplicit: keepsExplicitModel, + }); }} /> ({ modelSelection: ServerSettings.resolveSourceControlWriterModelSelection( settings, - ServerSettings.filterProvidersForProject(settings, providers, projectId), + providers, + projectId, ), style: settings.sourceControlWritingStyle, })), diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 655e5d3b4f8b..598f72c1b8b2 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -343,11 +343,8 @@ const make = Effect.gen(function* () { ? settings.textGenerationModelSelection : ServerSettings.resolveSourceControlWriterModelSelection( settings, - ServerSettings.filterProvidersForProject( - settings, - yield* providerRegistry.getProviders, - input.projectId, - ), + yield* providerRegistry.getProviders, + input.projectId, ); return yield* textGeneration .generateBranchName({ diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 2c822035c4b3..0d39ee4ee79e 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -73,7 +73,6 @@ import { } from "./sourceControl/sourceControlHostSecrets.ts"; export { - filterProvidersForProject, resolveReviewGuideModelSelection, resolveSourceControlWriterModelSelection, } from "@t3tools/shared/serverSettings"; diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index ba28e3f8be41..992bccd78bc0 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -837,9 +837,12 @@ function ScheduledTaskEditorDialog({ // The real model picker is keyed by a `${instanceId}:${model}` string, which // is exactly how the draft stores its selection. const firstInstance = instanceEntries[0]; - const activeSelection = draft.modelKey - ? splitModelKey(draft.modelKey) - : scheduledTaskDefaultModel(settings, selectedProject ?? null, instanceEntries); + const draftSelection = draft.modelKey ? splitModelKey(draft.modelKey) : null; + const activeSelection = + draftSelection && + instanceEntries.some((entry) => entry.instanceId === draftSelection.instanceId && entry.enabled) + ? draftSelection + : scheduledTaskDefaultModel(settings, selectedProject ?? null, instanceEntries); const activeInstanceId = activeSelection?.instanceId ?? firstInstance?.instanceId ?? ("" as ProviderInstanceId); const activeModel = activeSelection?.model ?? ""; diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx index 775534a9024e..4416823202a4 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx @@ -133,6 +133,7 @@ export function SourceControlWritingSettingsSection() { textGenerationModelSelection: resolveSourceControlWriterModelSelection( settings, projectEffectiveTextGenerationProviders, + target?.projectId ?? null, ), }, projectEffectiveTextGenerationProviders, diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index f0a7ee7d1600..1d2e40394f6b 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -17,7 +17,6 @@ import { resolveModelPreferences, isModelSelectionProviderEnabled, parsePersistedServerObservabilitySettings, - filterProvidersForProject, resolveSourceControlWriterModelSelection, resolveProjectAgentBrowserAccess, resolveProjectAutoPull, @@ -548,18 +547,49 @@ describe("serverSettings helpers", () => { skills: [], } satisfies ServerProvider; - expect( - resolveSourceControlWriterModelSelection( - settings, - filterProvidersForProject(settings, [provider], projectId), - ), - ).toBe(settings.textGenerationModelSelection); - expect( - resolveSourceControlWriterModelSelection( - settings, - filterProvidersForProject(settings, [provider], null), - ), - ).toBe(sourceControlWriterModelSelection); + expect(resolveSourceControlWriterModelSelection(settings, [provider], projectId)).toBe( + settings.textGenerationModelSelection, + ); + expect(resolveSourceControlWriterModelSelection(settings, [provider], null)).toBe( + sourceControlWriterModelSelection, + ); + }); + + it("keeps a writer on an instance the project turned on while the machine has it off", () => { + const instanceId = ProviderInstanceId.make("codex_writer"); + const projectId = ProjectId.make("project-a"); + const sourceControlWriterModelSelection = createModelSelection(instanceId, "gpt-5.4-mini"); + const settings = { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false, config: {} }, + }, + projectSettingsOverrides: { + [projectId]: { providerInstanceEnablement: { [instanceId]: true } }, + }, + sourceControlWriterModelSelection, + }; + const provider = { + instanceId, + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-07-27T00:00:00.000Z", + availability: "available", + models: [], + slashCommands: [], + skills: [], + } satisfies ServerProvider; + + expect(resolveSourceControlWriterModelSelection(settings, [provider], projectId)).toBe( + sourceControlWriterModelSelection, + ); + expect(resolveSourceControlWriterModelSelection(settings, [provider], null)).toBe( + settings.textGenerationModelSelection, + ); }); it("replaces providerInstances maps so omitted instance fields are cleared", () => { diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 990d02f7bcdb..97bdc27b185b 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -72,29 +72,16 @@ export function isModelSelectionProviderEnabled( return isUnconfiguredDefaultInstanceEnabled(selection.instanceId); } -/** - * Providers a project can actually use: machine-enabled instances minus - * whatever that project's `providerInstanceEnablement` override turned off. - * Feed this into provider-list-aware resolvers such as - * `resolveSourceControlWriterModelSelection` so a project-disabled instance - * falls back the same way a machine-disabled one does. - */ -export function filterProvidersForProject( - settings: Pick, - providers: ReadonlyArray, - projectId: ProjectId | null, -): ServerProvider[] { - return providers.filter((provider) => - resolveProjectProviderInstanceEnabled(settings, projectId, provider.instanceId), - ); -} - export function resolveSourceControlWriterModelSelection( settings: ServerSettings, providers?: ReadonlyArray, + projectId: ProjectId | null = null, ): ModelSelection { const selection = settings.sourceControlWriterModelSelection; - if (!selection || !isModelSelectionProviderEnabled(settings, selection)) { + if ( + !selection || + !resolveProjectProviderInstanceEnabled(settings, projectId, selection.instanceId) + ) { return settings.textGenerationModelSelection; } if (providers === undefined) { From 1b840d703e1bd1db1a09ad7784a95a0fbfd79793 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 10 Oct 2026 21:53:56 -0400 Subject: [PATCH 06/39] fix(server): retries of accepted commands replay even after a project turns the provider off Signed-off-by: Yordis Prieto --- .../server/src/mcp/McpToolAccess.race.test.ts | 19 ++- ...OrchestratorMcpToolkit.integration.test.ts | 8 ++ ...skCancelNativeSubagent.integration.test.ts | 5 + ...laudeAutomaticDelivery.integration.test.ts | 4 + .../ThreadLaunchService.test.ts | 136 +++++++++++++++++- .../orchestration-v2/ThreadLaunchService.ts | 12 +- .../ThreadManagementService.test.ts | 30 +++- .../ThreadManagementService.ts | 62 ++++++-- .../orchestration-v2/ThreadMessageIntake.ts | 12 +- .../src/orchestration-v2/ThreadStop.test.ts | 2 + .../ThreadTitleRegenerationService.test.ts | 2 + .../src/orchestration-v2/runtimeLayer.ts | 1 + .../src/relay/AgentAwarenessRelay.test.ts | 1 + 13 files changed, 261 insertions(+), 33 deletions(-) diff --git a/apps/server/src/mcp/McpToolAccess.race.test.ts b/apps/server/src/mcp/McpToolAccess.race.test.ts index 1f9196494cdf..362527324ae9 100644 --- a/apps/server/src/mcp/McpToolAccess.race.test.ts +++ b/apps/server/src/mcp/McpToolAccess.race.test.ts @@ -17,6 +17,7 @@ import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import { DispatchModeLimit } from "../orchestration-v2/DispatchModeLimit.ts"; import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; +import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; @@ -185,7 +186,14 @@ it.layer(layerOrchestrator)("writesThreads against a mode raise", (it) => { const shell = yield* projections.getThreadShell(threadId); assert.equal(shell?.runtimeMode, "full-access"); assert.equal(shell?.title, "Before"); - }).pipe(Effect.provide(ThreadManagement.layer.pipe(Layer.provide(ServerSettings.layerTest())))), + }).pipe( + Effect.provide( + ThreadManagement.layer.pipe( + Layer.provide(ServerSettings.layerTest()), + Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), + ), + ), + ), ); it.effect("lets the write through when the thread stays within the caller's modes", () => @@ -196,6 +204,13 @@ it.layer(layerOrchestrator)("writesThreads against a mode raise", (it) => { const outcome = yield* renameRacingTheUser(threadId, false); assert.deepEqual(outcome, { renamed: true }); assert.equal((yield* projections.getThreadShell(threadId))?.title, "Renamed by the agent"); - }).pipe(Effect.provide(ThreadManagement.layer.pipe(Layer.provide(ServerSettings.layerTest())))), + }).pipe( + Effect.provide( + ThreadManagement.layer.pipe( + Layer.provide(ServerSettings.layerTest()), + Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), + ), + ), + ), ); }); diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index d15b43146562..f6f61618da48 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -54,10 +54,12 @@ import { ClaudeProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/Claud import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; import { CodexOrchestratorReplayHarness } from "../orchestration-v2/Adapters/CodexAdapterV2.testkit.ts"; import { threadShellFromProjection } from "../orchestration-v2/ProjectionStore.ts"; +import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts"; import * as EventSink from "../orchestration-v2/EventSink.ts"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as ServerSettings from "../serverSettings.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ThreadSearch from "../orchestration-v2/ThreadSearch.ts"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; @@ -616,6 +618,9 @@ describe("orchestrator MCP toolkit", () => { ThreadManagementService.layer.pipe( Layer.provide(layerOrchestrator), Layer.provide(ServerSettings.layerTest()), + Layer.provide( + CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), + ), ), ); const layerProviderRegistry = ProviderRegistryMock.layer([ @@ -3816,6 +3821,9 @@ describe("orchestrator MCP toolkit", () => { ThreadManagementService.layer.pipe( Layer.provide(layerOrchestrator), Layer.provide(ServerSettings.layerTest()), + Layer.provide( + CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), + ), ), ); const layerProviderRegistry = ProviderRegistryMock.layer([ diff --git a/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts b/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts index 8b533ffbced7..bb15248f9cba 100644 --- a/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts +++ b/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts @@ -22,6 +22,7 @@ import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import { ClaudeProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; +import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts"; import * as EffectWorker from "../orchestration-v2/EffectWorker.ts"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; @@ -30,6 +31,7 @@ import * as ThreadManagementService from "../orchestration-v2/ThreadManagementSe import * as ServerSettings from "../serverSettings.ts"; import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; @@ -137,6 +139,9 @@ it.effect("task_cancel ends a delegated child's background native subagent", () ThreadManagementService.layer.pipe( Layer.provide(layerOrchestrator), Layer.provide(ServerSettings.layerTest()), + Layer.provide( + CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), + ), ), ); const layerTest = OrchestratorMcpService.layer.pipe( diff --git a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts index a5d92eff8a72..279c9bced95e 100644 --- a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts +++ b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts @@ -29,6 +29,7 @@ import * as SecretRequests from "../secrets/SecretRequests.ts"; import * as Scheduler from "../scheduling/Scheduler.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ClaudeAdapterV2 from "./Adapters/ClaudeAdapterV2.ts"; +import * as CommandReceiptStore from "./CommandReceiptStore.ts"; import * as EffectWorker from "./EffectWorker.ts"; import * as EventSink from "./EventSink.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; @@ -323,6 +324,9 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con ), Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})), Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), + Layer.provide( + CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), + ), Layer.provide( Layer.mergeAll( NodeCrypto.layer, diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 67193469c470..ca1a058a2cef 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -119,11 +119,12 @@ function makeHarness(options: HarnessOptions = {}) { { databaseLayer: layerDatabase, runEffectWorker: false }, ); const layerServerSettings = ServerSettings.layerTest(options.serverSettings); + const layerReceipts = CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase)); const layerThreadManagement = ThreadManagement.layer.pipe( Layer.provide(layerOrchestrator), Layer.provide(layerServerSettings), + Layer.provide(layerReceipts), ); - const layerReceipts = CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase)); const layerOutbox = EffectOutbox.layer.pipe(Layer.provide(layerDatabase)); const createWorktree = vi.fn( options.createWorktree ?? @@ -1796,6 +1797,43 @@ it.effect("refuses a launch when the project disables the model's provider insta }).pipe(Effect.provide(harness.layer)); }); +it.effect("replays an accepted launch after the project disables the instance", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const settings = yield* ServerSettings.ServerSettingsService; + const input = launchInput({ + command: "command:launch:retry-after-disable", + thread: "thread:launch:retry-after-disable", + message: "Hello", + }); + const first = yield* launches.launch(input); + + yield* settings.updateSettings({ + projectSettingsOverrides: { + [projectId]: { providerInstanceEnablement: { [modelSelection.instanceId]: false } }, + }, + }); + + const retry = yield* launches.launch(input); + assert.equal(retry.threadId, first.threadId); + assert.isTrue(retry.resumed); + + const fresh = yield* launches + .launch( + launchInput({ + command: "command:launch:fresh-after-disable", + thread: "thread:launch:fresh-after-disable", + message: "Hello", + }), + ) + .pipe(Effect.flip); + if (fresh._tag !== "ThreadManagementProviderInstanceDisabledError") { + assert.fail(`expected ThreadManagementProviderInstanceDisabledError, got ${fresh._tag}`); + } + }).pipe(Effect.provide(harness.layer)); +}); + it.effect("rejects a server-allocated launch replay with a mismatching thread id", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -2571,3 +2609,99 @@ it.effect.each([ }).pipe(Effect.provide(harness.layer)); }), ); + +it.effect("replays an accepted send after the project disables the instance", () => { + const harness = makeHarness(); + const layerFiles = ServerConfig.layerTest(process.cwd(), { prefix: "t3-send-retry-" }).pipe( + Layer.provideMerge(NodeServices.layer), + ); + return Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const settings = yield* ServerSettings.ServerSettingsService; + const created = yield* launches.launch( + launchInput({ + command: "command:send:retry-setup", + thread: "thread:send:retry-after-disable", + }), + ); + + const sendInput = { + commandId: CommandId.make("command:send:retry-after-disable"), + projectId, + threadId: created.threadId, + messageId: MessageId.make("message:send:retry-after-disable"), + text: "Hello again", + attachments: [], + mode: "auto" as const, + createdBy: "user" as const, + creationSource: "web" as const, + }; + const first = yield* ThreadMessageIntake.sendToThread(sendInput); + + yield* settings.updateSettings({ + projectSettingsOverrides: { + [projectId]: { providerInstanceEnablement: { [modelSelection.instanceId]: false } }, + }, + }); + + const retry = yield* ThreadMessageIntake.sendToThread(sendInput); + assert.equal(retry.message.id, first.message.id); + + const fresh = yield* ThreadMessageIntake.sendToThread({ + ...sendInput, + commandId: CommandId.make("command:send:fresh-after-disable"), + messageId: MessageId.make("message:send:fresh-after-disable"), + }).pipe(Effect.flip); + if (fresh._tag !== "ThreadManagementProviderInstanceDisabledError") { + assert.fail(`expected ThreadManagementProviderInstanceDisabledError, got ${fresh._tag}`); + } + }).pipe(Effect.provide(Layer.mergeAll(harness.layer, layerFiles))); +}); + +it.effect("replays an accepted message.dispatch after the project disables the instance", () => { + const harness = makeHarness(); + const layerFiles = ServerConfig.layerTest(process.cwd(), { prefix: "t3-dispatch-retry-" }).pipe( + Layer.provideMerge(NodeServices.layer), + ); + return Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const settings = yield* ServerSettings.ServerSettingsService; + const created = yield* launches.launch( + launchInput({ + command: "command:dispatch:retry-setup", + thread: "thread:dispatch:retry-after-disable", + }), + ); + + const dispatchInput = { + type: "message.dispatch" as const, + commandId: CommandId.make("command:dispatch:retry-after-disable"), + threadId: created.threadId, + messageId: MessageId.make("message:dispatch:retry-after-disable"), + text: "Hello again", + attachments: [], + dispatchMode: { type: "queue_after_active" as const }, + createdBy: "user" as const, + creationSource: "web" as const, + }; + const first = yield* ThreadMessageIntake.dispatchCommand(dispatchInput); + + yield* settings.updateSettings({ + projectSettingsOverrides: { + [projectId]: { providerInstanceEnablement: { [modelSelection.instanceId]: false } }, + }, + }); + + const retry = yield* ThreadMessageIntake.dispatchCommand(dispatchInput); + assert.deepEqual(retry, first); + + const fresh = yield* ThreadMessageIntake.dispatchCommand({ + ...dispatchInput, + commandId: CommandId.make("command:dispatch:fresh-after-disable"), + messageId: MessageId.make("message:dispatch:fresh-after-disable"), + }).pipe(Effect.flip); + if (fresh._tag !== "ThreadManagementProviderInstanceDisabledError") { + assert.fail(`expected ThreadManagementProviderInstanceDisabledError, got ${fresh._tag}`); + } + }).pipe(Effect.provide(Layer.mergeAll(harness.layer, layerFiles))); +}); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 598f72c1b8b2..6a231bd670ce 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -747,11 +747,6 @@ const make = Effect.gen(function* () { const launch: ThreadLaunchService["Service"]["launch"] = Effect.fn("ThreadLaunchService.launch")( function* (input) { - yield* ThreadManagement.assertProviderInstanceEnabledForProject( - serverSettings, - input.projectId, - input.modelSelection.instanceId, - ); yield* ProjectCloneTracker.rejectCommandsDuringClone(cloneTracker, { type: "thread.create", projectId: input.projectId, @@ -773,6 +768,13 @@ const make = Effect.gen(function* () { } const launchReceipt = yield* readReceipt(input, input.commandId); + yield* ThreadManagement.assertProviderInstanceEnabledForProject( + serverSettings, + receipts, + input.commandId, + input.projectId, + input.modelSelection.instanceId, + ); return yield* Effect.gen(function* () { // A retried launch has no client-supplied id to replay against, so // recover the thread id its accepted create was recorded under before diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index a579393173b8..ec99a6d90e76 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -32,6 +32,8 @@ import * as TestClock from "effect/testing/TestClock"; import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; import * as ServerSettings from "../serverSettings.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as CommandReceiptStore from "./CommandReceiptStore.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; @@ -274,9 +276,14 @@ it("derives thread management messages from structural error attributes", () => ); }); +const layerReceiptsTest = CommandReceiptStore.layer.pipe( + Layer.provide(SqlitePersistence.layerMemory), +); + it.effect("refuses a new turn when the project effectively disables the instance", () => { const projectId = ProjectId.make("project:thread-management:gate-disabled"); const instanceId = ProviderInstanceId.make("codex_work"); + const commandId = CommandId.make("command:thread-management:gate-disabled"); const layerTest = ServerSettings.layerTest({ providerInstances: { [instanceId]: { driver: "codex", enabled: true } }, projectSettingsOverrides: { @@ -286,9 +293,12 @@ it.effect("refuses a new turn when the project effectively disables the instance return Effect.gen(function* () { const settings = yield* ServerSettings.ServerSettingsService; + const receipts = yield* CommandReceiptStore.CommandReceiptStoreV2; const error = yield* Effect.flip( ThreadManagementService.assertProviderInstanceEnabledForProject( settings, + receipts, + commandId, projectId, instanceId, ), @@ -301,12 +311,13 @@ it.effect("refuses a new turn when the project effectively disables the instance expect(error.message).toBe( `Provider instance "${instanceId}" is disabled for project ${projectId}.`, ); - }).pipe(Effect.provide(layerTest)); + }).pipe(Effect.provide(layerTest), Effect.provide(layerReceiptsTest)); }); it.effect("allows a new turn when the project enables an instance the machine disabled", () => { const projectId = ProjectId.make("project:thread-management:gate-enabled"); const instanceId = ProviderInstanceId.make("codex_work"); + const commandId = CommandId.make("command:thread-management:gate-enabled"); const layerTest = ServerSettings.layerTest({ providerInstances: { [instanceId]: { driver: "codex", enabled: false } }, projectSettingsOverrides: { @@ -316,26 +327,33 @@ it.effect("allows a new turn when the project enables an instance the machine di return Effect.gen(function* () { const settings = yield* ServerSettings.ServerSettingsService; + const receipts = yield* CommandReceiptStore.CommandReceiptStoreV2; yield* ThreadManagementService.assertProviderInstanceEnabledForProject( settings, + receipts, + commandId, projectId, instanceId, ); - }).pipe(Effect.provide(layerTest)); + }).pipe(Effect.provide(layerTest), Effect.provide(layerReceiptsTest)); }); it.effect("inherits the machine value when the project has no override", () => { const projectId = ProjectId.make("project:thread-management:gate-inherit"); const instanceId = ProviderInstanceId.make("codex_work"); + const commandId = CommandId.make("command:thread-management:gate-inherit"); const layerTest = ServerSettings.layerTest({ providerInstances: { [instanceId]: { driver: "codex", enabled: false } }, }); return Effect.gen(function* () { const settings = yield* ServerSettings.ServerSettingsService; + const receipts = yield* CommandReceiptStore.CommandReceiptStoreV2; const error = yield* Effect.flip( ThreadManagementService.assertProviderInstanceEnabledForProject( settings, + receipts, + commandId, projectId, instanceId, ), @@ -344,7 +362,7 @@ it.effect("inherits the machine value when the project has no override", () => { expect(error).toBeInstanceOf( ThreadManagementService.ThreadManagementProviderInstanceDisabledError, ); - }).pipe(Effect.provide(layerTest)); + }).pipe(Effect.provide(layerTest), Effect.provide(layerReceiptsTest)); }); it.effect("classifies projection infrastructure failures separately from a missing thread", () => { @@ -363,6 +381,7 @@ it.effect("classifies projection infrastructure failures separately from a missi }), ), Layer.provide(ServerSettings.layerTest()), + Layer.provide(layerReceiptsTest), ); return Effect.gen(function* () { @@ -398,6 +417,7 @@ it.effect("uses thread-not-found only after a projection loads outside the proje }), ), Layer.provide(ServerSettings.layerTest()), + Layer.provide(layerReceiptsTest), ); return Effect.gen(function* () { @@ -431,6 +451,7 @@ it.effect("preserves failed legacy materialization when reading checkpoint conte ), ), Layer.provide(ServerSettings.layerTest()), + Layer.provide(layerReceiptsTest), ); return Effect.gen(function* () { const service = yield* ThreadManagementService.ThreadManagementService; @@ -480,6 +501,7 @@ it.effect.each([ }), ), Layer.provide(ServerSettings.layerTest()), + Layer.provide(layerReceiptsTest), ); const service = yield* ThreadManagementService.ThreadManagementService.pipe( Effect.provide(layerTest), @@ -552,6 +574,7 @@ it.effect("waitForThread reads the run again only when the run updates", () => }), ), Layer.provide(ServerSettings.layerTest()), + Layer.provide(layerReceiptsTest), ); const service = yield* ThreadManagementService.ThreadManagementService.pipe( Effect.provide(layerTest), @@ -815,6 +838,7 @@ it.effect.each([ }), ), Layer.provide(ServerSettings.layerTest()), + Layer.provide(layerReceiptsTest), ); const service = yield* ThreadManagementService.ThreadManagementService.pipe( Effect.provide(layerTest), diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 93d44f7cb305..2e789092bce3 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -43,6 +43,7 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as CommandReceiptStore from "./CommandReceiptStore.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; @@ -297,23 +298,30 @@ export type ThreadManagementError = typeof ThreadManagementError.Type; type ThreadManagementFailure = ThreadManagementError | Orchestrator.OrchestratorV2Error; /** - * Refuses to start a new turn on an instance a project has effectively - * disabled, in either direction: the machine disabled it with no project - * override, or the project disabled it over a machine-enabled instance. - * Shared by `sendToThread` and by the intake layer's new-thread and - * existing-thread dispatch paths, which all read settings themselves. + * Refuses a new turn on an instance the project has effectively disabled. + * A command that already has a receipt was let through before, so its retry + * replays instead of being refused under newer settings. */ export const assertProviderInstanceEnabledForProject = ( settings: ServerSettings.ServerSettingsService["Service"], + receipts: CommandReceiptStore.CommandReceiptStoreV2["Service"], + commandId: CommandId, projectId: ProjectId, instanceId: ProviderInstanceId, ): Effect.Effect => - settings.getSettings.pipe( - Effect.orDie, - Effect.flatMap((current) => - resolveProjectProviderInstanceEnabled(current, projectId, instanceId) + receipts.getByCommandId(commandId).pipe( + Effect.orElseSucceed(() => Option.none()), + Effect.flatMap((receipt) => + Option.isSome(receipt) ? Effect.void - : new ThreadManagementProviderInstanceDisabledError({ projectId, instanceId }), + : settings.getSettings.pipe( + Effect.orDie, + Effect.flatMap((current) => + resolveProjectProviderInstanceEnabled(current, projectId, instanceId) + ? Effect.void + : new ThreadManagementProviderInstanceDisabledError({ projectId, instanceId }), + ), + ), ), ); @@ -377,6 +385,16 @@ export interface ThreadManagementServiceShape { readonly sendToThread: ( input: ThreadManagementSendInput, ) => Effect.Effect; + /** + * Bound form of `assertProviderInstanceEnabledForProject` for callers that + * already hold a `ThreadManagementService` instance but not the settings + * and receipt-store services it needs. + */ + readonly assertProviderInstanceEnabled: (input: { + readonly projectId: ProjectId; + readonly commandId: CommandId; + readonly instanceId: ProviderInstanceId; + }) => Effect.Effect; readonly waitForThread: ( input: ThreadManagementWaitInput, ) => Effect.Effect; @@ -496,6 +514,17 @@ const make = Effect.gen(function* () { const layerScope = yield* Effect.scope; const legacyImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; const serverSettings = yield* ServerSettings.ServerSettingsService; + const receipts = yield* CommandReceiptStore.CommandReceiptStoreV2; + + const assertProviderInstanceEnabled: ThreadManagementServiceShape["assertProviderInstanceEnabled"] = + (input) => + assertProviderInstanceEnabledForProject( + serverSettings, + receipts, + input.commandId, + input.projectId, + input.instanceId, + ); const ensureLegacyTranscript = Effect.fn( "orchestrationV2.threadManagement.ensureLegacyTranscript", @@ -646,11 +675,11 @@ const make = Effect.gen(function* () { threadId: input.threadId, }); } - yield* assertProviderInstanceEnabledForProject( - serverSettings, - input.projectId, - (input.modelSelection ?? target.thread.modelSelection).instanceId, - ); + yield* assertProviderInstanceEnabled({ + projectId: input.projectId, + commandId: input.commandId, + instanceId: (input.modelSelection ?? target.thread.modelSelection).instanceId, + }); const steerableRun = latestSteerableRun(target); let dispatchMode: Extract< @@ -1056,6 +1085,7 @@ const make = Effect.gen(function* () { getThreadShell: orchestrator.getThreadShell, listProjectThreads, sendToThread, + assertProviderInstanceEnabled, waitForThread, settleAfterRun, settleThread, @@ -1087,6 +1117,7 @@ export const layer: Layer.Layer< | Orchestrator.OrchestratorV2 | ProviderSessionManager.ProviderSessionManagerV2 | ServerSettings.ServerSettingsService + | CommandReceiptStore.CommandReceiptStoreV2 > = Layer.effect(ThreadManagementService, make).pipe( Layer.provide(layerLegacyV1ThreadImporterNoop), Layer.provide(ThreadCommandExecutor.layer), @@ -1099,4 +1130,5 @@ export const layerWithLegacyImporter: Layer.Layer< | Orchestrator.OrchestratorV2 | ProviderSessionManager.ProviderSessionManagerV2 | ServerSettings.ServerSettingsService + | CommandReceiptStore.CommandReceiptStoreV2 > = Layer.effect(ThreadManagementService, make).pipe(Layer.provide(ThreadCommandExecutor.layer)); diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts index aac4ca774dae..458901c6a0bd 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts @@ -5,7 +5,6 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { resolveAttachmentPath } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; -import * as ServerSettings from "../serverSettings.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as AttachmentClaims from "./AttachmentClaims.ts"; @@ -58,13 +57,12 @@ export const dispatchCommand = Effect.fn("ThreadMessageIntake.dispatchCommand")( ) { const threads = yield* ThreadManagement.ThreadManagementService; if (command.type === "message.dispatch") { - const serverSettings = yield* ServerSettings.ServerSettingsService; const projection = yield* threads.getThreadRecords(command.threadId, []); - yield* ThreadManagement.assertProviderInstanceEnabledForProject( - serverSettings, - projection.thread.projectId, - (command.modelSelection ?? projection.thread.modelSelection).instanceId, - ); + yield* threads.assertProviderInstanceEnabled({ + projectId: projection.thread.projectId, + commandId: command.commandId, + instanceId: (command.modelSelection ?? projection.thread.modelSelection).instanceId, + }); } if (command.type === "runtime-request.respond" && command.attachmentsByQuestionId) { const config = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/orchestration-v2/ThreadStop.test.ts b/apps/server/src/orchestration-v2/ThreadStop.test.ts index 8bbebdbe22ac..c0732ba2a54c 100644 --- a/apps/server/src/orchestration-v2/ThreadStop.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStop.test.ts @@ -24,6 +24,7 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ServerSettings from "../serverSettings.ts"; import { CLAUDE_PROVIDER } from "./Adapters/ClaudeAdapterV2.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; +import * as CommandReceiptStore from "./CommandReceiptStore.ts"; import { OrchestrationEffectRequestV2 } from "./EffectOutbox.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -56,6 +57,7 @@ const layerTest = ThreadManagementService.layer.pipe( ), ), Layer.provideMerge(ServerSettings.layerTest()), + Layer.provideMerge(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), ); const encodeEffectRequest = Schema.encodeSync(Schema.fromJsonString(OrchestrationEffectRequestV2)); diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts index b294409edd50..34ab4ed71610 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts @@ -21,6 +21,7 @@ import * as ProjectStore from "./ProjectStore.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; +import * as CommandReceiptStore from "./CommandReceiptStore.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; @@ -58,6 +59,7 @@ function makeHarness( const layerThreadManagement = ThreadManagement.layer.pipe( Layer.provide(layerOrchestrator), Layer.provide(layerServerSettings), + Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), ); const layerOutbox = EffectOutbox.layer.pipe(Layer.provide(layerDatabase)); const generateThreadTitle = vi.fn( diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 623b1e084118..18ad606d9767 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -244,6 +244,7 @@ const layerThreadManagementProvided = ThreadManagementService.layerWithLegacyImp Layer.mergeAll( layerOrchestratorProvided, layerLegacyV1ThreadImporterProvided, + layerCommandReceiptStoreProvided, layerProviderSessionManagerProvided, ), ), diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index bc26ef7f021a..c09f4bf4b69e 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -208,6 +208,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( getProjectThread: unused, listProjectThreads: unused, sendToThread: unused, + assertProviderInstanceEnabled: unused, waitForThread: unused, settleAfterRun: unused, settleThread: unused, From d66561cdc8835791641e66e3140e8f8fa5555e3a Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 10 Oct 2026 22:06:09 -0400 Subject: [PATCH 07/39] fix(settings): text generation leaves a provider the project turned off Signed-off-by: Yordis Prieto --- apps/server/src/serverSettings.ts | 37 +++----------------- packages/shared/src/projectSettings.test.ts | 15 ++++++++ packages/shared/src/projectSettings.ts | 10 ++++++ packages/shared/src/serverSettings.ts | 38 +++++++++++++++++++++ 4 files changed, 68 insertions(+), 32 deletions(-) diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 0d39ee4ee79e..54515e383c6c 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -11,9 +11,6 @@ * @module ServerSettings */ import { - DEFAULT_TEXT_GENERATION_MODEL, - DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, - DEFAULT_MODEL_BY_PROVIDER, DEFAULT_SERVER_SETTINGS, ModelSelection, ProjectId, @@ -61,6 +58,7 @@ import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJso import { applyServerSettingsPatch, deriveLegacyProjectOverrides, + fallbackTextGenerationModelSelection, isModelSelectionProviderEnabled, } from "@t3tools/shared/serverSettings"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; @@ -560,17 +558,6 @@ function migrateLegacyProviderSettings( const ACP_REGISTRY_DRIVER = ProviderDriverKind.make("acpRegistry"); -const TEXT_GENERATION_FALLBACK_DRIVERS = [ - "codex", - "claudeAgent", - "cursor", - "grok", - "muse", - "pi", - "opencode", - "antigravity", -].map((driver) => ProviderDriverKind.make(driver)); - /** ACP Registry instances reject every application text-generation operation. */ function selectionSupportsTextGeneration( settings: ServerSettings, @@ -587,27 +574,13 @@ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings } function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { - // The built-in default instances in preference order. A slot without an - // explicit instance uses its driver's default enabled state. - const fallback = TEXT_GENERATION_FALLBACK_DRIVERS.find((driver) => + // A slot without an explicit instance uses its driver's default enabled state. + const fallback = fallbackTextGenerationModelSelection((instanceId, driver) => resolveProviderInstanceEnabled( - settings.providerInstances[defaultInstanceIdForDriver(driver)] ?? { driver, config: {} }, + settings.providerInstances[instanceId] ?? { driver, config: {} }, ), ); - if (!fallback) { - return settings; - } - - return { - ...settings, - textGenerationModelSelection: { - instanceId: ProviderInstanceId.make(fallback), - model: - DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ?? - DEFAULT_MODEL_BY_PROVIDER[fallback] ?? - DEFAULT_TEXT_GENERATION_MODEL, - } satisfies ModelSelection, - }; + return fallback === null ? settings : { ...settings, textGenerationModelSelection: fallback }; } // Values under these keys are compared as a whole — never stripped field-by-field. diff --git a/packages/shared/src/projectSettings.test.ts b/packages/shared/src/projectSettings.test.ts index 58fa99da4ea4..d9ee8901929e 100644 --- a/packages/shared/src/projectSettings.test.ts +++ b/packages/shared/src/projectSettings.test.ts @@ -147,6 +147,21 @@ describe("resolveProjectSettings", () => { expect(resolved.settings.defaultModelSelection).toBeNull(); expect(resolved.sources.defaultModelSelection).toBe("environment"); }); + + it("moves text generation off an instance the project turned off", () => { + const codex = ProviderInstanceId.make("codex"); + const settings = { + ...DEFAULT_SERVER_SETTINGS, + textGenerationModelSelection: createModelSelection(codex, "gpt-5"), + projectSettingsOverrides: { [projectId]: { providerInstanceEnablement: { [codex]: false } } }, + }; + expect( + resolveProjectSettings(settings, projectId).settings.textGenerationModelSelection.instanceId, + ).toBe(ProviderInstanceId.make("claudeAgent")); + expect( + resolveProjectSettings(settings, otherProjectId).settings.textGenerationModelSelection, + ).toEqual(settings.textGenerationModelSelection); + }); }); describe("resolveProjectSettings with a t3.json", () => { diff --git a/packages/shared/src/projectSettings.ts b/packages/shared/src/projectSettings.ts index 49d9a2095dce..7045087d30d7 100644 --- a/packages/shared/src/projectSettings.ts +++ b/packages/shared/src/projectSettings.ts @@ -14,6 +14,7 @@ import { type WorktreeCleanupRules, } from "@t3tools/contracts"; import { normalizeProjectPathForComparison } from "./path.ts"; +import { fallbackTextGenerationModelSelection } from "./serverSettings.ts"; /** * Where a project-scoped value came from. The order is the priority order: @@ -191,6 +192,15 @@ function resolveProjectOverrides( effective[key] = value; sources[key] = "project"; } + // Like the environment guard, text generation never runs on an instance + // this project turned off. + const textGeneration = effective.textGenerationModelSelection as ModelSelection; + if (!resolveProjectProviderInstanceEnabled(settings, projectId, textGeneration.instanceId)) { + const fallback = fallbackTextGenerationModelSelection((instanceId) => + resolveProjectProviderInstanceEnabled(settings, projectId, instanceId), + ); + if (fallback !== null) effective.textGenerationModelSelection = fallback; + } return { settings: effective as ServerSettings, sources, overrides }; } diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 97bdc27b185b..41caf4780471 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -1,5 +1,9 @@ import type { ModelPreferences } from "@t3tools/contracts/settings"; import { + DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_TEXT_GENERATION_MODEL, + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, + defaultInstanceIdForDriver, isProviderAvailable, isUnconfiguredDefaultInstanceEnabled, resolveProjectProviderInstanceEnabled, @@ -9,6 +13,8 @@ import { type ProjectId, type ProjectScopedServerSettingKey, type ProjectSettingsOverrides, + ProviderDriverKind, + type ProviderInstanceId, type ServerProvider, ServerSettings, type ServerSettingsPatch, @@ -72,6 +78,38 @@ export function isModelSelectionProviderEnabled( return isUnconfiguredDefaultInstanceEnabled(selection.instanceId); } +const TEXT_GENERATION_FALLBACK_DRIVERS = [ + "codex", + "claudeAgent", + "cursor", + "grok", + "muse", + "pi", + "opencode", + "antigravity", +].map((driver) => ProviderDriverKind.make(driver)); + +/** + * The text generation model on the first built-in default instance, in + * preference order, that `isEnabled` accepts. `null` when none is. + */ +export function fallbackTextGenerationModelSelection( + isEnabled: (instanceId: ProviderInstanceId, driver: ProviderDriverKind) => boolean, +): ModelSelection | null { + const driver = TEXT_GENERATION_FALLBACK_DRIVERS.find((candidate) => + isEnabled(defaultInstanceIdForDriver(candidate), candidate), + ); + return driver === undefined + ? null + : { + instanceId: defaultInstanceIdForDriver(driver), + model: + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[driver] ?? + DEFAULT_MODEL_BY_PROVIDER[driver] ?? + DEFAULT_TEXT_GENERATION_MODEL, + }; +} + export function resolveSourceControlWriterModelSelection( settings: ServerSettings, providers?: ReadonlyArray, From f6dd4bdd658a7d5e5939a8f449aa6e0107ee31b0 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 10 Oct 2026 22:13:38 -0400 Subject: [PATCH 08/39] fix(settings): text generation finds a configured provider when every default one is off Signed-off-by: Yordis Prieto --- .../ThreadManagementService.test.ts | 6 +-- apps/server/src/serverSettings.ts | 10 +++-- packages/shared/src/projectSettings.test.ts | 21 ++++++++++ packages/shared/src/projectSettings.ts | 5 ++- packages/shared/src/serverSettings.ts | 40 ++++++++++++------- 5 files changed, 58 insertions(+), 24 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index ec99a6d90e76..c87345b8740d 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -311,7 +311,7 @@ it.effect("refuses a new turn when the project effectively disables the instance expect(error.message).toBe( `Provider instance "${instanceId}" is disabled for project ${projectId}.`, ); - }).pipe(Effect.provide(layerTest), Effect.provide(layerReceiptsTest)); + }).pipe(Effect.provide(Layer.merge(layerTest, layerReceiptsTest))); }); it.effect("allows a new turn when the project enables an instance the machine disabled", () => { @@ -335,7 +335,7 @@ it.effect("allows a new turn when the project enables an instance the machine di projectId, instanceId, ); - }).pipe(Effect.provide(layerTest), Effect.provide(layerReceiptsTest)); + }).pipe(Effect.provide(Layer.merge(layerTest, layerReceiptsTest))); }); it.effect("inherits the machine value when the project has no override", () => { @@ -362,7 +362,7 @@ it.effect("inherits the machine value when the project has no override", () => { expect(error).toBeInstanceOf( ThreadManagementService.ThreadManagementProviderInstanceDisabledError, ); - }).pipe(Effect.provide(layerTest), Effect.provide(layerReceiptsTest)); + }).pipe(Effect.provide(Layer.merge(layerTest, layerReceiptsTest))); }); it.effect("classifies projection infrastructure failures separately from a missing thread", () => { diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 54515e383c6c..ca50480969dc 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -575,10 +575,12 @@ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { // A slot without an explicit instance uses its driver's default enabled state. - const fallback = fallbackTextGenerationModelSelection((instanceId, driver) => - resolveProviderInstanceEnabled( - settings.providerInstances[instanceId] ?? { driver, config: {} }, - ), + const fallback = fallbackTextGenerationModelSelection( + settings.providerInstances, + (instanceId, driver) => + resolveProviderInstanceEnabled( + settings.providerInstances[instanceId] ?? { driver, config: {} }, + ), ); return fallback === null ? settings : { ...settings, textGenerationModelSelection: fallback }; } diff --git a/packages/shared/src/projectSettings.test.ts b/packages/shared/src/projectSettings.test.ts index d9ee8901929e..3e86060b9d6a 100644 --- a/packages/shared/src/projectSettings.test.ts +++ b/packages/shared/src/projectSettings.test.ts @@ -162,6 +162,27 @@ describe("resolveProjectSettings", () => { resolveProjectSettings(settings, otherProjectId).settings.textGenerationModelSelection, ).toEqual(settings.textGenerationModelSelection); }); + + it("falls back to a configured instance when every built-in one is off for the project", () => { + const codex = ProviderInstanceId.make("codex"); + const codexWork = ProviderInstanceId.make("codex_work"); + const settings = { + ...DEFAULT_SERVER_SETTINGS, + textGenerationModelSelection: createModelSelection(codex, "gpt-5"), + providerInstances: { [codexWork]: { driver: ProviderDriverKind.make("codex"), config: {} } }, + projectSettingsOverrides: { + [projectId]: { + providerInstanceEnablement: { + [codex]: false, + [ProviderInstanceId.make("claudeAgent")]: false, + }, + }, + }, + }; + expect( + resolveProjectSettings(settings, projectId).settings.textGenerationModelSelection.instanceId, + ).toBe(codexWork); + }); }); describe("resolveProjectSettings with a t3.json", () => { diff --git a/packages/shared/src/projectSettings.ts b/packages/shared/src/projectSettings.ts index 7045087d30d7..8fd016e3507e 100644 --- a/packages/shared/src/projectSettings.ts +++ b/packages/shared/src/projectSettings.ts @@ -196,8 +196,9 @@ function resolveProjectOverrides( // this project turned off. const textGeneration = effective.textGenerationModelSelection as ModelSelection; if (!resolveProjectProviderInstanceEnabled(settings, projectId, textGeneration.instanceId)) { - const fallback = fallbackTextGenerationModelSelection((instanceId) => - resolveProjectProviderInstanceEnabled(settings, projectId, instanceId), + const fallback = fallbackTextGenerationModelSelection( + settings.providerInstances, + (instanceId) => resolveProjectProviderInstanceEnabled(settings, projectId, instanceId), ); if (fallback !== null) effective.textGenerationModelSelection = fallback; } diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 41caf4780471..9310cbb63d91 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -14,7 +14,7 @@ import { type ProjectScopedServerSettingKey, type ProjectSettingsOverrides, ProviderDriverKind, - type ProviderInstanceId, + ProviderInstanceId, type ServerProvider, ServerSettings, type ServerSettingsPatch, @@ -90,24 +90,34 @@ const TEXT_GENERATION_FALLBACK_DRIVERS = [ ].map((driver) => ProviderDriverKind.make(driver)); /** - * The text generation model on the first built-in default instance, in - * preference order, that `isEnabled` accepts. `null` when none is. + * The text generation model on the first instance `isEnabled` accepts: the + * built-in default instances in preference order, then configured instances + * of those drivers. `null` when none is. */ export function fallbackTextGenerationModelSelection( + providerInstances: ServerSettings["providerInstances"], isEnabled: (instanceId: ProviderInstanceId, driver: ProviderDriverKind) => boolean, ): ModelSelection | null { - const driver = TEXT_GENERATION_FALLBACK_DRIVERS.find((candidate) => - isEnabled(defaultInstanceIdForDriver(candidate), candidate), - ); - return driver === undefined - ? null - : { - instanceId: defaultInstanceIdForDriver(driver), - model: - DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[driver] ?? - DEFAULT_MODEL_BY_PROVIDER[driver] ?? - DEFAULT_TEXT_GENERATION_MODEL, - }; + const candidates = [ + ...TEXT_GENERATION_FALLBACK_DRIVERS.map( + (driver) => [defaultInstanceIdForDriver(driver), driver] as const, + ), + ...Object.entries(providerInstances).flatMap(([instanceId, instance]) => + TEXT_GENERATION_FALLBACK_DRIVERS.includes(instance.driver) + ? [[ProviderInstanceId.make(instanceId), instance.driver] as const] + : [], + ), + ]; + const match = candidates.find(([instanceId, driver]) => isEnabled(instanceId, driver)); + if (match === undefined) return null; + const [instanceId, driver] = match; + return { + instanceId, + model: + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[driver] ?? + DEFAULT_MODEL_BY_PROVIDER[driver] ?? + DEFAULT_TEXT_GENERATION_MODEL, + }; } export function resolveSourceControlWriterModelSelection( From 4e89219cb3db6bb811265b3ff8b31d4612711f5d Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 10 Oct 2026 22:25:52 -0400 Subject: [PATCH 09/39] fix(server): agent-started child threads respect a project's provider switches Signed-off-by: Yordis Prieto --- .../src/mcp/OrchestratorMcpService.test.ts | 47 +++++++++++++++++++ apps/server/src/mcp/OrchestratorMcpService.ts | 19 ++++++++ .../orchestration-v2/ThreadMessageIntake.ts | 8 ++++ 3 files changed, 74 insertions(+) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index d0a85e352a29..3553e5d24d66 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -1099,6 +1099,7 @@ describe("OrchestratorMcpService provider resolution", () => { const layerDependencies = Layer.mergeAll( NodeServices.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ + assertProviderInstanceEnabled: () => Effect.void, getThreadRecords: (threadId) => Effect.succeed( threadId === parentThreadId @@ -1197,6 +1198,7 @@ describe("OrchestratorMcpService provider resolution", () => { const layerDependencies = Layer.mergeAll( NodeServices.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ + assertProviderInstanceEnabled: () => Effect.void, getThreadRecords: (threadId) => Effect.succeed( threadId === parentThreadId @@ -1321,6 +1323,49 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); + it.effect("refuses delegation to a provider the project turned off", () => + Effect.gen(function* () { + const layerDependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: () => Effect.succeed(parentProjection([])), + assertProviderInstanceEnabled: ({ projectId, instanceId }) => + Effect.fail( + new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({ + projectId, + instanceId, + }), + ), + dispatch: () => Effect.die("a refused delegation must not dispatch"), + }), + providerRegistryLayer([ + providerSnapshot({ + instanceId: codexInstanceId, + driver: ProviderDriverKind.make("codex"), + model: "gpt-5.4", + }), + ]), + adapterRegistryLayer([codexInstanceId]), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ); + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const error = yield* service + .delegateTask(scope, { + task: "Summarize the diff.", + target: { providerInstanceId: codexInstanceId }, + mode: "async", + clientRequestId: "delegate-disabled-1", + }) + .pipe(Effect.flip); + assert.equal(error.code, "invalid_request"); + }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); + }), + ); + it.effect("re-probes an unavailable target once before refusing it", () => Effect.gen(function* () { const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); @@ -1368,6 +1413,7 @@ describe("OrchestratorMcpService provider resolution", () => { const layerDependencies = Layer.mergeAll( NodeServices.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ + assertProviderInstanceEnabled: () => Effect.void, getThreadRecords: (threadId) => Effect.succeed( threadId === parentThreadId ? parentProjection([task]) : childProjection, @@ -1513,6 +1559,7 @@ describe("OrchestratorMcpService provider resolution", () => { const layerDependencies = Layer.mergeAll( NodeServices.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ + assertProviderInstanceEnabled: () => Effect.void, getThreadRecords: (threadId) => Effect.succeed( threadId === parentThreadId diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 8a6495f0ee22..36f26877d9f0 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1137,6 +1137,19 @@ const make = Effect.gen(function* () { * unattended agent can see a provider as unavailable after it was fixed. * Re-probe the requested instance once before refusing it. */ + const assertTargetEnabledForProject = ( + parent: Pick, + commandId: CommandId, + target: ResolvedTarget, + ) => + threadManagement + .assertProviderInstanceEnabled({ + projectId: parent.thread.projectId, + commandId, + instanceId: target.modelSelection.instanceId, + }) + .pipe(Effect.mapError(threadManagementFailure)); + const resolveTargetRechecking = (input: Parameters[0]) => { const instanceId = input.target?.providerInstanceId ?? @@ -1947,6 +1960,7 @@ const make = Effect.gen(function* () { requestKey: key, operation: "delegate-task", }); + yield* assertTargetEnabledForProject(parent, commandId, target); const result = yield* threadManagement .dispatch({ type: "delegated_task.request", @@ -2232,6 +2246,11 @@ const make = Effect.gen(function* () { requestKey: key, index, }); + yield* assertTargetEnabledForProject( + parent, + stableCommandId({ scope, requestKey: key, operation: "create-thread", index }), + target, + ); const title = threadTitle({ parentTitle: parent.thread.title, prompt: request.prompt, diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts index 458901c6a0bd..88d072f40e77 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts @@ -64,6 +64,14 @@ export const dispatchCommand = Effect.fn("ThreadMessageIntake.dispatchCommand")( instanceId: (command.modelSelection ?? projection.thread.modelSelection).instanceId, }); } + if (command.type === "delegated_task.request") { + const parent = yield* threads.getThreadRecords(command.parentThreadId, []); + yield* threads.assertProviderInstanceEnabled({ + projectId: parent.thread.projectId, + commandId: command.commandId, + instanceId: command.modelSelection.instanceId, + }); + } if (command.type === "runtime-request.respond" && command.attachmentsByQuestionId) { const config = yield* ServerConfig.ServerConfig; const incomingByQuestionId = command.attachmentsByQuestionId; From 43f1322e75e25de076b3a811267269c21fd72582 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 20:04:25 -0700 Subject: [PATCH 10/39] fix(server): driver-only delegation skips instances the project turned off --- .../src/mcp/OrchestratorMcpService.test.ts | 105 ++++++++++++++++++ apps/server/src/mcp/OrchestratorMcpService.ts | 33 ++++-- .../ThreadManagementService.ts | 18 +++ .../src/relay/AgentAwarenessRelay.test.ts | 1 + 4 files changed, 148 insertions(+), 9 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 3553e5d24d66..3ed2b77bc642 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -1199,6 +1199,7 @@ describe("OrchestratorMcpService provider resolution", () => { NodeServices.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ assertProviderInstanceEnabled: () => Effect.void, + isProviderInstanceEnabledForProject: () => Effect.succeed(true), getThreadRecords: (threadId) => Effect.succeed( threadId === parentThreadId @@ -1366,6 +1367,109 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); + it.effect("driver-only delegation skips an instance the project turned off", () => + Effect.gen(function* () { + const altInstanceId = ProviderInstanceId.make("codex-alt"); + const dispatched = yield* Ref.make>([]); + const task = { + id: taskId, + threadId: parentThreadId, + runId: parentRunId, + parentNodeId, + origin: "app_owned", + createdBy: "agent", + driver: ProviderDriverKind.make("codex"), + providerInstanceId: altInstanceId, + providerThreadId: null, + childThreadId, + nativeTaskRef: null, + prompt: "Summarize the diff.", + title: null, + model: "gpt-5.4", + status: "running", + result: null, + startedAt: null, + completedAt: null, + }; + let delegated = false; + const layerDependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: (threadId) => + Effect.succeed( + threadId === parentThreadId + ? parentProjection(delegated ? [task] : []) + : childProjection, + ), + // The parent's own instance is off for this project; the other + // instance of the same driver is on. + isProviderInstanceEnabledForProject: ({ instanceId }) => + Effect.succeed(instanceId !== codexInstanceId), + assertProviderInstanceEnabled: ({ projectId, instanceId }) => + instanceId === codexInstanceId + ? Effect.fail( + new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({ + projectId, + instanceId, + }), + ) + : Effect.void, + dispatch: (command) => + Ref.update(dispatched, (commands) => [...commands, command]).pipe( + Effect.andThen( + Effect.sync(() => { + delegated = true; + }), + ), + Effect.as({ + sequence: 1, + storedEvents: [ + { + sequence: 1, + commandId: null, + event: { type: "subagent.updated", payload: task }, + }, + ], + } as never), + ), + }), + providerRegistryLayer([ + providerSnapshot({ + instanceId: codexInstanceId, + driver: ProviderDriverKind.make("codex"), + model: "gpt-5.4", + }), + providerSnapshot({ + instanceId: altInstanceId, + driver: ProviderDriverKind.make("codex"), + model: "gpt-5.4", + }), + ]), + adapterRegistryLayer([codexInstanceId, altInstanceId]), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ); + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const result = yield* service.delegateTask(scope, { + task: "Summarize the diff.", + target: { driverKind: ProviderDriverKind.make("codex") }, + mode: "async", + clientRequestId: "delegate-driver-skip-disabled-1", + }); + assert.equal(result.status, "running"); + const commands = yield* Ref.get(dispatched); + assert.equal(commands.length, 1); + const request = commands[0] as { + modelSelection: { instanceId: string; model: string }; + }; + assert.equal(request.modelSelection.instanceId, altInstanceId); + }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); + }), + ); + it.effect("re-probes an unavailable target once before refusing it", () => Effect.gen(function* () { const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); @@ -1560,6 +1664,7 @@ describe("OrchestratorMcpService provider resolution", () => { NodeServices.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ assertProviderInstanceEnabled: () => Effect.void, + isProviderInstanceEnabledForProject: () => Effect.succeed(true), getThreadRecords: (threadId) => Effect.succeed( threadId === parentThreadId diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 36f26877d9f0..b9cf336e466d 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1194,20 +1194,35 @@ const make = Effect.gen(function* () { } // Inherit the parent's instance only when it can actually serve the // child; an unavailable parent yields to a healthy instance of the - // requested driver rather than failing the delegation. - const inheritedCandidate = candidates.find( - (candidate) => - candidate.instanceId === input.parent.thread.modelSelection.instanceId && - providerConstraints(candidate, true).length === 0, - ); - const availableCandidate = candidates.find( + // requested driver rather than failing the delegation. An instance + // the project turned off cannot serve either, so selection skips it + // for the next candidate instead of failing the enablement check. + const healthyCandidates = candidates.filter( (candidate) => providerConstraints(candidate, true).length === 0, ); - instanceId = inheritedCandidate?.instanceId ?? availableCandidate?.instanceId; + const inheritedCandidate = healthyCandidates.find( + (candidate) => candidate.instanceId === input.parent.thread.modelSelection.instanceId, + ); + const orderedCandidates = [ + ...(inheritedCandidate === undefined ? [] : [inheritedCandidate]), + ...healthyCandidates.filter((candidate) => candidate !== inheritedCandidate), + ]; + for (const candidate of orderedCandidates) { + const enabledForProject = yield* threadManagement.isProviderInstanceEnabledForProject({ + projectId: input.parent.thread.projectId, + instanceId: candidate.instanceId, + }); + if (enabledForProject) { + instanceId = candidate.instanceId; + break; + } + } if (instanceId === undefined) { return yield* failure( "provider_unavailable", - `No available V2 provider instance for driver ${requestedDriver}.`, + healthyCandidates.length === 0 + ? `No available V2 provider instance for driver ${requestedDriver}.` + : `No instance of driver ${requestedDriver} is enabled for this project.`, ); } } diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 2e789092bce3..c0cf38363170 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -395,6 +395,14 @@ export interface ThreadManagementServiceShape { readonly commandId: CommandId; readonly instanceId: ProviderInstanceId; }) => Effect.Effect; + /** + * Effective enablement for candidate selection, without the receipt-replay + * exemption `assertProviderInstanceEnabled` grants an accepted command. + */ + readonly isProviderInstanceEnabledForProject: (input: { + readonly projectId: ProjectId; + readonly instanceId: ProviderInstanceId; + }) => Effect.Effect; readonly waitForThread: ( input: ThreadManagementWaitInput, ) => Effect.Effect; @@ -526,6 +534,15 @@ const make = Effect.gen(function* () { input.instanceId, ); + const isProviderInstanceEnabledForProject: ThreadManagementServiceShape["isProviderInstanceEnabledForProject"] = + (input) => + serverSettings.getSettings.pipe( + Effect.orDie, + Effect.map((current) => + resolveProjectProviderInstanceEnabled(current, input.projectId, input.instanceId), + ), + ); + const ensureLegacyTranscript = Effect.fn( "orchestrationV2.threadManagement.ensureLegacyTranscript", )(function* (threadId: ThreadId) { @@ -1086,6 +1103,7 @@ const make = Effect.gen(function* () { listProjectThreads, sendToThread, assertProviderInstanceEnabled, + isProviderInstanceEnabledForProject, waitForThread, settleAfterRun, settleThread, diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index c09f4bf4b69e..5aa5e193eb16 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -209,6 +209,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( listProjectThreads: unused, sendToThread: unused, assertProviderInstanceEnabled: unused, + isProviderInstanceEnabledForProject: unused, waitForThread: unused, settleAfterRun: unused, settleThread: unused, From 3fcad58c441616a1de912a3d492474fda0c5432c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 20:04:26 -0700 Subject: [PATCH 11/39] fix(web): automatic routing respects per-project provider switches --- apps/web/src/components/ChatView.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3d3a52062a4f..260db2e47b4f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -81,6 +81,7 @@ import { ProviderInteractionMode, ProviderDriverKind, resolveEnvironmentMachineKind, + resolveProjectProviderInstanceEnabled, RuntimeMode, TerminalOpenInput, type WorktreeSetupSnapshot, @@ -4363,7 +4364,14 @@ export default function ChatView(props: ChatViewProps) { (activeProviderInstanceId === null || provider.instanceId === activeProviderInstanceId) && provider.driver === selectedProvider && - provider.enabled && + // The destination project may override the machine switch. + (environment.serverConfig?.settings + ? resolveProjectProviderInstanceEnabled( + environment.serverConfig.settings, + candidate.projectId, + provider.instanceId, + ) + : provider.enabled) && provider.installed && provider.status !== "error" && provider.auth.status !== "unauthenticated" && From e4360cd1a4229e249ebf816bd20a1b5f5899b7dd Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 10 Oct 2026 22:40:42 -0400 Subject: [PATCH 12/39] fix(mobile): sending honors the project's provider choices Signed-off-by: Yordis Prieto --- apps/mobile/src/features/threads/NewTaskDraftScreen.tsx | 7 ++++++- apps/mobile/src/state/use-thread-composer-state.ts | 2 +- apps/mobile/src/state/use-thread-outbox-drain.ts | 8 ++++---- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index cfb12bfd72a8..c8fff2ec927b 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1236,6 +1236,7 @@ export function NewTaskDraftScreen(props: { resolveSelectableModelSelection( selectedEnvironmentServerConfig, draft.modelSelection ?? null, + selectedProject.id, ) ?? flow.selectedModel; const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode; const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName; @@ -1252,7 +1253,11 @@ export function NewTaskDraftScreen(props: { } if ( environmentConnected && - isModelSelectionUnavailable(selectedEnvironmentServerConfig, modelSelection) + isModelSelectionUnavailable( + selectedEnvironmentServerConfig, + modelSelection, + selectedProject.id, + ) ) { Alert.alert( "Antigravity model unavailable", diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 172f647cff0f..1bb1c6d093dc 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -616,7 +616,7 @@ export function useThreadComposerState() { const serverConfig = selectedEnvironmentRuntime?.serverConfig; if ( selectedEnvironmentRuntime?.connectionState === "connected" && - isModelSelectionUnavailable(serverConfig, modelSelection) + isModelSelectionUnavailable(serverConfig, modelSelection, thread.projectId) ) { Alert.alert( "Antigravity model unavailable", diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 66744fa0c927..f017be6b5fb9 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -811,7 +811,7 @@ export function useThreadOutboxDrain(): void { ); if (!serverConfig) return false; const settings = resolveQueuedThreadSettings(queuedMessage, thread, serverConfig.providers); - if (isModelSelectionUnavailable(serverConfig, settings.modelSelection)) { + if (isModelSelectionUnavailable(serverConfig, settings.modelSelection, thread.projectId)) { return restoreQueuedMessage( queuedMessage, "Antigravity model unavailable. Set it up on web or desktop, or choose another model.", @@ -893,7 +893,7 @@ export function useThreadOutboxDrain(): void { serverEnvironment.configValueAtom(queuedMessage.environmentId), ); if (!currentConfig) return false; - if (isModelSelectionUnavailable(currentConfig, settings.modelSelection)) { + if (isModelSelectionUnavailable(currentConfig, settings.modelSelection, thread.projectId)) { return restoreQueuedMessage( persistedMessage, "Antigravity model unavailable. Set it up on web or desktop, or choose another model.", @@ -990,7 +990,7 @@ export function useThreadOutboxDrain(): void { }, serverConfig.providers, ); - if (isModelSelectionUnavailable(serverConfig, settings.modelSelection)) { + if (isModelSelectionUnavailable(serverConfig, settings.modelSelection, creation.projectId)) { return restoreQueuedMessage( queuedMessage, "Antigravity model unavailable. Set it up on web or desktop, or choose another model.", @@ -1035,7 +1035,7 @@ export function useThreadOutboxDrain(): void { serverEnvironment.configValueAtom(queuedMessage.environmentId), ); if (!currentConfig) return false; - if (isModelSelectionUnavailable(currentConfig, settings.modelSelection)) { + if (isModelSelectionUnavailable(currentConfig, settings.modelSelection, creation.projectId)) { return restoreQueuedMessage( persistedMessage, "Antigravity model unavailable. Set it up on web or desktop, or choose another model.", From 6391b85a886fedf1121a6a8042e6de33c04dfff8 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 10 Oct 2026 22:48:40 -0400 Subject: [PATCH 13/39] fix(web): a project's provider choice applies to all its checkouts Signed-off-by: Yordis Prieto --- .../ProviderSettingsPanel.logic.test.ts | 32 +++++++- .../settings/ProviderSettingsPanel.logic.ts | 26 ++++--- .../settings/ProviderSettingsPanel.tsx | 75 +++++++++---------- apps/web/src/routes/settings.providers.tsx | 10 ++- 4 files changed, 87 insertions(+), 56 deletions(-) diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts index 38fb8251e0d5..3c3db06d3451 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts @@ -239,13 +239,37 @@ describe("buildProviderInstanceEnablementOverridePatch", () => { const instanceId = ProviderInstanceId.make("codex_work"); const otherInstanceId = ProviderInstanceId.make("claudeAgent_personal"); + it("writes the same choice to every checkout of the project", () => { + const siblingId = ProjectId.make("project-a-worktree"); + const settings: Pick = { + projectSettingsOverrides: { + [siblingId]: { newWorktreesStartFromOrigin: true }, + }, + }; + + expect( + buildProviderInstanceEnablementOverridePatch( + settings, + [projectId, siblingId], + instanceId, + false, + ), + ).toEqual({ + [projectId]: { providerInstanceEnablement: { [instanceId]: false } }, + [siblingId]: { + newWorktreesStartFromOrigin: true, + providerInstanceEnablement: { [instanceId]: false }, + }, + }); + }); + it("sets an override when the project has none yet", () => { const settings: Pick = { projectSettingsOverrides: {}, }; expect( - buildProviderInstanceEnablementOverridePatch(settings, projectId, instanceId, true), + buildProviderInstanceEnablementOverridePatch(settings, [projectId], instanceId, true), ).toEqual({ [projectId]: { providerInstanceEnablement: { [instanceId]: true } } }); }); @@ -260,7 +284,7 @@ describe("buildProviderInstanceEnablementOverridePatch", () => { }; expect( - buildProviderInstanceEnablementOverridePatch(settings, projectId, instanceId, true), + buildProviderInstanceEnablementOverridePatch(settings, [projectId], instanceId, true), ).toEqual({ [projectId]: { newWorktreesStartFromOrigin: true, @@ -277,7 +301,7 @@ describe("buildProviderInstanceEnablementOverridePatch", () => { }; expect( - buildProviderInstanceEnablementOverridePatch(settings, projectId, instanceId, undefined), + buildProviderInstanceEnablementOverridePatch(settings, [projectId], instanceId, undefined), ).toEqual({ [projectId]: null }); }); @@ -292,7 +316,7 @@ describe("buildProviderInstanceEnablementOverridePatch", () => { }; expect( - buildProviderInstanceEnablementOverridePatch(settings, projectId, instanceId, undefined), + buildProviderInstanceEnablementOverridePatch(settings, [projectId], instanceId, undefined), ).toEqual({ [projectId]: { newWorktreesStartFromOrigin: true, diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts index 4f53763c213e..16a5f64f5851 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts @@ -116,22 +116,28 @@ export function resolveRemoteOperateAccess(input: { * `value: undefined` resets that single instance back to inheriting the * environment's enablement, leaving any other overridden keys untouched. */ +/** Every checkout of a project on one environment shares the same choice. */ export function buildProviderInstanceEnablementOverridePatch( settings: Pick, - projectId: ProjectId, + projectIds: readonly ProjectId[], instanceId: ProviderInstanceId, value: boolean | undefined, ): Record { - const current = settings.projectSettingsOverrides[projectId]; - const enablement = { ...current?.providerInstanceEnablement }; - if (value === undefined) delete enablement[instanceId]; - else enablement[instanceId] = value; + const patch: Record = {}; + for (const projectId of projectIds) { + const current = settings.projectSettingsOverrides[projectId]; + const enablement = { ...current?.providerInstanceEnablement }; + if (value === undefined) delete enablement[instanceId]; + else enablement[instanceId] = value; - const { providerInstanceEnablement: _omit, ...rest } = current ?? {}; - const nextEntry: ProjectSettingsOverrides = - Object.keys(enablement).length > 0 ? { ...rest, providerInstanceEnablement: enablement } : rest; - - return { [projectId]: Object.keys(nextEntry).length === 0 ? null : nextEntry }; + const { providerInstanceEnablement: _omit, ...rest } = current ?? {}; + const nextEntry: ProjectSettingsOverrides = + Object.keys(enablement).length > 0 + ? { ...rest, providerInstanceEnablement: enablement } + : rest; + patch[projectId] = Object.keys(nextEntry).length === 0 ? null : nextEntry; + } + return patch; } export function classifyProviderEnvironmentAccess(input: { diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 7424fce0226b..6c9729840a7c 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -282,8 +282,11 @@ interface ProviderSettingsTarget { readonly instanceId?: ProviderInstanceId; readonly scoped?: boolean; readonly environmentIds?: readonly EnvironmentId[]; - /** The project in scope, if any. Narrows the enable toggle to that project's override. */ - readonly projectId?: ProjectId | null; + /** + * The project's checkouts on the selected environment, if a project is in + * scope. The enable toggle writes each one's override so they never diverge. + */ + readonly projectIds?: readonly ProjectId[]; } export function ProviderSettingsPanel(target: ProviderSettingsTarget) { @@ -455,7 +458,7 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) { ? target.instanceId : undefined } - projectId={target.projectId ?? null} + projectIds={target.projectIds ?? []} /> ) : null} @@ -466,19 +469,19 @@ function SelectedEnvironmentProviderSettings({ environment, deviceTabs, targetInstanceId, - projectId, + projectIds, }: { readonly environment: EnvironmentPresentation; readonly deviceTabs?: ReactNode; readonly targetInstanceId?: ProviderInstanceId | undefined; - readonly projectId: ProjectId | null; + readonly projectIds: readonly ProjectId[]; }) { return ( ); } @@ -487,12 +490,12 @@ function RemoteSessionGatedProviderSettings({ environment, deviceTabs, targetInstanceId, - projectId, + projectIds, }: { readonly environment: EnvironmentPresentation; readonly deviceTabs?: ReactNode; readonly targetInstanceId?: ProviderInstanceId | undefined; - readonly projectId: ProjectId | null; + readonly projectIds: readonly ProjectId[]; }) { const sessionState = useEnvironmentSessionState(environment.environmentId); const operateAccess = resolveRemoteOperateAccess({ @@ -506,7 +509,7 @@ function RemoteSessionGatedProviderSettings({ operateAccess={operateAccess} deviceTabs={deviceTabs} targetInstanceId={targetInstanceId} - projectId={projectId} + projectIds={projectIds} /> ); } @@ -516,13 +519,13 @@ function AccessGatedProviderSettings({ operateAccess, deviceTabs, targetInstanceId, - projectId, + projectIds, }: { readonly environment: EnvironmentPresentation; readonly operateAccess: ProviderOperateAccess; readonly deviceTabs?: ReactNode; readonly targetInstanceId?: ProviderInstanceId | undefined; - readonly projectId: ProjectId | null; + readonly projectIds: readonly ProjectId[]; }) { const access = classifyProviderEnvironmentAccess({ connectionPhase: environment.connection.phase, @@ -545,7 +548,7 @@ function AccessGatedProviderSettings({ readOnly={access.kind === "read-only"} deviceTabs={deviceTabs} targetInstanceId={targetInstanceId} - projectId={projectId} + projectIds={projectIds} /> ); } @@ -556,14 +559,14 @@ export function EnvironmentProviderSettings({ readOnly = false, deviceTabs, targetInstanceId, - projectId = null, + projectIds = [], }: { readonly environmentId: EnvironmentId; readonly environmentLabel: string; readonly deviceTabs?: ReactNode; readonly targetInstanceId?: ProviderInstanceId | undefined; - /** The project in scope, if any. Narrows the enable toggle to that project's override. */ - readonly projectId?: ProjectId | null; + /** The project's checkouts on this environment, if a project is in scope. */ + readonly projectIds?: readonly ProjectId[]; /** * Grey out and freeze every write control when this session's credential * lacks `providers:manage` on the environment. Selecting providers @@ -919,39 +922,31 @@ export function EnvironmentProviderSettings({ favorite.provider === row.instanceId ? Result.succeed(favorite.model) : Result.failVoid, ); const resetLabel = driverOption?.label ?? String(row.driver); + const representativeProjectId = projectIds[0]; + const writeProjectEnablement = (next: boolean | undefined) => { + updateSettings({ + projectSettingsOverrides: buildProviderInstanceEnablementOverridePatch( + settings, + projectIds, + row.instanceId, + next, + ), + }); + }; const projectEnablement = - projectId === null + representativeProjectId === undefined ? undefined : { value: - settings.projectSettingsOverrides[projectId]?.providerInstanceEnablement?.[ - row.instanceId - ], + settings.projectSettingsOverrides[representativeProjectId] + ?.providerInstanceEnablement?.[row.instanceId], effectiveEnabled: resolveProjectProviderInstanceEnabled( settings, - projectId, + representativeProjectId, row.instanceId, ), - onChange: (next: boolean) => { - updateSettings({ - projectSettingsOverrides: buildProviderInstanceEnablementOverridePatch( - settings, - projectId, - row.instanceId, - next, - ), - }); - }, - onReset: () => { - updateSettings({ - projectSettingsOverrides: buildProviderInstanceEnablementOverridePatch( - settings, - projectId, - row.instanceId, - undefined, - ), - }); - }, + onChange: (next: boolean) => writeProjectEnablement(next), + onReset: () => writeProjectEnablement(undefined), }; return ( diff --git a/apps/web/src/routes/settings.providers.tsx b/apps/web/src/routes/settings.providers.tsx index 889020e4616a..dc8474d73ca0 100644 --- a/apps/web/src/routes/settings.providers.tsx +++ b/apps/web/src/routes/settings.providers.tsx @@ -11,7 +11,7 @@ import { useSettingsScope } from "../components/settings/SettingsScopeContext"; */ function SettingsProvidersRoute() { const target = Route.useSearch(); - const { environment, scope, target: scopedTarget } = useSettingsScope(); + const { environment, scope } = useSettingsScope(); if (!environment) { return (

@@ -25,7 +25,13 @@ function SettingsProvidersRoute() { member.environmentId === environment.environmentId) + .map((member) => member.id) + : [] + } scoped /> ); From ea184dd1367b9e1e0d76edaf555644fac65ec3f0 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 10 Oct 2026 23:00:15 -0400 Subject: [PATCH 14/39] fix(mobile): a project-disabled model points to project settings, not setup Signed-off-by: Yordis Prieto --- .../features/threads/NewTaskDraftScreen.tsx | 9 +++-- apps/mobile/src/lib/modelOptions.test.ts | 18 +++++++++ apps/mobile/src/lib/modelOptions.ts | 17 +++++++++ .../src/state/use-thread-composer-state.ts | 13 +++++-- .../src/state/use-thread-outbox-drain.ts | 37 ++++++++++++------- 5 files changed, 74 insertions(+), 20 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index c8fff2ec927b..ba60d57d33fe 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -112,6 +112,7 @@ import { sourceControlEnvironment } from "../../state/sourceControl"; import { useAtomCommand } from "../../state/use-atom-command"; import { ProjectCloneBanner } from "../../components/ProjectCloneBanner"; import { + describeUnavailableModelSelection, isModelSelectionUnavailable, resolveSelectableModelSelection, } from "../../lib/modelOptions"; @@ -1259,10 +1260,12 @@ export function NewTaskDraftScreen(props: { selectedProject.id, ) ) { - Alert.alert( - "Antigravity model unavailable", - "Set up Antigravity on web or desktop, or choose another model.", + const { title, detail } = describeUnavailableModelSelection( + selectedEnvironmentServerConfig, + modelSelection, + selectedProject.id, ); + Alert.alert(title, detail); return; } // T3's own limits command is answered by the thread composer; a new task would diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 95bdbb5b3402..4d3773205be2 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -12,6 +12,7 @@ import { import { buildModelOptions, + describeUnavailableModelSelection, groupByProvider, isModelSelectionUnavailable, resolveDefaultableModelSelection, @@ -533,6 +534,23 @@ describe("mobile model options", () => { } as unknown as ServerConfig; } + it("words a project's own turn-off apart from missing setup", () => { + const settings: ServerSettings = { + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { + [projectId]: { providerInstanceEnablement: { [instanceId]: false } }, + }, + }; + const selection = { instanceId, model: "model-a" }; + + expect( + describeUnavailableModelSelection(configWith(settings), selection, projectId).title, + ).toBe("Model turned off for this project"); + expect(describeUnavailableModelSelection(configWith(settings), selection, null).title).toBe( + "Antigravity model unavailable", + ); + }); + it("hides an instance the project disables even though the machine enables it", () => { const settings: ServerSettings = { ...DEFAULT_SERVER_SETTINGS, diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 533d25da0c52..675f1f5ce500 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -145,6 +145,23 @@ export function isModelSelectionUnavailable( ); } +/** Why `isModelSelectionUnavailable` refused a selection, worded for the recovery that applies. */ +export function describeUnavailableModelSelection( + config: T3ServerConfig | null | undefined, + selection: ModelSelection, + projectId: ProjectId | null, +): { readonly title: string; readonly detail: string } { + return isProjectDisabledOverride(config, selection.instanceId, projectId) + ? { + title: "Model turned off for this project", + detail: "Turn it back on in this project's provider settings, or choose another model.", + } + : { + title: "Antigravity model unavailable", + detail: "Set up Antigravity on web or desktop, or choose another model.", + }; +} + /** * Keep Antigravity selections when setup or catalog changes make them * unavailable. Other providers fall through to the server default when they diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 1bb1c6d093dc..591dbefb20c9 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -39,7 +39,10 @@ import { composerContextSendBlockReason, reidentifyComposerContext } from "../li import { uuidv4 } from "../lib/uuid"; import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; -import { isModelSelectionUnavailable } from "../lib/modelOptions"; +import { + describeUnavailableModelSelection, + isModelSelectionUnavailable, +} from "../lib/modelOptions"; import { resolveProviderInteractionMode } from "./legacy-plan-mode"; import { convertPastedImagesToAttachments, @@ -618,10 +621,12 @@ export function useThreadComposerState() { selectedEnvironmentRuntime?.connectionState === "connected" && isModelSelectionUnavailable(serverConfig, modelSelection, thread.projectId) ) { - Alert.alert( - "Antigravity model unavailable", - "Set up Antigravity on web or desktop, or choose another model.", + const { title, detail } = describeUnavailableModelSelection( + serverConfig, + modelSelection, + thread.projectId, ); + Alert.alert(title, detail); return null; } const provider = serverConfig?.providers.find( diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index f017be6b5fb9..c7d886317a25 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -26,7 +26,10 @@ import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn" import { serializeComposerMessageForServer, uploadedComposerContext } from "../lib/composerContext"; import { prepareTurnAttachments, type PreparedTurnAttachments } from "../lib/attachmentUpload"; import { randomHex } from "../lib/uuid"; -import { isModelSelectionUnavailable } from "../lib/modelOptions"; +import { + describeUnavailableModelSelection, + isModelSelectionUnavailable, +} from "../lib/modelOptions"; import { retainAcknowledgedThreadMessage, forgetAcknowledgedThreadMessage, @@ -812,10 +815,12 @@ export function useThreadOutboxDrain(): void { if (!serverConfig) return false; const settings = resolveQueuedThreadSettings(queuedMessage, thread, serverConfig.providers); if (isModelSelectionUnavailable(serverConfig, settings.modelSelection, thread.projectId)) { - return restoreQueuedMessage( - queuedMessage, - "Antigravity model unavailable. Set it up on web or desktop, or choose another model.", + const { title, detail } = describeUnavailableModelSelection( + serverConfig, + settings.modelSelection, + thread.projectId, ); + return restoreQueuedMessage(queuedMessage, `${title}. ${detail}`); } const { reportFailure } = makeDeliveryHelpers(queuedMessage); @@ -894,10 +899,12 @@ export function useThreadOutboxDrain(): void { ); if (!currentConfig) return false; if (isModelSelectionUnavailable(currentConfig, settings.modelSelection, thread.projectId)) { - return restoreQueuedMessage( - persistedMessage, - "Antigravity model unavailable. Set it up on web or desktop, or choose another model.", + const { title, detail } = describeUnavailableModelSelection( + currentConfig, + settings.modelSelection, + thread.projectId, ); + return restoreQueuedMessage(persistedMessage, `${title}. ${detail}`); } const sendSettings = resolveQueuedThreadSettings( queuedMessage, @@ -991,10 +998,12 @@ export function useThreadOutboxDrain(): void { serverConfig.providers, ); if (isModelSelectionUnavailable(serverConfig, settings.modelSelection, creation.projectId)) { - return restoreQueuedMessage( - queuedMessage, - "Antigravity model unavailable. Set it up on web or desktop, or choose another model.", + const { title, detail } = describeUnavailableModelSelection( + serverConfig, + settings.modelSelection, + creation.projectId, ); + return restoreQueuedMessage(queuedMessage, `${title}. ${detail}`); } let prepared: PreparedTurnAttachments; let persistedMessage: QueuedThreadMessage; @@ -1036,10 +1045,12 @@ export function useThreadOutboxDrain(): void { ); if (!currentConfig) return false; if (isModelSelectionUnavailable(currentConfig, settings.modelSelection, creation.projectId)) { - return restoreQueuedMessage( - persistedMessage, - "Antigravity model unavailable. Set it up on web or desktop, or choose another model.", + const { title, detail } = describeUnavailableModelSelection( + currentConfig, + settings.modelSelection, + creation.projectId, ); + return restoreQueuedMessage(persistedMessage, `${title}. ${detail}`); } const sendSettings = resolveQueuedThreadSettings( queuedMessage, From 9a404e83165198c1ebd672e9140bb1167e80702a Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 10 Oct 2026 23:09:50 -0400 Subject: [PATCH 15/39] fix(mobile): sending a model the project turned off restores the draft Signed-off-by: Yordis Prieto --- apps/mobile/src/lib/modelOptions.test.ts | 16 ++++++++++++++++ apps/mobile/src/lib/modelOptions.ts | 8 +++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 4d3773205be2..90527e462478 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -551,6 +551,22 @@ describe("mobile model options", () => { ); }); + it("refuses to send a non-Antigravity model the project turned off", () => { + const settings: ServerSettings = { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, + }, + projectSettingsOverrides: { + [projectId]: { providerInstanceEnablement: { [instanceId]: false } }, + }, + }; + const selection = { instanceId, model: "model-a" }; + + expect(isModelSelectionUnavailable(configWith(settings), selection, projectId)).toBe(true); + expect(isModelSelectionUnavailable(configWith(settings), selection, null)).toBe(false); + }); + it("hides an instance the project disables even though the machine enables it", () => { const settings: ServerSettings = { ...DEFAULT_SERVER_SETTINGS, diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 675f1f5ce500..a468c5d236cf 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -120,7 +120,10 @@ function normalizeSelectionOptions( }; } -/** Whether a known Antigravity selection needs setup or a different model. */ +/** + * Whether the selection cannot be sent as is: the project turned its instance + * off, or a known Antigravity selection needs setup or a different model. + */ export function isModelSelectionUnavailable( config: T3ServerConfig | null | undefined, selection: ModelSelection | null | undefined, @@ -129,6 +132,9 @@ export function isModelSelectionUnavailable( if (!config || !selection) { return false; } + if (isProjectDisabledOverride(config, selection.instanceId, projectId)) { + return true; + } const provider = config.providers.find( (candidate) => candidate.instanceId === selection.instanceId, ); From 768748835ea8c19b5a0e90ea93a9dd75dd550f46 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 11 Oct 2026 02:23:57 -0400 Subject: [PATCH 16/39] refactor(settings): project provider switches use the settings.json shape Signed-off-by: Yordis Prieto --- apps/mobile/src/lib/modelOptions.test.ts | 16 +++---- apps/mobile/src/lib/modelOptions.ts | 5 +-- .../ThreadLaunchService.test.ts | 8 ++-- .../ThreadManagementService.test.ts | 4 +- .../ProviderInstanceRegistryHydration.test.ts | 14 +++--- .../ProviderInstanceRegistryHydration.ts | 8 ++-- .../settings/ProviderInstanceCard.tsx | 22 ++++----- .../ProviderSettingsPanel.logic.test.ts | 45 +++++++++---------- .../settings/ProviderSettingsPanel.logic.ts | 17 +++---- .../settings/ProviderSettingsPanel.tsx | 19 ++++---- apps/web/src/providerInstances.ts | 2 +- packages/contracts/src/settings.test.ts | 8 ++-- packages/contracts/src/settings.ts | 31 ++++++++----- packages/contracts/src/t3ProjectFile.ts | 6 +-- packages/shared/src/projectSettings.test.ts | 10 +++-- packages/shared/src/projectSettings.ts | 2 +- packages/shared/src/serverSettings.test.ts | 4 +- 17 files changed, 115 insertions(+), 106 deletions(-) diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 90527e462478..339dd38cc6cd 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -538,7 +538,7 @@ describe("mobile model options", () => { const settings: ServerSettings = { ...DEFAULT_SERVER_SETTINGS, projectSettingsOverrides: { - [projectId]: { providerInstanceEnablement: { [instanceId]: false } }, + [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, }, }; const selection = { instanceId, model: "model-a" }; @@ -558,7 +558,7 @@ describe("mobile model options", () => { [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, }, projectSettingsOverrides: { - [projectId]: { providerInstanceEnablement: { [instanceId]: false } }, + [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, }, }; const selection = { instanceId, model: "model-a" }; @@ -574,7 +574,7 @@ describe("mobile model options", () => { [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, }, projectSettingsOverrides: { - [projectId]: { providerInstanceEnablement: { [instanceId]: false } }, + [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, }, }; @@ -588,7 +588,7 @@ describe("mobile model options", () => { [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false }, }, projectSettingsOverrides: { - [projectId]: { providerInstanceEnablement: { [instanceId]: true } }, + [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, }, }; @@ -602,7 +602,7 @@ describe("mobile model options", () => { [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, }, projectSettingsOverrides: { - [projectId]: { providerInstanceEnablement: { [instanceId]: false } }, + [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, }, }; @@ -616,7 +616,7 @@ describe("mobile model options", () => { [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, }, projectSettingsOverrides: { - [projectId]: { providerInstanceEnablement: { [instanceId]: false } }, + [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, }, }; const fallback = { instanceId, model: "model-a" }; @@ -634,7 +634,7 @@ describe("mobile model options", () => { [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, }, projectSettingsOverrides: { - [projectId]: { providerInstanceEnablement: { [instanceId]: false } }, + [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, }, }; const selection = { instanceId, model: "model-a" }; @@ -671,7 +671,7 @@ describe("mobile model options", () => { }, }, projectSettingsOverrides: { - [projectId]: { providerInstanceEnablement: { [antigravityInstanceId]: false } }, + [projectId]: { providerInstances: { [antigravityInstanceId]: { enabled: false } } }, }, }, } as unknown as ServerConfig; diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index a468c5d236cf..4877123e3eec 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -89,9 +89,8 @@ function isProjectDisabledOverride( return false; } return ( - config.settings.projectSettingsOverrides[projectId]?.providerInstanceEnablement?.[ - instanceId - ] === false + config.settings.projectSettingsOverrides[projectId]?.providerInstances?.[instanceId] + ?.enabled === false ); } diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index ca1a058a2cef..3012e09411ac 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -1774,7 +1774,7 @@ it.effect("refuses a launch when the project disables the model's provider insta const harness = makeHarness({ serverSettings: { projectSettingsOverrides: { - [projectId]: { providerInstanceEnablement: { [modelSelection.instanceId]: false } }, + [projectId]: { providerInstances: { [modelSelection.instanceId]: { enabled: false } } }, }, }, }); @@ -1811,7 +1811,7 @@ it.effect("replays an accepted launch after the project disables the instance", yield* settings.updateSettings({ projectSettingsOverrides: { - [projectId]: { providerInstanceEnablement: { [modelSelection.instanceId]: false } }, + [projectId]: { providerInstances: { [modelSelection.instanceId]: { enabled: false } } }, }, }); @@ -2640,7 +2640,7 @@ it.effect("replays an accepted send after the project disables the instance", () yield* settings.updateSettings({ projectSettingsOverrides: { - [projectId]: { providerInstanceEnablement: { [modelSelection.instanceId]: false } }, + [projectId]: { providerInstances: { [modelSelection.instanceId]: { enabled: false } } }, }, }); @@ -2688,7 +2688,7 @@ it.effect("replays an accepted message.dispatch after the project disables the i yield* settings.updateSettings({ projectSettingsOverrides: { - [projectId]: { providerInstanceEnablement: { [modelSelection.instanceId]: false } }, + [projectId]: { providerInstances: { [modelSelection.instanceId]: { enabled: false } } }, }, }); diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index c87345b8740d..8a12777a3199 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -287,7 +287,7 @@ it.effect("refuses a new turn when the project effectively disables the instance const layerTest = ServerSettings.layerTest({ providerInstances: { [instanceId]: { driver: "codex", enabled: true } }, projectSettingsOverrides: { - [projectId]: { providerInstanceEnablement: { [instanceId]: false } }, + [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, }, }); @@ -321,7 +321,7 @@ it.effect("allows a new turn when the project enables an instance the machine di const layerTest = ServerSettings.layerTest({ providerInstances: { [instanceId]: { driver: "codex", enabled: false } }, projectSettingsOverrides: { - [projectId]: { providerInstanceEnablement: { [instanceId]: true } }, + [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, }, }); diff --git a/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts b/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts index 84c7a1923768..16b3958f3613 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts @@ -23,7 +23,7 @@ describe("deriveProviderInstanceConfigMap", () => { [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false }, }, projectSettingsOverrides: { - [projectId]: { providerInstanceEnablement: { [instanceId]: true } }, + [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, }, }); @@ -41,7 +41,7 @@ describe("deriveProviderInstanceConfigMap", () => { }, }, projectSettingsOverrides: { - [projectId]: { providerInstanceEnablement: { [instanceId]: true } }, + [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, }, }); @@ -60,7 +60,7 @@ describe("deriveProviderInstanceConfigMap", () => { [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, }, projectSettingsOverrides: { - [projectId]: { providerInstanceEnablement: { [instanceId]: true } }, + [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, }, }); @@ -73,7 +73,7 @@ describe("deriveProviderInstanceConfigMap", () => { const settings = settingsWith({ providerInstances: {}, projectSettingsOverrides: { - [projectId]: { providerInstanceEnablement: { [instanceId]: true } }, + [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, }, }); @@ -88,7 +88,7 @@ describe("deriveProviderInstanceConfigMap", () => { [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, }, projectSettingsOverrides: { - [projectId]: { providerInstanceEnablement: { [instanceId]: false } }, + [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, }, }); @@ -106,8 +106,8 @@ describe("deriveProviderInstanceConfigMap", () => { [otherInstanceId]: { driver: ProviderDriverKind.make("claudeAgent"), enabled: false }, }, projectSettingsOverrides: { - [projectId]: { providerInstanceEnablement: { [instanceId]: true } }, - [otherProjectId]: { providerInstanceEnablement: { [otherInstanceId]: true } }, + [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, + [otherProjectId]: { providerInstances: { [otherInstanceId]: { enabled: true } } }, }, }); diff --git a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts index bbe9f6ddd3f6..7940ea56220e 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts @@ -79,13 +79,13 @@ export const deriveProviderInstanceConfigMap = ( // A project that turns an instance on needs it alive and probed even when // the machine itself has it off, or a thread in that project would route // to an instance nobody ever checked in on. Fold every project's - // `providerInstanceEnablement` overrides in: an instance already enabled + // `providerInstances[id].enabled` overrides in: an instance already enabled // is untouched, and one absent from settings entirely (deleted) stays out. for (const overrides of Object.values(settings.projectSettingsOverrides)) { - for (const [rawInstanceId, projectEnabled] of Object.entries( - overrides?.providerInstanceEnablement ?? {}, + for (const [rawInstanceId, projectOverride] of Object.entries( + overrides?.providerInstances ?? {}, )) { - if (!projectEnabled) continue; + if (projectOverride.enabled !== true) continue; const instanceId = rawInstanceId as ProviderInstanceId; const existing = merged[instanceId]; if (existing === undefined || resolveProviderInstanceEnabled(existing)) continue; diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index ab09c19cd09b..554d19b24c72 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -478,7 +478,7 @@ export function ProviderEnvironmentSection(props: { * `value` is the project's explicit override (`undefined` means it * inherits the environment); `effectiveEnabled` is what the Switch shows. */ -interface ProjectEnablementControl { +interface ProjectOverrideControl { readonly value: boolean | undefined; readonly effectiveEnabled: boolean; readonly onChange: (next: boolean) => void; @@ -529,7 +529,7 @@ interface ProviderInstanceCardProps { readonly onAcceptUrlAuth?: ((action: AcpRegistryUrlAuthAction) => void) | undefined; readonly environmentId?: EnvironmentId | undefined; /** Omit this prop entirely for environment-wide editing. */ - readonly projectEnablement?: ProjectEnablementControl | undefined; + readonly projectOverride?: ProjectOverrideControl | undefined; readonly acpProjects?: | ReadonlyArray<{ readonly id: ProjectId; @@ -559,7 +559,7 @@ const EMPTY_ACP_PROJECTS: NonNullable * driver-specific `config.enabled` into the envelope on load and both * sides resolve through `resolveProviderInstanceEnabled` (an explicit * false wins, then envelope, then config, then the driver default). - * With `projectEnablement` set, the Switch instead reads and writes + * With `projectOverride` set, the Switch instead reads and writes * that project's override, leaving the environment's `instance.enabled` * untouched. */ @@ -591,10 +591,10 @@ export function ProviderInstanceCard({ onAcceptUrlAuth, environmentId, acpProjects = EMPTY_ACP_PROJECTS, - projectEnablement, + projectOverride, }: ProviderInstanceCardProps) { - const enabled = projectEnablement - ? projectEnablement.effectiveEnabled + const enabled = projectOverride + ? projectOverride.effectiveEnabled : resolveProviderInstanceEnabled(instance); const compatibility = enabled ? liveProvider?.compatibilityAdvisory : undefined; // A locally disabled provider reads "Disabled" with a muted dot even if its @@ -692,8 +692,8 @@ export function ProviderInstanceCard({ }; const updateEnabled = (value: boolean) => { - if (projectEnablement) { - projectEnablement.onChange(value); + if (projectOverride) { + projectOverride.onChange(value); return; } onUpdate({ ...instance, enabled: value }); @@ -995,17 +995,17 @@ export function ProviderInstanceCard({ - {projectEnablement?.value !== undefined ? ( + {projectOverride?.value !== undefined ? ( ) : null} updateEnabled(Boolean(checked))} aria-label={`Enable ${displayName}`} /> diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts index 3c3db06d3451..683ccb362aa2 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts @@ -9,7 +9,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildProviderEnvironmentOptions, - buildProviderInstanceEnablementOverridePatch, + buildProviderInstanceOverridePatch, classifyProviderEnvironmentAccess, isProviderSettingsEnvironmentAvailable, resolvePrimaryOperateAccess, @@ -234,7 +234,7 @@ describe("primary operate access", () => { }); }); -describe("buildProviderInstanceEnablementOverridePatch", () => { +describe("buildProviderInstanceOverridePatch", () => { const projectId = ProjectId.make("project-a"); const instanceId = ProviderInstanceId.make("codex_work"); const otherInstanceId = ProviderInstanceId.make("claudeAgent_personal"); @@ -248,17 +248,12 @@ describe("buildProviderInstanceEnablementOverridePatch", () => { }; expect( - buildProviderInstanceEnablementOverridePatch( - settings, - [projectId, siblingId], - instanceId, - false, - ), + buildProviderInstanceOverridePatch(settings, [projectId, siblingId], instanceId, false), ).toEqual({ - [projectId]: { providerInstanceEnablement: { [instanceId]: false } }, + [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, [siblingId]: { newWorktreesStartFromOrigin: true, - providerInstanceEnablement: { [instanceId]: false }, + providerInstances: { [instanceId]: { enabled: false } }, }, }); }); @@ -268,9 +263,9 @@ describe("buildProviderInstanceEnablementOverridePatch", () => { projectSettingsOverrides: {}, }; - expect( - buildProviderInstanceEnablementOverridePatch(settings, [projectId], instanceId, true), - ).toEqual({ [projectId]: { providerInstanceEnablement: { [instanceId]: true } } }); + expect(buildProviderInstanceOverridePatch(settings, [projectId], instanceId, true)).toEqual({ + [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, + }); }); it("adds an instance override alongside the project's other overrides", () => { @@ -278,17 +273,18 @@ describe("buildProviderInstanceEnablementOverridePatch", () => { projectSettingsOverrides: { [projectId]: { newWorktreesStartFromOrigin: true, - providerInstanceEnablement: { [otherInstanceId]: false }, + providerInstances: { [otherInstanceId]: { enabled: false } }, }, }, }; - expect( - buildProviderInstanceEnablementOverridePatch(settings, [projectId], instanceId, true), - ).toEqual({ + expect(buildProviderInstanceOverridePatch(settings, [projectId], instanceId, true)).toEqual({ [projectId]: { newWorktreesStartFromOrigin: true, - providerInstanceEnablement: { [otherInstanceId]: false, [instanceId]: true }, + providerInstances: { + [otherInstanceId]: { enabled: false }, + [instanceId]: { enabled: true }, + }, }, }); }); @@ -296,12 +292,12 @@ describe("buildProviderInstanceEnablementOverridePatch", () => { it("resets the only override by clearing the whole project entry", () => { const settings: Pick = { projectSettingsOverrides: { - [projectId]: { providerInstanceEnablement: { [instanceId]: true } }, + [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, }, }; expect( - buildProviderInstanceEnablementOverridePatch(settings, [projectId], instanceId, undefined), + buildProviderInstanceOverridePatch(settings, [projectId], instanceId, undefined), ).toEqual({ [projectId]: null }); }); @@ -310,17 +306,20 @@ describe("buildProviderInstanceEnablementOverridePatch", () => { projectSettingsOverrides: { [projectId]: { newWorktreesStartFromOrigin: true, - providerInstanceEnablement: { [instanceId]: true, [otherInstanceId]: false }, + providerInstances: { + [instanceId]: { enabled: true }, + [otherInstanceId]: { enabled: false }, + }, }, }, }; expect( - buildProviderInstanceEnablementOverridePatch(settings, [projectId], instanceId, undefined), + buildProviderInstanceOverridePatch(settings, [projectId], instanceId, undefined), ).toEqual({ [projectId]: { newWorktreesStartFromOrigin: true, - providerInstanceEnablement: { [otherInstanceId]: false }, + providerInstances: { [otherInstanceId]: { enabled: false } }, }, }); }); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts index 16a5f64f5851..309a28f8f2f4 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts @@ -117,7 +117,7 @@ export function resolveRemoteOperateAccess(input: { * environment's enablement, leaving any other overridden keys untouched. */ /** Every checkout of a project on one environment shares the same choice. */ -export function buildProviderInstanceEnablementOverridePatch( +export function buildProviderInstanceOverridePatch( settings: Pick, projectIds: readonly ProjectId[], instanceId: ProviderInstanceId, @@ -126,15 +126,16 @@ export function buildProviderInstanceEnablementOverridePatch( const patch: Record = {}; for (const projectId of projectIds) { const current = settings.projectSettingsOverrides[projectId]; - const enablement = { ...current?.providerInstanceEnablement }; - if (value === undefined) delete enablement[instanceId]; - else enablement[instanceId] = value; + const instances = { ...current?.providerInstances }; + const instance = { ...instances[instanceId] }; + if (value === undefined) delete instance.enabled; + else instance.enabled = value; + if (Object.keys(instance).length > 0) instances[instanceId] = instance; + else delete instances[instanceId]; - const { providerInstanceEnablement: _omit, ...rest } = current ?? {}; + const { providerInstances: _omit, ...rest } = current ?? {}; const nextEntry: ProjectSettingsOverrides = - Object.keys(enablement).length > 0 - ? { ...rest, providerInstanceEnablement: enablement } - : rest; + Object.keys(instances).length > 0 ? { ...rest, providerInstances: instances } : rest; patch[projectId] = Object.keys(nextEntry).length === 0 ? null : nextEntry; } return patch; diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 6c9729840a7c..caa14b47c272 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -118,7 +118,7 @@ import { } from "./settingsLayout"; import { buildProviderEnvironmentOptions, - buildProviderInstanceEnablementOverridePatch, + buildProviderInstanceOverridePatch, classifyProviderEnvironmentAccess, isProviderSettingsEnvironmentAvailable, type ProviderEnvironmentAccess, @@ -923,9 +923,9 @@ export function EnvironmentProviderSettings({ ); const resetLabel = driverOption?.label ?? String(row.driver); const representativeProjectId = projectIds[0]; - const writeProjectEnablement = (next: boolean | undefined) => { + const writeProjectOverride = (next: boolean | undefined) => { updateSettings({ - projectSettingsOverrides: buildProviderInstanceEnablementOverridePatch( + projectSettingsOverrides: buildProviderInstanceOverridePatch( settings, projectIds, row.instanceId, @@ -933,20 +933,21 @@ export function EnvironmentProviderSettings({ ), }); }; - const projectEnablement = + const projectOverride = representativeProjectId === undefined ? undefined : { value: - settings.projectSettingsOverrides[representativeProjectId] - ?.providerInstanceEnablement?.[row.instanceId], + settings.projectSettingsOverrides[representativeProjectId]?.providerInstances?.[ + row.instanceId + ]?.enabled, effectiveEnabled: resolveProjectProviderInstanceEnabled( settings, representativeProjectId, row.instanceId, ), - onChange: (next: boolean) => writeProjectEnablement(next), - onReset: () => writeProjectEnablement(undefined), + onChange: (next: boolean) => writeProjectOverride(next), + onReset: () => writeProjectOverride(undefined), }; return ( @@ -954,7 +955,7 @@ export function EnvironmentProviderSettings({ key={row.instanceId} environmentId={environmentId} acpProjects={projects} - projectEnablement={projectEnablement} + projectOverride={projectOverride} onAcceptUrlAuth={ readOnly ? undefined : (action) => acceptUrlAuthentication(row.instanceId, action) } diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 171e59f1e17b..b857ac0d7418 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -184,7 +184,7 @@ export function deriveProviderEntriesByEnvironment( * instance missing from `providerInstances` is stale (for example * immediately after deletion) and is treated as disabled. * - * `projectId` folds in that project's `providerInstanceEnablement` override + * `projectId` folds in that project's `providerInstances[id].enabled` override * (enable or disable in either direction over the machine value); `null` * (no project in scope, or a caller that isn't project-aware) resolves the * machine value only, matching every call site before this parameter existed. diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 3a7b78831453..deafcf507165 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -1085,7 +1085,7 @@ describe("resolveProjectProviderInstanceEnabled", () => { [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false }, }, projectSettingsOverrides: { - [projectId]: { providerInstanceEnablement: { [instanceId]: true } }, + [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, }, }; expect(resolveProjectProviderInstanceEnabled(settings, projectId, instanceId)).toBe(true); @@ -1098,7 +1098,7 @@ describe("resolveProjectProviderInstanceEnabled", () => { [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, }, projectSettingsOverrides: { - [projectId]: { providerInstanceEnablement: { [instanceId]: false } }, + [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, }, }; expect(resolveProjectProviderInstanceEnabled(settings, projectId, instanceId)).toBe(false); @@ -1111,7 +1111,7 @@ describe("resolveProjectProviderInstanceEnabled", () => { [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, }, projectSettingsOverrides: { - [projectId]: { providerInstanceEnablement: { [instanceId]: false } }, + [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, }, }; expect(resolveProjectProviderInstanceEnabled(settings, null, instanceId)).toBe(true); @@ -1129,7 +1129,7 @@ describe("resolveProjectProviderInstanceEnabled", () => { const overridden = { ...settings, projectSettingsOverrides: { - [projectId]: { providerInstanceEnablement: { [instanceId]: true } }, + [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, }, }; expect(resolveProjectProviderInstanceEnabled(overridden, projectId, instanceId)).toBe(true); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 9c6fe6fcb874..26a40a167393 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -972,18 +972,23 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ ] as const; export type ProjectScopedServerSettingKey = (typeof PROJECT_SCOPED_SERVER_SETTING_KEYS)[number]; +export const ProjectProviderInstanceOverride = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), +}); +export type ProjectProviderInstanceOverride = typeof ProjectProviderInstanceOverride.Type; + /** * One project's overrides. An absent key inherits the environment value; * `null` is a real value where the environment type is nullable (no default * model, no dedicated writer model, never auto-settle). * - * `providerInstanceEnablement` sits outside `PROJECT_SCOPED_SERVER_SETTING_KEYS`: - * every other key mirrors a `ServerSettings` field, but there is no single - * machine-wide `enabled` field to mirror, only each instance's own - * `providerInstances[id].enabled`. It overrides per `ProviderInstanceId` - * instead: an instance id absent from the record inherits the machine value, - * and a present entry wins in either direction, so a project can turn on an - * instance the machine disabled, or turn off one the machine enabled. See + * `providerInstances` sits outside `PROJECT_SCOPED_SERVER_SETTING_KEYS`: + * every other key replaces its `ServerSettings` field whole, while this one + * keeps the settings.json shape but overrides field by field, per + * `ProviderInstanceId`. Only `enabled` is overridable today. An instance id + * or field absent here inherits the machine value, and a present `enabled` + * wins in either direction, so a project can turn on an instance the machine + * disabled, or turn off one the machine enabled. See * `resolveProjectProviderInstanceEnabled` for the combined resolution. */ export const ProjectSettingsOverrides = Schema.Struct({ @@ -1011,8 +1016,10 @@ export const ProjectSettingsOverrides = Schema.Struct({ sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), responseStreamingMode: Schema.optionalKey(ResponseStreamingMode), - providerInstanceEnablement: Schema.optionalKey(Schema.Record(ProviderInstanceId, Schema.Boolean)), -} satisfies Record); + providerInstances: Schema.optionalKey( + Schema.Record(ProviderInstanceId, ProjectProviderInstanceOverride), + ), +} satisfies Record); export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type; /** @@ -1137,7 +1144,7 @@ export const ServerSettings = Schema.Struct({ ), /** * Per-project overrides of the keys in `PROJECT_SCOPED_SERVER_SETTING_KEYS`, - * plus `providerInstanceEnablement`. The source of truth for project + * plus `providerInstances`. The source of truth for project * settings; `projectAgentBrowserAccessOverrides`, `projectAutoPullOverrides` * and `projectScriptOverrides` are derived views kept for one release so * older clients keep reading them. @@ -1384,7 +1391,7 @@ export const resolveProviderInstanceEnabled = ( /** * Effective enablement for a provider instance within a project: the - * project's `providerInstanceEnablement` override when set, otherwise the + * project's `providerInstances[id].enabled` override when set, otherwise the * machine-wide `resolveProviderInstanceEnabled` value. `projectId` of `null` * (no project in scope) always resolves to the machine-wide value. */ @@ -1396,7 +1403,7 @@ export const resolveProjectProviderInstanceEnabled = ( const override = projectId === null ? undefined - : settings.projectSettingsOverrides[projectId]?.providerInstanceEnablement?.[instanceId]; + : settings.projectSettingsOverrides[projectId]?.providerInstances?.[instanceId]?.enabled; if (override !== undefined) return override; const instanceConfig = Object.hasOwn(settings.providerInstances, instanceId) ? settings.providerInstances[instanceId] diff --git a/packages/contracts/src/t3ProjectFile.ts b/packages/contracts/src/t3ProjectFile.ts index d3a72dabf3d4..eef374043fee 100644 --- a/packages/contracts/src/t3ProjectFile.ts +++ b/packages/contracts/src/t3ProjectFile.ts @@ -126,9 +126,9 @@ export const PROJECT_FILE_BACKED_SETTINGS = { defaultThreadEnvMode: { field: "defaultThreadEnvMode", builtIn: "local" }, worktreeSubmodules: { field: "worktreeSubmodules", builtIn: "recursive" }, } as const satisfies { - // `providerInstanceEnablement` is scoped per project but has no matching - // environment-level `ServerSettings` field (see its doc comment in - // settings.ts), so it cannot be file-backed and is excluded here. + // `providerInstances` is scoped per project but overrides field by field + // rather than replacing a whole `ServerSettings` value (see its doc comment + // in settings.ts), so it cannot be file-backed and is excluded here. readonly [K in ProjectScopedServerSettingKey & keyof ServerSettings]?: { readonly field: { readonly [F in keyof T3ProjectFile]: T3ProjectFile[F] extends diff --git a/packages/shared/src/projectSettings.test.ts b/packages/shared/src/projectSettings.test.ts index 3e86060b9d6a..89d38cae5d1c 100644 --- a/packages/shared/src/projectSettings.test.ts +++ b/packages/shared/src/projectSettings.test.ts @@ -153,7 +153,9 @@ describe("resolveProjectSettings", () => { const settings = { ...DEFAULT_SERVER_SETTINGS, textGenerationModelSelection: createModelSelection(codex, "gpt-5"), - projectSettingsOverrides: { [projectId]: { providerInstanceEnablement: { [codex]: false } } }, + projectSettingsOverrides: { + [projectId]: { providerInstances: { [codex]: { enabled: false } } }, + }, }; expect( resolveProjectSettings(settings, projectId).settings.textGenerationModelSelection.instanceId, @@ -172,9 +174,9 @@ describe("resolveProjectSettings", () => { providerInstances: { [codexWork]: { driver: ProviderDriverKind.make("codex"), config: {} } }, projectSettingsOverrides: { [projectId]: { - providerInstanceEnablement: { - [codex]: false, - [ProviderInstanceId.make("claudeAgent")]: false, + providerInstances: { + [codex]: { enabled: false }, + [ProviderInstanceId.make("claudeAgent")]: { enabled: false }, }, }, }, diff --git a/packages/shared/src/projectSettings.ts b/packages/shared/src/projectSettings.ts index 8fd016e3507e..eebaec477e37 100644 --- a/packages/shared/src/projectSettings.ts +++ b/packages/shared/src/projectSettings.ts @@ -176,7 +176,7 @@ function resolveProjectOverrides( if (value === undefined) continue; // A model on a disabled provider falls back to the environment, like the // environment-level guards do for these keys. An instance this project - // enabled via `providerInstanceEnablement` still counts as enabled here. + // enabled via `providerInstances[id].enabled` still counts as enabled here. if ( (key === "textGenerationModelSelection" || key === "defaultModelSelection") && value !== undefined && diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 1d2e40394f6b..2f4ef507ffa5 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -528,7 +528,7 @@ describe("serverSettings helpers", () => { [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true, config: {} }, }, projectSettingsOverrides: { - [projectId]: { providerInstanceEnablement: { [instanceId]: false } }, + [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, }, sourceControlWriterModelSelection, }; @@ -565,7 +565,7 @@ describe("serverSettings helpers", () => { [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false, config: {} }, }, projectSettingsOverrides: { - [projectId]: { providerInstanceEnablement: { [instanceId]: true } }, + [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, }, sourceControlWriterModelSelection, }; From af9b5c56ef1262835dee95fe7a27bd0c61352b4d Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 11 Oct 2026 02:30:41 -0400 Subject: [PATCH 17/39] refactor(web): the provider switch reads the project value with a plain fallback Signed-off-by: Yordis Prieto --- apps/web/src/components/settings/ProviderInstanceCard.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 554d19b24c72..a6265c044262 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -593,9 +593,7 @@ export function ProviderInstanceCard({ acpProjects = EMPTY_ACP_PROJECTS, projectOverride, }: ProviderInstanceCardProps) { - const enabled = projectOverride - ? projectOverride.effectiveEnabled - : resolveProviderInstanceEnabled(instance); + const enabled = projectOverride?.effectiveEnabled ?? resolveProviderInstanceEnabled(instance); const compatibility = enabled ? liveProvider?.compatibilityAdvisory : undefined; // A locally disabled provider reads "Disabled" with a muted dot even if its // last server status is stale. Enabled providers use the server status. From 86d5eb8f61d9d8c9d88bd1a0f209e231db271564 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 11 Oct 2026 02:47:50 -0400 Subject: [PATCH 18/39] fix(server): launch gates provider instances through the thread management service boundary Signed-off-by: Yordis Prieto --- .../orchestration-v2/ThreadLaunchService.ts | 12 ++- .../ThreadManagementService.test.ts | 83 +++++++++---------- .../ThreadManagementService.ts | 57 +++++-------- 3 files changed, 63 insertions(+), 89 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 6a231bd670ce..56d32db85c73 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -768,13 +768,11 @@ const make = Effect.gen(function* () { } const launchReceipt = yield* readReceipt(input, input.commandId); - yield* ThreadManagement.assertProviderInstanceEnabledForProject( - serverSettings, - receipts, - input.commandId, - input.projectId, - input.modelSelection.instanceId, - ); + yield* threads.assertProviderInstanceEnabled({ + projectId: input.projectId, + commandId: input.commandId, + instanceId: input.modelSelection.instanceId, + }); return yield* Effect.gen(function* () { // A retried launch has no client-supplied id to replay against, so // recover the thread id its accepted create was recorded under before diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index 8a12777a3199..a607646bc389 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -280,28 +280,31 @@ const layerReceiptsTest = CommandReceiptStore.layer.pipe( Layer.provide(SqlitePersistence.layerMemory), ); +const layerThreadManagementTest = (settingsLayer: ReturnType) => + ThreadManagementService.layer.pipe( + Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), + Layer.provide(Layer.mock(Orchestrator.OrchestratorV2)({})), + Layer.provide(settingsLayer), + Layer.provide(layerReceiptsTest), + ); + it.effect("refuses a new turn when the project effectively disables the instance", () => { const projectId = ProjectId.make("project:thread-management:gate-disabled"); const instanceId = ProviderInstanceId.make("codex_work"); const commandId = CommandId.make("command:thread-management:gate-disabled"); - const layerTest = ServerSettings.layerTest({ - providerInstances: { [instanceId]: { driver: "codex", enabled: true } }, - projectSettingsOverrides: { - [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, - }, - }); + const layerTest = layerThreadManagementTest( + ServerSettings.layerTest({ + providerInstances: { [instanceId]: { driver: "codex", enabled: true } }, + projectSettingsOverrides: { + [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, + }, + }), + ); return Effect.gen(function* () { - const settings = yield* ServerSettings.ServerSettingsService; - const receipts = yield* CommandReceiptStore.CommandReceiptStoreV2; + const threads = yield* ThreadManagementService.ThreadManagementService; const error = yield* Effect.flip( - ThreadManagementService.assertProviderInstanceEnabledForProject( - settings, - receipts, - commandId, - projectId, - instanceId, - ), + threads.assertProviderInstanceEnabled({ projectId, commandId, instanceId }), ); expect(error).toBeInstanceOf( @@ -311,58 +314,48 @@ it.effect("refuses a new turn when the project effectively disables the instance expect(error.message).toBe( `Provider instance "${instanceId}" is disabled for project ${projectId}.`, ); - }).pipe(Effect.provide(Layer.merge(layerTest, layerReceiptsTest))); + }).pipe(Effect.provide(layerTest)); }); it.effect("allows a new turn when the project enables an instance the machine disabled", () => { const projectId = ProjectId.make("project:thread-management:gate-enabled"); const instanceId = ProviderInstanceId.make("codex_work"); const commandId = CommandId.make("command:thread-management:gate-enabled"); - const layerTest = ServerSettings.layerTest({ - providerInstances: { [instanceId]: { driver: "codex", enabled: false } }, - projectSettingsOverrides: { - [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, - }, - }); + const layerTest = layerThreadManagementTest( + ServerSettings.layerTest({ + providerInstances: { [instanceId]: { driver: "codex", enabled: false } }, + projectSettingsOverrides: { + [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, + }, + }), + ); return Effect.gen(function* () { - const settings = yield* ServerSettings.ServerSettingsService; - const receipts = yield* CommandReceiptStore.CommandReceiptStoreV2; - yield* ThreadManagementService.assertProviderInstanceEnabledForProject( - settings, - receipts, - commandId, - projectId, - instanceId, - ); - }).pipe(Effect.provide(Layer.merge(layerTest, layerReceiptsTest))); + const threads = yield* ThreadManagementService.ThreadManagementService; + yield* threads.assertProviderInstanceEnabled({ projectId, commandId, instanceId }); + }).pipe(Effect.provide(layerTest)); }); it.effect("inherits the machine value when the project has no override", () => { const projectId = ProjectId.make("project:thread-management:gate-inherit"); const instanceId = ProviderInstanceId.make("codex_work"); const commandId = CommandId.make("command:thread-management:gate-inherit"); - const layerTest = ServerSettings.layerTest({ - providerInstances: { [instanceId]: { driver: "codex", enabled: false } }, - }); + const layerTest = layerThreadManagementTest( + ServerSettings.layerTest({ + providerInstances: { [instanceId]: { driver: "codex", enabled: false } }, + }), + ); return Effect.gen(function* () { - const settings = yield* ServerSettings.ServerSettingsService; - const receipts = yield* CommandReceiptStore.CommandReceiptStoreV2; + const threads = yield* ThreadManagementService.ThreadManagementService; const error = yield* Effect.flip( - ThreadManagementService.assertProviderInstanceEnabledForProject( - settings, - receipts, - commandId, - projectId, - instanceId, - ), + threads.assertProviderInstanceEnabled({ projectId, commandId, instanceId }), ); expect(error).toBeInstanceOf( ThreadManagementService.ThreadManagementProviderInstanceDisabledError, ); - }).pipe(Effect.provide(Layer.merge(layerTest, layerReceiptsTest))); + }).pipe(Effect.provide(layerTest)); }); it.effect("classifies projection infrastructure failures separately from a missing thread", () => { diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index c0cf38363170..dc4b447309b4 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -297,34 +297,6 @@ export type ThreadManagementError = typeof ThreadManagementError.Type; type ThreadManagementFailure = ThreadManagementError | Orchestrator.OrchestratorV2Error; -/** - * Refuses a new turn on an instance the project has effectively disabled. - * A command that already has a receipt was let through before, so its retry - * replays instead of being refused under newer settings. - */ -export const assertProviderInstanceEnabledForProject = ( - settings: ServerSettings.ServerSettingsService["Service"], - receipts: CommandReceiptStore.CommandReceiptStoreV2["Service"], - commandId: CommandId, - projectId: ProjectId, - instanceId: ProviderInstanceId, -): Effect.Effect => - receipts.getByCommandId(commandId).pipe( - Effect.orElseSucceed(() => Option.none()), - Effect.flatMap((receipt) => - Option.isSome(receipt) - ? Effect.void - : settings.getSettings.pipe( - Effect.orDie, - Effect.flatMap((current) => - resolveProjectProviderInstanceEnabled(current, projectId, instanceId) - ? Effect.void - : new ThreadManagementProviderInstanceDisabledError({ projectId, instanceId }), - ), - ), - ), - ); - export interface ThreadManagementServiceShape { readonly searchThreadStream: ( input: OrchestrationV2SearchThreadInput, @@ -386,9 +358,9 @@ export interface ThreadManagementServiceShape { input: ThreadManagementSendInput, ) => Effect.Effect; /** - * Bound form of `assertProviderInstanceEnabledForProject` for callers that - * already hold a `ThreadManagementService` instance but not the settings - * and receipt-store services it needs. + * Refuses a new turn on an instance the project has effectively disabled. + * A command that already has a receipt was let through before, so its + * retry replays instead of being refused under newer settings. */ readonly assertProviderInstanceEnabled: (input: { readonly projectId: ProjectId; @@ -526,12 +498,23 @@ const make = Effect.gen(function* () { const assertProviderInstanceEnabled: ThreadManagementServiceShape["assertProviderInstanceEnabled"] = (input) => - assertProviderInstanceEnabledForProject( - serverSettings, - receipts, - input.commandId, - input.projectId, - input.instanceId, + receipts.getByCommandId(input.commandId).pipe( + Effect.orElseSucceed(() => Option.none()), + Effect.flatMap((receipt) => + Option.isSome(receipt) + ? Effect.void + : serverSettings.getSettings.pipe( + Effect.orDie, + Effect.flatMap((current) => + resolveProjectProviderInstanceEnabled(current, input.projectId, input.instanceId) + ? Effect.void + : new ThreadManagementProviderInstanceDisabledError({ + projectId: input.projectId, + instanceId: input.instanceId, + }), + ), + ), + ), ); const isProviderInstanceEnabledForProject: ThreadManagementServiceShape["isProviderInstanceEnabledForProject"] = From 25371ccc1750bfc072e78bda8f039c31bbe6639a Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 11 Oct 2026 02:54:35 -0400 Subject: [PATCH 19/39] fix(server): a retried driver-only delegation replays after the project disables every candidate Signed-off-by: Yordis Prieto --- .../src/mcp/OrchestratorMcpService.test.ts | 133 ++++++++++++++++++ apps/server/src/mcp/OrchestratorMcpService.ts | 38 +++-- .../ThreadManagementService.ts | 15 ++ .../src/relay/AgentAwarenessRelay.test.ts | 1 + 4 files changed, 177 insertions(+), 10 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 3ed2b77bc642..1b0aedc07d5c 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -1099,6 +1099,7 @@ describe("OrchestratorMcpService provider resolution", () => { const layerDependencies = Layer.mergeAll( NodeServices.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ + hasReceipt: () => Effect.succeed(false), assertProviderInstanceEnabled: () => Effect.void, getThreadRecords: (threadId) => Effect.succeed( @@ -1198,6 +1199,7 @@ describe("OrchestratorMcpService provider resolution", () => { const layerDependencies = Layer.mergeAll( NodeServices.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ + hasReceipt: () => Effect.succeed(false), assertProviderInstanceEnabled: () => Effect.void, isProviderInstanceEnabledForProject: () => Effect.succeed(true), getThreadRecords: (threadId) => @@ -1278,6 +1280,7 @@ describe("OrchestratorMcpService provider resolution", () => { const layerDependencies = Layer.mergeAll( NodeServices.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ + hasReceipt: () => Effect.succeed(false), getThreadRecords: () => Effect.succeed(parentProjection([])), }), ServerSettings.layerTest(), @@ -1329,6 +1332,7 @@ describe("OrchestratorMcpService provider resolution", () => { const layerDependencies = Layer.mergeAll( NodeServices.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ + hasReceipt: () => Effect.succeed(false), getThreadRecords: () => Effect.succeed(parentProjection([])), assertProviderInstanceEnabled: ({ projectId, instanceId }) => Effect.fail( @@ -1395,6 +1399,7 @@ describe("OrchestratorMcpService provider resolution", () => { const layerDependencies = Layer.mergeAll( NodeServices.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ + hasReceipt: () => Effect.succeed(false), getThreadRecords: (threadId) => Effect.succeed( threadId === parentThreadId @@ -1470,6 +1475,132 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); + it.effect( + "retrying an accepted driver-only delegation replays even after the project disables every candidate", + () => + Effect.gen(function* () { + const altInstanceId = ProviderInstanceId.make("codex-alt"); + const dispatched = yield* Ref.make>([]); + const accepted = yield* Ref.make(false); + const everyCandidateDisabled = yield* Ref.make(false); + const task = { + id: taskId, + threadId: parentThreadId, + runId: parentRunId, + parentNodeId, + origin: "app_owned", + createdBy: "agent", + driver: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + providerThreadId: null, + childThreadId, + nativeTaskRef: null, + prompt: "Summarize the diff.", + title: null, + model: "gpt-5.4", + status: "running", + result: null, + startedAt: null, + completedAt: null, + }; + const layerDependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: (threadId) => + Ref.get(accepted).pipe( + Effect.map((isAccepted) => + threadId === parentThreadId + ? parentProjection(isAccepted ? [task] : []) + : childProjection, + ), + ), + // A command id already accepted once, even if the project later + // disables every candidate: its retry must replay, not re-resolve. + hasReceipt: () => Ref.get(accepted), + // Effective settings once the project turns every instance off, + // ignoring the already-accepted command's replay exemption. + isProviderInstanceEnabledForProject: () => + Ref.get(everyCandidateDisabled).pipe(Effect.map((disabled) => !disabled)), + assertProviderInstanceEnabled: ({ projectId, instanceId }) => + Effect.all([Ref.get(accepted), Ref.get(everyCandidateDisabled)]).pipe( + Effect.flatMap(([isAccepted, disabled]) => + isAccepted || !disabled + ? Effect.void + : Effect.fail( + new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({ + projectId, + instanceId, + }), + ), + ), + ), + dispatch: (command) => + Ref.update(dispatched, (commands) => [...commands, command]).pipe( + Effect.andThen(Ref.set(accepted, true)), + Effect.as({ + sequence: 1, + storedEvents: [ + { + sequence: 1, + commandId: null, + event: { type: "subagent.updated", payload: task }, + }, + ], + } as never), + ), + }), + providerRegistryLayer([ + providerSnapshot({ + instanceId: codexInstanceId, + driver: ProviderDriverKind.make("codex"), + model: "gpt-5.4", + }), + providerSnapshot({ + instanceId: altInstanceId, + driver: ProviderDriverKind.make("codex"), + model: "gpt-5.4", + }), + ]), + adapterRegistryLayer([codexInstanceId, altInstanceId]), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ); + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const first = yield* service.delegateTask(scope, { + task: "Summarize the diff.", + target: { driverKind: ProviderDriverKind.make("codex") }, + mode: "async", + clientRequestId: "delegate-driver-retry-after-disable-1", + }); + assert.equal(first.status, "running"); + + // The project turns off every instance of this driver only after + // the delegation above was already accepted. + yield* Ref.set(everyCandidateDisabled, true); + + const retry = yield* service.delegateTask(scope, { + task: "Summarize the diff.", + target: { driverKind: ProviderDriverKind.make("codex") }, + mode: "async", + clientRequestId: "delegate-driver-retry-after-disable-1", + }); + assert.equal(retry.status, "running"); + + const commands = yield* Ref.get(dispatched); + assert.equal(commands.length, 2); + const retryCommand = commands[1] as { + modelSelection: { instanceId: string; model: string }; + }; + assert.equal(retryCommand.modelSelection.instanceId, codexInstanceId); + }).pipe( + Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies))), + ); + }), + ); + it.effect("re-probes an unavailable target once before refusing it", () => Effect.gen(function* () { const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); @@ -1517,6 +1648,7 @@ describe("OrchestratorMcpService provider resolution", () => { const layerDependencies = Layer.mergeAll( NodeServices.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ + hasReceipt: () => Effect.succeed(false), assertProviderInstanceEnabled: () => Effect.void, getThreadRecords: (threadId) => Effect.succeed( @@ -1663,6 +1795,7 @@ describe("OrchestratorMcpService provider resolution", () => { const layerDependencies = Layer.mergeAll( NodeServices.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ + hasReceipt: () => Effect.succeed(false), assertProviderInstanceEnabled: () => Effect.void, isProviderInstanceEnabledForProject: () => Effect.succeed(true), getThreadRecords: (threadId) => diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index b9cf336e466d..167b0810535d 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1173,6 +1173,16 @@ const make = Effect.gen(function* () { readonly parent: Pick; readonly target: OrchestratorMcpTarget | undefined; readonly providers: ReadonlyArray; + /** + * Skips the per-candidate project-enablement check in driver-only + * resolution. Set only when the command that will dispatch this target + * already has a receipt: that dispatch can only replay the recorded + * result, never start anything new, so filtering candidates by the + * project's current settings would just refuse a retry that does no new + * work. Never set this for a resolution whose dispatch uses a different + * command id than the one checked for a receipt. + */ + readonly skipProjectEnablementFilter?: boolean; }): Effect.Effect => Effect.gen(function* () { const requestedInstanceId = input.target?.providerInstanceId; @@ -1208,10 +1218,12 @@ const make = Effect.gen(function* () { ...healthyCandidates.filter((candidate) => candidate !== inheritedCandidate), ]; for (const candidate of orderedCandidates) { - const enabledForProject = yield* threadManagement.isProviderInstanceEnabledForProject({ - projectId: input.parent.thread.projectId, - instanceId: candidate.instanceId, - }); + const enabledForProject = + input.skipProjectEnablementFilter === true || + (yield* threadManagement.isProviderInstanceEnabledForProject({ + projectId: input.parent.thread.projectId, + instanceId: candidate.instanceId, + })); if (enabledForProject) { instanceId = candidate.instanceId; break; @@ -1955,10 +1967,22 @@ const make = Effect.gen(function* () { ); } const providers = yield* loadProviders; + // Computed before resolving the target so a retry of an already + // accepted delegation can be recognized: dispatching under the same + // command id only replays the recorded result, so candidate + // selection must not refuse it over the project's current settings. + const key = yield* requestKey(input.clientRequestId); + const commandId = stableCommandId({ + scope, + requestKey: key, + operation: "delegate-task", + }); + const isRetryOfAcceptedDelegation = yield* threadManagement.hasReceipt(commandId); const target = yield* resolveTargetRechecking({ parent, target: input.target, providers, + skipProjectEnablementFilter: isRetryOfAcceptedDelegation, }); const runtimeMode = yield* resolveRuntimeMode( parent.thread.runtimeMode, @@ -1969,12 +1993,6 @@ const make = Effect.gen(function* () { parent.thread.interactionMode, input.interactionMode, ); - const key = yield* requestKey(input.clientRequestId); - const commandId = stableCommandId({ - scope, - requestKey: key, - operation: "delegate-task", - }); yield* assertTargetEnabledForProject(parent, commandId, target); const result = yield* threadManagement .dispatch({ diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index dc4b447309b4..3e3b57a005f9 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -375,6 +375,14 @@ export interface ThreadManagementServiceShape { readonly projectId: ProjectId; readonly instanceId: ProviderInstanceId; }) => Effect.Effect; + /** + * Whether `commandId` already has a receipt. A caller resolving a target + * ahead of a dispatch under this same command id can use it to skip + * project-enablement filtering: the dispatch will only replay the recorded + * result, never start anything new, so the filter would just refuse a + * retry the project's settings no longer allow fresh. + */ + readonly hasReceipt: (commandId: CommandId) => Effect.Effect; readonly waitForThread: ( input: ThreadManagementWaitInput, ) => Effect.Effect; @@ -526,6 +534,12 @@ const make = Effect.gen(function* () { ), ); + const hasReceipt: ThreadManagementServiceShape["hasReceipt"] = (commandId) => + receipts.getByCommandId(commandId).pipe( + Effect.orElseSucceed(() => Option.none()), + Effect.map(Option.isSome), + ); + const ensureLegacyTranscript = Effect.fn( "orchestrationV2.threadManagement.ensureLegacyTranscript", )(function* (threadId: ThreadId) { @@ -1087,6 +1101,7 @@ const make = Effect.gen(function* () { sendToThread, assertProviderInstanceEnabled, isProviderInstanceEnabledForProject, + hasReceipt, waitForThread, settleAfterRun, settleThread, diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 5aa5e193eb16..efb9ec3995b1 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -210,6 +210,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( sendToThread: unused, assertProviderInstanceEnabled: unused, isProviderInstanceEnabledForProject: unused, + hasReceipt: unused, waitForThread: unused, settleAfterRun: unused, settleThread: unused, From c32428e3707cae8b1bd927f8f3f7ac33311e092f Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 11 Oct 2026 02:49:57 -0400 Subject: [PATCH 20/39] feat(web): the provider switch shows at a glance whether the project overrides it Signed-off-by: Yordis Prieto --- .../settings/ProviderInstanceCard.tsx | 94 +++++++++++++++++-- 1 file changed, 88 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index a6265c044262..4a95cdccd555 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -4,10 +4,12 @@ import { Spinner } from "~/components/ui/spinner"; import { AlertTriangleIcon, + CheckIcon, ArrowUpCircleIcon, CopyIcon, DownloadIcon, ExternalLinkIcon, + LayersIcon, PlusIcon, Trash2Icon, XIcon, @@ -478,6 +480,80 @@ export function ProviderEnvironmentSection(props: { * `value` is the project's explicit override (`undefined` means it * inherits the environment); `effectiveEnabled` is what the Switch shows. */ +/** + * The project-scope counterpart of a settings row's inheritance indicator: + * hover for the summary, open for the project and device values side by side. + */ +function ProjectOverrideInheritance({ + projectEnabled, + deviceEnabled, +}: { + readonly projectEnabled: boolean; + readonly deviceEnabled: boolean; +}) { + const layers = [ + { key: "project", label: "Project", enabled: projectEnabled, effective: true }, + { key: "device", label: "Device", enabled: deviceEnabled, effective: false }, + ]; + return ( + + + + } + /> + } + > + + + Overridden for this project + + +

    + {layers.map((layer) => ( +
  1. + + {layer.label} + + + {layer.enabled ? "On" : "Off"} + {layer.effective ? ( + + ) : ( + + )} + +
  2. + ))} +
+ + + ); +} + interface ProjectOverrideControl { readonly value: boolean | undefined; readonly effectiveEnabled: boolean; @@ -994,12 +1070,18 @@ export function ProviderInstanceCard({ {projectOverride?.value !== undefined ? ( - + + + + ) : null} Date: Sun, 11 Oct 2026 03:11:39 -0400 Subject: [PATCH 21/39] fix(shared): machine-level text generation fallback ignores configured instances again Widening the fallback to configured instances was meant for a project that disabled every default instance; it should not change what an environment with no project overrides falls back to. Signed-off-by: Yordis Prieto --- packages/shared/src/projectSettings.ts | 1 + packages/shared/src/serverSettings.test.ts | 27 ++++++++++++++++++++++ packages/shared/src/serverSettings.ts | 24 +++++++++++++------ 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/packages/shared/src/projectSettings.ts b/packages/shared/src/projectSettings.ts index eebaec477e37..7bc976ad0adc 100644 --- a/packages/shared/src/projectSettings.ts +++ b/packages/shared/src/projectSettings.ts @@ -199,6 +199,7 @@ function resolveProjectOverrides( const fallback = fallbackTextGenerationModelSelection( settings.providerInstances, (instanceId) => resolveProjectProviderInstanceEnabled(settings, projectId, instanceId), + { includeConfiguredInstances: true }, ); if (fallback !== null) effective.textGenerationModelSelection = fallback; } diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 2f4ef507ffa5..40b688d51d37 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -13,6 +13,7 @@ import { createModelSelection } from "./model.ts"; import { resolveProjectScripts, projectScriptsInheritDefaults } from "./projectScripts.ts"; import { applyServerSettingsPatch, + fallbackTextGenerationModelSelection, modelPreferencesMigrationPatch, resolveModelPreferences, isModelSelectionProviderEnabled, @@ -25,6 +26,32 @@ import { /** Settings after the server has folded legacy per-project fields into `projectSettingsOverrides`. */ const FOLDED_SERVER_SETTINGS = { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true }; +describe("fallbackTextGenerationModelSelection", () => { + it("ignores a configured non-default instance at machine scope, matching no fallback", () => { + const configured = ProviderInstanceId.make("codex_work"); + const providerInstances = { + [configured]: { driver: ProviderDriverKind.make("codex"), config: {} }, + }; + const fallback = fallbackTextGenerationModelSelection(providerInstances, (instanceId) => + instanceId === configured ? true : false, + ); + expect(fallback).toBeNull(); + }); + + it("considers a configured non-default instance only when asked to", () => { + const configured = ProviderInstanceId.make("codex_work"); + const providerInstances = { + [configured]: { driver: ProviderDriverKind.make("codex"), config: {} }, + }; + const fallback = fallbackTextGenerationModelSelection( + providerInstances, + (instanceId) => instanceId === configured, + { includeConfiguredInstances: true }, + ); + expect(fallback?.instanceId).toBe(configured); + }); +}); + describe("serverSettings helpers", () => { it("changes a cleanup rule without replacing the machine's other rules", () => { const enabled = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 9310cbb63d91..c91ca5f9e1a1 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -91,22 +91,32 @@ const TEXT_GENERATION_FALLBACK_DRIVERS = [ /** * The text generation model on the first instance `isEnabled` accepts: the - * built-in default instances in preference order, then configured instances - * of those drivers. `null` when none is. + * built-in default instances in preference order, then, when + * `includeConfiguredInstances` is set, configured instances of those + * drivers. `null` when none is. + * + * `includeConfiguredInstances` is for the project-scoped caller only: a + * project that disabled every built-in instance should still be able to + * fall back to one it configured itself. The machine-level caller leaves it + * unset, since widening the candidates there would change behavior for + * every user regardless of project scope. */ export function fallbackTextGenerationModelSelection( providerInstances: ServerSettings["providerInstances"], isEnabled: (instanceId: ProviderInstanceId, driver: ProviderDriverKind) => boolean, + options?: { readonly includeConfiguredInstances?: boolean }, ): ModelSelection | null { const candidates = [ ...TEXT_GENERATION_FALLBACK_DRIVERS.map( (driver) => [defaultInstanceIdForDriver(driver), driver] as const, ), - ...Object.entries(providerInstances).flatMap(([instanceId, instance]) => - TEXT_GENERATION_FALLBACK_DRIVERS.includes(instance.driver) - ? [[ProviderInstanceId.make(instanceId), instance.driver] as const] - : [], - ), + ...(options?.includeConfiguredInstances === true + ? Object.entries(providerInstances).flatMap(([instanceId, instance]) => + TEXT_GENERATION_FALLBACK_DRIVERS.includes(instance.driver) + ? [[ProviderInstanceId.make(instanceId), instance.driver] as const] + : [], + ) + : []), ]; const match = candidates.find(([instanceId, driver]) => isEnabled(instanceId, driver)); if (match === undefined) return null; From 9151323e168a944458665138a809a93e7c89b770 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 11 Oct 2026 03:20:33 -0400 Subject: [PATCH 22/39] fix(settings): a project's providerInstances override survives an older client's edit An older client that predates per-project provider overrides reads and resends a project's whole settings entry without knowing about the field. Whole-entry replacement then wiped providerInstances on any unrelated edit, silently re-enabling a provider a project had turned off. A new client talking to an older server has the mirror problem: the server decodes the unknown field away, so the switch would look like it worked while doing nothing, which is worse than not offering it. Signed-off-by: Yordis Prieto --- .../src/environment/ServerEnvironment.test.ts | 1 + .../src/environment/ServerEnvironment.ts | 1 + .../ProviderSettingsPanel.logic.test.ts | 21 +++++++ .../settings/ProviderSettingsPanel.logic.ts | 12 +++- .../settings/ProviderSettingsPanel.tsx | 10 +++- packages/contracts/src/environment.ts | 5 ++ packages/contracts/src/settings.ts | 5 ++ packages/shared/src/serverSettings.test.ts | 56 +++++++++++++++++++ packages/shared/src/serverSettings.ts | 35 +++++++++++- 9 files changed, 140 insertions(+), 6 deletions(-) diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index f9e13212252f..1d2258b18ab1 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -222,6 +222,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.threadPullRequests).toBe(true); expect(second.capabilities.threadPullRequestLinking).toBe(true); expect(second.capabilities.serverResolvedCommandContext).toBe(true); + expect(second.capabilities.projectProviderInstanceOverrides).toBe(true); expect(second.capabilities.agentActivityPublishing).toBe(false); }), ); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index bb5ce86e60d2..a55b1098d5f0 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -234,6 +234,7 @@ export const make = Effect.gen(function* () { worktreesDirectory: true, threadRestartContinuation: true, projectSettingsOverrides: true, + projectProviderInstanceOverrides: true, threadSnooze: true, environmentThemes: true, usageLimitSources: true, diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts index 683ccb362aa2..d3d918521ba2 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts @@ -323,6 +323,27 @@ describe("buildProviderInstanceOverridePatch", () => { }, }); }); + + it("sends providerInstances: {} explicitly when clearing the last instance override leaves other keys", () => { + // The server keeps a stored providerInstances map when an incoming entry + // omits the key (so older clients cannot wipe it by editing another + // setting). This clear must say so explicitly, or the server would put + // the just-cleared override right back. + const settings: Pick = { + projectSettingsOverrides: { + [projectId]: { + newWorktreesStartFromOrigin: true, + providerInstances: { [instanceId]: { enabled: true } }, + }, + }, + }; + + expect( + buildProviderInstanceOverridePatch(settings, [projectId], instanceId, undefined), + ).toEqual({ + [projectId]: { newWorktreesStartFromOrigin: true, providerInstances: {} }, + }); + }); }); describe("remote operate access", () => { diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts index 309a28f8f2f4..3d105faf6487 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts @@ -115,6 +115,12 @@ export function resolveRemoteOperateAccess(input: { * replaces the project's whole override set, and `null` removes it. * `value: undefined` resets that single instance back to inheriting the * environment's enablement, leaving any other overridden keys untouched. + * + * `providerInstances` is always sent explicitly, even as `{}`, because the + * server keeps a stored `providerInstances` map when an incoming entry omits + * the key (so older clients that don't know the field cannot wipe it by + * editing another setting). Omitting it here would undo the very clear this + * function means to make. */ /** Every checkout of a project on one environment shares the same choice. */ export function buildProviderInstanceOverridePatch( @@ -134,9 +140,9 @@ export function buildProviderInstanceOverridePatch( else delete instances[instanceId]; const { providerInstances: _omit, ...rest } = current ?? {}; - const nextEntry: ProjectSettingsOverrides = - Object.keys(instances).length > 0 ? { ...rest, providerInstances: instances } : rest; - patch[projectId] = Object.keys(nextEntry).length === 0 ? null : nextEntry; + const nextEntry: ProjectSettingsOverrides = { ...rest, providerInstances: instances }; + patch[projectId] = + Object.keys(rest).length === 0 && Object.keys(instances).length === 0 ? null : nextEntry; } return patch; } diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index caa14b47c272..7941e252d200 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -56,7 +56,7 @@ import { } from "../../state/environments"; import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; import { useEnvironmentSessionState } from "../../state/session"; -import { useProjects } from "../../state/entities"; +import { useProjects, useServerConfigs } from "../../state/entities"; import { useAtomCommand } from "../../state/use-atom-command"; import { getRelativeTimeState } from "../../timestampFormat"; import { @@ -585,6 +585,12 @@ export function EnvironmentProviderSettings({ const serverProviders = useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS; const projects = useProjects().filter((project) => project.environmentId === environmentId); + // Older servers decode an unknown providerInstances key away, so sending + // a project override patch would silently do nothing. Hide the switch + // instead of offering a control that can never take effect. + const supportsProjectProviderOverrides = + useServerConfigs().get(environmentId)?.environment.capabilities + .projectProviderInstanceOverrides === true; const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false, }); @@ -934,7 +940,7 @@ export function EnvironmentProviderSettings({ }); }; const projectOverride = - representativeProjectId === undefined + representativeProjectId === undefined || !supportsProjectProviderOverrides ? undefined : { value: diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 08bfb3225a68..a4763076639b 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -135,6 +135,11 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ threadRestartContinuation: Schema.optionalKey(Schema.Boolean), /** Server resolves `projectSettingsOverrides`; older servers ignore the key. */ projectSettingsOverrides: Schema.optionalKey(Schema.Boolean), + /** Server resolves a project's `providerInstances` override entry. Older + servers decode the key away (unknown fields are stripped, not + rejected), so the per-project provider switch would silently do + nothing; clients hide it instead of sending a patch that goes nowhere. */ + projectProviderInstanceOverrides: Schema.optionalKey(Schema.Boolean), /** Server understands thread.snooze / thread.unsnooze commands. Same version-skew contract as threadSettlement. */ threadSnooze: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 26a40a167393..2d3fd53e31f1 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1540,6 +1540,11 @@ export const ServerSettingsPatch = Schema.Struct({ * the entry without that key. Per-key null cannot express "clear" for the * keys whose value type is itself nullable, and clients always hold the * current entry from the last settings snapshot. + * + * `providerInstances` is the one exception: an entry that omits the key + * keeps the stored value instead of clearing it, so a client built before + * that field existed cannot wipe it by editing another key. Clearing every + * provider override for a project means sending `providerInstances: {}`. */ projectSettingsOverrides: Schema.optionalKey( Schema.Record(ProjectId, Schema.NullOr(ProjectSettingsOverrides)), diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 40b688d51d37..0bb4c76c9409 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -653,6 +653,62 @@ describe("serverSettings helpers", () => { }); }); + it("keeps a project's providerInstances override when a patch entry omits the key", () => { + // An older client that predates per-project provider overrides reads and + // resends a project's whole entry without knowing about the field. A + // naive whole-entry replace would silently wipe it. + const instanceId = ProviderInstanceId.make("codex_work"); + const projectId = ProjectId.make("project-a"); + const current = { + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { + [projectId]: { + newWorktreesStartFromOrigin: true, + providerInstances: { [instanceId]: { enabled: false } }, + }, + }, + }; + + const patched = applyServerSettingsPatch(current, { + projectSettingsOverrides: { + [projectId]: { newWorktreesStartFromOrigin: false }, + }, + }); + + expect(patched.projectSettingsOverrides[projectId]).toEqual({ + newWorktreesStartFromOrigin: false, + providerInstances: { [instanceId]: { enabled: false } }, + }); + }); + + it("clears a project's providerInstances override when a patch entry sends an empty map", () => { + // A client that knows the field expresses "clear every provider override + // for this project" by sending providerInstances explicitly, not by + // omitting it. + const instanceId = ProviderInstanceId.make("codex_work"); + const projectId = ProjectId.make("project-a"); + const current = { + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { + [projectId]: { + newWorktreesStartFromOrigin: true, + providerInstances: { [instanceId]: { enabled: false } }, + }, + }, + }; + + const patched = applyServerSettingsPatch(current, { + projectSettingsOverrides: { + [projectId]: { newWorktreesStartFromOrigin: true, providerInstances: {} }, + }, + }); + + expect(patched.projectSettingsOverrides[projectId]).toEqual({ + newWorktreesStartFromOrigin: true, + providerInstances: {}, + }); + }); + it("upserts and removes usageLimitSources per entry so concurrent edits cannot clobber", () => { const hubA = UsageLimitSourceId.make("cliproxy-a"); const hubB = UsageLimitSourceId.make("cliproxy-b"); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index c91ca5f9e1a1..3230aefbb3d5 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -254,6 +254,36 @@ function mergeSettingsEntries( return Object.fromEntries(next); } +/** + * `projectSettingsOverrides` entries otherwise replace whole (see the field's + * doc comment in contracts), but `providerInstances` was added after clients + * already round-trip a project's entry on every edit. A client built before + * that addition sends an entry that never mentions the key, and a whole + * replace would silently wipe it. When an incoming entry omits the key, keep + * what is stored; a client that means to clear every provider override for + * the project says so explicitly with `providerInstances: {}`. + */ +function mergeProjectSettingsOverridesPatch( + current: Readonly>, + patch: Readonly>, +): Record { + const next = new Map(Object.entries(current)); + for (const [projectId, entry] of Object.entries(patch)) { + if (entry === null) { + next.delete(projectId); + continue; + } + const storedProviderInstances = current[projectId]?.providerInstances; + next.set(projectId, { + ...entry, + ...(Object.hasOwn(entry, "providerInstances") || storedProviderInstances === undefined + ? {} + : { providerInstances: storedProviderInstances }), + }); + } + return Object.fromEntries(next); +} + /** * Derived views of `projectSettingsOverrides` for clients that still read * the legacy per-key maps. Recomputed on every patch and load so they @@ -526,7 +556,10 @@ export function applyServerSettingsPatch( ? { projectSettingsOverrides: Object.fromEntries( Object.entries( - mergeSettingsEntries(current.projectSettingsOverrides, projectSettingsOverridesPatch), + mergeProjectSettingsOverridesPatch( + current.projectSettingsOverrides, + projectSettingsOverridesPatch, + ), ).filter(([, entry]) => Object.keys(entry).length > 0), ), } From fc501eb1ebdbd4546d5436b86069f34756f54705 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 11 Oct 2026 03:48:58 -0400 Subject: [PATCH 23/39] fix(server): a deleted project's instance overrides can no longer outlive it Registry hydration folds every project's enabled override in unconditionally, so a project deleted without this cleanup (an older server, or a crash before the cleanup write) left a machine-disabled instance probed and alive with no UI left to turn it off. Signed-off-by: Yordis Prieto --- apps/server/src/cli/project.test.ts | 3 + apps/server/src/cli/project.ts | 3 + .../src/project/ManagedProjectFolders.test.ts | 2 + .../project/ProjectService.deletion.test.ts | 30 +++++++++ .../server/src/project/ProjectService.test.ts | 3 + apps/server/src/project/ProjectService.ts | 24 +++++++- .../ProjectSettingsOverrideSweep.test.ts | 46 ++++++++++++++ .../provider/ProjectSettingsOverrideSweep.ts | 61 +++++++++++++++++++ apps/server/src/server.ts | 5 ++ 9 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/provider/ProjectSettingsOverrideSweep.test.ts create mode 100644 apps/server/src/provider/ProjectSettingsOverrideSweep.ts diff --git a/apps/server/src/cli/project.test.ts b/apps/server/src/cli/project.test.ts index 965787005ae0..fb7a386059d6 100644 --- a/apps/server/src/cli/project.test.ts +++ b/apps/server/src/cli/project.test.ts @@ -25,6 +25,7 @@ import * as References from "effect/References"; import * as Stream from "effect/Stream"; import { Command } from "effect/cli"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { cli } from "../binCli.ts"; import * as ServerConfig from "../config.ts"; import * as EventSink from "../orchestration-v2/EventSink.ts"; @@ -37,6 +38,7 @@ import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import { ProjectLiveServerDeclaredResponseError, @@ -93,6 +95,7 @@ const readProjects = (baseDir: string) => Layer.provideMerge(ProjectFaviconResolver.layer), Layer.provideMerge(T3ProjectFileLoader.layer), Layer.provideMerge(WorkspacePaths.layer), + Layer.provideMerge(ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer))), Layer.provideMerge(SqlitePersistence.layerConfig), Layer.provideMerge(NodeServices.layer), Layer.provide(ServerConfig.layer(config)), diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index afa37440c79f..ae5d84fa6391 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -22,6 +22,7 @@ import { FetchHttpClient, HttpClient, HttpClientError } from "effect/http"; import * as HttpApiClient from "effect/http-api/HttpApiClient"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfig from "../config.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; @@ -32,6 +33,7 @@ import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolv import * as ProjectService from "../project/ProjectService.ts"; import { projectMutationOperation } from "../project/ProjectMutation.ts"; import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; +import * as ServerSettings from "../serverSettings.ts"; import { clearPersistedServerRuntimeState, readPersistedServerRuntimeState, @@ -206,6 +208,7 @@ const layerProjectCliRuntime = RuntimeLayer.layerProjectService.pipe( ), ), Layer.provideMerge(WorkspacePaths.layer), + Layer.provideMerge(ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer))), Layer.provideMerge(SqlitePersistence.layerConfig), ); diff --git a/apps/server/src/project/ManagedProjectFolders.test.ts b/apps/server/src/project/ManagedProjectFolders.test.ts index 393c858a6931..2ff9246018b9 100644 --- a/apps/server/src/project/ManagedProjectFolders.test.ts +++ b/apps/server/src/project/ManagedProjectFolders.test.ts @@ -23,6 +23,7 @@ import * as GitWorkflow from "../git/GitWorkflowService.ts"; import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProcessRunner from "../processRunner.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; @@ -87,6 +88,7 @@ const layer = (baseDir: string, options?: HarnessOptions) => Layer.provideMerge(layerGitWorkflow(options?.vcsProcess)), Layer.provideMerge(options?.git ?? layerRealGit), Layer.provideMerge(SqlitePersistence.layerMemory), + Layer.provideMerge(ServerSettings.layerTest()), Layer.provideMerge(ServerConfig.layerTest(baseDir, baseDir)), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/project/ProjectService.deletion.test.ts b/apps/server/src/project/ProjectService.deletion.test.ts index a64896b637f8..acbd382abb8d 100644 --- a/apps/server/src/project/ProjectService.deletion.test.ts +++ b/apps/server/src/project/ProjectService.deletion.test.ts @@ -28,6 +28,7 @@ import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts"; import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; @@ -63,6 +64,7 @@ const layerServices = Layer.mergeAll( ), ), ), + Layer.provideMerge(ServerSettings.layerTest()), ); const layerDatabase = SqlitePersistence.layerMemory.pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "project-deletion-test-" })), @@ -494,3 +496,31 @@ it.effect("deletes a project without force once its imported threads were delete }).pipe(Effect.provide(layerServices)); }).pipe(Effect.provide(layerDatabase)), ); + +it.effect( + "clears a deleted project's settings overrides so a machine-disabled instance cannot stay alive", + () => + Effect.gen(function* () { + const projectId = ProjectId.make("project:overrides-cleanup"); + const instanceId = ProviderInstanceId.make("codex_work"); + yield* seedProject(projectId); + + yield* Effect.gen(function* () { + const service = yield* ProjectService.make; + const serverSettings = yield* ServerSettings.ServerSettingsService; + yield* serverSettings.updateSettings({ + projectSettingsOverrides: { + [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, + }, + }); + + yield* service.delete({ + commandId: CommandId.make("command:overrides-cleanup:project-delete"), + projectId, + }); + + const settingsAfterDelete = yield* serverSettings.getSettings; + assert.isUndefined(settingsAfterDelete.projectSettingsOverrides[projectId]); + }).pipe(Effect.provide(layerServices)); + }).pipe(Effect.provide(layerDatabase)), +); diff --git a/apps/server/src/project/ProjectService.test.ts b/apps/server/src/project/ProjectService.test.ts index 09d4f8229771..844a20c16849 100644 --- a/apps/server/src/project/ProjectService.test.ts +++ b/apps/server/src/project/ProjectService.test.ts @@ -19,6 +19,7 @@ import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts"; import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts"; import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; @@ -60,6 +61,7 @@ const layerTestFor = ( Layer.provideMerge(layerWorkspacePaths), Layer.provideMerge(projectMetadataLayer), Layer.provideMerge(SqlitePersistence.layerMemory), + Layer.provideMerge(ServerSettings.layerTest()), Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "project-service-test-" })), Layer.provide(NodeServices.layer), ); @@ -79,6 +81,7 @@ const layerProjectServiceDependencies = Layer.mergeAll( Layer.provideMerge(layerWorkspacePaths), Layer.provideMerge(layerMetadata), Layer.provideMerge(SqlitePersistence.layerMemory), + Layer.provideMerge(ServerSettings.layerTest()), Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "project-service-race-" })), Layer.provide(NodeServices.layer), ); diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index f37b4e383061..eef94210b3d2 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -30,6 +30,7 @@ import { import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; @@ -153,6 +154,7 @@ export const make = Effect.gen(function* () { const idAllocator = yield* IdAllocator.IdAllocatorV2; const legacyImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor; + const serverSettings = yield* ServerSettings.ServerSettingsService; // Commands for one project run in order. Commands that claim a workspace root // also hold that root, so two projects cannot both claim it. const projectLocks = yield* KeyedLock.make(); @@ -486,6 +488,22 @@ export const make = Effect.gen(function* () { ); }); + // A machine-disabled provider instance that a deleted project's override + // once turned on must not stay probed and alive forever. Nothing else + // removes the entry, so deletion is where it goes; best-effort because a + // settings write failure should not block the deletion it is cleaning up + // after, and a server crash before a replay never revisits this project. + const clearProjectSettingsOverrides = (projectId: ProjectId) => + serverSettings.updateSettings({ projectSettingsOverrides: { [projectId]: null } }).pipe( + Effect.asVoid, + Effect.catchCause((cause) => + Effect.logWarning( + `Failed to clear settings overrides for deleted project ${projectId}.`, + cause, + ), + ), + ); + const deleteProject: ProjectService["Service"]["delete"] = Effect.fn("ProjectService.delete")( function* (input) { const { projectId } = input; @@ -496,10 +514,14 @@ export const make = Effect.gen(function* () { return yield* new ProjectNotFoundError({ projectId }); } - if (existing.value.deletedAt === null) { + const isFirstDelete = existing.value.deletedAt === null; + if (isFirstDelete) { yield* deleteChildThreads(input); } yield* commit({ type: "project.delete", commandId: input.commandId, projectId }); + if (isFirstDelete) { + yield* clearProjectSettingsOverrides(projectId); + } yield* projectEnrichment.invalidate([existing.value.workspaceRoot]); return yield* readCommitted(projectId); }, diff --git a/apps/server/src/provider/ProjectSettingsOverrideSweep.test.ts b/apps/server/src/provider/ProjectSettingsOverrideSweep.test.ts new file mode 100644 index 000000000000..01fd35c0c598 --- /dev/null +++ b/apps/server/src/provider/ProjectSettingsOverrideSweep.test.ts @@ -0,0 +1,46 @@ +import { DEFAULT_SERVER_SETTINGS, ProjectId, type ServerSettings } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { findOrphanedProjectIds } from "./ProjectSettingsOverrideSweep.ts"; + +const liveProjectId = ProjectId.make("project-live"); +const deletedProjectId = ProjectId.make("project-deleted"); + +function settingsWith(overrides: Partial): ServerSettings { + return { ...DEFAULT_SERVER_SETTINGS, ...overrides }; +} + +describe("findOrphanedProjectIds", () => { + it("reports a project override whose project is no longer live", () => { + const settings = settingsWith({ + projectSettingsOverrides: { + [liveProjectId]: { providerInstances: {} }, + [deletedProjectId]: { providerInstances: {} }, + }, + }); + + const orphaned = findOrphanedProjectIds(settings, new Set([liveProjectId])); + + expect(orphaned).toEqual([deletedProjectId]); + }); + + it("reports nothing when every override's project is still live", () => { + const settings = settingsWith({ + projectSettingsOverrides: { + [liveProjectId]: { providerInstances: {} }, + }, + }); + + const orphaned = findOrphanedProjectIds(settings, new Set([liveProjectId])); + + expect(orphaned).toEqual([]); + }); + + it("reports nothing when there are no overrides at all", () => { + const settings = settingsWith({ projectSettingsOverrides: {} }); + + const orphaned = findOrphanedProjectIds(settings, new Set([liveProjectId])); + + expect(orphaned).toEqual([]); + }); +}); diff --git a/apps/server/src/provider/ProjectSettingsOverrideSweep.ts b/apps/server/src/provider/ProjectSettingsOverrideSweep.ts new file mode 100644 index 000000000000..eae4e25def37 --- /dev/null +++ b/apps/server/src/provider/ProjectSettingsOverrideSweep.ts @@ -0,0 +1,61 @@ +/** + * ProjectSettingsOverrideSweep — remove `projectSettingsOverrides` entries + * whose project no longer exists. + * + * `ProjectService.delete` clears a project's entry as part of deleting the + * project, but that only covers deletions a server new enough to run that + * code actually processes. A project deleted by an older server, or one + * whose cleanup write failed, leaves its override behind: + * `ProviderInstanceRegistryHydration` folds every project's override in + * unconditionally, so a machine-disabled provider instance the override once + * turned on stays probed and alive forever, with no UI left to turn it off. + * + * This sweep runs once at boot and removes overrides left over from projects + * that no longer exist. The settings watcher's normal reconcile picks up + * whatever it clears, so the registry does not need its own live-project + * check on every settings change. + * + * @module provider/ProjectSettingsOverrideSweep + */ +import { type ProjectId, type ServerSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; +import * as Settings from "../serverSettings.ts"; + +/** Pure so the orphan rule can be tested without layers. */ +export const findOrphanedProjectIds = ( + settings: Pick, + liveProjectIds: ReadonlySet, +): ReadonlyArray => + Object.keys(settings.projectSettingsOverrides) + .map((projectId) => projectId as ProjectId) + .filter((projectId) => !liveProjectIds.has(projectId)); + +/** + * Best-effort: a failed read or write here should not block server startup, + * and a sweep that does not run to completion is simply retried on the next + * boot. + */ +export const sweep = Effect.gen(function* () { + const serverSettings = yield* Settings.ServerSettingsService; + const projectStore = yield* ProjectStore.ProjectStoreV2; + + const settings = yield* serverSettings.getSettings; + const liveShells = yield* projectStore.listShells(); + const liveProjectIds = new Set(liveShells.map((shell) => shell.id)); + + const orphanedProjectIds = findOrphanedProjectIds(settings, liveProjectIds); + if (orphanedProjectIds.length === 0) return; + + yield* serverSettings.updateSettings({ + projectSettingsOverrides: Object.fromEntries( + orphanedProjectIds.map((projectId) => [projectId, null]), + ), + }); +}).pipe( + Effect.asVoid, + Effect.catchCause((cause) => + Effect.logWarning("Failed to sweep orphaned project settings overrides.", cause), + ), +); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 9f997b1aa30f..3c1539a70985 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -58,6 +58,7 @@ import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import * as ProviderInstanceRegistryHydration from "./provider/ProviderInstanceRegistryHydration.ts"; +import * as ProjectSettingsOverrideSweep from "./provider/ProjectSettingsOverrideSweep.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as McpHttpServer from "./mcp/McpHttpServer.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; @@ -578,6 +579,10 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( ProviderUsageLimitsIngestion.layer, layerProviderInstallationRefresh, ReplayMarkers.layer, + // Catches overrides left behind by a project deleted before this cleanup + // existed, or by a cleanup write that failed. See ProjectService.delete + // for the normal (on-delete) path. + Layer.effectDiscard(ProjectSettingsOverrideSweep.sweep), ).pipe( // Core Services Layer.provideMerge(layerOrchestrationApplication), From e90c49e168784db3e808d434891704498e7357f2 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 11 Oct 2026 03:25:32 -0400 Subject: [PATCH 24/39] fix(web,mobile,server): require projectId where provider enablement is resolved Every optional projectId on these helpers defaulted to the machine's own view, so a surface that forgot to pass one silently showed the wrong enablement instead of failing to compile. CommandPalette's model-switching entry and deriveProviderEntriesByEnvironment were two such misses; both now pass an explicit null, documented as a deliberate machine-wide scope rather than an omission. Making the parameter required turns the next miss into a compile error instead of a quiet wrong answer. Signed-off-by: Yordis Prieto --- apps/mobile/src/lib/modelOptions.test.ts | 130 ++++++++++++--------- apps/mobile/src/lib/modelOptions.ts | 21 +++- apps/server/src/serverSettings.test.ts | 4 +- apps/web/src/components/CommandPalette.tsx | 7 +- apps/web/src/providerInstances.test.ts | 128 ++++++++++++-------- apps/web/src/providerInstances.ts | 13 ++- packages/shared/src/serverSettings.test.ts | 6 +- packages/shared/src/serverSettings.ts | 10 +- 8 files changed, 200 insertions(+), 119 deletions(-) diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 339dd38cc6cd..48cd9be326e3 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -51,7 +51,7 @@ describe("mobile model options", () => { ], } as unknown as ServerConfig; - expect(groupByProvider(buildModelOptions(config, null))).toMatchObject([ + expect(groupByProvider(buildModelOptions(config, null, undefined, null))).toMatchObject([ { providerKey: "codex", providerLabel: "Codex", @@ -87,7 +87,7 @@ describe("mobile model options", () => { ], } as unknown as ServerConfig; - const [group] = groupByProvider(buildModelOptions(config, null)); + const [group] = groupByProvider(buildModelOptions(config, null, undefined, null)); expect(group).toMatchObject({ providerKey: "acpRegistry_antigravity", @@ -131,7 +131,7 @@ describe("mobile model options", () => { model: "github-copilot/claude-fable-5", }; - const options = buildModelOptions(config, selection); + const options = buildModelOptions(config, selection, undefined, null); expect(options).toMatchObject( sources.map((source) => ({ @@ -185,26 +185,41 @@ describe("mobile model options", () => { ], } as unknown as ServerConfig; - const [option] = buildModelOptions(config, { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-test", - }); + const [option] = buildModelOptions( + config, + { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-test", + }, + undefined, + null, + ); expect(option?.capabilities?.optionDescriptors?.[0]?.id).toBe("serviceTier"); expect(option?.selection.options).toBeUndefined(); - const [emptyOption] = buildModelOptions(config, { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-test", - options: [], - }); + const [emptyOption] = buildModelOptions( + config, + { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-test", + options: [], + }, + undefined, + null, + ); expect(emptyOption?.selection).toEqual(option?.selection); - const [explicitOption] = buildModelOptions(config, { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-test", - options: [{ id: "serviceTier", value: "priority" }], - }); + const [explicitOption] = buildModelOptions( + config, + { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-test", + options: [{ id: "serviceTier", value: "priority" }], + }, + undefined, + null, + ); expect(explicitOption?.selection.options).toEqual([{ id: "serviceTier", value: "priority" }]); }); @@ -220,12 +235,13 @@ describe("mobile model options", () => { const config = { providers } as unknown as ServerConfig; const selection = { instanceId: ProviderInstanceId.make("codex"), model: "test" }; - expect(buildModelOptions(config, selection).map((option) => option.providerKey)).toEqual([ - "codex", - "claudeAgent", - ]); - expect(buildModelOptions(config, selection, selection.instanceId)).toEqual( - buildModelOptions(config, selection).filter((option) => option.providerKey === "codex"), + expect( + buildModelOptions(config, selection, undefined, null).map((option) => option.providerKey), + ).toEqual(["codex", "claudeAgent"]); + expect(buildModelOptions(config, selection, selection.instanceId, null)).toEqual( + buildModelOptions(config, selection, undefined, null).filter( + (option) => option.providerKey === "codex", + ), ); }); @@ -251,8 +267,8 @@ describe("mobile model options", () => { providers: state === "missing" ? [] : [provider], settings: { providerInstances: { google_work: { driver: "antigravity" } } }, } as unknown as ServerConfig; - const options = buildModelOptions(config, selection, selection.instanceId); - expect(options).toEqual(buildModelOptions(config, selection)); + const options = buildModelOptions(config, selection, selection.instanceId, null); + expect(options).toEqual(buildModelOptions(config, selection, undefined, null)); expect(options).toHaveLength(1); expect(options[0]).toMatchObject({ selection, isUnavailable: true }); }, @@ -293,12 +309,12 @@ describe("mobile model options", () => { model: "gpt-5.6-sol", }; - expect(resolveSelectableModelSelection(config, usable)).toBe(usable); - expect(resolveSelectableModelSelection(config, disabled)).toBeNull(); - expect(resolveSelectableModelSelection(config, removed)).toBeNull(); - expect(isModelSelectionUnavailable(config, disabled)).toBe(false); + expect(resolveSelectableModelSelection(config, usable, null)).toBe(usable); + expect(resolveSelectableModelSelection(config, disabled, null)).toBeNull(); + expect(resolveSelectableModelSelection(config, removed, null)).toBeNull(); + expect(isModelSelectionUnavailable(config, disabled, null)).toBe(false); // An offline environment has no config to validate. - expect(resolveSelectableModelSelection(null, disabled)).toBe(disabled); + expect(resolveSelectableModelSelection(null, disabled, null)).toBe(disabled); }); describe("Antigravity selections", () => { @@ -351,11 +367,11 @@ describe("mobile model options", () => { providers: config.providers.map((provider) => ({ ...provider, ...update })), }; - expect(resolveSelectableModelSelection(unavailableConfig, selection)).toBe(selection); - expect(resolveDefaultableModelSelection(unavailableConfig, selection)).toBe(selection); - expect(isModelSelectionUnavailable(unavailableConfig, selection)).toBe(true); - expect(buildModelOptions(unavailableConfig, null)).toEqual([]); - const [option] = buildModelOptions(unavailableConfig, selection); + expect(resolveSelectableModelSelection(unavailableConfig, selection, null)).toBe(selection); + expect(resolveDefaultableModelSelection(unavailableConfig, selection, null)).toBe(selection); + expect(isModelSelectionUnavailable(unavailableConfig, selection, null)).toBe(true); + expect(buildModelOptions(unavailableConfig, null, undefined, null)).toEqual([]); + const [option] = buildModelOptions(unavailableConfig, selection, undefined, null); expect(option).toMatchObject({ key: `google_work:${selection.model}`, label: model.name, @@ -380,9 +396,9 @@ describe("mobile model options", () => { })), }; - expect(resolveDefaultableModelSelection(changedConfig, selection)).toBe(selection); - expect(isModelSelectionUnavailable(changedConfig, selection)).toBe(true); - const options = buildModelOptions(changedConfig, selection); + expect(resolveDefaultableModelSelection(changedConfig, selection, null)).toBe(selection); + expect(isModelSelectionUnavailable(changedConfig, selection, null)).toBe(true); + const options = buildModelOptions(changedConfig, selection, undefined, null); const missing = options.find((option) => option.selection.model === selection.model); expect(missing).toMatchObject({ label: selection.model, @@ -395,18 +411,20 @@ describe("mobile model options", () => { expect( resolveNewTaskModelSelection({ draftSelection: null, - projectDefaultSelection: resolveDefaultableModelSelection(changedConfig, selection), + projectDefaultSelection: resolveDefaultableModelSelection(changedConfig, selection, null), stickySelection: null, modelOptions: options, }), ).toBe(selection); - const [restored] = buildModelOptions(config, selection); - expect(isModelSelectionUnavailable(config, selection)).toBe(false); + const [restored] = buildModelOptions(config, selection, undefined, null); + expect(isModelSelectionUnavailable(config, selection, null)).toBe(false); expect(restored?.isUnavailable).not.toBe(true); expect(restored?.selection).toBe(selection); - expect(resolveDefaultableModelSelection(config, selection)).toBe(selection); - expect(buildModelOptions(config, null)[0]?.selection.options).toBeUndefined(); + expect(resolveDefaultableModelSelection(config, selection, null)).toBe(selection); + expect( + buildModelOptions(config, null, undefined, null)[0]?.selection.options, + ).toBeUndefined(); }); it("uses configured instance metadata when provider status is missing", () => { @@ -419,9 +437,11 @@ describe("mobile model options", () => { }, } as unknown as ServerConfig; - expect(resolveDefaultableModelSelection(missingStatusConfig, selection)).toBe(selection); - expect(isModelSelectionUnavailable(missingStatusConfig, selection)).toBe(true); - expect(buildModelOptions(missingStatusConfig, selection)).toMatchObject([ + expect(resolveDefaultableModelSelection(missingStatusConfig, selection, null)).toBe( + selection, + ); + expect(isModelSelectionUnavailable(missingStatusConfig, selection, null)).toBe(true); + expect(buildModelOptions(missingStatusConfig, selection, undefined, null)).toMatchObject([ { providerDriver: "antigravity", providerLabel: "Google Work", @@ -434,12 +454,12 @@ describe("mobile model options", () => { it("keeps offline selections without assuming that an unknown instance is Antigravity", () => { const unknownConfig = { ...config, providers: [] }; - expect(resolveDefaultableModelSelection(null, selection)).toBe(selection); - expect(isModelSelectionUnavailable(null, selection)).toBe(false); - expect(buildModelOptions(null, selection)[0]?.selection).toBe(selection); - expect(buildModelOptions(null, selection)[0]?.isUnavailable).not.toBe(true); - expect(isModelSelectionUnavailable(unknownConfig, selection)).toBe(false); - expect(resolveSelectableModelSelection(unknownConfig, selection)).toBeNull(); + expect(resolveDefaultableModelSelection(null, selection, null)).toBe(selection); + expect(isModelSelectionUnavailable(null, selection, null)).toBe(false); + expect(buildModelOptions(null, selection, undefined, null)[0]?.selection).toBe(selection); + expect(buildModelOptions(null, selection, undefined, null)[0]?.isUnavailable).not.toBe(true); + expect(isModelSelectionUnavailable(unknownConfig, selection, null)).toBe(false); + expect(resolveSelectableModelSelection(unknownConfig, selection, null)).toBeNull(); }); }); @@ -470,11 +490,11 @@ describe("mobile model options", () => { const current = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }; const legacy = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }; - expect(resolveDefaultableModelSelection(config, current)).toBe(current); + expect(resolveDefaultableModelSelection(config, current, null)).toBe(current); // A legacy last-used selection falls through to the provider default. - expect(resolveDefaultableModelSelection(config, legacy)).toBeNull(); + expect(resolveDefaultableModelSelection(config, legacy, null)).toBeNull(); // Offline: nothing to validate against, selection passes through. - expect(resolveDefaultableModelSelection(null, legacy)).toBe(legacy); + expect(resolveDefaultableModelSelection(null, legacy, null)).toBe(legacy); }); it("resolves new tasks from draft, project, sticky, then provider defaults", () => { diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 4877123e3eec..dd732f9a79d3 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -122,11 +122,15 @@ function normalizeSelectionOptions( /** * Whether the selection cannot be sent as is: the project turned its instance * off, or a known Antigravity selection needs setup or a different model. + * + * `projectId` has no default: a caller that forgets it would silently check + * only the machine-wide state and miss a project that turned this instance + * off. Pass `null` explicitly outside a project's scope. */ export function isModelSelectionUnavailable( config: T3ServerConfig | null | undefined, selection: ModelSelection | null | undefined, - projectId: ProjectId | null = null, + projectId: ProjectId | null, ): boolean { if (!config || !selection) { return false; @@ -171,11 +175,13 @@ export function describeUnavailableModelSelection( * Keep Antigravity selections when setup or catalog changes make them * unavailable. Other providers fall through to the server default when they * are disabled, missing, or signed out. Without config, keep stored selections. + * + * `projectId` has no default; see `isModelSelectionUnavailable`. */ export function resolveSelectableModelSelection( config: T3ServerConfig | null | undefined, selection: ModelSelection | null, - projectId: ProjectId | null = null, + projectId: ProjectId | null, ): ModelSelection | null { if (!selection || !config) { return selection; @@ -200,11 +206,13 @@ export function resolveSelectableModelSelection( * Reject legacy models for implicit defaults, except Antigravity selections, * which must not silently change after a catalog update. Explicit picks in * the settings sheet are unaffected. + * + * `projectId` has no default; see `isModelSelectionUnavailable`. */ export function resolveDefaultableModelSelection( config: T3ServerConfig | null | undefined, selection: ModelSelection | null, - projectId: ProjectId | null = null, + projectId: ProjectId | null, ): ModelSelection | null { const usable = resolveSelectableModelSelection(config, selection, projectId); if (!usable || !config) { @@ -231,11 +239,14 @@ export function resolveNewTaskModelSelection(input: { ); } +/** + * `projectId` has no default; see `isModelSelectionUnavailable`. + */ export function buildModelOptions( config: T3ServerConfig | null | undefined, fallbackModelSelection: ModelSelection | null, - providerInstanceId?: ModelSelection["instanceId"], - projectId: ProjectId | null = null, + providerInstanceId: ModelSelection["instanceId"] | undefined, + projectId: ProjectId | null, includeHidden = false, ): ReadonlyArray { const options = new Map(); diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 5c7466b472db..aba409b8eafa 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -873,7 +873,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { assert.deepEqual(next.sourceControlWriterModelSelection, sourceControlWriterModelSelection); assert.deepEqual( - ServerSettingsModule.resolveSourceControlWriterModelSelection(next), + ServerSettingsModule.resolveSourceControlWriterModelSelection(next, undefined, null), next.textGenerationModelSelection, ); assert.deepEqual( @@ -897,7 +897,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { }, }); assert.deepEqual( - ServerSettingsModule.resolveSourceControlWriterModelSelection(restored), + ServerSettingsModule.resolveSourceControlWriterModelSelection(restored, undefined, null), sourceControlWriterModelSelection, ); }).pipe(Effect.provide(layerServerSettings())), diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 42ead37a4d64..e2071ccf8847 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -725,9 +725,12 @@ function OpenCommandPaletteDialog(props: { (environment.environmentId === primaryEnvironmentId ? providers : []); const derived = deriveProviderInstanceEntries(environmentProviders); // Settings fill the ACP registry identity (agent id, icon URL) the - // derived entries alone do not carry. + // derived entries alone do not carry. This map is keyed by environment, + // not project, because it backs thread rows across every project on + // that environment; `enabled` stays machine-scoped (`null`) since no + // single project applies. const entries = serverConfig - ? applyProviderInstanceSettings(derived, serverConfig.settings) + ? applyProviderInstanceSettings(derived, serverConfig.settings, null) : derived; for (const entry of entries) { map.set(`${environment.environmentId}:${entry.instanceId}`, entry); diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index 35cab1c14e56..f1adc575bae9 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -136,15 +136,19 @@ describe("applyProviderInstanceSettings", () => { const entries = deriveProviderInstanceEntries([ provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" }), ]); - const [entry] = applyProviderInstanceSettings(entries, { - providerInstances: { - [ProviderInstanceId.make("codex")]: { - driver: ProviderDriverKind.make("codex"), - enabled: false, + const [entry] = applyProviderInstanceSettings( + entries, + { + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + enabled: false, + }, }, + projectSettingsOverrides: {}, }, - projectSettingsOverrides: {}, - }); + null, + ); expect(entry?.enabled).toBe(false); }); @@ -156,10 +160,14 @@ describe("applyProviderInstanceSettings", () => { instanceId: "claude_work", }), ]); - const [entry] = applyProviderInstanceSettings(entries, { - providerInstances: {}, - projectSettingsOverrides: {}, - }); + const [entry] = applyProviderInstanceSettings( + entries, + { + providerInstances: {}, + projectSettingsOverrides: {}, + }, + null, + ); expect(entry?.enabled).toBe(false); }); @@ -173,10 +181,14 @@ describe("applyProviderInstanceSettings", () => { instanceId, }), ]); - const [entry] = applyProviderInstanceSettings(entries, { - providerInstances: {}, - projectSettingsOverrides: {}, - }); + const [entry] = applyProviderInstanceSettings( + entries, + { + providerInstances: {}, + projectSettingsOverrides: {}, + }, + null, + ); expect(entry?.enabled).toBe(false); }, @@ -190,15 +202,19 @@ describe("applyProviderInstanceSettings", () => { instanceId, }), ]); - const [entry] = applyProviderInstanceSettings(entries, { - providerInstances: { - [instanceId]: { - driver: ProviderDriverKind.make("claudeAgent"), - enabled: false, + const [entry] = applyProviderInstanceSettings( + entries, + { + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: false, + }, }, + projectSettingsOverrides: {}, }, - projectSettingsOverrides: {}, - }); + null, + ); expect(entry?.enabled).toBe(false); }); @@ -211,10 +227,14 @@ describe("applyProviderInstanceSettings", () => { instanceId: "constructor", }), ]); - const [entry] = applyProviderInstanceSettings(entries, { - providerInstances: {}, - projectSettingsOverrides: {}, - }); + const [entry] = applyProviderInstanceSettings( + entries, + { + providerInstances: {}, + projectSettingsOverrides: {}, + }, + null, + ); expect(entry?.isDefault).toBe(true); expect(entry?.enabled).toBe(false); @@ -225,10 +245,14 @@ describe("applyProviderInstanceSettings", () => { provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex", enabled: false }), provider({ provider: ProviderDriverKind.make("grok"), instanceId: "grok", enabled: true }), ]); - const [codex, grok] = applyProviderInstanceSettings(entries, { - providerInstances: {}, - projectSettingsOverrides: {}, - }); + const [codex, grok] = applyProviderInstanceSettings( + entries, + { + providerInstances: {}, + projectSettingsOverrides: {}, + }, + null, + ); // Settings decide over a stale probe: Codex starts on, Grok starts off. expect(codex?.enabled).toBe(true); @@ -627,32 +651,44 @@ describe("provider icon metadata", () => { { ...provider({ provider: driver, instanceId }), iconUrl }, ]); const registryConfig = { agentId: "swe-agent", registryIconUrl: iconUrl }; - const brandedEntries = applyProviderInstanceSettings(snapshots, { - providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } }, - projectSettingsOverrides: {}, - }); + const brandedEntries = applyProviderInstanceSettings( + snapshots, + { + providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } }, + projectSettingsOverrides: {}, + }, + null, + ); expect(brandedEntries[0]?.acpRegistryAgentId).toBe("swe-agent"); expect(brandedEntries[0]?.acpRegistryIconUrl).toBe(iconUrl); - const [localEntry] = applyProviderInstanceSettings(brandedEntries, { - providerInstances: { - [instanceId]: { - driver, - enabled: false, - config: { ...registryConfig, source: "local", commandPath: "dsh" }, + const [localEntry] = applyProviderInstanceSettings( + brandedEntries, + { + providerInstances: { + [instanceId]: { + driver, + enabled: false, + config: { ...registryConfig, source: "local", commandPath: "dsh" }, + }, }, + projectSettingsOverrides: {}, }, - projectSettingsOverrides: {}, - }); + null, + ); expect(localEntry?.acpRegistryAgentId).toBeUndefined(); expect(localEntry?.acpRegistryIconUrl).toBeUndefined(); expect(localEntry?.enabled).toBe(false); expect(localEntry?.snapshot).toBe(snapshots[0]?.snapshot); - const [restoredEntry] = applyProviderInstanceSettings(localEntry ? [localEntry] : [], { - providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } }, - projectSettingsOverrides: {}, - }); + const [restoredEntry] = applyProviderInstanceSettings( + localEntry ? [localEntry] : [], + { + providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } }, + projectSettingsOverrides: {}, + }, + null, + ); expect(restoredEntry?.acpRegistryAgentId).toBe("swe-agent"); expect(restoredEntry?.acpRegistryIconUrl).toBe(iconUrl); expect(restoredEntry?.enabled).toBe(true); diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index b857ac0d7418..a8979aa4e257 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -164,7 +164,9 @@ export function deriveProviderEntriesByEnvironment( const byEnvironment = new Map>(); for (const [environmentId, providers, settings] of providersByEnvironment) { const derived = deriveProviderInstanceEntries(providers); - const entries = settings ? applyProviderInstanceSettings(derived, settings) : derived; + // One map per environment serves every project with a thread there, so + // there is no single project to scope `enabled` to; it stays machine-wide. + const entries = settings ? applyProviderInstanceSettings(derived, settings, null) : derived; byEnvironment.set( environmentId, new Map(entries.map((entry) => [entry.instanceId as string, entry] as const)), @@ -186,13 +188,16 @@ export function deriveProviderEntriesByEnvironment( * * `projectId` folds in that project's `providerInstances[id].enabled` override * (enable or disable in either direction over the machine value); `null` - * (no project in scope, or a caller that isn't project-aware) resolves the - * machine value only, matching every call site before this parameter existed. + * resolves the machine value only. It has no default: a caller that renders + * for a single project but forgets to pass it would silently show the + * machine's enablement instead of that project's, so passing `null` for a + * surface that isn't project-aware must be a deliberate choice, not an + * omission. */ export function applyProviderInstanceSettings( entries: ReadonlyArray, settings: Pick, - projectId: ProjectId | null = null, + projectId: ProjectId | null, ): ReadonlyArray { return entries.map((entry) => { const explicitInstance = Object.hasOwn(settings.providerInstances, entry.instanceId) diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 0bb4c76c9409..ab5b3db0cd7e 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -469,7 +469,7 @@ describe("serverSettings helpers", () => { expect(isModelSelectionProviderEnabled(settings, sourceControlWriterModelSelection)).toBe( false, ); - expect(resolveSourceControlWriterModelSelection(settings)).toBe( + expect(resolveSourceControlWriterModelSelection(settings, undefined, null)).toBe( settings.textGenerationModelSelection, ); expect(settings.sourceControlWriterModelSelection).toBe(sourceControlWriterModelSelection); @@ -504,7 +504,7 @@ describe("serverSettings helpers", () => { skills: [], } satisfies ServerProvider; - expect(resolveSourceControlWriterModelSelection(settings, [unavailableProvider])).toBe( + expect(resolveSourceControlWriterModelSelection(settings, [unavailableProvider], null)).toBe( settings.textGenerationModelSelection, ); expect(settings.sourceControlWriterModelSelection).toBe(sourceControlWriterModelSelection); @@ -539,7 +539,7 @@ describe("serverSettings helpers", () => { skills: [], } satisfies ServerProvider; - expect(resolveSourceControlWriterModelSelection(settings, [incapableProvider])).toBe( + expect(resolveSourceControlWriterModelSelection(settings, [incapableProvider], null)).toBe( settings.textGenerationModelSelection, ); expect(settings.sourceControlWriterModelSelection).toBe(sourceControlWriterModelSelection); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 3230aefbb3d5..d3473befdbf9 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -130,10 +130,16 @@ export function fallbackTextGenerationModelSelection( }; } +/** + * `projectId` has no default: a caller that forgets it would otherwise fall + * back to the machine's own writer choice, silently ignoring a project that + * turned the writer's instance off. Pass `null` explicitly outside a + * project's scope. + */ export function resolveSourceControlWriterModelSelection( settings: ServerSettings, - providers?: ReadonlyArray, - projectId: ProjectId | null = null, + providers: ReadonlyArray | undefined, + projectId: ProjectId | null, ): ModelSelection { const selection = settings.sourceControlWriterModelSelection; if ( From 263963862444efa24eb2a0206489ded317fc3357 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 11 Oct 2026 03:25:58 -0400 Subject: [PATCH 25/39] fix(mobile): point the disabled-model refusal at web or desktop Mobile has no provider-settings screen, so telling someone to turn their model back on "in this project's provider settings" sends them somewhere the app can't take them. The surfaces that can make that change are web and desktop. Signed-off-by: Yordis Prieto --- apps/mobile/src/lib/modelOptions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index dd732f9a79d3..e3b981af3315 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -163,7 +163,7 @@ export function describeUnavailableModelSelection( return isProjectDisabledOverride(config, selection.instanceId, projectId) ? { title: "Model turned off for this project", - detail: "Turn it back on in this project's provider settings, or choose another model.", + detail: "Turn it back on from web or desktop, or choose another model.", } : { title: "Antigravity model unavailable", From a1df62858d44a624229357876d48beaa9cb4587f Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 11 Oct 2026 03:41:25 -0400 Subject: [PATCH 26/39] fix(server): an off provider instance gates every internal wake, not just entry points Pre-dispatch asserts at launch, sendToThread, intake, and MCP delegation left internal message.dispatch paths uncovered: a delegated child's completion, a restart continuation, and a steer follow-up could still wake a thread on an instance the project had just turned off. Moving the check inside ThreadManagementService.dispatch closes those paths at their one shared chokepoint instead of enumerating callers, and treating a refusal there as terminal (not retried) avoids turning an intentional "off" into an unbounded retry loop. Signed-off-by: Yordis Prieto --- .../src/mcp/OrchestratorMcpService.test.ts | 7 +- apps/server/src/mcp/OrchestratorMcpService.ts | 14 ++- apps/server/src/mcp/threadAccess.ts | 24 ++-- .../src/orchestration-v2/EffectWorker.ts | 90 +++++++------ .../ProviderContinuationService.test.ts | 60 +++++++++ .../ProviderContinuationService.ts | 114 +++++++++++------ .../orchestration-v2/RestartContinuation.ts | 61 +++++---- .../orchestration-v2/ThreadLaunchService.ts | 10 +- .../ThreadManagementService.test.ts | 118 ++++++++++++++++++ .../ThreadManagementService.ts | 71 +++++++++-- .../orchestration-v2/ThreadMessageIntake.ts | 25 ++-- .../ThreadTitleRegenerationService.ts | 7 +- docs/user/project-settings.md | 5 +- 13 files changed, 458 insertions(+), 148 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 1b0aedc07d5c..0c04fa3166c6 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -1334,14 +1334,15 @@ describe("OrchestratorMcpService provider resolution", () => { Layer.mock(ThreadManagementService.ThreadManagementService)({ hasReceipt: () => Effect.succeed(false), getThreadRecords: () => Effect.succeed(parentProjection([])), - assertProviderInstanceEnabled: ({ projectId, instanceId }) => + // ThreadManagementService.dispatch is the chokepoint now: it is the + // one that refuses, not a separate pre-dispatch assert. + dispatch: () => Effect.fail( new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({ projectId, - instanceId, + instanceId: codexInstanceId, }), ), - dispatch: () => Effect.die("a refused delegation must not dispatch"), }), providerRegistryLayer([ providerSnapshot({ diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 167b0810535d..f9bb61fa810e 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1993,7 +1993,9 @@ const make = Effect.gen(function* () { parent.thread.interactionMode, input.interactionMode, ); - yield* assertTargetEnabledForProject(parent, commandId, target); + // ThreadManagementService.dispatch is the chokepoint for this command + // type's own enablement check, using the same command id and its + // receipt-replay exemption, so no separate assert runs here. const result = yield* threadManagement .dispatch({ type: "delegated_task.request", @@ -2015,10 +2017,12 @@ const make = Effect.gen(function* () { }) .pipe( Effect.mapError((error) => - failure( - "orchestration_error", - `Unable to create delegated task: ${errorMessage(error)}`, - ), + error._tag === "ThreadManagementProviderInstanceDisabledError" + ? threadManagementFailure(error) + : failure( + "orchestration_error", + `Unable to create delegated task: ${errorMessage(error)}`, + ), ), ); const taskEvent = result.storedEvents.find( diff --git a/apps/server/src/mcp/threadAccess.ts b/apps/server/src/mcp/threadAccess.ts index 239af1880a5a..93a8f107bd27 100644 --- a/apps/server/src/mcp/threadAccess.ts +++ b/apps/server/src/mcp/threadAccess.ts @@ -23,16 +23,20 @@ export const unavailable = () => }); /** Decider string rejections are public; wrapped storage and hydration causes are not. */ -export const dispatchFailure = (error: OrchestratorV2Error) => - (error._tag === "OrchestratorDispatchError" || - error._tag === "OrchestratorCommandRejectedError") && - typeof error.cause === "string" && - error.cause.length > 0 - ? new OrchestratorMcpFailure({ - code: "orchestration_error", - message: Array.from(error.cause).slice(0, 1000).join(""), - }) - : unavailable(); +export const dispatchFailure = ( + error: OrchestratorV2Error | ThreadManagement.ThreadManagementProviderInstanceDisabledError, +) => + error._tag === "ThreadManagementProviderInstanceDisabledError" + ? new OrchestratorMcpFailure({ code: "invalid_request", message: error.message }) + : (error._tag === "OrchestratorDispatchError" || + error._tag === "OrchestratorCommandRejectedError") && + typeof error.cause === "string" && + error.cause.length > 0 + ? new OrchestratorMcpFailure({ + code: "orchestration_error", + message: Array.from(error.cause).slice(0, 1000).join(""), + }) + : unavailable(); /** * The most a caller may hand to the threads it targets. A thread caller is diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index ddfc54b53320..06ba83ca0d42 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -256,41 +256,61 @@ export const layerExecutor: Layer.Layer< if (message === undefined || run === undefined) return yield* error; // Reuse the message identity and a stable command receipt so an outbox // retry cannot append a duplicate message or start a second follow-up. - yield* threads.dispatch({ - type: "message.dispatch", - commandId: CommandId.make(`command:steer-follow-up:${effect.id}`), - threadId: effect.threadId, - messageId: message.id, - text: message.text, - ...(message.context ? { context: message.context } : {}), - attachments: message.attachments, - // A user's follow-up starts on the thread's saved selection, - // which already holds the steer's choice. A delegated - // completion stays pinned to the run it reports to. - ...(message.delegatedCompletion === undefined - ? {} - : { modelSelection: run.modelSelection }), - dispatchMode: { - type: - message.delegatedCompletion === undefined - ? "start_immediately" - : "queue_after_active", - }, - createdBy: message.createdBy, - creationSource: message.creationSource, - ...(message.delegatedCompletion === undefined - ? {} - : { delegatedCompletion: message.delegatedCompletion }), - ...(message.notification === undefined - ? {} - : { notification: message.notification }), - ...(message.scheduledTaskId === undefined - ? {} - : { scheduledTaskId: message.scheduledTaskId }), - ...(message.senderThreadId === undefined - ? {} - : { senderThreadId: message.senderThreadId }), - }); + yield* threads + .dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`command:steer-follow-up:${effect.id}`), + threadId: effect.threadId, + messageId: message.id, + text: message.text, + ...(message.context ? { context: message.context } : {}), + attachments: message.attachments, + // A user's follow-up starts on the thread's saved selection, + // which already holds the steer's choice. A delegated + // completion stays pinned to the run it reports to. + ...(message.delegatedCompletion === undefined + ? {} + : { modelSelection: run.modelSelection }), + dispatchMode: { + type: + message.delegatedCompletion === undefined + ? "start_immediately" + : "queue_after_active", + }, + createdBy: message.createdBy, + creationSource: message.creationSource, + ...(message.delegatedCompletion === undefined + ? {} + : { delegatedCompletion: message.delegatedCompletion }), + ...(message.notification === undefined + ? {} + : { notification: message.notification }), + ...(message.scheduledTaskId === undefined + ? {} + : { scheduledTaskId: message.scheduledTaskId }), + ...(message.senderThreadId === undefined + ? {} + : { senderThreadId: message.senderThreadId }), + }) + .pipe( + // The turn that just completed leaves no further retry + // target once the instance is off: the completed turn + // stands as the thread's result instead of going + // through the outbox's bounded retry on a refusal that + // retrying cannot change. + Effect.catchTags({ + ThreadManagementProviderInstanceDisabledError: (disabledError) => + Effect.logInfo( + "orchestration-v2.effect-worker.steer-follow-up-disabled", + { + threadId: effect.threadId, + messageId: message.id, + projectId: disabledError.projectId, + instanceId: disabledError.instanceId, + }, + ), + }), + ); }), ), Effect.mapError( diff --git a/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts b/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts index 3cc41af2e7ab..8d0ff825ead5 100644 --- a/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts @@ -1,7 +1,9 @@ import { assert, describe, it } from "@effect/vitest"; import { MessageId, + ProjectId, ProviderDriverKind, + ProviderInstanceId, ProviderThreadId, RunId, ThreadId, @@ -529,6 +531,64 @@ describe("ProviderContinuationService", () => { }); }); + it.effect( + "a project-disabled instance refuses a delegated-completion wake exactly once, with no retry", + () => { + return Effect.gen(function* () { + const attempts = yield* Ref.make(0); + const cleared = yield* Ref.make(false); + const layerThreads = Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: () => Effect.succeed(delegatedProjection()), + dispatch: () => + Ref.update(attempts, (count) => count + 1).pipe( + Effect.andThen( + Effect.fail( + new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({ + projectId: ProjectId.make("project-provider-continuation-disabled"), + instanceId: ProviderInstanceId.make("instance-provider-continuation-disabled"), + }) as never, + ), + ), + ), + }); + const layerWorker = ProviderContinuationService.layer.pipe( + Layer.provide( + Layer.mergeAll(IdAllocator.layer, ProviderContinuationRequests.layer, layerThreads), + ), + ); + + yield* Effect.gen(function* () { + const requests = yield* ProviderContinuationRequests.ProviderContinuationRequests; + yield* requests.offer({ + threadId, + providerThreadId, + driver, + detail: null, + delivery: "message_text", + clearIfCurrent: () => Ref.set(cleared, true), + delegatedCompletion: { + parentRunId, + generation: 1, + messageId: delegatedMessageId, + }, + }); + yield* Effect.yieldNow; + assert.equal(yield* Ref.get(attempts), 1); + assert.isTrue(yield* Ref.get(cleared)); + + // A refusal is terminal: no backoff timer is pending behind it, + // so no further dispatch attempt can ever land. + yield* TestClock.adjust("1 hour"); + yield* Effect.yieldNow; + assert.equal(yield* Ref.get(attempts), 1); + }).pipe( + Effect.provide(Layer.merge(ProviderContinuationRequests.layer, layerWorker)), + Effect.scoped, + ); + }); + }, + ); + it.effect("keeps a Grok delegated completion queued instead of restarting active work", () => { return Effect.gen(function* () { const dispatched = yield* Queue.unbounded(); diff --git a/apps/server/src/orchestration-v2/ProviderContinuationService.ts b/apps/server/src/orchestration-v2/ProviderContinuationService.ts index 8e97da83df97..ba076dd808d6 100644 --- a/apps/server/src/orchestration-v2/ProviderContinuationService.ts +++ b/apps/server/src/orchestration-v2/ProviderContinuationService.ts @@ -126,22 +126,49 @@ export const layer = Layer.effectDiscard( fixtureName: "delegated-completion", commandName: "dispatch", }); - yield* threads.dispatch({ - type: "message.dispatch", - commandId, - threadId: request.threadId, - messageId: delivery.messageId, - text: delegatedCompletionText(delivery.taskIds), - attachments: [], - dispatchMode: { type: "queue_after_active" }, - createdBy: "agent", - creationSource: "server", - delegatedCompletion: { - parentRunId: request.delegatedCompletion.parentRunId, - generation: delivery.generation, - taskIds: delivery.taskIds, - }, - }); + yield* threads + .dispatch({ + type: "message.dispatch", + commandId, + threadId: request.threadId, + messageId: delivery.messageId, + text: delegatedCompletionText(delivery.taskIds), + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "agent", + creationSource: "server", + delegatedCompletion: { + parentRunId: request.delegatedCompletion.parentRunId, + generation: delivery.generation, + taskIds: delivery.taskIds, + }, + }) + .pipe( + // The project turned the target instance off between the cohort + // read above and this dispatch. The cohort's delivery stays open + // in the run record rather than retrying: the next event that + // re-offers it (a sibling completion, a restart) gets a fresh + // chance once an enabled instance can serve it. + Effect.catchTags({ + ThreadManagementProviderInstanceDisabledError: (error) => + Effect.logInfo( + "orchestration-v2.provider-continuation.delegated-completion-disabled", + { + threadId: request.threadId, + projectId: error.projectId, + instanceId: error.instanceId, + }, + ).pipe( + Effect.andThen( + request.clearIfCurrent !== undefined + ? request.clearIfCurrent() + : request.dispatchIfCurrent !== undefined + ? request.dispatchIfCurrent(Effect.void) + : Effect.void, + ), + ), + }), + ); yield* clearRetryAttempt(retryKey); return; } @@ -154,26 +181,41 @@ export const layer = Layer.effectDiscard( ordinal: projection.messages.length + 1, })); const commandId = CommandId.make(`provider-continuation:${messageId}`); - const dispatch = threads.dispatch({ - type: "message.dispatch", - commandId, - threadId: request.threadId, - messageId, - text: request.detail ?? CONTINUATION_MESSAGE_TEXT, - notification: request.notification ?? { - source: { kind: "background_task" }, - outcome: "updated", - summary: "Background activity updated", - }, - attachments: [], - dispatchMode: { type: "queue_after_active" }, - createdBy: "agent", - // "provider" marks an adapter-buffered wake, which ClaudeAdapterV2 - // detects to attach the buffered CLI output and drop this text. A - // message_text wake has no buffered output, so it must not carry that - // marker or the turn settles immediately having prompted nothing. - creationSource: request.delivery === "message_text" ? "server" : "provider", - }); + const dispatch = threads + .dispatch({ + type: "message.dispatch", + commandId, + threadId: request.threadId, + messageId, + text: request.detail ?? CONTINUATION_MESSAGE_TEXT, + notification: request.notification ?? { + source: { kind: "background_task" }, + outcome: "updated", + summary: "Background activity updated", + }, + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "agent", + // "provider" marks an adapter-buffered wake, which ClaudeAdapterV2 + // detects to attach the buffered CLI output and drop this text. A + // message_text wake has no buffered output, so it must not carry that + // marker or the turn settles immediately having prompted nothing. + creationSource: request.delivery === "message_text" ? "server" : "provider", + }) + .pipe( + // The adapter's buffered wake has nowhere else to go once the + // instance is off: drop it rather than warn, since nothing here + // retries it either way. + Effect.catchTags({ + ThreadManagementProviderInstanceDisabledError: (error) => + Effect.logInfo("orchestration-v2.provider-continuation.instance-disabled", { + threadId: request.threadId, + providerThreadId: request.providerThreadId, + projectId: error.projectId, + instanceId: error.instanceId, + }), + }), + ); if (request.dispatchIfCurrent === undefined) { yield* dispatch; return; diff --git a/apps/server/src/orchestration-v2/RestartContinuation.ts b/apps/server/src/orchestration-v2/RestartContinuation.ts index b7e905d0f4a9..19a257883cdc 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.ts @@ -159,28 +159,45 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta : note.settled ? noteText : `${noteText}\n\n${CONTINUE_PROMPT}`; - yield* threads.dispatch({ - type: "message.dispatch", - commandId: CommandId.make(`command:restart-continuation:${input.sourceRunId}`), - threadId: input.threadId, - messageId, - text, - // Shown as a work log row; the prompt the agent got is its detail. - notification: { - source: { kind: "system" }, - outcome: "updated", - summary: note.settled - ? "T3 Code restarted and stopped background work" - : "T3 Code restarted and resumed this turn", - detail: text, - }, - attachments: [], - modelSelection: source.modelSelection, - dispatchMode: { type: "start_immediately" }, - createdBy: "agent", - creationSource: "server", - restartContinuationOfRunId: input.sourceRunId, - }); + yield* threads + .dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`command:restart-continuation:${input.sourceRunId}`), + threadId: input.threadId, + messageId, + text, + // Shown as a work log row; the prompt the agent got is its detail. + notification: { + source: { kind: "system" }, + outcome: "updated", + summary: note.settled + ? "T3 Code restarted and stopped background work" + : "T3 Code restarted and resumed this turn", + detail: text, + }, + attachments: [], + modelSelection: source.modelSelection, + dispatchMode: { type: "start_immediately" }, + createdBy: "agent", + creationSource: "server", + restartContinuationOfRunId: input.sourceRunId, + }) + .pipe( + // The project turned this instance off since the restart. No turn + // resumes, and the success path below still runs so a delegated + // child waiting on this run is recovered immediately instead of + // after EffectWorker exhausts its retries on a refusal that will + // not change. + Effect.catchTags({ + ThreadManagementProviderInstanceDisabledError: (error) => + Effect.logInfo("orchestration-v2.restart-continuation.instance-disabled", { + threadId: input.threadId, + sourceRunId: input.sourceRunId, + projectId: error.projectId, + instanceId: error.instanceId, + }), + }), + ); }, // A delegated child this declined to continue still owes its parent a // result. Once a continuation run exists this is a no-op; that run settles it. diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 56d32db85c73..42b1d9cef1e4 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -180,9 +180,13 @@ export class ThreadLaunchService extends Context.Service< input: Pick, ) => Effect.Effect; /** Dispatches prepared-run.retry and prepares the run's workspace again. */ - readonly retryPreparation: ( - input: ThreadLaunchRetryInput, - ) => Effect.Effect; + readonly retryPreparation: (input: ThreadLaunchRetryInput) => Effect.Effect< + Orchestrator.OrchestratorV2DispatchResult, + // Dispatches prepared-run.retry, a command the enablement chokepoint + // never gates; the type stays accurate anyway. + | Orchestrator.OrchestratorV2Error + | ThreadManagement.ThreadManagementProviderInstanceDisabledError + >; } >()("t3/orchestration-v2/ThreadLaunchService") {} diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index a607646bc389..45231733eb5b 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -358,6 +358,124 @@ it.effect("inherits the machine value when the project has no override", () => { }).pipe(Effect.provide(layerTest)); }); +const layerDispatchChokepointTest = + (settingsLayer: ReturnType) => (dispatched: Array) => + ThreadManagementService.layer.pipe( + Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), + Layer.provide( + Layer.mock(Orchestrator.OrchestratorV2)({ + getThreadRecords: () => + Effect.succeed({ + thread: { + id: ThreadId.make("thread:thread-management:chokepoint"), + projectId: ProjectId.make("project:thread-management:chokepoint"), + deletedAt: null, + modelSelection: { + instanceId: ProviderInstanceId.make("codex_work"), + model: "test", + }, + }, + } as unknown as OrchestrationV2ThreadProjection), + dispatch: (command) => + Effect.sync(() => { + dispatched.push(command.type); + return { sequence: 1, storedEvents: [] } as never; + }), + }), + ), + Layer.provide(settingsLayer), + Layer.provideMerge(layerReceiptsTest), + ); + +it.effect( + "the dispatch chokepoint refuses a continuation message.dispatch on a project-disabled instance before reaching the orchestrator", + () => { + const projectId = ProjectId.make("project:thread-management:chokepoint"); + const threadId = ThreadId.make("thread:thread-management:chokepoint"); + const instanceId = ProviderInstanceId.make("codex_work"); + const commandId = CommandId.make("command:thread-management:chokepoint-disabled"); + const dispatched: Array = []; + const layerTest = layerDispatchChokepointTest( + ServerSettings.layerTest({ + providerInstances: { [instanceId]: { driver: "codex", enabled: true } }, + projectSettingsOverrides: { + [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, + }, + }), + )(dispatched); + + return Effect.gen(function* () { + const threads = yield* ThreadManagementService.ThreadManagementService; + const error = yield* Effect.flip( + threads.dispatch({ + type: "message.dispatch", + commandId, + threadId, + messageId: MessageId.make("message:thread-management:chokepoint-disabled"), + text: "Background task completed.", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "agent", + creationSource: "server", + }), + ); + + expect(error).toBeInstanceOf( + ThreadManagementService.ThreadManagementProviderInstanceDisabledError, + ); + expect(error).toMatchObject({ projectId, instanceId }); + expect(dispatched).toEqual([]); + }).pipe(Effect.provide(layerTest)); + }, +); + +it.effect( + "a replay of an already-accepted command id dispatches despite the instance now being disabled", + () => { + const projectId = ProjectId.make("project:thread-management:chokepoint"); + const threadId = ThreadId.make("thread:thread-management:chokepoint"); + const instanceId = ProviderInstanceId.make("codex_work"); + const commandId = CommandId.make("command:thread-management:chokepoint-replay"); + const dispatched: Array = []; + const layerTest = layerDispatchChokepointTest( + ServerSettings.layerTest({ + providerInstances: { [instanceId]: { driver: "codex", enabled: true } }, + projectSettingsOverrides: { + [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, + }, + }), + )(dispatched); + + return Effect.gen(function* () { + const receipts = yield* CommandReceiptStore.CommandReceiptStoreV2; + yield* receipts.insertIfAbsent({ + commandId, + threadId, + commandType: "message.dispatch", + acceptedAt: yield* DateTime.now, + resultSequence: 1, + status: "accepted", + error: null, + }); + + const threads = yield* ThreadManagementService.ThreadManagementService; + yield* threads.dispatch({ + type: "message.dispatch", + commandId, + threadId, + messageId: MessageId.make("message:thread-management:chokepoint-replay"), + text: "Background task completed.", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "agent", + creationSource: "server", + }); + + expect(dispatched).toEqual(["message.dispatch"]); + }).pipe(Effect.provide(layerTest)); + }, +); + it.effect("classifies projection infrastructure failures separately from a missing thread", () => { const projectId = ProjectId.make("project:thread-management:projection-failure"); const threadId = ThreadId.make("thread:thread-management:projection-failure"); diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 3e3b57a005f9..a3d6f7b85260 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -307,9 +307,18 @@ export interface ThreadManagementServiceShape { readonly ensureLegacyTranscript: ( threadId: ThreadId, ) => Effect.Effect; + /** + * The single chokepoint for commands that can start or wake provider work: + * `message.dispatch` and `delegated_task.request` are refused here when the + * resolved instance is off for the project, except a command id that + * already has a receipt, which only replays its recorded result. + */ readonly dispatch: ( command: OrchestrationV2ServerCommand, - ) => Effect.Effect; + ) => Effect.Effect< + Orchestrator.OrchestratorV2DispatchResult, + Orchestrator.OrchestratorV2Error | ThreadManagementProviderInstanceDisabledError + >; readonly getThreadHistoryPage: Orchestrator.OrchestratorV2["Service"]["getThreadHistoryPage"]; readonly getTimelinePage: Orchestrator.OrchestratorV2["Service"]["getTimelinePage"]; readonly getMessageCount: Orchestrator.OrchestratorV2["Service"]["getMessageCount"]; @@ -408,7 +417,9 @@ export interface ThreadManagementServiceShape { readonly byOwnAgent: boolean; }) => Effect.Effect< { readonly sequence: number } | { readonly settlesWhenTurnEnds: true }, - Orchestrator.OrchestratorV2Error + // Dispatches thread.settle, a command the enablement chokepoint never + // gates; the type stays accurate anyway. + Orchestrator.OrchestratorV2Error | ThreadManagementProviderInstanceDisabledError >; readonly interruptThread: ( input: ThreadManagementInterruptInput, @@ -429,7 +440,12 @@ export interface ThreadManagementServiceShape { readonly threadId: ThreadId; readonly commandId: CommandId; readonly reason?: string | undefined; - }) => Effect.Effect; + }) => Effect.Effect< + void, + // Dispatches thread.stop, a command the enablement chokepoint never + // gates; the type stays accurate anyway. + Orchestrator.OrchestratorV2Error | ThreadManagementProviderInstanceDisabledError + >; readonly getThreadEventSequence: Orchestrator.OrchestratorV2["Service"]["getThreadEventSequence"]; readonly recoverDelegatedTask: Orchestrator.OrchestratorV2["Service"]["recoverDelegatedTask"]; readonly delegatedTaskResultPending: Orchestrator.OrchestratorV2["Service"]["delegatedTaskResultPending"]; @@ -605,8 +621,41 @@ const make = Effect.gen(function* () { Effect.andThen(orchestrator.getThreadSnapshotWindow(threadId, options)), ); + // Every path that can start or wake a provider turn funnels through this + // dispatch, so resolving and checking the target instance here (instead of + // at each caller) is the one place "off" is enforced. + const assertDispatchTargetEnabled = (command: OrchestrationV2ServerCommand) => { + switch (command.type) { + case "message.dispatch": + return orchestrator.getThreadRecords(command.threadId, []).pipe( + Effect.flatMap((projection) => + assertProviderInstanceEnabled({ + projectId: projection.thread.projectId, + commandId: command.commandId, + instanceId: (command.modelSelection ?? projection.thread.modelSelection).instanceId, + }), + ), + ); + case "delegated_task.request": + return orchestrator.getThreadRecords(command.parentThreadId, []).pipe( + Effect.flatMap((parent) => + assertProviderInstanceEnabled({ + projectId: parent.thread.projectId, + commandId: command.commandId, + instanceId: command.modelSelection.instanceId, + }), + ), + ); + default: + return Effect.void; + } + }; + const dispatch: ThreadManagementServiceShape["dispatch"] = (command) => - ensureCommandTranscripts(command).pipe(Effect.andThen(orchestrator.dispatch(command))); + ensureCommandTranscripts(command).pipe( + Effect.andThen(assertDispatchTargetEnabled(command)), + Effect.andThen(orchestrator.dispatch(command)), + ); const getProjectThread: ThreadManagementServiceShape["getProjectThread"] = (input) => getThreadProjection(input.threadId).pipe( @@ -689,12 +738,6 @@ const make = Effect.gen(function* () { threadId: input.threadId, }); } - yield* assertProviderInstanceEnabled({ - projectId: input.projectId, - commandId: input.commandId, - instanceId: (input.modelSelection ?? target.thread.modelSelection).instanceId, - }); - const steerableRun = latestSteerableRun(target); let dispatchMode: Extract< OrchestrationV2Command, @@ -719,7 +762,7 @@ const make = Effect.gen(function* () { }; } - const dispatch = yield* orchestrator.dispatch({ + const dispatched = yield* dispatch({ type: "message.dispatch", commandId: input.commandId, threadId: input.threadId, @@ -771,7 +814,7 @@ const make = Effect.gen(function* () { : input.mode === "restart" ? "restarted" : "steered"; - return { dispatch, projection, message, run, turnItem, delivery }; + return { dispatch: dispatched, projection, message, run, turnItem, delivery }; }); const waitForThread: ThreadManagementServiceShape["waitForThread"] = (input) => @@ -1029,7 +1072,9 @@ const make = Effect.gen(function* () { const stopDelegatedTasks: ThreadManagementServiceShape["stopDelegatedTasks"] = (input) => Effect.gen(function* () { const { subagents } = yield* orchestrator.getThreadRecords(input.threadId, ["subagents"]); - const failures: Array = []; + const failures: Array< + Orchestrator.OrchestratorV2Error | ThreadManagementProviderInstanceDisabledError + > = []; for (const task of subagents) { if (task.origin !== "app_owned" || task.childThreadId === null) continue; const threadId = task.childThreadId; diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts index 88d072f40e77..21540703ffbc 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts @@ -13,7 +13,8 @@ import * as ThreadManagement from "./ThreadManagementService.ts"; // These dispatcher failures occur in receipt validation or planning, before // commitCommand. Generic dispatch errors can follow a commit and remain uncertain. -// A disabled-instance refusal is asserted before any dispatch is attempted. +// A disabled-instance refusal comes from ThreadManagementService.dispatch's +// own enablement check, which also runs before any commit is attempted. function dispatchWasNotAccepted( error: Orchestrator.OrchestratorV2Error | ThreadManagement.ThreadManagementError, ) { @@ -32,6 +33,7 @@ function dispatchWasNotAccepted( } } const isOrchestratorError = Schema.is(Orchestrator.OrchestratorV2Error); +const isThreadManagementError = Schema.is(ThreadManagement.ThreadManagementError); const releaseUnusedClaims = Effect.fn("ThreadMessageIntake.releaseUnusedClaims")(function* ( claimedPaths: ReadonlyArray, @@ -56,22 +58,6 @@ export const dispatchCommand = Effect.fn("ThreadMessageIntake.dispatchCommand")( command: OrchestrationV2Command, ) { const threads = yield* ThreadManagement.ThreadManagementService; - if (command.type === "message.dispatch") { - const projection = yield* threads.getThreadRecords(command.threadId, []); - yield* threads.assertProviderInstanceEnabled({ - projectId: projection.thread.projectId, - commandId: command.commandId, - instanceId: (command.modelSelection ?? projection.thread.modelSelection).instanceId, - }); - } - if (command.type === "delegated_task.request") { - const parent = yield* threads.getThreadRecords(command.parentThreadId, []); - yield* threads.assertProviderInstanceEnabled({ - projectId: parent.thread.projectId, - commandId: command.commandId, - instanceId: command.modelSelection.instanceId, - }); - } if (command.type === "runtime-request.respond" && command.attachmentsByQuestionId) { const config = yield* ServerConfig.ServerConfig; const incomingByQuestionId = command.attachmentsByQuestionId; @@ -244,11 +230,14 @@ export const launchThread = Effect.fn("ThreadMessageIntake.launchThread")(functi } // Project/receipt reads precede message dispatch. The create-thread error // also wraps post-message projection reads, so its tag alone is not proof. + // The initial message's own dispatch can still find the instance newly + // disabled, which wraps the same tag handled above instead of surfacing + // it directly, so the guard below also recognizes it. const notAccepted = error.operation === "resolve-project" || error.operation === "read-receipt" || ((error.operation === "create-thread" || error.operation === "dispatch-message") && - isOrchestratorError(error.cause) && + (isOrchestratorError(error.cause) || isThreadManagementError(error.cause)) && // Projection errors under create-thread can occur after the message commit. (error.operation !== "create-thread" || error.cause._tag !== "OrchestratorProjectionError") && diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts index dd3aeb52797f..86333c51405b 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts @@ -33,7 +33,12 @@ export class ThreadTitleRegenerationService extends Context.Service< | { readonly type: "regenerate" }; }) => Effect.Effect< void, - OrchestratorV2Error | ProjectStore.ProjectStoreV2Error | ServerSettingsError + | OrchestratorV2Error + | ProjectStore.ProjectStoreV2Error + | ServerSettingsError + // `complete` dispatches thread.title.regeneration.complete, a command + // the enablement chokepoint never gates; the type stays accurate anyway. + | ThreadManagementService.ThreadManagementProviderInstanceDisabledError >; } >()("t3/orchestration-v2/ThreadTitleRegenerationService") {} diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 9c01efdd433a..45aed8583a21 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -30,8 +30,9 @@ one until you pick another. Every other setting fans out to the selection. With a project selected, a provider's switch in **Settings → Providers** turns it on or off for that project only, in either direction: a provider off on the machine can be used in one project, and one on everywhere can be kept out of another. That project's model pickers follow the switch, -and new messages there cannot use a provider it turned off. Reset returns the project to the -machine's setting. Every other provider setting stays per machine. +and no new, queued, or follow-up turn in that project can start on a provider it turned off, even +a wake from a delegated task. A turn already running when the switch flips finishes on its own. +Reset returns the project to the machine's setting. Every other provider setting stays per machine. On mobile, open **Settings** and use the filter in its header to choose connected environments and a project. The filter stays available in server-setting pages. With **All projects** selected, From e6f5ee562d8148c7b732b443b7f754d776f010ee Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 11 Oct 2026 04:02:20 -0400 Subject: [PATCH 27/39] fix(server): a refused delegated-completion wake is acknowledged, not left open Leaving the cohort's delivery open after a disabled instance refused the wake relied on a later, unrelated event to notice and retry it, with no such event guaranteed to ever arrive. The parent's turn-item and context-transfer records already hold the children's results regardless, so the delivery itself only needs to stop looking like unfinished work. Signed-off-by: Yordis Prieto --- .../ProviderContinuationService.test.ts | 46 ++++++++++++++----- .../ProviderContinuationService.ts | 36 +++++++++++++-- docs/user/project-settings.md | 6 ++- 3 files changed, 72 insertions(+), 16 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts b/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts index 8d0ff825ead5..bd6de62c728c 100644 --- a/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts @@ -532,24 +532,31 @@ describe("ProviderContinuationService", () => { }); it.effect( - "a project-disabled instance refuses a delegated-completion wake exactly once, with no retry", + "a project-disabled instance refuses a delegated-completion wake exactly once, acknowledges it, and never retries", () => { return Effect.gen(function* () { const attempts = yield* Ref.make(0); const cleared = yield* Ref.make(false); + const acknowledged = yield* Queue.unbounded(); const layerThreads = Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: () => Effect.succeed(delegatedProjection()), - dispatch: () => - Ref.update(attempts, (count) => count + 1).pipe( - Effect.andThen( - Effect.fail( - new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({ - projectId: ProjectId.make("project-provider-continuation-disabled"), - instanceId: ProviderInstanceId.make("instance-provider-continuation-disabled"), - }) as never, + dispatch: (command) => { + if ((command as { type: string }).type === "message.dispatch") { + return Ref.update(attempts, (count) => count + 1).pipe( + Effect.andThen( + Effect.fail( + new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({ + projectId: ProjectId.make("project-provider-continuation-disabled"), + instanceId: ProviderInstanceId.make( + "instance-provider-continuation-disabled", + ), + }) as never, + ), ), - ), - ), + ); + } + return Queue.offer(acknowledged, command).pipe(Effect.as({} as never)); + }, }); const layerWorker = ProviderContinuationService.layer.pipe( Layer.provide( @@ -576,11 +583,28 @@ describe("ProviderContinuationService", () => { assert.equal(yield* Ref.get(attempts), 1); assert.isTrue(yield* Ref.get(cleared)); + // The refused delivery is acknowledged administratively, task by + // task, so the cohort's delivery is consumed instead of left open. + const acknowledgements = (yield* Queue.takeAll(acknowledged)) as ReadonlyArray<{ + type: string; + taskId: string; + observedByRunId: string | null; + }>; + assert.deepEqual( + acknowledgements.map((command) => command.taskId), + ["node-provider-continuation-first", "node-provider-continuation-second"], + ); + for (const command of acknowledgements) { + assert.equal(command.type, "delegated_task.completion-delivery.acknowledge"); + assert.isNull(command.observedByRunId); + } + // A refusal is terminal: no backoff timer is pending behind it, // so no further dispatch attempt can ever land. yield* TestClock.adjust("1 hour"); yield* Effect.yieldNow; assert.equal(yield* Ref.get(attempts), 1); + assert.equal(yield* Queue.size(acknowledged), 0); }).pipe( Effect.provide(Layer.merge(ProviderContinuationRequests.layer, layerWorker)), Effect.scoped, diff --git a/apps/server/src/orchestration-v2/ProviderContinuationService.ts b/apps/server/src/orchestration-v2/ProviderContinuationService.ts index ba076dd808d6..96c18d893158 100644 --- a/apps/server/src/orchestration-v2/ProviderContinuationService.ts +++ b/apps/server/src/orchestration-v2/ProviderContinuationService.ts @@ -145,10 +145,15 @@ export const layer = Layer.effectDiscard( }) .pipe( // The project turned the target instance off between the cohort - // read above and this dispatch. The cohort's delivery stays open - // in the run record rather than retrying: the next event that - // re-offers it (a sibling completion, a restart) gets a fresh - // chance once an enabled instance can serve it. + // read above and this dispatch. The finalize that claimed this + // delivery already wrote the children's results to the parent's + // history unconditionally, so nothing is lost: acknowledge each + // task administratively (observedByRunId: null, the same shape + // task_status uses when no run observed it) to consume this + // delivery instead of leaving it open to retry. The parent's + // next turn against an enabled instance reads those results from + // history the same way any turn does; nothing here re-offers + // this generation. Effect.catchTags({ ThreadManagementProviderInstanceDisabledError: (error) => Effect.logInfo( @@ -159,6 +164,29 @@ export const layer = Layer.effectDiscard( instanceId: error.instanceId, }, ).pipe( + Effect.andThen( + Effect.forEach( + delivery.taskIds, + (taskId) => + ids.allocate + .command({ + fixtureName: "delegated-completion", + commandName: "acknowledge-disabled", + }) + .pipe( + Effect.flatMap((ackCommandId) => + threads.dispatch({ + type: "delegated_task.completion-delivery.acknowledge", + commandId: ackCommandId, + parentThreadId: request.threadId, + taskId, + observedByRunId: null, + }), + ), + ), + { discard: true }, + ), + ), Effect.andThen( request.clearIfCurrent !== undefined ? request.clearIfCurrent() diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 45aed8583a21..de7b8f10da28 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -32,7 +32,11 @@ that project only, in either direction: a provider off on the machine can be use and one on everywhere can be kept out of another. That project's model pickers follow the switch, and no new, queued, or follow-up turn in that project can start on a provider it turned off, even a wake from a delegated task. A turn already running when the switch flips finishes on its own. -Reset returns the project to the machine's setting. Every other provider setting stays per machine. +A delegated task's result is recorded in its parent thread regardless of its wake being turned +away this way; the parent picks it up on its next turn against an enabled provider instead of +getting a push notification for it. An adapter's own buffered follow-up has nothing to attach to +once its instance is off, so it is dropped rather than queued. Reset returns the project to the +machine's setting. Every other provider setting stays per machine. On mobile, open **Settings** and use the filter in its header to choose connected environments and a project. The filter stays available in server-setting pages. With **All projects** selected, From f5e6f0bbdaa03bea21cb5da6b081c9e303423956 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 11 Oct 2026 04:04:10 -0400 Subject: [PATCH 28/39] docs: state what a project's provider switch means for queued and in-flight work Signed-off-by: Yordis Prieto --- docs/user/project-settings.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index de7b8f10da28..7506b92f725d 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -30,12 +30,9 @@ one until you pick another. Every other setting fans out to the selection. With a project selected, a provider's switch in **Settings → Providers** turns it on or off for that project only, in either direction: a provider off on the machine can be used in one project, and one on everywhere can be kept out of another. That project's model pickers follow the switch, -and no new, queued, or follow-up turn in that project can start on a provider it turned off, even -a wake from a delegated task. A turn already running when the switch flips finishes on its own. -A delegated task's result is recorded in its parent thread regardless of its wake being turned -away this way; the parent picks it up on its next turn against an enabled provider instead of -getting a push notification for it. An adapter's own buffered follow-up has nothing to attach to -once its instance is off, so it is dropped rather than queued. Reset returns the project to the +and no new turn there can start on a provider it turned off, including queued messages and +follow-ups from delegated tasks. A turn already running when the switch flips finishes. A delegated +task's result still lands in its parent thread, which sees it on its next turn. Reset returns the project to the machine's setting. Every other provider setting stays per machine. On mobile, open **Settings** and use the filter in its header to choose connected environments From 2bffa2a88ac484ef95278516616be45ccb170f64 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 11 Oct 2026 04:11:53 -0400 Subject: [PATCH 29/39] fix(mobile): pickers keep hiding providers the machine turned off when a project has no override Signed-off-by: Yordis Prieto --- apps/mobile/src/lib/modelOptions.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index e3b981af3315..22f32d34bd36 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -6,7 +6,6 @@ import type { RuntimeMode, ServerConfig as T3ServerConfig, } from "@t3tools/contracts"; -import { resolveProjectProviderInstanceEnabled } from "@t3tools/contracts"; import { buildExplicitProviderOptionSelectionsFromDescriptors, getProviderOptionDescriptors, @@ -71,9 +70,13 @@ function isProviderEnabledForProject( if (!provider) { return false; } - return config?.settings - ? resolveProjectProviderInstanceEnabled(config.settings, projectId, provider.instanceId) - : provider.enabled; + const projectOverride = + projectId && config?.settings + ? config.settings.projectSettingsOverrides[projectId]?.providerInstances?.[ + provider.instanceId + ]?.enabled + : undefined; + return projectOverride ?? provider.enabled; } /** From 3bc3b05196020e175a78e55766dc6a09d12bbb3c Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 11 Oct 2026 04:19:49 -0400 Subject: [PATCH 30/39] test(web): provider settings routing tests mock the server configs the override gate reads Signed-off-by: Yordis Prieto --- .../settings/ProviderSettingsPanel.environment.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx index bee9a6f49326..91ae48a5634d 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx @@ -140,6 +140,7 @@ vi.mock("../../state/session", () => ({ vi.mock("../../state/entities", () => ({ useProjects: () => [], + useServerConfigs: () => new Map(), })); import { EnvironmentProviderSettings } from "./ProviderSettingsPanel"; From 615e65ab5d01f8a36563b1a4e3ab7644fcbfc40e Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 11 Oct 2026 04:52:03 -0400 Subject: [PATCH 31/39] fix(server): a refused continuation wake still clears the adapter's sticky offer flag The adapter's own failure-cleanup only runs if it sees the dispatch actually fail, so pre-converting a disabled-instance rejection into a logged no-op before handing it to dispatchIfCurrent left its sticky flag set forever, permanently blocking rollback. Signed-off-by: Yordis Prieto --- .../ProviderContinuationService.test.ts | 54 +++++++++++++ .../ProviderContinuationService.ts | 77 +++++++++++-------- 2 files changed, 97 insertions(+), 34 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts b/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts index bd6de62c728c..897f757395bc 100644 --- a/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts @@ -13,6 +13,7 @@ import { import * as Deferred from "effect/Deferred"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; @@ -613,6 +614,59 @@ describe("ProviderContinuationService", () => { }, ); + it.effect( + "clears the adapter's sticky continuation flag when a non-delegated wake is refused", + () => { + return Effect.gen(function* () { + const requested = yield* Ref.make(true); + const layerThreads = Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: () => Effect.succeed(projection), + dispatch: () => + Effect.fail( + new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({ + projectId: ProjectId.make("project-provider-continuation-disabled"), + instanceId: ProviderInstanceId.make("instance-provider-continuation-disabled"), + }) as never, + ), + }); + const layerWorker = ProviderContinuationService.layer.pipe( + Layer.provide( + Layer.mergeAll(IdAllocator.layer, ProviderContinuationRequests.layer, layerThreads), + ), + ); + + // Mirrors the ACP adapter's own dispatchIfCurrent: on a failed effect + // it clears its sticky "continuation requested" flag before + // re-raising. If this service pre-recovered the failure before + // handing the effect here, this flag would stay stuck set and + // hasPendingBackgroundWork would never clear. + const dispatchIfCurrent = (effect: Effect.Effect) => + Effect.gen(function* () { + const exit = yield* Effect.exit(effect); + if (Exit.isFailure(exit)) { + yield* Ref.set(requested, false); + return yield* Effect.failCause(exit.cause); + } + return Option.some(exit.value); + }); + + yield* Effect.gen(function* () { + const requests = yield* ProviderContinuationRequests.ProviderContinuationRequests; + yield* requests.offer(request(dispatchIfCurrent)); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + assert.isFalse( + yield* Ref.get(requested), + "a disabled instance must still clear the adapter's sticky offer flag", + ); + }).pipe( + Effect.provide(Layer.merge(ProviderContinuationRequests.layer, layerWorker)), + Effect.scoped, + ); + }); + }, + ); + it.effect("keeps a Grok delegated completion queued instead of restarting active work", () => { return Effect.gen(function* () { const dispatched = yield* Queue.unbounded(); diff --git a/apps/server/src/orchestration-v2/ProviderContinuationService.ts b/apps/server/src/orchestration-v2/ProviderContinuationService.ts index 96c18d893158..538c31cb357a 100644 --- a/apps/server/src/orchestration-v2/ProviderContinuationService.ts +++ b/apps/server/src/orchestration-v2/ProviderContinuationService.ts @@ -209,46 +209,55 @@ export const layer = Layer.effectDiscard( ordinal: projection.messages.length + 1, })); const commandId = CommandId.make(`provider-continuation:${messageId}`); - const dispatch = threads - .dispatch({ - type: "message.dispatch", - commandId, + const dispatch = threads.dispatch({ + type: "message.dispatch", + commandId, + threadId: request.threadId, + messageId, + text: request.detail ?? CONTINUATION_MESSAGE_TEXT, + notification: request.notification ?? { + source: { kind: "background_task" }, + outcome: "updated", + summary: "Background activity updated", + }, + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "agent", + // "provider" marks an adapter-buffered wake, which ClaudeAdapterV2 + // detects to attach the buffered CLI output and drop this text. A + // message_text wake has no buffered output, so it must not carry that + // marker or the turn settles immediately having prompted nothing. + creationSource: request.delivery === "message_text" ? "server" : "provider", + }); + // The adapter's buffered wake has nowhere else to go once the + // instance is off: drop it rather than warn, since nothing here + // retries it either way. This catch sits outside dispatchIfCurrent so + // the adapter still observes the raw failure first and clears its + // own sticky offer flag before we swallow it here. + const logInstanceDisabled = ( + error: ThreadManagementService.ThreadManagementProviderInstanceDisabledError, + ) => + Effect.logInfo("orchestration-v2.provider-continuation.instance-disabled", { threadId: request.threadId, - messageId, - text: request.detail ?? CONTINUATION_MESSAGE_TEXT, - notification: request.notification ?? { - source: { kind: "background_task" }, - outcome: "updated", - summary: "Background activity updated", - }, - attachments: [], - dispatchMode: { type: "queue_after_active" }, - createdBy: "agent", - // "provider" marks an adapter-buffered wake, which ClaudeAdapterV2 - // detects to attach the buffered CLI output and drop this text. A - // message_text wake has no buffered output, so it must not carry that - // marker or the turn settles immediately having prompted nothing. - creationSource: request.delivery === "message_text" ? "server" : "provider", - }) - .pipe( - // The adapter's buffered wake has nowhere else to go once the - // instance is off: drop it rather than warn, since nothing here - // retries it either way. + providerThreadId: request.providerThreadId, + projectId: error.projectId, + instanceId: error.instanceId, + }); + if (request.dispatchIfCurrent === undefined) { + yield* dispatch.pipe( Effect.catchTags({ - ThreadManagementProviderInstanceDisabledError: (error) => - Effect.logInfo("orchestration-v2.provider-continuation.instance-disabled", { - threadId: request.threadId, - providerThreadId: request.providerThreadId, - projectId: error.projectId, - instanceId: error.instanceId, - }), + ThreadManagementProviderInstanceDisabledError: logInstanceDisabled, }), ); - if (request.dispatchIfCurrent === undefined) { - yield* dispatch; return; } - yield* request.dispatchIfCurrent(dispatch); + yield* request + .dispatchIfCurrent(dispatch) + .pipe( + Effect.catchTags({ + ThreadManagementProviderInstanceDisabledError: logInstanceDisabled, + }), + ); }, ); From c6696e84732518100506d76fef6d2fa116c84a69 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 11 Oct 2026 04:52:21 -0400 Subject: [PATCH 32/39] fix(server): queue.resume no longer starts a queued run on a project-disabled instance A project turning off a provider mid-queue, or while the queue sits held, had no gate on the path that actually starts the next run, unlike new turns and resumes already elsewhere. A turned-off provider must not start any new work, queued or otherwise. Signed-off-by: Yordis Prieto --- .../src/orchestration-v2/Orchestrator.ts | 14 ++ .../src/orchestration-v2/runtimeLayer.test.ts | 130 ++++++++++++++++++ .../testkit/ProviderReplayHarness.ts | 1 + 3 files changed, 145 insertions(+) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index de38ae792388..70c627828167 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -53,6 +53,7 @@ import { orchestrationV2RunWorkStartedAt, ProviderInstanceId, ProviderInteractionMode, + resolveProjectProviderInstanceEnabled, type ProviderSessionId, RunId, RuntimeMode, @@ -131,6 +132,7 @@ import { } from "./ThreadForkService.ts"; import { planThreadDeletion } from "./ThreadDeletion.ts"; import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ServerSettings from "../serverSettings.ts"; export class OrchestratorDispatchError extends Schema.TaggedError()( "OrchestratorDispatchError", @@ -832,6 +834,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; const providerSessions = yield* ProviderSessionManagerV2; const providerSwitchService = yield* ProviderSwitchServiceV2; + const serverSettings = yield* ServerSettings.ServerSettingsService; const runtimePolicy = yield* RuntimePolicyV2; const threadForkService = yield* ThreadForkServiceV2; const threadDispatch = yield* ThreadCommandExecutor.ThreadCommandExecutor; @@ -1318,8 +1321,18 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio failedRun?.id === options?.endedRunId && failedRun?.workspacePreparation?.type === "worktree" && projection.thread.worktreePath === null; + // A project can turn a provider off while a message waits in the queue, + // including between a run ending and this check, or while the queue is + // held and someone clicks resume. Neither path may start that run. + const settings = yield* serverSettings.getSettings.pipe(Effect.orDie); + const instanceDisabledForProject = !resolveProjectProviderInstanceEnabled( + settings, + projection.thread.projectId, + queuedRun.providerInstanceId, + ); if ( worktreeMissing || + instanceDisabledForProject || (failureClass !== undefined && failureClass !== "validation_error" && failedRun?.providerInstanceId === queuedRun.providerInstanceId) @@ -11437,6 +11450,7 @@ export const layer: Layer.Layer< | ProviderSwitchServiceV2 | ProjectionStoreV2 | RuntimePolicyV2 + | ServerSettings.ServerSettingsService | ThreadForkServiceV2 > = Layer.effect(OrchestratorV2, makeOrchestrator()).pipe( Layer.provide(ThreadCommandExecutor.layer), diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 4d3ff429f0c6..d122289ecc08 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -5453,3 +5453,133 @@ it.layer(layerTest)("usage-limit recovery", (it) => { }), ); }); + +const queueResumeDisabledInstanceProjectId = ProjectId.make( + "project:queue-resume-disabled-instance", +); + +const layerQueueResumeDisabledInstanceTest = Layer.mergeAll( + RuntimeLayer.layer, + RuntimeLayer.layerEventSink, + ProjectStore.layer, + ProjectionStore.layer, + EffectOutbox.layer, + ThreadCommandExecutor.layer, +).pipe( + Layer.provide(McpSessionRegistryTestkit.layer), + Layer.provide(McpProviderSessions.layer), + Layer.provide(SqlitePersistence.layerMemory), + Layer.provide(layerCheckpointStoreTest), + Layer.provide(layerServerConfig), + Layer.provide( + ServerSettings.layerTest({ + providerInstances: { + [alternateInstanceId]: { driver: "codex", enabled: true }, + }, + projectSettingsOverrides: { + [queueResumeDisabledInstanceProjectId]: { + providerInstances: { [alternateInstanceId]: { enabled: false } }, + }, + }, + }), + ), + Layer.provide(layerTestProviderInstanceRegistry), + Layer.provide(layerGitWorkflowTest), + Layer.provide(layerProjectServiceTest), + Layer.provide(layerPlatformTest), +); + +it.layer(layerQueueResumeDisabledInstanceTest)( + "queue.resume on a project-disabled instance", + (it) => { + it.effect( + "holds a queued run instead of starting it on an instance the project turned off", + () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const events = yield* EventSink.EventSinkV2; + const threadId = ThreadId.make("queue-resume-disabled-instance-thread"); + const queuedModelSelection = { + instanceId: alternateInstanceId, + model: "gpt-5.5", + } satisfies ModelSelection; + + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: queueResumeDisabledInstanceProjectId, + title: "Queue resume disabled instance", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${threadId}:message:active`), + threadId, + messageId: MessageId.make(`${threadId}:message:active`), + text: "Active", + attachments: [], + dispatchMode: { type: "defer_start" }, + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${threadId}:message:queued`), + threadId, + messageId: MessageId.make(`${threadId}:message:queued`), + text: "Queued", + attachments: [], + modelSelection: queuedModelSelection, + dispatchMode: { type: "queue_after_active" }, + }); + + const before = yield* orchestrator.getThreadProjection(threadId); + const activeRun = before.runs.find((run) => run.status !== "queued")!; + const queuedRun = before.runs.find((run) => run.status === "queued")!; + const now = yield* DateTime.now; + // A raw event ends the active run without going through dispatch, + // so nothing has yet tried to advance the queue onto the disabled + // instance. + yield* events.write({ + events: [ + { + id: EventId.make(`${threadId}:active-completed`), + type: "run.updated", + threadId, + runId: activeRun.id, + occurredAt: now, + payload: { ...activeRun, status: "completed", completedAt: now }, + }, + ], + }); + + yield* orchestrator.dispatch({ + type: "queue.resume", + commandId: CommandId.make(`${threadId}:resume`), + threadId, + }); + + const resumed = yield* orchestrator.getThreadProjection(threadId); + const resumedQueuedRun = resumed.runs.find((run) => run.id === queuedRun.id); + assert.equal( + resumedQueuedRun?.status, + "queued", + "a disabled instance must not start the queued run", + ); + assert.isTrue( + resumedQueuedRun?.queueHeld, + "a refused resume re-holds the queue instead of leaving it looking resumable", + ); + }), + ); + }, +); diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 577a78aae5ac..1ee5fb996484 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -450,6 +450,7 @@ export function layerWithRegistry( layerProviderSessionManagerProvided, layerProviderSwitchServiceProvided, layerRunExecutionServiceProvided, + layerServerSettings, ThreadForkService.layer, ), ), From f7ddbce81e2c6f4129cce935684ee7bb76f01673 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 11 Oct 2026 05:02:54 -0400 Subject: [PATCH 33/39] fix(mobile): a project with no override no longer inherits another project's keep-alive A project's enablement check must reflect its own settings, not a live snapshot another project's override happens to be keeping on. Signed-off-by: Yordis Prieto --- apps/mobile/src/lib/modelOptions.test.ts | 24 ++++++++++++++++++++++++ apps/mobile/src/lib/modelOptions.ts | 15 +++++++-------- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 48cd9be326e3..71edab004924 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -629,6 +629,23 @@ describe("mobile model options", () => { expect(buildModelOptions(configWith(settings), null, undefined, null)).toHaveLength(1); }); + it("hides a machine-disabled instance for a project with no override, even though another project's keep-alive left it running", () => { + const otherProjectId = ProjectId.make("project-b"); + const settings: ServerSettings = { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false }, + }, + projectSettingsOverrides: { + [otherProjectId]: { providerInstances: { [instanceId]: { enabled: true } } }, + }, + }; + + // configWith's snapshot always reports enabled: true, standing in for + // the server keeping the instance alive to serve project-b's override. + expect(buildModelOptions(configWith(settings), null, undefined, projectId)).toEqual([]); + }); + it("marks a project-disabled instance's fallback selection unavailable instead of reviving it", () => { const settings: ServerSettings = { ...DEFAULT_SERVER_SETTINGS, @@ -718,6 +735,13 @@ describe("synchronized mobile model visibility", () => { const config = { settings: { ...DEFAULT_SERVER_SETTINGS, + // A live snapshot entry always has a matching configured instance (or + // is a driver's own default id); these are stand-ins for neither, so + // the enablement fixture needs its own entry to read as enabled. + providerInstances: { + [provider]: { driver: ProviderDriverKind.make("codex"), enabled: true }, + [other]: { driver: ProviderDriverKind.make("codex"), enabled: true }, + }, providerModelPreferences: { [provider]: { hiddenModels: ["hidden", "custom", "missing"], diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 22f32d34bd36..832b08085bfb 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -6,6 +6,7 @@ import type { RuntimeMode, ServerConfig as T3ServerConfig, } from "@t3tools/contracts"; +import { resolveProjectProviderInstanceEnabled } from "@t3tools/contracts"; import { buildExplicitProviderOptionSelectionsFromDescriptors, getProviderOptionDescriptors, @@ -58,7 +59,9 @@ function providerDisplayLabel(provider: { /** * `provider.enabled` reflects the machine plus any project's keep-alive * override, so it alone can't tell this project apart from one that merely - * shares the instance. Settings carry the real per-project view. + * shares the instance. Settings carry the real per-project view the server + * itself enforces, so a project with no override of its own must resolve + * from there, not from a snapshot another project may be keeping alive. */ function isProviderEnabledForProject( config: T3ServerConfig | null | undefined, @@ -70,13 +73,9 @@ function isProviderEnabledForProject( if (!provider) { return false; } - const projectOverride = - projectId && config?.settings - ? config.settings.projectSettingsOverrides[projectId]?.providerInstances?.[ - provider.instanceId - ]?.enabled - : undefined; - return projectOverride ?? provider.enabled; + return config?.settings + ? resolveProjectProviderInstanceEnabled(config.settings, projectId, provider.instanceId) + : provider.enabled; } /** From 3d03fb59d288936fd9ed0cb2ce9d3bb7e4c0a764 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 11 Oct 2026 05:06:29 -0400 Subject: [PATCH 34/39] style(server): drop an em dash from the override sweep header Signed-off-by: Yordis Prieto --- apps/server/src/provider/ProjectSettingsOverrideSweep.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/provider/ProjectSettingsOverrideSweep.ts b/apps/server/src/provider/ProjectSettingsOverrideSweep.ts index eae4e25def37..05bad7f0a18f 100644 --- a/apps/server/src/provider/ProjectSettingsOverrideSweep.ts +++ b/apps/server/src/provider/ProjectSettingsOverrideSweep.ts @@ -1,5 +1,5 @@ /** - * ProjectSettingsOverrideSweep — remove `projectSettingsOverrides` entries + * Removes `projectSettingsOverrides` entries * whose project no longer exists. * * `ProjectService.delete` clears a project's entry as part of deleting the From fb98811ccbc4ebb4388b42ae0b3e7245c75eea5e Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 11 Oct 2026 05:22:08 -0400 Subject: [PATCH 35/39] fix(server): a refused continuation still clears offers that cannot self-clear An adapter whose dispatchIfCurrent forwards the dispatch effect without its own failure handling, unlike Pi or ACP, had no way to clear its sticky offer flag when the project disabled the target instance, so a refused background wake blocked every later background turn for that thread. Signed-off-by: Yordis Prieto --- .../ProviderContinuationService.test.ts | 50 +++++++++++++++++++ .../ProviderContinuationService.ts | 25 ++++++---- 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts b/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts index 897f757395bc..689c66a1d402 100644 --- a/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts @@ -667,6 +667,56 @@ describe("ProviderContinuationService", () => { }, ); + it.effect( + "clears a sticky continuation offer for an adapter whose dispatchIfCurrent does not self-clear on failure", + () => { + return Effect.gen(function* () { + const wakeHeld = yield* Ref.make(true); + const layerThreads = Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: () => Effect.succeed(projection), + dispatch: () => + Effect.fail( + new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({ + projectId: ProjectId.make("project-provider-continuation-disabled"), + instanceId: ProviderInstanceId.make("instance-provider-continuation-disabled"), + }) as never, + ), + }); + const layerWorker = ProviderContinuationService.layer.pipe( + Layer.provide( + Layer.mergeAll(IdAllocator.layer, ProviderContinuationRequests.layer, layerThreads), + ), + ); + + // Mirrors the Muse adapter: dispatchIfCurrent forwards the dispatch + // effect without any failure handling of its own (unlike ACP or Pi), + // so only the explicit clearIfCurrent callback can clear the sticky + // "wake held" flag. Without it, a disabled instance leaves the flag + // set forever and no later background turn is ever offered. + const dispatchIfCurrent = (effect: Effect.Effect) => + Effect.asSome(effect); + + yield* Effect.gen(function* () { + const requests = yield* ProviderContinuationRequests.ProviderContinuationRequests; + yield* requests.offer({ + ...request(dispatchIfCurrent), + clearIfCurrent: () => Ref.set(wakeHeld, false), + }); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + assert.isFalse( + yield* Ref.get(wakeHeld), + "a disabled instance must clear the offer via the explicit callback " + + "when the adapter itself does not clear on dispatch failure", + ); + }).pipe( + Effect.provide(Layer.merge(ProviderContinuationRequests.layer, layerWorker)), + Effect.scoped, + ); + }); + }, + ); + it.effect("keeps a Grok delegated completion queued instead of restarting active work", () => { return Effect.gen(function* () { const dispatched = yield* Queue.unbounded(); diff --git a/apps/server/src/orchestration-v2/ProviderContinuationService.ts b/apps/server/src/orchestration-v2/ProviderContinuationService.ts index 538c31cb357a..c0b0c55b7221 100644 --- a/apps/server/src/orchestration-v2/ProviderContinuationService.ts +++ b/apps/server/src/orchestration-v2/ProviderContinuationService.ts @@ -232,8 +232,11 @@ export const layer = Layer.effectDiscard( // The adapter's buffered wake has nowhere else to go once the // instance is off: drop it rather than warn, since nothing here // retries it either way. This catch sits outside dispatchIfCurrent so - // the adapter still observes the raw failure first and clears its - // own sticky offer flag before we swallow it here. + // an adapter that clears its own sticky offer flag on dispatch + // failure (inside dispatchIfCurrent) observes the raw failure first. + // Not every adapter self-clears that way, so the explicit callback is + // called here too; clearIfCurrent implementations are idempotent, so + // a source that already cleared is unaffected. const logInstanceDisabled = ( error: ThreadManagementService.ThreadManagementProviderInstanceDisabledError, ) => @@ -242,7 +245,11 @@ export const layer = Layer.effectDiscard( providerThreadId: request.providerThreadId, projectId: error.projectId, instanceId: error.instanceId, - }); + }).pipe( + Effect.andThen( + request.clearIfCurrent !== undefined ? request.clearIfCurrent() : Effect.void, + ), + ); if (request.dispatchIfCurrent === undefined) { yield* dispatch.pipe( Effect.catchTags({ @@ -251,13 +258,11 @@ export const layer = Layer.effectDiscard( ); return; } - yield* request - .dispatchIfCurrent(dispatch) - .pipe( - Effect.catchTags({ - ThreadManagementProviderInstanceDisabledError: logInstanceDisabled, - }), - ); + yield* request.dispatchIfCurrent(dispatch).pipe( + Effect.catchTags({ + ThreadManagementProviderInstanceDisabledError: logInstanceDisabled, + }), + ); }, ); From e17b8e0703067dd689251cce5896761ee2488490 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 11 Oct 2026 05:40:21 -0400 Subject: [PATCH 36/39] fix(server): queued runs only hold for a provider that was explicitly turned off Signed-off-by: Yordis Prieto --- .../src/orchestration-v2/Orchestrator.ts | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 70c627828167..d85504bb264e 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -53,7 +53,7 @@ import { orchestrationV2RunWorkStartedAt, ProviderInstanceId, ProviderInteractionMode, - resolveProjectProviderInstanceEnabled, + resolveProviderInstanceEnabled, type ProviderSessionId, RunId, RuntimeMode, @@ -1323,13 +1323,24 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio projection.thread.worktreePath === null; // A project can turn a provider off while a message waits in the queue, // including between a run ending and this check, or while the queue is - // held and someone clicks resume. Neither path may start that run. + // held and someone clicks resume. Neither path may start that run. Only + // an explicit turn-off holds the queue: an instance with no settings + // entry was already admitted when the message was queued. const settings = yield* serverSettings.getSettings.pipe(Effect.orDie); - const instanceDisabledForProject = !resolveProjectProviderInstanceEnabled( - settings, - projection.thread.projectId, + const projectOverride = + settings.projectSettingsOverrides[projection.thread.projectId]?.providerInstances?.[ + queuedRun.providerInstanceId + ]?.enabled; + const machineInstance = Object.hasOwn( + settings.providerInstances, queuedRun.providerInstanceId, - ); + ) + ? settings.providerInstances[queuedRun.providerInstanceId] + : undefined; + const instanceDisabledForProject = + projectOverride !== undefined + ? !projectOverride + : machineInstance !== undefined && !resolveProviderInstanceEnabled(machineInstance); if ( worktreeMissing || instanceDisabledForProject || From 8ea8d0d64c5c0f68f2e6edd418139b9864124d4e Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 11 Oct 2026 05:55:19 -0400 Subject: [PATCH 37/39] fix(mobile): a model inherited as disabled now takes the same recovery path as an explicit project turn-off An instance a project merely inherits as disabled from the machine level looked sendable to the composer and outbox even though the server always refuses it, and the new keep-alive mechanic can leave such an instance looking installed and running in the snapshot because another project kept it alive. Signed-off-by: Yordis Prieto --- apps/mobile/src/lib/modelOptions.test.ts | 27 ++++++++++++++++- apps/mobile/src/lib/modelOptions.ts | 38 +++++++++++++++++++----- 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 71edab004924..3341bff2fe45 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -312,7 +312,10 @@ describe("mobile model options", () => { expect(resolveSelectableModelSelection(config, usable, null)).toBe(usable); expect(resolveSelectableModelSelection(config, disabled, null)).toBeNull(); expect(resolveSelectableModelSelection(config, removed, null)).toBeNull(); - expect(isModelSelectionUnavailable(config, disabled, null)).toBe(false); + // Disabled is disabled even with no settings to consult; only a provider + // that is merely missing or unauthenticated falls through silently. + expect(isModelSelectionUnavailable(config, disabled, null)).toBe(true); + expect(isModelSelectionUnavailable(config, removed, null)).toBe(false); // An offline environment has no config to validate. expect(resolveSelectableModelSelection(null, disabled, null)).toBe(disabled); }); @@ -557,6 +560,9 @@ describe("mobile model options", () => { it("words a project's own turn-off apart from missing setup", () => { const settings: ServerSettings = { ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, + }, projectSettingsOverrides: { [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, }, @@ -646,6 +652,25 @@ describe("mobile model options", () => { expect(buildModelOptions(configWith(settings), null, undefined, projectId)).toEqual([]); }); + it("refuses to send an instance the project merely inherits as disabled, not just an explicit override", () => { + const settings: ServerSettings = { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false }, + }, + }; + const selection = { instanceId, model: "model-a" }; + + // configWith's snapshot always reports enabled: true, standing in for + // the server keeping the instance alive for another project's keep-alive. + expect(isModelSelectionUnavailable(configWith(settings), selection, projectId)).toBe(true); + expect( + describeUnavailableModelSelection(configWith(settings), selection, projectId).title, + ).toBe("Model turned off for this project"); + // With no project in scope, the machine value applies and is disabled. + expect(isModelSelectionUnavailable(configWith(settings), selection, null)).toBe(true); + }); + it("marks a project-disabled instance's fallback selection unavailable instead of reviving it", () => { const settings: ServerSettings = { ...DEFAULT_SERVER_SETTINGS, diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 832b08085bfb..126f55222aa5 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -96,6 +96,27 @@ function isProjectDisabledOverride( ); } +/** + * Whether the project cannot use this instance at all, whether the project + * turned it off itself or it merely inherits a machine-level disable. Either + * way the server's `ThreadManagementService.dispatch` refuses a send, and the + * instance may still look installed and running in the snapshot because + * another project's keep-alive left it up. + */ +function isDisabledForProject( + config: T3ServerConfig | null | undefined, + provider: + | { readonly instanceId: ModelSelection["instanceId"]; readonly enabled: boolean } + | undefined, + instanceId: ModelSelection["instanceId"], + projectId: ProjectId | null, +): boolean { + if (isProjectDisabledOverride(config, instanceId, projectId)) { + return true; + } + return provider !== undefined && !isProviderEnabledForProject(config, provider, projectId); +} + function normalizeSelectionOptions( selection: ModelSelection, capabilities: ModelCapabilities | null, @@ -122,8 +143,9 @@ function normalizeSelectionOptions( } /** - * Whether the selection cannot be sent as is: the project turned its instance - * off, or a known Antigravity selection needs setup or a different model. + * Whether the selection cannot be sent as is: the instance resolves disabled + * for the project (its own override, or an inherited machine-level disable), + * or a known Antigravity selection needs setup or a different model. * * `projectId` has no default: a caller that forgets it would silently check * only the machine-wide state and miss a project that turned this instance @@ -137,18 +159,17 @@ export function isModelSelectionUnavailable( if (!config || !selection) { return false; } - if (isProjectDisabledOverride(config, selection.instanceId, projectId)) { - return true; - } const provider = config.providers.find( (candidate) => candidate.instanceId === selection.instanceId, ); + if (isDisabledForProject(config, provider, selection.instanceId, projectId)) { + return true; + } const driver = provider?.driver ?? config.settings?.providerInstances[selection.instanceId]?.driver; return ( driver === "antigravity" && (!provider || - !isProviderEnabledForProject(config, provider, projectId) || !provider.installed || provider.auth.status === "unauthenticated" || provider.availability === "unavailable" || @@ -162,7 +183,10 @@ export function describeUnavailableModelSelection( selection: ModelSelection, projectId: ProjectId | null, ): { readonly title: string; readonly detail: string } { - return isProjectDisabledOverride(config, selection.instanceId, projectId) + const provider = config?.providers.find( + (candidate) => candidate.instanceId === selection.instanceId, + ); + return isDisabledForProject(config, provider, selection.instanceId, projectId) ? { title: "Model turned off for this project", detail: "Turn it back on from web or desktop, or choose another model.", From d2cafa2f099b9d101ca03adf2b80b98e8960fad6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 11 Oct 2026 10:10:59 -0700 Subject: [PATCH 38/39] fix(mobile,server): restore merge-introduced project threading lost in rebase Threading projectId through the mobile thread settings sheet and its callers, the updated model visibility test calls, and ServerSettings test layers were introduced while resolving merges with main, so replaying the branch dropped them. Restored from the pre-rebase tree. --- .../features/settings/ScheduledTaskPickerScreens.tsx | 1 + apps/mobile/src/features/threads/ThreadComposer.tsx | 2 ++ .../src/features/threads/ThreadSettingsSheet.tsx | 12 +++++++++++- apps/mobile/src/lib/modelOptions.test.ts | 9 +++++---- apps/server/src/mcp/OrchestratorMcpService.test.ts | 3 +++ .../mcp/OrchestratorMcpToolkit.integration.test.ts | 1 - .../mcp/TaskCancelNativeSubagent.integration.test.ts | 1 - .../orchestration-v2/ThreadManagementService.test.ts | 2 ++ 8 files changed, 24 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx b/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx index 9dbbcfa2012e..0947fe64b7c8 100644 --- a/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx +++ b/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx @@ -50,6 +50,7 @@ export function ScheduledTaskModelPickerRouteScreen() { return ( diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 178f2852c6d9..64d008a2fd38 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -689,6 +689,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer () => ({ ownerId: settingsOwnerId, environmentId: props.environmentId, + projectId: props.selectedThread.projectId, providerInstanceId: currentModelSelection.instanceId, ...(lockedProviderInstanceId ? { lockedProviderInstanceId } : {}), providerGroups: threadProviderGroups, @@ -718,6 +719,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer settingsOwnerId, lockedProviderInstanceId, threadProviderGroups, + props.selectedThread.projectId, ], ); const openSettings = useCallback(() => { diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 6aaf1c0ebb2f..34d47e7e580a 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -7,6 +7,7 @@ import { import type { EnvironmentId, ModelSelection, + ProjectId, ProviderInstanceId, ProviderOptionDescriptor, ProviderOptionSelection, @@ -252,6 +253,7 @@ type ThreadSettingsSubmenuPage = type ThreadSettingsSessionProps = { readonly environmentId: EnvironmentId | null; + readonly projectId: ProjectId | null; readonly providerInstanceId?: ProviderInstanceId; readonly lockedProviderInstanceId?: ProviderInstanceId; readonly providerGroups: ReadonlyArray; @@ -360,7 +362,13 @@ function ThreadSettingsSessionProvider( () => manageHidden && config ? groupByProvider( - buildModelOptions(config, props.selectedModel, props.lockedProviderInstanceId, true), + buildModelOptions( + config, + props.selectedModel, + props.lockedProviderInstanceId, + props.projectId, + true, + ), ) : props.providerGroups, [ @@ -369,6 +377,7 @@ function ThreadSettingsSessionProvider( props.providerGroups, props.selectedModel, props.lockedProviderInstanceId, + props.projectId, ], ); const legacyFavorites = AsyncResult.isSuccess(preferences) @@ -1605,6 +1614,7 @@ export function NewTaskThreadSettingsRouteScreen() { return ( { } as unknown as ServerConfig; it("applies server visibility and ordering independently to each provider instance", () => { - const options = buildModelOptions(config, null); + const options = buildModelOptions(config, null, undefined, null); expect( options .filter((option) => option.providerKey === provider) @@ -803,6 +803,7 @@ describe("synchronized mobile model visibility", () => { config, { instanceId: provider, model: "hidden" }, provider, + null, true, ); expect(options.map((option) => option.selection.model)).toEqual([ @@ -823,7 +824,7 @@ describe("synchronized mobile model visibility", () => { it.each(["hidden", "missing"])("does not resurrect the hidden fallback %s", (model) => { expect( - buildModelOptions(config, { instanceId: provider, model }, provider).map( + buildModelOptions(config, { instanceId: provider, model }, provider, null).map( (option) => option.selection.model, ), ).toEqual(["second", "first", "custom"]); @@ -831,13 +832,13 @@ describe("synchronized mobile model visibility", () => { it("keeps custom models visible and restores built-in models when the server clears hidden models", () => { expect( - buildModelOptions(config, { instanceId: provider, model: "custom" }, provider).map( + buildModelOptions(config, { instanceId: provider, model: "custom" }, provider, null).map( (option) => option.selection.model, ), ).toContain("custom"); const cleared = { ...config, settings: { ...config.settings, providerModelPreferences: {} } }; expect( - buildModelOptions(cleared, null, provider).map((option) => option.selection.model), + buildModelOptions(cleared, null, provider, null).map((option) => option.selection.model), ).toEqual(["hidden", "first", "second", "custom"]); }); }); diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 0c04fa3166c6..1b68f710d6df 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -1355,6 +1355,7 @@ describe("OrchestratorMcpService provider resolution", () => { Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ServerSettings.layerTest(), ); yield* Effect.gen(function* () { @@ -1455,6 +1456,7 @@ describe("OrchestratorMcpService provider resolution", () => { Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ServerSettings.layerTest(), ); yield* Effect.gen(function* () { @@ -1566,6 +1568,7 @@ describe("OrchestratorMcpService provider resolution", () => { Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ServerSettings.layerTest(), ); yield* Effect.gen(function* () { diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index f6f61618da48..aeae1a94c68b 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -73,7 +73,6 @@ import { import * as ProviderRegistryMock from "../provider/testUtils/providerRegistryMock.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; -import * as ServerSettings from "../serverSettings.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; diff --git a/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts b/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts index bb15248f9cba..c3bd9f0af5b4 100644 --- a/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts +++ b/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts @@ -36,7 +36,6 @@ import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; -import * as ServerSettings from "../serverSettings.ts"; import type { McpInvocationScope } from "./McpInvocationContext.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index 45231733eb5b..fb6e9af45424 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -830,6 +830,8 @@ it.effect.each([ }), ), ), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(layerReceiptsTest), ); yield* Effect.gen(function* () { const service = yield* ThreadManagementService.ThreadManagementService; From 5b257f452ff6c69fd6b170f34715bc92e30d00c4 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 11 Oct 2026 09:25:26 -0400 Subject: [PATCH 39/39] fix(settings): review guides skip providers a project turned off Signed-off-by: Yordis Prieto --- .../src/pullRequest/PullRequestReviewGuideService.ts | 1 + .../settings/SourceControlWritingSettings.tsx | 1 + packages/shared/src/serverSettings.ts | 10 +++++++--- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/server/src/pullRequest/PullRequestReviewGuideService.ts b/apps/server/src/pullRequest/PullRequestReviewGuideService.ts index 2063804b48ef..f074054c3a99 100644 --- a/apps/server/src/pullRequest/PullRequestReviewGuideService.ts +++ b/apps/server/src/pullRequest/PullRequestReviewGuideService.ts @@ -328,6 +328,7 @@ const make = Effect.gen(function* () { const modelSelection = ServerSettings.resolveReviewGuideModelSelection( projectSettings, providers, + input.projectId, ); // The checkout lives only as long as the model is reading it. The change's hunks are read // from it too, so the guide can be held to the lines the change actually touched. diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx index 4416823202a4..1a6b70c50452 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx @@ -156,6 +156,7 @@ export function SourceControlWritingSettingsSection() { textGenerationModelSelection: resolveReviewGuideModelSelection( settings, textGenerationProviders, + target?.projectId ?? null, ), }, textGenerationProviders, diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index d3473befdbf9..acfca1a8a08f 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -166,10 +166,14 @@ export function resolveSourceControlWriterModelSelection( */ export function resolveReviewGuideModelSelection( settings: ServerSettings, - providers?: ReadonlyArray, + providers: ReadonlyArray | undefined, + projectId: ProjectId | null, ): ModelSelection { const selection = settings.reviewGuideModelSelection; - if (selection && isModelSelectionProviderEnabled(settings, selection)) { + if ( + selection && + resolveProjectProviderInstanceEnabled(settings, projectId, selection.instanceId) + ) { const provider = providers?.find((candidate) => candidate.instanceId === selection.instanceId); if ( providers === undefined || @@ -180,7 +184,7 @@ export function resolveReviewGuideModelSelection( return selection; } } - return resolveSourceControlWriterModelSelection(settings, providers); + return resolveSourceControlWriterModelSelection(settings, providers, projectId); } export interface PersistedServerObservabilitySettings {