The task registry has run every background bash, pwsh, pty-send, and one-shot subagent since it landed, but only the model could read it: a human at the Web client could not see that a build was running, tell a finished task from a stuck one, or find its outcome anywhere but the `run_in_background` tool card that printed an id and never updated. Task state now reaches the browser as one whole-snapshot `session/tasks` mux frame per session, pushed at every registry commit that changes what that session can see. `TaskService` gains `onTasksChanged`, which is owner-granular because owner-disposal removal is a change no per-task record can express. The carrier reads the exact owner the listener hands it, so a push stays correct while that scope tears down, and reads the baseline through the non-resuming `ctx.agents.get` so listing never revives a cold session. The client keeps a last-wins mirror on `SessionListState`, and a new `dsh-client-ui-task` package renders it beside the subagent catalog — rendering nothing at all until the session has a task, so an ordinary conversation grows no new chrome. Streamed per-task output and human-initiated cancellation are separate phases; the note records why neither has to undo this channel, and why no Web path may call the consuming `ctx.tasks.read()`.
15 KiB
Agent Note: Web 后台任务展示
Status: implemented
English | 中文
问题
ctx.tasks 已经承载了 harness 在后台启动的全部长时工作——bash、pwsh、pty-send,以及一次性后台 subagent——但它唯一的读者是模型。dsh-tool-tasks 暴露了 task_list、task_output 和 task_kill,除此之外没有任何东西观察这个注册表。
于是 Web 端的人类看不到构建正在跑,分不清一个任务是已经完成还是卡死,也无法把它停掉。唯一的痕迹是 transcript 里更早某处那张打印了 task id 的 run_in_background 工具卡片,而那张卡片此后再也不会更新。
会话 header 本来就是每会话后台活动的落点:dsh-client-ui-subagent 把 subagent 目录贡献到 conversation.session.header.actions。位置没有争议。缺的是任何一条把任务状态送到浏览器的通道。
决策
任务状态以每会话一帧的整份快照到达浏览器,在注册表每一个会改变该会话可见内容的提交点推出。客户端保持一份 last-wins 镜像,由一个 header 入口渲染。没有 RPC,没有轮询,客户端不需要任何过期状态管理。
本次只交付列表。每个任务的流式输出与人类发起的中断是各自独立的阶段,而通道的形状让两者都不必推翻它。
线路形状
mux 流中的一帧:
| { type: 'session/tasks'; sessionId: SessionId; tasks: TaskView[] }
TaskView 是浏览器安全类型,由载体在 packages/host/apiproxy/src/api/tasks.ts 里拥有,与其他领域契约并列,线路 schema 就在旁边的 tasks.schema.ts:
import type { TaskId } from '@deepseek-ai/dsh-tasks/brand'
export interface TaskView {
id: TaskId
kind: string
label: string
status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
detail?: string
startedAt: number
finishedAt?: number
}
TaskId 取自不依赖 cordis 的 @deepseek-ai/dsh-tasks/brand 叶子——与 api/subagents.ts 已经在用的 @deepseek-ai/dsh-llm/brand 导入是同一种安排,因为 dsh-tasks 根出口会牵到 dsh-agent,即便只作类型也无法被客户端程序触及。和本仓库其他每一个非根子路径一样,它带有显式的 tsconfig.base.json paths 条目;没有这一条,TypeRT 分析器会把该 specifier 解析到 lib/types/ 并判定该引用未被导出。
线路上的 kind 是 string 而非 TaskKind。kind 映射由生产者插件按声明合并扩展,客户端构建无法枚举这个闭集;遇到无法识别的 kind,呈现层走一条有文档的默认分支。
TaskSnapshot 的三个字段被刻意省去:ownerSession(帧的 sessionId 已经带了)、reported(内部的通知投递位,对用户无意义),以及 outputLimitBytes(生产者拥有的模型呈现策略)。
这一帧带整份快照而非增量,理由就是 session/queue 为自己写下的那条:启动、中断、结算、重连,以及第二个浏览器标签页,全都通过同一个权威值收敛。一个会话的任务集是个位数,帧很小。
任务注册表变更订阅
TaskService 拥有一个观察方法:
abstract onTasksChanged(listener: TasksChangedListener): () => void
它在每一个会改变 list(owner) 返回内容的提交点之后触发:start() 末尾的注册、kill() 里转入 stopping、结算,以及 disposeOwner() 执行的移除。owner 为 undefined 表示一个无主任务发生了变化,因而每一个调用方的视图都变了。
监听器按 owner 而非按任务分粒度。唯一的消费方推的是整份快照,逐任务记录到手即弃——而且逐任务的订阅根本无法表达 owner 销毁时的移除,除非发明一个别处都不需要的墓碑状态。
onTaskDone 不是它的子集。后者按 first-wins 语义投递终态记录和确切的 owner Agent,dsh-tool-tasks 把这套语义与 reported 绑在一起;onTasksChanged 是纯观察,不含任何投递含义,也不把任何东西标为已上报。监听器抛错被包住且从不 await,与 onTaskDone 一致,每次注册都是调用方 fiber 上的 effect。
服务销毁刻意什么都不通告。每个 onTasksChanged 注册都是注册表自身 fiber 上的 effect,等到 teardown 清空 store 时监听器早已消失;观察者通过自己的销毁而不是一份最终空集来得知注册表离开了。
api-proxy 载体
mux() 订阅 ctx.tasks.onTasksChanged 并推送 session/tasks;订阅 baseline 紧挨着既有的 session/subscribed 控制帧发出,让重连的客户端在渲染前就是最新的。
载体守着四条规则:
- 绝不 resume。 变更推送用监听器给出的确切
Agent调tasks.list(owner),即使该 owner 的 scope 正在拆除、按 id 查找已经查不到,它依然正确。baseline 则读ctx.tasks.list(ctx.agents.get(session.id))——不触发 resume 的注册表读法,没有活体 Agent 的会话正确地只得到无主任务。两条路径都不碰api-remotes的 Agent 解析器,那个解析器会把查询变成复活冷会话的副作用;列个任务不该让用户随手划过的会话活过来。 - 无主变更要扇出。
owner为undefined时向每一个已订阅会话推一份新快照,因为无主任务对所有调用方可见。 - 保持可选。 载体读
ctx.get('tasks')。没有挂注册表的组合不发任何帧,客户端也就不渲染入口——sessionProjections在这个文件里已经是这个姿态。 - 没有就不说。 baseline 只为列表非空的会话推送,客户端上键缺失即表示空列表。把列表清空的那次变更仍然推
[],因为这一个转换是客户端唯一无法从「缺失」推断出来的东西。
客户端镜像
SessionListState 带有 tasksBySession: Readonly<Record<SessionId, readonly TaskView[]>>,由 SessionManager 拥有,按 last-wins 从帧折叠而来;被清空的集合存为缺失的键,使「缺失」与 [] 成为同一种表示。
它放在列表镜像而不是 Session 上,有三个理由:header 入口本来就通过 useSessions 读列表状态;没有任何东西需要 session/queue 那种实例化前的缓冲(没有 composer 行为依赖任务);将来侧栏加指示器时不必再开第二条通道。
两处清理让它保持诚实。重新订阅时 manager 丢弃该会话的镜像——session/queue 已经遵循的规则,因为新的 baseline 正在路上,而这一世代对空集不发 baseline,被留下的列表会变成幽灵。host/session-removed 时再丢一次:owner 销毁在注册表侧已经移除了记录,但那件事落在 mux 流上而这一帧走 host 流,两者没有相对顺序。
header 入口
@deepseek-ai/dsh-client-ui-task 在 conversation.session.header.actions 注册一个条目,排在 subagent 目录之后。呈现契约归它自己的 README;值得记在这里的决策是:会话没有任务时控件根本不渲染;活跃角标为零时省略,让只剩历史的会话保留一个安静的入口;终态行保持可见,因为失败任务的 detail 是其失败唯一可读之处。
因此一个运行中的一次性后台 subagent 会同时出现在那里和 subagent 目录里。两者回答不同的问题——目录负责进入子会话的 transcript,而这个列表是中断能力唯一可能附着的句柄——在这里屏蔽 kind: 'subagent' 会让中断那一期恰好对这批任务没有入口。
刻意不做的事
没有任何 Web 路径调用 ctx.tasks.read()。 它消费唯一的输出游标,浏览器读一次就悄悄拿走了模型 task_output 永远看不到的字节。这该是一条有测试兜底的不变量而不是一条约定,因为它的故障在调用点完全不可见。
不做中断。 那一期欠一个 seam 目前没有回答的决策:kill() 会把终态投递标为已上报,所以照今天的契约写出来的人类中断,会让模型一直以为它的任务还在跑。
帧上不带输出水位。 输出那一期的增量通道才是锚点字段该出现的地方;现在加就是一个没有读者的字段。
备选方案
信号帧加 RPC 拉取,即 subagent 目录的形状。 推一个无 payload 的 tasks-changed 信号,防抖后用一元 RPC 重读权威状态。subagent 目录就是这么做的,代价在 SessionManager 里一览无余:catalogInflight 做单飞行、catalogStale 在成员帧落于请求中途时补一次尾拉、updateCatalogActivity 既就地打补丁又往在途请求里写一份好让比帧更旧的响应被覆盖、parentAvailableOverride 重放一个过期的 false,还有重连时逐一重拉每个打开的目录。这套装置之所以存在,是因为目录的权威被劈成两半——持久血缘来自投影,活跃度是响应时刻的采样——而任务没有持久的那一半,不该继承这份复杂度。它还恰好在输出那一期最在意的时刻失效:任务结算,输出流立即关闭,状态却要等防抖加一次往返才到,那段窗口里 UI 显示一个流已死的运行中任务。
只在弹层打开时轮询,不改 seam。 最省事,也是唯一不碰 TaskService 的选项。它无法在不常驻轮询的前提下支持触发器上的常驻计数,而后面两期反正都需要一条真正的变更订阅,所以它省下一周又还回去。
基于持久任务事件的 session-projection 单元。 投影单元在已提交的会话事件上折叠,所以这条路要先让任务生命周期变持久——task/started … task/settled 作为一对独立的开合括号,由最后一个 session/end-seed 把未配对的开括号标为死历史,与 compaction 括号已有的做法完全一致。它在客户端确实更省:dsh-tool-todo 用十五行的单元展示了整套模式,而现成的 session/projection 帧、history-tail 块和持久化 checkpoint 缓存本可以承载这批数据,无需新线路面、无需载体订阅、无需 manager 状态。否决它,是因为这要拿一次持久格式变更去换一个浏览器列表,而且它并不能延伸到最需要它的那一期:spill/ 的存在正是为了让超大工具输出留在日志之外,所以流式任务输出无论如何都不能骑在持久事件上。如果持久任务历史将来凭自身价值站得住,本设计不阻挡重新考虑它。
复用 dsh-tool-tasks 的 PublicTaskSnapshot。 字段几乎就是对的,但它属于面向模型的控制面。浏览器程序从一个 tool 包导入线路类型,会把客户端呈现耦合到面向 prompt 的决策上,并把一个 host-only 包拖进客户端构建。
并进 subagent 目录做成统一的「活动」面板。 一个入口而不是两个。否决的理由是 SubagentCatalogAction 已经 605 行,其主题是含已结束子会话的持久会话血缘树;进程域的任务是第二套数据模型,身份、生命期和可用动作都不同,而目录的懒展开分支、时长与 token 契约全都要重写才能容纳它们。
跨全部会话的 host 全局任务列表。「显示所有运行中任务」的字面读法。否决是因为注册表的鉴权围栏是按 owner 会话的,全局读需要一条新的访问规则,而且全局列表不该出现在某个会话的 header 里——它需要侧栏里自己的位置。本设计没有阻挡后续再加;按会话的帧就是同一批数据。
测试
web e2e 场景是端到端的证据,且无需密钥:一次真实的 run_in_background bash 调用注册进 ctx.tasks,header 的计数与行在没有任何用户操作的情况下出现,通过注册表杀掉该任务后打开着的列表翻到生产者给出的 detail。它断言的是整条投递链路,而不是其中某一层。
在它之下,tasks-local 钉住变更订阅的全部四个提交点、对抛错观察者的包容,以及显式销毁与 fiber 拆除两条路径上的注销;api-proxy-tasks 钉住「非空才发 baseline」、三次变更推送、被丢弃的内部字段、无主扇出、不 resume 的保证,以及没有注册表的组合;客户端各套件钉住 last-wins 折叠、缺失键表示、两处清理,以及组件的排序、时长与关闭行为。
影响
漏掉一个提交点会漏行。 如果 disposeOwner() 的移除有朝一日不再触发订阅,客户端会一直留着已经不存在的任务,直到会话消失。整份快照的形状让这件事可恢复而非损坏——下一次正当变更就修好了——但销毁路径是最容易被忘掉的一条,所以它自带测试。
无主任务的扇出很容易做漏。 只推给变更 owner 所在的会话,对有主任务是对的,对处处可见的无主任务则是悄悄错的。这个 bug 只会在会创建无主任务的组合里显形,所以载体套件直接覆盖了它。
UI 的集合不等于注册表的集合。 无主任务在 header 里不可见,而 task_list 仍会把它报告给模型。实践中每次工具调用都带着 agent,所以这停留在理论层面,但两个界面并不可互换。
终态行会堆积。 注册表把已结算任务留到 owner 销毁,所以一个跑了很多后台命令的长会话会积出长列表。如果真的成为抱怨,给终态尾巴加上限是呈现层改动而非协议改动。
stopping 今天几乎不可达。 只有模型的 task_kill 会产生它,所以这个状态会被渲染但在人类中断落地之前很少见到。现在就纳入联合类型,是因为把它留在外面会让那一期变成一次线路变更。
一个运行中的 subagent 有两个入口。 这是刻意接受的,且被限制在一次性后台委派这一种情况。如果实际用起来读着像噪声,修法是呈现层的——可以让目录行引用那个任务,而不是让任务列表隐藏这个 kind。
新增非根子路径必须补 paths 条目。 @deepseek-ai/dsh-tasks/brand 得先登记进 tsconfig.base.json,TypeRT 分析器才会接受该引用。它的故障表现是一条来自远离改动处的生成器的、令人困惑的「not exported by」错误,所以这个条目是新增子路径的组成部分,而不是优化。