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() {
2525 const { editor, setEditor } = useScheduledTaskEditor();
2626 const config = useEnvironmentServerConfig(editor?.environmentId ?? null);
2727 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 );
2933 const providerGroups = useMemo(() => groupByProvider(models), [models]);
3034 const selectedOption = models.find(
3135 (option) =>
......@@ -46,6 +50,7 @@ export function ScheduledTaskModelPickerRouteScreen() {
4650 return (
4751 <ThreadSettingsPickerScreen
4852 environmentId={editor.environmentId}
53 projectId={projectId}
4954 providerGroups={providerGroups}
5055 selectedModel={selectedModel}
5156 onSelectModel={(option) =>
apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx+26-10
......@@ -1,5 +1,8 @@
11import { useAtomValue } from "@effect/atom-react";
2import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
2import {
3 AuthOrchestrationOperateScope,
4 resolveProjectProviderInstanceEnabled,
5} from "@t3tools/contracts";
36import { readEnvironmentScope } from "../../state/session";
47import type {
58 EnvironmentId,
......@@ -582,7 +585,10 @@ function TaskForm({
582585 );
583586 const projects = useProjects().filter((project) => project.environmentId === environmentId);
584587 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 );
586592 const canOperate = useAtomValue(
587593 serverEnvironment.upsertScheduledTask.permissionAtom(environmentId),
588594 );
......@@ -757,14 +763,24 @@ function TaskForm({
757763 }))}
758764 onSelect={(id) => {
759765 const project = projects.find((item) => item.id === id);
760 if (project)
761 setDraft({
762 ...draft,
763 projectId: project.id,
764 modelSelection: draft.modelSelectionIsExplicit
765 ? draft.modelSelection
766 : scheduledTaskDefaultModel(config, project),
767 });
766 if (!project) return;
767 const keepsExplicitModel =
768 draft.modelSelectionIsExplicit &&
769 (draft.modelSelection === null ||
770 !config?.settings ||
771 resolveProjectProviderInstanceEnabled(
772 config.settings,
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 });
768784 }}
769785 />
770786 <PickerRow
apps/mobile/src/features/settings/scheduledTaskDraft.test.ts+7
......@@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test";
22import {
33 DEFAULT_SERVER_SETTINGS,
44 type ServerConfig,
5 ProviderDriverKind,
56 ProviderInstanceId,
67 ProjectId,
78 ScheduledTaskId,
......@@ -339,6 +340,12 @@ describe("scheduled task model defaults", () => {
339340 resolve(
340341 {
341342 ...config,
343 settings: {
344 ...config.settings,
345 providerInstances: {
346 [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false },
347 },
348 },
342349 providers: config.providers.map((provider) => ({ ...provider, enabled: false })),
343350 },
344351 null,
apps/mobile/src/features/settings/scheduledTaskDraft.ts+8-3
......@@ -28,13 +28,18 @@ export function scheduledTaskDefaultModel(
2828 const configured = resolveProjectSettings(settings, project?.id ?? null, project).settings
2929 .defaultModelSelection;
3030 const projectDefaultSelection =
31 resolveDefaultableModelSelection(config, configured) ??
32 resolveDefaultableModelSelection(config, settings.defaultModelSelection);
31 resolveDefaultableModelSelection(config, configured, project?.id ?? null) ??
32 resolveDefaultableModelSelection(config, settings.defaultModelSelection, project?.id ?? null);
3333 return resolveNewTaskModelSelection({
3434 draftSelection: null,
3535 projectDefaultSelection,
3636 stickySelection: null,
37 modelOptions: buildModelOptions(config, projectDefaultSelection),
37 modelOptions: buildModelOptions(
38 config,
39 projectDefaultSelection,
40 undefined,
41 project?.id ?? null,
42 ),
3843 });
3944}
4045
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+12-4
......@@ -112,6 +112,7 @@ import { sourceControlEnvironment } from "../../state/sourceControl";
112112import { useAtomCommand } from "../../state/use-atom-command";
113113import { ProjectCloneBanner } from "../../components/ProjectCloneBanner";
114114import {
115 describeUnavailableModelSelection,
115116 isModelSelectionUnavailable,
116117 resolveSelectableModelSelection,
117118} from "../../lib/modelOptions";
......@@ -1236,6 +1237,7 @@ export function NewTaskDraftScreen(props: {
12361237 resolveSelectableModelSelection(
12371238 selectedEnvironmentServerConfig,
12381239 draft.modelSelection ?? null,
1240 selectedProject.id,
12391241 ) ?? flow.selectedModel;
12401242 const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode;
12411243 const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName;
......@@ -1252,12 +1254,18 @@ export function NewTaskDraftScreen(props: {
12521254 }
12531255 if (
12541256 environmentConnected &&
1255 isModelSelectionUnavailable(selectedEnvironmentServerConfig, modelSelection)
1257 isModelSelectionUnavailable(
1258 selectedEnvironmentServerConfig,
1259 modelSelection,
1260 selectedProject.id,
1261 )
12561262 ) {
1257 Alert.alert(
1258 "Antigravity model unavailable",
1259 "Set up Antigravity on web or desktop, or choose another model.",
1263 const { title, detail } = describeUnavailableModelSelection(
1264 selectedEnvironmentServerConfig,
1265 modelSelection,
1266 selectedProject.id,
12601267 );
1268 Alert.alert(title, detail);
12611269 return;
12621270 }
12631271 // 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
435435 const currentRuntimeMode = props.selectedThread.runtimeMode;
436436 const modelUnavailable =
437437 props.connectionState === "connected" &&
438 isModelSelectionUnavailable(props.serverConfig, currentModelSelection);
438 isModelSelectionUnavailable(
439 props.serverConfig,
440 currentModelSelection,
441 props.selectedThread.projectId,
442 );
439443 const selectedProviderStatus = useMemo(() => {
440444 if (!props.serverConfig) return null;
441445 return (
......@@ -651,8 +655,19 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
651655 ? undefined
652656 : currentModelSelection.instanceId;
653657 const modelOptions = useMemo(
654 () => buildModelOptions(props.serverConfig, currentModelSelection, lockedProviderInstanceId),
655 [props.serverConfig, currentModelSelection, lockedProviderInstanceId],
658 () =>
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 ],
656671 );
657672 const threadProviderGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]);
658673 const currentModelOption =
......@@ -674,6 +689,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
674689 () => ({
675690 ownerId: settingsOwnerId,
676691 environmentId: props.environmentId,
692 projectId: props.selectedThread.projectId,
677693 providerInstanceId: currentModelSelection.instanceId,
678694 ...(lockedProviderInstanceId ? { lockedProviderInstanceId } : {}),
679695 providerGroups: threadProviderGroups,
......@@ -703,6 +719,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
703719 settingsOwnerId,
704720 lockedProviderInstanceId,
705721 threadProviderGroups,
722 props.selectedThread.projectId,
706723 ],
707724 );
708725 const openSettings = useCallback(() => {
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+11-1
......@@ -7,6 +7,7 @@ import {
77import type {
88 EnvironmentId,
99 ModelSelection,
10 ProjectId,
1011 ProviderInstanceId,
1112 ProviderOptionDescriptor,
1213 ProviderOptionSelection,
......@@ -252,6 +253,7 @@ type ThreadSettingsSubmenuPage =
252253
253254type ThreadSettingsSessionProps = {
254255 readonly environmentId: EnvironmentId | null;
256 readonly projectId: ProjectId | null;
255257 readonly providerInstanceId?: ProviderInstanceId;
256258 readonly lockedProviderInstanceId?: ProviderInstanceId;
257259 readonly providerGroups: ReadonlyArray<ProviderGroup>;
......@@ -360,7 +362,13 @@ function ThreadSettingsSessionProvider(
360362 () =>
361363 manageHidden && config
362364 ? 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 ),
364372 )
365373 : props.providerGroups,
366374 [
......@@ -369,6 +377,7 @@ function ThreadSettingsSessionProvider(
369377 props.providerGroups,
370378 props.selectedModel,
371379 props.lockedProviderInstanceId,
380 props.projectId,
372381 ],
373382 );
374383 const legacyFavorites = AsyncResult.isSuccess(preferences)
......@@ -1605,6 +1614,7 @@ export function NewTaskThreadSettingsRouteScreen() {
16051614 return (
16061615 <ThreadSettingsPickerScreen
16071616 environmentId={flow.selectedEnvironmentId}
1617 projectId={flow.selectedProject?.id ?? null}
16081618 {...(flow.selectedModel ? { providerInstanceId: flow.selectedModel.instanceId } : {})}
16091619 providerGroups={flow.providerGroups}
16101620 selectedModel={flow.selectedModel}
apps/mobile/src/features/threads/new-task-flow-provider.tsx+7
......@@ -581,27 +581,33 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
581581 const draftModelSelection = resolveSelectableModelSelection(
582582 selectedEnvironmentServerConfig,
583583 selectedProjectDraft.modelSelection ?? null,
584 selectedProject?.id ?? null,
584585 );
585586 const projectDefaultModelSelection = resolveDefaultableModelSelection(
586587 selectedEnvironmentServerConfig,
587588 projectSettings.settings.defaultModelSelection,
589 selectedProject?.id ?? null,
588590 );
589591 const storedStickyModelSelection = useStickyComposerModelSelection();
590592 const stickyModelSelection = resolveDefaultableModelSelection(
591593 selectedEnvironmentServerConfig,
592594 storedStickyModelSelection,
595 selectedProject?.id ?? null,
593596 );
594597 const modelOptions = useMemo(
595598 () =>
596599 buildModelOptions(
597600 selectedEnvironmentServerConfig,
598601 draftModelSelection ?? projectDefaultModelSelection ?? stickyModelSelection,
602 undefined,
603 selectedProject?.id ?? null,
599604 ),
600605 [
601606 selectedEnvironmentServerConfig,
602607 draftModelSelection,
603608 projectDefaultModelSelection,
604609 stickyModelSelection,
610 selectedProject?.id,
605611 ],
606612 );
607613
......@@ -1117,6 +1123,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
11171123 resolveSelectableModelSelection(
11181124 selectedEnvironmentServerConfig,
11191125 draft.modelSelection ?? null,
1126 selectedProject.id,
11201127 ) ?? selectedModel;
11211128 // A shared image or file is a task on its own; text is optional.
11221129 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";
22
33import {
44 DEFAULT_SERVER_SETTINGS,
5 ProjectId,
6 ProviderDriverKind,
57 ProviderInstanceId,
68 type ModelSelection,
79 type ServerConfig,
10 type ServerSettings,
811} from "@t3tools/contracts";
912
1013import {
1114 buildModelOptions,
15 describeUnavailableModelSelection,
1216 groupByProvider,
1317 isModelSelectionUnavailable,
1418 resolveDefaultableModelSelection,
......@@ -47,7 +51,7 @@ describe("mobile model options", () => {
4751 ],
4852 } as unknown as ServerConfig;
4953
50 expect(groupByProvider(buildModelOptions(config, null))).toMatchObject([
54 expect(groupByProvider(buildModelOptions(config, null, undefined, null))).toMatchObject([
5155 {
5256 providerKey: "codex",
5357 providerLabel: "Codex",
......@@ -83,7 +87,7 @@ describe("mobile model options", () => {
8387 ],
8488 } as unknown as ServerConfig;
8589
86 const [group] = groupByProvider(buildModelOptions(config, null));
90 const [group] = groupByProvider(buildModelOptions(config, null, undefined, null));
8791
8892 expect(group).toMatchObject({
8993 providerKey: "acpRegistry_antigravity",
......@@ -127,7 +131,7 @@ describe("mobile model options", () => {
127131 model: "github-copilot/claude-fable-5",
128132 };
129133
130 const options = buildModelOptions(config, selection);
134 const options = buildModelOptions(config, selection, undefined, null);
131135
132136 expect(options).toMatchObject(
133137 sources.map((source) => ({
......@@ -181,26 +185,41 @@ describe("mobile model options", () => {
181185 ],
182186 } as unknown as ServerConfig;
183187
184 const [option] = buildModelOptions(config, {
185 instanceId: ProviderInstanceId.make("codex"),
186 model: "gpt-test",
187 });
188 const [option] = buildModelOptions(
189 config,
190 {
191 instanceId: ProviderInstanceId.make("codex"),
192 model: "gpt-test",
193 },
194 undefined,
195 null,
196 );
188197
189198 expect(option?.capabilities?.optionDescriptors?.[0]?.id).toBe("serviceTier");
190199 expect(option?.selection.options).toBeUndefined();
191200
192 const [emptyOption] = buildModelOptions(config, {
193 instanceId: ProviderInstanceId.make("codex"),
194 model: "gpt-test",
195 options: [],
196 });
201 const [emptyOption] = buildModelOptions(
202 config,
203 {
204 instanceId: ProviderInstanceId.make("codex"),
205 model: "gpt-test",
206 options: [],
207 },
208 undefined,
209 null,
210 );
197211 expect(emptyOption?.selection).toEqual(option?.selection);
198212
199 const [explicitOption] = buildModelOptions(config, {
200 instanceId: ProviderInstanceId.make("codex"),
201 model: "gpt-test",
202 options: [{ id: "serviceTier", value: "priority" }],
203 });
213 const [explicitOption] = buildModelOptions(
214 config,
215 {
216 instanceId: ProviderInstanceId.make("codex"),
217 model: "gpt-test",
218 options: [{ id: "serviceTier", value: "priority" }],
219 },
220 undefined,
221 null,
222 );
204223 expect(explicitOption?.selection.options).toEqual([{ id: "serviceTier", value: "priority" }]);
205224 });
206225
......@@ -216,12 +235,13 @@ describe("mobile model options", () => {
216235 const config = { providers } as unknown as ServerConfig;
217236 const selection = { instanceId: ProviderInstanceId.make("codex"), model: "test" };
218237
219 expect(buildModelOptions(config, selection).map((option) => option.providerKey)).toEqual([
220 "codex",
221 "claudeAgent",
222 ]);
223 expect(buildModelOptions(config, selection, selection.instanceId)).toEqual(
224 buildModelOptions(config, selection).filter((option) => option.providerKey === "codex"),
238 expect(
239 buildModelOptions(config, selection, undefined, null).map((option) => option.providerKey),
240 ).toEqual(["codex", "claudeAgent"]);
241 expect(buildModelOptions(config, selection, selection.instanceId, null)).toEqual(
242 buildModelOptions(config, selection, undefined, null).filter(
243 (option) => option.providerKey === "codex",
244 ),
225245 );
226246 });
227247
......@@ -247,8 +267,8 @@ describe("mobile model options", () => {
247267 providers: state === "missing" ? [] : [provider],
248268 settings: { providerInstances: { google_work: { driver: "antigravity" } } },
249269 } as unknown as ServerConfig;
250 const options = buildModelOptions(config, selection, selection.instanceId);
251 expect(options).toEqual(buildModelOptions(config, selection));
270 const options = buildModelOptions(config, selection, selection.instanceId, null);
271 expect(options).toEqual(buildModelOptions(config, selection, undefined, null));
252272 expect(options).toHaveLength(1);
253273 expect(options[0]).toMatchObject({ selection, isUnavailable: true });
254274 },
......@@ -289,12 +309,15 @@ describe("mobile model options", () => {
289309 model: "gpt-5.6-sol",
290310 };
291311
292 expect(resolveSelectableModelSelection(config, usable)).toBe(usable);
293 expect(resolveSelectableModelSelection(config, disabled)).toBeNull();
294 expect(resolveSelectableModelSelection(config, removed)).toBeNull();
295 expect(isModelSelectionUnavailable(config, disabled)).toBe(false);
312 expect(resolveSelectableModelSelection(config, usable, null)).toBe(usable);
313 expect(resolveSelectableModelSelection(config, disabled, null)).toBeNull();
314 expect(resolveSelectableModelSelection(config, removed, null)).toBeNull();
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);
296319 // An offline environment has no config to validate.
297 expect(resolveSelectableModelSelection(null, disabled)).toBe(disabled);
320 expect(resolveSelectableModelSelection(null, disabled, null)).toBe(disabled);
298321 });
299322
300323 describe("Antigravity selections", () => {
......@@ -347,11 +370,11 @@ describe("mobile model options", () => {
347370 providers: config.providers.map((provider) => ({ ...provider, ...update })),
348371 };
349372
350 expect(resolveSelectableModelSelection(unavailableConfig, selection)).toBe(selection);
351 expect(resolveDefaultableModelSelection(unavailableConfig, selection)).toBe(selection);
352 expect(isModelSelectionUnavailable(unavailableConfig, selection)).toBe(true);
353 expect(buildModelOptions(unavailableConfig, null)).toEqual([]);
354 const [option] = buildModelOptions(unavailableConfig, selection);
373 expect(resolveSelectableModelSelection(unavailableConfig, selection, null)).toBe(selection);
374 expect(resolveDefaultableModelSelection(unavailableConfig, selection, null)).toBe(selection);
375 expect(isModelSelectionUnavailable(unavailableConfig, selection, null)).toBe(true);
376 expect(buildModelOptions(unavailableConfig, null, undefined, null)).toEqual([]);
377 const [option] = buildModelOptions(unavailableConfig, selection, undefined, null);
355378 expect(option).toMatchObject({
356379 key: `google_work:${selection.model}`,
357380 label: model.name,
......@@ -376,9 +399,9 @@ describe("mobile model options", () => {
376399 })),
377400 };
378401
379 expect(resolveDefaultableModelSelection(changedConfig, selection)).toBe(selection);
380 expect(isModelSelectionUnavailable(changedConfig, selection)).toBe(true);
381 const options = buildModelOptions(changedConfig, selection);
402 expect(resolveDefaultableModelSelection(changedConfig, selection, null)).toBe(selection);
403 expect(isModelSelectionUnavailable(changedConfig, selection, null)).toBe(true);
404 const options = buildModelOptions(changedConfig, selection, undefined, null);
382405 const missing = options.find((option) => option.selection.model === selection.model);
383406 expect(missing).toMatchObject({
384407 label: selection.model,
......@@ -391,18 +414,20 @@ describe("mobile model options", () => {
391414 expect(
392415 resolveNewTaskModelSelection({
393416 draftSelection: null,
394 projectDefaultSelection: resolveDefaultableModelSelection(changedConfig, selection),
417 projectDefaultSelection: resolveDefaultableModelSelection(changedConfig, selection, null),
395418 stickySelection: null,
396419 modelOptions: options,
397420 }),
398421 ).toBe(selection);
399422
400 const [restored] = buildModelOptions(config, selection);
401 expect(isModelSelectionUnavailable(config, selection)).toBe(false);
423 const [restored] = buildModelOptions(config, selection, undefined, null);
424 expect(isModelSelectionUnavailable(config, selection, null)).toBe(false);
402425 expect(restored?.isUnavailable).not.toBe(true);
403426 expect(restored?.selection).toBe(selection);
404 expect(resolveDefaultableModelSelection(config, selection)).toBe(selection);
405 expect(buildModelOptions(config, null)[0]?.selection.options).toBeUndefined();
427 expect(resolveDefaultableModelSelection(config, selection, null)).toBe(selection);
428 expect(
429 buildModelOptions(config, null, undefined, null)[0]?.selection.options,
430 ).toBeUndefined();
406431 });
407432
408433 it("uses configured instance metadata when provider status is missing", () => {
......@@ -415,9 +440,11 @@ describe("mobile model options", () => {
415440 },
416441 } as unknown as ServerConfig;
417442
418 expect(resolveDefaultableModelSelection(missingStatusConfig, selection)).toBe(selection);
419 expect(isModelSelectionUnavailable(missingStatusConfig, selection)).toBe(true);
420 expect(buildModelOptions(missingStatusConfig, selection)).toMatchObject([
443 expect(resolveDefaultableModelSelection(missingStatusConfig, selection, null)).toBe(
444 selection,
445 );
446 expect(isModelSelectionUnavailable(missingStatusConfig, selection, null)).toBe(true);
447 expect(buildModelOptions(missingStatusConfig, selection, undefined, null)).toMatchObject([
421448 {
422449 providerDriver: "antigravity",
423450 providerLabel: "Google Work",
......@@ -430,12 +457,12 @@ describe("mobile model options", () => {
430457 it("keeps offline selections without assuming that an unknown instance is Antigravity", () => {
431458 const unknownConfig = { ...config, providers: [] };
432459
433 expect(resolveDefaultableModelSelection(null, selection)).toBe(selection);
434 expect(isModelSelectionUnavailable(null, selection)).toBe(false);
435 expect(buildModelOptions(null, selection)[0]?.selection).toBe(selection);
436 expect(buildModelOptions(null, selection)[0]?.isUnavailable).not.toBe(true);
437 expect(isModelSelectionUnavailable(unknownConfig, selection)).toBe(false);
438 expect(resolveSelectableModelSelection(unknownConfig, selection)).toBeNull();
460 expect(resolveDefaultableModelSelection(null, selection, null)).toBe(selection);
461 expect(isModelSelectionUnavailable(null, selection, null)).toBe(false);
462 expect(buildModelOptions(null, selection, undefined, null)[0]?.selection).toBe(selection);
463 expect(buildModelOptions(null, selection, undefined, null)[0]?.isUnavailable).not.toBe(true);
464 expect(isModelSelectionUnavailable(unknownConfig, selection, null)).toBe(false);
465 expect(resolveSelectableModelSelection(unknownConfig, selection, null)).toBeNull();
439466 });
440467 });
441468
......@@ -466,11 +493,11 @@ describe("mobile model options", () => {
466493 const current = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" };
467494 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);
470497 // A legacy last-used selection falls through to the provider default.
471 expect(resolveDefaultableModelSelection(config, legacy)).toBeNull();
498 expect(resolveDefaultableModelSelection(config, legacy, null)).toBeNull();
472499 // Offline: nothing to validate against, selection passes through.
473 expect(resolveDefaultableModelSelection(null, legacy)).toBe(legacy);
500 expect(resolveDefaultableModelSelection(null, legacy, null)).toBe(legacy);
474501 });
475502
476503 it("resolves new tasks from draft, project, sticky, then provider defaults", () => {
......@@ -508,6 +535,217 @@ describe("mobile model options", () => {
508535 }),
509536 ).toBeNull();
510537 });
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 });
511749});
512750
513751describe("synchronized mobile model visibility", () => {
......@@ -522,6 +760,13 @@ describe("synchronized mobile model visibility", () => {
522760 const config = {
523761 settings: {
524762 ...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 },
525770 providerModelPreferences: {
526771 [provider]: {
527772 hiddenModels: ["hidden", "custom", "missing"],
......@@ -540,7 +785,7 @@ describe("synchronized mobile model visibility", () => {
540785 } as unknown as ServerConfig;
541786
542787 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);
544789 expect(
545790 options
546791 .filter((option) => option.providerKey === provider)
......@@ -558,6 +803,7 @@ describe("synchronized mobile model visibility", () => {
558803 config,
559804 { instanceId: provider, model: "hidden" },
560805 provider,
806 null,
561807 true,
562808 );
563809 expect(options.map((option) => option.selection.model)).toEqual([
......@@ -578,7 +824,7 @@ describe("synchronized mobile model visibility", () => {
578824
579825 it.each(["hidden", "missing"])("does not resurrect the hidden fallback %s", (model) => {
580826 expect(
581 buildModelOptions(config, { instanceId: provider, model }, provider).map(
827 buildModelOptions(config, { instanceId: provider, model }, provider, null).map(
582828 (option) => option.selection.model,
583829 ),
584830 ).toEqual(["second", "first", "custom"]);
......@@ -586,13 +832,13 @@ describe("synchronized mobile model visibility", () => {
586832
587833 it("keeps custom models visible and restores built-in models when the server clears hidden models", () => {
588834 expect(
589 buildModelOptions(config, { instanceId: provider, model: "custom" }, provider).map(
835 buildModelOptions(config, { instanceId: provider, model: "custom" }, provider, null).map(
590836 (option) => option.selection.model,
591837 ),
592838 ).toContain("custom");
593839 const cleared = { ...config, settings: { ...config.settings, providerModelPreferences: {} } };
594840 expect(
595 buildModelOptions(cleared, null, provider).map((option) => option.selection.model),
841 buildModelOptions(cleared, null, provider, null).map((option) => option.selection.model),
596842 ).toEqual(["hidden", "first", "second", "custom"]);
597843 });
598844});
apps/mobile/src/lib/modelOptions.ts+113-8
......@@ -2,9 +2,11 @@ import type { MenuAction } from "@react-native-menu/menu";
22import type {
33 ModelCapabilities,
44 ModelSelection,
5 ProjectId,
56 RuntimeMode,
67 ServerConfig as T3ServerConfig,
78} from "@t3tools/contracts";
9import { resolveProjectProviderInstanceEnabled } from "@t3tools/contracts";
810import {
911 buildExplicitProviderOptionSelectionsFromDescriptors,
1012 getProviderOptionDescriptors,
......@@ -54,6 +56,67 @@ function providerDisplayLabel(provider: {
5456 return provider.instanceId;
5557}
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
57120function normalizeSelectionOptions(
58121 selection: ModelSelection,
59122 capabilities: ModelCapabilities | null,
......@@ -79,10 +142,19 @@ function normalizeSelectionOptions(
79142 };
80143}
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 */
83154export function isModelSelectionUnavailable(
84155 config: T3ServerConfig | null | undefined,
85156 selection: ModelSelection | null | undefined,
157 projectId: ProjectId | null,
86158): boolean {
87159 if (!config || !selection) {
88160 return false;
......@@ -90,12 +162,14 @@ export function isModelSelectionUnavailable(
90162 const provider = config.providers.find(
91163 (candidate) => candidate.instanceId === selection.instanceId,
92164 );
165 if (isDisabledForProject(config, provider, selection.instanceId, projectId)) {
166 return true;
167 }
93168 const driver =
94169 provider?.driver ?? config.settings?.providerInstances[selection.instanceId]?.driver;
95170 return (
96171 driver === "antigravity" &&
97172 (!provider ||
98 !provider.enabled ||
99173 !provider.installed ||
100174 provider.auth.status === "unauthenticated" ||
101175 provider.availability === "unavailable" ||
......@@ -103,14 +177,37 @@ export function isModelSelectionUnavailable(
103177 );
104178}
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
106200/**
107201 * Keep Antigravity selections when setup or catalog changes make them
108202 * unavailable. Other providers fall through to the server default when they
109203 * are disabled, missing, or signed out. Without config, keep stored selections.
204 *
205 * `projectId` has no default; see `isModelSelectionUnavailable`.
110206 */
111207export function resolveSelectableModelSelection(
112208 config: T3ServerConfig | null | undefined,
113209 selection: ModelSelection | null,
210 projectId: ProjectId | null,
114211): ModelSelection | null {
115212 if (!selection || !config) {
116213 return selection;
......@@ -121,10 +218,10 @@ export function resolveSelectableModelSelection(
121218 const driver =
122219 provider?.driver ?? config.settings?.providerInstances[selection.instanceId]?.driver;
123220 if (driver === "antigravity") {
124 return selection;
221 return isProjectDisabledOverride(config, selection.instanceId, projectId) ? null : selection;
125222 }
126223 return provider &&
127 provider.enabled &&
224 isProviderEnabledForProject(config, provider, projectId) &&
128225 provider.installed &&
129226 provider.auth.status !== "unauthenticated"
130227 ? selection
......@@ -135,12 +232,15 @@ export function resolveSelectableModelSelection(
135232 * Reject legacy models for implicit defaults, except Antigravity selections,
136233 * which must not silently change after a catalog update. Explicit picks in
137234 * the settings sheet are unaffected.
235 *
236 * `projectId` has no default; see `isModelSelectionUnavailable`.
138237 */
139238export function resolveDefaultableModelSelection(
140239 config: T3ServerConfig | null | undefined,
141240 selection: ModelSelection | null,
241 projectId: ProjectId | null,
142242): ModelSelection | null {
143 const usable = resolveSelectableModelSelection(config, selection);
243 const usable = resolveSelectableModelSelection(config, selection, projectId);
144244 if (!usable || !config) {
145245 return usable;
146246 }
......@@ -165,10 +265,14 @@ export function resolveNewTaskModelSelection(input: {
165265 );
166266}
167267
268/**
269 * `projectId` has no default; see `isModelSelectionUnavailable`.
270 */
168271export function buildModelOptions(
169272 config: T3ServerConfig | null | undefined,
170273 fallbackModelSelection: ModelSelection | null,
171 providerInstanceId?: ModelSelection["instanceId"],
274 providerInstanceId: ModelSelection["instanceId"] | undefined,
275 projectId: ProjectId | null,
172276 includeHidden = false,
173277): ReadonlyArray<ModelOption> {
174278 const options = new Map<string, ModelOption>();
......@@ -176,7 +280,7 @@ export function buildModelOptions(
176280 for (const provider of config?.providers ?? []) {
177281 if (
178282 (providerInstanceId !== undefined && provider.instanceId !== providerInstanceId) ||
179 !provider.enabled ||
283 !isProviderEnabledForProject(config, provider, projectId) ||
180284 !provider.installed ||
181285 provider.auth.status === "unauthenticated" ||
182286 (provider.driver === "antigravity" && provider.availability === "unavailable")
......@@ -271,7 +375,8 @@ export function buildModelOptions(
271375 providerDriver,
272376 isDefault: false,
273377 isLegacy: model?.isLegacy === true,
274 ...(isModelSelectionUnavailable(config, fallbackModelSelection) ||
378 ...(isModelSelectionUnavailable(config, fallbackModelSelection, projectId) ||
379 (provider !== undefined && !isProviderEnabledForProject(config, provider, projectId)) ||
275380 provider?.updateRequiredModels?.some((gated) => gated.slug === fallbackModelSelection.model)
276381 ? { isUnavailable: true }
277382 : {}),
apps/mobile/src/state/use-thread-composer-state.ts+10-5
......@@ -39,7 +39,10 @@ import { composerContextSendBlockReason, reidentifyComposerContext } from "../li
3939import { uuidv4 } from "../lib/uuid";
4040
4141import { makeQueuedMessageMetadata } from "../lib/commandMetadata";
42import { isModelSelectionUnavailable } from "../lib/modelOptions";
42import {
43 describeUnavailableModelSelection,
44 isModelSelectionUnavailable,
45} from "../lib/modelOptions";
4346import { resolveProviderInteractionMode } from "./legacy-plan-mode";
4447import {
4548 convertPastedImagesToAttachments,
......@@ -616,12 +619,14 @@ export function useThreadComposerState() {
616619 const serverConfig = selectedEnvironmentRuntime?.serverConfig;
617620 if (
618621 selectedEnvironmentRuntime?.connectionState === "connected" &&
619 isModelSelectionUnavailable(serverConfig, modelSelection)
622 isModelSelectionUnavailable(serverConfig, modelSelection, thread.projectId)
620623 ) {
621 Alert.alert(
622 "Antigravity model unavailable",
623 "Set up Antigravity on web or desktop, or choose another model.",
624 const { title, detail } = describeUnavailableModelSelection(
625 serverConfig,
626 modelSelection,
627 thread.projectId,
624628 );
629 Alert.alert(title, detail);
625630 return null;
626631 }
627632 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"
2626import { serializeComposerMessageForServer, uploadedComposerContext } from "../lib/composerContext";
2727import { prepareTurnAttachments, type PreparedTurnAttachments } from "../lib/attachmentUpload";
2828import { randomHex } from "../lib/uuid";
29import { isModelSelectionUnavailable } from "../lib/modelOptions";
29import {
30 describeUnavailableModelSelection,
31 isModelSelectionUnavailable,
32} from "../lib/modelOptions";
3033import {
3134 retainAcknowledgedThreadMessage,
3235 forgetAcknowledgedThreadMessage,
......@@ -811,11 +814,13 @@ export function useThreadOutboxDrain(): void {
811814 );
812815 if (!serverConfig) return false;
813816 const settings = resolveQueuedThreadSettings(queuedMessage, thread, serverConfig.providers);
814 if (isModelSelectionUnavailable(serverConfig, settings.modelSelection)) {
815 return restoreQueuedMessage(
816 queuedMessage,
817 "Antigravity model unavailable. Set it up on web or desktop, or choose another model.",
817 if (isModelSelectionUnavailable(serverConfig, settings.modelSelection, thread.projectId)) {
818 const { title, detail } = describeUnavailableModelSelection(
819 serverConfig,
820 settings.modelSelection,
821 thread.projectId,
818822 );
823 return restoreQueuedMessage(queuedMessage, `${title}. ${detail}`);
819824 }
820825 const { reportFailure } = makeDeliveryHelpers(queuedMessage);
821826
......@@ -893,11 +898,13 @@ export function useThreadOutboxDrain(): void {
893898 serverEnvironment.configValueAtom(queuedMessage.environmentId),
894899 );
895900 if (!currentConfig) return false;
896 if (isModelSelectionUnavailable(currentConfig, settings.modelSelection)) {
897 return restoreQueuedMessage(
898 persistedMessage,
899 "Antigravity model unavailable. Set it up on web or desktop, or choose another model.",
901 if (isModelSelectionUnavailable(currentConfig, settings.modelSelection, thread.projectId)) {
902 const { title, detail } = describeUnavailableModelSelection(
903 currentConfig,
904 settings.modelSelection,
905 thread.projectId,
900906 );
907 return restoreQueuedMessage(persistedMessage, `${title}. ${detail}`);
901908 }
902909 const sendSettings = resolveQueuedThreadSettings(
903910 queuedMessage,
......@@ -990,11 +997,13 @@ export function useThreadOutboxDrain(): void {
990997 },
991998 serverConfig.providers,
992999 );
993 if (isModelSelectionUnavailable(serverConfig, settings.modelSelection)) {
994 return restoreQueuedMessage(
995 queuedMessage,
996 "Antigravity model unavailable. Set it up on web or desktop, or choose another model.",
1000 if (isModelSelectionUnavailable(serverConfig, settings.modelSelection, creation.projectId)) {
1001 const { title, detail } = describeUnavailableModelSelection(
1002 serverConfig,
1003 settings.modelSelection,
1004 creation.projectId,
9971005 );
1006 return restoreQueuedMessage(queuedMessage, `${title}. ${detail}`);
9981007 }
9991008 let prepared: PreparedTurnAttachments;
10001009 let persistedMessage: QueuedThreadMessage;
......@@ -1035,11 +1044,13 @@ export function useThreadOutboxDrain(): void {
10351044 serverEnvironment.configValueAtom(queuedMessage.environmentId),
10361045 );
10371046 if (!currentConfig) return false;
1038 if (isModelSelectionUnavailable(currentConfig, settings.modelSelection)) {
1039 return restoreQueuedMessage(
1040 persistedMessage,
1041 "Antigravity model unavailable. Set it up on web or desktop, or choose another model.",
1047 if (isModelSelectionUnavailable(currentConfig, settings.modelSelection, creation.projectId)) {
1048 const { title, detail } = describeUnavailableModelSelection(
1049 currentConfig,
1050 settings.modelSelection,
1051 creation.projectId,
10421052 );
1053 return restoreQueuedMessage(persistedMessage, `${title}. ${detail}`);
10431054 }
10441055 const sendSettings = resolveQueuedThreadSettings(
10451056 queuedMessage,
apps/server/src/cli/project.test.ts+3
......@@ -25,6 +25,7 @@ import * as References from "effect/References";
2525import * as Stream from "effect/Stream";
2626import { Command } from "effect/cli";
2727
28import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
2829import { cli } from "../binCli.ts";
2930import * as ServerConfig from "../config.ts";
3031import * as EventSink from "../orchestration-v2/EventSink.ts";
......@@ -37,6 +38,7 @@ import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
3738import * as ProjectService from "../project/ProjectService.ts";
3839import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
3940import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts";
41import * as ServerSettings from "../serverSettings.ts";
4042import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
4143import {
4244 ProjectLiveServerDeclaredResponseError,
......@@ -93,6 +95,7 @@ const readProjects = (baseDir: string) =>
9395 Layer.provideMerge(ProjectFaviconResolver.layer),
9496 Layer.provideMerge(T3ProjectFileLoader.layer),
9597 Layer.provideMerge(WorkspacePaths.layer),
98 Layer.provideMerge(ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer))),
9699 Layer.provideMerge(SqlitePersistence.layerConfig),
97100 Layer.provideMerge(NodeServices.layer),
98101 Layer.provide(ServerConfig.layer(config)),
apps/server/src/cli/project.ts+3
......@@ -22,6 +22,7 @@ import { FetchHttpClient, HttpClient, HttpClientError } from "effect/http";
2222import * as HttpApiClient from "effect/http-api/HttpApiClient";
2323
2424import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
25import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
2526
2627import * as ServerConfig from "../config.ts";
2728import * as SqlitePersistence from "../persistence/Sqlite.ts";
......@@ -32,6 +33,7 @@ import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolv
3233import * as ProjectService from "../project/ProjectService.ts";
3334import { projectMutationOperation } from "../project/ProjectMutation.ts";
3435import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts";
36import * as ServerSettings from "../serverSettings.ts";
3537import {
3638 clearPersistedServerRuntimeState,
3739 readPersistedServerRuntimeState,
......@@ -206,6 +208,7 @@ const layerProjectCliRuntime = RuntimeLayer.layerProjectService.pipe(
206208 ),
207209 ),
208210 Layer.provideMerge(WorkspacePaths.layer),
211 Layer.provideMerge(ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer))),
209212 Layer.provideMerge(SqlitePersistence.layerConfig),
210213);
211214
apps/server/src/environment/ServerEnvironment.test.ts+1
......@@ -222,6 +222,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
222222 expect(second.capabilities.threadPullRequests).toBe(true);
223223 expect(second.capabilities.threadPullRequestLinking).toBe(true);
224224 expect(second.capabilities.serverResolvedCommandContext).toBe(true);
225 expect(second.capabilities.projectProviderInstanceOverrides).toBe(true);
225226 expect(second.capabilities.agentActivityPublishing).toBe(false);
226227 }),
227228 );
apps/server/src/environment/ServerEnvironment.ts+1
......@@ -234,6 +234,7 @@ export const make = Effect.gen(function* () {
234234 worktreesDirectory: true,
235235 threadRestartContinuation: true,
236236 projectSettingsOverrides: true,
237 projectProviderInstanceOverrides: true,
237238 threadSnooze: true,
238239 environmentThemes: true,
239240 usageLimitSources: true,
apps/server/src/git/GitManager.ts+7-5
......@@ -731,7 +731,8 @@ export const make = Effect.gen(function* () {
731731 readonly threadId?: ThreadId | undefined;
732732 }) {
733733 const settings = yield* serverSettingsService.getSettings;
734 if (!hasProjectSettingsOverrides(settings)) return settings;
734 if (!hasProjectSettingsOverrides(settings))
735 return { settings, projectId: null as ProjectId | null };
735736 const projectId: ProjectId | null = yield* input.threadId !== undefined
736737 ? threads.getThreadShell(input.threadId).pipe(
737738 Effect.map((thread) => thread?.projectId ?? null),
......@@ -741,7 +742,7 @@ export const make = Effect.gen(function* () {
741742 Effect.map((project) => Option.getOrNull(project)?.projectId ?? null),
742743 Effect.orElseSucceed(() => null),
743744 );
744 return resolveProjectSettings(settings, projectId).settings;
745 return { settings: resolveProjectSettings(settings, projectId).settings, projectId };
745746 });
746747 // Best effort: a settings read failure falls back to the default location.
747748 const readWorktreesDirectory = serverSettingsService.getSettings.pipe(
......@@ -755,7 +756,7 @@ export const make = Effect.gen(function* () {
755756 options?.submodules !== undefined
756757 ? options.submodules
757758 : yield* projectSettingsFor(input).pipe(
758 Effect.map((settings) => settings.worktreeSubmodules),
759 Effect.map(({ settings }) => settings.worktreeSubmodules),
759760 Effect.orElseSucceed(() => null),
760761 );
761762 const worktreesDirectory = yield* readWorktreesDirectory;
......@@ -2798,7 +2799,7 @@ export const make = Effect.gen(function* () {
27982799 worktreesDirectory: yield* readWorktreesDirectory,
27992800 // Best effort: a settings read failure falls back to the checkout's t3.json.
28002801 submodules: yield* projectSettingsFor(input).pipe(
2801 Effect.map((settings) => settings.worktreeSubmodules),
2802 Effect.map(({ settings }) => settings.worktreeSubmodules),
28022803 Effect.orElseSucceed(() => null),
28032804 ),
28042805 },
......@@ -2910,7 +2911,7 @@ export const make = Effect.gen(function* () {
29102911 let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined;
29112912
29122913 const textGenerationSettings = yield* projectSettingsFor(input).pipe(
2913 Effect.flatMap((settings) =>
2914 Effect.flatMap(({ settings, projectId }) =>
29142915 settings.sourceControlWriterModelSelection === null
29152916 ? Effect.succeed({
29162917 modelSelection: settings.textGenerationModelSelection,
......@@ -2921,6 +2922,7 @@ export const make = Effect.gen(function* () {
29212922 modelSelection: ServerSettings.resolveSourceControlWriterModelSelection(
29222923 settings,
29232924 providers,
2925 projectId,
29242926 ),
29252927 style: settings.sourceControlWritingStyle,
29262928 })),
apps/server/src/mcp/McpToolAccess.race.test.ts+18-2
......@@ -17,10 +17,12 @@ import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai";
1717import * as SqlitePersistence from "../persistence/Sqlite.ts";
1818import { DispatchModeLimit } from "../orchestration-v2/DispatchModeLimit.ts";
1919import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts";
20import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts";
2021import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
2122import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
2223import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";
2324import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts";
25import * as ServerSettings from "../serverSettings.ts";
2426import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts";
2527import * as McpHttpServer from "./McpHttpServer.ts";
2628import * as McpInvocationContext from "./McpInvocationContext.ts";
......@@ -184,7 +186,14 @@ it.layer(layerOrchestrator)("writesThreads against a mode raise", (it) => {
184186 const shell = yield* projections.getThreadShell(threadId);
185187 assert.equal(shell?.runtimeMode, "full-access");
186188 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 ),
188197 );
189198
190199 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) => {
195204 const outcome = yield* renameRacingTheUser(threadId, false);
196205 assert.deepEqual(outcome, { renamed: true });
197206 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 ),
199215 );
200216});
apps/server/src/mcp/OrchestratorMcpService.test.ts+289
......@@ -1099,6 +1099,8 @@ describe("OrchestratorMcpService provider resolution", () => {
10991099 const layerDependencies = Layer.mergeAll(
11001100 NodeServices.layer,
11011101 Layer.mock(ThreadManagementService.ThreadManagementService)({
1102 hasReceipt: () => Effect.succeed(false),
1103 assertProviderInstanceEnabled: () => Effect.void,
11021104 getThreadRecords: (threadId) =>
11031105 Effect.succeed(
11041106 threadId === parentThreadId
......@@ -1197,6 +1199,9 @@ describe("OrchestratorMcpService provider resolution", () => {
11971199 const layerDependencies = Layer.mergeAll(
11981200 NodeServices.layer,
11991201 Layer.mock(ThreadManagementService.ThreadManagementService)({
1202 hasReceipt: () => Effect.succeed(false),
1203 assertProviderInstanceEnabled: () => Effect.void,
1204 isProviderInstanceEnabledForProject: () => Effect.succeed(true),
12001205 getThreadRecords: (threadId) =>
12011206 Effect.succeed(
12021207 threadId === parentThreadId
......@@ -1275,6 +1280,7 @@ describe("OrchestratorMcpService provider resolution", () => {
12751280 const layerDependencies = Layer.mergeAll(
12761281 NodeServices.layer,
12771282 Layer.mock(ThreadManagementService.ThreadManagementService)({
1283 hasReceipt: () => Effect.succeed(false),
12781284 getThreadRecords: () => Effect.succeed(parentProjection([])),
12791285 }),
12801286 ServerSettings.layerTest(),
......@@ -1321,6 +1327,284 @@ describe("OrchestratorMcpService provider resolution", () => {
13211327 }),
13221328 );
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
13241608 it.effect("re-probes an unavailable target once before refusing it", () =>
13251609 Effect.gen(function* () {
13261610 const claudeInstanceId = ProviderInstanceId.make("claudeAgent");
......@@ -1368,6 +1652,8 @@ describe("OrchestratorMcpService provider resolution", () => {
13681652 const layerDependencies = Layer.mergeAll(
13691653 NodeServices.layer,
13701654 Layer.mock(ThreadManagementService.ThreadManagementService)({
1655 hasReceipt: () => Effect.succeed(false),
1656 assertProviderInstanceEnabled: () => Effect.void,
13711657 getThreadRecords: (threadId) =>
13721658 Effect.succeed(
13731659 threadId === parentThreadId ? parentProjection([task]) : childProjection,
......@@ -1513,6 +1799,9 @@ describe("OrchestratorMcpService provider resolution", () => {
15131799 const layerDependencies = Layer.mergeAll(
15141800 NodeServices.layer,
15151801 Layer.mock(ThreadManagementService.ThreadManagementService)({
1802 hasReceipt: () => Effect.succeed(false),
1803 assertProviderInstanceEnabled: () => Effect.void,
1804 isProviderInstanceEnabledForProject: () => Effect.succeed(true),
15161805 getThreadRecords: (threadId) =>
15171806 Effect.succeed(
15181807 threadId === parentThreadId
apps/server/src/mcp/OrchestratorMcpService.ts+77-19
......@@ -215,6 +215,8 @@ function threadManagementFailure(error: unknown): OrchestratorMcpFailure {
215215 case "ThreadManagementProjectThreadsListError":
216216 case "ThreadManagementDurableRunProjectionError":
217217 return failure("orchestration_error", error.message);
218 case "ThreadManagementProviderInstanceDisabledError":
219 return failure("invalid_request", error.message);
218220 }
219221}
220222
......@@ -1135,6 +1137,19 @@ const make = Effect.gen(function* () {
11351137 * unattended agent can see a provider as unavailable after it was fixed.
11361138 * Re-probe the requested instance once before refusing it.
11371139 */
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
11381153 const resolveTargetRechecking = (input: Parameters<typeof resolveTarget>[0]) => {
11391154 const instanceId =
11401155 input.target?.providerInstanceId ??
......@@ -1158,6 +1173,16 @@ const make = Effect.gen(function* () {
11581173 readonly parent: Pick<OrchestrationV2ThreadProjection, "thread">;
11591174 readonly target: OrchestratorMcpTarget | undefined;
11601175 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;
11611186 }): Effect.Effect<ResolvedTarget, OrchestratorMcpFailure> =>
11621187 Effect.gen(function* () {
11631188 const requestedInstanceId = input.target?.providerInstanceId;
......@@ -1179,20 +1204,37 @@ const make = Effect.gen(function* () {
11791204 }
11801205 // Inherit the parent's instance only when it can actually serve the
11811206 // child; an unavailable parent yields to a healthy instance of the
1182 // requested driver rather than failing the delegation.
1183 const inheritedCandidate = candidates.find(
1184 (candidate) =>
1185 candidate.instanceId === input.parent.thread.modelSelection.instanceId &&
1186 providerConstraints(candidate, true).length === 0,
1187 );
1188 const availableCandidate = candidates.find(
1207 // requested driver rather than failing the delegation. An instance
1208 // the project turned off cannot serve either, so selection skips it
1209 // for the next candidate instead of failing the enablement check.
1210 const healthyCandidates = candidates.filter(
11891211 (candidate) => providerConstraints(candidate, true).length === 0,
11901212 );
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 }
11921232 if (instanceId === undefined) {
11931233 return yield* failure(
11941234 "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.`,
11961238 );
11971239 }
11981240 }
......@@ -1925,10 +1967,22 @@ const make = Effect.gen(function* () {
19251967 );
19261968 }
19271969 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);
19281981 const target = yield* resolveTargetRechecking({
19291982 parent,
19301983 target: input.target,
19311984 providers,
1985 skipProjectEnablementFilter: isRetryOfAcceptedDelegation,
19321986 });
19331987 const runtimeMode = yield* resolveRuntimeMode(
19341988 parent.thread.runtimeMode,
......@@ -1939,12 +1993,9 @@ const make = Effect.gen(function* () {
19391993 parent.thread.interactionMode,
19401994 input.interactionMode,
19411995 );
1942 const key = yield* requestKey(input.clientRequestId);
1943 const commandId = stableCommandId({
1944 scope,
1945 requestKey: key,
1946 operation: "delegate-task",
1947 });
1996 // ThreadManagementService.dispatch is the chokepoint for this command
1997 // type's own enablement check, using the same command id and its
1998 // receipt-replay exemption, so no separate assert runs here.
19481999 const result = yield* threadManagement
19492000 .dispatch({
19502001 type: "delegated_task.request",
......@@ -1966,10 +2017,12 @@ const make = Effect.gen(function* () {
19662017 })
19672018 .pipe(
19682019 Effect.mapError((error) =>
1969 failure(
1970 "orchestration_error",
1971 `Unable to create delegated task: ${errorMessage(error)}`,
1972 ),
2020 error._tag === "ThreadManagementProviderInstanceDisabledError"
2021 ? threadManagementFailure(error)
2022 : failure(
2023 "orchestration_error",
2024 `Unable to create delegated task: ${errorMessage(error)}`,
2025 ),
19732026 ),
19742027 );
19752028 const taskEvent = result.storedEvents.find(
......@@ -2230,6 +2283,11 @@ const make = Effect.gen(function* () {
22302283 requestKey: key,
22312284 index,
22322285 });
2286 yield* assertTargetEnabledForProject(
2287 parent,
2288 stableCommandId({ scope, requestKey: key, operation: "create-thread", index }),
2289 target,
2290 );
22332291 const title = threadTitle({
22342292 parentTitle: parent.thread.title,
22352293 prompt: request.prompt,
apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts+17-3
......@@ -54,9 +54,12 @@ import { ClaudeProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/Claud
5454import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts";
5555import { CodexOrchestratorReplayHarness } from "../orchestration-v2/Adapters/CodexAdapterV2.testkit.ts";
5656import { threadShellFromProjection } from "../orchestration-v2/ProjectionStore.ts";
57import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts";
5758import * as EventSink from "../orchestration-v2/EventSink.ts";
5859import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
5960import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
61import * as ServerSettings from "../serverSettings.ts";
62import * as SqlitePersistence from "../persistence/Sqlite.ts";
6063import * as ThreadSearch from "../orchestration-v2/ThreadSearch.ts";
6164import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";
6265import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests";
......@@ -70,7 +73,6 @@ import {
7073import * as ProviderRegistryMock from "../provider/testUtils/providerRegistryMock.ts";
7174import * as ProjectService from "../project/ProjectService.ts";
7275import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
73import * as ServerSettings from "../serverSettings.ts";
7476import * as SecretRequests from "../secrets/SecretRequests.ts";
7577import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
7678import * as McpHttpServer from "./McpHttpServer.ts";
......@@ -612,7 +614,13 @@ describe("orchestrator MCP toolkit", () => {
612614 ).pipe(Layer.provide(layerContinuationProbe));
613615 const layerOrchestration = Layer.merge(
614616 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 ),
616624 );
617625 const layerProviderRegistry = ProviderRegistryMock.layer([
618626 makeProviderSnapshot({
......@@ -3809,7 +3817,13 @@ describe("orchestrator MCP toolkit", () => {
38093817 );
38103818 const layerOrchestration = Layer.merge(
38113819 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 ),
38133827 );
38143828 const layerProviderRegistry = ProviderRegistryMock.layer([
38153829 makeProviderSnapshot({
apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts+10-2
......@@ -22,18 +22,20 @@ import * as Queue from "effect/Queue";
2222import * as Stream from "effect/Stream";
2323
2424import { ClaudeProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts";
25import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts";
2526import * as EffectWorker from "../orchestration-v2/EffectWorker.ts";
2627import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
2728import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
2829import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";
2930import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
31import * as ServerSettings from "../serverSettings.ts";
3032import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts";
3133import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace";
34import * as SqlitePersistence from "../persistence/Sqlite.ts";
3235import * as ProjectService from "../project/ProjectService.ts";
3336import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
3437import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
3538import * as SecretRequests from "../secrets/SecretRequests.ts";
36import * as ServerSettings from "../serverSettings.ts";
3739import type { McpInvocationScope } from "./McpInvocationContext.ts";
3840import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
3941
......@@ -133,7 +135,13 @@ it.effect("task_cancel ends a delegated child's background native subagent", ()
133135 );
134136 const layerOrchestration = Layer.merge(
135137 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 ),
137145 );
138146 const layerTest = OrchestratorMcpService.layer.pipe(
139147 Layer.provideMerge(layerOrchestration),
apps/server/src/mcp/threadAccess.ts+14-10
......@@ -23,16 +23,20 @@ export const unavailable = () =>
2323 });
2424
2525/** Decider string rejections are public; wrapped storage and hydration causes are not. */
26export const dispatchFailure = (error: OrchestratorV2Error) =>
27 (error._tag === "OrchestratorDispatchError" ||
28 error._tag === "OrchestratorCommandRejectedError") &&
29 typeof error.cause === "string" &&
30 error.cause.length > 0
31 ? new OrchestratorMcpFailure({
32 code: "orchestration_error",
33 message: Array.from(error.cause).slice(0, 1000).join(""),
34 })
35 : unavailable();
26export const dispatchFailure = (
27 error: OrchestratorV2Error | ThreadManagement.ThreadManagementProviderInstanceDisabledError,
28) =>
29 error._tag === "ThreadManagementProviderInstanceDisabledError"
30 ? new OrchestratorMcpFailure({ code: "invalid_request", message: error.message })
31 : (error._tag === "OrchestratorDispatchError" ||
32 error._tag === "OrchestratorCommandRejectedError") &&
33 typeof error.cause === "string" &&
34 error.cause.length > 0
35 ? new OrchestratorMcpFailure({
36 code: "orchestration_error",
37 message: Array.from(error.cause).slice(0, 1000).join(""),
38 })
39 : unavailable();
3640
3741/**
3842 * 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";
1919import * as ThreadLaunch from "../../../orchestration-v2/ThreadLaunchService.ts";
2020import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts";
2121import * as ServerConfig from "../../../config.ts";
22import * as ServerSettings from "../../../serverSettings.ts";
2223import * as Project from "../../../project/ProjectService.ts";
2324import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts";
2425import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts";
......@@ -47,6 +48,7 @@ it.effect("attributes a launched thread's first message to the calling thread",
4748 } as OrchestrationV2ThreadShell;
4849 let launchedSender: ThreadId | undefined;
4950 const layerDependencies = Layer.mergeAll(
51 ServerSettings.layerTest(),
5052 NodeCrypto.layer,
5153 Layer.succeed(McpInvocationContext.McpInvocationContext, {
5254 environmentId: EnvironmentId.make("environment"),
......@@ -118,6 +120,7 @@ it.effect("launches a scratch thread into the Scratch project", () =>
118120 } as OrchestrationV2ThreadShell;
119121 const launched: Array<ThreadLaunch.ThreadLaunchInput> = [];
120122 const layerDependencies = Layer.mergeAll(
123 ServerSettings.layerTest(),
121124 NodeCrypto.layer,
122125 Layer.succeed(McpInvocationContext.McpInvocationContext, {
123126 environmentId: EnvironmentId.make("environment"),
......@@ -215,6 +218,7 @@ it.effect("starts a project from just a title when workspaceRoot is omitted", ()
215218 deletedAt: null,
216219 };
217220 const layerDependencies = Layer.mergeAll(
221 ServerSettings.layerTest(),
218222 NodeCrypto.layer,
219223 Layer.succeed(McpInvocationContext.McpInvocationContext, {
220224 environmentId: EnvironmentId.make("environment"),
......@@ -304,8 +308,15 @@ const clientLaunchHarness = (input: {
304308 readonly checkWorktreeBase?: ThreadLaunch.ThreadLaunchService["Service"]["checkWorktreeBase"];
305309}) => {
306310 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 };
308318 const layerDependencies = Layer.mergeAll(
319 ServerSettings.layerTest(),
309320 NodeCrypto.layer,
310321 Layer.succeed(McpInvocationContext.McpInvocationContext, {
311322 environmentId: EnvironmentId.make("environment"),
apps/server/src/mcp/toolkits/project/handlers.ts+15-5
......@@ -157,11 +157,21 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
157157 createdBy: "agent",
158158 creationSource: "mcp",
159159 }).pipe(
160 Effect.mapError((error) =>
161 error._tag === "AttachmentClaimError"
162 ? new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message })
163 : unavailable(),
164 ),
160 Effect.mapError((error) => {
161 if (error._tag === "AttachmentClaimError") {
162 return new OrchestratorMcpFailure({
163 code: "orchestration_error",
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 }),
165175 );
166176 const thread = result.projection.thread;
167177 const run = result.projection.runs.find((run) => run.userMessageId === messageId);
apps/server/src/mcp/toolkits/project/tools.ts+2
......@@ -19,6 +19,7 @@ import {
1919} from "@t3tools/contracts";
2020import * as FileSystem from "effect/FileSystem";
2121import * as ServerConfig from "../../../config.ts";
22import * as ServerSettings from "../../../serverSettings.ts";
2223import * as ThreadLaunchService from "../../../orchestration-v2/ThreadLaunchService.ts";
2324import * as Crypto from "effect/Crypto";
2425import * as Schema from "effect/Schema";
......@@ -144,6 +145,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", {
144145 GitVcsDriver.GitVcsDriver,
145146 FileSystem.FileSystem,
146147 ServerConfig.ServerConfig,
148 ServerSettings.ServerSettingsService,
147149 ],
148150})
149151 .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";
2929import * as Scheduler from "../scheduling/Scheduler.ts";
3030import * as SqlitePersistence from "../persistence/Sqlite.ts";
3131import * as ClaudeAdapterV2 from "./Adapters/ClaudeAdapterV2.ts";
32import * as CommandReceiptStore from "./CommandReceiptStore.ts";
3233import * as EffectWorker from "./EffectWorker.ts";
3334import * as EventSink from "./EventSink.ts";
3435import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
......@@ -38,6 +39,7 @@ import * as Orchestrator from "./Orchestrator.ts";
3839import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";
3940import * as ThreadLaunchService from "./ThreadLaunchService.ts";
4041import * as ThreadManagementService from "./ThreadManagementService.ts";
42import * as ServerSettings from "../serverSettings.ts";
4143import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts";
4244import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace";
4345
......@@ -313,6 +315,7 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con
313315 Effect.provide(
314316 ScheduledTaskService.layer.pipe(
315317 Layer.provide(ThreadManagementService.layer),
318 Layer.provide(ServerSettings.layerTest()),
316319 Layer.provide(
317320 Layer.mock(LegacyV1ThreadImporter.LegacyV1ThreadImporter)({
318321 ensureTranscript: () =>
......@@ -321,6 +324,9 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con
321324 ),
322325 Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})),
323326 Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
327 Layer.provide(
328 CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)),
329 ),
324330 Layer.provide(
325331 Layer.mergeAll(
326332 NodeCrypto.layer,
apps/server/src/orchestration-v2/EffectWorker.ts+55-35
......@@ -256,41 +256,61 @@ export const layerExecutor: Layer.Layer<
256256 if (message === undefined || run === undefined) return yield* error;
257257 // Reuse the message identity and a stable command receipt so an outbox
258258 // retry cannot append a duplicate message or start a second follow-up.
259 yield* threads.dispatch({
260 type: "message.dispatch",
261 commandId: CommandId.make(`command:steer-follow-up:${effect.id}`),
262 threadId: effect.threadId,
263 messageId: message.id,
264 text: message.text,
265 ...(message.context ? { context: message.context } : {}),
266 attachments: message.attachments,
267 // A user's follow-up starts on the thread's saved selection,
268 // which already holds the steer's choice. A delegated
269 // completion stays pinned to the run it reports to.
270 ...(message.delegatedCompletion === undefined
271 ? {}
272 : { modelSelection: run.modelSelection }),
273 dispatchMode: {
274 type:
275 message.delegatedCompletion === undefined
276 ? "start_immediately"
277 : "queue_after_active",
278 },
279 createdBy: message.createdBy,
280 creationSource: message.creationSource,
281 ...(message.delegatedCompletion === undefined
282 ? {}
283 : { delegatedCompletion: message.delegatedCompletion }),
284 ...(message.notification === undefined
285 ? {}
286 : { notification: message.notification }),
287 ...(message.scheduledTaskId === undefined
288 ? {}
289 : { scheduledTaskId: message.scheduledTaskId }),
290 ...(message.senderThreadId === undefined
291 ? {}
292 : { senderThreadId: message.senderThreadId }),
293 });
259 yield* threads
260 .dispatch({
261 type: "message.dispatch",
262 commandId: CommandId.make(`command:steer-follow-up:${effect.id}`),
263 threadId: effect.threadId,
264 messageId: message.id,
265 text: message.text,
266 ...(message.context ? { context: message.context } : {}),
267 attachments: message.attachments,
268 // A user's follow-up starts on the thread's saved selection,
269 // which already holds the steer's choice. A delegated
270 // completion stays pinned to the run it reports to.
271 ...(message.delegatedCompletion === undefined
272 ? {}
273 : { modelSelection: run.modelSelection }),
274 dispatchMode: {
275 type:
276 message.delegatedCompletion === undefined
277 ? "start_immediately"
278 : "queue_after_active",
279 },
280 createdBy: message.createdBy,
281 creationSource: message.creationSource,
282 ...(message.delegatedCompletion === undefined
283 ? {}
284 : { delegatedCompletion: message.delegatedCompletion }),
285 ...(message.notification === undefined
286 ? {}
287 : { notification: message.notification }),
288 ...(message.scheduledTaskId === undefined
289 ? {}
290 : { scheduledTaskId: message.scheduledTaskId }),
291 ...(message.senderThreadId === undefined
292 ? {}
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 );
294314 }),
295315 ),
296316 Effect.mapError(
apps/server/src/orchestration-v2/Orchestrator.ts+25
......@@ -53,6 +53,7 @@ import {
5353 orchestrationV2RunWorkStartedAt,
5454 ProviderInstanceId,
5555 ProviderInteractionMode,
56 resolveProviderInstanceEnabled,
5657 type ProviderSessionId,
5758 RunId,
5859 RuntimeMode,
......@@ -131,6 +132,7 @@ import {
131132} from "./ThreadForkService.ts";
132133import { planThreadDeletion } from "./ThreadDeletion.ts";
133134import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
135import * as ServerSettings from "../serverSettings.ts";
134136
135137export class OrchestratorDispatchError extends Schema.TaggedError<OrchestratorDispatchError>()(
136138 "OrchestratorDispatchError",
......@@ -832,6 +834,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
832834 const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests;
833835 const providerSessions = yield* ProviderSessionManagerV2;
834836 const providerSwitchService = yield* ProviderSwitchServiceV2;
837 const serverSettings = yield* ServerSettings.ServerSettingsService;
835838 const runtimePolicy = yield* RuntimePolicyV2;
836839 const threadForkService = yield* ThreadForkServiceV2;
837840 const threadDispatch = yield* ThreadCommandExecutor.ThreadCommandExecutor;
......@@ -1318,8 +1321,29 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
13181321 failedRun?.id === options?.endedRunId &&
13191322 failedRun?.workspacePreparation?.type === "worktree" &&
13201323 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);
13211344 if (
13221345 worktreeMissing ||
1346 instanceDisabledForProject ||
13231347 (failureClass !== undefined &&
13241348 failureClass !== "validation_error" &&
13251349 failedRun?.providerInstanceId === queuedRun.providerInstanceId)
......@@ -11437,6 +11461,7 @@ export const layer: Layer.Layer<
1143711461 | ProviderSwitchServiceV2
1143811462 | ProjectionStoreV2
1143911463 | RuntimePolicyV2
11464 | ServerSettings.ServerSettingsService
1144011465 | ThreadForkServiceV2
1144111466> = Layer.effect(OrchestratorV2, makeOrchestrator()).pipe(
1144211467 Layer.provide(ThreadCommandExecutor.layer),
apps/server/src/orchestration-v2/ProviderContinuationService.test.ts+188
......@@ -1,7 +1,9 @@
11import { assert, describe, it } from "@effect/vitest";
22import {
33 MessageId,
4 ProjectId,
45 ProviderDriverKind,
6 ProviderInstanceId,
57 ProviderThreadId,
68 RunId,
79 ThreadId,
......@@ -11,6 +13,7 @@ import {
1113import * as Deferred from "effect/Deferred";
1214import * as DateTime from "effect/DateTime";
1315import * as Effect from "effect/Effect";
16import * as Exit from "effect/Exit";
1417import * as Layer from "effect/Layer";
1518import * as Option from "effect/Option";
1619import * as Queue from "effect/Queue";
......@@ -529,6 +532,191 @@ describe("ProviderContinuationService", () => {
529532 });
530533 });
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
532720 it.effect("keeps a Grok delegated completion queued instead of restarting active work", () => {
533721 return Effect.gen(function* () {
534722 const dispatched = yield* Queue.unbounded<unknown>();
apps/server/src/orchestration-v2/ProviderContinuationService.ts+102-18
......@@ -126,22 +126,77 @@ export const layer = Layer.effectDiscard(
126126 fixtureName: "delegated-completion",
127127 commandName: "dispatch",
128128 });
129 yield* threads.dispatch({
130 type: "message.dispatch",
131 commandId,
132 threadId: request.threadId,
133 messageId: delivery.messageId,
134 text: delegatedCompletionText(delivery.taskIds),
135 attachments: [],
136 dispatchMode: { type: "queue_after_active" },
137 createdBy: "agent",
138 creationSource: "server",
139 delegatedCompletion: {
140 parentRunId: request.delegatedCompletion.parentRunId,
141 generation: delivery.generation,
142 taskIds: delivery.taskIds,
143 },
144 });
129 yield* threads
130 .dispatch({
131 type: "message.dispatch",
132 commandId,
133 threadId: request.threadId,
134 messageId: delivery.messageId,
135 text: delegatedCompletionText(delivery.taskIds),
136 attachments: [],
137 dispatchMode: { type: "queue_after_active" },
138 createdBy: "agent",
139 creationSource: "server",
140 delegatedCompletion: {
141 parentRunId: request.delegatedCompletion.parentRunId,
142 generation: delivery.generation,
143 taskIds: delivery.taskIds,
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 );
145200 yield* clearRetryAttempt(retryKey);
146201 return;
147202 }
......@@ -174,11 +229,40 @@ export const layer = Layer.effectDiscard(
174229 // marker or the turn settles immediately having prompted nothing.
175230 creationSource: request.delivery === "message_text" ? "server" : "provider",
176231 });
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 );
177253 if (request.dispatchIfCurrent === undefined) {
178 yield* dispatch;
254 yield* dispatch.pipe(
255 Effect.catchTags({
256 ThreadManagementProviderInstanceDisabledError: logInstanceDisabled,
257 }),
258 );
179259 return;
180260 }
181 yield* request.dispatchIfCurrent(dispatch);
261 yield* request.dispatchIfCurrent(dispatch).pipe(
262 Effect.catchTags({
263 ThreadManagementProviderInstanceDisabledError: logInstanceDisabled,
264 }),
265 );
182266 },
183267 );
184268
apps/server/src/orchestration-v2/RestartContinuation.ts+39-22
......@@ -159,28 +159,45 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta
159159 : note.settled
160160 ? noteText
161161 : `${noteText}\n\n${CONTINUE_PROMPT}`;
162 yield* threads.dispatch({
163 type: "message.dispatch",
164 commandId: CommandId.make(`command:restart-continuation:${input.sourceRunId}`),
165 threadId: input.threadId,
166 messageId,
167 text,
168 // Shown as a work log row; the prompt the agent got is its detail.
169 notification: {
170 source: { kind: "system" },
171 outcome: "updated",
172 summary: note.settled
173 ? "T3 Code restarted and stopped background work"
174 : "T3 Code restarted and resumed this turn",
175 detail: text,
176 },
177 attachments: [],
178 modelSelection: source.modelSelection,
179 dispatchMode: { type: "start_immediately" },
180 createdBy: "agent",
181 creationSource: "server",
182 restartContinuationOfRunId: input.sourceRunId,
183 });
162 yield* threads
163 .dispatch({
164 type: "message.dispatch",
165 commandId: CommandId.make(`command:restart-continuation:${input.sourceRunId}`),
166 threadId: input.threadId,
167 messageId,
168 text,
169 // Shown as a work log row; the prompt the agent got is its detail.
170 notification: {
171 source: { kind: "system" },
172 outcome: "updated",
173 summary: note.settled
174 ? "T3 Code restarted and stopped background work"
175 : "T3 Code restarted and resumed this turn",
176 detail: text,
177 },
178 attachments: [],
179 modelSelection: source.modelSelection,
180 dispatchMode: { type: "start_immediately" },
181 createdBy: "agent",
182 creationSource: "server",
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 );
184201 },
185202 // A delegated child this declined to continue still owes its parent a
186203 // 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 = {}) {
118118 layerRegistry,
119119 { databaseLayer: layerDatabase, runEffectWorker: false },
120120 );
121 const layerThreadManagement = ThreadManagement.layer.pipe(Layer.provide(layerOrchestrator));
121 const layerServerSettings = ServerSettings.layerTest(options.serverSettings);
122122 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 );
123128 const layerOutbox = EffectOutbox.layer.pipe(Layer.provide(layerDatabase));
124129 const createWorktree = vi.fn(
125130 options.createWorktree ??
......@@ -185,7 +190,7 @@ function makeHarness(options: HarnessOptions = {}) {
185190 generateThreadTitle,
186191 generateBranchName,
187192 }),
188 ServerSettings.layerTest(options.serverSettings),
193 layerServerSettings,
189194 ProviderRegistryMock.layer(options.providers),
190195 options.managedFolders ??
191196 Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({
......@@ -1765,6 +1770,70 @@ it.effect("replays a server-allocated launch", () =>
17651770 }),
17661771);
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
17681837it.effect("rejects a server-allocated launch replay with a mismatching thread id", () => {
17691838 const harness = makeHarness();
17701839 return Effect.gen(function* () {
......@@ -1782,7 +1851,9 @@ it.effect("rejects a server-allocated launch replay with a mismatching thread id
17821851 })
17831852 .pipe(Effect.flip);
17841853 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 }
17861857 assert.equal(failed.operation, "create-thread");
17871858 assert.include(String(failed.cause), "cannot be replayed");
17881859 }).pipe(Effect.provide(harness.layer));
......@@ -1804,7 +1875,9 @@ it.effect("rejects a server-allocated launch receipt from another project", () =
18041875 projectId: otherProjectId,
18051876 })
18061877 .pipe(Effect.flip);
1807 assert.equal(failed._tag, "ThreadLaunchError");
1878 if (failed._tag !== "ThreadLaunchError") {
1879 assert.fail(`expected ThreadLaunchError, got ${failed._tag}`);
1880 }
18081881 assert.equal(failed.operation, "resolve-project");
18091882 assert.equal(failed.threadId, first.threadId);
18101883 assert.equal(failed.cause, "Project identity changed.");
......@@ -1828,7 +1901,9 @@ it.effect("rejects a server-allocated launch retry after the thread is deleted",
18281901 threadId: first.threadId,
18291902 });
18301903 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 }
18321907 assert.equal(failed.operation, "create-thread");
18331908 assert.equal(failed.threadId, first.threadId);
18341909 assert.equal(failed.cause, "Thread not found.");
......@@ -1869,7 +1944,9 @@ it.effect("does not treat an unrelated accepted command receipt as a launch", ()
18691944 message: "Should not become a launch",
18701945 });
18711946 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 }
18731950 assert.equal(failed.operation, "create-thread");
18741951 assert.include(String(failed.cause), "cannot be replayed");
18751952 const projection = yield* threads.getThreadProjection(threadId);
......@@ -1914,7 +1991,9 @@ it.effect("bounds concurrent first launches to one thread per command", () =>
19141991 continue;
19151992 }
19161993 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 }
19181997 assert.equal(error.operation, "create-thread");
19191998 assert.include(String(error.cause), "cannot be replayed");
19201999 const retried = yield* launches.launch(rest);
......@@ -2530,3 +2609,99 @@ it.effect.each([
25302609 }).pipe(Effect.provide(harness.layer));
25312610 }),
25322611);
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<
167167 {
168168 readonly launch: (
169169 input: ThreadLaunchInput,
170 ) => Effect.Effect<ThreadLaunchResult, ThreadLaunchError>;
170 ) => Effect.Effect<
171 ThreadLaunchResult,
172 ThreadLaunchError | ThreadManagement.ThreadManagementProviderInstanceDisabledError
173 >;
171174 /**
172175 * Fails when a new worktree's base ref cannot give provisioning a commit.
173176 * `launch` accepts such a launch and reports the failure inside the
......@@ -177,9 +180,13 @@ export class ThreadLaunchService extends Context.Service<
177180 input: Pick<ThreadLaunchInput, "projectId" | "workspaceStrategy">,
178181 ) => Effect.Effect<void, ThreadLaunchBaseRefError>;
179182 /** Dispatches prepared-run.retry and prepares the run's workspace again. */
180 readonly retryPreparation: (
181 input: ThreadLaunchRetryInput,
182 ) => Effect.Effect<Orchestrator.OrchestratorV2DispatchResult, Orchestrator.OrchestratorV2Error>;
183 readonly retryPreparation: (input: ThreadLaunchRetryInput) => Effect.Effect<
184 Orchestrator.OrchestratorV2DispatchResult,
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 >;
183190 }
184191>()("t3/orchestration-v2/ThreadLaunchService") {}
185192
......@@ -341,6 +348,7 @@ const make = Effect.gen(function* () {
341348 : ServerSettings.resolveSourceControlWriterModelSelection(
342349 settings,
343350 yield* providerRegistry.getProviders,
351 input.projectId,
344352 );
345353 return yield* textGeneration
346354 .generateBranchName({
......@@ -764,6 +772,11 @@ const make = Effect.gen(function* () {
764772 }
765773
766774 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 });
767780 return yield* Effect.gen(function* () {
768781 // A retried launch has no client-supplied id to replay against, so
769782 // 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";
3131import * as TestClock from "effect/testing/TestClock";
3232
3333import * 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";
3437import * as Orchestrator from "./Orchestrator.ts";
3538import * as ProviderSessionManager from "./ProviderSessionManager.ts";
3639import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
......@@ -273,6 +276,206 @@ it("derives thread management messages from structural error attributes", () =>
273276 );
274277});
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
276479it.effect("classifies projection infrastructure failures separately from a missing thread", () => {
277480 const projectId = ProjectId.make("project:thread-management:projection-failure");
278481 const threadId = ThreadId.make("thread:thread-management:projection-failure");
......@@ -288,6 +491,8 @@ it.effect("classifies projection infrastructure failures separately from a missi
288491 getThreadProjection: () => Effect.fail(projectionError),
289492 }),
290493 ),
494 Layer.provide(ServerSettings.layerTest()),
495 Layer.provide(layerReceiptsTest),
291496 );
292497
293498 return Effect.gen(function* () {
......@@ -322,6 +527,8 @@ it.effect("uses thread-not-found only after a projection loads outside the proje
322527 getThreadProjection: () => Effect.succeed(projection),
323528 }),
324529 ),
530 Layer.provide(ServerSettings.layerTest()),
531 Layer.provide(layerReceiptsTest),
325532 );
326533
327534 return Effect.gen(function* () {
......@@ -354,6 +561,8 @@ it.effect("preserves failed legacy materialization when reading checkpoint conte
354561 }),
355562 ),
356563 ),
564 Layer.provide(ServerSettings.layerTest()),
565 Layer.provide(layerReceiptsTest),
357566 );
358567 return Effect.gen(function* () {
359568 const service = yield* ThreadManagementService.ThreadManagementService;
......@@ -402,6 +611,8 @@ it.effect.each([
402611 }),
403612 }),
404613 ),
614 Layer.provide(ServerSettings.layerTest()),
615 Layer.provide(layerReceiptsTest),
405616 );
406617 const service = yield* ThreadManagementService.ThreadManagementService.pipe(
407618 Effect.provide(layerTest),
......@@ -473,6 +684,8 @@ it.effect("waitForThread reads the run again only when the run updates", () =>
473684 }),
474685 }),
475686 ),
687 Layer.provide(ServerSettings.layerTest()),
688 Layer.provide(layerReceiptsTest),
476689 );
477690 const service = yield* ThreadManagementService.ThreadManagementService.pipe(
478691 Effect.provide(layerTest),
......@@ -617,6 +830,8 @@ it.effect.each([
617830 }),
618831 ),
619832 ),
833 Layer.provide(ServerSettings.layerTest()),
834 Layer.provide(layerReceiptsTest),
620835 );
621836 yield* Effect.gen(function* () {
622837 const service = yield* ThreadManagementService.ThreadManagementService;
......@@ -735,6 +950,8 @@ it.effect.each([
735950 }),
736951 }),
737952 ),
953 Layer.provide(ServerSettings.layerTest()),
954 Layer.provide(layerReceiptsTest),
738955 );
739956 const service = yield* ThreadManagementService.ThreadManagementService.pipe(
740957 Effect.provide(layerTest),
apps/server/src/orchestration-v2/ThreadManagementService.ts+148-9
......@@ -26,6 +26,8 @@ import {
2626 type OrchestrationV2ThreadShell,
2727 type OrchestrationV2TurnItem,
2828 ProjectId,
29 ProviderInstanceId,
30 resolveProjectProviderInstanceEnabled,
2931 RunId,
3032 type ScheduledTaskId,
3133 ThreadId,
......@@ -41,12 +43,14 @@ import * as Ref from "effect/Ref";
4143import * as Schema from "effect/Schema";
4244import * as Stream from "effect/Stream";
4345
46import * as CommandReceiptStore from "./CommandReceiptStore.ts";
4447import * as Orchestrator from "./Orchestrator.ts";
4548import * as ProviderSessionManager from "./ProviderSessionManager.ts";
4649import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts";
4750import { DispatchModeLimit, exceededDispatchModeLimit } from "./DispatchModeLimit.ts";
4851import { projectTurnItemForDetail } from "./WireProjection.ts";
4952import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts";
53import * as ServerSettings from "../serverSettings.ts";
5054
5155export type ThreadManagementSendMode = "auto" | "queue" | "steer" | "restart";
5256
......@@ -266,6 +270,18 @@ export class ThreadManagementDurableRunProjectionError extends Schema.TaggedErro
266270 }
267271}
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
269285export const ThreadManagementError = Schema.Union([
270286 ThreadManagementThreadNotFoundError,
271287 ThreadManagementRunNotFoundError,
......@@ -275,6 +291,7 @@ export const ThreadManagementError = Schema.Union([
275291 ThreadManagementProjectionLoadError,
276292 ThreadManagementProjectThreadsListError,
277293 ThreadManagementDurableRunProjectionError,
294 ThreadManagementProviderInstanceDisabledError,
278295]);
279296export type ThreadManagementError = typeof ThreadManagementError.Type;
280297
......@@ -290,9 +307,18 @@ export interface ThreadManagementServiceShape {
290307 readonly ensureLegacyTranscript: (
291308 threadId: ThreadId,
292309 ) => 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 */
293316 readonly dispatch: (
294317 command: OrchestrationV2ServerCommand,
295 ) => Effect.Effect<Orchestrator.OrchestratorV2DispatchResult, Orchestrator.OrchestratorV2Error>;
318 ) => Effect.Effect<
319 Orchestrator.OrchestratorV2DispatchResult,
320 Orchestrator.OrchestratorV2Error | ThreadManagementProviderInstanceDisabledError
321 >;
296322 readonly getThreadHistoryPage: Orchestrator.OrchestratorV2["Service"]["getThreadHistoryPage"];
297323 readonly getTimelinePage: Orchestrator.OrchestratorV2["Service"]["getTimelinePage"];
298324 readonly getMessageCount: Orchestrator.OrchestratorV2["Service"]["getMessageCount"];
......@@ -340,6 +366,32 @@ export interface ThreadManagementServiceShape {
340366 readonly sendToThread: (
341367 input: ThreadManagementSendInput,
342368 ) => 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>;
343395 readonly waitForThread: (
344396 input: ThreadManagementWaitInput,
345397 ) => Effect.Effect<ThreadManagementWaitResult, ThreadManagementError>;
......@@ -365,7 +417,9 @@ export interface ThreadManagementServiceShape {
365417 readonly byOwnAgent: boolean;
366418 }) => Effect.Effect<
367419 { readonly sequence: number } | { readonly settlesWhenTurnEnds: true },
368 Orchestrator.OrchestratorV2Error
420 // Dispatches thread.settle, a command the enablement chokepoint never
421 // gates; the type stays accurate anyway.
422 Orchestrator.OrchestratorV2Error | ThreadManagementProviderInstanceDisabledError
369423 >;
370424 readonly interruptThread: (
371425 input: ThreadManagementInterruptInput,
......@@ -386,7 +440,12 @@ export interface ThreadManagementServiceShape {
386440 readonly threadId: ThreadId;
387441 readonly commandId: CommandId;
388442 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 >;
390449 readonly getThreadEventSequence: Orchestrator.OrchestratorV2["Service"]["getThreadEventSequence"];
391450 readonly recoverDelegatedTask: Orchestrator.OrchestratorV2["Service"]["recoverDelegatedTask"];
392451 readonly delegatedTaskResultPending: Orchestrator.OrchestratorV2["Service"]["delegatedTaskResultPending"];
......@@ -458,6 +517,44 @@ const make = Effect.gen(function* () {
458517 const sessions = yield* ProviderSessionManager.ProviderSessionManagerV2;
459518 const layerScope = yield* Effect.scope;
460519 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
462559 const ensureLegacyTranscript = Effect.fn(
463560 "orchestrationV2.threadManagement.ensureLegacyTranscript",
......@@ -524,8 +621,41 @@ const make = Effect.gen(function* () {
524621 Effect.andThen(orchestrator.getThreadSnapshotWindow(threadId, options)),
525622 );
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
527654 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
530660 const getProjectThread: ThreadManagementServiceShape["getProjectThread"] = (input) =>
531661 getThreadProjection(input.threadId).pipe(
......@@ -608,7 +738,6 @@ const make = Effect.gen(function* () {
608738 threadId: input.threadId,
609739 });
610740 }
611
612741 const steerableRun = latestSteerableRun(target);
613742 let dispatchMode: Extract<
614743 OrchestrationV2Command,
......@@ -633,7 +762,7 @@ const make = Effect.gen(function* () {
633762 };
634763 }
635764
636 const dispatch = yield* orchestrator.dispatch({
765 const dispatched = yield* dispatch({
637766 type: "message.dispatch",
638767 commandId: input.commandId,
639768 threadId: input.threadId,
......@@ -685,7 +814,7 @@ const make = Effect.gen(function* () {
685814 : input.mode === "restart"
686815 ? "restarted"
687816 : "steered";
688 return { dispatch, projection, message, run, turnItem, delivery };
817 return { dispatch: dispatched, projection, message, run, turnItem, delivery };
689818 });
690819
691820 const waitForThread: ThreadManagementServiceShape["waitForThread"] = (input) =>
......@@ -943,7 +1072,9 @@ const make = Effect.gen(function* () {
9431072 const stopDelegatedTasks: ThreadManagementServiceShape["stopDelegatedTasks"] = (input) =>
9441073 Effect.gen(function* () {
9451074 const { subagents } = yield* orchestrator.getThreadRecords(input.threadId, ["subagents"]);
946 const failures: Array<Orchestrator.OrchestratorV2Error> = [];
1075 const failures: Array<
1076 Orchestrator.OrchestratorV2Error | ThreadManagementProviderInstanceDisabledError
1077 > = [];
9471078 for (const task of subagents) {
9481079 if (task.origin !== "app_owned" || task.childThreadId === null) continue;
9491080 const threadId = task.childThreadId;
......@@ -1013,6 +1144,9 @@ const make = Effect.gen(function* () {
10131144 getThreadShell: orchestrator.getThreadShell,
10141145 listProjectThreads,
10151146 sendToThread,
1147 assertProviderInstanceEnabled,
1148 isProviderInstanceEnabledForProject,
1149 hasReceipt,
10161150 waitForThread,
10171151 settleAfterRun,
10181152 settleThread,
......@@ -1041,7 +1175,10 @@ const layerLegacyV1ThreadImporterNoop = Layer.succeed(
10411175export const layer: Layer.Layer<
10421176 ThreadManagementService,
10431177 never,
1044 Orchestrator.OrchestratorV2 | ProviderSessionManager.ProviderSessionManagerV2
1178 | Orchestrator.OrchestratorV2
1179 | ProviderSessionManager.ProviderSessionManagerV2
1180 | ServerSettings.ServerSettingsService
1181 | CommandReceiptStore.CommandReceiptStoreV2
10451182> = Layer.effect(ThreadManagementService, make).pipe(
10461183 Layer.provide(layerLegacyV1ThreadImporterNoop),
10471184 Layer.provide(ThreadCommandExecutor.layer),
......@@ -1053,4 +1190,6 @@ export const layerWithLegacyImporter: Layer.Layer<
10531190 | LegacyV1ThreadImporter.LegacyV1ThreadImporter
10541191 | Orchestrator.OrchestratorV2
10551192 | ProviderSessionManager.ProviderSessionManagerV2
1193 | ServerSettings.ServerSettingsService
1194 | CommandReceiptStore.CommandReceiptStoreV2
10561195> = Layer.effect(ThreadManagementService, make).pipe(Layer.provide(ThreadCommandExecutor.layer));
apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts+56-2
......@@ -7,6 +7,9 @@ import {
77 ChatAttachmentId,
88 CommandId,
99 EventId,
10 MessageId,
11 ProjectId,
12 ProviderInstanceId,
1013 RuntimeRequestId,
1114 ThreadId,
1215 TurnItemId,
......@@ -20,16 +23,17 @@ import * as Effect from "effect/Effect";
2023import * as Layer from "effect/Layer";
2124import { createPendingAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts";
2225import * as ServerConfig from "../config.ts";
26import * as ServerSettings from "../serverSettings.ts";
2327import {
2428 OrchestratorCommandPreviouslyRejectedError,
2529 OrchestratorDispatchError,
2630} from "./Orchestrator.ts";
2731import * as ThreadManagementService from "./ThreadManagementService.ts";
28import { dispatchCommand } from "./ThreadMessageIntake.ts";
32import { dispatchCommand, sendToThread } from "./ThreadMessageIntake.ts";
2933
3034const layerIntakeTest = ServerConfig.layerTest(process.cwd(), {
3135 prefix: "t3-question-intake-",
32}).pipe(Layer.provideMerge(NodeServices.layer));
36}).pipe(Layer.provideMerge(NodeServices.layer), Layer.provideMerge(ServerSettings.layerTest()));
3337
3438const layerFailingDispatch = (captured: OrchestrationV2ServerCommand[]) =>
3539 Layer.mock(ThreadManagementService.ThreadManagementService)({
......@@ -764,3 +768,53 @@ it.effect("applies the image budget across all questions before dispatch", () =>
764768 expect(captured).toEqual([]);
765769 }).pipe(Effect.provide(layerIntakeTest)),
766770);
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";
1313
1414// These dispatcher failures occur in receipt validation or planning, before
1515// 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.
1618function dispatchWasNotAccepted(
1719 error: Orchestrator.OrchestratorV2Error | ThreadManagement.ThreadManagementError,
1820) {
......@@ -24,12 +26,14 @@ function dispatchWasNotAccepted(
2426 case "OrchestratorCommandIdConflictError":
2527 case "OrchestratorSubagentThreadReadOnlyError":
2628 case "OrchestratorThreadAboveModeLimitError":
29 case "ThreadManagementProviderInstanceDisabledError":
2730 return true;
2831 default:
2932 return false;
3033 }
3134}
3235const isOrchestratorError = Schema.is(Orchestrator.OrchestratorV2Error);
36const isThreadManagementError = Schema.is(ThreadManagement.ThreadManagementError);
3337
3438const releaseUnusedClaims = Effect.fn("ThreadMessageIntake.releaseUnusedClaims")(function* (
3539 claimedPaths: ReadonlyArray<string>,
......@@ -221,13 +225,19 @@ export const launchThread = Effect.fn("ThreadMessageIntake.launchThread")(functi
221225 ),
222226 ),
223227 Effect.tapError((error) => {
228 if (error._tag === "ThreadManagementProviderInstanceDisabledError") {
229 return AttachmentClaims.releaseClaimedAttachments(claimed.claimedPaths);
230 }
224231 // Project/receipt reads precede message dispatch. The create-thread error
225232 // 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.
226236 const notAccepted =
227237 error.operation === "resolve-project" ||
228238 error.operation === "read-receipt" ||
229239 ((error.operation === "create-thread" || error.operation === "dispatch-message") &&
230 isOrchestratorError(error.cause) &&
240 (isOrchestratorError(error.cause) || isThreadManagementError(error.cause)) &&
231241 // Projection errors under create-thread can occur after the message commit.
232242 (error.operation !== "create-thread" ||
233243 error.cause._tag !== "OrchestratorProjectionError") &&
apps/server/src/orchestration-v2/ThreadStop.test.ts+4
......@@ -21,8 +21,10 @@ import * as SqlClient from "effect/sql/SqlClient";
2121import * as TestClock from "effect/testing/TestClock";
2222
2323import * as SqlitePersistence from "../persistence/Sqlite.ts";
24import * as ServerSettings from "../serverSettings.ts";
2425import { CLAUDE_PROVIDER } from "./Adapters/ClaudeAdapterV2.ts";
2526import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
27import * as CommandReceiptStore from "./CommandReceiptStore.ts";
2628import { OrchestrationEffectRequestV2 } from "./EffectOutbox.ts";
2729import * as Orchestrator from "./Orchestrator.ts";
2830import * as ProjectionStore from "./ProjectionStore.ts";
......@@ -54,6 +56,8 @@ const layerTest = ThreadManagementService.layer.pipe(
5456 ),
5557 ),
5658 ),
59 Layer.provideMerge(ServerSettings.layerTest()),
60 Layer.provideMerge(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))),
5761);
5862
5963const 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";
2121import * as ServerSettings from "../serverSettings.ts";
2222import * as TextGeneration from "../textGeneration/TextGeneration.ts";
2323import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
24import * as CommandReceiptStore from "./CommandReceiptStore.ts";
2425import * as EffectOutbox from "./EffectOutbox.ts";
2526import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";
2627import * as ThreadManagement from "./ThreadManagementService.ts";
......@@ -54,7 +55,12 @@ function makeHarness(
5455 layerRegistry,
5556 { databaseLayer: layerDatabase, runEffectWorker: false },
5657 );
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 );
5864 const layerOutbox = EffectOutbox.layer.pipe(Layer.provide(layerDatabase));
5965 const generateThreadTitle = vi.fn(
6066 options.generateTitle ?? (() => Effect.succeed({ title: "Generated title" })),
......@@ -86,7 +92,7 @@ function makeHarness(
8692 layerThreadManagement,
8793 layerProjectedProjects,
8894 Layer.mock(TextGeneration.TextGeneration)({ generateThreadTitle }),
89 ServerSettings.layerTest({}),
95 layerServerSettings,
9096 ),
9197 ),
9298 );
apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts+6-1
......@@ -33,7 +33,12 @@ export class ThreadTitleRegenerationService extends Context.Service<
3333 | { readonly type: "regenerate" };
3434 }) => Effect.Effect<
3535 void,
36 OrchestratorV2Error | ProjectStore.ProjectStoreV2Error | ServerSettingsError
36 | 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
3742 >;
3843 }
3944>()("t3/orchestration-v2/ThreadTitleRegenerationService") {}
apps/server/src/orchestration-v2/runtimeLayer.test.ts+130
......@@ -5453,3 +5453,133 @@ it.layer(layerTest)("usage-limit recovery", (it) => {
54535453 }),
54545454 );
54555455});
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
244244 Layer.mergeAll(
245245 layerOrchestratorProvided,
246246 layerLegacyV1ThreadImporterProvided,
247 layerCommandReceiptStoreProvided,
247248 layerProviderSessionManagerProvided,
248249 ),
249250 ),
apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts+1
......@@ -450,6 +450,7 @@ export function layerWithRegistry<Error>(
450450 layerProviderSessionManagerProvided,
451451 layerProviderSwitchServiceProvided,
452452 layerRunExecutionServiceProvided,
453 layerServerSettings,
453454 ThreadForkService.layer,
454455 ),
455456 ),
apps/server/src/project/ManagedProjectFolders.test.ts+2
......@@ -23,6 +23,7 @@ import * as GitWorkflow from "../git/GitWorkflowService.ts";
2323import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts";
2424import * as SqlitePersistence from "../persistence/Sqlite.ts";
2525import * as ProcessRunner from "../processRunner.ts";
26import * as ServerSettings from "../serverSettings.ts";
2627import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
2728import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
2829import * as VcsProcess from "../vcs/VcsProcess.ts";
......@@ -87,6 +88,7 @@ const layer = (baseDir: string, options?: HarnessOptions) =>
8788 Layer.provideMerge(layerGitWorkflow(options?.vcsProcess)),
8889 Layer.provideMerge(options?.git ?? layerRealGit),
8990 Layer.provideMerge(SqlitePersistence.layerMemory),
91 Layer.provideMerge(ServerSettings.layerTest()),
9092 Layer.provideMerge(ServerConfig.layerTest(baseDir, baseDir)),
9193 Layer.provideMerge(NodeServices.layer),
9294 );
apps/server/src/project/ProjectService.deletion.test.ts+30
......@@ -28,6 +28,7 @@ import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
2828import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts";
2929import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts";
3030import * as SqlitePersistence from "../persistence/Sqlite.ts";
31import * as ServerSettings from "../serverSettings.ts";
3132import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
3233import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts";
3334import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
......@@ -63,6 +64,7 @@ const layerServices = Layer.mergeAll(
6364 ),
6465 ),
6566 ),
67 Layer.provideMerge(ServerSettings.layerTest()),
6668);
6769const layerDatabase = SqlitePersistence.layerMemory.pipe(
6870 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
494496 }).pipe(Effect.provide(layerServices));
495497 }).pipe(Effect.provide(layerDatabase)),
496498);
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";
1919import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts";
2020import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts";
2121import * as SqlitePersistence from "../persistence/Sqlite.ts";
22import * as ServerSettings from "../serverSettings.ts";
2223import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
2324import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts";
2425import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
......@@ -60,6 +61,7 @@ const layerTestFor = (
6061 Layer.provideMerge(layerWorkspacePaths),
6162 Layer.provideMerge(projectMetadataLayer),
6263 Layer.provideMerge(SqlitePersistence.layerMemory),
64 Layer.provideMerge(ServerSettings.layerTest()),
6365 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "project-service-test-" })),
6466 Layer.provide(NodeServices.layer),
6567 );
......@@ -79,6 +81,7 @@ const layerProjectServiceDependencies = Layer.mergeAll(
7981 Layer.provideMerge(layerWorkspacePaths),
8082 Layer.provideMerge(layerMetadata),
8183 Layer.provideMerge(SqlitePersistence.layerMemory),
84 Layer.provideMerge(ServerSettings.layerTest()),
8285 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "project-service-race-" })),
8386 Layer.provide(NodeServices.layer),
8487);
apps/server/src/project/ProjectService.ts+23-1
......@@ -30,6 +30,7 @@ import {
3030import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
3131import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts";
3232import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts";
33import * as ServerSettings from "../serverSettings.ts";
3334import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts";
3435import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
3536
......@@ -153,6 +154,7 @@ export const make = Effect.gen(function* () {
153154 const idAllocator = yield* IdAllocator.IdAllocatorV2;
154155 const legacyImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter;
155156 const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor;
157 const serverSettings = yield* ServerSettings.ServerSettingsService;
156158 // Commands for one project run in order. Commands that claim a workspace root
157159 // also hold that root, so two projects cannot both claim it.
158160 const projectLocks = yield* KeyedLock.make<ProjectId>();
......@@ -486,6 +488,22 @@ export const make = Effect.gen(function* () {
486488 );
487489 });
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
489507 const deleteProject: ProjectService["Service"]["delete"] = Effect.fn("ProjectService.delete")(
490508 function* (input) {
491509 const { projectId } = input;
......@@ -496,10 +514,14 @@ export const make = Effect.gen(function* () {
496514 return yield* new ProjectNotFoundError({ projectId });
497515 }
498516
499 if (existing.value.deletedAt === null) {
517 const isFirstDelete = existing.value.deletedAt === null;
518 if (isFirstDelete) {
500519 yield* deleteChildThreads(input);
501520 }
502521 yield* commit({ type: "project.delete", commandId: input.commandId, projectId });
522 if (isFirstDelete) {
523 yield* clearProjectSettingsOverrides(projectId);
524 }
503525 yield* projectEnrichment.invalidate([existing.value.workspaceRoot]);
504526 return yield* readCommitted(projectId);
505527 },
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 @@
2525 */
2626import {
2727 defaultInstanceIdForDriver,
28 providerInstanceConfigEnabledFlag,
29 resolveProviderInstanceEnabled,
2830 type ProviderInstanceConfig,
2931 type ProviderInstanceConfigMap,
32 type ProviderInstanceId,
3033 ServerSettings,
3134} from "@t3tools/contracts";
3235import * as Effect from "effect/Effect";
......@@ -73,6 +76,29 @@ export const deriveProviderInstanceConfigMap = (
7376 merged[instanceId] = { driver: driver.driverKind };
7477 }
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
76102 return merged as ProviderInstanceConfigMap;
77103};
78104
apps/server/src/pullRequest/PullRequestReviewGuideService.ts+1
......@@ -328,6 +328,7 @@ const make = Effect.gen(function* () {
328328 const modelSelection = ServerSettings.resolveReviewGuideModelSelection(
329329 projectSettings,
330330 providers,
331 input.projectId,
331332 );
332333 // The checkout lives only as long as the model is reading it. The change's hunks are read
333334 // 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* (
208208 getProjectThread: unused,
209209 listProjectThreads: unused,
210210 sendToThread: unused,
211 assertProviderInstanceEnabled: unused,
212 isProviderInstanceEnabledForProject: unused,
213 hasReceipt: unused,
211214 waitForThread: unused,
212215 settleAfterRun: unused,
213216 settleThread: unused,
apps/server/src/server.ts+5
......@@ -58,6 +58,7 @@ import * as CheckpointStore from "./checkpointing/CheckpointStore.ts";
5858import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts";
5959import * as TextGeneration from "./textGeneration/TextGeneration.ts";
6060import * as ProviderInstanceRegistryHydration from "./provider/ProviderInstanceRegistryHydration.ts";
61import * as ProjectSettingsOverrideSweep from "./provider/ProjectSettingsOverrideSweep.ts";
6162import * as TerminalManager from "./terminal/Manager.ts";
6263import * as McpHttpServer from "./mcp/McpHttpServer.ts";
6364import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts";
......@@ -578,6 +579,10 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(
578579 ProviderUsageLimitsIngestion.layer,
579580 layerProviderInstallationRefresh,
580581 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),
581586).pipe(
582587 // Core Services
583588 Layer.provideMerge(layerOrchestrationApplication),
apps/server/src/serverSettings.test.ts+2-2
......@@ -873,7 +873,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
873873
874874 assert.deepEqual(next.sourceControlWriterModelSelection, sourceControlWriterModelSelection);
875875 assert.deepEqual(
876 ServerSettingsModule.resolveSourceControlWriterModelSelection(next),
876 ServerSettingsModule.resolveSourceControlWriterModelSelection(next, undefined, null),
877877 next.textGenerationModelSelection,
878878 );
879879 assert.deepEqual(
......@@ -897,7 +897,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
897897 },
898898 });
899899 assert.deepEqual(
900 ServerSettingsModule.resolveSourceControlWriterModelSelection(restored),
900 ServerSettingsModule.resolveSourceControlWriterModelSelection(restored, undefined, null),
901901 sourceControlWriterModelSelection,
902902 );
903903 }).pipe(Effect.provide(layerServerSettings())),
apps/server/src/serverSettings.ts+9-34
......@@ -11,9 +11,6 @@
1111 * @module ServerSettings
1212 */
1313import {
14 DEFAULT_TEXT_GENERATION_MODEL,
15 DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER,
16 DEFAULT_MODEL_BY_PROVIDER,
1714 DEFAULT_SERVER_SETTINGS,
1815 ModelSelection,
1916 ProjectId,
......@@ -61,6 +58,7 @@ import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJso
6158import {
6259 applyServerSettingsPatch,
6360 deriveLegacyProjectOverrides,
61 fallbackTextGenerationModelSelection,
6462 isModelSelectionProviderEnabled,
6563} from "@t3tools/shared/serverSettings";
6664import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
......@@ -560,17 +558,6 @@ function migrateLegacyProviderSettings(
560558
561559const 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
574561/** ACP Registry instances reject every application text-generation operation. */
575562function selectionSupportsTextGeneration(
576563 settings: ServerSettings,
......@@ -587,27 +574,15 @@ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings
587574}
588575
589576function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings {
590 // The built-in default instances in preference order. A slot without an
591 // explicit instance uses its driver's default enabled state.
592 const fallback = TEXT_GENERATION_FALLBACK_DRIVERS.find((driver) =>
593 resolveProviderInstanceEnabled(
594 settings.providerInstances[defaultInstanceIdForDriver(driver)] ?? { driver, config: {} },
595 ),
577 // A slot without an explicit instance uses its driver's default enabled state.
578 const fallback = fallbackTextGenerationModelSelection(
579 settings.providerInstances,
580 (instanceId, driver) =>
581 resolveProviderInstanceEnabled(
582 settings.providerInstances[instanceId] ?? { driver, config: {} },
583 ),
596584 );
597 if (!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 };
585 return fallback === null ? settings : { ...settings, textGenerationModelSelection: fallback };
611586}
612587
613588// 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 = (
11991199 | ThreadLaunchService.ThreadLaunchService
12001200 | FileSystem.FileSystem
12011201 | ServerConfig.ServerConfig
1202 | ServerSettings.ServerSettingsService
12021203 >();
12031204 const applicationEvents = yield* OrchestrationEventStore.OrchestrationEventStore;
12041205 const projectStore = yield* ProjectStore.ProjectStoreV2;
......@@ -2029,6 +2030,13 @@ const layerWsRpc = (
20292030 message: "Failed to launch thread",
20302031 cause,
20312032 }),
2033 ThreadManagementProviderInstanceDisabledError: (cause) =>
2034 new OrchestrationV2ThreadLaunchError({
2035 commandId: input.commandId,
2036 projectId: input.projectId,
2037 message: cause.message,
2038 cause,
2039 }),
20322040 }),
20332041 ),
20342042 ),
apps/web/src/components/ChatView.tsx+16-3
......@@ -81,6 +81,7 @@ import {
8181 ProviderInteractionMode,
8282 ProviderDriverKind,
8383 resolveEnvironmentMachineKind,
84 resolveProjectProviderInstanceEnabled,
8485 RuntimeMode,
8586 TerminalOpenInput,
8687 type WorktreeSetupSnapshot,
......@@ -3241,9 +3242,13 @@ export default function ChatView(props: ChatViewProps) {
32413242 const providerInstanceEntries = useMemo(
32423243 () =>
32433244 sortProviderInstanceEntries(
3244 applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings),
3245 applyProviderInstanceSettings(
3246 deriveProviderInstanceEntries(providerStatuses),
3247 settings,
3248 activeProject?.id ?? null,
3249 ),
32453250 ),
3246 [providerStatuses, settings],
3251 [providerStatuses, settings, activeProject?.id],
32473252 );
32483253 const { selectedProviderEntry, requestedDriverKind } = useMemo(
32493254 () =>
......@@ -4359,7 +4364,14 @@ export default function ChatView(props: ChatViewProps) {
43594364 (activeProviderInstanceId === null ||
43604365 provider.instanceId === activeProviderInstanceId) &&
43614366 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) &&
43634375 provider.installed &&
43644376 provider.status !== "error" &&
43654377 provider.auth.status !== "unauthenticated" &&
......@@ -11759,6 +11771,7 @@ export default function ChatView(props: ChatViewProps) {
1175911771 activeProjectDefaultModelSelection
1176011772 }
1176111773 activeThreadModelSelection={activeThread?.modelSelection}
11774 activeProjectId={activeProject?.id ?? null}
1176211775 activeContextWindow={activeContextWindow}
1176311776 activeTasksProgress={activeComposerTasksProgress}
1176411777 activeTaskSteps={activeComposerTaskSteps}
apps/web/src/components/CommandPalette.tsx+5-2
......@@ -725,9 +725,12 @@ function OpenCommandPaletteDialog(props: {
725725 (environment.environmentId === primaryEnvironmentId ? providers : []);
726726 const derived = deriveProviderInstanceEntries(environmentProviders);
727727 // 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.
729732 const entries = serverConfig
730 ? applyProviderInstanceSettings(derived, serverConfig.settings)
733 ? applyProviderInstanceSettings(derived, serverConfig.settings, null)
731734 : derived;
732735 for (const entry of entries) {
733736 map.set(`${environment.environmentId}:${entry.instanceId}`, entry);
apps/web/src/components/chat/ChatComposer.tsx+9-2
......@@ -1607,6 +1607,8 @@ export interface ChatComposerProps {
16071607 activeProjectDefaultModelSelection: ModelSelection | null | undefined;
16081608 activeThreadModelSelection: ModelSelection | null | undefined;
16091609 reportedModelSelection?: ModelSelection | null;
1610 /** The active project, so the picker can fold in its provider instance overrides. */
1611 activeProjectId: ProjectId | null;
16101612
16111613 // Context window
16121614 activeContextWindow: ContextWindowSnapshot | null;
......@@ -1756,6 +1758,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
17561758 activeProjectDefaultModelSelection,
17571759 activeThreadModelSelection,
17581760 reportedModelSelection,
1761 activeProjectId,
17591762 activeContextWindow,
17601763 compactThreadUnavailable,
17611764 compactDisabled,
......@@ -2132,9 +2135,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
21322135 const providerInstanceEntries = useMemo<ReadonlyArray<ProviderInstanceEntry>>(
21332136 () =>
21342137 sortProviderInstanceEntries(
2135 applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings),
2138 applyProviderInstanceSettings(
2139 deriveProviderInstanceEntries(providerStatuses),
2140 settings,
2141 activeProjectId,
2142 ),
21362143 ),
2137 [providerStatuses, settings],
2144 [providerStatuses, settings, activeProjectId],
21382145 );
21392146 const selectedProviderByThreadId = composerDraft.activeProvider ?? null;
21402147 const {
apps/web/src/components/settings/ProjectDefaultsSettings.tsx+6-1
......@@ -64,7 +64,11 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
6464 const providers = representative?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS;
6565 const selection = resolveDefaultProviderModelSelection(providers, settings.defaultModelSelection);
6666 const entries = sortProviderInstanceEntries(
67 applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings),
67 applyProviderInstanceSettings(
68 deriveProviderInstanceEntries(providers),
69 settings,
70 target?.projectId ?? null,
71 ),
6872 );
6973 const modelOptions = getCustomModelOptionsByInstance(
7074 settings,
......@@ -105,6 +109,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
105109 const entry = applyProviderInstanceSettings(
106110 deriveProviderInstanceEntries(config.providers),
107111 candidate.settings,
112 candidate.projectId,
108113 ).find((option) => option.instanceId === instanceId);
109114 const options = getCustomModelOptionsByInstance(
110115 mergeEnvironmentSettings(candidate.settings, settings),
apps/web/src/components/settings/ProviderInstanceCard.tsx+119-5
......@@ -4,10 +4,12 @@ import { Spinner } from "~/components/ui/spinner";
44
55import {
66 AlertTriangleIcon,
7 CheckIcon,
78 ArrowUpCircleIcon,
89 CopyIcon,
910 DownloadIcon,
1011 ExternalLinkIcon,
12 LayersIcon,
1113 PlusIcon,
1214 Trash2Icon,
1315 XIcon,
......@@ -55,7 +57,7 @@ import { ProviderModelsSection } from "./ProviderModelsSection";
5557import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
5658import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker";
5759import { RedactedSensitiveText } from "./RedactedSensitiveText";
58import { SettingsRow, SettingsSection } from "./settingsLayout";
60import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout";
5961import { AcpSessionManagementSection } from "./AcpSessionManagementSection";
6062import { FoldedSettingsSection } from "./FoldedSettingsSection";
6163import { readCodexSetupMode } from "./CodexSetupSection.logic";
......@@ -472,6 +474,93 @@ export function ProviderEnvironmentSection(props: {
472474 );
473475}
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
475564interface ProviderInstanceCardProps {
476565 readonly instanceId: ProviderInstanceId;
477566 readonly instance: ProviderInstanceConfig;
......@@ -515,6 +604,8 @@ interface ProviderInstanceCardProps {
515604 readonly isUpdating?: boolean | undefined;
516605 readonly onAcceptUrlAuth?: ((action: AcpRegistryUrlAuthAction) => void) | undefined;
517606 readonly environmentId?: EnvironmentId | undefined;
607 /** Omit this prop entirely for environment-wide editing. */
608 readonly projectOverride?: ProjectOverrideControl | undefined;
518609 readonly acpProjects?:
519610 | ReadonlyArray<{
520611 readonly id: ProjectId;
......@@ -544,6 +635,9 @@ const EMPTY_ACP_PROJECTS: NonNullable<ProviderInstanceCardProps["acpProjects"]>
544635 * driver-specific `config.enabled` into the envelope on load and both
545636 * sides resolve through `resolveProviderInstanceEnabled` (an explicit
546637 * 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.
547641 */
548642export function ProviderInstanceCard({
549643 instanceId,
......@@ -573,8 +667,9 @@ export function ProviderInstanceCard({
573667 onAcceptUrlAuth,
574668 environmentId,
575669 acpProjects = EMPTY_ACP_PROJECTS,
670 projectOverride,
576671}: ProviderInstanceCardProps) {
577 const enabled = resolveProviderInstanceEnabled(instance);
672 const enabled = projectOverride?.effectiveEnabled ?? resolveProviderInstanceEnabled(instance);
578673 const compatibility = enabled ? liveProvider?.compatibilityAdvisory : undefined;
579674 // A locally disabled provider reads "Disabled" with a muted dot even if its
580675 // last server status is stale. Enabled providers use the server status.
......@@ -582,8 +677,9 @@ export function ProviderInstanceCard({
582677 ? ((liveProvider?.status as ProviderStatusKey | undefined) ?? "warning")
583678 : "disabled";
584679 const statusStyle = PROVIDER_STATUS_STYLES[statusKey];
680 // `liveProvider.enabled` is machine-wide; a project override can turn it on.
585681 const summary = enabled
586 ? getProviderSummary(liveProvider)
682 ? getProviderSummary(liveProvider && { ...liveProvider, enabled: true })
587683 : { headline: "Disabled", detail: null };
588684 const authEmail = liveProvider?.auth.email?.trim();
589685 const isAuthenticated = enabled && liveProvider?.auth.status === "authenticated";
......@@ -670,6 +766,10 @@ export function ProviderInstanceCard({
670766 };
671767
672768 const updateEnabled = (value: boolean) => {
769 if (projectOverride) {
770 projectOverride.onChange(value);
771 return;
772 }
673773 onUpdate({ ...instance, enabled: value });
674774 };
675775
......@@ -968,10 +1068,24 @@ export function ProviderInstanceCard({
9681068 </span>
9691069 </span>
9701070 </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}
9721086 <Switch
9731087 checked={enabled}
974 disabled={readOnly}
1088 disabled={readOnly || (projectOverride !== undefined && !canWriteSettings)}
9751089 onCheckedChange={(checked) => updateEnabled(Boolean(checked))}
9761090 aria-label={`Enable ${displayName}`}
9771091 />
apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx+1
......@@ -140,6 +140,7 @@ vi.mock("../../state/session", () => ({
140140
141141vi.mock("../../state/entities", () => ({
142142 useProjects: () => [],
143 useServerConfigs: () => new Map(),
143144}));
144145
145146import { EnvironmentProviderSettings } from "./ProviderSettingsPanel";
apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts+120-1
......@@ -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";
28import { describe, expect, it } from "vite-plus/test";
39
410import {
511 buildProviderEnvironmentOptions,
12 buildProviderInstanceOverridePatch,
613 classifyProviderEnvironmentAccess,
714 isProviderSettingsEnvironmentAvailable,
815 resolvePrimaryOperateAccess,
......@@ -227,6 +234,118 @@ describe("primary operate access", () => {
227234 });
228235});
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
230349describe("remote operate access", () => {
231350 it("does not treat the old orchestration grant as provider management", () => {
232351 expect(
apps/web/src/components/settings/ProviderSettingsPanel.logic.ts+42
......@@ -3,7 +3,11 @@ import {
33 AuthProvidersManageScope,
44 type AuthSessionState,
55 type EnvironmentId,
6 type ProjectId,
7 type ProjectSettingsOverrides,
8 type ProviderInstanceId,
69 sessionGrantsScope,
10 type ServerSettings,
711 type SessionGrantInput,
812} from "@t3tools/contracts";
913
......@@ -105,6 +109,44 @@ export function resolveRemoteOperateAccess(input: {
105109 return resolveSessionOperateAccess(input);
106110}
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
108150export function classifyProviderEnvironmentAccess(input: {
109151 readonly connectionPhase: EnvironmentConnectionPhase;
110152 readonly hasServerConfig: boolean;
apps/web/src/components/settings/ProviderSettingsPanel.tsx+56-1
......@@ -19,9 +19,11 @@ import {
1919 type AcpRegistryUrlAuthAction,
2020 PROVIDER_DISPLAY_NAMES,
2121 ProviderDriverKind,
22 type ProjectId,
2223 type ProviderInstanceConfig,
2324 type ProviderInstanceId,
2425 resolveEnvironmentMachineKind,
26 resolveProjectProviderInstanceEnabled,
2527 resolveProviderInstanceEnabled,
2628} from "@t3tools/contracts";
2729import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
......@@ -54,7 +56,7 @@ import {
5456} from "../../state/environments";
5557import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server";
5658import { useEnvironmentSessionState } from "../../state/session";
57import { useProjects } from "../../state/entities";
59import { useProjects, useServerConfigs } from "../../state/entities";
5860import { useAtomCommand } from "../../state/use-atom-command";
5961import { getRelativeTimeState } from "../../timestampFormat";
6062import {
......@@ -116,6 +118,7 @@ import {
116118} from "./settingsLayout";
117119import {
118120 buildProviderEnvironmentOptions,
121 buildProviderInstanceOverridePatch,
119122 classifyProviderEnvironmentAccess,
120123 isProviderSettingsEnvironmentAvailable,
121124 type ProviderEnvironmentAccess,
......@@ -279,6 +282,11 @@ interface ProviderSettingsTarget {
279282 readonly instanceId?: ProviderInstanceId;
280283 readonly scoped?: boolean;
281284 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[];
282290}
283291
284292export function ProviderSettingsPanel(target: ProviderSettingsTarget) {
......@@ -450,6 +458,7 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {
450458 ? target.instanceId
451459 : undefined
452460 }
461 projectIds={target.projectIds ?? []}
453462 />
454463 ) : null}
455464 </>
......@@ -460,16 +469,19 @@ function SelectedEnvironmentProviderSettings({
460469 environment,
461470 deviceTabs,
462471 targetInstanceId,
472 projectIds,
463473}: {
464474 readonly environment: EnvironmentPresentation;
465475 readonly deviceTabs?: ReactNode;
466476 readonly targetInstanceId?: ProviderInstanceId | undefined;
477 readonly projectIds: readonly ProjectId[];
467478}) {
468479 return (
469480 <RemoteSessionGatedProviderSettings
470481 environment={environment}
471482 deviceTabs={deviceTabs}
472483 targetInstanceId={targetInstanceId}
484 projectIds={projectIds}
473485 />
474486 );
475487}
......@@ -478,10 +490,12 @@ function RemoteSessionGatedProviderSettings({
478490 environment,
479491 deviceTabs,
480492 targetInstanceId,
493 projectIds,
481494}: {
482495 readonly environment: EnvironmentPresentation;
483496 readonly deviceTabs?: ReactNode;
484497 readonly targetInstanceId?: ProviderInstanceId | undefined;
498 readonly projectIds: readonly ProjectId[];
485499}) {
486500 const sessionState = useEnvironmentSessionState(environment.environmentId);
487501 const operateAccess = resolveRemoteOperateAccess({
......@@ -495,6 +509,7 @@ function RemoteSessionGatedProviderSettings({
495509 operateAccess={operateAccess}
496510 deviceTabs={deviceTabs}
497511 targetInstanceId={targetInstanceId}
512 projectIds={projectIds}
498513 />
499514 );
500515}
......@@ -504,11 +519,13 @@ function AccessGatedProviderSettings({
504519 operateAccess,
505520 deviceTabs,
506521 targetInstanceId,
522 projectIds,
507523}: {
508524 readonly environment: EnvironmentPresentation;
509525 readonly operateAccess: ProviderOperateAccess;
510526 readonly deviceTabs?: ReactNode;
511527 readonly targetInstanceId?: ProviderInstanceId | undefined;
528 readonly projectIds: readonly ProjectId[];
512529}) {
513530 const access = classifyProviderEnvironmentAccess({
514531 connectionPhase: environment.connection.phase,
......@@ -531,6 +548,7 @@ function AccessGatedProviderSettings({
531548 readOnly={access.kind === "read-only"}
532549 deviceTabs={deviceTabs}
533550 targetInstanceId={targetInstanceId}
551 projectIds={projectIds}
534552 />
535553 );
536554}
......@@ -541,11 +559,14 @@ export function EnvironmentProviderSettings({
541559 readOnly = false,
542560 deviceTabs,
543561 targetInstanceId,
562 projectIds = [],
544563}: {
545564 readonly environmentId: EnvironmentId;
546565 readonly environmentLabel: string;
547566 readonly deviceTabs?: ReactNode;
548567 readonly targetInstanceId?: ProviderInstanceId | undefined;
568 /** The project's checkouts on this environment, if a project is in scope. */
569 readonly projectIds?: readonly ProjectId[];
549570 /**
550571 * Grey out and freeze every write control when this session's credential
551572 * lacks `providers:manage` on the environment. Selecting providers
......@@ -564,6 +585,12 @@ export function EnvironmentProviderSettings({
564585 const serverProviders =
565586 useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS;
566587 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;
567594 const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, {
568595 reportFailure: false,
569596 });
......@@ -901,12 +928,40 @@ export function EnvironmentProviderSettings({
901928 favorite.provider === row.instanceId ? Result.succeed(favorite.model) : Result.failVoid,
902929 );
903930 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
905959 return (
906960 <ProviderInstanceCard
907961 key={row.instanceId}
908962 environmentId={environmentId}
909963 acpProjects={projects}
964 projectOverride={projectOverride}
910965 onAcceptUrlAuth={
911966 readOnly ? undefined : (action) => acceptUrlAuthentication(row.instanceId, action)
912967 }
apps/web/src/components/settings/ScheduledTasksSettings.tsx+19-10
......@@ -804,13 +804,6 @@ function ScheduledTaskEditorDialog({
804804 const upsertTask = useAtomCommand(serverEnvironment.upsertScheduledTask, {
805805 label: "scheduled task upsert",
806806 });
807 const instanceEntries = useMemo(
808 () =>
809 sortProviderInstanceEntries(
810 applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings),
811 ),
812 [providers, settings],
813 );
814807 const [draft, setDraft] = useState<DraftState>(() =>
815808 task ? taskToDraft(task) : { ...EMPTY_DRAFT, projectId: projects[0]?.id ?? "" },
816809 );
......@@ -827,13 +820,29 @@ function ScheduledTaskEditorDialog({
827820 : task;
828821 const selectedProjectId = draft.projectId || projects[0]?.id || "";
829822 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
831837 // The real model picker is keyed by a `${instanceId}:${model}` string, which
832838 // is exactly how the draft stores its selection.
833839 const firstInstance = instanceEntries[0];
834 const activeSelection = draft.modelKey
835 ? splitModelKey(draft.modelKey)
836 : scheduledTaskDefaultModel(settings, selectedProject ?? null, instanceEntries);
840 const draftSelection = draft.modelKey ? splitModelKey(draft.modelKey) : null;
841 const activeSelection =
842 draftSelection &&
843 instanceEntries.some((entry) => entry.instanceId === draftSelection.instanceId && entry.enabled)
844 ? draftSelection
845 : scheduledTaskDefaultModel(settings, selectedProject ?? null, instanceEntries);
837846 const activeInstanceId =
838847 activeSelection?.instanceId ?? firstInstance?.instanceId ?? ("" as ProviderInstanceId);
839848 const activeModel = activeSelection?.model ?? "";
apps/web/src/components/settings/SettingsPanels.tsx+6-2
......@@ -2183,7 +2183,7 @@ export function GeneralSettingsPanel() {
21832183 const settings = useScopedSettings();
21842184 const updateSettings = useUpdateScopedSettings();
21852185 const navigate = useNavigate();
2186 const { scope, environment, connectedEnvironments } = useSettingsScope();
2186 const { scope, environment, connectedEnvironments, target } = useSettingsScope();
21872187 // The representative environment supplies the provider list for pickers;
21882188 // a fanned-out model choice is validated against every target before it
21892189 // is written. Per-machine tuning (background activity overrides) still
......@@ -2219,7 +2219,11 @@ export function GeneralSettingsPanel() {
22192219 const textGenModel = textGenerationModelSelection.model;
22202220 const textGenModelOptions = textGenerationModelSelection.options;
22212221 const textGenerationModelInstanceEntries = sortProviderInstanceEntries(
2222 applyProviderInstanceSettings(deriveProviderInstanceEntries(textGenerationProviders), settings),
2222 applyProviderInstanceSettings(
2223 deriveProviderInstanceEntries(textGenerationProviders),
2224 settings,
2225 target?.projectId ?? null,
2226 ),
22232227 );
22242228 const hasTextGenerationProvider = textGenerationModelInstanceEntries.some(
22252229 (entry) => entry.enabled && entry.isAvailable,
apps/web/src/components/settings/SourceControlWritingSettings.tsx+26-8
......@@ -66,7 +66,7 @@ export function SourceControlWritingSettingsSection() {
6666 const updateSettings = useUpdateScopedSettings();
6767 const mixedReviewGuidesEnabled = useScopedSettingsMixed(["reviewGuidesEnabled"]);
6868 const navigate = useNavigate();
69 const { environment, connectedEnvironments, targets } = useSettingsScope();
69 const { environment, connectedEnvironments, targets, target } = useSettingsScope();
7070 // The representative supplies the provider list; a model choice is checked
7171 // against every target before it fans out.
7272 const environmentId = environment?.environmentId ?? null;
......@@ -106,20 +106,37 @@ export function SourceControlWritingSettingsSection() {
106106 const textGenerationProviders = serverProviders.filter(
107107 (provider) => provider.supportsTextGeneration !== false,
108108 );
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 );
110129 const usesDedicatedModel = settings.sourceControlWriterModelSelection !== null;
111130 const activeSelection = resolveAppModelSelectionState(
112131 {
113132 ...settings,
114133 textGenerationModelSelection: resolveSourceControlWriterModelSelection(
115134 settings,
116 textGenerationProviders,
135 projectEffectiveTextGenerationProviders,
136 target?.projectId ?? null,
117137 ),
118138 },
119 textGenerationProviders,
120 );
121 const instanceEntries = sortProviderInstanceEntries(
122 applyProviderInstanceSettings(deriveProviderInstanceEntries(textGenerationProviders), settings),
139 projectEffectiveTextGenerationProviders,
123140 );
124141 const canEnableDedicatedModel = instanceEntries.some(
125142 (entry) =>
......@@ -127,7 +144,7 @@ export function SourceControlWritingSettingsSection() {
127144 );
128145 const modelOptionsByInstance = getCustomModelOptionsByInstance(
129146 settings,
130 textGenerationProviders,
147 projectEffectiveTextGenerationProviders,
131148 activeSelection.instanceId,
132149 activeSelection.model,
133150 );
......@@ -139,6 +156,7 @@ export function SourceControlWritingSettingsSection() {
139156 textGenerationModelSelection: resolveReviewGuideModelSelection(
140157 settings,
141158 textGenerationProviders,
159 target?.projectId ?? null,
142160 ),
143161 },
144162 textGenerationProviders,
apps/web/src/components/settings/useScopedModelAvailability.ts+1
......@@ -35,6 +35,7 @@ export function useScopedModelDisabledReason(
3535 const entry = applyProviderInstanceSettings(
3636 deriveProviderInstanceEntries(config.providers),
3737 candidate.settings,
38 candidate.projectId,
3839 ).find((option) => option.instanceId === instanceId);
3940 const options = getCustomModelOptionsByInstance(
4041 mergeEnvironmentSettings(candidate.settings, settings),
apps/web/src/providerInstances.test.ts+83-35
......@@ -136,14 +136,19 @@ describe("applyProviderInstanceSettings", () => {
136136 const entries = deriveProviderInstanceEntries([
137137 provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" }),
138138 ]);
139 const [entry] = applyProviderInstanceSettings(entries, {
140 providerInstances: {
141 [ProviderInstanceId.make("codex")]: {
142 driver: ProviderDriverKind.make("codex"),
143 enabled: false,
139 const [entry] = applyProviderInstanceSettings(
140 entries,
141 {
142 providerInstances: {
143 [ProviderInstanceId.make("codex")]: {
144 driver: ProviderDriverKind.make("codex"),
145 enabled: false,
146 },
144147 },
148 projectSettingsOverrides: {},
145149 },
146 });
150 null,
151 );
147152
148153 expect(entry?.enabled).toBe(false);
149154 });
......@@ -155,9 +160,14 @@ describe("applyProviderInstanceSettings", () => {
155160 instanceId: "claude_work",
156161 }),
157162 ]);
158 const [entry] = applyProviderInstanceSettings(entries, {
159 providerInstances: {},
160 });
163 const [entry] = applyProviderInstanceSettings(
164 entries,
165 {
166 providerInstances: {},
167 projectSettingsOverrides: {},
168 },
169 null,
170 );
161171
162172 expect(entry?.enabled).toBe(false);
163173 });
......@@ -171,9 +181,14 @@ describe("applyProviderInstanceSettings", () => {
171181 instanceId,
172182 }),
173183 ]);
174 const [entry] = applyProviderInstanceSettings(entries, {
175 providerInstances: {},
176 });
184 const [entry] = applyProviderInstanceSettings(
185 entries,
186 {
187 providerInstances: {},
188 projectSettingsOverrides: {},
189 },
190 null,
191 );
177192
178193 expect(entry?.enabled).toBe(false);
179194 },
......@@ -187,14 +202,19 @@ describe("applyProviderInstanceSettings", () => {
187202 instanceId,
188203 }),
189204 ]);
190 const [entry] = applyProviderInstanceSettings(entries, {
191 providerInstances: {
192 [instanceId]: {
193 driver: ProviderDriverKind.make("claudeAgent"),
194 enabled: false,
205 const [entry] = applyProviderInstanceSettings(
206 entries,
207 {
208 providerInstances: {
209 [instanceId]: {
210 driver: ProviderDriverKind.make("claudeAgent"),
211 enabled: false,
212 },
195213 },
214 projectSettingsOverrides: {},
196215 },
197 });
216 null,
217 );
198218
199219 expect(entry?.enabled).toBe(false);
200220 });
......@@ -207,9 +227,14 @@ describe("applyProviderInstanceSettings", () => {
207227 instanceId: "constructor",
208228 }),
209229 ]);
210 const [entry] = applyProviderInstanceSettings(entries, {
211 providerInstances: {},
212 });
230 const [entry] = applyProviderInstanceSettings(
231 entries,
232 {
233 providerInstances: {},
234 projectSettingsOverrides: {},
235 },
236 null,
237 );
213238
214239 expect(entry?.isDefault).toBe(true);
215240 expect(entry?.enabled).toBe(false);
......@@ -220,7 +245,14 @@ describe("applyProviderInstanceSettings", () => {
220245 provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex", enabled: false }),
221246 provider({ provider: ProviderDriverKind.make("grok"), instanceId: "grok", enabled: true }),
222247 ]);
223 const [codex, grok] = applyProviderInstanceSettings(entries, { providerInstances: {} });
248 const [codex, grok] = applyProviderInstanceSettings(
249 entries,
250 {
251 providerInstances: {},
252 projectSettingsOverrides: {},
253 },
254 null,
255 );
224256
225257 // Settings decide over a stale probe: Codex starts on, Grok starts off.
226258 expect(codex?.enabled).toBe(true);
......@@ -260,6 +292,7 @@ describe("deriveProviderEntriesByEnvironment", () => {
260292 config: { agentId, registryIconUrl: `https://example.com/${agentId}.svg` },
261293 },
262294 },
295 projectSettingsOverrides: {},
263296 },
264297 ] as const,
265298 ),
......@@ -618,29 +651,44 @@ describe("provider icon metadata", () => {
618651 { ...provider({ provider: driver, instanceId }), iconUrl },
619652 ]);
620653 const registryConfig = { agentId: "swe-agent", registryIconUrl: iconUrl };
621 const brandedEntries = applyProviderInstanceSettings(snapshots, {
622 providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } },
623 });
654 const brandedEntries = applyProviderInstanceSettings(
655 snapshots,
656 {
657 providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } },
658 projectSettingsOverrides: {},
659 },
660 null,
661 );
624662 expect(brandedEntries[0]?.acpRegistryAgentId).toBe("swe-agent");
625663 expect(brandedEntries[0]?.acpRegistryIconUrl).toBe(iconUrl);
626664
627 const [localEntry] = applyProviderInstanceSettings(brandedEntries, {
628 providerInstances: {
629 [instanceId]: {
630 driver,
631 enabled: false,
632 config: { ...registryConfig, source: "local", commandPath: "dsh" },
665 const [localEntry] = applyProviderInstanceSettings(
666 brandedEntries,
667 {
668 providerInstances: {
669 [instanceId]: {
670 driver,
671 enabled: false,
672 config: { ...registryConfig, source: "local", commandPath: "dsh" },
673 },
633674 },
675 projectSettingsOverrides: {},
634676 },
635 });
677 null,
678 );
636679 expect(localEntry?.acpRegistryAgentId).toBeUndefined();
637680 expect(localEntry?.acpRegistryIconUrl).toBeUndefined();
638681 expect(localEntry?.enabled).toBe(false);
639682 expect(localEntry?.snapshot).toBe(snapshots[0]?.snapshot);
640683
641 const [restoredEntry] = applyProviderInstanceSettings(localEntry ? [localEntry] : [], {
642 providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } },
643 });
684 const [restoredEntry] = applyProviderInstanceSettings(
685 localEntry ? [localEntry] : [],
686 {
687 providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } },
688 projectSettingsOverrides: {},
689 },
690 null,
691 );
644692 expect(restoredEntry?.acpRegistryAgentId).toBe("swe-agent");
645693 expect(restoredEntry?.acpRegistryIconUrl).toBe(iconUrl);
646694 expect(restoredEntry?.enabled).toBe(true);
apps/web/src/providerInstances.ts+21-10
......@@ -15,9 +15,9 @@
1515import {
1616 DEFAULT_MODEL_BY_PROVIDER,
1717 defaultInstanceIdForDriver,
18 isUnconfiguredDefaultInstanceEnabled,
19 resolveProviderInstanceEnabled,
18 resolveProjectProviderInstanceEnabled,
2019 type ModelSelection,
20 type ProjectId,
2121 type ProviderDriverKind,
2222 ProviderInstanceId,
2323 type ServerProvider,
......@@ -154,13 +154,19 @@ export function deriveProviderInstanceEntries(
154154 */
155155export function deriveProviderEntriesByEnvironment(
156156 providersByEnvironment: Iterable<
157 readonly [string, ReadonlyArray<ServerProvider>, Pick<ServerSettings, "providerInstances">?]
157 readonly [
158 string,
159 ReadonlyArray<ServerProvider>,
160 Pick<ServerSettings, "providerInstances" | "projectSettingsOverrides">?,
161 ]
158162 >,
159163): ReadonlyMap<string, ReadonlyMap<string, ProviderInstanceEntry>> {
160164 const byEnvironment = new Map<string, ReadonlyMap<string, ProviderInstanceEntry>>();
161165 for (const [environmentId, providers, settings] of providersByEnvironment) {
162166 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;
164170 byEnvironment.set(
165171 environmentId,
166172 new Map(entries.map((entry) => [entry.instanceId as string, entry] as const)),
......@@ -179,20 +185,25 @@ export function deriveProviderEntriesByEnvironment(
179185 * streamed state, since the server runs it with default config. Any other
180186 * instance missing from `providerInstances` is stale (for example
181187 * 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.
182196 */
183197export function applyProviderInstanceSettings(
184198 entries: ReadonlyArray<ProviderInstanceEntry>,
185 settings: Pick<ServerSettings, "providerInstances">,
199 settings: Pick<ServerSettings, "providerInstances" | "projectSettingsOverrides">,
200 projectId: ProjectId | null,
186201): ReadonlyArray<ProviderInstanceEntry> {
187202 return entries.map((entry) => {
188203 const explicitInstance = Object.hasOwn(settings.providerInstances, entry.instanceId)
189204 ? settings.providerInstances[entry.instanceId]
190205 : undefined;
191 const enabled = explicitInstance
192 ? resolveProviderInstanceEnabled(explicitInstance)
193 : entry.isDefault
194 ? isUnconfiguredDefaultInstanceEnabled(entry.instanceId)
195 : false;
206 const enabled = resolveProjectProviderInstanceEnabled(settings, projectId, entry.instanceId);
196207 if (entry.driverKind !== "acpRegistry" || explicitInstance === undefined) {
197208 return enabled === entry.enabled ? entry : { ...entry, enabled };
198209 }
apps/web/src/routes/settings.providers.tsx+7
......@@ -25,6 +25,13 @@ function SettingsProvidersRoute() {
2525 <ProviderSettingsPanel
2626 environmentId={environment.environmentId}
2727 {...(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 }
2835 scoped
2936 />
3037 );
docs/user/project-settings.md+8
......@@ -27,6 +27,14 @@ again.
2727Providers and diagnostics are per machine: they show one environment at a time, the primary
2828one 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
3038On mobile, open **Settings** and use the filter in its header to choose connected environments
3139and a project. The filter stays available in server-setting pages. With **All projects** selected,
3240the **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({
135135 threadRestartContinuation: Schema.optionalKey(Schema.Boolean),
136136 /** Server resolves `projectSettingsOverrides`; older servers ignore the key. */
137137 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),
138143 /** Server understands thread.snooze / thread.unsnooze commands. Same
139144 version-skew contract as threadSettlement. */
140145 threadSnooze: Schema.optionalKey(Schema.Boolean),
packages/contracts/src/settings.test.ts+73
......@@ -9,6 +9,7 @@ import {
99 ClaudeSettings,
1010 DEFAULT_SERVER_SETTINGS,
1111 requiredScopesForProjectMutation,
12 resolveProjectProviderInstanceEnabled,
1213 resolveProviderInstanceEnabled,
1314 ServerSettings,
1415 ServerSettingsPatch,
......@@ -1063,6 +1064,78 @@ describe("ServerSettings.removeAgentCreditsOnMerge", () => {
10631064 });
10641065});
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
10661139describe("requiredScopesForProjectMutation", () => {
10671140 it("adds the settings scope only when a mutation carries scripts", () => {
10681141 const projectId = "project-1" as ProjectId;
packages/contracts/src/settings.ts+52-5
......@@ -972,10 +972,24 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [
972972] as const;
973973export 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
975980/**
976981 * One project's overrides. An absent key inherits the environment value;
977982 * `null` is a real value where the environment type is nullable (no default
978983 * 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.
979993 */
980994export const ProjectSettingsOverrides = Schema.Struct({
981995 worktreeCleanup: Schema.optionalKey(WorktreeCleanup),
......@@ -1002,7 +1016,10 @@ export const ProjectSettingsOverrides = Schema.Struct({
10021016 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
10031017 continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean),
10041018 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>);
10061023export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type;
10071024
10081025/**
......@@ -1126,10 +1143,11 @@ export const ServerSettings = Schema.Struct({
11261143 Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE)),
11271144 ),
11281145 /**
1129 * Per-project overrides of the keys in `PROJECT_SCOPED_SERVER_SETTING_KEYS`.
1130 * The source of truth for project settings; `projectAgentBrowserAccessOverrides`,
1131 * `projectAutoPullOverrides` and `projectScriptOverrides` are derived views
1132 * kept for one release so older clients keep reading them.
1146 * Per-project overrides of the keys in `PROJECT_SCOPED_SERVER_SETTING_KEYS`,
1147 * plus `providerInstances`. The source of truth for project
1148 * settings; `projectAgentBrowserAccessOverrides`, `projectAutoPullOverrides`
1149 * and `projectScriptOverrides` are derived views kept for one release so
1150 * older clients keep reading them.
11331151 */
11341152 projectSettingsOverrides: Schema.Record(ProjectId, ProjectSettingsOverrides).pipe(
11351153 Schema.withDecodingDefault(Effect.succeed({})),
......@@ -1371,6 +1389,30 @@ export const resolveProviderInstanceEnabled = (
13711389 return instance.enabled ?? configEnabled ?? defaultEnabledForDriver(instance.driver);
13721390};
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
13741416export const ServerSettingsOperation = Schema.Literals([
13751417 "normalize",
13761418 "check-exists",
......@@ -1498,6 +1540,11 @@ export const ServerSettingsPatch = Schema.Struct({
14981540 * the entry without that key. Per-key null cannot express "clear" for the
14991541 * keys whose value type is itself nullable, and clients always hold the
15001542 * 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: {}`.
15011548 */
15021549 projectSettingsOverrides: Schema.optionalKey(
15031550 Schema.Record(ProjectId, Schema.NullOr(ProjectSettingsOverrides)),
packages/contracts/src/t3ProjectFile.ts+4-1
......@@ -126,7 +126,10 @@ export const PROJECT_FILE_BACKED_SETTINGS = {
126126 defaultThreadEnvMode: { field: "defaultThreadEnvMode", builtIn: "local" },
127127 worktreeSubmodules: { field: "worktreeSubmodules", builtIn: "recursive" },
128128} 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]?: {
130133 readonly field: {
131134 readonly [F in keyof T3ProjectFile]: T3ProjectFile[F] extends
132135 | Exclude<ServerSettings[K], null>
packages/shared/src/projectSettings.test.ts+38
......@@ -147,6 +147,44 @@ describe("resolveProjectSettings", () => {
147147 expect(resolved.settings.defaultModelSelection).toBeNull();
148148 expect(resolved.sources.defaultModelSelection).toBe("environment");
149149 });
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 });
150188});
151189
152190describe("resolveProjectSettings with a t3.json", () => {
packages/shared/src/projectSettings.ts+20-3
......@@ -6,6 +6,7 @@ import {
66 type ProjectId,
77 type ProjectScopedServerSettingKey,
88 type ProjectSettingsOverrides,
9 resolveProjectProviderInstanceEnabled,
910 type ResolvedServerSettings,
1011 type ServerSettings,
1112 type T3ProjectFile,
......@@ -13,7 +14,7 @@ import {
1314 type WorktreeCleanupRules,
1415} from "@t3tools/contracts";
1516import { normalizeProjectPathForComparison } from "./path.ts";
16import { isModelSelectionProviderEnabled } from "./serverSettings.ts";
17import { fallbackTextGenerationModelSelection } from "./serverSettings.ts";
1718
1819/**
1920 * Where a project-scoped value came from. The order is the priority order:
......@@ -174,18 +175,34 @@ function resolveProjectOverrides(
174175 // undefined; that is not an override.
175176 if (value === undefined) continue;
176177 // 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.
178180 if (
179181 (key === "textGenerationModelSelection" || key === "defaultModelSelection") &&
180182 value !== undefined &&
181183 value !== null &&
182 !isModelSelectionProviderEnabled(settings, value as ModelSelection)
184 !resolveProjectProviderInstanceEnabled(
185 settings,
186 projectId,
187 (value as ModelSelection).instanceId,
188 )
183189 ) {
184190 continue;
185191 }
186192 effective[key] = value;
187193 sources[key] = "project";
188194 }
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 }
189206 return { settings: effective as ServerSettings, sources, overrides };
190207}
191208
packages/shared/src/serverSettings.test.ts+160-3
......@@ -13,6 +13,7 @@ import { createModelSelection } from "./model.ts";
1313import { resolveProjectScripts, projectScriptsInheritDefaults } from "./projectScripts.ts";
1414import {
1515 applyServerSettingsPatch,
16 fallbackTextGenerationModelSelection,
1617 modelPreferencesMigrationPatch,
1718 resolveModelPreferences,
1819 isModelSelectionProviderEnabled,
......@@ -25,6 +26,32 @@ import {
2526/** Settings after the server has folded legacy per-project fields into `projectSettingsOverrides`. */
2627const 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
2855describe("serverSettings helpers", () => {
2956 it("changes a cleanup rule without replacing the machine's other rules", () => {
3057 const enabled = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
......@@ -442,7 +469,7 @@ describe("serverSettings helpers", () => {
442469 expect(isModelSelectionProviderEnabled(settings, sourceControlWriterModelSelection)).toBe(
443470 false,
444471 );
445 expect(resolveSourceControlWriterModelSelection(settings)).toBe(
472 expect(resolveSourceControlWriterModelSelection(settings, undefined, null)).toBe(
446473 settings.textGenerationModelSelection,
447474 );
448475 expect(settings.sourceControlWriterModelSelection).toBe(sourceControlWriterModelSelection);
......@@ -477,7 +504,7 @@ describe("serverSettings helpers", () => {
477504 skills: [],
478505 } satisfies ServerProvider;
479506
480 expect(resolveSourceControlWriterModelSelection(settings, [unavailableProvider])).toBe(
507 expect(resolveSourceControlWriterModelSelection(settings, [unavailableProvider], null)).toBe(
481508 settings.textGenerationModelSelection,
482509 );
483510 expect(settings.sourceControlWriterModelSelection).toBe(sourceControlWriterModelSelection);
......@@ -512,12 +539,86 @@ describe("serverSettings helpers", () => {
512539 skills: [],
513540 } satisfies ServerProvider;
514541
515 expect(resolveSourceControlWriterModelSelection(settings, [incapableProvider])).toBe(
542 expect(resolveSourceControlWriterModelSelection(settings, [incapableProvider], null)).toBe(
516543 settings.textGenerationModelSelection,
517544 );
518545 expect(settings.sourceControlWriterModelSelection).toBe(sourceControlWriterModelSelection);
519546 });
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
521622 it("replaces providerInstances maps so omitted instance fields are cleared", () => {
522623 const codexId = ProviderInstanceId.make("codex");
523624 const current = {
......@@ -552,6 +653,62 @@ describe("serverSettings helpers", () => {
552653 });
553654 });
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
555712 it("upserts and removes usageLimitSources per entry so concurrent edits cannot clobber", () => {
556713 const hubA = UsageLimitSourceId.make("cliproxy-a");
557714 const hubB = UsageLimitSourceId.make("cliproxy-b");
packages/shared/src/serverSettings.ts+112-6
......@@ -1,13 +1,20 @@
11import type { ModelPreferences } from "@t3tools/contracts/settings";
22import {
3 DEFAULT_MODEL_BY_PROVIDER,
4 DEFAULT_TEXT_GENERATION_MODEL,
5 DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER,
6 defaultInstanceIdForDriver,
37 isProviderAvailable,
48 isUnconfiguredDefaultInstanceEnabled,
9 resolveProjectProviderInstanceEnabled,
510 resolveProviderInstanceEnabled,
611 isProviderTextGenerationCapable,
712 type ModelSelection,
813 type ProjectId,
914 type ProjectScopedServerSettingKey,
1015 type ProjectSettingsOverrides,
16 ProviderDriverKind,
17 ProviderInstanceId,
1118 type ServerProvider,
1219 ServerSettings,
1320 type ServerSettingsPatch,
......@@ -71,12 +78,74 @@ export function isModelSelectionProviderEnabled(
7178 return isUnconfiguredDefaultInstanceEnabled(selection.instanceId);
7279}
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 */
74139export function resolveSourceControlWriterModelSelection(
75140 settings: ServerSettings,
76 providers?: ReadonlyArray<ServerProvider>,
141 providers: ReadonlyArray<ServerProvider> | undefined,
142 projectId: ProjectId | null,
77143): ModelSelection {
78144 const selection = settings.sourceControlWriterModelSelection;
79 if (!selection || !isModelSelectionProviderEnabled(settings, selection)) {
145 if (
146 !selection ||
147 !resolveProjectProviderInstanceEnabled(settings, projectId, selection.instanceId)
148 ) {
80149 return settings.textGenerationModelSelection;
81150 }
82151 if (providers === undefined) {
......@@ -97,10 +166,14 @@ export function resolveSourceControlWriterModelSelection(
97166 */
98167export function resolveReviewGuideModelSelection(
99168 settings: ServerSettings,
100 providers?: ReadonlyArray<ServerProvider>,
169 providers: ReadonlyArray<ServerProvider> | undefined,
170 projectId: ProjectId | null,
101171): ModelSelection {
102172 const selection = settings.reviewGuideModelSelection;
103 if (selection && isModelSelectionProviderEnabled(settings, selection)) {
173 if (
174 selection &&
175 resolveProjectProviderInstanceEnabled(settings, projectId, selection.instanceId)
176 ) {
104177 const provider = providers?.find((candidate) => candidate.instanceId === selection.instanceId);
105178 if (
106179 providers === undefined ||
......@@ -111,7 +184,7 @@ export function resolveReviewGuideModelSelection(
111184 return selection;
112185 }
113186 }
114 return resolveSourceControlWriterModelSelection(settings, providers);
187 return resolveSourceControlWriterModelSelection(settings, providers, projectId);
115188}
116189
117190export interface PersistedServerObservabilitySettings {
......@@ -191,6 +264,36 @@ function mergeSettingsEntries<Value>(
191264 return Object.fromEntries(next);
192265}
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
194297/**
195298 * Derived views of `projectSettingsOverrides` for clients that still read
196299 * the legacy per-key maps. Recomputed on every patch and load so they
......@@ -463,7 +566,10 @@ export function applyServerSettingsPatch(
463566 ? {
464567 projectSettingsOverrides: Object.fromEntries(
465568 Object.entries(
466 mergeSettingsEntries(current.projectSettingsOverrides, projectSettingsOverridesPatch),
569 mergeProjectSettingsOverridesPatch(
570 current.projectSettingsOverrides,
571 projectSettingsOverridesPatch,
572 ),
467573 ).filter(([, entry]) => Object.keys(entry).length > 0),
468574 ),
469575 }