@sema-agent/core
Version:
Stateless, task-oriented AI agent core
252 lines (251 loc) • 19.2 kB
TypeScript
import type { TaskResult } from "./types.js";
/**
* design/97 CORE-7 — one recorded `ctx.agent` result in a workflow's RESUME journal. Unlike the observation-only
* {@link WorkflowRunStore} (best-effort, swallowed on throw), the journal is LOAD-BEARING: a resume replays the
* longest unchanged PREFIX of these (keyed by the deterministic {@link workflowAgentCallKey}) and runs only the
* first changed/new call + everything after it live. Same script + same args → 100% cache hit.
*/
export type WorkflowJournalEntry = {
/** The agent's deterministic call key (`ordinal:specIdentityHash`) — the replay match key. */
callKey: string;
/** The agent's TaskResult, replayed verbatim when a resume's call key matches at the same ordinal. */
result: TaskResult;
parked?: never;
} | {
callKey: string;
result?: never;
/**
* The ordinal's leg is PARKED at a durable approval gate: the child's own paused `TaskResult`
* (its `terminal` carries the checkpoint token + gate, its `sessionId` the pinned session). A resume
* reaching this ordinal with a matching call key does NOT run the call live (the parked child's
* session is pinned under a pending checkpoint; a fresh spawn would duplicate it) — it drives the
* parked child's resume when the caller hands it a decision for the token, else re-parks. Deliberately
* a DIFFERENT key from `result`: a journal reader from before this entry kind existed finds no
* `result.terminal` and refuses the whole resume (`WorkflowJournalIncompatibleError`) instead of
* admitting a paused cause it would re-run live — one-way, loud, like every journal schema step.
*/
parked: TaskResult & {
terminal: Extract<TaskResult["terminal"], {
kind: "paused";
}>;
};
};
/** REF-D5: the resume-claim key triple — ONE shape for the interface pair and every implementation
* (it was re-inlined seven times; a key-field rename must red every leg at once). */
export interface ResumeClaimArgs {
sourceRunId: string;
newRunId: string;
scope: string;
}
/**
* The seam a resume reads/writes its journal through. Core ships {@link InMemoryWorkflowJournalStore}; a durable
* deployment provides a Postgres-backed one (service SVC-2) so a workflow survives a replica crash. The owner of
* a run is the sole writer of its journal.
*
* 🔐 `scope` (the TENANT/principal of the resuming run) is LOAD-BEARING for multi-tenant isolation (CORE-9 audit
* BLOCKER): an LLM controls the `resumeFromRunId`, so `load` MUST be constrained to the caller's scope — exactly
* as {@link WorkflowRunStore} puts scope in the WHERE of every method. A store implementation MUST return ONLY a
* run's journal whose recorded scope equals the requested `scope` (a cross-scope `resumeFromRunId` resolves to an
* EMPTY journal → the resume safely diverges to a live re-run, never disclosing another tenant's results).
*
* SESSION-axis RULING (ruled 2026-07-22):
* `load` is deliberately SCOPE-only, PERMANENTLY — same-scope callers from a different session may
* replay a run's journal. Rationale: (a) same-scope = same trust domain (the task-list store
* records the same posture) and a runId is engine-minted, not enumerable; (b) cross-session resume
* is `resumeFromRunId`'s CORE legitimate use — a fresh session picking up yesterday's run has a new
* taskId AND a new sessionId by construction, so ANY engine-side session/owner clamp on this face
* refuses exactly the contract the tool teaches (the prefix-replay pins are the regression
* proof; a γ-batch recheck attempt was reverted on them). Layering: the ENGINE owns the scope
* hard-wall (this WHERE); the SESSION acceptance arm belongs to the SERVING layer, which has a
* session-bound connection to adjudicate against (server 1.247's wire-face soft acceptance). This
* face diverges from poll/stop (scope+owner/session positive-match) BY DESIGN: they gate on the
* caller's CURRENT identity, resume asserts continuity with a PRIOR leg.
*/
export interface WorkflowJournalStore {
/** Entries for `runId` IF its recorded scope === `scope`, ASCENDING by ordinal; otherwise EMPTY (cross-scope). */
load(runId: string, scope: string): Promise<WorkflowJournalEntry[]>;
/** Record one agent's result under `(runId, scope)`. MUST be idempotent per `(runId, ordinal)` — a resumed run
* re-appends the cached results of its replayed prefix, so a second append for the same ordinal overwrites.
* The engine waits for the returned promise at most `RunWorkflowOptions.journalAppendTimeoutMs` (default 30 s);
* a store that has not answered by then is a store fault (`workflow.journal_append_timeout`), and the write is
* NOT cancelled — it may still land after the engine gave up on it, which idempotence makes harmless. */
append(runId: string, scope: string, entry: WorkflowJournalEntry): Promise<void>;
/** (CC diagnostics parity, additive): a HUMAN/MODEL-readable coordinate for this run's journal —
* the file store returns the on-disk jsonl path (CC "Read journal.jsonl" local form); a service store
* returns its API route (cloud form). `undefined`/absent ⇒ the completion notification's diagnostics
* line teaches only the TaskOutput route (which is always live). Engine-facing string, never parsed. */
locator?(runId: string, scope: string): string | undefined;
/**
* ADMISSION for a resume, so two concurrent
* `resumeFromRunId` legs off ONE source run cannot fork its execution.
*
* Why a store seam at all: the engine's own mutex is in-process only (`startWorkflow` refuses a duplicate
* `runId` and refuses resuming from a source still ACTIVE here). A resume mints a FRESH runId, so a second
* resume off the same source — from another process, replica, or a later session — trips neither guard: both
* legs replay the same prefix and then re-run the whole suffix LIVE, duplicating every side effect after it,
* each writing its own journal, neither ever seeing the other. The only place that fork is visible is the
* shared durable layer, i.e. here.
*
* OPTIONAL, and absence is a first-class state: a store that does NOT implement this has NO STORE-LEVEL lease
* semantics. A store without the hook is no longer completely
* unguarded, though — `runWorkflow`'s engine falls back to a process-local admission table for exactly this
* shape, so a same-process double `resumeFromRunId` off one source is still refused even against a store
* that never implements this member. That fallback cannot see a SECOND process, which is the one thing a
* real implementation is for — core's own {@link InMemoryWorkflowJournalStore} DOES implement this member
* (with in-process-only semantics; a single-process store cannot promise more), so the engine's fallback
* table is, in practice, only ever exercised by some OTHER store that omits the hook.
*
* Contract:
* - `granted: true` ⇒ the caller (`newRunId`) now HOLDS the claim on `(sourceRunId, scope)` and owes exactly
* one {@link releaseResumeClaim} for it. The engine calls that at the run's terminal boundary — completed,
* failed, cancelled/aborted, or abandoned by a total-timeout — in a `finally`.
* - `granted: false` ⇒ someone else holds it. The engine REFUSES the resume with an explicit error rather
* than forking; `holder` (the holding leg's `newRunId`, when the store can attribute it) goes into that
* error text so an operator can find the run that is actually still going. `holder` is store-supplied text
* and is charset-clamped before it lands in a message.
* - IDEMPOTENT for the same holder: a re-claim by the SAME `newRunId` on a claim it already holds MUST be
* granted (a retry must not deadlock a run against itself).
* - `scope` is the journal scope passed through VERBATIM. A claim is keyed by `(sourceRunId, scope)`,
* NOT by `sourceRunId` alone: a cross-scope caller already resolves to an EMPTY journal (the CORE-9 wall
* above) so it cannot fork anything, and letting it take a global claim would hand any tenant a
* denial-of-resume lever over another's runs.
* - THROWING is a store failure, not a refusal — it propagates and fails the resume. Return `{granted:false}`
* to refuse.
* - A GRANT MEANS POSSESSION (RB-356①). `granted: true` may only be returned once
* the claim is RECORDED where every other contender will see it; a store that cannot tell whether it holds
* the claim must refuse, with `holder` omitted if it cannot attribute the winner. The asymmetry is the
* reason: an unearned refusal fails one resume loudly and is recoverable, an unearned grant forks a run
* and its duplicated side effects are not.
* - ACQUISITION MUST BE ATOMIC (RB-354). Contenders must never be able to observe a claim MID-ACQUISITION
* and read that state as "no claim" — an implementation that reserves the key first and fills the record
* in a second step needs the reservation itself to be the exclusion (SQL: one INSERT; core's file store:
* write a temp, then `link` it into place). Note this interacts with crash recovery: any rule of the form
* "an incomplete record is dead, take it over" is only safe if an in-flight acquisition can never look
* incomplete.
*
* TWO INVALIDATION DOMAINS, each covering what the other cannot:
* 1. ENGINE release — the normal path, prompt, covers every terminal outcome of a live engine.
* 2. STORE TTL — the backstop for an engine that DIED holding a claim (crash/kill/replica loss): it never
* reaches its `finally`, so only a store-side expiry can ever free that row. Server's SQL twins land this
* as the bake-store `idem_key UNIQUE` + TTL shape; core's file store carries the same TTL semantics.
* A store implementing neither expiry nor release would wedge the source run's resume permanently.
*/
resumeClaim?(input: ResumeClaimArgs): Promise<{
granted: boolean;
holder?: string;
}>;
/**
* Release the claim {@link resumeClaim} granted to `newRunId` on `(sourceRunId, scope)`.
* Called by the engine at the resuming run's TERMINAL boundary (in a `finally`, so abort/error/abandon are
* covered), and ONLY when the matching claim was actually granted.
*
* MUST be idempotent and MUST NOT release a claim the caller does not hold: the claim may already be gone
* (TTL-reaped after a stall, then taken over by another leg), and unlinking the successor's claim would
* re-open exactly the fork this seam exists to prevent. A missing claim is a no-op, never an error.
*
* A throw here is swallowed by the engine — a release failure must never replace the run's real outcome, and
* the store's TTL is the standing backstop for the row it left behind.
*
* The engine's terminal boundary now BOUNDS this call (and its wait on
* the {@link resumeClaim} verdict) by a timeout — a store that hangs would otherwise keep the run's `done`
* promise pending forever, after the outcome is already decided. A release that outlives that bound is
* treated exactly like one that throws: swallowed, with the TTL as the backstop. Implementations should not
* rely on the engine waiting indefinitely for cleanup.
*/
releaseResumeClaim?(input: ResumeClaimArgs): Promise<void>;
}
/** The ordinal a callKey (`ordinal:hash`) was minted at — the journal's positional index. The sole producer
* ({@link workflowAgentCallKey}) always emits a well-formed `<int>:<hash>`; a MALFORMED key (no leading int)
* falls back to `0` rather than throwing — callers must not feed it arbitrary strings (audit note). */
export declare const MAX_JOURNAL_RESULT_BYTES: number;
/** True when a serialized journal result is too large to store (UTF-8 byte length over {@link MAX_JOURNAL_RESULT_BYTES}). */
export declare function oversizeJournalResult(serialized: string): boolean;
export declare function callKeyOrdinal(callKey: string): number;
/** The errorCode a journal OVERSIZE TOMBSTONE carries — the resume path recognizes it to
* say WHY an ordinal never replays. `failed` + this code lands on the T2A-4 no-replay arm, so replay
* behavior is identical to the entry simply being absent (the pre-tombstone shape). */
export declare const JOURNAL_OVERSIZE_ERROR_CODE = "workflow.journal_oversize";
/**
* The refusal every {@link WorkflowJournalStore.append} answers an oversize entry with (#672): a stable
* `code` ({@link JOURNAL_OVERSIZE_ERROR_CODE} — the same word the engine's result-arm tombstone carries, so
* one code means "too big for the journal" on every face) and a message a person can act on (the bytes, the
* cap, the entry's callKey). A backend outside this package throws THIS class from its own `append` — the
* contract kit asserts the code only, so the class is the convenience, the code is the contract.
*/
export declare class WorkflowJournalOversizeError extends Error {
readonly callKey: string;
readonly bytes: number;
readonly cap: number;
readonly code = "workflow.journal_oversize";
constructor(callKey: string, bytes: number, cap?: number);
}
/**
* @contract workflow.journal_oversize.refused — every {@link WorkflowJournalStore.append} REFUSES an entry whose
* payload (the `parked` arm when present, else the `result` arm) serializes to more than
* {@link MAX_JOURNAL_RESULT_BYTES} UTF-8 bytes: it throws {@link WorkflowJournalOversizeError} and journals
* nothing, on BOTH arms alike — no backend degrades an oversize entry to a silent skip, and no backend
* judges the size by a rule of its own. A backend calls this first, before it touches its medium.
*
* Why a refusal and not the earlier skip (RB-168 "every backend degrades identically"): the skip was written
* for the `result` arm, where the engine measures the SAME serialization first and journals a small
* tombstone (RB-243) — a completed result never reaches this refusal. The `parked` arm (#642) is the child's
* whole paused TaskResult, which can exceed the cap; a park the journal dropped silently is a resume that
* runs the ordinal live beside the pinned child, and the write is the one moment the deployment can be told.
* A bad value answered with a default is the failure mode this repository refuses by default; the loud
* answer is the rule, the engine's tombstone the one place that pre-empts it.
*/
export declare function assertJournalEntryFits(entry: WorkflowJournalEntry): void;
/** Mint the tiny FAILED entry the ENGINE journals in place of an oversize result. The
* tombstone is minted in the engine, NOT in a store — a store REFUSES an oversize entry
* ({@link assertJournalEntryFits}) and never invents one; the engine measures the durable copy first, so
* the store sees the tombstone, not the payload. Never copies `result.result`/`structuredOutput` (the
* oversize payload); keeps taskId/sessionId/stats so the journal read-face still shows the session handle
* and the paid spend. Its own serialization is a few hundred bytes — always under the cap (pinned). */
export declare function journalOversizeTombstone(result: TaskResult, bytes: number): TaskResult;
/**
* The TTL a resume claim {@link
* InMemoryWorkflowJournalStore} grants survives WITHOUT an engine release: the backstop for an engine that
* abandoned a granted claim without ever reaching the terminal `finally` that releases it (a bug, not the
* normal path — the normal path releases promptly). Same VALUE as the file store's `RESUME_CLAIM_TTL_MS`
* (`src/stores/file/workflow-journal-store.ts`) — kept as an independent constant rather than imported from
* it, because `core/` is upstream of `stores/file/` (the file store already imports FROM this module; the
* reverse would invert that layering) and an in-memory store's claims die with the process regardless, so the
* two constants have no shared-drift risk to guard against by literally sharing one declaration.
*/
export declare const IN_MEMORY_RESUME_CLAIM_TTL_MS: number;
/** In-process journal store (core default). A single-replica run; durable resume across crashes needs the
* service's Postgres store (SVC-2). Records each run's `scope` and enforces it on load (CORE-9 audit BLOCKER:
* a cross-scope resumeFromRunId resolves to an empty journal — no cross-tenant disclosure).
*
* Also carries a REAL {@link WorkflowJournalStore.resumeClaim}/{@link
* WorkflowJournalStore.releaseResumeClaim} implementation (this store used to be the canonical example of a
* store WITHOUT the seam — its own header said so). Left unimplemented, the DEFAULT deployment (no
* `journalStore` supplied, or this store supplied explicitly) had NO protection against two same-process
* `resumeFromRunId` calls off one source forking its execution — the class of bug RB-242 exists to close, and
* a dual-process probe found it wide open on the default path while the file store's half already
* worked. A single-process store's promise is necessarily single-process: this implementation gives no
* cross-process guarantee (there is no shared medium to publish a claim on), so a multi-process deployment
* still needs a store that IS durable/shared (the file or SQL stores) for the seam to mean anything beyond
* this one process. */
export declare class InMemoryWorkflowJournalStore implements WorkflowJournalStore {
private readonly runs;
/** RB-367 resume claims, nested by scope then sourceRunId (rather than a single string-concatenated key) so
* untrusted-length `scope`/`sourceRunId` text (`sourceRunId` is `resumeFromRunId` — LLM-controlled) can never
* collide across the boundary between the two fields. */
private readonly resumeClaims;
private claimsForScope;
/** Contract parity with the file store: keyed by `(sourceRunId, scope)`; idempotent re-grant to the
* SAME holder; an unexpired claim held by someone else refuses (naming the holder); an expired or absent
* claim is free to take. Acquisition is a single synchronous Map read-then-write with no `await` between
* the check and the publish — there is no window (matching the atomicity RB-354 requires of a durable
* implementation) in which a concurrent caller could observe the key as free mid-acquire. */
resumeClaim(input: ResumeClaimArgs): Promise<{
granted: boolean;
holder?: string;
}>;
/** Release ONLY a claim `newRunId` still holds (a missing/foreign/already-taken-over claim is a
* no-op, never an error — matches the file store's contract). */
releaseResumeClaim(input: ResumeClaimArgs): Promise<void>;
load(runId: string, scope: string): Promise<WorkflowJournalEntry[]>;
append(runId: string, scope: string, entry: WorkflowJournalEntry): Promise<void>;
}