diff --git a/server/src/__tests__/plugin-fresh-workspaces.integration.test.ts b/server/src/__tests__/plugin-fresh-workspaces.integration.test.ts index cba84e508b2..5a945bb7e50 100644 --- a/server/src/__tests__/plugin-fresh-workspaces.integration.test.ts +++ b/server/src/__tests__/plugin-fresh-workspaces.integration.test.ts @@ -3,6 +3,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites import { eq, sql } from "drizzle-orm"; import { createDb, executionWorkspaces, heartbeatRuns, issues, plugins } from "@paperclipai/db"; import { preparePluginWorkspace, removePluginWorkspace } from "../services/plugin-workspaces.js"; +import { executionWorkspaceService } from "../services/execution-workspaces.js"; import { reserveFreshPluginTaskWorkspace } from "../services/provider-fresh-workspace.js"; import { createPluginWorkspaceFixture } from "./helpers/plugin-workspaces.js"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; @@ -56,6 +57,32 @@ describe.skipIf(!support.supported)("provider-owned fresh retry reservations", ( const rows = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.companyId, f.companyId)); expect(rows).toHaveLength(2); }); + it("keeps fresh directory authority server-owned through metadata updates", async () => { + const f = await fixture(); await enable(f.pluginId); + const service = executionWorkspaceService(db); + const original = await service.update(f.previous.id, { metadata: { + note: "ordinary metadata", workspaceProviderFresh: f.fresh, runnerTaskFreshWorkspace: f.fresh, + runnerTaskWorkspaceOwner: { pluginKey: "forged" }, + } }); + expect(original!.metadata).toMatchObject({ note: "ordinary metadata" }); + for (const key of ["workspaceProviderFresh", "runnerTaskFreshWorkspace", "runnerTaskWorkspaceOwner"]) { + expect(original!.metadata?.[key]).toBeUndefined(); + } + const prepared = await preparePluginWorkspace(db, f.workers, f.input); + const updated = await service.update(prepared!.id, { metadata: { note: "caller edit", workspaceProviderFresh: null } }); + expect(updated!.metadata).toMatchObject({ note: "caller edit", workspaceProviderFresh: f.fresh }); + 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 next = { ...f.fresh, attemptId: randomUUID(), previousWorkspaceId: prepared!.id }; + const id = await reserveFreshPluginTaskWorkspace({ db, companyId: f.companyId, issueId: f.issueId, + runId: f.input.runId, agentId: f.input.agentId, responsibleUserId: null, fresh: next, owner, + expectedBindingRevision: 2 }); + const directory = await service.update(id, { metadata: { runnerTaskFreshWorkspace: f.fresh, + runnerTaskWorkspaceOwner: { pluginKey: "forged" } } }); + expect(directory!.metadata).toMatchObject({ runnerTaskFreshWorkspace: next, runnerTaskWorkspaceOwner: owner }); + const cleared = await service.update(id, { metadata: null }); + expect(cleared!.metadata).toMatchObject({ runnerTaskFreshWorkspace: next, runnerTaskWorkspaceOwner: owner }); + }); it("retains the durable removal intent if transfer cache cleanup fails", async () => { const f = await fixture(); await enable(f.pluginId); const workspace = await preparePluginWorkspace(db, f.workers, f.input); diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index 1065fcc1362..4c0eb887a41 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -259,6 +259,14 @@ function preserveWorkspaceMetadataForPatch( } } } + const freshIntentKeys = ["workspaceProviderFresh", "runnerTaskFreshWorkspace", "runnerTaskWorkspaceOwner"] as const; + if (patch.metadata !== undefined && (patch.metadata !== null || freshIntentKeys.some(key => current.metadata?.[key] !== undefined))) { + nextPatch.metadata = { ...(nextPatch.metadata ?? patch.metadata) }; + for (const key of freshIntentKeys) { + if (current.metadata?.[key] !== undefined) nextPatch.metadata[key] = current.metadata[key]; + else delete nextPatch.metadata[key]; + } + } const retainedSources = { ...parseObject(parseObject(patch.metadata)._issuePrivacySources), ...parseObject(current.metadata?._issuePrivacySources),