diff --git a/server/src/__tests__/environment-task-runtime.test.ts b/server/src/__tests__/environment-task-runtime.test.ts index 0c3a9181193..cf2211f0b4b 100644 --- a/server/src/__tests__/environment-task-runtime.test.ts +++ b/server/src/__tests__/environment-task-runtime.test.ts @@ -390,6 +390,34 @@ describe("plugin-provided Runner execution", () => { await executeEnvironmentTask(database(value).db, workers as never, { companyId: "company", leaseId, operation: { ...submit, projectIds: [] } }); expect(workers.call).toHaveBeenCalledWith("original", "environmentTask", expect.objectContaining({ projectIds: [] }), 15_000); }); + it.each(["release", "resume"] as const)("sends the prepared project and fresh scope with a retained %s", async kind => { + const identity = { runnerInstanceId: "runner", environmentLeaseId: leaseId, runId: "run", normalizedSessionId: "session", turnId: "turn", itemId: "item" }; + const operation = kind === "release" ? { kind, identity } : { kind, identity, bootstrapTicket: "transient-test-ticket" }; + const result = kind === "release" ? { kind: "released", taskId: "attempt-1" } : { kind: "resume", taskId: "attempt-1", outcome: "resumed" }; + const fresh = { attemptId: leaseId, sourceRunId: secondProjectId, previousWorkspaceId: null, recoverySnapshots: [] }; + const value = row(); value.lease.status = "retained"; value.run!.status = "succeeded"; + Object.assign(value.lease.metadata, { runnerTaskProjectIds: [projectId, secondProjectId], freshWorkspace: fresh }); + const { db, query } = database(value, [{ id: projectId }, { id: secondProjectId }]); + const workers = worker(result); + await expect(executeEnvironmentTask(db, workers as never, { companyId: "company", leaseId, operation })).resolves.toEqual(result); + expect(workers.call).toHaveBeenCalledWith("original", "environmentTask", expect.objectContaining({ + projectIds: [projectId, secondProjectId], fresh, operation }), 15_000); + const scope = new PgDialect().sqlToQuery(query.where.mock.calls[1][0]); + expect(scope.sql).toContain('"projects"."company_id" ='); + expect(scope.params).toEqual(["company", projectId, secondProjectId]); + + const unscoped = row(); unscoped.lease.status = "retained"; + const plain = worker(result); + await executeEnvironmentTask(database(unscoped, []).db, plain as never, { companyId: "company", leaseId, operation }); + const sent = (plain.call.mock.calls[0] as unknown as any[])[2]; + expect(sent.projectIds).toEqual([]); + expect(sent).not.toHaveProperty("fresh"); + + const rejected = worker(result); + await expect(executeEnvironmentTask(database(value, [{ id: projectId }]).db, rejected as never, { companyId: "company", leaseId, operation })) + .rejects.toThrow("project unavailable"); + expect(rejected.call).not.toHaveBeenCalled(); + }); it("rejects an absent or cross-company lease before calling a worker", async () => { const workers = worker(); await expect(executeEnvironmentTask(database(null).db, workers as never, { companyId: "other", leaseId, operation: submit })).rejects.toThrow("lease unavailable"); diff --git a/server/src/services/environment-task-runtime.ts b/server/src/services/environment-task-runtime.ts index c7b4a35b3c0..b078a986659 100644 --- a/server/src/services/environment-task-runtime.ts +++ b/server/src/services/environment-task-runtime.ts @@ -80,6 +80,7 @@ export async function executeEnvironmentTask(db: Db, workers: PluginWorkerManage } : null; const launches = operation.kind === "submit" || operation.kind === "prepare"; const scopesProjects = launches || operation.kind === "check_project_auth" || operation.kind === "read_file"; + const retainedTask = operation.kind === "release" || operation.kind === "resume"; const resumeFrom = launches || checkpointIdentity ? (operation as { resumeFrom?: string }).resumeFrom : undefined; const savedIdentity = metadata.runnerTaskIdentity as Record | undefined; const prior = operation.kind === "prepare" ? operation.priorIdentity : undefined; @@ -192,7 +193,10 @@ export async function executeEnvironmentTask(db: Db, workers: PluginWorkerManage freshWorkspace = await stageProviderFreshWorkspace({ db, companyId: input.companyId, issueId: lease.issueId, ...freshWorkspace, existing: freshWorkspace }); } - const projectIds = scopesProjects ? operation.projectIds : []; + const savedProjectIds = metadata.runnerTaskProjectIds ?? []; + if (retainedTask && (!Array.isArray(savedProjectIds) || savedProjectIds.some(id => typeof id !== "string"))) + throw new Error("Plugin Runner project scope changed"); + const projectIds: string[] = scopesProjects ? operation.projectIds : retainedTask ? [...savedProjectIds as string[]] : []; if (scopesProjects && Array.isArray(metadata.runnerTaskProjectIds) && JSON.stringify(projectIds) !== JSON.stringify(metadata.runnerTaskProjectIds)) throw new Error("Plugin Runner project scope changed"); if (activeProbe && projectIds.length > 0) throw new Error("Plugin Runner probe cannot use project mounts"); @@ -380,7 +384,7 @@ export async function executeEnvironmentTask(db: Db, workers: PluginWorkerManage taskId, runId: run?.id ?? (activeProbe ? String(probe.runId) : null), agentId: run?.agentId ?? null, projectIds, lease: { providerLeaseId: lease.providerLeaseId, metadata: lease.metadata ?? undefined }, ...(workspaceBinding ? { workspace: workspaceBinding } : {}), - ...(freshWorkspace && scopesProjects ? { fresh: freshWorkspace } : {}), operation, + ...(freshWorkspace && (scopesProjects || retainedTask) ? { fresh: freshWorkspace } : {}), operation, executionContextSupported: 1 as const, ...(executionContext ? { executionContext } : {}), };