Skip to content

Project execution defaults are one row per project: using a second provider erases the first provider's remembered model and reasoning #3463

Description

@albrand

What happens

Use codex in a project with, say, high reasoning. Then start one thread on claude-code. Go back to codex: the project no longer remembers codex's model or reasoning; it falls back to the provider defaults. Each provider switch erases the other provider's remembered settings.

Why

project_execution_defaults holds one row per project:

  • packages/db/src/schema.ts:148: uniqueIndex("project_execution_defaults_project_idx").on(table.projectId)
  • packages/db/src/data/project-execution-defaults.ts:93: the upsert conflicts on projectId alone and overwrites providerId.
  • apps/server/src/services/threads/thread-execution-plan.ts:270 then discards the stored row whenever its provider differs from the thread's, so an existing codex thread gets no project defaults after any claude-code thread was created.

The existing tests describe the intended behaviour as provider-scoped ("upserts provider-scoped execution defaults", project-execution-defaults.test.ts:39; "returns null when a project has no stored defaults for a provider"; "replaces the previous defaults for the same project and provider"). The storage doesn't implement that.

Fix (patch below, against main 8d32c61)

  • Unique index on (project_id, provider_id) (migration 0117), with the upsert conflicting on both columns.
  • getProjectExecutionDefaults takes an optional providerId. With one, it returns that provider's row. Without one, it returns the project's most recently used provider, ordered by updated_at then rowid, so new-thread preselection behaves exactly as today.
  • The read paths for an existing thread (thread-execution-plan.ts, thread-execution-override.ts) and create with an explicitly requested provider ask for their own provider's row.

This also fixes a latent bug: thread-execution-override.ts returned the other provider's model as a fallback when the stored row belonged to a different provider.

Tests

  • New: "keeps each provider's defaults when another provider is used". It fails on main and passes with the patch.
  • The existing "replaces the remembered provider choice for the project" still passes.
  • packages/db defaults suite 6/6. Server project-execution-defaults, thread-execution-plan, public-threads.defaults and public-project-startup 27/27. Typecheck green.

The migration SQL and journal entry are in the patch. The 0117_snapshot.json I derived from 0116 is left out of the issue for size; regenerate it with drizzle-kit.

Patch (without the generated snapshot)
diff --git a/apps/server/src/services/threads/project-execution-defaults.ts b/apps/server/src/services/threads/project-execution-defaults.ts
index fa1870e5..f68bddbf 100644
--- a/apps/server/src/services/threads/project-execution-defaults.ts
+++ b/apps/server/src/services/threads/project-execution-defaults.ts
@@ -78,14 +78,17 @@ export function resolveProjectExecutionDefaultsForCreate(
   deps: Pick<AppDeps, "db" | "providerRegistry">,
   args: ResolveProjectExecutionDefaultsForCreateArgs,
 ): ResolvedProjectExecutionDefaultsForCreate {
-  const storedDefaults = getProjectExecutionDefaults(deps.db, {
-    projectId: args.projectId,
-  });
   const requestedProviderId = resolveRequestedCreateExecutionValue({
     field: "providerId",
     sources: args.executionInputSources,
     value: args.providerId,
   });
+  // A requested provider gets ITS remembered settings; none requested gets the
+  // project's most recently used provider.
+  const storedDefaults = getProjectExecutionDefaults(deps.db, {
+    projectId: args.projectId,
+    ...(requestedProviderId ? { providerId: requestedProviderId } : {}),
+  });
   const requestedModel = resolveRequestedCreateExecutionValue({
     field: "model",
     sources: args.executionInputSources,
diff --git a/apps/server/src/services/threads/thread-execution-override.ts b/apps/server/src/services/threads/thread-execution-override.ts
index bdff65d0..2bfae984 100644
--- a/apps/server/src/services/threads/thread-execution-override.ts
+++ b/apps/server/src/services/threads/thread-execution-override.ts
@@ -199,6 +199,7 @@ function resolveFallbackModel(
   }
   const projectDefaults = getProjectExecutionDefaults(deps.db, {
     projectId: thread.projectId,
+    providerId: thread.providerId,
   });
   return projectDefaults?.model ?? null;
 }
diff --git a/apps/server/src/services/threads/thread-execution-plan.ts b/apps/server/src/services/threads/thread-execution-plan.ts
index d8739be0..e269d61b 100644
--- a/apps/server/src/services/threads/thread-execution-plan.ts
+++ b/apps/server/src/services/threads/thread-execution-plan.ts
@@ -73,6 +73,7 @@ export function resolveExistingThreadPermissionMode(
   }
   const projectDefaults = getProjectExecutionDefaults(deps.db, {
     projectId: thread.projectId,
+    providerId: thread.providerId,
   });
   const projectExecution =
     projectDefaults?.providerId === thread.providerId ? projectDefaults : null;
@@ -264,6 +265,7 @@ export async function resolveExistingThreadExecutionPlan(
     args.projectDefaults === undefined
       ? getProjectExecutionDefaults(deps.db, {
           projectId: thread.projectId,
+          providerId: thread.providerId,
         })
       : args.projectDefaults;
   const projectExecution =
diff --git a/packages/db/drizzle/0117_project_defaults_per_provider.sql b/packages/db/drizzle/0117_project_defaults_per_provider.sql
new file mode 100644
index 00000000..fa146665
--- /dev/null
+++ b/packages/db/drizzle/0117_project_defaults_per_provider.sql
@@ -0,0 +1,2 @@
+DROP INDEX `project_execution_defaults_project_idx`;--> statement-breakpoint
+CREATE UNIQUE INDEX `project_execution_defaults_project_provider_idx` ON `project_execution_defaults` (`project_id`,`provider_id`);
diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json
index 96049dca..2d179472 100644
--- a/packages/db/drizzle/meta/_journal.json
+++ b/packages/db/drizzle/meta/_journal.json
@@ -820,6 +820,13 @@
       "when": 1789075667774,
       "tag": "0116_majestic_swordsman",
       "breakpoints": true
+    },
+    {
+      "idx": 117,
+      "version": "6",
+      "when": 1789200000000,
+      "tag": "0117_project_defaults_per_provider",
+      "breakpoints": true
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/packages/db/src/data/project-execution-defaults.ts b/packages/db/src/data/project-execution-defaults.ts
index 8de60373..cfc682cd 100644
--- a/packages/db/src/data/project-execution-defaults.ts
+++ b/packages/db/src/data/project-execution-defaults.ts
@@ -1,4 +1,4 @@
-import { eq, inArray } from "drizzle-orm";
+import { and, asc, desc, eq, inArray, sql } from "drizzle-orm";
 import type {
   ProjectExecutionDefaults,
   PermissionMode,
@@ -10,6 +10,12 @@ import { projectExecutionDefaults } from "../schema.js";
 
 export interface GetProjectExecutionDefaultsArgs {
   projectId: string;
+  /**
+   * The provider whose remembered settings are wanted. Omitted, the project's
+   * most recently used provider is returned, which is what preselects a new
+   * thread.
+   */
+  providerId?: string;
 }
 
 export interface ListProjectExecutionDefaultsByProjectIdsArgs {
@@ -38,7 +44,17 @@ export function getProjectExecutionDefaults(
       serviceTier: projectExecutionDefaults.serviceTier,
     })
     .from(projectExecutionDefaults)
-    .where(eq(projectExecutionDefaults.projectId, args.projectId))
+    .where(
+      args.providerId === undefined
+        ? eq(projectExecutionDefaults.projectId, args.projectId)
+        : and(
+            eq(projectExecutionDefaults.projectId, args.projectId),
+            eq(projectExecutionDefaults.providerId, args.providerId),
+          ),
+    )
+    // rowid breaks a same-millisecond tie toward the row written last.
+    .orderBy(desc(projectExecutionDefaults.updatedAt), desc(sql`rowid`))
+    .limit(1)
     .get();
 
   return row ?? null;
@@ -64,6 +80,8 @@ export function listProjectExecutionDefaultsByProjectIds(
     })
     .from(projectExecutionDefaults)
     .where(inArray(projectExecutionDefaults.projectId, [...args.projectIds]))
+    // Oldest first, so the loop below leaves each project's latest provider.
+    .orderBy(asc(projectExecutionDefaults.updatedAt), asc(sql`rowid`))
     .all();
 
   for (const row of rows) {
@@ -90,9 +108,8 @@ export function upsertProjectExecutionDefaults(
       updatedAt,
     })
     .onConflictDoUpdate({
-      target: [projectExecutionDefaults.projectId],
+      target: [projectExecutionDefaults.projectId, projectExecutionDefaults.providerId],
       set: {
-        providerId: args.providerId,
         model: args.model,
         reasoningLevel: args.reasoningLevel,
         permissionMode: args.permissionMode,
diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts
index aa6443cc..03e1ba35 100644
--- a/packages/db/src/schema.ts
+++ b/packages/db/src/schema.ts
@@ -145,7 +145,10 @@ export const projectExecutionDefaults = sqliteTable(
     updatedAt: integer("updated_at").notNull(),
   },
   (table) => [
-    uniqueIndex("project_execution_defaults_project_idx").on(table.projectId),
+    uniqueIndex("project_execution_defaults_project_provider_idx").on(
+      table.projectId,
+      table.providerId,
+    ),
   ],
 );
 
diff --git a/packages/db/test/data/project-execution-defaults.test.ts b/packages/db/test/data/project-execution-defaults.test.ts
index 851cfcfc..8b5e0071 100644
--- a/packages/db/test/data/project-execution-defaults.test.ts
+++ b/packages/db/test/data/project-execution-defaults.test.ts
@@ -124,6 +124,41 @@ describe("project-execution-defaults", () => {
     });
   });
 
+  it("keeps each provider's defaults when another provider is used", () => {
+    const { db, project } = setup();
+
+    upsertProjectExecutionDefaults(db, {
+      projectId: project.id,
+      providerId: "codex",
+      model: "gpt-5",
+      reasoningLevel: "high",
+      permissionMode: "full",
+      serviceTier: "default",
+      updatedAt: 1,
+    });
+    upsertProjectExecutionDefaults(db, {
+      projectId: project.id,
+      providerId: "claude-code",
+      model: "claude-opus-4-1",
+      reasoningLevel: "medium",
+      permissionMode: "auto",
+      serviceTier: "fast",
+      updatedAt: 2,
+    });
+
+    // Using claude-code must not erase what the project remembers for codex.
+    expect(
+      getProjectExecutionDefaults(db, {
+        projectId: project.id,
+        providerId: "codex",
+      }),
+    ).toMatchObject({ providerId: "codex", model: "gpt-5", reasoningLevel: "high" });
+    // Unscoped reads still preselect the most recently used provider.
+    expect(
+      getProjectExecutionDefaults(db, { projectId: project.id }),
+    ).toMatchObject({ providerId: "claude-code" });
+  });
+
   it("deletes defaults when the project is deleted", () => {
     const { db, project } = setup();
 

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    confirmed-reproBug reproduced again from a clean trusted checkout; see linked reportprovidersCross-provider bridges, models, login

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions