UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

122 lines (121 loc) • 8.67 kB
import { type MailboxAckResult, type MailboxAppendMessage, type MailboxLease, type MailboxStore } from "../../core/mailbox-store.js"; import { type CorruptReadInfo } from "./corrupt-read-sink.js"; /** The waiter's refusal when a box lock stays held by a LIVE process past `lockWaitMs`. Carried as * `.code` on a plain Error (consumers branch on the string); the message names the lock and the holder. */ export declare const MAILBOX_BOX_LOCK_TIMEOUT_CODE = "mailbox.box_lock_timeout"; /** The holder's refusal when its lock was found to have changed hands at release — the critical * section may have overlapped another process's; its effects are reported as NOT trustworthy. */ export declare const MAILBOX_BOX_LOCK_COMPROMISED_CODE = "mailbox.box_lock_compromised"; /** * Acquire the cross-process BOX LOCK (design/385 §1.2⑥ ring 1) at `lockPath` — `<box>.jsonl.lock`. * Resolves with a release function once held; rejects with `mailbox.box_lock_timeout` when the lock is * not obtainable within `waitMs` — a LIVE holder keeps it, or it is unreadable/unreclaimable and needs * an operator (the message names which). Exported at module level for the real-process acceptance * run (a fixture that holds the lock and dies / stays alive); not part of the package surface. * * Reclaim protocol (a holder that DIED — the pid recorded in the lock no longer exists): * - ONE reclaimer per stale lock instance: a reclaimer first publishes a GATE named after the stale * lock's own nonce (`<lock>.reclaim.<nonce>`, `O_EXCL`); only the gate winner touches the lock, so * no second reclaimer can remove the stale file underneath the winner and let a fresh live lock * appear at the path in between — the interleaving that would otherwise let the winner move a * live holder's lock aside. Losers back off like any waiter. * - the winner renames the stale file aside, re-reads the moved file and confirms it is exactly the * (pid, nonce) it judged (belt: a mismatch is moved back untouched), unlinks it, then removes the * gate. A gate or an aside file older than {@link BOX_LOCK_RECLAIM_GRACE_MS} is the residue of a * reclaimer that died mid-reclaim and is cleared by the next one. * - a lock whose recorded pid is ALIVE is never reclaimed. Liveness is `kill(pid, 0)`: the processes * that share a box must share the host's pid namespace (a container that mounts the same volume * from another pid namespace cannot see the holder and would judge every lock dead). A dead * holder's pid reused by an unrelated long-lived process reads as alive; the waiter then times out * LOUDLY naming the pid, and the lock file is removed by hand — the accepted failure, chosen over * an age rule that would reclaim a live-but-slow holder. * - the holder verifies its nonce at release; a lock that changed hands underneath it surfaces as * `mailbox.box_lock_compromised`, never as a silent success. */ export declare function acquireMailboxBoxLock(lockPath: string, waitMs: number): Promise<() => void>; export interface FileMailboxStoreOptions { /** When false, ledger appends are NOT fsync'd. Default true. */ fsync?: boolean; /** design/385 — how long an operation waits for a box lock a LIVE process holds before failing with * `mailbox.box_lock_timeout`. Default 10 000 ms. Must be a finite number > 0 (anything else refuses * at construction — a bad knob never becomes a silent default). */ lockWaitMs?: number; /** Compact a box's event log into a snapshot-form log once it exceeds this many events. Default 500. */ compactEvery?: number; /** Disclosure seat (review-510 family; server pickup receipt named this store's gap): a corrupt * INTERIOR record dropped by the JSONL replay is disclosed here instead of silently reading as * "less mail". The torn tail stays silent by design — that is the append-log's documented * crash-recovery shape, not corruption. Swallow-guarded; absent = the old silent tolerance. */ onCorruptRead?: (info: CorruptReadInfo) => void; } export declare class FileMailboxStore implements MailboxStore { /** design/385 — the whole cross-process set (box lock + fresh re-read + descriptor reopen + * compaction under the lock) is in place and accepted by `mailboxCrossProcessContract`. */ readonly crossProcessSafe = true; private readonly dir; private readonly tmpDir; private readonly fsyncEnabled; private readonly compactEvery; private readonly lockWaitMs; /** Paths THIS instance has touched → the EXACT BoxState it took a ref on (server[1523] 对等复审 * 发现1: after a drop/reap deletes the shared box, another instance's stale close() must not * decrement a REBUILT box's refs — identity is checked, not just the path). */ private readonly touched; /** The swallow-guarded corrupt-read seat (undefined = the old silent tolerance). */ private readonly discloseCorrupt; constructor(root: string, opts?: FileMailboxStoreOptions); /** * RB-67 (2026-07-25, red probe): the on-disk name is CASE-FOLDED, so the in-memory key and the physical * file can never disagree. Before this, `AgentOne` and `agentone` produced two DIFFERENT shared-state * keys but the SAME file on a case-insensitive filesystem (macOS/Windows default) — one recipient read * the other's messages after a restart. Folding is also the semantically correct answer, not just the * safe one: the addressing layer's `normalizeAgentName` (defined in core/task-registry-shared.ts and * re-exported by task-registry.ts) already lowercases, so * two case-variant handles ARE the same agent; this makes the storage layer say so explicitly instead of * depending on an upstream coincidence, and behave identically on case-SENSITIVE filesystems (Linux), * where the old code silently split one logical agent's mail across two boxes. */ private boxPath; /** * The canonical mutex / shared-state key — instance-independent (X-5). * * RB-162: this went through the physical path but NOT the case fold, even though the module header * claimed otherwise. The handle component was folded by `boxPath` while the ROOT was not, so two * spellings of one directory on a case-insensitive filesystem produced two shared-state slots over one * physical file, each allocating its own `seq` — the same contract violation the RB-62 comment above * describes, reached by the other half of the same canonicalization. The enumerative guard written for * this family tested `src.includes("canonicalStoreKey")`, which the CLAIM satisfied: a guard that reads * prose is green while the code diverges. */ private lockKey; /** * The two-ring critical section (module header): the in-process mutex on the canonical key, then the * cross-process box lock on the verbatim path. `fn` runs with both held; the box lock is released on * every exit. A compromised release (the lock changed hands) surfaces as a throw AFTER `fn` — an * operation whose critical section may have overlapped another process's is never reported as a * success; when `fn` itself threw, its own error wins. */ private withBox; /** * design/385 ring 2 — bring a CACHED box up to date with the file, inside the box lock. A mark that * matches means nothing happened since this process last left the file at rest; a moved mark means * another process wrote (append/lease/ack/release, or a compaction/drop that replaced the inode), * and the cache is replayed from the file. A moved INODE also swap-closes the descriptor (ring 3): * the next write reopens the current file instead of landing on the unlinked one. */ private reconcile; private load; private commit; private compact; append(scope: string, handle: string, msg: MailboxAppendMessage): Promise<number>; claimLease(scope: string, handle: string, owner: string, ttlMs: number, now?: number): Promise<MailboxLease | null>; ack(scope: string, handle: string, owner: string, upToSeq: number): Promise<MailboxAckResult>; releaseLease(scope: string, handle: string, owner: string): Promise<void>; peekCount(scope: string, handle: string): Promise<number>; drop(scope: string, handle: string): Promise<void>; reap(scope: string, now: number, opts?: { maxAgeMs?: number; }): Promise<number>; /** Release THIS instance's share of the box handles (fd closes when the last instance leaves). */ close(): void; }