diff --git a/server/src/__tests__/agent-instructions-service.test.ts b/server/src/__tests__/agent-instructions-service.test.ts index 6483707b1a6..1c1811f72fb 100644 --- a/server/src/__tests__/agent-instructions-service.test.ts +++ b/server/src/__tests__/agent-instructions-service.test.ts @@ -620,7 +620,9 @@ describe("agent instructions service", () => { ]); expect(firstOutcome).toBe("waiting"); - await fs.rm(lockPath, { recursive: true, force: true }); + const releasedLockPath = `${lockPath}.released`; + await fs.rename(lockPath, releasedLockPath); + await fs.rm(releasedLockPath, { recursive: true, force: true }); await expect(materialization).resolves.toMatchObject({ materialization: { action: "added" }, }); @@ -667,7 +669,7 @@ describe("agent instructions service", () => { }); it.skipIf(process.platform !== "linux")( - "does not evict a live lock with a stale heartbeat when identity falls back to another scheme", + "does not evict a live lock with a stale heartbeat when its identity scheme is unavailable", async () => { const paperclipHome = await makeTempDir("paperclip-agent-instructions-marker-fallback-"); cleanupDirs.add(paperclipHome); @@ -693,7 +695,7 @@ describe("agent instructions service", () => { `${JSON.stringify({ pid: process.pid, token, - processStartMarker: "ps:same-live-process-in-another-format", + processStartMarker: "unavailable:same-live-process-in-another-format", createdAt: new Date().toISOString(), })}\n`, "utf8", @@ -710,13 +712,56 @@ describe("agent instructions service", () => { ]); expect(firstOutcome).toBe("waiting"); - await fs.rm(lockPath, { recursive: true, force: true }); + const releasedLockPath = `${lockPath}.released`; + await fs.rename(lockPath, releasedLockPath); + await fs.rm(releasedLockPath, { recursive: true, force: true }); await expect(materialization).resolves.toMatchObject({ materialization: { action: "added" }, }); }, ); + it.skipIf(process.platform !== "linux")( + "recovers a reused PID when the owner recorded the ps fallback scheme", + async () => { + const paperclipHome = await makeTempDir("paperclip-agent-instructions-ps-reused-pid-"); + 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:not-the-current-process-start", + createdAt: new Date().toISOString(), + })}\n`, + "utf8", + ); + await fs.writeFile(path.join(lockPath, `heartbeat-${token}`), "", "utf8"); + + const result = await svc.materializeManagedBundle(agent, { "AGENTS.md": "# Stock\n" }); + + expect(result.materialization.action).toBe("added"); + await expect(fs.stat(lockPath)).rejects.toThrow(); + }, + ); + 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 f7b165d806f..d7c9a8997b5 100644 --- a/server/src/services/agent-instructions.ts +++ b/server/src/services/agent-instructions.ts @@ -189,9 +189,20 @@ function isProcessAlive(pid: number): boolean { } } -async function processStartMarker(pid: number): Promise { +type ProcessStartMarkerScheme = "linux" | "ps"; + +function processStartMarkerScheme(marker: string): ProcessStartMarkerScheme | null { + if (marker.startsWith("linux:")) return "linux"; + if (marker.startsWith("ps:")) return "ps"; + return null; +} + +async function processStartMarker( + pid: number, + preferredScheme?: ProcessStartMarkerScheme, +): Promise { if (!isProcessAlive(pid)) return null; - if (process.platform === "linux") { + if (process.platform === "linux" && preferredScheme !== "ps") { try { const stat = await fs.readFile(`/proc/${pid}/stat`, "utf8"); const commandEnd = stat.lastIndexOf(")"); @@ -203,8 +214,11 @@ async function processStartMarker(pid: number): Promise { } catch { // Fall through to ps for non-standard procfs environments. } + if (preferredScheme === "linux") return null; } + if (preferredScheme === "linux") return null; + return new Promise((resolve) => { execFile( "ps", @@ -287,7 +301,8 @@ async function removeStaleManagedBundleLock(lockPath: string): Promise const recordedStart = typeof owner.processStartMarker === "string" ? owner.processStartMarker : null; - const currentStart = await processStartMarker(pid); + const recordedScheme = recordedStart ? processStartMarkerScheme(recordedStart) : null; + const currentStart = await processStartMarker(pid, recordedScheme ?? undefined); if ( recordedStart && currentStart