UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

141 lines (140 loc) • 9.99 kB
import { type WorkflowJournalEntry, type WorkflowJournalStore, type ResumeClaimArgs } from "../../core/workflow-journal-store.js"; import { type CorruptReadInfo } from "./corrupt-read-sink.js"; /** * SVC-2 — the shared size bound for a journaled workflow-agent entry, so ALL backends answer an oversize * TaskResult with the SAME refusal (no dialect divergence). Inlined from service's `workflow-journal-limits` on * onboarding — the 5 MiB figure aligns with TiDB's ~6 MiB per-row txn-entry-size-limit (a row over it is * REJECTED with "entry too large"). PostgreSQL TEXT has no such low limit, so without a shared bound the same * large entry would journal on PG and fail on TiDB; the shared bound makes every backend refuse at the same * byte and with the same code (`workflow.journal_oversize`) before the row reaches the medium. Measured in * BYTES (UTF-8) — a multibyte result can be larger than its `.length` in chars. */ export { MAX_JOURNAL_RESULT_BYTES, oversizeJournalResult } from "../../core/workflow-journal-store.js"; /** * RB-242 — how long a resume claim survives WITHOUT an engine release. This is the backstop for an engine that * died holding one (crash / kill -9 / box reboot): it never reaches its `finally`, so nothing but expiry can * ever free the row, and a source run whose resume is wedged forever is a worse failure than the fork this * guards. One hour is the balance: comfortably longer than the gap between a resume's admission and its first * journal write (the window in which a fork actually duplicates work), and short enough that a crashed box does * not lock a run out for a working day. A LIVE engine's own release is the normal path and is prompt — this * value is only ever reached by a run that is no longer running. */ export declare const RESUME_CLAIM_TTL_MS: number; /** * RB-356② — a release REFRAINS from unlinking its own claim once the claim is this close to (or past) its * expiry. Rationale: a peer may legitimately take a claim over the instant it expires, and `release` is a * read-then-unlink pair with no compare-and-delete syscall between them — so the one situation where the * unlink could destroy a SUCCESSOR's claim is precisely the one where the record we just read is already * takeable. Refusing to unlink there is free: an expired claim blocks nobody (the next acquirer takes it over) * and the sweep below reaps it. The margin only has to cover the scheduling gap between this method's read and * its unlink; seconds is orders of magnitude more than that, and losing the last five seconds of a one-hour * lease costs nothing. */ export declare const RESUME_CLAIM_RELEASE_GRACE_MS = 5000; /** * RB-356③ — how often (per store instance) an acquisition also SWEEPS the claims directory. The header's * "the TTL reaps any orphan" was an aspiration, not a mechanism: nothing ever reaped anything. An expired * claim was only removed if a LATER resume off the SAME `(sourceRunId, scope)` came along to take it over — * and the normal shape is exactly one resume per source run, so a crash (or any release that could not * complete) leaked one small file per source run for the lifetime of the data root. The sweep makes the * comment true. Throttled because it is a `readdir` on the claims directory: it is opportunistic hygiene, not * a correctness path (takeover, not the sweep, is what unblocks a contended claim promptly). */ export declare const RESUME_CLAIM_SWEEP_INTERVAL_MS = 60000; /** * RB-356③ — the sweep only reaps a claim that has been expired for at least this long. A just-expired claim * is the exclusive business of whoever is taking it over right now; the sweep has no deadline to meet, so it * stays well clear of that handover rather than adding a second unlinker to it. */ export declare const RESUME_CLAIM_REAP_GRACE_MS: number; export interface FileWorkflowJournalStoreOptions { /** Disclosure seat for a ledger line this store DROPPED on replay (not JSON, not a record, or a record * with none of this ledger's members) — a resume that silently finds "nothing to replay" re-runs the * work with no signal. Never fires on a missing ledger; swallow-guarded. */ onCorruptRead?: (info: CorruptReadInfo) => void; } export declare class FileWorkflowJournalStore implements WorkflowJournalStore { private readonly fsyncEnabled; private readonly dir; /** RB-242: resume claims live in their own subdirectory so a claim file can never be mistaken for a run * ledger by anything that enumerates `workflow-journal/` (GC, the torn-tail replay, an operator's `ls`). */ private readonly claimsDir; private readonly shared; private readonly sharedKey; private closed; /** RB-356③: last {@link sweepExpiredClaims} timestamp — throttles the reap to one `readdir` per interval. * Per INSTANCE, not per directory: the sweep is idempotent hygiene, so a second instance doing its own * first sweep is harmless (unlike the fd/scope caches above, which are authority over the directory). */ private lastClaimSweepAt; /** RB-61: one AppendLog per run (torn-tail truncation at open) — replaces the raw fd map. */ private get logs(); /** Per-run committed scope, cached at first touch — the append-path scope guard must NOT re-read the whole * ledger per append (a journaled result can be MBs; a many-agent workflow would go O(n²) on disk). One replay * discovers it; every later append compares against the cache. */ private get scopes(); /** The swallow-guarded corrupt-read seat (undefined = the old silent tolerance). */ private readonly discloseCorrupt; constructor(root: string, fsyncEnabled?: boolean, opts?: FileWorkflowJournalStoreOptions); private pathFor; /** CC local form: the run's on-disk journal path for the notification diagnostics line. * Traversal-shaped ids (LLM-controlled on some paths) degrade to undefined, never throw. */ locator(runId: string): string | undefined; /** Replay a run's ledger: last-writer-wins per ordinal; a torn/unparsable line is skipped (uncommitted). */ private replay; append(runId: string, scope: string, entry: WorkflowJournalEntry): Promise<void>; load(runId: string, scope: string): Promise<WorkflowJournalEntry[]>; /** * RB-242 — the claim file for `(sourceRunId, scope)`. Keyed by BOTH: a cross-scope caller already * resolves to an empty journal (it cannot fork anything), so letting it share one claim key would only hand * any tenant a denial-of-resume lever over another's runs. The scope is folded in as a digest rather than * a sanitized literal so two distinct scopes can never collapse onto one filename by sanitization. * Throws (via `sanitizePathComponent`) on a traversal-shaped runId — callers degrade, see below. */ private claimPathFor; /** * Read a claim file, KEEPING the exact bytes it was parsed from. A missing / truncated / malformed record * reads as NO claim (takeable). * * RB-356② — the raw bytes are the record's identity token. POSIX has no compare-and-delete, so the only way * this store can check "the file I decided about is still the file on disk" immediately before removing one * is to re-read and compare (see {@link unlinkIfUnchanged}). Since RB-354 a claim is published whole, so * "unparsable" no longer includes "a peer is mid-acquire" — it means external truncation/corruption, which * genuinely is not a live claim. */ private readClaimRaw; /** * RB-356② — remove a claim ONLY if the bytes on disk are still the ones the decision to remove it was made * on. This narrows the decide→delete window to two adjacent syscalls with no user-space work between them, * and everything a peer can publish into that window is published atomically (RB-354), so the re-read sees * either the bytes we judged (safe to unlink) or a peer's complete new claim (we back off and re-contend). * `expectedRaw === undefined` = the file did not parse: there is no live claim to destroy. */ private unlinkIfUnchanged; /** * RB-356③ — reap claims nobody can be holding any more (see {@link RESUME_CLAIM_SWEEP_INTERVAL_MS}). * Best-effort throughout: a sweep failure must never turn an admission into an error. Also drops the * `.tmp` scratch a crash could strand mid-publish, on the same TTL horizon. */ private sweepExpiredClaims; /** * RB-242 — admission for a resume off `sourceRunId`. The LOCAL (TOC) twin of the SQL-backed * `idem_key UNIQUE` + TTL: publishing the claim file is one atomic namespace operation that fails EEXIST * against anyone who got there first, so two processes racing on the same box (the shape the in-process * `startWorkflow` guards cannot see — a shell restart resuming a run a still-live process is already * resuming) produce exactly one winner. */ resumeClaim(input: ResumeClaimArgs): Promise<{ granted: boolean; holder?: string; }>; /** RB-242 — release ONLY a claim this `newRunId` still holds. A claim that is already gone (TTL-reaped * and possibly taken over) is a no-op: unlinking a successor's claim would re-open the very fork this prevents. */ releaseResumeClaim(input: ResumeClaimArgs): Promise<void>; /** GC (service extra, mirrors the SQL twins): drop the run's ledger once the run is terminal + retained. */ deleteByRun(runId: string): Promise<number>; /** Release ledger fds (graceful shutdown; parity with the other file stores' dispose). * Refcounted: the fds are shared with any other instance on the same directory, so the LAST holder * closes them. Idempotent — a second dispose() on the same instance must not decrement twice and pull * the ledger out from under a live peer. */ dispose(): void; }