UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

448 lines (447 loc) • 27.5 kB
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;