Skip to content

Todo plan state goes stale while work is delegated to subagents (the active-todo reminder never fires) #10953

Description

@yiliang114

What happened?

In a session that delegates most of its work to foreground subagents, the persisted Todo plan froze for 55m44s while the real work advanced through four plan nodes.

The plan file itself was never wrong — it faithfully mirrored the last todo_write call. The problem is that nothing feeds a delegated execution back into plan state, and the nudge that is supposed to compensate for that never fired once in the whole session.

Observed sequence (relative offsets; the session took one small bugfix from triage to PR):

T What actually happened Persisted plan
+0s todo_write registers 6 nodes (node 2 in_progress) and, in the same assistant turn, launches a foreground subagent with todo_id pointing at node 3 accurate
+0s → +39m The subagent implements, tests and commits the fix (commit lands at +37m42s) frozen: node 2 in_progress, nodes 3–6 pending
+39m39s The todo_write response for the call issued at +0s is only recorded now — it was queued behind its long-running sibling tool call in the same batch frozen
+39m45s The user sends a progress query → a new user turn frozen
+41m47s The parent delegates review to a second foreground subagent, todo_id for node 5 frozen
+55m44s First todo_write in 55m44s: flips nodes 2–5 to completed in one batched call, node 6 in_progress caught up
+59m03s Node 6 completed done

Per-node lag at the moment of that catch-up write: node 2 ≈ 50 min, node 3 ≈ 18 min, node 4 ≈ 16 min. Four nodes that actually finished 50 / 18 / 16 / 0 minutes apart were reported as having completed together.

Practical impact: anything that reads plan state — the Todo pill, the plan-execution view, an operator watching a long session — shows the task stuck at "create worktree" while the fix is already committed and under review. Asking the agent "how is progress going?" mid-flight makes it worse, because that very turn throws the plan context away (point 4 below).

What did you expect to happen?

Plan state should track delegated progress within a bounded lag. At minimum:

  1. When a delegated execution that carries todo_id finishes, the model should be re-prompted to update that plan node, instead of the reminder budget being consumed by parent tool turns that delegation never produces.
  2. A new user turn should not discard the context of a plan that still has unfinished items. Today the plan nudge is destroyed by exactly the message that asks about the plan.

Why it happens

Verified against main @ 661f41eef06.

  1. Only todo_write can change plan state. The sidecar's sole writer is writeTodosToFile() (packages/core/src/tools/todoWrite.ts:133, called at :491). A subagent execution has no path back into the parent's plan.
  2. todo_id is not load-bearing for status. It is declared (packages/core/src/tools/agent/agent.ts:224), documented (:861), shape-validated (:994-999) and used as an admission gate inside an approved Session Workflow (:1002-1013). Its only other consumer is display grouping in the Web Shell plan view (packages/web-shell/client/components/messages/PlanExecutionView.tsx:448). The feature's own design doc states it is observational and "does not schedule, retry, unblock, or complete work" (docs/design/ordinary-session-plan-execution.md).
  3. The compensating nudge is turn-budgeted on the parent chain. todo_write registers an "unfinished todo items" reminder for the current prompt (todoWrite.ts:277-295). It is re-injected only every ACTIVE_TODO_REMINDER_REFRESH_TURNS = 3 parent tool turns (packages/core/src/config/config.ts:292; takeActiveTodoReminder at :7557, consumed on the ToolResult path at packages/core/src/core/client.ts:3911). The forced variant is reserved for Retry / Cron / Notification / Teammate turns (client.ts:3832). A parent that delegates to a foreground subagent spends roughly one turn per tens of minutes of real work, so the budget is never reached.
  4. Any new user turn discards it. SendMessageType.UserQuery calls startActiveTodoWorkChain(prompt_id) with no continuedFrom (client.ts:3387), which clears the reminder, the chain owners and the turn budget (config.ts:7584-7599). Only Retry continues the chain (client.ts:3390). Nothing re-registers the reminder until the next todo_write.

Measurement note (corrected after filing): injection is not observable from the session transcript. The reminder is appended to the outgoing request after that turn's tool-result record has already been written, so a transcript search for the reminder string only ever matches a tool's own output echoing it. This report originally claimed the reminder was "injected 0 times" across the transcript of the session above and of a second, unrelated ~7.5 MB session; that inference was invalid and is retracted, and whether the reminder fired in those two sessions is not recoverable from their transcripts. The valid oracle is the outgoing request payload (--openai-logging), and both rules proposed below have since been measured that way against real builds — see the Before/After evidence on #10963. The staleness timeline above is unaffected: it comes from the plan sidecar's mtime and the todo_write call timestamps, not from reminder injection.

Reproduction

Needs a delegation-heavy session; the timing is the only non-deterministic part.

  1. Start a session and let the model create a multi-node plan with todo_write.
  2. In the same turn, delegate one node to a foreground subagent with todo_id set, and give that subagent enough work to run for several minutes.
  3. Send any user message while it runs.
  4. Inspect the plan sidecar (<runtime-dir>/todos/<session-id>.json) and the transcript: node statuses stay at their pre-delegation values until the model spontaneously calls todo_write again, and the string The current task still has unfinished todo items never appears in the transcript.

Possible directions

  • (a) When a top-level Agent execution carrying todo_id completes, make the reminder due immediately — takeActiveTodoReminder(promptId, true) already supports forcing, so this is one call site at the agent-result boundary. Cheapest, and it puts the nudge exactly where the information arrives.
  • (b) On a user turn, continue the previous work chain instead of clearing it when a reminder is still registered (the existing continuedFrom path already does the re-keying). A registered reminder implies unfinished items, since todo_write deletes it when the plan completes.
  • (c) Make the cadence time-aware as well as turn-aware — a turn count is meaningless when one turn is a 40-minute subagent run.
  • (d) Make todo_id load-bearing for status (auto in_progress on launch, prompt or auto-complete on finish). This contradicts the "observational" contract in the design doc, so it needs a deliberate decision rather than a drive-by change.
  • (e) Separate defect, same session: record a tool response when the tool executes, not after the batch's slowest sibling. Here a todo_write response was recorded 39m39s late, which makes transcript-based auditing of plan timing wrong by tens of minutes.

(a) + (b) look like the smallest change that removes the observed failure without touching the observational contract; (c) is a cheap follow-up; (d) and (e) deserve their own discussion.

Client information

Observed on a locally built CLI (version string 0.23.0) running from a git worktree of this repository, macOS (arm64). The four code paths above are all present in current main (661f41eef06), so this is not specific to that build. Login method is not relevant to the defect and is omitted.

Forensic identifiers (local-only, for maintainer follow-up)

These identifiers are only meaningful on the machine that produced them; no paths, hostnames, credentials or task content are included.

  • Session ID: d6d0a49e-1a8a-47b5-83cf-c7e1129b3480
  • Plan ID: 0df766f9-1573-4647-a028-be7c9cff14a4
  • todo_write call IDs, in order: call_a419080da6114d198f4971ed, call_c1c8f5d2c28147f1979539ce, call_a39fce0868d7475d972adf0d, call_49533d90cedf41979a2817f3 (the delayed one), call_04d0e4aa6adc4ca9a9e46828, call_ecda4fcdd50046cc97a7abd1
  • Delegated executions: call_91ee484db5e54e9093dd68ec (todo_id → node 3), call_b93d8dc2b48649b583efe39c (todo_id → node 5)
  • Absolute UTC timestamps for the table above: 2026-09-03T15:40:09Z (plan write + delegation), 2026-09-03T16:19:48Z (delayed response), 2026-09-03T16:21:56Z (second delegation), 2026-09-03T16:35:53Z (catch-up write), 2026-09-03T16:39:12Z (plan completed)
  • Plan sidecar mtime always equalled the last todo_write timestamp, which is what rules out a persistence defect.
中文

现象

在一个把大部分工作委派给前台子 agent 的会话里,持久化的 Todo plan 冻结了 55 分 44 秒,而实际工作已经推进了四个节点。

plan 文件本身没有写错——它忠实反映了最后一次 todo_write。问题在于:委派出去的执行没有任何回灌 plan 状态的通路,而本该起补偿作用的提醒在整场会话里 一次都没有触发

时间线(相对时间;该会话把一个小修复从 triage 走到了 PR):

T 实际发生 持久化的 plan
+0s todo_write 写入 6 个节点(节点 2 为 in_progress),并在同一个 assistant turn 里启动前台子 agent,todo_id 指向节点 3 准确
+0s → +39m 子 agent 实现、测试并提交修复(提交落在 +37m42s) 冻结:节点 2 in_progress,节点 3–6 pending
+39m39s +0s 那次 todo_write响应此刻才被记录——它被同一批次里长跑的兄弟工具调用堵住了 冻结
+39m45s 用户发了一句询问进展的消息 → 新的 user turn 冻结
+41m47s 父会话把 review 委派给第二个前台子 agent,todo_id 指向节点 5 冻结
+55m44s 55m44s 以来第一次 todo_write:一次性把节点 2–5 刷成 completed,节点 6 in_progress 追上
+59m03s 节点 6 completed 完成

补刷那一刻的各节点滞后:节点 2 约 50 分钟、节点 3 约 18 分钟、节点 4 约 16 分钟。四个实际相隔 50 / 18 / 16 / 0 分钟完成的节点,被报告成"一起完成"。

实际影响:任何读取 plan 状态的地方——Todo pill、plan-execution 视图、盯着长会话的人——都会看到任务卡在"建 worktree",而修复其实早已提交并在 review 中。中途问一句"进展如何"还会让情况更糟,因为正是那一轮把 plan 上下文丢掉了(见下面第 4 点)。

期望行为

plan 状态应当以有界的滞后跟随委派出去的进展。至少要满足:

  1. todo_id 的委派执行结束时,模型应当被重新提醒去更新对应节点,而不是让提醒预算被委派根本不会产生的父会话 tool turn 消耗掉。
  2. 新的 user turn 不应该丢弃仍有未完成项的 plan 上下文。今天恰恰是"询问 plan 的那条消息"销毁了 plan 提醒。

原因

已在 main @ 661f41eef06 上复核。

  1. 只有 todo_write 能改 plan 状态。 sidecar 的唯一写入者是 writeTodosToFile()packages/core/src/tools/todoWrite.ts:133,在 :491 被调用)。子 agent 的执行没有回到父会话 plan 的通路。
  2. todo_id 对状态不承载语义。 它被声明(packages/core/src/tools/agent/agent.ts:224)、被文档描述(:861)、做形状校验(:994-999),并在已审批的 Session Workflow 里作为准入门禁(:1002-1013)。唯一的另一个消费方是 Web Shell plan 视图里的展示分组(packages/web-shell/client/components/messages/PlanExecutionView.tsx:448)。该功能自己的设计文档写明它是观测性的,"does not schedule, retry, unblock, or complete work"(docs/design/ordinary-session-plan-execution.md)。
  3. 补偿提醒的预算按父会话链的 turn 计。 todo_write 为当前 prompt 注册"未完成 todo"提醒(todoWrite.ts:277-295)。它只在每 ACTIVE_TODO_REMINDER_REFRESH_TURNS = 3 个父会话 tool turn 才重新注入一次(packages/core/src/config/config.ts:292takeActiveTodoReminder:7557,ToolResult 路径消费点在 packages/core/src/core/client.ts:3911)。强制注入的变体只留给 Retry / Cron / Notification / Teammate 轮次(client.ts:3832)。而把活委派给前台子 agent 的父会话,几十个分钟的真实工作才烧掉大约一个 turn,预算永远凑不满。
  4. 任何新的 user turn 都会把它丢掉。 SendMessageType.UserQuery 调用 startActiveTodoWorkChain(prompt_id) 且不带 continuedFromclient.ts:3387),这会清空提醒、链归属和 turn 预算(config.ts:7584-7599)。只有 Retry 会续链(client.ts:3390)。在下一次 todo_write 之前没有任何东西会重新注册提醒。

测量口径更正(提交后修订):注入这件事无法从会话 transcript 观测。提醒是在该轮 tool-result 记录写盘之后才追加到出站请求上的,所以在 transcript 里搜这句提醒文本,只会命中某个工具自己输出里的回声。本报告最初写的「上述会话整份 transcript(约 1.6 MB)里注入 0 次、另一份无关的约 7.5 MB 会话里也是 0 次」这个推断无效,现予撤回;那两场会话里提醒究竟有没有触发,已无法从其 transcript 还原。有效口径是出站请求体(--openai-logging),下面提出的两条规则后来已按此口径在真实构建上完成测量,见 #10963 上的 Before/After 证据。上面的滞后时间线不受影响:它来自 plan sidecar 的 mtime 与 todo_write 调用时间戳,与提醒注入无关。

复现

需要一个委派密集的会话;唯一不确定的是耗时。

  1. 起一个会话,让模型用 todo_write 建一个多节点 plan。
  2. 在同一轮里,把某个节点委派给一个前台子 agent 并设置 todo_id,给足让它跑几分钟的工作量。
  3. 在它运行期间发送任意一条用户消息。
  4. 检查 plan sidecar(<runtime-dir>/todos/<session-id>.json)和 transcript:节点状态会停在委派前的值,直到模型自发地再次调用 todo_write;并且 transcript 里始终不会出现 The current task still has unfinished todo items 这个字符串。

可能的方向

  • (a)todo_id 的顶层 Agent 执行结束时,让提醒立即到期——takeActiveTodoReminder(promptId, true) 已经支持强制注入,所以这只是 agent 结果边界上的一个调用点。成本最低,而且把提醒放在了信息真正到达的位置。
  • (b) 用户轮次到来时,如果仍有已注册的提醒,就续上上一条工作链而不是清空(现有的 continuedFrom 路径已经会重新挂载归属)。提醒存在即意味着有未完成项,因为 plan 完成时 todo_write 会把它删掉。
  • (c) 让注入节奏同时感知时间,而不只感知 turn——当一个 turn 就是 40 分钟的子 agent 运行时,turn 计数没有意义。
  • (d)todo_id 真正承载状态(启动即 in_progress,结束时提示或自动完成)。这与设计文档里的"观测性"契约冲突,需要一个明确的决策,不适合顺手改。
  • (e) 同一会话里暴露的另一个独立缺陷:工具响应应在工具执行时记录,而不是等同批次里最慢的兄弟调用。这里一次 todo_write 响应晚记录了 39m39s,导致基于 transcript 的 plan 时序审计会差几十分钟。

(a) + (b) 看起来是能在不触碰观测性契约的前提下消除该故障的最小改动;(c) 是便宜的后续;(d) 和 (e) 值得单独讨论。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

category/coreCore engine and logicdogfoodingDogfooding report from internal usagepriority/P2Medium - Moderately impactful, noticeable problemroadmap/subagents-toolsRoadmap: Subagents and tool systemscope/session-managementSession state and persistencetype/bugSomething isn't working as expected

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions