diff --git a/server/src/__tests__/plugin-fresh-workspaces.integration.test.ts b/server/src/__tests__/plugin-fresh-workspaces.integration.test.ts index 461e4d4dcd7..cba84e508b2 100644 --- a/server/src/__tests__/plugin-fresh-workspaces.integration.test.ts +++ b/server/src/__tests__/plugin-fresh-workspaces.integration.test.ts @@ -82,7 +82,7 @@ describe.skipIf(!support.supported)("provider-owned fresh retry reservations", ( await db.update(issues).set({ projectId: null, projectWorkspaceId: null }).where(eq(issues.id, f.issueId)); const owner = { agentId: f.input.agentId, environmentId: randomUUID(), pluginKey: "test.tasks", driverKey: "tasks" }; const input = { db, companyId: f.companyId, issueId: f.issueId, runId: f.input.runId, - agentId: f.input.agentId, fresh: f.fresh, owner, expectedBindingRevision: 1 }; + agentId: f.input.agentId, responsibleUserId: null, fresh: f.fresh, owner, expectedBindingRevision: 1 }; const id = await reserveFreshPluginTaskWorkspace(input); expect(await reserveFreshPluginTaskWorkspace(input)).toBe(id); const [workspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, id)); @@ -91,5 +91,7 @@ describe.skipIf(!support.supported)("provider-owned fresh retry reservations", ( const [task] = await db.select().from(issues).where(eq(issues.id, f.issueId)); expect(task.executionWorkspaceId).toBe(id); expect(task.workspaceBindingRevision).toBe(2); + await db.update(heartbeatRuns).set({ responsibleUserId: "changed-user" }).where(eq(heartbeatRuns.id, f.input.runId)); + await expect(reserveFreshPluginTaskWorkspace(input)).rejects.toThrow("responsible user changed"); }); }); diff --git a/server/src/services/heartbeat/workspace-preparation.test.ts b/server/src/services/heartbeat/workspace-preparation.test.ts index 09c2c18f745..b9642f0ee26 100644 --- a/server/src/services/heartbeat/workspace-preparation.test.ts +++ b/server/src/services/heartbeat/workspace-preparation.test.ts @@ -177,6 +177,34 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary", expect(call).not.toHaveBeenCalled(); }); + it("keeps a fresh projectless reservation stable through repeated preparation", async () => { + const { input, issue, envOrchestrator } = await fixture(); + await db.update(issues).set({ projectId: null, projectWorkspaceId: null }).where(eq(issues.id, issue.id)); + const pluginKey = "test.fresh-projectless"; + await db.insert(plugins).values({ pluginKey, packageName: "test-tasks", version: "1.0.0", apiVersion: 1, + status: "ready", categories: ["workspace"], manifestJson: { id: pluginKey, apiVersion: 1, version: "1.0.0", + displayName: "Tasks", description: "Tasks", author: "Test", categories: ["workspace"], + capabilities: ["environment.drivers.register"], entrypoints: { worker: "./worker.js" }, + environmentDrivers: [{ driverKey: "tasks", displayName: "Task VM", supportsTasks: true, freshWorkspaceVersion: 1 }] } }); + input.agent.adapterType = "paperclip_runner"; + input.task.issueRef = { ...input.task.issueRef!, projectId: null, projectWorkspaceId: null }; + input.task.context = { issueId: issue.id }; + input.task.freshWorkspace = { mode: "fresh_isolated_workspace", retryIntentId: input.run.id, sourceRunId: input.run.id, retainedSourceIds: [] }; + input.policy.requestedExecutionWorkspaceMode = input.policy.effectiveExecutionWorkspaceMode = "isolated_workspace"; + input.config.mergedConfig = { workspaceStrategy: { type: "project_primary" } }; + input.environment.selectedEnvironmentForConfig = { ...input.environment.localEnvironment, driver: "plugin", + config: { pluginKey, driverKey: "tasks" } }; + const stopped = new Error("Reached projectless environment handoff"); + const acquire = vi.spyOn(envOrchestrator, "acquireForRun").mockRejectedValue(stopped); + const read = vi.spyOn(input.services.executionWorkspacesSvc, "getStoredById"); + for (let attempt = 0; attempt < 2; attempt++) await expect(prepareHeartbeatWorkspace(db, input)).rejects.toBe(stopped); + expect(read).toHaveBeenCalledWith(input.agent.companyId, input.run.id); + expect(acquire).toHaveBeenCalledTimes(2); + for (const [args] of acquire.mock.calls) expect(args.persistedExecutionWorkspace).toMatchObject({ id: input.run.id, + providerType: "adapter_managed", metadata: { runnerTaskFreshWorkspace: { attemptId: input.run.id } } }); + const rows = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.companyId, input.agent.companyId)); + expect(rows).toHaveLength(1); + }); const readIssue = (id: string) => db.select().from(issues).where(eq(issues.id, id)).then(rows => rows[0]!); const readRun = (id: string) => db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, id)).then(rows => rows[0]!); const leases = (id: string) => db.select().from(environmentLeases).where(eq(environmentLeases.heartbeatRunId, id)); diff --git a/server/src/services/heartbeat/workspace-preparation.ts b/server/src/services/heartbeat/workspace-preparation.ts index 6f2b52f84e7..03ef7b10999 100644 --- a/server/src/services/heartbeat/workspace-preparation.ts +++ b/server/src/services/heartbeat/workspace-preparation.ts @@ -419,7 +419,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace }); const freshTaskReservationId = providerFresh && pluginTaskWorkspace && !pluginPrepared && !resolvedWorkspace.projectId && issueId ? await reserveFreshPluginTaskWorkspace({ db, companyId: agent.companyId, issueId, runId: run.id, agentId: agent.id, - fresh: providerFresh, expectedBindingRevision: previousProviderBindingRevision, owner: taskWorkspaceOwner! }) : null; + fresh: providerFresh, responsibleUserId, expectedBindingRevision: previousProviderBindingRevision, owner: taskWorkspaceOwner! }) : null; const freshTaskReservation = freshTaskReservationId ? await executionWorkspacesSvc.getStoredById(agent.companyId, freshTaskReservationId) : null; const copyOnlyProvisioning = pluginPrepared ? true : await timeWorkspace("workspace_provision_policy", () => workspaceRequiresCopyOnlyProvisioning(db, { companyId: agent.companyId, diff --git a/server/src/services/provider-fresh-workspace.ts b/server/src/services/provider-fresh-workspace.ts index b4bb99d5e28..04f418f7878 100644 --- a/server/src/services/provider-fresh-workspace.ts +++ b/server/src/services/provider-fresh-workspace.ts @@ -1,3 +1,4 @@ +import { assertTaskWorkspaceExecutionAccess } from "./task-workspace-access.js"; import { isDeepStrictEqual } from "node:util"; import { createHash, randomUUID } from "node:crypto"; import { createReadStream, createWriteStream } from "node:fs"; @@ -88,7 +89,7 @@ async function verify(file: string, expected: { sha256: string; byteSize: number } export async function reserveFreshPluginTaskWorkspace(input: { - db: Db; companyId: string; issueId: string; runId: string; agentId: string; + db: Db; companyId: string; issueId: string; runId: string; agentId: string; responsibleUserId: string | null; fresh: PluginFreshWorkspace; expectedBindingRevision: number; owner: Record; }) { @@ -99,6 +100,10 @@ export async function reserveFreshPluginTaskWorkspace(input: { eq(heartbeatRuns.companyId, input.companyId))); if (!task || !run || run.agentId !== input.agentId || run.status !== "running" || ![run.issueId, run.nativeIssueId].includes(input.issueId)) throw forbidden("Workspace preparation no longer has an active run"); + await assertTaskWorkspaceExecutionAccess(tx, { type: "agent", source: "agent_jwt", + companyId: input.companyId, agentId: input.agentId, runId: input.runId, + onBehalfOfUserId: input.responsibleUserId === "local-board" ? null : input.responsibleUserId, + }, input.companyId, { issueId: input.issueId }); const [reserved] = await tx.select().from(executionWorkspaces).where(and( eq(executionWorkspaces.companyId, input.companyId), eq(executionWorkspaces.sourceIssueId, input.issueId), eq(executionWorkspaces.id, input.fresh.attemptId)));