authorgravatar for yordis.prieto@gmail.comYordis Prieto <yordis.prieto@gmail.com> 2026-10-11 13:28:14-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 10:28:14-07:00
log8ac6846a0b855c3aad70adbf745ff113d4eb3f1f
tree531bb7453f6135f9f0f664eca7a0d1a38c8bac60
parent243c9994d047f3102a9622d1a5cff42ef78515ee
signature Signed by PGP key B5690EEEBB952194

feat(settings): providers can be turned on or off per project (#17879)

Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com> Co-authored-by: Julius Marminge <julius0216@outlook.com>

83 files changed, 3568 insertions(+), 394 deletions(-)

apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx+6-1
...@@ -25,7 +25,11 @@ export function ScheduledTaskModelPickerRouteScreen() {...@@ -25,7 +25,11 @@ export function ScheduledTaskModelPickerRouteScreen() {
25 const { editor, setEditor } = useScheduledTaskEditor();25 const { editor, setEditor } = useScheduledTaskEditor();
26 const config = useEnvironmentServerConfig(editor?.environmentId ?? null);26 const config = useEnvironmentServerConfig(editor?.environmentId ?? null);
27 const selectedModel = editor?.draft.modelSelection ?? null;27 const selectedModel = editor?.draft.modelSelection ?? null;
28 const models = useMemo(() => buildModelOptions(config, selectedModel), [config, selectedModel]);28 const projectId = editor?.draft.projectId ?? null;
29 const models = useMemo(
30 () => buildModelOptions(config, selectedModel, undefined, projectId),
31 [config, selectedModel, projectId],
32 );
29 const providerGroups = useMemo(() => groupByProvider(models), [models]);33 const providerGroups = useMemo(() => groupByProvider(models), [models]);
30 const selectedOption = models.find(34 const selectedOption = models.find(
31 (option) =>35 (option) =>
...@@ -46,6 +50,7 @@ export function ScheduledTaskModelPickerRouteScreen() {...@@ -46,6 +50,7 @@ export function ScheduledTaskModelPickerRouteScreen() {
46 return (50 return (
47 <ThreadSettingsPickerScreen51 <ThreadSettingsPickerScreen
48 environmentId={editor.environmentId}52 environmentId={editor.environmentId}
53 projectId={projectId}
49 providerGroups={providerGroups}54 providerGroups={providerGroups}
50 selectedModel={selectedModel}55 selectedModel={selectedModel}
51 onSelectModel={(option) =>56 onSelectModel={(option) =>
apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx+26-10
...@@ -1,5 +1,8 @@...@@ -1,5 +1,8 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { AuthOrchestrationOperateScope } from "@t3tools/contracts";2import {
3 AuthOrchestrationOperateScope,
4 resolveProjectProviderInstanceEnabled,
5} from "@t3tools/contracts";
3import { readEnvironmentScope } from "../../state/session";6import { readEnvironmentScope } from "../../state/session";
4import type {7import type {
5 EnvironmentId,8 EnvironmentId,
...@@ -582,7 +585,10 @@ function TaskForm({...@@ -582,7 +585,10 @@ function TaskForm({
582 );585 );
583 const projects = useProjects().filter((project) => project.environmentId === environmentId);586 const projects = useProjects().filter((project) => project.environmentId === environmentId);
584 const config = useEnvironmentServerConfig(environmentId);587 const config = useEnvironmentServerConfig(environmentId);
585 const modelOptions = useMemo(() => buildModelOptions(config, null), [config]);588 const modelOptions = useMemo(
589 () => buildModelOptions(config, null, undefined, draft.projectId),
590 [config, draft.projectId],
591 );
586 const canOperate = useAtomValue(592 const canOperate = useAtomValue(
587 serverEnvironment.upsertScheduledTask.permissionAtom(environmentId),593 serverEnvironment.upsertScheduledTask.permissionAtom(environmentId),
588 );594 );
...@@ -757,14 +763,24 @@ function TaskForm({...@@ -757,14 +763,24 @@ function TaskForm({
757 }))}763 }))}
758 onSelect={(id) => {764 onSelect={(id) => {
759 const project = projects.find((item) => item.id === id);765 const project = projects.find((item) => item.id === id);
760 if (project)766 if (!project) return;
761 setDraft({767 const keepsExplicitModel =
762 ...draft,768 draft.modelSelectionIsExplicit &&
763 projectId: project.id,769 (draft.modelSelection === null ||
764 modelSelection: draft.modelSelectionIsExplicit770 !config?.settings ||
765 ? draft.modelSelection771 resolveProjectProviderInstanceEnabled(
766 : scheduledTaskDefaultModel(config, project),772 config.settings,
767 });773 project.id,
774 draft.modelSelection.instanceId,
775 ));
776 setDraft({
777 ...draft,
778 projectId: project.id,
779 modelSelection: keepsExplicitModel
780 ? draft.modelSelection
781 : scheduledTaskDefaultModel(config, project),
782 modelSelectionIsExplicit: keepsExplicitModel,
783 });
768 }}784 }}
769 />785 />
770 <PickerRow786 <PickerRow
apps/mobile/src/features/settings/scheduledTaskDraft.test.ts+7
...@@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test";...@@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test";
2import {2import {
3 DEFAULT_SERVER_SETTINGS,3 DEFAULT_SERVER_SETTINGS,
4 type ServerConfig,4 type ServerConfig,
5 ProviderDriverKind,
5 ProviderInstanceId,6 ProviderInstanceId,
6 ProjectId,7 ProjectId,
7 ScheduledTaskId,8 ScheduledTaskId,
...@@ -339,6 +340,12 @@ describe("scheduled task model defaults", () => {...@@ -339,6 +340,12 @@ describe("scheduled task model defaults", () => {
339 resolve(340 resolve(
340 {341 {
341 ...config,342 ...config,
343 settings: {
344 ...config.settings,
345 providerInstances: {
346 [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false },
347 },
348 },
342 providers: config.providers.map((provider) => ({ ...provider, enabled: false })),349 providers: config.providers.map((provider) => ({ ...provider, enabled: false })),
343 },350 },
344 null,351 null,
apps/mobile/src/features/settings/scheduledTaskDraft.ts+8-3
...@@ -28,13 +28,18 @@ export function scheduledTaskDefaultModel(...@@ -28,13 +28,18 @@ export function scheduledTaskDefaultModel(
28 const configured = resolveProjectSettings(settings, project?.id ?? null, project).settings28 const configured = resolveProjectSettings(settings, project?.id ?? null, project).settings
29 .defaultModelSelection;29 .defaultModelSelection;
30 const projectDefaultSelection =30 const projectDefaultSelection =
31 resolveDefaultableModelSelection(config, configured) ??31 resolveDefaultableModelSelection(config, configured, project?.id ?? null) ??
32 resolveDefaultableModelSelection(config, settings.defaultModelSelection);32 resolveDefaultableModelSelection(config, settings.defaultModelSelection, project?.id ?? null);
33 return resolveNewTaskModelSelection({33 return resolveNewTaskModelSelection({
34 draftSelection: null,34 draftSelection: null,
35 projectDefaultSelection,35 projectDefaultSelection,
36 stickySelection: null,36 stickySelection: null,
37 modelOptions: buildModelOptions(config, projectDefaultSelection),37 modelOptions: buildModelOptions(
38 config,
39 projectDefaultSelection,
40 undefined,
41 project?.id ?? null,
42 ),
38 });43 });
39}44}
4045
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+12-4
...@@ -112,6 +112,7 @@ import { sourceControlEnvironment } from "../../state/sourceControl";...@@ -112,6 +112,7 @@ import { sourceControlEnvironment } from "../../state/sourceControl";
112import { useAtomCommand } from "../../state/use-atom-command";112import { useAtomCommand } from "../../state/use-atom-command";
113import { ProjectCloneBanner } from "../../components/ProjectCloneBanner";113import { ProjectCloneBanner } from "../../components/ProjectCloneBanner";
114import {114import {
115 describeUnavailableModelSelection,
115 isModelSelectionUnavailable,116 isModelSelectionUnavailable,
116 resolveSelectableModelSelection,117 resolveSelectableModelSelection,
117} from "../../lib/modelOptions";118} from "../../lib/modelOptions";
...@@ -1236,6 +1237,7 @@ export function NewTaskDraftScreen(props: {...@@ -1236,6 +1237,7 @@ export function NewTaskDraftScreen(props: {
1236 resolveSelectableModelSelection(1237 resolveSelectableModelSelection(
1237 selectedEnvironmentServerConfig,1238 selectedEnvironmentServerConfig,
1238 draft.modelSelection ?? null,1239 draft.modelSelection ?? null,
1240 selectedProject.id,
1239 ) ?? flow.selectedModel;1241 ) ?? flow.selectedModel;
1240 const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode;1242 const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode;
1241 const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName;1243 const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName;
...@@ -1252,12 +1254,18 @@ export function NewTaskDraftScreen(props: {...@@ -1252,12 +1254,18 @@ export function NewTaskDraftScreen(props: {
1252 }1254 }
1253 if (1255 if (
1254 environmentConnected &&1256 environmentConnected &&
1255 isModelSelectionUnavailable(selectedEnvironmentServerConfig, modelSelection)1257 isModelSelectionUnavailable(
1258 selectedEnvironmentServerConfig,
1259 modelSelection,
1260 selectedProject.id,
1261 )
1256 ) {1262 ) {
1257 Alert.alert(1263 const { title, detail } = describeUnavailableModelSelection(
1258 "Antigravity model unavailable",1264 selectedEnvironmentServerConfig,
1259 "Set up Antigravity on web or desktop, or choose another model.",1265 modelSelection,
1266 selectedProject.id,
1260 );1267 );
1268 Alert.alert(title, detail);
1261 return;1269 return;
1262 }1270 }
1263 // T3's own limits command is answered by the thread composer; a new task would1271 // T3's own limits command is answered by the thread composer; a new task would
apps/mobile/src/features/threads/ThreadComposer.tsx+20-3
...@@ -435,7 +435,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -435,7 +435,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
435 const currentRuntimeMode = props.selectedThread.runtimeMode;435 const currentRuntimeMode = props.selectedThread.runtimeMode;
436 const modelUnavailable =436 const modelUnavailable =
437 props.connectionState === "connected" &&437 props.connectionState === "connected" &&
438 isModelSelectionUnavailable(props.serverConfig, currentModelSelection);438 isModelSelectionUnavailable(
439 props.serverConfig,
440 currentModelSelection,
441 props.selectedThread.projectId,
442 );
439 const selectedProviderStatus = useMemo(() => {443 const selectedProviderStatus = useMemo(() => {
440 if (!props.serverConfig) return null;444 if (!props.serverConfig) return null;
441 return (445 return (
...@@ -651,8 +655,19 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -651,8 +655,19 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
651 ? undefined655 ? undefined
652 : currentModelSelection.instanceId;656 : currentModelSelection.instanceId;
653 const modelOptions = useMemo(657 const modelOptions = useMemo(
654 () => buildModelOptions(props.serverConfig, currentModelSelection, lockedProviderInstanceId),658 () =>
655 [props.serverConfig, currentModelSelection, lockedProviderInstanceId],659 buildModelOptions(
660 props.serverConfig,
661 currentModelSelection,
662 lockedProviderInstanceId,
663 props.selectedThread.projectId,
664 ),
665 [
666 props.serverConfig,
667 currentModelSelection,
668 lockedProviderInstanceId,
669 props.selectedThread.projectId,
670 ],
656 );671 );
657 const threadProviderGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]);672 const threadProviderGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]);
658 const currentModelOption =673 const currentModelOption =
...@@ -674,6 +689,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -674,6 +689,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
674 () => ({689 () => ({
675 ownerId: settingsOwnerId,690 ownerId: settingsOwnerId,
676 environmentId: props.environmentId,691 environmentId: props.environmentId,
692 projectId: props.selectedThread.projectId,
677 providerInstanceId: currentModelSelection.instanceId,693 providerInstanceId: currentModelSelection.instanceId,
678 ...(lockedProviderInstanceId ? { lockedProviderInstanceId } : {}),694 ...(lockedProviderInstanceId ? { lockedProviderInstanceId } : {}),
679 providerGroups: threadProviderGroups,695 providerGroups: threadProviderGroups,
...@@ -703,6 +719,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -703,6 +719,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
703 settingsOwnerId,719 settingsOwnerId,
704 lockedProviderInstanceId,720 lockedProviderInstanceId,
705 threadProviderGroups,721 threadProviderGroups,
722 props.selectedThread.projectId,
706 ],723 ],
707 );724 );
708 const openSettings = useCallback(() => {725 const openSettings = useCallback(() => {
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+11-1
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7import type {7import type {
8 EnvironmentId,8 EnvironmentId,
9 ModelSelection,9 ModelSelection,
10 ProjectId,
10 ProviderInstanceId,11 ProviderInstanceId,
11 ProviderOptionDescriptor,12 ProviderOptionDescriptor,
12 ProviderOptionSelection,13 ProviderOptionSelection,
...@@ -252,6 +253,7 @@ type ThreadSettingsSubmenuPage =...@@ -252,6 +253,7 @@ type ThreadSettingsSubmenuPage =
252253
253type ThreadSettingsSessionProps = {254type ThreadSettingsSessionProps = {
254 readonly environmentId: EnvironmentId | null;255 readonly environmentId: EnvironmentId | null;
256 readonly projectId: ProjectId | null;
255 readonly providerInstanceId?: ProviderInstanceId;257 readonly providerInstanceId?: ProviderInstanceId;
256 readonly lockedProviderInstanceId?: ProviderInstanceId;258 readonly lockedProviderInstanceId?: ProviderInstanceId;
257 readonly providerGroups: ReadonlyArray<ProviderGroup>;259 readonly providerGroups: ReadonlyArray<ProviderGroup>;
...@@ -360,7 +362,13 @@ function ThreadSettingsSessionProvider(...@@ -360,7 +362,13 @@ function ThreadSettingsSessionProvider(
360 () =>362 () =>
361 manageHidden && config363 manageHidden && config
362 ? groupByProvider(364 ? groupByProvider(
363 buildModelOptions(config, props.selectedModel, props.lockedProviderInstanceId, true),365 buildModelOptions(
366 config,
367 props.selectedModel,
368 props.lockedProviderInstanceId,
369 props.projectId,
370 true,
371 ),
364 )372 )
365 : props.providerGroups,373 : props.providerGroups,
366 [374 [
...@@ -369,6 +377,7 @@ function ThreadSettingsSessionProvider(...@@ -369,6 +377,7 @@ function ThreadSettingsSessionProvider(
369 props.providerGroups,377 props.providerGroups,
370 props.selectedModel,378 props.selectedModel,
371 props.lockedProviderInstanceId,379 props.lockedProviderInstanceId,
380 props.projectId,
372 ],381 ],
373 );382 );
374 const legacyFavorites = AsyncResult.isSuccess(preferences)383 const legacyFavorites = AsyncResult.isSuccess(preferences)
...@@ -1605,6 +1614,7 @@ export function NewTaskThreadSettingsRouteScreen() {...@@ -1605,6 +1614,7 @@ export function NewTaskThreadSettingsRouteScreen() {
1605 return (1614 return (
1606 <ThreadSettingsPickerScreen1615 <ThreadSettingsPickerScreen
1607 environmentId={flow.selectedEnvironmentId}1616 environmentId={flow.selectedEnvironmentId}
1617 projectId={flow.selectedProject?.id ?? null}
1608 {...(flow.selectedModel ? { providerInstanceId: flow.selectedModel.instanceId } : {})}1618 {...(flow.selectedModel ? { providerInstanceId: flow.selectedModel.instanceId } : {})}
1609 providerGroups={flow.providerGroups}1619 providerGroups={flow.providerGroups}
1610 selectedModel={flow.selectedModel}1620 selectedModel={flow.selectedModel}
apps/mobile/src/features/threads/new-task-flow-provider.tsx+7
...@@ -581,27 +581,33 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -581,27 +581,33 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
581 const draftModelSelection = resolveSelectableModelSelection(581 const draftModelSelection = resolveSelectableModelSelection(
582 selectedEnvironmentServerConfig,582 selectedEnvironmentServerConfig,
583 selectedProjectDraft.modelSelection ?? null,583 selectedProjectDraft.modelSelection ?? null,
584 selectedProject?.id ?? null,
584 );585 );
585 const projectDefaultModelSelection = resolveDefaultableModelSelection(586 const projectDefaultModelSelection = resolveDefaultableModelSelection(
586 selectedEnvironmentServerConfig,587 selectedEnvironmentServerConfig,
587 projectSettings.settings.defaultModelSelection,588 projectSettings.settings.defaultModelSelection,
589 selectedProject?.id ?? null,
588 );590 );
589 const storedStickyModelSelection = useStickyComposerModelSelection();591 const storedStickyModelSelection = useStickyComposerModelSelection();
590 const stickyModelSelection = resolveDefaultableModelSelection(592 const stickyModelSelection = resolveDefaultableModelSelection(
591 selectedEnvironmentServerConfig,593 selectedEnvironmentServerConfig,
592 storedStickyModelSelection,594 storedStickyModelSelection,
595 selectedProject?.id ?? null,
593 );596 );
594 const modelOptions = useMemo(597 const modelOptions = useMemo(
595 () =>598 () =>
596 buildModelOptions(599 buildModelOptions(
597 selectedEnvironmentServerConfig,600 selectedEnvironmentServerConfig,
598 draftModelSelection ?? projectDefaultModelSelection ?? stickyModelSelection,601 draftModelSelection ?? projectDefaultModelSelection ?? stickyModelSelection,
602 undefined,
603 selectedProject?.id ?? null,
599 ),604 ),
600 [605 [
601 selectedEnvironmentServerConfig,606 selectedEnvironmentServerConfig,
602 draftModelSelection,607 draftModelSelection,
603 projectDefaultModelSelection,608 projectDefaultModelSelection,
604 stickyModelSelection,609 stickyModelSelection,
610 selectedProject?.id,
605 ],611 ],
606 );612 );
607613
...@@ -1117,6 +1123,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -1117,6 +1123,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
1117 resolveSelectableModelSelection(1123 resolveSelectableModelSelection(
1118 selectedEnvironmentServerConfig,1124 selectedEnvironmentServerConfig,
1119 draft.modelSelection ?? null,1125 draft.modelSelection ?? null,
1126 selectedProject.id,
1120 ) ?? selectedModel;1127 ) ?? selectedModel;
1121 // A shared image or file is a task on its own; text is optional.1128 // A shared image or file is a task on its own; text is optional.
1122 if ((text.length === 0 && draft.attachments.length === 0) || !draftModelSelection) {1129 if ((text.length === 0 && draft.attachments.length === 0) || !draftModelSelection) {
apps/mobile/src/lib/modelOptions.test.ts+305-59
...@@ -2,13 +2,17 @@ import { describe, expect, it } from "vite-plus/test";...@@ -2,13 +2,17 @@ import { describe, expect, it } from "vite-plus/test";
22
3import {3import {
4 DEFAULT_SERVER_SETTINGS,4 DEFAULT_SERVER_SETTINGS,
5 ProjectId,
6 ProviderDriverKind,
5 ProviderInstanceId,7 ProviderInstanceId,
6 type ModelSelection,8 type ModelSelection,
7 type ServerConfig,9 type ServerConfig,
10 type ServerSettings,
8} from "@t3tools/contracts";11} from "@t3tools/contracts";
912
10import {13import {
11 buildModelOptions,14 buildModelOptions,
15 describeUnavailableModelSelection,
12 groupByProvider,16 groupByProvider,
13 isModelSelectionUnavailable,17 isModelSelectionUnavailable,
14 resolveDefaultableModelSelection,18 resolveDefaultableModelSelection,
...@@ -47,7 +51,7 @@ describe("mobile model options", () => {...@@ -47,7 +51,7 @@ describe("mobile model options", () => {
47 ],51 ],
48 } as unknown as ServerConfig;52 } as unknown as ServerConfig;
4953
50 expect(groupByProvider(buildModelOptions(config, null))).toMatchObject([54 expect(groupByProvider(buildModelOptions(config, null, undefined, null))).toMatchObject([
51 {55 {
52 providerKey: "codex",56 providerKey: "codex",
53 providerLabel: "Codex",57 providerLabel: "Codex",
...@@ -83,7 +87,7 @@ describe("mobile model options", () => {...@@ -83,7 +87,7 @@ describe("mobile model options", () => {
83 ],87 ],
84 } as unknown as ServerConfig;88 } as unknown as ServerConfig;
8589
86 const [group] = groupByProvider(buildModelOptions(config, null));90 const [group] = groupByProvider(buildModelOptions(config, null, undefined, null));
8791
88 expect(group).toMatchObject({92 expect(group).toMatchObject({
89 providerKey: "acpRegistry_antigravity",93 providerKey: "acpRegistry_antigravity",
...@@ -127,7 +131,7 @@ describe("mobile model options", () => {...@@ -127,7 +131,7 @@ describe("mobile model options", () => {
127 model: "github-copilot/claude-fable-5",131 model: "github-copilot/claude-fable-5",
128 };132 };
129133
130 const options = buildModelOptions(config, selection);134 const options = buildModelOptions(config, selection, undefined, null);
131135
132 expect(options).toMatchObject(136 expect(options).toMatchObject(
133 sources.map((source) => ({137 sources.map((source) => ({
...@@ -181,26 +185,41 @@ describe("mobile model options", () => {...@@ -181,26 +185,41 @@ describe("mobile model options", () => {
181 ],185 ],
182 } as unknown as ServerConfig;186 } as unknown as ServerConfig;
183187
184 const [option] = buildModelOptions(config, {188 const [option] = buildModelOptions(
185 instanceId: ProviderInstanceId.make("codex"),189 config,
186 model: "gpt-test",190 {
187 });191 instanceId: ProviderInstanceId.make("codex"),
192 model: "gpt-test",
193 },
194 undefined,
195 null,
196 );
188197
189 expect(option?.capabilities?.optionDescriptors?.[0]?.id).toBe("serviceTier");198 expect(option?.capabilities?.optionDescriptors?.[0]?.id).toBe("serviceTier");
190 expect(option?.selection.options).toBeUndefined();199 expect(option?.selection.options).toBeUndefined();
191200
192 const [emptyOption] = buildModelOptions(config, {201 const [emptyOption] = buildModelOptions(
193 instanceId: ProviderInstanceId.make("codex"),202 config,
194 model: "gpt-test",203 {
195 options: [],204 instanceId: ProviderInstanceId.make("codex"),
196 });205 model: "gpt-test",
206 options: [],
207 },
208 undefined,
209 null,
210 );
197 expect(emptyOption?.selection).toEqual(option?.selection);211 expect(emptyOption?.selection).toEqual(option?.selection);
198212
199 const [explicitOption] = buildModelOptions(config, {213 const [explicitOption] = buildModelOptions(
200 instanceId: ProviderInstanceId.make("codex"),214 config,
201 model: "gpt-test",215 {
202 options: [{ id: "serviceTier", value: "priority" }],216 instanceId: ProviderInstanceId.make("codex"),
203 });217 model: "gpt-test",
218 options: [{ id: "serviceTier", value: "priority" }],
219 },
220 undefined,
221 null,
222 );
204 expect(explicitOption?.selection.options).toEqual([{ id: "serviceTier", value: "priority" }]);223 expect(explicitOption?.selection.options).toEqual([{ id: "serviceTier", value: "priority" }]);
205 });224 });
206225
...@@ -216,12 +235,13 @@ describe("mobile model options", () => {...@@ -216,12 +235,13 @@ describe("mobile model options", () => {
216 const config = { providers } as unknown as ServerConfig;235 const config = { providers } as unknown as ServerConfig;
217 const selection = { instanceId: ProviderInstanceId.make("codex"), model: "test" };236 const selection = { instanceId: ProviderInstanceId.make("codex"), model: "test" };
218237
219 expect(buildModelOptions(config, selection).map((option) => option.providerKey)).toEqual([238 expect(
220 "codex",239 buildModelOptions(config, selection, undefined, null).map((option) => option.providerKey),
221 "claudeAgent",240 ).toEqual(["codex", "claudeAgent"]);
222 ]);241 expect(buildModelOptions(config, selection, selection.instanceId, null)).toEqual(
223 expect(buildModelOptions(config, selection, selection.instanceId)).toEqual(242 buildModelOptions(config, selection, undefined, null).filter(
224 buildModelOptions(config, selection).filter((option) => option.providerKey === "codex"),243 (option) => option.providerKey === "codex",
244 ),
225 );245 );
226 });246 });
227247
...@@ -247,8 +267,8 @@ describe("mobile model options", () => {...@@ -247,8 +267,8 @@ describe("mobile model options", () => {
247 providers: state === "missing" ? [] : [provider],267 providers: state === "missing" ? [] : [provider],
248 settings: { providerInstances: { google_work: { driver: "antigravity" } } },268 settings: { providerInstances: { google_work: { driver: "antigravity" } } },
249 } as unknown as ServerConfig;269 } as unknown as ServerConfig;
250 const options = buildModelOptions(config, selection, selection.instanceId);270 const options = buildModelOptions(config, selection, selection.instanceId, null);
251 expect(options).toEqual(buildModelOptions(config, selection));271 expect(options).toEqual(buildModelOptions(config, selection, undefined, null));
252 expect(options).toHaveLength(1);272 expect(options).toHaveLength(1);
253 expect(options[0]).toMatchObject({ selection, isUnavailable: true });273 expect(options[0]).toMatchObject({ selection, isUnavailable: true });
254 },274 },
...@@ -289,12 +309,15 @@ describe("mobile model options", () => {...@@ -289,12 +309,15 @@ describe("mobile model options", () => {
289 model: "gpt-5.6-sol",309 model: "gpt-5.6-sol",
290 };310 };
291311
292 expect(resolveSelectableModelSelection(config, usable)).toBe(usable);312 expect(resolveSelectableModelSelection(config, usable, null)).toBe(usable);
293 expect(resolveSelectableModelSelection(config, disabled)).toBeNull();313 expect(resolveSelectableModelSelection(config, disabled, null)).toBeNull();
294 expect(resolveSelectableModelSelection(config, removed)).toBeNull();314 expect(resolveSelectableModelSelection(config, removed, null)).toBeNull();
295 expect(isModelSelectionUnavailable(config, disabled)).toBe(false);315 // Disabled is disabled even with no settings to consult; only a provider
316 // that is merely missing or unauthenticated falls through silently.
317 expect(isModelSelectionUnavailable(config, disabled, null)).toBe(true);
318 expect(isModelSelectionUnavailable(config, removed, null)).toBe(false);
296 // An offline environment has no config to validate.319 // An offline environment has no config to validate.
297 expect(resolveSelectableModelSelection(null, disabled)).toBe(disabled);320 expect(resolveSelectableModelSelection(null, disabled, null)).toBe(disabled);
298 });321 });
299322
300 describe("Antigravity selections", () => {323 describe("Antigravity selections", () => {
...@@ -347,11 +370,11 @@ describe("mobile model options", () => {...@@ -347,11 +370,11 @@ describe("mobile model options", () => {
347 providers: config.providers.map((provider) => ({ ...provider, ...update })),370 providers: config.providers.map((provider) => ({ ...provider, ...update })),
348 };371 };
349372
350 expect(resolveSelectableModelSelection(unavailableConfig, selection)).toBe(selection);373 expect(resolveSelectableModelSelection(unavailableConfig, selection, null)).toBe(selection);
351 expect(resolveDefaultableModelSelection(unavailableConfig, selection)).toBe(selection);374 expect(resolveDefaultableModelSelection(unavailableConfig, selection, null)).toBe(selection);
352 expect(isModelSelectionUnavailable(unavailableConfig, selection)).toBe(true);375 expect(isModelSelectionUnavailable(unavailableConfig, selection, null)).toBe(true);
353 expect(buildModelOptions(unavailableConfig, null)).toEqual([]);376 expect(buildModelOptions(unavailableConfig, null, undefined, null)).toEqual([]);
354 const [option] = buildModelOptions(unavailableConfig, selection);377 const [option] = buildModelOptions(unavailableConfig, selection, undefined, null);
355 expect(option).toMatchObject({378 expect(option).toMatchObject({
356 key: `google_work:${selection.model}`,379 key: `google_work:${selection.model}`,
357 label: model.name,380 label: model.name,
...@@ -376,9 +399,9 @@ describe("mobile model options", () => {...@@ -376,9 +399,9 @@ describe("mobile model options", () => {
376 })),399 })),
377 };400 };
378401
379 expect(resolveDefaultableModelSelection(changedConfig, selection)).toBe(selection);402 expect(resolveDefaultableModelSelection(changedConfig, selection, null)).toBe(selection);
380 expect(isModelSelectionUnavailable(changedConfig, selection)).toBe(true);403 expect(isModelSelectionUnavailable(changedConfig, selection, null)).toBe(true);
381 const options = buildModelOptions(changedConfig, selection);404 const options = buildModelOptions(changedConfig, selection, undefined, null);
382 const missing = options.find((option) => option.selection.model === selection.model);405 const missing = options.find((option) => option.selection.model === selection.model);
383 expect(missing).toMatchObject({406 expect(missing).toMatchObject({
384 label: selection.model,407 label: selection.model,
...@@ -391,18 +414,20 @@ describe("mobile model options", () => {...@@ -391,18 +414,20 @@ describe("mobile model options", () => {
391 expect(414 expect(
392 resolveNewTaskModelSelection({415 resolveNewTaskModelSelection({
393 draftSelection: null,416 draftSelection: null,
394 projectDefaultSelection: resolveDefaultableModelSelection(changedConfig, selection),417 projectDefaultSelection: resolveDefaultableModelSelection(changedConfig, selection, null),
395 stickySelection: null,418 stickySelection: null,
396 modelOptions: options,419 modelOptions: options,
397 }),420 }),
398 ).toBe(selection);421 ).toBe(selection);
399422
400 const [restored] = buildModelOptions(config, selection);423 const [restored] = buildModelOptions(config, selection, undefined, null);
401 expect(isModelSelectionUnavailable(config, selection)).toBe(false);424 expect(isModelSelectionUnavailable(config, selection, null)).toBe(false);
402 expect(restored?.isUnavailable).not.toBe(true);425 expect(restored?.isUnavailable).not.toBe(true);
403 expect(restored?.selection).toBe(selection);426 expect(restored?.selection).toBe(selection);
404 expect(resolveDefaultableModelSelection(config, selection)).toBe(selection);427 expect(resolveDefaultableModelSelection(config, selection, null)).toBe(selection);
405 expect(buildModelOptions(config, null)[0]?.selection.options).toBeUndefined();428 expect(
429 buildModelOptions(config, null, undefined, null)[0]?.selection.options,
430 ).toBeUndefined();
406 });431 });
407432
408 it("uses configured instance metadata when provider status is missing", () => {433 it("uses configured instance metadata when provider status is missing", () => {
...@@ -415,9 +440,11 @@ describe("mobile model options", () => {...@@ -415,9 +440,11 @@ describe("mobile model options", () => {
415 },440 },
416 } as unknown as ServerConfig;441 } as unknown as ServerConfig;
417442
418 expect(resolveDefaultableModelSelection(missingStatusConfig, selection)).toBe(selection);443 expect(resolveDefaultableModelSelection(missingStatusConfig, selection, null)).toBe(
419 expect(isModelSelectionUnavailable(missingStatusConfig, selection)).toBe(true);444 selection,
420 expect(buildModelOptions(missingStatusConfig, selection)).toMatchObject([445 );
446 expect(isModelSelectionUnavailable(missingStatusConfig, selection, null)).toBe(true);
447 expect(buildModelOptions(missingStatusConfig, selection, undefined, null)).toMatchObject([
421 {448 {
422 providerDriver: "antigravity",449 providerDriver: "antigravity",
423 providerLabel: "Google Work",450 providerLabel: "Google Work",
...@@ -430,12 +457,12 @@ describe("mobile model options", () => {...@@ -430,12 +457,12 @@ describe("mobile model options", () => {
430 it("keeps offline selections without assuming that an unknown instance is Antigravity", () => {457 it("keeps offline selections without assuming that an unknown instance is Antigravity", () => {
431 const unknownConfig = { ...config, providers: [] };458 const unknownConfig = { ...config, providers: [] };
432459
433 expect(resolveDefaultableModelSelection(null, selection)).toBe(selection);460 expect(resolveDefaultableModelSelection(null, selection, null)).toBe(selection);
434 expect(isModelSelectionUnavailable(null, selection)).toBe(false);461 expect(isModelSelectionUnavailable(null, selection, null)).toBe(false);
435 expect(buildModelOptions(null, selection)[0]?.selection).toBe(selection);462 expect(buildModelOptions(null, selection, undefined, null)[0]?.selection).toBe(selection);
436 expect(buildModelOptions(null, selection)[0]?.isUnavailable).not.toBe(true);463 expect(buildModelOptions(null, selection, undefined, null)[0]?.isUnavailable).not.toBe(true);
437 expect(isModelSelectionUnavailable(unknownConfig, selection)).toBe(false);464 expect(isModelSelectionUnavailable(unknownConfig, selection, null)).toBe(false);
438 expect(resolveSelectableModelSelection(unknownConfig, selection)).toBeNull();465 expect(resolveSelectableModelSelection(unknownConfig, selection, null)).toBeNull();
439 });466 });
440 });467 });
441468
...@@ -466,11 +493,11 @@ describe("mobile model options", () => {...@@ -466,11 +493,11 @@ describe("mobile model options", () => {
466 const current = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" };493 const current = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" };
467 const legacy = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" };494 const legacy = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" };
468495
469 expect(resolveDefaultableModelSelection(config, current)).toBe(current);496 expect(resolveDefaultableModelSelection(config, current, null)).toBe(current);
470 // A legacy last-used selection falls through to the provider default.497 // A legacy last-used selection falls through to the provider default.
471 expect(resolveDefaultableModelSelection(config, legacy)).toBeNull();498 expect(resolveDefaultableModelSelection(config, legacy, null)).toBeNull();
472 // Offline: nothing to validate against, selection passes through.499 // Offline: nothing to validate against, selection passes through.
473 expect(resolveDefaultableModelSelection(null, legacy)).toBe(legacy);500 expect(resolveDefaultableModelSelection(null, legacy, null)).toBe(legacy);
474 });501 });
475502
476 it("resolves new tasks from draft, project, sticky, then provider defaults", () => {503 it("resolves new tasks from draft, project, sticky, then provider defaults", () => {
...@@ -508,6 +535,217 @@ describe("mobile model options", () => {...@@ -508,6 +535,217 @@ describe("mobile model options", () => {
508 }),535 }),
509 ).toBeNull();536 ).toBeNull();
510 });537 });
538
539 describe("project-aware enablement", () => {
540 const projectId = ProjectId.make("project-a");
541 const instanceId = ProviderInstanceId.make("codex_work");
542
543 function configWith(settings: ServerSettings): ServerConfig {
544 return {
545 providers: [
546 {
547 instanceId,
548 driver: "codex",
549 displayName: "Codex Work",
550 enabled: true,
551 installed: true,
552 auth: { status: "authenticated" },
553 models: [{ slug: "model-a", name: "Model A", isCustom: false, capabilities: null }],
554 },
555 ],
556 settings,
557 } as unknown as ServerConfig;
558 }
559
560 it("words a project's own turn-off apart from missing setup", () => {
561 const settings: ServerSettings = {
562 ...DEFAULT_SERVER_SETTINGS,
563 providerInstances: {
564 [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true },
565 },
566 projectSettingsOverrides: {
567 [projectId]: { providerInstances: { [instanceId]: { enabled: false } } },
568 },
569 };
570 const selection = { instanceId, model: "model-a" };
571
572 expect(
573 describeUnavailableModelSelection(configWith(settings), selection, projectId).title,
574 ).toBe("Model turned off for this project");
575 expect(describeUnavailableModelSelection(configWith(settings), selection, null).title).toBe(
576 "Antigravity model unavailable",
577 );
578 });
579
580 it("refuses to send a non-Antigravity model the project turned off", () => {
581 const settings: ServerSettings = {
582 ...DEFAULT_SERVER_SETTINGS,
583 providerInstances: {
584 [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true },
585 },
586 projectSettingsOverrides: {
587 [projectId]: { providerInstances: { [instanceId]: { enabled: false } } },
588 },
589 };
590 const selection = { instanceId, model: "model-a" };
591
592 expect(isModelSelectionUnavailable(configWith(settings), selection, projectId)).toBe(true);
593 expect(isModelSelectionUnavailable(configWith(settings), selection, null)).toBe(false);
594 });
595
596 it("hides an instance the project disables even though the machine enables it", () => {
597 const settings: ServerSettings = {
598 ...DEFAULT_SERVER_SETTINGS,
599 providerInstances: {
600 [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true },
601 },
602 projectSettingsOverrides: {
603 [projectId]: { providerInstances: { [instanceId]: { enabled: false } } },
604 },
605 };
606
607 expect(buildModelOptions(configWith(settings), null, undefined, projectId)).toEqual([]);
608 });
609
610 it("shows an instance the project enables even though the machine disables it", () => {
611 const settings: ServerSettings = {
612 ...DEFAULT_SERVER_SETTINGS,
613 providerInstances: {
614 [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false },
615 },
616 projectSettingsOverrides: {
617 [projectId]: { providerInstances: { [instanceId]: { enabled: true } } },
618 },
619 };
620
621 expect(buildModelOptions(configWith(settings), null, undefined, projectId)).toHaveLength(1);
622 });
623
624 it("falls back to the machine value with no project in scope", () => {
625 const settings: ServerSettings = {
626 ...DEFAULT_SERVER_SETTINGS,
627 providerInstances: {
628 [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true },
629 },
630 projectSettingsOverrides: {
631 [projectId]: { providerInstances: { [instanceId]: { enabled: false } } },
632 },
633 };
634
635 expect(buildModelOptions(configWith(settings), null, undefined, null)).toHaveLength(1);
636 });
637
638 it("hides a machine-disabled instance for a project with no override, even though another project's keep-alive left it running", () => {
639 const otherProjectId = ProjectId.make("project-b");
640 const settings: ServerSettings = {
641 ...DEFAULT_SERVER_SETTINGS,
642 providerInstances: {
643 [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false },
644 },
645 projectSettingsOverrides: {
646 [otherProjectId]: { providerInstances: { [instanceId]: { enabled: true } } },
647 },
648 };
649
650 // configWith's snapshot always reports enabled: true, standing in for
651 // the server keeping the instance alive to serve project-b's override.
652 expect(buildModelOptions(configWith(settings), null, undefined, projectId)).toEqual([]);
653 });
654
655 it("refuses to send an instance the project merely inherits as disabled, not just an explicit override", () => {
656 const settings: ServerSettings = {
657 ...DEFAULT_SERVER_SETTINGS,
658 providerInstances: {
659 [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false },
660 },
661 };
662 const selection = { instanceId, model: "model-a" };
663
664 // configWith's snapshot always reports enabled: true, standing in for
665 // the server keeping the instance alive for another project's keep-alive.
666 expect(isModelSelectionUnavailable(configWith(settings), selection, projectId)).toBe(true);
667 expect(
668 describeUnavailableModelSelection(configWith(settings), selection, projectId).title,
669 ).toBe("Model turned off for this project");
670 // With no project in scope, the machine value applies and is disabled.
671 expect(isModelSelectionUnavailable(configWith(settings), selection, null)).toBe(true);
672 });
673
674 it("marks a project-disabled instance's fallback selection unavailable instead of reviving it", () => {
675 const settings: ServerSettings = {
676 ...DEFAULT_SERVER_SETTINGS,
677 providerInstances: {
678 [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true },
679 },
680 projectSettingsOverrides: {
681 [projectId]: { providerInstances: { [instanceId]: { enabled: false } } },
682 },
683 };
684 const fallback = { instanceId, model: "model-a" };
685
686 const options = buildModelOptions(configWith(settings), fallback, undefined, projectId);
687
688 expect(options).toHaveLength(1);
689 expect(options[0]).toMatchObject({ selection: fallback, isUnavailable: true });
690 });
691
692 it("rejects a project-disabled instance's selection via resolveSelectableModelSelection", () => {
693 const settings: ServerSettings = {
694 ...DEFAULT_SERVER_SETTINGS,
695 providerInstances: {
696 [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true },
697 },
698 projectSettingsOverrides: {
699 [projectId]: { providerInstances: { [instanceId]: { enabled: false } } },
700 },
701 };
702 const selection = { instanceId, model: "model-a" };
703
704 expect(
705 resolveSelectableModelSelection(configWith(settings), selection, projectId),
706 ).toBeNull();
707 expect(resolveSelectableModelSelection(configWith(settings), selection, null)).toBe(
708 selection,
709 );
710 });
711
712 it("rejects a project-disabled antigravity instance's selection instead of keeping it", () => {
713 const antigravityInstanceId = ProviderInstanceId.make("antigravity_work");
714 const config = {
715 providers: [
716 {
717 instanceId: antigravityInstanceId,
718 driver: "antigravity",
719 displayName: "Antigravity Work",
720 enabled: true,
721 installed: true,
722 auth: { status: "authenticated" },
723 availability: "available",
724 models: [{ slug: "model-a", name: "Model A", isCustom: false, capabilities: null }],
725 },
726 ],
727 settings: {
728 ...DEFAULT_SERVER_SETTINGS,
729 providerInstances: {
730 [antigravityInstanceId]: {
731 driver: ProviderDriverKind.make("antigravity"),
732 enabled: true,
733 },
734 },
735 projectSettingsOverrides: {
736 [projectId]: { providerInstances: { [antigravityInstanceId]: { enabled: false } } },
737 },
738 },
739 } as unknown as ServerConfig;
740 const selection = { instanceId: antigravityInstanceId, model: "model-a" };
741
742 expect(resolveSelectableModelSelection(config, selection, projectId)).toBeNull();
743 expect(resolveSelectableModelSelection(config, selection, null)).toBe(selection);
744 expect(
745 resolveSelectableModelSelection({ ...config, providers: [] }, selection, projectId),
746 ).toBeNull();
747 });
748 });
511});749});
512750
513describe("synchronized mobile model visibility", () => {751describe("synchronized mobile model visibility", () => {
...@@ -522,6 +760,13 @@ describe("synchronized mobile model visibility", () => {...@@ -522,6 +760,13 @@ describe("synchronized mobile model visibility", () => {
522 const config = {760 const config = {
523 settings: {761 settings: {
524 ...DEFAULT_SERVER_SETTINGS,762 ...DEFAULT_SERVER_SETTINGS,
763 // A live snapshot entry always has a matching configured instance (or
764 // is a driver's own default id); these are stand-ins for neither, so
765 // the enablement fixture needs its own entry to read as enabled.
766 providerInstances: {
767 [provider]: { driver: ProviderDriverKind.make("codex"), enabled: true },
768 [other]: { driver: ProviderDriverKind.make("codex"), enabled: true },
769 },
525 providerModelPreferences: {770 providerModelPreferences: {
526 [provider]: {771 [provider]: {
527 hiddenModels: ["hidden", "custom", "missing"],772 hiddenModels: ["hidden", "custom", "missing"],
...@@ -540,7 +785,7 @@ describe("synchronized mobile model visibility", () => {...@@ -540,7 +785,7 @@ describe("synchronized mobile model visibility", () => {
540 } as unknown as ServerConfig;785 } as unknown as ServerConfig;
541786
542 it("applies server visibility and ordering independently to each provider instance", () => {787 it("applies server visibility and ordering independently to each provider instance", () => {
543 const options = buildModelOptions(config, null);788 const options = buildModelOptions(config, null, undefined, null);
544 expect(789 expect(
545 options790 options
546 .filter((option) => option.providerKey === provider)791 .filter((option) => option.providerKey === provider)
...@@ -558,6 +803,7 @@ describe("synchronized mobile model visibility", () => {...@@ -558,6 +803,7 @@ describe("synchronized mobile model visibility", () => {
558 config,803 config,
559 { instanceId: provider, model: "hidden" },804 { instanceId: provider, model: "hidden" },
560 provider,805 provider,
806 null,
561 true,807 true,
562 );808 );
563 expect(options.map((option) => option.selection.model)).toEqual([809 expect(options.map((option) => option.selection.model)).toEqual([
...@@ -578,7 +824,7 @@ describe("synchronized mobile model visibility", () => {...@@ -578,7 +824,7 @@ describe("synchronized mobile model visibility", () => {
578824
579 it.each(["hidden", "missing"])("does not resurrect the hidden fallback %s", (model) => {825 it.each(["hidden", "missing"])("does not resurrect the hidden fallback %s", (model) => {
580 expect(826 expect(
581 buildModelOptions(config, { instanceId: provider, model }, provider).map(827 buildModelOptions(config, { instanceId: provider, model }, provider, null).map(
582 (option) => option.selection.model,828 (option) => option.selection.model,
583 ),829 ),
584 ).toEqual(["second", "first", "custom"]);830 ).toEqual(["second", "first", "custom"]);
...@@ -586,13 +832,13 @@ describe("synchronized mobile model visibility", () => {...@@ -586,13 +832,13 @@ describe("synchronized mobile model visibility", () => {
586832
587 it("keeps custom models visible and restores built-in models when the server clears hidden models", () => {833 it("keeps custom models visible and restores built-in models when the server clears hidden models", () => {
588 expect(834 expect(
589 buildModelOptions(config, { instanceId: provider, model: "custom" }, provider).map(835 buildModelOptions(config, { instanceId: provider, model: "custom" }, provider, null).map(
590 (option) => option.selection.model,836 (option) => option.selection.model,
591 ),837 ),
592 ).toContain("custom");838 ).toContain("custom");
593 const cleared = { ...config, settings: { ...config.settings, providerModelPreferences: {} } };839 const cleared = { ...config, settings: { ...config.settings, providerModelPreferences: {} } };
594 expect(840 expect(
595 buildModelOptions(cleared, null, provider).map((option) => option.selection.model),841 buildModelOptions(cleared, null, provider, null).map((option) => option.selection.model),
596 ).toEqual(["hidden", "first", "second", "custom"]);842 ).toEqual(["hidden", "first", "second", "custom"]);
597 });843 });
598});844});
apps/mobile/src/lib/modelOptions.ts+113-8
...@@ -2,9 +2,11 @@ import type { MenuAction } from "@react-native-menu/menu";...@@ -2,9 +2,11 @@ import type { MenuAction } from "@react-native-menu/menu";
2import type {2import type {
3 ModelCapabilities,3 ModelCapabilities,
4 ModelSelection,4 ModelSelection,
5 ProjectId,
5 RuntimeMode,6 RuntimeMode,
6 ServerConfig as T3ServerConfig,7 ServerConfig as T3ServerConfig,
7} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import { resolveProjectProviderInstanceEnabled } from "@t3tools/contracts";
8import {10import {
9 buildExplicitProviderOptionSelectionsFromDescriptors,11 buildExplicitProviderOptionSelectionsFromDescriptors,
10 getProviderOptionDescriptors,12 getProviderOptionDescriptors,
...@@ -54,6 +56,67 @@ function providerDisplayLabel(provider: {...@@ -54,6 +56,67 @@ function providerDisplayLabel(provider: {
54 return provider.instanceId;56 return provider.instanceId;
55}57}
5658
59/**
60 * `provider.enabled` reflects the machine plus any project's keep-alive
61 * override, so it alone can't tell this project apart from one that merely
62 * shares the instance. Settings carry the real per-project view the server
63 * itself enforces, so a project with no override of its own must resolve
64 * from there, not from a snapshot another project may be keeping alive.
65 */
66function isProviderEnabledForProject(
67 config: T3ServerConfig | null | undefined,
68 provider:
69 | { readonly instanceId: ModelSelection["instanceId"]; readonly enabled: boolean }
70 | undefined,
71 projectId: ProjectId | null,
72): boolean {
73 if (!provider) {
74 return false;
75 }
76 return config?.settings
77 ? resolveProjectProviderInstanceEnabled(config.settings, projectId, provider.instanceId)
78 : provider.enabled;
79}
80
81/**
82 * Whether the project has explicitly turned this instance off, as opposed
83 * to it merely being disabled or unreachable at the machine level.
84 */
85function isProjectDisabledOverride(
86 config: T3ServerConfig | null | undefined,
87 instanceId: ModelSelection["instanceId"],
88 projectId: ProjectId | null,
89): boolean {
90 if (!projectId || !config?.settings) {
91 return false;
92 }
93 return (
94 config.settings.projectSettingsOverrides[projectId]?.providerInstances?.[instanceId]
95 ?.enabled === false
96 );
97}
98
99/**
100 * Whether the project cannot use this instance at all, whether the project
101 * turned it off itself or it merely inherits a machine-level disable. Either
102 * way the server's `ThreadManagementService.dispatch` refuses a send, and the
103 * instance may still look installed and running in the snapshot because
104 * another project's keep-alive left it up.
105 */
106function isDisabledForProject(
107 config: T3ServerConfig | null | undefined,
108 provider:
109 | { readonly instanceId: ModelSelection["instanceId"]; readonly enabled: boolean }
110 | undefined,
111 instanceId: ModelSelection["instanceId"],
112 projectId: ProjectId | null,
113): boolean {
114 if (isProjectDisabledOverride(config, instanceId, projectId)) {
115 return true;
116 }
117 return provider !== undefined && !isProviderEnabledForProject(config, provider, projectId);
118}
119
57function normalizeSelectionOptions(120function normalizeSelectionOptions(
58 selection: ModelSelection,121 selection: ModelSelection,
59 capabilities: ModelCapabilities | null,122 capabilities: ModelCapabilities | null,
...@@ -79,10 +142,19 @@ function normalizeSelectionOptions(...@@ -79,10 +142,19 @@ function normalizeSelectionOptions(
79 };142 };
80}143}
81144
82/** Whether a known Antigravity selection needs setup or a different model. */145/**
146 * Whether the selection cannot be sent as is: the instance resolves disabled
147 * for the project (its own override, or an inherited machine-level disable),
148 * or a known Antigravity selection needs setup or a different model.
149 *
150 * `projectId` has no default: a caller that forgets it would silently check
151 * only the machine-wide state and miss a project that turned this instance
152 * off. Pass `null` explicitly outside a project's scope.
153 */
83export function isModelSelectionUnavailable(154export function isModelSelectionUnavailable(
84 config: T3ServerConfig | null | undefined,155 config: T3ServerConfig | null | undefined,
85 selection: ModelSelection | null | undefined,156 selection: ModelSelection | null | undefined,
157 projectId: ProjectId | null,
86): boolean {158): boolean {
87 if (!config || !selection) {159 if (!config || !selection) {
88 return false;160 return false;
...@@ -90,12 +162,14 @@ export function isModelSelectionUnavailable(...@@ -90,12 +162,14 @@ export function isModelSelectionUnavailable(
90 const provider = config.providers.find(162 const provider = config.providers.find(
91 (candidate) => candidate.instanceId === selection.instanceId,163 (candidate) => candidate.instanceId === selection.instanceId,
92 );164 );
165 if (isDisabledForProject(config, provider, selection.instanceId, projectId)) {
166 return true;
167 }
93 const driver =168 const driver =
94 provider?.driver ?? config.settings?.providerInstances[selection.instanceId]?.driver;169 provider?.driver ?? config.settings?.providerInstances[selection.instanceId]?.driver;
95 return (170 return (
96 driver === "antigravity" &&171 driver === "antigravity" &&
97 (!provider ||172 (!provider ||
98 !provider.enabled ||
99 !provider.installed ||173 !provider.installed ||
100 provider.auth.status === "unauthenticated" ||174 provider.auth.status === "unauthenticated" ||
101 provider.availability === "unavailable" ||175 provider.availability === "unavailable" ||
...@@ -103,14 +177,37 @@ export function isModelSelectionUnavailable(...@@ -103,14 +177,37 @@ export function isModelSelectionUnavailable(
103 );177 );
104}178}
105179
180/** Why `isModelSelectionUnavailable` refused a selection, worded for the recovery that applies. */
181export function describeUnavailableModelSelection(
182 config: T3ServerConfig | null | undefined,
183 selection: ModelSelection,
184 projectId: ProjectId | null,
185): { readonly title: string; readonly detail: string } {
186 const provider = config?.providers.find(
187 (candidate) => candidate.instanceId === selection.instanceId,
188 );
189 return isDisabledForProject(config, provider, selection.instanceId, projectId)
190 ? {
191 title: "Model turned off for this project",
192 detail: "Turn it back on from web or desktop, or choose another model.",
193 }
194 : {
195 title: "Antigravity model unavailable",
196 detail: "Set up Antigravity on web or desktop, or choose another model.",
197 };
198}
199
106/**200/**
107 * Keep Antigravity selections when setup or catalog changes make them201 * Keep Antigravity selections when setup or catalog changes make them
108 * unavailable. Other providers fall through to the server default when they202 * unavailable. Other providers fall through to the server default when they
109 * are disabled, missing, or signed out. Without config, keep stored selections.203 * are disabled, missing, or signed out. Without config, keep stored selections.
204 *
205 * `projectId` has no default; see `isModelSelectionUnavailable`.
110 */206 */
111export function resolveSelectableModelSelection(207export function resolveSelectableModelSelection(
112 config: T3ServerConfig | null | undefined,208 config: T3ServerConfig | null | undefined,
113 selection: ModelSelection | null,209 selection: ModelSelection | null,
210 projectId: ProjectId | null,
114): ModelSelection | null {211): ModelSelection | null {
115 if (!selection || !config) {212 if (!selection || !config) {
116 return selection;213 return selection;
...@@ -121,10 +218,10 @@ export function resolveSelectableModelSelection(...@@ -121,10 +218,10 @@ export function resolveSelectableModelSelection(
121 const driver =218 const driver =
122 provider?.driver ?? config.settings?.providerInstances[selection.instanceId]?.driver;219 provider?.driver ?? config.settings?.providerInstances[selection.instanceId]?.driver;
123 if (driver === "antigravity") {220 if (driver === "antigravity") {
124 return selection;221 return isProjectDisabledOverride(config, selection.instanceId, projectId) ? null : selection;
125 }222 }
126 return provider &&223 return provider &&
127 provider.enabled &&224 isProviderEnabledForProject(config, provider, projectId) &&
128 provider.installed &&225 provider.installed &&
129 provider.auth.status !== "unauthenticated"226 provider.auth.status !== "unauthenticated"
130 ? selection227 ? selection
...@@ -135,12 +232,15 @@ export function resolveSelectableModelSelection(...@@ -135,12 +232,15 @@ export function resolveSelectableModelSelection(
135 * Reject legacy models for implicit defaults, except Antigravity selections,232 * Reject legacy models for implicit defaults, except Antigravity selections,
136 * which must not silently change after a catalog update. Explicit picks in233 * which must not silently change after a catalog update. Explicit picks in
137 * the settings sheet are unaffected.234 * the settings sheet are unaffected.
235 *
236 * `projectId` has no default; see `isModelSelectionUnavailable`.
138 */237 */
139export function resolveDefaultableModelSelection(238export function resolveDefaultableModelSelection(
140 config: T3ServerConfig | null | undefined,239 config: T3ServerConfig | null | undefined,
141 selection: ModelSelection | null,240 selection: ModelSelection | null,
241 projectId: ProjectId | null,
142): ModelSelection | null {242): ModelSelection | null {
143 const usable = resolveSelectableModelSelection(config, selection);243 const usable = resolveSelectableModelSelection(config, selection, projectId);
144 if (!usable || !config) {244 if (!usable || !config) {
145 return usable;245 return usable;
146 }246 }
...@@ -165,10 +265,14 @@ export function resolveNewTaskModelSelection(input: {...@@ -165,10 +265,14 @@ export function resolveNewTaskModelSelection(input: {
165 );265 );
166}266}
167267
268/**
269 * `projectId` has no default; see `isModelSelectionUnavailable`.
270 */
168export function buildModelOptions(271export function buildModelOptions(
169 config: T3ServerConfig | null | undefined,272 config: T3ServerConfig | null | undefined,
170 fallbackModelSelection: ModelSelection | null,273 fallbackModelSelection: ModelSelection | null,
171 providerInstanceId?: ModelSelection["instanceId"],274 providerInstanceId: ModelSelection["instanceId"] | undefined,
275 projectId: ProjectId | null,
172 includeHidden = false,276 includeHidden = false,
173): ReadonlyArray<ModelOption> {277): ReadonlyArray<ModelOption> {
174 const options = new Map<string, ModelOption>();278 const options = new Map<string, ModelOption>();
...@@ -176,7 +280,7 @@ export function buildModelOptions(...@@ -176,7 +280,7 @@ export function buildModelOptions(
176 for (const provider of config?.providers ?? []) {280 for (const provider of config?.providers ?? []) {
177 if (281 if (
178 (providerInstanceId !== undefined && provider.instanceId !== providerInstanceId) ||282 (providerInstanceId !== undefined && provider.instanceId !== providerInstanceId) ||
179 !provider.enabled ||283 !isProviderEnabledForProject(config, provider, projectId) ||
180 !provider.installed ||284 !provider.installed ||
181 provider.auth.status === "unauthenticated" ||285 provider.auth.status === "unauthenticated" ||
182 (provider.driver === "antigravity" && provider.availability === "unavailable")286 (provider.driver === "antigravity" && provider.availability === "unavailable")
...@@ -271,7 +375,8 @@ export function buildModelOptions(...@@ -271,7 +375,8 @@ export function buildModelOptions(
271 providerDriver,375 providerDriver,
272 isDefault: false,376 isDefault: false,
273 isLegacy: model?.isLegacy === true,377 isLegacy: model?.isLegacy === true,
274 ...(isModelSelectionUnavailable(config, fallbackModelSelection) ||378 ...(isModelSelectionUnavailable(config, fallbackModelSelection, projectId) ||
379 (provider !== undefined && !isProviderEnabledForProject(config, provider, projectId)) ||
275 provider?.updateRequiredModels?.some((gated) => gated.slug === fallbackModelSelection.model)380 provider?.updateRequiredModels?.some((gated) => gated.slug === fallbackModelSelection.model)
276 ? { isUnavailable: true }381 ? { isUnavailable: true }
277 : {}),382 : {}),
apps/mobile/src/state/use-thread-composer-state.ts+10-5
...@@ -39,7 +39,10 @@ import { composerContextSendBlockReason, reidentifyComposerContext } from "../li...@@ -39,7 +39,10 @@ import { composerContextSendBlockReason, reidentifyComposerContext } from "../li
39import { uuidv4 } from "../lib/uuid";39import { uuidv4 } from "../lib/uuid";
4040
41import { makeQueuedMessageMetadata } from "../lib/commandMetadata";41import { makeQueuedMessageMetadata } from "../lib/commandMetadata";
42import { isModelSelectionUnavailable } from "../lib/modelOptions";42import {
43 describeUnavailableModelSelection,
44 isModelSelectionUnavailable,
45} from "../lib/modelOptions";
43import { resolveProviderInteractionMode } from "./legacy-plan-mode";46import { resolveProviderInteractionMode } from "./legacy-plan-mode";
44import {47import {
45 convertPastedImagesToAttachments,48 convertPastedImagesToAttachments,
...@@ -616,12 +619,14 @@ export function useThreadComposerState() {...@@ -616,12 +619,14 @@ export function useThreadComposerState() {
616 const serverConfig = selectedEnvironmentRuntime?.serverConfig;619 const serverConfig = selectedEnvironmentRuntime?.serverConfig;
617 if (620 if (
618 selectedEnvironmentRuntime?.connectionState === "connected" &&621 selectedEnvironmentRuntime?.connectionState === "connected" &&
619 isModelSelectionUnavailable(serverConfig, modelSelection)622 isModelSelectionUnavailable(serverConfig, modelSelection, thread.projectId)
620 ) {623 ) {
621 Alert.alert(624 const { title, detail } = describeUnavailableModelSelection(
622 "Antigravity model unavailable",625 serverConfig,
623 "Set up Antigravity on web or desktop, or choose another model.",626 modelSelection,
627 thread.projectId,
624 );628 );
629 Alert.alert(title, detail);
625 return null;630 return null;
626 }631 }
627 const provider = serverConfig?.providers.find(632 const provider = serverConfig?.providers.find(
apps/mobile/src/state/use-thread-outbox-drain.ts+28-17
...@@ -26,7 +26,10 @@ import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"...@@ -26,7 +26,10 @@ import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"
26import { serializeComposerMessageForServer, uploadedComposerContext } from "../lib/composerContext";26import { serializeComposerMessageForServer, uploadedComposerContext } from "../lib/composerContext";
27import { prepareTurnAttachments, type PreparedTurnAttachments } from "../lib/attachmentUpload";27import { prepareTurnAttachments, type PreparedTurnAttachments } from "../lib/attachmentUpload";
28import { randomHex } from "../lib/uuid";28import { randomHex } from "../lib/uuid";
29import { isModelSelectionUnavailable } from "../lib/modelOptions";29import {
30 describeUnavailableModelSelection,
31 isModelSelectionUnavailable,
32} from "../lib/modelOptions";
30import {33import {
31 retainAcknowledgedThreadMessage,34 retainAcknowledgedThreadMessage,
32 forgetAcknowledgedThreadMessage,35 forgetAcknowledgedThreadMessage,
...@@ -811,11 +814,13 @@ export function useThreadOutboxDrain(): void {...@@ -811,11 +814,13 @@ export function useThreadOutboxDrain(): void {
811 );814 );
812 if (!serverConfig) return false;815 if (!serverConfig) return false;
813 const settings = resolveQueuedThreadSettings(queuedMessage, thread, serverConfig.providers);816 const settings = resolveQueuedThreadSettings(queuedMessage, thread, serverConfig.providers);
814 if (isModelSelectionUnavailable(serverConfig, settings.modelSelection)) {817 if (isModelSelectionUnavailable(serverConfig, settings.modelSelection, thread.projectId)) {
815 return restoreQueuedMessage(818 const { title, detail } = describeUnavailableModelSelection(
816 queuedMessage,819 serverConfig,
817 "Antigravity model unavailable. Set it up on web or desktop, or choose another model.",820 settings.modelSelection,
821 thread.projectId,
818 );822 );
823 return restoreQueuedMessage(queuedMessage, `${title}. ${detail}`);
819 }824 }
820 const { reportFailure } = makeDeliveryHelpers(queuedMessage);825 const { reportFailure } = makeDeliveryHelpers(queuedMessage);
821826
...@@ -893,11 +898,13 @@ export function useThreadOutboxDrain(): void {...@@ -893,11 +898,13 @@ export function useThreadOutboxDrain(): void {
893 serverEnvironment.configValueAtom(queuedMessage.environmentId),898 serverEnvironment.configValueAtom(queuedMessage.environmentId),
894 );899 );
895 if (!currentConfig) return false;900 if (!currentConfig) return false;
896 if (isModelSelectionUnavailable(currentConfig, settings.modelSelection)) {901 if (isModelSelectionUnavailable(currentConfig, settings.modelSelection, thread.projectId)) {
897 return restoreQueuedMessage(902 const { title, detail } = describeUnavailableModelSelection(
898 persistedMessage,903 currentConfig,
899 "Antigravity model unavailable. Set it up on web or desktop, or choose another model.",904 settings.modelSelection,
905 thread.projectId,
900 );906 );
907 return restoreQueuedMessage(persistedMessage, `${title}. ${detail}`);
901 }908 }
902 const sendSettings = resolveQueuedThreadSettings(909 const sendSettings = resolveQueuedThreadSettings(
903 queuedMessage,910 queuedMessage,
...@@ -990,11 +997,13 @@ export function useThreadOutboxDrain(): void {...@@ -990,11 +997,13 @@ export function useThreadOutboxDrain(): void {
990 },997 },
991 serverConfig.providers,998 serverConfig.providers,
992 );999 );
993 if (isModelSelectionUnavailable(serverConfig, settings.modelSelection)) {1000 if (isModelSelectionUnavailable(serverConfig, settings.modelSelection, creation.projectId)) {
994 return restoreQueuedMessage(1001 const { title, detail } = describeUnavailableModelSelection(
995 queuedMessage,1002 serverConfig,
996 "Antigravity model unavailable. Set it up on web or desktop, or choose another model.",1003 settings.modelSelection,
1004 creation.projectId,
997 );1005 );
1006 return restoreQueuedMessage(queuedMessage, `${title}. ${detail}`);
998 }1007 }
999 let prepared: PreparedTurnAttachments;1008 let prepared: PreparedTurnAttachments;
1000 let persistedMessage: QueuedThreadMessage;1009 let persistedMessage: QueuedThreadMessage;
...@@ -1035,11 +1044,13 @@ export function useThreadOutboxDrain(): void {...@@ -1035,11 +1044,13 @@ export function useThreadOutboxDrain(): void {
1035 serverEnvironment.configValueAtom(queuedMessage.environmentId),1044 serverEnvironment.configValueAtom(queuedMessage.environmentId),
1036 );1045 );
1037 if (!currentConfig) return false;1046 if (!currentConfig) return false;
1038 if (isModelSelectionUnavailable(currentConfig, settings.modelSelection)) {1047 if (isModelSelectionUnavailable(currentConfig, settings.modelSelection, creation.projectId)) {
1039 return restoreQueuedMessage(1048 const { title, detail } = describeUnavailableModelSelection(
1040 persistedMessage,1049 currentConfig,
1041 "Antigravity model unavailable. Set it up on web or desktop, or choose another model.",1050 settings.modelSelection,
1051 creation.projectId,
1042 );1052 );
1053 return restoreQueuedMessage(persistedMessage, `${title}. ${detail}`);
1043 }1054 }
1044 const sendSettings = resolveQueuedThreadSettings(1055 const sendSettings = resolveQueuedThreadSettings(
1045 queuedMessage,1056 queuedMessage,
apps/server/src/cli/project.test.ts+3
...@@ -25,6 +25,7 @@ import * as References from "effect/References";...@@ -25,6 +25,7 @@ import * as References from "effect/References";
25import * as Stream from "effect/Stream";25import * as Stream from "effect/Stream";
26import { Command } from "effect/cli";26import { Command } from "effect/cli";
2727
28import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
28import { cli } from "../binCli.ts";29import { cli } from "../binCli.ts";
29import * as ServerConfig from "../config.ts";30import * as ServerConfig from "../config.ts";
30import * as EventSink from "../orchestration-v2/EventSink.ts";31import * as EventSink from "../orchestration-v2/EventSink.ts";
...@@ -37,6 +38,7 @@ import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";...@@ -37,6 +38,7 @@ import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
37import * as ProjectService from "../project/ProjectService.ts";38import * as ProjectService from "../project/ProjectService.ts";
38import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";39import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
39import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts";40import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts";
41import * as ServerSettings from "../serverSettings.ts";
40import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";42import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
41import {43import {
42 ProjectLiveServerDeclaredResponseError,44 ProjectLiveServerDeclaredResponseError,
...@@ -93,6 +95,7 @@ const readProjects = (baseDir: string) =>...@@ -93,6 +95,7 @@ const readProjects = (baseDir: string) =>
93 Layer.provideMerge(ProjectFaviconResolver.layer),95 Layer.provideMerge(ProjectFaviconResolver.layer),
94 Layer.provideMerge(T3ProjectFileLoader.layer),96 Layer.provideMerge(T3ProjectFileLoader.layer),
95 Layer.provideMerge(WorkspacePaths.layer),97 Layer.provideMerge(WorkspacePaths.layer),
98 Layer.provideMerge(ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer))),
96 Layer.provideMerge(SqlitePersistence.layerConfig),99 Layer.provideMerge(SqlitePersistence.layerConfig),
97 Layer.provideMerge(NodeServices.layer),100 Layer.provideMerge(NodeServices.layer),
98 Layer.provide(ServerConfig.layer(config)),101 Layer.provide(ServerConfig.layer(config)),
apps/server/src/cli/project.ts+3
...@@ -22,6 +22,7 @@ import { FetchHttpClient, HttpClient, HttpClientError } from "effect/http";...@@ -22,6 +22,7 @@ import { FetchHttpClient, HttpClient, HttpClientError } from "effect/http";
22import * as HttpApiClient from "effect/http-api/HttpApiClient";22import * as HttpApiClient from "effect/http-api/HttpApiClient";
2323
24import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";24import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
25import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
2526
26import * as ServerConfig from "../config.ts";27import * as ServerConfig from "../config.ts";
27import * as SqlitePersistence from "../persistence/Sqlite.ts";28import * as SqlitePersistence from "../persistence/Sqlite.ts";
...@@ -32,6 +33,7 @@ import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolv...@@ -32,6 +33,7 @@ import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolv
32import * as ProjectService from "../project/ProjectService.ts";33import * as ProjectService from "../project/ProjectService.ts";
33import { projectMutationOperation } from "../project/ProjectMutation.ts";34import { projectMutationOperation } from "../project/ProjectMutation.ts";
34import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts";35import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts";
36import * as ServerSettings from "../serverSettings.ts";
35import {37import {
36 clearPersistedServerRuntimeState,38 clearPersistedServerRuntimeState,
37 readPersistedServerRuntimeState,39 readPersistedServerRuntimeState,
...@@ -206,6 +208,7 @@ const layerProjectCliRuntime = RuntimeLayer.layerProjectService.pipe(...@@ -206,6 +208,7 @@ const layerProjectCliRuntime = RuntimeLayer.layerProjectService.pipe(
206 ),208 ),
207 ),209 ),
208 Layer.provideMerge(WorkspacePaths.layer),210 Layer.provideMerge(WorkspacePaths.layer),
211 Layer.provideMerge(ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer))),
209 Layer.provideMerge(SqlitePersistence.layerConfig),212 Layer.provideMerge(SqlitePersistence.layerConfig),
210);213);
211214
apps/server/src/environment/ServerEnvironment.test.ts+1
...@@ -222,6 +222,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {...@@ -222,6 +222,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
222 expect(second.capabilities.threadPullRequests).toBe(true);222 expect(second.capabilities.threadPullRequests).toBe(true);
223 expect(second.capabilities.threadPullRequestLinking).toBe(true);223 expect(second.capabilities.threadPullRequestLinking).toBe(true);
224 expect(second.capabilities.serverResolvedCommandContext).toBe(true);224 expect(second.capabilities.serverResolvedCommandContext).toBe(true);
225 expect(second.capabilities.projectProviderInstanceOverrides).toBe(true);
225 expect(second.capabilities.agentActivityPublishing).toBe(false);226 expect(second.capabilities.agentActivityPublishing).toBe(false);
226 }),227 }),
227 );228 );
apps/server/src/environment/ServerEnvironment.ts+1
...@@ -234,6 +234,7 @@ export const make = Effect.gen(function* () {...@@ -234,6 +234,7 @@ export const make = Effect.gen(function* () {
234 worktreesDirectory: true,234 worktreesDirectory: true,
235 threadRestartContinuation: true,235 threadRestartContinuation: true,
236 projectSettingsOverrides: true,236 projectSettingsOverrides: true,
237 projectProviderInstanceOverrides: true,
237 threadSnooze: true,238 threadSnooze: true,
238 environmentThemes: true,239 environmentThemes: true,
239 usageLimitSources: true,240 usageLimitSources: true,
apps/server/src/git/GitManager.ts+7-5
...@@ -731,7 +731,8 @@ export const make = Effect.gen(function* () {...@@ -731,7 +731,8 @@ export const make = Effect.gen(function* () {
731 readonly threadId?: ThreadId | undefined;731 readonly threadId?: ThreadId | undefined;
732 }) {732 }) {
733 const settings = yield* serverSettingsService.getSettings;733 const settings = yield* serverSettingsService.getSettings;
734 if (!hasProjectSettingsOverrides(settings)) return settings;734 if (!hasProjectSettingsOverrides(settings))
735 return { settings, projectId: null as ProjectId | null };
735 const projectId: ProjectId | null = yield* input.threadId !== undefined736 const projectId: ProjectId | null = yield* input.threadId !== undefined
736 ? threads.getThreadShell(input.threadId).pipe(737 ? threads.getThreadShell(input.threadId).pipe(
737 Effect.map((thread) => thread?.projectId ?? null),738 Effect.map((thread) => thread?.projectId ?? null),
...@@ -741,7 +742,7 @@ export const make = Effect.gen(function* () {...@@ -741,7 +742,7 @@ export const make = Effect.gen(function* () {
741 Effect.map((project) => Option.getOrNull(project)?.projectId ?? null),742 Effect.map((project) => Option.getOrNull(project)?.projectId ?? null),
742 Effect.orElseSucceed(() => null),743 Effect.orElseSucceed(() => null),
743 );744 );
744 return resolveProjectSettings(settings, projectId).settings;745 return { settings: resolveProjectSettings(settings, projectId).settings, projectId };
745 });746 });
746 // Best effort: a settings read failure falls back to the default location.747 // Best effort: a settings read failure falls back to the default location.
747 const readWorktreesDirectory = serverSettingsService.getSettings.pipe(748 const readWorktreesDirectory = serverSettingsService.getSettings.pipe(
...@@ -755,7 +756,7 @@ export const make = Effect.gen(function* () {...@@ -755,7 +756,7 @@ export const make = Effect.gen(function* () {
755 options?.submodules !== undefined756 options?.submodules !== undefined
756 ? options.submodules757 ? options.submodules
757 : yield* projectSettingsFor(input).pipe(758 : yield* projectSettingsFor(input).pipe(
758 Effect.map((settings) => settings.worktreeSubmodules),759 Effect.map(({ settings }) => settings.worktreeSubmodules),
759 Effect.orElseSucceed(() => null),760 Effect.orElseSucceed(() => null),
760 );761 );
761 const worktreesDirectory = yield* readWorktreesDirectory;762 const worktreesDirectory = yield* readWorktreesDirectory;
...@@ -2798,7 +2799,7 @@ export const make = Effect.gen(function* () {...@@ -2798,7 +2799,7 @@ export const make = Effect.gen(function* () {
2798 worktreesDirectory: yield* readWorktreesDirectory,2799 worktreesDirectory: yield* readWorktreesDirectory,
2799 // Best effort: a settings read failure falls back to the checkout's t3.json.2800 // Best effort: a settings read failure falls back to the checkout's t3.json.
2800 submodules: yield* projectSettingsFor(input).pipe(2801 submodules: yield* projectSettingsFor(input).pipe(
2801 Effect.map((settings) => settings.worktreeSubmodules),2802 Effect.map(({ settings }) => settings.worktreeSubmodules),
2802 Effect.orElseSucceed(() => null),2803 Effect.orElseSucceed(() => null),
2803 ),2804 ),
2804 },2805 },
...@@ -2910,7 +2911,7 @@ export const make = Effect.gen(function* () {...@@ -2910,7 +2911,7 @@ export const make = Effect.gen(function* () {
2910 let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined;2911 let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined;
29112912
2912 const textGenerationSettings = yield* projectSettingsFor(input).pipe(2913 const textGenerationSettings = yield* projectSettingsFor(input).pipe(
2913 Effect.flatMap((settings) =>2914 Effect.flatMap(({ settings, projectId }) =>
2914 settings.sourceControlWriterModelSelection === null2915 settings.sourceControlWriterModelSelection === null
2915 ? Effect.succeed({2916 ? Effect.succeed({
2916 modelSelection: settings.textGenerationModelSelection,2917 modelSelection: settings.textGenerationModelSelection,
...@@ -2921,6 +2922,7 @@ export const make = Effect.gen(function* () {...@@ -2921,6 +2922,7 @@ export const make = Effect.gen(function* () {
2921 modelSelection: ServerSettings.resolveSourceControlWriterModelSelection(2922 modelSelection: ServerSettings.resolveSourceControlWriterModelSelection(
2922 settings,2923 settings,
2923 providers,2924 providers,
2925 projectId,
2924 ),2926 ),
2925 style: settings.sourceControlWritingStyle,2927 style: settings.sourceControlWritingStyle,
2926 })),2928 })),
apps/server/src/mcp/McpToolAccess.race.test.ts+18-2
...@@ -17,10 +17,12 @@ import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai";...@@ -17,10 +17,12 @@ import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai";
17import * as SqlitePersistence from "../persistence/Sqlite.ts";17import * as SqlitePersistence from "../persistence/Sqlite.ts";
18import { DispatchModeLimit } from "../orchestration-v2/DispatchModeLimit.ts";18import { DispatchModeLimit } from "../orchestration-v2/DispatchModeLimit.ts";
19import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts";19import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts";
20import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts";
20import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";21import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
21import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";22import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
22import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";23import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";
23import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts";24import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts";
25import * as ServerSettings from "../serverSettings.ts";
24import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts";26import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts";
25import * as McpHttpServer from "./McpHttpServer.ts";27import * as McpHttpServer from "./McpHttpServer.ts";
26import * as McpInvocationContext from "./McpInvocationContext.ts";28import * as McpInvocationContext from "./McpInvocationContext.ts";
...@@ -184,7 +186,14 @@ it.layer(layerOrchestrator)("writesThreads against a mode raise", (it) => {...@@ -184,7 +186,14 @@ it.layer(layerOrchestrator)("writesThreads against a mode raise", (it) => {
184 const shell = yield* projections.getThreadShell(threadId);186 const shell = yield* projections.getThreadShell(threadId);
185 assert.equal(shell?.runtimeMode, "full-access");187 assert.equal(shell?.runtimeMode, "full-access");
186 assert.equal(shell?.title, "Before");188 assert.equal(shell?.title, "Before");
187 }).pipe(Effect.provide(ThreadManagement.layer)),189 }).pipe(
190 Effect.provide(
191 ThreadManagement.layer.pipe(
192 Layer.provide(ServerSettings.layerTest()),
193 Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))),
194 ),
195 ),
196 ),
188 );197 );
189198
190 it.effect("lets the write through when the thread stays within the caller's modes", () =>199 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) => {...@@ -195,6 +204,13 @@ it.layer(layerOrchestrator)("writesThreads against a mode raise", (it) => {
195 const outcome = yield* renameRacingTheUser(threadId, false);204 const outcome = yield* renameRacingTheUser(threadId, false);
196 assert.deepEqual(outcome, { renamed: true });205 assert.deepEqual(outcome, { renamed: true });
197 assert.equal((yield* projections.getThreadShell(threadId))?.title, "Renamed by the agent");206 assert.equal((yield* projections.getThreadShell(threadId))?.title, "Renamed by the agent");
198 }).pipe(Effect.provide(ThreadManagement.layer)),207 }).pipe(
208 Effect.provide(
209 ThreadManagement.layer.pipe(
210 Layer.provide(ServerSettings.layerTest()),
211 Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))),
212 ),
213 ),
214 ),
199 );215 );
200});216});
apps/server/src/mcp/OrchestratorMcpService.test.ts+289
...@@ -1099,6 +1099,8 @@ describe("OrchestratorMcpService provider resolution", () => {...@@ -1099,6 +1099,8 @@ describe("OrchestratorMcpService provider resolution", () => {
1099 const layerDependencies = Layer.mergeAll(1099 const layerDependencies = Layer.mergeAll(
1100 NodeServices.layer,1100 NodeServices.layer,
1101 Layer.mock(ThreadManagementService.ThreadManagementService)({1101 Layer.mock(ThreadManagementService.ThreadManagementService)({
1102 hasReceipt: () => Effect.succeed(false),
1103 assertProviderInstanceEnabled: () => Effect.void,
1102 getThreadRecords: (threadId) =>1104 getThreadRecords: (threadId) =>
1103 Effect.succeed(1105 Effect.succeed(
1104 threadId === parentThreadId1106 threadId === parentThreadId
...@@ -1197,6 +1199,9 @@ describe("OrchestratorMcpService provider resolution", () => {...@@ -1197,6 +1199,9 @@ describe("OrchestratorMcpService provider resolution", () => {
1197 const layerDependencies = Layer.mergeAll(1199 const layerDependencies = Layer.mergeAll(
1198 NodeServices.layer,1200 NodeServices.layer,
1199 Layer.mock(ThreadManagementService.ThreadManagementService)({1201 Layer.mock(ThreadManagementService.ThreadManagementService)({
1202 hasReceipt: () => Effect.succeed(false),
1203 assertProviderInstanceEnabled: () => Effect.void,
1204 isProviderInstanceEnabledForProject: () => Effect.succeed(true),
1200 getThreadRecords: (threadId) =>1205 getThreadRecords: (threadId) =>
1201 Effect.succeed(1206 Effect.succeed(
1202 threadId === parentThreadId1207 threadId === parentThreadId
...@@ -1275,6 +1280,7 @@ describe("OrchestratorMcpService provider resolution", () => {...@@ -1275,6 +1280,7 @@ describe("OrchestratorMcpService provider resolution", () => {
1275 const layerDependencies = Layer.mergeAll(1280 const layerDependencies = Layer.mergeAll(
1276 NodeServices.layer,1281 NodeServices.layer,
1277 Layer.mock(ThreadManagementService.ThreadManagementService)({1282 Layer.mock(ThreadManagementService.ThreadManagementService)({
1283 hasReceipt: () => Effect.succeed(false),
1278 getThreadRecords: () => Effect.succeed(parentProjection([])),1284 getThreadRecords: () => Effect.succeed(parentProjection([])),
1279 }),1285 }),
1280 ServerSettings.layerTest(),1286 ServerSettings.layerTest(),
...@@ -1321,6 +1327,284 @@ describe("OrchestratorMcpService provider resolution", () => {...@@ -1321,6 +1327,284 @@ describe("OrchestratorMcpService provider resolution", () => {
1321 }),1327 }),
1322 );1328 );
13231329
1330 it.effect("refuses delegation to a provider the project turned off", () =>
1331 Effect.gen(function* () {
1332 const layerDependencies = Layer.mergeAll(
1333 NodeServices.layer,
1334 Layer.mock(ThreadManagementService.ThreadManagementService)({
1335 hasReceipt: () => Effect.succeed(false),
1336 getThreadRecords: () => Effect.succeed(parentProjection([])),
1337 // ThreadManagementService.dispatch is the chokepoint now: it is the
1338 // one that refuses, not a separate pre-dispatch assert.
1339 dispatch: () =>
1340 Effect.fail(
1341 new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({
1342 projectId,
1343 instanceId: codexInstanceId,
1344 }),
1345 ),
1346 }),
1347 providerRegistryLayer([
1348 providerSnapshot({
1349 instanceId: codexInstanceId,
1350 driver: ProviderDriverKind.make("codex"),
1351 model: "gpt-5.4",
1352 }),
1353 ]),
1354 adapterRegistryLayer([codexInstanceId]),
1355 Layer.mock(ProjectService.ProjectService)({}),
1356 Layer.mock(SecretRequests.SecretRequests)({}),
1357 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
1358 ServerSettings.layerTest(),
1359 );
1360
1361 yield* Effect.gen(function* () {
1362 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
1363 const error = yield* service
1364 .delegateTask(scope, {
1365 task: "Summarize the diff.",
1366 target: { providerInstanceId: codexInstanceId },
1367 mode: "async",
1368 clientRequestId: "delegate-disabled-1",
1369 })
1370 .pipe(Effect.flip);
1371 assert.equal(error.code, "invalid_request");
1372 }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies))));
1373 }),
1374 );
1375
1376 it.effect("driver-only delegation skips an instance the project turned off", () =>
1377 Effect.gen(function* () {
1378 const altInstanceId = ProviderInstanceId.make("codex-alt");
1379 const dispatched = yield* Ref.make<ReadonlyArray<unknown>>([]);
1380 const task = {
1381 id: taskId,
1382 threadId: parentThreadId,
1383 runId: parentRunId,
1384 parentNodeId,
1385 origin: "app_owned",
1386 createdBy: "agent",
1387 driver: ProviderDriverKind.make("codex"),
1388 providerInstanceId: altInstanceId,
1389 providerThreadId: null,
1390 childThreadId,
1391 nativeTaskRef: null,
1392 prompt: "Summarize the diff.",
1393 title: null,
1394 model: "gpt-5.4",
1395 status: "running",
1396 result: null,
1397 startedAt: null,
1398 completedAt: null,
1399 };
1400 let delegated = false;
1401 const layerDependencies = Layer.mergeAll(
1402 NodeServices.layer,
1403 Layer.mock(ThreadManagementService.ThreadManagementService)({
1404 hasReceipt: () => Effect.succeed(false),
1405 getThreadRecords: (threadId) =>
1406 Effect.succeed(
1407 threadId === parentThreadId
1408 ? parentProjection(delegated ? [task] : [])
1409 : childProjection,
1410 ),
1411 // The parent's own instance is off for this project; the other
1412 // instance of the same driver is on.
1413 isProviderInstanceEnabledForProject: ({ instanceId }) =>
1414 Effect.succeed(instanceId !== codexInstanceId),
1415 assertProviderInstanceEnabled: ({ projectId, instanceId }) =>
1416 instanceId === codexInstanceId
1417 ? Effect.fail(
1418 new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({
1419 projectId,
1420 instanceId,
1421 }),
1422 )
1423 : Effect.void,
1424 dispatch: (command) =>
1425 Ref.update(dispatched, (commands) => [...commands, command]).pipe(
1426 Effect.andThen(
1427 Effect.sync(() => {
1428 delegated = true;
1429 }),
1430 ),
1431 Effect.as({
1432 sequence: 1,
1433 storedEvents: [
1434 {
1435 sequence: 1,
1436 commandId: null,
1437 event: { type: "subagent.updated", payload: task },
1438 },
1439 ],
1440 } as never),
1441 ),
1442 }),
1443 providerRegistryLayer([
1444 providerSnapshot({
1445 instanceId: codexInstanceId,
1446 driver: ProviderDriverKind.make("codex"),
1447 model: "gpt-5.4",
1448 }),
1449 providerSnapshot({
1450 instanceId: altInstanceId,
1451 driver: ProviderDriverKind.make("codex"),
1452 model: "gpt-5.4",
1453 }),
1454 ]),
1455 adapterRegistryLayer([codexInstanceId, altInstanceId]),
1456 Layer.mock(ProjectService.ProjectService)({}),
1457 Layer.mock(SecretRequests.SecretRequests)({}),
1458 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
1459 ServerSettings.layerTest(),
1460 );
1461
1462 yield* Effect.gen(function* () {
1463 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
1464 const result = yield* service.delegateTask(scope, {
1465 task: "Summarize the diff.",
1466 target: { driverKind: ProviderDriverKind.make("codex") },
1467 mode: "async",
1468 clientRequestId: "delegate-driver-skip-disabled-1",
1469 });
1470 assert.equal(result.status, "running");
1471 const commands = yield* Ref.get(dispatched);
1472 assert.equal(commands.length, 1);
1473 const request = commands[0] as {
1474 modelSelection: { instanceId: string; model: string };
1475 };
1476 assert.equal(request.modelSelection.instanceId, altInstanceId);
1477 }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies))));
1478 }),
1479 );
1480
1481 it.effect(
1482 "retrying an accepted driver-only delegation replays even after the project disables every candidate",
1483 () =>
1484 Effect.gen(function* () {
1485 const altInstanceId = ProviderInstanceId.make("codex-alt");
1486 const dispatched = yield* Ref.make<ReadonlyArray<unknown>>([]);
1487 const accepted = yield* Ref.make(false);
1488 const everyCandidateDisabled = yield* Ref.make(false);
1489 const task = {
1490 id: taskId,
1491 threadId: parentThreadId,
1492 runId: parentRunId,
1493 parentNodeId,
1494 origin: "app_owned",
1495 createdBy: "agent",
1496 driver: ProviderDriverKind.make("codex"),
1497 providerInstanceId: codexInstanceId,
1498 providerThreadId: null,
1499 childThreadId,
1500 nativeTaskRef: null,
1501 prompt: "Summarize the diff.",
1502 title: null,
1503 model: "gpt-5.4",
1504 status: "running",
1505 result: null,
1506 startedAt: null,
1507 completedAt: null,
1508 };
1509 const layerDependencies = Layer.mergeAll(
1510 NodeServices.layer,
1511 Layer.mock(ThreadManagementService.ThreadManagementService)({
1512 getThreadRecords: (threadId) =>
1513 Ref.get(accepted).pipe(
1514 Effect.map((isAccepted) =>
1515 threadId === parentThreadId
1516 ? parentProjection(isAccepted ? [task] : [])
1517 : childProjection,
1518 ),
1519 ),
1520 // A command id already accepted once, even if the project later
1521 // disables every candidate: its retry must replay, not re-resolve.
1522 hasReceipt: () => Ref.get(accepted),
1523 // Effective settings once the project turns every instance off,
1524 // ignoring the already-accepted command's replay exemption.
1525 isProviderInstanceEnabledForProject: () =>
1526 Ref.get(everyCandidateDisabled).pipe(Effect.map((disabled) => !disabled)),
1527 assertProviderInstanceEnabled: ({ projectId, instanceId }) =>
1528 Effect.all([Ref.get(accepted), Ref.get(everyCandidateDisabled)]).pipe(
1529 Effect.flatMap(([isAccepted, disabled]) =>
1530 isAccepted || !disabled
1531 ? Effect.void
1532 : Effect.fail(
1533 new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({
1534 projectId,
1535 instanceId,
1536 }),
1537 ),
1538 ),
1539 ),
1540 dispatch: (command) =>
1541 Ref.update(dispatched, (commands) => [...commands, command]).pipe(
1542 Effect.andThen(Ref.set(accepted, true)),
1543 Effect.as({
1544 sequence: 1,
1545 storedEvents: [
1546 {
1547 sequence: 1,
1548 commandId: null,
1549 event: { type: "subagent.updated", payload: task },
1550 },
1551 ],
1552 } as never),
1553 ),
1554 }),
1555 providerRegistryLayer([
1556 providerSnapshot({
1557 instanceId: codexInstanceId,
1558 driver: ProviderDriverKind.make("codex"),
1559 model: "gpt-5.4",
1560 }),
1561 providerSnapshot({
1562 instanceId: altInstanceId,
1563 driver: ProviderDriverKind.make("codex"),
1564 model: "gpt-5.4",
1565 }),
1566 ]),
1567 adapterRegistryLayer([codexInstanceId, altInstanceId]),
1568 Layer.mock(ProjectService.ProjectService)({}),
1569 Layer.mock(SecretRequests.SecretRequests)({}),
1570 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
1571 ServerSettings.layerTest(),
1572 );
1573
1574 yield* Effect.gen(function* () {
1575 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
1576 const first = yield* service.delegateTask(scope, {
1577 task: "Summarize the diff.",
1578 target: { driverKind: ProviderDriverKind.make("codex") },
1579 mode: "async",
1580 clientRequestId: "delegate-driver-retry-after-disable-1",
1581 });
1582 assert.equal(first.status, "running");
1583
1584 // The project turns off every instance of this driver only after
1585 // the delegation above was already accepted.
1586 yield* Ref.set(everyCandidateDisabled, true);
1587
1588 const retry = yield* service.delegateTask(scope, {
1589 task: "Summarize the diff.",
1590 target: { driverKind: ProviderDriverKind.make("codex") },
1591 mode: "async",
1592 clientRequestId: "delegate-driver-retry-after-disable-1",
1593 });
1594 assert.equal(retry.status, "running");
1595
1596 const commands = yield* Ref.get(dispatched);
1597 assert.equal(commands.length, 2);
1598 const retryCommand = commands[1] as {
1599 modelSelection: { instanceId: string; model: string };
1600 };
1601 assert.equal(retryCommand.modelSelection.instanceId, codexInstanceId);
1602 }).pipe(
1603 Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies))),
1604 );
1605 }),
1606 );
1607
1324 it.effect("re-probes an unavailable target once before refusing it", () =>1608 it.effect("re-probes an unavailable target once before refusing it", () =>
1325 Effect.gen(function* () {1609 Effect.gen(function* () {
1326 const claudeInstanceId = ProviderInstanceId.make("claudeAgent");1610 const claudeInstanceId = ProviderInstanceId.make("claudeAgent");
...@@ -1368,6 +1652,8 @@ describe("OrchestratorMcpService provider resolution", () => {...@@ -1368,6 +1652,8 @@ describe("OrchestratorMcpService provider resolution", () => {
1368 const layerDependencies = Layer.mergeAll(1652 const layerDependencies = Layer.mergeAll(
1369 NodeServices.layer,1653 NodeServices.layer,
1370 Layer.mock(ThreadManagementService.ThreadManagementService)({1654 Layer.mock(ThreadManagementService.ThreadManagementService)({
1655 hasReceipt: () => Effect.succeed(false),
1656 assertProviderInstanceEnabled: () => Effect.void,
1371 getThreadRecords: (threadId) =>1657 getThreadRecords: (threadId) =>
1372 Effect.succeed(1658 Effect.succeed(
1373 threadId === parentThreadId ? parentProjection([task]) : childProjection,1659 threadId === parentThreadId ? parentProjection([task]) : childProjection,
...@@ -1513,6 +1799,9 @@ describe("OrchestratorMcpService provider resolution", () => {...@@ -1513,6 +1799,9 @@ describe("OrchestratorMcpService provider resolution", () => {
1513 const layerDependencies = Layer.mergeAll(1799 const layerDependencies = Layer.mergeAll(
1514 NodeServices.layer,1800 NodeServices.layer,
1515 Layer.mock(ThreadManagementService.ThreadManagementService)({1801 Layer.mock(ThreadManagementService.ThreadManagementService)({
1802 hasReceipt: () => Effect.succeed(false),
1803 assertProviderInstanceEnabled: () => Effect.void,
1804 isProviderInstanceEnabledForProject: () => Effect.succeed(true),
1516 getThreadRecords: (threadId) =>1805 getThreadRecords: (threadId) =>
1517 Effect.succeed(1806 Effect.succeed(
1518 threadId === parentThreadId1807 threadId === parentThreadId
apps/server/src/mcp/OrchestratorMcpService.ts+77-19
...@@ -215,6 +215,8 @@ function threadManagementFailure(error: unknown): OrchestratorMcpFailure {...@@ -215,6 +215,8 @@ function threadManagementFailure(error: unknown): OrchestratorMcpFailure {
215 case "ThreadManagementProjectThreadsListError":215 case "ThreadManagementProjectThreadsListError":
216 case "ThreadManagementDurableRunProjectionError":216 case "ThreadManagementDurableRunProjectionError":
217 return failure("orchestration_error", error.message);217 return failure("orchestration_error", error.message);
218 case "ThreadManagementProviderInstanceDisabledError":
219 return failure("invalid_request", error.message);
218 }220 }
219}221}
220222
...@@ -1135,6 +1137,19 @@ const make = Effect.gen(function* () {...@@ -1135,6 +1137,19 @@ const make = Effect.gen(function* () {
1135 * unattended agent can see a provider as unavailable after it was fixed.1137 * unattended agent can see a provider as unavailable after it was fixed.
1136 * Re-probe the requested instance once before refusing it.1138 * Re-probe the requested instance once before refusing it.
1137 */1139 */
1140 const assertTargetEnabledForProject = (
1141 parent: Pick<OrchestrationV2ThreadProjection, "thread">,
1142 commandId: CommandId,
1143 target: ResolvedTarget,
1144 ) =>
1145 threadManagement
1146 .assertProviderInstanceEnabled({
1147 projectId: parent.thread.projectId,
1148 commandId,
1149 instanceId: target.modelSelection.instanceId,
1150 })
1151 .pipe(Effect.mapError(threadManagementFailure));
1152
1138 const resolveTargetRechecking = (input: Parameters<typeof resolveTarget>[0]) => {1153 const resolveTargetRechecking = (input: Parameters<typeof resolveTarget>[0]) => {
1139 const instanceId =1154 const instanceId =
1140 input.target?.providerInstanceId ??1155 input.target?.providerInstanceId ??
...@@ -1158,6 +1173,16 @@ const make = Effect.gen(function* () {...@@ -1158,6 +1173,16 @@ const make = Effect.gen(function* () {
1158 readonly parent: Pick<OrchestrationV2ThreadProjection, "thread">;1173 readonly parent: Pick<OrchestrationV2ThreadProjection, "thread">;
1159 readonly target: OrchestratorMcpTarget | undefined;1174 readonly target: OrchestratorMcpTarget | undefined;
1160 readonly providers: ReadonlyArray<ServerProvider>;1175 readonly providers: ReadonlyArray<ServerProvider>;
1176 /**
1177 * Skips the per-candidate project-enablement check in driver-only
1178 * resolution. Set only when the command that will dispatch this target
1179 * already has a receipt: that dispatch can only replay the recorded
1180 * result, never start anything new, so filtering candidates by the
1181 * project's current settings would just refuse a retry that does no new
1182 * work. Never set this for a resolution whose dispatch uses a different
1183 * command id than the one checked for a receipt.
1184 */
1185 readonly skipProjectEnablementFilter?: boolean;
1161 }): Effect.Effect<ResolvedTarget, OrchestratorMcpFailure> =>1186 }): Effect.Effect<ResolvedTarget, OrchestratorMcpFailure> =>
1162 Effect.gen(function* () {1187 Effect.gen(function* () {
1163 const requestedInstanceId = input.target?.providerInstanceId;1188 const requestedInstanceId = input.target?.providerInstanceId;
...@@ -1179,20 +1204,37 @@ const make = Effect.gen(function* () {...@@ -1179,20 +1204,37 @@ const make = Effect.gen(function* () {
1179 }1204 }
1180 // Inherit the parent's instance only when it can actually serve the1205 // Inherit the parent's instance only when it can actually serve the
1181 // child; an unavailable parent yields to a healthy instance of the1206 // child; an unavailable parent yields to a healthy instance of the
1182 // requested driver rather than failing the delegation.1207 // requested driver rather than failing the delegation. An instance
1183 const inheritedCandidate = candidates.find(1208 // the project turned off cannot serve either, so selection skips it
1184 (candidate) =>1209 // for the next candidate instead of failing the enablement check.
1185 candidate.instanceId === input.parent.thread.modelSelection.instanceId &&1210 const healthyCandidates = candidates.filter(
1186 providerConstraints(candidate, true).length === 0,
1187 );
1188 const availableCandidate = candidates.find(
1189 (candidate) => providerConstraints(candidate, true).length === 0,1211 (candidate) => providerConstraints(candidate, true).length === 0,
1190 );1212 );
1191 instanceId = inheritedCandidate?.instanceId ?? availableCandidate?.instanceId;1213 const inheritedCandidate = healthyCandidates.find(
1214 (candidate) => candidate.instanceId === input.parent.thread.modelSelection.instanceId,
1215 );
1216 const orderedCandidates = [
1217 ...(inheritedCandidate === undefined ? [] : [inheritedCandidate]),
1218 ...healthyCandidates.filter((candidate) => candidate !== inheritedCandidate),
1219 ];
1220 for (const candidate of orderedCandidates) {
1221 const enabledForProject =
1222 input.skipProjectEnablementFilter === true ||
1223 (yield* threadManagement.isProviderInstanceEnabledForProject({
1224 projectId: input.parent.thread.projectId,
1225 instanceId: candidate.instanceId,
1226 }));
1227 if (enabledForProject) {
1228 instanceId = candidate.instanceId;
1229 break;
1230 }
1231 }
1192 if (instanceId === undefined) {1232 if (instanceId === undefined) {
1193 return yield* failure(1233 return yield* failure(
1194 "provider_unavailable",1234 "provider_unavailable",
1195 `No available V2 provider instance for driver ${requestedDriver}.`,1235 healthyCandidates.length === 0
1236 ? `No available V2 provider instance for driver ${requestedDriver}.`
1237 : `No instance of driver ${requestedDriver} is enabled for this project.`,
1196 );1238 );
1197 }1239 }
1198 }1240 }
...@@ -1925,10 +1967,22 @@ const make = Effect.gen(function* () {...@@ -1925,10 +1967,22 @@ const make = Effect.gen(function* () {
1925 );1967 );
1926 }1968 }
1927 const providers = yield* loadProviders;1969 const providers = yield* loadProviders;
1970 // Computed before resolving the target so a retry of an already
1971 // accepted delegation can be recognized: dispatching under the same
1972 // command id only replays the recorded result, so candidate
1973 // selection must not refuse it over the project's current settings.
1974 const key = yield* requestKey(input.clientRequestId);
1975 const commandId = stableCommandId({
1976 scope,
1977 requestKey: key,
1978 operation: "delegate-task",
1979 });
1980 const isRetryOfAcceptedDelegation = yield* threadManagement.hasReceipt(commandId);
1928 const target = yield* resolveTargetRechecking({1981 const target = yield* resolveTargetRechecking({
1929 parent,1982 parent,
1930 target: input.target,1983 target: input.target,
1931 providers,1984 providers,
1985 skipProjectEnablementFilter: isRetryOfAcceptedDelegation,
1932 });1986 });
1933 const runtimeMode = yield* resolveRuntimeMode(1987 const runtimeMode = yield* resolveRuntimeMode(
1934 parent.thread.runtimeMode,1988 parent.thread.runtimeMode,
...@@ -1939,12 +1993,9 @@ const make = Effect.gen(function* () {...@@ -1939,12 +1993,9 @@ const make = Effect.gen(function* () {
1939 parent.thread.interactionMode,1993 parent.thread.interactionMode,
1940 input.interactionMode,1994 input.interactionMode,
1941 );1995 );
1942 const key = yield* requestKey(input.clientRequestId);1996 // ThreadManagementService.dispatch is the chokepoint for this command
1943 const commandId = stableCommandId({1997 // type's own enablement check, using the same command id and its
1944 scope,1998 // receipt-replay exemption, so no separate assert runs here.
1945 requestKey: key,
1946 operation: "delegate-task",
1947 });
1948 const result = yield* threadManagement1999 const result = yield* threadManagement
1949 .dispatch({2000 .dispatch({
1950 type: "delegated_task.request",2001 type: "delegated_task.request",
...@@ -1966,10 +2017,12 @@ const make = Effect.gen(function* () {...@@ -1966,10 +2017,12 @@ const make = Effect.gen(function* () {
1966 })2017 })
1967 .pipe(2018 .pipe(
1968 Effect.mapError((error) =>2019 Effect.mapError((error) =>
1969 failure(2020 error._tag === "ThreadManagementProviderInstanceDisabledError"
1970 "orchestration_error",2021 ? threadManagementFailure(error)
1971 `Unable to create delegated task: ${errorMessage(error)}`,2022 : failure(
1972 ),2023 "orchestration_error",
2024 `Unable to create delegated task: ${errorMessage(error)}`,
2025 ),
1973 ),2026 ),
1974 );2027 );
1975 const taskEvent = result.storedEvents.find(2028 const taskEvent = result.storedEvents.find(
...@@ -2230,6 +2283,11 @@ const make = Effect.gen(function* () {...@@ -2230,6 +2283,11 @@ const make = Effect.gen(function* () {
2230 requestKey: key,2283 requestKey: key,
2231 index,2284 index,
2232 });2285 });
2286 yield* assertTargetEnabledForProject(
2287 parent,
2288 stableCommandId({ scope, requestKey: key, operation: "create-thread", index }),
2289 target,
2290 );
2233 const title = threadTitle({2291 const title = threadTitle({
2234 parentTitle: parent.thread.title,2292 parentTitle: parent.thread.title,
2235 prompt: request.prompt,2293 prompt: request.prompt,
apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts+17-3
...@@ -54,9 +54,12 @@ import { ClaudeProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/Claud...@@ -54,9 +54,12 @@ import { ClaudeProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/Claud
54import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts";54import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts";
55import { CodexOrchestratorReplayHarness } from "../orchestration-v2/Adapters/CodexAdapterV2.testkit.ts";55import { CodexOrchestratorReplayHarness } from "../orchestration-v2/Adapters/CodexAdapterV2.testkit.ts";
56import { threadShellFromProjection } from "../orchestration-v2/ProjectionStore.ts";56import { threadShellFromProjection } from "../orchestration-v2/ProjectionStore.ts";
57import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts";
57import * as EventSink from "../orchestration-v2/EventSink.ts";58import * as EventSink from "../orchestration-v2/EventSink.ts";
58import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";59import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
59import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";60import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
61import * as ServerSettings from "../serverSettings.ts";
62import * as SqlitePersistence from "../persistence/Sqlite.ts";
60import * as ThreadSearch from "../orchestration-v2/ThreadSearch.ts";63import * as ThreadSearch from "../orchestration-v2/ThreadSearch.ts";
61import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";64import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";
62import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests";65import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests";
...@@ -70,7 +73,6 @@ import {...@@ -70,7 +73,6 @@ import {
70import * as ProviderRegistryMock from "../provider/testUtils/providerRegistryMock.ts";73import * as ProviderRegistryMock from "../provider/testUtils/providerRegistryMock.ts";
71import * as ProjectService from "../project/ProjectService.ts";74import * as ProjectService from "../project/ProjectService.ts";
72import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";75import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
73import * as ServerSettings from "../serverSettings.ts";
74import * as SecretRequests from "../secrets/SecretRequests.ts";76import * as SecretRequests from "../secrets/SecretRequests.ts";
75import * as ServerSecretStore from "../auth/ServerSecretStore.ts";77import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
76import * as McpHttpServer from "./McpHttpServer.ts";78import * as McpHttpServer from "./McpHttpServer.ts";
...@@ -612,7 +614,13 @@ describe("orchestrator MCP toolkit", () => {...@@ -612,7 +614,13 @@ describe("orchestrator MCP toolkit", () => {
612 ).pipe(Layer.provide(layerContinuationProbe));614 ).pipe(Layer.provide(layerContinuationProbe));
613 const layerOrchestration = Layer.merge(615 const layerOrchestration = Layer.merge(
614 layerOrchestrator,616 layerOrchestrator,
615 ThreadManagementService.layer.pipe(Layer.provide(layerOrchestrator)),617 ThreadManagementService.layer.pipe(
618 Layer.provide(layerOrchestrator),
619 Layer.provide(ServerSettings.layerTest()),
620 Layer.provide(
621 CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)),
622 ),
623 ),
616 );624 );
617 const layerProviderRegistry = ProviderRegistryMock.layer([625 const layerProviderRegistry = ProviderRegistryMock.layer([
618 makeProviderSnapshot({626 makeProviderSnapshot({
...@@ -3809,7 +3817,13 @@ describe("orchestrator MCP toolkit", () => {...@@ -3809,7 +3817,13 @@ describe("orchestrator MCP toolkit", () => {
3809 );3817 );
3810 const layerOrchestration = Layer.merge(3818 const layerOrchestration = Layer.merge(
3811 layerOrchestrator,3819 layerOrchestrator,
3812 ThreadManagementService.layer.pipe(Layer.provide(layerOrchestrator)),3820 ThreadManagementService.layer.pipe(
3821 Layer.provide(layerOrchestrator),
3822 Layer.provide(ServerSettings.layerTest()),
3823 Layer.provide(
3824 CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)),
3825 ),
3826 ),
3813 );3827 );
3814 const layerProviderRegistry = ProviderRegistryMock.layer([3828 const layerProviderRegistry = ProviderRegistryMock.layer([
3815 makeProviderSnapshot({3829 makeProviderSnapshot({
apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts+10-2
...@@ -22,18 +22,20 @@ import * as Queue from "effect/Queue";...@@ -22,18 +22,20 @@ import * as Queue from "effect/Queue";
22import * as Stream from "effect/Stream";22import * as Stream from "effect/Stream";
2323
24import { ClaudeProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts";24import { ClaudeProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts";
25import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts";
25import * as EffectWorker from "../orchestration-v2/EffectWorker.ts";26import * as EffectWorker from "../orchestration-v2/EffectWorker.ts";
26import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";27import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
27import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";28import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
28import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";29import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";
29import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";30import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
31import * as ServerSettings from "../serverSettings.ts";
30import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts";32import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts";
31import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace";33import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace";
34import * as SqlitePersistence from "../persistence/Sqlite.ts";
32import * as ProjectService from "../project/ProjectService.ts";35import * as ProjectService from "../project/ProjectService.ts";
33import * as ProviderRegistry from "../provider/ProviderRegistry.ts";36import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
34import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";37import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
35import * as SecretRequests from "../secrets/SecretRequests.ts";38import * as SecretRequests from "../secrets/SecretRequests.ts";
36import * as ServerSettings from "../serverSettings.ts";
37import type { McpInvocationScope } from "./McpInvocationContext.ts";39import type { McpInvocationScope } from "./McpInvocationContext.ts";
38import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";40import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
3941
...@@ -133,7 +135,13 @@ it.effect("task_cancel ends a delegated child's background native subagent", ()...@@ -133,7 +135,13 @@ it.effect("task_cancel ends a delegated child's background native subagent", ()
133 );135 );
134 const layerOrchestration = Layer.merge(136 const layerOrchestration = Layer.merge(
135 layerOrchestrator,137 layerOrchestrator,
136 ThreadManagementService.layer.pipe(Layer.provide(layerOrchestrator)),138 ThreadManagementService.layer.pipe(
139 Layer.provide(layerOrchestrator),
140 Layer.provide(ServerSettings.layerTest()),
141 Layer.provide(
142 CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)),
143 ),
144 ),
137 );145 );
138 const layerTest = OrchestratorMcpService.layer.pipe(146 const layerTest = OrchestratorMcpService.layer.pipe(
139 Layer.provideMerge(layerOrchestration),147 Layer.provideMerge(layerOrchestration),
apps/server/src/mcp/threadAccess.ts+14-10
...@@ -23,16 +23,20 @@ export const unavailable = () =>...@@ -23,16 +23,20 @@ export const unavailable = () =>
23 });23 });
2424
25/** Decider string rejections are public; wrapped storage and hydration causes are not. */25/** Decider string rejections are public; wrapped storage and hydration causes are not. */
26export const dispatchFailure = (error: OrchestratorV2Error) =>26export const dispatchFailure = (
27 (error._tag === "OrchestratorDispatchError" ||27 error: OrchestratorV2Error | ThreadManagement.ThreadManagementProviderInstanceDisabledError,
28 error._tag === "OrchestratorCommandRejectedError") &&28) =>
29 typeof error.cause === "string" &&29 error._tag === "ThreadManagementProviderInstanceDisabledError"
30 error.cause.length > 030 ? new OrchestratorMcpFailure({ code: "invalid_request", message: error.message })
31 ? new OrchestratorMcpFailure({31 : (error._tag === "OrchestratorDispatchError" ||
32 code: "orchestration_error",32 error._tag === "OrchestratorCommandRejectedError") &&
33 message: Array.from(error.cause).slice(0, 1000).join(""),33 typeof error.cause === "string" &&
34 })34 error.cause.length > 0
35 : unavailable();35 ? new OrchestratorMcpFailure({
36 code: "orchestration_error",
37 message: Array.from(error.cause).slice(0, 1000).join(""),
38 })
39 : unavailable();
3640
37/**41/**
38 * The most a caller may hand to the threads it targets. A thread caller is42 * The most a caller may hand to the threads it targets. A thread caller is
apps/server/src/mcp/toolkits/project/handlers.test.ts+12-1
...@@ -19,6 +19,7 @@ import * as Stream from "effect/Stream";...@@ -19,6 +19,7 @@ import * as Stream from "effect/Stream";
19import * as ThreadLaunch from "../../../orchestration-v2/ThreadLaunchService.ts";19import * as ThreadLaunch from "../../../orchestration-v2/ThreadLaunchService.ts";
20import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts";20import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts";
21import * as ServerConfig from "../../../config.ts";21import * as ServerConfig from "../../../config.ts";
22import * as ServerSettings from "../../../serverSettings.ts";
22import * as Project from "../../../project/ProjectService.ts";23import * as Project from "../../../project/ProjectService.ts";
23import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts";24import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts";
24import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts";25import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts";
...@@ -47,6 +48,7 @@ it.effect("attributes a launched thread's first message to the calling thread",...@@ -47,6 +48,7 @@ it.effect("attributes a launched thread's first message to the calling thread",
47 } as OrchestrationV2ThreadShell;48 } as OrchestrationV2ThreadShell;
48 let launchedSender: ThreadId | undefined;49 let launchedSender: ThreadId | undefined;
49 const layerDependencies = Layer.mergeAll(50 const layerDependencies = Layer.mergeAll(
51 ServerSettings.layerTest(),
50 NodeCrypto.layer,52 NodeCrypto.layer,
51 Layer.succeed(McpInvocationContext.McpInvocationContext, {53 Layer.succeed(McpInvocationContext.McpInvocationContext, {
52 environmentId: EnvironmentId.make("environment"),54 environmentId: EnvironmentId.make("environment"),
...@@ -118,6 +120,7 @@ it.effect("launches a scratch thread into the Scratch project", () =>...@@ -118,6 +120,7 @@ it.effect("launches a scratch thread into the Scratch project", () =>
118 } as OrchestrationV2ThreadShell;120 } as OrchestrationV2ThreadShell;
119 const launched: Array<ThreadLaunch.ThreadLaunchInput> = [];121 const launched: Array<ThreadLaunch.ThreadLaunchInput> = [];
120 const layerDependencies = Layer.mergeAll(122 const layerDependencies = Layer.mergeAll(
123 ServerSettings.layerTest(),
121 NodeCrypto.layer,124 NodeCrypto.layer,
122 Layer.succeed(McpInvocationContext.McpInvocationContext, {125 Layer.succeed(McpInvocationContext.McpInvocationContext, {
123 environmentId: EnvironmentId.make("environment"),126 environmentId: EnvironmentId.make("environment"),
...@@ -215,6 +218,7 @@ it.effect("starts a project from just a title when workspaceRoot is omitted", ()...@@ -215,6 +218,7 @@ it.effect("starts a project from just a title when workspaceRoot is omitted", ()
215 deletedAt: null,218 deletedAt: null,
216 };219 };
217 const layerDependencies = Layer.mergeAll(220 const layerDependencies = Layer.mergeAll(
221 ServerSettings.layerTest(),
218 NodeCrypto.layer,222 NodeCrypto.layer,
219 Layer.succeed(McpInvocationContext.McpInvocationContext, {223 Layer.succeed(McpInvocationContext.McpInvocationContext, {
220 environmentId: EnvironmentId.make("environment"),224 environmentId: EnvironmentId.make("environment"),
...@@ -304,8 +308,15 @@ const clientLaunchHarness = (input: {...@@ -304,8 +308,15 @@ const clientLaunchHarness = (input: {
304 readonly checkWorktreeBase?: ThreadLaunch.ThreadLaunchService["Service"]["checkWorktreeBase"];308 readonly checkWorktreeBase?: ThreadLaunch.ThreadLaunchService["Service"]["checkWorktreeBase"];
305}) => {309}) => {
306 const projectId = ProjectId.make("project:client-target");310 const projectId = ProjectId.make("project:client-target");
307 const modelSelection = { instanceId: ProviderInstanceId.make("claude"), model: "claude-opus" };311 // "claudeAgent" is the real default instance id for the Claude driver
312 // (see `defaultInstanceIdForDriver`), so it resolves as enabled by default
313 // under the project-scoped provider instance gate the launch path now runs.
314 const modelSelection = {
315 instanceId: ProviderInstanceId.make("claudeAgent"),
316 model: "claude-opus",
317 };
308 const layerDependencies = Layer.mergeAll(318 const layerDependencies = Layer.mergeAll(
319 ServerSettings.layerTest(),
309 NodeCrypto.layer,320 NodeCrypto.layer,
310 Layer.succeed(McpInvocationContext.McpInvocationContext, {321 Layer.succeed(McpInvocationContext.McpInvocationContext, {
311 environmentId: EnvironmentId.make("environment"),322 environmentId: EnvironmentId.make("environment"),
apps/server/src/mcp/toolkits/project/handlers.ts+15-5
...@@ -157,11 +157,21 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {...@@ -157,11 +157,21 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
157 createdBy: "agent",157 createdBy: "agent",
158 creationSource: "mcp",158 creationSource: "mcp",
159 }).pipe(159 }).pipe(
160 Effect.mapError((error) =>160 Effect.mapError((error) => {
161 error._tag === "AttachmentClaimError"161 if (error._tag === "AttachmentClaimError") {
162 ? new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message })162 return new OrchestratorMcpFailure({
163 : unavailable(),163 code: "orchestration_error",
164 ),164 message: error.message,
165 });
166 }
167 if (error._tag === "ThreadManagementProviderInstanceDisabledError") {
168 return new OrchestratorMcpFailure({
169 code: "invalid_request",
170 message: error.message,
171 });
172 }
173 return unavailable();
174 }),
165 );175 );
166 const thread = result.projection.thread;176 const thread = result.projection.thread;
167 const run = result.projection.runs.find((run) => run.userMessageId === messageId);177 const run = result.projection.runs.find((run) => run.userMessageId === messageId);
apps/server/src/mcp/toolkits/project/tools.ts+2
...@@ -19,6 +19,7 @@ import {...@@ -19,6 +19,7 @@ import {
19} from "@t3tools/contracts";19} from "@t3tools/contracts";
20import * as FileSystem from "effect/FileSystem";20import * as FileSystem from "effect/FileSystem";
21import * as ServerConfig from "../../../config.ts";21import * as ServerConfig from "../../../config.ts";
22import * as ServerSettings from "../../../serverSettings.ts";
22import * as ThreadLaunchService from "../../../orchestration-v2/ThreadLaunchService.ts";23import * as ThreadLaunchService from "../../../orchestration-v2/ThreadLaunchService.ts";
23import * as Crypto from "effect/Crypto";24import * as Crypto from "effect/Crypto";
24import * as Schema from "effect/Schema";25import * as Schema from "effect/Schema";
...@@ -144,6 +145,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", {...@@ -144,6 +145,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", {
144 GitVcsDriver.GitVcsDriver,145 GitVcsDriver.GitVcsDriver,
145 FileSystem.FileSystem,146 FileSystem.FileSystem,
146 ServerConfig.ServerConfig,147 ServerConfig.ServerConfig,
148 ServerSettings.ServerSettingsService,
147 ],149 ],
148})150})
149 .annotate(Tool.Destructive, true)151 .annotate(Tool.Destructive, true)
apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts+6
...@@ -29,6 +29,7 @@ import * as SecretRequests from "../secrets/SecretRequests.ts";...@@ -29,6 +29,7 @@ import * as SecretRequests from "../secrets/SecretRequests.ts";
29import * as Scheduler from "../scheduling/Scheduler.ts";29import * as Scheduler from "../scheduling/Scheduler.ts";
30import * as SqlitePersistence from "../persistence/Sqlite.ts";30import * as SqlitePersistence from "../persistence/Sqlite.ts";
31import * as ClaudeAdapterV2 from "./Adapters/ClaudeAdapterV2.ts";31import * as ClaudeAdapterV2 from "./Adapters/ClaudeAdapterV2.ts";
32import * as CommandReceiptStore from "./CommandReceiptStore.ts";
32import * as EffectWorker from "./EffectWorker.ts";33import * as EffectWorker from "./EffectWorker.ts";
33import * as EventSink from "./EventSink.ts";34import * as EventSink from "./EventSink.ts";
34import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";35import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
...@@ -38,6 +39,7 @@ import * as Orchestrator from "./Orchestrator.ts";...@@ -38,6 +39,7 @@ import * as Orchestrator from "./Orchestrator.ts";
38import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";39import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";
39import * as ThreadLaunchService from "./ThreadLaunchService.ts";40import * as ThreadLaunchService from "./ThreadLaunchService.ts";
40import * as ThreadManagementService from "./ThreadManagementService.ts";41import * as ThreadManagementService from "./ThreadManagementService.ts";
42import * as ServerSettings from "../serverSettings.ts";
41import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts";43import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts";
42import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace";44import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace";
4345
...@@ -313,6 +315,7 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con...@@ -313,6 +315,7 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con
313 Effect.provide(315 Effect.provide(
314 ScheduledTaskService.layer.pipe(316 ScheduledTaskService.layer.pipe(
315 Layer.provide(ThreadManagementService.layer),317 Layer.provide(ThreadManagementService.layer),
318 Layer.provide(ServerSettings.layerTest()),
316 Layer.provide(319 Layer.provide(
317 Layer.mock(LegacyV1ThreadImporter.LegacyV1ThreadImporter)({320 Layer.mock(LegacyV1ThreadImporter.LegacyV1ThreadImporter)({
318 ensureTranscript: () =>321 ensureTranscript: () =>
...@@ -321,6 +324,9 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con...@@ -321,6 +324,9 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con
321 ),324 ),
322 Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})),325 Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})),
323 Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),326 Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
327 Layer.provide(
328 CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)),
329 ),
324 Layer.provide(330 Layer.provide(
325 Layer.mergeAll(331 Layer.mergeAll(
326 NodeCrypto.layer,332 NodeCrypto.layer,
apps/server/src/orchestration-v2/EffectWorker.ts+55-35
...@@ -256,41 +256,61 @@ export const layerExecutor: Layer.Layer<...@@ -256,41 +256,61 @@ export const layerExecutor: Layer.Layer<
256 if (message === undefined || run === undefined) return yield* error;256 if (message === undefined || run === undefined) return yield* error;
257 // Reuse the message identity and a stable command receipt so an outbox257 // Reuse the message identity and a stable command receipt so an outbox
258 // retry cannot append a duplicate message or start a second follow-up.258 // retry cannot append a duplicate message or start a second follow-up.
259 yield* threads.dispatch({259 yield* threads
260 type: "message.dispatch",260 .dispatch({
261 commandId: CommandId.make(`command:steer-follow-up:${effect.id}`),261 type: "message.dispatch",
262 threadId: effect.threadId,262 commandId: CommandId.make(`command:steer-follow-up:${effect.id}`),
263 messageId: message.id,263 threadId: effect.threadId,
264 text: message.text,264 messageId: message.id,
265 ...(message.context ? { context: message.context } : {}),265 text: message.text,
266 attachments: message.attachments,266 ...(message.context ? { context: message.context } : {}),
267 // A user's follow-up starts on the thread's saved selection,267 attachments: message.attachments,
268 // which already holds the steer's choice. A delegated268 // A user's follow-up starts on the thread's saved selection,
269 // completion stays pinned to the run it reports to.269 // which already holds the steer's choice. A delegated
270 ...(message.delegatedCompletion === undefined270 // completion stays pinned to the run it reports to.
271 ? {}271 ...(message.delegatedCompletion === undefined
272 : { modelSelection: run.modelSelection }),272 ? {}
273 dispatchMode: {273 : { modelSelection: run.modelSelection }),
274 type:274 dispatchMode: {
275 message.delegatedCompletion === undefined275 type:
276 ? "start_immediately"276 message.delegatedCompletion === undefined
277 : "queue_after_active",277 ? "start_immediately"
278 },278 : "queue_after_active",
279 createdBy: message.createdBy,279 },
280 creationSource: message.creationSource,280 createdBy: message.createdBy,
281 ...(message.delegatedCompletion === undefined281 creationSource: message.creationSource,
282 ? {}282 ...(message.delegatedCompletion === undefined
283 : { delegatedCompletion: message.delegatedCompletion }),283 ? {}
284 ...(message.notification === undefined284 : { delegatedCompletion: message.delegatedCompletion }),
285 ? {}285 ...(message.notification === undefined
286 : { notification: message.notification }),286 ? {}
287 ...(message.scheduledTaskId === undefined287 : { notification: message.notification }),
288 ? {}288 ...(message.scheduledTaskId === undefined
289 : { scheduledTaskId: message.scheduledTaskId }),289 ? {}
290 ...(message.senderThreadId === undefined290 : { scheduledTaskId: message.scheduledTaskId }),
291 ? {}291 ...(message.senderThreadId === undefined
292 : { senderThreadId: message.senderThreadId }),292 ? {}
293 });293 : { senderThreadId: message.senderThreadId }),
294 })
295 .pipe(
296 // The turn that just completed leaves no further retry
297 // target once the instance is off: the completed turn
298 // stands as the thread's result instead of going
299 // through the outbox's bounded retry on a refusal that
300 // retrying cannot change.
301 Effect.catchTags({
302 ThreadManagementProviderInstanceDisabledError: (disabledError) =>
303 Effect.logInfo(
304 "orchestration-v2.effect-worker.steer-follow-up-disabled",
305 {
306 threadId: effect.threadId,
307 messageId: message.id,
308 projectId: disabledError.projectId,
309 instanceId: disabledError.instanceId,
310 },
311 ),
312 }),
313 );
294 }),314 }),
295 ),315 ),
296 Effect.mapError(316 Effect.mapError(
apps/server/src/orchestration-v2/Orchestrator.ts+25
...@@ -53,6 +53,7 @@ import {...@@ -53,6 +53,7 @@ import {
53 orchestrationV2RunWorkStartedAt,53 orchestrationV2RunWorkStartedAt,
54 ProviderInstanceId,54 ProviderInstanceId,
55 ProviderInteractionMode,55 ProviderInteractionMode,
56 resolveProviderInstanceEnabled,
56 type ProviderSessionId,57 type ProviderSessionId,
57 RunId,58 RunId,
58 RuntimeMode,59 RuntimeMode,
...@@ -131,6 +132,7 @@ import {...@@ -131,6 +132,7 @@ import {
131} from "./ThreadForkService.ts";132} from "./ThreadForkService.ts";
132import { planThreadDeletion } from "./ThreadDeletion.ts";133import { planThreadDeletion } from "./ThreadDeletion.ts";
133import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";134import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
135import * as ServerSettings from "../serverSettings.ts";
134136
135export class OrchestratorDispatchError extends Schema.TaggedError<OrchestratorDispatchError>()(137export class OrchestratorDispatchError extends Schema.TaggedError<OrchestratorDispatchError>()(
136 "OrchestratorDispatchError",138 "OrchestratorDispatchError",
...@@ -832,6 +834,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -832,6 +834,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
832 const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests;834 const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests;
833 const providerSessions = yield* ProviderSessionManagerV2;835 const providerSessions = yield* ProviderSessionManagerV2;
834 const providerSwitchService = yield* ProviderSwitchServiceV2;836 const providerSwitchService = yield* ProviderSwitchServiceV2;
837 const serverSettings = yield* ServerSettings.ServerSettingsService;
835 const runtimePolicy = yield* RuntimePolicyV2;838 const runtimePolicy = yield* RuntimePolicyV2;
836 const threadForkService = yield* ThreadForkServiceV2;839 const threadForkService = yield* ThreadForkServiceV2;
837 const threadDispatch = yield* ThreadCommandExecutor.ThreadCommandExecutor;840 const threadDispatch = yield* ThreadCommandExecutor.ThreadCommandExecutor;
...@@ -1318,8 +1321,29 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -1318,8 +1321,29 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
1318 failedRun?.id === options?.endedRunId &&1321 failedRun?.id === options?.endedRunId &&
1319 failedRun?.workspacePreparation?.type === "worktree" &&1322 failedRun?.workspacePreparation?.type === "worktree" &&
1320 projection.thread.worktreePath === null;1323 projection.thread.worktreePath === null;
1324 // A project can turn a provider off while a message waits in the queue,
1325 // including between a run ending and this check, or while the queue is
1326 // held and someone clicks resume. Neither path may start that run. Only
1327 // an explicit turn-off holds the queue: an instance with no settings
1328 // entry was already admitted when the message was queued.
1329 const settings = yield* serverSettings.getSettings.pipe(Effect.orDie);
1330 const projectOverride =
1331 settings.projectSettingsOverrides[projection.thread.projectId]?.providerInstances?.[
1332 queuedRun.providerInstanceId
1333 ]?.enabled;
1334 const machineInstance = Object.hasOwn(
1335 settings.providerInstances,
1336 queuedRun.providerInstanceId,
1337 )
1338 ? settings.providerInstances[queuedRun.providerInstanceId]
1339 : undefined;
1340 const instanceDisabledForProject =
1341 projectOverride !== undefined
1342 ? !projectOverride
1343 : machineInstance !== undefined && !resolveProviderInstanceEnabled(machineInstance);
1321 if (1344 if (
1322 worktreeMissing ||1345 worktreeMissing ||
1346 instanceDisabledForProject ||
1323 (failureClass !== undefined &&1347 (failureClass !== undefined &&
1324 failureClass !== "validation_error" &&1348 failureClass !== "validation_error" &&
1325 failedRun?.providerInstanceId === queuedRun.providerInstanceId)1349 failedRun?.providerInstanceId === queuedRun.providerInstanceId)
...@@ -11437,6 +11461,7 @@ export const layer: Layer.Layer<...@@ -11437,6 +11461,7 @@ export const layer: Layer.Layer<
11437 | ProviderSwitchServiceV211461 | ProviderSwitchServiceV2
11438 | ProjectionStoreV211462 | ProjectionStoreV2
11439 | RuntimePolicyV211463 | RuntimePolicyV2
11464 | ServerSettings.ServerSettingsService
11440 | ThreadForkServiceV211465 | ThreadForkServiceV2
11441> = Layer.effect(OrchestratorV2, makeOrchestrator()).pipe(11466> = Layer.effect(OrchestratorV2, makeOrchestrator()).pipe(
11442 Layer.provide(ThreadCommandExecutor.layer),11467 Layer.provide(ThreadCommandExecutor.layer),
apps/server/src/orchestration-v2/ProviderContinuationService.test.ts+188
...@@ -1,7 +1,9 @@...@@ -1,7 +1,9 @@
1import { assert, describe, it } from "@effect/vitest";1import { assert, describe, it } from "@effect/vitest";
2import {2import {
3 MessageId,3 MessageId,
4 ProjectId,
4 ProviderDriverKind,5 ProviderDriverKind,
6 ProviderInstanceId,
5 ProviderThreadId,7 ProviderThreadId,
6 RunId,8 RunId,
7 ThreadId,9 ThreadId,
...@@ -11,6 +13,7 @@ import {...@@ -11,6 +13,7 @@ import {
11import * as Deferred from "effect/Deferred";13import * as Deferred from "effect/Deferred";
12import * as DateTime from "effect/DateTime";14import * as DateTime from "effect/DateTime";
13import * as Effect from "effect/Effect";15import * as Effect from "effect/Effect";
16import * as Exit from "effect/Exit";
14import * as Layer from "effect/Layer";17import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";18import * as Option from "effect/Option";
16import * as Queue from "effect/Queue";19import * as Queue from "effect/Queue";
...@@ -529,6 +532,191 @@ describe("ProviderContinuationService", () => {...@@ -529,6 +532,191 @@ describe("ProviderContinuationService", () => {
529 });532 });
530 });533 });
531534
535 it.effect(
536 "a project-disabled instance refuses a delegated-completion wake exactly once, acknowledges it, and never retries",
537 () => {
538 return Effect.gen(function* () {
539 const attempts = yield* Ref.make(0);
540 const cleared = yield* Ref.make(false);
541 const acknowledged = yield* Queue.unbounded<unknown>();
542 const layerThreads = Layer.mock(ThreadManagementService.ThreadManagementService)({
543 getThreadRecords: () => Effect.succeed(delegatedProjection()),
544 dispatch: (command) => {
545 if ((command as { type: string }).type === "message.dispatch") {
546 return Ref.update(attempts, (count) => count + 1).pipe(
547 Effect.andThen(
548 Effect.fail(
549 new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({
550 projectId: ProjectId.make("project-provider-continuation-disabled"),
551 instanceId: ProviderInstanceId.make(
552 "instance-provider-continuation-disabled",
553 ),
554 }) as never,
555 ),
556 ),
557 );
558 }
559 return Queue.offer(acknowledged, command).pipe(Effect.as({} as never));
560 },
561 });
562 const layerWorker = ProviderContinuationService.layer.pipe(
563 Layer.provide(
564 Layer.mergeAll(IdAllocator.layer, ProviderContinuationRequests.layer, layerThreads),
565 ),
566 );
567
568 yield* Effect.gen(function* () {
569 const requests = yield* ProviderContinuationRequests.ProviderContinuationRequests;
570 yield* requests.offer({
571 threadId,
572 providerThreadId,
573 driver,
574 detail: null,
575 delivery: "message_text",
576 clearIfCurrent: () => Ref.set(cleared, true),
577 delegatedCompletion: {
578 parentRunId,
579 generation: 1,
580 messageId: delegatedMessageId,
581 },
582 });
583 yield* Effect.yieldNow;
584 assert.equal(yield* Ref.get(attempts), 1);
585 assert.isTrue(yield* Ref.get(cleared));
586
587 // The refused delivery is acknowledged administratively, task by
588 // task, so the cohort's delivery is consumed instead of left open.
589 const acknowledgements = (yield* Queue.takeAll(acknowledged)) as ReadonlyArray<{
590 type: string;
591 taskId: string;
592 observedByRunId: string | null;
593 }>;
594 assert.deepEqual(
595 acknowledgements.map((command) => command.taskId),
596 ["node-provider-continuation-first", "node-provider-continuation-second"],
597 );
598 for (const command of acknowledgements) {
599 assert.equal(command.type, "delegated_task.completion-delivery.acknowledge");
600 assert.isNull(command.observedByRunId);
601 }
602
603 // A refusal is terminal: no backoff timer is pending behind it,
604 // so no further dispatch attempt can ever land.
605 yield* TestClock.adjust("1 hour");
606 yield* Effect.yieldNow;
607 assert.equal(yield* Ref.get(attempts), 1);
608 assert.equal(yield* Queue.size(acknowledged), 0);
609 }).pipe(
610 Effect.provide(Layer.merge(ProviderContinuationRequests.layer, layerWorker)),
611 Effect.scoped,
612 );
613 });
614 },
615 );
616
617 it.effect(
618 "clears the adapter's sticky continuation flag when a non-delegated wake is refused",
619 () => {
620 return Effect.gen(function* () {
621 const requested = yield* Ref.make(true);
622 const layerThreads = Layer.mock(ThreadManagementService.ThreadManagementService)({
623 getThreadRecords: () => Effect.succeed(projection),
624 dispatch: () =>
625 Effect.fail(
626 new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({
627 projectId: ProjectId.make("project-provider-continuation-disabled"),
628 instanceId: ProviderInstanceId.make("instance-provider-continuation-disabled"),
629 }) as never,
630 ),
631 });
632 const layerWorker = ProviderContinuationService.layer.pipe(
633 Layer.provide(
634 Layer.mergeAll(IdAllocator.layer, ProviderContinuationRequests.layer, layerThreads),
635 ),
636 );
637
638 // Mirrors the ACP adapter's own dispatchIfCurrent: on a failed effect
639 // it clears its sticky "continuation requested" flag before
640 // re-raising. If this service pre-recovered the failure before
641 // handing the effect here, this flag would stay stuck set and
642 // hasPendingBackgroundWork would never clear.
643 const dispatchIfCurrent = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
644 Effect.gen(function* () {
645 const exit = yield* Effect.exit(effect);
646 if (Exit.isFailure(exit)) {
647 yield* Ref.set(requested, false);
648 return yield* Effect.failCause(exit.cause);
649 }
650 return Option.some(exit.value);
651 });
652
653 yield* Effect.gen(function* () {
654 const requests = yield* ProviderContinuationRequests.ProviderContinuationRequests;
655 yield* requests.offer(request(dispatchIfCurrent));
656 yield* Effect.yieldNow;
657 yield* Effect.yieldNow;
658 assert.isFalse(
659 yield* Ref.get(requested),
660 "a disabled instance must still clear the adapter's sticky offer flag",
661 );
662 }).pipe(
663 Effect.provide(Layer.merge(ProviderContinuationRequests.layer, layerWorker)),
664 Effect.scoped,
665 );
666 });
667 },
668 );
669
670 it.effect(
671 "clears a sticky continuation offer for an adapter whose dispatchIfCurrent does not self-clear on failure",
672 () => {
673 return Effect.gen(function* () {
674 const wakeHeld = yield* Ref.make(true);
675 const layerThreads = Layer.mock(ThreadManagementService.ThreadManagementService)({
676 getThreadRecords: () => Effect.succeed(projection),
677 dispatch: () =>
678 Effect.fail(
679 new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({
680 projectId: ProjectId.make("project-provider-continuation-disabled"),
681 instanceId: ProviderInstanceId.make("instance-provider-continuation-disabled"),
682 }) as never,
683 ),
684 });
685 const layerWorker = ProviderContinuationService.layer.pipe(
686 Layer.provide(
687 Layer.mergeAll(IdAllocator.layer, ProviderContinuationRequests.layer, layerThreads),
688 ),
689 );
690
691 // Mirrors the Muse adapter: dispatchIfCurrent forwards the dispatch
692 // effect without any failure handling of its own (unlike ACP or Pi),
693 // so only the explicit clearIfCurrent callback can clear the sticky
694 // "wake held" flag. Without it, a disabled instance leaves the flag
695 // set forever and no later background turn is ever offered.
696 const dispatchIfCurrent = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
697 Effect.asSome(effect);
698
699 yield* Effect.gen(function* () {
700 const requests = yield* ProviderContinuationRequests.ProviderContinuationRequests;
701 yield* requests.offer({
702 ...request(dispatchIfCurrent),
703 clearIfCurrent: () => Ref.set(wakeHeld, false),
704 });
705 yield* Effect.yieldNow;
706 yield* Effect.yieldNow;
707 assert.isFalse(
708 yield* Ref.get(wakeHeld),
709 "a disabled instance must clear the offer via the explicit callback " +
710 "when the adapter itself does not clear on dispatch failure",
711 );
712 }).pipe(
713 Effect.provide(Layer.merge(ProviderContinuationRequests.layer, layerWorker)),
714 Effect.scoped,
715 );
716 });
717 },
718 );
719
532 it.effect("keeps a Grok delegated completion queued instead of restarting active work", () => {720 it.effect("keeps a Grok delegated completion queued instead of restarting active work", () => {
533 return Effect.gen(function* () {721 return Effect.gen(function* () {
534 const dispatched = yield* Queue.unbounded<unknown>();722 const dispatched = yield* Queue.unbounded<unknown>();
apps/server/src/orchestration-v2/ProviderContinuationService.ts+102-18
...@@ -126,22 +126,77 @@ export const layer = Layer.effectDiscard(...@@ -126,22 +126,77 @@ export const layer = Layer.effectDiscard(
126 fixtureName: "delegated-completion",126 fixtureName: "delegated-completion",
127 commandName: "dispatch",127 commandName: "dispatch",
128 });128 });
129 yield* threads.dispatch({129 yield* threads
130 type: "message.dispatch",130 .dispatch({
131 commandId,131 type: "message.dispatch",
132 threadId: request.threadId,132 commandId,
133 messageId: delivery.messageId,133 threadId: request.threadId,
134 text: delegatedCompletionText(delivery.taskIds),134 messageId: delivery.messageId,
135 attachments: [],135 text: delegatedCompletionText(delivery.taskIds),
136 dispatchMode: { type: "queue_after_active" },136 attachments: [],
137 createdBy: "agent",137 dispatchMode: { type: "queue_after_active" },
138 creationSource: "server",138 createdBy: "agent",
139 delegatedCompletion: {139 creationSource: "server",
140 parentRunId: request.delegatedCompletion.parentRunId,140 delegatedCompletion: {
141 generation: delivery.generation,141 parentRunId: request.delegatedCompletion.parentRunId,
142 taskIds: delivery.taskIds,142 generation: delivery.generation,
143 },143 taskIds: delivery.taskIds,
144 });144 },
145 })
146 .pipe(
147 // The project turned the target instance off between the cohort
148 // read above and this dispatch. The finalize that claimed this
149 // delivery already wrote the children's results to the parent's
150 // history unconditionally, so nothing is lost: acknowledge each
151 // task administratively (observedByRunId: null, the same shape
152 // task_status uses when no run observed it) to consume this
153 // delivery instead of leaving it open to retry. The parent's
154 // next turn against an enabled instance reads those results from
155 // history the same way any turn does; nothing here re-offers
156 // this generation.
157 Effect.catchTags({
158 ThreadManagementProviderInstanceDisabledError: (error) =>
159 Effect.logInfo(
160 "orchestration-v2.provider-continuation.delegated-completion-disabled",
161 {
162 threadId: request.threadId,
163 projectId: error.projectId,
164 instanceId: error.instanceId,
165 },
166 ).pipe(
167 Effect.andThen(
168 Effect.forEach(
169 delivery.taskIds,
170 (taskId) =>
171 ids.allocate
172 .command({
173 fixtureName: "delegated-completion",
174 commandName: "acknowledge-disabled",
175 })
176 .pipe(
177 Effect.flatMap((ackCommandId) =>
178 threads.dispatch({
179 type: "delegated_task.completion-delivery.acknowledge",
180 commandId: ackCommandId,
181 parentThreadId: request.threadId,
182 taskId,
183 observedByRunId: null,
184 }),
185 ),
186 ),
187 { discard: true },
188 ),
189 ),
190 Effect.andThen(
191 request.clearIfCurrent !== undefined
192 ? request.clearIfCurrent()
193 : request.dispatchIfCurrent !== undefined
194 ? request.dispatchIfCurrent(Effect.void)
195 : Effect.void,
196 ),
197 ),
198 }),
199 );
145 yield* clearRetryAttempt(retryKey);200 yield* clearRetryAttempt(retryKey);
146 return;201 return;
147 }202 }
...@@ -174,11 +229,40 @@ export const layer = Layer.effectDiscard(...@@ -174,11 +229,40 @@ export const layer = Layer.effectDiscard(
174 // marker or the turn settles immediately having prompted nothing.229 // marker or the turn settles immediately having prompted nothing.
175 creationSource: request.delivery === "message_text" ? "server" : "provider",230 creationSource: request.delivery === "message_text" ? "server" : "provider",
176 });231 });
232 // The adapter's buffered wake has nowhere else to go once the
233 // instance is off: drop it rather than warn, since nothing here
234 // retries it either way. This catch sits outside dispatchIfCurrent so
235 // an adapter that clears its own sticky offer flag on dispatch
236 // failure (inside dispatchIfCurrent) observes the raw failure first.
237 // Not every adapter self-clears that way, so the explicit callback is
238 // called here too; clearIfCurrent implementations are idempotent, so
239 // a source that already cleared is unaffected.
240 const logInstanceDisabled = (
241 error: ThreadManagementService.ThreadManagementProviderInstanceDisabledError,
242 ) =>
243 Effect.logInfo("orchestration-v2.provider-continuation.instance-disabled", {
244 threadId: request.threadId,
245 providerThreadId: request.providerThreadId,
246 projectId: error.projectId,
247 instanceId: error.instanceId,
248 }).pipe(
249 Effect.andThen(
250 request.clearIfCurrent !== undefined ? request.clearIfCurrent() : Effect.void,
251 ),
252 );
177 if (request.dispatchIfCurrent === undefined) {253 if (request.dispatchIfCurrent === undefined) {
178 yield* dispatch;254 yield* dispatch.pipe(
255 Effect.catchTags({
256 ThreadManagementProviderInstanceDisabledError: logInstanceDisabled,
257 }),
258 );
179 return;259 return;
180 }260 }
181 yield* request.dispatchIfCurrent(dispatch);261 yield* request.dispatchIfCurrent(dispatch).pipe(
262 Effect.catchTags({
263 ThreadManagementProviderInstanceDisabledError: logInstanceDisabled,
264 }),
265 );
182 },266 },
183 );267 );
184268
apps/server/src/orchestration-v2/RestartContinuation.ts+39-22
...@@ -159,28 +159,45 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta...@@ -159,28 +159,45 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta
159 : note.settled159 : note.settled
160 ? noteText160 ? noteText
161 : `${noteText}\n\n${CONTINUE_PROMPT}`;161 : `${noteText}\n\n${CONTINUE_PROMPT}`;
162 yield* threads.dispatch({162 yield* threads
163 type: "message.dispatch",163 .dispatch({
164 commandId: CommandId.make(`command:restart-continuation:${input.sourceRunId}`),164 type: "message.dispatch",
165 threadId: input.threadId,165 commandId: CommandId.make(`command:restart-continuation:${input.sourceRunId}`),
166 messageId,166 threadId: input.threadId,
167 text,167 messageId,
168 // Shown as a work log row; the prompt the agent got is its detail.168 text,
169 notification: {169 // Shown as a work log row; the prompt the agent got is its detail.
170 source: { kind: "system" },170 notification: {
171 outcome: "updated",171 source: { kind: "system" },
172 summary: note.settled172 outcome: "updated",
173 ? "T3 Code restarted and stopped background work"173 summary: note.settled
174 : "T3 Code restarted and resumed this turn",174 ? "T3 Code restarted and stopped background work"
175 detail: text,175 : "T3 Code restarted and resumed this turn",
176 },176 detail: text,
177 attachments: [],177 },
178 modelSelection: source.modelSelection,178 attachments: [],
179 dispatchMode: { type: "start_immediately" },179 modelSelection: source.modelSelection,
180 createdBy: "agent",180 dispatchMode: { type: "start_immediately" },
181 creationSource: "server",181 createdBy: "agent",
182 restartContinuationOfRunId: input.sourceRunId,182 creationSource: "server",
183 });183 restartContinuationOfRunId: input.sourceRunId,
184 })
185 .pipe(
186 // The project turned this instance off since the restart. No turn
187 // resumes, and the success path below still runs so a delegated
188 // child waiting on this run is recovered immediately instead of
189 // after EffectWorker exhausts its retries on a refusal that will
190 // not change.
191 Effect.catchTags({
192 ThreadManagementProviderInstanceDisabledError: (error) =>
193 Effect.logInfo("orchestration-v2.restart-continuation.instance-disabled", {
194 threadId: input.threadId,
195 sourceRunId: input.sourceRunId,
196 projectId: error.projectId,
197 instanceId: error.instanceId,
198 }),
199 }),
200 );
184 },201 },
185 // A delegated child this declined to continue still owes its parent a202 // A delegated child this declined to continue still owes its parent a
186 // result. Once a continuation run exists this is a no-op; that run settles it.203 // result. Once a continuation run exists this is a no-op; that run settles it.
apps/server/src/orchestration-v2/ThreadLaunchService.test.ts+182-7
...@@ -118,8 +118,13 @@ function makeHarness(options: HarnessOptions = {}) {...@@ -118,8 +118,13 @@ function makeHarness(options: HarnessOptions = {}) {
118 layerRegistry,118 layerRegistry,
119 { databaseLayer: layerDatabase, runEffectWorker: false },119 { databaseLayer: layerDatabase, runEffectWorker: false },
120 );120 );
121 const layerThreadManagement = ThreadManagement.layer.pipe(Layer.provide(layerOrchestrator));121 const layerServerSettings = ServerSettings.layerTest(options.serverSettings);
122 const layerReceipts = CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase));122 const layerReceipts = CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase));
123 const layerThreadManagement = ThreadManagement.layer.pipe(
124 Layer.provide(layerOrchestrator),
125 Layer.provide(layerServerSettings),
126 Layer.provide(layerReceipts),
127 );
123 const layerOutbox = EffectOutbox.layer.pipe(Layer.provide(layerDatabase));128 const layerOutbox = EffectOutbox.layer.pipe(Layer.provide(layerDatabase));
124 const createWorktree = vi.fn(129 const createWorktree = vi.fn(
125 options.createWorktree ??130 options.createWorktree ??
...@@ -185,7 +190,7 @@ function makeHarness(options: HarnessOptions = {}) {...@@ -185,7 +190,7 @@ function makeHarness(options: HarnessOptions = {}) {
185 generateThreadTitle,190 generateThreadTitle,
186 generateBranchName,191 generateBranchName,
187 }),192 }),
188 ServerSettings.layerTest(options.serverSettings),193 layerServerSettings,
189 ProviderRegistryMock.layer(options.providers),194 ProviderRegistryMock.layer(options.providers),
190 options.managedFolders ??195 options.managedFolders ??
191 Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({196 Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({
...@@ -1765,6 +1770,70 @@ it.effect("replays a server-allocated launch", () =>...@@ -1765,6 +1770,70 @@ it.effect("replays a server-allocated launch", () =>
1765 }),1770 }),
1766);1771);
17671772
1773it.effect("refuses a launch when the project disables the model's provider instance", () => {
1774 const harness = makeHarness({
1775 serverSettings: {
1776 projectSettingsOverrides: {
1777 [projectId]: { providerInstances: { [modelSelection.instanceId]: { enabled: false } } },
1778 },
1779 },
1780 });
1781 return Effect.gen(function* () {
1782 const launches = yield* ThreadLaunch.ThreadLaunchService;
1783 const failed = yield* launches
1784 .launch(
1785 launchInput({
1786 command: "command:launch:project-disabled-instance",
1787 thread: "thread:launch:project-disabled-instance",
1788 message: "Hello",
1789 }),
1790 )
1791 .pipe(Effect.flip);
1792 if (failed._tag !== "ThreadManagementProviderInstanceDisabledError") {
1793 assert.fail(`expected ThreadManagementProviderInstanceDisabledError, got ${failed._tag}`);
1794 }
1795 assert.equal(failed.projectId, projectId);
1796 assert.equal(failed.instanceId, modelSelection.instanceId);
1797 }).pipe(Effect.provide(harness.layer));
1798});
1799
1800it.effect("replays an accepted launch after the project disables the instance", () => {
1801 const harness = makeHarness();
1802 return Effect.gen(function* () {
1803 const launches = yield* ThreadLaunch.ThreadLaunchService;
1804 const settings = yield* ServerSettings.ServerSettingsService;
1805 const input = launchInput({
1806 command: "command:launch:retry-after-disable",
1807 thread: "thread:launch:retry-after-disable",
1808 message: "Hello",
1809 });
1810 const first = yield* launches.launch(input);
1811
1812 yield* settings.updateSettings({
1813 projectSettingsOverrides: {
1814 [projectId]: { providerInstances: { [modelSelection.instanceId]: { enabled: false } } },
1815 },
1816 });
1817
1818 const retry = yield* launches.launch(input);
1819 assert.equal(retry.threadId, first.threadId);
1820 assert.isTrue(retry.resumed);
1821
1822 const fresh = yield* launches
1823 .launch(
1824 launchInput({
1825 command: "command:launch:fresh-after-disable",
1826 thread: "thread:launch:fresh-after-disable",
1827 message: "Hello",
1828 }),
1829 )
1830 .pipe(Effect.flip);
1831 if (fresh._tag !== "ThreadManagementProviderInstanceDisabledError") {
1832 assert.fail(`expected ThreadManagementProviderInstanceDisabledError, got ${fresh._tag}`);
1833 }
1834 }).pipe(Effect.provide(harness.layer));
1835});
1836
1768it.effect("rejects a server-allocated launch replay with a mismatching thread id", () => {1837it.effect("rejects a server-allocated launch replay with a mismatching thread id", () => {
1769 const harness = makeHarness();1838 const harness = makeHarness();
1770 return Effect.gen(function* () {1839 return Effect.gen(function* () {
...@@ -1782,7 +1851,9 @@ it.effect("rejects a server-allocated launch replay with a mismatching thread id...@@ -1782,7 +1851,9 @@ it.effect("rejects a server-allocated launch replay with a mismatching thread id
1782 })1851 })
1783 .pipe(Effect.flip);1852 .pipe(Effect.flip);
1784 assert.notEqual(first.threadId, ThreadId.make("thread:launch:allocated-mismatch"));1853 assert.notEqual(first.threadId, ThreadId.make("thread:launch:allocated-mismatch"));
1785 assert.equal(failed._tag, "ThreadLaunchError");1854 if (failed._tag !== "ThreadLaunchError") {
1855 assert.fail(`expected ThreadLaunchError, got ${failed._tag}`);
1856 }
1786 assert.equal(failed.operation, "create-thread");1857 assert.equal(failed.operation, "create-thread");
1787 assert.include(String(failed.cause), "cannot be replayed");1858 assert.include(String(failed.cause), "cannot be replayed");
1788 }).pipe(Effect.provide(harness.layer));1859 }).pipe(Effect.provide(harness.layer));
...@@ -1804,7 +1875,9 @@ it.effect("rejects a server-allocated launch receipt from another project", () =...@@ -1804,7 +1875,9 @@ it.effect("rejects a server-allocated launch receipt from another project", () =
1804 projectId: otherProjectId,1875 projectId: otherProjectId,
1805 })1876 })
1806 .pipe(Effect.flip);1877 .pipe(Effect.flip);
1807 assert.equal(failed._tag, "ThreadLaunchError");1878 if (failed._tag !== "ThreadLaunchError") {
1879 assert.fail(`expected ThreadLaunchError, got ${failed._tag}`);
1880 }
1808 assert.equal(failed.operation, "resolve-project");1881 assert.equal(failed.operation, "resolve-project");
1809 assert.equal(failed.threadId, first.threadId);1882 assert.equal(failed.threadId, first.threadId);
1810 assert.equal(failed.cause, "Project identity changed.");1883 assert.equal(failed.cause, "Project identity changed.");
...@@ -1828,7 +1901,9 @@ it.effect("rejects a server-allocated launch retry after the thread is deleted",...@@ -1828,7 +1901,9 @@ it.effect("rejects a server-allocated launch retry after the thread is deleted",
1828 threadId: first.threadId,1901 threadId: first.threadId,
1829 });1902 });
1830 const failed = yield* launches.launch(rest).pipe(Effect.flip);1903 const failed = yield* launches.launch(rest).pipe(Effect.flip);
1831 assert.equal(failed._tag, "ThreadLaunchError");1904 if (failed._tag !== "ThreadLaunchError") {
1905 assert.fail(`expected ThreadLaunchError, got ${failed._tag}`);
1906 }
1832 assert.equal(failed.operation, "create-thread");1907 assert.equal(failed.operation, "create-thread");
1833 assert.equal(failed.threadId, first.threadId);1908 assert.equal(failed.threadId, first.threadId);
1834 assert.equal(failed.cause, "Thread not found.");1909 assert.equal(failed.cause, "Thread not found.");
...@@ -1869,7 +1944,9 @@ it.effect("does not treat an unrelated accepted command receipt as a launch", ()...@@ -1869,7 +1944,9 @@ it.effect("does not treat an unrelated accepted command receipt as a launch", ()
1869 message: "Should not become a launch",1944 message: "Should not become a launch",
1870 });1945 });
1871 const failed = yield* launches.launch(rest).pipe(Effect.flip);1946 const failed = yield* launches.launch(rest).pipe(Effect.flip);
1872 assert.equal(failed._tag, "ThreadLaunchError");1947 if (failed._tag !== "ThreadLaunchError") {
1948 assert.fail(`expected ThreadLaunchError, got ${failed._tag}`);
1949 }
1873 assert.equal(failed.operation, "create-thread");1950 assert.equal(failed.operation, "create-thread");
1874 assert.include(String(failed.cause), "cannot be replayed");1951 assert.include(String(failed.cause), "cannot be replayed");
1875 const projection = yield* threads.getThreadProjection(threadId);1952 const projection = yield* threads.getThreadProjection(threadId);
...@@ -1914,7 +1991,9 @@ it.effect("bounds concurrent first launches to one thread per command", () =>...@@ -1914,7 +1991,9 @@ it.effect("bounds concurrent first launches to one thread per command", () =>
1914 continue;1991 continue;
1915 }1992 }
1916 const error = Cause.findErrorOption(result.cause).pipe(Option.getOrThrow);1993 const error = Cause.findErrorOption(result.cause).pipe(Option.getOrThrow);
1917 assert.equal(error._tag, "ThreadLaunchError");1994 if (error._tag !== "ThreadLaunchError") {
1995 assert.fail(`expected ThreadLaunchError, got ${error._tag}`);
1996 }
1918 assert.equal(error.operation, "create-thread");1997 assert.equal(error.operation, "create-thread");
1919 assert.include(String(error.cause), "cannot be replayed");1998 assert.include(String(error.cause), "cannot be replayed");
1920 const retried = yield* launches.launch(rest);1999 const retried = yield* launches.launch(rest);
...@@ -2530,3 +2609,99 @@ it.effect.each([...@@ -2530,3 +2609,99 @@ it.effect.each([
2530 }).pipe(Effect.provide(harness.layer));2609 }).pipe(Effect.provide(harness.layer));
2531 }),2610 }),
2532);2611);
2612
2613it.effect("replays an accepted send after the project disables the instance", () => {
2614 const harness = makeHarness();
2615 const layerFiles = ServerConfig.layerTest(process.cwd(), { prefix: "t3-send-retry-" }).pipe(
2616 Layer.provideMerge(NodeServices.layer),
2617 );
2618 return Effect.gen(function* () {
2619 const launches = yield* ThreadLaunch.ThreadLaunchService;
2620 const settings = yield* ServerSettings.ServerSettingsService;
2621 const created = yield* launches.launch(
2622 launchInput({
2623 command: "command:send:retry-setup",
2624 thread: "thread:send:retry-after-disable",
2625 }),
2626 );
2627
2628 const sendInput = {
2629 commandId: CommandId.make("command:send:retry-after-disable"),
2630 projectId,
2631 threadId: created.threadId,
2632 messageId: MessageId.make("message:send:retry-after-disable"),
2633 text: "Hello again",
2634 attachments: [],
2635 mode: "auto" as const,
2636 createdBy: "user" as const,
2637 creationSource: "web" as const,
2638 };
2639 const first = yield* ThreadMessageIntake.sendToThread(sendInput);
2640
2641 yield* settings.updateSettings({
2642 projectSettingsOverrides: {
2643 [projectId]: { providerInstances: { [modelSelection.instanceId]: { enabled: false } } },
2644 },
2645 });
2646
2647 const retry = yield* ThreadMessageIntake.sendToThread(sendInput);
2648 assert.equal(retry.message.id, first.message.id);
2649
2650 const fresh = yield* ThreadMessageIntake.sendToThread({
2651 ...sendInput,
2652 commandId: CommandId.make("command:send:fresh-after-disable"),
2653 messageId: MessageId.make("message:send:fresh-after-disable"),
2654 }).pipe(Effect.flip);
2655 if (fresh._tag !== "ThreadManagementProviderInstanceDisabledError") {
2656 assert.fail(`expected ThreadManagementProviderInstanceDisabledError, got ${fresh._tag}`);
2657 }
2658 }).pipe(Effect.provide(Layer.mergeAll(harness.layer, layerFiles)));
2659});
2660
2661it.effect("replays an accepted message.dispatch after the project disables the instance", () => {
2662 const harness = makeHarness();
2663 const layerFiles = ServerConfig.layerTest(process.cwd(), { prefix: "t3-dispatch-retry-" }).pipe(
2664 Layer.provideMerge(NodeServices.layer),
2665 );
2666 return Effect.gen(function* () {
2667 const launches = yield* ThreadLaunch.ThreadLaunchService;
2668 const settings = yield* ServerSettings.ServerSettingsService;
2669 const created = yield* launches.launch(
2670 launchInput({
2671 command: "command:dispatch:retry-setup",
2672 thread: "thread:dispatch:retry-after-disable",
2673 }),
2674 );
2675
2676 const dispatchInput = {
2677 type: "message.dispatch" as const,
2678 commandId: CommandId.make("command:dispatch:retry-after-disable"),
2679 threadId: created.threadId,
2680 messageId: MessageId.make("message:dispatch:retry-after-disable"),
2681 text: "Hello again",
2682 attachments: [],
2683 dispatchMode: { type: "queue_after_active" as const },
2684 createdBy: "user" as const,
2685 creationSource: "web" as const,
2686 };
2687 const first = yield* ThreadMessageIntake.dispatchCommand(dispatchInput);
2688
2689 yield* settings.updateSettings({
2690 projectSettingsOverrides: {
2691 [projectId]: { providerInstances: { [modelSelection.instanceId]: { enabled: false } } },
2692 },
2693 });
2694
2695 const retry = yield* ThreadMessageIntake.dispatchCommand(dispatchInput);
2696 assert.deepEqual(retry, first);
2697
2698 const fresh = yield* ThreadMessageIntake.dispatchCommand({
2699 ...dispatchInput,
2700 commandId: CommandId.make("command:dispatch:fresh-after-disable"),
2701 messageId: MessageId.make("message:dispatch:fresh-after-disable"),
2702 }).pipe(Effect.flip);
2703 if (fresh._tag !== "ThreadManagementProviderInstanceDisabledError") {
2704 assert.fail(`expected ThreadManagementProviderInstanceDisabledError, got ${fresh._tag}`);
2705 }
2706 }).pipe(Effect.provide(Layer.mergeAll(harness.layer, layerFiles)));
2707});
apps/server/src/orchestration-v2/ThreadLaunchService.ts+17-4
...@@ -167,7 +167,10 @@ export class ThreadLaunchService extends Context.Service<...@@ -167,7 +167,10 @@ export class ThreadLaunchService extends Context.Service<
167 {167 {
168 readonly launch: (168 readonly launch: (
169 input: ThreadLaunchInput,169 input: ThreadLaunchInput,
170 ) => Effect.Effect<ThreadLaunchResult, ThreadLaunchError>;170 ) => Effect.Effect<
171 ThreadLaunchResult,
172 ThreadLaunchError | ThreadManagement.ThreadManagementProviderInstanceDisabledError
173 >;
171 /**174 /**
172 * Fails when a new worktree's base ref cannot give provisioning a commit.175 * Fails when a new worktree's base ref cannot give provisioning a commit.
173 * `launch` accepts such a launch and reports the failure inside the176 * `launch` accepts such a launch and reports the failure inside the
...@@ -177,9 +180,13 @@ export class ThreadLaunchService extends Context.Service<...@@ -177,9 +180,13 @@ export class ThreadLaunchService extends Context.Service<
177 input: Pick<ThreadLaunchInput, "projectId" | "workspaceStrategy">,180 input: Pick<ThreadLaunchInput, "projectId" | "workspaceStrategy">,
178 ) => Effect.Effect<void, ThreadLaunchBaseRefError>;181 ) => Effect.Effect<void, ThreadLaunchBaseRefError>;
179 /** Dispatches prepared-run.retry and prepares the run's workspace again. */182 /** Dispatches prepared-run.retry and prepares the run's workspace again. */
180 readonly retryPreparation: (183 readonly retryPreparation: (input: ThreadLaunchRetryInput) => Effect.Effect<
181 input: ThreadLaunchRetryInput,184 Orchestrator.OrchestratorV2DispatchResult,
182 ) => Effect.Effect<Orchestrator.OrchestratorV2DispatchResult, Orchestrator.OrchestratorV2Error>;185 // Dispatches prepared-run.retry, a command the enablement chokepoint
186 // never gates; the type stays accurate anyway.
187 | Orchestrator.OrchestratorV2Error
188 | ThreadManagement.ThreadManagementProviderInstanceDisabledError
189 >;
183 }190 }
184>()("t3/orchestration-v2/ThreadLaunchService") {}191>()("t3/orchestration-v2/ThreadLaunchService") {}
185192
...@@ -341,6 +348,7 @@ const make = Effect.gen(function* () {...@@ -341,6 +348,7 @@ const make = Effect.gen(function* () {
341 : ServerSettings.resolveSourceControlWriterModelSelection(348 : ServerSettings.resolveSourceControlWriterModelSelection(
342 settings,349 settings,
343 yield* providerRegistry.getProviders,350 yield* providerRegistry.getProviders,
351 input.projectId,
344 );352 );
345 return yield* textGeneration353 return yield* textGeneration
346 .generateBranchName({354 .generateBranchName({
...@@ -764,6 +772,11 @@ const make = Effect.gen(function* () {...@@ -764,6 +772,11 @@ const make = Effect.gen(function* () {
764 }772 }
765773
766 const launchReceipt = yield* readReceipt(input, input.commandId);774 const launchReceipt = yield* readReceipt(input, input.commandId);
775 yield* threads.assertProviderInstanceEnabled({
776 projectId: input.projectId,
777 commandId: input.commandId,
778 instanceId: input.modelSelection.instanceId,
779 });
767 return yield* Effect.gen(function* () {780 return yield* Effect.gen(function* () {
768 // A retried launch has no client-supplied id to replay against, so781 // A retried launch has no client-supplied id to replay against, so
769 // recover the thread id its accepted create was recorded under before782 // recover the thread id its accepted create was recorded under before
apps/server/src/orchestration-v2/ThreadManagementService.test.ts+217
...@@ -31,6 +31,9 @@ import * as Stream from "effect/Stream";...@@ -31,6 +31,9 @@ import * as Stream from "effect/Stream";
31import * as TestClock from "effect/testing/TestClock";31import * as TestClock from "effect/testing/TestClock";
3232
33import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts";33import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts";
34import * as ServerSettings from "../serverSettings.ts";
35import * as SqlitePersistence from "../persistence/Sqlite.ts";
36import * as CommandReceiptStore from "./CommandReceiptStore.ts";
34import * as Orchestrator from "./Orchestrator.ts";37import * as Orchestrator from "./Orchestrator.ts";
35import * as ProviderSessionManager from "./ProviderSessionManager.ts";38import * as ProviderSessionManager from "./ProviderSessionManager.ts";
36import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";39import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
...@@ -273,6 +276,206 @@ it("derives thread management messages from structural error attributes", () =>...@@ -273,6 +276,206 @@ it("derives thread management messages from structural error attributes", () =>
273 );276 );
274});277});
275278
279const layerReceiptsTest = CommandReceiptStore.layer.pipe(
280 Layer.provide(SqlitePersistence.layerMemory),
281);
282
283const layerThreadManagementTest = (settingsLayer: ReturnType<typeof ServerSettings.layerTest>) =>
284 ThreadManagementService.layer.pipe(
285 Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})),
286 Layer.provide(Layer.mock(Orchestrator.OrchestratorV2)({})),
287 Layer.provide(settingsLayer),
288 Layer.provide(layerReceiptsTest),
289 );
290
291it.effect("refuses a new turn when the project effectively disables the instance", () => {
292 const projectId = ProjectId.make("project:thread-management:gate-disabled");
293 const instanceId = ProviderInstanceId.make("codex_work");
294 const commandId = CommandId.make("command:thread-management:gate-disabled");
295 const layerTest = layerThreadManagementTest(
296 ServerSettings.layerTest({
297 providerInstances: { [instanceId]: { driver: "codex", enabled: true } },
298 projectSettingsOverrides: {
299 [projectId]: { providerInstances: { [instanceId]: { enabled: false } } },
300 },
301 }),
302 );
303
304 return Effect.gen(function* () {
305 const threads = yield* ThreadManagementService.ThreadManagementService;
306 const error = yield* Effect.flip(
307 threads.assertProviderInstanceEnabled({ projectId, commandId, instanceId }),
308 );
309
310 expect(error).toBeInstanceOf(
311 ThreadManagementService.ThreadManagementProviderInstanceDisabledError,
312 );
313 expect(error).toMatchObject({ projectId, instanceId });
314 expect(error.message).toBe(
315 `Provider instance "${instanceId}" is disabled for project ${projectId}.`,
316 );
317 }).pipe(Effect.provide(layerTest));
318});
319
320it.effect("allows a new turn when the project enables an instance the machine disabled", () => {
321 const projectId = ProjectId.make("project:thread-management:gate-enabled");
322 const instanceId = ProviderInstanceId.make("codex_work");
323 const commandId = CommandId.make("command:thread-management:gate-enabled");
324 const layerTest = layerThreadManagementTest(
325 ServerSettings.layerTest({
326 providerInstances: { [instanceId]: { driver: "codex", enabled: false } },
327 projectSettingsOverrides: {
328 [projectId]: { providerInstances: { [instanceId]: { enabled: true } } },
329 },
330 }),
331 );
332
333 return Effect.gen(function* () {
334 const threads = yield* ThreadManagementService.ThreadManagementService;
335 yield* threads.assertProviderInstanceEnabled({ projectId, commandId, instanceId });
336 }).pipe(Effect.provide(layerTest));
337});
338
339it.effect("inherits the machine value when the project has no override", () => {
340 const projectId = ProjectId.make("project:thread-management:gate-inherit");
341 const instanceId = ProviderInstanceId.make("codex_work");
342 const commandId = CommandId.make("command:thread-management:gate-inherit");
343 const layerTest = layerThreadManagementTest(
344 ServerSettings.layerTest({
345 providerInstances: { [instanceId]: { driver: "codex", enabled: false } },
346 }),
347 );
348
349 return Effect.gen(function* () {
350 const threads = yield* ThreadManagementService.ThreadManagementService;
351 const error = yield* Effect.flip(
352 threads.assertProviderInstanceEnabled({ projectId, commandId, instanceId }),
353 );
354
355 expect(error).toBeInstanceOf(
356 ThreadManagementService.ThreadManagementProviderInstanceDisabledError,
357 );
358 }).pipe(Effect.provide(layerTest));
359});
360
361const layerDispatchChokepointTest =
362 (settingsLayer: ReturnType<typeof ServerSettings.layerTest>) => (dispatched: Array<string>) =>
363 ThreadManagementService.layer.pipe(
364 Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})),
365 Layer.provide(
366 Layer.mock(Orchestrator.OrchestratorV2)({
367 getThreadRecords: () =>
368 Effect.succeed({
369 thread: {
370 id: ThreadId.make("thread:thread-management:chokepoint"),
371 projectId: ProjectId.make("project:thread-management:chokepoint"),
372 deletedAt: null,
373 modelSelection: {
374 instanceId: ProviderInstanceId.make("codex_work"),
375 model: "test",
376 },
377 },
378 } as unknown as OrchestrationV2ThreadProjection),
379 dispatch: (command) =>
380 Effect.sync(() => {
381 dispatched.push(command.type);
382 return { sequence: 1, storedEvents: [] } as never;
383 }),
384 }),
385 ),
386 Layer.provide(settingsLayer),
387 Layer.provideMerge(layerReceiptsTest),
388 );
389
390it.effect(
391 "the dispatch chokepoint refuses a continuation message.dispatch on a project-disabled instance before reaching the orchestrator",
392 () => {
393 const projectId = ProjectId.make("project:thread-management:chokepoint");
394 const threadId = ThreadId.make("thread:thread-management:chokepoint");
395 const instanceId = ProviderInstanceId.make("codex_work");
396 const commandId = CommandId.make("command:thread-management:chokepoint-disabled");
397 const dispatched: Array<string> = [];
398 const layerTest = layerDispatchChokepointTest(
399 ServerSettings.layerTest({
400 providerInstances: { [instanceId]: { driver: "codex", enabled: true } },
401 projectSettingsOverrides: {
402 [projectId]: { providerInstances: { [instanceId]: { enabled: false } } },
403 },
404 }),
405 )(dispatched);
406
407 return Effect.gen(function* () {
408 const threads = yield* ThreadManagementService.ThreadManagementService;
409 const error = yield* Effect.flip(
410 threads.dispatch({
411 type: "message.dispatch",
412 commandId,
413 threadId,
414 messageId: MessageId.make("message:thread-management:chokepoint-disabled"),
415 text: "Background task completed.",
416 attachments: [],
417 dispatchMode: { type: "queue_after_active" },
418 createdBy: "agent",
419 creationSource: "server",
420 }),
421 );
422
423 expect(error).toBeInstanceOf(
424 ThreadManagementService.ThreadManagementProviderInstanceDisabledError,
425 );
426 expect(error).toMatchObject({ projectId, instanceId });
427 expect(dispatched).toEqual([]);
428 }).pipe(Effect.provide(layerTest));
429 },
430);
431
432it.effect(
433 "a replay of an already-accepted command id dispatches despite the instance now being disabled",
434 () => {
435 const projectId = ProjectId.make("project:thread-management:chokepoint");
436 const threadId = ThreadId.make("thread:thread-management:chokepoint");
437 const instanceId = ProviderInstanceId.make("codex_work");
438 const commandId = CommandId.make("command:thread-management:chokepoint-replay");
439 const dispatched: Array<string> = [];
440 const layerTest = layerDispatchChokepointTest(
441 ServerSettings.layerTest({
442 providerInstances: { [instanceId]: { driver: "codex", enabled: true } },
443 projectSettingsOverrides: {
444 [projectId]: { providerInstances: { [instanceId]: { enabled: false } } },
445 },
446 }),
447 )(dispatched);
448
449 return Effect.gen(function* () {
450 const receipts = yield* CommandReceiptStore.CommandReceiptStoreV2;
451 yield* receipts.insertIfAbsent({
452 commandId,
453 threadId,
454 commandType: "message.dispatch",
455 acceptedAt: yield* DateTime.now,
456 resultSequence: 1,
457 status: "accepted",
458 error: null,
459 });
460
461 const threads = yield* ThreadManagementService.ThreadManagementService;
462 yield* threads.dispatch({
463 type: "message.dispatch",
464 commandId,
465 threadId,
466 messageId: MessageId.make("message:thread-management:chokepoint-replay"),
467 text: "Background task completed.",
468 attachments: [],
469 dispatchMode: { type: "queue_after_active" },
470 createdBy: "agent",
471 creationSource: "server",
472 });
473
474 expect(dispatched).toEqual(["message.dispatch"]);
475 }).pipe(Effect.provide(layerTest));
476 },
477);
478
276it.effect("classifies projection infrastructure failures separately from a missing thread", () => {479it.effect("classifies projection infrastructure failures separately from a missing thread", () => {
277 const projectId = ProjectId.make("project:thread-management:projection-failure");480 const projectId = ProjectId.make("project:thread-management:projection-failure");
278 const threadId = ThreadId.make("thread:thread-management:projection-failure");481 const threadId = ThreadId.make("thread:thread-management:projection-failure");
...@@ -288,6 +491,8 @@ it.effect("classifies projection infrastructure failures separately from a missi...@@ -288,6 +491,8 @@ it.effect("classifies projection infrastructure failures separately from a missi
288 getThreadProjection: () => Effect.fail(projectionError),491 getThreadProjection: () => Effect.fail(projectionError),
289 }),492 }),
290 ),493 ),
494 Layer.provide(ServerSettings.layerTest()),
495 Layer.provide(layerReceiptsTest),
291 );496 );
292497
293 return Effect.gen(function* () {498 return Effect.gen(function* () {
...@@ -322,6 +527,8 @@ it.effect("uses thread-not-found only after a projection loads outside the proje...@@ -322,6 +527,8 @@ it.effect("uses thread-not-found only after a projection loads outside the proje
322 getThreadProjection: () => Effect.succeed(projection),527 getThreadProjection: () => Effect.succeed(projection),
323 }),528 }),
324 ),529 ),
530 Layer.provide(ServerSettings.layerTest()),
531 Layer.provide(layerReceiptsTest),
325 );532 );
326533
327 return Effect.gen(function* () {534 return Effect.gen(function* () {
...@@ -354,6 +561,8 @@ it.effect("preserves failed legacy materialization when reading checkpoint conte...@@ -354,6 +561,8 @@ it.effect("preserves failed legacy materialization when reading checkpoint conte
354 }),561 }),
355 ),562 ),
356 ),563 ),
564 Layer.provide(ServerSettings.layerTest()),
565 Layer.provide(layerReceiptsTest),
357 );566 );
358 return Effect.gen(function* () {567 return Effect.gen(function* () {
359 const service = yield* ThreadManagementService.ThreadManagementService;568 const service = yield* ThreadManagementService.ThreadManagementService;
...@@ -402,6 +611,8 @@ it.effect.each([...@@ -402,6 +611,8 @@ it.effect.each([
402 }),611 }),
403 }),612 }),
404 ),613 ),
614 Layer.provide(ServerSettings.layerTest()),
615 Layer.provide(layerReceiptsTest),
405 );616 );
406 const service = yield* ThreadManagementService.ThreadManagementService.pipe(617 const service = yield* ThreadManagementService.ThreadManagementService.pipe(
407 Effect.provide(layerTest),618 Effect.provide(layerTest),
...@@ -473,6 +684,8 @@ it.effect("waitForThread reads the run again only when the run updates", () =>...@@ -473,6 +684,8 @@ it.effect("waitForThread reads the run again only when the run updates", () =>
473 }),684 }),
474 }),685 }),
475 ),686 ),
687 Layer.provide(ServerSettings.layerTest()),
688 Layer.provide(layerReceiptsTest),
476 );689 );
477 const service = yield* ThreadManagementService.ThreadManagementService.pipe(690 const service = yield* ThreadManagementService.ThreadManagementService.pipe(
478 Effect.provide(layerTest),691 Effect.provide(layerTest),
...@@ -617,6 +830,8 @@ it.effect.each([...@@ -617,6 +830,8 @@ it.effect.each([
617 }),830 }),
618 ),831 ),
619 ),832 ),
833 Layer.provide(ServerSettings.layerTest()),
834 Layer.provide(layerReceiptsTest),
620 );835 );
621 yield* Effect.gen(function* () {836 yield* Effect.gen(function* () {
622 const service = yield* ThreadManagementService.ThreadManagementService;837 const service = yield* ThreadManagementService.ThreadManagementService;
...@@ -735,6 +950,8 @@ it.effect.each([...@@ -735,6 +950,8 @@ it.effect.each([
735 }),950 }),
736 }),951 }),
737 ),952 ),
953 Layer.provide(ServerSettings.layerTest()),
954 Layer.provide(layerReceiptsTest),
738 );955 );
739 const service = yield* ThreadManagementService.ThreadManagementService.pipe(956 const service = yield* ThreadManagementService.ThreadManagementService.pipe(
740 Effect.provide(layerTest),957 Effect.provide(layerTest),
apps/server/src/orchestration-v2/ThreadManagementService.ts+148-9
...@@ -26,6 +26,8 @@ import {...@@ -26,6 +26,8 @@ import {
26 type OrchestrationV2ThreadShell,26 type OrchestrationV2ThreadShell,
27 type OrchestrationV2TurnItem,27 type OrchestrationV2TurnItem,
28 ProjectId,28 ProjectId,
29 ProviderInstanceId,
30 resolveProjectProviderInstanceEnabled,
29 RunId,31 RunId,
30 type ScheduledTaskId,32 type ScheduledTaskId,
31 ThreadId,33 ThreadId,
...@@ -41,12 +43,14 @@ import * as Ref from "effect/Ref";...@@ -41,12 +43,14 @@ import * as Ref from "effect/Ref";
41import * as Schema from "effect/Schema";43import * as Schema from "effect/Schema";
42import * as Stream from "effect/Stream";44import * as Stream from "effect/Stream";
4345
46import * as CommandReceiptStore from "./CommandReceiptStore.ts";
44import * as Orchestrator from "./Orchestrator.ts";47import * as Orchestrator from "./Orchestrator.ts";
45import * as ProviderSessionManager from "./ProviderSessionManager.ts";48import * as ProviderSessionManager from "./ProviderSessionManager.ts";
46import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts";49import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts";
47import { DispatchModeLimit, exceededDispatchModeLimit } from "./DispatchModeLimit.ts";50import { DispatchModeLimit, exceededDispatchModeLimit } from "./DispatchModeLimit.ts";
48import { projectTurnItemForDetail } from "./WireProjection.ts";51import { projectTurnItemForDetail } from "./WireProjection.ts";
49import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts";52import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts";
53import * as ServerSettings from "../serverSettings.ts";
5054
51export type ThreadManagementSendMode = "auto" | "queue" | "steer" | "restart";55export type ThreadManagementSendMode = "auto" | "queue" | "steer" | "restart";
5256
...@@ -266,6 +270,18 @@ export class ThreadManagementDurableRunProjectionError extends Schema.TaggedErro...@@ -266,6 +270,18 @@ export class ThreadManagementDurableRunProjectionError extends Schema.TaggedErro
266 }270 }
267}271}
268272
273export class ThreadManagementProviderInstanceDisabledError extends Schema.TaggedError<ThreadManagementProviderInstanceDisabledError>()(
274 "ThreadManagementProviderInstanceDisabledError",
275 {
276 projectId: ProjectId,
277 instanceId: ProviderInstanceId,
278 },
279) {
280 override get message(): string {
281 return `Provider instance "${this.instanceId}" is disabled for project ${this.projectId}.`;
282 }
283}
284
269export const ThreadManagementError = Schema.Union([285export const ThreadManagementError = Schema.Union([
270 ThreadManagementThreadNotFoundError,286 ThreadManagementThreadNotFoundError,
271 ThreadManagementRunNotFoundError,287 ThreadManagementRunNotFoundError,
...@@ -275,6 +291,7 @@ export const ThreadManagementError = Schema.Union([...@@ -275,6 +291,7 @@ export const ThreadManagementError = Schema.Union([
275 ThreadManagementProjectionLoadError,291 ThreadManagementProjectionLoadError,
276 ThreadManagementProjectThreadsListError,292 ThreadManagementProjectThreadsListError,
277 ThreadManagementDurableRunProjectionError,293 ThreadManagementDurableRunProjectionError,
294 ThreadManagementProviderInstanceDisabledError,
278]);295]);
279export type ThreadManagementError = typeof ThreadManagementError.Type;296export type ThreadManagementError = typeof ThreadManagementError.Type;
280297
...@@ -290,9 +307,18 @@ export interface ThreadManagementServiceShape {...@@ -290,9 +307,18 @@ export interface ThreadManagementServiceShape {
290 readonly ensureLegacyTranscript: (307 readonly ensureLegacyTranscript: (
291 threadId: ThreadId,308 threadId: ThreadId,
292 ) => Effect.Effect<void, LegacyV1ThreadImporter.LegacyV1ThreadImportError>;309 ) => Effect.Effect<void, LegacyV1ThreadImporter.LegacyV1ThreadImportError>;
310 /**
311 * The single chokepoint for commands that can start or wake provider work:
312 * `message.dispatch` and `delegated_task.request` are refused here when the
313 * resolved instance is off for the project, except a command id that
314 * already has a receipt, which only replays its recorded result.
315 */
293 readonly dispatch: (316 readonly dispatch: (
294 command: OrchestrationV2ServerCommand,317 command: OrchestrationV2ServerCommand,
295 ) => Effect.Effect<Orchestrator.OrchestratorV2DispatchResult, Orchestrator.OrchestratorV2Error>;318 ) => Effect.Effect<
319 Orchestrator.OrchestratorV2DispatchResult,
320 Orchestrator.OrchestratorV2Error | ThreadManagementProviderInstanceDisabledError
321 >;
296 readonly getThreadHistoryPage: Orchestrator.OrchestratorV2["Service"]["getThreadHistoryPage"];322 readonly getThreadHistoryPage: Orchestrator.OrchestratorV2["Service"]["getThreadHistoryPage"];
297 readonly getTimelinePage: Orchestrator.OrchestratorV2["Service"]["getTimelinePage"];323 readonly getTimelinePage: Orchestrator.OrchestratorV2["Service"]["getTimelinePage"];
298 readonly getMessageCount: Orchestrator.OrchestratorV2["Service"]["getMessageCount"];324 readonly getMessageCount: Orchestrator.OrchestratorV2["Service"]["getMessageCount"];
...@@ -340,6 +366,32 @@ export interface ThreadManagementServiceShape {...@@ -340,6 +366,32 @@ export interface ThreadManagementServiceShape {
340 readonly sendToThread: (366 readonly sendToThread: (
341 input: ThreadManagementSendInput,367 input: ThreadManagementSendInput,
342 ) => Effect.Effect<ThreadManagementSendResult, ThreadManagementFailure>;368 ) => Effect.Effect<ThreadManagementSendResult, ThreadManagementFailure>;
369 /**
370 * Refuses a new turn on an instance the project has effectively disabled.
371 * A command that already has a receipt was let through before, so its
372 * retry replays instead of being refused under newer settings.
373 */
374 readonly assertProviderInstanceEnabled: (input: {
375 readonly projectId: ProjectId;
376 readonly commandId: CommandId;
377 readonly instanceId: ProviderInstanceId;
378 }) => Effect.Effect<void, ThreadManagementProviderInstanceDisabledError>;
379 /**
380 * Effective enablement for candidate selection, without the receipt-replay
381 * exemption `assertProviderInstanceEnabled` grants an accepted command.
382 */
383 readonly isProviderInstanceEnabledForProject: (input: {
384 readonly projectId: ProjectId;
385 readonly instanceId: ProviderInstanceId;
386 }) => Effect.Effect<boolean>;
387 /**
388 * Whether `commandId` already has a receipt. A caller resolving a target
389 * ahead of a dispatch under this same command id can use it to skip
390 * project-enablement filtering: the dispatch will only replay the recorded
391 * result, never start anything new, so the filter would just refuse a
392 * retry the project's settings no longer allow fresh.
393 */
394 readonly hasReceipt: (commandId: CommandId) => Effect.Effect<boolean>;
343 readonly waitForThread: (395 readonly waitForThread: (
344 input: ThreadManagementWaitInput,396 input: ThreadManagementWaitInput,
345 ) => Effect.Effect<ThreadManagementWaitResult, ThreadManagementError>;397 ) => Effect.Effect<ThreadManagementWaitResult, ThreadManagementError>;
...@@ -365,7 +417,9 @@ export interface ThreadManagementServiceShape {...@@ -365,7 +417,9 @@ export interface ThreadManagementServiceShape {
365 readonly byOwnAgent: boolean;417 readonly byOwnAgent: boolean;
366 }) => Effect.Effect<418 }) => Effect.Effect<
367 { readonly sequence: number } | { readonly settlesWhenTurnEnds: true },419 { readonly sequence: number } | { readonly settlesWhenTurnEnds: true },
368 Orchestrator.OrchestratorV2Error420 // Dispatches thread.settle, a command the enablement chokepoint never
421 // gates; the type stays accurate anyway.
422 Orchestrator.OrchestratorV2Error | ThreadManagementProviderInstanceDisabledError
369 >;423 >;
370 readonly interruptThread: (424 readonly interruptThread: (
371 input: ThreadManagementInterruptInput,425 input: ThreadManagementInterruptInput,
...@@ -386,7 +440,12 @@ export interface ThreadManagementServiceShape {...@@ -386,7 +440,12 @@ export interface ThreadManagementServiceShape {
386 readonly threadId: ThreadId;440 readonly threadId: ThreadId;
387 readonly commandId: CommandId;441 readonly commandId: CommandId;
388 readonly reason?: string | undefined;442 readonly reason?: string | undefined;
389 }) => Effect.Effect<void, Orchestrator.OrchestratorV2Error>;443 }) => Effect.Effect<
444 void,
445 // Dispatches thread.stop, a command the enablement chokepoint never
446 // gates; the type stays accurate anyway.
447 Orchestrator.OrchestratorV2Error | ThreadManagementProviderInstanceDisabledError
448 >;
390 readonly getThreadEventSequence: Orchestrator.OrchestratorV2["Service"]["getThreadEventSequence"];449 readonly getThreadEventSequence: Orchestrator.OrchestratorV2["Service"]["getThreadEventSequence"];
391 readonly recoverDelegatedTask: Orchestrator.OrchestratorV2["Service"]["recoverDelegatedTask"];450 readonly recoverDelegatedTask: Orchestrator.OrchestratorV2["Service"]["recoverDelegatedTask"];
392 readonly delegatedTaskResultPending: Orchestrator.OrchestratorV2["Service"]["delegatedTaskResultPending"];451 readonly delegatedTaskResultPending: Orchestrator.OrchestratorV2["Service"]["delegatedTaskResultPending"];
...@@ -458,6 +517,44 @@ const make = Effect.gen(function* () {...@@ -458,6 +517,44 @@ const make = Effect.gen(function* () {
458 const sessions = yield* ProviderSessionManager.ProviderSessionManagerV2;517 const sessions = yield* ProviderSessionManager.ProviderSessionManagerV2;
459 const layerScope = yield* Effect.scope;518 const layerScope = yield* Effect.scope;
460 const legacyImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter;519 const legacyImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter;
520 const serverSettings = yield* ServerSettings.ServerSettingsService;
521 const receipts = yield* CommandReceiptStore.CommandReceiptStoreV2;
522
523 const assertProviderInstanceEnabled: ThreadManagementServiceShape["assertProviderInstanceEnabled"] =
524 (input) =>
525 receipts.getByCommandId(input.commandId).pipe(
526 Effect.orElseSucceed(() => Option.none()),
527 Effect.flatMap((receipt) =>
528 Option.isSome(receipt)
529 ? Effect.void
530 : serverSettings.getSettings.pipe(
531 Effect.orDie,
532 Effect.flatMap((current) =>
533 resolveProjectProviderInstanceEnabled(current, input.projectId, input.instanceId)
534 ? Effect.void
535 : new ThreadManagementProviderInstanceDisabledError({
536 projectId: input.projectId,
537 instanceId: input.instanceId,
538 }),
539 ),
540 ),
541 ),
542 );
543
544 const isProviderInstanceEnabledForProject: ThreadManagementServiceShape["isProviderInstanceEnabledForProject"] =
545 (input) =>
546 serverSettings.getSettings.pipe(
547 Effect.orDie,
548 Effect.map((current) =>
549 resolveProjectProviderInstanceEnabled(current, input.projectId, input.instanceId),
550 ),
551 );
552
553 const hasReceipt: ThreadManagementServiceShape["hasReceipt"] = (commandId) =>
554 receipts.getByCommandId(commandId).pipe(
555 Effect.orElseSucceed(() => Option.none()),
556 Effect.map(Option.isSome),
557 );
461558
462 const ensureLegacyTranscript = Effect.fn(559 const ensureLegacyTranscript = Effect.fn(
463 "orchestrationV2.threadManagement.ensureLegacyTranscript",560 "orchestrationV2.threadManagement.ensureLegacyTranscript",
...@@ -524,8 +621,41 @@ const make = Effect.gen(function* () {...@@ -524,8 +621,41 @@ const make = Effect.gen(function* () {
524 Effect.andThen(orchestrator.getThreadSnapshotWindow(threadId, options)),621 Effect.andThen(orchestrator.getThreadSnapshotWindow(threadId, options)),
525 );622 );
526623
624 // Every path that can start or wake a provider turn funnels through this
625 // dispatch, so resolving and checking the target instance here (instead of
626 // at each caller) is the one place "off" is enforced.
627 const assertDispatchTargetEnabled = (command: OrchestrationV2ServerCommand) => {
628 switch (command.type) {
629 case "message.dispatch":
630 return orchestrator.getThreadRecords(command.threadId, []).pipe(
631 Effect.flatMap((projection) =>
632 assertProviderInstanceEnabled({
633 projectId: projection.thread.projectId,
634 commandId: command.commandId,
635 instanceId: (command.modelSelection ?? projection.thread.modelSelection).instanceId,
636 }),
637 ),
638 );
639 case "delegated_task.request":
640 return orchestrator.getThreadRecords(command.parentThreadId, []).pipe(
641 Effect.flatMap((parent) =>
642 assertProviderInstanceEnabled({
643 projectId: parent.thread.projectId,
644 commandId: command.commandId,
645 instanceId: command.modelSelection.instanceId,
646 }),
647 ),
648 );
649 default:
650 return Effect.void;
651 }
652 };
653
527 const dispatch: ThreadManagementServiceShape["dispatch"] = (command) =>654 const dispatch: ThreadManagementServiceShape["dispatch"] = (command) =>
528 ensureCommandTranscripts(command).pipe(Effect.andThen(orchestrator.dispatch(command)));655 ensureCommandTranscripts(command).pipe(
656 Effect.andThen(assertDispatchTargetEnabled(command)),
657 Effect.andThen(orchestrator.dispatch(command)),
658 );
529659
530 const getProjectThread: ThreadManagementServiceShape["getProjectThread"] = (input) =>660 const getProjectThread: ThreadManagementServiceShape["getProjectThread"] = (input) =>
531 getThreadProjection(input.threadId).pipe(661 getThreadProjection(input.threadId).pipe(
...@@ -608,7 +738,6 @@ const make = Effect.gen(function* () {...@@ -608,7 +738,6 @@ const make = Effect.gen(function* () {
608 threadId: input.threadId,738 threadId: input.threadId,
609 });739 });
610 }740 }
611
612 const steerableRun = latestSteerableRun(target);741 const steerableRun = latestSteerableRun(target);
613 let dispatchMode: Extract<742 let dispatchMode: Extract<
614 OrchestrationV2Command,743 OrchestrationV2Command,
...@@ -633,7 +762,7 @@ const make = Effect.gen(function* () {...@@ -633,7 +762,7 @@ const make = Effect.gen(function* () {
633 };762 };
634 }763 }
635764
636 const dispatch = yield* orchestrator.dispatch({765 const dispatched = yield* dispatch({
637 type: "message.dispatch",766 type: "message.dispatch",
638 commandId: input.commandId,767 commandId: input.commandId,
639 threadId: input.threadId,768 threadId: input.threadId,
...@@ -685,7 +814,7 @@ const make = Effect.gen(function* () {...@@ -685,7 +814,7 @@ const make = Effect.gen(function* () {
685 : input.mode === "restart"814 : input.mode === "restart"
686 ? "restarted"815 ? "restarted"
687 : "steered";816 : "steered";
688 return { dispatch, projection, message, run, turnItem, delivery };817 return { dispatch: dispatched, projection, message, run, turnItem, delivery };
689 });818 });
690819
691 const waitForThread: ThreadManagementServiceShape["waitForThread"] = (input) =>820 const waitForThread: ThreadManagementServiceShape["waitForThread"] = (input) =>
...@@ -943,7 +1072,9 @@ const make = Effect.gen(function* () {...@@ -943,7 +1072,9 @@ const make = Effect.gen(function* () {
943 const stopDelegatedTasks: ThreadManagementServiceShape["stopDelegatedTasks"] = (input) =>1072 const stopDelegatedTasks: ThreadManagementServiceShape["stopDelegatedTasks"] = (input) =>
944 Effect.gen(function* () {1073 Effect.gen(function* () {
945 const { subagents } = yield* orchestrator.getThreadRecords(input.threadId, ["subagents"]);1074 const { subagents } = yield* orchestrator.getThreadRecords(input.threadId, ["subagents"]);
946 const failures: Array<Orchestrator.OrchestratorV2Error> = [];1075 const failures: Array<
1076 Orchestrator.OrchestratorV2Error | ThreadManagementProviderInstanceDisabledError
1077 > = [];
947 for (const task of subagents) {1078 for (const task of subagents) {
948 if (task.origin !== "app_owned" || task.childThreadId === null) continue;1079 if (task.origin !== "app_owned" || task.childThreadId === null) continue;
949 const threadId = task.childThreadId;1080 const threadId = task.childThreadId;
...@@ -1013,6 +1144,9 @@ const make = Effect.gen(function* () {...@@ -1013,6 +1144,9 @@ const make = Effect.gen(function* () {
1013 getThreadShell: orchestrator.getThreadShell,1144 getThreadShell: orchestrator.getThreadShell,
1014 listProjectThreads,1145 listProjectThreads,
1015 sendToThread,1146 sendToThread,
1147 assertProviderInstanceEnabled,
1148 isProviderInstanceEnabledForProject,
1149 hasReceipt,
1016 waitForThread,1150 waitForThread,
1017 settleAfterRun,1151 settleAfterRun,
1018 settleThread,1152 settleThread,
...@@ -1041,7 +1175,10 @@ const layerLegacyV1ThreadImporterNoop = Layer.succeed(...@@ -1041,7 +1175,10 @@ const layerLegacyV1ThreadImporterNoop = Layer.succeed(
1041export const layer: Layer.Layer<1175export const layer: Layer.Layer<
1042 ThreadManagementService,1176 ThreadManagementService,
1043 never,1177 never,
1044 Orchestrator.OrchestratorV2 | ProviderSessionManager.ProviderSessionManagerV21178 | Orchestrator.OrchestratorV2
1179 | ProviderSessionManager.ProviderSessionManagerV2
1180 | ServerSettings.ServerSettingsService
1181 | CommandReceiptStore.CommandReceiptStoreV2
1045> = Layer.effect(ThreadManagementService, make).pipe(1182> = Layer.effect(ThreadManagementService, make).pipe(
1046 Layer.provide(layerLegacyV1ThreadImporterNoop),1183 Layer.provide(layerLegacyV1ThreadImporterNoop),
1047 Layer.provide(ThreadCommandExecutor.layer),1184 Layer.provide(ThreadCommandExecutor.layer),
...@@ -1053,4 +1190,6 @@ export const layerWithLegacyImporter: Layer.Layer<...@@ -1053,4 +1190,6 @@ export const layerWithLegacyImporter: Layer.Layer<
1053 | LegacyV1ThreadImporter.LegacyV1ThreadImporter1190 | LegacyV1ThreadImporter.LegacyV1ThreadImporter
1054 | Orchestrator.OrchestratorV21191 | Orchestrator.OrchestratorV2
1055 | ProviderSessionManager.ProviderSessionManagerV21192 | ProviderSessionManager.ProviderSessionManagerV2
1193 | ServerSettings.ServerSettingsService
1194 | CommandReceiptStore.CommandReceiptStoreV2
1056> = Layer.effect(ThreadManagementService, make).pipe(Layer.provide(ThreadCommandExecutor.layer));1195> = Layer.effect(ThreadManagementService, make).pipe(Layer.provide(ThreadCommandExecutor.layer));
apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts+56-2
...@@ -7,6 +7,9 @@ import {...@@ -7,6 +7,9 @@ import {
7 ChatAttachmentId,7 ChatAttachmentId,
8 CommandId,8 CommandId,
9 EventId,9 EventId,
10 MessageId,
11 ProjectId,
12 ProviderInstanceId,
10 RuntimeRequestId,13 RuntimeRequestId,
11 ThreadId,14 ThreadId,
12 TurnItemId,15 TurnItemId,
...@@ -20,16 +23,17 @@ import * as Effect from "effect/Effect";...@@ -20,16 +23,17 @@ import * as Effect from "effect/Effect";
20import * as Layer from "effect/Layer";23import * as Layer from "effect/Layer";
21import { createPendingAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts";24import { createPendingAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts";
22import * as ServerConfig from "../config.ts";25import * as ServerConfig from "../config.ts";
26import * as ServerSettings from "../serverSettings.ts";
23import {27import {
24 OrchestratorCommandPreviouslyRejectedError,28 OrchestratorCommandPreviouslyRejectedError,
25 OrchestratorDispatchError,29 OrchestratorDispatchError,
26} from "./Orchestrator.ts";30} from "./Orchestrator.ts";
27import * as ThreadManagementService from "./ThreadManagementService.ts";31import * as ThreadManagementService from "./ThreadManagementService.ts";
28import { dispatchCommand } from "./ThreadMessageIntake.ts";32import { dispatchCommand, sendToThread } from "./ThreadMessageIntake.ts";
2933
30const layerIntakeTest = ServerConfig.layerTest(process.cwd(), {34const layerIntakeTest = ServerConfig.layerTest(process.cwd(), {
31 prefix: "t3-question-intake-",35 prefix: "t3-question-intake-",
32}).pipe(Layer.provideMerge(NodeServices.layer));36}).pipe(Layer.provideMerge(NodeServices.layer), Layer.provideMerge(ServerSettings.layerTest()));
3337
34const layerFailingDispatch = (captured: OrchestrationV2ServerCommand[]) =>38const layerFailingDispatch = (captured: OrchestrationV2ServerCommand[]) =>
35 Layer.mock(ThreadManagementService.ThreadManagementService)({39 Layer.mock(ThreadManagementService.ThreadManagementService)({
...@@ -764,3 +768,53 @@ it.effect("applies the image budget across all questions before dispatch", () =>...@@ -764,3 +768,53 @@ it.effect("applies the image budget across all questions before dispatch", () =>
764 expect(captured).toEqual([]);768 expect(captured).toEqual([]);
765 }).pipe(Effect.provide(layerIntakeTest)),769 }).pipe(Effect.provide(layerIntakeTest)),
766);770);
771
772it.effect("releases claimed copies when a project disables the message's provider instance", () =>
773 Effect.gen(function* () {
774 const config = yield* ServerConfig.ServerConfig;
775 const pendingId = ChatAttachmentId.make(createPendingAttachmentId()!);
776 NodeFS.writeFileSync(
777 NodePath.join(config.attachmentsDir, `${pendingId}.png`),
778 new Uint8Array([1, 2, 3]),
779 );
780 const result = yield* sendToThread({
781 projectId: ProjectId.make("project-disabled-send"),
782 commandId: CommandId.make("command-disabled-send"),
783 threadId: ThreadId.make("thread-disabled-send"),
784 messageId: MessageId.make("message-disabled-send"),
785 text: "Hello",
786 attachments: [
787 {
788 type: "image",
789 id: pendingId,
790 name: "screen.png",
791 mimeType: "image/png",
792 sizeBytes: 3,
793 },
794 ],
795 mode: "auto",
796 createdBy: "user",
797 creationSource: "web",
798 }).pipe(
799 Effect.provide(
800 Layer.mock(ThreadManagementService.ThreadManagementService)({
801 sendToThread: (input) =>
802 Effect.fail(
803 new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({
804 projectId: input.projectId,
805 instanceId: ProviderInstanceId.make("codex"),
806 }),
807 ),
808 }),
809 ),
810 Effect.result,
811 );
812 expect(result._tag).toBe("Failure");
813 expect(
814 NodeFS.readdirSync(config.attachmentsDir).filter((entry) =>
815 entry.startsWith("thread-disabled-send-"),
816 ),
817 ).toEqual([]);
818 expect(NodeFS.existsSync(NodePath.join(config.attachmentsDir, `${pendingId}.png`))).toBe(true);
819 }).pipe(Effect.provide(layerIntakeTest)),
820);
apps/server/src/orchestration-v2/ThreadMessageIntake.ts+11-1
...@@ -13,6 +13,8 @@ import * as ThreadManagement from "./ThreadManagementService.ts";...@@ -13,6 +13,8 @@ import * as ThreadManagement from "./ThreadManagementService.ts";
1313
14// These dispatcher failures occur in receipt validation or planning, before14// These dispatcher failures occur in receipt validation or planning, before
15// commitCommand. Generic dispatch errors can follow a commit and remain uncertain.15// commitCommand. Generic dispatch errors can follow a commit and remain uncertain.
16// A disabled-instance refusal comes from ThreadManagementService.dispatch's
17// own enablement check, which also runs before any commit is attempted.
16function dispatchWasNotAccepted(18function dispatchWasNotAccepted(
17 error: Orchestrator.OrchestratorV2Error | ThreadManagement.ThreadManagementError,19 error: Orchestrator.OrchestratorV2Error | ThreadManagement.ThreadManagementError,
18) {20) {
...@@ -24,12 +26,14 @@ function dispatchWasNotAccepted(...@@ -24,12 +26,14 @@ function dispatchWasNotAccepted(
24 case "OrchestratorCommandIdConflictError":26 case "OrchestratorCommandIdConflictError":
25 case "OrchestratorSubagentThreadReadOnlyError":27 case "OrchestratorSubagentThreadReadOnlyError":
26 case "OrchestratorThreadAboveModeLimitError":28 case "OrchestratorThreadAboveModeLimitError":
29 case "ThreadManagementProviderInstanceDisabledError":
27 return true;30 return true;
28 default:31 default:
29 return false;32 return false;
30 }33 }
31}34}
32const isOrchestratorError = Schema.is(Orchestrator.OrchestratorV2Error);35const isOrchestratorError = Schema.is(Orchestrator.OrchestratorV2Error);
36const isThreadManagementError = Schema.is(ThreadManagement.ThreadManagementError);
3337
34const releaseUnusedClaims = Effect.fn("ThreadMessageIntake.releaseUnusedClaims")(function* (38const releaseUnusedClaims = Effect.fn("ThreadMessageIntake.releaseUnusedClaims")(function* (
35 claimedPaths: ReadonlyArray<string>,39 claimedPaths: ReadonlyArray<string>,
...@@ -221,13 +225,19 @@ export const launchThread = Effect.fn("ThreadMessageIntake.launchThread")(functi...@@ -221,13 +225,19 @@ export const launchThread = Effect.fn("ThreadMessageIntake.launchThread")(functi
221 ),225 ),
222 ),226 ),
223 Effect.tapError((error) => {227 Effect.tapError((error) => {
228 if (error._tag === "ThreadManagementProviderInstanceDisabledError") {
229 return AttachmentClaims.releaseClaimedAttachments(claimed.claimedPaths);
230 }
224 // Project/receipt reads precede message dispatch. The create-thread error231 // Project/receipt reads precede message dispatch. The create-thread error
225 // also wraps post-message projection reads, so its tag alone is not proof.232 // also wraps post-message projection reads, so its tag alone is not proof.
233 // The initial message's own dispatch can still find the instance newly
234 // disabled, which wraps the same tag handled above instead of surfacing
235 // it directly, so the guard below also recognizes it.
226 const notAccepted =236 const notAccepted =
227 error.operation === "resolve-project" ||237 error.operation === "resolve-project" ||
228 error.operation === "read-receipt" ||238 error.operation === "read-receipt" ||
229 ((error.operation === "create-thread" || error.operation === "dispatch-message") &&239 ((error.operation === "create-thread" || error.operation === "dispatch-message") &&
230 isOrchestratorError(error.cause) &&240 (isOrchestratorError(error.cause) || isThreadManagementError(error.cause)) &&
231 // Projection errors under create-thread can occur after the message commit.241 // Projection errors under create-thread can occur after the message commit.
232 (error.operation !== "create-thread" ||242 (error.operation !== "create-thread" ||
233 error.cause._tag !== "OrchestratorProjectionError") &&243 error.cause._tag !== "OrchestratorProjectionError") &&
apps/server/src/orchestration-v2/ThreadStop.test.ts+4
...@@ -21,8 +21,10 @@ import * as SqlClient from "effect/sql/SqlClient";...@@ -21,8 +21,10 @@ import * as SqlClient from "effect/sql/SqlClient";
21import * as TestClock from "effect/testing/TestClock";21import * as TestClock from "effect/testing/TestClock";
2222
23import * as SqlitePersistence from "../persistence/Sqlite.ts";23import * as SqlitePersistence from "../persistence/Sqlite.ts";
24import * as ServerSettings from "../serverSettings.ts";
24import { CLAUDE_PROVIDER } from "./Adapters/ClaudeAdapterV2.ts";25import { CLAUDE_PROVIDER } from "./Adapters/ClaudeAdapterV2.ts";
25import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";26import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
27import * as CommandReceiptStore from "./CommandReceiptStore.ts";
26import { OrchestrationEffectRequestV2 } from "./EffectOutbox.ts";28import { OrchestrationEffectRequestV2 } from "./EffectOutbox.ts";
27import * as Orchestrator from "./Orchestrator.ts";29import * as Orchestrator from "./Orchestrator.ts";
28import * as ProjectionStore from "./ProjectionStore.ts";30import * as ProjectionStore from "./ProjectionStore.ts";
...@@ -54,6 +56,8 @@ const layerTest = ThreadManagementService.layer.pipe(...@@ -54,6 +56,8 @@ const layerTest = ThreadManagementService.layer.pipe(
54 ),56 ),
55 ),57 ),
56 ),58 ),
59 Layer.provideMerge(ServerSettings.layerTest()),
60 Layer.provideMerge(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))),
57);61);
5862
59const encodeEffectRequest = Schema.encodeSync(Schema.fromJsonString(OrchestrationEffectRequestV2));63const encodeEffectRequest = Schema.encodeSync(Schema.fromJsonString(OrchestrationEffectRequestV2));
apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts+8-2
...@@ -21,6 +21,7 @@ import * as ProjectStore from "./ProjectStore.ts";...@@ -21,6 +21,7 @@ import * as ProjectStore from "./ProjectStore.ts";
21import * as ServerSettings from "../serverSettings.ts";21import * as ServerSettings from "../serverSettings.ts";
22import * as TextGeneration from "../textGeneration/TextGeneration.ts";22import * as TextGeneration from "../textGeneration/TextGeneration.ts";
23import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";23import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
24import * as CommandReceiptStore from "./CommandReceiptStore.ts";
24import * as EffectOutbox from "./EffectOutbox.ts";25import * as EffectOutbox from "./EffectOutbox.ts";
25import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";26import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";
26import * as ThreadManagement from "./ThreadManagementService.ts";27import * as ThreadManagement from "./ThreadManagementService.ts";
...@@ -54,7 +55,12 @@ function makeHarness(...@@ -54,7 +55,12 @@ function makeHarness(
54 layerRegistry,55 layerRegistry,
55 { databaseLayer: layerDatabase, runEffectWorker: false },56 { databaseLayer: layerDatabase, runEffectWorker: false },
56 );57 );
57 const layerThreadManagement = ThreadManagement.layer.pipe(Layer.provide(layerOrchestrator));58 const layerServerSettings = ServerSettings.layerTest({});
59 const layerThreadManagement = ThreadManagement.layer.pipe(
60 Layer.provide(layerOrchestrator),
61 Layer.provide(layerServerSettings),
62 Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))),
63 );
58 const layerOutbox = EffectOutbox.layer.pipe(Layer.provide(layerDatabase));64 const layerOutbox = EffectOutbox.layer.pipe(Layer.provide(layerDatabase));
59 const generateThreadTitle = vi.fn(65 const generateThreadTitle = vi.fn(
60 options.generateTitle ?? (() => Effect.succeed({ title: "Generated title" })),66 options.generateTitle ?? (() => Effect.succeed({ title: "Generated title" })),
...@@ -86,7 +92,7 @@ function makeHarness(...@@ -86,7 +92,7 @@ function makeHarness(
86 layerThreadManagement,92 layerThreadManagement,
87 layerProjectedProjects,93 layerProjectedProjects,
88 Layer.mock(TextGeneration.TextGeneration)({ generateThreadTitle }),94 Layer.mock(TextGeneration.TextGeneration)({ generateThreadTitle }),
89 ServerSettings.layerTest({}),95 layerServerSettings,
90 ),96 ),
91 ),97 ),
92 );98 );
apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts+6-1
...@@ -33,7 +33,12 @@ export class ThreadTitleRegenerationService extends Context.Service<...@@ -33,7 +33,12 @@ export class ThreadTitleRegenerationService extends Context.Service<
33 | { readonly type: "regenerate" };33 | { readonly type: "regenerate" };
34 }) => Effect.Effect<34 }) => Effect.Effect<
35 void,35 void,
36 OrchestratorV2Error | ProjectStore.ProjectStoreV2Error | ServerSettingsError36 | OrchestratorV2Error
37 | ProjectStore.ProjectStoreV2Error
38 | ServerSettingsError
39 // `complete` dispatches thread.title.regeneration.complete, a command
40 // the enablement chokepoint never gates; the type stays accurate anyway.
41 | ThreadManagementService.ThreadManagementProviderInstanceDisabledError
37 >;42 >;
38 }43 }
39>()("t3/orchestration-v2/ThreadTitleRegenerationService") {}44>()("t3/orchestration-v2/ThreadTitleRegenerationService") {}
apps/server/src/orchestration-v2/runtimeLayer.test.ts+130
...@@ -5453,3 +5453,133 @@ it.layer(layerTest)("usage-limit recovery", (it) => {...@@ -5453,3 +5453,133 @@ it.layer(layerTest)("usage-limit recovery", (it) => {
5453 }),5453 }),
5454 );5454 );
5455});5455});
5456
5457const queueResumeDisabledInstanceProjectId = ProjectId.make(
5458 "project:queue-resume-disabled-instance",
5459);
5460
5461const layerQueueResumeDisabledInstanceTest = Layer.mergeAll(
5462 RuntimeLayer.layer,
5463 RuntimeLayer.layerEventSink,
5464 ProjectStore.layer,
5465 ProjectionStore.layer,
5466 EffectOutbox.layer,
5467 ThreadCommandExecutor.layer,
5468).pipe(
5469 Layer.provide(McpSessionRegistryTestkit.layer),
5470 Layer.provide(McpProviderSessions.layer),
5471 Layer.provide(SqlitePersistence.layerMemory),
5472 Layer.provide(layerCheckpointStoreTest),
5473 Layer.provide(layerServerConfig),
5474 Layer.provide(
5475 ServerSettings.layerTest({
5476 providerInstances: {
5477 [alternateInstanceId]: { driver: "codex", enabled: true },
5478 },
5479 projectSettingsOverrides: {
5480 [queueResumeDisabledInstanceProjectId]: {
5481 providerInstances: { [alternateInstanceId]: { enabled: false } },
5482 },
5483 },
5484 }),
5485 ),
5486 Layer.provide(layerTestProviderInstanceRegistry),
5487 Layer.provide(layerGitWorkflowTest),
5488 Layer.provide(layerProjectServiceTest),
5489 Layer.provide(layerPlatformTest),
5490);
5491
5492it.layer(layerQueueResumeDisabledInstanceTest)(
5493 "queue.resume on a project-disabled instance",
5494 (it) => {
5495 it.effect(
5496 "holds a queued run instead of starting it on an instance the project turned off",
5497 () =>
5498 Effect.gen(function* () {
5499 const orchestrator = yield* Orchestrator.OrchestratorV2;
5500 const events = yield* EventSink.EventSinkV2;
5501 const threadId = ThreadId.make("queue-resume-disabled-instance-thread");
5502 const queuedModelSelection = {
5503 instanceId: alternateInstanceId,
5504 model: "gpt-5.5",
5505 } satisfies ModelSelection;
5506
5507 yield* orchestrator.dispatch({
5508 type: "thread.create",
5509 createdBy: "user",
5510 creationSource: "web",
5511 commandId: CommandId.make(`${threadId}:create`),
5512 threadId,
5513 projectId: queueResumeDisabledInstanceProjectId,
5514 title: "Queue resume disabled instance",
5515 modelSelection,
5516 runtimeMode: "full-access",
5517 interactionMode: "default",
5518 branch: null,
5519 worktreePath: null,
5520 });
5521 yield* orchestrator.dispatch({
5522 type: "message.dispatch",
5523 createdBy: "user",
5524 creationSource: "web",
5525 commandId: CommandId.make(`${threadId}:message:active`),
5526 threadId,
5527 messageId: MessageId.make(`${threadId}:message:active`),
5528 text: "Active",
5529 attachments: [],
5530 dispatchMode: { type: "defer_start" },
5531 });
5532 yield* orchestrator.dispatch({
5533 type: "message.dispatch",
5534 createdBy: "user",
5535 creationSource: "web",
5536 commandId: CommandId.make(`${threadId}:message:queued`),
5537 threadId,
5538 messageId: MessageId.make(`${threadId}:message:queued`),
5539 text: "Queued",
5540 attachments: [],
5541 modelSelection: queuedModelSelection,
5542 dispatchMode: { type: "queue_after_active" },
5543 });
5544
5545 const before = yield* orchestrator.getThreadProjection(threadId);
5546 const activeRun = before.runs.find((run) => run.status !== "queued")!;
5547 const queuedRun = before.runs.find((run) => run.status === "queued")!;
5548 const now = yield* DateTime.now;
5549 // A raw event ends the active run without going through dispatch,
5550 // so nothing has yet tried to advance the queue onto the disabled
5551 // instance.
5552 yield* events.write({
5553 events: [
5554 {
5555 id: EventId.make(`${threadId}:active-completed`),
5556 type: "run.updated",
5557 threadId,
5558 runId: activeRun.id,
5559 occurredAt: now,
5560 payload: { ...activeRun, status: "completed", completedAt: now },
5561 },
5562 ],
5563 });
5564
5565 yield* orchestrator.dispatch({
5566 type: "queue.resume",
5567 commandId: CommandId.make(`${threadId}:resume`),
5568 threadId,
5569 });
5570
5571 const resumed = yield* orchestrator.getThreadProjection(threadId);
5572 const resumedQueuedRun = resumed.runs.find((run) => run.id === queuedRun.id);
5573 assert.equal(
5574 resumedQueuedRun?.status,
5575 "queued",
5576 "a disabled instance must not start the queued run",
5577 );
5578 assert.isTrue(
5579 resumedQueuedRun?.queueHeld,
5580 "a refused resume re-holds the queue instead of leaving it looking resumable",
5581 );
5582 }),
5583 );
5584 },
5585);
apps/server/src/orchestration-v2/runtimeLayer.ts+1
...@@ -244,6 +244,7 @@ const layerThreadManagementProvided = ThreadManagementService.layerWithLegacyImp...@@ -244,6 +244,7 @@ const layerThreadManagementProvided = ThreadManagementService.layerWithLegacyImp
244 Layer.mergeAll(244 Layer.mergeAll(
245 layerOrchestratorProvided,245 layerOrchestratorProvided,
246 layerLegacyV1ThreadImporterProvided,246 layerLegacyV1ThreadImporterProvided,
247 layerCommandReceiptStoreProvided,
247 layerProviderSessionManagerProvided,248 layerProviderSessionManagerProvided,
248 ),249 ),
249 ),250 ),
apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts+1
...@@ -450,6 +450,7 @@ export function layerWithRegistry<Error>(...@@ -450,6 +450,7 @@ export function layerWithRegistry<Error>(
450 layerProviderSessionManagerProvided,450 layerProviderSessionManagerProvided,
451 layerProviderSwitchServiceProvided,451 layerProviderSwitchServiceProvided,
452 layerRunExecutionServiceProvided,452 layerRunExecutionServiceProvided,
453 layerServerSettings,
453 ThreadForkService.layer,454 ThreadForkService.layer,
454 ),455 ),
455 ),456 ),
apps/server/src/project/ManagedProjectFolders.test.ts+2
...@@ -23,6 +23,7 @@ import * as GitWorkflow from "../git/GitWorkflowService.ts";...@@ -23,6 +23,7 @@ import * as GitWorkflow from "../git/GitWorkflowService.ts";
23import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts";23import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts";
24import * as SqlitePersistence from "../persistence/Sqlite.ts";24import * as SqlitePersistence from "../persistence/Sqlite.ts";
25import * as ProcessRunner from "../processRunner.ts";25import * as ProcessRunner from "../processRunner.ts";
26import * as ServerSettings from "../serverSettings.ts";
26import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";27import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
27import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";28import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
28import * as VcsProcess from "../vcs/VcsProcess.ts";29import * as VcsProcess from "../vcs/VcsProcess.ts";
...@@ -87,6 +88,7 @@ const layer = (baseDir: string, options?: HarnessOptions) =>...@@ -87,6 +88,7 @@ const layer = (baseDir: string, options?: HarnessOptions) =>
87 Layer.provideMerge(layerGitWorkflow(options?.vcsProcess)),88 Layer.provideMerge(layerGitWorkflow(options?.vcsProcess)),
88 Layer.provideMerge(options?.git ?? layerRealGit),89 Layer.provideMerge(options?.git ?? layerRealGit),
89 Layer.provideMerge(SqlitePersistence.layerMemory),90 Layer.provideMerge(SqlitePersistence.layerMemory),
91 Layer.provideMerge(ServerSettings.layerTest()),
90 Layer.provideMerge(ServerConfig.layerTest(baseDir, baseDir)),92 Layer.provideMerge(ServerConfig.layerTest(baseDir, baseDir)),
91 Layer.provideMerge(NodeServices.layer),93 Layer.provideMerge(NodeServices.layer),
92 );94 );
apps/server/src/project/ProjectService.deletion.test.ts+30
...@@ -28,6 +28,7 @@ import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";...@@ -28,6 +28,7 @@ import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
28import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts";28import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts";
29import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts";29import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts";
30import * as SqlitePersistence from "../persistence/Sqlite.ts";30import * as SqlitePersistence from "../persistence/Sqlite.ts";
31import * as ServerSettings from "../serverSettings.ts";
31import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";32import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
32import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts";33import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts";
33import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";34import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
...@@ -63,6 +64,7 @@ const layerServices = Layer.mergeAll(...@@ -63,6 +64,7 @@ const layerServices = Layer.mergeAll(
63 ),64 ),
64 ),65 ),
65 ),66 ),
67 Layer.provideMerge(ServerSettings.layerTest()),
66);68);
67const layerDatabase = SqlitePersistence.layerMemory.pipe(69const layerDatabase = SqlitePersistence.layerMemory.pipe(
68 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "project-deletion-test-" })),70 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...@@ -494,3 +496,31 @@ it.effect("deletes a project without force once its imported threads were delete
494 }).pipe(Effect.provide(layerServices));496 }).pipe(Effect.provide(layerServices));
495 }).pipe(Effect.provide(layerDatabase)),497 }).pipe(Effect.provide(layerDatabase)),
496);498);
499
500it.effect(
501 "clears a deleted project's settings overrides so a machine-disabled instance cannot stay alive",
502 () =>
503 Effect.gen(function* () {
504 const projectId = ProjectId.make("project:overrides-cleanup");
505 const instanceId = ProviderInstanceId.make("codex_work");
506 yield* seedProject(projectId);
507
508 yield* Effect.gen(function* () {
509 const service = yield* ProjectService.make;
510 const serverSettings = yield* ServerSettings.ServerSettingsService;
511 yield* serverSettings.updateSettings({
512 projectSettingsOverrides: {
513 [projectId]: { providerInstances: { [instanceId]: { enabled: true } } },
514 },
515 });
516
517 yield* service.delete({
518 commandId: CommandId.make("command:overrides-cleanup:project-delete"),
519 projectId,
520 });
521
522 const settingsAfterDelete = yield* serverSettings.getSettings;
523 assert.isUndefined(settingsAfterDelete.projectSettingsOverrides[projectId]);
524 }).pipe(Effect.provide(layerServices));
525 }).pipe(Effect.provide(layerDatabase)),
526);
apps/server/src/project/ProjectService.test.ts+3
...@@ -19,6 +19,7 @@ import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";...@@ -19,6 +19,7 @@ import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
19import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts";19import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts";
20import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts";20import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts";
21import * as SqlitePersistence from "../persistence/Sqlite.ts";21import * as SqlitePersistence from "../persistence/Sqlite.ts";
22import * as ServerSettings from "../serverSettings.ts";
22import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";23import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
23import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts";24import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts";
24import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";25import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
...@@ -60,6 +61,7 @@ const layerTestFor = (...@@ -60,6 +61,7 @@ const layerTestFor = (
60 Layer.provideMerge(layerWorkspacePaths),61 Layer.provideMerge(layerWorkspacePaths),
61 Layer.provideMerge(projectMetadataLayer),62 Layer.provideMerge(projectMetadataLayer),
62 Layer.provideMerge(SqlitePersistence.layerMemory),63 Layer.provideMerge(SqlitePersistence.layerMemory),
64 Layer.provideMerge(ServerSettings.layerTest()),
63 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "project-service-test-" })),65 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "project-service-test-" })),
64 Layer.provide(NodeServices.layer),66 Layer.provide(NodeServices.layer),
65 );67 );
...@@ -79,6 +81,7 @@ const layerProjectServiceDependencies = Layer.mergeAll(...@@ -79,6 +81,7 @@ const layerProjectServiceDependencies = Layer.mergeAll(
79 Layer.provideMerge(layerWorkspacePaths),81 Layer.provideMerge(layerWorkspacePaths),
80 Layer.provideMerge(layerMetadata),82 Layer.provideMerge(layerMetadata),
81 Layer.provideMerge(SqlitePersistence.layerMemory),83 Layer.provideMerge(SqlitePersistence.layerMemory),
84 Layer.provideMerge(ServerSettings.layerTest()),
82 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "project-service-race-" })),85 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "project-service-race-" })),
83 Layer.provide(NodeServices.layer),86 Layer.provide(NodeServices.layer),
84);87);
apps/server/src/project/ProjectService.ts+23-1
...@@ -30,6 +30,7 @@ import {...@@ -30,6 +30,7 @@ import {
30import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";30import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
31import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts";31import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts";
32import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts";32import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts";
33import * as ServerSettings from "../serverSettings.ts";
33import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts";34import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts";
34import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";35import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
3536
...@@ -153,6 +154,7 @@ export const make = Effect.gen(function* () {...@@ -153,6 +154,7 @@ export const make = Effect.gen(function* () {
153 const idAllocator = yield* IdAllocator.IdAllocatorV2;154 const idAllocator = yield* IdAllocator.IdAllocatorV2;
154 const legacyImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter;155 const legacyImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter;
155 const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor;156 const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor;
157 const serverSettings = yield* ServerSettings.ServerSettingsService;
156 // Commands for one project run in order. Commands that claim a workspace root158 // Commands for one project run in order. Commands that claim a workspace root
157 // also hold that root, so two projects cannot both claim it.159 // also hold that root, so two projects cannot both claim it.
158 const projectLocks = yield* KeyedLock.make<ProjectId>();160 const projectLocks = yield* KeyedLock.make<ProjectId>();
...@@ -486,6 +488,22 @@ export const make = Effect.gen(function* () {...@@ -486,6 +488,22 @@ export const make = Effect.gen(function* () {
486 );488 );
487 });489 });
488490
491 // A machine-disabled provider instance that a deleted project's override
492 // once turned on must not stay probed and alive forever. Nothing else
493 // removes the entry, so deletion is where it goes; best-effort because a
494 // settings write failure should not block the deletion it is cleaning up
495 // after, and a server crash before a replay never revisits this project.
496 const clearProjectSettingsOverrides = (projectId: ProjectId) =>
497 serverSettings.updateSettings({ projectSettingsOverrides: { [projectId]: null } }).pipe(
498 Effect.asVoid,
499 Effect.catchCause((cause) =>
500 Effect.logWarning(
501 `Failed to clear settings overrides for deleted project ${projectId}.`,
502 cause,
503 ),
504 ),
505 );
506
489 const deleteProject: ProjectService["Service"]["delete"] = Effect.fn("ProjectService.delete")(507 const deleteProject: ProjectService["Service"]["delete"] = Effect.fn("ProjectService.delete")(
490 function* (input) {508 function* (input) {
491 const { projectId } = input;509 const { projectId } = input;
...@@ -496,10 +514,14 @@ export const make = Effect.gen(function* () {...@@ -496,10 +514,14 @@ export const make = Effect.gen(function* () {
496 return yield* new ProjectNotFoundError({ projectId });514 return yield* new ProjectNotFoundError({ projectId });
497 }515 }
498516
499 if (existing.value.deletedAt === null) {517 const isFirstDelete = existing.value.deletedAt === null;
518 if (isFirstDelete) {
500 yield* deleteChildThreads(input);519 yield* deleteChildThreads(input);
501 }520 }
502 yield* commit({ type: "project.delete", commandId: input.commandId, projectId });521 yield* commit({ type: "project.delete", commandId: input.commandId, projectId });
522 if (isFirstDelete) {
523 yield* clearProjectSettingsOverrides(projectId);
524 }
503 yield* projectEnrichment.invalidate([existing.value.workspaceRoot]);525 yield* projectEnrichment.invalidate([existing.value.workspaceRoot]);
504 return yield* readCommitted(projectId);526 return yield* readCommitted(projectId);
505 },527 },
apps/server/src/provider/ProjectSettingsOverrideSweep.test.ts created+46
...@@ -0,0 +1,46 @@
1import { DEFAULT_SERVER_SETTINGS, ProjectId, type ServerSettings } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { findOrphanedProjectIds } from "./ProjectSettingsOverrideSweep.ts";
5
6const liveProjectId = ProjectId.make("project-live");
7const deletedProjectId = ProjectId.make("project-deleted");
8
9function settingsWith(overrides: Partial<ServerSettings>): ServerSettings {
10 return { ...DEFAULT_SERVER_SETTINGS, ...overrides };
11}
12
13describe("findOrphanedProjectIds", () => {
14 it("reports a project override whose project is no longer live", () => {
15 const settings = settingsWith({
16 projectSettingsOverrides: {
17 [liveProjectId]: { providerInstances: {} },
18 [deletedProjectId]: { providerInstances: {} },
19 },
20 });
21
22 const orphaned = findOrphanedProjectIds(settings, new Set([liveProjectId]));
23
24 expect(orphaned).toEqual([deletedProjectId]);
25 });
26
27 it("reports nothing when every override's project is still live", () => {
28 const settings = settingsWith({
29 projectSettingsOverrides: {
30 [liveProjectId]: { providerInstances: {} },
31 },
32 });
33
34 const orphaned = findOrphanedProjectIds(settings, new Set([liveProjectId]));
35
36 expect(orphaned).toEqual([]);
37 });
38
39 it("reports nothing when there are no overrides at all", () => {
40 const settings = settingsWith({ projectSettingsOverrides: {} });
41
42 const orphaned = findOrphanedProjectIds(settings, new Set([liveProjectId]));
43
44 expect(orphaned).toEqual([]);
45 });
46});
apps/server/src/provider/ProjectSettingsOverrideSweep.ts created+61
...@@ -0,0 +1,61 @@
1/**
2 * Removes `projectSettingsOverrides` entries
3 * whose project no longer exists.
4 *
5 * `ProjectService.delete` clears a project's entry as part of deleting the
6 * project, but that only covers deletions a server new enough to run that
7 * code actually processes. A project deleted by an older server, or one
8 * whose cleanup write failed, leaves its override behind:
9 * `ProviderInstanceRegistryHydration` folds every project's override in
10 * unconditionally, so a machine-disabled provider instance the override once
11 * turned on stays probed and alive forever, with no UI left to turn it off.
12 *
13 * This sweep runs once at boot and removes overrides left over from projects
14 * that no longer exist. The settings watcher's normal reconcile picks up
15 * whatever it clears, so the registry does not need its own live-project
16 * check on every settings change.
17 *
18 * @module provider/ProjectSettingsOverrideSweep
19 */
20import { type ProjectId, type ServerSettings } from "@t3tools/contracts";
21import * as Effect from "effect/Effect";
22
23import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
24import * as Settings from "../serverSettings.ts";
25
26/** Pure so the orphan rule can be tested without layers. */
27export const findOrphanedProjectIds = (
28 settings: Pick<ServerSettings, "projectSettingsOverrides">,
29 liveProjectIds: ReadonlySet<ProjectId>,
30): ReadonlyArray<ProjectId> =>
31 Object.keys(settings.projectSettingsOverrides)
32 .map((projectId) => projectId as ProjectId)
33 .filter((projectId) => !liveProjectIds.has(projectId));
34
35/**
36 * Best-effort: a failed read or write here should not block server startup,
37 * and a sweep that does not run to completion is simply retried on the next
38 * boot.
39 */
40export const sweep = Effect.gen(function* () {
41 const serverSettings = yield* Settings.ServerSettingsService;
42 const projectStore = yield* ProjectStore.ProjectStoreV2;
43
44 const settings = yield* serverSettings.getSettings;
45 const liveShells = yield* projectStore.listShells();
46 const liveProjectIds = new Set(liveShells.map((shell) => shell.id));
47
48 const orphanedProjectIds = findOrphanedProjectIds(settings, liveProjectIds);
49 if (orphanedProjectIds.length === 0) return;
50
51 yield* serverSettings.updateSettings({
52 projectSettingsOverrides: Object.fromEntries(
53 orphanedProjectIds.map((projectId) => [projectId, null]),
54 ),
55 });
56}).pipe(
57 Effect.asVoid,
58 Effect.catchCause((cause) =>
59 Effect.logWarning("Failed to sweep orphaned project settings overrides.", cause),
60 ),
61);
apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts created+122
...@@ -0,0 +1,122 @@
1import {
2 DEFAULT_SERVER_SETTINGS,
3 ProjectId,
4 ProviderDriverKind,
5 ProviderInstanceId,
6 type ServerSettings,
7} from "@t3tools/contracts";
8import { describe, expect, it } from "vite-plus/test";
9
10import { deriveProviderInstanceConfigMap } from "./ProviderInstanceRegistryHydration.ts";
11
12const projectId = ProjectId.make("project-a");
13const instanceId = ProviderInstanceId.make("codex_work");
14
15function settingsWith(overrides: Partial<ServerSettings>): ServerSettings {
16 return { ...DEFAULT_SERVER_SETTINGS, ...overrides };
17}
18
19describe("deriveProviderInstanceConfigMap", () => {
20 it("turns a machine-disabled instance on when a project enables it", () => {
21 const settings = settingsWith({
22 providerInstances: {
23 [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false },
24 },
25 projectSettingsOverrides: {
26 [projectId]: { providerInstances: { [instanceId]: { enabled: true } } },
27 },
28 });
29
30 const merged = deriveProviderInstanceConfigMap(settings);
31
32 expect(merged[instanceId]).toEqual({ driver: ProviderDriverKind.make("codex"), enabled: true });
33 });
34
35 it("clears a nested config.enabled:false flag when a project enables the instance", () => {
36 const settings = settingsWith({
37 providerInstances: {
38 [instanceId]: {
39 driver: ProviderDriverKind.make("codex"),
40 config: { enabled: false },
41 },
42 },
43 projectSettingsOverrides: {
44 [projectId]: { providerInstances: { [instanceId]: { enabled: true } } },
45 },
46 });
47
48 const merged = deriveProviderInstanceConfigMap(settings);
49
50 expect(merged[instanceId]).toEqual({
51 driver: ProviderDriverKind.make("codex"),
52 enabled: true,
53 config: { enabled: true },
54 });
55 });
56
57 it("leaves an already machine-enabled instance untouched", () => {
58 const settings = settingsWith({
59 providerInstances: {
60 [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true },
61 },
62 projectSettingsOverrides: {
63 [projectId]: { providerInstances: { [instanceId]: { enabled: true } } },
64 },
65 });
66
67 const merged = deriveProviderInstanceConfigMap(settings);
68
69 expect(merged[instanceId]).toBe(settings.providerInstances[instanceId]);
70 });
71
72 it("does not revive an instance a project override references but settings no longer configure", () => {
73 const settings = settingsWith({
74 providerInstances: {},
75 projectSettingsOverrides: {
76 [projectId]: { providerInstances: { [instanceId]: { enabled: true } } },
77 },
78 });
79
80 const merged = deriveProviderInstanceConfigMap(settings);
81
82 expect(merged[instanceId]).toBeUndefined();
83 });
84
85 it("ignores a project override that disables an instance, leaving the machine's config in charge", () => {
86 const settings = settingsWith({
87 providerInstances: {
88 [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true },
89 },
90 projectSettingsOverrides: {
91 [projectId]: { providerInstances: { [instanceId]: { enabled: false } } },
92 },
93 });
94
95 const merged = deriveProviderInstanceConfigMap(settings);
96
97 expect(merged[instanceId]).toBe(settings.providerInstances[instanceId]);
98 });
99
100 it("folds overrides from multiple projects", () => {
101 const otherInstanceId = ProviderInstanceId.make("claudeAgent_personal");
102 const otherProjectId = ProjectId.make("project-b");
103 const settings = settingsWith({
104 providerInstances: {
105 [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false },
106 [otherInstanceId]: { driver: ProviderDriverKind.make("claudeAgent"), enabled: false },
107 },
108 projectSettingsOverrides: {
109 [projectId]: { providerInstances: { [instanceId]: { enabled: true } } },
110 [otherProjectId]: { providerInstances: { [otherInstanceId]: { enabled: true } } },
111 },
112 });
113
114 const merged = deriveProviderInstanceConfigMap(settings);
115
116 expect(merged[instanceId]).toEqual({ driver: ProviderDriverKind.make("codex"), enabled: true });
117 expect(merged[otherInstanceId]).toEqual({
118 driver: ProviderDriverKind.make("claudeAgent"),
119 enabled: true,
120 });
121 });
122});
apps/server/src/provider/ProviderInstanceRegistryHydration.ts+26
...@@ -25,8 +25,11 @@...@@ -25,8 +25,11 @@
25 */25 */
26import {26import {
27 defaultInstanceIdForDriver,27 defaultInstanceIdForDriver,
28 providerInstanceConfigEnabledFlag,
29 resolveProviderInstanceEnabled,
28 type ProviderInstanceConfig,30 type ProviderInstanceConfig,
29 type ProviderInstanceConfigMap,31 type ProviderInstanceConfigMap,
32 type ProviderInstanceId,
30 ServerSettings,33 ServerSettings,
31} from "@t3tools/contracts";34} from "@t3tools/contracts";
32import * as Effect from "effect/Effect";35import * as Effect from "effect/Effect";
...@@ -73,6 +76,29 @@ export const deriveProviderInstanceConfigMap = (...@@ -73,6 +76,29 @@ export const deriveProviderInstanceConfigMap = (
73 merged[instanceId] = { driver: driver.driverKind };76 merged[instanceId] = { driver: driver.driverKind };
74 }77 }
7578
79 // A project that turns an instance on needs it alive and probed even when
80 // the machine itself has it off, or a thread in that project would route
81 // to an instance nobody ever checked in on. Fold every project's
82 // `providerInstances[id].enabled` overrides in: an instance already enabled
83 // is untouched, and one absent from settings entirely (deleted) stays out.
84 for (const overrides of Object.values(settings.projectSettingsOverrides)) {
85 for (const [rawInstanceId, projectOverride] of Object.entries(
86 overrides?.providerInstances ?? {},
87 )) {
88 if (projectOverride.enabled !== true) continue;
89 const instanceId = rawInstanceId as ProviderInstanceId;
90 const existing = merged[instanceId];
91 if (existing === undefined || resolveProviderInstanceEnabled(existing)) continue;
92 merged[instanceId] = {
93 ...existing,
94 enabled: true,
95 ...(providerInstanceConfigEnabledFlag(existing.config) === false
96 ? { config: { ...(existing.config as Record<string, unknown>), enabled: true } }
97 : {}),
98 };
99 }
100 }
101
76 return merged as ProviderInstanceConfigMap;102 return merged as ProviderInstanceConfigMap;
77};103};
78104
apps/server/src/pullRequest/PullRequestReviewGuideService.ts+1
...@@ -328,6 +328,7 @@ const make = Effect.gen(function* () {...@@ -328,6 +328,7 @@ const make = Effect.gen(function* () {
328 const modelSelection = ServerSettings.resolveReviewGuideModelSelection(328 const modelSelection = ServerSettings.resolveReviewGuideModelSelection(
329 projectSettings,329 projectSettings,
330 providers,330 providers,
331 input.projectId,
331 );332 );
332 // The checkout lives only as long as the model is reading it. The change's hunks are read333 // The checkout lives only as long as the model is reading it. The change's hunks are read
333 // from it too, so the guide can be held to the lines the change actually touched.334 // from it too, so the guide can be held to the lines the change actually touched.
apps/server/src/relay/AgentAwarenessRelay.test.ts+3
...@@ -208,6 +208,9 @@ const makeTestRelay = Effect.fnUntraced(function* (...@@ -208,6 +208,9 @@ const makeTestRelay = Effect.fnUntraced(function* (
208 getProjectThread: unused,208 getProjectThread: unused,
209 listProjectThreads: unused,209 listProjectThreads: unused,
210 sendToThread: unused,210 sendToThread: unused,
211 assertProviderInstanceEnabled: unused,
212 isProviderInstanceEnabledForProject: unused,
213 hasReceipt: unused,
211 waitForThread: unused,214 waitForThread: unused,
212 settleAfterRun: unused,215 settleAfterRun: unused,
213 settleThread: unused,216 settleThread: unused,
apps/server/src/server.ts+5
...@@ -58,6 +58,7 @@ import * as CheckpointStore from "./checkpointing/CheckpointStore.ts";...@@ -58,6 +58,7 @@ import * as CheckpointStore from "./checkpointing/CheckpointStore.ts";
58import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts";58import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts";
59import * as TextGeneration from "./textGeneration/TextGeneration.ts";59import * as TextGeneration from "./textGeneration/TextGeneration.ts";
60import * as ProviderInstanceRegistryHydration from "./provider/ProviderInstanceRegistryHydration.ts";60import * as ProviderInstanceRegistryHydration from "./provider/ProviderInstanceRegistryHydration.ts";
61import * as ProjectSettingsOverrideSweep from "./provider/ProjectSettingsOverrideSweep.ts";
61import * as TerminalManager from "./terminal/Manager.ts";62import * as TerminalManager from "./terminal/Manager.ts";
62import * as McpHttpServer from "./mcp/McpHttpServer.ts";63import * as McpHttpServer from "./mcp/McpHttpServer.ts";
63import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts";64import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts";
...@@ -578,6 +579,10 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(...@@ -578,6 +579,10 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(
578 ProviderUsageLimitsIngestion.layer,579 ProviderUsageLimitsIngestion.layer,
579 layerProviderInstallationRefresh,580 layerProviderInstallationRefresh,
580 ReplayMarkers.layer,581 ReplayMarkers.layer,
582 // Catches overrides left behind by a project deleted before this cleanup
583 // existed, or by a cleanup write that failed. See ProjectService.delete
584 // for the normal (on-delete) path.
585 Layer.effectDiscard(ProjectSettingsOverrideSweep.sweep),
581).pipe(586).pipe(
582 // Core Services587 // Core Services
583 Layer.provideMerge(layerOrchestrationApplication),588 Layer.provideMerge(layerOrchestrationApplication),
apps/server/src/serverSettings.test.ts+2-2
...@@ -873,7 +873,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -873,7 +873,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
873873
874 assert.deepEqual(next.sourceControlWriterModelSelection, sourceControlWriterModelSelection);874 assert.deepEqual(next.sourceControlWriterModelSelection, sourceControlWriterModelSelection);
875 assert.deepEqual(875 assert.deepEqual(
876 ServerSettingsModule.resolveSourceControlWriterModelSelection(next),876 ServerSettingsModule.resolveSourceControlWriterModelSelection(next, undefined, null),
877 next.textGenerationModelSelection,877 next.textGenerationModelSelection,
878 );878 );
879 assert.deepEqual(879 assert.deepEqual(
...@@ -897,7 +897,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -897,7 +897,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
897 },897 },
898 });898 });
899 assert.deepEqual(899 assert.deepEqual(
900 ServerSettingsModule.resolveSourceControlWriterModelSelection(restored),900 ServerSettingsModule.resolveSourceControlWriterModelSelection(restored, undefined, null),
901 sourceControlWriterModelSelection,901 sourceControlWriterModelSelection,
902 );902 );
903 }).pipe(Effect.provide(layerServerSettings())),903 }).pipe(Effect.provide(layerServerSettings())),
apps/server/src/serverSettings.ts+9-34
...@@ -11,9 +11,6 @@...@@ -11,9 +11,6 @@
11 * @module ServerSettings11 * @module ServerSettings
12 */12 */
13import {13import {
14 DEFAULT_TEXT_GENERATION_MODEL,
15 DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER,
16 DEFAULT_MODEL_BY_PROVIDER,
17 DEFAULT_SERVER_SETTINGS,14 DEFAULT_SERVER_SETTINGS,
18 ModelSelection,15 ModelSelection,
19 ProjectId,16 ProjectId,
...@@ -61,6 +58,7 @@ import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJso...@@ -61,6 +58,7 @@ import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJso
61import {58import {
62 applyServerSettingsPatch,59 applyServerSettingsPatch,
63 deriveLegacyProjectOverrides,60 deriveLegacyProjectOverrides,
61 fallbackTextGenerationModelSelection,
64 isModelSelectionProviderEnabled,62 isModelSelectionProviderEnabled,
65} from "@t3tools/shared/serverSettings";63} from "@t3tools/shared/serverSettings";
66import * as ServerSecretStore from "./auth/ServerSecretStore.ts";64import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
...@@ -560,17 +558,6 @@ function migrateLegacyProviderSettings(...@@ -560,17 +558,6 @@ function migrateLegacyProviderSettings(
560558
561const ACP_REGISTRY_DRIVER = ProviderDriverKind.make("acpRegistry");559const ACP_REGISTRY_DRIVER = ProviderDriverKind.make("acpRegistry");
562560
563const TEXT_GENERATION_FALLBACK_DRIVERS = [
564 "codex",
565 "claudeAgent",
566 "cursor",
567 "grok",
568 "muse",
569 "pi",
570 "opencode",
571 "antigravity",
572].map((driver) => ProviderDriverKind.make(driver));
573
574/** ACP Registry instances reject every application text-generation operation. */561/** ACP Registry instances reject every application text-generation operation. */
575function selectionSupportsTextGeneration(562function selectionSupportsTextGeneration(
576 settings: ServerSettings,563 settings: ServerSettings,
...@@ -587,27 +574,15 @@ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings...@@ -587,27 +574,15 @@ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings
587}574}
588575
589function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings {576function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings {
590 // The built-in default instances in preference order. A slot without an577 // A slot without an explicit instance uses its driver's default enabled state.
591 // explicit instance uses its driver's default enabled state.578 const fallback = fallbackTextGenerationModelSelection(
592 const fallback = TEXT_GENERATION_FALLBACK_DRIVERS.find((driver) =>579 settings.providerInstances,
593 resolveProviderInstanceEnabled(580 (instanceId, driver) =>
594 settings.providerInstances[defaultInstanceIdForDriver(driver)] ?? { driver, config: {} },581 resolveProviderInstanceEnabled(
595 ),582 settings.providerInstances[instanceId] ?? { driver, config: {} },
583 ),
596 );584 );
597 if (!fallback) {585 return fallback === null ? settings : { ...settings, textGenerationModelSelection: fallback };
598 return settings;
599 }
600
601 return {
602 ...settings,
603 textGenerationModelSelection: {
604 instanceId: ProviderInstanceId.make(fallback),
605 model:
606 DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ??
607 DEFAULT_MODEL_BY_PROVIDER[fallback] ??
608 DEFAULT_TEXT_GENERATION_MODEL,
609 } satisfies ModelSelection,
610 };
611}586}
612587
613// Values under these keys are compared as a whole — never stripped field-by-field.588// Values under these keys are compared as a whole — never stripped field-by-field.
apps/server/src/ws.ts+8
...@@ -1199,6 +1199,7 @@ const layerWsRpc = (...@@ -1199,6 +1199,7 @@ const layerWsRpc = (
1199 | ThreadLaunchService.ThreadLaunchService1199 | ThreadLaunchService.ThreadLaunchService
1200 | FileSystem.FileSystem1200 | FileSystem.FileSystem
1201 | ServerConfig.ServerConfig1201 | ServerConfig.ServerConfig
1202 | ServerSettings.ServerSettingsService
1202 >();1203 >();
1203 const applicationEvents = yield* OrchestrationEventStore.OrchestrationEventStore;1204 const applicationEvents = yield* OrchestrationEventStore.OrchestrationEventStore;
1204 const projectStore = yield* ProjectStore.ProjectStoreV2;1205 const projectStore = yield* ProjectStore.ProjectStoreV2;
...@@ -2029,6 +2030,13 @@ const layerWsRpc = (...@@ -2029,6 +2030,13 @@ const layerWsRpc = (
2029 message: "Failed to launch thread",2030 message: "Failed to launch thread",
2030 cause,2031 cause,
2031 }),2032 }),
2033 ThreadManagementProviderInstanceDisabledError: (cause) =>
2034 new OrchestrationV2ThreadLaunchError({
2035 commandId: input.commandId,
2036 projectId: input.projectId,
2037 message: cause.message,
2038 cause,
2039 }),
2032 }),2040 }),
2033 ),2041 ),
2034 ),2042 ),
apps/web/src/components/ChatView.tsx+16-3
...@@ -81,6 +81,7 @@ import {...@@ -81,6 +81,7 @@ import {
81 ProviderInteractionMode,81 ProviderInteractionMode,
82 ProviderDriverKind,82 ProviderDriverKind,
83 resolveEnvironmentMachineKind,83 resolveEnvironmentMachineKind,
84 resolveProjectProviderInstanceEnabled,
84 RuntimeMode,85 RuntimeMode,
85 TerminalOpenInput,86 TerminalOpenInput,
86 type WorktreeSetupSnapshot,87 type WorktreeSetupSnapshot,
...@@ -3241,9 +3242,13 @@ export default function ChatView(props: ChatViewProps) {...@@ -3241,9 +3242,13 @@ export default function ChatView(props: ChatViewProps) {
3241 const providerInstanceEntries = useMemo(3242 const providerInstanceEntries = useMemo(
3242 () =>3243 () =>
3243 sortProviderInstanceEntries(3244 sortProviderInstanceEntries(
3244 applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings),3245 applyProviderInstanceSettings(
3246 deriveProviderInstanceEntries(providerStatuses),
3247 settings,
3248 activeProject?.id ?? null,
3249 ),
3245 ),3250 ),
3246 [providerStatuses, settings],3251 [providerStatuses, settings, activeProject?.id],
3247 );3252 );
3248 const { selectedProviderEntry, requestedDriverKind } = useMemo(3253 const { selectedProviderEntry, requestedDriverKind } = useMemo(
3249 () =>3254 () =>
...@@ -4359,7 +4364,14 @@ export default function ChatView(props: ChatViewProps) {...@@ -4359,7 +4364,14 @@ export default function ChatView(props: ChatViewProps) {
4359 (activeProviderInstanceId === null ||4364 (activeProviderInstanceId === null ||
4360 provider.instanceId === activeProviderInstanceId) &&4365 provider.instanceId === activeProviderInstanceId) &&
4361 provider.driver === selectedProvider &&4366 provider.driver === selectedProvider &&
4362 provider.enabled &&4367 // The destination project may override the machine switch.
4368 (environment.serverConfig?.settings
4369 ? resolveProjectProviderInstanceEnabled(
4370 environment.serverConfig.settings,
4371 candidate.projectId,
4372 provider.instanceId,
4373 )
4374 : provider.enabled) &&
4363 provider.installed &&4375 provider.installed &&
4364 provider.status !== "error" &&4376 provider.status !== "error" &&
4365 provider.auth.status !== "unauthenticated" &&4377 provider.auth.status !== "unauthenticated" &&
...@@ -11759,6 +11771,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -11759,6 +11771,7 @@ export default function ChatView(props: ChatViewProps) {
11759 activeProjectDefaultModelSelection11771 activeProjectDefaultModelSelection
11760 }11772 }
11761 activeThreadModelSelection={activeThread?.modelSelection}11773 activeThreadModelSelection={activeThread?.modelSelection}
11774 activeProjectId={activeProject?.id ?? null}
11762 activeContextWindow={activeContextWindow}11775 activeContextWindow={activeContextWindow}
11763 activeTasksProgress={activeComposerTasksProgress}11776 activeTasksProgress={activeComposerTasksProgress}
11764 activeTaskSteps={activeComposerTaskSteps}11777 activeTaskSteps={activeComposerTaskSteps}
apps/web/src/components/CommandPalette.tsx+5-2
...@@ -725,9 +725,12 @@ function OpenCommandPaletteDialog(props: {...@@ -725,9 +725,12 @@ function OpenCommandPaletteDialog(props: {
725 (environment.environmentId === primaryEnvironmentId ? providers : []);725 (environment.environmentId === primaryEnvironmentId ? providers : []);
726 const derived = deriveProviderInstanceEntries(environmentProviders);726 const derived = deriveProviderInstanceEntries(environmentProviders);
727 // Settings fill the ACP registry identity (agent id, icon URL) the727 // Settings fill the ACP registry identity (agent id, icon URL) the
728 // derived entries alone do not carry.728 // derived entries alone do not carry. This map is keyed by environment,
729 // not project, because it backs thread rows across every project on
730 // that environment; `enabled` stays machine-scoped (`null`) since no
731 // single project applies.
729 const entries = serverConfig732 const entries = serverConfig
730 ? applyProviderInstanceSettings(derived, serverConfig.settings)733 ? applyProviderInstanceSettings(derived, serverConfig.settings, null)
731 : derived;734 : derived;
732 for (const entry of entries) {735 for (const entry of entries) {
733 map.set(`${environment.environmentId}:${entry.instanceId}`, entry);736 map.set(`${environment.environmentId}:${entry.instanceId}`, entry);
apps/web/src/components/chat/ChatComposer.tsx+9-2
...@@ -1607,6 +1607,8 @@ export interface ChatComposerProps {...@@ -1607,6 +1607,8 @@ export interface ChatComposerProps {
1607 activeProjectDefaultModelSelection: ModelSelection | null | undefined;1607 activeProjectDefaultModelSelection: ModelSelection | null | undefined;
1608 activeThreadModelSelection: ModelSelection | null | undefined;1608 activeThreadModelSelection: ModelSelection | null | undefined;
1609 reportedModelSelection?: ModelSelection | null;1609 reportedModelSelection?: ModelSelection | null;
1610 /** The active project, so the picker can fold in its provider instance overrides. */
1611 activeProjectId: ProjectId | null;
16101612
1611 // Context window1613 // Context window
1612 activeContextWindow: ContextWindowSnapshot | null;1614 activeContextWindow: ContextWindowSnapshot | null;
...@@ -1756,6 +1758,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1756,6 +1758,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1756 activeProjectDefaultModelSelection,1758 activeProjectDefaultModelSelection,
1757 activeThreadModelSelection,1759 activeThreadModelSelection,
1758 reportedModelSelection,1760 reportedModelSelection,
1761 activeProjectId,
1759 activeContextWindow,1762 activeContextWindow,
1760 compactThreadUnavailable,1763 compactThreadUnavailable,
1761 compactDisabled,1764 compactDisabled,
...@@ -2132,9 +2135,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2132,9 +2135,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2132 const providerInstanceEntries = useMemo<ReadonlyArray<ProviderInstanceEntry>>(2135 const providerInstanceEntries = useMemo<ReadonlyArray<ProviderInstanceEntry>>(
2133 () =>2136 () =>
2134 sortProviderInstanceEntries(2137 sortProviderInstanceEntries(
2135 applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings),2138 applyProviderInstanceSettings(
2139 deriveProviderInstanceEntries(providerStatuses),
2140 settings,
2141 activeProjectId,
2142 ),
2136 ),2143 ),
2137 [providerStatuses, settings],2144 [providerStatuses, settings, activeProjectId],
2138 );2145 );
2139 const selectedProviderByThreadId = composerDraft.activeProvider ?? null;2146 const selectedProviderByThreadId = composerDraft.activeProvider ?? null;
2140 const {2147 const {
apps/web/src/components/settings/ProjectDefaultsSettings.tsx+6-1
...@@ -64,7 +64,11 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting...@@ -64,7 +64,11 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
64 const providers = representative?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS;64 const providers = representative?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS;
65 const selection = resolveDefaultProviderModelSelection(providers, settings.defaultModelSelection);65 const selection = resolveDefaultProviderModelSelection(providers, settings.defaultModelSelection);
66 const entries = sortProviderInstanceEntries(66 const entries = sortProviderInstanceEntries(
67 applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings),67 applyProviderInstanceSettings(
68 deriveProviderInstanceEntries(providers),
69 settings,
70 target?.projectId ?? null,
71 ),
68 );72 );
69 const modelOptions = getCustomModelOptionsByInstance(73 const modelOptions = getCustomModelOptionsByInstance(
70 settings,74 settings,
...@@ -105,6 +109,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting...@@ -105,6 +109,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
105 const entry = applyProviderInstanceSettings(109 const entry = applyProviderInstanceSettings(
106 deriveProviderInstanceEntries(config.providers),110 deriveProviderInstanceEntries(config.providers),
107 candidate.settings,111 candidate.settings,
112 candidate.projectId,
108 ).find((option) => option.instanceId === instanceId);113 ).find((option) => option.instanceId === instanceId);
109 const options = getCustomModelOptionsByInstance(114 const options = getCustomModelOptionsByInstance(
110 mergeEnvironmentSettings(candidate.settings, settings),115 mergeEnvironmentSettings(candidate.settings, settings),
apps/web/src/components/settings/ProviderInstanceCard.tsx+119-5
...@@ -4,10 +4,12 @@ import { Spinner } from "~/components/ui/spinner";...@@ -4,10 +4,12 @@ import { Spinner } from "~/components/ui/spinner";
44
5import {5import {
6 AlertTriangleIcon,6 AlertTriangleIcon,
7 CheckIcon,
7 ArrowUpCircleIcon,8 ArrowUpCircleIcon,
8 CopyIcon,9 CopyIcon,
9 DownloadIcon,10 DownloadIcon,
10 ExternalLinkIcon,11 ExternalLinkIcon,
12 LayersIcon,
11 PlusIcon,13 PlusIcon,
12 Trash2Icon,14 Trash2Icon,
13 XIcon,15 XIcon,
...@@ -55,7 +57,7 @@ import { ProviderModelsSection } from "./ProviderModelsSection";...@@ -55,7 +57,7 @@ import { ProviderModelsSection } from "./ProviderModelsSection";
55import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";57import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
56import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker";58import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker";
57import { RedactedSensitiveText } from "./RedactedSensitiveText";59import { RedactedSensitiveText } from "./RedactedSensitiveText";
58import { SettingsRow, SettingsSection } from "./settingsLayout";60import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout";
59import { AcpSessionManagementSection } from "./AcpSessionManagementSection";61import { AcpSessionManagementSection } from "./AcpSessionManagementSection";
60import { FoldedSettingsSection } from "./FoldedSettingsSection";62import { FoldedSettingsSection } from "./FoldedSettingsSection";
61import { readCodexSetupMode } from "./CodexSetupSection.logic";63import { readCodexSetupMode } from "./CodexSetupSection.logic";
...@@ -472,6 +474,93 @@ export function ProviderEnvironmentSection(props: {...@@ -472,6 +474,93 @@ export function ProviderEnvironmentSection(props: {
472 );474 );
473}475}
474476
477/**
478 * When a project is in scope, the enable control reads and writes that
479 * project's override instead of the environment's `instance.enabled`.
480 * `value` is the project's explicit override (`undefined` means it
481 * inherits the environment); `effectiveEnabled` is what the Switch shows.
482 */
483/**
484 * The project-scope counterpart of a settings row's inheritance indicator:
485 * hover for the summary, open for the project and device values side by side.
486 */
487function ProjectOverrideInheritance({
488 projectEnabled,
489 deviceEnabled,
490}: {
491 readonly projectEnabled: boolean;
492 readonly deviceEnabled: boolean;
493}) {
494 const layers = [
495 { key: "project", label: "Project", enabled: projectEnabled, effective: true },
496 { key: "device", label: "Device", enabled: deviceEnabled, effective: false },
497 ];
498 return (
499 <Popover>
500 <Tooltip>
501 <TooltipTrigger
502 render={
503 <PopoverTrigger
504 render={
505 <Button
506 size="icon-micro"
507 variant="ghost-muted"
508 aria-label="Overridden for this project. Show where this value comes from"
509 />
510 }
511 />
512 }
513 >
514 <LayersIcon className="size-3 text-primary" />
515 </TooltipTrigger>
516 <TooltipPopup side="top">Overridden for this project</TooltipPopup>
517 </Tooltip>
518 <PopoverPopup align="end" width="md" padding="none">
519 <ol role="list" className="px-3 py-2.5 text-sm">
520 {layers.map((layer) => (
521 <li
522 key={layer.key}
523 className={cn(
524 "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 rounded-md px-2 py-1",
525 layer.effective && "bg-foreground/[0.06]",
526 )}
527 >
528 <span
529 className={cn(
530 "min-w-0 truncate",
531 layer.effective ? "font-medium text-foreground" : "text-muted-foreground",
532 )}
533 >
534 {layer.label}
535 </span>
536 <span
537 className={cn(
538 "flex items-center gap-1.5",
539 layer.effective ? "text-foreground" : "text-muted-foreground",
540 )}
541 >
542 {layer.enabled ? "On" : "Off"}
543 {layer.effective ? (
544 <CheckIcon aria-hidden className="size-3.5 shrink-0 text-primary" />
545 ) : (
546 <span aria-hidden className="size-3.5 shrink-0" />
547 )}
548 </span>
549 </li>
550 ))}
551 </ol>
552 </PopoverPopup>
553 </Popover>
554 );
555}
556
557interface ProjectOverrideControl {
558 readonly value: boolean | undefined;
559 readonly effectiveEnabled: boolean;
560 readonly onChange: (next: boolean) => void;
561 readonly onReset: () => void;
562}
563
475interface ProviderInstanceCardProps {564interface ProviderInstanceCardProps {
476 readonly instanceId: ProviderInstanceId;565 readonly instanceId: ProviderInstanceId;
477 readonly instance: ProviderInstanceConfig;566 readonly instance: ProviderInstanceConfig;
...@@ -515,6 +604,8 @@ interface ProviderInstanceCardProps {...@@ -515,6 +604,8 @@ interface ProviderInstanceCardProps {
515 readonly isUpdating?: boolean | undefined;604 readonly isUpdating?: boolean | undefined;
516 readonly onAcceptUrlAuth?: ((action: AcpRegistryUrlAuthAction) => void) | undefined;605 readonly onAcceptUrlAuth?: ((action: AcpRegistryUrlAuthAction) => void) | undefined;
517 readonly environmentId?: EnvironmentId | undefined;606 readonly environmentId?: EnvironmentId | undefined;
607 /** Omit this prop entirely for environment-wide editing. */
608 readonly projectOverride?: ProjectOverrideControl | undefined;
518 readonly acpProjects?:609 readonly acpProjects?:
519 | ReadonlyArray<{610 | ReadonlyArray<{
520 readonly id: ProjectId;611 readonly id: ProjectId;
...@@ -544,6 +635,9 @@ const EMPTY_ACP_PROJECTS: NonNullable<ProviderInstanceCardProps["acpProjects"]>...@@ -544,6 +635,9 @@ const EMPTY_ACP_PROJECTS: NonNullable<ProviderInstanceCardProps["acpProjects"]>
544 * driver-specific `config.enabled` into the envelope on load and both635 * driver-specific `config.enabled` into the envelope on load and both
545 * sides resolve through `resolveProviderInstanceEnabled` (an explicit636 * sides resolve through `resolveProviderInstanceEnabled` (an explicit
546 * false wins, then envelope, then config, then the driver default).637 * false wins, then envelope, then config, then the driver default).
638 * With `projectOverride` set, the Switch instead reads and writes
639 * that project's override, leaving the environment's `instance.enabled`
640 * untouched.
547 */641 */
548export function ProviderInstanceCard({642export function ProviderInstanceCard({
549 instanceId,643 instanceId,
...@@ -573,8 +667,9 @@ export function ProviderInstanceCard({...@@ -573,8 +667,9 @@ export function ProviderInstanceCard({
573 onAcceptUrlAuth,667 onAcceptUrlAuth,
574 environmentId,668 environmentId,
575 acpProjects = EMPTY_ACP_PROJECTS,669 acpProjects = EMPTY_ACP_PROJECTS,
670 projectOverride,
576}: ProviderInstanceCardProps) {671}: ProviderInstanceCardProps) {
577 const enabled = resolveProviderInstanceEnabled(instance);672 const enabled = projectOverride?.effectiveEnabled ?? resolveProviderInstanceEnabled(instance);
578 const compatibility = enabled ? liveProvider?.compatibilityAdvisory : undefined;673 const compatibility = enabled ? liveProvider?.compatibilityAdvisory : undefined;
579 // A locally disabled provider reads "Disabled" with a muted dot even if its674 // A locally disabled provider reads "Disabled" with a muted dot even if its
580 // last server status is stale. Enabled providers use the server status.675 // last server status is stale. Enabled providers use the server status.
...@@ -582,8 +677,9 @@ export function ProviderInstanceCard({...@@ -582,8 +677,9 @@ export function ProviderInstanceCard({
582 ? ((liveProvider?.status as ProviderStatusKey | undefined) ?? "warning")677 ? ((liveProvider?.status as ProviderStatusKey | undefined) ?? "warning")
583 : "disabled";678 : "disabled";
584 const statusStyle = PROVIDER_STATUS_STYLES[statusKey];679 const statusStyle = PROVIDER_STATUS_STYLES[statusKey];
680 // `liveProvider.enabled` is machine-wide; a project override can turn it on.
585 const summary = enabled681 const summary = enabled
586 ? getProviderSummary(liveProvider)682 ? getProviderSummary(liveProvider && { ...liveProvider, enabled: true })
587 : { headline: "Disabled", detail: null };683 : { headline: "Disabled", detail: null };
588 const authEmail = liveProvider?.auth.email?.trim();684 const authEmail = liveProvider?.auth.email?.trim();
589 const isAuthenticated = enabled && liveProvider?.auth.status === "authenticated";685 const isAuthenticated = enabled && liveProvider?.auth.status === "authenticated";
...@@ -670,6 +766,10 @@ export function ProviderInstanceCard({...@@ -670,6 +766,10 @@ export function ProviderInstanceCard({
670 };766 };
671767
672 const updateEnabled = (value: boolean) => {768 const updateEnabled = (value: boolean) => {
769 if (projectOverride) {
770 projectOverride.onChange(value);
771 return;
772 }
673 onUpdate({ ...instance, enabled: value });773 onUpdate({ ...instance, enabled: value });
674 };774 };
675775
...@@ -968,10 +1068,24 @@ export function ProviderInstanceCard({...@@ -968,10 +1068,24 @@ export function ProviderInstanceCard({
968 </span>1068 </span>
969 </span>1069 </span>
970 </div>1070 </div>
971 <span className="flex h-5 shrink-0 items-center">1071 <span className="flex h-5 shrink-0 items-center gap-1">
1072 {projectOverride?.value !== undefined ? (
1073 <span className="flex items-center">
1074 <ProjectOverrideInheritance
1075 projectEnabled={projectOverride.value}
1076 deviceEnabled={resolveProviderInstanceEnabled(instance)}
1077 />
1078 <SettingResetButton
1079 label={`${displayName} enablement`}
1080 tooltip="Reset to the device setting"
1081 disabled={readOnly || !canWriteSettings}
1082 onClick={projectOverride.onReset}
1083 />
1084 </span>
1085 ) : null}
972 <Switch1086 <Switch
973 checked={enabled}1087 checked={enabled}
974 disabled={readOnly}1088 disabled={readOnly || (projectOverride !== undefined && !canWriteSettings)}
975 onCheckedChange={(checked) => updateEnabled(Boolean(checked))}1089 onCheckedChange={(checked) => updateEnabled(Boolean(checked))}
976 aria-label={`Enable ${displayName}`}1090 aria-label={`Enable ${displayName}`}
977 />1091 />
apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx+1
...@@ -140,6 +140,7 @@ vi.mock("../../state/session", () => ({...@@ -140,6 +140,7 @@ vi.mock("../../state/session", () => ({
140140
141vi.mock("../../state/entities", () => ({141vi.mock("../../state/entities", () => ({
142 useProjects: () => [],142 useProjects: () => [],
143 useServerConfigs: () => new Map(),
143}));144}));
144145
145import { EnvironmentProviderSettings } from "./ProviderSettingsPanel";146import { EnvironmentProviderSettings } from "./ProviderSettingsPanel";
apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts+120-1
...@@ -1,8 +1,15 @@...@@ -1,8 +1,15 @@
1import { AuthProvidersManageScope, EnvironmentId } from "@t3tools/contracts";1import {
2 AuthProvidersManageScope,
3 EnvironmentId,
4 ProjectId,
5 ProviderInstanceId,
6 type ServerSettings,
7} from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";8import { describe, expect, it } from "vite-plus/test";
39
4import {10import {
5 buildProviderEnvironmentOptions,11 buildProviderEnvironmentOptions,
12 buildProviderInstanceOverridePatch,
6 classifyProviderEnvironmentAccess,13 classifyProviderEnvironmentAccess,
7 isProviderSettingsEnvironmentAvailable,14 isProviderSettingsEnvironmentAvailable,
8 resolvePrimaryOperateAccess,15 resolvePrimaryOperateAccess,
...@@ -227,6 +234,118 @@ describe("primary operate access", () => {...@@ -227,6 +234,118 @@ describe("primary operate access", () => {
227 });234 });
228});235});
229236
237describe("buildProviderInstanceOverridePatch", () => {
238 const projectId = ProjectId.make("project-a");
239 const instanceId = ProviderInstanceId.make("codex_work");
240 const otherInstanceId = ProviderInstanceId.make("claudeAgent_personal");
241
242 it("writes the same choice to every checkout of the project", () => {
243 const siblingId = ProjectId.make("project-a-worktree");
244 const settings: Pick<ServerSettings, "projectSettingsOverrides"> = {
245 projectSettingsOverrides: {
246 [siblingId]: { newWorktreesStartFromOrigin: true },
247 },
248 };
249
250 expect(
251 buildProviderInstanceOverridePatch(settings, [projectId, siblingId], instanceId, false),
252 ).toEqual({
253 [projectId]: { providerInstances: { [instanceId]: { enabled: false } } },
254 [siblingId]: {
255 newWorktreesStartFromOrigin: true,
256 providerInstances: { [instanceId]: { enabled: false } },
257 },
258 });
259 });
260
261 it("sets an override when the project has none yet", () => {
262 const settings: Pick<ServerSettings, "projectSettingsOverrides"> = {
263 projectSettingsOverrides: {},
264 };
265
266 expect(buildProviderInstanceOverridePatch(settings, [projectId], instanceId, true)).toEqual({
267 [projectId]: { providerInstances: { [instanceId]: { enabled: true } } },
268 });
269 });
270
271 it("adds an instance override alongside the project's other overrides", () => {
272 const settings: Pick<ServerSettings, "projectSettingsOverrides"> = {
273 projectSettingsOverrides: {
274 [projectId]: {
275 newWorktreesStartFromOrigin: true,
276 providerInstances: { [otherInstanceId]: { enabled: false } },
277 },
278 },
279 };
280
281 expect(buildProviderInstanceOverridePatch(settings, [projectId], instanceId, true)).toEqual({
282 [projectId]: {
283 newWorktreesStartFromOrigin: true,
284 providerInstances: {
285 [otherInstanceId]: { enabled: false },
286 [instanceId]: { enabled: true },
287 },
288 },
289 });
290 });
291
292 it("resets the only override by clearing the whole project entry", () => {
293 const settings: Pick<ServerSettings, "projectSettingsOverrides"> = {
294 projectSettingsOverrides: {
295 [projectId]: { providerInstances: { [instanceId]: { enabled: true } } },
296 },
297 };
298
299 expect(
300 buildProviderInstanceOverridePatch(settings, [projectId], instanceId, undefined),
301 ).toEqual({ [projectId]: null });
302 });
303
304 it("resets one instance while another instance's override and other keys survive", () => {
305 const settings: Pick<ServerSettings, "projectSettingsOverrides"> = {
306 projectSettingsOverrides: {
307 [projectId]: {
308 newWorktreesStartFromOrigin: true,
309 providerInstances: {
310 [instanceId]: { enabled: true },
311 [otherInstanceId]: { enabled: false },
312 },
313 },
314 },
315 };
316
317 expect(
318 buildProviderInstanceOverridePatch(settings, [projectId], instanceId, undefined),
319 ).toEqual({
320 [projectId]: {
321 newWorktreesStartFromOrigin: true,
322 providerInstances: { [otherInstanceId]: { enabled: false } },
323 },
324 });
325 });
326
327 it("sends providerInstances: {} explicitly when clearing the last instance override leaves other keys", () => {
328 // The server keeps a stored providerInstances map when an incoming entry
329 // omits the key (so older clients cannot wipe it by editing another
330 // setting). This clear must say so explicitly, or the server would put
331 // the just-cleared override right back.
332 const settings: Pick<ServerSettings, "projectSettingsOverrides"> = {
333 projectSettingsOverrides: {
334 [projectId]: {
335 newWorktreesStartFromOrigin: true,
336 providerInstances: { [instanceId]: { enabled: true } },
337 },
338 },
339 };
340
341 expect(
342 buildProviderInstanceOverridePatch(settings, [projectId], instanceId, undefined),
343 ).toEqual({
344 [projectId]: { newWorktreesStartFromOrigin: true, providerInstances: {} },
345 });
346 });
347});
348
230describe("remote operate access", () => {349describe("remote operate access", () => {
231 it("does not treat the old orchestration grant as provider management", () => {350 it("does not treat the old orchestration grant as provider management", () => {
232 expect(351 expect(
apps/web/src/components/settings/ProviderSettingsPanel.logic.ts+42
...@@ -3,7 +3,11 @@ import {...@@ -3,7 +3,11 @@ import {
3 AuthProvidersManageScope,3 AuthProvidersManageScope,
4 type AuthSessionState,4 type AuthSessionState,
5 type EnvironmentId,5 type EnvironmentId,
6 type ProjectId,
7 type ProjectSettingsOverrides,
8 type ProviderInstanceId,
6 sessionGrantsScope,9 sessionGrantsScope,
10 type ServerSettings,
7 type SessionGrantInput,11 type SessionGrantInput,
8} from "@t3tools/contracts";12} from "@t3tools/contracts";
913
...@@ -105,6 +109,44 @@ export function resolveRemoteOperateAccess(input: {...@@ -105,6 +109,44 @@ export function resolveRemoteOperateAccess(input: {
105 return resolveSessionOperateAccess(input);109 return resolveSessionOperateAccess(input);
106}110}
107111
112/**
113 * The patch for one instance's project override, following the envelope
114 * replacement rule for `projectSettingsOverrides`: the returned entry
115 * replaces the project's whole override set, and `null` removes it.
116 * `value: undefined` resets that single instance back to inheriting the
117 * environment's enablement, leaving any other overridden keys untouched.
118 *
119 * `providerInstances` is always sent explicitly, even as `{}`, because the
120 * server keeps a stored `providerInstances` map when an incoming entry omits
121 * the key (so older clients that don't know the field cannot wipe it by
122 * editing another setting). Omitting it here would undo the very clear this
123 * function means to make.
124 */
125/** Every checkout of a project on one environment shares the same choice. */
126export function buildProviderInstanceOverridePatch(
127 settings: Pick<ServerSettings, "projectSettingsOverrides">,
128 projectIds: readonly ProjectId[],
129 instanceId: ProviderInstanceId,
130 value: boolean | undefined,
131): Record<ProjectId, ProjectSettingsOverrides | null> {
132 const patch: Record<ProjectId, ProjectSettingsOverrides | null> = {};
133 for (const projectId of projectIds) {
134 const current = settings.projectSettingsOverrides[projectId];
135 const instances = { ...current?.providerInstances };
136 const instance = { ...instances[instanceId] };
137 if (value === undefined) delete instance.enabled;
138 else instance.enabled = value;
139 if (Object.keys(instance).length > 0) instances[instanceId] = instance;
140 else delete instances[instanceId];
141
142 const { providerInstances: _omit, ...rest } = current ?? {};
143 const nextEntry: ProjectSettingsOverrides = { ...rest, providerInstances: instances };
144 patch[projectId] =
145 Object.keys(rest).length === 0 && Object.keys(instances).length === 0 ? null : nextEntry;
146 }
147 return patch;
148}
149
108export function classifyProviderEnvironmentAccess(input: {150export function classifyProviderEnvironmentAccess(input: {
109 readonly connectionPhase: EnvironmentConnectionPhase;151 readonly connectionPhase: EnvironmentConnectionPhase;
110 readonly hasServerConfig: boolean;152 readonly hasServerConfig: boolean;
apps/web/src/components/settings/ProviderSettingsPanel.tsx+56-1
...@@ -19,9 +19,11 @@ import {...@@ -19,9 +19,11 @@ import {
19 type AcpRegistryUrlAuthAction,19 type AcpRegistryUrlAuthAction,
20 PROVIDER_DISPLAY_NAMES,20 PROVIDER_DISPLAY_NAMES,
21 ProviderDriverKind,21 ProviderDriverKind,
22 type ProjectId,
22 type ProviderInstanceConfig,23 type ProviderInstanceConfig,
23 type ProviderInstanceId,24 type ProviderInstanceId,
24 resolveEnvironmentMachineKind,25 resolveEnvironmentMachineKind,
26 resolveProjectProviderInstanceEnabled,
25 resolveProviderInstanceEnabled,27 resolveProviderInstanceEnabled,
26} from "@t3tools/contracts";28} from "@t3tools/contracts";
27import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";29import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
...@@ -54,7 +56,7 @@ import {...@@ -54,7 +56,7 @@ import {
54} from "../../state/environments";56} from "../../state/environments";
55import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server";57import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server";
56import { useEnvironmentSessionState } from "../../state/session";58import { useEnvironmentSessionState } from "../../state/session";
57import { useProjects } from "../../state/entities";59import { useProjects, useServerConfigs } from "../../state/entities";
58import { useAtomCommand } from "../../state/use-atom-command";60import { useAtomCommand } from "../../state/use-atom-command";
59import { getRelativeTimeState } from "../../timestampFormat";61import { getRelativeTimeState } from "../../timestampFormat";
60import {62import {
...@@ -116,6 +118,7 @@ import {...@@ -116,6 +118,7 @@ import {
116} from "./settingsLayout";118} from "./settingsLayout";
117import {119import {
118 buildProviderEnvironmentOptions,120 buildProviderEnvironmentOptions,
121 buildProviderInstanceOverridePatch,
119 classifyProviderEnvironmentAccess,122 classifyProviderEnvironmentAccess,
120 isProviderSettingsEnvironmentAvailable,123 isProviderSettingsEnvironmentAvailable,
121 type ProviderEnvironmentAccess,124 type ProviderEnvironmentAccess,
...@@ -279,6 +282,11 @@ interface ProviderSettingsTarget {...@@ -279,6 +282,11 @@ interface ProviderSettingsTarget {
279 readonly instanceId?: ProviderInstanceId;282 readonly instanceId?: ProviderInstanceId;
280 readonly scoped?: boolean;283 readonly scoped?: boolean;
281 readonly environmentIds?: readonly EnvironmentId[];284 readonly environmentIds?: readonly EnvironmentId[];
285 /**
286 * The project's checkouts on the selected environment, if a project is in
287 * scope. The enable toggle writes each one's override so they never diverge.
288 */
289 readonly projectIds?: readonly ProjectId[];
282}290}
283291
284export function ProviderSettingsPanel(target: ProviderSettingsTarget) {292export function ProviderSettingsPanel(target: ProviderSettingsTarget) {
...@@ -450,6 +458,7 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {...@@ -450,6 +458,7 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {
450 ? target.instanceId458 ? target.instanceId
451 : undefined459 : undefined
452 }460 }
461 projectIds={target.projectIds ?? []}
453 />462 />
454 ) : null}463 ) : null}
455 </>464 </>
...@@ -460,16 +469,19 @@ function SelectedEnvironmentProviderSettings({...@@ -460,16 +469,19 @@ function SelectedEnvironmentProviderSettings({
460 environment,469 environment,
461 deviceTabs,470 deviceTabs,
462 targetInstanceId,471 targetInstanceId,
472 projectIds,
463}: {473}: {
464 readonly environment: EnvironmentPresentation;474 readonly environment: EnvironmentPresentation;
465 readonly deviceTabs?: ReactNode;475 readonly deviceTabs?: ReactNode;
466 readonly targetInstanceId?: ProviderInstanceId | undefined;476 readonly targetInstanceId?: ProviderInstanceId | undefined;
477 readonly projectIds: readonly ProjectId[];
467}) {478}) {
468 return (479 return (
469 <RemoteSessionGatedProviderSettings480 <RemoteSessionGatedProviderSettings
470 environment={environment}481 environment={environment}
471 deviceTabs={deviceTabs}482 deviceTabs={deviceTabs}
472 targetInstanceId={targetInstanceId}483 targetInstanceId={targetInstanceId}
484 projectIds={projectIds}
473 />485 />
474 );486 );
475}487}
...@@ -478,10 +490,12 @@ function RemoteSessionGatedProviderSettings({...@@ -478,10 +490,12 @@ function RemoteSessionGatedProviderSettings({
478 environment,490 environment,
479 deviceTabs,491 deviceTabs,
480 targetInstanceId,492 targetInstanceId,
493 projectIds,
481}: {494}: {
482 readonly environment: EnvironmentPresentation;495 readonly environment: EnvironmentPresentation;
483 readonly deviceTabs?: ReactNode;496 readonly deviceTabs?: ReactNode;
484 readonly targetInstanceId?: ProviderInstanceId | undefined;497 readonly targetInstanceId?: ProviderInstanceId | undefined;
498 readonly projectIds: readonly ProjectId[];
485}) {499}) {
486 const sessionState = useEnvironmentSessionState(environment.environmentId);500 const sessionState = useEnvironmentSessionState(environment.environmentId);
487 const operateAccess = resolveRemoteOperateAccess({501 const operateAccess = resolveRemoteOperateAccess({
...@@ -495,6 +509,7 @@ function RemoteSessionGatedProviderSettings({...@@ -495,6 +509,7 @@ function RemoteSessionGatedProviderSettings({
495 operateAccess={operateAccess}509 operateAccess={operateAccess}
496 deviceTabs={deviceTabs}510 deviceTabs={deviceTabs}
497 targetInstanceId={targetInstanceId}511 targetInstanceId={targetInstanceId}
512 projectIds={projectIds}
498 />513 />
499 );514 );
500}515}
...@@ -504,11 +519,13 @@ function AccessGatedProviderSettings({...@@ -504,11 +519,13 @@ function AccessGatedProviderSettings({
504 operateAccess,519 operateAccess,
505 deviceTabs,520 deviceTabs,
506 targetInstanceId,521 targetInstanceId,
522 projectIds,
507}: {523}: {
508 readonly environment: EnvironmentPresentation;524 readonly environment: EnvironmentPresentation;
509 readonly operateAccess: ProviderOperateAccess;525 readonly operateAccess: ProviderOperateAccess;
510 readonly deviceTabs?: ReactNode;526 readonly deviceTabs?: ReactNode;
511 readonly targetInstanceId?: ProviderInstanceId | undefined;527 readonly targetInstanceId?: ProviderInstanceId | undefined;
528 readonly projectIds: readonly ProjectId[];
512}) {529}) {
513 const access = classifyProviderEnvironmentAccess({530 const access = classifyProviderEnvironmentAccess({
514 connectionPhase: environment.connection.phase,531 connectionPhase: environment.connection.phase,
...@@ -531,6 +548,7 @@ function AccessGatedProviderSettings({...@@ -531,6 +548,7 @@ function AccessGatedProviderSettings({
531 readOnly={access.kind === "read-only"}548 readOnly={access.kind === "read-only"}
532 deviceTabs={deviceTabs}549 deviceTabs={deviceTabs}
533 targetInstanceId={targetInstanceId}550 targetInstanceId={targetInstanceId}
551 projectIds={projectIds}
534 />552 />
535 );553 );
536}554}
...@@ -541,11 +559,14 @@ export function EnvironmentProviderSettings({...@@ -541,11 +559,14 @@ export function EnvironmentProviderSettings({
541 readOnly = false,559 readOnly = false,
542 deviceTabs,560 deviceTabs,
543 targetInstanceId,561 targetInstanceId,
562 projectIds = [],
544}: {563}: {
545 readonly environmentId: EnvironmentId;564 readonly environmentId: EnvironmentId;
546 readonly environmentLabel: string;565 readonly environmentLabel: string;
547 readonly deviceTabs?: ReactNode;566 readonly deviceTabs?: ReactNode;
548 readonly targetInstanceId?: ProviderInstanceId | undefined;567 readonly targetInstanceId?: ProviderInstanceId | undefined;
568 /** The project's checkouts on this environment, if a project is in scope. */
569 readonly projectIds?: readonly ProjectId[];
549 /**570 /**
550 * Grey out and freeze every write control when this session's credential571 * Grey out and freeze every write control when this session's credential
551 * lacks `providers:manage` on the environment. Selecting providers572 * lacks `providers:manage` on the environment. Selecting providers
...@@ -564,6 +585,12 @@ export function EnvironmentProviderSettings({...@@ -564,6 +585,12 @@ export function EnvironmentProviderSettings({
564 const serverProviders =585 const serverProviders =
565 useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS;586 useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS;
566 const projects = useProjects().filter((project) => project.environmentId === environmentId);587 const projects = useProjects().filter((project) => project.environmentId === environmentId);
588 // Older servers decode an unknown providerInstances key away, so sending
589 // a project override patch would silently do nothing. Hide the switch
590 // instead of offering a control that can never take effect.
591 const supportsProjectProviderOverrides =
592 useServerConfigs().get(environmentId)?.environment.capabilities
593 .projectProviderInstanceOverrides === true;
567 const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, {594 const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, {
568 reportFailure: false,595 reportFailure: false,
569 });596 });
...@@ -901,12 +928,40 @@ export function EnvironmentProviderSettings({...@@ -901,12 +928,40 @@ export function EnvironmentProviderSettings({
901 favorite.provider === row.instanceId ? Result.succeed(favorite.model) : Result.failVoid,928 favorite.provider === row.instanceId ? Result.succeed(favorite.model) : Result.failVoid,
902 );929 );
903 const resetLabel = driverOption?.label ?? String(row.driver);930 const resetLabel = driverOption?.label ?? String(row.driver);
931 const representativeProjectId = projectIds[0];
932 const writeProjectOverride = (next: boolean | undefined) => {
933 updateSettings({
934 projectSettingsOverrides: buildProviderInstanceOverridePatch(
935 settings,
936 projectIds,
937 row.instanceId,
938 next,
939 ),
940 });
941 };
942 const projectOverride =
943 representativeProjectId === undefined || !supportsProjectProviderOverrides
944 ? undefined
945 : {
946 value:
947 settings.projectSettingsOverrides[representativeProjectId]?.providerInstances?.[
948 row.instanceId
949 ]?.enabled,
950 effectiveEnabled: resolveProjectProviderInstanceEnabled(
951 settings,
952 representativeProjectId,
953 row.instanceId,
954 ),
955 onChange: (next: boolean) => writeProjectOverride(next),
956 onReset: () => writeProjectOverride(undefined),
957 };
904958
905 return (959 return (
906 <ProviderInstanceCard960 <ProviderInstanceCard
907 key={row.instanceId}961 key={row.instanceId}
908 environmentId={environmentId}962 environmentId={environmentId}
909 acpProjects={projects}963 acpProjects={projects}
964 projectOverride={projectOverride}
910 onAcceptUrlAuth={965 onAcceptUrlAuth={
911 readOnly ? undefined : (action) => acceptUrlAuthentication(row.instanceId, action)966 readOnly ? undefined : (action) => acceptUrlAuthentication(row.instanceId, action)
912 }967 }
apps/web/src/components/settings/ScheduledTasksSettings.tsx+19-10
...@@ -804,13 +804,6 @@ function ScheduledTaskEditorDialog({...@@ -804,13 +804,6 @@ function ScheduledTaskEditorDialog({
804 const upsertTask = useAtomCommand(serverEnvironment.upsertScheduledTask, {804 const upsertTask = useAtomCommand(serverEnvironment.upsertScheduledTask, {
805 label: "scheduled task upsert",805 label: "scheduled task upsert",
806 });806 });
807 const instanceEntries = useMemo(
808 () =>
809 sortProviderInstanceEntries(
810 applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings),
811 ),
812 [providers, settings],
813 );
814 const [draft, setDraft] = useState<DraftState>(() =>807 const [draft, setDraft] = useState<DraftState>(() =>
815 task ? taskToDraft(task) : { ...EMPTY_DRAFT, projectId: projects[0]?.id ?? "" },808 task ? taskToDraft(task) : { ...EMPTY_DRAFT, projectId: projects[0]?.id ?? "" },
816 );809 );
...@@ -827,13 +820,29 @@ function ScheduledTaskEditorDialog({...@@ -827,13 +820,29 @@ function ScheduledTaskEditorDialog({
827 : task;820 : task;
828 const selectedProjectId = draft.projectId || projects[0]?.id || "";821 const selectedProjectId = draft.projectId || projects[0]?.id || "";
829 const selectedProject = projects.find((project) => project.id === selectedProjectId);822 const selectedProject = projects.find((project) => project.id === selectedProjectId);
823 // The task runs in selectedProject, so its model picker should reflect that
824 // project's effective enablement, not just the machine's.
825 const instanceEntries = useMemo(
826 () =>
827 sortProviderInstanceEntries(
828 applyProviderInstanceSettings(
829 deriveProviderInstanceEntries(providers),
830 settings,
831 selectedProject?.id ?? null,
832 ),
833 ),
834 [providers, settings, selectedProject?.id],
835 );
830836
831 // The real model picker is keyed by a `${instanceId}:${model}` string, which837 // The real model picker is keyed by a `${instanceId}:${model}` string, which
832 // is exactly how the draft stores its selection.838 // is exactly how the draft stores its selection.
833 const firstInstance = instanceEntries[0];839 const firstInstance = instanceEntries[0];
834 const activeSelection = draft.modelKey840 const draftSelection = draft.modelKey ? splitModelKey(draft.modelKey) : null;
835 ? splitModelKey(draft.modelKey)841 const activeSelection =
836 : scheduledTaskDefaultModel(settings, selectedProject ?? null, instanceEntries);842 draftSelection &&
843 instanceEntries.some((entry) => entry.instanceId === draftSelection.instanceId && entry.enabled)
844 ? draftSelection
845 : scheduledTaskDefaultModel(settings, selectedProject ?? null, instanceEntries);
837 const activeInstanceId =846 const activeInstanceId =
838 activeSelection?.instanceId ?? firstInstance?.instanceId ?? ("" as ProviderInstanceId);847 activeSelection?.instanceId ?? firstInstance?.instanceId ?? ("" as ProviderInstanceId);
839 const activeModel = activeSelection?.model ?? "";848 const activeModel = activeSelection?.model ?? "";
apps/web/src/components/settings/SettingsPanels.tsx+6-2
...@@ -2183,7 +2183,7 @@ export function GeneralSettingsPanel() {...@@ -2183,7 +2183,7 @@ export function GeneralSettingsPanel() {
2183 const settings = useScopedSettings();2183 const settings = useScopedSettings();
2184 const updateSettings = useUpdateScopedSettings();2184 const updateSettings = useUpdateScopedSettings();
2185 const navigate = useNavigate();2185 const navigate = useNavigate();
2186 const { scope, environment, connectedEnvironments } = useSettingsScope();2186 const { scope, environment, connectedEnvironments, target } = useSettingsScope();
2187 // The representative environment supplies the provider list for pickers;2187 // The representative environment supplies the provider list for pickers;
2188 // a fanned-out model choice is validated against every target before it2188 // a fanned-out model choice is validated against every target before it
2189 // is written. Per-machine tuning (background activity overrides) still2189 // is written. Per-machine tuning (background activity overrides) still
...@@ -2219,7 +2219,11 @@ export function GeneralSettingsPanel() {...@@ -2219,7 +2219,11 @@ export function GeneralSettingsPanel() {
2219 const textGenModel = textGenerationModelSelection.model;2219 const textGenModel = textGenerationModelSelection.model;
2220 const textGenModelOptions = textGenerationModelSelection.options;2220 const textGenModelOptions = textGenerationModelSelection.options;
2221 const textGenerationModelInstanceEntries = sortProviderInstanceEntries(2221 const textGenerationModelInstanceEntries = sortProviderInstanceEntries(
2222 applyProviderInstanceSettings(deriveProviderInstanceEntries(textGenerationProviders), settings),2222 applyProviderInstanceSettings(
2223 deriveProviderInstanceEntries(textGenerationProviders),
2224 settings,
2225 target?.projectId ?? null,
2226 ),
2223 );2227 );
2224 const hasTextGenerationProvider = textGenerationModelInstanceEntries.some(2228 const hasTextGenerationProvider = textGenerationModelInstanceEntries.some(
2225 (entry) => entry.enabled && entry.isAvailable,2229 (entry) => entry.enabled && entry.isAvailable,
apps/web/src/components/settings/SourceControlWritingSettings.tsx+26-8
...@@ -66,7 +66,7 @@ export function SourceControlWritingSettingsSection() {...@@ -66,7 +66,7 @@ export function SourceControlWritingSettingsSection() {
66 const updateSettings = useUpdateScopedSettings();66 const updateSettings = useUpdateScopedSettings();
67 const mixedReviewGuidesEnabled = useScopedSettingsMixed(["reviewGuidesEnabled"]);67 const mixedReviewGuidesEnabled = useScopedSettingsMixed(["reviewGuidesEnabled"]);
68 const navigate = useNavigate();68 const navigate = useNavigate();
69 const { environment, connectedEnvironments, targets } = useSettingsScope();69 const { environment, connectedEnvironments, targets, target } = useSettingsScope();
70 // The representative supplies the provider list; a model choice is checked70 // The representative supplies the provider list; a model choice is checked
71 // against every target before it fans out.71 // against every target before it fans out.
72 const environmentId = environment?.environmentId ?? null;72 const environmentId = environment?.environmentId ?? null;
...@@ -106,20 +106,37 @@ export function SourceControlWritingSettingsSection() {...@@ -106,20 +106,37 @@ export function SourceControlWritingSettingsSection() {
106 const textGenerationProviders = serverProviders.filter(106 const textGenerationProviders = serverProviders.filter(
107 (provider) => provider.supportsTextGeneration !== false,107 (provider) => provider.supportsTextGeneration !== false,
108 );108 );
109 const defaultModelSelection = resolveAppModelSelectionState(settings, textGenerationProviders);109 const instanceEntries = sortProviderInstanceEntries(
110 applyProviderInstanceSettings(
111 deriveProviderInstanceEntries(textGenerationProviders),
112 settings,
113 target?.projectId ?? null,
114 ),
115 );
116 // The environment default can name an instance this project turned off.
117 const projectEnabledInstanceIds = new Set(
118 instanceEntries
119 .filter((entry) => entry.enabled && entry.isAvailable)
120 .map((entry) => entry.instanceId),
121 );
122 const projectEffectiveTextGenerationProviders = textGenerationProviders.filter((provider) =>
123 projectEnabledInstanceIds.has(provider.instanceId),
124 );
125 const defaultModelSelection = resolveAppModelSelectionState(
126 settings,
127 projectEffectiveTextGenerationProviders,
128 );
110 const usesDedicatedModel = settings.sourceControlWriterModelSelection !== null;129 const usesDedicatedModel = settings.sourceControlWriterModelSelection !== null;
111 const activeSelection = resolveAppModelSelectionState(130 const activeSelection = resolveAppModelSelectionState(
112 {131 {
113 ...settings,132 ...settings,
114 textGenerationModelSelection: resolveSourceControlWriterModelSelection(133 textGenerationModelSelection: resolveSourceControlWriterModelSelection(
115 settings,134 settings,
116 textGenerationProviders,135 projectEffectiveTextGenerationProviders,
136 target?.projectId ?? null,
117 ),137 ),
118 },138 },
119 textGenerationProviders,139 projectEffectiveTextGenerationProviders,
120 );
121 const instanceEntries = sortProviderInstanceEntries(
122 applyProviderInstanceSettings(deriveProviderInstanceEntries(textGenerationProviders), settings),
123 );140 );
124 const canEnableDedicatedModel = instanceEntries.some(141 const canEnableDedicatedModel = instanceEntries.some(
125 (entry) =>142 (entry) =>
...@@ -127,7 +144,7 @@ export function SourceControlWritingSettingsSection() {...@@ -127,7 +144,7 @@ export function SourceControlWritingSettingsSection() {
127 );144 );
128 const modelOptionsByInstance = getCustomModelOptionsByInstance(145 const modelOptionsByInstance = getCustomModelOptionsByInstance(
129 settings,146 settings,
130 textGenerationProviders,147 projectEffectiveTextGenerationProviders,
131 activeSelection.instanceId,148 activeSelection.instanceId,
132 activeSelection.model,149 activeSelection.model,
133 );150 );
...@@ -139,6 +156,7 @@ export function SourceControlWritingSettingsSection() {...@@ -139,6 +156,7 @@ export function SourceControlWritingSettingsSection() {
139 textGenerationModelSelection: resolveReviewGuideModelSelection(156 textGenerationModelSelection: resolveReviewGuideModelSelection(
140 settings,157 settings,
141 textGenerationProviders,158 textGenerationProviders,
159 target?.projectId ?? null,
142 ),160 ),
143 },161 },
144 textGenerationProviders,162 textGenerationProviders,
apps/web/src/components/settings/useScopedModelAvailability.ts+1
...@@ -35,6 +35,7 @@ export function useScopedModelDisabledReason(...@@ -35,6 +35,7 @@ export function useScopedModelDisabledReason(
35 const entry = applyProviderInstanceSettings(35 const entry = applyProviderInstanceSettings(
36 deriveProviderInstanceEntries(config.providers),36 deriveProviderInstanceEntries(config.providers),
37 candidate.settings,37 candidate.settings,
38 candidate.projectId,
38 ).find((option) => option.instanceId === instanceId);39 ).find((option) => option.instanceId === instanceId);
39 const options = getCustomModelOptionsByInstance(40 const options = getCustomModelOptionsByInstance(
40 mergeEnvironmentSettings(candidate.settings, settings),41 mergeEnvironmentSettings(candidate.settings, settings),
apps/web/src/providerInstances.test.ts+83-35
...@@ -136,14 +136,19 @@ describe("applyProviderInstanceSettings", () => {...@@ -136,14 +136,19 @@ describe("applyProviderInstanceSettings", () => {
136 const entries = deriveProviderInstanceEntries([136 const entries = deriveProviderInstanceEntries([
137 provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" }),137 provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" }),
138 ]);138 ]);
139 const [entry] = applyProviderInstanceSettings(entries, {139 const [entry] = applyProviderInstanceSettings(
140 providerInstances: {140 entries,
141 [ProviderInstanceId.make("codex")]: {141 {
142 driver: ProviderDriverKind.make("codex"),142 providerInstances: {
143 enabled: false,143 [ProviderInstanceId.make("codex")]: {
144 driver: ProviderDriverKind.make("codex"),
145 enabled: false,
146 },
144 },147 },
148 projectSettingsOverrides: {},
145 },149 },
146 });150 null,
151 );
147152
148 expect(entry?.enabled).toBe(false);153 expect(entry?.enabled).toBe(false);
149 });154 });
...@@ -155,9 +160,14 @@ describe("applyProviderInstanceSettings", () => {...@@ -155,9 +160,14 @@ describe("applyProviderInstanceSettings", () => {
155 instanceId: "claude_work",160 instanceId: "claude_work",
156 }),161 }),
157 ]);162 ]);
158 const [entry] = applyProviderInstanceSettings(entries, {163 const [entry] = applyProviderInstanceSettings(
159 providerInstances: {},164 entries,
160 });165 {
166 providerInstances: {},
167 projectSettingsOverrides: {},
168 },
169 null,
170 );
161171
162 expect(entry?.enabled).toBe(false);172 expect(entry?.enabled).toBe(false);
163 });173 });
...@@ -171,9 +181,14 @@ describe("applyProviderInstanceSettings", () => {...@@ -171,9 +181,14 @@ describe("applyProviderInstanceSettings", () => {
171 instanceId,181 instanceId,
172 }),182 }),
173 ]);183 ]);
174 const [entry] = applyProviderInstanceSettings(entries, {184 const [entry] = applyProviderInstanceSettings(
175 providerInstances: {},185 entries,
176 });186 {
187 providerInstances: {},
188 projectSettingsOverrides: {},
189 },
190 null,
191 );
177192
178 expect(entry?.enabled).toBe(false);193 expect(entry?.enabled).toBe(false);
179 },194 },
...@@ -187,14 +202,19 @@ describe("applyProviderInstanceSettings", () => {...@@ -187,14 +202,19 @@ describe("applyProviderInstanceSettings", () => {
187 instanceId,202 instanceId,
188 }),203 }),
189 ]);204 ]);
190 const [entry] = applyProviderInstanceSettings(entries, {205 const [entry] = applyProviderInstanceSettings(
191 providerInstances: {206 entries,
192 [instanceId]: {207 {
193 driver: ProviderDriverKind.make("claudeAgent"),208 providerInstances: {
194 enabled: false,209 [instanceId]: {
210 driver: ProviderDriverKind.make("claudeAgent"),
211 enabled: false,
212 },
195 },213 },
214 projectSettingsOverrides: {},
196 },215 },
197 });216 null,
217 );
198218
199 expect(entry?.enabled).toBe(false);219 expect(entry?.enabled).toBe(false);
200 });220 });
...@@ -207,9 +227,14 @@ describe("applyProviderInstanceSettings", () => {...@@ -207,9 +227,14 @@ describe("applyProviderInstanceSettings", () => {
207 instanceId: "constructor",227 instanceId: "constructor",
208 }),228 }),
209 ]);229 ]);
210 const [entry] = applyProviderInstanceSettings(entries, {230 const [entry] = applyProviderInstanceSettings(
211 providerInstances: {},231 entries,
212 });232 {
233 providerInstances: {},
234 projectSettingsOverrides: {},
235 },
236 null,
237 );
213238
214 expect(entry?.isDefault).toBe(true);239 expect(entry?.isDefault).toBe(true);
215 expect(entry?.enabled).toBe(false);240 expect(entry?.enabled).toBe(false);
...@@ -220,7 +245,14 @@ describe("applyProviderInstanceSettings", () => {...@@ -220,7 +245,14 @@ describe("applyProviderInstanceSettings", () => {
220 provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex", enabled: false }),245 provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex", enabled: false }),
221 provider({ provider: ProviderDriverKind.make("grok"), instanceId: "grok", enabled: true }),246 provider({ provider: ProviderDriverKind.make("grok"), instanceId: "grok", enabled: true }),
222 ]);247 ]);
223 const [codex, grok] = applyProviderInstanceSettings(entries, { providerInstances: {} });248 const [codex, grok] = applyProviderInstanceSettings(
249 entries,
250 {
251 providerInstances: {},
252 projectSettingsOverrides: {},
253 },
254 null,
255 );
224256
225 // Settings decide over a stale probe: Codex starts on, Grok starts off.257 // Settings decide over a stale probe: Codex starts on, Grok starts off.
226 expect(codex?.enabled).toBe(true);258 expect(codex?.enabled).toBe(true);
...@@ -260,6 +292,7 @@ describe("deriveProviderEntriesByEnvironment", () => {...@@ -260,6 +292,7 @@ describe("deriveProviderEntriesByEnvironment", () => {
260 config: { agentId, registryIconUrl: `https://example.com/${agentId}.svg` },292 config: { agentId, registryIconUrl: `https://example.com/${agentId}.svg` },
261 },293 },
262 },294 },
295 projectSettingsOverrides: {},
263 },296 },
264 ] as const,297 ] as const,
265 ),298 ),
...@@ -618,29 +651,44 @@ describe("provider icon metadata", () => {...@@ -618,29 +651,44 @@ describe("provider icon metadata", () => {
618 { ...provider({ provider: driver, instanceId }), iconUrl },651 { ...provider({ provider: driver, instanceId }), iconUrl },
619 ]);652 ]);
620 const registryConfig = { agentId: "swe-agent", registryIconUrl: iconUrl };653 const registryConfig = { agentId: "swe-agent", registryIconUrl: iconUrl };
621 const brandedEntries = applyProviderInstanceSettings(snapshots, {654 const brandedEntries = applyProviderInstanceSettings(
622 providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } },655 snapshots,
623 });656 {
657 providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } },
658 projectSettingsOverrides: {},
659 },
660 null,
661 );
624 expect(brandedEntries[0]?.acpRegistryAgentId).toBe("swe-agent");662 expect(brandedEntries[0]?.acpRegistryAgentId).toBe("swe-agent");
625 expect(brandedEntries[0]?.acpRegistryIconUrl).toBe(iconUrl);663 expect(brandedEntries[0]?.acpRegistryIconUrl).toBe(iconUrl);
626664
627 const [localEntry] = applyProviderInstanceSettings(brandedEntries, {665 const [localEntry] = applyProviderInstanceSettings(
628 providerInstances: {666 brandedEntries,
629 [instanceId]: {667 {
630 driver,668 providerInstances: {
631 enabled: false,669 [instanceId]: {
632 config: { ...registryConfig, source: "local", commandPath: "dsh" },670 driver,
671 enabled: false,
672 config: { ...registryConfig, source: "local", commandPath: "dsh" },
673 },
633 },674 },
675 projectSettingsOverrides: {},
634 },676 },
635 });677 null,
678 );
636 expect(localEntry?.acpRegistryAgentId).toBeUndefined();679 expect(localEntry?.acpRegistryAgentId).toBeUndefined();
637 expect(localEntry?.acpRegistryIconUrl).toBeUndefined();680 expect(localEntry?.acpRegistryIconUrl).toBeUndefined();
638 expect(localEntry?.enabled).toBe(false);681 expect(localEntry?.enabled).toBe(false);
639 expect(localEntry?.snapshot).toBe(snapshots[0]?.snapshot);682 expect(localEntry?.snapshot).toBe(snapshots[0]?.snapshot);
640683
641 const [restoredEntry] = applyProviderInstanceSettings(localEntry ? [localEntry] : [], {684 const [restoredEntry] = applyProviderInstanceSettings(
642 providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } },685 localEntry ? [localEntry] : [],
643 });686 {
687 providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } },
688 projectSettingsOverrides: {},
689 },
690 null,
691 );
644 expect(restoredEntry?.acpRegistryAgentId).toBe("swe-agent");692 expect(restoredEntry?.acpRegistryAgentId).toBe("swe-agent");
645 expect(restoredEntry?.acpRegistryIconUrl).toBe(iconUrl);693 expect(restoredEntry?.acpRegistryIconUrl).toBe(iconUrl);
646 expect(restoredEntry?.enabled).toBe(true);694 expect(restoredEntry?.enabled).toBe(true);
apps/web/src/providerInstances.ts+21-10
...@@ -15,9 +15,9 @@...@@ -15,9 +15,9 @@
15import {15import {
16 DEFAULT_MODEL_BY_PROVIDER,16 DEFAULT_MODEL_BY_PROVIDER,
17 defaultInstanceIdForDriver,17 defaultInstanceIdForDriver,
18 isUnconfiguredDefaultInstanceEnabled,18 resolveProjectProviderInstanceEnabled,
19 resolveProviderInstanceEnabled,
20 type ModelSelection,19 type ModelSelection,
20 type ProjectId,
21 type ProviderDriverKind,21 type ProviderDriverKind,
22 ProviderInstanceId,22 ProviderInstanceId,
23 type ServerProvider,23 type ServerProvider,
...@@ -154,13 +154,19 @@ export function deriveProviderInstanceEntries(...@@ -154,13 +154,19 @@ export function deriveProviderInstanceEntries(
154 */154 */
155export function deriveProviderEntriesByEnvironment(155export function deriveProviderEntriesByEnvironment(
156 providersByEnvironment: Iterable<156 providersByEnvironment: Iterable<
157 readonly [string, ReadonlyArray<ServerProvider>, Pick<ServerSettings, "providerInstances">?]157 readonly [
158 string,
159 ReadonlyArray<ServerProvider>,
160 Pick<ServerSettings, "providerInstances" | "projectSettingsOverrides">?,
161 ]
158 >,162 >,
159): ReadonlyMap<string, ReadonlyMap<string, ProviderInstanceEntry>> {163): ReadonlyMap<string, ReadonlyMap<string, ProviderInstanceEntry>> {
160 const byEnvironment = new Map<string, ReadonlyMap<string, ProviderInstanceEntry>>();164 const byEnvironment = new Map<string, ReadonlyMap<string, ProviderInstanceEntry>>();
161 for (const [environmentId, providers, settings] of providersByEnvironment) {165 for (const [environmentId, providers, settings] of providersByEnvironment) {
162 const derived = deriveProviderInstanceEntries(providers);166 const derived = deriveProviderInstanceEntries(providers);
163 const entries = settings ? applyProviderInstanceSettings(derived, settings) : derived;167 // One map per environment serves every project with a thread there, so
168 // there is no single project to scope `enabled` to; it stays machine-wide.
169 const entries = settings ? applyProviderInstanceSettings(derived, settings, null) : derived;
164 byEnvironment.set(170 byEnvironment.set(
165 environmentId,171 environmentId,
166 new Map(entries.map((entry) => [entry.instanceId as string, entry] as const)),172 new Map(entries.map((entry) => [entry.instanceId as string, entry] as const)),
...@@ -179,20 +185,25 @@ export function deriveProviderEntriesByEnvironment(...@@ -179,20 +185,25 @@ export function deriveProviderEntriesByEnvironment(
179 * streamed state, since the server runs it with default config. Any other185 * streamed state, since the server runs it with default config. Any other
180 * instance missing from `providerInstances` is stale (for example186 * instance missing from `providerInstances` is stale (for example
181 * immediately after deletion) and is treated as disabled.187 * immediately after deletion) and is treated as disabled.
188 *
189 * `projectId` folds in that project's `providerInstances[id].enabled` override
190 * (enable or disable in either direction over the machine value); `null`
191 * resolves the machine value only. It has no default: a caller that renders
192 * for a single project but forgets to pass it would silently show the
193 * machine's enablement instead of that project's, so passing `null` for a
194 * surface that isn't project-aware must be a deliberate choice, not an
195 * omission.
182 */196 */
183export function applyProviderInstanceSettings(197export function applyProviderInstanceSettings(
184 entries: ReadonlyArray<ProviderInstanceEntry>,198 entries: ReadonlyArray<ProviderInstanceEntry>,
185 settings: Pick<ServerSettings, "providerInstances">,199 settings: Pick<ServerSettings, "providerInstances" | "projectSettingsOverrides">,
200 projectId: ProjectId | null,
186): ReadonlyArray<ProviderInstanceEntry> {201): ReadonlyArray<ProviderInstanceEntry> {
187 return entries.map((entry) => {202 return entries.map((entry) => {
188 const explicitInstance = Object.hasOwn(settings.providerInstances, entry.instanceId)203 const explicitInstance = Object.hasOwn(settings.providerInstances, entry.instanceId)
189 ? settings.providerInstances[entry.instanceId]204 ? settings.providerInstances[entry.instanceId]
190 : undefined;205 : undefined;
191 const enabled = explicitInstance206 const enabled = resolveProjectProviderInstanceEnabled(settings, projectId, entry.instanceId);
192 ? resolveProviderInstanceEnabled(explicitInstance)
193 : entry.isDefault
194 ? isUnconfiguredDefaultInstanceEnabled(entry.instanceId)
195 : false;
196 if (entry.driverKind !== "acpRegistry" || explicitInstance === undefined) {207 if (entry.driverKind !== "acpRegistry" || explicitInstance === undefined) {
197 return enabled === entry.enabled ? entry : { ...entry, enabled };208 return enabled === entry.enabled ? entry : { ...entry, enabled };
198 }209 }
apps/web/src/routes/settings.providers.tsx+7
...@@ -25,6 +25,13 @@ function SettingsProvidersRoute() {...@@ -25,6 +25,13 @@ function SettingsProvidersRoute() {
25 <ProviderSettingsPanel25 <ProviderSettingsPanel
26 environmentId={environment.environmentId}26 environmentId={environment.environmentId}
27 {...(target.instanceId ? { instanceId: target.instanceId } : {})}27 {...(target.instanceId ? { instanceId: target.instanceId } : {})}
28 projectIds={
29 scope.kind === "project" || scope.kind === "checkout"
30 ? scope.members
31 .filter((member) => member.environmentId === environment.environmentId)
32 .map((member) => member.id)
33 : []
34 }
28 scoped35 scoped
29 />36 />
30 );37 );
docs/user/project-settings.md+8
...@@ -27,6 +27,14 @@ again....@@ -27,6 +27,14 @@ again.
27Providers and diagnostics are per machine: they show one environment at a time, the primary27Providers and diagnostics are per machine: they show one environment at a time, the primary
28one until you pick another. Every other setting fans out to the selection.28one until you pick another. Every other setting fans out to the selection.
2929
30With a project selected, a provider's switch in **Settings → Providers** turns it on or off for
31that project only, in either direction: a provider off on the machine can be used in one project,
32and one on everywhere can be kept out of another. That project's model pickers follow the switch,
33and no new turn there can start on a provider it turned off, including queued messages and
34follow-ups from delegated tasks. A turn already running when the switch flips finishes. A delegated
35task's result still lands in its parent thread, which sees it on its next turn. Reset returns the project to the
36machine's setting. Every other provider setting stays per machine.
37
30On mobile, open **Settings** and use the filter in its header to choose connected environments38On mobile, open **Settings** and use the filter in its header to choose connected environments
31and a project. The filter stays available in server-setting pages. With **All projects** selected,39and a project. The filter stays available in server-setting pages. With **All projects** selected,
32the **Server settings** categories and auto-settle controls in **Thread behavior** edit the40the **Server settings** categories and auto-settle controls in **Thread behavior** edit the
packages/contracts/src/environment.ts+5
...@@ -135,6 +135,11 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({...@@ -135,6 +135,11 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
135 threadRestartContinuation: Schema.optionalKey(Schema.Boolean),135 threadRestartContinuation: Schema.optionalKey(Schema.Boolean),
136 /** Server resolves `projectSettingsOverrides`; older servers ignore the key. */136 /** Server resolves `projectSettingsOverrides`; older servers ignore the key. */
137 projectSettingsOverrides: Schema.optionalKey(Schema.Boolean),137 projectSettingsOverrides: Schema.optionalKey(Schema.Boolean),
138 /** Server resolves a project's `providerInstances` override entry. Older
139 servers decode the key away (unknown fields are stripped, not
140 rejected), so the per-project provider switch would silently do
141 nothing; clients hide it instead of sending a patch that goes nowhere. */
142 projectProviderInstanceOverrides: Schema.optionalKey(Schema.Boolean),
138 /** Server understands thread.snooze / thread.unsnooze commands. Same143 /** Server understands thread.snooze / thread.unsnooze commands. Same
139 version-skew contract as threadSettlement. */144 version-skew contract as threadSettlement. */
140 threadSnooze: Schema.optionalKey(Schema.Boolean),145 threadSnooze: Schema.optionalKey(Schema.Boolean),
packages/contracts/src/settings.test.ts+73
...@@ -9,6 +9,7 @@ import {...@@ -9,6 +9,7 @@ import {
9 ClaudeSettings,9 ClaudeSettings,
10 DEFAULT_SERVER_SETTINGS,10 DEFAULT_SERVER_SETTINGS,
11 requiredScopesForProjectMutation,11 requiredScopesForProjectMutation,
12 resolveProjectProviderInstanceEnabled,
12 resolveProviderInstanceEnabled,13 resolveProviderInstanceEnabled,
13 ServerSettings,14 ServerSettings,
14 ServerSettingsPatch,15 ServerSettingsPatch,
...@@ -1063,6 +1064,78 @@ describe("ServerSettings.removeAgentCreditsOnMerge", () => {...@@ -1063,6 +1064,78 @@ describe("ServerSettings.removeAgentCreditsOnMerge", () => {
1063 });1064 });
1064});1065});
10651066
1067describe("resolveProjectProviderInstanceEnabled", () => {
1068 const projectId = "project-a" as ProjectId;
1069 const instanceId = ProviderInstanceId.make("codex_work");
1070
1071 it("inherits the machine value when the project has no override", () => {
1072 const settings: ServerSettings = {
1073 ...DEFAULT_SERVER_SETTINGS,
1074 providerInstances: {
1075 [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false },
1076 },
1077 };
1078 expect(resolveProjectProviderInstanceEnabled(settings, projectId, instanceId)).toBe(false);
1079 });
1080
1081 it("lets a project enable an instance the machine disabled", () => {
1082 const settings: ServerSettings = {
1083 ...DEFAULT_SERVER_SETTINGS,
1084 providerInstances: {
1085 [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false },
1086 },
1087 projectSettingsOverrides: {
1088 [projectId]: { providerInstances: { [instanceId]: { enabled: true } } },
1089 },
1090 };
1091 expect(resolveProjectProviderInstanceEnabled(settings, projectId, instanceId)).toBe(true);
1092 });
1093
1094 it("lets a project disable an instance the machine enabled", () => {
1095 const settings: ServerSettings = {
1096 ...DEFAULT_SERVER_SETTINGS,
1097 providerInstances: {
1098 [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true },
1099 },
1100 projectSettingsOverrides: {
1101 [projectId]: { providerInstances: { [instanceId]: { enabled: false } } },
1102 },
1103 };
1104 expect(resolveProjectProviderInstanceEnabled(settings, projectId, instanceId)).toBe(false);
1105 });
1106
1107 it("always resolves to the machine value when no project is in scope", () => {
1108 const settings: ServerSettings = {
1109 ...DEFAULT_SERVER_SETTINGS,
1110 providerInstances: {
1111 [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true },
1112 },
1113 projectSettingsOverrides: {
1114 [projectId]: { providerInstances: { [instanceId]: { enabled: false } } },
1115 },
1116 };
1117 expect(resolveProjectProviderInstanceEnabled(settings, null, instanceId)).toBe(true);
1118 });
1119
1120 it("falls back to the unconfigured default when the instance has no settings entry", () => {
1121 const settings: ServerSettings = { ...DEFAULT_SERVER_SETTINGS, providerInstances: {} };
1122 // `codex_work` has no entry in `providerInstances`, so with no project
1123 // override either, this is the same unconfigured-default fallback a
1124 // machine-wide (no project) lookup would use.
1125 expect(resolveProjectProviderInstanceEnabled(settings, projectId, instanceId)).toBe(
1126 resolveProjectProviderInstanceEnabled(settings, null, instanceId),
1127 );
1128
1129 const overridden = {
1130 ...settings,
1131 projectSettingsOverrides: {
1132 [projectId]: { providerInstances: { [instanceId]: { enabled: true } } },
1133 },
1134 };
1135 expect(resolveProjectProviderInstanceEnabled(overridden, projectId, instanceId)).toBe(true);
1136 });
1137});
1138
1066describe("requiredScopesForProjectMutation", () => {1139describe("requiredScopesForProjectMutation", () => {
1067 it("adds the settings scope only when a mutation carries scripts", () => {1140 it("adds the settings scope only when a mutation carries scripts", () => {
1068 const projectId = "project-1" as ProjectId;1141 const projectId = "project-1" as ProjectId;
packages/contracts/src/settings.ts+52-5
...@@ -972,10 +972,24 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [...@@ -972,10 +972,24 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [
972] as const;972] as const;
973export type ProjectScopedServerSettingKey = (typeof PROJECT_SCOPED_SERVER_SETTING_KEYS)[number];973export type ProjectScopedServerSettingKey = (typeof PROJECT_SCOPED_SERVER_SETTING_KEYS)[number];
974974
975export const ProjectProviderInstanceOverride = Schema.Struct({
976 enabled: Schema.optionalKey(Schema.Boolean),
977});
978export type ProjectProviderInstanceOverride = typeof ProjectProviderInstanceOverride.Type;
979
975/**980/**
976 * One project's overrides. An absent key inherits the environment value;981 * One project's overrides. An absent key inherits the environment value;
977 * `null` is a real value where the environment type is nullable (no default982 * `null` is a real value where the environment type is nullable (no default
978 * model, no dedicated writer model, never auto-settle).983 * model, no dedicated writer model, never auto-settle).
984 *
985 * `providerInstances` sits outside `PROJECT_SCOPED_SERVER_SETTING_KEYS`:
986 * every other key replaces its `ServerSettings` field whole, while this one
987 * keeps the settings.json shape but overrides field by field, per
988 * `ProviderInstanceId`. Only `enabled` is overridable today. An instance id
989 * or field absent here inherits the machine value, and a present `enabled`
990 * wins in either direction, so a project can turn on an instance the machine
991 * disabled, or turn off one the machine enabled. See
992 * `resolveProjectProviderInstanceEnabled` for the combined resolution.
979 */993 */
980export const ProjectSettingsOverrides = Schema.Struct({994export const ProjectSettingsOverrides = Schema.Struct({
981 worktreeCleanup: Schema.optionalKey(WorktreeCleanup),995 worktreeCleanup: Schema.optionalKey(WorktreeCleanup),
...@@ -1002,7 +1016,10 @@ export const ProjectSettingsOverrides = Schema.Struct({...@@ -1002,7 +1016,10 @@ export const ProjectSettingsOverrides = Schema.Struct({
1002 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),1016 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
1003 continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean),1017 continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean),
1004 responseStreamingMode: Schema.optionalKey(ResponseStreamingMode),1018 responseStreamingMode: Schema.optionalKey(ResponseStreamingMode),
1005} satisfies Record<ProjectScopedServerSettingKey, unknown>);1019 providerInstances: Schema.optionalKey(
1020 Schema.Record(ProviderInstanceId, ProjectProviderInstanceOverride),
1021 ),
1022} satisfies Record<ProjectScopedServerSettingKey | "providerInstances", unknown>);
1006export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type;1023export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type;
10071024
1008/**1025/**
...@@ -1126,10 +1143,11 @@ export const ServerSettings = Schema.Struct({...@@ -1126,10 +1143,11 @@ export const ServerSettings = Schema.Struct({
1126 Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE)),1143 Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE)),
1127 ),1144 ),
1128 /**1145 /**
1129 * Per-project overrides of the keys in `PROJECT_SCOPED_SERVER_SETTING_KEYS`.1146 * Per-project overrides of the keys in `PROJECT_SCOPED_SERVER_SETTING_KEYS`,
1130 * The source of truth for project settings; `projectAgentBrowserAccessOverrides`,1147 * plus `providerInstances`. The source of truth for project
1131 * `projectAutoPullOverrides` and `projectScriptOverrides` are derived views1148 * settings; `projectAgentBrowserAccessOverrides`, `projectAutoPullOverrides`
1132 * kept for one release so older clients keep reading them.1149 * and `projectScriptOverrides` are derived views kept for one release so
1150 * older clients keep reading them.
1133 */1151 */
1134 projectSettingsOverrides: Schema.Record(ProjectId, ProjectSettingsOverrides).pipe(1152 projectSettingsOverrides: Schema.Record(ProjectId, ProjectSettingsOverrides).pipe(
1135 Schema.withDecodingDefault(Effect.succeed({})),1153 Schema.withDecodingDefault(Effect.succeed({})),
...@@ -1371,6 +1389,30 @@ export const resolveProviderInstanceEnabled = (...@@ -1371,6 +1389,30 @@ export const resolveProviderInstanceEnabled = (
1371 return instance.enabled ?? configEnabled ?? defaultEnabledForDriver(instance.driver);1389 return instance.enabled ?? configEnabled ?? defaultEnabledForDriver(instance.driver);
1372};1390};
13731391
1392/**
1393 * Effective enablement for a provider instance within a project: the
1394 * project's `providerInstances[id].enabled` override when set, otherwise the
1395 * machine-wide `resolveProviderInstanceEnabled` value. `projectId` of `null`
1396 * (no project in scope) always resolves to the machine-wide value.
1397 */
1398export const resolveProjectProviderInstanceEnabled = (
1399 settings: Pick<ServerSettings, "providerInstances" | "projectSettingsOverrides">,
1400 projectId: ProjectId | null,
1401 instanceId: ProviderInstanceId,
1402): boolean => {
1403 const override =
1404 projectId === null
1405 ? undefined
1406 : settings.projectSettingsOverrides[projectId]?.providerInstances?.[instanceId]?.enabled;
1407 if (override !== undefined) return override;
1408 const instanceConfig = Object.hasOwn(settings.providerInstances, instanceId)
1409 ? settings.providerInstances[instanceId]
1410 : undefined;
1411 return instanceConfig !== undefined
1412 ? resolveProviderInstanceEnabled(instanceConfig)
1413 : isUnconfiguredDefaultInstanceEnabled(instanceId);
1414};
1415
1374export const ServerSettingsOperation = Schema.Literals([1416export const ServerSettingsOperation = Schema.Literals([
1375 "normalize",1417 "normalize",
1376 "check-exists",1418 "check-exists",
...@@ -1498,6 +1540,11 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -1498,6 +1540,11 @@ export const ServerSettingsPatch = Schema.Struct({
1498 * the entry without that key. Per-key null cannot express "clear" for the1540 * the entry without that key. Per-key null cannot express "clear" for the
1499 * keys whose value type is itself nullable, and clients always hold the1541 * keys whose value type is itself nullable, and clients always hold the
1500 * current entry from the last settings snapshot.1542 * current entry from the last settings snapshot.
1543 *
1544 * `providerInstances` is the one exception: an entry that omits the key
1545 * keeps the stored value instead of clearing it, so a client built before
1546 * that field existed cannot wipe it by editing another key. Clearing every
1547 * provider override for a project means sending `providerInstances: {}`.
1501 */1548 */
1502 projectSettingsOverrides: Schema.optionalKey(1549 projectSettingsOverrides: Schema.optionalKey(
1503 Schema.Record(ProjectId, Schema.NullOr(ProjectSettingsOverrides)),1550 Schema.Record(ProjectId, Schema.NullOr(ProjectSettingsOverrides)),
packages/contracts/src/t3ProjectFile.ts+4-1
...@@ -126,7 +126,10 @@ export const PROJECT_FILE_BACKED_SETTINGS = {...@@ -126,7 +126,10 @@ export const PROJECT_FILE_BACKED_SETTINGS = {
126 defaultThreadEnvMode: { field: "defaultThreadEnvMode", builtIn: "local" },126 defaultThreadEnvMode: { field: "defaultThreadEnvMode", builtIn: "local" },
127 worktreeSubmodules: { field: "worktreeSubmodules", builtIn: "recursive" },127 worktreeSubmodules: { field: "worktreeSubmodules", builtIn: "recursive" },
128} as const satisfies {128} as const satisfies {
129 readonly [K in ProjectScopedServerSettingKey]?: {129 // `providerInstances` is scoped per project but overrides field by field
130 // rather than replacing a whole `ServerSettings` value (see its doc comment
131 // in settings.ts), so it cannot be file-backed and is excluded here.
132 readonly [K in ProjectScopedServerSettingKey & keyof ServerSettings]?: {
130 readonly field: {133 readonly field: {
131 readonly [F in keyof T3ProjectFile]: T3ProjectFile[F] extends134 readonly [F in keyof T3ProjectFile]: T3ProjectFile[F] extends
132 | Exclude<ServerSettings[K], null>135 | Exclude<ServerSettings[K], null>
packages/shared/src/projectSettings.test.ts+38
...@@ -147,6 +147,44 @@ describe("resolveProjectSettings", () => {...@@ -147,6 +147,44 @@ describe("resolveProjectSettings", () => {
147 expect(resolved.settings.defaultModelSelection).toBeNull();147 expect(resolved.settings.defaultModelSelection).toBeNull();
148 expect(resolved.sources.defaultModelSelection).toBe("environment");148 expect(resolved.sources.defaultModelSelection).toBe("environment");
149 });149 });
150
151 it("moves text generation off an instance the project turned off", () => {
152 const codex = ProviderInstanceId.make("codex");
153 const settings = {
154 ...DEFAULT_SERVER_SETTINGS,
155 textGenerationModelSelection: createModelSelection(codex, "gpt-5"),
156 projectSettingsOverrides: {
157 [projectId]: { providerInstances: { [codex]: { enabled: false } } },
158 },
159 };
160 expect(
161 resolveProjectSettings(settings, projectId).settings.textGenerationModelSelection.instanceId,
162 ).toBe(ProviderInstanceId.make("claudeAgent"));
163 expect(
164 resolveProjectSettings(settings, otherProjectId).settings.textGenerationModelSelection,
165 ).toEqual(settings.textGenerationModelSelection);
166 });
167
168 it("falls back to a configured instance when every built-in one is off for the project", () => {
169 const codex = ProviderInstanceId.make("codex");
170 const codexWork = ProviderInstanceId.make("codex_work");
171 const settings = {
172 ...DEFAULT_SERVER_SETTINGS,
173 textGenerationModelSelection: createModelSelection(codex, "gpt-5"),
174 providerInstances: { [codexWork]: { driver: ProviderDriverKind.make("codex"), config: {} } },
175 projectSettingsOverrides: {
176 [projectId]: {
177 providerInstances: {
178 [codex]: { enabled: false },
179 [ProviderInstanceId.make("claudeAgent")]: { enabled: false },
180 },
181 },
182 },
183 };
184 expect(
185 resolveProjectSettings(settings, projectId).settings.textGenerationModelSelection.instanceId,
186 ).toBe(codexWork);
187 });
150});188});
151189
152describe("resolveProjectSettings with a t3.json", () => {190describe("resolveProjectSettings with a t3.json", () => {
packages/shared/src/projectSettings.ts+20-3
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 type ProjectId,6 type ProjectId,
7 type ProjectScopedServerSettingKey,7 type ProjectScopedServerSettingKey,
8 type ProjectSettingsOverrides,8 type ProjectSettingsOverrides,
9 resolveProjectProviderInstanceEnabled,
9 type ResolvedServerSettings,10 type ResolvedServerSettings,
10 type ServerSettings,11 type ServerSettings,
11 type T3ProjectFile,12 type T3ProjectFile,
...@@ -13,7 +14,7 @@ import {...@@ -13,7 +14,7 @@ import {
13 type WorktreeCleanupRules,14 type WorktreeCleanupRules,
14} from "@t3tools/contracts";15} from "@t3tools/contracts";
15import { normalizeProjectPathForComparison } from "./path.ts";16import { normalizeProjectPathForComparison } from "./path.ts";
16import { isModelSelectionProviderEnabled } from "./serverSettings.ts";17import { fallbackTextGenerationModelSelection } from "./serverSettings.ts";
1718
18/**19/**
19 * Where a project-scoped value came from. The order is the priority order:20 * Where a project-scoped value came from. The order is the priority order:
...@@ -174,18 +175,34 @@ function resolveProjectOverrides(...@@ -174,18 +175,34 @@ function resolveProjectOverrides(
174 // undefined; that is not an override.175 // undefined; that is not an override.
175 if (value === undefined) continue;176 if (value === undefined) continue;
176 // A model on a disabled provider falls back to the environment, like the177 // A model on a disabled provider falls back to the environment, like the
177 // environment-level guards do for these keys.178 // environment-level guards do for these keys. An instance this project
179 // enabled via `providerInstances[id].enabled` still counts as enabled here.
178 if (180 if (
179 (key === "textGenerationModelSelection" || key === "defaultModelSelection") &&181 (key === "textGenerationModelSelection" || key === "defaultModelSelection") &&
180 value !== undefined &&182 value !== undefined &&
181 value !== null &&183 value !== null &&
182 !isModelSelectionProviderEnabled(settings, value as ModelSelection)184 !resolveProjectProviderInstanceEnabled(
185 settings,
186 projectId,
187 (value as ModelSelection).instanceId,
188 )
183 ) {189 ) {
184 continue;190 continue;
185 }191 }
186 effective[key] = value;192 effective[key] = value;
187 sources[key] = "project";193 sources[key] = "project";
188 }194 }
195 // Like the environment guard, text generation never runs on an instance
196 // this project turned off.
197 const textGeneration = effective.textGenerationModelSelection as ModelSelection;
198 if (!resolveProjectProviderInstanceEnabled(settings, projectId, textGeneration.instanceId)) {
199 const fallback = fallbackTextGenerationModelSelection(
200 settings.providerInstances,
201 (instanceId) => resolveProjectProviderInstanceEnabled(settings, projectId, instanceId),
202 { includeConfiguredInstances: true },
203 );
204 if (fallback !== null) effective.textGenerationModelSelection = fallback;
205 }
189 return { settings: effective as ServerSettings, sources, overrides };206 return { settings: effective as ServerSettings, sources, overrides };
190}207}
191208
packages/shared/src/serverSettings.test.ts+160-3
...@@ -13,6 +13,7 @@ import { createModelSelection } from "./model.ts";...@@ -13,6 +13,7 @@ import { createModelSelection } from "./model.ts";
13import { resolveProjectScripts, projectScriptsInheritDefaults } from "./projectScripts.ts";13import { resolveProjectScripts, projectScriptsInheritDefaults } from "./projectScripts.ts";
14import {14import {
15 applyServerSettingsPatch,15 applyServerSettingsPatch,
16 fallbackTextGenerationModelSelection,
16 modelPreferencesMigrationPatch,17 modelPreferencesMigrationPatch,
17 resolveModelPreferences,18 resolveModelPreferences,
18 isModelSelectionProviderEnabled,19 isModelSelectionProviderEnabled,
...@@ -25,6 +26,32 @@ import {...@@ -25,6 +26,32 @@ import {
25/** Settings after the server has folded legacy per-project fields into `projectSettingsOverrides`. */26/** Settings after the server has folded legacy per-project fields into `projectSettingsOverrides`. */
26const FOLDED_SERVER_SETTINGS = { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true };27const FOLDED_SERVER_SETTINGS = { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true };
2728
29describe("fallbackTextGenerationModelSelection", () => {
30 it("ignores a configured non-default instance at machine scope, matching no fallback", () => {
31 const configured = ProviderInstanceId.make("codex_work");
32 const providerInstances = {
33 [configured]: { driver: ProviderDriverKind.make("codex"), config: {} },
34 };
35 const fallback = fallbackTextGenerationModelSelection(providerInstances, (instanceId) =>
36 instanceId === configured ? true : false,
37 );
38 expect(fallback).toBeNull();
39 });
40
41 it("considers a configured non-default instance only when asked to", () => {
42 const configured = ProviderInstanceId.make("codex_work");
43 const providerInstances = {
44 [configured]: { driver: ProviderDriverKind.make("codex"), config: {} },
45 };
46 const fallback = fallbackTextGenerationModelSelection(
47 providerInstances,
48 (instanceId) => instanceId === configured,
49 { includeConfiguredInstances: true },
50 );
51 expect(fallback?.instanceId).toBe(configured);
52 });
53});
54
28describe("serverSettings helpers", () => {55describe("serverSettings helpers", () => {
29 it("changes a cleanup rule without replacing the machine's other rules", () => {56 it("changes a cleanup rule without replacing the machine's other rules", () => {
30 const enabled = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {57 const enabled = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
...@@ -442,7 +469,7 @@ describe("serverSettings helpers", () => {...@@ -442,7 +469,7 @@ describe("serverSettings helpers", () => {
442 expect(isModelSelectionProviderEnabled(settings, sourceControlWriterModelSelection)).toBe(469 expect(isModelSelectionProviderEnabled(settings, sourceControlWriterModelSelection)).toBe(
443 false,470 false,
444 );471 );
445 expect(resolveSourceControlWriterModelSelection(settings)).toBe(472 expect(resolveSourceControlWriterModelSelection(settings, undefined, null)).toBe(
446 settings.textGenerationModelSelection,473 settings.textGenerationModelSelection,
447 );474 );
448 expect(settings.sourceControlWriterModelSelection).toBe(sourceControlWriterModelSelection);475 expect(settings.sourceControlWriterModelSelection).toBe(sourceControlWriterModelSelection);
...@@ -477,7 +504,7 @@ describe("serverSettings helpers", () => {...@@ -477,7 +504,7 @@ describe("serverSettings helpers", () => {
477 skills: [],504 skills: [],
478 } satisfies ServerProvider;505 } satisfies ServerProvider;
479506
480 expect(resolveSourceControlWriterModelSelection(settings, [unavailableProvider])).toBe(507 expect(resolveSourceControlWriterModelSelection(settings, [unavailableProvider], null)).toBe(
481 settings.textGenerationModelSelection,508 settings.textGenerationModelSelection,
482 );509 );
483 expect(settings.sourceControlWriterModelSelection).toBe(sourceControlWriterModelSelection);510 expect(settings.sourceControlWriterModelSelection).toBe(sourceControlWriterModelSelection);
...@@ -512,12 +539,86 @@ describe("serverSettings helpers", () => {...@@ -512,12 +539,86 @@ describe("serverSettings helpers", () => {
512 skills: [],539 skills: [],
513 } satisfies ServerProvider;540 } satisfies ServerProvider;
514541
515 expect(resolveSourceControlWriterModelSelection(settings, [incapableProvider])).toBe(542 expect(resolveSourceControlWriterModelSelection(settings, [incapableProvider], null)).toBe(
516 settings.textGenerationModelSelection,543 settings.textGenerationModelSelection,
517 );544 );
518 expect(settings.sourceControlWriterModelSelection).toBe(sourceControlWriterModelSelection);545 expect(settings.sourceControlWriterModelSelection).toBe(sourceControlWriterModelSelection);
519 });546 });
520547
548 it("falls back from a writer provider a project turned off, keeping it for other projects", () => {
549 const instanceId = ProviderInstanceId.make("codex_writer");
550 const projectId = ProjectId.make("project-a");
551 const sourceControlWriterModelSelection = createModelSelection(instanceId, "gpt-5.4-mini");
552 const settings = {
553 ...DEFAULT_SERVER_SETTINGS,
554 providerInstances: {
555 [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true, config: {} },
556 },
557 projectSettingsOverrides: {
558 [projectId]: { providerInstances: { [instanceId]: { enabled: false } } },
559 },
560 sourceControlWriterModelSelection,
561 };
562 const provider = {
563 instanceId,
564 driver: ProviderDriverKind.make("codex"),
565 enabled: true,
566 installed: true,
567 version: null,
568 status: "ready",
569 auth: { status: "authenticated" },
570 checkedAt: "2026-07-27T00:00:00.000Z",
571 availability: "available",
572 models: [],
573 slashCommands: [],
574 skills: [],
575 } satisfies ServerProvider;
576
577 expect(resolveSourceControlWriterModelSelection(settings, [provider], projectId)).toBe(
578 settings.textGenerationModelSelection,
579 );
580 expect(resolveSourceControlWriterModelSelection(settings, [provider], null)).toBe(
581 sourceControlWriterModelSelection,
582 );
583 });
584
585 it("keeps a writer on an instance the project turned on while the machine has it off", () => {
586 const instanceId = ProviderInstanceId.make("codex_writer");
587 const projectId = ProjectId.make("project-a");
588 const sourceControlWriterModelSelection = createModelSelection(instanceId, "gpt-5.4-mini");
589 const settings = {
590 ...DEFAULT_SERVER_SETTINGS,
591 providerInstances: {
592 [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false, config: {} },
593 },
594 projectSettingsOverrides: {
595 [projectId]: { providerInstances: { [instanceId]: { enabled: true } } },
596 },
597 sourceControlWriterModelSelection,
598 };
599 const provider = {
600 instanceId,
601 driver: ProviderDriverKind.make("codex"),
602 enabled: true,
603 installed: true,
604 version: null,
605 status: "ready",
606 auth: { status: "authenticated" },
607 checkedAt: "2026-07-27T00:00:00.000Z",
608 availability: "available",
609 models: [],
610 slashCommands: [],
611 skills: [],
612 } satisfies ServerProvider;
613
614 expect(resolveSourceControlWriterModelSelection(settings, [provider], projectId)).toBe(
615 sourceControlWriterModelSelection,
616 );
617 expect(resolveSourceControlWriterModelSelection(settings, [provider], null)).toBe(
618 settings.textGenerationModelSelection,
619 );
620 });
621
521 it("replaces providerInstances maps so omitted instance fields are cleared", () => {622 it("replaces providerInstances maps so omitted instance fields are cleared", () => {
522 const codexId = ProviderInstanceId.make("codex");623 const codexId = ProviderInstanceId.make("codex");
523 const current = {624 const current = {
...@@ -552,6 +653,62 @@ describe("serverSettings helpers", () => {...@@ -552,6 +653,62 @@ describe("serverSettings helpers", () => {
552 });653 });
553 });654 });
554655
656 it("keeps a project's providerInstances override when a patch entry omits the key", () => {
657 // An older client that predates per-project provider overrides reads and
658 // resends a project's whole entry without knowing about the field. A
659 // naive whole-entry replace would silently wipe it.
660 const instanceId = ProviderInstanceId.make("codex_work");
661 const projectId = ProjectId.make("project-a");
662 const current = {
663 ...DEFAULT_SERVER_SETTINGS,
664 projectSettingsOverrides: {
665 [projectId]: {
666 newWorktreesStartFromOrigin: true,
667 providerInstances: { [instanceId]: { enabled: false } },
668 },
669 },
670 };
671
672 const patched = applyServerSettingsPatch(current, {
673 projectSettingsOverrides: {
674 [projectId]: { newWorktreesStartFromOrigin: false },
675 },
676 });
677
678 expect(patched.projectSettingsOverrides[projectId]).toEqual({
679 newWorktreesStartFromOrigin: false,
680 providerInstances: { [instanceId]: { enabled: false } },
681 });
682 });
683
684 it("clears a project's providerInstances override when a patch entry sends an empty map", () => {
685 // A client that knows the field expresses "clear every provider override
686 // for this project" by sending providerInstances explicitly, not by
687 // omitting it.
688 const instanceId = ProviderInstanceId.make("codex_work");
689 const projectId = ProjectId.make("project-a");
690 const current = {
691 ...DEFAULT_SERVER_SETTINGS,
692 projectSettingsOverrides: {
693 [projectId]: {
694 newWorktreesStartFromOrigin: true,
695 providerInstances: { [instanceId]: { enabled: false } },
696 },
697 },
698 };
699
700 const patched = applyServerSettingsPatch(current, {
701 projectSettingsOverrides: {
702 [projectId]: { newWorktreesStartFromOrigin: true, providerInstances: {} },
703 },
704 });
705
706 expect(patched.projectSettingsOverrides[projectId]).toEqual({
707 newWorktreesStartFromOrigin: true,
708 providerInstances: {},
709 });
710 });
711
555 it("upserts and removes usageLimitSources per entry so concurrent edits cannot clobber", () => {712 it("upserts and removes usageLimitSources per entry so concurrent edits cannot clobber", () => {
556 const hubA = UsageLimitSourceId.make("cliproxy-a");713 const hubA = UsageLimitSourceId.make("cliproxy-a");
557 const hubB = UsageLimitSourceId.make("cliproxy-b");714 const hubB = UsageLimitSourceId.make("cliproxy-b");
packages/shared/src/serverSettings.ts+112-6
...@@ -1,13 +1,20 @@...@@ -1,13 +1,20 @@
1import type { ModelPreferences } from "@t3tools/contracts/settings";1import type { ModelPreferences } from "@t3tools/contracts/settings";
2import {2import {
3 DEFAULT_MODEL_BY_PROVIDER,
4 DEFAULT_TEXT_GENERATION_MODEL,
5 DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER,
6 defaultInstanceIdForDriver,
3 isProviderAvailable,7 isProviderAvailable,
4 isUnconfiguredDefaultInstanceEnabled,8 isUnconfiguredDefaultInstanceEnabled,
9 resolveProjectProviderInstanceEnabled,
5 resolveProviderInstanceEnabled,10 resolveProviderInstanceEnabled,
6 isProviderTextGenerationCapable,11 isProviderTextGenerationCapable,
7 type ModelSelection,12 type ModelSelection,
8 type ProjectId,13 type ProjectId,
9 type ProjectScopedServerSettingKey,14 type ProjectScopedServerSettingKey,
10 type ProjectSettingsOverrides,15 type ProjectSettingsOverrides,
16 ProviderDriverKind,
17 ProviderInstanceId,
11 type ServerProvider,18 type ServerProvider,
12 ServerSettings,19 ServerSettings,
13 type ServerSettingsPatch,20 type ServerSettingsPatch,
...@@ -71,12 +78,74 @@ export function isModelSelectionProviderEnabled(...@@ -71,12 +78,74 @@ export function isModelSelectionProviderEnabled(
71 return isUnconfiguredDefaultInstanceEnabled(selection.instanceId);78 return isUnconfiguredDefaultInstanceEnabled(selection.instanceId);
72}79}
7380
81const TEXT_GENERATION_FALLBACK_DRIVERS = [
82 "codex",
83 "claudeAgent",
84 "cursor",
85 "grok",
86 "muse",
87 "pi",
88 "opencode",
89 "antigravity",
90].map((driver) => ProviderDriverKind.make(driver));
91
92/**
93 * The text generation model on the first instance `isEnabled` accepts: the
94 * built-in default instances in preference order, then, when
95 * `includeConfiguredInstances` is set, configured instances of those
96 * drivers. `null` when none is.
97 *
98 * `includeConfiguredInstances` is for the project-scoped caller only: a
99 * project that disabled every built-in instance should still be able to
100 * fall back to one it configured itself. The machine-level caller leaves it
101 * unset, since widening the candidates there would change behavior for
102 * every user regardless of project scope.
103 */
104export function fallbackTextGenerationModelSelection(
105 providerInstances: ServerSettings["providerInstances"],
106 isEnabled: (instanceId: ProviderInstanceId, driver: ProviderDriverKind) => boolean,
107 options?: { readonly includeConfiguredInstances?: boolean },
108): ModelSelection | null {
109 const candidates = [
110 ...TEXT_GENERATION_FALLBACK_DRIVERS.map(
111 (driver) => [defaultInstanceIdForDriver(driver), driver] as const,
112 ),
113 ...(options?.includeConfiguredInstances === true
114 ? Object.entries(providerInstances).flatMap(([instanceId, instance]) =>
115 TEXT_GENERATION_FALLBACK_DRIVERS.includes(instance.driver)
116 ? [[ProviderInstanceId.make(instanceId), instance.driver] as const]
117 : [],
118 )
119 : []),
120 ];
121 const match = candidates.find(([instanceId, driver]) => isEnabled(instanceId, driver));
122 if (match === undefined) return null;
123 const [instanceId, driver] = match;
124 return {
125 instanceId,
126 model:
127 DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[driver] ??
128 DEFAULT_MODEL_BY_PROVIDER[driver] ??
129 DEFAULT_TEXT_GENERATION_MODEL,
130 };
131}
132
133/**
134 * `projectId` has no default: a caller that forgets it would otherwise fall
135 * back to the machine's own writer choice, silently ignoring a project that
136 * turned the writer's instance off. Pass `null` explicitly outside a
137 * project's scope.
138 */
74export function resolveSourceControlWriterModelSelection(139export function resolveSourceControlWriterModelSelection(
75 settings: ServerSettings,140 settings: ServerSettings,
76 providers?: ReadonlyArray<ServerProvider>,141 providers: ReadonlyArray<ServerProvider> | undefined,
142 projectId: ProjectId | null,
77): ModelSelection {143): ModelSelection {
78 const selection = settings.sourceControlWriterModelSelection;144 const selection = settings.sourceControlWriterModelSelection;
79 if (!selection || !isModelSelectionProviderEnabled(settings, selection)) {145 if (
146 !selection ||
147 !resolveProjectProviderInstanceEnabled(settings, projectId, selection.instanceId)
148 ) {
80 return settings.textGenerationModelSelection;149 return settings.textGenerationModelSelection;
81 }150 }
82 if (providers === undefined) {151 if (providers === undefined) {
...@@ -97,10 +166,14 @@ export function resolveSourceControlWriterModelSelection(...@@ -97,10 +166,14 @@ export function resolveSourceControlWriterModelSelection(
97 */166 */
98export function resolveReviewGuideModelSelection(167export function resolveReviewGuideModelSelection(
99 settings: ServerSettings,168 settings: ServerSettings,
100 providers?: ReadonlyArray<ServerProvider>,169 providers: ReadonlyArray<ServerProvider> | undefined,
170 projectId: ProjectId | null,
101): ModelSelection {171): ModelSelection {
102 const selection = settings.reviewGuideModelSelection;172 const selection = settings.reviewGuideModelSelection;
103 if (selection && isModelSelectionProviderEnabled(settings, selection)) {173 if (
174 selection &&
175 resolveProjectProviderInstanceEnabled(settings, projectId, selection.instanceId)
176 ) {
104 const provider = providers?.find((candidate) => candidate.instanceId === selection.instanceId);177 const provider = providers?.find((candidate) => candidate.instanceId === selection.instanceId);
105 if (178 if (
106 providers === undefined ||179 providers === undefined ||
...@@ -111,7 +184,7 @@ export function resolveReviewGuideModelSelection(...@@ -111,7 +184,7 @@ export function resolveReviewGuideModelSelection(
111 return selection;184 return selection;
112 }185 }
113 }186 }
114 return resolveSourceControlWriterModelSelection(settings, providers);187 return resolveSourceControlWriterModelSelection(settings, providers, projectId);
115}188}
116189
117export interface PersistedServerObservabilitySettings {190export interface PersistedServerObservabilitySettings {
...@@ -191,6 +264,36 @@ function mergeSettingsEntries<Value>(...@@ -191,6 +264,36 @@ function mergeSettingsEntries<Value>(
191 return Object.fromEntries(next);264 return Object.fromEntries(next);
192}265}
193266
267/**
268 * `projectSettingsOverrides` entries otherwise replace whole (see the field's
269 * doc comment in contracts), but `providerInstances` was added after clients
270 * already round-trip a project's entry on every edit. A client built before
271 * that addition sends an entry that never mentions the key, and a whole
272 * replace would silently wipe it. When an incoming entry omits the key, keep
273 * what is stored; a client that means to clear every provider override for
274 * the project says so explicitly with `providerInstances: {}`.
275 */
276function mergeProjectSettingsOverridesPatch(
277 current: Readonly<Record<string, ProjectSettingsOverrides>>,
278 patch: Readonly<Record<string, ProjectSettingsOverrides | null>>,
279): Record<string, ProjectSettingsOverrides> {
280 const next = new Map(Object.entries(current));
281 for (const [projectId, entry] of Object.entries(patch)) {
282 if (entry === null) {
283 next.delete(projectId);
284 continue;
285 }
286 const storedProviderInstances = current[projectId]?.providerInstances;
287 next.set(projectId, {
288 ...entry,
289 ...(Object.hasOwn(entry, "providerInstances") || storedProviderInstances === undefined
290 ? {}
291 : { providerInstances: storedProviderInstances }),
292 });
293 }
294 return Object.fromEntries(next);
295}
296
194/**297/**
195 * Derived views of `projectSettingsOverrides` for clients that still read298 * Derived views of `projectSettingsOverrides` for clients that still read
196 * the legacy per-key maps. Recomputed on every patch and load so they299 * the legacy per-key maps. Recomputed on every patch and load so they
...@@ -463,7 +566,10 @@ export function applyServerSettingsPatch(...@@ -463,7 +566,10 @@ export function applyServerSettingsPatch(
463 ? {566 ? {
464 projectSettingsOverrides: Object.fromEntries(567 projectSettingsOverrides: Object.fromEntries(
465 Object.entries(568 Object.entries(
466 mergeSettingsEntries(current.projectSettingsOverrides, projectSettingsOverridesPatch),569 mergeProjectSettingsOverridesPatch(
570 current.projectSettingsOverrides,
571 projectSettingsOverridesPatch,
572 ),
467 ).filter(([, entry]) => Object.keys(entry).length > 0),573 ).filter(([, entry]) => Object.keys(entry).length > 0),
468 ),574 ),
469 }575 }