UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

252 lines (251 loc) • 19.2 kB
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>; }