# Agent Note: 后台任务运行时（`ctx.jobs`）与通用任务控制工具

Status: implemented

[English](2026-06-20-generic-long-running-tool-runtime.md) | 中文

## 问题

后台 bash 原本兼有两项职责：bash 执行器既运行进程，又管理 job id、所有权、增量读取、取消、完成监听器和面向模型的控制工具。新增后台 subagent 需要相同的生命周期与交互约定。如果每种长时间运行能力都独立实现该约定，就会重复隔离、清理、通知和提示词行为，还会让模型为每种生产方学习不同的收集与停止协议。

任务注册表、控制工具与完成通知共同构成一项 harness 能力。bash 和 subagent 只提供执行专属的钩子，不拥有通用任务行为。

## 决策

`jobs/` 包组拥有后台任务语义：

- `@deepseek-ai/dsh-jobs` 将运行中的工作注册为 `ctx.jobs`，并拥有 job id、授权、快照、读取、取消、等待、完成监听器与清理。
- `@deepseek-ai/dsh-tool-jobs` 暴露 `job_output`、`job_list` 和 `job_kill`，注入完成通知，并提供后台任务的系统提示词指导。

长时间运行工具是生产方。`dsh-tool-bash` 将 `ShellProcess` 适配为增量输出与进程取消；`dsh-tool-subagent` 将子运行适配为最终输出与子运行释放。bash 与 subagent 能力 seam 保持独立，不依赖会话或任务注册表。

`JobRegistry` 是 `@deepseek-ai/dsh-jobs` 中的 Service Definition；进程内 Service Provider 是 `@deepseek-ai/dsh-jobs-local` 中的 `LocalJobRegistry`（该拆分记录在[任务注册表约定 Agent Note](2026-07-26-job-registry-seam.zh.md)中）。

## 运行时约定

字面类型见[任务子系统页面](../../../../docs/subsystems/jobs.zh.md)。生产方调用 `ctx.jobs.start()`，传入 kind、label、可选的所属 `Agent`、可选的正数 `outputLimitBytes` 与一个 `run()` 函数。运行时会在调用 `run()` 前完成所有可能失败的预检工作，并且只调用一次。`run()` 返回钩子后，注册过程不会再执行可能失败的步骤而直接提交；生产方无法启动没有可收集 job id 的工作。

进程内 Service Provider 还拥有有界准入，其理由记录在[有界后台任务准入决策](../bug-fix/2026-08-11-bounded-background-job-admission.zh.md)中。它的 `maxConcurrentJobsPerOwner` 配置必须是正的安全整数，默认值为 `10`；`start()` 从 `running` 与 `stopping` 记录派生每个确切 `Agent` 对象的活动数量，而全部无 owner 任务共享一个服务级桶。容量拒绝发生在 `run()` 与 id 分配之前，处于 stopping 的任务只有在生产方 `done` 结算时才释放名额。Service Provider 不排队或抢占任务，也不保留第二份可变计数。

`outputLimitBytes` 是生产方拥有的呈现策略，而非注册表缓冲区。注册表校验该值，并将其原样投影到 `JobSnapshot`；通用任务控制器添加自身的状态或通知元数据后，再将该上限应用于完整的面向模型输出。省略该值时保持现有控制器行为，因此运行时不会向无关的生产方类别施加隐式默认值。

面向模型的生产方会在规范成功值中暴露已提交的 id，通常为 `{ kind: 'background', jobId }`；Native 渲染仍可保留便于人类阅读的行文。预先被中止的后台调用会失败，而不是返回空操作，因为不存在可履行所承诺句柄的任务。一旦注册过程发布 id，取消就归任务自身的控制器与任务运行时所有：随后取消生产工具调用不得终止已发布的任务。`job_kill`、所有者资源释放和服务拆除会请求取消；前台执行仍与调用的 `exec.signal` 耦合。

生产方钩子定义三项职责：

- `cancel(reason?)` 同步请求终止，具备幂等性，并且必须使 `done` 完成。
- `done` 从不拒绝，并且仅在生产方释放任务资源后完成。
- 可选的 `readOutput()` 返回下一个消费式输出增量。省略该钩子即声明这是最终输出任务，其终止结果来自 `JobOutcome.output`。

状态包括 `running`、`stopping`、`completed`、`killed` 和 `failed`。退出码或停止原因等生产方专属信息放在 `detail` 中，注册表不解释这些信息。任务 kind 构成可合并扩展的字符串联合；job id 带品牌，并按 `<kind>-N` 生成，每个 kind 各有一个计数器。

运行时为 `done` 附加一个 continuation，记录第一个终止结果、使等待方完成等待，并逐个调用完成监听器，同时隔离每个监听器的错误。首次结果优先的结算在资源销毁期间至关重要：如果 `cancel` 抛出，运行时会强制将记录标为失败，并警告工作可能遗留，而不是永远等待一个可能永不完成的 promise。后续生产方结果不能覆盖该诊断，也不能重复通知。`cancel` 返回后如果最终未使 `done` 完成，仍会阻塞资源销毁，因为运行时无法区分这种情况与缓慢但有效的停止。

任务注册不是生产方工具 fiber 的 effect。因此，重新加载工具或控制器插件不会终止由 agent（智能体）和后端拥有的工作。任务服务自身释放时会取消所有尚未终止的任务，并等待遵守约定的生产方。

## 授权与所有者生命周期

job id 在运行时全局可见且可预测，因此注册表会授权每次访问。`get`、`read`、`wait` 和 `kill` 接受调用方 `Agent`；`list` 仅返回该调用方可见的任务。有所有者的任务仅允许对应的确切会话访问。无所有者任务向非 agent 调用方开放，并随任务服务一起终止。

快照存储所有者的品牌化 `SessionId` 以供授权，生命周期操作则保留确切的存活 `Agent` 实例。这两种身份用途不同：会话相等性授予访问权，精确对象身份决定清理和完成通知的接收方。复用 agent 或会话 id，不能将旧作用域的清理或通知重定向到替代实例。

某个所有者的第一个任务会向 `owner.ctx` 附加一个异步 effect。agent 作用域释放时会取消该所有者尚未终止的任务、等待其终止记录，并移除其快照。该 effect 可跨生产方重载存续，并加入 agent 现有的完全停稳边界。任务服务保留 effect disposer，使服务重载可以在全局资源销毁后，从仍然存活的 agent 作用域中分离回调。

对于遵守约定的生产方，`AgentHandle.dispose()` 只在所属后台工作停止后完成。需要比 agent 存活更久的工作必须以无所有者方式启动；要跨运行时重启存续，则需另行设计持久任务。

## 服务 API

`JobRegistry` 提供：

- `start(spec)`：经过预检与 Service Provider 准入的原子注册。
- `get(id, caller?)` 和 `list(caller?)`：非消费式快照。
- `read(id, caller?)`：消费式流增量或幂等的最终结果。
- `kill(id, caller?, reason?)`：取消。
- `wait(id, timeoutMs, caller?, signal?)`：有界的终止等待。
- `onJobDone(listener)`：effect 作用域内的观察，具有精确所有者投递和监听器隔离。
- `attachController(name)`：任务控制器可用性防线。

`wait` 在任务完成时返回终止快照，在等待超时时返回当前快照。中止一次等待只取消该次等待。如果结算已经将终止投递分配给该等待方，终止快照仍然优先。等待方在中止时同步注销，因此同一 tick 内发生结算时，不会代表一个实际未收到任何内容的读取方压制完成通知。

如果生产方加载时没有任何任务控制器，调用方就能启动无法收集或停止的工作。因此，`dsh-tool-jobs` 在其整个生命周期内调用 `attachController()`；没有附加控制器时，`start()` 会在生产方开始执行前失败。该检查发生在启动时而非插件加载时，因为兄弟插件可能并发激活。自定义的非模型控制器可以自行附加，无需让注册表了解工具名称。

## 面向模型的控制 API

`dsh-tool-jobs` 注册三个与 kind 无关的工具，并使用通用 UI 卡片：

- `job_output(job_id, wait?, timeout_ms?)` 读取输出，并始终追加 `[status: ...]`。流式任务只返回上次读取以来的输出；最终输出任务在结算后返回结果。除非指定 `wait: true`，否则读取不会阻塞；等待超时由插件配置提供默认值并限定上限。等待超时会报告仍在运行的状态，不会停止任务。
- `job_list()` 将调用方可见的任务返回为 `<id> [<kind>] <status> — <label>`，没有任务时返回 `(no background jobs)`。
- `job_kill(job_id, reason?)` 立即请求取消。可选的已记录原因会转发给生产方。终止任务报告现有状态；生产方的取消操作若抛出，调用便会失败，任务保持运行。

流式读取共享一个任务作用域内的消费游标，因为所属模型是预期读取方。UI 或多个独立读取方需要单独的非消费式观察 API；共享该游标会让读取方彼此消费对方的输出。

系统提示词要求模型保留 job id、在后台工作运行时继续处理独立工作而非忙轮询或重复启动同一任务、在给出最终答案前收集相关任务，并终止不再重要的工作。完成时，系统会向确切所有者的会话交付一条已记录的消息：繁忙的所有者走注入，空闲的所有者会被唤醒，其有界策略由[空闲所有者唤醒决策](../feature/2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md)负责。

当读取或等待交付终止任务、尚在等待的等待方在结算时认领了投递，或模型显式终止任务时，运行时将终止任务标为 `reported`。已报告的任务不会注入冗余的完成通知。监听器失败会独立记录，不会阻止后续监听器，也不会被等待方或资源销毁过程等待。当快照携带 `outputLimitBytes` 时，`dsh-tool-jobs` 会保持 UTF-8 边界，并复用生产方已有的截断标记，而不会重复添加。读取会为状态后缀预留空间并保留输出尾部；完成通知会先为稳定的 `background job <id>` 前缀与 `job_output` 指令预留空间，再截断可变的 kind、label、status、detail，乃至截断标记本身，因此 PTY 的最小上限仍能标识需要收集的任务。任务控制器在策略有机会拒绝或短路分发之前，于最先执行的 pre-execute 监听器中解析调用方可见的生产方上限；随后通过任务定义最后一道的 `finalizeContent` 回调应用该上限，使规范化的工具错误、外层流水线失败与单文本策略结果都无法绕过该边界；经特意结构化的多块策略结果仍由策略拥有其形状与大小。

## 生产方显式启用

每个生产方通过带默认值的配置，自行决定其 schema 是否暴露 `run_in_background`。`dsh-tool-bash`、`dsh-tool-terminal` 和每个 `dsh-tool-subagent` 实例都使用 `enableRunInBackground`，默认值为 true。禁用的实例会省略该参数；由于通用参数校验器允许未声明的键，它还会在执行时拒绝强制传入的后台参数。省略 schema 用于声明能力不可用；执行检查负责强制该约束。

`ctx.jobs` 不改写生产方 schema。bundle 只转发其所拥有的生产方的配置。如果后台调用在没有附加控制器的情况下到达 `start()`，运行时防线会在执行前使其失败。

## 生产方集成

bash seam 暴露 `resolve`、`run` 和 `start`。`start(spec)` 返回一个 `ShellProcess`，提供增量读取、取消、退出事实以及不拒绝的完全停稳 promise。本地执行器只为自身释放时能终止并等待进程而保留活动进程的句柄。前台调用方继续直接使用 `resolve` 和 `run`。

对于后台 bash，`dsh-tool-bash` 将调用方 agent 注册为所有者。其钩子将 `kill()` 映射为取消，将 `done` 映射为 completed 或 killed 的 `JobOutcome`，并将 `readOutput()` 映射为进程的有界增量输出，以及 spill 与沙箱通知。通用任务工具拥有 id、状态行、列表、等待和完成通知。

对于后台 subagent，`dsh-tool-subagent` 创建由任务拥有的 `AbortController`，并在任务 starter 内启动提供方。无论提供方发布前后，取消都会中止同一个 signal。`done` 同时等待子运行结果和子运行释放，将已完成输出映射为最终结果，将中止映射为 `killed`，并将其他停止原因或基础设施失败映射为 `failed`。中间子历史保留在子会话中，不通过 `readOutput()` 暴露。

## 备选方案

### 按能力划分控制工具

为 bash 与 subagent 分别提供输出/停止工具，会重复 id、隔离、清理、通知和指导，并增加模型的 schema 与协议负担。统一运行时将执行专属行为保留在生产方中，而无需复制任务生命周期。

### 立即抽象任务运行时后端

当前 `JobStart.run()` 约定传入进程内回调与确切的 `Agent` 对象。持久化后端会改变身份、重启、所有权与观察语义，因此在引入之时注册表保持为单一具体服务，而非固化错误的边界。[任务注册表约定 Agent Note](2026-07-26-job-registry-seam.zh.md)后来在不改变这些进程内语义的前提下，将约定与进程内实现分离。

### 由消费方负责授权或清理事件

由消费方负责检查，会使每个新接口的隔离实现不一致或遗漏。广播清理事件会迫使每个监听器过滤所有 agent，且不提供注册 disposer。集中授权加一个所有者作用域内的 effect，为每个消费方提供相同防线，以及可等待、可移除的生命周期钩子。

### 阻塞输出或单独的等待工具

默认阻塞会在后台工作运行时串行化父任务。只等待而不读取会增加一次不返回有用信息的模型调用和 schema。`job_output(wait: true)` 显式表达阻塞，并将其与结果交付合并。

等待使用共享的 deadline 原语，而不使用通用工具超时策略。等待超时是一次成功的观察，会返回 `[status: running]`；通用策略会将它替换为超时错误。任务返回 job id 后，没有任何工具调用超时会控制任务生命周期。

### 由运行时拥有输出接收端

推送式接收端可以集中缓冲，但 bash 已经在执行器 seam 后拥有有界缓冲、截断与 spill 文件。拉取格式化增量能够保留这一所有权。拥有存储的持久化后端可能足以支持重新审视生产方接口。

### 随机 id、提升或生命周期会话事件

授权而非不可猜测性才是访问边界，并且 id 不用于派生文件系统路径；顺序生成的品牌化 id 可保持 transcript（文本记录）易读。将前台任务提升为后台任务需要 SDK 并未规定的用户交互约定。启动、读取和通知已作为工具与上下文事件记录，因此专用任务会话事件会重复面向模型的事实。

## 测试

单元覆盖固定预检原子性、按 kind 分配的 id、按确切 owner 与无 owner 桶执行的准入、`stopping` 占位、终态释放、输出上限的校验与投影、完整结果的 UTF-8 字节上限、流式与最终读取、等待超时与中止竞态、取消、首次结果优先的结算、监听器隔离、通知压制、所有者隔离、陈旧的所有者实例、所有者清理、服务资源销毁和无控制器防线。生产方测试覆盖 bash 进程映射、subagent 启动取消、终止映射与释放。快照覆盖固定控制工具 schema、提示词指导，以及一条组合完整的 ACP 路径：配置上限会拒绝第二个真实后台 Bash 任务，并给出 `job_kill` 恢复动作。

## 后果

bash 命令与 subagent 共享一套 id 词汇、列表、通知格式、提示词习惯和控制工具。新的长时间运行生产方只需实现执行钩子，而不必再实现一套注册表与工具族。[工具实操手册](../../../../docs/cookbook/adding-a-tool.zh.md)将生产方指向本约定。

单个确切 owner 无法再无限增加进程内由 Task 承载的工作，另一个 owner 也不会消耗它的额度。取消请求会继续占用容量，直到生产方真正释放资源，因此用新工作替换缓慢停止的任务不会突破已配置的实时资源预算。

有所属后台 bash 会随其 agent 一起停止，不再比 agent 存活更久。后台进程没有执行器超时；调用方必须终止无关工作，或依赖所有者/服务释放。流式读取只支持一个消费方；生产方的 `cancel` 返回后如果未使 `done` 完成，仍可能阻塞资源销毁。持久任务、独立观察游标和前台提升仍属于单独设计。
