@sema-agent/core
Version:
Stateless, task-oriented AI agent core
103 lines (102 loc) • 7.74 kB
TypeScript
/**
* The commit-side vocabulary of a durable park: which holder a COMMITTED checkpoint publishes to, the abort-classification
* marker the loop stamps on the contaminated siblings, and the deadline arithmetic every mint shares (the retention
* defaults and the one TTL sanitizer). Moved whole out of the orchestrator so the boundary-parks phase (resource /
* platform / review) and the park-ask phase (the approval park) reach DOWN for the same publication seam and the same
* numbers, and the driver keeps reaching down for the marker its harness stamps. Pure: no engine state; the only clock
* is the caller's.
*/
import type { CheckpointGate, CheckpointToken, PendingAction } from "../checkpoint-store.js";
import type { WorkspaceHandle } from "../remote-env.js";
import type { Prepared } from "./contracts.js";
/** Default resource-checkpoint retention (30 days, the spec's
* `RESOURCE_SUSPEND_TTL_SEC`). Stamped onto the checkpoint `deadline` so the durable reaper can GC an
* ORPHANED resource suspend; a legitimate auto-resume fires long before this. Override via
* `TaskSpec.resourceSuspend.ttlMs`. */
export declare const DEFAULT_RESOURCE_TTL_MS: number;
/**
* How long AFTER a governance window frees a `usage_window` checkpoint stays reapable-free.
* The retention TTL answers "was this abandoned?"; a usage-window suspend is not abandoned while the window
* it waits on is still full, so its deadline is pushed to `retryAfterMs + this`. One hour gives a host's
* scheduler a realistic chance to pick the resume up (a cron tick, a queue drain) before the row is
* treated as garbage — small next to the 30-day retention it is compared against, and large next to any
* polling interval a driver would sanely use.
*/
export declare const USAGE_WINDOW_REAP_MARGIN_MS: number;
/** Abandonment TTL stamped on an UNATTENDED human/irreversible_ask suspend (no
* `durableApproval` = no operator/resolver wired). Without it the checkpoint has `deadline=undefined` and the
* reaper (which filters on a non-null deadline) can NEVER GC it → a pinned session + a stranded paused VM +
* an orphan checkpoint leak forever. A `durableApproval` suspend keeps the operator's `ttlMs` (undefined =
* unbounded by the operator's choice; D-D `terminalAt` is the eventual liveness backstop there). 30 days. */
export declare const DEFAULT_UNATTENDED_APPROVAL_TTL_MS: number;
/**
* A caller-supplied TTL is only a TTL if it is a finite, positive number.
* `TaskSpec.durableApproval.ttlMs` / `resourceSuspend.ttlMs` are typed as a bare `number`, and the mint
* arithmetic was `Date.now() + ttlMs` with NO validation — so `Number(process.env.APPROVAL_TTL_MS)` on an
* unset/malformed env var (a config-center miss, an upstream arithmetic slip) silently produced
* `deadline = NaN`. Every reaper comparison is `deadline <= cutoff`, and `NaN <= anything` is FALSE, so a
* checkpoint that LOOKS bounded (it carries a ttlMs and a deadline) became permanently IMMORTAL: no reap
* cutoff, however far in the future, could ever expire it → a pinned session + a stranded paused VM, and
* a misconfigured deployment silently defeating its own abandoned-approval cleanup with no error anywhere.
*
* Handling follows the codebase's established treatment of malformed numeric config — sanitize to the
* documented default rather than throw — `task-registry-shared.ts`'s `TASK_OUTPUT_DEFAULT_CHARS` fallbacks
* (non-numeric / non-finite / non-positive all return the documented default) and `media-byte-cap.ts`'s
* `!Number.isFinite(limitBytes) || limitBytes <= 0` guard are the two live precedents: garbage in a bound
* field must never widen the bound, and
* killing an in-flight task over it would cost more than it saves. `undefined` (the deliberate
* "unbounded, D-D terminalAt is the backstop" choice) is preserved as-is and is NOT what this rejects.
*/
export declare function sanitizedTtlMs(ttlMs: number | undefined): number | undefined;
/**
* WHICH tool call a committed durable park is holding this run — `undefined` when nothing parked, or
* when the park that did commit holds no call (a resource slice, a plan review).
*
* SINGLE derivation on purpose. Two consumers need this answer: the abort-classification seam, which
* stamps it into the `details` of the results the loop mints for the contaminated siblings, and the
* `tool_end` projection, which puts it on the wire. Reading it from one function keeps the two from
* disagreeing about what a pause with no call means. They are not otherwise
* interchangeable: the frame face applies strictly narrower conditions on top of this answer (see
* `tool_end.gatedCallId`), so a frame may omit an id this function returns — never the reverse.
* "Which holder wins" is no longer a question: there is one holder, and a pause that holds no call
* publishes absence.
*
* The frame side must not read the id back out of a tool RESULT even though the marker is there: a
* result's `details` is written by the tool (and replaceable by post-tool hooks), so lifting a
* cross-call attribution from it would let any failing tool name an arbitrary call and put a phantom
* approval wait on someone else's frame. Same rule, same reason, as `tool_end.gate`.
*
*/
export declare function gatedCallIdOf(p: Pick<Prepared, "pausedRef">): string | undefined;
/**
* The abort-classification marker for the loop's short-circuit "Operation aborted" family: when a gate
* committed a durable park this run (`pausedRef.current`, whatever the pause's kind), the contaminated
* sibling frames carry something a consumer can key
* on instead of parsing the (contract-stable) result text (ruled 2026-08-04). The commit is published
* BEFORE the abort fires, so this read is race-free at mint time.
*
* `gatedCallId` NAMES the call the gate is holding, so a consumer can attribute a contaminated frame to
* its cause instead of inferring the pair from batch adjacency. Absent, never approximated: a park that
* holds no tool call (a resource slice, a plan review) publishes no id, and this marker does not invent
* one from whatever call happens to be in flight.
*
* Module-level rather than a closure in `prepareTask` (extract, don't accrete) — the
* marker's shape and its rules belong beside {@link gatedCallIdOf}, the derivation it reads.
*/
export declare function parkContaminationMarker(refs: Pick<Prepared, "pausedRef">): Record<string, unknown> | undefined;
/**
* THE COMMIT-SIDE PUBLICATION. Called AFTER `commitSuspendSaga` returns true (the checkpoint is durably
* committed) to publish the run's one committed pause for the run loop's terminal assembly. There is no
* routing: the cause carries the committed gate whole, and WHICH terminal status it reads
* (`suspended` / `needs_review`) is the gate kind's registry row — the two holders the review family and
* the approval family used to be split across, and the branch that chose between them, are gone.
* (Review checkpoints themselves are minted by a PROFILE, out of core scope; core owns this seam and the
* gate/status/resume discriminants.)
*
* Module-level, taking the holder as a parameter (extract, don't accrete) — beside the
* two readers of what it publishes ({@link gatedCallIdOf}, {@link parkContaminationMarker}).
*/
export declare function publishCommittedSuspend(refs: Pick<Prepared, "pausedRef">, token: CheckpointToken, gate: CheckpointGate, scope: string, remoteHandle: WorkspaceHandle | undefined, checkpointId?: string,
/** The COMMITTED row's pendingAction — passed whole, not as a pre-extracted id, so no call site can
* hand this seam an id the committed row does not actually park on. */
pendingAction?: PendingAction): void;