Fix environment resolution and sandbox lease isolation

This commit is contained in:
Devin Foley committed 2026-06-19 22:54:12 -07:00
1 parent 63e26e9147
commit 816f5e6a39
12 files changed
+328 -503

No files matched your search

+2
View File
@@ -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
+197 -18
View File
@@ -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();
@@ -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,
});
});
@@ -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",
+14 -21
View File
@@ -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,
@@ -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<Environment> {
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<ExecutionWorkspace, "id" | "mode"> | 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,
+39 -5
View File
@@ -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<ExecutionWorkspace, "id" | "mode"> | 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,
+17 -109
View File
@@ -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<string, unknown>) } }
@@ -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<string, unknown>) } }
@@ -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<ExecutionWorkspaceEnvironmentSource, "workspace">;
};
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<ExecutionWorkspaceEnvironmentSource, "workspace">;
} {
// 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(
+9 -38
View File
@@ -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,
+2 -1
View File
@@ -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) {
+7 -121
View File
@@ -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<typeof parseProjectExecutionWorkspacePolicy> | null = null;
let projectPolicyLoaded = false;
const loadProjectPolicyOnce = async () => {
@@ -5006,37 +5005,6 @@ export function issueService(db: Db) {
),
) as Record<string, unknown> | 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,
@@ -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<SandboxLeaseHandle> {
assertProviderConfig<FakeSandboxEnvironmentConfig>(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<SandboxLeaseHandle> {
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,
});
}