UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

220 lines (219 loc) • 16.2 kB
/** * 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>;