mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 04:02:18 +01:00
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:
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user