8.3 KiB
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 的部署没有任何办法回到它想要的纪律。
展示面是本次改动的一部分
解除上限使一种此前任何渲染器都不曾收到的列表形状变得可达,因此本分支 stack(栈叠)在 web todo 展示之上,而不是与之并行落地:两者都改 tool-todo,而 GUI 正是并行计划变得可见的地方。web 有两处用 todos.find(t => t.status === 'in_progress') 推导单行摘要——折叠态的计划横条表头与 todo_write 工具行——在旧上限下这个 find 是完备的,因为最多只能有一个条目匹配。一旦有多个活跃项,它会静默丢掉除第一个之外的全部活跃条目:一个四条目、三个任务在跑的计划折叠后只显示其中一个的名字,工具行读作 0/8 已完成 · <一个任务>,而另外七个仍在进行。展开态的列表始终正确(它遍历每个条目),这也是两个 PR 的测试都没抓到它的原因——只有折叠表头与工具行丢失了信息。其后 #740 的面板重做已把折叠表头的具名提示换成 <done>/<total> tasks · <n> in progress 计数,它能正确报告并行工作,且不需要任何可被截断的名字;工具行才是本分支仍需修的那一处。
工具行改用 toolviews/plan-summary.ts 中的 planSummary。它给出第一个活跃条目,并计数其余活跃项,因此工具行报告的是有多少任务在跑,而不是暗示只有一个。列出全部活跃条目被否决了:工具行是单行,无上界的拼接会溢出——在列表做不到的地方,计数能够可预测地降级。该推导放在 toolviews 域内而非 contract/(域间共享面):面板自行内联计算其计数,与工具行不共享任何东西,因此放进 contract 会声明一种已不存在的共享关系。
planSummary 把任务名与计数作为两个独立字段返回,而不是一个拼好的字符串,因为工具行用 overflow: hidden / text-overflow: ellipsis 截断其摘要文本。计数接在任务名之后时位于可截断文本的末端,于是恰恰是让计数变得有意义的那些场景——窄视口、长任务名——会把它裁掉,让并行计划看起来与顺序计划无异。因此工具行把计数渲染在自己的 flex: none span 中,与被省略号截断的文本并列;一个预先拼好的字符串无法表达这个切分,而把计数放到任务名之前也被否决了:读者首先要找的是任务名。
把计数拆进独立 span 也意味着它落在 .summary 规则之外,因此必须重复该规则的 font-size 与 line-height。Web 外壳把正文字号留在浏览器默认值而非该行的 14px,所以未加样式的 span 会明显大于同一 24px 行内与之并列的文本。另一个方案是从共同父元素继承;重复这两条声明让被拆开的两个 span 保持互不影响,而这正是省略号边界所需要的性质。
后果
现在 todo 列表可以忠实反映并行执行,并且每个 UI 都能一次渲染多个活跃标记:TUI 按状态区分的前缀无需改动,计划横条的表头会计数活跃条目,工具行则需要上述推导。设置 allowParallelInProgress: true 的组合不再拒绝一种此前无效的快照形状;设置为 false 的组合仍保留旧的拒绝行为,而持久日志不变式两者都接受。面向模型的描述发生了变化,这重新记录了 tool-catalog 页面以及每个带有 todo schema 的 tool-schemas.expected.json sidecar(树中八个里有七个)。组合出相同 header 的场景通过 toolSchemasSource 共用同一份 sidecar,而非各自保留副本,因此这个数量对应的是不同的 header 组合,而不是场景数;改动工具描述的分支仍须刷新它分叉之后落地的那些 sidecar —— pnpm run test:snapshot:refresh 可以无 key 完成。web fixture 的 todo 样本现在有两个条目处于 in_progress,因此组装后的 web transcript 回放的是一个并行计划;若任一展示面退回单活跃项推导,它会再次失败。