From 816f5e6a39e0150128ffbb8c31bb1de12489342c Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Fri, 19 Jun 2026 19:52:29 -0700 Subject: [PATCH] Fix environment resolution and sandbox lease isolation --- packages/plugins/sdk/src/protocol.ts | 2 + .../src/__tests__/environment-runtime.test.ts | 215 ++++++++++++++++-- .../execution-workspace-policy.test.ts | 191 ++-------------- .../heartbeat-plugin-environment.test.ts | 13 +- server/src/__tests__/issues-service.test.ts | 35 ++- .../services/environment-run-orchestrator.ts | 19 +- server/src/services/environment-runtime.ts | 44 +++- .../services/execution-workspace-policy.ts | 126 ++-------- server/src/services/heartbeat.ts | 47 +--- server/src/services/instance-settings.ts | 3 +- server/src/services/issues.ts | 128 +---------- .../src/services/sandbox-provider-runtime.ts | 8 +- 12 files changed, 328 insertions(+), 503 deletions(-) diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index 0e8183b5a13..a3925049a7f 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -478,6 +478,8 @@ export interface PluginEnvironmentAcquireLeaseParams extends PluginEnvironmentDr runId: string; workspaceMode?: string; requestedCwd?: string; + agentId?: string; + executionWorkspaceId?: string | null; /** * The harness/adapter type for THIS run (the agent's adapter), so a single * environment can serve mixed harnesses. When omitted, the driver falls back to diff --git a/server/src/__tests__/environment-runtime.test.ts b/server/src/__tests__/environment-runtime.test.ts index c850da811a5..e7ff1f9c988 100644 --- a/server/src/__tests__/environment-runtime.test.ts +++ b/server/src/__tests__/environment-runtime.test.ts @@ -18,8 +18,10 @@ import { createDb, environmentLeases, environments, + executionWorkspaces, heartbeatRuns, plugins, + projects, } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, @@ -129,9 +131,11 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { await db.delete(heartbeatRuns); await db.delete(agents); await db.delete(environments); + await db.delete(executionWorkspaces); await db.delete(plugins); await db.delete(companySecretVersions); await db.delete(companySecrets); + await db.delete(projects); await db.delete(companies); }); @@ -149,6 +153,8 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { const agentId = randomUUID(); const environmentId = randomUUID(); const runId = randomUUID(); + const driver = input.driver ?? "local"; + const environmentName = input.name ?? `${driver}-${environmentId.slice(0, 8)}`; let config = input.config ?? {}; await db.insert(companies).values({ @@ -194,16 +200,35 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { }, }; } - await db.insert(environments).values({ + const existingLocalEnvironment = driver === "local" + ? await db + .select() + .from(environments) + .where(eq(environments.driver, "local")) + .then((rows) => rows[0] ?? null) + : null; + const environmentRecord = existingLocalEnvironment ?? { id: environmentId, - companyId, - name: input.name ?? "Local", - driver: input.driver ?? "local", + name: environmentName, + description: null, + driver, status: input.status ?? "active", config, + metadata: null, createdAt: new Date(), updatedAt: new Date(), - }); + }; + if (!existingLocalEnvironment) { + await db.insert(environments).values({ + id: environmentRecord.id, + name: environmentRecord.name, + driver: environmentRecord.driver, + status: environmentRecord.status, + config: environmentRecord.config, + createdAt: environmentRecord.createdAt, + updatedAt: environmentRecord.updatedAt, + }); + } await db.insert(heartbeatRuns).values({ id: runId, companyId, @@ -216,17 +241,18 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { return { companyId, + agentId, environment: { - id: environmentId, + id: environmentRecord.id, companyId, - name: input.name ?? "Local", - description: null, - driver: input.driver ?? "local", - status: input.status ?? "active", - config, - metadata: null, - createdAt: new Date(), - updatedAt: new Date(), + name: environmentRecord.name, + description: environmentRecord.description, + driver: environmentRecord.driver, + status: environmentRecord.status, + config: environmentRecord.config, + metadata: environmentRecord.metadata, + createdAt: environmentRecord.createdAt, + updatedAt: environmentRecord.updatedAt, } as const, runId, }; @@ -283,7 +309,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { }); it("rejects truly unsupported drivers before acquiring a lease", async () => { - const { companyId, environment, runId } = await seedEnvironment({ + const { companyId, agentId, environment, runId } = await seedEnvironment({ driver: "ssh", name: "Fixture SSH", config: { @@ -389,7 +415,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { }); expect(acquired.lease.status).toBe("active"); - expect(acquired.lease.providerLeaseId).toBe(`sandbox://fake/${environment.id}`); + expect(acquired.lease.providerLeaseId).toBe(`sandbox://fake/${environment.id}/workspace/agent`); expect(acquired.lease.metadata).toMatchObject({ driver: "sandbox", provider: "fake", @@ -876,7 +902,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { it("falls back to acquire when plugin-backed sandbox lease resume throws", async () => { const pluginId = randomUUID(); - const { companyId, environment: baseEnvironment, runId } = await seedEnvironment(); + const { companyId, agentId, environment: baseEnvironment, runId } = await seedEnvironment(); const providerConfig = { provider: "fake-plugin", image: "fake:test", @@ -931,14 +957,38 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { installOrder: 1, updatedAt: new Date(), } as any); + const executionWorkspaceId = randomUUID(); + const projectId = randomUUID(); + await db.insert(projects).values({ + id: projectId, + companyId, + name: `Workspace ${projectId.slice(0, 8)}`, + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Reusable workspace", + status: "active", + providerType: "local_fs", + createdAt: new Date(), + updatedAt: new Date(), + }); await environmentService(db).acquireLease({ companyId, environmentId: environment.id, + executionWorkspaceId, heartbeatRunId: runId, leasePolicy: "reuse_by_environment", provider: "fake-plugin", providerLeaseId: "stale-plugin-lease", metadata: { + agentId, provider: "fake-plugin", image: "fake:test", timeoutMs: 1234, @@ -973,8 +1023,12 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { companyId, environment, issueId: null, + agentId, heartbeatRunId: runId, - persistedExecutionWorkspace: null, + persistedExecutionWorkspace: { + id: executionWorkspaceId, + mode: "shared_workspace", + }, }); expect(acquired.lease.providerLeaseId).toBe("fresh-plugin-lease"); @@ -989,6 +1043,8 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { timeoutMs: 1234, reuseLease: true, }, + agentId, + executionWorkspaceId, runId, }), 31234); }); @@ -1024,6 +1080,129 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { expect(released[0]?.lease.status).toBe("released"); }); + it("does not reuse a sandbox lease owned by a different agent for the same execution workspace", async () => { + const { companyId, agentId, environment, runId } = await seedEnvironment({ + driver: "plugin", + name: "Plugin Fake plugin", + config: { + pluginKey: "acme.environments", + driverKey: "fake-plugin", + driverConfig: { + template: "base", + }, + }, + }); + const otherAgentId = randomUUID(); + const executionWorkspaceId = randomUUID(); + const pluginId = randomUUID(); + const projectId = randomUUID(); + + await db.insert(projects).values({ + id: projectId, + companyId, + name: `Workspace ${projectId.slice(0, 8)}`, + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Existing workspace", + status: "active", + providerType: "local_fs", + createdAt: new Date(), + updatedAt: new Date(), + }); + + await db.insert(plugins).values({ + id: pluginId, + pluginKey: "acme.environments", + packageName: "@acme/paperclip-environments", + version: "1.0.0", + apiVersion: 1, + categories: ["automation"], + manifestJson: { + id: "acme.environments", + 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: "fake-plugin", + kind: "sandbox_provider", + displayName: "Fake Plugin", + configSchema: { type: "object" }, + }, + ], + }, + status: "ready", + installOrder: 1, + updatedAt: new Date(), + } as any); + + await environmentService(db).acquireLease({ + companyId, + environmentId: environment.id, + executionWorkspaceId, + heartbeatRunId: runId, + leasePolicy: "reuse_by_environment", + provider: "fake-plugin", + providerLeaseId: "other-agent-lease", + metadata: { + agentId, + provider: "fake-plugin", + template: "base", + reuseLease: true, + }, + }); + + const workerManager = { + isRunning: vi.fn((id: string) => id === pluginId), + call: vi.fn(async (_pluginId: string, method: string) => { + if (method === "environmentAcquireLease") { + return { + providerLeaseId: "fresh-agent-lease", + metadata: { + provider: "fake-plugin", + template: "base", + reuseLease: true, + }, + }; + } + throw new Error(`Unexpected plugin method: ${method}`); + }), + } as unknown as PluginWorkerManager; + const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager }); + + const acquired = await runtimeWithPlugin.acquireRunLease({ + companyId, + environment, + issueId: null, + agentId: otherAgentId, + heartbeatRunId: runId, + persistedExecutionWorkspace: { + id: executionWorkspaceId, + mode: "shared_workspace", + }, + }); + + expect(acquired.lease.providerLeaseId).toBe("fresh-agent-lease"); + expect(workerManager.call).toHaveBeenCalledTimes(1); + expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentAcquireLease", expect.objectContaining({ + agentId: otherAgentId, + executionWorkspaceId, + })); + }); + it("delegates plugin environment leases through the plugin worker manager", async () => { const pluginId = randomUUID(); const expiresAt = new Date(Date.now() + 60_000).toISOString(); diff --git a/server/src/__tests__/execution-workspace-policy.test.ts b/server/src/__tests__/execution-workspace-policy.test.ts index 93dcfc4619d..f90480a7891 100644 --- a/server/src/__tests__/execution-workspace-policy.test.ts +++ b/server/src/__tests__/execution-workspace-policy.test.ts @@ -135,7 +135,6 @@ describe("execution workspace policy helpers", () => { parseProjectExecutionWorkspacePolicy({ enabled: true, defaultMode: "isolated", - environmentId: "8f8ab8f2-d95f-4315-9f08-d683a1e0f73b", workspaceStrategy: { type: "git_worktree", worktreeParentDir: ".paperclip/worktrees", @@ -146,7 +145,6 @@ describe("execution workspace policy helpers", () => { ).toEqual({ enabled: true, defaultMode: "isolated_workspace", - environmentId: "8f8ab8f2-d95f-4315-9f08-d683a1e0f73b", workspaceStrategy: { type: "git_worktree", worktreeParentDir: ".paperclip/worktrees", @@ -157,205 +155,48 @@ describe("execution workspace policy helpers", () => { expect( parseIssueExecutionWorkspaceSettings({ mode: "project_primary", - environmentId: "8f8ab8f2-d95f-4315-9f08-d683a1e0f73b", }), ).toEqual({ mode: "shared_workspace", - environmentId: "8f8ab8f2-d95f-4315-9f08-d683a1e0f73b", }); }); - it("reuses persisted workspace environment when it agrees with the assignee's identity", () => { + it("prefers the agent default environment", () => { expect( resolveExecutionWorkspaceEnvironmentId({ - projectPolicy: { enabled: true, environmentId: "agent-env" }, - issueSettings: { environmentId: "agent-env" }, - workspaceConfig: { environmentId: "agent-env" }, agentDefaultEnvironmentId: "agent-env", - defaultEnvironmentId: "default-env", - }), - ).toEqual({ - environmentId: "agent-env", - source: "workspace", - conflict: null, - }); - }); - - it("refuses silent reuse when the persisted workspace env disagrees with the assignee (PAPA-380: sandbox agent on local workspace)", () => { - // Claude E2B was assigned to a child issue whose parent had already - // realized a `Local` workspace. The persisted workspace env must not - // shadow the agent's intended sandbox env. - expect( - resolveExecutionWorkspaceEnvironmentId({ - projectPolicy: { enabled: true, environmentId: null }, - issueSettings: { environmentId: "sandbox-env", mode: "shared_workspace" }, - workspaceConfig: { environmentId: "local-env" }, - agentDefaultEnvironmentId: "sandbox-env", - defaultEnvironmentId: "local-env", - }), - ).toEqual({ - environmentId: "sandbox-env", - source: "issue", - conflict: { - reason: "reused_workspace_environment_mismatch", - workspaceEnvironmentId: "local-env", - assigneeIntendedEnvironmentId: "sandbox-env", - assigneeIntendedSource: "issue", - }, - }); - }); - - it("refuses silent reuse when a null-default (local) agent inherits a non-local workspace env (PAPA-431: Manual QA on engineer SSH workspace)", () => { - // Manual QA agent has defaultEnvironmentId: null. When a sibling issue's - // SSH workspace is inherited via inheritExecutionWorkspaceFromIssueId, - // the persisted SSH env must NOT shadow the agent's deliberate local - // identity. The inherited issueSettings.environmentId is treated as a - // promoted artifact, not an explicit operator choice. - expect( - resolveExecutionWorkspaceEnvironmentId({ - projectPolicy: { enabled: true, environmentId: null }, - issueSettings: { environmentId: "ssh-env", mode: "isolated_workspace" }, - workspaceConfig: { environmentId: "ssh-env" }, - agentDefaultEnvironmentId: null, - defaultEnvironmentId: "local-env", - }), - ).toEqual({ - environmentId: "local-env", - source: "default", - conflict: { - reason: "reused_workspace_environment_mismatch", - workspaceEnvironmentId: "ssh-env", - assigneeIntendedEnvironmentId: "local-env", - assigneeIntendedSource: "default", - }, - }); - }); - - it("honors an explicit issue env override for null-default agents when no workspace is being reused", () => { - // Operator explicitly chose an env on this issue via PATCH (see the - // issues-service contract at issues-service.test.ts:1924). For null-default - // agents, this is a deliberate choice — only inherited issue env (which - // matches a reused workspace env) should be discarded. - expect( - resolveExecutionWorkspaceEnvironmentId({ - projectPolicy: { enabled: true, environmentId: "project-env" }, - issueSettings: { environmentId: "issue-env" }, - workspaceConfig: null, - agentDefaultEnvironmentId: null, - defaultEnvironmentId: "local-env", - }), - ).toEqual({ - environmentId: "issue-env", - source: "issue", - conflict: null, - }); - }); - - it("honors an explicit issue env override for null-default agents even against a disagreeing reused workspace", () => { - // Operator picked sandbox-env explicitly while the previously-realized - // workspace was on local-env. The mismatch is genuine — surface a conflict - // so the heartbeat forces a fresh realization on the operator's chosen env. - expect( - resolveExecutionWorkspaceEnvironmentId({ - projectPolicy: { enabled: true, environmentId: null }, - issueSettings: { environmentId: "sandbox-env", mode: "shared_workspace" }, - workspaceConfig: { environmentId: "local-env" }, - agentDefaultEnvironmentId: null, - defaultEnvironmentId: "local-env", - }), - ).toEqual({ - environmentId: "sandbox-env", - source: "issue", - conflict: { - reason: "reused_workspace_environment_mismatch", - workspaceEnvironmentId: "local-env", - assigneeIntendedEnvironmentId: "sandbox-env", - assigneeIntendedSource: "issue", - }, - }); - }); - - it("prefers the explicit issue environment over project and agent defaults when no workspace is reused", () => { - expect( - resolveExecutionWorkspaceEnvironmentId({ - projectPolicy: { enabled: true, environmentId: "project-env" }, - issueSettings: { environmentId: "issue-env" }, - workspaceConfig: null, - agentDefaultEnvironmentId: "agent-env", - defaultEnvironmentId: "default-env", - }), - ).toEqual({ - environmentId: "issue-env", - source: "issue", - conflict: null, - }); - expect( - resolveExecutionWorkspaceEnvironmentId({ - projectPolicy: { enabled: true, environmentId: "project-env" }, - issueSettings: null, - workspaceConfig: null, - agentDefaultEnvironmentId: "agent-env", - defaultEnvironmentId: "default-env", - }), - ).toEqual({ - environmentId: "project-env", - source: "project", - conflict: null, - }); - }); - - it("falls back to the agent default environment before the company default", () => { - expect( - resolveExecutionWorkspaceEnvironmentId({ - projectPolicy: null, - issueSettings: null, - workspaceConfig: null, - agentDefaultEnvironmentId: "agent-env", - defaultEnvironmentId: "default-env", + instanceDefaultEnvironmentId: "instance-env", + localDefaultEnvironmentId: "local-env", }), ).toEqual({ environmentId: "agent-env", source: "agent", - conflict: null, }); + }); + + it("falls back to the instance default environment when the agent has none", () => { expect( resolveExecutionWorkspaceEnvironmentId({ - projectPolicy: { enabled: true, environmentId: null }, - issueSettings: null, - workspaceConfig: null, - agentDefaultEnvironmentId: "agent-env", - defaultEnvironmentId: "default-env", - }), - ).toEqual({ - environmentId: "default-env", - source: "project", - conflict: null, - }); - expect( - resolveExecutionWorkspaceEnvironmentId({ - projectPolicy: null, - issueSettings: null, - workspaceConfig: null, agentDefaultEnvironmentId: null, - defaultEnvironmentId: "default-env", + instanceDefaultEnvironmentId: "instance-env", + localDefaultEnvironmentId: "local-env", }), ).toEqual({ - environmentId: "default-env", - source: "default", - conflict: null, + environmentId: "instance-env", + source: "instance", }); + }); + + it("falls back to the built-in local environment when neither agent nor instance selects one", () => { expect( resolveExecutionWorkspaceEnvironmentId({ - projectPolicy: { enabled: true, environmentId: null }, - issueSettings: null, - workspaceConfig: null, agentDefaultEnvironmentId: null, - defaultEnvironmentId: "default-env", + instanceDefaultEnvironmentId: null, + localDefaultEnvironmentId: "local-env", }), ).toEqual({ - environmentId: "default-env", + environmentId: "local-env", source: "default", - conflict: null, }); }); diff --git a/server/src/__tests__/heartbeat-plugin-environment.test.ts b/server/src/__tests__/heartbeat-plugin-environment.test.ts index 5419dc2235f..eea00acf85a 100644 --- a/server/src/__tests__/heartbeat-plugin-environment.test.ts +++ b/server/src/__tests__/heartbeat-plugin-environment.test.ts @@ -202,12 +202,14 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => { expect(latest?.status).toBe("succeeded"); }, { timeout: 5_000 }); - expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentAcquireLease", { + expect(workerManager.call).toHaveBeenNthCalledWith(1, pluginId, "environmentAcquireLease", { driverKey: "sandbox", companyId, environmentId, + executionWorkspaceId: expect.any(String), issueId: null, config: { template: "base" }, + agentId, runId: run!.id, workspaceMode: "shared_workspace", // Pins the HEARTBEAT-path lease call forwarding the AGENT's adapter type @@ -235,7 +237,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => { expect(adapterExecute).toHaveBeenCalledTimes(1); }, 15_000); - it("ignores stale non-reused workspace environment config in favor of the issue selection", async () => { + it("ignores stale non-reused workspace environment config in favor of the assignee selection", async () => { const companyId = randomUUID(); const projectId = randomUUID(); const workspaceId = randomUUID(); @@ -368,7 +370,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => { adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, - defaultEnvironmentId: oldEnvironmentId, + defaultEnvironmentId: newEnvironmentId, permissions: {}, createdAt: new Date(), updatedAt: new Date(), @@ -405,7 +407,6 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => { executionWorkspaceId: staleExecutionWorkspaceId, executionWorkspaceSettings: { mode: "shared_workspace", - environmentId: newEnvironmentId, }, createdAt: new Date(), updatedAt: new Date(), @@ -424,12 +425,14 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => { expect(latest?.status).toBe("succeeded"); }, { timeout: 5_000 }); - expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentAcquireLease", { + expect(workerManager.call).toHaveBeenNthCalledWith(1, pluginId, "environmentAcquireLease", { driverKey: "sandbox", companyId, environmentId: newEnvironmentId, + executionWorkspaceId: expect.any(String), issueId, config: { template: "new" }, + agentId, runId: run!.id, workspaceMode: "shared_workspace", adapterType: "codex_local", diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 44e63a74ad5..68aecc8b32f 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -2152,6 +2152,7 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { await db.delete(projects); await db.delete(goals); await db.delete(agents); + await db.delete(environments); await db.delete(instanceSettings); await db.delete(companies); }); @@ -2236,7 +2237,7 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { }); }); - it("captures the assignee default environment when neither issue nor project specifies one", async () => { + it("does not stamp the assignee default environment onto new issues", async () => { const companyId = randomUUID(); const projectId = randomUUID(); const projectWorkspaceId = randomUUID(); @@ -2306,11 +2307,10 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { 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 () => { + it("ignores legacy project environment selection when creating new issues", async () => { const companyId = randomUUID(); const projectId = randomUUID(); const projectWorkspaceId = randomUUID(); @@ -2388,14 +2388,10 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { 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 () => { + it("does not rewrite execution workspace settings on reassignment", async () => { const companyId = randomUUID(); const projectId = randomUUID(); const projectWorkspaceId = randomUUID(); @@ -2487,8 +2483,8 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { priority: "medium", }); - expect(created.executionWorkspaceSettings).toMatchObject({ - environmentId: firstEnvironmentId, + expect(created.executionWorkspaceSettings).toEqual({ + mode: "shared_workspace", }); const reassigned = await svc.update(created.id, { @@ -2496,12 +2492,12 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { }); expect(reassigned).not.toBeNull(); - expect(reassigned!.executionWorkspaceSettings).toMatchObject({ - environmentId: secondEnvironmentId, + expect(reassigned!.executionWorkspaceSettings).toEqual({ + mode: "shared_workspace", }); }); - it("preserves an operator-set environmentId across reassignment", async () => { + it("strips legacy environmentId values from execution workspace settings updates", async () => { const companyId = randomUUID(); const projectId = randomUUID(); const projectWorkspaceId = randomUUID(); @@ -2560,24 +2556,21 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { 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, + expect(overridden!.executionWorkspaceSettings).toEqual({ + mode: "shared_workspace", }); - // 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, + expect(reassigned!.executionWorkspaceSettings).toEqual({ + mode: "shared_workspace", }); }); @@ -3939,7 +3932,7 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { expect(workspace?.metadata).toEqual({ config: { - environmentId: "env-new", + environmentId: null, provisionCommand: "bash ./scripts/provision-new.sh", teardownCommand: "bash ./scripts/teardown-new.sh", cleanupCommand: null, diff --git a/server/src/services/environment-run-orchestrator.ts b/server/src/services/environment-run-orchestrator.ts index 4884e6111a7..1add1a2b24b 100644 --- a/server/src/services/environment-run-orchestrator.ts +++ b/server/src/services/environment-run-orchestrator.ts @@ -159,20 +159,19 @@ export function environmentRunOrchestrator( }); /** - * Resolve the selected environment for a run. Ensures a local default - * exists and resolves the priority chain: - * execution workspace config > issue settings > project policy > agent default > company default + * Resolve the selected environment for a run. The caller passes the concrete + * selected environment id plus the built-in local fallback id used to lazily + * ensure the local environment row exists. */ async function resolveEnvironment(input: { companyId: string; selectedEnvironmentId: string; - defaultEnvironmentId: string; + localEnvironmentId: string; }): Promise { - const environmentId = - input.selectedEnvironmentId || input.defaultEnvironmentId; + const environmentId = input.selectedEnvironmentId || input.localEnvironmentId; const environment = - environmentId === input.defaultEnvironmentId + environmentId === input.localEnvironmentId ? await environmentsSvc.ensureLocalEnvironment(input.companyId) : await environmentsSvc.getById(environmentId); @@ -200,6 +199,7 @@ export function environmentRunOrchestrator( companyId: string; environment: Environment; issueId: string | null; + agentId: string; heartbeatRunId: string; persistedExecutionWorkspace: Pick | null; adapterType: string | null; @@ -256,7 +256,7 @@ export function environmentRunOrchestrator( async function acquireForRun(input: { companyId: string; selectedEnvironmentId: string; - defaultEnvironmentId: string; + localEnvironmentId: string; adapterType: string; issueId: string | null; heartbeatRunId: string; @@ -267,7 +267,7 @@ export function environmentRunOrchestrator( const environment = await resolveEnvironment({ companyId: input.companyId, selectedEnvironmentId: input.selectedEnvironmentId, - defaultEnvironmentId: input.defaultEnvironmentId, + localEnvironmentId: input.localEnvironmentId, }); // Step 2: Acquire lease @@ -275,6 +275,7 @@ export function environmentRunOrchestrator( companyId: input.companyId, environment, issueId: input.issueId, + agentId: input.agentId, heartbeatRunId: input.heartbeatRunId, persistedExecutionWorkspace: input.persistedExecutionWorkspace, adapterType: input.adapterType ?? null, diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index 08451082d66..496c85c20ba 100644 --- a/server/src/services/environment-runtime.ts +++ b/server/src/services/environment-runtime.ts @@ -103,6 +103,7 @@ export interface EnvironmentDriverAcquireInput { companyId: string; environment: Environment; issueId: string | null; + agentId: string | null; /** * UUID of the owning heartbeat run, or null for ad-hoc invocations * (e.g. operator-initiated `Test` probes) that are not tied to a run. @@ -219,6 +220,7 @@ function createLocalEnvironmentDriver(db: Db): EnvironmentRuntimeDriver { leasePolicy: "ephemeral", provider: "local", metadata: { + ...(input.agentId ? { agentId: input.agentId } : {}), driver: input.environment.driver, executionWorkspaceMode: input.executionWorkspaceMode, }, @@ -272,6 +274,7 @@ function createSshEnvironmentDriver(db: Db): EnvironmentRuntimeDriver { provider: "ssh", providerLeaseId: `ssh://${parsed.config.username}@${parsed.config.host}:${parsed.config.port}${remoteCwd}`, metadata: { + ...(input.agentId ? { agentId: input.agentId } : {}), driver: input.environment.driver, executionWorkspaceMode: input.executionWorkspaceMode, host: parsed.config.host, @@ -452,11 +455,23 @@ function createSandboxEnvironmentDriver( // We also filter out leases whose policy is not reuse_by_environment // so any non-reusable lease (including ad-hoc test leases that // landed in the table from older code paths) cannot be matched. - const reusableExistingLeases = parsed.config.reuseLease && input.heartbeatRunId !== null + const reusableExistingLeases = + parsed.config.reuseLease && + input.heartbeatRunId !== null && + input.executionWorkspaceId !== null && + input.agentId !== null ? (await environmentsSvc.listLeases(input.environment.id)) - .filter((lease) => lease.leasePolicy === "reuse_by_environment") + .filter((lease) => + lease.leasePolicy === "reuse_by_environment" && + lease.executionWorkspaceId === input.executionWorkspaceId && + lease.metadata?.agentId === input.agentId, + ) : []; - const reusableProviderLeaseId = parsed.config.reuseLease && input.heartbeatRunId !== null + const reusableProviderLeaseId = + parsed.config.reuseLease && + input.heartbeatRunId !== null && + input.executionWorkspaceId !== null && + input.agentId !== null ? findReusableSandboxLeaseId({ config: storedConfig, leases: reusableExistingLeases }) : null; const reusableLease = reusableProviderLeaseId @@ -497,6 +512,8 @@ function createSandboxEnvironmentDriver( // a well-formed identifier. runId: input.heartbeatRunId ?? randomUUID(), workspaceMode: input.executionWorkspaceMode ?? undefined, + agentId: input.agentId ?? undefined, + executionWorkspaceId: input.executionWorkspaceId ?? undefined, // The agent's harness for THIS run, so the plugin picks the matching // runtime image (per-run adapter, mixed-harness environments). // NOTE: environment-runtime.ts has TWO drivers calling @@ -527,6 +544,7 @@ function createSandboxEnvironmentDriver( providerLeaseId: acquiredLease.providerLeaseId, expiresAt: acquiredLease.expiresAt ? new Date(acquiredLease.expiresAt) : undefined, metadata: { + ...(input.agentId ? { agentId: input.agentId } : {}), driver: input.environment.driver, executionWorkspaceMode: input.executionWorkspaceMode, pluginId: pluginProvider.resolved.plugin.id, @@ -546,13 +564,21 @@ function createSandboxEnvironmentDriver( // provider lease, or releasing the test lease will terminate the live // heartbeat run that shares it. Filter to leases whose policy is // reuse_by_environment so non-reusable rows can never be matched. - const reusableProviderLeaseId = parsed.config.reuseLease && input.heartbeatRunId !== null + const reusableProviderLeaseId = + parsed.config.reuseLease && + input.heartbeatRunId !== null && + input.executionWorkspaceId !== null && + input.agentId !== null ? (await environmentsSvc .listLeases(input.environment.id) .then((leases) => findReusableSandboxLeaseId({ config: parsed.config, - leases: leases.filter((lease) => lease.leasePolicy === "reuse_by_environment"), + leases: leases.filter((lease) => + lease.leasePolicy === "reuse_by_environment" && + lease.executionWorkspaceId === input.executionWorkspaceId && + lease.metadata?.agentId === input.agentId, + ), }), )) : null; @@ -562,6 +588,8 @@ function createSandboxEnvironmentDriver( environmentId: input.environment.id, heartbeatRunId: input.heartbeatRunId ?? randomUUID(), issueId: input.issueId, + agentId: input.agentId, + executionWorkspaceId: input.executionWorkspaceId, reusableProviderLeaseId, }); @@ -581,6 +609,7 @@ function createSandboxEnvironmentDriver( provider: parsed.config.provider, providerLeaseId: providerLease.providerLeaseId, metadata: { + ...(input.agentId ? { agentId: input.agentId } : {}), driver: input.environment.driver, executionWorkspaceMode: input.executionWorkspaceMode, ...providerLease.metadata, @@ -913,6 +942,8 @@ function createPluginEnvironmentDriver( config: parsed.config.driverConfig, runId: input.heartbeatRunId ?? randomUUID(), workspaceMode: input.executionWorkspaceMode ?? undefined, + agentId: input.agentId ?? undefined, + executionWorkspaceId: input.executionWorkspaceId ?? undefined, adapterType: input.adapterType ?? undefined, }); @@ -927,6 +958,7 @@ function createPluginEnvironmentDriver( providerLeaseId: providerLease.providerLeaseId, expiresAt: parseExpiresAt(providerLease.expiresAt), metadata: { + ...(input.agentId ? { agentId: input.agentId } : {}), providerMetadata: providerLease.metadata ?? {}, driver: input.environment.driver, executionWorkspaceMode: input.executionWorkspaceMode, @@ -1126,6 +1158,7 @@ export function environmentRuntimeService( companyId: string; environment: Environment; issueId: string | null; + agentId?: string | null; /** Null for ad-hoc invocations (e.g. operator-initiated `Test` probes). */ heartbeatRunId: string | null; persistedExecutionWorkspace: Pick | null; @@ -1144,6 +1177,7 @@ export function environmentRuntimeService( companyId: input.companyId, environment: input.environment, issueId: input.issueId, + agentId: input.agentId ?? null, heartbeatRunId: input.heartbeatRunId, executionWorkspaceId: leaseContext.executionWorkspaceId, executionWorkspaceMode: leaseContext.executionWorkspaceMode, diff --git a/server/src/services/execution-workspace-policy.ts b/server/src/services/execution-workspace-policy.ts index 706acb3f340..5cece4e5292 100644 --- a/server/src/services/execution-workspace-policy.ts +++ b/server/src/services/execution-workspace-policy.ts @@ -38,7 +38,6 @@ export function parseProjectExecutionWorkspacePolicy(raw: unknown): ProjectExecu const defaultMode = asString(parsed.defaultMode, ""); const defaultProjectWorkspaceId = typeof parsed.defaultProjectWorkspaceId === "string" ? parsed.defaultProjectWorkspaceId : undefined; - const environmentId = typeof parsed.environmentId === "string" ? parsed.environmentId : undefined; const allowIssueOverride = typeof parsed.allowIssueOverride === "boolean" ? parsed.allowIssueOverride : undefined; const normalizedDefaultMode = (() => { @@ -59,7 +58,6 @@ export function parseProjectExecutionWorkspacePolicy(raw: unknown): ProjectExecu ...(normalizedDefaultMode ? { defaultMode: normalizedDefaultMode } : {}), ...(allowIssueOverride !== undefined ? { allowIssueOverride } : {}), ...(defaultProjectWorkspaceId ? { defaultProjectWorkspaceId } : {}), - ...(environmentId !== undefined ? { environmentId } : {}), ...(workspaceStrategy ? { workspaceStrategy } : {}), ...(parsed.workspaceRuntime && typeof parsed.workspaceRuntime === "object" && !Array.isArray(parsed.workspaceRuntime) ? { workspaceRuntime: { ...(parsed.workspaceRuntime as Record) } } @@ -114,7 +112,6 @@ export function parseIssueExecutionWorkspaceSettings(raw: unknown): IssueExecuti ...(normalizedMode ? { mode: normalizedMode as IssueExecutionWorkspaceSettings["mode"] } : {}), - ...(typeof parsed.environmentId === "string" ? { environmentId: parsed.environmentId } : {}), ...(workspaceStrategy ? { workspaceStrategy } : {}), ...(parsed.workspaceRuntime && typeof parsed.workspaceRuntime === "object" && !Array.isArray(parsed.workspaceRuntime) ? { workspaceRuntime: { ...(parsed.workspaceRuntime as Record) } } @@ -123,125 +120,36 @@ export function parseIssueExecutionWorkspaceSettings(raw: unknown): IssueExecuti } export type ExecutionWorkspaceEnvironmentSource = - | "workspace" - | "issue" - | "project" | "agent" + | "instance" | "default"; -export type ExecutionWorkspaceEnvironmentConflict = { - reason: "reused_workspace_environment_mismatch"; - workspaceEnvironmentId: string; - assigneeIntendedEnvironmentId: string; - assigneeIntendedSource: Exclude; -}; - export type ExecutionWorkspaceEnvironmentResolution = { environmentId: string; source: ExecutionWorkspaceEnvironmentSource; - conflict: ExecutionWorkspaceEnvironmentConflict | null; }; -function resolveAssigneeIntendedExecutionWorkspaceEnvironment(input: { - projectPolicy: ProjectExecutionWorkspacePolicy | null; - issueSettings: IssueExecutionWorkspaceSettings | null; - agentDefaultEnvironmentId: string | null; - defaultEnvironmentId: string; -}): { - environmentId: string; - source: Exclude; -} { - // Explicit issue-level env override always wins, even for null-default - // (local-only) agents. An operator who deliberately set - // `executionWorkspaceSettings.environmentId` on this specific issue (see the - // issues-service contract preserved in issues.ts:4243) chose that env for - // this assignment and should not be silently downgraded to the local default - // (PAPA-430 review fix). Inherited issue envs from - // `inheritExecutionWorkspaceFromIssueId` are stripped before this point in - // `resolveExecutionWorkspaceEnvironmentId`. - if (input.issueSettings?.environmentId !== undefined) { - return { - environmentId: input.issueSettings.environmentId ?? input.defaultEnvironmentId, - source: "issue", - }; - } - // A null defaultEnvironmentId on the agent means it is deliberately scoped to - // the local default (e.g. Manual QA today). Project policy must not promote - // such an agent off of local — only an explicit issue-level override above - // can move the assignee away from the local default. - if (input.agentDefaultEnvironmentId === null) { - return { environmentId: input.defaultEnvironmentId, source: "default" }; - } - if (input.projectPolicy?.environmentId !== undefined) { - return { - environmentId: input.projectPolicy.environmentId ?? input.defaultEnvironmentId, - source: "project", - }; - } - return { environmentId: input.agentDefaultEnvironmentId, source: "agent" }; -} - export function resolveExecutionWorkspaceEnvironmentId(input: { - projectPolicy: ProjectExecutionWorkspacePolicy | null; - issueSettings: IssueExecutionWorkspaceSettings | null; - workspaceConfig: { environmentId?: string | null } | null; agentDefaultEnvironmentId: string | null; - defaultEnvironmentId: string; + instanceDefaultEnvironmentId: string | null; + localDefaultEnvironmentId: string; }): ExecutionWorkspaceEnvironmentResolution { - // PAPA-431 companion: when the assignee has no explicit defaultEnvironmentId - // (deliberately local-only, e.g. Manual QA) AND the issue settings env exactly - // matches the reused workspace env, treat the issue env as a promoted artifact - // from `inheritExecutionWorkspaceFromIssueId` rather than a deliberate - // operator choice. Strip it so the resolver falls back to the local default - // and the workspace-vs-intended conflict check forces a fresh realization. - // A genuine operator override (via PATCH on the issue) reaches this code path - // either with no reused workspace (workspaceConfig === null) or against a - // workspace whose persisted env does not match the new override; both keep - // the issue setting in place. - const inheritedIssueEnvOnNullDefaultAssignee = - input.agentDefaultEnvironmentId === null && - input.workspaceConfig?.environmentId !== undefined && - input.workspaceConfig?.environmentId !== null && - input.issueSettings?.environmentId !== undefined && - input.issueSettings.environmentId === input.workspaceConfig.environmentId; - let issueSettingsForResolution = input.issueSettings; - if (inheritedIssueEnvOnNullDefaultAssignee && input.issueSettings) { - const { environmentId: _droppedInheritedEnv, ...rest } = input.issueSettings; - void _droppedInheritedEnv; - issueSettingsForResolution = rest as IssueExecutionWorkspaceSettings; + if (input.agentDefaultEnvironmentId) { + return { + environmentId: input.agentDefaultEnvironmentId, + source: "agent", + }; } - - const assigneeIntended = resolveAssigneeIntendedExecutionWorkspaceEnvironment({ - projectPolicy: input.projectPolicy, - issueSettings: issueSettingsForResolution, - agentDefaultEnvironmentId: input.agentDefaultEnvironmentId, - defaultEnvironmentId: input.defaultEnvironmentId, - }); - - if (input.workspaceConfig?.environmentId !== undefined) { - const workspaceEnvironmentId = - input.workspaceConfig.environmentId ?? input.defaultEnvironmentId; - // PAPA-380 / PAPA-431: a reused workspace's persisted environmentId must - // never silently shadow the current assignee's environment identity. - // When they disagree, refuse the silent reuse: return the assignee's - // intended env and surface a conflict signal so the caller forces a fresh - // workspace realization (or otherwise alerts the operator) instead of - // running the agent on someone else's environment. - if (workspaceEnvironmentId !== assigneeIntended.environmentId) { - return { - environmentId: assigneeIntended.environmentId, - source: assigneeIntended.source, - conflict: { - reason: "reused_workspace_environment_mismatch", - workspaceEnvironmentId, - assigneeIntendedEnvironmentId: assigneeIntended.environmentId, - assigneeIntendedSource: assigneeIntended.source, - }, - }; - } - return { environmentId: workspaceEnvironmentId, source: "workspace", conflict: null }; + if (input.instanceDefaultEnvironmentId) { + return { + environmentId: input.instanceDefaultEnvironmentId, + source: "instance", + }; } - return { environmentId: assigneeIntended.environmentId, source: assigneeIntended.source, conflict: null }; + return { + environmentId: input.localDefaultEnvironmentId, + source: "default", + }; } export function defaultIssueExecutionWorkspaceSettingsForProject( diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 72423127550..fde4b8a0d19 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -8417,37 +8417,15 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const requestedReusableExecutionWorkspaceConfig = requestedShouldReuseExisting ? existingExecutionWorkspace?.config ?? null : null; - const defaultEnvironment = await environmentsSvc.ensureLocalEnvironment(agent.companyId); + const localEnvironment = await environmentsSvc.ensureLocalEnvironment(agent.companyId); + const resolvedInstanceSettings = await instanceSettings.get(); const environmentResolution = resolveExecutionWorkspaceEnvironmentId({ - projectPolicy: projectExecutionWorkspacePolicy, - issueSettings: issueExecutionWorkspaceSettings, - workspaceConfig: requestedReusableExecutionWorkspaceConfig, agentDefaultEnvironmentId: agent.defaultEnvironmentId, - defaultEnvironmentId: defaultEnvironment.id, + instanceDefaultEnvironmentId: + (resolvedInstanceSettings as { defaultEnvironmentId?: string | null }).defaultEnvironmentId ?? null, + localDefaultEnvironmentId: localEnvironment.id, }); - // PAPA-380 / PAPA-431: when the resolver refuses silent reuse of the - // persisted workspace environment, also force a fresh workspace - // realization on the assignee's intended env. Reusing the on-disk - // workspace while swapping the env underneath it would mismatch the cwd's - // runtime expectations (e.g. an SSH-targeted worktree running on the - // local default driver). - if (environmentResolution.conflict) { - logger.warn( - { - runId: run.id, - issueId, - agentId: agent.id, - adapterType: agent.adapterType, - existingExecutionWorkspaceId: existingExecutionWorkspace?.id ?? null, - workspaceEnvironmentId: environmentResolution.conflict.workspaceEnvironmentId, - assigneeIntendedEnvironmentId: - environmentResolution.conflict.assigneeIntendedEnvironmentId, - assigneeIntendedSource: environmentResolution.conflict.assigneeIntendedSource, - }, - "Refusing silent reuse of execution workspace whose environment does not match the assignee's intended environment; forcing fresh realization", - ); - } - const shouldReuseExisting = requestedShouldReuseExisting && !environmentResolution.conflict; + const shouldReuseExisting = requestedShouldReuseExisting; const reusableExecutionWorkspaceConfig = shouldReuseExisting ? requestedReusableExecutionWorkspaceConfig : null; @@ -8545,7 +8523,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const preflightEnvironment = await envOrchestrator.resolveEnvironment({ companyId: agent.companyId, selectedEnvironmentId, - defaultEnvironmentId: defaultEnvironment.id, + localEnvironmentId: localEnvironment.id, }); return preflightEnvironment.driver; }, @@ -8849,17 +8827,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) }) .where(eq(heartbeatRuns.id, run.id)); } - // When execution is forced to Kubernetes, `selectedEnvironmentId` is already - // pinned to the managed k8s environment above; ignore any persisted workspace - // environmentId (which could point at a stale local/ssh env) so a reused - // workspace can never downgrade us off the sandbox. - const persistedEnvironmentId = isExecutionForcedToKubernetes(executionPolicy) - ? selectedEnvironmentId - : persistedExecutionWorkspace?.config?.environmentId ?? selectedEnvironmentId; const acquiredEnvironment = await envOrchestrator.acquireForRun({ companyId: agent.companyId, - selectedEnvironmentId: persistedEnvironmentId, - defaultEnvironmentId: defaultEnvironment.id, + selectedEnvironmentId, + localEnvironmentId: localEnvironment.id, adapterType: agent.adapterType, issueId: issueId ?? null, heartbeatRunId: run.id, diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 7d9e0e6cd34..81e7f0ac196 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -79,11 +79,12 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta function toInstanceSettings(row: typeof instanceSettings.$inferSelect): InstanceSettings { return { id: row.id, + defaultEnvironmentId: row.defaultEnvironmentId ?? null, general: normalizeGeneralSettings(row.general), experimental: normalizeExperimentalSettings(row.experimental), createdAt: row.createdAt, updatedAt: row.updatedAt, - }; + } as InstanceSettings; } export function instanceSettingsService(db: Db) { diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 43c86e7b3e9..5768d09c771 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -4975,9 +4975,8 @@ export function issueService(db: Db) { issueData.projectId = workspace.projectId; } const projectGoalId = await getProjectDefaultGoalId(tx, companyId, issueData.projectId); - // 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. + // Cache the project policy lookup for this insert so the default + // workspace-settings block does not re-query the project row. let projectPolicyCached: ReturnType | null = null; let projectPolicyLoaded = false; const loadProjectPolicyOnce = async () => { @@ -5006,37 +5005,6 @@ export function issueService(db: Db) { ), ) 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({ @@ -5236,6 +5204,11 @@ export function issueService(db: Db) { issueData.executionWorkspaceSettings !== undefined ? parseIssueExecutionWorkspaceSettings(issueData.executionWorkspaceSettings) : parseIssueExecutionWorkspaceSettings(existing.executionWorkspaceSettings); + if (issueData.executionWorkspaceSettings !== undefined) { + patch.executionWorkspaceSettings = nextExecutionWorkspaceSettings + ? { ...nextExecutionWorkspaceSettings } + : null; + } let validatedProjectWorkspace: { projectId: string } | null = null; let validatedExecutionWorkspace: { projectId: string } | null = null; if (!nextProjectId && nextProjectWorkspaceId) { @@ -5295,93 +5268,6 @@ export function issueService(db: Db) { ), ]); - // 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, diff --git a/server/src/services/sandbox-provider-runtime.ts b/server/src/services/sandbox-provider-runtime.ts index 42b48199c72..e664fe00d52 100644 --- a/server/src/services/sandbox-provider-runtime.ts +++ b/server/src/services/sandbox-provider-runtime.ts @@ -18,6 +18,8 @@ export interface AcquireSandboxLeaseInput { environmentId: string; heartbeatRunId: string; issueId: string | null; + agentId?: string | null; + executionWorkspaceId?: string | null; } export interface ResumeSandboxLeaseInput { @@ -137,7 +139,7 @@ class FakeSandboxProvider implements SandboxProvider { async acquireLease(input: AcquireSandboxLeaseInput): Promise { assertProviderConfig(this.provider, input.config); const providerLeaseId = input.config.reuseLease - ? `sandbox://fake/${input.environmentId}` + ? `sandbox://fake/${input.environmentId}/${input.executionWorkspaceId ?? "workspace"}/${input.agentId ?? "agent"}` : `sandbox://fake/${input.heartbeatRunId}/${randomUUID()}`; return { @@ -329,6 +331,8 @@ export async function acquireSandboxProviderLease(input: { environmentId: string; heartbeatRunId: string; issueId: string | null; + agentId?: string | null; + executionWorkspaceId?: string | null; reusableProviderLeaseId?: string | null; }): Promise { const provider = requireSandboxProvider(input.config.provider); @@ -347,6 +351,8 @@ export async function acquireSandboxProviderLease(input: { environmentId: input.environmentId, heartbeatRunId: input.heartbeatRunId, issueId: input.issueId, + agentId: input.agentId, + executionWorkspaceId: input.executionWorkspaceId, }); }