mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 04:02:18 +01:00
fix(server): publish instruction locks atomically
Prepare lock owner metadata and heartbeat in a private directory, then atomically publish the complete lock so stale recovery can never observe a live ownerless lock. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
fab8c2af2a
commit
23b4420dbd
2 files changed
+83
-24
No files matched your search
@@ -531,6 +531,63 @@ describe("agent instructions service", () => {
|
||||
expect(Array.from({ length: 8 }, (_, index) => `# Stock ${index}\n`)).toContain(live);
|
||||
});
|
||||
|
||||
it("publishes owner metadata atomically with the materialization lock", async () => {
|
||||
const paperclipHome = await makeTempDir("paperclip-agent-instructions-lock-publish-");
|
||||
cleanupDirs.add(paperclipHome);
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
|
||||
const agent = makeAgent({});
|
||||
const managedRoot = path.join(
|
||||
paperclipHome,
|
||||
"instances",
|
||||
"test-instance",
|
||||
"companies",
|
||||
"company-1",
|
||||
"agents",
|
||||
"agent-1",
|
||||
"instructions",
|
||||
);
|
||||
const lockPath = `${managedRoot}.paperclip-materialize.lock`;
|
||||
const originalRename = fs.rename.bind(fs);
|
||||
let pendingLockPath: string | null = null;
|
||||
let publish: (() => void) | null = null;
|
||||
const publishAllowed = new Promise<void>((resolve) => {
|
||||
publish = resolve;
|
||||
});
|
||||
let pendingReady: (() => void) | null = null;
|
||||
const pendingPrepared = new Promise<void>((resolve) => {
|
||||
pendingReady = resolve;
|
||||
});
|
||||
const renameSpy = vi.spyOn(fs, "rename").mockImplementation(async (source, destination) => {
|
||||
if (String(source).startsWith(`${lockPath}.pending-`) && String(destination) === lockPath) {
|
||||
pendingLockPath = String(source);
|
||||
pendingReady?.();
|
||||
await publishAllowed;
|
||||
}
|
||||
return originalRename(source, destination);
|
||||
});
|
||||
const materialization = agentInstructionsService().materializeManagedBundle(
|
||||
agent,
|
||||
{ "AGENTS.md": "# Stock\n" },
|
||||
);
|
||||
|
||||
try {
|
||||
await pendingPrepared;
|
||||
await expect(fs.stat(lockPath)).rejects.toThrow();
|
||||
expect(pendingLockPath).not.toBeNull();
|
||||
await expect(fs.readFile(path.join(pendingLockPath!, "owner.json"), "utf8")).resolves.toContain(
|
||||
`"pid":${process.pid}`,
|
||||
);
|
||||
const pendingFiles = await fs.readdir(pendingLockPath!);
|
||||
expect(pendingFiles.some((file) => file.startsWith("heartbeat-"))).toBe(true);
|
||||
} finally {
|
||||
publish?.();
|
||||
}
|
||||
|
||||
await expect(materialization).resolves.toMatchObject({ materialization: { action: "added" } });
|
||||
renameSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does not evict an old materialization lock owned by a live process", async () => {
|
||||
const paperclipHome = await makeTempDir("paperclip-agent-instructions-live-lock-");
|
||||
cleanupDirs.add(paperclipHome);
|
||||
|
||||
@@ -338,37 +338,39 @@ async function withManagedBundleMaterializationLock<T>(
|
||||
const ownerPath = path.join(lockPath, "owner.json");
|
||||
const token = randomUUID();
|
||||
const heartbeatPath = path.join(lockPath, `heartbeat-${token}`);
|
||||
const pendingLockPath = `${lockPath}.pending-${token}`;
|
||||
const ownerProcessStartMarker = await processStartMarker(process.pid);
|
||||
const deadline = Date.now() + MANAGED_BUNDLE_LOCK_TIMEOUT_MS;
|
||||
await fs.mkdir(path.dirname(rootPath), { recursive: true });
|
||||
while (true) {
|
||||
try {
|
||||
await fs.mkdir(lockPath);
|
||||
try {
|
||||
await fs.mkdir(pendingLockPath);
|
||||
await fs.writeFile(
|
||||
path.join(pendingLockPath, "owner.json"),
|
||||
`${JSON.stringify({
|
||||
pid: process.pid,
|
||||
token,
|
||||
processStartMarker: ownerProcessStartMarker,
|
||||
createdAt: new Date().toISOString(),
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(path.join(pendingLockPath, `heartbeat-${token}`), "", "utf8");
|
||||
while (true) {
|
||||
try {
|
||||
await fs.writeFile(
|
||||
ownerPath,
|
||||
`${JSON.stringify({
|
||||
pid: process.pid,
|
||||
token,
|
||||
processStartMarker: ownerProcessStartMarker,
|
||||
createdAt: new Date().toISOString(),
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(heartbeatPath, "", "utf8");
|
||||
await fs.rename(pendingLockPath, lockPath);
|
||||
break;
|
||||
} catch (error) {
|
||||
await fs.rm(lockPath, { recursive: true, force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
if (!(await statIfExists(lockPath))) throw error;
|
||||
if (await removeStaleManagedBundleLock(lockPath)) continue;
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`Timed out waiting for managed instructions lock at ${lockPath}`);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
break;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
||||
if (await removeStaleManagedBundleLock(lockPath)) continue;
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`Timed out waiting for managed instructions lock at ${lockPath}`);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
} catch (error) {
|
||||
await fs.rm(pendingLockPath, { recursive: true, force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
|
||||
Reference in new issue
Block a user