diff --git a/server/src/__tests__/agent-instructions-service.test.ts b/server/src/__tests__/agent-instructions-service.test.ts index 52c2142ade3..a8b97e4713e 100644 --- a/server/src/__tests__/agent-instructions-service.test.ts +++ b/server/src/__tests__/agent-instructions-service.test.ts @@ -431,6 +431,33 @@ describe("agent instructions service", () => { await expect(fs.stat(path.join(updated.bundle.managedRootPath, "docs", "OLD.md"))).rejects.toThrow(); }); + it("serializes concurrent routine materializations for the same agent", async () => { + const paperclipHome = await makeTempDir("paperclip-agent-instructions-concurrent-"); + cleanupDirs.add(paperclipHome); + process.env.PAPERCLIP_HOME = paperclipHome; + process.env.PAPERCLIP_INSTANCE_ID = "test-instance"; + const svc = agentInstructionsService(); + const agent = makeAgent({}); + const initial = await svc.materializeManagedBundle(agent, { "AGENTS.md": "# Stock v1\n" }); + const configuredAgent = { ...agent, adapterConfig: initial.adapterConfig }; + + const [first, second] = await Promise.all([ + svc.materializeManagedBundle(configuredAgent, { "AGENTS.md": "# Stock v2\n" }), + agentInstructionsService().materializeManagedBundle(configuredAgent, { + "AGENTS.md": "# Stock v3\n", + }), + ]); + + expect(first.materialization.action).toBe("updated"); + expect(second.materialization).toMatchObject({ + stockStatus: "operator_modified", + action: "skipped", + }); + await expect(fs.readFile(path.join(initial.bundle.managedRootPath, "AGENTS.md"), "utf8")).resolves.toBe( + "# Stock v2\n", + ); + }); + it("canonicalizes line endings and ignores transient files when classifying drift", async () => { const paperclipHome = await makeTempDir("paperclip-agent-instructions-canonical-"); cleanupDirs.add(paperclipHome); @@ -518,4 +545,34 @@ describe("agent instructions service", () => { "# Second operator edit\n", ); }); + + it("allocates distinct backups for concurrent force resets", async () => { + const paperclipHome = await makeTempDir("paperclip-agent-instructions-concurrent-reset-"); + cleanupDirs.add(paperclipHome); + process.env.PAPERCLIP_HOME = paperclipHome; + process.env.PAPERCLIP_INSTANCE_ID = "test-instance"; + const svc = agentInstructionsService(); + const agent = makeAgent({}); + const initial = await svc.materializeManagedBundle(agent, { "AGENTS.md": "# Stock v1\n" }); + const configuredAgent = { ...agent, adapterConfig: initial.adapterConfig }; + + const [first, second] = await Promise.all([ + svc.forceResetManagedBundle(configuredAgent, { "AGENTS.md": "# Stock v2\n" }), + agentInstructionsService().forceResetManagedBundle(configuredAgent, { + "AGENTS.md": "# Stock v3\n", + }), + ]); + + expect(first.materialization.backupPath).toBe(`${initial.bundle.managedRootPath}.backup-1`); + expect(second.materialization.backupPath).toBe(`${initial.bundle.managedRootPath}.backup-2`); + await expect( + fs.readFile(path.join(`${initial.bundle.managedRootPath}.backup-1`, "AGENTS.md"), "utf8"), + ).resolves.toBe("# Stock v1\n"); + await expect( + fs.readFile(path.join(`${initial.bundle.managedRootPath}.backup-2`, "AGENTS.md"), "utf8"), + ).resolves.toBe("# Stock v2\n"); + await expect(fs.readFile(path.join(initial.bundle.managedRootPath, "AGENTS.md"), "utf8")).resolves.toBe( + "# Stock v3\n", + ); + }); }); diff --git a/server/src/services/agent-instructions.ts b/server/src/services/agent-instructions.ts index ae6964133b9..8b0d16a6f05 100644 --- a/server/src/services/agent-instructions.ts +++ b/server/src/services/agent-instructions.ts @@ -28,6 +28,7 @@ const IGNORED_INSTRUCTIONS_DIRECTORY_NAMES = new Set([ "node_modules", "venv", ]); +const managedBundleMaterializationTails = new Map>(); type BundleMode = "managed" | "external"; @@ -174,6 +175,29 @@ async function statIfExists(targetPath: string) { return fs.stat(targetPath).catch(() => null); } +async function withManagedBundleMaterializationLock( + rootPath: string, + operation: () => Promise, +): Promise { + const previous = managedBundleMaterializationTails.get(rootPath) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + const tail = previous.catch(() => undefined).then(() => current); + managedBundleMaterializationTails.set(rootPath, tail); + + await previous.catch(() => undefined); + try { + return await operation(); + } finally { + release(); + if (managedBundleMaterializationTails.get(rootPath) === tail) { + managedBundleMaterializationTails.delete(rootPath); + } + } +} + function shouldIgnoreInstructionsEntry(entry: { name: string; isDirectory(): boolean; isFile(): boolean }) { if (entry.name === "." || entry.name === "..") return true; if (entry.isDirectory()) { @@ -937,7 +961,10 @@ export function agentInstructionsService() { recordedStockHash?: string | null; }, ) { - return materializeManagedBundleInternal(agent, files, options); + const rootPath = resolveManagedInstructionsRoot(agent); + return withManagedBundleMaterializationLock(rootPath, () => + materializeManagedBundleInternal(agent, files, options), + ); } async function forceResetManagedBundle( @@ -949,7 +976,10 @@ export function agentInstructionsService() { recordedStockHash?: string | null; }, ) { - return materializeManagedBundleInternal(agent, files, { ...options, forceReset: true }); + const rootPath = resolveManagedInstructionsRoot(agent); + return withManagedBundleMaterializationLock(rootPath, () => + materializeManagedBundleInternal(agent, files, { ...options, forceReset: true }), + ); } return {