diff --git a/server/src/__tests__/heartbeat-plugin-environment.test.ts b/server/src/__tests__/heartbeat-plugin-environment.test.ts index ac9fdb42..46de3f47 100644 --- a/server/src/__tests__/heartbeat-plugin-environment.test.ts +++ b/server/src/__tests__/heartbeat-plugin-environment.test.ts @@ -8,6 +8,8 @@ import { companies, createDb, environments, + executionWorkspaces, + issues, plugins, projects, projectWorkspaces, @@ -17,6 +19,7 @@ import { startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { heartbeatService } from "../services/heartbeat.ts"; +import { instanceSettingsService } from "../services/instance-settings.ts"; import type { PluginWorkerManager } from "../services/plugin-worker-manager.ts"; const adapterExecute = vi.hoisted(() => vi.fn(async () => ({ @@ -76,6 +79,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => { const workspaceId = randomUUID(); const environmentId = randomUUID(); const pluginId = randomUUID(); + const pluginKey = `acme.environments.${pluginId}`; const agentId = randomUUID(); const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-env-heartbeat-")); tempRoots.push(workspaceRoot); @@ -100,6 +104,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => { await db.insert(companies).values({ id: companyId, name: "Acme", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, status: "active", createdAt: new Date(), updatedAt: new Date(), @@ -124,13 +129,13 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => { }); await db.insert(plugins).values({ id: pluginId, - pluginKey: "acme.environments", + pluginKey, packageName: "@acme/paperclip-environments", version: "1.0.0", apiVersion: 1, categories: ["automation"], manifestJson: { - id: "acme.environments", + id: pluginKey, apiVersion: 1, version: "1.0.0", displayName: "Acme Environments", @@ -157,8 +162,8 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => { name: "Plugin Sandbox", driver: "plugin", status: "active", - config: { - pluginKey: "acme.environments", + config: { + pluginKey, driverKey: "sandbox", driverConfig: { template: "base", @@ -213,11 +218,210 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => { leaseMetadata: expect.objectContaining({ driver: "plugin", pluginId, - pluginKey: "acme.environments", + pluginKey, driverKey: "sandbox", }), }); }, { timeout: 5_000 }); expect(adapterExecute).toHaveBeenCalledTimes(1); - }); + }, 15_000); + + it("ignores stale non-reused workspace environment config in favor of the issue selection", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const workspaceId = randomUUID(); + const oldEnvironmentId = randomUUID(); + const newEnvironmentId = randomUUID(); + const pluginId = randomUUID(); + const pluginKey = `acme.environments.${pluginId}`; + const agentId = randomUUID(); + const issueId = randomUUID(); + const staleExecutionWorkspaceId = randomUUID(); + const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-env-issue-")); + tempRoots.push(workspaceRoot); + const workerManager = { + isRunning: vi.fn((id: string) => id === pluginId), + call: vi.fn(async (_pluginId: string, method: string, payload: Record) => { + if (method === "environmentAcquireLease") { + return { + providerLeaseId: `plugin-heartbeat-lease-${String(payload.environmentId)}`, + metadata: { + remoteCwd: `/workspace/${String(payload.environmentId)}`, + }, + }; + } + if (method === "environmentReleaseLease") { + return undefined; + } + throw new Error(`Unexpected plugin environment method: ${method}`); + }), + } as unknown as PluginWorkerManager; + + await instanceSettingsService(db).updateExperimental({ + enableEnvironments: true, + enableIsolatedWorkspaces: true, + }); + await db.insert(companies).values({ + id: companyId, + name: "Acme", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Plugin Environment Issue", + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(projectWorkspaces).values({ + id: workspaceId, + companyId, + projectId, + name: "Primary", + cwd: workspaceRoot, + isPrimary: true, + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(plugins).values({ + id: pluginId, + pluginKey, + packageName: "@acme/paperclip-environments", + version: "1.0.0", + apiVersion: 1, + categories: ["automation"], + manifestJson: { + id: pluginKey, + apiVersion: 1, + version: "1.0.0", + displayName: "Acme Environments", + description: "Test plugin environment driver", + author: "Acme", + categories: ["automation"], + capabilities: ["environment.drivers.register"], + entrypoints: { worker: "dist/worker.js" }, + environmentDrivers: [ + { + driverKey: "sandbox", + displayName: "Sandbox", + configSchema: { type: "object" }, + }, + ], + }, + status: "ready", + installOrder: 1, + updatedAt: new Date(), + } as any); + await db.insert(environments).values([ + { + id: oldEnvironmentId, + companyId, + name: "QA SSH", + driver: "plugin", + status: "active", + config: { + pluginKey, + driverKey: "sandbox", + driverConfig: { + template: "old", + }, + }, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: newEnvironmentId, + companyId, + name: "QA E2B", + driver: "plugin", + status: "active", + config: { + pluginKey, + driverKey: "sandbox", + driverConfig: { + template: "new", + }, + }, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + defaultEnvironmentId: oldEnvironmentId, + permissions: {}, + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(executionWorkspaces).values({ + id: staleExecutionWorkspaceId, + companyId, + projectId, + projectWorkspaceId: workspaceId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Stale workspace", + status: "active", + cwd: workspaceRoot, + providerType: "local_fs", + providerRef: workspaceRoot, + metadata: { + config: { + environmentId: oldEnvironmentId, + }, + }, + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(issues).values({ + id: issueId, + companyId, + projectId, + projectWorkspaceId: workspaceId, + title: "Environment matrix: e2b / codex_local", + status: "in_progress", + priority: "medium", + assigneeAgentId: agentId, + executionWorkspaceId: staleExecutionWorkspaceId, + executionWorkspaceSettings: { + mode: "shared_workspace", + environmentId: newEnvironmentId, + }, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const heartbeat = heartbeatService(db, { pluginWorkerManager: workerManager }); + const run = await heartbeat.wakeup(agentId, { + source: "assignment", + triggerDetail: "manual", + contextSnapshot: { issueId }, + }); + + expect(run).not.toBeNull(); + await vi.waitFor(async () => { + const latest = await heartbeat.getRun(run!.id); + expect(latest?.status).toBe("succeeded"); + }, { timeout: 5_000 }); + + expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentAcquireLease", { + driverKey: "sandbox", + companyId, + environmentId: newEnvironmentId, + config: { template: "new" }, + runId: run!.id, + workspaceMode: "shared_workspace", + }); + }, 15_000); }); diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index ad11eecd..3e2ab794 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -7,6 +7,7 @@ import { agents, companies, createDb, + environments, executionWorkspaces, goals, heartbeatRuns, @@ -1184,6 +1185,351 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { }); }); + it("captures the assignee default environment when neither issue nor project specifies one", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const assigneeEnvironmentId = randomUUID(); + const assigneeAgentId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + + await db.insert(environments).values([ + { + id: assigneeEnvironmentId, + companyId, + name: "QA E2B", + driver: "sandbox", + status: "active", + config: { provider: "e2b" }, + }, + ]); + + await db.insert(agents).values({ + id: assigneeAgentId, + companyId, + name: "QA E2B Codex", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + defaultEnvironmentId: assigneeEnvironmentId, + permissions: {}, + }); + + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspace project", + status: "in_progress", + executionWorkspacePolicy: { + enabled: true, + defaultMode: "shared_workspace", + allowIssueOverride: true, + defaultProjectWorkspaceId: projectWorkspaceId, + }, + }); + + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary workspace", + isPrimary: true, + }); + + const issue = await svc.create(companyId, { + projectId, + assigneeAgentId, + title: "Environment matrix: e2b / codex_local", + status: "todo", + priority: "medium", + }); + + expect(issue.executionWorkspaceSettings).toEqual({ + mode: "shared_workspace", + environmentId: assigneeEnvironmentId, + }); + }); + + it("does not promote the assignee default environment when the project policy already specifies one", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const projectEnvironmentId = randomUUID(); + const assigneeEnvironmentId = randomUUID(); + const assigneeAgentId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + + await db.insert(environments).values([ + { + id: projectEnvironmentId, + companyId, + name: "QA SSH", + driver: "ssh", + status: "active", + config: {}, + }, + { + id: assigneeEnvironmentId, + companyId, + name: "QA E2B", + driver: "sandbox", + status: "active", + config: { provider: "e2b" }, + }, + ]); + + await db.insert(agents).values({ + id: assigneeAgentId, + companyId, + name: "QA E2B Codex", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + defaultEnvironmentId: assigneeEnvironmentId, + permissions: {}, + }); + + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspace project", + status: "in_progress", + executionWorkspacePolicy: { + enabled: true, + defaultMode: "shared_workspace", + allowIssueOverride: true, + defaultProjectWorkspaceId: projectWorkspaceId, + environmentId: projectEnvironmentId, + }, + }); + + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary workspace", + isPrimary: true, + }); + + const issue = await svc.create(companyId, { + projectId, + assigneeAgentId, + title: "Environment matrix: e2b / codex_local", + status: "todo", + priority: "medium", + }); + + // Project policy's environmentId must win over the assignee's default; + // executionWorkspaceSettings should not bake in an environmentId in this case + // so resolveExecutionWorkspaceEnvironmentId can fall through to the project + // policy's value at run time. + expect(issue.executionWorkspaceSettings).toEqual({ mode: "shared_workspace" }); + }); + + it("captures the new assignee's default environment on reassignment", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const firstEnvironmentId = randomUUID(); + const secondEnvironmentId = randomUUID(); + const firstAgentId = randomUUID(); + const secondAgentId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + + await db.insert(environments).values([ + { + id: firstEnvironmentId, + companyId, + name: "QA SSH", + driver: "ssh", + status: "active", + config: {}, + }, + { + id: secondEnvironmentId, + companyId, + name: "QA E2B", + driver: "sandbox", + status: "active", + config: { provider: "e2b" }, + }, + ]); + + await db.insert(agents).values([ + { + id: firstAgentId, + companyId, + name: "QA SSH Codex", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + defaultEnvironmentId: firstEnvironmentId, + permissions: {}, + }, + { + id: secondAgentId, + companyId, + name: "QA E2B Codex", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + defaultEnvironmentId: secondEnvironmentId, + permissions: {}, + }, + ]); + + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspace project", + status: "in_progress", + executionWorkspacePolicy: { + enabled: true, + defaultMode: "shared_workspace", + allowIssueOverride: true, + defaultProjectWorkspaceId: projectWorkspaceId, + }, + }); + + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary workspace", + isPrimary: true, + }); + + const created = await svc.create(companyId, { + projectId, + assigneeAgentId: firstAgentId, + title: "Environment matrix: ssh / codex_local", + status: "todo", + priority: "medium", + }); + + expect(created.executionWorkspaceSettings).toMatchObject({ + environmentId: firstEnvironmentId, + }); + + const reassigned = await svc.update(created.id, { + assigneeAgentId: secondAgentId, + }); + + expect(reassigned).not.toBeNull(); + expect(reassigned!.executionWorkspaceSettings).toMatchObject({ + environmentId: secondEnvironmentId, + }); + }); + + it("preserves an operator-set environmentId across reassignment", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const firstEnvironmentId = randomUUID(); + const secondEnvironmentId = randomUUID(); + const operatorEnvironmentId = randomUUID(); + const firstAgentId = randomUUID(); + const secondAgentId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + + await db.insert(environments).values([ + { id: firstEnvironmentId, companyId, name: "Env 1", driver: "ssh", status: "active", config: {} }, + { id: secondEnvironmentId, companyId, name: "Env 2", driver: "sandbox", status: "active", config: { provider: "e2b" } }, + { id: operatorEnvironmentId, companyId, name: "Operator pick", driver: "ssh", status: "active", config: {} }, + ]); + + await db.insert(agents).values([ + { + id: firstAgentId, companyId, name: "First agent", role: "engineer", status: "active", + adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, + defaultEnvironmentId: firstEnvironmentId, permissions: {}, + }, + { + id: secondAgentId, companyId, name: "Second agent", role: "engineer", status: "active", + adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, + defaultEnvironmentId: secondEnvironmentId, permissions: {}, + }, + ]); + + await db.insert(projects).values({ + id: projectId, companyId, name: "Workspace project", status: "in_progress", + executionWorkspacePolicy: { + enabled: true, + defaultMode: "shared_workspace", + allowIssueOverride: true, + defaultProjectWorkspaceId: projectWorkspaceId, + }, + }); + + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, companyId, projectId, name: "Primary workspace", isPrimary: true, + }); + + const created = await svc.create(companyId, { + projectId, + assigneeAgentId: firstAgentId, + title: "Operator overrides env then reassigns", + status: "todo", + priority: "medium", + }); + + // Operator explicitly overrides the environmentId in a separate update. + const overridden = await svc.update(created.id, { + executionWorkspaceSettings: { + mode: "shared_workspace", + environmentId: operatorEnvironmentId, + }, + }); + expect(overridden!.executionWorkspaceSettings).toMatchObject({ + environmentId: operatorEnvironmentId, + }); + + // A subsequent reassignment-only update must NOT overwrite the operator's + // explicit choice with the new assignee's default. + const reassigned = await svc.update(created.id, { + assigneeAgentId: secondAgentId, + }); + expect(reassigned!.executionWorkspaceSettings).toMatchObject({ + environmentId: operatorEnvironmentId, + }); + }); + it("keeps explicit workspace fields instead of inheriting the parent linkage", async () => { const companyId = randomUUID(); const projectId = randomUUID(); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 02ae14d2..71032747 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -6320,6 +6320,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) issueRef?.executionWorkspacePreference === "reuse_existing" && existingExecutionWorkspace !== null && existingExecutionWorkspace.status !== "archived"; + const reusableExecutionWorkspaceConfig = shouldReuseExisting + ? existingExecutionWorkspace?.config ?? null + : null; const persistedExecutionWorkspaceMode = shouldReuseExisting && existingExecutionWorkspace ? issueExecutionWorkspaceModeForPersistedWorkspace(existingExecutionWorkspace.mode) : null; @@ -6333,7 +6336,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const selectedEnvironmentId = resolveExecutionWorkspaceEnvironmentId({ projectPolicy: projectExecutionWorkspacePolicy, issueSettings: issueExecutionWorkspaceSettings, - workspaceConfig: existingExecutionWorkspace?.config ?? null, + workspaceConfig: reusableExecutionWorkspaceConfig, agentDefaultEnvironmentId: agent.defaultEnvironmentId, defaultEnvironmentId: defaultEnvironment.id, }); @@ -6348,7 +6351,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) }); const persistedWorkspaceManagedConfig = applyPersistedExecutionWorkspaceConfig({ config: workspaceManagedConfig, - workspaceConfig: existingExecutionWorkspace?.config ?? null, + workspaceConfig: reusableExecutionWorkspaceConfig, mode: effectiveExecutionWorkspaceMode, }); let adapterModelProfiles: AdapterModelProfileDefinition[] = []; diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 8f49238d..48ddc561 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -35,6 +35,7 @@ import type { } from "@paperclipai/shared"; import { clampIssueRequestDepth, extractAgentMentionIds, extractProjectMentionIds, isUuidLike } from "@paperclipai/shared"; import { conflict, notFound, unprocessable } from "../errors.js"; +import { parseObject } from "../adapters/utils.js"; import { defaultIssueExecutionWorkspaceSettingsForProject, gateProjectExecutionWorkspacePolicy, @@ -2733,24 +2734,68 @@ export function issueService(db: Db) { } } } + // Cache the project policy lookup for this insert. Both the + // default-settings block and the assignee-environment-promotion block + // need the same row; without caching they'd issue two round-trips. + let projectPolicyCached: ReturnType | null = null; + let projectPolicyLoaded = false; + const loadProjectPolicyOnce = async () => { + if (projectPolicyLoaded) return projectPolicyCached; + projectPolicyLoaded = true; + if (!issueData.projectId) return null; + const projectRow = await tx + .select({ executionWorkspacePolicy: projects.executionWorkspacePolicy }) + .from(projects) + .where(and(eq(projects.id, issueData.projectId), eq(projects.companyId, companyId))) + .then((rows) => rows[0] ?? null); + projectPolicyCached = parseProjectExecutionWorkspacePolicy(projectRow?.executionWorkspacePolicy); + return projectPolicyCached; + }; + if ( executionWorkspaceSettings == null && executionWorkspaceId == null && issueData.projectId ) { - const project = await tx - .select({ executionWorkspacePolicy: projects.executionWorkspacePolicy }) - .from(projects) - .where(and(eq(projects.id, issueData.projectId), eq(projects.companyId, companyId))) - .then((rows) => rows[0] ?? null); executionWorkspaceSettings = defaultIssueExecutionWorkspaceSettingsForProject( gateProjectExecutionWorkspacePolicy( - parseProjectExecutionWorkspacePolicy(project?.executionWorkspacePolicy), + await loadProjectPolicyOnce(), isolatedWorkspacesEnabled, ), ) as Record | null; } + if (data.assigneeAgentId && isolatedWorkspacesEnabled) { + const currentWorkspaceSettings = executionWorkspaceSettings == null + ? {} + : parseObject(executionWorkspaceSettings); + const issueHasEnvironmentSelection = + Object.prototype.hasOwnProperty.call(currentWorkspaceSettings, "environmentId"); + // Don't promote the assignee agent's defaultEnvironmentId if either + // the issue or the project policy already specifies an environment. + // resolveExecutionWorkspaceEnvironmentId treats issue settings as + // higher priority than project policy, so promoting the agent's + // default to issue settings would invert the documented priority + // (project policy must win over agent default when explicitly set). + let projectHasEnvironmentSelection = false; + if (!issueHasEnvironmentSelection && issueData.projectId) { + const projectPolicy = await loadProjectPolicyOnce(); + projectHasEnvironmentSelection = projectPolicy?.environmentId !== undefined; + } + if (!issueHasEnvironmentSelection && !projectHasEnvironmentSelection) { + const assigneeAgent = await tx + .select({ defaultEnvironmentId: agents.defaultEnvironmentId }) + .from(agents) + .where(and(eq(agents.id, data.assigneeAgentId), eq(agents.companyId, companyId))) + .then((rows) => rows[0] ?? null); + if (typeof assigneeAgent?.defaultEnvironmentId === "string" && assigneeAgent.defaultEnvironmentId.length > 0) { + executionWorkspaceSettings = { + ...currentWorkspaceSettings, + environmentId: assigneeAgent.defaultEnvironmentId, + }; + } + } + } if (!projectWorkspaceId && issueData.projectId) { const project = await tx .select({ @@ -2978,6 +3023,94 @@ export function issueService(db: Db) { issueData.projectId !== undefined ? issueData.projectId : existing.projectId, ), ]); + + // Mirror the create() path: when the assignee changes to a non-null + // agent, default the issue's executionWorkspaceSettings.environmentId + // to the new agent's defaultEnvironmentId. Skip when: + // - this update explicitly sets executionWorkspaceSettings.environmentId + // (caller is making a deliberate override; respect it), OR + // - the project policy already specifies an environmentId (project + // policy must win over agent default per the documented priority + // order in resolveExecutionWorkspaceEnvironmentId), OR + // - the issue already has an environmentId that was *not* the prior + // assignee's default (i.e., the operator set it explicitly in an + // earlier update; preserve their choice). When the existing + // environmentId matches the prior assignee's default, treat it as + // auto-promoted and refresh it to the new assignee's default. + const assigneeChanged = + issueData.assigneeAgentId !== undefined && + issueData.assigneeAgentId !== null && + issueData.assigneeAgentId !== existing.assigneeAgentId; + const explicitEnvInThisUpdate = + issueData.executionWorkspaceSettings !== undefined && + Object.prototype.hasOwnProperty.call( + parseObject(issueData.executionWorkspaceSettings), + "environmentId", + ); + if (assigneeChanged && isolatedWorkspacesEnabled && !explicitEnvInThisUpdate) { + let projectHasEnvironmentSelection = false; + if (nextProjectId) { + const projectRow = await tx + .select({ executionWorkspacePolicy: projects.executionWorkspacePolicy }) + .from(projects) + .where(and(eq(projects.id, nextProjectId), eq(projects.companyId, existing.companyId))) + .then((rows: Array<{ executionWorkspacePolicy: unknown }>) => rows[0] ?? null); + const projectPolicy = parseProjectExecutionWorkspacePolicy(projectRow?.executionWorkspacePolicy); + projectHasEnvironmentSelection = projectPolicy?.environmentId !== undefined; + } + if (!projectHasEnvironmentSelection) { + const baseSettings = nextExecutionWorkspaceSettings == null + ? {} + : parseObject(nextExecutionWorkspaceSettings); + const existingEnvId = typeof baseSettings.environmentId === "string" + ? baseSettings.environmentId + : null; + + // Look up both the prior assignee (to detect auto-promoted env) + // and the new assignee in a single query. + type AgentRow = { id: string; defaultEnvironmentId: string | null }; + const agentRows: AgentRow[] = await tx + .select({ id: agents.id, defaultEnvironmentId: agents.defaultEnvironmentId }) + .from(agents) + .where( + and( + eq(agents.companyId, existing.companyId), + inArray( + agents.id, + [issueData.assigneeAgentId!, existing.assigneeAgentId].filter( + (value): value is string => typeof value === "string", + ), + ), + ), + ); + + const newAssignee = agentRows.find((row: AgentRow) => row.id === issueData.assigneeAgentId); + const previousAssignee = existing.assigneeAgentId + ? agentRows.find((row: AgentRow) => row.id === existing.assigneeAgentId) + : null; + + const newDefaultEnvId = + typeof newAssignee?.defaultEnvironmentId === "string" && newAssignee.defaultEnvironmentId.length > 0 + ? newAssignee.defaultEnvironmentId + : null; + const previousDefaultEnvId = + typeof previousAssignee?.defaultEnvironmentId === "string" && previousAssignee.defaultEnvironmentId.length > 0 + ? previousAssignee.defaultEnvironmentId + : null; + + const existingEnvWasAutoPromoted = + existingEnvId === null || + (previousDefaultEnvId !== null && existingEnvId === previousDefaultEnvId); + + if (newDefaultEnvId && existingEnvWasAutoPromoted) { + patch.executionWorkspaceSettings = { + ...baseSettings, + environmentId: newDefaultEnvId, + }; + } + } + } + patch.goalId = resolveNextIssueGoalId({ currentProjectId: existing.projectId, currentGoalId: existing.goalId,