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