@sema-agent/core
Version:
Stateless, task-oriented AI agent core
984 lines • 70.7 kB
TypeScript
import type { ExecutionEnv } from "../internal/harness.js";
import type { BackgroundShellCapability, BackgroundShellId } from "./background-shell.js";
import { type WorkflowRunStore } from "./workflow-run-store.js";
import type { BackgroundAgentRecord, BackgroundAgentStore } from "./background-agent-store.js";
import type { WorkflowHandle, WorkflowRun } from "../orchestration/workflow.js";
import type { TaskNotificationPayload } from "./task-notification.js";
import type { ToolResultStore } from "./tool-result-store.js";
import type { AssertAllKeysHandled } from "./ask-origin.js";
export type SemaTaskType = "background_bash" | "workflow" | "background_agent" | "monitor";
/** design/134 §3.3 (R3): WHO initiated a kill — recorded at the initiation site (first-marker-wins),
* because the settle-side then/catch context has already lost it. OPEN ENUM on the wire, now in the
* TYPE too (`(string & {})` keeps literal completion while admitting future values like "timeout"
* without a breaking change): consumers must tolerate unknown values and fall back to default copy.
* SINGLE SOURCE of the shape — two wire mirrors are INLINED to avoid import cycles (this module sits
* above both) and must stay in sync: types.ts `BackgroundChildEvent.stoppedBy` and
* task-notification.ts `TaskNotificationPayload.stoppedBy`. */
export type StopSource = "user" | "parent" | "system" | (string & {});
/** design/153 §7.1: `"parked"` (background agents only today) = durably suspended on a pending
* approval checkpoint — neither live nor terminal. Every `=== "running"` liveness check treats it
* as not-running (correct: no process exists); it exits poll waits, refuses settles, and is
* reap-exempt. Downstream switches over this union gain an arm (announced). */
export type SemaTaskStatus = "pending" | "running" | "parked" | "completed" | "failed" | "killed" | "cancelled";
export type TaskRetrievalStatus = "success" | "not_ready" | "timeout";
export interface UnifiedTaskOutput {
task_id: string;
/** `"unknown"` is a DECLARED member here, not a stray literal. A
* not-found lookup has no task kind to report, and the TaskStop envelope's `task_type` is required by
* the client schema — so the wrapper was already emitting `"unknown"` while the type said it could
* only be a `SemaTaskType`. Declaring it keeps the two honest with each other, and keeps `SemaTaskType`
* itself clean for every consumer that switches over real task kinds. */
type?: SemaTaskType | "unknown";
status?: SemaTaskStatus | string;
retrieval_status: TaskRetrievalStatus;
content?: string;
error?: string;
/** Machine twin of {@link error}, same token, present exactly where `error` names a coded refusal
* (`not_local`/`parked_pending_approval`/`park_resume_won`/`park_arbiter_unreachable`): the wire
* errorCode lift reads `details.code`, and an error-only refusal is unclassifiable on tool_end
* frames. Distinct from {@link errorCode}, which carries a FAILED child's own result taxonomy —
* this key classifies THIS poll/stop answer itself. Additive. */
code?: string;
/** RB-386② — machine-readable failure code on a FAILED background_agent row's poll
* details (the child's TaskResult.errorCode taxonomy — brain codes / `limit.*` / `budget.*` …).
* Additive; absent on non-failed rows, on rows whose failure carried no code, and on other lanes.
* The same key also carries a failed WORKFLOW run's `WorkflowRun.errorCode` (
* `workflow.*` for the orchestration's own refusals / a failed child's terminal code), populated
* by `formatWorkflowRun`: one field name for "why this poll answer is a failure", whichever lane
* minted the failure. */
errorCode?: string;
/** RB-386② — retryability verdict for a FAILED background_agent row, derived at settle time by the
* same classifier the sync sub-agent report's `error_kind (retryable: …)` line uses. Additive. */
retryable?: boolean;
/** design/134 §3.3: only present when `status === "killed"` (open enum — see {@link StopSource}). */
stoppedBy?: StopSource;
/** server[1526] ask — the durable row's committed stop-cycle number (1.371 settle mint), served on
* durable-fallback polls so a tail consumer can key revive cycles without the notify lane.
* ALSO served by the in-process live poll/stop arms (the handle's current cycle
* projection — spawn = 1, each 1.332/tier-3 revival opens the next), same key, same meaning,
* so tail meta is complete whichever arm answers. */
seq?: number;
/** fidelity-sweep A1: the served result is the child's PARTIAL work — it landed after a kill had
* already won the terminal race and was backfilled (produced before/while the task was stopped). */
partial_result?: boolean;
/** A cross-channel
* correlation key for ONE completion event. The same task's completion fans out across several
* independent channels today (a live `TaskNotificationPayload` frame, a durable inbox entry, and
* this poll projection) — a consumer today has to reconstruct "are these the same completion?" from
* weaker signals (task_id + status + timing). Minted ONCE per completion, fill-once (first writer
* wins) on the handle/record the completion is settled onto, so every read face — this poll
* projection, the notification payload, and (for background_agent) the cross-restart durable
* fallback row — serves the exact same value. Open-shape `string` (a uuidv7 in this implementation,
* not contractually required to stay one); absent when the underlying task hasn't reached a terminal
* settle yet, or on a pre-P1-3 core build that never minted one. */
completionId?: string;
/** A background agent's current state (status word, last activity, the approval card a parked row
* waits on) — {@link import("./agent-state.js").readAgentState}, the one reading AgentTranscript and
* ListAgents render too. Present on every background_agent POLL answer that resolved a row (live,
* durable terminal, durable parked, durable still-running); absent on other lanes, on not-found, and on
* the stop face. */
agentState?: import("./agent-state.js").AgentState;
details?: unknown;
}
/**
* The shared internal poll/stop result shape every helper below returns
* (`noTask`/`noTaskForStop`/`formatWorkflowRun`/`serveDurableAgentRow`/`pollTask`/`stopTask` and every
* per-task-kind poll/stop arm). `UnifiedTaskOutput` (the `details` face) has no isError channel of its
* own — it is a REQUIRED-field wire shape consumed by callers beyond these two tools too, so isError is
* carried as a sibling on the WRAPPER instead (trap avoidance per the E1 sweep: never carry across isError
* through a narrower helper contract). Optional, absent/false = success — `createTaskOutputTool`/
* `createTaskStopTool` forward it onto the real `ToolReturn` at the tool boundary. Every internal helper
* keeps constructing `content`/`details` exactly as before; this only adds a place to say "and this one
* is a genuine failure" where the failure is actually detected.
*/
export type UnifiedTaskResult = {
content: string;
details: UnifiedTaskOutput;
isError?: boolean;
};
export interface SemaTaskHandle {
id: string;
type: SemaTaskType;
status: SemaTaskStatus;
description?: string;
owner?: string;
scope?: string;
toolUseId?: string;
createdAt: number;
updatedAt: number;
outputFile?: string;
/** P1-3 — see {@link UnifiedTaskOutput.completionId}. Minted fill-once at this task's own settle
* point ({@link mintCompletionId}); absent until the task reaches a terminal status. */
completionId?: string;
}
/** Mint a cross-channel completion correlation id ONCE per
* handle/record, fill-once (first writer wins): every settle/direct-terminal-flip site calls this
* instead of writing `completionId` directly, so no matter how many terminal-transition sites a lane
* has (settle / TaskStop circumvention / backfill), the value is minted exactly once and every later caller
* (including an idempotent re-call from a circumvention path that already ran) gets back the SAME id. `string`
* is intentionally loose (any object with an optional `completionId` field) so the same helper serves
* both a `RegisteredTaskHandle` and a `WorkflowRun` (orchestration/workflow.ts — a different module,
* no shared base type, same fill-once shape). */
export declare function mintCompletionId(target: {
completionId?: string;
}): string;
export interface TaskAccess {
owner?: string;
scope?: string;
/** design/129: the caller's SESSION id — matches session-scoped handles across turns (a fresh per-turn
* spec.taskId changes `owner`, but the session is the stable key a background child outlives turns under). */
sessionId?: string;
}
export interface BackgroundBashTaskHandle extends SemaTaskHandle {
type: "background_bash";
env: ExecutionEnv & BackgroundShellCapability;
shellId: BackgroundShellId;
/** (design/129 lifetime discipline, CC parity): a background bash spawned inside a SESSION
* outlives the run — CC's bg shells survive the agent turn (TB live evidence in settleKilledForOwner's
* T1-1 note) and die at the SESSION terminal. When set, owner MUST be the sessionId: skipped by the
* run-teardown settle/evict, kept alive through the dispose except-list, reaped by
* {@link TaskRegistry.reapSessionBackground}. Explicit flag only — never inferred from owner shape. */
sessionScoped?: true;
/** Deliver-a-living-service ledger mark — the row's lane of THE retain predicate
* (`retainsBackgroundProcesses`). Set at REGISTRATION when the launching task
* (`TaskSpec.retainBackgroundProcesses`) or the env capability declares retain, and by a run teardown
* whose own spec declares it. In-memory only (shell-lane rows are never persisted); a row without the
* field reads as not marked. The row is the delivered daemon's ONLY management surface — while the process runs it survives the
* per-run evict (TaskOutput/TaskStop resolve through this map; the watcher must stay live to mint
* the natural-exit receipt) and rides every later run's dispose except-list on the same env
* ({@link TaskRegistry.runningShellIds}). A terminal row evicts normally. Terminals: explicit
* TaskStop, the process's own exit (watcher settle), or env destruction (watcher env-death settle)
* — deliberately NO timed reaper (the deliverable is the living process). A retained SESSION-resident
* row is likewise exempt from the session-release reap ({@link TaskRegistry.reapSessionBackground}):
* the delivered daemon outlives its session, with the same three terminals. */
retained?: true;
/** The launcher STATED the task lane at registration (`retainBackgroundProcesses` was passed, true or
* false): the mark above already carries it, so a run teardown's own spec lane does not re-judge this row
* (a later run's declaration must not re-label a process its launch receipt described). Absent ⇒ the
* launcher did not know the lane (a host tool registering directly): the owning run's teardown supplies it. */
retainStatedAtLaunch?: true;
/** design/116 §7 G2b: when a watcher is attached, the registry accumulates the FULL output here (rolling
* bound) — TaskOutput then serves a RE-READABLE snapshot (CC semantics) instead of a consume-once cursor.
* `timedOut`/`timeoutSec` record a SELF-INFLICTED background-timeout kill (the env's per-shell budget
* fired), so later re-readable polls keep rendering it distinguishably from an external stop.
* `mirrorFailed` records that at least one append to the advertised output FILE failed — the terminal
* frame then discloses the file as incomplete instead of letting the launch receipt's "full output is
* appended there" promise stand over a silently partial file. */
/** `droppedBytes`/`dropUnknown` account ENV-SIDE tail-buffer eviction disclosed by
* the poll (`bytesDroppedBeforeCursor`/`truncated`) — a loss upstream of (and distinct from) the
* registry's own `rolledChars` memory bound. Bytes counted here never reached the spool at all. */
spool?: {
stdout: string;
stderr: string;
rolledChars: number;
exitCode?: number;
timedOut?: boolean;
timeoutSec?: number;
mirrorFailed?: true;
droppedBytes?: number;
dropUnknown?: true;
};
watcher?: ReturnType<typeof setInterval>;
/** design/134 §3.3 (R3 MED, 修类不修点): same attribution pair as the agent handle — see there. */
stopSource?: StopSource;
stoppedBy?: StopSource;
/** The terminal-notification sink, HELD ON THE HANDLE (not only inside the watcher closure)
* so the run-teardown killed settle ({@link TaskRegistry.settleKilledForOwner}) can mint the receipt the
* watcher never will — a disposed shell polls as vanished ("nothing to notify about") and an evicted
* handle stops the watcher outright. */
onTerminal?: (notification: TaskNotificationPayload) => void;
/** Terminal-notification once-latch (fidelity R1-3): set atomically right before the FIRST terminal
* notice is sent. A teardown settle and an in-flight watcher tick can both reach their emit point
* (the kill/poll awaits are suspension windows between the status gate and the send) — whichever
* fires first wins; every later producer stays silent. */
terminalNotified?: true;
}
export interface WorkflowTaskHandle extends SemaTaskHandle {
type: "workflow";
runId: string;
handle: WorkflowHandle<unknown>;
store?: WorkflowRunStore;
/** B-1 (live leg) — see {@link RegisterWorkflowInput.originatingSessionId}. */
originatingSessionId?: string;
/** Design/129 session-lifetime discipline, extended to workflow — the
* ONE background kind that never had it (`background_bash`/`monitor`/`background_agent` all do).
* Unlike those three (task-scoped BY DEFAULT, `sessionScoped` an explicit opt-in via
* `TaskSpec.backgroundScope`), a workflow is unconditionally session-anchored — `originatingSessionId`
* is set at every standard mount (`prepare-task.ts`, never gated), and Workflow's whole raison d'être
* (its own guidance text) is "runs asynchronously, notifies you or is polled LATER" — there is no
* sensible "dies with this turn" mode for it to opt out into, unlike a background_agent which
* genuinely has both use cases. Set `true` whenever `originatingSessionId` is present (`registerWorkflow`).
* Read only by {@link TaskRegistry.reapSessionBackground} — the explicit, deployment-invoked session
* terminal anchor (core has no session TTL reaper of its own); NEVER by `Runner.runTask()`'s own
* per-leg teardown, whose finally block runs on every NORMAL leg completion, not just a genuine
* session end — a would-be regression caught by cross-review before this shipped (see docs/
* REVIEW-BACKLOG.md's "RB-215 复核" entry for the full incident). */
sessionScoped?: true;
/** See {@link RegisterWorkflowInput.onServedTerminal}. Cleared after the single fire. */
onServedTerminal?: () => void;
/** The bounded return value, captured on the done-hook so the STORE-LESS in-memory lane's
* terminal poll can hand it over too (the launch note promises it; without a store there was no result). */
result?: string;
/** T2A-8: the failure reason (bounded + redacted), captured on the done-hook's rejection so a store-less
* terminal poll can say WHY it failed (the store lane's formatWorkflowRun already carries run.error). */
error?: string;
}
/** design/151 S1a — the per-row durable write lane. The registry is the row's SINGLE writer; writes
* are serialized on a promise chain (put → bind → settle order preserved), CAS'd with the locally
* held `record.rev`, and BEST-EFFORT (a store outage degrades durability with a visible warning,
* never execution). A lost CAS or a put `already_exists` means a FOREIGN row lives under this
* handle (leftover from a dead process / another writer) — the lane poisons itself loudly instead
* of clobbering (design/151 MED-8: the writer's own rev is the anti-clobber line). */
export interface DurableAgentLane {
store: BackgroundAgentStore;
/** The writer's authoritative local copy (rev included — bumped only on a confirmed CAS win). */
record: BackgroundAgentRecord;
chain: Promise<void>;
/** True once the initial `put` landed (an outage before that retries put on the next write). */
written: boolean;
poisoned: boolean;
/** true while the LATEST flush exhausted its retries (cleared by the next success).
* The lifecycle-flip probe requires it false: a row whose bind/settle never persisted must not
* have its transcript preserved-but-unreachable (the local record accumulates patches, so the
* next successful write self-heals the missing fields). */
flushFailed: boolean;
/** 增量 R-4 — consecutive heartbeat re-drives of a settled flush-failed lane (reset on any
* flush success); past the ceiling the lane is abandoned loudly instead of retrying forever. */
redrives?: number;
}
export interface BackgroundAgentTaskHandle extends SemaTaskHandle {
type: "background_agent";
/** design/115 P3: aborting this cancels the child run (TaskStop / parent teardown). */
abort: AbortController;
/** Lineage axes, declared (registration always spread them into the handle; readers used
* to cast). `rootSessionId` feeds {@link canAccess}'s root-session arm; the other two stay
* read-only lineage metadata on the live face (no parentSessionId access arm — see the arm's
* comment for why). The register side clamps `parentTaskId`. */
parentTaskId?: string;
parentSessionId?: string;
rootSessionId?: string;
/** design/151 S1a — the child's store session id (transcript/resume anchor), bound once the child
* session is minted ({@link TaskRegistry.bindBackgroundAgentSession}). */
sessionId?: string;
/** design/151 S1a — the durable write lane (present iff the registration wired a store). */
durable?: DurableAgentLane;
/** design/147 S2a — the RUNNING child run's notification injector (attached once its lane is
* live, dropped at terminal settle). A parent/sibling SendMessage delivers through this at the
* child's next turn boundary (CC in-memory pendingMessages pedestal); late calls after the run
* ends park per session inside the injector itself (the lane-teardown branch). */
notify?: (notification: import("./task-notification.js").TaskNotificationPayload, opts?: {
priority?: import("./task-notification.js").SystemInjectionPriority;
}) => import("./task-notification.js").NotificationInjectionOutcome | Promise<import("./task-notification.js").NotificationInjectionOutcome>;
/** design/147 S2a — messages that arrived in the STARTUP window (row already
* running, lane not yet bound). Bounded; flushed in order by attachAgentNotify. Discarded at
* terminal settle: a run that failed during prepare never had a lane to park through, and the
* sender's completion notification (failed) prompts the resend. */
/** S2b — park redeemability (see RegisterBackgroundAgentInput.retainedContinuation). */
retainedContinuation?: boolean;
/** S2b RB-27② — revive-cycle stamp (see reviveBackgroundAgent): attach/settle from a resume leg
* carry it so a stale cycle's late calls no-op instead of clobbering the newer cycle. */
reviveCycle?: number;
/** The CURRENT stop-cycle number in settle-generation terms (spawn run = 1; each
* revival — 1.332 retained or tier-3 seeded — opens the next). Served by the LIVE poll/stop
* projection under the SAME `seq` key as the durable-fallback arm, so a tail consumer keys
* revive cycles identically whichever arm answered. Re-aligned to the settle's authoritative
* `outcome.seq` at settle time (the ledger's cycleSeq is the mint; this is its projection). */
cycleSeq?: number;
/** design/153 §7.1 — the approval checkpoint this handle is parked on (`status:"parked"` only). */
parkedCheckpointToken?: string;
/** design/153 §7.1 — in-process TaskStop arbitration closure, minted by the parking watcher
* (captures the checkpoint store + token; the registry itself holds no durable stores — r4 F-06).
* Resolves `true` iff the STOP won the checkpoint CAS (expire landed; resume can no longer
* consume it) — only then may the row flip killed. `false` = a resume consumed the token first.
* Absent (cross-process row, no live park context) ⇒ TaskStop refuses honestly: the durable
* approval inbox (server /decide) owns that arbitration. */
resolveParkedStop?: () => Promise<boolean>;
/** S2b — resolvers of DIRECT (post-attach) deliveries currently awaiting the
* injector; terminal settle/stop sweep these as not_running so a never-settling adapter can
* never pin the sender's tool call. */
inFlightDirect?: Set<(r: {
ok: true;
disposition: "queued" | "parked";
} | {
ok: false;
reason: "not_found" | "not_running" | "no_channel";
}) => void>;
/** S2a F7 — channel lifecycle: undefined = unsupported (spawner never declared), "attaching" =
* declared, lane not yet bound (buffer window), "attached" = live injector present. */
channelState?: "attaching" | "attached";
preAttachQueue?: Array<[
import("./task-notification.js").TaskNotificationPayload,
{
priority?: import("./task-notification.js").SystemInjectionPriority;
} | undefined,
(((r: {
ok: true;
disposition: "queued" | "parked";
} | {
ok: false;
reason: "not_found" | "not_running" | "no_channel";
}) => void) | undefined)?
]>;
/** [c209-D] the EXPLICIT addressing name (CC Agent.name parity, validated at spawn: charset regex +
* "main" reserved). Distinct from `description` (the display/legacy-matching label): the name layer
* resolves latest-wins ("a newer agent took the name"), the description layers keep their
* running-preferred + ambiguous posture. Absent on pre-[c209] rows and unnamed spawns. */
name?: string;
/** design/129: this child OUTLIVES its parent turn — owner is the SESSION id; the parent-teardown reap
* skips it; `reapSessionBackground` is its terminal anchor. Explicit flag, never inferred from key values
* (hostTaskId===sessionId deployments would otherwise be ambiguous). */
sessionScoped?: true;
/** Terminal snapshot (bounded, {@link BG_AGENT_RESULT_MAX} in subagent.ts) — served by TaskOutput
* once the child finishes. */
result?: string;
/** The FULL result (bounded only by the defensive {@link BG_AGENT_RESULT_FULL_MAX} ceiling
* in subagent.ts), set ONLY when it differs from `result` — same shape as {@link WorkflowRun.resultFull}
* (workflow.ts NH-1). Before this field existed, the settle call sites pre-sliced `child.result` to
* ~2-4K chars with no disclosure — clipTaskOutput's own file-pointer/offload-friendly design at the
* poll layer was structurally unreachable because `result` itself could never be long enough to need it. */
resultFull?: string;
/** design/158 S1 (RB-205-B 半) — the tool-result-store ref `resultFull`'s full text was spilled to,
* the FIRST time a poll's `clipTaskOutput` actually cut it (lazy write, not at settle time). Cached
* here so every LATER poll within the SAME cycle reuses the same ref instead of re-writing (the
* store's own `put` is write-once too, but re-deriving/re-calling it every poll would still cost a
* redundant round-trip on a durable backend, and a changing ref across polls would be a needless
* disclosure churn). {@link reviveBackgroundAgentLane} clears this on revive — the cycle's full text
* is gone, so a stale ref must not be reused across cycles. Ref format ties in `reviveCycle` (see
* {@link import("./task-registry-agent.js").spillClippedAgentResult}) precisely so a revived cycle's
* eventual spill lands under a DIFFERENT ref rather than colliding with (and being silently refused
* by the write-once store under) cycle 1's. */
spillRef?: string;
/** Backlog #169 — the spill `put` for the CURRENT revive cycle threw or rejected. One-time latch,
* scoped to the cycle ({@link import("./task-registry-agent.js").reviveBackgroundAgentLane} clears
* it in lockstep with `spillRef`): later polls serve the clipped text with an honest ref-free loss
* note instead of retrying the write — the ref is minted deterministically, so the known permanent
* failure class (the store's write-once/provenance conflict) would fail identically on every poll,
* each retry a guaranteed-failure round trip to a durable backend. The monitor lane's
* `spillFailed` twin (same degrade discipline, different write shape — that one is fire-and-forget,
* this one is awaited). */
spillFailed?: true;
error?: string;
/** RB-386② — the machine-readable failure code beside `error`, threaded from the settle
* mint point (subagent.ts computes it ONCE from the child's TaskResult.errorCode taxonomy /
* extractErrorCode — single source with the sync report's classification). Served on the failed
* row's poll `details` and the failed terminal notification. Absent when no code was known. */
errorCode?: string;
/** RB-386② — retryability verdict for a FAILED row, computed at the settle mint point by the SAME
* classifier the sync report face uses (`classifySubagentError` — error_kind ∈ rate_limit/
* overloaded/timeout/network ⇒ true, logic ⇒ false). Stored rather than re-derived so the core
* poll lane never has to import the agents-domain classifier (no new domain cycle). */
errorRetryable?: boolean;
/** The coarse classification KIND beside `errorRetryable`
* (classifySubagentError's error_kind ∈ rate_limit/overloaded/timeout/network/logic), threaded
* from the same settle mint point and stored for the same no-domain-cycle reason as its sibling.
* Rendered on the FAILED row's poll TEXT face as the `(error_kind: …, retryable: …)` clause —
* the same field names the sync report's error_kind line already speaks. */
errorKind?: string;
/** Ruled 2026-08-05 (governance kind follow-up): the wait hint paired with a deferred-retryable
* failure (`usage.*` codes — errorKind `"governance"`), threaded from the same settle mint point
* as its three siblings. Without it the background faces exposed `retryable:false` with no way to
* know WHEN the refusal lifts — the sync report card carried `retryAfterMs` while the bg poll/
* notification faces structurally lost it. */
errorRetryAfterMs?: number;
/** fidelity-sweep A1: `result` arrived AFTER a kill won the status race — it is the child's partial
* work (produced before/while being stopped), backfilled by the refused settle. Poll frames label it. */
resultIsPartial?: boolean;
/** design/134 §3.3 (R3): the PENDING attribution marker, written by {@link TaskRegistry.markStopSource}
* at the kill INITIATION site, strictly BEFORE the abort/status flip (once the handle leaves "running"
* the marker is refused and attribution falls back to "system"). First-marker-wins. */
stopSource?: StopSource;
/** The LANDED attribution — set exactly when the handle goes `killed`, never rewritten afterwards
* (first-writer-wins, same discipline as the terminal status itself). Absent on natural completion/failure. */
stoppedBy?: StopSource;
/** RB-375 A1 — the reap-time terminal notifier, hung by the spawn lanes (subagent.ts) right after
* registration: composes and sends the SAME terminal faces (observer sink + parent notification)
* the child's own unwind would, so {@link import("./task-registry.js").TaskRegistry.reapSessionBackground}
* can emit the terminal news AT REAP TIME instead of waiting on the child's unwind (which blocks on
* the in-flight tool actually honoring the abort — unbounded). The bash handle's `onTerminal` twin.
* Cleared at the accepted settle (with `notify`); invoked at most once via {@link terminalNotified}. */
onReapTerminal?: () => void;
/** RB-375 A1 — terminal-notification once-latch (the bash/monitor `terminalNotified` twin): claimed
* by whichever side reaches its emit point first — the session-release reap sweep (synchronous send)
* or the child's own unwind (`TaskRegistry.claimAgentTerminalNotify`). The loser stays silent, so a
* reaped child never double-announces. Cleared on revive (a new stop cycle owns its own terminal). */
terminalNotified?: true;
}
/** design/135 G2 (Monitor): injectable timer/clock seam for the monitor watcher — the 200ms batch window
* and the timeout/storm checks must be drivable by a test without real sleeps (design/87 discipline).
* `setInterval` receives an ASYNC tick (the default adapter fires it void; a fake can await it). */
export interface MonitorTimers {
setInterval: (fn: () => unknown, ms: number) => unknown;
clearInterval: (handle: unknown) => void;
now: () => number;
}
export declare const defaultMonitorTimers: MonitorTimers;
/** design/135 G2: Monitor watch — background_bash's near kin, but the watcher streams stdout LINES as
* batched event notifications instead of firing one terminal notify. Spool stays re-readable (TaskOutput). */
export interface MonitorTaskHandle extends SemaTaskHandle {
type: "monitor";
env: ExecutionEnv & BackgroundShellCapability;
shellId: BackgroundShellId;
/** RB-238/RB-239/RB-240 spool honesty fields: `droppedBytes`/`dropUnknown` = env-side
* tail-buffer eviction (see the bash handle's twin); `dropNotified` = the once-latch for the event-stream
* disclosure (terminal frames disclose unconditionally); `drainFailed` = the stop-time final drain poll
* failed, so the spool may be missing the last window; `timedOut`/`timeoutSec` = the env's own
* background time budget killed the process (bash-lane parity — renders distinguishably from a stop). */
spool: {
stdout: string;
stderr: string;
rolledChars: number;
exitCode?: number;
droppedBytes?: number;
dropUnknown?: true;
dropNotified?: true;
drainFailed?: true;
timedOut?: boolean;
timeoutSec?: number;
};
/** Partial (newline-less) stdout tail awaiting completion — a line becomes an event only once terminated. */
lineBuf: string;
/** Per-batch monotonic counter → unique notification dedup keys (see task-notification.ts). */
seq: number;
/** The TWO-LEVEL event-storm control's state.
* Level 1 = a token bucket (`stormTokens` out of `stormBurst`, refilled one per
* `stormRefillIntervalMs`): a batch that cannot consume a token is SUPPRESSED (its lines stay in the
* re-readable spool) and counted in `suppressedBatches`; the next batch that DOES consume one first
* emits the disclosure frame naming that count. Level 2 = kill, and only for a monitor that stayed
* continuously over budget since `overloadSince` for MONITOR_STORM_KILL_AFTER_MS.
* `lastSuppressedAt` clears `overloadSince` once the pressure is old enough (CC's `OCo * 3` rule) —
* a short burst every few minutes must never accumulate into a kill. */
stormTokens: number;
stormBurst: number;
stormRefillIntervalMs: number;
stormRefilledAt: number;
suppressedBatches?: number;
overloadSince?: number;
lastSuppressedAt?: number;
batchWindowMs: number;
maxBatchesPerMinute: number;
/** Session-level residency (design/129 sessionScoped semantics): reaped by `reapSessionBackground`,
* skipped by the per-run `clearBackgroundForOwner`, and NEVER timeout-killed by the watcher. */
persistent?: true;
sessionScoped?: true;
/** Deliver-a-living-service ledger mark — same semantics as the bash handle's `retained` (see there). */
retained?: true;
/** Same as the bash handle's `retainStatedAtLaunch` (see there). */
retainStatedAtLaunch?: true;
/** Non-persistent only: watcher-enforced hard deadline (epoch ms on the injected clock).
* (CC parity): this deadline — not the parent run — is the watch's lifetime anchor.
* A monitor's life = min(timeout, process exit, TaskStop); a persistent one is SESSION-anchored.
* NEITHER is run-anchored: the run teardown leaves an in-window watch alive (settle/evict/dispose
* all skip it) so between-turns events keep flowing through the pending lane. */
deadlineAt?: number;
timeoutMs?: number;
watcher?: unknown;
/** A watcher tick is suspended on its pollBackground await RIGHT NOW. The stop-time
* final drain must skip its own poll then (that in-flight tick already consumed the env cursor — and a
* never-resolving adapter must not pin TaskStop). Set/cleared in lockstep with the tick's re-entrancy
* guard. */
tickInFlight?: true;
timers: MonitorTimers;
onEvent?: (n: TaskNotificationPayload) => void;
stopSource?: StopSource;
stoppedBy?: StopSource;
/** Terminal-notification once-latch — same discipline as the bash handle (fidelity R1-3). Only the
* TERMINAL frame is latched; "event" frames are unaffected. */
terminalNotified?: true;
/** design/158 S2 (RB-364 半) — the offload store a ROLLED-OFF spool middle spills to, captured at
* REGISTRATION time (RegisterMonitorInput's additive field), not per-poll like the background_agent
* lane's `TaskPollOptions.toolResultStore` (S1): the roll happens inside the watcher tick — an async
* timer callback with no per-call "access" to thread a store through — so registration is the only
* point that has one to hang on the handle. Absent ⇒ legal degrade: `rollSpoolText`'s `onDrop` hook
* is simply never wired, byte-identical to pre-S2 rolling (design/158 §2.2). */
toolResultStore?: ToolResultStore;
/** design/158 S2 — the session id spilled refs are scoped under (same `TaskAccess.sessionId` role as
* every other `buildToolResultRef` call site); captured at registration for the same reason as
* `toolResultStore` above. */
spillSessionId?: string;
/** design/158 S2 — next segment index to mint for the STDOUT dropped-middle ref chain (0-based;
* design/158 §3 "方案 a": a ref PER dropped segment, chained by index, rather than one ref with an
* append primitive — ToolResultStore's contract is write-once with no append). stdout and stderr get
* INDEPENDENT counters (this field vs `spillErrSegCount`) so each stream's own segments concatenate
* back in original byte order without interleaving the other stream's drops between them. */
spillSegCount?: number;
/** design/158 S2 — the STDERR twin of {@link spillSegCount}. */
spillErrSegCount?: number;
/** design/158 S2 — cumulative chars (UTF-16 code units, `string.length`) handed to `store.put`
* across every segment this handle has spilled so far (both streams combined) — the per-handle
* disk-cap accounting ({@link import("./task-registry-monitor.js")} reads `MONITOR_SPILL_CAP_CHARS`
* against this). The field used to be named `spillBytesUsed` while the ledger counted
* chars — renamed to the axis it really measures (see the constant's own doc for why chars). */
spillCharsUsed?: number;
/** design/158 S2 — the per-handle spill cap (`MONITOR_SPILL_CAP_CHARS`) was reached; further
* rolled-off middles are no longer spilled (already-written segments stay readable — "earlier
* segments retained"). One-time latch; the poll disclosure reads it to stop claiming full ref
* coverage. */
spillCapped?: true;
/** design/158 S2 — at least one `store.put` for this handle rejected or threw. One-time latch; the
* poll disclosure downgrades its wording once this is set (never claims the ref chain is complete —
* the fire-and-forget write may have silently failed; see task-registry-monitor.ts's spill helper). */
spillFailed?: true;
}
export type RegisteredTaskHandle = BackgroundBashTaskHandle | WorkflowTaskHandle | BackgroundAgentTaskHandle | MonitorTaskHandle;
export interface RegisterMonitorInput extends TaskAccess {
shellId: BackgroundShellId;
env: ExecutionEnv & BackgroundShellCapability;
description?: string;
toolUseId?: string;
/** Session-level residency: no watcher timeout; reaped by session release (design/129 semantics —
* when set, `owner` MUST be the session id and `sessionScoped` should be set too). */
persistent?: boolean;
/** design/129 explicit-flag discipline: session-scoped access matching + session-terminal reap. */
sessionScoped?: true;
/** Non-persistent watch deadline (ms). The tool clamps to [1, 3_600_000], default 300_000. */
timeoutMs?: number;
/** The event sink — each stdout-line batch AND the single terminal state fires exactly one payload. */
onEvent?: (n: TaskNotificationPayload) => void;
/** design/87: injectable timers/clock (tests drive ticks + time manually; default = real timers). */
timers?: MonitorTimers;
/** Batch window (ms, default 200): lines arriving within one tick coalesce into one notification. */
batchWindowMs?: number;
/** The LAUNCHING task's retain declaration (`TaskSpec.retainBackgroundProcesses`) — the task lane of
* THE retain predicate; stamped onto the row's `retained` mark at registration, and passing it states the
* lane, same as the bash lane (see `RegisterBackgroundBashInput.retainBackgroundProcesses`). */
retainBackgroundProcesses?: boolean;
/** The SUSTAINED batch rate the storm bucket refills at (default 50/min). Exceeding it is
* suppression, not death — see `stormBurst` and MONITOR_STORM_KILL_AFTER_MS. */
maxBatchesPerMinute?: number;
/** Burst capacity of the storm bucket (default MONITOR_STORM_BURST = CC's 10). */
stormBurst?: number;
now?: number;
/** design/158 S2 (RB-364 半) — the offload store a rolled-off spool middle spills to (see
* {@link MonitorTaskHandle.toolResultStore}). Absent ⇒ legal degrade, byte-identical rolling. */
toolResultStore?: ToolResultStore;
}
export interface RegisterWorkflowInput extends TaskAccess {
runId: string;
handle: WorkflowHandle<unknown>;
store?: WorkflowRunStore;
description?: string;
toolUseId?: string;
now?: number;
/** B-1 (live leg): the ORIGINATING session id — anchors the in-process handle to its session
* so a later turn of the SAME session (fresh per-turn owner) reaches it through `canAccess`'s
* workflow session arm, mirroring the durable predicate. A workflow is a SESSION-level asset
* (its completion notification routes to the session) — deliberately unlike a background agent's
* live handle, whose sibling-turn invisibility is a channel-discipline ruling (§3.1c). */
originatingSessionId?: string;
/**
* Fired AT MOST ONCE when a `pollTask` through THIS registry entry (= the originating
* session's own lane; cross-session store-fallback polls don't fire it) serves a TERMINAL snapshot.
* The RunWorkflow tool wires it to {@link import("../orchestration/run-workflow-tool.js").WorkflowCompletionNotifier.ackServed}
* so the deployment can drop the now-redundant completion-inbox entry. Errors are swallowed.
*/
onServedTerminal?: () => void;
}
/** T2A-7 (2026-07-05 soak): one blocked-poll step, CLAMPED to the caller's deadline — a small timeoutMs
* (e.g. 10ms) must not sleep the full 400ms step (~40× overshoot, and the overshoot window even served a
* late terminal as success). `undefined` deadline keeps the plain step (callers without a deadline). */
export declare const sleepPollStep: (deadline: number | undefined, signal?: AbortSignal) => Promise<void>;
export declare const sleepMs: (ms: number, signal?: AbortSignal) => Promise<void>;
/** design/135 G2 Monitor defaults (CC schema: 200ms batch window; timeout default 5min / max 60min).
* `MONITOR_MAX_BATCHES_PER_MINUTE` = the SUSTAINED batch rate the storm bucket refills at
* (RB-334: CC `TIs(bIs=10, OCo=2000)` 220:403284/403421-403423 refills 1 token per 2000ms = 30/min;
* sema keeps its own 50/min figure — the sema batch window is the same 200ms, so the two are the same
* order of magnitude — and derives the refill interval from it, see `stormRefillIntervalMs`). */
export declare const MONITOR_BATCH_WINDOW_MS = 200;
export declare const MONITOR_DEFAULT_TIMEOUT_MS = 300000;
export declare const MONITOR_MAX_TIMEOUT_MS = 3600000;
export declare const MONITOR_MAX_BATCHES_PER_MINUTE = 50;
/** Burst capacity of the storm token bucket — how many batches may fire
* back-to-back before the SUPPRESSION level engages. CC value, adopted verbatim. */
export declare const MONITOR_STORM_BURST = 10;
/** How long a monitor must stay CONTINUOUSLY over budget before the
* second level (kill) fires. CC value, adopted verbatim. */
export declare const MONITOR_STORM_KILL_AFTER_MS = 30000;
/** The partial-line accumulator bound. sema's `lineBuf` had NO bound —
* a monitored process emitting megabytes without a single newline grew it without limit (the spool's
* own roll never covered it). Keep the TAIL (the newest bytes are the ones a watch is about). */
export declare const MONITOR_LINE_BUF_CAP = 1048576;
/** The monitor event-result clip, shared by the watcher's batch/terminal emits and
* the stop-time drain emit (one clip, one truncation wording). */
export declare function clipMonitorEvent(s: string): string;
/** Per-line clip applied to the `lines[]` a batch carries. Spool untouched. */
export declare function clipMonitorLine(s: string): string;
/**
* The ONE terminal-notification summary
* HEAD for a background command / monitor watch. Every terminal frame in the bash and monitor lanes
* composes its summary as `terminalTaskSummary(...)` plus this lane's own honest detail clause — the
* pre-RB-335 code had six hand-written phrasings whose comment claimed "no CC sample for those faces"
* while 220 carries a verbatim sample for every one of them (the `failed` branch was the concrete
* mismatch: sema said `failed (exit code N)`, CC says `failed with exit code N`).
*/
export declare function terminalTaskSummary(kind: "bash" | "monitor", label: string, status: "completed" | "failed" | "killed", exitCode?: number): string;
export declare const TASK_OUTPUT_MAX_CHARS = 160000;
/**
* CC `FHl` shape (pretty.js:458012-458016) when the task has an on-disk output file: TAIL-keep (a
* background command's latest output is the valuable end) behind a `[Truncated. Full output: <path>]`
* pointer so the model can Read the full spool. Without a file (workflow/agent results), keep the
* legacy head+tail middle-omission — there is no path to point at, and the head often carries status.
* RB-198 F4: the clip arithmetic itself moved to the shared {@link clipWithFilePointer} (a second caller,
* the foreground Bash tool, needed the SAME already-hardened RB-85/RB-112/RB-122 logic) — this wrapper
* only supplies the task-output budget.
*/
export declare function clipTaskOutput(s: string, fullOutputPath?: string): string;
export declare function statusFromBackground(status: string, exitCode?: number): SemaTaskStatus;
/** Rolling middle-drop bound for the bash spool, shared by the watcher's per-tick accumulate and the
* teardown settle's terminal flush: keep head+tail halves within `cap`, account the dropped middle in
* `rolledChars` (the honesty counter the poll face reports as permanently-dropped bytes).
*
* design/158 S2 (RB-364 半): `onDrop`, when given, is called SYNCHRONOUSLY (before this function
* returns) with exactly the middle substring being dropped — the ONE place that substring is ever
* materialized, so a caller wanting to preserve it (the monitor lane's spill-to-offload-store) does not
* need to re-derive the same slice arithmetic a second time. Every EXISTING caller (the background_bash
* lane, task-registry.ts) omits the 4th argument, so their behavior — return value AND `rolledChars`
* accounting — is untouched byte-for-byte; the callback is a pure side-channel hook. */
export declare function rollSpoolText(spool: {
rolledChars: number;
}, s: string, cap: number, onDrop?: (dropped: string) => void): string;
/** design/158 S2 (RB-364 半) — per-monitor-handle hard cap on cumulative CHARS (UTF-16 code units)
* spilled to the offload store (design/158 §3's S3 half, pulled forward alongside S2). Once a
* handle's running total reaches this, further rolled-off middles are simply not spilled —
* already-written segments stay readable (the poll disclosure says "earlier segments retained"),
* matching the pre-S2 loss shape for whatever is dropped PAST the cap. Exported so the boundary is a
* testable contract, not a buried literal.
*
* The constant used to be named
* `..._BYTES` while the ledger added `dropped.length` — chars, up to ~3× fewer than UTF-8 bytes on
* multibyte content. CHARS is the axis kept (name changed, arithmetic untouched) because it is the
* offload store family's own coordinate (`InMemoryToolResultStore.maxTotalChars` evicts by
* `content.length`; `ToolResultSlice.totalChars`) and the spool's `rolledChars` axis — one unit
* end-to-end; a `Buffer.byteLength` ledger here would sit crosswise to the store's own cap. */
export declare const MONITOR_SPILL_CAP_CHARS: number;
/** Fold one poll's env-side-loss disclosure into the spool's account. The env's
* tail buffer evicts its HEAD under pressure (node-execution-env 8MB bound) — those bytes are gone
* before any watcher ever saw them, which is a different loss than the registry-side `rolledChars`
* middle-drop. `bytesDroppedBeforeCursor` only counts bytes dropped before an unconsumed cursor, so
* summing successive polls never double-counts; an env that says only `truncated` (no byte figure)
* degrades to the unquantified flag. Shared by ALL THREE consumers of a background poll — the monitor
* watcher, the bash watcher, and the blocking pollBackgroundBash leg (one class, one arithmetic).
* The blocked-wait leg used to keep its own hand-rolled `dropped += bytesDroppedBeforeCursor ?? 0`
* accumulator, which ignored `truncated` outright: an env that discloses the loss WITHOUT a byte
* figure produced ZERO disclosure on that leg. It routes through here now — the header's former
* "already accounted this on its own" claim was describing exactly the half-accounting that made the
* degraded shape invisible. */
export declare function accountDroppedBytes(spool: {
droppedBytes?: number;
dropUnknown?: true;
}, poll: {
truncated?: boolean;
bytesDroppedBeforeCursor?: number;
}): void;
/** The POLL-FACE wording for env-side loss (the terminal/event-frame wording is
* {@link droppedGapNote}'s; the two faces differ by design and always have). One function so the two
* quantified/unquantified branches cannot drift between the three legs that render it: the monitor
* re-readable spool, the bash re-readable spool, and the bash blocked wait. Trailing newline included
* (it is a banner LINE above the stdout heading); "" when nothing was dropped. */
export declare function spoolDropNote(acct: {
droppedBytes?: number;
dropUnknown?: true;
}): string;
/**
* HOW a poll's output body may be read AGAIN — the machine word beside `details`, previously legible
* only from the stdout heading's prose.
*
* `full` = the whole output of the task, re-rendered from the registry's own spool on every poll: a
* later poll answers the same bytes plus whatever arrived, so a consumer REPLACES what it holds.
* `cursor` = only what is NEW since the previous read: the env cursor was consumed and those bytes
* cannot be served twice, so a consumer APPENDS.
*
* The distinction was machine-invisible and the one key nearby reads the two faces OPPOSITELY:
* `bytesDroppedBeforeCursor` rides the spool faces only when something was actually dropped, and the
* increment face unconditionally (value possibly 0). A consumer discriminating on that key's PRESENCE
* therefore reads a spool that happened to drop bytes as an increment — and appends a body that was a
* full re-render, printing the same text twice.
*/
export declare const TASK_OUTPUT_REPLAY_KINDS: readonly ["full", "cursor"];
export type TaskOutputReplay = (typeof TASK_OUTPUT_REPLAY_KINDS)[number];
/**
* The ONE table the word and the prose both come out of. Each face's stdout/stderr headings live here
* rather than at the three legs that render them, so the banner a person reads and the key a program
* reads cannot drift — the failure mode this whole field exists to end (the two spool renderers were
* hand-copied twins before {@link renderSpoolBody}, and their drift was three disclosure bugs).
*
* `stdoutWhenGapped` is the heading a face falls back to once a gap has been disclosed. `undefined` is
* a ROW, not an omission: an increment window claims nothing about completeness to begin with ("new
* stdout" is true whether or not bytes were lost before it), so it has no claim to downgrade — while a
* spool advertising itself as "full" with a disclosed gap would be saying something false.
*/
export declare const REPLAY_FACE: {
readonly full: {
readonly stdout: "stdout (full, re-readable)";
readonly stdoutWhenGapped: "stdout (re-readable spool — INCOMPLETE, see note above)";
readonly stderr: "stderr";
};
readonly cursor: {
readonly stdout: "new stdout";
readonly stdoutWhenGapped: undefined;
readonly stderr: "new stderr";
};
};
export type ReplayHeadingTableCoversEveryReplay = AssertAllKeysHandled<Exclude<TaskOutputReplay, keyof typeof REPLAY_FACE>>;
/** The re-readable spool face: the monitor lane and the bash spool lane, both rendered by
* {@link renderSpoolBody}. Named once so the three legs that report it cannot each spell it. */
export declare const SPOOL_REPLAY = "full";
/** The consume-once increment face: the bash blocked wait, which accumulates env-cursor increments. */
export declare const INCREMENT_REPLAY = "cursor";
/** The stdout heading of a face, downgraded when a gap has been disclosed (see {@link REPLAY_FACE}). */
export declare function stdoutHeadingOf(replay: TaskOutputReplay, gapDisclosed: boolean): string;
/** The re-readable spool fields {@link renderSpoolBody} reads — the intersection of the bash handle's
* optional `spool` and the monitor handle's mandatory one (both carry supersets: `mirrorFailed` is
* bash-only, `drainFailed`/`dropNotified` monitor-only). Declared as a named shape, not inlined, so
* the renderer's contract is one thing both lanes are checked against. */
export interface RenderableSpool {
stdout: string;
stderr: string;
rolledChars: number;
exitCode?: number;
timedOut?: boolean;
timeoutSec?: number;
droppedBytes?: number;
dropUnknown?: true;
}
/** The per-lane inputs {@link renderSpoolBody} cannot derive from the spool itself.
* `rolledExtraClause` / `drainNote` are PRE-RENDERED STRINGS, deliberately not booleans or a lane
* discriminator: the renderer must stay ignorant of which lane called it, otherwise the two monitor-only
* disclosures (design/158 S2 spill refs; RB-239 stop-time drain failure) come back as in-renderer
* branches on a kind switch — the exact shape that let the two hand-copied renderers drift in the first
* place. Both are "" on a lane that has no such disclosure. */
export interface SpoolBodyOptions {
/** The handle is still running (drives the "running" display status). */
running: boolean;
/** Fallback display status for a non-running handle with no recorded exit code. */
handleStatus: SemaTaskStatus;
/** The lane's filter+clip projection, applied to stdout and stderr alike. */
shape: (text: string) => string;
/** Appended INSIDE the roll note, before its newline ("" when the lane has no extra clause). */
rolledExtraClause: string;
/** A full banner line (own trailing newline) rendered after the drop note, or "". */
drainNote: string;
}
/** The ONE renderer for a re-readable spool's TaskOutput body,
* shared by the monitor lane (pollMonitorLane) and the bash spool lane (pollBackgroundBash).
*
* Both faces were hand-copied twins carrying three real disclosure bugs' worth of wording (env-side
* eviction vs registry roll are SEPARATE notes, never merged; a self-inflicted background-timeout kill
* renders distinguishably from an external stop; a spool with a disclosed gap must stop advertising
* itself as "full") — kept in sync by a comment on each side telling the reader to make the same edit
* twice. The heading rule is the load-bearing one: any disclosed gap (env-side drop OR a failed
* stop-time drain) downgrades the heading, so a caller cannot add a gap note and forget the heading.
*
* NOT used by the blocked-wait leg: that face is a consume-once "new stdout" increment with its own
* wording ("accumulated char(s) dropped from the middle during the blocked wait"), an intentional
* divergence — it shares {@link accountDroppedBytes} + {@link spoolDropNote}, not this. */
export declare function renderSpoolBody(spool: RenderableSpool, opts: SpoolBodyOptions): string;
/** The honesty suffix for frames over a spool with env-side loss. Empty when nothing dropped. */
export declare function droppedGapNote(spool: {
droppedBytes?: number;
dropUnknown?: true;
}): string;
/** The event-stream disclosure rides the FIRST batch after a loss is observed (once-latch —
* a chatty watch must not repeat the note on every batch; terminal frames disclose unconditionally). */
export declare function firstDropNote(spool: {
droppedBytes?: number;
dropUnknown?: true;
dropNotified?: true;
}): string;
/** ONE wording for "you stopped nothing" across every already-terminal stop face —
* the in-process bash/monitor/agent arms and the durable-row fallback previously split: the durable
* arm answered honestly while the in-process arms claimed `Terminated <id>.` over work that finished
* (or was killed) on its own. CC 220 refuses the call outright (`Task X is not running (status: …)`,
* 220:468231-468236); sema keeps the idempotent no-op posture (stopWorkflow / durable-arm precedent,
* no isError) but the receipt must tell the truth. */
export declare function alreadyTerminalStopNote(id: string, status: string): string;
/** design/115 review B1 → A-2: register-side fail-fast, now on BOTH axes — `canAccess` is
* default-deny, so a handle missing either axis would be unreachable by every caller (a dead row
* is worse than a loud registration error). The production chain always sets both (hostTaskId +
* principal-or-"default"); a direct registry caller declares its access domain explicitly
* (`scope: "default"` is the single-tenant spelling — explicit, never implied). */
export declare function assertOwnership(input: TaskAccess, site: string): void;
/** POSITIVE-MATCH form, one polarity with `canAccess`/
* `canAccessAgentRecord` (A-2): scope must match, then the caller must HIT an arm — owner
* (the spawning task) or SESSION (the run's `originatingSessionId`). The session arm is the B-1
* HIGH fix: the completion notification routes back to the originating SESSION, but per-turn
* taskIds die with their turn — before this arm, a later turn of the same session could not poll
* or stop its own workflow (default-deny functional breakage). The old form's
* `access.owner === undefined ⇒ pass` scope-only residue is gone with the same β polarity ruling
* (design/136 §2.3 S-1 定谳 posture, now positive-match on every arm). Exported: the run-workflow
* tool's resume-ownership recheck and a deployment's serving layer apply the SAME predicate. */
export declare function canAccessWorkflowRun(run: Pick<WorkflowRun, "scope" | "sourceTaskId" | "originatingSessionId">, access: TaskAccess): boolean;
export declare function formatWorkflowRun(run: WorkflowRun): UnifiedTaskResult;
/** design/157 B13 一期定稿(二期细察补充 §「私有成员解法」)——the kernel-面 seam the MON/WF lane free
* functions call through instead of `this`: exactly the 5 members `startMonitorWatcher`/`stopMonitor`/
* `registerMonitor`/`registerWorkflow` actually reach into (`this.*` 耦合 grep 逐方法统计,见 B13 档).
* Does NOT include `fireServedTerminal` (WF-private helper, only called from within `pollWorkflow`
* itself — travels with the lane, never crosses the core seam) nor any AGENT-domain member (二期 extends
* this as `DurableAgentCore` per B13 §「私有成员解法」). The TaskRegistry class satisfies this via a
* `#coreView`-style cached object (`private readonly core: RegistryCore`), declared AFTER `handles` so
* the eager `handles` field reference below sees an already-initialized Map. */
export interface RegistryCore {
handles: Map<string, RegisteredTaskHandle>;
mintTaskId(type: SemaTaskType): string;
markStopSource(id: string, source: StopSource): void;
notifyTerminalOnce(handle: BackgroundBashTaskHandle | MonitorTaskHandle, send: () => void): void;
pokeBgQuiescence(owner: string | undefined): void;
}
/** writer-lease renewal period for armed durable rows (see ensureDurableHeartbeat).
* Exported so deployments size `staleRunningMaxAgeMs` against it (recommend ≥10× this). */
export declare const DURABLE_AGENT_HEARTBEAT_MS = 60000;
/** design/151 S1a: the durable fallback consults the store ONLY for handle-shaped ids —
* a NAME (the fuzzy stop leg) or a foreign-domain id must not take a store round-trip, and a store
* outage must never block the local name-resolution ladder. Mint form: `a` + 16 hex; the revive
* leg re-registers the same form.
* EXPORTED: a serving layer's fallback needs the SAME shape gate before its
* durable arm (a bare exact-key miss lets padded/legacy/name-shaped ids leak into live branches —
* the server 1.250 HIGH) — one shared regex kills the drift, roster-normalize precedent. */
export declare const DURABLE_AGENT_HANDLE_RE: RegExp;
/** Synthetic settle error the subagent reap path passes after a stop/parent teardown. It is NOT a
* real child error — settleBackgroundAgent never stores it (status "killed" + stoppedBy already say
* it); exported so the subagent call sites and the registry agree on the exact sentinel. */
export declare const BG_AGENT_REAP_STOP_ERROR = "stopped (parent task ended or TaskStop)";
/** CC206-B 欠账 — TaskStop name addressing, CC `XN` verbatim (206:574955-574962): NFKC-normalize, strip
* control/format chars (whitespace-class ones survive), trim, lowercase, whitespace runs → hyphens. Both
* the query and the candidate label go through this, so "Fix Tests" addresses "fix tests".
* [c209-D]: EXPORTED — SendMessage's "main" reserved lane must use the SAME canonicalization
* as this resolver, or an NFKC-equivalent spelling (fullwidth "main") skips the reserved lane and
* reaches the description layers. One normalizer, every name face. */
export declare function normalizeAgentName(s: string): string;
/** CC `gtr` (fable 复审 F1 — 亲核 @16169850: NOT plain Levenshtein but OSA / restricted
* Damerau-Levenshtein, WITH the adjacent-transposition term `o[i][s]=min(o[i][s], o[i-2][s-2]+1)`
* when `e[i-1]===t[s-2] && e[i-2]===t[s-1]`). The transposition matters: a typo like "tset"→"test"
* is distance 1 (CC has a Did-you-mean), plain Levenshtein would call it 2. */
export declare function editDistance(a: string, b: string): number;
/** CC `htr` shape (206:519814-519827): candidates within Levenshtein ≤2 (length-diff ≤2 pre-filter),
* best-first; the single best is the "Did you mean" suggestion (`htr(e, keys, 1)[0]`, 206:575196). */
export declare function closestName(query: string, candidates: Iterable<string>): string | undefined;
/** DEFAULT-DENY on BOTH axes — the old predicate skipped
* a missing axis (owner-less handle = readable by everyone in scope; scope-less handle = readable
* across scopes), splitting polarity with `canAccessAgentRecord` and re-opening the session-axis
* class cli/server just swept. Registration now REQUIRES both axes ({@link assertOwnership}), so a
* missing axis here is a broken invariant, answered with denial, never a pass. */
export declare function canAccess(handle: SemaTaskHandle, access: TaskAccess): boolean;
/** design/157 B13 二期(B13 档预调研 §4.2/§4.3 定稿)— the durable-agent lane's widened core seam.
* Extends {@link RegistryCore} with the extra members the AGENT lane's `this.*` census reached
* (73 处/9 成员,扣车道内互调后落在 5 个上,见 B13 档「二期细察补充」;`reapedHandles` 是后加的
* 第 6 个——reap 窗口的后半段,见其自身注释)。
* `durableAgentWrite`/`ensureDurableHeartbeat`/`settleBackgroundAgent` are NOT here — they are lane
* members themselves (车道内直接函数调用;类上只留转发或不留)。The class satisfies this via the
* same cached-view field, upgraded with lazy getter/setter proxies (an IIFE capturing `this`) so
* field declaration ORDER no longer constrains it — `writerId`/`reapingHandles` 등 are read at
* call time, after construction completes. */
export interface DurableAgentCore extends RegistryCore {
readonly writerId: string;
readonly reapingHandles: Set<string>;
/** The SECOND half of the reap window: ids whose durable row the sweep has already DELETED under its
* rev guard, still inside the lifecycle fence while the sweep drops the mailbox and releases the
* session. The fence must hold across that cleanup, but the row is gone from the moment the delete
* wins, and the two halves owe a caller opposite answers — "this clears on its own, send again"
* versus "it is gone, relaunch". A subset of `reapingHandles`, cleared with it. */
readonly reapedHandles: Set<string>;
readonly claimingHandles: Set<string>;
/** get/set proxy — `ensureDurableHeartbeatLane` both reads AND writes the timer slot. */
durableHeartbeatTimer: ReturnType<typeof setInterval> | undefined;
readonly deliverConfirmTimeoutMs: number;
}
/** design/153 §7.2c (件3b) — the compensable reservation ticket {@link TaskRegistry.claimParkedAgent}
* returns: everything {@link TaskRegistry.rollbackParkedClaim} and the 件3c consume flip need to
* proceed or compensate. `row` is the PRE-RESERVATION snapshot (restored verbatim on rollback);
* `reservedRev` is the row's rev AFTER the reservation CAS (the next guarded write binds to it). */
export interface ParkedClaimTicket {
handle: string;
scope: string;
token: string;
claimId: string;
reservedRev: number;
row: import("./background-agent-store.js").BackgroundAgentRecord;
}
/** Sibling of {@link mintCompletionId} for a caller that must compute the
* value BEFORE it is safe to commit (a CAS not yet known to win): fill-once with an EXTERNALLY-chosen
* value instead of generating a fresh one. Exists because `rollbackParkedClaim`'s original fix minted
* directly onto the live handle before its durable CAS was confirmed — a losing CAS then left a
* non-terminal ("parked") handle carrying a completionId nothing ever cleared, which a later legitimate
* parked→running flip (`consumeParkedFlip`) silently carried into an unrelated real completion via
* `mintCompletionId`'s own fill-once guard (independently reproduced end-to-end). The fix: compute the
* candidate once, use it in the durable payload immediately (that write is fine — it only takes effect
* if its own CAS wins), and commit it to the live handle ONLY from the call that already knows the CAS
* won. */
export declare function commitCompletionIdIfEmpty(target: {
completionId?: string;
}, value: string): string;
export interface RegisterBackgroundAgentInput extends TaskAccess {
/** design/153 §7.2d (件3c) — parked-resume registration shape: the handle is born PARKED (the
* pre-consume window keeps the live face's checkpoint arbitration; the consume flip
* {@link TaskRegistry.adoptParkedResume} is the only path to running). Pass the ticket's token
* and a stop-arbitration closure alongside. Also licenses replacing an in-process PARKED prior
* handle of the same id (the planned park→resume handoff; lane contaminated like the terminal replace). */
initialStatus?: "parked";
/** design/153 §7.2d — the parked checkpoint token this parked-born handle waits on. */
parkedCheckpointToken?: string;
/** design/153 §7.2d — the TaskStop arbitration closure for the parked-born handle (expire CAS). */
resolveParkedStop?: () => Promise<boolean>;
/** design/147 S2a — declare that the spawner WILL attach a mid-run delivery
* channel (attachAgentNotify) once the child's lane binds. Only declared-"attaching" rows buffer
* startup-window messages; an undeclared row refuses `no_channel` IMMEDIATELY — a spawner that
* never attaches must not collect acknowledged-then-discarded messages (silent loss). */
deliveryChannel?: "attaching";
/** S2b RB-27① — the spawner DECLARES whether a parked message is redeemable: true when
* the child's session outlives the run (retain ledger or session-scoped registration), so a
* continuation can drain the park. A non-retained row must NOT hand out parked acknowledgements
* (the session is released right after settle — the park would be irrecoverable): its
* lane-dead race window reads as `not_running` instead, steering the sender to the settled path. */
retainedContinuation?: boolean;
description?: string;
/** [c209-D] explicit addressing name (CC Agent.name parity) — the CALLER validates it (charset +
* reserved "main") before registration; the registry stores it verbatim. Duplicate names are legal
* (latest-wins at resolution; older rows stay task_id-addressable). */
name?: string;
/** the RESOLVED subagent type ("general-purpose"/"fork"/a roster name), persisted
* so a REVIVE cycle's observer frames can reproduce the row's TYPE column (never the description). */
agentType?: string;
/** the spawn frame's parent linkage, persisted so a REVIVE cycle's spawn frame nests the
* revived row exactly like the first spawn did. The caller passes the SAME values it emitted on the
* original frame (parentTaskId already-clamped there; parentSessionId always-on). */
parentTaskId?: string;
parentSessionId?: string;
/** the ROOT host session of the delegation tree (fixed point down the trusted
* internals chain; equals parentSessionId at depth 1). Persisted on the handle, the durable row
* and the roster so recovery faces enumerate the whole tree under the root without alias walks. */
rootSessionId?: string;
/** design/151 S3b — the revival lookup key (CC meta-sidecar shape): the model RECORD KEY,
* persisted on the durable row so a tier-3 revival rebuilds the spec the way a fresh
* named-teammate spawn would. A lookup key only, never a serialized spec. `model` is NOT the
* resolved model id: a spawn that named a model in WORDS records the caller's spelling verbatim
* (catalog key / tier word / CC alias) so the revival can re-resolve it against the catalog in
* force at WAKE time; only a spawn that carried a Model OBJECT records that object's id. Same
* value and same rule as the roster row's `model` column. */
model?: string;
toolUseId?: string;
abort: AbortController;
now?: number;
/** design/129: register as session-scoped (owner MUST be the sessionId; see the handle flag). */
sessionScoped?: true;
/** design/151 S1a (LOW-12): caller-supplied handle — the tier-3 revival leg (S3) re-registers the
* SAME durable handle so the row stays continuous across restarts. Collision with a live in-memory
* row throws (registerBackgroundBash parity). Absent ⇒ mint. */
id?: string;
/** design/151 S1a: the durable execution-row ledger. Present (with BOTH owner and scope — the
* durable predicate is fail-closed on both axes) ⇒ this registration writes a durable row
* (put → bind → settle, a serialized best-effort single-writer lane); absent ⇒ the pre-151
* in-memory-only lifecycle, byte for byte. */
store?: BackgroundAgentStore;
/** design/151 §7.2 (X-2/F-7) — tier-3 revival lane seeding: the CLAIMED durable row (identity
* inherited verbatim, status running, writerId = this instance) and its post-claim rev. Present
* ⇒ the durable lane starts from this snapshot with `written: true` and SKIPS the initial put
* (the claim-CAS already committed the row); every later write CASes against `rev`, so the first
* settle moves the claim forward instead of losing to it. Also authorizes REPLACING a leftover
* TERMINAL in-memory handle of the same id (the pre-revival cycle's row survives ~1h of terminal
* GC): its stale lane is silenced first — a queued/re-driven write from it carries the pre-claim
* rev under this process's own writerId, and the adopt path would clobber the claim. */
durableSeed?: {
record: BackgroundAgentRecord;
rev: number;
};
}