Files
deepseek-harness/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md
T
Chinesezjc b462d5fd69 Merge remote-tracking branch 'origin/master' into feat/web-message-feedback-ui
Adapt to two contract changes master introduced:

- The generated Remote face now wraps every business result in
  RemoteResult, folding carrier failures into an ok:false branch instead
  of rejecting. The controller reads that envelope at its three call
  sites and maps a carrier failure onto the same settled shape the
  controls already render; three specs cover the new branch.
- Client packages split their tsconfig into host and client halves, and
  the host aggregate now compiles any test not named *.client.spec.*.
  Rename this package's specs to the client convention and drop the
  ../connection project reference, which pointed at a solution file that
  no longer carries the client sources.

Keep master's mount loop with its rollback-on-failure in api-remotes and
add messageFeedbackRemote to it.
2026-08-12 10:43:23 +08:00

9.9 KiB
Raw Blame History

Agent Note: 允许同时存在多个 in_progress todo

Status: implemented

English | 中文

问题

原始 todo_write 设计execute 和持久日志不变式中都强制每个列表至多一个 in_progress 任务。该不变式假设工作是顺序进行的,但 harness 会运行真正并行的工作(通过委派工具启动的并发 subagent、后台 bash 命令、工作流扇出),而一个只能标出单个活跃任务的列表无法表示这种情况。模型被迫要么把并行任务错误标记为 pending,要么把它们合并成一个含糊的条目,导致 UI 进度清单少报了实际正在运行的工作。

决策

把单一 in_progress 上限从固定规则改为部署策略,并要求每个组合都作出选择:

  • packages/todo/tool-todo/src/index.ts 新增必填的 Config.allowParallelInProgress 字段。为 true 时,execute 接受任意数量的活跃条目,描述指示模型把每个正在处理的任务标记为 in_progress(并行工作时可以有多个,顺序工作时只有一个),并在仍有工作未完成时至少保留一个;为 false 时,描述要求恰好一个,execute 拒绝标记多个活跃条目的调用。
  • packages/todo/tool-todo/src/invariant.ts 中的持久日志不变式不再拒绝含多个活跃条目的快照,且不跟随该配置,因此此前持久化的日志不受影响,并行快照在任一策略下都能干净回放。

其余编码的不变式保持不变:content 去除首尾空白后非空且唯一、status 为合法枚举值。本决定取代原始设计的校验决策中「至多一个活跃」的条款;该 Agent Note 的其余部分(整列表替换、日志支撑的状态、单一所有者)依然成立。

为何用指引而非感知并行的不变式

编码的不变式只能看到列表,看不到运行时:两个 in_progress 条目是否合理,取决于工作是否真的在并发运行,而这一点工具无法观测。因此,恰恰在并行让上限变得重要的场景里,强制上限反而是错的;任何替代方案(例如把活跃条目数限制为在线 subagent 的数量)都会把工具耦合到它有意一无所知的运行时上。把 in_progress 标记与真正并发的工作对应起来这一纪律,转移到工具描述中,也就是排序与列表新鲜度已经所在的地方。

该策略是部署层的选择

并发的活跃任务是否合理,取决于工具无法观测的运行时并发情况——但一个部署的 agent(智能体)是否会并发展开工作,在组装期就是可知的。因此该策略是必填的 Config 字段,而非常量或默认值:每个 cordis.yml 组合都会有意设置 allowParallelInProgress,为可能并行展开工作的 agent 选择 true,或为单活跃项纪律选择 false

该开关会同时改变面向模型的指令与接受的输入。把两者拆开才是 bug:描述要求只保留一个活跃任务、而 execute 却接受多个,等于教给模型一条工具并不遵守的规则;反过来则会拒绝描述所邀请的调用。描述中只有活跃状态那一句会变化,因为这是该策略唯一改变的指令。

持久日志不变式刻意跟随该开关。在允许并行时写下的日志,在部署收紧策略之后仍必须可回放,因此把 invariant.ts 绑定到当前配置会拒绝在写入当时合法的历史。不变式对活跃数量保持沉默;策略生效之处是工具,时机是写入的那一刻。

曾考虑的替代方案

  • 保留上限并增加一个显式的并行 opt-in 标志——为服务常见场景而给每次调用增加一个额外参数;这个标志对顺序工作而言只是噪声,而且仍然无法验证。
  • 把活跃条目限制在一个可配置的上限内——任何固定数字都是任意的。这正是该配置字段是布尔策略开关而非数量的原因:「是否允许多个任务同时活跃」是部署的属性,而「最多 N 个」凭空发明了一个无从论证的阈值。
  • 把并行策略硬编码——本变更的第一版就是如此,这也是 allowParallelInProgress 之所以必要的原因:运行严格顺序 agent 的部署没有任何办法回到它想要的纪律。

展示面是本次改动的一部分

解除上限使一种此前任何渲染器都不曾收到的列表形状变得可达,因此本变更构建在 web todo 展示之上,而不是与之并行落地:两者都改 tool-todo,而 GUI 正是并行计划变得可见的地方。web 有两处用 todos.find(t => t.status === 'in_progress') 推导单行摘要——折叠态的计划横条表头与 todo_write 工具行——在旧上限下这个 find 是完备的,因为最多只能有一个条目匹配。一旦有多个活跃项,它会静默丢掉除第一个之外的全部活跃条目:一个四条目、三个任务在跑的计划折叠后只显示其中一个的名字,工具行读作 1/4 已完成 · <一个任务>,而另外两个仍在进行。展开态的列表始终正确(它遍历每个条目),这也是两个变更的测试都没抓到它的原因——只有折叠表头与工具行丢失了信息。面板重做把折叠表头的具名提示换成以 · 连接的各状态计数(本地化后形如 1 已完成 · 2 进行中 · 1 待处理,计数为零的段落省略),它能正确报告并行工作,且不需要任何可被截断的名字;工具行才是本变更仍需修的那一处。

工具行改用 toolviews/plan-summary.ts 中的 planSummary。它给出第一个活跃条目,并计数其余活跃项,因此工具行报告的是有多少任务在跑,而不是暗示只有一个。列出全部活跃条目被否决了:工具行是单行,无上界的拼接会溢出——在列表做不到的地方,计数能够可预测地降级。该推导放在 toolviews 域内而非 contract/(域间共享面):面板自行内联计算其计数,与工具行不共享任何东西,因此放进 contract 会声明一种已不存在的共享关系。

planSummary 把任务名与计数作为两个独立字段返回,而不是一个拼好的字符串,因为工具行用 overflow: hidden / text-overflow: ellipsis 截断其摘要文本。计数接在任务名之后时位于可截断文本的末端,于是恰恰是让计数变得有意义的那些场景——窄视口、长任务名——会把它裁掉,让并行计划看起来与顺序计划无异。因此工具行把计数交给共享的 ToolRow,作为 summarySuffix——一个紧邻被省略号截断的摘要文本、且不会收缩的 slot;一个预先拼好的字符串无法表达这个切分,而把计数放到任务名之前也被否决了:读者首先要找的是任务名。

summarySuffixToolRow 上的 slot,而不是 todo 工具行自有的标记:每个 toolview 都经由这个共享组件渲染,而它的 summary 是一个会被省略号截断的普通字符串,容不下一个必须挺过截断的片段。该后缀落在 .summary 规则之外,因此重复了该规则的 font-sizeline-height——Web 外壳把正文字号留在浏览器默认值而非该行的 14px,所以未加样式的 span 会明显大于同一 24px 行内与之并列的文本。错误行会丢弃该后缀,因为它折叠态的摘要是失败行,而非任何由调用 args 推导出的内容。

暂缓项

两个已知缺口暂缓处理。summarySuffix 这个 span 没有无障碍名称,屏幕阅读器读出的数量缺少它所修饰的名词(… 实现 fixture 样本 +1);为它命名会引入带自身测试约定的本地化文案,这属于对整条 ToolRow 摘要行做的无障碍专项,而不属于某一行。以及,当第一个活跃条目的 content 不可用时——缺失、类型不对、或 trim 后为空——行会连同数量一起丢掉活跃子句,于是并行计划渲染成裸计数;向后跳到第一个可用活跃条目的方案被否决,因为调用 args 是一处明确未经校验的边界,模型给出的顺序是该行唯一能遵循的顺序,而只丢掉不可用的那个子句可以保住 done/total 计数——这两个数无论如何都是可信的。

后果

现在 todo 列表可以忠实反映并行执行,并且每个展示面都能一次渲染多个活跃标记:计划横条的表头会计数活跃条目,工具行则需要上述推导。设置 allowParallelInProgress: true 的组合不再拒绝一种此前无效的快照形状;设置为 false 的组合仍保留旧的拒绝行为,而持久日志不变式两者都接受。面向模型的描述发生了变化,这重新记录了 tool-catalog 页面以及每个带有 todo schema 的快照伴随文件。此处不记录数量:该集合会随每个新落地的 pin 场景增长。有效规则是:改动工具描述的分支必须刷新它分叉之后落地的那些伴随文件——包括固定 subagent 类工具的编号文件 tool-schemas.<n>.expected.json,其 schema 不被父场景覆盖——pnpm run test:snapshot:refresh 可以无 key 地对整个语料完成刷新。web fixture(测试前置数据)的 todo 样本现在有两个条目处于 in_progress,因此两个由 fixture 驱动的展示面渲染的都是并行计划。packages/client/ui-conversation/tests/todo-panel.client.spec.tsx 在 src 上固定工具行摘要与计划横条,ACPAgent Client Protocoltodo-write 场景录制的是三条目、两个活跃的计划,而 apps/web/tests/todo-row.snapshot.ts 在组装后的应用中固定这两个面——它从构建产物 packages/client/*/lib/client.js 启动,因此是唯一覆盖 keyed 注册与打包接线的地方。该文件把 summarysuffix 与横条表头记录为独立字段,因此即便拼接后的文本读起来一样,把 +N 计数折回摘要字符串也会改变预期输出。