fix(server): serialize managed instruction swaps

Prevent same-agent materialization races and allocate force-reset backups under the same per-root queue.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-07 08:18:32 +00:00
1 parent af5000fce4
commit 128cadbbe8
2 files changed
+89 -2

No files matched your search

@@ -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",
);
});
});
+32 -2
View File
@@ -28,6 +28,7 @@ const IGNORED_INSTRUCTIONS_DIRECTORY_NAMES = new Set([
"node_modules",
"venv",
]);
const managedBundleMaterializationTails = new Map<string, Promise<void>>();
type BundleMode = "managed" | "external";
@@ -174,6 +175,29 @@ async function statIfExists(targetPath: string) {
return fs.stat(targetPath).catch(() => null);
}
async function withManagedBundleMaterializationLock<T>(
rootPath: string,
operation: () => Promise<T>,
): Promise<T> {
const previous = managedBundleMaterializationTails.get(rootPath) ?? Promise.resolve();
let release!: () => void;
const current = new Promise<void>((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 {