@sema-agent/core
Version:
Stateless, task-oriented AI agent core
96 lines (95 loc) • 6.53 kB
TypeScript
/**
* Host-callback isolation, one primitive.
*
* The invariant "a callback the HOST handed us must never take the engine down" was implemented by hand at
* ~20 sites across 10 modules (observer taps, cascade/verify observability hooks, roster eviction sinks, the
* retain ledger's eviction reporter, task-registry reap/quiescence/terminal notifiers, compaction observers,
* the workflow event sink, the tool gate's deny observers). Every one of them was a correct swallow, but the
* DISCLOSURE grade differed per site: the retain ledger counted its failures and offered them on a dedicated
* channel, while the cascade and the roster store swallowed in total silence — so a deployment whose observer
* is broken had no way to find out, and WHICH sites would tell it was a matter of which module it happened to
* touch.
*
* This module is the single implementation those sites now share. Semantics:
* · SWALLOW — the callback's throw never reaches the engine's control flow (unchanged at every site);
* · COUNT — every failure is counted per site, readable for diagnostics ({@link SafeNotifier.failuresAt});
* · BOUNDED DISCLOSURE — the FIRST failure of each site is offered to an optional `onError`, later ones are
* counted only. A hot path whose sink fails on every call therefore reports once, never floods.
*
* WIRING STATUS (RB-473, batches 1/2a/2b landed 2026-08-02): the disclosure channel is now WIRED at the
* owners a deployment can reach — TaskRegistry offers `notifierFailureCounts()` (read face; it is
* constructed before any deployment sink exists), the delegation tool routes into `opts.onObserverError`
* (site attached), auto-compaction / cascade / verify / roster-store take an `onNotifyError` option, the
* tool gate surfaces failures as `observer.notify_failed` trace events, and the workflow options + the
* SendMessage resume face take `onNotifyError`. Remaining unwired scopes (retain ledger `failuresAt`
* read-face only, observer taps, misc subagent internals) disclose via counts.
*
* State discipline: no module-level mutable state. Counters live on the notifier instance the owner created
* (per class instance, or per call for the run-scoped functions), so two runs — and two tests — can never see
* each other's counts.
*
* WHY A THUNK and not `(fn, ...args)`: almost every site reads its callback off a host-owned object
* (`config.onRung?.(info)`, `opts.onEvicted?.(e)`). `?.()` on a property calls it with THAT OBJECT as the
* receiver, so a host that passed a method using `this` works today. Handing the extracted function value to
* a helper would silently re-bind the receiver to `undefined` — an invisible behavior change at ~20 sites.
* The thunk keeps the call expression (and its `?.`) at the site, byte-for-byte what it was.
*
* Scope note (deliberate parity): a callback typed `=> void` that instead returns a REJECTING promise is not
* contained by {@link SafeNotifier.notify} — it is synchronous and never touches the return value, exactly
* like the hand-written try/catch it replaced. Sites that genuinely await their callback use
* {@link SafeNotifier.notifyAsync}.
*/
/** One contained failure, as offered to {@link SafeNotifierOptions.onError}. */
export interface SafeNotifyFailure {
/** The owner-chosen site name (e.g. `"cascade.onRung"`) — a stable literal, never derived from host input. */
readonly site: string;
/** The thrown value, normalized to an `Error`. */
readonly error: Error;
/** This site's cumulative failure count including this one (always 1 for a disclosed failure). */
readonly failures: number;
}
export interface SafeNotifierOptions {
/**
* Optional disclosure channel: called with the FIRST failure of each site (bounded — later failures of the
* same site are counted only). A throw from `onError` itself is swallowed: the disclosure channel is host
* code too, and it must not become a second fault source.
*/
readonly onError?: (failure: SafeNotifyFailure) => void;
}
/** The sentence of a contained failure, for a consumer that puts it in front of a person. Total by the
* same rule as {@link containedError}: an error object handed in from elsewhere (a `lastErrorAt`
* reading, a rejection an adapter wrapped) may still carry a hostile `message`, and the disclosure a
* site composes from it must not be the thing that faults. */
export declare function safeErrorMessage(error: unknown): string;
export interface SafeNotifier {
/**
* Run one host-callback invocation, containing any throw. Returns `true` when it completed, `false` when it
* threw. The thunk normally holds the original optional call (`() => config.onRung?.(info)`), so an absent
* callback is a plain no-op that never touches the failure bookkeeping.
*/
notify(run: () => void, site: string): boolean;
/** {@link notify} for a site that AWAITS its callback — contains a rejection as well as a synchronous throw. */
notifyAsync(run: () => unknown, site: string): Promise<boolean>;
/** Total contained failures across all sites of this notifier. */
readonly failures: number;
/** Contained failures at one site (0 when that site never failed). */
failuresAt(site: string): number;
/** The most recent contained error at one site, if any. */
lastErrorAt(site: string): Error | undefined;
/** The sites that have failed at least once (diagnostics; empty on the healthy path). */
failedSites(): readonly string[];
}
/**
* Create an isolation scope for host callbacks. Cheap: the failure bookkeeping is allocated lazily, so a
* notifier whose callbacks never throw costs one object and nothing per call beyond the `try`.
*/
export declare function createSafeNotifier(opts?: SafeNotifierOptions): SafeNotifier;
/**
* Observe a `=> void` host callback's RETURN value for the async dialect: TypeScript accepts an
* `async` function at a void seat, and a rejected one would escape {@link SafeNotifier.notify}
* (which deliberately ignores return values — see the scope note above) as an unhandled rejection.
* Call this with the callback's return value INSIDE the notify thunk: a thenable's rejection is
* routed back through the same notifier/site, so async and sync failures share one count and one
* bounded disclosure, and neither can fault the engine's control flow.
*/
export declare function observeThenableRejection(r: unknown, notifier: SafeNotifier, site: string): void;