DSH / Atlas
2026-07-26implementedfeature

Allow several `in_progress` todos at once

允许同时存在多个 `in_progress` todo

The [original `todo_write` design](2026-06-29-todo-write-tool.md) enforced at most one `in_progress` task per list, both in `execute` and in the durable-log invariant. That invariant assumes sequential work, but the harness runs genuinely parallel work — concurrent subagents through the delegation tool, background bash commands, workflow fan-out — and a list that can name only one active task cannot represent it. The

English

Problem

The original todo_write design enforced at most one in_progress task per list, both in execute and in the durable-log invariant. That invariant assumes sequential work, but the harness runs genuinely parallel work — concurrent subagents through the delegation tool, background bash commands, workflow fan-out — and a list that can name only one active task cannot represent it. The model was forced to either mislabel parallel tasks as pending or merge them into one vague item, and the UI progress checklist under-reported what was actually running.

Decision

Make the single-in_progress cap a deployment policy instead of a fixed rule, requiring every composition to choose:

  • packages/todo/tool-todo/src/index.ts gains the required Config.allowParallelInProgress field. At true, execute accepts any number of active items and the description instructs the model to mark every actively-worked task — several during parallel work, one for sequential work — keeping at least one while work remains. At false, the description asks for exactly one and execute rejects a call marking more.
  • The durable-log invariant in packages/todo/tool-todo/src/invariant.ts no longer rejects snapshots with several active items, and does not follow the config, so previously-persisted logs are unaffected and parallel snapshots replay cleanly under either policy.

The remaining coded invariants are unchanged: non-empty trimmed unique content, valid status enum. This supersedes the "at most one active" clause of the original design's validation decision; the rest of that Agent Note (whole-list replace, log-backed state, single owner) stands.

Why guidance, not a parallelism-aware invariant

A coded invariant can only see the list, not the runtime: whether two in_progress items are legitimate depends on whether work is actually running concurrently, which the tool cannot observe. Enforcing a cap was therefore wrong in exactly the cases parallelism made it matter, and any replacement (for example, capping active items at the live subagent count) would couple the tool to runtimes it deliberately knows nothing about. The discipline of matching in_progress marks to genuinely concurrent work moves to the tool description, the same place ordering and list freshness already live.

The policy is a deployment choice

Whether concurrent active tasks are legitimate depends on runtime concurrency the tool cannot observe — but whether a deployment's agents ever run work concurrently is knowable at composition time. That makes the policy a required Config field rather than a constant or default: every cordis.yml composition sets allowParallelInProgress deliberately, choosing true for agents that may fan out work or false for the single-active discipline.

The flag moves the model-facing instruction and the accepted input together. Splitting them would be the bug: a description asking for one active task while execute accepts several teaches the model a rule the tool does not hold, and the reverse rejects calls the description invited. Only the active-status clause of the description varies, because that is the only instruction the policy changes.

The durable-log invariant deliberately does NOT follow the flag. A log written while parallel work was allowed must still replay after a deployment tightens the policy, so tying invariant.ts to the current config would reject history that was valid when it was written. The invariant stays silent on the active count; the tool is where the policy applies, at the moment of the write.

Alternatives considered

  • Keep the cap and add an explicit parallel opt-in flag — an extra argument on every call to serve the common case; the flag would be noise for sequential work and still unverifiable.
  • Cap active items at a configured maximum — any fixed number is arbitrary. This is why the config field is a boolean policy switch and not a count: "may several tasks be active" is a property of the deployment, while "at most N" invents a threshold nothing can justify.
  • Hardcode the parallel policy — the first revision of this change did, which is what made allowParallelInProgress necessary: a deployment running strictly sequential agents had no way back to the discipline it wanted.

The display surfaces are part of the change

Lifting the cap makes a list shape reachable that no renderer had ever received, so this change builds on the web todo display rather than landing beside it: both change tool-todo, and the GUI is where a parallel plan becomes visible. Two web sites derived their one-line summary with todos.find(t => t.status === 'in_progress') — the collapsed plan-strip header and the todo_write row — and under the old cap that find was total, since at most one item could match. With several active it silently dropped every active item but the first: a four-item plan with three running tasks collapsed to the name of one, and the row read 1/4 已完成 · <one task> while two others were in flight. The expanded list was always correct (it maps every item), which is why neither change's tests caught it — only the collapsed header and the row lost information. The panel redesign replaced the collapsed header's named hint with ·-joined per-status counts (localized, 1 completed · 2 in progress · 1 pending, zero-count segments omitted), which reports parallel work correctly and needs no name to truncate; the row is the one site this change still had to fix.

The row takes planSummary in toolviews/plan-summary.ts. It names the first active item and counts the rest, so the row reports how many tasks are running instead of implying one. Naming every active item was rejected: the row is a single line, and an unbounded join would overflow it — the count degrades predictably where a list does not. The derivation sits inside the toolviews domain rather than in contract/, the inter-domain face: the panel computes its own counts inline and shares nothing with the row, so a contract module would declare a sharing relationship that no longer exists.

planSummary returns the name and the count as separate fields rather than one joined string, because the row truncates its summary with overflow: hidden / text-overflow: ellipsis. A count appended to the task name sits at the far end of the truncatable text, so exactly the narrow viewports and long task names that make the count informative are the ones that clip it away, leaving a parallel plan indistinguishable from a sequential one. The row therefore hands the count to the shared ToolRow as summarySuffix, a non-shrinking slot beside the ellipsized summary text; a pre-joined string could not express that split, and pushing the count in front of the name was rejected because the task name is what the reader is looking for first.

summarySuffix is a slot on ToolRow rather than markup owned by the todo row: every toolview renders through that shared component, whose summary is a plain ellipsized string with no place for a fragment that must survive the clip. Sitting outside the .summary rule, the suffix repeats that rule's font-size and line-height — the web shell leaves body text at the browser default rather than the row's 14px, so an unstyled span renders visibly larger than the text beside it on a 24px row. An error row drops the suffix, because its collapsed summary is the failure line rather than anything derived from the call args.

Deferred

Two known gaps are deferred. The summarySuffix span carries no accessible name, so a screen reader reads the count without its noun (… 实现 fixture 样本 +1); naming it introduces localized copy with its own test contract, which belongs to an accessibility pass over the whole ToolRow summary line rather than to one row. And when the first active item's content is unusable — missing, mistyped, or blank once trimmed — the row drops the active clause and the count with it, so a parallel plan renders as bare counts; skipping forward to the first usable active item was rejected because call args are an explicitly unvalidated boundary where model order is the only ordering the row can honour, and dropping the unusable clause alone keeps the done/total counts, which are trustworthy regardless.

Consequences

A todo list can now faithfully mirror parallel execution, and every surface renders several active markers at once: the plan strip's header counts the active items, and the row needed the derivation above. A composition that sets allowParallelInProgress: true no longer rejects a formerly-invalid snapshot shape; one that sets false keeps the old rejection, and the durable-log invariant accepts both. The model-facing description changed, which re-recorded the tool-catalog page and every snapshot sidecar carrying the todo schema. No count is recorded here: the set grows with every pinning scenario that lands. The operative rule is that a branch changing the tool description must refresh whichever sidecars landed after it branched — including the numbered tool-schemas.<n>.expected.json files pinning a subagent class, whose schemas the parent scenario does not cover — and pnpm run test:snapshot:refresh does it keylessly over the whole corpus. The web fixture's todo sample now runs two items in_progress, so both fixture-driven surfaces render a parallel plan. packages/client/ui-conversation/tests/todo-panel.client.spec.tsx pins the row summary and the plan strip over src, the ACP todo-write scenario records a three-todo plan with two active, and apps/web/tests/todo-row.snapshot.ts pins both surfaces in the assembled application — booted from the built packages/client/*/lib/client.js bundles, so it is the one place the keyed registration and the bundled wiring are under test. That last file records summary, suffix, and the strip's header as separate fields, so folding the +N count back into the summary string changes the expected output even though the concatenated text would read the same.

中文

问题

原始 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 上固定工具行摘要与计划横条,ACP(Agent Client Protocol)todo-write 场景录制的是三条目、两个活跃的计划,而 apps/web/tests/todo-row.snapshot.ts 在组装后的应用中固定这两个面——它从构建产物 packages/client/*/lib/client.js 启动,因此是唯一覆盖 keyed 注册与打包接线的地方。该文件把 summarysuffix 与横条表头记录为独立字段,因此即便拼接后的文本读起来一样,把 +N 计数折回摘要字符串也会改变预期输出。