@sema-agent/core
Version:
Stateless, task-oriented AI agent core
689 lines (688 loc) • 50.1 kB
TypeScript
import type { AgentTool, ExecutionEnv } from "../internal/harness.js";
import type { ToolEffect } from "./types.js";
import { type BackgroundHardKillOutcome, type BackgroundShellCapability, type BackgroundShellId } from "./background-shell.js";
import type { WorkflowRunStore } from "./workflow-run-store.js";
import { type BackgroundAgentStore } from "./background-agent-store.js";
import type { TaskNotificationPayload } from "./task-notification.js";
import type { ToolResultStore } from "./tool-result-store.js";
import { type SemaTaskType, type StopSource, type SemaTaskStatus, type TaskRetrievalStatus, type UnifiedTaskOutput, type UnifiedTaskResult, type SemaTaskHandle, type TaskAccess, type BackgroundAgentTaskHandle, type MonitorTimers, type RegisterMonitorInput, type RegisterWorkflowInput, canAccessWorkflowRun, clipTaskOutput, MONITOR_BATCH_WINDOW_MS, MONITOR_DEFAULT_TIMEOUT_MS, MONITOR_MAX_TIMEOUT_MS, MONITOR_MAX_BATCHES_PER_MINUTE } from "./task-registry-shared.js";
export { normalizeAgentName, DURABLE_AGENT_HEARTBEAT_MS, DURABLE_AGENT_HANDLE_RE, BG_AGENT_REAP_STOP_ERROR } from "./task-registry-shared.js";
export type { ParkedClaimTicket, RegisterBackgroundAgentInput } from "./task-registry-shared.js";
import type { RegisterBackgroundAgentInput, ParkedClaimTicket } from "./task-registry-shared.js";
import { type AgentStateSource } from "./agent-state.js";
import type { CheckpointSeat } from "./checkpoint-seat.js";
export type { SemaTaskType, StopSource, SemaTaskStatus, TaskRetrievalStatus, UnifiedTaskOutput, SemaTaskHandle, TaskAccess, MonitorTimers, RegisterMonitorInput, RegisterWorkflowInput, };
export { canAccessWorkflowRun, clipTaskOutput, MONITOR_BATCH_WINDOW_MS, MONITOR_DEFAULT_TIMEOUT_MS, MONITOR_MAX_TIMEOUT_MS, MONITOR_MAX_BATCHES_PER_MINUTE };
export interface TaskKind {
type: SemaTaskType;
effect: ToolEffect;
}
export interface RegisterBackgroundBashInput extends TaskAccess {
shellId: BackgroundShellId;
env: ExecutionEnv & BackgroundShellCapability;
description?: string;
toolUseId?: string;
id?: string;
now?: number;
/** (design/129 discipline): session-resident background bash — `owner` MUST be the
* sessionId (a later turn's fresh per-turn owner reaches it via `access.sessionId`). Survives the
* run teardown (settle/evict skip + dispose except-list); terminal anchor = `reapSessionBackground`. */
sessionScoped?: true;
/** design/116 §7 G2b: when set, the registry WATCHES the process (1s poll, increments spooled with a
* rolling bound) and fires ONE completion task-notification at the terminal state — the background-bash
* half of CC's LocalShellTask completion notify. Also flips TaskOutput to re-readable spool reads. */
onTerminal?: (notification: TaskNotificationPayload) => void;
/** CC 2.1.209 对齐批A A5: the task's OUTPUT FILE — the launch receipt advertises it and the watcher
* mirrors every polled increment into it (append-only, unbounded — unlike the rolling in-memory
* spool), so "Read the output file path" is a real alternative to TaskOutput. Terminal
* task-notifications carry the same path (`output_file`). Primarily meaningful with `onTerminal`
* (only the watcher mirrors increments live); a watcher-less row still gets ONE final best-effort
* append at TaskStop/run-teardown (the tail drain), so the file is written even without a watcher —
* there is just no live mirror. Callers create the file (empty) before registering. */
outputFile?: string;
/** The LAUNCHING task's retain declaration (`TaskSpec.retainBackgroundProcesses`) — the task lane of
* {@link retainsBackgroundProcesses}. Carried onto the row as its `retained` mark at registration
* (together with the env lane), so faces that never see the spec — the session reap, the exit-boundary
* hard face — answer the same retain question the launch receipt answered. Passing it (true or false)
* STATES the lane: a later run teardown does not re-judge the row. Absent = the launcher does not know the
* lane; the owning run's teardown supplies it. */
retainBackgroundProcesses?: boolean;
}
/** One row's receipt from {@link TaskRegistry.killAllBackgroundShellsNow} (closed `outcome` set; `retained`
* present when the row was retained, whatever the outcome). */
export interface BackgroundShellHardKillReceipt {
taskId: string;
type: "background_bash" | "monitor";
owner?: string;
retained?: true;
outcome: BackgroundHardKillOutcome | "retained_skipped";
}
export interface TaskPollOptions {
workflowStore?: WorkflowRunStore;
/** design/151 S1a: the durable agent ledger — a live-registry miss on an `a*` id falls back to it
* (terminal snapshot / honest cross-instance "running elsewhere"), behind {@link canAccessAgentRecord}. */
agentStore?: BackgroundAgentStore;
filter?: string;
/** design/116 W3 (CC TaskOutput `block`): wait until the task reaches a terminal state (or `timeoutMs`)
* instead of returning the instantaneous snapshot — saves the model blind re-poll turns. The TOOL layer
* defaults this to TRUE (CC 187 `block: default(!0)`, 裁定 2026-07-02); registry callers pass it explicitly. */
block?: boolean;
/** Max wait for `block` (ms). Default 30s, capped at 600s (CC 187). */
timeoutMs?: number;
/** Abort signal — a blocked wait must stop when the tool call is cancelled. */
signal?: AbortSignal;
/** Mirrors {@link import("./types.js").TaskSpec.oneShot} (see {@link TaskToolOptions.oneShot}
* for the full contract): forwarded to the background_agent lane's still-running poll body so it
* does not teach "you will be notified" when this run has no later turn to receive one. */
oneShot?: boolean;
/** design/158 S1 (RB-205-B 半): the offload store a CLIPPED background_agent result's full text
* spills to — the disclosure gains a `ReadToolResult` ref instead of the middle staying gone for
* good (see {@link import("./task-registry-agent.js").spillClippedAgentResult}). Absent (a
* deployment that never wired a tool-result store) ⇒ legal degrade, byte-identical to pre-S1 output. */
toolResultStore?: ToolResultStore;
/** The run's checkpoint seat: a PARKED background_agent row's approval card is read from it for the
* answer's `agentState` ({@link import("./agent-state.js").readAgentState}). Absent ⇒ the card reads
* `unreadable`; nothing else changes. */
checkpointSeat?: CheckpointSeat;
}
export interface TaskStopOptions {
workflowStore?: WorkflowRunStore;
/** design/151 S1a: durable-ledger fallback for the stop face (an off-process row reads `not_local` —
* workflow-fallback parity — instead of a misleading not-found). */
agentStore?: BackgroundAgentStore;
}
export interface TaskToolOptions extends TaskAccess {
registry: TaskRegistry;
workflowStore?: WorkflowRunStore;
/** design/151 S1a: durable agent ledger — threads into pollTask/stopTask fallback reads. */
agentStore?: BackgroundAgentStore;
/** the task's soft wall-clock deadline (absolute ms epoch), same source as the Bash
* foreground clamp (design/130 P2b). When present, a BLOCKING TaskOutput wait is clamped so it
* cannot eat the write-out window: the model gets an honest, visible clamp note instead of a
* silent 10-minute blind wait into the finalize wall (TB rs**: 9.1min poll = the exact tax).
* CC posture note (anchors/2.1.212/print-mode-drain.md): CC classifies WIND-DOWN waits, and its
* block cap rides per-query budgets — a deadline-aware wait is the sema equivalent on the tool face. */
deadlineMs?: () => number | undefined;
/** design/157 B17 S2 (fable 终判 D4) — whether a terminal <task-notification> is actually wired for
* this mount. Drives the composed description's notification sentence: the pre-S2 literal advertised
* the notification unconditionally, which was a false promise on deployments that never wired
* `taskNotification` (B17 方案查出的既有假广告). Default true (a direct public-API consumer keeps the
* historical wording); prepare-task passes the real wiring state. */
notificationWired?: boolean;
/**
* Mirrors {@link import("./types.js").TaskSpec.oneShot}: this run has no later turn for an
* async background notification to land in (a headless `-p` process exits once the turn ends).
* Threaded into the background_agent lane's non-blocking/blocked-timeout "still running" poll body
* (see {@link import("./task-registry-agent.js").pollBackgroundAgentLane}), which otherwise taught
* "you will be notified when it completes" — the same failure form `run_workflow`'s launch note was
* fixed for (RB-215/run-workflow-tool.ts). Default undefined ⇒ byte-compat (pre-RB-220 wording).
*/
oneShot?: boolean;
/** design/158 S1 (RB-205-B 半): the offload store a CLIPPED background_agent result's full text
* spills to — see {@link TaskPollOptions.toolResultStore} (forwarded verbatim into `pollTask`).
* Absent ⇒ legal degrade, byte-identical to pre-S1 output. */
toolResultStore?: ToolResultStore;
/** The run's checkpoint seat — see {@link TaskPollOptions.checkpointSeat} (forwarded verbatim into
* `pollTask`). The first-party mount passes the same seat the Agent tool parks children into. */
checkpointSeat?: CheckpointSeat;
}
/**
* RB-49 B-3 (2026-07-25): the row-metadata projection `getAccessibleTask` returns. Was spelled out as a
* hand-written literal in THREE places (this signature, the internal cast below, and the SendMessage
* consumer in agents/subagent.ts) with an identical field list — adding a row field meant editing three
* spots with zero compiler enforcement (the projection-antipattern family).
* One exported name, three consumers, drift becomes a type error.
*/
export interface AccessibleTaskRow {
task_id: string;
type: SemaTaskHandle["type"];
status: SemaTaskHandle["status"];
toolUseId?: string;
sessionScoped?: boolean;
description?: string;
name?: string;
agentType?: string;
owner?: string;
scope?: string;
parentTaskId?: string;
parentSessionId?: string;
rootSessionId?: string;
/** The row's registration instant (the original spawn time for a first-cycle row): the
* revive projection needs it so a revive frame's `startedAt` anchors the ROW, not the wake call. */
createdAt?: number;
}
export declare class TaskRegistry {
private handles;
/** observers of the design/129 session terminal anchor — `reapSessionBackground`
* fires each hook (swallow-guarded) AFTER reaping, so session-anchored side state (the session-scoped
* subagent retain ledger) releases on the SAME deployment call. Registered idempotently per consumer. */
private sessionReapHooks;
/** design/144 §1.2 (child gate): one-shot quiescence watchers keyed by OWNER — each set fires (and is
* removed) when that owner's running background-task count reaches zero. Populated by
* {@link onBackgroundQuiescence}; poked from every terminal transition / running-row eviction site.
* 1.284 C3: callbacks carry a REASON — "quiescent" (the count really reached zero) vs "evicted"
* (cap overflow dropped the watcher; the owner's tasks may still be running — the subscriber must
* NOT treat this as quiescence). */
private bgQuiescenceWatchers;
/** One host-callback isolation scope for this registry's five notification seams (reap terminal
* notifier, session-reap hooks, quiescence fire + cap-overflow eviction, terminal-once send). Same swallow
* as the five hand-written try/catches it replaced, now counted per site and readable for diagnostics. */
private readonly notifier;
/** The read face for this registry's CONTAINED host-callback failures (site → count).
* The registry has no deployment error sink of its own to disclose to (it is process-global,
* constructed before any RunnerDeps exists), so readability IS its disclosure grade: a deployment
* that suspects a broken observer polls this instead of grepping for silence. Empty on the
* healthy path. */
notifierFailureCounts(): ReadonlyArray<{
site: string;
count: number;
}>;
/** design/157 B13 一期(#coreView 解法,B13 档「私有成员解法」定稿): the MON+WF lane free functions'
* only seam back into the class — exactly the 5 `RegistryCore` members those two lanes' `this.*`
* calls actually reached (grep-verified, see task-registry-shared.ts's own doc comment). Declared
* AFTER `handles` (its `handles` field below is captured eagerly at construction, so `this.handles`
* must already be assigned); the 4 method arrows close over `this` lazily and don't share that
* ordering constraint. Two-期 extends this to `DurableAgentCore` (durable-agent 车道, per the B13
* 「私有成员解法」 note) without touching this shape. */
private readonly core;
durableAgentArmed(id: string): boolean;
durableAgentRowProbe(id: string): (() => Promise<boolean>) | undefined;
beginDurableClaim(id: string): boolean;
endDurableClaim(id: string): void;
reapDurableAgents(scope: string, deps: {
store: BackgroundAgentStore;
/** `listPlaced` (subagent transcript persistence, optional) arms the partition-orphan leg —
* see {@link reapDurableAgentsLane}. Pass the deployment's SessionStore directly (the
* TtlSessionStore over a placement-capable repo exposes it). */
sessions?: {
unpin?(sessionId: string): unknown;
release(sessionId: string): Promise<void> | void;
listPlaced?(kind: "subagent", opts?: {
olderThanMs?: number;
scope?: string;
}): Promise<Array<{
sessionId: string;
placedAt: number;
} & ({
scope: string;
handle: string;
} | {
tupleIncomplete: true;
})>>;
};
mailbox?: {
drop(scope: string, handle: string): Promise<void>;
};
}, policy: {
now?: number;
maxAgeMs?: number;
keep?: number;
staleRunningMaxAgeMs?: number;
}): Promise<{
rowsReaped: number;
sessionsReleased: number;
skippedNoSessions: number;
orphanPlacedReleased: number;
}>;
releaseDurableTranscriptAnchor(id: string): void;
bindBackgroundAgentSession(id: string, sessionId: string): void;
registerBackgroundAgent(input: RegisterBackgroundAgentInput): string;
/** #22 — persist this leg's org-memory admission verdict on the durable row (the cross-process
* revival's only carrier). See {@link recordBackgroundAgentOrgAdmissionLane}. */
recordBackgroundAgentOrgAdmission(id: string, verdict: import("./memory-admission.js").OwnOrgAdmissionVerdict): void;
parkBackgroundAgent(id: string, park: {
checkpointToken: string;
seq?: number;
resolveStop?: () => Promise<boolean>;
}): "parked" | undefined;
reconcileParkedAgents(stores: {
agentStore: import("./background-agent-store.js").BackgroundAgentStore;
checkpointStore: Pick<import("./checkpoint-store.js").CheckpointStore, "get">;
}, scope: string, now: number, opts?: {
staleClaimMaxAgeMs?: number;
}): Promise<{
failed: number;
rolledBack: number;
}>;
claimParkedAgent(stores: {
agentStore: import("./background-agent-store.js").BackgroundAgentStore;
checkpointStore: Pick<import("./checkpoint-store.js").CheckpointStore, "get">;
}, handle: string, access: TaskAccess): Promise<{
ok: true;
ticket: ParkedClaimTicket;
} | {
ok: false;
reason: "not_found" | "not_parked" | "binding_broken" | "checkpoint_not_pending" | "claim_lost" | "store_unreachable";
}>;
rollbackParkedClaim(stores: {
agentStore: import("./background-agent-store.js").BackgroundAgentStore;
checkpointStore: Pick<import("./checkpoint-store.js").CheckpointStore, "get">;
}, ticket: ParkedClaimTicket): Promise<"rolled_back" | "failed" | "lost" | "retry">;
consumeParkedFlip(id: string, stores: {
agentStore: import("./background-agent-store.js").BackgroundAgentStore;
}, ticket: ParkedClaimTicket): Promise<boolean>;
finalizeParkedResume(id: string): void;
/** `outcome.cycle` names the revive cycle this settle speaks for (absent = 0 = the original
* spawn cycle). A spawn leg never passes it; a caller that settles by LIFECYCLE AUTHORITY (reap /
* session release — "whatever is running under this row dies") passes the row's current cycle. See
* {@link settleBackgroundAgentLane} for why a stale stamp must be a no-op. */
settleBackgroundAgent(id: string, outcome: {
status: "completed" | "failed" | "killed";
result?: string;
resultFull?: string;
error?: string;
errorCode?: string;
retryable?: boolean;
errorKind?: string;
retryAfterMs?: number;
stoppedBy?: StopSource;
seq?: number;
cycle?: number;
}): "completed" | "failed" | "killed" | undefined;
/** design/164 — record one tool-lifecycle beat on a running background-agent row; see
* {@link noteBackgroundAgentActivityLane} for why the in-memory row needed a stall signal at all. */
noteBackgroundAgentActivity(id: string, now?: number): void;
/** design/164 — collect SESSION-SCOPED background agents that have gone silent for `staleMs`, the
* stall-based replacement for the retired wall-clock ceiling on that lane. Caller-supplied policy
* like every other retention knob here; see {@link reapStaleSessionBackgroundAgentsLane}. */
reapStaleSessionBackgroundAgents(staleMs: number, now?: number): number;
abortBackgroundAgentsForOwner(access: TaskAccess, opts?: {
skipSessionScoped?: boolean;
sessionScopedOnly?: boolean;
}): number;
/** A durable row's answer with its state: only a parked row waits on the card read; any other row is
* answered in place. */
private serveDurableAgentRow;
/** The row facts {@link import("./agent-state.js").readAgentState} reads for a background agent this
* caller can see — the live handle only (a durable-only row is projected by its reader from the record,
* {@link import("./agent-state.js").agentStateSourceOfRecord}). Same access guard as a poll; unknown and
* out-of-scope ids read identically (undefined). */
agentStateSource(taskId: string, access: TaskAccess): AgentStateSource | undefined;
resolveBackgroundAgentByName(name: string, access: TaskAccess, opts?: {
preferRunning?: boolean;
}): {
status: "found";
handle: BackgroundAgentTaskHandle;
} | {
status: "ambiguous";
message: string;
} | {
status: "not_found";
suggestion?: string;
};
markRetainedContinuation(id: string): void;
/** The row's current stop-cycle counter, read by the spawn lanes right after registering to
* thread into the child's `RunInternals.cycleSeq`; see {@link backgroundAgentCycleSeqLane}. */
backgroundAgentCycleSeq(id: string): number | undefined;
/** Subagent transcript persistence (delegation entry caps) — one delegation tree's ACTIVE
* (running/pending) a* handles in THIS process, keyed `(scope, rootSessionId)`; SYNCHRONOUS by
* contract (the caps check and the registration form one atomic segment). See
* {@link activeDelegationHandlesLane}. */
activeDelegationHandles(scope: string, rootSessionId: string): string[];
/** ASYNC (revive arbitration) — the durable half is a guarded ownership claim on the row (awaited before the
* in-memory flip), so this leg and a cross-process claim arbitrate in one domain instead of both
* believing they own the cycle. See {@link reviveBackgroundAgentLane}. */
reviveBackgroundAgent(id: string, access: TaskAccess, abort?: AbortController): Promise<{
ok: true;
cycle: number;
} | {
ok: false;
reason: "not_found" | "still_running" | "recycling";
}>;
settleRevivedAgent(id: string, cycle: number, outcome: {
status: "completed" | "failed" | "killed";
result?: string;
resultFull?: string;
error?: string;
errorCode?: string;
retryable?: boolean;
errorKind?: string;
retryAfterMs?: number;
}): "completed" | "failed" | "killed" | undefined;
unmarkRetainedContinuation(id: string): void;
attachAgentNotify(id: string, notify: NonNullable<BackgroundAgentTaskHandle["notify"]>, cycle?: number): void;
/** RB-375 A1 — hang the reap-time terminal notifier on a live background-agent row (see
* {@link BackgroundAgentTaskHandle.onReapTerminal}). No-op on unknown/terminal rows: a row that
* already settled has its unwind (or the settle itself) owning the terminal story. */
attachAgentTerminalNotifier(id: string, fn: () => void): void;
/** RB-375 A1 — the child-unwind side of the terminal-notification once-latch: returns `true` when
* the CALLER now owns sending the terminal faces (and claims the latch), `false` when the reap
* sweep already sent them (the caller must stay silent — the registry row still takes the settle's
* payload/backfill either way; only the announcement is deduplicated). Unknown/GC'd row ⇒ `true`
* (no latch survives eviction; the L1-1 late-report posture is unchanged). */
claimAgentTerminalNotify(id: string): boolean;
deliverToRunningAgent(id: string, access: TaskAccess, notification: import("./task-notification.js").TaskNotificationPayload, opts?: {
priority?: import("./task-notification.js").SystemInjectionPriority;
}): Promise<{
ok: true;
disposition: "queued" | "parked" | "pending";
} | {
ok: false;
reason: "not_found" | "not_running" | "no_channel" | "queue_full";
}>;
runningBackgroundAgentLabels(access: TaskAccess): string[];
private pollBackgroundAgent;
private stopBackgroundAgent;
/** design/151 §3.1d (HIGH-3): this process instance's durable-row writer identity (random per
* registry instance). A fallback read of a `running` row written by a DIFFERENT instance must
* never claim liveness — it serves the honest "outcome unknown here" instead. */
readonly writerId: string;
/** the writer-liveness LEASE: while any armed durable row is running, one lazy
* unref'd interval bumps their `updatedAt` (an empty patch), so row age measures SILENCE, not
* run length. A `staleRunningMaxAgeMs` reap policy must therefore be ≫ this period (the store
* contract recommends ≥10min) — and even a premature flip self-heals: the real settle's
* read-back adopts the bumped rev and re-CASes the truth over the reaper's guess. */
/** 增量 R-1 — handles a reap sweep is CURRENTLY adjudicating (between its live-handle check
* and the delete/release outcome). A revive admitted inside that await window would race the
* conditional delete with its own durable transition still unflushed (old rev ⇒ the delete wins,
* the session is released under an ADMITTED resume, and the revive's queued write poisons on
* "gone"). Membership is synchronous on both sides, with three readers (the `claimingHandles` twin
* below carries the same fence set): reap marks BEFORE its terminal check, a revival lane refuses
* while marked (the row is being recycled), and the terminal-handle
* GC skips a marked id instead of evicting it. */
private reapingHandles;
/** The post-delete half of the window above (see {@link DurableAgentCore.reapedHandles}): the sweep's
* guarded delete has WON, and the fence is still held while the mailbox is dropped and the session
* released. The fence's readers are unchanged — this only splits what a refused revival is TOLD,
* since a caller advised to retry a row that no longer exists would retry forever. */
private reapedHandles;
/** design/151 §7.3 (F-8) — handles a revival lane is CURRENTLY claiming (between the claim-CAS
* decision and the revive registration/rollback). BOTH lanes are writers: the tier-3 SendMessage
* claim, and (since the retained revive started claiming its own row) the in-process retained-revive
* path, which holds the mark across its store round trip. Synchronous membership on both sides, the
* `reapingHandles` twin, with three readers: the reap sweep skips a mid-claim row (its rev is about
* to move), the sibling revival lane refuses one (`reviveBackgroundAgent` recycling — a transient
* hold, told apart from a row that is actually gone), and the
* terminal-handle GC skips it — so the revival lanes, the reaper and the GC can never adjudicate the
* same handle concurrently in-process. */
private claimingHandles;
private durableHeartbeatTimer;
mintTaskId(type: SemaTaskType): string;
registerBackgroundBash(input: RegisterBackgroundBashInput): string;
/** Background output-file read exemption: true iff `canonicalPath` is EXACTLY the registered output
* file of a background bash row that (a) the caller can access (same owner/scope/session visibility as
* a TaskOutput poll — {@link canAccess}) and (b) was registered on the caller's OWN execution env —
* `env` identity is part of the match, same discipline as {@link runningShellIds}. A canonical path only names a file within ONE env's filesystem
* namespace: on a shared registry, a row minted on env A must never exempt a same-named outside-root
* file in env B's namespace (same session or not), so instance equality — never a string proxy — is
* required. The output file is minted by the ENGINE itself (createTempFile
* at spawn/adopt) in the OS temp dir — structurally outside the hands containment root — and its path
* is handed to the model in the launch receipt and the completion notification; Read consults this so
* it can honor that instruction without widening the root fence (exact registered file only, no
* directory/prefix admission). Membership dies with the row (per-run evict / session reap / terminal
* GC), so the exemption never outlives the task's addressability. Exact string compare: both sides are
* `canonicalPath` outputs for the same env, so a symlinked temp dir cannot cause a miss or a false hit. */
isBackgroundOutputFile(canonicalPath: string, access: TaskAccess, env: unknown): boolean;
/** [c209-D]: display-cap-free EXACT id lookup with the same access guard as a poll — identity
* resolution must NEVER ride the display-capped `list()` (with >LIMIT visible rows the cap can evict
* the addressed id, and a name-layer fallback then MISROUTES the message to a同名 row instead of an
* honest not-found). Unknown and out-of-scope ids read identically (info-hiding, undefined both). */
getAccessibleTask(taskId: string, access: TaskAccess): AccessibleTaskRow | undefined;
/** design/134 §3.3 (R3 single-source): record WHO is about to kill `id`, at the initiation site,
* BEFORE the abort()/status flip — the ordering is load-bearing: the guard below refuses markers on a
* non-running handle, so a caller that flips first loses its claim and attribution falls back to
* "system". First-marker-wins: an earlier marker (e.g. a service-wire "user") is never overwritten.
* Applies to background_agent, background_bash AND monitor handles (the markable kinds — the monitor
* watcher/stop lanes mark and read it too); workflow cancellation is out of scope. */
markStopSource(id: string, source: StopSource): void;
/**
* Attribute an ENV-LEVEL blanket sweep before it runs.
*
* The incident: a user's `run_in_background` Bash died mid-run reporting `stopped-by:"system"` with a
* zero-byte output file, and diagnosing it took a cross-repo investigation with a timeline argument.
* `"system"` is not a cause — it is the DEFAULT, i.e. the signature of a kill that never passed through
* this registry. Every kill that does pass through records who did it; `disposeBackgroundShells` lives in
* the execution-env layer, cannot reach the registry, and so left the one class of kill a user actually
* notices as the only one with no attribution at all.
*
* This marks every background_bash row on `env` that the sweep is about to take, so the row the user
* reads says what happened to it. `except` mirrors the sweep's own keep-list exactly — a spared shell
* must not be marked, or the next reader would be told a live process had been killed.
*
* Attribution only: it never kills anything. The caller has already demonstrated kill capability on the
* env, which is why there is no access guard (same reasoning as {@link markStopSourceByShellId}).
*/
markStopSourceForEnvSweep(env: unknown, source: StopSource, except?: readonly string[]): string[];
/** design/134 KNOWN-ISSUES close-out (env-direct TaskStop): the stop-source annotation face
* addressed by the ENV-level shellId — for a kill initiator that holds only the env handle (the
* registry-less TaskStop band) and is about to `env.killBackground` directly, i.e. without going
* through {@link stopTask}. Resolves the shellId → unified task id and delegates to
* {@link markStopSource}, so the running-only + first-marker-wins guards hold unchanged.
* ⚠️ `env` is REQUIRED for any effect (RB-479-A②, 5.0.0): a shellId without its env is ambiguous
* by construction on a shared registry, so an env-less call is a NO-OP — this marker is
* best-effort attribution, not data. No access guard — attribution only (never kills); the
* caller already demonstrated kill capability on the env. */
markStopSourceByShellId(shellId: string, source: StopSource, env?: unknown): void;
/** Withdraw a pending stop-source marker after the kill it announced FAILED (小优化批 2026-07-13):
* a marker left by a failed env-direct kill would win first-marker-wins against the NEXT, real stopper
* (e.g. a later user TaskStop reads "parent"). Clears ONLY the exact still-pending marker the caller
* minted — same (shellId, env) resolution as {@link markStopSourceByShellId}; a row already settled,
* or carrying a different source, is left alone. */
clearPendingStopSourceByShellId(shellId: string, source: StopSource, env?: unknown): void;
/** design/134 §3.3: narrow terminal read for consumers (subagent sinkEmit/notify) — only a KILLED row
* has an attribution; "system" is the defensive floor for a row that went killed without a landed
* stoppedBy (e.g. env-direct flips). Returns undefined for running/completed/failed/evicted rows. */
getStopAttribution(id: string): StopSource | undefined;
/** P1-3 — the fill-once completion correlation id (see {@link UnifiedTaskOutput.completionId}), for a
* notification-construction call site that already has the task's `id` and needs to attach it to the
* payload it is building. Deliberately NOT restricted to a subset of task types (unlike
* {@link getStopAttribution}) — `completionId` lives on the shared {@link SemaTaskHandle} base and
* every registered task kind, workflow included, carries it the same way. Undefined until the task's
* own settle point has run ({@link mintCompletionId}), or for an id this registry never held. */
getCompletionId(id: string): string | undefined;
/** Bulk stop-source attribution for one owner — the registry-side primitive that
* replaces the service's `list()+markStopSource` per-row loop, which had TWO holes: ① `list()` didn't
* expose `sessionScoped`, so on the resume leg (canonical taskId==sessionId, rebuilt taskConfig without
* spec.taskId) task-scoped and session-scoped children share the same owner KEY VALUE and can't be told
* apart; ② `list()`'s 500-row display cap silently skipped rows on >500-children runs.
* Same owner-filter semantics as {@link abortBackgroundAgentsForOwner}: {@link canAccess} + the EXPLICIT
* `sessionScoped` flag (design/129 — never inferred from key values), over a DIRECT handle-map walk
* (no list() cap in the path). Covers every markable kind ({@link markStopSource}'s own set:
* background_agent / background_bash / monitor); each row is delegated to `markStopSource`, so the
* running-only + first-marker-wins guards hold unchanged — an existing claim is never overwritten.
* Returns how many rows NEWLY took the marker (already-marked / non-running rows don't count). */
markStopSourceForOwner(access: TaskAccess, source: StopSource, opts?: {
skipSessionScoped?: boolean;
}): number;
/** The run-teardown KILLED-receipt producer for the SHELL lanes (background_bash + per-run
* monitor). The runner teardown disposes the processes (`disposeBackgroundShells`) and evicts the rows
* (`clearBackgroundForOwner`) — after which NO producer can ever mint the killed notification: the bash
* watcher's next poll sees a vanished shell ("nothing to notify about") or its evicted handle and just
* stops. So a mid-turn abort (or a normal turn end) silently swallowed children the model had been
* promised notifications for ("You will be notified when it completes — do not poll"). The AGENT lane
* already has its producer (the child promise's settle → notify); this is the same receipt for
* the shell lanes — call it BEFORE dispose/evict so the sink chain (onTerminal/onEvent → the run's
* notification wrapper, whose lane is already down at teardown) PARKS the receipt on the per-session
* pending store and the session's next run drains it (the pending lane, one shape for all producers).
*
* Attribution (design/134 §3.3): `markStopSource(source)` first — first-marker-wins, so a deployment's
* earlier explicit mark (e.g. a service-wire "user" on abort) is never overwritten; `stoppedBy` then
* lands from the marker. Rows without a sink still settle (registry truth), they just have no receipt
* to send. Session-scoped rows are skipped under `skipSessionScoped` (they outlive the turn; their
* terminal anchor is `reapSessionBackground`). KNOWN small race, recorded: a child that exited within
* the last watcher-poll interval (~1s) but whose exit the watcher hasn't observed yet still reads
* "running" here and is settled `killed` — the receipt is at worst one status coarser, never silent.
*
* (HIGH): the settle EXECUTES the kill itself (await `killBackground`, Result-typed)
* before minting the receipt — previously the receipt claimed "killed" while the actual kill was left to
* the later `disposeBackgroundShells`, whose failure was swallowed (process alive + registry cleared =
* the receipt lied). A failed kill now mints an HONEST receipt naming the failure (the env dispose is
* still the idempotent backstop). Returns how many rows were settled. */
settleKilledForOwner(access: TaskAccess, opts?: {
source?: StopSource;
skipSessionScoped?: boolean;
envDying?: boolean;
/** Spec-level retain (`TaskSpec.retainBackgroundProcesses`): treat every shell-lane row as if its
* env had declared retain — skip entirely (no kill, no killed receipt: nothing died, a receipt
* would lie). Same `envDying` exception as the env-capability gate: retain on a dying owned env
* is illusory, so those rows still settle with the honest killed receipt. */
retainProcesses?: boolean;
}): Promise<number>;
/** A-005.4/5: the best-effort kill `reapSessionBackground`'s monitor/background_bash arms fire is
* fire-and-forget by DESIGN (the reap is synchronous and settles the row regardless of the kill
* outcome — see the callers) but was fire-and-FORGET-the-evidence-too: a resolved `{ok:false}` was
* never read, and even a promise rejection landed on `.catch(() => undefined)`. Reads the Result,
* drops the benign `not_found` case (already dead — same filter the awaited TaskStop kill path uses,
* see `killFailed` above), and routes anything else through `shutdownDebug` — the SAME kill-path
* forensics channel the row-scoped calls right above these call sites already use for this reap, so
* a `SEMA_DEBUG_SHUTDOWN=1` "who killed my process" hunt no longer dead-ends at a silently-swallowed
* reap-time failure. Never throws (a forensics call must not fault the reap it is reporting on). */
private reportSessionReapKillFailure;
/** design/129: the session-scoped children's TERMINAL anchor — call from your session release/sweep.
* Aborts every running session-scoped background child registered under `sessionId` and settles it
* `killed` (the child's own catch/then would also classify the abort as killed; this makes the registry
* state immediate rather than waiting on the child's promise). Idempotent.
* RETAINED shell-lane rows (THE retain predicate over the row's mark and the env's declaration) are left
* whole — row, watcher and process (the deliver-a-living-service contract; their terminals: TaskStop, own
* exit, env destruction, the hard face under `includeRetained: true`). Every other running shell-lane row
* this session owns is killed and settled. {@link reapAllSessionBackground} delegates here.
* RB-375 A3 — RETURN-VALUE semantics: the count covers DIRECT hits of this sweep only (rows whose
* own arm above matched). Children the reaped agents themselves spawned (`sessionScoped:false`
* grandchildren) die COLLATERALLY, via each reaped agent's own teardown
* (`abortBackgroundAgentsForOwner` in the child's unwind) — this function never sees those rows,
* so they are NOT counted here.
* RB-375 A1 — a directly reaped `background_agent` row's terminal notification is sent
* SYNCHRONOUSLY by this sweep (through the spawn-lane-hung {@link BackgroundAgentTaskHandle.onReapTerminal},
* once-latched against the child's own unwind), so consumers observe the terminal immediately
* instead of after the in-flight tool honors the abort (unbounded). */
reapSessionBackground(sessionId: string, scope?: string): number;
/** The FULL-registry reap for the ENGINE's own exit path. TOC form:
* the engine process IS the session terminal (the shell has no line to call — live evidence:
* detached bg children reparent to PID 1 and outlive the engine), so the engine's SIGHUP/SIGTERM/
* normal-exit terminus calls THIS. Enumerates every running session-scoped row's (sessionId, scope)
* pair — `owner` = the sessionId by design/129 discipline for background_bash/monitor/background_agent
* (RB-215 note: NOT for workflow, whose `owner` is the launching LEG's taskId; its session anchor is
* `originatingSessionId` — see {@link reapSessionBackground}'s own workflow arm for why) — and runs
* the per-session reap on each pair; retained rows are left alone (row and process), exactly like the
* per-session anchor. Timing discipline: call at the hardShutdown TERMINUS, never at drain start — an
* in-flight turn may still be TaskOutput-following a bg process. Returns total rows reaped.
*
* Its kills go through each env's `killBackground` (possibly a TERM-then-timer ladder), and it reaches
* session-resident rows only: every exit terminus calls {@link killAllBackgroundShellsNow} after it. */
reapAllSessionBackground(): number;
/** The EXIT-BOUNDARY hard face over every shell-lane row (`background_bash` + `monitor`), session-resident or
* not (docs/background-process-lifetime.md is the contract). SYNCHRONOUS (no await, no timer) and
* IDEMPOTENT. Visits every row except `completed` (a `killed` row may sit inside a softer stop's grace
* window; a `failed` one may have lost its watcher, not its process). A retained row (THE retain predicate)
* is left alone under `includeRetained: false` — no default — unless an explicit stop already settled it
* `killed`. Every other visited row goes through the seat {@link BackgroundShellCapability.killBackgroundNow}.
* One receipt per visited row: `killed` (the seat delivered its kill now) / `already_gone` / `retained_skipped`
* / `no_pgid` (no synchronous kill landed — no seat, the seat said so or threw; the env's ordinary
* `killBackground` is then dispatched, nothing claimed). Only a running row the seat killed is settled
* `killed` (stop source "system", no notification). `reason` is forensics only.
* `sessionId` + `scope` narrow the visit to the rows {@link reapSessionBackground} (sessionId, scope) keys on
* — the same access (`canAccess` with owner = session = sessionId, in that scope): every session-resident
* row of the session, plus its run rows where the run's task id is the session id (the default); a run
* named with its own task id is not reached. Absent ⇒ the whole registry. Same receipts either way. */
killAllBackgroundShellsNow(opts: {
includeRetained: boolean;
reason?: string;
} & ({
sessionId?: undefined;
scope?: undefined;
} | {
sessionId: string;
scope: string;
})): BackgroundShellHardKillReceipt[];
/** subscribe to `reapSessionBackground` (the design/129 session terminal anchor).
* Returns an unsubscribe. Hooks are best-effort observers; a throwing hook is swallowed. */
onSessionReap(hook: (sessionId: string, scope?: string) => void): () => void;
/** design/144 §1.2 — the child gate's liveness read: how many BACKGROUND tasks (bg bash / monitor /
* bg agent) registered under EXACTLY this owner key are still running. Deliberately an owner-equality
* walk, NOT {@link canAccess}: an owner-less handle is world-*readable* but attributable to nobody —
* counting it against every owner would park every parent's completion frame behind a stranger's task.
* Workflow rows are out of the gate's task class by design (§1.2 names bg bash/monitor/grandchild
* agents; a workflow's terminal already notifies on its own lane). */
countRunningBackgroundForOwner(owner: string): number;
/** design/144 §1.2 — hard cap on concurrently watched owners (each parked parent notification holds
* exactly one watcher; the retain-ledger caps bound this in practice — the cap is a defensive floor).
* 1.284 C3: overflow notifies the OLDEST owner's watchers with reason "evicted" (CANCELLATION
* semantics, not a fake quiescence — the pre-fix synchronous no-arg fire read as "count reached
* zero" while the owner's tasks were still running, an early FALSE completion). The subscriber
* decides its own degradation (the child gate delivers its parked frame immediately, undecorated —
* 宁发勿丢); a silently leaked/never-fired watcher would lose the frame forever. */
private static readonly BG_QUIESCENCE_WATCHERS_MAX;
/** design/144 §1.2 (child gate, 补发钩子) — subscribe a ONE-SHOT callback fired when `owner`'s running
* background-task count reaches zero (reason "quiescent"). Exactly-once discipline for the N-children
* race: every terminal transition pokes, only the poke that OBSERVES count==0 fires, and firing removes
* the whole owner set first (count-zero judgement + once latch). NEVER fires synchronously from this
* call — the caller checks the count itself in the same synchronous block (count > 0 → subscribe; no
* await between, so no missed-settle window). 1.284 C3: a cap-overflow eviction fires the victims with
* reason "evicted" instead, and ASYNCHRONOUSLY (queueMicrotask) — the new watcher is installed before
* any victim runs, closing the re-entrant window where a victim's callback observed a half-updated map.
* Returns an unsubscribe. Callbacks are swallow-guarded observers. */
onBackgroundQuiescence(owner: string, cb: (reason: "quiescent" | "evicted") => void): () => void;
/** design/144 §1.2 — poke the quiescence watchers of `owner` after a terminal transition (or a
* running-row eviction). Cheap no-op without watchers; fires + removes the owner's whole set only
* when the running count is zero (the LAST settling task's poke wins — count-zero judgement). */
private pokeBgQuiescence;
/** fidelity R1-3: the terminal-notification once-latch. Checks-and-sets BEFORE sending (single-threaded,
* no await between check and set — atomic), so a racing producer pair (a teardown settle vs an in-flight
* watcher tick that passed its status gate before the settle landed) emits exactly ONE terminal notice.
* The latch stays set even if the sink throws — a faulty sink must not re-arm it. */
private notifyTerminalOnce;
/** G2b watcher (design/116 §7): 1s poll loop that spools increments (rolling bound) and fires ONE
* completion notification at the terminal state. `unref()`d — a watcher never keeps the process alive.
* Stops itself on terminal, on a vanished shell (env disposed), or when the handle is evicted. */
private startBashWatcher;
/** Every shell-lane row still RUNNING on this env, any owner — the run-boundary dispose's except-list (the
* run teardown and the park-only parks: `sweepRunBoundaryBackgroundShells`).
* A run's teardown disposes only shells it can attribute to itself: its settle sweep has already killed
* and settled the rows of this run that end with it, so whatever registered row is still running here is
* either something that settle kept (session-resident, an in-window Monitor, retained) or belongs to
* another run or session sharing the env. Unregistered env shells are still disposed. */
runningShellIds(env: unknown): BackgroundShellId[];
/** design/135 G2: register a spawned background process as a MONITOR — the watcher polls at the batch
* window (200ms), turns each completed stdout line into an event, batches lines within one tick into
* ONE notification, enforces the non-persistent timeout, auto-stops an event storm, and fires exactly
* one terminal notification (exit code / timeout / auto-stop / env-side kill). */
registerMonitor(input: RegisterMonitorInput): string;
registerWorkflow(input: RegisterWorkflowInput): string;
pollTask(id: string, access: TaskAccess, opts?: TaskPollOptions): Promise<UnifiedTaskResult>;
stopTask(id: string, access: TaskAccess, opts?: TaskStopOptions): Promise<UnifiedTaskResult>;
/** S2b — bounded delivery-confirmation wait (see deliverToRunningAgent). Test-tunable. */
deliverConfirmTimeoutMs: number;
private lastGcAt;
/**
* The data source behind a shell's task list and footer pill: list the caller's OWN tasks as a bounded DISPLAY
* projection — never the internal handles (no shellId/env/abort). Same owner/scope guard as poll/stop;
* a caller only ever sees its own tasks. Sorted newest-first, capped at `limit` (default 50).
* CC parity note: CC's /tasks is a UI/SDK surface, NOT an LLM tool — expose this via SDK/service,
* do not mount it as a model tool by default.
*/
list(access: TaskAccess, opts?: {
limit?: number;
}): Array<{
task_id: string;
type: SemaTaskType;
status: SemaTaskStatus;
/** design/129 lifetime flag, surfaced for observability — on resume-leg deployments the
* canonical taskId==sessionId, so task- and session-scoped rows share an owner KEY VALUE and only this
* explicit flag tells them apart. Bulk attribution should use {@link markStopSourceForOwner}, not a
* list()-driven loop (the 500-row cap). */
sessionScoped: boolean;
/** Present on a shell-lane row (`background_bash` / `monitor`) that THE retain predicate keeps alive
* past the automatic reaps — the "keeps running" half of an exit listing: the controlled-exit reap and
* the hard face under `includeRetained: false` leave it running; every other running shell row stops. */
retained?: true;
description?: string;
toolUseId?: string;
createdAt: number;
updatedAt: number;
}>;
/** Throttled {@link gc} for hot-path callers (design/115 review B5): prepare-task runs once per task,
* and a full-map sweep per prepare is pure overhead — sweep at most once per minute. */
maybeGc(now?: number): number;
gc(now?: number, terminalTtlMs?: number): number;
clearBackgroundForOwner(access: TaskAccess): number;
private resolveInternal;
private pollBackgroundBash;
private pollWorkflow;
private stopBackgroundBash;
private pollMonitor;
private stopMonitor;
private stopWorkflow;
}
export declare const defaultTaskRegistry: TaskRegistry;
export declare function createTaskOutputTool(opts: TaskToolOptions): AgentTool;
export declare function createTaskStopTool(opts: TaskToolOptions): AgentTool;