diff --git a/cli/src/__tests__/install-store.test.ts b/cli/src/__tests__/install-store.test.ts index 732ee5267ca..e8ac8f7da70 100644 --- a/cli/src/__tests__/install-store.test.ts +++ b/cli/src/__tests__/install-store.test.ts @@ -138,13 +138,13 @@ describe("managed install store", () => { expect(fs.existsSync(paths.lockPath)).toBe(false); }); - it("fails closed without replacing a stale-looking lock", async () => { - const staleToken = "999999:stale"; + it("recovers a lock owned by a process that no longer exists", async () => { + const staleToken = "2147483647:stale"; await withInstallStoreLock(async () => undefined, paths); fs.writeFileSync(paths.lockPath, `${staleToken}\n`, { mode: 0o600 }); - await expect(withInstallStoreLock(async () => undefined, paths)).rejects.toThrow("stale lock"); - expect(fs.readFileSync(paths.lockPath, "utf8")).toBe(`${staleToken}\n`); + await expect(withInstallStoreLock(async () => undefined, paths)).resolves.toBeUndefined(); + expect(fs.existsSync(paths.lockPath)).toBe(false); }); it("reports managed provenance only for the payload selected by current", () => { diff --git a/cli/src/__tests__/update-command.test.ts b/cli/src/__tests__/update-command.test.ts index 77de35b6e24..99e09d156de 100644 --- a/cli/src/__tests__/update-command.test.ts +++ b/cli/src/__tests__/update-command.test.ts @@ -73,9 +73,11 @@ describe("update command", () => { flipCurrentAtomic(oldPayload, paths); writeInstallManifestAtomic({ schemaVersion: 1, source: "git", version: "0.3.1", channel: "pinned", repo: "paperclipai/paperclip", ref: "master", sha: oldSha, payloadPath: oldPayload, installedAt: "2026-07-22T00:00:00.000Z", previous: [] }, paths); const backup = vi.fn(async () => undefined); + const confirm = vi.fn(async () => true); const restartActiveService = vi.fn(async () => true); const runCommand = vi.fn(async (file: string) => file === "curl" ? { stdout: JSON.stringify({ sha: newSha }), stderr: "" } : { stdout: "0.3.1\n", stderr: "" }); - await updateCommand({}, { paths, executablePath: executable, runCommand, backup, restartActiveService, now: () => new Date("2026-07-22T12:00:00Z") }); + await updateCommand({}, { paths, executablePath: executable, runCommand, backup, confirm, restartActiveService, now: () => new Date("2026-07-22T12:00:00Z") }); + expect(confirm).toHaveBeenCalledWith(expect.stringContaining(`commit ${newSha.slice(0, 12)}`)); expect(backup).toHaveBeenCalledOnce(); expect(restartActiveService).toHaveBeenCalledWith("0.3.1"); expect(readInstallManifest(paths)?.sha).toBe(newSha); diff --git a/cli/src/commands/update.ts b/cli/src/commands/update.ts index 64c1f9376a1..a04704999e8 100644 --- a/cli/src/commands/update.ts +++ b/cli/src/commands/update.ts @@ -138,6 +138,10 @@ export async function updateCommand(options: UpdateOptions, overrides: Partial dbBackupCommand({})))(); const installed = await withInstallStoreLock(async () => { const payload = await installGitPayload(manifest.repo!, targetSha, runCommand, paths); diff --git a/cli/src/install-store.ts b/cli/src/install-store.ts index c74b3f18ded..18898fbab88 100644 --- a/cli/src/install-store.ts +++ b/cli/src/install-store.ts @@ -151,6 +151,14 @@ export async function withInstallStoreLock( ): Promise { if (options.initialize !== false) initializeInstallStore(paths); const token = `${process.pid}:${Date.now()}:${Math.random().toString(16).slice(2)}`; + const processIsAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } + }; const acquire = (): void => { const temporaryPath = `${paths.lockPath}.${token}.tmp`; try { @@ -163,6 +171,12 @@ export async function withInstallStoreLock( } const owner = fs.readFileSync(paths.lockPath, "utf8").trim(); const ownerPid = Number.parseInt(owner.split(":", 1)[0] ?? "", 10); + if (Number.isInteger(ownerPid) && ownerPid > 0 && !processIsAlive(ownerPid)) { + fs.rmSync(paths.lockPath); + fs.rmSync(temporaryPath, { force: true }); + acquire(); + return; + } const ownerLabel = Number.isInteger(ownerPid) && ownerPid > 0 ? ` (pid ${ownerPid})` : ""; throw new Error( `Another managed install is already running${ownerLabel}. ` +