Files
hyperframes/packages/cli/src/utils/projectLock.ts
T
Xuanru Li 1b60a40eff feat(cli): HF_TOOL_CONTEXT artifact events and project lock for agent harnesses (#5520)
* feat(cli): HF_TOOL_CONTEXT artifact events and project lock for agent harnesses

* fix(cli): serialize stale project lock takeover

Use a short-lived break lock so only one waiter can remove a dead
holder's lock. Cover the contention path with six real processes.

* fix(cli): hold snapshot lock through finalization

Keep the project lock while contact sheets, artifact events, and frame
descriptions read captured files so another snapshot cannot replace them.

* fix(cli): recover a project lock after the breaker crashes

A killed takeover left project.lock.break in place, so later commands
waited out the timeout even though both holders were dead. Reclaim that
file only when its recorded process is dead, and compare artifact paths
after the platform resolves them.

* fix(cli): accept a killed breaker process with no signal name

Windows ends the process without reporting SIGKILL, so the regression
was failing before it checked that the leftover breaker can be reclaimed.

* fix(cli): leave a killed project-lock breaker in place

Reclaiming a dead breaker unlinks a name another process may just have
created, so two commands can hold the lock. The timeout names that file
and its pid and says to delete it to continue.
2026-10-13 03:55:36 +00:00

117 lines
3.6 KiB
TypeScript

import { linkSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import { readToolContext, type ToolContext } from "./toolContext.js";
export class ProjectLockTimeout extends Error {}
interface Holder {
pid: number;
command: string;
startedAt: number;
}
const POLL_MS = 250;
export const projectLockPath = (projectDir: string) =>
join(projectDir, ".hyperframes", "project.lock");
function readHolder(path: string): Holder | null {
try {
return JSON.parse(readFileSync(path, "utf8")) as Holder;
} catch {
return null;
}
}
function alive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === "EPERM";
}
}
const sameHolder = (a: Holder | null, b: Holder) =>
a?.pid === b.pid && a.startedAt === b.startedAt && a.command === b.command;
function breakStaleHolder(path: string, current: Holder, holder: Holder): boolean {
const breakPath = `${path}.break`;
const breakDraft = `${breakPath}.${holder.pid}.${holder.startedAt}`;
let acquired = false;
writeFileSync(breakDraft, JSON.stringify(holder), { flag: "wx" });
try {
try {
linkSync(breakDraft, breakPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
throw error;
}
if (sameHolder(readHolder(path), current)) {
try {
unlinkSync(path);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}
acquired = tryAcquire(path, holder);
} finally {
unlinkSync(breakDraft);
if (sameHolder(readHolder(breakPath), holder)) unlinkSync(breakPath);
}
return acquired;
}
function tryAcquire(path: string, holder: Holder): boolean {
const draft = `${path}.${holder.pid}.${holder.startedAt}`;
writeFileSync(draft, JSON.stringify(holder), { flag: "wx" });
try {
linkSync(draft, path);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
} finally {
unlinkSync(draft);
}
const current = readHolder(path);
if (current && !alive(current.pid)) return breakStaleHolder(path, current, holder);
return false;
}
export async function withProjectLock<T>(
projectDir: string,
command: string,
run: () => Promise<T>,
context: ToolContext = readToolContext(),
): Promise<T> {
if (!context.projectLock) return run();
const path = projectLockPath(projectDir);
mkdirSync(dirname(path), { recursive: true });
const holder: Holder = { pid: process.pid, command, startedAt: Date.now() };
const deadline = holder.startedAt + context.projectLock.waitMs;
while (!tryAcquire(path, holder)) {
const remaining = deadline - Date.now();
if (remaining <= 0) {
const current = readHolder(path);
const breakPath = `${path}.break`;
const breaker = readHolder(breakPath);
if ((!current || !alive(current.pid)) && breaker && !alive(breaker.pid)) {
throw new ProjectLockTimeout(
`hyperframes left ${breakPath} (pid ${breaker.pid}); delete it to continue`,
);
}
throw new ProjectLockTimeout(
`hyperframes ${current?.command ?? "another command"} (pid ${current?.pid ?? "unknown"}) ` +
`is still using this project after ${Math.round(context.projectLock.waitMs / 1000)}s`,
);
}
await sleep(Math.min(POLL_MS, remaining));
}
try {
return await run();
} finally {
if (sameHolder(readHolder(path), holder)) unlinkSync(path);
}
}