@sema-agent/core
Version:
Stateless, task-oriented AI agent core
448 lines (447 loc) • 27.5 kB
TypeScript
import type { MemoryOriginCause } from "./types.js";
export declare const DELEGATION_SETTLEMENT_FILE = "delegation-settlement.json";
/** A row's FIRST-WRITE status. Rows are immutable after their first write — every later transition
* is an append-only event ({@link SettlementEvent}); the effective status is the fold. */
export type SettlementRowStatus = "pending" | "unattestable";
/** The effective (folded) status vocabulary. */
export type SettlementStatus = "pending" | "clean" | "external" | "expired" | "void" | "unattestable";
/** One delegation's launch fact (write-ahead: this row lands BEFORE the delegation registers or
* invokes — a launch that cannot be accounted must not run). */
export interface DelegationSettlementRow {
/** Background lane: minted at the write-ahead point (the launch site holds it thereafter).
* Sync lane: the injective `(sessionId, toolCallId)` encoding ({@link syncSettleId}). */
settleId: string;
sessionId: string;
launchedAt: number;
lane: "background" | "sync";
status: SettlementRowStatus;
/** The delegation TOOL CALL that launched this row (background lane) — the receipt-exemption
* anchor: a sync delivery claiming to be a background launch receipt is honored only when a
* write-ahead row for ITS OWN toolUseId exists (a status string alone must not buy the
* exemption — adversarial round 2's forged-receipt vehicle). */
toolUseId?: string;
}
/** One append-only transition/annotation event. Idempotent per eventId. */
export interface SettlementEvent {
settleId: string;
eventId: string;
at: number;
kind: "settle" | "admin-resolve" | "annotation";
/** For `settle`: the observed terminal (`"unattestable"` = the delegation terminated but its
* conduct is permanently unprovable — a recorder-less or incomplete chain). For `admin-resolve`:
* the host's ruling ("clean"|"external"). */
status?: "clean" | "external" | "expired" | "void" | "unattestable";
/** Audit attribution for `admin-resolve` (who asked — required there, refused absent). */
requestId?: string;
note?: string;
}
/** The injective sync-lane settleId: `encodeURIComponent` on both members keeps the separator
* unambiguous (neither side can smuggle a `:`), so `(sessionId, toolCallId)` is one-to-one. */
export declare function syncSettleId(sessionId: string, toolCallId: string): string;
/**
* §3.3 write-ahead — land the pending row for a BACKGROUND delegation, before registration and
* before invoke (行→注册→invoke). Idempotent per settleId (a revive cycle re-registers under a
* fresh settleId; a crash-retry of the same launch replays the same row). Throws on an unwritable
* or corrupt ledger — the launch site fails the launch loudly (a delegation that cannot be
* accounted must not run: the whole point is that no child exists outside the account).
*/
export declare function registerDelegationLaunch(controlDir: string, input: {
settleId: string;
sessionId: string;
now: () => number;
toolUseId?: string;
}): void;
/**
* §3.5 (r5-3) — the SYNC lane's unattestable row: a synchronous delegation delivered with a
* missing/unknown attestation under the "attested-only" evidence standard. The row is a durable
* TERMINAL at first write (there is nothing left to settle — the window is already closed
* unprovable), and it must commit BEFORE the result enters the transcript: the caller treats a
* throw here as "deliver an error instead" (fail-closed — unattested content must not ride into
* the transcript of a session whose account could not record the fact).
*/
export declare function recordSyncUnattestable(controlDir: string, input: {
sessionId: string;
toolCallId: string;
now: () => number;
}): void;
/**
* §3.3 settle — append one observed terminal (idempotent per eventId; the default eventId keys on
* (settleId, status) so an at-least-once observer replays into one event). Transition legality is
* the FOLD's question, not the append's: an event that the fold rules out (e.g. `clean` arriving
* after `expired`) still lands — as an event row the fold reads as an annotation-grade fact — so
* the account never loses an observation (事件不丢), it just doesn't let it rewrite history.
*/
export declare function settleDelegation(controlDir: string, input: {
settleId: string;
status: "clean" | "external" | "expired" | "void" | "unattestable";
now: () => number;
eventId?: string;
note?: string;
}): void;
/**
* §3.3 host valve — `resolveSettlement(settleId, "clean"|"external", requestId)`. Advisory, same
* trust plane as the host API family; `requestId` is the audit anchor and is REQUIRED (a
* settlement resolution without attribution is refused loudly, #123 posture). Effective-state
* semantics live in the fold: an admin `clean` flips the EFFECTIVE state (releases post-expiry)
* but never the history bit, and a LATER observed `external` overrides it (观测证据>admin 裁决).
*/
export declare function resolveSettlementRecord(controlDir: string, input: {
settleId: string;
to: "clean" | "external";
requestId: string;
now: () => number;
}): void;
/** One settlement's folded (effective) view. */
export interface EffectiveSettlement {
row: DelegationSettlementRow;
/** The effective status after the fold (终局序: clean/external 互斥首达胜; external 可覆写
* expired; admin clean 翻 effective 不翻史; admin 之后的 observed external 再覆写). */
effective: SettlementStatus;
/** Monotonic history bit: an `external` was EVER observed (never cleared, admin included not —
* admin resolves are rulings, not observations). */
everExternal: boolean;
/** The terminal event whose identity keys the external-effects replay (undefined while pending). */
terminalEventId?: string;
}
/** Lock-less folded read of the whole ledger (journal-aware, fail-closed on corruption). */
export declare function effectiveSettlements(controlDir: string): EffectiveSettlement[];
/** One session's folded rows (the harvest classifier's input). */
export declare function sessionSettlements(controlDir: string, sessionId: string): EffectiveSettlement[];
/**
* §3.1 — the session exposure classifier over the settlement account (the durable pollution marker
* is the OTHER input; the caller ORs them). `exposed` ⇔ any {external, unattestable, expired}
* effective row; `pending` ⇔ not exposed ∧ any pending row. The mint cause follows the strongest
* evidence: an external row is an observed fact; unattestable/expired are evidence windows that
* closed unprovable (the static-standard cause).
*/
export declare function classifySessionSettlements(rows: readonly EffectiveSettlement[]): {
state: "clean" | "pending" | "exposed";
cause?: MemoryOriginCause;
reason?: string;
};
/**
* §3.3-4 — expire overdue pending rows (reconciliation's first leg). Every expiry is an event
* (idempotent by (settleId, "expired") key); returns the settleIds newly expired this pass.
*/
export declare function expireOverdueSettlements(controlDir: string, input: {
timeoutMs: number;
now: () => number;
}): string[];
/**
* §3.3-2 — replay the EXTERNAL rows' side effects (账先落、副作用可重放): ensure the durable
* pollution marker is in place (wx, first-write-wins) and the retroaction challenge sweep has
* covered the session's unmarked committed contributions (eventId keyed to the terminal event —
* a replay lands the SAME generation, a later terminal re-arms). Also enqueues the next-session
* announcement once per terminal event. Idempotent throughout; the watermark only skips work.
* `carry` scopes the sweep domain exactly like the engine's own sweeps (marked rows carry their
* account under "carry"; the "off" width challenges everything).
*/
export declare function replayExternalSettlementEffects(controlDir: string, input: {
carry: boolean;
now: () => number;
}): Array<{
settleId: string;
sessionId: string;
}>;
export declare const SESSION_ACCOUNTS_FILE = "session-accounts.json";
/** One member of the sticky set: the ledger form `(scope, rel)` — `rel` relative to the scope's
* home, `/`-separated. Location-free, so a home move never re-keys the membership. */
export interface UnattributedMember {
scope: string;
rel: string;
}
export interface SessionAccountRow {
sessionId: string;
openedAt: number;
/** The scope this session writes (its write home) — the layer it is a live writer of until
* `terminatedAt` ({@link openLayerWriters}). Absent on rows written before the field. */
writeScope?: string;
/** The session's leg ENDED here: its terminal harvest returned (whatever it admitted — a refused,
* zero-admission or budget-deferred pass ends the leg all the same), or the host valve closed the
* row. A lifecycle fact, independent of `closedAt` (every full harvest closes the account — a
* checkpoint harvest too — while the session may keep writing); cleared when a resume re-opens
* the row. */
terminatedAt?: number;
/** The LEG that holds the row now: minted by the materialize that opened (or re-opened) it and
* carried on that mount's handle. A close or a leg end made for another leg — an earlier leg
* whose harvest finished after a resume re-opened the row — is not this leg's and is not written. */
leg?: string;
/** The STICKY unattributed set, frozen at materialize (§3.6 归属判据): pre-existing, uncommitted
* files seen while a FOREIGN dangling open row stood. Membership never shrinks on later writes
* (the sticky law); it clears only when the row is re-opened with a fresh classification (the
* residue was adjudicated by an intervening harvest). */
unattributed: UnattributedMember[];
/** The v1 members (memory-dir-relative paths) the registry could not attribute at the one-time
* conversion. Kept, never dropped; while any stands, EVERY membership question about
* this session answers "unattributed" (over-marking is the safe direction). */
unattributable?: string[];
closedAt?: number;
/** r7-3 — the host valve closed this row WITHOUT adjudication: it stops dangling but keeps
* triggering the residue arm until the next FULL-domain harvest closes an account normally
* (an unadjudicated close must not launder the residue window it covered). */
unadjudicated?: true;
}
/** The one-time v1 key-form conversions of the three engine sidecars, in one call (each in its own
* lock, each idempotent). The layout adoption runs this before any later scope registration can
* change what a v1 key's first segment names. */
export declare function convertV1SidecarKeys(controlDir: string): void;
/** The dangling-trigger read: open rows (and unadjudicated closes) belonging to OTHER sessions.
* These are what arm the unattributed residue classification at a session's materialize. */
export declare function foreignDanglingSessionAccounts(controlDir: string, selfSessionId: string): SessionAccountRow[];
/** The live writers of the store's layers, as the account knows them: every row other than the one
* held by `selfLeg` (the asking mount's own leg — another leg of the same session is another
* writer) whose session's leg has not ended (no `terminatedAt` — running, or ended without
* a terminal close, e.g. a crash), by its write scope. A row written before the write scope was
* recorded (the engine then closed the account at every full harvest, so its close cannot say
* whether the leg ended) reads as before: live while never closed, counted in `unscoped` — a
* writer of an unknown layer. Sessions that open no row (no session id, provenance "off", an
* adoption-restricted mount) are not visible here. */
export declare function openLayerWriters(controlDir: string, selfLeg: string | undefined): {
scopes: Set<string>;
unscoped: number;
};
/** The leg that holds `sessionId`'s account row (undefined ⇔ no row, or a row without one). */
export declare function sessionAccountLeg(controlDir: string, sessionId: string): string | undefined;
/** The write scope `sessionId`'s account row records — the scope its newest account-opening mount wrote
* under (the value that mount's handle carries as `writeScope`), whether that leg is still live or has
* ended. Undefined ⇔ no row, a row written before the field, or a v1 ledger (whose rows predate it —
* answered without the conversion, so this read writes nothing). Throws on a corrupt ledger. */
export declare function sessionAccountWriteHome(controlDir: string, sessionId: string): {
writeScope: string;
openedAt: number;
} | undefined;
/** The membership question the harvest asks per file: is `(scope, rel)` in the session's sticky
* set? A session carrying members the conversion could not attribute answers yes for every seat. */
export interface SessionUnattributedSet {
has(scope: string, rel: string): boolean;
/** True when no seat can answer yes (no members, none unattributable). */
readonly empty: boolean;
}
export declare function emptyUnattributedSet(): SessionUnattributedSet;
/** Open (or re-open, on a same-session resume) the session's account row, persisting the sticky
* unattributed classification frozen at THIS materialize. */
export declare function openSessionAccount(controlDir: string, input: {
sessionId: string;
now: () => number;
unattributed: readonly UnattributedMember[];
writeScope?: string;
leg?: string;
}): void;
/** The current session's sticky unattributed set (the harvest's residue-arm input). */
export declare function sessionUnattributedSet(controlDir: string, sessionId: string): SessionUnattributedSet;
/** Close the session's account row — called ONLY after a FULL-domain harvest (zero BUDGET-deferred
* files, i.e. `HarvestReport.degraded` absent): a partial harvest's close would launder the deferred
* residue window (§3.6 序则②). A normal full close also clears every standing `unadjudicated` flag
* (r7-3: the valve's conservative window ends when a full harvest has adjudicated the plane).
*
* ORTHOGONAL to the projection-debt ledger: neither this close nor the valve's
* `closed-unadjudicated` close reads, settles or faults a debt row, so a projection-debt deferral
* OUTLIVES the close and is re-judged by the next harvest's consult — it is not a partial-harvest
* condition and does not hold the close, but the caller DISCLOSES any standing deferral beside it
* (a close over id-less deferred files must not read as a clean full pass).
*
* Returns what happened ({@link SessionAccountWrite}): `"absent"` (a session that never opened an
* account — e.g. an adoption-restricted materialize — has no close, and the caller's disclosure
* must not claim one), or `"stale"` when `leg` names a mount that no longer holds the row. */
export declare function closeSessionAccount(controlDir: string, input: {
sessionId: string;
now: () => number;
leg?: string;
}): SessionAccountWrite;
/** What a close / leg end did: wrote the row, found none, or found the row held by ANOTHER leg (the
* caller's leg is over — a resume re-opened the row — so nothing was written). */
export type SessionAccountWrite = "written" | "absent" | "stale";
/** Record that the session's leg ended ({@link SessionAccountRow.terminatedAt}). Touches nothing
* else — `closedAt` stays whatever the harvests made it. `leg` (the caller's mount) must hold the
* row: a leg that is over does not end the one that re-opened it. */
export declare function terminateSessionAccount(controlDir: string, input: {
sessionId: string;
now: () => number;
leg?: string;
}): SessionAccountWrite;
/** The host valve for a dangling row (advisory; §3.6 — dangling rows never auto-expire). The
* close is `closed-unadjudicated` (r7-3): it stops the row dangling but the residue arm keeps
* firing until a full-domain harvest closes normally. `requestId` required (audit, #123). */
export declare function resolveSessionAccountRecord(controlDir: string, input: {
sessionId: string;
requestId: string;
now: () => number;
}): void;
export declare const HOLDS_FILE = "holds.json";
export declare const HOLD_DIR = "hold";
export type HoldStatus = "capturing" | "held" | "released" | "disposed";
export type HoldTerminal = "dirty" | "expired" | "conflict" | "discarded" | "capture_lost";
export interface HoldRow {
holdId: string;
/** The WRITER session (lineage attribution on release records this id — the true author). */
sessionId: string;
/** The committed entry this hold's file addressed, when it addressed one (update form). */
entryId?: string;
op: "add" | "update";
/** CAS anchor at capture time (update form): release refuses to blind-write over a later edit. */
baseRev?: string;
/** The plane seat the file was removed from, in the ledger form: relative to the write scope's
* home (`<memoryDir>/<label>`), `/`-separated — `${slug}.md`. Location-free, so a home move never
* re-keys a hold. */
rel: string;
/** The entry slug (path relative to the scope dir, sans `.md`) — the release leg's projection
* identity and its filename-scan input. */
slug: string;
/** The write scope the capture belonged to — the release commits into it. */
scope: string;
/** The custody file name under `hold/`. */
captureName: string;
/** sha256 over the captured bytes (minted from the ONE read buffer — the same buffer writes the
* custody file and, verified, is what release commits: no verify-reread window). */
contentDigest: string;
capturedAt: number;
/** The settlement rows whose outcome this hold waits on (the session's pending set at capture). */
settleIds: string[];
status: HoldStatus;
disposition?: {
terminal: HoldTerminal;
quarantineName?: string;
};
/** Host valve verdict awaiting the next harvest ("release" commits with cause "static"). */
resolved?: "release" | "discard";
/** The verdict's GENERATION (strictly increases on every re-resolution): the settle leg's
* consumed-verdict exit clears only the exact generation it read, so a host re-resolution
* racing a failing release attempt is never erased by the stale attempt's cleanup. Absent on
* rows resolved before the member existed (a generation-less clear matches those). */
resolvedAt?: number;
}
export declare function readHolds(controlDir: string): HoldRow[];
/** Where a plane file is staged while it is displaced: a SIBLING in the file's OWN directory.
* `rename` is atomic only WITHIN one filesystem, and the control plane is routinely a different mount
* from the memory dir — staging there surrenders the atomicity the rename-first law exists for, and a
* cross-device refusal is the ordinary shape of a two-volume deployment, not an exotic failure. A
* sibling is on the file's filesystem by construction, so one path serves both layouts. The staged
* name is never `.md`-suffixed; custody stays in the control plane ({@link retireStagingToCustody}). */
export declare function stagingPathBeside(absPath: string, stagingName: string): string;
/** Move whatever still sits at a staged generation into control-plane custody and report WHERE the
* bytes ended up. Withheld bytes belong in the control plane, never on the model-visible plane — but
* a copy that cannot be made there must leave the only copy where it is and SAY so, so the coordinate
* is the host's recovery address either way and `settled` answers one question: in custody, with
* nothing left beside the seat? Takes an ALREADY-CLAIMED path (read, decision and removal address
* one inode) and removes it only after {@link writeCustodyVerified} has proven the copy. */
export declare function retireStagingToCustody(claimedPath: string, custodyDir: string, stagingName: string): {
settled: boolean;
at: string;
};
/** ATOMIC no-replace restore of a staged file onto a plane path (adversarial round 2: an
* existsSync-then-rename pair is a TOCTOU — POSIX rename REPLACES a destination created between
* the check and the call, clobbering a third writer's newer bytes). `link` refuses EEXIST
* atomically: on success the staging name is dropped (one inode, two names → one); on EEXIST the
* staging file is KEPT (custody — never overwrite the plane's newer legal bytes). */
export declare function noReplaceRestore(stagingPath: string, destPath: string): "restored" | "kept" | "stranded";
/**
* §4.2 — open one hold over an instruction-form file: the two-stage journal.
* ① append row `status:"capturing"` (deterministic captureName + contentDigest minted from the
* one read buffer) — the account exists before any byte moves;
* ② write the custody file FROM THE SAME BUFFER, read it back, verify the digest;
* ③ remove the plane file RENAME-FIRST (atomic move to a staged sibling in the file's OWN
* directory — {@link stagingPathBeside}), then hash what actually moved: equal ⇒ the staged
* duplicate is dropped (custody already holds verified bytes); unequal ⇒ a third writer replaced
* the file between read and rename — NO-CLOBBER rollback (the newer bytes go back ONLY if the
* plane path is still absent; a re-created path keeps both copies, the staged bytes retire into
* control-plane custody and the caller hears "conflict" — never an overwrite of newer legal bytes);
* ④ flip the row to `"held"`.
* Every failure path returns a structured outcome (never throws past the row append): the caller
* fail-closes the file through the ordinary quarantine containment instead — an instruction file
* must never stay on the plane because its hold failed. A directory that refuses the move (read-only
* plane, revoked permission) is one such refusal: the file would stay on the plane, the one outcome
* a hold may not produce.
*/
export declare function openInstructionHold(controlDir: string, input: {
sessionId: string;
/** The seat in the ledger form (relative to the write scope's home). */
rel: string;
absPath: string;
content: string;
entryId?: string;
op: "add" | "update";
baseRev?: string;
slug: string;
scope: string;
settleIds: readonly string[];
now: () => number;
}): {
ok: true;
holdId: string;
thirdWriterStrandedAt?: string;
} | {
ok: false;
holdId?: string;
reason: string;
};
/**
* §4.2 reconciliation — converge half-way holds (a crash between the journal stages):
* - `capturing` + custody file present & digest-equal ⇒ flip held (the crash was after ②);
* - `capturing` + custody absent + plane file present & digest-equal ⇒ complete the capture and
* continue the two-stage walk; digest-UNEQUAL ⇒ the bytes were replaced — `capture_lost`
* (the file stays on the plane and walks the ordinary/§3.6 arms; never "capture whatever is
* there now" — that would hold a third writer's bytes under the first writer's account);
* - `capturing` + custody absent + plane file gone ⇒ `capture_lost` (nothing to hold).
* - `held` + plane file REAPPEARED at the seat ⇒ left alone (a fresh write is a fresh file —
* the next harvest judges it on its own; the hold's custody bytes stay the hold's).
*/
export declare function reconcileHolds(controlDir: string, input: {
homeOf: (scope: string) => string;
now: () => number;
}): void;
/** Dispose one held row: custody bytes move to the quarantine directory (host-auditable), the row
* records its terminal + quarantineName (from-row-to-bytes addressing survives every terminal). */
export declare function disposeHold(controlDir: string, input: {
holdId: string;
terminal: HoldTerminal;
now: () => number;
}): {
ok: boolean;
quarantineName?: string;
};
/** Flip one row to released (the caller — the harvest release leg — has already committed the
* custody bytes through the full gate set; the custody file is dropped after the flip). */
export declare function markHoldReleased(controlDir: string, input: {
holdId: string;
now: () => number;
}): void;
/** The host valve — `resolveHold(holdId, "release"|"discard", requestId)`: records the verdict on
* the row; the NEXT harvest reconciliation executes it ("release" re-walks the full gates and
* commits WITH cause "static" — a timeout release is not proof of cleanliness; "discard"
* disposes to quarantine). requestId required (audit, #123). */
export declare function resolveHoldRecord(controlDir: string, input: {
holdId: string;
action: "release" | "discard";
requestId: string;
now: () => number;
}): void;
/**
* Consume a standing valve verdict WITHOUT any other transition — the settle leg's exit for a
* valve release that FAILED on an already-disposed row (an expired hold whose release hit a
* judgment refusal: lost custody, re-scan refusal, CAS conflict). `disposeHold` is a no-op there
* (the row is already terminal), so leaving `resolved` standing would re-run the identical failing
* release — and re-announce a disposal that never happened — on every subsequent harvest, forever.
* Clearing it returns the row to its plain disposed/expired state: the failure is disclosed by the
* caller, custody/quarantine bytes stay addressable, and `resolveHold(holdId, "release")` remains
* admissible after the host reconciles. GENERATION-KEYED: only the exact verdict generation the
* caller read is cleared — a host re-resolution that landed while the failing attempt was in
* flight is a NEWER generation and stands (the stale attempt's cleanup must not erase a live host
* action). A generation-less verdict (a row written before the member existed) is consumable
* against a generation-less read — every CURRENT writer mints a generation, so the only verdict
* such a clear can erase is one written by a PRE-generation writer racing on the same control
* plane: the standing mixed-version-writer class the upgrade-order duty prices, kept consumable
* on purpose (refusing it would revive the forever-re-firing valve loop for every verdict minted
* before the upgrade). Idempotent; unknown/verdict-less/other-generation rows are a no-op.
*/
export declare function clearHoldResolution(controlDir: string, input: {
holdId: string;
ifResolvedAt: number | undefined;
}): void;
/** Read one hold's custody bytes (release leg + expired-release valve). Returns undefined when
* the custody file is gone or fails its digest — the caller records capture_lost, never commits
* unverified bytes. For an expired-released row the bytes may already sit in quarantine
* (disposition.quarantineName) — both seats are tried, digest-verified either way. */
export declare function readHoldCustody(controlDir: string, row: HoldRow): string | undefined;
/** True ⇔ the session's pollution marker or any non-clean settlement row blocks a clean release
* (§4.2-1: any non-clean row in the session's account keeps the hold held/disposed). */
export declare function sessionBlocksCleanRelease(controlDir: string, sessionId: string): boolean;