@sema-agent/core
Version:
Stateless, task-oriented AI agent core
172 lines (171 loc) • 11.9 kB
TypeScript
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>;
}