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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-07 08:18:32 +00:00
1 parent e698d5ba30
commit 7c6fa32c48
2 files changed
+64 -2

No files matched your search

@@ -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);
+13 -1
View File
@@ -223,6 +223,14 @@ async function managedBundleLockAgeMs(targetPath: string): Promise<number> {
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<boolean> {
let shouldRemove = false;
try {
@@ -242,7 +250,11 @@ async function removeStaleManagedBundleLock(lockPath: string): Promise<boolean>
? 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(