mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
Fix environment resolution and sandbox lease isolation
This commit is contained in:
1 parent
63e26e9147
commit
816f5e6a39
12 files changed
+328
-503
No files matched your search
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user