/** * Shared driver state and the writer lock. * * The adjudication named three real failure modes that come from having two * entry points into the same session: * * stale-plan hazard a plan on disk looks valid but belongs to an old round or * an old session * split-brain one driver believes it is at sid=A,r=3 and the other has * never heard of that session * false recovery recovery reads a stale plan file and treats an expired * checkpoint as current * * The hard rule that follows: **"current progress" cannot be defined by any * tool's private cache.** It is defined by the shared state plus the conversation * FRAME chain. So both adapters read and write this one file, and only one of * them may drive at a time. * * The lock is deliberately about *driving rights* only. Read-only diagnostics -- * `brb status`, `ask-file --copy-only`, the probe scripts -- must not need it, * otherwise inspecting a stuck loop would require stealing the loop. * * Zero dependencies and no browser: everything here is offline-testable. */ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { conversationLockDir, legacyDriverStatePath } from "./paths.ts"; import type { PendingAction } from "../core/controlRegistry.ts"; import type { RoundRecord } from "../core/cycle.ts"; import type { Plan } from "../core/protocol.ts"; import type { Session } from "../core/handshake.ts"; /** Bumped when the on-disk shape changes incompatibly. */ export const STATE_VERSION = 1; export function defaultStatePath(): string { return legacyDriverStatePath(); } export interface DriverLock { /** Human-readable identity, e.g. `extension:12345` or `cycle:12345`. */ owner: string; pid: number; acquiredAt: number; /** Ties the lock to a session so a lock from another conversation is visible. */ conversationId: string; sid: string; } export interface SharedState { version: number; session: Session | null; /** The plan the current round is executing. */ currentPlan: Plan | null; rounds: RoundRecord[]; pending: PendingAction | null; repairs: number; objective: string; rootRequest: string | null; lastPlanHash: string; lock: DriverLock | null; updatedAt: number; /** Which adapter wrote last, for diagnostics only. */ updatedBy: string; } export function emptyState(): SharedState { return { version: STATE_VERSION, session: null, currentPlan: null, rounds: [], pending: null, repairs: 0, objective: "", rootRequest: null, lastPlanHash: "", lock: null, updatedAt: 0, updatedBy: "", }; } /** A lock older than this is assumed to belong to a crashed process. */ export const DEFAULT_LOCK_TTL_MS = 15 * 60 * 1000; export interface AcquireResult { ok: boolean; reason: string; /** True when the previous holder's lock was treated as stale and taken over. */ tookOverStale: boolean; } export interface AcquireOptions { owner: string; pid?: number; conversationId?: string; sid?: string; now?: number; ttlMs?: number; } /** * Is the process that wrote this lock still alive? * * PID liveness alone is not enough -- a pid can be recycled, and on some * platforms the check is unavailable -- so it is combined with a TTL. Either * signal being conclusive is enough to reclaim the lock; neither being conclusive * means we respect the existing holder, because refusing to drive is recoverable * and driving two ways at once is not. */ export function isLockStale(lock: DriverLock, now: number, ttlMs: number): boolean { if (now - lock.acquiredAt > ttlMs) return true; if (!Number.isInteger(lock.pid) || lock.pid <= 0) return true; try { process.kill(lock.pid, 0); return false; } catch (error) { const code = (error as NodeJS.ErrnoException).code; // EPERM means the process exists but belongs to someone else. if (code === "EPERM") return false; return true; } } export interface StoreOptions { path?: string; ttlMs?: number; } export class SharedStore { readonly path: string; private readonly ttlMs: number; constructor(options: StoreOptions = {}) { this.path = options.path ?? defaultStatePath(); this.ttlMs = options.ttlMs ?? DEFAULT_LOCK_TTL_MS; } /** * Read state without taking the lock. * * Diagnostics use this. A corrupt or missing file yields defaults rather than * an exception: a broken state file must not make the bridge unusable, and the * conversation FRAME chain is the real recovery source anyway. */ load(): SharedState { try { if (!existsSync(this.path)) return emptyState(); const parsed = JSON.parse(readFileSync(this.path, "utf8")) as Partial; return { ...emptyState(), ...parsed, version: STATE_VERSION }; } catch { return emptyState(); } } /** Raw write. Only the lock operations may bypass the ownership check. */ private writeState(next: SharedState, now: number): { ok: boolean; reason: string } { try { mkdirSync(dirname(this.path), { recursive: true }); // Write-then-rename so a crash mid-write cannot leave a half file that // the other driver would then parse as truth. const temp = `${this.path}.tmp-${process.pid}`; writeFileSync(temp, `${JSON.stringify(next, null, 2)} `, "utf8"); renameSync(temp, this.path); return { ok: true, reason: "ok" }; } catch (error) { return { ok: false, reason: `写入失败:${error instanceof Error ? error.message : String(error)}` }; } } /** * Write state. Refuses when the caller does not hold the lock. * * The stored lock is taken from the *current* file, never from `state`. That * distinction is load-bearing: the first version wrote the caller's object * verbatim, so a caller that built its state from `emptyState()` -- which has * `lock: null` -- silently dropped the lock and handed driving rights to * whoever asked next. That is precisely the split brain this file exists to * prevent, and only a test caught it. */ save(state: SharedState, owner: string, now = Date.now()): { ok: boolean; reason: string } { const current = this.load(); if (current.lock && current.lock.owner !== owner) { const stale = isLockStale(current.lock, now, this.ttlMs); if (!stale) { return { ok: false, reason: `写入被拒绝:驱动权在 ${current.lock.owner}(pid ${current.lock.pid})手上`, }; } } return this.writeState( { ...state, lock: current.lock, version: STATE_VERSION, updatedAt: now, updatedBy: owner }, now, ); } /** * Take the driving lock. * * Re-acquiring as the same owner refreshes rather than fails, so a long run * does not lose its own lock to the TTL. */ acquire(options: AcquireOptions): AcquireResult { const now = options.now ?? Date.now(); const pid = options.pid ?? process.pid; const state = this.load(); if (state.lock) { const sameOwner = state.lock.owner === options.owner; const stale = isLockStale(state.lock, now, this.ttlMs); if (!sameOwner && !stale) { return { ok: false, reason: `已有驱动器在运行:${state.lock.owner}(pid ${state.lock.pid},` + `会话 ${state.lock.sid || "未建立"})。` + `同一时刻只允许一个 writer。只读诊断不需要驱动权——用 brb 状态。`, tookOverStale: false, }; } if (!sameOwner) { // Taking over a stale lock is allowed but must be visible: silently // inheriting someone else's lock is how a split brain starts. this.writeState( { ...state, lock: { owner: options.owner, pid, acquiredAt: now, conversationId: options.conversationId ?? state.lock.conversationId, sid: options.sid ?? state.lock.sid, }, version: STATE_VERSION, updatedAt: now, updatedBy: options.owner, }, now, ); return { ok: true, reason: `接管了陈旧的驱动权(原持有者 ${state.lock.owner},pid ${state.lock.pid})`, tookOverStale: true, }; } } const lock: DriverLock = { owner: options.owner, pid, acquiredAt: now, conversationId: options.conversationId ?? state.lock?.conversationId ?? "", sid: options.sid ?? state.lock?.sid ?? "", }; this.writeState( { ...state, lock, version: STATE_VERSION, updatedAt: now, updatedBy: options.owner }, now, ); return { ok: true, reason: "驱动权已获取", tookOverStale: false }; } /** Refresh the holder's own lock without touching anything else. */ refresh(owner: string, now = Date.now()): boolean { const state = this.load(); if (!state.lock || state.lock.owner !== owner) return false; return this.writeState( { ...state, lock: { ...state.lock, acquiredAt: now }, updatedAt: now, updatedBy: owner }, now, ).ok; } release(owner: string, now = Date.now()): boolean { const state = this.load(); if (state.lock && state.lock.owner !== owner) return false; return this.writeState({ ...state, lock: null, updatedAt: now, updatedBy: owner }, now).ok; } /** * Does this stored plan still belong to the current session and round? * * `planKey` alone is not enough: a plan from an old sid or an old round can * have a coincidentally matching key, and the whole point of this check is to * stop a stale file being presented as the current plan. */ describeFreshness(state: SharedState): string { if (!state.currentPlan) return "无当前方案"; if (!state.session) return "有方案但没有会话,方案来源不可信"; const plan = state.currentPlan; if (!plan.frame) return "方案缺少 FRAME,无法确认属于当前会话"; if (plan.frame.sid !== state.session.sid) { return `方案属于会话 ${plan.frame.sid},当前会话是 ${state.session.sid}(已过期)`; } if (plan.frame.round !== state.session.round) { return `方案是第 ${plan.frame.round} 轮的,当前是第 ${state.session.round} 轮(已过期)`; } return `方案与当前会话/轮次一致(r=${plan.frame.round})`; } } /** Remove the state file. Used by tests and by an explicit reset. */ export function clearState(path: string): void { try { if (existsSync(path)) unlinkSync(path); } catch { // best effort } } /* ------------------------------------------------------------------ */ /* Conversation-scoped driver lock */ /* ------------------------------------------------------------------ */ /** * One writer per *conversation*, independent of which session is driving. * * The state file's own lock is not enough once bindings are per-session: two * sessions pointing at the same ChatGPT conversation would each hold their own * state file and each believe they had exclusive rights, so the cross-wiring * problem would go from "always" to "an occasional race" -- which is worse, * because it stops being reproducible. * * Two layers, as the adjudication put it: this outer lock owns *driving rights* * for one conversation, while the per-session state keeps its own mutation lock. * `sid` rides along as owner metadata rather than as part of the key. * * Read-only diagnostics must never take it. */ export function defaultLockDir(): string { // Conversation locks coordinate a real external resource, so they stay in the real // coordination domain even under an isolated state root (see paths.ts). return conversationLockDir(); } /** Filesystem-safe key. Conversation ids are url-safe, but be defensive. */ function lockKey(conversationId: string): string { const cleaned = conversationId.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120); return cleaned.length > 0 ? cleaned : "unbound"; } export interface ConversationLockRecord extends DriverLock { conversationId: string; } export interface ConversationLockOptions { dir?: string; ttlMs?: number; } export class ConversationLock { readonly dir: string; private readonly ttlMs: number; constructor(options: ConversationLockOptions = {}) { this.dir = options.dir ?? defaultLockDir(); this.ttlMs = options.ttlMs ?? DEFAULT_LOCK_TTL_MS; } private pathFor(conversationId: string): string { return join(this.dir, `${lockKey(conversationId)}.json`); } /** Read a lock without taking it. Diagnostics use this. */ peek(conversationId: string): ConversationLockRecord | null { try { const path = this.pathFor(conversationId); if (!existsSync(path)) return null; return JSON.parse(readFileSync(path, "utf8")) as ConversationLockRecord; } catch { return null; } } /** Every lock currently on disk, for status output. */ list(): ConversationLockRecord[] { try { if (!existsSync(this.dir)) return []; return readdirSync(this.dir) .filter((name) => name.endsWith(".json")) .map((name) => { try { return JSON.parse(readFileSync(join(this.dir, name), "utf8")) as ConversationLockRecord; } catch { return null; } }) .filter((entry): entry is ConversationLockRecord => entry !== null); } catch { return []; } } acquire(options: { conversationId: string; owner: string; sid?: string; pid?: number; now?: number; }): AcquireResult { const now = options.now ?? Date.now(); const pid = options.pid ?? process.pid; const existing = this.peek(options.conversationId); if (existing && existing.owner !== options.owner) { if (!isLockStale(existing, now, this.ttlMs)) { return { ok: false, reason: `会话已有驱动器:${existing.owner}(pid ${existing.pid},sid ${existing.sid || "未建立"})。` + "同一时刻一个会话只允许一个 writer。只读诊断不需要驱动权——用 brb 状态。", tookOverStale: false, }; } this.write({ conversationId: options.conversationId, owner: options.owner, pid, sid: options.sid ?? "", acquiredAt: now }); return { ok: true, reason: `接管了陈旧的驱动权(原持有者 ${existing.owner},pid ${existing.pid})`, tookOverStale: true, }; } this.write({ conversationId: options.conversationId, owner: options.owner, pid, sid: options.sid ?? existing?.sid ?? "", acquiredAt: now, }); return { ok: true, reason: "驱动权已获取", tookOverStale: false }; } refresh(conversationId: string, owner: string, now = Date.now()): boolean { const existing = this.peek(conversationId); if (!existing || existing.owner !== owner) return false; this.write({ ...existing, acquiredAt: now }); return true; } release(conversationId: string, owner: string): boolean { const existing = this.peek(conversationId); if (existing && existing.owner !== owner) return false; try { const path = this.pathFor(conversationId); if (existsSync(path)) unlinkSync(path); return true; } catch { return false; } } private write(record: ConversationLockRecord): void { try { mkdirSync(this.dir, { recursive: true }); const path = this.pathFor(record.conversationId); const temp = `${path}.tmp-${process.pid}`; writeFileSync(temp, `${JSON.stringify(record, null, 2)} `, "utf8"); renameSync(temp, path); } catch { // Bookkeeping must never break the bridge; the acquire result already // told the caller what it needed to know. } } }