/** * Project-wide run lock. * * Three Senai commands (`/senai-approve`, `/senai-brainstorm-approve`, * `/senai-purge-community-cache`) share one lock file so they mutually * exclude each other. A second Pi session in the same project, or a * double-click inside one session, both surface as * "Lock busy: holder pid=… command=… started=…". Stale locks (dead pid or * heartbeat older than SENAI_LOCK_STALE_MS) are auto-stolen on the next * acquire attempt so a crashed previous session never wedges the run. * * Layout on disk: * .IDE_Plans/pi-senai/.lock/ * meta.json # { pid, host, command, startedAt, heartbeatAt, mode, runId } * * Algorithm: * 1. mkdir(dir) — atomic on POSIX. Success → we hold the lock. * 2. If mkdir fails with EEXIST, read meta.json. If the holder's heartbeat * is fresh and the pid is alive, busy-wait up to SENAI_LOCK_TIMEOUT_MS. * 3. Otherwise (stale heartbeat or dead pid), steal the lock by * atomically rewriting meta.json with our metadata. * 4. While held, refresh heartbeatAt every SENAI_LOCK_HEARTBEAT_MS. * 5. Release = atomically rewrite meta.json with pid=-1 (a sentinel that * survives a crash between the rewrite and the directory removal) and * rm the lock dir. */ export type LockMode = "approve" | "brainstorm-approve" | "brainstorm-research"; export interface LockMeta { pid: number; host: string; command: string; startedAt: string; heartbeatAt: string; mode: LockMode; runId?: string; } export interface AcquireOptions { cwd: string; mode: LockMode; /** Human-readable label, e.g. "/senai-approve". Shown in busy messages. */ command: string; runId?: string; /** Max time to wait for the holder to release. Default SENAI_LOCK_TIMEOUT_MS or 5000ms. */ timeoutMs?: number; /** Heartbeat age that counts as stale. Default SENAI_LOCK_STALE_MS or 60000ms. */ staleMs?: number; /** Heartbeat refresh interval while held. Default SENAI_LOCK_HEARTBEAT_MS or 5000ms. */ heartbeatMs?: number; } export interface AcquireOk { ok: true; release: () => void; } export interface AcquireBusy { ok: false; reason: string; holder: LockMeta | null; } export type AcquireResult = AcquireOk | AcquireBusy; /** Internal acquire with explicit tunables (for tests). */ export declare function acquireLock(opts: AcquireOptions): AcquireResult; /** * Async wrapper: acquire the lock, run `fn`, release in `finally`. * Returns `{ ok: true, value }` or `{ ok: false, reason, holder }`. */ export declare function withRunLock(opts: AcquireOptions, fn: () => Promise | T): Promise<{ ok: true; value: T; } | { ok: false; reason: string; holder: LockMeta | null; }>; /** Read-only access to the current lock holder. Returns null when free. */ export declare function lockInfo(cwd: string): LockMeta | null; /** Force-take the lock regardless of holder state. Used by /senai-doctor's * recovery flow when a user explicitly confirms after a stale detection. */ export declare function forceStealLock(cwd: string): LockMeta; /** Defensive cleanup: if the recorded pid is ours (a previous session of * this process crashed mid-release), remove the lock dir. Returns true * if a removal happened. */ export declare function releaseStaleLockIfHeldByUs(cwd: string, pid: number): boolean; /** Format a holder for user-facing messages. */ export declare function describeHolder(meta: LockMeta | null): string; /** Generate the heartbeat file path (exported for tests). */ export declare function lockFilePathForTest(cwd: string): string;