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