UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

689 lines (688 loc) • 50.1 kB
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;