UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

193 lines (192 loc) • 13.3 kB
import { type SessionRepo } from "../internal/harness.js"; import { type AcquiredSession, type PlacedSessionRow, type SessionPlacement, type SessionStore, type SessionStoreSummary } from "./session.js"; import type { StoreDurability } from "./checkpoint-store.js"; import { SESSION_DEFAULT_TTL_DAYS } from "../config/defaults.js"; export { SESSION_DEFAULT_TTL_DAYS }; /** What idle-eviction and `release` do to the underlying repo. */ export type EvictPolicy = "delete" | "forget"; export interface TtlSessionStoreOptions { /** Idle TTL before a cached session is evicted. Default 7 days. */ defaultTtlDays?: number; /** Optional background sweep interval (ms). Off by default (sweeps opportunistically on acquire). */ sweepIntervalMs?: number; /** * Backing session repo. Default `InMemorySessionRepo` (process-only, lost on restart). Supply a * **durable** repo (e.g. a TiDB-backed `SessionRepo`, or the vendored file repo) to persist * sessions: `acquire` then **resumes** an existing session via `repo.open`, so any stateless * runner instance — even after a restart or on another replica — continues the same conversation. */ repo?: SessionRepo; /** * What idle-eviction and `release` do to the repo: * - `"delete"` — remove the session from the repo too (correct for the throwaway in-memory repo). * - `"forget"` — only drop the in-memory cache; the **durable** backend keeps the history so it can * be resumed later (the backend runs its own retention/GC). * * Defaults to `"delete"` for the built-in in-memory repo and `"forget"` when a custom `repo` is * supplied — so a durable store never silently deletes history on an idle timer. */ evict?: EvictPolicy; /** * design/173 §2.3 — the store's {@link StoreDurability} declaration. This cache layer cannot * inspect whether the injected {@link repo} persists across processes, so the DEPLOYMENT that * wired the repo states the fact (e.g. `FileStorageBackend` passes `"durable"`). Defaults: * without a custom `repo` the truth is knowable — `"process-local"` is declared; with a custom * `repo` and no explicit value the store declares NOTHING, which the wiring manifest reads * fail-closed as process-local (under-promise, never over-promise). */ durability?: StoreDurability; /** * Subagent transcript persistence — the store's per-placement durability declaration (see * {@link SessionStore.placements}). Same declaration rule as {@link durability}: this cache layer * cannot inspect whether the injected repo persists placed sessions, so the DEPLOYMENT that wired * the repo states the fact (`FileStorageBackend` passes `{subagent:{durability:"durable"}}`). * Declaring it also arms the cache-layer placement obligations (list exclusion, claim refusal, * release-is-deletion, sweep-never-deletes) — they key on the repo-persisted tuple, which a repo * that ignores `placement` never mints, so declaring over such a repo is the deployment lying to * itself (declaration-制, wiring-manifest posture). */ placements?: { subagent?: { durability: StoreDurability; }; }; } /** * TTL-cached {@link SessionStore} over a pluggable {@link SessionRepo}. * * Sessions are cached in process memory and evicted after `defaultTtlDays` of inactivity. With the * default in-memory repo they live only in this process (lost on restart). Supply a **durable** repo * and the same caching/concurrency logic becomes a "session center": on `acquire`, a session not in * the live cache is **resumed from the repo** (`repo.open`) rather than recreated — so a stateless * runner resumes a conversation after a restart or across replicas. The optimistic-lock seam * (`SessionWriteOptions.expectedLeafId` → `SessionError("conflict")`) keeps two replicas writing the * same session safe; the Runner reconciles a lost CAS and retries. * * See `design/10-会话持久化与会话中心.md` for the durable-backend (TiDB) contract. */ export declare class TtlSessionStore implements SessionStore { /** Managed-retention declaration (ruled 2026-08-05, retention.ts): with a custom repo the default * evict policy is `forget` — cache eviction only, the durable rows live on — and there is no * tenant-scoped scheduled-deletion contract on any arm. Honestly `"none"`: a deployment with a * LOCKED retention policy refuses to start over this store. (The `SessionStore` interface itself * is declaration-agnostic; the retention face is the structural `RetentionDeclaring` shape.) */ readonly retention: "none"; /** design/173 §2.3 — see {@link TtlSessionStoreOptions.durability} for the declaration rules. */ readonly durability?: StoreDurability; /** Subagent transcript persistence — see {@link TtlSessionStoreOptions.placements}. */ readonly placements?: { subagent?: { durability: StoreDurability; }; }; /** Placed-partition enumeration, present only when the backing repo can enumerate placements * (see {@link SessionStore.listPlaced} — absence means the partition-GC leg is unavailable). */ readonly listPlaced?: (kind: "subagent", opts?: { olderThanMs?: number; scope?: string; }) => Promise<PlacedSessionRow[]>; private repo; private entries; /** Session-ownership rows (the multi-tenant admission capability face — see SessionStore.ownerOf). * Deliberately NOT part of `entries`: an evicted-but-durable session (`forget` / evict:"forget") * still EXISTS, and its ownership row outliving the cache entry is what keeps a live session from * reading as `undefined` (= re-claimable by anyone). Rows die only when the repo's history does. */ private owners; /** In-flight acquisitions keyed by id, so concurrent acquire(sameId) share one session. */ private pending; /** Subagent transcript persistence — placed ids whose durable deletion FAILED: the * placed fact for the retry lives HERE, not on a retained cache entry, because the repo may have * torn down the shared session authority before its removal failed (the File repo closes the * append fd pre-rmSync) — a retained entry would serve a session whose log is closed while the * file still exists (append → log_closed, transcript revival broken until restart). The entry is * dropped instead, so a re-acquire replays a FRESH authority from disk; success clears the row. */ private pendingPlacedDeletes; /** Subagent transcript persistence — placed deletions IN FLIGHT: acquire awaits the * settlement before resolving the id, because the repo's delete tears down the shared session * authority mid-flight — an acquire racing the await window could capture (or rebuild and then * lose) an authority the delete is about to close, and the caller would hold a session whose * first append answers log_closed while the file still exists. The gate is per-id and bounded by * the delete's own settlement (both outcomes release it). */ private deletingPlaced; /** Sessions pinned by a design/45 checkpoint — skipped by idle sweep until unpinned (B6/§5). */ private pinned; private defaultTtlMs; private evictPolicy; private sweepTimer?; constructor(opts?: TtlSessionStoreOptions); /** Get an existing session by id (resuming from the repo if needed), or create one. */ acquire(sessionId?: string, opts?: { requireExisting?: boolean; placement?: SessionPlacement; }): Promise<AcquiredSession>; /** * Placement obligation 6 (claim closure): an EXISTING placed session refuses the claim-form * acquire — no `requireExisting` (the read/revive form, obligation 5) and no creation placement * (the trusted-internals form; the creating call and its dedup/retry re-acquires stay legal, and * placement immutability means the argument can never REWRITE anything — the persisted tuple wins). * * …and the MIRROR direction (the pre-claim SQUAT race): the child session id is * published on the spawn frame BEFORE the child's prepare creates the session, so a concurrent * claim-form acquire could win first creation and hand the trusted placement caller an ORDINARY * session — host-listable, never really-deleted, the declared full tier falsified in silence. On * a store that DECLARES the partition (an ignoring store legally drops placements), an acquire * carrying a placement must therefore come back with a PERSISTED tuple whose join identity * matches the declaration — a missing or foreign tuple refuses loudly, on every resolution path * (cache hit, pending-promise inherit, open, idempotent re-create). */ private assertPlacementAdmission; /** Resume an existing session from the repo, or create it if the backend has no such id (unless * `requireExisting`, in which case a genuinely-missing id fails loud instead — design/114 Phase3). */ private openOrCreate; private createAndStore; /** Refresh the idle timer for a session. */ touch(sessionId: string): void; /** Record the most recent task run on a cached session (the `/resume` `lastRunId`); also bumps the idle * timer. Best-effort — a no-op if the session isn't cached here (the run will still be re-attachable via * its checkpoint). */ noteTaskRun(sessionId: string, taskId: string, runId?: string): void; /** List the live cache as {@link SessionStoreSummary} projections, newest-first by `lastActiveAt` (so a * shell's `/resume` shows recent sessions + their last run first). The in-memory store lists only its * live cache, not evicted/durable history — a durable backend overrides this to list persisted sessions. */ list(): Promise<SessionStoreSummary[]>; /** * design/110 (F3) — FORK a session: copy `sourceId`'s committed history into a NEW session and return its id * (or `null` when the source doesn't exist). Delegates to the backing repo's `fork`, so a durable repo persists the * branch; the in-memory repo holds it in-process. Registering the result in this cache makes `acquire(forkedId)` * hit immediately — the synchronous child run that `Agent(subagent_type:"fork")` launches resumes the fork without a repo round-trip. * * Adding this method is what makes {@link hasSessionFork} true for a `TtlSessionStore` wrapping ANY repo, so the * fork capability mounts (it was INERT before — the durable repo had `fork` but the store never exposed it). */ fork(sourceId: string, owner?: string | null): Promise<string | null>; /** Drop a session from the live set. With a durable repo (`evict: "forget"`) the history is kept. */ release(sessionId: string): Promise<void>; /** Drop only the cached view — the repo keeps the session regardless of `evictPolicy`, so a re-acquire * re-opens the SAME history (B-17: the reconcile retry / prepare-throw paths must never delete). Cost * on the default in-memory repo: ONE retained session object **per forgotten id** until process end * (invisible to `size`/`sweep`, which track the cache) — accumulation across many distinct forgotten * ids is the accepted trade-off vs silent history loss; deployments with churn use a durable repo, * where retention is the backend's normal job. A pinned id stays pinned (a forget must never expose a * suspended task's session to sweep eviction). */ forget(sessionId: string): void; /** Pin a session so idle sweep can't evict it while a design/45 checkpoint awaits resume (§5). */ pin(sessionId: string): void; /** Release a design/45 pin (on resume or checkpoint expiry); the session resumes normal idle eviction. */ unpin(sessionId: string): void; /** Multi-tenant admission capability (see the SessionStore contract for the three-state semantics * and the first-write-wins requirement — both are load-bearing for the deployment's owner gate). */ ownerOf(sessionId: string): Promise<string | null | undefined>; /** First-write-wins idempotent upsert; a synchronous Map write is atomic per the contract. Makes no * authorization decision — the authorizer re-reads {@link ownerOf} and judges there. */ register(sessionId: string, owner: string | null): Promise<void>; /** The session row's incarnation cell (see the SessionStore contract), read from the ROW through the * repo on every call — never from this cache: a cached view outlives a row another store over the * same repo deleted or recreated, and its copy would name the previous occupant. No row ⇒ * `undefined`; any other repo failure rejects (a fault is not an answer). Never caches, never creates. */ incarnationOf(sessionId: string): Promise<string | undefined>; /** Evict idle-expired sessions from the cache. Deletes durable history only when `evict: "delete"`. */ sweep(now?: number): void; get size(): number; dispose(): void; }