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