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

Status: implemented

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

## 问题

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

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

## 决策

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

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

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

`TaskService` 是 `@deepseek-ai/dsh-tasks` 中的抽象 seam；进程内注册表是 `@deepseek-ai/dsh-tasks-local` 中的 `LocalTaskService`（该拆分记录在[任务注册表 seam Agent Note](2026-07-26-task-registry-seam.md)中）。

## 运行时契约

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

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

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

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

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

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

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

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

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

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

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

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

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

## 服务接口

`TaskService` 提供：

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

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

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

## 面向模型的控制接口

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

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

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

系统提示词要求模型保留 task id、在后台工作运行时继续处理独立工作而非忙轮询或重复启动同一任务、在给出最终答案前收集相关任务，并终止不再重要的工作。完成时，系统会向确切所有者的会话注入一条已记录的 `context/message`；它会成为下一个请求的持久上下文，但不会唤醒空闲的 agent。

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

## 生产方显式启用

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

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

## 生产方集成

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

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

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

## 备选方案

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

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

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

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

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

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

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

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

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

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

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

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

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

## 测试

单元覆盖固定预检原子性、按 kind 分配的 id、输出上限的校验与投影、完整结果的 UTF-8 字节上限、流式与最终读取、等待超时与中止竞态、取消、首次结果优先的结算、监听器隔离、通知压制、所有者隔离、陈旧的所有者实例、所有者清理、服务资源销毁和无接口防线。生产方测试覆盖 bash 进程映射、subagent 启动取消、终止映射与释放。快照覆盖固定控制工具 schema 与提示词指导。

## 后果

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

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