Skip to content
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
538845a
feat(server,web,mobile): fall back when the default provider is out o…
maria-rcks Oct 27, 2026
2b814d7
fix(web,shared): keep sticky models and skip signed-out fallbacks
maria-rcks Oct 27, 2026
4010689
fix(shared): only count the usage pools the default model draws from
maria-rcks Oct 27, 2026
c0a6ed9
fix(server): skip signed-out providers in the text generation fallback
maria-rcks Oct 27, 2026
e89c4fa
fix(shared): count every Antigravity pool for models without a known …
maria-rcks Oct 27, 2026
bccbbb2
fix(server): check text generation fallbacks against the model they w…
maria-rcks Oct 27, 2026
822d971
fix(server): fall back to Codex or Claude for review guides
maria-rcks Oct 27, 2026
45b3a82
fix(server,web): respect provider enablement and OpenCode quota error…
maria-rcks Oct 27, 2026
1c7fca3
fix(web): pick the auto default like the composer and hide the fallba…
maria-rcks Oct 27, 2026
fb29b26
fix(web): resolve the auto default within the composer's first driver…
maria-rcks Oct 27, 2026
5ddc2fe
fix(server): refresh provider usage when a turn hits its usage limit
maria-rcks Oct 28, 2026
38a5136
fix(server): re-probe Claude usage past its cache and classify usage …
maria-rcks Oct 28, 2026
c57da93
fix(cursor): treat the SDK's detailed rate and usage limit codes as u…
maria-rcks Oct 28, 2026
43f7015
refactor(server): trim usage refresh tests and OpenCode reset parsing
maria-rcks Oct 28, 2026
ab32a3d
refactor(server,web): trim model fallback to its essentials
maria-rcks Oct 28, 2026
4315e9c
Merge remote-tracking branch 'origin/feat/model-fallback' into feat/m…
maria-rcks Oct 28, 2026
56469c8
fix(server,web): keep the draft on the fallback and check text genera…
maria-rcks Oct 28, 2026
60c0f1a
Merge remote-tracking branch 'origin/feat/model-fallback' into feat/m…
maria-rcks Oct 28, 2026
fe72653
Merge main into feat/model-fallback
maria-rcks Oct 28, 2026
8f1ecc2
Merge remote-tracking branch 'origin/feat/model-fallback' into feat/m…
maria-rcks Oct 28, 2026
fe8bc02
fix(web): give the fallback model notice the usage notice layout and …
maria-rcks Oct 28, 2026
096700d
Merge remote-tracking branch 'origin/feat/model-fallback' into feat/m…
maria-rcks Oct 28, 2026
ed3f6de
fix(web): let the fallback model notice be dismissed until its usage …
maria-rcks Oct 29, 2026
632a9d1
Merge remote-tracking branch 'origin/feat/model-fallback' into feat/m…
maria-rcks Oct 29, 2026
d0363d2
fix(web): hoist the fallback notice dismissal schema
maria-rcks Oct 29, 2026
ced10aa
Merge remote-tracking branch 'origin/main' into feat/model-fallback
maria-rcks Oct 29, 2026
4136f10
Merge remote-tracking branch 'origin/feat/model-fallback' into feat/m…
maria-rcks Oct 29, 2026
86b3a57
fix(server): queue a usage refresh behind an in-flight provider refresh
maria-rcks Oct 29, 2026
aaa9ab3
fix(server): only the queued usage refresh clears its own claim
maria-rcks Oct 29, 2026
ec0ef36
test(server): return the never-ending usage probe
maria-rcks Oct 29, 2026
f78151e
fix(server): claim the queued usage refresh without an interrupt gap
maria-rcks Oct 29, 2026
dc7e4fb
Merge remote-tracking branch 'origin/main' into feat/model-fallback-u…
maria-rcks Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { ActiveNewTaskContext } from "../../state/active-new-task";
import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits";
import { useNowMinuteMs } from "../../lib/clock";
import * as Dialog from "../../components/ui/Dialog";
import React, { useEffect, useRef, useState } from "react";

Expand Down Expand Up @@ -483,6 +485,7 @@ export function NewTaskFlowProvider(
selectedProjectDraft.modelSelection ?? null,
selectedProject?.id ?? null,
);
const modelSelectionNow = useNowMinuteMs();
const projectDefaultModelSelection = resolveDefaultableModelSelection(
selectedEnvironmentServerConfig,
projectSettings.settings.defaultModelSelection,
Expand All @@ -501,12 +504,24 @@ export function NewTaskFlowProvider(
selectedProject?.id ?? null,
);

const selectedModel = resolveNewTaskModelSelection({
const selectedDefaultModel = resolveNewTaskModelSelection({
draftSelection: draftModelSelection,
projectDefaultSelection: projectDefaultModelSelection,
stickySelection: stickyModelSelection,
modelOptions,
});
const selectedModel = resolveNewThreadModelSelection({
defaultSelection: selectedDefaultModel,
explicitSelection:
draftModelSelection ?? (projectDefaultModelSelection ? null : stickyModelSelection),
fallbackSelection: selectedEnvironmentServerConfig?.settings.fallbackModelSelection ?? null,
providers: selectedEnvironmentServerConfig?.providers ?? [],
...(selectedEnvironmentServerConfig
? { settings: selectedEnvironmentServerConfig.settings }
: {}),
projectId: selectedProject?.id ?? null,
now: modelSelectionNow,
}).selection;
const selectedModelKey = selectedModel
? `${selectedModel.instanceId}:${selectedModel.model}`
: null;
Expand Down
58 changes: 58 additions & 0 deletions apps/server/src/mcp/toolkits/project/handlers.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts";
import { expect, it } from "@effect/vitest";
import {
EnvironmentId,
ProjectId,
ProviderInstanceId,
ProviderDriverKind,
type ServerProvider,
type Project as ProjectRecord,
type ModelSelection,
type RuntimeMode,
Expand Down Expand Up @@ -37,6 +40,8 @@ import { ProjectToolkit } from "./tools.ts";
const clientLaunchHarness = (input: {
readonly runtimeModeCeiling: RuntimeMode;
readonly defaultRuntimeMode?: RuntimeMode;
readonly fallbackModel?: ModelSelection;
readonly providers?: readonly ServerProvider[];
/** Where Settings saves a project's model; the legacy project field stays null. */
readonly overrideModel?: ModelSelection;
readonly launched: Array<ThreadLaunch.ThreadLaunchInput>;
Expand All @@ -54,7 +59,11 @@ const clientLaunchHarness = (input: {
model: input.overrideModel === undefined ? modelSelection : null,
};
const layerDependencies = Layer.mergeAll(
Layer.mock(ProviderRegistry.ProviderRegistry)({
getProviders: Effect.succeed(input.providers ?? []),
}),
ServerSettings.layerTest({
fallbackModelSelection: input.fallbackModel ?? null,
...(input.defaultRuntimeMode === undefined
? {}
: { defaultRuntimeMode: input.defaultRuntimeMode }),
Expand Down Expand Up @@ -473,3 +482,52 @@ it.effect("a retried worktree launch replays after its base ref is gone", () =>
]);
}),
);

it.effect("a client launches with the fallback for an exhausted default", () =>
Effect.gen(function* () {
const launched: ThreadLaunch.ThreadLaunchInput[] = [];
const fallbackModel = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-6.1-sol" };
const providers: ServerProvider[] = ["claudeAgent", "codex"].map((driver) => ({
instanceId: ProviderInstanceId.make(driver),
driver: ProviderDriverKind.make(driver),
enabled: true,
installed: true,
status: "ready",
version: null,
auth: { status: "authenticated" },
checkedAt: "2026-01-01T00:00:00Z",
models: [],
slashCommands: [],
skills: [],
...(driver === "claudeAgent"
? {
usageLimits: {
checkedAt: "2026-01-01T00:00:00Z",
windows: [{ id: "session", kind: "session", label: "Session", usedPercent: 100 }],
},
}
: {}),
}));
const { projectId, dependencies } = clientLaunchHarness({
runtimeModeCeiling: "full-access",
launched,
fallbackModel,
providers,
});
const toolkit = yield* ProjectToolkit.pipe(
Effect.provide(
McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)),
),
);
const result = yield* toolkit
.handle("t3_thread_launch", {
projectId,
title: "Fallback",
})
.pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies));
expect(result.at(-1)?.result).toMatchObject({
modelSelection: fallbackModel,
});
expect(launched[0]?.modelSelection).toEqual(fallbackModel);
}),
);
16 changes: 15 additions & 1 deletion apps/server/src/mcp/toolkits/project/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.t
import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts";
import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts";
import * as ServerSettings from "../../../serverSettings.ts";
import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts";
import * as DateTime from "effect/DateTime";
import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits";
import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
import { resolveRuntimeMode } from "../../delegatedTaskModes.ts";
import * as McpInvocationContext from "../../McpInvocationContext.ts";
Expand Down Expand Up @@ -180,7 +183,18 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
input.modelSelection ??
launchedModel ??
caller?.modelSelection ??
projectDefaults?.defaultModelSelection ??
(projectDefaults
? resolveNewThreadModelSelection({
defaultSelection: projectDefaults.defaultModelSelection,
fallbackSelection: projectDefaults.fallbackModelSelection,
providers: yield* ProviderRegistry.ProviderRegistry.pipe(
Effect.flatMap((registry) => registry.getProviders),
),
settings: projectDefaults,
projectId,
now: DateTime.toEpochMillis(yield* DateTime.now),
}).selection
: null) ??
undefined;
if (modelSelection === undefined)
return yield* new OrchestratorMcpFailure({
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/mcp/toolkits/project/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import * as FileSystem from "effect/FileSystem";
import * as DocumentText from "../../../provider/DocumentText.ts";
import * as ServerConfig from "../../../config.ts";
import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts";
import * as ServerSettings from "../../../serverSettings.ts";
import * as ThreadLaunchService from "../../../orchestration-v2/ThreadLaunchService.ts";
import * as Crypto from "effect/Crypto";
Expand Down Expand Up @@ -166,6 +167,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", {
FileSystem.FileSystem,
ServerConfig.ServerConfig,
ServerSettings.ServerSettingsService,
ProviderRegistry.ProviderRegistry,
DocumentText.DocumentText,
],
})
Expand Down
109 changes: 108 additions & 1 deletion apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
import * as ProjectionStore from "./ProjectionStore.ts";
import * as ProviderEventIngestor from "./ProviderEventIngestor.ts";
import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts";
import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
import type { ProviderInstance } from "@t3tools/provider-core/server/driver";
import { makeProviderFailure } from "@t3tools/provider-core/server/failure";
import {
makeProviderEventRoutingState,
Expand All @@ -68,8 +71,16 @@ const layerGit = GitVcsDriver.layer.pipe(
Layer.provideMerge(NodeServices.layer),
);

const makeLayerTest = <E>(ingestorEventSink: Layer.Layer<EventSink.EventSinkV2, E>) =>
const layerEmptyInstances = Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({
// @effect-diagnostics-next-line effectSucceedWithVoid:off - Optional instances require undefined rather than void.
getInstance: () => Effect.succeed(undefined),
});
const makeLayerTest = <E>(
ingestorEventSink: Layer.Layer<EventSink.EventSinkV2, E>,
instances = layerEmptyInstances,
) =>
Layer.mergeAll(
instances,
layerTestDatabase,
layerTestStores,
layerTestEventSink,
Expand All @@ -79,6 +90,7 @@ const makeLayerTest = <E>(ingestorEventSink: Layer.Layer<EventSink.EventSinkV2,
ProviderEventIngestor.layer.pipe(
Layer.provide(
Layer.mergeAll(
instances,
layerTestStores,
ingestorEventSink,
IdAllocator.layer,
Expand Down Expand Up @@ -217,6 +229,101 @@ function threadCreatedEvent(

const layer = it.layer(layerTest);

it.effect("refreshes the failing instance snapshot after a committed usage_limit terminal", () =>
Effect.scoped(
Effect.gen(function* () {
const refreshed = yield* Deferred.make<void>();
const adapter = yield* ProviderAdapter.ProviderAdapterV2.pipe(
Effect.provide(
Layer.mock(ProviderAdapter.ProviderAdapterV2)({
instanceId: ProviderInstanceId.make("cursor-work"),
driver: ProviderDriverKind.make("cursor"),
}),
),
);
const instance: ProviderInstance = {
instanceId: ProviderInstanceId.make("cursor-work"),
driverKind: ProviderDriverKind.make("cursor"),
continuationIdentity: {
driverKind: ProviderDriverKind.make("cursor"),
continuationKey: "work",
},
enabled: true,
displayName: undefined,
orchestrationAdapter: adapter,
textGeneration: {
generateBranchName: () => Effect.die("unused"),
generateCommitMessage: () => Effect.die("unused"),
generatePrContent: () => Effect.die("unused"),
generateThreadTitle: () => Effect.die("unused"),
generateReviewGuide: () => Effect.die("unused"),
},
snapshot: {
refreshUsage: Deferred.succeed(refreshed, undefined).pipe(Effect.asVoid),
getSnapshot: Effect.die("unused"),
refresh: Effect.die("unused"),
resolveMaintenance: () => Effect.die("unused"),
applyUsageLimits: () => Effect.die("unused"),
streamChanges: Stream.empty,
},
};
const lookups: ProviderInstanceId[] = [];
const instances = Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({
getInstance: (id) =>
Effect.sync(() => {
lookups.push(id);
return id === instance.instanceId ? instance : undefined;
}),
});
yield* Effect.gen(function* () {
const now = yield* DateTime.now;
const eventSink = yield* EventSink.EventSinkV2;
const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2;
const idAllocator = yield* IdAllocator.IdAllocatorV2;
const threadEvent = yield* threadCreatedEvent(now);
yield* eventSink.write({ events: [threadEvent] });
const input = {
providerSessionId: yield* idAllocator.allocate.providerSession({
providerInstanceId: instance.instanceId,
threadId: threadEvent.threadId,
}),
providerInstanceId: instance.instanceId,
threadId: threadEvent.threadId,
};
for (const failureClass of ["validation_error", "usage_limit"] as const) {
const stored = yield* ingestor.ingestNormalized({
...input,
event: {
type: "turn.terminal",
driver: instance.driverKind,
providerThreadId: idAllocator.derive.providerThread({
driver: instance.driverKind,
nativeThreadId: "limited",
}),
providerTurnId: idAllocator.derive.providerTurn({
driver: instance.driverKind,
nativeTurnId: failureClass,
}),
runOrdinal: 1,
failureItemOrdinal: 1,
status: "failed",
failure: makeProviderFailure({
class: failureClass,
message: "Provider rejected turn.",
}),
threadDisposition: "reusable",
},
});
assert.equal(stored.length, 1);
if (failureClass === "validation_error") assert.deepEqual(lookups, []);
}
assert.deepEqual(lookups, [instance.instanceId]);
yield* Deferred.await(refreshed);
}).pipe(Effect.provide(makeLayerTest(layerTestEventSink, instances)));
}),
),
);

it.effect("records accepted billed turn usage once without billing the context window", () => {
const recorded: Array<Readonly<Record<string, unknown>>> = [];
const layerAnalytics = Layer.succeed(ProviderEventIngestor.ProviderTurnAnalytics, {
Expand Down
21 changes: 21 additions & 0 deletions apps/server/src/orchestration-v2/ProviderEventIngestor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { isTemporaryWorktreeBranch } from "@t3tools/shared/git";
import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model";
import * as AnalyticsService from "../telemetry/AnalyticsService.ts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
import * as EventSink from "./EventSink.ts";
import * as ProjectionStore from "./ProjectionStore.ts";
import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
Expand Down Expand Up @@ -326,6 +327,7 @@ export const layer: Layer.Layer<
| IdAllocator.IdAllocatorV2
| ProjectionStore.ProjectionStoreV2
| ThreadCommandExecutor.ThreadCommandExecutor
| ProviderInstanceRegistry.ProviderInstanceRegistry
> = Layer.effect(
ProviderEventIngestorV2,
Effect.gen(function* () {
Expand All @@ -336,6 +338,8 @@ export const layer: Layer.Layer<
const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor;
const analytics = yield* ProviderTurnAnalytics;
const agentScope = yield* AgentScope;
const instances = yield* ProviderInstanceRegistry.ProviderInstanceRegistry;
const scope = yield* Effect.scope;
const completedTurnAnalytics = new Set<string>();

const makeDomainEvent =
Expand Down Expand Up @@ -794,6 +798,23 @@ export const layer: Layer.Layer<
},
(effect, input) =>
effect.pipe(
Effect.tap((storedEvents) =>
Effect.gen(function* () {
if (
storedEvents.length === 0 ||
input.event.type !== "turn.terminal" ||
input.event.status !== "failed" ||
input.event.failure.class !== "usage_limit"
)
return;
const instance = yield* instances.getInstance(input.providerInstanceId);
if (!instance?.snapshot.refreshUsage) return;
yield* instance.snapshot.refreshUsage.pipe(
Effect.ignoreCause({ log: true }),
Effect.forkIn(scope, { startImmediately: true }),
);
}),
),
Effect.flatMap((storedEvents) =>
Effect.gen(function* () {
if (input.event.type !== "app_thread.created") return storedEvents;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
import * as ProjectionStore from "./ProjectionStore.ts";
import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";
import * as ProviderEventIngestor from "./ProviderEventIngestor.ts";
import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts";
import * as ProviderSessionManager from "./ProviderSessionManager.ts";
Expand Down Expand Up @@ -533,6 +534,10 @@ function layerTest(input: {
const layerProviderEventIngestorTest = ProviderEventIngestor.layer.pipe(
Layer.provide(
Layer.mergeAll(
Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({
// @effect-diagnostics-next-line effectSucceedWithVoid:off - Optional instances require undefined rather than void.
getInstance: () => Effect.succeed(undefined),
}),
layerConfiguredEventSink,
IdAllocator.layer,
layerTestStores,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import * as ProviderAuthService from "../../provider/ProviderAuthService.ts";
import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests";
import * as ProviderContinuationService from "../ProviderContinuationService.ts";
import * as ProviderEventIngestor from "../ProviderEventIngestor.ts";
import * as ProviderInstanceRegistry from "../../provider/ProviderInstanceRegistry.ts";
import * as ProviderRuntimeRecoveryService from "../ProviderRuntimeRecoveryService.ts";
import * as ProviderSessionManager from "../ProviderSessionManager.ts";
import * as CommandOutputRecorder from "./CommandOutputRecorder.ts";
Expand Down Expand Up @@ -320,6 +321,10 @@ export function layerWithRegistry<Error>(
const layerProviderEventIngestorProvided = ProviderEventIngestor.layer.pipe(
Layer.provide(
Layer.mergeAll(
Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({
// @effect-diagnostics-next-line effectSucceedWithVoid:off - Optional instances require undefined rather than void.
getInstance: () => Effect.succeed(undefined),
}),
layerStores,
layerEventSinkProvided,
IdAllocator.layer,
Expand Down
Loading
Loading