mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-05 05:37:09 +00:00
* 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.
117 lines
3.6 KiB
TypeScript
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);
|
|
}
|
|
}
|