UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

172 lines (171 loc) • 11.9 kB
import type { CorruptReadInfo } from "./corrupt-read-sink.js"; /** * design/157 A9 — the SHARED substrate under the three snapshot+ledger file stores * ({@link import("./background-agent-store.js").FileBackgroundAgentStore}, * {@link import("./checkpoint-store.js").FileCheckpointStore}, * {@link import("./workflow-run-store.js").FileWorkflowRunStore}). * * Each of them carried its own copy of the SAME three mechanisms: a per-directory refcounted * authority table, the snapshot+ledger replay/commit skeleton, and `compact()`'s * close → rewrite → reopen swap. What stays in the stores is what genuinely differs — the ledger * EVENT model (`applyEvent`) and the domain predicates (CAS / reap / query projections). The stores * COMPOSE this (they hold a {@link LedgerCore}); nothing here is inherited or subclassed. * * ── CLASS-FIX LEDGER ──────────────────────────────────────────────────────────────────────────── * Every entry below was first fixed on ONE file and then had to be re-fixed on the siblings, some of * them twice. They now live here once, and the old addresses are named so the考据 is not lost with * the code that carried it: * * · **RB-55 / RB-59 / RB-134 — one authority per DIRECTORY, not per instance.** Two stores over one * directory each replayed a PRIVATE view, so two concurrent same-rev CASes BOTH won: a double * claim (background-agent, RB-55), a double-won run update (workflow-run, RB-59 — the 1.408.0 * sweep covered the agent store and not this one, whose cross-instance test only exercised `put`), * and a human approval consumed twice (checkpoint, RB-134, in the file whose own header calls * itself "THE LOAD-BEARING file store"). Now: {@link SharedLedgerTable}. * · **RB-144 — the table KEY is `canonicalStoreKey` (realpath + case fold).** Three stores were * still keying on a lexical `resolve()` while two of them cited the mailbox as the precedent they * copied. Now one call site, in {@link SharedLedgerTable.acquire}. * · **RB-145 — `close()` is IDEMPOTENT per INSTANCE; the refcount belongs to the DIRECTORY.** A * second `close()` on the same object used to decrement again and tear the shared log out from * under a live sibling — `log_closed` on its every later write. The per-instance latch stays in * each store (it is per-object state); the refcount, the identity-checked eviction and the fd * teardown are {@link LedgerCore.release}. * · **RB-149 / RB-167 — the compaction swap must be `closeForSwap()`, never `close()`.** `close()` * means "finished", so a failed rewrite left the log permanently unusable — one transient I/O * fault bricking every instance on that directory. The mailbox's `finally { new AppendLog(path) }` * compensation was the closest of the four and still wrong twice: it does not cover an unwritable * FILE (the reopen throws too), and its reopen error REPLACES the fault that caused the swap. The * answer is AppendLog's lazy reopen — {@link LedgerCore.compact}. * · **(2026-08-22, cross-process defect probe) — one authority per directory needs one WRITER per * directory, and that half was never enforced.** RB-55/59/134 closed the IN-PROCESS shape (two * instances, two private replays, two winners); two OS PROCESSES over one directory reproduced it * verbatim (a human approval and its refusal each reported as the winner) — and worse, the one that * never loaded the other's rows ERASED them at its next {@link LedgerCore.compact} (the snapshot is * rewritten from the in-memory map, and the live ledger is then truncated). The class docs all * credited a fence they did not hold: `root/LOCK` belongs to `FileStorageBackend`, which never * constructs the workflow-run store at all and which a directly-constructed store (all three are root * exports) never goes through. So the fence now lives WITH the authority: {@link LedgerCore.bootstrap} * takes `dir/LOCK` ({@link acquireStoreDirLock}) and the last {@link LedgerCore.release} drops it — * exclusive across processes, refcount-joined in-process, exactly like the authority it guards. * · **RB-150 / RB-167 — compaction is HOUSEKEEPING.** It runs after the operation is durable AND * applied, so its failure must never be reported as the operation's failure: a committed * once-only CAS surfaced as "this did not happen" is the worst possible direction (the caller * retries, hits the new rev, and fails again — one success reported as two failures). The swallow * is safe ONLY because of the lazy reopen above — {@link LedgerCore.commit}. * * DELIBERATELY NOT FOLDED IN: `mailbox-store.ts` (refcount is per-BOX with a per-instance dedup, and * its compaction rewrites the LIVE log as a replay-equivalent event stream instead of a separate * snapshot) and `workflow-journal-store.ts` (one ledger per RUN, no snapshot, no row map). Their * tables are the same SHAPE, not the same MECHANISM; both keep their own, with a pointer to this * ledger for the shared half of the history. */ /** The paths one ledger authority owns. `dir` is also what the authority is keyed on. */ export interface LedgerPaths { readonly dir: string; /** Disclosure seat for a replay row this authority DROPPED as corrupt — without it the loss is * indistinguishable from "fewer rows". Already swallow-guarded by the store that wired it. Only the * FIRST instance over a directory replays, so only its seat can fire: a joiner reads the authority * that instance built, which is the same fact its refcount records. */ readonly onCorruptRead?: (info: CorruptReadInfo) => void; /** Scratch dir for {@link atomicWriteFile} (same filesystem as the targets → rename is atomic). */ readonly tmpDir: string; readonly ledgerPath: string; readonly snapshotPath: string; } /** The per-store half: how a SNAPSHOT row is keyed, and how one LEDGER event folds into the map. */ export interface LedgerModel<Row, Ev> { /** What this ledger IS, in an operator's words ("checkpoint ledger") — the noun the directory-fence * refusal names, so the message points at the store that is actually in use rather than at a path. */ label: string; keyOf(row: Row): string; apply(rows: Map<string, Row>, ev: Ev): void; /** * `@contract store.read_back_shape_judged` floor: is this parsed body one of MY rows / MY events at * all? Judged on the DISCRIMINANT only — the key a row is filed under, the tag an event is folded by — * because what to do with a discriminant this binary does not KNOW is the model's own business (the * checkpoint ledger refuses the whole replay for a kind from a newer worker, deliberately). A body * that carries no discriminant carries no such choice: it is damage, and `bootstrap` discloses and * skips it. Required rather than optional, so a fourth ledger store cannot inherit the silence by * omission — before this floor existed, `{"note":"x"}` in an agent ledger filed a row under the key * `undefined` and the next enumeration read members off it. */ judgeRow(x: unknown): boolean; judgeEvent(x: unknown): boolean; } /** * One directory's authority: the replayed row map, the per-key async mutex, the append log and the * event counter. Created and owned by {@link SharedLedgerTable}; every store instance over the * directory holds a reference to the SAME object. */ export declare class LedgerCore<Row, Ev> { private readonly key; private readonly paths; private readonly model; private readonly table; /** The authoritative replayed state — domain queries read it directly. */ readonly rows: Map<string, Row>; /** How many store instances hold this authority (RB-145: it belongs to the directory). */ refs: number; private readonly locks; private events; private handle; /** This directory's cross-process writer fence, held for as long as the authority exists. */ private fence; /** Set when the last holder left: this object is no longer the directory's authority (see compact). */ private released; constructor(key: string, paths: LedgerPaths, model: LedgerModel<Row, Ev>, table: Map<string, LedgerCore<Row, Ev>>); /** * Rebuild the authority: take the directory's cross-process writer fence, then the snapshot (the * compacted base), then the live ledger's events in order (last-writer-wins), then open the append * handle. Only the FIRST instance over the directory runs it. * * The fence comes FIRST because everything after it assumes what the fence establishes: the replayed * map is treated as THE authority for the directory (writes are decided against memory, and * compaction rewrites the on-disk state FROM memory). Replaying first and locking after would be a * window in which a second process's view is already stale. */ bootstrap(): void; /** * Serialize an op behind any in-flight op on the same key — the in-process per-key async mutex (the * SAME `Map<key, Promise>` chain shape as `TtlSessionStore.pending`). Each call appends a new tail; * the next caller awaits THIS op before running. A prior op's rejection never blocks the chain (we * await it `.catch`-guarded). The entry is pruned once it is the settled tail with nothing queued. */ withLock<T>(key: string, fn: () => Promise<T> | T): Promise<T>; /** * Commit one ledger event: fsync'd append FIRST, in-memory flip AFTER — so a crash can never leave * memory and disk disagreeing. Then the best-effort housekeeping compaction (RB-150/RB-167). */ commit(ev: Ev, fsync: boolean, compactEvery: number): void; /** * Rewrite the snapshot from the authoritative map (atomic) and truncate the live ledger. After * this a replay reads the whole state from the snapshot alone — the round-trip is identical. * * REFUSED once this authority has been RELEASED. Compaction is the one operation that does not touch * the append fd (it rewrites whole files), so a store object kept alive past its `close()` could still * run it — rewriting the directory from a map that stopped being the authority, over rows a SUCCESSOR * (here or in another process, holding the fence this core gave back) has since written. That is the * same erasure the directory fence exists to prevent, arriving through a stale reference instead of a * second process, so it is refused at the same door rather than trusted to caller discipline. */ compact(): void; /** Drop one instance's share. The LAST holder evicts the authority — identity-checked, so a stale * holder can never revoke a REBUILT entry — retires it (see {@link compact}) and closes the append fd. */ release(): void; /** Test/inspection: events appended since the last compaction. */ get pendingEvents(): number; private get log(); } /** * The module-level authority table one store family keeps: ONE entry per canonical directory, with * eviction (RB-134 — a table without invalidation is RB-73's mistake again: after a `close()` the * next store over that root would join a table whose log was already closed and every write would * throw `log_closed`, instead of replaying the on-disk truth into a fresh authority). */ export declare class SharedLedgerTable<Row, Ev> { private readonly model; private readonly live; constructor(model: LedgerModel<Row, Ev>); /** Join the live authority for this directory, or create + replay a fresh one. */ acquire(paths: LedgerPaths): LedgerCore<Row, Ev>; }