UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

103 lines (102 loc) • 7.74 kB
/** * 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;