// Windows-only patch for the SDK's session-file rename. // // `FileSessionStorage.rename` calls `fs.promises.rename` without retry. On // Windows the same call transiently fails with EPERM / EBUSY / EACCES when // another handle (the just-closed JSONL writer, an antivirus scanner, the // Search indexer, or a process tailing the file) hasn't fully released the // destination yet. SessionManager's atomic rewrite path // (`setSessionName` -> `#rewriteFile` -> `#writeEntriesAtomically` -> rename) // drives directly into this; a single transient EPERM trips // `SessionManager.#persistError`, after which every subsequent persist throws // and the session is effectively dead until reload. // // graceful-fs handles the same class of failures with exponential backoff up // to ~1s. We install the same retry on `FileSessionStorage.prototype.rename` // once at server boot so every SessionManager.storage instance benefits. // // POSIX rename is atomic; EPERM there means "different filesystem" or a real // permission error and must not be retried. Patch is gated on `win32`. import { FileSessionStorage } from "@oh-my-pi/pi-coding-agent/session/session-storage"; import { logger } from "../log.ts"; const log = logger("bridge:fs-rename-fix"); const TRANSIENT_CODES = new Set(["EPERM", "EBUSY", "EACCES"]); export interface RetryOptions { maxAttempts: number; delayMs: (attempt: number) => number; } export const DEFAULT_RETRY_OPTIONS: RetryOptions = { maxAttempts: 10, // 10, 20, 40, 80, 160, 320, 640, then capped at 1000ms — ~4.3s max total // before giving up. Mirrors graceful-fs's schedule for the same codes. delayMs: attempt => Math.min(1000, 10 * 2 ** attempt), }; function isTransientFsError(err: unknown): boolean { if (typeof err !== "object" || err === null) return false; const code = (err as { code?: unknown }).code; return typeof code === "string" && TRANSIENT_CODES.has(code); } function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Retry `rename(from, to)` on the Windows transient codes EPERM / EBUSY / * EACCES. Re-throws non-transient errors immediately. Exported for testing. */ export async function renameWithRetry( rename: (from: string, to: string) => Promise, from: string, to: string, options: RetryOptions = DEFAULT_RETRY_OPTIONS, ): Promise { let lastErr: unknown; for (let attempt = 0; attempt < options.maxAttempts; attempt++) { try { await rename(from, to); if (attempt > 0) { log.info(`rename succeeded after ${attempt + 1} attempts`, { from, to }); } return; } catch (err) { lastErr = err; if (!isTransientFsError(err)) throw err; if (attempt === options.maxAttempts - 1) break; await sleep(options.delayMs(attempt)); } } log.warn(`rename exhausted ${options.maxAttempts} attempts; giving up`, { from, to, code: (lastErr as { code?: string } | undefined)?.code, }); throw lastErr; } let installed = false; /** * Patch `FileSessionStorage.prototype.rename` to retry transient Windows * errors. Idempotent. No-op on non-Windows platforms. Returns true if the * patch was applied on this call, false if it was a no-op. */ export function installFsWindowsRenameRetry(): boolean { if (installed) return false; if (process.platform !== "win32") { installed = true; return false; } const proto = FileSessionStorage.prototype as unknown as { rename(from: string, to: string): Promise; }; const originalRename = proto.rename; proto.rename = function patchedRename(from: string, to: string): Promise { return renameWithRetry((f, t) => originalRename.call(this, f, t), from, to); }; installed = true; log.info("FileSessionStorage.rename patched with Windows transient-error retry"); return true; } // Side-effect install. Importing this module patches the SDK once. installFsWindowsRenameRetry();