@sema-agent/core
Version:
Stateless, task-oriented AI agent core
220 lines (219 loc) • 16.2 kB
TypeScript
/**
* design/103 — 后台 shell seam(detached process)。「手」腿(`ExecutionEnv`)的 long-running 进程能力:启动一个不会
* 立刻退出的命令、轮询它的输出、需要时杀掉它。让引擎能跑真实开发循环(起 dev server / build watcher → 轮询日志 →
* 迭代),用于自足 make-real。
*
* 这是一个 **env 上的可选能力**(非一种独立 env 类型),所以不 `extends ExecutionEnv` —— 用交叉类型挂到具体实现上,并经
* {@link hasBackgroundShell} 运行时检测(对齐 remote-env.ts 的 `hasDestroy`/`isRemoteExecutionEnv` 模式,零 vendor 接口改动)。
*
* 设计裁定(经 + workflow 5-lens 双轨证伪式复审收敛,design/103 v2):
* - **不跨 durable suspend**:后台进程在每条退出路径被 dispose;Runner 必须在 suspendVM **之前** 调
* {@link BackgroundShellCapability.disposeBackgroundShells}(detached job 不在 suspendVM 的 in-flight 契约射程内)。
* - **越界隔离**:`pollBackground`/`killBackground` 的 shellId MUST 被校验为本 env 自己 spawn 过的;非本 env 走 `not_found`。
* - **按-id-可重读**:实现 MUST 保证按 shellId 跨多次独立调用可重复读取累积/增量输出(execStream 的 consume-once 不满足)。
*/
import type { ExecutionEnv, ExecutionEnvExecOptions, Result } from "../internal/harness-types.js";
/**
* 一个 long-running / detached 进程的句柄。**env-local、非 durable、对调用方不透明** —— adapter 内部把它映射到真实进程/
* provider job,**绝不**把可猜的 raw provider job id 暴露成 shellId(否则跨租户可枚举,design/103 §3.8 越界红线)。
*/
export type BackgroundShellId = string & {
readonly __brand: "BackgroundShellId";
};
/** 一个后台进程的生命周期状态。`exited` 携带 exitCode;`failed` = 启动后异常终止(非被 kill)。 */
export type BackgroundShellStatus = "running" | "exited" | "killed" | "failed";
/**
* 类型化错误码(对齐 `RemoteExecutionErrorCode` 风格)。`timeout`/`io` 覆盖 TOB 下的有界 liveness/传输失败 —— 接口规定
* 方法 MUST NOT throw(返回 {@link Result}),所以挂掉的 provider RPC 必须有码可表达、而非违约抛异常。
*/
export type BackgroundShellErrorCode = "unsupported" | "not_found" | "spawn_failed" | "limit_exceeded" | "timeout" | "io";
/** 后台 shell 操作的类型化错误。`code` 给程序分类,`message` 给模型自纠(design/103 错误文案纪律)。 */
export declare class BackgroundShellError extends Error {
readonly code: BackgroundShellErrorCode;
readonly cause?: unknown | undefined;
constructor(code: BackgroundShellErrorCode, message: string, cause?: unknown | undefined);
}
/**
* `spawnBackground` 的 options。后台进程只用 `cwd`/`env`/`timeout`;流式回调(`onStdout`/`onStderr`)和 `abortSignal`
* 都对「启动后轮询」语义无意义(输出进 tail buffer 等 poll;后台进程的生命周期由 kill/timeout/dispose 管,不绑启动它
* 的工具调用的 signal),故显式 `Omit` 以免实现者误以为传 abortSignal 能约束进程。`timeout` 在后台是**硬墙**
* (到点自杀 → status `killed`,design/103 §3.6)。
*/
export type BackgroundSpawnOptions = Omit<ExecutionEnvExecOptions, "onStdout" | "onStderr" | "abortSignal">;
/** 一次 {@link BackgroundShellCapability.pollBackground} 的返回:自上次 poll 以来的增量 + 当前状态 + cursor 元数据。 */
export interface BackgroundPoll {
/** 自上次 poll 后的新增 stdout(已应用 `filter`,且 filter 先于截断 —— design/103 §3.4)。 */
stdout: string;
/** 自上次 poll 后的新增 stderr。 */
stderr: string;
/** 当前生命周期状态。 */
status: BackgroundShellStatus;
/** `status==="exited"` 时的退出码。 */
exitCode?: number;
/**
* 本 cursor 之前有字节因 **8MB tail buffer 的 head-evict 永久丢失**(配合 {@link bytesDroppedBeforeCursor})。
* 🔴 注意:这是**不可恢复**的丢失,不是「可再 poll 拉取」——进程产出超过 8MB tail 时最早段被挤掉。(单次 poll 增量
* 超 per-poll 显示上限的 head+tail 截断发生在**工具层**,那是另一回事,由工具的截断 marker 诚实标注。)
*/
truncated?: boolean;
/** 从进程启动至今的总输出字节(cursor 元数据,供模型判断是否错过早期日志)。 */
bytesFromStart?: number;
/** 因 8MB tail buffer head-evict 在本 cursor 之前**永久丢失**的字节数(诚实标注,不谎称「全部」)。 */
bytesDroppedBeforeCursor?: number;
/** Present (`true`) only when the shell reached its terminal state via its OWN background timeout
* (the env's per-shell hard wall killed it) — distinguishes a self-inflicted timeout from an
* external kill, which otherwise produce identical `status:"killed"` frames. */
timedOut?: boolean;
/** The timeout budget (seconds) that was actually applied to this shell (requested value clamped
* to the env's hard cap). Reported alongside {@link timedOut}. */
timeoutSec?: number;
}
/**
* 「手」腿的后台进程能力。一个具体 `ExecutionEnv` 实现可选地附加它(交叉类型),经 {@link hasBackgroundShell} 检测。
* 不支持的 env(StubExecutionEnv / SSH / ADB)= 不实现(或 `supported:false`),三个后台工具自动不挂(INERT)。
*/
export interface BackgroundShellCapability {
/** 显式声明能力(像 `RemoteExecutionEnv.capabilities` 一样不猜)。per-env / per-adapter。 */
readonly backgroundCapabilities: {
/** 是否真支持后台 spawn;`false` → 三工具不挂(design/103 §3.8)。 */
readonly supported: boolean;
/** 单 env 同时存活的后台进程上限(防 fork bomb;超限 spawn 返回 `limit_exceeded`)。 */
readonly maxConcurrent: number;
/** 后台默认 timeout(秒)。**per-端下沉**(E2B 沙箱寿命 ≠ TOC 宿主),非单一 core 常量。 */
readonly defaultBgTimeoutSec: number;
/** 后台 timeout **硬上限**(秒)。fail-closed 到有限值 —— **绝不允许无界**(design/103 §3.6)。 */
readonly maxBgTimeoutSec: number;
/** design/116 detach([C]):env 是否支持把前台 exec 的运行中子进程「领养」为后台(exec options 的
* `detachSignal`)。缺省/false ⇒ detach 请求被忽略(exec 继续前台跑完)。 */
readonly supportsDetach?: boolean;
/** design/128 T1-1 留驻声明(TB 2026-07-08 翻红回归修):env 声明后台进程 **outlive the run** ——
* 一切**自动**收割路径(runner 每退出路径的 `disposeBackgroundShells`、registry 的 run-teardown settle
* 清扫、session release 的 reap)MUST 跳过 kill,把进程留作孤儿(宿主退出后由部署收尸,e.g. TB 容器)。
* **显式** TaskStop/KillShell 与 per-shell timeout 硬墙不受影响(host 在世期间照常工作)。
* 实现侧 `disposeBackgroundShells` 自守 no-op 只护住了 dispose 一条路径;registry 直调 `killBackground`
* 的清扫(settleKilledForOwner)在 1.257.3 绕穿了它把 TB 留驻服务全数击杀 —— 声明上浮到能力面,让每个
* 自动收割调用方都看得见。缺省 false = 常规回收行为。 */
readonly retainBackgroundProcesses?: boolean;
};
/**
* 启动一条命令为后台进程,不等退出,返回 env-local 不透明句柄。MUST NOT block on 进程退出。`timeout` 是硬墙(到点自杀)。
* 后台子进程的 env MUST 经与前台 `exec` **完全相同**的 secret-scrub(design/103 §3.1 红线),cwd 默认 = 追踪的逻辑 cwd。
*/
spawnBackground(command: string, options?: BackgroundSpawnOptions): Promise<Result<{
shellId: BackgroundShellId;
}, BackgroundShellError>>;
/**
* 读一个后台进程**自上次 poll 以来的新增**输出 + 当前状态(cursor 语义)。退出后仍可读残余 + exitCode,直到被 dispose/reap。
*
* 🔴 越界契约:`shellId` MUST 被校验为**本 env 自己 spawnBackground 返回过**的;非本 env 一律 `not_found`,绝不解析外部 job id。
* 🔴 重读契约:实现 MUST 保证「按 shellId 跨多次独立调用可重复读取累积/增量」(remote 的 execStream consume-once 不满足 —— 见 design/103 §5.2 两条路径)。
*/
pollBackground(shellId: BackgroundShellId): Promise<Result<BackgroundPoll, BackgroundShellError>>;
/** 杀一个后台进程(幂等:杀已死的是 no-op,返回 ok)。`shellId` 同 {@link pollBackground} 的越界校验。 */
killBackground(shellId: BackgroundShellId): Promise<Result<void, BackgroundShellError>>;
/**
* OPTIONAL — the SYNCHRONOUS hard kill for an exit boundary with no later tick, where a ladder-style
* {@link killBackground} (TERM, then a timer-driven KILL) never finishes a shell that ignores SIGTERM.
* Contract (docs/background-process-lifetime.md): complete the kill before returning — POSIX one
* `kill(-pgid, SIGKILL)` over the shell's process group (an env with no synchronous hard kill, e.g. win32,
* answers `no_pgid` and the registry falls back to the ordinary kill); no TERM, no timer,
* no await; never signal a pid the env cannot prove is still its own; never throw; idempotent; the pid never
* leaves the env. Outcomes: `killed` (delivered now), `already_gone` (exited, or this seat already delivered
* its kill, or the id is not its shell any more), `no_pgid` (no synchronous kill landed — nothing host-local
* to signal, or the signal was refused; nothing claimed, retryable). Without this seat the registry's hard
* face reads `no_pgid` for every shell.
*/
killBackgroundNow?(shellId: BackgroundShellId): BackgroundHardKillOutcome;
/**
* 杀掉并清理**本 env 的所有**后台进程。Runner 在每条退出路径调:finish/abort/throw 在 run-loop tail finally,**suspend/
* review 必须在 `suspendVM` 之前**(design/103 §3.7;detached job 不在 suspendVM in-flight 契约内,不能指望 adapter 隐式处理)。
* Best-effort,MUST NOT throw(像 `cleanup`/`destroy`)。幂等。
*
* `except`(可选):这些 shellId **留活**(session 驻留 persistent Monitor 的进程 —— 它的全部意义
* 就是跨 turn 存活;run-end 全灭会留下「registry handle 活着、进程死了」的孤儿 watch)。不认识此参数的旧
* 实现照旧全灭 = 今天的行为(诚实降级,not silent corruption:watcher 会打出 env-death 终态通知)。
* suspend/review 前的 dispose **不带** except(挂起整个 VM,进程死亡是既有契约)。
*/
disposeBackgroundShells(opts?: {
except?: readonly BackgroundShellId[];
}): Promise<void>;
}
/** The closed outcome set of {@link BackgroundShellCapability.killBackgroundNow}. */
export type BackgroundHardKillOutcome = "killed" | "already_gone" | "no_pgid";
/** The retain lanes a face can see (absent = not declared). */
export interface BackgroundRetainLanes {
/** The env's own declaration: `backgroundCapabilities.retainBackgroundProcesses`. */
readonly env?: boolean;
/** The task's declaration: `TaskSpec.retainBackgroundProcesses` of the run whose face is asking. */
readonly task?: boolean;
/** The row's `retained` mark (stamped at registration; see the registry row). */
readonly marked?: boolean;
/** The owning ISOLATED env is destroyed with this run: retention there is illusory; overrides every lane. */
readonly envDying?: boolean;
}
/**
* THE retain predicate — "is this background process kept alive past the automatic reaps?" Every face that
* answers it calls this function (Bash description + receipts, Monitor receipt, the row stamp, teardown settle,
* the run-boundary dispose at teardown and park-only parks, session reap, the exit hard face, the listing).
* Any declared lane retains, unless the owning isolated env is being destroyed.
* Contract: docs/background-process-lifetime.md.
*/
export declare function retainsBackgroundProcesses(lanes: BackgroundRetainLanes): boolean;
/** The env lane of {@link BackgroundRetainLanes}, read off an env (false for an env without the capability). */
export declare function envDeclaresRetain(env: ExecutionEnv): boolean;
/**
* 结构 + 语义检测:`env` 是否暴露后台 shell 能力且声明 `supported:true`。挂载门控用此(对齐 `hasDestroy`)。
*
* 单谓词足够:remote-env 刻意拆 `isRemoteExecutionEnv`(结构)/`isSuspendable`(语义)是因为 isolation/suspendable 两轴
* 彼此独立;这里 `supported` 是唯一语义轴,合进一个谓词不会丢信息。
*/
export declare function hasBackgroundShell(env: ExecutionEnv): env is ExecutionEnv & BackgroundShellCapability;
/**
* The ONE way core runs an env-level background sweep.
*
* ## Why a single entry point
* `disposeBackgroundShells` lives in the execution-env layer and cannot reach the task registry, so the
* one class of kill a user actually notices — their `run_in_background` job disappearing — was the only
* one that left no attribution: the row still read `stopped-by:"system"`, which is not a cause but the
* default, i.e. "nothing recorded who did this". Diagnosing a real incident took a cross-repo timeline
* argument to establish only that the kill had bypassed the registry.
*
* Attribution therefore has to be applied by the CALLER, immediately before the sweep — and this repo has
* spent the week learning what happens when a rule is applied at some of its call sites. There were six.
* So there is one function, and `test/defectscan-shell-gate-surface.test.ts`… (see the guard) forbids calling
* `disposeBackgroundShells` anywhere in `src/core/` except here.
*
* `attribution` is taken structurally rather than as a `TaskRegistry` import — this module is below the
* registry in the dependency order, and inverting that to get a log line would be the wrong trade.
*/
export interface EnvSweepAttribution {
markStopSourceForEnvSweep(env: unknown, source: string, except?: readonly string[]): string[];
}
export declare function sweepBackgroundShells(env: ExecutionEnv & BackgroundShellCapability, attribution: EnvSweepAttribution | undefined, opts?: {
except?: readonly string[];
}): Promise<void>;
/**
* THE env-wide dispose at a run boundary: the run's teardown (every exit path) and the park-only park (a
* durable park that does not pause the env — its workspace, and the processes on it, persist on the target).
* One rule for "which background shells does this boundary take":
*
* - nothing, when THE retain predicate holds for the lanes the boundary sees (the env's declaration, the
* run's `TaskSpec` lane; `envDying` overrides both);
* - otherwise every registered shell row still running on the env is spared (`runningShellIds`, any owner):
* what the run's own settle kept (session-resident rows, in-window Monitors, retained rows) and every other
* run's or session's rows sharing the env. Unregistered env shells are disposed;
* - `envDying` (an isolated owned env destroyed with this run) spares nothing: its processes die with it.
*
* A park and a teardown ask the same question because a park-only park IS the run ending for now: the run's
* teardown follows every park, its settle collects the run's own rows that end with it, and its dispose
* spares the rest — a park that took more would take what the run's end keeps (a session's loop, killed
* while its approval card is on screen). The sweep before a snapshot pause is a different question (the VM
* pause must not capture detached jobs; resume would revive untracked zombies), which is why those sites
* call {@link sweepBackgroundShells} with no keep-list.
*/
export declare function sweepRunBoundaryBackgroundShells(env: ExecutionEnv & BackgroundShellCapability, registry: EnvSweepAttribution & {
runningShellIds(env: unknown): readonly string[];
}, lanes: {
task: boolean;
envDying?: boolean;
}): Promise<void>;