From 7c6fa32c482c92e1a743ca9089c39f73f132fd4f Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 6 Aug 2026 14:43:18 +0000 Subject: [PATCH] fix(server): compare lock identity schemes safely Fall back to the token heartbeat when process-start marker representations differ instead of evicting a live owner. Co-Authored-By: Paperclip --- .../agent-instructions-service.test.ts | 52 ++++++++++++++++++- server/src/services/agent-instructions.ts | 14 ++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/agent-instructions-service.test.ts b/server/src/__tests__/agent-instructions-service.test.ts index ad4260f1cd8..2add6e67169 100644 --- a/server/src/__tests__/agent-instructions-service.test.ts +++ b/server/src/__tests__/agent-instructions-service.test.ts @@ -552,7 +552,9 @@ describe("agent instructions service", () => { `${JSON.stringify({ pid: process.pid, token, - processStartMarker: "not-the-current-process-start", + processStartMarker: process.platform === "linux" + ? "linux:not-the-current-process-start" + : "ps:not-the-current-process-start", createdAt: new Date().toISOString(), })}\n`, "utf8", @@ -565,6 +567,54 @@ describe("agent instructions service", () => { await expect(fs.stat(lockPath)).rejects.toThrow(); }); + it.skipIf(process.platform !== "linux")( + "does not evict a live lock when process identity falls back to another scheme", + async () => { + const paperclipHome = await makeTempDir("paperclip-agent-instructions-marker-fallback-"); + cleanupDirs.add(paperclipHome); + process.env.PAPERCLIP_HOME = paperclipHome; + process.env.PAPERCLIP_INSTANCE_ID = "test-instance"; + const svc = agentInstructionsService(); + 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 token = "00000000-0000-4000-8000-000000000000"; + await fs.mkdir(lockPath, { recursive: true }); + await fs.writeFile( + path.join(lockPath, "owner.json"), + `${JSON.stringify({ + pid: process.pid, + token, + processStartMarker: "ps:same-live-process-in-another-format", + createdAt: new Date().toISOString(), + })}\n`, + "utf8", + ); + await fs.writeFile(path.join(lockPath, `heartbeat-${token}`), "", "utf8"); + + const materialization = svc.materializeManagedBundle(agent, { "AGENTS.md": "# Stock\n" }); + const firstOutcome = await Promise.race([ + materialization.then(() => "materialized"), + new Promise<"waiting">((resolve) => setTimeout(() => resolve("waiting"), 75)), + ]); + + expect(firstOutcome).toBe("waiting"); + await fs.rm(lockPath, { recursive: true, force: true }); + await expect(materialization).resolves.toMatchObject({ + materialization: { action: "added" }, + }); + }, + ); + it("canonicalizes line endings and ignores transient files when classifying drift", async () => { const paperclipHome = await makeTempDir("paperclip-agent-instructions-canonical-"); cleanupDirs.add(paperclipHome); diff --git a/server/src/services/agent-instructions.ts b/server/src/services/agent-instructions.ts index e0c9da701cd..a7610e9eec3 100644 --- a/server/src/services/agent-instructions.ts +++ b/server/src/services/agent-instructions.ts @@ -223,6 +223,14 @@ async function managedBundleLockAgeMs(targetPath: string): Promise { return stat ? Date.now() - stat.mtimeMs : Number.POSITIVE_INFINITY; } +function processStartMarkersComparable(left: string, right: string): boolean { + const leftSeparator = left.indexOf(":"); + const rightSeparator = right.indexOf(":"); + return leftSeparator > 0 + && rightSeparator > 0 + && left.slice(0, leftSeparator) === right.slice(0, rightSeparator); +} + async function removeStaleManagedBundleLock(lockPath: string): Promise { let shouldRemove = false; try { @@ -242,7 +250,11 @@ async function removeStaleManagedBundleLock(lockPath: string): Promise ? owner.processStartMarker : null; const currentStart = await processStartMarker(pid); - if (recordedStart && currentStart) { + if ( + recordedStart + && currentStart + && processStartMarkersComparable(recordedStart, currentStart) + ) { shouldRemove = recordedStart !== currentStart; } else if (token) { shouldRemove = await managedBundleLockAgeMs(