diff --git a/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx b/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx index ce27c1debbcc..0947fe64b7c8 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) => @@ -46,6 +50,7 @@ export function ScheduledTaskModelPickerRouteScreen() { return ( diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index e9cc8563f2a9..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, @@ -582,7 +585,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), ); @@ -757,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, + }); }} /> { 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 0ba9a437cdb6..707bd987e822 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.ts @@ -28,13 +28,18 @@ 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, stickySelection: null, - modelOptions: buildModelOptions(config, projectDefaultSelection), + modelOptions: buildModelOptions( + config, + projectDefaultSelection, + undefined, + project?.id ?? null, + ), }); } diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index cfb12bfd72a8..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"; @@ -1236,6 +1237,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,12 +1254,18 @@ export function NewTaskDraftScreen(props: { } if ( environmentConnected && - isModelSelectionUnavailable(selectedEnvironmentServerConfig, modelSelection) + isModelSelectionUnavailable( + selectedEnvironmentServerConfig, + modelSelection, + 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/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 4000571c1e80..64d008a2fd38 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 ( @@ -651,8 +655,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 = @@ -674,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, @@ -703,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 ( buildModelOptions( selectedEnvironmentServerConfig, draftModelSelection ?? projectDefaultModelSelection ?? stickyModelSelection, + undefined, + selectedProject?.id ?? null, ), [ selectedEnvironmentServerConfig, draftModelSelection, projectDefaultModelSelection, stickyModelSelection, + selectedProject?.id, ], ); @@ -1117,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 ccf1e7bfd820..71697dee7a67 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -2,13 +2,17 @@ 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 { buildModelOptions, + describeUnavailableModelSelection, groupByProvider, isModelSelectionUnavailable, resolveDefaultableModelSelection, @@ -47,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", @@ -83,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", @@ -127,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) => ({ @@ -181,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" }]); }); @@ -216,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", + ), ); }); @@ -247,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 }); }, @@ -289,12 +309,15 @@ 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(); + // 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)).toBe(disabled); + expect(resolveSelectableModelSelection(null, disabled, null)).toBe(disabled); }); describe("Antigravity selections", () => { @@ -347,11 +370,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, @@ -376,9 +399,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, @@ -391,18 +414,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", () => { @@ -415,9 +440,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", @@ -430,12 +457,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(); }); }); @@ -466,11 +493,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", () => { @@ -508,6 +535,217 @@ 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("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 } } }, + }, + }; + 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("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]: { providerInstances: { [instanceId]: { enabled: 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, + providerInstances: { + [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, + }, + projectSettingsOverrides: { + [projectId]: { providerInstances: { [instanceId]: { enabled: 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]: { providerInstances: { [instanceId]: { enabled: 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]: { providerInstances: { [instanceId]: { enabled: false } } }, + }, + }; + + 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("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, + providerInstances: { + [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, + }, + projectSettingsOverrides: { + [projectId]: { providerInstances: { [instanceId]: { enabled: 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]: { providerInstances: { [instanceId]: { enabled: false } } }, + }, + }; + const selection = { instanceId, model: "model-a" }; + + expect( + resolveSelectableModelSelection(configWith(settings), selection, projectId), + ).toBeNull(); + expect(resolveSelectableModelSelection(configWith(settings), selection, null)).toBe( + 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]: { providerInstances: { [antigravityInstanceId]: { enabled: 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); + expect( + resolveSelectableModelSelection({ ...config, providers: [] }, selection, projectId), + ).toBeNull(); + }); + }); }); describe("synchronized mobile model visibility", () => { @@ -522,6 +760,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"], @@ -540,7 +785,7 @@ describe("synchronized mobile model visibility", () => { } 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) @@ -558,6 +803,7 @@ describe("synchronized mobile model visibility", () => { config, { instanceId: provider, model: "hidden" }, provider, + null, true, ); expect(options.map((option) => option.selection.model)).toEqual([ @@ -578,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"]); @@ -586,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/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index eeae36a336b0..126f55222aa5 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, @@ -54,6 +56,67 @@ 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 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, + 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; +} + +/** + * 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, + instanceId: ModelSelection["instanceId"], + projectId: ProjectId | null, +): boolean { + if (!projectId || !config?.settings) { + return false; + } + return ( + config.settings.projectSettingsOverrides[projectId]?.providerInstances?.[instanceId] + ?.enabled === false + ); +} + +/** + * 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, @@ -79,10 +142,19 @@ function normalizeSelectionOptions( }; } -/** Whether 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 + * off. Pass `null` explicitly outside a project's scope. + */ export function isModelSelectionUnavailable( config: T3ServerConfig | null | undefined, selection: ModelSelection | null | undefined, + projectId: ProjectId | null, ): boolean { if (!config || !selection) { return false; @@ -90,12 +162,14 @@ export function isModelSelectionUnavailable( 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 || - !provider.enabled || !provider.installed || provider.auth.status === "unauthenticated" || provider.availability === "unavailable" || @@ -103,14 +177,37 @@ 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 } { + 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.", + } + : { + 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 * 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, ): ModelSelection | null { if (!selection || !config) { return selection; @@ -121,10 +218,10 @@ export function resolveSelectableModelSelection( const driver = provider?.driver ?? config.settings?.providerInstances[selection.instanceId]?.driver; if (driver === "antigravity") { - return selection; + return isProjectDisabledOverride(config, selection.instanceId, projectId) ? null : selection; } return provider && - provider.enabled && + isProviderEnabledForProject(config, provider, projectId) && provider.installed && provider.auth.status !== "unauthenticated" ? selection @@ -135,12 +232,15 @@ 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, ): ModelSelection | null { - const usable = resolveSelectableModelSelection(config, selection); + const usable = resolveSelectableModelSelection(config, selection, projectId); if (!usable || !config) { return usable; } @@ -165,10 +265,14 @@ export function resolveNewTaskModelSelection(input: { ); } +/** + * `projectId` has no default; see `isModelSelectionUnavailable`. + */ export function buildModelOptions( config: T3ServerConfig | null | undefined, fallbackModelSelection: ModelSelection | null, - providerInstanceId?: ModelSelection["instanceId"], + providerInstanceId: ModelSelection["instanceId"] | undefined, + projectId: ProjectId | null, includeHidden = false, ): ReadonlyArray { const options = new Map(); @@ -176,7 +280,7 @@ export function buildModelOptions( for (const provider of config?.providers ?? []) { if ( (providerInstanceId !== undefined && provider.instanceId !== providerInstanceId) || - !provider.enabled || + !isProviderEnabledForProject(config, provider, projectId) || !provider.installed || provider.auth.status === "unauthenticated" || (provider.driver === "antigravity" && provider.availability === "unavailable") @@ -271,7 +375,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/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 172f647cff0f..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, @@ -616,12 +619,14 @@ export function useThreadComposerState() { const serverConfig = selectedEnvironmentRuntime?.serverConfig; if ( selectedEnvironmentRuntime?.connectionState === "connected" && - isModelSelectionUnavailable(serverConfig, modelSelection) + 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 66744fa0c927..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, @@ -811,11 +814,13 @@ export function useThreadOutboxDrain(): void { ); if (!serverConfig) return false; const settings = resolveQueuedThreadSettings(queuedMessage, thread, serverConfig.providers); - if (isModelSelectionUnavailable(serverConfig, settings.modelSelection)) { - return restoreQueuedMessage( - queuedMessage, - "Antigravity model unavailable. Set it up on web or desktop, or choose another model.", + if (isModelSelectionUnavailable(serverConfig, settings.modelSelection, thread.projectId)) { + const { title, detail } = describeUnavailableModelSelection( + serverConfig, + settings.modelSelection, + thread.projectId, ); + return restoreQueuedMessage(queuedMessage, `${title}. ${detail}`); } const { reportFailure } = makeDeliveryHelpers(queuedMessage); @@ -893,11 +898,13 @@ export function useThreadOutboxDrain(): void { serverEnvironment.configValueAtom(queuedMessage.environmentId), ); if (!currentConfig) return false; - if (isModelSelectionUnavailable(currentConfig, settings.modelSelection)) { - return restoreQueuedMessage( - persistedMessage, - "Antigravity model unavailable. Set it up on web or desktop, or choose another model.", + if (isModelSelectionUnavailable(currentConfig, settings.modelSelection, thread.projectId)) { + const { title, detail } = describeUnavailableModelSelection( + currentConfig, + settings.modelSelection, + thread.projectId, ); + return restoreQueuedMessage(persistedMessage, `${title}. ${detail}`); } const sendSettings = resolveQueuedThreadSettings( queuedMessage, @@ -990,11 +997,13 @@ export function useThreadOutboxDrain(): void { }, serverConfig.providers, ); - if (isModelSelectionUnavailable(serverConfig, settings.modelSelection)) { - return restoreQueuedMessage( - queuedMessage, - "Antigravity model unavailable. Set it up on web or desktop, or choose another model.", + if (isModelSelectionUnavailable(serverConfig, settings.modelSelection, creation.projectId)) { + const { title, detail } = describeUnavailableModelSelection( + serverConfig, + settings.modelSelection, + creation.projectId, ); + return restoreQueuedMessage(queuedMessage, `${title}. ${detail}`); } let prepared: PreparedTurnAttachments; let persistedMessage: QueuedThreadMessage; @@ -1035,11 +1044,13 @@ export function useThreadOutboxDrain(): void { serverEnvironment.configValueAtom(queuedMessage.environmentId), ); if (!currentConfig) return false; - if (isModelSelectionUnavailable(currentConfig, settings.modelSelection)) { - return restoreQueuedMessage( - persistedMessage, - "Antigravity model unavailable. Set it up on web or desktop, or choose another model.", + if (isModelSelectionUnavailable(currentConfig, settings.modelSelection, creation.projectId)) { + const { title, detail } = describeUnavailableModelSelection( + currentConfig, + settings.modelSelection, + creation.projectId, ); + return restoreQueuedMessage(persistedMessage, `${title}. ${detail}`); } const sendSettings = resolveQueuedThreadSettings( queuedMessage, 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/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/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 6079ee714f69..e2a594fd8436 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, @@ -2921,6 +2922,7 @@ export const make = Effect.gen(function* () { modelSelection: ServerSettings.resolveSourceControlWriterModelSelection( settings, providers, + projectId, ), style: settings.sourceControlWritingStyle, })), diff --git a/apps/server/src/mcp/McpToolAccess.race.test.ts b/apps/server/src/mcp/McpToolAccess.race.test.ts index 46522810f707..362527324ae9 100644 --- a/apps/server/src/mcp/McpToolAccess.race.test.ts +++ b/apps/server/src/mcp/McpToolAccess.race.test.ts @@ -17,10 +17,12 @@ 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"; 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 +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( + 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", () => @@ -195,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( + Effect.provide( + ThreadManagement.layer.pipe( + Layer.provide(ServerSettings.layerTest()), + Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), + ), + ), + ), ); }); diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index d0a85e352a29..1b68f710d6df 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -1099,6 +1099,8 @@ 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( threadId === parentThreadId @@ -1197,6 +1199,9 @@ 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) => Effect.succeed( threadId === parentThreadId @@ -1275,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(), @@ -1321,6 +1327,284 @@ 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)({ + hasReceipt: () => Effect.succeed(false), + getThreadRecords: () => Effect.succeed(parentProjection([])), + // 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: codexInstanceId, + }), + ), + }), + 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)({}), + ServerSettings.layerTest(), + ); + + 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("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)({ + hasReceipt: () => Effect.succeed(false), + 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)({}), + ServerSettings.layerTest(), + ); + + 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( + "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)({}), + ServerSettings.layerTest(), + ); + + 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"); @@ -1368,6 +1652,8 @@ 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( threadId === parentThreadId ? parentProjection([task]) : childProjection, @@ -1513,6 +1799,9 @@ 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) => Effect.succeed( threadId === parentThreadId diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 64c8cc020086..f9bb61fa810e 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); } } @@ -1135,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 ?? @@ -1158,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; @@ -1179,20 +1204,37 @@ 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 = + input.skipProjectEnablementFilter === true || + (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.`, ); } } @@ -1925,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, @@ -1939,12 +1993,9 @@ 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", - }); + // 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", @@ -1966,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( @@ -2230,6 +2283,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/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index d0b1753eddc0..aeae1a94c68b 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -54,9 +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"; @@ -70,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"; @@ -612,7 +614,13 @@ 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()), + Layer.provide( + CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), + ), + ), ); const layerProviderRegistry = ProviderRegistryMock.layer([ makeProviderSnapshot({ @@ -3809,7 +3817,13 @@ 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()), + Layer.provide( + CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), + ), + ), ); 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..c3bd9f0af5b4 100644 --- a/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts +++ b/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts @@ -22,18 +22,20 @@ 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"; 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 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"; 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"; @@ -133,7 +135,13 @@ 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()), + Layer.provide( + CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), + ), + ), ); const layerTest = OrchestratorMcpService.layer.pipe( Layer.provideMerge(layerOrchestration), 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/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/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/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..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"; @@ -38,6 +39,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 +315,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: () => @@ -321,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/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/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index de38ae792388..d85504bb264e 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, + resolveProviderInstanceEnabled, 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,29 @@ 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. 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 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 || (failureClass !== undefined && failureClass !== "validation_error" && failedRun?.providerInstanceId === queuedRun.providerInstanceId) @@ -11437,6 +11461,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/ProviderContinuationService.test.ts b/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts index 3cc41af2e7ab..689c66a1d402 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, @@ -11,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"; @@ -529,6 +532,191 @@ describe("ProviderContinuationService", () => { }); }); + it.effect( + "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: (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( + 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)); + + // 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, + ); + }); + }, + ); + + 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( + "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 8e97da83df97..c0b0c55b7221 100644 --- a/apps/server/src/orchestration-v2/ProviderContinuationService.ts +++ b/apps/server/src/orchestration-v2/ProviderContinuationService.ts @@ -126,22 +126,77 @@ 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 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( + "orchestration-v2.provider-continuation.delegated-completion-disabled", + { + threadId: request.threadId, + projectId: error.projectId, + 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() + : request.dispatchIfCurrent !== undefined + ? request.dispatchIfCurrent(Effect.void) + : Effect.void, + ), + ), + }), + ); yield* clearRetryAttempt(retryKey); return; } @@ -174,11 +229,40 @@ export const layer = Layer.effectDiscard( // 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 + // 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, + ) => + Effect.logInfo("orchestration-v2.provider-continuation.instance-disabled", { + threadId: request.threadId, + 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; + yield* dispatch.pipe( + Effect.catchTags({ + ThreadManagementProviderInstanceDisabledError: logInstanceDisabled, + }), + ); return; } - yield* request.dispatchIfCurrent(dispatch); + yield* request.dispatchIfCurrent(dispatch).pipe( + Effect.catchTags({ + ThreadManagementProviderInstanceDisabledError: logInstanceDisabled, + }), + ); }, ); 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.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index f7eede36b8e4..3012e09411ac 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -118,8 +118,13 @@ function makeHarness(options: HarnessOptions = {}) { layerRegistry, { databaseLayer: layerDatabase, runEffectWorker: false }, ); - const layerThreadManagement = ThreadManagement.layer.pipe(Layer.provide(layerOrchestrator)); + 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 layerOutbox = EffectOutbox.layer.pipe(Layer.provide(layerDatabase)); const createWorktree = vi.fn( options.createWorktree ?? @@ -185,7 +190,7 @@ function makeHarness(options: HarnessOptions = {}) { generateThreadTitle, generateBranchName, }), - ServerSettings.layerTest(options.serverSettings), + layerServerSettings, ProviderRegistryMock.layer(options.providers), options.managedFolders ?? Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ @@ -1765,6 +1770,70 @@ 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]: { providerInstances: { [modelSelection.instanceId]: { enabled: 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("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]: { providerInstances: { [modelSelection.instanceId]: { enabled: 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* () { @@ -1782,7 +1851,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)); @@ -1804,7 +1875,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."); @@ -1828,7 +1901,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."); @@ -1869,7 +1944,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); @@ -1914,7 +1991,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); @@ -2530,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]: { providerInstances: { [modelSelection.instanceId]: { enabled: 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]: { providerInstances: { [modelSelection.instanceId]: { enabled: 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 d1a432bc9b52..42b1d9cef1e4 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 @@ -177,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") {} @@ -341,6 +348,7 @@ const make = Effect.gen(function* () { : ServerSettings.resolveSourceControlWriterModelSelection( settings, yield* providerRegistry.getProviders, + input.projectId, ); return yield* textGeneration .generateBranchName({ @@ -764,6 +772,11 @@ const make = Effect.gen(function* () { } const launchReceipt = yield* readReceipt(input, input.commandId); + 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 b4694a19863c..fb6e9af45424 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -31,6 +31,9 @@ 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 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"; @@ -273,6 +276,206 @@ it("derives thread management messages from structural error attributes", () => ); }); +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 = layerThreadManagementTest( + ServerSettings.layerTest({ + providerInstances: { [instanceId]: { driver: "codex", enabled: true } }, + projectSettingsOverrides: { + [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, + }, + }), + ); + + return Effect.gen(function* () { + const threads = yield* ThreadManagementService.ThreadManagementService; + const error = yield* Effect.flip( + threads.assertProviderInstanceEnabled({ projectId, commandId, 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 commandId = CommandId.make("command:thread-management:gate-enabled"); + const layerTest = layerThreadManagementTest( + ServerSettings.layerTest({ + providerInstances: { [instanceId]: { driver: "codex", enabled: false } }, + projectSettingsOverrides: { + [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, + }, + }), + ); + + return Effect.gen(function* () { + 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 = layerThreadManagementTest( + ServerSettings.layerTest({ + providerInstances: { [instanceId]: { driver: "codex", enabled: false } }, + }), + ); + + return Effect.gen(function* () { + const threads = yield* ThreadManagementService.ThreadManagementService; + const error = yield* Effect.flip( + threads.assertProviderInstanceEnabled({ projectId, commandId, instanceId }), + ); + + expect(error).toBeInstanceOf( + ThreadManagementService.ThreadManagementProviderInstanceDisabledError, + ); + }).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"); @@ -288,6 +491,8 @@ it.effect("classifies projection infrastructure failures separately from a missi getThreadProjection: () => Effect.fail(projectionError), }), ), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(layerReceiptsTest), ); return Effect.gen(function* () { @@ -322,6 +527,8 @@ it.effect("uses thread-not-found only after a projection loads outside the proje getThreadProjection: () => Effect.succeed(projection), }), ), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(layerReceiptsTest), ); return Effect.gen(function* () { @@ -354,6 +561,8 @@ 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; @@ -402,6 +611,8 @@ it.effect.each([ }), }), ), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(layerReceiptsTest), ); const service = yield* ThreadManagementService.ThreadManagementService.pipe( Effect.provide(layerTest), @@ -473,6 +684,8 @@ 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), @@ -617,6 +830,8 @@ it.effect.each([ }), ), ), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(layerReceiptsTest), ); yield* Effect.gen(function* () { const service = yield* ThreadManagementService.ThreadManagementService; @@ -735,6 +950,8 @@ 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 c1a542bf7761..a3d6f7b85260 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, @@ -41,12 +43,14 @@ 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"; 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 +270,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,6 +291,7 @@ export const ThreadManagementError = Schema.Union([ ThreadManagementProjectionLoadError, ThreadManagementProjectThreadsListError, ThreadManagementDurableRunProjectionError, + ThreadManagementProviderInstanceDisabledError, ]); export type ThreadManagementError = typeof ThreadManagementError.Type; @@ -290,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"]; @@ -340,6 +366,32 @@ export interface ThreadManagementServiceShape { readonly sendToThread: ( input: ThreadManagementSendInput, ) => Effect.Effect; + /** + * 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; + 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; + /** + * 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; @@ -365,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, @@ -386,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"]; @@ -458,6 +517,44 @@ 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 receipts = yield* CommandReceiptStore.CommandReceiptStoreV2; + + const assertProviderInstanceEnabled: ThreadManagementServiceShape["assertProviderInstanceEnabled"] = + (input) => + 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"] = + (input) => + serverSettings.getSettings.pipe( + Effect.orDie, + Effect.map((current) => + resolveProjectProviderInstanceEnabled(current, input.projectId, input.instanceId), + ), + ); + + const hasReceipt: ThreadManagementServiceShape["hasReceipt"] = (commandId) => + receipts.getByCommandId(commandId).pipe( + Effect.orElseSucceed(() => Option.none()), + Effect.map(Option.isSome), + ); const ensureLegacyTranscript = Effect.fn( "orchestrationV2.threadManagement.ensureLegacyTranscript", @@ -524,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( @@ -608,7 +738,6 @@ const make = Effect.gen(function* () { threadId: input.threadId, }); } - const steerableRun = latestSteerableRun(target); let dispatchMode: Extract< OrchestrationV2Command, @@ -633,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, @@ -685,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) => @@ -943,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; @@ -1013,6 +1144,9 @@ const make = Effect.gen(function* () { getThreadShell: orchestrator.getThreadShell, listProjectThreads, sendToThread, + assertProviderInstanceEnabled, + isProviderInstanceEnabledForProject, + hasReceipt, waitForThread, settleAfterRun, settleThread, @@ -1041,7 +1175,10 @@ const layerLegacyV1ThreadImporterNoop = Layer.succeed( export const layer: Layer.Layer< ThreadManagementService, never, - Orchestrator.OrchestratorV2 | ProviderSessionManager.ProviderSessionManagerV2 + | Orchestrator.OrchestratorV2 + | ProviderSessionManager.ProviderSessionManagerV2 + | ServerSettings.ServerSettingsService + | CommandReceiptStore.CommandReceiptStoreV2 > = Layer.effect(ThreadManagementService, make).pipe( Layer.provide(layerLegacyV1ThreadImporterNoop), Layer.provide(ThreadCommandExecutor.layer), @@ -1053,4 +1190,6 @@ export const layerWithLegacyImporter: Layer.Layer< | LegacyV1ThreadImporter.LegacyV1ThreadImporter | 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.test.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts index 81dd25847799..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, @@ -20,16 +23,17 @@ 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, } 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-", -}).pipe(Layer.provideMerge(NodeServices.layer)); +}).pipe(Layer.provideMerge(NodeServices.layer), Layer.provideMerge(ServerSettings.layerTest())); const layerFailingDispatch = (captured: OrchestrationV2ServerCommand[]) => Layer.mock(ThreadManagementService.ThreadManagementService)({ @@ -764,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 c8f65fd0340a..21540703ffbc 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts @@ -13,6 +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 comes from ThreadManagementService.dispatch's +// own enablement check, which also runs before any commit is attempted. function dispatchWasNotAccepted( error: Orchestrator.OrchestratorV2Error | ThreadManagement.ThreadManagementError, ) { @@ -24,12 +26,14 @@ function dispatchWasNotAccepted( case "OrchestratorCommandIdConflictError": case "OrchestratorSubagentThreadReadOnlyError": case "OrchestratorThreadAboveModeLimitError": + case "ThreadManagementProviderInstanceDisabledError": return true; default: return false; } } const isOrchestratorError = Schema.is(Orchestrator.OrchestratorV2Error); +const isThreadManagementError = Schema.is(ThreadManagement.ThreadManagementError); const releaseUnusedClaims = Effect.fn("ThreadMessageIntake.releaseUnusedClaims")(function* ( claimedPaths: ReadonlyArray, @@ -221,13 +225,19 @@ export const launchThread = Effect.fn("ThreadMessageIntake.launchThread")(functi ), ), Effect.tapError((error) => { + 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. + // 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/ThreadStop.test.ts b/apps/server/src/orchestration-v2/ThreadStop.test.ts index 6b058f25a7e8..c0732ba2a54c 100644 --- a/apps/server/src/orchestration-v2/ThreadStop.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStop.test.ts @@ -21,8 +21,10 @@ 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 * as CommandReceiptStore from "./CommandReceiptStore.ts"; import { OrchestrationEffectRequestV2 } from "./EffectOutbox.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -54,6 +56,8 @@ 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 06f7aa2488c8..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"; @@ -54,7 +55,12 @@ 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), + Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), + ); const layerOutbox = EffectOutbox.layer.pipe(Layer.provide(layerDatabase)); const generateThreadTitle = vi.fn( options.generateTitle ?? (() => Effect.succeed({ title: "Generated title" })), @@ -86,7 +92,7 @@ function makeHarness( layerThreadManagement, layerProjectedProjects, Layer.mock(TextGeneration.TextGeneration)({ generateThreadTitle }), - ServerSettings.layerTest({}), + layerServerSettings, ), ), ); 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/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/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/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, ), ), 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..05bad7f0a18f --- /dev/null +++ b/apps/server/src/provider/ProjectSettingsOverrideSweep.ts @@ -0,0 +1,61 @@ +/** + * Removes `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/provider/ProviderInstanceRegistryHydration.test.ts b/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts new file mode 100644 index 000000000000..16b3958f3613 --- /dev/null +++ b/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts @@ -0,0 +1,122 @@ +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]: { providerInstances: { [instanceId]: { enabled: true } } }, + }, + }); + + const merged = deriveProviderInstanceConfigMap(settings); + + 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]: { providerInstances: { [instanceId]: { enabled: 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: { + [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, + }, + projectSettingsOverrides: { + [projectId]: { providerInstances: { [instanceId]: { enabled: 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]: { providerInstances: { [instanceId]: { enabled: 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]: { providerInstances: { [instanceId]: { enabled: 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]: { providerInstances: { [instanceId]: { enabled: true } } }, + [otherProjectId]: { providerInstances: { [otherInstanceId]: { enabled: 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..7940ea56220e 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts @@ -25,8 +25,11 @@ */ import { defaultInstanceIdForDriver, + providerInstanceConfigEnabledFlag, + resolveProviderInstanceEnabled, type ProviderInstanceConfig, type ProviderInstanceConfigMap, + type ProviderInstanceId, ServerSettings, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -73,6 +76,29 @@ 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 + // `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, projectOverride] of Object.entries( + overrides?.providerInstances ?? {}, + )) { + if (projectOverride.enabled !== true) continue; + const instanceId = rawInstanceId as ProviderInstanceId; + const existing = merged[instanceId]; + if (existing === undefined || resolveProviderInstanceEnabled(existing)) continue; + merged[instanceId] = { + ...existing, + enabled: true, + ...(providerInstanceConfigEnabledFlag(existing.config) === false + ? { config: { ...(existing.config as Record), enabled: true } } + : {}), + }; + } + } + return merged as ProviderInstanceConfigMap; }; 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/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index bc26ef7f021a..efb9ec3995b1 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -208,6 +208,9 @@ const makeTestRelay = Effect.fnUntraced(function* ( getProjectThread: unused, listProjectThreads: unused, sendToThread: unused, + assertProviderInstanceEnabled: unused, + isProviderInstanceEnabledForProject: unused, + hasReceipt: unused, waitForThread: unused, settleAfterRun: unused, settleThread: unused, 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), 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/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 0d39ee4ee79e..ca50480969dc 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,15 @@ 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) => - resolveProviderInstanceEnabled( - settings.providerInstances[defaultInstanceIdForDriver(driver)] ?? { driver, config: {} }, - ), + // A slot without an explicit instance uses its driver's default enabled state. + const fallback = fallbackTextGenerationModelSelection( + settings.providerInstances, + (instanceId, driver) => + resolveProviderInstanceEnabled( + 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/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..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, @@ -3241,9 +3242,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( () => @@ -4359,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" && @@ -11759,6 +11771,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/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/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..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, @@ -55,7 +57,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"; @@ -472,6 +474,93 @@ 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. + */ +/** + * 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; + readonly onChange: (next: boolean) => void; + readonly onReset: () => void; +} + interface ProviderInstanceCardProps { readonly instanceId: ProviderInstanceId; readonly instance: ProviderInstanceConfig; @@ -515,6 +604,8 @@ interface ProviderInstanceCardProps { readonly isUpdating?: boolean | undefined; readonly onAcceptUrlAuth?: ((action: AcpRegistryUrlAuthAction) => void) | undefined; readonly environmentId?: EnvironmentId | undefined; + /** Omit this prop entirely for environment-wide editing. */ + readonly projectOverride?: ProjectOverrideControl | undefined; readonly acpProjects?: | ReadonlyArray<{ readonly id: ProjectId; @@ -544,6 +635,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 `projectOverride` set, the Switch instead reads and writes + * that project's override, leaving the environment's `instance.enabled` + * untouched. */ export function ProviderInstanceCard({ instanceId, @@ -573,8 +667,9 @@ export function ProviderInstanceCard({ onAcceptUrlAuth, environmentId, acpProjects = EMPTY_ACP_PROJECTS, + projectOverride, }: ProviderInstanceCardProps) { - const enabled = 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. @@ -582,8 +677,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"; @@ -670,6 +766,10 @@ export function ProviderInstanceCard({ }; const updateEnabled = (value: boolean) => { + if (projectOverride) { + projectOverride.onChange(value); + return; + } onUpdate({ ...instance, enabled: value }); }; @@ -968,10 +1068,24 @@ export function ProviderInstanceCard({ - + + {projectOverride?.value !== undefined ? ( + + + + + ) : null} updateEnabled(Boolean(checked))} aria-label={`Enable ${displayName}`} /> 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"; diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts index 5af0c709c269..d3d918521ba2 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts @@ -1,8 +1,15 @@ -import { AuthProvidersManageScope, EnvironmentId } from "@t3tools/contracts"; +import { + AuthProvidersManageScope, + EnvironmentId, + ProjectId, + ProviderInstanceId, + type ServerSettings, +} from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { buildProviderEnvironmentOptions, + buildProviderInstanceOverridePatch, classifyProviderEnvironmentAccess, isProviderSettingsEnvironmentAvailable, resolvePrimaryOperateAccess, @@ -227,6 +234,118 @@ describe("primary operate access", () => { }); }); +describe("buildProviderInstanceOverridePatch", () => { + const projectId = ProjectId.make("project-a"); + 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( + buildProviderInstanceOverridePatch(settings, [projectId, siblingId], instanceId, false), + ).toEqual({ + [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, + [siblingId]: { + newWorktreesStartFromOrigin: true, + providerInstances: { [instanceId]: { enabled: false } }, + }, + }); + }); + + it("sets an override when the project has none yet", () => { + const settings: Pick = { + projectSettingsOverrides: {}, + }; + + expect(buildProviderInstanceOverridePatch(settings, [projectId], instanceId, true)).toEqual({ + [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, + }); + }); + + it("adds an instance override alongside the project's other overrides", () => { + const settings: Pick = { + projectSettingsOverrides: { + [projectId]: { + newWorktreesStartFromOrigin: true, + providerInstances: { [otherInstanceId]: { enabled: false } }, + }, + }, + }; + + expect(buildProviderInstanceOverridePatch(settings, [projectId], instanceId, true)).toEqual({ + [projectId]: { + newWorktreesStartFromOrigin: true, + providerInstances: { + [otherInstanceId]: { enabled: false }, + [instanceId]: { enabled: true }, + }, + }, + }); + }); + + it("resets the only override by clearing the whole project entry", () => { + const settings: Pick = { + projectSettingsOverrides: { + [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, + }, + }; + + expect( + buildProviderInstanceOverridePatch(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, + providerInstances: { + [instanceId]: { enabled: true }, + [otherInstanceId]: { enabled: false }, + }, + }, + }, + }; + + expect( + buildProviderInstanceOverridePatch(settings, [projectId], instanceId, undefined), + ).toEqual({ + [projectId]: { + newWorktreesStartFromOrigin: true, + providerInstances: { [otherInstanceId]: { enabled: false } }, + }, + }); + }); + + 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", () => { 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..3d105faf6487 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,44 @@ 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. + * + * `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( + settings: Pick, + projectIds: readonly ProjectId[], + instanceId: ProviderInstanceId, + value: boolean | undefined, +): Record { + const patch: Record = {}; + for (const projectId of projectIds) { + const current = settings.projectSettingsOverrides[projectId]; + 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 { providerInstances: _omit, ...rest } = current ?? {}; + const nextEntry: ProjectSettingsOverrides = { ...rest, providerInstances: instances }; + patch[projectId] = + Object.keys(rest).length === 0 && Object.keys(instances).length === 0 ? null : nextEntry; + } + return patch; +} + 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..7941e252d200 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"; @@ -54,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 { @@ -116,6 +118,7 @@ import { } from "./settingsLayout"; import { buildProviderEnvironmentOptions, + buildProviderInstanceOverridePatch, classifyProviderEnvironmentAccess, isProviderSettingsEnvironmentAvailable, type ProviderEnvironmentAccess, @@ -279,6 +282,11 @@ interface ProviderSettingsTarget { readonly instanceId?: ProviderInstanceId; readonly scoped?: boolean; readonly environmentIds?: readonly EnvironmentId[]; + /** + * 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) { @@ -450,6 +458,7 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) { ? target.instanceId : undefined } + projectIds={target.projectIds ?? []} /> ) : null} @@ -460,16 +469,19 @@ function SelectedEnvironmentProviderSettings({ environment, deviceTabs, targetInstanceId, + projectIds, }: { readonly environment: EnvironmentPresentation; readonly deviceTabs?: ReactNode; readonly targetInstanceId?: ProviderInstanceId | undefined; + readonly projectIds: readonly ProjectId[]; }) { return ( ); } @@ -478,10 +490,12 @@ function RemoteSessionGatedProviderSettings({ environment, deviceTabs, targetInstanceId, + projectIds, }: { readonly environment: EnvironmentPresentation; readonly deviceTabs?: ReactNode; readonly targetInstanceId?: ProviderInstanceId | undefined; + readonly projectIds: readonly ProjectId[]; }) { const sessionState = useEnvironmentSessionState(environment.environmentId); const operateAccess = resolveRemoteOperateAccess({ @@ -495,6 +509,7 @@ function RemoteSessionGatedProviderSettings({ operateAccess={operateAccess} deviceTabs={deviceTabs} targetInstanceId={targetInstanceId} + projectIds={projectIds} /> ); } @@ -504,11 +519,13 @@ function AccessGatedProviderSettings({ operateAccess, deviceTabs, targetInstanceId, + projectIds, }: { readonly environment: EnvironmentPresentation; readonly operateAccess: ProviderOperateAccess; readonly deviceTabs?: ReactNode; readonly targetInstanceId?: ProviderInstanceId | undefined; + readonly projectIds: readonly ProjectId[]; }) { const access = classifyProviderEnvironmentAccess({ connectionPhase: environment.connection.phase, @@ -531,6 +548,7 @@ function AccessGatedProviderSettings({ readOnly={access.kind === "read-only"} deviceTabs={deviceTabs} targetInstanceId={targetInstanceId} + projectIds={projectIds} /> ); } @@ -541,11 +559,14 @@ export function EnvironmentProviderSettings({ readOnly = false, deviceTabs, targetInstanceId, + projectIds = [], }: { readonly environmentId: EnvironmentId; readonly environmentLabel: string; readonly deviceTabs?: ReactNode; readonly targetInstanceId?: ProviderInstanceId | undefined; + /** 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 @@ -564,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, }); @@ -901,12 +928,40 @@ 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 writeProjectOverride = (next: boolean | undefined) => { + updateSettings({ + projectSettingsOverrides: buildProviderInstanceOverridePatch( + settings, + projectIds, + row.instanceId, + next, + ), + }); + }; + const projectOverride = + representativeProjectId === undefined || !supportsProjectProviderOverrides + ? undefined + : { + value: + settings.projectSettingsOverrides[representativeProjectId]?.providerInstances?.[ + row.instanceId + ]?.enabled, + effectiveEnabled: resolveProjectProviderInstanceEnabled( + settings, + representativeProjectId, + row.instanceId, + ), + onChange: (next: boolean) => writeProjectOverride(next), + onReset: () => writeProjectOverride(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..992bccd78bc0 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,13 +820,29 @@ 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. 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/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..1a6b70c50452 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; @@ -106,20 +106,37 @@ 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( { ...settings, textGenerationModelSelection: resolveSourceControlWriterModelSelection( settings, - textGenerationProviders, + projectEffectiveTextGenerationProviders, + target?.projectId ?? null, ), }, - textGenerationProviders, - ); - const instanceEntries = sortProviderInstanceEntries( - applyProviderInstanceSettings(deriveProviderInstanceEntries(textGenerationProviders), settings), + projectEffectiveTextGenerationProviders, ); const canEnableDedicatedModel = instanceEntries.some( (entry) => @@ -127,7 +144,7 @@ export function SourceControlWritingSettingsSection() { ); const modelOptionsByInstance = getCustomModelOptionsByInstance( settings, - textGenerationProviders, + projectEffectiveTextGenerationProviders, activeSelection.instanceId, activeSelection.model, ); @@ -139,6 +156,7 @@ export function SourceControlWritingSettingsSection() { textGenerationModelSelection: resolveReviewGuideModelSelection( settings, textGenerationProviders, + target?.projectId ?? null, ), }, textGenerationProviders, 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..f1adc575bae9 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -136,14 +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: {}, }, - }); + null, + ); expect(entry?.enabled).toBe(false); }); @@ -155,9 +160,14 @@ describe("applyProviderInstanceSettings", () => { instanceId: "claude_work", }), ]); - const [entry] = applyProviderInstanceSettings(entries, { - providerInstances: {}, - }); + const [entry] = applyProviderInstanceSettings( + entries, + { + providerInstances: {}, + projectSettingsOverrides: {}, + }, + null, + ); expect(entry?.enabled).toBe(false); }); @@ -171,9 +181,14 @@ describe("applyProviderInstanceSettings", () => { instanceId, }), ]); - const [entry] = applyProviderInstanceSettings(entries, { - providerInstances: {}, - }); + const [entry] = applyProviderInstanceSettings( + entries, + { + providerInstances: {}, + projectSettingsOverrides: {}, + }, + null, + ); expect(entry?.enabled).toBe(false); }, @@ -187,14 +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: {}, }, - }); + null, + ); expect(entry?.enabled).toBe(false); }); @@ -207,9 +227,14 @@ describe("applyProviderInstanceSettings", () => { instanceId: "constructor", }), ]); - const [entry] = applyProviderInstanceSettings(entries, { - providerInstances: {}, - }); + const [entry] = applyProviderInstanceSettings( + entries, + { + providerInstances: {}, + projectSettingsOverrides: {}, + }, + null, + ); expect(entry?.isDefault).toBe(true); expect(entry?.enabled).toBe(false); @@ -220,7 +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: {} }); + 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); @@ -260,6 +292,7 @@ describe("deriveProviderEntriesByEnvironment", () => { config: { agentId, registryIconUrl: `https://example.com/${agentId}.svg` }, }, }, + projectSettingsOverrides: {}, }, ] as const, ), @@ -618,29 +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 } }, - }); + 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: {}, }, - }); + 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 } }, - }); + 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 d6c6c5e2a945..a8979aa4e257 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,13 +154,19 @@ export function deriveProviderInstanceEntries( */ export function deriveProviderEntriesByEnvironment( providersByEnvironment: Iterable< - readonly [string, ReadonlyArray, Pick?] + readonly [ + string, + ReadonlyArray, + Pick?, + ] >, ): ReadonlyMap> { 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)), @@ -179,20 +185,25 @@ 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 `providerInstances[id].enabled` override + * (enable or disable in either direction over the machine value); `null` + * 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, + settings: Pick, + projectId: ProjectId | 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..dc8474d73ca0 100644 --- a/apps/web/src/routes/settings.providers.tsx +++ b/apps/web/src/routes/settings.providers.tsx @@ -25,6 +25,13 @@ function SettingsProvidersRoute() { member.environmentId === environment.environmentId) + .map((member) => member.id) + : [] + } scoped /> ); diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index c92226a9ee4f..7506b92f725d 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -27,6 +27,14 @@ 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 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 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/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.test.ts b/packages/contracts/src/settings.test.ts index 60145dba1a05..deafcf507165 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]: { providerInstances: { [instanceId]: { enabled: 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]: { providerInstances: { [instanceId]: { enabled: 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]: { providerInstances: { [instanceId]: { enabled: 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]: { providerInstances: { [instanceId]: { enabled: 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..2d3fd53e31f1 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -972,10 +972,24 @@ 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). + * + * `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({ worktreeCleanup: Schema.optionalKey(WorktreeCleanup), @@ -1002,7 +1016,10 @@ export const ProjectSettingsOverrides = Schema.Struct({ sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), responseStreamingMode: Schema.optionalKey(ResponseStreamingMode), -} satisfies Record); + providerInstances: Schema.optionalKey( + Schema.Record(ProviderInstanceId, ProjectProviderInstanceOverride), + ), +} satisfies Record); export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type; /** @@ -1126,10 +1143,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 `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. */ projectSettingsOverrides: Schema.Record(ProjectId, ProjectSettingsOverrides).pipe( Schema.withDecodingDefault(Effect.succeed({})), @@ -1371,6 +1389,30 @@ export const resolveProviderInstanceEnabled = ( return instance.enabled ?? configEnabled ?? defaultEnabledForDriver(instance.driver); }; +/** + * Effective enablement for a provider instance within a project: 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. + */ +export const resolveProjectProviderInstanceEnabled = ( + settings: Pick, + projectId: ProjectId | null, + instanceId: ProviderInstanceId, +): boolean => { + const override = + projectId === null + ? undefined + : settings.projectSettingsOverrides[projectId]?.providerInstances?.[instanceId]?.enabled; + 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", @@ -1498,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/contracts/src/t3ProjectFile.ts b/packages/contracts/src/t3ProjectFile.ts index 5b3274be6348..eef374043fee 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]?: { + // `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 | Exclude diff --git a/packages/shared/src/projectSettings.test.ts b/packages/shared/src/projectSettings.test.ts index 58fa99da4ea4..89d38cae5d1c 100644 --- a/packages/shared/src/projectSettings.test.ts +++ b/packages/shared/src/projectSettings.test.ts @@ -147,6 +147,44 @@ 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]: { providerInstances: { [codex]: { enabled: false } } }, + }, + }; + expect( + resolveProjectSettings(settings, projectId).settings.textGenerationModelSelection.instanceId, + ).toBe(ProviderInstanceId.make("claudeAgent")); + expect( + 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]: { + providerInstances: { + [codex]: { enabled: false }, + [ProviderInstanceId.make("claudeAgent")]: { enabled: 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 2165fc8310ab..7bc976ad0adc 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,7 @@ import { type WorktreeCleanupRules, } from "@t3tools/contracts"; import { normalizeProjectPathForComparison } from "./path.ts"; -import { isModelSelectionProviderEnabled } from "./serverSettings.ts"; +import { fallbackTextGenerationModelSelection } from "./serverSettings.ts"; /** * Where a project-scoped value came from. The order is the priority order: @@ -174,18 +175,34 @@ 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 `providerInstances[id].enabled` 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; } 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( + settings.providerInstances, + (instanceId) => resolveProjectProviderInstanceEnabled(settings, projectId, instanceId), + { includeConfiguredInstances: true }, + ); + if (fallback !== null) effective.textGenerationModelSelection = fallback; + } return { settings: effective as ServerSettings, sources, overrides }; } diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 8a0d21470d6b..ab5b3db0cd7e 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, { @@ -442,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); @@ -477,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); @@ -512,12 +539,86 @@ describe("serverSettings helpers", () => { skills: [], } satisfies ServerProvider; - expect(resolveSourceControlWriterModelSelection(settings, [incapableProvider])).toBe( + expect(resolveSourceControlWriterModelSelection(settings, [incapableProvider], null)).toBe( settings.textGenerationModelSelection, ); 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]: { providerInstances: { [instanceId]: { enabled: 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, [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]: { providerInstances: { [instanceId]: { enabled: 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", () => { const codexId = ProviderInstanceId.make("codex"); const current = { @@ -552,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 65ddbcd9b5a7..acfca1a8a08f 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -1,13 +1,20 @@ 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, resolveProviderInstanceEnabled, isProviderTextGenerationCapable, type ModelSelection, type ProjectId, type ProjectScopedServerSettingKey, type ProjectSettingsOverrides, + ProviderDriverKind, + ProviderInstanceId, type ServerProvider, ServerSettings, type ServerSettingsPatch, @@ -71,12 +78,74 @@ 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 instance `isEnabled` accepts: the + * 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, + ), + ...(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; + const [instanceId, driver] = match; + return { + instanceId, + model: + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[driver] ?? + DEFAULT_MODEL_BY_PROVIDER[driver] ?? + DEFAULT_TEXT_GENERATION_MODEL, + }; +} + +/** + * `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, + providers: ReadonlyArray | undefined, + projectId: ProjectId | null, ): ModelSelection { const selection = settings.sourceControlWriterModelSelection; - if (!selection || !isModelSelectionProviderEnabled(settings, selection)) { + if ( + !selection || + !resolveProjectProviderInstanceEnabled(settings, projectId, selection.instanceId) + ) { return settings.textGenerationModelSelection; } if (providers === undefined) { @@ -97,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 || @@ -111,7 +184,7 @@ export function resolveReviewGuideModelSelection( return selection; } } - return resolveSourceControlWriterModelSelection(settings, providers); + return resolveSourceControlWriterModelSelection(settings, providers, projectId); } export interface PersistedServerObservabilitySettings { @@ -191,6 +264,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 @@ -463,7 +566,10 @@ export function applyServerSettingsPatch( ? { projectSettingsOverrides: Object.fromEntries( Object.entries( - mergeSettingsEntries(current.projectSettingsOverrides, projectSettingsOverridesPatch), + mergeProjectSettingsOverridesPatch( + current.projectSettingsOverrides, + projectSettingsOverridesPatch, + ), ).filter(([, entry]) => Object.keys(entry).length > 0), ), }