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

Status: implemented

[English](2026-07-26-todo-parallel-in-progress.md) | 中文

## 问题

[原始 `todo_write` 设计](2026-06-29-todo-write-tool.zh.md)在 `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 为合法枚举值。本决定取代[原始设计的校验决策](2026-06-29-todo-write-tool.zh.md)中「至多一个活跃」的条款；该 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 展示](2026-07-23-web-todo-display.zh.md)之上，而不是与之并行落地：两者都改 `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；一个预先拼好的字符串无法表达这个切分，而把计数放到任务名之前也被否决了：读者首先要找的是任务名。

`summarySuffix` 是 `ToolRow` 上的 slot，而不是 todo 工具行自有的标记：每个 toolview 都经由这个共享组件渲染，而它的 `summary` 是一个会被省略号截断的普通字符串，容不下一个必须挺过截断的片段。该后缀落在 `.summary` 规则之外，因此重复了该规则的 `font-size` 与 `line-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 上固定工具行摘要与计划横条，ACP（Agent Client Protocol）`todo-write` 场景录制的是三条目、两个活跃的计划，而 `apps/web/tests/todo-row.expected.e2e.ts` 在组装后的应用中固定这两个面——它从构建产物 `packages/client/*/lib/client.js` 启动，因此是唯一覆盖 keyed 注册与打包接线的地方。该文件把 `summary`、`suffix` 与横条表头记录为独立字段，因此即便拼接后的文本读起来一样，把 `+N` 计数折回摘要字符串也会改变预期输出。
